Merge "Merge branch 'upstream-master' into new_robolectric"
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..cc6c99d
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,53 @@
+# Contributing to Robolectric
+
+## Getting Started
+
+Dependencies:
+
+1. Android SDK with Tools, Extras, and 'Google APIs' for APIs 22 and 23 installed
+
+Set Android enviroment variables:
+
+    export ANDROID_HOME=/path-to-sdk-root
+    export PATH=${PATH}:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools
+
+Fork and clone the repo:
+
+    git clone git@github.com:username/robolectric.git
+
+Create a feature branch to make your changes:
+
+    git checkout -b my-feature-name
+
+Copy all required Android dependencies into your local Maven repository:
+
+    ./scripts/install-dependencies.rb
+
+Perform a full build of all shadows:
+
+    ./gradlew clean assemble install compileTestJava
+
+## Building and Testing
+
+Robolectric's tests run against the jars that are installed in your local Maven repo. This means that for the tests to pick up your code changes, you must run `mvn install` before running `mvn test`. Running `mvn install` will only build and install shadows for API 21. If your tests run against older versions of Android, you will need to activate a different profile (i.e. `mvn test -P android-19`).
+
+To include the source jar in the build:
+
+    export INCLUDE_SOURCE=1
+
+Similarly with Javadocs:
+
+    export INCLUDE_JAVADOC=1
+
+## Writing Tests
+
+Robolectric is a unit testing framework and it is important that Robolectric itself be very well tested. All classes should have unit test classes. All public methods should have unit tests. Those classes and methods should have their possible states well tested. Pull requests without tests will be sent back to the submitter.
+
+## Code Style
+
+Essentially the IntelliJ default Java style, but with two-space indents.
+
+1. Spaces, not tabs.
+2. Two space indent.
+3. Curly braces for everything: if, else, etc.
+4. One line of white space between methods.
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..c8dc466
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,7 @@
+### Description
+
+### Steps to Reproduce
+
+### Robolectric & Android Version
+
+### Link to a public git repo demonstrating the problem:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..9e2ecc1
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,3 @@
+### Overview
+
+### Proposed Changes
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..0068402
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+
+  - package-ecosystem: "gradle"
+    directory: "/"
+    schedule:
+      interval: "weekly"
diff --git a/.github/workflows/build_native_runtime.yml b/.github/workflows/build_native_runtime.yml
new file mode 100644
index 0000000..ae94d7c
--- /dev/null
+++ b/.github/workflows/build_native_runtime.yml
@@ -0,0 +1,145 @@
+name: Build Native Runtime
+
+on:
+  push:
+    branches: [ master ]
+
+  pull_request:
+    branches: [ master, google ]
+
+permissions:
+  contents: read
+
+jobs:
+  build_native_runtime:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ ubuntu-20.04, macos-latest, self-hosted, windows-2019 ]
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+          submodules: recursive
+
+      # msys2 only log not fail on non-Windows platform.
+      - uses: msys2/setup-msys2@v2
+        with:
+          msystem: mingw64
+          update: true
+          platform-check-severity: warn
+          location: D:\
+          install: >-
+            make
+            mingw-w64-x86_64-gcc
+            mingw-w64-x86_64-ninja
+            mingw-w64-x86_64-cmake
+            mingw-w64-x86_64-make
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu'
+          java-version: 11
+
+      - name: Install build tools (Mac OS)
+        if: runner.os == 'macOS'
+        run: brew install ninja
+
+      - name: Install build tools (Linux)
+        if: runner.os == 'Linux'
+        run: sudo apt-get install ninja-build
+
+      - name: Cache ICU build output (Non Windows)
+        id: cache-icu
+        uses: actions/cache@v3
+        if: runner.os == 'Linux' || runner.os == 'macOS'
+        with:
+          path: ~/icu-bin
+          key: ${{ runner.os }}-${{ runner.arch }}-icu-${{ hashFiles('nativeruntime/external/icu/**') }}
+
+      - name: Cache ICU build output (Windows)
+        id: cache-icu-windows
+        uses: actions/cache@v3
+        if: runner.os == 'Windows'
+        with:
+          path: D:\msys64\home\runneradmin\icu-bin
+          key: ${{ runner.os }}-${{ runner.arch }}-icu-windows-${{ hashFiles('nativeruntime/external/icu/**') }}
+
+      - name: Build ICU for MacOS
+        if: runner.os =='macOS' && steps.cache-icu.outputs.cache-hit != 'true'
+        run: |
+          cd nativeruntime/external/icu/icu4c/source
+          ./runConfigureICU MacOSX --enable-static --prefix=$HOME/icu-bin
+          make -j4
+          make install
+
+      - name: Build ICU for Linux
+        if: runner.os == 'Linux' && steps.cache-icu.outputs.cache-hit != 'true'
+        run: |
+          export CFLAGS="$CFLAGS -fPIC"
+          export CXXFLAGS="$CXXFLAGS -fPIC"
+          cd nativeruntime/external/icu/icu4c/source
+          ./runConfigureICU Linux --enable-static --prefix=$HOME/icu-bin
+          make -j4
+          make install
+
+      - name: Build ICU for Windows
+        if: runner.os == 'Windows' && steps.cache-icu-windows.outputs.cache-hit != 'true'
+        shell: msys2 {0}
+        run: |
+          cd nativeruntime/external/icu/icu4c/source
+          ./runConfigureICU MinGW --enable-static --prefix=$HOME/icu-bin
+          make -j4
+          make install
+
+      - name: Run CMake (Non Windows)
+        if: runner.os == 'Linux' || runner.os == 'macOS'
+        run: |
+          mkdir build
+          cd build
+          ICU_ROOT_DIR=$HOME/icu-bin cmake -B . -S ../nativeruntime/cpp -G Ninja
+          ninja
+
+      - name: Run CMake (Windows)
+        if: runner.os == 'Windows'
+        shell: msys2 {0}
+        run: |
+          mkdir build
+          cd build
+          ICU_ROOT_DIR=$HOME/icu-bin cmake -B . -S ../nativeruntime/cpp -G Ninja
+          ninja
+
+      - name: Rename libnativeruntime for Linux
+        if: runner.os == 'Linux'
+        run: |
+          echo "NATIVERUNTIME_ARTIFACT_FILE=librobolectric-nativeruntime-linux-x86_64.so" >> $GITHUB_ENV
+          mv build/libnativeruntime.so build/librobolectric-nativeruntime-linux-x86_64.so
+
+      - name: Rename libnativeruntime for macOS
+        if: runner.os == 'macOS'
+        run: |
+          echo "NATIVERUNTIME_ARTIFACT_FILE=librobolectric-nativeruntime-mac-$(uname -m).dylib" >> $GITHUB_ENV
+          mv build/libnativeruntime.dylib build/librobolectric-nativeruntime-mac-$(uname -m).dylib
+
+      - name: Rename libnativeruntime for Windows
+        if: runner.os == 'Windows'
+        shell: msys2 {0}
+        run: |
+          mv build/libnativeruntime.dll build/robolectric-nativeruntime-windows-x86_64.dll
+
+      - name: Upload libnativeruntime (Non Windows)
+        if: runner.os == 'Linux' || runner.os == 'macOS'
+        uses: actions/upload-artifact@v3
+        with:
+          name: ${{env.NATIVERUNTIME_ARTIFACT_FILE}}
+          path: |
+            build/${{env.NATIVERUNTIME_ARTIFACT_FILE}}
+
+      - name: Upload libnativeruntime (Windows)
+        if: runner.os == 'Windows'
+        uses: actions/upload-artifact@v3
+        with:
+          name: robolectric-nativeruntime-windows-x86_64.dll
+          path: |
+            build/robolectric-nativeruntime-windows-x86_64.dll
diff --git a/.github/workflows/check_aggregateDocs.yml b/.github/workflows/check_aggregateDocs.yml
new file mode 100644
index 0000000..e3251c4
--- /dev/null
+++ b/.github/workflows/check_aggregateDocs.yml
@@ -0,0 +1,32 @@
+name: Check aggregateDocs
+
+on:
+  push:
+    branches: [ master ]
+
+  pull_request:
+    branches: [ master, google ]
+
+permissions:
+  contents: read
+
+jobs:
+  check_aggregateDocs:
+    runs-on: ubuntu-20.04
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+          submodules: recursive
+
+      - name: Set up JDK
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu' # zulu suports complete JDK list
+          java-version: 14
+
+      - uses: gradle/gradle-build-action@v2
+
+      - name: Run aggregateDocs
+        run: SKIP_NATIVERUNTIME_BUILD=true ./gradlew clean aggregateDocs # building the native runtime is not required for checking javadoc
diff --git a/.github/workflows/check_code_formatting.yml b/.github/workflows/check_code_formatting.yml
new file mode 100644
index 0000000..d3ad97b
--- /dev/null
+++ b/.github/workflows/check_code_formatting.yml
@@ -0,0 +1,46 @@
+name: Check Code Formatting
+
+on:
+  push:
+    branches: [ master ]
+
+  pull_request:
+    branches: [ master, google ]
+
+permissions:
+  contents: read
+
+jobs:
+  check_code_formatting:
+    runs-on: ubuntu-20.04
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu'
+          java-version: 11
+
+      - uses: gradle/gradle-build-action@v2
+
+      - name: Download google-java-format 1.9
+        run: |
+          curl -L -o $HOME/google-java-format.jar https://github.com/google/google-java-format/releases/download/v1.15.0/google-java-format-1.15.0-all-deps.jar
+          curl -L -o $HOME/google-java-format-diff.py https://raw.githubusercontent.com/google/google-java-format/v1.15.0/scripts/google-java-format-diff.py
+          chmod +x $HOME/google-java-format-diff.py
+      - name: Check Java formatting
+        run: |
+          diff=$(git diff -U0 $(git merge-base HEAD origin/master) | $HOME/google-java-format-diff.py --google-java-format-jar=$HOME/google-java-format.jar -p1)
+          if [[ $diff ]]; then
+            echo "Please run google-java-format on the changes in this pull request"
+            git diff -U0 $(git merge-base HEAD origin/master) | $HOME/google-java-format-diff.py --google-java-format-jar=$HOME/google-java-format.jar -p1
+            exit 1
+          fi
+
+      - name: Check Kotlin formatting
+        run: |
+          ./gradlew spotlessCheck
diff --git a/.github/workflows/gradle_wrapper_validation.yml b/.github/workflows/gradle_wrapper_validation.yml
new file mode 100644
index 0000000..03a37e5
--- /dev/null
+++ b/.github/workflows/gradle_wrapper_validation.yml
@@ -0,0 +1,19 @@
+name: Validate Gradle Wrapper
+
+on:
+  push:
+    branches: [ master ]
+
+  pull_request:
+    branches: [ master, google ]
+
+permissions:
+  contents: read
+
+jobs:
+  validation:
+    name: Validation
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: gradle/wrapper-validation-action@v1
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..b6f8575
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,170 @@
+name: Tests
+
+on:
+  push:
+    branches: [ master ]
+
+  pull_request:
+    branches: [ master, google ]
+
+permissions:
+  contents: read
+
+jobs:
+  build:
+    runs-on: ubuntu-20.04
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: recursive
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu'
+          java-version: 11
+
+      - uses: gradle/gradle-build-action@v2
+
+      - name: Cache ICU build output
+        id: cache-icu
+        uses: actions/cache@v3
+        with:
+          path: ~/icu-bin
+          key: ${{ runner.os }}-${{ runner.arch }}-icu-${{ hashFiles('nativeruntime/external/icu/**') }}
+
+      - name: Build ICU
+        if: steps.cache-icu.outputs.cache-hit != 'true'
+        run: |
+          cd nativeruntime/external/icu/icu4c/source
+          CFLAGS="-fPIC" CXXFLAGS="-fPIC" ./runConfigureICU Linux --enable-static --prefix=$HOME/icu-bin
+          make -j4
+          make install
+
+      - name: Install Ninja
+        run: sudo apt-get install ninja-build
+
+      - name: Build
+        run: |
+          ICU_ROOT_DIR=$HOME/icu-bin SKIP_ERRORPRONE=true SKIP_JAVADOC=true \
+          ./gradlew clean assemble testClasses --parallel --stacktrace --no-watch-fs
+
+  unit-tests:
+    runs-on: ubuntu-20.04
+    needs: build
+    strategy:
+      fail-fast: false
+      matrix:
+        api-versions: [ '16,17,18', '19,21,22', '23,24,25', '26,27,28', '29,30,31', '32,33' ]
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: recursive
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu'
+          java-version: 11
+
+      - uses: gradle/gradle-build-action@v2
+
+      - name: Cache ICU build output
+        id: cache-icu
+        uses: actions/cache@v3
+        with:
+          path: ~/icu-bin
+          key: ${{ runner.os }}-${{ runner.arch }}-icu-${{ hashFiles('nativeruntime/external/icu/**') }}
+
+      - name: Install Ninja
+        run: sudo apt-get install ninja-build
+
+      - name: Run unit tests
+        run: |
+          ICU_ROOT_DIR=$HOME/icu-bin SKIP_ERRORPRONE=true SKIP_JAVADOC=true ./gradlew test \
+          --info --stacktrace --continue \
+          --parallel \
+          --no-watch-fs \
+          -Drobolectric.enabledSdks=${{ matrix.api-versions }} \
+          -Drobolectric.alwaysIncludeVariantMarkersInTestName=true \
+          -Dorg.gradle.workers.max=2
+
+      - name: Upload Test Results
+        uses: actions/upload-artifact@v3
+        if: always()
+        with:
+          name: test_results_${{ matrix.api-versions }}
+          path: '**/build/test-results/**/TEST-*.xml'
+
+  instrumentation-tests:
+    runs-on: macos-11
+    timeout-minutes: 60
+    needs: build
+
+    strategy:
+      # Allow tests to continue on other devices if they fail on one device.
+      fail-fast: false
+      matrix:
+        api-level: [ 29, 33 ]
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: recursive
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu'
+          java-version: 11
+
+      - uses: gradle/gradle-build-action@v2
+
+      - name: Determine emulator target
+        id: determine-target
+        run: |
+          TARGET="google_apis"
+          echo "::set-output name=TARGET::$TARGET"
+          
+      - name: AVD cache
+        uses: actions/cache@v3
+        id: avd-cache
+        with:
+          path: |
+            ~/.android/avd/*
+            ~/.android/adb*
+          key: avd-${{ matrix.api-level }}
+
+      - name: Create AVD and generate snapshot for caching
+        if: steps.avd-cache.outputs.cache-hit != 'true'
+        uses: reactivecircus/android-emulator-runner@v2
+        with:
+          api-level: ${{ matrix.api-level }}
+          target: ${{ steps.determine-target.outputs.TARGET }}
+          arch: x86_64
+          force-avd-creation: false
+          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+          disable-animations: false
+          script: echo "Generated AVD snapshot for caching."
+
+      - name: Run device tests
+        # See https://github.com/orgs/community/discussions/27121
+        uses: reactivecircus/android-emulator-runner@v2
+        with:
+          api-level: ${{ matrix.api-level }}
+          target: ${{ steps.determine-target.outputs.TARGET }}
+          arch: x86_64
+          profile: Nexus One
+          script: |
+            ./gradlew cAT || ./gradlew cAT || ./gradlew cAT || exit 1
+
+      - name: Upload test results
+        if: always()
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-results-${{ matrix.api-level }}-${{ steps.determine-target.outputs.TARGET }}-${{ matrix.shard }}
+          path: |
+            **/build/reports/*
+            **/build/outputs/*/connected/*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..03ef5e0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,61 @@
+# Eclipse
+.classpath
+.project
+.settings
+eclipsebin
+
+# m2e-apt
+.factorypath
+
+# Ant
+bin/
+out/
+
+# Maven
+dist
+target
+pom.xml.*
+release.properties
+
+# Gradle
+.gradle/
+build
+
+# IntelliJ
+.idea
+*.iml
+*.iws
+*.ipr
+classes
+
+# Other editors
+*.orig
+*.swp
+*~
+\#*\#
+
+# Mac
+.DS_Store
+
+tmp
+local.properties
+
+
+# CTS stuff
+cts/
+cts-libs/
+
+# CMake
+# CMakeLists.txt.user
+CMakeCache.txt
+CMakeFiles
+CMakeScripts
+Testing
+Makefile
+cmake_install.cmake
+install_manifest.txt
+compile_commands.json
+CTestTestfile.cmake
+_deps
+*.a
+*.dylib
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..bbbdc09
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,10 @@
+[submodule "nativeruntime/external/sqlite"]
+	path = nativeruntime/external/sqlite
+	url = https://android.googlesource.com/platform/external/sqlite
+	branch = android11-release
+
+[submodule "nativeruntime/external/icu"]
+	path = nativeruntime/external/icu
+	url = https://github.com/unicode-org/icu
+	branch = release-69-1
+	shallow = false
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..2501cfb
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,74 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+  address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at robolectric-dev@googlegroups.com. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at [http://contributor-covenant.org/version/1/4][version]
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7cdaf5a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,233 @@
+The MIT License
+
+Copyright (c) 2010 Xtreme Labs, Pivotal Labs and Google Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+-------------------------------------------------------------------------------
+
+A subset of files in Robolectric are licenced under the Apache-2.0 license and
+have the appropriate Apache-2.0 license header. This primarily occurs when
+Android sources are copied into Robolectric.
+
+List of files licensed under the Apache License:
+https://github.com/robolectric/robolectric/search?q=%22Apache+License%22
+
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   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.
diff --git a/OWNERS b/OWNERS
index 26ebc9d..d8cb0bf 100644
--- a/OWNERS
+++ b/OWNERS
@@ -1,4 +1 @@
-# Default code reviewers picked from top 3 or more developers.
-# Please update this list if you find better candidates.
-jplemieux@google.com
-yukl@google.com
+rexhoffman@google.com
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..71d5d31
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+<a name="README">[<img src="https://rawgithub.com/robolectric/robolectric/master/images/robolectric-horizontal.png"/>](http://robolectric.org)</a>
+
+[![Build Status](https://github.com/robolectric/robolectric/actions/workflows/tests.yml/badge.svg)](https://github.com/robolectric/robolectric/actions?query=workflow%3Atests)
+[![GitHub release](https://img.shields.io/github/release/robolectric/robolectric.svg?maxAge=60)](https://github.com/robolectric/robolectric/releases)
+
+Robolectric is the industry-standard unit testing framework for Android. With Robolectric, your tests run in a simulated Android environment inside a JVM, without the overhead and flakiness of an emulator. Robolectric tests routinely run 10x faster than those on cold-started emulators.
+
+Robolectric supports running unit tests for *17* different versions of Android, ranging from Jelly Bean (API level 16) to TIRAMISU (API level 33).
+
+## Usage
+
+Here's an example of a simple test written using Robolectric:
+
+```java
+@RunWith(AndroidJUnit4.class)
+public class MyActivityTest {
+
+  @Test
+  public void clickingButton_shouldChangeResultsViewText() {
+    Activity activity = Robolectric.setupActivity(MyActivity.class);
+
+    Button button = (Button) activity.findViewById(R.id.press_me_button);
+    TextView results = (TextView) activity.findViewById(R.id.results_text_view);
+
+    button.performClick();
+    assertThat(results.getText().toString(), equalTo("Testing Android Rocks!"));
+  }
+}
+```
+
+For more information about how to install and use Robolectric on your project, extend its functionality, and join the community of contributors, please visit [http://robolectric.org](http://robolectric.org).
+
+## Install
+
+### Starting a New Project
+
+If you'd like to start a new project with Robolectric tests you can refer to `deckard` (for either [maven](http://github.com/robolectric/deckard-maven) or [gradle](http://github.com/robolectric/deckard-gradle)) as a guide to setting up both Android and Robolectric on your machine.
+
+#### build.gradle:
+
+```groovy
+testImplementation "junit:junit:4.13.2"
+testImplementation "org.robolectric:robolectric:4.9"
+```
+
+## Building And Contributing
+
+Robolectric is built using Gradle. Both IntelliJ and Android Studio can import the top-level `build.gradle` file and will automatically generate their project files from it.
+
+### Prerequisites
+
+Those software configurations are recommended and tested.
+
+- JDK 11. Gradle JVM should be set to Java 11.
+  - For command line, make sure the environment variable `JAVA_HOME` is correctly point to JDK11, or set the build environment by [Gradle CLI option](https://docs.gradle.org/current/userguide/command_line_interface.html#sec:environment_options) `-Dorg.gradle.java.home="YourJdkHomePath"` or by [Gradle Properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties) `org.gradle.java.home=YourJdkHomePath`.
+  - For both IntelliJ and Android Studio, see _Settings/Preferences | Build, Execution, Deployment | Build Tools | Gradle_.
+- Ninja 1.10.2+. Check it by `ninja --version`.
+- CMake 3.22.1+. Check it by `cmake --version`.
+- GCC 7.5.0+ on Linux or Apple clang 12.0.0+ on macOS. Check it by `gcc --version`.
+
+See [Building Robolectric](http://robolectric.org/building-robolectric/) for more details about setting up a build environment for Robolectric.
+
+### Building
+
+Robolectric supports running tests against multiple Android API levels. The work it must do to support each API level is slightly different, so its shadows are built separately for each. To build shadows for every API version, run:
+
+    ./gradlew clean assemble testClasses --parallel
+
+### Testing
+
+Run tests for all API levels:
+
+> The fully tests could consume more than 16G memory(total of physical and virtual memory).
+
+    ./gradlew test --parallel
+
+Run tests for part of supported API levels, e.g. run tests for API level 26, 27, 28:
+
+    ./gradlew test --parallel -Drobolectric.enabledSdks=26,27,28
+
+Run compatibility test suites on opening Emulator:
+
+    ./gradlew connectedCheck
+
+### Using Snapshots
+
+If you would like to live on the bleeding edge, you can try running against a snapshot build. Keep in mind that snapshots represent the most recent changes on master and may contain bugs.
+
+#### build.gradle:
+
+```groovy
+repositories {
+    maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
+}
+
+dependencies {
+    testImplementation "org.robolectric:robolectric:4.9-SNAPSHOT"
+}
+```
diff --git a/WORKSPACE b/WORKSPACE
new file mode 100644
index 0000000..3631c93
--- /dev/null
+++ b/WORKSPACE
@@ -0,0 +1 @@
+# This is a WORKSPACE file for Bazel
\ No newline at end of file
diff --git a/annotations/build.gradle b/annotations/build.gradle
new file mode 100644
index 0000000..65b4f06
--- /dev/null
+++ b/annotations/build.gradle
@@ -0,0 +1,10 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/Config.java b/annotations/src/main/java/org/robolectric/annotation/Config.java
new file mode 100644
index 0000000..fed0032
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/Config.java
@@ -0,0 +1,600 @@
+package org.robolectric.annotation;
+
+import android.app.Application;
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import javax.annotation.Nonnull;
+
+/** Configuration settings that can be used on a per-class or per-test basis. */
+@Documented
+@Inherited
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@SuppressWarnings(value = {"BadAnnotationImplementation", "ImmutableAnnotationChecker"})
+public @interface Config {
+  /**
+   * TODO(vnayar): Create named constants for default values instead of magic numbers. Array named
+   * constants must be avoided in order to dodge a JDK 1.7 bug. error: annotation Config is missing
+   * value for the attribute &lt;clinit&gt; See <a
+   * href="https://bugs.openjdk.java.net/browse/JDK-8013485">JDK-8013485</a>.
+   */
+  String NONE = "--none";
+
+  String DEFAULT_VALUE_STRING = "--default";
+  int DEFAULT_VALUE_INT = -1;
+
+  String DEFAULT_MANIFEST_NAME = "AndroidManifest.xml";
+  Class<? extends Application> DEFAULT_APPLICATION = DefaultApplication.class;
+  String DEFAULT_PACKAGE_NAME = "";
+  String DEFAULT_QUALIFIERS = "";
+  String DEFAULT_RES_FOLDER = "res";
+  String DEFAULT_ASSET_FOLDER = "assets";
+
+  int ALL_SDKS = -2;
+  int TARGET_SDK = -3;
+  int OLDEST_SDK = -4;
+  int NEWEST_SDK = -5;
+
+  /** The Android SDK level to emulate. This value will also be set as Build.VERSION.SDK_INT. */
+  int[] sdk() default {}; // DEFAULT_SDK
+
+  /** The minimum Android SDK level to emulate when running tests on multiple API versions. */
+  int minSdk() default -1;
+
+  /** The maximum Android SDK level to emulate when running tests on multiple API versions. */
+  int maxSdk() default -1;
+
+  /**
+   * The Android manifest file to load; Robolectric will look relative to the current directory.
+   * Resources and assets will be loaded relative to the manifest.
+   *
+   * <p>If not specified, Robolectric defaults to {@code AndroidManifest.xml}.
+   *
+   * <p>If your project has no manifest or resources, use {@link Config#NONE}.
+   *
+   * @deprecated If you are using at least Android Studio 3.0 alpha 5 or Bazel's android_local_test
+   *     please migrate to the preferred way to configure builds
+   *     http://robolectric.org/getting-started/
+   * @return The Android manifest file to load.
+   */
+  @Deprecated
+  String manifest() default DEFAULT_VALUE_STRING;
+
+  /**
+   * The {@link android.app.Application} class to use in the test, this takes precedence over any
+   * application specified in the AndroidManifest.xml.
+   *
+   * @return The {@link android.app.Application} class to use in the test.
+   */
+  Class<? extends Application> application() default
+      DefaultApplication.class; // DEFAULT_APPLICATION
+
+  /**
+   * Java package name where the "R.class" file is located. This only needs to be specified if you
+   * define an {@code applicationId} associated with {@code productFlavors} or specify {@code
+   * applicationIdSuffix} in your build.gradle.
+   *
+   * <p>If not specified, Robolectric defaults to the {@code applicationId}.
+   *
+   * @return The java package name for R.class.
+   * @deprecated To change your package name please override the applicationId in your build system.
+   *     Changing package name here is broken as the package name will no longer match the package
+   *     name encoded in the arsc resources file. If you are looking to simulate another application
+   *     you can create another applications Context using {@link
+   *     android.content.Context#createPackageContext(String, int)}. Note that you must add this
+   *     package to {@link
+   *     org.robolectric.shadows.ShadowPackageManager#addPackage(android.content.pm.PackageInfo)}
+   *     first.
+   */
+  @Deprecated
+  String packageName() default DEFAULT_PACKAGE_NAME;
+
+  /**
+   * Qualifiers specifying device configuration for this test, such as "fr-normal-port-hdpi".
+   *
+   * <p>If the string is prefixed with '+', the qualifiers that follow are overlayed on any more
+   * broadly-scoped qualifiers.
+   *
+   * @see <a href="http://robolectric.org/device-configuration">Device Configuration</a> for
+   *     details.
+   * @return Qualifiers used for device configuration and resource resolution.
+   */
+  String qualifiers() default DEFAULT_QUALIFIERS;
+
+  /**
+   * The directory from which to load resources. This should be relative to the directory containing
+   * AndroidManifest.xml.
+   *
+   * <p>If not specified, Robolectric defaults to {@code res}.
+   *
+   * @deprecated If you are using at least Android Studio 3.0 alpha 5 or Bazel's android_local_test
+   *     please migrate to the preferred way to configure
+   * @return Android resource directory.
+   */
+  @Deprecated
+  String resourceDir() default DEFAULT_RES_FOLDER;
+
+  /**
+   * The directory from which to load assets. This should be relative to the directory containing
+   * AndroidManifest.xml.
+   *
+   * <p>If not specified, Robolectric defaults to {@code assets}.
+   *
+   * @deprecated If you are using at least Android Studio 3.0 alpha 5 or Bazel's android_local_test
+   *     please migrate to the preferred way to configure
+   * @return Android asset directory.
+   */
+  @Deprecated
+  String assetDir() default DEFAULT_ASSET_FOLDER;
+
+  /**
+   * A list of shadow classes to enable, in addition to those that are already present.
+   *
+   * @return A list of additional shadow classes to enable.
+   */
+  Class<?>[] shadows() default {}; // DEFAULT_SHADOWS
+
+  /**
+   * A list of instrumented packages, in addition to those that are already instrumented.
+   *
+   * @return A list of additional instrumented packages.
+   */
+  String[] instrumentedPackages() default {}; // DEFAULT_INSTRUMENTED_PACKAGES
+
+  /**
+   * A list of folders containing Android Libraries on which this project depends.
+   *
+   * @deprecated If you are using at least Android Studio 3.0 alpha 5 or Bazel's android_local_test
+   *     please migrate to the preferred way to configure
+   * @return A list of Android Libraries.
+   */
+  @Deprecated
+  String[] libraries() default {}; // DEFAULT_LIBRARIES;
+
+  class Implementation implements Config {
+    private final int[] sdk;
+    private final int minSdk;
+    private final int maxSdk;
+    private final String manifest;
+    private final String qualifiers;
+    private final String resourceDir;
+    private final String assetDir;
+    private final String packageName;
+    private final Class<?>[] shadows;
+    private final String[] instrumentedPackages;
+    private final Class<? extends Application> application;
+    private final String[] libraries;
+
+    public static Config fromProperties(Properties properties) {
+      if (properties == null || properties.size() == 0) return null;
+      return new Implementation(
+          parseSdkArrayProperty(properties.getProperty("sdk", "")),
+          parseSdkInt(properties.getProperty("minSdk", "-1")),
+          parseSdkInt(properties.getProperty("maxSdk", "-1")),
+          properties.getProperty("manifest", DEFAULT_VALUE_STRING),
+          properties.getProperty("qualifiers", DEFAULT_QUALIFIERS),
+          properties.getProperty("packageName", DEFAULT_PACKAGE_NAME),
+          properties.getProperty("resourceDir", DEFAULT_RES_FOLDER),
+          properties.getProperty("assetDir", DEFAULT_ASSET_FOLDER),
+          parseClasses(properties.getProperty("shadows", "")),
+          parseStringArrayProperty(properties.getProperty("instrumentedPackages", "")),
+          parseApplication(
+              properties.getProperty("application", DEFAULT_APPLICATION.getCanonicalName())),
+          parseStringArrayProperty(properties.getProperty("libraries", "")));
+    }
+
+    private static Class<?> parseClass(String className) {
+      if (className.isEmpty()) return null;
+      try {
+        return Implementation.class.getClassLoader().loadClass(className);
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException("Could not load class: " + className);
+      }
+    }
+
+    private static Class<?>[] parseClasses(String input) {
+      if (input.isEmpty()) return new Class[0];
+      final String[] classNames = input.split("[, ]+", 0);
+      final Class[] classes = new Class[classNames.length];
+      for (int i = 0; i < classNames.length; i++) {
+        classes[i] = parseClass(classNames[i]);
+      }
+      return classes;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <T extends Application> Class<T> parseApplication(String className) {
+      return (Class<T>) parseClass(className);
+    }
+
+    private static String[] parseStringArrayProperty(String property) {
+      if (property.isEmpty()) return new String[0];
+      return property.split("[, ]+");
+    }
+
+    private static int[] parseSdkArrayProperty(String property) {
+      String[] parts = parseStringArrayProperty(property);
+      int[] result = new int[parts.length];
+      for (int i = 0; i < parts.length; i++) {
+        result[i] = parseSdkInt(parts[i]);
+      }
+
+      return result;
+    }
+
+    private static int parseSdkInt(String part) {
+      String spec = part.trim();
+      switch (spec) {
+        case "ALL_SDKS":
+          return Config.ALL_SDKS;
+        case "TARGET_SDK":
+          return Config.TARGET_SDK;
+        case "OLDEST_SDK":
+          return Config.OLDEST_SDK;
+        case "NEWEST_SDK":
+          return Config.NEWEST_SDK;
+        default:
+          return Integer.parseInt(spec);
+      }
+    }
+
+    private static void validate(Config config) {
+      //noinspection ConstantConditions
+      if (config.sdk() != null
+          && config.sdk().length > 0
+          && (config.minSdk() != DEFAULT_VALUE_INT || config.maxSdk() != DEFAULT_VALUE_INT)) {
+        throw new IllegalArgumentException(
+            "sdk and minSdk/maxSdk may not be specified together"
+                + " (sdk="
+                + Arrays.toString(config.sdk())
+                + ", minSdk="
+                + config.minSdk()
+                + ", maxSdk="
+                + config.maxSdk()
+                + ")");
+      }
+
+      if (config.minSdk() > DEFAULT_VALUE_INT
+          && config.maxSdk() > DEFAULT_VALUE_INT
+          && config.minSdk() > config.maxSdk()) {
+        throw new IllegalArgumentException(
+            "minSdk may not be larger than maxSdk"
+                + " (minSdk="
+                + config.minSdk()
+                + ", maxSdk="
+                + config.maxSdk()
+                + ")");
+      }
+    }
+
+    public Implementation(
+        int[] sdk,
+        int minSdk,
+        int maxSdk,
+        String manifest,
+        String qualifiers,
+        String packageName,
+        String resourceDir,
+        String assetDir,
+        Class<?>[] shadows,
+        String[] instrumentedPackages,
+        Class<? extends Application> application,
+        String[] libraries) {
+      this.sdk = sdk;
+      this.minSdk = minSdk;
+      this.maxSdk = maxSdk;
+      this.manifest = manifest;
+      this.qualifiers = qualifiers;
+      this.packageName = packageName;
+      this.resourceDir = resourceDir;
+      this.assetDir = assetDir;
+      this.shadows = shadows;
+      this.instrumentedPackages = instrumentedPackages;
+      this.application = application;
+      this.libraries = libraries;
+
+      validate(this);
+    }
+
+    @Override
+    public int[] sdk() {
+      return sdk;
+    }
+
+    @Override
+    public int minSdk() {
+      return minSdk;
+    }
+
+    @Override
+    public int maxSdk() {
+      return maxSdk;
+    }
+
+    @Override
+    public String manifest() {
+      return manifest;
+    }
+
+    @Override
+    public Class<? extends Application> application() {
+      return application;
+    }
+
+    @Override
+    public String qualifiers() {
+      return qualifiers;
+    }
+
+    @Override
+    public String packageName() {
+      return packageName;
+    }
+
+    @Override
+    public String resourceDir() {
+      return resourceDir;
+    }
+
+    @Override
+    public String assetDir() {
+      return assetDir;
+    }
+
+    @Override
+    public Class<?>[] shadows() {
+      return shadows;
+    }
+
+    @Override
+    public String[] instrumentedPackages() {
+      return instrumentedPackages;
+    }
+
+    @Override
+    public String[] libraries() {
+      return libraries;
+    }
+
+    @Nonnull
+    @Override
+    public Class<? extends Annotation> annotationType() {
+      return Config.class;
+    }
+
+    @Override
+    public String toString() {
+      return "Implementation{"
+          + "sdk="
+          + Arrays.toString(sdk)
+          + ", minSdk="
+          + minSdk
+          + ", maxSdk="
+          + maxSdk
+          + ", manifest='"
+          + manifest
+          + '\''
+          + ", qualifiers='"
+          + qualifiers
+          + '\''
+          + ", resourceDir='"
+          + resourceDir
+          + '\''
+          + ", assetDir='"
+          + assetDir
+          + '\''
+          + ", packageName='"
+          + packageName
+          + '\''
+          + ", shadows="
+          + Arrays.toString(shadows)
+          + ", instrumentedPackages="
+          + Arrays.toString(instrumentedPackages)
+          + ", application="
+          + application
+          + ", libraries="
+          + Arrays.toString(libraries)
+          + '}';
+    }
+  }
+
+  class Builder {
+    protected int[] sdk = new int[0];
+    protected int minSdk = -1;
+    protected int maxSdk = -1;
+    protected String manifest = Config.DEFAULT_VALUE_STRING;
+    protected String qualifiers = Config.DEFAULT_QUALIFIERS;
+    protected String packageName = Config.DEFAULT_PACKAGE_NAME;
+    protected String resourceDir = Config.DEFAULT_RES_FOLDER;
+    protected String assetDir = Config.DEFAULT_ASSET_FOLDER;
+    protected Class<?>[] shadows = new Class[0];
+    protected String[] instrumentedPackages = new String[0];
+    protected Class<? extends Application> application = DEFAULT_APPLICATION;
+    protected String[] libraries = new String[0];
+
+    public Builder() {}
+
+    public Builder(Config config) {
+      sdk = config.sdk();
+      minSdk = config.minSdk();
+      maxSdk = config.maxSdk();
+      manifest = config.manifest();
+      qualifiers = config.qualifiers();
+      packageName = config.packageName();
+      resourceDir = config.resourceDir();
+      assetDir = config.assetDir();
+      shadows = config.shadows();
+      instrumentedPackages = config.instrumentedPackages();
+      application = config.application();
+      libraries = config.libraries();
+    }
+
+    public Builder setSdk(int... sdk) {
+      this.sdk = sdk;
+      return this;
+    }
+
+    public Builder setMinSdk(int minSdk) {
+      this.minSdk = minSdk;
+      return this;
+    }
+
+    public Builder setMaxSdk(int maxSdk) {
+      this.maxSdk = maxSdk;
+      return this;
+    }
+
+    public Builder setManifest(String manifest) {
+      this.manifest = manifest;
+      return this;
+    }
+
+    public Builder setQualifiers(String qualifiers) {
+      this.qualifiers = qualifiers;
+      return this;
+    }
+
+    public Builder setPackageName(String packageName) {
+      this.packageName = packageName;
+      return this;
+    }
+
+    public Builder setResourceDir(String resourceDir) {
+      this.resourceDir = resourceDir;
+      return this;
+    }
+
+    public Builder setAssetDir(String assetDir) {
+      this.assetDir = assetDir;
+      return this;
+    }
+
+    public Builder setShadows(Class<?>... shadows) {
+      this.shadows = shadows;
+      return this;
+    }
+
+    public Builder setInstrumentedPackages(String... instrumentedPackages) {
+      this.instrumentedPackages = instrumentedPackages;
+      return this;
+    }
+
+    public Builder setApplication(Class<? extends Application> application) {
+      this.application = application;
+      return this;
+    }
+
+    public Builder setLibraries(String... libraries) {
+      this.libraries = libraries;
+      return this;
+    }
+
+    /**
+     * This returns actual default values where they exist, in the sense that we could use the
+     * values, rather than markers like {@code -1} or {@code --default}.
+     */
+    public static Builder defaults() {
+      return new Builder()
+          .setManifest(DEFAULT_MANIFEST_NAME)
+          .setResourceDir(DEFAULT_RES_FOLDER)
+          .setAssetDir(DEFAULT_ASSET_FOLDER);
+    }
+
+    public Builder overlay(Config overlayConfig) {
+      int[] overlaySdk = overlayConfig.sdk();
+      int overlayMinSdk = overlayConfig.minSdk();
+      int overlayMaxSdk = overlayConfig.maxSdk();
+
+      //noinspection ConstantConditions
+      if (overlaySdk != null && overlaySdk.length > 0) {
+        this.sdk = overlaySdk;
+        this.minSdk = overlayMinSdk;
+        this.maxSdk = overlayMaxSdk;
+      } else {
+        if (overlayMinSdk != DEFAULT_VALUE_INT || overlayMaxSdk != DEFAULT_VALUE_INT) {
+          this.sdk = new int[0];
+        } else {
+          this.sdk = pickSdk(this.sdk, overlaySdk, new int[0]);
+        }
+        this.minSdk = pick(this.minSdk, overlayMinSdk, DEFAULT_VALUE_INT);
+        this.maxSdk = pick(this.maxSdk, overlayMaxSdk, DEFAULT_VALUE_INT);
+      }
+      this.manifest = pick(this.manifest, overlayConfig.manifest(), DEFAULT_VALUE_STRING);
+
+      String qualifiersOverlayValue = overlayConfig.qualifiers();
+      if (qualifiersOverlayValue != null && !qualifiersOverlayValue.equals("")) {
+        if (qualifiersOverlayValue.startsWith("+")) {
+          this.qualifiers = this.qualifiers + " " + qualifiersOverlayValue;
+        } else {
+          this.qualifiers = qualifiersOverlayValue;
+        }
+      }
+
+      this.packageName = pick(this.packageName, overlayConfig.packageName(), "");
+      this.resourceDir =
+          pick(this.resourceDir, overlayConfig.resourceDir(), Config.DEFAULT_RES_FOLDER);
+      this.assetDir = pick(this.assetDir, overlayConfig.assetDir(), Config.DEFAULT_ASSET_FOLDER);
+
+      List<Class<?>> shadows = new ArrayList<>(Arrays.asList(this.shadows));
+      shadows.addAll(Arrays.asList(overlayConfig.shadows()));
+      this.shadows = shadows.toArray(new Class[shadows.size()]);
+
+      Set<String> instrumentedPackages = new HashSet<>();
+      instrumentedPackages.addAll(Arrays.asList(this.instrumentedPackages));
+      instrumentedPackages.addAll(Arrays.asList(overlayConfig.instrumentedPackages()));
+      this.instrumentedPackages =
+          instrumentedPackages.toArray(new String[instrumentedPackages.size()]);
+
+      this.application = pick(this.application, overlayConfig.application(), DEFAULT_APPLICATION);
+
+      Set<String> libraries = new HashSet<>();
+      libraries.addAll(Arrays.asList(this.libraries));
+      libraries.addAll(Arrays.asList(overlayConfig.libraries()));
+      this.libraries = libraries.toArray(new String[libraries.size()]);
+
+      return this;
+    }
+
+    private <T> T pick(T baseValue, T overlayValue, T nullValue) {
+      return overlayValue != null
+          ? (overlayValue.equals(nullValue) ? baseValue : overlayValue)
+          : null;
+    }
+
+    private int[] pickSdk(int[] baseValue, int[] overlayValue, int[] nullValue) {
+      return Arrays.equals(overlayValue, nullValue) ? baseValue : overlayValue;
+    }
+
+    public Implementation build() {
+      return new Implementation(
+          sdk,
+          minSdk,
+          maxSdk,
+          manifest,
+          qualifiers,
+          packageName,
+          resourceDir,
+          assetDir,
+          shadows,
+          instrumentedPackages,
+          application,
+          libraries);
+    }
+
+    public static boolean isDefaultApplication(Class<? extends Application> clazz) {
+      return clazz == null
+          || clazz.getCanonicalName().equals(DEFAULT_APPLICATION.getCanonicalName());
+    }
+  }
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/ConscryptMode.java b/annotations/src/main/java/org/robolectric/annotation/ConscryptMode.java
new file mode 100644
index 0000000..9662861
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/ConscryptMode.java
@@ -0,0 +1,26 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Annotation for Conscrypt modes in Robolectric. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+public @interface ConscryptMode {
+
+  /**
+   * Specifies the different supported Conscrypt modes. If ConscryptMode is ON, it will install
+   * Conscrypt. If it is OFF, it won't do that but either way BouncyCastle is still installed.
+   */
+  enum Mode {
+    ON,
+
+    OFF,
+  }
+
+  Mode value();
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/DefaultApplication.java b/annotations/src/main/java/org/robolectric/annotation/DefaultApplication.java
new file mode 100644
index 0000000..d2f6b97
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/DefaultApplication.java
@@ -0,0 +1,10 @@
+package org.robolectric.annotation;
+
+import android.app.Application;
+
+class DefaultApplication extends Application {
+  @SuppressWarnings("UnusedParameters")
+  private DefaultApplication(DefaultApplication defaultApplication) {
+    // don't make one of me!
+  }
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/GetInstallerPackageNameMode.java b/annotations/src/main/java/org/robolectric/annotation/GetInstallerPackageNameMode.java
new file mode 100644
index 0000000..e3fd6d3
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/GetInstallerPackageNameMode.java
@@ -0,0 +1,45 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A {@link org.robolectric.pluginapi.config.Configurer} annotation for controlling how Robolectric
+ * executes {@code PackageManager#getInstallerPackageName} method.
+ *
+ * <p>'getInstallerPackageName' method in PackageManager must throw IllegalArgumentException if the
+ * installer package is not present. The legacy robolectric behavior returns a null value for these
+ * cases.
+ *
+ * <p>This annotation can be applied to tests to have Robolectric perform the legacy mechanism of
+ * not throwing IllegalArgumentException and instead return 'null', when installer package name is
+ * not found.
+ *
+ * <p>This annotation will be deleted in a forthcoming Robolectric release.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+public @interface GetInstallerPackageNameMode {
+
+  /**
+   * Specifies the different {@code ShadowApplicationPackageManager#getInstallerPackageName} modes.
+   */
+  enum Mode {
+    /** Robolectric's prior behavior when calling getInstallerPackageName method. */
+    LEGACY,
+    /** The new, real behavior when calling getInstallerPackageName method. */
+    REALISTIC,
+  }
+
+  Mode value();
+
+  /**
+   * Optional string for storing the issue / bug id tracking the fixing of the affected tests and
+   * thus removal of this annotation.
+   */
+  String issueId() default "";
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/HiddenApi.java b/annotations/src/main/java/org/robolectric/annotation/HiddenApi.java
new file mode 100644
index 0000000..cd21e9e
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/HiddenApi.java
@@ -0,0 +1,13 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Indicates that the annotated method is hidden in the public Android API. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD})
+public @interface HiddenApi {}
diff --git a/annotations/src/main/java/org/robolectric/annotation/Implementation.java b/annotations/src/main/java/org/robolectric/annotation/Implementation.java
new file mode 100644
index 0000000..a041c27
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/Implementation.java
@@ -0,0 +1,24 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that a method declaration is intended to shadow a method with the same signature on the
+ * associated Android class.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD})
+public @interface Implementation {
+  int DEFAULT_SDK = -1;
+
+  /** The annotated shadow method will be invoked only for the specified SDK or greater. */
+  int minSdk() default DEFAULT_SDK;
+
+  /** The annotated shadow method will be invoked only for the specified SDK or lesser. */
+  int maxSdk() default DEFAULT_SDK;
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/Implements.java b/annotations/src/main/java/org/robolectric/annotation/Implements.java
new file mode 100644
index 0000000..d366003
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/Implements.java
@@ -0,0 +1,75 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.robolectric.shadow.api.ShadowPicker;
+
+/**
+ * Indicates that a class declaration is intended to shadow an Android class declaration. The
+ * Robolectric runtime searches classes with this annotation for methods with the {@link
+ * Implementation} annotation and calls them in place of the methods on the Android class.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE})
+public @interface Implements {
+
+  /**
+   * The Android class to be shadowed.
+   *
+   * @return Android class to shadow.
+   */
+  Class<?> value() default void.class;
+
+  /**
+   * Android class name (if the Class object is not accessible).
+   *
+   * @return Android class name.
+   */
+  String className() default "";
+
+  /**
+   * Denotes that this type exists in the public Android SDK. When this value is true, the
+   * annotation processor will generate a shadowOf method.
+   *
+   * @return True if the type is exposed in the Android SDK.
+   */
+  boolean isInAndroidSdk() default true;
+
+  /**
+   * If true, Robolectric will invoke the actual Android code for any method that isn't shadowed.
+   *
+   * @return True to invoke the underlying method.
+   */
+  boolean callThroughByDefault() default true;
+
+  /**
+   * If true, when an exact method signature match isn't found, Robolectric will look for a method
+   * with the same name but with all argument types replaced with java.lang.Object.
+   *
+   * @return True to disable strict signature matching.
+   */
+  boolean looseSignatures() default false;
+
+  /** If specified, the shadow class will be applied only for this SDK or greater. */
+  int minSdk() default -1;
+
+  /** If specified, the shadow class will be applied only for this SDK or lesser. */
+  int maxSdk() default -1;
+
+  /**
+   * If specified, the {@code picker} will be instantiated and called from within the newly-created
+   * Robolectric classloader. All shadow classes implementing the same Android class must use the
+   * same {@link ShadowPicker}.
+   */
+  Class<? extends ShadowPicker<?>> shadowPicker() default DefaultShadowPicker.class;
+
+  /**
+   * An interface used as the default for the {@code picker} param. Indicates that no custom {@link
+   * ShadowPicker} is being used.
+   */
+  interface DefaultShadowPicker extends ShadowPicker<Object> {}
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/LooperMode.java b/annotations/src/main/java/org/robolectric/annotation/LooperMode.java
new file mode 100644
index 0000000..264d6cc
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/LooperMode.java
@@ -0,0 +1,130 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A {@link org.robolectric.pluginapi.config.Configurer} annotation for controlling Robolectric's
+ * {@link android.os.Looper} behavior.
+ *
+ * <p>Currently Robolectric will default to {@link LooperMode.Mode#PAUSED} behavior, but this can be
+ * overridden by applying a @LooperMode(NewMode) annotation to a test package, test class, or test
+ * method, or via the 'robolectric.looperMode' system property.
+ *
+ * @see org.robolectric.plugins.LooperModeConfigurer
+ * @see org.robolectric.util.Scheduler
+ * @see org.robolectric.shadows.ShadowLooper
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+public @interface LooperMode {
+
+  /** Specifies the different supported Looper modes. */
+  enum Mode {
+    /**
+     * Robolectric's default threading model prior to 4.4.
+     *
+     * <p>Tasks posted to Loopers are managed via a {@link org.robolectric.util.Scheduler}. {@link
+     * org.robolectric.util.Scheduler} behavior can be controlled via {@link
+     * org.robolectric.util.Scheduler#setIdleState(org.robolectric.util.Scheduler.IdleState)
+     * setIdleState(IdleState)}, with a default of {@link
+     * org.robolectric.util.Scheduler.IdleState#UNPAUSED UNPAUSED}.
+     *
+     * <p>There is only a single Looper thread - with tests and all posted Looper tasks executing on
+     * that thread.
+     *
+     * <p>{@link org.robolectric.shadows.ShadowLooper} APIs can also be used to control posted
+     * tasks, but most of those APIs just serve as a facade to {@link
+     * org.robolectric.util.Scheduler} APIs.
+     *
+     * <p>There are multiple problems with this mode. Some of the major ones are:
+     *
+     * <ol>
+     *   <li>The default {@link org.robolectric.util.Scheduler.IdleState#UNPAUSED UNPAUSED} state
+     *       will execute tasks posted to a {@link android.os.Looper} inline synchronously. This
+     *       differs from real Android behaviour, and can cause issues with code that
+     *       expects/enforces that posted tasks execute in the correct order, such as RecyclerViews.
+     *   <li>The {@link org.robolectric.util.Scheduler} list of Runnables can get out of sync with
+     *       the Looper's {@link android.os.MessageQueue}, causing deadlocks or other race
+     *       conditions.
+     *   <li>Each {@link org.robolectric.util.Scheduler} keeps its own time value, which can get out
+     *       of sync.
+     *   <li>Background {@link android.os.Looper} tasks execute in the main thread, causing errors
+     *       for code that enforces that it runs on a non-main {@link android.os.Looper} thread.
+     * </ol>
+     *
+     * @deprecated use LooperMode.PAUSED
+     */
+    @Deprecated
+    LEGACY,
+
+    /**
+     * A mode that more accurately models real Android's {@link android.os.Looper} behavior.
+     *
+     * <p>Conceptually LooperMode.PAUSED is similar to the LEGACY {@link
+     * org.robolectric.util.Scheduler.IdleState#PAUSED} in the following ways:
+     *
+     * <ul>
+     *   <li>Tests run on the main looper thread
+     *   <li>Tasks posted to the main {@link android.os.Looper} are not executed automatically, and
+     *       must be explicitly executed via {@link org.robolectric.shadows.ShadowLooper} APIs like
+     *       {@link org.robolectric.shadows.ShadowLooper#idle()}. This guarantees execution order
+     *       correctness
+     *   <li>{@link android.os.SystemClock} time is frozen, and can be manually advanced via
+     *       Robolectric APIs.
+     * </ul>
+     *
+     * However, it has the following improvements:
+     *
+     * <ul>
+     *   <li>Robolectric will warn users if a test fails with unexecuted tasks in the main Looper
+     *       queue
+     *   <li>Robolectric test APIs, like {@link
+     *       org.robolectric.android.controller.ActivityController#setup()}, will automatically idle
+     *       the main {@link android.os.Looper}
+     *   <li>Each {@link android.os.Looper} has its own thread. Tasks posted to background loopers
+     *       are executed asynchronously in separate threads.
+     *   <li>{@link android.os.Looper} use the real {@link android.os.MessageQueue} to store their
+     *       queue of pending tasks
+     *   <li>There is only a single clock value, managed via {@link
+     *       org.robolectric.shadows.ShadowSystemClock}. This can be explictly incremented via
+     *       {@link android.os.SystemClock#setCurrentTimeMillis(long)}, or {@link
+     *       org.robolectric.shadows.ShadowLooper#idleFor(Duration)}.
+     * </ul>
+     *
+     * A subset of the {@link org.robolectric.util.Scheduler} APIs for the 'foreground' scheduler
+     * are currently supported in this mode as well, although it is recommended to switch to use
+     * ShadowLooper APIs directly.
+     *
+     * <p>To use:
+     *
+     * <ul>
+     *   <li>Apply the LooperMode(PAUSED) annotation to your test package/class/method (or remove a
+     *       LooperMode(LEGACY) annotation)
+     *   <li>Convert any background {@link org.robolectric.util.Scheduler} for controlling {@link
+     *       android.os.Looper}s to shadowOf(looper)
+     *   <li>Convert any {@link org.robolectric.android.util.concurrent.RoboExecutorService} usages
+     *       to {@link org.robolectric.android.util.concurrent.PausedExecutorService} or {@link
+     *       org.robolectric.android.util.concurrent.InlineExecutorService}
+     *   <li>Run your tests. If you see an test failures like 'Main looper has queued unexecuted
+     *       runnables.', you may need to insert shadowOf(getMainLooper()).idle() calls to your test
+     *       to drain the main Looper.
+     * </ul>
+     */
+    PAUSED,
+
+    /**
+     * Currently not supported.
+     *
+     * <p>In future, will have free running threads with an automatically increasing clock.
+     */
+    // RUNNING
+  }
+
+  /** Set the Looper mode. */
+  Mode value();
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/RealObject.java b/annotations/src/main/java/org/robolectric/annotation/RealObject.java
new file mode 100644
index 0000000..431d11d
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/RealObject.java
@@ -0,0 +1,13 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Shadow fields annotated @RealObject will have the real instance injected. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD})
+public @interface RealObject {}
diff --git a/annotations/src/main/java/org/robolectric/annotation/ReflectorObject.java b/annotations/src/main/java/org/robolectric/annotation/ReflectorObject.java
new file mode 100644
index 0000000..0703415
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/ReflectorObject.java
@@ -0,0 +1,18 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Serves to cache the reflector object instance and lower test runtime.
+ *
+ * <p>For example, <code>@ReflectorObject MyReflector objectReflector</code> is equivalent to
+ * calling <code>reflector(MyReflector.class, realObject)</code>.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD})
+public @interface ReflectorObject {}
diff --git a/annotations/src/main/java/org/robolectric/annotation/Resetter.java b/annotations/src/main/java/org/robolectric/annotation/Resetter.java
new file mode 100644
index 0000000..e5972f4
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/Resetter.java
@@ -0,0 +1,13 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Indicates that the annotated method is used to reset static state in a shadow. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD})
+public @interface Resetter {}
diff --git a/annotations/src/main/java/org/robolectric/annotation/SQLiteMode.java b/annotations/src/main/java/org/robolectric/annotation/SQLiteMode.java
new file mode 100644
index 0000000..63f9169
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/SQLiteMode.java
@@ -0,0 +1,27 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A {@link org.robolectric.pluginapi.config.Configurer} annotation for controlling which SQLite
+ * shadow implementation is used for the {@link android.database} package.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+public @interface SQLiteMode {
+
+  /** Specifies the different supported SQLite modes. */
+  enum Mode {
+    /** Use the legacy SQLite implementation backed by sqlite4java. */
+    LEGACY,
+    /** Use the new SQLite implementation backed by native Android code from AOSP. */
+    NATIVE,
+  }
+
+  Mode value();
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/TextLayoutMode.java b/annotations/src/main/java/org/robolectric/annotation/TextLayoutMode.java
new file mode 100644
index 0000000..9525d32
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/TextLayoutMode.java
@@ -0,0 +1,54 @@
+package org.robolectric.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A {@link org.robolectric.pluginapi.config.Configurer} annotation for controlling how Robolectric
+ * performs UI layout.
+ *
+ * <p>PR #4818 changed Robolectric to be more realistic when performing layout on Android views.
+ * This change in behavior could cause tests still using the legacy 'UNPAUSED' looper mode or
+ * relying on views being a specific size to fail.
+ *
+ * <p>This annotation can be applied to tests to have Robolectric perform the legacy, less accurate
+ * mechanism of laying out and measuring Android text views, as a stopgap until the tests can be
+ * properly fixed.
+ *
+ * <p>This annotation will be deleted in a forthcoming Robolectric release.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+public @interface TextLayoutMode {
+
+  /** Specifies the different supported Text layout modes. */
+  enum Mode {
+    /**
+     * Robolectric's layout mode prior to 4.3.
+     *
+     * @deprecated LEGACY mode is inaccurate, has known bugs and will be removed in a future
+     *     release.
+     */
+    @Deprecated
+    LEGACY,
+    /**
+     * The new, more accurate layout mechanism.
+     *
+     * @deprecated REALTISTIC is the default mode and does not need to be stated explicity.
+     */
+    @Deprecated
+    REALISTIC,
+  }
+
+  Mode value();
+
+  /**
+   * Optional string for storing the issue / bug id tracking the fixing of the affected tests and
+   * thus removal of this annotation.
+   */
+  String issueId() default "";
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/experimental/LazyApplication.java b/annotations/src/main/java/org/robolectric/annotation/experimental/LazyApplication.java
new file mode 100644
index 0000000..d51d9cc
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/experimental/LazyApplication.java
@@ -0,0 +1,30 @@
+package org.robolectric.annotation.experimental;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A {@link org.robolectric.pluginapi.config.Configurer} annotation that dictates whether or not
+ * Robolectric should lazily instantiate the Application under test.
+ *
+ * <p>In particular, any test with {@link LazyLoad.ON} that does not need the Application will not
+ * load it (and recoup the associated cost)
+ *
+ * <p>NOTE: This feature is currently still experimental, so any users of {@link LazyLoad.ON} do so
+ * at their own risk
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+public @interface LazyApplication {
+
+  /** Whether or not the Application should be lazily loaded */
+  LazyLoad value();
+
+  /** Whether or not the Application should be lazily loaded */
+  enum LazyLoad {
+    ON,
+    OFF,
+  }
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/internal/ConfigUtils.java b/annotations/src/main/java/org/robolectric/annotation/internal/ConfigUtils.java
new file mode 100644
index 0000000..ed4a19a
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/internal/ConfigUtils.java
@@ -0,0 +1,47 @@
+package org.robolectric.annotation.internal;
+
+import org.robolectric.annotation.Config;
+
+public class ConfigUtils {
+  private ConfigUtils() {
+  }
+
+  public static String[] parseStringArrayProperty(String property) {
+    if (property.isEmpty()) return new String[0];
+    return property.split("[, ]+");
+  }
+
+  public static int[] parseSdkArrayProperty(String property) {
+    String[] parts = parseStringArrayProperty(property);
+    int[] result = new int[parts.length];
+    for (int i = 0; i < parts.length; i++) {
+      result[i] = parseSdkInt(parts[i]);
+    }
+
+    return result;
+  }
+
+  public static int parseSdkInt(String part) {
+    String spec = part.trim();
+    switch (spec) {
+      case "ALL_SDKS":
+        return Config.ALL_SDKS;
+      case "TARGET_SDK":
+        return Config.TARGET_SDK;
+      case "OLDEST_SDK":
+        return Config.OLDEST_SDK;
+      case "NEWEST_SDK":
+        return Config.NEWEST_SDK;
+      default:
+        try {
+          return Integer.parseInt(spec);
+        } catch (NumberFormatException e) {
+          try {
+            return (int) android.os.Build.VERSION_CODES.class.getField(part).get(null);
+          } catch (Exception e2) {
+            throw new IllegalArgumentException("unknown SDK \"" + part + "\"");
+          }
+        }
+    }
+  }
+}
diff --git a/annotations/src/main/java/org/robolectric/annotation/package-info.java b/annotations/src/main/java/org/robolectric/annotation/package-info.java
new file mode 100644
index 0000000..b7b9f03
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/annotation/package-info.java
@@ -0,0 +1,2 @@
+/** Package containing Robolectric annotations. */
+package org.robolectric.annotation;
diff --git a/annotations/src/main/java/org/robolectric/shadow/api/ShadowPicker.java b/annotations/src/main/java/org/robolectric/shadow/api/ShadowPicker.java
new file mode 100644
index 0000000..94355d0
--- /dev/null
+++ b/annotations/src/main/java/org/robolectric/shadow/api/ShadowPicker.java
@@ -0,0 +1,13 @@
+package org.robolectric.shadow.api;
+
+// TODO: move this to org.robolectric.annotation
+public interface ShadowPicker<T> {
+
+  /**
+   * Determines the shadow class to be used depending on the configuration of the {@link
+   * org.robolectric.internal.Environment}. Must be deterministic.
+   *
+   * @return the shadow class to be used
+   */
+  Class<? extends T> pickShadowClass();
+}
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..ee22a54
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,219 @@
+import org.gradle.plugins.ide.idea.model.IdeaModel
+
+buildscript {
+    apply from: 'dependencies.gradle'
+
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+        maven {
+            url "https://plugins.gradle.org/m2/"
+        }
+    }
+
+    dependencies {
+        gradle
+        classpath 'com.android.tools.build:gradle:7.3.0'
+        classpath 'net.ltgt.gradle:gradle-errorprone-plugin:2.0.2'
+        classpath 'com.netflix.nebula:gradle-aggregate-javadocs-plugin:3.0.1'
+        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
+        classpath "com.github.ben-manes:gradle-versions-plugin:0.42.0"
+        classpath "com.diffplug.spotless:spotless-plugin-gradle:6.9.1"
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+
+    group = "org.robolectric"
+    version = thisVersion
+}
+
+apply plugin: 'idea'
+apply plugin: 'com.github.ben-manes.versions'
+
+project.ext.configAnnotationProcessing = []
+project.afterEvaluate {
+    def ideaProject = rootProject.extensions.getByType(IdeaModel).project
+    ideaProject.ipr.withXml { provider ->
+        def compilerConfiguration = provider.asNode().component.find { it.'@name' == 'CompilerConfiguration' }
+
+        // prevent compiler from complaining about duplicate classes...
+        def excludeFromCompile = compilerConfiguration.appendNode 'excludeFromCompile'
+        configAnnotationProcessing.each { Project subProject ->
+            excludeFromCompile.appendNode('directory',
+                    [url: "file://${subProject.buildDir}/classes/java/main/generated", includeSubdirectories: "true"])
+        }
+
+        // replace existing annotationProcessing tag with a new one...
+        compilerConfiguration.annotationProcessing.replaceNode {
+            annotationProcessing {
+                configAnnotationProcessing.each { Project subProject ->
+                    profile(name: "${subProject.name}_main", enabled: "true") {
+                        module(name: "${subProject.name}_main")
+                        option(name: "org.robolectric.annotation.processing.shadowPackage",
+                                value: subProject.shadows.packageName)
+                        processor(name: "org.robolectric.annotation.processing.RobolectricProcessor")
+
+                        processorPath(useClasspath: "false") {
+                            def processorRuntimeCfg = project.project(":processor").configurations['runtime']
+                            processorRuntimeCfg.allArtifacts.each { artifact ->
+                                entry(name: artifact.file)
+                            }
+                            processorRuntimeCfg.files.each { file ->
+                                entry(name: file)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+apply plugin: 'nebula-aggregate-javadocs'
+
+rootProject.gradle.projectsEvaluated {
+    rootProject.tasks['aggregateJavadocs'].failOnError = false
+}
+
+gradle.projectsEvaluated {
+    def headerHtml = "<ul class=\"navList\" style=\"font-size: 1.5em;\"><li>Robolectric $thisVersion |" +
+            " <a href=\"/\" target=\"_top\">" +
+            "<img src=\"http://robolectric.org/images/logo-with-bubbles-down.png\"" +
+            " style=\"max-height: 18pt; vertical-align: sub;\"/></a></li></ul>"
+    project.allprojects { p ->
+        p.tasks.withType(Javadoc) {
+            options {
+                noTimestamp = true
+                links = [
+                        "https://docs.oracle.com/javase/8/docs/api/",
+                        "https://developer.android.com/reference/",
+                ]
+                // Set javadoc source to JDK 8 to avoid unnamed module problem
+                // when running aggregateJavadoc with OpenJDK 13+.
+                source("8")
+                header = headerHtml
+                footer = headerHtml
+                // bottom = "<link rel=\"stylesheet\" href=\"http://robolectric.org/assets/css/main.css\">"
+                version = thisVersion
+            }
+        }
+    }
+}
+
+task aggregateJsondocs(type: Copy) {
+    gradle.projectsEvaluated {
+        project.subprojects.findAll { it.plugins.hasPlugin(ShadowsPlugin) }.each { subproject ->
+            dependsOn subproject.tasks["compileJava"]
+            from "${subproject.buildDir}/docs/json"
+        }
+    }
+    into "$buildDir/docs/json"
+}
+
+task aggregateDocs {
+    dependsOn ':aggregateJavadocs'
+    dependsOn ':aggregateJsondocs'
+}
+
+// aggregate test results from all projects...
+task aggregateTestReports(type: TestReport) {
+    def jobNumber = System.getenv('TRAVIS_JOB_NUMBER')
+    if (jobNumber == null) {
+        destinationDir = file("$buildDir/reports/allTests")
+    } else {
+        destinationDir = file("$buildDir/reports/allTests/$jobNumber")
+    }
+}
+
+afterEvaluate {
+    def aggregateTestReportsTask = rootProject.tasks['aggregateTestReports']
+
+    allprojects.each { p ->
+        p.afterEvaluate {
+            p.tasks.withType(Test) { t ->
+                aggregateTestReportsTask.reportOn binResultsDir
+                finalizedBy aggregateTestReportsTask
+            }
+        }
+    }
+}
+
+task prefetchSdks() {
+    AndroidSdk.ALL_SDKS.each { androidSdk ->
+        doLast {
+            println("Prefetching ${androidSdk.coordinates}...")
+            // prefetch into maven local repo...
+            def mvnCommand = "mvn -q dependency:get -DrepoUrl=http://maven.google.com \
+                -DgroupId=${androidSdk.groupId} -DartifactId=${androidSdk.artifactId} \
+                -Dversion=${androidSdk.version}"
+            shellExec(mvnCommand)
+
+            // prefetch into gradle local cache...
+            def config = configurations.create("sdk${androidSdk.apiLevel}")
+            dependencies.add("sdk${androidSdk.apiLevel}", androidSdk.coordinates)
+            // causes dependencies to be resolved:
+            config.files
+        }
+    }
+}
+
+task prefetchInstrumentedSdks() {
+    AndroidSdk.ALL_SDKS.each { androidSdk ->
+        doLast {
+            println("Prefetching ${androidSdk.preinstrumentedCoordinates}...")
+            // prefetch into maven local repo...
+            def mvnCommand = "mvn -q dependency:get -DrepoUrl=http://maven.google.com \
+                -DgroupId=${androidSdk.groupId} -DartifactId=${androidSdk.preinstrumentedArtifactId} \
+                -Dversion=${androidSdk.preinstrumentedVersion}"
+            shellExec(mvnCommand)
+
+            // prefetch into gradle local cache...
+            def config = configurations.create("sdk${androidSdk.apiLevel}")
+            dependencies.add("sdk${androidSdk.apiLevel}", androidSdk.preinstrumentedCoordinates)
+            // causes dependencies to be resolved:
+            config.files
+        }
+    }
+}
+
+private void shellExec(String mvnCommand) {
+    def process = mvnCommand.execute()
+    def out = new StringBuffer()
+    def err = new StringBuffer()
+    process.consumeProcessOutput(out, err)
+    process.waitFor()
+    if (out.size() > 0) println out
+    if (err.size() > 0) println err
+    if (process.exitValue() != 0) System.exit(1)
+}
+
+task prefetchDependencies() {
+    doLast {
+        allprojects.each { p ->
+            p.configurations.each { config ->
+                // causes dependencies to be resolved:
+                if (config.isCanBeResolved()) {
+                    try {
+                        config.files
+                    } catch (ResolveException e) {
+                        // ignore resolution issues for integration tests and test app, sigh
+                        if (!p.path.startsWith(":integration_tests:")
+                                && !p.path.startsWith(":testapp")) {
+                            throw e
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+// for use of external initialization scripts...
+project.ext.allSdks = AndroidSdk.ALL_SDKS
diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle
new file mode 100644
index 0000000..a6ba7d7
--- /dev/null
+++ b/buildSrc/build.gradle
@@ -0,0 +1,18 @@
+apply plugin: "java-library"
+apply plugin: "groovy"
+
+repositories {
+    google()
+    mavenCentral()
+    gradlePluginPortal()
+}
+
+dependencies {
+    implementation gradleApi()
+    implementation localGroovy()
+
+    api "com.google.guava:guava:31.1-jre"
+    api 'org.jetbrains:annotations:16.0.2'
+    implementation "org.ow2.asm:asm-tree:9.2"
+    implementation 'com.android.tools.build:gradle:7.3.0'
+}
diff --git a/buildSrc/src/main/groovy/AndroidSdk.groovy b/buildSrc/src/main/groovy/AndroidSdk.groovy
new file mode 100644
index 0000000..1219ebb
--- /dev/null
+++ b/buildSrc/src/main/groovy/AndroidSdk.groovy
@@ -0,0 +1,96 @@
+class AndroidSdk implements Comparable<AndroidSdk> {
+    static final PREINSTRUMENTED_VERSION = 4
+
+    static final JELLY_BEAN = new AndroidSdk(16, "4.1.2_r1", "r1")
+    static final JELLY_BEAN_MR1 = new AndroidSdk(17, "4.2.2_r1.2", "r1")
+    static final JELLY_BEAN_MR2 = new AndroidSdk(18, "4.3_r2", "r1")
+    static final KITKAT = new AndroidSdk(19, "4.4_r1", "r2")
+    static final LOLLIPOP = new AndroidSdk(21, "5.0.2_r3", "r0")
+    static final LOLLIPOP_MR1 = new AndroidSdk(22, "5.1.1_r9", "r2")
+    static final M = new AndroidSdk(23, "6.0.1_r3", "r1")
+    static final N = new AndroidSdk(24, "7.0.0_r1", "r1")
+    static final N_MR1 = new AndroidSdk(25, "7.1.0_r7", "r1")
+    static final O = new AndroidSdk(26, "8.0.0_r4", "r1")
+    static final O_MR1 = new AndroidSdk(27, "8.1.0", "4611349")
+    static final P = new AndroidSdk(28, "9", "4913185-2");
+    static final Q = new AndroidSdk(29, "10", "5803371");
+    static final R = new AndroidSdk(30, "11", "6757853");
+    static final S = new AndroidSdk(31, "12", "7732740");
+    static final S_V2 = new AndroidSdk(32, "12.1", "8229987");
+    static final TIRAMISU = new AndroidSdk(33, "13", "9030017");
+
+
+    static final List<AndroidSdk> ALL_SDKS = [
+            JELLY_BEAN, JELLY_BEAN_MR1, JELLY_BEAN_MR2, KITKAT,
+            LOLLIPOP, LOLLIPOP_MR1, M, N, N_MR1, O, O_MR1, P, Q, R, S, S_V2,
+            TIRAMISU
+    ]
+
+    static final MAX_SDK = Collections.max(ALL_SDKS)
+
+    public final int apiLevel
+    private final String androidVersion
+    private final String frameworkSdkBuildVersion
+
+    AndroidSdk(int apiLevel, String androidVersion, String frameworkSdkBuildVersion) {
+        this.apiLevel = apiLevel
+        this.androidVersion = androidVersion
+        this.frameworkSdkBuildVersion = frameworkSdkBuildVersion
+    }
+
+    String getGroupId() {
+        return "org.robolectric"
+    }
+
+    String getArtifactId() {
+        return "android-all"
+    }
+
+    String getPreinstrumentedArtifactId() {
+        return "android-all-instrumented"
+    }
+
+    String getVersion() {
+        return "${androidVersion}-robolectric-${frameworkSdkBuildVersion}"
+    }
+
+    String getPreinstrumentedVersion() {
+        return "${androidVersion}-robolectric-${frameworkSdkBuildVersion}-i${PREINSTRUMENTED_VERSION}"
+    }
+
+    String getCoordinates() {
+        return "${groupId}:${artifactId}:${version}"
+    }
+
+    String getPreinstrumentedCoordinates() {
+        return "${groupId}:${preinstrumentedArtifactId}:${preinstrumentedVersion}"
+    }
+
+    String getJarFileName() {
+        return "android-all-${androidVersion}-robolectric-${frameworkSdkBuildVersion}.jar"
+    }
+
+    String getPreinstrumentedJarFileName() {
+        return "android-all-instrumented-${preinstrumentedVersion}.jar"
+    }
+
+    @Override
+    int compareTo(AndroidSdk other) {
+        return apiLevel - other.apiLevel
+    }
+
+    boolean equals(o) {
+        if (this.is(o)) return true
+        if (getClass() != o.class) return false
+
+        AndroidSdk that = (AndroidSdk) o
+
+        if (apiLevel != that.apiLevel) return false
+
+        return true
+    }
+
+    int hashCode() {
+        return apiLevel
+    }
+}
diff --git a/buildSrc/src/main/groovy/CheckApiChangesPlugin.groovy b/buildSrc/src/main/groovy/CheckApiChangesPlugin.groovy
new file mode 100644
index 0000000..c0671c5
--- /dev/null
+++ b/buildSrc/src/main/groovy/CheckApiChangesPlugin.groovy
@@ -0,0 +1,390 @@
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.objectweb.asm.ClassReader
+import org.objectweb.asm.tree.AnnotationNode
+import org.objectweb.asm.tree.ClassNode
+import org.objectweb.asm.tree.MethodNode
+
+import java.util.jar.JarEntry
+import java.util.jar.JarInputStream
+import java.util.regex.Pattern
+
+import static org.objectweb.asm.Opcodes.ACC_PRIVATE
+import static org.objectweb.asm.Opcodes.ACC_PROTECTED
+import static org.objectweb.asm.Opcodes.ACC_PUBLIC
+import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC
+
+class CheckApiChangesPlugin implements Plugin<Project> {
+    @Override
+    void apply(Project project) {
+        project.extensions.create("checkApiChanges", CheckApiChangesExtension)
+
+        project.configurations {
+            checkApiChangesFrom
+            checkApiChangesTo
+        }
+
+        project.afterEvaluate {
+            project.checkApiChanges.from.each {
+                project.dependencies.checkApiChangesFrom(it) {
+                    transitive = false
+                    force = true
+                }
+            }
+
+            project.checkApiChanges.to.findAll { it instanceof String }.each {
+                project.dependencies.checkApiChangesTo(it) {
+                    transitive = false
+                    force = true
+                }
+            }
+        }
+
+        project.task('checkForApiChanges', dependsOn: 'jar') {
+            doLast {
+                Map<ClassMethod, Change> changedClassMethods = new TreeMap<>()
+
+                def fromUrls = project.configurations.checkApiChangesFrom*.toURI()*.toURL()
+                println "fromUrls = ${fromUrls*.toString()*.replaceAll("^.*/", "")}"
+
+                def jarUrls = project.checkApiChanges.to
+                        .findAll { it instanceof Project }
+                        .collect { it.jar.archivePath.toURL() }
+                def toUrls = jarUrls + project.configurations.checkApiChangesTo*.toURI()*.toURL()
+                println "toUrls = ${toUrls*.toString()*.replaceAll("^.*/", "")}"
+
+                Analysis prev = new Analysis(fromUrls)
+                Analysis cur = new Analysis(toUrls)
+
+                Set<String> allMethods = new TreeSet<>(prev.classMethods.keySet())
+                allMethods.addAll(cur.classMethods.keySet())
+
+                Set<ClassMethod> deprecatedNotRemoved = new TreeSet<>()
+                Set<ClassMethod> newlyDeprecated = new TreeSet<>()
+
+                for (String classMethodName : allMethods) {
+                    ClassMethod prevClassMethod = prev.classMethods.get(classMethodName)
+                    ClassMethod curClassMethod = cur.classMethods.get(classMethodName)
+
+                    if (prevClassMethod == null) {
+                        // added
+                        if (curClassMethod.visible) {
+                            changedClassMethods.put(curClassMethod, Change.ADDED)
+                        }
+                    } else if (curClassMethod == null) {
+                        def theClass = prevClassMethod.classNode.name.replace('/', '.')
+                        def methodDesc = prevClassMethod.methodDesc
+                        while (curClassMethod == null && cur.parents[theClass] != null) {
+                            theClass = cur.parents[theClass]
+                            def parentMethodName = "${theClass}#${methodDesc}"
+                            curClassMethod = cur.classMethods[parentMethodName]
+                        }
+
+                        // removed
+                        if (curClassMethod == null && prevClassMethod.visible && !prevClassMethod.deprecated) {
+                            if (classMethodName.contains("getActivityTitle")) {
+                                println "hi!"
+                            }
+                            changedClassMethods.put(prevClassMethod, Change.REMOVED)
+                        }
+                    } else {
+                        if (prevClassMethod.deprecated) {
+                            deprecatedNotRemoved << prevClassMethod;
+                        } else if (curClassMethod.deprecated) {
+                            newlyDeprecated << prevClassMethod;
+                        }
+//                        println "changed: $classMethodName"
+                    }
+                }
+
+                String prevClassName = null
+                def introClass = { classMethod ->
+                    if (classMethod.className != prevClassName) {
+                        prevClassName = classMethod.className
+                        println "\n$prevClassName:"
+                    }
+                }
+
+                def entryPoints = project.checkApiChanges.entryPoints
+                Closure matchesEntryPoint = { ClassMethod classMethod ->
+                    for (String entryPoint : entryPoints) {
+                        if (classMethod.className.matches(entryPoint)) {
+                            return true
+                        }
+                    }
+                    return false
+                }
+
+                def expectedREs = project.checkApiChanges.expectedChanges.collect { Pattern.compile(it) }
+
+                for (Map.Entry<ClassMethod, Change> change : changedClassMethods.entrySet()) {
+                    def classMethod = change.key
+                    def changeType = change.value
+
+                    def showAllChanges = true // todo: only show stuff that's interesting...
+                    if (matchesEntryPoint(classMethod) || showAllChanges) {
+                        String classMethodDesc = classMethod.desc
+                        def expected = expectedREs.any { it.matcher(classMethodDesc).find() }
+                        if (!expected) {
+                            introClass(classMethod)
+
+                            switch (changeType) {
+                                case Change.ADDED:
+                                    println "+ ${classMethod.methodDesc}"
+                                    break
+                                case Change.REMOVED:
+                                    println "- ${classMethod.methodDesc}"
+                                    break
+                            }
+                        }
+                    }
+                }
+
+                if (!deprecatedNotRemoved.empty) {
+                    println "\nDeprecated but not removed:"
+                    for (ClassMethod classMethod : deprecatedNotRemoved) {
+                        introClass(classMethod)
+                        println "* ${classMethod.methodDesc}"
+                    }
+                }
+
+                if (!newlyDeprecated.empty) {
+                    println "\nNewly deprecated:"
+                    for (ClassMethod classMethod : newlyDeprecated) {
+                        introClass(classMethod)
+                        println "* ${classMethod.methodDesc}"
+                    }
+                }
+            }
+        }
+    }
+
+    static class Analysis {
+        final Map<String, String> parents = new HashMap<>()
+        final Map<String, ClassMethod> classMethods = new HashMap<>()
+
+        Analysis(List<URL> baseUrls) {
+            for (URL url : baseUrls) {
+                if (url.protocol == 'file') {
+                    def file = new File(url.path)
+                    def stream = new FileInputStream(file)
+                    def jarStream = new JarInputStream(stream)
+                    while (true) {
+                        JarEntry entry = jarStream.nextJarEntry
+                        if (entry == null) break
+
+                        if (!entry.directory && entry.name.endsWith(".class")) {
+                            def reader = new ClassReader(jarStream)
+                            def classNode = new ClassNode()
+                            reader.accept(classNode, ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES)
+
+                            def superName = classNode.superName.replace('/', '.')
+                            if (!"java.lang.Object".equals(superName)) {
+                                parents[classNode.name.replace('/', '.')] = superName
+                            }
+
+                            if (bitSet(classNode.access, ACC_PUBLIC) || bitSet(classNode.access, ACC_PROTECTED)) {
+                                for (MethodNode method : classNode.methods) {
+                                    def classMethod = new ClassMethod(classNode, method, url)
+                                    if (!bitSet(method.access, ACC_SYNTHETIC)) {
+                                        classMethods.put(classMethod.desc, classMethod)
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    stream.close()
+                }
+            }
+            classMethods
+        }
+
+    }
+
+    static enum Change {
+        REMOVED,
+        ADDED,
+    }
+
+    static class ClassMethod implements Comparable<ClassMethod> {
+        final ClassNode classNode
+        final MethodNode methodNode
+        final URL originUrl
+
+        ClassMethod(ClassNode classNode, MethodNode methodNode, URL originUrl) {
+            this.classNode = classNode
+            this.methodNode = methodNode
+            this.originUrl = originUrl
+        }
+
+        boolean equals(o) {
+            if (this.is(o)) return true
+            if (getClass() != o.class) return false
+
+            ClassMethod that = (ClassMethod) o
+
+            if (classNode.name != that.classNode.name) return false
+            if (methodNode.name != that.methodNode.name) return false
+            if (methodNode.signature != that.methodNode.signature) return false
+
+            return true
+        }
+
+        int hashCode() {
+            int result
+            result = (classNode.name != null ? classNode.name.hashCode() : 0)
+            result = 31 * result + (methodNode.name != null ? methodNode.name.hashCode() : 0)
+            result = 31 * result + (methodNode.signature != null ? methodNode.signature.hashCode() : 0)
+            return result
+        }
+
+        public String getDesc() {
+            return "$className#$methodDesc"
+        }
+
+        boolean hasParent() {
+            parentClassName() != "java/lang/Object"
+        }
+
+        String parentClassName() {
+            classNode.superName
+        }
+
+        private String getMethodDesc() {
+            def args = new StringBuilder()
+            def returnType = new StringBuilder()
+            def buf = args
+
+            int arrayDepth = 0
+            def write = { typeName ->
+                if (buf.size() > 0) buf.append(", ")
+                buf.append(typeName)
+                for (; arrayDepth > 0; arrayDepth--) {
+                    buf.append("[]")
+                }
+            }
+
+            def chars = methodNode.desc.toCharArray()
+            def i = 0
+
+            def readObj = {
+                if (buf.size() > 0) buf.append(", ")
+                def objNameBuf = new StringBuilder()
+                for (; i < chars.length; i++) {
+                    char c = chars[i]
+                    if (c == ';' as char) break
+                    objNameBuf.append((c == '/' as char) ? '.' : c)
+                }
+                buf.append(objNameBuf.toString().replaceAll(/^java\.lang\./, ''))
+            }
+
+            for (; i < chars.length;) {
+                def c = chars[i++]
+                switch (c) {
+                    case '(': break;
+                    case ')': buf = returnType; break;
+                    case '[': arrayDepth++; break;
+                    case 'Z': write('boolean'); break;
+                    case 'B': write('byte'); break;
+                    case 'S': write('short'); break;
+                    case 'I': write('int'); break;
+                    case 'J': write('long'); break;
+                    case 'F': write('float'); break;
+                    case 'D': write('double'); break;
+                    case 'C': write('char'); break;
+                    case 'L': readObj(); break;
+                    case 'V': write('void'); break;
+                }
+            }
+            "$methodAccessString ${isHiddenApi() ? "@HiddenApi " : ""}${isImplementation() ? "@Implementation " : ""}$methodNode.name(${args.toString()}): ${returnType.toString()}"
+        }
+
+        @Override
+        public String toString() {
+            internalName
+        }
+
+        private String getInternalName() {
+            classNode.name + "#$methodInternalName"
+        }
+
+        private String getMethodInternalName() {
+            "$methodNode.name$methodNode.desc"
+        }
+
+        private String getSignature() {
+            methodNode.signature == null ? "()V" : methodNode.signature
+        }
+
+        private String getClassName() {
+            classNode.name.replace('/', '.')
+        }
+
+        boolean isDeprecated() {
+            containsAnnotation(classNode.visibleAnnotations, "Ljava/lang/Deprecated;") ||
+                    containsAnnotation(methodNode.visibleAnnotations, "Ljava/lang/Deprecated;")
+        }
+
+        boolean isImplementation() {
+            containsAnnotation(methodNode.visibleAnnotations, "Lorg/robolectric/annotation/Implementation;")
+        }
+
+        boolean isHiddenApi() {
+            containsAnnotation(methodNode.visibleAnnotations, "Lorg/robolectric/annotation/HiddenApi;")
+        }
+
+        String getMethodAccessString() {
+            return getAccessString(methodNode.access)
+        }
+
+        private String getClassAccessString() {
+            return getAccessString(classNode.access)
+        }
+
+        String getAccessString(int access) {
+            if (bitSet(access, ACC_PROTECTED)) {
+                return "protected"
+            } else if (bitSet(access, ACC_PUBLIC)) {
+                return "public"
+            } else if (bitSet(access, ACC_PRIVATE)) {
+                return "private"
+            } else {
+                return "[package]"
+            }
+        }
+
+        boolean isVisible() {
+            (bitSet(classNode.access, ACC_PUBLIC) || bitSet(classNode.access, ACC_PROTECTED)) &&
+                    (bitSet(methodNode.access, ACC_PUBLIC) || bitSet(methodNode.access, ACC_PROTECTED)) &&
+                    !bitSet(classNode.access, ACC_SYNTHETIC) &&
+                    !(classNode.name =~ /\$[0-9]/) &&
+                    !(methodNode.name =~ /^access\$/ || methodNode.name == '<clinit>')
+        }
+
+        private static boolean containsAnnotation(List<AnnotationNode> annotations, String annotationInternalName) {
+            for (AnnotationNode annotationNode : annotations) {
+                if (annotationNode.desc == annotationInternalName) {
+                    return true
+                }
+            }
+            return false
+        }
+
+        @Override
+        int compareTo(ClassMethod o) {
+            internalName <=> o.internalName
+        }
+    }
+
+    private static boolean bitSet(int field, int bit) {
+        (field & bit) == bit
+    }
+}
+
+class CheckApiChangesExtension {
+    String[] from
+    Object[] to
+
+    String[] entryPoints
+    String[] expectedChanges
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/ProvideBuildClasspathTask.groovy b/buildSrc/src/main/groovy/ProvideBuildClasspathTask.groovy
new file mode 100644
index 0000000..8a6d0f2
--- /dev/null
+++ b/buildSrc/src/main/groovy/ProvideBuildClasspathTask.groovy
@@ -0,0 +1,37 @@
+import org.gradle.api.DefaultTask
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+
+class ProvideBuildClasspathTask extends DefaultTask {
+    @OutputFile File outFile
+
+    @TaskAction
+    public void writeProperties() throws Exception {
+        final Properties props = new Properties()
+
+        String preinstrumentedKey = "robolectric.usePreinstrumentedJars";
+        boolean usePreinstrumentedJars =
+            Boolean.parseBoolean(
+              System.getProperty(preinstrumentedKey, "true"));
+
+        AndroidSdk.ALL_SDKS.each { androidSdk ->
+            String coordinates =
+              usePreinstrumentedJars ?
+                androidSdk.preinstrumentedCoordinates : androidSdk.coordinates;
+            def config =
+                project.configurations.create("sdk${androidSdk.apiLevel}")
+            project.dependencies.add(
+                "sdk${androidSdk.apiLevel}",
+                coordinates)
+            props.setProperty(
+                coordinates,
+                config.files.join(File.pathSeparator))
+        }
+
+        File outDir = outFile.parentFile
+        if (!outDir.directory) outDir.mkdirs()
+        outFile.withPrintWriter { out ->
+            props.store(out, "# GENERATED by ${this} -- do not edit")
+        }
+    }
+}
diff --git a/buildSrc/src/main/groovy/ShadowsPlugin.groovy b/buildSrc/src/main/groovy/ShadowsPlugin.groovy
new file mode 100644
index 0000000..c41dc8c
--- /dev/null
+++ b/buildSrc/src/main/groovy/ShadowsPlugin.groovy
@@ -0,0 +1,75 @@
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.tasks.compile.JavaCompile
+
+import java.util.jar.JarFile
+
+@SuppressWarnings("GroovyUnusedDeclaration")
+class ShadowsPlugin implements Plugin<Project> {
+    @Override
+    void apply(Project project) {
+        project.apply plugin: 'idea'
+
+        project.extensions.create("shadows", ShadowsPluginExtension)
+
+        project.dependencies {
+            annotationProcessor project.project(":processor")
+        }
+
+        def compileJavaTask = project.tasks["compileJava"]
+
+        // write generated Java into its own dir... see https://github.com/gradle/gradle/issues/4956
+        def generatedSrcRelPath = 'build/generated/src/apt/main'
+        def generatedSrcDir = project.file(generatedSrcRelPath)
+
+        project.sourceSets.main.java { srcDir generatedSrcRelPath }
+        project.mkdir(generatedSrcDir)
+        compileJavaTask.options.annotationProcessorGeneratedSourcesDirectory = generatedSrcDir
+        compileJavaTask.outputs.dir(generatedSrcDir)
+
+        compileJavaTask.doFirst {
+            options.compilerArgs.add("-Aorg.robolectric.annotation.processing.jsonDocsEnabled=true")
+            options.compilerArgs.add("-Aorg.robolectric.annotation.processing.jsonDocsDir=${project.buildDir}/docs/json")
+            options.compilerArgs.add("-Aorg.robolectric.annotation.processing.shadowPackage=${project.shadows.packageName}")
+            options.compilerArgs.add("-Aorg.robolectric.annotation.processing.sdkCheckMode=${project.shadows.sdkCheckMode}")
+        }
+
+        // include generated sources in javadoc jar
+        project.tasks['javadoc'].source(generatedSrcDir)
+
+        // verify that we have the apt-generated files in our javadoc and sources jars
+        project.tasks['javadocJar'].doLast { task ->
+            def shadowPackageNameDir = project.shadows.packageName.replaceAll(/\./, '/')
+            checkForFile(task.archivePath, "${shadowPackageNameDir}/Shadows.html")
+        }
+
+        project.tasks['sourcesJar'].doLast { task ->
+            def shadowPackageNameDir = project.shadows.packageName.replaceAll(/\./, '/')
+            checkForFile(task.archivePath, "${shadowPackageNameDir}/Shadows.java")
+        }
+
+        project.rootProject.configAnnotationProcessing += project
+
+        /* Prevents sporadic compilation error:
+         * 'Bad service configuration file, or exception thrown while constructing
+         *  Processor object: javax.annotation.processing.Processor: Error reading
+         *  configuration file'
+         *
+         * See https://discuss.gradle.org/t/gradle-not-compiles-with-solder-tooling-jar/7583/20
+         */
+        project.tasks.withType(JavaCompile) { options.fork = true }
+    }
+
+    static class ShadowsPluginExtension {
+        String packageName
+        String sdkCheckMode = "WARN"
+    }
+
+    private static void checkForFile(jar, String name) {
+        def files = new JarFile(jar).entries().collect { it.name }.toSet()
+
+        if (!files.contains(name)) {
+            throw new RuntimeException("Missing file ${name} in ${jar}")
+        }
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/robolectric/gradle/AarDepsPlugin.java b/buildSrc/src/main/groovy/org/robolectric/gradle/AarDepsPlugin.java
new file mode 100644
index 0000000..0432cea
--- /dev/null
+++ b/buildSrc/src/main/groovy/org/robolectric/gradle/AarDepsPlugin.java
@@ -0,0 +1,116 @@
+package org.robolectric.gradle;
+
+import static org.gradle.api.artifacts.type.ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE;
+
+import com.android.build.gradle.internal.dependency.ExtractAarTransform;
+import com.google.common.base.Joiner;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.inject.Inject;
+import org.gradle.api.Action;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.artifacts.transform.TransformOutputs;
+import org.gradle.api.file.FileCollection;
+import org.gradle.api.tasks.compile.JavaCompile;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Resolve aar dependencies into jars for non-Android projects.
+ */
+public class AarDepsPlugin implements Plugin<Project> {
+  @Override
+  public void apply(Project project) {
+    project
+        .getDependencies()
+        .registerTransform(
+            ClassesJarExtractor.class,
+            reg -> {
+              reg.getParameters().getProjectName().set(project.getName());
+              reg.getFrom().attribute(ARTIFACT_TYPE_ATTRIBUTE, "aar");
+              reg.getTo().attribute(ARTIFACT_TYPE_ATTRIBUTE, "jar");
+            });
+
+    project.afterEvaluate(
+        p ->
+            project
+                .getConfigurations()
+                .forEach(
+                    c -> {
+                      // I suspect we're meant to use the org.gradle.usage attribute, but this
+                      // works.
+                      if (c.getName().endsWith("Classpath")) {
+                        c.attributes(
+                            cfgAttrs -> cfgAttrs.attribute(ARTIFACT_TYPE_ATTRIBUTE, "jar"));
+                      }
+                    }));
+
+    // warn if any AARs do make it through somehow; there must be a gradle configuration
+    // that isn't matched above.
+    //noinspection Convert2Lambda
+    project
+        .getTasks()
+        .withType(JavaCompile.class)
+        .all(
+            // the following Action<Task needs to remain an anonymous subclass or gradle's
+            // incremental compile breaks (run `gradlew -i classes` twice to see impact):
+            t -> t.doFirst(new Action<Task>() {
+              @Override
+              public void execute(Task task) {
+                List<File> aarFiles = AarDepsPlugin.this.findAarFiles(t.getClasspath());
+                if (!aarFiles.isEmpty()) {
+                  throw new IllegalStateException(
+                      "AARs on classpath: " + Joiner.on("\n  ").join(aarFiles));
+                }
+              }
+            }));
+  }
+
+  private List<File> findAarFiles(FileCollection files) {
+    List<File> bad = new ArrayList<>();
+    for (File file : files.getFiles()) {
+      if (file.getName().toLowerCase().endsWith(".aar")) {
+        bad.add(file);
+      }
+    }
+    return bad;
+  }
+
+  public static abstract class ClassesJarExtractor extends ExtractAarTransform {
+    @Inject
+    public ClassesJarExtractor() {
+    }
+
+    @Override
+    public void transform(@NotNull TransformOutputs outputs) {
+      AtomicReference<File> classesJarFile = new AtomicReference<>();
+      AtomicReference<File> outJarFile = new AtomicReference<>();
+      super.transform(new TransformOutputs() {
+        // This is the one that ExtractAarTransform calls.
+        @Override
+        public File dir(Object o) {
+          // ExtractAarTransform needs a place to extract the AAR. We don't really need to
+          // register this as an output, but it'd be tricky to avoid it.
+          File dir = outputs.dir(o);
+
+          // Also, register our jar file. Its name needs to be quasi-unique or
+          // IntelliJ Gradle/Android plugins get confused.
+          classesJarFile.set(new File(new File(dir, "jars"), "classes.jar"));
+          outJarFile.set(new File(new File(dir, "jars"), o + ".jar"));
+          outputs.file(o + "/jars/" + o + ".jar");
+          return outputs.dir(o);
+        }
+
+        @Override
+        public File file(Object o) {
+          throw new IllegalStateException("shouldn't be called");
+        }
+      });
+
+      classesJarFile.get().renameTo(outJarFile.get());
+    }
+  }
+}
diff --git a/buildSrc/src/main/groovy/org/robolectric/gradle/AndroidProjectConfigPlugin.groovy b/buildSrc/src/main/groovy/org/robolectric/gradle/AndroidProjectConfigPlugin.groovy
new file mode 100644
index 0000000..bcc79e3
--- /dev/null
+++ b/buildSrc/src/main/groovy/org/robolectric/gradle/AndroidProjectConfigPlugin.groovy
@@ -0,0 +1,54 @@
+package org.robolectric.gradle
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+public class AndroidProjectConfigPlugin implements Plugin<Project> {
+    @Override
+    public void apply(Project project) {
+        project.android.testOptions.unitTests.all {
+            // TODO: DRY up code with RoboJavaModulePlugin...
+            testLogging {
+                exceptionFormat "full"
+                showCauses true
+                showExceptions true
+                showStackTraces true
+                showStandardStreams true
+                events = ["failed", "skipped"]
+            }
+
+            minHeapSize = "2048m"
+            maxHeapSize = "8192m"
+
+            if (System.env['GRADLE_MAX_PARALLEL_FORKS'] != null) {
+                maxParallelForks = Integer.parseInt(System.env['GRADLE_MAX_PARALLEL_FORKS'])
+            }
+
+            def forwardedSystemProperties = System.properties
+                    .findAll { k,v -> k.startsWith("robolectric.") }
+                    .collect { k,v -> "-D$k=$v" }
+            jvmArgs = forwardedSystemProperties
+
+            doFirst {
+                if (!forwardedSystemProperties.isEmpty()) {
+                    println "Running tests with ${forwardedSystemProperties}"
+                }
+            }
+        }
+
+        project.task('provideBuildClasspath', type: ProvideBuildClasspathTask) {
+            File outDir = new File(project.buildDir, "generated/robolectric")
+            outFile = new File(outDir, 'robolectric-deps.properties')
+
+            project.android.sourceSets['test'].resources.srcDir(outDir)
+        }
+
+        project.afterEvaluate {
+            project.tasks.forEach { task ->
+                if (task.name.matches("process.*UnitTestJavaRes")) {
+                    task.dependsOn "provideBuildClasspath"
+                }
+            }
+        }
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/robolectric/gradle/DeployedRoboJavaModulePlugin.groovy b/buildSrc/src/main/groovy/org/robolectric/gradle/DeployedRoboJavaModulePlugin.groovy
new file mode 100644
index 0000000..324d04d
--- /dev/null
+++ b/buildSrc/src/main/groovy/org/robolectric/gradle/DeployedRoboJavaModulePlugin.groovy
@@ -0,0 +1,116 @@
+package org.robolectric.gradle
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.publish.maven.MavenPublication
+import org.gradle.api.tasks.bundling.Jar
+
+class DeployedRoboJavaModulePlugin implements Plugin<Project> {
+    Closure doApply = {
+        project.apply plugin: "signing"
+        project.apply plugin: "maven-publish"
+
+        task('sourcesJar', type: Jar, dependsOn: classes) {
+            archiveClassifier = "sources"
+            from sourceSets.main.allJava
+        }
+
+        javadoc {
+            failOnError = false
+            source = sourceSets.main.allJava
+            options.noTimestamp = true
+            options.header = "<ul class=\"navList\"><li>Robolectric $thisVersion | <a href=\"/\">Home</a></li></ul>"
+            options.footer = "<ul class=\"navList\"><li>Robolectric $thisVersion | <a href=\"/\">Home</a></li></ul>"
+            options.bottom = "<link rel=\"stylesheet\" href=\"https://robolectric.org/assets/css/main.css\">"
+            options.version = thisVersion
+        }
+
+        task('javadocJar', type: Jar, dependsOn: javadoc) {
+            archiveClassifier = "javadoc"
+            from javadoc.destinationDir
+        }
+
+        // for maven local install:
+        archivesBaseName = mavenArtifactName
+
+        publishing {
+            publications {
+                mavenJava(MavenPublication) {
+                    from components.java
+
+                    def skipJavadoc = System.getenv('SKIP_JAVADOC') == "true"
+                    artifact sourcesJar
+                    if (!skipJavadoc) {
+                        artifact javadocJar
+                    }
+
+                    artifactId = mavenArtifactName
+                    pom {
+                        name = project.name
+                        description = "An alternative Android testing framework."
+                        url = "http://robolectric.org"
+
+                        licenses {
+                            license {
+                                name = "The MIT License"
+                                url = "https://opensource.org/licenses/MIT"
+                            }
+                        }
+
+                        scm {
+                            url = "git@github.com:robolectric/robolectric.git"
+                            connection = "scm:git:git://github.com/robolectric/robolectric.git"
+                            developerConnection = "scm:git:https://github.com/robolectric/robolectric.git"
+                        }
+
+                        developers {
+                            developer {
+                                name = "Brett Chabot"
+                                email = "brettchabot@google.com"
+                                organization = "Google Inc."
+                                organizationUrl = "http://google.com"
+                            }
+
+                            developer {
+                                name = "Michael Hoisie"
+                                email = "hoisie@google.com"
+                                organization = "Google Inc."
+                                organizationUrl = "http://google.com"
+                            }
+
+                            developer {
+                                name = "Christian Williams"
+                                email = "antixian666@gmail.com"
+                            }
+                        }
+                    }
+                }
+            }
+
+            repositories {
+                maven {
+                    def releasesRepoUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
+                    def snapshotsRepoUrl = "https://oss.sonatype.org/content/repositories/snapshots"
+                    url = project.version.endsWith("-SNAPSHOT") ? snapshotsRepoUrl : releasesRepoUrl
+
+                    credentials {
+                        username = System.properties["sonatype-login"] ?: System.env['sonatypeLogin']
+                        password = System.properties["sonatype-password"] ?: System.env['sonatypePassword']
+                    }
+                }
+            }
+        }
+
+        signing {
+            required { !version.endsWith("SNAPSHOT") && gradle.taskGraph.hasTask("uploadArchives") }
+            sign publishing.publications.mavenJava
+        }
+    }
+
+    @Override
+    void apply(Project project) {
+        doApply.delegate = project
+        doApply.resolveStrategy = Closure.DELEGATE_ONLY
+        doApply()
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/robolectric/gradle/GradleManagedDevicePlugin.groovy b/buildSrc/src/main/groovy/org/robolectric/gradle/GradleManagedDevicePlugin.groovy
new file mode 100644
index 0000000..7289d0c
--- /dev/null
+++ b/buildSrc/src/main/groovy/org/robolectric/gradle/GradleManagedDevicePlugin.groovy
@@ -0,0 +1,27 @@
+package org.robolectric.gradle
+
+import com.android.build.api.dsl.ManagedVirtualDevice
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+class GradleManagedDevicePlugin implements Plugin<Project> {
+    @Override
+    void apply(Project project) {
+        project.android.testOptions {
+            devices {
+                // ./gradlew -Pandroid.sdk.channel=3 nexusOneApi29DebugAndroidTest
+                nexusOneApi29(ManagedVirtualDevice) {
+                    device = "Nexus One"
+                    apiLevel = 29
+                    systemImageSource = "aosp"
+                }
+                // ./gradlew -Pandroid.sdk.channel=3 nexusOneApi33DebugAndroidTest
+                nexusOneApi33(ManagedVirtualDevice) {
+                    device = "Nexus One"
+                    apiLevel = 33
+                    systemImageSource = "google"
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/org/robolectric/gradle/RoboJavaModulePlugin.groovy b/buildSrc/src/main/groovy/org/robolectric/gradle/RoboJavaModulePlugin.groovy
new file mode 100644
index 0000000..6c0e058
--- /dev/null
+++ b/buildSrc/src/main/groovy/org/robolectric/gradle/RoboJavaModulePlugin.groovy
@@ -0,0 +1,89 @@
+package org.robolectric.gradle
+
+import org.gradle.api.JavaVersion
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.tasks.compile.JavaCompile
+
+class RoboJavaModulePlugin implements Plugin<Project> {
+    Closure doApply = {
+        apply plugin: "java-library"
+
+        def skipErrorprone = System.getenv('SKIP_ERRORPRONE') == "true"
+        if (!skipErrorprone) {
+          apply plugin: "net.ltgt.errorprone"
+          project.dependencies {
+            errorprone("com.google.errorprone:error_prone_core:$errorproneVersion")
+            errorproneJavac("com.google.errorprone:javac:$errorproneJavacVersion")
+          }
+        }
+
+        apply plugin: AarDepsPlugin
+
+        sourceCompatibility = JavaVersion.VERSION_1_8
+        targetCompatibility = JavaVersion.VERSION_1_8
+
+        tasks.withType(JavaCompile) { task ->
+            sourceCompatibility = JavaVersion.VERSION_1_8
+            targetCompatibility = JavaVersion.VERSION_1_8
+
+            // Show all warnings except boot classpath
+            configure(options) {
+                if (System.properties["lint"] != null && System.properties["lint"] != "false") {
+                    compilerArgs << "-Xlint:all"        // Turn on all warnings
+                }
+                compilerArgs << "-Xlint:-options"       // Turn off "missing" bootclasspath warning
+                encoding = "utf-8"                      // Make sure source encoding is UTF-8
+            }
+
+        }
+
+        ext.mavenArtifactName = project.path.substring(1).split(/:/).join("-")
+
+        task('provideBuildClasspath', type: ProvideBuildClasspathTask) {
+            File outDir = project.sourceSets['test'].output.resourcesDir
+            outFile = new File(outDir, 'robolectric-deps.properties')
+        }
+
+        tasks['test'].dependsOn provideBuildClasspath
+
+        test {
+            exclude "**/*\$*" // otherwise gradle runs static inner classes like TestRunnerSequenceTest$SimpleTest
+
+            // TODO: DRY up code with AndroidProjectConfigPlugin...
+            testLogging {
+                exceptionFormat "full"
+                showCauses true
+                showExceptions true
+                showStackTraces true
+                showStandardStreams true
+                events = ["failed", "skipped"]
+            }
+
+            minHeapSize = "1024m"
+            maxHeapSize = "8192m"
+
+            if (System.env['GRADLE_MAX_PARALLEL_FORKS'] != null) {
+                maxParallelForks = Integer.parseInt(System.env['GRADLE_MAX_PARALLEL_FORKS'])
+            }
+
+            def forwardedSystemProperties = System.properties
+                    .findAll { k,v -> k.startsWith("robolectric.") }
+                    .collect { k,v -> "-D$k=$v" }
+            jvmArgs = forwardedSystemProperties
+
+            doFirst {
+                if (!forwardedSystemProperties.isEmpty()) {
+                    println "Running tests with ${forwardedSystemProperties}"
+                }
+            }
+        }
+    }
+
+    @Override
+    void apply(Project project) {
+        doApply.delegate = project
+        doApply.resolveStrategy = Closure.DELEGATE_ONLY
+        doApply()
+    }
+}
diff --git a/dependencies.gradle b/dependencies.gradle
new file mode 100644
index 0000000..1e93657
--- /dev/null
+++ b/dependencies.gradle
@@ -0,0 +1,38 @@
+ext {
+    apiCompatVersion='4.9'
+
+    errorproneVersion='2.16'
+    errorproneJavacVersion='9+181-r4173-1'
+
+    // AndroidX test versions
+    axtMonitorVersion='1.6.0-beta01'
+    axtRunnerVersion='1.5.0-beta01'
+    axtRulesVersion='1.4.1-beta01'
+    axtCoreVersion='1.5.0-beta01'
+    axtTruthVersion='1.5.0-beta01'
+    espressoVersion='3.5.0-beta01'
+    axtJunitVersion='1.1.4-beta01'
+
+    // AndroidX versions
+    coreVersion='1.9.0'
+    appCompatVersion='1.4.1'
+    constraintlayoutVersion='2.1.4'
+    windowVersion='1.0.0'
+    lifecycleVersion='2.2.0'
+    fragmentVersion='1.5.3'
+
+    truthVersion='1.1.3'
+
+    junitVersion='4.13.2'
+
+    mockitoVersion='4.1.0'
+
+    guavaJREVersion='31.1-jre'
+
+    asmVersion='9.3'
+
+    kotlinVersion='1.7.20'
+    autoServiceVersion='1.0.1'
+    multidexVersion='2.0.1'
+    sqlite4javaVersion='1.0.392'
+}
diff --git a/errorprone/build.gradle b/errorprone/build.gradle
new file mode 100644
index 0000000..1932066
--- /dev/null
+++ b/errorprone/build.gradle
@@ -0,0 +1,45 @@
+import org.gradle.internal.jvm.Jvm
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+// Disable annotation processor for tests
+compileTestJava {
+    options.compilerArgs.add("-proc:none")
+}
+
+test {
+    enabled = false
+}
+
+dependencies {
+    // Project dependencies
+    implementation project(":annotations")
+    implementation project(":shadowapi")
+
+    // Compile dependencies
+    implementation "com.google.errorprone:error_prone_annotation:$errorproneVersion"
+    implementation "com.google.errorprone:error_prone_refaster:$errorproneVersion"
+    implementation "com.google.errorprone:error_prone_check_api:$errorproneVersion"
+    compileOnly "com.google.auto.service:auto-service-annotations:$autoServiceVersion"
+    compileOnly(AndroidSdk.MAX_SDK.coordinates) { force = true }
+
+    annotationProcessor "com.google.auto.service:auto-service:$autoServiceVersion"
+    annotationProcessor "com.google.errorprone:error_prone_core:$errorproneVersion"
+
+    // in jdk 9, tools.jar disappears!
+    def toolsJar = Jvm.current().getToolsJar()
+    if (toolsJar != null) {
+        compile files(toolsJar)
+    }
+
+    // Testing dependencies
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation("com.google.errorprone:error_prone_test_helpers:${errorproneVersion}") {
+        exclude group: 'junit', module: 'junit' // because it depends on a snapshot!?
+    }
+    testCompileOnly(AndroidSdk.MAX_SDK.coordinates) { force = true }
+}
diff --git a/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/DeprecatedMethodsCheck.java b/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/DeprecatedMethodsCheck.java
new file mode 100644
index 0000000..6ed6c5a
--- /dev/null
+++ b/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/DeprecatedMethodsCheck.java
@@ -0,0 +1,255 @@
+package org.robolectric.errorprone.bugpatterns;
+
+import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;
+import static com.google.errorprone.matchers.Description.NO_MATCH;
+import static com.google.errorprone.matchers.Matchers.instanceMethod;
+import static com.google.errorprone.matchers.Matchers.staticMethod;
+import static com.google.errorprone.util.ASTHelpers.hasAnnotation;
+import static org.robolectric.errorprone.bugpatterns.Helpers.isCastableTo;
+import static org.robolectric.errorprone.bugpatterns.Helpers.isInShadowClass;
+
+import com.google.auto.service.AutoService;
+import com.google.errorprone.BugPattern;
+import com.google.errorprone.BugPattern.LinkType;
+import com.google.errorprone.BugPattern.StandardTags;
+import com.google.errorprone.VisitorState;
+import com.google.errorprone.bugpatterns.BugChecker;
+import com.google.errorprone.bugpatterns.BugChecker.ClassTreeMatcher;
+import com.google.errorprone.fixes.Fix;
+import com.google.errorprone.fixes.SuggestedFix;
+import com.google.errorprone.matchers.Description;
+import com.google.errorprone.matchers.method.MethodMatchers.MethodNameMatcher;
+import com.sun.source.tree.ClassTree;
+import com.sun.source.tree.ImportTree;
+import com.sun.source.tree.MethodInvocationTree;
+import com.sun.source.tree.Tree;
+import com.sun.source.tree.Tree.Kind;
+import com.sun.source.util.TreePath;
+import com.sun.source.util.TreeScanner;
+import com.sun.tools.javac.tree.JCTree.JCFieldAccess;
+import com.sun.tools.javac.tree.JCTree.JCMethodInvocation;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Checks for the deprecated methods.
+ *
+ * @author christianw@google.com (Christian Williams)
+ */
+@AutoService(BugChecker.class)
+@BugPattern(
+    name = "DeprecatedMethods",
+    summary = "Prefer supported APIs.",
+    severity = WARNING,
+    documentSuppression = false,
+    tags = StandardTags.REFACTORING,
+    link = "http://robolectric.org/migrating/#deprecations",
+    linkType = LinkType.CUSTOM)
+public class DeprecatedMethodsCheck extends BugChecker implements ClassTreeMatcher {
+  private final java.util.List<MethodInvocationMatcher> matchers =
+      Arrays.asList(
+          // Matches calls to <code>ShadowApplication.getInstance()</code>.
+          new MethodInvocationMatcher() {
+            @Override
+            MethodNameMatcher matcher() {
+              return staticMethod()
+                  .onClass(shadowName("org.robolectric.shadows.ShadowApplication"))
+                  .named("getInstance");
+            }
+
+            @Override
+            void replace(
+                MethodInvocationTree tree,
+                VisitorState state,
+                SuggestedFix.Builder fixBuilder,
+                HashMap<Tree, Runnable> possibleFixes) {
+              MethodCall surroundingMethodCall = getSurroundingMethodCall(tree, state);
+
+              if (surroundingMethodCall != null
+                  && surroundingMethodCall.getName().equals("getApplicationContext")) {
+                // transform `ShadowApplication.getInstance().getApplicationContext()`
+                //  to `RuntimeEnvironment.application`:
+
+                fixBuilder
+                    .replace(surroundingMethodCall.node, "RuntimeEnvironment.application")
+                    .addImport("org.robolectric.RuntimeEnvironment");
+              } else {
+                // transform `ShadowApplication.getInstance()`
+                //  to `shadowOf(RuntimeEnvironment.application)`:
+                Tree parent = state.getPath().getParentPath().getLeaf();
+
+                possibleFixes.put(
+                    parent,
+                    () ->
+                        fixBuilder
+                            .addImport("org.robolectric.RuntimeEnvironment")
+                            .replace(
+                                tree,
+                                wrapInShadows(
+                                    state, fixBuilder, "RuntimeEnvironment.application")));
+              }
+            }
+          },
+          new AppGetLastMatcher(
+              "org.robolectric.shadows.ShadowAlertDialog",
+              "ShadowAlertDialog",
+              "getLatestAlertDialog"),
+          new AppGetLastMatcher(
+              "org.robolectric.shadows.ShadowDialog", "ShadowDialog", "getLatestDialog"),
+          new AppGetLastMatcher(
+              "org.robolectric.shadows.ShadowPopupMenu", "ShadowPopupMenu", "getLatestPopupMenu"));
+
+  abstract static class MethodInvocationMatcher {
+    abstract MethodNameMatcher matcher();
+
+    abstract void replace(
+        MethodInvocationTree tree,
+        VisitorState state,
+        SuggestedFix.Builder fixBuilder,
+        HashMap<Tree, Runnable> possibleFixes);
+  }
+
+  @Override
+  public Description matchClass(ClassTree tree, VisitorState state) {
+    if (isInShadowClass(state.getPath(), state)) {
+      return NO_MATCH;
+    }
+
+    final SuggestedFix.Builder fixBuilder = SuggestedFix.builder();
+    HashMap<Tree, Runnable> possibleFixes = new HashMap<>();
+
+    new TreeScanner<Void, VisitorState>() {
+      private boolean inShadowClass;
+
+      @Override
+      public Void visitClass(ClassTree classTree, VisitorState visitorState) {
+        boolean priorInShadowClass = inShadowClass;
+        inShadowClass = hasAnnotation(classTree, Implements.class, visitorState);
+        try {
+          return super.visitClass(classTree, visitorState);
+        } finally {
+          inShadowClass = priorInShadowClass;
+        }
+      }
+
+      @Override
+      public Void visitMethodInvocation(MethodInvocationTree tree, VisitorState state) {
+        VisitorState nowState = state.withPath(TreePath.getPath(state.getPath(), tree));
+
+        if (!inShadowClass) {
+          for (MethodInvocationMatcher matcher : matchers) {
+            if (matcher.matcher().matches(tree, state)) {
+              matcher.replace(tree, nowState, fixBuilder, possibleFixes);
+              return null;
+            }
+          }
+        }
+
+        return super.visitMethodInvocation(tree, nowState);
+      }
+    }.scan(tree, state);
+
+    for (Runnable runnable : possibleFixes.values()) {
+      runnable.run();
+    }
+
+    Fix fix = fixBuilder.build();
+    return fix.isEmpty() ? NO_MATCH : describeMatch(tree, fix);
+  }
+
+  private String wrapInShadows(
+      VisitorState state, SuggestedFix.Builder fixBuilder, String content) {
+    Set<String> imports = getImports(state);
+    String shadowyContent;
+    if (imports.contains(shadowName("org.robolectric.Shadows"))) {
+      shadowyContent = shortShadowName("Shadows") + ".shadowOf(" + content + ")";
+    } else {
+      fixBuilder.addStaticImport(shadowName("org.robolectric.Shadows.shadowOf"));
+      shadowyContent = "shadowOf(" + content + ")";
+    }
+    return shadowyContent;
+  }
+
+  private static Set<String> getImports(VisitorState state) {
+    Set<String> imports = new HashSet<>();
+    for (ImportTree importTree : state.getPath().getCompilationUnit().getImports()) {
+      imports.add(state.getSourceForNode(importTree.getQualifiedIdentifier()));
+    }
+    return imports;
+  }
+
+  private static MethodCall getSurroundingMethodCall(Tree node, VisitorState state) {
+    TreePath nodePath = TreePath.getPath(state.getPath(), node);
+    TreePath parentPath = nodePath.getParentPath();
+    if (parentPath.getLeaf().getKind() == Kind.MEMBER_SELECT) {
+      Tree grandparentNode = parentPath.getParentPath().getLeaf();
+      if (grandparentNode.getKind() == Kind.METHOD_INVOCATION) {
+        return new MethodCall((JCMethodInvocation) grandparentNode);
+      }
+    }
+
+    return null;
+  }
+
+  static class MethodCall {
+
+    private final JCMethodInvocation node;
+
+    public MethodCall(JCMethodInvocation node) {
+      this.node = node;
+    }
+
+    public String getName() {
+      return ((JCFieldAccess) node.getMethodSelect()).name.toString();
+    }
+  }
+
+  String shadowName(String className) {
+    return className;
+  }
+
+  String shortShadowName(String shadowClassName) {
+    return shadowClassName;
+  }
+
+  private class AppGetLastMatcher extends MethodInvocationMatcher {
+    private final String methodName;
+    private final String shadowClassName;
+    private final String shadowShortClassName;
+
+    AppGetLastMatcher(String shadowClassName, String shadowShortClassName, String methodName) {
+      this.methodName = methodName;
+      this.shadowClassName = shadowClassName;
+      this.shadowShortClassName = shadowShortClassName;
+    }
+
+    @Override
+    MethodNameMatcher matcher() {
+      return instanceMethod()
+          .onClass(isCastableTo(shadowName("org.robolectric.shadows.ShadowApplication")))
+          .named(methodName);
+    }
+
+    @Override
+    void replace(
+        MethodInvocationTree tree,
+        VisitorState state,
+        SuggestedFix.Builder fixBuilder,
+        HashMap<Tree, Runnable> possibleFixes) {
+      possibleFixes.put(
+          tree,
+          () ->
+              fixBuilder
+                  .addImport(shadowName(shadowClassName))
+                  .replace(
+                      tree,
+                      wrapInShadows(
+                          state,
+                          fixBuilder,
+                          shortShadowName(shadowShortClassName) + "." + methodName + "()")));
+    }
+  }
+}
diff --git a/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/Helpers.java b/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/Helpers.java
new file mode 100644
index 0000000..234b96e
--- /dev/null
+++ b/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/Helpers.java
@@ -0,0 +1,59 @@
+package org.robolectric.errorprone.bugpatterns;
+
+import static com.google.errorprone.util.ASTHelpers.findEnclosingNode;
+import static com.google.errorprone.util.ASTHelpers.hasAnnotation;
+
+import com.google.errorprone.VisitorState;
+import com.google.errorprone.predicates.TypePredicate;
+import com.google.errorprone.suppliers.Supplier;
+import com.google.errorprone.suppliers.Suppliers;
+import com.google.errorprone.util.ASTHelpers;
+import com.sun.source.tree.Tree;
+import com.sun.source.util.TreePath;
+import com.sun.tools.javac.code.Type;
+import com.sun.tools.javac.tree.JCTree.JCClassDecl;
+import org.robolectric.annotation.Implements;
+
+/** Matchers for {@link ShadowUsageCheck}. */
+public class Helpers {
+
+  /** Match sub-types or implementations of the given type. */
+  public static TypePredicate isCastableTo(Supplier<Type> type) {
+    return new CastableTo(type);
+  }
+
+  /** Match sub-types or implementations of the given type. */
+  public static TypePredicate isCastableTo(String type) {
+    return new CastableTo(Suppliers.typeFromString(type));
+  }
+
+  public static boolean isInShadowClass(TreePath path, VisitorState state) {
+    Tree leaf = path.getLeaf();
+    JCClassDecl classDecl = JCClassDecl.class.isInstance(leaf)
+        ? (JCClassDecl) leaf
+        : findEnclosingNode(state.getPath(), JCClassDecl.class);
+
+    return hasAnnotation(classDecl, Implements.class, state);
+  }
+
+  /** Matches implementations of the given interface. */
+  public static class CastableTo implements TypePredicate {
+
+    public final Supplier<Type> expected;
+
+    public CastableTo(Supplier<Type> type) {
+      this.expected = type;
+    }
+
+    @Override
+    public boolean apply(Type type, VisitorState state) {
+      Type bound = expected.get(state);
+      if (bound == null || type == null) {
+        // TODO(cushon): type suppliers are allowed to return null :(
+        return false;
+      }
+      return ASTHelpers.isCastable(type, bound, state);
+    }
+  }
+
+}
diff --git a/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/RobolectricShadow.java b/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/RobolectricShadow.java
new file mode 100644
index 0000000..b5aceb4
--- /dev/null
+++ b/errorprone/src/main/java/org/robolectric/errorprone/bugpatterns/RobolectricShadow.java
@@ -0,0 +1,224 @@
+package org.robolectric.errorprone.bugpatterns;
+
+import static com.google.errorprone.BugPattern.SeverityLevel.SUGGESTION;
+import static com.google.errorprone.matchers.Matchers.hasAnnotation;
+
+import com.google.errorprone.BugPattern;
+import com.google.errorprone.BugPattern.StandardTags;
+import com.google.errorprone.VisitorState;
+import com.google.errorprone.bugpatterns.BugChecker;
+import com.google.errorprone.bugpatterns.BugChecker.ClassTreeMatcher;
+import com.google.errorprone.fixes.SuggestedFix;
+import com.google.errorprone.fixes.SuggestedFixes;
+import com.google.errorprone.matchers.Description;
+import com.google.errorprone.matchers.Matcher;
+import com.google.errorprone.util.ASTHelpers;
+import com.sun.source.doctree.DocCommentTree;
+import com.sun.source.doctree.EndElementTree;
+import com.sun.source.doctree.ReferenceTree;
+import com.sun.source.doctree.StartElementTree;
+import com.sun.source.doctree.TextTree;
+import com.sun.source.tree.AnnotationTree;
+import com.sun.source.tree.ClassTree;
+import com.sun.source.tree.ExpressionTree;
+import com.sun.source.tree.IdentifierTree;
+import com.sun.source.tree.MethodTree;
+import com.sun.source.tree.ModifiersTree;
+import com.sun.source.util.DocTreePath;
+import com.sun.source.util.DocTreePathScanner;
+import com.sun.source.util.TreePathScanner;
+import com.sun.tools.javac.api.JavacTrees;
+import com.sun.tools.javac.code.Symbol;
+import com.sun.tools.javac.tree.DCTree.DCDocComment;
+import com.sun.tools.javac.tree.DCTree.DCReference;
+import com.sun.tools.javac.tree.DCTree.DCStartElement;
+import com.sun.tools.javac.tree.JCTree.JCAssign;
+import com.sun.tools.javac.tree.JCTree.JCIdent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import javax.lang.model.element.Modifier;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Ensure Robolectric shadow's method marked with {@code @Implemenetation} is protected
+ *
+ * @author christianw@google.com (Christian Williams)
+ */
+@BugPattern(
+    name = "RobolectricShadow",
+    summary = "Robolectric @Implementation methods should be protected.",
+    severity = SUGGESTION,
+    documentSuppression = false,
+    tags = StandardTags.REFACTORING)
+public final class RobolectricShadow extends BugChecker implements ClassTreeMatcher {
+  private static final Matcher<ClassTree> implementsClassMatcher = hasAnnotation(Implements.class);
+
+  private static final Matcher<MethodTree> implementationMethodMatcher =
+      hasAnnotation(Implementation.class);
+
+  private boolean doScanJavadoc = false;
+
+  @Override
+  public Description matchClass(ClassTree classTree, VisitorState state) {
+    List<Optional<SuggestedFix>> fixes = new ArrayList<>();
+
+    if (implementsClassMatcher.matches(classTree, state)) {
+      boolean inSdk = true;
+
+      JavacTrees trees = JavacTrees.instance(state.context);
+      for (AnnotationTree annotationTree : classTree.getModifiers().getAnnotations()) {
+        JCIdent ident = (JCIdent) annotationTree.getAnnotationType();
+        String annotationClassName = ident.sym.getQualifiedName().toString();
+        if ("org.robolectric.annotation.Implements".equals(annotationClassName)) {
+          for (ExpressionTree expressionTree : annotationTree.getArguments()) {
+            JCAssign jcAnnotation = (JCAssign) expressionTree;
+            if ("isInAndroidSdk".equals(state.getSourceForNode(jcAnnotation.lhs))
+                && "false".equals(state.getSourceForNode(jcAnnotation.rhs))) {
+              // shadows of classes not in the public Android SDK can keep their public methods.
+              inSdk = false;
+            }
+          }
+        }
+      }
+
+      if (inSdk) {
+        new ImplementationMethodScanner(state, fixes, trees).scan(state.getPath(), null);
+      }
+    }
+
+    SuggestedFix.Builder builder = SuggestedFix.builder();
+    for (Optional<SuggestedFix> fix : fixes) {
+      fix.ifPresent(builder::merge);
+    }
+
+    if (builder.isEmpty()) {
+      return Description.NO_MATCH;
+    } else {
+      return describeMatch(classTree, builder.build());
+    }
+  }
+
+  static final class DocTreeSymbolScanner extends DocTreePathScanner<Void, Void> {
+    private final JavacTrees trees;
+    private final List<Optional<SuggestedFix>> fixes;
+
+    DocTreeSymbolScanner(JavacTrees trees, List<Optional<SuggestedFix>> fixes) {
+      this.trees = trees;
+      this.fixes = fixes;
+    }
+
+    @Override
+    public Void visitStartElement(StartElementTree startElementTree, Void aVoid) {
+      if (startElementTree.getName().toString().equalsIgnoreCase("p")) {
+        DCStartElement node = (DCStartElement) startElementTree;
+
+        DocTreePath path = getCurrentPath();
+        int start = (int) node.getSourcePosition((DCDocComment) path.getDocComment()) + node.pos;
+        int end = node.getEndPos((DCDocComment) getCurrentPath().getDocComment());
+
+        fixes.add(Optional.of(SuggestedFix.replace(start, end, "")));
+      }
+      return super.visitStartElement(startElementTree, aVoid);
+    }
+
+    @Override
+    public Void visitEndElement(EndElementTree endElementTree, Void aVoid) {
+      return super.visitEndElement(endElementTree, aVoid);
+    }
+
+    @Override
+    public Void visitText(TextTree textTree, Void aVoid) {
+      System.out.println("textTree = " + textTree);
+      return super.visitText(textTree, aVoid);
+    }
+
+    @Override
+    public Void visitReference(ReferenceTree referenceTree, Void sink) {
+      // do this first, it attributes the referenceTree as a side-effect
+      trees.getElement(getCurrentPath());
+      com.sun.source.util.TreeScanner<Void, Void> nonRecursiveScanner =
+          new com.sun.source.util.TreeScanner<Void, Void>() {
+            @Override
+            public Void visitIdentifier(IdentifierTree tree, Void sink) {
+              Symbol sym = ASTHelpers.getSymbol(tree);
+              if (sym != null) {
+                System.out.println("sym = " + sym);
+              }
+              return null;
+            }
+          };
+      DCReference reference = (DCReference) referenceTree;
+      nonRecursiveScanner.scan(reference.qualifierExpression, sink);
+      nonRecursiveScanner.scan(reference.paramTypes, sink);
+      return null;
+    }
+  }
+
+  private class ImplementationMethodScanner extends TreePathScanner<Void, Void> {
+
+    private final com.google.errorprone.VisitorState state;
+    private final List<Optional<SuggestedFix>> fixes;
+    private final JavacTrees trees;
+
+    ImplementationMethodScanner(
+        com.google.errorprone.VisitorState state,
+        List<Optional<SuggestedFix>> fixes,
+        JavacTrees trees) {
+      this.state = state;
+      this.fixes = fixes;
+      this.trees = trees;
+    }
+
+    @Override
+    public Void visitMethod(MethodTree methodTree, Void aVoid) {
+      if (implementationMethodMatcher.matches(methodTree, state)) {
+        processImplementationMethod(methodTree);
+      }
+      return super.visitMethod(methodTree, aVoid);
+    }
+
+    private void processImplementationMethod(MethodTree methodTree) {
+      String methodName = methodTree.getName().toString();
+      if ("toString".equals(methodName)
+          || "equals".equals(methodName)
+          || "hashCode".equals(methodName)) {
+        return; // they need to remain public
+      }
+      ModifiersTree modifiersTree = methodTree.getModifiers();
+      for (AnnotationTree annotationTree : modifiersTree.getAnnotations()) {
+        JCIdent ident = (JCIdent) annotationTree.getAnnotationType();
+        String annotationClassName = ident.sym.getQualifiedName().toString();
+        if ("java.lang.Override".equals(annotationClassName)) {
+          // can't have more restrictive permissions than the overridden method.
+          return;
+        }
+        if ("org.robolectric.annotation.HiddenApi".equals(annotationClassName)) {
+          // @HiddenApi implementation methods can stay public for the convenience of tests.
+          return;
+        }
+      }
+
+      Set<Modifier> modifiers = modifiersTree.getFlags();
+      if (!modifiers.contains(Modifier.PROTECTED)) {
+        fixes.add(
+            SuggestedFixes.removeModifiers(methodTree, state, Modifier.PUBLIC, Modifier.PRIVATE));
+        fixes.add(SuggestedFixes.addModifiers(methodTree, state, Modifier.PROTECTED));
+      }
+
+      if (doScanJavadoc) {
+        scanJavadoc();
+      }
+    }
+
+    private void scanJavadoc() {
+      DocCommentTree commentTree = trees.getDocCommentTree(getCurrentPath());
+      if (commentTree != null) {
+        DocTreePath docTrees = new DocTreePath(getCurrentPath(), commentTree);
+        new DocTreeSymbolScanner(trees, fixes).scan(docTrees, null);
+      }
+    }
+  }
+}
diff --git a/errorprone/src/test/java/org/robolectric/errorprone/bugpatterns/DeprecatedMethodsCheckTest.java b/errorprone/src/test/java/org/robolectric/errorprone/bugpatterns/DeprecatedMethodsCheckTest.java
new file mode 100644
index 0000000..490c5db
--- /dev/null
+++ b/errorprone/src/test/java/org/robolectric/errorprone/bugpatterns/DeprecatedMethodsCheckTest.java
@@ -0,0 +1,227 @@
+package org.robolectric.errorprone.bugpatterns;
+
+import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;
+
+import com.google.errorprone.BugCheckerRefactoringTestHelper;
+import com.google.errorprone.BugPattern;
+import java.io.IOException;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link DeprecatedMethodsCheck} */
+@RunWith(JUnit4.class)
+@SuppressWarnings("LineLength")
+public class DeprecatedMethodsCheckTest {
+  private BugCheckerRefactoringTestHelper testHelper;
+
+  @Before
+  public void setUp() {
+    this.testHelper =
+        BugCheckerRefactoringTestHelper.newInstance(
+            DeprecatedMethodsCheckForTest.class, getClass());
+  }
+
+  @Test
+  public void replaceShadowApplicationGetInstance() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeTest.java",
+            "import android.content.Context;",
+            "import org.junit.Test;",
+            "import xxx.XShadowApplication;",
+            "",
+            "public class SomeTest {",
+            "  Context application;",
+            "  @Test void theTest() {",
+            "    XShadowApplication.getInstance().runBackgroundTasks();",
+            "    application = XShadowApplication.getInstance().getApplicationContext();",
+            "  }",
+            "}")
+        .addOutputLines(
+            "in/SomeTest.java",
+            "import static xxx.XShadows.shadowOf;",
+            "",
+            "import android.content.Context;",
+            "import org.junit.Test;",
+            "import org.robolectric.RuntimeEnvironment;",
+            "import xxx.XShadowApplication;", // removable
+            "",
+            "public class SomeTest {",
+            "  Context application;",
+            "  @Test void theTest() {",
+            "    shadowOf(RuntimeEnvironment.application).runBackgroundTasks();",
+            "    application = RuntimeEnvironment.application;",
+            "  }",
+            "}")
+        .doTest();
+  }
+
+  @Test
+  public void replaceShadowApplicationGetLatestStuff() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeTest.java",
+            "import static xxx.XShadows.shadowOf;",
+            "",
+            "import org.junit.Test;",
+            "import org.robolectric.RuntimeEnvironment;",
+            "import xxx.XShadowApplication;",
+            "import xxx.XShadowAlertDialog;",
+            "import xxx.XShadowDialog;",
+            "import xxx.XShadowPopupMenu;",
+            "",
+            "public class SomeTest {",
+            "  @Test void theTest() {",
+            "    XShadowAlertDialog ad ="
+                + " shadowOf(RuntimeEnvironment.application).getLatestAlertDialog();",
+            "    XShadowDialog d = shadowOf(RuntimeEnvironment.application).getLatestDialog();",
+            "    XShadowPopupMenu pm ="
+                + " shadowOf(RuntimeEnvironment.application).getLatestPopupMenu();",
+            "  }",
+            "}")
+        .addOutputLines(
+            "in/SomeTest.java",
+            "import static xxx.XShadows.shadowOf;",
+            "",
+            "import org.junit.Test;",
+            "import org.robolectric.RuntimeEnvironment;", // removable
+            "import xxx.XShadowApplication;",
+            "import xxx.XShadowAlertDialog;",
+            "import xxx.XShadowDialog;",
+            "import xxx.XShadowPopupMenu;",
+            "",
+            "public class SomeTest {",
+            "  @Test void theTest() {",
+            "    XShadowAlertDialog ad = shadowOf(XShadowAlertDialog.getLatestAlertDialog());",
+            "    XShadowDialog d = shadowOf(XShadowDialog.getLatestDialog());",
+            "    XShadowPopupMenu pm = shadowOf(XShadowPopupMenu.getLatestPopupMenu());",
+            "  }",
+            "}")
+        .doTest();
+  }
+
+  @Test
+  @Ignore("multiple-step refactorings not currently supported")
+  public void inlineShadowVars() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeTest.java",
+            "import org.junit.Test;",
+            "import xxx.XShadowApplication;",
+            "",
+            "public class SomeTest {",
+            "  @Test void theTest() {",
+            "    XShadowApplication shadowApplication = XShadowApplication.getInstance();",
+            "    shadowApplication.runBackgroundTasks();",
+            "  }",
+            "}")
+        .addOutputLines(
+            "in/SomeTest.java",
+            "import static xxx.XShadows.shadowOf;",
+            "",
+            "import android.app.Application;",
+            "import org.junit.Test;",
+            "import org.robolectric.RuntimeEnvironment;",
+            "import org.robolectric.Shadows;",
+            "import xxx.XShadowApplication;", // removable
+            "",
+            "public class SomeTest {",
+            "  @Test void theTest() {",
+            "    Application application = RuntimeEnvironment.application;",
+            "    XShadows.shadowOf(application).runBackgroundTasks();",
+            "  }",
+            "}")
+        .doTest();
+  }
+
+  @Test
+  public void useShadowsNonStaticIfAlreadyImported() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeTest.java",
+            "import android.content.Context;",
+            "import org.junit.Test;",
+            "import xxx.XShadows;",
+            "import xxx.XShadowApplication;",
+            "",
+            "public class SomeTest {",
+            "  Context application;",
+            "  @Test void theTest() {",
+            "    XShadowApplication.getInstance().runBackgroundTasks();",
+            "    application = XShadowApplication.getInstance().getApplicationContext();",
+            "  }",
+            "}")
+        .addOutputLines(
+            "in/SomeTest.java",
+            "import android.content.Context;",
+            "import org.junit.Test;",
+            "import org.robolectric.RuntimeEnvironment;",
+            "import xxx.XShadowApplication;", // removable
+            "import xxx.XShadows;",
+            "",
+            "public class SomeTest {",
+            "  Context application;",
+            "  @Test void theTest() {",
+            "    XShadows.shadowOf(RuntimeEnvironment.application).runBackgroundTasks();",
+            "    application = RuntimeEnvironment.application;",
+            "  }",
+            "}")
+        .doTest();
+  }
+
+  @Test
+  @Ignore("multiple-step refactorings not currently supported")
+  public void useFrameworkMethodWhenAppropriateAfterApplicationSubstitution() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeTest.java",
+            "import android.content.Context;",
+            "import org.junit.Test;",
+            "import org.robolectric.Shadows;",
+            "import xxx.XShadowApplication;",
+            "",
+            "public class SomeTest {",
+            "  XShadowApplication shadowApplication;",
+            "  @Test void theTest() {",
+            "    shadowApplication = XShadowApplication.getInstance();",
+            "    shadowApplication.getMainLooper();",
+            "    shadowApplication.runBackgroundTasks();",
+            "  }",
+            "}")
+        .addOutputLines(
+            "in/SomeTest.java",
+            "import android.app.Application;",
+            "import android.content.Context;",
+            "import org.junit.Test;",
+            "import org.robolectric.RuntimeEnvironment;",
+            "import xxx.XShadows;",
+            "import xxx.XShadowApplication;", // removable
+            "",
+            "public class SomeTest {",
+            "  Application application;",
+            "  @Test void theTest() {",
+            "    application = RuntimeEnvironment.application;",
+            "    application.getMainLooper();",
+            "    XShadows.shadowOf(application).runBackgroundTasks();",
+            "  }",
+            "}")
+        .doTest();
+  }
+
+  /** Test overrides for {@link DeprecatedMethodsCheck} */
+  @BugPattern(name = "DeprecatedMethods", summary = "", severity = WARNING)
+  public static class DeprecatedMethodsCheckForTest extends DeprecatedMethodsCheck {
+    @Override
+    String shadowName(String className) {
+      return className.replaceAll("org\\.robolectric\\..*Shadow", "xxx.XShadow");
+    }
+
+    @Override
+    String shortShadowName(String className) {
+      return className.replaceAll("Shadow", "XShadow");
+    }
+  }
+}
diff --git a/errorprone/src/test/java/org/robolectric/errorprone/bugpatterns/RobolectricShadowTest.java b/errorprone/src/test/java/org/robolectric/errorprone/bugpatterns/RobolectricShadowTest.java
new file mode 100644
index 0000000..0919797
--- /dev/null
+++ b/errorprone/src/test/java/org/robolectric/errorprone/bugpatterns/RobolectricShadowTest.java
@@ -0,0 +1,118 @@
+package org.robolectric.errorprone.bugpatterns;
+
+import com.google.errorprone.BugCheckerRefactoringTestHelper;
+import java.io.IOException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** @author christianw@google.com (Christian Williams) */
+@RunWith(JUnit4.class)
+public class RobolectricShadowTest {
+  private BugCheckerRefactoringTestHelper testHelper;
+
+  @Before
+  public void setUp() {
+    this.testHelper =
+        BugCheckerRefactoringTestHelper.newInstance(RobolectricShadow.class, getClass());
+  }
+
+  @Test
+  public void implMethodsShouldBeProtected() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeShadow.java",
+            "import org.robolectric.annotation.HiddenApi;",
+            "import org.robolectric.annotation.Implementation;",
+            "import org.robolectric.annotation.Implements;",
+            "",
+            "@Implements(Object.class)",
+            "public class SomeShadow {",
+            "  @Implementation public void publicMethod() {}",
+            "  @Implementation @HiddenApi public void publicHiddenMethod() {}",
+            "  @Implementation protected void protectedMethod() {}",
+            "  @Implementation void packageMethod() {}",
+            "  @Implementation private void privateMethod() {}",
+            "}")
+        .addOutputLines(
+            "in/SomeShadow.java",
+            "import org.robolectric.annotation.HiddenApi;",
+            "import org.robolectric.annotation.Implementation;",
+            "import org.robolectric.annotation.Implements;",
+            "",
+            "@Implements(Object.class)",
+            "public class SomeShadow {",
+            "  @Implementation protected void publicMethod() {}",
+            "  @Implementation @HiddenApi public void publicHiddenMethod() {}",
+            "  @Implementation protected void protectedMethod() {}",
+            "  @Implementation protected void packageMethod() {}",
+            "  @Implementation protected void privateMethod() {}",
+            "}")
+        .doTest();
+  }
+
+  @Test
+  public void implMethodsNotProtectedForClassesNotInAndroidSdk() throws IOException {
+    testHelper
+        .addInputLines(
+            "in/SomeShadow.java",
+            "import org.robolectric.annotation.HiddenApi;",
+            "import org.robolectric.annotation.Implementation;",
+            "import org.robolectric.annotation.Implements;",
+            "",
+            "@Implements(value = Object.class, isInAndroidSdk = false)",
+            "public class SomeShadow {",
+            "  @Implementation public void publicMethod() {}",
+            "  @Implementation @HiddenApi public void publicHiddenMethod() {}",
+            "  @Implementation protected void protectedMethod() {}",
+            "  @Implementation void packageMethod() {}",
+            "  @Implementation private void privateMethod() {}",
+            "}")
+        .addOutputLines(
+            "in/SomeShadow.java",
+            "import org.robolectric.annotation.HiddenApi;",
+            "import org.robolectric.annotation.Implementation;",
+            "import org.robolectric.annotation.Implements;",
+            "",
+            "@Implements(value = Object.class, isInAndroidSdk = false)",
+            "public class SomeShadow {",
+            "  @Implementation public void publicMethod() {}",
+            "  @Implementation @HiddenApi public void publicHiddenMethod() {}",
+            "  @Implementation protected void protectedMethod() {}",
+            "  @Implementation void packageMethod() {}",
+            "  @Implementation private void privateMethod() {}",
+            "}")
+        .doTest();
+  }
+
+  @Test
+  public void implMethodJavadocShouldBeMarkdown() throws Exception {
+    testHelper
+        .addInputLines(
+            "in/SomeShadow.java",
+            "import org.robolectric.annotation.Implementation;",
+            "import org.robolectric.annotation.Implements;",
+            "",
+            "@Implements(Object.class)",
+            "public class SomeShadow {",
+            "  /**",
+            "   * <p>Should be markdown!</p>",
+            "   */",
+            "  @Implementation public void aMethod() {}",
+            "}")
+        .addOutputLines(
+            "in/SomeShadow.java",
+            "import org.robolectric.annotation.Implementation;",
+            "import org.robolectric.annotation.Implements;",
+            "",
+            "@Implements(Object.class)",
+            "public class SomeShadow {",
+            "  /**",
+            "   * Should be markdown!",
+            "   */",
+            "  @Implementation protected void aMethod() {}",
+            "}")
+        .doTest();
+  }
+}
diff --git a/errorprone/src/test/java/xxx/README b/errorprone/src/test/java/xxx/README
new file mode 100644
index 0000000..084f399
--- /dev/null
+++ b/errorprone/src/test/java/xxx/README
@@ -0,0 +1,5 @@
+Local stand-ins for Robolectric shadows that won't change over time...
+
+They start with "X" so IDEs aren't as likely to suggest 'em to you.
+
+The goal is to keep migrations working for outdated versions, see?
diff --git a/errorprone/src/test/java/xxx/XShadowAlertDialog.java b/errorprone/src/test/java/xxx/XShadowAlertDialog.java
new file mode 100644
index 0000000..9713a76
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowAlertDialog.java
@@ -0,0 +1,14 @@
+package xxx;
+
+import android.app.AlertDialog;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(AlertDialog.class)
+public class XShadowAlertDialog {
+  public static AlertDialog getLatestAlertDialog() {
+    return null;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowApplication.java b/errorprone/src/test/java/xxx/XShadowApplication.java
new file mode 100644
index 0000000..28728a2
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowApplication.java
@@ -0,0 +1,41 @@
+package xxx;
+
+import android.app.Application;
+import android.content.Context;
+import android.os.Looper;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(Application.class)
+public class XShadowApplication {
+  public static XShadowApplication getInstance() {
+    return null;
+  }
+
+  @Implementation
+  public Context getApplicationContext() {
+    return null;
+  }
+
+  public XShadowAlertDialog getLatestAlertDialog() {
+    return null;
+  }
+
+  public XShadowDialog getLatestDialog() {
+    return null;
+  }
+
+  public XShadowPopupMenu getLatestPopupMenu() {
+    return null;
+  }
+
+  @Implementation
+  public Looper getMainLooper() {
+    return null;
+  }
+
+  public void runBackgroundTasks() {}
+}
diff --git a/errorprone/src/test/java/xxx/XShadowConnectivityManager.java b/errorprone/src/test/java/xxx/XShadowConnectivityManager.java
new file mode 100644
index 0000000..c6da432
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowConnectivityManager.java
@@ -0,0 +1,17 @@
+package xxx;
+
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(ConnectivityManager.class)
+public class XShadowConnectivityManager {
+  @Implementation
+  public NetworkInfo getActiveNetworkInfo() {
+    return null;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowDialog.java b/errorprone/src/test/java/xxx/XShadowDialog.java
new file mode 100644
index 0000000..c9ffdbf
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowDialog.java
@@ -0,0 +1,14 @@
+package xxx;
+
+import android.app.Dialog;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(Dialog.class)
+public class XShadowDialog {
+  public static Dialog getLatestDialog() {
+    return null;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowDrawable.java b/errorprone/src/test/java/xxx/XShadowDrawable.java
new file mode 100644
index 0000000..01af573
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowDrawable.java
@@ -0,0 +1,12 @@
+package xxx;
+
+import android.graphics.drawable.Drawable;
+import org.robolectric.annotation.Implements;
+
+/** Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.ShadowUsageCheck}. */
+@Implements(Drawable.class)
+public class XShadowDrawable {
+  public int getCreatedFromResId() {
+    return 1234;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowLinearLayout.java b/errorprone/src/test/java/xxx/XShadowLinearLayout.java
new file mode 100644
index 0000000..9b52fff
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowLinearLayout.java
@@ -0,0 +1,12 @@
+package xxx;
+
+import android.widget.LinearLayout;
+import org.robolectric.annotation.Implements;
+
+/** Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.ShadowUsageCheck}. */
+@Implements(LinearLayout.class)
+public class XShadowLinearLayout extends XShadowViewGroup {
+  public int getGravity() {
+    return 0;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowLooper.java b/errorprone/src/test/java/xxx/XShadowLooper.java
new file mode 100644
index 0000000..619a083
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowLooper.java
@@ -0,0 +1,23 @@
+package xxx;
+
+import android.os.Looper;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(Looper.class)
+public class XShadowLooper {
+  @Implementation
+  public static Looper getMainLooper() {
+    return null;
+  }
+
+  public String getSchedule() {
+    return null;
+  }
+
+  public void runToEndOfTasks() {
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowNetworkInfo.java b/errorprone/src/test/java/xxx/XShadowNetworkInfo.java
new file mode 100644
index 0000000..17e15ca
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowNetworkInfo.java
@@ -0,0 +1,13 @@
+package xxx;
+
+import android.net.ConnectivityManager;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(ConnectivityManager.class)
+public class XShadowNetworkInfo {
+  public void setConnectionType(int connectionType) {
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowPopupMenu.java b/errorprone/src/test/java/xxx/XShadowPopupMenu.java
new file mode 100644
index 0000000..5676476
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowPopupMenu.java
@@ -0,0 +1,14 @@
+package xxx;
+
+import android.widget.PopupMenu;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.DeprecatedMethodsCheck}.
+ */
+@Implements(PopupMenu.class)
+public class XShadowPopupMenu {
+  public static PopupMenu getLatestPopupMenu() {
+    return null;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadowViewGroup.java b/errorprone/src/test/java/xxx/XShadowViewGroup.java
new file mode 100644
index 0000000..3c81e19
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadowViewGroup.java
@@ -0,0 +1,27 @@
+package xxx;
+
+import android.view.ViewGroup;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.LayoutAnimationController;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Fake shadow for testing {@link org.robolectric.errorprone.bugpatterns.ShadowUsageCheck}. */
+@Implements(ViewGroup.class)
+public class XShadowViewGroup {
+  @Implementation
+  public void setLayoutAnimationListener(AnimationListener listener) {}
+
+  @Implementation
+  public AnimationListener getLayoutAnimationListener() {
+    return null;
+  }
+
+  @Implementation
+  public void setLayoutAnimation(LayoutAnimationController layoutAnim) {}
+
+  @Implementation
+  public LayoutAnimationController getLayoutAnimation() {
+    return null;
+  }
+}
diff --git a/errorprone/src/test/java/xxx/XShadows.java b/errorprone/src/test/java/xxx/XShadows.java
new file mode 100644
index 0000000..af9d6a0
--- /dev/null
+++ b/errorprone/src/test/java/xxx/XShadows.java
@@ -0,0 +1,73 @@
+package xxx;
+
+import android.app.AlertDialog;
+import android.app.Application;
+import android.app.Dialog;
+import android.graphics.drawable.Drawable;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Looper;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.PopupMenu;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Fake {@link org.robolectric.internal.ShadowProvider} for testing
+ * {@link org.robolectric.errorprone.bugpatterns.ShadowUsageCheck}.
+ */
+public class XShadows implements org.robolectric.internal.ShadowProvider {
+  public static XShadowAlertDialog shadowOf(AlertDialog actual) {
+    return null;
+  }
+
+  public static XShadowApplication shadowOf(Application actual) {
+    return null;
+  }
+
+  public static XShadowConnectivityManager shadowOf(ConnectivityManager actual) {
+    return null;
+  }
+
+  public static XShadowDialog shadowOf(Dialog actual) {
+    return null;
+  }
+
+  public static XShadowDrawable shadowOf(Drawable actual) {
+    return null;
+  }
+
+  public static XShadowLooper shadowOf(Looper actual) {
+    return null;
+  }
+
+  public static XShadowLinearLayout shadowOf(LinearLayout actual) {
+    return null;
+  }
+
+  public static XShadowNetworkInfo shadowOf(NetworkInfo actual) {
+    return null;
+  }
+
+  public static XShadowPopupMenu shadowOf(PopupMenu actual) {
+    return null;
+  }
+
+  public static XShadowViewGroup shadowOf(ViewGroup actual) {
+    return null;
+  }
+
+  @Override
+  public void reset() {}
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return null;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return null;
+  }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..dc9eb66
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+thisVersion=4.10-SNAPSHOT
+android.useAndroidX=true
+kotlin.stdlib.default.dependency=false
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..249e583
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ae04661
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..a69d9cb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,240 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# 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
+#
+#      https://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.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+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" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+# Collect all arguments for the java command;
+#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+#     shell script including quotes and variable substitutions, so put them in
+#     double quotes to make sure that they get re-expanded; and
+#   * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..53a6b23
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,91 @@
+@rem

+@rem Copyright 2015 the original author or authors.

+@rem

+@rem Licensed under the Apache License, Version 2.0 (the "License");

+@rem you may not use this file except in compliance with the License.

+@rem You may obtain a copy of the License at

+@rem

+@rem      https://www.apache.org/licenses/LICENSE-2.0

+@rem

+@rem Unless required by applicable law or agreed to in writing, software

+@rem distributed under the License is distributed on an "AS IS" BASIS,

+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+@rem See the License for the specific language governing permissions and

+@rem limitations under the License.

+@rem

+

+@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

+

+set DIRNAME=%~dp0

+if "%DIRNAME%"=="" set DIRNAME=.

+set APP_BASE_NAME=%~n0

+set APP_HOME=%DIRNAME%

+

+@rem Resolve any "." and ".." in APP_HOME to make it shorter.

+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

+

+@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="-Xmx64m" "-Xms64m"

+

+@rem Find java.exe

+if defined JAVA_HOME goto findJavaFromJavaHome

+

+set JAVA_EXE=java.exe

+%JAVA_EXE% -version >NUL 2>&1

+if %ERRORLEVEL% equ 0 goto execute

+

+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 execute

+

+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

+

+: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 %*

+

+:end

+@rem End local scope for the variables with windows NT shell

+if %ERRORLEVEL% equ 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!

+set EXIT_CODE=%ERRORLEVEL%

+if %EXIT_CODE% equ 0 set EXIT_CODE=1

+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%

+exit /b %EXIT_CODE%

+

+:mainEnd

+if "%OS%"=="Windows_NT" endlocal

+

+:omega

diff --git a/images/robolectric-horizontal.png b/images/robolectric-horizontal.png
new file mode 100644
index 0000000..a232283
--- /dev/null
+++ b/images/robolectric-horizontal.png
Binary files differ
diff --git a/integration_tests/agp/build.gradle b/integration_tests/agp/build.gradle
new file mode 100644
index 0000000..a079d1e
--- /dev/null
+++ b/integration_tests/agp/build.gradle
@@ -0,0 +1,32 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+
+    testOptions.unitTests.includeAndroidResources true
+}
+
+dependencies {
+    // Testing dependencies
+    testImplementation project(path: ':testapp')
+    testImplementation project(":robolectric")
+    testImplementation project(":integration_tests:agp:testsupport")
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation("androidx.test:core:$axtCoreVersion")
+    testImplementation("androidx.test:runner:$axtRunnerVersion")
+    testImplementation("androidx.test.ext:junit:$axtJunitVersion")
+}
diff --git a/integration_tests/agp/src/main/AndroidManifest.xml b/integration_tests/agp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4e2dd6d
--- /dev/null
+++ b/integration_tests/agp/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.agp">
+  <application />
+</manifest>
diff --git a/integration_tests/agp/src/test/java/org/robolectric/integrationtests/agp/TestActivityTest.java b/integration_tests/agp/src/test/java/org/robolectric/integrationtests/agp/TestActivityTest.java
new file mode 100644
index 0000000..16a33fc
--- /dev/null
+++ b/integration_tests/agp/src/test/java/org/robolectric/integrationtests/agp/TestActivityTest.java
@@ -0,0 +1,23 @@
+package org.robolectric.integrationtests.agp;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.integrationtests.agp.testsupport.TestActivity;
+
+/**
+ * Test asserting that test-only activities can be declared in a dependency project's manifest as a
+ * workaround for the fact that Android Gradle Plugin doesn't merge the test manifest (as of 3.4).
+ *
+ * <p>When http://issuetracker.google.com/issues/127986458 is fixed, we can collapse {@code
+ * :integration_tests:agp:testsupport} back into {@code :integration_tests:agp}.
+ */
+@RunWith(AndroidJUnit4.class)
+public class TestActivityTest {
+
+  @Test
+  public void testActivitiesCanBeDeclaredInADependencyLibrary() throws Exception {
+    ActivityScenario.launch(TestActivity.class);
+  }
+}
diff --git a/integration_tests/agp/testsupport/build.gradle b/integration_tests/agp/testsupport/build.gradle
new file mode 100644
index 0000000..dcec3d4
--- /dev/null
+++ b/integration_tests/agp/testsupport/build.gradle
@@ -0,0 +1,19 @@
+apply plugin: 'com.android.library'
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+}
+
+dependencies {
+    api project(":integration_tests:agp")
+}
diff --git a/integration_tests/agp/testsupport/src/main/AndroidManifest.xml b/integration_tests/agp/testsupport/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1478552
--- /dev/null
+++ b/integration_tests/agp/testsupport/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.agp.testsupport">
+  <application>
+    <activity android:name="TestActivity"/>
+  </application>
+</manifest>
diff --git a/integration_tests/agp/testsupport/src/main/java/org/robolectric/integrationtests/agp/testsupport/TestActivity.java b/integration_tests/agp/testsupport/src/main/java/org/robolectric/integrationtests/agp/testsupport/TestActivity.java
new file mode 100644
index 0000000..eb6d5d2
--- /dev/null
+++ b/integration_tests/agp/testsupport/src/main/java/org/robolectric/integrationtests/agp/testsupport/TestActivity.java
@@ -0,0 +1,8 @@
+package org.robolectric.integrationtests.agp.testsupport;
+
+import android.app.Activity;
+
+/** Activity for use in unit tests. */
+public class TestActivity extends Activity {
+
+}
\ No newline at end of file
diff --git a/integration_tests/androidx/AndroidManifest.xml b/integration_tests/androidx/AndroidManifest.xml
new file mode 100644
index 0000000..b5ffbde
--- /dev/null
+++ b/integration_tests/androidx/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for instrumentation tests
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.androidx">
+  <uses-sdk
+      android:targetSdkVersion="29" />
+
+  <application />
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric">
+  </instrumentation>
+
+</manifest>
diff --git a/integration_tests/androidx/build.gradle b/integration_tests/androidx/build.gradle
new file mode 100644
index 0000000..0f2124f
--- /dev/null
+++ b/integration_tests/androidx/build.gradle
@@ -0,0 +1,45 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+
+}
+
+dependencies {
+    implementation("androidx.appcompat:appcompat:$appCompatVersion")
+    implementation("androidx.window:window:$windowVersion")
+
+    // Testing dependencies
+    testImplementation project(path: ':testapp')
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation("androidx.test:core:$axtCoreVersion")
+    testImplementation("androidx.core:core:$coreVersion")
+    testImplementation("androidx.test:runner:$axtRunnerVersion")
+    testImplementation("androidx.test:rules:$axtRulesVersion")
+    testImplementation("androidx.test.espresso:espresso-intents:$espressoVersion")
+    testImplementation("androidx.test.ext:truth:$axtTruthVersion")
+    // TODO: this should be a transitive dependency of core...
+    testImplementation("androidx.lifecycle:lifecycle-common:$lifecycleVersion")
+    testImplementation("androidx.test.ext:junit:$axtJunitVersion")
+    testImplementation("com.google.truth:truth:$truthVersion")
+}
diff --git a/integration_tests/androidx/src/main/AndroidManifest.xml b/integration_tests/androidx/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2fdd311
--- /dev/null
+++ b/integration_tests/androidx/src/main/AndroidManifest.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for androidx integration test module
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integration.axt">
+
+    <application>
+    </application>
+</manifest>
+
diff --git a/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/BuildCompatTest.java b/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/BuildCompatTest.java
new file mode 100644
index 0000000..b9e2243
--- /dev/null
+++ b/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/BuildCompatTest.java
@@ -0,0 +1,83 @@
+package org.robolectric.integrationtests.androidx;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build;
+import androidx.core.os.BuildCompat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Compatibility test for {@link BuildCompat} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class BuildCompatTest {
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void isAtLeastN() {
+    assertThat(BuildCompat.isAtLeastN()).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = Build.VERSION_CODES.M)
+  public void isAtLeastN_preN() {
+    assertThat(BuildCompat.isAtLeastN()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N_MR1)
+  public void isAtLeastNMR1() {
+    assertThat(BuildCompat.isAtLeastNMR1()).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = Build.VERSION_CODES.N)
+  public void isAtLeastNMR1_preNMR1() {
+    assertThat(BuildCompat.isAtLeastNMR1()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void isAtLeastO() {
+    assertThat(BuildCompat.isAtLeastO()).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = Build.VERSION_CODES.N_MR1)
+  public void isAtLeastO_preO() {
+    assertThat(BuildCompat.isAtLeastO()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void isAtLeastP() {
+    assertThat(BuildCompat.isAtLeastP()).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = Build.VERSION_CODES.O)
+  public void isAtLeastP_preP() {
+    assertThat(BuildCompat.isAtLeastP()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void isAtLeastQ() {
+    assertThat(BuildCompat.isAtLeastQ()).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = Build.VERSION_CODES.P)
+  public void isAtLeastQ_preQ() {
+    assertThat(BuildCompat.isAtLeastQ()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.R)
+  public void isAtLeastR() {
+    assertThat(BuildCompat.isAtLeastR()).isTrue();
+  }
+}
diff --git a/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/ResourcesCompatTest.java b/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/ResourcesCompatTest.java
new file mode 100644
index 0000000..ea0371b
--- /dev/null
+++ b/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/ResourcesCompatTest.java
@@ -0,0 +1,28 @@
+package org.robolectric.integrationtests.androidx;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Typeface;
+import androidx.core.content.res.ResourcesCompat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.testapp.R;
+
+/** Compatibility test for {@link ResourcesCompat} */
+@RunWith(AndroidJUnit4.class)
+public class ResourcesCompatTest {
+
+  @Test
+  public void getFont() {
+    Typeface typeface = ResourcesCompat.getFont(getApplicationContext(), R.font.vt323_regular);
+    assertThat(typeface).isNotNull();
+  }
+
+  @Test
+  public void getFontFamily() {
+    Typeface typeface = ResourcesCompat.getFont(getApplicationContext(), R.font.vt323);
+    assertThat(typeface).isNotNull();
+  }
+}
diff --git a/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/WindowMetricsCalculatorTest.java b/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/WindowMetricsCalculatorTest.java
new file mode 100644
index 0000000..03a7f02
--- /dev/null
+++ b/integration_tests/androidx/src/test/java/org/robolectric/integrationtests/androidx/WindowMetricsCalculatorTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.integrationtests.androidx;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.window.layout.WindowMetrics;
+import androidx.window.layout.WindowMetricsCalculator;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+import org.robolectric.testapp.TestActivity;
+
+/** Compatibility test for {@link WindowMetricsCalculator} */
+@RunWith(AndroidJUnit4.class)
+public class WindowMetricsCalculatorTest {
+  @Test
+  @Config(qualifiers = "w400dp-h600dp")
+  public void computeCurrentWindowMetrics() {
+    TestActivity activity = Robolectric.buildActivity(TestActivity.class).setup().get();
+    WindowMetrics windowMetrics =
+        WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity);
+
+    assertThat(windowMetrics.getBounds().width()).isEqualTo(400);
+    assertThat(windowMetrics.getBounds().height()).isEqualTo(600);
+  }
+}
diff --git a/integration_tests/androidx/src/test/resources/androidx/robolectric.properties b/integration_tests/androidx/src/test/resources/androidx/robolectric.properties
new file mode 100644
index 0000000..7c7f39f
--- /dev/null
+++ b/integration_tests/androidx/src/test/resources/androidx/robolectric.properties
@@ -0,0 +1 @@
+sdk=ALL_SDKS
diff --git a/integration_tests/androidx_test/build.gradle b/integration_tests/androidx_test/build.gradle
new file mode 100644
index 0000000..6abd174
--- /dev/null
+++ b/integration_tests/androidx_test/build.gradle
@@ -0,0 +1,70 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+import org.robolectric.gradle.GradleManagedDevicePlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+apply plugin: GradleManagedDevicePlugin
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+    sourceSets {
+        String sharedTestDir = 'src/sharedTest/'
+        String sharedTestSourceDir = sharedTestDir + 'java'
+        String sharedTestResourceDir = sharedTestDir + 'resources'
+        String sharedAndroidManifest = sharedTestDir + "AndroidManifest.xml"
+        test.resources.srcDirs += sharedTestResourceDir
+        test.java.srcDirs += sharedTestSourceDir
+        test.manifest.srcFile sharedAndroidManifest
+        androidTest.resources.srcDirs += sharedTestResourceDir
+        androidTest.java.srcDirs += sharedTestSourceDir
+        androidTest.manifest.srcFile sharedAndroidManifest
+    }
+}
+
+dependencies {
+    implementation "androidx.appcompat:appcompat:$appCompatVersion"
+    implementation "androidx.constraintlayout:constraintlayout:$constraintlayoutVersion"
+    implementation "androidx.multidex:multidex:$multidexVersion"
+
+    // Testing dependencies
+    testImplementation project(":robolectric")
+    testImplementation "androidx.test:runner:$axtRunnerVersion"
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation "androidx.test:rules:$axtRulesVersion"
+    testImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
+    testImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+    testImplementation "androidx.test.ext:truth:$axtTruthVersion"
+    testImplementation "androidx.test:core:$axtCoreVersion"
+    testImplementation "androidx.fragment:fragment:$fragmentVersion"
+    testImplementation "androidx.fragment:fragment-testing:$fragmentVersion"
+    testImplementation "androidx.test.ext:junit:$axtJunitVersion"
+    testImplementation "com.google.truth:truth:$truthVersion"
+
+    androidTestImplementation project(':annotations')
+    androidTestImplementation "androidx.test:runner:$axtRunnerVersion"
+    androidTestImplementation "junit:junit:$junitVersion"
+    androidTestImplementation "androidx.test:rules:$axtRulesVersion"
+    androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
+    androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+    androidTestImplementation "androidx.test.ext:truth:$axtTruthVersion"
+    androidTestImplementation "androidx.test:core:$axtCoreVersion"
+    androidTestImplementation "androidx.test.ext:junit:$axtJunitVersion"
+    androidTestImplementation "com.google.truth:truth:$truthVersion"
+}
diff --git a/integration_tests/androidx_test/src/main/AndroidManifest-NoTestPackageActivities.xml b/integration_tests/androidx_test/src/main/AndroidManifest-NoTestPackageActivities.xml
new file mode 100644
index 0000000..fc3a595
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/AndroidManifest-NoTestPackageActivities.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for ATSL integration tests
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integration.axt">
+    <uses-sdk android:targetSdkVersion="28"/>
+
+    <uses-permission android:name="android.permission.READ_CONTACTS"/>
+
+    <application>
+        <activity android:name="org.robolectric.integrationtests.axt.EspressoActivity"
+                  android:label="Activity Label"
+                  android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.EspressoScrollingActivity"
+            android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityWithPlatformMenu"
+            android:exported="true">
+        </activity>
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityWithAppCompatMenu"
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat" />
+        <activity android:name="org.robolectric.integrationtests.axt.AppCompatActivityWithToolbarMenu"
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat.NoActionBar" />
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityWithSwitchCompat"
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat" />
+        <activity android:name="org.robolectric.integrationtests.axt.StubBrowserActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/integration_tests/androidx_test/src/main/AndroidManifest.xml b/integration_tests/androidx_test/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ccb4fba
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/AndroidManifest.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for ATSL integration tests
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integration.axt">
+
+    <uses-permission android:name="android.permission.READ_CONTACTS"/>
+
+    <application>
+        <activity android:name="org.robolectric.integrationtests.axt.EspressoActivity"
+                  android:label="Activity Label"
+                  android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.EspressoScrollingActivity"
+            android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityWithPlatformMenu"
+            android:exported="true">
+        </activity>
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityWithAppCompatMenu"
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat" />
+        <activity android:name="org.robolectric.integrationtests.axt.AppCompatActivityWithToolbarMenu"
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat.NoActionBar" />
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityTestRuleTest$TranscriptActivity"
+            android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.IntentsTest$ResultCapturingActivity"
+            android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.IntentsTest$DummyActivity"
+            android:exported="true" />
+        <activity android:name="org.robolectric.integrationtests.axt.ActivityWithSwitchCompat"
+            android:exported="true"
+            android:theme="@style/Theme.AppCompat" />
+        <activity android:name="org.robolectric.integrationtests.axt.StubBrowserActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+            </intent-filter>
+        </activity>
+
+    </application>
+</manifest>
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithAppCompatMenu.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithAppCompatMenu.java
new file mode 100644
index 0000000..937abf2
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithAppCompatMenu.java
@@ -0,0 +1,33 @@
+package org.robolectric.integrationtests.axt;
+
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import org.robolectric.integration.axt.R;
+
+/** {@link EspressoWithMenuTest} fixture activity that uses appcompat menu's */
+public class ActivityWithAppCompatMenu extends AppCompatActivity {
+
+  boolean menuClicked;
+
+  @Override
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+  }
+
+  @Override
+  public boolean onCreateOptionsMenu(Menu menu) {
+    MenuInflater inflater = new MenuInflater(this);
+
+    inflater.inflate(R.menu.menu, menu);
+    return true;
+  }
+
+  @Override
+  public boolean onOptionsItemSelected(MenuItem item) {
+    menuClicked = true;
+    return true;
+  }
+}
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithPlatformMenu.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithPlatformMenu.java
new file mode 100644
index 0000000..b869d9e
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithPlatformMenu.java
@@ -0,0 +1,33 @@
+package org.robolectric.integrationtests.axt;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import org.robolectric.integration.axt.R;
+
+/** {@link EspressoWithMenuTest} fixture activity that uses Android platform menu's */
+public class ActivityWithPlatformMenu extends Activity {
+
+  boolean menuClicked;
+
+  @Override
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+  }
+
+  @Override
+  public boolean onCreateOptionsMenu(Menu menu) {
+    MenuInflater inflater = new MenuInflater(this);
+
+    inflater.inflate(R.menu.menu, menu);
+    return true;
+  }
+
+  @Override
+  public boolean onOptionsItemSelected(MenuItem item) {
+    menuClicked = true;
+    return true;
+  }
+}
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithSwitchCompat.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithSwitchCompat.java
new file mode 100644
index 0000000..d1df2d1
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/ActivityWithSwitchCompat.java
@@ -0,0 +1,14 @@
+package org.robolectric.integrationtests.axt;
+
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import org.robolectric.integration.axt.R;
+
+/** Fixture activity for {@link EspressoWithSwitchCompat} */
+public class ActivityWithSwitchCompat extends AppCompatActivity {
+  @Override
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.activity_switch_compat);
+  }
+}
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/AppCompatActivityWithToolbarMenu.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/AppCompatActivityWithToolbarMenu.java
new file mode 100644
index 0000000..f51cd4e
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/AppCompatActivityWithToolbarMenu.java
@@ -0,0 +1,26 @@
+package org.robolectric.integrationtests.axt;
+
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import org.robolectric.integration.axt.R;
+
+/** {@link EspressoWithMenuTest} fixture activity that uses appcompat menu's */
+public class AppCompatActivityWithToolbarMenu extends AppCompatActivity {
+  boolean menuClicked;
+
+  @Override
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+
+    setContentView(R.layout.appcompat_activity_with_toolbar_menu);
+
+    final Toolbar toolbar = findViewById(R.id.toolbar);
+    toolbar.inflateMenu(R.menu.menu);
+    toolbar.setOnMenuItemClickListener(
+        item -> {
+          menuClicked = true;
+          return true;
+        });
+  }
+}
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java
new file mode 100644
index 0000000..1964fa0
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java
@@ -0,0 +1,26 @@
+package org.robolectric.integrationtests.axt;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.Button;
+import android.widget.EditText;
+import org.robolectric.integration.axt.R;
+
+/** Fixture activity for {@link EspressoTest} */
+public class EspressoActivity extends Activity {
+
+  EditText editText;
+  Button button;
+  boolean buttonClicked;
+
+  @Override
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+
+    setContentView(R.layout.espresso_activity);
+
+    editText = findViewById(R.id.edit_text);
+    button = findViewById(R.id.button);
+    button.setOnClickListener(view -> buttonClicked = true);
+  }
+}
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoScrollingActivity.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoScrollingActivity.java
new file mode 100644
index 0000000..1dcbbb7
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoScrollingActivity.java
@@ -0,0 +1,22 @@
+package org.robolectric.integrationtests.axt;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.Button;
+import org.robolectric.integration.axt.R;
+
+/** Activity that inflates a {@link android.widget.ScrollView} . */
+public class EspressoScrollingActivity extends Activity {
+  Button button;
+  boolean buttonClicked;
+
+  @Override
+  public void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+
+    setContentView(R.layout.espresso_scrolling_activity);
+
+    button = findViewById(R.id.button);
+    button.setOnClickListener(view -> buttonClicked = true);
+  }
+}
diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/StubBrowserActivity.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/StubBrowserActivity.java
new file mode 100644
index 0000000..3274669
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/StubBrowserActivity.java
@@ -0,0 +1,9 @@
+package org.robolectric.integrationtests.axt;
+
+import android.app.Activity;
+
+/**
+ * A Activity that receives browser intents. Used for
+ * IntentsTest#testIntendedSuccess_truthChainedCorrespondence
+ */
+public class StubBrowserActivity extends Activity {}
diff --git a/integration_tests/androidx_test/src/main/res/layout/activity_switch_compat.xml b/integration_tests/androidx_test/src/main/res/layout/activity_switch_compat.xml
new file mode 100644
index 0000000..70a39b0
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/res/layout/activity_switch_compat.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/main"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:fillViewport="true">
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+
+        <androidx.appcompat.widget.SwitchCompat
+            android:id="@+id/switch_compat"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:text="Switch Me"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+        <androidx.appcompat.widget.SwitchCompat
+            android:id="@+id/switch_compat_2"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:text="Don't Switch Me"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toBottomOf="@+id/switch_compat"
+            tools:checked="true" />
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+</androidx.core.widget.NestedScrollView>
\ No newline at end of file
diff --git a/integration_tests/androidx_test/src/main/res/layout/appcompat_activity_with_toolbar_menu.xml b/integration_tests/androidx_test/src/main/res/layout/appcompat_activity_with_toolbar_menu.xml
new file mode 100644
index 0000000..fc61cc5
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/res/layout/appcompat_activity_with_toolbar_menu.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/layout"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+    <androidx.appcompat.widget.Toolbar
+        android:id="@+id/toolbar"
+        android:layout_height="wrap_content"
+        android:layout_width="match_parent"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/integration_tests/androidx_test/src/main/res/layout/espresso_activity.xml b/integration_tests/androidx_test/src/main/res/layout/espresso_activity.xml
new file mode 100644
index 0000000..716d4e0
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/res/layout/espresso_activity.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/layout"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <EditText
+    android:id="@+id/edit_text"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"/>
+
+  <Button
+    android:id="@+id/button"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"/>
+
+  <TextView
+      android:id="@+id/text_view"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"
+      android:text="Text View"/>
+
+  <TextView
+      android:id="@+id/text_view_positive_scale_x"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"
+      android:textScaleX="1.5"
+      android:text="Text View with positive textScaleX"/>
+
+  <TextView
+      android:id="@+id/text_view_negative_scale_x"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"
+      android:textScaleX="-1.5"
+      android:text="Text View with negative textScaleX"/>
+
+  <TextView
+      android:id="@+id/text_view_letter_spacing"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"
+      android:letterSpacing="0.05"
+      android:text="Text View with letterSpacing"/>
+
+  <EditText
+      android:id="@+id/edit_text_phone"
+      android:layout_height="wrap_content"
+      android:layout_width="wrap_content"
+      android:inputType="phone"
+      />
+
+</LinearLayout>
diff --git a/integration_tests/androidx_test/src/main/res/layout/espresso_scrolling_activity.xml b/integration_tests/androidx_test/src/main/res/layout/espresso_scrolling_activity.xml
new file mode 100644
index 0000000..73b6f48
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/res/layout/espresso_scrolling_activity.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="100dp"
+    android:id="@+id/scroll_view" >
+  <LinearLayout
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:orientation="vertical">
+    <!-- Spacer View -->
+    <View
+        android:layout_width="match_parent"
+        android:layout_height="60dp"
+        android:background="#FF0000FF"/>
+    <!-- Button View that is only partially visible -->
+    <Button
+        android:layout_width="match_parent"
+        android:layout_height="60dp"
+        android:id="@+id/button"
+        android:text="Click me!" />
+  </LinearLayout>
+</ScrollView>
\ No newline at end of file
diff --git a/integration_tests/androidx_test/src/main/res/menu/menu.xml b/integration_tests/androidx_test/src/main/res/menu/menu.xml
new file mode 100644
index 0000000..05ff42d
--- /dev/null
+++ b/integration_tests/androidx_test/src/main/res/menu/menu.xml
@@ -0,0 +1,8 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+  <item
+      android:id="@+id/menu_filter"
+      android:title="menu_title"
+
+      android:showAsAction="never" />
+</menu>
\ No newline at end of file
diff --git a/integration_tests/androidx_test/src/sharedTest/AndroidManifest-NoTestPackageActivities.xml b/integration_tests/androidx_test/src/sharedTest/AndroidManifest-NoTestPackageActivities.xml
new file mode 100644
index 0000000..c4f5e2b
--- /dev/null
+++ b/integration_tests/androidx_test/src/sharedTest/AndroidManifest-NoTestPackageActivities.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.axt">
+
+  <uses-sdk
+      android:minSdkVersion="14"
+      android:targetSdkVersion="27"/>
+
+  <application>
+    <activity android:name=".EspressoActivity" android:exported="true"/>
+  </application>
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric.integration.axt"/>
+
+</manifest>
diff --git a/integration_tests/androidx_test/src/sharedTest/AndroidManifest.xml b/integration_tests/androidx_test/src/sharedTest/AndroidManifest.xml
new file mode 100644
index 0000000..8aaff4d
--- /dev/null
+++ b/integration_tests/androidx_test/src/sharedTest/AndroidManifest.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.axt">
+
+  <application>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.ActivityTestRuleTest$TranscriptActivity"
+        android:exported="true"/>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.EspressoActivity"
+        android:exported="true"/>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.EspressoScrollingActivity"
+        android:exported="true"/>
+
+    <activity
+        android:name="org.robolectric.integrationtests.axt.ActivityScenarioTest$LifecycleOwnerActivity"
+        android:exported="true"/>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.ActivityScenarioTest$TranscriptActivity"
+        android:exported = "true"/>
+    <activity-alias
+        android:name="org.robolectric.integrationtests.axt.ActivityScenarioTestAlias"
+        android:targetActivity="org.robolectric.integrationtests.axt.ActivityScenarioTest$TranscriptActivity" />
+    <activity
+        android:name="org.robolectric.integrationtests.axt.IntentsTest$ResultCapturingActivity"
+        android:exported = "true"/>
+  </application>
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric.integration.axt"/>
+
+</manifest>
diff --git a/integration_tests/androidx_test/src/sharedTest/java/org/robolectric/integrationtests/axt/EspressoTest.java b/integration_tests/androidx_test/src/sharedTest/java/org/robolectric/integrationtests/axt/EspressoTest.java
new file mode 100644
index 0000000..494ce82
--- /dev/null
+++ b/integration_tests/androidx_test/src/sharedTest/java/org/robolectric/integrationtests/axt/EspressoTest.java
@@ -0,0 +1,216 @@
+package org.robolectric.integrationtests.axt;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
+import static androidx.test.espresso.action.ViewActions.pressKey;
+import static androidx.test.espresso.action.ViewActions.swipeUp;
+import static androidx.test.espresso.action.ViewActions.typeText;
+import static androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.KeyEvent;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.espresso.Espresso;
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.integration.axt.R;
+
+/** Simple tests to verify espresso APIs can be used on both Robolectric and device. */
+@RunWith(AndroidJUnit4.class)
+public final class EspressoTest {
+
+  @Rule
+  public ActivityScenarioRule<EspressoActivity> activityRule =
+      new ActivityScenarioRule<>(EspressoActivity.class);
+
+  @Test
+  public void onIdle_doesnt_block() {
+    Espresso.onIdle();
+  }
+
+  @Test
+  public void launchActivityAndFindView_ById() {
+    activityRule
+        .getScenario()
+        .onActivity(
+            activity -> {
+              EditText editText = activity.findViewById(R.id.edit_text);
+
+              assertThat(editText).isNotNull();
+              assertThat(editText.isEnabled()).isTrue();
+            });
+  }
+
+  /** Perform the equivalent of launchActivityAndFindView_ById except using espresso APIs */
+  @Test
+  public void launchActivityAndFindView_espresso() {
+    onView(withId(R.id.edit_text)).check(matches(isCompletelyDisplayed()));
+  }
+
+  /** Perform the 'traditional' mechanism of clicking a button in Robolectric using findViewById */
+  @Test
+  @UiThreadTest
+  public void buttonClick() {
+    activityRule
+        .getScenario()
+        .onActivity(
+            activity -> {
+              Button button = activity.findViewById(R.id.button);
+
+              button.performClick();
+
+              assertThat(activity.buttonClicked).isTrue();
+            });
+  }
+
+  /** Perform the equivalent of click except using espresso APIs */
+  @Test
+  public void buttonClick_espresso() {
+    // All methods within ActivityScenario are blocking calls, so the API requires us to run
+    // them in the instrumentation thread. But ActivityScenario ActivityAction runs on main
+    // thread, we should run Espresso checking in instrumentation thread.
+    AtomicReference<EspressoActivity> activityRef = new AtomicReference<>();
+    activityRule.getScenario().onActivity(activityRef::set);
+    onView(withId(R.id.button)).check(matches(isCompletelyDisplayed()));
+    onView(withId(R.id.button)).perform(click());
+    // If we have clicked the button of EspressoActivity, we can get correct Activity
+    // instance from ActivityScenario safely.
+    assertThat(activityRef.get()).isNotNull();
+    assertThat(activityRef.get().buttonClicked).isTrue();
+  }
+
+  /** Perform the 'traditional' mechanism of setting contents of a text view using findViewById */
+  @Test
+  @UiThreadTest
+  public void typeText_findView() {
+    activityRule
+        .getScenario()
+        .onActivity(
+            activity -> {
+              EditText editText = activity.findViewById(R.id.edit_text);
+              editText.setText("\"new TEXT!#$%&'*+-/=?^_`{|}~@robolectric.org");
+
+              assertThat(editText.getText().toString())
+                  .isEqualTo("\"new TEXT!#$%&'*+-/=?^_`{|}~@robolectric.org");
+            });
+  }
+
+  /** Perform the equivalent of setText except using espresso APIs */
+  @Test
+  public void typeText_espresso() {
+    onView(withId(R.id.edit_text))
+        .perform(typeText("\"new TEXT!#$%&'*+-/=?^_`{|}~@robolectric.org"));
+
+    onView(withId(R.id.edit_text))
+        .check(matches(withText("\"new TEXT!#$%&'*+-/=?^_`{|}~@robolectric.org")));
+  }
+
+  /** use typeText with a inputType phone */
+  @Test
+  public void typeText_phone() {
+    onView(withId(R.id.edit_text_phone)).perform(typeText("411"));
+
+    onView(withId(R.id.edit_text_phone)).check(matches(withText("411")));
+  }
+
+  @Test
+  public void textView() {
+    onView(withText("Text View"))
+        .check(
+            (view, noViewFoundException) -> {
+              assertThat(view.getWidth()).isGreaterThan(0);
+              assertThat(view.getHeight()).isGreaterThan(0);
+            });
+    onView(withText("Text View")).check(matches(isCompletelyDisplayed()));
+  }
+
+  @Test
+  public void textViewWithPositiveScaleX() {
+    onView(withId(R.id.text_view_positive_scale_x))
+        .check(
+            (view, noViewFoundException) -> {
+              TextView textView = (TextView) view;
+              float expectedTextScaleX = 1.5f;
+              assertThat(textView.getTextScaleX()).isEqualTo(expectedTextScaleX);
+              float scaledWidth = textView.getPaint().measureText(textView.getText().toString());
+              textView.setTextScaleX(1f);
+              float unscaledWidth = textView.getPaint().measureText(textView.getText().toString());
+              assertThat(scaledWidth).isGreaterThan(unscaledWidth);
+            });
+  }
+
+  @Test
+  public void textViewWithNegativeScaleX() {
+    onView(withId(R.id.text_view_negative_scale_x))
+        .check(
+            (view, noViewFoundException) -> {
+              TextView textView = (TextView) view;
+              assertThat(textView.getTextScaleX()).isEqualTo(-1.5f);
+              float scaledWidth = textView.getPaint().measureText(textView.getText().toString());
+              textView.setTextScaleX(1f);
+              float unscaledWidth = textView.getPaint().measureText(textView.getText().toString());
+              assertThat(scaledWidth).isLessThan(unscaledWidth);
+            });
+  }
+
+  @Config(minSdk = LOLLIPOP)
+  @SdkSuppress(minSdkVersion = LOLLIPOP)
+  @Test
+  public void textViewWithLetterSpacing() {
+    onView(withId(R.id.text_view_letter_spacing))
+        .check(
+            (view, noViewFoundException) -> {
+              TextView textView = (TextView) view;
+              assertThat(textView.getLetterSpacing()).isEqualTo(0.05f);
+            });
+  }
+
+  @Test
+  public void customActivityLabel() {
+    onView(withText("Activity Label")).check(matches(isCompletelyDisplayed()));
+  }
+
+  @Test
+  public void changeText_withCloseSoftKeyboard() {
+    // Type text and then press the button.
+    onView(withId(R.id.edit_text)).perform(typeText("anything"), closeSoftKeyboard());
+
+    // Check that the text was changed.
+    onView(withId(R.id.edit_text)).check(matches(withText("anything")));
+  }
+
+  @Test
+  public void changeText_addNewline() {
+    onView(withId(R.id.edit_text)).perform(typeText("Some text."));
+    onView(withId(R.id.edit_text)).perform(pressKey(KeyEvent.KEYCODE_ENTER));
+    onView(withId(R.id.edit_text)).perform(typeTextIntoFocusedView("Other text."));
+
+    onView(withId(R.id.edit_text)).check(matches(withText("Some text.\nOther text.")));
+  }
+
+  @Test
+  public void clickButton_after_swipeUp() {
+    try (ActivityScenario<EspressoScrollingActivity> activityScenario =
+        ActivityScenario.launch(EspressoScrollingActivity.class)) {
+      onView(withId(R.id.scroll_view)).perform(swipeUp());
+      onView(withId(R.id.button)).perform(click());
+      activityScenario.onActivity(action -> assertThat(action.buttonClicked).isTrue());
+    }
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/AndroidManifest-NoTestPackageActivities.xml b/integration_tests/androidx_test/src/test/AndroidManifest-NoTestPackageActivities.xml
new file mode 100644
index 0000000..c4f5e2b
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/AndroidManifest-NoTestPackageActivities.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.axt">
+
+  <uses-sdk
+      android:minSdkVersion="14"
+      android:targetSdkVersion="27"/>
+
+  <application>
+    <activity android:name=".EspressoActivity" android:exported="true"/>
+  </application>
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric.integration.axt"/>
+
+</manifest>
diff --git a/integration_tests/androidx_test/src/test/AndroidManifest.xml b/integration_tests/androidx_test/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..e79dbdb
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/AndroidManifest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.axt">
+
+  <uses-sdk
+      android:minSdkVersion="14"
+      android:targetSdkVersion="27"/>
+
+  <application>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.ActivityTestRuleTest$TranscriptActivity"
+        android:exported="true"/>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.EspressoActivity"
+        android:exported="true"/>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.EspressoScrollingActivity"
+        android:exported="true"/>
+
+    <activity
+        android:name="org.robolectric.integrationtests.axt.ActivityScenarioTest$LifecycleOwnerActivity"
+        android:exported="true"/>
+    <activity
+        android:name="org.robolectric.integrationtests.axt.ActivityScenarioTest$TranscriptActivity"
+        android:exported = "true"/>
+    <activity-alias
+        android:name="org.robolectric.integrationtests.axt.ActivityScenarioTestAlias"
+        android:targetActivity="org.robolectric.integrationtests.axt.ActivityScenarioTest$TranscriptActivity" />
+    <activity
+        android:name="org.robolectric.integrationtests.axt.IntentsTest$ResultCapturingActivity"
+        android:exported = "true"/>
+  </application>
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric.integration.axt"/>
+
+</manifest>
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java
new file mode 100644
index 0000000..41e61ac
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java
@@ -0,0 +1,241 @@
+package org.robolectric.integrationtests.axt;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.app.UiAutomation;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import androidx.appcompat.R;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.Lifecycle.State;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/**
+ * Integration tests for {@link ActivityScenario} that verify it behaves consistently on device and
+ * Robolectric.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ActivityScenarioTest {
+
+  private static final List<String> callbacks = new ArrayList<>();
+
+  public static class TranscriptActivity extends Activity {
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      callbacks.add("onCreate");
+    }
+
+    @Override
+    public void onStart() {
+      super.onStart();
+      callbacks.add("onStart");
+    }
+
+    @Override
+    public void onPostCreate(Bundle savedInstanceState) {
+      super.onPostCreate(savedInstanceState);
+      callbacks.add("onPostCreate");
+    }
+
+    @Override
+    public void onResume() {
+      super.onResume();
+      callbacks.add("onResume");
+    }
+
+    @Override
+    public void onPause() {
+      super.onPause();
+      callbacks.add("onPause");
+    }
+
+    @Override
+    public void onStop() {
+      super.onStop();
+      callbacks.add("onStop");
+    }
+
+    @Override
+    public void onRestart() {
+      super.onRestart();
+      callbacks.add("onRestart");
+    }
+
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+      callbacks.add("onDestroy");
+    }
+
+    @Override
+    public void onWindowFocusChanged(boolean hasFocus) {
+      super.onWindowFocusChanged(hasFocus);
+      callbacks.add("onWindowFocusChanged " + hasFocus);
+    }
+  }
+
+  public static class LifecycleOwnerActivity extends AppCompatActivity {
+    @Override
+    protected void onCreate(Bundle bundle) {
+      setTheme(R.style.Theme_AppCompat);
+      super.onCreate(bundle);
+    }
+  }
+
+  @Before
+  public void setUp() {
+    callbacks.clear();
+  }
+
+  @Test
+  public void launch_callbackSequence() {
+    ActivityScenario<TranscriptActivity> activityScenario =
+        ActivityScenario.launch(TranscriptActivity.class);
+    assertThat(activityScenario).isNotNull();
+    assertThat(callbacks)
+        .containsExactly(
+            "onCreate", "onStart", "onPostCreate", "onResume", "onWindowFocusChanged true");
+  }
+
+  @Test
+  public void launch_pauseAndResume_callbackSequence() {
+    ActivityScenario<TranscriptActivity> activityScenario =
+        ActivityScenario.launch(TranscriptActivity.class);
+    assertThat(activityScenario).isNotNull();
+    activityScenario.moveToState(State.STARTED);
+    activityScenario.moveToState(State.RESUMED);
+    assertThat(callbacks)
+        .containsExactly(
+            "onCreate", "onStart", "onPostCreate", "onResume", "onWindowFocusChanged true",
+            "onPause", "onResume");
+  }
+
+  @Test
+  public void launch_stopAndResume_callbackSequence() {
+      ActivityScenario<TranscriptActivity> activityScenario =
+          ActivityScenario.launch(TranscriptActivity.class);
+      assertThat(activityScenario).isNotNull();
+      activityScenario.moveToState(State.CREATED);
+      activityScenario.moveToState(State.RESUMED);
+      assertThat(callbacks)
+          .containsExactly(
+              "onCreate", "onStart", "onPostCreate", "onResume", "onWindowFocusChanged true",
+              "onPause", "onStop", "onRestart", "onStart", "onResume");
+  }
+
+  @Test
+  public void launchAlias_createTargetAndCallbackSequence() {
+    Context context = ApplicationProvider.getApplicationContext();
+    ActivityScenario<Activity> activityScenario =
+        ActivityScenario.launch(
+            new Intent()
+                .setClassName(
+                    context, "org.robolectric.integrationtests.axt.ActivityScenarioTestAlias"));
+
+    assertThat(activityScenario).isNotNull();
+    activityScenario.onActivity(
+        activity -> assertThat(activity).isInstanceOf(TranscriptActivity.class));
+    assertThat(callbacks)
+        .containsExactly(
+            "onCreate", "onStart", "onPostCreate", "onResume", "onWindowFocusChanged true");
+  }
+
+  @Test
+  public void launch_lifecycleOwnerActivity() {
+    ActivityScenario<LifecycleOwnerActivity> activityScenario =
+        ActivityScenario.launch(LifecycleOwnerActivity.class);
+    assertThat(activityScenario).isNotNull();
+    activityScenario.onActivity(
+        activity -> assertThat(activity.getLifecycle().getCurrentState()).isEqualTo(State.RESUMED));
+    activityScenario.moveToState(State.STARTED);
+    activityScenario.onActivity(
+        activity -> assertThat(activity.getLifecycle().getCurrentState()).isEqualTo(State.STARTED));
+    activityScenario.moveToState(State.CREATED);
+    activityScenario.onActivity(
+        activity -> assertThat(activity.getLifecycle().getCurrentState()).isEqualTo(State.CREATED));
+  }
+
+  @Test
+  public void recreate_retainFragmentHostingActivity() {
+    Fragment fragment = new Fragment();
+    fragment.setRetainInstance(true);
+    ActivityScenario<LifecycleOwnerActivity> activityScenario =
+        ActivityScenario.launch(LifecycleOwnerActivity.class);
+    assertThat(activityScenario).isNotNull();
+    activityScenario.onActivity(
+        activity -> {
+          activity
+              .getSupportFragmentManager()
+              .beginTransaction()
+              .add(android.R.id.content, fragment)
+              .commitNow();
+          assertThat(activity.getSupportFragmentManager().findFragmentById(android.R.id.content))
+              .isSameInstanceAs(fragment);
+        });
+    activityScenario.recreate();
+    activityScenario.onActivity(
+        activity ->
+            assertThat(activity.getSupportFragmentManager().findFragmentById(android.R.id.content))
+                .isSameInstanceAs(fragment));
+  }
+
+  @Test
+  public void recreate_nonRetainFragmentHostingActivity() {
+    Fragment fragment = new Fragment();
+    fragment.setRetainInstance(false);
+    ActivityScenario<LifecycleOwnerActivity> activityScenario =
+        ActivityScenario.launch(LifecycleOwnerActivity.class);
+    assertThat(activityScenario).isNotNull();
+    activityScenario.onActivity(
+        activity -> {
+          activity
+              .getSupportFragmentManager()
+              .beginTransaction()
+              .add(android.R.id.content, fragment)
+              .commitNow();
+          assertThat(activity.getSupportFragmentManager().findFragmentById(android.R.id.content))
+              .isSameInstanceAs(fragment);
+        });
+    activityScenario.recreate();
+    activityScenario.onActivity(
+        activity ->
+            assertThat(activity.getSupportFragmentManager().findFragmentById(android.R.id.content))
+                .isNotSameInstanceAs(fragment));
+  }
+
+  @Config(minSdk = JELLY_BEAN_MR2)
+  @Test
+  public void setRotation_recreatesActivity() {
+    UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+    try (ActivityScenario<?> scenario = ActivityScenario.launch(TranscriptActivity.class)) {
+      AtomicReference<Activity> originalActivity = new AtomicReference<>();
+      scenario.onActivity(originalActivity::set);
+
+      uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_90);
+
+      scenario.onActivity(
+          activity -> {
+            assertThat(activity.getResources().getConfiguration().orientation)
+                .isEqualTo(Configuration.ORIENTATION_LANDSCAPE);
+            assertThat(activity).isNotSameInstanceAs(originalActivity);
+          });
+    }
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityTestRuleTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityTestRuleTest.java
new file mode 100644
index 0000000..49ea7e9
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityTestRuleTest.java
@@ -0,0 +1,151 @@
+package org.robolectric.integrationtests.axt;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.rule.ActivityTestRule;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Integration tests for {@link ActivityTestRule} that verify it behaves consistently on device and
+ * Robolectric. */
+@RunWith(AndroidJUnit4.class)
+public class ActivityTestRuleTest {
+
+  private static final List<String> callbacks = new ArrayList<>();
+
+  @Rule
+  public ActivityTestRule<TranscriptActivity> rule =
+      new ActivityTestRule<TranscriptActivity>(TranscriptActivity.class, false, false) {
+        @Override
+        protected void beforeActivityLaunched() {
+          super.beforeActivityLaunched();
+          callbacks.add("beforeActivityLaunched");
+        }
+
+        @Override
+        protected void afterActivityLaunched() {
+          callbacks.add("afterActivityLaunched");
+          super.afterActivityLaunched();
+        }
+
+        @Override
+        protected void afterActivityFinished() {
+          callbacks.add("afterActivityFinished");
+          super.afterActivityFinished();
+        }
+      };
+
+  public static class TranscriptActivity extends Activity {
+    Bundle receivedBundle;
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      receivedBundle = savedInstanceState;
+      callbacks.add("onCreate");
+    }
+
+    @Override
+    public void onStart() {
+      super.onStart();
+     callbacks.add("onStart");
+    }
+
+    @Override
+    public void onResume() {
+      super.onResume();
+      callbacks.add("onResume");
+    }
+
+    @Override
+    public void onPause() {
+      super.onPause();
+      callbacks.add("onPause");
+    }
+
+    @Override
+    public void onStop() {
+      super.onStop();
+      callbacks.add("onStop");
+    }
+
+    @Override
+    public void onRestart() {
+      super.onRestart();
+      callbacks.add("onRestart");
+    }
+
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+      callbacks.add("onDestroy");
+    }
+  }
+
+  @Before
+  public void setUp() {
+    callbacks.clear();
+  }
+
+  @Test
+  public void launchActivity_callbackSequence() {
+    TranscriptActivity activity = rule.launchActivity(null);
+    assertThat(activity).isNotNull();
+    assertThat(callbacks)
+        .containsExactly(
+            "beforeActivityLaunched", "onCreate", "onStart", "onResume", "afterActivityLaunched");
+  }
+
+  /**
+   * Starting an activity with options is currently not supported, so check that received bundle is
+   * always null in both modes.
+   */
+  @Test
+  public void launchActivity_bundle() {
+    TranscriptActivity activity = rule.launchActivity(null);
+    assertThat(activity.receivedBundle).isNull();
+  }
+
+  @Test public void launchActivity_intentExtras() {
+    Intent intent = new Intent();
+    intent.putExtra("Key", "Value");
+
+    TranscriptActivity activity = rule.launchActivity(intent);
+
+    Intent activityIntent = activity.getIntent();
+    assertThat(activityIntent.getExtras()).isNotNull();
+    assertThat(activityIntent.getStringExtra("Key")).isEqualTo("Value");
+  }
+
+  @Test
+  public void finishActivity() {
+    rule.launchActivity(null);
+    callbacks.clear();
+    rule.finishActivity();
+
+    assertThat(callbacks).contains("afterActivityFinished");
+    // TODO: On-device this will also invoke onPause windowFocusChanged false
+    // need to track activity state and respond accordingly in robolectric
+  }
+
+  @Test
+  @Ignore // javadoc for ActivityTestRule#finishActivity is incorrect
+  public void finishActivity_notLaunched() {
+    try {
+      rule.finishActivity();
+      fail("exception not thrown");
+    } catch (IllegalStateException e) {
+      // expected
+    }
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java
new file mode 100644
index 0000000..f70c0c9
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java
@@ -0,0 +1,181 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.espresso.Espresso.onIdle;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import androidx.test.espresso.IdlingRegistry;
+import androidx.test.espresso.IdlingResource;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test Espresso IdlingResource support. */
+@RunWith(AndroidJUnit4.class)
+public final class EspressoIdlingResourceTest {
+  private final IdlingRegistry idlingRegistry = IdlingRegistry.getInstance();
+
+  private ExecutorService executor;
+
+  @Before
+  public void setup() {
+    executor = Executors.newSingleThreadExecutor();
+  }
+
+  @After
+  public void teardown() {
+    for (IdlingResource resource : idlingRegistry.getResources()) {
+      idlingRegistry.unregister(resource);
+    }
+    for (Looper looper : idlingRegistry.getLoopers()) {
+      idlingRegistry.unregisterLooperAsIdlingResource(looper);
+    }
+    executor.shutdown();
+  }
+
+  @Test
+  public void onIdle_idlingResourceIsIdle_doesntBlock() {
+    AtomicBoolean didCheckIdle = new AtomicBoolean();
+    idlingRegistry.register(
+        new NamedIdleResource("Test", /* isIdle= */ true) {
+          @Override
+          public boolean isIdleNow() {
+            didCheckIdle.set(true);
+            return super.isIdleNow();
+          }
+        });
+
+    onIdle();
+
+    assertThat(didCheckIdle.get()).isTrue();
+  }
+
+  @SuppressWarnings("FutureReturnValueIgnored")
+  @Test
+  public void onIdle_postToMainThread() {
+    idlingRegistry.register(
+        new NamedIdleResource("Test", /* isIdle= */ false) {
+          boolean submitted;
+
+          @Override
+          public boolean isIdleNow() {
+            if (!submitted) {
+              submitted = true;
+              executor.submit(this::postToMainLooper);
+            }
+            return super.isIdleNow();
+          }
+
+          void postToMainLooper() {
+            new Handler(Looper.getMainLooper()).post(() -> setIdle(true));
+          }
+        });
+
+    onIdle();
+  }
+
+  @SuppressWarnings("FutureReturnValueIgnored")
+  @Test
+  public void onIdle_cooperativeResources() {
+    NamedIdleResource a = new NamedIdleResource("A", /* isIdle= */ true);
+    NamedIdleResource b = new NamedIdleResource("B", /* isIdle= */ false);
+    NamedIdleResource c = new NamedIdleResource("C", /* isIdle= */ false);
+    idlingRegistry.register(a, b, c);
+    executor.submit(
+        () -> {
+          a.setIdle(false);
+          b.setIdle(true);
+          c.setIdle(false);
+          executor.submit(
+              () -> {
+                a.setIdle(true);
+                b.setIdle(false);
+                c.setIdle(false);
+                executor.submit(
+                    () -> {
+                      a.setIdle(true);
+                      b.setIdle(true);
+                      c.setIdle(true);
+                    });
+              });
+        });
+
+    onIdle();
+
+    assertThat(a.isIdleNow()).isTrue();
+    assertThat(b.isIdleNow()).isTrue();
+    assertThat(c.isIdleNow()).isTrue();
+  }
+
+  @SuppressWarnings("FutureReturnValueIgnored")
+  @Test
+  public void onIdle_looperIsIdle() throws Exception {
+    HandlerThread handlerThread = new HandlerThread("Test");
+    try {
+      handlerThread.start();
+      Handler handler = new Handler(handlerThread.getLooper());
+      CountDownLatch handlerStarted = new CountDownLatch(1);
+      CountDownLatch releaseHandler = new CountDownLatch(1);
+      handler.post(
+          () -> {
+            handlerStarted.countDown();
+            try {
+              releaseHandler.await();
+            } catch (InterruptedException e) {
+              // ignore
+            }
+          });
+      handlerStarted.await();
+      idlingRegistry.registerLooperAsIdlingResource(handlerThread.getLooper());
+
+      executor.submit(releaseHandler::countDown);
+      onIdle();
+
+      // onIdle should have blocked on the looper waiting on the release latch
+      assertThat(releaseHandler.getCount()).isEqualTo(0);
+    } finally {
+      handlerThread.quit();
+    }
+  }
+
+  private static class NamedIdleResource implements IdlingResource {
+    final String name;
+    final AtomicBoolean isIdle;
+    ResourceCallback callback;
+
+    NamedIdleResource(String name, boolean isIdle) {
+      this.name = name;
+      this.isIdle = new AtomicBoolean(isIdle);
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public boolean isIdleNow() {
+      return isIdle.get();
+    }
+
+    void setIdle(boolean isIdle) {
+      this.isIdle.set(isIdle);
+      if (isIdle && callback != null) {
+        callback.onTransitionToIdle();
+      }
+    }
+
+    @Override
+    public void registerIdleTransitionCallback(ResourceCallback callback) {
+      this.callback = callback;
+    }
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithMenuTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithMenuTest.java
new file mode 100644
index 0000000..aecda6e
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithMenuTest.java
@@ -0,0 +1,46 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.TextLayoutMode;
+import org.robolectric.annotation.TextLayoutMode.Mode;
+
+/** Test Espresso on Robolectric interoperability for menus. */
+@RunWith(AndroidJUnit4.class)
+@TextLayoutMode(Mode.REALISTIC)
+@LooperMode(PAUSED)
+public class EspressoWithMenuTest {
+
+  @Test
+  public void platformMenuClick() {
+    try (ActivityScenario<ActivityWithPlatformMenu> scenario =
+        ActivityScenario.launch(ActivityWithPlatformMenu.class)) {
+      openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext());
+      onView(withText("menu_title")).perform(click());
+
+      scenario.onActivity(activity -> assertThat(activity.menuClicked).isTrue());
+    }
+  }
+
+  @Test
+  public void appCompatMenuClick() {
+    try (ActivityScenario<ActivityWithAppCompatMenu> scenario =
+        ActivityScenario.launch(ActivityWithAppCompatMenu.class)) {
+      openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext());
+      onView(withText("menu_title")).perform(click());
+
+      scenario.onActivity(activity -> assertThat(activity.menuClicked).isTrue());
+    }
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithPausedLooperTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithPausedLooperTest.java
new file mode 100644
index 0000000..1a1bd0f
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithPausedLooperTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.espresso.Espresso;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.integration.axt.R;
+
+/** Verify Espresso usage with paused looper */
+@RunWith(AndroidJUnit4.class)
+public final class EspressoWithPausedLooperTest {
+
+  @Before
+  public void setUp() {
+    shadowMainLooper().pause();
+    ActivityScenario.launch(EspressoActivity.class);
+  }
+
+  @Test
+  public void launchActivity() {}
+
+  @Test
+  public void onIdle_doesnt_block() {
+    Espresso.onIdle();
+  }
+
+  /** Perform the equivalent of launchActivityAndFindView_ById except using espresso APIs */
+  @Test
+  public void launchActivityAndFindView_espresso() {
+    onView(withId(R.id.edit_text)).check(matches(isCompletelyDisplayed()));
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithSwitchCompatTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithSwitchCompatTest.java
new file mode 100644
index 0000000..13f8fc2
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithSwitchCompatTest.java
@@ -0,0 +1,25 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.TextLayoutMode;
+import org.robolectric.integration.axt.R;
+
+/** Tests Espresso on Activities with {@link androidx.appcompat.widget.SwitchCompat}. */
+@RunWith(AndroidJUnit4.class)
+@TextLayoutMode(TextLayoutMode.Mode.REALISTIC)
+public class EspressoWithSwitchCompatTest {
+  @Test
+  public void switchCompatTest() {
+    ActivityScenario.launch(ActivityWithSwitchCompat.class);
+    onView(withId(R.id.switch_compat_2)).check(matches(isCompletelyDisplayed())).perform(click());
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithToolbarMenuTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithToolbarMenuTest.java
new file mode 100644
index 0000000..7025be5
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithToolbarMenuTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.TextLayoutMode;
+import org.robolectric.annotation.TextLayoutMode.Mode;
+import org.robolectric.shadows.ShadowViewConfiguration;
+
+/** Test Espresso on Robolectric interoperability for toolbar menus. */
+@RunWith(AndroidJUnit4.class)
+@TextLayoutMode(Mode.REALISTIC)
+@LooperMode(PAUSED)
+public class EspressoWithToolbarMenuTest {
+  @Test
+  public void appCompatToolbarMenuClick() {
+    ShadowViewConfiguration.setHasPermanentMenuKey(false);
+    try (ActivityScenario<AppCompatActivityWithToolbarMenu> scenario =
+        ActivityScenario.launch(AppCompatActivityWithToolbarMenu.class)) {
+      openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext());
+      onView(withText("menu_title")).perform(click());
+
+      scenario.onActivity(activity -> assertThat(activity.menuClicked).isTrue());
+    }
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithWindowLayersTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithWindowLayersTest.java
new file mode 100644
index 0000000..50b3b8e
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithWindowLayersTest.java
@@ -0,0 +1,206 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.core.app.ActivityScenario.launch;
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.typeText;
+import static androidx.test.espresso.matcher.RootMatchers.isDialog;
+import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.AlertDialog;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.view.Gravity;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.PopupWindow;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.action.CoordinatesProvider;
+import androidx.test.espresso.action.GeneralClickAction;
+import androidx.test.espresso.action.GeneralLocation;
+import androidx.test.espresso.action.Press;
+import androidx.test.espresso.action.Tap;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.integration.axt.R;
+
+/** Test Espresso on Robolectric interoperability for event dispatch with various window flags. */
+@RunWith(AndroidJUnit4.class)
+public class EspressoWithWindowLayersTest {
+  private static final String TEXT = "Hello World";
+
+  private static final int FOCUSABLE = 1;
+  private static final int TOUCHABLE = 2;
+  private static final int TOUCH_MODAL = 4;
+  private static final int TOUCH_OUTSIDE = 8;
+
+  private boolean popupTouchOutside;
+
+  @Test
+  public void click_notTouchablePopupOverButton_isClicked() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.button, FOCUSABLE);
+
+      onView(isRoot()).perform(clickAtLocation(centerOf(scenario, R.id.button)));
+
+      scenario.onActivity(activity -> assertThat(activity.buttonClicked).isTrue());
+    }
+  }
+
+  @Test
+  public void click_touchablePopupOverButton_isNotClicked() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.button, FOCUSABLE | TOUCHABLE);
+
+      onView(isRoot()).perform(clickAtLocation(centerOf(scenario, R.id.button)));
+
+      scenario.onActivity(activity -> assertThat(activity.buttonClicked).isFalse());
+    }
+  }
+
+  @Test
+  public void click_touchablePopupNotOverButton_isClicked() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.edit_text, FOCUSABLE | TOUCHABLE);
+
+      onView(isRoot()).perform(clickAtLocation(centerOf(scenario, R.id.button)));
+
+      scenario.onActivity(activity -> assertThat(activity.buttonClicked).isTrue());
+    }
+  }
+
+  @Test
+  public void click_touchModalPopupNotOverButton_isNotClicked() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.edit_text, FOCUSABLE | TOUCHABLE | TOUCH_MODAL);
+
+      onView(isRoot()).perform(clickAtLocation(centerOf(scenario, R.id.button)));
+
+      scenario.onActivity(activity -> assertThat(activity.buttonClicked).isFalse());
+    }
+  }
+
+  @Test
+  public void click_touchOutsidePopupNotOverButton_isClicked() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.edit_text, FOCUSABLE | TOUCHABLE | TOUCH_OUTSIDE);
+
+      onView(isRoot()).perform(clickAtLocation(centerOf(scenario, R.id.button)));
+
+      scenario.onActivity(activity -> assertThat(activity.buttonClicked).isTrue());
+      assertThat(popupTouchOutside).isTrue();
+    }
+  }
+
+  @Test
+  public void click_twoDialogs_clicksOnTopMost() {
+    AtomicBoolean clicked = new AtomicBoolean();
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      scenario.onActivity(
+          activity -> {
+            new AlertDialog.Builder(activity)
+                .setPositiveButton("Hello", (dialog, which) -> {})
+                .create()
+                .show();
+            new AlertDialog.Builder(activity)
+                .setPositiveButton("Hello", (dialog, which) -> clicked.set(true))
+                .create()
+                .show();
+          });
+
+      onView(withText("Hello")).inRoot(isDialog()).perform(click());
+
+      assertThat(clicked.get()).isTrue();
+    }
+  }
+
+  @Test
+  public void typeText_focusablePopupWindow_textIsNotTyped() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.button, FOCUSABLE);
+
+      onView(withId(R.id.edit_text)).perform(typeText(TEXT));
+
+      scenario.onActivity(
+          activity -> assertThat(activity.editText.getText().toString()).isNotEqualTo(TEXT));
+    }
+  }
+
+  @Test
+  public void typeText_notFocusablePopupWindow_textIsTyped() {
+    try (ActivityScenario<EspressoActivity> scenario = launch(EspressoActivity.class)) {
+      showPopupOver(scenario, R.id.button, /* flags= */ 0);
+
+      onView(withId(R.id.edit_text)).perform(typeText(TEXT));
+
+      scenario.onActivity(
+          activity -> assertThat(activity.editText.getText().toString()).isEqualTo(TEXT));
+    }
+  }
+
+  /**
+   * Shows a popup window entirely occluding the view identified by the view id in the activity. The
+   * popup window will be cleared of its touch and focus flags so they reflect the flags configured.
+   */
+  private void showPopupOver(ActivityScenario<EspressoActivity> scenario, int viewId, int flags) {
+    scenario.onActivity(
+        activity -> {
+          View view = activity.findViewById(viewId);
+          int[] viewLocation = new int[2];
+          view.getLocationOnScreen(viewLocation);
+
+          // Create an edit text with the same id as the edit text in EspressoActivity so we can
+          // test that the correct window receives the text input.
+          EditText popupContentView = new EditText(view.getContext());
+          popupContentView.setId(R.id.edit_text);
+
+          PopupWindow popup = new PopupWindow(popupContentView, view.getWidth(), view.getHeight());
+          popup.setFocusable((flags & FOCUSABLE) != 0);
+          popup.setTouchable((flags & TOUCHABLE) != 0);
+          popup.setTouchModal((flags & TOUCH_MODAL) != 0);
+          popup.setOutsideTouchable((flags & TOUCH_OUTSIDE) != 0);
+          // On sdk <=22 the touch interceptor is only used if the popup has a background configured
+          popup.setBackgroundDrawable(new ColorDrawable(Color.RED));
+          popup.setTouchInterceptor(
+              (v, e) -> {
+                if (e.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
+                  popupTouchOutside = true;
+                }
+                return true;
+              });
+          popup.showAtLocation(view, Gravity.LEFT | Gravity.TOP, viewLocation[0], viewLocation[1]);
+        });
+  }
+
+  /** Performs a click at the location from the coordinates provider (ignoring the matched view). */
+  private static ViewAction clickAtLocation(CoordinatesProvider coordinatesProvider) {
+    return new GeneralClickAction(
+        Tap.SINGLE,
+        coordinatesProvider,
+        Press.FINGER,
+        InputDevice.SOURCE_UNKNOWN,
+        MotionEvent.BUTTON_PRIMARY);
+  }
+
+  /** Returns coordinates at the center of the view. */
+  private static CoordinatesProvider centerOf(
+      ActivityScenario<EspressoActivity> scenario, int viewId) {
+    return v -> {
+      AtomicReference<float[]> result = new AtomicReference<>();
+      scenario.onActivity(
+          activity ->
+              result.set(
+                  GeneralLocation.CENTER.calculateCoordinates(activity.findViewById(viewId))));
+      return result.get();
+    };
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/FragmentScenarioTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/FragmentScenarioTest.java
new file mode 100644
index 0000000..64c8fe0
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/FragmentScenarioTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.integrationtests.axt;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.testing.FragmentScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link FragmentScenario} in Robolectric. */
+@RunWith(AndroidJUnit4.class)
+public class FragmentScenarioTest {
+
+  @Test
+  public void launchFragment() {
+    final AtomicReference<Fragment> loadedFragment = new AtomicReference<>();
+    FragmentScenario.launch(Fragment.class).onFragment(loadedFragment::set);
+    assertThat(loadedFragment.get()).isNotNull();
+  }
+
+  /**
+   * This is a stress test to see if Robolectric instrumentation supports Kotlin-compiled bytecode.
+   * There have been some issues in the past with Robolectric instrumentation running on AndroidX
+   * code, particularly constructors. If this test breaks, please add `@Ignore` and file an issue.
+   */
+  @Test
+  @Config(instrumentedPackages = "androidx.")
+  public void launchFragmentInstrumented() {
+    final AtomicReference<Fragment> loadedFragment = new AtomicReference<>();
+    FragmentScenario.launch(Fragment.class).onFragment(loadedFragment::set);
+    assertThat(loadedFragment.get()).isNotNull();
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/GrantPermissionRuleTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/GrantPermissionRuleTest.java
new file mode 100644
index 0000000..e3b0e2a
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/GrantPermissionRuleTest.java
@@ -0,0 +1,35 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.Manifest.permission;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Process;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.rule.GrantPermissionRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Integration tests for {@link androidx.test.rule.GrantPermissionRule} that verify it behaves
+ * consistently on device and Robolectric.
+ */
+@RunWith(AndroidJUnit4.class)
+public class GrantPermissionRuleTest {
+
+  @Rule
+  public final GrantPermissionRule rule =
+      GrantPermissionRule.grant(android.Manifest.permission.READ_CONTACTS);
+
+  @Test
+  public void some_test_with_permissions() {
+    Context context = getApplicationContext();
+    assertThat(context.checkPermission(permission.READ_CONTACTS, Process.myPid(), Process.myUid()))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+    assertThat(context.checkPermission(permission.WRITE_CONTACTS, Process.myPid(), Process.myUid()))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/InstrumentationRegistryTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/InstrumentationRegistryTest.java
new file mode 100644
index 0000000..285fac0
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/InstrumentationRegistryTest.java
@@ -0,0 +1,60 @@
+package org.robolectric.integrationtests.axt;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Instrumentation;
+import android.content.Context;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** {@link InstrumentationRegistry} tests. */
+@RunWith(AndroidJUnit4.class)
+public class InstrumentationRegistryTest {
+
+  private static Instrumentation priorInstrumentation = null;
+  private static Context priorContext = null;
+
+  @Test
+  public void getArguments() {
+    assertThat(InstrumentationRegistry.getArguments()).isNotNull();
+  }
+
+  @Test
+  public void getInstrumentation() {
+    assertThat(InstrumentationRegistry.getInstrumentation()).isNotNull();
+  }
+
+  @Test
+  public void getTargetContext() {
+    assertThat(InstrumentationRegistry.getInstrumentation().getTargetContext()).isNotNull();
+    assertThat(InstrumentationRegistry.getInstrumentation().getContext()).isNotNull();
+  }
+
+  @Test
+  public void uniqueInstancesPerTest() {
+    checkInstances();
+  }
+
+  @Test
+  public void uniqueInstancesPerTest2() {
+    checkInstances();
+  }
+
+  /**
+   * Verifies that each test gets a new Instrumentation and Context, by comparing against instances
+   * stored by prior test.
+   */
+  private void checkInstances() {
+    if (priorInstrumentation == null) {
+      priorInstrumentation = InstrumentationRegistry.getInstrumentation();
+      priorContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+    } else {
+      assertThat(priorInstrumentation).isNotEqualTo(InstrumentationRegistry.getInstrumentation());
+      assertThat(priorContext)
+          .isNotEqualTo(InstrumentationRegistry.getInstrumentation().getTargetContext());
+    }
+  }
+
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/IntentsTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/IntentsTest.java
new file mode 100644
index 0000000..dd425dc
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/IntentsTest.java
@@ -0,0 +1,205 @@
+package org.robolectric.integrationtests.axt;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static androidx.test.espresso.intent.Intents.getIntents;
+import static androidx.test.espresso.intent.Intents.intending;
+import static androidx.test.espresso.intent.matcher.ComponentNameMatchers.hasClassName;
+import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction;
+import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent;
+import static androidx.test.ext.truth.content.IntentCorrespondences.action;
+import static androidx.test.ext.truth.content.IntentCorrespondences.all;
+import static androidx.test.ext.truth.content.IntentCorrespondences.data;
+import static androidx.test.ext.truth.content.IntentSubject.assertThat;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.Activity;
+import android.app.Instrumentation.ActivityResult;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.espresso.intent.Intents;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Iterables;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Integration tests for using androidx.test's espresso intents API on Robolectric. */
+@RunWith(AndroidJUnit4.class)
+public class IntentsTest {
+
+  @Before
+  public void setUp() {
+    Intents.init();
+  }
+
+  @After
+  public void tearDown() {
+    Intents.release();
+  }
+
+  @Test
+  public void testNoIntents() {
+    Intents.assertNoUnverifiedIntents();
+  }
+
+  @Test
+  public void testIntendedFailEmpty() {
+    assertThrows(
+        AssertionError.class, () -> Intents.intended(org.hamcrest.Matchers.any(Intent.class)));
+  }
+
+  @Test
+  public void testIntendedSuccess() {
+    Intent i = new Intent();
+    i.setAction(Intent.ACTION_VIEW);
+    i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    getApplicationContext().startActivity(i);
+    Intents.intended(hasAction(Intent.ACTION_VIEW));
+  }
+
+  @Test
+  public void testIntendedNotMatching() {
+    Intent i = new Intent();
+    i.setAction(Intent.ACTION_VIEW);
+    i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    getApplicationContext().startActivity(i);
+    assertThrows(
+        AssertionError.class,
+        () -> Intents.intended(hasAction(Intent.ACTION_AIRPLANE_MODE_CHANGED)));
+  }
+
+  /**
+   * Variant of testIntendedSuccess that uses truth APIs.
+   *
+   * <p>In this form the test verifies that only a single intent was sent.
+   */
+  @Test
+  public void testIntendedSuccess_truth() {
+    Intent i = new Intent();
+    i.setAction(Intent.ACTION_VIEW);
+    i.putExtra("ignoreextra", "");
+    i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    getApplicationContext().startActivity(i);
+    assertThat(Iterables.getOnlyElement(getIntents())).hasAction(Intent.ACTION_VIEW);
+  }
+
+  /**
+   * Variant of testIntendedSuccess that uses truth APIs.
+   *
+   * <p>This is a more flexible/lenient variant of {@link #testIntendedSuccess_truth} that handles
+   * cases where other intents might have been sent.
+   */
+  @Test
+  public void testIntendedSuccess_truthCorrespondence() {
+    Intent i = new Intent();
+    i.setAction(Intent.ACTION_VIEW);
+    i.putExtra("ignoreextra", "");
+    i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    getApplicationContext().startActivity(i);
+    Intent alsoSentIntentButDontCare = new Intent(Intent.ACTION_MAIN);
+    alsoSentIntentButDontCare.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+    getApplicationContext().startActivity(alsoSentIntentButDontCare);
+    assertThat(getIntents())
+        .comparingElementsUsing(action())
+        .contains(new Intent(Intent.ACTION_VIEW));
+  }
+
+  /** Variant of testIntendedSuccess_truthCorrespondence that uses chained Correspondences. */
+  @Test
+  public void testIntendedSuccess_truthChainedCorrespondence() {
+    Intent i = new Intent();
+    i.setAction(Intent.ACTION_VIEW);
+    i.putExtra("ignoreextra", "");
+    i.setData(Uri.parse("http://robolectric.org"));
+    i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    getApplicationContext().startActivity(i);
+    Intent alsoSentIntentButNotMatching = new Intent(Intent.ACTION_VIEW);
+    alsoSentIntentButNotMatching.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    getApplicationContext().startActivity(alsoSentIntentButNotMatching);
+
+    Intent expectedIntent =
+        new Intent(Intent.ACTION_VIEW).setData(Uri.parse("http://robolectric.org"));
+    assertThat(getIntents()).comparingElementsUsing(all(action(), data())).contains(expectedIntent);
+  }
+
+  /** Activity that captures calls to {#onActivityResult() } */
+  public static class ResultCapturingActivity extends Activity {
+
+    private ActivityResult activityResult;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+    }
+
+    @Override
+    protected void onStart() {
+      super.onStart();
+    }
+
+    @Override
+    protected void onResume() {
+      super.onResume();
+    }
+
+    @Override
+    protected void onDestroy() {
+      super.onDestroy();
+    }
+
+    @Override
+    protected void onPause() {
+      super.onPause();
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+      super.onActivityResult(requestCode, resultCode, data);
+      activityResult = new ActivityResult(resultCode, data);
+    }
+  }
+
+  /** Dummy activity whose calls we intent to we're stubbing out. */
+  public static class DummyActivity extends Activity {}
+
+  @Test
+  public void intending_callsOnActivityResult() {
+    intending(hasComponent(hasClassName(DummyActivity.class.getName())))
+        .respondWith(new ActivityResult(Activity.RESULT_OK, new Intent().putExtra("key", 123)));
+
+    ActivityScenario<ResultCapturingActivity> activityScenario =
+        ActivityScenario.launch(ResultCapturingActivity.class);
+
+    activityScenario.onActivity(
+        activity -> activity.startActivityForResult(new Intent(activity, DummyActivity.class), 0));
+
+    activityScenario.onActivity(
+        activity -> {
+          assertThat(activity.activityResult.getResultCode()).isEqualTo(Activity.RESULT_OK);
+          assertThat(activity.activityResult.getResultData()).extras().containsKey("key");
+        });
+  }
+
+  @Test
+  public void browserIntentNotResolved() {
+    Intent browserIntent = new Intent(Intent.ACTION_VIEW);
+    browserIntent.setData(Uri.parse("http://www.robolectric.org"));
+    browserIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+    Intents.intending(hasAction(Intent.ACTION_VIEW))
+        .respondWithFunction(
+            intent -> {
+              throw new ActivityNotFoundException();
+            });
+
+    assertThrows(
+        ActivityNotFoundException.class,
+        () -> getApplicationContext().startActivity(browserIntent));
+  }
+}
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/robolectric.properties b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/robolectric.properties
new file mode 100644
index 0000000..5b66826
--- /dev/null
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/robolectric.properties
@@ -0,0 +1,3 @@
+sdk=ALL_SDKS
+# make Robolectric match the emulator used
+qualifiers=w480dp-h800dp
diff --git a/integration_tests/compat-target28/build.gradle b/integration_tests/compat-target28/build.gradle
new file mode 100644
index 0000000..6188f42
--- /dev/null
+++ b/integration_tests/compat-target28/build.gradle
@@ -0,0 +1,39 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+apply plugin: 'kotlin-android'
+apply plugin: "com.diffplug.spotless"
+
+spotless {
+    kotlin {
+        target '**/*.kt'
+        ktfmt('0.34').googleStyle()
+    }
+}
+
+android {
+    compileSdk 28
+
+    defaultConfig {
+        minSdk 16
+        // We must keep targetSdk to 28 for compatibility testing purpose
+        targetSdk 28
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+
+    testOptions.unitTests.includeAndroidResources true
+}
+
+dependencies {
+    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+
+    testImplementation project(path: ':testapp')
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation "com.google.truth:truth:$truthVersion"
+}
diff --git a/integration_tests/compat-target28/src/main/AndroidManifest.xml b/integration_tests/compat-target28/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..daca432
--- /dev/null
+++ b/integration_tests/compat-target28/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest package="org.robolectric.integrationtests.compattarget29">
+    <application />
+</manifest>
diff --git a/integration_tests/compat-target28/src/test/java/org/robolectric/integration/compat/target28/JavaClassResolveCompatibilityTest.java b/integration_tests/compat-target28/src/test/java/org/robolectric/integration/compat/target28/JavaClassResolveCompatibilityTest.java
new file mode 100644
index 0000000..9c86cff
--- /dev/null
+++ b/integration_tests/compat-target28/src/test/java/org/robolectric/integration/compat/target28/JavaClassResolveCompatibilityTest.java
@@ -0,0 +1,28 @@
+package org.robolectric.integration.compat.target28;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/**
+ * Test class for Java's class resolve compatibility test. We must keep it with Java instead of
+ * converting it to Kotlin, because Kotlin has different behavior than Java without any error.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class JavaClassResolveCompatibilityTest {
+  @Test
+  public void sdkIs28() {
+    assertThat(Build.VERSION.SDK_INT).isEqualTo(Build.VERSION_CODES.P);
+  }
+
+  @Test
+  public void shadowOf() {
+    // https://github.com/robolectric/robolectric/issues/7095
+    // Enable this assertion when resolving all shadowOf compatibility problem
+    // assertThat(Shadows.shadowOf((Application) ApplicationProvider.getApplicationContext()))
+    //     .isNotNull();
+  }
+}
diff --git a/integration_tests/compat-target28/src/test/java/org/robolectric/integration/compat/target28/NormalCompatibilityTest.kt b/integration_tests/compat-target28/src/test/java/org/robolectric/integration/compat/target28/NormalCompatibilityTest.kt
new file mode 100644
index 0000000..4605b9a
--- /dev/null
+++ b/integration_tests/compat-target28/src/test/java/org/robolectric/integration/compat/target28/NormalCompatibilityTest.kt
@@ -0,0 +1,38 @@
+package org.robolectric.integration.compat.target28
+
+import android.content.Context
+import android.os.Build
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.testapp.TestActivity
+
+@RunWith(RobolectricTestRunner::class)
+class NormalCompatibilityTest {
+  private val application = RuntimeEnvironment.getApplication()
+
+  @Test
+  fun `Environment SDK is 28`() {
+    assertThat(Build.VERSION.SDK_INT).isEqualTo(Build.VERSION_CODES.P)
+  }
+
+  @Test
+  fun `Initialize LocationManager succeed`() {
+    val locationManager = application.getSystemService(Context.LOCATION_SERVICE)
+    assertThat(locationManager).isNotNull()
+  }
+
+  @Test
+  fun `Initialize AppOpsManager succeed`() {
+    val appOpsManager = application.getSystemService(Context.APP_OPS_SERVICE)
+    assertThat(appOpsManager).isNotNull()
+  }
+
+  @Test
+  fun `Initialize Activity succeed`() {
+    Robolectric.setupActivity(TestActivity::class.java)
+  }
+}
diff --git a/integration_tests/compat-target28/src/test/resources/robolectric.properties b/integration_tests/compat-target28/src/test/resources/robolectric.properties
new file mode 100644
index 0000000..89a6c8b
--- /dev/null
+++ b/integration_tests/compat-target28/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28
\ No newline at end of file
diff --git a/integration_tests/ctesque/AndroidManifest.xml b/integration_tests/ctesque/AndroidManifest.xml
new file mode 100644
index 0000000..29dc46a
--- /dev/null
+++ b/integration_tests/ctesque/AndroidManifest.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for instrumentation tests
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.ctesque">
+  <uses-sdk
+      android:minSdkVersion="16"
+      android:targetSdkVersion="27" />
+
+  <application />
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric.testapp">
+  </instrumentation>
+
+</manifest>
diff --git a/integration_tests/ctesque/build.gradle b/integration_tests/ctesque/build.gradle
new file mode 100644
index 0000000..7d88d3d
--- /dev/null
+++ b/integration_tests/ctesque/build.gradle
@@ -0,0 +1,70 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+import org.robolectric.gradle.GradleManagedDevicePlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+apply plugin: GradleManagedDevicePlugin
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility 1.8
+        targetCompatibility 1.8
+    }
+
+    aaptOptions {
+        noCompress 'txt'
+    }
+
+    sourceSets {
+        String sharedTestDir = 'src/sharedTest/'
+        String sharedTestSourceDir = sharedTestDir + 'java'
+        String sharedTestResourceDir = sharedTestDir + 'resources'
+        test.resources.srcDirs += sharedTestResourceDir
+        test.java.srcDirs += sharedTestSourceDir
+        androidTest.resources.srcDirs += sharedTestResourceDir
+        androidTest.java.srcDirs += sharedTestSourceDir
+    }
+}
+
+dependencies {
+    implementation project(':testapp')
+
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation("androidx.test:monitor:$axtMonitorVersion")
+    testImplementation("androidx.test:runner:$axtRunnerVersion")
+    testImplementation("androidx.test:rules:$axtRulesVersion")
+    testImplementation("androidx.test.ext:junit:$axtJunitVersion")
+    testImplementation("androidx.test.ext:truth:$axtTruthVersion")
+    testImplementation("androidx.test:core:$axtCoreVersion")
+    testImplementation("com.google.truth:truth:${truthVersion}")
+    testImplementation("com.google.guava:guava:$guavaJREVersion")
+
+    // Testing dependencies
+    androidTestImplementation project(':shadowapi')
+    androidTestImplementation("androidx.test:monitor:$axtMonitorVersion")
+    androidTestImplementation("androidx.test:runner:$axtRunnerVersion")
+    androidTestImplementation("androidx.test:rules:$axtRulesVersion")
+    androidTestImplementation("androidx.test.ext:junit:$axtJunitVersion")
+    androidTestImplementation("androidx.test.ext:truth:$axtTruthVersion")
+    androidTestImplementation("com.google.truth:truth:${truthVersion}")
+    androidTestImplementation("com.google.guava:guava:$guavaJREVersion")
+}
diff --git a/integration_tests/ctesque/src/androidTest/java/android/app/ActivityInstrTest.java b/integration_tests/ctesque/src/androidTest/java/android/app/ActivityInstrTest.java
new file mode 100644
index 0000000..65e0427
--- /dev/null
+++ b/integration_tests/ctesque/src/androidTest/java/android/app/ActivityInstrTest.java
@@ -0,0 +1,81 @@
+package android.app;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.drawable.ColorDrawable;
+import android.widget.Button;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.ActivityWithAnotherTheme;
+import org.robolectric.testapp.ActivityWithoutTheme;
+import org.robolectric.testapp.R;
+
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class ActivityInstrTest {
+
+  @Before
+  public void setUp() {
+    ActivityWithAnotherTheme.setThemeBeforeContentView = null;
+  }
+
+  @Test
+  public void whenSetOnActivityInManifest_activityGetsThemeFromActivityInManifest() {
+    try (ActivityScenario<ActivityWithAnotherTheme> scenario =
+        ActivityScenario.launch(ActivityWithAnotherTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xffff0000);
+          });
+    }
+  }
+
+  @Test
+  public void
+      whenExplicitlySetOnActivity_afterSetContentView_activityGetsThemeFromActivityInManifest() {
+    try (ActivityScenario<ActivityWithAnotherTheme> scenario =
+        ActivityScenario.launch(ActivityWithAnotherTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            activity.setTheme(R.style.Theme_Robolectric);
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xffff0000);
+          });
+    }
+  }
+
+  @Test
+  public void whenExplicitlySetOnActivity_beforeSetContentView_activityUsesNewTheme() {
+    ActivityWithAnotherTheme.setThemeBeforeContentView = R.style.Theme_Robolectric;
+    try (ActivityScenario<ActivityWithAnotherTheme> scenario =
+        ActivityScenario.launch(ActivityWithAnotherTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xff00ff00);
+          });
+    }
+  }
+
+  @Test
+  public void whenNotSetOnActivityInManifest_activityGetsThemeFromApplicationInManifest() {
+    try (ActivityScenario<ActivityWithoutTheme> scenario =
+        ActivityScenario.launch(ActivityWithoutTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xff00ff00);
+          });
+    }
+  }
+
+}
diff --git a/integration_tests/ctesque/src/main/AndroidManifest.xml b/integration_tests/ctesque/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ccca6cb
--- /dev/null
+++ b/integration_tests/ctesque/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for ctesque tests
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.ctesque">
+
+  <application />
+</manifest>
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/app/ActivityTest.java b/integration_tests/ctesque/src/sharedTest/java/android/app/ActivityTest.java
new file mode 100644
index 0000000..71d404c
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/app/ActivityTest.java
@@ -0,0 +1,80 @@
+package android.app;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.drawable.ColorDrawable;
+import android.widget.Button;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.ActivityWithAnotherTheme;
+import org.robolectric.testapp.ActivityWithoutTheme;
+import org.robolectric.testapp.R;
+
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class ActivityTest {
+
+  @Before
+  public void setUp() throws Exception {
+    ActivityWithAnotherTheme.setThemeBeforeContentView = null;
+  }
+
+  @Test
+  public void whenSetOnActivityInManifest_activityGetsThemeFromActivityInManifest() {
+    try (ActivityScenario<ActivityWithAnotherTheme> scenario =
+        ActivityScenario.launch(ActivityWithAnotherTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xffff0000);
+          });
+    }
+  }
+
+  @Test
+  public void
+      whenExplicitlySetOnActivity_afterSetContentView_activityGetsThemeFromActivityInManifest() {
+    try (ActivityScenario<ActivityWithAnotherTheme> scenario =
+        ActivityScenario.launch(ActivityWithAnotherTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            activity.setTheme(R.style.Theme_Robolectric);
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xffff0000);
+          });
+    }
+  }
+
+  @Test
+  public void whenExplicitlySetOnActivity_beforeSetContentView_activityUsesNewTheme() {
+    ActivityWithAnotherTheme.setThemeBeforeContentView = R.style.Theme_Robolectric;
+    try (ActivityScenario<ActivityWithAnotherTheme> scenario =
+        ActivityScenario.launch(ActivityWithAnotherTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xff00ff00);
+          });
+    }
+  }
+
+  @Test
+  public void whenNotSetOnActivityInManifest_activityGetsThemeFromApplicationInManifest() {
+    try (ActivityScenario<ActivityWithoutTheme> scenario =
+        ActivityScenario.launch(ActivityWithoutTheme.class)) {
+      scenario.onActivity(
+          activity -> {
+            Button theButton = activity.findViewById(R.id.button);
+            ColorDrawable background = (ColorDrawable) theButton.getBackground();
+            assertThat(background.getColor()).isEqualTo(0xff00ff00);
+          });
+    }
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/app/InstrumentationTest.java b/integration_tests/ctesque/src/sharedTest/java/android/app/InstrumentationTest.java
new file mode 100644
index 0000000..12c992f
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/app/InstrumentationTest.java
@@ -0,0 +1,51 @@
+package android.app;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/**
+ * Tests to verify android.app.Instrumentation APIs behave consistently between Robolectric and
+ * device.
+ */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+@LooperMode(PAUSED)
+public final class InstrumentationTest {
+
+  /**
+   * Verify that runOnMainSync main looper synchronization is consistent between on device and
+   * robolectric.
+   */
+  @Test
+  public void runOnMainSync() {
+    final List<String> events = new ArrayList<>();
+    Handler mainHandler = new Handler(Looper.getMainLooper());
+
+    mainHandler.post(() -> events.add("before runOnMainSync"));
+    getInstrumentation()
+        .runOnMainSync(
+            new Runnable() {
+              @Override
+              public void run() {
+                events.add("in runOnMainSync");
+                // as expected, on device tests become flaky and fail deterministically on
+                // Robolectric with this line, as runOnMainSync does not drain the main looper
+                // after runnable executes
+                // mainHandler.post(() -> events.add("post from runOnMainSync"));
+              }
+            });
+
+    assertThat(events).containsExactly("before runOnMainSync", "in runOnMainSync").inOrder();
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/app/UiAutomationTest.kt b/integration_tests/ctesque/src/sharedTest/java/android/app/UiAutomationTest.kt
new file mode 100644
index 0000000..dd2ef15
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/app/UiAutomationTest.kt
@@ -0,0 +1,56 @@
+package android.app
+
+import android.content.res.Configuration
+import android.os.Build.VERSION_CODES.JELLY_BEAN_MR2
+import android.view.Surface
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.testapp.TestActivity
+
+@Suppress("DEPRECATION")
+@DoNotInstrument
+@Config(minSdk = JELLY_BEAN_MR2)
+@RunWith(AndroidJUnit4::class)
+class UiAutomationTest {
+  @Test
+  fun setRotation_freeze90_isLandscape() {
+    val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
+
+    uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_90)
+
+    ActivityScenario.launch(TestActivity::class.java).use { scenario ->
+      scenario.onActivity { activity ->
+        val display = activity.windowManager.defaultDisplay
+        val configuration = activity.resources.configuration
+        assertThat(display.rotation).isEqualTo(Surface.ROTATION_90)
+        assertThat(display.width).isGreaterThan(display.height)
+        assertThat(configuration.orientation).isEqualTo(Configuration.ORIENTATION_LANDSCAPE)
+        assertThat(configuration.screenWidthDp).isGreaterThan(configuration.screenHeightDp)
+      }
+    }
+  }
+
+  @Test
+  fun setRotation_freeze180_isPortrait() {
+    val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
+
+    uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_180)
+
+    ActivityScenario.launch(TestActivity::class.java).use { scenario ->
+      scenario.onActivity { activity ->
+        val display = activity.windowManager.defaultDisplay
+        val configuration = activity.resources.configuration
+        assertThat(display.rotation).isEqualTo(Surface.ROTATION_180)
+        assertThat(display.width).isLessThan(display.height)
+        assertThat(configuration.orientation).isEqualTo(Configuration.ORIENTATION_PORTRAIT)
+        assertThat(configuration.screenWidthDp).isLessThan(configuration.screenHeightDp)
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/content/pm/PackageManagerTest.java b/integration_tests/ctesque/src/sharedTest/java/android/content/pm/PackageManagerTest.java
new file mode 100644
index 0000000..c7f60f1
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/content/pm/PackageManagerTest.java
@@ -0,0 +1,310 @@
+package android.content.pm;
+
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT;
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
+import static android.content.pm.PackageManager.DONT_KILL_APP;
+import static android.content.pm.PackageManager.GET_ACTIVITIES;
+import static android.content.pm.PackageManager.GET_RESOLVED_FILTER;
+import static android.content.pm.PackageManager.GET_SERVICES;
+import static android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager.NameNotFoundException;
+import androidx.test.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.TestService;
+
+/** Compatibility test for {@link PackageManager} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public final class PackageManagerTest {
+  private Context context;
+  private PackageManager pm;
+
+  @Before
+  public void setup() throws Exception {
+    context = InstrumentationRegistry.getTargetContext();
+    pm = context.getPackageManager();
+  }
+
+  @After
+  public void tearDown() {
+    pm.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DEFAULT, DONT_KILL_APP);
+    pm.setComponentEnabledSetting(
+        new ComponentName(context, "org.robolectric.testapp.TestActivity"),
+        COMPONENT_ENABLED_STATE_DEFAULT,
+        DONT_KILL_APP);
+    pm.setComponentEnabledSetting(
+        new ComponentName(context, "org.robolectric.testapp.DisabledTestActivity"),
+        COMPONENT_ENABLED_STATE_DEFAULT,
+        DONT_KILL_APP);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  @SdkSuppress(minSdkVersion = O)
+  public void isInstantApp_shouldNotBlowup() {
+    assertThat(context.getPackageManager().isInstantApp()).isFalse();
+  }
+
+  @Test
+  public void getPackageInfo() throws Exception {
+    PackageInfo info =
+        pm.getPackageInfo(
+            context.getPackageName(), MATCH_DISABLED_COMPONENTS | GET_ACTIVITIES | GET_SERVICES);
+    ActivityInfo[] activities = filterExtraneous(info.activities);
+
+    assertThat(activities).hasLength(4);
+    assertThat(info.services).hasLength(1);
+
+    assertThat(activities[0].name).isEqualTo("org.robolectric.testapp.TestActivity");
+    assertThat(activities[0].enabled).isTrue();
+    assertThat(activities[1].name).isEqualTo("org.robolectric.testapp.DisabledTestActivity");
+    assertThat(activities[1].enabled).isFalse();
+
+    assertThat(info.services[0].name).isEqualTo("org.robolectric.testapp.TestService");
+    assertThat(info.services[0].enabled).isTrue();
+  }
+
+  @Test
+  public void getPackageInfo_noFlagsGetNoComponents() throws Exception {
+    PackageInfo info = pm.getPackageInfo(context.getPackageName(), 0);
+    assertThat(info.activities).isNull();
+    assertThat(info.services).isNull();
+  }
+
+  @Test
+  public void getPackageInfo_skipsDisabledComponents() throws Exception {
+    PackageInfo info = pm.getPackageInfo(context.getPackageName(), GET_ACTIVITIES);
+    ActivityInfo[] activities = filterExtraneous(info.activities);
+
+    assertThat(activities).hasLength(3);
+    assertThat(activities[0].name).isEqualTo("org.robolectric.testapp.TestActivity");
+  }
+
+  @Test
+  public void getComponent_partialName() throws Exception {
+    ComponentName serviceName = new ComponentName(context, ".TestService");
+
+    try {
+      pm.getServiceInfo(serviceName, 0);
+      fail("Expected NameNotFoundException");
+    } catch (NameNotFoundException expected) {
+    }
+  }
+
+  @Test
+  public void getComponent_wrongNameActivity() throws Exception {
+    ComponentName activityName = new ComponentName(context, "WrongNameActivity");
+
+    try {
+      pm.getActivityInfo(activityName, 0);
+      fail("Expected NameNotFoundException");
+    } catch (NameNotFoundException expected) {
+    }
+  }
+
+  @Test
+  public void getComponent_validName() throws Exception {
+    ComponentName componentName = new ComponentName(context, "org.robolectric.testapp.TestService");
+    ServiceInfo info = pm.getServiceInfo(componentName, 0);
+
+    assertThat(info).isNotNull();
+  }
+
+  @Test
+  public void getComponent_validName_queryWithMoreFlags() throws Exception {
+    ComponentName componentName = new ComponentName(context, "org.robolectric.testapp.TestService");
+    ServiceInfo info = pm.getServiceInfo(componentName, MATCH_DISABLED_COMPONENTS);
+
+    assertThat(info).isNotNull();
+  }
+
+  @Test
+  public void queryIntentServices_noFlags() throws Exception {
+    List<ResolveInfo> result = pm.queryIntentServices(new Intent(context, TestService.class), 0);
+
+    assertThat(result).hasSize(1);
+  }
+
+  @Test
+  public void getCompoent_disabledComponent_doesntInclude() throws Exception {
+    ComponentName disabledActivityName =
+        new ComponentName(context, "org.robolectric.testapp.DisabledTestActivity");
+
+    try {
+      pm.getActivityInfo(disabledActivityName, 0);
+      fail("NameNotFoundException expected");
+    } catch (NameNotFoundException expected) {
+    }
+  }
+
+  @Test
+  public void getCompoent_disabledComponent_include() throws Exception {
+    ComponentName disabledActivityName =
+        new ComponentName(context, "org.robolectric.testapp.DisabledTestActivity");
+
+    ActivityInfo info = pm.getActivityInfo(disabledActivityName, MATCH_DISABLED_COMPONENTS);
+    assertThat(info).isNotNull();
+    assertThat(info.enabled).isFalse();
+  }
+
+  @Test
+  public void getPackageInfo_programmaticallyDisabledComponent_noFlags_notReturned()
+      throws Exception {
+    ComponentName activityName = new ComponentName(context, "org.robolectric.testapp.TestActivity");
+    pm.setComponentEnabledSetting(activityName, COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP);
+
+    try {
+      pm.getActivityInfo(activityName, 0);
+      fail("NameNotFoundException expected");
+    } catch (NameNotFoundException expected) {
+    }
+  }
+
+  @Test
+  public void getPackageInfo_programmaticallyDisabledComponent_withFlags_returned()
+      throws Exception {
+    ComponentName activityName = new ComponentName(context, "org.robolectric.testapp.TestActivity");
+    pm.setComponentEnabledSetting(activityName, COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP);
+
+    ActivityInfo info = pm.getActivityInfo(activityName, MATCH_DISABLED_COMPONENTS);
+    assertThat(info).isNotNull();
+    // WHAT?? Seems like we always get the manifest value for ComponentInfo.enabled
+    assertThat(info.enabled).isTrue();
+    assertThat(info.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void getPackageInfo_programmaticallyEnabledComponent_returned() throws Exception {
+    ComponentName activityName =
+        new ComponentName(context, "org.robolectric.testapp.DisabledTestActivity");
+    pm.setComponentEnabledSetting(activityName, COMPONENT_ENABLED_STATE_ENABLED, DONT_KILL_APP);
+
+    ActivityInfo info = pm.getActivityInfo(activityName, 0);
+    assertThat(info).isNotNull();
+    // WHAT?? Seems like we always get the manifest value for ComponentInfo.enabled
+    assertThat(info.enabled).isFalse();
+    assertThat(info.isEnabled()).isFalse();
+  }
+
+  @Test
+  @Config(maxSdk = 23)
+  @SdkSuppress(maxSdkVersion = 23)
+  public void getPackageInfo_disabledAplication_stillReturned_below24() throws Exception {
+    pm.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP);
+
+    PackageInfo packageInfo =
+        pm.getPackageInfo(context.getPackageName(), GET_SERVICES | GET_ACTIVITIES);
+    ActivityInfo[] activities = filterExtraneous(packageInfo.activities);
+
+    assertThat(packageInfo.packageName).isEqualTo(context.getPackageName());
+    assertThat(packageInfo.applicationInfo.enabled).isFalse();
+
+    // Seems that although disabled app makes everything disabled it is still returned with its
+    // manifest state below API 23
+    assertThat(activities).hasLength(3);
+    assertThat(packageInfo.services).hasLength(1);
+
+    assertThat(activities[0].enabled).isTrue();
+    assertThat(packageInfo.services[0].enabled).isTrue();
+    assertThat(activities[0].isEnabled()).isFalse();
+    assertThat(packageInfo.services[0].isEnabled()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = 24)
+  @SdkSuppress(minSdkVersion = 24)
+  public void getPackageInfo_disabledAplication_stillReturned_after24() throws Exception {
+    pm.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP);
+
+    PackageInfo packageInfo =
+        pm.getPackageInfo(context.getPackageName(), GET_SERVICES | GET_ACTIVITIES);
+
+    assertThat(packageInfo.packageName).isEqualTo(context.getPackageName());
+    assertThat(packageInfo.applicationInfo.enabled).isFalse();
+
+    // seems that since API 24 it is isEnabled() and not enabled that gets something into default
+    // result
+    assertThat(packageInfo.activities).isNull();
+    assertThat(packageInfo.services).isNull();
+  }
+
+  @Test
+  public void getPackageInfo_disabledAplication_withFlags_returnedEverything() throws Exception {
+    pm.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP);
+
+    PackageInfo packageInfo =
+        pm.getPackageInfo(
+            context.getPackageName(),
+            GET_SERVICES | GET_ACTIVITIES | MATCH_DISABLED_COMPONENTS);
+    ActivityInfo[] activities = filterExtraneous(packageInfo.activities);
+
+    assertThat(packageInfo.applicationInfo.enabled).isFalse();
+    assertThat(packageInfo.packageName).isEqualTo(context.getPackageName());
+    assertThat(activities).hasLength(4);
+    assertThat(packageInfo.services).hasLength(1);
+    assertThat(activities[0].enabled).isTrue(); // default enabled flag
+  }
+
+  @Test
+  public void getApplicationInfo_disabledAplication_stillReturnedWithNoFlags() throws Exception {
+    pm.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP);
+
+    ApplicationInfo applicationInfo = pm.getApplicationInfo(context.getPackageName(), 0);
+
+    assertThat(applicationInfo.enabled).isFalse();
+    assertThat(applicationInfo.packageName).isEqualTo(context.getPackageName());
+  }
+
+  @Test
+  public void queryIntentActivities_packageOnly() {
+    List<ResolveInfo> resolveInfos =
+        pm.queryIntentActivities(
+            new Intent().setPackage(context.getPackageName()),
+            MATCH_DISABLED_COMPONENTS | GET_RESOLVED_FILTER);
+
+    for (ResolveInfo resolveInfo : resolveInfos) {
+      assertThat(resolveInfo.filter).isNotNull();
+    }
+  }
+
+  private ActivityInfo[] filterExtraneous(ActivityInfo[] activities) {
+    List<ActivityInfo> filtered = new ArrayList<>();
+    for (ActivityInfo activity : activities) {
+      if (activity.name.startsWith("org.robolectric")) {
+        filtered.add(activity);
+      }
+    }
+    return filtered.toArray(new ActivityInfo[0]);
+  }
+
+  private static boolean isRobolectric() {
+    try {
+      Class.forName("org.robolectric.RuntimeEnvironment");
+      return true;
+    } catch (ClassNotFoundException e) {
+      return false;
+    }
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/content/res/AssetManagerTest.java b/integration_tests/ctesque/src/sharedTest/java/android/content/res/AssetManagerTest.java
new file mode 100644
index 0000000..bed5a22
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/content/res/AssetManagerTest.java
@@ -0,0 +1,100 @@
+package android.content.res;
+
+import static androidx.test.InstrumentationRegistry.getTargetContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.CharStreams;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/**
+ * Compatibility test for {@link AssetManager}
+ */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class AssetManagerTest {
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private AssetManager assetManager;
+
+  private static final Charset UTF_8 = Charset.forName("UTF-8");
+
+  @Before
+  public void setup() throws Exception {
+    Context context = getTargetContext();
+    assetManager = context.getResources().getAssets();
+  }
+
+  @Test
+  public void assetsPathListing() throws IOException {
+    assertThat(assetManager.list(""))
+        .asList()
+        .containsAtLeast("assetsHome.txt", "robolectric.png", "myFont.ttf");
+
+    assertThat(assetManager.list("testing")).asList()
+        .contains("hello.txt");
+
+    assertThat(assetManager.list("bogus-dir")).isEmpty();
+  }
+
+  @Test
+  public void open_shouldOpenFile() throws IOException {
+    final String contents =
+        CharStreams.toString(new InputStreamReader(assetManager.open("assetsHome.txt"), UTF_8));
+    assertThat(contents).isEqualTo("assetsHome!");
+  }
+
+  @Test
+  public void open_withAccessMode_shouldOpenFile() throws IOException {
+    final String contents =
+        CharStreams.toString(
+            new InputStreamReader(
+                assetManager.open("assetsHome.txt", AssetManager.ACCESS_BUFFER), UTF_8));
+    assertThat(contents).isEqualTo("assetsHome!");
+  }
+
+  @Test
+  public void openFd_shouldProvideFileDescriptorForAsset() throws Exception {
+    AssetFileDescriptor assetFileDescriptor = assetManager.openFd("assetsHome.txt");
+    assertThat(CharStreams.toString(new InputStreamReader(assetFileDescriptor.createInputStream(), UTF_8)))
+        .isEqualTo("assetsHome!");
+    assertThat(assetFileDescriptor.getLength()).isEqualTo(11);
+  }
+
+  @Test
+  public void open_shouldProvideFileDescriptor() throws Exception {
+    File file =
+        new File(
+            getTargetContext().getFilesDir()
+                + File.separator
+                + "open_shouldProvideFileDescriptor.txt");
+    FileOutputStream output = new FileOutputStream(file);
+    output.write("hi".getBytes());
+
+    ParcelFileDescriptor parcelFileDescriptor =
+        ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+    AssetFileDescriptor assetFileDescriptor =
+        new AssetFileDescriptor(parcelFileDescriptor, 0, "hi".getBytes().length);
+
+    assertThat(
+            CharStreams.toString(
+                new InputStreamReader(assetFileDescriptor.createInputStream(), UTF_8)))
+        .isEqualTo("hi");
+    assertThat(assetFileDescriptor.getLength()).isEqualTo(2);
+    assetFileDescriptor.close();
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/content/res/ResourcesTest.java b/integration_tests/ctesque/src/sharedTest/java/android/content/res/ResourcesTest.java
new file mode 100644
index 0000000..131c45e
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/content/res/ResourcesTest.java
@@ -0,0 +1,1202 @@
+package android.content.res;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.util.TypedValue.COMPLEX_UNIT_DIP;
+import static android.util.TypedValue.COMPLEX_UNIT_IN;
+import static android.util.TypedValue.COMPLEX_UNIT_MM;
+import static android.util.TypedValue.COMPLEX_UNIT_PT;
+import static android.util.TypedValue.COMPLEX_UNIT_PX;
+import static android.util.TypedValue.COMPLEX_UNIT_SP;
+import static android.util.TypedValue.TYPE_FIRST_COLOR_INT;
+import static android.util.TypedValue.TYPE_INT_BOOLEAN;
+import static android.util.TypedValue.TYPE_INT_COLOR_ARGB8;
+import static android.util.TypedValue.TYPE_INT_COLOR_RGB8;
+import static android.util.TypedValue.TYPE_INT_DEC;
+import static android.util.TypedValue.TYPE_LAST_INT;
+import static android.util.TypedValue.TYPE_REFERENCE;
+import static android.util.TypedValue.TYPE_STRING;
+import static android.util.TypedValue.applyDimension;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
+import static org.robolectric.testapp.R.color.test_ARGB8;
+import static org.robolectric.testapp.R.color.test_RGB8;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.graphics.drawable.AnimationDrawable;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.NinePatchDrawable;
+import android.graphics.fonts.Font;
+import android.graphics.fonts.FontFamily;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.util.Xml;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import com.google.common.collect.Range;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.R;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+/**
+ * Compatibility test for {@link Resources}
+ */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class ResourcesTest {
+  private Resources resources;
+  private Context context;
+
+  @Before
+  public void setup() {
+    context = ApplicationProvider.getApplicationContext();
+    resources = context.getResources();
+  }
+
+  @Test
+  public void getString() {
+    assertThat(resources.getString(R.string.hello)).isEqualTo("Hello");
+    assertThat(resources.getString(R.string.say_it_with_item)).isEqualTo("flowers");
+  }
+
+  @Test
+  public void getString_withReference() {
+    assertThat(resources.getString(R.string.greeting)).isEqualTo("Howdy");
+  }
+
+  @Test
+  public void getString_withInterpolation() {
+    assertThat(resources.getString(R.string.interpolate, "value")).isEqualTo("Here is a value!");
+  }
+
+  @Test
+  public void getString_withHtml() {
+    assertThat(resources.getString(R.string.some_html, "value")).isEqualTo("Hello, world");
+  }
+
+  @Test
+  public void getString_withSurroundingQuotes() {
+    assertThat(resources.getString(R.string.surrounding_quotes, "value")).isEqualTo("This'll work");
+  }
+
+  @Test
+  public void getStringWithEscapedApostrophes() {
+    assertThat(resources.getString(R.string.escaped_apostrophe)).isEqualTo("This'll also work");
+  }
+
+  @Test
+  public void getStringWithEscapedQuotes() {
+    assertThat(resources.getString(R.string.escaped_quotes)).isEqualTo("Click \"OK\"");
+  }
+
+  @Test
+  public void getString_StringWithInlinedQuotesAreStripped() {
+    assertThat(resources.getString(R.string.bad_example)).isEqualTo("This is a bad string.");
+  }
+
+  @Test
+  public void getStringShouldStripNewLines() {
+    assertThat(resources.getString(R.string.leading_and_trailing_new_lines)).isEqualTo("Some text");
+  }
+
+  @Test
+  public void preserveEscapedNewlineAndTab() {
+    assertThat(resources.getString(R.string.new_lines_and_tabs, 4)).isEqualTo("4\tmph\nfaster");
+  }
+
+  @Test
+  public void getStringShouldConvertCodePoints() {
+    assertThat(resources.getString(R.string.non_breaking_space)).isEqualTo("Closing"
+                                                                               + " soon:\u00A05pm");
+    assertThat(resources.getString(R.string.space)).isEqualTo("Closing soon: 5pm");
+  }
+
+  @Test
+  public void getMultilineLayoutResource_shouldResolveLayoutReferencesWithLineBreaks() {
+    // multiline_layout is a layout reference to activity_main layout.
+    TypedValue multilineLayoutValue = new TypedValue();
+    resources.getValue(R.layout.multiline_layout, multilineLayoutValue, true /* resolveRefs */);
+    TypedValue mainActivityLayoutValue = new TypedValue();
+    resources.getValue(R.layout.activity_main, mainActivityLayoutValue, false /* resolveRefs */);
+    assertThat(multilineLayoutValue.string).isEqualTo(mainActivityLayoutValue.string);
+  }
+
+  @Test
+  public void getText_withHtml() {
+    assertThat(resources.getText(R.string.some_html, "value").toString()).isEqualTo("Hello, world");
+    // TODO: Raw resources have lost the tags early, but the following call should return a
+    // SpannedString
+    // assertThat(resources.getText(R.string.some_html)).isInstanceOf(SpannedString.class);
+  }
+
+  @Test
+  public void getText_plainString() {
+    assertThat(resources.getText(R.string.hello, "value").toString()).isEqualTo("Hello");
+    assertThat(resources.getText(R.string.hello)).isInstanceOf(String.class);
+  }
+
+  @Test
+  public void getText_withLayoutId() {
+    // This isn't _really_ supported by the platform (gives a lint warning that getText() expects a
+    // String resource type
+    // but the actual platform behaviour is to return a string that equals
+    // "res/layout/layout_file.xml" so the current
+    // Robolectric behaviour deviates from the platform as we append the full file path from the
+    // current working directory.
+    String textString = resources.getText(R.layout.different_screen_sizes, "value").toString();
+    assertThat(textString).containsMatch("/different_screen_sizes.xml$");
+    // If we run tests on devices with different config, the resource system will select different
+    // layout directories.
+    assertThat(textString).containsMatch("^res/layout");
+  }
+
+  @Test
+  public void getStringArray() {
+    assertThat(resources.getStringArray(R.array.items)).isEqualTo(new String[]{"foo", "bar"});
+    assertThat(resources.getStringArray(R.array.greetings))
+        .isEqualTo(new String[] {"hola", "Hello"});
+  }
+
+  @Test
+  public void withIdReferenceEntry_obtainTypedArray() {
+    TypedArray typedArray = resources.obtainTypedArray(R.array.typed_array_with_resource_id);
+    assertThat(typedArray.length()).isEqualTo(2);
+
+    assertThat(typedArray.getResourceId(0, 0)).isEqualTo(R.id.id_declared_in_item_tag);
+    assertThat(typedArray.getResourceId(1, 0)).isEqualTo(R.id.id_declared_in_layout);
+  }
+
+  @Test
+  public void obtainTypedArray() {
+    final TypedArray valuesTypedArray = resources.obtainTypedArray(R.array.typed_array_values);
+    assertThat(valuesTypedArray.getString(0)).isEqualTo("abcdefg");
+    assertThat(valuesTypedArray.getInt(1, 0)).isEqualTo(3875);
+    assertThat(valuesTypedArray.getInteger(1, 0)).isEqualTo(3875);
+    assertThat(valuesTypedArray.getFloat(2, 0.0f)).isEqualTo(2.0f);
+    assertThat(valuesTypedArray.getColor(3, Color.BLACK)).isEqualTo(Color.MAGENTA);
+    assertThat(valuesTypedArray.getColor(4, Color.BLACK)).isEqualTo(Color.parseColor("#00ffff"));
+    assertThat(valuesTypedArray.getDimension(5, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_PX, 8, resources.getDisplayMetrics()));
+    assertThat(valuesTypedArray.getDimension(6, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_DIP, 12, resources.getDisplayMetrics()));
+    assertThat(valuesTypedArray.getDimension(7, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_DIP, 6, resources.getDisplayMetrics()));
+    assertThat(valuesTypedArray.getDimension(8, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_MM, 3, resources.getDisplayMetrics()));
+    assertThat(valuesTypedArray.getDimension(9, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_IN, 4, resources.getDisplayMetrics()));
+    assertThat(valuesTypedArray.getDimension(10, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_SP, 36, resources.getDisplayMetrics()));
+    assertThat(valuesTypedArray.getDimension(11, 0.0f))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_PT, 18, resources.getDisplayMetrics()));
+
+    final TypedArray refsTypedArray = resources.obtainTypedArray(R.array.typed_array_references);
+    assertThat(refsTypedArray.getString(0)).isEqualTo("apple");
+    assertThat(refsTypedArray.getString(1)).isEqualTo("banana");
+    assertThat(refsTypedArray.getInt(2, 0)).isEqualTo(5);
+    assertThat(refsTypedArray.getBoolean(3, false)).isTrue();
+
+    assertThat(refsTypedArray.getResourceId(8, 0)).isEqualTo(R.array.string_array_values);
+    assertThat(refsTypedArray.getTextArray(8))
+        .asList()
+        .containsAtLeast(
+            "abcdefg",
+            "3875",
+            "2.0",
+            "#ffff00ff",
+            "#00ffff",
+            "8px",
+            "12dp",
+            "6dip",
+            "3mm",
+            "4in",
+            "36sp",
+            "18pt");
+
+    assertThat(refsTypedArray.getResourceId(9, 0)).isEqualTo(R.style.Theme_Robolectric);
+  }
+
+  @Test
+  public void getInt() {
+    assertThat(resources.getInteger(R.integer.meaning_of_life)).isEqualTo(42);
+    assertThat(resources.getInteger(R.integer.test_integer1)).isEqualTo(2000);
+    assertThat(resources.getInteger(R.integer.test_integer2)).isEqualTo(9);
+    assertThat(resources.getInteger(R.integer.test_large_hex)).isEqualTo(-65536);
+    assertThat(resources.getInteger(R.integer.test_value_with_zero)).isEqualTo(7210);
+    assertThat(resources.getInteger(R.integer.meaning_of_life_as_item)).isEqualTo(42);
+  }
+
+  @Test
+  public void getInt_withReference() {
+    assertThat(resources.getInteger(R.integer.reference_to_meaning_of_life)).isEqualTo(42);
+  }
+
+  @Test
+  public void getIntArray() {
+    assertThat(resources.getIntArray(R.array.empty_int_array)).isEqualTo(new int[]{});
+    assertThat(resources.getIntArray(R.array.zero_to_four_int_array)).isEqualTo(new int[]{0, 1, 2, 3, 4});
+    assertThat(resources.getIntArray(R.array.with_references_int_array)).isEqualTo(new int[]{0, 2000, 1});
+    assertThat(resources.getIntArray(R.array.referenced_colors_int_array)).isEqualTo(new int[]{0x1, 0xFFFFFFFF, 0xFF000000, 0xFFF5F5F5, 0x802C76AD});
+  }
+
+  @Test
+  public void getBoolean() {
+    assertThat(resources.getBoolean(R.bool.false_bool_value)).isEqualTo(false);
+    assertThat(resources.getBoolean(R.bool.true_as_item)).isEqualTo(true);
+  }
+
+  @Test
+  public void getBoolean_withReference() {
+    assertThat(resources.getBoolean(R.bool.reference_to_true)).isEqualTo(true);
+  }
+
+  @Test
+  public void getDimension() {
+    assertThat(resources.getDimension(R.dimen.test_dip_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_DIP, 20, resources.getDisplayMetrics()));
+    assertThat(resources.getDimension(R.dimen.test_dp_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_DIP, 8, resources.getDisplayMetrics()));
+    assertThat(resources.getDimension(R.dimen.test_in_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_IN, 99, resources.getDisplayMetrics()));
+    assertThat(resources.getDimension(R.dimen.test_mm_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_MM, 42, resources.getDisplayMetrics()));
+    assertThat(resources.getDimension(R.dimen.test_px_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_PX, 15, resources.getDisplayMetrics()));
+    assertThat(resources.getDimension(R.dimen.test_pt_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_PT, 12, resources.getDisplayMetrics()));
+    assertThat(resources.getDimension(R.dimen.test_sp_dimen))
+        .isEqualTo(applyDimension(COMPLEX_UNIT_SP, 5, resources.getDisplayMetrics()));
+  }
+
+  @Test
+  public void getDimensionPixelSize() {
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_dip_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_DIP, 20)));
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_dp_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_DIP, 8)));
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_in_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_IN, 99)));
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_mm_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_MM, 42)));
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_px_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_PX, 15)));
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_pt_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_PT, 12)));
+    assertThat(resources.getDimensionPixelSize(R.dimen.test_sp_dimen))
+        .isIn(onePixelOf(convertDimension(COMPLEX_UNIT_SP, 5)));
+  }
+
+  private static Range<Integer> onePixelOf(int i) {
+    return Range.closed(i - 1, i + 1);
+  }
+
+  @Test
+  public void getDimensionPixelOffset() {
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_dip_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_DIP, 20));
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_dp_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_DIP, 8));
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_in_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_IN, 99));
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_mm_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_MM, 42));
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_px_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_PX, 15));
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_pt_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_PT, 12));
+    assertThat(resources.getDimensionPixelOffset(R.dimen.test_sp_dimen))
+        .isEqualTo(convertDimension(COMPLEX_UNIT_SP, 5));
+  }
+
+  private int convertDimension(int unit, float value) {
+    return (int) applyDimension(unit, value, resources.getDisplayMetrics());
+  }
+
+  @Test
+  public void getDimension_withReference() {
+    assertThat(resources.getBoolean(R.bool.reference_to_true)).isEqualTo(true);
+  }
+
+  @Test
+  public void getStringArray_shouldThrowExceptionIfNotFound() {
+    assertThrows(Resources.NotFoundException.class, () -> resources.getStringArray(-1));
+  }
+
+  @Test
+  public void getIntegerArray_shouldThrowExceptionIfNotFound() {
+    assertThrows(Resources.NotFoundException.class, () -> resources.getIntArray(-1));
+  }
+
+  @Test
+  public void getQuantityString() {
+    assertThat(resources.getQuantityString(R.plurals.beer, 1)).isEqualTo("a beer");
+    assertThat(resources.getQuantityString(R.plurals.beer, 2)).isEqualTo("some beers");
+    assertThat(resources.getQuantityString(R.plurals.beer, 3)).isEqualTo("some beers");
+  }
+
+  @Test
+  public void getQuantityText() {
+    // Feature not supported in legacy (raw) resource mode.
+    assumeFalse(isRobolectricLegacyMode());
+
+    assertThat(resources.getQuantityText(R.plurals.beer, 1)).isEqualTo("a beer");
+    assertThat(resources.getQuantityText(R.plurals.beer, 2)).isEqualTo("some beers");
+    assertThat(resources.getQuantityText(R.plurals.beer, 3)).isEqualTo("some beers");
+  }
+
+  @Test
+  public void getFraction() {
+    final int myself = 300;
+    final int myParent = 600;
+    assertThat(resources.getFraction(R.fraction.half, myself, myParent)).isEqualTo(150f);
+    assertThat(resources.getFraction(R.fraction.half_of_parent, myself, myParent)).isEqualTo(300f);
+
+    assertThat(resources.getFraction(R.fraction.quarter_as_item, myself, myParent)).isEqualTo(75f);
+    assertThat(resources.getFraction(R.fraction.quarter_of_parent_as_item, myself, myParent)).isEqualTo(150f);
+
+    assertThat(resources.getFraction(R.fraction.fifth_as_reference, myself, myParent)).isWithin(0.01f).of(60f);
+    assertThat(resources.getFraction(R.fraction.fifth_of_parent_as_reference, myself, myParent)).isWithin(0.01f).of(120f);
+  }
+
+  @Test
+  public void testConfiguration() {
+    Configuration configuration = resources.getConfiguration();
+    assertThat(configuration).isNotNull();
+    assertThat(configuration.locale).isNotNull();
+  }
+
+  @Test
+  public void testConfigurationReturnsTheSameInstance() {
+    assertThat(resources.getConfiguration()).isSameInstanceAs(resources.getConfiguration());
+  }
+
+  @Test
+  public void testNewTheme() {
+    assertThat(resources.newTheme()).isNotNull();
+  }
+
+  @Test
+  public void testGetDrawableNullRClass() {
+    assertThrows(
+        Resources.NotFoundException.class,
+        () -> assertThat(resources.getDrawable(-12345)).isInstanceOf(BitmapDrawable.class));
+  }
+
+  @Test
+  public void testGetAnimationDrawable() {
+    assertThat(resources.getDrawable(R.anim.animation_list)).isInstanceOf(AnimationDrawable.class);
+  }
+
+  @Test
+  public void testGetColorDrawable() {
+    Drawable drawable = resources.getDrawable(R.color.color_with_alpha);
+    assertThat(drawable).isInstanceOf(ColorDrawable.class);
+    assertThat(((ColorDrawable) drawable).getColor()).isEqualTo(0x802C76AD);
+  }
+
+  @Test
+  public void getColor() {
+    assertThat(resources.getColor(R.color.color_with_alpha)).isEqualTo(0x802C76AD);
+  }
+
+  @Test
+  public void getColor_withReference() {
+    assertThat(resources.getColor(R.color.background)).isEqualTo(0xfff5f5f5);
+  }
+
+  @Test
+  public void testGetColor_Missing() {
+    assertThrows(Resources.NotFoundException.class, () -> resources.getColor(11234));
+  }
+
+  @Test
+  public void testGetColorStateList() {
+    assertThat(resources.getColorStateList(R.color.color_state_list)).isInstanceOf(ColorStateList.class);
+  }
+
+  @Test
+  public void testGetBitmapDrawable() {
+    assertThat(resources.getDrawable(R.drawable.an_image)).isInstanceOf(BitmapDrawable.class);
+  }
+
+  @Test
+  public void testGetNinePatchDrawable() {
+    assertThat(resources.getDrawable(R.drawable.nine_patch_drawable)).isInstanceOf(NinePatchDrawable.class);
+  }
+
+  @Test
+  public void testGetBitmapDrawableForUnknownId() {
+    assertThrows(
+        Resources.NotFoundException.class,
+        () ->
+            assertThat(resources.getDrawable(Integer.MAX_VALUE))
+                .isInstanceOf(BitmapDrawable.class));
+  }
+
+  @Test
+  public void testGetNinePatchDrawableIntrinsicWidth() {
+    float density = resources.getDisplayMetrics().density;
+    NinePatchDrawable ninePatchDrawable =
+        (NinePatchDrawable) resources.getDrawable(R.drawable.nine_patch_drawable);
+    // Use Math.round to convert calculated float width to int,
+    // see NinePatchDrawable#scaleFromDensity.
+    assertThat(ninePatchDrawable.getIntrinsicWidth()).isEqualTo(Math.round(98.0f * density));
+  }
+
+  @Test
+  public void testGetIdentifier() {
+
+    final String resourceType = "string";
+    final String packageName = context.getPackageName();
+
+    final String resourceName = "hello";
+    final int resId1 = resources.getIdentifier(resourceName, resourceType, packageName);
+    assertThat(resId1).isEqualTo(R.string.hello);
+
+    final String typedResourceName = resourceType + "/" + resourceName;
+    final int resId2 = resources.getIdentifier(typedResourceName, resourceType, packageName);
+    assertThat(resId2).isEqualTo(R.string.hello);
+
+    final String fqn = packageName + ":" + typedResourceName;
+    final int resId3 = resources.getIdentifier(fqn, resourceType, packageName);
+    assertThat(resId3).isEqualTo(R.string.hello);
+  }
+
+  @Test
+  public void getIdentifier() {
+    String string = resources.getString(R.string.hello);
+    assertThat(string).isEqualTo("Hello");
+
+
+    int id = resources.getIdentifier("hello", "string", context.getPackageName());
+    assertThat(id).isEqualTo(R.string.hello);
+
+    String hello = resources.getString(id);
+    assertThat(hello).isEqualTo("Hello");
+  }
+
+  @Test
+  public void getIdentifier_nonExistantResource() {
+    int id = resources.getIdentifier("just_alot_of_crap", "string", context.getPackageName());
+    assertThat(id).isEqualTo(0);
+  }
+
+  /**
+   * Public framework symbols are defined here:
+   * https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/public.xml
+   * Private framework symbols are defined here:
+   * https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/symbols.xml
+   *
+   * <p>These generate android.R and com.android.internal.R respectively, when Framework Java code
+   * does not need to reference a framework resource it will not have an R value generated.
+   * Robolectric is then missing an identifier for this resource so we must generate a placeholder
+   * ourselves.
+   */
+  @Test
+  // @Config(sdk = Build.VERSION_CODES.LOLLIPOP) // android:color/secondary_text_material_dark was
+  // added in API 21
+  @SdkSuppress(minSdkVersion = LOLLIPOP)
+  @Config(minSdk = LOLLIPOP)
+  public void shouldGenerateIdsForResourcesThatAreMissingRValues() {
+    int identifierMissingFromRFile =
+        resources.getIdentifier("secondary_text_material_dark", "color", "android");
+
+    // We expect Robolectric to generate a placeholder identifier where one was not generated in the
+    // android R files.
+    assertThat(identifierMissingFromRFile).isNotEqualTo(0);
+
+    // We expect to be able to successfully android:color/secondary_text_material_dark to a
+    // ColorStateList.
+    assertThat(resources.getColorStateList(identifierMissingFromRFile)).isNotNull();
+  }
+
+  @Test
+  public void getSystemShouldReturnSystemResources() {
+    assertThat(Resources.getSystem()).isInstanceOf(Resources.class);
+  }
+
+  @Test
+  public void multipleCallsToGetSystemShouldReturnSameInstance() {
+    assertThat(Resources.getSystem()).isEqualTo(Resources.getSystem());
+  }
+
+  @Test
+  public void applicationResourcesShouldHaveBothSystemAndLocalValues() {
+    assertThat(context.getResources().getString(android.R.string.copy)).isEqualTo("Copy");
+    assertThat(context.getResources().getString(R.string.copy)).isEqualTo("Local Copy");
+  }
+
+  @Test
+  public void systemResourcesShouldReturnCorrectSystemId() {
+    assertThat(Resources.getSystem().getIdentifier("copy", "string", "android"))
+        .isEqualTo(android.R.string.copy);
+  }
+
+  @Test
+  public void systemResourcesShouldReturnZeroForLocalId() {
+    assertThat(Resources.getSystem().getIdentifier("copy", "string", context.getPackageName()))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void testGetXml() throws Exception {
+    XmlResourceParser parser = resources.getXml(R.xml.preferences);
+    assertThat(parser).isNotNull();
+    assertThat(findRootTag(parser)).isEqualTo("PreferenceScreen");
+
+    parser = resources.getXml(R.layout.custom_layout);
+    assertThat(parser).isNotNull();
+    assertThat(findRootTag(parser)).isEqualTo("org.robolectric.android.CustomView");
+
+    parser = resources.getXml(R.menu.test);
+    assertThat(parser).isNotNull();
+    assertThat(findRootTag(parser)).isEqualTo("menu");
+
+    parser = resources.getXml(R.drawable.rainbow);
+    assertThat(parser).isNotNull();
+    assertThat(findRootTag(parser)).isEqualTo("layer-list");
+
+    parser = resources.getXml(R.anim.test_anim_1);
+    assertThat(parser).isNotNull();
+    assertThat(findRootTag(parser)).isEqualTo("set");
+
+    parser = resources.getXml(R.color.color_state_list);
+    assertThat(parser).isNotNull();
+    assertThat(findRootTag(parser)).isEqualTo("selector");
+  }
+
+  @Test
+  public void testGetXml_nonexistentResource() {
+    assertThrows(Resources.NotFoundException.class, () -> resources.getXml(0));
+  }
+
+  @Test
+  public void testGetXml_nonxmlfile() {
+    assertThrows(Resources.NotFoundException.class, () -> resources.getXml(R.drawable.an_image));
+  }
+
+  @Test
+  public void testGetXml_notNPEAfterClose() {
+    XmlResourceParser parser = resources.getXml(R.xml.preferences);
+    parser.close();
+    // the following methods should not NPE if the XmlResourceParser has been closed.
+    assertThat(parser.getName()).isNull();
+    assertThat(parser.getNamespace()).isEmpty();
+    assertThat(parser.getText()).isNull();
+  }
+
+  @Test
+  public void openRawResource_shouldLoadRawResources() {
+    InputStream resourceStream = resources.openRawResource(R.raw.raw_resource);
+    assertThat(resourceStream).isNotNull();
+    // assertThat(TestUtil.readString(resourceStream)).isEqualTo("raw txt file contents");
+  }
+
+  @Test
+  public void openRawResource_shouldLoadDrawables() {
+    InputStream resourceStream = resources.openRawResource(R.drawable.an_image);
+    Bitmap bitmap = BitmapFactory.decodeStream(resourceStream);
+    assertThat(bitmap.getHeight()).isEqualTo(53);
+    assertThat(bitmap.getWidth()).isEqualTo(64);
+  }
+
+  @Test
+  public void openRawResource_withNonFile_throwsNotFoundException() {
+    try {
+      resources.openRawResource(R.string.hello);
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+
+    try {
+      resources.openRawResource(R.string.hello, new TypedValue());
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+
+    try {
+      resources.openRawResource(-1234, new TypedValue());
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+  }
+
+  @Test
+  @Ignore("todo: incorrect behavior on robolectric vs framework?")
+  public void openRawResourceFd_returnsNull_todo_FIX() {
+    assertThat(resources.openRawResourceFd(R.raw.raw_resource)).isNull();
+  }
+
+  @Test
+  public void openRawResourceFd_withNonFile_throwsNotFoundException() {
+    try {
+      resources.openRawResourceFd(R.string.hello);
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+
+    try {
+      resources.openRawResourceFd(-1234);
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+  }
+
+  @Test
+  public void getXml_withNonFile_throwsNotFoundException() {
+    try {
+      resources.getXml(R.string.hello);
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+
+    try {
+      resources.getXml(-1234);
+      fail("should throw");
+    } catch (Resources.NotFoundException e) {
+      // cool
+    }
+  }
+
+  @Test
+  public void themeResolveAttribute_shouldSupportNotDereferencingResource() {
+    TypedValue out = new TypedValue();
+
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.MyBlackTheme, false);
+
+    theme.resolveAttribute(android.R.attr.windowBackground, out, false);
+    assertThat(out.type).isEqualTo(TYPE_REFERENCE);
+    assertThat(out.data).isEqualTo(android.R.color.black);
+  }
+
+  @Test
+  public void obtainAttributes_shouldReturnValuesFromResources() throws Exception {
+    XmlPullParser parser = resources.getXml(R.xml.xml_attrs);
+    parser.next();
+    parser.next();
+    AttributeSet attributes = Xml.asAttributeSet(parser);
+
+    TypedArray typedArray =
+        resources.obtainAttributes(
+            attributes, new int[] {android.R.attr.title, android.R.attr.scrollbarFadeDuration});
+
+    assertThat(typedArray.getString(0)).isEqualTo("Android Title");
+    assertThat(typedArray.getInt(1, 0)).isEqualTo(1111);
+    typedArray.recycle();
+  }
+
+  // @Test
+  // public void obtainAttributes_shouldUseReferencedIdFromAttributeSet() throws Exception {
+  //   // android:id/mask was introduced in API 21, but it's still possible for apps built against API 21 to refer to it
+  //   // in older runtimes because referenced resource ids are compiled (by aapt) into the binary XML format.
+  //   AttributeSet attributeSet = Robolectric.buildAttributeSet()
+  //       .addAttribute(android.R.attr.id, "@android:id/mask").build();
+  //   TypedArray typedArray = resources.obtainAttributes(attributeSet, new int[]{android.R.attr.id});
+  //   assertThat(typedArray.getResourceId(0, -9)).isEqualTo(android.R.id.mask);
+  // }
+
+  @Test
+  public void obtainStyledAttributesShouldDereferenceValues() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.MyBlackTheme, false);
+    TypedArray arr = theme.obtainStyledAttributes(new int[]{android.R.attr.windowBackground});
+    TypedValue value = new TypedValue();
+    arr.getValue(0, value);
+    arr.recycle();
+
+    assertThat(value.type).isAtLeast(TYPE_FIRST_COLOR_INT);
+    assertThat(value.type).isAtMost(TYPE_LAST_INT);
+  }
+
+  // @Test
+  // public void obtainStyledAttributes_shouldCheckXmlFirst_fromAttributeSetBuilder() throws Exception {
+  //
+  //   // This simulates a ResourceProvider built from a 21+ SDK as viewportHeight / viewportWidth were introduced in API 21
+  //   // but the public ID values they are assigned clash with private com.android.internal.R values on older SDKs. This
+  //   // test ensures that even on older SDKs, on calls to obtainStyledAttributes() Robolectric will first check for matching
+  //   // resource ID values in the AttributeSet before checking the theme.
+  //
+  //   AttributeSet attributes = Robolectric.buildAttributeSet()
+  //       .addAttribute(android.R.attr.viewportWidth, "12.0")
+  //       .addAttribute(android.R.attr.viewportHeight, "24.0")
+  //       .build();
+  //
+  //   TypedArray typedArray = context.getTheme().obtainStyledAttributes(attributes, new int[] {
+  //       android.R.attr.viewportWidth,
+  //       android.R.attr.viewportHeight
+  //   }, 0, 0);
+  //   assertThat(typedArray.getFloat(0, 0)).isEqualTo(12.0f);
+  //   assertThat(typedArray.getFloat(1, 0)).isEqualTo(24.0f);
+  //   typedArray.recycle();
+  // }
+
+  @Test
+  public void obtainStyledAttributes_shouldCheckXmlFirst_fromXmlLoadedFromResources() throws Exception {
+
+    // This simulates a ResourceProvider built from a 21+ SDK as viewportHeight / viewportWidth were introduced in API 21
+    // but the public ID values they are assigned clash with private com.android.internal.R values on older SDKs. This
+    // test ensures that even on older SDKs, on calls to obtainStyledAttributes() Robolectric will first check for matching
+    // resource ID values in the AttributeSet before checking the theme.
+
+    XmlResourceParser xml = context.getResources().getXml(R.drawable.vector);
+    xml.next();
+    xml.next();
+    AttributeSet attributeSet = Xml.asAttributeSet(xml);
+
+    TypedArray typedArray = context.getTheme().obtainStyledAttributes(attributeSet, new int[] {
+        android.R.attr.viewportWidth,
+        android.R.attr.viewportHeight
+    }, 0, 0);
+    assertThat(typedArray.getFloat(0, 0)).isEqualTo(12.0f);
+    assertThat(typedArray.getFloat(1, 0)).isEqualTo(24.0f);
+    typedArray.recycle();
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = LOLLIPOP)
+  @Config(minSdk = LOLLIPOP)
+  public void whenAttrIsDefinedInRuntimeSdk_getResourceName_findsResource() {
+    assertThat(context.getResources().getResourceName(android.R.attr.viewportHeight))
+        .isEqualTo("android:attr/viewportHeight");
+  }
+
+  @Test
+  @SdkSuppress(maxSdkVersion = KITKAT)
+  @Config(maxSdk = KITKAT_WATCH)
+  public void whenAttrIsNotDefinedInRuntimeSdk_getResourceName_doesntFindRequestedResourceButInsteadFindsInternalResourceWithSameId() {
+    // asking for an attr defined after the current SDK doesn't have a defined result; in this case it returns
+    //   numberPickerStyle from com.internal.android.R
+    assertThat(context.getResources().getResourceName(android.R.attr.viewportHeight))
+        .isNotEqualTo("android:attr/viewportHeight");
+
+    assertThat(context.getResources().getIdentifier("viewportHeight", "attr", "android"))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void subClassInitializedOK() {
+    SubClassResources subClassResources = new SubClassResources(resources);
+    assertThat(subClassResources.openRawResource(R.raw.raw_resource)).isNotNull();
+  }
+
+  @Test
+  public void applyStyleForced() {
+    final Resources.Theme theme = resources.newTheme();
+
+    theme.applyStyle(R.style.MyBlackTheme, true);
+    TypedArray arr = theme.obtainStyledAttributes(new int[]{android.R.attr.windowBackground, android.R.attr.textColorHint});
+
+    final TypedValue blackBackgroundColor = new TypedValue();
+    arr.getValue(0, blackBackgroundColor);
+    assertThat(blackBackgroundColor.resourceId).isEqualTo(android.R.color.black);
+    arr.recycle();
+
+    theme.applyStyle(R.style.MyBlueTheme, true);
+    arr = theme.obtainStyledAttributes(new int[]{android.R.attr.windowBackground, android.R.attr.textColor, android.R.attr.textColorHint});
+
+    final TypedValue blueBackgroundColor = new TypedValue();
+    arr.getValue(0, blueBackgroundColor);
+    assertThat(blueBackgroundColor.resourceId).isEqualTo(R.color.blue);
+
+    final TypedValue blueTextColor = new TypedValue();
+    arr.getValue(1, blueTextColor);
+    assertThat(blueTextColor.resourceId).isEqualTo(R.color.white);
+
+    final TypedValue blueTextColorHint = new TypedValue();
+    arr.getValue(2, blueTextColorHint);
+    assertThat(blueTextColorHint.resourceId).isEqualTo(android.R.color.darker_gray);
+
+    arr.recycle();
+  }
+
+  @Test
+  public void applyStyleNotForced() {
+    final Resources.Theme theme = resources.newTheme();
+
+    // Apply black theme
+    theme.applyStyle(R.style.MyBlackTheme, true);
+    TypedArray arr = theme.obtainStyledAttributes(new int[]{android.R.attr.windowBackground, android.R.attr.textColorHint});
+
+    final TypedValue blackBackgroundColor = new TypedValue();
+    arr.getValue(0, blackBackgroundColor);
+    assertThat(blackBackgroundColor.resourceId).isEqualTo(android.R.color.black);
+
+    final TypedValue blackTextColorHint = new TypedValue();
+    arr.getValue(1, blackTextColorHint);
+    assertThat(blackTextColorHint.resourceId).isEqualTo(android.R.color.darker_gray);
+
+    arr.recycle();
+
+    // Apply blue theme
+    theme.applyStyle(R.style.MyBlueTheme, false);
+    arr = theme.obtainStyledAttributes(new int[]{android.R.attr.windowBackground, android.R.attr.textColor, android.R.attr.textColorHint});
+
+    final TypedValue blueBackgroundColor = new TypedValue();
+    arr.getValue(0, blueBackgroundColor);
+    assertThat(blueBackgroundColor.resourceId).isEqualTo(android.R.color.black);
+
+    final TypedValue blueTextColor = new TypedValue();
+    arr.getValue(1, blueTextColor);
+    assertThat(blueTextColor.resourceId).isEqualTo(R.color.white);
+
+    final TypedValue blueTextColorHint = new TypedValue();
+    arr.getValue(2, blueTextColorHint);
+    assertThat(blueTextColorHint.resourceId).isEqualTo(android.R.color.darker_gray);
+
+    arr.recycle();
+  }
+
+  @Test
+  public void getValueShouldClearTypedArrayBetweenCalls() {
+    TypedValue outValue = new TypedValue();
+
+    resources.getValue(R.string.hello, outValue, true);
+    assertThat(outValue.type).isEqualTo(TYPE_STRING);
+    assertThat(outValue.string).isEqualTo(resources.getString(R.string.hello));
+    // outValue.data is an index into the String block which we don't know for raw xml resources.
+    assertThat(outValue.assetCookie).isNotEqualTo(0);
+
+    resources.getValue(R.color.blue, outValue, true);
+    assertThat(outValue.type).isEqualTo(TYPE_INT_COLOR_RGB8);
+    assertThat(outValue.data).isEqualTo(0xFF0000FF);
+    assertThat(outValue.string).isNull();
+    // outValue.assetCookie is not supported with raw XML
+
+    resources.getValue(R.integer.loneliest_number, outValue, true);
+    assertThat(outValue.type).isEqualTo(TYPE_INT_DEC);
+    assertThat(outValue.data).isEqualTo(1);
+    assertThat(outValue.string).isNull();
+
+    resources.getValue(R.bool.true_bool_value, outValue, true);
+    assertThat(outValue.type).isEqualTo(TYPE_INT_BOOLEAN);
+    assertThat(outValue.data).isNotEqualTo(0); // true == traditionally 0xffffffff, -1 in Java but
+    // tests should be checking for non-zero
+    assertThat(outValue.string).isNull();
+  }
+
+  @Test
+  public void getXml() throws Exception {
+    XmlResourceParser xmlResourceParser = resources.getXml(R.xml.preferences);
+    assertThat(xmlResourceParser).isNotNull();
+    assertThat(xmlResourceParser.next()).isEqualTo(XmlResourceParser.START_DOCUMENT);
+    assertThat(xmlResourceParser.next()).isEqualTo(XmlResourceParser.START_TAG);
+    assertThat(xmlResourceParser.getName()).isEqualTo("PreferenceScreen");
+  }
+
+  @Test
+  public void getXml_shouldParseEmojiCorrectly() throws IOException, XmlPullParserException {
+    XmlResourceParser xmlResourceParser = resources.getXml(R.xml.has_emoji);
+    xmlResourceParser.next();
+    xmlResourceParser.next();
+    assertThat(xmlResourceParser.getName()).isEqualTo("EmojiRoot");
+    AttributeSet attributeSet = Xml.asAttributeSet(xmlResourceParser);
+    assertThat(attributeSet.getAttributeValue(null, "label1")).isEqualTo("no emoji");
+    String pureEmoji = "\uD83D\uDE00";
+    assertThat(attributeSet.getAttributeValue(null, "label2")).isEqualTo(pureEmoji);
+    assertThat(attributeSet.getAttributeValue(null, "label3")).isEqualTo(pureEmoji);
+    String mixEmojiAndText = "\uD83D\uDE00internal1\uD83D\uDE00internal2\uD83D\uDE00";
+    assertThat(attributeSet.getAttributeValue(null, "label4")).isEqualTo(mixEmojiAndText);
+    assertThat(attributeSet.getAttributeValue(null, "label5")).isEqualTo(mixEmojiAndText);
+    assertThat(attributeSet.getAttributeValue(null, "label6"))
+        .isEqualTo("don't worry be \uD83D\uDE00");
+  }
+
+  @Test
+  public void whenMissingXml_throwNotFoundException() {
+    try {
+      resources.getXml(0x3038);
+      fail();
+    } catch (Resources.NotFoundException e) {
+      assertThat(e.getMessage()).contains("Resource ID #0x3038");
+    }
+  }
+
+  @Test
+  public void stringWithSpaces() {
+    // this differs from actual Android behavior, which collapses whitespace as "Up to 25 USD"
+    assertThat(resources.getString(R.string.string_with_spaces, "25", "USD"))
+        .isEqualTo("Up to 25 USD");
+  }
+
+  @Test
+  public void internalWhiteSpaceShouldBeCollapsed() {
+    assertThat(resources.getString(R.string.internal_whitespace_blocks))
+        .isEqualTo("Whitespace in" + " the middle");
+    assertThat(resources.getString(R.string.internal_newlines)).isEqualTo("Some Newlines");
+  }
+
+  @Test
+  public void fontTagWithAttributesShouldBeRead() {
+    assertThat(resources.getString(R.string.font_tag_with_attribute))
+        .isEqualTo("This string has a font tag");
+  }
+
+  @Test
+  public void linkTagWithAttributesShouldBeRead() {
+    assertThat(resources.getString(R.string.link_tag_with_attribute))
+        .isEqualTo("This string has a link tag");
+  }
+
+  @Test
+  public void getResourceTypeName_mipmap() {
+    assertThat(resources.getResourceTypeName(R.mipmap.mipmap_reference)).isEqualTo("mipmap");
+    assertThat(resources.getResourceTypeName(R.mipmap.robolectric)).isEqualTo("mipmap");
+  }
+
+  @Test
+  public void getDrawable_mipmapReferencesResolve() {
+    Drawable reference = resources.getDrawable(R.mipmap.mipmap_reference);
+    Drawable original = resources.getDrawable(R.mipmap.robolectric);
+
+    assertThat(reference.getMinimumHeight()).isEqualTo(original.getMinimumHeight());
+    assertThat(reference.getMinimumWidth()).isEqualTo(original.getMinimumWidth());
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void getDrawable_mipmapReferencesResolveXml() {
+    Drawable reference = resources.getDrawable(R.mipmap.robolectric_xml);
+    Drawable original = resources.getDrawable(R.mipmap.mipmap_reference_xml);
+
+    assertThat(reference.getMinimumHeight()).isEqualTo(original.getMinimumHeight());
+    assertThat(reference.getMinimumWidth()).isEqualTo(original.getMinimumWidth());
+  }
+
+  @Test
+  public void forUntouchedThemes_copyTheme_shouldCopyNothing() {
+    Resources.Theme theme1 = resources.newTheme();
+    Resources.Theme theme2 = resources.newTheme();
+    theme2.setTo(theme1);
+  }
+
+  @Test
+  public void getResourceIdentifier_shouldReturnValueFromRClass() {
+    assertThat(
+        resources.getIdentifier("id_declared_in_item_tag", "id", context.getPackageName()))
+        .isEqualTo(R.id.id_declared_in_item_tag);
+    assertThat(
+        resources.getIdentifier("id/id_declared_in_item_tag", null, context.getPackageName()))
+        .isEqualTo(R.id.id_declared_in_item_tag);
+    assertThat(
+        resources.getIdentifier(context.getPackageName() + ":id_declared_in_item_tag", "id", null))
+        .isEqualTo(R.id.id_declared_in_item_tag);
+    assertThat(
+            resources.getIdentifier(
+                context.getPackageName() + ":id/id_declared_in_item_tag", "other", "other"))
+        .isEqualTo(R.id.id_declared_in_item_tag);
+  }
+
+  @Test
+  public void whenPackageIsUnknown_getResourceIdentifier_shouldReturnZero() {
+    assertThat(
+        resources.getIdentifier("whatever", "id", "some.unknown.package"))
+        .isEqualTo(0);
+    assertThat(
+        resources.getIdentifier("id/whatever", null, "some.unknown.package"))
+        .isEqualTo(0);
+    assertThat(
+        resources.getIdentifier("some.unknown.package:whatever", "id", null))
+        .isEqualTo(0);
+    assertThat(
+        resources.getIdentifier("some.unknown.package:id/whatever", "other", "other"))
+        .isEqualTo(0);
+
+    assertThat(
+        resources.getIdentifier("whatever", "drawable", "some.unknown.package"))
+        .isEqualTo(0);
+    assertThat(
+        resources.getIdentifier("drawable/whatever", null, "some.unknown.package"))
+        .isEqualTo(0);
+    assertThat(
+        resources.getIdentifier("some.unknown.package:whatever", "drawable", null))
+        .isEqualTo(0);
+    assertThat(
+        resources.getIdentifier("some.unknown.package:id/whatever", "other", "other"))
+        .isEqualTo(0);
+  }
+
+  @Test
+  @Ignore(
+      "currently ids are always automatically assigned a value; to fix this we'd need to check "
+          + "layouts for +@id/___, which is expensive")
+  public void whenCalledForIdWithNameNotInRClassOrXml_getResourceIdentifier_shouldReturnZero() {
+    assertThat(
+        resources.getIdentifier(
+            "org.robolectric:id/idThatDoesntExistAnywhere", "other", "other"))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void
+      whenIdIsAbsentInXmlButPresentInRClass_getResourceIdentifier_shouldReturnIdFromRClass_probablyBecauseItWasDeclaredInALayout() {
+    assertThat(
+        resources.getIdentifier("id_declared_in_layout", "id", context.getPackageName()))
+        .isEqualTo(R.id.id_declared_in_layout);
+  }
+
+  @Test
+  public void whenResourceIsAbsentInXml_getResourceIdentifier_shouldReturn0() {
+    assertThat(
+        resources.getIdentifier("fictitiousDrawable", "drawable", context.getPackageName()))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void whenResourceIsAbsentInXml_getResourceIdentifier_shouldReturnId() {
+    assertThat(
+        resources.getIdentifier("an_image", "drawable", context.getPackageName()))
+        .isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void whenResourceIsXml_getResourceIdentifier_shouldReturnId() {
+    assertThat(
+        resources.getIdentifier("preferences", "xml", context.getPackageName()))
+        .isEqualTo(R.xml.preferences);
+  }
+
+  @Test
+  public void whenResourceIsRaw_getResourceIdentifier_shouldReturnId() {
+    assertThat(
+        resources.getIdentifier("raw_resource", "raw", context.getPackageName()))
+        .isEqualTo(R.raw.raw_resource);
+  }
+
+  @Test
+  public void getResourceValue_colorARGB8() {
+    TypedValue outValue = new TypedValue();
+    resources.getValue(test_ARGB8, outValue, false);
+    assertThat(outValue.type).isEqualTo(TYPE_INT_COLOR_ARGB8);
+    assertThat(Color.blue(outValue.data)).isEqualTo(2);
+  }
+
+  @Test
+  public void getResourceValue_colorRGB8() {
+    TypedValue outValue = new TypedValue();
+    resources.getValue(test_RGB8, outValue, false);
+    assertThat(outValue.type).isEqualTo(TYPE_INT_COLOR_RGB8);
+    assertThat(Color.blue(outValue.data)).isEqualTo(4);
+  }
+
+  @Test
+  public void getResourceEntryName_forStyle() {
+    assertThat(resources.getResourceEntryName(android.R.style.TextAppearance_Small))
+        .isEqualTo("TextAppearance.Small");
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = O)
+  @Config(minSdk = O)
+  public void getFont() {
+    // Feature not supported in legacy (raw) resource mode.
+    assumeFalse(isRobolectricLegacyMode());
+
+    Typeface typeface = resources.getFont(R.font.vt323_regular);
+    assertThat(typeface).isNotNull();
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = O)
+  @Config(minSdk = O)
+  public void getFontFamily() {
+    // Feature not supported in legacy (raw) resource mode.
+    assumeFalse(isRobolectricLegacyMode());
+
+    Typeface typeface = resources.getFont(R.font.vt323);
+    assertThat(typeface).isNotNull();
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = O)
+  @Config(minSdk = O)
+  public void getFontFamily_downloadable() {
+    // Feature not supported in legacy (raw) resource mode.
+    assumeFalse(isRobolectricLegacyMode());
+
+    Typeface typeface = resources.getFont(R.font.downloadable);
+    assertThat(typeface).isNotNull();
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = Q)
+  @Config(minSdk = Q)
+  public void testFontBuilder() throws Exception {
+    // Used to throw `java.io.IOException: Failed to read font contents`
+    new Font.Builder(context.getResources(), R.font.vt323_regular).build();
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = Q)
+  @Config(minSdk = Q)
+  public void fontFamily_getFont() throws Exception {
+    Font platformFont = new Font.Builder(resources, R.font.vt323_regular).build();
+    FontFamily fontFamily = new FontFamily.Builder(platformFont).build();
+    assertThat(fontFamily.getFont(0)).isNotNull();
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = Q)
+  @Config(minSdk = Q)
+  public void getAttributeSetSourceResId() {
+    XmlResourceParser xmlResourceParser = resources.getXml(R.xml.preferences);
+
+    int sourceResId = Resources.getAttributeSetSourceResId(xmlResourceParser);
+
+    assertThat(sourceResId).isEqualTo(R.xml.preferences);
+  }
+
+  private static String findRootTag(XmlResourceParser parser) throws Exception {
+    int event;
+    do {
+      event = parser.next();
+    } while (event != XmlPullParser.START_TAG);
+    return parser.getName();
+  }
+
+  private static class SubClassResources extends Resources {
+    public SubClassResources(Resources res) {
+      super(res.getAssets(), res.getDisplayMetrics(), res.getConfiguration());
+    }
+  }
+
+  private static boolean isRobolectricLegacyMode() {
+    try {
+      Class<?> runtimeEnvironmentClass = Class.forName("org.robolectric.RuntimeEnvironment");
+      Method useLegacyResourcesMethod =
+          runtimeEnvironmentClass.getDeclaredMethod("useLegacyResources");
+      return (boolean) useLegacyResourcesMethod.invoke(null);
+    } catch (Exception e) {
+      return false;
+    }
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/content/res/ThemeTest.java b/integration_tests/ctesque/src/sharedTest/java/android/content/res/ThemeTest.java
new file mode 100644
index 0000000..b546a37
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/content/res/ThemeTest.java
@@ -0,0 +1,319 @@
+package android.content.res;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.content.res.Resources.Theme;
+import android.graphics.Color;
+import android.util.TypedValue;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.R;
+
+/**
+ * Compatibility test for {@link Resources.Theme}
+ */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class ThemeTest {
+
+  private Resources resources;
+  private Context context;
+
+  @Before
+  public void setup() throws Exception {
+    context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+    resources = context.getResources();
+  }
+
+  @Test
+  public void withEmptyTheme_returnsEmptyAttributes() {
+    assertThat(resources.newTheme().obtainStyledAttributes(new int[] {R.attr.string1}).hasValue(0))
+        .isFalse();
+  }
+
+  @Test
+  public void shouldLookUpStylesFromStyleResId() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+    TypedArray a = theme.obtainStyledAttributes(R.style.MyCustomView, R.styleable.CustomView);
+
+    boolean enabled = a.getBoolean(R.styleable.CustomView_aspectRatioEnabled, false);
+    assertThat(enabled).isTrue();
+  }
+
+  @Test
+  public void shouldApplyStylesFromResourceReference() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+    TypedArray a =
+        theme.obtainStyledAttributes(null, R.styleable.CustomView, R.attr.animalStyle, 0);
+
+    int animalStyleId = a.getResourceId(R.styleable.CustomView_animalStyle, 0);
+    assertThat(animalStyleId).isEqualTo(R.style.Gastropod);
+    assertThat(a.getFloat(R.styleable.CustomView_aspectRatio, 0.2f)).isEqualTo(1.69f);
+  }
+
+  @Test
+  public void shouldApplyStylesFromAttributeReference() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_ThirdTheme, true);
+    TypedArray a =
+        theme.obtainStyledAttributes(null, R.styleable.CustomView, R.attr.animalStyle, 0);
+
+    int animalStyleId = a.getResourceId(R.styleable.CustomView_animalStyle, 0);
+    assertThat(animalStyleId).isEqualTo(R.style.Gastropod);
+    assertThat(a.getFloat(R.styleable.CustomView_aspectRatio, 0.2f)).isEqualTo(1.69f);
+  }
+
+  @Test
+  public void shouldGetValuesFromAttributeReference() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_ThirdTheme, true);
+
+    TypedValue value1 = new TypedValue();
+    TypedValue value2 = new TypedValue();
+    boolean resolved1 = theme.resolveAttribute(R.attr.someLayoutOne, value1, true);
+    boolean resolved2 = theme.resolveAttribute(R.attr.someLayoutTwo, value2, true);
+
+    assertThat(resolved1).isTrue();
+    assertThat(resolved2).isTrue();
+    assertThat(value1.resourceId).isEqualTo(R.layout.activity_main);
+    assertThat(value2.resourceId).isEqualTo(R.layout.activity_main);
+    assertThat(value1.coerceToString()).isEqualTo(value2.coerceToString());
+  }
+
+  @Test
+  public void withResolveRefsFalse_shouldResolveValue() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    TypedValue value = new TypedValue();
+    boolean resolved = theme.resolveAttribute(R.attr.logoWidth, value, false);
+
+    assertThat(resolved).isTrue();
+    assertThat(value.type).isEqualTo(TypedValue.TYPE_REFERENCE);
+    assertThat(value.data).isEqualTo(R.dimen.test_dp_dimen);
+  }
+
+  @Test
+  public void withResolveRefsFalse_shouldNotResolveResource() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    TypedValue value = new TypedValue();
+    boolean resolved = theme.resolveAttribute(R.attr.logoHeight, value, false);
+
+    assertThat(resolved).isTrue();
+    assertThat(value.type).isEqualTo(TypedValue.TYPE_REFERENCE);
+    assertThat(value.data).isEqualTo(R.dimen.test_dp_dimen);
+  }
+
+  @Test
+  public void withResolveRefsTrue_shouldResolveResource() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    TypedValue value = new TypedValue();
+    boolean resolved = theme.resolveAttribute(R.attr.logoHeight, value, true);
+
+    assertThat(resolved).isTrue();
+    assertThat(value.type).isEqualTo(TypedValue.TYPE_DIMENSION);
+    assertThat(value.resourceId).isEqualTo(R.dimen.test_dp_dimen);
+    assertThat(value.coerceToString()).isEqualTo("8.0dip");
+  }
+
+  @Test
+  public void failToResolveCircularReference() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    TypedValue value = new TypedValue();
+    boolean resolved = theme.resolveAttribute(R.attr.isSugary, value, false);
+
+    assertThat(resolved).isFalse();
+  }
+
+  @Test
+  public void canResolveAttrReferenceToDifferentPackage() {
+    Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    TypedValue value = new TypedValue();
+    boolean resolved = theme.resolveAttribute(R.attr.styleReference, value, false);
+
+    assertThat(resolved).isTrue();
+    assertThat(value.type).isEqualTo(TypedValue.TYPE_REFERENCE);
+    assertThat(value.data).isEqualTo(R.style.Widget_AnotherTheme_Button);
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Test
+  public void forStylesWithImplicitParents_shouldInheritValuesNotDefinedInChild() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric_ImplicitChild, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string3}).getString(0))
+        .isEqualTo("string 3 from Theme.Robolectric.ImplicitChild");
+  }
+
+  @Test
+  public void whenAThemeHasExplicitlyEmptyParentAttr_shouldHaveNoParent() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric_EmptyParent, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).hasValue(0)).isFalse();
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Test
+  public void shouldApplyParentStylesFromAttrs() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.AnotherTheme");
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string3}).getString(0))
+        .isEqualTo("string 3 from Theme.Robolectric");
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Test
+  public void setTo_shouldCopyAllAttributesToEmptyTheme() {
+    Resources.Theme theme1 = resources.newTheme();
+    theme1.applyStyle(R.style.Theme_Robolectric, false);
+    assertThat(theme1.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+
+    Resources.Theme theme2 = resources.newTheme();
+    theme2.setTo(theme1);
+
+    assertThat(theme2.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Test
+  public void setTo_whenDestThemeIsModified_sourceThemeShouldNotMutate() {
+    Resources.Theme sourceTheme = resources.newTheme();
+    sourceTheme.applyStyle(R.style.Theme_Robolectric, false);
+    assertThat(sourceTheme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+
+    Resources.Theme destTheme = resources.newTheme();
+    destTheme.setTo(sourceTheme);
+    destTheme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    assertThat(sourceTheme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Test
+  public void setTo_whenSourceThemeIsModified_destThemeShouldNotMutate() {
+    Resources.Theme sourceTheme = resources.newTheme();
+    sourceTheme.applyStyle(R.style.Theme_Robolectric, false);
+    assertThat(sourceTheme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+
+    Resources.Theme destTheme = resources.newTheme();
+    destTheme.setTo(sourceTheme);
+    sourceTheme.applyStyle(R.style.Theme_AnotherTheme, true);
+
+    assertThat(destTheme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = O)
+  public void applyStyle_withForceFalse_shouldApplyButNotOverwriteExistingAttributeValues() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric, false);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+
+    theme.applyStyle(R.style.Theme_AnotherTheme, false);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string2}).getString(0))
+        .isEqualTo("string 2 from Theme.AnotherTheme");
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = O)
+  public void applyStyle_withForceTrue_shouldApplyAndOverwriteExistingAttributeValues() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric, false);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+
+    theme.applyStyle(R.style.Theme_AnotherTheme, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.AnotherTheme");
+
+    // force apply the original theme; values should be overwritten
+    theme.applyStyle(R.style.Theme_Robolectric, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+  }
+
+  @Test
+  public void shouldFindInheritedAndroidAttributeInTheme() {
+    context.setTheme(R.style.Theme_AnotherTheme);
+    Resources.Theme theme1 = context.getTheme();
+
+    TypedArray typedArray =
+        theme1.obtainStyledAttributes(new int[] {R.attr.typeface, android.R.attr.buttonStyle});
+    assertThat(typedArray.hasValue(0)).isTrue(); // animalStyle
+    assertThat(typedArray.hasValue(1)).isTrue(); // layout_height
+  }
+
+  @Test
+  public void themesShouldBeApplyableAcrossResources() {
+    Resources.Theme themeFromSystem = Resources.getSystem().newTheme();
+    themeFromSystem.applyStyle(android.R.style.Theme_Light, true);
+
+    Resources.Theme themeFromApp = resources.newTheme();
+    themeFromApp.applyStyle(android.R.style.Theme, true);
+
+    // themeFromSystem is Theme_Light, which has a white background...
+    assertThat(
+            themeFromSystem
+                .obtainStyledAttributes(new int[] {android.R.attr.colorBackground})
+                .getColor(0, 123))
+        .isEqualTo(Color.WHITE);
+
+    // themeFromApp is Theme, which has a black background...
+    assertThat(
+            themeFromApp
+                .obtainStyledAttributes(new int[] {android.R.attr.colorBackground})
+                .getColor(0, 123))
+        .isEqualTo(Color.BLACK);
+
+    themeFromApp.setTo(themeFromSystem);
+
+    // themeFromApp now has style values from themeFromSystem, so now it has a black background...
+    assertThat(
+            themeFromApp
+                .obtainStyledAttributes(new int[] {android.R.attr.colorBackground})
+                .getColor(0, 123))
+        .isEqualTo(Color.WHITE);
+  }
+
+  @Test
+  public void styleResolutionShouldIgnoreThemes() {
+    Resources.Theme themeFromSystem = resources.newTheme();
+    themeFromSystem.applyStyle(android.R.style.Theme_DeviceDefault, true);
+    themeFromSystem.applyStyle(R.style.ThemeWithSelfReferencingTextAttr, true);
+    assertThat(
+            themeFromSystem
+                .obtainStyledAttributes(new int[] {android.R.attr.textAppearance})
+                .getResourceId(0, 0))
+        .isEqualTo(0);
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java b/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java
new file mode 100644
index 0000000..fa11ce7
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java
@@ -0,0 +1,226 @@
+package android.database;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
+
+import android.content.ContentValues;
+import android.database.sqlite.SQLiteConstraintException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import com.google.common.base.Ascii;
+import com.google.common.base.Throwables;
+import com.google.common.io.ByteStreams;
+import java.io.File;
+import java.io.PrintStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Compatibility test for {@link android.database.sqlite.SQLiteDatabase} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class SQLiteDatabaseTest {
+
+  private SQLiteDatabase database;
+  private File databasePath;
+
+  @Before
+  public void setUp() {
+    databasePath = ApplicationProvider.getApplicationContext().getDatabasePath("database.db");
+    databasePath.getParentFile().mkdirs();
+
+    database = SQLiteDatabase.openOrCreateDatabase(databasePath, null);
+    database.execSQL(
+        "CREATE TABLE table_name (\n"
+            + "  id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
+            + "  first_column VARCHAR(255),\n"
+            + "  second_column BINARY,\n"
+            + "  name VARCHAR(255),\n"
+            + "  big_int INTEGER\n"
+            + ");");
+  }
+
+  @After
+  public void tearDown() {
+    database.close();
+    assertThat(databasePath.delete()).isTrue();
+  }
+
+  @Test
+  public void shouldGetBlobFromString() {
+    String s = "this is a string";
+    ContentValues values = new ContentValues();
+    values.put("first_column", s);
+
+    database.insert("table_name", null, values);
+
+    try (Cursor data =
+        database.query("table_name", new String[] {"first_column"}, null, null, null, null, null)) {
+      assertThat(data.getCount()).isEqualTo(1);
+      data.moveToFirst();
+      byte[] columnBytes = data.getBlob(0);
+      byte[] expected = Arrays.copyOf(s.getBytes(), s.length() + 1); // include zero-terminal
+      assertThat(columnBytes).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void shouldGetBlobFromNullString() {
+    ContentValues values = new ContentValues();
+    values.put("first_column", (String) null);
+    database.insert("table_name", null, values);
+
+    try (Cursor data =
+        database.query("table_name", new String[] {"first_column"}, null, null, null, null, null)) {
+      assertThat(data.getCount()).isEqualTo(1);
+      data.moveToFirst();
+      assertThat(data.getBlob(0)).isEqualTo(null);
+    }
+  }
+
+  @Test
+  public void shouldGetBlobFromEmptyString() {
+    ContentValues values = new ContentValues();
+    values.put("first_column", "");
+    database.insert("table_name", null, values);
+
+    try (Cursor data =
+        database.query("table_name", new String[] {"first_column"}, null, null, null, null, null)) {
+      assertThat(data.getCount()).isEqualTo(1);
+      data.moveToFirst();
+      assertThat(data.getBlob(0)).isEqualTo(new byte[] {0});
+    }
+  }
+
+  @Test
+  public void shouldThrowWhenForeignKeysConstraintIsViolated() {
+    database.execSQL(
+        "CREATE TABLE artist(\n"
+            + "  artistid    INTEGER PRIMARY KEY, \n"
+            + "  artistname  TEXT\n"
+            + ");\n");
+
+    database.execSQL(
+        "CREATE TABLE track(\n"
+            + "  trackid     INTEGER, \n"
+            + "  trackname   TEXT, \n"
+            + "  trackartist INTEGER,\n"
+            + "  FOREIGN KEY(trackartist) REFERENCES artist(artistid)\n"
+            + ");");
+
+    database.execSQL("PRAGMA foreign_keys=ON");
+    database.execSQL("INSERT into artist (artistid, artistname) VALUES (1, 'Kanye')");
+    database.execSQL(
+        "INSERT into track (trackid, trackname, trackartist) VALUES (1, 'Good Life', 1)");
+    SQLiteConstraintException ex =
+        assertThrows(SQLiteConstraintException.class, () -> database.execSQL("delete from artist"));
+    assertThat(Ascii.toLowerCase(Throwables.getStackTraceAsString(ex))).contains("foreign key");
+  }
+
+  @Test
+  public void shouldDeleteWithLikeEscape() {
+    ContentValues values = new ContentValues();
+    values.put("first_column", "test");
+    database.insert("table_name", null, values);
+    String select = "first_column LIKE ? ESCAPE ?";
+    String[] selectArgs = {
+      "test", Character.toString('\\'),
+    };
+    assertThat(database.delete("table_name", select, selectArgs)).isEqualTo(1);
+  }
+
+  @Test
+  public void shouldThrowsExceptionWhenQueryingUsingExecSQL() {
+    SQLiteException e = assertThrows(SQLiteException.class, () -> database.execSQL("select 1"));
+    assertThat(e)
+        .hasMessageThat()
+        .contains("Queries can be performed using SQLiteDatabase query or rawQuery methods only.");
+  }
+
+  @Test
+  public void close_withExclusiveLockingMode() {
+    database.rawQuery("PRAGMA locking_mode = EXCLUSIVE", new String[0]).close();
+    ContentValues values = new ContentValues();
+    values.put("first_column", "");
+    database.insert("table_name", null, values);
+    database.close();
+
+    database = SQLiteDatabase.openOrCreateDatabase(databasePath, null);
+    database.insert("table_name", null, values);
+  }
+
+  static class MyCursorWindow extends CursorWindow {
+    public MyCursorWindow(String name) {
+      super(name);
+    }
+
+    /** Make the finalize method public */
+    @Override
+    public void finalize() throws Throwable {
+      super.finalize();
+    }
+  }
+
+  // TODO(hoisie): This test crashes in emulators, enable when it is fixed in Android.
+  @SdkSuppress(minSdkVersion = 34)
+  @Test
+  public void cursorWindow_finalize_concurrentStressTest() throws Throwable {
+    final PrintStream originalErr = System.err;
+    // discard stderr output for this test to prevent CloseGuard logspam.
+    System.setErr(new PrintStream(ByteStreams.nullOutputStream()));
+    try {
+      ExecutorService executor = Executors.newFixedThreadPool(4);
+      for (int i = 0; i < 1000; i++) {
+        final MyCursorWindow cursorWindow = new MyCursorWindow(String.valueOf(i));
+        for (int j = 0; j < 4; j++) {
+          executor.execute(
+              () -> {
+                try {
+                  cursorWindow.finalize();
+                } catch (Throwable e) {
+                  throw new AssertionError(e);
+                }
+              });
+        }
+      }
+      executor.shutdown();
+      executor.awaitTermination(100, SECONDS);
+    } finally {
+      System.setErr(originalErr);
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  @SdkSuppress(minSdkVersion = LOLLIPOP)
+  public void collate_unicode() {
+    String[] names = new String[] {"aaa", "abc", "ABC", "bbb"};
+    for (String name : names) {
+      ContentValues values = new ContentValues();
+      values.put("name", name);
+      database.insert("table_name", null, values);
+    }
+    Cursor c =
+        database.rawQuery("SELECT name from table_name ORDER BY name COLLATE UNICODE ASC", null);
+    c.moveToFirst();
+    ArrayList<String> sorted = new ArrayList<>();
+    while (!c.isAfterLast()) {
+      sorted.add(c.getString(0));
+      c.moveToNext();
+    }
+    c.close();
+    assertThat(sorted).containsExactly("aaa", "abc", "ABC", "bbb").inOrder();
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/graphics/BitmapFactoryTest.java b/integration_tests/ctesque/src/sharedTest/java/android/graphics/BitmapFactoryTest.java
new file mode 100644
index 0000000..b9d95d5
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/graphics/BitmapFactoryTest.java
@@ -0,0 +1,113 @@
+package android.graphics;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.lang.Math.round;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.BitmapFactory.Options;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.common.truth.TruthJUnit;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.R;
+
+/** Compatibility test for {@link BitmapFactory} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class BitmapFactoryTest {
+
+  // height and width of start.jpg
+  private static final int START_HEIGHT = 53;
+  private static final int START_WIDTH = 64;
+
+  private Resources resources;
+
+  @Before
+  public void setUp() {
+    resources = InstrumentationRegistry.getInstrumentation().getTargetContext().getResources();
+  }
+
+  @Test
+  public void decodeResource() {
+    Options opt = new BitmapFactory.Options();
+    opt.inScaled = false;
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image, opt);
+    assertThat(bitmap.getHeight()).isEqualTo(START_HEIGHT);
+    assertThat(bitmap.getWidth()).isEqualTo(START_WIDTH);
+  }
+
+  @Test
+  public void testDecodeByteArray1() {
+    byte[] array = obtainArray();
+
+    Options options1 = new Options();
+    options1.inScaled = false;
+
+    Bitmap b = BitmapFactory.decodeByteArray(array, 0, array.length, options1);
+    assertThat(b).isNotNull();
+    // Test the bitmap size
+    assertThat(b.getHeight()).isEqualTo(START_HEIGHT);
+    assertThat(b.getWidth()).isEqualTo(START_WIDTH);
+  }
+
+  @Test
+  public void testDecodeByteArray2() {
+    byte[] array = obtainArray();
+    Bitmap b = BitmapFactory.decodeByteArray(array, 0, array.length);
+    assertThat(b).isNotNull();
+    // Test the bitmap size
+    assertThat(b.getHeight()).isEqualTo(START_HEIGHT);
+    assertThat(b.getWidth()).isEqualTo(START_WIDTH);
+  }
+
+  private byte[] obtainArray() {
+    ByteArrayOutputStream stm = new ByteArrayOutputStream();
+    Options opt = new BitmapFactory.Options();
+    opt.inScaled = false;
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image, opt);
+    bitmap.compress(CompressFormat.PNG, 0, stm);
+    return stm.toByteArray();
+  }
+
+  /**
+   * When methods such as {@link BitmapFactory#decodeStream(InputStream, Rect, Options)} are called
+   * with invalid Bitmap data, the return value should be null, and {@link
+   * BitmapFactory.Options#outWidth} and {@link BitmapFactory.Options#outHeight} should be set to
+   * -1. This tests fails in Robolectric due to legacy BitmapFactory behavior of always returning a
+   * Bitmap object, even if the bitmap data is invalid. Once {@link
+   * org.robolectric.shadows.ShadowBitmap} defaults to not allowing invalid Bitmap data, this test
+   * can be enabled for Robolectric.
+   */
+  @Test
+  public void decodeStream_options_setsOutWidthToMinusOne() {
+    TruthJUnit.assume().that(Build.FINGERPRINT).isNotEqualTo("robolectric");
+    byte[] invalidBitmapPixels = "invalid bitmap pixels".getBytes(Charset.defaultCharset());
+    ByteArrayInputStream inputStream = new ByteArrayInputStream(invalidBitmapPixels);
+    BitmapFactory.Options opts = new Options();
+    Bitmap result = BitmapFactory.decodeStream(inputStream, null, opts);
+    assertThat(result).isEqualTo(null);
+    assertThat(opts.outWidth).isEqualTo(-1);
+    assertThat(opts.outHeight).isEqualTo(-1);
+  }
+
+  @Test
+  public void decodeFile_scaledDensity_shouldHaveCorrectWidthAndHeight() throws Exception {
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    opts.inScaled = true;
+    opts.inDensity = 2;
+    opts.inTargetDensity = 1;
+    Bitmap bitmap = BitmapFactory.decodeStream(new ByteArrayInputStream(obtainArray()), null, opts);
+
+    assertThat(bitmap.getWidth()).isEqualTo(round(START_WIDTH / 2f));
+    assertThat(bitmap.getHeight()).isEqualTo(round(START_HEIGHT / 2f));
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/graphics/BitmapTest.java b/integration_tests/ctesque/src/sharedTest/java/android/graphics/BitmapTest.java
new file mode 100644
index 0000000..fddac6f
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/graphics/BitmapTest.java
@@ -0,0 +1,696 @@
+package android.graphics;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static androidx.test.InstrumentationRegistry.getTargetContext;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.graphics.HardwareRendererCompat;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.IntBuffer;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.testapp.R;
+
+/** Compatibility test for {@link Bitmap} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class BitmapTest {
+
+  private Resources resources;
+
+  @Before
+  public void setUp() {
+    resources = getTargetContext().getResources();
+  }
+
+  @Config(minSdk = P)
+  @SdkSuppress(minSdkVersion = P)
+  @Test public void createBitmap() {
+    // Bitmap.createBitmap(Picture) requires hardware-backed bitmaps
+    HardwareRendererCompat.setDrawingEnabled(true);
+    Picture picture = new Picture();
+    Canvas canvas = picture.beginRecording(200, 100);
+
+    Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+    p.setColor(0x88FF0000);
+    canvas.drawCircle(50, 50, 40, p);
+
+    p.setColor(Color.GREEN);
+    p.setTextSize(30);
+    canvas.drawText("Pictures", 60, 60, p);
+    picture.endRecording();
+
+    Bitmap bitmap = Bitmap.createBitmap(picture);
+    assertThat(bitmap).isNotNull();
+  }
+
+  @Test
+  public void testEraseColor() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xffff0000);
+    assertThat(bitmap.getPixel(10, 10)).isEqualTo(0xffff0000);
+    assertThat(bitmap.getPixel(50, 50)).isEqualTo(0xffff0000);
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = M) // getAlpha() returns 0 on less than M
+  public void testExtractAlpha() {
+    // normal case
+    Bitmap bitmap =
+        BitmapFactory.decodeResource(resources, R.drawable.an_image, new BitmapFactory.Options());
+    Bitmap ret = bitmap.extractAlpha();
+    int source = bitmap.getPixel(10, 20);
+    int result = ret.getPixel(10, 20);
+    assertThat(Color.alpha(result)).isEqualTo(Color.alpha(source));
+  }
+
+  @Test
+  public void testCopy() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Bitmap copy = bitmap.copy(Bitmap.Config.ARGB_8888, false);
+    assertThat(copy.getWidth()).isEqualTo(bitmap.getWidth());
+    assertThat(copy.getHeight()).isEqualTo(bitmap.getHeight());
+    assertThat(copy.getConfig()).isEqualTo(bitmap.getConfig());
+  }
+
+  @Test
+  public void testCopyAndEraseColor() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xffffff00);
+    assertThat(bitmap.getPixel(10, 10)).isEqualTo(0xffffff00);
+    assertThat(bitmap.getPixel(50, 50)).isEqualTo(0xffffff00);
+
+    Bitmap copy = bitmap.copy(Bitmap.Config.ARGB_8888, true);
+    assertThat(copy.getPixel(10, 10)).isEqualTo(0xffffff00);
+    assertThat(copy.getPixel(50, 50)).isEqualTo(0xffffff00);
+
+    copy.eraseColor(0xffff0000);
+    assertThat(copy.getPixel(10, 10)).isEqualTo(0xffff0000);
+    assertThat(copy.getPixel(50, 50)).isEqualTo(0xffff0000);
+  }
+
+  @Test
+  public void compress() {
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image);
+
+    ByteArrayOutputStream stm = new ByteArrayOutputStream();
+    assertThat(bitmap.compress(CompressFormat.JPEG, 0, stm)).isTrue();
+    assertThat(stm.toByteArray()).isNotEmpty();
+  }
+
+  @Test
+  public void getConfigAfterCompress() throws IOException {
+    InputStream inputStream = resources.getAssets().open("robolectric.png");
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+    Matrix matrix = new Matrix();
+    matrix.setScale(0.5f, 0.5f);
+    Bitmap scaledBitmap =
+        Bitmap.createBitmap(
+            bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, /* filter */ true);
+    assertThat(scaledBitmap.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888);
+  }
+
+  @Test
+  public void getConfigAfterCreateScaledBitmap() throws IOException {
+    InputStream inputStream = resources.getAssets().open("robolectric.png");
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+    Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, 50, 50, /* filter= */ false);
+    assertThat(scaledBitmap.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888);
+  }
+
+  @Test
+  public void scaledBitmap_sameAs() {
+    Bitmap bitmap1 = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap1.eraseColor(0xffff0000);
+    Bitmap bitmap2 = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap2.eraseColor(0xff00ff00);
+    assertThat(bitmap1.sameAs(bitmap2)).isFalse();
+
+    Bitmap scaled1 = Bitmap.createScaledBitmap(bitmap1, 200, 200, false);
+    Bitmap scaled2 = Bitmap.createScaledBitmap(bitmap2, 200, 200, false);
+    assertThat(scaled1.sameAs(scaled2)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN)
+  public void checkBitmapNotRecycled() throws IOException {
+    InputStream inputStream = resources.getAssets().open("robolectric.png");
+    BitmapFactory.Options options = new BitmapFactory.Options();
+    options.inScaled = true;
+    options.inDensity = 100;
+    options.inTargetDensity = 500;
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, options);
+    assertThat(bitmap.isRecycled()).isFalse();
+  }
+
+  @Test
+  public void decodeResource_withMutableOpt_isMutable() {
+    BitmapFactory.Options opt = new BitmapFactory.Options();
+    opt.inMutable = true;
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image, opt);
+    assertThat(bitmap.isMutable()).isTrue();
+  }
+
+  @Test
+  public void scaledBitmap_isMutable() throws IOException {
+    InputStream inputStream = resources.getAssets().open("robolectric.png");
+    BitmapFactory.Options opt = new BitmapFactory.Options();
+    opt.inMutable = true;
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, opt);
+    Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, 50, 50, false);
+    assertThat(scaledBitmap.isMutable()).isTrue();
+  }
+
+  @Test
+  public void colorDrawable_drawToBitmap() {
+    Drawable colorDrawable = new ColorDrawable(Color.RED);
+    Bitmap bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(bitmap);
+    assertThat(canvas.getWidth()).isEqualTo(1);
+    assertThat(canvas.getHeight()).isEqualTo(1);
+    colorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+    colorDrawable.draw(canvas);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(Color.RED);
+  }
+
+  @Test
+  public void drawCanvas_bitmap_sameSize() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xff00ff00);
+    Bitmap output = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    canvas.drawBitmap(bitmap, 0, 0, null);
+    assertThat(bitmap.sameAs(output)).isTrue();
+  }
+
+  @Test
+  public void drawCanvas_bitmap_centered() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xff00ff00);
+    Bitmap output = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    canvas.drawBitmap(bitmap, 50, 50, null);
+    assertThat(output.getPixel(49, 49)).isEqualTo(0);
+    assertThat(output.getPixel(50, 50)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(149, 149)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(150, 150)).isEqualTo(0);
+  }
+
+  @Test
+  public void drawCanvas_overflow_topLeft() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xff00ff00);
+    Bitmap output = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    canvas.drawBitmap(bitmap, -50, -50, null);
+    assertThat(output.getPixel(0, 0)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(49, 49)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(50, 50)).isEqualTo(0);
+  }
+
+  @Test
+  public void drawCanvas_overflow_topRight() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xff00ff00);
+    Bitmap output = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    canvas.drawBitmap(bitmap, 50, -50, null);
+    assertThat(output.getPixel(99, 0)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(50, 49)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(49, 50)).isEqualTo(0);
+  }
+
+  @Test
+  public void drawCanvas_overflow_bottomLeft() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xff00ff00);
+    Bitmap output = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    canvas.drawBitmap(bitmap, -50, 50, null);
+    assertThat(output.getPixel(0, 99)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(49, 50)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(50, 49)).isEqualTo(0);
+  }
+
+  @Test
+  public void drawCanvas_overflow_bottomRight() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0xff00ff00);
+    Bitmap output = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    canvas.drawBitmap(bitmap, 50, 50, null);
+    assertThat(output.getPixel(99, 99)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(50, 50)).isEqualTo(0xff00ff00);
+    assertThat(output.getPixel(49, 49)).isEqualTo(0);
+  }
+
+  @Test
+  public void createScaledBitmap_zeroWidthAndHeight_error() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Exception exception =
+        assertThrows(
+            IllegalArgumentException.class, () -> Bitmap.createScaledBitmap(bitmap, 0, 0, false));
+
+    assertThat(exception).hasMessageThat().contains("width and height must be > 0");
+  }
+
+  @Test
+  public void getBitmapPixels_strideTooLong() {
+    int[] bitmapPixels = {1, 2, 3, 4, 5, 6, 7, 8, 9};
+    Bitmap bitmap = Bitmap.createBitmap(bitmapPixels, 3, 3, Bitmap.Config.ARGB_8888);
+    int[] pixelsCopy = new int[bitmap.getHeight() * bitmap.getWidth()];
+    assertThrows(
+        ArrayIndexOutOfBoundsException.class,
+        () ->
+            bitmap.getPixels(
+                pixelsCopy, 0, bitmap.getRowBytes(), 0, 0, bitmap.getWidth(), bitmap.getHeight()));
+  }
+
+  @Test
+  public void eraseColor_toTransparent() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(0);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  @SdkSuppress(minSdkVersion = KITKAT)
+  public void reconfigure_drawPixel() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 50, Bitmap.Config.ARGB_8888);
+    bitmap.reconfigure(50, 100, Bitmap.Config.ARGB_8888);
+    bitmap.setPixel(0, 99, Color.RED);
+    assertThat(bitmap.getPixel(0, 99)).isEqualTo(Color.RED);
+  }
+
+  /**
+   * Questionable ARGB_8888 pixel values like '10' may be simplified by some graphics engines to
+   * '0'. This happens because '10' has alpha transparency '0', so the values for RGB don't matter.
+   * This happens when Java's Graphics2d is used for certain.
+   */
+  @Test
+  public void recompress_png100_samePixelss() {
+    Bitmap applicationIconBitmap =
+        Bitmap.createBitmap(new int[] {10, 11, 12, 13}, 2, 2, Bitmap.Config.ARGB_8888);
+
+    BitmapDrawable applicationIcon = new BitmapDrawable(resources, applicationIconBitmap);
+
+    ByteArrayOutputStream outputStream1 = new ByteArrayOutputStream();
+    applicationIconBitmap.compress(CompressFormat.PNG, 100, outputStream1);
+
+    Bitmap bitmap =
+        Bitmap.createBitmap(
+            applicationIcon.getIntrinsicWidth(),
+            applicationIcon.getIntrinsicHeight(),
+            Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(bitmap);
+    applicationIcon.draw(canvas);
+    ByteArrayOutputStream outputStream2 = new ByteArrayOutputStream();
+    bitmap.compress(CompressFormat.PNG, 100, outputStream2);
+    assertThat(outputStream2.toByteArray()).isEqualTo(outputStream1.toByteArray());
+  }
+
+  @Test
+  public void compress_thenDecodeStream_sameAs() {
+    Bitmap bitmap = Bitmap.createBitmap(/* width= */ 10, /* height= */ 10, Bitmap.Config.ARGB_8888);
+    ByteArrayOutputStream outStream = new ByteArrayOutputStream();
+    bitmap.compress(CompressFormat.PNG, /* quality= */ 100, outStream);
+    byte[] outBytes = outStream.toByteArray();
+    ByteArrayInputStream inStream = new ByteArrayInputStream(outBytes);
+    Bitmap bitmap2 = BitmapFactory.decodeStream(inStream);
+    assertThat(bitmap.sameAs(bitmap2)).isTrue();
+  }
+
+  @Test
+  public void compress_asJpeg_convertsTransparentToBlack() {
+    Bitmap bitmap = Bitmap.createBitmap(/* width= */ 10, /* height= */ 10, Bitmap.Config.ARGB_8888);
+    ByteArrayOutputStream outStream = new ByteArrayOutputStream();
+    bitmap.compress(CompressFormat.JPEG, /* quality= */ 90, outStream);
+    byte[] outBytes = outStream.toByteArray();
+    assertThat(outBytes).isNotEmpty();
+    ByteArrayInputStream inStream = new ByteArrayInputStream(outBytes);
+    Bitmap bitmap2 = BitmapFactory.decodeStream(inStream);
+    assertThat(bitmap2.getPixel(0, 0)).isEqualTo(Color.BLACK);
+  }
+
+  @Test
+  public void createBitmapWithOffsetAndStride() {
+    int[] pixels = new int[10];
+    Bitmap result = Bitmap.createBitmap(pixels, 0, 2, 2, 5, Bitmap.Config.ARGB_8888);
+    assertThat(result).isNotNull();
+  }
+
+  @Test
+  public void createBitmap_mutability() {
+    // Mutable constructor variants.
+    assertThat(
+            Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.ARGB_8888)
+                .isMutable())
+        .isTrue();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+      assertThat(
+              Bitmap.createBitmap(
+                      (DisplayMetrics) null,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888)
+                  .isMutable())
+          .isTrue();
+    }
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      assertThat(
+              Bitmap.createBitmap(
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ true)
+                  .isMutable())
+          .isTrue();
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ true)
+                  .isMutable())
+          .isTrue();
+    }
+
+    // Immutable constructor variants.
+    assertThat(
+            Bitmap.createBitmap(
+                    /* colors= */ new int[] {0},
+                    /* width= */ 1,
+                    /* height= */ 1,
+                    Bitmap.Config.ARGB_8888)
+                .isMutable())
+        .isFalse();
+    assertThat(
+            Bitmap.createBitmap(
+                    /* colors= */ new int[] {0},
+                    /* offset= */ 0,
+                    /* stride= */ 1,
+                    /* width= */ 1,
+                    /* height= */ 1,
+                    Bitmap.Config.ARGB_8888)
+                .isMutable())
+        .isFalse();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* colors= */ new int[] {0},
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888)
+                  .isMutable())
+          .isFalse();
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* colors= */ new int[] {0},
+                      /* offset= */ 0,
+                      /* stride= */ 1,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888)
+                  .isMutable())
+          .isFalse();
+    }
+  }
+
+  @Test
+  public void createBitmap_hasAlpha() {
+    // ARGB_8888 has alpha by default.
+    assertThat(
+            Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.ARGB_8888)
+                .hasAlpha())
+        .isTrue();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      assertThat(
+              Bitmap.createBitmap(
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ true)
+                  .hasAlpha())
+          .isTrue();
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ true)
+                  .hasAlpha())
+          .isTrue();
+    }
+    assertThat(
+            Bitmap.createBitmap(
+                    /* colors= */ new int[] {0},
+                    /* width= */ 1,
+                    /* height= */ 1,
+                    Bitmap.Config.ARGB_8888)
+                .hasAlpha())
+        .isTrue();
+
+    // Doesn't have alpha
+    assertThat(
+            Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.RGB_565).hasAlpha())
+        .isFalse();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      assertThat(
+              Bitmap.createBitmap(
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ false)
+                  .hasAlpha())
+          .isFalse();
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ false)
+                  .hasAlpha())
+          .isFalse();
+    }
+  }
+
+  @Config(minSdk = JELLY_BEAN_MR1)
+  @SdkSuppress(minSdkVersion = JELLY_BEAN_MR1)
+  @Test
+  public void createBitmap_premultiplied() {
+    // ARGB_8888 has alpha by default, is premultiplied.
+    assertThat(
+            Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.ARGB_8888)
+                .isPremultiplied())
+        .isTrue();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      assertThat(
+              Bitmap.createBitmap(
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ true)
+                  .isPremultiplied())
+          .isTrue();
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ true)
+                  .isPremultiplied())
+          .isTrue();
+    }
+    assertThat(
+            Bitmap.createBitmap(
+                    /* colors= */ new int[] {0},
+                    /* width= */ 1,
+                    /* height= */ 1,
+                    Bitmap.Config.ARGB_8888)
+                .isPremultiplied())
+        .isTrue();
+
+    // Doesn't have alpha, is not premultiplied
+    assertThat(
+            Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Bitmap.Config.RGB_565)
+                .isPremultiplied())
+        .isFalse();
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      assertThat(
+              Bitmap.createBitmap(
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ false)
+                  .isPremultiplied())
+          .isFalse();
+      assertThat(
+              Bitmap.createBitmap(
+                      /* display= */ null,
+                      /* width= */ 1,
+                      /* height= */ 1,
+                      Bitmap.Config.ARGB_8888,
+                      /* hasAlpha= */ false)
+                  .isPremultiplied())
+          .isFalse();
+    }
+  }
+
+  @Test
+  public void extractAlpha_isMutable() {
+    Bitmap result = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Bitmap alphaBitmap = result.extractAlpha();
+    assertThat(alphaBitmap.isMutable()).isTrue();
+  }
+
+  @Test
+  public void createBitmap_withBitmap_containsImageData() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(Color.BLUE);
+    Bitmap cropped = Bitmap.createBitmap(bitmap, 0, 0, 50, 50);
+    assertThat(cropped.isMutable()).isTrue();
+    assertThat(cropped.getPixel(0, 0)).isEqualTo(Color.BLUE);
+  }
+
+  @Test
+  public void createBitmap_withBitmap_thenCopy_isValid() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(Color.BLUE);
+    Bitmap cropped = Bitmap.createBitmap(bitmap, 50, 50, 50, 50);
+    Bitmap copy = cropped.copy(Bitmap.Config.ARGB_8888, true);
+    assertThat(copy.isMutable()).isTrue();
+    assertThat(copy.getPixel(0, 0)).isEqualTo(Color.BLUE);
+  }
+
+  @Test
+  public void copyPixelsFromBuffer_intBuffer() {
+    Bitmap bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888);
+    IntBuffer input = IntBuffer.allocate(bitmap.getWidth() * bitmap.getHeight());
+    for (int i = 0; i < input.capacity(); i++) {
+      // IntBuffer is interpreted as ABGR. Use A=255 to avoid premultiplication.
+      input.put((0xFF << 24) | (i + 2) << 16 | (i + 1) << 8 | i);
+    }
+    input.rewind();
+    bitmap.copyPixelsFromBuffer(input);
+
+    IntBuffer output = IntBuffer.allocate(input.capacity());
+    bitmap.copyPixelsToBuffer(output);
+
+    input.rewind();
+    output.rewind();
+
+    assertThat(output).isEqualTo(input);
+  }
+
+  @Test
+  public void copyPixelsFromBuffer_byteBuffer() {
+    Bitmap bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888);
+    ByteBuffer input = ByteBuffer.allocate(bitmap.getWidth() * bitmap.getHeight() * 4);
+    for (int i = 0; i < bitmap.getWidth() * bitmap.getHeight(); i++) {
+      // ByteBuffer is interpreted as RGBA. Use A=255 to avoid premultiplication.
+      input.put((byte) i);
+      input.put((byte) (i + 1));
+      input.put((byte) (i + 2));
+      input.put((byte) 0xFF);
+    }
+    input.rewind();
+    bitmap.copyPixelsFromBuffer(input);
+
+    ByteBuffer output = ByteBuffer.allocate(input.capacity());
+    bitmap.copyPixelsToBuffer(output);
+
+    input.rewind();
+    output.rewind();
+
+    assertThat(output).isEqualTo(input);
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Config(minSdk = O)
+  @Test
+  public void createBitmap_colorSpace_defaultColorSpace() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+
+    assertThat(bitmap.getColorSpace()).isEqualTo(ColorSpace.get(ColorSpace.Named.SRGB));
+  }
+
+  @SdkSuppress(minSdkVersion = O)
+  @Config(minSdk = O)
+  @Test
+  public void createBitmap_colorSpace_customColorSpace() {
+    Bitmap bitmap =
+        Bitmap.createBitmap(
+            100, 100, Bitmap.Config.ARGB_8888, true, ColorSpace.get(ColorSpace.Named.ADOBE_RGB));
+
+    assertThat(bitmap.getColorSpace()).isEqualTo(ColorSpace.get(ColorSpace.Named.ADOBE_RGB));
+  }
+
+  @SdkSuppress(minSdkVersion = Q)
+  @Config(minSdk = Q)
+  @Test
+  public void setColorSpace() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.setColorSpace(ColorSpace.get(ColorSpace.Named.ADOBE_RGB));
+
+    assertThat(bitmap.getColorSpace()).isEqualTo(ColorSpace.get(ColorSpace.Named.ADOBE_RGB));
+  }
+
+  @SdkSuppress(minSdkVersion = LOLLIPOP)
+  @Config(minSdk = LOLLIPOP)
+  @Test
+  public void bitmapDrawable_mutate() {
+    BitmapDrawable drawable1 = (BitmapDrawable) resources.getDrawable(R.drawable.an_image);
+    BitmapDrawable drawable2 = (BitmapDrawable) resources.getDrawable(R.drawable.an_image);
+
+    Drawable mutated1 = drawable1.mutate();
+    Drawable mutated2 = drawable2.mutate();
+    mutated1.setAlpha(100);
+    mutated1.setColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN);
+    mutated2.setAlpha(200);
+    mutated2.setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN);
+    assertThat(mutated1.getAlpha()).isEqualTo(100);
+    // ColorFilter is part of the Drawable paint, so BLUE is overridden by RED.
+    assertThat(mutated1.getColorFilter())
+        .isEqualTo(new PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN));
+    assertThat(mutated2.getAlpha()).isEqualTo(200);
+    assertThat(mutated2.getColorFilter())
+        .isEqualTo(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
+  }
+
+  @Test
+  public void null_bitmapConfig_throwsNPE() {
+    assertThrows(NullPointerException.class, () -> Bitmap.createBitmap(100, 100, null));
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/graphics/CanvasTest.java b/integration_tests/ctesque/src/sharedTest/java/android/graphics/CanvasTest.java
new file mode 100644
index 0000000..039b5b6
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/graphics/CanvasTest.java
@@ -0,0 +1,30 @@
+package android.graphics;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Compatibility tests for {@link Canvas} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class CanvasTest {
+  @Test
+  public void getClipBounds_emptyClip() {
+    Canvas canvas = new Canvas();
+    Rect r = canvas.getClipBounds();
+    assertThat(r).isEqualTo(new Rect(0, 0, 0, 0));
+    assertThat(canvas.getClipBounds(new Rect())).isFalse();
+  }
+
+  @Test
+  public void getClipBounds_backingBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(bitmap);
+    Rect r = canvas.getClipBounds();
+    assertThat(r).isEqualTo(new Rect(0, 0, 100, 100));
+    assertThat(canvas.getClipBounds(new Rect())).isTrue();
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/graphics/MatrixTest.java b/integration_tests/ctesque/src/sharedTest/java/android/graphics/MatrixTest.java
new file mode 100644
index 0000000..14ecd5b
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/graphics/MatrixTest.java
@@ -0,0 +1,126 @@
+package android.graphics;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.graphics.Matrix.ScaleToFit;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Compatibility test for {@link Matrix} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public final class MatrixTest {
+
+  @Test
+  public void mapRadius() throws Exception {
+    Matrix matrix = new Matrix();
+
+    assertThat(matrix.mapRadius(100f)).isEqualTo(100f);
+    assertThat(matrix.mapRadius(Float.MAX_VALUE)).isEqualTo(Float.POSITIVE_INFINITY);
+    assertThat(matrix.mapRadius(Float.MIN_VALUE)).isEqualTo(0f);
+
+    matrix.postScale(2.0f, 2.0f);
+    assertThat(matrix.mapRadius(1.0f)).isWithin(0.01f).of(2.0f);
+  }
+
+  @Test
+  public void mapPoints() {
+    float[] value = new float[9];
+    value[0] = 100f;
+    new Matrix().mapPoints(value);
+    assertThat(value[0]).isEqualTo(100f);
+  }
+
+  @Test
+  public void mapPointsNull() {
+    assertThrows(Exception.class, () -> new Matrix().mapPoints(null));
+  }
+
+  @Test
+  public void mapPoints2() {
+    float[] dst = new float[9];
+    dst[0] = 100f;
+    float[] src = new float[9];
+    src[0] = 200f;
+    new Matrix().mapPoints(dst, src);
+    assertThat(dst[0]).isEqualTo(200f);
+  }
+
+  @Test
+  public void mapPointsArraysMismatch() {
+    assertThrows(Exception.class, () -> new Matrix().mapPoints(new float[8], new float[9]));
+  }
+
+  @Test
+  public void mapPointsWithIndices() {
+    float[] dst = new float[9];
+    dst[0] = 100f;
+    float[] src = new float[9];
+    src[0] = 200f;
+    new Matrix().mapPoints(dst, 0, src, 0, 9 >> 1);
+    assertThat(dst[0]).isEqualTo(200f);
+  }
+
+  @Test
+  public void mapPointsWithIndicesNull() {
+    assertThrows(Exception.class, () -> new Matrix().mapPoints(null, 0, new float[9], 0, 1));
+  }
+
+  @Test
+  public void setRectToRect() {
+    RectF r1 = new RectF();
+    r1.set(1f, 2f, 3f, 3f);
+    RectF r2 = new RectF();
+    r1.set(10f, 20f, 30f, 30f);
+    Matrix matrix = new Matrix();
+    float[] result = new float[9];
+
+    assertThat(matrix.setRectToRect(r1, r2, ScaleToFit.CENTER)).isTrue();
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    matrix.setRectToRect(r1, r2, ScaleToFit.END);
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    matrix.setRectToRect(r1, r2, ScaleToFit.FILL);
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    matrix.setRectToRect(r1, r2, ScaleToFit.START);
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    assertThat(matrix.setRectToRect(r2, r1, ScaleToFit.CENTER)).isFalse();
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    assertThat(matrix.setRectToRect(r2, r1, ScaleToFit.FILL)).isFalse();
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    assertThat(matrix.setRectToRect(r2, r1, ScaleToFit.START)).isFalse();
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+
+    assertThat(matrix.setRectToRect(r2, r1, ScaleToFit.END)).isFalse();
+    matrix.getValues(result);
+    assertThat(result)
+        .isEqualTo(new float[] {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f});
+  }
+
+  @Test
+  public void testSetRectToRectNull() {
+    assertThrows(Exception.class, () -> new Matrix().setRectToRect(null, null, ScaleToFit.CENTER));
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/graphics/PathTest.java b/integration_tests/ctesque/src/sharedTest/java/android/graphics/PathTest.java
new file mode 100644
index 0000000..f92d5a6
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/graphics/PathTest.java
@@ -0,0 +1,125 @@
+package android.graphics;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Compatibility test for {@link Path} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class PathTest {
+
+  // Test constants
+  private static final float LEFT = 10.0f;
+  private static final float RIGHT = 50.0f;
+  private static final float TOP = 10.0f;
+  private static final float BOTTOM = 50.0f;
+  private static final float XCOORD = 40.0f;
+  private static final float YCOORD = 40.0f;
+
+  @Test
+  public void moveTo() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+
+    path.moveTo(0, 0);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void lineTo() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    path.lineTo(XCOORD, YCOORD);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void quadTo() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    path.quadTo(20.0f, 20.0f, 40.0f, 40.0f);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void addRect1() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    RectF rect = new RectF(LEFT, TOP, RIGHT, BOTTOM);
+    path.addRect(rect, Path.Direction.CW);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void addRect2() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    path.addRect(LEFT, TOP, RIGHT, BOTTOM, Path.Direction.CW);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void getFillType() {
+    Path path = new Path();
+    path.setFillType(Path.FillType.EVEN_ODD);
+    assertThat(path.getFillType()).isEqualTo(Path.FillType.EVEN_ODD);
+  }
+
+  @Test
+  public void transform() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+
+    Path dst = new Path();
+    path.addRect(new RectF(LEFT, TOP, RIGHT, BOTTOM), Path.Direction.CW);
+    path.transform(new Matrix(), dst);
+
+    assertThat(dst.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void testAddCircle() {
+    // new the Path instance
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    path.addCircle(XCOORD, YCOORD, 10.0f, Path.Direction.CW);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void arcTo1() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    RectF oval = new RectF(LEFT, TOP, RIGHT, BOTTOM);
+    path.arcTo(oval, 0.0f, 30.0f, true);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void arcTo2() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    RectF oval = new RectF(LEFT, TOP, RIGHT, BOTTOM);
+    path.arcTo(oval, 0.0f, 30.0f);
+    assertThat(path.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void close() {
+    Path path = new Path();
+    assertThat(path.isEmpty()).isTrue();
+    path.close();
+  }
+
+  @Test
+  public void invalidArc_doesNotNPE() {
+    Path path = new Path();
+    // This arc is invalid because the bounding rectangle has left > right and top > bottom.
+    path.arcTo(new RectF(1, 1, 0, 0), 0, 30);
+    assertThat(path.isEmpty()).isTrue();
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/text/format/DateFormatTest.java b/integration_tests/ctesque/src/sharedTest/java/android/text/format/DateFormatTest.java
new file mode 100644
index 0000000..651e2c4
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/text/format/DateFormatTest.java
@@ -0,0 +1,72 @@
+package android.text.format;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import java.util.Calendar;
+import java.util.Date;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests that Robolectric's android.text.format.DateFormat support is consistent with device. */
+@RunWith(AndroidJUnit4.class)
+@DoNotInstrument
+public class DateFormatTest {
+
+  private Date dateAM;
+  private Date datePM;
+
+  @Before
+  public void setDate() {
+    Calendar c = Calendar.getInstance();
+    c.set(2000, 10, 25, 8, 24, 30);
+    dateAM = c.getTime();
+    c.set(2000, 10, 25, 16, 24, 30);
+    datePM = c.getTime();
+  }
+
+  @Test
+  public void getLongDateFormat() {
+    assertThat(DateFormat.getLongDateFormat(getApplicationContext()).format(dateAM))
+        .isEqualTo("November 25, 2000");
+  }
+
+  @Test
+  public void getMediumDateFormat() {
+    assertThat(DateFormat.getMediumDateFormat(getApplicationContext()).format(dateAM))
+        .isEqualTo("Nov 25, 2000");
+  }
+
+  @SdkSuppress(maxSdkVersion = 22)
+  @Config(maxSdk = 22)
+  @Test
+  public void getDateFormat_pre23() {
+    assertThat(DateFormat.getDateFormat(getApplicationContext()).format(dateAM))
+        .isEqualTo("11/25/2000");
+  }
+
+  @SdkSuppress(minSdkVersion = 23)
+  @Config(minSdk = 23)
+  @Test
+  public void getDateFormat() {
+    assertThat(DateFormat.getDateFormat(getApplicationContext()).format(dateAM))
+        .isEqualTo("11/25/00");
+  }
+
+  @Test
+  public void getTimeFormat_am() {
+    assertThat(DateFormat.getTimeFormat(getApplicationContext()).format(dateAM))
+        .isEqualTo("8:24 AM");
+  }
+
+  @Test
+  public void getTimeFormat_pm() {
+    assertThat(DateFormat.getTimeFormat(getApplicationContext()).format(datePM))
+        .isEqualTo("4:24 PM");
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/text/format/LineBreakerTest.java b/integration_tests/ctesque/src/sharedTest/java/android/text/format/LineBreakerTest.java
new file mode 100644
index 0000000..8bf97ce
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/text/format/LineBreakerTest.java
@@ -0,0 +1,42 @@
+package android.text.format;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Paint;
+import android.graphics.text.LineBreaker;
+import android.graphics.text.LineBreaker.ParagraphConstraints;
+import android.graphics.text.LineBreaker.Result;
+import android.graphics.text.MeasuredText;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests that Robolectric's android.graphics.text.LineBreaker support is consistent with device. */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class LineBreakerTest {
+  @Test
+  @Config(minSdk = Q)
+  public void breakLines() {
+    String text = "Hello, Android.";
+    MeasuredText mt =
+        new MeasuredText.Builder(text.toCharArray())
+            .appendStyleRun(new Paint(), text.length(), false)
+            .build();
+
+    LineBreaker lb =
+        new LineBreaker.Builder()
+            .setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE)
+            .setHyphenationFrequency(LineBreaker.HYPHENATION_FREQUENCY_NONE)
+            .build();
+
+    ParagraphConstraints c = new ParagraphConstraints();
+    c.setWidth(240);
+    Result r = lb.computeLineBreaks(mt, c, 0);
+    assertThat(r.getLineCount()).isEqualTo(1);
+    assertThat(r.getLineBreakOffset(0)).isEqualTo(15);
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/text/format/StaticLayoutTest.java b/integration_tests/ctesque/src/sharedTest/java/android/text/format/StaticLayoutTest.java
new file mode 100644
index 0000000..51f49d6
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/text/format/StaticLayoutTest.java
@@ -0,0 +1,24 @@
+package android.text.format;
+
+import static android.os.Build.VERSION_CODES.P;
+
+import android.text.StaticLayout;
+import android.text.TextPaint;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests that Robolectric's android.text.StaticLayout support is consistent with device. */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class StaticLayoutTest {
+  @Test
+  @Config(minSdk = P)
+  public void testStaticLayout() {
+    StaticLayout.Builder.obtain("invalidEmail", 0, 12, new TextPaint(), 256)
+        .build()
+        .getPrimaryHorizontal(12);
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/text/format/TimeTest.java b/integration_tests/ctesque/src/sharedTest/java/android/text/format/TimeTest.java
new file mode 100644
index 0000000..0e3f989
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/text/format/TimeTest.java
@@ -0,0 +1,347 @@
+package android.text.format;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import android.util.TimeFormatException;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import java.util.Arrays;
+import java.util.TimeZone;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class TimeTest {
+
+  @Test
+  public void shouldParseRfc3339() {
+    for (String tz : Arrays.asList("Europe/Berlin", "America/Los Angeles", "Australia/Adelaide")) {
+      String desc = "Eval when local timezone is " + tz;
+      TimeZone.setDefault(TimeZone.getTimeZone(tz));
+
+      Time t = new Time("Europe/Berlin");
+      assertTrue(desc, t.parse3339("2008-10-13T16:30:50Z"));
+      assertEquals(desc, 2008, t.year);
+      assertEquals(desc, 9, t.month);
+      assertEquals(desc, 13, t.monthDay);
+      assertEquals(desc, 16, t.hour);
+      assertEquals(desc, 30, t.minute);
+      assertEquals(desc, 50, t.second);
+      assertEquals(desc, "UTC", t.timezone);
+      assertFalse(desc, t.allDay);
+
+      t = new Time("Europe/Berlin");
+      assertTrue(desc, t.parse3339("2008-10-13T16:30:50.000+07:00"));
+      assertEquals(desc, 2008, t.year);
+      assertEquals(desc, 9, t.month);
+      assertEquals(desc, 13, t.monthDay);
+      assertEquals(desc, 9, t.hour);
+      assertEquals(desc, 30, t.minute);
+      assertEquals(desc, 50, t.second);
+      assertEquals(desc, "UTC", t.timezone);
+      assertFalse(desc, t.allDay);
+
+      t = new Time("Europe/Berlin");
+      assertFalse(desc, t.parse3339("2008-10-13"));
+      assertEquals(desc, 2008, t.year);
+      assertEquals(desc, 9, t.month);
+      assertEquals(desc, 13, t.monthDay);
+      assertEquals(desc, 0, t.hour);
+      assertEquals(desc, 0, t.minute);
+      assertEquals(desc, 0, t.second);
+      assertEquals(desc, "Europe/Berlin", t.timezone);
+      assertTrue(desc, t.allDay);
+    }
+  }
+
+  private static final TimeZone DEFAULT_TIMEZONE = TimeZone.getDefault();
+
+  @After
+  public void tearDown() {
+    // Just in case any of the tests mess with the system-wide
+    // default time zone, make sure we've set it back to what
+    // it should be.
+    TimeZone.setDefault(DEFAULT_TIMEZONE);
+  }
+
+  @Test
+  public void shouldHaveNoArgsConstructor() {
+    Time t = new Time();
+    assertNotNull(t.timezone);
+  }
+
+  @Test
+  public void shouldHaveCopyConstructor() {
+    Time t = new Time();
+    t.setToNow();
+    Time t2 = new Time(t);
+    assertEquals(t.timezone, t2.timezone);
+    assertEquals(t.year, t2.year);
+    assertEquals(t.month, t2.month);
+    assertEquals(t.monthDay, t2.monthDay);
+    assertEquals(t.hour, t2.hour);
+    assertEquals(t.minute, t2.minute);
+    assertEquals(t.second, t2.second);
+  }
+
+  @Test
+  public void shouldHaveSetTime() {
+    Time t = new Time();
+    t.setToNow();
+    Time t2 = new Time();
+    t2.set(t);
+    assertEquals(t.timezone, t2.timezone);
+    assertEquals(t.year, t2.year);
+    assertEquals(t.month, t2.month);
+    assertEquals(t.monthDay, t2.monthDay);
+    assertEquals(t.hour, t2.hour);
+    assertEquals(t.minute, t2.minute);
+    assertEquals(t.second, t2.second);
+  }
+
+  @Test
+  public void shouldHaveSet3Args() {
+    Time t = new Time();
+    t.set(1, 1, 2000);
+    assertEquals(t.year, 2000);
+    assertEquals(t.month, 1);
+    assertEquals(t.monthDay, 1);
+  }
+
+  @Test
+  public void shouldHaveSet6Args() {
+    Time t = new Time();
+    t.set(1, 1, 1, 1, 1, 2000);
+    assertEquals(t.year, 2000);
+    assertEquals(t.month, 1);
+    assertEquals(t.monthDay, 1);
+    assertEquals(t.second, 1);
+    assertEquals(t.minute, 1);
+    assertEquals(t.hour, 1);
+  }
+
+  @Test
+  public void shouldHaveTimeZoneConstructor() {
+    Time t = new Time("UTC");
+    assertEquals(t.timezone, "UTC");
+  }
+
+  @Test
+  public void shouldClear() {
+    Time t = new Time();
+    t.setToNow();
+    t.clear("UTC");
+    assertEquals("UTC", t.timezone);
+    assertEquals(0, t.year);
+    assertEquals(0, t.month);
+    assertEquals(0, t.monthDay);
+    assertEquals(0, t.hour);
+    assertEquals(0, t.minute);
+    assertEquals(0, t.second);
+    assertEquals(0, t.weekDay);
+    assertEquals(0, t.yearDay);
+    assertEquals(0, t.gmtoff);
+    assertEquals(-1, t.isDst);
+  }
+
+  @Test
+  public void shouldHaveToMillis() {
+    Time t = new Time();
+    t.set(86400 * 1000);
+    assertEquals(86400 * 1000, t.toMillis(false));
+  }
+
+  @Test
+  public void shouldHaveCurrentTimeZone() {
+    assertNotNull(Time.getCurrentTimezone());
+  }
+
+  @Test
+  public void shouldSwitchTimeZones() {
+    Time t = new Time("UTC");
+
+    t.set(1414213562373L);
+    assertThat(t.timezone).isEqualTo("UTC");
+    assertThat(t.gmtoff).isEqualTo(0);
+    assertThat(t.format3339(false)).isEqualTo("2014-10-25T05:06:02.000Z");
+
+    t.switchTimezone("America/New_York");
+    assertThat(t.format3339(false)).isEqualTo("2014-10-25T01:06:02.000-04:00");
+    assertThat(t.timezone).isEqualTo("America/New_York");
+    assertThat(t.gmtoff).isEqualTo(-14400L);
+    assertThat(t.toMillis(true)).isEqualTo(1414213562000L);
+  }
+
+  @Test
+  public void shouldHaveCompareAndBeforeAfter() {
+    Time a = new Time();
+    Time b = new Time();
+
+    assertThat(Time.compare(a, b)).isEqualTo(0);
+    assertThat(a.before(b)).isFalse();
+    assertThat(a.after(b)).isFalse();
+
+    a.year = 2000;
+    assertThat(Time.compare(a, b)).isAtLeast(0);
+    assertThat(a.after(b)).isTrue();
+    assertThat(b.before(a)).isTrue();
+
+    b.year = 2001;
+    assertThat(Time.compare(a, b)).isAtMost(0);
+    assertThat(b.after(a)).isTrue();
+    assertThat(a.before(b)).isTrue();
+  }
+
+  @Test
+  public void shouldHaveParse() {
+    Time t = new Time("Europe/Berlin");
+    assertFalse(t.parse("20081013T160000"));
+    assertEquals(2008, t.year);
+    assertEquals(9, t.month);
+    assertEquals(13, t.monthDay);
+    assertEquals(16, t.hour);
+    assertEquals(0, t.minute);
+    assertEquals(0, t.second);
+
+    assertTrue(t.parse("20081013T160000Z"));
+    assertEquals(2008, t.year);
+    assertEquals(9, t.month);
+    assertEquals(13, t.monthDay);
+    assertEquals(16, t.hour);
+    assertEquals(0, t.minute);
+    assertEquals(0, t.second);
+  }
+
+  @Test
+  public void shouldThrowTimeFormatException() {
+    Time t = new Time();
+    assertThrows(TimeFormatException.class, () -> t.parse("BLARGH"));
+  }
+
+  @Test
+  public void shouldHaveParseShort() {
+    Time t = new Time();
+    t.parse("20081013");
+    assertEquals(2008, t.year);
+    assertEquals(9, t.month);
+    assertEquals(13, t.monthDay);
+    assertEquals(0, t.hour);
+    assertEquals(0, t.minute);
+    assertEquals(0, t.second);
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = JELLY_BEAN_MR1)
+  public void shouldFormat() {
+    Time t = new Time(Time.TIMEZONE_UTC);
+    t.set(3600000L);
+
+    assertEquals("Hello epoch 01 1970 01", t.format("Hello epoch %d %Y %d"));
+    assertEquals("Hello epoch  1:00 AM", t.format("Hello epoch %l:%M %p"));
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = JELLY_BEAN_MR1)
+  public void shouldFormatAndroidStrings() {
+    Time t = new Time("UTC");
+    // NOTE: month is zero-based.
+    t.set(12, 13, 14, 8, 8, 1987);
+
+    assertEquals(1987, t.year);
+    assertEquals(8, t.month);
+    assertEquals(8, t.monthDay);
+    assertEquals(14, t.hour);
+    assertEquals(13, t.minute);
+    assertEquals(12, t.second);
+
+    // ICS
+
+    // date_and_time
+    assertEquals(
+        "Sep 8, 1987, 2:13:12 PM",
+        t.format("%b %-e, %Y, %-l:%M:%S %p"));
+
+    // hour_minute_cap_ampm
+    assertEquals(
+        "2:13PM",
+        t.format("%-l:%M%^p"));
+  }
+
+  @Test
+  public void shouldFormat2445() {
+    Time t = new Time();
+    t.timezone = "PST";
+    assertEquals("19700101T000000", t.format2445());
+
+    t.timezone = Time.TIMEZONE_UTC;
+    //2445 formatted date should hava a Z postfix
+    assertEquals("19700101T000000Z",t.format2445());
+  }
+
+  @Test
+  public void shouldFormat3339() {
+    Time t = new Time("Europe/Berlin");
+    assertEquals("1970-01-01T00:00:00.000+00:00", t.format3339(false));
+    assertEquals("1970-01-01", t.format3339(true));
+  }
+
+  @Test
+  public void testIsEpoch() {
+    Time t = new Time(Time.TIMEZONE_UTC);
+    assertThat(Time.isEpoch(t)).isTrue();
+  }
+
+  @Test
+  public void testGetJulianDay() {
+    Time time = new Time();
+
+    time.set(0, 0, 0, 12, 5, 2008);
+    time.timezone = "Australia/Sydney";
+    long millis = time.normalize(true);
+
+    // This is the Julian day for 12am for this day of the year
+    int julianDay = Time.getJulianDay(millis, time.gmtoff);
+
+    // Change the time during the day and check that we get the same
+    // Julian day.
+    for (int hour = 0; hour < 24; hour++) {
+      for (int minute = 0; minute < 60; minute += 15) {
+        time.set(0, minute, hour, 12, 5, 2008);
+        millis = time.normalize(true);
+        int day = Time.getJulianDay(millis, time.gmtoff);
+
+        assertEquals(day, julianDay);
+      }
+    }
+  }
+
+  @Test
+  public void testSetJulianDay() {
+    Time time = new Time();
+    time.set(0, 0, 0, 12, 5, 2008);
+    time.timezone = "Australia/Sydney";
+    long millis = time.normalize(true);
+
+    int julianDay = Time.getJulianDay(millis, time.gmtoff);
+    time.setJulianDay(julianDay);
+
+    assertTrue(time.hour == 0 || time.hour == 1);
+    assertEquals(0, time.minute);
+    assertEquals(0, time.second);
+
+    millis = time.toMillis(false);
+    int day = Time.getJulianDay(millis, time.gmtoff);
+
+    assertEquals(day, julianDay);
+  }
+
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/view/KeyCharacterMapTest.java b/integration_tests/ctesque/src/sharedTest/java/android/view/KeyCharacterMapTest.java
new file mode 100644
index 0000000..aadad99
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/view/KeyCharacterMapTest.java
@@ -0,0 +1,112 @@
+package android.view;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/**
+ * Test {@link android.view.KeyCharacterMap}.
+ *
+ * <p>Inspired from Android cts/tests/tests/view/src/android/view/cts/KeyCharacterMap.java
+ */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public final class KeyCharacterMapTest {
+
+  private KeyCharacterMap keyCharacterMap;
+
+  @Before
+  public void setup() {
+    keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+  }
+
+  @Test
+  public void testLoad() {
+    keyCharacterMap = null;
+    keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.BUILT_IN_KEYBOARD);
+    assertNotNull(keyCharacterMap);
+  }
+
+  @Test
+  public void testGetMatchNull() {
+    assertThrows(
+        IllegalArgumentException.class, () -> keyCharacterMap.getMatch(KeyEvent.KEYCODE_0, null));
+  }
+
+  @Test
+  public void testGetMatchMetaStateNull() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> keyCharacterMap.getMatch(KeyEvent.KEYCODE_0, null, 1));
+  }
+
+  @Test
+  public void testGetKeyboardType() {
+    assertThat(keyCharacterMap.getKeyboardType()).isEqualTo(KeyCharacterMap.FULL);
+  }
+
+  @Test
+  public void testGetEventsNull() {
+    assertThrows(IllegalArgumentException.class, () -> keyCharacterMap.getEvents(null));
+  }
+
+  @Test
+  public void testGetEventsLowerCase() {
+    KeyEvent[] events = keyCharacterMap.getEvents("test".toCharArray());
+
+    assertThat(events[0].getAction()).isEqualTo(KeyEvent.ACTION_DOWN);
+    assertThat(events[0].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_T);
+    assertThat(events[1].getAction()).isEqualTo(KeyEvent.ACTION_UP);
+    assertThat(events[1].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_T);
+
+    assertThat(events[2].getAction()).isEqualTo(KeyEvent.ACTION_DOWN);
+    assertThat(events[2].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_E);
+    assertThat(events[3].getAction()).isEqualTo(KeyEvent.ACTION_UP);
+    assertThat(events[3].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_E);
+
+    assertThat(events[4].getAction()).isEqualTo(KeyEvent.ACTION_DOWN);
+    assertThat(events[4].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_S);
+    assertThat(events[5].getAction()).isEqualTo(KeyEvent.ACTION_UP);
+    assertThat(events[5].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_S);
+
+    assertThat(events[6].getAction()).isEqualTo(KeyEvent.ACTION_DOWN);
+    assertThat(events[6].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_T);
+    assertThat(events[7].getAction()).isEqualTo(KeyEvent.ACTION_UP);
+    assertThat(events[7].getKeyCode()).isEqualTo(KeyEvent.KEYCODE_T);
+  }
+
+  @Test
+  public void testGetEventsCapital() {
+    // Just assert that we got something back, there are many ways to return correct KeyEvents for
+    // this sequence.
+    assertThat(keyCharacterMap.getEvents("Test".toCharArray())).isNotEmpty();
+  }
+
+  @Test
+  public void testUnknownCharacters() {
+    assertThat(keyCharacterMap.get(KeyEvent.KEYCODE_UNKNOWN, 0)).isEqualTo(0);
+    assertThat(keyCharacterMap.get(KeyEvent.KEYCODE_BACK, 0)).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetNumber() {
+    assertThat(keyCharacterMap.getNumber(KeyEvent.KEYCODE_1)).isEqualTo('1');
+  }
+
+  @Test
+  public void testGetDisplayLabel() {
+    assertThat(keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_W)).isEqualTo('W');
+  }
+
+  @Test
+  public void testIsPrintingKey() {
+    assertThat(keyCharacterMap.isPrintingKey(KeyEvent.KEYCODE_W)).isTrue();
+    assertThat(keyCharacterMap.isPrintingKey(KeyEvent.KEYCODE_ALT_LEFT)).isFalse();
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/view/MotionEventTest.java b/integration_tests/ctesque/src/sharedTest/java/android/view/MotionEventTest.java
new file mode 100644
index 0000000..87f19de
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/view/MotionEventTest.java
@@ -0,0 +1,1010 @@
+package android.view;
+
+import static android.os.Build.VERSION_CODES.N;
+import static androidx.test.ext.truth.view.MotionEventSubject.assertThat;
+import static androidx.test.ext.truth.view.PointerCoordsSubject.assertThat;
+import static androidx.test.ext.truth.view.PointerPropertiesSubject.assertThat;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.graphics.Matrix;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.view.MotionEvent.PointerCoords;
+import android.view.MotionEvent.PointerProperties;
+import androidx.test.core.view.PointerCoordsBuilder;
+import androidx.test.core.view.PointerPropertiesBuilder;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/**
+ * Test {@link android.view.MotionEvent}.
+ *
+ * <p>Baselined from Android cts/tests/tests/view/src/android/view/cts/MotionEventTest.java
+ */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class MotionEventTest {
+  private MotionEvent motionEvent1;
+  private MotionEvent motionEvent2;
+  private MotionEvent motionEventDynamic;
+  private long downTime;
+  private long eventTime;
+  private static final float X_3F = 3.0f;
+  private static final float Y_4F = 4.0f;
+  private static final int META_STATE = KeyEvent.META_SHIFT_ON;
+  private static final float PRESSURE_1F = 1.0f;
+  private static final float SIZE_1F = 1.0f;
+  private static final float X_PRECISION_3F = 3.0f;
+  private static final float Y_PRECISION_4F = 4.0f;
+  private static final int DEVICE_ID_1 = 1;
+  private static final int EDGE_FLAGS = MotionEvent.EDGE_TOP;
+  private static final float TOLERANCE = 0.01f;
+
+  @Before
+  public void setup() {
+    downTime = SystemClock.uptimeMillis();
+    eventTime = SystemClock.uptimeMillis();
+    motionEvent1 =
+        MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, X_3F, Y_4F, META_STATE);
+    motionEvent2 =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_MOVE,
+            X_3F,
+            Y_4F,
+            PRESSURE_1F,
+            SIZE_1F,
+            META_STATE,
+            X_PRECISION_3F,
+            Y_PRECISION_4F,
+            DEVICE_ID_1,
+            EDGE_FLAGS);
+  }
+
+  @After
+  public void teardown() {
+    if (null != motionEvent1) {
+      motionEvent1.recycle();
+    }
+    if (null != motionEvent2) {
+      motionEvent2.recycle();
+    }
+    if (null != motionEventDynamic) {
+      motionEventDynamic.recycle();
+    }
+  }
+
+  @Test
+  public void obtainBasic() {
+    motionEvent1 =
+        MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, X_3F, Y_4F, META_STATE);
+    assertThat(motionEvent1).isNotNull();
+    assertThat(motionEvent1).hasDownTime(downTime);
+    assertThat(motionEvent1).hasEventTime(eventTime);
+    assertThat(motionEvent1).hasAction(MotionEvent.ACTION_DOWN);
+    assertThat(motionEvent1).x().isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEvent1).y().isWithin(TOLERANCE).of(Y_4F);
+    assertThat(motionEvent1).rawX().isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEvent1).rawY().isWithin(TOLERANCE).of(Y_4F);
+    assertThat(motionEvent1).hasMetaState(META_STATE);
+    assertThat(motionEvent1).hasDeviceId(0);
+    assertThat(motionEvent1).hasEdgeFlags(0);
+    assertThat(motionEvent1).pressure().isWithin(TOLERANCE).of(PRESSURE_1F);
+    assertThat(motionEvent1).size().isWithin(TOLERANCE).of(SIZE_1F);
+    assertThat(motionEvent1).xPrecision().isWithin(TOLERANCE).of(1.0f);
+    assertThat(motionEvent1).yPrecision().isWithin(TOLERANCE).of(1.0f);
+  }
+
+  @Test
+  public void testObtainFromMotionEvent() {
+    motionEventDynamic = MotionEvent.obtain(motionEvent2);
+    assertThat(motionEventDynamic).isNotNull();
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .isEqualToWithinTolerance(motionEvent2, TOLERANCE);
+  }
+
+  @Test
+  public void testObtainAllFields() {
+    motionEventDynamic =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_DOWN,
+            X_3F,
+            Y_4F,
+            PRESSURE_1F,
+            SIZE_1F,
+            META_STATE,
+            X_PRECISION_3F,
+            Y_PRECISION_4F,
+            DEVICE_ID_1,
+            EDGE_FLAGS);
+    assertThat(motionEventDynamic).isNotNull();
+    assertThat(motionEventDynamic).hasButtonState(0);
+    assertThat(motionEventDynamic).hasDownTime(downTime);
+    assertThat(motionEventDynamic).hasEventTime(eventTime);
+    assertThat(motionEventDynamic).hasAction(MotionEvent.ACTION_DOWN);
+    assertThat(motionEventDynamic).x().isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEventDynamic).y().isWithin(TOLERANCE).of(Y_4F);
+    assertThat(motionEventDynamic).rawX().isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEventDynamic).rawY().isWithin(TOLERANCE).of(Y_4F);
+    assertThat(motionEventDynamic).hasMetaState(META_STATE);
+    assertThat(motionEventDynamic).hasDeviceId(DEVICE_ID_1);
+    assertThat(motionEventDynamic).hasEdgeFlags(EDGE_FLAGS);
+    assertThat(motionEventDynamic).pressure().isWithin(TOLERANCE).of(PRESSURE_1F);
+    assertThat(motionEventDynamic).size().isWithin(TOLERANCE).of(SIZE_1F);
+    assertThat(motionEventDynamic).xPrecision().isWithin(TOLERANCE).of(X_PRECISION_3F);
+    assertThat(motionEventDynamic).yPrecision().isWithin(TOLERANCE).of(Y_PRECISION_4F);
+  }
+
+  @Test
+  public void actionButton() {
+    MotionEvent event =
+        MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, X_3F, Y_4F, META_STATE);
+    if (Build.VERSION.SDK_INT < VERSION_CODES.M) {
+      try {
+        assertThat(event).hasActionButton(0);
+        fail("IllegalStateException not thrown");
+      } catch (IllegalStateException e) {
+        // expected
+      }
+    } else {
+      assertThat(event).hasActionButton(0);
+    }
+  }
+
+  @Test
+  public void testObtainFromRecycledEvent() {
+    PointerCoords coords0 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(X_3F, Y_4F)
+            .setPressure(PRESSURE_1F)
+            .setSize(SIZE_1F)
+            .setTool(1.2f, 1.4f)
+            .build();
+    PointerProperties properties0 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(0)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+    motionEventDynamic =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_MOVE,
+            1,
+            new PointerProperties[] {properties0},
+            new PointerCoords[] {coords0},
+            META_STATE,
+            0,
+            X_PRECISION_3F,
+            Y_PRECISION_4F,
+            DEVICE_ID_1,
+            EDGE_FLAGS,
+            InputDevice.SOURCE_TOUCHSCREEN,
+            0);
+    MotionEvent motionEventDynamicCopy = MotionEvent.obtain(motionEventDynamic);
+    assertThat(motionEventDynamic.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_FINGER);
+    assertThat(motionEventDynamicCopy.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_FINGER);
+    motionEventDynamic.recycle();
+
+    PointerCoords coords1 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(X_3F + 1.0f, Y_4F - 2.0f)
+            .setPressure(PRESSURE_1F + 0.2f)
+            .setSize(SIZE_1F + 0.5f)
+            .setTouch(2.2f, 0.6f)
+            .build();
+    PointerProperties properties1 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(0)
+            .setToolType(MotionEvent.TOOL_TYPE_MOUSE)
+            .build();
+    motionEventDynamic =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_MOVE,
+            1,
+            new PointerProperties[] {properties1},
+            new PointerCoords[] {coords1},
+            META_STATE,
+            0,
+            X_PRECISION_3F,
+            Y_PRECISION_4F,
+            DEVICE_ID_1,
+            EDGE_FLAGS,
+            InputDevice.SOURCE_TOUCHSCREEN,
+            0);
+    assertThat(motionEventDynamicCopy.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_FINGER);
+    assertThat(motionEventDynamic.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_MOUSE);
+  }
+
+  @Test
+  public void testObtainFromPropertyArrays() {
+    PointerCoords coords0 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(X_3F, Y_4F)
+            .setPressure(PRESSURE_1F)
+            .setSize(SIZE_1F)
+            .setTool(1.2f, 1.4f)
+            .build();
+    PointerCoords coords1 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(X_3F + 1.0f, Y_4F - 2.0f)
+            .setPressure(PRESSURE_1F + 0.2f)
+            .setSize(SIZE_1F + 0.5f)
+            .setTouch(2.2f, 0.6f)
+            .build();
+
+    PointerProperties properties0 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(0)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+    PointerProperties properties1 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(1)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+
+    motionEventDynamic =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_MOVE,
+            2,
+            new PointerProperties[] {properties0, properties1},
+            new PointerCoords[] {coords0, coords1},
+            META_STATE,
+            0,
+            X_PRECISION_3F,
+            Y_PRECISION_4F,
+            DEVICE_ID_1,
+            EDGE_FLAGS,
+            InputDevice.SOURCE_TOUCHSCREEN,
+            0);
+
+    // We expect to have data for two pointers
+    assertThat(motionEventDynamic).hasPointerCount(2);
+    assertThat(motionEventDynamic).hasFlags(0);
+    assertThat(motionEventDynamic).pointerId(0).isEqualTo(0);
+    assertThat(motionEventDynamic).pointerId(1).isEqualTo(1);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(0)
+        .isEqualToWithinTolerance(coords0, TOLERANCE);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(1)
+        .isEqualToWithinTolerance(coords1, TOLERANCE);
+    assertThat(motionEventDynamic).pointerProperties(0).isEqualTo(properties0);
+    assertThat(motionEventDynamic).pointerProperties(1).isEqualTo(properties1);
+  }
+
+  @Test
+  public void testObtainNoHistory() {
+    // Add two batch to one of our events
+    motionEvent2.addBatch(eventTime + 10, X_3F + 5.0f, Y_4F + 5.0f, 0.5f, 0.5f, 0);
+    motionEvent2.addBatch(eventTime + 20, X_3F + 10.0f, Y_4F + 15.0f, 2.0f, 3.0f, 0);
+    // The newly added batch should be the "new" values of the event
+    assertThat(motionEvent2).x().isWithin(TOLERANCE).of(X_3F + 10.0f);
+    assertThat(motionEvent2).y().isWithin(TOLERANCE).of(Y_4F + 15.0f);
+    assertThat(motionEvent2).pressure().isWithin(TOLERANCE).of(2.0f);
+    assertThat(motionEvent2).size().isWithin(TOLERANCE).of(3.0f);
+    assertThat(motionEvent2).hasEventTime(eventTime + 20);
+
+    // We should have history with 2 entries
+    assertThat(motionEvent2).hasHistorySize(2);
+
+    // The previous data should be history at index 1
+    assertThat(motionEvent2).historicalX(1).isWithin(TOLERANCE).of(X_3F + 5.0f);
+    assertThat(motionEvent2).historicalY(1).isWithin(TOLERANCE).of(Y_4F + 5.0f);
+    assertThat(motionEvent2).historicalPressure(1).isWithin(TOLERANCE).of(0.5f);
+    assertThat(motionEvent2).historicalSize(1).isWithin(TOLERANCE).of(0.5f);
+    assertThat(motionEvent2).historicalEventTime(1).isEqualTo(eventTime + 10);
+
+    // And the original data should be history at index 0
+    assertThat(motionEvent2).historicalX(0).isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEvent2).historicalY(0).isWithin(TOLERANCE).of(Y_4F);
+    assertThat(motionEvent2).historicalPressure(0).isWithin(TOLERANCE).of(1.0f);
+    assertThat(motionEvent2).historicalSize(0).isWithin(TOLERANCE).of(1.0f);
+    assertThat(motionEvent2).historicalEventTime(0).isEqualTo(eventTime);
+
+    motionEventDynamic = MotionEvent.obtainNoHistory(motionEvent2);
+    // The newly obtained event should have the matching current content and no history
+    assertThat(motionEventDynamic).x().isWithin(TOLERANCE).of(X_3F + 10.0f);
+    assertThat(motionEventDynamic).y().isWithin(TOLERANCE).of(Y_4F + 15.0f);
+    assertThat(motionEventDynamic).pressure().isWithin(TOLERANCE).of(2.0f);
+    assertThat(motionEventDynamic).size().isWithin(TOLERANCE).of(3.0f);
+    assertThat(motionEventDynamic).hasHistorySize(0);
+  }
+
+  @Test
+  public void testAccessAction() {
+    assertThat(motionEvent1).hasAction(MotionEvent.ACTION_MOVE);
+
+    motionEvent1.setAction(MotionEvent.ACTION_UP);
+    assertThat(motionEvent1).hasAction(MotionEvent.ACTION_UP);
+  }
+
+  @Test
+  public void testDescribeContents() {
+    // make sure this method never throw any exception.
+    motionEvent2.describeContents();
+  }
+
+  @Test
+  public void testAccessEdgeFlags() {
+    assertThat(motionEvent2).hasEdgeFlags(EDGE_FLAGS);
+
+    motionEvent2.setEdgeFlags(10);
+    assertThat(motionEvent2).hasEdgeFlags(10);
+  }
+
+  @Test
+  public void testWriteToParcel() {
+    Parcel parcel = Parcel.obtain();
+    motionEvent2.writeToParcel(parcel, Parcelable.PARCELABLE_WRITE_RETURN_VALUE);
+    parcel.setDataPosition(0);
+
+    MotionEvent motionEvent = MotionEvent.CREATOR.createFromParcel(parcel);
+    assertThat(motionEvent).rawY().isWithin(TOLERANCE).of(motionEvent2.getRawY());
+    assertThat(motionEvent).rawX().isWithin(TOLERANCE).of(motionEvent2.getRawX());
+    assertThat(motionEvent).y().isWithin(TOLERANCE).of(motionEvent2.getY());
+    assertThat(motionEvent).x().isWithin(TOLERANCE).of(motionEvent2.getX());
+    assertThat(motionEvent).hasAction(motionEvent2.getAction());
+    assertThat(motionEvent).hasDownTime(motionEvent2.getDownTime());
+    assertThat(motionEvent).hasEventTime(motionEvent2.getEventTime());
+    assertThat(motionEvent).hasEdgeFlags(motionEvent2.getEdgeFlags());
+    assertThat(motionEvent).hasDeviceId(motionEvent2.getDeviceId());
+  }
+
+  @Test
+  public void testReadFromParcelWithInvalidPointerCountSize() {
+    Parcel parcel = Parcel.obtain();
+    motionEvent2.writeToParcel(parcel, Parcelable.PARCELABLE_WRITE_RETURN_VALUE);
+
+    // Move to pointer id count.
+    parcel.setDataPosition(4);
+    parcel.writeInt(17);
+
+    parcel.setDataPosition(0);
+    try {
+      MotionEvent.CREATOR.createFromParcel(parcel);
+      fail("deserialized invalid parcel");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  @SdkSuppress(minSdkVersion = N)
+  public void testReadFromParcelWithInvalidSampleSize() {
+    Parcel parcel = Parcel.obtain();
+    motionEvent2.writeToParcel(parcel, Parcelable.PARCELABLE_WRITE_RETURN_VALUE);
+
+    // Move to sample count.
+    parcel.setDataPosition(2 * 4);
+    parcel.writeInt(0x000f0000);
+
+    parcel.setDataPosition(0);
+    try {
+      MotionEvent.CREATOR.createFromParcel(parcel);
+      fail("deserialized invalid parcel");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void testToString() {
+    // make sure this method never throw exception.
+    motionEvent2.toString();
+  }
+
+  @Test
+  public void testOffsetLocationForPointerSource() {
+    assertThat(motionEvent2).x().isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEvent2).y().isWithin(TOLERANCE).of(Y_4F);
+    motionEvent2.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+
+    float offsetX = 1.0f;
+    float offsetY = 1.0f;
+    motionEvent2.offsetLocation(offsetX, offsetY);
+
+    assertThat(motionEvent2).x().isWithin(TOLERANCE).of(X_3F + offsetX);
+    assertThat(motionEvent2).y().isWithin(TOLERANCE).of(Y_4F + offsetY);
+  }
+
+  @Test
+  public void testSetLocationForPointerSource() {
+    assertThat(motionEvent2).x().isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEvent2).y().isWithin(TOLERANCE).of(Y_4F);
+    motionEvent2.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+
+    motionEvent2.setLocation(2.0f, 2.0f);
+
+    assertThat(motionEvent2).x().isWithin(TOLERANCE).of(2.0f);
+    assertThat(motionEvent2).y().isWithin(TOLERANCE).of(2.0f);
+  }
+
+  @Test
+  public void testGetHistoricalData() {
+    assertThat(motionEvent2).hasHistorySize(0);
+
+    motionEvent2.addBatch(eventTime + 10, X_3F + 5.0f, Y_4F + 5.0f, 0.5f, 0.5f, 0);
+    // The newly added batch should be the "new" values of the event
+    assertThat(motionEvent2).x().isWithin(TOLERANCE).of(X_3F + 5.0f);
+    assertThat(motionEvent2).y().isWithin(TOLERANCE).of(Y_4F + 5.0f);
+    assertThat(motionEvent2).pressure().isWithin(TOLERANCE).of(0.5f);
+    assertThat(motionEvent2).size().isWithin(TOLERANCE).of(0.5f);
+    assertThat(motionEvent2).hasEventTime(eventTime + 10);
+
+    // We should have history with 1 entry
+    assertThat(motionEvent2).hasHistorySize(1);
+    // And the previous / original data should be history at index 0
+    assertThat(motionEvent2).historicalEventTime(0).isEqualTo(eventTime);
+    assertThat(motionEvent2).historicalX(0).isWithin(TOLERANCE).of(X_3F);
+    assertThat(motionEvent2).historicalY(0).isWithin(TOLERANCE).of(Y_4F);
+    assertThat(motionEvent2).historicalPressure(0).isWithin(TOLERANCE).of(1.0f);
+    assertThat(motionEvent2).historicalSize(0).isWithin(TOLERANCE).of(1.0f);
+  }
+
+  @Test
+  public void testGetCurrentDataWithTwoPointers() {
+    PointerCoords coords0 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(10.0f, 20.0f)
+            .setPressure(1.2f)
+            .setSize(2.0f)
+            .setTool(1.2f, 1.4f)
+            .build();
+    PointerCoords coords1 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(30.0f, 40.0f)
+            .setPressure(1.4f)
+            .setSize(3.0f)
+            .setTouch(2.2f, 0.6f)
+            .build();
+
+    PointerProperties properties0 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(0)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+    PointerProperties properties1 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(1)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+
+    motionEventDynamic =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_MOVE,
+            2,
+            new PointerProperties[] {properties0, properties1},
+            new PointerCoords[] {coords0, coords1},
+            0,
+            0,
+            1.0f,
+            1.0f,
+            0,
+            0,
+            InputDevice.SOURCE_TOUCHSCREEN,
+            0);
+
+    // We expect to have data for two pointers
+    assertThat(motionEventDynamic).pointerId(0).isEqualTo(0);
+    assertThat(motionEventDynamic).pointerId(1).isEqualTo(1);
+
+    assertThat(motionEventDynamic).hasPointerCount(2);
+    assertThat(motionEventDynamic).hasFlags(0);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(0)
+        .isEqualToWithinTolerance(coords0, TOLERANCE);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(1)
+        .isEqualToWithinTolerance(coords1, TOLERANCE);
+    assertThat(motionEventDynamic).pointerProperties(0).isEqualTo(properties0);
+    assertThat(motionEventDynamic).pointerProperties(1).isEqualTo(properties1);
+  }
+
+  @Test
+  public void testGetHistoricalDataWithTwoPointers() {
+    // PHASE 1 - construct the initial data for the event
+    PointerCoords coordsInitial0 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(10.0f, 20.0f)
+            .setPressure(1.2f)
+            .setSize(2.0f)
+            .setTool(1.2f, 1.4f)
+            .setTouch(0.7f, 0.6f)
+            .setOrientation(2.0f)
+            .build();
+    PointerCoords coordsInitial1 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(30.0f, 40.0f)
+            .setPressure(1.4f)
+            .setSize(3.0f)
+            .setTool(1.3f, 1.7f)
+            .setTouch(2.7f, 3.6f)
+            .setOrientation(1.0f)
+            .build();
+
+    PointerProperties properties0 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(0)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+    PointerProperties properties1 =
+        PointerPropertiesBuilder.newBuilder()
+            .setId(1)
+            .setToolType(MotionEvent.TOOL_TYPE_FINGER)
+            .build();
+
+    motionEventDynamic =
+        MotionEvent.obtain(
+            downTime,
+            eventTime,
+            MotionEvent.ACTION_MOVE,
+            2,
+            new PointerProperties[] {properties0, properties1},
+            new PointerCoords[] {coordsInitial0, coordsInitial1},
+            0,
+            0,
+            1.0f,
+            1.0f,
+            0,
+            0,
+            InputDevice.SOURCE_TOUCHSCREEN,
+            0);
+
+    // We expect to have data for two pointers
+    assertThat(motionEventDynamic).hasPointerCount(2);
+    assertThat(motionEventDynamic).pointerId(0).isEqualTo(0);
+    assertThat(motionEventDynamic).pointerId(1).isEqualTo(1);
+    assertThat(motionEventDynamic).hasFlags(0);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(0)
+        .isEqualToWithinTolerance(coordsInitial0, TOLERANCE);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(1)
+        .isEqualToWithinTolerance(coordsInitial1, TOLERANCE);
+    assertThat(motionEventDynamic).pointerProperties(0).isEqualTo(properties0);
+    assertThat(motionEventDynamic).pointerProperties(1).isEqualTo(properties1);
+
+    // PHASE 2 - add a new batch of data to our event
+    PointerCoords coordsNext0 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(15.0f, 25.0f)
+            .setPressure(1.6f)
+            .setSize(2.2f)
+            .setTool(1.2f, 1.4f)
+            .setTouch(1.0f, 0.9f)
+            .setOrientation(2.2f)
+            .build();
+    PointerCoords coordsNext1 =
+        PointerCoordsBuilder.newBuilder()
+            .setCoords(35.0f, 45.0f)
+            .setPressure(1.8f)
+            .setSize(3.2f)
+            .setTool(1.2f, 1.4f)
+            .setTouch(0.7f, 0.6f)
+            .setOrientation(2.9f)
+            .build();
+
+    motionEventDynamic.addBatch(eventTime + 10, new PointerCoords[] {coordsNext0, coordsNext1}, 0);
+    // We still expect to have data for two pointers
+    assertThat(motionEventDynamic).hasPointerCount(2);
+    assertThat(motionEventDynamic).pointerId(0).isEqualTo(0);
+    assertThat(motionEventDynamic).pointerId(1).isEqualTo(1);
+    assertThat(motionEventDynamic).hasFlags(0);
+
+    // The newly added batch should be the "new" values of the event
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(0)
+        .isEqualToWithinTolerance(coordsNext0, TOLERANCE);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .pointerCoords(1)
+        .isEqualToWithinTolerance(coordsNext1, TOLERANCE);
+    assertThat(motionEventDynamic).pointerProperties(0).isEqualTo(properties0);
+    assertThat(motionEventDynamic).pointerProperties(1).isEqualTo(properties1);
+    assertThat(motionEventDynamic).hasEventTime(eventTime + 10);
+
+    // We should have history with 1 entry
+    assertThat(motionEventDynamic).hasHistorySize(1);
+    // And the previous / original data should be history at position 0
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .historicalPointerCoords(0, 0)
+        .isEqualToWithinTolerance(coordsInitial0, TOLERANCE);
+    MotionEventEqualitySubject.assertThat(motionEventDynamic)
+        .historicalPointerCoords(1, 0)
+        .isEqualToWithinTolerance(coordsInitial1, TOLERANCE);
+  }
+
+  @Test
+  public void testGetHistorySize() {
+    long eventTime = SystemClock.uptimeMillis();
+    float x = 10.0f;
+    float y = 20.0f;
+    float pressure = 1.0f;
+    float size = 1.0f;
+
+    motionEvent2.setAction(MotionEvent.ACTION_DOWN);
+    assertThat(motionEvent2).hasHistorySize(0);
+
+    motionEvent2.addBatch(eventTime, x, y, pressure, size, 0);
+    assertThat(motionEvent2).hasHistorySize(1);
+  }
+
+  @Test
+  public void testRecycle() {
+    motionEvent2.recycle();
+
+    try {
+      motionEvent2.recycle();
+      fail("recycle() should throw an exception when the event has already been recycled.");
+    } catch (RuntimeException ex) {
+      // expected
+    }
+    motionEvent2 = null; // since it was recycled, don't try to recycle again in tear down
+  }
+
+  @Test
+  public void testTransformShouldApplyMatrixToPointsAndPreserveRawPosition() {
+    // Generate some points on a circle.
+    // Each point 'i' is a point on a circle of radius ROTATION centered at (3,2) at an angle
+    // of ARC * i degrees clockwise relative to the Y axis.
+    // The geometrical representation is irrelevant to the test, it's just easy to generate
+    // and check rotation.  We set the orientation to the same angle.
+    // Coordinate system: down is increasing Y, right is increasing X.
+    final float pi180 = (float) (Math.PI / 180);
+    final float radius = 10;
+    final float arc = 36;
+    final float rotation = arc * 2;
+
+    final int pointerCount = 11;
+    final int[] pointerIds = new int[pointerCount];
+    final PointerCoords[] pointerCoords = new PointerCoords[pointerCount];
+    for (int i = 0; i < pointerCount; i++) {
+      final PointerCoords c = new PointerCoords();
+      final float angle = (float) (i * arc * pi180);
+      pointerIds[i] = i;
+      pointerCoords[i] = c;
+      c.x = (float) (Math.sin(angle) * radius + 3);
+      c.y = (float) (-Math.cos(angle) * radius + 2);
+      c.orientation = angle;
+    }
+    final MotionEvent event =
+        MotionEvent.obtain(
+            0,
+            0,
+            MotionEvent.ACTION_MOVE,
+            pointerCount,
+            pointerIds,
+            pointerCoords,
+            0,
+            0,
+            0,
+            0,
+            0,
+            InputDevice.SOURCE_TOUCHSCREEN,
+            0);
+    final float originalRawX = 0 + 3;
+    final float originalRawY = -radius + 2;
+
+    // Check original raw X and Y assumption.
+    assertThat(event).rawX().isWithin(TOLERANCE).of(originalRawX);
+    assertThat(event).rawY().isWithin(TOLERANCE).of(originalRawY);
+
+    // Now translate the motion event so the circle's origin is at (0,0).
+    event.offsetLocation(-3, -2);
+
+    // Offsetting the location should preserve the raw X and Y of the first point.
+    assertThat(event).rawX().isWithin(TOLERANCE).of(originalRawX);
+    assertThat(event).rawY().isWithin(TOLERANCE).of(originalRawY);
+
+    // Apply a rotation about the origin by ROTATION degrees clockwise.
+    Matrix matrix = new Matrix();
+    matrix.setRotate(rotation);
+    event.transform(matrix);
+
+    // Check the points.
+    for (int i = 0; i < pointerCount; i++) {
+      final PointerCoords c = pointerCoords[i];
+      event.getPointerCoords(i, c);
+
+      final float angle = (float) ((i * arc + rotation) * pi180);
+      assertThat(event)
+          .pointerCoords(i)
+          .x()
+          .isWithin(TOLERANCE)
+          .of((float) (Math.sin(angle) * radius));
+      assertThat(event)
+          .pointerCoords(i)
+          .y()
+          .isWithin(TOLERANCE)
+          .of(-(float) Math.cos(angle) * radius);
+
+      assertThat(Math.tan(c.orientation)).isWithin(0.1f).of(Math.tan(angle));
+    }
+
+    // Applying the transformation should preserve the raw X and Y of the first point.
+    assertThat(event).rawX().isWithin(TOLERANCE).of(originalRawX);
+    assertThat(event).rawY().isWithin(TOLERANCE).of(originalRawY);
+  }
+
+  @Test
+  public void testPointerCoordsCopyConstructor() {
+    PointerCoords coords = new PointerCoords();
+    coords.x = 1;
+    coords.y = 2;
+    coords.pressure = 3;
+    coords.size = 4;
+    coords.touchMajor = 5;
+    coords.touchMinor = 6;
+    coords.toolMajor = 7;
+    coords.toolMinor = 8;
+    coords.orientation = 9;
+    coords.setAxisValue(MotionEvent.AXIS_GENERIC_1, 10);
+
+    PointerCoords copy = new PointerCoords(coords);
+    assertThat(copy).x().isWithin(TOLERANCE).of(1f);
+    assertThat(copy).y().isWithin(TOLERANCE).of(2f);
+    assertThat(copy).pressure().isWithin(TOLERANCE).of(3f);
+    assertThat(copy).size().isWithin(TOLERANCE).of(4f);
+    assertThat(copy).touchMajor().isWithin(TOLERANCE).of(5f);
+    assertThat(copy).touchMinor().isWithin(TOLERANCE).of(6f);
+    assertThat(copy).toolMajor().isWithin(TOLERANCE).of(7f);
+    assertThat(copy).toolMinor().isWithin(TOLERANCE).of(8f);
+    assertThat(copy).orientation().isWithin(TOLERANCE).of(9f);
+    assertThat(copy).axisValue(MotionEvent.AXIS_GENERIC_1).isWithin(TOLERANCE).of(10f);
+  }
+
+  @Test
+  public void testPointerCoordsCopyFrom() {
+    PointerCoords coords = new PointerCoords();
+    coords.x = 1;
+    coords.y = 2;
+    coords.pressure = 3;
+    coords.size = 4;
+    coords.touchMajor = 5;
+    coords.touchMinor = 6;
+    coords.toolMajor = 7;
+    coords.toolMinor = 8;
+    coords.orientation = 9;
+    coords.setAxisValue(MotionEvent.AXIS_GENERIC_1, 10);
+
+    PointerCoords copy = new PointerCoords();
+    copy.copyFrom(coords);
+    assertThat(copy).x().isWithin(TOLERANCE).of(1f);
+    assertThat(copy).y().isWithin(TOLERANCE).of(2f);
+    assertThat(copy).pressure().isWithin(TOLERANCE).of(3f);
+    assertThat(copy).size().isWithin(TOLERANCE).of(4f);
+    assertThat(copy).touchMajor().isWithin(TOLERANCE).of(5f);
+    assertThat(copy).touchMinor().isWithin(TOLERANCE).of(6f);
+    assertThat(copy).toolMajor().isWithin(TOLERANCE).of(7f);
+    assertThat(copy).toolMinor().isWithin(TOLERANCE).of(8f);
+    assertThat(copy).orientation().isWithin(TOLERANCE).of(9f);
+    assertThat(copy).axisValue(MotionEvent.AXIS_GENERIC_1).isWithin(TOLERANCE).of(10f);
+  }
+
+  @Test
+  public void testPointerPropertiesDefaultConstructor() {
+    PointerProperties properties = new PointerProperties();
+
+    assertThat(properties).hasId(MotionEvent.INVALID_POINTER_ID);
+    assertThat(properties).hasToolType(MotionEvent.TOOL_TYPE_UNKNOWN);
+  }
+
+  @Test
+  public void testPointerPropertiesCopyConstructor() {
+    PointerProperties properties = new PointerProperties();
+    properties.id = 1;
+    properties.toolType = MotionEvent.TOOL_TYPE_MOUSE;
+
+    PointerProperties copy = new PointerProperties(properties);
+    assertThat(copy).hasId(1);
+    assertThat(copy).hasToolType(MotionEvent.TOOL_TYPE_MOUSE);
+  }
+
+  @Test
+  public void testPointerPropertiesCopyFrom() {
+    PointerProperties properties = new PointerProperties();
+    properties.id = 1;
+    properties.toolType = MotionEvent.TOOL_TYPE_MOUSE;
+
+    PointerProperties copy = new PointerProperties();
+    copy.copyFrom(properties);
+    assertThat(copy).hasId(1);
+    assertThat(properties).hasToolType(MotionEvent.TOOL_TYPE_MOUSE);
+  }
+
+  @Test
+  public void testAxisFromToString() {
+    assertThat(MotionEvent.axisToString(MotionEvent.AXIS_RTRIGGER)).isEqualTo("AXIS_RTRIGGER");
+    assertThat(MotionEvent.axisFromString("AXIS_RTRIGGER")).isEqualTo(MotionEvent.AXIS_RTRIGGER);
+  }
+
+  private static class MotionEventEqualitySubject extends Subject {
+    private final MotionEvent actual;
+
+    private MotionEventEqualitySubject(FailureMetadata metadata, MotionEvent actual) {
+      super(metadata, actual);
+      this.actual = actual;
+    }
+
+    public static MotionEventEqualitySubject assertThat(MotionEvent event) {
+      return Truth.assertAbout(motionEvents()).that(event);
+    }
+
+    public static Subject.Factory<MotionEventEqualitySubject, MotionEvent> motionEvents() {
+      return MotionEventEqualitySubject::new;
+    }
+
+    public PointerCoordsEqualitySubject pointerCoords(int pointerIndex) {
+      PointerCoords outPointerCoords = new PointerCoords();
+      actual.getPointerCoords(pointerIndex, outPointerCoords);
+      return check("getPointerCoords(%s)", pointerIndex)
+          .about(PointerCoordsEqualitySubject.pointerCoords())
+          .that(outPointerCoords);
+    }
+
+    public PointerCoordsEqualitySubject historicalPointerCoords(int pointerIndex, int pos) {
+      PointerCoords outPointerCoords = new PointerCoords();
+      actual.getHistoricalPointerCoords(pointerIndex, pos, outPointerCoords);
+      return check("getHistoricalPointerCoords(%s, %s)", pointerIndex, pos)
+          .about(PointerCoordsEqualitySubject.pointerCoords())
+          .that(outPointerCoords);
+    }
+
+    /** Asserts that the given MotionEvent matches the current subject. */
+    public void isEqualToWithinTolerance(MotionEvent other, float tolerance) {
+      check("getDownTime()").that(actual.getDownTime()).isEqualTo(other.getDownTime());
+      check("getEventTime()").that(actual.getEventTime()).isEqualTo(other.getEventTime());
+      check("action()").that(actual.getAction()).isEqualTo(other.getAction());
+      check("buttonState()").that(actual.getButtonState()).isEqualTo(other.getButtonState());
+      check("deviceId()").that(actual.getDeviceId()).isEqualTo(other.getDeviceId());
+      check("getFlags()").that(actual.getFlags()).isEqualTo(other.getFlags());
+      check("getEdgeFlags()").that(actual.getEdgeFlags()).isEqualTo(other.getEdgeFlags());
+      check("getXPrecision()").that(actual.getXPrecision()).isEqualTo(other.getXPrecision());
+      check("getYPrecision()").that(actual.getYPrecision()).isEqualTo(other.getYPrecision());
+
+      check("getX()").that(actual.getX()).isWithin(tolerance).of(other.getX());
+      check("getY()").that(actual.getY()).isWithin(tolerance).of(other.getY());
+      check("getPressure()").that(actual.getPressure()).isWithin(tolerance).of(other.getPressure());
+      check("getSize()").that(actual.getSize()).isWithin(tolerance).of(other.getSize());
+      check("getTouchMajor()")
+          .that(actual.getTouchMajor())
+          .isWithin(tolerance)
+          .of(other.getTouchMajor());
+      check("getTouchMinor()")
+          .that(actual.getTouchMinor())
+          .isWithin(tolerance)
+          .of(other.getTouchMinor());
+      check("getToolMajor()")
+          .that(actual.getToolMajor())
+          .isWithin(tolerance)
+          .of(other.getToolMajor());
+      check("getToolMinor()")
+          .that(actual.getToolMinor())
+          .isWithin(tolerance)
+          .of(other.getToolMinor());
+      check("getOrientation()")
+          .that(actual.getOrientation())
+          .isWithin(tolerance)
+          .of(other.getOrientation());
+      check("getPointerCount()").that(actual.getPointerCount()).isEqualTo(other.getPointerCount());
+
+      for (int i = 1; i < actual.getPointerCount(); i++) {
+        check("getX(%s)", i).that(actual.getX(i)).isWithin(tolerance).of(other.getX(i));
+        check("getY(%s)", i).that(actual.getY(i)).isWithin(tolerance).of(other.getY(i));
+        check("getPressure(%s)", i)
+            .that(actual.getPressure(i))
+            .isWithin(tolerance)
+            .of(other.getPressure(i));
+        check("getSize(%s)", i).that(actual.getSize(i)).isWithin(tolerance).of(other.getSize(i));
+        check("getTouchMajor(%s)", i)
+            .that(actual.getTouchMajor(i))
+            .isWithin(tolerance)
+            .of(other.getTouchMajor(i));
+        check("getTouchMinor(%s)", i)
+            .that(actual.getTouchMinor(i))
+            .isWithin(tolerance)
+            .of(other.getTouchMinor(i));
+        check("getToolMajor(%s)", i)
+            .that(actual.getToolMajor(i))
+            .isWithin(tolerance)
+            .of(other.getToolMajor(i));
+        check("getToolMinor(%s)", i)
+            .that(actual.getToolMinor(i))
+            .isWithin(tolerance)
+            .of(other.getToolMinor(i));
+        check("getOrientation(%s)", i)
+            .that(actual.getOrientation(i))
+            .isWithin(tolerance)
+            .of(other.getOrientation(i));
+      }
+      check("getHistorySize()").that(actual.getHistorySize()).isEqualTo(other.getHistorySize());
+
+      for (int i = 0; i < actual.getHistorySize(); i++) {
+        check("getHistoricalX(%s)", i).that(actual.getX(i)).isWithin(tolerance).of(other.getX(i));
+        check("getHistoricalY(%s)", i)
+            .that(actual.getHistoricalY(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalY(i));
+        check("getHistoricalPressure(%s)", i)
+            .that(actual.getHistoricalPressure(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalPressure(i));
+        check("getHistoricalSize(%s)", i)
+            .that(actual.getHistoricalSize(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalSize(i));
+        check("getHistoricalTouchMajor(%s)", i)
+            .that(actual.getHistoricalTouchMajor(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalTouchMajor(i));
+        check("getHistoricalTouchMinor(%s)", i)
+            .that(actual.getHistoricalTouchMinor(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalTouchMinor(i));
+        check("getHistoricalToolMajor(%s)", i)
+            .that(actual.getHistoricalToolMajor(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalToolMajor(i));
+        check("getHistoricalToolMinor(%s)", i)
+            .that(actual.getHistoricalToolMinor(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalToolMinor(i));
+        check("getHistoricalOrientation(%s)", i)
+            .that(actual.getHistoricalOrientation(i))
+            .isWithin(tolerance)
+            .of(other.getHistoricalOrientation(i));
+      }
+    }
+  }
+
+  private static class PointerCoordsEqualitySubject extends Subject {
+    private final PointerCoords actual;
+
+    private PointerCoordsEqualitySubject(FailureMetadata metadata, PointerCoords actual) {
+      super(metadata, actual);
+      this.actual = actual;
+    }
+
+    public static PointerCoordsEqualitySubject assertThat(PointerCoords coords) {
+      return Truth.assertAbout(pointerCoords()).that(coords);
+    }
+
+    public static Subject.Factory<PointerCoordsEqualitySubject, PointerCoords> pointerCoords() {
+      return PointerCoordsEqualitySubject::new;
+    }
+
+    public void isEqualToWithinTolerance(PointerCoords other, float tolerance) {
+      check("orientation").that(actual.orientation).isWithin(tolerance).of(other.orientation);
+      check("pressure").that(actual.pressure).isWithin(tolerance).of(other.pressure);
+      check("size").that(actual.size).isWithin(tolerance).of(other.size);
+      check("toolMajor").that(actual.toolMajor).isWithin(tolerance).of(other.toolMajor);
+      check("toolMinor").that(actual.toolMinor).isWithin(tolerance).of(other.toolMinor);
+      check("touchMajor").that(actual.touchMajor).isWithin(tolerance).of(other.touchMajor);
+      check("touchMinor").that(actual.touchMinor).isWithin(tolerance).of(other.touchMinor);
+      check("x").that(actual.x).isWithin(tolerance).of(other.x);
+      check("y").that(actual.y).isWithin(tolerance).of(other.y);
+    }
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/webkit/CookieManagerTest.java b/integration_tests/ctesque/src/sharedTest/java/android/webkit/CookieManagerTest.java
new file mode 100644
index 0000000..5e25e7e
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/java/android/webkit/CookieManagerTest.java
@@ -0,0 +1,60 @@
+package android.webkit;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Compatibility test for {@link CookieManager} */
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class CookieManagerTest {
+
+  @Before
+  public void setUp() {
+    // Required to initialize native CookieManager for emulators with SDK < 19.
+    if (VERSION.SDK_INT < VERSION_CODES.KITKAT) {
+      CookieSyncManager.createInstance(ApplicationProvider.getApplicationContext());
+    }
+  }
+
+  @After
+  public void tearDown() {
+    CookieManager.getInstance().removeAllCookie();
+  }
+
+  @Test
+  public void setCookie_doesNotAllowMultipleCookies() {
+    final String httpsUrl = "https://robolectric.org/";
+    final CookieManager cookieManager = CookieManager.getInstance();
+    cookieManager.setCookie(httpsUrl, "A=100; B=200");
+    String cookie = cookieManager.getCookie(httpsUrl);
+    assertThat(cookie).isEqualTo("A=100");
+  }
+
+  @Test
+  public void setCookie_multipleCookies() {
+    final String httpsUrl = "https://robolectric.org/";
+    final CookieManager cookieManager = CookieManager.getInstance();
+    cookieManager.setCookie(httpsUrl, "A=100;");
+    cookieManager.setCookie(httpsUrl, "B=100;");
+    String cookie = cookieManager.getCookie(httpsUrl);
+    assertThat(cookie).isEqualTo("A=100; B=100");
+  }
+
+  @Test
+  public void getCookie_doesNotReturnAttributes() {
+    final String httpsUrl = "https://robolectric.org/";
+    final CookieManager cookieManager = CookieManager.getInstance();
+    cookieManager.setCookie(httpsUrl, "ID=test-id; Path=/; Domain=.robolectric.org");
+    String cookie = cookieManager.getCookie(httpsUrl);
+    assertThat(cookie).isEqualTo("ID=test-id");
+  }
+}
diff --git a/integration_tests/ctesque/src/sharedTest/resources/android/robolectric.properties b/integration_tests/ctesque/src/sharedTest/resources/android/robolectric.properties
new file mode 100644
index 0000000..7c7f39f
--- /dev/null
+++ b/integration_tests/ctesque/src/sharedTest/resources/android/robolectric.properties
@@ -0,0 +1 @@
+sdk=ALL_SDKS
diff --git a/integration_tests/dependency-on-stubs/build.gradle b/integration_tests/dependency-on-stubs/build.gradle
new file mode 100644
index 0000000..6efe513
--- /dev/null
+++ b/integration_tests/dependency-on-stubs/build.gradle
@@ -0,0 +1,18 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+// test with a project that depends on the stubs jar, not org.robolectric:android-all
+
+dependencies {
+    api project(":robolectric")
+    api "junit:junit:${junitVersion}"
+
+    testImplementation files("${System.getenv("ANDROID_HOME")}/platforms/android-29/android.jar")
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates // compile against latest Android SDK
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+    testImplementation "org.hamcrest:hamcrest-junit:2.0.0.0"
+}
diff --git a/integration_tests/dependency-on-stubs/src/test/java/org/robolectric/LoadWeirdClassesTest.java b/integration_tests/dependency-on-stubs/src/test/java/org/robolectric/LoadWeirdClassesTest.java
new file mode 100644
index 0000000..0d4093f
--- /dev/null
+++ b/integration_tests/dependency-on-stubs/src/test/java/org/robolectric/LoadWeirdClassesTest.java
@@ -0,0 +1,49 @@
+package org.robolectric;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static org.junit.Assume.assumeThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.content.pm.PackageInfo;
+import android.os.Build;
+import android.view.Display;
+import java.io.File;
+import org.hamcrest.Matchers;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowDisplay;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Config.ALL_SDKS)
+public class LoadWeirdClassesTest {
+
+  @Test
+  @Config(sdk = KITKAT)
+  public void shouldLoadDisplay() {
+    ReflectionHelpers.callInstanceMethod(
+        Display.class, ShadowDisplay.getDefaultDisplay(), "getDisplayAdjustments");
+  }
+
+  @Test
+  public void reset_shouldWorkEvenIfSdkIntIsOverridden() {
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 23);
+  }
+
+  @Test
+  public void shadowOf_shouldCompile() {
+    assumeThat("Windows is an affront to decency.",
+        File.separator, Matchers.equalTo("/"));
+
+    shadowOf(Robolectric.setupActivity(Activity.class));
+  }
+
+  @Test
+  public void packageManager() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "test.package";
+    shadowOf(RuntimeEnvironment.application.getPackageManager()).addPackage(packageInfo);
+  }
+}
diff --git a/integration_tests/libphonenumber/build.gradle b/integration_tests/libphonenumber/build.gradle
new file mode 100644
index 0000000..84be0c7
--- /dev/null
+++ b/integration_tests/libphonenumber/build.gradle
@@ -0,0 +1,13 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+dependencies {
+    api project(":robolectric")
+    api "junit:junit:${junitVersion}"
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation 'com.googlecode.libphonenumber:libphonenumber:8.0.0'
+}
\ No newline at end of file
diff --git a/integration_tests/libphonenumber/src/test/java/org/robolectric/integrationtests/libphonenumber/ClassloadingTest.java b/integration_tests/libphonenumber/src/test/java/org/robolectric/integrationtests/libphonenumber/ClassloadingTest.java
new file mode 100644
index 0000000..199da35
--- /dev/null
+++ b/integration_tests/libphonenumber/src/test/java/org/robolectric/integrationtests/libphonenumber/ClassloadingTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.integrationtests.libphonenumber;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.Phonenumber;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Config.ALL_SDKS )
+public class ClassloadingTest {
+
+  /**
+   * <a href="https://github.com/robolectric/robolectric/issues/2773">Issue</a>
+   */
+  @Test
+  public void getResourceAsStream() throws Exception {
+    Phonenumber.PhoneNumber phoneNumber = new Phonenumber.PhoneNumber();
+    phoneNumber.setCountryCode(7);
+    phoneNumber.setNationalNumber(4956360636L);
+    String format = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL);
+    assertThat(format).isNotNull();
+  }
+}
diff --git a/integration_tests/memoryleaks/build.gradle b/integration_tests/memoryleaks/build.gradle
new file mode 100644
index 0000000..743c0da
--- /dev/null
+++ b/integration_tests/memoryleaks/build.gradle
@@ -0,0 +1,34 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+
+}
+
+dependencies {
+    // Testing dependencies
+    testImplementation project(path: ':testapp')
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation "com.google.guava:guava-testlib:$guavaJREVersion"
+    testImplementation("androidx.fragment:fragment:1.3.4")
+}
diff --git a/integration_tests/memoryleaks/src/main/AndroidManifest.xml b/integration_tests/memoryleaks/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5cafa77
--- /dev/null
+++ b/integration_tests/memoryleaks/src/main/AndroidManifest.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for androidx memoryleaks test module
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.memoryleaks">
+
+    <application>
+    </application>
+</manifest>
diff --git a/integration_tests/memoryleaks/src/test/java/org/robolectric/integrationtests/memoryleaks/BaseMemoryLeaksTest.java b/integration_tests/memoryleaks/src/test/java/org/robolectric/integrationtests/memoryleaks/BaseMemoryLeaksTest.java
new file mode 100644
index 0000000..ec7ca8b
--- /dev/null
+++ b/integration_tests/memoryleaks/src/test/java/org/robolectric/integrationtests/memoryleaks/BaseMemoryLeaksTest.java
@@ -0,0 +1,130 @@
+package org.robolectric.integrationtests.memoryleaks;
+
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.content.res.Configuration;
+import android.os.Looper;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.fragment.app.FragmentContainerView;
+import java.lang.ref.WeakReference;
+import java.util.concurrent.Callable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+
+/**
+ * A test that verifies that activities and fragments become GC candidates after being destroyed, or
+ * after a test terminates.
+ *
+ * <p>For internal reasons, this class is subclassed rather than inlining {@link #assertNotLeaking}
+ * in this file.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Config.ALL_SDKS)
+public abstract class BaseMemoryLeaksTest {
+
+  private static WeakReference<Activity> awr = null;
+
+  @Test
+  public void activityCanBeGcdAfterDestroyed() {
+    assertNotLeaking(
+        () -> {
+          ActivityController<Activity> ac = Robolectric.buildActivity(Activity.class).setup();
+          Activity activity = ac.get();
+          ac.pause().stop().destroy();
+          return activity;
+        });
+  }
+
+  @Test
+  public void activityCanBeGcdAfterConfigChange() {
+    assertNotLeaking(
+        () -> {
+          ActivityController<Activity> ac = Robolectric.buildActivity(Activity.class).setup();
+          Activity activity = ac.get();
+          Configuration currentConfiguration = activity.getResources().getConfiguration();
+          Configuration newConfiguration = new Configuration(currentConfiguration);
+          newConfiguration.orientation =
+              currentConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE
+                  ? Configuration.ORIENTATION_PORTRAIT
+                  : Configuration.ORIENTATION_LANDSCAPE;
+          ac.configurationChange(newConfiguration);
+          return activity;
+        });
+  }
+
+  @Test
+  public void fragmentCanBeGcdAfterActivityDestroyed() {
+    assertNotLeaking(
+        () -> {
+          ActivityController<FragmentActivity> ac =
+              Robolectric.buildActivity(FragmentActivity.class).setup();
+          FragmentContainerView contentView = new FragmentContainerView(ac.get());
+          contentView.setId(android.R.id.list_container);
+          ac.get().setContentView(contentView);
+          Fragment f = new Fragment();
+          ac.get()
+              .getSupportFragmentManager()
+              .beginTransaction()
+              .replace(android.R.id.list_container, f)
+              .commitNow();
+          ac.pause().stop().destroy();
+          // Idle any potential Fragment animations.
+          shadowOf(Looper.getMainLooper()).idle();
+          return f;
+        });
+  }
+
+  @Test
+  public void fragmentCanBeGcdAfterRemoved() {
+    assertNotLeaking(
+        () -> {
+          ActivityController<FragmentActivity> ac =
+              Robolectric.buildActivity(FragmentActivity.class).setup();
+          FragmentContainerView contentView = new FragmentContainerView(ac.get());
+          contentView.setId(android.R.id.list_container);
+          ac.get().setContentView(contentView);
+          Fragment f = new Fragment();
+          ac.get()
+              .getSupportFragmentManager()
+              .beginTransaction()
+              .replace(android.R.id.list_container, f)
+              .commitNow();
+          ac.get()
+              .getSupportFragmentManager()
+              .beginTransaction()
+              .replace(android.R.id.list_container, new Fragment())
+              .commitNow();
+          return f;
+        });
+  }
+
+  @Test
+  // Do not shard these two tests, they must run on the same machine sequentially.
+  public void activityCanBeGcdBetweenTest_1() {
+    if (awr == null) {
+      ActivityController<Activity> ac = Robolectric.buildActivity(Activity.class).setup();
+      awr = new WeakReference<>(ac.get());
+    } else {
+      assertNotLeaking(awr::get);
+    }
+  }
+
+  @Test
+  // Do not shard these two tests, they must run on the same machine sequentially.
+  public void activityCanBeGcdBetweenTest_2() {
+    if (awr == null) {
+      ActivityController<Activity> ac = Robolectric.buildActivity(Activity.class).setup();
+      awr = new WeakReference<>(ac.get());
+    } else {
+      assertNotLeaking(awr::get);
+    }
+  }
+
+  public abstract <T> void assertNotLeaking(Callable<T> potentiallyLeakingCallable);
+}
diff --git a/integration_tests/memoryleaks/src/test/java/org/robolectric/integrationtests/memoryleaks/MemoryLeaksTest.java b/integration_tests/memoryleaks/src/test/java/org/robolectric/integrationtests/memoryleaks/MemoryLeaksTest.java
new file mode 100644
index 0000000..0ff02cc
--- /dev/null
+++ b/integration_tests/memoryleaks/src/test/java/org/robolectric/integrationtests/memoryleaks/MemoryLeaksTest.java
@@ -0,0 +1,50 @@
+package org.robolectric.integrationtests.memoryleaks;
+
+import com.google.common.testing.GcFinalization;
+import java.lang.ref.WeakReference;
+import java.util.Locale;
+import java.util.concurrent.Callable;
+
+/** Gradle-specific implementation of {@link BaseMemoryLeaksTest}. */
+public final class MemoryLeaksTest extends BaseMemoryLeaksTest {
+
+  // Allow assigning null to potentiallyLeakingCallable to clear the stack's reference on the
+  // callable.
+  @SuppressWarnings("assignment.type.incompatible")
+  @Override
+  public <T> void assertNotLeaking(Callable<T> potentiallyLeakingCallable) {
+    WeakReference<T> wr;
+    try {
+      wr = new WeakReference<>(potentiallyLeakingCallable.call());
+      // Make it explicit that the callable isn't reachable from this method's stack, in case it
+      // holds a strong reference on the supplied instance.
+      potentiallyLeakingCallable = null;
+    } catch (Exception e) {
+      throw new IllegalStateException("encountered an error in the callable", e);
+    }
+    assertReferentWeaklyReachable(wr);
+  }
+
+  private static <T> void assertReferentWeaklyReachable(WeakReference<T> wr) {
+    try {
+      GcFinalization.awaitClear(wr);
+    } catch (RuntimeException e) {
+      T notWeaklyReachable = wr.get();
+
+      if (notWeaklyReachable == null) {
+        // Looks like it is weakly reachable after all.
+        return;
+      }
+
+      // GcFinalization throws a RuntimeException instead of a TimeoutException when we timeout to
+      // clear the weak reference, so we catch any exception and consider that the assertion failed
+      // in that case.
+      throw new AssertionError(
+          String.format(
+              Locale.ROOT,
+              "Not true that <%s> is not leaking, encountered an error while attempting to GC it.",
+              notWeaklyReachable),
+          e);
+    }
+  }
+}
diff --git a/integration_tests/mockito-experimental/build.gradle b/integration_tests/mockito-experimental/build.gradle
new file mode 100644
index 0000000..4aafcbc
--- /dev/null
+++ b/integration_tests/mockito-experimental/build.gradle
@@ -0,0 +1,14 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+dependencies {
+    api project(":robolectric")
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
+}
diff --git a/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoMockFinalsTest.java b/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoMockFinalsTest.java
new file mode 100644
index 0000000..21bac82
--- /dev/null
+++ b/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoMockFinalsTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.integrationtests.mockito.experimental;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.text.Layout;
+import android.widget.TextView;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests the ability to mock final classes and methods with mockito-inline. */
+@RunWith(RobolectricTestRunner.class)
+public class MockitoMockFinalsTest {
+  @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();
+
+  @Mock TextView textView;
+
+  @Test
+  public void testInjection() {
+    Layout layout = mock(Layout.class);
+    when(textView.getLayout()).thenReturn(layout);
+    assertThat(textView.getLayout()).isSameInstanceAs(layout);
+  }
+
+  @Test
+  public void canMockUserId() {
+    User user = mock(User.class);
+    when(user.getId()).thenReturn(1);
+    assertThat(user.getId()).isEqualTo(1);
+  }
+
+  static final class User {
+    final int getId() {
+      return -1;
+    }
+  }
+}
diff --git a/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoMockJavaFrameworkTest.java b/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoMockJavaFrameworkTest.java
new file mode 100644
index 0000000..5034507
--- /dev/null
+++ b/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoMockJavaFrameworkTest.java
@@ -0,0 +1,30 @@
+package org.robolectric.integrationtests.mockito.experimental;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import javax.crypto.Cipher;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests the ability to mock Java framework methods with mockito-inline. */
+@RunWith(RobolectricTestRunner.class)
+public class MockitoMockJavaFrameworkTest {
+  @Test
+  public void file_getAbsolutePath_isMockable() throws Exception {
+    File file = mock(File.class);
+    doReturn("absolute/path").when(file).getAbsolutePath();
+    assertThat(file.getAbsolutePath()).isEqualTo("absolute/path");
+  }
+
+  @Test
+  public void cipher_getIV_isMockable() {
+    Cipher cipher = mock(Cipher.class);
+    doReturn("fake".getBytes(StandardCharsets.UTF_8)).when(cipher).getIV();
+    assertThat(cipher.getIV()).isEqualTo("fake".getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git a/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoSpyTest.java b/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoSpyTest.java
new file mode 100644
index 0000000..7daf4c0
--- /dev/null
+++ b/integration_tests/mockito-experimental/src/test/java/org/robolectric/integrationtests/mockito/experimental/MockitoSpyTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.integrationtests.mockito.experimental;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.inputmethod.InputMethodManager;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Tests the ability to mock final classes and methods with mockito-inline. */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 28)
+public class MockitoSpyTest {
+
+  /** Regression test for https://github.com/mockito/mockito/issues/2040 */
+  @Test
+  public void spyActivity_hasSameBaseContext() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    Activity spyActivity = spy(activity);
+    assertThat(activity.getBaseContext()).isEqualTo(spyActivity.getBaseContext());
+  }
+
+  @Test
+  public void spyContext_canSpyGetSystemService() {
+    Context context = spy(RuntimeEnvironment.getApplication());
+    InputMethodManager expected = mock(InputMethodManager.class);
+    when(context.getSystemService(Context.INPUT_METHOD_SERVICE)).thenReturn(expected);
+    InputMethodManager actual =
+        (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+    assertThat(actual).isSameInstanceAs(expected);
+  }
+}
diff --git a/integration_tests/mockito-kotlin/build.gradle b/integration_tests/mockito-kotlin/build.gradle
new file mode 100644
index 0000000..14a5b00
--- /dev/null
+++ b/integration_tests/mockito-kotlin/build.gradle
@@ -0,0 +1,25 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: 'kotlin'
+apply plugin: "com.diffplug.spotless"
+
+spotless {
+    kotlin {
+        target '**/*.kt'
+        ktfmt('0.34').googleStyle()
+    }
+}
+
+dependencies {
+    api project(":robolectric")
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "androidx.test.ext:junit:$axtJunitVersion@aar"
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation "com.google.truth:truth:$truthVersion"
+    testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+    testImplementation "org.mockito:mockito-core:$mockitoVersion"
+}
diff --git a/integration_tests/mockito-kotlin/src/test/java/org/robolectric/integrationtests/mockito/kotlin/MockitoKotlinFunctionTest.java b/integration_tests/mockito-kotlin/src/test/java/org/robolectric/integrationtests/mockito/kotlin/MockitoKotlinFunctionTest.java
new file mode 100644
index 0000000..9e2cf83
--- /dev/null
+++ b/integration_tests/mockito-kotlin/src/test/java/org/robolectric/integrationtests/mockito/kotlin/MockitoKotlinFunctionTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.integrationtests.mockito.kotlin;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doReturn;
+
+import kotlin.Function;
+import kotlin.jvm.functions.Function0;
+import kotlin.jvm.functions.Function1;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for mocking Kotlin classes with Mockito (in Kotlin code). */
+@RunWith(RobolectricTestRunner.class)
+public class MockitoKotlinFunctionTest {
+  @Test
+  public void testFunctionMock() {
+    Function function = Mockito.mock(Function.class);
+    assertThat(function).isNotNull();
+  }
+
+  @Test
+  public void testFunction0Mock() {
+    Function0 function = Mockito.mock(Function0.class);
+    doReturn(null).when(function).invoke();
+    Object retVal = function.invoke();
+    assertThat(retVal).isNull();
+  }
+
+  @Test
+  public void testFunction1Mock() {
+    Function1 function = Mockito.mock(Function1.class);
+    doReturn(null).when(function).invoke(any());
+    Object retVal = function.invoke(null);
+    assertThat(retVal).isNull();
+  }
+}
diff --git a/integration_tests/mockito-kotlin/src/test/kotlin/org/robolectric/integrationtests/mockito/kotlin/MockitoKotlinFunctionInKotlinTest.kt b/integration_tests/mockito-kotlin/src/test/kotlin/org/robolectric/integrationtests/mockito/kotlin/MockitoKotlinFunctionInKotlinTest.kt
new file mode 100644
index 0000000..dcb18a0
--- /dev/null
+++ b/integration_tests/mockito-kotlin/src/test/kotlin/org/robolectric/integrationtests/mockito/kotlin/MockitoKotlinFunctionInKotlinTest.kt
@@ -0,0 +1,18 @@
+package org.robolectric.integrationtests.mockito.kotlin
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+
+/** Tests for Mockito + Kotlin in Kotlin in a Robolectric environment. */
+@RunWith(AndroidJUnit4::class)
+class MockitoKotlinFunctionInKotlinTest {
+  @Test
+  fun testFunction1() {
+    val function = mock(Function1::class.java) as (String) -> Unit
+    function.invoke("test")
+    verify(function).invoke("test")
+  }
+}
diff --git a/integration_tests/mockito/build.gradle b/integration_tests/mockito/build.gradle
new file mode 100644
index 0000000..e199cd7
--- /dev/null
+++ b/integration_tests/mockito/build.gradle
@@ -0,0 +1,14 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+dependencies {
+    api project(":robolectric")
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+}
\ No newline at end of file
diff --git a/integration_tests/mockito/src/test/java/org/robolectric/integrationtests/mockito/MockitoInjectMocksTest.java b/integration_tests/mockito/src/test/java/org/robolectric/integrationtests/mockito/MockitoInjectMocksTest.java
new file mode 100644
index 0000000..f893bc3
--- /dev/null
+++ b/integration_tests/mockito/src/test/java/org/robolectric/integrationtests/mockito/MockitoInjectMocksTest.java
@@ -0,0 +1,30 @@
+package org.robolectric.integrationtests.mockito;
+
+import android.app.Activity;
+import android.widget.TextView;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class MockitoInjectMocksTest {
+  @Rule
+  public MockitoRule mockitoRule = MockitoJUnit.rule();
+
+  @Mock
+  TextView textView;
+
+  @InjectMocks
+  Activity activity = Robolectric.setupActivity(Activity.class);
+
+  @Test
+  public void testInjection() {
+    activity.finish();
+  }
+}
diff --git a/integration_tests/mockito/src/test/java/org/robolectric/integrationtests/mockito/SpyTest.java b/integration_tests/mockito/src/test/java/org/robolectric/integrationtests/mockito/SpyTest.java
new file mode 100644
index 0000000..23108b0
--- /dev/null
+++ b/integration_tests/mockito/src/test/java/org/robolectric/integrationtests/mockito/SpyTest.java
@@ -0,0 +1,61 @@
+package org.robolectric.integrationtests.mockito;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.spy;
+
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.integrationtests.mockito.SpyTest.ShadowFoo;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Tests for an Mockito spies with Robolectric. */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = ShadowFoo.class)
+public final class SpyTest {
+
+  /**
+   * This captures an esoteric issue where Robolectric's use {@link RealObject} may cause problems
+   * with Mockito spies.
+   *
+   * @see <a href "https://github.com/mockito/mockito/issues/2552">The mockito issue</a>
+   */
+  @Test
+  @Ignore("https://github.com/mockito/mockito/issues/2552")
+  public void spy_shadowUpdatingFieldWithReflection() {
+    Foo f = new Foo();
+    Foo spyFoo = spy(f);
+    spyFoo.setA(100);
+    assertThat(spyFoo.getA()).isEqualTo(100);
+  }
+
+  /** A simple class with an int field. */
+  public static class Foo {
+
+    int a;
+
+    public void setA(int value) {
+      this.a = value;
+    }
+
+    public int getA() {
+      return this.a;
+    }
+  }
+
+  /** This class shadows {@link Foo#setA(int)} to set 'a' using reflection. */
+  @Implements(Foo.class)
+  public static class ShadowFoo {
+    @RealObject Foo f;
+
+    @Implementation
+    protected void setA(int value) {
+      ReflectionHelpers.setField(f, "a", value);
+    }
+  }
+}
diff --git a/integration_tests/mockk/build.gradle b/integration_tests/mockk/build.gradle
new file mode 100644
index 0000000..2dcf2f7
--- /dev/null
+++ b/integration_tests/mockk/build.gradle
@@ -0,0 +1,27 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: 'kotlin'
+apply plugin: "com.diffplug.spotless"
+
+spotless {
+    kotlin {
+        target '**/*.kt'
+        ktfmt('0.34').googleStyle()
+    }
+}
+
+compileTestKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
+
+dependencies {
+    api project(":robolectric")
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation 'io.mockk:mockk:1.9.3'
+}
diff --git a/integration_tests/mockk/src/test/java/org/robolectric/integrationtests/mockk/MockkGenericMethodTestCase.kt b/integration_tests/mockk/src/test/java/org/robolectric/integrationtests/mockk/MockkGenericMethodTestCase.kt
new file mode 100644
index 0000000..0e21a12
--- /dev/null
+++ b/integration_tests/mockk/src/test/java/org/robolectric/integrationtests/mockk/MockkGenericMethodTestCase.kt
@@ -0,0 +1,25 @@
+package org.robolectric.integrationtests.mockk
+
+import com.google.common.truth.Truth.assertThat
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+class Entity
+
+interface Repository {
+  fun <T> get(key: String, type: Class<T>): T
+}
+
+@RunWith(RobolectricTestRunner::class)
+class MockkGenericMethodTestCase {
+  @Test
+  fun `stubbing a generic method works`() {
+    val entity = Entity()
+    val repo: Repository = mockk { every { get(any(), Entity::class.java) } returns entity }
+
+    assertThat(repo.get("a", Entity::class.java)).isEqualTo(entity)
+  }
+}
diff --git a/integration_tests/mockk/src/test/java/org/robolectric/integrationtests/mockk/MockkInitTestCase.kt b/integration_tests/mockk/src/test/java/org/robolectric/integrationtests/mockk/MockkInitTestCase.kt
new file mode 100644
index 0000000..eac598f
--- /dev/null
+++ b/integration_tests/mockk/src/test/java/org/robolectric/integrationtests/mockk/MockkInitTestCase.kt
@@ -0,0 +1,27 @@
+package org.robolectric.integrationtests.mockk
+
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+class NumberReturner {
+  fun returnNumber() = 0
+}
+
+@RunWith(RobolectricTestRunner::class)
+class MockkInitTestCase {
+
+  @MockK lateinit var returner: NumberReturner
+
+  @Before fun setUp() = MockKAnnotations.init(this)
+
+  @Test
+  fun `Mockk1`() {
+    every { returner.returnNumber() } returns 1
+    assert(returner.returnNumber() == 1)
+  }
+}
diff --git a/integration_tests/multidex/src/test/AndroidManifest.xml b/integration_tests/multidex/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..3da90a8
--- /dev/null
+++ b/integration_tests/multidex/src/test/AndroidManifest.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.integrationtests.multidex">
+
+  <uses-sdk
+      android:minSdkVersion="14"
+      android:targetSdkVersion="27"/>
+
+  <application />
+
+  <instrumentation
+      android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="org.robolectric.integrationtests.multidex"/>
+
+</manifest>
diff --git a/integration_tests/multidex/src/test/java/org/robolectric/integrationtests/multidex/MultiDexTest.java b/integration_tests/multidex/src/test/java/org/robolectric/integrationtests/multidex/MultiDexTest.java
new file mode 100644
index 0000000..02715f1
--- /dev/null
+++ b/integration_tests/multidex/src/test/java/org/robolectric/integrationtests/multidex/MultiDexTest.java
@@ -0,0 +1,17 @@
+package org.robolectric.integrationtests.multidex;
+
+import android.support.multidex.MultiDex;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Integration tests for MultiDex Robolectric. */
+@RunWith(AndroidJUnit4.class)
+public class MultiDexTest {
+
+  @Test
+  public void testIntendedFailEmpty() {
+    MultiDex.install(ApplicationProvider.getApplicationContext());
+  }
+}
diff --git a/integration_tests/multidex/src/test/java/org/robolectric/integrationtests/multidex/robolectric.properties b/integration_tests/multidex/src/test/java/org/robolectric/integrationtests/multidex/robolectric.properties
new file mode 100644
index 0000000..5b66826
--- /dev/null
+++ b/integration_tests/multidex/src/test/java/org/robolectric/integrationtests/multidex/robolectric.properties
@@ -0,0 +1,3 @@
+sdk=ALL_SDKS
+# make Robolectric match the emulator used
+qualifiers=w480dp-h800dp
diff --git a/integration_tests/play_services/build.gradle b/integration_tests/play_services/build.gradle
new file mode 100644
index 0000000..409ee22
--- /dev/null
+++ b/integration_tests/play_services/build.gradle
@@ -0,0 +1,14 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+dependencies {
+    api project(":robolectric")
+    api project(":shadows:playservices")
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.android.gms:play-services-basement:18.0.1"
+}
\ No newline at end of file
diff --git a/integration_tests/play_services/src/test/java/org/robolectric/integrationtests/playservices/ApiExceptionTest.java b/integration_tests/play_services/src/test/java/org/robolectric/integrationtests/playservices/ApiExceptionTest.java
new file mode 100644
index 0000000..d0c4ea5
--- /dev/null
+++ b/integration_tests/play_services/src/test/java/org/robolectric/integrationtests/playservices/ApiExceptionTest.java
@@ -0,0 +1,26 @@
+package org.robolectric.integrationtests.playservices;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.android.gms.common.api.ApiException;
+import com.google.android.gms.common.api.CommonStatusCodes;
+import com.google.android.gms.common.api.Status;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(instrumentedPackages = "com.google.android.gms.common.api")
+public class ApiExceptionTest {
+  /**
+   * The ApiException constructor bundled with play-services-basement has a constructor that does
+   * not conform to what Robolectric expects. It is likely due to either desugaring or proguarding.
+   * Ensure that attempting to instantiate it does not cause a VerifyError
+   */
+  @Test
+  public void testApiException() {
+    ApiException apiException = new ApiException(new Status(CommonStatusCodes.ERROR, ""));
+    assertThat(apiException).isNotNull();
+  }
+}
diff --git a/integration_tests/powermock/build.gradle b/integration_tests/powermock/build.gradle
new file mode 100644
index 0000000..be4180c
--- /dev/null
+++ b/integration_tests/powermock/build.gradle
@@ -0,0 +1,17 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+dependencies {
+    api project(":robolectric")
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+
+    testImplementation "org.powermock:powermock-module-junit4:2.0.9"
+    testImplementation "org.powermock:powermock-module-junit4-rule:2.0.9"
+    testImplementation "org.powermock:powermock-api-mockito2:2.0.9"
+    testImplementation "org.powermock:powermock-classloading-xstream:2.0.9"
+}
\ No newline at end of file
diff --git a/integration_tests/powermock/src/test/java/org/robolectric/integrationtests/mockito/PowerMockStaticTest.java b/integration_tests/powermock/src/test/java/org/robolectric/integrationtests/mockito/PowerMockStaticTest.java
new file mode 100644
index 0000000..60040d6
--- /dev/null
+++ b/integration_tests/powermock/src/test/java/org/robolectric/integrationtests/mockito/PowerMockStaticTest.java
@@ -0,0 +1,35 @@
+package org.robolectric.integrationtests.mockito;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.powermock.api.mockito.PowerMockito;
+import org.powermock.core.classloader.annotations.PowerMockIgnore;
+import org.powermock.core.classloader.annotations.PrepareForTest;
+import org.powermock.modules.junit4.rule.PowerMockRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*"})
+@PrepareForTest(PowerMockStaticTest.Static.class)
+public class PowerMockStaticTest {
+  @Rule
+  public PowerMockRule rule = new PowerMockRule();
+
+  @Test
+  public void testStaticMocking() {
+    PowerMockito.mockStatic(Static.class);
+    Mockito.when(Static.staticMethod()).thenReturn("hello mock");
+
+    assertThat(Static.staticMethod()).isEqualTo("hello mock");
+  }
+
+  public static class Static {
+    public static String staticMethod() {
+      return "";
+    }
+  }
+}
diff --git a/integration_tests/security-providers/build.gradle b/integration_tests/security-providers/build.gradle
new file mode 100644
index 0000000..69d605b
--- /dev/null
+++ b/integration_tests/security-providers/build.gradle
@@ -0,0 +1,15 @@
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+
+dependencies {
+    api project(":robolectric")
+    api "junit:junit:${junitVersion}"
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.4.0"
+    testImplementation "com.squareup.okhttp3:okhttp"
+    testImplementation platform("com.squareup.okhttp3:okhttp-bom:4.8.0")
+}
diff --git a/integration_tests/security-providers/src/test/java/org/robolectric/integrationtests/securityproviders/SecurityProvidersTest.java b/integration_tests/security-providers/src/test/java/org/robolectric/integrationtests/securityproviders/SecurityProvidersTest.java
new file mode 100644
index 0000000..6e00269
--- /dev/null
+++ b/integration_tests/security-providers/src/test/java/org/robolectric/integrationtests/securityproviders/SecurityProvidersTest.java
@@ -0,0 +1,38 @@
+package org.robolectric.integrationtests.securityproviders;
+
+import java.net.URL;
+import java.security.Provider;
+import java.security.Security;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import org.conscrypt.OpenSSLProvider;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Integration tests for {@link java.security.Provider} related features. */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Config.ALL_SDKS)
+public class SecurityProvidersTest {
+  // Conscrypt (which calls into native libraries) can only be loaded once as per JNI spec:
+  // https://docs.oracle.com/javase/1.5.0/docs/guide/jni/spec/invocation.html#library_version
+  // If we try to load it into multiple class loaders, UnsatisfiedLinkErrors will be thrown.
+  private static final Provider CONSCRYPT_PROVIDER = new OpenSSLProvider();
+
+  @Test
+  public void jsseProvider_isFunctioning() throws Exception {
+    URL url = new URL("https://www.google.com");
+    url.openConnection().getInputStream();
+  }
+
+  @Test
+  public void conscryptProvider_isSupported() throws Exception {
+    if (!"Conscrypt".equals(Security.getProviders()[0].getName())) {
+      Security.insertProviderAt(CONSCRYPT_PROVIDER, 1);
+    }
+    OkHttpClient client = new OkHttpClient.Builder().build();
+    Request request = new Request.Builder().url("https://www.google.com").build();
+    client.newCall(request).execute();
+  }
+}
diff --git a/integration_tests/sparsearray/build.gradle b/integration_tests/sparsearray/build.gradle
new file mode 100644
index 0000000..4aee7f6
--- /dev/null
+++ b/integration_tests/sparsearray/build.gradle
@@ -0,0 +1,47 @@
+import org.robolectric.gradle.AndroidProjectConfigPlugin
+
+apply plugin: 'com.android.library'
+apply plugin: AndroidProjectConfigPlugin
+apply plugin: 'kotlin-android'
+apply plugin: "com.diffplug.spotless"
+
+spotless {
+    kotlin {
+        target '**/*.kt'
+        ktfmt('0.34').googleStyle()
+    }
+}
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+    }
+
+    compileOptions {
+        sourceCompatibility = '1.8'
+        targetCompatibility = '1.8'
+    }
+
+    android {
+        testOptions {
+            unitTests {
+                includeAndroidResources = true
+            }
+        }
+    }
+}
+
+dependencies {
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+    implementation project(path: ':shadowapi', configuration: 'default')
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation "com.google.truth:truth:$truthVersion"
+    testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+}
diff --git a/integration_tests/sparsearray/src/main/AndroidManifest.xml b/integration_tests/sparsearray/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6ce6065
--- /dev/null
+++ b/integration_tests/sparsearray/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest package="org.robolectric.sparsearray">
+    <application />
+</manifest>
diff --git a/integration_tests/sparsearray/src/test/java/org/robolectric/integrationtests/sparsearray/SparseArraySetTest.kt b/integration_tests/sparsearray/src/test/java/org/robolectric/integrationtests/sparsearray/SparseArraySetTest.kt
new file mode 100644
index 0000000..05a2661
--- /dev/null
+++ b/integration_tests/sparsearray/src/test/java/org/robolectric/integrationtests/sparsearray/SparseArraySetTest.kt
@@ -0,0 +1,35 @@
+package org.robolectric.integrationtests.sparsearray
+
+import android.util.SparseArray
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+// Despite being a bug for pre-31, we don't want to break 31+ with our change, so we test all SDKs
+@Config(sdk = [Config.ALL_SDKS])
+class SparseArraySetTest {
+
+  val sparseArray = SparseArray<String>()
+
+  // See https://github.com/robolectric/robolectric/issues/6840
+  @Test
+  fun testSparseArrayBracketOperator_callsSetMethodPreApi31() {
+    sparseArray[0] = "Blizzard"
+    sparseArray[1] = "Blizzara"
+
+    assertThat(sparseArray[0]).isEqualTo("Blizzard")
+    assertThat(sparseArray[1]).isEqualTo("Blizzara")
+  }
+
+  @Test
+  fun testSparseArraySetFunction_callsSetMethodPreApi31() {
+    sparseArray.set(0, "Blizzaga")
+    sparseArray.set(1, "Blizzaja")
+
+    assertThat(sparseArray[0]).isEqualTo("Blizzaga")
+    assertThat(sparseArray[1]).isEqualTo("Blizzaja")
+  }
+}
diff --git a/junit/build.gradle b/junit/build.gradle
new file mode 100644
index 0000000..9a11978
--- /dev/null
+++ b/junit/build.gradle
@@ -0,0 +1,16 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    api project(":annotations")
+    api project(":sandbox")
+    api project(":pluginapi")
+    api project(":shadowapi")
+    api project(":utils:reflector")
+
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+    compileOnly "junit:junit:${junitVersion}"
+}
diff --git a/junit/src/main/java/org/robolectric/internal/SandboxTestRunner.java b/junit/src/main/java/org/robolectric/internal/SandboxTestRunner.java
new file mode 100644
index 0000000..fb5a87f
--- /dev/null
+++ b/junit/src/main/java/org/robolectric/internal/SandboxTestRunner.java
@@ -0,0 +1,435 @@
+package org.robolectric.internal;
+
+import static java.util.Arrays.asList;
+import static java.util.Arrays.stream;
+
+import com.google.common.base.Splitter;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.internal.runners.statements.FailOnTimeout;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.BlockJUnit4ClassRunner;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+import org.junit.runners.model.Statement;
+import org.junit.runners.model.TestClass;
+import org.robolectric.internal.bytecode.ClassHandler;
+import org.robolectric.internal.bytecode.ClassHandlerBuilder;
+import org.robolectric.internal.bytecode.ClassInstrumentor;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.internal.bytecode.Interceptor;
+import org.robolectric.internal.bytecode.Interceptors;
+import org.robolectric.internal.bytecode.Sandbox;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.internal.bytecode.ShadowInfo;
+import org.robolectric.internal.bytecode.ShadowMap;
+import org.robolectric.internal.bytecode.ShadowProviders;
+import org.robolectric.internal.bytecode.UrlResourceProvider;
+import org.robolectric.pluginapi.perf.Metadata;
+import org.robolectric.pluginapi.perf.Metric;
+import org.robolectric.pluginapi.perf.PerfStatsReporter;
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.PerfStatsCollector.Event;
+import org.robolectric.util.Util;
+import org.robolectric.util.inject.Injector;
+
+/**
+ * Sandbox test runner that runs each test in a sandboxed class loader environment. Typically this
+ * runner should not be directly accessed, use {@link org.robolectric.RobolectricTestRunner}
+ * instead.
+ */
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+public class SandboxTestRunner extends BlockJUnit4ClassRunner {
+
+  private static final Injector DEFAULT_INJECTOR = defaultInjector().build();
+
+  protected static Injector.Builder defaultInjector() {
+    return new Injector.Builder();
+  }
+
+  private final ClassInstrumentor classInstrumentor;
+  private final Interceptors interceptors;
+  private final ShadowProviders shadowProviders;
+  protected final ClassHandlerBuilder classHandlerBuilder;
+
+  private final List<PerfStatsReporter> perfStatsReporters;
+  private final HashMap<Class<?>, Sandbox> loadedTestClasses = new HashMap<>();
+  private final HashMap<Class<?>, HelperTestRunner> helperRunners = new HashMap<>();
+
+  public SandboxTestRunner(Class<?> klass) throws InitializationError {
+    this(klass, DEFAULT_INJECTOR);
+  }
+
+  public SandboxTestRunner(Class<?> klass, Injector injector) throws InitializationError {
+    super(klass);
+
+    classInstrumentor = injector.getInstance(ClassInstrumentor.class);
+    interceptors = new Interceptors(findInterceptors());
+    shadowProviders = injector.getInstance(ShadowProviders.class);
+    classHandlerBuilder = injector.getInstance(ClassHandlerBuilder.class);
+    perfStatsReporters = Arrays.asList(injector.getInstance(PerfStatsReporter[].class));
+  }
+
+  @Nonnull
+  protected Collection<Interceptor> findInterceptors() {
+    return Collections.emptyList();
+  }
+
+  @Nonnull
+  protected Interceptors getInterceptors() {
+    return interceptors;
+  }
+
+  @Override
+  protected Statement classBlock(RunNotifier notifier) {
+    final Statement statement = childrenInvoker(notifier);
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        try {
+          statement.evaluate();
+          for (Map.Entry<Class<?>, Sandbox> entry : loadedTestClasses.entrySet()) {
+            Sandbox sandbox = entry.getValue();
+            sandbox.runOnMainThread(
+                () -> {
+                  ClassLoader priorContextClassLoader =
+                      Thread.currentThread().getContextClassLoader();
+                  Thread.currentThread().setContextClassLoader(sandbox.getRobolectricClassLoader());
+                  try {
+                    invokeAfterClass(entry.getKey());
+                  } catch (Throwable throwable) {
+                    throw Util.sneakyThrow(throwable);
+                  } finally {
+                    Thread.currentThread().setContextClassLoader(priorContextClassLoader);
+                  }
+                });
+          }
+        } finally {
+          afterClass();
+          loadedTestClasses.clear();
+        }
+      }
+    };
+  }
+
+  private void invokeBeforeClass(final Class<?> clazz, final Sandbox sandbox) throws Throwable {
+    if (!loadedTestClasses.containsKey(clazz)) {
+      loadedTestClasses.put(clazz, sandbox);
+
+      final TestClass testClass = new TestClass(clazz);
+      final List<FrameworkMethod> befores = testClass.getAnnotatedMethods(BeforeClass.class);
+      for (FrameworkMethod before : befores) {
+        before.invokeExplosively(null);
+      }
+    }
+  }
+
+  private static void invokeAfterClass(final Class<?> clazz) throws Throwable {
+    final TestClass testClass = new TestClass(clazz);
+    final List<FrameworkMethod> afters = testClass.getAnnotatedMethods(AfterClass.class);
+    for (FrameworkMethod after : afters) {
+      after.invokeExplosively(null);
+    }
+  }
+
+  protected void afterClass() {}
+
+  @Nonnull
+  protected Sandbox getSandbox(FrameworkMethod method) {
+    InstrumentationConfiguration instrumentationConfiguration = createClassLoaderConfig(method);
+    return new Sandbox(instrumentationConfiguration, new UrlResourceProvider(), classInstrumentor);
+  }
+
+  /**
+   * Create an {@link InstrumentationConfiguration} suitable for the provided {@link
+   * FrameworkMethod}.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide alternate
+   * configuration.
+   *
+   * @param method the test method that's about to run
+   * @return an {@link InstrumentationConfiguration}
+   */
+  @Nonnull
+  protected InstrumentationConfiguration createClassLoaderConfig(FrameworkMethod method) {
+    InstrumentationConfiguration.Builder builder =
+        InstrumentationConfiguration.newBuilder()
+            .doNotAcquirePackage("java.")
+            .doNotAcquirePackage("jdk.internal.")
+            .doNotAcquirePackage("sun.")
+            .doNotAcquirePackage("org.robolectric.annotation.")
+            .doNotAcquirePackage("org.robolectric.internal.")
+            .doNotAcquirePackage("org.robolectric.pluginapi.")
+            .doNotAcquirePackage("org.robolectric.util.")
+            .doNotAcquirePackage("org.junit");
+
+    String customPackages = System.getProperty("org.robolectric.packagesToNotAcquire", "");
+    for (String pkg : Splitter.on(',').split(customPackages)) {
+      if (!pkg.isEmpty()) {
+        builder.doNotAcquirePackage(pkg);
+      }
+    }
+
+    String customClassesRegex =
+        System.getProperty("org.robolectric.classesToNotInstrumentRegex", "");
+    if (!customClassesRegex.isEmpty()) {
+      builder.setDoNotInstrumentClassRegex(customClassesRegex);
+    }
+
+    for (Class<?> shadowClass : getExtraShadows(method)) {
+      ShadowInfo shadowInfo = ShadowMap.obtainShadowInfo(shadowClass);
+      builder.addInstrumentedClass(shadowInfo.shadowedClassName);
+    }
+
+    addInstrumentedPackages(method, builder);
+
+    return builder.build();
+  }
+
+  private void addInstrumentedPackages(
+      FrameworkMethod method, InstrumentationConfiguration.Builder builder) {
+    SandboxConfig classConfig = getTestClass().getJavaClass().getAnnotation(SandboxConfig.class);
+    if (classConfig != null) {
+      for (String pkgName : classConfig.instrumentedPackages()) {
+        builder.addInstrumentedPackage(pkgName);
+      }
+    }
+
+    SandboxConfig methodConfig = method.getAnnotation(SandboxConfig.class);
+    if (methodConfig != null) {
+      for (String pkgName : methodConfig.instrumentedPackages()) {
+        builder.addInstrumentedPackage(pkgName);
+      }
+    }
+  }
+
+  protected void configureSandbox(Sandbox sandbox, FrameworkMethod method) {
+    ShadowMap.Builder builder = shadowProviders.getBaseShadowMap().newBuilder();
+
+    // Configure shadows *BEFORE* setting the ClassLoader. This is necessary because
+    // creating the ShadowMap loads all ShadowProviders via ServiceLoader and this is
+    // not available once we install the Robolectric class loader.
+    Class<?>[] shadows = getExtraShadows(method);
+    if (shadows.length > 0) {
+      builder.addShadowClasses(shadows);
+    }
+    ShadowMap shadowMap = builder.build();
+    sandbox.replaceShadowMap(shadowMap);
+
+    sandbox.configure(createClassHandler(shadowMap, sandbox), getInterceptors());
+  }
+
+  @Override
+  @SuppressWarnings("CatchAndPrintStackTrace")
+  protected Statement methodBlock(final FrameworkMethod method) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        PerfStatsCollector perfStatsCollector = PerfStatsCollector.getInstance();
+        perfStatsCollector.reset();
+        perfStatsCollector.setEnabled(!perfStatsReporters.isEmpty());
+
+        Event initialization = perfStatsCollector.startEvent("initialization");
+
+        final Sandbox sandbox = getSandbox(method);
+
+        // Configure sandbox *BEFORE* setting the ClassLoader. This is necessary because
+        // creating the ShadowMap loads all ShadowProviders via ServiceLoader and this is
+        // not available once we install the Robolectric class loader.
+        configureSandbox(sandbox, method);
+
+        sandbox.runOnMainThread(
+            () -> {
+              ClassLoader priorContextClassLoader = Thread.currentThread().getContextClassLoader();
+              Thread.currentThread().setContextClassLoader(sandbox.getRobolectricClassLoader());
+
+              Class<?> bootstrappedTestClass =
+                  sandbox.bootstrappedClass(getTestClass().getJavaClass());
+              HelperTestRunner helperTestRunner = getCachedHelperTestRunner(bootstrappedTestClass);
+              helperTestRunner.frameworkMethod = method;
+
+              final Method bootstrappedMethod;
+              try {
+                Class<?>[] parameterTypes =
+                    stream(method.getMethod().getParameterTypes())
+                        .map(type -> type.isPrimitive() ? type : sandbox.bootstrappedClass(type))
+                        .toArray(Class[]::new);
+                bootstrappedMethod =
+                    bootstrappedTestClass.getMethod(method.getMethod().getName(), parameterTypes);
+              } catch (NoSuchMethodException e) {
+                throw new RuntimeException(e);
+              }
+
+              try {
+                // Only invoke @BeforeClass once per class
+                invokeBeforeClass(bootstrappedTestClass, sandbox);
+
+                beforeTest(sandbox, method, bootstrappedMethod);
+
+                initialization.finished();
+
+                Statement statement =
+                    helperTestRunner.methodBlock(new FrameworkMethod(bootstrappedMethod));
+
+                // todo: this try/finally probably isn't right -- should mimic RunAfters? [xw]
+                try {
+                  statement.evaluate();
+                } finally {
+                  afterTest(method, bootstrappedMethod);
+                }
+              } catch (Throwable throwable) {
+                throw Util.sneakyThrow(throwable);
+              } finally {
+                Thread.currentThread().setContextClassLoader(priorContextClassLoader);
+                try {
+                  finallyAfterTest(method);
+                } catch (Exception e) {
+                  e.printStackTrace();
+                }
+              }
+            });
+
+        reportPerfStats(perfStatsCollector);
+        perfStatsCollector.reset();
+      }
+    };
+  }
+
+  @SuppressWarnings("CatchAndPrintStackTrace")
+  private void reportPerfStats(PerfStatsCollector perfStatsCollector) {
+    if (perfStatsReporters.isEmpty()) {
+      return;
+    }
+
+    Metadata metadata = perfStatsCollector.getMetadata();
+    Collection<Metric> metrics = perfStatsCollector.getMetrics();
+
+    for (PerfStatsReporter perfStatsReporter : perfStatsReporters) {
+      try {
+        perfStatsReporter.report(metadata, metrics);
+      } catch (Exception e) {
+        e.printStackTrace();
+      }
+    }
+  }
+
+  protected void beforeTest(Sandbox sandbox, FrameworkMethod method, Method bootstrappedMethod)
+      throws Throwable {}
+
+  protected void afterTest(FrameworkMethod method, Method bootstrappedMethod) {}
+
+  protected void finallyAfterTest(FrameworkMethod method) {}
+
+  protected HelperTestRunner getHelperTestRunner(Class<?> bootstrappedTestClass)
+      throws InitializationError {
+    return new HelperTestRunner(bootstrappedTestClass);
+  }
+
+  private HelperTestRunner getCachedHelperTestRunner(Class<?> bootstrappedTestClass) {
+    return helperRunners.computeIfAbsent(
+        bootstrappedTestClass,
+        klass -> {
+          try {
+            return getHelperTestRunner(klass);
+          } catch (InitializationError e) {
+            throw new RuntimeException(e);
+          }
+        });
+  }
+
+  protected static class HelperTestRunner extends BlockJUnit4ClassRunner {
+    public FrameworkMethod frameworkMethod;
+
+    public HelperTestRunner(Class<?> klass) throws InitializationError {
+      super(klass);
+    }
+
+    // for visibility from SandboxTestRunner.methodBlock()
+    @Override
+    protected Statement methodBlock(FrameworkMethod method) {
+      return super.methodBlock(method);
+    }
+
+    /**
+     * For tests with a timeout, we need to wrap the test method execution (but not {@code @Before}s
+     * or {@code @After}s in a {@link TimeLimitedStatement}. JUnit's built-in {@link FailOnTimeout}
+     * statement causes the test method (but not {@code @Before}s or {@code @After}s) to be run on a
+     * short-lived thread. This is inadequate for our purposes; we want to guarantee that every
+     * entry point to test code is run from the same thread.
+     */
+    @Override
+    protected Statement methodInvoker(FrameworkMethod method, Object test) {
+      Statement delegate = super.methodInvoker(method, test);
+      long timeout = getTimeout(method.getAnnotation(Test.class));
+
+      if (timeout == 0) {
+        return delegate;
+      } else {
+        return new TimeLimitedStatement(timeout, delegate);
+      }
+    }
+
+    /**
+     * Disables JUnit's normal timeout mode strategy.
+     *
+     * @see #methodInvoker(FrameworkMethod, Object)
+     * @see TimeLimitedStatement
+     */
+    @Override
+    protected Statement withPotentialTimeout(FrameworkMethod method, Object test, Statement next) {
+      return next;
+    }
+
+    private long getTimeout(Test annotation) {
+      if (annotation == null) {
+        return 0;
+      }
+      return annotation.timeout();
+    }
+
+    @Override
+    protected String testName(FrameworkMethod method) {
+      return frameworkMethod.getName();
+    }
+  }
+
+  @Nonnull
+  protected Class<?>[] getExtraShadows(FrameworkMethod method) {
+    List<Class<?>> shadowClasses = new ArrayList<>();
+    addShadows(shadowClasses, getTestClass().getJavaClass().getAnnotation(SandboxConfig.class));
+    addShadows(shadowClasses, method.getAnnotation(SandboxConfig.class));
+    return shadowClasses.toArray(new Class[shadowClasses.size()]);
+  }
+
+  private void addShadows(List<Class<?>> shadowClasses, SandboxConfig annotation) {
+    if (annotation != null) {
+      shadowClasses.addAll(asList(annotation.shadows()));
+    }
+  }
+
+  @Nonnull
+  protected ClassHandler createClassHandler(ShadowMap shadowMap, Sandbox sandbox) {
+    return classHandlerBuilder.build(shadowMap, ShadowMatcher.MATCH_ALL, interceptors);
+  }
+
+  /**
+   * Disables JUnit's normal timeout mode strategy.
+   *
+   * @see #methodInvoker(FrameworkMethod, Object)
+   * @see TimeLimitedStatement
+   */
+  protected Statement withPotentialTimeout(FrameworkMethod method, Object test, Statement next) {
+    return next;
+  }
+}
diff --git a/junit/src/main/java/org/robolectric/internal/TimeLimitedStatement.java b/junit/src/main/java/org/robolectric/internal/TimeLimitedStatement.java
new file mode 100644
index 0000000..53cdf98
--- /dev/null
+++ b/junit/src/main/java/org/robolectric/internal/TimeLimitedStatement.java
@@ -0,0 +1,48 @@
+package org.robolectric.internal;
+
+import java.util.concurrent.TimeUnit;
+import org.junit.runners.model.Statement;
+import org.junit.runners.model.TestTimedOutException;
+
+/**
+ * Similar to JUnit's {@link org.junit.internal.runners.statements.FailOnTimeout}, but runs the
+ * test on the current thread (with a timer on a new thread) rather than the other way around.
+ */
+class TimeLimitedStatement extends Statement {
+
+  private final long timeout;
+  private final Statement delegate;
+
+  public TimeLimitedStatement(long timeout, Statement delegate) {
+    this.timeout = timeout;
+    this.delegate = delegate;
+  }
+
+  @Override
+  public void evaluate() throws Throwable {
+    Thread testThread = Thread.currentThread();
+    Thread timeoutThread =
+        new Thread(
+            () -> {
+              try {
+                Thread.sleep(timeout);
+                testThread.interrupt();
+              } catch (InterruptedException e) {
+                // ok
+              }
+            },
+            "Robolectric time-limited test");
+    timeoutThread.start();
+
+    try {
+      delegate.evaluate();
+    } catch (InterruptedException e) {
+      Exception e2 = new TestTimedOutException(timeout, TimeUnit.MILLISECONDS);
+      e2.setStackTrace(e.getStackTrace());
+      throw e2;
+    } finally {
+      timeoutThread.interrupt();
+      timeoutThread.join();
+    }
+  }
+}
diff --git a/nativeruntime/build.gradle b/nativeruntime/build.gradle
new file mode 100644
index 0000000..ecfe569
--- /dev/null
+++ b/nativeruntime/build.gradle
@@ -0,0 +1,195 @@
+import groovy.json.JsonSlurper
+import java.nio.charset.StandardCharsets
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+static def osName() {
+  def osName = System.getProperty("os.name").toLowerCase(Locale.US);
+  if (osName.contains("linux")) {
+    return "linux"
+  } else if (osName.contains("mac")) {
+    return "mac"
+  } else if (osName.contains("win")) {
+    return "windows"
+  }
+  return "unknown"
+}
+
+static def arch() {
+  def arch = System.getProperty("os.arch").toLowerCase(Locale.US);
+  if (arch.equals("x86_64") || arch.equals("amd64")) {
+    return "x86_64"
+  }
+  return arch
+}
+
+static def authHeader() {
+  def user = System.getenv('GITHUB_USER')
+  if (!user) {
+    throw new GradleException("Missing GITHUB_USER environment variable")
+  }
+  def token = System.getenv('GITHUB_TOKEN')
+  if (!token) {
+    throw new GradleException("Missing GITHUB_TOKEN environment variable")
+  }
+  def lp = "$user:$token"
+  def encoded = Base64.getEncoder().encodeToString(lp.getBytes(StandardCharsets.UTF_8))
+  return "Basic $encoded"
+}
+
+task cmakeNativeRuntime {
+  doLast {
+    mkdir "$buildDir/cpp"
+    exec {
+      workingDir "$buildDir/cpp"
+      commandLine 'cmake', "-B", ".", "-S","$projectDir/cpp/", "-G", "Ninja"
+    }
+  }
+}
+
+task configureICU {
+  onlyIf { !System.getenv('ICU_ROOT_DIR') }
+  doLast {
+    def os = osName()
+    if (!file("$projectDir/external/icu/icu4c/source").exists()) {
+      throw new GradleException("ICU submodule not detected. Please run `git submodule update --init`")
+    }
+    if (file("$projectDir/external/icu/icu4c/source/Makefile").exists()) {
+      println("ICU Makefile detected, skipping ICU configure")
+    } else {
+      exec {
+        workingDir "$projectDir/external/icu/icu4c/source"
+        if (os.contains("linux")) {
+          environment "CFLAGS", "-fPIC"
+          environment "CXXFLAGS", "-fPIC"
+          commandLine './runConfigureICU', 'Linux', '--enable-static', '--disable-shared'
+        } else if (os.contains("mac")) {
+          commandLine './runConfigureICU', 'MacOSX', '--enable-static', '--disable-shared'
+        } else if (os.contains("win")) {
+          commandLine 'sh', './runConfigureICU', 'MinGW', '--enable-static', '--disable-shared'
+        } else {
+          println("ICU configure not supported for OS '${System.getProperty("os.name")}'")
+        }
+      }
+    }
+  }
+}
+
+task buildICU {
+  onlyIf { !System.getenv('ICU_ROOT_DIR') }
+  dependsOn configureICU
+  doLast {
+    exec {
+      def os = osName()
+      if (os.contains("linux") || os.contains("mac") || os.contains("win")) {
+        workingDir "$projectDir/external/icu/icu4c/source"
+        commandLine 'make', '-j4'
+      }
+    }
+  }
+}
+
+task makeNativeRuntime {
+  dependsOn buildICU
+  dependsOn cmakeNativeRuntime
+  doLast {
+    exec {
+      workingDir "$buildDir/cpp"
+      commandLine 'ninja'
+    }
+  }
+}
+
+task copyNativeRuntimeToResources {
+  def os = osName()
+  if (System.getenv('SKIP_NATIVERUNTIME_BUILD')) {
+    println("Skipping the nativeruntime build");
+  } else if (!os.contains("linux") && !os.contains("mac") && !os.contains("win")) {
+    println("Building the nativeruntime not supported for OS '${System.getProperty("os.name")}'")
+  } else {
+    dependsOn makeNativeRuntime
+    outputs.dir "$buildDir/resources/main/native"
+    doLast {
+      copy {
+        from ("$buildDir/cpp")
+        include '*libnativeruntime.*'
+        rename { String fileName ->
+          if (os.contains("win")) {
+            fileName.replace("libnativeruntime", "robolectric-nativeruntime")
+          } else {
+            fileName.replace("libnativeruntime", "librobolectric-nativeruntime")
+          }
+        }
+        into "$buildDir/resources/main/native/$os/${arch()}/"
+      }
+    }
+  }
+}
+
+task copyNativeRuntimeFromGithubAction {
+  outputs.dir "$buildDir/resources/main/native"
+  doLast {
+    def checkRunId = System.getenv('NATIVERUNTIME_ACTION_RUN_ID')
+    def artifactsUrl = "https://api.github.com/repos/robolectric/robolectric/actions/runs/$checkRunId/artifacts"
+    def downloadDir = new File("$buildDir/robolectric-nativeruntime-artifacts-$checkRunId")
+    downloadDir.mkdirs()
+    new JsonSlurper().parseText(new URL(artifactsUrl).text).artifacts.each { artifact ->
+      def f = new File(downloadDir, "${artifact.name}.zip")
+      if (!f.exists()) {
+        println("Fetching ${artifact.name}.zip to $f")
+        def conn = (HttpURLConnection) new URL(artifact.archive_download_url).openConnection()
+        conn.instanceFollowRedirects = true
+        conn.setRequestProperty("Authorization", authHeader())
+
+        f.withOutputStream { out ->
+          conn.inputStream.with { inp ->
+            out << inp
+            inp.close()
+            out.close()
+          }
+        }
+      }
+      copy {
+        from zipTree(f)
+        include "librobolectric*"
+        rename { String fileName ->
+          fileName = fileName.replaceFirst("librobolectric.*dylib", "librobolectric-nativeruntime.dylib")
+          return fileName.replaceFirst("librobolectric.*so", "librobolectric-nativeruntime.so")
+        }
+        def os = "linux"
+        if (artifact.name.contains("-mac")) {
+          os = "mac"
+        }
+        def arch = "x86_64"
+        if (artifact.name.contains("-arm64")) {
+          arch = "aarch64"
+        }
+        into "$buildDir/resources/main/native/$os/$arch/"
+      }
+    }
+  }
+}
+
+processResources {
+  if (System.getenv('NATIVERUNTIME_ACTION_RUN_ID')) {
+    dependsOn copyNativeRuntimeFromGithubAction
+  } else {
+    dependsOn copyNativeRuntimeToResources
+  }
+}
+
+dependencies {
+  api project(":utils")
+
+  annotationProcessor "com.google.auto.service:auto-service:$autoServiceVersion"
+  api "com.google.guava:guava:$guavaJREVersion"
+
+  compileOnly "com.google.auto.service:auto-service-annotations:$autoServiceVersion"
+  compileOnly AndroidSdk.MAX_SDK.coordinates
+
+  testImplementation "junit:junit:${junitVersion}"
+  testImplementation "com.google.truth:truth:${truthVersion}"
+}
diff --git a/nativeruntime/cpp/CMakeLists.txt b/nativeruntime/cpp/CMakeLists.txt
new file mode 100644
index 0000000..ee5d03c
--- /dev/null
+++ b/nativeruntime/cpp/CMakeLists.txt
@@ -0,0 +1,186 @@
+cmake_minimum_required(VERSION 3.10)
+
+# This is needed to ensure that static libraries can be linked into shared libraries.
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+
+# Some libutils headers require C++17
+set (CMAKE_CXX_STANDARD 17)
+
+project(nativeruntime)
+
+if (WIN32)
+  if(NOT DEFINED ENV{JAVA_HOME})
+    message(FATAL_ERROR "JAVA_HOME is required in Windows")
+  endif()
+  # find_package JNI is broken on Windows, manually include header files
+  set(JNI_INCLUDE_DIRS "$ENV{JAVA_HOME}/include" "$ENV{JAVA_HOME}/include/win32")
+else()
+  find_package(JNI REQUIRED)
+endif()
+
+set(ANDROID_SQLITE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../external/sqlite")
+
+if(NOT EXISTS "${ANDROID_SQLITE_DIR}/dist/sqlite3.c")
+  message(FATAL_ERROR "SQLite submodule missing. Please run `git submodule update --init`.")
+endif()
+
+if(DEFINED ENV{ICU_ROOT_DIR})
+  if (WIN32)
+    if(NOT EXISTS "$ENV{ICU_ROOT_DIR}/lib/libsicuin.a")
+      message(FATAL_ERROR "ICU_ROOT_DIR does not contain 'lib/libsicuin.a'.")
+    endif()
+  else()
+    if(NOT EXISTS "$ENV{ICU_ROOT_DIR}/lib/libicui18n.a")
+      message(FATAL_ERROR "ICU_ROOT_DIR does not contain 'lib/libicui18n.a'.")
+    endif()
+  endif()
+
+  message(NOTICE "Using $ENV{ICU_ROOT_DIR} as the ICU root dir")
+  list(APPEND CMAKE_PREFIX_PATH "$ENV{ICU_ROOT_DIR}")
+  if (WIN32)
+    find_library(STATIC_ICUI18N_LIBRARY libsicuin.a)
+    find_library(STATIC_ICUUC_LIBRARY libsicuuc.a)
+    find_library(STATIC_ICUDATA_LIBRARY libsicudt.a)
+  else()
+    find_library(STATIC_ICUI18N_LIBRARY libicui18n.a)
+    find_library(STATIC_ICUUC_LIBRARY libicuuc.a)
+    find_library(STATIC_ICUDATA_LIBRARY libicudata.a)
+  endif()
+  include_directories($ENV{ICU_ROOT_DIR}/include)
+else()
+  set(ICU_SUBMODULE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../external/icu")
+
+  if(NOT EXISTS "${ICU_SUBMODULE_DIR}/icu4c/source/i18n/ucol.cpp")
+    message(FATAL_ERROR "ICU submodule missing. Please run `git submodule update --init`.")
+  endif()
+
+  message(NOTICE "Using ${ICU_SUBMODULE_DIR} as the ICU root dir")
+
+  if (WIN32)
+    if(NOT EXISTS "${ICU_SUBMODULE_DIR}/icu4c/source/lib/libsicuin.a")
+      message(FATAL_ERROR "ICU not built. Please run `./gradlew :nativeruntime:buildICU`.")
+    endif()
+  else()
+    if(NOT EXISTS "${ICU_SUBMODULE_DIR}/icu4c/source/lib/libicui18n.a")
+      message(FATAL_ERROR "ICU not built. Please run `./gradlew :nativeruntime:buildICU`.")
+    endif()
+  endif()
+
+  list(APPEND CMAKE_PREFIX_PATH "${ICU_SUBMODULE_DIR}/icu4c/source/")
+  if (WIN32)
+    find_library(STATIC_ICUI18N_LIBRARY libsicuin.a)
+    find_library(STATIC_ICUUC_LIBRARY libsicuuc.a)
+    find_library(STATIC_ICUDATA_LIBRARY libsicudt.a)
+  else()
+    find_library(STATIC_ICUI18N_LIBRARY libicui18n.a)
+    find_library(STATIC_ICUUC_LIBRARY libicuuc.a)
+    find_library(STATIC_ICUDATA_LIBRARY libicudata.a)
+  endif()
+  include_directories(${ICU_SUBMODULE_DIR}/icu4c/source/i18n)
+  include_directories(${ICU_SUBMODULE_DIR}/icu4c/source/common)
+endif()
+
+# Build flags derived from
+# https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:external/sqlite/dist/Android.bp
+
+set(SQLITE_COMPILE_OPTIONS
+  -DHAVE_USLEEP=1
+  -DNDEBUG=1
+  -DSQLITE_DEFAULT_AUTOVACUUM=1
+  -DSQLITE_DEFAULT_FILE_FORMAT=4
+  -DSQLITE_DEFAULT_FILE_PERMISSIONS=0600
+  -DSQLITE_DEFAULT_JOURNAL_SIZE_LIMIT=1048576
+  -DSQLITE_DEFAULT_LEGACY_ALTER_TABLE
+  -DSQLITE_ENABLE_BATCH_ATOMIC_WRITE
+  -DSQLITE_ENABLE_FTS3
+  -DSQLITE_ENABLE_FTS3=1
+  -DSQLITE_ENABLE_FTS3_BACKWARDS
+  -DSQLITE_ENABLE_FTS4
+  -DSQLITE_ENABLE_ICU=1
+  -DSQLITE_ENABLE_MEMORY_MANAGEMENT=1
+  -DSQLITE_HAVE_ISNAN
+  -DSQLITE_OMIT_BUILTIN_TEST
+  -DSQLITE_OMIT_COMPILEOPTION_DIAGS
+  -DSQLITE_OMIT_LOAD_EXTENSION
+  -DSQLITE_POWERSAFE_OVERWRITE=1
+  -DSQLITE_SECURE_DELETE
+  -DSQLITE_TEMP_STORE=3
+  -DSQLITE_THREADSAFE=2
+)
+
+include_directories(${ANDROID_SQLITE_DIR}/dist)
+include_directories(${ANDROID_SQLITE_DIR}/android)
+
+add_library(androidsqlite STATIC
+  ${ANDROID_SQLITE_DIR}/android/OldPhoneNumberUtils.cpp
+  ${ANDROID_SQLITE_DIR}/android/PhoneNumberUtils.cpp
+  ${ANDROID_SQLITE_DIR}/android/PhoneNumberUtils.h
+  ${ANDROID_SQLITE_DIR}/android/sqlite3_android.cpp
+  ${ANDROID_SQLITE_DIR}/dist/sqlite3.c
+  ${ANDROID_SQLITE_DIR}/dist/sqlite3ext.h
+)
+
+target_compile_options(androidsqlite PRIVATE ${SQLITE_COMPILE_OPTIONS})
+
+if (WIN32)
+  target_link_libraries(androidsqlite
+    --static
+    ${STATIC_ICUI18N_LIBRARY}
+    ${STATIC_ICUUC_LIBRARY}
+    ${STATIC_ICUDATA_LIBRARY}
+    gcc
+    stdc++
+  )
+else()
+  target_link_libraries(androidsqlite
+    ${STATIC_ICUI18N_LIBRARY}
+    ${STATIC_ICUUC_LIBRARY}
+    ${STATIC_ICUDATA_LIBRARY}
+    -ldl
+    -lpthread
+  )
+endif()
+
+include_directories(${JNI_INCLUDE_DIRS})
+
+add_subdirectory (liblog)
+include_directories(liblog/include)
+
+include_directories(libnativehelper/include)
+
+add_subdirectory (libutils)
+include_directories(libutils/include)
+
+add_subdirectory (androidfw)
+include_directories(androidfw/include)
+
+add_subdirectory (libcutils)
+include_directories(libcutils/include)
+
+include_directories(base/include)
+
+add_library(nativeruntime SHARED
+  jni/AndroidRuntime.cpp
+  jni/AndroidRuntime.h
+  jni/JNIMain.cpp
+  jni/robo_android_database_CursorWindow.cpp
+  jni/robo_android_database_SQLiteCommon.cpp
+  jni/robo_android_database_SQLiteCommon.h
+  jni/robo_android_database_SQLiteConnection.cpp
+)
+
+target_link_libraries(nativeruntime
+  log
+  utils
+  androidsqlite
+  cutils
+  androidfw
+)
+
+if (CMAKE_HOST_SYSTEM_NAME MATCHES "Linux")
+  target_link_libraries(nativeruntime
+    -static-libgcc
+    -static-libstdc++
+    -Wl,--no-undefined # print an error if there are any undefined symbols
+  )
+endif()
diff --git a/nativeruntime/cpp/androidfw/CMakeLists.txt b/nativeruntime/cpp/androidfw/CMakeLists.txt
new file mode 100644
index 0000000..87b89dd
--- /dev/null
+++ b/nativeruntime/cpp/androidfw/CMakeLists.txt
@@ -0,0 +1,11 @@
+cmake_minimum_required(VERSION 3.10)
+
+project(androidfw)
+
+include_directories(include)
+
+include_directories(../libutils/include)
+include_directories(../libcutils/include)
+include_directories(../liblog/include)
+
+add_library(androidfw STATIC CursorWindow.cpp)
diff --git a/nativeruntime/cpp/androidfw/CursorWindow.cpp b/nativeruntime/cpp/androidfw/CursorWindow.cpp
new file mode 100644
index 0000000..59b767a
--- /dev/null
+++ b/nativeruntime/cpp/androidfw/CursorWindow.cpp
@@ -0,0 +1,431 @@
+/*
+ * Copyright (C) 2006-2007 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/libs/androidfw/CursorWindow.cpp
+
+#undef LOG_TAG
+#define LOG_TAG "CursorWindow"
+
+#include <androidfw/CursorWindow.h>
+// #include <binder/Parcel.h>
+#include <assert.h>
+#include <cutils/ashmem.h>
+#include <log/log.h>
+#include <stdlib.h>
+#include <string.h>
+#if !defined(_WIN32)
+#include <sys/mman.h>
+#endif
+#include <unistd.h>
+
+namespace android {
+
+CursorWindow::CursorWindow(const String8& name, int ashmemFd, void* data,
+                           size_t size, bool readOnly)
+    : mName(name),
+      mAshmemFd(ashmemFd),
+      mData(data),
+      mSize(size),
+      mReadOnly(readOnly) {
+  #if defined(_WIN32)
+  mHeader = new Header;
+  #else
+  mHeader = static_cast<Header*>(mData);
+  #endif
+}
+
+CursorWindow::~CursorWindow() {
+  #if defined(_WIN32)
+  delete mHeader;
+  #else
+  ::munmap(mData, mSize);
+  ::close(mAshmemFd);
+  #endif
+}
+
+#if defined(_WIN32)
+status_t CursorWindow::create(const String8& name, size_t size,
+                              CursorWindow** outCursorWindow) {
+  String8 ashmemName("CursorWindow: ");
+  ashmemName.append(name);
+
+  status_t result;
+  // We don't use ashmem here, and CursorWindow constructor will use in-memory struct
+  // to support Windows.
+  CursorWindow* window = new CursorWindow(name, -1, nullptr, size, true /*readOnly*/);
+  LOG_WINDOW("Created CursorWindow from parcel: freeOffset=%d, "
+             "numRows=%d, numColumns=%d, mSize=%zu, mData=%p",
+             window->mHeader->freeOffset,
+             window->mHeader->numRows,
+             window->mHeader->numColumns,
+             window->mSize, window->mData);
+  if (window != nullptr) {
+    *outCursorWindow = window;
+    return OK;
+  }
+  *outCursorWindow = nullptr;
+  return result;
+}
+#else
+status_t CursorWindow::create(const String8& name, size_t size,
+                              CursorWindow** outCursorWindow) {
+  String8 ashmemName("CursorWindow: ");
+  ashmemName.append(name);
+
+  status_t result;
+  int ashmemFd = ashmem_create_region(ashmemName.string(), size);
+  if (ashmemFd < 0) {
+    result = -errno;
+    ALOGE("CursorWindow: ashmem_create_region() failed: errno=%d.", errno);
+  } else {
+    result = ashmem_set_prot_region(ashmemFd, PROT_READ | PROT_WRITE);
+    if (result < 0) {
+      ALOGE("CursorWindow: ashmem_set_prot_region() failed: errno=%d", errno);
+    } else {
+      void* data = ::mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED,
+                          ashmemFd, 0);
+      if (data == MAP_FAILED) {
+        result = -errno;
+        ALOGE("CursorWindow: mmap() failed: errno=%d.", errno);
+      } else {
+        result = ashmem_set_prot_region(ashmemFd, PROT_READ);
+        if (result < 0) {
+          ALOGE("CursorWindow: ashmem_set_prot_region() failed: errno=%d.",
+                errno);
+        } else {
+          CursorWindow* window =
+              new CursorWindow(name, ashmemFd, data, size, false /*readOnly*/);
+          result = window->clear();
+          if (!result) {
+            LOG_WINDOW(
+                "Created new CursorWindow: freeOffset=%d, "
+                "numRows=%d, numColumns=%d, mSize=%zu, mData=%p",
+                window->mHeader->freeOffset, window->mHeader->numRows,
+                window->mHeader->numColumns, window->mSize, window->mData);
+            *outCursorWindow = window;
+            return OK;
+          }
+          delete window;
+        }
+      }
+      ::munmap(data, size);
+    }
+    ::close(ashmemFd);
+  }
+  *outCursorWindow = nullptr;
+  return result;
+}
+#endif
+//
+// status_t CursorWindow::createFromParcel(Parcel* parcel, CursorWindow**
+// outCursorWindow) {
+//  String8 name = parcel->readString8();
+//
+//  status_t result;
+//  int actualSize;
+//  int ashmemFd = parcel->readFileDescriptor();
+//  if (ashmemFd == int(BAD_TYPE)) {
+//    result = BAD_TYPE;
+//    ALOGE("CursorWindow: readFileDescriptor() failed");
+//  } else {
+//    ssize_t size = ashmem_get_size_region(ashmemFd);
+//    if (size < 0) {
+//      result = UNKNOWN_ERROR;
+//      ALOGE("CursorWindow: ashmem_get_size_region() failed: errno=%d.",
+//      errno);
+//    } else {
+//      int dupAshmemFd = ::fcntl(ashmemFd, F_DUPFD_CLOEXEC, 0);
+//      if (dupAshmemFd < 0) {
+//        result = -errno;
+//        ALOGE("CursorWindow: fcntl() failed: errno=%d.", errno);
+//      } else {
+//        // the size of the ashmem descriptor can be modified between
+//        ashmem_get_size_region
+//        // call and mmap, so we'll check again immediately after memory is
+//        mapped void* data = ::mmap(NULL, size, PROT_READ, MAP_SHARED,
+//        dupAshmemFd, 0); if (data == MAP_FAILED) {
+//          result = -errno;
+//          ALOGE("CursorWindow: mmap() failed: errno=%d.", errno);
+//        } else if ((actualSize = ashmem_get_size_region(dupAshmemFd)) != size)
+//        {
+//          ::munmap(data, size);
+//          result = BAD_VALUE;
+//          ALOGE("CursorWindow: ashmem_get_size_region() returned %d, expected
+//          %d"
+//                " errno=%d",
+//                actualSize, (int) size, errno);
+//        } else {
+//          CursorWindow* window = new CursorWindow(name, dupAshmemFd,
+//                                                  data, size, true
+//                                                  /*readOnly*/);
+//          LOG_WINDOW("Created CursorWindow from parcel: freeOffset=%d, "
+//                     "numRows=%d, numColumns=%d, mSize=%zu, mData=%p",
+//                     window->mHeader->freeOffset,
+//                     window->mHeader->numRows,
+//                     window->mHeader->numColumns,
+//                     window->mSize, window->mData);
+//          *outCursorWindow = window;
+//          return OK;
+//        }
+//        ::close(dupAshmemFd);
+//      }
+//    }
+//  }
+//  *outCursorWindow = NULL;
+//  return result;
+//}
+//
+// status_t CursorWindow::writeToParcel(Parcel* parcel) {
+//  status_t status = parcel->writeString8(mName);
+//  if (!status) {
+//    status = parcel->writeDupFileDescriptor(mAshmemFd);
+//  }
+//  return status;
+//}
+
+status_t CursorWindow::clear() {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  mHeader->freeOffset = sizeof(Header) + sizeof(RowSlotChunk);
+  mHeader->firstChunkOffset = sizeof(Header);
+  mHeader->numRows = 0;
+  mHeader->numColumns = 0;
+
+  RowSlotChunk* firstChunk =
+      static_cast<RowSlotChunk*>(offsetToPtr(mHeader->firstChunkOffset));
+  firstChunk->nextChunkOffset = 0;
+  return OK;
+}
+
+status_t CursorWindow::setNumColumns(uint32_t numColumns) {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  uint32_t cur = mHeader->numColumns;
+  if ((cur > 0 || mHeader->numRows > 0) && cur != numColumns) {
+    ALOGE("Trying to go from %d columns to %d", cur, numColumns);
+    return INVALID_OPERATION;
+  }
+  mHeader->numColumns = numColumns;
+  return OK;
+}
+
+status_t CursorWindow::allocRow() {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  // Fill in the row slot
+  RowSlot* rowSlot = allocRowSlot();
+  if (rowSlot == nullptr) {
+    return NO_MEMORY;
+  }
+
+  // Allocate the slots for the field directory
+  size_t fieldDirSize = mHeader->numColumns * sizeof(FieldSlot);
+  uint32_t fieldDirOffset = alloc(fieldDirSize, true /*aligned*/);
+  if (!fieldDirOffset) {
+    mHeader->numRows--;
+    LOG_WINDOW(
+        "The row failed, so back out the new row accounting "
+        "from allocRowSlot %d",
+        mHeader->numRows);
+    return NO_MEMORY;
+  }
+  FieldSlot* fieldDir = static_cast<FieldSlot*>(offsetToPtr(fieldDirOffset));
+  memset(fieldDir, 0, fieldDirSize);
+
+  LOG_WINDOW(
+      "Allocated row %u, rowSlot is at offset %u, fieldDir is %zu bytes at "
+      "offset %u\n",
+      mHeader->numRows - 1, offsetFromPtr(rowSlot), fieldDirSize,
+      fieldDirOffset);
+  rowSlot->offset = fieldDirOffset;
+  return OK;
+}
+
+status_t CursorWindow::freeLastRow() {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  if (mHeader->numRows > 0) {
+    mHeader->numRows--;
+  }
+  return OK;
+}
+
+uint32_t CursorWindow::alloc(size_t size, bool aligned) {
+  uint32_t padding;
+  if (aligned) {
+    // 4 byte alignment
+    padding = (~mHeader->freeOffset + 1) & 3;
+  } else {
+    padding = 0;
+  }
+
+  uint32_t offset = mHeader->freeOffset + padding;
+  uint32_t nextFreeOffset = offset + size;
+  if (nextFreeOffset > mSize) {
+    //    ALOGW("Window is full: requested allocation %zu bytes, "
+    //          "free space %zu bytes, window size %zu bytes",
+    //          size, freeSpace(), mSize);
+    return 0;
+  }
+
+  mHeader->freeOffset = nextFreeOffset;
+  return offset;
+}
+
+CursorWindow::RowSlot* CursorWindow::getRowSlot(uint32_t row) {
+  uint32_t chunkPos = row;
+  RowSlotChunk* chunk =
+      static_cast<RowSlotChunk*>(offsetToPtr(mHeader->firstChunkOffset));
+  while (chunkPos >= ROW_SLOT_CHUNK_NUM_ROWS) {
+    chunk = static_cast<RowSlotChunk*>(offsetToPtr(chunk->nextChunkOffset));
+    chunkPos -= ROW_SLOT_CHUNK_NUM_ROWS;
+  }
+  return &chunk->slots[chunkPos];
+}
+
+CursorWindow::RowSlot* CursorWindow::allocRowSlot() {
+  uint32_t chunkPos = mHeader->numRows;
+  RowSlotChunk* chunk =
+      static_cast<RowSlotChunk*>(offsetToPtr(mHeader->firstChunkOffset));
+  while (chunkPos > ROW_SLOT_CHUNK_NUM_ROWS) {
+    chunk = static_cast<RowSlotChunk*>(offsetToPtr(chunk->nextChunkOffset));
+    chunkPos -= ROW_SLOT_CHUNK_NUM_ROWS;
+  }
+  if (chunkPos == ROW_SLOT_CHUNK_NUM_ROWS) {
+    if (!chunk->nextChunkOffset) {
+      chunk->nextChunkOffset = alloc(sizeof(RowSlotChunk), true /*aligned*/);
+      if (!chunk->nextChunkOffset) {
+        return nullptr;
+      }
+    }
+    chunk = static_cast<RowSlotChunk*>(offsetToPtr(chunk->nextChunkOffset));
+    chunk->nextChunkOffset = 0;
+    chunkPos = 0;
+  }
+  mHeader->numRows += 1;
+  return &chunk->slots[chunkPos];
+}
+
+CursorWindow::FieldSlot* CursorWindow::getFieldSlot(uint32_t row,
+                                                    uint32_t column) {
+  if (row >= mHeader->numRows || column >= mHeader->numColumns) {
+    ALOGE(
+        "Failed to read row %d, column %d from a CursorWindow which "
+        "has %d rows, %d columns.",
+        row, column, mHeader->numRows, mHeader->numColumns);
+    return nullptr;
+  }
+  RowSlot* rowSlot = getRowSlot(row);
+  if (!rowSlot) {
+    ALOGE("Failed to find rowSlot for row %d.", row);
+    return nullptr;
+  }
+  FieldSlot* fieldDir = static_cast<FieldSlot*>(offsetToPtr(rowSlot->offset));
+  return &fieldDir[column];
+}
+
+status_t CursorWindow::putBlob(uint32_t row, uint32_t column, const void* value,
+                               size_t size) {
+  return putBlobOrString(row, column, value, size, FIELD_TYPE_BLOB);
+}
+
+status_t CursorWindow::putString(uint32_t row, uint32_t column,
+                                 const char* value, size_t sizeIncludingNull) {
+  return putBlobOrString(row, column, value, sizeIncludingNull,
+                         FIELD_TYPE_STRING);
+}
+
+status_t CursorWindow::putBlobOrString(uint32_t row, uint32_t column,
+                                       const void* value, size_t size,
+                                       int32_t type) {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  FieldSlot* fieldSlot = getFieldSlot(row, column);
+  if (!fieldSlot) {
+    return BAD_VALUE;
+  }
+
+  uint32_t offset = alloc(size);
+  if (!offset) {
+    return NO_MEMORY;
+  }
+
+  memcpy(offsetToPtr(offset), value, size);
+
+  fieldSlot->type = type;
+  fieldSlot->data.buffer.offset = offset;
+  fieldSlot->data.buffer.size = size;
+  return OK;
+}
+
+status_t CursorWindow::putLong(uint32_t row, uint32_t column, int64_t value) {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  FieldSlot* fieldSlot = getFieldSlot(row, column);
+  if (!fieldSlot) {
+    return BAD_VALUE;
+  }
+
+  fieldSlot->type = FIELD_TYPE_INTEGER;
+  fieldSlot->data.l = value;
+  return OK;
+}
+
+status_t CursorWindow::putDouble(uint32_t row, uint32_t column, double value) {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  FieldSlot* fieldSlot = getFieldSlot(row, column);
+  if (!fieldSlot) {
+    return BAD_VALUE;
+  }
+
+  fieldSlot->type = FIELD_TYPE_FLOAT;
+  fieldSlot->data.d = value;
+  return OK;
+}
+
+status_t CursorWindow::putNull(uint32_t row, uint32_t column) {
+  if (mReadOnly) {
+    return INVALID_OPERATION;
+  }
+
+  FieldSlot* fieldSlot = getFieldSlot(row, column);
+  if (!fieldSlot) {
+    return BAD_VALUE;
+  }
+
+  fieldSlot->type = FIELD_TYPE_NULL;
+  fieldSlot->data.buffer.offset = 0;
+  fieldSlot->data.buffer.size = 0;
+  return OK;
+}
+
+};  // namespace android
diff --git a/nativeruntime/cpp/androidfw/include/androidfw/CursorWindow.h b/nativeruntime/cpp/androidfw/include/androidfw/CursorWindow.h
new file mode 100644
index 0000000..8975c64
--- /dev/null
+++ b/nativeruntime/cpp/androidfw/include/androidfw/CursorWindow.h
@@ -0,0 +1,215 @@
+/*
+ * Copyright (C) 2006 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/libs/androidfw/include/androidfw/CursorWindow.h
+
+#ifndef _ANDROID__DATABASE_WINDOW_H
+#define _ANDROID__DATABASE_WINDOW_H
+
+#include <inttypes.h>
+#include <stddef.h>
+#include <stdint.h>
+
+// #include <binder/Parcel.h>
+#include <log/log.h>
+#include <utils/String8.h>
+
+#if LOG_NDEBUG
+
+#define IF_LOG_WINDOW() if (false)
+#define LOG_WINDOW(...)
+
+#else
+
+#define IF_LOG_WINDOW() IF_ALOG(LOG_DEBUG, "CursorWindow")
+#define LOG_WINDOW(...) ALOG(LOG_DEBUG, "CursorWindow", __VA_ARGS__)
+
+#endif
+
+namespace android {
+
+/**
+ * This class stores a set of rows from a database in a buffer. The beginning of
+ * the window has first chunk of RowSlots, which are offsets to the row
+ * directory, followed by an offset to the next chunk in a linked-list of
+ * additional chunk of RowSlots in case the pre-allocated chunk isn't big enough
+ * to refer to all rows. Each row directory has a FieldSlot per column, which
+ * has the size, offset, and type of the data for that field. Note that the data
+ * types come from sqlite3.h.
+ *
+ * Strings are stored in UTF-8.
+ */
+class CursorWindow {
+  CursorWindow(const String8& name, int ashmemFd, void* data, size_t size,
+               bool readOnly);
+
+ public:
+  /* Field types. */
+  enum {
+    FIELD_TYPE_NULL = 0,
+    FIELD_TYPE_INTEGER = 1,
+    FIELD_TYPE_FLOAT = 2,
+    FIELD_TYPE_STRING = 3,
+    FIELD_TYPE_BLOB = 4,
+  };
+
+  /* Opaque type that describes a field slot. */
+  struct FieldSlot {
+   private:
+    int32_t type;
+    union {
+      double d;
+      int64_t l;
+      struct {
+        uint32_t offset;
+        uint32_t size;
+      } buffer;
+    } data;
+
+    friend class CursorWindow;
+  } __attribute((packed));
+
+  ~CursorWindow();
+
+  static status_t create(const String8& name, size_t size,
+                         CursorWindow** outCursorWindow);
+  //  static status_t createFromParcel(Parcel* parcel, CursorWindow**
+  //  outCursorWindow);
+  //
+  //  status_t writeToParcel(Parcel* parcel);
+
+  inline String8 name() { return mName; }
+  inline size_t size() { return mSize; }
+  inline size_t freeSpace() { return mSize - mHeader->freeOffset; }
+  inline uint32_t getNumRows() { return mHeader->numRows; }
+  inline uint32_t getNumColumns() { return mHeader->numColumns; }
+
+  status_t clear();
+  status_t setNumColumns(uint32_t numColumns);
+
+  /**
+   * Allocate a row slot and its directory.
+   * The row is initialized will null entries for each field.
+   */
+  status_t allocRow();
+  status_t freeLastRow();
+
+  status_t putBlob(uint32_t row, uint32_t column, const void* value,
+                   size_t size);
+  status_t putString(uint32_t row, uint32_t column, const char* value,
+                     size_t sizeIncludingNull);
+  status_t putLong(uint32_t row, uint32_t column, int64_t value);
+  status_t putDouble(uint32_t row, uint32_t column, double value);
+  status_t putNull(uint32_t row, uint32_t column);
+
+  /**
+   * Gets the field slot at the specified row and column.
+   * Returns null if the requested row or column is not in the window.
+   */
+  FieldSlot* getFieldSlot(uint32_t row, uint32_t column);
+
+  inline int32_t getFieldSlotType(FieldSlot* fieldSlot) {
+    return fieldSlot->type;
+  }
+
+  inline int64_t getFieldSlotValueLong(FieldSlot* fieldSlot) {
+    return fieldSlot->data.l;
+  }
+
+  inline double getFieldSlotValueDouble(FieldSlot* fieldSlot) {
+    return fieldSlot->data.d;
+  }
+
+  inline const char* getFieldSlotValueString(FieldSlot* fieldSlot,
+                                             size_t* outSizeIncludingNull) {
+    *outSizeIncludingNull = fieldSlot->data.buffer.size;
+    return static_cast<char*>(offsetToPtr(fieldSlot->data.buffer.offset,
+                                          fieldSlot->data.buffer.size));
+  }
+
+  inline const void* getFieldSlotValueBlob(FieldSlot* fieldSlot,
+                                           size_t* outSize) {
+    *outSize = fieldSlot->data.buffer.size;
+    return offsetToPtr(fieldSlot->data.buffer.offset,
+                       fieldSlot->data.buffer.size);
+  }
+
+ private:
+  static const size_t ROW_SLOT_CHUNK_NUM_ROWS = 100;
+
+  struct Header {
+    // Offset of the lowest unused byte in the window.
+    uint32_t freeOffset;
+
+    // Offset of the first row slot chunk.
+    uint32_t firstChunkOffset;
+
+    uint32_t numRows;
+    uint32_t numColumns;
+  };
+
+  struct RowSlot {
+    uint32_t offset;
+  };
+
+  struct RowSlotChunk {
+    RowSlot slots[ROW_SLOT_CHUNK_NUM_ROWS];
+    uint32_t nextChunkOffset;
+  };
+
+  String8 mName;
+  int mAshmemFd;
+  void* mData;
+  size_t mSize;
+  bool mReadOnly;
+  Header* mHeader;
+
+  inline void* offsetToPtr(uint32_t offset, uint32_t bufferSize = 0) {
+    if (offset >= mSize) {
+      //      ALOGE("Offset %" PRIu32 " out of bounds, max value %zu", offset,
+      //      mSize);
+      return nullptr;
+    }
+    if (offset + bufferSize > mSize) {
+      //      ALOGE("End offset %" PRIu32 " out of bounds, max value %zu",
+      //            offset + bufferSize, mSize);
+      return nullptr;
+    }
+    return static_cast<uint8_t*>(mData) + offset;
+  }
+
+  inline uint32_t offsetFromPtr(void* ptr) {
+    return static_cast<uint8_t*>(ptr) - static_cast<uint8_t*>(mData);
+  }
+
+  /**
+   * Allocate a portion of the window. Returns the offset
+   * of the allocation, or 0 if there isn't enough space.
+   * If aligned is true, the allocation gets 4 byte alignment.
+   */
+  uint32_t alloc(size_t size, bool aligned = false);
+
+  RowSlot* getRowSlot(uint32_t row);
+  RowSlot* allocRowSlot();
+
+  status_t putBlobOrString(uint32_t row, uint32_t column, const void* value,
+                           size_t size, int32_t type);
+};
+
+};  // namespace android
+
+#endif
diff --git a/nativeruntime/cpp/base/include/android-base/macros.h b/nativeruntime/cpp/base/include/android-base/macros.h
new file mode 100644
index 0000000..5a4a13a
--- /dev/null
+++ b/nativeruntime/cpp/base/include/android-base/macros.h
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2015 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/base/include/android-base/macros.h
+
+#ifndef UTILS_MACROS_H
+#define UTILS_MACROS_H
+
+#include <stddef.h>  // for size_t
+#include <unistd.h>  // for TEMP_FAILURE_RETRY
+
+#include <utility>
+
+// bionic and glibc both have TEMP_FAILURE_RETRY, but eg Mac OS' libc doesn't.
+#ifndef TEMP_FAILURE_RETRY
+#define TEMP_FAILURE_RETRY(exp)            \
+  ({                                       \
+    decltype(exp) _rc;                     \
+    do {                                   \
+      _rc = (exp);                         \
+    } while (_rc == -1 && errno == EINTR); \
+    _rc;                                   \
+  })
+#endif
+
+// A macro to disallow the copy constructor and operator= functions
+// This must be placed in the private: declarations for a class.
+//
+// For disallowing only assign or copy, delete the relevant operator or
+// constructor, for example:
+// void operator=(const TypeName&) = delete;
+// Note, that most uses of DISALLOW_ASSIGN and DISALLOW_COPY are broken
+// semantically, one should either use disallow both or neither. Try to
+// avoid these in new code.
+#define DISALLOW_COPY_AND_ASSIGN(TypeName) \
+  TypeName(const TypeName&) = delete;      \
+  void operator=(const TypeName&) = delete
+
+// A macro to disallow all the implicit constructors, namely the
+// default constructor, copy constructor and operator= functions.
+//
+// This should be used in the private: declarations for a class
+// that wants to prevent anyone from instantiating it. This is
+// especially useful for classes containing only static methods.
+#define DISALLOW_IMPLICIT_CONSTRUCTORS(TypeName) \
+  TypeName() = delete;                           \
+  DISALLOW_COPY_AND_ASSIGN(TypeName)
+
+// The arraysize(arr) macro returns the # of elements in an array arr.
+// The expression is a compile-time constant, and therefore can be
+// used in defining new arrays, for example.  If you use arraysize on
+// a pointer by mistake, you will get a compile-time error.
+//
+// One caveat is that arraysize() doesn't accept any array of an
+// anonymous type or a type defined inside a function.  In these rare
+// cases, you have to use the unsafe ARRAYSIZE_UNSAFE() macro below.  This is
+// due to a limitation in C++'s template system.  The limitation might
+// eventually be removed, but it hasn't happened yet.
+
+// This template function declaration is used in defining arraysize.
+// Note that the function doesn't need an implementation, as we only
+// use its type.
+template <typename T, size_t N>
+char (&ArraySizeHelper(T (&array)[N]))[N];  // NOLINT(readability/casting)
+
+#define arraysize(array) (sizeof(ArraySizeHelper(array)))
+
+#define SIZEOF_MEMBER(t, f) sizeof(std::declval<t>().f)
+
+// Changing this definition will cause you a lot of pain.  A majority of
+// vendor code defines LIKELY and UNLIKELY this way, and includes
+// this header through an indirect path.
+#define LIKELY(exp) (__builtin_expect((exp) != 0, true))
+#define UNLIKELY(exp) (__builtin_expect((exp) != 0, false))
+
+#define WARN_UNUSED __attribute__((warn_unused_result))
+
+// A deprecated function to call to create a false use of the parameter, for
+// example:
+//   int foo(int x) { UNUSED(x); return 10; }
+// to avoid compiler warnings. Going forward we prefer ATTRIBUTE_UNUSED.
+template <typename... T>
+void UNUSED(const T&...) {}
+
+// An attribute to place on a parameter to a function, for example:
+//   int foo(int x ATTRIBUTE_UNUSED) { return 10; }
+// to avoid compiler warnings.
+#define ATTRIBUTE_UNUSED __attribute__((__unused__))
+
+// The FALLTHROUGH_INTENDED macro can be used to annotate implicit fall-through
+// between switch labels:
+//  switch (x) {
+//    case 40:
+//    case 41:
+//      if (truth_is_out_there) {
+//        ++x;
+//        FALLTHROUGH_INTENDED;  // Use instead of/along with annotations in
+//                               // comments.
+//      } else {
+//        return x;
+//      }
+//    case 42:
+//      ...
+//
+// As shown in the example above, the FALLTHROUGH_INTENDED macro should be
+// followed by a semicolon. It is designed to mimic control-flow statements
+// like 'break;', so it can be placed in most places where 'break;' can, but
+// only if there are no statements on the execution path between it and the
+// next switch label.
+//
+// When compiled with clang, the FALLTHROUGH_INTENDED macro is expanded to
+// [[clang::fallthrough]] attribute, which is analysed when performing switch
+// labels fall-through diagnostic ('-Wimplicit-fallthrough'). See clang
+// documentation on language extensions for details:
+// http://clang.llvm.org/docs/LanguageExtensions.html#clang__fallthrough
+//
+// When used with unsupported compilers, the FALLTHROUGH_INTENDED macro has no
+// effect on diagnostics.
+//
+// In either case this macro has no effect on runtime behavior and performance
+// of code.
+#ifndef FALLTHROUGH_INTENDED
+#define FALLTHROUGH_INTENDED [[clang::fallthrough]]  // NOLINT
+#endif
+
+// Current ABI string
+#if defined(__arm__)
+#define ABI_STRING "arm"
+#elif defined(__aarch64__)
+#define ABI_STRING "arm64"
+#elif defined(__i386__)
+#define ABI_STRING "x86"
+#elif defined(__x86_64__)
+#define ABI_STRING "x86_64"
+#elif defined(__mips__) && !defined(__LP64__)
+#define ABI_STRING "mips"
+#elif defined(__mips__) && defined(__LP64__)
+#define ABI_STRING "mips64"
+#endif
+
+#endif  // UTILS_MACROS_H
diff --git a/nativeruntime/cpp/jni/AndroidRuntime.cpp b/nativeruntime/cpp/jni/AndroidRuntime.cpp
new file mode 100644
index 0000000..e22381c
--- /dev/null
+++ b/nativeruntime/cpp/jni/AndroidRuntime.cpp
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/jni/AndroidRuntime.cpp
+
+#include "AndroidRuntime.h"
+
+#include <assert.h>
+
+#include "jni.h"
+
+using namespace android;
+
+/*static*/ JavaVM* AndroidRuntime::mJavaVM = nullptr;
+
+/*static*/ JavaVM* AndroidRuntime::getJavaVM() {
+  return AndroidRuntime::mJavaVM;
+}
+
+/*
+ * Get the JNIEnv pointer for this thread.
+ *
+ * Returns NULL if the slot wasn't allocated or populated.
+ */
+/*static*/ JNIEnv* AndroidRuntime::getJNIEnv() {
+  JNIEnv* env;
+  JavaVM* vm = AndroidRuntime::getJavaVM();
+  assert(vm != nullptr);
+
+  if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_4) != JNI_OK)
+    return nullptr;
+  return env;
+}
diff --git a/nativeruntime/cpp/jni/AndroidRuntime.h b/nativeruntime/cpp/jni/AndroidRuntime.h
new file mode 100644
index 0000000..5e1d47e
--- /dev/null
+++ b/nativeruntime/cpp/jni/AndroidRuntime.h
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/jni/include/android_runtime/AndroidRuntime.h
+
+#ifndef _RUNTIME_ANDROID_RUNTIME_H
+#define _RUNTIME_ANDROID_RUNTIME_H
+
+#include <jni.h>
+
+namespace android {
+
+class AndroidRuntime {
+ public:
+  /** return a pointer to the VM running in this process */
+  static JavaVM* getJavaVM();
+
+  /** return a pointer to the JNIEnv pointer for this thread */
+  static JNIEnv* getJNIEnv();
+
+ private:
+  /* JNI JavaVM pointer */
+  static JavaVM* mJavaVM;
+};
+}  // namespace android
+
+#endif
diff --git a/nativeruntime/cpp/jni/JNIMain.cpp b/nativeruntime/cpp/jni/JNIMain.cpp
new file mode 100644
index 0000000..166e894
--- /dev/null
+++ b/nativeruntime/cpp/jni/JNIMain.cpp
@@ -0,0 +1,62 @@
+#include <jni.h>
+#include <log/log.h>
+
+#include "unicode/locid.h"
+
+namespace android {
+
+extern int register_android_database_CursorWindow(JNIEnv* env);
+extern int register_android_database_SQLiteConnection(JNIEnv* env);
+
+}  // namespace android
+
+/*
+ * JNI Initialization
+ */
+jint JNI_OnLoad(JavaVM* jvm, void* reserved) {
+  JNIEnv* env;
+
+  ALOGV("loading JNI\n");
+  // Check JNI version
+  if (jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_4)) {
+    ALOGE("JNI version mismatch error");
+    return JNI_ERR;
+  }
+
+  if (android::register_android_database_CursorWindow(env) != JNI_VERSION_1_4 ||
+      android::register_android_database_SQLiteConnection(env) !=
+          JNI_VERSION_1_4) {
+    ALOGE("Failure during registration");
+    return JNI_ERR;
+  }
+
+  // Configuration is stored as java System properties.
+  // Get a reference to System.getProperty
+  jclass systemClass = env->FindClass("java/lang/System");
+  jmethodID getPropertyMethod = env->GetStaticMethodID(
+      systemClass, "getProperty",
+      "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;");
+
+  // Set the default locale, which is required for e.g. SQLite's 'COLLATE
+  // UNICODE'.
+  auto stringLanguageTag = (jstring)env->CallStaticObjectMethod(
+      systemClass, getPropertyMethod,
+      env->NewStringUTF("robolectric.nativeruntime.languageTag"),
+      env->NewStringUTF(""));
+  const char* languageTag = env->GetStringUTFChars(stringLanguageTag, 0);
+  int languageTagLength = env->GetStringLength(stringLanguageTag);
+  if (languageTagLength > 0) {
+    UErrorCode status = U_ZERO_ERROR;
+    icu::Locale locale = icu::Locale::forLanguageTag(languageTag, status);
+    if (U_SUCCESS(status)) {
+      icu::Locale::setDefault(locale, status);
+    }
+    if (U_FAILURE(status)) {
+      fprintf(stderr,
+              "Failed to set the ICU default locale to '%s' (error code %d)\n",
+              languageTag, status);
+    }
+  }
+  env->ReleaseStringUTFChars(stringLanguageTag, languageTag);
+  return JNI_VERSION_1_4;
+}
diff --git a/nativeruntime/cpp/jni/robo_android_database_CursorWindow.cpp b/nativeruntime/cpp/jni/robo_android_database_CursorWindow.cpp
new file mode 100644
index 0000000..9481b8e
--- /dev/null
+++ b/nativeruntime/cpp/jni/robo_android_database_CursorWindow.cpp
@@ -0,0 +1,628 @@
+/*
+ * Copyright (C) 2007 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/jni/android_database_CursorWindow.cpp
+
+#undef LOG_TAG
+#define LOG_TAG "CursorWindow"
+#define LOG_NDEBUG 0
+
+#include <dirent.h>
+#include <inttypes.h>
+#include <jni.h>
+#include <log/log.h>
+#include <nativehelper/JNIHelp.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/types.h>
+#include <unistd.h>
+#include <utils/String16.h>
+#include <utils/String8.h>
+#include <utils/Unicode.h>
+
+#undef LOG_NDEBUG
+#define LOG_NDEBUG 1
+
+#include <androidfw/CursorWindow.h>
+// #include "android_os_Parcel.h"
+// #include "android_util_Binder.h"
+#include <nativehelper/scoped_local_ref.h>
+
+#include "robo_android_database_SQLiteCommon.h"
+
+namespace android {
+
+// static struct {
+//   jfieldID data;
+//   jfieldID sizeCopied;
+// } gCharArrayBufferClassInfo;
+
+static jfieldID getCharArrayBufferDataFieldId(JNIEnv* env, jobject obj) {
+  jclass clsObj = env->GetObjectClass(obj);
+  if (clsObj == nullptr) {
+    printf("cls obj is null");
+  }
+  return env->GetFieldID(clsObj, "data", "[C");
+}
+
+static jfieldID getCharArrayBufferSizeCopiedFieldId(JNIEnv* env, jobject obj) {
+  jclass clsObj = env->GetObjectClass(obj);
+  if (clsObj == nullptr) {
+    printf("cls obj is null");
+  }
+  return env->GetFieldID(clsObj, "sizeCopied", "I");
+}
+
+static jstring gEmptyString;
+
+static void throwExceptionWithRowCol(JNIEnv* env, jint row, jint column) {
+  String8 msg;
+  msg.appendFormat(
+      "Couldn't read row %d, col %d from CursorWindow.  "
+      "Make sure the Cursor is initialized correctly before accessing data "
+      "from it.",
+      row, column);
+  jniThrowException(env, "java/lang/IllegalStateException", msg.string());
+}
+
+static void throwUnknownTypeException(JNIEnv* env, jint type) {
+  String8 msg;
+  msg.appendFormat("UNKNOWN type %d", type);
+  jniThrowException(env, "java/lang/IllegalStateException", msg.string());
+}
+
+// static int getFdCount() {
+//   char fdpath[PATH_MAX];
+//   int count = 0;
+//   snprintf(fdpath, PATH_MAX, "/proc/%d/fd", getpid());
+//   DIR* dir = opendir(fdpath);
+//   if (dir != NULL) {
+//     struct dirent* dirent;
+//     while ((dirent = readdir(dir))) {
+//       count++;
+//     }
+//     count -= 2;  // discount "." and ".."
+//     closedir(dir);
+//   }
+//   return count;
+// }
+
+static jlong nativeCreate(JNIEnv* env, jclass clazz, jstring nameObj,
+                          jint cursorWindowSize) {
+  String8 name;
+  const char* nameStr = env->GetStringUTFChars(nameObj, nullptr);
+  name.setTo(nameStr);
+  env->ReleaseStringUTFChars(nameObj, nameStr);
+
+  CursorWindow* window;
+  status_t status = CursorWindow::create(name, cursorWindowSize, &window);
+  if (status || !window) {
+    jniThrowExceptionFmt(
+        env, "android/database/CursorWindowAllocationException",
+        "Could not allocate CursorWindow '%s' of size %d due to error %d.",
+        name.string(), cursorWindowSize, status);
+    return 0;
+  }
+
+  LOG_WINDOW("nativeInitializeEmpty: window = %p", window);
+  return reinterpret_cast<jlong>(window);
+}
+
+// static jlong nativeCreateFromParcel(JNIEnv* env, jclass clazz, jobject
+// parcelObj) {
+//   Parcel* parcel = parcelForJavaObject(env, parcelObj);
+//
+//   CursorWindow* window;
+//   status_t status = CursorWindow::createFromParcel(parcel, &window);
+//   if (status || !window) {
+//     jniThrowExceptionFmt(env,
+//                          "android/database/CursorWindowAllocationException",
+//                          "Could not create CursorWindow from Parcel due to
+//                          error %d, process fd count=%d", status,
+//                          getFdCount());
+//     return 0;
+//   }
+//
+//   LOG_WINDOW("nativeInitializeFromBinder: numRows = %d, numColumns = %d,
+//   window = %p",
+//              window->getNumRows(), window->getNumColumns(), window);
+//   return reinterpret_cast<jlong>(window);
+// }
+
+static void nativeDispose(JNIEnv* env, jclass clazz, jlong windowPtr) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  if (window) {
+    LOG_WINDOW("Closing window %p", window);
+    delete window;
+  }
+}
+
+static jstring nativeGetName(JNIEnv* env, jclass clazz, jlong windowPtr) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  return env->NewStringUTF(window->name().string());
+}
+//
+// static void nativeWriteToParcel(JNIEnv * env, jclass clazz, jlong windowPtr,
+//                                jobject parcelObj) {
+//  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+//  Parcel* parcel = parcelForJavaObject(env, parcelObj);
+//
+//  status_t status = window->writeToParcel(parcel);
+//  if (status) {
+//    String8 msg;
+//    msg.appendFormat("Could not write CursorWindow to Parcel due to error
+//    %d.", status); jniThrowRuntimeException(env, msg.string());
+//  }
+//}
+
+static void nativeClear(JNIEnv* env, jclass clazz, jlong windowPtr) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("Clearing window %p", window);
+  status_t status = window->clear();
+  if (status) {
+    LOG_WINDOW("Could not clear window. error=%d", status);
+  }
+}
+
+static jint nativeGetNumRows(JNIEnv* env, jclass clazz, jlong windowPtr) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  return window->getNumRows();
+}
+
+static jboolean nativeSetNumColumns(JNIEnv* env, jclass clazz, jlong windowPtr,
+                                    jint columnNum) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  status_t status = window->setNumColumns(columnNum);
+  return status == OK;
+}
+
+static jboolean nativeAllocRow(JNIEnv* env, jclass clazz, jlong windowPtr) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  status_t status = window->allocRow();
+  return status == OK;
+}
+
+static void nativeFreeLastRow(JNIEnv* env, jclass clazz, jlong windowPtr) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  window->freeLastRow();
+}
+
+static jint nativeGetType(JNIEnv* env, jclass clazz, jlong windowPtr, jint row,
+                          jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("returning column type affinity for %d,%d from %p", row, column,
+             window);
+
+  CursorWindow::FieldSlot* fieldSlot = window->getFieldSlot(row, column);
+  if (!fieldSlot) {
+    // FIXME: This is really broken but we have CTS tests that depend
+    // on this legacy behavior.
+    // throwExceptionWithRowCol(env, row, column);
+    return CursorWindow::FIELD_TYPE_NULL;
+  }
+  return window->getFieldSlotType(fieldSlot);
+}
+
+static jbyteArray nativeGetBlob(JNIEnv* env, jclass clazz, jlong windowPtr,
+                                jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("Getting blob for %d,%d from %p", row, column, window);
+
+  CursorWindow::FieldSlot* fieldSlot = window->getFieldSlot(row, column);
+  if (!fieldSlot) {
+    throwExceptionWithRowCol(env, row, column);
+    return nullptr;
+  }
+
+  int32_t type = window->getFieldSlotType(fieldSlot);
+  if (type == CursorWindow::FIELD_TYPE_BLOB ||
+      type == CursorWindow::FIELD_TYPE_STRING) {
+    size_t size;
+    const void* value = window->getFieldSlotValueBlob(fieldSlot, &size);
+    if (!value) {
+      throw_sqlite3_exception(env, "Native could not read blob slot");
+      return nullptr;
+    }
+    jbyteArray byteArray = env->NewByteArray(size);
+    if (!byteArray) {
+      env->ExceptionClear();
+      throw_sqlite3_exception(env, "Native could not create new byte[]");
+      return nullptr;
+    }
+    env->SetByteArrayRegion(byteArray, 0, size,
+                            static_cast<const jbyte*>(value));
+    return byteArray;
+  } else if (type == CursorWindow::FIELD_TYPE_INTEGER) {
+    throw_sqlite3_exception(env, "INTEGER data in nativeGetBlob ");
+  } else if (type == CursorWindow::FIELD_TYPE_FLOAT) {
+    throw_sqlite3_exception(env, "FLOAT data in nativeGetBlob ");
+  } else if (type == CursorWindow::FIELD_TYPE_NULL) {
+    // do nothing
+  } else {
+    throwUnknownTypeException(env, type);
+  }
+  return nullptr;
+}
+
+static jstring nativeGetString(JNIEnv* env, jclass clazz, jlong windowPtr,
+                               jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("Getting string for %d,%d from %p", row, column, window);
+
+  CursorWindow::FieldSlot* fieldSlot = window->getFieldSlot(row, column);
+  if (!fieldSlot) {
+    throwExceptionWithRowCol(env, row, column);
+    return nullptr;
+  }
+
+  int32_t type = window->getFieldSlotType(fieldSlot);
+  if (type == CursorWindow::FIELD_TYPE_STRING) {
+    size_t sizeIncludingNull;
+    const char* value =
+        window->getFieldSlotValueString(fieldSlot, &sizeIncludingNull);
+    if (!value) {
+      throw_sqlite3_exception(env, "Native could not read string slot");
+      return nullptr;
+    }
+    if (sizeIncludingNull <= 1) {
+      return gEmptyString;
+    }
+    // Convert to UTF-16 here instead of calling NewStringUTF.  NewStringUTF
+    // doesn't like UTF-8 strings with high codepoints.  It actually expects
+    // Modified UTF-8 with encoded surrogate pairs.
+    String16 utf16(value, sizeIncludingNull - 1);
+    return env->NewString(reinterpret_cast<const jchar*>(utf16.string()),
+                          utf16.size());
+  } else if (type == CursorWindow::FIELD_TYPE_INTEGER) {
+    int64_t value = window->getFieldSlotValueLong(fieldSlot);
+    char buf[32];
+    snprintf(buf, sizeof(buf), "%" PRId64, value);
+    return env->NewStringUTF(buf);
+  } else if (type == CursorWindow::FIELD_TYPE_FLOAT) {
+    double value = window->getFieldSlotValueDouble(fieldSlot);
+    char buf[32];
+    snprintf(buf, sizeof(buf), "%g", value);
+    return env->NewStringUTF(buf);
+  } else if (type == CursorWindow::FIELD_TYPE_NULL) {
+    return nullptr;
+  } else if (type == CursorWindow::FIELD_TYPE_BLOB) {
+    throw_sqlite3_exception(env, "Unable to convert BLOB to string");
+    return nullptr;
+  } else {
+    throwUnknownTypeException(env, type);
+    return nullptr;
+  }
+}
+
+static jcharArray allocCharArrayBuffer(JNIEnv* env, jobject bufferObj,
+                                       size_t size) {
+  jcharArray dataObj = jcharArray(env->GetObjectField(
+      bufferObj, getCharArrayBufferDataFieldId(env, bufferObj)));
+  if (dataObj && size) {
+    jsize capacity = env->GetArrayLength(dataObj);
+    if (size_t(capacity) < size) {
+      env->DeleteLocalRef(dataObj);
+      dataObj = nullptr;
+    }
+  }
+  if (!dataObj) {
+    jsize capacity = size;
+    if (capacity < 64) {
+      capacity = 64;
+    }
+    dataObj = env->NewCharArray(capacity);  // might throw OOM
+    if (dataObj) {
+      env->SetObjectField(
+          bufferObj, getCharArrayBufferDataFieldId(env, bufferObj), dataObj);
+    }
+  }
+  return dataObj;
+}
+
+static void fillCharArrayBufferUTF(JNIEnv* env, jobject bufferObj,
+                                   const char* str, size_t len) {
+  ssize_t size =
+      utf8_to_utf16_length(reinterpret_cast<const uint8_t*>(str), len);
+  if (size < 0) {
+    size = 0;  // invalid UTF8 string
+  }
+  jcharArray dataObj = allocCharArrayBuffer(env, bufferObj, size);
+  if (dataObj) {
+    if (size) {
+      jchar* data =
+          static_cast<jchar*>(env->GetPrimitiveArrayCritical(dataObj, nullptr));
+      utf8_to_utf16_no_null_terminator(reinterpret_cast<const uint8_t*>(str),
+                                       len, reinterpret_cast<char16_t*>(data),
+                                       static_cast<size_t>(size));
+      env->ReleasePrimitiveArrayCritical(dataObj, data, 0);
+    }
+    env->SetIntField(bufferObj,
+                     getCharArrayBufferSizeCopiedFieldId(env, bufferObj), size);
+  }
+}
+
+static void clearCharArrayBuffer(JNIEnv* env, jobject bufferObj) {
+  jcharArray dataObj = allocCharArrayBuffer(env, bufferObj, 0);
+  if (dataObj) {
+    env->SetIntField(bufferObj,
+                     getCharArrayBufferSizeCopiedFieldId(env, bufferObj), 0);
+  }
+}
+
+static void nativeCopyStringToBuffer(JNIEnv* env, jclass clazz, jlong windowPtr,
+                                     jint row, jint column, jobject bufferObj) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("Copying string for %d,%d from %p", row, column, window);
+
+  CursorWindow::FieldSlot* fieldSlot = window->getFieldSlot(row, column);
+  if (!fieldSlot) {
+    throwExceptionWithRowCol(env, row, column);
+    return;
+  }
+
+  int32_t type = window->getFieldSlotType(fieldSlot);
+  if (type == CursorWindow::FIELD_TYPE_STRING) {
+    size_t sizeIncludingNull;
+    const char* value =
+        window->getFieldSlotValueString(fieldSlot, &sizeIncludingNull);
+    if (sizeIncludingNull > 1) {
+      fillCharArrayBufferUTF(env, bufferObj, value, sizeIncludingNull - 1);
+    } else {
+      clearCharArrayBuffer(env, bufferObj);
+    }
+  } else if (type == CursorWindow::FIELD_TYPE_INTEGER) {
+    int64_t value = window->getFieldSlotValueLong(fieldSlot);
+    char buf[32];
+    snprintf(buf, sizeof(buf), "%" PRId64, value);
+    fillCharArrayBufferUTF(env, bufferObj, buf, strlen(buf));
+  } else if (type == CursorWindow::FIELD_TYPE_FLOAT) {
+    double value = window->getFieldSlotValueDouble(fieldSlot);
+    char buf[32];
+    snprintf(buf, sizeof(buf), "%g", value);
+    fillCharArrayBufferUTF(env, bufferObj, buf, strlen(buf));
+  } else if (type == CursorWindow::FIELD_TYPE_NULL) {
+    clearCharArrayBuffer(env, bufferObj);
+  } else if (type == CursorWindow::FIELD_TYPE_BLOB) {
+    throw_sqlite3_exception(env, "Unable to convert BLOB to string");
+  } else {
+    throwUnknownTypeException(env, type);
+  }
+}
+
+static jlong nativeGetLong(JNIEnv* env, jclass clazz, jlong windowPtr, jint row,
+                           jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("Getting long for %d,%d from %p", row, column, window);
+
+  CursorWindow::FieldSlot* fieldSlot = window->getFieldSlot(row, column);
+  if (!fieldSlot) {
+    throwExceptionWithRowCol(env, row, column);
+    return 0;
+  }
+
+  int32_t type = window->getFieldSlotType(fieldSlot);
+  if (type == CursorWindow::FIELD_TYPE_INTEGER) {
+    return window->getFieldSlotValueLong(fieldSlot);
+  } else if (type == CursorWindow::FIELD_TYPE_STRING) {
+    size_t sizeIncludingNull;
+    const char* value =
+        window->getFieldSlotValueString(fieldSlot, &sizeIncludingNull);
+    return sizeIncludingNull > 1 ? strtoll(value, nullptr, 0) : 0L;
+  } else if (type == CursorWindow::FIELD_TYPE_FLOAT) {
+    return jlong(window->getFieldSlotValueDouble(fieldSlot));
+  } else if (type == CursorWindow::FIELD_TYPE_NULL) {
+    return 0;
+  } else if (type == CursorWindow::FIELD_TYPE_BLOB) {
+    throw_sqlite3_exception(env, "Unable to convert BLOB to long");
+    return 0;
+  } else {
+    throwUnknownTypeException(env, type);
+    return 0;
+  }
+}
+
+static jdouble nativeGetDouble(JNIEnv* env, jclass clazz, jlong windowPtr,
+                               jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  LOG_WINDOW("Getting double for %d,%d from %p", row, column, window);
+
+  CursorWindow::FieldSlot* fieldSlot = window->getFieldSlot(row, column);
+  if (!fieldSlot) {
+    throwExceptionWithRowCol(env, row, column);
+    return 0.0;
+  }
+
+  int32_t type = window->getFieldSlotType(fieldSlot);
+  if (type == CursorWindow::FIELD_TYPE_FLOAT) {
+    return window->getFieldSlotValueDouble(fieldSlot);
+  } else if (type == CursorWindow::FIELD_TYPE_STRING) {
+    size_t sizeIncludingNull;
+    const char* value =
+        window->getFieldSlotValueString(fieldSlot, &sizeIncludingNull);
+    return sizeIncludingNull > 1 ? strtod(value, nullptr) : 0.0;
+  } else if (type == CursorWindow::FIELD_TYPE_INTEGER) {
+    return jdouble(window->getFieldSlotValueLong(fieldSlot));
+  } else if (type == CursorWindow::FIELD_TYPE_NULL) {
+    return 0.0;
+  } else if (type == CursorWindow::FIELD_TYPE_BLOB) {
+    throw_sqlite3_exception(env, "Unable to convert BLOB to double");
+    return 0.0;
+  } else {
+    throwUnknownTypeException(env, type);
+    return 0.0;
+  }
+}
+
+static jboolean nativePutBlob(JNIEnv* env, jclass clazz, jlong windowPtr,
+                              jbyteArray valueObj, jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  jsize len = env->GetArrayLength(valueObj);
+
+  void* value = env->GetPrimitiveArrayCritical(valueObj, nullptr);
+  status_t status = window->putBlob(row, column, value, len);
+  env->ReleasePrimitiveArrayCritical(valueObj, value, JNI_ABORT);
+
+  if (status) {
+    LOG_WINDOW("Failed to put blob. error=%d", status);
+    return false;
+  }
+
+  LOG_WINDOW("%d,%d is BLOB with %u bytes", row, column, len);
+  return true;
+}
+
+static jboolean nativePutString(JNIEnv* env, jclass clazz, jlong windowPtr,
+                                jstring valueObj, jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+
+  size_t sizeIncludingNull = env->GetStringUTFLength(valueObj) + 1;
+  const char* valueStr = env->GetStringUTFChars(valueObj, nullptr);
+  if (!valueStr) {
+    LOG_WINDOW("value can't be transferred to UTFChars");
+    return false;
+  }
+  status_t status = window->putString(row, column, valueStr, sizeIncludingNull);
+  env->ReleaseStringUTFChars(valueObj, valueStr);
+
+  if (status) {
+    LOG_WINDOW("Failed to put string. error=%d", status);
+    return false;
+  }
+
+  LOG_WINDOW("%d,%d is TEXT with %zu bytes", row, column, sizeIncludingNull);
+  return true;
+}
+
+static jboolean nativePutLong(JNIEnv* env, jclass clazz, jlong windowPtr,
+                              jlong value, jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  status_t status = window->putLong(row, column, value);
+
+  if (status) {
+    LOG_WINDOW("Failed to put long. error=%d", status);
+    return false;
+  }
+
+  LOG_WINDOW("%d,%d is INTEGER %" PRId64, row, column, value);
+  return true;
+}
+
+static jboolean nativePutDouble(JNIEnv* env, jclass clazz, jlong windowPtr,
+                                jdouble value, jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  status_t status = window->putDouble(row, column, value);
+
+  if (status) {
+    LOG_WINDOW("Failed to put double. error=%d", status);
+    return false;
+  }
+
+  LOG_WINDOW("%d,%d is FLOAT %lf", row, column, value);
+  return true;
+}
+
+static jboolean nativePutNull(JNIEnv* env, jclass clazz, jlong windowPtr,
+                              jint row, jint column) {
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+  status_t status = window->putNull(row, column);
+
+  if (status) {
+    LOG_WINDOW("Failed to put null. error=%d", status);
+    return false;
+  }
+
+  LOG_WINDOW("%d,%d is NULL", row, column);
+  return true;
+}
+
+static const JNINativeMethod sMethods[] = {
+    /* name, signature, funcPtr */
+    {(char*)"nativeCreate", (char*)"(Ljava/lang/String;I)J",
+     reinterpret_cast<void*>(nativeCreate)},
+    //        { "nativeCreateFromParcel", "(Landroid/os/Parcel;)J",
+    //          (void*)nativeCreateFromParcel },
+    {(char*)"nativeDispose", (char*)"(J)V",
+     reinterpret_cast<void*>(nativeDispose)},
+    //        { "nativeWriteToParcel", "(JLandroid/os/Parcel;)V",
+    //          (void*)nativeWriteToParcel },
+
+    {const_cast<char*>("nativeGetName"),
+     const_cast<char*>("(J)Ljava/lang/String;"),
+     reinterpret_cast<void*>(nativeGetName)},
+    {const_cast<char*>("nativeGetBlob"), const_cast<char*>("(JII)[B"),
+     reinterpret_cast<void*>(nativeGetBlob)},
+    {const_cast<char*>("nativeGetString"),
+     const_cast<char*>("(JII)Ljava/lang/String;"),
+     reinterpret_cast<void*>(nativeGetString)},
+    {const_cast<char*>("nativeCopyStringToBuffer"),
+     const_cast<char*>("(JIILandroid/database/CharArrayBuffer;)V"),
+     reinterpret_cast<void*>(nativeCopyStringToBuffer)},
+    {const_cast<char*>("nativePutBlob"), const_cast<char*>("(J[BII)Z"),
+     reinterpret_cast<void*>(nativePutBlob)},
+    {const_cast<char*>("nativePutString"),
+     const_cast<char*>("(JLjava/lang/String;II)Z"),
+     reinterpret_cast<void*>(nativePutString)},
+
+    // ------- @FastNative below here ----------------------
+    {const_cast<char*>("nativeClear"), const_cast<char*>("(J)V"),
+     reinterpret_cast<void*>(nativeClear)},
+    {const_cast<char*>("nativeGetNumRows"), const_cast<char*>("(J)I"),
+     reinterpret_cast<void*>(nativeGetNumRows)},
+    {const_cast<char*>("nativeSetNumColumns"), const_cast<char*>("(JI)Z"),
+     reinterpret_cast<void*>(nativeSetNumColumns)},
+    {const_cast<char*>("nativeAllocRow"), const_cast<char*>("(J)Z"),
+     reinterpret_cast<void*>(nativeAllocRow)},
+    {const_cast<char*>("nativeFreeLastRow"), const_cast<char*>("(J)V"),
+     reinterpret_cast<void*>(nativeFreeLastRow)},
+    {const_cast<char*>("nativeGetType"), const_cast<char*>("(JII)I"),
+     reinterpret_cast<void*>(nativeGetType)},
+    {const_cast<char*>("nativeGetLong"), const_cast<char*>("(JII)J"),
+     reinterpret_cast<void*>(nativeGetLong)},
+    {const_cast<char*>("nativeGetDouble"), const_cast<char*>("(JII)D"),
+     reinterpret_cast<void*>(nativeGetDouble)},
+    {const_cast<char*>("nativePutLong"), const_cast<char*>("(JJII)Z"),
+     reinterpret_cast<void*>(nativePutLong)},
+    {const_cast<char*>("nativePutDouble"), const_cast<char*>("(JDII)Z"),
+     reinterpret_cast<void*>(nativePutDouble)},
+    {const_cast<char*>("nativePutNull"), const_cast<char*>("(JII)Z"),
+     reinterpret_cast<void*>(nativePutNull)},
+};
+
+int register_android_database_CursorWindow(JNIEnv* env) {
+  gEmptyString = (jstring)env->NewGlobalRef(env->NewStringUTF(""));
+
+  static const char* kCursorWindowClass =
+      "org/robolectric/nativeruntime/CursorWindowNatives";
+
+  ScopedLocalRef<jclass> cls(env, env->FindClass(kCursorWindowClass));
+
+  if (cls.get() == nullptr) {
+    ALOGE("jni CursorWindow registration failure, class not found '%s'",
+          kCursorWindowClass);
+    return JNI_ERR;
+  }
+
+  const jint count = sizeof(sMethods) / sizeof(sMethods[0]);
+  int status = env->RegisterNatives(cls.get(), sMethods, count);
+  if (status < 0) {
+    ALOGE("jni CursorWindow registration failure, status: %d", status);
+    return JNI_ERR;
+  }
+  return JNI_VERSION_1_4;
+}
+
+}  // namespace android
diff --git a/nativeruntime/cpp/jni/robo_android_database_SQLiteCommon.cpp b/nativeruntime/cpp/jni/robo_android_database_SQLiteCommon.cpp
new file mode 100644
index 0000000..0da34da
--- /dev/null
+++ b/nativeruntime/cpp/jni/robo_android_database_SQLiteCommon.cpp
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2011 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/jni/android_database_SQLiteCommon.cpp
+
+#include "robo_android_database_SQLiteCommon.h"
+
+#include <utils/String8.h>
+
+#include <map>
+#include <string>
+
+namespace android {
+
+static const std::map<int, std::string> sErrorCodesMap = {
+    // Primary Result Code List
+    {4, "SQLITE_ABORT"},
+    {23, "SQLITE_AUTH"},
+    {5, "SQLITE_BUSY"},
+    {14, "SQLITE_CANTOPEN"},
+    {19, "SQLITE_CONSTRAINT"},
+    {11, "SQLITE_CORRUPT"},
+    {101, "SQLITE_DONE"},
+    {16, "SQLITE_EMPTY"},
+    {1, "SQLITE_ERROR"},
+    {24, "SQLITE_FORMAT"},
+    {13, "SQLITE_FULL"},
+    {2, "SQLITE_INTERNAL"},
+    {9, "SQLITE_INTERRUPT"},
+    {10, "SQLITE_IOERR"},
+    {6, "SQLITE_LOCKED"},
+    {20, "SQLITE_MISMATCH"},
+    {21, "SQLITE_MISUSE"},
+    {22, "SQLITE_NOLFS"},
+    {7, "SQLITE_NOMEM"},
+    {26, "SQLITE_NOTADB"},
+    {12, "SQLITE_NOTFOUND"},
+    {27, "SQLITE_NOTICE"},
+    {0, "SQLITE_OK"},
+    {3, "SQLITE_PERM"},
+    {15, "SQLITE_PROTOCOL"},
+    {25, "SQLITE_RANGE"},
+    {8, "SQLITE_READONLY"},
+    {100, "SQLITE_ROW"},
+    {17, "SQLITE_SCHEMA"},
+    {18, "SQLITE_TOOBIG"},
+    {28, "SQLITE_WARNING"},
+    // Extended Result Code List
+    {516, "SQLITE_ABORT_ROLLBACK"},
+    {261, "SQLITE_BUSY_RECOVERY"},
+    {517, "SQLITE_BUSY_SNAPSHOT"},
+    {1038, "SQLITE_CANTOPEN_CONVPATH"},
+    {782, "SQLITE_CANTOPEN_FULLPATH"},
+    {526, "SQLITE_CANTOPEN_ISDIR"},
+    {270, "SQLITE_CANTOPEN_NOTEMPDIR"},
+    {275, "SQLITE_CONSTRAINT_CHECK"},
+    {531, "SQLITE_CONSTRAINT_COMMITHOOK"},
+    {787, "SQLITE_CONSTRAINT_FOREIGNKEY"},
+    {1043, "SQLITE_CONSTRAINT_FUNCTION"},
+    {1299, "SQLITE_CONSTRAINT_NOTNULL"},
+    {1555, "SQLITE_CONSTRAINT_PRIMARYKEY"},
+    {2579, "SQLITE_CONSTRAINT_ROWID"},
+    {1811, "SQLITE_CONSTRAINT_TRIGGER"},
+    {2067, "SQLITE_CONSTRAINT_UNIQUE"},
+    {2323, "SQLITE_CONSTRAINT_VTAB"},
+    {267, "SQLITE_CORRUPT_VTAB"},
+    {3338, "SQLITE_IOERR_ACCESS"},
+    {2826, "SQLITE_IOERR_BLOCKED"},
+    {3594, "SQLITE_IOERR_CHECKRESERVEDLOCK"},
+    {4106, "SQLITE_IOERR_CLOSE"},
+    {6666, "SQLITE_IOERR_CONVPATH"},
+    {2570, "SQLITE_IOERR_DELETE"},
+    {5898, "SQLITE_IOERR_DELETE_NOENT"},
+    {4362, "SQLITE_IOERR_DIR_CLOSE"},
+    {1290, "SQLITE_IOERR_DIR_FSYNC"},
+    {1802, "SQLITE_IOERR_FSTAT"},
+    {1034, "SQLITE_IOERR_FSYNC"},
+    {6410, "SQLITE_IOERR_GETTEMPPATH"},
+    {3850, "SQLITE_IOERR_LOCK"},
+    {6154, "SQLITE_IOERR_MMAP"},
+    {3082, "SQLITE_IOERR_NOMEM"},
+    {2314, "SQLITE_IOERR_RDLOCK"},
+    {266, "SQLITE_IOERR_READ"},
+    {5642, "SQLITE_IOERR_SEEK"},
+    {5130, "SQLITE_IOERR_SHMLOCK"},
+    {5386, "SQLITE_IOERR_SHMMAP"},
+    {4618, "SQLITE_IOERR_SHMOPEN"},
+    {4874, "SQLITE_IOERR_SHMSIZE"},
+    {522, "SQLITE_IOERR_SHORT_READ"},
+    {1546, "SQLITE_IOERR_TRUNCATE"},
+    {2058, "SQLITE_IOERR_UNLOCK"},
+    {778, "SQLITE_IOERR_WRITE"},
+    {262, "SQLITE_LOCKED_SHAREDCACHE"},
+    {539, "SQLITE_NOTICE_RECOVER_ROLLBACK"},
+    {283, "SQLITE_NOTICE_RECOVER_WAL"},
+    {256, "SQLITE_OK_LOAD_PERMANENTLY"},
+    {520, "SQLITE_READONLY_CANTLOCK"},
+    {1032, "SQLITE_READONLY_DBMOVED"},
+    {264, "SQLITE_READONLY_RECOVERY"},
+    {776, "SQLITE_READONLY_ROLLBACK"},
+    {284, "SQLITE_WARNING_AUTOINDEX"},
+};
+
+static std::string sqlite3_error_code_to_msg(int errcode) {
+  auto it = sErrorCodesMap.find(errcode);
+  if (it != sErrorCodesMap.end()) {
+    return std::to_string(errcode) + " " + it->second;
+  } else {
+    return std::to_string(errcode);
+  }
+}
+
+/* throw a SQLiteException with a message appropriate for the error in handle */
+void throw_sqlite3_exception(JNIEnv* env, sqlite3* handle) {
+  throw_sqlite3_exception(env, handle, nullptr);
+}
+
+/* throw a SQLiteException with the given message */
+void throw_sqlite3_exception(JNIEnv* env, const char* message) {
+  throw_sqlite3_exception(env, nullptr, message);
+}
+
+/* throw a SQLiteException with a message appropriate for the error in handle
+   concatenated with the given message
+ */
+void throw_sqlite3_exception(JNIEnv* env, sqlite3* handle,
+                             const char* message) {
+  if (handle) {
+    // get the error code and message from the SQLite connection
+    // the error message may contain more information than the error code
+    // because it is based on the extended error code rather than the simplified
+    // error code that SQLite normally returns.
+    throw_sqlite3_exception(env, sqlite3_extended_errcode(handle),
+                            sqlite3_errmsg(handle), message);
+  } else {
+    // we use SQLITE_OK so that a generic SQLiteException is thrown;
+    // any code not specified in the switch statement below would do.
+    throw_sqlite3_exception(env, SQLITE_OK, "unknown error", message);
+  }
+}
+
+/* throw a SQLiteException for a given error code
+ * should only be used when the database connection is not available because the
+ * error information will not be quite as rich */
+void throw_sqlite3_exception_errcode(JNIEnv* env, int errcode,
+                                     const char* message) {
+  throw_sqlite3_exception(env, errcode, "unknown error", message);
+}
+
+/* throw a SQLiteException for a given error code, sqlite3message, and
+   user message
+ */
+void throw_sqlite3_exception(JNIEnv* env, int errcode,
+                             const char* sqlite3Message, const char* message) {
+  const char* exceptionClass;
+  switch (errcode & 0xff) { /* mask off extended error code */
+    case SQLITE_IOERR:
+      exceptionClass = "android/database/sqlite/SQLiteDiskIOException";
+      break;
+    case SQLITE_CORRUPT:
+    case SQLITE_NOTADB:  // treat "unsupported file format" error as corruption
+                         // also
+      exceptionClass = "android/database/sqlite/SQLiteDatabaseCorruptException";
+      break;
+    case SQLITE_CONSTRAINT:
+      exceptionClass = "android/database/sqlite/SQLiteConstraintException";
+      break;
+    case SQLITE_ABORT:
+      exceptionClass = "android/database/sqlite/SQLiteAbortException";
+      break;
+    case SQLITE_DONE:
+      exceptionClass = "android/database/sqlite/SQLiteDoneException";
+      sqlite3Message =
+          nullptr;  // SQLite error message is irrelevant in this case
+      break;
+    case SQLITE_FULL:
+      exceptionClass = "android/database/sqlite/SQLiteFullException";
+      break;
+    case SQLITE_MISUSE:
+      exceptionClass = "android/database/sqlite/SQLiteMisuseException";
+      break;
+    case SQLITE_PERM:
+      exceptionClass = "android/database/sqlite/SQLiteAccessPermException";
+      break;
+    case SQLITE_BUSY:
+      exceptionClass = "android/database/sqlite/SQLiteDatabaseLockedException";
+      break;
+    case SQLITE_LOCKED:
+      exceptionClass = "android/database/sqlite/SQLiteTableLockedException";
+      break;
+    case SQLITE_READONLY:
+      exceptionClass =
+          "android/database/sqlite/SQLiteReadOnlyDatabaseException";
+      break;
+    case SQLITE_CANTOPEN:
+      exceptionClass =
+          "android/database/sqlite/SQLiteCantOpenDatabaseException";
+      break;
+    case SQLITE_TOOBIG:
+      exceptionClass = "android/database/sqlite/SQLiteBlobTooBigException";
+      break;
+    case SQLITE_RANGE:
+      exceptionClass =
+          "android/database/sqlite/SQLiteBindOrColumnIndexOutOfRangeException";
+      break;
+    case SQLITE_NOMEM:
+      exceptionClass = "android/database/sqlite/SQLiteOutOfMemoryException";
+      break;
+    case SQLITE_MISMATCH:
+      exceptionClass =
+          "android/database/sqlite/SQLiteDatatypeMismatchException";
+      break;
+    case SQLITE_INTERRUPT:
+      exceptionClass = "android/os/OperationCanceledException";
+      break;
+    default:
+      exceptionClass = "android/database/sqlite/SQLiteException";
+      break;
+  }
+
+  if (sqlite3Message) {
+    String8 fullMessage;
+    fullMessage.append(sqlite3Message);
+    std::string errcode_msg = sqlite3_error_code_to_msg(errcode);
+    fullMessage.appendFormat(" (code %s)",
+                             errcode_msg.c_str());  // print extended error code
+    if (message) {
+      fullMessage.append(": ");
+      fullMessage.append(message);
+    }
+    jniThrowException(env, exceptionClass, fullMessage.string());
+  } else {
+    jniThrowException(env, exceptionClass, message);
+  }
+}
+
+}  // namespace android
diff --git a/nativeruntime/cpp/jni/robo_android_database_SQLiteCommon.h b/nativeruntime/cpp/jni/robo_android_database_SQLiteCommon.h
new file mode 100644
index 0000000..a426ce2
--- /dev/null
+++ b/nativeruntime/cpp/jni/robo_android_database_SQLiteCommon.h
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2007 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/jni/android_database_SQLiteCommon.h
+
+#ifndef _ANDROID_DATABASE_SQLITE_COMMON_H
+#define _ANDROID_DATABASE_SQLITE_COMMON_H
+
+#include <jni.h>
+#include <nativehelper/JNIHelp.h>
+#include <sqlite3.h>
+
+// Special log tags defined in SQLiteDebug.java.
+#define SQLITE_LOG_TAG "SQLiteLog"
+#define SQLITE_TRACE_TAG "SQLiteStatements"
+#define SQLITE_PROFILE_TAG "SQLiteTime"
+
+namespace android {
+
+/* throw a SQLiteException with a message appropriate for the error in handle */
+void throw_sqlite3_exception(JNIEnv* env, sqlite3* handle);
+
+/* throw a SQLiteException with the given message */
+void throw_sqlite3_exception(JNIEnv* env, const char* message);
+
+/* throw a SQLiteException with a message appropriate for the error in handle
+   concatenated with the given message
+ */
+void throw_sqlite3_exception(JNIEnv* env, sqlite3* handle, const char* message);
+
+/* throw a SQLiteException for a given error code */
+void throw_sqlite3_exception_errcode(JNIEnv* env, int errcode,
+                                     const char* message);
+
+void throw_sqlite3_exception(JNIEnv* env, int errcode,
+                             const char* sqlite3Message, const char* message);
+
+}  // namespace android
+
+#endif  // _ANDROID_DATABASE_SQLITE_COMMON_H
diff --git a/nativeruntime/cpp/jni/robo_android_database_SQLiteConnection.cpp b/nativeruntime/cpp/jni/robo_android_database_SQLiteConnection.cpp
new file mode 100644
index 0000000..d12df85
--- /dev/null
+++ b/nativeruntime/cpp/jni/robo_android_database_SQLiteConnection.cpp
@@ -0,0 +1,1068 @@
+/*
+ * Copyright (C) 2011 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/jni/android_database_SQLiteConnection.cpp
+
+#define LOG_TAG "SQLiteConnection"
+
+#include <jni.h>
+#include <nativehelper/JNIHelp.h>
+
+#include "AndroidRuntime.h"
+// #include <android_runtime/Log.h>
+
+#include <androidfw/CursorWindow.h>
+#include <cutils/ashmem.h>
+#include <log/log.h>
+#include <nativehelper/scoped_local_ref.h>
+#include <nativehelper/scoped_utf8_chars.h>
+#include <sqlite3.h>
+#include <sqlite3_android.h>
+#include <string.h>
+#if !defined(_WIN32)
+#include <sys/mman.h>
+#endif
+#include <unistd.h>
+#include <utils/String16.h>
+#include <utils/String8.h>
+
+#include "robo_android_database_SQLiteCommon.h"
+
+// #include "core_jni_helpers.h"
+
+// Set to 1 to use UTF16 storage for localized indexes.
+#define UTF16_STORAGE 0
+
+namespace android {
+
+/* Busy timeout in milliseconds.
+ * If another connection (possibly in another process) has the database locked
+ * for longer than this amount of time then SQLite will generate a SQLITE_BUSY
+ * error. The SQLITE_BUSY error is then raised as a
+ * SQLiteDatabaseLockedException.
+ *
+ * In ordinary usage, busy timeouts are quite rare.  Most databases only ever
+ * have a single open connection at a time unless they are using WAL.  When
+ * using WAL, a timeout could occur if one connection is busy performing an
+ * auto-checkpoint operation.  The busy timeout needs to be long enough to
+ * tolerate slow I/O write operations but not so long as to cause the
+ * application to hang indefinitely if there is a problem acquiring a database
+ * lock.
+ */
+static const int BUSY_TIMEOUT_MS = 2500;
+
+static struct { jmethodID apply; } gUnaryOperator;
+
+static struct { jmethodID apply; } gBinaryOperator;
+
+struct SQLiteConnection {
+  // Open flags.
+  // Must be kept in sync with the constants defined in SQLiteDatabase.java.
+  enum {
+    OPEN_READWRITE = 0x00000000,
+    OPEN_READONLY = 0x00000001,
+    OPEN_READ_MASK = 0x00000001,
+    NO_LOCALIZED_COLLATORS = 0x00000010,
+    CREATE_IF_NECESSARY = 0x10000000,
+  };
+
+  sqlite3* const db;
+  const int openFlags;
+  const String8 path;
+  const String8 label;
+
+  volatile bool canceled;
+
+  SQLiteConnection(sqlite3* db, int openFlags, const String8& path,
+                   const String8& label)
+      : db(db),
+        openFlags(openFlags),
+        path(path),
+        label(label),
+        canceled(false) {}
+};
+
+// Called each time a statement begins execution, when tracing is enabled.
+static void sqliteTraceCallback(void* data, const char* sql) {
+  SQLiteConnection* connection = static_cast<SQLiteConnection*>(data);
+  ALOG(LOG_VERBOSE, SQLITE_TRACE_TAG, "%s: \"%s\"\n",
+       connection->label.string(), sql);
+}
+
+// Called each time a statement finishes execution, when profiling is enabled.
+static void sqliteProfileCallback(void* data, const char* sql,
+                                  sqlite3_uint64 tm) {
+  SQLiteConnection* connection = static_cast<SQLiteConnection*>(data);
+  ALOG(LOG_VERBOSE, SQLITE_PROFILE_TAG, "%s: \"%s\" took %0.3f ms\n",
+       connection->label.string(), sql, tm * 0.000001f);
+}
+
+// Called after each SQLite VM instruction when cancelation is enabled.
+static int sqliteProgressHandlerCallback(void* data) {
+  SQLiteConnection* connection = static_cast<SQLiteConnection*>(data);
+  return connection->canceled;
+}
+
+static jlong nativeOpen(JNIEnv* env, jclass clazz, jstring pathStr,
+                        jint openFlags, jstring labelStr, jboolean enableTrace,
+                        jboolean enableProfile, jint lookasideSz,
+                        jint lookasideCnt) {
+  int sqliteFlags;
+  if (openFlags & SQLiteConnection::CREATE_IF_NECESSARY) {
+    sqliteFlags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
+  } else if (openFlags & SQLiteConnection::OPEN_READONLY) {
+    sqliteFlags = SQLITE_OPEN_READONLY;
+  } else {
+    sqliteFlags = SQLITE_OPEN_READWRITE;
+  }
+
+  const char* pathChars = env->GetStringUTFChars(pathStr, nullptr);
+  String8 path(pathChars);
+  env->ReleaseStringUTFChars(pathStr, pathChars);
+
+  const char* labelChars = env->GetStringUTFChars(labelStr, nullptr);
+  String8 label(labelChars);
+  env->ReleaseStringUTFChars(labelStr, labelChars);
+
+  sqlite3* db;
+  int err = sqlite3_open_v2(path.string(), &db, sqliteFlags, nullptr);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception_errcode(env, err, "Could not open database");
+    return 0;
+  }
+
+  if (lookasideSz >= 0 && lookasideCnt >= 0) {
+    int err = sqlite3_db_config(db, SQLITE_DBCONFIG_LOOKASIDE, NULL,
+                                lookasideSz, lookasideCnt);
+    if (err != SQLITE_OK) {
+      ALOGE("sqlite3_db_config(..., %d, %d) failed: %d", lookasideSz,
+            lookasideCnt, err);
+      throw_sqlite3_exception(env, db, "Cannot set lookaside");
+      sqlite3_close(db);
+      return 0;
+    }
+  }
+
+  // Check that the database is really read/write when that is what we asked
+  // for.
+  if ((sqliteFlags & SQLITE_OPEN_READWRITE) &&
+      sqlite3_db_readonly(db, nullptr)) {
+    throw_sqlite3_exception(env, db,
+                            "Could not open the database in read/write mode.");
+    sqlite3_close(db);
+    return 0;
+  }
+
+  // Set the default busy handler to retry automatically before returning
+  // SQLITE_BUSY.
+  err = sqlite3_busy_timeout(db, BUSY_TIMEOUT_MS);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, db, "Could not set busy timeout");
+    sqlite3_close(db);
+    return 0;
+  }
+
+  // Register custom Android functions.
+  err = register_android_functions(db, UTF16_STORAGE);
+  if (err) {
+    throw_sqlite3_exception(env, db,
+                            "Could not register Android SQL functions.");
+    sqlite3_close(db);
+    return 0;
+  }
+
+  // Create wrapper object.
+  SQLiteConnection* connection =
+      new SQLiteConnection(db, openFlags, path, label);
+
+  // Enable tracing and profiling if requested.
+  if (enableTrace) {
+    sqlite3_trace(db, &sqliteTraceCallback, connection);
+  }
+  if (enableProfile) {
+    sqlite3_profile(db, &sqliteProfileCallback, connection);
+  }
+
+  ALOGV("Opened connection %p with label '%s'", db, label.string());
+  return reinterpret_cast<jlong>(connection);
+}
+
+static void nativeClose(JNIEnv* env, jclass clazz, jlong connectionPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+
+  if (connection) {
+    ALOGV("Closing connection %p", connection->db);
+    int err = sqlite3_close(connection->db);
+    if (err != SQLITE_OK) {
+      // This can happen if sub-objects aren't closed first.  Make sure the
+      // caller knows.
+      ALOGE("sqlite3_close(%p) failed: %d", connection->db, err);
+      throw_sqlite3_exception(env, connection->db, "Count not close db.");
+      return;
+    }
+
+    delete connection;
+  }
+}
+
+static void sqliteCustomScalarFunctionCallback(sqlite3_context* context,
+                                               int argc, sqlite3_value** argv) {
+  JNIEnv* env = AndroidRuntime::getJNIEnv();
+  jobject functionObjGlobal =
+      reinterpret_cast<jobject>(sqlite3_user_data(context));
+  ScopedLocalRef<jobject> functionObj(env, env->NewLocalRef(functionObjGlobal));
+  ScopedLocalRef<jstring> argString(
+      env, env->NewStringUTF(
+               reinterpret_cast<const char*>(sqlite3_value_text(argv[0]))));
+  ScopedLocalRef<jstring> resString(
+      env, (jstring)env->CallObjectMethod(
+               functionObj.get(), gUnaryOperator.apply, argString.get()));
+
+  if (env->ExceptionCheck()) {
+    ALOGE("Exception thrown by custom scalar function");
+    sqlite3_result_error(context, "Exception thrown by custom scalar function",
+                         -1);
+    env->ExceptionDescribe();
+    env->ExceptionClear();
+    return;
+  }
+
+  if (resString.get() == nullptr) {
+    sqlite3_result_null(context);
+  } else {
+    ScopedUtfChars res(env, resString.get());
+    sqlite3_result_text(context, res.c_str(), -1, SQLITE_TRANSIENT);
+  }
+}
+
+static void sqliteCustomScalarFunctionDestructor(void* data) {
+  jobject functionObjGlobal = reinterpret_cast<jobject>(data);
+
+  JNIEnv* env = AndroidRuntime::getJNIEnv();
+  env->DeleteGlobalRef(functionObjGlobal);
+}
+
+static void nativeRegisterCustomScalarFunction(JNIEnv* env, jclass clazz,
+                                               jlong connectionPtr,
+                                               jstring functionName,
+                                               jobject functionObj) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+
+  jobject functionObjGlobal = env->NewGlobalRef(functionObj);
+  ScopedUtfChars functionNameChars(env, functionName);
+  int err = sqlite3_create_function_v2(
+      connection->db, functionNameChars.c_str(), 1, SQLITE_UTF8,
+      reinterpret_cast<void*>(functionObjGlobal),
+      &sqliteCustomScalarFunctionCallback, nullptr, nullptr,
+      &sqliteCustomScalarFunctionDestructor);
+
+  if (err != SQLITE_OK) {
+    ALOGE("sqlite3_create_function returned %d", err);
+    env->DeleteGlobalRef(functionObjGlobal);
+    throw_sqlite3_exception(env, connection->db);
+    return;
+  }
+}
+
+static void sqliteCustomAggregateFunctionStep(sqlite3_context* context,
+                                              int argc, sqlite3_value** argv) {
+  char** agg = reinterpret_cast<char**>(
+      sqlite3_aggregate_context(context, sizeof(const char**)));
+  if (agg == nullptr) {
+    return;
+  } else if (*agg == nullptr) {
+    // During our first call the best we can do is allocate our result
+    // holder and populate it with our first value; we'll reduce it
+    // against any additional values in future calls
+    const char* res =
+        reinterpret_cast<const char*>(sqlite3_value_text(argv[0]));
+    if (res == nullptr) {
+      *agg = nullptr;
+    } else {
+      *agg = strdup(res);
+    }
+    return;
+  }
+
+  JNIEnv* env = AndroidRuntime::getJNIEnv();
+  jobject functionObjGlobal =
+      reinterpret_cast<jobject>(sqlite3_user_data(context));
+  ScopedLocalRef<jobject> functionObj(env, env->NewLocalRef(functionObjGlobal));
+  ScopedLocalRef<jstring> arg0String(
+      env, env->NewStringUTF(reinterpret_cast<const char*>(*agg)));
+  ScopedLocalRef<jstring> arg1String(
+      env, env->NewStringUTF(
+               reinterpret_cast<const char*>(sqlite3_value_text(argv[0]))));
+  ScopedLocalRef<jstring> resString(
+      env,
+      (jstring)env->CallObjectMethod(functionObj.get(), gBinaryOperator.apply,
+                                     arg0String.get(), arg1String.get()));
+
+  if (env->ExceptionCheck()) {
+    ALOGE("Exception thrown by custom aggregate function");
+    sqlite3_result_error(context,
+                         "Exception thrown by custom aggregate function", -1);
+    env->ExceptionDescribe();
+    env->ExceptionClear();
+    return;
+  }
+
+  // One way or another, we have a new value to collect, and we need to
+  // free our previous value
+  if (*agg != nullptr) {
+    free(*agg);
+  }
+  if (resString.get() == nullptr) {
+    *agg = nullptr;
+  } else {
+    ScopedUtfChars res(env, resString.get());
+    *agg = strdup(res.c_str());
+  }
+}
+
+static void sqliteCustomAggregateFunctionFinal(sqlite3_context* context) {
+  // We pass zero size here to avoid allocating for empty sets
+  char** agg = reinterpret_cast<char**>(sqlite3_aggregate_context(context, 0));
+  if (agg == nullptr) {
+    return;
+  } else if (*agg == nullptr) {
+    sqlite3_result_null(context);
+  } else {
+    sqlite3_result_text(context, *agg, -1, SQLITE_TRANSIENT);
+    free(*agg);
+  }
+}
+
+static void sqliteCustomAggregateFunctionDestructor(void* data) {
+  jobject functionObjGlobal = reinterpret_cast<jobject>(data);
+
+  JNIEnv* env = AndroidRuntime::getJNIEnv();
+  env->DeleteGlobalRef(functionObjGlobal);
+}
+
+static void nativeRegisterCustomAggregateFunction(JNIEnv* env, jclass clazz,
+                                                  jlong connectionPtr,
+                                                  jstring functionName,
+                                                  jobject functionObj) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+
+  jobject functionObjGlobal = env->NewGlobalRef(functionObj);
+  ScopedUtfChars functionNameChars(env, functionName);
+  int err = sqlite3_create_function_v2(
+      connection->db, functionNameChars.c_str(), 1, SQLITE_UTF8,
+      reinterpret_cast<void*>(functionObjGlobal), nullptr,
+      &sqliteCustomAggregateFunctionStep, &sqliteCustomAggregateFunctionFinal,
+      &sqliteCustomAggregateFunctionDestructor);
+
+  if (err != SQLITE_OK) {
+    ALOGE("sqlite3_create_function returned %d", err);
+    env->DeleteGlobalRef(functionObjGlobal);
+    throw_sqlite3_exception(env, connection->db);
+    return;
+  }
+}
+
+static void nativeRegisterLocalizedCollators(JNIEnv* env, jclass clazz,
+                                             jlong connectionPtr,
+                                             jstring localeStr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+
+  const char* locale = env->GetStringUTFChars(localeStr, nullptr);
+  int err = register_localized_collators(connection->db, locale, UTF16_STORAGE);
+  env->ReleaseStringUTFChars(localeStr, locale);
+
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db);
+  }
+}
+
+static jlong nativePrepareStatement(JNIEnv* env, jclass clazz,
+                                    jlong connectionPtr, jstring sqlString) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+
+  jsize sqlLength = env->GetStringLength(sqlString);
+  const jchar* sql = env->GetStringCritical(sqlString, nullptr);
+  sqlite3_stmt* statement;
+  int err = sqlite3_prepare16_v2(connection->db, sql, sqlLength * sizeof(jchar),
+                                 &statement, nullptr);
+  env->ReleaseStringCritical(sqlString, sql);
+
+  if (err != SQLITE_OK) {
+    // Error messages like 'near ")": syntax error' are not
+    // always helpful enough, so construct an error string that
+    // includes the query itself.
+    const char* query = env->GetStringUTFChars(sqlString, nullptr);
+    char* message = static_cast<char*>(malloc(strlen(query) + 50));
+    if (message) {
+      strcpy(message, ", while compiling: ");  // less than 50 chars
+      strcat(message, query);
+    }
+    env->ReleaseStringUTFChars(sqlString, query);
+    throw_sqlite3_exception(env, connection->db, message);
+    free(message);
+    return 0;
+  }
+
+  ALOGV("Prepared statement %p on connection %p", statement, connection->db);
+  return reinterpret_cast<jlong>(statement);
+}
+
+static void nativeFinalizeStatement(JNIEnv* env, jclass clazz,
+                                    jlong connectionPtr, jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  // We ignore the result of sqlite3_finalize because it is really telling us
+  // about whether any errors occurred while executing the statement.  The
+  // statement itself is always finalized regardless.
+  ALOGV("Finalized statement %p on connection %p", statement, connection->db);
+  sqlite3_finalize(statement);
+}
+
+static jint nativeGetParameterCount(JNIEnv* env, jclass clazz,
+                                    jlong connectionPtr, jlong statementPtr) {
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  return sqlite3_bind_parameter_count(statement);
+}
+
+static jboolean nativeIsReadOnly(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                                 jlong statementPtr) {
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  return sqlite3_stmt_readonly(statement) != 0;
+}
+
+static jint nativeGetColumnCount(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                                 jlong statementPtr) {
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  return sqlite3_column_count(statement);
+}
+
+static jstring nativeGetColumnName(JNIEnv* env, jclass clazz,
+                                   jlong connectionPtr, jlong statementPtr,
+                                   jint index) {
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  const jchar* name =
+      static_cast<const jchar*>(sqlite3_column_name16(statement, index));
+  if (name) {
+    size_t length = 0;
+    while (name[length]) {
+      length += 1;
+    }
+    return env->NewString(name, length);
+  }
+  return nullptr;
+}
+
+static void nativeBindNull(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                           jlong statementPtr, jint index) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = sqlite3_bind_null(statement, index);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db, nullptr);
+  }
+}
+
+static void nativeBindLong(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                           jlong statementPtr, jint index, jlong value) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = sqlite3_bind_int64(statement, index, value);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db, nullptr);
+  }
+}
+
+static void nativeBindDouble(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                             jlong statementPtr, jint index, jdouble value) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = sqlite3_bind_double(statement, index, value);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db, nullptr);
+  }
+}
+
+static void nativeBindString(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                             jlong statementPtr, jint index,
+                             jstring valueString) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  jsize valueLength = env->GetStringLength(valueString);
+  const jchar* value = env->GetStringCritical(valueString, nullptr);
+  int err = sqlite3_bind_text16(statement, index, value,
+                                valueLength * sizeof(jchar), SQLITE_TRANSIENT);
+  env->ReleaseStringCritical(valueString, value);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db, nullptr);
+  }
+}
+
+static void nativeBindBlob(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                           jlong statementPtr, jint index,
+                           jbyteArray valueArray) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  jsize valueLength = env->GetArrayLength(valueArray);
+  jbyte* value =
+      static_cast<jbyte*>(env->GetPrimitiveArrayCritical(valueArray, nullptr));
+  int err =
+      sqlite3_bind_blob(statement, index, value, valueLength, SQLITE_TRANSIENT);
+  env->ReleasePrimitiveArrayCritical(valueArray, value, JNI_ABORT);
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db, nullptr);
+  }
+}
+
+static void nativeResetStatementAndClearBindings(JNIEnv* env, jclass clazz,
+                                                 jlong connectionPtr,
+                                                 jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = sqlite3_reset(statement);
+  if (err == SQLITE_OK) {
+    err = sqlite3_clear_bindings(statement);
+  }
+  if (err != SQLITE_OK) {
+    throw_sqlite3_exception(env, connection->db, nullptr);
+  }
+}
+
+static int executeNonQuery(JNIEnv* env, SQLiteConnection* connection,
+                           sqlite3_stmt* statement, bool isPragmaStmt) {
+  int rc = sqlite3_step(statement);
+  if (isPragmaStmt) {
+    while (rc == SQLITE_ROW) {
+      rc = sqlite3_step(statement);
+    }
+  }
+  if (rc == SQLITE_ROW) {
+    throw_sqlite3_exception(env,
+                            "Queries can be performed using SQLiteDatabase "
+                            "query or rawQuery methods only.");
+  } else if (rc != SQLITE_DONE) {
+    throw_sqlite3_exception(env, connection->db);
+  }
+  return rc;
+}
+
+static void nativeExecute(JNIEnv* env, jclass clazz, jlong connectionPtr,
+                          jlong statementPtr, jboolean isPragmaStmt) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  executeNonQuery(env, connection, statement, isPragmaStmt);
+}
+
+static jint nativeExecuteForChangedRowCount(JNIEnv* env, jclass clazz,
+                                            jlong connectionPtr,
+                                            jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = executeNonQuery(env, connection, statement, false);
+  return err == SQLITE_DONE ? sqlite3_changes(connection->db) : -1;
+}
+
+static jlong nativeExecuteForLastInsertedRowId(JNIEnv* env, jclass clazz,
+                                               jlong connectionPtr,
+                                               jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = executeNonQuery(env, connection, statement, false);
+  return err == SQLITE_DONE && sqlite3_changes(connection->db) > 0
+             ? sqlite3_last_insert_rowid(connection->db)
+             : -1;
+}
+
+static int executeOneRowQuery(JNIEnv* env, SQLiteConnection* connection,
+                              sqlite3_stmt* statement) {
+  int err = sqlite3_step(statement);
+  if (err != SQLITE_ROW) {
+    throw_sqlite3_exception(env, connection->db);
+  }
+  return err;
+}
+
+static jlong nativeExecuteForLong(JNIEnv* env, jclass clazz,
+                                  jlong connectionPtr, jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = executeOneRowQuery(env, connection, statement);
+  if (err == SQLITE_ROW && sqlite3_column_count(statement) >= 1) {
+    return sqlite3_column_int64(statement, 0);
+  }
+  return -1;
+}
+
+static jstring nativeExecuteForString(JNIEnv* env, jclass clazz,
+                                      jlong connectionPtr, jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = executeOneRowQuery(env, connection, statement);
+  if (err == SQLITE_ROW && sqlite3_column_count(statement) >= 1) {
+    const jchar* text =
+        static_cast<const jchar*>(sqlite3_column_text16(statement, 0));
+    if (text) {
+      size_t length = sqlite3_column_bytes16(statement, 0) / sizeof(jchar);
+      return env->NewString(text, length);
+    }
+  }
+  return nullptr;
+}
+
+#if defined(_WIN32)
+static int createAshmemRegionWithData(JNIEnv* env, const void* data,
+                                      size_t length) {
+  jniThrowIOException(env, -1);
+  return -1;
+}
+#else
+static int createAshmemRegionWithData(JNIEnv* env, const void* data,
+                                      size_t length) {
+  int error = 0;
+  int fd = ashmem_create_region(nullptr, length);
+  if (fd < 0) {
+    error = errno;
+    ALOGE("ashmem_create_region failed: %s", strerror(error));
+  } else {
+    if (length > 0) {
+      void* ptr =
+          mmap(nullptr, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+      if (ptr == MAP_FAILED) {
+        error = errno;
+        ALOGE("mmap failed: %s", strerror(error));
+      } else {
+        memcpy(ptr, data, length);
+        munmap(ptr, length);
+      }
+    }
+
+    if (!error) {
+      if (ashmem_set_prot_region(fd, PROT_READ) < 0) {
+        error = errno;
+        ALOGE("ashmem_set_prot_region failed: %s", strerror(errno));
+      } else {
+        return fd;
+      }
+    }
+
+    close(fd);
+  }
+
+  jniThrowIOException(env, error);
+  return -1;
+}
+#endif
+
+static jint nativeExecuteForBlobFileDescriptor(JNIEnv* env, jclass clazz,
+                                               jlong connectionPtr,
+                                               jlong statementPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+
+  int err = executeOneRowQuery(env, connection, statement);
+  if (err == SQLITE_ROW && sqlite3_column_count(statement) >= 1) {
+    const void* blob = sqlite3_column_blob(statement, 0);
+    if (blob) {
+      int length = sqlite3_column_bytes(statement, 0);
+      if (length >= 0) {
+        return createAshmemRegionWithData(env, blob, length);
+      }
+    }
+  }
+  return -1;
+}
+
+enum CopyRowResult {
+  CPR_OK,
+  CPR_FULL,
+  CPR_ERROR,
+};
+
+static CopyRowResult copyRow(JNIEnv* env, CursorWindow* window,
+                             sqlite3_stmt* statement, int numColumns,
+                             int startPos, int addedRows) {
+  // Allocate a new field directory for the row.
+  status_t status = window->allocRow();
+  if (status) {
+    LOG_WINDOW("Failed allocating fieldDir at startPos %d row %d, error=%d",
+               startPos, addedRows, status);
+    return CPR_FULL;
+  }
+
+  // Pack the row into the window.
+  CopyRowResult result = CPR_OK;
+  for (int i = 0; i < numColumns; i++) {
+    int type = sqlite3_column_type(statement, i);
+    if (type == SQLITE_TEXT) {
+      // TEXT data
+      const char* text =
+          reinterpret_cast<const char*>(sqlite3_column_text(statement, i));
+      // SQLite does not include the NULL terminator in size, but does
+      // ensure all strings are NULL terminated, so increase size by
+      // one to make sure we store the terminator.
+      size_t sizeIncludingNull = sqlite3_column_bytes(statement, i) + 1;
+      status = window->putString(addedRows, i, text, sizeIncludingNull);
+      if (status) {
+        LOG_WINDOW("Failed allocating %zu bytes for text at %d,%d, error=%d",
+                   sizeIncludingNull, startPos + addedRows, i, status);
+        result = CPR_FULL;
+        break;
+      }
+      LOG_WINDOW("%d,%d is TEXT with %zu bytes", startPos + addedRows, i,
+                 sizeIncludingNull);
+    } else if (type == SQLITE_INTEGER) {
+      // INTEGER data
+      int64_t value = sqlite3_column_int64(statement, i);
+      status = window->putLong(addedRows, i, value);
+      if (status) {
+        LOG_WINDOW("Failed allocating space for a long in column %d, error=%d",
+                   i, status);
+        result = CPR_FULL;
+        break;
+      }
+      LOG_WINDOW("%d,%d is INTEGER %" PRId64, startPos + addedRows, i, value);
+    } else if (type == SQLITE_FLOAT) {
+      // FLOAT data
+      double value = sqlite3_column_double(statement, i);
+      status = window->putDouble(addedRows, i, value);
+      if (status) {
+        LOG_WINDOW(
+            "Failed allocating space for a double in column %d, error=%d", i,
+            status);
+        result = CPR_FULL;
+        break;
+      }
+      LOG_WINDOW("%d,%d is FLOAT %lf", startPos + addedRows, i, value);
+    } else if (type == SQLITE_BLOB) {
+      // BLOB data
+      const void* blob = sqlite3_column_blob(statement, i);
+      size_t size = sqlite3_column_bytes(statement, i);
+      status = window->putBlob(addedRows, i, blob, size);
+      if (status) {
+        LOG_WINDOW("Failed allocating %zu bytes for blob at %d,%d, error=%d",
+                   size, startPos + addedRows, i, status);
+        result = CPR_FULL;
+        break;
+      }
+      LOG_WINDOW("%d,%d is Blob with %zu bytes", startPos + addedRows, i, size);
+    } else if (type == SQLITE_NULL) {
+      // NULL field
+      status = window->putNull(addedRows, i);
+      if (status) {
+        LOG_WINDOW("Failed allocating space for a null in column %d, error=%d",
+                   i, status);
+        result = CPR_FULL;
+        break;
+      }
+
+      LOG_WINDOW("%d,%d is NULL", startPos + addedRows, i);
+    } else {
+      // Unknown data
+      ALOGE("Unknown column type when filling database window");
+      throw_sqlite3_exception(env, "Unknown column type when filling window");
+      result = CPR_ERROR;
+      break;
+    }
+  }
+
+  // Free the last row if it was not successfully copied.
+  if (result != CPR_OK) {
+    window->freeLastRow();
+  }
+  return result;
+}
+
+static jlong nativeExecuteForCursorWindow(JNIEnv* env, jclass clazz,
+                                          jlong connectionPtr,
+                                          jlong statementPtr, jlong windowPtr,
+                                          jint startPos, jint requiredPos,
+                                          jboolean countAllRows) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  sqlite3_stmt* statement = reinterpret_cast<sqlite3_stmt*>(statementPtr);
+  CursorWindow* window = reinterpret_cast<CursorWindow*>(windowPtr);
+
+  status_t status = window->clear();
+  if (status) {
+    String8 msg;
+    msg.appendFormat("Failed to clear the cursor window, status=%d", status);
+    throw_sqlite3_exception(env, connection->db, msg.string());
+    return 0;
+  }
+
+  int numColumns = sqlite3_column_count(statement);
+  status = window->setNumColumns(numColumns);
+  if (status) {
+    String8 msg;
+    msg.appendFormat(
+        "Failed to set the cursor window column count to %d, status=%d",
+        numColumns, status);
+    throw_sqlite3_exception(env, connection->db, msg.string());
+    return 0;
+  }
+
+  int retryCount = 0;
+  int totalRows = 0;
+  int addedRows = 0;
+  bool windowFull = false;
+  bool gotException = false;
+  while (!gotException && (!windowFull || countAllRows)) {
+    int err = sqlite3_step(statement);
+    if (err == SQLITE_ROW) {
+      LOG_WINDOW("Stepped statement %p to row %d", statement, totalRows);
+      retryCount = 0;
+      totalRows += 1;
+
+      // Skip the row if the window is full or we haven't reached the start
+      // position yet.
+      if (startPos >= totalRows || windowFull) {
+        continue;
+      }
+
+      CopyRowResult cpr =
+          copyRow(env, window, statement, numColumns, startPos, addedRows);
+      if (cpr == CPR_FULL && addedRows && startPos + addedRows <= requiredPos) {
+        // We filled the window before we got to the one row that we really
+        // wanted. Clear the window and start filling it again from here.
+        // TODO: Would be nicer if we could progressively replace earlier rows.
+        window->clear();
+        window->setNumColumns(numColumns);
+        startPos += addedRows;
+        addedRows = 0;
+        cpr = copyRow(env, window, statement, numColumns, startPos, addedRows);
+      }
+
+      if (cpr == CPR_OK) {
+        addedRows += 1;
+      } else if (cpr == CPR_FULL) {
+        windowFull = true;
+      } else {
+        gotException = true;
+      }
+    } else if (err == SQLITE_DONE) {
+      // All rows processed, bail
+      LOG_WINDOW("Processed all rows");
+      break;
+    } else if (err == SQLITE_LOCKED || err == SQLITE_BUSY) {
+      // The table is locked, retry
+      LOG_WINDOW("Database locked, retrying");
+      if (retryCount > 50) {
+        ALOGE("Bailing on database busy retry");
+        throw_sqlite3_exception(env, connection->db, "retrycount exceeded");
+        gotException = true;
+      } else {
+        // Sleep to give the thread holding the lock a chance to finish
+        usleep(1000);
+        retryCount++;
+      }
+    } else {
+      throw_sqlite3_exception(env, connection->db);
+      gotException = true;
+    }
+  }
+
+  LOG_WINDOW(
+      "Resetting statement %p after fetching %d rows and adding %d rows "
+      "to the window in %zu bytes",
+      statement, totalRows, addedRows, window->size() - window->freeSpace());
+  sqlite3_reset(statement);
+
+  // Report the total number of rows on request.
+  if (startPos > totalRows) {
+    ALOGE("startPos %d > actual rows %d", startPos, totalRows);
+  }
+  if (totalRows > 0 && addedRows == 0) {
+    String8 msg;
+    msg.appendFormat(
+        "Row too big to fit into CursorWindow requiredPos=%d, totalRows=%d",
+        requiredPos, totalRows);
+    throw_sqlite3_exception(env, SQLITE_TOOBIG, nullptr, msg.string());
+    return 0;
+  }
+
+  jlong result = jlong(startPos) << 32 | jlong(totalRows);
+  return result;
+}
+
+static jint nativeGetDbLookaside(JNIEnv* env, jobject clazz,
+                                 jlong connectionPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+
+  int cur = -1;
+  int unused;
+  sqlite3_db_status(connection->db, SQLITE_DBSTATUS_LOOKASIDE_USED, &cur,
+                    &unused, 0);
+  return cur;
+}
+
+static void nativeCancel(JNIEnv* env, jobject clazz, jlong connectionPtr) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  connection->canceled = true;
+}
+
+static void nativeResetCancel(JNIEnv* env, jobject clazz, jlong connectionPtr,
+                              jboolean cancelable) {
+  SQLiteConnection* connection =
+      reinterpret_cast<SQLiteConnection*>(connectionPtr);
+  connection->canceled = false;
+
+  if (cancelable) {
+    sqlite3_progress_handler(connection->db, 4, sqliteProgressHandlerCallback,
+                             connection);
+  } else {
+    sqlite3_progress_handler(connection->db, 0, nullptr, nullptr);
+  }
+}
+
+static const JNINativeMethod sMethods[] = {
+    /* name, signature, funcPtr */
+    {const_cast<char*>("nativeOpen"),
+     const_cast<char*>("(Ljava/lang/String;ILjava/lang/String;ZZII)J"),
+     reinterpret_cast<void*>(nativeOpen)},
+    {const_cast<char*>("nativeClose"), const_cast<char*>("(J)V"),
+     reinterpret_cast<void*>(nativeClose)},
+    {const_cast<char*>("nativeRegisterCustomScalarFunction"),
+     const_cast<char*>(
+         "(JLjava/lang/String;Ljava/util/function/UnaryOperator;)V"),
+     reinterpret_cast<void*>(nativeRegisterCustomScalarFunction)},
+    {const_cast<char*>("nativeRegisterCustomAggregateFunction"),
+     const_cast<char*>(
+         "(JLjava/lang/String;Ljava/util/function/BinaryOperator;)V"),
+     reinterpret_cast<void*>(nativeRegisterCustomAggregateFunction)},
+    {const_cast<char*>("nativeRegisterLocalizedCollators"),
+     const_cast<char*>("(JLjava/lang/String;)V"),
+     reinterpret_cast<void*>(nativeRegisterLocalizedCollators)},
+    {const_cast<char*>("nativePrepareStatement"),
+     const_cast<char*>("(JLjava/lang/String;)J"),
+     reinterpret_cast<void*>(nativePrepareStatement)},
+    {const_cast<char*>("nativeFinalizeStatement"), const_cast<char*>("(JJ)V"),
+     reinterpret_cast<void*>(nativeFinalizeStatement)},
+    {const_cast<char*>("nativeGetParameterCount"), const_cast<char*>("(JJ)I"),
+     reinterpret_cast<void*>(nativeGetParameterCount)},
+    {const_cast<char*>("nativeIsReadOnly"), const_cast<char*>("(JJ)Z"),
+     reinterpret_cast<void*>(nativeIsReadOnly)},
+    {const_cast<char*>("nativeGetColumnCount"), const_cast<char*>("(JJ)I"),
+     reinterpret_cast<void*>(nativeGetColumnCount)},
+    {const_cast<char*>("nativeGetColumnName"),
+     const_cast<char*>("(JJI)Ljava/lang/String;"),
+     reinterpret_cast<void*>(nativeGetColumnName)},
+    {const_cast<char*>("nativeBindNull"), const_cast<char*>("(JJI)V"),
+     reinterpret_cast<void*>(nativeBindNull)},
+    {const_cast<char*>("nativeBindLong"), const_cast<char*>("(JJIJ)V"),
+     reinterpret_cast<void*>(nativeBindLong)},
+    {const_cast<char*>("nativeBindDouble"), const_cast<char*>("(JJID)V"),
+     reinterpret_cast<void*>(nativeBindDouble)},
+    {const_cast<char*>("nativeBindString"),
+     const_cast<char*>("(JJILjava/lang/String;)V"),
+     reinterpret_cast<void*>(nativeBindString)},
+    {const_cast<char*>("nativeBindBlob"), const_cast<char*>("(JJI[B)V"),
+     reinterpret_cast<void*>(nativeBindBlob)},
+    {const_cast<char*>("nativeResetStatementAndClearBindings"),
+     const_cast<char*>("(JJ)V"),
+     reinterpret_cast<void*>(nativeResetStatementAndClearBindings)},
+    {const_cast<char*>("nativeExecute"), const_cast<char*>("(JJZ)V"),
+     reinterpret_cast<void*>(nativeExecute)},
+    {const_cast<char*>("nativeExecuteForLong"), const_cast<char*>("(JJ)J"),
+     reinterpret_cast<void*>(nativeExecuteForLong)},
+    {const_cast<char*>("nativeExecuteForString"),
+     const_cast<char*>("(JJ)Ljava/lang/String;"),
+     reinterpret_cast<void*>(nativeExecuteForString)},
+    {const_cast<char*>("nativeExecuteForBlobFileDescriptor"),
+     const_cast<char*>("(JJ)I"),
+     reinterpret_cast<void*>(nativeExecuteForBlobFileDescriptor)},
+    {const_cast<char*>("nativeExecuteForChangedRowCount"),
+     const_cast<char*>("(JJ)I"),
+     reinterpret_cast<void*>(nativeExecuteForChangedRowCount)},
+    {const_cast<char*>("nativeExecuteForLastInsertedRowId"),
+     const_cast<char*>("(JJ)J"),
+     reinterpret_cast<void*>(nativeExecuteForLastInsertedRowId)},
+    {const_cast<char*>("nativeExecuteForCursorWindow"),
+     const_cast<char*>("(JJJIIZ)J"),
+     reinterpret_cast<void*>(nativeExecuteForCursorWindow)},
+    {const_cast<char*>("nativeGetDbLookaside"), const_cast<char*>("(J)I"),
+     reinterpret_cast<void*>(nativeGetDbLookaside)},
+    {const_cast<char*>("nativeCancel"), const_cast<char*>("(J)V"),
+     reinterpret_cast<void*>(nativeCancel)},
+    {const_cast<char*>("nativeResetCancel"), const_cast<char*>("(JZ)V"),
+     reinterpret_cast<void*>(nativeResetCancel)},
+};
+
+int register_android_database_SQLiteConnection(JNIEnv* env) {
+  static const char* kSQLiteClass =
+      "org/robolectric/nativeruntime/SQLiteConnectionNatives";
+  ScopedLocalRef<jclass> cls(env, env->FindClass(kSQLiteClass));
+
+  if (cls.get() == nullptr) {
+    ALOGE("jni SQLiteConnection registration failure, class not found '%s'",
+          kSQLiteClass);
+    return JNI_ERR;
+  }
+
+  jclass unaryClazz = env->FindClass("java/util/function/UnaryOperator");
+  gUnaryOperator.apply = env->GetMethodID(
+      unaryClazz, "apply", "(Ljava/lang/Object;)Ljava/lang/Object;");
+
+  jclass binaryClazz = env->FindClass("java/util/function/BinaryOperator");
+  gBinaryOperator.apply = env->GetMethodID(
+      binaryClazz, "apply",
+      "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;");
+
+  const jint count = sizeof(sMethods) / sizeof(sMethods[0]);
+  int status = env->RegisterNatives(cls.get(), sMethods, count);
+  if (status < 0) {
+    ALOGE("jni SQLite registration failure, status: %d", status);
+    return JNI_ERR;
+  }
+
+  return JNI_VERSION_1_4;
+}
+};  // namespace android
diff --git a/nativeruntime/cpp/libcutils/CMakeLists.txt b/nativeruntime/cpp/libcutils/CMakeLists.txt
new file mode 100644
index 0000000..0c71a0a
--- /dev/null
+++ b/nativeruntime/cpp/libcutils/CMakeLists.txt
@@ -0,0 +1,8 @@
+cmake_minimum_required(VERSION 3.10)
+
+project(libcutils)
+
+include_directories(include)
+include_directories(../base/include)
+
+add_library(cutils STATIC ashmem.cpp)
diff --git a/nativeruntime/cpp/libcutils/ashmem.cpp b/nativeruntime/cpp/libcutils/ashmem.cpp
new file mode 100644
index 0000000..1b1fd71
--- /dev/null
+++ b/nativeruntime/cpp/libcutils/ashmem.cpp
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2008 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libcutils/ashmem-host.cpp
+
+#include "cutils/ashmem.h"
+
+/*
+ * Implementation of the user-space ashmem API for the simulator, which lacks
+ * an ashmem-enabled kernel. See ashmem-dev.c for the real ashmem-based version.
+ */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <time.h>
+#include <unistd.h>
+
+// bionic and glibc both have TEMP_FAILURE_RETRY, but some (e.g. Mac OS) do not.
+#include "android-base/macros.h"
+
+static bool ashmem_validate_stat(int fd, struct stat* buf) {
+  int result = fstat(fd, buf);
+  if (result == -1) {
+    return false;
+  }
+
+  /*
+   * Check if this is an "ashmem" region.
+   * TODO: This is very hacky, and can easily break.
+   * We need some reliable indicator.
+   */
+  if (!(buf->st_nlink == 0 && S_ISREG(buf->st_mode))) {
+    errno = ENOTTY;
+    return false;
+  }
+  return true;
+}
+
+int ashmem_valid(int fd) {
+  struct stat buf;
+  return ashmem_validate_stat(fd, &buf);
+}
+
+int ashmem_create_region(const char* /*ignored*/, size_t size) {
+  char pattern[PATH_MAX];
+  snprintf(pattern, sizeof(pattern), "/tmp/android-ashmem-%d-XXXXXXXXX",
+           getpid());
+  int fd = mkstemp(pattern);
+  if (fd == -1) return -1;
+
+  unlink(pattern);
+
+  if (TEMP_FAILURE_RETRY(ftruncate(fd, size)) == -1) {
+    close(fd);
+    return -1;
+  }
+
+  return fd;
+}
+
+int ashmem_set_prot_region(int /*fd*/, int /*prot*/) { return 0; }
+
+int ashmem_pin_region(int /*fd*/, size_t /*offset*/, size_t /*len*/) {
+  return 0 /*ASHMEM_NOT_PURGED*/;
+}
+
+int ashmem_unpin_region(int /*fd*/, size_t /*offset*/, size_t /*len*/) {
+  return 0 /*ASHMEM_IS_UNPINNED*/;
+}
+
+int ashmem_get_size_region(int fd) {
+  struct stat buf;
+  if (!ashmem_validate_stat(fd, &buf)) {
+    return -1;
+  }
+
+  return buf.st_size;
+}
diff --git a/nativeruntime/cpp/libcutils/include/cutils/ashmem.h b/nativeruntime/cpp/libcutils/include/cutils/ashmem.h
new file mode 100644
index 0000000..cc70b0a
--- /dev/null
+++ b/nativeruntime/cpp/libcutils/include/cutils/ashmem.h
@@ -0,0 +1,37 @@
+/* cutils/ashmem.h
+ **
+ ** Copyright 2008 The Android Open Source Project
+ **
+ ** This file is dual licensed.  It may be redistributed and/or modified
+ ** under the terms of the Apache 2.0 License OR version 2 of the GNU
+ ** General Public License.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libcutils/include/cutils/ashmem.h
+
+#ifndef _CUTILS_ASHMEM_H
+#define _CUTILS_ASHMEM_H
+
+#include <stddef.h>
+
+#if defined(__BIONIC__)
+#include <linux/ashmem.h>
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+int ashmem_valid(int fd);
+int ashmem_create_region(const char *name, size_t size);
+int ashmem_set_prot_region(int fd, int prot);
+int ashmem_pin_region(int fd, size_t offset, size_t len);
+int ashmem_unpin_region(int fd, size_t offset, size_t len);
+int ashmem_get_size_region(int fd);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* _CUTILS_ASHMEM_H */
diff --git a/nativeruntime/cpp/liblog/CMakeLists.txt b/nativeruntime/cpp/liblog/CMakeLists.txt
new file mode 100644
index 0000000..520d4e2
--- /dev/null
+++ b/nativeruntime/cpp/liblog/CMakeLists.txt
@@ -0,0 +1,5 @@
+cmake_minimum_required(VERSION 3.10)
+
+project(log)
+
+add_library(log STATIC log.c)
diff --git a/nativeruntime/cpp/liblog/include/log/log.h b/nativeruntime/cpp/liblog/include/log/log.h
new file mode 100644
index 0000000..8bf9a92
--- /dev/null
+++ b/nativeruntime/cpp/liblog/include/log/log.h
@@ -0,0 +1,78 @@
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:device/generic/goldfish-opengl/fuchsia/include/cutils/log.h
+
+#include <stdint.h>
+
+#ifndef __CUTILS_LOG_H__
+#define __CUTILS_LOG_H__
+
+#ifndef LOG_TAG
+#define LOG_TAG nullptr
+#endif
+
+enum {
+  ANDROID_LOG_UNKNOWN = 0,
+  ANDROID_LOG_DEFAULT,
+  ANDROID_LOG_VERBOSE,
+  ANDROID_LOG_DEBUG,
+  ANDROID_LOG_INFO,
+  ANDROID_LOG_WARN,
+  ANDROID_LOG_ERROR,
+  ANDROID_LOG_FATAL,
+  ANDROID_LOG_SILENT,
+};
+
+#define android_printLog(prio, tag, format, ...) \
+  __android_log_print(prio, tag, "[prio %d] " format, prio, ##__VA_ARGS__)
+
+#define LOG_PRI(priority, tag, ...) android_printLog(priority, tag, __VA_ARGS__)
+#define ALOG(priority, tag, ...) LOG_PRI(ANDROID_##priority, tag, __VA_ARGS__)
+
+#define __android_second(dummy, second, ...) second
+#define __android_rest(first, ...) , ##__VA_ARGS__
+
+#define android_printAssert(condition, tag, format, ...)                \
+  __android_log_assert(condition, tag, "assert: condition: %s " format, \
+                       condition, ##__VA_ARGS__)
+
+#define LOG_ALWAYS_FATAL_IF(condition, ...)                              \
+  ((condition)                                                           \
+       ? ((void)android_printAssert(#condition, LOG_TAG, ##__VA_ARGS__)) \
+       : (void)0)
+
+#define LOG_ALWAYS_FATAL(...) \
+  (((void)android_printAssert(NULL, LOG_TAG, ##__VA_ARGS__)))
+
+#define ALOGV(...) ((void)ALOG(LOG_VERBOSE, LOG_TAG, __VA_ARGS__))
+#define ALOGE(...) ((void)ALOG(LOG_ERROR, LOG_TAG, __VA_ARGS__))
+#define ALOGW(...) ((void)ALOG(LOG_WARN, LOG_TAG, __VA_ARGS__))
+#define ALOGD(...) ((void)ALOG(LOG_DEBUG, LOG_TAG, __VA_ARGS__))
+
+#define LOG_FATAL_IF(cond, ...) LOG_ALWAYS_FATAL_IF(cond, ##__VA_ARGS__)
+
+#define LOG_FATAL(...) LOG_ALWAYS_FATAL(__VA_ARGS__)
+
+#define ALOG_ASSERT(cond, ...) LOG_FATAL_IF(!(cond), ##__VA_ARGS__)
+
+#ifndef android_errorWriteLog
+#define android_errorWriteLog(tag, subTag) \
+  __android_log_error_write(tag, subTag, -1, NULL, 0)
+#endif
+
+#ifndef android_errorWriteWithInfoLog
+#define android_errorWriteWithInfoLog(tag, subTag, uid, data, dataLen) \
+  __android_log_error_write(tag, subTag, uid, data, dataLen)
+#endif
+
+extern "C" {
+
+int __android_log_print(int priority, const char* tag, const char* format, ...);
+
+[[noreturn]] void __android_log_assert(const char* condition, const char* tag,
+                                       const char* format, ...);
+
+int __android_log_error_write(int tag, const char* subTag, int32_t uid,
+                              const char* data, uint32_t dataLen);
+}
+
+#endif
diff --git a/nativeruntime/cpp/liblog/log.c b/nativeruntime/cpp/liblog/log.c
new file mode 100644
index 0000000..271a2d4
--- /dev/null
+++ b/nativeruntime/cpp/liblog/log.c
@@ -0,0 +1,35 @@
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdarg.h>
+
+int __android_log_print(int prio, const char* tag, const char* fmt, ...) {
+  ((void)prio);
+  ((void)tag);
+  ((void)fmt);
+
+  if (prio >= 4) {
+    va_list args;
+    va_start(args, fmt);
+    fprintf(stderr, "%s: ", tag);
+    fprintf(stderr, fmt, args);
+    va_end(args);
+  }
+  return 0;
+}
+
+int __android_log_error_write(int tag, const char* subTag, int32_t uid,
+                              const char* data, uint32_t dataLen) {
+  ((void)tag);
+  return 0;
+}
+
+void __android_log_assert(const char* condition, const char* tag,
+                                       const char* format, ...) {
+  va_list args;
+  va_start(args, format);
+  fprintf(stderr, "%s: ", tag);
+  fprintf(stderr, format, args);
+  va_end(args);
+  abort();
+}
diff --git a/nativeruntime/cpp/libnativehelper/include/nativehelper/JNIHelp.h b/nativeruntime/cpp/libnativehelper/include/nativehelper/JNIHelp.h
new file mode 100644
index 0000000..1dd44b6
--- /dev/null
+++ b/nativeruntime/cpp/libnativehelper/include/nativehelper/JNIHelp.h
@@ -0,0 +1,552 @@
+/*
+ * Copyright (C) 2007 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/master:libnativehelper/include/nativehelper/JNIHelp.h
+
+/*
+ * JNI helper functions.
+ *
+ * This file may be included by C or C++ code, which is trouble because jni.h
+ * uses different typedefs for JNIEnv in each language.
+ */
+#ifndef NATIVEHELPER_JNIHELP_H_
+#define NATIVEHELPER_JNIHELP_H_
+
+#include <errno.h>
+#include <jni.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/cdefs.h>
+#include <unistd.h>
+
+// #include <android/log.h>
+
+// Avoid formatting this as it must match webview's usage
+// (webview/graphics_utils.cpp).
+// clang-format off
+#ifndef NELEM
+#define NELEM(x) ((int) (sizeof(x) / sizeof((x)[0])))
+#endif
+// clang-format on
+
+/*
+ * For C++ code, we provide inlines that map to the C functions.  g++ always
+ * inlines these, even on non-optimized builds.
+ */
+#if defined(__cplusplus)
+
+namespace android::jnihelp {
+struct [[maybe_unused]] ExpandableString {
+  size_t dataSize;  // The length of the C string data (not including the
+                    // null-terminator).
+  char* data;       // The C string data.
+};
+
+[[maybe_unused]] static void ExpandableStringInitialize(
+    struct ExpandableString* s) {
+  memset(s, 0, sizeof(*s));
+}
+
+[[maybe_unused]] static void ExpandableStringRelease(
+    struct ExpandableString* s) {
+  free(s->data);
+  memset(s, 0, sizeof(*s));
+}
+
+[[maybe_unused]] static bool ExpandableStringAppend(struct ExpandableString* s,
+                                                    const char* text) {
+  size_t textSize = strlen(text);
+  size_t requiredSize = s->dataSize + textSize + 1;
+  char* data = static_cast<char*>(realloc(s->data, requiredSize));
+  if (data == nullptr) {
+    return false;
+  }
+  s->data = data;
+  memcpy(s->data + s->dataSize, text, textSize + 1);
+  s->dataSize += textSize;
+  return true;
+}
+
+[[maybe_unused]] static bool ExpandableStringAssign(struct ExpandableString* s,
+                                                    const char* text) {
+  ExpandableStringRelease(s);
+  return ExpandableStringAppend(s, text);
+}
+
+[[maybe_unused]] inline const char* platformStrError(int errnum, char* buf,
+                                                     size_t buflen) {
+#ifdef _WIN32
+  strerror_s(buf, buflen, errnum);
+  return buf;
+#elif defined(__USE_GNU)
+  // char *strerror_r(int errnum, char *buf, size_t buflen);  /* GNU-specific */
+  return strerror_r(errnum, buf, buflen);
+#else
+  // int strerror_r(int errnum, char *buf, size_t buflen);  /* XSI-compliant */
+  int rc = strerror_r(errnum, buf, buflen);
+  if (rc != 0) {
+    snprintf(buf, buflen, "errno %d", errnum);
+  }
+  return buf;
+#endif
+}
+
+[[maybe_unused]] static jmethodID FindMethod(JNIEnv* env, const char* className,
+                                             const char* methodName,
+                                             const char* descriptor) {
+  // This method is only valid for classes in the core library which are
+  // not unloaded during the lifetime of managed code execution.
+  jclass clazz = env->FindClass(className);
+  jmethodID methodId = env->GetMethodID(clazz, methodName, descriptor);
+  env->DeleteLocalRef(clazz);
+  return methodId;
+}
+
+[[maybe_unused]] static bool AppendJString(JNIEnv* env, jstring text,
+                                           struct ExpandableString* dst) {
+  const char* utfText = env->GetStringUTFChars(text, nullptr);
+  if (utfText == nullptr) {
+    return false;
+  }
+  bool success = ExpandableStringAppend(dst, utfText);
+  env->ReleaseStringUTFChars(text, utfText);
+  return success;
+}
+
+/*
+ * Returns a human-readable summary of an exception object.  The buffer will
+ * be populated with the "binary" class name and, if present, the
+ * exception message.
+ */
+[[maybe_unused]] static bool GetExceptionSummary(JNIEnv* env, jthrowable thrown,
+                                                 struct ExpandableString* dst) {
+  // Summary is <exception_class_name> ": " <exception_message>
+  jclass exceptionClass = env->GetObjectClass(thrown);  // Always succeeds
+  jmethodID getName =
+      FindMethod(env, "java/lang/Class", "getName", "()Ljava/lang/String;");
+  jstring className = (jstring)env->CallObjectMethod(exceptionClass, getName);
+  if (className == nullptr) {
+    ExpandableStringAssign(dst, "<error getting class name>");
+    env->ExceptionClear();
+    env->DeleteLocalRef(exceptionClass);
+    return false;
+  }
+  env->DeleteLocalRef(exceptionClass);
+  exceptionClass = nullptr;
+
+  if (!AppendJString(env, className, dst)) {
+    ExpandableStringAssign(dst, "<error getting class name UTF-8>");
+    env->ExceptionClear();
+    env->DeleteLocalRef(className);
+    return false;
+  }
+  env->DeleteLocalRef(className);
+  className = nullptr;
+
+  jmethodID getMessage = FindMethod(env, "java/lang/Throwable", "getMessage",
+                                    "()Ljava/lang/String;");
+  jstring message = (jstring)env->CallObjectMethod(thrown, getMessage);
+  if (message == nullptr) {
+    return true;
+  }
+
+  bool success =
+      (ExpandableStringAppend(dst, ": ") && AppendJString(env, message, dst));
+  if (!success) {
+    // Two potential reasons for reaching here:
+    //
+    // 1. managed heap allocation failure (OOME).
+    // 2. native heap allocation failure for the storage in |dst|.
+    //
+    // Attempt to append failure notification, okay to fail, |dst| contains the
+    // class name of |thrown|.
+    ExpandableStringAppend(dst, "<error getting message>");
+    // Clear OOME if present.
+    env->ExceptionClear();
+  }
+  env->DeleteLocalRef(message);
+  message = nullptr;
+  return success;
+}
+
+[[maybe_unused]] static jobject NewStringWriter(JNIEnv* env) {
+  jclass clazz = env->FindClass("java/io/StringWriter");
+  jmethodID init = env->GetMethodID(clazz, "<init>", "()V");
+  jobject instance = env->NewObject(clazz, init);
+  env->DeleteLocalRef(clazz);
+  return instance;
+}
+
+[[maybe_unused]] static jstring StringWriterToString(JNIEnv* env,
+                                                     jobject stringWriter) {
+  jmethodID toString = FindMethod(env, "java/io/StringWriter", "toString",
+                                  "()Ljava/lang/String;");
+  return (jstring)env->CallObjectMethod(stringWriter, toString);
+}
+
+[[maybe_unused]] static jobject NewPrintWriter(JNIEnv* env, jobject writer) {
+  jclass clazz = env->FindClass("java/io/PrintWriter");
+  jmethodID init = env->GetMethodID(clazz, "<init>", "(Ljava/io/Writer;)V");
+  jobject instance = env->NewObject(clazz, init, writer);
+  env->DeleteLocalRef(clazz);
+  return instance;
+}
+
+[[maybe_unused]] static bool GetStackTrace(JNIEnv* env, jthrowable thrown,
+                                           struct ExpandableString* dst) {
+  // This function is equivalent to the following Java snippet:
+  //   StringWriter sw = new StringWriter();
+  //   PrintWriter pw = new PrintWriter(sw);
+  //   thrown.printStackTrace(pw);
+  //   String trace = sw.toString();
+  //   return trace;
+  jobject sw = NewStringWriter(env);
+  if (sw == nullptr) {
+    return false;
+  }
+
+  jobject pw = NewPrintWriter(env, sw);
+  if (pw == nullptr) {
+    env->DeleteLocalRef(sw);
+    return false;
+  }
+
+  jmethodID printStackTrace =
+      FindMethod(env, "java/lang/Throwable", "printStackTrace",
+                 "(Ljava/io/PrintWriter;)V");
+  env->CallVoidMethod(thrown, printStackTrace, pw);
+
+  jstring trace = StringWriterToString(env, sw);
+
+  env->DeleteLocalRef(pw);
+  pw = nullptr;
+  env->DeleteLocalRef(sw);
+  sw = nullptr;
+
+  if (trace == nullptr) {
+    return false;
+  }
+
+  bool success = AppendJString(env, trace, dst);
+  env->DeleteLocalRef(trace);
+  return success;
+}
+
+[[maybe_unused]] static void GetStackTraceOrSummary(
+    JNIEnv* env, jthrowable thrown, struct ExpandableString* dst) {
+  // This method attempts to get a stack trace or summary info for an exception.
+  // The exception may be provided in the |thrown| argument to this function.
+  // If |thrown| is NULL, then any pending exception is used if it exists.
+
+  // Save pending exception, callees may raise other exceptions. Any pending
+  // exception is rethrown when this function exits.
+  jthrowable pendingException = env->ExceptionOccurred();
+  if (pendingException != nullptr) {
+    env->ExceptionClear();
+  }
+
+  if (thrown == nullptr) {
+    if (pendingException == nullptr) {
+      ExpandableStringAssign(dst, "<no pending exception>");
+      return;
+    }
+    thrown = pendingException;
+  }
+
+  if (!GetStackTrace(env, thrown, dst)) {
+    // GetStackTrace may have raised an exception, clear it since it's not for
+    // the caller.
+    env->ExceptionClear();
+    GetExceptionSummary(env, thrown, dst);
+  }
+
+  if (pendingException != nullptr) {
+    // Re-throw the pending exception present when this method was called.
+    env->Throw(pendingException);
+    env->DeleteLocalRef(pendingException);
+  }
+}
+
+[[maybe_unused]] static void DiscardPendingException(JNIEnv* env,
+                                                     const char* className) {
+  jthrowable exception = env->ExceptionOccurred();
+  env->ExceptionClear();
+  if (exception == nullptr) {
+    return;
+  }
+
+  struct ExpandableString summary;
+  ExpandableStringInitialize(&summary);
+  GetExceptionSummary(env, exception, &summary);
+  //  const char* details = (summary.data != NULL) ? summary.data : "Unknown";
+  //  __android_log_print(ANDROID_LOG_WARN, "JNIHelp",
+  //                      "Discarding pending exception (%s) to throw %s",
+  //                      details, className);
+  ExpandableStringRelease(&summary);
+  env->DeleteLocalRef(exception);
+}
+
+[[maybe_unused]] static int ThrowException(JNIEnv* env, const char* className,
+                                           const char* ctorSig, ...) {
+  int status = -1;
+  jclass exceptionClass = nullptr;
+
+  va_list args;
+  va_start(args, ctorSig);
+
+  DiscardPendingException(env, className);
+
+  {
+    /* We want to clean up local references before returning from this function,
+     * so, regardless of return status, the end block must run. Have the work
+     * done in a
+     * nested block to avoid using any uninitialized variables in the end block.
+     */
+    exceptionClass = env->FindClass(className);
+    if (exceptionClass == nullptr) {
+      //      __android_log_print(ANDROID_LOG_ERROR, "JNIHelp", "Unable to find
+      //      exception class %s",
+      //                          className);
+      /* an exception, most likely ClassNotFoundException, will now be pending
+       */
+      goto end;
+    }
+
+    jmethodID init = env->GetMethodID(exceptionClass, "<init>", ctorSig);
+    if (init == nullptr) {
+      //      __android_log_print(ANDROID_LOG_ERROR, "JNIHelp",
+      //                          "Failed to find constructor for '%s' '%s'",
+      //                          className, ctorSig);
+      goto end;
+    }
+
+    jobject instance = env->NewObjectV(exceptionClass, init, args);
+    if (instance == nullptr) {
+      //      __android_log_print(ANDROID_LOG_ERROR, "JNIHelp", "Failed to
+      //      construct '%s'",
+      //                          className);
+      goto end;
+    }
+
+    if (env->Throw((jthrowable)instance) != JNI_OK) {
+      //      __android_log_print(ANDROID_LOG_ERROR, "JNIHelp", "Failed to throw
+      //      '%s'", className);
+      /* an exception, most likely OOM, will now be pending */
+      goto end;
+    }
+
+    /* everything worked fine, just update status to success and clean up */
+    status = 0;
+  }
+
+end:
+  va_end(args);
+  if (exceptionClass != nullptr) {
+    env->DeleteLocalRef(exceptionClass);
+  }
+  return status;
+}
+
+[[maybe_unused]] static jstring CreateExceptionMsg(JNIEnv* env,
+                                                   const char* msg) {
+  jstring detailMessage = env->NewStringUTF(msg);
+  if (detailMessage == nullptr) {
+    /* Not really much we can do here. We're probably dead in the water,
+    but let's try to stumble on... */
+    env->ExceptionClear();
+  }
+  return detailMessage;
+}
+}  // namespace android::jnihelp
+
+/*
+ * Register one or more native methods with a particular class.  "className"
+ * looks like "java/lang/String". Aborts on failure, returns 0 on success.
+ */
+[[maybe_unused]] static int jniRegisterNativeMethods(
+    JNIEnv* env, const char* className, const JNINativeMethod* methods,
+    int numMethods) {
+  using namespace android::jnihelp;
+  jclass clazz = env->FindClass(className);
+  if (clazz == nullptr) {
+    //    __android_log_assert("clazz == NULL", "JNIHelp",
+    //                         "Native registration unable to find class '%s';
+    //                         aborting...", className);
+  }
+  int result = env->RegisterNatives(clazz, methods, numMethods);
+  env->DeleteLocalRef(clazz);
+  if (result == 0) {
+    return 0;
+  }
+
+  // Failure to register natives is fatal. Try to report the corresponding
+  // exception, otherwise abort with generic failure message.
+  jthrowable thrown = env->ExceptionOccurred();
+  if (thrown != nullptr) {
+    struct ExpandableString summary;
+    ExpandableStringInitialize(&summary);
+    if (GetExceptionSummary(env, thrown, &summary)) {
+      //      __android_log_print(ANDROID_LOG_FATAL, "JNIHelp", "%s",
+      //      summary.data);
+    }
+    ExpandableStringRelease(&summary);
+    env->DeleteLocalRef(thrown);
+  }
+  //  __android_log_print(ANDROID_LOG_FATAL, "JNIHelp",
+  //                      "RegisterNatives failed for '%s'; aborting...",
+  //                      className);
+  return result;
+}
+
+/*
+ * Throw an exception with the specified class and an optional message.
+ *
+ * The "className" argument will be passed directly to FindClass, which
+ * takes strings with slashes (e.g. "java/lang/Object").
+ *
+ * If an exception is currently pending, we log a warning message and
+ * clear it.
+ *
+ * Returns 0 on success, nonzero if something failed (e.g. the exception
+ * class couldn't be found, so *an* exception will still be pending).
+ *
+ * Currently aborts the VM if it can't throw the exception.
+ */
+[[maybe_unused]] static int jniThrowException(JNIEnv* env,
+                                              const char* className,
+                                              const char* msg) {
+  using namespace android::jnihelp;
+  jstring _detailMessage = CreateExceptionMsg(env, msg);
+  int _status =
+      ThrowException(env, className, "(Ljava/lang/String;)V", _detailMessage);
+  if (_detailMessage != nullptr) {
+    env->DeleteLocalRef(_detailMessage);
+  }
+  return _status;
+}
+
+/*
+ * Throw an android.system.ErrnoException, with the given function name and
+ * errno value.
+ */
+[[maybe_unused]] static int jniThrowErrnoException(JNIEnv* env,
+                                                   const char* functionName,
+                                                   int errnum) {
+  using namespace android::jnihelp;
+  jstring _detailMessage = CreateExceptionMsg(env, functionName);
+  int _status =
+      ThrowException(env, "android/system/ErrnoException",
+                     "(Ljava/lang/String;I)V", _detailMessage, errnum);
+  if (_detailMessage != nullptr) {
+    env->DeleteLocalRef(_detailMessage);
+  }
+  return _status;
+}
+
+/*
+ * Throw an exception with the specified class and formatted error message.
+ *
+ * The "className" argument will be passed directly to FindClass, which
+ * takes strings with slashes (e.g. "java/lang/Object").
+ *
+ * If an exception is currently pending, we log a warning message and
+ * clear it.
+ *
+ * Returns 0 on success, nonzero if something failed (e.g. the exception
+ * class couldn't be found, so *an* exception will still be pending).
+ *
+ * Currently aborts the VM if it can't throw the exception.
+ */
+[[maybe_unused]] static int jniThrowExceptionFmt(JNIEnv* env,
+                                                 const char* className,
+                                                 const char* fmt, ...) {
+  va_list args;
+  va_start(args, fmt);
+  char msgBuf[512];
+  vsnprintf(msgBuf, sizeof(msgBuf), fmt, args);
+  va_end(args);
+  return jniThrowException(env, className, msgBuf);
+}
+
+[[maybe_unused]] static int jniThrowNullPointerException(JNIEnv* env,
+                                                         const char* msg) {
+  return jniThrowException(env, "java/lang/NullPointerException", msg);
+}
+
+[[maybe_unused]] static int jniThrowRuntimeException(JNIEnv* env,
+                                                     const char* msg) {
+  return jniThrowException(env, "java/lang/RuntimeException", msg);
+}
+
+[[maybe_unused]] static int jniThrowIOException(JNIEnv* env, int errno_value) {
+  using namespace android::jnihelp;
+  char buffer[80];
+  const char* message = platformStrError(errno_value, buffer, sizeof(buffer));
+  return jniThrowException(env, "java/io/IOException", message);
+}
+
+/*
+ * Returns a Java String object created from UTF-16 data either from jchar or,
+ * if called from C++11, char16_t (a bitwise identical distinct type).
+ */
+[[maybe_unused]] static inline jstring jniCreateString(
+    JNIEnv* env, const jchar* unicodeChars, jsize len) {
+  return env->NewString(unicodeChars, len);
+}
+
+[[maybe_unused]] static inline jstring jniCreateString(
+    JNIEnv* env, const char16_t* unicodeChars, jsize len) {
+  return jniCreateString(env, reinterpret_cast<const jchar*>(unicodeChars),
+                         len);
+}
+
+/*
+ * Log a message and an exception.
+ * If exception is NULL, logs the current exception in the JNI environment.
+ */
+[[maybe_unused]] static void jniLogException(JNIEnv* env, int priority,
+                                             const char* tag,
+                                             jthrowable exception = nullptr) {
+  using namespace android::jnihelp;
+  struct ExpandableString summary;
+  ExpandableStringInitialize(&summary);
+  GetStackTraceOrSummary(env, exception, &summary);
+  //  const char* details = (summary.data != NULL) ? summary.data : "No memory
+  //  to report exception";
+  //  __android_log_write(priority, tag, details);
+  ExpandableStringRelease(&summary);
+}
+
+#else  // defined(__cplusplus)
+
+// ART-internal only methods (not exported), exposed for legacy C users
+
+int jniRegisterNativeMethods(JNIEnv* env, const char* className,
+                             const JNINativeMethod* gMethods, int numMethods);
+
+void jniLogException(JNIEnv* env, int priority, const char* tag,
+                     jthrowable thrown);
+
+int jniThrowException(JNIEnv* env, const char* className, const char* msg);
+
+int jniThrowNullPointerException(JNIEnv* env, const char* msg);
+
+#endif  // defined(__cplusplus)
+
+#endif  // NATIVEHELPER_JNIHELP_H_
diff --git a/nativeruntime/cpp/libnativehelper/include/nativehelper/scoped_local_ref.h b/nativeruntime/cpp/libnativehelper/include/nativehelper/scoped_local_ref.h
new file mode 100644
index 0000000..53e2644
--- /dev/null
+++ b/nativeruntime/cpp/libnativehelper/include/nativehelper/scoped_local_ref.h
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2010 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:libnativehelper/header_only_include/nativehelper/scoped_local_ref.h
+
+#ifndef LIBNATIVEHELPER_HEADER_ONLY_INCLUDE_NATIVEHELPER_SCOPED_LOCAL_REF_H_
+#define LIBNATIVEHELPER_HEADER_ONLY_INCLUDE_NATIVEHELPER_SCOPED_LOCAL_REF_H_
+
+#include <android-base/macros.h>
+
+#include <cstddef>
+
+#include "jni.h"
+// #include "nativehelper_utils.h"
+
+// A smart pointer that deletes a JNI local reference when it goes out of scope.
+template <typename T>
+class ScopedLocalRef {
+ public:
+  ScopedLocalRef(JNIEnv* env, T localRef) : mEnv(env), mLocalRef(localRef) {}
+
+  ScopedLocalRef(ScopedLocalRef&& s) noexcept
+      : mEnv(s.mEnv), mLocalRef(s.release()) {}
+
+  explicit ScopedLocalRef(JNIEnv* env) : mEnv(env), mLocalRef(nullptr) {}
+
+  ~ScopedLocalRef() { reset(); }
+
+  void reset(T ptr = NULL) {
+    if (ptr != mLocalRef) {
+      if (mLocalRef != NULL) {
+        mEnv->DeleteLocalRef(mLocalRef);
+      }
+      mLocalRef = ptr;
+    }
+  }
+
+  T release() __attribute__((warn_unused_result)) {
+    T localRef = mLocalRef;
+    mLocalRef = NULL;
+    return localRef;
+  }
+
+  T get() const { return mLocalRef; }
+
+  // We do not expose an empty constructor as it can easily lead to errors
+  // using common idioms, e.g.:
+  //   ScopedLocalRef<...> ref;
+  //   ref.reset(...);
+
+  // Move assignment operator.
+  ScopedLocalRef& operator=(ScopedLocalRef&& s) noexcept {
+    reset(s.release());
+    mEnv = s.mEnv;
+    return *this;
+  }
+
+  // Allows "if (scoped_ref == nullptr)"
+  bool operator==(std::nullptr_t) const { return mLocalRef == nullptr; }
+
+  // Allows "if (scoped_ref != nullptr)"
+  bool operator!=(std::nullptr_t) const { return mLocalRef != nullptr; }
+
+ private:
+  JNIEnv* mEnv;
+  T mLocalRef;
+
+  DISALLOW_COPY_AND_ASSIGN(ScopedLocalRef);
+};
+
+#endif  // LIBNATIVEHELPER_HEADER_ONLY_INCLUDE_NATIVEHELPER_SCOPED_LOCAL_REF_H_
diff --git a/nativeruntime/cpp/libnativehelper/include/nativehelper/scoped_utf8_chars.h b/nativeruntime/cpp/libnativehelper/include/nativehelper/scoped_utf8_chars.h
new file mode 100644
index 0000000..e2be8ef
--- /dev/null
+++ b/nativeruntime/cpp/libnativehelper/include/nativehelper/scoped_utf8_chars.h
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2010 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:libnativehelper/header_only_include/nativehelper/scoped_utf_chars.h
+
+#ifndef LIBNATIVEHELPER_HEADER_ONLY_INCLUDE_NATIVEHELPER_SCOPED_UTF_CHARS_H_
+#define LIBNATIVEHELPER_HEADER_ONLY_INCLUDE_NATIVEHELPER_SCOPED_UTF_CHARS_H_
+
+#include <android-base/macros.h>
+#include <string.h>
+
+#include "JNIHelp.h"
+#include "jni.h"
+// #include "nativehelper_utils.h"
+
+// A smart pointer that provides read-only access to a Java string's UTF chars.
+// Unlike GetStringUTFChars, we throw NullPointerException rather than abort if
+// passed a null jstring, and c_str will return nullptr.
+// This makes the correct idiom very simple:
+//
+//   ScopedUtfChars name(env, java_name);
+//   if (name.c_str() == nullptr) {
+//     return nullptr;
+//   }
+class ScopedUtfChars {
+ public:
+  ScopedUtfChars(JNIEnv* env, jstring s) : env_(env), string_(s) {
+    if (s == nullptr) {
+      utf_chars_ = nullptr;
+      jniThrowNullPointerException(env, "null");
+    } else {
+      utf_chars_ = env->GetStringUTFChars(s, nullptr);
+    }
+  }
+
+  ScopedUtfChars(ScopedUtfChars&& rhs) noexcept
+      : env_(rhs.env_), string_(rhs.string_), utf_chars_(rhs.utf_chars_) {
+    rhs.env_ = nullptr;
+    rhs.string_ = nullptr;
+    rhs.utf_chars_ = nullptr;
+  }
+
+  ~ScopedUtfChars() {
+    if (utf_chars_) {
+      env_->ReleaseStringUTFChars(string_, utf_chars_);
+    }
+  }
+
+  ScopedUtfChars& operator=(ScopedUtfChars&& rhs) noexcept {
+    if (this != &rhs) {
+      // Delete the currently owned UTF chars.
+      this->~ScopedUtfChars();
+
+      // Move the rhs ScopedUtfChars and zero it out.
+      env_ = rhs.env_;
+      string_ = rhs.string_;
+      utf_chars_ = rhs.utf_chars_;
+      rhs.env_ = nullptr;
+      rhs.string_ = nullptr;
+      rhs.utf_chars_ = nullptr;
+    }
+    return *this;
+  }
+
+  const char* c_str() const { return utf_chars_; }
+
+  size_t size() const { return strlen(utf_chars_); }
+
+  const char& operator[](size_t n) const { return utf_chars_[n]; }
+
+ private:
+  JNIEnv* env_;
+  jstring string_;
+  const char* utf_chars_;
+
+  DISALLOW_COPY_AND_ASSIGN(ScopedUtfChars);
+};
+
+#endif  // LIBNATIVEHELPER_HEADER_ONLY_INCLUDE_NATIVEHELPER_SCOPED_UTF_CHARS_H_
diff --git a/nativeruntime/cpp/libutils/CMakeLists.txt b/nativeruntime/cpp/libutils/CMakeLists.txt
new file mode 100644
index 0000000..46251c5
--- /dev/null
+++ b/nativeruntime/cpp/libutils/CMakeLists.txt
@@ -0,0 +1,17 @@
+cmake_minimum_required(VERSION 3.10)
+# Some libutils headers require C++17
+set (CMAKE_CXX_STANDARD 17)
+
+project(utils)
+
+include_directories(../base/include)
+include_directories(../liblog/include)
+include_directories(include)
+
+add_library(utils STATIC
+  include/utils/String16.h
+  SharedBuffer.cpp
+  Unicode.cpp
+  String8.cpp
+  String16.cpp
+)
diff --git a/nativeruntime/cpp/libutils/SharedBuffer.cpp b/nativeruntime/cpp/libutils/SharedBuffer.cpp
new file mode 100644
index 0000000..4e35bec
--- /dev/null
+++ b/nativeruntime/cpp/libutils/SharedBuffer.cpp
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/SharedBuffer.cpp
+
+#define LOG_TAG "sharedbuffer"
+
+#include "SharedBuffer.h"
+
+#include <log/log.h>
+#include <stdlib.h>
+#include <string.h>
+
+// ---------------------------------------------------------------------------
+
+namespace android {
+
+SharedBuffer* SharedBuffer::alloc(size_t size) {
+  // Don't overflow if the combined size of the buffer / header is larger than
+  // size_max.
+  LOG_ALWAYS_FATAL_IF((size >= (SIZE_MAX - sizeof(SharedBuffer))),
+                      "Invalid buffer size %zu", size);
+
+  SharedBuffer* sb =
+      static_cast<SharedBuffer*>(malloc(sizeof(SharedBuffer) + size));
+  if (sb) {
+    // Should be std::atomic_init(&sb->mRefs, 1);
+    // But that generates a warning with some compilers.
+    // The following is OK on Android-supported platforms.
+    sb->mRefs.store(1, std::memory_order_relaxed);
+    sb->mSize = size;
+    sb->mClientMetadata = 0;
+  }
+  return sb;
+}
+
+void SharedBuffer::dealloc(const SharedBuffer* released) {
+  free(const_cast<SharedBuffer*>(released));
+}
+
+SharedBuffer* SharedBuffer::edit() const {
+  if (onlyOwner()) {
+    return const_cast<SharedBuffer*>(this);
+  }
+  SharedBuffer* sb = alloc(mSize);
+  if (sb) {
+    memcpy(sb->data(), data(), size());
+    release();
+  }
+  return sb;
+}
+
+SharedBuffer* SharedBuffer::editResize(size_t newSize) const {
+  if (onlyOwner()) {
+    SharedBuffer* buf = const_cast<SharedBuffer*>(this);
+    if (buf->mSize == newSize) return buf;
+    // Don't overflow if the combined size of the new buffer / header is larger
+    // than size_max.
+    LOG_ALWAYS_FATAL_IF((newSize >= (SIZE_MAX - sizeof(SharedBuffer))),
+                        "Invalid buffer size %zu", newSize);
+
+    buf = static_cast<SharedBuffer*>(
+        realloc(buf, sizeof(SharedBuffer) + newSize));
+    if (buf != nullptr) {
+      buf->mSize = newSize;
+      return buf;
+    }
+  }
+  SharedBuffer* sb = alloc(newSize);
+  if (sb) {
+    const size_t mySize = mSize;
+    memcpy(sb->data(), data(), newSize < mySize ? newSize : mySize);
+    release();
+  }
+  return sb;
+}
+
+SharedBuffer* SharedBuffer::attemptEdit() const {
+  if (onlyOwner()) {
+    return const_cast<SharedBuffer*>(this);
+  }
+  return nullptr;
+}
+
+SharedBuffer* SharedBuffer::reset(size_t new_size) const {
+  // cheap-o-reset.
+  SharedBuffer* sb = alloc(new_size);
+  if (sb) {
+    release();
+  }
+  return sb;
+}
+
+void SharedBuffer::acquire() const {
+  mRefs.fetch_add(1, std::memory_order_relaxed);
+}
+
+int32_t SharedBuffer::release(uint32_t flags) const {
+  const bool useDealloc = ((flags & eKeepStorage) == 0);
+  if (onlyOwner()) {
+    // Since we're the only owner, our reference count goes to zero.
+    mRefs.store(0, std::memory_order_relaxed);
+    if (useDealloc) {
+      dealloc(this);
+    }
+    // As the only owner, our previous reference count was 1.
+    return 1;
+  }
+  // There's multiple owners, we need to use an atomic decrement.
+  int32_t prevRefCount = mRefs.fetch_sub(1, std::memory_order_release);
+  if (prevRefCount == 1) {
+    // We're the last reference, we need the acquire fence.
+    std::atomic_thread_fence(std::memory_order_acquire);
+    if (useDealloc) {
+      dealloc(this);
+    }
+  }
+  return prevRefCount;
+}
+
+};  // namespace android
diff --git a/nativeruntime/cpp/libutils/SharedBuffer.h b/nativeruntime/cpp/libutils/SharedBuffer.h
new file mode 100644
index 0000000..dc5d504
--- /dev/null
+++ b/nativeruntime/cpp/libutils/SharedBuffer.h
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/SharedBuffer.h
+
+/*
+ * DEPRECATED.  DO NOT USE FOR NEW CODE.
+ */
+
+#ifndef ANDROID_SHARED_BUFFER_H
+#define ANDROID_SHARED_BUFFER_H
+
+#include <stdint.h>
+#include <sys/types.h>
+
+#include <atomic>
+
+// ---------------------------------------------------------------------------
+
+namespace android {
+
+class SharedBuffer {
+ public:
+  /* flags to use with release() */
+  enum { eKeepStorage = 0x00000001 };
+
+  /*! allocate a buffer of size 'size' and acquire() it.
+   *  call release() to free it.
+   */
+  static SharedBuffer* alloc(size_t size);
+
+  /*! free the memory associated with the SharedBuffer.
+   * Fails if there are any users associated with this SharedBuffer.
+   * In other words, the buffer must have been release by all its
+   * users.
+   */
+  static void dealloc(const SharedBuffer* released);
+
+  //! access the data for read
+  inline const void* data() const;
+
+  //! access the data for read/write
+  inline void* data();
+
+  //! get size of the buffer
+  inline size_t size() const;
+
+  //! get back a SharedBuffer object from its data
+  static inline SharedBuffer* bufferFromData(void* data);
+
+  //! get back a SharedBuffer object from its data
+  static inline const SharedBuffer* bufferFromData(const void* data);
+
+  //! get the size of a SharedBuffer object from its data
+  static inline size_t sizeFromData(const void* data);
+
+  //! edit the buffer (get a writtable, or non-const, version of it)
+  SharedBuffer* edit() const;
+
+  //! edit the buffer, resizing if needed
+  SharedBuffer* editResize(size_t size) const;
+
+  //! like edit() but fails if a copy is required
+  SharedBuffer* attemptEdit() const;
+
+  //! resize and edit the buffer, loose its content.
+  SharedBuffer* reset(size_t size) const;
+
+  //! acquire/release a reference on this buffer
+  void acquire() const;
+
+  /*! release a reference on this buffer, with the option of not
+   * freeing the memory associated with it if it was the last reference
+   * returns the previous reference count
+   */
+  int32_t release(uint32_t flags = 0) const;
+
+  //! returns whether or not we're the only owner
+  inline bool onlyOwner() const;
+
+ private:
+  inline SharedBuffer() {}
+  inline ~SharedBuffer() {}
+  SharedBuffer(const SharedBuffer&);
+  SharedBuffer& operator=(const SharedBuffer&);
+
+  // Must be sized to preserve correct alignment.
+  mutable std::atomic<int32_t> mRefs;
+  size_t mSize;
+  uint32_t mReserved;
+
+ public:
+  // mClientMetadata is reserved for client use.  It is initialized to 0
+  // and the clients can do whatever they want with it.  Note that this is
+  // placed last so that it is adjcent to the buffer allocated.
+  uint32_t mClientMetadata;
+};
+
+static_assert(sizeof(SharedBuffer) % 8 == 0 &&
+                  (sizeof(size_t) > 4 || sizeof(SharedBuffer) == 16),
+              "SharedBuffer has unexpected size");
+
+// ---------------------------------------------------------------------------
+
+const void* SharedBuffer::data() const { return this + 1; }
+
+void* SharedBuffer::data() { return this + 1; }
+
+size_t SharedBuffer::size() const { return mSize; }
+
+SharedBuffer* SharedBuffer::bufferFromData(void* data) {
+  return data ? static_cast<SharedBuffer*>(data) - 1 : nullptr;
+}
+
+const SharedBuffer* SharedBuffer::bufferFromData(const void* data) {
+  return data ? static_cast<const SharedBuffer*>(data) - 1 : nullptr;
+}
+
+size_t SharedBuffer::sizeFromData(const void* data) {
+  return data ? bufferFromData(data)->mSize : 0;
+}
+
+bool SharedBuffer::onlyOwner() const {
+  return (mRefs.load(std::memory_order_acquire) == 1);
+}
+
+}  // namespace android
+
+// ---------------------------------------------------------------------------
+
+#endif  // ANDROID_VECTOR_H
diff --git a/nativeruntime/cpp/libutils/String16.cpp b/nativeruntime/cpp/libutils/String16.cpp
new file mode 100644
index 0000000..460b19c
--- /dev/null
+++ b/nativeruntime/cpp/libutils/String16.cpp
@@ -0,0 +1,450 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/String16.cpp
+
+#include <ctype.h>
+#include <log/log.h>
+#include <utils/String16.h>
+
+#include "SharedBuffer.h"
+
+namespace android {
+
+static const StaticString16 emptyString(u"");
+static inline char16_t* getEmptyString() {
+  return const_cast<char16_t*>(emptyString.string());
+}
+
+// ---------------------------------------------------------------------------
+
+void* String16::alloc(size_t size) {
+  SharedBuffer* buf = SharedBuffer::alloc(size);
+  buf->mClientMetadata = kIsSharedBufferAllocated;
+  return buf;
+}
+
+char16_t* String16::allocFromUTF8(const char* u8str, size_t u8len) {
+  if (u8len == 0) return getEmptyString();
+
+  const uint8_t* u8cur = reinterpret_cast<const uint8_t*>(u8str);
+
+  const ssize_t u16len = utf8_to_utf16_length(u8cur, u8len);
+  if (u16len < 0) {
+    return getEmptyString();
+  }
+
+  SharedBuffer* buf =
+      static_cast<SharedBuffer*>(alloc(sizeof(char16_t) * (u16len + 1)));
+  if (buf) {
+    u8cur = reinterpret_cast<const uint8_t*>(u8str);
+    char16_t* u16str = static_cast<char16_t*>(buf->data());
+
+    utf8_to_utf16(u8cur, u8len, u16str, (static_cast<size_t>(u16len)) + 1);
+
+    // printf("Created UTF-16 string from UTF-8 \"%s\":", in);
+    // printHexData(1, str, buf->size(), 16, 1);
+    // printf("\n");
+
+    return u16str;
+  }
+
+  return getEmptyString();
+}
+
+char16_t* String16::allocFromUTF16(const char16_t* u16str, size_t u16len) {
+  if (u16len >= SIZE_MAX / sizeof(char16_t)) {
+    android_errorWriteLog(0x534e4554, "73826242");
+    abort();
+  }
+
+  SharedBuffer* buf =
+      static_cast<SharedBuffer*>(alloc((u16len + 1) * sizeof(char16_t)));
+  ALOG_ASSERT(buf, "Unable to allocate shared buffer");
+  if (buf) {
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    memcpy(str, u16str, u16len * sizeof(char16_t));
+    str[u16len] = 0;
+    return str;
+  }
+  return getEmptyString();
+}
+
+// ---------------------------------------------------------------------------
+
+String16::String16() : mString(getEmptyString()) {}
+
+String16::String16(StaticLinkage) : mString(nullptr) {
+  // this constructor is used when we can't rely on the static-initializers
+  // having run. In this case we always allocate an empty string. It's less
+  // efficient than using getEmptyString(), but we assume it's uncommon.
+
+  SharedBuffer* buf = static_cast<SharedBuffer*>(alloc(sizeof(char16_t)));
+  char16_t* data = static_cast<char16_t*>(buf->data());
+  data[0] = 0;
+  mString = data;
+}
+
+String16::String16(const String16& o) : mString(o.mString) { acquire(); }
+
+String16::String16(const String16& o, size_t len, size_t begin)
+    : mString(getEmptyString()) {
+  setTo(o, len, begin);
+}
+
+String16::String16(const char16_t* o)
+    : mString(allocFromUTF16(o, strlen16(o))) {}
+
+String16::String16(const char16_t* o, size_t len)
+    : mString(allocFromUTF16(o, len)) {}
+
+String16::String16(const String8& o)
+    : mString(allocFromUTF8(o.string(), o.size())) {}
+
+String16::String16(const char* o) : mString(allocFromUTF8(o, strlen(o))) {}
+
+String16::String16(const char* o, size_t len)
+    : mString(allocFromUTF8(o, len)) {}
+
+String16::~String16() { release(); }
+
+size_t String16::size() const {
+  if (isStaticString()) {
+    return staticStringSize();
+  } else {
+    return SharedBuffer::sizeFromData(mString) / sizeof(char16_t) - 1;
+  }
+}
+
+void String16::setTo(const String16& other) {
+  release();
+  mString = other.mString;
+  acquire();
+}
+
+status_t String16::setTo(const String16& other, size_t len, size_t begin) {
+  const size_t N = other.size();
+  if (begin >= N) {
+    release();
+    mString = getEmptyString();
+    return OK;
+  }
+  if ((begin + len) > N) len = N - begin;
+  if (begin == 0 && len == N) {
+    setTo(other);
+    return OK;
+  }
+
+  if (&other == this) {
+    LOG_ALWAYS_FATAL("Not implemented");
+  }
+
+  return setTo(other.string() + begin, len);
+}
+
+status_t String16::setTo(const char16_t* other) {
+  return setTo(other, strlen16(other));
+}
+
+status_t String16::setTo(const char16_t* other, size_t len) {
+  if (len >= SIZE_MAX / sizeof(char16_t)) {
+    android_errorWriteLog(0x534e4554, "73826242");
+    abort();
+  }
+
+  SharedBuffer* buf =
+      static_cast<SharedBuffer*>(editResize((len + 1) * sizeof(char16_t)));
+  if (buf) {
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    memmove(str, other, len * sizeof(char16_t));
+    str[len] = 0;
+    mString = str;
+    return OK;
+  }
+  return NO_MEMORY;
+}
+
+status_t String16::append(const String16& other) {
+  const size_t myLen = size();
+  const size_t otherLen = other.size();
+  if (myLen == 0) {
+    setTo(other);
+    return OK;
+  } else if (otherLen == 0) {
+    return OK;
+  }
+
+  if (myLen >= SIZE_MAX / sizeof(char16_t) - otherLen) {
+    android_errorWriteLog(0x534e4554, "73826242");
+    abort();
+  }
+
+  SharedBuffer* buf = static_cast<SharedBuffer*>(
+      editResize((myLen + otherLen + 1) * sizeof(char16_t)));
+  if (buf) {
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    memcpy(str + myLen, other, (otherLen + 1) * sizeof(char16_t));
+    mString = str;
+    return OK;
+  }
+  return NO_MEMORY;
+}
+
+status_t String16::append(const char16_t* chrs, size_t otherLen) {
+  const size_t myLen = size();
+  if (myLen == 0) {
+    setTo(chrs, otherLen);
+    return OK;
+  } else if (otherLen == 0) {
+    return OK;
+  }
+
+  if (myLen >= SIZE_MAX / sizeof(char16_t) - otherLen) {
+    android_errorWriteLog(0x534e4554, "73826242");
+    abort();
+  }
+
+  SharedBuffer* buf = static_cast<SharedBuffer*>(
+      editResize((myLen + otherLen + 1) * sizeof(char16_t)));
+  if (buf) {
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    memcpy(str + myLen, chrs, otherLen * sizeof(char16_t));
+    str[myLen + otherLen] = 0;
+    mString = str;
+    return OK;
+  }
+  return NO_MEMORY;
+}
+
+status_t String16::insert(size_t pos, const char16_t* chrs) {
+  return insert(pos, chrs, strlen16(chrs));
+}
+
+status_t String16::insert(size_t pos, const char16_t* chrs, size_t len) {
+  const size_t myLen = size();
+  if (myLen == 0) {
+    return setTo(chrs, len);
+    return OK;
+  } else if (len == 0) {
+    return OK;
+  }
+
+  if (pos > myLen) pos = myLen;
+
+#if 0
+  printf("Insert in to %s: pos=%d, len=%d, myLen=%d, chrs=%s\n",
+           String8(*this).string(), pos,
+           len, myLen, String8(chrs, len).string());
+#endif
+
+  SharedBuffer* buf = static_cast<SharedBuffer*>(
+      editResize((myLen + len + 1) * sizeof(char16_t)));
+  if (buf) {
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    if (pos < myLen) {
+      memmove(str + pos + len, str + pos, (myLen - pos) * sizeof(char16_t));
+    }
+    memcpy(str + pos, chrs, len * sizeof(char16_t));
+    str[myLen + len] = 0;
+    mString = str;
+#if 0
+    printf("Result (%d chrs): %s\n", size(), String8(*this).string());
+#endif
+    return OK;
+  }
+  return NO_MEMORY;
+}
+
+ssize_t String16::findFirst(char16_t c) const {
+  const char16_t* str = string();
+  const char16_t* p = str;
+  const char16_t* e = p + size();
+  while (p < e) {
+    if (*p == c) {
+      return p - str;
+    }
+    p++;
+  }
+  return -1;
+}
+
+ssize_t String16::findLast(char16_t c) const {
+  const char16_t* str = string();
+  const char16_t* p = str;
+  const char16_t* e = p + size();
+  while (p < e) {
+    e--;
+    if (*e == c) {
+      return e - str;
+    }
+  }
+  return -1;
+}
+
+bool String16::startsWith(const String16& prefix) const {
+  const size_t ps = prefix.size();
+  if (ps > size()) return false;
+  return strzcmp16(mString, ps, prefix.string(), ps) == 0;
+}
+
+bool String16::startsWith(const char16_t* prefix) const {
+  const size_t ps = strlen16(prefix);
+  if (ps > size()) return false;
+  return strncmp16(mString, prefix, ps) == 0;
+}
+
+bool String16::contains(const char16_t* chrs) const {
+  return strstr16(mString, chrs) != nullptr;
+}
+
+void* String16::edit() {
+  SharedBuffer* buf;
+  if (isStaticString()) {
+    buf = static_cast<SharedBuffer*>(alloc((size() + 1) * sizeof(char16_t)));
+    if (buf) {
+      memcpy(buf->data(), mString, (size() + 1) * sizeof(char16_t));
+    }
+  } else {
+    buf = SharedBuffer::bufferFromData(mString)->edit();
+    buf->mClientMetadata = kIsSharedBufferAllocated;
+  }
+  return buf;
+}
+
+void* String16::editResize(size_t newSize) {
+  SharedBuffer* buf;
+  if (isStaticString()) {
+    size_t copySize = (size() + 1) * sizeof(char16_t);
+    if (newSize < copySize) {
+      copySize = newSize;
+    }
+    buf = static_cast<SharedBuffer*>(alloc(newSize));
+    if (buf) {
+      memcpy(buf->data(), mString, copySize);
+    }
+  } else {
+    buf = SharedBuffer::bufferFromData(mString)->editResize(newSize);
+    buf->mClientMetadata = kIsSharedBufferAllocated;
+  }
+  return buf;
+}
+
+void String16::acquire() {
+  if (!isStaticString()) {
+    SharedBuffer::bufferFromData(mString)->acquire();
+  }
+}
+
+void String16::release() {
+  if (!isStaticString()) {
+    SharedBuffer::bufferFromData(mString)->release();
+  }
+}
+
+bool String16::isStaticString() const {
+  // See String16.h for notes on the memory layout of String16::StaticData and
+  // SharedBuffer.
+  static_assert(
+      sizeof(SharedBuffer) - offsetof(SharedBuffer, mClientMetadata) == 4);
+  const uint32_t* p = reinterpret_cast<const uint32_t*>(mString);
+  return (*(p - 1) & kIsSharedBufferAllocated) == 0;
+}
+
+size_t String16::staticStringSize() const {
+  // See String16.h for notes on the memory layout of String16::StaticData and
+  // SharedBuffer.
+  static_assert(
+      sizeof(SharedBuffer) - offsetof(SharedBuffer, mClientMetadata) == 4);
+  const uint32_t* p = reinterpret_cast<const uint32_t*>(mString);
+  return static_cast<size_t>(*(p - 1));
+}
+
+status_t String16::makeLower() {
+  const size_t N = size();
+  const char16_t* str = string();
+  char16_t* edited = nullptr;
+  for (size_t i = 0; i < N; i++) {
+    const char16_t v = str[i];
+    if (v >= 'A' && v <= 'Z') {
+      if (!edited) {
+        SharedBuffer* buf = static_cast<SharedBuffer*>(edit());
+        if (!buf) {
+          return NO_MEMORY;
+        }
+        edited = static_cast<char16_t*>(buf->data());
+        mString = str = edited;
+      }
+      edited[i] = tolower(static_cast<char>(v));
+    }
+  }
+  return OK;
+}
+
+status_t String16::replaceAll(char16_t replaceThis, char16_t withThis) {
+  const size_t N = size();
+  const char16_t* str = string();
+  char16_t* edited = nullptr;
+  for (size_t i = 0; i < N; i++) {
+    if (str[i] == replaceThis) {
+      if (!edited) {
+        SharedBuffer* buf = static_cast<SharedBuffer*>(edit());
+        if (!buf) {
+          return NO_MEMORY;
+        }
+        edited = static_cast<char16_t*>(buf->data());
+        mString = str = edited;
+      }
+      edited[i] = withThis;
+    }
+  }
+  return OK;
+}
+
+status_t String16::remove(size_t len, size_t begin) {
+  const size_t N = size();
+  if (begin >= N) {
+    release();
+    mString = getEmptyString();
+    return OK;
+  }
+  if (len > N || len > N - begin) len = N - begin;
+  if (begin == 0 && len == N) {
+    return OK;
+  }
+
+  if (begin > 0) {
+    SharedBuffer* buf =
+        static_cast<SharedBuffer*>(editResize((N + 1) * sizeof(char16_t)));
+    if (!buf) {
+      return NO_MEMORY;
+    }
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    memmove(str, str + begin, (N - begin + 1) * sizeof(char16_t));
+    mString = str;
+  }
+  SharedBuffer* buf =
+      static_cast<SharedBuffer*>(editResize((len + 1) * sizeof(char16_t)));
+  if (buf) {
+    char16_t* str = static_cast<char16_t*>(buf->data());
+    str[len] = 0;
+    mString = str;
+    return OK;
+  }
+  return NO_MEMORY;
+}
+
+};  // namespace android
diff --git a/nativeruntime/cpp/libutils/String8.cpp b/nativeruntime/cpp/libutils/String8.cpp
new file mode 100644
index 0000000..1407064
--- /dev/null
+++ b/nativeruntime/cpp/libutils/String8.cpp
@@ -0,0 +1,575 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/String8.cpp
+
+#define __STDC_LIMIT_MACROS
+#include <ctype.h>
+#include <log/log.h>
+#include <stdint.h>
+#include <utils/Compat.h>
+#include <utils/String16.h>
+#include <utils/String8.h>
+
+#include "SharedBuffer.h"
+
+/*
+ * Functions outside android is below the namespace android, since they use
+ * functions and constants in android namespace.
+ */
+
+// ---------------------------------------------------------------------------
+
+namespace android {
+
+// Separator used by resource paths. This is not platform dependent contrary
+// to OS_PATH_SEPARATOR.
+#define RES_PATH_SEPARATOR '/'
+
+static inline char* getEmptyString() {
+  static SharedBuffer* gEmptyStringBuf = [] {
+    SharedBuffer* buf = SharedBuffer::alloc(1);
+    char* str = static_cast<char*>(buf->data());
+    *str = 0;
+    return buf;
+  }();
+
+  gEmptyStringBuf->acquire();
+  return static_cast<char*>(gEmptyStringBuf->data());
+}
+
+// ---------------------------------------------------------------------------
+
+static char* allocFromUTF8(const char* in, size_t len) {
+  if (len > 0) {
+    if (len == SIZE_MAX) {
+      return nullptr;
+    }
+    SharedBuffer* buf = SharedBuffer::alloc(len + 1);
+    ALOG_ASSERT(buf, "Unable to allocate shared buffer");
+    if (buf) {
+      char* str = static_cast<char*>(buf->data());
+      memcpy(str, in, len);
+      str[len] = 0;
+      return str;
+    }
+    return nullptr;
+  }
+
+  return getEmptyString();
+}
+
+static char* allocFromUTF16(const char16_t* in, size_t len) {
+  if (len == 0) return getEmptyString();
+
+  // Allow for closing '\0'
+  const ssize_t resultStrLen = utf16_to_utf8_length(in, len) + 1;
+  if (resultStrLen < 1) {
+    return getEmptyString();
+  }
+
+  SharedBuffer* buf = SharedBuffer::alloc(resultStrLen);
+  ALOG_ASSERT(buf, "Unable to allocate shared buffer");
+  if (!buf) {
+    return getEmptyString();
+  }
+
+  char* resultStr = static_cast<char*>(buf->data());
+  utf16_to_utf8(in, len, resultStr, resultStrLen);
+  return resultStr;
+}
+
+static char* allocFromUTF32(const char32_t* in, size_t len) {
+  if (len == 0) {
+    return getEmptyString();
+  }
+
+  const ssize_t resultStrLen = utf32_to_utf8_length(in, len) + 1;
+  if (resultStrLen < 1) {
+    return getEmptyString();
+  }
+
+  SharedBuffer* buf = SharedBuffer::alloc(resultStrLen);
+  ALOG_ASSERT(buf, "Unable to allocate shared buffer");
+  if (!buf) {
+    return getEmptyString();
+  }
+
+  char* resultStr = static_cast<char*>(buf->data());
+  utf32_to_utf8(in, len, resultStr, resultStrLen);
+
+  return resultStr;
+}
+
+// ---------------------------------------------------------------------------
+
+String8::String8() : mString(getEmptyString()) {}
+
+String8::String8(StaticLinkage) : mString(nullptr) {
+  // this constructor is used when we can't rely on the static-initializers
+  // having run. In this case we always allocate an empty string. It's less
+  // efficient than using getEmptyString(), but we assume it's uncommon.
+
+  char* data = static_cast<char*>(SharedBuffer::alloc(sizeof(char))->data());
+  data[0] = 0;
+  mString = data;
+}
+
+String8::String8(const String8& o) : mString(o.mString) {
+  SharedBuffer::bufferFromData(mString)->acquire();
+}
+
+String8::String8(const char* o) : mString(allocFromUTF8(o, strlen(o))) {
+  if (mString == nullptr) {
+    mString = getEmptyString();
+  }
+}
+
+String8::String8(const char* o, size_t len) : mString(allocFromUTF8(o, len)) {
+  if (mString == nullptr) {
+    mString = getEmptyString();
+  }
+}
+
+String8::String8(const String16& o)
+    : mString(allocFromUTF16(o.string(), o.size())) {}
+
+String8::String8(const char16_t* o) : mString(allocFromUTF16(o, strlen16(o))) {}
+
+String8::String8(const char16_t* o, size_t len)
+    : mString(allocFromUTF16(o, len)) {}
+
+String8::String8(const char32_t* o) : mString(allocFromUTF32(o, strlen32(o))) {}
+
+String8::String8(const char32_t* o, size_t len)
+    : mString(allocFromUTF32(o, len)) {}
+
+String8::~String8() {
+  if (mString != nullptr) {
+    SharedBuffer::bufferFromData(mString)->release();
+  }
+}
+
+size_t String8::length() const {
+  return SharedBuffer::sizeFromData(mString) - 1;
+}
+
+String8 String8::format(const char* fmt, ...) {
+  va_list args;
+  va_start(args, fmt);
+
+  String8 result(formatV(fmt, args));
+
+  va_end(args);
+  return result;
+}
+
+String8 String8::formatV(const char* fmt, va_list args) {
+  String8 result;
+  result.appendFormatV(fmt, args);
+  return result;
+}
+
+void String8::clear() {
+  SharedBuffer::bufferFromData(mString)->release();
+  mString = getEmptyString();
+}
+
+void String8::setTo(const String8& other) {
+  SharedBuffer::bufferFromData(other.mString)->acquire();
+  SharedBuffer::bufferFromData(mString)->release();
+  mString = other.mString;
+}
+
+status_t String8::setTo(const char* other) {
+  const char* newString = allocFromUTF8(other, strlen(other));
+  SharedBuffer::bufferFromData(mString)->release();
+  mString = newString;
+  if (mString) return OK;
+
+  mString = getEmptyString();
+  return NO_MEMORY;
+}
+
+status_t String8::setTo(const char* other, size_t len) {
+  const char* newString = allocFromUTF8(other, len);
+  SharedBuffer::bufferFromData(mString)->release();
+  mString = newString;
+  if (mString) return OK;
+
+  mString = getEmptyString();
+  return NO_MEMORY;
+}
+
+status_t String8::setTo(const char16_t* other, size_t len) {
+  const char* newString = allocFromUTF16(other, len);
+  SharedBuffer::bufferFromData(mString)->release();
+  mString = newString;
+  if (mString) return OK;
+
+  mString = getEmptyString();
+  return NO_MEMORY;
+}
+
+status_t String8::setTo(const char32_t* other, size_t len) {
+  const char* newString = allocFromUTF32(other, len);
+  SharedBuffer::bufferFromData(mString)->release();
+  mString = newString;
+  if (mString) return OK;
+
+  mString = getEmptyString();
+  return NO_MEMORY;
+}
+
+status_t String8::append(const String8& other) {
+  const size_t otherLen = other.bytes();
+  if (bytes() == 0) {
+    setTo(other);
+    return OK;
+  } else if (otherLen == 0) {
+    return OK;
+  }
+
+  return real_append(other.string(), otherLen);
+}
+
+status_t String8::append(const char* other) {
+  return append(other, strlen(other));
+}
+
+status_t String8::append(const char* other, size_t otherLen) {
+  if (bytes() == 0) {
+    return setTo(other, otherLen);
+  } else if (otherLen == 0) {
+    return OK;
+  }
+
+  return real_append(other, otherLen);
+}
+
+status_t String8::appendFormat(const char* fmt, ...) {
+  va_list args;
+  va_start(args, fmt);
+
+  status_t result = appendFormatV(fmt, args);
+
+  va_end(args);
+  return result;
+}
+
+status_t String8::appendFormatV(const char* fmt, va_list args) {
+  int n, result = OK;
+  va_list tmp_args;
+
+  /* args is undefined after vsnprintf.
+   * So we need a copy here to avoid the
+   * second vsnprintf access undefined args.
+   */
+  va_copy(tmp_args, args);
+  n = vsnprintf(nullptr, 0, fmt, tmp_args);
+  va_end(tmp_args);
+
+  if (n != 0) {
+    size_t oldLength = length();
+    char* buf = lockBuffer(oldLength + n);
+    if (buf) {
+      vsnprintf(buf + oldLength, n + 1, fmt, args);
+    } else {
+      result = NO_MEMORY;
+    }
+  }
+  return result;
+}
+
+status_t String8::real_append(const char* other, size_t otherLen) {
+  const size_t myLen = bytes();
+
+  SharedBuffer* buf =
+      SharedBuffer::bufferFromData(mString)->editResize(myLen + otherLen + 1);
+  if (buf) {
+    char* str = static_cast<char*>(buf->data());
+    mString = str;
+    str += myLen;
+    memcpy(str, other, otherLen);
+    str[otherLen] = '\0';
+    return OK;
+  }
+  return NO_MEMORY;
+}
+
+char* String8::lockBuffer(size_t size) {
+  SharedBuffer* buf =
+      SharedBuffer::bufferFromData(mString)->editResize(size + 1);
+  if (buf) {
+    char* str = static_cast<char*>(buf->data());
+    mString = str;
+    return str;
+  }
+  return nullptr;
+}
+
+void String8::unlockBuffer() { unlockBuffer(strlen(mString)); }
+
+status_t String8::unlockBuffer(size_t size) {
+  if (size != this->size()) {
+    SharedBuffer* buf =
+        SharedBuffer::bufferFromData(mString)->editResize(size + 1);
+    if (!buf) {
+      return NO_MEMORY;
+    }
+
+    char* str = static_cast<char*>(buf->data());
+    str[size] = 0;
+    mString = str;
+  }
+
+  return OK;
+}
+
+ssize_t String8::find(const char* other, size_t start) const {
+  size_t len = size();
+  if (start >= len) {
+    return -1;
+  }
+  const char* s = mString + start;
+  const char* p = strstr(s, other);
+  return p ? p - mString : -1;
+}
+
+bool String8::removeAll(const char* other) {
+  ssize_t index = find(other);
+  if (index < 0) return false;
+
+  char* buf = lockBuffer(size());
+  if (!buf) return false;  // out of memory
+
+  size_t skip = strlen(other);
+  size_t len = size();
+  size_t tail = index;
+  while (size_t(index) < len) {
+    ssize_t next = find(other, index + skip);
+    if (next < 0) {
+      next = len;
+    }
+
+    memmove(buf + tail, buf + index + skip, next - index - skip);
+    tail += next - index - skip;
+    index = next;
+  }
+  unlockBuffer(tail);
+  return true;
+}
+
+void String8::toLower() { toLower(0, size()); }
+
+void String8::toLower(size_t start, size_t length) {
+  const size_t len = size();
+  if (start >= len) {
+    return;
+  }
+  if (start + length > len) {
+    length = len - start;
+  }
+  char* buf = lockBuffer(len);
+  buf += start;
+  while (length > 0) {
+    *buf = tolower(*buf);
+    buf++;
+    length--;
+  }
+  unlockBuffer(len);
+}
+
+void String8::toUpper() { toUpper(0, size()); }
+
+void String8::toUpper(size_t start, size_t length) {
+  const size_t len = size();
+  if (start >= len) {
+    return;
+  }
+  if (start + length > len) {
+    length = len - start;
+  }
+  char* buf = lockBuffer(len);
+  buf += start;
+  while (length > 0) {
+    *buf = toupper(*buf);
+    buf++;
+    length--;
+  }
+  unlockBuffer(len);
+}
+
+// ---------------------------------------------------------------------------
+// Path functions
+
+void String8::setPathName(const char* name) { setPathName(name, strlen(name)); }
+
+void String8::setPathName(const char* name, size_t len) {
+  char* buf = lockBuffer(len);
+
+  memcpy(buf, name, len);
+
+  // remove trailing path separator, if present
+  if (len > 0 && buf[len - 1] == OS_PATH_SEPARATOR) len--;
+
+  buf[len] = '\0';
+
+  unlockBuffer(len);
+}
+
+String8 String8::getPathLeaf() const {
+  const char* cp;
+  const char* const buf = mString;
+
+  cp = strrchr(buf, OS_PATH_SEPARATOR);
+  if (cp == nullptr)
+    return String8(*this);
+  else
+    return String8(cp + 1);
+}
+
+String8 String8::getPathDir() const {
+  const char* cp;
+  const char* const str = mString;
+
+  cp = strrchr(str, OS_PATH_SEPARATOR);
+  if (cp == nullptr)
+    return String8("");
+  else
+    return String8(str, cp - str);
+}
+
+String8 String8::walkPath(String8* outRemains) const {
+  const char* cp;
+  const char* const str = mString;
+  const char* buf = str;
+
+  cp = strchr(buf, OS_PATH_SEPARATOR);
+  if (cp == buf) {
+    // don't include a leading '/'.
+    buf = buf + 1;
+    cp = strchr(buf, OS_PATH_SEPARATOR);
+  }
+
+  if (cp == nullptr) {
+    String8 res = buf != str ? String8(buf) : *this;
+    if (outRemains) *outRemains = String8("");
+    return res;
+  }
+
+  String8 res(buf, cp - buf);
+  if (outRemains) *outRemains = String8(cp + 1);
+  return res;
+}
+
+/*
+ * Helper function for finding the start of an extension in a pathname.
+ *
+ * Returns a pointer inside mString, or NULL if no extension was found.
+ */
+char* String8::find_extension() const {
+  const char* lastSlash;
+  const char* lastDot;
+  const char* const str = mString;
+
+  // only look at the filename
+  lastSlash = strrchr(str, OS_PATH_SEPARATOR);
+  if (lastSlash == nullptr)
+    lastSlash = str;
+  else
+    lastSlash++;
+
+  // find the last dot
+  lastDot = strrchr(lastSlash, '.');
+  if (lastDot == nullptr) return nullptr;
+
+  // looks good, ship it
+  return const_cast<char*>(lastDot);
+}
+
+String8 String8::getPathExtension() const {
+  char* ext;
+
+  ext = find_extension();
+  if (ext != nullptr)
+    return String8(ext);
+  else
+    return String8("");
+}
+
+String8 String8::getBasePath() const {
+  char* ext;
+  const char* const str = mString;
+
+  ext = find_extension();
+  if (ext == nullptr)
+    return String8(*this);
+  else
+    return String8(str, ext - str);
+}
+
+String8& String8::appendPath(const char* name) {
+  // TODO: The test below will fail for Win32 paths. Fix later or ignore.
+  if (name[0] != OS_PATH_SEPARATOR) {
+    if (*name == '\0') {
+      // nothing to do
+      return *this;
+    }
+
+    size_t len = length();
+    if (len == 0) {
+      // no existing filename, just use the new one
+      setPathName(name);
+      return *this;
+    }
+
+    // make room for oldPath + '/' + newPath
+    int newlen = strlen(name);
+
+    char* buf = lockBuffer(len + 1 + newlen);
+
+    // insert a '/' if needed
+    if (buf[len - 1] != OS_PATH_SEPARATOR) buf[len++] = OS_PATH_SEPARATOR;
+
+    memcpy(buf + len, name, newlen + 1);
+    len += newlen;
+
+    unlockBuffer(len);
+
+    return *this;
+  } else {
+    setPathName(name);
+    return *this;
+  }
+}
+
+String8& String8::convertToResPath() {
+#if OS_PATH_SEPARATOR != RES_PATH_SEPARATOR
+  size_t len = length();
+  if (len > 0) {
+    char* buf = lockBuffer(len);
+    for (char* end = buf + len; buf < end; ++buf) {
+      if (*buf == OS_PATH_SEPARATOR) *buf = RES_PATH_SEPARATOR;
+    }
+    unlockBuffer(len);
+  }
+#endif
+  return *this;
+}
+
+};  // namespace android
diff --git a/nativeruntime/cpp/libutils/Unicode.cpp b/nativeruntime/cpp/libutils/Unicode.cpp
new file mode 100644
index 0000000..64c7697
--- /dev/null
+++ b/nativeruntime/cpp/libutils/Unicode.cpp
@@ -0,0 +1,561 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/Unicode.cpp
+
+#define LOG_TAG "unicode"
+
+#include <android-base/macros.h>
+#include <limits.h>
+#include <log/log.h>
+#include <utils/Unicode.h>
+
+#if defined(_WIN32)
+#undef nhtol
+#undef htonl
+#undef nhtos
+#undef htons
+
+#define ntohl(x)                                                 \
+  (((x) << 24) | (((x) >> 24) & 255) | (((x) << 8) & 0xff0000) | \
+   (((x) >> 8) & 0xff00))
+#define htonl(x) ntohl(x)
+#define ntohs(x) ((((x) << 8) & 0xff00) | (((x) >> 8) & 255))
+#define htons(x) ntohs(x)
+#else
+#include <netinet/in.h>
+#endif
+
+extern "C" {
+
+static const char32_t kByteMask = 0x000000BF;
+static const char32_t kByteMark = 0x00000080;
+
+// Surrogates aren't valid for UTF-32 characters, so define some
+// constants that will let us screen them out.
+static const char32_t kUnicodeSurrogateHighStart = 0x0000D800;
+// Unused, here for completeness:
+// static const char32_t kUnicodeSurrogateHighEnd = 0x0000DBFF;
+// static const char32_t kUnicodeSurrogateLowStart = 0x0000DC00;
+static const char32_t kUnicodeSurrogateLowEnd = 0x0000DFFF;
+static const char32_t kUnicodeSurrogateStart = kUnicodeSurrogateHighStart;
+static const char32_t kUnicodeSurrogateEnd = kUnicodeSurrogateLowEnd;
+static const char32_t kUnicodeMaxCodepoint = 0x0010FFFF;
+
+// Mask used to set appropriate bits in first byte of UTF-8 sequence,
+// indexed by number of bytes in the sequence.
+// 0xxxxxxx
+// -> (00-7f) 7bit. Bit mask for the first byte is 0x00000000
+// 110yyyyx 10xxxxxx
+// -> (c0-df)(80-bf) 11bit. Bit mask is 0x000000C0
+// 1110yyyy 10yxxxxx 10xxxxxx
+// -> (e0-ef)(80-bf)(80-bf) 16bit. Bit mask is 0x000000E0
+// 11110yyy 10yyxxxx 10xxxxxx 10xxxxxx
+// -> (f0-f7)(80-bf)(80-bf)(80-bf) 21bit. Bit mask is 0x000000F0
+static const char32_t kFirstByteMark[] = {0x00000000, 0x00000000, 0x000000C0,
+                                          0x000000E0, 0x000000F0};
+
+// --------------------------------------------------------------------------
+// UTF-32
+// --------------------------------------------------------------------------
+
+/**
+ * Return number of UTF-8 bytes required for the character. If the character
+ * is invalid, return size of 0.
+ */
+static inline size_t utf32_codepoint_utf8_length(char32_t srcChar) {
+  // Figure out how many bytes the result will require.
+  if (srcChar < 0x00000080) {
+    return 1;
+  } else if (srcChar < 0x00000800) {
+    return 2;
+  } else if (srcChar < 0x00010000) {
+    if ((srcChar < kUnicodeSurrogateStart) ||
+        (srcChar > kUnicodeSurrogateEnd)) {
+      return 3;
+    } else {
+      // Surrogates are invalid UTF-32 characters.
+      return 0;
+    }
+  }
+  // Max code point for Unicode is 0x0010FFFF.
+  else if (srcChar <= kUnicodeMaxCodepoint) {
+    return 4;
+  } else {
+    // Invalid UTF-32 character.
+    return 0;
+  }
+}
+
+// Write out the source character to <dstP>.
+
+static inline void utf32_codepoint_to_utf8(uint8_t *dstP, char32_t srcChar,
+                                           size_t bytes) {
+  dstP += bytes;
+  switch (bytes) { /* note: everything falls through. */
+    case 4:
+      *--dstP = (uint8_t)((srcChar | kByteMark) & kByteMask);
+      srcChar >>= 6;
+      FALLTHROUGH_INTENDED;
+    case 3:
+      *--dstP = (uint8_t)((srcChar | kByteMark) & kByteMask);
+      srcChar >>= 6;
+      FALLTHROUGH_INTENDED;
+    case 2:
+      *--dstP = (uint8_t)((srcChar | kByteMark) & kByteMask);
+      srcChar >>= 6;
+      FALLTHROUGH_INTENDED;
+    case 1:
+      *--dstP = (uint8_t)(srcChar | kFirstByteMark[bytes]);
+  }
+}
+
+size_t strlen32(const char32_t *s) {
+  const char32_t *ss = s;
+  while (*ss) ss++;
+  return ss - s;
+}
+
+size_t strnlen32(const char32_t *s, size_t maxlen) {
+  const char32_t *ss = s;
+  while ((maxlen > 0) && *ss) {
+    ss++;
+    maxlen--;
+  }
+  return ss - s;
+}
+
+static inline int32_t utf32_at_internal(const char *cur, size_t *num_read) {
+  const char first_char = *cur;
+  if ((first_char & 0x80) == 0) {  // ASCII
+    *num_read = 1;
+    return *cur;
+  }
+  cur++;
+  char32_t mask, to_ignore_mask;
+  size_t num_to_read = 0;
+  char32_t utf32 = first_char;
+  for (num_to_read = 1, mask = 0x40, to_ignore_mask = 0xFFFFFF80;
+       (first_char & mask); num_to_read++, to_ignore_mask |= mask, mask >>= 1) {
+    // 0x3F == 00111111
+    utf32 = (utf32 << 6) + (*cur++ & 0x3F);
+  }
+  to_ignore_mask |= mask;
+  utf32 &= ~(to_ignore_mask << (6 * (num_to_read - 1)));
+
+  *num_read = num_to_read;
+  return static_cast<int32_t>(utf32);
+}
+
+int32_t utf32_from_utf8_at(const char *src, size_t src_len, size_t index,
+                           size_t *next_index) {
+  if (index >= src_len) {
+    return -1;
+  }
+  size_t dummy_index;
+  if (next_index == nullptr) {
+    next_index = &dummy_index;
+  }
+  size_t num_read;
+  int32_t ret = utf32_at_internal(src + index, &num_read);
+  if (ret >= 0) {
+    *next_index = index + num_read;
+  }
+
+  return ret;
+}
+
+ssize_t utf32_to_utf8_length(const char32_t *src, size_t src_len) {
+  if (src == nullptr || src_len == 0) {
+    return -1;
+  }
+
+  size_t ret = 0;
+  const char32_t *end = src + src_len;
+  while (src < end) {
+    size_t char_len = utf32_codepoint_utf8_length(*src++);
+    if (SSIZE_MAX - char_len < ret) {
+      // If this happens, we would overflow the ssize_t type when
+      // returning from this function, so we cannot express how
+      // long this string is in an ssize_t.
+      android_errorWriteLog(0x534e4554, "37723026");
+      return -1;
+    }
+    ret += char_len;
+  }
+  return ret;
+}
+
+void utf32_to_utf8(const char32_t *src, size_t src_len, char *dst,
+                   size_t dst_len) {
+  if (src == nullptr || src_len == 0 || dst == nullptr) {
+    return;
+  }
+
+  const char32_t *cur_utf32 = src;
+  const char32_t *end_utf32 = src + src_len;
+  char *cur = dst;
+  while (cur_utf32 < end_utf32) {
+    size_t len = utf32_codepoint_utf8_length(*cur_utf32);
+    LOG_ALWAYS_FATAL_IF(dst_len < len, "%zu < %zu", dst_len, len);
+    utf32_codepoint_to_utf8((uint8_t *)cur, *cur_utf32++, len);
+    cur += len;
+    dst_len -= len;
+  }
+  LOG_ALWAYS_FATAL_IF(dst_len < 1, "dst_len < 1: %zu < 1", dst_len);
+  *cur = '\0';
+}
+
+// --------------------------------------------------------------------------
+// UTF-16
+// --------------------------------------------------------------------------
+
+int strcmp16(const char16_t *s1, const char16_t *s2) {
+  char16_t ch;
+  int d = 0;
+
+  while (true) {
+    d = (int)(ch = *s1++) - (int)*s2++;
+    if (d || !ch) break;
+  }
+
+  return d;
+}
+
+int strncmp16(const char16_t *s1, const char16_t *s2, size_t n) {
+  char16_t ch;
+  int d = 0;
+
+  if (n == 0) {
+    return 0;
+  }
+
+  do {
+    d = (int)(ch = *s1++) - (int)*s2++;
+    if (d || !ch) {
+      break;
+    }
+  } while (--n);
+
+  return d;
+}
+
+char16_t *strcpy16(char16_t *dst, const char16_t *src) {
+  char16_t *q = dst;
+  const char16_t *p = src;
+  char16_t ch;
+
+  do {
+    *q++ = ch = *p++;
+  } while (ch);
+
+  return dst;
+}
+
+size_t strlen16(const char16_t *s) {
+  const char16_t *ss = s;
+  while (*ss) ss++;
+  return ss - s;
+}
+
+size_t strnlen16(const char16_t *s, size_t maxlen) {
+  const char16_t *ss = s;
+
+  /* Important: the maxlen test must precede the reference through ss;
+     since the byte beyond the maximum may segfault */
+  while ((maxlen > 0) && *ss) {
+    ss++;
+    maxlen--;
+  }
+  return ss - s;
+}
+
+char16_t *strstr16(const char16_t *src, const char16_t *target) {
+  const char16_t needle = *target;
+  if (needle == '\0') return (char16_t *)src;
+
+  const size_t target_len = strlen16(++target);
+  do {
+    do {
+      if (*src == '\0') {
+        return nullptr;
+      }
+    } while (*src++ != needle);
+  } while (strncmp16(src, target, target_len) != 0);
+  src--;
+
+  return (char16_t *)src;
+}
+
+int strzcmp16(const char16_t *s1, size_t n1, const char16_t *s2, size_t n2) {
+  const char16_t *e1 = s1 + n1;
+  const char16_t *e2 = s2 + n2;
+
+  while (s1 < e1 && s2 < e2) {
+    const int d = (int)*s1++ - (int)*s2++;
+    if (d) {
+      return d;
+    }
+  }
+
+  return n1 < n2 ? (0 - (int)*s2) : (n1 > n2 ? ((int)*s1 - 0) : 0);
+}
+
+void utf16_to_utf8(const char16_t *src, size_t src_len, char *dst,
+                   size_t dst_len) {
+  if (src == nullptr || src_len == 0 || dst == nullptr) {
+    return;
+  }
+
+  const char16_t *cur_utf16 = src;
+  const char16_t *const end_utf16 = src + src_len;
+  char *cur = dst;
+  while (cur_utf16 < end_utf16) {
+    char32_t utf32;
+    // surrogate pairs
+    if ((*cur_utf16 & 0xFC00) == 0xD800 && (cur_utf16 + 1) < end_utf16 &&
+        (*(cur_utf16 + 1) & 0xFC00) == 0xDC00) {
+      utf32 = (*cur_utf16++ - 0xD800) << 10;
+      utf32 |= *cur_utf16++ - 0xDC00;
+      utf32 += 0x10000;
+    } else {
+      utf32 = (char32_t)*cur_utf16++;
+    }
+    const size_t len = utf32_codepoint_utf8_length(utf32);
+    LOG_ALWAYS_FATAL_IF(dst_len < len, "%zu < %zu", dst_len, len);
+    utf32_codepoint_to_utf8((uint8_t *)cur, utf32, len);
+    cur += len;
+    dst_len -= len;
+  }
+  LOG_ALWAYS_FATAL_IF(dst_len < 1, "%zu < 1", dst_len);
+  *cur = '\0';
+}
+
+// --------------------------------------------------------------------------
+// UTF-8
+// --------------------------------------------------------------------------
+
+ssize_t utf8_length(const char *src) {
+  const char *cur = src;
+  size_t ret = 0;
+  while (*cur != '\0') {
+    const char first_char = *cur++;
+    if ((first_char & 0x80) == 0) {  // ASCII
+      ret += 1;
+      continue;
+    }
+    // (UTF-8's character must not be like 10xxxxxx,
+    //  but 110xxxxx, 1110xxxx, ... or 1111110x)
+    if ((first_char & 0x40) == 0) {
+      return -1;
+    }
+
+    int32_t mask, to_ignore_mask;
+    size_t num_to_read = 0;
+    char32_t utf32 = 0;
+    for (num_to_read = 1, mask = 0x40, to_ignore_mask = 0x80;
+         num_to_read < 5 && (first_char & mask);
+         num_to_read++, to_ignore_mask |= mask, mask >>= 1) {
+      if ((*cur & 0xC0) != 0x80) {  // must be 10xxxxxx
+        return -1;
+      }
+      // 0x3F == 00111111
+      utf32 = (utf32 << 6) + (*cur++ & 0x3F);
+    }
+    // "first_char" must be (110xxxxx - 11110xxx)
+    if (num_to_read == 5) {
+      return -1;
+    }
+    to_ignore_mask |= mask;
+    utf32 |= ((~to_ignore_mask) & first_char) << (6 * (num_to_read - 1));
+    if (utf32 > kUnicodeMaxCodepoint) {
+      return -1;
+    }
+
+    ret += num_to_read;
+  }
+  return ret;
+}
+
+ssize_t utf16_to_utf8_length(const char16_t *src, size_t src_len) {
+  if (src == nullptr || src_len == 0) {
+    return -1;
+  }
+
+  size_t ret = 0;
+  const char16_t *const end = src + src_len;
+  while (src < end) {
+    size_t char_len;
+    if ((*src & 0xFC00) == 0xD800 && (src + 1) < end &&
+        (*(src + 1) & 0xFC00) == 0xDC00) {
+      // surrogate pairs are always 4 bytes.
+      char_len = 4;
+      src += 2;
+    } else {
+      char_len = utf32_codepoint_utf8_length((char32_t)*src++);
+    }
+    if (SSIZE_MAX - char_len < ret) {
+      // If this happens, we would overflow the ssize_t type when
+      // returning from this function, so we cannot express how
+      // long this string is in an ssize_t.
+      android_errorWriteLog(0x534e4554, "37723026");
+      return -1;
+    }
+    ret += char_len;
+  }
+  return ret;
+}
+
+/**
+ * Returns 1-4 based on the number of leading bits.
+ *
+ * 1111 -> 4
+ * 1110 -> 3
+ * 110x -> 2
+ * 10xx -> 1
+ * 0xxx -> 1
+ */
+static inline size_t utf8_codepoint_len(uint8_t ch) {
+  return ((0xe5000000 >> ((ch >> 3) & 0x1e)) & 3) + 1;
+}
+
+static inline void utf8_shift_and_mask(uint32_t *codePoint,
+                                       const uint8_t byte) {
+  *codePoint <<= 6;
+  *codePoint |= 0x3F & byte;
+}
+
+static inline uint32_t utf8_to_utf32_codepoint(const uint8_t *src,
+                                               size_t length) {
+  uint32_t unicode;
+
+  switch (length) {
+    case 1:
+      return src[0];
+    case 2:
+      unicode = src[0] & 0x1f;
+      utf8_shift_and_mask(&unicode, src[1]);
+      return unicode;
+    case 3:
+      unicode = src[0] & 0x0f;
+      utf8_shift_and_mask(&unicode, src[1]);
+      utf8_shift_and_mask(&unicode, src[2]);
+      return unicode;
+    case 4:
+      unicode = src[0] & 0x07;
+      utf8_shift_and_mask(&unicode, src[1]);
+      utf8_shift_and_mask(&unicode, src[2]);
+      utf8_shift_and_mask(&unicode, src[3]);
+      return unicode;
+    default:
+      return 0xffff;
+  }
+
+  // printf("Char at %p: len=%d, utf-16=%p\n", src, length, (void*)result);
+}
+
+ssize_t utf8_to_utf16_length(const uint8_t *u8str, size_t u8len,
+                             bool overreadIsFatal) {
+  const uint8_t *const u8end = u8str + u8len;
+  const uint8_t *u8cur = u8str;
+
+  /* Validate that the UTF-8 is the correct len */
+  size_t u16measuredLen = 0;
+  while (u8cur < u8end) {
+    u16measuredLen++;
+    int u8charLen = utf8_codepoint_len(*u8cur);
+    // Malformed utf8, some characters are beyond the end.
+    // Cases:
+    // If u8charLen == 1, this becomes u8cur >= u8end, which cannot happen as
+    // u8cur < u8end, then this condition fail and we continue, as expected. If
+    // u8charLen == 2, this becomes u8cur + 1 >= u8end, which fails only if
+    // u8cur == u8end - 1, that is, there was only one remaining character to
+    // read but we need 2 of them. This condition holds and we return -1, as
+    // expected.
+    if (u8cur + u8charLen - 1 >= u8end) {
+      if (overreadIsFatal) {
+        LOG_ALWAYS_FATAL("Attempt to overread computing length of utf8 string");
+      } else {
+        return -1;
+      }
+    }
+    uint32_t codepoint = utf8_to_utf32_codepoint(u8cur, u8charLen);
+    if (codepoint > 0xFFFF)
+      u16measuredLen++;  // this will be a surrogate pair in utf16
+    u8cur += u8charLen;
+  }
+
+  /**
+   * Make sure that we ended where we thought we would and the output UTF-16
+   * will be exactly how long we were told it would be.
+   */
+  if (u8cur != u8end) {
+    return -1;
+  }
+
+  return u16measuredLen;
+}
+
+char16_t *utf8_to_utf16(const uint8_t *u8str, size_t u8len, char16_t *u16str,
+                        size_t u16len) {
+  // A value > SSIZE_MAX is probably a negative value returned as an error and
+  // casted.
+  LOG_ALWAYS_FATAL_IF(u16len == 0 || u16len > SSIZE_MAX, "u16len is %zu",
+                      u16len);
+  char16_t *end =
+      utf8_to_utf16_no_null_terminator(u8str, u8len, u16str, u16len - 1);
+  *end = 0;
+  return end;
+}
+
+char16_t *utf8_to_utf16_no_null_terminator(const uint8_t *src, size_t srcLen,
+                                           char16_t *dst, size_t dstLen) {
+  if (dstLen == 0) {
+    return dst;
+  }
+  // A value > SSIZE_MAX is probably a negative value returned as an error and
+  // casted.
+  LOG_ALWAYS_FATAL_IF(dstLen > SSIZE_MAX, "dstLen is %zu", dstLen);
+  const uint8_t *const u8end = src + srcLen;
+  const uint8_t *u8cur = src;
+  const char16_t *const u16end = dst + dstLen;
+  char16_t *u16cur = dst;
+
+  while (u8cur < u8end && u16cur < u16end) {
+    size_t u8len = utf8_codepoint_len(*u8cur);
+    uint32_t codepoint = utf8_to_utf32_codepoint(u8cur, u8len);
+
+    // Convert the UTF32 codepoint to one or more UTF16 codepoints
+    if (codepoint <= 0xFFFF) {
+      // Single UTF16 character
+      *u16cur++ = (char16_t)codepoint;
+    } else {
+      // Multiple UTF16 characters with surrogates
+      codepoint = codepoint - 0x10000;
+      *u16cur++ = (char16_t)((codepoint >> 10) + 0xD800);
+      if (u16cur >= u16end) {
+        // Ooops...  not enough room for this surrogate pair.
+        return u16cur - 1;
+      }
+      *u16cur++ = (char16_t)((codepoint & 0x3FF) + 0xDC00);
+    }
+
+    u8cur += u8len;
+  }
+  return u16cur;
+}
+}
diff --git a/nativeruntime/cpp/libutils/include/utils/Compat.h b/nativeruntime/cpp/libutils/include/utils/Compat.h
new file mode 100644
index 0000000..2846441
--- /dev/null
+++ b/nativeruntime/cpp/libutils/include/utils/Compat.h
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2010 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/include/utils/Compat.h
+
+#ifndef __LIB_UTILS_COMPAT_H
+#define __LIB_UTILS_COMPAT_H
+
+#include <unistd.h>
+
+#if !defined(__MINGW32__)
+#include <sys/mman.h>
+#endif
+
+#if defined(__APPLE__)
+
+/* Mac OS has always had a 64-bit off_t, so it doesn't have off64_t. */
+static_assert(sizeof(off_t) >= 8,
+              "This code requires that Mac OS have at least a 64-bit off_t.");
+typedef off_t off64_t;
+
+static inline void* mmap64(void* addr, size_t length, int prot, int flags,
+                           int fd, off64_t offset) {
+  return mmap(addr, length, prot, flags, fd, offset);
+}
+
+static inline off64_t lseek64(int fd, off64_t offset, int whence) {
+  return lseek(fd, offset, whence);
+}
+
+static inline ssize_t pread64(int fd, void* buf, size_t nbytes,
+                              off64_t offset) {
+  return pread(fd, buf, nbytes, offset);
+}
+
+static inline ssize_t pwrite64(int fd, const void* buf, size_t nbytes,
+                               off64_t offset) {
+  return pwrite(fd, buf, nbytes, offset);
+}
+
+static inline int ftruncate64(int fd, off64_t length) {
+  return ftruncate(fd, length);
+}
+
+#endif /* __APPLE__ */
+
+#if defined(_WIN32)
+#define O_CLOEXEC O_NOINHERIT
+#define O_NOFOLLOW 0
+#define DEFFILEMODE 0666
+#endif /* _WIN32 */
+
+#define ZD "%zd"
+#define ZD_TYPE ssize_t
+
+/*
+ * Needed for cases where something should be constexpr if possible, but not
+ * being constexpr is fine if in pre-C++11 code (such as a const static float
+ * member variable).
+ */
+#if __cplusplus >= 201103L
+#define CONSTEXPR constexpr
+#else
+#define CONSTEXPR
+#endif
+
+/*
+ * TEMP_FAILURE_RETRY is defined by some, but not all, versions of
+ * <unistd.h>. (Alas, it is not as standard as we'd hoped!) So, if it's
+ * not already defined, then define it here.
+ */
+#ifndef TEMP_FAILURE_RETRY
+/* Used to retry syscalls that can return EINTR. */
+#define TEMP_FAILURE_RETRY(exp)            \
+  ({                                       \
+    typeof(exp) _rc;                       \
+    do {                                   \
+      _rc = (exp);                         \
+    } while (_rc == -1 && errno == EINTR); \
+    _rc;                                   \
+  })
+#endif
+
+#if defined(_WIN32)
+#define OS_PATH_SEPARATOR '\\'
+#else
+#define OS_PATH_SEPARATOR '/'
+#endif
+
+#endif /* __LIB_UTILS_COMPAT_H */
diff --git a/nativeruntime/cpp/libutils/include/utils/Errors.h b/nativeruntime/cpp/libutils/include/utils/Errors.h
new file mode 100644
index 0000000..673f2cc
--- /dev/null
+++ b/nativeruntime/cpp/libutils/include/utils/Errors.h
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2007 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/include/utils/Errors.h
+
+#ifndef ANDROID_ERRORS_H
+#define ANDROID_ERRORS_H
+
+#include <errno.h>
+#include <stdint.h>
+#include <sys/types.h>
+
+#include <string>
+
+namespace android {
+
+/**
+ * The type used to return success/failure from frameworks APIs.
+ * See the anonymous enum below for valid values.
+ */
+typedef int32_t status_t;
+
+/*
+ * Error codes.
+ * All error codes are negative values.
+ */
+
+// Win32 #defines NO_ERROR as well.  It has the same value, so there's no
+// real conflict, though it's a bit awkward.
+#ifdef _WIN32
+#undef NO_ERROR
+#endif
+
+enum {
+  OK = 0,         // Preferred constant for checking success.
+  NO_ERROR = OK,  // Deprecated synonym for `OK`. Prefer `OK` because it doesn't
+                  // conflict with Windows.
+
+  UNKNOWN_ERROR = (-2147483647 - 1),  // INT32_MIN value
+
+  NO_MEMORY = -ENOMEM,
+  INVALID_OPERATION = -ENOSYS,
+  BAD_VALUE = -EINVAL,
+  BAD_TYPE = (UNKNOWN_ERROR + 1),
+  NAME_NOT_FOUND = -ENOENT,
+  PERMISSION_DENIED = -EPERM,
+  NO_INIT = -ENODEV,
+  ALREADY_EXISTS = -EEXIST,
+  DEAD_OBJECT = -EPIPE,
+  FAILED_TRANSACTION = (UNKNOWN_ERROR + 2),
+#if !defined(_WIN32)
+  BAD_INDEX = -EOVERFLOW,
+  NOT_ENOUGH_DATA = -ENODATA,
+  WOULD_BLOCK = -EWOULDBLOCK,
+  TIMED_OUT = -ETIMEDOUT,
+  UNKNOWN_TRANSACTION = -EBADMSG,
+#else
+  BAD_INDEX = -E2BIG,
+  NOT_ENOUGH_DATA = (UNKNOWN_ERROR + 3),
+  WOULD_BLOCK = (UNKNOWN_ERROR + 4),
+  TIMED_OUT = (UNKNOWN_ERROR + 5),
+  UNKNOWN_TRANSACTION = (UNKNOWN_ERROR + 6),
+#endif
+  FDS_NOT_ALLOWED = (UNKNOWN_ERROR + 7),
+  UNEXPECTED_NULL = (UNKNOWN_ERROR + 8),
+};
+
+// Human readable name of error
+std::string statusToString(status_t status);
+
+// Restore define; enumeration is in "android" namespace, so the value defined
+// there won't work for Win32 code in a different namespace.
+#ifdef _WIN32
+#define NO_ERROR 0L
+#endif
+
+}  // namespace android
+
+#endif  // ANDROID_ERRORS_H
diff --git a/nativeruntime/cpp/libutils/include/utils/String16.h b/nativeruntime/cpp/libutils/include/utils/String16.h
new file mode 100644
index 0000000..6794b32
--- /dev/null
+++ b/nativeruntime/cpp/libutils/include/utils/String16.h
@@ -0,0 +1,331 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/include/utils/String16.h
+
+#ifndef ANDROID_STRING16_H
+#define ANDROID_STRING16_H
+
+#include <utils/Errors.h>
+#include <utils/String8.h>
+#include <utils/TypeHelpers.h>
+
+#include <iostream>
+#include <string>
+
+// ---------------------------------------------------------------------------
+
+namespace android {
+
+// ---------------------------------------------------------------------------
+
+template <size_t N>
+class StaticString16;
+
+// DO NOT USE: please use std::u16string
+
+//! This is a string holding UTF-16 characters.
+class String16 {
+ public:
+  /*
+   * Use String16(StaticLinkage) if you're statically linking against
+   * libutils and declaring an empty static String16, e.g.:
+   *
+   *   static String16 sAStaticEmptyString(String16::kEmptyString);
+   *   static String16 sAnotherStaticEmptyString(sAStaticEmptyString);
+   */
+  enum StaticLinkage { kEmptyString };
+
+  String16();
+  explicit String16(StaticLinkage);
+  String16(const String16& o);
+  String16(const String16& o, size_t len, size_t begin = 0);
+  explicit String16(const char16_t* o);
+  explicit String16(const char16_t* o, size_t len);
+  explicit String16(const String8& o);
+  explicit String16(const char* o);
+  explicit String16(const char* o, size_t len);
+
+  ~String16();
+
+  inline const char16_t* string() const;
+
+ private:
+  static inline std::string std_string(const String16& str);
+
+ public:
+  size_t size() const;
+  void setTo(const String16& other);
+  status_t setTo(const char16_t* other);
+  status_t setTo(const char16_t* other, size_t len);
+  status_t setTo(const String16& other, size_t len, size_t begin = 0);
+
+  status_t append(const String16& other);
+  status_t append(const char16_t* chrs, size_t len);
+
+  inline String16& operator=(const String16& other);
+
+  inline String16& operator+=(const String16& other);
+  inline String16 operator+(const String16& other) const;
+
+  status_t insert(size_t pos, const char16_t* chrs);
+  status_t insert(size_t pos, const char16_t* chrs, size_t len);
+
+  ssize_t findFirst(char16_t c) const;
+  ssize_t findLast(char16_t c) const;
+
+  bool startsWith(const String16& prefix) const;
+  bool startsWith(const char16_t* prefix) const;
+
+  bool contains(const char16_t* chrs) const;
+
+  status_t makeLower();
+
+  status_t replaceAll(char16_t replaceThis, char16_t withThis);
+
+  status_t remove(size_t len, size_t begin = 0);
+
+  inline int compare(const String16& other) const;
+
+  inline bool operator<(const String16& other) const;
+  inline bool operator<=(const String16& other) const;
+  inline bool operator==(const String16& other) const;
+  inline bool operator!=(const String16& other) const;
+  inline bool operator>=(const String16& other) const;
+  inline bool operator>(const String16& other) const;
+
+  inline bool operator<(const char16_t* other) const;
+  inline bool operator<=(const char16_t* other) const;
+  inline bool operator==(const char16_t* other) const;
+  inline bool operator!=(const char16_t* other) const;
+  inline bool operator>=(const char16_t* other) const;
+  inline bool operator>(const char16_t* other) const;
+
+  inline operator const char16_t*() const;
+
+  // Static and non-static String16 behave the same for the users, so
+  // this method isn't of much use for the users. It is public for testing.
+  bool isStaticString() const;
+
+ private:
+  /*
+   * A flag indicating the type of underlying buffer.
+   */
+  static constexpr uint32_t kIsSharedBufferAllocated = 0x80000000;
+
+  /*
+   * alloc() returns void* so that SharedBuffer class is not exposed.
+   */
+  static void* alloc(size_t size);
+  static char16_t* allocFromUTF8(const char* u8str, size_t u8len);
+  static char16_t* allocFromUTF16(const char16_t* u16str, size_t u16len);
+
+  /*
+   * edit() and editResize() return void* so that SharedBuffer class
+   * is not exposed.
+   */
+  void* edit();
+  void* editResize(size_t newSize);
+
+  void acquire();
+  void release();
+
+  size_t staticStringSize() const;
+
+  const char16_t* mString;
+
+ protected:
+  /*
+   * Data structure used to allocate static storage for static String16.
+   *
+   * Note that this data structure and SharedBuffer are used interchangeably
+   * as the underlying data structure for a String16.  Therefore, the layout
+   * of this data structure must match the part in SharedBuffer that is
+   * visible to String16.
+   */
+  template <size_t N>
+  struct StaticData {
+    // The high bit of 'size' is used as a flag.
+    static_assert(N - 1 < kIsSharedBufferAllocated, "StaticString16 too long!");
+    constexpr StaticData() : size(N - 1), data{0} {}
+    const uint32_t size;
+    char16_t data[N];
+
+    constexpr StaticData(const StaticData<N>&) = default;
+  };
+
+  /*
+   * Helper function for constructing a StaticData object.
+   */
+  template <size_t N>
+  static constexpr const StaticData<N> makeStaticData(const char16_t (&s)[N]) {
+    StaticData<N> r;
+    // The 'size' field is at the same location where mClientMetadata would
+    // be for a SharedBuffer.  We do NOT set kIsSharedBufferAllocated flag
+    // here.
+    for (size_t i = 0; i < N - 1; ++i) r.data[i] = s[i];
+    return r;
+  }
+
+  template <size_t N>
+  explicit constexpr String16(const StaticData<N>& s) : mString(s.data) {}
+
+ public:
+  template <size_t N>
+  explicit constexpr String16(const StaticString16<N>& s)
+      : mString(s.mString) {}
+};
+
+// String16 can be trivially moved using memcpy() because moving does not
+// require any change to the underlying SharedBuffer contents or reference
+// count.
+ANDROID_TRIVIAL_MOVE_TRAIT(String16)
+
+static inline std::ostream& operator<<(std::ostream& os, const String16& str) {
+  os << String8(str).c_str();
+  return os;
+}
+
+// ---------------------------------------------------------------------------
+
+/*
+ * A StaticString16 object is a specialized String16 object.  Instead of holding
+ * the string data in a ref counted SharedBuffer object, it holds data in a
+ * buffer within StaticString16 itself.  Note that this buffer is NOT ref
+ * counted and is assumed to be available for as long as there is at least a
+ * String16 object using it.  Therefore, one must be extra careful to NEVER
+ * assign a StaticString16 to a String16 that outlives the StaticString16
+ * object.
+ *
+ * THE SAFEST APPROACH IS TO USE StaticString16 ONLY AS GLOBAL VARIABLES.
+ *
+ * A StaticString16 SHOULD NEVER APPEAR IN APIs.  USE String16 INSTEAD.
+ */
+template <size_t N>
+class StaticString16 : public String16 {
+ public:
+  constexpr StaticString16(const char16_t (&s)[N])
+      : String16(mData), mData(makeStaticData(s)) {}
+
+  constexpr StaticString16(const StaticString16<N>& other)
+      : String16(mData), mData(other.mData) {}
+
+  constexpr StaticString16(const StaticString16<N>&&) = delete;
+
+  // There is no reason why one would want to 'new' a StaticString16.  Delete
+  // it to discourage misuse.
+  static void* operator new(std::size_t) = delete;
+
+ private:
+  const StaticData<N> mData;
+};
+
+template <typename F>
+StaticString16(const F&) -> StaticString16<sizeof(F) / sizeof(char16_t)>;
+
+// ---------------------------------------------------------------------------
+// No user servicable parts below.
+
+inline int compare_type(const String16& lhs, const String16& rhs) {
+  return lhs.compare(rhs);
+}
+
+inline int strictly_order_type(const String16& lhs, const String16& rhs) {
+  return compare_type(lhs, rhs) < 0;
+}
+
+inline const char16_t* String16::string() const { return mString; }
+
+inline std::string String16::std_string(const String16& str) {
+  return std::string(String8(str).string());
+}
+
+inline String16& String16::operator=(const String16& other) {
+  setTo(other);
+  return *this;
+}
+
+inline String16& String16::operator+=(const String16& other) {
+  append(other);
+  return *this;
+}
+
+inline String16 String16::operator+(const String16& other) const {
+  String16 tmp(*this);
+  tmp += other;
+  return tmp;
+}
+
+inline int String16::compare(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size());
+}
+
+inline bool String16::operator<(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size()) < 0;
+}
+
+inline bool String16::operator<=(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size()) <= 0;
+}
+
+inline bool String16::operator==(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size()) == 0;
+}
+
+inline bool String16::operator!=(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size()) != 0;
+}
+
+inline bool String16::operator>=(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size()) >= 0;
+}
+
+inline bool String16::operator>(const String16& other) const {
+  return strzcmp16(mString, size(), other.mString, other.size()) > 0;
+}
+
+inline bool String16::operator<(const char16_t* other) const {
+  return strcmp16(mString, other) < 0;
+}
+
+inline bool String16::operator<=(const char16_t* other) const {
+  return strcmp16(mString, other) <= 0;
+}
+
+inline bool String16::operator==(const char16_t* other) const {
+  return strcmp16(mString, other) == 0;
+}
+
+inline bool String16::operator!=(const char16_t* other) const {
+  return strcmp16(mString, other) != 0;
+}
+
+inline bool String16::operator>=(const char16_t* other) const {
+  return strcmp16(mString, other) >= 0;
+}
+
+inline bool String16::operator>(const char16_t* other) const {
+  return strcmp16(mString, other) > 0;
+}
+
+inline String16::operator const char16_t*() const { return mString; }
+
+}  // namespace android
+
+// ---------------------------------------------------------------------------
+
+#endif  // ANDROID_STRING16_H
diff --git a/nativeruntime/cpp/libutils/include/utils/String8.h b/nativeruntime/cpp/libutils/include/utils/String8.h
new file mode 100644
index 0000000..af8d03c
--- /dev/null
+++ b/nativeruntime/cpp/libutils/include/utils/String8.h
@@ -0,0 +1,371 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/include/utils/String8.h
+
+#ifndef ANDROID_STRING8_H
+#define ANDROID_STRING8_H
+
+#include <stdarg.h>
+#include <string.h>  // for strcmp
+#include <utils/Errors.h>
+#include <utils/TypeHelpers.h>
+#include <utils/Unicode.h>
+
+#include <string>  // for std::string
+
+// ---------------------------------------------------------------------------
+
+namespace android {
+
+class String16;
+
+// DO NOT USE: please use std::string
+
+//! This is a string holding UTF-8 characters. Does not allow the value more
+// than 0x10FFFF, which is not valid unicode codepoint.
+class String8 {
+ public:
+  /* use String8(StaticLinkage) if you're statically linking against
+   * libutils and declaring an empty static String8, e.g.:
+   *
+   *   static String8 sAStaticEmptyString(String8::kEmptyString);
+   *   static String8 sAnotherStaticEmptyString(sAStaticEmptyString);
+   */
+  enum StaticLinkage { kEmptyString };
+
+  String8();
+  explicit String8(StaticLinkage);
+  String8(const String8& o);
+  explicit String8(const char* o);
+  explicit String8(const char* o, size_t len);
+
+  explicit String8(const String16& o);
+  explicit String8(const char16_t* o);
+  explicit String8(const char16_t* o, size_t len);
+  explicit String8(const char32_t* o);
+  explicit String8(const char32_t* o, size_t len);
+  ~String8();
+
+  static inline const String8 empty();
+
+  static String8 format(const char* fmt, ...)
+      __attribute__((format(printf, 1, 2)));
+  static String8 formatV(const char* fmt, va_list args);
+
+  inline const char* c_str() const;
+  inline const char* string() const;
+
+ private:
+  static inline std::string std_string(const String8& str);
+
+ public:
+  inline size_t size() const;
+  inline size_t bytes() const;
+  inline bool isEmpty() const;
+
+  size_t length() const;
+
+  void clear();
+
+  void setTo(const String8& other);
+  status_t setTo(const char* other);
+  status_t setTo(const char* other, size_t len);
+  status_t setTo(const char16_t* other, size_t len);
+  status_t setTo(const char32_t* other, size_t length);
+
+  status_t append(const String8& other);
+  status_t append(const char* other);
+  status_t append(const char* other, size_t otherLen);
+
+  status_t appendFormat(const char* fmt, ...)
+      __attribute__((format(printf, 2, 3)));
+  status_t appendFormatV(const char* fmt, va_list args);
+
+  inline String8& operator=(const String8& other);
+  inline String8& operator=(const char* other);
+
+  inline String8& operator+=(const String8& other);
+  inline String8 operator+(const String8& other) const;
+
+  inline String8& operator+=(const char* other);
+  inline String8 operator+(const char* other) const;
+
+  inline int compare(const String8& other) const;
+
+  inline bool operator<(const String8& other) const;
+  inline bool operator<=(const String8& other) const;
+  inline bool operator==(const String8& other) const;
+  inline bool operator!=(const String8& other) const;
+  inline bool operator>=(const String8& other) const;
+  inline bool operator>(const String8& other) const;
+
+  inline bool operator<(const char* other) const;
+  inline bool operator<=(const char* other) const;
+  inline bool operator==(const char* other) const;
+  inline bool operator!=(const char* other) const;
+  inline bool operator>=(const char* other) const;
+  inline bool operator>(const char* other) const;
+
+  inline operator const char*() const;
+
+  char* lockBuffer(size_t size);
+  void unlockBuffer();
+  status_t unlockBuffer(size_t size);
+
+  // return the index of the first byte of other in this at or after
+  // start, or -1 if not found
+  ssize_t find(const char* other, size_t start = 0) const;
+
+  // return true if this string contains the specified substring
+  inline bool contains(const char* other) const;
+
+  // removes all occurrence of the specified substring
+  // returns true if any were found and removed
+  bool removeAll(const char* other);
+
+  void toLower();
+  void toLower(size_t start, size_t length);
+  void toUpper();
+  void toUpper(size_t start, size_t length);
+
+  /*
+   * These methods operate on the string as if it were a path name.
+   */
+
+  /*
+   * Set the filename field to a specific value.
+   *
+   * Normalizes the filename, removing a trailing '/' if present.
+   */
+  void setPathName(const char* name);
+  void setPathName(const char* name, size_t len);
+
+  /*
+   * Get just the filename component.
+   *
+   * "/tmp/foo/bar.c" --> "bar.c"
+   */
+  String8 getPathLeaf() const;
+
+  /*
+   * Remove the last (file name) component, leaving just the directory
+   * name.
+   *
+   * "/tmp/foo/bar.c" --> "/tmp/foo"
+   * "/tmp" --> "" // ????? shouldn't this be "/" ???? XXX
+   * "bar.c" --> ""
+   */
+  String8 getPathDir() const;
+
+  /*
+   * Retrieve the front (root dir) component.  Optionally also return the
+   * remaining components.
+   *
+   * "/tmp/foo/bar.c" --> "tmp" (remain = "foo/bar.c")
+   * "/tmp" --> "tmp" (remain = "")
+   * "bar.c" --> "bar.c" (remain = "")
+   */
+  String8 walkPath(String8* outRemains = nullptr) const;
+
+  /*
+   * Return the filename extension.  This is the last '.' and any number
+   * of characters that follow it.  The '.' is included in case we
+   * decide to expand our definition of what constitutes an extension.
+   *
+   * "/tmp/foo/bar.c" --> ".c"
+   * "/tmp" --> ""
+   * "/tmp/foo.bar/baz" --> ""
+   * "foo.jpeg" --> ".jpeg"
+   * "foo." --> ""
+   */
+  String8 getPathExtension() const;
+
+  /*
+   * Return the path without the extension.  Rules for what constitutes
+   * an extension are described in the comment for getPathExtension().
+   *
+   * "/tmp/foo/bar.c" --> "/tmp/foo/bar"
+   */
+  String8 getBasePath() const;
+
+  /*
+   * Add a component to the pathname.  We guarantee that there is
+   * exactly one path separator between the old path and the new.
+   * If there is no existing name, we just copy the new name in.
+   *
+   * If leaf is a fully qualified path (i.e. starts with '/', it
+   * replaces whatever was there before.
+   */
+  String8& appendPath(const char* leaf);
+  String8& appendPath(const String8& leaf) { return appendPath(leaf.string()); }
+
+  /*
+   * Like appendPath(), but does not affect this string.  Returns a new one
+   * instead.
+   */
+  String8 appendPathCopy(const char* leaf) const {
+    String8 p(*this);
+    p.appendPath(leaf);
+    return p;
+  }
+  String8 appendPathCopy(const String8& leaf) const {
+    return appendPathCopy(leaf.string());
+  }
+
+  /*
+   * Converts all separators in this string to /, the default path separator.
+   *
+   * If the default OS separator is backslash, this converts all
+   * backslashes to slashes, in-place. Otherwise it does nothing.
+   * Returns self.
+   */
+  String8& convertToResPath();
+
+ private:
+  status_t real_append(const char* other, size_t otherLen);
+  char* find_extension() const;
+
+  const char* mString;
+};
+
+// String8 can be trivially moved using memcpy() because moving does not
+// require any change to the underlying SharedBuffer contents or reference
+// count.
+ANDROID_TRIVIAL_MOVE_TRAIT(String8)
+
+// ---------------------------------------------------------------------------
+// No user servicable parts below.
+
+inline int compare_type(const String8& lhs, const String8& rhs) {
+  return lhs.compare(rhs);
+}
+
+inline int strictly_order_type(const String8& lhs, const String8& rhs) {
+  return compare_type(lhs, rhs) < 0;
+}
+
+inline const String8 String8::empty() { return String8(); }
+
+inline const char* String8::c_str() const { return mString; }
+inline const char* String8::string() const { return mString; }
+
+inline std::string String8::std_string(const String8& str) {
+  return std::string(str.string());
+}
+
+inline size_t String8::size() const { return length(); }
+
+inline bool String8::isEmpty() const { return length() == 0; }
+
+inline size_t String8::bytes() const { return length(); }
+
+inline bool String8::contains(const char* other) const {
+  return find(other) >= 0;
+}
+
+inline String8& String8::operator=(const String8& other) {
+  setTo(other);
+  return *this;
+}
+
+inline String8& String8::operator=(const char* other) {
+  setTo(other);
+  return *this;
+}
+
+inline String8& String8::operator+=(const String8& other) {
+  append(other);
+  return *this;
+}
+
+inline String8 String8::operator+(const String8& other) const {
+  String8 tmp(*this);
+  tmp += other;
+  return tmp;
+}
+
+inline String8& String8::operator+=(const char* other) {
+  append(other);
+  return *this;
+}
+
+inline String8 String8::operator+(const char* other) const {
+  String8 tmp(*this);
+  tmp += other;
+  return tmp;
+}
+
+inline int String8::compare(const String8& other) const {
+  return strcmp(mString, other.mString);
+}
+
+inline bool String8::operator<(const String8& other) const {
+  return strcmp(mString, other.mString) < 0;
+}
+
+inline bool String8::operator<=(const String8& other) const {
+  return strcmp(mString, other.mString) <= 0;
+}
+
+inline bool String8::operator==(const String8& other) const {
+  return strcmp(mString, other.mString) == 0;
+}
+
+inline bool String8::operator!=(const String8& other) const {
+  return strcmp(mString, other.mString) != 0;
+}
+
+inline bool String8::operator>=(const String8& other) const {
+  return strcmp(mString, other.mString) >= 0;
+}
+
+inline bool String8::operator>(const String8& other) const {
+  return strcmp(mString, other.mString) > 0;
+}
+
+inline bool String8::operator<(const char* other) const {
+  return strcmp(mString, other) < 0;
+}
+
+inline bool String8::operator<=(const char* other) const {
+  return strcmp(mString, other) <= 0;
+}
+
+inline bool String8::operator==(const char* other) const {
+  return strcmp(mString, other) == 0;
+}
+
+inline bool String8::operator!=(const char* other) const {
+  return strcmp(mString, other) != 0;
+}
+
+inline bool String8::operator>=(const char* other) const {
+  return strcmp(mString, other) >= 0;
+}
+
+inline bool String8::operator>(const char* other) const {
+  return strcmp(mString, other) > 0;
+}
+
+inline String8::operator const char*() const { return mString; }
+
+}  // namespace android
+
+// ---------------------------------------------------------------------------
+
+#endif  // ANDROID_STRING8_H
diff --git a/nativeruntime/cpp/libutils/include/utils/TypeHelpers.h b/nativeruntime/cpp/libutils/include/utils/TypeHelpers.h
new file mode 100644
index 0000000..9c5b14d
--- /dev/null
+++ b/nativeruntime/cpp/libutils/include/utils/TypeHelpers.h
@@ -0,0 +1,370 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/include/utils/TypeHelpers.h
+
+#ifndef ANDROID_TYPE_HELPERS_H
+#define ANDROID_TYPE_HELPERS_H
+
+#include <stdint.h>
+#include <string.h>
+#include <sys/types.h>
+
+#include <new>
+#include <type_traits>
+
+// ---------------------------------------------------------------------------
+
+namespace android {
+
+/*
+ * Types traits
+ */
+
+template <typename T>
+struct trait_trivial_ctor {
+  enum { value = false };
+};
+template <typename T>
+struct trait_trivial_dtor {
+  enum { value = false };
+};
+template <typename T>
+struct trait_trivial_copy {
+  enum { value = false };
+};
+template <typename T>
+struct trait_trivial_move {
+  enum { value = false };
+};
+template <typename T>
+struct trait_pointer {
+  enum { value = false };
+};
+template <typename T>
+struct trait_pointer<T*> {
+  enum { value = true };
+};
+
+template <typename TYPE>
+struct traits {
+  enum {
+    // whether this type is a pointer
+    is_pointer = trait_pointer<TYPE>::value,
+    // whether this type's constructor is a no-op
+    has_trivial_ctor = is_pointer || trait_trivial_ctor<TYPE>::value,
+    // whether this type's destructor is a no-op
+    has_trivial_dtor = is_pointer || trait_trivial_dtor<TYPE>::value,
+    // whether this type type can be copy-constructed with memcpy
+    has_trivial_copy = is_pointer || trait_trivial_copy<TYPE>::value,
+    // whether this type can be moved with memmove
+    has_trivial_move = is_pointer || trait_trivial_move<TYPE>::value
+  };
+};
+
+template <typename T, typename U>
+struct aggregate_traits {
+  enum {
+    is_pointer = false,
+    has_trivial_ctor =
+        traits<T>::has_trivial_ctor && traits<U>::has_trivial_ctor,
+    has_trivial_dtor =
+        traits<T>::has_trivial_dtor && traits<U>::has_trivial_dtor,
+    has_trivial_copy =
+        traits<T>::has_trivial_copy && traits<U>::has_trivial_copy,
+    has_trivial_move =
+        traits<T>::has_trivial_move && traits<U>::has_trivial_move
+  };
+};
+
+#define ANDROID_TRIVIAL_CTOR_TRAIT(T) \
+  template <>                         \
+  struct trait_trivial_ctor<T> {      \
+    enum { value = true };            \
+  };
+
+#define ANDROID_TRIVIAL_DTOR_TRAIT(T) \
+  template <>                         \
+  struct trait_trivial_dtor<T> {      \
+    enum { value = true };            \
+  };
+
+#define ANDROID_TRIVIAL_COPY_TRAIT(T) \
+  template <>                         \
+  struct trait_trivial_copy<T> {      \
+    enum { value = true };            \
+  };
+
+#define ANDROID_TRIVIAL_MOVE_TRAIT(T) \
+  template <>                         \
+  struct trait_trivial_move<T> {      \
+    enum { value = true };            \
+  };
+
+#define ANDROID_BASIC_TYPES_TRAITS(T) \
+  ANDROID_TRIVIAL_CTOR_TRAIT(T)       \
+  ANDROID_TRIVIAL_DTOR_TRAIT(T)       \
+  ANDROID_TRIVIAL_COPY_TRAIT(T)       \
+  ANDROID_TRIVIAL_MOVE_TRAIT(T)
+
+// ---------------------------------------------------------------------------
+
+/*
+ * basic types traits
+ */
+
+ANDROID_BASIC_TYPES_TRAITS(void)
+ANDROID_BASIC_TYPES_TRAITS(bool)
+ANDROID_BASIC_TYPES_TRAITS(char)
+ANDROID_BASIC_TYPES_TRAITS(unsigned char)
+ANDROID_BASIC_TYPES_TRAITS(short)
+ANDROID_BASIC_TYPES_TRAITS(unsigned short)
+ANDROID_BASIC_TYPES_TRAITS(int)
+ANDROID_BASIC_TYPES_TRAITS(unsigned int)
+ANDROID_BASIC_TYPES_TRAITS(long)
+ANDROID_BASIC_TYPES_TRAITS(unsigned long)
+ANDROID_BASIC_TYPES_TRAITS(long long)
+ANDROID_BASIC_TYPES_TRAITS(unsigned long long)
+ANDROID_BASIC_TYPES_TRAITS(float)
+ANDROID_BASIC_TYPES_TRAITS(double)
+
+// ---------------------------------------------------------------------------
+
+/*
+ * compare and order types
+ */
+
+template <typename TYPE>
+inline int strictly_order_type(const TYPE& lhs, const TYPE& rhs) {
+  return (lhs < rhs) ? 1 : 0;
+}
+
+template <typename TYPE>
+inline int compare_type(const TYPE& lhs, const TYPE& rhs) {
+  return strictly_order_type(rhs, lhs) - strictly_order_type(lhs, rhs);
+}
+
+/*
+ * create, destroy, copy and move types...
+ */
+
+template <typename TYPE>
+inline void construct_type(TYPE* p, size_t n) {
+  if (!traits<TYPE>::has_trivial_ctor) {
+    while (n > 0) {
+      n--;
+      new (p++) TYPE;
+    }
+  }
+}
+
+template <typename TYPE>
+inline void destroy_type(TYPE* p, size_t n) {
+  if (!traits<TYPE>::has_trivial_dtor) {
+    while (n > 0) {
+      n--;
+      p->~TYPE();
+      p++;
+    }
+  }
+}
+
+template <typename TYPE>
+typename std::enable_if<traits<TYPE>::has_trivial_copy>::type inline copy_type(
+    TYPE* d, const TYPE* s, size_t n) {
+  memcpy(d, s, n * sizeof(TYPE));
+}
+
+template <typename TYPE>
+typename std::enable_if<!traits<TYPE>::has_trivial_copy>::type inline copy_type(
+    TYPE* d, const TYPE* s, size_t n) {
+  while (n > 0) {
+    n--;
+    new (d) TYPE(*s);
+    d++, s++;
+  }
+}
+
+template <typename TYPE>
+inline void splat_type(TYPE* where, const TYPE* what, size_t n) {
+  if (!traits<TYPE>::has_trivial_copy) {
+    while (n > 0) {
+      n--;
+      new (where) TYPE(*what);
+      where++;
+    }
+  } else {
+    while (n > 0) {
+      n--;
+      *where++ = *what;
+    }
+  }
+}
+
+template <typename TYPE>
+struct use_trivial_move
+    : public std::integral_constant<bool, (traits<TYPE>::has_trivial_dtor &&
+                                           traits<TYPE>::has_trivial_copy) ||
+                                              traits<TYPE>::has_trivial_move> {
+};
+
+template <typename TYPE>
+typename std::enable_if<use_trivial_move<TYPE>::value>::
+    type inline move_forward_type(TYPE* d, const TYPE* s, size_t n = 1) {
+  memmove(d, s, n * sizeof(TYPE));
+}
+
+template <typename TYPE>
+typename std::enable_if<!use_trivial_move<TYPE>::value>::
+    type inline move_forward_type(TYPE* d, const TYPE* s, size_t n = 1) {
+  d += n;
+  s += n;
+  while (n > 0) {
+    n--;
+    --d, --s;
+    if (!traits<TYPE>::has_trivial_copy) {
+      new (d) TYPE(*s);
+    } else {
+      *d = *s;
+    }
+    if (!traits<TYPE>::has_trivial_dtor) {
+      s->~TYPE();
+    }
+  }
+}
+
+template <typename TYPE>
+typename std::enable_if<use_trivial_move<TYPE>::value>::
+    type inline move_backward_type(TYPE* d, const TYPE* s, size_t n = 1) {
+  memmove(d, s, n * sizeof(TYPE));
+}
+
+template <typename TYPE>
+typename std::enable_if<!use_trivial_move<TYPE>::value>::
+    type inline move_backward_type(TYPE* d, const TYPE* s, size_t n = 1) {
+  while (n > 0) {
+    n--;
+    if (!traits<TYPE>::has_trivial_copy) {
+      new (d) TYPE(*s);
+    } else {
+      *d = *s;
+    }
+    if (!traits<TYPE>::has_trivial_dtor) {
+      s->~TYPE();
+    }
+    d++, s++;
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+/*
+ * a key/value pair
+ */
+
+template <typename KEY, typename VALUE>
+struct key_value_pair_t {
+  typedef KEY key_t;
+  typedef VALUE value_t;
+
+  KEY key;
+  VALUE value;
+  key_value_pair_t() {}
+  key_value_pair_t(const key_value_pair_t& o) : key(o.key), value(o.value) {}
+  key_value_pair_t& operator=(const key_value_pair_t& o) {
+    key = o.key;
+    value = o.value;
+    return *this;
+  }
+  key_value_pair_t(const KEY& k, const VALUE& v) : key(k), value(v) {}
+  explicit key_value_pair_t(const KEY& k) : key(k) {}
+  inline bool operator<(const key_value_pair_t& o) const {
+    return strictly_order_type(key, o.key);
+  }
+  inline const KEY& getKey() const { return key; }
+  inline const VALUE& getValue() const { return value; }
+};
+
+template <typename K, typename V>
+struct trait_trivial_ctor<key_value_pair_t<K, V> > {
+  enum { value = aggregate_traits<K, V>::has_trivial_ctor };
+};
+template <typename K, typename V>
+struct trait_trivial_dtor<key_value_pair_t<K, V> > {
+  enum { value = aggregate_traits<K, V>::has_trivial_dtor };
+};
+template <typename K, typename V>
+struct trait_trivial_copy<key_value_pair_t<K, V> > {
+  enum { value = aggregate_traits<K, V>::has_trivial_copy };
+};
+template <typename K, typename V>
+struct trait_trivial_move<key_value_pair_t<K, V> > {
+  enum { value = aggregate_traits<K, V>::has_trivial_move };
+};
+
+// ---------------------------------------------------------------------------
+
+/*
+ * Hash codes.
+ */
+typedef uint32_t hash_t;
+
+template <typename TKey>
+hash_t hash_type(const TKey& key);
+
+/* Built-in hash code specializations */
+#define ANDROID_INT32_HASH(T)               \
+  template <>                               \
+  inline hash_t hash_type(const T& value) { \
+    return hash_t(value);                   \
+  }
+#define ANDROID_INT64_HASH(T)               \
+  template <>                               \
+  inline hash_t hash_type(const T& value) { \
+    return hash_t((value >> 32) ^ value);   \
+  }
+#define ANDROID_REINTERPRET_HASH(T, R)                                 \
+  template <>                                                          \
+  inline hash_t hash_type(const T& value) {                            \
+    R newValue;                                                        \
+    static_assert(sizeof(newValue) == sizeof(value), "size mismatch"); \
+    memcpy(&newValue, &value, sizeof(newValue));                       \
+    return hash_type(newValue);                                        \
+  }
+
+ANDROID_INT32_HASH(bool)
+ANDROID_INT32_HASH(int8_t)
+ANDROID_INT32_HASH(uint8_t)
+ANDROID_INT32_HASH(int16_t)
+ANDROID_INT32_HASH(uint16_t)
+ANDROID_INT32_HASH(int32_t)
+ANDROID_INT32_HASH(uint32_t)
+ANDROID_INT64_HASH(int64_t)
+ANDROID_INT64_HASH(uint64_t)
+ANDROID_REINTERPRET_HASH(float, uint32_t)
+ANDROID_REINTERPRET_HASH(double, uint64_t)
+
+template <typename T>
+inline hash_t hash_type(T* const& value) {
+  return hash_type(uintptr_t(value));
+}
+
+}  // namespace android
+
+// ---------------------------------------------------------------------------
+
+#endif  // ANDROID_TYPE_HELPERS_H
diff --git a/nativeruntime/cpp/libutils/include/utils/Unicode.h b/nativeruntime/cpp/libutils/include/utils/Unicode.h
new file mode 100644
index 0000000..dfd73f3
--- /dev/null
+++ b/nativeruntime/cpp/libutils/include/utils/Unicode.h
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2005 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.
+ */
+
+// Derived from
+// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:system/core/libutils/include/utils/Unicode.h
+
+#ifndef ANDROID_UNICODE_H
+#define ANDROID_UNICODE_H
+
+#include <stdint.h>
+#include <sys/types.h>
+
+extern "C" {
+
+// Standard string functions on char16_t strings.
+int strcmp16(const char16_t *, const char16_t *);
+int strncmp16(const char16_t *s1, const char16_t *s2, size_t n);
+size_t strlen16(const char16_t *);
+size_t strnlen16(const char16_t *, size_t);
+char16_t *strcpy16(char16_t *, const char16_t *);
+char16_t *strstr16(const char16_t *, const char16_t *);
+
+// Version of comparison that supports embedded NULs.
+// This is different than strncmp() because we don't stop
+// at a nul character and consider the strings to be different
+// if the lengths are different (thus we need to supply the
+// lengths of both strings).  This can also be used when
+// your string is not nul-terminated as it will have the
+// equivalent result as strcmp16 (unlike strncmp16).
+int strzcmp16(const char16_t *s1, size_t n1, const char16_t *s2, size_t n2);
+
+// Standard string functions on char32_t strings.
+size_t strlen32(const char32_t *);
+size_t strnlen32(const char32_t *, size_t);
+
+/**
+ * Measure the length of a UTF-32 string in UTF-8. If the string is invalid
+ * such as containing a surrogate character, -1 will be returned.
+ */
+ssize_t utf32_to_utf8_length(const char32_t *src, size_t src_len);
+
+/**
+ * Stores a UTF-8 string converted from "src" in "dst", if "dst_length" is not
+ * large enough to store the string, the part of the "src" string is stored
+ * into "dst" as much as possible. See the examples for more detail.
+ * Returns the size actually used for storing the string.
+ * dst" is not nul-terminated when dst_len is fully used (like strncpy).
+ *
+ * \code
+ * Example 1
+ * "src" == \u3042\u3044 (\xE3\x81\x82\xE3\x81\x84)
+ * "src_len" == 2
+ * "dst_len" >= 7
+ * ->
+ * Returned value == 6
+ * "dst" becomes \xE3\x81\x82\xE3\x81\x84\0
+ * (note that "dst" is nul-terminated)
+ *
+ * Example 2
+ * "src" == \u3042\u3044 (\xE3\x81\x82\xE3\x81\x84)
+ * "src_len" == 2
+ * "dst_len" == 5
+ * ->
+ * Returned value == 3
+ * "dst" becomes \xE3\x81\x82\0
+ * (note that "dst" is nul-terminated, but \u3044 is not stored in "dst"
+ * since "dst" does not have enough size to store the character)
+ *
+ * Example 3
+ * "src" == \u3042\u3044 (\xE3\x81\x82\xE3\x81\x84)
+ * "src_len" == 2
+ * "dst_len" == 6
+ * ->
+ * Returned value == 6
+ * "dst" becomes \xE3\x81\x82\xE3\x81\x84
+ * (note that "dst" is NOT nul-terminated, like strncpy)
+ * \endcode
+ */
+void utf32_to_utf8(const char32_t *src, size_t src_len, char *dst,
+                   size_t dst_len);
+
+/**
+ * Returns the unicode value at "index".
+ * Returns -1 when the index is invalid (equals to or more than "src_len").
+ * If returned value is positive, it is able to be converted to char32_t, which
+ * is unsigned. Then, if "next_index" is not NULL, the next index to be used is
+ * stored in "next_index". "next_index" can be NULL.
+ */
+int32_t utf32_from_utf8_at(const char *src, size_t src_len, size_t index,
+                           size_t *next_index);
+
+/**
+ * Returns the UTF-8 length of UTF-16 string "src".
+ */
+ssize_t utf16_to_utf8_length(const char16_t *src, size_t src_len);
+
+/**
+ * Converts a UTF-16 string to UTF-8. The destination buffer must be large
+ * enough to fit the UTF-16 as measured by utf16_to_utf8_length with an added
+ * NUL terminator.
+ */
+void utf16_to_utf8(const char16_t *src, size_t src_len, char *dst,
+                   size_t dst_len);
+
+/**
+ * Returns the length of "src" when "src" is valid UTF-8 string.
+ * Returns 0 if src is NULL or 0-length string. Returns -1 when the source
+ * is an invalid string.
+ *
+ * This function should be used to determine whether "src" is valid UTF-8
+ * characters with valid unicode codepoints. "src" must be nul-terminated.
+ *
+ * If you are going to use other utf8_to_... functions defined in this header
+ * with string which may not be valid UTF-8 with valid codepoint (form 0 to
+ * 0x10FFFF), you should use this function before calling others, since the
+ * other functions do not check whether the string is valid UTF-8 or not.
+ *
+ * If you do not care whether "src" is valid UTF-8 or not, you should use
+ * strlen() as usual, which should be much faster.
+ */
+ssize_t utf8_length(const char *src);
+
+/**
+ * Returns the UTF-16 length of UTF-8 string "src". Returns -1 in case
+ * it's invalid utf8. No buffer over-read occurs because of bound checks. Using
+ * overreadIsFatal you can ask to log a message and fail in case the invalid
+ * utf8 could have caused an override if no bound checks were used (otherwise -1
+ * is returned).
+ */
+ssize_t utf8_to_utf16_length(const uint8_t *u8str, size_t u8len,
+                             bool overreadIsFatal = false);
+
+/**
+ * Convert UTF-8 to UTF-16 including surrogate pairs.
+ * Returns a pointer to the end of the string (where a NUL terminator might go
+ * if you wanted to add one). At most dstLen characters are written; it won't
+ * emit half a surrogate pair. If dstLen == 0 nothing is written and dst is
+ * returned. If dstLen > SSIZE_MAX it aborts (this being probably a negative
+ * number returned as an error and casted to unsigned).
+ */
+char16_t *utf8_to_utf16_no_null_terminator(const uint8_t *src, size_t srcLen,
+                                           char16_t *dst, size_t dstLen);
+
+/**
+ * Convert UTF-8 to UTF-16 including surrogate pairs. At most dstLen - 1
+ * characters are written; it won't emit half a surrogate pair; and a NUL
+ * terminator is appended after. dstLen - 1 can be measured beforehand using
+ * utf8_to_utf16_length. Aborts if dstLen == 0 (at least one character is needed
+ * for the NUL terminator) or dstLen > SSIZE_MAX (the latter case being likely a
+ * negative number returned as an error and casted to unsigned) . Returns a
+ * pointer to the NUL terminator.
+ */
+char16_t *utf8_to_utf16(const uint8_t *u8str, size_t u8len, char16_t *u16str,
+                        size_t u16len);
+}
+
+#endif
diff --git a/nativeruntime/external/icu b/nativeruntime/external/icu
new file mode 160000
index 0000000..0e7b442
--- /dev/null
+++ b/nativeruntime/external/icu
@@ -0,0 +1 @@
+Subproject commit 0e7b4428866f3133b4abba2d932ee3faa708db1d
diff --git a/nativeruntime/external/sqlite b/nativeruntime/external/sqlite
new file mode 160000
index 0000000..41e1a36
--- /dev/null
+++ b/nativeruntime/external/sqlite
@@ -0,0 +1 @@
+Subproject commit 41e1a3604e32f92120a0d4ce2d62c4a5c8f8673f
diff --git a/nativeruntime/src/main/java/org/robolectric/nativeruntime/CursorWindowNatives.java b/nativeruntime/src/main/java/org/robolectric/nativeruntime/CursorWindowNatives.java
new file mode 100644
index 0000000..241c499
--- /dev/null
+++ b/nativeruntime/src/main/java/org/robolectric/nativeruntime/CursorWindowNatives.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.nativeruntime;
+
+import android.database.CharArrayBuffer;
+
+/**
+ * Native methods for CursorWindow JNI registration.
+ *
+ * <p>Native method signatures are derived from
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/java/android/database/CursorWindow.java
+ */
+public final class CursorWindowNatives {
+  private CursorWindowNatives() {}
+
+  // May throw CursorWindowAllocationException
+  public static native long nativeCreate(String name, int cursorWindowSize);
+
+  // May throw CursorWindowAllocationException
+  // Parcel is not available
+  // public static native long nativeCreateFromParcel(Parcel parcel);
+
+  public static native void nativeDispose(long windowPtr);
+
+  // Parcel is not available
+  // public static native void nativeWriteToParcel(long windowPtr, Parcel parcel);
+
+  public static native String nativeGetName(long windowPtr);
+
+  public static native byte[] nativeGetBlob(long windowPtr, int row, int column);
+
+  public static native String nativeGetString(long windowPtr, int row, int column);
+
+  public static native void nativeCopyStringToBuffer(
+      long windowPtr, int row, int column, CharArrayBuffer buffer);
+
+  public static native boolean nativePutBlob(long windowPtr, byte[] value, int row, int column);
+
+  public static native boolean nativePutString(long windowPtr, String value, int row, int column);
+
+  public static native void nativeClear(long windowPtr);
+
+  public static native int nativeGetNumRows(long windowPtr);
+
+  public static native boolean nativeSetNumColumns(long windowPtr, int columnNum);
+
+  public static native boolean nativeAllocRow(long windowPtr);
+
+  public static native void nativeFreeLastRow(long windowPtr);
+
+  public static native int nativeGetType(long windowPtr, int row, int column);
+
+  public static native long nativeGetLong(long windowPtr, int row, int column);
+
+  public static native double nativeGetDouble(long windowPtr, int row, int column);
+
+  public static native boolean nativePutLong(long windowPtr, long value, int row, int column);
+
+  public static native boolean nativePutDouble(long windowPtr, double value, int row, int column);
+
+  public static native boolean nativePutNull(long windowPtr, int row, int column);
+}
diff --git a/nativeruntime/src/main/java/org/robolectric/nativeruntime/DefaultNativeRuntimeLoader.java b/nativeruntime/src/main/java/org/robolectric/nativeruntime/DefaultNativeRuntimeLoader.java
new file mode 100644
index 0000000..5716052
--- /dev/null
+++ b/nativeruntime/src/main/java/org/robolectric/nativeruntime/DefaultNativeRuntimeLoader.java
@@ -0,0 +1,114 @@
+package org.robolectric.nativeruntime;
+
+import static com.google.common.base.StandardSystemProperty.OS_ARCH;
+import static com.google.common.base.StandardSystemProperty.OS_NAME;
+
+import android.database.CursorWindow;
+import com.google.auto.service.AutoService;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.io.Files;
+import com.google.common.io.Resources;
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Priority;
+import org.robolectric.pluginapi.NativeRuntimeLoader;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.inject.Injector;
+
+/** Loads the Robolectric native runtime. */
+@AutoService(NativeRuntimeLoader.class)
+@Priority(Integer.MIN_VALUE)
+public class DefaultNativeRuntimeLoader implements NativeRuntimeLoader {
+  protected static final AtomicBoolean loaded = new AtomicBoolean(false);
+
+  private static final AtomicReference<NativeRuntimeLoader> nativeRuntimeLoader =
+      new AtomicReference<>();
+
+  public static void injectAndLoad() {
+    // Ensure a single instance.
+    synchronized (nativeRuntimeLoader) {
+      if (nativeRuntimeLoader.get() == null) {
+        Injector injector = new Injector.Builder(CursorWindow.class.getClassLoader()).build();
+        NativeRuntimeLoader loader = injector.getInstance(NativeRuntimeLoader.class);
+        nativeRuntimeLoader.set(loader);
+      }
+    }
+    nativeRuntimeLoader.get().ensureLoaded();
+  }
+
+  @Override
+  public synchronized void ensureLoaded() {
+    if (loaded.get()) {
+      return;
+    }
+
+    if (!isSupported()) {
+      String errorMessage =
+          String.format(
+              "The Robolectric native runtime is not supported on %s (%s)",
+              OS_NAME.value(), OS_ARCH.value());
+      throw new AssertionError(errorMessage);
+    }
+    loaded.set(true);
+
+    try {
+      PerfStatsCollector.getInstance()
+          .measure(
+              "loadNativeRuntime",
+              () -> {
+                String libraryName = System.mapLibraryName("robolectric-nativeruntime");
+                System.setProperty(
+                    "robolectric.nativeruntime.languageTag", Locale.getDefault().toLanguageTag());
+                File tmpLibraryFile = java.nio.file.Files.createTempFile("", libraryName).toFile();
+                tmpLibraryFile.deleteOnExit();
+                URL resource = Resources.getResource(nativeLibraryPath());
+                Resources.asByteSource(resource).copyTo(Files.asByteSink(tmpLibraryFile));
+                System.load(tmpLibraryFile.getAbsolutePath());
+              });
+    } catch (IOException e) {
+      throw new AssertionError("Unable to load Robolectric native runtime library", e);
+    }
+  }
+
+  private static boolean isSupported() {
+    return ("mac".equals(osName()) && ("aarch64".equals(arch()) || "x86_64".equals(arch())))
+        || ("linux".equals(osName()) && "x86_64".equals(arch()))
+        || ("windows".equals(osName()) && "x86_64".equals(arch()));
+  }
+
+  private static String nativeLibraryPath() {
+    String os = osName();
+    String arch = arch();
+    return String.format(
+        "native/%s/%s/%s", os, arch, System.mapLibraryName("robolectric-nativeruntime"));
+  }
+
+  private static String osName() {
+    String osName = OS_NAME.value().toLowerCase(Locale.US);
+    if (osName.contains("linux")) {
+      return "linux";
+    } else if (osName.contains("mac")) {
+      return "mac";
+    } else if (osName.contains("win")) {
+      return "windows";
+    }
+    return "unknown";
+  }
+
+  private static String arch() {
+    String arch = OS_ARCH.value().toLowerCase(Locale.US);
+    if (arch.equals("x86_64") || arch.equals("amd64")) {
+      return "x86_64";
+    }
+    return arch;
+  }
+
+  @VisibleForTesting
+  static boolean isLoaded() {
+    return loaded.get();
+  }
+}
diff --git a/nativeruntime/src/main/java/org/robolectric/nativeruntime/SQLiteConnectionNatives.java b/nativeruntime/src/main/java/org/robolectric/nativeruntime/SQLiteConnectionNatives.java
new file mode 100644
index 0000000..12bcad7
--- /dev/null
+++ b/nativeruntime/src/main/java/org/robolectric/nativeruntime/SQLiteConnectionNatives.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.nativeruntime;
+
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+
+/**
+ * Native methods for SQLiteConnection JNI registration.
+ *
+ * <p>Native method signatures are derived from
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/core/java/android/database/sqlite/SQLiteConnection.java
+ */
+public class SQLiteConnectionNatives {
+
+  private SQLiteConnectionNatives() {}
+
+  public static native long nativeOpen(
+      String path,
+      int openFlags,
+      String label,
+      boolean enableTrace,
+      boolean enableProfile,
+      int lookasideSlotSize,
+      int lookasideSlotCount);
+
+  public static native void nativeClose(long connectionPtr);
+
+  public static native void nativeRegisterCustomScalarFunction(
+      long connectionPtr, String name, UnaryOperator<String> function);
+
+  public static native void nativeRegisterCustomAggregateFunction(
+      long connectionPtr, String name, BinaryOperator<String> function);
+
+  public static native void nativeRegisterLocalizedCollators(long connectionPtr, String locale);
+
+  public static native long nativePrepareStatement(long connectionPtr, String sql);
+
+  public static native void nativeFinalizeStatement(long connectionPtr, long statementPtr);
+
+  public static native int nativeGetParameterCount(long connectionPtr, long statementPtr);
+
+  public static native boolean nativeIsReadOnly(long connectionPtr, long statementPtr);
+
+  public static native int nativeGetColumnCount(long connectionPtr, long statementPtr);
+
+  public static native String nativeGetColumnName(long connectionPtr, long statementPtr, int index);
+
+  public static native void nativeBindNull(long connectionPtr, long statementPtr, int index);
+
+  public static native void nativeBindLong(
+      long connectionPtr, long statementPtr, int index, long value);
+
+  public static native void nativeBindDouble(
+      long connectionPtr, long statementPtr, int index, double value);
+
+  public static native void nativeBindString(
+      long connectionPtr, long statementPtr, int index, String value);
+
+  public static native void nativeBindBlob(
+      long connectionPtr, long statementPtr, int index, byte[] value);
+
+  public static native void nativeResetStatementAndClearBindings(
+      long connectionPtr, long statementPtr);
+
+  public static native void nativeExecute(
+      long connectionPtr, long statementPtr, boolean isPragmaStmt);
+
+  public static native long nativeExecuteForLong(long connectionPtr, long statementPtr);
+
+  public static native String nativeExecuteForString(long connectionPtr, long statementPtr);
+
+  public static native int nativeExecuteForBlobFileDescriptor(
+      long connectionPtr, long statementPtr);
+
+  public static native int nativeExecuteForChangedRowCount(long connectionPtr, long statementPtr);
+
+  public static native long nativeExecuteForLastInsertedRowId(
+      long connectionPtr, long statementPtr);
+
+  public static native long nativeExecuteForCursorWindow(
+      long connectionPtr,
+      long statementPtr,
+      long windowPtr,
+      int startPos,
+      int requiredPos,
+      boolean countAllRows);
+
+  public static native int nativeGetDbLookaside(long connectionPtr);
+
+  public static native void nativeCancel(long connectionPtr);
+
+  public static native void nativeResetCancel(long connectionPtr, boolean cancelable);
+}
diff --git a/pluginapi/build.gradle b/pluginapi/build.gradle
new file mode 100644
index 0000000..375cd10
--- /dev/null
+++ b/pluginapi/build.gradle
@@ -0,0 +1,15 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    compileOnly 'com.google.code.findbugs:jsr305:3.0.2'
+    api project(":annotations")
+    api "com.google.guava:guava:$guavaJREVersion"
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+}
diff --git a/pluginapi/src/main/java/org/robolectric/internal/dependency/DependencyJar.java b/pluginapi/src/main/java/org/robolectric/internal/dependency/DependencyJar.java
new file mode 100644
index 0000000..b8f5d73
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/internal/dependency/DependencyJar.java
@@ -0,0 +1,49 @@
+package org.robolectric.internal.dependency;
+
+public class DependencyJar {
+  private final String groupId;
+  private final String artifactId;
+  private final String version;
+  private final String classifier;
+
+  public DependencyJar(String groupId, String artifactId, String version) {
+    this(groupId, artifactId, version, null);
+  }
+
+  public DependencyJar(String groupId, String artifactId, String version, String classifier) {
+    this.groupId = groupId;
+    this.artifactId = artifactId;
+    this.version = version;
+    this.classifier = classifier;
+  }
+
+  public String getGroupId() {
+    return groupId;
+  }
+
+  public String getArtifactId() {
+    return artifactId;
+  }
+
+  public String getVersion() {
+    return version;
+  }
+
+  public String getType() {
+    return "jar";
+  }
+
+  public String getClassifier() {
+    return classifier;
+  }
+
+  public String getShortName() {
+    return getGroupId() + ":" + getArtifactId() + ":" + getVersion()
+        + ((getClassifier() == null) ? "" : ":" + getClassifier());
+  }
+
+  @Override
+  public String toString() {
+    return "DependencyJar{" + getShortName() + '}';
+  }
+}
diff --git a/pluginapi/src/main/java/org/robolectric/internal/dependency/DependencyResolver.java b/pluginapi/src/main/java/org/robolectric/internal/dependency/DependencyResolver.java
new file mode 100644
index 0000000..b21dce5
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/internal/dependency/DependencyResolver.java
@@ -0,0 +1,27 @@
+package org.robolectric.internal.dependency;
+
+import java.net.URL;
+
+/**
+ * Provides mapping between a Maven coordinate (e.g. {@code
+ * org.robolectric:android-all:7.1.0_r7-robolectric-r1}) and a file on disk (e.g. {@code
+ * android-all-7.1.0_r7-robolectric-r1.jar}).
+ *
+ * <p>An instance of {@link DependencyResolver} is employed when {@link
+ * org.robolectric.plugins.DefaultSdkProvider} is used.
+ *
+ * <p>See {@link org.robolectric.pluginapi} for instructions for providing your own implementation.
+ */
+public interface DependencyResolver {
+  URL getLocalArtifactUrl(DependencyJar dependency);
+
+  /**
+   * Returns URLs representing the full transitive dependency graph of the given Maven dependency.
+   * @deprecated Robolectric will never ask for a dependency composed of more than one artifact,
+   *     so this method isn't necessary.
+   */
+  @Deprecated
+  default URL[] getLocalArtifactUrls(DependencyJar dependency) {
+    return new URL[] {getLocalArtifactUrl(dependency)};
+  }
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/ExtensionPoint.java b/pluginapi/src/main/java/org/robolectric/pluginapi/ExtensionPoint.java
new file mode 100644
index 0000000..6264994
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/ExtensionPoint.java
@@ -0,0 +1,14 @@
+package org.robolectric.pluginapi;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a component of Robolectric that may be replaced with a custom implementation.
+ */
+@Documented
+@Target(ElementType.TYPE)
+public @interface ExtensionPoint {
+
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/NativeRuntimeLoader.java b/pluginapi/src/main/java/org/robolectric/pluginapi/NativeRuntimeLoader.java
new file mode 100644
index 0000000..fbca48a
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/NativeRuntimeLoader.java
@@ -0,0 +1,14 @@
+package org.robolectric.pluginapi;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * Loads the Robolectric native runtime.
+ *
+ * <p>By default, the native runtime shared library is loaded from Java resources. However, in some
+ * environments, there may be a faster and simpler way to load it.
+ */
+@Beta
+public interface NativeRuntimeLoader {
+  void ensureLoaded();
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/Sdk.java b/pluginapi/src/main/java/org/robolectric/pluginapi/Sdk.java
new file mode 100644
index 0000000..22f57e3
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/Sdk.java
@@ -0,0 +1,123 @@
+package org.robolectric.pluginapi;
+
+import java.nio.file.Path;
+import javax.annotation.Nonnull;
+
+/**
+ * Represents a unique build of the Android SDK.
+ */
+@SuppressWarnings("NewApi")
+public abstract class Sdk implements Comparable<Sdk> {
+
+  private final int apiLevel;
+
+  protected Sdk(int apiLevel) {
+    this.apiLevel = apiLevel;
+  }
+
+  /**
+   * Returns the Android API level for this SDK.
+   *
+   * <p>It must match the version reported by {@code android.os.Build.VERSION.SDK_INT} provided
+   * within.
+   *
+   * @see <a href="https://source.android.com/setup/start/build-numbers">Android build numbers</a>
+   */
+  public final int getApiLevel() {
+    return apiLevel;
+  }
+
+  /**
+   * Returns the Android Version for this SDK.
+   *
+   * <p>It should match the version reported by {@code android.os.Build.VERSION.RELEASE} provided
+   * within.
+   *
+   * <p>If this is an expensive operation, the implementation should cache the return value.
+   *
+   * @see <a href="https://source.android.com/setup/start/build-numbers">Android build numbers</a>
+   */
+  public abstract String getAndroidVersion();
+
+  /**
+   * Returns the Android codename for this SDK.
+   *
+   * <p>It should match the version reported by {@code android.os.Build.VERSION.CODENAME} provided
+   * within.
+   *
+   * <p>If this is an expensive operation, the implementation should cache the return value.
+   */
+  public abstract String getAndroidCodeName();
+
+  /**
+   * Returns the path to jar for this SDK.
+   */
+  public abstract Path getJarPath();
+
+  /**
+   * Determines if this SDK is supported in the running Robolectric environment.
+   *
+   * An SDK might be unsupported if e.g. it requires a newer version of the JVM than is currently
+   * running.
+   *
+   * Unsupported SDKs should throw some explanatory exception when {@link #getJarPath()} is invoked.
+   *
+   * If this is an expensive operation, the implementation should cache the return value.
+   */
+  public abstract boolean isSupported();
+
+  /**
+   * Returns a human-readable message explaining why this SDK isn't supported.
+   *
+   * If this is an expensive operation, the implementation should cache the return value.
+   */
+  public abstract String getUnsupportedMessage();
+
+  /**
+   * Determines if this SDK is known by its provider.
+   *
+   * Unknown SDKs can serve as placeholder objects; they should throw some explanatory exception
+   * when {@link #getJarPath()} is invoked.
+   */
+  public boolean isKnown() {
+    return true;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof Sdk)) {
+      return false;
+    }
+    Sdk sdk = (Sdk) o;
+    return apiLevel == sdk.apiLevel;
+  }
+
+  @Override
+  public int hashCode() {
+    return apiLevel;
+  }
+
+  @Override
+  public String toString() {
+    return "SDK " + apiLevel;
+  }
+
+  /** Instances of {@link Sdk} are ordered by the API level they implement. */
+  @Override
+  public int compareTo(@Nonnull Sdk o) {
+    return apiLevel - o.apiLevel;
+  }
+
+  /**
+   * Verify that the SDK is supported.
+   *
+   * <p>Implementations should throw an exception if SDK is unsupported. They can choose to either
+   * throw org.junit.AssumptionViolatedException to just skip execution of tests on the SDK, with a
+   * warning, or throw a RuntimeException to fail the test.
+   *
+   */
+  public abstract void verifySupportedSdk(String testClassName);
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/SdkPicker.java b/pluginapi/src/main/java/org/robolectric/pluginapi/SdkPicker.java
new file mode 100644
index 0000000..9d93be6
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/SdkPicker.java
@@ -0,0 +1,11 @@
+package org.robolectric.pluginapi;
+
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+
+public interface SdkPicker {
+
+  @Nonnull
+  List<Sdk> selectSdks(Configuration configuration, UsesSdk usesSdk);
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/SdkProvider.java b/pluginapi/src/main/java/org/robolectric/pluginapi/SdkProvider.java
new file mode 100644
index 0000000..1623ad6
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/SdkProvider.java
@@ -0,0 +1,21 @@
+package org.robolectric.pluginapi;
+
+import java.util.Collection;
+
+/**
+ * A provider of known instances of {@link Sdk}. Implement this interface if you need to provide
+ * SDKs in a special way for your environment.
+ *
+ * This is an extension point for Robolectric; see {@link org.robolectric.pluginapi} for details.
+ */
+@ExtensionPoint
+public interface SdkProvider {
+
+  /**
+   * Returns the set of SDKs available to run tests against.
+   *
+   * It's okay for the implementation to block briefly while building the list; the results will be
+   * cached.
+   */
+  Collection<Sdk> getSdks();
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/TestEnvironmentLifecyclePlugin.java b/pluginapi/src/main/java/org/robolectric/pluginapi/TestEnvironmentLifecyclePlugin.java
new file mode 100644
index 0000000..3c01577
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/TestEnvironmentLifecyclePlugin.java
@@ -0,0 +1,12 @@
+package org.robolectric.pluginapi;
+
+/**
+ * Plugin which allows behaviour extension in TestEnvironment.
+ */
+public interface TestEnvironmentLifecyclePlugin {
+
+  /**
+   * Runs additional setup during TestEnvironment.before().
+   */
+  void onSetupApplicationState();
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/UsesSdk.java b/pluginapi/src/main/java/org/robolectric/pluginapi/UsesSdk.java
new file mode 100644
index 0000000..94b6bf2
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/UsesSdk.java
@@ -0,0 +1,33 @@
+package org.robolectric.pluginapi;
+
+/** Represents the contents of a {@code uses-sdk} element in an Android manifest file. */
+public interface UsesSdk {
+  /**
+   * Returns the minimum Android SDK version that this package expects to be runnable on, as
+   * specified in the manifest.
+   *
+   * @return the minimum SDK version
+   */
+  int getMinSdkVersion();
+
+  /**
+   * Returns the Android SDK version that this package prefers to be run on, as specified in the
+   * manifest.
+   *
+   * Note that this value changes the behavior of some Android code (notably {@link
+   * android.content.SharedPreferences}) to emulate old bugs.
+   *
+   * @return the target SDK version
+   */
+  int getTargetSdkVersion();
+
+  /**
+   * Returns the maximum Android SDK version that this package expects to be runnable on, as
+   * specified in the manifest.
+   *
+   * <p>If no maximum version is specified, null may be returned.
+   *
+   * @return the maximum SDK version, or null
+   */
+  Integer getMaxSdkVersion();
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/config/ConfigurationStrategy.java b/pluginapi/src/main/java/org/robolectric/pluginapi/config/ConfigurationStrategy.java
new file mode 100644
index 0000000..8d5ab04
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/config/ConfigurationStrategy.java
@@ -0,0 +1,43 @@
+package org.robolectric.pluginapi.config;
+
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Strategy for configuring individual tests.
+ *
+ * @since 4.2
+ */
+public interface ConfigurationStrategy {
+
+  /**
+   * Determine the configuration for the given test class and method.
+   *
+   * <p>Since a method may be run on multiple test subclasses, {@code testClass} indicates which
+   * test case is currently being evaluated.
+   *
+   * @param testClass the test class being evaluated; this might be a subclass of the method's
+   *     declaring class.
+   * @param method the test method to be evaluated
+   * @return the set of configs
+   */
+  Configuration getConfig(Class<?> testClass, Method method);
+
+  /**
+   * Heterogeneous typesafe collection of configuration objects managed by their {@link Configurer}.
+   *
+   * @since 4.2
+   */
+  interface Configuration {
+
+    /** Returns the configuration instance of the specified class for the current test. */
+    <T> T get(Class<T> configClass);
+
+    /** Returns the set of known configuration classes. */
+    Collection<Class<?>> keySet();
+
+    /** Returns the map of known configuration classes to configuration instances. */
+    Map<Class<?>, Object> map();
+  }
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/config/Configurer.java b/pluginapi/src/main/java/org/robolectric/pluginapi/config/Configurer.java
new file mode 100644
index 0000000..b51f323
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/config/Configurer.java
@@ -0,0 +1,95 @@
+package org.robolectric.pluginapi.config;
+
+import java.lang.reflect.Method;
+import javax.annotation.Nonnull;
+
+/**
+ * Provides configuration data for tests.
+ *
+ * <p>The test author can apply configuration data at a package, class, or method level, or any
+ * combination of those.
+ *
+ * <p>The implementation of the configurer determines how config information is collected and merged
+ * for each test.
+ *
+ * <p>For the test:
+ *
+ * <pre>
+ *   class com.foo.MyTest extends com.foo.BaseTest {
+ *     &#064;Test void testMethod() {}
+ *   }
+ * </pre>
+ *
+ * <p>the configuration is applied in the following order:
+ *
+ * <ul>
+ *   <li>the {@link #defaultConfig()}
+ *   <li>as specified in /robolectric.properties
+ *   <li>as specified in /com/robolectric.properties
+ *   <li>as specified in /com/foo/robolectric.properties
+ *   <li>as specified in BaseTest
+ *   <li>as specified in MyTest
+ *   <li>as specified in MyTest.testMethod
+ * </ul>
+ *
+ * <p>Configuration objects can be accessed by shadows or tests via {@link
+ * org.robolectric.config.ConfigurationRegistry#get(Class)}.
+ *
+ * @param <T> the configuration object's type
+ * @see <a href="http://robolectric.org/configuring/">Configuring Robolectric</a> for more details.
+ */
+public interface Configurer<T> {
+
+  /** Retrieve the class type for this Configurer */
+  Class<T> getConfigClass();
+
+  /**
+   * Returns the default configuration for tests that do not specify a configuration of this type.
+   */
+  @Nonnull T defaultConfig();
+
+  /**
+   * Returns the configuration for a given package.
+   *
+   * <p>This method will be called once for package in the hierarchy leading to the test class being
+   * configured. For example, for {@code com.example.FooTest}, this method will be called three
+   * times with {@code "com.example"}, {@code "@com"}, and {@code ""} (representing the top level
+   * package).
+   *
+   * @param packageName the name of the package, or the empty string representing the top level
+   *     unnamed package
+   * @return a configuration object, or null if the given properties has no relevant data for this
+   *     configuration
+   */
+  T getConfigFor(@Nonnull String packageName);
+
+  /**
+   * Returns the configuration for the given class.
+   *
+   * <p>This method will be called for each class in the test's class inheritance hierarchy.
+   *
+   * @return a configuration object, or null if the given class has no relevant data for this
+   *     configuration
+   */
+  T getConfigFor(@Nonnull Class<?> testClass);
+
+  /**
+   * Returns the configuration for the given method.
+   *
+   * @return a configuration object, or null if the given method has no relevant data for this
+   *     configuration
+   */
+  T getConfigFor(@Nonnull Method method);
+
+  /**
+   * Merges two configurations.
+   *
+   * This method will called whenever {@link #getConfigFor} returns a non-null configuration object.
+   *
+   * @param parentConfig a less specific configuration object
+   * @param childConfig a more specific configuration object
+   * @return the new configuration with merged parent and child data.
+   */
+  @Nonnull T merge(@Nonnull T parentConfig, @Nonnull T childConfig);
+
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/config/GlobalConfigProvider.java b/pluginapi/src/main/java/org/robolectric/pluginapi/config/GlobalConfigProvider.java
new file mode 100644
index 0000000..94865b7
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/config/GlobalConfigProvider.java
@@ -0,0 +1,8 @@
+package org.robolectric.pluginapi.config;
+
+import org.robolectric.annotation.Config;
+
+/** Provides the default config for a test. */
+public interface GlobalConfigProvider {
+  Config get();
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/package-info.java b/pluginapi/src/main/java/org/robolectric/pluginapi/package-info.java
new file mode 100644
index 0000000..50c2d5e
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/package-info.java
@@ -0,0 +1,34 @@
+/**
+ * Extension points for customizing Robolectric.
+ *
+ * <p>Robolectric has many components which can be customized or replaced using an extension
+ * mechanism based on {@link java.util.ServiceLoader Java Services}.
+ *
+ * <p>Historically, customizing Robolectric required subclassing {@link
+ * org.robolectric.RobolectricTestRunner} to override behavior at various ad-hoc extension points.
+ * This mechanism is now deprecated. The Plugin API provides a number of well documented and
+ * supported extension points allowing you to customize behavior for your organization's needs.
+ *
+ * <p>The interfaces listed below can be implemented with customizations suitable for your
+ * organization. To make your custom implementation visible to Robolectric, publish it as a service
+ * and include it in the test classpath.
+ *
+ * <p>Extension points:
+ *
+ * <ul>
+ *   <li>{@link org.robolectric.pluginapi.config.ConfigurationStrategy} (default {@link
+ *       org.robolectric.plugins.HierarchicalConfigurationStrategy})
+ *   <li>{@link org.robolectric.internal.dependency.DependencyResolver} (default {@link
+ *       org.robolectric.plugins.LegacyDependencyResolver}
+ *   <li>{@link org.robolectric.pluginapi.config.GlobalConfigProvider} (no default)
+ *   <li>{@link org.robolectric.pluginapi.perf.PerfStatsReporter} (no default)
+ *   <li>{@link org.robolectric.pluginapi.SdkPicker} (default {@link
+ *       org.robolectric.plugins.DefaultSdkPicker})
+ *   <li>{@link org.robolectric.pluginapi.SdkProvider} (default {@link
+ *       org.robolectric.plugins.DefaultSdkProvider})
+ * </ul>
+ *
+ * @see <a href="https://github.com/google/auto/tree/master/service">Google AutoService</a> for a
+ *     helpful way to define Java Services.
+ */
+package org.robolectric.pluginapi;
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/perf/Metadata.java b/pluginapi/src/main/java/org/robolectric/pluginapi/perf/Metadata.java
new file mode 100644
index 0000000..4d00de8
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/perf/Metadata.java
@@ -0,0 +1,19 @@
+package org.robolectric.pluginapi.perf;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Metadata for perf stats collection.
+ */
+public class Metadata {
+  private final Map<Class<?>, Object> metadata;
+
+  public Metadata(Map<Class<?>, Object> metadata) {
+    this.metadata = new HashMap<>(metadata);
+  }
+
+  public <T> T get(Class<T> metadataClass) {
+    return metadataClass.cast(metadata.get(metadataClass));
+  }
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/perf/Metric.java b/pluginapi/src/main/java/org/robolectric/pluginapi/perf/Metric.java
new file mode 100644
index 0000000..ec9a1ba
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/perf/Metric.java
@@ -0,0 +1,102 @@
+package org.robolectric.pluginapi.perf;
+
+/**
+ * Metric for perf stats collection.
+ */
+public class Metric {
+  private final String name;
+  private int count;
+  private long elapsedNs;
+  private long minNs;
+  private long maxNs;
+  private final boolean success;
+
+  public Metric(String name, int count, int elapsedNs, boolean success) {
+    this.name = name;
+    this.count = count;
+    this.elapsedNs = elapsedNs;
+    this.success = success;
+  }
+
+  public Metric(String name, boolean success) {
+    this(name, 0, 0, success);
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public int getCount() {
+    return count;
+  }
+
+  public long getElapsedNs() {
+    return elapsedNs;
+  }
+
+  public long getMinNs() {
+    return minNs;
+  }
+
+  public long getMaxNs() {
+    return maxNs;
+  }
+
+  public boolean isSuccess() {
+    return success;
+  }
+
+  public void record(long elapsedNs) {
+    if (count == 0 || elapsedNs < minNs) {
+      minNs = elapsedNs;
+    }
+
+    if (elapsedNs > maxNs) {
+      maxNs = elapsedNs;
+    }
+
+    this.elapsedNs += elapsedNs;
+
+    count++;
+  }
+
+  public void incrementCount() {
+    this.count++;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof Metric)) {
+      return false;
+    }
+
+    Metric metric = (Metric) o;
+
+    if (success != metric.success) {
+      return false;
+    }
+    return name != null ? name.equals(metric.name) : metric.name == null;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = name != null ? name.hashCode() : 0;
+    result = 31 * result + (success ? 1 : 0);
+    return result;
+  }
+
+  @Override
+  public String toString() {
+    return "Metric{"
+        + "name='" + name + '\''
+        + ", count=" + count
+        + ", minNs=" + minNs
+        + ", maxNs=" + maxNs
+        + ", elapsedNs=" + elapsedNs
+        + ", success=" + success
+        + '}';
+  }
+}
diff --git a/pluginapi/src/main/java/org/robolectric/pluginapi/perf/PerfStatsReporter.java b/pluginapi/src/main/java/org/robolectric/pluginapi/perf/PerfStatsReporter.java
new file mode 100644
index 0000000..4cc6da0
--- /dev/null
+++ b/pluginapi/src/main/java/org/robolectric/pluginapi/perf/PerfStatsReporter.java
@@ -0,0 +1,15 @@
+package org.robolectric.pluginapi.perf;
+
+import java.util.Collection;
+
+public interface PerfStatsReporter {
+
+  /**
+   * Report performance stats.
+   *
+   * @param metadata metadata about this set of metrics.
+   * @param metrics the metrics.
+   */
+  void report(Metadata metadata, Collection<Metric> metrics);
+
+}
diff --git a/pluginapi/src/test/java/org/robolectric/internal/dependency/DependencyJarTest.java b/pluginapi/src/test/java/org/robolectric/internal/dependency/DependencyJarTest.java
new file mode 100644
index 0000000..039ccba
--- /dev/null
+++ b/pluginapi/src/test/java/org/robolectric/internal/dependency/DependencyJarTest.java
@@ -0,0 +1,18 @@
+package org.robolectric.internal.dependency;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class DependencyJarTest {
+  @Test
+  public void testGetShortName() throws Exception {
+    assertThat(new DependencyJar("com.group", "artifact", "1.3", null).getShortName())
+        .isEqualTo("com.group:artifact:1.3");
+    assertThat(new DependencyJar("com.group", "artifact", "1.3", "dll").getShortName())
+        .isEqualTo("com.group:artifact:1.3:dll");
+  }
+}
\ No newline at end of file
diff --git a/plugins/maven-dependency-resolver/build.gradle b/plugins/maven-dependency-resolver/build.gradle
new file mode 100644
index 0000000..48e799a
--- /dev/null
+++ b/plugins/maven-dependency-resolver/build.gradle
@@ -0,0 +1,15 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    api project(":pluginapi")
+    api project(":utils")
+    api "com.google.guava:guava:$guavaJREVersion"
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+}
diff --git a/plugins/maven-dependency-resolver/src/main/java/org/robolectric/MavenRoboSettings.java b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/MavenRoboSettings.java
new file mode 100644
index 0000000..527ee33
--- /dev/null
+++ b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/MavenRoboSettings.java
@@ -0,0 +1,55 @@
+package org.robolectric;
+
+/**
+ * Class that encapsulates reading global configuration options from the Java system properties file.
+ *
+ * @deprecated Don't put more stuff here.
+ */
+@Deprecated
+public class MavenRoboSettings {
+
+  private static String mavenRepositoryId;
+  private static String mavenRepositoryUrl;
+  private static String mavenRepositoryUserName;
+  private static String mavenRepositoryPassword;
+
+  static {
+    mavenRepositoryId = System.getProperty("robolectric.dependency.repo.id", "mavenCentral");
+    mavenRepositoryUrl =
+        System.getProperty("robolectric.dependency.repo.url", "https://repo1.maven.org/maven2");
+    mavenRepositoryUserName = System.getProperty("robolectric.dependency.repo.username");
+    mavenRepositoryPassword = System.getProperty("robolectric.dependency.repo.password");
+  }
+
+  public static String getMavenRepositoryId() {
+    return mavenRepositoryId;
+  }
+
+  public static void setMavenRepositoryId(String mavenRepositoryId) {
+    MavenRoboSettings.mavenRepositoryId = mavenRepositoryId;
+  }
+
+  public static String getMavenRepositoryUrl() {
+    return mavenRepositoryUrl;
+  }
+
+  public static void setMavenRepositoryUrl(String mavenRepositoryUrl) {
+    MavenRoboSettings.mavenRepositoryUrl = mavenRepositoryUrl;
+  }
+
+  public static String getMavenRepositoryUserName() {
+    return mavenRepositoryUserName;
+  }
+
+  public static void setMavenRepositoryUserName(String mavenRepositoryUserName) {
+    MavenRoboSettings.mavenRepositoryUserName = mavenRepositoryUserName;
+  }
+
+  public static String getMavenRepositoryPassword() {
+    return mavenRepositoryPassword;
+  }
+
+  public static void setMavenRepositoryPassword(String mavenRepositoryPassword) {
+    MavenRoboSettings.mavenRepositoryPassword = mavenRepositoryPassword;
+  }
+}
diff --git a/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenArtifactFetcher.java b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenArtifactFetcher.java
new file mode 100644
index 0000000..9b3a28a
--- /dev/null
+++ b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenArtifactFetcher.java
@@ -0,0 +1,200 @@
+package org.robolectric.internal.dependency;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.Hashing;
+import com.google.common.io.ByteStreams;
+import com.google.common.io.Files;
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Base64;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import org.robolectric.util.Logger;
+
+/**
+ * Class responsible for fetching artifacts from Maven. This uses a thread pool of size two in order
+ * to parallelize downloads. It uses the Sun JSSE provider for downloading due to its seamless
+ * integration with HTTPUrlConnection.
+ */
+@SuppressWarnings("UnstableApiUsage")
+public class MavenArtifactFetcher {
+  private final String repositoryUrl;
+  private final String repositoryUserName;
+  private final String repositoryPassword;
+  private final File localRepositoryDir;
+  private final ExecutorService executorService;
+  private File stagingRepositoryDir;
+
+  public MavenArtifactFetcher(
+      String repositoryUrl,
+      String repositoryUserName,
+      String repositoryPassword,
+      File localRepositoryDir,
+      ExecutorService executorService) {
+    this.repositoryUrl = repositoryUrl;
+    this.repositoryUserName = repositoryUserName;
+    this.repositoryPassword = repositoryPassword;
+    this.localRepositoryDir = localRepositoryDir;
+    this.executorService = executorService;
+  }
+
+  public void fetchArtifact(MavenJarArtifact artifact) {
+    // Assume that if the file exists in the local repository, it has been fetched successfully.
+    if (new File(localRepositoryDir, artifact.jarPath()).exists()) {
+      Logger.info(String.format("Found %s in local maven repository", artifact));
+      return;
+    }
+    this.stagingRepositoryDir = Files.createTempDir();
+    this.stagingRepositoryDir.deleteOnExit();
+    try {
+      createArtifactSubdirectory(artifact, stagingRepositoryDir);
+      Futures.whenAllSucceed(
+              fetchToStagingRepository(artifact.pomSha512Path()),
+              fetchToStagingRepository(artifact.pomPath()),
+              fetchToStagingRepository(artifact.jarSha512Path()),
+              fetchToStagingRepository(artifact.jarPath()))
+          .callAsync(
+              () -> {
+                // double check that the artifact has not been installed
+                if (new File(localRepositoryDir, artifact.jarPath()).exists()) {
+                  removeArtifactFiles(stagingRepositoryDir, artifact);
+                  return Futures.immediateFuture(null);
+                }
+                createArtifactSubdirectory(artifact, localRepositoryDir);
+                boolean pomValid =
+                    validateStagedFiles(artifact.pomPath(), artifact.pomSha512Path());
+                if (!pomValid) {
+                  throw new AssertionError("SHA512 mismatch for POM file fetched in " + artifact);
+                }
+                boolean jarValid =
+                    validateStagedFiles(artifact.jarPath(), artifact.jarSha512Path());
+                if (!jarValid) {
+                  throw new AssertionError("SHA512 mismatch for JAR file fetched in " + artifact);
+                }
+                Logger.info(
+                    String.format(
+                        "Checksums validated, moving artifact %s to local maven directory",
+                        artifact));
+                commitFromStaging(artifact.pomSha512Path());
+                commitFromStaging(artifact.pomPath());
+                commitFromStaging(artifact.jarSha512Path());
+                commitFromStaging(artifact.jarPath());
+                removeArtifactFiles(stagingRepositoryDir, artifact);
+                return Futures.immediateFuture(null);
+              },
+              executorService)
+          .get();
+    } catch (InterruptedException | ExecutionException | IOException e) {
+      if (e instanceof InterruptedException) {
+        Thread.currentThread().interrupt(); // Restore the interrupted status
+      }
+      removeArtifactFiles(stagingRepositoryDir, artifact);
+      removeArtifactFiles(localRepositoryDir, artifact);
+      Logger.error("Failed to fetch maven artifact " + artifact, e);
+      throw new AssertionError("Failed to fetch maven artifact " + artifact, e);
+    }
+  }
+
+  private void removeArtifactFiles(File repositoryDir, MavenJarArtifact artifact) {
+    new File(repositoryDir, artifact.jarPath()).delete();
+    new File(repositoryDir, artifact.jarSha512Path()).delete();
+    new File(repositoryDir, artifact.pomPath()).delete();
+    new File(repositoryDir, artifact.pomSha512Path()).delete();
+  }
+
+  private boolean validateStagedFiles(String filePath, String sha512Path) throws IOException {
+    File tempFile = new File(this.stagingRepositoryDir, filePath);
+    File sha512File = new File(this.stagingRepositoryDir, sha512Path);
+
+    HashCode expected =
+        HashCode.fromString(new String(Files.asByteSource(sha512File).read(), UTF_8));
+
+    HashCode actual = Files.asByteSource(tempFile).hash(Hashing.sha512());
+    return expected.equals(actual);
+  }
+
+  private void createArtifactSubdirectory(MavenJarArtifact artifact, File repositoryDir)
+      throws IOException {
+    File jarPath = new File(repositoryDir, artifact.jarPath());
+    Files.createParentDirs(jarPath);
+  }
+
+  private URL getRemoteUrl(String path) {
+    String url = this.repositoryUrl;
+    if (!url.endsWith("/")) {
+      url = url + "/";
+    }
+    try {
+      return new URI(url + path).toURL();
+    } catch (URISyntaxException | MalformedURLException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private ListenableFuture<Void> fetchToStagingRepository(String path) {
+    URL remoteUrl = getRemoteUrl(path);
+    File destination = new File(this.stagingRepositoryDir, path);
+    return createFetchToFileTask(remoteUrl, destination);
+  }
+
+  protected ListenableFuture<Void> createFetchToFileTask(URL remoteUrl, File tempFile) {
+    return Futures.submitAsync(
+        new FetchToFileTask(remoteUrl, tempFile, repositoryUserName, repositoryPassword),
+        this.executorService);
+  }
+
+  private void commitFromStaging(String path) throws IOException {
+    File source = new File(this.stagingRepositoryDir, path);
+    File destination = new File(this.localRepositoryDir, path);
+    Files.move(source, destination);
+  }
+
+  static class FetchToFileTask implements AsyncCallable<Void> {
+
+    private final URL remoteURL;
+    private final File localFile;
+    private String repositoryUserName;
+    private String repositoryPassword;
+
+    public FetchToFileTask(
+        URL remoteURL, File localFile, String repositoryUserName, String repositoryPassword) {
+      this.remoteURL = remoteURL;
+      this.localFile = localFile;
+      this.repositoryUserName = repositoryUserName;
+      this.repositoryPassword = repositoryPassword;
+    }
+
+    @Override
+    public ListenableFuture<Void> call() throws Exception {
+      URLConnection connection = remoteURL.openConnection();
+      // Add authorization header if applicable.
+      if (!Strings.isNullOrEmpty(this.repositoryUserName)) {
+        String encoded =
+            Base64.getEncoder()
+                .encodeToString(
+                    (this.repositoryUserName + ":" + this.repositoryPassword).getBytes(UTF_8));
+        connection.setRequestProperty("Authorization", "Basic " + encoded);
+      }
+
+      Logger.info("Transferring " + remoteURL);
+      try (InputStream inputStream = connection.getInputStream();
+          FileOutputStream outputStream = new FileOutputStream(localFile)) {
+        ByteStreams.copy(inputStream, outputStream);
+      }
+      return Futures.immediateFuture(null);
+    }
+  }
+}
diff --git a/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenDependencyResolver.java b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenDependencyResolver.java
new file mode 100755
index 0000000..22adfae
--- /dev/null
+++ b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenDependencyResolver.java
@@ -0,0 +1,175 @@
+package org.robolectric.internal.dependency;
+
+import com.google.common.base.Strings;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.robolectric.MavenRoboSettings;
+import org.robolectric.util.Logger;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+/**
+ * This class is mainly responsible for fetching Android framework JAR dependencies from
+ * MavenCentral. Initially the fetching was being done with maven-ant-tasks, but that dependency
+ * become outdated and unmaintained and had security vulnerabilities.
+ *
+ * <p>There was an initial attempt to use maven-resolver for this, but that depends on a newer
+ * version of Apache Http Client that is not compatible with the one expected to be on the classpath
+ * for Android 16-18.
+ *
+ * <p>This uses only basic {@link java.net.HttpURLConnection} for fetching. In general using an HTTP
+ * client library here could create conflicts with the ones in the Android system.
+ *
+ * @see <a href="https://maven.apache.org/ant-tasks/">maven-ant-tasks</a>
+ * @see <a href="https://maven.apache.org/resolver/index.html">Maven Resolver</a></a>
+ */
+public class MavenDependencyResolver implements DependencyResolver {
+
+  private final ExecutorService executorService;
+  private final MavenArtifactFetcher mavenArtifactFetcher;
+  private final File localRepositoryDir;
+
+  public MavenDependencyResolver() {
+    this(MavenRoboSettings.getMavenRepositoryUrl(), MavenRoboSettings.getMavenRepositoryId(), MavenRoboSettings
+        .getMavenRepositoryUserName(), MavenRoboSettings.getMavenRepositoryPassword());
+  }
+
+  public MavenDependencyResolver(String repositoryUrl, String repositoryId, String repositoryUserName, String repositoryPassword) {
+    this.executorService = createExecutorService();
+    this.localRepositoryDir = getLocalRepositoryDir();
+    this.mavenArtifactFetcher =
+        createMavenFetcher(
+            repositoryUrl,
+            repositoryUserName,
+            repositoryPassword,
+            localRepositoryDir,
+            this.executorService);
+  }
+
+  @Override
+  public URL[] getLocalArtifactUrls(DependencyJar dependency) {
+    return getLocalArtifactUrls(new DependencyJar[] {dependency});
+  }
+
+  /**
+   * Get an array of local artifact URLs for the given dependencies. The order of the URLs is guaranteed to be the
+   * same as the input order of dependencies, i.e., urls[i] is the local artifact URL for dependencies[i].
+   */
+  @SuppressWarnings("NewApi")
+  public URL[] getLocalArtifactUrls(DependencyJar... dependencies) {
+    List<MavenJarArtifact> artifacts = new ArrayList<>(dependencies.length);
+    whileLocked(
+        () -> {
+          for (DependencyJar dependencyJar : dependencies) {
+            MavenJarArtifact artifact = new MavenJarArtifact(dependencyJar);
+            artifacts.add(artifact);
+            mavenArtifactFetcher.fetchArtifact(artifact);
+          }
+        });
+    URL[] urls = new URL[dependencies.length];
+    try {
+      for (int i = 0; i < artifacts.size(); i++) {
+        MavenJarArtifact artifact = artifacts.get(i);
+        urls[i] = new File(localRepositoryDir, artifact.jarPath()).toURI().toURL();
+      }
+    } catch (MalformedURLException e) {
+      throw new AssertionError(e);
+    }
+    return urls;
+  }
+
+  private void whileLocked(Runnable runnable) {
+    File lockFile = createLockFile();
+    try (RandomAccessFile raf = new RandomAccessFile(lockFile, "rw")) {
+      try (FileChannel channel = raf.getChannel()) {
+        try (FileLock ignored = channel.lock()) {
+          runnable.run();
+        }
+      }
+    } catch (IOException e) {
+      throw new IllegalStateException("Couldn't create lock file " + lockFile, e);
+    } finally {
+      lockFile.delete();
+    }
+  }
+
+  protected File createLockFile() {
+    return new File(System.getProperty("user.home"), ".robolectric-download-lock");
+  }
+
+  @Override
+  public URL getLocalArtifactUrl(DependencyJar dependency) {
+    URL[] urls = getLocalArtifactUrls(dependency);
+    if (urls.length > 0) {
+      return urls[0];
+    }
+    return null;
+  }
+
+  /** Locates the local maven repo. */
+  protected File getLocalRepositoryDir() {
+    String localRepoDir = System.getProperty("maven.repo.local");
+    if (!Strings.isNullOrEmpty(localRepoDir)) {
+      return new File(localRepoDir);
+    }
+    File mavenHome = new File(System.getProperty("user.home"), ".m2");
+    String settingsRepoDir = getLocalRepositoryFromSettings(mavenHome);
+    if (!Strings.isNullOrEmpty(settingsRepoDir)) {
+      return new File(settingsRepoDir);
+    }
+    return new File(mavenHome, "repository");
+  }
+
+  private String getLocalRepositoryFromSettings(File mavenHome) {
+    File mavenSettings = new File(mavenHome, "settings.xml");
+    if (!mavenSettings.exists() || !mavenSettings.isFile()) {
+      return null;
+    }
+    try {
+      DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+      Document document = builder.parse(mavenSettings);
+      NodeList nodeList = document.getElementsByTagName("localRepository");
+
+      if (nodeList.getLength() != 0) {
+        Node node = nodeList.item(0);
+        String repository = node.getTextContent();
+
+        if (repository == null) {
+          return null;
+        }
+        return repository.trim();
+      }
+    } catch (ParserConfigurationException | IOException | SAXException e) {
+      Logger.error("Error reading settings.xml", e);
+    }
+    return null;
+  }
+
+  protected MavenArtifactFetcher createMavenFetcher(
+      String repositoryUrl,
+      String repositoryUserName,
+      String repositoryPassword,
+      File localRepositoryDir,
+      ExecutorService executorService) {
+    return new MavenArtifactFetcher(
+        repositoryUrl, repositoryUserName, repositoryPassword, localRepositoryDir, executorService);
+  }
+
+  protected ExecutorService createExecutorService() {
+    return Executors.newFixedThreadPool(2);
+  }
+}
diff --git a/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenJarArtifact.java b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenJarArtifact.java
new file mode 100644
index 0000000..72a8152
--- /dev/null
+++ b/plugins/maven-dependency-resolver/src/main/java/org/robolectric/internal/dependency/MavenJarArtifact.java
@@ -0,0 +1,50 @@
+package org.robolectric.internal.dependency;
+
+/**
+ * Encapsulates some parts of a Maven artifact. This assumes all artifacts are of type jar and do
+ * not have a classifier.
+ */
+public class MavenJarArtifact {
+  private final String groupId;
+  private final String artifactId;
+  private final String version;
+  private final String jarPath;
+  private final String jarSha512Path;
+  private final String pomPath;
+  private final String pomSha512Path;
+
+  public MavenJarArtifact(DependencyJar dependencyJar) {
+    this.groupId = dependencyJar.getGroupId();
+    this.artifactId = dependencyJar.getArtifactId();
+    this.version = dependencyJar.getVersion();
+    String basePath =
+        String.format("%s/%s/%s", this.groupId.replace(".", "/"), this.artifactId, this.version);
+    String baseName = String.format("%s-%s", this.artifactId, this.version);
+    this.jarPath = String.format("%s/%s.jar", basePath, baseName);
+    this.jarSha512Path = String.format("%s/%s.jar.sha512", basePath, baseName);
+    this.pomPath = String.format("%s/%s.pom", basePath, baseName);
+    this.pomSha512Path = String.format("%s/%s.pom.sha512", basePath, baseName);
+  }
+
+  public String jarPath() {
+    return jarPath;
+  }
+
+  public String jarSha512Path() {
+    return jarSha512Path;
+  }
+
+  public String pomPath() {
+    return pomPath;
+  }
+
+  public String pomSha512Path() {
+    return pomSha512Path;
+  }
+
+  @Override
+  public String toString() {
+    // return coordinates
+    return String.format("%s:%s:%s", groupId, artifactId, version);
+  }
+}
diff --git a/plugins/maven-dependency-resolver/src/test/java/org/robolectric/MavenRoboSettingsTest.java b/plugins/maven-dependency-resolver/src/test/java/org/robolectric/MavenRoboSettingsTest.java
new file mode 100644
index 0000000..164203b
--- /dev/null
+++ b/plugins/maven-dependency-resolver/src/test/java/org/robolectric/MavenRoboSettingsTest.java
@@ -0,0 +1,68 @@
+package org.robolectric;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class MavenRoboSettingsTest {
+
+  private String originalMavenRepositoryId;
+  private String originalMavenRepositoryUrl;
+  private String originalMavenRepositoryUserName;
+  private String originalMavenRepositoryPassword;
+
+  @Before
+  public void setUp() {
+    originalMavenRepositoryId = MavenRoboSettings.getMavenRepositoryId();
+    originalMavenRepositoryUrl = MavenRoboSettings.getMavenRepositoryUrl();
+    originalMavenRepositoryUserName = MavenRoboSettings.getMavenRepositoryUserName();
+    originalMavenRepositoryPassword = MavenRoboSettings.getMavenRepositoryPassword();
+  }
+
+  @After
+  public void tearDown() {
+    MavenRoboSettings.setMavenRepositoryId(originalMavenRepositoryId);
+    MavenRoboSettings.setMavenRepositoryUrl(originalMavenRepositoryUrl);
+    MavenRoboSettings.setMavenRepositoryUserName(originalMavenRepositoryUserName);
+    MavenRoboSettings.setMavenRepositoryPassword(originalMavenRepositoryPassword);
+  }
+
+  @Test
+  public void getMavenRepositoryId_defaultSonatype() {
+    assertEquals("mavenCentral", MavenRoboSettings.getMavenRepositoryId());
+  }
+
+  @Test
+  public void setMavenRepositoryId() {
+    MavenRoboSettings.setMavenRepositoryId("testRepo");
+    assertEquals("testRepo", MavenRoboSettings.getMavenRepositoryId());
+  }
+
+  @Test
+  public void getMavenRepositoryUrl_defaultSonatype() {
+    assertEquals("https://repo1.maven.org/maven2", MavenRoboSettings.getMavenRepositoryUrl());
+  }
+
+  @Test
+  public void setMavenRepositoryUrl() {
+    MavenRoboSettings.setMavenRepositoryUrl("http://local");
+    assertEquals("http://local", MavenRoboSettings.getMavenRepositoryUrl());
+  }
+
+  @Test
+  public void setMavenRepositoryUserName() {
+    MavenRoboSettings.setMavenRepositoryUserName("username");
+    assertEquals("username", MavenRoboSettings.getMavenRepositoryUserName());
+  }
+
+  @Test
+  public void setMavenRepositoryPassword() {
+    MavenRoboSettings.setMavenRepositoryPassword("password");
+    assertEquals("password", MavenRoboSettings.getMavenRepositoryPassword());
+  }
+}
diff --git a/plugins/maven-dependency-resolver/src/test/java/org/robolectric/internal/dependency/MavenDependencyResolverTest.java b/plugins/maven-dependency-resolver/src/test/java/org/robolectric/internal/dependency/MavenDependencyResolverTest.java
new file mode 100644
index 0000000..3849c03
--- /dev/null
+++ b/plugins/maven-dependency-resolver/src/test/java/org/robolectric/internal/dependency/MavenDependencyResolverTest.java
@@ -0,0 +1,281 @@
+package org.robolectric.internal.dependency;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+import com.google.common.io.Files;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutorService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+@SuppressWarnings("UnstableApiUsage")
+public class MavenDependencyResolverTest {
+  private static final File REPOSITORY_DIR;
+  private static final String REPOSITORY_URL;
+  private static final String REPOSITORY_USERNAME = "username";
+  private static final String REPOSITORY_PASSWORD = "password";
+  private static final HashFunction SHA512 = Hashing.sha512();
+
+  private static DependencyJar[] successCases =
+      new DependencyJar[] {
+        new DependencyJar("group", "artifact", "1"),
+        new DependencyJar("org.group2", "artifact2-name", "2.4.5"),
+        new DependencyJar("org.robolectric", "android-all", "10-robolectric-5803371"),
+      };
+
+  static {
+    try {
+      REPOSITORY_DIR = Files.createTempDir();
+      REPOSITORY_DIR.deleteOnExit();
+      REPOSITORY_URL = REPOSITORY_DIR.toURI().toURL().toString();
+
+      for (DependencyJar dependencyJar : successCases) {
+        addTestArtifact(dependencyJar);
+      }
+    } catch (Exception e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private File localRepositoryDir;
+  private ExecutorService executorService;
+  private MavenDependencyResolver mavenDependencyResolver;
+  private TestMavenArtifactFetcher mavenArtifactFetcher;
+
+  @Before
+  public void setUp() throws Exception {
+    executorService = MoreExecutors.newDirectExecutorService();
+    localRepositoryDir = Files.createTempDir();
+    localRepositoryDir.deleteOnExit();
+    mavenArtifactFetcher =
+        new TestMavenArtifactFetcher(
+            REPOSITORY_URL,
+            REPOSITORY_USERNAME,
+            REPOSITORY_PASSWORD,
+            localRepositoryDir,
+            executorService);
+    mavenDependencyResolver = new TestMavenDependencyResolver();
+  }
+
+  @Test
+  public void getLocalArtifactUrl_placesFilesCorrectlyForSingleURL() throws Exception {
+    DependencyJar dependencyJar = successCases[0];
+    mavenDependencyResolver.getLocalArtifactUrl(dependencyJar);
+    assertThat(mavenArtifactFetcher.getNumRequests()).isEqualTo(4);
+    MavenJarArtifact artifact = new MavenJarArtifact(dependencyJar);
+    checkJarArtifact(artifact);
+  }
+
+  @Test
+  public void getLocalArtifactUrl_placesFilesCorrectlyForMultipleURL() throws Exception {
+    mavenDependencyResolver.getLocalArtifactUrls(successCases);
+    assertThat(mavenArtifactFetcher.getNumRequests()).isEqualTo(4 * successCases.length);
+    for (DependencyJar dependencyJar : successCases) {
+      MavenJarArtifact artifact = new MavenJarArtifact(dependencyJar);
+      checkJarArtifact(artifact);
+    }
+  }
+
+  /** Checks the case where the existing artifact directory is valid. */
+  @Test
+  public void getLocalArtifactUrl_handlesExistingArtifactDirectory() throws Exception {
+    DependencyJar dependencyJar = new DependencyJar("group", "artifact", "1");
+    MavenJarArtifact mavenJarArtifact = new MavenJarArtifact(dependencyJar);
+    File jarFile = new File(localRepositoryDir, mavenJarArtifact.jarPath());
+    Files.createParentDirs(jarFile);
+    assertThat(jarFile.getParentFile().isDirectory()).isTrue();
+    mavenDependencyResolver.getLocalArtifactUrl(dependencyJar);
+    checkJarArtifact(mavenJarArtifact);
+  }
+
+  /**
+   * Checks the case where there is some existing artifact metadata in the artifact directory, but
+   * not the JAR.
+   */
+  @Test
+  public void getLocalArtifactUrl_handlesExistingMetadataFile() throws Exception {
+    DependencyJar dependencyJar = new DependencyJar("group", "artifact", "1");
+    MavenJarArtifact mavenJarArtifact = new MavenJarArtifact(dependencyJar);
+    File pomFile = new File(localRepositoryDir, mavenJarArtifact.pomPath());
+    pomFile.getParentFile().mkdirs();
+    Files.write(new byte[0], pomFile);
+    assertThat(pomFile.exists()).isTrue();
+    mavenDependencyResolver.getLocalArtifactUrl(dependencyJar);
+    checkJarArtifact(mavenJarArtifact);
+  }
+
+  private void checkJarArtifact(MavenJarArtifact artifact) throws Exception {
+    File jar = new File(localRepositoryDir, artifact.jarPath());
+    File pom = new File(localRepositoryDir, artifact.pomPath());
+    File jarSha512 = new File(localRepositoryDir, artifact.jarSha512Path());
+    File pomSha512 = new File(localRepositoryDir, artifact.pomSha512Path());
+    assertThat(jar.exists()).isTrue();
+    assertThat(readFile(jar)).isEqualTo(artifact.toString() + " jar contents");
+    assertThat(pom.exists()).isTrue();
+    assertThat(readFile(pom)).isEqualTo(artifact.toString() + " pom contents");
+    assertThat(jarSha512.exists()).isTrue();
+    assertThat(readFile(jarSha512)).isEqualTo(sha512(artifact.toString() + " jar contents"));
+    assertThat(pom.exists()).isTrue();
+    assertThat(readFile(pomSha512)).isEqualTo(sha512(artifact.toString() + " pom contents"));
+  }
+
+  @Test
+  public void getLocalArtifactUrl_doesNotFetchWhenArtifactsExist() throws Exception {
+    DependencyJar dependencyJar = new DependencyJar("group", "artifact", "1");
+    MavenJarArtifact mavenJarArtifact = new MavenJarArtifact(dependencyJar);
+    File artifactFile = new File(localRepositoryDir, mavenJarArtifact.jarPath());
+    artifactFile.getParentFile().mkdirs();
+    Files.write(new byte[0], artifactFile);
+    assertThat(artifactFile.exists()).isTrue();
+    mavenDependencyResolver.getLocalArtifactUrl(dependencyJar);
+    assertThat(mavenArtifactFetcher.getNumRequests()).isEqualTo(0);
+  }
+
+  @Test
+  public void getLocalArtifactUrl_handlesFileNotFound() throws Exception {
+    DependencyJar dependencyJar = new DependencyJar("group", "missing-artifact", "1");
+
+    assertThrows(
+        AssertionError.class, () -> mavenDependencyResolver.getLocalArtifactUrl(dependencyJar));
+  }
+
+  @Test
+  public void getLocalArtifactUrl_handlesInvalidSha512() throws Exception {
+    DependencyJar dependencyJar = new DependencyJar("group", "artifact-invalid-sha512", "1");
+    addTestArtifactInvalidSha512(dependencyJar);
+    assertThrows(
+        AssertionError.class, () -> mavenDependencyResolver.getLocalArtifactUrl(dependencyJar));
+  }
+
+  class TestMavenDependencyResolver extends MavenDependencyResolver {
+
+    @Override
+    protected MavenArtifactFetcher createMavenFetcher(
+        String repositoryUrl,
+        String repositoryUserName,
+        String repositoryPassword,
+        File localRepositoryDir,
+        ExecutorService executorService) {
+      return mavenArtifactFetcher;
+    }
+
+    @Override
+    protected ExecutorService createExecutorService() {
+      return executorService;
+    }
+
+    @Override
+    protected File getLocalRepositoryDir() {
+      return localRepositoryDir;
+    }
+
+    @Override
+    protected File createLockFile() {
+      try {
+        return File.createTempFile("MavenDependencyResolverTest", null);
+      } catch (IOException e) {
+        throw new AssertionError(e);
+      }
+    }
+  }
+
+  static class TestMavenArtifactFetcher extends MavenArtifactFetcher {
+    private ExecutorService executorService;
+    private int numRequests;
+
+    public TestMavenArtifactFetcher(
+        String repositoryUrl,
+        String repositoryUserName,
+        String repositoryPassword,
+        File localRepositoryDir,
+        ExecutorService executorService) {
+      super(
+          repositoryUrl,
+          repositoryUserName,
+          repositoryPassword,
+          localRepositoryDir,
+          executorService);
+      this.executorService = executorService;
+    }
+
+    @Override
+    protected ListenableFuture<Void> createFetchToFileTask(URL remoteUrl, File tempFile) {
+      return Futures.submitAsync(
+          new FetchToFileTask(remoteUrl, tempFile, null, null) {
+            @Override
+            public ListenableFuture<Void> call() throws Exception {
+              numRequests += 1;
+              return super.call();
+            }
+          },
+          executorService);
+    }
+
+    public int getNumRequests() {
+      return numRequests;
+    }
+  }
+
+  static void addTestArtifact(DependencyJar dependencyJar) throws IOException {
+    MavenJarArtifact mavenJarArtifact = new MavenJarArtifact(dependencyJar);
+    try {
+      Files.createParentDirs(new File(REPOSITORY_DIR, mavenJarArtifact.jarPath()));
+      String jarContents = mavenJarArtifact.toString() + " jar contents";
+      Files.write(
+          jarContents.getBytes(StandardCharsets.UTF_8),
+          new File(REPOSITORY_DIR, mavenJarArtifact.jarPath()));
+      Files.write(
+          sha512(jarContents).getBytes(),
+          new File(REPOSITORY_DIR, mavenJarArtifact.jarSha512Path()));
+      String pomContents = mavenJarArtifact.toString() + " pom contents";
+      Files.write(
+          pomContents.getBytes(StandardCharsets.UTF_8),
+          new File(REPOSITORY_DIR, mavenJarArtifact.pomPath()));
+      Files.write(
+          sha512(pomContents).getBytes(),
+          new File(REPOSITORY_DIR, mavenJarArtifact.pomSha512Path()));
+    } catch (MalformedURLException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  static void addTestArtifactInvalidSha512(DependencyJar dependencyJar) throws IOException {
+    MavenJarArtifact mavenJarArtifact = new MavenJarArtifact(dependencyJar);
+    try {
+      Files.createParentDirs(new File(REPOSITORY_DIR, mavenJarArtifact.jarPath()));
+      String jarContents = mavenJarArtifact.toString() + " jar contents";
+      Files.write(jarContents.getBytes(), new File(REPOSITORY_DIR, mavenJarArtifact.jarPath()));
+      Files.write(
+          sha512("No the same content").getBytes(),
+          new File(REPOSITORY_DIR, mavenJarArtifact.jarSha512Path()));
+      String pomContents = mavenJarArtifact.toString() + " pom contents";
+      Files.write(pomContents.getBytes(), new File(REPOSITORY_DIR, mavenJarArtifact.pomPath()));
+      Files.write(
+          sha512("Really not the same content").getBytes(),
+          new File(REPOSITORY_DIR, mavenJarArtifact.pomSha512Path()));
+    } catch (MalformedURLException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  static String sha512(String contents) {
+    return SHA512.hashString(contents, StandardCharsets.UTF_8).toString();
+  }
+
+  static String readFile(File file) throws IOException {
+    return new String(Files.asByteSource(file).read(), StandardCharsets.UTF_8);
+  }
+}
diff --git a/preinstrumented/build.gradle b/preinstrumented/build.gradle
new file mode 100644
index 0000000..438307c
--- /dev/null
+++ b/preinstrumented/build.gradle
@@ -0,0 +1,132 @@
+plugins {
+    id "application"
+}
+apply plugin: 'java'
+
+ext {
+    javaMainClass = "org.robolectric.preinstrumented.JarInstrumentor"
+}
+
+application {
+    mainClassName = javaMainClass
+}
+
+java {
+    sourceCompatibility = JavaVersion.VERSION_1_8
+    targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+dependencies {
+    implementation "com.google.guava:guava:$guavaJREVersion"
+    implementation project(":sandbox")
+}
+
+task instrumentAll {
+    dependsOn ':prefetchSdks'
+    dependsOn 'build'
+
+    doLast {
+        def androidAllMavenLocal = "${System.getProperty('user.home')}/.m2/repository/org/robolectric/android-all"
+
+        sdksToInstrument().each { androidSdk ->
+            println("Instrumenting ${androidSdk.coordinates}")
+            def inputPath = "${androidAllMavenLocal}/${androidSdk.version}/${androidSdk.jarFileName}"
+            def outputPath = "${buildDir}/${androidSdk.preinstrumentedJarFileName}"
+
+            javaexec {
+                classpath = sourceSets.main.runtimeClasspath
+                main = javaMainClass
+                args = [inputPath, outputPath]
+            }
+        }
+    }
+}
+
+task('sourcesJar', type: Jar) {
+    archiveClassifier = "sources"
+}
+
+task('javadocJar', type: Jar) {
+    archiveClassifier = "javadoc"
+}
+
+// Avoid publishing the preinstrumented jars by default. They are published
+// manually when the instrumentation configuration changes to maximize gradle
+// and maven caching.
+if (System.getenv('PUBLISH_PREINSTRUMENTED_JARS') == "true") {
+    apply plugin: 'maven-publish'
+    apply plugin: "signing"
+
+
+    publishing {
+        publications {
+            sdksToInstrument().each { androidSdk ->
+                "sdk${androidSdk.apiLevel}"(MavenPublication) {
+                    artifact "${buildDir}/${androidSdk.preinstrumentedJarFileName}"
+                    artifactId 'android-all-instrumented'
+                    artifact sourcesJar
+                    artifact javadocJar
+                    version androidSdk.preinstrumentedVersion
+
+                    pom {
+                        name = "Google Android ${androidSdk.androidVersion} instrumented android-all library"
+                        description = "Google Android ${androidSdk.androidVersion} framework jars transformed with Robolectric instrumentation."
+                        url = "https://source.android.com/"
+                        inceptionYear = "2008"
+
+                        licenses {
+                            license {
+                                name = "Apache 2.0"
+                                url = "http://www.apache.org/licenses/LICENSE-2.0"
+                                comments = "While the EULA for the Android SDK restricts distribution of those binaries, the source code is licensed under Apache 2.0 which allows compiling binaries from source and then distributing those versions."
+                                distribution = "repo"
+                            }
+                        }
+
+                        scm {
+                            url = "https://android.googlesource.com/platform/manifest.git"
+                            connection = "https://android.googlesource.com/platform/manifest.git"
+                        }
+
+                        developers {
+                            developer {
+                                name = "The Android Open Source Projects"
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        repositories {
+            maven {
+                url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
+
+                credentials {
+                    username = System.properties["sonatype-login"] ?: System.env['sonatypeLogin']
+                    password = System.properties["sonatype-password"] ?: System.env['sonatypePassword']
+                }
+            }
+        }
+    }
+
+    signing {
+        sdksToInstrument().each { androidSdk ->
+            sign publishing.publications."sdk${androidSdk.apiLevel}"
+        }
+    }
+}
+
+def sdksToInstrument() {
+  var result = AndroidSdk.ALL_SDKS
+  var sdkFilter = (System.getenv('PREINSTRUMENTED_SDK_VERSIONS') ?: "").split(",").collect { it as Integer }
+  if (sdkFilter.size > 0) {
+    result = result.findAll { sdkFilter.contains(it.apiLevel) }
+  }
+  return result
+}
+
+clean.doFirst {
+    AndroidSdk.ALL_SDKS.each { androidSdk ->
+        delete "${buildDir}/${androidSdk.preinstrumentedJarFileName}"
+    }
+}
\ No newline at end of file
diff --git a/preinstrumented/src/main/java/org/robolectric/preinstrumented/JarInstrumentor.java b/preinstrumented/src/main/java/org/robolectric/preinstrumented/JarInstrumentor.java
new file mode 100644
index 0000000..ed57692
--- /dev/null
+++ b/preinstrumented/src/main/java/org/robolectric/preinstrumented/JarInstrumentor.java
@@ -0,0 +1,139 @@
+package org.robolectric.preinstrumented;
+
+import com.google.common.io.ByteStreams;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Enumeration;
+import java.util.Locale;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.zip.ZipEntry;
+import org.robolectric.config.AndroidConfigurer;
+import org.robolectric.interceptors.AndroidInterceptors;
+import org.robolectric.internal.bytecode.ClassDetails;
+import org.robolectric.internal.bytecode.ClassInstrumentor;
+import org.robolectric.internal.bytecode.ClassNodeProvider;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.internal.bytecode.Interceptors;
+import org.robolectric.util.inject.Injector;
+
+/** Runs Robolectric invokedynamic instrumentation on an android-all jar. */
+public class JarInstrumentor {
+
+  private static final int ONE_MB = 1024 * 1024;
+
+  private static final Injector INJECTOR = new Injector.Builder().build();
+
+  private final ClassInstrumentor classInstrumentor;
+  private final InstrumentationConfiguration instrumentationConfiguration;
+
+  public JarInstrumentor() {
+    AndroidConfigurer androidConfigurer = INJECTOR.getInstance(AndroidConfigurer.class);
+    classInstrumentor = INJECTOR.getInstance(ClassInstrumentor.class);
+
+    InstrumentationConfiguration.Builder builder = new InstrumentationConfiguration.Builder();
+    Interceptors interceptors = new Interceptors(AndroidInterceptors.all());
+    androidConfigurer.configure(builder, interceptors);
+    instrumentationConfiguration = builder.build();
+  }
+
+  public static void main(String[] args) throws IOException, ClassNotFoundException {
+    if (args.length != 2) {
+      System.err.println("Usage: JarInstrumentor <source jar> <dest jar>");
+      System.exit(1);
+    }
+    new JarInstrumentor().instrumentJar(new File(args[0]), new File(args[1]));
+  }
+
+  private void instrumentJar(File sourceFile, File destFile)
+      throws IOException, ClassNotFoundException {
+    long startNs = System.nanoTime();
+    JarFile jarFile = new JarFile(sourceFile);
+    ClassNodeProvider classNodeProvider =
+        new ClassNodeProvider() {
+          @Override
+          protected byte[] getClassBytes(String className) throws ClassNotFoundException {
+            return JarInstrumentor.getClassBytes(className, jarFile);
+          }
+        };
+
+    int nonClassCount = 0;
+    int classCount = 0;
+
+    try (JarOutputStream jarOut =
+        new JarOutputStream(new BufferedOutputStream(new FileOutputStream(destFile), ONE_MB))) {
+      Enumeration<JarEntry> entries = jarFile.entries();
+      while (entries.hasMoreElements()) {
+        JarEntry jarEntry = entries.nextElement();
+
+        String name = jarEntry.getName();
+        if (name.endsWith("/")) {
+          jarOut.putNextEntry(createJarEntry(jarEntry));
+        } else if (name.endsWith(".class")) {
+          String className = name.substring(0, name.length() - ".class".length()).replace('/', '.');
+
+          try {
+            byte[] classBytes = getClassBytes(className, jarFile);
+            ClassDetails classDetails = new ClassDetails(classBytes);
+            byte[] outBytes = classBytes;
+            if (instrumentationConfiguration.shouldInstrument(classDetails)) {
+              outBytes =
+                  classInstrumentor.instrument(
+                      classDetails, instrumentationConfiguration, classNodeProvider);
+            }
+            jarOut.putNextEntry(createJarEntry(jarEntry));
+            jarOut.write(outBytes);
+            classCount++;
+          } catch (NegativeArraySizeException e) {
+            System.err.println(
+                "Skipping instrumenting due to NegativeArraySizeException for class: " + className);
+          }
+        } else {
+          // resources & stuff
+          jarOut.putNextEntry(createJarEntry(jarEntry));
+          ByteStreams.copy(jarFile.getInputStream(jarEntry), jarOut);
+          nonClassCount++;
+        }
+      }
+    }
+    long elapsedNs = System.nanoTime() - startNs;
+    System.out.println(
+        String.format(
+            Locale.getDefault(),
+            "Wrote %d classes and %d resources in %1.2f seconds",
+            classCount,
+            nonClassCount,
+            elapsedNs / 1000000000.0));
+  }
+
+  private static byte[] getClassBytes(String className, JarFile jarFile)
+      throws ClassNotFoundException {
+    String classFilename = className.replace('.', '/') + ".class";
+    ZipEntry entry = jarFile.getEntry(classFilename);
+    try {
+      InputStream inputStream;
+      if (entry == null) {
+        inputStream = JarInstrumentor.class.getClassLoader().getResourceAsStream(classFilename);
+      } else {
+        inputStream = jarFile.getInputStream(entry);
+      }
+      if (inputStream == null) {
+        throw new ClassNotFoundException("Couldn't find " + className.replace('/', '.'));
+      }
+      return ByteStreams.toByteArray(inputStream);
+    } catch (IOException e) {
+      throw new ClassNotFoundException("Couldn't load " + className.replace('/', '.'), e);
+    }
+  }
+
+  private static JarEntry createJarEntry(JarEntry original) {
+    JarEntry entry = new JarEntry(original.getName());
+    // Setting the timestamp to the original is necessary for deterministic output.
+    entry.setTime(original.getTime());
+    return entry;
+  }
+}
diff --git a/processor/README.md b/processor/README.md
new file mode 100644
index 0000000..fae13a4
--- /dev/null
+++ b/processor/README.md
@@ -0,0 +1,82 @@
+# Robolectric Annotation Processor (RAP)
+
+Welcome to the Robolectric Annotation Processor project.
+
+## What is the Robolectric Annotation Processor?
+
+The Robolectric Annotation Processor (RAP) is an annotation processor that uses Java's built-in annotation processing capability (introduced in Java 6) to make Robolectric development easier. It does this in two ways:
+
+1. It can automatically generate some parts of the code based on the annotations on the shadows.
+2. It enforces at compile-time some constraints that are implied by Robolectric annotations, to help keep code clean and avoid silly bugs.
+3. It improves performance by doing some things at compile time that previously had to be done at load time or run time.
+
+These are discussed in more detail below.
+
+### Automatic code generation
+
+Robolectric has a large amount of boilerplate code in the form of the <code>shadowOf()</code> methods that form a convenient type-safe wrapper around the <code>shadowOf_()</code> base method. This is boilerplate code that must be kept in sync with the actual state of the shadows and imposes extra maintenance overhead. RAP can generate these methods automatically using the information contained in the annotations on the shadows.
+
+### Constraint enforcement
+
+There are a number of usages and constraints implicit in Robolectric's annotation model which if violated will introduce bugs. Many of these cannot be expressed using the simple type system in the annotations themselves. Testing for violations of these constraints at present is enforced by unit tests such as `RobolectricWiringTest`.
+
+RAP employs the philosophy that the earlier in the development cycle you can perform these tests, the better. RAP allows detection of constraint violations at *compile time* rather than during unit testing, which helps to further shorten the development cycle.
+
+Constraints currently enforced by RAP are:
+
+* <code>@Resetter</code> on a method that is not public static void with no parameters.
+* <code>@RealObject</code> or <code>@Resetter</code> on a class not annotated by <code>@Implements</code>
+* <code>@RealObject</code> annotated field with a type that is not assignment compatible with the implemented class.
+* <code>@Implements</code> specifying an unknown class.
+
+Eventually it should be possible to migrate all of the relevant unit tests (such as `RobolectricWiringTest`) to RAP so that all the relevant constraints are enforced by the compiler.
+
+#### In-editor error feedback
+
+As an added bonus over constraint enforcement during unit testing, when RAP reports an constraint violation to its tooling environment, the tooling environment will give feedback on where in the source the error is. In Eclipse (and presumably other modern IDEs like IntelliJ), errors detected by RAP will be reported in the editor with the standard error markers, and mouse-overing them will tell you what the error is. This typically happens immediately when you save a document.
+
+![Example RealObject constraint violation](images/RealObject-error.png)
+
+### Better runtime performance
+
+One of the benefits of RAP is that it enables things to be done at compile time instead of runtime. For example, there are two ways that global reset can be implemented:
+
+1. by implementing a method that statically calls all of the known reset methods on all shadows, or
+2. by iterating over all shadows at runtime looking for reset methods.
+
+The first is more efficient at runtime but imposes an extra maintenance burden, because the developer needs to remember to add calls to reset methods here when they add new resets. The second is requires less maintenance, but imposes an extra runtime overhead.
+
+RAP allows you to have the best of both worlds - it will automatically find all reset methods and generate a global <code>reset()</code> method that statically invokes them all. Because <code>reset()</code> is called for each and every individual test, improvements in its performance can make a noticeable difference to a fixture's performance.
+
+There are likely other opportunities for this sort of optimization in Robolectric using RAP, which could be the subject of a future expansion.
+
+## How to use RAP
+
+In this early stage, RAP is mostly targeted at those contributing to Robolectric itself. Those who wish to do so will simply need to check out the relevant branch of the Robolectric repository, which will already have the <code>pom.xml</code> file configured appropriately.
+
+However, with some few modifications some of the features (such as the constraint enforcement, or custom <code>shadowOf()</code>/global <code>reset()</code> methods) could also be of benefit to users of Robolectric who are developing their own custom shadows. This feature may be added in a future version
+
+## Future enhancements
+
+In developing RAP I forsaw a number of enhancements that would be potentially useful:
+
+* Some of the features in this list have not yet been added due to limitations in the testing framework - if I haven't been able to fully test a feature I haven't added it. Hopefully improvements in the compile-testing library will rectify this situation in the near future.
+* Ability to use RAP outside of the context of developing the Robolectric core, to be able to validate your own shadows.
+* Validation of <code>@Implementation</code>-annotated methods to make sure that their method signatures matched appropriately (similar to the <code>@Overrides</code> annotation). I know personally that mismatching method signatures in shadows has caused me grief in the past and the error reporting for such conditions is not all that obvious. I think that this feature could help many Robolectric developers working on core or custom shadow implementations.
+* Warn when the type of a <code>@RealObject</code> is not the narrowest available type.
+* Better handling of shadows of generic types. At present the checking of appropriate generic types is limited and there is a lot more that could be done to help the developer. Also, the auto-generated <code>shadowOf()</code> methods do not include the type parameters which can lead to "unchecked" warnings in client code.
+* Generally speaking, there is probably a lot more which Robolectric currently does at initialization/run time that could conceivably be pre-computed at compile time using RAP or something similar, to improve runtime performance.
+
+## Credits
+
+A special thanks to the Robolectric team (both its original authors and current maintainers) for producing such a useful tool for test-driven Android development.
+
+### About the author
+
+I started my professional career as a software engineer, and more recently was ordained an Orthodox Christian priest. For now I still works as a software engineer while assisting at my parish in a part-time capacity. I try to employ my engineering experience in ways that might assist my parish (the [Holy Monastery of St Nectarios, Adelaide](http://www.stnectarios.org.au/)).
+
+## Licensing & Donating
+
+Like Robolectric itself, RAP is free software distributed under the MIT license.
+
+However, as a priest I feel that everything I do must be in service of my ministry, and so I also developed RAP partially in the hope that appreciative users might find it in their hearts to make a financial contribution to our parish as an expression of their appreciation. Please visit our website for details: the [Holy Monastery of St Nectarios, Adelaide](http://www.stnectarios.org.au).
diff --git a/processor/build.gradle b/processor/build.gradle
new file mode 100644
index 0000000..b082b5e
--- /dev/null
+++ b/processor/build.gradle
@@ -0,0 +1,55 @@
+import org.gradle.internal.jvm.Jvm
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+class GenerateSdksFileTask extends DefaultTask {
+    @OutputFile File outFile
+
+    @TaskAction
+    public void writeProperties() throws Exception {
+        File outDir = outFile.parentFile
+        if (!outDir.directory) outDir.mkdirs()
+        outFile.withPrintWriter { out ->
+            out << "# GENERATED by ${this} -- do not edit\n"
+
+            AndroidSdk.ALL_SDKS.each { androidSdk ->
+                def config = project.configurations.create("processor_sdk${androidSdk.apiLevel}")
+                project.dependencies.add("processor_sdk${androidSdk.apiLevel}", androidSdk.coordinates)
+                def sdkPath = config.files.first().getAbsolutePath()
+                out << "${sdkPath}\n"
+            }
+        }
+    }
+}
+
+task('generateSdksFile', type: GenerateSdksFileTask) {
+    outFile = new File(project.rootProject.buildDir, 'sdks.txt')
+}
+
+tasks['classes'].dependsOn(generateSdksFile)
+
+dependencies {
+    api project(":annotations")
+    api project(":shadowapi")
+
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+    api "org.ow2.asm:asm:${asmVersion}"
+    api "org.ow2.asm:asm-commons:${asmVersion}"
+    api "com.google.guava:guava:$guavaJREVersion"
+    api "com.google.code.gson:gson:2.9.1"
+    implementation 'com.google.auto:auto-common:1.1.2'
+
+    def toolsJar = Jvm.current().getToolsJar()
+    if (toolsJar != null) {
+        implementation files(toolsJar)
+    }
+
+    testImplementation "javax.annotation:jsr250-api:1.0"
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+    testImplementation "com.google.testing.compile:compile-testing:0.19"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+}
diff --git a/processor/images/RealObject-error.png b/processor/images/RealObject-error.png
new file mode 100644
index 0000000..923650b
--- /dev/null
+++ b/processor/images/RealObject-error.png
Binary files differ
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/DocumentedElement.java b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedElement.java
new file mode 100644
index 0000000..4e34fb4
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedElement.java
@@ -0,0 +1,33 @@
+package org.robolectric.annotation.processing;
+
+import java.util.regex.Pattern;
+
+public abstract class DocumentedElement {
+  private static final Pattern START_OR_NEWLINE_SPACE = Pattern.compile("(^|\n) ");
+
+  private final String name;
+  private String documentation;
+
+  protected DocumentedElement(String name) {
+    this.name = name;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{name='" + name + '\'' + '}';
+  }
+
+  public void setDocumentation(String docStr) {
+    if (docStr != null) {
+      this.documentation = START_OR_NEWLINE_SPACE.matcher(docStr).replaceAll("$1");
+    }
+  }
+
+  public String getDocumentation() {
+    return documentation;
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/DocumentedMethod.java b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedMethod.java
new file mode 100644
index 0000000..727e057
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedMethod.java
@@ -0,0 +1,18 @@
+package org.robolectric.annotation.processing;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class DocumentedMethod extends DocumentedElement {
+  public boolean isImplementation;
+  public List<String> modifiers = new ArrayList<>();
+  public List<String> params = new ArrayList<>();
+  public String returnType;
+  public List<String> exceptions = new ArrayList<>();
+  public Integer minSdk;
+  public Integer maxSdk;
+
+  public DocumentedMethod(String name) {
+    super(name);
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/DocumentedPackage.java b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedPackage.java
new file mode 100644
index 0000000..307cbda
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedPackage.java
@@ -0,0 +1,26 @@
+package org.robolectric.annotation.processing;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class DocumentedPackage extends DocumentedElement {
+  private final Map<String, DocumentedType> documentedTypes = new TreeMap<>();
+
+  DocumentedPackage(String name) {
+    super(name);
+  }
+
+  public Collection<DocumentedType> getDocumentedTypes() {
+    return documentedTypes.values();
+  }
+
+  public DocumentedType getDocumentedType(String name) {
+    DocumentedType documentedType = documentedTypes.get(name);
+    if (documentedType == null) {
+      documentedType = new DocumentedType(name);
+      documentedTypes.put(name, documentedType);
+    }
+    return documentedType;
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/DocumentedType.java b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedType.java
new file mode 100644
index 0000000..0063211
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/DocumentedType.java
@@ -0,0 +1,29 @@
+package org.robolectric.annotation.processing;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class DocumentedType extends DocumentedElement {
+  public final Map<String, DocumentedMethod> methods = new TreeMap<>();
+
+  public List<String> imports;
+
+  DocumentedType(String name) {
+    super(name);
+  }
+
+  public DocumentedMethod getDocumentedMethod(String desc) {
+    DocumentedMethod documentedMethod = methods.get(desc);
+    if (documentedMethod == null) {
+      documentedMethod = new DocumentedMethod(desc);
+      methods.put(desc, documentedMethod);
+    }
+    return documentedMethod;
+  }
+
+  public Collection<DocumentedMethod> getMethods() {
+    return methods.values();
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/Helpers.java b/processor/src/main/java/org/robolectric/annotation/processing/Helpers.java
new file mode 100644
index 0000000..1e3dcb7
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/Helpers.java
@@ -0,0 +1,257 @@
+package org.robolectric.annotation.processing;
+
+import static com.google.common.collect.Lists.newArrayList;
+
+import com.google.common.base.Equivalence;
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
+import java.util.List;
+import java.util.Map.Entry;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.AnnotationValue;
+import javax.lang.model.element.AnnotationValueVisitor;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementVisitor;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Name;
+import javax.lang.model.element.PackageElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.TypeParameterElement;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.util.Elements;
+import javax.lang.model.util.SimpleAnnotationValueVisitor6;
+import javax.lang.model.util.SimpleElementVisitor6;
+import javax.lang.model.util.Types;
+
+public class Helpers {
+
+  private static final AnnotationValueVisitor<TypeMirror, Void> TYPE_MIRROR_VISITOR =
+      new SimpleAnnotationValueVisitor6<TypeMirror, Void>() {
+        @Override
+        public TypeMirror visitType(TypeMirror t, Void arg) {
+          return t;
+        }
+      };
+
+  private static final ElementVisitor<TypeElement, Void> TYPE_ELEMENT_VISITOR =
+      new SimpleElementVisitor6<TypeElement, Void>() {
+        @Override
+        public TypeElement visitType(TypeElement e, Void p) {
+          return e;
+        }
+      };
+
+  private static final AnnotationValueVisitor<String, Void> STRING_VISITOR =
+      new SimpleAnnotationValueVisitor6<String, Void>() {
+        @Override
+        public String visitString(String s, Void arg) {
+          return s;
+        }
+      };
+
+  private static final AnnotationValueVisitor<Integer, Void> INT_VISITOR =
+      new SimpleAnnotationValueVisitor6<Integer, Void>() {
+        @Override
+        public Integer visitInt(int i, Void aVoid) {
+          return i;
+        }
+      };
+
+  public static TypeMirror getAnnotationTypeMirrorValue(AnnotationValue av) {
+    return TYPE_MIRROR_VISITOR.visit(av);
+  }
+
+  public static TypeElement getAnnotationTypeMirrorValue(Element el) {
+    return TYPE_ELEMENT_VISITOR.visit(el);
+  }
+
+  public static AnnotationValue getAnnotationTypeMirrorValue(AnnotationMirror annotationMirror,
+      String key) {
+    for (Entry<? extends ExecutableElement, ? extends AnnotationValue> entry :
+        annotationMirror.getElementValues().entrySet()) {
+      if (entry.getKey().getSimpleName().contentEquals(key)) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  public static String getAnnotationStringValue(AnnotationValue av) {
+    return STRING_VISITOR.visit(av);
+  }
+
+  public static int getAnnotationIntValue(AnnotationValue av) {
+    return INT_VISITOR.visit(av);
+  }
+
+  public static AnnotationMirror getAnnotationMirror(Types types, Element element,
+      TypeElement annotation) {
+    TypeMirror expectedType = annotation.asType();
+    for (AnnotationMirror m : element.getAnnotationMirrors()) {
+      if (types.isSameType(expectedType, m.getAnnotationType())) {
+        return m;
+      }
+    }
+    return null;
+  }
+
+  public static AnnotationMirror getImplementsMirror(Element elem, Types types,
+      TypeElement typeElement) {
+    return getAnnotationMirror(types, elem, typeElement);
+  }
+
+  private final Types types;
+  private final Elements elements;
+
+  /**
+   * TypeMirror representing the Object class.
+   */
+  private final Predicate<TypeMirror> notObject;
+
+  public Helpers(ProcessingEnvironment environment) {
+    this.elements = environment.getElementUtils();
+    this.types = environment.getTypeUtils();
+
+    TypeMirror objectMirror = elements.getTypeElement(Object.class.getCanonicalName()).asType();
+    notObject = t -> !types.isSameType(t, objectMirror);
+  }
+
+  List<TypeMirror> getExplicitBounds(TypeParameterElement typeParam) {
+    return newArrayList(Iterables.filter(typeParam.getBounds(), notObject));
+  }
+
+  private final Equivalence<TypeMirror> typeMirrorEq = new Equivalence<TypeMirror>() {
+    @Override
+    protected boolean doEquivalent(TypeMirror a, TypeMirror b) {
+      return types.isSameType(a, b);
+    }
+
+    @Override
+    protected int doHash(TypeMirror t) {
+      // We're not using the hash.
+      return 0;
+    }
+  };
+
+  private final Equivalence<TypeParameterElement> typeEq = new Equivalence<TypeParameterElement>() {
+    @Override
+    @SuppressWarnings({"unchecked"})
+    protected boolean doEquivalent(TypeParameterElement arg0,
+        TypeParameterElement arg1) {
+      // Casts are necessary due to flaw in pairwise equivalence implementation.
+      return typeMirrorEq.pairwise().equivalent((List<TypeMirror>) arg0.getBounds(),
+          (List<TypeMirror>) arg1.getBounds());
+    }
+
+    @Override
+    protected int doHash(TypeParameterElement arg0) {
+      // We don't use the hash code.
+      return 0;
+    }
+  };
+
+  @SuppressWarnings({"unchecked"})
+  public boolean isSameParameterList(List<? extends TypeParameterElement> l1,
+      List<? extends TypeParameterElement> l2) {
+    // Cast is necessary because of a flaw in the API design of "PairwiseEquivalent",
+    // a flaw that is even acknowledged in the source.
+    // Our casts are safe because we're not trying to add elements to the list
+    // and therefore can't violate the constraint.
+    return typeEq.pairwise().equivalent((List<TypeParameterElement>) l1,
+        (List<TypeParameterElement>) l2);
+  }
+
+  private TypeMirror getImplementedClassName(AnnotationMirror am) {
+    AnnotationValue className = Helpers.getAnnotationTypeMirrorValue(am, "className");
+    if (className == null) {
+      return null;
+    }
+    String classNameString = Helpers.getAnnotationStringValue(className);
+    if (classNameString == null) {
+      return null;
+    }
+    TypeElement impElement = elements.getTypeElement(classNameString.replace('$', '.'));
+    if (impElement == null) {
+      return null;
+    }
+    return impElement.asType();
+  }
+
+  public TypeMirror getImplementedClass(AnnotationMirror am) {
+    if (am == null) {
+      return null;
+    }
+    // RobolectricWiringTest prefers className (if provided) to value, so we do the same here.
+    TypeMirror impType = getImplementedClassName(am);
+    if (impType != null) {
+      return impType;
+    }
+    AnnotationValue av = Helpers.getAnnotationTypeMirrorValue(am, "value");
+    if (av == null) {
+      return null;
+    }
+    TypeMirror type = Helpers.getAnnotationTypeMirrorValue(av);
+    if (type == null) {
+      return null;
+    }
+    return type;
+  }
+
+  String getPackageOf(TypeElement typeElement) {
+    PackageElement name = typeElement == null ? null : elements.getPackageOf(typeElement);
+    return name == null ? null : name.toString();
+  }
+
+  String getBinaryName(TypeElement typeElement) {
+    Name name = typeElement == null ? null : elements.getBinaryName(typeElement);
+    return name == null ? null : name.toString();
+  }
+
+  public void appendParameterList(StringBuilder message,
+      List<? extends TypeParameterElement> tpeList) {
+    boolean first = true;
+    for (TypeParameterElement tpe : tpeList) {
+      if (first) {
+        first = false;
+      } else {
+        message.append(',');
+      }
+      message.append(tpe);
+      boolean iFirst = true;
+      for (TypeMirror bound : getExplicitBounds(tpe)) {
+        if (iFirst) {
+          message.append(" extends ");
+          iFirst = false;
+        } else {
+          message.append(',');
+        }
+        message.append(bound);
+      }
+    }
+  }
+
+  TypeMirror findInterface(TypeElement shadowPickerType, Class<?> interfaceClass) {
+    TypeMirror shadowPickerMirror = elements
+        .getTypeElement(interfaceClass.getName())
+        .asType();
+    for (TypeMirror typeMirror : shadowPickerType.getInterfaces()) {
+      if (types.isSameType(types.erasure(typeMirror), types.erasure(shadowPickerMirror))) {
+        return typeMirror;
+      }
+    }
+    return null;
+  }
+
+  public Element getPackageElement(String packageName) {
+    return elements.getPackageElement(packageName);
+  }
+
+  public Element asElement(TypeMirror typeMirror) {
+    return types.asElement(typeMirror);
+  }
+
+  public TypeElement getTypeElement(String className) {
+    return elements.getTypeElement(className);
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java b/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java
new file mode 100644
index 0000000..79c2461
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java
@@ -0,0 +1,504 @@
+package org.robolectric.annotation.processing;
+
+import static com.google.common.collect.Maps.newHashMap;
+import static com.google.common.collect.Maps.newTreeMap;
+import static com.google.common.collect.Sets.newTreeSet;
+
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimaps;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementVisitor;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.PackageElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.TypeParameterElement;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.type.TypeVisitor;
+import javax.lang.model.util.SimpleElementVisitor6;
+import javax.lang.model.util.SimpleTypeVisitor6;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.ShadowPicker;
+
+/**
+ * Model describing the Robolectric source file.
+ */
+public class RobolectricModel {
+
+  private final TreeSet<String> imports;
+  /**
+   * Key: name of shadow class
+   */
+  private final TreeMap<String, ShadowInfo> shadowTypes;
+  private final TreeMap<String, String> extraShadowTypes;
+  private final TreeMap<String, String> extraShadowPickers;
+  /**
+   * Key: name of shadow class
+   */
+  private final TreeMap<String, ResetterInfo> resetterMap;
+
+  private final TreeMap<String, DocumentedPackage> documentedPackages;
+
+  public Collection<DocumentedPackage> getDocumentedPackages() {
+    return documentedPackages.values();
+  }
+
+  public RobolectricModel(
+      TreeSet<String> imports,
+      TreeMap<String, ShadowInfo> shadowTypes,
+      TreeMap<String, String> extraShadowTypes,
+      TreeMap<String, String> extraShadowPickers,
+      TreeMap<String, ResetterInfo> resetterMap,
+      Map<String, DocumentedPackage> documentedPackages) {
+    this.imports = new TreeSet<>(imports);
+    this.shadowTypes = new TreeMap<>(shadowTypes);
+    this.extraShadowTypes = new TreeMap<>(extraShadowTypes);
+    this.extraShadowPickers = new TreeMap<>(extraShadowPickers);
+    this.resetterMap = new TreeMap<>(resetterMap);
+    this.documentedPackages = new TreeMap<>(documentedPackages);
+  }
+
+  private final static ElementVisitor<TypeElement, Void> TYPE_ELEMENT_VISITOR =
+      new SimpleElementVisitor6<TypeElement, Void>() {
+        @Override
+        public TypeElement visitType(TypeElement e, Void p) {
+          return e;
+        }
+      };
+
+  public static class Builder {
+
+    private final Helpers helpers;
+
+    private final TreeSet<String> imports = newTreeSet();
+    private final TreeMap<String, ShadowInfo> shadowTypes = newTreeMap();
+    private final TreeMap<String, String> extraShadowTypes = newTreeMap();
+    private final TreeMap<String, String> extraShadowPickers = newTreeMap();
+    private final TreeMap<String, ResetterInfo> resetterMap = newTreeMap();
+    private final Map<String, DocumentedPackage> documentedPackages = new TreeMap<>();
+
+    private final Map<TypeElement, TypeElement> importMap = newHashMap();
+    private final Map<TypeElement, String> referentMap = newHashMap();
+    private HashMultimap<String, TypeElement> typeMap = HashMultimap.create();
+
+    Builder(ProcessingEnvironment environment) {
+      this.helpers = new Helpers(environment);
+    }
+
+    public void addShadowType(TypeElement shadowType, TypeElement actualType,
+        TypeElement shadowPickerType) {
+      TypeElement shadowBaseType = null;
+      if (shadowPickerType != null) {
+        TypeMirror iface = helpers.findInterface(shadowPickerType, ShadowPicker.class);
+        if (iface != null) {
+          com.sun.tools.javac.code.Type type = ((com.sun.tools.javac.code.Type.ClassType) iface)
+              .allparams().get(0);
+          String baseClassName = type.asElement().getQualifiedName().toString();
+          shadowBaseType = helpers.getTypeElement(baseClassName);
+        }
+      }
+      ShadowInfo shadowInfo =
+          new ShadowInfo(shadowType, actualType, shadowPickerType, shadowBaseType);
+
+      if (shadowInfo.isInAndroidSdk()) {
+        registerType(shadowInfo.shadowType);
+        registerType(shadowInfo.actualType);
+        registerType(shadowInfo.shadowBaseClass);
+      }
+
+      shadowTypes.put(shadowType.getQualifiedName().toString(), shadowInfo);
+    }
+
+    public void addExtraShadow(String sdkClassName, String shadowClassName) {
+      extraShadowTypes.put(shadowClassName, sdkClassName);
+    }
+
+    public void addExtraShadowPicker(String sdkClassName, TypeElement pickerTypeElement) {
+      extraShadowPickers.put(sdkClassName, helpers.getBinaryName(pickerTypeElement));
+    }
+
+    public void addResetter(TypeElement shadowTypeElement, ExecutableElement elem) {
+      registerType(shadowTypeElement);
+
+      resetterMap.put(shadowTypeElement.getQualifiedName().toString(),
+          new ResetterInfo(shadowTypeElement, elem));
+    }
+
+    public void documentPackage(String name, String documentation) {
+      getDocumentedPackage(name).setDocumentation(documentation);
+    }
+
+    public void documentType(TypeElement type, String documentation, List<String> imports) {
+      DocumentedType documentedType = getDocumentedType(type);
+      documentedType.setDocumentation(documentation);
+      documentedType.imports = imports;
+    }
+
+    public void documentMethod(TypeElement shadowClass, DocumentedMethod documentedMethod) {
+      DocumentedType documentedType = getDocumentedType(shadowClass);
+      documentedType.methods.put(documentedMethod.getName(), documentedMethod);
+    }
+
+    private DocumentedPackage getDocumentedPackage(String name) {
+      DocumentedPackage documentedPackage = documentedPackages.get(name);
+      if (documentedPackage == null) {
+        documentedPackage = new DocumentedPackage(name);
+        documentedPackages.put(name, documentedPackage);
+      }
+      return documentedPackage;
+    }
+
+    private DocumentedPackage getDocumentedPackage(TypeElement type) {
+      Element pkgElement = type.getEnclosingElement();
+      return getDocumentedPackage(pkgElement.toString());
+    }
+
+    private DocumentedType getDocumentedType(TypeElement type) {
+      DocumentedPackage documentedPackage = getDocumentedPackage(type);
+      return documentedPackage.getDocumentedType(type.getQualifiedName().toString());
+    }
+
+    RobolectricModel build() {
+      prepare();
+
+      return new RobolectricModel(
+          imports,
+          shadowTypes,
+          extraShadowTypes,
+          extraShadowPickers,
+          resetterMap,
+          documentedPackages);
+    }
+
+    /**
+     * Prepares the various derived parts of the model based on the class mappings that have been
+     * registered to date.
+     */
+    void prepare() {
+      while (!typeMap.isEmpty()) {
+        final HashMultimap<String, TypeElement> nextRound = HashMultimap.create();
+        for (Map.Entry<String, Set<TypeElement>> referents : Multimaps.asMap(typeMap).entrySet()) {
+          final Set<TypeElement> c = referents.getValue();
+          // If there is only one type left with the given simple
+          // name, then
+          if (c.size() == 1) {
+            final TypeElement type = c.iterator().next();
+            referentMap.put(type, referents.getKey());
+          } else {
+            for (TypeElement type : c) {
+              SimpleElementVisitor6<Void, TypeElement> visitor = new SimpleElementVisitor6<Void, TypeElement>() {
+                @Override
+                public Void visitType(TypeElement parent, TypeElement type) {
+                  nextRound.put(parent.getSimpleName() + "." + type.getSimpleName(), type);
+                  importMap.put(type, parent);
+                  return null;
+                }
+
+                @Override
+                public Void visitPackage(PackageElement parent, TypeElement type) {
+                  referentMap.put(type, type.getQualifiedName().toString());
+                  importMap.remove(type);
+                  return null;
+                }
+              };
+              visitor.visit(importMap.get(type).getEnclosingElement(), type);
+            }
+          }
+        }
+        typeMap = nextRound;
+      }
+
+      // FIXME: check this type lookup for NPEs (and also the ones in the validators)
+      Element javaLang = helpers.getPackageElement("java.lang");
+
+      for (TypeElement imp : importMap.values()) {
+        if (imp.getModifiers().contains(Modifier.PUBLIC)
+            && !javaLang.equals(imp.getEnclosingElement())) {
+          imports.add(imp.getQualifiedName().toString());
+        }
+      }
+
+      // Other imports that the generated class needs
+      imports.add("java.util.AbstractMap");
+      imports.add("java.util.ArrayList");
+      imports.add("java.util.Collection");
+      imports.add("java.util.HashMap");
+      imports.add("java.util.List");
+      imports.add("java.util.Map");
+      imports.add("javax.annotation.Generated");
+      imports.add("org.robolectric.internal.ShadowProvider");
+      imports.add("org.robolectric.shadow.api.Shadow");
+
+      ReferentResolver referentResolver = new ReferentResolver() {
+        @Override
+        public String getReferentFor(TypeMirror typeMirror) {
+          return findReferent.visit(typeMirror);
+        }
+
+        @Override
+        public String getReferentFor(TypeElement type) {
+          return referentMap.get(type);
+        }
+      };
+      shadowTypes.values().forEach(shadowInfo -> shadowInfo.prepare(referentResolver, helpers));
+      resetterMap.values().forEach(resetterInfo -> resetterInfo.prepare(referentResolver));
+    }
+
+    private void registerType(TypeElement type) {
+      if (type != null && !importMap.containsKey(type)) {
+        typeMap.put(type.getSimpleName().toString(), type);
+        importMap.put(type, type);
+        for (TypeParameterElement typeParam : type.getTypeParameters()) {
+          for (TypeMirror bound : typeParam.getBounds()) {
+            // FIXME: get rid of cast using a visitor
+            TypeElement boundElement = TYPE_ELEMENT_VISITOR.visit(helpers.asElement(bound));
+            registerType(boundElement);
+          }
+        }
+      }
+    }
+
+    private final TypeVisitor<String, Void> findReferent = new SimpleTypeVisitor6<String, Void>() {
+      @Override
+      public String visitDeclared(DeclaredType t, Void p) {
+        return referentMap.get(t.asElement());
+      }
+    };
+  }
+
+  public Collection<ResetterInfo> getResetters() {
+    return resetterMap.values();
+  }
+
+  public Set<String> getImports() {
+    return imports;
+  }
+
+  public Collection<ShadowInfo> getAllShadowTypes() {
+    return shadowTypes.values();
+  }
+
+  public Map<String, String> getExtraShadowTypes() {
+    return extraShadowTypes;
+  }
+
+  public Map<String, String> getExtraShadowPickers() {
+    return extraShadowPickers;
+  }
+
+  public Iterable<ShadowInfo> getVisibleShadowTypes() {
+    return Iterables.filter(shadowTypes.values(),
+        ShadowInfo::isInAndroidSdk);
+  }
+
+  public TreeMap<String, ShadowInfo> getShadowPickers() {
+    TreeMap<String, ShadowInfo> map = new TreeMap<>();
+    Iterables.filter(shadowTypes.values(), ShadowInfo::hasShadowPicker)
+        .forEach(shadowInfo -> {
+          String actualName = shadowInfo.getActualName();
+          String shadowPickerClassName = shadowInfo.getShadowPickerBinaryName();
+          ShadowInfo otherShadowInfo = map.get(actualName);
+          String otherPicker =
+              otherShadowInfo == null ? null : otherShadowInfo.getShadowPickerBinaryName();
+          if (otherPicker != null && !otherPicker.equals(shadowPickerClassName)) {
+            throw new IllegalArgumentException(
+                actualName + " has conflicting pickers: " + shadowPickerClassName + " != "
+                    + otherPicker);
+          } else {
+            map.put(actualName, shadowInfo);
+          }
+        });
+    return map;
+  }
+
+  public Collection<String> getShadowedPackages() {
+    Set<String> packages = new TreeSet<>();
+    for (ShadowInfo shadowInfo : shadowTypes.values()) {
+      String packageName = shadowInfo.getActualPackage();
+
+      // org.robolectric.* should never be instrumented
+      if (packageName.matches("org.robolectric(\\..*)?")) {
+        continue;
+      }
+
+      packages.add("\"" + packageName + "\"");
+    }
+    return packages;
+  }
+
+  interface ReferentResolver {
+
+    String getReferentFor(TypeMirror typeMirror);
+
+    /**
+     * Returns a plain string to be used in the generated source to identify the given type. The
+     * returned string will have sufficient level of qualification in order to make the referent
+     * unique for the source file.
+     */
+    String getReferentFor(TypeElement type);
+  }
+
+  public static class ShadowInfo {
+
+    private final TypeElement shadowType;
+    private final TypeElement actualType;
+    private final TypeElement shadowPickerType;
+    private final TypeElement shadowBaseClass;
+
+    private String paramDefStr;
+    private String paramUseStr;
+    private String actualBinaryName;
+    private String actualTypeReferent;
+    private String shadowTypeReferent;
+    private String actualTypePackage;
+    private String shadowBinaryName;
+    private String shadowPickerBinaryName;
+    private String shadowBaseName;
+
+    ShadowInfo(TypeElement shadowType, TypeElement actualType, TypeElement shadowPickerType,
+        TypeElement shadowBaseClass) {
+      this.shadowType = shadowType;
+      this.actualType = actualType;
+      this.shadowPickerType = shadowPickerType;
+      this.shadowBaseClass = shadowBaseClass;
+    }
+
+    void prepare(ReferentResolver referentResolver, Helpers helpers) {
+      int paramCount = 0;
+      StringBuilder paramDef = new StringBuilder("<");
+      StringBuilder paramUse = new StringBuilder("<");
+      for (TypeParameterElement typeParam : actualType.getTypeParameters()) {
+        if (paramCount > 0) {
+          paramDef.append(',');
+          paramUse.append(',');
+        }
+        boolean first = true;
+        paramDef.append(typeParam);
+        paramUse.append(typeParam);
+        for (TypeMirror bound : helpers.getExplicitBounds(typeParam)) {
+          if (first) {
+            paramDef.append(" extends ");
+            first = false;
+          } else {
+            paramDef.append(" & ");
+          }
+          paramDef.append(referentResolver.getReferentFor(bound));
+        }
+        paramCount++;
+      }
+
+      this.paramDefStr = "";
+      this.paramUseStr = "";
+      if (paramCount > 0) {
+        paramDefStr = paramDef.append('>').toString();
+        paramUseStr = paramUse.append('>').toString();
+      }
+
+      actualTypeReferent = referentResolver.getReferentFor(actualType);
+      actualTypePackage = helpers.getPackageOf(actualType);
+      actualBinaryName = helpers.getBinaryName(actualType);
+      shadowTypeReferent = referentResolver.getReferentFor(shadowType);
+      shadowBinaryName = helpers.getBinaryName(shadowType);
+      shadowPickerBinaryName = helpers.getBinaryName(shadowPickerType);
+      shadowBaseName = referentResolver.getReferentFor(shadowBaseClass);
+    }
+
+    public String getActualBinaryName() {
+      return actualBinaryName;
+    }
+
+    public String getActualName() {
+      return actualType.getQualifiedName().toString();
+    }
+
+    public boolean isInAndroidSdk() {
+      return shadowType.getAnnotation(Implements.class).isInAndroidSdk();
+    }
+
+    public String getParamDefStr() {
+      return paramDefStr;
+    }
+
+    public boolean shadowIsDeprecated() {
+      return shadowType.getAnnotation(Deprecated.class) != null;
+    }
+
+    public boolean actualIsPublic() {
+      return actualType.getModifiers().contains(Modifier.PUBLIC);
+    }
+
+    public String getActualTypeWithParams() {
+      return actualTypeReferent + paramUseStr;
+    }
+
+    public String getShadowName() {
+      return shadowType.getQualifiedName().toString();
+    }
+
+    public String getShadowBinaryName() {
+      return shadowBinaryName;
+    }
+
+    public String getShadowTypeWithParams() {
+      return shadowTypeReferent + paramUseStr;
+    }
+
+    String getActualPackage() {
+      return actualTypePackage;
+    }
+
+    boolean hasShadowPicker() {
+      return shadowPickerType != null;
+    }
+
+    public String getShadowPickerBinaryName() {
+      return shadowPickerBinaryName;
+    }
+
+    public String getShadowBaseName() {
+      return shadowBaseName;
+    }
+  }
+
+  public static class ResetterInfo {
+
+    private final TypeElement shadowType;
+    private final ExecutableElement executableElement;
+    private String shadowTypeReferent;
+
+    ResetterInfo(TypeElement shadowType, ExecutableElement executableElement) {
+      this.shadowType = shadowType;
+      this.executableElement = executableElement;
+    }
+
+    void prepare(ReferentResolver referentResolver) {
+      shadowTypeReferent = referentResolver.getReferentFor(shadowType);
+    }
+
+    private Implements getImplementsAnnotation() {
+      return shadowType.getAnnotation(Implements.class);
+    }
+
+    public String getMethodCall() {
+      return shadowTypeReferent + "." + executableElement.getSimpleName() + "();";
+    }
+
+    public int getMinSdk() {
+      return getImplementsAnnotation().minSdk();
+    }
+
+    public int getMaxSdk() {
+      return getImplementsAnnotation().maxSdk();
+    }
+  }
+
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/RobolectricProcessor.java b/processor/src/main/java/org/robolectric/annotation/processing/RobolectricProcessor.java
new file mode 100644
index 0000000..f767765
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/RobolectricProcessor.java
@@ -0,0 +1,157 @@
+package org.robolectric.annotation.processing;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.annotation.processing.SupportedOptions;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.TypeElement;
+import org.robolectric.annotation.processing.generator.Generator;
+import org.robolectric.annotation.processing.generator.JavadocJsonGenerator;
+import org.robolectric.annotation.processing.generator.ServiceLoaderGenerator;
+import org.robolectric.annotation.processing.generator.ShadowProviderGenerator;
+import org.robolectric.annotation.processing.validator.ImplementationValidator;
+import org.robolectric.annotation.processing.validator.ImplementsValidator;
+import org.robolectric.annotation.processing.validator.ImplementsValidator.SdkCheckMode;
+import org.robolectric.annotation.processing.validator.RealObjectValidator;
+import org.robolectric.annotation.processing.validator.ResetterValidator;
+import org.robolectric.annotation.processing.validator.SdkStore;
+import org.robolectric.annotation.processing.validator.Validator;
+
+/**
+ * Annotation processor entry point for Robolectric annotations.
+ */
+@SupportedOptions({
+  RobolectricProcessor.PACKAGE_OPT, 
+  RobolectricProcessor.SHOULD_INSTRUMENT_PKG_OPT})
+@SupportedAnnotationTypes("org.robolectric.annotation.*")
+public class RobolectricProcessor extends AbstractProcessor {
+  static final String PACKAGE_OPT = "org.robolectric.annotation.processing.shadowPackage";
+  static final String SHOULD_INSTRUMENT_PKG_OPT = 
+      "org.robolectric.annotation.processing.shouldInstrumentPackage";
+  static final String JSON_DOCS_DIR = "org.robolectric.annotation.processing.jsonDocsDir";
+  static final String JSON_DOCS_ENABLED = "org.robolectric.annotation.processing.jsonDocsEnabled";
+  static final String SDK_CHECK_MODE = "org.robolectric.annotation.processing.sdkCheckMode";
+  private static final String SDKS_FILE = "org.robolectric.annotation.processing.sdks";
+  private static final String PRIORITY = "org.robolectric.annotation.processing.priority";
+
+  private RobolectricModel.Builder modelBuilder;
+  private String shadowPackage;
+  private boolean shouldInstrumentPackages;
+  private int priority;
+  private ImplementsValidator.SdkCheckMode sdkCheckMode;
+  private String sdksFile;
+  private Map<String, String> options;
+  private boolean generated = false;
+  private final List<Generator> generators = new ArrayList<>();
+  private final Map<TypeElement, Validator> elementValidators = new HashMap<>(13);
+  private File jsonDocsDir;
+  private boolean jsonDocsEnabled;
+
+  /**
+   * Default constructor.
+   */
+  public RobolectricProcessor() {
+  }
+
+  /**
+   * Constructor to use for testing passing options in. Only
+   * necessary until compile-testing supports passing options
+   * in.
+   *
+   * @param options simulated options that would ordinarily
+   *                be passed in the {@link ProcessingEnvironment}.
+   */
+  @VisibleForTesting
+  public RobolectricProcessor(Map<String, String> options) {
+    processOptions(options);
+  }
+
+  @Override
+  public synchronized void init(ProcessingEnvironment environment) {
+    super.init(environment);
+    processOptions(environment.getOptions());
+    modelBuilder = new RobolectricModel.Builder(environment);
+
+    SdkStore sdkStore = new SdkStore(sdksFile);
+
+    addValidator(new ImplementationValidator(modelBuilder, environment));
+    addValidator(new ImplementsValidator(modelBuilder, environment, sdkCheckMode, sdkStore));
+    addValidator(new RealObjectValidator(modelBuilder, environment));
+    addValidator(new ResetterValidator(modelBuilder, environment));
+  }
+
+  @Override
+  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+    for (TypeElement annotation : annotations) {
+      Validator validator = elementValidators.get(annotation);
+      if (validator != null) {
+        for (Element elem : roundEnv.getElementsAnnotatedWith(annotation)) {
+          validator.visit(elem, elem.getEnclosingElement());
+        }
+      }
+    }
+
+    if (!generated) {
+      RobolectricModel model = modelBuilder.build();
+
+      generators.add(
+          new ShadowProviderGenerator(
+              model, processingEnv, shadowPackage, shouldInstrumentPackages, priority));
+      generators.add(new ServiceLoaderGenerator(processingEnv, shadowPackage));
+      if (jsonDocsEnabled) {
+        generators.add(new JavadocJsonGenerator(model, processingEnv, jsonDocsDir));
+      }
+      for (Generator generator : generators) {
+        generator.generate();
+      }
+      generated = true;
+    }
+    return false;
+  }
+
+  private void addValidator(Validator v) {
+    elementValidators.put(v.getAnnotationType(), v);
+  }
+
+  private void processOptions(Map<String, String> options) {
+    if (this.options == null) {
+      this.options = options;
+      this.shadowPackage = options.get(PACKAGE_OPT);
+      this.shouldInstrumentPackages =
+          !"false".equalsIgnoreCase(options.get(SHOULD_INSTRUMENT_PKG_OPT));
+      this.jsonDocsDir = new File(options.getOrDefault(JSON_DOCS_DIR, "build/docs/json"));
+      this.jsonDocsEnabled = "true".equalsIgnoreCase(options.get(JSON_DOCS_ENABLED));
+      this.sdkCheckMode =
+          SdkCheckMode.valueOf(options.getOrDefault(SDK_CHECK_MODE, "WARN").toUpperCase());
+      this.sdksFile = getSdksFile(options, SDKS_FILE);
+      this.priority =
+          Integer.parseInt(options.getOrDefault(PRIORITY, "0"));
+
+      if (this.shadowPackage == null) {
+        throw new IllegalArgumentException("no package specified for " + PACKAGE_OPT);
+      }
+    }
+  }
+
+  /**
+   * Extendable to support Bazel environments, where the sdks file is generated as a build artifact.
+   */
+  protected String getSdksFile(Map<String, String> options, String sdksFileParam) {
+    return options.get(sdksFileParam);
+  }
+
+  @Override
+  public SourceVersion getSupportedSourceVersion() {
+    return SourceVersion.latest();
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/generator/Generator.java b/processor/src/main/java/org/robolectric/annotation/processing/generator/Generator.java
new file mode 100644
index 0000000..b78197b
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/generator/Generator.java
@@ -0,0 +1,10 @@
+package org.robolectric.annotation.processing.generator;
+
+/**
+ * Base class for code generators.
+ */
+public abstract class Generator {
+  protected static final String GEN_CLASS = "Shadows";
+
+  public abstract void generate();
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/generator/JavadocJsonGenerator.java b/processor/src/main/java/org/robolectric/annotation/processing/generator/JavadocJsonGenerator.java
new file mode 100644
index 0000000..ae6d4c0
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/generator/JavadocJsonGenerator.java
@@ -0,0 +1,83 @@
+package org.robolectric.annotation.processing.generator;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.tools.Diagnostic;
+import javax.tools.Diagnostic.Kind;
+import org.robolectric.annotation.processing.DocumentedPackage;
+import org.robolectric.annotation.processing.DocumentedType;
+import org.robolectric.annotation.processing.RobolectricModel;
+import org.robolectric.annotation.processing.RobolectricModel.ShadowInfo;
+
+/**
+ * Primarily used by the Robolectric Chrome extension for Robolectric docs alongside of Android SDK
+ * docs.
+ */
+public class JavadocJsonGenerator extends Generator {
+  private final RobolectricModel model;
+  private final Messager messager;
+  private final Gson gson;
+  private final File jsonDocsDir;
+
+  public JavadocJsonGenerator(
+      RobolectricModel model, ProcessingEnvironment environment, File jsonDocsDir) {
+    super();
+
+    this.model = model;
+    this.messager = environment.getMessager();
+    gson = new GsonBuilder()
+        .setPrettyPrinting()
+        .create();
+    this.jsonDocsDir = jsonDocsDir;
+  }
+
+  @Override
+  public void generate() {
+    Map<String, String> shadowedTypes = new HashMap<>();
+    for (ShadowInfo entry : model.getVisibleShadowTypes()) {
+      shadowedTypes.put(entry.getShadowName().replace('$', '.'), entry.getActualName());
+    }
+
+    for (Map.Entry<String, String> entry : model.getExtraShadowTypes().entrySet()) {
+      String shadowType = entry.getKey().replace('$', '.');
+      String shadowedType = entry.getValue();
+      shadowedTypes.put(shadowType, shadowedType);
+    }
+
+    for (DocumentedPackage documentedPackage : model.getDocumentedPackages()) {
+      for (DocumentedType documentedType : documentedPackage.getDocumentedTypes()) {
+        String shadowedType = shadowedTypes.get(documentedType.getName());
+        if (shadowedType == null) {
+          messager.printMessage(Kind.WARNING,
+              "Couldn't find shadowed type for " + documentedType.getName());
+        } else {
+          writeJson(documentedType, new File(jsonDocsDir, shadowedType + ".json"));
+        }
+      }
+    }
+  }
+
+  private void writeJson(Object object, File file) {
+    try {
+      file.getParentFile().mkdirs();
+
+      try (BufferedWriter writer =
+          new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
+        gson.toJson(object, writer);
+      }
+    } catch (IOException e) {
+      messager.printMessage(Diagnostic.Kind.ERROR, "Failed to write javadoc JSON file: " + e);
+      throw new RuntimeException(e);
+    }
+  }
+
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/generator/ServiceLoaderGenerator.java b/processor/src/main/java/org/robolectric/annotation/processing/generator/ServiceLoaderGenerator.java
new file mode 100644
index 0000000..54d2bc6
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/generator/ServiceLoaderGenerator.java
@@ -0,0 +1,41 @@
+package org.robolectric.annotation.processing.generator;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import javax.annotation.processing.Filer;
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.tools.Diagnostic;
+import javax.tools.FileObject;
+import javax.tools.StandardLocation;
+
+/**
+ * Generator that creates the service loader metadata for a shadow package.
+ */
+public class ServiceLoaderGenerator extends Generator {
+  private final Filer filer;
+  private final Messager messager;
+  private final String shadowPackage;
+
+  public ServiceLoaderGenerator(ProcessingEnvironment environment, String shadowPackage) {
+    this.filer = environment.getFiler();
+    this.messager = environment.getMessager();
+    this.shadowPackage = shadowPackage;
+  }
+
+  @Override
+  public void generate() {
+    final String fileName = "org.robolectric.internal.ShadowProvider";
+
+    try {
+      FileObject file = filer.createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/services/" + fileName);
+      PrintWriter pw = new PrintWriter(new OutputStreamWriter(file.openOutputStream(), "UTF-8"));
+      pw.print(shadowPackage + '.' + GEN_CLASS + '\n');
+      pw.close();
+    } catch (IOException e) {
+      messager.printMessage(Diagnostic.Kind.ERROR, "Failed to write service loader metadata file: " + e);
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/generator/ShadowProviderGenerator.java b/processor/src/main/java/org/robolectric/annotation/processing/generator/ShadowProviderGenerator.java
new file mode 100644
index 0000000..571fe57
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/generator/ShadowProviderGenerator.java
@@ -0,0 +1,250 @@
+package org.robolectric.annotation.processing.generator;
+
+import com.google.common.base.Joiner;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+import javax.annotation.processing.Filer;
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.tools.Diagnostic;
+import javax.tools.JavaFileObject;
+import org.robolectric.annotation.processing.RobolectricModel;
+import org.robolectric.annotation.processing.RobolectricModel.ShadowInfo;
+import org.robolectric.annotation.processing.RobolectricProcessor;
+
+/** Generator that creates the "ShadowProvider" implementation for a shadow package. */
+public class ShadowProviderGenerator extends Generator {
+  private final Filer filer;
+  private final Messager messager;
+  private final RobolectricModel model;
+  private final String shadowPackage;
+  private final boolean shouldInstrumentPackages;
+  private final int priority;
+
+  public ShadowProviderGenerator(
+      RobolectricModel model,
+      ProcessingEnvironment environment,
+      String shadowPackage,
+      boolean shouldInstrumentPackages,
+      int priority) {
+    this.messager = environment.getMessager();
+    this.filer = environment.getFiler();
+    this.model = model;
+    this.shadowPackage = shadowPackage;
+    this.shouldInstrumentPackages = shouldInstrumentPackages;
+    this.priority = priority;
+  }
+
+  @Override
+  public void generate() {
+    if (shadowPackage == null) {
+      return;
+    }
+
+    final String shadowClassName = shadowPackage + '.' + GEN_CLASS;
+
+    try {
+      JavaFileObject jfo = filer.createSourceFile(shadowClassName);
+      try (PrintWriter writer = new PrintWriter(jfo.openWriter())) {
+        generate(writer);
+      }
+    } catch (IOException e) {
+      messager.printMessage(Diagnostic.Kind.ERROR, "Failed to write shadow class file: " + e);
+      throw new RuntimeException(e);
+    }
+  }
+
+  void generate(PrintWriter writer) {
+    writer.print("package " + shadowPackage + ";\n");
+    for (String name : model.getImports()) {
+      writer.println("import " + name + ';');
+    }
+    writer.println();
+    writer.println("/**");
+    writer.println(
+        " * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.");
+    writer.println(" */");
+    writer.println("@Generated(\"" + RobolectricProcessor.class.getCanonicalName() + "\")");
+    if (priority != 0) {
+      writer.println("@javax.annotation.Priority(" + priority + ")");
+    }
+    writer.println("@SuppressWarnings({\"unchecked\",\"deprecation\"})");
+    writer.println("public class " + GEN_CLASS + " implements ShadowProvider {");
+
+    writer.println(
+        "  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>("
+            + (model.getAllShadowTypes().size() + model.getExtraShadowTypes().size())
+            + ");");
+    writer.println();
+
+    writer.println("  static {");
+    for (ShadowInfo shadowInfo : model.getAllShadowTypes()) {
+      final String shadow = shadowInfo.getShadowBinaryName();
+      final String actual = shadowInfo.getActualName();
+      if (shadowInfo.getShadowPickerBinaryName() == null) {
+        writer.println(
+            "    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>(\""
+                + actual
+                + "\", \""
+                + shadow
+                + "\"));");
+      }
+    }
+
+    for (Map.Entry<String, String> entry : model.getExtraShadowTypes().entrySet()) {
+      final String shadow = entry.getKey();
+      final String actual = entry.getValue();
+      writer.println(
+          "    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>(\""
+              + actual
+              + "\", \""
+              + shadow
+              + "\"));");
+    }
+
+    writer.println("  }");
+    writer.println();
+
+    for (ShadowInfo shadowInfo : model.getVisibleShadowTypes()) {
+      if (!shadowInfo.actualIsPublic()) {
+        continue;
+      }
+
+      if (shadowInfo.getShadowPickerBinaryName() != null) {
+        continue;
+      }
+
+      if (shadowInfo.shadowIsDeprecated()) {
+        writer.println("  @Deprecated");
+      }
+      String paramDefStr = shadowInfo.getParamDefStr();
+      final String shadow = shadowInfo.getShadowTypeWithParams();
+      writer.println(
+          "  public static "
+              + (paramDefStr.isEmpty() ? "" : paramDefStr + " ")
+              + shadow
+              + " shadowOf("
+              + shadowInfo.getActualTypeWithParams()
+              + " actual) {");
+      writer.println("    return (" + shadow + ") Shadow.extract(actual);");
+      writer.println("  }");
+      writer.println();
+    }
+
+    // this sucks, kill:
+    for (Entry<String, ShadowInfo> entry : model.getShadowPickers().entrySet()) {
+      ShadowInfo shadowInfo = entry.getValue();
+
+      if (!shadowInfo.actualIsPublic() || !shadowInfo.isInAndroidSdk()) {
+        continue;
+      }
+
+      if (shadowInfo.shadowIsDeprecated()) {
+        writer.println("  @Deprecated");
+      }
+      String paramDefStr = shadowInfo.getParamDefStr();
+      final String shadow = shadowInfo.getShadowName();
+      writer.println(
+          "  public static "
+              + (paramDefStr.isEmpty() ? "" : paramDefStr + " ")
+              + shadow
+              + " shadowOf("
+              + shadowInfo.getActualTypeWithParams()
+              + " actual) {");
+      writer.println("    return (" + shadow + ") Shadow.extract(actual);");
+      writer.println("  }");
+      writer.println();
+    }
+
+    writer.println("  @Override");
+    writer.println("  public void reset() {");
+    for (RobolectricModel.ResetterInfo resetterInfo : model.getResetters()) {
+      int minSdk = resetterInfo.getMinSdk();
+      int maxSdk = resetterInfo.getMaxSdk();
+      String ifClause;
+      if (minSdk != -1 && maxSdk != -1) {
+        ifClause =
+            "if (org.robolectric.RuntimeEnvironment.getApiLevel() >= "
+                + minSdk
+                + " && org.robolectric.RuntimeEnvironment.getApiLevel() <= "
+                + maxSdk
+                + ") ";
+      } else if (maxSdk != -1) {
+        ifClause = "if (org.robolectric.RuntimeEnvironment.getApiLevel() <= " + maxSdk + ") ";
+      } else if (minSdk != -1) {
+        ifClause = "if (org.robolectric.RuntimeEnvironment.getApiLevel() >= " + minSdk + ") ";
+      } else {
+        ifClause = "";
+      }
+      writer.println("    " + ifClause + resetterInfo.getMethodCall());
+    }
+    writer.println("  }");
+    writer.println();
+
+    writer.println("  @Override");
+    writer.println("  public Collection<Map.Entry<String, String>> getShadows() {");
+    writer.println("    return SHADOWS;");
+    writer.println("  }");
+    writer.println();
+
+    writer.println("  @Override");
+    writer.println("  public String[] getProvidedPackageNames() {");
+    writer.println("    return new String[] {");
+    if (shouldInstrumentPackages) {
+      writer.println("      " + Joiner.on(",\n      ").join(model.getShadowedPackages()));
+    }
+    writer.println("    };");
+    writer.println("  }");
+    writer.println();
+
+    TreeMap<String, ShadowInfo> shadowPickers = model.getShadowPickers();
+    if (!shadowPickers.isEmpty()) {
+      writer.println(
+          "  private static final Map<String, String> SHADOW_PICKER_MAP = "
+              + "new HashMap<>("
+              + shadowPickers.size()
+              + model.getExtraShadowPickers().size()
+              + ");");
+      writer.println();
+
+      writer.println("  static {");
+      for (Entry<String, ShadowInfo> entry : shadowPickers.entrySet()) {
+        ShadowInfo shadowInfo = entry.getValue();
+        final String actualBinaryName = shadowInfo.getActualBinaryName();
+        final String shadowPickerClassName = shadowInfo.getShadowPickerBinaryName();
+        writer.println(
+            "    SHADOW_PICKER_MAP.put(\""
+                + actualBinaryName
+                + "\", "
+                + "\""
+                + shadowPickerClassName
+                + "\");");
+      }
+
+      for (Entry<String, String> entry : model.getExtraShadowPickers().entrySet()) {
+        final String className = entry.getKey();
+        final String shadowPickerClassName = entry.getValue();
+        writer.println(
+            "    SHADOW_PICKER_MAP.put(\""
+                + className
+                + "\", "
+                + "\""
+                + shadowPickerClassName
+                + "\");");
+      }
+
+      writer.println("  }");
+      writer.println();
+
+      writer.println("  @Override");
+      writer.println("  public Map<String, String> getShadowPickerMap() {");
+      writer.println("    return SHADOW_PICKER_MAP;");
+      writer.println("  }");
+    }
+
+    writer.println('}');
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/generator/package-info.java b/processor/src/main/java/org/robolectric/annotation/processing/generator/package-info.java
new file mode 100644
index 0000000..0701d87
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/generator/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Classes used to generate code.
+ */
+package org.robolectric.annotation.processing.generator;
\ No newline at end of file
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/package-info.java b/processor/src/main/java/org/robolectric/annotation/processing/package-info.java
new file mode 100644
index 0000000..b2613d1
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * Robolectric annotation processor.
+ *
+ * <p>Annotation processor used to generate {@code shadowOf} methods and other metadata needed by
+ * shadow packages, as well as perform compile-time checking of constraints that are implied by
+ * Robolectric's annotations.
+ */
+package org.robolectric.annotation.processing;
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/FoundOnImplementsValidator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/FoundOnImplementsValidator.java
new file mode 100644
index 0000000..ee1bbe2
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/FoundOnImplementsValidator.java
@@ -0,0 +1,67 @@
+package org.robolectric.annotation.processing.validator;
+
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.TypeMirror;
+import org.robolectric.annotation.processing.Helpers;
+import org.robolectric.annotation.processing.RobolectricModel;
+
+/**
+ * Validator that checks usages of {@link org.robolectric.annotation.Implements}.
+ */
+public abstract class FoundOnImplementsValidator extends Validator {
+
+  private final TypeElement implementsType =
+      elements.getTypeElement(ImplementsValidator.IMPLEMENTS_CLASS);
+
+  protected AnnotationMirror imp;
+  
+  public FoundOnImplementsValidator(RobolectricModel.Builder modelBuilder,
+      ProcessingEnvironment env,
+      String annotationType) {
+    super(modelBuilder, env, annotationType);
+  }
+
+  @Override
+  public void init(Element elem, Element p) {
+    super.init(elem, p);
+
+    do {
+      imp = Helpers.getImplementsMirror(p, types, implementsType);
+
+      // if not found, search on superclasses too...
+      if (imp == null) {
+        TypeMirror superclass = ((TypeElement) p).getSuperclass();
+        p = superclass == null ? null : types.asElement(superclass);
+      } else {
+        break;
+      }
+    } while (p != null);
+
+    if (imp == null) {
+      error('@' + annotationType.getSimpleName().toString() + " without @Implements");
+    }
+  }
+  
+  @Override
+  final public Void visitVariable(VariableElement elem, Element parent) {
+    return visitVariable(elem, Helpers.getAnnotationTypeMirrorValue(parent));
+  }
+  
+  public Void visitVariable(VariableElement elem, TypeElement parent) {
+    return null;
+  }
+
+  @Override
+  final public Void visitExecutable(ExecutableElement elem, Element parent) {
+    return visitExecutable(elem, Helpers.getAnnotationTypeMirrorValue(parent));
+  }
+
+  public Void visitExecutable(ExecutableElement elem, TypeElement parent) {
+    return null;
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/ImplementationValidator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/ImplementationValidator.java
new file mode 100644
index 0000000..cec4b22
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/ImplementationValidator.java
@@ -0,0 +1,41 @@
+package org.robolectric.annotation.processing.validator;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.tools.Diagnostic.Kind;
+import org.robolectric.annotation.processing.RobolectricModel;
+
+/**
+ * Validator that checks usages of {@link org.robolectric.annotation.Implementation}.
+ */
+public class ImplementationValidator extends FoundOnImplementsValidator {
+  public static final ImmutableSet<String> METHODS_ALLOWED_TO_BE_PUBLIC =
+      ImmutableSet.of(
+          "toString",
+          "hashCode",
+          "equals"
+      );
+
+  public ImplementationValidator(RobolectricModel.Builder modelBuilder, ProcessingEnvironment env) {
+    super(modelBuilder, env, "org.robolectric.annotation.Implementation");
+  }
+
+  @Override
+  public Void visitExecutable(ExecutableElement elem, TypeElement parent) {
+    Set<Modifier> modifiers = elem.getModifiers();
+    if (!METHODS_ALLOWED_TO_BE_PUBLIC.contains(elem.getSimpleName().toString())) {
+      if (!modifiers.contains(Modifier.PUBLIC) && !modifiers.contains(Modifier.PROTECTED)) {
+        message(
+            Kind.ERROR,
+            "@Implementation methods should be protected (preferred) or public (deprecated)");
+      }
+    }
+
+    // TODO: Check that it has the right signature
+    return null;
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/ImplementsValidator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/ImplementsValidator.java
new file mode 100644
index 0000000..e1186b9
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/ImplementsValidator.java
@@ -0,0 +1,422 @@
+package org.robolectric.annotation.processing.validator;
+
+import static org.robolectric.annotation.processing.validator.ImplementationValidator.METHODS_ALLOWED_TO_BE_PUBLIC;
+
+import com.google.auto.common.AnnotationValues;
+import com.google.auto.common.MoreElements;
+import com.sun.source.tree.ImportTree;
+import com.sun.source.util.Trees;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.TreeSet;
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.AnnotationValue;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.TypeParameterElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.util.ElementFilter;
+import javax.lang.model.util.Elements;
+import javax.tools.Diagnostic.Kind;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.processing.DocumentedMethod;
+import org.robolectric.annotation.processing.Helpers;
+import org.robolectric.annotation.processing.RobolectricModel;
+
+/**
+ * Validator that checks usages of {@link org.robolectric.annotation.Implements}.
+ */
+public class ImplementsValidator extends Validator {
+
+  public static final String IMPLEMENTS_CLASS = "org.robolectric.annotation.Implements";
+  public static final int MAX_SUPPORTED_ANDROID_SDK = 10000; // Now == Build.VERSION_CODES.O
+
+  public static final String STATIC_INITIALIZER_METHOD_NAME = "__staticInitializer__";
+  public static final String CONSTRUCTOR_METHOD_NAME = "__constructor__";
+
+  private final ProcessingEnvironment env;
+  private final SdkCheckMode sdkCheckMode;
+  private final SdkStore sdkStore;
+
+  /**
+   * Supported modes for validation of {@link Implementation} methods against SDKs.
+   */
+  public enum SdkCheckMode {
+    OFF,
+    WARN,
+    ERROR
+  }
+
+  public ImplementsValidator(RobolectricModel.Builder modelBuilder, ProcessingEnvironment env,
+      SdkCheckMode sdkCheckMode, SdkStore sdkStore) {
+    super(modelBuilder, env, IMPLEMENTS_CLASS);
+
+    this.env = env;
+    this.sdkCheckMode = sdkCheckMode;
+    this.sdkStore = sdkStore;
+  }
+
+  private TypeElement getClassNameTypeElement(AnnotationValue cv) {
+    String className = Helpers.getAnnotationStringValue(cv);
+    return elements.getTypeElement(className.replace('$', '.'));
+  }
+
+  @Override
+  public Void visitType(TypeElement shadowType, Element parent) {
+    captureJavadoc(shadowType);
+
+    // inner class shadows must be static
+    if (shadowType.getEnclosingElement().getKind() == ElementKind.CLASS
+        && !shadowType.getModifiers().contains(Modifier.STATIC)) {
+
+      error("inner shadow classes must be static");
+    }
+
+    // Don't import nested classes because some of them have the same name.
+    AnnotationMirror am = getCurrentAnnotation();
+    AnnotationValue av = Helpers.getAnnotationTypeMirrorValue(am, "value");
+    AnnotationValue cv = Helpers.getAnnotationTypeMirrorValue(am, "className");
+
+    AnnotationValue minSdkVal = Helpers.getAnnotationTypeMirrorValue(am, "minSdk");
+    int minSdk = minSdkVal == null ? -1 : Helpers.getAnnotationIntValue(minSdkVal);
+    AnnotationValue maxSdkVal = Helpers.getAnnotationTypeMirrorValue(am, "maxSdk");
+    int maxSdk = maxSdkVal == null ? -1 : Helpers.getAnnotationIntValue(maxSdkVal);
+
+    AnnotationValue shadowPickerValue =
+        Helpers.getAnnotationTypeMirrorValue(am, "shadowPicker");
+
+    TypeElement shadowPickerTypeElement =
+        shadowPickerValue == null
+            ? null
+            : (TypeElement)
+                types.asElement(Helpers.getAnnotationTypeMirrorValue(shadowPickerValue));
+
+    // This shadow doesn't apply to the current SDK. todo: check each SDK.
+    if (maxSdk != -1 && maxSdk < MAX_SUPPORTED_ANDROID_SDK) {
+      addShadowNotInSdk(shadowType, av, cv, shadowPickerTypeElement);
+      return null;
+    }
+
+    TypeElement actualType = null;
+    if (av == null) {
+      if (cv == null) {
+        error("@Implements: must specify <value> or <className>");
+        return null;
+      }
+      actualType = getClassNameTypeElement(cv);
+
+      if (actualType == null
+          && !suppressWarnings(shadowType, "robolectric.internal.IgnoreMissingClass")) {
+        error("@Implements: could not resolve class <" + AnnotationValues.toString(cv) + '>', cv);
+        return null;
+      }
+    } else {
+      TypeMirror value = Helpers.getAnnotationTypeMirrorValue(av);
+      if (value == null) {
+        return null;
+      }
+      if (cv != null) {
+        error("@Implements: cannot specify both <value> and <className> attributes");
+      } else {
+        actualType = Helpers.getAnnotationTypeMirrorValue(types.asElement(value));
+      }
+    }
+    if (actualType == null) {
+      addShadowNotInSdk(shadowType, av, cv, shadowPickerTypeElement);
+      return null;
+    }
+    final List<? extends TypeParameterElement> typeTP = actualType.getTypeParameters();
+    final List<? extends TypeParameterElement> elemTP = shadowType.getTypeParameters();
+    if (!helpers.isSameParameterList(typeTP, elemTP)) {
+      StringBuilder message = new StringBuilder();
+      if (elemTP.isEmpty()) {
+        message.append("Shadow type is missing type parameters, expected <");
+        helpers.appendParameterList(message, actualType.getTypeParameters());
+        message.append('>');
+      } else if (typeTP.isEmpty()) {
+        message.append("Shadow type has type parameters but real type does not");
+      } else {
+        message.append(
+            "Shadow type must have same type parameters as its real counterpart: expected <");
+        helpers.appendParameterList(message, actualType.getTypeParameters());
+        message.append(">, was <");
+        helpers.appendParameterList(message, shadowType.getTypeParameters());
+        message.append('>');
+      }
+      messager.printMessage(Kind.ERROR, message, shadowType);
+      return null;
+    }
+
+    AnnotationValue looseSignaturesAttr =
+        Helpers.getAnnotationTypeMirrorValue(am, "looseSignatures");
+    boolean looseSignatures =
+        looseSignaturesAttr != null && (Boolean) looseSignaturesAttr.getValue();
+    validateShadowMethods(actualType, shadowType, minSdk, maxSdk, looseSignatures);
+
+    modelBuilder.addShadowType(shadowType, actualType, shadowPickerTypeElement);
+    return null;
+  }
+
+  private void addShadowNotInSdk(
+      TypeElement shadowType,
+      AnnotationValue valueAttr,
+      AnnotationValue classNameAttr,
+      TypeElement shadowPickerTypeElement) {
+
+    String sdkClassName;
+    if (valueAttr == null) {
+      sdkClassName = Helpers.getAnnotationStringValue(classNameAttr).replace('$', '.');
+    } else {
+      sdkClassName = Helpers.getAnnotationTypeMirrorValue(valueAttr).toString();
+    }
+
+    // there's no such type at the current SDK level, so just use strings...
+    // getQualifiedName() uses Outer.Inner and we want Outer$Inner, so:
+    String name = getClassFQName(shadowType);
+    modelBuilder.addExtraShadow(sdkClassName, name);
+    if (shadowPickerTypeElement != null) {
+      modelBuilder.addExtraShadowPicker(sdkClassName, shadowPickerTypeElement);
+    }
+  }
+
+  private static boolean suppressWarnings(Element element, String warningName) {
+    SuppressWarnings[] suppressWarnings = element.getAnnotationsByType(SuppressWarnings.class);
+    for (SuppressWarnings suppression : suppressWarnings) {
+      for (String name : suppression.value()) {
+        if (warningName.equals(name)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  static String getClassFQName(TypeElement elem) {
+    StringBuilder name = new StringBuilder();
+    while (isClassy(elem.getEnclosingElement().getKind())) {
+      name.insert(0, "$" + elem.getSimpleName());
+      elem = (TypeElement) elem.getEnclosingElement();
+    }
+    name.insert(0, elem.getQualifiedName());
+    return name.toString();
+  }
+
+  private static boolean isClassy(ElementKind kind) {
+    return kind == ElementKind.CLASS || kind == ElementKind.INTERFACE;
+  }
+
+  private void validateShadowMethods(TypeElement sdkClassElem, TypeElement shadowClassElem,
+      int classMinSdk, int classMaxSdk, boolean looseSignatures) {
+    for (Element memberElement : ElementFilter.methodsIn(shadowClassElem.getEnclosedElements())) {
+      ExecutableElement methodElement = MoreElements.asExecutable(memberElement);
+
+      // equals, hashCode, and toString are exempt, because of Robolectric's weird special behavior
+      if (METHODS_ALLOWED_TO_BE_PUBLIC.contains(methodElement.getSimpleName().toString())) {
+        continue;
+      }
+
+      verifySdkMethod(sdkClassElem, methodElement, classMinSdk, classMaxSdk, looseSignatures);
+      if (shadowClassElem.getQualifiedName().toString().startsWith("org.robolectric")
+          && !methodElement.getModifiers().contains(Modifier.ABSTRACT)) {
+        checkForMissingImplementationAnnotation(
+            sdkClassElem, methodElement, classMinSdk, classMaxSdk, looseSignatures);
+      }
+
+      String methodName = methodElement.getSimpleName().toString();
+      if (methodName.equals(CONSTRUCTOR_METHOD_NAME)
+          || methodName.equals(STATIC_INITIALIZER_METHOD_NAME)) {
+        Implementation implementation = memberElement.getAnnotation(Implementation.class);
+        if (implementation == null) {
+          messager.printMessage(
+              Kind.ERROR, "Shadow methods must be annotated @Implementation", methodElement);
+        }
+      }
+    }
+  }
+
+  private void verifySdkMethod(TypeElement sdkClassElem, ExecutableElement methodElement,
+      int classMinSdk, int classMaxSdk, boolean looseSignatures) {
+    if (sdkCheckMode == SdkCheckMode.OFF) {
+      return;
+    }
+
+    Implementation implementation = methodElement.getAnnotation(Implementation.class);
+    if (implementation != null) {
+      Kind kind = sdkCheckMode == SdkCheckMode.WARN
+          ? Kind.WARNING
+          : Kind.ERROR;
+      Problems problems = new Problems(kind);
+
+      for (SdkStore.Sdk sdk : sdkStore.sdksMatching(implementation, classMinSdk, classMaxSdk)) {
+        String problem = sdk.verifyMethod(sdkClassElem, methodElement, looseSignatures);
+        if (problem != null) {
+          problems.add(problem, sdk.sdkInt);
+        }
+      }
+
+      if (problems.any()) {
+        problems.recount(messager, methodElement);
+      }
+    }
+  }
+
+  /**
+   * For the given {@link ExecutableElement}, check to see if it should have a {@link
+   * Implementation} tag but is missing one
+   */
+  private void checkForMissingImplementationAnnotation(
+      TypeElement sdkClassElem,
+      ExecutableElement methodElement,
+      int classMinSdk,
+      int classMaxSdk,
+      boolean looseSignatures) {
+
+    if (sdkCheckMode == SdkCheckMode.OFF) {
+      return;
+    }
+
+    Implementation implementation = methodElement.getAnnotation(Implementation.class);
+    if (implementation == null) {
+      Kind kind = sdkCheckMode == SdkCheckMode.WARN ? Kind.WARNING : Kind.ERROR;
+      Problems problems = new Problems(kind);
+
+      for (SdkStore.Sdk sdk : sdkStore.sdksMatching(implementation, classMinSdk, classMaxSdk)) {
+        String problem = sdk.verifyMethod(sdkClassElem, methodElement, looseSignatures);
+        if (problem == null) {
+          problems.add(
+              "Missing @Implementation on method " + methodElement.getSimpleName(), sdk.sdkInt);
+        }
+      }
+
+      if (problems.any()) {
+        problems.recount(messager, methodElement);
+      }
+    }
+  }
+
+  private void captureJavadoc(TypeElement elem) {
+    List<String> imports = new ArrayList<>();
+    try {
+      List<? extends ImportTree> importLines =
+          Trees.instance(env).getPath(elem).getCompilationUnit().getImports();
+      for (ImportTree importLine : importLines) {
+        imports.add(importLine.getQualifiedIdentifier().toString());
+      }
+    } catch (IllegalArgumentException e) {
+      // Trees relies on javac APIs and is not available in all annotation processing
+      // implementations
+    }
+
+    List<TypeElement> enclosedTypes = ElementFilter.typesIn(elem.getEnclosedElements());
+    for (TypeElement enclosedType : enclosedTypes) {
+      imports.add(enclosedType.getQualifiedName().toString());
+    }
+
+    Elements elementUtils = env.getElementUtils();
+    modelBuilder.documentType(elem, elementUtils.getDocComment(elem), imports);
+
+    for (Element memberElement : ElementFilter.methodsIn(elem.getEnclosedElements())) {
+      try {
+        ExecutableElement methodElement = (ExecutableElement) memberElement;
+        Implementation implementation = memberElement.getAnnotation(Implementation.class);
+
+        DocumentedMethod documentedMethod = new DocumentedMethod(memberElement.toString());
+        for (Modifier modifier : memberElement.getModifiers()) {
+          documentedMethod.modifiers.add(modifier.toString());
+        }
+        documentedMethod.isImplementation = implementation != null;
+        if (implementation != null) {
+          documentedMethod.minSdk = sdkOrNull(implementation.minSdk());
+          documentedMethod.maxSdk = sdkOrNull(implementation.maxSdk());
+        }
+        for (VariableElement variableElement : methodElement.getParameters()) {
+          documentedMethod.params.add(variableElement.toString());
+        }
+        documentedMethod.returnType = methodElement.getReturnType().toString();
+        for (TypeMirror typeMirror : methodElement.getThrownTypes()) {
+          documentedMethod.exceptions.add(typeMirror.toString());
+        }
+        String docMd = elementUtils.getDocComment(methodElement);
+        if (docMd != null) {
+          documentedMethod.setDocumentation(docMd);
+        }
+
+        modelBuilder.documentMethod(elem, documentedMethod);
+      } catch (Exception e) {
+        throw new RuntimeException(
+            "failed to capture javadoc for " + elem + "." + memberElement, e);
+      }
+    }
+  }
+
+  private Integer sdkOrNull(int sdk) {
+    return sdk == -1 ? null : sdk;
+  }
+
+  private static class Problems {
+    private final Kind kind;
+    private final Map<String, Set<Integer>> problems = new HashMap<>();
+
+    public Problems(Kind kind) {
+      this.kind = kind;
+    }
+
+    void add(String problem, int sdkInt) {
+      Set<Integer> sdks = problems.get(problem);
+      if (sdks == null) {
+        problems.put(problem, sdks = new TreeSet<>());
+      }
+      sdks.add(sdkInt);
+    }
+
+    boolean any() {
+      return !problems.isEmpty();
+    }
+
+    void recount(Messager messager, Element element) {
+      for (Entry<String, Set<Integer>> e : problems.entrySet()) {
+        String problem = e.getKey();
+        Set<Integer> sdks = e.getValue();
+
+        StringBuilder buf = new StringBuilder();
+        buf.append(problem)
+            .append(" for ")
+            .append(sdks.size() == 1 ? "SDK " : "SDKs ");
+
+        Integer previousSdk = null;
+        Integer lastSdk = null;
+        for (Integer sdk : sdks) {
+          if (previousSdk == null) {
+            buf.append(sdk);
+          } else {
+            if (previousSdk != sdk - 1) {
+              buf.append("-").append(previousSdk);
+              buf.append("/").append(sdk);
+              lastSdk = null;
+            } else {
+              lastSdk = sdk;
+            }
+          }
+
+          previousSdk = sdk;
+        }
+
+        if (lastSdk != null) {
+          buf.append("-").append(lastSdk);
+        }
+
+        messager.printMessage(kind, buf.toString(), element);
+      }
+    }
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/RealObjectValidator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/RealObjectValidator.java
new file mode 100644
index 0000000..49bcc30
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/RealObjectValidator.java
@@ -0,0 +1,79 @@
+package org.robolectric.annotation.processing.validator;
+
+import java.util.List;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.TypeParameterElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.type.TypeVisitor;
+import javax.lang.model.util.SimpleTypeVisitor6;
+import javax.tools.Diagnostic.Kind;
+import org.robolectric.annotation.processing.RobolectricModel;
+
+/**
+ * Validator that checks usages of {@link org.robolectric.annotation.RealObject}.
+ */
+public class RealObjectValidator extends FoundOnImplementsValidator {
+
+  public RealObjectValidator(RobolectricModel.Builder modelBuilder, ProcessingEnvironment env) {
+    super(modelBuilder, env, "org.robolectric.annotation.RealObject");
+  }
+
+  public static String join(List<?> params) {
+    StringBuilder retval = new StringBuilder();
+    boolean comma = false;
+    for (Object p : params) {
+      if (comma) {
+        retval.append(',');
+      }
+      comma = true;
+      retval.append(p);
+    }
+    return retval.toString();
+  }
+
+  TypeVisitor<Void, VariableElement> typeVisitor =
+      new SimpleTypeVisitor6<Void, VariableElement>() {
+        @Override
+        public Void visitDeclared(DeclaredType t, VariableElement v) {
+          List<? extends TypeMirror> typeParams = t.getTypeArguments();
+          List<? extends TypeParameterElement> parentTypeParams = parent.getTypeParameters();
+
+          if (!parentTypeParams.isEmpty() && typeParams.isEmpty()) {
+            messager.printMessage(Kind.ERROR, "@RealObject is missing type parameters", v);
+          } else {
+            String typeString = join(typeParams);
+            String parentString = join(parentTypeParams);
+            if (!typeString.equals(parentString)) {
+              messager.printMessage(
+                  Kind.ERROR,
+                  "Parameter type mismatch: expecting <"
+                      + parentString
+                      + ">, was <"
+                      + typeString
+                      + '>',
+                  v);
+            }
+          }
+          return null;
+        }
+      };
+
+  TypeElement parent;
+  
+  @Override
+  public Void visitVariable(VariableElement elem, TypeElement parent) {
+    this.parent = parent;
+    TypeMirror impClass = helpers.getImplementedClass(imp);
+    if (impClass != null) {
+      TypeMirror elemType = elem.asType();
+      if (!types.isAssignable(impClass, elemType)) {
+        error("@RealObject with type <" + elemType + ">; expected <" + impClass + '>');
+      }
+      typeVisitor.visit(elemType, elem);
+    }
+    return null;
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java
new file mode 100644
index 0000000..d409f83
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java
@@ -0,0 +1,44 @@
+package org.robolectric.annotation.processing.validator;
+
+import java.util.List;
+import java.util.Set;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import org.robolectric.annotation.processing.RobolectricModel;
+
+/**
+ * Validator that checks usages of {@link org.robolectric.annotation.Resetter}.
+ */
+public class ResetterValidator extends FoundOnImplementsValidator {
+  public ResetterValidator(RobolectricModel.Builder modelBuilder, ProcessingEnvironment env) {
+    super(modelBuilder, env, "org.robolectric.annotation.Resetter");
+  }
+
+  @Override
+  public Void visitExecutable(ExecutableElement elem, TypeElement parent) {
+   if (imp != null) {
+      final Set<Modifier> modifiers = elem.getModifiers();
+      boolean error = false;
+      if (!modifiers.contains(Modifier.STATIC)) {
+        error("@Resetter methods must be static");
+        error = true;
+      }
+      if (!modifiers.contains(Modifier.PUBLIC)) {
+        error("@Resetter methods must be public");
+        error = true;
+      }
+      List<? extends VariableElement> params = elem.getParameters();
+      if (params != null && !params.isEmpty()) {
+        error("@Resetter methods must not have parameters");
+        error = true;
+      }
+      if (!error) {
+        modelBuilder.addResetter(parent, elem);
+      }
+    }
+    return null;
+  }
+}
\ No newline at end of file
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/SdkStore.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/SdkStore.java
new file mode 100644
index 0000000..05822d7
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/SdkStore.java
@@ -0,0 +1,468 @@
+package org.robolectric.annotation.processing.validator;
+
+import static org.robolectric.annotation.Implementation.DEFAULT_SDK;
+import static org.robolectric.annotation.processing.validator.ImplementsValidator.CONSTRUCTOR_METHOD_NAME;
+import static org.robolectric.annotation.processing.validator.ImplementsValidator.STATIC_INITIALIZER_METHOD_NAME;
+import static org.robolectric.annotation.processing.validator.ImplementsValidator.getClassFQName;
+
+import com.google.common.collect.ImmutableList;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.ArrayType;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.type.TypeVariable;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.robolectric.annotation.Implementation;
+
+/** Encapsulates a collection of Android framework jars. */
+public class SdkStore {
+
+  private final Set<Sdk> sdks = new TreeSet<>();
+  private boolean loaded = false;
+  private final String sdksFile;
+
+  public SdkStore(String sdksFile) {
+    this.sdksFile = sdksFile;
+  }
+
+  List<Sdk> sdksMatching(Implementation implementation, int classMinSdk, int classMaxSdk) {
+    loadSdksOnce();
+
+    int minSdk = implementation == null ? DEFAULT_SDK : implementation.minSdk();
+    if (minSdk == DEFAULT_SDK) {
+      minSdk = 0;
+    }
+    if (classMinSdk > minSdk) {
+      minSdk = classMinSdk;
+    }
+
+    int maxSdk = implementation == null ? -1 : implementation.maxSdk();
+    if (maxSdk == -1) {
+      maxSdk = Integer.MAX_VALUE;
+    }
+    if (classMaxSdk != -1 && classMaxSdk < maxSdk) {
+      maxSdk = classMaxSdk;
+    }
+
+    List<Sdk> matchingSdks = new ArrayList<>();
+    for (Sdk sdk : sdks) {
+      Integer sdkInt = sdk.sdkInt;
+      if (sdkInt >= minSdk && sdkInt <= maxSdk) {
+        matchingSdks.add(sdk);
+      }
+    }
+    return matchingSdks;
+  }
+
+  private synchronized void loadSdksOnce() {
+    if (!loaded) {
+      sdks.addAll(loadFromSdksFile(sdksFile));
+      loaded = true;
+    }
+  }
+
+  private static ImmutableList<Sdk> loadFromSdksFile(String fileName) {
+    if (fileName == null || Files.notExists(Paths.get(fileName))) {
+      return ImmutableList.of();
+    }
+
+    try (InputStream resIn = new FileInputStream(fileName)) {
+      if (resIn == null) {
+        throw new RuntimeException("no such file " + fileName);
+      }
+
+      BufferedReader in =
+          new BufferedReader(new InputStreamReader(resIn, Charset.defaultCharset()));
+      List<Sdk> sdks = new ArrayList<>();
+      String line;
+      while ((line = in.readLine()) != null) {
+        if (!line.startsWith("#")) {
+          sdks.add(new Sdk(line));
+        }
+      }
+      return ImmutableList.copyOf(sdks);
+    } catch (IOException e) {
+      throw new RuntimeException("failed reading " + fileName, e);
+    }
+  }
+
+  private static String canonicalize(TypeMirror typeMirror) {
+    if (typeMirror instanceof TypeVariable) {
+      return ((TypeVariable) typeMirror).getUpperBound().toString();
+    } else if (typeMirror instanceof ArrayType) {
+      return canonicalize(((ArrayType) typeMirror).getComponentType()) + "[]";
+    } else {
+      return typeMirror.toString();
+    }
+  }
+
+  private static String typeWithoutGenerics(String paramType) {
+    return paramType.replaceAll("<.*", "");
+  }
+
+  static class Sdk implements Comparable<Sdk> {
+    private static final ClassInfo NULL_CLASS_INFO = new ClassInfo();
+
+    private final String path;
+    private final JarFile jarFile;
+    final int sdkInt;
+    private final Map<String, ClassInfo> classInfos = new HashMap<>();
+    private static File tempDir;
+
+    Sdk(String path) {
+      this.path = path;
+      this.jarFile = ensureJar();
+      this.sdkInt = readSdkInt();
+    }
+
+    /**
+     * Matches an {@code @Implementation} method against the framework method for this SDK.
+     *
+     * @param sdkClassElem the framework class being shadowed
+     * @param methodElement the {@code @Implementation} method declaration to check
+     * @param looseSignatures if true, also match any framework method with the same class, name,
+     *     return type, and arity of parameters.
+     * @return a string describing any problems with this method, or null if it checks out.
+     */
+    public String verifyMethod(
+        TypeElement sdkClassElem, ExecutableElement methodElement, boolean looseSignatures) {
+      String className = getClassFQName(sdkClassElem);
+      ClassInfo classInfo = getClassInfo(className);
+
+      if (classInfo == null) {
+        return "No such class " + className;
+      }
+
+      MethodExtraInfo sdkMethod = classInfo.findMethod(methodElement, looseSignatures);
+      if (sdkMethod == null) {
+        return "No such method in " + className;
+      }
+
+      MethodExtraInfo implMethod = new MethodExtraInfo(methodElement);
+      if (!sdkMethod.equals(implMethod)
+          && !suppressWarnings(methodElement, "robolectric.ShadowReturnTypeMismatch")) {
+        if (implMethod.isStatic != sdkMethod.isStatic) {
+          return "@Implementation for " + methodElement.getSimpleName()
+              + " is " + (implMethod.isStatic ? "static" : "not static")
+              + " unlike the SDK method";
+        }
+        if (!implMethod.returnType.equals(sdkMethod.returnType)) {
+          if (
+              (looseSignatures && typeIsOkForLooseSignatures(implMethod, sdkMethod))
+                  || (looseSignatures && implMethod.returnType.equals("java.lang.Object[]"))
+                  // Number is allowed for int or long return types
+                  || typeIsNumeric(sdkMethod, implMethod)) {
+            return null;
+          } else {
+            return "@Implementation for " + methodElement.getSimpleName()
+                + " has a return type of " + implMethod.returnType
+                + ", not " + sdkMethod.returnType + " as in the SDK method";
+          }
+        }
+      }
+
+      return null;
+    }
+
+    private static boolean suppressWarnings(ExecutableElement methodElement, String warningName) {
+      SuppressWarnings[] suppressWarnings =
+          methodElement.getAnnotationsByType(SuppressWarnings.class);
+      for (SuppressWarnings suppression : suppressWarnings) {
+        for (String name : suppression.value()) {
+          if (warningName.equals(name)) {
+            return true;
+          }
+        }
+      }
+      return false;
+    }
+
+    private static boolean typeIsNumeric(MethodExtraInfo sdkMethod, MethodExtraInfo implMethod) {
+      return implMethod.returnType.equals("java.lang.Number")
+      && isNumericType(sdkMethod.returnType);
+    }
+
+    private static boolean typeIsOkForLooseSignatures(
+        MethodExtraInfo implMethod, MethodExtraInfo sdkMethod) {
+      return
+          // loose signatures allow a return type of Object...
+          implMethod.returnType.equals("java.lang.Object")
+              // or Object[] for arrays...
+              || (implMethod.returnType.equals("java.lang.Object[]")
+                  && sdkMethod.returnType.endsWith("[]"));
+    }
+
+    private static boolean isNumericType(String type) {
+      return type.equals("int") || type.equals("long");
+    }
+
+    /**
+     * Load and analyze bytecode for the specified class, with caching.
+     *
+     * @param name the name of the class to analyze
+     * @return information about the methods in the specified class
+     */
+    private synchronized ClassInfo getClassInfo(String name) {
+      ClassInfo classInfo = classInfos.get(name);
+      if (classInfo == null) {
+        ClassNode classNode = loadClassNode(name);
+
+        if (classNode == null) {
+          classInfos.put(name, NULL_CLASS_INFO);
+        } else {
+          classInfo = new ClassInfo(classNode);
+          classInfos.put(name, classInfo);
+        }
+      }
+
+      return classInfo == NULL_CLASS_INFO ? null : classInfo;
+    }
+
+    /**
+     * Determine the API level for this SDK jar by inspecting its {@code build.prop} file.
+     *
+     * <p>If the {@code ro.build.version.codename} value isn't {@code REL}, this is an unreleased
+     * SDK, which is represented as 10000 (see {@link
+     * android.os.Build.VERSION_CODES#CUR_DEVELOPMENT}.
+     *
+     * @return the API level, or 10000
+     */
+    private int readSdkInt() {
+      Properties properties = new Properties();
+      try (InputStream inputStream = jarFile.getInputStream(jarFile.getJarEntry("build.prop"))) {
+        properties.load(inputStream);
+      } catch (IOException e) {
+        throw new RuntimeException("failed to read build.prop from " + path);
+      }
+      int sdkInt = Integer.parseInt(properties.getProperty("ro.build.version.sdk"));
+      String codename = properties.getProperty("ro.build.version.codename");
+      if (!"REL".equals(codename)) {
+        sdkInt = 10000;
+      }
+
+      return sdkInt;
+    }
+
+    private JarFile ensureJar() {
+      try {
+        if (path.startsWith("classpath:")) {
+          return new JarFile(copyResourceToFile(URI.create(path).getSchemeSpecificPart()));
+        } else {
+          return new JarFile(path);
+        }
+
+      } catch (IOException e) {
+        throw new RuntimeException("failed to open SDK " + sdkInt + " at " + path, e);
+      }
+    }
+
+    private static File copyResourceToFile(String resourcePath) throws IOException {
+      if (tempDir == null){
+        File tempFile = File.createTempFile("prefix", "suffix");
+        tempFile.deleteOnExit();
+        tempDir = tempFile.getParentFile();
+      }
+      InputStream jarIn = SdkStore.class.getClassLoader().getResourceAsStream(resourcePath);
+      if (jarIn == null) {
+        throw new RuntimeException("SDK " + resourcePath + " not found");
+      }
+      File outFile = new File(tempDir, new File(resourcePath).getName());
+      outFile.deleteOnExit();
+      try (FileOutputStream jarOut = new FileOutputStream(outFile)) {
+        byte[] buffer = new byte[4096];
+        int len;
+        while ((len = jarIn.read(buffer)) != -1) {
+          jarOut.write(buffer, 0, len);
+        }
+      }
+
+      return outFile;
+    }
+
+    private ClassNode loadClassNode(String name) {
+      String classFileName = name.replace('.', '/') + ".class";
+      ZipEntry entry = jarFile.getEntry(classFileName);
+      if (entry == null) {
+        return null;
+      }
+      try (InputStream inputStream = jarFile.getInputStream(entry)) {
+        ClassReader classReader = new ClassReader(inputStream);
+        ClassNode classNode = new ClassNode();
+        classReader.accept(classNode,
+            ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+        return classNode;
+      } catch (IOException e) {
+        throw new RuntimeException("failed to analyze " + classFileName + " in " + path, e);
+      }
+    }
+
+    @Override
+    public int compareTo(Sdk sdk) {
+      return sdk.sdkInt - sdkInt;
+    }
+  }
+
+  static class ClassInfo {
+    private final Map<MethodInfo, MethodExtraInfo> methods = new HashMap<>();
+    private final Map<MethodInfo, MethodExtraInfo> erasedParamTypesMethods = new HashMap<>();
+
+    private ClassInfo() {
+    }
+
+    public ClassInfo(ClassNode classNode) {
+      for (Object aMethod : classNode.methods) {
+        MethodNode method = ((MethodNode) aMethod);
+        MethodInfo methodInfo = new MethodInfo(method);
+        MethodExtraInfo methodExtraInfo = new MethodExtraInfo(method);
+        methods.put(methodInfo, methodExtraInfo);
+        erasedParamTypesMethods.put(methodInfo.erase(), methodExtraInfo);
+      }
+    }
+
+    MethodExtraInfo findMethod(ExecutableElement methodElement, boolean looseSignatures) {
+      MethodInfo methodInfo = new MethodInfo(methodElement);
+
+      MethodExtraInfo methodExtraInfo = methods.get(methodInfo);
+      if (looseSignatures && methodExtraInfo == null) {
+        methodExtraInfo = erasedParamTypesMethods.get(methodInfo);
+      }
+      return methodExtraInfo;
+    }
+  }
+
+  static class MethodInfo {
+    private final String name;
+    private final List<String> paramTypes = new ArrayList<>();
+
+    /** Create a MethodInfo from ASM in-memory representation (an Android framework method). */
+    public MethodInfo(MethodNode method) {
+      this.name = method.name;
+      for (Type type : Type.getArgumentTypes(method.desc)) {
+        paramTypes.add(normalize(type));
+      }
+    }
+
+    /** Create a MethodInfo with all Object params (for looseSignatures=true). */
+    public MethodInfo(String name, int size) {
+      this.name = name;
+      for (int i = 0; i < size; i++) {
+        paramTypes.add("java.lang.Object");
+      }
+    }
+
+    /** Create a MethodInfo from AST (an @Implementation method in a shadow class). */
+    public MethodInfo(ExecutableElement methodElement) {
+      this.name = cleanMethodName(methodElement);
+
+      for (VariableElement variableElement : methodElement.getParameters()) {
+        TypeMirror varTypeMirror = variableElement.asType();
+        String paramType = canonicalize(varTypeMirror);
+        String paramTypeWithoutGenerics = typeWithoutGenerics(paramType);
+        paramTypes.add(paramTypeWithoutGenerics);
+      }
+    }
+
+    private static String cleanMethodName(ExecutableElement methodElement) {
+      String name = methodElement.getSimpleName().toString();
+      if (CONSTRUCTOR_METHOD_NAME.equals(name)) {
+        return "<init>";
+      } else if (STATIC_INITIALIZER_METHOD_NAME.equals(name)) {
+        return "<clinit>";
+      } else {
+        return name;
+      }
+    }
+
+    public MethodInfo erase() {
+      return new MethodInfo(name, paramTypes.size());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof MethodInfo)) {
+        return false;
+      }
+      MethodInfo that = (MethodInfo) o;
+      return Objects.equals(name, that.name)
+          && Objects.equals(paramTypes, that.paramTypes);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(name, paramTypes);
+    }
+    @Override
+    public String toString() {
+      return "MethodInfo{"
+          + "name='" + name + '\''
+          + ", paramTypes=" + paramTypes
+          + '}';
+    }
+  }
+
+  private static String normalize(Type type) {
+    return type.getClassName().replace('$', '.');
+  }
+
+  static class MethodExtraInfo {
+    private final boolean isStatic;
+    private final String returnType;
+
+    public MethodExtraInfo(MethodNode method) {
+      this.isStatic = (method.access & Opcodes.ACC_STATIC) != 0;
+      this.returnType = typeWithoutGenerics(normalize(Type.getReturnType(method.desc)));
+    }
+
+    public MethodExtraInfo(ExecutableElement methodElement) {
+      this.isStatic = methodElement.getModifiers().contains(Modifier.STATIC);
+      this.returnType = typeWithoutGenerics(canonicalize(methodElement.getReturnType()));
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof MethodExtraInfo)) {
+        return false;
+      }
+      MethodExtraInfo that = (MethodExtraInfo) o;
+      return isStatic == that.isStatic && Objects.equals(returnType, that.returnType);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(isStatic, returnType);
+    }
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/Validator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/Validator.java
new file mode 100644
index 0000000..6b204dc
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/Validator.java
@@ -0,0 +1,146 @@
+package org.robolectric.annotation.processing.validator;
+
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.AnnotationValue;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementVisitor;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.PackageElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.TypeParameterElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.util.AbstractElementVisitor6;
+import javax.lang.model.util.Elements;
+import javax.lang.model.util.Types;
+import javax.tools.Diagnostic.Kind;
+import org.robolectric.annotation.processing.Helpers;
+import org.robolectric.annotation.processing.RobolectricModel;
+
+/**
+ * Base class for validators.
+ */
+public abstract class Validator implements ElementVisitor<Void, Element> {
+  final protected RobolectricModel.Builder modelBuilder;
+  final protected Elements elements;
+  final protected Types types;
+  final protected Messager messager;
+  final protected TypeElement annotationType;
+  final protected Helpers helpers;
+  // This is the easiest way to do it because visit() is final in AbstractEV6
+  final ElementVisitor<Void, Element> visitorAdapter = new AbstractElementVisitor6<Void, Element>() {
+
+    @Override
+    public Void visitPackage(PackageElement e, Element p) {
+      return Validator.this.visitPackage(e, p);
+    }
+
+    @Override
+    public Void visitType(TypeElement e, Element p) {
+      return Validator.this.visitType(e, p);
+    }
+
+    @Override
+    public Void visitVariable(VariableElement e, Element p) {
+      return Validator.this.visitVariable(e, p);
+    }
+
+    @Override
+    public Void visitExecutable(ExecutableElement e, Element p) {
+      return Validator.this.visitExecutable(e, p);
+    }
+
+    @Override
+    public Void visitTypeParameter(TypeParameterElement e, Element p) {
+      return Validator.this.visitTypeParameter(e, p);
+    }
+  };
+  protected Element currentElement;
+  protected AnnotationMirror currentAnnotation;
+
+  public Validator(RobolectricModel.Builder modelBuilder, ProcessingEnvironment env, String annotationType) {
+    this.modelBuilder = modelBuilder;
+    elements = env.getElementUtils();
+    types = env.getTypeUtils();
+    this.helpers = new Helpers(env);
+    messager = env.getMessager();
+    // FIXME: Need to test case where type lookup fails
+    this.annotationType = elements.getTypeElement(annotationType);
+  }
+
+  protected AnnotationMirror getCurrentAnnotation() {
+    if (currentAnnotation == null) {
+      currentAnnotation = Helpers.getAnnotationMirror(types, currentElement, annotationType);
+    }
+    return currentAnnotation;
+  }
+
+  protected void message(Kind severity, String msg, AnnotationValue av) {
+    final AnnotationMirror am = getCurrentAnnotation();
+    messager.printMessage(severity, msg, currentElement, am, av);
+  }
+
+  protected void message(Kind severity, String msg) {
+    final AnnotationMirror am = getCurrentAnnotation();
+    messager.printMessage(severity, msg, currentElement, am);
+  }
+
+  protected void error(String msg) {
+    message(Kind.ERROR, msg);
+  }
+
+  protected void error(String msg, AnnotationValue av) {
+    message(Kind.ERROR, msg, av);
+  }
+
+  public void init(Element e, Element p) {
+    currentElement = e;
+    currentAnnotation = null;
+  }
+
+  public TypeElement getAnnotationType() {
+    return annotationType;
+  }
+
+  @Override
+  public Void visit(Element e, Element p) {
+    init(e, p);
+    return visitorAdapter.visit(e, p);
+  }
+
+  @Override
+  public Void visit(Element e) {
+    return visit(e, null);
+  }
+
+  @Override
+  public Void visitPackage(PackageElement e, Element p) {
+    return null;
+  }
+
+  @Override
+  public Void visitType(TypeElement e, Element p) {
+    return null;
+  }
+
+  @Override
+  public Void visitVariable(VariableElement e, Element p) {
+    return null;
+  }
+
+  @Override
+  public Void visitExecutable(ExecutableElement e, Element p) {
+    return null;
+  }
+
+  @Override
+  public Void visitTypeParameter(TypeParameterElement e, Element p) {
+    return null;
+  }
+
+  @Override
+  public Void visitUnknown(Element e, Element p) {
+    return null;
+  }
+}
diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/package-info.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/package-info.java
new file mode 100644
index 0000000..654fa44
--- /dev/null
+++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Classes used to perform compile-time checking of shadows.
+ */
+package org.robolectric.annotation.processing.validator;
\ No newline at end of file
diff --git a/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor
new file mode 100644
index 0000000..311b2d1
--- /dev/null
+++ b/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor
@@ -0,0 +1 @@
+org.robolectric.annotation.processing.RobolectricProcessor
\ No newline at end of file
diff --git a/processor/src/test/java/com/example/objects/AnyObject.java b/processor/src/test/java/com/example/objects/AnyObject.java
new file mode 100644
index 0000000..c2816a1
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/AnyObject.java
@@ -0,0 +1,4 @@
+package com.example.objects;
+
+public class AnyObject {
+}
diff --git a/processor/src/test/java/com/example/objects/Dummy.java b/processor/src/test/java/com/example/objects/Dummy.java
new file mode 100644
index 0000000..ad065f6
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/Dummy.java
@@ -0,0 +1,4 @@
+package com.example.objects;
+
+public class Dummy {
+}
diff --git a/processor/src/test/java/com/example/objects/OuterDummy.java b/processor/src/test/java/com/example/objects/OuterDummy.java
new file mode 100644
index 0000000..af2e7ca
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/OuterDummy.java
@@ -0,0 +1,7 @@
+package com.example.objects;
+
+public class OuterDummy {
+  @SuppressWarnings("ClassCanBeStatic")
+  public class InnerDummy {
+  }
+}
diff --git a/processor/src/test/java/com/example/objects/OuterDummy2.java b/processor/src/test/java/com/example/objects/OuterDummy2.java
new file mode 100644
index 0000000..a47a536
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/OuterDummy2.java
@@ -0,0 +1,16 @@
+package com.example.objects;
+
+public class OuterDummy2 {
+  @SuppressWarnings("ClassCanBeStatic")
+  protected class InnerProtected {
+  }
+
+  @SuppressWarnings("ClassCanBeStatic")
+  class InnerPackage {
+  }
+
+  @SuppressWarnings(value = {"unused", "ClassCanBeStatic"})
+  private class InnerPrivate {
+    
+  }
+}
diff --git a/processor/src/test/java/com/example/objects/ParameterizedDummy.java b/processor/src/test/java/com/example/objects/ParameterizedDummy.java
new file mode 100644
index 0000000..bdf4c46
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/ParameterizedDummy.java
@@ -0,0 +1,5 @@
+package com.example.objects;
+
+public class ParameterizedDummy<T, N extends Number> {
+
+}
diff --git a/processor/src/test/java/com/example/objects/Private.java b/processor/src/test/java/com/example/objects/Private.java
new file mode 100644
index 0000000..cc0fff6
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/Private.java
@@ -0,0 +1,4 @@
+package com.example.objects;
+
+class Private {
+}
diff --git a/processor/src/test/java/com/example/objects/UniqueDummy.java b/processor/src/test/java/com/example/objects/UniqueDummy.java
new file mode 100644
index 0000000..233ef31
--- /dev/null
+++ b/processor/src/test/java/com/example/objects/UniqueDummy.java
@@ -0,0 +1,12 @@
+package com.example.objects;
+
+public class UniqueDummy {
+
+  @SuppressWarnings("ClassCanBeStatic")
+  public class InnerDummy {
+  }
+
+  @SuppressWarnings("ClassCanBeStatic")
+  public class UniqueInnerDummy {
+  }
+}
diff --git a/processor/src/test/java/com/example/objects2/Dummy.java b/processor/src/test/java/com/example/objects2/Dummy.java
new file mode 100644
index 0000000..416a673
--- /dev/null
+++ b/processor/src/test/java/com/example/objects2/Dummy.java
@@ -0,0 +1,5 @@
+package com.example.objects2;
+
+public class Dummy {
+
+}
diff --git a/processor/src/test/java/com/example/objects2/OuterDummy.java b/processor/src/test/java/com/example/objects2/OuterDummy.java
new file mode 100644
index 0000000..00b1b5d
--- /dev/null
+++ b/processor/src/test/java/com/example/objects2/OuterDummy.java
@@ -0,0 +1,9 @@
+package com.example.objects2;
+
+public class OuterDummy {
+
+  @SuppressWarnings("ClassCanBeStatic")
+  public class InnerDummy {
+    
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/UnrecognizedAnnotation.java b/processor/src/test/java/org/robolectric/annotation/UnrecognizedAnnotation.java
new file mode 100644
index 0000000..9f4029e
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/UnrecognizedAnnotation.java
@@ -0,0 +1,7 @@
+package org.robolectric.annotation;
+
+@java.lang.annotation.Documented
+@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
+@java.lang.annotation.Target({java.lang.annotation.ElementType.TYPE})
+public @interface UnrecognizedAnnotation {
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/JavadocJsonGeneratorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/JavadocJsonGeneratorTest.java
new file mode 100644
index 0000000..c97e6b7
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/JavadocJsonGeneratorTest.java
@@ -0,0 +1,75 @@
+package org.robolectric.annotation.processing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.testing.compile.JavaFileObjects.forResource;
+import static com.google.testing.compile.JavaSourcesSubjectFactory.javaSources;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.annotation.processing.RobolectricProcessor.JSON_DOCS_DIR;
+import static org.robolectric.annotation.processing.RobolectricProcessor.JSON_DOCS_ENABLED;
+import static org.robolectric.annotation.processing.Utils.DEFAULT_OPTS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link org.robolectric.annotation.processing.generator.JavadocJsonGenerator} */
+@RunWith(JUnit4.class)
+public class JavadocJsonGeneratorTest {
+
+  @Test
+  public void jsonDocs_disabledByDefault() throws Exception {
+    File tmpDir = Files.createTempDirectory("JavadocJsonGeneratorTest").toFile();
+    tmpDir.deleteOnExit();
+    Map<String, String> options = new HashMap<>(DEFAULT_OPTS);
+    options.put(JSON_DOCS_DIR, tmpDir.getAbsolutePath());
+    assertAbout(javaSources())
+        .that(
+            ImmutableList.of(
+                forResource("org/robolectric/DocumentedObjectOuter.java"),
+                forResource(
+                    "org/robolectric/annotation/processing/shadows/DocumentedObjectShadow.java")))
+        .processedWith(new RobolectricProcessor(options))
+        .compilesWithoutError();
+    assertThat(tmpDir.list()).isEmpty();
+  }
+
+  @Test
+  public void shouldGenerateJavadocJson() throws Exception {
+    Map<String, String> options = new HashMap<>(DEFAULT_OPTS);
+    options.put(JSON_DOCS_ENABLED, "true");
+    assertAbout(javaSources())
+        .that(
+            ImmutableList.of(
+                forResource("org/robolectric/DocumentedObjectOuter.java"),
+                forResource(
+                    "org/robolectric/annotation/processing/shadows/DocumentedObjectShadow.java")))
+        .processedWith(new RobolectricProcessor(options))
+        .compilesWithoutError();
+    String jsonDocsDir = options.get(JSON_DOCS_DIR);
+    String jsonFile = jsonDocsDir + "/org.robolectric.DocumentedObjectOuter.DocumentedObject.json";
+    JsonElement json = JsonParser.parseReader(Files.newBufferedReader(Paths.get(jsonFile), UTF_8));
+    assertThat(((JsonObject) json).get("documentation").getAsString())
+        .isEqualTo("DocumentedObjectOuter Javadoc goes here! ");
+
+    // must list imported classes, including inner classes...
+    assertThat(((JsonObject) json).get("imports").getAsJsonArray())
+        .containsExactly(
+            new JsonPrimitive("org.robolectric.DocumentedObjectOuter"),
+            new JsonPrimitive("org.robolectric.annotation.Implementation"),
+            new JsonPrimitive("org.robolectric.annotation.Implements"),
+            new JsonPrimitive("java.util.Map"),
+            new JsonPrimitive(
+                "org.robolectric.annotation.processing.shadows.DocumentedObjectShadow.SomeEnum"));
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/RobolectricProcessorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/RobolectricProcessorTest.java
new file mode 100644
index 0000000..368fe30
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/RobolectricProcessorTest.java
@@ -0,0 +1,210 @@
+package org.robolectric.annotation.processing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.testing.compile.JavaFileObjects.forResource;
+import static com.google.testing.compile.JavaFileObjects.forSourceString;
+import static com.google.testing.compile.JavaSourcesSubjectFactory.javaSources;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.annotation.processing.RobolectricProcessor.JSON_DOCS_DIR;
+import static org.robolectric.annotation.processing.RobolectricProcessor.PACKAGE_OPT;
+import static org.robolectric.annotation.processing.RobolectricProcessor.SHOULD_INSTRUMENT_PKG_OPT;
+import static org.robolectric.annotation.processing.Utils.DEFAULT_OPTS;
+import static org.robolectric.annotation.processing.Utils.SHADOW_EXTRACTOR_SOURCE;
+import static org.robolectric.annotation.processing.Utils.SHADOW_PROVIDER_SOURCE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.Files;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RobolectricProcessorTest {
+  @Test
+  public void robolectricProcessor_supportsPackageOption() {
+    assertThat(new RobolectricProcessor(DEFAULT_OPTS).getSupportedOptions()).contains(PACKAGE_OPT);
+  }
+
+  @Test
+  public void robolectricProcessor_supportsShouldInstrumentPackageOption() {
+    assertThat(
+        new RobolectricProcessor(DEFAULT_OPTS).getSupportedOptions()).contains(SHOULD_INSTRUMENT_PKG_OPT);
+  }
+
+  @Test
+  public void unannotatedSource_shouldCompile() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forSourceString("HelloWorld", "final class HelloWorld {}")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError();
+    //.and().generatesNoSources(); Should add this assertion onces
+    // it becomes available in compile-testing
+  }
+
+  @Test
+  public void generatedFile_shouldHandleInnerClassCollisions() {
+    // Because the Generated annotation has a retention of "source", it can't
+    // be tested by a unit test - must run a source-level test.
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowOuterDummy.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowUniqueDummy.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError()
+      .and()
+      .generatesSources(forResource("org/robolectric/Robolectric_InnerClassCollision.java"));
+  }
+
+  @Test
+  public void generatedFile_shouldHandleNonPublicClasses() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowPrivate.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowOuterDummy2.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError()
+      .and()
+      .generatesSources(forResource("org/robolectric/Robolectric_HiddenClasses.java"));
+  }
+
+  @Test
+  public void generatedFile_shouldComplainAboutNonStaticInnerClasses() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowOuterDummyWithErrs.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .failsToCompile()
+      .withErrorContaining("inner shadow classes must be static");
+  }
+
+  @Test
+  public void generatedFile_shouldHandleClassNameOnlyShadows() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowClassNameOnly.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError()
+      .and()
+      .generatesSources(forResource("org/robolectric/Robolectric_ClassNameOnly.java"));
+  }
+
+  @Test
+  public void generatedFile_shouldNotGenerateShadowOfMethodsForExcludedClasses() {
+    assertAbout(javaSources())
+        .that(ImmutableList.of(
+            SHADOW_PROVIDER_SOURCE,
+            SHADOW_EXTRACTOR_SOURCE,
+            forResource("org/robolectric/annotation/processing/shadows/ShadowExcludedFromAndroidSdk.java")))
+        .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+        .compilesWithoutError()
+        .and()
+        .generatesSources(forResource("org/robolectric/Robolectric_NoExcludedTypes.java"));
+  }
+
+  @Test
+  public void generatedFile_shouldUseSpecifiedPackage() throws IOException {
+    StringBuilder expected = new StringBuilder();
+    InputStream in = RobolectricProcessorTest.class.getClassLoader()
+        .getResourceAsStream("org/robolectric/Robolectric_ClassNameOnly.java");
+    BufferedReader reader = new BufferedReader(new InputStreamReader(in, UTF_8));
+
+    String line;
+    while ((line = reader.readLine()) != null) {
+      expected.append(line).append("\n");
+    }
+    line = expected.toString();
+    line = line.replace("package org.robolectric", "package my.test.pkg");
+
+    ImmutableMap<String, String> opts =
+        ImmutableMap.of(
+            PACKAGE_OPT, "my.test.pkg", JSON_DOCS_DIR, Files.createTempDir().toString());
+
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowClassNameOnly.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java")))
+      .processedWith(new RobolectricProcessor(opts))
+      .compilesWithoutError()
+      .and()
+      .generatesSources(forSourceString("Shadows", line));
+  }
+
+  @Test
+  public void shouldGenerateMetaInfServicesFile() {
+    assertAbout(javaSources())
+        .that(ImmutableList.of(
+            SHADOW_PROVIDER_SOURCE,
+            SHADOW_EXTRACTOR_SOURCE,
+            forResource("org/robolectric/annotation/processing/shadows/ShadowClassNameOnly.java"),
+            forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java")))
+        .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+        .compilesWithoutError()
+        .and()
+        .generatesFiles(forResource("META-INF/services/org.robolectric.internal.ShadowProvider"));
+  }
+
+  @Test
+  public void shouldGracefullyHandleUnrecognisedAnnotation() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/TestWithUnrecognizedAnnotation.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError();
+  }
+
+  @Test
+  public void shouldGenerateGenericShadowOf() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_PROVIDER_SOURCE,
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java"),
+          forResource("org/robolectric/annotation/processing/shadows/ShadowParameterizedDummy.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError()
+      .and()
+      .generatesSources(forResource("org/robolectric/Robolectric_Parameterized.java"));
+  }
+
+  @Test
+  public void generatedShadowProvider_canConfigureInstrumentingPackages() {
+    Map<String, String> options = new HashMap<>(DEFAULT_OPTS);
+    options.put(SHOULD_INSTRUMENT_PKG_OPT, "false");
+
+    assertAbout(javaSources())
+    .that(ImmutableList.of(
+        SHADOW_PROVIDER_SOURCE,
+        SHADOW_EXTRACTOR_SOURCE,
+        forResource("org/robolectric/annotation/processing/shadows/ShadowDummy.java")))
+    .processedWith(new RobolectricProcessor(options))
+    .compilesWithoutError()
+    .and()
+    .generatesSources(forResource("org/robolectric/Robolectric_EmptyProvidedPackageNames.java"));
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/Utils.java b/processor/src/test/java/org/robolectric/annotation/processing/Utils.java
new file mode 100644
index 0000000..bca6a79
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/Utils.java
@@ -0,0 +1,29 @@
+package org.robolectric.annotation.processing;
+
+import static com.google.testing.compile.JavaFileObjects.forResource;
+import static org.robolectric.annotation.processing.RobolectricProcessor.JSON_DOCS_DIR;
+import static org.robolectric.annotation.processing.RobolectricProcessor.PACKAGE_OPT;
+import static org.robolectric.annotation.processing.RobolectricProcessor.SDK_CHECK_MODE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.Files;
+import javax.tools.JavaFileObject;
+
+public class Utils {
+
+  public static final ImmutableMap<String, String> DEFAULT_OPTS =
+      ImmutableMap.<String, String>builder()
+          .put(PACKAGE_OPT, "org.robolectric")
+          .put(JSON_DOCS_DIR, Files.createTempDir().toString())
+          .put(SDK_CHECK_MODE, "OFF")
+          .build();
+
+  public static final JavaFileObject SHADOW_PROVIDER_SOURCE =
+      forResource("mock-source/org/robolectric/internal/ShadowProvider.java");
+  public static final JavaFileObject SHADOW_EXTRACTOR_SOURCE =
+      forResource("mock-source/org/robolectric/shadow/api/Shadow.java");
+
+  public static String toResourcePath(String clazzName) {
+    return clazzName.replace('.', '/') + ".java";
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/generator/ShadowProviderGeneratorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/generator/ShadowProviderGeneratorTest.java
new file mode 100644
index 0000000..6e23262
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/generator/ShadowProviderGeneratorTest.java
@@ -0,0 +1,71 @@
+package org.robolectric.annotation.processing.generator;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.processing.ProcessingEnvironment;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.processing.RobolectricModel;
+import org.robolectric.annotation.processing.RobolectricModel.ResetterInfo;
+
+/** Tests for {@link ShadowProviderGenerator} */
+@RunWith(JUnit4.class)
+public class ShadowProviderGeneratorTest {
+
+  private RobolectricModel model;
+  private ShadowProviderGenerator generator;
+  private StringWriter writer;
+
+  @Before
+  public void setUp() throws Exception {
+    model = mock(RobolectricModel.class);
+    generator =
+        new ShadowProviderGenerator(
+            model, mock(ProcessingEnvironment.class), "the.package", true, 0);
+    writer = new StringWriter();
+  }
+
+  @Test
+  public void resettersAreOnlyCalledIfSdkMatches() throws Exception {
+    when(model.getVisibleShadowTypes()).thenReturn(Collections.emptyList());
+
+    List<ResetterInfo> resetterInfos = new ArrayList<>();
+    resetterInfos.add(resetterInfo("ShadowThing", 19, 20, "reset19To20"));
+    resetterInfos.add(resetterInfo("ShadowThing", -1, 18, "resetMax18"));
+    resetterInfos.add(resetterInfo("ShadowThing", 21, -1, "resetMin21"));
+    when(model.getResetters()).thenReturn(resetterInfos);
+
+    generator.generate(new PrintWriter(writer));
+
+    assertThat(writer.toString())
+        .contains(
+            "if (org.robolectric.RuntimeEnvironment.getApiLevel() >= 19"
+                + " && org.robolectric.RuntimeEnvironment.getApiLevel() <= 20)"
+                + " ShadowThing.reset19To20();");
+    assertThat(writer.toString())
+        .contains(
+            "if (org.robolectric.RuntimeEnvironment.getApiLevel() >= 21)"
+                + " ShadowThing.resetMin21();");
+    assertThat(writer.toString())
+        .contains(
+            "if (org.robolectric.RuntimeEnvironment.getApiLevel() <= 18)"
+                + " ShadowThing.resetMax18();");
+  }
+
+  private ResetterInfo resetterInfo(String shadowName, int minSdk, int maxSdk, String methodName) {
+    ResetterInfo resetterInfo = mock(ResetterInfo.class);
+    when(resetterInfo.getMinSdk()).thenReturn(minSdk);
+    when(resetterInfo.getMaxSdk()).thenReturn(maxSdk);
+    when(resetterInfo.getMethodCall()).thenReturn(shadowName + "." + methodName + "();");
+    return resetterInfo;
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/validator/ImplementationValidatorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/validator/ImplementationValidatorTest.java
new file mode 100644
index 0000000..b7d1512
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/validator/ImplementationValidatorTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.annotation.processing.validator;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static org.robolectric.annotation.processing.validator.SingleClassSubject.singleClass;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link ImplementationValidator} */
+@RunWith(JUnit4.class)
+public class ImplementationValidatorTest {
+
+  @Test
+  public void implementationWithoutImplements_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementationWithoutImplements";
+    assertAbout(singleClass())
+        .that(testClass)
+        .failsToCompile()
+        .withErrorContaining("@Implementation without @Implements")
+        .onLine(7);
+  }
+
+  @Test
+  public void implementationWithIncorrectVisibility_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementationWithIncorrectVisibility";
+    assertAbout(singleClass())
+        .that(testClass)
+        .failsToCompile()
+        .withErrorContaining("@Implementation methods should be protected (preferred) or public (deprecated)")
+        .onLine(17)
+        .and()
+        .withErrorContaining("@Implementation methods should be protected (preferred) or public (deprecated)")
+        .onLine(21)
+        .and()
+        .withErrorContaining("@Implementation methods should be protected (preferred) or public (deprecated)")
+        .onLine(31)
+        .and()
+        .withErrorContaining("@Implementation methods should be protected (preferred) or public (deprecated)")
+        .onLine(34)
+    ;
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/validator/ImplementsValidatorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/validator/ImplementsValidatorTest.java
new file mode 100644
index 0000000..bb1d49c
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/validator/ImplementsValidatorTest.java
@@ -0,0 +1,98 @@
+package org.robolectric.annotation.processing.validator;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.processing.validator.SingleClassSubject.singleClass;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.processing.DocumentedMethod;
+
+/** Tests for {@link ImplementsValidator */
+@RunWith(JUnit4.class)
+public class ImplementsValidatorTest {
+  @Test
+  public void implementsWithoutClassOrClassName_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementsWithoutClass";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@Implements: must specify <value> or <className>")
+      .onLine(5);
+  }
+
+  @Test
+  public void value_withUnresolvableClassNameAndOldMaxSdk_shouldNotCompile() {
+    final String testClass =
+        "org.robolectric.annotation.processing.shadows.ShadowWithUnresolvableClassNameAndOldMaxSdk";
+    assertAbout(singleClass())
+        .that(testClass)
+        .compilesWithoutError();
+  }
+
+  @Test
+  public void value_withClassName_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementsDummyWithOuterDummyClassName";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@Implements: cannot specify both <value> and <className> attributes")
+      .onLine(6);
+  }
+
+  @Test
+  public void implementsWithParameterMismatch_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementsWithParameterMismatch";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("Shadow type must have same type parameters as its real counterpart: expected <T,N extends java.lang.Number>, was <N extends java.lang.Number,T>")
+      .onLine(7);
+  }
+
+  @Test
+  public void implementsWithMissingParameters_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementsWithMissingParameters";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("Shadow type is missing type parameters, expected <T,N extends java.lang.Number>")
+      .onLine(7);
+  }
+
+  @Test
+  public void implementsWithExtraParameters_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowImplementsWithExtraParameters";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("Shadow type has type parameters but real type does not")
+      .onLine(7);
+  }
+
+  @Test
+  public void constructorShadowWithoutImplementation_shouldNotCompile() {
+    final String testClass =
+        "org.robolectric.annotation.processing.shadows.ShadowWithImplementationlessShadowMethods";
+    assertAbout(singleClass())
+        .that(testClass)
+        .failsToCompile()
+        .withErrorContaining("Shadow methods must be annotated @Implementation")
+        .onLine(8)
+        .and()
+        .withErrorContaining("Shadow methods must be annotated @Implementation")
+        .onLine(10);
+  }
+
+  @Test
+  public void javadocMarkdownFormatting() throws Exception {
+    DocumentedMethod documentedMethod = new DocumentedMethod("name");
+    documentedMethod.setDocumentation(
+        " First sentence.\n \n Second sentence.\n \n ASCII art:\n   *  *  *\n @return null\n"
+    );
+
+    assertThat(documentedMethod.getDocumentation())
+        .isEqualTo("First sentence.\n\nSecond sentence.\n\nASCII art:\n  *  *  *\n@return null\n");
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/validator/RealObjectValidatorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/validator/RealObjectValidatorTest.java
new file mode 100644
index 0000000..db76bb6
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/validator/RealObjectValidatorTest.java
@@ -0,0 +1,113 @@
+package org.robolectric.annotation.processing.validator;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.testing.compile.JavaFileObjects.forResource;
+import static com.google.testing.compile.JavaSourcesSubjectFactory.javaSources;
+import static org.robolectric.annotation.processing.Utils.DEFAULT_OPTS;
+import static org.robolectric.annotation.processing.Utils.SHADOW_EXTRACTOR_SOURCE;
+import static org.robolectric.annotation.processing.validator.SingleClassSubject.singleClass;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.processing.RobolectricProcessor;
+
+/** Tests for {@link RealObjectValidator} */
+@RunWith(JUnit4.class)
+public class RealObjectValidatorTest {
+  @Test
+  public void realObjectWithoutImplements_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithoutImplements";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@RealObject without @Implements")
+      .onLine(7);
+  }
+
+  @Test
+  public void realObjectParameterizedMissingParameters_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectParameterizedMissingParameters";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@RealObject is missing type parameters")
+      .onLine(11);
+  }
+
+  @Test
+  public void realObjectParameterizedMismatch_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectParameterizedMismatch";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("Parameter type mismatch: expecting <T,S>, was <S,T>")
+      .onLine(11);
+  }
+
+  @Test
+  public void realObjectWithEmptyImplements_shouldNotRaiseOwnError() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithEmptyImplements";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withNoErrorContaining("@RealObject");
+  }
+
+  @Test
+  public void realObjectWithEmptyClassName_shouldNotRaiseOwnError() {
+    final String testClass =
+        "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithEmptyClassName";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withNoErrorContaining("@RealObject");
+  }
+
+  @Test
+  public void realObjectWithTypeMismatch_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithWrongType";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@RealObject with type <com.example.objects.UniqueDummy>; expected <com.example.objects.Dummy>")
+      .onLine(11);
+  }
+
+  @Test
+  public void realObjectWithClassName_typeMismatch_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithIncorrectClassName";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@RealObject with type <com.example.objects.UniqueDummy>; expected <com.example.objects.Dummy>")
+      .onLine(10);
+  }
+
+  @Test
+  public void realObjectWithCorrectType_shouldCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithCorrectType";
+    assertAbout(singleClass())
+      .that(testClass)
+      .compilesWithoutError();
+  }
+
+  @Test
+  public void realObjectWithCorrectClassName_shouldCompile() {
+    assertAbout(javaSources())
+      .that(ImmutableList.of(
+          SHADOW_EXTRACTOR_SOURCE,
+          forResource("org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectClassName.java")))
+      .processedWith(new RobolectricProcessor(DEFAULT_OPTS))
+      .compilesWithoutError();
+  }
+  
+  @Test
+  public void realObjectWithNestedClassName_shouldCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowRealObjectWithNestedClassName";
+    assertAbout(singleClass())
+      .that(testClass)
+      .compilesWithoutError();
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java
new file mode 100644
index 0000000..6890040
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java
@@ -0,0 +1,60 @@
+package org.robolectric.annotation.processing.validator;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static org.robolectric.annotation.processing.validator.SingleClassSubject.singleClass;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link ResetterValidator} */
+@RunWith(JUnit4.class)
+public class ResetterValidatorTest {
+  @Test
+  public void resetterWithoutImplements_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterWithoutImplements";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@Resetter without @Implements")
+      .onLine(7);
+  }
+
+  @Test
+  public void nonStaticResetter_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterNonStatic";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@Resetter methods must be static")
+      .onLine(10);
+  }
+
+  @Test
+  public void nonPublicResetter_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterNonPublic";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@Resetter methods must be public")
+      .onLine(10);
+  }
+
+  @Test
+  public void resetterWithParameters_shouldNotCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterWithParameters";
+    assertAbout(singleClass())
+      .that(testClass)
+      .failsToCompile()
+      .withErrorContaining("@Resetter methods must not have parameters")
+      .onLine(11);
+  }
+
+  @Test
+  public void goodResetter_shouldCompile() {
+    final String testClass = "org.robolectric.annotation.processing.shadows.ShadowDummy";
+    assertAbout(singleClass())
+      .that(testClass)
+      .compilesWithoutError();
+  }
+}
diff --git a/processor/src/test/java/org/robolectric/annotation/processing/validator/SingleClassSubject.java b/processor/src/test/java/org/robolectric/annotation/processing/validator/SingleClassSubject.java
new file mode 100644
index 0000000..c016495
--- /dev/null
+++ b/processor/src/test/java/org/robolectric/annotation/processing/validator/SingleClassSubject.java
@@ -0,0 +1,125 @@
+package org.robolectric.annotation.processing.validator;
+
+import static com.google.common.truth.Fact.simpleFact;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.testing.compile.JavaSourcesSubjectFactory.javaSources;
+import static org.robolectric.annotation.processing.Utils.DEFAULT_OPTS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.testing.compile.CompileTester;
+import com.google.testing.compile.CompileTester.LineClause;
+import com.google.testing.compile.CompileTester.SuccessfulCompilationClause;
+import com.google.testing.compile.CompileTester.UnsuccessfulCompilationClause;
+import com.google.testing.compile.JavaFileObjects;
+import javax.tools.JavaFileObject;
+import org.robolectric.annotation.processing.RobolectricProcessor;
+import org.robolectric.annotation.processing.Utils;
+
+public final class SingleClassSubject extends Subject {
+
+  public static Subject.Factory<SingleClassSubject, String> singleClass() {
+
+    return SingleClassSubject::new;
+  }
+
+
+  JavaFileObject source;
+  CompileTester tester;
+  
+  public SingleClassSubject(FailureMetadata failureMetadata, String subject) {
+    super(failureMetadata, subject);
+    source = JavaFileObjects.forResource(Utils.toResourcePath(subject));
+    tester =
+        assertAbout(javaSources())
+            .that(ImmutableList.of(source, Utils.SHADOW_EXTRACTOR_SOURCE))
+            .processedWith(new RobolectricProcessor(DEFAULT_OPTS));
+  }
+
+  public SuccessfulCompilationClause compilesWithoutError() {
+    try {
+      return tester.compilesWithoutError();
+    } catch (AssertionError e) {
+      failWithoutActual(simpleFact(e.getMessage()));
+    }
+    return null;
+  }
+  
+  public SingleFileClause failsToCompile() {
+    try {
+      return new SingleFileClause(tester.failsToCompile(), source);
+    } catch (AssertionError e) {
+      failWithoutActual(simpleFact(e.getMessage()));
+    }
+    return null;
+  }
+  
+  final class SingleFileClause implements CompileTester.ChainingClause<SingleFileClause> {
+
+    UnsuccessfulCompilationClause unsuccessful;
+    JavaFileObject source;
+    
+    public SingleFileClause(UnsuccessfulCompilationClause unsuccessful, JavaFileObject source) {
+      this.unsuccessful = unsuccessful;
+      this.source = source;
+    }
+    
+    public SingleLineClause withErrorContaining(final String messageFragment) {
+      try {
+        return new SingleLineClause(unsuccessful.withErrorContaining(messageFragment).in(source));
+      } catch (AssertionError e) {
+        failWithoutActual(simpleFact(e.getMessage()));
+      }
+      return null;
+    }
+
+    public SingleFileClause withNoErrorContaining(final String messageFragment) {
+      try {
+        unsuccessful.withErrorContaining(messageFragment);
+      } catch (AssertionError e) {
+        return this;
+      }
+      failWithoutActual(
+          simpleFact(
+              "Shouldn't have found any errors containing " + messageFragment + ", but we did"));
+
+      return this;
+    }
+    
+    @Override
+    public SingleFileClause and() {
+      return this;
+    }
+
+    final class SingleLineClause implements CompileTester.ChainingClause<SingleFileClause> {
+
+      LineClause lineClause;
+      
+      public SingleLineClause(LineClause lineClause) {
+        this.lineClause = lineClause;
+      }
+      
+      public CompileTester.ChainingClause<SingleFileClause> onLine(long lineNumber) {
+        try {
+          lineClause.onLine(lineNumber);
+          return new CompileTester.ChainingClause<SingleFileClause>() {
+            @Override
+            public SingleFileClause and() {
+              return SingleFileClause.this;
+            }
+          };
+        } catch (AssertionError e) {
+          failWithoutActual(simpleFact(e.getMessage()));
+        }
+        return null;
+      }
+      
+      @Override
+      public SingleFileClause and() {
+        return SingleFileClause.this;
+      }
+    
+    }
+  }
+}
diff --git a/processor/src/test/resources/META-INF/services/org.robolectric.internal.ShadowProvider b/processor/src/test/resources/META-INF/services/org.robolectric.internal.ShadowProvider
new file mode 100644
index 0000000..e46dc6a
--- /dev/null
+++ b/processor/src/test/resources/META-INF/services/org.robolectric.internal.ShadowProvider
@@ -0,0 +1 @@
+org.robolectric.Shadows
diff --git a/processor/src/test/resources/mock-source/org/robolectric/internal/ShadowProvider.java b/processor/src/test/resources/mock-source/org/robolectric/internal/ShadowProvider.java
new file mode 100644
index 0000000..22b858a
--- /dev/null
+++ b/processor/src/test/resources/mock-source/org/robolectric/internal/ShadowProvider.java
@@ -0,0 +1,13 @@
+package org.robolectric.internal;
+
+import java.util.Collection;
+import java.util.Map;
+
+public interface ShadowProvider {
+
+  void reset();
+
+  String[] getProvidedPackageNames();
+
+  Collection<Map.Entry<String, String>> getShadows();
+}
diff --git a/processor/src/test/resources/mock-source/org/robolectric/shadow/api/Shadow.java b/processor/src/test/resources/mock-source/org/robolectric/shadow/api/Shadow.java
new file mode 100644
index 0000000..30c7ce9
--- /dev/null
+++ b/processor/src/test/resources/mock-source/org/robolectric/shadow/api/Shadow.java
@@ -0,0 +1,8 @@
+package org.robolectric.shadow.api;
+
+public class Shadow {
+
+  public static <T> T extract(Object source) {
+    return null;
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/DocumentedObjectOuter.java b/processor/src/test/resources/org/robolectric/DocumentedObjectOuter.java
new file mode 100644
index 0000000..82ce136
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/DocumentedObjectOuter.java
@@ -0,0 +1,10 @@
+package org.robolectric;
+
+/**
+ * Outer class used for {@link org.robolectric.annotation.processing.generator.JavadocJsonGenerator}
+ * tests.
+ */
+public class DocumentedObjectOuter {
+  /** Inner class that the corresponding Shadow object documents. */
+  public static class DocumentedObject {}
+}
diff --git a/processor/src/test/resources/org/robolectric/Robolectric_ClassNameOnly.java b/processor/src/test/resources/org/robolectric/Robolectric_ClassNameOnly.java
new file mode 100644
index 0000000..aa06774
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/Robolectric_ClassNameOnly.java
@@ -0,0 +1,55 @@
+package org.robolectric;
+import com.example.objects.AnyObject;
+import com.example.objects.Dummy;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+import org.robolectric.annotation.processing.shadows.ShadowClassNameOnly;
+import org.robolectric.annotation.processing.shadows.ShadowDummy;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.
+ */
+@Generated("org.robolectric.annotation.processing.RobolectricProcessor")
+@SuppressWarnings({"unchecked","deprecation"})
+public class Shadows implements ShadowProvider {
+  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>(2);
+
+  static {
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.AnyObject", "org.robolectric.annotation.processing.shadows.ShadowClassNameOnly"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Dummy", "org.robolectric.annotation.processing.shadows.ShadowDummy"));
+  }
+
+  public static ShadowClassNameOnly shadowOf(AnyObject actual) {
+    return (ShadowClassNameOnly) Shadow.extract(actual);
+  }
+
+  public static ShadowDummy shadowOf(Dummy actual) {
+    return (ShadowDummy) Shadow.extract(actual);
+  }
+
+  @Override
+  public void reset() {
+    ShadowClassNameOnly.anotherResetter();
+    ShadowDummy.resetter_method();
+  }
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return SHADOWS;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return new String[] {
+        "com.example.objects"
+    };
+  }
+
+}
\ No newline at end of file
diff --git a/processor/src/test/resources/org/robolectric/Robolectric_EmptyProvidedPackageNames.java b/processor/src/test/resources/org/robolectric/Robolectric_EmptyProvidedPackageNames.java
new file mode 100644
index 0000000..8ace607
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/Robolectric_EmptyProvidedPackageNames.java
@@ -0,0 +1,46 @@
+package org.robolectric;
+import com.example.objects.Dummy;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+import org.robolectric.annotation.processing.shadows.ShadowDummy;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.
+ */
+@Generated("org.robolectric.annotation.processing.RobolectricProcessor")
+@SuppressWarnings({"unchecked","deprecation"})
+public class Shadows implements ShadowProvider {
+  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>(1);
+
+  static {
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Dummy", "org.robolectric.annotation.processing.shadows.ShadowDummy"));
+  }
+
+  public static ShadowDummy shadowOf(Dummy actual) {
+    return (ShadowDummy) Shadow.extract(actual);
+  }
+
+  @Override
+  public void reset() {
+    ShadowDummy.resetter_method();
+  }
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return SHADOWS;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return new String[] {
+    };
+  }
+
+}
\ No newline at end of file
diff --git a/processor/src/test/resources/org/robolectric/Robolectric_HiddenClasses.java b/processor/src/test/resources/org/robolectric/Robolectric_HiddenClasses.java
new file mode 100644
index 0000000..b503ced
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/Robolectric_HiddenClasses.java
@@ -0,0 +1,62 @@
+package org.robolectric;
+import com.example.objects.Dummy;
+import com.example.objects.OuterDummy2;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+import org.robolectric.annotation.processing.shadows.ShadowDummy;
+import org.robolectric.annotation.processing.shadows.ShadowOuterDummy2;
+import org.robolectric.annotation.processing.shadows.ShadowOuterDummy2.ShadowInnerPackage;
+import org.robolectric.annotation.processing.shadows.ShadowOuterDummy2.ShadowInnerProtected;
+import org.robolectric.annotation.processing.shadows.ShadowPrivate;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.
+ */
+@Generated("org.robolectric.annotation.processing.RobolectricProcessor")
+@SuppressWarnings({"unchecked","deprecation"})
+public class Shadows implements ShadowProvider {
+  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>(6);
+
+  static {
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Dummy", "org.robolectric.annotation.processing.shadows.ShadowDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.OuterDummy2", "org.robolectric.annotation.processing.shadows.ShadowOuterDummy2"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.OuterDummy2.InnerPackage", "org.robolectric.annotation.processing.shadows.ShadowOuterDummy2$ShadowInnerPackage"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.OuterDummy2.InnerProtected", "org.robolectric.annotation.processing.shadows.ShadowOuterDummy2$ShadowInnerProtected"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Private", "org.robolectric.annotation.processing.shadows.ShadowPrivate"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.OuterDummy2.InnerPrivate", "org.robolectric.annotation.processing.shadows.ShadowOuterDummy2$ShadowInnerPrivate"));
+  }
+
+  public static ShadowDummy shadowOf(Dummy actual) {
+    return (ShadowDummy) Shadow.extract(actual);
+  }
+
+  public static ShadowOuterDummy2 shadowOf(OuterDummy2 actual) {
+    return (ShadowOuterDummy2) Shadow.extract(actual);
+  }
+
+  @Override
+  public void reset() {
+    ShadowDummy.resetter_method();
+    ShadowPrivate.resetMethod();
+  }
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return SHADOWS;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return new String[] {
+        "com.example.objects"
+    };
+  }
+
+}
\ No newline at end of file
diff --git a/processor/src/test/resources/org/robolectric/Robolectric_InnerClassCollision.java b/processor/src/test/resources/org/robolectric/Robolectric_InnerClassCollision.java
new file mode 100644
index 0000000..be99355
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/Robolectric_InnerClassCollision.java
@@ -0,0 +1,78 @@
+package org.robolectric;
+import com.example.objects.Dummy;
+import com.example.objects.OuterDummy;
+import com.example.objects.UniqueDummy;
+import com.example.objects.UniqueDummy.UniqueInnerDummy;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+import org.robolectric.annotation.processing.shadows.ShadowDummy;
+import org.robolectric.annotation.processing.shadows.ShadowOuterDummy;
+import org.robolectric.annotation.processing.shadows.ShadowUniqueDummy;
+import org.robolectric.annotation.processing.shadows.ShadowUniqueDummy.ShadowUniqueInnerDummy;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.
+ */
+@Generated("org.robolectric.annotation.processing.RobolectricProcessor")
+@SuppressWarnings({"unchecked","deprecation"})
+public class Shadows implements ShadowProvider {
+  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>(6);
+
+  static {
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Dummy", "org.robolectric.annotation.processing.shadows.ShadowDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.OuterDummy", "org.robolectric.annotation.processing.shadows.ShadowOuterDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.OuterDummy.InnerDummy", "org.robolectric.annotation.processing.shadows.ShadowOuterDummy$ShadowInnerDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.UniqueDummy", "org.robolectric.annotation.processing.shadows.ShadowUniqueDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.UniqueDummy.InnerDummy", "org.robolectric.annotation.processing.shadows.ShadowUniqueDummy$ShadowInnerDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.UniqueDummy.UniqueInnerDummy", "org.robolectric.annotation.processing.shadows.ShadowUniqueDummy$ShadowUniqueInnerDummy"));
+  }
+
+  public static ShadowDummy shadowOf(Dummy actual) {
+    return (ShadowDummy) Shadow.extract(actual);
+  }
+
+  public static ShadowOuterDummy shadowOf(OuterDummy actual) {
+    return (ShadowOuterDummy) Shadow.extract(actual);
+  }
+
+  public static ShadowOuterDummy.ShadowInnerDummy shadowOf(OuterDummy.InnerDummy actual) {
+    return (ShadowOuterDummy.ShadowInnerDummy) Shadow.extract(actual);
+  }
+
+  public static ShadowUniqueDummy shadowOf(UniqueDummy actual) {
+    return (ShadowUniqueDummy) Shadow.extract(actual);
+  }
+
+  public static ShadowUniqueDummy.ShadowInnerDummy shadowOf(UniqueDummy.InnerDummy actual) {
+    return (ShadowUniqueDummy.ShadowInnerDummy) Shadow.extract(actual);
+  }
+
+  public static ShadowUniqueInnerDummy shadowOf(UniqueInnerDummy actual) {
+    return (ShadowUniqueInnerDummy) Shadow.extract(actual);
+  }
+
+  @Override
+  public void reset() {
+    ShadowDummy.resetter_method();
+  }
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return SHADOWS;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return new String[] {
+        "com.example.objects"
+    };
+  }
+
+}
\ No newline at end of file
diff --git a/processor/src/test/resources/org/robolectric/Robolectric_NoExcludedTypes.java b/processor/src/test/resources/org/robolectric/Robolectric_NoExcludedTypes.java
new file mode 100644
index 0000000..b4f5602
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/Robolectric_NoExcludedTypes.java
@@ -0,0 +1,40 @@
+package org.robolectric;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.
+ */
+@Generated("org.robolectric.annotation.processing.RobolectricProcessor")
+@SuppressWarnings({"unchecked","deprecation"})
+public class Shadows implements ShadowProvider {
+  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>(1);
+
+  static {
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Dummy", "org.robolectric.annotation.processing.shadows.ShadowExcludedFromAndroidSdk"));
+  }
+
+  @Override
+  public void reset() {
+  }
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return SHADOWS;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return new String[] {
+        "com.example.objects"
+    };
+  }
+
+}
\ No newline at end of file
diff --git a/processor/src/test/resources/org/robolectric/Robolectric_Parameterized.java b/processor/src/test/resources/org/robolectric/Robolectric_Parameterized.java
new file mode 100644
index 0000000..e4ce5cb
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/Robolectric_Parameterized.java
@@ -0,0 +1,54 @@
+package org.robolectric;
+import com.example.objects.Dummy;
+import com.example.objects.ParameterizedDummy;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+import org.robolectric.annotation.processing.shadows.ShadowDummy;
+import org.robolectric.annotation.processing.shadows.ShadowParameterizedDummy;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Shadow mapper. Automatically generated by the Robolectric Annotation Processor.
+ */
+@Generated("org.robolectric.annotation.processing.RobolectricProcessor")
+@SuppressWarnings({"unchecked","deprecation"})
+public class Shadows implements ShadowProvider {
+  private static final List<Map.Entry<String, String>> SHADOWS = new ArrayList<>(2);
+
+  static {
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.Dummy", "org.robolectric.annotation.processing.shadows.ShadowDummy"));
+    SHADOWS.add(new AbstractMap.SimpleImmutableEntry<>("com.example.objects.ParameterizedDummy", "org.robolectric.annotation.processing.shadows.ShadowParameterizedDummy"));
+  }
+
+  public static ShadowDummy shadowOf(Dummy actual) {
+    return (ShadowDummy) Shadow.extract(actual);
+  }
+
+  public static <T,N extends Number> ShadowParameterizedDummy<T,N> shadowOf(ParameterizedDummy<T,N> actual) {
+    return (ShadowParameterizedDummy<T,N>) Shadow.extract(actual);
+  }
+
+  @Override
+  public void reset() {
+    ShadowDummy.resetter_method();
+  }
+
+  @Override
+  public Collection<Map.Entry<String, String>> getShadows() {
+    return SHADOWS;
+  }
+
+  @Override
+  public String[] getProvidedPackageNames() {
+    return new String[] {
+        "com.example.objects"
+    };
+  }
+
+}
\ No newline at end of file
diff --git a/processor/src/test/resources/org/robolectric/annotation/TestWithUnrecognizedAnnotation.java b/processor/src/test/resources/org/robolectric/annotation/TestWithUnrecognizedAnnotation.java
new file mode 100644
index 0000000..aaf35d1
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/TestWithUnrecognizedAnnotation.java
@@ -0,0 +1,7 @@
+package org.robolectric.annotation;
+
+import org.robolectric.annotation.UnrecognizedAnnotation;
+
+@UnrecognizedAnnotation
+public class TestWithUnrecognizedAnnotation {
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/DocumentedObjectShadow.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/DocumentedObjectShadow.java
new file mode 100644
index 0000000..29850e5
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/DocumentedObjectShadow.java
@@ -0,0 +1,22 @@
+package org.robolectric.annotation.processing.shadows;
+
+import java.util.Map;
+import org.robolectric.DocumentedObjectOuter;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** DocumentedObjectOuter Javadoc goes here! */
+@Implements(value = DocumentedObjectOuter.DocumentedObject.class)
+public class DocumentedObjectShadow {
+  /**
+   * Docs for shadow method go here!
+   */
+  @Implementation
+  protected String getSomething(int index, Map<String, String> defaultValue) {
+    return null;
+  }
+
+  public enum SomeEnum {
+    VALUE1, VALUE2
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowClassNameOnly.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowClassNameOnly.java
new file mode 100644
index 0000000..f3a9a66
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowClassNameOnly.java
@@ -0,0 +1,13 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(className = "com.example.objects.AnyObject")
+public class ShadowClassNameOnly {
+  public static int resetCount = 0;
+  @Resetter
+  public static void anotherResetter() {
+    resetCount++;
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowDummy.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowDummy.java
new file mode 100644
index 0000000..d3ed8be
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowDummy.java
@@ -0,0 +1,14 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import com.example.objects.Dummy;
+
+@Implements(Dummy.class)
+public class ShadowDummy {
+  public static int resetCount = 0;
+  @Resetter
+  public static void resetter_method() {
+    resetCount++;
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowExcludedFromAndroidSdk.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowExcludedFromAndroidSdk.java
new file mode 100644
index 0000000..2141147
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowExcludedFromAndroidSdk.java
@@ -0,0 +1,8 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import com.example.objects.Dummy;
+
+@Implements(value = Dummy.class, isInAndroidSdk = false)
+public class ShadowExcludedFromAndroidSdk {
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementationWithIncorrectVisibility.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementationWithIncorrectVisibility.java
new file mode 100644
index 0000000..4d5513c
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementationWithIncorrectVisibility.java
@@ -0,0 +1,36 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.Dummy;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(Dummy.class)
+public class ShadowImplementationWithIncorrectVisibility {
+  @Implementation
+  public void __constructor__(int i0) {
+  }
+
+  @Implementation
+  protected void __constructor__(int i0, int i1) {
+  }
+
+  @Implementation
+  void __constructor__(int i0, int i1, int i2) {
+  }
+
+  @Implementation
+  private void __constructor__(int i0, int i1, int i2, int i3) {
+  }
+
+  @Implementation
+  public static void publicMethod() {}
+
+  @Implementation
+  protected static void protectedMethod() {}
+
+  @Implementation
+  static void packageMethod() {}
+
+  @Implementation
+  private static void privateMethod() {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementationWithoutImplements.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementationWithoutImplements.java
new file mode 100644
index 0000000..987d86b
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementationWithoutImplements.java
@@ -0,0 +1,9 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implementation;
+
+public class ShadowImplementationWithoutImplements {
+
+  @Implementation
+  protected static void implementation_method() {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsDummyWithOuterDummyClassName.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsDummyWithOuterDummyClassName.java
new file mode 100644
index 0000000..1f42b2f
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsDummyWithOuterDummyClassName.java
@@ -0,0 +1,10 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import com.example.objects.Dummy;
+
+@Implements(value = Dummy.class,
+            className="com.example.objects.OuterDummy")
+public class ShadowImplementsDummyWithOuterDummyClassName {
+  
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithExtraParameters.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithExtraParameters.java
new file mode 100644
index 0000000..a7d7f67
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithExtraParameters.java
@@ -0,0 +1,8 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import com.example.objects.Dummy;
+
+@Implements(Dummy.class)
+public class ShadowImplementsWithExtraParameters<T,S,R> {
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithMissingParameters.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithMissingParameters.java
new file mode 100644
index 0000000..694399f
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithMissingParameters.java
@@ -0,0 +1,8 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import com.example.objects.ParameterizedDummy;
+
+@Implements(ParameterizedDummy.class)
+public class ShadowImplementsWithMissingParameters {
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithParameterMismatch.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithParameterMismatch.java
new file mode 100644
index 0000000..3e8396b
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithParameterMismatch.java
@@ -0,0 +1,8 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import com.example.objects.ParameterizedDummy;
+
+@Implements(ParameterizedDummy.class)
+public class ShadowImplementsWithParameterMismatch<N extends Number,T> {
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithoutClass.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithoutClass.java
new file mode 100644
index 0000000..6a1865f
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowImplementsWithoutClass.java
@@ -0,0 +1,7 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+
+@Implements
+public class ShadowImplementsWithoutClass {
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummy.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummy.java
new file mode 100644
index 0000000..9a4fd47
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummy.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.OuterDummy;
+import org.robolectric.annotation.Implements;
+
+@Implements(OuterDummy.class)
+public class ShadowOuterDummy {
+
+  @Implements(OuterDummy.InnerDummy.class)
+  public static class ShadowInnerDummy {
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummy2.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummy2.java
new file mode 100644
index 0000000..6d1a0e6
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummy2.java
@@ -0,0 +1,21 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.OuterDummy2;
+import org.robolectric.annotation.Implements;
+
+/** A Shadow that implements an outer class */
+@Implements(OuterDummy2.class)
+public class ShadowOuterDummy2 {
+
+  /** A Shadow that implements an protected inner class name */
+  @Implements(className = "com.example.objects.OuterDummy2$InnerProtected")
+  public static class ShadowInnerProtected {}
+
+  /** A Shadow that implements an inner package-private class */
+  @Implements(className = "com.example.objects.OuterDummy2$InnerPackage")
+  public static class ShadowInnerPackage {}
+
+  /** A Shadow that implements an inner private class */
+  @Implements(className = "com.example.objects.OuterDummy2$InnerPrivate", maxSdk = 1)
+  public static class ShadowInnerPrivate {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummyWithErrs.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummyWithErrs.java
new file mode 100644
index 0000000..dad9e83
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowOuterDummyWithErrs.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.OuterDummy2;
+import org.robolectric.annotation.Implements;
+
+@Implements(OuterDummy2.class)
+public class ShadowOuterDummyWithErrs {
+
+  @Implements(className="com.example.objects.OuterDummy2$InnerProtected")
+  public class ShadowInnerProtected {
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowParameterizedDummy.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowParameterizedDummy.java
new file mode 100644
index 0000000..28a2142
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowParameterizedDummy.java
@@ -0,0 +1,11 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.ParameterizedDummy;
+
+@Implements(ParameterizedDummy.class)
+public class ShadowParameterizedDummy<T, S extends Number> {
+  @RealObject
+  ParameterizedDummy<T,S> real;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowPrivate.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowPrivate.java
new file mode 100644
index 0000000..23af594
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowPrivate.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** A Shadow that implements a private class name */
+@Implements(className = "com.example.objects.Private")
+public class ShadowPrivate {
+  @Resetter
+  public static void resetMethod() {
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectParameterizedMismatch.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectParameterizedMismatch.java
new file mode 100644
index 0000000..ed46753
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectParameterizedMismatch.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.ParameterizedDummy;
+
+@Implements(ParameterizedDummy.class)
+public class ShadowRealObjectParameterizedMismatch<T,S extends Number> {
+
+  @RealObject
+  ParameterizedDummy<S,T> someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectParameterizedMissingParameters.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectParameterizedMissingParameters.java
new file mode 100644
index 0000000..2e7b8c2
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectParameterizedMissingParameters.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.ParameterizedDummy;
+
+@Implements(ParameterizedDummy.class)
+public class ShadowRealObjectParameterizedMissingParameters<T,S extends Number> {
+
+  @RealObject
+  ParameterizedDummy someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectClassName.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectClassName.java
new file mode 100644
index 0000000..3a6b4d7
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectClassName.java
@@ -0,0 +1,11 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.Dummy;
+
+@Implements(className="com.example.objects.Dummy")
+public class ShadowRealObjectWithCorrectClassName {
+
+  @RealObject Dummy someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectType.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectType.java
new file mode 100644
index 0000000..bc9b6e9
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithCorrectType.java
@@ -0,0 +1,11 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.Dummy;
+
+@Implements(Dummy.class)
+public class ShadowRealObjectWithCorrectType {
+
+  @RealObject Dummy someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithEmptyClassName.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithEmptyClassName.java
new file mode 100644
index 0000000..c234319
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithEmptyClassName.java
@@ -0,0 +1,11 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** A Shadow with a RealObject field that implements an empty class name */
+@Implements(className = "")
+public class ShadowRealObjectWithEmptyClassName {
+
+  @RealObject String someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithEmptyImplements.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithEmptyImplements.java
new file mode 100644
index 0000000..99b8259
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithEmptyImplements.java
@@ -0,0 +1,10 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@Implements
+public class ShadowRealObjectWithEmptyImplements {
+
+  @RealObject String someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithIncorrectClassName.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithIncorrectClassName.java
new file mode 100644
index 0000000..4fb6cb8
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithIncorrectClassName.java
@@ -0,0 +1,11 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.UniqueDummy;
+
+@Implements(className="com.example.objects.Dummy")
+public class ShadowRealObjectWithIncorrectClassName {
+
+  @RealObject UniqueDummy someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithNestedClassName.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithNestedClassName.java
new file mode 100644
index 0000000..488e004
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithNestedClassName.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.OuterDummy;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** A Shadow that implements a nested class name */
+@Implements(className = "com.example.objects.OuterDummy$InnerDummy")
+public class ShadowRealObjectWithNestedClassName {
+
+  @RealObject OuterDummy.InnerDummy someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithWrongType.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithWrongType.java
new file mode 100644
index 0000000..1590f7f
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithWrongType.java
@@ -0,0 +1,13 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import com.example.objects.Dummy;
+import com.example.objects.UniqueDummy;
+
+@Implements(Dummy.class)
+public class ShadowRealObjectWithWrongType {
+
+  @RealObject
+  UniqueDummy someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithoutImplements.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithoutImplements.java
new file mode 100644
index 0000000..41824f8
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowRealObjectWithoutImplements.java
@@ -0,0 +1,8 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.RealObject;
+
+public class ShadowRealObjectWithoutImplements {
+
+  @RealObject Object someField;
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterNonPublic.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterNonPublic.java
new file mode 100644
index 0000000..7a2048a
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterNonPublic.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import com.example.objects.Dummy;
+
+@Implements(Dummy.class)
+public class ShadowResetterNonPublic {
+
+  @Resetter
+  protected static void resetter_method() {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterNonStatic.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterNonStatic.java
new file mode 100644
index 0000000..dd9a809
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterNonStatic.java
@@ -0,0 +1,12 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import com.example.objects.Dummy;
+
+@Implements(Dummy.class)
+public class ShadowResetterNonStatic {
+
+  @Resetter
+  public void resetter_method() {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterWithParameters.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterWithParameters.java
new file mode 100644
index 0000000..1af4dff
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterWithParameters.java
@@ -0,0 +1,13 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import com.example.objects.Dummy;
+
+@Implements(Dummy.class)
+public class ShadowResetterWithParameters {
+
+  
+  @Resetter
+  public static void resetter_method(String param) {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterWithoutImplements.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterWithoutImplements.java
new file mode 100644
index 0000000..df14d90
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowResetterWithoutImplements.java
@@ -0,0 +1,9 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Resetter;
+
+public class ShadowResetterWithoutImplements {
+
+  @Resetter
+  public static void resetter_method() {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowUniqueDummy.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowUniqueDummy.java
new file mode 100644
index 0000000..920e15b
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowUniqueDummy.java
@@ -0,0 +1,16 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.UniqueDummy;
+import org.robolectric.annotation.Implements;
+
+@Implements(UniqueDummy.class)
+public class ShadowUniqueDummy {
+
+  @Implements(UniqueDummy.InnerDummy.class)
+  public static class ShadowInnerDummy {
+  }
+  
+  @Implements(UniqueDummy.UniqueInnerDummy.class)
+  public static class ShadowUniqueInnerDummy {
+  }
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithImplementationlessShadowMethods.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithImplementationlessShadowMethods.java
new file mode 100644
index 0000000..19c30c2
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithImplementationlessShadowMethods.java
@@ -0,0 +1,11 @@
+package org.robolectric.annotation.processing.shadows;
+
+import com.example.objects.Dummy;
+import org.robolectric.annotation.Implements;
+
+@Implements(Dummy.class)
+public class ShadowWithImplementationlessShadowMethods {
+  public void __constructor__() {}
+
+  public void __staticInitializer__() {}
+}
diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithUnresolvableClassNameAndOldMaxSdk.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithUnresolvableClassNameAndOldMaxSdk.java
new file mode 100644
index 0000000..298b367
--- /dev/null
+++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithUnresolvableClassNameAndOldMaxSdk.java
@@ -0,0 +1,7 @@
+package org.robolectric.annotation.processing.shadows;
+
+import org.robolectric.annotation.Implements;
+
+/** A Shadow that implements an unresolvable class name and an old max SDK */
+@Implements(className = "some.Stuff", maxSdk = 21)
+public class ShadowWithUnresolvableClassNameAndOldMaxSdk {}
diff --git a/resources/build-resources.sh b/resources/build-resources.sh
new file mode 100755
index 0000000..13cec25
--- /dev/null
+++ b/resources/build-resources.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+resourcesProjDir=`dirname $0`
+echo $resourcesProjDir
+
+aapt=$ANDROID_HOME/build-tools/26.0.1/aapt
+raw=$resourcesProjDir/src/test/resources/rawresources
+binary=$resourcesProjDir/src/test/resources/binaryresources
+javaSrc=$resourcesProjDir/src/test/java
+
+mkdir -p $binary
+mkdir -p $javaSrc
+
+$aapt p -v -f -m --auto-add-overlay -I $ANDROID_HOME/platforms/android-25/android.jar \
+  -S $raw/res -M $raw/AndroidManifest.xml \
+  -F $binary/resources.ap_ \
+  -J $javaSrc
diff --git a/resources/build.gradle b/resources/build.gradle
new file mode 100644
index 0000000..9bc1390
--- /dev/null
+++ b/resources/build.gradle
@@ -0,0 +1,19 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    api project(":utils")
+    api project(":annotations")
+    api project(":pluginapi")
+
+    api "com.google.guava:guava:$guavaJREVersion"
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "com.google.testing.compile:compile-testing:0.19"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+}
diff --git a/resources/src/main/java/org/robolectric/RoboSettings.java b/resources/src/main/java/org/robolectric/RoboSettings.java
new file mode 100644
index 0000000..a76d90d
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/RoboSettings.java
@@ -0,0 +1,24 @@
+package org.robolectric;
+
+/**
+ * Class that encapsulates reading global configuration options from the Java system properties file.
+ *
+ * @deprecated Don't put more stuff here.
+ */
+@Deprecated
+public class RoboSettings {
+
+  private static boolean useGlobalScheduler;
+
+  static {
+    useGlobalScheduler = Boolean.getBoolean("robolectric.scheduling.global");
+  }
+
+  public static boolean isUseGlobalScheduler() {
+    return useGlobalScheduler;
+  }
+
+  public static void setUseGlobalScheduler(boolean useGlobalScheduler) {
+    RoboSettings.useGlobalScheduler = useGlobalScheduler;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/ActivityData.java b/resources/src/main/java/org/robolectric/manifest/ActivityData.java
new file mode 100644
index 0000000..0da5d19
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/ActivityData.java
@@ -0,0 +1,210 @@
+package org.robolectric.manifest;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ActivityData {
+  private static final String ALLOW_TASK_REPARENTING = "allowTaskReparenting";
+  private static final String ALWAYS_RETAIN_TASK_STATE = "alwaysRetainTaskState";
+  private static final String CLEAR_TASK_ON_LAUNCH = "clearTaskOnLaunch";
+  private static final String CONFIG_CHANGES = "configChanges";
+  private static final String ENABLED = "enabled";
+  private static final String EXCLUDE_FROM_RECENTS = "excludeFromRecents";
+  private static final String EXPORTED = "exported";
+  private static final String FINISH_ON_TASK_LAUNCH = "finishOnTaskLaunch";
+  private static final String HARDWARE_ACCELERATED = "hardwareAccelerated";
+  private static final String LABEL = "label";
+  private static final String LAUNCH_MODE = "launchMode";
+  private static final String MULTIPROCESS = "multiprocess";
+  private static final String NAME = "name";
+  private static final String NO_HISTORY = "noHistory";
+  private static final String PARENT_ACTIVITY_NAME = "parentActivityName";
+  private static final String PERMISSION = "permission";
+  private static final String PROCESS = "process";
+  private static final String SCREEN_ORIENTATION = "screenOrientation";
+  private static final String STATE_NOT_NEEDED = "stateNotNeeded";
+  private static final String TARGET_ACTIVITY = "targetActivity";
+  private static final String TASK_AFFINITY = "taskAffinity";
+  private static final String THEME = "theme";
+  private static final String UI_OPTIONS = "uiOptions";
+  private static final String WINDOW_SOFT_INPUT_MODE = "windowSoftInputMode";
+
+  private final List<IntentFilterData> intentFilters;
+  private final HashMap<String, String> attrs;
+  // Non-null only for activity-alias'es.
+  private final ActivityData targetActivity;
+
+  /**
+   * XML Namespace used for android.
+   */
+  private final String xmlns;
+  private final MetaData metaData;
+
+  public ActivityData(Map<String, String> attrMap, List<IntentFilterData> intentFilters) {
+    this("android", attrMap, intentFilters);
+  }
+
+  public ActivityData(String xmlns, Map<String, String> attrMap, List<IntentFilterData> intentFilters) {
+    this(xmlns, attrMap, intentFilters, null, null);
+  }
+
+  public ActivityData(String xmlns, Map<String, String> attrMap, List<IntentFilterData> intentFilters,
+      ActivityData targetActivity, MetaData metaData) {
+    this.xmlns = xmlns;
+    attrs = new HashMap<>();
+    attrs.putAll(attrMap);
+    this.intentFilters = new ArrayList<>(intentFilters);
+    this.targetActivity = targetActivity;
+    this.metaData = metaData;
+  }
+
+  public boolean isAllowTaskReparenting() {
+    return getBooleanAttr(withXMLNS(ALLOW_TASK_REPARENTING), false);
+  }
+
+  public boolean isAlwaysRetainTaskState() {
+    return getBooleanAttr(withXMLNS(ALWAYS_RETAIN_TASK_STATE), false);
+  }
+
+  public boolean isClearTaskOnLaungh() {
+    return getBooleanAttr(withXMLNS(CLEAR_TASK_ON_LAUNCH), false);
+  }
+
+  public String getConfigChanges() {
+    return attrs.get(withXMLNS(CONFIG_CHANGES));
+  }
+
+  public boolean isEnabled() {
+    return getBooleanAttr(withXMLNS(ENABLED), true);
+  }
+
+  public boolean isExcludedFromRecents() {
+    return getBooleanAttr(withXMLNS(EXCLUDE_FROM_RECENTS), false);
+  }
+
+  public boolean isExported() {
+    boolean defaultValue = !intentFilters.isEmpty();
+    return getBooleanAttr(withXMLNS(EXPORTED), defaultValue);
+  }
+
+  public boolean isFinishOnTaskLaunch() {
+    return getBooleanAttr(withXMLNS(FINISH_ON_TASK_LAUNCH), false);
+  }
+
+  public boolean isHardwareAccelerated() {
+    return getBooleanAttr(withXMLNS(HARDWARE_ACCELERATED), false);
+  }
+
+  /* TODO: public boolean getIcon() {} */
+
+  public String getLabel() {
+    return attrs.get(withXMLNS(LABEL));
+  }
+
+  public String getLaunchMode() {
+    return attrs.get(withXMLNS(LAUNCH_MODE));
+  }
+
+  public boolean isMultiprocess() {
+    return getBooleanAttr(withXMLNS(MULTIPROCESS), false);
+  }
+
+  public String getName() {
+    return attrs.get(withXMLNS(NAME));
+  }
+
+  public boolean isNoHistory() {
+    return getBooleanAttr(withXMLNS(NO_HISTORY), false);
+  }
+
+  public String getParentActivityName() {
+    return attrs.get(withXMLNS(PARENT_ACTIVITY_NAME));
+  }
+
+  public String getPermission() {
+    return attrs.get(withXMLNS(PERMISSION));
+  }
+
+  public String getProcess() {
+    return attrs.get(withXMLNS(PROCESS));
+  }
+
+  public String getScreenOrientation() {
+    return attrs.get(withXMLNS(SCREEN_ORIENTATION));
+  }
+
+  public boolean isStateNotNeeded() {
+    return getBooleanAttr(withXMLNS(STATE_NOT_NEEDED), false);
+  }
+
+  public String getTargetActivityName() {
+    return attrs.get(withXMLNS(TARGET_ACTIVITY));
+  }
+
+  public String getTaskAffinity() {
+    return attrs.get(withXMLNS(TASK_AFFINITY));
+  }
+
+  /**
+   * Convenience accessor for value of android:THEME attribute.
+   *
+   * @return The theme attribute.
+   */
+  public String getThemeRef() {
+    return attrs.get(withXMLNS(THEME));
+  }
+
+  public String getUIOptions() {
+    return attrs.get(withXMLNS(UI_OPTIONS));
+  }
+
+  public String getWindowSoftInputMode() {
+    return attrs.get(withXMLNS(WINDOW_SOFT_INPUT_MODE));
+  }
+
+  private boolean getBooleanAttr(String n, boolean defaultValue) {
+    return (attrs.containsKey(n) ? Boolean.parseBoolean(attrs.get(n)): defaultValue);
+  }
+
+  private String withXMLNS(String attr) {
+    return withXMLNS(xmlns, attr);
+  }
+
+  /**
+   * Get the map for all attributes defined for the activity XML.
+   * @return map of attributes names to values from the manifest. Not null.
+   */
+  public Map<String, String> getAllAttributes() {
+    return attrs;
+  }
+
+  /**
+   * Get the intent filters defined for activity.
+   * @return A list of intent filters. Not null.
+   */
+  public List<IntentFilterData> getIntentFilters() {
+    return intentFilters;
+  }
+
+  public MetaData getMetaData() {
+    return metaData;
+  }
+
+  public ActivityData getTargetActivity() {
+    return targetActivity;
+  }
+
+  private static String withXMLNS(String xmlns, String attr) {
+    return String.format("%s:%s", xmlns, attr);
+  }
+
+  public static String getNameAttr(String xmlns) {
+    return withXMLNS(xmlns, NAME);
+  }
+
+  public static String getTargetAttr(String xmlns) {
+    return withXMLNS("android", TARGET_ACTIVITY);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/AndroidManifest.java b/resources/src/main/java/org/robolectric/manifest/AndroidManifest.java
new file mode 100644
index 0000000..82de00f
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/AndroidManifest.java
@@ -0,0 +1,849 @@
+package org.robolectric.manifest;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.robolectric.pluginapi.UsesSdk;
+import org.robolectric.res.Fs;
+import org.robolectric.res.ResourcePath;
+import org.robolectric.res.ResourceTable;
+import org.w3c.dom.Document;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+/**
+ * A wrapper for an Android App Manifest, which represents information about one's App to an Android
+ * system.
+ *
+ * @see <a href="https://developer.android.com/guide/topics/manifest/manifest-intro.html">Android
+ *     App Manifest</a>
+ */
+@SuppressWarnings("NewApi")
+public class AndroidManifest implements UsesSdk {
+  private final Path androidManifestFile;
+  private final Path resDirectory;
+  private final Path assetsDirectory;
+  private final String overridePackageName;
+  private final List<AndroidManifest> libraryManifests;
+  private final Path apkFile;
+
+  private boolean manifestIsParsed;
+
+  private String applicationName;
+  private String applicationLabel;
+  private String rClassName;
+  private String packageName;
+  private String processName;
+  private String themeRef;
+  private String labelRef;
+  private Integer minSdkVersion;
+  private Integer targetSdkVersion;
+  private Integer maxSdkVersion;
+  private int versionCode;
+  private String versionName;
+  private final Map<String, PermissionItemData> permissions = new HashMap<>();
+  private final Map<String, PermissionGroupItemData> permissionGroups = new HashMap<>();
+  private final List<ContentProviderData> providers = new ArrayList<>();
+  private final List<BroadcastReceiverData> receivers = new ArrayList<>();
+  private final Map<String, ServiceData> serviceDatas = new LinkedHashMap<>();
+  private final Map<String, ActivityData> activityDatas = new LinkedHashMap<>();
+  private final List<String> usedPermissions = new ArrayList<>();
+  private final Map<String, String> applicationAttributes = new HashMap<>();
+  private MetaData applicationMetaData;
+
+  private Boolean supportsBinaryResourcesMode;
+
+  /**
+   * Creates a Robolectric configuration using specified locations.
+   *
+   * @param androidManifestFile Location of the AndroidManifest.xml file.
+   * @param resDirectory Location of the res directory.
+   * @param assetsDirectory Location of the assets directory.
+   */
+  public AndroidManifest(Path androidManifestFile, Path resDirectory, Path assetsDirectory) {
+    this(androidManifestFile, resDirectory, assetsDirectory, null);
+  }
+
+  /**
+   * Creates a Robolectric configuration using specified values.
+   *
+   * @param androidManifestFile Location of the AndroidManifest.xml file.
+   * @param resDirectory Location of the res directory.
+   * @param assetsDirectory Location of the assets directory.
+   * @param overridePackageName Application package name.
+   */
+  public AndroidManifest(
+      Path androidManifestFile,
+      Path resDirectory,
+      Path assetsDirectory,
+      String overridePackageName) {
+    this(androidManifestFile, resDirectory, assetsDirectory, Collections.emptyList(), overridePackageName);
+  }
+
+  /**
+   * Creates a Robolectric configuration using specified values.
+   *
+   * @param androidManifestFile Location of the AndroidManifest.xml file.
+   * @param resDirectory Location of the res directory.
+   * @param assetsDirectory Location of the assets directory.
+   * @param libraryManifests List of dependency library manifests.
+   * @param overridePackageName Application package name.
+   */
+  public AndroidManifest(
+      Path androidManifestFile,
+      Path resDirectory,
+      Path assetsDirectory,
+      @Nonnull List<AndroidManifest> libraryManifests,
+      String overridePackageName) {
+    this(
+        androidManifestFile,
+        resDirectory,
+        assetsDirectory,
+        libraryManifests,
+        overridePackageName,
+        null);
+  }
+
+  public AndroidManifest(
+      Path androidManifestFile,
+      Path resDirectory,
+      Path assetsDirectory,
+      @Nonnull List<AndroidManifest> libraryManifests,
+      String overridePackageName,
+      Path apkFile) {
+    this.androidManifestFile = androidManifestFile;
+    this.resDirectory = resDirectory;
+    this.assetsDirectory = assetsDirectory;
+    this.overridePackageName = overridePackageName;
+    this.libraryManifests = libraryManifests;
+
+    this.packageName = overridePackageName;
+    this.apkFile = apkFile;
+  }
+
+  public String getThemeRef(String activityClassName) {
+    ActivityData activityData = getActivityData(activityClassName);
+    String themeRef = activityData != null ? activityData.getThemeRef() : null;
+    if (themeRef == null) {
+      themeRef = getThemeRef();
+    }
+    return themeRef;
+  }
+
+  public String getRClassName() throws Exception {
+    parseAndroidManifest();
+    return rClassName;
+  }
+
+  public Class getRClass() {
+    try {
+      String rClassName = getRClassName();
+      return Class.forName(rClassName);
+    } catch (Exception e) {
+      return null;
+    }
+  }
+
+  @SuppressWarnings("CatchAndPrintStackTrace")
+  void parseAndroidManifest() {
+    if (manifestIsParsed) {
+      return;
+    }
+
+    if (androidManifestFile != null && Files.exists(androidManifestFile)) {
+      try {
+        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+
+        DocumentBuilder db = dbf.newDocumentBuilder();
+        InputStream inputStream = Fs.getInputStream(androidManifestFile);
+        Document manifestDocument = db.parse(inputStream);
+        inputStream.close();
+
+        if (!packageNameIsOverridden()) {
+          packageName = getTagAttributeText(manifestDocument, "manifest", "package");
+        }
+
+        versionCode =
+            getTagAttributeIntValue(manifestDocument, "manifest", "android:versionCode", 0);
+        versionName = getTagAttributeText(manifestDocument, "manifest", "android:versionName");
+        rClassName = packageName + ".R";
+
+        Node applicationNode = findApplicationNode(manifestDocument);
+        if (applicationNode != null) {
+          NamedNodeMap attributes = applicationNode.getAttributes();
+          int attrCount = attributes.getLength();
+          for (int i = 0; i < attrCount; i++) {
+            Node attr = attributes.item(i);
+            applicationAttributes.put(attr.getNodeName(), attr.getTextContent());
+          }
+
+          applicationName = applicationAttributes.get("android:name");
+          applicationLabel = applicationAttributes.get("android:label");
+          processName = applicationAttributes.get("android:process");
+          themeRef = applicationAttributes.get("android:theme");
+          labelRef = applicationAttributes.get("android:label");
+
+          parseReceivers(applicationNode);
+          parseServices(applicationNode);
+          parseActivities(applicationNode);
+          parseApplicationMetaData(applicationNode);
+          parseContentProviders(applicationNode);
+        }
+
+        minSdkVersion =
+            getTagAttributeIntValue(manifestDocument, "uses-sdk", "android:minSdkVersion");
+
+        String targetSdkText =
+            getTagAttributeText(manifestDocument, "uses-sdk", "android:targetSdkVersion");
+        if (targetSdkText != null) {
+          // Support Android O Preview. This can be removed once Android O is officially launched.
+          targetSdkVersion = targetSdkText.equals("O") ? 26 : Integer.parseInt(targetSdkText);
+        }
+
+        maxSdkVersion =
+            getTagAttributeIntValue(manifestDocument, "uses-sdk", "android:maxSdkVersion");
+        if (processName == null) {
+          processName = packageName;
+        }
+
+        parseUsedPermissions(manifestDocument);
+        parsePermissions(manifestDocument);
+        parsePermissionGroups(manifestDocument);
+      } catch (Exception ignored) {
+        ignored.printStackTrace();
+      }
+    } else {
+      if (androidManifestFile != null) {
+        System.out.println("WARNING: No manifest file found at " + androidManifestFile + ".");
+        System.out.println("Falling back to the Android OS resources only.");
+        System.out.println(
+            "To remove this warning, annotate your test class with @Config(manifest=Config.NONE).");
+      }
+
+      if (packageName == null || packageName.equals("")) {
+        packageName = "org.robolectric.default";
+      }
+
+      rClassName = packageName + ".R";
+
+      if (androidManifestFile != null) {
+        System.err.println("No such manifest file: " + androidManifestFile);
+      }
+    }
+
+    manifestIsParsed = true;
+  }
+
+  private boolean packageNameIsOverridden() {
+    return overridePackageName != null && !overridePackageName.isEmpty();
+  }
+
+  private void parseUsedPermissions(Document manifestDocument) {
+    NodeList elementsByTagName = manifestDocument.getElementsByTagName("uses-permission");
+    int length = elementsByTagName.getLength();
+    for (int i = 0; i < length; i++) {
+      Node node = elementsByTagName.item(i).getAttributes().getNamedItem("android:name");
+      usedPermissions.add(node.getNodeValue());
+    }
+  }
+
+  private void parsePermissions(final Document manifestDocument) {
+    NodeList elementsByTagName = manifestDocument.getElementsByTagName("permission");
+
+    for (int i = 0; i < elementsByTagName.getLength(); i++) {
+      Node permissionNode = elementsByTagName.item(i);
+      final MetaData metaData = new MetaData(getChildrenTags(permissionNode, "meta-data"));
+      String name = getAttributeValue(permissionNode, "android:name");
+      permissions.put(
+          name,
+          new PermissionItemData(
+              name,
+              getAttributeValue(permissionNode, "android:label"),
+              getAttributeValue(permissionNode, "android:description"),
+              getAttributeValue(permissionNode, "android:permissionGroup"),
+              getAttributeValue(permissionNode, "android:protectionLevel"),
+              metaData));
+    }
+  }
+
+  private void parsePermissionGroups(final Document manifestDocument) {
+    NodeList elementsByTagName = manifestDocument.getElementsByTagName("permission-group");
+
+    for (int i = 0; i < elementsByTagName.getLength(); i++) {
+      Node permissionGroupNode = elementsByTagName.item(i);
+      final MetaData metaData = new MetaData(getChildrenTags(permissionGroupNode, "meta-data"));
+      String name = getAttributeValue(permissionGroupNode, "android:name");
+      permissionGroups.put(
+          name,
+          new PermissionGroupItemData(
+              name,
+              getAttributeValue(permissionGroupNode, "android:label"),
+              getAttributeValue(permissionGroupNode, "android:description"),
+              metaData));
+    }
+  }
+
+  private void parseContentProviders(Node applicationNode) {
+    for (Node contentProviderNode : getChildrenTags(applicationNode, "provider")) {
+      String name = getAttributeValue(contentProviderNode, "android:name");
+      String authorities = getAttributeValue(contentProviderNode, "android:authorities");
+      MetaData metaData = new MetaData(getChildrenTags(contentProviderNode, "meta-data"));
+
+      List<PathPermissionData> pathPermissionDatas = new ArrayList<>();
+      for (Node node : getChildrenTags(contentProviderNode, "path-permission")) {
+        pathPermissionDatas.add(new PathPermissionData(
+                getAttributeValue(node, "android:path"),
+                getAttributeValue(node, "android:pathPrefix"),
+                getAttributeValue(node, "android:pathPattern"),
+                getAttributeValue(node, "android:readPermission"),
+                getAttributeValue(node, "android:writePermission")
+        ));
+      }
+
+      providers.add(
+          new ContentProviderData(
+              resolveClassRef(name),
+              metaData,
+              authorities,
+              parseNodeAttributes(contentProviderNode),
+              pathPermissionDatas));
+    }
+  }
+
+  private @Nullable String getAttributeValue(Node parentNode, String attributeName) {
+    Node attributeNode = parentNode.getAttributes().getNamedItem(attributeName);
+    return attributeNode == null ? null : attributeNode.getTextContent();
+  }
+
+  private static HashMap<String, String> parseNodeAttributes(Node node) {
+    final NamedNodeMap attributes = node.getAttributes();
+    final int attrCount = attributes.getLength();
+    final HashMap<String, String> receiverAttrs = new HashMap<>(attributes.getLength());
+    for (int i = 0; i < attrCount; i++) {
+      Node attribute = attributes.item(i);
+      String value = attribute.getNodeValue();
+      if (value != null) {
+        receiverAttrs.put(attribute.getNodeName(), value);
+      }
+    }
+    return receiverAttrs;
+  }
+
+  private void parseReceivers(Node applicationNode) {
+    for (Node receiverNode : getChildrenTags(applicationNode, "receiver")) {
+      final HashMap<String, String> receiverAttrs = parseNodeAttributes(receiverNode);
+
+      String receiverName = resolveClassRef(receiverAttrs.get("android:name"));
+      receiverAttrs.put("android:name", receiverName);
+
+      MetaData metaData = new MetaData(getChildrenTags(receiverNode, "meta-data"));
+
+      final List<IntentFilterData> intentFilterData = parseIntentFilters(receiverNode);
+      BroadcastReceiverData receiver =
+          new BroadcastReceiverData(receiverAttrs, metaData, intentFilterData);
+      List<Node> intentFilters = getChildrenTags(receiverNode, "intent-filter");
+      for (Node intentFilterNode : intentFilters) {
+        for (Node actionNode : getChildrenTags(intentFilterNode, "action")) {
+          Node nameNode = actionNode.getAttributes().getNamedItem("android:name");
+          if (nameNode != null) {
+            receiver.addAction(nameNode.getTextContent());
+          }
+        }
+      }
+
+      receivers.add(receiver);
+    }
+  }
+
+  private void parseServices(Node applicationNode) {
+    for (Node serviceNode : getChildrenTags(applicationNode, "service")) {
+      final HashMap<String, String> serviceAttrs = parseNodeAttributes(serviceNode);
+
+      String serviceName = resolveClassRef(serviceAttrs.get("android:name"));
+      serviceAttrs.put("android:name", serviceName);
+
+      MetaData metaData = new MetaData(getChildrenTags(serviceNode, "meta-data"));
+
+      final List<IntentFilterData> intentFilterData = parseIntentFilters(serviceNode);
+      ServiceData service = new ServiceData(serviceAttrs, metaData, intentFilterData);
+      List<Node> intentFilters = getChildrenTags(serviceNode, "intent-filter");
+      for (Node intentFilterNode : intentFilters) {
+        for (Node actionNode : getChildrenTags(intentFilterNode, "action")) {
+          Node nameNode = actionNode.getAttributes().getNamedItem("android:name");
+          if (nameNode != null) {
+            service.addAction(nameNode.getTextContent());
+          }
+        }
+      }
+
+      serviceDatas.put(serviceName, service);
+    }
+  }
+
+  private void parseActivities(Node applicationNode) {
+    for (Node activityNode : getChildrenTags(applicationNode, "activity")) {
+      parseActivity(activityNode, false);
+    }
+
+    for (Node activityNode : getChildrenTags(applicationNode, "activity-alias")) {
+      parseActivity(activityNode, true);
+    }
+  }
+
+  private Node findApplicationNode(Document manifestDocument) {
+    NodeList applicationNodes = manifestDocument.getElementsByTagName("application");
+    if (applicationNodes.getLength() > 1) {
+      throw new RuntimeException("found " + applicationNodes.getLength() + " application elements");
+    }
+    return applicationNodes.item(0);
+  }
+
+  private void parseActivity(Node activityNode, boolean isAlias) {
+    final List<IntentFilterData> intentFilterData = parseIntentFilters(activityNode);
+    final MetaData metaData = new MetaData(getChildrenTags(activityNode, "meta-data"));
+    final HashMap<String, String> activityAttrs = parseNodeAttributes(activityNode);
+
+    String activityName = resolveClassRef(activityAttrs.get(ActivityData.getNameAttr("android")));
+    if (activityName == null) {
+      return;
+    }
+    ActivityData targetActivity = null;
+    if (isAlias) {
+      String targetName = resolveClassRef(activityAttrs.get(ActivityData.getTargetAttr("android")));
+      if (activityName == null) {
+        return;
+      }
+      // The target activity should have been parsed already so if it exists we should find it in
+      // activityDatas.
+      targetActivity = activityDatas.get(targetName);
+      activityAttrs.put(ActivityData.getTargetAttr("android"), targetName);
+    }
+    activityAttrs.put(ActivityData.getNameAttr("android"), activityName);
+    activityDatas.put(
+        activityName,
+        new ActivityData("android", activityAttrs, intentFilterData, targetActivity, metaData));
+  }
+
+  private List<IntentFilterData> parseIntentFilters(final Node activityNode) {
+    ArrayList<IntentFilterData> intentFilterDatas = new ArrayList<>();
+    for (Node n : getChildrenTags(activityNode, "intent-filter")) {
+      ArrayList<String> actionNames = new ArrayList<>();
+      ArrayList<String> categories = new ArrayList<>();
+      //should only be one action.
+      for (Node action : getChildrenTags(n, "action")) {
+        NamedNodeMap attributes = action.getAttributes();
+        Node actionNameNode = attributes.getNamedItem("android:name");
+        if (actionNameNode != null) {
+          actionNames.add(actionNameNode.getNodeValue());
+        }
+      }
+      for (Node category : getChildrenTags(n, "category")) {
+        NamedNodeMap attributes = category.getAttributes();
+        Node categoryNameNode = attributes.getNamedItem("android:name");
+        if (categoryNameNode != null) {
+          categories.add(categoryNameNode.getNodeValue());
+        }
+      }
+      IntentFilterData intentFilterData = new IntentFilterData(actionNames, categories);
+      intentFilterData = parseIntentFilterData(n, intentFilterData);
+      intentFilterDatas.add(intentFilterData);
+    }
+
+    return intentFilterDatas;
+  }
+
+  private IntentFilterData parseIntentFilterData(final Node intentFilterNode, IntentFilterData intentFilterData) {
+    for (Node n : getChildrenTags(intentFilterNode, "data")) {
+      NamedNodeMap attributes = n.getAttributes();
+      String host = null;
+      String port = null;
+
+      Node schemeNode = attributes.getNamedItem("android:scheme");
+      if (schemeNode != null) {
+        intentFilterData.addScheme(schemeNode.getNodeValue());
+      }
+
+      Node hostNode = attributes.getNamedItem("android:host");
+      if (hostNode != null) {
+        host = hostNode.getNodeValue();
+      }
+
+      Node portNode = attributes.getNamedItem("android:port");
+      if (portNode != null) {
+        port = portNode.getNodeValue();
+      }
+      intentFilterData.addAuthority(host, port);
+
+      Node pathNode = attributes.getNamedItem("android:path");
+      if (pathNode != null) {
+        intentFilterData.addPath(pathNode.getNodeValue());
+      }
+
+      Node pathPatternNode = attributes.getNamedItem("android:pathPattern");
+      if (pathPatternNode != null) {
+        intentFilterData.addPathPattern(pathPatternNode.getNodeValue());
+      }
+
+      Node pathPrefixNode = attributes.getNamedItem("android:pathPrefix");
+      if (pathPrefixNode != null) {
+        intentFilterData.addPathPrefix(pathPrefixNode.getNodeValue());
+      }
+
+      Node mimeTypeNode = attributes.getNamedItem("android:mimeType");
+      if (mimeTypeNode != null) {
+        intentFilterData.addMimeType(mimeTypeNode.getNodeValue());
+      }
+    }
+    return intentFilterData;
+  }
+
+  /***
+   * Allows ShadowPackageManager to provide
+   * a resource index for initialising the resource attributes in all the metadata elements
+   * @param resourceTable used for getting resource IDs from string identifiers
+   */
+  public void initMetaData(ResourceTable resourceTable) throws RoboNotFoundException {
+    if (!packageNameIsOverridden()) {
+      // packageName needs to be resolved
+      parseAndroidManifest();
+    }
+
+    if (applicationMetaData != null) {
+      applicationMetaData.init(resourceTable, packageName);
+    }
+    for (PackageItemData receiver : receivers) {
+      receiver.getMetaData().init(resourceTable, packageName);
+    }
+    for (ServiceData service : serviceDatas.values()) {
+      service.getMetaData().init(resourceTable, packageName);
+    }
+    for (ContentProviderData providerData : providers) {
+      providerData.getMetaData().init(resourceTable, packageName);
+    }
+  }
+
+  private void parseApplicationMetaData(Node applicationNode) {
+    applicationMetaData = new MetaData(getChildrenTags(applicationNode, "meta-data"));
+  }
+
+  private String resolveClassRef(String maybePartialClassName) {
+    return maybePartialClassName.startsWith(".")
+        ? packageName + maybePartialClassName
+        : maybePartialClassName;
+  }
+
+  private List<Node> getChildrenTags(final Node node, final String tagName) {
+    List<Node> children = new ArrayList<>();
+    for (int i = 0; i < node.getChildNodes().getLength(); i++) {
+      Node childNode = node.getChildNodes().item(i);
+      if (childNode.getNodeName().equalsIgnoreCase(tagName)) {
+        children.add(childNode);
+      }
+    }
+    return children;
+  }
+
+  private Integer getTagAttributeIntValue(final Document doc, final String tag, final String attribute) {
+    return getTagAttributeIntValue(doc, tag, attribute, null);
+  }
+
+  private Integer getTagAttributeIntValue(final Document doc, final String tag, final String attribute, final Integer defaultValue) {
+    String valueString = getTagAttributeText(doc, tag, attribute);
+    if (valueString != null) {
+      return Integer.parseInt(valueString);
+    }
+    return defaultValue;
+  }
+
+  public String getApplicationName() {
+    parseAndroidManifest();
+    return applicationName;
+  }
+
+  public String getActivityLabel(String activityClassName) {
+    parseAndroidManifest();
+    ActivityData data = getActivityData(activityClassName);
+    return (data != null && data.getLabel() != null) ? data.getLabel() : applicationLabel;
+  }
+
+  public String getPackageName() {
+    parseAndroidManifest();
+    return packageName;
+  }
+
+  public int getVersionCode() {
+    return versionCode;
+  }
+
+  public String getVersionName() {
+    return versionName;
+  }
+
+  public String getLabelRef() {
+    return labelRef;
+  }
+
+  /**
+   * Returns the minimum Android SDK version that this package expects to be runnable on, as
+   * specified in the manifest.
+   *
+   * <p>Note that if {@link #targetSdkVersion} isn't set, this value changes the behavior of some
+   * Android code (notably {@link android.content.SharedPreferences}) to emulate old bugs.
+   *
+   * @return the minimum SDK version, or Jelly Bean (16) by default
+   */
+  @Override
+  public int getMinSdkVersion() {
+    parseAndroidManifest();
+    return minSdkVersion == null ? 16 : minSdkVersion;
+  }
+
+  /**
+   * Returns the Android SDK version that this package prefers to be run on, as specified in the
+   * manifest.
+   *
+   * <p>Note that this value changes the behavior of some Android code (notably {@link
+   * android.content.SharedPreferences}) to emulate old bugs.
+   *
+   * @return the minimum SDK version, or Jelly Bean (16) by default
+   */
+  @Override
+  public int getTargetSdkVersion() {
+    parseAndroidManifest();
+    return targetSdkVersion == null ? getMinSdkVersion() : targetSdkVersion;
+  }
+
+  @Override
+  public Integer getMaxSdkVersion() {
+    parseAndroidManifest();
+    return maxSdkVersion;
+  }
+
+  public Map<String, String> getApplicationAttributes() {
+    parseAndroidManifest();
+    return applicationAttributes;
+  }
+
+  public String getProcessName() {
+    parseAndroidManifest();
+    return processName;
+  }
+
+  public Map<String, Object> getApplicationMetaData() {
+    parseAndroidManifest();
+    if (applicationMetaData == null) {
+      applicationMetaData = new MetaData(Collections.<Node>emptyList());
+    }
+    return applicationMetaData.getValueMap();
+  }
+
+  public ResourcePath getResourcePath() {
+    return new ResourcePath(getRClass(), resDirectory, assetsDirectory);
+  }
+
+  public List<ResourcePath> getIncludedResourcePaths() {
+    Collection<ResourcePath> resourcePaths = new LinkedHashSet<>(); // Needs stable ordering and no duplicates
+    resourcePaths.add(getResourcePath());
+    for (AndroidManifest libraryManifest : getLibraryManifests()) {
+      resourcePaths.addAll(libraryManifest.getIncludedResourcePaths());
+    }
+    return new ArrayList<>(resourcePaths);
+  }
+
+  public List<ContentProviderData> getContentProviders() {
+    parseAndroidManifest();
+    return providers;
+  }
+
+  public List<AndroidManifest> getLibraryManifests() {
+    assert(libraryManifests != null);
+    return Collections.unmodifiableList(libraryManifests);
+  }
+
+  /**
+   * Returns all transitively reachable manifests, including this one, in order and without
+   * duplicates.
+   */
+  public List<AndroidManifest> getAllManifests() {
+    Set<AndroidManifest> seenManifests = new HashSet<>();
+    List<AndroidManifest> uniqueManifests = new ArrayList<>();
+    addTransitiveManifests(seenManifests, uniqueManifests);
+    return uniqueManifests;
+  }
+
+  private void addTransitiveManifests(Set<AndroidManifest> unique, List<AndroidManifest> list) {
+    if (unique.add(this)) {
+      list.add(this);
+      for (AndroidManifest androidManifest : getLibraryManifests()) {
+        androidManifest.addTransitiveManifests(unique, list);
+      }
+    }
+  }
+
+  public Path getResDirectory() {
+    return resDirectory;
+  }
+
+  public Path getAssetsDirectory() {
+    return assetsDirectory;
+  }
+
+  public Path getAndroidManifestFile() {
+    return androidManifestFile;
+  }
+
+  public List<BroadcastReceiverData> getBroadcastReceivers() {
+    parseAndroidManifest();
+    return receivers;
+  }
+
+  public List<ServiceData> getServices() {
+    parseAndroidManifest();
+    return new ArrayList<>(serviceDatas.values());
+  }
+
+  public ServiceData getServiceData(String serviceClassName) {
+    parseAndroidManifest();
+    return serviceDatas.get(serviceClassName);
+  }
+
+  private static String getTagAttributeText(final Document doc, final String tag, final String attribute) {
+    NodeList elementsByTagName = doc.getElementsByTagName(tag);
+    for (int i = 0; i < elementsByTagName.getLength(); ++i) {
+      Node item = elementsByTagName.item(i);
+      Node namedItem = item.getAttributes().getNamedItem(attribute);
+      if (namedItem != null) {
+        return namedItem.getTextContent();
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof AndroidManifest)) {
+      return false;
+    }
+
+    AndroidManifest that = (AndroidManifest) o;
+
+    if (androidManifestFile != null ? !androidManifestFile.equals(that.androidManifestFile)
+        : that.androidManifestFile != null) {
+      return false;
+    }
+    if (resDirectory != null ? !resDirectory.equals(that.resDirectory)
+        : that.resDirectory != null) {
+      return false;
+    }
+    if (assetsDirectory != null ? !assetsDirectory.equals(that.assetsDirectory)
+        : that.assetsDirectory != null) {
+      return false;
+    }
+    if (overridePackageName != null ? !overridePackageName.equals(that.overridePackageName)
+        : that.overridePackageName != null) {
+      return false;
+    }
+    if (libraryManifests != null ? !libraryManifests.equals(that.libraryManifests)
+        : that.libraryManifests != null) {
+      return false;
+    }
+    return apkFile != null ? apkFile.equals(that.apkFile) : that.apkFile == null;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = androidManifestFile != null ? androidManifestFile.hashCode() : 0;
+    result = 31 * result + (resDirectory != null ? resDirectory.hashCode() : 0);
+    result = 31 * result + (assetsDirectory != null ? assetsDirectory.hashCode() : 0);
+    result = 31 * result + (overridePackageName != null ? overridePackageName.hashCode() : 0);
+    result = 31 * result + (libraryManifests != null ? libraryManifests.hashCode() : 0);
+    result = 31 * result + (apkFile != null ? apkFile.hashCode() : 0);
+    return result;
+  }
+
+  public ActivityData getActivityData(String activityClassName) {
+    parseAndroidManifest();
+    return activityDatas.get(activityClassName);
+  }
+
+  public String getThemeRef() {
+    return themeRef;
+  }
+
+  public Map<String, ActivityData> getActivityDatas() {
+    parseAndroidManifest();
+    return activityDatas;
+  }
+
+  public List<String> getUsedPermissions() {
+    parseAndroidManifest();
+    return usedPermissions;
+  }
+
+  public Map<String, PermissionItemData> getPermissions() {
+    parseAndroidManifest();
+    return permissions;
+  }
+
+  public Map<String, PermissionGroupItemData> getPermissionGroups() {
+    parseAndroidManifest();
+    return permissionGroups;
+  }
+
+  /**
+   * Returns data for the broadcast receiver with the provided name from this manifest. If no
+   * receiver with the class name can be found, returns null.
+   *
+   * @param className the fully resolved class name of the receiver
+   * @return data for the receiver or null if it cannot be found
+   */
+  public @Nullable BroadcastReceiverData getBroadcastReceiver(String className) {
+    parseAndroidManifest();
+    for (BroadcastReceiverData receiver : receivers) {
+      if (receiver.getName().equals(className)) {
+        return receiver;
+      }
+    }
+    return null;
+  }
+
+  public Path getApkFile() {
+    return apkFile;
+  }
+
+  /**
+   * @deprecated Do not use.
+   */
+  @Deprecated
+  @SuppressWarnings("InlineMeSuggester")
+  public final boolean supportsLegacyResourcesMode() {
+    return true;
+  }
+
+  /** @deprecated Do not use. */
+  @Deprecated
+  synchronized public boolean supportsBinaryResourcesMode() {
+    if (supportsBinaryResourcesMode == null) {
+      supportsBinaryResourcesMode = apkFile != null && Files.exists(apkFile);
+    }
+    return supportsBinaryResourcesMode;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/BroadcastReceiverData.java b/resources/src/main/java/org/robolectric/manifest/BroadcastReceiverData.java
new file mode 100644
index 0000000..41539a4
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/BroadcastReceiverData.java
@@ -0,0 +1,83 @@
+package org.robolectric.manifest;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class BroadcastReceiverData extends PackageItemData {
+
+  private static final String EXPORTED = "android:exported";
+  private static final String NAME = "android:name";
+  private static final String PERMISSION = "android:permission";
+  private static final String ENABLED = "android:enabled";
+
+  private final Map<String, String> attributes;
+  private final List<String> actions;
+  private List<IntentFilterData> intentFilters;
+
+  public BroadcastReceiverData(
+      Map<String, String> attributes, MetaData metaData, List<IntentFilterData> intentFilters) {
+    super(attributes.get(NAME), metaData);
+    this.attributes = attributes;
+    this.actions = new ArrayList<>();
+    this.intentFilters = new ArrayList<>(intentFilters);
+  }
+
+  public BroadcastReceiverData(String className, MetaData metaData) {
+    super(className, metaData);
+    this.actions = new ArrayList<>();
+    this.attributes = new HashMap<>();
+    intentFilters = new ArrayList<>();
+  }
+
+  public List<String> getActions() {
+    return actions;
+  }
+
+  public void addAction(String action) {
+    this.actions.add(action);
+  }
+
+  public void setPermission(final String permission) {
+    attributes.put(PERMISSION, permission);
+  }
+
+  public String getPermission() {
+    return attributes.get(PERMISSION);
+  }
+
+  /**
+   * Get the intent filters defined for the broadcast receiver.
+   *
+   * @return A list of intent filters.
+   */
+  public List<IntentFilterData> getIntentFilters() {
+    return intentFilters;
+  }
+
+  /**
+   * Get the map for all attributes defined for the broadcast receiver.
+   *
+   * @return map of attributes names to values from the manifest.
+   */
+  public Map<String, String> getAllAttributes() {
+    return attributes;
+  }
+
+  /**
+   * Returns whether this broadcast receiver is exported by checking the XML attribute.
+   *
+   * @return true if the broadcast receiver is exported
+   */
+  public boolean isExported() {
+    boolean defaultValue = !intentFilters.isEmpty();
+    return (attributes.containsKey(EXPORTED)
+        ? Boolean.parseBoolean(attributes.get(EXPORTED))
+        : defaultValue);
+  }
+
+  public boolean isEnabled() {
+    return attributes.containsKey(ENABLED) ? Boolean.parseBoolean(attributes.get(ENABLED)) : true;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/ContentProviderData.java b/resources/src/main/java/org/robolectric/manifest/ContentProviderData.java
new file mode 100644
index 0000000..b1c063a
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/ContentProviderData.java
@@ -0,0 +1,51 @@
+package org.robolectric.manifest;
+
+import java.util.List;
+import java.util.Map;
+
+public class ContentProviderData extends PackageItemData {
+  private static final String READ_PERMISSION = "android:readPermission";
+  private static final String WRITE_PERMISSION = "android:writePermission";
+  private static final String GRANT_URI_PERMISSION = "android:grantUriPermissions";
+  private static final String ENABLED = "android:enabled";
+
+  private final String authority;
+  private final Map<String, String> attributes;
+  private final List<PathPermissionData> pathPermissionDatas;
+
+  public ContentProviderData(
+      String className,
+      MetaData metaData,
+      String authority,
+      Map<String, String> attributes,
+      List<PathPermissionData> pathPermissionDatas) {
+    super(className, metaData);
+    this.authority = authority;
+    this.attributes = attributes;
+    this.pathPermissionDatas = pathPermissionDatas;
+  }
+
+  public String getAuthorities() {
+    return authority;
+  }
+
+  public String getReadPermission() {
+    return attributes.get(READ_PERMISSION);
+  }
+
+  public String getWritePermission() {
+    return attributes.get(WRITE_PERMISSION);
+  }
+
+  public List<PathPermissionData> getPathPermissionDatas() {
+    return pathPermissionDatas;
+  }
+
+  public boolean getGrantUriPermissions() {
+    return Boolean.parseBoolean(attributes.get(GRANT_URI_PERMISSION));
+  }
+
+  public boolean isEnabled() {
+    return attributes.containsKey(ENABLED) ? Boolean.parseBoolean(attributes.get(ENABLED)) : true;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/IntentFilterData.java b/resources/src/main/java/org/robolectric/manifest/IntentFilterData.java
new file mode 100644
index 0000000..d18b443
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/IntentFilterData.java
@@ -0,0 +1,112 @@
+package org.robolectric.manifest;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class IntentFilterData {
+  private final List<String> actions;
+  private final List<String> categories;
+  private final List<String> schemes;
+  private final List<String> mimeTypes;
+  private final List<DataAuthority> authorities;
+  private final List<String> paths;
+  private final List<String> pathPatterns;
+  private final List<String> pathPrefixes;
+
+  public IntentFilterData(List<String> actions, List<String> categories) {
+    this.actions = actions;
+    this.categories = new ArrayList<>(categories);
+    this.schemes = new ArrayList<>();
+    this.mimeTypes = new ArrayList<>();
+    this.authorities = new ArrayList<>();
+    this.paths = new ArrayList<>();
+    this.pathPatterns = new ArrayList<>();
+    this.pathPrefixes = new ArrayList<>();
+  }
+
+  public List<String> getActions() {
+    return actions;
+  }
+
+  public List<String> getCategories() {
+    return categories;
+  }
+
+  public List<String> getSchemes() {
+    return schemes;
+  }
+
+  public List<String> getMimeTypes() {
+    return mimeTypes;
+  }
+
+  public List<DataAuthority> getAuthorities() {
+    return authorities;
+  }
+
+  public List<String> getPaths() {
+    return paths;
+  }
+
+  public List<String> getPathPatterns() {
+    return pathPatterns;
+  }
+
+  public List<String> getPathPrefixes() {
+    return pathPrefixes;
+  }
+
+  public void addScheme(String scheme) {
+    if (scheme != null) {
+      schemes.add(scheme);
+    }
+  }
+
+  public void addMimeType(String mimeType) {
+    if (mimeType != null) {
+      mimeTypes.add(mimeType);
+    }
+  }
+
+  public void addPath(String path) {
+    if (path != null) {
+      paths.add(path);
+    }
+  }
+
+  public void addPathPattern(String pathPattern) {
+    if (pathPattern != null) {
+      pathPatterns.add(pathPattern);
+    }
+  }
+
+  public void addPathPrefix(String pathPrefix) {
+    if (pathPrefix != null) {
+      pathPrefixes.add(pathPrefix);
+    }
+  }
+
+  public void addAuthority(String host, String port) {
+    if (host != null) {
+      authorities.add(new DataAuthority(host, port));
+    }
+  }
+
+  public static class DataAuthority {
+    private String host;
+    private String port;
+
+    public DataAuthority(String host, String port) {
+      this.host = host;
+      this.port = port;
+    }
+
+    public String getHost() {
+      return host;
+    }
+
+    public String getPort() {
+      return port;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/MetaData.java b/resources/src/main/java/org/robolectric/manifest/MetaData.java
new file mode 100644
index 0000000..d6c8553
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/MetaData.java
@@ -0,0 +1,169 @@
+package org.robolectric.manifest;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.TypedResource;
+import org.robolectric.res.android.ResTable_config;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+
+public final class MetaData {
+  private final Map<String, Object> valueMap = new LinkedHashMap<>();
+  private final Map<String, VALUE_TYPE> typeMap = new LinkedHashMap<>();
+  private boolean initialised;
+
+  public MetaData(List<Node> nodes) {
+    for (Node metaNode : nodes) {
+      NamedNodeMap attributes = metaNode.getAttributes();
+      Node nameAttr = attributes.getNamedItem("android:name");
+      Node valueAttr = attributes.getNamedItem("android:value");
+      Node resourceAttr = attributes.getNamedItem("android:resource");
+
+      if (valueAttr != null) {
+        valueMap.put(nameAttr.getNodeValue(), valueAttr.getNodeValue());
+        typeMap.put(nameAttr.getNodeValue(), VALUE_TYPE.VALUE);
+      } else if (resourceAttr != null) {
+        valueMap.put(nameAttr.getNodeValue(), resourceAttr.getNodeValue());
+        typeMap.put(nameAttr.getNodeValue(), VALUE_TYPE.RESOURCE);
+      }
+    }
+  }
+
+  public void init(ResourceTable resourceTable, String packageName) throws RoboNotFoundException {
+    if (!initialised) {
+      for (Map.Entry<String,VALUE_TYPE> entry : typeMap.entrySet()) {
+        String value = valueMap.get(entry.getKey()).toString();
+        if (value.startsWith("@")) {
+          ResName resName = ResName.qualifyResName(value.substring(1), packageName, null);
+
+          switch (entry.getValue()) {
+            case RESOURCE:
+              // Was provided by resource attribute, store resource ID
+              valueMap.put(entry.getKey(), resourceTable.getResourceId(resName));
+              break;
+            case VALUE:
+              // Was provided by value attribute, need to inferFromValue it
+              TypedResource<?> typedRes = resourceTable.getValue(resName, new ResTable_config());
+              // The typed resource's data is always a String, so need to inferFromValue the value.
+              if (typedRes == null) {
+                throw new RoboNotFoundException(resName.getFullyQualifiedName());
+              }
+              switch (typedRes.getResType()) {
+                case BOOLEAN: case COLOR: case INTEGER: case FLOAT:
+                  valueMap.put(entry.getKey(), parseValue(typedRes.getData().toString()));
+                  break;
+                default:
+                  valueMap.put(entry.getKey(),typedRes.getData());
+              }
+              break;
+          }
+        } else if (entry.getValue() == VALUE_TYPE.VALUE) {
+          // Raw value, so inferFromValue it in to the appropriate type and store it
+          valueMap.put(entry.getKey(), parseValue(value));
+        }
+      }
+      // Finished parsing, mark as initialised
+      initialised = true;
+    }
+  }
+
+  public Map<String, Object> getValueMap() {
+    return valueMap;
+  }
+
+  private enum VALUE_TYPE {
+    RESOURCE,
+    VALUE
+  }
+
+  private Object parseValue(String value) {
+    if (value == null) {
+      return null;
+    } else if ("true".equals(value)) {
+      return true;
+    } else if ("false".equals(value)) {
+      return false;
+    } else if (value.startsWith("#")) {
+      // if it's a color, add it and continue
+      try {
+        return getColor(value);
+      } catch (NumberFormatException e) {
+            /* Not a color */
+      }
+    } else if (value.contains(".")) {
+      // most likely a float
+      try {
+        return Float.parseFloat(value);
+      } catch (NumberFormatException e) {
+        // Not a float
+      }
+    } else {
+      // if it's an int, add it and continue
+      try {
+        return Integer.parseInt(value);
+      } catch (NumberFormatException ei) {
+        // Not an int
+      }
+    }
+
+    // Not one of the above types, keep as String
+    return value;
+  }
+
+  // todo: this is copied from ResourceHelper, dedupe
+  /**
+   * Returns the color value represented by the given string value
+   * @param value the color value
+   * @return the color as an int
+   * @throws NumberFormatException if the conversion failed.
+   */
+  public static int getColor(String value) {
+    if (value != null) {
+      if (value.startsWith("#") == false) {
+        throw new NumberFormatException(
+            String.format("Color value '%s' must start with #", value));
+      }
+
+      value = value.substring(1);
+
+      // make sure it's not longer than 32bit
+      if (value.length() > 8) {
+        throw new NumberFormatException(String.format(
+            "Color value '%s' is too long. Format is either" +
+                "#AARRGGBB, #RRGGBB, #RGB, or #ARGB",
+            value));
+      }
+
+      if (value.length() == 3) { // RGB format
+        char[] color = new char[8];
+        color[0] = color[1] = 'F';
+        color[2] = color[3] = value.charAt(0);
+        color[4] = color[5] = value.charAt(1);
+        color[6] = color[7] = value.charAt(2);
+        value = new String(color);
+      } else if (value.length() == 4) { // ARGB format
+        char[] color = new char[8];
+        color[0] = color[1] = value.charAt(0);
+        color[2] = color[3] = value.charAt(1);
+        color[4] = color[5] = value.charAt(2);
+        color[6] = color[7] = value.charAt(3);
+        value = new String(color);
+      } else if (value.length() == 6) {
+        value = "FF" + value;
+      }
+
+      // this is a RRGGBB or AARRGGBB value
+
+      // Integer.parseInt will fail to inferFromValue strings like "ff191919", so we use
+      // a Long, but cast the result back into an int, since we know that we're only
+      // dealing with 32 bit values.
+      return (int)Long.parseLong(value, 16);
+    }
+
+    throw new NumberFormatException();
+  }
+
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/PackageItemData.java b/resources/src/main/java/org/robolectric/manifest/PackageItemData.java
new file mode 100644
index 0000000..862b7d7
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/PackageItemData.java
@@ -0,0 +1,30 @@
+package org.robolectric.manifest;
+
+import com.google.errorprone.annotations.InlineMe;
+
+public class PackageItemData {
+  protected final String name;
+  protected final MetaData metaData;
+
+  public PackageItemData(String name, MetaData metaData) {
+    this.metaData = metaData;
+    this.name = name;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  /**
+   * @deprecated - Use {@link #getName()} instead.
+   */
+  @Deprecated
+  @InlineMe(replacement = "this.getName()")
+  public final String getClassName() {
+    return getName();
+  }
+
+  public MetaData getMetaData() {
+    return metaData;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/PathPermissionData.java b/resources/src/main/java/org/robolectric/manifest/PathPermissionData.java
new file mode 100644
index 0000000..48820db
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/PathPermissionData.java
@@ -0,0 +1,17 @@
+package org.robolectric.manifest;
+
+public class PathPermissionData {
+  public final String path;
+  public final String pathPrefix;
+  public final String pathPattern;
+  public final String readPermission;
+  public final String writePermission;
+
+  PathPermissionData(String path, String pathPrefix, String pathPattern, String readPermission, String writePermission) {
+    this.path = path;
+    this.pathPrefix = pathPrefix;
+    this.pathPattern = pathPattern;
+    this.readPermission = readPermission;
+    this.writePermission = writePermission;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/PermissionGroupItemData.java b/resources/src/main/java/org/robolectric/manifest/PermissionGroupItemData.java
new file mode 100644
index 0000000..eb67888
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/PermissionGroupItemData.java
@@ -0,0 +1,26 @@
+package org.robolectric.manifest;
+
+/**
+ * Holds permission data from manifest.
+ */
+public class PermissionGroupItemData extends PackageItemData {
+
+  private final String label;
+  private final String description;
+
+  public PermissionGroupItemData(String name, String label, String description,
+      MetaData metaData) {
+    super(name, metaData);
+
+    this.label = label;
+    this.description = description;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/PermissionItemData.java b/resources/src/main/java/org/robolectric/manifest/PermissionItemData.java
new file mode 100644
index 0000000..938dc1a
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/PermissionItemData.java
@@ -0,0 +1,38 @@
+package org.robolectric.manifest;
+
+/**
+ * Holds permission data from manifest.
+ */
+public class PermissionItemData extends PackageItemData {
+
+  private final String label;
+  private final String description;
+  private final String permissionGroup;
+  private final String protectionLevel;
+
+  public PermissionItemData(String name, String label, String description,
+      String permissionGroup, String protectionLevel, MetaData metaData) {
+    super(name, metaData);
+
+    this.label = label;
+    this.description = description;
+    this.permissionGroup = permissionGroup;
+    this.protectionLevel = protectionLevel;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  public String getPermissionGroup() {
+    return permissionGroup;
+  }
+
+  public String getProtectionLevel() {
+    return protectionLevel;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/RoboNotFoundException.java b/resources/src/main/java/org/robolectric/manifest/RoboNotFoundException.java
new file mode 100644
index 0000000..ba05c26
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/RoboNotFoundException.java
@@ -0,0 +1,7 @@
+package org.robolectric.manifest;
+
+public class RoboNotFoundException extends Exception {
+  public RoboNotFoundException(String name) {
+    super(name);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/manifest/ServiceData.java b/resources/src/main/java/org/robolectric/manifest/ServiceData.java
new file mode 100644
index 0000000..82f9d87
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/manifest/ServiceData.java
@@ -0,0 +1,78 @@
+package org.robolectric.manifest;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Holds parsed service data from manifest.
+ */
+public class ServiceData extends PackageItemData {
+
+  private static final String EXPORTED = "android:exported";
+  private static final String NAME = "android:name";
+  private static final String PERMISSION = "android:permission";
+  private static final String ENABLED = "android:enabled";
+
+  private final Map<String, String> attributes;
+  private final List<String> actions;
+  private List<IntentFilterData> intentFilters;
+
+  public ServiceData(
+      Map<String, String> attributes, MetaData metaData, List<IntentFilterData> intentFilters) {
+    super(attributes.get(NAME), metaData);
+    this.attributes = attributes;
+    this.actions = new ArrayList<>();
+    this.intentFilters = new ArrayList<>(intentFilters);
+  }
+
+  public List<String> getActions() {
+    return actions;
+  }
+
+  public void addAction(String action) {
+    this.actions.add(action);
+  }
+
+  public void setPermission(final String permission) {
+    attributes.put(PERMISSION, permission);
+  }
+
+  public String getPermission() {
+    return attributes.get(PERMISSION);
+  }
+
+  /**
+   * Get the intent filters defined for the service.
+   *
+   * @return A list of intent filters.
+   */
+  public List<IntentFilterData> getIntentFilters() {
+    return intentFilters;
+  }
+
+  /**
+   * Get the map for all attributes defined for the service.
+   *
+   * @return map of attributes names to values from the manifest.
+   */
+  public Map<String, String> getAllAttributes() {
+    return attributes;
+  }
+
+  /**
+   * Returns whether this service is exported by checking the XML attribute.
+   *
+   * @return true if the service is exported
+   */
+  public boolean isExported() {
+    boolean defaultValue = !intentFilters.isEmpty();
+    return (attributes.containsKey(EXPORTED)
+        ? Boolean.parseBoolean(attributes.get(EXPORTED))
+        : defaultValue);
+  }
+
+  public boolean isEnabled() {
+    return attributes.containsKey(ENABLED) ? Boolean.parseBoolean(attributes.get(ENABLED)) : true;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/AttrData.java b/resources/src/main/java/org/robolectric/res/AttrData.java
new file mode 100644
index 0000000..f6b59a2
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/AttrData.java
@@ -0,0 +1,75 @@
+package org.robolectric.res;
+
+import java.util.List;
+
+public class AttrData {
+  private final String name;
+  private final String format;
+  private final List<Pair> pairs;
+
+  public AttrData(String name, String format, List<Pair> pairs) {
+    this.name = name;
+    this.format = format;
+    this.pairs = pairs;
+  }
+
+  public String getFormat() {
+    return format;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getValueFor(String key) {
+    if (pairs == null) return null;
+    for (Pair pair : pairs) {
+      if (pair.name.equals(key)) {
+        return pair.value;
+      }
+    }
+    return null;
+  }
+
+  public boolean isValue(String value) {
+    if (pairs == null) {
+      return false;
+    } else {
+      for (Pair pair : pairs) {
+        if (pair.value.equals(value)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  @Override public String toString() {
+    StringBuilder builder = new StringBuilder("AttrData{name='")
+        .append(name)
+        .append("', format='")
+        .append(format)
+        .append('\'');
+    if (pairs != null) {
+      for (Pair p : pairs) {
+        builder.append(' ')
+            .append(p.name)
+            .append("='")
+            .append(p.value)
+            .append('\'');
+      }
+    }
+    builder.append('}');
+    return builder.toString();
+  }
+
+  public static class Pair {
+    private final String name;
+    private final String value;
+
+    public Pair(String name, String value) {
+      this.name = name;
+      this.value = value;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/AttributeResource.java b/resources/src/main/java/org/robolectric/res/AttributeResource.java
new file mode 100644
index 0000000..e3fd66a
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/AttributeResource.java
@@ -0,0 +1,103 @@
+package org.robolectric.res;
+
+import java.util.regex.Pattern;
+import javax.annotation.Nonnull;
+
+public class AttributeResource {
+  public static final String ANDROID_NS = "http://schemas.android.com/apk/res/android";
+  public static final String ANDROID_RES_NS_PREFIX = "http://schemas.android.com/apk/res/";
+  public static final String RES_AUTO_NS_URI = "http://schemas.android.com/apk/res-auto";
+
+  public static final String NULL_VALUE = "@null";
+  public static final String EMPTY_VALUE = "@empty";
+  public static final Pattern IS_RESOURCE_REFERENCE = Pattern.compile("^\\s*@");
+
+  public final @Nonnull ResName resName;
+  public final @Nonnull String value;
+  public final @Nonnull String trimmedValue;
+  public final @Nonnull String contextPackageName;
+  private final Integer referenceResId;
+
+  public AttributeResource(@Nonnull ResName resName, @Nonnull String value, @Nonnull String contextPackageName) {
+    this(resName, value, contextPackageName, null);
+  }
+
+  public AttributeResource(@Nonnull ResName resName, @Nonnull String value, @Nonnull String contextPackageName, Integer referenceResId) {
+    this.referenceResId = referenceResId;
+    if (!resName.type.equals("attr")) throw new IllegalStateException("\"" + resName.getFullyQualifiedName() + "\" unexpected");
+
+    this.resName = resName;
+    this.value = value;
+    this.trimmedValue = value.trim();
+    this.contextPackageName = contextPackageName;
+  }
+
+  public boolean isResourceReference() {
+    return isResourceReference(trimmedValue);
+  }
+
+  public @Nonnull ResName getResourceReference() {
+    if (!isResourceReference()) throw new RuntimeException("not a resource reference: " + this);
+    return ResName.qualifyResName(deref(trimmedValue).replace("+", ""), contextPackageName, "style");
+  }
+
+  public boolean isStyleReference() {
+    return isStyleReference(trimmedValue);
+  }
+
+  public ResName getStyleReference() {
+    if (!isStyleReference()) throw new RuntimeException("not a style reference: " + this);
+    return ResName.qualifyResName(value.substring(1), contextPackageName, "attr");
+  }
+
+  public boolean isNull() {
+    return NULL_VALUE.equals(trimmedValue);
+  }
+
+  public boolean isEmpty() {
+    return EMPTY_VALUE.equals(trimmedValue);
+  }
+
+  @Override
+  public String toString() {
+    return "Attribute{" +
+        "name='" + resName + '\'' +
+        ", value='" + value + '\'' +
+        ", contextPackageName='" + contextPackageName + '\'' +
+        '}';
+  }
+
+  public static boolean isResourceReference(String value) {
+    return IS_RESOURCE_REFERENCE.matcher(value).find() && !isNull(value);
+  }
+
+  public static @Nonnull ResName getResourceReference(String value, String defPackage, String defType) {
+    if (!isResourceReference(value)) throw new IllegalArgumentException("not a resource reference: " + value);
+    return ResName.qualifyResName(deref(value).replace("+", ""), defPackage, defType);
+  }
+
+  private static @Nonnull String deref(@Nonnull String value) {
+    return value.substring(value.indexOf('@') + 1);
+  }
+
+  public static boolean isStyleReference(String value) {
+    return value.startsWith("?");
+  }
+
+  public static ResName getStyleReference(String value, String defPackage, String defType) {
+    if (!isStyleReference(value)) throw new IllegalArgumentException("not a style reference: " + value);
+    return ResName.qualifyResName(value.substring(1), defPackage, defType);
+  }
+
+  public static boolean isNull(String value) {
+    return NULL_VALUE.equals(value);
+  }
+
+  public static boolean isEmpty(String value) {
+    return EMPTY_VALUE.equals(value);
+  }
+
+  public Integer getReferenceResId() {
+    return referenceResId;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/DirBaseNameFilter.java b/resources/src/main/java/org/robolectric/res/DirBaseNameFilter.java
new file mode 100644
index 0000000..4d2cced
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/DirBaseNameFilter.java
@@ -0,0 +1,34 @@
+package org.robolectric.res;
+
+import java.nio.file.Path;
+import java.util.function.Predicate;
+
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+class DirBaseNameFilter implements Predicate<Path> {
+  private final String prefix;
+  private final String prefixDash;
+
+  DirBaseNameFilter(String prefix) {
+    this.prefix = prefix;
+    this.prefixDash = prefix + "-";
+  }
+
+  @Override
+  public boolean test(Path file) {
+    String fileName = nameWithoutTrailingSeparator(file);
+    return fileName.equals(prefix) || fileName.startsWith(prefixDash);
+  }
+
+  /**
+   * It sure seems like a bug that Path#getFileName() returns "name/" for paths inside a jar, but
+   * "name" for paths on a regular filesystem. It's always a normal slash, even on Windows. :-p
+   */
+  private String nameWithoutTrailingSeparator(Path file) {
+    String fileName = file.getFileName().toString();
+    int trailingSlash = fileName.indexOf('/');
+    if (trailingSlash != -1) {
+      fileName = fileName.substring(0, trailingSlash);
+    }
+    return fileName;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/DocumentLoader.java b/resources/src/main/java/org/robolectric/res/DocumentLoader.java
new file mode 100644
index 0000000..e7d543f
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/DocumentLoader.java
@@ -0,0 +1,46 @@
+package org.robolectric.res;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.robolectric.util.Logger;
+
+@SuppressWarnings("NewApi")
+public abstract class DocumentLoader {
+  protected final String packageName;
+  private final Path resourceBase;
+
+  public DocumentLoader(String packageName, Path resourceBase) {
+    this.packageName = packageName;
+    this.resourceBase = resourceBase;
+  }
+
+  public void load(String folderBaseName) throws IOException {
+    for (Path dir : Fs.listFiles(resourceBase, new DirBaseNameFilter(folderBaseName))) {
+      loadFile(dir);
+    }
+  }
+
+  private void loadFile(Path dir) throws IOException {
+    if (!Files.exists(dir)) {
+      throw new RuntimeException("no such directory " + dir);
+    }
+    if (!Files.isDirectory(dir)) {
+      return;
+    }
+
+    Qualifiers qualifiers;
+    try {
+      qualifiers = Qualifiers.fromParentDir(dir);
+    } catch (IllegalArgumentException e) {
+      Logger.warn(dir + ": " + e.getMessage());
+      return;
+    }
+
+    for (Path file : Fs.listFiles(dir, path -> path.getFileName().toString().endsWith(".xml"))) {
+      loadResourceXmlFile(new XmlContext(packageName, file, qualifiers));
+    }
+  }
+
+  protected abstract void loadResourceXmlFile(XmlContext xmlContext);
+}
diff --git a/resources/src/main/java/org/robolectric/res/DrawableResourceLoader.java b/resources/src/main/java/org/robolectric/res/DrawableResourceLoader.java
new file mode 100644
index 0000000..956ae71
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/DrawableResourceLoader.java
@@ -0,0 +1,64 @@
+package org.robolectric.res;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.robolectric.util.Logger;
+
+/** DrawableResourceLoader */
+@SuppressWarnings("NewApi")
+public class DrawableResourceLoader {
+  private final PackageResourceTable resourceTable;
+
+  DrawableResourceLoader(PackageResourceTable resourceTable) {
+    this.resourceTable = resourceTable;
+  }
+
+  void findDrawableResources(ResourcePath resourcePath) throws IOException {
+    Path[] files = Fs.listFiles(resourcePath.getResourceBase());
+    if (files != null) {
+      for (Path f : files) {
+        if (Files.isDirectory(f) && f.getFileName().toString().startsWith("drawable")) {
+          listDrawableResources(f, "drawable");
+        } else if (Files.isDirectory(f) && f.getFileName().toString().startsWith("mipmap")) {
+          listDrawableResources(f, "mipmap");
+        }
+      }
+    }
+  }
+
+  private void listDrawableResources(Path dir, String type) throws IOException {
+    Path[] files = Fs.listFiles(dir);
+    if (files != null) {
+      Qualifiers qualifiers;
+      try {
+        qualifiers = Qualifiers.fromParentDir(dir);
+      } catch (IllegalArgumentException e) {
+        Logger.warn(dir + ": " + e.getMessage());
+        return;
+      }
+
+      for (Path f : files) {
+        String name = f.getFileName().toString();
+        if (name.startsWith(".")) continue;
+
+        String shortName;
+        boolean isNinePatch;
+        if (name.endsWith(".xml")) {
+          // already handled, do nothing...
+          continue;
+        } else if (name.endsWith(".9.png")) {
+          String[] tokens = name.split("\\.9\\.png$", -1);
+          shortName = tokens[0];
+          isNinePatch = true;
+        } else {
+          shortName = Fs.baseNameFor(f);
+          isNinePatch = false;
+        }
+
+        XmlContext fakeXmlContext = new XmlContext(resourceTable.getPackageName(), f, qualifiers);
+        resourceTable.addResource(type, shortName, new FileTypedResource.Image(f, isNinePatch, fakeXmlContext));
+      }
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/EmptyStyle.java b/resources/src/main/java/org/robolectric/res/EmptyStyle.java
new file mode 100644
index 0000000..f75317e
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/EmptyStyle.java
@@ -0,0 +1,13 @@
+package org.robolectric.res;
+
+public class EmptyStyle implements Style {
+  @Override
+  public AttributeResource getAttrValue(ResName resName) {
+    return null;
+  }
+
+  @Override
+  public String toString() {
+    return "Empty Style";
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/FileTypedResource.java b/resources/src/main/java/org/robolectric/res/FileTypedResource.java
new file mode 100644
index 0000000..c1873d4
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/FileTypedResource.java
@@ -0,0 +1,40 @@
+package org.robolectric.res;
+
+import java.nio.file.Path;
+
+@SuppressWarnings("NewApi")
+public class FileTypedResource extends TypedResource<String> {
+  private final Path path;
+
+  FileTypedResource(Path path, ResType resType, XmlContext xmlContext) {
+    super(Fs.externalize(path), resType, xmlContext);
+
+    this.path = path;
+  }
+
+  @Override public boolean isFile() {
+    return true;
+  }
+
+  public Path getPath() {
+    return path;
+  }
+
+  @Override
+  public boolean isXml() {
+    return path.toString().endsWith("xml");
+  }
+
+  public static class Image extends FileTypedResource {
+    private final boolean isNinePatch;
+
+    Image(Path path, boolean isNinePatch, XmlContext xmlContext) {
+      super(path, ResType.DRAWABLE, xmlContext);
+      this.isNinePatch = isNinePatch;
+    }
+
+    public boolean isNinePatch() {
+      return isNinePatch;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/Fs.java b/resources/src/main/java/org/robolectric/res/Fs.java
new file mode 100644
index 0000000..dd45876
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/Fs.java
@@ -0,0 +1,275 @@
+package org.robolectric.res;
+
+import com.google.errorprone.annotations.InlineMe;
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.FileStore;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.nio.file.Paths;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.UserPrincipalLookupService;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.util.Util;
+
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+abstract public class Fs {
+
+  @GuardedBy("ZIP_FILESYSTEMS")
+  private static final Map<Path, FsWrapper> ZIP_FILESYSTEMS = new HashMap<>();
+
+  /**
+   * @deprecated Use {@link File#toPath()} instead.
+   */
+  @Deprecated
+  @InlineMe(replacement = "file.toPath()")
+  public static Path newFile(File file) {
+    return file.toPath();
+  }
+
+  /**
+   * @deprecated Use {@link #fromUrl(String)} instead.
+   */
+  @Deprecated
+  @InlineMe(replacement = "Fs.fromUrl(path)", imports = "org.robolectric.res.Fs")
+  public static Path fileFromPath(String path) {
+    return Fs.fromUrl(path);
+  }
+
+  public static FileSystem forJar(URL url) {
+    return forJar(Paths.get(toUri(url)));
+  }
+
+  public static FileSystem forJar(Path jarFile) {
+    try {
+      return getJarFs(jarFile);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Use this method instead of {@link Paths#get(String, String...)} or {@link Paths#get(URI)}.
+   *
+   * <p>Supports "file:path", "jar:file:jarfile.jar!/path", and plain old paths.
+   *
+   * <p>For JAR files, automatically open and cache filesystems.
+   */
+  public static Path fromUrl(String urlString) {
+    if (urlString.startsWith("file:") || urlString.startsWith("jar:")) {
+      URL url;
+      try {
+        url = new URL(urlString);
+      } catch (MalformedURLException e) {
+        throw new RuntimeException("Failed to resolve path from " + urlString, e);
+      }
+      return fromUrl(url);
+    } else {
+      return Paths.get(urlString);
+    }
+  }
+
+  /** Isn't this what {@link Paths#get(URI)} should do? */
+  public static Path fromUrl(URL url) {
+    try {
+      switch (url.getProtocol()) {
+        case "file":
+          return Paths.get(url.toURI());
+        case "jar":
+          String[] parts = url.getPath().split("!", 0);
+          Path jarFile = Paths.get(new URI(parts[0]).toURL().getFile());
+          FileSystem fs = getJarFs(jarFile);
+          return fs.getPath(parts[1].substring(1));
+        default:
+          throw new IllegalArgumentException("unsupported fs type for '" + url + "'");
+      }
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to resolve path from " + url, e);
+    }
+  }
+
+  public static URI toUri(URL url) {
+    try {
+      return url.toURI();
+    } catch (URISyntaxException e) {
+      throw new IllegalArgumentException("invalid URL: " + url, e);
+    }
+  }
+
+  static String baseNameFor(Path path) {
+    String name = path.getFileName().toString();
+    int dotIndex = name.indexOf(".");
+    return dotIndex >= 0 ? name.substring(0, dotIndex) : name;
+  }
+
+  public static InputStream getInputStream(Path path) throws IOException {
+    // otherwise we get ClosedByInterruptException, meh
+    if (path.toUri().getScheme().equals("file")) {
+      return new BufferedInputStream(new FileInputStream(path.toFile()));
+    }
+    return new BufferedInputStream(Files.newInputStream(path));
+  }
+
+  public static byte[] getBytes(Path path) throws IOException {
+    return Util.readBytes(getInputStream(path));
+  }
+
+  public static Path[] listFiles(Path path) throws IOException {
+    try (Stream<Path> list = Files.list(path)) {
+      return list.toArray(Path[]::new);
+    }
+  }
+
+  public static Path[] listFiles(Path path, final Predicate<Path> filter) throws IOException {
+    try (Stream<Path> list = Files.list(path)) {
+      return list.filter(filter).toArray(Path[]::new);
+    }
+  }
+
+  public static String[] listFileNames(Path path) {
+    File[] files = path.toFile().listFiles();
+    if (files == null) return null;
+    String[] strings = new String[files.length];
+    for (int i = 0; i < files.length; i++) {
+      strings[i] = files[i].getName();
+    }
+    return strings;
+  }
+
+  public static Path join(Path path, String... pathParts) {
+    for (String pathPart : pathParts) {
+      path = path.resolve(pathPart);
+    }
+    return path;
+  }
+
+  public static String externalize(Path path) {
+    if (path.getFileSystem().provider().getScheme().equals("file")) {
+      return path.toString();
+    } else {
+      return path.toUri().toString();
+    }
+  }
+
+  /** Returns a reference-counted Jar FileSystem, possibly one that was previously returned. */
+  private static FileSystem getJarFs(Path jarFile) throws IOException {
+    Path key = jarFile.toAbsolutePath();
+
+    synchronized (ZIP_FILESYSTEMS) {
+      FsWrapper fs = ZIP_FILESYSTEMS.get(key);
+      if (fs == null) {
+        fs = new FsWrapper(FileSystems.newFileSystem(key, (ClassLoader) null), key);
+        fs.incrRefCount();
+
+        ZIP_FILESYSTEMS.put(key, fs);
+      } else {
+        fs.incrRefCount();
+      }
+
+      return fs;
+    }
+  }
+
+  @SuppressWarnings("NewApi")
+  private static class FsWrapper extends FileSystem {
+    private final FileSystem delegate;
+    private final Path jarFile;
+
+    @GuardedBy("this")
+    private int refCount;
+
+    public FsWrapper(FileSystem delegate, Path jarFile) {
+      this.delegate = delegate;
+      this.jarFile = jarFile;
+    }
+
+    synchronized void incrRefCount() {
+      refCount++;
+    }
+
+    synchronized void decrRefCount() throws IOException {
+      if (--refCount == 0) {
+        synchronized (ZIP_FILESYSTEMS) {
+          ZIP_FILESYSTEMS.remove(jarFile);
+        }
+        delegate.close();
+      }
+    }
+
+    @Override
+    public FileSystemProvider provider() {
+      return delegate.provider();
+    }
+
+    @Override
+    public void close() throws IOException {
+      decrRefCount();
+    }
+
+    @Override
+    public boolean isOpen() {
+      return delegate.isOpen();
+    }
+
+    @Override
+    public boolean isReadOnly() {
+      return delegate.isReadOnly();
+    }
+
+    @Override
+    public String getSeparator() {
+      return delegate.getSeparator();
+    }
+
+    @Override
+    public Iterable<Path> getRootDirectories() {
+      return delegate.getRootDirectories();
+    }
+
+    @Override
+    public Iterable<FileStore> getFileStores() {
+      return delegate.getFileStores();
+    }
+
+    @Override
+    public Set<String> supportedFileAttributeViews() {
+      return delegate.supportedFileAttributeViews();
+    }
+
+    @Override
+    public Path getPath(String first, String... more) {
+      return delegate.getPath(first, more);
+    }
+
+    @Override
+    public PathMatcher getPathMatcher(String syntaxAndPattern) {
+      return delegate.getPathMatcher(syntaxAndPattern);
+    }
+
+    @Override
+    public UserPrincipalLookupService getUserPrincipalLookupService() {
+      return delegate.getUserPrincipalLookupService();
+    }
+
+    @Override
+    public WatchService newWatchService() throws IOException {
+      return delegate.newWatchService();
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/FsFile.java b/resources/src/main/java/org/robolectric/res/FsFile.java
new file mode 100644
index 0000000..07a5a87
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/FsFile.java
@@ -0,0 +1,21 @@
+package org.robolectric.res;
+
+import java.nio.file.Path;
+
+/** @deprecated Use {@link Path} instead. */
+@Deprecated
+@SuppressWarnings("NewApi")
+public interface FsFile extends Path {
+
+  /** @deprecated use {@link Fs#externalize(Path)} instead. */
+  @Deprecated
+  default String getPath() {
+    return Fs.externalize(this);
+  }
+
+  /** @deprecated use {@link Path#resolve(Path)} instead. */
+  @Deprecated
+  default Path join(String name) {
+    return this.resolve(name);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/NodeHandler.java b/resources/src/main/java/org/robolectric/res/NodeHandler.java
new file mode 100644
index 0000000..69a1fd6
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/NodeHandler.java
@@ -0,0 +1,91 @@
+package org.robolectric.res;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+class NodeHandler {
+  private static final Pattern ATTR_RE = Pattern.compile("([^\\[]*)(?:\\[@(.+)='(.+)'])?");
+
+  private final Map<String, ElementHandler> subElementHandlers = new HashMap<>();
+
+  private static class ElementHandler extends HashMap<String, AttrHandler> {
+    final NodeHandler nodeHandler;
+
+    private ElementHandler(NodeHandler nodeHandler) {
+      this.nodeHandler = nodeHandler;
+    }
+  }
+
+  private static class AttrHandler extends HashMap<String, NodeHandler> {
+  }
+
+  NodeHandler findMatchFor(XMLStreamReader xml) {
+    String tagName = xml.getLocalName();
+    ElementHandler elementHandler = subElementHandlers.get(tagName);
+    if (elementHandler == null) {
+      elementHandler = subElementHandlers.get("*");
+    }
+    if (elementHandler != null) {
+      for (Map.Entry<String, AttrHandler> entry : elementHandler.entrySet()) {
+        String attrName = entry.getKey();
+        String attributeValue = xml.getAttributeValue(null, attrName);
+        if (attributeValue != null) {
+          AttrHandler attrHandler = entry.getValue();
+          NodeHandler nodeHandler = attrHandler.get(attributeValue);
+          if (nodeHandler != null) {
+            return nodeHandler;
+          }
+        }
+      }
+
+      return elementHandler.nodeHandler;
+    }
+
+    return null;
+  }
+
+  public NodeHandler addHandler(String matchExpr, NodeHandler subHandler) {
+    Matcher attrMatcher = ATTR_RE.matcher(matchExpr);
+    if (attrMatcher.find()) {
+      String elementName = attrMatcher.group(1);
+      String attrName = attrMatcher.group(2);
+      String attrValue = attrMatcher.group(3);
+
+      if (elementName == null || elementName.isEmpty()) {
+        elementName = "*";
+      }
+
+      ElementHandler elementHandler = subElementHandlers.get(elementName);
+      if (elementHandler == null) {
+        elementHandler = new ElementHandler(attrName == null ? subHandler : null);
+        subElementHandlers.put(elementName, elementHandler);
+      }
+
+      if (attrName != null) {
+        AttrHandler attrHandler = elementHandler.get(attrName);
+        if (attrHandler == null) {
+          attrHandler = new AttrHandler();
+          elementHandler.put(attrName, attrHandler);
+        }
+        attrHandler.put(attrValue, subHandler);
+      }
+    } else {
+      throw new RuntimeException("unknown pattern " + matchExpr);
+    }
+
+    return this;
+  }
+
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+  }
+
+  public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+  }
+
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/PackageResourceTable.java b/resources/src/main/java/org/robolectric/res/PackageResourceTable.java
new file mode 100644
index 0000000..7d43871
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/PackageResourceTable.java
@@ -0,0 +1,156 @@
+package org.robolectric.res;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import javax.annotation.Nonnull;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.builder.XmlBlock;
+
+/**
+ * A {@link ResourceTable} for a single package, e.g: "android" / ox01
+ */
+public class PackageResourceTable implements ResourceTable {
+
+  private final ResBunch resources = new ResBunch();
+  private final BiMap<Integer, ResName> resourceTable = HashBiMap.create();
+
+  private final ResourceIdGenerator androidResourceIdGenerator = new ResourceIdGenerator(0x01);
+  private final String packageName;
+  private int packageIdentifier;
+
+
+  public PackageResourceTable(String packageName) {
+    this.packageName = packageName;
+  }
+
+  @Override
+  public String getPackageName() {
+    return packageName;
+  }
+
+  int getPackageIdentifier() {
+    return packageIdentifier;
+  }
+
+  @Override
+  public Integer getResourceId(ResName resName) {
+    Integer id = resourceTable.inverse().get(resName);
+    if (id == null && resName != null && resName.name.contains(".")) {
+      // try again with underscores (in case we're looking in the compile-time resources, where
+      // we haven't read XML declarations and only know what the R.class tells us).
+      id =
+          resourceTable
+              .inverse()
+              .get(new ResName(resName.packageName, resName.type, underscorize(resName.name)));
+    }
+    return id != null ? id : 0;
+  }
+
+  @Override
+  public ResName getResName(int resourceId) {
+    return resourceTable.get(resourceId);
+  }
+
+  @Override
+  public TypedResource getValue(@Nonnull ResName resName, ResTable_config config) {
+    return resources.get(resName, config);
+  }
+
+  @Override
+  public TypedResource getValue(int resId, ResTable_config config) {
+    return resources.get(getResName(resId), config);
+  }
+
+  @Override public XmlBlock getXml(ResName resName, ResTable_config config) {
+    FileTypedResource fileTypedResource = getFileResource(resName, config);
+    if (fileTypedResource == null || !fileTypedResource.isXml()) {
+      return null;
+    } else {
+      return XmlBlock.create(fileTypedResource.getPath(), resName.packageName);
+    }
+  }
+
+  @Override public InputStream getRawValue(ResName resName, ResTable_config config) {
+    FileTypedResource fileTypedResource = getFileResource(resName, config);
+    if (fileTypedResource == null) {
+      return null;
+    } else {
+      Path file = fileTypedResource.getPath();
+      try {
+        return file == null ? null : Fs.getInputStream(file);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  private FileTypedResource getFileResource(ResName resName, ResTable_config config) {
+    TypedResource typedResource = resources.get(resName, config);
+    if (!(typedResource instanceof FileTypedResource)) {
+      return null;
+    } else {
+      return (FileTypedResource) typedResource;
+    }
+  }
+
+  @Override
+  public InputStream getRawValue(int resId, ResTable_config config) {
+    return getRawValue(getResName(resId), config);
+  }
+
+  @Override
+  public void receive(Visitor visitor) {
+    resources.receive(visitor);
+  }
+
+  void addResource(int resId, String type, String name) {
+      if (ResourceIds.isFrameworkResource(resId)) {
+        androidResourceIdGenerator.record(resId, type, name);
+      }
+      ResName resName = new ResName(packageName, type, name);
+      int resIdPackageIdentifier = ResourceIds.getPackageIdentifier(resId);
+      if (getPackageIdentifier() == 0) {
+        this.packageIdentifier = resIdPackageIdentifier;
+      } else if (getPackageIdentifier() != resIdPackageIdentifier) {
+        throw new IllegalArgumentException("Incompatible package for " + packageName + ":" + type + "/" + name + " with resId " + resIdPackageIdentifier + " to ResourceIndex with packageIdentifier " + getPackageIdentifier());
+      }
+
+      ResName existingEntry = resourceTable.put(resId, resName);
+      if (existingEntry != null && !existingEntry.equals(resName)) {
+        throw new IllegalArgumentException("ResId " + Integer.toHexString(resId) + " mapped to both " + resName + " and " + existingEntry);
+      }
+  }
+
+  void addResource(String type, String name, TypedResource value) {
+    ResName resName = new ResName(packageName, type, name);
+
+    // compound style names were previously registered with underscores (TextAppearance_Small)
+    // because they came from R.style; re-register with dots.
+    ResName resNameWithUnderscores = new ResName(packageName, type, underscorize(name));
+    Integer oldId = resourceTable.inverse().get(resNameWithUnderscores);
+    if (oldId != null) {
+      resourceTable.forcePut(oldId, resName);
+    }
+
+    Integer id = resourceTable.inverse().get(resName);
+    if (id == null && isAndroidPackage(resName)) {
+      id = androidResourceIdGenerator.generate(type, name);
+      ResName existing = resourceTable.put(id, resName);
+      if (existing != null) {
+        throw new IllegalStateException(resName + " assigned ID to already existing " + existing);
+      }
+    }
+    resources.put(resName, value);
+  }
+
+  private boolean isAndroidPackage(ResName resName) {
+    return "android".equals(resName.packageName);
+  }
+
+  private static String underscorize(String s) {
+    return s == null ? null : s.replace('.', '_');
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/Plural.java b/resources/src/main/java/org/robolectric/res/Plural.java
new file mode 100644
index 0000000..f77e737
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/Plural.java
@@ -0,0 +1,37 @@
+package org.robolectric.res;
+
+public class Plural {
+  final String quantity, string;
+  final int num;
+  final boolean usedInEnglish;
+
+  Plural(String quantity, String string) {
+    this.quantity = quantity;
+    this.string = string;
+    if ("zero".equals(quantity)) {
+      num = 0;
+      usedInEnglish = false;
+    } else if ("one".equals(quantity)) {
+      num = 1;
+      usedInEnglish = true;
+    } else if ("two".equals(quantity)) {
+      num = 2;
+      usedInEnglish = false;
+    } else if ("other".equals(quantity)) {
+      num = -1;
+      usedInEnglish = true;
+    } else {
+      num = -1;
+      usedInEnglish = true;
+    }
+  }
+
+  public String getString() {
+    return string;
+  }
+
+  @Override
+  public String toString() {
+    return quantity + "(" + num + "): " + string;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/PluralRules.java b/resources/src/main/java/org/robolectric/res/PluralRules.java
new file mode 100644
index 0000000..f2ee5cf
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/PluralRules.java
@@ -0,0 +1,19 @@
+package org.robolectric.res;
+
+import java.util.List;
+
+public class PluralRules extends TypedResource<List<Plural>> {
+  public PluralRules(List<Plural> data, ResType resType, XmlContext xmlContext) {
+    super(data, resType, xmlContext);
+  }
+
+  public Plural find(int quantity) {
+    for (Plural p : getData()) {
+      if (p.num == quantity && p.usedInEnglish) return p;
+    }
+    for (Plural p : getData()) {
+      if (p.num == -1) return p;
+    }
+    return null;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/Qualifiers.java b/resources/src/main/java/org/robolectric/res/Qualifiers.java
new file mode 100644
index 0000000..ac42300
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/Qualifiers.java
@@ -0,0 +1,180 @@
+package org.robolectric.res;
+
+import java.nio.file.Path;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.robolectric.res.android.ConfigDescription;
+import org.robolectric.res.android.ResTable_config;
+
+/**
+ * Android qualifers as defined by
+ * https://developer.android.com/guide/topics/resources/providing-resources.html
+ */
+@SuppressWarnings("NewApi")
+public class Qualifiers {
+  private static final Pattern DIR_QUALIFIER_PATTERN = Pattern.compile("^[^-]+(?:-([^/]*))?/?$");
+
+  // Matches a version qualifier like "v14". Parentheses capture the numeric
+  // part for easy retrieval with Matcher.group(2).
+  private static final Pattern SCREEN_WIDTH_PATTERN = Pattern.compile("^w([0-9]+)dp");
+  private static final Pattern SMALLEST_SCREEN_WIDTH_PATTERN = Pattern.compile("^sw([0-9]+)dp");
+  private static final Pattern VERSION_QUALIFIER_PATTERN = Pattern.compile("(v)([0-9]+)$");
+  private static final Pattern ORIENTATION_QUALIFIER_PATTERN = Pattern.compile("(land|port)");
+
+  private final String qualifiers;
+  private final ResTable_config config;
+
+  public static Qualifiers parse(String qualifiers) {
+    return parse(qualifiers, true);
+  }
+
+  public static Qualifiers parse(String qualifiers, boolean applyVersionForCompat) {
+    final ResTable_config config = new ResTable_config();
+    if (!qualifiers.isEmpty()
+        && !ConfigDescription.parse(qualifiers, config, applyVersionForCompat)) {
+      throw new IllegalArgumentException(
+          "failed to parse qualifiers '"
+              + qualifiers
+              + "'. See"
+              + " https://developer.android.com/guide/topics/resources/providing-resources.html#QualifierRules"
+              + " for expected format.");
+    }
+
+    return new Qualifiers(qualifiers, config);
+  }
+
+  protected Qualifiers(String qualifiers, ResTable_config config) {
+    this.qualifiers = qualifiers;
+    this.config = config;
+  }
+
+  public ResTable_config getConfig() {
+    return config;
+  }
+
+  @Override
+  public String toString() {
+    return qualifiers;
+  }
+
+  public static Qualifiers fromParentDir(Path parentDir) {
+    if (parentDir == null) {
+      return parse("");
+    } else {
+      String parentDirName = parentDir.getFileName().toString();
+      Matcher matcher = DIR_QUALIFIER_PATTERN.matcher(parentDirName);
+      if (!matcher.find()) {
+        throw new IllegalStateException(parentDirName);
+      }
+      String qualifiers = matcher.group(1);
+      return parse(qualifiers != null ? qualifiers : "");
+    }
+  }
+
+  /**
+   * @deprecated Use {@link android.os.Build.VERSION#SDK_INT} instead.
+   */
+  @Deprecated
+  public static int getPlatformVersion(String qualifiers) {
+    Matcher m = VERSION_QUALIFIER_PATTERN.matcher(qualifiers);
+    if (m.find()) {
+      return Integer.parseInt(m.group(2));
+    }
+    return -1;
+  }
+
+  /**
+   * @deprecated Use {@link android.content.res.Configuration#smallestScreenWidthDp} instead.
+   */
+  @Deprecated
+  public static int getSmallestScreenWidth(String qualifiers) {
+    for (String qualifier : qualifiers.split("-", 0)) {
+      Matcher matcher = SMALLEST_SCREEN_WIDTH_PATTERN.matcher(qualifier);
+      if (matcher.find()) {
+        return Integer.parseInt(matcher.group(1));
+      }
+    }
+
+    return -1;
+  }
+
+  /**
+   * If the Config already has a version qualifier, do nothing. Otherwise, add a version
+   * qualifier for the target api level (which comes from the manifest or Config.sdk()).
+   *
+   * @deprecated Figure something else out.
+   */
+  @Deprecated
+  public static String addPlatformVersion(String qualifiers, int apiLevel) {
+    int versionQualifierApiLevel = Qualifiers.getPlatformVersion(qualifiers);
+    if (versionQualifierApiLevel == -1) {
+      if (qualifiers.length() > 0) {
+        qualifiers += "-";
+      }
+      qualifiers += "v" + apiLevel;
+    }
+    return qualifiers;
+  }
+
+  /**
+   * If the Config already has a {@code sw} qualifier, do nothing. Otherwise, add a {@code sw}
+   * qualifier for the given width.
+   *
+   * @deprecated Use {@link android.content.res.Configuration#smallestScreenWidthDp} instead.
+   */
+  @Deprecated
+  public static String addSmallestScreenWidth(String qualifiers, int smallestScreenWidth) {
+    int qualifiersSmallestScreenWidth = Qualifiers.getSmallestScreenWidth(qualifiers);
+    if (qualifiersSmallestScreenWidth == -1) {
+      if (qualifiers.length() > 0) {
+        qualifiers += "-";
+      }
+      qualifiers += "sw" + smallestScreenWidth + "dp";
+    }
+    return qualifiers;
+  }
+
+  /**
+   * @deprecated Use {@link android.content.res.Configuration#screenWidthDp} instead.
+   */
+  @Deprecated
+  public static int getScreenWidth(String qualifiers) {
+    for (String qualifier : qualifiers.split("-", 0)) {
+      Matcher matcher = SCREEN_WIDTH_PATTERN.matcher(qualifier);
+      if (matcher.find()) {
+        return Integer.parseInt(matcher.group(1));
+      }
+    }
+
+    return -1;
+  }
+
+  /**
+   * @deprecated Use {@link android.content.res.Configuration#screenWidthDp} instead.
+   */
+  @Deprecated
+  public static String addScreenWidth(String qualifiers, int screenWidth) {
+    int qualifiersScreenWidth = Qualifiers.getScreenWidth(qualifiers);
+    if (qualifiersScreenWidth == -1) {
+      if (qualifiers.length() > 0) {
+        qualifiers += "-";
+      }
+      qualifiers += "w" + screenWidth + "dp";
+    }
+    return qualifiers;
+  }
+
+  /**
+   * @deprecated Use {@link android.content.res.Configuration#orientation} instead.
+   */
+  @Deprecated
+  public static String getOrientation(String qualifiers) {
+    for (String qualifier : qualifiers.split("-", 0)) {
+      Matcher matcher = ORIENTATION_QUALIFIER_PATTERN.matcher(qualifier);
+      if (matcher.find()) {
+        return matcher.group(1);
+      }
+    }
+    return null;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/RawResourceLoader.java b/resources/src/main/java/org/robolectric/res/RawResourceLoader.java
new file mode 100644
index 0000000..a029ec6
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/RawResourceLoader.java
@@ -0,0 +1,48 @@
+package org.robolectric.res;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import org.robolectric.util.Logger;
+
+@SuppressWarnings("NewApi")
+public class RawResourceLoader {
+  private final ResourcePath resourcePath;
+
+  public RawResourceLoader(ResourcePath resourcePath) {
+    this.resourcePath = resourcePath;
+  }
+
+  public void loadTo(PackageResourceTable resourceTable) throws IOException {
+    load(resourceTable, "raw");
+    load(resourceTable, "drawable");
+  }
+
+  public void load(PackageResourceTable resourceTable, String folderBaseName) throws IOException {
+    Path resourceBase = resourcePath.getResourceBase();
+    for (Path dir : Fs.listFiles(resourceBase, new DirBaseNameFilter(folderBaseName))) {
+      loadRawFiles(resourceTable, folderBaseName, dir);
+    }
+  }
+
+  private void loadRawFiles(PackageResourceTable resourceTable, String resourceType, Path rawDir)
+      throws IOException {
+    Qualifiers qualifiers;
+    try {
+      qualifiers = Qualifiers.fromParentDir(rawDir);
+    } catch (IllegalArgumentException e) {
+      Logger.warn(rawDir + ": " + e.getMessage());
+      return;
+    }
+
+    for (Path file : Fs.listFiles(rawDir)) {
+      String fileBaseName = Fs.baseNameFor(file);
+      resourceTable.addResource(
+          resourceType,
+          fileBaseName,
+          new FileTypedResource(
+              file,
+              ResType.FILE,
+              new XmlContext(resourceTable.getPackageName(), file, qualifiers)));
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResBunch.java b/resources/src/main/java/org/robolectric/res/ResBunch.java
new file mode 100644
index 0000000..f0b39df
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResBunch.java
@@ -0,0 +1,35 @@
+package org.robolectric.res;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import org.robolectric.res.android.ResTable_config;
+
+public class ResBunch {
+  private final Map<String, ResBundle> types = new LinkedHashMap<>();
+
+  public void put(ResName resName, TypedResource value) {
+    ResBundle bundle = getBundle(resName.type);
+    bundle.put(resName, value);
+  }
+
+  private ResBundle getBundle(String attrType) {
+    ResBundle bundle = types.get(attrType);
+    if (bundle == null) {
+      bundle = new ResBundle();
+      types.put(attrType, bundle);
+    }
+    return bundle;
+  }
+
+  public TypedResource get(@Nonnull ResName resName, ResTable_config config) {
+    ResBundle bundle = getBundle(resName.type);
+    return bundle.get(resName, config);
+  }
+
+  void receive(ResourceTable.Visitor visitor) {
+    for (ResBundle resBundle : types.values()) {
+      resBundle.receive(visitor);
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResBundle.java b/resources/src/main/java/org/robolectric/res/ResBundle.java
new file mode 100644
index 0000000..52da22c
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResBundle.java
@@ -0,0 +1,66 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.util.Logger;
+
+public class ResBundle {
+  private final ResMap valuesMap = new ResMap();
+
+  public void put(ResName resName, TypedResource value) {
+    valuesMap.put(resName, value);
+  }
+
+  public TypedResource get(ResName resName, ResTable_config config) {
+    return valuesMap.pick(resName, config);
+  }
+
+  public void receive(ResourceTable.Visitor visitor) {
+    for (final Map.Entry<ResName, List<TypedResource>> entry : valuesMap.map.entrySet()) {
+      visitor.visit(entry.getKey(), entry.getValue());
+    }
+  }
+
+  static class ResMap {
+    private final Map<ResName, List<TypedResource>> map = new HashMap<>();
+
+    public TypedResource pick(ResName resName, ResTable_config toMatch) {
+      List<TypedResource> values = map.get(resName);
+      if (values == null || values.size() == 0) return null;
+
+      TypedResource bestMatchSoFar = null;
+      for (TypedResource candidate : values) {
+        ResTable_config candidateConfig = candidate.getConfig();
+        if (candidateConfig.match(toMatch)) {
+          if (bestMatchSoFar == null || candidateConfig.isBetterThan(bestMatchSoFar.getConfig(), toMatch)) {
+            bestMatchSoFar = candidate;
+          }
+        }
+      }
+
+      if (Logger.loggingEnabled()) {
+        Logger.debug("Picked '%s' for %s for qualifiers '%s' (%d candidates)",
+            bestMatchSoFar == null ? "<none>" : bestMatchSoFar.getXmlContext().getQualifiers().toString(),
+            resName.getFullyQualifiedName(),
+            toMatch,
+            values.size());
+      }
+      return bestMatchSoFar;
+    }
+
+    public void put(ResName resName, TypedResource value) {
+      if (!map.containsKey(resName)) {
+        map.put(resName, new ArrayList<>());
+      }
+
+      map.get(resName).add(value);
+    }
+
+    public int size() {
+      return map.size();
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResName.java b/resources/src/main/java/org/robolectric/res/ResName.java
new file mode 100644
index 0000000..18ebc6e
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResName.java
@@ -0,0 +1,169 @@
+package org.robolectric.res;
+
+import java.io.File;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nonnull;
+
+@SuppressWarnings("NewApi")
+public class ResName {
+  public static final String ID_TYPE = "id";
+
+  private static final Pattern FQN_PATTERN = Pattern.compile("^([^:]*):([^/]+)/(.+)$");
+  private static final int NAMESPACE = 1;
+  private static final int TYPE = 2;
+  private static final int NAME = 3;
+
+  public final @Nonnull String packageName;
+  public final @Nonnull String type;
+  public final @Nonnull String name;
+
+  public final int hashCode;
+
+  public ResName(@Nonnull String packageName, @Nonnull String type, @Nonnull String name) {
+    this.packageName = packageName;
+    this.type = type.trim();
+    this.name = name.trim();
+
+    hashCode = computeHashCode();
+  }
+
+  public ResName(@Nonnull String fullyQualifiedName) {
+    Matcher matcher = FQN_PATTERN.matcher(fullyQualifiedName.trim());
+    if (!matcher.find()) {
+      throw new IllegalStateException("\"" + fullyQualifiedName + "\" is not fully qualified");
+    }
+    packageName = matcher.group(NAMESPACE);
+    type = matcher.group(TYPE).trim();
+    name = matcher.group(NAME).trim();
+
+    hashCode = computeHashCode();
+    if (packageName.equals("xmlns"))
+      throw new IllegalStateException("\"" + fullyQualifiedName + "\" unexpected");
+  }
+
+  /** Returns the fully qualified resource name if null if the resource could not be qualified. */
+  public static String qualifyResourceName(
+      @Nonnull String possiblyQualifiedResourceName,
+      String defaultPackageName,
+      String defaultType) {
+    ResName resName = qualifyResName(possiblyQualifiedResourceName, defaultPackageName, defaultType);
+    return resName != null ? resName.getFullyQualifiedName() : null;
+  }
+
+  public static ResName qualifyResName(@Nonnull String possiblyQualifiedResourceName, ResName defaults) {
+    return qualifyResName(possiblyQualifiedResourceName, defaults.packageName, defaults.type);
+  }
+
+  public static ResName qualifyResName(@Nonnull String possiblyQualifiedResourceName, String defaultPackageName, String defaultType) {
+    int indexOfColon = possiblyQualifiedResourceName.indexOf(':');
+    int indexOfSlash = possiblyQualifiedResourceName.indexOf('/');
+    String type = null;
+    String packageName = null;
+    String name = possiblyQualifiedResourceName;
+    if (indexOfColon > indexOfSlash) {
+      if (indexOfSlash > 0) {
+        type = possiblyQualifiedResourceName.substring(0, indexOfSlash);
+      }
+      packageName = possiblyQualifiedResourceName.substring(indexOfSlash + 1, indexOfColon);
+      name =  possiblyQualifiedResourceName.substring(indexOfColon + 1);
+    } else if (indexOfSlash > indexOfColon) {
+      if (indexOfColon > 0) {
+        packageName = possiblyQualifiedResourceName.substring(0, indexOfColon);
+      }
+      type = possiblyQualifiedResourceName.substring(indexOfColon + 1, indexOfSlash);
+      name = possiblyQualifiedResourceName.substring(indexOfSlash + 1);
+    }
+
+    if ((type == null && defaultType == null) || (packageName == null && defaultPackageName == null)) {
+      return null;
+    }
+
+    if (packageName == null) {
+      packageName = defaultPackageName;
+    } else if ("*android".equals(packageName)) {
+      packageName = "android";
+    }
+
+    return new ResName(packageName, type == null ? defaultType : type, name);
+  }
+
+  public static String qualifyResName(String possiblyQualifiedResourceName, String contextPackageName) {
+    if (possiblyQualifiedResourceName == null) {
+      return null;
+    }
+
+    if (AttributeResource.isNull(possiblyQualifiedResourceName)) {
+      return null;
+    }
+
+    // Was not able to fully qualify the resource name
+    String fullyQualifiedResourceName = qualifyResourceName(possiblyQualifiedResourceName, contextPackageName, null);
+    if (fullyQualifiedResourceName == null) {
+      return null;
+    }
+
+    return fullyQualifiedResourceName.replaceAll("[@+]", "");
+  }
+
+  public static ResName qualifyFromFilePath(@Nonnull final String packageName, @Nonnull final String filePath) {
+    final File file = new File(filePath);
+    final String type = file.getParentFile().getName().split("-", 0)[0];
+    final String name = Fs.baseNameFor(file.toPath());
+
+    return new ResName(packageName, type, name);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ResName)) return false;
+
+    ResName resName = (ResName) o;
+
+    if (hashCode() != resName.hashCode()) return false;
+
+    if (!packageName.equals(resName.packageName)) return false;
+    if (!type.equals(resName.type)) return false;
+    if (!name.equals(resName.name)) return false;
+
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    return hashCode;
+  }
+
+  @Override
+  public String toString() {
+    return "ResName{" + getFullyQualifiedName() + "}";
+  }
+
+  public String getFullyQualifiedName() {
+    return packageName + ":" + type + "/" + name;
+  }
+
+  public String getNamespaceUri() {
+    return "http://schemas.android.com/apk/res/" + packageName;
+  }
+
+  public ResName withPackageName(String packageName) {
+    if (packageName.equals(this.packageName)) return this;
+    return new ResName(packageName, type, name);
+  }
+
+  public void mustBe(String expectedType) {
+    if (!type.equals(expectedType)) {
+      throw new RuntimeException(
+          "expected " + getFullyQualifiedName() + " to be a " + expectedType + ", is a " + type);
+    }
+  }
+
+  private int computeHashCode() {
+    int result = packageName.hashCode();
+    result = 31 * result + type.hashCode();
+    result = 31 * result + name.hashCode();
+    return result;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResType.java b/resources/src/main/java/org/robolectric/res/ResType.java
new file mode 100644
index 0000000..ea6ab92
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResType.java
@@ -0,0 +1,79 @@
+package org.robolectric.res;
+
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+public enum ResType {
+  DRAWABLE,
+  ATTR_DATA,
+  BOOLEAN,
+  COLOR,
+  COLOR_STATE_LIST,
+  DIMEN,
+  FILE,
+  FLOAT,
+  FRACTION,
+  INTEGER,
+  LAYOUT,
+  STYLE,
+  CHAR_SEQUENCE,
+  CHAR_SEQUENCE_ARRAY,
+  INTEGER_ARRAY,
+  TYPED_ARRAY,
+  NULL;
+
+  private static final Pattern DIMEN_RE = Pattern.compile("^\\d+(dp|dip|sp|pt|px|mm|in)$");
+
+  @Nullable
+  public static ResType inferType(String itemString) {
+    ResType itemResType = ResType.inferFromValue(itemString);
+    if (itemResType == ResType.CHAR_SEQUENCE) {
+      if (AttributeResource.isStyleReference(itemString)) {
+        itemResType = ResType.STYLE;
+      } else if (itemString.equals("@null")) {
+        itemResType = ResType.NULL;
+      } else if (AttributeResource.isResourceReference(itemString)) {
+        // This is a reference; no type info needed.
+        itemResType = null;
+      }
+    }
+    return itemResType;
+  }
+
+  /**
+   * Parses a resource value to infer the type
+   */
+  public static ResType inferFromValue(String value) {
+    if (value.startsWith("#")) {
+      return COLOR;
+    } else if ("true".equals(value) || "false".equals(value)) {
+      return BOOLEAN;
+    } else if (DIMEN_RE.matcher(value).find()) {
+      return DIMEN;
+    } else if (isInteger(value)) {
+      return INTEGER;
+    } else if (isFloat(value)) {
+      return FRACTION;
+    } else {
+      return CHAR_SEQUENCE;
+    }
+  }
+
+  private static boolean isInteger(String value) {
+    try {
+      Integer.parseInt(value);
+      return true;
+    } catch (NumberFormatException nfe) {
+      return false;
+    }
+  }
+
+  private static boolean isFloat(String value) {
+    try {
+      Float.parseFloat(value);
+      return true;
+    } catch (NumberFormatException nfe) {
+      return false;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourceIdGenerator.java b/resources/src/main/java/org/robolectric/res/ResourceIdGenerator.java
new file mode 100644
index 0000000..6863c79
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceIdGenerator.java
@@ -0,0 +1,64 @@
+package org.robolectric.res;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Tracks resource ids and generates new unique values.
+ */
+public class ResourceIdGenerator {
+
+  private final Map<String, TypeTracker> typeInfo = new HashMap<>();
+  private int packageIdentifier;
+
+  private static class TypeTracker {
+    private int typeIdentifier;
+    private int currentMaxEntry;
+
+    TypeTracker(int typeIdentifier) {
+      this.typeIdentifier = typeIdentifier;
+    }
+
+    void record(int entryIdentifier) {
+      currentMaxEntry = Math.max(currentMaxEntry, entryIdentifier);
+    }
+
+    public int getFreeIdentifier() {
+      return ++currentMaxEntry;
+    }
+
+    public int getTypeIdentifier() {
+      return typeIdentifier;
+    }
+  }
+
+  ResourceIdGenerator(int packageIdentifier) {
+    this.packageIdentifier = packageIdentifier;
+  }
+
+  public void record(int resId, String type, String name) {
+    TypeTracker typeTracker = typeInfo.get(type);
+    if (typeTracker == null) {
+      typeTracker = new TypeTracker(ResourceIds.getTypeIdentifier(resId));
+      typeInfo.put(type, typeTracker);
+    }
+    typeTracker.record(ResourceIds.getEntryIdentifier(resId));
+  }
+
+  public int generate(String type, String name) {
+    TypeTracker typeTracker = typeInfo.get(type);
+    if (typeTracker == null) {
+      typeTracker = new TypeTracker(getNextFreeTypeIdentifier());
+      typeInfo.put(type, typeTracker);
+    }
+    return ResourceIds.makeIdentifer(packageIdentifier, typeTracker.getTypeIdentifier(), typeTracker.getFreeIdentifier());
+  }
+
+  private int getNextFreeTypeIdentifier() {
+    int result = 0;
+    for (TypeTracker typeTracker : typeInfo.values()) {
+      result = Math.max(result, typeTracker.getTypeIdentifier());
+    }
+    return ++result;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourceIds.java b/resources/src/main/java/org/robolectric/res/ResourceIds.java
new file mode 100644
index 0000000..a4dbdb7
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceIds.java
@@ -0,0 +1,26 @@
+package org.robolectric.res;
+
+/**
+ * Utility class to that checks if a resource ID is a framework resource or application resource.
+ */
+public class ResourceIds {
+  public static boolean isFrameworkResource(int resId) {
+    return ((resId >>> 24) == 0x1);
+  }
+
+  public static int getPackageIdentifier(int resId) {
+    return (resId >>> 24);
+  }
+
+  public static int getTypeIdentifier(int resId) {
+    return (resId & 0x00FF0000) >>> 16;
+  }
+
+  public static int getEntryIdentifier(int resId) {
+    return resId & 0x0000FFFF;
+  }
+
+  public static int makeIdentifer(int packageIdentifier, int typeIdentifier, int entryIdenifier) {
+    return packageIdentifier << 24 | typeIdentifier << 16 | entryIdenifier;
+  }
+}
\ No newline at end of file
diff --git a/resources/src/main/java/org/robolectric/res/ResourceMerger.java b/resources/src/main/java/org/robolectric/res/ResourceMerger.java
new file mode 100644
index 0000000..8a54f84
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceMerger.java
@@ -0,0 +1,23 @@
+package org.robolectric.res;
+
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.robolectric.manifest.AndroidManifest;
+
+public class ResourceMerger {
+  @Nonnull
+  public PackageResourceTable buildResourceTable(AndroidManifest appManifest) {
+    ResourceRemapper resourceRemapper = new ResourceRemapper(appManifest.getRClass());
+
+    ResourcePath appResourcePath = appManifest.getResourcePath();
+    List<ResourcePath> allResourcePaths = appManifest.getIncludedResourcePaths();
+    for (ResourcePath resourcePath : allResourcePaths) {
+      if (!resourcePath.equals(appResourcePath) && resourcePath.getRClass() != null) {
+        resourceRemapper.remapRClass(resourcePath.getRClass());
+      }
+    }
+
+    return new ResourceTableFactory().newResourceTable(appManifest.getPackageName(),
+        allResourcePaths.toArray(new ResourcePath[allResourcePaths.size()]));
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourcePath.java b/resources/src/main/java/org/robolectric/res/ResourcePath.java
new file mode 100644
index 0000000..2f43db9
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourcePath.java
@@ -0,0 +1,71 @@
+package org.robolectric.res;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@SuppressWarnings("NewApi")
+public class ResourcePath {
+  private final Class<?> rClass;
+  private final Path resourceBase;
+  private final Path assetsDir;
+  private final Class<?> internalRClass;
+
+  public ResourcePath(Class<?> rClass, Path resourceBase, Path assetsDir) {
+    this(rClass, resourceBase, assetsDir, null);
+  }
+
+  public ResourcePath(Class<?> rClass, Path resourceBase, Path assetsDir, Class<?> internalRClass) {
+    this.rClass = rClass;
+    this.resourceBase = resourceBase;
+    this.assetsDir = assetsDir;
+    this.internalRClass = internalRClass;
+  }
+
+  public Class<?> getRClass() {
+    return rClass;
+  }
+
+  public Path getResourceBase() {
+    return resourceBase;
+  }
+
+  public boolean hasResources() {
+    return getResourceBase() != null && Files.exists(getResourceBase());
+  }
+
+  public Path getAssetsDir() {
+    return assetsDir;
+  }
+
+  public Class<?> getInternalRClass() {
+    return internalRClass;
+  }
+
+  @Override
+  public String toString() {
+    return "ResourcePath { path=" + resourceBase + "}";
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ResourcePath)) return false;
+
+    ResourcePath that = (ResourcePath) o;
+
+    if (rClass != null ? !rClass.equals(that.rClass) : that.rClass != null) return false;
+    if (resourceBase != null ? !resourceBase.equals(that.resourceBase) : that.resourceBase != null) return false;
+    if (assetsDir != null ? !assetsDir.equals(that.assetsDir) : that.assetsDir != null) return false;
+    return internalRClass != null ? internalRClass.equals(that.internalRClass) : that.internalRClass == null;
+
+  }
+
+  @Override
+  public int hashCode() {
+    int result = rClass != null ? rClass.hashCode() : 0;
+    result = 31 * result + (resourceBase != null ? resourceBase.hashCode() : 0);
+    result = 31 * result + (assetsDir != null ? assetsDir.hashCode() : 0);
+    result = 31 * result + (internalRClass != null ? internalRClass.hashCode() : 0);
+    return result;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourceRemapper.java b/resources/src/main/java/org/robolectric/res/ResourceRemapper.java
new file mode 100644
index 0000000..901240b
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceRemapper.java
@@ -0,0 +1,131 @@
+package org.robolectric.res;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * This class rewrites application R class resource values from multiple input R classes to all have unique values
+ * existing within the same ID space, i.e: no resource collisions. This replicates the behaviour of AAPT when building
+ * the final APK.
+ *
+ * IDs are in the format:-
+ *
+ * 0x PPTTEEEE
+ *
+ * where:
+ *
+ * P is unique for the package
+ * T is unique for the type
+ * E is the entry within that type.
+ */
+class ResourceRemapper {
+
+  private BiMap<String, Integer> resIds = HashBiMap.create();
+  private ResourceIdGenerator resourceIdGenerator = new ResourceIdGenerator(0x7F);
+
+  /**
+   * @param primaryRClass - An R class (usually the applications) that can be assumed to have a complete set of IDs. If
+   *                      this is provided then use the values from this class for re-writting all values in follow up
+   *                      calls to {@link #remapRClass(Class)}. If it is not provided the ResourceRemapper will generate
+   *                      its own unique non-conflicting IDs.
+   */
+  ResourceRemapper(Class<?> primaryRClass) {
+    if (primaryRClass != null) {
+      remapRClass(true, primaryRClass);
+    }
+  }
+
+  void remapRClass(Class<?> rClass) {
+    remapRClass(false, rClass);
+  }
+
+  /**
+   * @param isPrimary - Only one R class can allow final values and that is the final R class for the application
+   *                  that has had its resource id values generated to include all libraries in its dependency graph
+   *                  and therefore will be the only R file with the complete set of IDs in a unique ID space so we
+   *                  can assume to use the values from this class only. All other R files are partial R files for each
+   *                  library and on non-Android aware build systems like Maven where library R files are not re-written
+   *                  with the final R values we need to rewrite them ourselves.
+   */
+  private void remapRClass(boolean isPrimary, Class<?> rClass) {
+    // Collect all the local attribute id -> name mappings. These are used when processing the stylables to look up
+    // the reassigned values.
+    Map<Integer, String> localAttributeIds = new HashMap<>();
+    for (Class<?> aClass : rClass.getClasses()) {
+      if (aClass.getSimpleName().equals("attr")) {
+        for (Field field : aClass.getFields()) {
+          try {
+            localAttributeIds.put(field.getInt(null), field.getName());
+          } catch (IllegalAccessException e) {
+            throw new RuntimeException("Could not read attr value for " + field.getName(), e);
+          }
+        }
+      }
+    }
+
+    for (Class<?> innerClass : rClass.getClasses()) {
+      String resourceType = innerClass.getSimpleName();
+      if (!resourceType.startsWith("styleable")) {
+        for (Field field : innerClass.getFields()) {
+          try {
+            if (!isPrimary && Modifier.isFinal(field.getModifiers())) {
+              throw new IllegalArgumentException(
+                  rClass
+                      + " contains final fields, these will be inlined by the compiler and cannot"
+                      + " be remapped.");
+            }
+
+            String resourceName = resourceType + "/" + field.getName();
+            Integer value = resIds.get(resourceName);
+            if (value != null) {
+              field.setAccessible(true);
+              field.setInt(null, value);
+              resourceIdGenerator.record(field.getInt(null), resourceType, field.getName());
+            } else if (resIds.containsValue(field.getInt(null))) {
+              int remappedValue = resourceIdGenerator.generate(resourceType, field.getName());
+              field.setInt(null, remappedValue);
+              resIds.put(resourceName, remappedValue);
+            } else {
+              if (isPrimary) {
+                resourceIdGenerator.record(field.getInt(null), resourceType, field.getName());
+                resIds.put(resourceName, field.getInt(null));
+              } else {
+                int remappedValue = resourceIdGenerator.generate(resourceType, field.getName());
+                field.setInt(null, remappedValue);
+                resIds.put(resourceName, remappedValue);
+              }
+            }
+          } catch (IllegalAccessException e) {
+            throw new IllegalStateException(e);
+          }
+        }
+      }
+    }
+
+    // Reassign the ids in the style arrays accordingly.
+    for (Class<?> innerClass : rClass.getClasses()) {
+      String resourceType = innerClass.getSimpleName();
+      if (resourceType.startsWith("styleable")) {
+        for (Field field : innerClass.getFields()) {
+          if (field.getType().equals(int[].class)) {
+            try {
+              int[] styleableArray = (int[]) field.get(null);
+              for (int k = 0; k < styleableArray.length; k++) {
+                Integer value = resIds.get("attr/" + localAttributeIds.get(styleableArray[k]));
+                if (value != null) {
+                  styleableArray[k] = value;
+                }
+              }
+            } catch (IllegalAccessException e) {
+              throw new IllegalStateException(e);
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourceTable.java b/resources/src/main/java/org/robolectric/res/ResourceTable.java
new file mode 100644
index 0000000..b28ef14
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceTable.java
@@ -0,0 +1,31 @@
+package org.robolectric.res;
+
+import java.io.InputStream;
+import javax.annotation.Nonnull;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.builder.XmlBlock;
+
+public interface ResourceTable {
+
+  Integer getResourceId(ResName resName);
+
+  ResName getResName(int resourceId);
+
+  TypedResource getValue(int resId, ResTable_config config);
+
+  TypedResource getValue(@Nonnull ResName resName, ResTable_config config);
+
+  XmlBlock getXml(ResName resName, ResTable_config config);
+
+  InputStream getRawValue(ResName resName, ResTable_config config);
+
+  InputStream getRawValue(int resId, ResTable_config config);
+
+  void receive(Visitor visitor);
+
+  String getPackageName();
+
+  interface Visitor {
+    void visit(ResName key, Iterable<TypedResource> values);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourceTableFactory.java b/resources/src/main/java/org/robolectric/res/ResourceTableFactory.java
new file mode 100644
index 0000000..9f65367
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceTableFactory.java
@@ -0,0 +1,250 @@
+package org.robolectric.res;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import org.robolectric.util.Logger;
+import org.robolectric.util.PerfStatsCollector;
+
+public class ResourceTableFactory {
+  /** Builds an Android framework resource table in the "android" package space. */
+  public PackageResourceTable newFrameworkResourceTable(ResourcePath resourcePath) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "load legacy framework resources",
+            () -> {
+              PackageResourceTable resourceTable = new PackageResourceTable("android");
+
+              if (resourcePath.getRClass() != null) {
+                addRClassValues(resourceTable, resourcePath.getRClass());
+                addMissingStyleableAttributes(resourceTable, resourcePath.getRClass());
+              }
+              if (resourcePath.getInternalRClass() != null) {
+                addRClassValues(resourceTable, resourcePath.getInternalRClass());
+                addMissingStyleableAttributes(resourceTable, resourcePath.getInternalRClass());
+              }
+
+              parseResourceFiles(resourcePath, resourceTable);
+
+              return resourceTable;
+            });
+  }
+
+  /**
+   * Creates an application resource table which can be constructed with multiple resources paths
+   * representing overlayed resource libraries.
+   */
+  public PackageResourceTable newResourceTable(String packageName, ResourcePath... resourcePaths) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "load legacy app resources",
+            () -> {
+              PackageResourceTable resourceTable = new PackageResourceTable(packageName);
+
+              for (ResourcePath resourcePath : resourcePaths) {
+                if (resourcePath.getRClass() != null) {
+                  addRClassValues(resourceTable, resourcePath.getRClass());
+                }
+              }
+
+              for (ResourcePath resourcePath : resourcePaths) {
+                parseResourceFiles(resourcePath, resourceTable);
+              }
+
+              return resourceTable;
+            });
+  }
+
+  private void addRClassValues(PackageResourceTable resourceTable, Class<?> rClass) {
+    for (Class innerClass : rClass.getClasses()) {
+      String resourceType = innerClass.getSimpleName();
+      if (!resourceType.equals("styleable")) {
+        for (Field field : innerClass.getDeclaredFields()) {
+          if (field.getType().equals(Integer.TYPE) && Modifier.isStatic(field.getModifiers())) {
+            int id;
+            try {
+              id = field.getInt(null);
+            } catch (IllegalAccessException e) {
+              throw new RuntimeException(e);
+            }
+            String resourceName = field.getName();
+            // Pre-release versions of Android use the resource value '0' to indicate that the
+            // resource is being staged for inclusion in the public Android SDK. Skip these
+            // resource ids.
+            if (id == 0) {
+              Logger.debug("Ignoring staged resource " + resourceName);
+              continue;
+            }
+            resourceTable.addResource(id, resourceType, resourceName);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Check the stylable elements. Not for aapt generated R files but for framework R files it is possible to
+   * have attributes in the styleable array for which there is no corresponding R.attr field.
+   */
+  private void addMissingStyleableAttributes(PackageResourceTable resourceTable, Class<?> rClass) {
+    for (Class innerClass : rClass.getClasses()) {
+      if (innerClass.getSimpleName().equals("styleable")) {
+        String styleableName = null; // Current styleable name
+        int[] styleableArray = null; // Current styleable value array or references
+        for (Field field : innerClass.getDeclaredFields()) {
+          if (field.getType().equals(int[].class) && Modifier.isStatic(field.getModifiers())) {
+            styleableName = field.getName();
+            try {
+              styleableArray = (int[]) field.get(null);
+            } catch (IllegalAccessException e) {
+              throw new RuntimeException(e);
+            }
+          } else if (field.getType().equals(Integer.TYPE) && Modifier.isStatic(field.getModifiers())) {
+            String attributeName = field.getName().substring(styleableName.length() + 1);
+            try {
+              int styleableIndex = field.getInt(null);
+              int attributeResId = styleableArray[styleableIndex];
+              resourceTable.addResource(attributeResId, "attr", attributeName);
+            } catch (IllegalAccessException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  private void parseResourceFiles(ResourcePath resourcePath, PackageResourceTable resourceTable) {
+    if (!resourcePath.hasResources()) {
+      Logger.debug("No resources for %s", resourceTable.getPackageName());
+      return;
+    }
+
+    Logger.debug(
+        "Loading resources for %s from %s...",
+        resourceTable.getPackageName(), resourcePath.getResourceBase());
+
+    try {
+      new StaxDocumentLoader(
+              resourceTable.getPackageName(),
+              resourcePath.getResourceBase(),
+              new NodeHandler()
+                  .addHandler(
+                      "resources",
+                      new NodeHandler()
+                          .addHandler(
+                              "bool", new StaxValueLoader(resourceTable, "bool", ResType.BOOLEAN))
+                          .addHandler(
+                              "item[@type='bool']",
+                              new StaxValueLoader(resourceTable, "bool", ResType.BOOLEAN))
+                          .addHandler(
+                              "color", new StaxValueLoader(resourceTable, "color", ResType.COLOR))
+                          .addHandler(
+                              "item[@type='color']",
+                              new StaxValueLoader(resourceTable, "color", ResType.COLOR))
+                          .addHandler(
+                              "drawable",
+                              new StaxValueLoader(resourceTable, "drawable", ResType.DRAWABLE))
+                          .addHandler(
+                              "item[@type='drawable']",
+                              new StaxValueLoader(resourceTable, "drawable", ResType.DRAWABLE))
+                          .addHandler(
+                              "item[@type='mipmap']",
+                              new StaxValueLoader(resourceTable, "mipmap", ResType.DRAWABLE))
+                          .addHandler(
+                              "dimen", new StaxValueLoader(resourceTable, "dimen", ResType.DIMEN))
+                          .addHandler(
+                              "item[@type='dimen']",
+                              new StaxValueLoader(resourceTable, "dimen", ResType.DIMEN))
+                          .addHandler(
+                              "integer",
+                              new StaxValueLoader(resourceTable, "integer", ResType.INTEGER))
+                          .addHandler(
+                              "item[@type='integer']",
+                              new StaxValueLoader(resourceTable, "integer", ResType.INTEGER))
+                          .addHandler(
+                              "integer-array",
+                              new StaxArrayLoader(
+                                  resourceTable, "array", ResType.INTEGER_ARRAY, ResType.INTEGER))
+                          .addHandler(
+                              "fraction",
+                              new StaxValueLoader(resourceTable, "fraction", ResType.FRACTION))
+                          .addHandler(
+                              "item[@type='fraction']",
+                              new StaxValueLoader(resourceTable, "fraction", ResType.FRACTION))
+                          .addHandler(
+                              "item[@type='layout']",
+                              new StaxValueLoader(resourceTable, "layout", ResType.LAYOUT))
+                          .addHandler(
+                              "plurals",
+                              new StaxPluralsLoader(
+                                  resourceTable, "plurals", ResType.CHAR_SEQUENCE))
+                          .addHandler(
+                              "string",
+                              new StaxValueLoader(resourceTable, "string", ResType.CHAR_SEQUENCE))
+                          .addHandler(
+                              "item[@type='string']",
+                              new StaxValueLoader(resourceTable, "string", ResType.CHAR_SEQUENCE))
+                          .addHandler(
+                              "string-array",
+                              new StaxArrayLoader(
+                                  resourceTable,
+                                  "array",
+                                  ResType.CHAR_SEQUENCE_ARRAY,
+                                  ResType.CHAR_SEQUENCE))
+                          .addHandler(
+                              "array",
+                              new StaxArrayLoader(
+                                  resourceTable, "array", ResType.TYPED_ARRAY, null))
+                          .addHandler(
+                              "id", new StaxValueLoader(resourceTable, "id", ResType.CHAR_SEQUENCE))
+                          .addHandler(
+                              "item[@type='id']",
+                              new StaxValueLoader(resourceTable, "id", ResType.CHAR_SEQUENCE))
+                          .addHandler(
+                              "attr", new StaxAttrLoader(resourceTable, "attr", ResType.ATTR_DATA))
+                          .addHandler(
+                              "declare-styleable",
+                              new NodeHandler()
+                                  .addHandler(
+                                      "attr",
+                                      new StaxAttrLoader(resourceTable, "attr", ResType.ATTR_DATA)))
+                          .addHandler(
+                              "style", new StaxStyleLoader(resourceTable, "style", ResType.STYLE))))
+          .load("values");
+
+      loadOpaque(resourcePath, resourceTable, "layout", ResType.LAYOUT);
+      loadOpaque(resourcePath, resourceTable, "menu", ResType.LAYOUT);
+      loadOpaque(resourcePath, resourceTable, "drawable", ResType.DRAWABLE);
+      loadOpaque(resourcePath, resourceTable, "mipmap", ResType.DRAWABLE);
+      loadOpaque(resourcePath, resourceTable, "anim", ResType.LAYOUT);
+      loadOpaque(resourcePath, resourceTable, "animator", ResType.LAYOUT);
+      loadOpaque(resourcePath, resourceTable, "color", ResType.COLOR_STATE_LIST);
+      loadOpaque(resourcePath, resourceTable, "xml", ResType.LAYOUT);
+      loadOpaque(resourcePath, resourceTable, "transition", ResType.LAYOUT);
+      loadOpaque(resourcePath, resourceTable, "interpolator", ResType.LAYOUT);
+
+      new DrawableResourceLoader(resourceTable).findDrawableResources(resourcePath);
+      new RawResourceLoader(resourcePath).loadTo(resourceTable);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private void loadOpaque(
+      ResourcePath resourcePath,
+      final PackageResourceTable resourceTable,
+      final String type,
+      final ResType resType)
+      throws IOException {
+    new DocumentLoader(resourceTable.getPackageName(), resourcePath.getResourceBase()) {
+      @Override
+      protected void loadResourceXmlFile(XmlContext xmlContext) {
+        resourceTable.addResource(
+            type,
+            Fs.baseNameFor(xmlContext.getXmlFile()),
+            new FileTypedResource(xmlContext.getXmlFile(), resType, xmlContext));
+      }
+    }.load(type);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ResourceValueConverter.java b/resources/src/main/java/org/robolectric/res/ResourceValueConverter.java
new file mode 100644
index 0000000..b6f72b1
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ResourceValueConverter.java
@@ -0,0 +1,5 @@
+package org.robolectric.res;
+
+public interface ResourceValueConverter {
+  Object convertRawValue(String rawValue);
+}
diff --git a/resources/src/main/java/org/robolectric/res/RoutingResourceTable.java b/resources/src/main/java/org/robolectric/res/RoutingResourceTable.java
new file mode 100644
index 0000000..4e63dcc
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/RoutingResourceTable.java
@@ -0,0 +1,106 @@
+package org.robolectric.res;
+
+import java.io.InputStream;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeSet;
+import javax.annotation.Nonnull;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.builder.XmlBlock;
+
+public class RoutingResourceTable implements ResourceTable {
+  private static final PackageResourceTable EMPTY_RESOURCE_TABLE = new ResourceTableFactory().newResourceTable("");
+  private final Map<String, PackageResourceTable> resourceTables;
+
+  public RoutingResourceTable(PackageResourceTable... resourceTables) {
+    this.resourceTables = new LinkedHashMap<>();
+
+    for (PackageResourceTable resourceTable : resourceTables) {
+      this.resourceTables.put(resourceTable.getPackageName(), resourceTable);
+    }
+  }
+
+  @Override public InputStream getRawValue(int resId, ResTable_config config) {
+    ResName resName = getResName(resId);
+    return resName != null ? getRawValue(resName, config) : null;
+  }
+
+  @Override public TypedResource getValue(@Nonnull ResName resName, ResTable_config config) {
+    return pickFor(resName).getValue(resName, config);
+  }
+
+  @Override public TypedResource getValue(int resId, ResTable_config config) {
+    ResName resName = pickFor(resId).getResName(resId);
+    return resName != null ? getValue(resName, config) : null;
+  }
+
+  @Override public XmlBlock getXml(ResName resName, ResTable_config config) {
+    return pickFor(resName).getXml(resName, config);
+  }
+
+  @Override public InputStream getRawValue(ResName resName, ResTable_config config) {
+    return pickFor(resName).getRawValue(resName, config);
+  }
+
+  @Override
+  public Integer getResourceId(ResName resName) {
+    return pickFor(resName).getResourceId(resName);
+  }
+
+  @Override
+  public ResName getResName(int resourceId) {
+    return pickFor(resourceId).getResName(resourceId);
+  }
+
+  @Override
+  public void receive(Visitor visitor) {
+    for (PackageResourceTable resourceTable : resourceTables.values()) {
+      resourceTable.receive(visitor);
+    }
+  }
+
+  @Override
+  public String getPackageName() {
+    return resourceTables.keySet().iterator().next();
+  }
+
+  private PackageResourceTable pickFor(int resId) {
+    for (PackageResourceTable resourceTable : resourceTables.values()) {
+      if (resourceTable.getPackageIdentifier() == ResourceIds.getPackageIdentifier(resId)) {
+        return resourceTable;
+      }
+    }
+    return EMPTY_RESOURCE_TABLE;
+  }
+
+  private PackageResourceTable pickFor(ResName resName) {
+    if (resName == null) return EMPTY_RESOURCE_TABLE;
+    return pickFor(resName.packageName);
+  }
+
+  private PackageResourceTable pickFor(String namespace) {
+    if (namespace.equals("android.internal")) {
+      return EMPTY_RESOURCE_TABLE;
+    }
+    PackageResourceTable resourceTable = resourceTables.get(namespace);
+    if (resourceTable == null) {
+      resourceTable = whichProvidesFor(namespace);
+      return (resourceTable != null) ? resourceTable : EMPTY_RESOURCE_TABLE;
+    }
+    return resourceTable;
+  }
+
+  private PackageResourceTable whichProvidesFor(String namespace) {
+    for (PackageResourceTable resourceTable : resourceTables.values()) {
+      if (resourceTable.getPackageName().equals(namespace)) {
+        return resourceTable;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public String toString() {
+    return new TreeSet<>(resourceTables.keySet()).toString();
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxArrayLoader.java b/resources/src/main/java/org/robolectric/res/StaxArrayLoader.java
new file mode 100644
index 0000000..e21d809
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxArrayLoader.java
@@ -0,0 +1,50 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class StaxArrayLoader extends StaxLoader {
+  private String name;
+  private List<TypedResource> items;
+  private final StringBuilder buf = new StringBuilder();
+
+  public StaxArrayLoader(PackageResourceTable resourceTable, String attrType, ResType arrayResType, final ResType scalarResType) {
+    super(resourceTable, attrType, arrayResType);
+
+    addHandler("item", new NodeHandler() {
+      @Override
+      public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        buf.setLength(0);
+      }
+
+      @Override
+      public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        buf.append(xml.getText());
+      }
+
+      @Override
+      public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        ResType resType = scalarResType == null ? ResType.inferType(buf.toString()) : scalarResType;
+        items.add(new TypedResource<>(buf.toString(), resType, xmlContext));
+      }
+
+      @Override
+      NodeHandler findMatchFor(XMLStreamReader xml) {
+        return new TextCollectingNodeHandler(buf);
+      }
+    });
+  }
+
+  @Override
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    name = xml.getAttributeValue(null, "name");
+    items = new ArrayList<>();
+  }
+
+  @Override
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    resourceTable.addResource(attrType, name, new TypedResource<>(items, resType, xmlContext));
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxAttrLoader.java b/resources/src/main/java/org/robolectric/res/StaxAttrLoader.java
new file mode 100644
index 0000000..92bf880
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxAttrLoader.java
@@ -0,0 +1,61 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class StaxAttrLoader extends StaxLoader {
+  private String name;
+  private String format;
+  private final List<AttrData.Pair> pairs = new ArrayList<>();
+
+  public StaxAttrLoader(PackageResourceTable resourceTable, String attrType, ResType resType) {
+    super(resourceTable, attrType, resType);
+
+    addHandler("*", new NodeHandler() {
+      private String value;
+      private String name;
+
+      @Override
+      public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        String type = xml.getLocalName();
+        if (pairs.isEmpty()) {
+          if (format == null) {
+            format = type;
+          } else {
+            format = format + "|" + type;
+          }
+        }
+        name = xml.getAttributeValue(null, "name");
+        value = xml.getAttributeValue(null, "value");
+        pairs.add(new AttrData.Pair(name, value));
+      }
+
+      @Override
+      public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+      }
+
+      @Override
+      public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+      }
+    });
+  }
+
+  @Override
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    name = xml.getAttributeValue(null, "name");
+    format = xml.getAttributeValue(null, "format");
+  }
+
+  @Override
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    AttrData attrData = new AttrData(name, format, new ArrayList<>(pairs));
+    pairs.clear();
+
+//      xmlContext = xmlContext.withLineNumber(xml.getLocation().getLineNumber());
+    if (attrData.getFormat() != null) {
+      resourceTable.addResource(attrType, name, new TypedResource<>(attrData, resType, xmlContext));
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxDocumentLoader.java b/resources/src/main/java/org/robolectric/res/StaxDocumentLoader.java
new file mode 100644
index 0000000..4e756f2
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxDocumentLoader.java
@@ -0,0 +1,76 @@
+package org.robolectric.res;
+
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class StaxDocumentLoader extends DocumentLoader {
+  private static final NodeHandler NO_OP_HANDLER = new NodeHandler();
+
+  private final NodeHandler topLevelNodeHandler;
+  private final XMLInputFactory factory;
+
+  public StaxDocumentLoader(
+      String packageName, Path resourceBase, NodeHandler topLevelNodeHandler) {
+    super(packageName, resourceBase);
+
+    this.topLevelNodeHandler = topLevelNodeHandler;
+    factory = XMLInputFactory.newFactory();
+  }
+
+  @Override
+  protected void loadResourceXmlFile(XmlContext xmlContext) {
+    Path xmlFile = xmlContext.getXmlFile();
+
+    XMLStreamReader xmlStreamReader;
+    try {
+      xmlStreamReader = factory.createXMLStreamReader(Fs.getInputStream(xmlFile));
+      doParse(xmlStreamReader, xmlContext);
+    } catch (Exception e) {
+      throw new RuntimeException("error parsing " + xmlFile, e);
+    }
+    if (xmlStreamReader != null) {
+      try {
+        xmlStreamReader.close();
+      } catch (XMLStreamException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  protected void doParse(XMLStreamReader reader, XmlContext xmlContext) throws XMLStreamException {
+    NodeHandler nodeHandler = this.topLevelNodeHandler;
+    Deque<NodeHandler> nodeHandlerStack = new ArrayDeque<>();
+
+    while (reader.hasNext()) {
+      int event = reader.next();
+      switch (event) {
+        case XMLStreamConstants.START_DOCUMENT:
+          break;
+
+        case XMLStreamConstants.START_ELEMENT:
+          nodeHandlerStack.push(nodeHandler);
+          NodeHandler elementHandler = nodeHandler.findMatchFor(reader);
+          nodeHandler = elementHandler == null ? NO_OP_HANDLER : elementHandler;
+          nodeHandler.onStart(reader, xmlContext);
+          break;
+
+        case XMLStreamConstants.CDATA:
+        case XMLStreamConstants.CHARACTERS:
+          nodeHandler.onCharacters(reader, xmlContext);
+          break;
+
+        case XMLStreamConstants.END_ELEMENT:
+          nodeHandler.onEnd(reader, xmlContext);
+          nodeHandler = nodeHandlerStack.pop();
+          break;
+
+        case XMLStreamConstants.ATTRIBUTE:
+      }
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxLoader.java b/resources/src/main/java/org/robolectric/res/StaxLoader.java
new file mode 100644
index 0000000..3c19ab4
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxLoader.java
@@ -0,0 +1,29 @@
+package org.robolectric.res;
+
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public abstract class StaxLoader extends NodeHandler {
+
+  protected final PackageResourceTable resourceTable;
+  protected final String attrType;
+  protected final ResType resType;
+
+  public StaxLoader(PackageResourceTable resourceTable, String attrType, ResType resType) {
+    this.resourceTable = resourceTable;
+    this.attrType = attrType;
+    this.resType = resType;
+  }
+
+  @Override
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+  }
+
+  @Override
+  public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+  }
+
+  @Override
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxPluralsLoader.java b/resources/src/main/java/org/robolectric/res/StaxPluralsLoader.java
new file mode 100644
index 0000000..7be9dfe
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxPluralsLoader.java
@@ -0,0 +1,52 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class StaxPluralsLoader extends StaxLoader {
+  protected String name;
+  private String quantity;
+  private final List<Plural> plurals = new ArrayList<>();
+
+  public StaxPluralsLoader(PackageResourceTable resourceTable, String attrType, ResType charSequence) {
+    super(resourceTable, attrType, charSequence);
+
+    addHandler("item", new NodeHandler() {
+      private final StringBuilder buf = new StringBuilder();
+
+      @Override
+      public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        quantity = xml.getAttributeValue(null, "quantity");
+        buf.setLength(0);
+      }
+
+      @Override
+      public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        buf.append(xml.getText());
+      }
+
+      @Override
+      public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        plurals.add(new Plural(quantity, buf.toString()));
+      }
+
+      @Override
+      NodeHandler findMatchFor(XMLStreamReader xml) {
+        return new TextCollectingNodeHandler(buf);
+      }
+    });
+  }
+
+  @Override
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    name = xml.getAttributeValue(null, "name");
+  }
+
+  @Override
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    resourceTable.addResource(attrType, name, new PluralRules(new ArrayList<>(plurals), resType, xmlContext));
+    plurals.clear();
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxStyleLoader.java b/resources/src/main/java/org/robolectric/res/StaxStyleLoader.java
new file mode 100644
index 0000000..db11295
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxStyleLoader.java
@@ -0,0 +1,61 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class StaxStyleLoader extends StaxLoader {
+  private String name;
+  private String parent;
+  private List<AttributeResource> attributeResources;
+
+  public StaxStyleLoader(PackageResourceTable resourceTable, String attrType, ResType resType) {
+    super(resourceTable, attrType, resType);
+
+    addHandler("item", new NodeHandler() {
+      private String attrName;
+      private StringBuilder buf = new StringBuilder();
+
+      @Override
+      public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        attrName = xml.getAttributeValue(null, "name");
+        buf.setLength(0);
+      }
+
+      @Override
+      public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        buf.append(xml.getText());
+      }
+
+      @Override
+      public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+        ResName attrResName = ResName.qualifyResName(attrName, xmlContext.getPackageName(), "attr");
+        attributeResources.add(new AttributeResource(attrResName, buf.toString(), xmlContext.getPackageName()));
+      }
+    });
+  }
+
+  @Override
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    name = xml.getAttributeValue(null, "name");
+    parent = xml.getAttributeValue(null, "parent");
+    attributeResources = new ArrayList<>();
+  }
+
+  @Override
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    String styleParent = parent;
+
+    if (styleParent == null) {
+      int lastDot = name.lastIndexOf('.');
+      if (lastDot != -1) {
+        styleParent = name.substring(0, lastDot);
+      }
+    }
+
+    StyleData styleData = new StyleData(xmlContext.getPackageName(), name, styleParent, attributeResources);
+
+    resourceTable.addResource("style", styleData.getName(), new TypedResource<>(styleData, resType, xmlContext));
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StaxValueLoader.java b/resources/src/main/java/org/robolectric/res/StaxValueLoader.java
new file mode 100644
index 0000000..6b580e6
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StaxValueLoader.java
@@ -0,0 +1,39 @@
+package org.robolectric.res;
+
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class StaxValueLoader extends StaxLoader {
+  private final StringBuilder buf = new StringBuilder();
+  protected String name;
+
+  public StaxValueLoader(PackageResourceTable resourceTable, String attrType, ResType resType) {
+    super(resourceTable, attrType, resType);
+
+    if (resType == ResType.CHAR_SEQUENCE) {
+      addHandler("*", new TextCollectingNodeHandler(buf));
+    }
+  }
+
+  @Override
+  public void onStart(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    name = xml.getAttributeValue(null, "name");
+    buf.setLength(0);
+  }
+
+  @Override
+  public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    buf.append(xml.getText());
+  }
+
+  @Override
+  public void onEnd(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    String s = buf.toString();
+    if (resType == ResType.CHAR_SEQUENCE) {
+      s = StringResources.processStringResources(s);
+    } else {
+      s = s.trim();
+    }
+    resourceTable.addResource(attrType, name, new TypedResource<>(s, resType, xmlContext));
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StringResources.java b/resources/src/main/java/org/robolectric/res/StringResources.java
new file mode 100644
index 0000000..b58855c
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StringResources.java
@@ -0,0 +1,106 @@
+package org.robolectric.res;
+
+import static com.google.common.base.CharMatcher.whitespace;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.robolectric.util.Logger;
+
+public class StringResources {
+
+  private static final int CODE_POINT_LENGTH = 4;
+
+  /**
+   * Processes String resource values in the same way real Android does, namely:-
+   * 1) Trim leading and trailing whitespace.
+   * 2) Converts code points.
+   * 3) Escapes
+   */
+  public static String processStringResources(String inputValue) {
+    return escape(whitespace().collapseFrom(inputValue.trim(), ' '));
+  }
+
+  /**
+   * Provides escaping of String resources.
+   *
+   * @see <a
+   *     href="http://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling">String
+   *     resource formatting</a>.
+   * @param text Text to escape.
+   * @return Escaped text.
+   */
+  @VisibleForTesting
+  static String escape(String text) {
+    // unwrap double quotes
+    if (text.length() > 1 && text.charAt(0) == '"' && text.charAt(text.length() - 1) == '"') {
+      text = text.substring(1, text.length() - 1);
+    }
+    int i = 0;
+    int length = text.length();
+    StringBuilder result = new StringBuilder(text.length());
+    while (true) {
+      int j = text.indexOf('\\', i);
+      if (j == -1) {
+        result.append(removeUnescapedDoubleQuotes(text.substring(i)));
+        break;
+      }
+      result.append(removeUnescapedDoubleQuotes(text.substring(i, j)));
+      if (j == length - 1) {
+        // dangling backslash
+        break;
+      }
+      boolean isUnicodeEscape = false;
+      char escapeCode = text.charAt(j + 1);
+      switch (escapeCode) {
+        case '\'':
+        case '"':
+        case '\\':
+        case '?':
+        case '@':
+        case '#':
+          result.append(escapeCode);
+          break;
+        case 'n':
+          result.append('\n');
+          break;
+        case 't':
+          result.append('\t');
+          break;
+        case 'u':
+          isUnicodeEscape = true;
+          break;
+        default:
+          Logger.strict("Unsupported string resource escape code '%s'", escapeCode);
+      }
+      if (!isUnicodeEscape) {
+        i = j + 2;
+      } else {
+        j += 2;
+        if (length - j < CODE_POINT_LENGTH) {
+          throw new IllegalArgumentException("Too short code point: \\u" + text.substring(j));
+        }
+        String codePoint = text.substring(j, j + CODE_POINT_LENGTH);
+        result.append(extractCodePoint(codePoint));
+        i = j + CODE_POINT_LENGTH;
+      }
+    }
+    return result.toString();
+  }
+
+  /**
+   * Converts code points in a given string to actual characters. This method doesn't handle code
+   * points whose char counts are 2. In other words, this method doesn't handle U+10XXXX.
+   */
+  private static char[] extractCodePoint(String codePoint) {
+    try {
+      return Character.toChars(Integer.valueOf(codePoint, 16));
+    } catch (IllegalArgumentException e) {
+      // This may be caused by NumberFormatException of Integer.valueOf() or
+      // IllegalArgumentException of Character.toChars().
+      throw new IllegalArgumentException("Invalid code point: \\u" + codePoint, e);
+    }
+  }
+
+  private static String removeUnescapedDoubleQuotes(String input) {
+    return input.replaceAll("\"", "");
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/Style.java b/resources/src/main/java/org/robolectric/res/Style.java
new file mode 100644
index 0000000..8bca7d6
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/Style.java
@@ -0,0 +1,5 @@
+package org.robolectric.res;
+
+public interface Style {
+  AttributeResource getAttrValue(ResName resName);
+}
diff --git a/resources/src/main/java/org/robolectric/res/StyleData.java b/resources/src/main/java/org/robolectric/res/StyleData.java
new file mode 100644
index 0000000..4270e26
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StyleData.java
@@ -0,0 +1,105 @@
+package org.robolectric.res;
+
+import com.google.common.base.Strings;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+public class StyleData implements Style {
+  private final String packageName;
+  private final String name;
+  private final String parent;
+  private final Map<ResName, AttributeResource> items = new LinkedHashMap<>();
+
+  public StyleData(String packageName, String name, String parent, List<AttributeResource> attributeResources) {
+    this.packageName = packageName;
+    this.name = name;
+    this.parent = parent;
+
+    for (AttributeResource attributeResource : attributeResources) {
+      add(attributeResource.resName, attributeResource);
+    }
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getParent() {
+    return parent;
+  }
+
+  private void add(ResName attrName, AttributeResource attribute) {
+    attrName.mustBe("attr");
+    items.put(attrName, attribute);
+  }
+
+  @Override public AttributeResource getAttrValue(ResName resName) {
+    AttributeResource attributeResource = items.get(resName);
+
+    // This hack allows us to look up attributes from downstream dependencies, see comment in
+    // org.robolectric.shadows.ShadowThemeTest.obtainTypedArrayFromDependencyLibrary()
+    // for an explanation. TODO(jongerrish): Make Robolectric use a more realistic resource merging
+    // scheme.
+    if (attributeResource == null && !"android".equals(resName.packageName) && !"android".equals(packageName)) {
+      attributeResource = items.get(resName.withPackageName(packageName));
+      if (attributeResource != null && !"android".equals(attributeResource.contextPackageName)) {
+        attributeResource = new AttributeResource(resName, attributeResource.value, resName.packageName);
+      }
+    }
+
+    return attributeResource;
+  }
+
+  public boolean grep(Pattern pattern) {
+    for (ResName resName : items.keySet()) {
+      if (pattern.matcher(resName.getFullyQualifiedName()).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public void visit(Visitor visitor) {
+    for (AttributeResource attributeResource : items.values()) {
+      visitor.visit(attributeResource);
+    }
+  }
+
+  public interface Visitor {
+    void visit(AttributeResource attributeResource);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof StyleData)) {
+      return false;
+    }
+    StyleData other = (StyleData) obj;
+
+    return Objects.equals(packageName, other.packageName)
+        && Objects.equals(name, other.name)
+        && Objects.equals(parent, other.parent)
+        && items.size() == other.items.size();
+  }
+
+  @Override
+  public int hashCode() {
+    int hashCode = 0;
+    hashCode = 31 * hashCode + Strings.nullToEmpty(packageName).hashCode();
+    hashCode = 31 * hashCode + Strings.nullToEmpty(name).hashCode();
+    hashCode = 31 * hashCode + Strings.nullToEmpty(parent).hashCode();
+    hashCode = 31 * hashCode + items.size();
+    return hashCode;
+  }
+
+  @Override public String toString() {
+    return "Style " + packageName + ":" + name;
+  }
+
+  public String getPackageName() {
+    return packageName;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/StyleResolver.java b/resources/src/main/java/org/robolectric/res/StyleResolver.java
new file mode 100644
index 0000000..0c0f816
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/StyleResolver.java
@@ -0,0 +1,176 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import org.robolectric.res.android.ResTable_config;
+
+public class StyleResolver implements Style {
+  private final List<StyleData> styles = new ArrayList<>();
+  private final ResourceTable appResourceTable;
+  private final ResourceTable systemResourceTable;
+  private final Style theme;
+  private final ResName myResName;
+  private final ResTable_config config;
+
+  public StyleResolver(ResourceTable appResourceTable, ResourceTable systemResourceTable, StyleData styleData,
+                       Style theme, ResName myResName, ResTable_config config) {
+    this.appResourceTable = appResourceTable;
+    this.systemResourceTable = systemResourceTable;
+    this.theme = theme;
+    this.myResName = myResName;
+    this.config = config;
+    styles.add(styleData);
+  }
+
+  @Override public AttributeResource getAttrValue(ResName resName) {
+    for (StyleData style : styles) {
+      AttributeResource value = style.getAttrValue(resName);
+      if (value != null) return value;
+    }
+    int initialSize = styles.size();
+    while (hasParent(styles.get(styles.size() - 1))) {
+      StyleData parent = getParent(styles.get(styles.size() - 1));
+      if (parent != null) {
+        styles.add(parent);
+      } else {
+        break;
+      }
+    }
+    for (int i = initialSize; i < styles.size(); i++) {
+      StyleData style = styles.get(i);
+      AttributeResource value = style.getAttrValue(resName);
+      if (value != null) return value;
+    }
+
+    // todo: is this tested?
+    if (theme != null) {
+      AttributeResource value = theme.getAttrValue(resName);
+      if (value != null) return value;
+    }
+
+    return null;
+  }
+
+  private static String getParentStyleName(StyleData style) {
+    if (style == null) {
+      return null;
+    }
+    String parent = style.getParent();
+    if (parent == null || parent.isEmpty()) {
+      parent = null;
+      String name = style.getName();
+      if (name.contains(".")) {
+        parent = name.substring(0, name.lastIndexOf('.'));
+        if (parent.isEmpty()) {
+          return null;
+        }
+      }
+    }
+    return parent;
+  }
+
+  private static boolean hasParent(StyleData style) {
+    if (style == null) return false;
+    String parent = style.getParent();
+    return parent != null && !parent.isEmpty();
+  }
+
+  private StyleData getParent(StyleData style) {
+    String parent = getParentStyleName(style);
+
+    if (parent == null) return null;
+
+    if (parent.startsWith("@")) parent = parent.substring(1);
+
+    ResName styleRef = ResName.qualifyResName(parent, style.getPackageName(), "style");
+
+    styleRef = dereferenceResName(styleRef);
+
+    // TODO: Refactor this to a ResourceLoaderChooser
+    ResourceTable resourceProvider = "android".equals(styleRef.packageName) ? systemResourceTable : appResourceTable;
+    TypedResource typedResource = resourceProvider.getValue(styleRef, config);
+
+    if (typedResource == null) {
+      StringBuilder builder = new StringBuilder("Could not find any resource")
+          .append(" from reference ").append(styleRef)
+          .append(" from ").append(style)
+          .append(" with ").append(theme);
+      throw new RuntimeException(builder.toString());
+    }
+
+    Object data = typedResource.getData();
+    if (data instanceof StyleData) {
+      return (StyleData) data;
+    } else {
+      StringBuilder builder = new StringBuilder(styleRef.toString())
+          .append(" does not resolve to a Style.")
+          .append(" got ").append(data).append(" instead. ")
+          .append(" from ").append(style)
+          .append(" with ").append(theme);
+      throw new RuntimeException(builder.toString());
+    }
+  }
+
+  private ResName dereferenceResName(ResName res) {
+    ResName styleRef = res;
+    boolean dereferencing = true;
+    while ("attr".equals(styleRef.type) && dereferencing) {
+      dereferencing = false;
+      for (StyleData parentStyle : styles) {
+        AttributeResource value = parentStyle.getAttrValue(styleRef);
+        if (value != null) {
+          styleRef = dereferenceAttr(value);
+          dereferencing = true;
+          break;
+        }
+      }
+      if (!dereferencing && theme != null) {
+        AttributeResource value = theme.getAttrValue(styleRef);
+        if (value != null) {
+          styleRef = dereferenceAttr(value);
+          dereferencing = true;
+        }
+      }
+    }
+
+    return styleRef;
+  }
+
+  private ResName dereferenceAttr(AttributeResource attr) {
+    if (attr.isResourceReference()) {
+      return attr.getResourceReference();
+    } else if (attr.isStyleReference()) {
+      return attr.getStyleReference();
+    }
+    throw new RuntimeException("Found a " + attr + " but can't cast it :(");
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof StyleResolver)) {
+      return false;
+    }
+    StyleResolver other = (StyleResolver) obj;
+
+    return ((theme == null && other.theme == null) || (theme != null && theme.equals(other.theme)))
+        && ((myResName == null && other.myResName == null)
+            || (myResName != null && myResName.equals(other.myResName)))
+        && Objects.equals(config, other.config);
+  }
+
+  @Override
+  public int hashCode() {
+    int hashCode = 0;
+    hashCode = 31 * hashCode + (theme != null ? theme.hashCode() : 0);
+    hashCode = 31 * hashCode + (myResName != null ? myResName.hashCode() : 0);
+    hashCode = 31 * hashCode + (config != null ? config.hashCode() : 0);
+    return hashCode;
+  }
+
+  @Override
+  public String toString() {
+    return styles.get(0) + " (and parents)";
+  }
+
+}
\ No newline at end of file
diff --git a/resources/src/main/java/org/robolectric/res/TextCollectingNodeHandler.java b/resources/src/main/java/org/robolectric/res/TextCollectingNodeHandler.java
new file mode 100644
index 0000000..2e2cca1
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/TextCollectingNodeHandler.java
@@ -0,0 +1,22 @@
+package org.robolectric.res;
+
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+public class TextCollectingNodeHandler extends NodeHandler {
+  private final StringBuilder buf;
+
+  public TextCollectingNodeHandler(StringBuilder buf) {
+    this.buf = buf;
+  }
+
+  @Override
+  public void onCharacters(XMLStreamReader xml, XmlContext xmlContext) throws XMLStreamException {
+    buf.append(xml.getText());
+  }
+
+  @Override
+  NodeHandler findMatchFor(XMLStreamReader xml) {
+    return this;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/ThemeStyleSet.java b/resources/src/main/java/org/robolectric/res/ThemeStyleSet.java
new file mode 100644
index 0000000..9c893e5
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/ThemeStyleSet.java
@@ -0,0 +1,81 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents the list of styles applied to a Theme.
+ */
+public class ThemeStyleSet implements Style {
+
+  private List<OverlayedStyle> styles = new ArrayList<>();
+
+  @Override public AttributeResource getAttrValue(ResName attrName) {
+    AttributeResource attribute = null;
+
+    for (OverlayedStyle overlayedStyle : styles) {
+      AttributeResource overlayedAttribute = overlayedStyle.style.getAttrValue(attrName);
+      if (overlayedAttribute != null && (attribute == null || overlayedStyle.force)) {
+        attribute = overlayedAttribute;
+      }
+    }
+
+    return attribute;
+  }
+
+  public void apply(Style style, boolean force) {
+    OverlayedStyle styleToAdd = new OverlayedStyle(style, force);
+    for (int i = 0; i < styles.size(); ++i) {
+      if (styleToAdd.equals(styles.get(i))) {
+        styles.remove(i);
+        break;
+      }
+    }
+    styles.add(styleToAdd);
+  }
+
+  public ThemeStyleSet copy() {
+    ThemeStyleSet themeStyleSet = new ThemeStyleSet();
+    themeStyleSet.styles.addAll(this.styles);
+    return themeStyleSet;
+  }
+
+  @Override
+  public String toString() {
+    if (styles.isEmpty()) {
+      return "theme with no applied styles";
+    } else {
+      return "theme with applied styles: " + styles + "";
+    }
+  }
+
+  private static class OverlayedStyle {
+    Style style;
+    boolean force;
+
+    OverlayedStyle(Style style, boolean force) {
+      this.style = style;
+      this.force = force;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof OverlayedStyle)) {
+        return false;
+      }
+      OverlayedStyle overlayedStyle = (OverlayedStyle) obj;
+      return style.equals(overlayedStyle.style);
+    }
+
+    @Override
+    public int hashCode() {
+      return style.hashCode();
+    }
+
+    @Override
+    public String toString() {
+      return style.toString() + (force ? " (forced)" : "");
+    }
+  }
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/TypedResource.java b/resources/src/main/java/org/robolectric/res/TypedResource.java
new file mode 100644
index 0000000..50978fe
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/TypedResource.java
@@ -0,0 +1,61 @@
+package org.robolectric.res;
+
+import org.robolectric.res.android.ResTable_config;
+
+public class TypedResource<T> {
+  private final T data;
+  private final ResType resType;
+  private final XmlContext xmlContext;
+
+  public TypedResource(T data, ResType resType, XmlContext xmlContext) {
+    this.data = data;
+    this.resType = resType;
+    this.xmlContext = xmlContext;
+  }
+
+  public T getData() {
+    return data;
+  }
+
+  public ResType getResType() {
+    return resType;
+  }
+
+  public ResTable_config getConfig() {
+    return xmlContext.getConfig();
+  }
+
+  public XmlContext getXmlContext() {
+    return xmlContext;
+  }
+
+  public String asString() {
+    T data = getData();
+    return data instanceof String ? (String) data : null;
+  }
+
+  public boolean isFile() {
+    return false;
+  }
+
+  public boolean isReference() {
+    Object data = getData();
+    if (data instanceof String) {
+      String s = (String) data;
+      return !s.isEmpty() && s.charAt(0) == '@';
+    }
+    return false;
+  }
+
+  @Override public String toString() {
+    return getClass().getSimpleName() + "{" +
+        "values=" + data +
+        ", resType=" + resType +
+        ", xmlContext=" + xmlContext +
+        '}';
+  }
+
+  public boolean isXml() {
+    return false;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/XmlContext.java b/resources/src/main/java/org/robolectric/res/XmlContext.java
new file mode 100644
index 0000000..e2fae7a
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/XmlContext.java
@@ -0,0 +1,36 @@
+package org.robolectric.res;
+
+import java.nio.file.Path;
+import org.robolectric.res.android.ResTable_config;
+
+public class XmlContext {
+  private final String packageName;
+  private final Path xmlFile;
+  private final Qualifiers qualifiers;
+
+  public XmlContext(String packageName, Path xmlFile, Qualifiers qualifiers) {
+    this.packageName = packageName;
+    this.xmlFile = xmlFile;
+    this.qualifiers = qualifiers;
+  }
+
+  public String getPackageName() {
+    return packageName;
+  }
+
+  public ResTable_config getConfig() {
+    return qualifiers.getConfig();
+  }
+
+  public Qualifiers getQualifiers() {
+    return qualifiers;
+  }
+
+  public Path getXmlFile() {
+    return xmlFile;
+  }
+
+  @Override public String toString() {
+    return '{' + packageName + ':' + xmlFile + '}';
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/AConfiguration.java b/resources/src/main/java/org/robolectric/res/android/AConfiguration.java
new file mode 100644
index 0000000..11b2ad4
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/AConfiguration.java
@@ -0,0 +1,413 @@
+package org.robolectric.res.android;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/native/+/android-9.0.0_r12/include/android/configuration.h
+public class AConfiguration {
+/** Orientation: not specified. */
+  public static final int ACONFIGURATION_ORIENTATION_ANY  = 0x0000;
+  /**
+   * Orientation: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#OrientationQualifier">port</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_ORIENTATION_PORT = 0x0001;
+  /**
+   * Orientation: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#OrientationQualifier">land</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_ORIENTATION_LAND = 0x0002;
+  /** @deprecated Not currently supported or used. */
+  @Deprecated
+  public static final int ACONFIGURATION_ORIENTATION_SQUARE = 0x0003;
+  /** Touchscreen: not specified. */
+  public static final int ACONFIGURATION_TOUCHSCREEN_ANY  = 0x0000;
+  /**
+   * Touchscreen: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#TouchscreenQualifier">notouch</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_TOUCHSCREEN_NOTOUCH  = 0x0001;
+  /** @deprecated Not currently supported or used. */
+  @Deprecated
+  public static final int ACONFIGURATION_TOUCHSCREEN_STYLUS  = 0x0002;
+  /**
+   * Touchscreen: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#TouchscreenQualifier">finger</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_TOUCHSCREEN_FINGER  = 0x0003;
+  /** Density: default density. */
+  public static final int ACONFIGURATION_DENSITY_DEFAULT = 0;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">ldpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_LOW = 120;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">mdpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_MEDIUM = 160;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">tvdpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_TV = 213;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">hdpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_HIGH = 240;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">xhdpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_XHIGH = 320;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">xxhdpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_XXHIGH = 480;
+  /**
+   * Density: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">xxxhdpi</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_DENSITY_XXXHIGH = 640;
+  /** Density: any density. */
+  public static final int ACONFIGURATION_DENSITY_ANY = 0xfffe;
+  /** Density: no density specified. */
+  public static final int ACONFIGURATION_DENSITY_NONE = 0xffff;
+  /** Keyboard: not specified. */
+  public static final int ACONFIGURATION_KEYBOARD_ANY  = 0x0000;
+  /**
+   * Keyboard: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ImeQualifier">nokeys</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_KEYBOARD_NOKEYS  = 0x0001;
+  /**
+   * Keyboard: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ImeQualifier">qwerty</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_KEYBOARD_QWERTY  = 0x0002;
+  /**
+   * Keyboard: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ImeQualifier">12key</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_KEYBOARD_12KEY  = 0x0003;
+  /** Navigation: not specified. */
+  public static final int ACONFIGURATION_NAVIGATION_ANY  = 0x0000;
+  /**
+   * Navigation: value corresponding to the
+   * <a href="@@dacRoot/guide/topics/resources/providing-resources.html#NavigationQualifier">nonav</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_NAVIGATION_NONAV  = 0x0001;
+  /**
+   * Navigation: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NavigationQualifier">dpad</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_NAVIGATION_DPAD  = 0x0002;
+  /**
+   * Navigation: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NavigationQualifier">trackball</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_NAVIGATION_TRACKBALL  = 0x0003;
+  /**
+   * Navigation: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NavigationQualifier">wheel</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_NAVIGATION_WHEEL  = 0x0004;
+  /** Keyboard availability: not specified. */
+  public static final int ACONFIGURATION_KEYSHIDDEN_ANY = 0x0000;
+  /**
+   * Keyboard availability: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#KeyboardAvailQualifier">keysexposed</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_KEYSHIDDEN_NO = 0x0001;
+  /**
+   * Keyboard availability: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#KeyboardAvailQualifier">keyshidden</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_KEYSHIDDEN_YES = 0x0002;
+  /**
+   * Keyboard availability: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#KeyboardAvailQualifier">keyssoft</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_KEYSHIDDEN_SOFT = 0x0003;
+  /** Navigation availability: not specified. */
+  public static final int ACONFIGURATION_NAVHIDDEN_ANY = 0x0000;
+  /**
+   * Navigation availability: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NavAvailQualifier">navexposed</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_NAVHIDDEN_NO = 0x0001;
+  /**
+   * Navigation availability: value corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NavAvailQualifier">navhidden</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_NAVHIDDEN_YES = 0x0002;
+  /** Screen size: not specified. */
+  public static final int ACONFIGURATION_SCREENSIZE_ANY  = 0x00;
+  /**
+   * Screen size: value indicating the screen is at least
+   * approximately 320x426 dp units, corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenSizeQualifier">small</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_SCREENSIZE_SMALL = 0x01;
+  /**
+   * Screen size: value indicating the screen is at least
+   * approximately 320x470 dp units, corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenSizeQualifier">normal</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_SCREENSIZE_NORMAL = 0x02;
+  /**
+   * Screen size: value indicating the screen is at least
+   * approximately 480x640 dp units, corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenSizeQualifier">large</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_SCREENSIZE_LARGE = 0x03;
+  /**
+   * Screen size: value indicating the screen is at least
+   * approximately 720x960 dp units, corresponding to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenSizeQualifier">xlarge</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_SCREENSIZE_XLARGE = 0x04;
+  /** Screen layout: not specified. */
+  public static final int ACONFIGURATION_SCREENLONG_ANY = 0x00;
+  /**
+   * Screen layout: value that corresponds to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenAspectQualifier">notlong</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_SCREENLONG_NO = 0x1;
+  /**
+   * Screen layout: value that corresponds to the
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenAspectQualifier">long</a>
+   * resource qualifier.
+   */
+  public static final int ACONFIGURATION_SCREENLONG_YES = 0x2;
+  public static final int ACONFIGURATION_SCREENROUND_ANY = 0x00;
+  public static final int ACONFIGURATION_SCREENROUND_NO = 0x1;
+  public static final int ACONFIGURATION_SCREENROUND_YES = 0x2;
+
+  /** Wide color gamut: not specified. */
+  public static final int ACONFIGURATION_WIDE_COLOR_GAMUT_ANY = 0x00;
+  /**
+   * Wide color gamut: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#WideColorGamutQualifier">no
+   * nowidecg</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_WIDE_COLOR_GAMUT_NO = 0x1;
+  /**
+   * Wide color gamut: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#WideColorGamutQualifier">
+   * widecg</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_WIDE_COLOR_GAMUT_YES = 0x2;
+
+  /** HDR: not specified. */
+  public static final int ACONFIGURATION_HDR_ANY = 0x00;
+  /**
+   * HDR: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#HDRQualifier">
+   * lowdr</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_HDR_NO = 0x1;
+  /**
+   * HDR: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#HDRQualifier">
+   * highdr</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_HDR_YES = 0x2;
+
+  /** UI mode: not specified. */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_ANY = 0x00;
+  /**
+   * UI mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">no
+   * UI mode type</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_NORMAL = 0x01;
+  /**
+   * UI mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">desk</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_DESK = 0x02;
+  /**
+   * UI mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">car</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_CAR = 0x03;
+  /**
+   * UI mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">television</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_TELEVISION = 0x04;
+  /**
+   * UI mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">appliance</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_APPLIANCE = 0x05;
+  /**
+   * UI mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">watch</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_WATCH = 0x06;
+   /**
+  * UI mode: value that corresponds to
+  * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">vr</a> resource qualifier specified.
+  */
+  public static final int ACONFIGURATION_UI_MODE_TYPE_VR_HEADSET = 0x07;
+  /** UI night mode: not specified.*/
+  public static final int ACONFIGURATION_UI_MODE_NIGHT_ANY = 0x00;
+  /**
+   * UI night mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NightQualifier">notnight</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_NIGHT_NO = 0x1;
+  /**
+   * UI night mode: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NightQualifier">night</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_UI_MODE_NIGHT_YES = 0x2;
+  /** Screen width DPI: not specified. */
+  public static final int ACONFIGURATION_SCREEN_WIDTH_DP_ANY = 0x0000;
+  /** Screen height DPI: not specified. */
+  public static final int ACONFIGURATION_SCREEN_HEIGHT_DP_ANY = 0x0000;
+  /** Smallest screen width DPI: not specified.*/
+  public static final int ACONFIGURATION_SMALLEST_SCREEN_WIDTH_DP_ANY = 0x0000;
+  /** Layout direction: not specified. */
+  public static final int ACONFIGURATION_LAYOUTDIR_ANY  = 0x00;
+  /**
+   * Layout direction: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#LayoutDirectionQualifier">ldltr</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_LAYOUTDIR_LTR  = 0x01;
+  /**
+   * Layout direction: value that corresponds to
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#LayoutDirectionQualifier">ldrtl</a> resource qualifier specified.
+   */
+  public static final int ACONFIGURATION_LAYOUTDIR_RTL  = 0x02;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#MccQualifier">mcc</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_MCC = 0x0001;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#MccQualifier">mnc</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_MNC = 0x0002;
+  /**
+   * Bit mask for
+   * <a href="{@docRoot}guide/topics/resources/providing-resources.html#LocaleQualifier">locale</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_LOCALE = 0x0004;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#TouchscreenQualifier">touchscreen</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_TOUCHSCREEN = 0x0008;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ImeQualifier">keyboard</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_KEYBOARD = 0x0010;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#KeyboardAvailQualifier">keyboardHidden</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_KEYBOARD_HIDDEN = 0x0020;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#NavigationQualifier">navigation</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_NAVIGATION = 0x0040;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#OrientationQualifier">orientation</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_ORIENTATION = 0x0080;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#DensityQualifier">density</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_DENSITY = 0x0100;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#ScreenSizeQualifier">screen size</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_SCREEN_SIZE = 0x0200;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#VersionQualifier">platform version</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_VERSION = 0x0400;
+  /**
+   * Bit mask for screen layout configuration.
+   */
+  public static final int ACONFIGURATION_SCREEN_LAYOUT = 0x0800;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#UiModeQualifier">ui mode</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_UI_MODE = 0x1000;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#SmallestScreenWidthQualifier">smallest screen width</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_SMALLEST_SCREEN_SIZE = 0x2000;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#LayoutDirectionQualifier">layout direction</a>
+   * configuration.
+   */
+  public static final int ACONFIGURATION_LAYOUTDIR = 0x4000;
+  public static final int ACONFIGURATION_SCREEN_ROUND = 0x8000;
+  /**
+   * Bit mask for
+   * <a href="@dacRoot/guide/topics/resources/providing-resources.html#WideColorGamutQualifier">wide color gamut</a>
+   * and <a href="@dacRoot/guide/topics/resources/providing-resources.html#HDRQualifier">HDR</a> configurations.
+   */
+  public static final int ACONFIGURATION_COLOR_MODE = 0x10000;
+  /**
+   * Constant used to to represent MNC (Mobile Network Code) zero.
+   * 0 cannot be used, since it is used to represent an undefined MNC.
+   */
+  public static final int ACONFIGURATION_MNC_ZERO = 0xffff;
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ApkAssetsCookie.java b/resources/src/main/java/org/robolectric/res/android/ApkAssetsCookie.java
new file mode 100644
index 0000000..cf97918
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ApkAssetsCookie.java
@@ -0,0 +1,32 @@
+package org.robolectric.res.android;
+
+public class ApkAssetsCookie {
+
+  public static final int kInvalidCookie = -1;
+  public static final ApkAssetsCookie K_INVALID_COOKIE = new ApkAssetsCookie(kInvalidCookie);
+
+  // hey memory/gc optimization!
+  private static final ApkAssetsCookie[] PREBAKED = new ApkAssetsCookie[256];
+  static {
+    for (int i = 0; i < PREBAKED.length; i++) {
+      PREBAKED[i] = new ApkAssetsCookie(i);
+    }
+  }
+
+  public static ApkAssetsCookie forInt(int cookie) {
+    if (cookie == kInvalidCookie) {
+      return K_INVALID_COOKIE;
+    }
+    return PREBAKED[cookie];
+  }
+
+  private final int cookie;
+
+  private ApkAssetsCookie(int cookie) {
+    this.cookie = cookie;
+  }
+
+  public int intValue() {
+    return cookie;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Asset.java b/resources/src/main/java/org/robolectric/res/android/Asset.java
new file mode 100644
index 0000000..a659a6c
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Asset.java
@@ -0,0 +1,1390 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Asset.AccessMode.ACCESS_BUFFER;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Util.ALOGE;
+import static org.robolectric.res.android.Util.ALOGV;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import org.robolectric.res.FileTypedResource;
+import org.robolectric.res.Fs;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/Asset.cpp
+// and
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/Asset.h
+/*
+ * Instances of this class provide read-only operations on a byte stream.
+ *
+ * Access may be optimized for streaming, random, or whole buffer modes.  All
+ * operations are supported regardless of how the file was opened, but some
+ * things will be less efficient.  [pass that in??]
+ *
+ * "Asset" is the base class for all types of assets.  The classes below
+ * provide most of the implementation.  The AssetManager uses one of the
+ * static "create" functions defined here to create a new instance.
+ */
+@SuppressWarnings("NewApi")
+public abstract class Asset {
+  public static final Asset EXCLUDED_ASSET = new _FileAsset();
+
+  public Runnable onClose;
+
+  public static Asset newFileAsset(FileTypedResource fileTypedResource) throws IOException {
+    _FileAsset fileAsset = new _FileAsset();
+    Path path = fileTypedResource.getPath();
+    fileAsset.mFileName = Fs.externalize(path);
+    fileAsset.mLength = Files.size(path);
+    fileAsset.mBuf = Fs.getBytes(path);
+    return fileAsset;
+  }
+
+  // public:
+  // virtual ~Asset(void) = default;
+
+  // static int getGlobalCount();
+  // static String8 getAssetAllocations();
+
+  public enum AccessMode {
+    ACCESS_UNKNOWN(0),
+    /* read chunks, and seek forward and backward */
+    ACCESS_RANDOM(1),
+    /* read sequentially, with an occasional forward seek */
+    ACCESS_STREAMING(2),
+    /* caller plans to ask for a read-only buffer with all data */
+    ACCESS_BUFFER(3);
+
+    private final int mode;
+
+    AccessMode(int mode) {
+      this.mode = mode;
+    }
+
+    public int mode() {
+      return mode;
+    }
+
+    public static AccessMode fromInt(int mode) {
+      for (AccessMode enumMode : values()) {
+        if (mode == enumMode.mode()) {
+          return enumMode;
+        }
+      }
+      throw new IllegalArgumentException("invalid mode " + Integer.toString(mode));
+    }
+  }
+
+  public static final int SEEK_SET = 0;
+  public static final int SEEK_CUR = 1;
+  public static final int SEEK_END = 2;
+
+  public final int read(byte[] buf, int count) {
+    return read(buf, 0, count);
+  }
+
+  /*
+   * Read data from the current offset.  Returns the actual number of
+   * bytes read, 0 on EOF, or -1 on error.
+   *
+   * Transliteration note: added bufOffset to translate to: index into buf to start writing at
+   */
+  public abstract int read(byte[] buf, int bufOffset, int count);
+
+  /*
+   * Seek to the specified offset.  "whence" uses the same values as
+   * lseek/fseek.  Returns the new position on success, or (long) -1
+   * on failure.
+   */
+  public abstract long seek(long offset, int whence);
+
+    /*
+     * Close the asset, freeing all associated resources.
+     */
+    public abstract void close();
+
+    /*
+     * Get a pointer to a buffer with the entire contents of the file.
+     */
+  public abstract byte[] getBuffer(boolean wordAligned);
+
+  /*
+   * Get the total amount of data that can be read.
+   */
+  public abstract long getLength();
+
+  /*
+   * Get the total amount of data that can be read from the current position.
+   */
+  public abstract long getRemainingLength();
+
+    /*
+     * Open a new file descriptor that can be used to read this asset.
+     * Returns -1 if you can not use the file descriptor (for example if the
+     * asset is compressed).
+     */
+  public abstract FileDescriptor openFileDescriptor(Ref<Long> outStart, Ref<Long> outLength);
+
+  public abstract File getFile();
+
+  public abstract String getFileName();
+
+  /*
+   * Return whether this asset's buffer is allocated in RAM (not mmapped).
+   * Note: not virtual so it is safe to call even when being destroyed.
+   */
+  abstract boolean isAllocated(); // { return false; }
+
+  /*
+   * Get a string identifying the asset's source.  This might be a full
+   * path, it might be a colon-separated list of identifiers.
+   *
+   * This is NOT intended to be used for anything except debug output.
+   * DO NOT try to parse this or use it to open a file.
+   */
+  final String getAssetSource() { return mAssetSource.string(); }
+
+  public abstract boolean isNinePatch();
+
+//   protected:
+//   /*
+//    * Adds this Asset to the global Asset list for debugging and
+//    * accounting.
+//    * Concrete subclasses must call this in their finalructor.
+//    */
+//   static void registerAsset(Asset asset);
+//
+//   /*
+//    * Removes this Asset from the global Asset list.
+//    * Concrete subclasses must call this in their destructor.
+//    */
+//   static void unregisterAsset(Asset asset);
+//
+//   Asset(void);        // finalructor; only invoked indirectly
+//
+//   /* handle common seek() housekeeping */
+//   long handleSeek(long offset, int whence, long curPosn, long maxPosn);
+
+  /* set the asset source string */
+  void setAssetSource(final String8 path) { mAssetSource = path; }
+
+  AccessMode getAccessMode() { return mAccessMode; }
+
+//   private:
+//   /* these operations are not implemented */
+//   Asset(final Asset& src);
+//   Asset& operator=(final Asset& src);
+//
+//     /* AssetManager needs access to our "create" functions */
+//   friend class AssetManager;
+//
+//     /*
+//      * Create the asset from a named file on disk.
+//      */
+//   static Asset createFromFile(final String fileName, AccessMode mode);
+//
+//     /*
+//      * Create the asset from a named, compressed file on disk (e.g. ".gz").
+//      */
+//   static Asset createFromCompressedFile(final String fileName,
+//       AccessMode mode);
+//
+// #if 0
+//     /*
+//      * Create the asset from a segment of an open file.  This will fail
+//      * if "offset" and "length" don't fit within the bounds of the file.
+//      *
+//      * The asset takes ownership of the file descriptor.
+//      */
+//   static Asset createFromFileSegment(int fd, long offset, int length,
+//       AccessMode mode);
+//
+//     /*
+//      * Create from compressed data.  "fd" should be seeked to the start of
+//      * the compressed data.  This could be inside a gzip file or part of a
+//      * Zip archive.
+//      *
+//      * The asset takes ownership of the file descriptor.
+//      *
+//      * This may not verify the validity of the compressed data until first
+//      * use.
+//      */
+//   static Asset createFromCompressedData(int fd, long offset,
+//       int compressionMethod, int compressedLength,
+//       int uncompressedLength, AccessMode mode);
+// #endif
+//
+//     /*
+//      * Create the asset from a memory-mapped file segment.
+//      *
+//      * The asset takes ownership of the FileMap.
+//      */
+//   static Asset createFromUncompressedMap(FileMap dataMap, AccessMode mode);
+//
+//     /*
+//      * Create the asset from a memory-mapped file segment with compressed
+//      * data.
+//      *
+//      * The asset takes ownership of the FileMap.
+//      */
+//   static Asset createFromCompressedMap(FileMap dataMap,
+//       int uncompressedLen, AccessMode mode);
+//
+//
+//     /*
+//      * Create from a reference-counted chunk of shared memory.
+//      */
+//   // TODO
+
+  AccessMode  mAccessMode;        // how the asset was opened
+  String8    mAssetSource;       // debug string
+
+  Asset		mNext;				// linked list.
+  Asset		mPrev;
+
+  static final boolean kIsDebug = false;
+
+  final static Object gAssetLock = new Object();
+  static int gCount = 0;
+  static Asset gHead = null;
+  static Asset gTail = null;
+
+  void registerAsset(Asset asset)
+  {
+  //   AutoMutex _l(gAssetLock);
+  //   gCount++;
+  //   asset.mNext = asset.mPrev = null;
+  //   if (gTail == null) {
+  //     gHead = gTail = asset;
+  //   } else {
+  //     asset.mPrev = gTail;
+  //     gTail.mNext = asset;
+  //     gTail = asset;
+  //   }
+  //
+  //   if (kIsDebug) {
+  //     ALOGI("Creating Asset %s #%d\n", asset, gCount);
+  //   }
+  }
+
+  void unregisterAsset(Asset asset)
+  {
+  //   AutoMutex _l(gAssetLock);
+  //   gCount--;
+  //   if (gHead == asset) {
+  //     gHead = asset.mNext;
+  //   }
+  //   if (gTail == asset) {
+  //     gTail = asset.mPrev;
+  //   }
+  //   if (asset.mNext != null) {
+  //     asset.mNext.mPrev = asset.mPrev;
+  //   }
+  //   if (asset.mPrev != null) {
+  //     asset.mPrev.mNext = asset.mNext;
+  //   }
+  //   asset.mNext = asset.mPrev = null;
+  //
+  //   if (kIsDebug) {
+  //     ALOGI("Destroying Asset in %s #%d\n", asset, gCount);
+  //   }
+  }
+
+  public static int getGlobalCount()
+  {
+    // AutoMutex _l(gAssetLock);
+    synchronized (gAssetLock) {
+      return gCount;
+    }
+  }
+
+  public static String getAssetAllocations()
+  {
+    // AutoMutex _l(gAssetLock);
+    synchronized (gAssetLock) {
+      StringBuilder res = new StringBuilder();
+      Asset cur = gHead;
+      while (cur != null) {
+        if (cur.isAllocated()) {
+          res.append("    ");
+          res.append(cur.getAssetSource());
+          long size = (cur.getLength()+512)/1024;
+          String buf = String.format(": %dK\n", (int)size);
+          res.append(buf);
+        }
+        cur = cur.mNext;
+      }
+
+      return res.toString();
+    }
+  }
+
+  Asset() {
+    // : mAccessMode(ACCESS_UNKNOWN), mNext(null), mPrev(null)
+    mAccessMode = AccessMode.ACCESS_UNKNOWN;
+  }
+
+  /*
+   * Create a new Asset from a file on disk.  There is a fair chance that
+   * the file doesn't actually exist.
+   *
+   * We can use "mode" to decide how we want to go about it.
+   */
+  static Asset createFromFile(final String fileName, AccessMode mode)
+  {
+    File file = new File(fileName);
+    if (!file.exists()) {
+      return null;
+    }
+    throw new UnsupportedOperationException();
+
+    // _FileAsset pAsset;
+    // int result;
+    // long length;
+    // int fd;
+    //
+    //   fd = open(fileName, O_RDONLY | O_BINARY);
+    //   if (fd < 0)
+    //     return null;
+    //
+    //   /*
+    //    * Under Linux, the lseek fails if we actually opened a directory.  To
+    //    * be correct we should test the file type explicitly, but since we
+    //    * always open things read-only it doesn't really matter, so there's
+    //    * no value in incurring the extra overhead of an fstat() call.
+    //    */
+    //   // TODO(kroot): replace this with fstat despite the plea above.
+    //   #if 1
+    //   length = lseek64(fd, 0, SEEK_END);
+    //   if (length < 0) {
+    //   ::close(fd);
+    //     return null;
+    //   }
+    //   (void) lseek64(fd, 0, SEEK_SET);
+    //   #else
+    //   struct stat st;
+    //   if (fstat(fd, &st) < 0) {
+    //   ::close(fd);
+    //   return null;
+    // }
+    //
+    //   if (!S_ISREG(st.st_mode)) {
+    //   ::close(fd);
+    //     return null;
+    //   }
+    //   #endif
+    //
+    //     pAsset = new _FileAsset;
+    //   result = pAsset.openChunk(fileName, fd, 0, length);
+    //   if (result != NO_ERROR) {
+    //     delete pAsset;
+    //     return null;
+    //   }
+    //
+    //   pAsset.mAccessMode = mode;
+    //   return pAsset;
+  }
+
+  /*
+   * Create a new Asset from a compressed file on disk.  There is a fair chance
+   * that the file doesn't actually exist.
+   *
+   * We currently support gzip files.  We might want to handle .bz2 someday.
+   */
+  @SuppressWarnings("DoNotCallSuggester")
+  static Asset createFromCompressedFile(final String fileName, AccessMode mode) {
+    throw new UnsupportedOperationException();
+    // _CompressedAsset pAsset;
+    // int result;
+    // long fileLen;
+    // boolean scanResult;
+    // long offset;
+    // int method;
+    // long uncompressedLen, compressedLen;
+    // int fd;
+    //
+    // fd = open(fileName, O_RDONLY | O_BINARY);
+    // if (fd < 0)
+    //   return null;
+    //
+    // fileLen = lseek(fd, 0, SEEK_END);
+    // if (fileLen < 0) {
+    // ::close(fd);
+    //   return null;
+    // }
+    // (void) lseek(fd, 0, SEEK_SET);
+    //
+    // /* want buffered I/O for the file scan; must dup so fclose() is safe */
+    // FILE* fp = fdopen(dup(fd), "rb");
+    // if (fp == null) {
+    // ::close(fd);
+    //   return null;
+    // }
+    //
+    // unsigned long crc32;
+    // scanResult = ZipUtils::examineGzip(fp, &method, &uncompressedLen,
+    // &compressedLen, &crc32);
+    // offset = ftell(fp);
+    // fclose(fp);
+    // if (!scanResult) {
+    //   ALOGD("File '%s' is not in gzip format\n", fileName);
+    // ::close(fd);
+    //   return null;
+    // }
+    //
+    // pAsset = new _CompressedAsset;
+    // result = pAsset.openChunk(fd, offset, method, uncompressedLen,
+    //     compressedLen);
+    // if (result != NO_ERROR) {
+    //   delete pAsset;
+    //   return null;
+    // }
+    //
+    // pAsset.mAccessMode = mode;
+    // return pAsset;
+  }
+
+
+//     #if 0
+// /*
+//  * Create a new Asset from part of an open file.
+//  */
+// /*static*/ Asset createFromFileSegment(int fd, long offset,
+//       int length, AccessMode mode)
+//   {
+//     _FileAsset pAsset;
+//     int result;
+//
+//     pAsset = new _FileAsset;
+//     result = pAsset.openChunk(null, fd, offset, length);
+//     if (result != NO_ERROR)
+//       return null;
+//
+//     pAsset.mAccessMode = mode;
+//     return pAsset;
+//   }
+//
+// /*
+//  * Create a new Asset from compressed data in an open file.
+//  */
+// /*static*/ Asset createFromCompressedData(int fd, long offset,
+//       int compressionMethod, int uncompressedLen, int compressedLen,
+//       AccessMode mode)
+//   {
+//     _CompressedAsset pAsset;
+//     int result;
+//
+//     pAsset = new _CompressedAsset;
+//     result = pAsset.openChunk(fd, offset, compressionMethod,
+//         uncompressedLen, compressedLen);
+//     if (result != NO_ERROR)
+//       return null;
+//
+//     pAsset.mAccessMode = mode;
+//     return pAsset;
+//   }
+//     #endif
+
+  /*
+   * Create a new Asset from a memory mapping.
+   */
+  static Asset createFromUncompressedMap(FileMap dataMap,
+      AccessMode mode)
+  {
+    _FileAsset pAsset;
+    int result;
+
+    pAsset = new _FileAsset();
+    result = pAsset.openChunk(dataMap);
+    if (result != NO_ERROR)
+      return null;
+
+    pAsset.mAccessMode = mode;
+    return pAsset;
+  }
+
+  /*
+   * Create a new Asset from compressed data in a memory mapping.
+   */
+static Asset createFromCompressedMap(FileMap dataMap,
+      int uncompressedLen, AccessMode mode)
+  {
+    _CompressedAsset pAsset;
+    int result;
+
+    pAsset = new _CompressedAsset();
+    result = pAsset.openChunk(dataMap, uncompressedLen);
+    if (result != NO_ERROR)
+      return null;
+
+    pAsset.mAccessMode = mode;
+    return pAsset;
+  }
+
+
+  /*
+   * Do generic seek() housekeeping.  Pass in the offset/whence values from
+   * the seek request, along with the current chunk offset and the chunk
+   * length.
+   *
+   * Returns the new chunk offset, or -1 if the seek is illegal.
+   */
+  long handleSeek(long offset, int whence, long curPosn, long maxPosn)
+  {
+    long newOffset;
+
+    switch (whence) {
+      case SEEK_SET:
+        newOffset = offset;
+        break;
+      case SEEK_CUR:
+        newOffset = curPosn + offset;
+        break;
+      case SEEK_END:
+        newOffset = maxPosn + offset;
+        break;
+      default:
+        ALOGW("unexpected whence %d\n", whence);
+        // this was happening due to an long size mismatch
+        assert(false);
+        return (long) -1;
+    }
+
+    if (newOffset < 0 || newOffset > maxPosn) {
+      ALOGW("seek out of range: want %d, end=%d\n",
+          (long) newOffset, (long) maxPosn);
+      return (long) -1;
+    }
+
+    return newOffset;
+  }
+
+  /*
+   * An asset based on an uncompressed file on disk.  It may encompass the
+   * entire file or just a piece of it.  Access is through fread/fseek.
+   */
+  static class _FileAsset extends Asset {
+
+    // public:
+//     _FileAsset(void);
+//     virtual ~_FileAsset(void);
+//
+//     /*
+//      * Use a piece of an already-open file.
+//      *
+//      * On success, the object takes ownership of "fd".
+//      */
+//     int openChunk(final String fileName, int fd, long offset, int length);
+//
+//     /*
+//      * Use a memory-mapped region.
+//      *
+//      * On success, the object takes ownership of "dataMap".
+//      */
+//     int openChunk(FileMap dataMap);
+//
+//     /*
+//      * Standard Asset interfaces.
+//      */
+//     virtual ssize_t read(void* buf, int count);
+//     virtual long seek(long offset, int whence);
+//     virtual void close(void);
+//     virtual final void* getBuffer(boolean wordAligned);
+
+    @Override
+    public long getLength() { return mLength; }
+
+    @Override
+    public long getRemainingLength() { return mLength-mOffset; }
+
+//     virtual int openFileDescriptor(long* outStart, long* outLength) final;
+    @Override
+    boolean isAllocated() { return mBuf != null; }
+
+    @Override
+    public boolean isNinePatch() {
+      String fileName = getFileName();
+      if (mMap != null) {
+        fileName = mMap.getZipEntry().getName();
+      }
+      return fileName != null && fileName.toLowerCase().endsWith(".9.png");
+    }
+
+    //
+// private:
+    long mStart;         // absolute file offset of start of chunk
+    long mLength;        // length of the chunk
+    long mOffset;        // current local offset, 0 == mStart
+    // FILE*       mFp;            // for read/seek
+    RandomAccessFile mFp;            // for read/seek
+    String mFileName;      // for opening
+
+    /*
+     * To support getBuffer() we either need to read the entire thing into
+     * a buffer or memory-map it.  For small files it's probably best to
+     * just read them in.
+     */
+// enum {
+  public static int kReadVsMapThreshold = 4096;
+// };
+
+    FileMap mMap;           // for memory map
+    byte[] mBuf;        // for read
+
+    // final void* ensureAlignment(FileMap map);
+/*
+ * ===========================================================================
+ *      _FileAsset
+ * ===========================================================================
+ */
+
+    /*
+     * Constructor.
+     */
+    _FileAsset()
+    // : mStart(0), mLength(0), mOffset(0), mFp(null), mFileName(null), mMap(null), mBuf(null)
+    {
+      // Register the Asset with the global list here after it is fully constructed and its
+      // vtable pointer points to this concrete type.
+      registerAsset(this);
+    }
+
+    /*
+     * Destructor.  Release resources.
+     */
+    @Override
+    protected void finalize() {
+      close();
+
+      // Unregister the Asset from the global list here before it is destructed and while its vtable
+      // pointer still points to this concrete type.
+      unregisterAsset(this);
+    }
+
+    /*
+     * Operate on a chunk of an uncompressed file.
+     *
+     * Zero-length chunks are allowed.
+     */
+    int openChunk(final String fileName, int fd, long offset, int length) {
+      throw new UnsupportedOperationException();
+      // assert(mFp == null);    // no reopen
+      // assert(mMap == null);
+      // assert(fd >= 0);
+      // assert(offset >= 0);
+      //
+      // /*
+      //  * Seek to end to get file length.
+      //  */
+      // long fileLength;
+      // fileLength = lseek64(fd, 0, SEEK_END);
+      // if (fileLength == (long) -1) {
+      //   // probably a bad file descriptor
+      //   ALOGD("failed lseek (errno=%d)\n", errno);
+      //   return UNKNOWN_ERROR;
+      // }
+      //
+      // if ((long) (offset + length) > fileLength) {
+      //   ALOGD("start (%ld) + len (%ld) > end (%ld)\n",
+      //       (long) offset, (long) length, (long) fileLength);
+      //   return BAD_INDEX;
+      // }
+      //
+      // /* after fdopen, the fd will be closed on fclose() */
+      // mFp = fdopen(fd, "rb");
+      // if (mFp == null)
+      //   return UNKNOWN_ERROR;
+      //
+      // mStart = offset;
+      // mLength = length;
+      // assert(mOffset == 0);
+      //
+      // /* seek the FILE* to the start of chunk */
+      // if (fseek(mFp, mStart, SEEK_SET) != 0) {
+      //   assert(false);
+      // }
+      //
+      // mFileName = fileName != null ? strdup(fileName) : null;
+      //
+      // return NO_ERROR;
+    }
+
+    /*
+     * Create the chunk from the map.
+     */
+    int openChunk(FileMap dataMap) {
+      assert(mFp == null);    // no reopen
+      assert(mMap == null);
+      assert(dataMap != null);
+
+      mMap = dataMap;
+      mStart = -1;            // not used
+      mLength = dataMap.getDataLength();
+      assert(mOffset == 0);
+
+      mBuf = dataMap.getDataPtr();
+
+      return NO_ERROR;
+    }
+
+    /*
+     * Read a chunk of data.
+     */
+    @Override
+    public int read(byte[] buf, int bufOffset, int count) {
+      int maxLen;
+      int actual;
+
+      assert(mOffset >= 0 && mOffset <= mLength);
+
+      if (getAccessMode() == ACCESS_BUFFER) {
+          /*
+           * On first access, read or map the entire file.  The caller has
+           * requested buffer access, either because they're going to be
+           * using the buffer or because what they're doing has appropriate
+           * performance needs and access patterns.
+           */
+        if (mBuf == null)
+          getBuffer(false);
+      }
+
+      /* adjust count if we're near EOF */
+      maxLen = toIntExact(mLength - mOffset);
+      if (count > maxLen)
+        count = maxLen;
+
+      if (!isTruthy(count)) {
+        return 0;
+      }
+
+      if (mMap != null) {
+          /* copy from mapped area */
+        //printf("map read\n");
+        // memcpy(buf, (String)mMap.getDataPtr() + mOffset, count);
+        System.arraycopy(mMap.getDataPtr(), toIntExact(mOffset), buf, bufOffset, count);
+        actual = count;
+      } else if (mBuf != null) {
+          /* copy from buffer */
+        //printf("buf read\n");
+        // memcpy(buf, (String)mBuf + mOffset, count);
+        System.arraycopy(mBuf, toIntExact(mOffset), buf, bufOffset, count);
+        actual = count;
+      } else {
+          /* read from the file */
+        //printf("file read\n");
+        // if (ftell(mFp) != mStart + mOffset) {
+        try {
+          if (mFp.getFilePointer() != mStart + mOffset) {
+            ALOGE("Hosed: %d != %d+%d\n",
+                mFp.getFilePointer(), (long) mStart, (long) mOffset);
+            assert(false);
+          }
+
+          /*
+           * This returns 0 on error or eof.  We need to use ferror() or feof()
+           * to tell the difference, but we don't currently have those on the
+           * device.  However, we know how much data is *supposed* to be in the
+           * file, so if we don't read the full amount we know something is
+           * hosed.
+           */
+          actual = mFp.read(buf, 0, count);
+          if (actual == 0)        // something failed -- I/O error?
+            return -1;
+
+          assert(actual == count);
+        } catch (IOException e) {
+          throw new RuntimeException(e);
+        }
+      }
+
+      mOffset += actual;
+      return actual;
+    }
+
+    /*
+     * Seek to a new position.
+     */
+    @Override
+    public long seek(long offset, int whence) {
+      long newPosn;
+      long actualOffset;
+
+      // compute new position within chunk
+      newPosn = handleSeek(offset, whence, mOffset, mLength);
+      if (newPosn == (long) -1)
+        return newPosn;
+
+      actualOffset = mStart + newPosn;
+
+      if (mFp != null) {
+        throw new UnsupportedOperationException();
+        // if (fseek(mFp, (long) actualOffset, SEEK_SET) != 0)
+        //   return (long) -1;
+      }
+
+      mOffset = actualOffset - mStart;
+      return mOffset;
+    }
+
+    /*
+     * Close the asset.
+     */
+    @Override
+    public void close() {
+      throw new UnsupportedOperationException();
+      // if (mMap != null) {
+      //   delete mMap;
+      //   mMap = null;
+      // }
+      // if (mBuf != null) {
+      //   delete[] mBuf;
+      //   mBuf = null;
+      // }
+      //
+      // if (mFileName != null) {
+      //   free(mFileName);
+      //   mFileName = null;
+      // }
+      //
+      // if (mFp != null) {
+      //   // can only be null when called from destructor
+      //   // (otherwise we would never return this object)
+      //   fclose(mFp);
+      //   mFp = null;
+      // }
+    }
+
+    /*
+     * Return a read-only pointer to a buffer.
+     *
+     * We can either read the whole thing in or map the relevant piece of
+     * the source file.  Ideally a map would be established at a higher
+     * level and we'd be using a different object, but we didn't, so we
+     * deal with it here.
+     */
+    @Override
+    public final byte[] getBuffer(boolean wordAligned) {
+      /* subsequent requests just use what we did previously */
+      if (mBuf != null)
+        return mBuf;
+      if (mMap != null) {
+        // if (!wordAligned) {
+          return  mMap.getDataPtr();
+        // }
+        // return ensureAlignment(mMap);
+      }
+
+      // assert(mFp != null);
+
+      if (true /*mLength < kReadVsMapThreshold*/) {
+        byte[] buf;
+        int allocLen;
+
+          /* zero-length files are allowed; not sure about zero-len allocs */
+          /* (works fine with gcc + x86linux) */
+        allocLen = toIntExact(mLength);
+        if (mLength == 0)
+          allocLen = 1;
+
+        buf = new byte[allocLen];
+        if (buf == null) {
+          ALOGE("alloc of %d bytes failed\n", (long) allocLen);
+          return null;
+        }
+
+        ALOGV("Asset %s allocating buffer size %d (smaller than threshold)", this, (int)allocLen);
+        if (mLength > 0) {
+          try {
+            // long oldPosn = ftell(mFp);
+            long oldPosn = mFp.getFilePointer();
+            // fseek(mFp, mStart, SEEK_SET);
+            mFp.seek(mStart);
+            // if (fread(buf, 1, mLength, mFp) != (size_t) mLength) {
+            if (mFp.read(buf, 0, toIntExact(mLength)) != (int) mLength) {
+              ALOGE("failed reading %d bytes\n", (long) mLength);
+              // delete[] buf;
+              return null;
+            }
+            // fseek(mFp, oldPosn, SEEK_SET);
+            mFp.seek(oldPosn);
+          } catch (IOException e) {
+            throw new RuntimeException(e);
+          }
+        }
+
+        ALOGV(" getBuffer: loaded into buffer\n");
+
+        mBuf = buf;
+        return mBuf;
+      } else {
+        FileMap map;
+
+        map = new FileMap();
+        // if (!map.create(null, fileno(mFp), mStart, mLength, true)) {
+        if (!map.create(null, -1, mStart, toIntExact(mLength), true)) {
+          // delete map;
+          return null;
+        }
+
+        ALOGV(" getBuffer: mapped\n");
+
+        mMap = map;
+        // if (!wordAligned) {
+        //   return  mMap.getDataPtr();
+        // }
+        return ensureAlignment(mMap);
+      }
+    }
+
+    /**
+     * Return the file on disk representing this asset.
+     *
+     * Non-Android framework method. Based on {@link #openFileDescriptor(Ref, Ref)}.
+     */
+    @Override
+    public File getFile() {
+      if (mMap != null) {
+        String fname = mMap.getFileName();
+        if (fname == null) {
+          fname = mFileName;
+        }
+        if (fname == null) {
+          return null;
+        }
+        // return open(fname, O_RDONLY | O_BINARY);
+        return new File(fname);
+      }
+      if (mFileName == null) {
+        return null;
+      }
+      return new File(mFileName);
+    }
+
+    @Override
+    public String getFileName() {
+      File file = getFile();
+      return file == null ? null : file.getName();
+    }
+
+    @Override
+    public FileDescriptor openFileDescriptor(Ref<Long> outStart, Ref<Long> outLength) {
+      if (mMap != null) {
+        String fname = mMap.getFileName();
+        if (fname == null) {
+          fname = mFileName;
+        }
+        if (fname == null) {
+          return null;
+        }
+        outStart.set(mMap.getDataOffset());
+        outLength.set((long) mMap.getDataLength());
+        // return open(fname, O_RDONLY | O_BINARY);
+        return open(fname);
+      }
+      if (mFileName == null) {
+        return null;
+      }
+      outStart.set(mStart);
+      outLength.set(mLength);
+      // return open(mFileName, O_RDONLY | O_BINARY);
+      return open(mFileName);
+    }
+
+    private static FileDescriptor open(String fname) {
+      try {
+        return new FileInputStream(new File(fname)).getFD();
+      } catch (IOException e) {
+        return null;
+      }
+    }
+
+    @SuppressWarnings("DoNotCallSuggester")
+    final byte[] ensureAlignment(FileMap map) {
+      throw new UnsupportedOperationException();
+      //   void* data = map.getDataPtr();
+      //   if ((((int)data)&0x3) == 0) {
+      //     // We can return this directly if it is aligned on a word
+      //     // boundary.
+      //     ALOGV("Returning aligned FileAsset %s (%s).", this,
+      //         getAssetSource());
+      //     return data;
+      //   }
+      //   // If not aligned on a word boundary, then we need to copy it into
+      //   // our own buffer.
+      //   ALOGV("Copying FileAsset %s (%s) to buffer size %d to make it aligned.", this,
+      //       getAssetSource(), (int)mLength);
+      //   unsigned String buf = new unsigned char[mLength];
+      //   if (buf == null) {
+      //     ALOGE("alloc of %ld bytes failed\n", (long) mLength);
+      //     return null;
+      //   }
+      //   memcpy(buf, data, mLength);
+      //   mBuf = buf;
+      //   return buf;
+      // }
+    }
+
+    @Override
+    public String toString() {
+      if (mFileName == null) {
+        return "_FileAsset{" +
+            "mMap=" + mMap +
+            '}';
+      } else {
+        return "_FileAsset{" +
+            "mFileName='" + mFileName + '\'' +
+            '}';
+      }
+    }
+  }
+
+  /*
+   * An asset based on compressed data in a file.
+   */
+  static class _CompressedAsset extends Asset {
+// public:
+//     _CompressedAsset(void);
+//     virtual ~_CompressedAsset(void);
+//
+//     /*
+//      * Use a piece of an already-open file.
+//      *
+//      * On success, the object takes ownership of "fd".
+//      */
+//     int openChunk(int fd, long offset, int compressionMethod,
+//     int uncompressedLen, int compressedLen);
+//
+//     /*
+//      * Use a memory-mapped region.
+//      *
+//      * On success, the object takes ownership of "fd".
+//      */
+//     int openChunk(FileMap dataMap, int uncompressedLen);
+//
+//     /*
+//      * Standard Asset interfaces.
+//      */
+//     virtual ssize_t read(void* buf, int count);
+//     virtual long seek(long offset, int whence);
+//     virtual void close(void);
+//     virtual final void* getBuffer(boolean wordAligned);
+
+    @Override
+    public long getLength() { return mUncompressedLen; }
+
+    @Override
+    public long getRemainingLength() { return mUncompressedLen-mOffset; }
+
+    @Override
+    public File getFile() {
+      return null;
+    }
+
+    @Override
+    public String getFileName() {
+      ZipEntry zipEntry = mMap.getZipEntry();
+      return zipEntry == null ? null : zipEntry.getName();
+    }
+
+    @Override
+    public FileDescriptor openFileDescriptor(Ref<Long> outStart, Ref<Long> outLength) { return null; }
+
+    @Override
+    boolean isAllocated() { return mBuf != null; }
+
+    @Override
+    public boolean isNinePatch() {
+      String fileName = getFileName();
+      return fileName != null && fileName.toLowerCase().endsWith(".9.png");
+    }
+
+    // private:
+    long mStart;         // offset to start of compressed data
+    long mCompressedLen; // length of the compressed data
+    long mUncompressedLen; // length of the uncompressed data
+    long mOffset;        // current offset, 0 == start of uncomp data
+
+    FileMap mMap;           // for memory-mapped input
+    int mFd;            // for file input
+
+// class StreamingZipInflater mZipInflater;  // for streaming large compressed assets
+
+    byte[] mBuf;       // for getBuffer()
+/*
+ * ===========================================================================
+ *      _CompressedAsset
+ * ===========================================================================
+ */
+
+    /*
+     * Constructor.
+     */
+    _CompressedAsset()
+    // : mStart(0), mCompressedLen(0), mUncompressedLen(0), mOffset(0),
+    // mMap(null), mFd(-1), mZipInflater(null), mBuf(null)
+    {
+      mFd = -1;
+
+      // Register the Asset with the global list here after it is fully constructed and its
+      // vtable pointer points to this concrete type.
+      registerAsset(this);
+    }
+
+    ZipFile zipFile;
+    String entryName;
+
+    // @Override
+    // public byte[] getBuffer(boolean wordAligned) {
+    //   ZipEntry zipEntry = zipFile.getEntry(entryName);
+    //   int size = (int) zipEntry.getSize();
+    //   byte[] buf = new byte[size];
+    //   try (InputStream in = zipFile.getInputStream(zipEntry)) {
+    //     if (in.read(buf) != size) {
+    //       throw new IOException(
+    //           "Failed to read " + size + " bytes from " + zipFile + "!" + entryName);
+    //     }
+    //     return buf;
+    //   } catch (IOException e) {
+    //     throw new RuntimeException(e);
+    //   }
+    // }
+
+    /*
+     * Destructor.  Release resources.
+     */
+    @Override
+    protected void finalize() {
+      close();
+
+      // Unregister the Asset from the global list here before it is destructed and while its vtable
+      // pointer still points to this concrete type.
+      unregisterAsset(this);
+    }
+
+    /*
+     * Open a chunk of compressed data inside a file.
+     *
+     * This currently just sets up some values and returns.  On the first
+     * read, we expand the entire file into a buffer and return data from it.
+     */
+    int openChunk(int fd, long offset,
+        int compressionMethod, int uncompressedLen, int compressedLen) {
+      throw new UnsupportedOperationException();
+      // assert(mFd < 0);        // no re-open
+      // assert(mMap == null);
+      // assert(fd >= 0);
+      // assert(offset >= 0);
+      // assert(compressedLen > 0);
+      //
+      // if (compressionMethod != ZipFileRO::kCompressDeflated) {
+      // assert(false);
+      // return UNKNOWN_ERROR;
+      // }
+      //
+      // mStart = offset;
+      // mCompressedLen = compressedLen;
+      // mUncompressedLen = uncompressedLen;
+      // assert(mOffset == 0);
+      // mFd = fd;
+      // assert(mBuf == null);
+      //
+      // if (uncompressedLen > StreamingZipInflater::OUTPUT_CHUNK_SIZE) {
+      // mZipInflater = new StreamingZipInflater(mFd, offset, uncompressedLen, compressedLen);
+      // }
+      //
+      // return NO_ERROR;
+    }
+
+    /*
+     * Open a chunk of compressed data in a mapped region.
+     *
+     * Nothing is expanded until the first read call.
+     */
+    int openChunk(FileMap dataMap, int uncompressedLen) {
+      assert(mFd < 0);        // no re-open
+      assert(mMap == null);
+      assert(dataMap != null);
+
+      mMap = dataMap;
+      mStart = -1;        // not used
+      mCompressedLen = dataMap.getDataLength();
+      mUncompressedLen = uncompressedLen;
+      assert(mOffset == 0);
+
+      // if (uncompressedLen > StreamingZipInflater::OUTPUT_CHUNK_SIZE) {
+      // mZipInflater = new StreamingZipInflater(dataMap, uncompressedLen);
+      // }
+      return NO_ERROR;
+    }
+
+    /*
+     * Read data from a chunk of compressed data.
+     *
+     * [For now, that's just copying data out of a buffer.]
+     */
+    @Override
+    public int read(byte[] buf, int bufOffset, int count) {
+      int maxLen;
+      int actual;
+
+      assert(mOffset >= 0 && mOffset <= mUncompressedLen);
+
+       /* If we're relying on a streaming inflater, go through that */
+//       if (mZipInflater) {
+//       actual = mZipInflater.read(buf, count);
+//       } else {
+      if (mBuf == null) {
+        if (getBuffer(false) == null)
+          return -1;
+      }
+      assert(mBuf != null);
+
+      /* adjust count if we're near EOF */
+      maxLen = toIntExact(mUncompressedLen - mOffset);
+      if (count > maxLen)
+        count = maxLen;
+
+      if (!isTruthy(count))
+        return 0;
+
+      /* copy from buffer */
+      //printf("comp buf read\n");
+//      memcpy(buf, (String)mBuf + mOffset, count);
+      System.arraycopy(mBuf, toIntExact(mOffset), buf, bufOffset, count);
+      actual = count;
+//       }
+
+      mOffset += actual;
+      return actual;
+    }
+
+    /*
+     * Handle a seek request.
+     *
+     * If we're working in a streaming mode, this is going to be fairly
+     * expensive, because it requires plowing through a bunch of compressed
+     * data.
+     */
+    @Override
+    public long seek(long offset, int whence) {
+      long newPosn;
+
+      // compute new position within chunk
+      newPosn = handleSeek(offset, whence, mOffset, mUncompressedLen);
+      if (newPosn == -1) return newPosn;
+
+      // if (mZipInflater) {
+      //   mZipInflater.seekAbsolute(newPosn);
+      // }
+      mOffset = newPosn;
+      return mOffset;
+    }
+
+    /*
+     * Close the asset.
+     */
+    @Override
+    public void close() {
+       if (mMap != null) {
+//       delete mMap;
+       mMap = null;
+       }
+
+//       delete[] mBuf;
+       mBuf = null;
+
+//       delete mZipInflater;
+//       mZipInflater = null;
+
+       if (mFd > 0) {
+//       ::close(mFd);
+       mFd = -1;
+       }
+    }
+
+    /*
+     * Get a pointer to a read-only buffer of data.
+     *
+     * The first time this is called, we expand the compressed data into a
+     * buffer.
+     */
+    @Override
+    public byte[] getBuffer(boolean wordAligned) {
+      // return mBuf = mMap.getDataPtr();
+      byte[] buf = null;
+
+      if (mBuf != null)
+        return mBuf;
+
+      /*
+       * Allocate a buffer and read the file into it.
+       */
+      // buf = new byte[(int) mUncompressedLen];
+      // if (buf == null) {
+      //   ALOGW("alloc %ld bytes failed\n", (long) mUncompressedLen);
+      //   return null;
+      // }
+
+      if (mMap != null) {
+        buf = mMap.getDataPtr();
+        // if (!ZipUtils::inflateToBuffer(mMap.getDataPtr(), buf,
+        //     mUncompressedLen, mCompressedLen))
+        // return null;
+      } else {
+        throw new UnsupportedOperationException();
+        // assert(mFd >= 0);
+        //
+        // /*
+        //    * Seek to the start of the compressed data.
+        //    */
+        // if (lseek(mFd, mStart, SEEK_SET) != mStart)
+        // goto bail;
+        //
+        // /*
+        //    * Expand the data into it.
+        //    */
+        // if (!ZipUtils::inflateToBuffer(mFd, buf, mUncompressedLen,
+        //     mCompressedLen))
+        // goto bail;
+      }
+
+      /*
+       * Success - now that we have the full asset in RAM we
+       * no longer need the streaming inflater
+       */
+      // delete mZipInflater;
+      // mZipInflater = null;
+
+      mBuf = buf;
+      // buf = null;
+
+      // bail:
+      // delete[] buf;
+      return mBuf;
+    }
+
+    @Override
+    public String toString() {
+      return "_CompressedAsset{" +
+          "mMap=" + mMap +
+          '}';
+    }
+  }
+
+  // todo: remove when Android supports this
+  static int toIntExact(long value) {
+    if ((int)value != value) {
+      throw new ArithmeticException("integer overflow");
+    }
+    return (int)value;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/AssetDir.java b/resources/src/main/java/org/robolectric/res/android/AssetDir.java
new file mode 100644
index 0000000..b5a6901
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/AssetDir.java
@@ -0,0 +1,122 @@
+package org.robolectric.res.android;
+
+import org.robolectric.res.android.CppAssetManager.FileType;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/AssetDir.cpp and
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/AssetDir.h
+public class AssetDir {
+
+  private SortedVector<FileInfo> mFileInfo;
+
+  AssetDir() {
+    mFileInfo = null;
+  }
+
+  AssetDir(AssetDir src) {
+
+  }
+
+  /*
+ * Vector-style access.
+ */
+  public int getFileCount() {
+    return mFileInfo.size();
+  }
+
+  public String8 getFileName(int idx) {
+    return mFileInfo.itemAt(idx).getFileName();
+  }
+
+//    const String8& getSourceName(int idx) {
+//    return mFileInfo->itemAt(idx).getSourceName();
+//  }
+
+  /*
+   * Get the type of a file (usually regular or directory).
+   */
+//  FileType getFileType(int idx) {
+//    return mFileInfo->itemAt(idx).getFileType();
+//  }
+
+  /**
+   * This holds information about files in the asset hierarchy.
+   */
+  static class FileInfo implements Comparable<FileInfo> {
+    private String8    mFileName;    // filename only
+    private FileType mFileType;      // regular, directory, etc
+    private String8    mSourceName;  // currently debug-only
+
+    FileInfo() {}
+
+    FileInfo(String8 path) {      // useful for e.g. svect.indexOf
+            mFileName = path;
+            mFileType = FileType.kFileTypeUnknown;
+    }
+
+    FileInfo(FileInfo src) {
+      copyMembers(src);
+    }
+//        const FileInfo& operator= (const FileInfo& src) {
+//      if (this != &src)
+//        copyMembers(src);
+//      return *this;
+//    }
+
+    void copyMembers(final FileInfo src) {
+      mFileName = src.mFileName;
+      mFileType = src.mFileType;
+      mSourceName = src.mSourceName;
+    }
+
+    /* need this for SortedVector; must compare only on file name */
+//    bool operator< (const FileInfo& rhs) const {
+//      return mFileName < rhs.mFileName;
+//    }
+//
+//    /* used by AssetManager */
+//    bool operator== (const FileInfo& rhs) const {
+//      return mFileName == rhs.mFileName;
+//    }
+
+    void set(final String8 path, FileType type) {
+      mFileName = path;
+      mFileType = type;
+    }
+
+    String8 getFileName()  { return mFileName; }
+    void setFileName(String8 path) { mFileName = path; }
+
+    FileType getFileType() { return mFileType; }
+    void setFileType(FileType type) { mFileType = type; }
+
+    String8 getSourceName() { return mSourceName; }
+    void setSourceName(String8 path) { mSourceName = path; }
+
+    public boolean isLessThan(FileInfo fileInfo) {
+      return mFileName.string().compareTo(fileInfo.mFileName.string()) < 0;
+    }
+
+    @Override
+    public int compareTo(FileInfo other) {
+      return mFileName.string().compareTo(other.mFileName.string());
+    }
+
+    /*
+     * Handy utility for finding an entry in a sorted vector of FileInfo.
+     * Returns the index of the matching entry, or -1 if none found.
+     */
+    static int findEntry(SortedVector<FileInfo> pVector,
+             String8 fileName) {
+      FileInfo tmpInfo = new FileInfo();
+
+      tmpInfo.setFileName(fileName);
+      return pVector.indexOf(tmpInfo);
+    }
+
+
+  };
+
+  /* AssetManager uses this to initialize us */
+  void setFileList(SortedVector<FileInfo> list) { mFileInfo = list; }
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/AssetPath.java b/resources/src/main/java/org/robolectric/res/android/AssetPath.java
new file mode 100644
index 0000000..27ae7ab
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/AssetPath.java
@@ -0,0 +1,13 @@
+package org.robolectric.res.android;
+
+import java.nio.file.Path;
+
+public class AssetPath {
+  public final Path file;
+  public final boolean isSystem;
+
+  public AssetPath(Path file, boolean isSystem) {
+    this.file = file;
+    this.isSystem = isSystem;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/AttributeResolution.java b/resources/src/main/java/org/robolectric/res/android/AttributeResolution.java
new file mode 100644
index 0000000..752e0f4
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/AttributeResolution.java
@@ -0,0 +1,529 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Errors.BAD_INDEX;
+import static org.robolectric.res.android.Util.ALOGI;
+
+import org.robolectric.res.android.ResourceTypes.Res_value;
+import org.robolectric.util.Logger;
+
+public class AttributeResolution {
+  public static final boolean kThrowOnBadId = false;
+  private static final boolean kDebugStyles = false;
+
+  public static final int STYLE_NUM_ENTRIES = 6;
+  public static final int STYLE_TYPE = 0;
+  public static final int STYLE_DATA = 1;
+  public static final int STYLE_ASSET_COOKIE = 2;
+  public static final int STYLE_RESOURCE_ID = 3;
+  public static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+  public static final int STYLE_DENSITY = 5;
+
+  public static class BagAttributeFinder {
+
+    private final ResTable.bag_entry[] bag_entries;
+    private final int bagEndIndex;
+
+    public BagAttributeFinder(ResTable.bag_entry[] bag_entries, int bagEndIndex) {
+      this.bag_entries = bag_entries;
+      this.bagEndIndex = bagEndIndex;
+    }
+
+    public ResTable.bag_entry find(int curIdent) {
+      for (int curIndex = bagEndIndex - 1; curIndex >= 0; curIndex--) {
+        if (bag_entries[curIndex].map.name.ident == curIdent) {
+          return bag_entries[curIndex];
+        }
+      }
+      return null;
+    }
+  }
+
+  public static class XmlAttributeFinder {
+
+    private ResXMLParser xmlParser;
+
+    public XmlAttributeFinder(ResXMLParser xmlParser) {
+      this.xmlParser = xmlParser;
+    }
+
+    public int find(int curIdent) {
+      if (xmlParser == null) {
+        return 0;
+      }
+
+      int attributeCount = xmlParser.getAttributeCount();
+      for (int i = 0; i < attributeCount; i++) {
+        if (xmlParser.getAttributeNameResID(i) == curIdent) {
+          return i;
+        }
+      }
+      return attributeCount;
+    }
+  }
+
+  public static boolean ResolveAttrs(ResTableTheme theme, int defStyleAttr,
+                                     int defStyleRes, int[] srcValues,
+                                     int srcValuesLength, int[] attrs,
+                                     int attrsLength, int[] outValues, int[] outIndices) {
+    if (kDebugStyles) {
+      ALOGI(
+          "APPLY STYLE: theme=%s defStyleAttr=0x%x defStyleRes=0x%x",
+          theme, defStyleAttr, defStyleRes);
+    }
+
+    final ResTable res = theme.getResTable();
+    ResTable_config config = new ResTable_config();
+    Res_value value;
+
+    int indicesIdx = 0;
+
+    // Load default style from attribute, if specified...
+    Ref<Integer> defStyleBagTypeSetFlags = new Ref<>(0);
+    if (defStyleAttr != 0) {
+      Ref<Res_value> valueRef = new Ref<>(null);
+      if (theme.GetAttribute(defStyleAttr, valueRef, defStyleBagTypeSetFlags) >= 0) {
+        value = valueRef.get();
+        if (value.dataType == Res_value.TYPE_REFERENCE) {
+          defStyleRes = value.data;
+        }
+      }
+    }
+
+    // Now lock down the resource object and start pulling stuff from it.
+    res.lock();
+
+    // Retrieve the default style bag, if requested.
+    final Ref<ResTable.bag_entry[]> defStyleStart = new Ref<>(null);
+    Ref<Integer> defStyleTypeSetFlags = new Ref<>(0);
+    int bagOff = defStyleRes != 0
+        ? res.getBagLocked(defStyleRes, defStyleStart, defStyleTypeSetFlags) : -1;
+    defStyleTypeSetFlags.set(defStyleTypeSetFlags.get() | defStyleBagTypeSetFlags.get());
+//    const ResTable::bag_entry* const defStyleEnd = defStyleStart + (bagOff >= 0 ? bagOff : 0);
+    final int defStyleEnd = (bagOff >= 0 ? bagOff : 0);
+    BagAttributeFinder defStyleAttrFinder = new BagAttributeFinder(defStyleStart.get(), defStyleEnd);
+
+    // Now iterate through all of the attributes that the client has requested,
+    // filling in each with whatever data we can find.
+    int destOffset = 0;
+    for (int ii=0; ii<attrsLength; ii++) {
+      final int curIdent = attrs[ii];
+
+      if (kDebugStyles) {
+        ALOGI("RETRIEVING ATTR 0x%08x...", curIdent);
+      }
+
+      int block = -1;
+      int typeSetFlags = 0;
+
+      value = Res_value.NULL_VALUE;
+      config.density = 0;
+
+      // Try to find a value for this attribute...  we prioritize values
+      // coming from, first XML attributes, then XML style, then default
+      // style, and finally the theme.
+
+      // Retrieve the current input value if available.
+      if (srcValuesLength > 0 && srcValues[ii] != 0) {
+        value = new Res_value((byte) Res_value.TYPE_ATTRIBUTE, srcValues[ii]);
+        if (kDebugStyles) {
+          ALOGI("-> From values: type=0x%x, data=0x%08x", value.dataType, value.data);
+        }
+      } else {
+        final ResTable.bag_entry defStyleEntry = defStyleAttrFinder.find(curIdent);
+        if (defStyleEntry != null) {
+          block = defStyleEntry.stringBlock;
+          typeSetFlags = defStyleTypeSetFlags.get();
+          value = defStyleEntry.map.value;
+          if (kDebugStyles) {
+            ALOGI("-> From def style: type=0x%x, data=0x%08x", value.dataType, value.data);
+          }
+        }
+      }
+
+      int resid = 0;
+      Ref<Res_value> valueRef = new Ref<>(value);
+      Ref<Integer> residRef = new Ref<>(resid);
+      Ref<Integer> typeSetFlagsRef = new Ref<>(typeSetFlags);
+      Ref<ResTable_config> configRef = new Ref<>(config);
+      if (value.dataType != Res_value.TYPE_NULL) {
+        // Take care of resolving the found resource to its final value.
+        int newBlock = theme.resolveAttributeReference(valueRef, block,
+            residRef, typeSetFlagsRef, configRef);
+        value = valueRef.get();
+        resid = residRef.get();
+        typeSetFlags = typeSetFlagsRef.get();
+        config = configRef.get();
+        if (newBlock >= 0) block = newBlock;
+        if (kDebugStyles) {
+          ALOGI("-> Resolved attr: type=0x%x, data=0x%08x", value.dataType, value.data);
+        }
+      } else {
+        // If we still don't have a value for this attribute, try to find
+        // it in the theme!
+        int newBlock = theme.GetAttribute(curIdent, valueRef, typeSetFlagsRef);
+        value = valueRef.get();
+        typeSetFlags = typeSetFlagsRef.get();
+
+        if (newBlock >= 0) {
+          if (kDebugStyles) {
+            ALOGI("-> From theme: type=0x%x, data=0x%08x", value.dataType, value.data);
+          }
+          newBlock = res.resolveReference(valueRef, newBlock, residRef, typeSetFlagsRef, configRef);
+          value = valueRef.get();
+          resid = residRef.get();
+          typeSetFlags = typeSetFlagsRef.get();
+          config = configRef.get();
+          if (kThrowOnBadId) {
+            if (newBlock == BAD_INDEX) {
+              throw new IllegalStateException("Bad resource!");
+            }
+          }
+          if (newBlock >= 0) block = newBlock;
+          if (kDebugStyles) {
+            ALOGI("-> Resolved theme: type=0x%x, data=0x%08x", value.dataType, value.data);
+          }
+        }
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.dataType == Res_value.TYPE_REFERENCE && value.data == 0) {
+        if (kDebugStyles) {
+          ALOGI("-> Setting to @null!");
+        }
+        value = Res_value.NULL_VALUE;
+        block = -1;
+      }
+
+      if (kDebugStyles) {
+        ALOGI("Attribute 0x%08x: type=0x%x, data=0x%08x", curIdent, value.dataType,
+            value.data);
+      }
+
+      // Write the final value back to Java.
+      outValues[destOffset + STYLE_TYPE] = value.dataType;
+      outValues[destOffset + STYLE_DATA] = value.data;
+      outValues[destOffset + STYLE_ASSET_COOKIE] =
+          block != -1 ? res.getTableCookie(block) : -1;
+      outValues[destOffset + STYLE_RESOURCE_ID] = resid;
+      outValues[destOffset + STYLE_CHANGING_CONFIGURATIONS] = typeSetFlags;
+      outValues[destOffset + STYLE_DENSITY] = config.density;
+
+      if (outIndices != null && value.dataType != Res_value.TYPE_NULL) {
+        indicesIdx++;
+        outIndices[indicesIdx] = ii;
+      }
+
+      destOffset += STYLE_NUM_ENTRIES;
+    }
+
+    res.unlock();
+
+    if (outIndices != null) {
+      outIndices[0] = indicesIdx;
+    }
+    return true;
+  }
+
+  public static void ApplyStyle(ResTableTheme theme, ResXMLParser xmlParser, int defStyleAttr, int defStyleRes,
+                                int[] attrs, int attrsLength, int[] outValues, int[] outIndices) {
+    if (kDebugStyles) {
+      ALOGI("APPLY STYLE: theme=%s defStyleAttr=0x%x defStyleRes=0x%x xml=%s",
+          theme, defStyleAttr, defStyleRes, xmlParser);
+    }
+
+    final ResTable res = theme.getResTable();
+    Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+    Ref<Res_value> value = new Ref<>(new Res_value());
+
+    int indices_idx = 0;
+
+    // Load default style from attribute, if specified...
+    Ref<Integer> defStyleBagTypeSetFlags = new Ref<>(0);
+    if (defStyleAttr != 0) {
+      if (theme.GetAttribute(defStyleAttr, value, defStyleBagTypeSetFlags) >= 0) {
+        if (value.get().dataType == DataType.REFERENCE.code()) {
+          defStyleRes = value.get().data;
+        }
+      }
+    }
+
+    // Retrieve the style class associated with the current XML tag.
+    int style = 0;
+    Ref<Integer> styleBagTypeSetFlags = new Ref<>(0);
+    if (xmlParser != null) {
+      int idx = xmlParser.indexOfStyle();
+      if (idx >= 0 && xmlParser.getAttributeValue(idx, value) >= 0) {
+        if (value.get().dataType == DataType.ATTRIBUTE.code()) {
+          if (theme.GetAttribute(value.get().data, value, styleBagTypeSetFlags) < 0) {
+            value.set(value.get().withType(DataType.NULL.code()));
+          }
+        }
+        if (value.get().dataType == DataType.REFERENCE.code()) {
+          style = value.get().data;
+        }
+      }
+    }
+
+    // Now lock down the resource object and start pulling stuff from it.
+    res.lock();
+
+    // Retrieve the default style bag, if requested.
+    final Ref<ResTable.bag_entry[]> defStyleAttrStart = new Ref<>(null);
+    Ref<Integer> defStyleTypeSetFlags = new Ref<>(0);
+    int bagOff = defStyleRes != 0
+        ? res.getBagLocked(defStyleRes, defStyleAttrStart, defStyleTypeSetFlags)
+        : -1;
+    defStyleTypeSetFlags.set(defStyleTypeSetFlags.get() | defStyleBagTypeSetFlags.get());
+    // const ResTable::bag_entry* defStyleAttrEnd = defStyleAttrStart + (bagOff >= 0 ? bagOff : 0);
+    final ResTable.bag_entry defStyleAttrEnd = null;
+    // BagAttributeFinder defStyleAttrFinder = new BagAttributeFinder(defStyleAttrStart, defStyleAttrEnd);
+    BagAttributeFinder defStyleAttrFinder = new BagAttributeFinder(defStyleAttrStart.get(), bagOff);
+
+    // Retrieve the style class bag, if requested.
+    final Ref<ResTable.bag_entry[]> styleAttrStart = new Ref<>(null);
+    Ref<Integer> styleTypeSetFlags = new Ref<>(0);
+    bagOff = style != 0
+        ? res.getBagLocked(style, styleAttrStart, styleTypeSetFlags)
+        : -1;
+    styleTypeSetFlags.set(styleTypeSetFlags.get() | styleBagTypeSetFlags.get());
+    // final ResTable::bag_entry* final styleAttrEnd = styleAttrStart + (bagOff >= 0 ? bagOff : 0);
+    final ResTable.bag_entry styleAttrEnd = null;
+    //BagAttributeFinder styleAttrFinder = new BagAttributeFinder(styleAttrStart, styleAttrEnd);
+    BagAttributeFinder styleAttrFinder = new BagAttributeFinder(styleAttrStart.get(), bagOff);
+
+    // Retrieve the XML attributes, if requested.
+    final int kXmlBlock = 0x10000000;
+    XmlAttributeFinder xmlAttrFinder = new XmlAttributeFinder(xmlParser);
+    final int xmlAttrEnd = xmlParser != null ? xmlParser.getAttributeCount() : 0;
+
+    // Now iterate through all of the attributes that the client has requested,
+    // filling in each with whatever data we can find.
+    for (int ii = 0; ii < attrsLength; ii++) {
+      final int curIdent = attrs[ii];
+
+      if (kDebugStyles) {
+        ALOGI("RETRIEVING ATTR 0x%08x...", curIdent);
+      }
+
+      int block = kXmlBlock;
+      Ref<Integer> typeSetFlags = new Ref<>(0);
+
+      value.set(Res_value.NULL_VALUE);
+      config.get().density = 0;
+
+      // Try to find a value for this attribute...  we prioritize values
+      // coming from, first XML attributes, then XML style, then default
+      // style, and finally the theme.
+
+      // Walk through the xml attributes looking for the requested attribute.
+      final int xmlAttrIdx = xmlAttrFinder.find(curIdent);
+      if (xmlAttrIdx != xmlAttrEnd) {
+        // We found the attribute we were looking for.
+        xmlParser.getAttributeValue(xmlAttrIdx, value);
+        if (kDebugStyles) {
+          ALOGI("-> From XML: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+        }
+      }
+
+      if (value.get().dataType == DataType.NULL.code() && value.get().data != Res_value.DATA_NULL_EMPTY) {
+        // Walk through the style class values looking for the requested attribute.
+        final ResTable.bag_entry styleAttrEntry = styleAttrFinder.find(curIdent);
+        if (styleAttrEntry != styleAttrEnd) {
+          // We found the attribute we were looking for.
+          block = styleAttrEntry.stringBlock;
+          typeSetFlags.set(styleTypeSetFlags.get());
+          value.set(styleAttrEntry.map.value);
+          if (kDebugStyles) {
+            ALOGI("-> From style: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+          }
+        }
+      }
+
+      if (value.get().dataType == DataType.NULL.code() && value.get().data != Res_value.DATA_NULL_EMPTY) {
+        // Walk through the default style values looking for the requested attribute.
+        final ResTable.bag_entry defStyleAttrEntry = defStyleAttrFinder.find(curIdent);
+        if (defStyleAttrEntry != defStyleAttrEnd) {
+          // We found the attribute we were looking for.
+          block = defStyleAttrEntry.stringBlock;
+          typeSetFlags.set(styleTypeSetFlags.get());
+          value.set(defStyleAttrEntry.map.value);
+          if (kDebugStyles) {
+            ALOGI(
+                "-> From def style: type=0x%x, data=0x%08x",
+                value.get().dataType, value.get().data);
+          }
+        }
+      }
+
+      Ref<Integer> resid = new Ref<>(0);
+      if (value.get().dataType != DataType.NULL.code()) {
+        // Take care of resolving the found resource to its final value.
+        int newBlock = theme.resolveAttributeReference(value, block,
+            resid, typeSetFlags, config);
+        if (newBlock >= 0) {
+          block = newBlock;
+        }
+
+        if (kDebugStyles) {
+          ALOGI("-> Resolved attr: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+        }
+      } else if (value.get().data != Res_value.DATA_NULL_EMPTY) {
+        // If we still don't have a value for this attribute, try to find it in the theme!
+        int newBlock = theme.GetAttribute(curIdent, value, typeSetFlags);
+        if (newBlock >= 0) {
+          if (kDebugStyles) {
+            ALOGI("-> From theme: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+          }
+          newBlock = res.resolveReference(value, newBlock, resid, typeSetFlags, config);
+          if (newBlock >= 0) {
+            block = newBlock;
+          }
+
+          if (kDebugStyles) {
+            ALOGI(
+                "-> Resolved theme: type=0x%x, data=0x%08x",
+                value.get().dataType, value.get().data);
+          }
+        }
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.get().dataType == DataType.REFERENCE.code() && value.get().data == 0) {
+        if (kDebugStyles) {
+          ALOGI(". Setting to @null!");
+        }
+        value.set(Res_value.NULL_VALUE);
+        block = kXmlBlock;
+      }
+
+      if (kDebugStyles) {
+        ALOGI(
+            "Attribute 0x%08x: type=0x%x, data=0x%08x",
+            curIdent, value.get().dataType, value.get().data);
+      }
+
+      // Write the final value back to Java.
+      int destIndex = ii * STYLE_NUM_ENTRIES;
+      Res_value res_value = value.get();
+      outValues[destIndex + STYLE_TYPE] = res_value.dataType;
+      outValues[destIndex + STYLE_DATA] = res_value.data;
+      outValues[destIndex + STYLE_ASSET_COOKIE] =
+          block != kXmlBlock ? res.getTableCookie(block) : -1;
+      outValues[destIndex + STYLE_RESOURCE_ID] = resid.get();
+      outValues[destIndex + STYLE_CHANGING_CONFIGURATIONS] = typeSetFlags.get();
+      outValues[destIndex + STYLE_DENSITY] = config.get().density;
+
+      if (res_value.dataType != DataType.NULL.code() || res_value.data == Res_value.DATA_NULL_EMPTY) {
+        indices_idx++;
+
+        // out_indices must NOT be nullptr.
+        outIndices[indices_idx] = ii;
+      }
+
+      if (res_value.dataType == DataType.ATTRIBUTE.code()) {
+        ResTable.ResourceName attrName = new ResTable.ResourceName();
+        ResTable.ResourceName attrRefName = new ResTable.ResourceName();
+        boolean gotName = res.getResourceName(curIdent, true, attrName);
+        boolean gotRefName = res.getResourceName(res_value.data, true, attrRefName);
+        Logger.warn(
+            "Failed to resolve attribute lookup: %s=\"?%s\"; theme: %s",
+            gotName ? attrName : "unknown", gotRefName ? attrRefName : "unknown",
+            theme);
+      }
+
+//      out_values += STYLE_NUM_ENTRIES;
+    }
+
+    res.unlock();
+
+    // out_indices must NOT be nullptr.
+    outIndices[0] = indices_idx;
+  }
+
+  public static boolean RetrieveAttributes(ResTable res, ResXMLParser xmlParser, int[] attrs, int attrsLength, int[] outValues, int[] outIndices) {
+    Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+    Ref<Res_value> value = new Ref<>(null);
+
+    int indices_idx = 0;
+
+    // Now lock down the resource object and start pulling stuff from it.
+    res.lock();
+
+    // Retrieve the XML attributes, if requested.
+    final int xmlAttrCount = xmlParser.getAttributeCount();
+    int ix=0;
+    int curXmlAttr = xmlParser.getAttributeNameResID(ix);
+
+    final int kXmlBlock = 0x10000000;
+
+    // Now iterate through all of the attributes that the client has requested,
+    // filling in each with whatever data we can find.
+    int baseDest = 0;
+    for (int ii=0; ii<attrsLength; ii++) {
+      final int curIdent = attrs[ii];
+      int block = 0;
+      Ref<Integer> typeSetFlags = new Ref<>(0);
+
+      value.set(Res_value.NULL_VALUE);
+      config.get().density = 0;
+
+      // Try to find a value for this attribute...
+      // Skip through XML attributes until the end or the next possible match.
+      while (ix < xmlAttrCount && curIdent > curXmlAttr) {
+        ix++;
+        curXmlAttr = xmlParser.getAttributeNameResID(ix);
+      }
+      // Retrieve the current XML attribute if it matches, and step to next.
+      if (ix < xmlAttrCount && curIdent == curXmlAttr) {
+        block = kXmlBlock;
+        xmlParser.getAttributeValue(ix, value);
+        ix++;
+        curXmlAttr = xmlParser.getAttributeNameResID(ix);
+      }
+
+      //printf("Attribute 0x%08x: type=0x%x, data=0x%08x\n", curIdent, value.dataType, value.data);
+      Ref<Integer> resid = new Ref<>(0);
+      if (value.get().dataType != Res_value.TYPE_NULL) {
+        // Take care of resolving the found resource to its final value.
+        //printf("Resolving attribute reference\n");
+        int newBlock = res.resolveReference(value, block, resid,
+            typeSetFlags, config);
+        if (newBlock >= 0) block = newBlock;
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.get().dataType == Res_value.TYPE_REFERENCE && value.get().data == 0) {
+        value.set(Res_value.NULL_VALUE);
+        block = kXmlBlock;
+      }
+
+      //printf("Attribute 0x%08x: final type=0x%x, data=0x%08x\n", curIdent, value.dataType, value.data);
+
+      // Write the final value back to Java.
+      outValues[baseDest + STYLE_TYPE] = value.get().dataType;
+      outValues[baseDest + STYLE_DATA] = value.get().data;
+      outValues[baseDest + STYLE_ASSET_COOKIE] =
+          block != kXmlBlock ? res.getTableCookie(block) : -1;
+      outValues[baseDest + STYLE_RESOURCE_ID] = resid.get();
+      outValues[baseDest + STYLE_CHANGING_CONFIGURATIONS] = typeSetFlags.get();
+      outValues[baseDest + STYLE_DENSITY] = config.get().density;
+
+      if (outIndices != null &&
+          (value.get().dataType != Res_value.TYPE_NULL || value.get().data == Res_value.DATA_NULL_EMPTY)) {
+        indices_idx++;
+        outIndices[indices_idx] = ii;
+      }
+
+//      dest += STYLE_NUM_ENTRIES;
+      baseDest += STYLE_NUM_ENTRIES;
+    }
+
+    res.unlock();
+
+    if (outIndices != null) {
+      outIndices[0] = indices_idx;
+    }
+
+    return true;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/AttributeResolution10.java b/resources/src/main/java/org/robolectric/res/android/AttributeResolution10.java
new file mode 100644
index 0000000..2d5d734
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/AttributeResolution10.java
@@ -0,0 +1,530 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.ApkAssetsCookie.K_INVALID_COOKIE;
+import static org.robolectric.res.android.ApkAssetsCookie.kInvalidCookie;
+import static org.robolectric.res.android.Util.ALOGI;
+
+import java.util.Arrays;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag.Entry;
+import org.robolectric.res.android.CppAssetManager2.Theme;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+// TODO: update paths to released version.
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-10.0.0_rXX/libs/androidfw/AttributeResolution.cpp and
+// https://android.googlesource.com/platform/frameworks/base/+/android-10.0.0_rXX/libs/androidfw/include/androidfw/AttributeResolution.h
+
+public class AttributeResolution10 {
+    public static final boolean kThrowOnBadId = false;
+    private static final boolean kDebugStyles = false;
+
+    // Offsets into the outValues array populated by the methods below. outValues is a uint32_t
+    // array, but each logical element takes up 7 uint32_t-sized physical elements.
+    // Keep these in sync with android.content.res.TypedArray java class
+    public static final int STYLE_NUM_ENTRIES = 7;
+    public static final int STYLE_TYPE = 0;
+    public static final int STYLE_DATA = 1;
+    public static final int STYLE_ASSET_COOKIE = 2;
+    public static final int STYLE_RESOURCE_ID = 3;
+    public static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+    public static final int STYLE_DENSITY = 5;
+    public static final int STYLE_SOURCE_STYLE_RESOURCE_ID = 6;
+
+    // Java asset cookies have 0 as an invalid cookie, but TypedArray expects < 0.
+    private static int ApkAssetsCookieToJavaCookie(ApkAssetsCookie cookie) {
+        return cookie.intValue() != kInvalidCookie ? (cookie.intValue() + 1) : -1;
+    }
+
+    public static class XmlAttributeFinder {
+
+        private ResXMLParser xmlParser;
+
+        XmlAttributeFinder(ResXMLParser xmlParser) {
+            this.xmlParser = xmlParser;
+        }
+
+        public int Find(int curIdent) {
+            if (xmlParser == null) {
+                return -1;
+            }
+
+            int attributeCount = xmlParser.getAttributeCount();
+            for (int i = 0; i < attributeCount; i++) {
+                if (xmlParser.getAttributeNameResID(i) == curIdent) {
+                    return i;
+                }
+            }
+            return -1;
+        }
+    }
+
+    public static class BagAttributeFinder {
+        private final Entry[] bagEntries;
+
+        BagAttributeFinder(ResolvedBag bag) {
+            this.bagEntries = bag == null ? null : bag.entries;
+        }
+
+        // Robolectric: unoptimized relative to Android impl
+        Entry Find(int ident) {
+            Entry needle = new Entry();
+            needle.key = ident;
+
+            if (bagEntries == null) {
+                return null;
+            }
+
+            int i = Arrays.binarySearch(bagEntries, needle, (o1, o2) -> o1.key - o2.key);
+            return i < 0 ? null : bagEntries[i];
+        }
+    }
+
+    // These are all variations of the same method. They each perform the exact same operation,
+    // but on various data sources. I *think* they are re-written to avoid an extra branch
+    // in the inner loop, but after one branch miss (some pointer != null), the branch predictor should
+    // predict the rest of the iterations' branch correctly.
+    // TODO(adamlesinski): Run performance tests against these methods and a new, single method
+    // that uses all the sources and branches to the right ones within the inner loop.
+
+    // `out_values` must NOT be nullptr.
+    // `out_indices` may be nullptr.
+    public static boolean ResolveAttrs(Theme theme, int def_style_attr,
+            int def_style_res, int[] src_values,
+            int src_values_length, int[] attrs,
+            int attrs_length, int[] out_values, int[] out_indices) {
+        if (kDebugStyles) {
+            ALOGI("APPLY STYLE: theme=0x%s defStyleAttr=0x%x defStyleRes=0x%x", theme,
+                    def_style_attr, def_style_res);
+        }
+
+        CppAssetManager2 assetmanager = theme.GetAssetManager();
+        ResTable_config config = new ResTable_config();
+        Res_value value;
+
+        int indicesIdx = 0;
+
+        // Load default style from attribute, if specified...
+        final Ref<Integer> def_style_flags = new Ref<>(0);
+        if (def_style_attr != 0) {
+            final Ref<Res_value> valueRef = new Ref<>(null);
+            if (theme.GetAttribute(def_style_attr, valueRef, def_style_flags).intValue() != kInvalidCookie) {
+                value = valueRef.get();
+                if (value.dataType == Res_value.TYPE_REFERENCE) {
+                    def_style_res = value.data;
+                }
+            }
+        }
+
+        // Retrieve the default style bag, if requested.
+        ResolvedBag default_style_bag = null;
+        if (def_style_res != 0) {
+            default_style_bag = assetmanager.GetBag(def_style_res);
+            if (default_style_bag != null) {
+                def_style_flags.set(def_style_flags.get() | default_style_bag.type_spec_flags);
+            }
+        }
+        BagAttributeFinder def_style_attr_finder = new BagAttributeFinder(default_style_bag);
+
+        // Now iterate through all of the attributes that the client has requested,
+        // filling in each with whatever data we can find.
+        int destOffset = 0;
+        for (int ii=0; ii<attrs_length; ii++) {
+            final int cur_ident = attrs[ii];
+
+            if (kDebugStyles) {
+                ALOGI("RETRIEVING ATTR 0x%08x...", cur_ident);
+            }
+
+            ApkAssetsCookie cookie = K_INVALID_COOKIE;
+            int type_set_flags = 0;
+
+            value = Res_value.NULL_VALUE;
+            config.density = 0;
+
+            // Try to find a value for this attribute...  we prioritize values
+            // coming from, first XML attributes, then XML style, then default
+            // style, and finally the theme.
+
+            // Retrieve the current input value if available.
+            if (src_values_length > 0 && src_values[ii] != 0) {
+                value = new Res_value((byte) Res_value.TYPE_ATTRIBUTE, src_values[ii]);
+                if (kDebugStyles) {
+                    ALOGI("-> From values: type=0x%x, data=0x%08x", value.dataType, value.data);
+                }
+            } else {
+                final Entry entry = def_style_attr_finder.Find(cur_ident);
+                if (entry != null) {
+                    cookie = entry.cookie;
+                    type_set_flags = def_style_flags.get();
+                    value = entry.value;
+                    if (kDebugStyles) {
+                        ALOGI("-> From def style: type=0x%x, data=0x%08x", value.dataType, value.data);
+                    }
+                }
+            }
+
+            int resid = 0;
+            final Ref<Res_value> valueRef = new Ref<>(value);
+            final Ref<Integer> residRef = new Ref<>(resid);
+            final Ref<Integer> type_set_flagsRef = new Ref<>(type_set_flags);
+            final Ref<ResTable_config> configRef = new Ref<>(config);
+            if (value.dataType != Res_value.TYPE_NULL) {
+                // Take care of resolving the found resource to its final value.
+                ApkAssetsCookie new_cookie =
+                        theme.ResolveAttributeReference(cookie, valueRef, configRef, type_set_flagsRef, residRef);
+                if (new_cookie.intValue() != kInvalidCookie) {
+                    cookie = new_cookie;
+                }
+                if (kDebugStyles) {
+                    ALOGI("-> Resolved attr: type=0x%x, data=0x%08x", value.dataType, value.data);
+                }
+            } else if (value.data != Res_value.DATA_NULL_EMPTY) {
+                // If we still don't have a value for this attribute, try to find it in the theme!
+                ApkAssetsCookie new_cookie = theme.GetAttribute(cur_ident, valueRef, type_set_flagsRef);
+                if (new_cookie.intValue() != kInvalidCookie) {
+                    if (kDebugStyles) {
+                        ALOGI("-> From theme: type=0x%x, data=0x%08x", value.dataType, value.data);
+                    }
+                    new_cookie =
+                            assetmanager.ResolveReference(new_cookie, valueRef, configRef, type_set_flagsRef, residRef);
+                    if (new_cookie.intValue() != kInvalidCookie) {
+                        cookie = new_cookie;
+                    }
+                    if (kDebugStyles) {
+                        ALOGI("-> Resolved theme: type=0x%x, data=0x%08x", value.dataType, value.data);
+                    }
+                }
+            }
+            value = valueRef.get();
+            resid = residRef.get();
+            type_set_flags = type_set_flagsRef.get();
+            config = configRef.get();
+
+            // Deal with the special @null value -- it turns back to TYPE_NULL.
+            if (value.dataType == Res_value.TYPE_REFERENCE && value.data == 0) {
+                if (kDebugStyles) {
+                    ALOGI("-> Setting to @null!");
+                }
+                value = Res_value.NULL_VALUE;
+                cookie = K_INVALID_COOKIE;
+            }
+
+            if (kDebugStyles) {
+                ALOGI("Attribute 0x%08x: type=0x%x, data=0x%08x", cur_ident, value.dataType,
+                        value.data);
+            }
+
+            // Write the final value back to Java.
+            out_values[destOffset + STYLE_TYPE] = value.dataType;
+            out_values[destOffset + STYLE_DATA] = value.data;
+            out_values[destOffset + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+            out_values[destOffset + STYLE_RESOURCE_ID] = resid;
+            out_values[destOffset + STYLE_CHANGING_CONFIGURATIONS] = type_set_flags;
+            out_values[destOffset + STYLE_DENSITY] = config.density;
+
+            if (out_indices != null && value.dataType != Res_value.TYPE_NULL) {
+                indicesIdx++;
+                out_indices[indicesIdx] = ii;
+            }
+
+            destOffset += STYLE_NUM_ENTRIES;
+        }
+
+        if (out_indices != null) {
+            out_indices[0] = indicesIdx;
+        }
+        return true;
+    }
+
+    public static void ApplyStyle(Theme theme, ResXMLParser xml_parser, int def_style_attr,
+            int def_style_resid, int[] attrs, int attrs_length,
+            int[] out_values, int[] out_indices) {
+        if (kDebugStyles) {
+            ALOGI("APPLY STYLE: theme=%s defStyleAttr=0x%x defStyleRes=0x%x xml=%s",
+                    theme, def_style_attr, def_style_resid, xml_parser);
+        }
+
+        CppAssetManager2 assetmanager = theme.GetAssetManager();
+        final Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+        final Ref<Res_value> value = new Ref<>(new Res_value());
+
+        int indices_idx = 0;
+
+        // Load default style from attribute, if specified...
+        final Ref<Integer> def_style_flags = new Ref<>(0);
+        if (def_style_attr != 0) {
+            if (theme.GetAttribute(def_style_attr, value, def_style_flags).intValue() != kInvalidCookie) {
+                if (value.get().dataType == DataType.REFERENCE.code()) {
+                    def_style_resid = value.get().data;
+                }
+            }
+        }
+
+        // Retrieve the style resource ID associated with the current XML tag's style attribute.
+        int style_resid = 0;
+        final Ref<Integer> style_flags = new Ref<>(0);
+        if (xml_parser != null) {
+            int idx = xml_parser.indexOfStyle();
+            if (idx >= 0 && xml_parser.getAttributeValue(idx, value) >= 0) {
+                if (value.get().dataType == DataType.ATTRIBUTE.code()) {
+                    // Resolve the attribute with out theme.
+                    if (theme.GetAttribute(value.get().data, value, style_flags).intValue() == kInvalidCookie) {
+                        value.set(value.get().withType(DataType.NULL.code()));
+                    }
+                }
+
+                if (value.get().dataType == DataType.REFERENCE.code()) {
+                    style_resid = value.get().data;
+                }
+            }
+        }
+
+        // Retrieve the default style bag, if requested.
+        ResolvedBag default_style_bag = null;
+        if (def_style_resid != 0) {
+            default_style_bag = assetmanager.GetBag(def_style_resid);
+            if (default_style_bag != null) {
+                def_style_flags.set(def_style_flags.get() | default_style_bag.type_spec_flags);
+            }
+        }
+
+        BagAttributeFinder def_style_attr_finder = new BagAttributeFinder(default_style_bag);
+
+        // Retrieve the style class bag, if requested.
+        ResolvedBag xml_style_bag = null;
+        if (style_resid != 0) {
+            xml_style_bag = assetmanager.GetBag(style_resid);
+            if (xml_style_bag != null) {
+                style_flags.set(style_flags.get() | xml_style_bag.type_spec_flags);
+            }
+        }
+
+        BagAttributeFinder xml_style_attr_finder = new BagAttributeFinder(xml_style_bag);
+
+        // Retrieve the XML attributes, if requested.
+        XmlAttributeFinder xml_attr_finder = new XmlAttributeFinder(xml_parser);
+
+        // Now iterate through all of the attributes that the client has requested,
+        // filling in each with whatever data we can find.
+        for (int ii = 0; ii < attrs_length; ii++) {
+            final int cur_ident = attrs[ii];
+
+            if (kDebugStyles) {
+                ALOGI("RETRIEVING ATTR 0x%08x...", cur_ident);
+            }
+
+            ApkAssetsCookie cookie = K_INVALID_COOKIE;
+            final Ref<Integer> type_set_flags = new Ref<>(0);
+
+            value.set(Res_value.NULL_VALUE);
+            config.get().density = 0;
+            int source_style_resid = 0;
+
+            // Try to find a value for this attribute...  we prioritize values
+            // coming from, first XML attributes, then XML style, then default
+            // style, and finally the theme.
+
+            // Walk through the xml attributes looking for the requested attribute.
+            int xml_attr_idx = xml_attr_finder.Find(cur_ident);
+            if (xml_attr_idx != -1) {
+                // We found the attribute we were looking for.
+                xml_parser.getAttributeValue(xml_attr_idx, value);
+                type_set_flags.set(style_flags.get());
+                if (kDebugStyles) {
+                    ALOGI("-> From XML: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+                }
+            }
+
+            if (value.get().dataType == DataType.NULL.code() && value.get().data != Res_value.DATA_NULL_EMPTY) {
+                // Walk through the style class values looking for the requested attribute.
+                Entry entry = xml_style_attr_finder.Find(cur_ident);
+                if (entry != null) {
+                    // We found the attribute we were looking for.
+                    cookie = entry.cookie;
+                    type_set_flags.set(style_flags.get());
+                    value.set(entry.value);
+                    source_style_resid = entry.style;
+                    if (kDebugStyles) {
+                        ALOGI("-> From style: type=0x%x, data=0x%08x, style=0x%08x", value.get().dataType, value.get().data,
+                                entry.style);
+                    }
+                }
+            }
+
+            if (value.get().dataType == DataType.NULL.code() && value.get().data != Res_value.DATA_NULL_EMPTY) {
+                // Walk through the default style values looking for the requested attribute.
+                Entry entry = def_style_attr_finder.Find(cur_ident);
+                if (entry != null) {
+                    // We found the attribute we were looking for.
+                    cookie = entry.cookie;
+                    type_set_flags.set(def_style_flags.get());
+
+                    value.set(entry.value);
+                    if (kDebugStyles) {
+                        ALOGI("-> From def style: type=0x%x, data=0x%08x, style=0x%08x", value.get().dataType, value.get().data,
+                                entry.style);
+                    }
+                    source_style_resid = entry.style;
+                }
+            }
+
+            final Ref<Integer> resid = new Ref<>(0);
+            if (value.get().dataType != DataType.NULL.code()) {
+                // Take care of resolving the found resource to its final value.
+                ApkAssetsCookie new_cookie =
+                        theme.ResolveAttributeReference(cookie, value, config, type_set_flags, resid);
+                if (new_cookie.intValue() != kInvalidCookie) {
+                    cookie = new_cookie;
+                }
+
+                if (kDebugStyles) {
+                    ALOGI("-> Resolved attr: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+                }
+            } else if (value.get().data != Res_value.DATA_NULL_EMPTY) {
+                // If we still don't have a value for this attribute, try to find it in the theme!
+                ApkAssetsCookie new_cookie = theme.GetAttribute(cur_ident, value, type_set_flags);
+                if (new_cookie.intValue() != kInvalidCookie) {
+                    if (kDebugStyles) {
+                        ALOGI("-> From theme: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+                    }
+                    new_cookie =
+                            assetmanager.ResolveReference(new_cookie, value, config, type_set_flags, resid);
+                    if (new_cookie.intValue() != kInvalidCookie) {
+                        cookie = new_cookie;
+                    }
+
+                    if (kDebugStyles) {
+                        ALOGI("-> Resolved theme: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+                    }
+                }
+            }
+
+            // Deal with the special @null value -- it turns back to TYPE_NULL.
+            if (value.get().dataType == DataType.REFERENCE.code() && value.get().data == 0) {
+                if (kDebugStyles) {
+                    ALOGI(". Setting to @null!");
+                }
+                value.set(Res_value.NULL_VALUE);
+                cookie = K_INVALID_COOKIE;
+            }
+
+            if (kDebugStyles) {
+                ALOGI("Attribute 0x%08x: type=0x%x, data=0x%08x", cur_ident, value.get().dataType, value.get().data);
+            }
+
+            // Write the final value back to Java.
+            int destIndex = ii * STYLE_NUM_ENTRIES;
+            Res_value res_value = value.get();
+            out_values[destIndex + STYLE_TYPE] = res_value.dataType;
+            out_values[destIndex + STYLE_DATA] = res_value.data;
+            out_values[destIndex + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+            out_values[destIndex + STYLE_RESOURCE_ID] = resid.get();
+            out_values[destIndex + STYLE_CHANGING_CONFIGURATIONS] = type_set_flags.get();
+            out_values[destIndex + STYLE_DENSITY] = config.get().density;
+            out_values[destIndex + STYLE_SOURCE_STYLE_RESOURCE_ID] = source_style_resid;
+
+            if (res_value.dataType != DataType.NULL.code() || res_value.data == Res_value.DATA_NULL_EMPTY) {
+                indices_idx++;
+
+                // out_indices must NOT be nullptr.
+                out_indices[indices_idx] = ii;
+            }
+
+            // Robolectric-custom:
+            // if (false && res_value.dataType == DataType.ATTRIBUTE.code()) {
+            //   final Ref<ResourceName> attrName = new Ref<>(null);
+            //   final Ref<ResourceName> attrRefName = new Ref<>(null);
+            //   boolean gotName = assetmanager.GetResourceName(cur_ident, attrName);
+            //   boolean gotRefName = assetmanager.GetResourceName(res_value.data, attrRefName);
+            //   Logger.warn(
+            //       "Failed to resolve attribute lookup: %s=\"?%s\"; theme: %s",
+            //       gotName ? attrName.get() : "unknown", gotRefName ? attrRefName.get() : "unknown",
+            //       theme);
+            // }
+
+//      out_values += STYLE_NUM_ENTRIES;
+        }
+
+        // out_indices must NOT be nullptr.
+        out_indices[0] = indices_idx;
+    }
+
+    public static boolean RetrieveAttributes(CppAssetManager2 assetmanager, ResXMLParser xml_parser, int[] attrs,
+            int attrs_length, int[] out_values, int[] out_indices) {
+        final Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+        final Ref<Res_value> value = new Ref<>(null);
+
+        int indices_idx = 0;
+
+        // Retrieve the XML attributes, if requested.
+        final int xml_attr_count = xml_parser.getAttributeCount();
+        int ix = 0;
+        int cur_xml_attr = xml_parser.getAttributeNameResID(ix);
+
+        // Now iterate through all of the attributes that the client has requested,
+        // filling in each with whatever data we can find.
+        int baseDest = 0;
+        for (int ii = 0; ii < attrs_length; ii++) {
+            final int cur_ident = attrs[ii];
+            ApkAssetsCookie cookie = K_INVALID_COOKIE;
+            final Ref<Integer> type_set_flags = new Ref<>(0);
+
+            value.set(Res_value.NULL_VALUE);
+            config.get().density = 0;
+
+            // Try to find a value for this attribute...
+            // Skip through XML attributes until the end or the next possible match.
+            while (ix < xml_attr_count && cur_ident > cur_xml_attr) {
+                ix++;
+                cur_xml_attr = xml_parser.getAttributeNameResID(ix);
+            }
+            // Retrieve the current XML attribute if it matches, and step to next.
+            if (ix < xml_attr_count && cur_ident == cur_xml_attr) {
+                xml_parser.getAttributeValue(ix, value);
+                ix++;
+                cur_xml_attr = xml_parser.getAttributeNameResID(ix);
+            }
+
+            final Ref<Integer> resid = new Ref<>(0);
+            if (value.get().dataType != Res_value.TYPE_NULL) {
+                // Take care of resolving the found resource to its final value.
+                ApkAssetsCookie new_cookie =
+                        assetmanager.ResolveReference(cookie, value, config, type_set_flags, resid);
+                if (new_cookie.intValue() != kInvalidCookie) {
+                    cookie = new_cookie;
+                }
+            }
+
+            // Deal with the special @null value -- it turns back to TYPE_NULL.
+            if (value.get().dataType == Res_value.TYPE_REFERENCE && value.get().data == 0) {
+                value.set(Res_value.NULL_VALUE);
+                cookie = K_INVALID_COOKIE;
+            }
+
+            // Write the final value back to Java.
+            out_values[baseDest + STYLE_TYPE] = value.get().dataType;
+            out_values[baseDest + STYLE_DATA] = value.get().data;
+            out_values[baseDest + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+            out_values[baseDest + STYLE_RESOURCE_ID] = resid.get();
+            out_values[baseDest + STYLE_CHANGING_CONFIGURATIONS] = type_set_flags.get();
+            out_values[baseDest + STYLE_DENSITY] = config.get().density;
+
+            if (out_indices != null &&
+                    (value.get().dataType != Res_value.TYPE_NULL
+                            || value.get().data == Res_value.DATA_NULL_EMPTY)) {
+                indices_idx++;
+                out_indices[indices_idx] = ii;
+            }
+
+//      out_values += STYLE_NUM_ENTRIES;
+            baseDest += STYLE_NUM_ENTRIES;
+        }
+
+        if (out_indices != null) {
+            out_indices[0] = indices_idx;
+        }
+
+        return true;
+    }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/AttributeResolution9.java b/resources/src/main/java/org/robolectric/res/android/AttributeResolution9.java
new file mode 100644
index 0000000..4fcc26c
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/AttributeResolution9.java
@@ -0,0 +1,521 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.ApkAssetsCookie.K_INVALID_COOKIE;
+import static org.robolectric.res.android.ApkAssetsCookie.kInvalidCookie;
+import static org.robolectric.res.android.Util.ALOGI;
+
+import java.util.Arrays;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag.Entry;
+import org.robolectric.res.android.CppAssetManager2.Theme;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/AttributeResolution.cpp and
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/AttributeResolution.h
+
+public class AttributeResolution9 {
+  public static final boolean kThrowOnBadId = false;
+  private static final boolean kDebugStyles = false;
+
+  // Offsets into the outValues array populated by the methods below. outValues is a uint32_t
+  // array, but each logical element takes up 6 uint32_t-sized physical elements.
+  public static final int STYLE_NUM_ENTRIES = 6;
+  public static final int STYLE_TYPE = 0;
+  public static final int STYLE_DATA = 1;
+  public static final int STYLE_ASSET_COOKIE = 2;
+  public static final int STYLE_RESOURCE_ID = 3;
+  public static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+  public static final int STYLE_DENSITY = 5;
+
+  // Java asset cookies have 0 as an invalid cookie, but TypedArray expects < 0.
+  private static int ApkAssetsCookieToJavaCookie(ApkAssetsCookie cookie) {
+    return cookie.intValue() != kInvalidCookie ? (cookie.intValue() + 1) : -1;
+  }
+
+  public static class XmlAttributeFinder {
+
+    private ResXMLParser xmlParser;
+
+    XmlAttributeFinder(ResXMLParser xmlParser) {
+      this.xmlParser = xmlParser;
+    }
+
+    public int Find(int curIdent) {
+      if (xmlParser == null) {
+        return -1;
+      }
+
+      int attributeCount = xmlParser.getAttributeCount();
+      for (int i = 0; i < attributeCount; i++) {
+        if (xmlParser.getAttributeNameResID(i) == curIdent) {
+          return i;
+        }
+      }
+      return -1;
+    }
+  }
+
+  public static class BagAttributeFinder {
+    private final Entry[] bagEntries;
+
+    BagAttributeFinder(ResolvedBag bag) {
+      this.bagEntries = bag == null ? null : bag.entries;
+    }
+
+    // Robolectric: unoptimized relative to Android impl
+    Entry Find(int ident) {
+      Entry needle = new Entry();
+      needle.key = ident;
+
+      if (bagEntries == null) {
+        return null;
+      }
+
+      int i = Arrays.binarySearch(bagEntries, needle, (o1, o2) -> o1.key - o2.key);
+      return i < 0 ? null : bagEntries[i];
+    }
+  }
+
+  // These are all variations of the same method. They each perform the exact same operation,
+  // but on various data sources. I *think* they are re-written to avoid an extra branch
+  // in the inner loop, but after one branch miss (some pointer != null), the branch predictor should
+  // predict the rest of the iterations' branch correctly.
+  // TODO(adamlesinski): Run performance tests against these methods and a new, single method
+  // that uses all the sources and branches to the right ones within the inner loop.
+
+  // `out_values` must NOT be nullptr.
+  // `out_indices` may be nullptr.
+  public static boolean ResolveAttrs(Theme theme, int def_style_attr,
+                                     int def_style_res, int[] src_values,
+                                     int src_values_length, int[] attrs,
+                                     int attrs_length, int[] out_values, int[] out_indices) {
+    if (kDebugStyles) {
+      ALOGI("APPLY STYLE: theme=0x%s defStyleAttr=0x%x defStyleRes=0x%x", theme,
+          def_style_attr, def_style_res);
+    }
+
+    CppAssetManager2 assetmanager = theme.GetAssetManager();
+    ResTable_config config = new ResTable_config();
+    Res_value value;
+
+    int indicesIdx = 0;
+
+    // Load default style from attribute, if specified...
+    final Ref<Integer> def_style_flags = new Ref<>(0);
+    if (def_style_attr != 0) {
+      final Ref<Res_value> valueRef = new Ref<>(null);
+      if (theme.GetAttribute(def_style_attr, valueRef, def_style_flags).intValue() != kInvalidCookie) {
+        value = valueRef.get();
+        if (value.dataType == Res_value.TYPE_REFERENCE) {
+          def_style_res = value.data;
+        }
+      }
+    }
+
+    // Retrieve the default style bag, if requested.
+    ResolvedBag default_style_bag = null;
+    if (def_style_res != 0) {
+      default_style_bag = assetmanager.GetBag(def_style_res);
+      if (default_style_bag != null) {
+        def_style_flags.set(def_style_flags.get() | default_style_bag.type_spec_flags);
+      }
+    }
+    BagAttributeFinder def_style_attr_finder = new BagAttributeFinder(default_style_bag);
+
+    // Now iterate through all of the attributes that the client has requested,
+    // filling in each with whatever data we can find.
+    int destOffset = 0;
+    for (int ii=0; ii<attrs_length; ii++) {
+      final int cur_ident = attrs[ii];
+
+      if (kDebugStyles) {
+        ALOGI("RETRIEVING ATTR 0x%08x...", cur_ident);
+      }
+
+      ApkAssetsCookie cookie = K_INVALID_COOKIE;
+      int type_set_flags = 0;
+
+      value = Res_value.NULL_VALUE;
+      config.density = 0;
+
+      // Try to find a value for this attribute...  we prioritize values
+      // coming from, first XML attributes, then XML style, then default
+      // style, and finally the theme.
+
+      // Retrieve the current input value if available.
+      if (src_values_length > 0 && src_values[ii] != 0) {
+        value = new Res_value((byte) Res_value.TYPE_ATTRIBUTE, src_values[ii]);
+        if (kDebugStyles) {
+          ALOGI("-> From values: type=0x%x, data=0x%08x", value.dataType, value.data);
+        }
+      } else {
+        final Entry entry = def_style_attr_finder.Find(cur_ident);
+        if (entry != null) {
+          cookie = entry.cookie;
+          type_set_flags = def_style_flags.get();
+          value = entry.value;
+          if (kDebugStyles) {
+            ALOGI("-> From def style: type=0x%x, data=0x%08x", value.dataType, value.data);
+          }
+        }
+      }
+
+      int resid = 0;
+      final Ref<Res_value> valueRef = new Ref<>(value);
+      final Ref<Integer> residRef = new Ref<>(resid);
+      final Ref<Integer> type_set_flagsRef = new Ref<>(type_set_flags);
+      final Ref<ResTable_config> configRef = new Ref<>(config);
+      if (value.dataType != Res_value.TYPE_NULL) {
+        // Take care of resolving the found resource to its final value.
+        ApkAssetsCookie new_cookie =
+            theme.ResolveAttributeReference(cookie, valueRef, configRef, type_set_flagsRef, residRef);
+        if (new_cookie.intValue() != kInvalidCookie) {
+          cookie = new_cookie;
+        }
+        if (kDebugStyles) {
+          ALOGI("-> Resolved attr: type=0x%x, data=0x%08x", value.dataType, value.data);
+        }
+      } else if (value.data != Res_value.DATA_NULL_EMPTY) {
+        // If we still don't have a value for this attribute, try to find it in the theme!
+        ApkAssetsCookie new_cookie = theme.GetAttribute(cur_ident, valueRef, type_set_flagsRef);
+        if (new_cookie.intValue() != kInvalidCookie) {
+          if (kDebugStyles) {
+            ALOGI("-> From theme: type=0x%x, data=0x%08x", value.dataType, value.data);
+          }
+          new_cookie =
+              assetmanager.ResolveReference(new_cookie, valueRef, configRef, type_set_flagsRef, residRef);
+          if (new_cookie.intValue() != kInvalidCookie) {
+            cookie = new_cookie;
+          }
+          if (kDebugStyles) {
+            ALOGI("-> Resolved theme: type=0x%x, data=0x%08x", value.dataType, value.data);
+          }
+        }
+      }
+      value = valueRef.get();
+      resid = residRef.get();
+      type_set_flags = type_set_flagsRef.get();
+      config = configRef.get();
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.dataType == Res_value.TYPE_REFERENCE && value.data == 0) {
+        if (kDebugStyles) {
+          ALOGI("-> Setting to @null!");
+        }
+        value = Res_value.NULL_VALUE;
+        cookie = K_INVALID_COOKIE;
+      }
+
+      if (kDebugStyles) {
+        ALOGI("Attribute 0x%08x: type=0x%x, data=0x%08x", cur_ident, value.dataType,
+            value.data);
+      }
+
+      // Write the final value back to Java.
+      out_values[destOffset + STYLE_TYPE] = value.dataType;
+      out_values[destOffset + STYLE_DATA] = value.data;
+      out_values[destOffset + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+      out_values[destOffset + STYLE_RESOURCE_ID] = resid;
+      out_values[destOffset + STYLE_CHANGING_CONFIGURATIONS] = type_set_flags;
+      out_values[destOffset + STYLE_DENSITY] = config.density;
+
+      if (out_indices != null && value.dataType != Res_value.TYPE_NULL) {
+        indicesIdx++;
+        out_indices[indicesIdx] = ii;
+      }
+
+      destOffset += STYLE_NUM_ENTRIES;
+    }
+
+    if (out_indices != null) {
+      out_indices[0] = indicesIdx;
+    }
+    return true;
+  }
+
+  public static void ApplyStyle(Theme theme, ResXMLParser xml_parser, int def_style_attr,
+                                int def_style_resid, int[] attrs, int attrs_length,
+                                int[] out_values, int[] out_indices) {
+    if (kDebugStyles) {
+      ALOGI("APPLY STYLE: theme=%s defStyleAttr=0x%x defStyleRes=0x%x xml=%s",
+          theme, def_style_attr, def_style_resid, xml_parser);
+    }
+
+    CppAssetManager2 assetmanager = theme.GetAssetManager();
+    final Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+    final Ref<Res_value> value = new Ref<>(new Res_value());
+
+    int indices_idx = 0;
+
+    // Load default style from attribute, if specified...
+    final Ref<Integer> def_style_flags = new Ref<>(0);
+    if (def_style_attr != 0) {
+      if (theme.GetAttribute(def_style_attr, value, def_style_flags).intValue() != kInvalidCookie) {
+        if (value.get().dataType == DataType.REFERENCE.code()) {
+          def_style_resid = value.get().data;
+        }
+      }
+    }
+
+    // Retrieve the style resource ID associated with the current XML tag's style attribute.
+    int style_resid = 0;
+    final Ref<Integer> style_flags = new Ref<>(0);
+    if (xml_parser != null) {
+      int idx = xml_parser.indexOfStyle();
+      if (idx >= 0 && xml_parser.getAttributeValue(idx, value) >= 0) {
+        if (value.get().dataType == DataType.ATTRIBUTE.code()) {
+          // Resolve the attribute with out theme.
+          if (theme.GetAttribute(value.get().data, value, style_flags).intValue() == kInvalidCookie) {
+            value.set(value.get().withType(DataType.NULL.code()));
+          }
+        }
+
+        if (value.get().dataType == DataType.REFERENCE.code()) {
+          style_resid = value.get().data;
+        }
+      }
+    }
+
+    // Retrieve the default style bag, if requested.
+    ResolvedBag default_style_bag = null;
+    if (def_style_resid != 0) {
+      default_style_bag = assetmanager.GetBag(def_style_resid);
+      if (default_style_bag != null) {
+        def_style_flags.set(def_style_flags.get() | default_style_bag.type_spec_flags);
+      }
+    }
+
+    BagAttributeFinder def_style_attr_finder = new BagAttributeFinder(default_style_bag);
+
+    // Retrieve the style class bag, if requested.
+    ResolvedBag xml_style_bag = null;
+    if (style_resid != 0) {
+      xml_style_bag = assetmanager.GetBag(style_resid);
+      if (xml_style_bag != null) {
+        style_flags.set(style_flags.get() | xml_style_bag.type_spec_flags);
+      }
+    }
+
+    BagAttributeFinder xml_style_attr_finder = new BagAttributeFinder(xml_style_bag);
+
+    // Retrieve the XML attributes, if requested.
+    XmlAttributeFinder xml_attr_finder = new XmlAttributeFinder(xml_parser);
+
+    // Now iterate through all of the attributes that the client has requested,
+    // filling in each with whatever data we can find.
+    for (int ii = 0; ii < attrs_length; ii++) {
+      final int cur_ident = attrs[ii];
+
+      if (kDebugStyles) {
+        ALOGI("RETRIEVING ATTR 0x%08x...", cur_ident);
+      }
+
+      ApkAssetsCookie cookie = K_INVALID_COOKIE;
+      final Ref<Integer> type_set_flags = new Ref<>(0);
+
+      value.set(Res_value.NULL_VALUE);
+      config.get().density = 0;
+
+      // Try to find a value for this attribute...  we prioritize values
+      // coming from, first XML attributes, then XML style, then default
+      // style, and finally the theme.
+
+      // Walk through the xml attributes looking for the requested attribute.
+      int xml_attr_idx = xml_attr_finder.Find(cur_ident);
+      if (xml_attr_idx != -1) {
+        // We found the attribute we were looking for.
+        xml_parser.getAttributeValue(xml_attr_idx, value);
+        type_set_flags.set(style_flags.get());
+        if (kDebugStyles) {
+          ALOGI("-> From XML: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+        }
+      }
+
+      if (value.get().dataType == DataType.NULL.code() && value.get().data != Res_value.DATA_NULL_EMPTY) {
+        // Walk through the style class values looking for the requested attribute.
+        Entry entry = xml_style_attr_finder.Find(cur_ident);
+        if (entry != null) {
+          // We found the attribute we were looking for.
+          cookie = entry.cookie;
+          type_set_flags.set(style_flags.get());
+          value.set(entry.value);
+          if (kDebugStyles) {
+            ALOGI("-> From style: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+          }
+        }
+      }
+
+      if (value.get().dataType == DataType.NULL.code() && value.get().data != Res_value.DATA_NULL_EMPTY) {
+        // Walk through the default style values looking for the requested attribute.
+        Entry entry = def_style_attr_finder.Find(cur_ident);
+        if (entry != null) {
+          // We found the attribute we were looking for.
+          cookie = entry.cookie;
+          type_set_flags.set(def_style_flags.get());
+          
+          value.set(entry.value);
+          if (kDebugStyles) {
+            ALOGI("-> From def style: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+          }
+        }
+      }
+
+      final Ref<Integer> resid = new Ref<>(0);
+      if (value.get().dataType != DataType.NULL.code()) {
+        // Take care of resolving the found resource to its final value.
+        ApkAssetsCookie new_cookie =
+            theme.ResolveAttributeReference(cookie, value, config, type_set_flags, resid);
+        if (new_cookie.intValue() != kInvalidCookie) {
+          cookie = new_cookie;
+        }
+
+        if (kDebugStyles) {
+          ALOGI("-> Resolved attr: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+        }
+      } else if (value.get().data != Res_value.DATA_NULL_EMPTY) {
+        // If we still don't have a value for this attribute, try to find it in the theme!
+        ApkAssetsCookie new_cookie = theme.GetAttribute(cur_ident, value, type_set_flags);
+        if (new_cookie.intValue() != kInvalidCookie) {
+          if (kDebugStyles) {
+            ALOGI("-> From theme: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+          }
+          new_cookie =
+              assetmanager.ResolveReference(new_cookie, value, config, type_set_flags, resid);
+          if (new_cookie.intValue() != kInvalidCookie) {
+            cookie = new_cookie;
+          }
+
+          if (kDebugStyles) {
+            ALOGI("-> Resolved theme: type=0x%x, data=0x%08x", value.get().dataType, value.get().data);
+          }
+        }
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.get().dataType == DataType.REFERENCE.code() && value.get().data == 0) {
+        if (kDebugStyles) {
+          ALOGI(". Setting to @null!");
+        }
+        value.set(Res_value.NULL_VALUE);
+        cookie = K_INVALID_COOKIE;
+      }
+
+      if (kDebugStyles) {
+        ALOGI("Attribute 0x%08x: type=0x%x, data=0x%08x", cur_ident, value.get().dataType, value.get().data);
+      }
+
+      // Write the final value back to Java.
+      int destIndex = ii * STYLE_NUM_ENTRIES;
+      Res_value res_value = value.get();
+      out_values[destIndex + STYLE_TYPE] = res_value.dataType;
+      out_values[destIndex + STYLE_DATA] = res_value.data;
+      out_values[destIndex + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+      out_values[destIndex + STYLE_RESOURCE_ID] = resid.get();
+      out_values[destIndex + STYLE_CHANGING_CONFIGURATIONS] = type_set_flags.get();
+      out_values[destIndex + STYLE_DENSITY] = config.get().density;
+
+      if (res_value.dataType != DataType.NULL.code() || res_value.data == Res_value.DATA_NULL_EMPTY) {
+        indices_idx++;
+
+        // out_indices must NOT be nullptr.
+        out_indices[indices_idx] = ii;
+      }
+
+      // Robolectric-custom:
+      // if (false && res_value.dataType == DataType.ATTRIBUTE.code()) {
+      //   final Ref<ResourceName> attrName = new Ref<>(null);
+      //   final Ref<ResourceName> attrRefName = new Ref<>(null);
+      //   boolean gotName = assetmanager.GetResourceName(cur_ident, attrName);
+      //   boolean gotRefName = assetmanager.GetResourceName(res_value.data, attrRefName);
+      //   Logger.warn(
+      //       "Failed to resolve attribute lookup: %s=\"?%s\"; theme: %s",
+      //       gotName ? attrName.get() : "unknown", gotRefName ? attrRefName.get() : "unknown",
+      //       theme);
+      // }
+
+//      out_values += STYLE_NUM_ENTRIES;
+    }
+
+    // out_indices must NOT be nullptr.
+    out_indices[0] = indices_idx;
+  }
+
+  public static boolean RetrieveAttributes(CppAssetManager2 assetmanager, ResXMLParser xml_parser, int[] attrs,
+      int attrs_length, int[] out_values, int[] out_indices) {
+    final Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+    final Ref<Res_value> value = new Ref<>(null);
+
+    int indices_idx = 0;
+
+    // Retrieve the XML attributes, if requested.
+    final int xml_attr_count = xml_parser.getAttributeCount();
+    int ix = 0;
+    int cur_xml_attr = xml_parser.getAttributeNameResID(ix);
+
+    // Now iterate through all of the attributes that the client has requested,
+    // filling in each with whatever data we can find.
+    int baseDest = 0;
+    for (int ii = 0; ii < attrs_length; ii++) {
+      final int cur_ident = attrs[ii];
+      ApkAssetsCookie cookie = K_INVALID_COOKIE;
+      final Ref<Integer> type_set_flags = new Ref<>(0);
+
+      value.set(Res_value.NULL_VALUE);
+      config.get().density = 0;
+
+      // Try to find a value for this attribute...
+      // Skip through XML attributes until the end or the next possible match.
+      while (ix < xml_attr_count && cur_ident > cur_xml_attr) {
+        ix++;
+        cur_xml_attr = xml_parser.getAttributeNameResID(ix);
+      }
+      // Retrieve the current XML attribute if it matches, and step to next.
+      if (ix < xml_attr_count && cur_ident == cur_xml_attr) {
+        xml_parser.getAttributeValue(ix, value);
+        ix++;
+        cur_xml_attr = xml_parser.getAttributeNameResID(ix);
+      }
+
+      final Ref<Integer> resid = new Ref<>(0);
+      if (value.get().dataType != Res_value.TYPE_NULL) {
+        // Take care of resolving the found resource to its final value.
+        ApkAssetsCookie new_cookie =
+            assetmanager.ResolveReference(cookie, value, config, type_set_flags, resid);
+        if (new_cookie.intValue() != kInvalidCookie) {
+          cookie = new_cookie;
+        }
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.get().dataType == Res_value.TYPE_REFERENCE && value.get().data == 0) {
+        value.set(Res_value.NULL_VALUE);
+        cookie = K_INVALID_COOKIE;
+      }
+
+      // Write the final value back to Java.
+      out_values[baseDest + STYLE_TYPE] = value.get().dataType;
+      out_values[baseDest + STYLE_DATA] = value.get().data;
+      out_values[baseDest + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+      out_values[baseDest + STYLE_RESOURCE_ID] = resid.get();
+      out_values[baseDest + STYLE_CHANGING_CONFIGURATIONS] = type_set_flags.get();
+      out_values[baseDest + STYLE_DENSITY] = config.get().density;
+
+      if (out_indices != null &&
+          (value.get().dataType != Res_value.TYPE_NULL
+              || value.get().data == Res_value.DATA_NULL_EMPTY)) {
+        indices_idx++;
+        out_indices[indices_idx] = ii;
+      }
+
+//      out_values += STYLE_NUM_ENTRIES;
+      baseDest += STYLE_NUM_ENTRIES;
+    }
+
+    if (out_indices != null) {
+      out_indices[0] = indices_idx;
+    }
+
+    return true;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ByteBucketArray.java b/resources/src/main/java/org/robolectric/res/android/ByteBucketArray.java
new file mode 100644
index 0000000..c4d5088
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ByteBucketArray.java
@@ -0,0 +1,80 @@
+package org.robolectric.res.android;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/ByteBucketArray.h
+/**
+ * Stores a sparsely populated array. Has a fixed size of 256 (number of entries that a byte can
+ * represent).
+ */
+@SuppressWarnings("AndroidJdkLibsChecker")
+public abstract class ByteBucketArray<T> {
+  public ByteBucketArray(T mDefault) {
+    this.mDefault = mDefault;
+  }
+
+  final int size() {
+    return NUM_BUCKETS * BUCKET_SIZE;
+  }
+
+//  inline const T& get(size_t index) const {
+//    return (*this)[index];
+//  }
+
+  final T get(int index) {
+    if (index >= size()) {
+      return mDefault;
+    }
+
+    //    byte bucketIndex = static_cast<byte>(index) >> 4;
+    byte bucketIndex = (byte) (Byte.toUnsignedInt((byte) index) >> 4);
+    T[] bucket = (T[]) mBuckets[bucketIndex];
+    if (bucket == null) {
+      return mDefault;
+    }
+    T t = bucket[0x0f & ((byte) index)];
+    return t == null ? mDefault : t;
+  }
+
+  T editItemAt(int index) {
+    //    ALOG_ASSERT(index < size(), "ByteBucketArray.getOrCreate(index=%u) with size=%u",
+    //        (uint32_t) index, (uint32_t) size());
+
+    //    uint8_t bucketIndex = static_cast<uint8_t>(index) >> 4;
+    byte bucketIndex = (byte) (Byte.toUnsignedInt((byte) index) >> 4);
+    Object[] bucket = mBuckets[bucketIndex];
+    if (bucket == null) {
+      bucket = mBuckets[bucketIndex] = new Object[BUCKET_SIZE];
+    }
+//    return bucket[0x0f & static_cast<uint8_t>(index)];
+    T t = (T) bucket[0x0f & ((byte) index)];
+    if (t == null) {
+      t = newInstance();
+      bucket[0x0f & ((byte) index)] = t;
+    }
+    return t;
+  }
+
+  abstract T newInstance();
+
+  boolean set(int index, final T value) {
+    if (index >= size()) {
+      return false;
+    }
+
+    //    editItemAt(index) = value;
+    byte bucketIndex = (byte) (Byte.toUnsignedInt((byte) index) >> 4);
+    Object[] bucket = mBuckets[bucketIndex];
+    if (bucket == null) {
+      bucket = mBuckets[bucketIndex] = new Object[BUCKET_SIZE];
+    }
+    bucket[0x0f & ((byte) index)] = value;
+
+    return true;
+  }
+
+  private static final int NUM_BUCKETS = 16, BUCKET_SIZE = 16;
+
+  Object[][] mBuckets = new Object[NUM_BUCKETS][];
+  T mDefault;
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Chunk.java b/resources/src/main/java/org/robolectric/res/android/Chunk.java
new file mode 100644
index 0000000..9318af9
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Chunk.java
@@ -0,0 +1,227 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.nio.ByteBuffer;
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_lib_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_lib_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_package;
+import org.robolectric.res.android.ResourceTypes.ResTable_type;
+import org.robolectric.res.android.ResourceTypes.WithOffset;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ChunkIterator.cpp and
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/Chunk.h
+
+// Helpful wrapper around a ResChunk_header that provides getter methods
+// that handle endianness conversions and provide access to the data portion
+// of the chunk.
+class Chunk {
+
+  // public:
+  Chunk(ResChunk_header chunk) {
+    this.device_chunk_ = chunk;
+  }
+
+  // Returns the type of the chunk. Caller need not worry about endianness.
+  int type() {
+    return dtohs(device_chunk_.type);
+  }
+
+  // Returns the size of the entire chunk. This can be useful for skipping
+  // over the entire chunk. Caller need not worry about endianness.
+  int size() {
+    return dtohl(device_chunk_.size);
+  }
+
+  // Returns the size of the header. Caller need not worry about endianness.
+  int header_size() {
+    return dtohs(device_chunk_.headerSize);
+  }
+
+  // template <typename T, int MinSize = sizeof(T)>
+  // T* header() {
+  //   if (header_size() >= MinSize) {
+  //     return reinterpret_cast<T*>(device_chunk_);
+  //   }
+  //   return nullptr;
+  // }
+
+  ByteBuffer myBuf() {
+    return device_chunk_.myBuf();
+  }
+
+  int myOffset() {
+    return device_chunk_.myOffset();
+  }
+
+  public WithOffset data_ptr() {
+    return new WithOffset(device_chunk_.myBuf(), device_chunk_.myOffset() + header_size());
+  }
+
+  int data_size() {
+    return size() - header_size();
+  }
+
+  // private:
+  private ResChunk_header device_chunk_;
+
+  public ResTable_header asResTable_header() {
+    if (header_size() >= ResTable_header.SIZEOF) {
+      return new ResTable_header(device_chunk_.myBuf(), device_chunk_.myOffset());
+    } else {
+      return null;
+    }
+  }
+
+  public ResStringPool_header asResStringPool_header() {
+    if (header_size() >= ResStringPool_header.SIZEOF) {
+      return new ResStringPool_header(device_chunk_.myBuf(), device_chunk_.myOffset());
+    } else {
+      return null;
+    }
+  }
+
+  public ResTable_package asResTable_package(int size) {
+    if (header_size() >= size) {
+      return new ResTable_package(device_chunk_.myBuf(), device_chunk_.myOffset());
+    } else {
+      return null;
+    }
+  }
+
+  public ResTable_type asResTable_type(int size) {
+    if (header_size() >= size) {
+      return new ResTable_type(device_chunk_.myBuf(), device_chunk_.myOffset());
+    } else {
+      return null;
+    }
+  }
+
+  public ResTable_lib_header asResTable_lib_header() {
+    if (header_size() >= ResTable_lib_header.SIZEOF) {
+      return new ResTable_lib_header(device_chunk_.myBuf(), device_chunk_.myOffset());
+    } else {
+      return null;
+    }
+  }
+
+  public ResTable_lib_entry asResTable_lib_entry() {
+    if (header_size() >= ResTable_lib_entry.SIZEOF) {
+      return new ResTable_lib_entry(device_chunk_.myBuf(), device_chunk_.myOffset());
+    } else {
+      return null;
+    }
+  }
+
+  static class Iterator {
+    private ResChunk_header next_chunk_;
+    private int len_;
+    private String last_error_;
+    private boolean last_error_was_fatal_ = true;
+
+    public Iterator(WithOffset buf, int itemSize) {
+      this.next_chunk_ = new ResChunk_header(buf.myBuf(), buf.myOffset());
+      this.len_ = itemSize;
+    }
+
+    boolean HasNext() { return !HadError() && len_ != 0; };
+    // Returns whether there was an error and processing should stop
+    boolean HadError() { return last_error_ != null; }
+    String GetLastError() { return last_error_; }
+    // Returns whether there was an error and processing should stop. For legacy purposes,
+    // some errors are considered "non fatal". Fatal errors stop processing new chunks and
+    // throw away any chunks already processed. Non fatal errors also stop processing new
+    // chunks, but, will retain and use any valid chunks already processed.
+    boolean HadFatalError() { return HadError() && last_error_was_fatal_; }
+
+    Chunk Next() {
+      assert (len_ != 0) : "called Next() after last chunk";
+
+      ResChunk_header this_chunk = next_chunk_;
+
+      // We've already checked the values of this_chunk, so safely increment.
+      // next_chunk_ = reinterpret_cast<const ResChunk_header*>(
+      //     reinterpret_cast<const uint8_t*>(this_chunk) + dtohl(this_chunk->size));
+      int remaining = len_ - dtohl(this_chunk.size);
+      if (remaining <= 0) {
+        next_chunk_ = null;
+      } else {
+        next_chunk_ = new ResChunk_header(
+            this_chunk.myBuf(), this_chunk.myOffset() + dtohl(this_chunk.size));
+      }
+      len_ -= dtohl(this_chunk.size);
+
+      if (len_ != 0) {
+        // Prepare the next chunk.
+        if (VerifyNextChunkNonFatal()) {
+          VerifyNextChunk();
+        }
+      }
+      return new Chunk(this_chunk);
+    }
+
+    // Returns false if there was an error. For legacy purposes.
+    boolean VerifyNextChunkNonFatal() {
+      if (len_ < ResChunk_header.SIZEOF) {
+        last_error_ = "not enough space for header";
+        last_error_was_fatal_ = false;
+        return false;
+      }
+      int size = dtohl(next_chunk_.size);
+      if (size > len_) {
+        last_error_ = "chunk size is bigger than given data";
+        last_error_was_fatal_ = false;
+        return false;
+      }
+      return true;
+    }
+
+    // Returns false if there was an error.
+    boolean VerifyNextChunk() {
+      // uintptr_t header_start = reinterpret_cast<uintptr_t>(next_chunk_);
+      int header_start = next_chunk_.myOffset();
+
+      // This data must be 4-byte aligned, since we directly
+      // access 32-bit words, which must be aligned on
+      // certain architectures.
+      if (isTruthy(header_start & 0x03)) {
+        last_error_ = "header not aligned on 4-byte boundary";
+        return false;
+      }
+
+      if (len_ < ResChunk_header.SIZEOF) {
+        last_error_ = "not enough space for header";
+        return false;
+      }
+
+      int header_size = dtohs(next_chunk_.headerSize);
+      int size = dtohl(next_chunk_.size);
+      if (header_size < ResChunk_header.SIZEOF) {
+        last_error_ = "header size too small";
+        return false;
+      }
+
+      if (header_size > size) {
+        last_error_ = "header size is larger than entire chunk";
+        return false;
+      }
+
+      if (size > len_) {
+        last_error_ = "chunk size is bigger than given data";
+        return false;
+      }
+
+      if (isTruthy((size | header_size) & 0x03)) {
+        last_error_ = "header sizes are not aligned on 4-byte boundary";
+        return false;
+      }
+      return true;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ConfigDescription.java b/resources/src/main/java/org/robolectric/res/android/ConfigDescription.java
new file mode 100644
index 0000000..1f512c8
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ConfigDescription.java
@@ -0,0 +1,1018 @@
+package org.robolectric.res.android;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * transliterated from
+ * https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/tools/aapt2/ConfigDescription.cpp
+ */
+public class ConfigDescription {
+  public static final int SDK_CUPCAKE = 3;
+  public static final int SDK_DONUT = 4;
+  public static final int SDK_ECLAIR = 5;
+  public static final int SDK_ECLAIR_0_1 = 6;
+  public static final int SDK_ECLAIR_MR1 = 7;
+  public static final int SDK_FROYO = 8;
+  public static final int SDK_GINGERBREAD = 9;
+  public static final int SDK_GINGERBREAD_MR1 = 10;
+  public static final int SDK_HONEYCOMB = 11;
+  public static final int SDK_HONEYCOMB_MR1 = 12;
+  public static final int SDK_HONEYCOMB_MR2 = 13;
+  public static final int SDK_ICE_CREAM_SANDWICH = 14;
+  public static final int SDK_ICE_CREAM_SANDWICH_MR1 = 15;
+  public static final int SDK_JELLY_BEAN = 16;
+  public static final int SDK_JELLY_BEAN_MR1 = 17;
+  public static final int SDK_JELLY_BEAN_MR2 = 18;
+  public static final int SDK_KITKAT = 19;
+  public static final int SDK_KITKAT_WATCH = 20;
+  public static final int SDK_LOLLIPOP = 21;
+  public static final int SDK_LOLLIPOP_MR1 = 22;
+  public static final int SDK_MNC = 23;
+  public static final int SDK_NOUGAT = 24;
+  public static final int SDK_NOUGAT_MR1 = 25;
+  public static final int SDK_O = 26;
+
+  /**
+   * Constant used to to represent MNC (Mobile Network Code) zero.
+   * 0 cannot be used, since it is used to represent an undefined MNC.
+   */
+  private static final int ACONFIGURATION_MNC_ZERO = 0xffff;
+
+  private static final String kWildcardName = "any";
+
+  private static final Pattern MCC_PATTERN = Pattern.compile("mcc([\\d]+)");
+  private static final Pattern MNC_PATTERN = Pattern.compile("mnc([\\d]+)");
+  private static final Pattern SMALLEST_SCREEN_WIDTH_PATTERN = Pattern.compile("^sw([0-9]+)dp");
+  private static final Pattern SCREEN_WIDTH_PATTERN = Pattern.compile("^w([0-9]+)dp");
+  private static final Pattern SCREEN_HEIGHT_PATTERN = Pattern.compile("^h([0-9]+)dp");
+  private static final Pattern DENSITY_PATTERN = Pattern.compile("^([0-9]+)dpi");
+  private static final Pattern HEIGHT_WIDTH_PATTERN = Pattern.compile("^([0-9]+)x([0-9]+)");
+  private static final Pattern VERSION_QUALIFIER_PATTERN = Pattern.compile("v([0-9]+)$");
+
+  public static class LocaleValue {
+
+    String language;
+    String region;
+    String script;
+    String variant;
+
+    void set_language(String language_chars) {
+      language = language_chars.trim().toLowerCase();
+    }
+
+    void set_region(String region_chars) {
+      region = region_chars.trim().toUpperCase();
+    }
+
+    void set_script(String script_chars) {
+      script = String.valueOf(Character.toUpperCase(script_chars.charAt(0))) +
+          script_chars.substring(1).toLowerCase();
+    }
+
+    void set_variant(String variant_chars) {
+      variant = variant_chars.trim();
+    }
+
+
+    static boolean is_alpha(final String str) {
+      for (int i = 0; i < str.length(); i++) {
+        if (!Character.isAlphabetic(str.charAt(i))) {
+          return false;
+        }
+      }
+
+      return true;
+    }
+
+    int initFromParts(PeekingIterator<String> iter) {
+
+      String part = iter.peek();
+      if (part.startsWith("b+")) {
+        // This is a "modified" BCP 47 language tag. Same semantics as BCP 47 tags,
+        // except that the separator is "+" and not "-".
+        String[] subtags = part.substring(2).toLowerCase().split("\\+", 0);
+        if (subtags.length == 1) {
+          set_language(subtags[0]);
+        } else if (subtags.length == 2) {
+          set_language(subtags[0]);
+
+          // The second tag can either be a region, a variant or a script.
+          switch (subtags[1].length()) {
+            case 2:
+            case 3:
+              set_region(subtags[1]);
+              break;
+            case 4:
+              if ('0' <= subtags[1].charAt(0) && subtags[1].charAt(0) <= '9') {
+                // This is a variant: fall through
+              } else {
+                set_script(subtags[1]);
+                break;
+              }
+              // fall through
+            case 5:
+            case 6:
+            case 7:
+            case 8:
+              set_variant(subtags[1]);
+              break;
+            default:
+              return -1;
+          }
+        } else if (subtags.length == 3) {
+          // The language is always the first subtag.
+          set_language(subtags[0]);
+
+          // The second subtag can either be a script or a region code.
+          // If its size is 4, it's a script code, else it's a region code.
+          if (subtags[1].length() == 4) {
+            set_script(subtags[1]);
+          } else if (subtags[1].length() == 2 || subtags[1].length() == 3) {
+            set_region(subtags[1]);
+          } else {
+            return -1;
+          }
+
+          // The third tag can either be a region code (if the second tag was
+          // a script), else a variant code.
+          if (subtags[2].length() >= 4) {
+            set_variant(subtags[2]);
+          } else {
+            set_region(subtags[2]);
+          }
+        } else if (subtags.length == 4) {
+          set_language(subtags[0]);
+          set_script(subtags[1]);
+          set_region(subtags[2]);
+          set_variant(subtags[3]);
+        } else {
+          return -1;
+        }
+
+        iter.next();
+
+      } else {
+        if ((part.length() == 2 || part.length() == 3) && is_alpha(part) &&
+            !Objects.equals(part, "car")) {
+          set_language(part);
+          iter.next();
+
+          if (iter.hasNext()) {
+            final String region_part = iter.peek();
+            if (region_part.charAt(0) == 'r' && region_part.length() == 3) {
+              set_region(region_part.substring(1));
+              iter.next();
+            }
+          }
+        }
+      }
+
+      return 0;
+    }
+
+    public void writeTo(ResTable_config out) {
+      out.packLanguage(language);
+      out.packRegion(region);
+
+      Arrays.fill(out.localeScript, (byte) 0);
+      byte[] scriptBytes = script == null ? new byte[4] : script.getBytes(UTF_8);
+      System.arraycopy(scriptBytes, 0, out.localeScript, 0, scriptBytes.length);
+
+      Arrays.fill(out.localeVariant, (byte) 0);
+      byte[] variantBytes = variant == null ? new byte[8] : variant.getBytes(UTF_8);
+      System.arraycopy(variantBytes, 0, out.localeVariant, 0, variantBytes.length);
+    }
+  }
+
+  public static boolean parse(final String str, ResTable_config out) {
+    return parse(str, out, true);
+  }
+
+  public static boolean parse(final String str, ResTable_config out, boolean applyVersionForCompat) {
+    PeekingIterator<String> part_iter = Iterators
+        .peekingIterator(Arrays.asList(str.toLowerCase().split("-")).iterator());
+
+    LocaleValue locale = new LocaleValue();
+
+    boolean success = !part_iter.hasNext();
+    if (part_iter.hasNext() && parseMcc(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseMnc(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext()) {
+      // Locale spans a few '-' separators, so we let it
+      // control the index.
+      int parts_consumed = locale.initFromParts(part_iter);
+      if (parts_consumed < 0) {
+        return false;
+      } else {
+        locale.writeTo(out);
+        if (!part_iter.hasNext()) {
+          success = !part_iter.hasNext();
+        }
+      }
+    }
+
+    if (part_iter.hasNext() && parseLayoutDirection(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseSmallestScreenWidthDp(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseScreenWidthDp(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseScreenHeightDp(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseScreenLayoutSize(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseScreenLayoutLong(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseScreenRound(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseWideColorGamut(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseHdr(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseOrientation(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseUiModeType(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseUiModeNight(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseDensity(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseTouchscreen(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseKeysHidden(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseKeyboard(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseNavHidden(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseNavigation(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseScreenSize(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (part_iter.hasNext() && parseVersion(part_iter.peek(), out)) {
+      part_iter.next();
+      if (!part_iter.hasNext()) {
+        success = !part_iter.hasNext();
+      }
+    }
+
+    if (!success) {
+      // Unrecognized.
+      return false;
+    }
+
+    if (out != null && applyVersionForCompat) {
+      applyVersionForCompatibility(out);
+    }
+    return true;
+  }
+
+  private static boolean parseLayoutDirection(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_LAYOUTDIR) |
+                ResTable_config.LAYOUTDIR_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "ldltr")) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_LAYOUTDIR) |
+                ResTable_config.LAYOUTDIR_LTR;
+      }
+      return true;
+    } else if (Objects.equals(name, "ldrtl")) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_LAYOUTDIR) |
+                ResTable_config.LAYOUTDIR_RTL;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseSmallestScreenWidthDp(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.smallestScreenWidthDp = ResTable_config.SCREENWIDTH_ANY;
+      }
+      return true;
+    }
+
+    Matcher matcher = SMALLEST_SCREEN_WIDTH_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.smallestScreenWidthDp = Integer.parseInt(matcher.group(1));
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseScreenWidthDp(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenWidthDp = ResTable_config.SCREENWIDTH_ANY;
+      }
+      return true;
+    }
+
+    Matcher matcher = SCREEN_WIDTH_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.screenWidthDp = Integer.parseInt(matcher.group(1));
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseScreenHeightDp(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenHeightDp = ResTable_config.SCREENWIDTH_ANY;
+      }
+      return true;
+    }
+
+    Matcher matcher = SCREEN_HEIGHT_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.screenHeightDp = Integer.parseInt(matcher.group(1));
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseScreenLayoutSize(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_SCREENSIZE) |
+                ResTable_config.SCREENSIZE_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "small")) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_SCREENSIZE) |
+                ResTable_config.SCREENSIZE_SMALL;
+      }
+      return true;
+    } else if (Objects.equals(name, "normal")) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_SCREENSIZE) |
+                ResTable_config.SCREENSIZE_NORMAL;
+      }
+      return true;
+    } else if (Objects.equals(name, "large")) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_SCREENSIZE) |
+                ResTable_config.SCREENSIZE_LARGE;
+      }
+      return true;
+    } else if (Objects.equals(name, "xlarge")) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout & ~ResTable_config.MASK_SCREENSIZE) |
+                ResTable_config.SCREENSIZE_XLARGE;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  static boolean parseScreenLayoutLong(final String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenLayout =
+            (out.screenLayout&~ResTable_config.MASK_SCREENLONG)
+                | ResTable_config.SCREENLONG_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "long")) {
+      if (out != null) out.screenLayout =
+          (out.screenLayout&~ResTable_config.MASK_SCREENLONG)
+              | ResTable_config.SCREENLONG_YES;
+      return true;
+    } else if (Objects.equals(name, "notlong")) {
+      if (out != null) out.screenLayout =
+          (out.screenLayout&~ResTable_config.MASK_SCREENLONG)
+              | ResTable_config.SCREENLONG_NO;
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseScreenRound(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenLayout2 =
+            (byte) ((out.screenLayout2 & ~ResTable_config.MASK_SCREENROUND) |
+                            ResTable_config.SCREENROUND_ANY);
+      }
+      return true;
+    } else if (Objects.equals(name, "round")) {
+      if (out != null) {
+        out.screenLayout2 =
+            (byte) ((out.screenLayout2 & ~ResTable_config.MASK_SCREENROUND) |
+                            ResTable_config.SCREENROUND_YES);
+      }
+      return true;
+    } else if (Objects.equals(name, "notround")) {
+      if (out != null) {
+        out.screenLayout2 =
+            (byte) ((out.screenLayout2 & ~ResTable_config.MASK_SCREENROUND) |
+                ResTable_config.SCREENROUND_NO);
+      }
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseWideColorGamut(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null)
+        out.colorMode =
+            (byte) ((out.colorMode & ~ResTable_config.MASK_WIDE_COLOR_GAMUT) |
+                            ResTable_config.WIDE_COLOR_GAMUT_ANY);
+      return true;
+    } else if (Objects.equals(name, "widecg")) {
+      if (out != null)
+        out.colorMode =
+            (byte) ((out.colorMode & ~ResTable_config.MASK_WIDE_COLOR_GAMUT) |
+                            ResTable_config.WIDE_COLOR_GAMUT_YES);
+      return true;
+    } else if (Objects.equals(name, "nowidecg")) {
+      if (out != null)
+        out.colorMode =
+            (byte) ((out.colorMode & ~ResTable_config.MASK_WIDE_COLOR_GAMUT) |
+                            ResTable_config.WIDE_COLOR_GAMUT_NO);
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseHdr(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null)
+        out.colorMode =
+            (byte) ((out.colorMode & ~ResTable_config.MASK_HDR) |
+                            ResTable_config.HDR_ANY);
+      return true;
+    } else if (Objects.equals(name, "highdr")) {
+      if (out != null)
+        out.colorMode =
+            (byte) ((out.colorMode & ~ResTable_config.MASK_HDR) |
+                            ResTable_config.HDR_YES);
+      return true;
+    } else if (Objects.equals(name, "lowdr")) {
+      if (out != null)
+        out.colorMode =
+            (byte) ((out.colorMode & ~ResTable_config.MASK_HDR) |
+                            ResTable_config.HDR_NO);
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseOrientation(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.orientation = ResTable_config.ORIENTATION_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "port")) {
+      if (out != null) {
+        out.orientation = ResTable_config.ORIENTATION_PORT;
+      }
+      return true;
+    } else if (Objects.equals(name, "land")) {
+      if (out != null) {
+        out.orientation = ResTable_config.ORIENTATION_LAND;
+      }
+      return true;
+    } else if (Objects.equals(name, "square")) {
+      if (out != null) {
+        out.orientation = ResTable_config.ORIENTATION_SQUARE;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseUiModeType(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "desk")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_DESK;
+      }
+      return true;
+    } else if (Objects.equals(name, "car")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_CAR;
+      }
+      return true;
+    } else if (Objects.equals(name, "television")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_TELEVISION;
+      }
+      return true;
+    } else if (Objects.equals(name, "appliance")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_APPLIANCE;
+      }
+      return true;
+    } else if (Objects.equals(name, "watch")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_WATCH;
+      }
+      return true;
+    } else if (Objects.equals(name, "vrheadset")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_TYPE) |
+            ResTable_config.UI_MODE_TYPE_VR_HEADSET;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseUiModeNight(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_NIGHT) |
+            ResTable_config.UI_MODE_NIGHT_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "night")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_NIGHT) |
+            ResTable_config.UI_MODE_NIGHT_YES;
+      }
+      return true;
+    } else if (Objects.equals(name, "notnight")) {
+      if (out != null) {
+        out.uiMode = (out.uiMode & ~ResTable_config.MASK_UI_MODE_NIGHT) |
+            ResTable_config.UI_MODE_NIGHT_NO;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseDensity(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_DEFAULT;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "anydpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_ANY;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "nodpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_NONE;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "ldpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_LOW;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "mdpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_MEDIUM;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "tvdpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_TV;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "hdpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_HIGH;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "xhdpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_XHIGH;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "xxhdpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_XXHIGH;
+      }
+      return true;
+    }
+
+    if (Objects.equals(name, "xxxhdpi")) {
+      if (out != null) {
+        out.density = ResTable_config.DENSITY_XXXHIGH;
+      }
+      return true;
+    }
+
+    // check that we have 'dpi' after the last digit.
+    Matcher matcher = DENSITY_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.density = Integer.parseInt(matcher.group(1));
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseTouchscreen(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.touchscreen = ResTable_config.TOUCHSCREEN_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "notouch")) {
+      if (out != null) {
+        out.touchscreen = ResTable_config.TOUCHSCREEN_NOTOUCH;
+      }
+      return true;
+    } else if (Objects.equals(name, "stylus")) {
+      if (out != null) {
+        out.touchscreen = ResTable_config.TOUCHSCREEN_STYLUS;
+      }
+      return true;
+    } else if (Objects.equals(name, "finger")) {
+      if (out != null) {
+        out.touchscreen = ResTable_config.TOUCHSCREEN_FINGER;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseKeysHidden(String name, ResTable_config out) {
+    byte mask = 0;
+    byte value = 0;
+    if (Objects.equals(name, kWildcardName)) {
+      mask = ResTable_config.MASK_KEYSHIDDEN;
+      value = ResTable_config.KEYSHIDDEN_ANY;
+    } else if (Objects.equals(name, "keysexposed")) {
+      mask = ResTable_config.MASK_KEYSHIDDEN;
+      value = ResTable_config.KEYSHIDDEN_NO;
+    } else if (Objects.equals(name, "keyshidden")) {
+      mask = ResTable_config.MASK_KEYSHIDDEN;
+      value = ResTable_config.KEYSHIDDEN_YES;
+    } else if (Objects.equals(name, "keyssoft")) {
+      mask = ResTable_config.MASK_KEYSHIDDEN;
+      value = ResTable_config.KEYSHIDDEN_SOFT;
+    }
+
+    if (mask != 0) {
+      if (out != null) {
+        out.inputFlags = (out.inputFlags & ~mask) | value;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseKeyboard(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.keyboard = ResTable_config.KEYBOARD_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "nokeys")) {
+      if (out != null) {
+        out.keyboard = ResTable_config.KEYBOARD_NOKEYS;
+      }
+      return true;
+    } else if (Objects.equals(name, "qwerty")) {
+      if (out != null) {
+        out.keyboard = ResTable_config.KEYBOARD_QWERTY;
+      }
+      return true;
+    } else if (Objects.equals(name, "12key")) {
+      if (out != null) {
+        out.keyboard = ResTable_config.KEYBOARD_12KEY;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseNavHidden(String name, ResTable_config out) {
+    byte mask = 0;
+    byte value = 0;
+    if (Objects.equals(name, kWildcardName)) {
+      mask = ResTable_config.MASK_NAVHIDDEN;
+      value = ResTable_config.NAVHIDDEN_ANY;
+    } else if (Objects.equals(name, "navexposed")) {
+      mask = ResTable_config.MASK_NAVHIDDEN;
+      value = ResTable_config.NAVHIDDEN_NO;
+    } else if (Objects.equals(name, "navhidden")) {
+      mask = ResTable_config.MASK_NAVHIDDEN;
+      value = ResTable_config.NAVHIDDEN_YES;
+    }
+
+    if (mask != 0) {
+      if (out != null) {
+        out.inputFlags = (out.inputFlags & ~mask) | value;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseNavigation(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.navigation = ResTable_config.NAVIGATION_ANY;
+      }
+      return true;
+    } else if (Objects.equals(name, "nonav")) {
+      if (out != null) {
+        out.navigation = ResTable_config.NAVIGATION_NONAV;
+      }
+      return true;
+    } else if (Objects.equals(name, "dpad")) {
+      if (out != null) {
+        out.navigation = ResTable_config.NAVIGATION_DPAD;
+      }
+      return true;
+    } else if (Objects.equals(name, "trackball")) {
+      if (out != null) {
+        out.navigation = ResTable_config.NAVIGATION_TRACKBALL;
+      }
+      return true;
+    } else if (Objects.equals(name, "wheel")) {
+      if (out != null) {
+        out.navigation = ResTable_config.NAVIGATION_WHEEL;
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  private static boolean parseScreenSize(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.screenWidth = ResTable_config.SCREENWIDTH_ANY;
+        out.screenHeight = ResTable_config.SCREENHEIGHT_ANY;
+      }
+      return true;
+    }
+
+    Matcher matcher = HEIGHT_WIDTH_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      int w = Integer.parseInt(matcher.group(1));
+      int h = Integer.parseInt(matcher.group(2));
+      if (w < h) {
+        return false;
+      }
+      out.screenWidth = w;
+      out.screenHeight = h;
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseVersion(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.sdkVersion = ResTable_config.SDKVERSION_ANY;
+        out.minorVersion = ResTable_config.MINORVERSION_ANY;
+      }
+      return true;
+    }
+
+    Matcher matcher = VERSION_QUALIFIER_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.sdkVersion = Integer.parseInt(matcher.group(1));
+      out.minorVersion = 0;
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseMnc(String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.mnc = 0;
+      }
+      return true;
+    }
+
+    Matcher matcher = MNC_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.mnc = Integer.parseInt(matcher.group(1));
+      if (out.mnc == 0) {
+        out.mnc = ACONFIGURATION_MNC_ZERO;
+      }
+      return true;
+    }
+    return false;
+  }
+
+  private static boolean parseMcc(final String name, ResTable_config out) {
+    if (Objects.equals(name, kWildcardName)) {
+      if (out != null) {
+        out.mcc = 0;
+      }
+      return true;
+    }
+
+    Matcher matcher = MCC_PATTERN.matcher(name);
+    if (matcher.matches()) {
+      out.mcc = Integer.parseInt(matcher.group(1));
+      return true;
+    }
+    return false;
+  }
+
+  // transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/tools/aapt/AaptConfig.cpp
+  private static void applyVersionForCompatibility(ResTable_config config) {
+    if (config == null) {
+      return;
+    }
+    int min_sdk = 0;
+    if (((config.uiMode & ResTable_config.MASK_UI_MODE_TYPE)
+        == ResTable_config.UI_MODE_TYPE_VR_HEADSET) ||
+        (config.colorMode & ResTable_config.MASK_WIDE_COLOR_GAMUT) != 0 ||
+            (config.colorMode & ResTable_config.MASK_HDR) != 0) {
+      min_sdk = SDK_O;
+    } else if (isTruthy(config.screenLayout2 & ResTable_config.MASK_SCREENROUND)) {
+      min_sdk = SDK_MNC;
+    } else if (config.density == ResTable_config.DENSITY_ANY) {
+      min_sdk = SDK_LOLLIPOP;
+    } else if (config.smallestScreenWidthDp != ResTable_config.SCREENWIDTH_ANY
+        || config.screenWidthDp != ResTable_config.SCREENWIDTH_ANY
+        || config.screenHeightDp != ResTable_config.SCREENHEIGHT_ANY) {
+      min_sdk = SDK_HONEYCOMB_MR2;
+    } else if ((config.uiMode & ResTable_config.MASK_UI_MODE_TYPE)
+        != ResTable_config.UI_MODE_TYPE_ANY
+        ||  (config.uiMode & ResTable_config.MASK_UI_MODE_NIGHT)
+        != ResTable_config.UI_MODE_NIGHT_ANY) {
+      min_sdk = SDK_FROYO;
+    } else if ((config.screenLayout & ResTable_config.MASK_SCREENSIZE)
+        != ResTable_config.SCREENSIZE_ANY
+        ||  (config.screenLayout & ResTable_config.MASK_SCREENLONG)
+        != ResTable_config.SCREENLONG_ANY
+        || config.density != ResTable_config.DENSITY_DEFAULT) {
+      min_sdk = SDK_DONUT;
+    }
+    if (min_sdk > config.sdkVersion) {
+      config.sdkVersion = min_sdk;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/CppApkAssets.java b/resources/src/main/java/org/robolectric/res/android/CppApkAssets.java
new file mode 100644
index 0000000..656661d
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/CppApkAssets.java
@@ -0,0 +1,397 @@
+package org.robolectric.res.android;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/ApkAssets.h
+// and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ApkAssets.cpp
+
+import static org.robolectric.res.android.CppAssetManager.FileType.kFileTypeDirectory;
+import static org.robolectric.res.android.CppAssetManager.FileType.kFileTypeRegular;
+import static org.robolectric.res.android.Util.CHECK;
+import static org.robolectric.res.android.ZipFileRO.OpenArchive;
+import static org.robolectric.res.android.ZipFileRO.kCompressDeflated;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import org.robolectric.res.android.Asset.AccessMode;
+import org.robolectric.res.android.CppAssetManager.FileType;
+import org.robolectric.res.android.Idmap.LoadedIdmap;
+import org.robolectric.res.android.ZipFileRO.ZipEntryRO;
+import org.robolectric.util.PerfStatsCollector;
+
+//
+// #ifndef APKASSETS_H_
+// #define APKASSETS_H_
+//
+// #include <memory>
+// #include <string>
+//
+// #include "android-base/macros.h"
+// #include "ziparchive/zip_archive.h"
+//
+// #include "androidfw/Asset.h"
+// #include "androidfw/LoadedArsc.h"
+// #include "androidfw/misc.h"
+//
+// namespace android {
+//
+// // Holds an APK.
+@SuppressWarnings("NewApi")
+public class CppApkAssets {
+  private static final String kResourcesArsc = "resources.arsc";
+//  public:
+//   static std::unique_ptr<const ApkAssets> Load(const String& path, bool system = false);
+//   static std::unique_ptr<const ApkAssets> LoadAsSharedLibrary(const String& path,
+//                                                               bool system = false);
+//
+//   std::unique_ptr<Asset> Open(const String& path,
+//                               Asset::AccessMode mode = Asset::AccessMode::ACCESS_RANDOM) const;
+//
+//   bool ForEachFile(const String& path,
+//                    const std::function<void(const StringPiece&, FileType)>& f) const;
+
+
+  public CppApkAssets(ZipArchiveHandle zip_handle_, String path_) {
+    this.zip_handle_ = zip_handle_;
+    this.path_ = path_;
+    this.zipFileRO = new ZipFileRO(zip_handle_, zip_handle_.zipFile.getName());
+  }
+
+  public String GetPath() { return path_; }
+
+  // This is never nullptr.
+  public LoadedArsc GetLoadedArsc() {
+    return loaded_arsc_;
+  }
+
+  //  private:
+//   DISALLOW_COPY_AND_ASSIGN(ApkAssets);
+//
+//   static std::unique_ptr<const ApkAssets> LoadImpl(const String& path, bool system,
+//                                                    bool load_as_shared_library);
+//
+//   ApkAssets() = default;
+//
+//   struct ZipArchivePtrCloser {
+//     void operator()(::ZipArchiveHandle handle) { ::CloseArchive(handle); }
+//   };
+//
+//   using ZipArchivePtr =
+//       std::unique_ptr<typename std::remove_pointer<::ZipArchiveHandle>::type, ZipArchivePtrCloser>;
+
+  ZipArchiveHandle zip_handle_;
+  private final ZipFileRO zipFileRO;
+  private String path_;
+  Asset resources_asset_;
+  Asset idmap_asset_;
+  private LoadedArsc loaded_arsc_;
+  // };
+  //
+  // }  // namespace android
+  //
+  // #endif // APKASSETS_H_
+  //
+  // #define ATRACE_TAG ATRACE_TAG_RESOURCES
+  //
+  // #include "androidfw/ApkAssets.h"
+  //
+  // #include <algorithm>
+  //
+  // #include "android-base/logging.h"
+  // #include "utils/FileMap.h"
+  // #include "utils/Trace.h"
+  // #include "ziparchive/zip_archive.h"
+  //
+  // #include "androidfw/Asset.h"
+  // #include "androidfw/Util.h"
+  //
+  // namespace android {
+  //
+  // Creates an ApkAssets.
+  // If `system` is true, the package is marked as a system package, and allows some functions to
+  // filter out this package when computing what configurations/resources are available.
+  // std::unique_ptr<const ApkAssets> ApkAssets::Load(const String& path, bool system) {
+  public static CppApkAssets Load(String path, boolean system) {
+    return LoadImpl(/*{}*/-1 /*fd*/, path, null, null, system, false /*load_as_shared_library*/);
+  }
+
+  // Creates an ApkAssets, but forces any package with ID 0x7f to be loaded as a shared library.
+  // If `system` is true, the package is marked as a system package, and allows some functions to
+  // filter out this package when computing what configurations/resources are available.
+// std::unique_ptr<const ApkAssets> ApkAssets::LoadAsSharedLibrary(const String& path,
+//                                                                 bool system) {
+  public static CppApkAssets LoadAsSharedLibrary(String path,
+      boolean system) {
+    return LoadImpl(/*{}*/ -1 /*fd*/, path, null, null, system, true /*load_as_shared_library*/);
+  }
+
+  // Creates an ApkAssets from an IDMAP, which contains the original APK path, and the overlay
+  // data.
+  // If `system` is true, the package is marked as a system package, and allows some functions to
+  // filter out this package when computing what configurations/resources are available.
+  // std::unique_ptr<const ApkAssets> ApkAssets::LoadOverlay(const std::string& idmap_path,
+  //                                                         bool system) {
+  @SuppressWarnings("DoNotCallSuggester")
+  public static CppApkAssets LoadOverlay(String idmap_path, boolean system) {
+    throw new UnsupportedOperationException();
+    // Asset idmap_asset = CreateAssetFromFile(idmap_path);
+    // if (idmap_asset == null) {
+    //   return {};
+    // }
+    //
+    // StringPiece idmap_data(
+    //     reinterpret_cast<char*>(idmap_asset.getBuffer(true /*wordAligned*/)),
+    //     static_cast<size_t>(idmap_asset.getLength()));
+    // LoadedIdmap loaded_idmap = LoadedIdmap.Load(idmap_data);
+    // if (loaded_idmap == null) {
+    //   System.err.println( + "failed to load IDMAP " + idmap_path;
+    //   return {};
+    // }
+    // return LoadImpl({} /*fd*/, loaded_idmap.OverlayApkPath(), std.move(idmap_asset),
+    //     std.move(loaded_idmap), system, false /*load_as_shared_library*/);
+  }
+
+  // Creates an ApkAssets from the given file descriptor, and takes ownership of the file
+  // descriptor. The `friendly_name` is some name that will be used to identify the source of
+  // this ApkAssets in log messages and other debug scenarios.
+  // If `system` is true, the package is marked as a system package, and allows some functions to
+  // filter out this package when computing what configurations/resources are available.
+  // If `force_shared_lib` is true, any package with ID 0x7f is loaded as a shared library.
+  // std::unique_ptr<const ApkAssets> ApkAssets::LoadFromFd(unique_fd fd,
+  //                                                        const std::string& friendly_name,
+  //                                                        bool system, bool force_shared_lib) {
+  //   public static ApkAssets LoadFromFd(unique_fd fd,
+  //       String friendly_name,
+  //       boolean system, boolean force_shared_lib) {
+  //     return LoadImpl(std.move(fd), friendly_name, null /*idmap_asset*/, null /*loaded_idmap*/,
+  //         system, force_shared_lib);
+  //   }
+
+  // std::unique_ptr<Asset> ApkAssets::CreateAssetFromFile(const std::string& path) {
+  @SuppressWarnings("DoNotCallSuggester")
+  static Asset CreateAssetFromFile(String path) {
+    throw new UnsupportedOperationException();
+    // unique_fd fd(base.utf8.open(path.c_str(), O_RDONLY | O_BINARY | O_CLOEXEC));
+    // if (fd == -1) {
+    //   System.err.println( + "Failed to open file '" + path + "': " + SystemErrorCodeToString(errno);
+    //   return {};
+    // }
+    //
+    // long file_len = lseek64(fd, 0, SEEK_END);
+    // if (file_len < 0) {
+    //   System.err.println( + "Failed to get size of file '" + path + "': " + SystemErrorCodeToString(errno);
+    //   return {};
+    // }
+    //
+    // std.unique_ptr<FileMap> file_map = util.make_unique<FileMap>();
+    // if (!file_map.create(path.c_str(), fd, 0, static_cast<size_t>(file_len), true /*readOnly*/)) {
+    //   System.err.println( + "Failed to mmap file '" + path + "': " + SystemErrorCodeToString(errno);
+    //   return {};
+    // }
+    // return Asset.createFromUncompressedMap(std.move(file_map), Asset.AccessMode.ACCESS_RANDOM);
+  }
+
+  /**
+   * Measure performance implications of loading {@link CppApkAssets}.
+   */
+  static CppApkAssets LoadImpl(
+      int fd, String path, Asset idmap_asset,
+      LoadedIdmap loaded_idmap, boolean system, boolean load_as_shared_library) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "load binary " + (system ? "framework" : "app") + " resources",
+            () ->
+                LoadImpl_measured(
+                    fd, path, idmap_asset, loaded_idmap, system, load_as_shared_library));
+  }
+
+  // std::unique_ptr<const ApkAssets> ApkAssets::LoadImpl(
+  //     unique_fd fd, const std::string& path, std::unique_ptr<Asset> idmap_asset,
+  //     std::unique_ptr<const LoadedIdmap> loaded_idmap, bool system, bool load_as_shared_library) {
+  static CppApkAssets LoadImpl_measured(
+      int fd, String path, Asset idmap_asset,
+      LoadedIdmap loaded_idmap, boolean system, boolean load_as_shared_library) {
+    Ref<ZipArchiveHandle> unmanaged_handle = new Ref<>(null);
+    int result;
+    if (fd >= 0) {
+      throw new UnsupportedOperationException();
+      // result =
+      //   OpenArchiveFd(fd.release(), path, &unmanaged_handle, true /*assume_ownership*/);
+    } else {
+      result = OpenArchive(path, unmanaged_handle);
+    }
+
+    if (result != 0) {
+      System.err.println("Failed to open APK '" + path + "' " + ErrorCodeString(result));
+      return null;
+    }
+
+    // Wrap the handle in a unique_ptr so it gets automatically closed.
+    CppApkAssets loaded_apk = new CppApkAssets(unmanaged_handle.get(), path);
+
+    // Find the resource table.
+    String entry_name = kResourcesArsc;
+    Ref<ZipEntry> entry = new Ref<>(null);
+    // result = FindEntry(loaded_apk.zip_handle_.get(), entry_name, &entry);
+    result = ZipFileRO.FindEntry(loaded_apk.zip_handle_, entry_name, entry);
+    if (result != 0) {
+      // There is no resources.arsc, so create an empty LoadedArsc and return.
+      loaded_apk.loaded_arsc_ = LoadedArsc.CreateEmpty();
+      return loaded_apk;
+    }
+
+    // Open the resource table via mmap unless it is compressed. This logic is taken care of by Open.
+    loaded_apk.resources_asset_ = loaded_apk.Open(kResourcesArsc, Asset.AccessMode.ACCESS_BUFFER);
+    if (loaded_apk.resources_asset_ == null) {
+      System.err.println("Failed to open '" + kResourcesArsc + "' in APK '" + path + "'.");
+      return null;
+    }
+
+    // Must retain ownership of the IDMAP Asset so that all pointers to its mmapped data remain valid.
+    loaded_apk.idmap_asset_ = idmap_asset;
+
+  // const StringPiece data(
+  //       reinterpret_cast<const char*>(loaded_apk.resources_asset_.getBuffer(true /*wordAligned*/)),
+  //       loaded_apk.resources_asset_.getLength());
+    StringPiece data = new StringPiece(
+        ByteBuffer.wrap(loaded_apk.resources_asset_.getBuffer(true /*wordAligned*/))
+            .order(ByteOrder.LITTLE_ENDIAN),
+        0 /*(int) loaded_apk.resources_asset_.getLength()*/);
+    loaded_apk.loaded_arsc_ =
+        LoadedArsc.Load(data, loaded_idmap, system, load_as_shared_library);
+    if (loaded_apk.loaded_arsc_ == null) {
+      System.err.println("Failed to load '" + kResourcesArsc + "' in APK '" + path + "'.");
+      return null;
+    }
+
+    // Need to force a move for mingw32.
+    return loaded_apk;
+  }
+
+  private static String ErrorCodeString(int result) {
+    return "Error " + result;
+  }
+
+  public Asset Open(String path, AccessMode mode) {
+    CHECK(zip_handle_ != null);
+
+    String name = path;
+    ZipEntryRO entry;
+    entry = zipFileRO.findEntryByName(name);
+    // int result = FindEntry(zip_handle_.get(), name, &entry);
+    // if (result != 0) {
+    //   LOG(ERROR) + "No entry '" + path + "' found in APK '" + path_ + "'";
+    //   return {};
+    // }
+    if (entry == null) {
+      return null;
+    }
+
+    if (entry.entry.getMethod() == kCompressDeflated) {
+      // FileMap map = new FileMap();
+      // if (!map.create(path_, .GetFileDescriptor(zip_handle_), entry.offset,
+      //     entry.getCompressedSize(), true /*readOnly*/)) {
+      //   LOG(ERROR) + "Failed to mmap file '" + path + "' in APK '" + path_ + "'";
+      //   return {};
+      // }
+      FileMap map = zipFileRO.createEntryFileMap(entry);
+
+      Asset asset =
+          Asset.createFromCompressedMap(map, (int) entry.entry.getSize(), mode);
+      if (asset == null) {
+        System.err.println("Failed to decompress '" + path + "'.");
+        return null;
+      }
+      return asset;
+    } else {
+      FileMap map = zipFileRO.createEntryFileMap(entry);
+
+      // if (!map.create(path_, .GetFileDescriptor(zip_handle_.get()), entry.offset,
+      //     entry.uncompressed_length, true /*readOnly*/)) {
+      //   System.err.println("Failed to mmap file '" + path + "' in APK '" + path_ + "'");
+      //   return null;
+      // }
+
+      Asset asset = Asset.createFromUncompressedMap(map, mode);
+      if (asset == null) {
+        System.err.println("Failed to mmap file '" + path + "' in APK '" + path_ + "'");
+        return null;
+      }
+      return asset;
+    }
+  }
+
+  interface ForEachFileCallback {
+    void callback(String string, FileType fileType);
+  }
+
+  boolean ForEachFile(String root_path,
+      ForEachFileCallback f) {
+    CHECK(zip_handle_ != null);
+
+    String root_path_full = root_path;
+    // if (root_path_full.back() != '/') {
+    if (!root_path_full.endsWith("/")) {
+      root_path_full += '/';
+    }
+
+    String prefix = root_path_full;
+    Enumeration<? extends ZipEntry> entries = zip_handle_.zipFile.entries();
+    // if (StartIteration(zip_handle_.get(), &cookie, &prefix, null) != 0) {
+    //   return false;
+    // }
+    if (!entries.hasMoreElements()) {
+      return false;
+    }
+
+    // String name;
+    // ZipEntry entry;
+
+    // We need to hold back directories because many paths will contain them and we want to only
+    // surface one.
+    final Set<String> dirs = new HashSet<>();
+
+    // int32_t result;
+    // while ((result = Next(cookie, &entry, &name)) == 0) {
+    while (entries.hasMoreElements()) {
+      ZipEntry zipEntry =  entries.nextElement();
+      if (!zipEntry.getName().startsWith(prefix)) {
+        continue;
+      }
+
+      // StringPiece full_file_path(reinterpret_cast<const char*>(name.name), name.name_length);
+      String full_file_path = zipEntry.getName();
+
+      // StringPiece leaf_file_path = full_file_path.substr(root_path_full.size());
+      String leaf_file_path = full_file_path.substring(root_path_full.length());
+
+      if (!leaf_file_path.isEmpty()) {
+        // auto iter = stdfind(leaf_file_path.begin(), leaf_file_path.end(), '/');
+
+        // if (iter != leaf_file_path.end()) {
+        //   stdstring dir =
+        //       leaf_file_path.substr(0, stddistance(leaf_file_path.begin(), iter)).to_string();
+        //   dirs.insert(stdmove(dir));
+        if (zipEntry.isDirectory()) {
+          dirs.add(leaf_file_path.substring(0, leaf_file_path.indexOf("/")));
+        } else {
+          f.callback(leaf_file_path, kFileTypeRegular);
+        }
+      }
+    }
+    // EndIteration(cookie);
+
+    // Now present the unique directories.
+    for (final String dir : dirs) {
+      f.callback(dir, kFileTypeDirectory);
+    }
+
+    // -1 is end of iteration, anything else is an error.
+    // return result == -1;
+    return true;
+  }
+//
+}  // namespace android
+
diff --git a/resources/src/main/java/org/robolectric/res/android/CppAssetManager.java b/resources/src/main/java/org/robolectric/res/android/CppAssetManager.java
new file mode 100644
index 0000000..b796f23
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/CppAssetManager.java
@@ -0,0 +1,1785 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Asset.toIntExact;
+import static org.robolectric.res.android.CppAssetManager.FileType.kFileTypeDirectory;
+import static org.robolectric.res.android.Util.ALOGD;
+import static org.robolectric.res.android.Util.ALOGE;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.ALOGV;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.ATRACE_CALL;
+import static org.robolectric.res.android.Util.LOG_FATAL_IF;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.io.File;
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.zip.ZipEntry;
+import javax.annotation.Nullable;
+import org.robolectric.res.Fs;
+import org.robolectric.res.android.Asset.AccessMode;
+import org.robolectric.res.android.AssetDir.FileInfo;
+import org.robolectric.res.android.ZipFileRO.ZipEntryRO;
+import org.robolectric.util.PerfStatsCollector;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/AssetManager.cpp
+@SuppressWarnings("NewApi")
+public class CppAssetManager {
+
+  private static final boolean kIsDebug = false;
+
+  enum FileType {
+    kFileTypeUnknown,
+    kFileTypeNonexistent,       // i.e. ENOENT
+    kFileTypeRegular,
+    kFileTypeDirectory,
+    kFileTypeCharDev,
+    kFileTypeBlockDev,
+    kFileTypeFifo,
+    kFileTypeSymlink,
+    kFileTypeSocket,
+  }
+
+  // transliterated from
+  // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/libs/androidfw/include/androidfw/AssetManager.h
+  private static class asset_path {
+//    asset_path() : path(""), type(kFileTypeRegular), idmap(""),
+//      isSystemOverlay(false), isSystemAsset(false) {}
+
+
+    public asset_path() {
+      this(new String8(), FileType.kFileTypeRegular, new String8(""), false, false);
+    }
+
+    public asset_path(String8 path, FileType fileType, String8 idmap,
+        boolean isSystemOverlay,
+        boolean isSystemAsset) {
+      this.path = path;
+      this.type = fileType;
+      this.idmap = idmap;
+      this.isSystemOverlay = isSystemOverlay;
+      this.isSystemAsset = isSystemAsset;
+    }
+
+    String8 path;
+    FileType type;
+    String8 idmap;
+    boolean isSystemOverlay;
+    boolean isSystemAsset;
+
+    @Override
+    public String toString() {
+      return "asset_path{" +
+          "path=" + path +
+          ", type=" + type +
+          ", idmap='" + idmap + '\'' +
+          ", isSystemOverlay=" + isSystemOverlay +
+          ", isSystemAsset=" + isSystemAsset +
+          '}';
+    }
+  }
+
+  private final Object mLock = new Object();
+
+  // unlike AssetManager.cpp, this is shared between CppAssetManager instances, and is used
+  // to cache ResTables between tests.
+  private static final ZipSet mZipSet = new ZipSet();
+
+  private final List<asset_path> mAssetPaths = new ArrayList<>();
+  private String mLocale;
+
+  private ResTable mResources;
+  private ResTable_config mConfig = new ResTable_config();
+
+
+  //  static final boolean kIsDebug = false;
+//  
+  static final String kAssetsRoot = "assets";
+  static final String kAppZipName = null; //"classes.jar";
+  static final String kSystemAssets = "android.jar";
+  //  static final char* kResourceCache = "resource-cache";
+//  
+  static final String kExcludeExtension = ".EXCLUDE";
+//  
+
+  // static Asset final kExcludedAsset = (Asset*) 0xd000000d;
+  static final Asset kExcludedAsset = Asset.EXCLUDED_ASSET;
+
+
+ static volatile int gCount = 0;
+
+//  final char* RESOURCES_FILENAME = "resources.arsc";
+//  final char* IDMAP_BIN = "/system/bin/idmap";
+//  final char* OVERLAY_DIR = "/vendor/overlay";
+//  final char* OVERLAY_THEME_DIR_PROPERTY = "ro.boot.vendor.overlay.theme";
+//  final char* TARGET_PACKAGE_NAME = "android";
+//  final char* TARGET_APK_PATH = "/system/framework/framework-res.apk";
+//  final char* IDMAP_DIR = "/data/resource-cache";
+//  
+//  namespace {
+//  
+  String8 idmapPathForPackagePath(final String8 pkgPath) {
+    // TODO: implement this?
+    return pkgPath;
+//    const char* root = getenv("ANDROID_DATA");
+//    LOG_ALWAYS_FATAL_IF(root == NULL, "ANDROID_DATA not set");
+//    String8 path(root);
+//    path.appendPath(kResourceCache);
+//    char buf[256]; // 256 chars should be enough for anyone...
+//    strncpy(buf, pkgPath.string(), 255);
+//    buf[255] = '\0';
+//    char* filename = buf;
+//    while (*filename && *filename == '/') {
+//      ++filename;
+//    }
+//    char* p = filename;
+//    while (*p) {
+//      if (*p == '/') {
+//           *p = '@';
+//      }
+//      ++p;
+//    }
+//    path.appendPath(filename);
+//    path.append("@idmap");
+//    return path;
+  }
+//  
+//  /*
+//   * Like strdup(), but uses C++ "new" operator instead of malloc.
+//   */
+//  static char* strdupNew(final char* str) {
+//      char* newStr;
+//      int len;
+//  
+//      if (str == null)
+//          return null;
+//  
+//      len = strlen(str);
+//      newStr = new char[len+1];
+//      memcpy(newStr, str, len+1);
+//  
+//      return newStr;
+//  }
+//  
+//  } // namespace
+//  
+//  /*
+//   * ===========================================================================
+//   *      AssetManager
+//   * ===========================================================================
+//   */
+
+  public static int getGlobalCount() {
+    return gCount;
+  }
+
+//  AssetManager() :
+//          mLocale(null), mResources(null), mConfig(new ResTable_config) {
+//      int count = android_atomic_inc(&gCount) + 1;
+//      if (kIsDebug) {
+//          ALOGI("Creating AssetManager %s #%d\n", this, count);
+//      }
+//      memset(mConfig, 0, sizeof(ResTable_config));
+//  }
+//  
+//  ~AssetManager() {
+//      int count = android_atomic_dec(&gCount);
+//      if (kIsDebug) {
+//          ALOGI("Destroying AssetManager in %s #%d\n", this, count);
+//      } else {
+//          ALOGI("Destroying AssetManager in %s #%d\n", this, count);
+//      }
+//      // Manually close any fd paths for which we have not yet opened their zip (which
+//      // will take ownership of the fd and close it when done).
+//      for (size_t i=0; i<mAssetPaths.size(); i++) {
+//          ALOGV("Cleaning path #%d: fd=%d, zip=%p", (int)i, mAssetPaths[i].rawFd,
+//                  mAssetPaths[i].zip.get());
+//          if (mAssetPaths[i].rawFd >= 0 && mAssetPaths[i].zip == NULL) {
+//              close(mAssetPaths[i].rawFd);
+//          }
+//      }
+//
+//      delete mConfig;
+//      delete mResources;
+//  
+//      // don't have a String class yet, so make sure we clean up
+//      delete[] mLocale;
+//  }
+
+  public boolean addAssetPath(String8 path, Ref<Integer> cookie, boolean appAsLib) {
+    return addAssetPath(path, cookie, appAsLib, false);
+  }
+
+  public boolean addAssetPath(
+      final String8 path, @Nullable Ref<Integer> cookie, boolean appAsLib, boolean isSystemAsset) {
+    synchronized (mLock) {
+
+      asset_path ap = new asset_path();
+
+      String8 realPath = path;
+      if (kAppZipName != null) {
+        realPath.appendPath(kAppZipName);
+      }
+      ap.type = getFileType(realPath.string());
+      if (ap.type == FileType.kFileTypeRegular) {
+        ap.path = realPath;
+      } else {
+        ap.path = path;
+        ap.type = getFileType(path.string());
+        if (ap.type != kFileTypeDirectory && ap.type != FileType.kFileTypeRegular) {
+          ALOGW("Asset path %s is neither a directory nor file (type=%s).",
+              path.toString(), ap.type.name());
+          return false;
+        }
+      }
+
+      // Skip if we have it already.
+      for (int i = 0; i < mAssetPaths.size(); i++) {
+        if (mAssetPaths.get(i).path.equals(ap.path)) {
+          if (cookie != null) {
+            cookie.set(i + 1);
+          }
+          return true;
+        }
+      }
+
+      ALOGV("In %s Asset %s path: %s", this,
+          ap.type.name(), ap.path.toString());
+
+      ap.isSystemAsset = isSystemAsset;
+      /*int apPos =*/ mAssetPaths.add(ap);
+
+      // new paths are always added at the end
+      if (cookie != null) {
+        cookie.set(mAssetPaths.size());
+      }
+
+      // TODO: implement this?
+      //#ifdef __ANDROID__
+      // Load overlays, if any
+      //asset_path oap;
+      //for (int idx = 0; mZipSet.getOverlay(ap.path, idx, & oap)
+      //  ; idx++){
+      //  oap.isSystemAsset = isSystemAsset;
+      //  mAssetPaths.add(oap);
+      // }
+      //#endif
+
+      if (mResources != null) {
+        // appendPathToResTable(mAssetPaths.editItemAt(apPos), appAsLib);
+        appendPathToResTable(ap, appAsLib);
+      }
+
+      return true;
+    }
+  }
+
+  //
+  //  boolean addOverlayPath(final String8 packagePath, Ref<Integer> cookie)
+  //  {
+  //      final String8 idmapPath = idmapPathForPackagePath(packagePath);
+  //
+  //      synchronized (mLock) {
+  //
+  //        for (int i = 0; i < mAssetPaths.size(); ++i) {
+  //          if (mAssetPaths.get(i).idmap.equals(idmapPath)) {
+  //             cookie.set(i + 1);
+  //            return true;
+  //          }
+  //        }
+  //
+  //        Asset idmap = null;
+  //        if ((idmap = openAssetFromFileLocked(idmapPath, Asset.AccessMode.ACCESS_BUFFER)) ==
+  // null) {
+  //          ALOGW("failed to open idmap file %s\n", idmapPath.string());
+  //          return false;
+  //        }
+  //
+  //        String8 targetPath;
+  //        String8 overlayPath;
+  //        if (!ResTable.getIdmapInfo(idmap.getBuffer(false), idmap.getLength(),
+  //            null, null, null, & targetPath, &overlayPath)){
+  //          ALOGW("failed to read idmap file %s\n", idmapPath.string());
+  //          // delete idmap;
+  //          return false;
+  //        }
+  //        // delete idmap;
+  //
+  //        if (overlayPath != packagePath) {
+  //          ALOGW("idmap file %s inconcistent: expected path %s does not match actual path %s\n",
+  //              idmapPath.string(), packagePath.string(), overlayPath.string());
+  //          return false;
+  //        }
+  //        if (access(targetPath.string(), R_OK) != 0) {
+  //          ALOGW("failed to access file %s: %s\n", targetPath.string(), strerror(errno));
+  //          return false;
+  //        }
+  //        if (access(idmapPath.string(), R_OK) != 0) {
+  //          ALOGW("failed to access file %s: %s\n", idmapPath.string(), strerror(errno));
+  //          return false;
+  //        }
+  //        if (access(overlayPath.string(), R_OK) != 0) {
+  //          ALOGW("failed to access file %s: %s\n", overlayPath.string(), strerror(errno));
+  //          return false;
+  //        }
+  //
+  //        asset_path oap;
+  //        oap.path = overlayPath;
+  //        oap.type = .getFileType(overlayPath.string());
+  //        oap.idmap = idmapPath;
+  //  #if 0
+  //        ALOGD("Overlay added: targetPath=%s overlayPath=%s idmapPath=%s\n",
+  //            targetPath.string(), overlayPath.string(), idmapPath.string());
+  //  #endif
+  //        mAssetPaths.add(oap);
+  //      *cookie = static_cast <int>(mAssetPaths.size());
+  //
+  //        if (mResources != null) {
+  //          appendPathToResTable(oap);
+  //        }
+  //
+  //        return true;
+  //      }
+  //   }
+  //
+  //  boolean createIdmap(final char* targetApkPath, final char* overlayApkPath,
+  //          uint32_t targetCrc, uint32_t overlayCrc, uint32_t** outData, int* outSize)
+  //  {
+  //      AutoMutex _l(mLock);
+  //      final String8 paths[2] = { String8(targetApkPath), String8(overlayApkPath) };
+  //      Asset* assets[2] = {null, null};
+  //      boolean ret = false;
+  //      {
+  //          ResTable tables[2];
+  //
+  //          for (int i = 0; i < 2; ++i) {
+  //              asset_path ap;
+  //              ap.type = kFileTypeRegular;
+  //              ap.path = paths[i];
+  //              assets[i] = openNonAssetInPathLocked("resources.arsc",
+  //                      Asset.ACCESS_BUFFER, ap);
+  //              if (assets[i] == null) {
+  //                  ALOGW("failed to find resources.arsc in %s\n", ap.path.string());
+  //                  goto exit;
+  //              }
+  //              if (tables[i].add(assets[i]) != NO_ERROR) {
+  //                  ALOGW("failed to add %s to resource table", paths[i].string());
+  //                  goto exit;
+  //              }
+  //          }
+  //          ret = tables[0].createIdmap(tables[1], targetCrc, overlayCrc,
+  //                  targetApkPath, overlayApkPath, (void**)outData, outSize) == NO_ERROR;
+  //      }
+  //
+  //  exit:
+  //      delete assets[0];
+  //      delete assets[1];
+  //      return ret;
+  //  }
+  //
+  public boolean addDefaultAssets(Path systemAssetsPath) {
+    return addDefaultAssets(Fs.externalize(systemAssetsPath));
+  }
+
+  public boolean addDefaultAssets(String systemAssetsPath) {
+    String8 path = new String8(systemAssetsPath);
+    return addAssetPath(path, null, false /* appAsLib */, true /* isSystemAsset */);
+  }
+//  
+//  int nextAssetPath(final int cookie) final
+//  {
+//      AutoMutex _l(mLock);
+//      final int next = static_cast<int>(cookie) + 1;
+//      return next > mAssetPaths.size() ? -1 : next;
+//  }
+//  
+//  String8 getAssetPath(final int cookie) final
+//  {
+//      AutoMutex _l(mLock);
+//      final int which = static_cast<int>(cookie) - 1;
+//      if (which < mAssetPaths.size()) {
+//          return mAssetPaths[which].path;
+//      }
+//      return String8();
+//  }
+
+  void setLocaleLocked(final String locale) {
+    //      if (mLocale != null) {
+    //          delete[] mLocale;
+    //      }
+
+    mLocale = /*strdupNew*/ locale;
+    updateResourceParamsLocked();
+  }
+
+  public void setConfiguration(final ResTable_config config, final String locale) {
+    synchronized (mLock) {
+      mConfig = config;
+      if (isTruthy(locale)) {
+        setLocaleLocked(locale);
+      } else {
+        if (config.language[0] != 0) {
+//          byte[] spec = new byte[RESTABLE_MAX_LOCALE_LEN];
+          String spec = config.getBcp47Locale(false);
+          setLocaleLocked(spec);
+        } else {
+          updateResourceParamsLocked();
+        }
+      }
+    }
+  }
+
+  @VisibleForTesting
+  public void getConfiguration(Ref<ResTable_config> outConfig) {
+    synchronized (mLock) {
+      outConfig.set(mConfig);
+    }
+  }
+
+  /*
+   * Open an asset.
+   *
+   * The data could be in any asset path. Each asset path could be:
+   *  - A directory on disk.
+   *  - A Zip archive, uncompressed or compressed.
+   *
+   * If the file is in a directory, it could have a .gz suffix, meaning it is compressed.
+   *
+   * We should probably reject requests for "illegal" filenames, e.g. those
+   * with illegal characters or "../" backward relative paths.
+   */
+  public Asset open(final String fileName, AccessMode mode) {
+    synchronized (mLock) {
+      LOG_FATAL_IF(mAssetPaths.isEmpty(), "No assets added to AssetManager");
+
+      String8 assetName = new String8(kAssetsRoot);
+      assetName.appendPath(fileName);
+      /*
+       * For each top-level asset path, search for the asset.
+       */
+      int i = mAssetPaths.size();
+      while (i > 0) {
+        i--;
+        ALOGV("Looking for asset '%s' in '%s'\n",
+            assetName.string(), mAssetPaths.get(i).path.string());
+        Asset pAsset = openNonAssetInPathLocked(assetName.string(), mode,
+            mAssetPaths.get(i));
+        if (pAsset != null) {
+          return Objects.equals(pAsset, kExcludedAsset) ? null : pAsset;
+        }
+      }
+
+      return null;
+    }
+  }
+
+  /*
+   * Open a non-asset file as if it were an asset.
+   *
+   * The "fileName" is the partial path starting from the application name.
+   */
+  public Asset openNonAsset(final String fileName, AccessMode mode, Ref<Integer> outCookie) {
+    synchronized (mLock) {
+      //      AutoMutex _l(mLock);
+
+      LOG_FATAL_IF(mAssetPaths.isEmpty(), "No assets added to AssetManager");
+
+      /*
+       * For each top-level asset path, search for the asset.
+       */
+
+      int i = mAssetPaths.size();
+      while (i > 0) {
+        i--;
+        ALOGV("Looking for non-asset '%s' in '%s'\n", fileName,
+            mAssetPaths.get(i).path.string());
+        Asset pAsset = openNonAssetInPathLocked(
+            fileName, mode, mAssetPaths.get(i));
+        if (pAsset != null) {
+          if (outCookie != null) {
+            outCookie.set(i + 1);
+          }
+          return pAsset != kExcludedAsset ? pAsset : null;
+        }
+      }
+
+      return null;
+    }
+  }
+
+  public Asset openNonAsset(final int cookie, final String fileName, AccessMode mode) {
+    final int which = cookie - 1;
+
+    synchronized (mLock) {
+      LOG_FATAL_IF(mAssetPaths.isEmpty(), "No assets added to AssetManager");
+
+      if (which < mAssetPaths.size()) {
+        ALOGV("Looking for non-asset '%s' in '%s'\n", fileName,
+            mAssetPaths.get(which).path.string());
+        Asset pAsset = openNonAssetInPathLocked(
+            fileName, mode, mAssetPaths.get(which));
+        if (pAsset != null) {
+          return pAsset != kExcludedAsset ? pAsset : null;
+        }
+      }
+
+      return null;
+    }
+  }
+
+  /*
+   * Get the type of a file
+   */
+  FileType getFileType(final String fileName) {
+    // deviate from Android CPP implementation here. Assume fileName is a complete path
+    // rather than limited to just asset namespace
+    File assetFile = new File(fileName);
+    if (!assetFile.exists()) {
+      return FileType.kFileTypeNonexistent;
+    } else if (assetFile.isFile()) {
+      return FileType.kFileTypeRegular;
+    } else if (assetFile.isDirectory()) {
+      return kFileTypeDirectory;
+    }
+    return FileType.kFileTypeNonexistent;
+//      Asset pAsset = null;
+//
+//      /*
+//       * Open the asset.  This is less efficient than simply finding the
+//       * file, but it's not too bad (we don't uncompress or mmap data until
+//       * the first read() call).
+//       */
+//      pAsset = open(fileName, Asset.AccessMode.ACCESS_STREAMING);
+//      // delete pAsset;
+//
+//      if (pAsset == null) {
+//          return FileType.kFileTypeNonexistent;
+//      } else {
+//          return FileType.kFileTypeRegular;
+//      }
+  }
+
+  boolean appendPathToResTable(final asset_path ap, boolean appAsLib) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "load binary " + (ap.isSystemAsset ? "framework" : "app") + " resources",
+            () -> appendPathToResTable_measured(ap, appAsLib));
+  }
+
+  boolean appendPathToResTable_measured(final asset_path ap, boolean appAsLib) {
+    // TODO: properly handle reading system resources
+//    if (!ap.isSystemAsset) {
+//      URL resource = getClass().getResource("/resources.ap_"); // todo get this from asset_path
+//      // System.out.println("Reading ARSC file  from " + resource);
+//      LOG_FATAL_IF(resource == null, "Could not find resources.ap_");
+//      try {
+//        ZipFile zipFile = new ZipFile(resource.getFile());
+//        ZipEntry arscEntry = zipFile.getEntry("resources.arsc");
+//        InputStream inputStream = zipFile.getInputStream(arscEntry);
+//        mResources.add(inputStream, mResources.getTableCount() + 1);
+//      } catch (IOException e) {
+//        throw new RuntimeException(e);
+//      }
+//    } else {
+//      try {
+//        ZipFile zipFile = new ZipFile(ap.path.string());
+//        ZipEntry arscEntry = zipFile.getEntry("resources.arsc");
+//        InputStream inputStream = zipFile.getInputStream(arscEntry);
+//        mResources.add(inputStream, mResources.getTableCount() + 1);
+//      } catch (IOException e) {
+//        e.printStackTrace();
+//      }
+//    }
+//    return false;
+
+    // skip those ap's that correspond to system overlays
+    if (ap.isSystemOverlay) {
+      return true;
+    }
+
+    Asset ass = null;
+    ResTable sharedRes = null;
+    boolean shared = true;
+    boolean onlyEmptyResources = true;
+//      ATRACE_NAME(ap.path.string());
+    Asset idmap = openIdmapLocked(ap);
+    int nextEntryIdx = mResources.getTableCount();
+    ALOGV("Looking for resource asset in '%s'\n", ap.path.string());
+    if (ap.type != kFileTypeDirectory /*&& ap.rawFd < 0*/) {
+      if (nextEntryIdx == 0) {
+        // The first item is typically the framework resources,
+        // which we want to avoid parsing every time.
+        sharedRes = mZipSet.getZipResourceTable(ap.path);
+        if (sharedRes != null) {
+          // skip ahead the number of system overlay packages preloaded
+          nextEntryIdx = sharedRes.getTableCount();
+        }
+      }
+      if (sharedRes == null) {
+        ass = mZipSet.getZipResourceTableAsset(ap.path);
+        if (ass == null) {
+          ALOGV("loading resource table %s\n", ap.path.string());
+          ass = openNonAssetInPathLocked("resources.arsc",
+              AccessMode.ACCESS_BUFFER,
+              ap);
+          if (ass != null && ass != kExcludedAsset) {
+            ass = mZipSet.setZipResourceTableAsset(ap.path, ass);
+          }
+        }
+
+        if (nextEntryIdx == 0 && ass != null) {
+          // If this is the first resource table in the asset
+          // manager, then we are going to cache it so that we
+          // can quickly copy it out for others.
+          ALOGV("Creating shared resources for %s", ap.path.string());
+          sharedRes = new ResTable();
+          sharedRes.add(ass, idmap, nextEntryIdx + 1, false, false, false);
+//  #ifdef __ANDROID__
+//                  final char* data = getenv("ANDROID_DATA");
+//                  LOG_ALWAYS_FATAL_IF(data == null, "ANDROID_DATA not set");
+//                  String8 overlaysListPath(data);
+//                  overlaysListPath.appendPath(kResourceCache);
+//                  overlaysListPath.appendPath("overlays.list");
+//                  addSystemOverlays(overlaysListPath.string(), ap.path, sharedRes, nextEntryIdx);
+//  #endif
+          sharedRes = mZipSet.setZipResourceTable(ap.path, sharedRes);
+        }
+      }
+    } else {
+      ALOGV("loading resource table %s\n", ap.path.string());
+      ass = openNonAssetInPathLocked("resources.arsc",
+          AccessMode.ACCESS_BUFFER,
+          ap);
+      shared = false;
+    }
+
+    if ((ass != null || sharedRes != null) && ass != kExcludedAsset) {
+      ALOGV("Installing resource asset %s in to table %s\n", ass, mResources);
+      if (sharedRes != null) {
+        ALOGV("Copying existing resources for %s", ap.path.string());
+        mResources.add(sharedRes, ap.isSystemAsset);
+      } else {
+        ALOGV("Parsing resources for %s", ap.path.string());
+        mResources.add(ass, idmap, nextEntryIdx + 1, !shared, appAsLib, ap.isSystemAsset);
+      }
+      onlyEmptyResources = false;
+
+//          if (!shared) {
+//              delete ass;
+//          }
+    } else {
+      ALOGV("Installing empty resources in to table %s\n", mResources);
+      mResources.addEmpty(nextEntryIdx + 1);
+    }
+
+//      if (idmap != null) {
+//          delete idmap;
+//      }
+    return onlyEmptyResources;
+  }
+
+  final ResTable getResTable(boolean required) {
+    ResTable rt = mResources;
+    if (isTruthy(rt)) {
+      return rt;
+    }
+
+    // Iterate through all asset packages, collecting resources from each.
+
+    synchronized (mLock) {
+      if (mResources != null) {
+        return mResources;
+      }
+
+      if (required) {
+        LOG_FATAL_IF(mAssetPaths.isEmpty(), "No assets added to AssetManager");
+      }
+
+      PerfStatsCollector.getInstance().measure("load binary resources", () -> {
+        mResources = new ResTable();
+        updateResourceParamsLocked();
+
+        boolean onlyEmptyResources = true;
+        final int N = mAssetPaths.size();
+        for (int i = 0; i < N; i++) {
+          boolean empty = appendPathToResTable(mAssetPaths.get(i), false);
+          onlyEmptyResources = onlyEmptyResources && empty;
+        }
+
+        if (required && onlyEmptyResources) {
+          ALOGW("Unable to find resources file resources.arsc");
+//          delete mResources;
+          mResources = null;
+        }
+      });
+
+      return mResources;
+    }
+  }
+
+  void updateResourceParamsLocked() {
+    ATRACE_CALL();
+    ResTable res = mResources;
+    if (!isTruthy(res)) {
+      return;
+    }
+
+    if (isTruthy(mLocale)) {
+      mConfig.setBcp47Locale(mLocale);
+    } else {
+      mConfig.clearLocale();
+    }
+
+    res.setParameters(mConfig);
+  }
+
+  Asset openIdmapLocked(asset_path ap) {
+    Asset ass = null;
+    if (ap.idmap.length() != 0) {
+      ass = openAssetFromFileLocked(ap.idmap, AccessMode.ACCESS_BUFFER);
+      if (isTruthy(ass)) {
+        ALOGV("loading idmap %s\n", ap.idmap.string());
+      } else {
+        ALOGW("failed to load idmap %s\n", ap.idmap.string());
+      }
+    }
+    return ass;
+  }
+
+//  void addSystemOverlays(final char* pathOverlaysList,
+//          final String8& targetPackagePath, ResTable* sharedRes, int offset) final
+//  {
+//      FILE* fin = fopen(pathOverlaysList, "r");
+//      if (fin == null) {
+//          return;
+//      }
+//  
+//  #ifndef _WIN32
+//      if (TEMP_FAILURE_RETRY(flock(fileno(fin), LOCK_SH)) != 0) {
+//          fclose(fin);
+//          return;
+//      }
+//  #endif
+//      char buf[1024];
+//      while (fgets(buf, sizeof(buf), fin)) {
+//          // format of each line:
+//          //   <path to apk><space><path to idmap><newline>
+//          char* space = strchr(buf, ' ');
+//          char* newline = strchr(buf, '\n');
+//          asset_path oap;
+//  
+//          if (space == null || newline == null || newline < space) {
+//              continue;
+//          }
+//  
+//          oap.path = String8(buf, space - buf);
+//          oap.type = kFileTypeRegular;
+//          oap.idmap = String8(space + 1, newline - space - 1);
+//          oap.isSystemOverlay = true;
+//  
+//          Asset* oass = final_cast<AssetManager*>(this).
+//              openNonAssetInPathLocked("resources.arsc",
+//                      Asset.ACCESS_BUFFER,
+//                      oap);
+//  
+//          if (oass != null) {
+//              Asset* oidmap = openIdmapLocked(oap);
+//              offset++;
+//              sharedRes.add(oass, oidmap, offset + 1, false);
+//              final_cast<AssetManager*>(this).mAssetPaths.add(oap);
+//              final_cast<AssetManager*>(this).mZipSet.addOverlay(targetPackagePath, oap);
+//              delete oidmap;
+//          }
+//      }
+//  
+//  #ifndef _WIN32
+//      TEMP_FAILURE_RETRY(flock(fileno(fin), LOCK_UN));
+//  #endif
+//      fclose(fin);
+//  }
+
+  public final ResTable getResources() {
+    return getResources(true);
+  }
+
+  final ResTable getResources(boolean required) {
+    final ResTable rt = getResTable(required);
+    return rt;
+  }
+
+  //  boolean isUpToDate()
+//  {
+//      AutoMutex _l(mLock);
+//      return mZipSet.isUpToDate();
+//  }
+//  
+//  void getLocales(Vector<String8>* locales, boolean includeSystemLocales) final
+//  {
+//      ResTable* res = mResources;
+//      if (res != null) {
+//          res.getLocales(locales, includeSystemLocales, true /* mergeEquivalentLangs */);
+//      }
+//  }
+//  
+  /*
+   * Open a non-asset file as if it were an asset, searching for it in the
+   * specified app.
+   *
+   * Pass in a null values for "appName" if the common app directory should
+   * be used.
+   */
+  static Asset openNonAssetInPathLocked(final String fileName, AccessMode mode,
+      final asset_path ap) {
+    Asset pAsset = null;
+
+      /* look at the filesystem on disk */
+    if (ap.type == kFileTypeDirectory) {
+      String8 path = new String8(ap.path);
+      path.appendPath(fileName);
+
+      pAsset = openAssetFromFileLocked(path, mode);
+
+      if (pAsset == null) {
+              /* try again, this time with ".gz" */
+        path.append(".gz");
+        pAsset = openAssetFromFileLocked(path, mode);
+      }
+
+      if (pAsset != null) {
+        //printf("FOUND NA '%s' on disk\n", fileName);
+        pAsset.setAssetSource(path);
+      }
+
+      /* look inside the zip file */
+    } else {
+      String8 path = new String8(fileName);
+
+          /* check the appropriate Zip file */
+      ZipFileRO pZip = getZipFileLocked(ap);
+      if (pZip != null) {
+        //printf("GOT zip, checking NA '%s'\n", (final char*) path);
+        ZipEntryRO entry = pZip.findEntryByName(path.string());
+        if (entry != null) {
+          //printf("FOUND NA in Zip file for %s\n", appName ? appName : kAppCommon);
+          pAsset = openAssetFromZipLocked(pZip, entry, mode, path);
+          pZip.releaseEntry(entry);
+        }
+      }
+
+      if (pAsset != null) {
+              /* create a "source" name, for debug/display */
+        pAsset.setAssetSource(
+            createZipSourceNameLocked(ap.path, new String8(), new String8(fileName)));
+      }
+    }
+
+    return pAsset;
+  }
+
+  /*
+   * Create a "source name" for a file from a Zip archive.
+   */
+  static String8 createZipSourceNameLocked(final String8 zipFileName,
+      final String8 dirName, final String8 fileName) {
+    String8 sourceName = new String8("zip:");
+    sourceName.append(zipFileName.string());
+    sourceName.append(":");
+    if (dirName.length() > 0) {
+      sourceName.appendPath(dirName.string());
+    }
+    sourceName.appendPath(fileName.string());
+    return sourceName;
+  }
+
+  /*
+   * Create a path to a loose asset (asset-base/app/rootDir).
+   */
+  static String8 createPathNameLocked(final asset_path ap, final String rootDir) {
+    String8 path = new String8(ap.path);
+    if (rootDir != null) {
+      path.appendPath(rootDir);
+    }
+    return path;
+  }
+
+  /*
+   * Return a pointer to one of our open Zip archives.  Returns null if no
+   * matching Zip file exists.
+   */
+  static ZipFileRO getZipFileLocked(final asset_path ap) {
+    ALOGV("getZipFileLocked() in %s\n", CppAssetManager.class);
+
+    return mZipSet.getZip(ap.path.string());
+  }
+
+  /*
+   * Try to open an asset from a file on disk.
+   *
+   * If the file is compressed with gzip, we seek to the start of the
+   * deflated data and pass that in (just like we would for a Zip archive).
+   *
+   * For uncompressed data, we may already have an mmap()ed version sitting
+   * around.  If so, we want to hand that to the Asset instead.
+   *
+   * This returns null if the file doesn't exist, couldn't be opened, or
+   * claims to be a ".gz" but isn't.
+   */
+  static Asset openAssetFromFileLocked(final String8 pathName,
+      AccessMode mode) {
+    Asset pAsset = null;
+
+    if (pathName.getPathExtension().toLowerCase().equals(".gz")) {
+      //printf("TRYING '%s'\n", (final char*) pathName);
+      pAsset = Asset.createFromCompressedFile(pathName.string(), mode);
+    } else {
+      //printf("TRYING '%s'\n", (final char*) pathName);
+      pAsset = Asset.createFromFile(pathName.string(), mode);
+    }
+
+    return pAsset;
+  }
+
+  /*
+   * Given an entry in a Zip archive, create a new Asset object.
+   *
+   * If the entry is uncompressed, we may want to create or share a
+   * slice of shared memory.
+   */
+  static Asset openAssetFromZipLocked(final ZipFileRO pZipFile,
+      final ZipEntryRO entry, AccessMode mode, final String8 entryName) {
+    Asset pAsset = null;
+
+    // TODO: look for previously-created shared memory slice?
+    final Ref<Short> method = new Ref<>((short) 0);
+    final Ref<Long> uncompressedLen = new Ref<>(0L);
+
+    //printf("USING Zip '%s'\n", pEntry.getFileName());
+
+    if (!pZipFile.getEntryInfo(entry, method, uncompressedLen, null, null,
+        null, null)) {
+      ALOGW("getEntryInfo failed\n");
+      return null;
+    }
+
+    //return Asset.createFromZipEntry(pZipFile, entry, entryName);
+    FileMap dataMap = pZipFile.createEntryFileMap(entry);
+//      if (dataMap == null) {
+//          ALOGW("create map from entry failed\n");
+//          return null;
+//      }
+//
+    if (method.get() == ZipFileRO.kCompressStored) {
+      pAsset = Asset.createFromUncompressedMap(dataMap, mode);
+      ALOGV("Opened uncompressed entry %s in zip %s mode %s: %s", entryName.string(),
+          pZipFile.mFileName, mode, pAsset);
+    } else {
+      pAsset = Asset.createFromCompressedMap(dataMap, toIntExact(uncompressedLen.get()), mode);
+      ALOGV("Opened compressed entry %s in zip %s mode %s: %s", entryName.string(),
+          pZipFile.mFileName, mode, pAsset);
+    }
+    if (pAsset == null) {
+         /* unexpected */
+      ALOGW("create from segment failed\n");
+    }
+
+    return pAsset;
+  }
+
+  /*
+   * Open a directory in the asset namespace.
+   *
+   * An "asset directory" is simply the combination of all asset paths' "assets/" directories.
+   *
+   * Pass in "" for the root dir.
+   */
+  public AssetDir openDir(final String dirName) {
+    synchronized (mLock) {
+      AssetDir pDir;
+      final Ref<SortedVector<AssetDir.FileInfo>> pMergedInfo;
+
+      LOG_FATAL_IF(mAssetPaths.isEmpty(), "No assets added to AssetManager");
+      Preconditions.checkNotNull(dirName);
+
+      //printf("+++ openDir(%s) in '%s'\n", dirName, (final char*) mAssetBase);
+
+      pDir = new AssetDir();
+
+      /*
+       * Scan the various directories, merging what we find into a single
+       * vector.  We want to scan them in reverse priority order so that
+       * the ".EXCLUDE" processing works correctly.  Also, if we decide we
+       * want to remember where the file is coming from, we'll get the right
+       * version.
+       *
+       * We start with Zip archives, then do loose files.
+       */
+      pMergedInfo = new Ref<>(new SortedVector<AssetDir.FileInfo>());
+
+      int i = mAssetPaths.size();
+      while (i > 0) {
+        i--;
+        final asset_path ap = mAssetPaths.get(i);
+        if (ap.type == FileType.kFileTypeRegular) {
+          ALOGV("Adding directory %s from zip %s", dirName, ap.path.string());
+          scanAndMergeZipLocked(pMergedInfo, ap, kAssetsRoot, dirName);
+        } else {
+          ALOGV("Adding directory %s from dir %s", dirName, ap.path.string());
+          scanAndMergeDirLocked(pMergedInfo, ap, kAssetsRoot, dirName);
+        }
+      }
+
+//  #if 0
+//        printf("FILE LIST:\n");
+//        for (i = 0; i < (int) pMergedInfo.size(); i++) {
+//          printf(" %d: (%d) '%s'\n", i,
+//              pMergedInfo.itemAt(i).getFileType(),
+//              ( final char*)pMergedInfo.itemAt(i).getFileName());
+//        }
+//  #endif
+
+      pDir.setFileList(pMergedInfo.get());
+      return pDir;
+    }
+  }
+
+  //
+//  /*
+//   * Open a directory in the non-asset namespace.
+//   *
+//   * An "asset directory" is simply the combination of all asset paths' "assets/" directories.
+//   *
+//   * Pass in "" for the root dir.
+//   */
+//  AssetDir* openNonAssetDir(final int cookie, final char* dirName)
+//  {
+//      AutoMutex _l(mLock);
+//  
+//      AssetDir* pDir = null;
+//      SortedVector<AssetDir.FileInfo>* pMergedInfo = null;
+//  
+//      LOG_FATAL_IF(mAssetPaths.isEmpty(), "No assets added to AssetManager");
+//      assert(dirName != null);
+//  
+//      //printf("+++ openDir(%s) in '%s'\n", dirName, (final char*) mAssetBase);
+//  
+//      pDir = new AssetDir;
+//  
+//      pMergedInfo = new SortedVector<AssetDir.FileInfo>;
+//  
+//      final int which = static_cast<int>(cookie) - 1;
+//  
+//      if (which < mAssetPaths.size()) {
+//          final asset_path& ap = mAssetPaths.itemAt(which);
+//          if (ap.type == kFileTypeRegular) {
+//              ALOGV("Adding directory %s from zip %s", dirName, ap.path.string());
+//              scanAndMergeZipLocked(pMergedInfo, ap, null, dirName);
+//          } else {
+//              ALOGV("Adding directory %s from dir %s", dirName, ap.path.string());
+//              scanAndMergeDirLocked(pMergedInfo, ap, null, dirName);
+//          }
+//      }
+//  
+//  #if 0
+//      printf("FILE LIST:\n");
+//      for (i = 0; i < (int) pMergedInfo.size(); i++) {
+//          printf(" %d: (%d) '%s'\n", i,
+//              pMergedInfo.itemAt(i).getFileType(),
+//              (final char*) pMergedInfo.itemAt(i).getFileName());
+//      }
+//  #endif
+//  
+//      pDir.setFileList(pMergedInfo);
+//      return pDir;
+//  }
+//  
+  /*
+   * Scan the contents of the specified directory and merge them into the
+   * "pMergedInfo" vector, removing previous entries if we find "exclude"
+   * directives.
+   *
+   * Returns "false" if we found nothing to contribute.
+   */
+  boolean scanAndMergeDirLocked(Ref<SortedVector<AssetDir.FileInfo>> pMergedInfoRef,
+      final asset_path ap, final String rootDir, final String dirName) {
+    SortedVector<AssetDir.FileInfo> pMergedInfo = pMergedInfoRef.get();
+    assert (pMergedInfo != null);
+
+    //printf("scanAndMergeDir: %s %s %s\n", ap.path.string(), rootDir, dirName);
+
+    String8 path = createPathNameLocked(ap, rootDir);
+    if (dirName.charAt(0) != '\0') {
+      path.appendPath(dirName);
+    }
+
+    SortedVector<AssetDir.FileInfo> pContents = scanDirLocked(path);
+    if (pContents == null) {
+      return false;
+    }
+
+    // if we wanted to do an incremental cache fill, we would do it here
+
+      /*
+       * Process "exclude" directives.  If we find a filename that ends with
+       * ".EXCLUDE", we look for a matching entry in the "merged" set, and
+       * remove it if we find it.  We also delete the "exclude" entry.
+       */
+    int i, count, exclExtLen;
+
+    count = pContents.size();
+    exclExtLen = kExcludeExtension.length();
+    for (i = 0; i < count; i++) {
+      final String name;
+      int nameLen;
+
+      name = pContents.itemAt(i).getFileName().string();
+      nameLen = name.length();
+      if (name.endsWith(kExcludeExtension)) {
+        String8 match = new String8(name, nameLen - exclExtLen);
+        int matchIdx;
+
+        matchIdx = AssetDir.FileInfo.findEntry(pMergedInfo, match);
+        if (matchIdx > 0) {
+          ALOGV("Excluding '%s' [%s]\n",
+              pMergedInfo.itemAt(matchIdx).getFileName().string(),
+              pMergedInfo.itemAt(matchIdx).getSourceName().string());
+          pMergedInfo.removeAt(matchIdx);
+        } else {
+          //printf("+++ no match on '%s'\n", (final char*) match);
+        }
+
+        ALOGD("HEY: size=%d removing %d\n", (int) pContents.size(), i);
+        pContents.removeAt(i);
+        i--;        // adjust "for" loop
+        count--;    //  and loop limit
+      }
+    }
+
+    mergeInfoLocked(pMergedInfoRef, pContents);
+
+    return true;
+  }
+
+  /*
+   * Scan the contents of the specified directory, and stuff what we find
+   * into a newly-allocated vector.
+   *
+   * Files ending in ".gz" will have their extensions removed.
+   *
+   * We should probably think about skipping files with "illegal" names,
+   * e.g. illegal characters (/\:) or excessive length.
+   *
+   * Returns null if the specified directory doesn't exist.
+   */
+  SortedVector<AssetDir.FileInfo> scanDirLocked(final String8 path) {
+
+    String8 pathCopy = new String8(path);
+    SortedVector<AssetDir.FileInfo> pContents;
+    //DIR* dir;
+    File dir;
+    FileType fileType;
+
+    ALOGV("Scanning dir '%s'\n", path.string());
+
+    dir = new File(path.string());
+    if (!dir.exists()) {
+      return null;
+    }
+
+    pContents = new SortedVector<>();
+
+    for (File entry : dir.listFiles()) {
+      if (entry == null) {
+        break;
+      }
+
+//          if (strcmp(entry.d_name, ".") == 0 ||
+//              strcmp(entry.d_name, "..") == 0)
+//              continue;
+
+//  #ifdef _DIRENT_HAVE_D_TYPE
+//          if (entry.d_type == DT_REG)
+//              fileType = kFileTypeRegular;
+//          else if (entry.d_type == DT_DIR)
+//              fileType = kFileTypeDirectory;
+//          else
+//              fileType = kFileTypeUnknown;
+//  #else
+      // stat the file
+      fileType = getFileType(pathCopy.appendPath(entry.getName()).string());
+//  #endif
+
+      if (fileType != FileType.kFileTypeRegular && fileType != kFileTypeDirectory) {
+        continue;
+      }
+
+      AssetDir.FileInfo info = new AssetDir.FileInfo();
+      info.set(new String8(entry.getName()), fileType);
+      if (info.getFileName().getPathExtension().equalsIgnoreCase(".gz")) {
+        info.setFileName(info.getFileName().getBasePath());
+      }
+      info.setSourceName(pathCopy.appendPath(info.getFileName().string()));
+      pContents.add(info);
+    }
+
+    return pContents;
+  }
+
+  /*
+   * Scan the contents out of the specified Zip archive, and merge what we
+   * find into "pMergedInfo".  If the Zip archive in question doesn't exist,
+   * we return immediately.
+   *
+   * Returns "false" if we found nothing to contribute.
+   */
+  boolean scanAndMergeZipLocked(Ref<SortedVector<AssetDir.FileInfo>> pMergedInfo,
+      final asset_path ap, final String rootDir, final String baseDirName) {
+    ZipFileRO pZip;
+    List<String8> dirs = new ArrayList<>();
+    //AssetDir.FileInfo info = new FileInfo();
+    SortedVector<AssetDir.FileInfo> contents = new SortedVector<>();
+    String8 zipName;
+    String8 dirName = new String8();
+
+    pZip = mZipSet.getZip(ap.path.string());
+    if (pZip == null) {
+      ALOGW("Failure opening zip %s\n", ap.path.string());
+      return false;
+    }
+
+    zipName = ZipSet.getPathName(ap.path.string());
+
+      /* convert "sounds" to "rootDir/sounds" */
+    if (rootDir != null) {
+      dirName = new String8(rootDir);
+    }
+
+    dirName.appendPath(baseDirName);
+
+    /*
+     * Scan through the list of files, looking for a match.  The files in
+     * the Zip table of contents are not in sorted order, so we have to
+     * process the entire list.  We're looking for a string that begins
+     * with the characters in "dirName", is followed by a '/', and has no
+     * subsequent '/' in the stuff that follows.
+     *
+     * What makes this especially fun is that directories are not stored
+     * explicitly in Zip archives, so we have to infer them from context.
+     * When we see "sounds/foo.wav" we have to leave a note to ourselves
+     * to insert a directory called "sounds" into the list.  We store
+     * these in temporary vector so that we only return each one once.
+     *
+     * Name comparisons are case-sensitive to match UNIX filesystem
+     * semantics.
+     */
+    int dirNameLen = dirName.length();
+    final Ref<Enumeration<? extends ZipEntry>> iterationCookie = new Ref<>(null);
+    if (!pZip.startIteration(iterationCookie, dirName.string(), null)) {
+      ALOGW("ZipFileRO.startIteration returned false");
+      return false;
+    }
+
+    ZipEntryRO entry;
+    while ((entry = pZip.nextEntry(iterationCookie.get())) != null) {
+
+      final Ref<String> nameBuf = new Ref<>(null);
+
+      if (pZip.getEntryFileName(entry, nameBuf) != 0) {
+        // TODO: fix this if we expect to have long names
+        ALOGE("ARGH: name too long?\n");
+        continue;
+      }
+
+//      System.out.printf("Comparing %s in %s?\n", nameBuf.get(), dirName.string());
+      if (!nameBuf.get().startsWith(dirName.string() + '/')) {
+        // not matching
+        continue;
+      }
+      if (dirNameLen == 0 || nameBuf.get().charAt(dirNameLen) == '/') {
+        int cp = 0;
+        int nextSlashIndex;
+
+        //cp = nameBuf + dirNameLen;
+        cp += dirNameLen;
+        if (dirNameLen != 0) {
+          cp++;       // advance past the '/'
+        }
+
+        nextSlashIndex = nameBuf.get().indexOf('/', cp);
+        //xxx this may break if there are bare directory entries
+        if (nextSlashIndex == -1) {
+          /* this is a file in the requested directory */
+          String8 fileName = new String8(nameBuf.get()).getPathLeaf();
+          if (fileName.string().isEmpty()) {
+            // ignore
+            continue;
+          }
+          AssetDir.FileInfo info = new FileInfo();
+          info.set(fileName, FileType.kFileTypeRegular);
+
+          info.setSourceName(
+              createZipSourceNameLocked(zipName, dirName, info.getFileName()));
+
+          contents.add(info);
+          //printf("FOUND: file '%s'\n", info.getFileName().string());
+        } else {
+          /* this is a subdir; add it if we don't already have it*/
+          String8 subdirName = new String8(nameBuf.get().substring(cp, nextSlashIndex));
+          int j;
+          int N = dirs.size();
+
+          for (j = 0; j < N; j++) {
+            if (subdirName.equals(dirs.get(j))) {
+              break;
+            }
+          }
+          if (j == N) {
+            dirs.add(subdirName);
+          }
+
+          //printf("FOUND: dir '%s'\n", subdirName.string());
+        }
+      }
+    }
+
+    pZip.endIteration(iterationCookie);
+
+      /*
+       * Add the set of unique directories.
+       */
+    for (int i = 0; i < dirs.size(); i++) {
+      AssetDir.FileInfo info = new FileInfo();
+      info.set(dirs.get(i), kFileTypeDirectory);
+      info.setSourceName(
+          createZipSourceNameLocked(zipName, dirName, info.getFileName()));
+      contents.add(info);
+    }
+
+    mergeInfoLocked(pMergedInfo, contents);
+
+    return true;
+
+  }
+
+
+  /*
+   * Merge two vectors of FileInfo.
+   *
+   * The merged contents will be stuffed into *pMergedInfo.
+   *
+   * If an entry for a file exists in both "pMergedInfo" and "pContents",
+   * we use the newer "pContents" entry.
+   */
+  void mergeInfoLocked(Ref<SortedVector<AssetDir.FileInfo>> pMergedInfoRef,
+      final SortedVector<AssetDir.FileInfo> pContents) {
+      /*
+       * Merge what we found in this directory with what we found in
+       * other places.
+       *
+       * Two basic approaches:
+       * (1) Create a new array that holds the unique values of the two
+       *     arrays.
+       * (2) Take the elements from pContents and shove them into pMergedInfo.
+       *
+       * Because these are vectors of complex objects, moving elements around
+       * inside the vector requires finalructing new objects and allocating
+       * storage for members.  With approach #1, we're always adding to the
+       * end, whereas with #2 we could be inserting multiple elements at the
+       * front of the vector.  Approach #1 requires a full copy of the
+       * contents of pMergedInfo, but approach #2 requires the same copy for
+       * every insertion at the front of pMergedInfo.
+       *
+       * (We should probably use a SortedVector interface that allows us to
+       * just stuff items in, trusting us to maintain the sort order.)
+       */
+    SortedVector<AssetDir.FileInfo> pNewSorted;
+    int mergeMax, contMax;
+    int mergeIdx, contIdx;
+
+    SortedVector<AssetDir.FileInfo> pMergedInfo = pMergedInfoRef.get();
+    pNewSorted = new SortedVector<>();
+    mergeMax = pMergedInfo.size();
+    contMax = pContents.size();
+    mergeIdx = contIdx = 0;
+
+    while (mergeIdx < mergeMax || contIdx < contMax) {
+      if (mergeIdx == mergeMax) {
+              /* hit end of "merge" list, copy rest of "contents" */
+        pNewSorted.add(pContents.itemAt(contIdx));
+        contIdx++;
+      } else if (contIdx == contMax) {
+              /* hit end of "cont" list, copy rest of "merge" */
+        pNewSorted.add(pMergedInfo.itemAt(mergeIdx));
+        mergeIdx++;
+      } else if (pMergedInfo.itemAt(mergeIdx) == pContents.itemAt(contIdx)) {
+              /* items are identical, add newer and advance both indices */
+        pNewSorted.add(pContents.itemAt(contIdx));
+        mergeIdx++;
+        contIdx++;
+      } else if (pMergedInfo.itemAt(mergeIdx).isLessThan(pContents.itemAt(contIdx))) {
+              /* "merge" is lower, add that one */
+        pNewSorted.add(pMergedInfo.itemAt(mergeIdx));
+        mergeIdx++;
+      } else {
+              /* "cont" is lower, add that one */
+        assert (pContents.itemAt(contIdx).isLessThan(pMergedInfo.itemAt(mergeIdx)));
+        pNewSorted.add(pContents.itemAt(contIdx));
+        contIdx++;
+      }
+    }
+
+      /*
+       * Overwrite the "merged" list with the new stuff.
+       */
+    pMergedInfoRef.set(pNewSorted);
+
+//  #if 0       // for Vector, rather than SortedVector
+//      int i, j;
+//      for (i = pContents.size() -1; i >= 0; i--) {
+//          boolean add = true;
+//
+//          for (j = pMergedInfo.size() -1; j >= 0; j--) {
+//              /* case-sensitive comparisons, to behave like UNIX fs */
+//              if (strcmp(pContents.itemAt(i).mFileName,
+//                         pMergedInfo.itemAt(j).mFileName) == 0)
+//              {
+//                  /* match, don't add this entry */
+//                  add = false;
+//                  break;
+//              }
+//          }
+//
+//          if (add)
+//              pMergedInfo.add(pContents.itemAt(i));
+//      }
+//  #endif
+  }
+
+  /*
+   * ===========================================================================
+   *      SharedZip
+   * ===========================================================================
+   */
+
+  static class SharedZip /*: public RefBase */ {
+
+    final String mPath;
+    final ZipFileRO mZipFile;
+    final long mModWhen;
+
+    Asset mResourceTableAsset;
+    ResTable mResourceTable;
+
+    List<asset_path> mOverlays;
+
+    final static Object gLock = new Object();
+    final static Map<String8, WeakReference<SharedZip>> gOpen = new HashMap<>();
+
+    public SharedZip(String path, long modWhen) {
+      this.mPath = path;
+      this.mModWhen = modWhen;
+      this.mResourceTableAsset = null;
+      this.mResourceTable = null;
+
+      if (kIsDebug) {
+        ALOGI("Creating SharedZip %s %s\n", this, mPath);
+      }
+      ALOGV("+++ opening zip '%s'\n", mPath);
+      this.mZipFile = ZipFileRO.open(mPath);
+      if (mZipFile == null) {
+        ALOGD("failed to open Zip archive '%s'\n", mPath);
+      }
+    }
+
+    static SharedZip get(final String8 path) {
+      return get(path, true);
+    }
+
+    static SharedZip get(final String8 path, boolean createIfNotPresent) {
+      synchronized (gLock) {
+        long modWhen = getFileModDate(path.string());
+        WeakReference<SharedZip> ref = gOpen.get(path);
+        SharedZip zip = ref == null ? null : ref.get();
+        if (zip != null && zip.mModWhen == modWhen) {
+          return zip;
+        }
+        if (zip == null && !createIfNotPresent) {
+          return null;
+        }
+        zip = new SharedZip(path.string(), modWhen);
+        gOpen.put(path, new WeakReference<>(zip));
+        return zip;
+
+      }
+
+    }
+
+    ZipFileRO getZip() {
+      return mZipFile;
+    }
+
+    Asset getResourceTableAsset() {
+      synchronized (gLock) {
+        ALOGV("Getting from SharedZip %s resource asset %s\n", this, mResourceTableAsset);
+        return mResourceTableAsset;
+      }
+    }
+
+    Asset setResourceTableAsset(Asset asset) {
+      synchronized (gLock) {
+        if (mResourceTableAsset == null) {
+          // This is not thread safe the first time it is called, so
+          // do it here with the global lock held.
+          asset.getBuffer(true);
+          mResourceTableAsset = asset;
+          return asset;
+        }
+      }
+      return mResourceTableAsset;
+    }
+
+    ResTable getResourceTable() {
+      ALOGV("Getting from SharedZip %s resource table %s\n", this, mResourceTable);
+      return mResourceTable;
+    }
+
+    ResTable setResourceTable(ResTable res) {
+      synchronized (gLock) {
+        if (mResourceTable == null) {
+          mResourceTable = res;
+          return res;
+        }
+      }
+      return mResourceTable;
+    }
+
+//  boolean SharedZip.isUpToDate()
+//  {
+//      time_t modWhen = getFileModDate(mPath.string());
+//      return mModWhen == modWhen;
+//  }
+//
+//  void SharedZip.addOverlay(final asset_path& ap)
+//  {
+//      mOverlays.add(ap);
+//  }
+//
+//  boolean SharedZip.getOverlay(int idx, asset_path* out) final
+//  {
+//      if (idx >= mOverlays.size()) {
+//          return false;
+//      }
+//      *out = mOverlays[idx];
+//      return true;
+//  }
+//
+//  SharedZip.~SharedZip()
+//  {
+//      if (kIsDebug) {
+//          ALOGI("Destroying SharedZip %s %s\n", this, (final char*)mPath);
+//      }
+//      if (mResourceTable != null) {
+//          delete mResourceTable;
+//      }
+//      if (mResourceTableAsset != null) {
+//          delete mResourceTableAsset;
+//      }
+//      if (mZipFile != null) {
+//          delete mZipFile;
+//          ALOGV("Closed '%s'\n", mPath.string());
+//      }
+//  }
+
+    @Override
+    public String toString() {
+      String id = Integer.toString(System.identityHashCode(this), 16);
+      return "SharedZip{mPath='" + mPath + "\', id=0x" + id + "}";
+    }
+  }
+
+
+  /*
+ * Manage a set of Zip files.  For each file we need a pointer to the
+ * ZipFile and a time_t with the file's modification date.
+ *
+ * We currently only have two zip files (current app, "common" app).
+ * (This was originally written for 8, based on app/locale/vendor.)
+ */
+  static class ZipSet {
+
+    final List<String> mZipPath = new ArrayList<>();
+    final List<SharedZip> mZipFile = new ArrayList<>();
+
+  /*
+   * ===========================================================================
+   *      ZipSet
+   * ===========================================================================
+   */
+
+    /*
+     * Destructor.  Close any open archives.
+     */
+//  ZipSet.~ZipSet(void)
+    @Override
+    protected void finalize() {
+      int N = mZipFile.size();
+      for (int i = 0; i < N; i++) {
+        closeZip(i);
+      }
+    }
+
+    /*
+     * Close a Zip file and reset the entry.
+     */
+    void closeZip(int idx) {
+      mZipFile.set(idx, null);
+    }
+
+
+    /*
+     * Retrieve the appropriate Zip file from the set.
+     */
+    synchronized ZipFileRO getZip(final String path) {
+      int idx = getIndex(path);
+      SharedZip zip = mZipFile.get(idx);
+      if (zip == null) {
+        zip = SharedZip.get(new String8(path));
+        mZipFile.set(idx, zip);
+      }
+      return zip.getZip();
+    }
+
+    synchronized Asset getZipResourceTableAsset(final String8 path) {
+      int idx = getIndex(path.string());
+      SharedZip zip = mZipFile.get(idx);
+      if (zip == null) {
+        zip = SharedZip.get(path);
+        mZipFile.set(idx, zip);
+      }
+      return zip.getResourceTableAsset();
+    }
+
+    synchronized Asset setZipResourceTableAsset(final String8 path, Asset asset) {
+      int idx = getIndex(path.string());
+      SharedZip zip = mZipFile.get(idx);
+      // doesn't make sense to call before previously accessing.
+      return zip.setResourceTableAsset(asset);
+    }
+
+    synchronized ResTable getZipResourceTable(final String8 path) {
+      int idx = getIndex(path.string());
+      SharedZip zip = mZipFile.get(idx);
+      if (zip == null) {
+        zip = SharedZip.get(path);
+        mZipFile.set(idx, zip);
+      }
+      return zip.getResourceTable();
+    }
+
+    synchronized ResTable setZipResourceTable(final String8 path, ResTable res) {
+      int idx = getIndex(path.string());
+      SharedZip zip = mZipFile.get(idx);
+      // doesn't make sense to call before previously accessing.
+      return zip.setResourceTable(res);
+    }
+
+    /*
+     * Generate the partial pathname for the specified archive.  The caller
+     * gets to prepend the asset root directory.
+     *
+     * Returns something like "common/en-US-noogle.jar".
+     */
+    static String8 getPathName(final String zipPath) {
+      return new String8(zipPath);
+    }
+
+    //
+//  boolean ZipSet.isUpToDate()
+//  {
+//      final int N = mZipFile.size();
+//      for (int i=0; i<N; i++) {
+//          if (mZipFile[i] != null && !mZipFile[i].isUpToDate()) {
+//              return false;
+//          }
+//      }
+//      return true;
+//  }
+//
+//  void ZipSet.addOverlay(final String8& path, final asset_path& overlay)
+//  {
+//      int idx = getIndex(path);
+//      sp<SharedZip> zip = mZipFile[idx];
+//      zip.addOverlay(overlay);
+//  }
+//
+//  boolean ZipSet.getOverlay(final String8& path, int idx, asset_path* out) final
+//  {
+//      sp<SharedZip> zip = SharedZip.get(path, false);
+//      if (zip == null) {
+//          return false;
+//      }
+//      return zip.getOverlay(idx, out);
+//  }
+//
+  /*
+   * Compute the zip file's index.
+   *
+   * "appName", "locale", and "vendor" should be set to null to indicate the
+   * default directory.
+   */
+    int getIndex(final String zip) {
+      final int N = mZipPath.size();
+      for (int i = 0; i < N; i++) {
+        if (Objects.equals(mZipPath.get(i), zip)) {
+          return i;
+        }
+      }
+
+      mZipPath.add(zip);
+      mZipFile.add(null);
+
+      return mZipPath.size() - 1;
+    }
+
+  }
+
+  private static long getFileModDate(String path) {
+    try {
+      return Files.getLastModifiedTime(Paths.get(path)).toMillis();
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public List<AssetPath> getAssetPaths() {
+    synchronized (mLock) {
+      ArrayList<AssetPath> assetPaths = new ArrayList<>(mAssetPaths.size());
+      for (asset_path assetPath : mAssetPaths) {
+        Path path;
+        switch (assetPath.type) {
+          case kFileTypeDirectory:
+            path = Fs.fromUrl(assetPath.path.string());
+            break;
+          case kFileTypeRegular:
+            path = Fs.fromUrl(assetPath.path.string());
+            break;
+          default:
+            throw new IllegalStateException(
+                "Unsupported type " + assetPath.type + " for + " + assetPath.path.string());
+        }
+        assetPaths.add(new AssetPath(path, assetPath.isSystemAsset));
+      }
+      return assetPaths;
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java b/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java
new file mode 100644
index 0000000..2918673
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java
@@ -0,0 +1,1676 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.ApkAssetsCookie.K_INVALID_COOKIE;
+import static org.robolectric.res.android.ApkAssetsCookie.kInvalidCookie;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.ResourceUtils.ExtractResourceName;
+import static org.robolectric.res.android.ResourceUtils.fix_package_id;
+import static org.robolectric.res.android.ResourceUtils.get_entry_id;
+import static org.robolectric.res.android.ResourceUtils.get_package_id;
+import static org.robolectric.res.android.ResourceUtils.get_type_id;
+import static org.robolectric.res.android.ResourceUtils.is_internal_resid;
+import static org.robolectric.res.android.ResourceUtils.is_valid_resid;
+import static org.robolectric.res.android.Util.ATRACE_CALL;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.robolectric.res.Fs;
+import org.robolectric.res.android.AssetDir.FileInfo;
+import org.robolectric.res.android.CppApkAssets.ForEachFileCallback;
+import org.robolectric.res.android.CppAssetManager.FileType;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag.Entry;
+import org.robolectric.res.android.Idmap.LoadedIdmap;
+import org.robolectric.res.android.LoadedArsc.DynamicPackageEntry;
+import org.robolectric.res.android.LoadedArsc.LoadedPackage;
+import org.robolectric.res.android.LoadedArsc.TypeSpec;
+import org.robolectric.res.android.ResourceTypes.ResTable_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_map;
+import org.robolectric.res.android.ResourceTypes.ResTable_map_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_type;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/AssetManager2.h
+// and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/AssetManager2.cpp
+@SuppressWarnings("NewApi")
+public class CppAssetManager2 {
+//  #define ATRACE_TAG ATRACE_TAG_RESOURCES
+//  
+//  #include "androidfw/AssetManager2.h"
+
+//#include <array>
+//#include <limits>
+//#include <set>
+//#include <unordered_map>
+//
+//#include "androidfw/ApkAssets.h"
+//#include "androidfw/Asset.h"
+//#include "androidfw/AssetManager.h"
+//#include "androidfw/ResourceTypes.h"
+//#include "androidfw/Util.h"
+//
+//namespace android {
+//
+//class Theme;
+//
+//using ApkAssetsCookie = int32_t;
+//
+//enum : ApkAssetsCookie {
+  //};
+
+  // Holds a bag that has been merged with its parent, if one exists.
+  public static class ResolvedBag {
+    // A single key-value entry in a bag.
+    public static class Entry {
+      // The key, as described in ResTable_map.name.
+      public int key;
+
+      public Res_value value = new Res_value();
+
+      // The resource ID of the origin style associated with the given entry
+      public int style;
+
+      // Which ApkAssets this entry came from.
+      public ApkAssetsCookie cookie;
+
+      ResStringPool key_pool;
+      ResStringPool type_pool;
+
+      public ResolvedBag.Entry copy() {
+        Entry entry = new Entry();
+        entry.key = key;
+        entry.value = value.copy();
+        entry.cookie = cookie == null ? null : ApkAssetsCookie.forInt(cookie.intValue());
+        entry.key_pool = key_pool;
+        entry.type_pool = type_pool;
+        return entry;
+      }
+
+      @Override
+      public String toString() {
+        return "Entry{" +
+            "key=" + key +
+            ", value=" + value +
+            '}';
+      }
+    };
+
+    // Denotes the configuration axis that this bag varies with.
+    // If a configuration changes with respect to one of these axis,
+    // the bag should be reloaded.
+    public int type_spec_flags;
+
+    // The number of entries in this bag. Access them by indexing into `entries`.
+    public int entry_count;
+
+    // The array of entries for this bag. An empty array is a neat trick to force alignment
+    // of the Entry structs that follow this structure and avoids a bunch of casts.
+    public Entry[] entries;
+  };
+
+  // AssetManager2 is the main entry point for accessing assets and resources.
+  // AssetManager2 provides caching of resources retrieved via the underlying ApkAssets.
+//  class AssetManager2 : public .AAssetManager {
+//   public:
+  public static class ResourceName {
+    public String package_ = null;
+    // int package_len = 0;
+
+    public String type = null;
+    // public String type16 = null;
+    // int type_len = 0;
+
+    public String entry = null;
+    // public String entry16 = null;
+    // int entry_len = 0;
+  };
+
+  public CppAssetManager2() {
+  }
+
+
+  public final List<CppApkAssets> GetApkAssets() { return apk_assets_; }
+
+  final ResTable_config GetConfiguration() { return configuration_; }
+
+// private:
+//  DISALLOW_COPY_AND_ASSIGN(AssetManager2);
+
+  // The ordered list of ApkAssets to search. These are not owned by the AssetManager, and must
+  // have a longer lifetime.
+  private List<CppApkAssets> apk_assets_;
+
+  // A collection of configurations and their associated ResTable_type that match the current
+  // AssetManager configuration.
+  static class FilteredConfigGroup {
+    final List<ResTable_config> configurations = new ArrayList<>();
+    final List<ResTable_type> types = new ArrayList<>();
+  }
+
+  // Represents an single package.
+  static class ConfiguredPackage {
+    // A pointer to the immutable, loaded package info.
+    LoadedPackage loaded_package_;
+
+    // A mutable AssetManager-specific list of configurations that match the AssetManager's
+    // current configuration. This is used as an optimization to avoid checking every single
+    // candidate configuration when looking up resources.
+    ByteBucketArray<FilteredConfigGroup> filtered_configs_;
+
+    public ConfiguredPackage(LoadedPackage package_) {
+      this.loaded_package_ = package_;
+    }
+  }
+
+  // Represents a logical package, which can be made up of many individual packages. Each package
+  // in a PackageGroup shares the same package name and package ID.
+  static class PackageGroup {
+    // The set of packages that make-up this group.
+    final List<ConfiguredPackage> packages_ = new ArrayList<>();
+
+    // The cookies associated with each package in the group. They share the same order as
+    // packages_.
+    final List<ApkAssetsCookie> cookies_ = new ArrayList<>();
+
+    // A library reference table that contains build-package ID to runtime-package ID mappings.
+    DynamicRefTable dynamic_ref_table;
+  }
+
+  // DynamicRefTables for shared library package resolution.
+  // These are ordered according to apk_assets_. The mappings may change depending on what is
+  // in apk_assets_, therefore they must be stored in the AssetManager and not in the
+  // immutable ApkAssets class.
+  final private List<PackageGroup> package_groups_ = new ArrayList<>();
+
+  // An array mapping package ID to index into package_groups. This keeps the lookup fast
+  // without taking too much memory.
+//  private std.array<byte, std.numeric_limits<byte>.max() + 1> package_ids_;
+  final private byte[] package_ids_ = new byte[256];
+
+  // The current configuration set for this AssetManager. When this changes, cached resources
+  // may need to be purged.
+  private ResTable_config configuration_ = new ResTable_config();
+
+  // Cached set of bags. These are cached because they can inherit keys from parent bags,
+  // which involves some calculation.
+//  private std.unordered_map<int, util.unique_cptr<ResolvedBag>> cached_bags_;
+  final private Map<Integer, ResolvedBag> cached_bags_ = new HashMap<>();
+  //  };
+
+  // final ResolvedBag.Entry* begin(final ResolvedBag* bag) { return bag.entries; }
+  //
+  // final ResolvedBag.Entry* end(final ResolvedBag* bag) {
+  //  return bag.entries + bag.entry_count;
+  // }
+  //
+  // }  // namespace android
+  //
+  // #endif // ANDROIDFW_ASSETMANAGER2_H_
+
+  //
+  //  #include <set>
+  //
+  //  #include "android-base/logging.h"
+  //  #include "android-base/stringprintf.h"
+  //  #include "utils/ByteOrder.h"
+  //  #include "utils/Trace.h"
+  //
+  //  #ifdef _WIN32
+  //  #ifdef ERROR
+  //  #undef ERROR
+  //  #endif
+  //  #endif
+  //
+  //  #include "androidfw/ResourceUtils.h"
+  //
+  //  namespace android {
+  static class FindEntryResult {
+    // A pointer to the resource table entry for this resource.
+    // If the size of the entry is > sizeof(ResTable_entry), it can be cast to
+    // a ResTable_map_entry and processed as a bag/map.
+    ResTable_entry entry;
+
+    // The configuration for which the resulting entry was defined. This is already swapped to host
+    // endianness.
+    ResTable_config config;
+
+    // The bitmask of configuration axis with which the resource value varies.
+    int type_flags;
+
+    // The dynamic package ID map for the package from which this resource came from.
+    DynamicRefTable dynamic_ref_table;
+
+    // The string pool reference to the type's name. This uses a different string pool than
+    // the global string pool, but this is hidden from the caller.
+    StringPoolRef type_string_ref;
+
+    // The string pool reference to the entry's name. This uses a different string pool than
+    // the global string pool, but this is hidden from the caller.
+    StringPoolRef entry_string_ref;
+  }
+
+//  AssetManager2() { memset(&configuration_, 0, sizeof(configuration_)); }
+
+  // Sets/resets the underlying ApkAssets for this AssetManager. The ApkAssets
+  // are not owned by the AssetManager, and must have a longer lifetime.
+  //
+  // Only pass invalidate_caches=false when it is known that the structure
+  // change in ApkAssets is due to a safe addition of resources with completely
+  // new resource IDs.
+//  boolean SetApkAssets(final List<ApkAssets> apk_assets, boolean invalidate_caches = true);
+  public boolean SetApkAssets(final List<CppApkAssets> apk_assets, boolean invalidate_caches) {
+    apk_assets_ = apk_assets;
+    BuildDynamicRefTable();
+    RebuildFilterList();
+    if (invalidate_caches) {
+//      InvalidateCaches(static_cast<int>(-1));
+      InvalidateCaches(-1);
+    }
+    return true;
+  }
+
+  // Assigns package IDs to all shared library ApkAssets.
+  // Should be called whenever the ApkAssets are changed.
+//  void BuildDynamicRefTable();
+  void BuildDynamicRefTable() {
+    package_groups_.clear();
+//    package_ids_.fill(0xff);
+    for (int i = 0; i < package_ids_.length; i++) {
+      package_ids_[i] = (byte) 0xff;
+    }
+
+    // 0x01 is reserved for the android package.
+    int next_package_id = 0x02;
+    final int apk_assets_count = apk_assets_.size();
+    for (int i = 0; i < apk_assets_count; i++) {
+      final LoadedArsc loaded_arsc = apk_assets_.get(i).GetLoadedArsc();
+//      for (final std.unique_ptr<final LoadedPackage>& package_ :
+      for (final LoadedPackage package_ :
+          loaded_arsc.GetPackages()) {
+        // Get the package ID or assign one if a shared library.
+        int package_id;
+        if (package_.IsDynamic()) {
+          package_id = next_package_id++;
+        } else {
+          package_id = package_.GetPackageId();
+        }
+
+        // Add the mapping for package ID to index if not present.
+        byte idx = package_ids_[package_id];
+        if (idx == (byte) 0xff) {
+          // package_ids_[package_id] = idx = static_cast<byte>(package_groups_.size());
+          package_ids_[package_id] = idx = (byte) package_groups_.size();
+          // DynamicRefTable& ref_table = package_groups_.back().dynamic_ref_table;
+          // ref_table.mAssignedPackageId = package_id;
+          // ref_table.mAppAsLib = package->IsDynamic() && package->GetPackageId() == 0x7f;
+          DynamicRefTable ref_table = new DynamicRefTable((byte) package_id,
+              package_.IsDynamic() && package_.GetPackageId() == 0x7f);
+          PackageGroup newPackageGroup = new PackageGroup();
+          newPackageGroup.dynamic_ref_table = ref_table;
+
+          package_groups_.add(newPackageGroup);
+        }
+        PackageGroup package_group = package_groups_.get(idx);
+
+        // Add the package and to the set of packages with the same ID.
+        // package_group->packages_.push_back(ConfiguredPackage{package.get(), {}});
+        // package_group.cookies_.push_back(static_cast<ApkAssetsCookie>(i));
+        package_group.packages_.add(new ConfiguredPackage(package_));
+        package_group.cookies_.add(ApkAssetsCookie.forInt(i));
+
+        // Add the package name . build time ID mappings.
+        for (final DynamicPackageEntry entry : package_.GetDynamicPackageMap()) {
+          // String package_name(entry.package_name.c_str(), entry.package_name.size());
+          package_group.dynamic_ref_table.mEntries.put(
+              entry.package_name, (byte) entry.package_id);
+        }
+      }
+    }
+
+    // Now assign the runtime IDs so that we have a build-time to runtime ID map.
+    for (PackageGroup iter : package_groups_) {
+      String package_name = iter.packages_.get(0).loaded_package_.GetPackageName();
+      for (PackageGroup iter2 : package_groups_) {
+        iter2.dynamic_ref_table.addMapping(package_name,
+            iter.dynamic_ref_table.mAssignedPackageId);
+      }
+    }
+  }
+
+// void AssetManager2::DumpToLog() const {
+//   base::ScopedLogSeverity _log(base::INFO);
+//
+//   LOG(INFO) << base::StringPrintf("AssetManager2(this=%p)", this);
+//
+//   std::string list;
+//   for (const auto& apk_assets : apk_assets_) {
+//     base::StringAppendF(&list, "%s,", apk_assets->GetPath().c_str());
+//   }
+//   LOG(INFO) << "ApkAssets: " << list;
+//
+//   list = "";
+//   for (size_t i = 0; i < package_ids_.size(); i++) {
+//     if (package_ids_[i] != 0xff) {
+//       base::StringAppendF(&list, "%02x -> %d, ", (int)i, package_ids_[i]);
+//     }
+//   }
+//   LOG(INFO) << "Package ID map: " << list;
+//
+//   for (const auto& package_group: package_groups_) {
+//     list = "";
+//     for (const auto& package : package_group.packages_) {
+//       const LoadedPackage* loaded_package = package.loaded_package_;
+//       base::StringAppendF(&list, "%s(%02x%s), ", loaded_package->GetPackageName().c_str(),
+//                           loaded_package->GetPackageId(),
+//                           (loaded_package->IsDynamic() ? " dynamic" : ""));
+//     }
+//     LOG(INFO) << base::StringPrintf("PG (%02x): ",
+//                                     package_group.dynamic_ref_table.mAssignedPackageId)
+//               << list;
+//   }
+// }
+
+  // Returns the string pool for the given asset cookie.
+  // Use the string pool returned here with a valid Res_value object of type Res_value.TYPE_STRING.
+//  final ResStringPool GetStringPoolForCookie(ApkAssetsCookie cookie) const;
+  final ResStringPool GetStringPoolForCookie(ApkAssetsCookie cookie) {
+    if (cookie.intValue() < 0 || cookie.intValue() >= apk_assets_.size()) {
+      return null;
+    }
+    return apk_assets_.get(cookie.intValue()).GetLoadedArsc().GetStringPool();
+  }
+
+  // Returns the DynamicRefTable for the given package ID.
+  // This may be nullptr if the APK represented by `cookie` has no resource table.
+//  final DynamicRefTable GetDynamicRefTableForPackage(int package_id) const;
+  final DynamicRefTable GetDynamicRefTableForPackage(int package_id) {
+    if (package_id >= package_ids_.length) {
+      return null;
+    }
+
+    final int idx = package_ids_[package_id];
+    if (idx == 0xff) {
+      return null;
+    }
+    return package_groups_.get(idx).dynamic_ref_table;
+  }
+
+  // Returns the DynamicRefTable for the ApkAssets represented by the cookie.
+//  final DynamicRefTable GetDynamicRefTableForCookie(ApkAssetsCookie cookie) const;
+  public final DynamicRefTable GetDynamicRefTableForCookie(ApkAssetsCookie cookie) {
+    for (final PackageGroup package_group : package_groups_) {
+      for (final ApkAssetsCookie package_cookie : package_group.cookies_) {
+        if (package_cookie == cookie) {
+          return package_group.dynamic_ref_table;
+        }
+      }
+    }
+    return null;
+  }
+
+  // Sets/resets the configuration for this AssetManager. This will cause all
+  // caches that are related to the configuration change to be invalidated.
+//  void SetConfiguration(final ResTable_config& configuration);
+  public void SetConfiguration(final ResTable_config configuration) {
+    final int diff = configuration_.diff(configuration);
+    configuration_ = configuration;
+
+    if (isTruthy(diff)) {
+      RebuildFilterList();
+//      InvalidateCaches(static_cast<int>(diff));
+      InvalidateCaches(diff);
+    }
+  }
+
+  // Returns all configurations for which there are resources defined. This includes resource
+  // configurations in all the ApkAssets set for this AssetManager.
+  // If `exclude_system` is set to true, resource configurations from system APKs
+  // ('android' package, other libraries) will be excluded from the list.
+  // If `exclude_mipmap` is set to true, resource configurations defined for resource type 'mipmap'
+  // will be excluded from the list.
+//  Set<ResTable_config> GetResourceConfigurations(boolean exclude_system = false,
+//                                                 boolean exclude_mipmap = false);
+  public Set<ResTable_config> GetResourceConfigurations(boolean exclude_system,
+      boolean exclude_mipmap) {
+    // ATRACE_NAME("AssetManager::GetResourceConfigurations");
+    Set<ResTable_config> configurations = new HashSet<>();
+    for (final PackageGroup package_group : package_groups_) {
+      for (final ConfiguredPackage package_ : package_group.packages_) {
+        if (exclude_system && package_.loaded_package_.IsSystem()) {
+          continue;
+        }
+        package_.loaded_package_.CollectConfigurations(exclude_mipmap, configurations);
+      }
+    }
+    return configurations;
+  }
+
+  // Returns all the locales for which there are resources defined. This includes resource
+  // locales in all the ApkAssets set for this AssetManager.
+  // If `exclude_system` is set to true, resource locales from system APKs
+  // ('android' package, other libraries) will be excluded from the list.
+  // If `merge_equivalent_languages` is set to true, resource locales will be canonicalized
+  // and de-duped in the resulting list.
+//  Set<String> GetResourceLocales(boolean exclude_system = false,
+//                                 boolean merge_equivalent_languages = false);
+  public Set<String> GetResourceLocales(boolean exclude_system,
+      boolean merge_equivalent_languages) {
+    ATRACE_CALL();
+    Set<String> locales = new HashSet<>();
+    for (final PackageGroup package_group : package_groups_) {
+      for (final ConfiguredPackage package_ : package_group.packages_) {
+        if (exclude_system && package_.loaded_package_.IsSystem()) {
+          continue;
+        }
+        package_.loaded_package_.CollectLocales(merge_equivalent_languages, locales);
+      }
+    }
+    return locales;
+  }
+
+  // Searches the set of APKs loaded by this AssetManager and opens the first one found located
+  // in the assets/ directory.
+  // `mode` controls how the file is opened.
+  //
+  // NOTE: The loaded APKs are searched in reverse order.
+//  Asset Open(final String filename, Asset.AccessMode mode);
+  public Asset Open(final String filename, Asset.AccessMode mode) {
+    final String new_path = "assets/" + filename;
+    return OpenNonAsset(new_path, mode);
+  }
+
+  // Opens a file within the assets/ directory of the APK specified by `cookie`.
+  // `mode` controls how the file is opened.
+//  Asset Open(final String filename, ApkAssetsCookie cookie,
+//             Asset.AccessMode mode);
+  Asset Open(final String filename, ApkAssetsCookie cookie,
+      Asset.AccessMode mode) {
+    final String new_path = "assets/" + filename;
+    return OpenNonAsset(new_path, cookie, mode);
+  }
+
+  // Opens the directory specified by `dirname`. The result is an AssetDir that is the combination
+  // of all directories matching `dirname` under the assets/ directory of every ApkAssets loaded.
+  // The entries are sorted by their ASCII name.
+//  AssetDir OpenDir(final String dirname);
+  public AssetDir OpenDir(final String dirname) {
+    ATRACE_CALL();
+
+    String full_path = "assets/" + dirname;
+    // std.unique_ptr<SortedVector<AssetDir.FileInfo>> files =
+    //     util.make_unique<SortedVector<AssetDir.FileInfo>>();
+    SortedVector<FileInfo> files = new SortedVector<>();
+
+    // Start from the back.
+    for (CppApkAssets apk_assets : apk_assets_) {
+      // auto func = [&](final String& name, FileType type) {
+      ForEachFileCallback func = (final String name, FileType type) -> {
+        AssetDir.FileInfo info = new FileInfo();
+        info.setFileName(new String8(name));
+        info.setFileType(type);
+        info.setSourceName(new String8(apk_assets.GetPath()));
+        files.add(info);
+      };
+
+      if (!apk_assets.ForEachFile(full_path, func)) {
+        return new AssetDir();
+      }
+    }
+
+    // std.unique_ptr<AssetDir> asset_dir = util.make_unique<AssetDir>();
+    AssetDir asset_dir = new AssetDir();
+    asset_dir.setFileList(files);
+    return asset_dir;
+  }
+
+  // Searches the set of APKs loaded by this AssetManager and opens the first one found.
+  // `mode` controls how the file is opened.
+  // `out_cookie` is populated with the cookie of the APK this file was found in.
+  //
+  // NOTE: The loaded APKs are searched in reverse order.
+//  Asset OpenNonAsset(final String filename, Asset.AccessMode mode,
+//                     ApkAssetsCookie* out_cookie = null);
+  // Search in reverse because that's how we used to do it and we need to preserve behaviour.
+  // This is unfortunate, because ClassLoaders delegate to the parent first, so the order
+  // is inconsistent for split APKs.
+  public Asset OpenNonAsset(final String filename,
+      Asset.AccessMode mode,
+      Ref<ApkAssetsCookie> out_cookie) {
+    ATRACE_CALL();
+    for (int i = apk_assets_.size() - 1; i >= 0; i--) {
+      Asset asset = apk_assets_.get(i).Open(filename, mode);
+      if (isTruthy(asset)) {
+        if (out_cookie != null) {
+          out_cookie.set(ApkAssetsCookie.forInt(i));
+        }
+        return asset;
+      }
+    }
+
+    if (out_cookie != null) {
+      out_cookie.set(K_INVALID_COOKIE);
+    }
+    return null;
+  }
+
+  public Asset OpenNonAsset(final String filename, Asset.AccessMode mode) {
+    return OpenNonAsset(filename, mode, null);
+  }
+
+  // Opens a file in the APK specified by `cookie`. `mode` controls how the file is opened.
+  // This is typically used to open a specific AndroidManifest.xml, or a binary XML file
+  // referenced by a resource lookup with GetResource().
+//  Asset OpenNonAsset(final String filename, ApkAssetsCookie cookie,
+//                     Asset.AccessMode mode);
+  public Asset OpenNonAsset(final String filename,
+      ApkAssetsCookie cookie, Asset.AccessMode mode) {
+    ATRACE_CALL();
+    if (cookie.intValue() < 0 || cookie.intValue() >= apk_assets_.size()) {
+      return null;
+    }
+    return apk_assets_.get(cookie.intValue()).Open(filename, mode);
+  }
+
+  // template <typename Func>
+  public interface PackageFunc {
+    void apply(String package_name, byte package_id);
+  }
+
+  public void ForEachPackage(PackageFunc func) {
+    for (PackageGroup package_group : package_groups_) {
+      func.apply(package_group.packages_.get(0).loaded_package_.GetPackageName(),
+          package_group.dynamic_ref_table.mAssignedPackageId);
+    }
+  }
+
+  // Finds the best entry for `resid` from the set of ApkAssets. The entry can be a simple
+  // Res_value, or a complex map/bag type. If successful, it is available in `out_entry`.
+  // Returns kInvalidCookie on failure. Otherwise, the return value is the cookie associated with
+  // the ApkAssets in which the entry was found.
+  //
+  // `density_override` overrides the density of the current configuration when doing a search.
+  //
+  // When `stop_at_first_match` is true, the first match found is selected and the search
+  // terminates. This is useful for methods that just look up the name of a resource and don't
+  // care about the value. In this case, the value of `FindEntryResult::type_flags` is incomplete
+  // and should not be used.
+  //
+  // NOTE: FindEntry takes care of ensuring that structs within FindEntryResult have been properly
+  // bounds-checked. Callers of FindEntry are free to trust the data if this method succeeds.
+//  ApkAssetsCookie FindEntry(int resid, short density_override, boolean stop_at_first_match,
+//                            LoadedArscEntry* out_entry, ResTable_config out_selected_config,
+//                            int* out_flags);
+  private ApkAssetsCookie FindEntry(int resid, short density_override,
+      final Ref<FindEntryResult> out_entry) {
+    ATRACE_CALL();
+
+    // Might use this if density_override != 0.
+    ResTable_config density_override_config;
+
+    // Select our configuration or generate a density override configuration.
+    ResTable_config desired_config = configuration_;
+    if (density_override != 0 && density_override != configuration_.density) {
+      density_override_config = configuration_;
+      density_override_config.density = density_override;
+      desired_config = density_override_config;
+    }
+
+    if (!is_valid_resid(resid)) {
+      System.err.println(String.format("Invalid ID 0x%08x.", resid));
+      return K_INVALID_COOKIE;
+    }
+
+    final int package_id = get_package_id(resid);
+    final int type_idx = (byte) (get_type_id(resid) - 1);
+    final int entry_idx = get_entry_id(resid);
+
+    final byte package_idx = package_ids_[package_id];
+    if (package_idx == (byte) 0xff) {
+      System.err.println(
+          String.format("No package ID %02x found for ID 0x%08x.", package_id, resid));
+      return K_INVALID_COOKIE;
+    }
+
+    final PackageGroup package_group = package_groups_.get(package_idx);
+    final int package_count = package_group.packages_.size();
+
+    ApkAssetsCookie best_cookie = K_INVALID_COOKIE;
+    LoadedPackage best_package = null;
+    ResTable_type best_type = null;
+    ResTable_config best_config = null;
+    ResTable_config best_config_copy;
+    int best_offset = 0;
+    int type_flags = 0;
+
+    // If desired_config is the same as the set configuration, then we can use our filtered list
+    // and we don't need to match the configurations, since they already matched.
+    boolean use_fast_path = desired_config == configuration_;
+
+    for (int pi = 0; pi < package_count; pi++) {
+      ConfiguredPackage loaded_package_impl = package_group.packages_.get(pi);
+      LoadedPackage loaded_package = loaded_package_impl.loaded_package_;
+      ApkAssetsCookie cookie = package_group.cookies_.get(pi);
+
+      // If the type IDs are offset in this package, we need to take that into account when searching
+      // for a type.
+      TypeSpec type_spec = loaded_package.GetTypeSpecByTypeIndex(type_idx);
+      if (Util.UNLIKELY(type_spec == null)) {
+        continue;
+      }
+
+      int local_entry_idx = entry_idx;
+
+      // If there is an IDMAP supplied with this package, translate the entry ID.
+      if (type_spec.idmap_entries != null) {
+        if (!LoadedIdmap
+            .Lookup(type_spec.idmap_entries, local_entry_idx, new Ref<>(local_entry_idx))) {
+          // There is no mapping, so the resource is not meant to be in this overlay package.
+          continue;
+        }
+      }
+
+      type_flags |= type_spec.GetFlagsForEntryIndex(local_entry_idx);
+
+      // If the package is an overlay, then even configurations that are the same MUST be chosen.
+      boolean package_is_overlay = loaded_package.IsOverlay();
+
+      FilteredConfigGroup filtered_group = loaded_package_impl.filtered_configs_.get(type_idx);
+      if (use_fast_path) {
+        List<ResTable_config> candidate_configs = filtered_group.configurations;
+        int type_count = candidate_configs.size();
+        for (int i = 0; i < type_count; i++) {
+          ResTable_config this_config = candidate_configs.get(i);
+
+          // We can skip calling ResTable_config.match() because we know that all candidate
+          // configurations that do NOT match have been filtered-out.
+          if ((best_config == null || this_config.isBetterThan(best_config, desired_config)) ||
+              (package_is_overlay && this_config.compare(best_config) == 0)) {
+            // The configuration matches and is better than the previous selection.
+            // Find the entry value if it exists for this configuration.
+            ResTable_type type_chunk = filtered_group.types.get(i);
+            int offset = LoadedPackage.GetEntryOffset(type_chunk, local_entry_idx);
+            if (offset == ResTable_type.NO_ENTRY) {
+              continue;
+            }
+
+            best_cookie = cookie;
+            best_package = loaded_package;
+            best_type = type_chunk;
+            best_config = this_config;
+            best_offset = offset;
+          }
+        }
+      } else {
+        // This is the slower path, which doesn't use the filtered list of configurations.
+        // Here we must read the ResTable_config from the mmapped APK, convert it to host endianness
+        // and fill in any new fields that did not exist when the APK was compiled.
+        // Furthermore when selecting configurations we can't just record the pointer to the
+        // ResTable_config, we must copy it.
+        // auto iter_end = type_spec.types + type_spec.type_count;
+        //   for (auto iter = type_spec.types; iter != iter_end; ++iter) {
+        for (ResTable_type type : type_spec.types) {
+          ResTable_config this_config = ResTable_config.fromDtoH(type.config);
+
+          if (this_config.match(desired_config)) {
+            if ((best_config == null || this_config.isBetterThan(best_config, desired_config)) ||
+                (package_is_overlay && this_config.compare(best_config) == 0)) {
+              // The configuration matches and is better than the previous selection.
+              // Find the entry value if it exists for this configuration.
+              int offset = LoadedPackage.GetEntryOffset(type, local_entry_idx);
+              if (offset == ResTable_type.NO_ENTRY) {
+                continue;
+              }
+
+              best_cookie = cookie;
+              best_package = loaded_package;
+              best_type = type;
+              best_config_copy = this_config;
+              best_config = best_config_copy;
+              best_offset = offset;
+            }
+          }
+        }
+      }
+    }
+
+    if (Util.UNLIKELY(best_cookie.intValue() == kInvalidCookie)) {
+      return K_INVALID_COOKIE;
+    }
+
+    ResTable_entry best_entry = LoadedPackage.GetEntryFromOffset(best_type, best_offset);
+    if (Util.UNLIKELY(best_entry == null)) {
+      return K_INVALID_COOKIE;
+    }
+
+    FindEntryResult out_entry_ = new FindEntryResult();
+    out_entry_.entry = best_entry;
+    out_entry_.config = best_config;
+    out_entry_.type_flags = type_flags;
+    out_entry_.type_string_ref = new StringPoolRef(best_package.GetTypeStringPool(), best_type.id - 1);
+    out_entry_.entry_string_ref =
+        new StringPoolRef(best_package.GetKeyStringPool(), best_entry.key.index);
+    out_entry_.dynamic_ref_table = package_group.dynamic_ref_table;
+    out_entry.set(out_entry_);
+    return best_cookie;
+  }
+
+  // Populates the `out_name` parameter with resource name information.
+  // Utf8 strings are preferred, and only if they are unavailable are
+  // the Utf16 variants populated.
+  // Returns false if the resource was not found or the name was missing/corrupt.
+//  boolean GetResourceName(int resid, ResourceName* out_name);
+  public boolean GetResourceName(int resid, ResourceName out_name) {
+    final Ref<FindEntryResult> entryRef = new Ref<>(null);
+    ApkAssetsCookie cookie = FindEntry(resid, (short) 0 /* density_override */, entryRef);
+    if (cookie.intValue() == kInvalidCookie) {
+      return false;
+    }
+
+    final LoadedPackage package_ =
+        apk_assets_.get(cookie.intValue()).GetLoadedArsc().GetPackageById(get_package_id(resid));
+    if (package_ == null) {
+      return false;
+    }
+
+    out_name.package_ = package_.GetPackageName();
+    // out_name.package_len = out_name.package_.length();
+
+    FindEntryResult entry = entryRef.get();
+    out_name.type = entry.type_string_ref.string();
+    // out_name.type_len = out_name.type == null ? 0 : out_name.type.length();
+    // out_name.type16 = null;
+    if (out_name.type == null) {
+      // out_name.type16 = entry.type_string_ref.string();
+      // out_name.type_len = out_name.type16 == null ? 0 : out_name.type16.length();
+      // if (out_name.type16 == null) {
+        return false;
+      // }
+    }
+
+    out_name.entry = entry.entry_string_ref.string();
+    // out_name.entry_len = out_name.entry == null ? 0 : out_name.entry.length();
+    // out_name.entry16 = null;
+    if (out_name.entry == null) {
+      // out_name.entry16 = entry.entry_string_ref.string();
+      // out_name.entry_len = out_name.entry16 == null ? 0 : out_name.entry16.length();
+      // if (out_name.entry16 == null) {
+        return false;
+      // }
+    }
+    return true;
+  }
+
+  // Populates `out_flags` with the bitmask of configuration axis that this resource varies with.
+  // See ResTable_config for the list of configuration axis.
+  // Returns false if the resource was not found.
+//  boolean GetResourceFlags(int resid, int* out_flags);
+  boolean GetResourceFlags(int resid, Ref<Integer> out_flags) {
+    final Ref<FindEntryResult> entry = new Ref<>(null);
+    ApkAssetsCookie cookie = FindEntry(resid, (short) 0 /* density_override */, entry);
+    if (cookie.intValue() != kInvalidCookie) {
+      out_flags.set(entry.get().type_flags);
+      // this makes no sense, not a boolean:
+      // return cookie;
+    }
+    // this makes no sense, not a boolean:
+    // return kInvalidCookie;
+
+    return cookie.intValue() != kInvalidCookie;
+  }
+
+
+  // Retrieves the best matching resource with ID `resid`. The resource value is filled into
+  // `out_value` and the configuration for the selected value is populated in `out_selected_config`.
+  // `out_flags` holds the same flags as retrieved with GetResourceFlags().
+  // If `density_override` is non-zero, the configuration to match against is overridden with that
+  // density.
+  //
+  // Returns a valid cookie if the resource was found. If the resource was not found, or if the
+  // resource was a map/bag type, then kInvalidCookie is returned. If `may_be_bag` is false,
+  // this function logs if the resource was a map/bag type before returning kInvalidCookie.
+//  ApkAssetsCookie GetResource(int resid, boolean may_be_bag, short density_override,
+//                              Res_value out_value, ResTable_config out_selected_config,
+//                              int* out_flags);
+  public ApkAssetsCookie GetResource(int resid, boolean may_be_bag,
+      short density_override, Ref<Res_value> out_value,
+      final Ref<ResTable_config> out_selected_config,
+      final Ref<Integer> out_flags) {
+    final Ref<FindEntryResult> entry = new Ref<>(null);
+    ApkAssetsCookie cookie = FindEntry(resid, density_override, entry);
+    if (cookie.intValue() == kInvalidCookie) {
+      return K_INVALID_COOKIE;
+    }
+
+    if (isTruthy(dtohl(entry.get().entry.flags) & ResTable_entry.FLAG_COMPLEX)) {
+      if (!may_be_bag) {
+        System.err.println(String.format("Resource %08x is a complex map type.", resid));
+        return K_INVALID_COOKIE;
+      }
+
+      // Create a reference since we can't represent this complex type as a Res_value.
+      out_value.set(new Res_value((byte) Res_value.TYPE_REFERENCE, resid));
+      out_selected_config.set(new ResTable_config(entry.get().config));
+      out_flags.set(entry.get().type_flags);
+      return cookie;
+    }
+
+    // final Res_value device_value = reinterpret_cast<final Res_value>(
+    //     reinterpret_cast<final byte*>(entry.entry) + dtohs(entry.entry.size));
+    // out_value.copyFrom_dtoh(*device_value);
+    Res_value device_value = entry.get().entry.getResValue();
+    out_value.set(device_value.copy());
+
+    // Convert the package ID to the runtime assigned package ID.
+    entry.get().dynamic_ref_table.lookupResourceValue(out_value);
+
+    out_selected_config.set(new ResTable_config(entry.get().config));
+    out_flags.set(entry.get().type_flags);
+    return cookie;
+  }
+
+  // Resolves the resource reference in `in_out_value` if the data type is
+  // Res_value::TYPE_REFERENCE.
+  // `cookie` is the ApkAssetsCookie of the reference in `in_out_value`.
+  // `in_out_value` is the reference to resolve. The result is placed back into this object.
+  // `in_out_flags` is the type spec flags returned from calls to GetResource() or
+  // GetResourceFlags(). Configuration flags of the values pointed to by the reference
+  // are OR'd together with `in_out_flags`.
+  // `in_out_config` is populated with the configuration for which the resolved value was defined.
+  // `out_last_reference` is populated with the last reference ID before resolving to an actual
+  // value. This is only initialized if the passed in `in_out_value` is a reference.
+  // Returns the cookie of the APK the resolved resource was defined in, or kInvalidCookie if
+  // it was not found.
+//  ApkAssetsCookie ResolveReference(ApkAssetsCookie cookie, Res_value in_out_value,
+//                                   ResTable_config in_out_selected_config, int* in_out_flags,
+//                                   int* out_last_reference);
+  public ApkAssetsCookie ResolveReference(ApkAssetsCookie cookie, Ref<Res_value> in_out_value,
+      final Ref<ResTable_config> in_out_selected_config,
+      final Ref<Integer> in_out_flags,
+      final Ref<Integer> out_last_reference) {
+    final int kMaxIterations = 20;
+
+    for (int iteration = 0; in_out_value.get().dataType == Res_value.TYPE_REFERENCE &&
+        in_out_value.get().data != 0 && iteration < kMaxIterations;
+        iteration++) {
+      out_last_reference.set(in_out_value.get().data);
+      final Ref<Integer> new_flags = new Ref<>(0);
+      cookie = GetResource(in_out_value.get().data, true /*may_be_bag*/, (short) 0 /*density_override*/,
+          in_out_value, in_out_selected_config, new_flags);
+      if (cookie.intValue() == kInvalidCookie) {
+        return K_INVALID_COOKIE;
+      }
+      if (in_out_flags != null) {
+        in_out_flags.set(in_out_flags.get() | new_flags.get());
+      }
+      if (out_last_reference.get() == in_out_value.get().data) {
+        // This reference can't be resolved, so exit now and let the caller deal with it.
+        return cookie;
+      }
+    }
+    return cookie;
+  }
+
+  // AssetManager2::GetBag(resid) wraps this function to track which resource ids have already
+  // been seen while traversing bag parents.
+  //  final ResolvedBag* GetBag(int resid);
+  public final ResolvedBag GetBag(int resid) {
+    List<Integer> found_resids = new ArrayList<>();
+    return GetBag(resid, found_resids);
+  }
+
+  // Retrieves the best matching bag/map resource with ID `resid`.
+  // This method will resolve all parent references for this bag and merge keys with the child.
+  // To iterate over the keys, use the following idiom:
+  //
+  //  final ResolvedBag* bag = asset_manager.GetBag(id);
+  //  if (bag != null) {
+  //    for (auto iter = begin(bag); iter != end(bag); ++iter) {
+  //      ...
+  //    }
+  //  }
+  ResolvedBag GetBag(int resid, List<Integer> child_resids) {
+    // ATRACE_NAME("AssetManager::GetBag");
+
+    ResolvedBag cached_iter = cached_bags_.get(resid);
+    if (cached_iter != null) {
+      return cached_iter;
+    }
+
+    final Ref<FindEntryResult> entryRef = new Ref<>(null);
+    ApkAssetsCookie cookie = FindEntry(resid, (short) 0 /* density_override */, entryRef);
+    if (cookie.intValue() == kInvalidCookie) {
+      return null;
+    }
+
+    FindEntryResult entry = entryRef.get();
+
+    // Check that the size of the entry header is at least as big as
+    // the desired ResTable_map_entry. Also verify that the entry
+    // was intended to be a map.
+    if (dtohs(entry.entry.size) < ResTable_map_entry.BASE_SIZEOF ||
+        (dtohs(entry.entry.flags) & ResourceTypes.ResTable_entry.FLAG_COMPLEX) == 0) {
+      // Not a bag, nothing to do.
+      return null;
+    }
+
+    // final ResTable_map_entry map = reinterpret_cast<final ResTable_map_entry*>(entry.entry);
+    // final ResTable_map map_entry =
+    //     reinterpret_cast<final ResTable_map*>(reinterpret_cast<final byte*>(map) + map.size);
+    // final ResTable_map map_entry_end = map_entry + dtohl(map.count);
+    final ResTable_map_entry map = new ResTable_map_entry(entry.entry.myBuf(), entry.entry.myOffset());
+    int curOffset = map.myOffset() + map.size;
+    ResTable_map map_entry = null; // = new ResTable_map(map.myBuf(), curOffset);
+    final int map_entry_end =
+        curOffset + dtohl(map.count) * ResTable_map.SIZEOF;
+    if (curOffset < map_entry_end) {
+      map_entry = new ResTable_map(map.myBuf(), curOffset);
+    }
+
+    // Keep track of ids that have already been seen to prevent infinite loops caused by circular
+    // dependencies between bags
+    child_resids.add(resid);
+
+    final Ref<Integer> parent_resid = new Ref<>(dtohl(map.parent.ident));
+    if (parent_resid.get() == 0 || child_resids.contains(parent_resid.get())) {
+      // There is no parent or that a circular dependency exist, meaning there is nothing to
+      // inherit and we can do a simple copy of the entries in the map.
+      final int entry_count = (map_entry_end - curOffset) / ResTable_map.SIZEOF;
+      // util.unique_cptr<ResolvedBag> new_bag{reinterpret_cast<ResolvedBag*>(
+      //     malloc(sizeof(ResolvedBag) + (entry_count * sizeof(ResolvedBag.Entry))))};
+      ResolvedBag new_bag = new ResolvedBag();
+      ResolvedBag.Entry[] new_entry = new_bag.entries = new Entry[entry_count];
+      int i = 0;
+      while (curOffset < map_entry_end) {
+        map_entry = new ResTable_map(map_entry.myBuf(), curOffset);
+        final Ref<Integer> new_key = new Ref<>(dtohl(map_entry.name.ident));
+        if (!is_internal_resid(new_key.get())) {
+          // Attributes, arrays, etc don't have a resource id as the name. They specify
+          // other data, which would be wrong to change via a lookup.
+          if (entry.dynamic_ref_table.lookupResourceId(new_key) != NO_ERROR) {
+            System.err.println(
+                String.format("Failed to resolve key 0x%08x in bag 0x%08x.", new_key.get(), resid));
+            return null;
+          }
+        }
+        Entry new_entry_ = new_entry[i] = new Entry();
+        new_entry_.cookie = cookie;
+        new_entry_.key = new_key.get();
+        new_entry_.key_pool = null;
+        new_entry_.type_pool = null;
+        new_entry_.style = resid;
+        new_entry_.value = map_entry.value.copy();
+        final Ref<Res_value> valueRef = new Ref<>(new_entry_.value);
+        int err = entry.dynamic_ref_table.lookupResourceValue(valueRef);
+        new_entry_.value = valueRef.get();
+        if (err != NO_ERROR) {
+          System.err.println(
+              String.format(
+                  "Failed to resolve value t=0x%02x d=0x%08x for key 0x%08x.",
+                  new_entry_.value.dataType, new_entry_.value.data, new_key.get()));
+          return null;
+        }
+        // ++new_entry;
+        ++i;
+
+        final int size = dtohs(map_entry.value.size);
+//      curOffset += size + sizeof(*map)-sizeof(map->value);
+        curOffset += size + ResTable_map.SIZEOF-Res_value.SIZEOF;
+
+      }
+      new_bag.type_spec_flags = entry.type_flags;
+      new_bag.entry_count = entry_count;
+      ResolvedBag result = new_bag;
+      cached_bags_.put(resid, new_bag);
+      return result;
+    }
+
+    // In case the parent is a dynamic reference, resolve it.
+    entry.dynamic_ref_table.lookupResourceId(parent_resid);
+
+    // Get the parent and do a merge of the keys.
+    final ResolvedBag parent_bag = GetBag(parent_resid.get(), child_resids);
+    if (parent_bag == null) {
+      // Failed to get the parent that should exist.
+      System.err.println(
+          String.format("Failed to find parent 0x%08x of bag 0x%08x.", parent_resid.get(), resid));
+      return null;
+    }
+
+    // Create the max possible entries we can make. Once we construct the bag,
+    // we will realloc to fit to size.
+    final int max_count = parent_bag.entry_count + dtohl(map.count);
+    // util::unique_cptr<ResolvedBag> new_bag{reinterpret_cast<ResolvedBag*>(
+    //     malloc(sizeof(ResolvedBag) + (max_count * sizeof(ResolvedBag::Entry))))};
+    ResolvedBag new_bag = new ResolvedBag();
+    new_bag.entries = new Entry[max_count];
+    final ResolvedBag.Entry[] new_entry = new_bag.entries;
+    int newEntryIndex = 0;
+
+  // const ResolvedBag::Entry* parent_entry = parent_bag->entries;
+    int parentEntryIndex = 0;
+    // final ResolvedBag.Entry parent_entry_end = parent_entry + parent_bag.entry_count;
+    final int parentEntryCount = parent_bag.entry_count;
+
+    // The keys are expected to be in sorted order. Merge the two bags.
+    while (map_entry != null
+        && curOffset != map_entry_end
+        && parentEntryIndex != parentEntryCount) {
+      map_entry = new ResTable_map(map_entry.myBuf(), curOffset);
+      final Ref<Integer> child_keyRef = new Ref<>(dtohl(map_entry.name.ident));
+      if (!is_internal_resid(child_keyRef.get())) {
+        if (entry.dynamic_ref_table.lookupResourceId(child_keyRef) != NO_ERROR) {
+          System.err.println(
+              String.format(
+                  "Failed to resolve key 0x%08x in bag 0x%08x.", child_keyRef.get(), resid));
+          return null;
+        }
+      }
+      int child_key = child_keyRef.get();
+
+      Entry parent_entry = parent_bag.entries[parentEntryIndex];
+      if (parent_entry == null) {
+        parent_entry = new Entry();
+      }
+
+      if (child_key <= parent_entry.key) {
+        // Use the child key if it comes before the parent
+        // or is equal to the parent (overrides).
+        Entry new_entry_ = new_entry[newEntryIndex] = new Entry();
+        new_entry_.cookie = cookie;
+        new_entry_.key = child_key;
+        new_entry_.key_pool = null;
+        new_entry_.type_pool = null;
+        new_entry_.value = map_entry.value.copy();
+        new_entry_.style = resid;
+        final Ref<Res_value> valueRef = new Ref<>(new_entry_.value);
+        int err = entry.dynamic_ref_table.lookupResourceValue(valueRef);
+        new_entry_.value = valueRef.get();
+        if (err != NO_ERROR) {
+          System.err.println(
+              String.format(
+                  "Failed to resolve value t=0x%02x d=0x%08x for key 0x%08x.",
+                  new_entry_.value.dataType, new_entry_.value.data, child_key));
+          return null;
+        }
+
+        // ++map_entry;
+        curOffset += map_entry.value.size + ResTable_map.SIZEOF - Res_value.SIZEOF;
+      } else {
+        // Take the parent entry as-is.
+        // memcpy(new_entry, parent_entry, sizeof(*new_entry));
+        new_entry[newEntryIndex] = parent_entry.copy();
+      }
+
+      if (child_key >= parent_entry.key) {
+        // Move to the next parent entry if we used it or it was overridden.
+        // ++parent_entry;
+        ++parentEntryIndex;
+        // parent_entry = parent_bag.entries[parentEntryIndex];
+      }
+      // Increment to the next entry to fill.
+      // ++new_entry;
+      ++newEntryIndex;
+    }
+
+    // Finish the child entries if they exist.
+    while (map_entry != null && curOffset != map_entry_end) {
+      map_entry = new ResTable_map(map_entry.myBuf(), curOffset);
+      final Ref<Integer> new_key = new Ref<>(map_entry.name.ident);
+      if (!is_internal_resid(new_key.get())) {
+        if (entry.dynamic_ref_table.lookupResourceId(new_key) != NO_ERROR) {
+          System.err.println(
+              String.format("Failed to resolve key 0x%08x in bag 0x%08x.", new_key.get(), resid));
+          return null;
+        }
+      }
+      Entry new_entry_ = new_entry[newEntryIndex] = new Entry();
+      new_entry_.cookie = cookie;
+      new_entry_.key = new_key.get();
+      new_entry_.key_pool = null;
+      new_entry_.type_pool = null;
+      new_entry_.value = map_entry.value.copy();
+      new_entry_.style = resid;
+      final Ref<Res_value> valueRef = new Ref<>(new_entry_.value);
+      int err = entry.dynamic_ref_table.lookupResourceValue(valueRef);
+      new_entry_.value = valueRef.get();
+      if (err != NO_ERROR) {
+        System.err.println(String.format(
+            "Failed to resolve value t=0x%02x d=0x%08x for key 0x%08x.",
+            new_entry_.value.dataType,
+            new_entry_.value.data, new_key.get()));
+        return null;
+      }
+      // ++map_entry;
+      curOffset += map_entry.value.size + ResTable_map.SIZEOF - Res_value.SIZEOF;
+      // ++new_entry;
+      ++newEntryIndex;
+    }
+
+    // Finish the parent entries if they exist.
+    while (parentEntryIndex != parent_bag.entry_count) {
+      // Take the rest of the parent entries as-is.
+      // final int num_entries_to_copy = parent_entry_end - parent_entry;
+      // final int num_entries_to_copy = parent_bag.entry_count - parentEntryIndex;
+      // memcpy(new_entry, parent_entry, num_entries_to_copy * sizeof(*new_entry));
+      Entry parentEntry = parent_bag.entries[parentEntryIndex];
+      new_entry[newEntryIndex] = parentEntry == null ? new Entry() : parentEntry.copy();
+      // new_entry += num_entries_to_copy;
+      ++newEntryIndex;
+      ++parentEntryIndex;
+    }
+
+    // Resize the resulting array to fit.
+    // final int actual_count = new_entry - new_bag.entries;
+    final int actual_count = newEntryIndex;
+    if (actual_count != max_count) {
+      // new_bag.reset(reinterpret_cast<ResolvedBag*>(realloc(
+      //     new_bag.release(), sizeof(ResolvedBag) + (actual_count * sizeof(ResolvedBag::Entry)))));
+      Entry[] resizedEntries = new Entry[actual_count];
+      System.arraycopy(new_bag.entries, 0, resizedEntries, 0, actual_count);
+      new_bag.entries = resizedEntries;
+    }
+
+    // Combine flags from the parent and our own bag.
+    new_bag.type_spec_flags = entry.type_flags | parent_bag.type_spec_flags;
+    new_bag.entry_count = actual_count;
+    ResolvedBag result2 = new_bag;
+    // cached_bags_[resid] = std::move(new_bag);
+    cached_bags_.put(resid, new_bag);
+    return result2;
+  }
+
+  String GetResourceName(int resid) {
+    ResourceName out_name = new ResourceName();
+    if (GetResourceName(resid, out_name)) {
+      return out_name.package_ + ":" + out_name.type + "@" + out_name.entry;
+    } else {
+      return null;
+    }
+  }
+
+  @SuppressWarnings("DoNotCallSuggester")
+  static boolean Utf8ToUtf16(final String str, Ref<String> out) {
+    throw new UnsupportedOperationException();
+    // ssize_t len =
+    //     utf8_to_utf16_length(reinterpret_cast<final byte*>(str.data()), str.size(), false);
+    // if (len < 0) {
+    //   return false;
+    // }
+    // out.resize(static_cast<int>(len));
+    // utf8_to_utf16(reinterpret_cast<final byte*>(str.data()), str.size(), &*out.begin(),
+    //               static_cast<int>(len + 1));
+    // return true;
+  }
+
+  // Finds the resource ID assigned to `resource_name`.
+  // `resource_name` must be of the form '[package:][type/]entry'.
+  // If no package is specified in `resource_name`, then `fallback_package` is used as the package.
+  // If no type is specified in `resource_name`, then `fallback_type` is used as the type.
+  // Returns 0x0 if no resource by that name was found.
+//  int GetResourceId(final String resource_name, final String fallback_type = {},
+//    final String fallback_package = {});
+  @SuppressWarnings("NewApi")
+  public int GetResourceId(final String resource_name,
+      final String fallback_type,
+      final String fallback_package) {
+    final Ref<String> package_name = new Ref<>(null),
+        type = new Ref<>(null),
+        entry = new Ref<>(null);
+    if (!ExtractResourceName(resource_name, package_name, type, entry)) {
+      return 0;
+    }
+
+    if (entry.get().isEmpty()) {
+      return 0;
+    }
+
+    if (package_name.get().isEmpty()) {
+      package_name.set(fallback_package);
+    }
+
+    if (type.get().isEmpty()) {
+      type.set(fallback_type);
+    }
+
+    String type16 = type.get();
+    // if (!Utf8ToUtf16(type, &type16)) {
+    //   return 0;
+    // }
+
+    String entry16 = entry.get();
+    // if (!Utf8ToUtf16(entry, &entry16)) {
+    //   return 0;
+    // }
+
+    final String kAttr16 = "attr";
+    final String kAttrPrivate16 = "^attr-private";
+
+    for (final PackageGroup package_group : package_groups_) {
+      for (final ConfiguredPackage package_impl : package_group.packages_) {
+        LoadedPackage package_= package_impl.loaded_package_;
+        if (!Objects.equals(package_name.get(), package_.GetPackageName())) {
+          // All packages in the same group are expected to have the same package name.
+          break;
+        }
+
+        int resid = package_.FindEntryByName(type16, entry16);
+        if (resid == 0 && Objects.equals(kAttr16, type16)) {
+          // Private attributes in libraries (such as the framework) are sometimes encoded
+          // under the type '^attr-private' in order to leave the ID space of public 'attr'
+          // free for future additions. Check '^attr-private' for the same name.
+          resid = package_.FindEntryByName(kAttrPrivate16, entry16);
+        }
+
+        if (resid != 0) {
+          return fix_package_id(resid, package_group.dynamic_ref_table.mAssignedPackageId);
+        }
+      }
+    }
+    return 0;
+  }
+
+  // Triggers the re-construction of lists of types that match the set configuration.
+  // This should always be called when mutating the AssetManager's configuration or ApkAssets set.
+  void RebuildFilterList() {
+    for (PackageGroup group : package_groups_) {
+      for (ConfiguredPackage impl : group.packages_) {
+        // // Destroy it.
+        // impl.filtered_configs_.~ByteBucketArray();
+        //
+        // // Re-create it.
+        // new (impl.filtered_configs_) ByteBucketArray<FilteredConfigGroup>();
+        impl.filtered_configs_ =
+            new ByteBucketArray<FilteredConfigGroup>(new FilteredConfigGroup()) {
+              @Override
+              FilteredConfigGroup newInstance() {
+                return new FilteredConfigGroup();
+              }
+            };
+
+        // Create the filters here.
+        impl.loaded_package_.ForEachTypeSpec((TypeSpec spec, byte type_index) -> {
+          FilteredConfigGroup configGroup = impl.filtered_configs_.editItemAt(type_index);
+          // const auto iter_end = spec->types + spec->type_count;
+          //   for (auto iter = spec->types; iter != iter_end; ++iter) {
+          for (ResTable_type iter : spec.types) {
+            ResTable_config this_config = ResTable_config.fromDtoH(iter.config);
+            if (this_config.match(configuration_)) {
+              configGroup.configurations.add(this_config);
+              configGroup.types.add(iter);
+            }
+          }
+        });
+      }
+    }
+  }
+
+  // Purge all resources that are cached and vary by the configuration axis denoted by the
+  // bitmask `diff`.
+//  void InvalidateCaches(int diff);
+  private void InvalidateCaches(int diff) {
+    if (diff == 0xffffffff) {
+      // Everything must go.
+      cached_bags_.clear();
+      return;
+    }
+
+    // Be more conservative with what gets purged. Only if the bag has other possible
+    // variations with respect to what changed (diff) should we remove it.
+    // for (auto iter = cached_bags_.cbegin(); iter != cached_bags_.cend();) {
+    for (Integer key : new ArrayList<>(cached_bags_.keySet())) {
+      // if (diff & iter.second.type_spec_flags) {
+      if (isTruthy(diff & cached_bags_.get(key).type_spec_flags)) {
+        // iter = cached_bags_.erase(iter);
+        cached_bags_.remove(key);
+      }
+    }
+  }
+
+  // Creates a new Theme from this AssetManager.
+//  std.unique_ptr<Theme> NewTheme();
+  public Theme NewTheme() {
+    return new Theme(this);
+  }
+
+  public static class Theme {
+    //  friend class AssetManager2;
+//
+// public:
+//
+//
+//
+//  final AssetManager2* GetAssetManager() { return asset_manager_; }
+//
+    public CppAssetManager2 GetAssetManager() { return asset_manager_; }
+    //
+//  // Returns a bit mask of configuration changes that will impact this
+//  // theme (and thus require completely reloading it).
+    public int GetChangingConfigurations() { return type_spec_flags_; }
+
+// private:
+//  private DISALLOW_COPY_AND_ASSIGN(Theme);
+
+    // Called by AssetManager2.
+//  private explicit Theme(AssetManager2* asset_manager) : asset_manager_(asset_manager) {}
+
+    private final CppAssetManager2 asset_manager_;
+    private int type_spec_flags_ = 0;
+    //  std.array<std.unique_ptr<Package>, kPackageCount> packages_;
+    private ThemePackage[] packages_ = new ThemePackage[kPackageCount];
+
+    public Theme(CppAssetManager2 cppAssetManager2) {
+      asset_manager_ = cppAssetManager2;
+    }
+
+    private static class ThemeEntry {
+      static final int SIZEOF = 8 + Res_value.SIZEOF;
+
+      ApkAssetsCookie cookie;
+      int type_spec_flags;
+      Res_value value;
+    }
+
+    private static class ThemeType {
+      static final int SIZEOF_WITHOUT_ENTRIES = 8;
+
+      int entry_count;
+      ThemeEntry entries[];
+    }
+
+    //  static final int kPackageCount = std.numeric_limits<byte>.max() + 1;
+    static final int kPackageCount = 256;
+    //  static final int kTypeCount = std.numeric_limits<byte>.max() + 1;
+    static final int kTypeCount = 256;
+
+    private static class ThemePackage {
+      // Each element of Type will be a dynamically sized object
+      // allocated to have the entries stored contiguously with the Type.
+      // std::array<util::unique_cptr<ThemeType>, kTypeCount> types;
+      ThemeType[] types = new ThemeType[kTypeCount];
+    }
+
+    // Applies the style identified by `resid` to this theme. This can be called
+    // multiple times with different styles. By default, any theme attributes that
+    // are already defined before this call are not overridden. If `force` is set
+    // to true, this behavior is changed and all theme attributes from the style at
+    // `resid` are applied.
+    // Returns false if the style failed to apply.
+//  boolean ApplyStyle(int resid, boolean force = false);
+    public boolean ApplyStyle(int resid, boolean force) {
+      // ATRACE_NAME("Theme::ApplyStyle");
+
+      final ResolvedBag bag = asset_manager_.GetBag(resid);
+      if (bag == null) {
+        return false;
+      }
+
+      // Merge the flags from this style.
+      type_spec_flags_ |= bag.type_spec_flags;
+
+      int last_type_idx = -1;
+      int last_package_idx = -1;
+      ThemePackage last_package = null;
+      ThemeType last_type = null;
+
+      // Iterate backwards, because each bag is sorted in ascending key ID order, meaning we will only
+      // need to perform one resize per type.
+      //     using reverse_bag_iterator = std::reverse_iterator<const ResolvedBag::Entry*>;
+      // const auto bag_iter_end = reverse_bag_iterator(begin(bag));
+      //     for (auto bag_iter = reverse_bag_iterator(end(bag)); bag_iter != bag_iter_end; ++bag_iter) {
+      List<Entry> bagEntries = new ArrayList<>(Arrays.asList(bag.entries));
+      Collections.reverse(bagEntries);
+      for (ResolvedBag.Entry bag_iter : bagEntries) {
+        //   final int attr_resid = bag_iter.key;
+        final int attr_resid = bag_iter == null ? 0 : bag_iter.key;
+
+        // If the resource ID passed in is not a style, the key can be some other identifier that is not
+        // a resource ID. We should fail fast instead of operating with strange resource IDs.
+        if (!is_valid_resid(attr_resid)) {
+          return false;
+        }
+
+        // We don't use the 0-based index for the type so that we can avoid doing ID validation
+        // upon lookup. Instead, we keep space for the type ID 0 in our data structures. Since
+        // the construction of this type is guarded with a resource ID check, it will never be
+        // populated, and querying type ID 0 will always fail.
+        int package_idx = get_package_id(attr_resid);
+        int type_idx = get_type_id(attr_resid);
+        int entry_idx = get_entry_id(attr_resid);
+
+        if (last_package_idx != package_idx) {
+          ThemePackage package_ = packages_[package_idx];
+          if (package_ == null) {
+            package_ = packages_[package_idx] = new ThemePackage();
+          }
+          last_package_idx = package_idx;
+          last_package = package_;
+          last_type_idx = -1;
+        }
+
+        if (last_type_idx != type_idx) {
+          ThemeType type = last_package.types[type_idx];
+          if (type == null) {
+            // Allocate enough memory to contain this entry_idx. Since we're iterating in reverse over
+            // a sorted list of attributes, this shouldn't be resized again during this method call.
+            // type.reset(reinterpret_cast<ThemeType*>(
+            //     calloc(sizeof(ThemeType) + (entry_idx + 1) * sizeof(ThemeEntry), 1)));
+            type = last_package.types[type_idx] = new ThemeType();
+            type.entries = new ThemeEntry[entry_idx + 1];
+            type.entry_count = entry_idx + 1;
+          } else if (entry_idx >= type.entry_count) {
+            // Reallocate the memory to contain this entry_idx. Since we're iterating in reverse over
+            // a sorted list of attributes, this shouldn't be resized again during this method call.
+            int new_count = entry_idx + 1;
+            // type.reset(reinterpret_cast<ThemeType*>(
+            //     realloc(type.release(), sizeof(ThemeType) + (new_count * sizeof(ThemeEntry)))));
+            ThemeEntry[] oldEntries = type.entries;
+            type.entries = new ThemeEntry[new_count];
+            System.arraycopy(oldEntries, 0, type.entries, 0, oldEntries.length);
+
+            // Clear out the newly allocated space (which isn't zeroed).
+            // memset(type.entries + type.entry_count, 0,
+            //     (new_count - type.entry_count) * sizeof(ThemeEntry));
+            type.entry_count = new_count;
+          }
+          last_type_idx = type_idx;
+          last_type = type;
+        }
+
+        ThemeEntry entry = last_type.entries[entry_idx];
+        if (entry == null) {
+          entry = last_type.entries[entry_idx] = new ThemeEntry();
+          entry.value = new Res_value();
+        }
+        if (force || (entry.value.dataType == Res_value.TYPE_NULL &&
+            entry.value.data != Res_value.DATA_NULL_EMPTY)) {
+          entry.cookie = bag_iter.cookie;
+          entry.type_spec_flags |= bag.type_spec_flags;
+          entry.value = bag_iter.value;
+        }
+      }
+      return true;
+    }
+
+    // Retrieve a value in the theme. If the theme defines this value, returns an asset cookie
+    // indicating which ApkAssets it came from and populates `out_value` with the value.
+    // `out_flags` is populated with a bitmask of the configuration axis with which the resource
+    // varies.
+    //
+    // If the attribute is not found, returns kInvalidCookie.
+    //
+    // NOTE: This function does not do reference traversal. If you want to follow references to other
+    // resources to get the "real" value to use, you need to call ResolveReference() after this
+    // function.
+//  ApkAssetsCookie GetAttribute(int resid, Res_value* out_value,
+//                               int* out_flags) const;
+    public ApkAssetsCookie GetAttribute(int resid, Ref<Res_value> out_value,
+        final Ref<Integer> out_flags) {
+      int cnt = 20;
+
+      int type_spec_flags = 0;
+
+      do {
+        int package_idx = get_package_id(resid);
+        ThemePackage package_ = packages_[package_idx];
+        if (package_ != null) {
+          // The themes are constructed with a 1-based type ID, so no need to decrement here.
+          int type_idx = get_type_id(resid);
+          ThemeType type = package_.types[type_idx];
+          if (type != null) {
+            int entry_idx = get_entry_id(resid);
+            if (entry_idx < type.entry_count) {
+              ThemeEntry entry = type.entries[entry_idx];
+              if (entry == null) {
+                entry = new ThemeEntry();
+                entry.value = new Res_value();
+              }
+              type_spec_flags |= entry.type_spec_flags;
+
+              if (entry.value.dataType == Res_value.TYPE_ATTRIBUTE) {
+                if (cnt > 0) {
+                  cnt--;
+                  resid = entry.value.data;
+                  continue;
+                }
+                return K_INVALID_COOKIE;
+              }
+
+              // @null is different than @empty.
+              if (entry.value.dataType == Res_value.TYPE_NULL &&
+                  entry.value.data != Res_value.DATA_NULL_EMPTY) {
+                return K_INVALID_COOKIE;
+              }
+
+              out_value.set(entry.value);
+              out_flags.set(type_spec_flags);
+              return entry.cookie;
+            }
+          }
+        }
+        break;
+      } while (true);
+      return K_INVALID_COOKIE;
+    }
+
+    // This is like ResolveReference(), but also takes
+    // care of resolving attribute references to the theme.
+//  ApkAssetsCookie ResolveAttributeReference(ApkAssetsCookie cookie, Res_value* in_out_value,
+//                                            ResTable_config in_out_selected_config = null,
+//                                            int* in_out_type_spec_flags = null,
+//                                            int* out_last_ref = null);
+    ApkAssetsCookie ResolveAttributeReference(ApkAssetsCookie cookie, Ref<Res_value> in_out_value,
+        final Ref<ResTable_config> in_out_selected_config,
+        final Ref<Integer> in_out_type_spec_flags,
+        final Ref<Integer> out_last_ref) {
+      if (in_out_value.get().dataType == Res_value.TYPE_ATTRIBUTE) {
+        final Ref<Integer> new_flags = new Ref<>(0);
+        cookie = GetAttribute(in_out_value.get().data, in_out_value, new_flags);
+        if (cookie.intValue() == kInvalidCookie) {
+          return K_INVALID_COOKIE;
+        }
+
+        if (in_out_type_spec_flags != null) {
+//          *in_out_type_spec_flags |= new_flags;
+          in_out_type_spec_flags.set(in_out_type_spec_flags.get() | new_flags.get());
+        }
+      }
+      return asset_manager_.ResolveReference(cookie, in_out_value, in_out_selected_config,
+          in_out_type_spec_flags, out_last_ref);
+    }
+
+    //  void Clear();
+    public void Clear() {
+      type_spec_flags_ = 0;
+      for (int i = 0; i < packages_.length; i++) {
+//        package_.reset();
+        packages_[i] = null;
+      }
+    }
+
+    // Sets this Theme to be a copy of `o` if `o` has the same AssetManager as this Theme.
+    // Returns false if the AssetManagers of the Themes were not compatible.
+//  boolean SetTo(final Theme& o);
+    public boolean SetTo(final Theme o) {
+      if (this == o) {
+        return true;
+      }
+
+      type_spec_flags_ = o.type_spec_flags_;
+
+      boolean copy_only_system = asset_manager_ != o.asset_manager_;
+
+      // for (int p = 0; p < packages_.size(); p++) {
+      //   final Package package_ = o.packages_[p].get();
+      for (int p = 0; p < packages_.length; p++) {
+        ThemePackage package_ = o.packages_[p];
+        if (package_ == null || (copy_only_system && p != 0x01)) {
+          // The other theme doesn't have this package, clear ours.
+          packages_[p] = new ThemePackage();
+          continue;
+        }
+
+        if (packages_[p] == null) {
+          // The other theme has this package, but we don't. Make one.
+          packages_[p] = new ThemePackage();
+        }
+
+        // for (int t = 0; t < package_.types.size(); t++) {
+        // final Type type = package_.types[t].get();
+        for (int t = 0; t < package_.types.length; t++) {
+          ThemeType type = package_.types[t];
+          if (type == null) {
+            // The other theme doesn't have this type, clear ours.
+            // packages_[p].types[t].reset();
+            continue;
+          }
+
+          // Create a new type and update it to theirs.
+          // const size_t type_alloc_size = sizeof(ThemeType) + (type->entry_count * sizeof(ThemeEntry));
+          // void* copied_data = malloc(type_alloc_size);
+          ThemeType copied_data = new ThemeType();
+          copied_data.entry_count = type.entry_count;
+          // memcpy(copied_data, type, type_alloc_size);
+          ThemeEntry[] newEntries = copied_data.entries = new ThemeEntry[type.entry_count];
+          for (int i = 0; i < type.entry_count; i++) {
+            ThemeEntry entry = type.entries[i];
+            ThemeEntry newEntry = new ThemeEntry();
+            if (entry != null) {
+              newEntry.cookie = entry.cookie;
+              newEntry.type_spec_flags = entry.type_spec_flags;
+              newEntry.value = entry.value.copy();
+            } else {
+              newEntry.value = Res_value.NULL_VALUE;
+            }
+            newEntries[i] = newEntry;
+          }
+
+          packages_[p].types[t] = copied_data;
+          // packages_[p].types[t].reset(reinterpret_cast<Type*>(copied_data));
+        }
+      }
+      return true;
+    }
+
+//
+  }  // namespace android
+
+  public List<AssetPath> getAssetPaths() {
+    ArrayList<AssetPath> assetPaths = new ArrayList<>(apk_assets_.size());
+    for (CppApkAssets apkAssets : apk_assets_) {
+      Path path = Fs.fromUrl(apkAssets.GetPath());
+      assetPaths.add(new AssetPath(path, apkAssets.GetLoadedArsc().IsSystem()));
+    }
+    return assetPaths;
+  }
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/DataType.java b/resources/src/main/java/org/robolectric/res/android/DataType.java
new file mode 100644
index 0000000..3093892
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/DataType.java
@@ -0,0 +1,75 @@
+package org.robolectric.res.android;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.UnsignedBytes;
+import java.util.Map;
+
+/** Resource type codes. */
+public enum DataType {
+  /** {@code data} is either 0 (undefined) or 1 (empty). */
+  NULL(0x00),
+  /** {@code data} holds a {@link ResourceTableChunk} entry reference. */
+  REFERENCE(0x01),
+  /** {@code data} holds an attribute resource identifier. */
+  ATTRIBUTE(0x02),
+  /** {@code data} holds an index into the containing resource table's string pool. */
+  STRING(0x03),
+  /** {@code data} holds a single-precision floating point number. */
+  FLOAT(0x04),
+  /** {@code data} holds a complex number encoding a dimension value, such as "100in". */
+  DIMENSION(0x05),
+  /** {@code data} holds a complex number encoding a fraction of a container. */
+  FRACTION(0x06),
+  /** {@code data} holds a dynamic {@link ResourceTableChunk} entry reference. */
+  DYNAMIC_REFERENCE(0x07),
+  /** {@code data} holds an attribute resource identifier, which needs to be resolved
+    * before it can be used like a TYPE_ATTRIBUTE.
+    */
+  DYNAMIC_ATTRIBUTE(0x08),
+  /** {@code data} is a raw integer value of the form n..n. */
+  INT_DEC(0x10),
+  /** {@code data} is a raw integer value of the form 0xn..n. */
+  INT_HEX(0x11),
+  /** {@code data} is either 0 (false) or 1 (true). */
+  INT_BOOLEAN(0x12),
+  /** {@code data} is a raw integer value of the form #aarrggbb. */
+  INT_COLOR_ARGB8(0x1c),
+  /** {@code data} is a raw integer value of the form #rrggbb. */
+  INT_COLOR_RGB8(0x1d),
+  /** {@code data} is a raw integer value of the form #argb. */
+  INT_COLOR_ARGB4(0x1e),
+  /** {@code data} is a raw integer value of the form #rgb. */
+  INT_COLOR_RGB4(0x1f);
+
+  public static final int TYPE_FIRST_INT = INT_DEC.code();
+  public static final int TYPE_LAST_INT = INT_COLOR_RGB4.code();
+
+  private final byte code;
+
+  private static final Map<Byte, DataType> FROM_BYTE;
+
+  static {
+    ImmutableMap.Builder<Byte, DataType> builder = ImmutableMap.builder();
+    for (DataType type : values()) {
+      builder.put(type.code(), type);
+    }
+    FROM_BYTE = builder.build();
+  }
+
+  DataType(int code) {
+    this.code = UnsignedBytes.checkedCast(code);
+  }
+
+  public byte code() {
+    return code;
+  }
+
+  public static DataType fromCode(int code) {
+    return fromCode((byte) code);
+  }
+
+  public static DataType fromCode(byte code) {
+    return Preconditions.checkNotNull(FROM_BYTE.get(code), "Unknown resource type: %s", code);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java b/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java
new file mode 100644
index 0000000..4897ec6
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java
@@ -0,0 +1,182 @@
+package org.robolectric.res.android;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h
+
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Errors.UNKNOWN_ERROR;
+import static org.robolectric.res.android.ResTable.APP_PACKAGE_ID;
+import static org.robolectric.res.android.ResTable.Res_GETPACKAGE;
+import static org.robolectric.res.android.ResTable.SYS_PACKAGE_ID;
+import static org.robolectric.res.android.Util.ALOGW;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+/**
+ * Holds the shared library ID table. Shared libraries are assigned package IDs at
+ * build time, but they may be loaded in a different order, so we need to maintain
+ * a mapping of build-time package ID to run-time assigned package ID.
+ *
+ * Dynamic references are not currently supported in overlays. Only the base package
+ * may have dynamic references.
+ */
+public class DynamicRefTable
+{
+  DynamicRefTable(byte packageId, boolean appAsLib) {
+    this.mAssignedPackageId = packageId;
+    this.mAppAsLib = appAsLib;
+
+    mLookupTable[APP_PACKAGE_ID] = APP_PACKAGE_ID;
+    mLookupTable[SYS_PACKAGE_ID] = SYS_PACKAGE_ID;
+  }
+
+//  // Loads an unmapped reference table from the package.
+//  Errors load(final ResTable_lib_header header) {
+//    return null;
+//  }
+
+  // Adds mappings from the other DynamicRefTable
+  int addMappings(final DynamicRefTable other) {
+    if (mAssignedPackageId != other.mAssignedPackageId) {
+      return UNKNOWN_ERROR;
+    }
+
+//    final int entryCount = other.mEntries.size();
+//    for (size_t i = 0; i < entryCount; i++) {
+//      ssize_t index = mEntries.indexOfKey(other.mEntries.keyAt(i));
+//      if (index < 0) {
+//        mEntries.add(other.mEntries.keyAt(i), other.mEntries[i]);
+//      } else {
+//        if (other.mEntries[i] != mEntries[index]) {
+//          return UNKNOWN_ERROR;
+//        }
+//      }
+//    }
+    for (Entry<String, Byte> otherEntry : other.mEntries.entrySet()) {
+      String key = otherEntry.getKey();
+      Byte curValue = mEntries.get(key);
+      if (curValue == null) {
+        mEntries.put(key, otherEntry.getValue());
+      } else {
+        if (!Objects.equals(otherEntry.getValue(), curValue)) {
+          return UNKNOWN_ERROR;
+        }
+      }
+    }
+
+    // Merge the lookup table. No entry can conflict
+    // (value of 0 means not set).
+    for (int i = 0; i < 256; i++) {
+      if (mLookupTable[i] != other.mLookupTable[i]) {
+        if (mLookupTable[i] == 0) {
+          mLookupTable[i] = other.mLookupTable[i];
+        } else if (other.mLookupTable[i] != 0) {
+          return UNKNOWN_ERROR;
+        }
+      }
+    }
+    return NO_ERROR;
+  }
+
+  // Creates a mapping from build-time package ID to run-time package ID for
+  // the given package.
+  int addMapping(final String packageName, byte packageId) {
+    Byte index = mEntries.get(packageName);
+    if (index == null) {
+      return UNKNOWN_ERROR;
+    }
+    mLookupTable[index] = packageId;
+    return NO_ERROR;
+  }
+
+//  // Performs the actual conversion of build-time resource ID to run-time
+//  // resource ID.
+  int lookupResourceId(Ref<Integer> resId) {
+    int res = resId.get();
+    int packageId = Res_GETPACKAGE(res) + 1;
+
+    if (packageId == APP_PACKAGE_ID && !mAppAsLib) {
+      // No lookup needs to be done, app package IDs are absolute.
+      return NO_ERROR;
+    }
+
+    if (packageId == 0 || (packageId == APP_PACKAGE_ID && mAppAsLib)) {
+      // The package ID is 0x00. That means that a shared library is accessing
+      // its own local resource.
+      // Or if app resource is loaded as shared library, the resource which has
+      // app package Id is local resources.
+      // so we fix up those resources with the calling package ID.
+      resId.set((0xFFFFFF & resId.get()) | (((int) mAssignedPackageId) << 24));
+      return NO_ERROR;
+    }
+
+    // Do a proper lookup.
+    int translatedId = mLookupTable[packageId];
+    if (translatedId == 0) {
+      ALOGW("DynamicRefTable(0x%02x): No mapping for build-time package ID 0x%02x.",
+          mAssignedPackageId, packageId);
+      for (int i = 0; i < 256; i++) {
+        if (mLookupTable[i] != 0) {
+          ALOGW("e[0x%02x] . 0x%02x", i, mLookupTable[i]);
+        }
+      }
+      return UNKNOWN_ERROR;
+    }
+
+    resId.set((res & 0x00ffffff) | (((int) translatedId) << 24));
+    return NO_ERROR;
+  }
+//
+  int lookupResourceValue(Ref<Res_value> value) {
+    byte resolvedType = DataType.REFERENCE.code();
+    Res_value inValue = value.get();
+    switch (DataType.fromCode(inValue.dataType)) {
+      case ATTRIBUTE:
+        resolvedType = DataType.ATTRIBUTE.code();
+        // fallthrough
+      case REFERENCE:
+        if (!mAppAsLib) {
+          return NO_ERROR;
+        }
+
+        // If the package is loaded as shared library, the resource reference
+        // also need to be fixed.
+        break;
+      case DYNAMIC_ATTRIBUTE:
+        resolvedType = DataType.ATTRIBUTE.code();
+        // fallthrough
+      case DYNAMIC_REFERENCE:
+        break;
+      default:
+        return NO_ERROR;
+    }
+
+    final Ref<Integer> resIdRef = new Ref<>(inValue.data);
+    int err = lookupResourceId(resIdRef);
+    value.set(inValue.withData(resIdRef.get()));
+    if (err != NO_ERROR) {
+      return err;
+    }
+
+    value.set(new Res_value(resolvedType, resIdRef.get()));
+    return NO_ERROR;
+ }
+
+  public Map<String, Byte> entries() {
+    return mEntries;
+  }
+
+  //
+//  final KeyedVector<String16, uint8_t>& entries() final {
+//  return mEntries;
+//}
+//
+//  private:
+    final byte                   mAssignedPackageId;
+  final byte[]                         mLookupTable = new byte[256];
+  final Map<String, Byte> mEntries = new HashMap<>();
+  boolean                            mAppAsLib;
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Errors.java b/resources/src/main/java/org/robolectric/res/android/Errors.java
new file mode 100644
index 0000000..0cab2b7
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Errors.java
@@ -0,0 +1,21 @@
+package org.robolectric.res.android;
+
+// transliterated from https://android.googlesource.com/platform/system/core/+/android-9.0.0_r12/include/utils/Errors.h
+
+public class Errors {
+
+  public static final int NO_ERROR = 0;
+
+  // in the Cpp code, 'int' return values can either indicate an error, or a valid value
+  // success can be interpreted as return value >= 0. So make all error codes negative values
+  // following the convention of  assigning UNKNOWN_ERROR to INT32_MIN value as a base and
+  // incrementing from there
+
+  public static final int UNKNOWN_ERROR = Integer.MIN_VALUE;
+  public static final int BAD_INDEX = UNKNOWN_ERROR + 1;
+  public static final int BAD_TYPE = UNKNOWN_ERROR + 2;
+  public static final int BAD_VALUE = UNKNOWN_ERROR + 3;
+  public static final int NO_MEMORY = UNKNOWN_ERROR + 4;
+  public static final int NAME_NOT_FOUND = UNKNOWN_ERROR + 5;
+  public static final int NO_INIT = UNKNOWN_ERROR + 6;
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/FileMap.java b/resources/src/main/java/org/robolectric/res/android/FileMap.java
new file mode 100644
index 0000000..f127268
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/FileMap.java
@@ -0,0 +1,459 @@
+package org.robolectric.res.android;
+
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.res.android.Asset.toIntExact;
+import static org.robolectric.res.android.Util.ALOGV;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Shorts;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+import java.nio.charset.Charset;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+
+public class FileMap {
+
+  /** ZIP archive central directory end header signature. */
+  private static final int ENDSIG = 0x6054b50;
+
+  private static final int ENDHDR = 22;
+  /** ZIP64 archive central directory end header signature. */
+  private static final int ENDSIG64 = 0x6064b50;
+  /** the maximum size of the end of central directory section in bytes */
+  private static final int MAXIMUM_ZIP_EOCD_SIZE = 64 * 1024 + ENDHDR;
+
+  private ZipFile zipFile;
+  private ZipEntry zipEntry;
+
+  @SuppressWarnings("unused")
+  private boolean readOnly;
+
+  private int fd;
+  private boolean isFromZip;
+
+  // Create a new mapping on an open file.
+//
+// Closing the file descriptor does not unmap the pages, so we don't
+// claim ownership of the fd.
+//
+// Returns "false" on failure.
+  boolean create(String origFileName, int fd, long offset, int length,
+      boolean readOnly)
+  {
+    this.mFileName = origFileName;
+    this.fd = fd;
+    this.mDataOffset = offset;
+    this.readOnly = readOnly;
+    return true;
+  }
+
+// #if defined(__MINGW32__)
+//     int     adjust;
+//     off64_t adjOffset;
+//     size_t  adjLength;
+//
+//     if (mPageSize == -1) {
+//       SYSTEM_INFO  si;
+//
+//       GetSystemInfo( &si );
+//       mPageSize = si.dwAllocationGranularity;
+//     }
+//
+//     DWORD  protect = readOnly ? PAGE_READONLY : PAGE_READWRITE;
+//
+//     mFileHandle  = (HANDLE) _get_osfhandle(fd);
+//     mFileMapping = CreateFileMapping( mFileHandle, NULL, protect, 0, 0, NULL);
+//     if (mFileMapping == NULL) {
+//       ALOGE("CreateFileMapping(%s, %" PRIx32 ") failed with error %" PRId32 "\n",
+//           mFileHandle, protect, GetLastError() );
+//       return false;
+//     }
+//
+//     adjust    = offset % mPageSize;
+//     adjOffset = offset - adjust;
+//     adjLength = length + adjust;
+//
+//     mBasePtr = MapViewOfFile( mFileMapping,
+//         readOnly ? FILE_MAP_READ : FILE_MAP_ALL_ACCESS,
+//         0,
+//         (DWORD)(adjOffset),
+//         adjLength );
+//     if (mBasePtr == NULL) {
+//       ALOGE("MapViewOfFile(%" PRId64 ", 0x%x) failed with error %" PRId32 "\n",
+//           adjOffset, adjLength, GetLastError() );
+//       CloseHandle(mFileMapping);
+//       mFileMapping = INVALID_HANDLE_VALUE;
+//       return false;
+//     }
+// #else // !defined(__MINGW32__)
+//     int     prot, flags, adjust;
+//     off64_t adjOffset;
+//     size_t  adjLength;
+//
+//     void* ptr;
+//
+//     assert(fd >= 0);
+//     assert(offset >= 0);
+//     assert(length > 0);
+//
+//     // init on first use
+//     if (mPageSize == -1) {
+//       mPageSize = sysconf(_SC_PAGESIZE);
+//       if (mPageSize == -1) {
+//         ALOGE("could not get _SC_PAGESIZE\n");
+//         return false;
+//       }
+//     }
+//
+//     adjust = offset % mPageSize;
+//     adjOffset = offset - adjust;
+//     adjLength = length + adjust;
+//
+//     flags = MAP_SHARED;
+//     prot = PROT_READ;
+//     if (!readOnly)
+//       prot |= PROT_WRITE;
+//
+//     ptr = mmap(NULL, adjLength, prot, flags, fd, adjOffset);
+//     if (ptr == MAP_FAILED) {
+//       ALOGE("mmap(%lld,0x%x) failed: %s\n",
+//           (long long)adjOffset, adjLength, strerror(errno));
+//       return false;
+//     }
+//     mBasePtr = ptr;
+// #endif // !defined(__MINGW32__)
+//
+//       mFileName = origFileName != NULL ? strdup(origFileName) : NULL;
+//     mBaseLength = adjLength;
+//     mDataOffset = offset;
+//     mDataPtr = (char*) mBasePtr + adjust;
+//     mDataLength = length;
+//
+//     assert(mBasePtr != NULL);
+//
+//     ALOGV("MAP: base %s/0x%x data %s/0x%x\n",
+//         mBasePtr, mBaseLength, mDataPtr, mDataLength);
+//
+//     return true;
+//   }
+
+  boolean createFromZip(
+      String origFileName,
+      ZipFile zipFile,
+      ZipEntry entry,
+      long offset,
+      int length,
+      boolean readOnly) {
+    isFromZip = true;
+    this.zipFile = zipFile;
+    this.zipEntry = entry;
+
+    assert(fd >= 0);
+    assert(offset >= 0);
+    // assert(length > 0);
+
+    // init on first use
+//    if (mPageSize == -1) {
+//      mPageSize = sysconf(_SC_PAGESIZE);
+//      if (mPageSize == -1) {
+//        ALOGE("could not get _SC_PAGESIZE\n");
+//        return false;
+//      }
+//    }
+
+    // adjust = Math.toIntExact(offset % mPageSize);
+    // adjOffset = offset - adjust;
+    // adjLength = length + adjust;
+
+    //flags = MAP_SHARED;
+    //prot = PROT_READ;
+    //if (!readOnly)
+    //  prot |= PROT_WRITE;
+
+    // ptr = mmap(null, adjLength, prot, flags, fd, adjOffset);
+    // if (ptr == MAP_FAILED) {
+    //   ALOGE("mmap(%lld,0x%x) failed: %s\n",
+    //       (long long)adjOffset, adjLength, strerror(errno));
+    //   return false;
+    // }
+    // mBasePtr = ptr;
+
+    mFileName = origFileName != null ? origFileName : null;
+    //mBaseLength = adjLength;
+    mDataOffset = offset;
+    //mDataPtr = mBasePtr + adjust;
+    mDataLength = toIntExact(entry.getSize());
+
+    //assert(mBasePtr != 0);
+
+    ALOGV("MAP: base %s/0x%x data %s/0x%x\n",
+        mBasePtr, mBaseLength, mDataPtr, mDataLength);
+
+    return true;
+  }
+
+  static ImmutableMap<String, Long> guessDataOffsets(File zipFile, int length) {
+    ImmutableMap.Builder<String, Long> result = ImmutableMap.builder();
+
+    // Parse the zip file entry offsets from the central directory section.
+    // See https://en.wikipedia.org/wiki/Zip_(file_format)
+
+    try (RandomAccessFile randomAccessFile = new RandomAccessFile(zipFile, "r")) {
+
+      // First read the 'end of central directory record' in order to find the start of the central
+      // directory
+      // The end of central directory record (EOCD) is max comment length (64K) + 22 bytes
+      int endOfCdSize = Math.min(MAXIMUM_ZIP_EOCD_SIZE, length);
+      int endofCdOffset = length - endOfCdSize;
+      randomAccessFile.seek(endofCdOffset);
+      byte[] buffer = new byte[endOfCdSize];
+      randomAccessFile.readFully(buffer);
+
+      int centralDirOffset = findCentralDir(buffer);
+
+      int offset = centralDirOffset - endofCdOffset;
+      if (offset < 0) {
+        // read the entire central directory record into memory
+        // for the framework jars this max of 5MB for Q
+        // TODO: consider using a smaller buffer size and re-reading as necessary
+        offset = 0;
+        randomAccessFile.seek(centralDirOffset);
+        final int cdSize = length - centralDirOffset;
+        buffer = new byte[cdSize];
+        randomAccessFile.readFully(buffer);
+      } else {
+        // the central directory is already in the buffer, no need to reread
+      }
+
+      // now read the entries
+      while (true) {
+        // Instead of trusting numRecords, read until we find the
+        // end-of-central-directory signature.  numRecords may wrap
+        // around with >64K entries.
+        int sig = readInt(buffer, offset);
+        if (sig == ENDSIG || sig == ENDSIG64) {
+          break;
+        }
+
+        int bitFlag = readShort(buffer, offset + 8);
+        int fileNameLength = readShort(buffer, offset + 28);
+        int extraLength = readShort(buffer, offset + 30);
+        int fieldCommentLength = readShort(buffer, offset + 32);
+        int relativeOffsetOfLocalFileHeader = readInt(buffer, offset + 42);
+
+        byte[] nameBytes = copyBytes(buffer, offset + 46, fileNameLength);
+        Charset encoding = getEncoding(bitFlag);
+        String fileName = new String(nameBytes, encoding);
+        byte[] localHeaderBuffer = new byte[30];
+        randomAccessFile.seek(relativeOffsetOfLocalFileHeader);
+        randomAccessFile.readFully(localHeaderBuffer);
+        // There are two extra field lengths stored in the zip - one in the central directory,
+        // one in the local header. And we should use one in local header to calculate the
+        // correct file content offset, because they are different some times.
+        int localHeaderExtraLength = readShort(localHeaderBuffer, 28);
+        int fileOffset =
+            relativeOffsetOfLocalFileHeader + 30 + fileNameLength + localHeaderExtraLength;
+        result.put(fileName, (long) fileOffset);
+        offset += 46 + fileNameLength + extraLength + fieldCommentLength;
+      }
+
+      return result.build();
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static byte[] copyBytes(byte[] buffer, int offset, int length) {
+    byte[] result = new byte[length];
+    System.arraycopy(buffer, offset, result, 0, length);
+    return result;
+  }
+
+  private static Charset getEncoding(int bitFlags) {
+    // UTF-8 now supported in name and comments: check general bit flag, bit
+    // 11, to determine if UTF-8 is being used or ISO-8859-1 is being used.
+    return (0 != ((bitFlags >>> 11) & 1)) ? UTF_8 : ISO_8859_1;
+  }
+
+  private static int findCentralDir(byte[] buffer) throws IOException {
+    // find start of central directory by scanning backwards
+    int scanOffset = buffer.length - ENDHDR;
+
+    while (true) {
+      int val = readInt(buffer, scanOffset);
+      if (val == ENDSIG) {
+        break;
+      }
+
+      // Ok, keep backing up looking for the ZIP end central directory
+      // signature.
+      --scanOffset;
+      if (scanOffset < 0) {
+        throw new ZipException("ZIP directory not found, not a ZIP archive.");
+      }
+    }
+    // scanOffset is now start of end of central directory record
+    // the 'offset to central dir' data is at position 16 in the record
+    int offsetToCentralDir = readInt(buffer, scanOffset + 16);
+    return offsetToCentralDir;
+  }
+
+  /** Read a 32-bit integer from a bytebuffer in little-endian order. */
+  private static int readInt(byte[] buffer, int offset) {
+    return Ints.fromBytes(
+        buffer[offset + 3], buffer[offset + 2], buffer[offset + 1], buffer[offset]);
+  }
+
+  /** Read a 16-bit short from a bytebuffer in little-endian order. */
+  private static short readShort(byte[] buffer, int offset) {
+    return Shorts.fromBytes(buffer[offset + 1], buffer[offset]);
+  }
+
+  /*
+   * This represents a memory-mapped file.  It might be the entire file or
+   * only part of it.  This requires a little bookkeeping because the mapping
+   * needs to be aligned on page boundaries, and in some cases we'd like to
+   * have multiple references to the mapped area without creating additional
+   * maps.
+   *
+   * This always uses MAP_SHARED.
+   *
+   * TODO: we should be able to create a new FileMap that is a subset of
+   * an existing FileMap and shares the underlying mapped pages.  Requires
+   * completing the refcounting stuff and possibly introducing the notion
+   * of a FileMap hierarchy.
+   */
+  // class FileMap {
+  //   public:
+  //   FileMap(void);
+  //
+  //   FileMap(FileMap&& f);
+  //   FileMap& operator=(FileMap&& f);
+
+  /*
+   * Create a new mapping on an open file.
+   *
+   * Closing the file descriptor does not unmap the pages, so we don't
+   * claim ownership of the fd.
+   *
+   * Returns "false" on failure.
+   */
+  // boolean create(String origFileName, int fd,
+  //     long offset, int length, boolean readOnly) {
+  // }
+
+    // ~FileMap(void);
+
+    /*
+     * Return the name of the file this map came from, if known.
+     */
+    String getFileName() { return mFileName; }
+
+    /*
+     * Get a pointer to the piece of the file we requested.
+     */
+  synchronized byte[] getDataPtr() {
+    if (mDataPtr == null) {
+      mDataPtr = new byte[mDataLength];
+
+      InputStream is;
+      try {
+        if (isFromZip) {
+          is = zipFile.getInputStream(zipEntry);
+        } else {
+          is = new FileInputStream(getFileName());
+        }
+        try {
+          readFully(is, mDataPtr);
+        } finally {
+          is.close();
+        }
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+    return mDataPtr;
+  }
+
+  public static void readFully(InputStream is, byte[] bytes) throws IOException {
+    int size = bytes.length;
+    int remaining = size;
+    while (remaining > 0) {
+      int location = size - remaining;
+      int bytesRead = is.read(bytes, location, remaining);
+      if (bytesRead == -1) {
+        break;
+      }
+      remaining -= bytesRead;
+    }
+
+    if (remaining > 0) {
+      throw new RuntimeException("failed to read " + size + " (" + remaining + " bytes unread)");
+    }
+  }
+
+  /*
+   * Get the length we requested.
+   */
+  int getDataLength() { return mDataLength; }
+
+  /*
+   * Get the data offset used to create this map.
+   */
+  long getDataOffset() { return mDataOffset; }
+
+  public ZipEntry getZipEntry() {
+    return zipEntry;
+  }
+
+  //   /*
+//    * This maps directly to madvise() values, but allows us to avoid
+//    * including <sys/mman.h> everywhere.
+//    */
+//   enum MapAdvice {
+//     NORMAL, RANDOM, SEQUENTIAL, WILLNEED, DONTNEED
+//   };
+//
+//   /*
+//    * Apply an madvise() call to the entire file.
+//    *
+//    * Returns 0 on success, -1 on failure.
+//    */
+//   int advise(MapAdvice advice);
+//
+//   protected:
+//
+//   private:
+//   // these are not implemented
+//   FileMap(const FileMap& src);
+//     const FileMap& operator=(const FileMap& src);
+//
+  String       mFileName;      // original file name, if known
+  int       mBasePtr;       // base of mmap area; page aligned
+  int      mBaseLength;    // length, measured from "mBasePtr"
+  long     mDataOffset;    // offset used when map was created
+  byte[]       mDataPtr;       // start of requested data, offset from base
+  int      mDataLength;    // length, measured from "mDataPtr"
+  static long mPageSize;
+
+  @Override
+  public String toString() {
+    if (isFromZip) {
+      return "FileMap{" +
+          "zipFile=" + zipFile.getName() +
+          ", zipEntry=" + zipEntry +
+          '}';
+    } else {
+      return "FileMap{" +
+          "mFileName='" + mFileName + '\'' +
+          '}';
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Formatter.java b/resources/src/main/java/org/robolectric/res/android/Formatter.java
new file mode 100644
index 0000000..ee765c3
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Formatter.java
@@ -0,0 +1,13 @@
+package org.robolectric.res.android;
+
+public class Formatter {
+  public static StringBuilder toHex(int value, int digits) {
+    StringBuilder sb = new StringBuilder(digits + 2);
+    sb.append("0x");
+    String hex = Integer.toHexString(value);
+    for (int i = hex.length(); i < digits; i++) {
+      sb.append("0");
+    }
+    return sb.append(hex);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Idmap.java b/resources/src/main/java/org/robolectric/res/android/Idmap.java
new file mode 100644
index 0000000..b7f6a9c
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Idmap.java
@@ -0,0 +1,235 @@
+package org.robolectric.res.android;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/Idmap.cpp and
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/Idmap.h
+
+import static org.robolectric.res.android.Util.ATRACE_CALL;
+import static org.robolectric.res.android.Util.SIZEOF_CPTR;
+import static org.robolectric.res.android.Util.SIZEOF_INT;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.logError;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.res.android.ResourceTypes.IdmapEntry_header;
+import org.robolectric.res.android.ResourceTypes.Idmap_header;
+
+// #define ATRACE_TAG ATRACE_TAG_RESOURCES
+//
+// #include "androidfw/Idmap.h"
+//
+// #include "android-base/logging.h"
+// #include "android-base/stringprintf.h"
+// #include "utils/ByteOrder.h"
+// #include "utils/Trace.h"
+//
+// #ifdef _WIN32
+// #ifdef ERROR
+// #undef ERROR
+// #endif
+// #endif
+//
+// #include "androidfw/ResourceTypes.h"
+//
+// using ::android::base::StringPrintf;
+//
+// namespace android {
+class Idmap {
+
+  static boolean is_valid_package_id(short id) {
+    return id != 0 && id <= 255;
+  }
+
+  static boolean is_valid_type_id(short id) {
+    // Type IDs and package IDs have the same constraints in the IDMAP.
+    return is_valid_package_id(id);
+  }
+
+  // Represents a loaded/parsed IDMAP for a Runtime Resource Overlay (RRO).
+  // An RRO and its target APK have different resource IDs assigned to their resources. Overlaying
+  // a resource is done by resource name. An IDMAP is a generated mapping between the resource IDs
+  // of the RRO and the target APK for each resource with the same name.
+  // A LoadedIdmap can be set alongside the overlay's LoadedArsc to allow the overlay ApkAssets to
+  // masquerade as the target ApkAssets resources.
+  static class LoadedIdmap {
+    Idmap_header header_ = null;
+    String overlay_apk_path_;
+    final Map<Byte, IdmapEntry_header> type_map_ = new HashMap<>();
+
+    public LoadedIdmap(Idmap_header header_) {
+      this.header_ = header_;
+    }
+
+    // Performs a lookup of the expected entry ID for the given IDMAP entry header.
+    // Returns true if the mapping exists and fills `output_entry_id` with the result.
+    static boolean Lookup(IdmapEntry_header header, int input_entry_id,
+        final Ref<Integer> output_entry_id) {
+      if (input_entry_id < dtohs(header.entry_id_offset)) {
+        // After applying the offset, the entry is not present.
+        return false;
+      }
+
+      input_entry_id -= dtohs(header.entry_id_offset);
+      if (input_entry_id >= dtohs(header.entry_count)) {
+        // The entry is not present.
+        return false;
+      }
+
+      int result = dtohl(header.entries[input_entry_id]);
+      if (result == 0xffffffff) {
+        return false;
+      }
+      output_entry_id.set(result);
+      return true;
+    }
+
+    static boolean is_word_aligned(int offset) {
+      return (offset & 0x03) == 0;
+    }
+
+    @SuppressWarnings("DoNotCallSuggester")
+    static boolean IsValidIdmapHeader(StringPiece data) {
+      throw new UnsupportedOperationException();
+//   if (!is_word_aligned(data.data())) {
+//     LOG(ERROR) << "Idmap header is not word aligned.";
+//     return false;
+//   }
+//
+//   if (data.size() < sizeof(Idmap_header)) {
+//     LOG(ERROR) << "Idmap header is too small.";
+//     return false;
+//   }
+//
+//   const Idmap_header* header = reinterpret_cast<const Idmap_header*>(data.data());
+//   if (dtohl(header->magic) != kIdmapMagic) {
+//     LOG(ERROR) << StringPrintf("Invalid Idmap file: bad magic value (was 0x%08x, expected 0x%08x)",
+//                                dtohl(header->magic), kIdmapMagic);
+//     return false;
+//   }
+//
+//   if (dtohl(header->version) != kIdmapCurrentVersion) {
+//     // We are strict about versions because files with this format are auto-generated and don't need
+//     // backwards compatibility.
+//     LOG(ERROR) << StringPrintf("Version mismatch in Idmap (was 0x%08x, expected 0x%08x)",
+//                                dtohl(header->version), kIdmapCurrentVersion);
+//     return false;
+//   }
+//
+//   if (!is_valid_package_id(dtohs(header->target_package_id))) {
+//     LOG(ERROR) << StringPrintf("Target package ID in Idmap is invalid: 0x%02x",
+//                                dtohs(header->target_package_id));
+//     return false;
+//   }
+//
+//   if (dtohs(header->type_count) > 255) {
+//     LOG(ERROR) << StringPrintf("Idmap has too many type mappings (was %d, max 255)",
+//                                (int)dtohs(header->type_count));
+//     return false;
+//   }
+//   return true;
+    }
+
+// LoadedIdmap::LoadedIdmap(const Idmap_header* header) : header_(header) {
+//   size_t length = strnlen(reinterpret_cast<const char*>(header_->overlay_path),
+//                           arraysize(header_->overlay_path));
+//   overlay_apk_path_.assign(reinterpret_cast<const char*>(header_->overlay_path), length);
+// }
+    // Loads an IDMAP from a chunk of memory. Returns nullptr if the IDMAP data was malformed.
+    LoadedIdmap Load(StringPiece idmap_data) {
+      ATRACE_CALL();
+      if (!IsValidIdmapHeader(idmap_data)) {
+        return emptyBraces();
+      }
+
+      // Idmap_header header = reinterpret_cast<const Idmap_header*>(idmap_data.data());
+      Idmap_header header = idmap_data.asIdmap_header();
+
+      // Can't use make_unique because LoadedImpl constructor is private.
+      LoadedIdmap loaded_idmap = new LoadedIdmap(header);
+
+  // const byte* data_ptr = reinterpret_cast<const byte*>(idmap_data.data()) + sizeof(*header);
+      StringPiece data_ptr = new StringPiece(idmap_data.myBuf(),
+          idmap_data.myOffset() + SIZEOF_CPTR);
+      // int data_size = idmap_data.size() - sizeof(*header);
+      int data_size = idmap_data.size() - SIZEOF_CPTR;
+
+      int type_maps_encountered = 0;
+      while (data_size >= IdmapEntry_header.SIZEOF) {
+        if (!is_word_aligned(data_ptr.myOffset())) {
+          logError("Type mapping in Idmap is not word aligned");
+          return emptyBraces();
+        }
+
+        // Validate the type IDs.
+    // IdmapEntry_header entry_header = reinterpret_cast<const IdmapEntry_header*>(data_ptr);
+        IdmapEntry_header entry_header = new IdmapEntry_header(data_ptr.myBuf(), data_ptr.myOffset());
+        if (!is_valid_type_id(dtohs(entry_header.target_type_id)) || !is_valid_type_id(dtohs(entry_header.overlay_type_id))) {
+          logError(String.format("Invalid type map (0x%02x -> 0x%02x)",
+              dtohs(entry_header.target_type_id),
+              dtohs(entry_header.overlay_type_id)));
+          return emptyBraces();
+        }
+
+        // Make sure there is enough space for the entries declared in the header.
+        if ((data_size - SIZEOF_CPTR) / SIZEOF_INT < dtohs(entry_header.entry_count)) {
+          logError(String.format("Idmap too small for the number of entries (%d)",
+              (int) dtohs(entry_header.entry_count)));
+          return emptyBraces();
+        }
+
+        // Only add a non-empty overlay.
+        if (dtohs(entry_header.entry_count) != 0) {
+          // loaded_idmap.type_map_[static_cast<byte>(dtohs(entry_header.overlay_type_id))] =
+          //     entry_header;
+          loaded_idmap.type_map_.put((byte) dtohs(entry_header.overlay_type_id),
+              entry_header);
+        }
+
+        // int entry_size_bytes =
+        //     sizeof(*entry_header) + (dtohs(entry_header.entry_count) * SIZEOF_INT);
+        int entry_size_bytes =
+            SIZEOF_CPTR + (dtohs(entry_header.entry_count) * SIZEOF_INT);
+        data_ptr = new StringPiece(data_ptr.myBuf(), data_ptr.myOffset() + entry_size_bytes);
+        data_size -= entry_size_bytes;
+        type_maps_encountered++;
+      }
+
+      // Verify that we parsed all the type maps.
+      if (type_maps_encountered != dtohs(header.type_count)) {
+        logError("Parsed " + type_maps_encountered + " type maps but expected "
+            + (int) dtohs(header.type_count));
+        return emptyBraces();
+      }
+      // return std.move(loaded_idmap);
+      return loaded_idmap;
+    }
+
+    private LoadedIdmap emptyBraces() {
+      return new LoadedIdmap(null);
+    }
+
+    // Returns the package ID for which this overlay should apply.
+    int TargetPackageId() {
+      return dtohs(header_.target_package_id);
+    }
+
+    // Returns the path to the RRO (Runtime Resource Overlay) APK for which this IDMAP was generated.
+    String OverlayApkPath() {
+      return overlay_apk_path_;
+    }
+
+    // Returns the mapping of target entry ID to overlay entry ID for the given target type.
+    IdmapEntry_header GetEntryMapForType(byte type_id) {
+      // auto iter = type_map_.find(type_id);
+      // if (iter != type_map_.end()) {
+      //   return iter.second;
+      // }
+      // return null;
+      return type_map_.get(type_id);
+    }
+//
+// }  // namespace android
+  }
+}
\ No newline at end of file
diff --git a/resources/src/main/java/org/robolectric/res/android/IdmapEntries.java b/resources/src/main/java/org/robolectric/res/android/IdmapEntries.java
new file mode 100644
index 0000000..0ea0d9a
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/IdmapEntries.java
@@ -0,0 +1,71 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Errors.*;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceTypes.cpp
+public class IdmapEntries {
+
+  public boolean hasEntries() {
+    if (mData == null) {
+      return false;
+    }
+
+    return (Util.dtohs(mData[0]) > 0);
+  }
+
+//  int byteSize() {
+//    if (mData == null) {
+//      return 0;
+//    }
+//    short entryCount = Util.dtohs(mData[2]);
+//    return (SIZEOF_SHORT * 4) + (SIZEOF_INT * static_cast<int>(entryCount));
+//  }
+
+  byte targetTypeId() {
+    if (mData == null) {
+      return 0;
+    }
+    return (byte) Util.dtohs(mData[0]);
+  }
+
+  public byte overlayTypeId() {
+    if (mData == null) {
+      return 0;
+    }
+    return (byte) Util.dtohs(mData[1]);
+  }
+
+  public int lookup(int entryId, Ref<Short> outEntryId) {
+    short entryCount = Util.dtohs(mData[2]);
+    short offset = Util.dtohs(mData[3]);
+
+    if (entryId < offset) {
+      // The entry is not present in this idmap
+      return BAD_INDEX;
+    }
+
+    entryId -= offset;
+
+    if (entryId >= entryCount) {
+      // The entry is not present in this idmap
+      return BAD_INDEX;
+    }
+
+    throw new UnsupportedOperationException("todo"); // todo
+
+//    // It is safe to access the type here without checking the size because
+//    // we have checked this when it was first loaded.
+////        final int[] entries = reinterpret_cast<final uint32_t*>(mData) + 2;
+//        final int[] entries = reinterpret_cast<final uint32_t*>(mData) + 2;
+//    int mappedEntry = Util.dtohl(entries[entryId]);
+//    if (mappedEntry == 0xffffffff) {
+//      // This entry is not present in this idmap
+//      return BAD_INDEX;
+//    }
+//        *outEntryId = static_cast<short>(mappedEntry);
+//    return NO_ERROR;
+  }
+
+  private short[] mData;
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/LoadedArsc.java b/resources/src/main/java/org/robolectric/res/android/LoadedArsc.java
new file mode 100644
index 0000000..6aed904
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/LoadedArsc.java
@@ -0,0 +1,1001 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Errors.NO_INIT;
+import static org.robolectric.res.android.ResourceTypes.RES_STRING_POOL_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_LIBRARY_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_PACKAGE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_TYPE_SPEC_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_TYPE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.kResTableTypeMinSize;
+import static org.robolectric.res.android.ResourceUtils.make_resid;
+import static org.robolectric.res.android.Util.UNLIKELY;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.isTruthy;
+import static org.robolectric.res.android.Util.logError;
+import static org.robolectric.res.android.Util.logWarning;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import org.robolectric.res.android.Chunk.Iterator;
+import org.robolectric.res.android.Idmap.LoadedIdmap;
+import org.robolectric.res.android.ResourceTypes.IdmapEntry_header;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_lib_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_lib_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_map;
+import org.robolectric.res.android.ResourceTypes.ResTable_map_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_package;
+import org.robolectric.res.android.ResourceTypes.ResTable_sparseTypeEntry;
+import org.robolectric.res.android.ResourceTypes.ResTable_type;
+import org.robolectric.res.android.ResourceTypes.ResTable_typeSpec;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/LoadedArsc.h
+// and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/LoadedArsc.cpp
+public class LoadedArsc {
+
+  //#ifndef LOADEDARSC_H_
+//#define LOADEDARSC_H_
+//
+//#include <memory>
+//#include <set>
+//#include <vector>
+//
+//#include "android-base/macros.h"
+//
+//#include "androidfw/ByteBucketArray.h"
+//#include "androidfw/Chunk.h"
+//#include "androidfw/ResourceTypes.h"
+//#include "androidfw/Util.h"
+//
+//namespace android {
+//
+  static class DynamicPackageEntry {
+
+    // public:
+    //
+    // DynamicPackageEntry() =default;
+
+    DynamicPackageEntry(String package_name, int package_id) {
+      this.package_name = package_name;
+      this.package_id = package_id;
+    }
+
+    String package_name;
+    int package_id = 0;
+  }
+
+  // TypeSpec is going to be immediately proceeded by
+// an array of Type structs, all in the same block of memory.
+  static class TypeSpec {
+
+    public static final int SIZEOF = ResTable_typeSpec.SIZEOF + IdmapEntry_header.SIZEOF;
+    
+    // Pointer to the mmapped data where flags are kept.
+    // Flags denote whether the resource entry is public
+    // and under which configurations it varies.
+    ResTable_typeSpec type_spec;
+
+    // Pointer to the mmapped data where the IDMAP mappings for this type
+    // exist. May be nullptr if no IDMAP exists.
+    IdmapEntry_header idmap_entries;
+
+    // The number of types that follow this struct.
+    // There is a type for each configuration that entries are defined for.
+    int type_count;
+
+    // Trick to easily access a variable number of Type structs
+    // proceeding this struct, and to ensure their alignment.
+    // ResTable_type* types[0];
+    ResTable_type[] types;
+
+    int GetFlagsForEntryIndex(int entry_index) {
+      if (entry_index >= dtohl(type_spec.entryCount)) {
+        return 0;
+      }
+
+      // uint32_t* flags = reinterpret_cast<uint32_t*>(type_spec + 1);
+      int[] flags = type_spec.getSpecFlags();
+      return flags[entry_index];
+    }
+  }
+
+  // Returns the string pool where all string resource values
+  // (Res_value::dataType == Res_value::TYPE_STRING) are indexed.
+  public ResStringPool GetStringPool() {
+    return global_string_pool_;
+  }
+
+  // Returns a vector of LoadedPackage pointers, representing the packages in this LoadedArsc.
+  List<LoadedPackage> GetPackages() {
+    return packages_;
+  }
+
+  // Returns true if this is a system provided resource.
+  boolean IsSystem() {
+    return system_;
+  }
+
+  //
+// private:
+//  DISALLOW_COPY_AND_ASSIGN(LoadedArsc);
+//
+//  LoadedArsc() = default;
+//   bool LoadTable(const Chunk& chunk, const LoadedIdmap* loaded_idmap, bool load_as_shared_library);
+//
+  final ResStringPool global_string_pool_ = new ResStringPool();
+  final List<LoadedPackage> packages_ = new ArrayList<>();
+  boolean system_ = false;
+  // };
+  //
+  // }  // namespace android
+  //
+  // #endif // LOADEDARSC_H_
+
+  //  #define ATRACE_TAG ATRACE_TAG_RESOURCES
+  //
+  //  #include "androidfw/LoadedArsc.h"
+  //
+  //  #include <cstddef>
+  //  #include <limits>
+  //
+  //  #include "android-base/logging.h"
+  //  #include "android-base/stringprintf.h"
+  //  #include "utils/ByteOrder.h"
+  //  #include "utils/Trace.h"
+  //
+  //  #ifdef _WIN32
+  //  #ifdef ERROR
+  //  #undef ERROR
+  //  #endif
+  //  #endif
+  //
+  //  #include "androidfw/ByteBucketArray.h"
+  //  #include "androidfw/Chunk.h"
+  //  #include "androidfw/ResourceUtils.h"
+  //  #include "androidfw/Util.h"
+  //
+  //  using android::base::StringPrintf;
+  //
+  //  namespace android {
+
+  static final int kAppPackageId = 0x7f;
+
+//  namespace {
+
+  // Builder that helps accumulate Type structs and then create a single
+  // contiguous block of memory to store both the TypeSpec struct and
+  // the Type structs.
+  static class TypeSpecPtrBuilder {
+    // public:
+    TypeSpecPtrBuilder(ResTable_typeSpec header, IdmapEntry_header idmap_header) {
+      this.header_ = header;
+      this.idmap_header_ = idmap_header;
+    }
+
+    void AddType(ResTable_type type) {
+      types_.add(type);
+    }
+
+    TypeSpec Build() {
+      // Check for overflow.
+      // using ElementType = ResTable_type*;
+      // if ((std.numeric_limits<size_t>.max() - sizeof(TypeSpec)) / sizeof(ElementType) <
+      //     types_.size()) {
+      if ((Integer.MAX_VALUE - TypeSpec.SIZEOF) / 4 < types_.size()) {
+        return null; // {} ;
+      }
+      // TypeSpec* type_spec =
+      //     (TypeSpec*).malloc(sizeof(TypeSpec) + (types_.size() * sizeof(ElementType)));
+      TypeSpec type_spec = new TypeSpec();
+      type_spec.types = new ResTable_type[types_.size()];
+      type_spec.type_spec = header_;
+      type_spec.idmap_entries = idmap_header_;
+      type_spec.type_count = types_.size();
+      // memcpy(type_spec + 1, types_.data(), types_.size() * sizeof(ElementType));
+      for (int i = 0; i < type_spec.types.length; i++) {
+        type_spec.types[i] = types_.get(i);
+        
+      }
+      return type_spec;
+    }
+
+    // private:
+    // DISALLOW_COPY_AND_ASSIGN(TypeSpecPtrBuilder);
+
+    ResTable_typeSpec header_;
+    IdmapEntry_header idmap_header_;
+    final List<ResTable_type> types_ = new ArrayList<>();
+  };
+
+//  }  // namespace
+
+  // Precondition: The header passed in has already been verified, so reading any fields and trusting
+// the ResChunk_header is safe.
+  static boolean VerifyResTableType(ResTable_type header) {
+    if (header.id == 0) {
+      logError("RES_TABLE_TYPE_TYPE has invalid ID 0.");
+      return false;
+    }
+
+    int entry_count = dtohl(header.entryCount);
+    // if (entry_count > std.numeric_limits<uint16_t>.max()) {
+    if (entry_count > 0xffff) {
+      logError("RES_TABLE_TYPE_TYPE has too many entries (" + entry_count + ").");
+      return false;
+    }
+
+    // Make sure that there is enough room for the entry offsets.
+    int offsets_offset = dtohs(header.header.headerSize);
+    int entries_offset = dtohl(header.entriesStart);
+    int offsets_length = 4 * entry_count;
+
+    if (offsets_offset > entries_offset || entries_offset - offsets_offset < offsets_length) {
+      logError("RES_TABLE_TYPE_TYPE entry offsets overlap actual entry data.");
+      return false;
+    }
+
+    if (entries_offset > dtohl(header.header.size)) {
+      logError("RES_TABLE_TYPE_TYPE entry offsets extend beyond chunk.");
+      return false;
+    }
+
+    if (isTruthy(entries_offset & 0x03)) {
+      logError("RES_TABLE_TYPE_TYPE entries start at unaligned address.");
+      return false;
+    }
+    return true;
+  }
+
+  static boolean VerifyResTableEntry(ResTable_type type, int entry_offset) {
+    // Check that the offset is aligned.
+    if (isTruthy(entry_offset & 0x03)) {
+      logError("Entry at offset " + entry_offset + " is not 4-byte aligned.");
+      return false;
+    }
+
+    // Check that the offset doesn't overflow.
+    // if (entry_offset > std.numeric_limits<int>.max() - dtohl(type.entriesStart)) {
+    if (entry_offset > Integer.MAX_VALUE - dtohl(type.entriesStart)) {
+      // Overflow in offset.
+      logError("Entry at offset " + entry_offset + " is too large.");
+      return false;
+    }
+
+    int chunk_size = dtohl(type.header.size);
+
+    entry_offset += dtohl(type.entriesStart);
+    if (entry_offset > chunk_size - ResTable_entry.SIZEOF) {
+      logError("Entry at offset " + entry_offset
+          + " is too large. No room for ResTable_entry.");
+      return false;
+    }
+
+    // ResTable_entry* entry = reinterpret_cast<ResTable_entry*>(
+    //       reinterpret_cast<uint8_t*>(type) + entry_offset);
+    ResTable_entry entry = new ResTable_entry(type.myBuf(), type.myOffset() + entry_offset);
+
+    int entry_size = dtohs(entry.size);
+    // if (entry_size < sizeof(*entry)) {
+    if (entry_size < ResTable_entry.SIZEOF) {
+      logError("ResTable_entry size " + entry_size + " at offset " + entry_offset
+          + " is too small.");
+      return false;
+    }
+
+    if (entry_size > chunk_size || entry_offset > chunk_size - entry_size) {
+      logError("ResTable_entry size " + entry_size + " at offset " + entry_offset
+          + " is too large.");
+      return false;
+    }
+
+    if (entry_size < ResTable_map_entry.BASE_SIZEOF) {
+      // There needs to be room for one Res_value struct.
+      if (entry_offset + entry_size > chunk_size - Res_value.SIZEOF) {
+        logError("No room for Res_value after ResTable_entry at offset " + entry_offset
+            + " for type " + (int) type.id + ".");
+        return false;
+      }
+
+      // Res_value value =
+      //       reinterpret_cast<Res_value*>(reinterpret_cast<uint8_t*>(entry) + entry_size);
+      Res_value value =
+          new Res_value(entry.myBuf(), entry.myOffset() + ResTable_entry.SIZEOF);
+      int value_size = dtohs(value.size);
+      if (value_size < Res_value.SIZEOF) {
+        logError("Res_value at offset " + entry_offset + " is too small.");
+        return false;
+      }
+
+      if (value_size > chunk_size || entry_offset + entry_size > chunk_size - value_size) {
+        logError("Res_value size " + value_size + " at offset " + entry_offset
+            + " is too large.");
+        return false;
+      }
+    } else {
+      ResTable_map_entry map = new ResTable_map_entry(entry.myBuf(), entry.myOffset());
+      int map_entry_count = dtohl(map.count);
+      int map_entries_start = entry_offset + entry_size;
+      if (isTruthy(map_entries_start & 0x03)) {
+        logError("Map entries at offset " + entry_offset + " start at unaligned offset.");
+        return false;
+      }
+
+      // Each entry is sizeof(ResTable_map) big.
+      if (map_entry_count > ((chunk_size - map_entries_start) / ResTable_map.SIZEOF)) {
+        logError("Too many map entries in ResTable_map_entry at offset " + entry_offset + ".");
+        return false;
+      }
+    }
+    return true;
+  }
+
+  static class LoadedPackage {
+    // private:
+
+    // DISALLOW_COPY_AND_ASSIGN(LoadedPackage);
+
+    // LoadedPackage();
+
+    ResStringPool type_string_pool_ = new ResStringPool();
+    ResStringPool key_string_pool_ = new ResStringPool();
+    String package_name_;
+    int package_id_ = -1;
+    int type_id_offset_ = 0;
+    boolean dynamic_ = false;
+    boolean system_ = false;
+    boolean overlay_ = false;
+
+    // final ByteBucketArray<TypeSpec> type_specs_ = new ByteBucketArray<TypeSpec>() {
+    //   @Override
+    //   TypeSpec newInstance() {
+    //     return new TypeSpec();
+    //   }
+    // };
+    final Map<Integer, TypeSpec> type_specs_ = new HashMap<>();
+    final List<DynamicPackageEntry> dynamic_package_map_ = new ArrayList<>();
+
+    ResTable_entry GetEntry(ResTable_type type_chunk,
+        short entry_index) {
+      int entry_offset = GetEntryOffset(type_chunk, entry_index);
+      if (entry_offset == ResTable_type.NO_ENTRY) {
+        return null;
+      }
+      return GetEntryFromOffset(type_chunk, entry_offset);
+    }
+
+    static int GetEntryOffset(ResTable_type type_chunk, int entry_index) {
+      // The configuration matches and is better than the previous selection.
+      // Find the entry value if it exists for this configuration.
+      int entry_count = dtohl(type_chunk.entryCount);
+      int offsets_offset = dtohs(type_chunk.header.headerSize);
+
+      // Check if there is the desired entry in this type.
+
+      if (isTruthy(type_chunk.flags & ResTable_type.FLAG_SPARSE)) {
+        // This is encoded as a sparse map, so perform a binary search.
+        // ResTable_sparseTypeEntry sparse_indices =
+        //     reinterpret_cast<ResTable_sparseTypeEntry*>(
+        //         reinterpret_cast<uint8_t*>(type_chunk) + offsets_offset);
+        // ResTable_sparseTypeEntry* sparse_indices_end = sparse_indices + entry_count;
+        // ResTable_sparseTypeEntry* result =
+        //     std.lower_bound(sparse_indices, sparse_indices_end, entry_index,
+        //         [](ResTable_sparseTypeEntry& entry, short entry_idx) {
+        //   return dtohs(entry.idx) < entry_idx;
+        // });
+        ResTable_sparseTypeEntry result = null;
+        for (int i = 0; i < entry_count; i++) {
+          ResTable_sparseTypeEntry entry =
+              new ResTable_sparseTypeEntry(
+                  type_chunk.myBuf(),
+                  type_chunk.myOffset() + offsets_offset + i * ResTable_sparseTypeEntry.SIZEOF);
+          if (entry.idx >= entry_index) {
+            result = entry;
+            break;
+          }
+        }
+
+        if (result == null || dtohs(result.idx) != entry_index) {
+          // No entry found.
+          return ResTable_type.NO_ENTRY;
+        }
+
+        // Extract the offset from the entry. Each offset must be a multiple of 4 so we store it as
+        // the real offset divided by 4.
+        // return int{dtohs(result.offset)} * 4u;
+        return dtohs(result.offset) * 4;
+      }
+
+      // This type is encoded as a dense array.
+      if (entry_index >= entry_count) {
+        // This entry cannot be here.
+        return ResTable_type.NO_ENTRY;
+      }
+
+      // int* entry_offsets = reinterpret_cast<int*>(
+      //     reinterpret_cast<uint8_t*>(type_chunk) + offsets_offset);
+      // return dtohl(entry_offsets[entry_index]);
+      return dtohl(type_chunk.entryOffset(entry_index));
+    }
+
+    static ResTable_entry GetEntryFromOffset(ResTable_type type_chunk,
+        int offset) {
+      if (UNLIKELY(!VerifyResTableEntry(type_chunk, offset))) {
+        return null;
+      }
+      // return reinterpret_cast<ResTable_entry*>(reinterpret_cast<uint8_t*>(type_chunk) +
+      //     offset + dtohl(type_chunk.entriesStart));
+      return new ResTable_entry(type_chunk.myBuf(),
+          type_chunk.myOffset() + offset + dtohl(type_chunk.entriesStart));
+    }
+
+    void CollectConfigurations(boolean exclude_mipmap,
+        Set<ResTable_config> out_configs) {
+      String kMipMap = "mipmap";
+      int type_count = type_specs_.size();
+      for (int i = 0; i < type_count; i++) {
+        TypeSpec type_spec = type_specs_.get(i);
+        if (type_spec != null) {
+          if (exclude_mipmap) {
+            int type_idx = type_spec.type_spec.id - 1;
+            final Ref<Integer> type_name_len = new Ref<>(0);
+            String type_name16 = type_string_pool_.stringAt(type_idx, type_name_len);
+            if (type_name16 != null) {
+              // if (kMipMap.compare(0, std::u16string::npos,type_name16, type_name_len) ==0){
+              if (kMipMap.equals(type_name16)) {
+                // This is a mipmap type, skip collection.
+                continue;
+              }
+            }
+            String type_name = type_string_pool_.string8At(type_idx, type_name_len);
+            if (type_name != null) {
+              // if (strncmp(type_name, "mipmap", type_name_len) == 0) {
+              if ("mipmap".equals(type_name))
+                // This is a mipmap type, skip collection.
+                continue;
+            }
+          }
+        }
+
+        for (ResTable_type iter : type_spec.types) {
+          ResTable_config config = ResTable_config.fromDtoH(iter.config);
+          out_configs.add(config);
+        }
+      }
+    }
+
+    void CollectLocales(boolean canonicalize, Set<String> out_locales) {
+      // char temp_locale[ RESTABLE_MAX_LOCALE_LEN];
+      String temp_locale;
+      int type_count = type_specs_.size();
+      for (int i = 0; i < type_count; i++) {
+        TypeSpec type_spec = type_specs_.get(i);
+        if (type_spec != null) {
+          for (ResTable_type iter : type_spec.types) {
+            ResTable_config configuration = ResTable_config.fromDtoH(iter.config);
+            if (configuration.locale() != 0) {
+              temp_locale = configuration.getBcp47Locale(canonicalize);
+              String locale = temp_locale;
+              out_locales.add(locale);
+            }
+          }
+        }
+      }
+    }
+
+    // Finds the entry with the specified type name and entry name. The names are in UTF-16 because
+    // the underlying ResStringPool API expects this. For now this is acceptable, but since
+    // the default policy in AAPT2 is to build UTF-8 string pools, this needs to change.
+    // Returns a partial resource ID, with the package ID left as 0x00. The caller is responsible
+    // for patching the correct package ID to the resource ID.
+    int FindEntryByName(String type_name, String entry_name) {
+      int type_idx = type_string_pool_.indexOfString(type_name);
+      if (type_idx < 0) {
+        return 0;
+      }
+
+      int key_idx = key_string_pool_.indexOfString(entry_name);
+      if (key_idx < 0) {
+        return 0;
+      }
+
+      TypeSpec type_spec = type_specs_.get(type_idx);
+      if (type_spec == null) {
+        return 0;
+      }
+
+      for (ResTable_type iter : type_spec.types) {
+        ResTable_type type = iter;
+        int entry_count = type.entryCount;
+
+        for (int entry_idx = 0; entry_idx < entry_count; entry_idx++) {
+          // const uint32_t* entry_offsets = reinterpret_cast<const uint32_t*>(
+          //     reinterpret_cast<const uint8_t*>(type.type) + dtohs(type.type.header.headerSize));
+          // ResTable_type entry_offsets = new ResTable_type(type.myBuf(),
+          //     type.myOffset() + type.header.headerSize);
+          // int offset = dtohl(entry_offsets[entry_idx]);
+          int offset = dtohl(type.entryOffset(entry_idx));
+          if (offset != ResTable_type.NO_ENTRY) {
+            // const ResTable_entry* entry =
+            //     reinterpret_cast<const ResTable_entry*>(reinterpret_cast<const uint8_t*>(type.type) +
+            //     dtohl(type.type.entriesStart) + offset);
+            ResTable_entry entry =
+                new ResTable_entry(type.myBuf(), type.myOffset() +
+                    dtohl(type.entriesStart) + offset);
+            if (dtohl(entry.key.index) == key_idx) {
+              // The package ID will be overridden by the caller (due to runtime assignment of package
+              // IDs for shared libraries).
+              return make_resid((byte) 0x00, (byte) (type_idx + type_id_offset_ + 1), (short) entry_idx);
+            }
+          }
+        }
+      }
+      return 0;
+    }
+
+    static LoadedPackage Load(Chunk chunk,
+        LoadedIdmap loaded_idmap,
+        boolean system, boolean load_as_shared_library) {
+      // ATRACE_NAME("LoadedPackage::Load");
+      LoadedPackage loaded_package = new LoadedPackage();
+
+      // typeIdOffset was added at some point, but we still must recognize apps built before this
+      // was added.
+      // constexpr int kMinPackageSize =
+      //     sizeof(ResTable_package) - sizeof(ResTable_package.typeIdOffset);
+      final int kMinPackageSize = ResTable_package.SIZEOF - 4;
+      // ResTable_package header = chunk.header<ResTable_package, kMinPackageSize>();
+      ResTable_package header = chunk.asResTable_package(kMinPackageSize);
+      if (header == null) {
+        logError("RES_TABLE_PACKAGE_TYPE too small.");
+        return emptyBraces();
+      }
+
+      loaded_package.system_ = system;
+
+      loaded_package.package_id_ = dtohl(header.id);
+      if (loaded_package.package_id_ == 0 ||
+          (loaded_package.package_id_ == kAppPackageId && load_as_shared_library)) {
+        // Package ID of 0 means this is a shared library.
+        loaded_package.dynamic_ = true;
+      }
+
+      if (loaded_idmap != null) {
+        // This is an overlay and so it needs to pretend to be the target package.
+        loaded_package.package_id_ = loaded_idmap.TargetPackageId();
+        loaded_package.overlay_ = true;
+      }
+
+      if (header.header.headerSize >= ResTable_package.SIZEOF) {
+        int type_id_offset = dtohl(header.typeIdOffset);
+        // if (type_id_offset > std.numeric_limits<uint8_t>.max()) {
+        if (type_id_offset > 255) {
+          logError("RES_TABLE_PACKAGE_TYPE type ID offset too large.");
+          return emptyBraces();
+        }
+        loaded_package.type_id_offset_ = type_id_offset;
+      }
+
+      loaded_package.package_name_ = Util
+          .ReadUtf16StringFromDevice(header.name, header.name.length);
+
+      // A map of TypeSpec builders, each associated with an type index.
+      // We use these to accumulate the set of Types available for a TypeSpec, and later build a single,
+      // contiguous block of memory that holds all the Types together with the TypeSpec.
+      Map<Integer, TypeSpecPtrBuilder> type_builder_map = new HashMap<>();
+
+      Chunk.Iterator iter = new Iterator(chunk.data_ptr(), chunk.data_size());
+      while (iter.HasNext()) {
+        Chunk child_chunk = iter.Next();
+        switch (child_chunk.type()) {
+          case RES_STRING_POOL_TYPE: {
+            // uintptr_t pool_address =
+            //     reinterpret_cast<uintptr_t>(child_chunk.header<ResChunk_header>());
+            // uintptr_t header_address = reinterpret_cast<uintptr_t>(header);
+            int pool_address =
+                child_chunk.myOffset();
+            int header_address = header.myOffset();
+            if (pool_address == header_address + dtohl(header.typeStrings)) {
+              // This string pool is the type string pool.
+              int err = loaded_package.type_string_pool_.setTo(
+                  child_chunk.myBuf(), child_chunk.myOffset(), child_chunk.size(), false);
+              if (err != NO_ERROR) {
+                logError("RES_STRING_POOL_TYPE for types corrupt.");
+                return emptyBraces();
+              }
+            } else if (pool_address == header_address + dtohl(header.keyStrings)) {
+              // This string pool is the key string pool.
+              int err = loaded_package.key_string_pool_.setTo(
+                  child_chunk.myBuf(), child_chunk.myOffset(), child_chunk.size(), false);
+              if (err != NO_ERROR) {
+                logError("RES_STRING_POOL_TYPE for keys corrupt.");
+                return emptyBraces();
+              }
+            } else {
+              logWarning("Too many RES_STRING_POOL_TYPEs found in RES_TABLE_PACKAGE_TYPE.");
+            }
+          } break;
+
+          case RES_TABLE_TYPE_SPEC_TYPE: {
+            ResTable_typeSpec type_spec = new ResTable_typeSpec(child_chunk.myBuf(),
+                child_chunk.myOffset());
+            if (type_spec == null) {
+              logError("RES_TABLE_TYPE_SPEC_TYPE too small.");
+              return emptyBraces();
+            }
+
+            if (type_spec.id == 0) {
+              logError("RES_TABLE_TYPE_SPEC_TYPE has invalid ID 0.");
+              return emptyBraces();
+            }
+
+            // if (loaded_package.type_id_offset_ + static_cast<int>(type_spec.id) >
+            //     std.numeric_limits<uint8_t>.max()) {
+            if (loaded_package.type_id_offset_ + type_spec.id > 255) {
+              logError("RES_TABLE_TYPE_SPEC_TYPE has out of range ID.");
+              return emptyBraces();
+            }
+
+            // The data portion of this chunk contains entry_count 32bit entries,
+            // each one representing a set of flags.
+            // Here we only validate that the chunk is well formed.
+            int entry_count = dtohl(type_spec.entryCount);
+
+            // There can only be 2^16 entries in a type, because that is the ID
+            // space for entries (EEEE) in the resource ID 0xPPTTEEEE.
+            // if (entry_count > std.numeric_limits<short>.max()) {
+            if (entry_count > 0xffff) {
+              logError("RES_TABLE_TYPE_SPEC_TYPE has too many entries (" + entry_count + ").");
+              return emptyBraces();
+            }
+
+            if (entry_count * 4 /*sizeof(int)*/ > chunk.data_size()) {
+              logError("RES_TABLE_TYPE_SPEC_TYPE too small to hold entries.");
+              return emptyBraces();
+            }
+
+            // If this is an overlay, associate the mapping of this type to the target type
+            // from the IDMAP.
+            IdmapEntry_header idmap_entry_header = null;
+            if (loaded_idmap != null) {
+              idmap_entry_header = loaded_idmap.GetEntryMapForType(type_spec.id);
+            }
+
+            TypeSpecPtrBuilder builder_ptr = type_builder_map.get(type_spec.id - 1);
+            if (builder_ptr == null) {
+              // builder_ptr = util.make_unique<TypeSpecPtrBuilder>(type_spec, idmap_entry_header);
+              builder_ptr = new TypeSpecPtrBuilder(type_spec, idmap_entry_header);
+              type_builder_map.put(type_spec.id - 1, builder_ptr);
+            } else {
+              logWarning(String.format("RES_TABLE_TYPE_SPEC_TYPE already defined for ID %02x",
+                  type_spec.id));
+            }
+          } break;
+
+          case RES_TABLE_TYPE_TYPE: {
+            // ResTable_type type = child_chunk.header<ResTable_type, kResTableTypeMinSize>();
+            ResTable_type type = child_chunk.asResTable_type(kResTableTypeMinSize);
+            if (type == null) {
+              logError("RES_TABLE_TYPE_TYPE too small.");
+              return emptyBraces();
+            }
+
+            if (!VerifyResTableType(type)) {
+              return emptyBraces();
+            }
+
+            // Type chunks must be preceded by their TypeSpec chunks.
+            TypeSpecPtrBuilder builder_ptr = type_builder_map.get(type.id - 1);
+            if (builder_ptr != null) {
+              builder_ptr.AddType(type);
+            } else {
+                logError(
+                    String.format(
+                        "RES_TABLE_TYPE_TYPE with ID %02x found without preceding"
+                            + " RES_TABLE_TYPE_SPEC_TYPE.",
+                        type.id));
+              return emptyBraces();
+            }
+          } break;
+
+          case RES_TABLE_LIBRARY_TYPE: {
+            ResTable_lib_header lib = child_chunk.asResTable_lib_header();
+            if (lib == null) {
+              logError("RES_TABLE_LIBRARY_TYPE too small.");
+              return emptyBraces();
+            }
+
+            if (child_chunk.data_size() / ResTable_lib_entry.SIZEOF < dtohl(lib.count)) {
+              logError("RES_TABLE_LIBRARY_TYPE too small to hold entries.");
+              return emptyBraces();
+            }
+
+            // loaded_package.dynamic_package_map_.reserve(dtohl(lib.count));
+
+            // ResTable_lib_entry entry_begin =
+            //     reinterpret_cast<ResTable_lib_entry*>(child_chunk.data_ptr());
+            ResTable_lib_entry entry_begin =
+                child_chunk.asResTable_lib_entry();
+            // ResTable_lib_entry entry_end = entry_begin + dtohl(lib.count);
+            // for (auto entry_iter = entry_begin; entry_iter != entry_end; ++entry_iter) {
+            for (ResTable_lib_entry entry_iter = entry_begin;
+                entry_iter.myOffset() != entry_begin.myOffset() + dtohl(lib.count);
+                entry_iter = new ResTable_lib_entry(entry_iter.myBuf(), entry_iter.myOffset() + ResTable_lib_entry.SIZEOF)) {
+              String package_name =
+                  Util.ReadUtf16StringFromDevice(entry_iter.packageName,
+                      entry_iter.packageName.length);
+              
+              if (dtohl(entry_iter.packageId) >= 255) {
+                logError(String.format(
+                    "Package ID %02x in RES_TABLE_LIBRARY_TYPE too large for package '%s'.",
+                    dtohl(entry_iter.packageId), package_name));
+                return emptyBraces();
+              }
+
+              // loaded_package.dynamic_package_map_.emplace_back(std.move(package_name),
+              //     dtohl(entry_iter.packageId));
+              loaded_package.dynamic_package_map_.add(new DynamicPackageEntry(package_name,
+                  dtohl(entry_iter.packageId)));
+            }
+
+          } break;
+
+          default:
+            logWarning(String.format("Unknown chunk type '%02x'.", chunk.type()));
+            break;
+        }
+      }
+
+      if (iter.HadError()) {
+        logError(iter.GetLastError());
+        if (iter.HadFatalError()) {
+          return emptyBraces();
+        }
+      }
+
+      // Flatten and construct the TypeSpecs.
+      for (Entry<Integer, TypeSpecPtrBuilder> entry : type_builder_map.entrySet()) {
+        byte type_idx = (byte) entry.getKey().byteValue();
+        TypeSpec type_spec_ptr = entry.getValue().Build();
+        if (type_spec_ptr == null) {
+          logError("Too many type configurations, overflow detected.");
+          return emptyBraces();
+        }
+
+        // We only add the type to the package if there is no IDMAP, or if the type is
+        // overlaying something.
+        if (loaded_idmap == null || type_spec_ptr.idmap_entries != null) {
+          // If this is an overlay, insert it at the target type ID.
+          if (type_spec_ptr.idmap_entries != null) {
+            type_idx = (byte) (dtohs(type_spec_ptr.idmap_entries.target_type_id) - 1);
+          }
+          // loaded_package.type_specs_.editItemAt(type_idx) = std.move(type_spec_ptr);
+          loaded_package.type_specs_.put((int) type_idx, type_spec_ptr);
+        }
+      }
+
+      // return std.move(loaded_package);
+      return loaded_package;
+    }
+
+    // Returns the string pool where type names are stored.
+    ResStringPool GetTypeStringPool() {
+      return type_string_pool_;
+    }
+
+    // Returns the string pool where the names of resource entries are stored.
+    ResStringPool GetKeyStringPool() {
+      return key_string_pool_;
+    }
+
+    String GetPackageName() {
+      return package_name_;
+    }
+
+    int GetPackageId() {
+      return package_id_;
+    }
+
+    // Returns true if this package is dynamic (shared library) and needs to have an ID assigned.
+    boolean IsDynamic() {
+      return dynamic_;
+    }
+
+    // Returns true if this package originates from a system provided resource.
+    boolean IsSystem() {
+      return system_;
+    }
+
+    // Returns true if this package is from an overlay ApkAssets.
+    boolean IsOverlay() {
+      return overlay_;
+    }
+
+    // Returns the map of package name to package ID used in this LoadedPackage. At runtime, a
+    // package could have been assigned a different package ID than what this LoadedPackage was
+    // compiled with. AssetManager rewrites the package IDs so that they are compatible at runtime.
+    List<DynamicPackageEntry> GetDynamicPackageMap() {
+      return dynamic_package_map_;
+    }
+
+    // type_idx is TT - 1 from 0xPPTTEEEE.
+    TypeSpec GetTypeSpecByTypeIndex(int type_index) {
+      // If the type IDs are offset in this package, we need to take that into account when searching
+      // for a type.
+      return type_specs_.get(type_index - type_id_offset_);
+    }
+
+    // template <typename Func>
+    interface TypeSpecFunc {
+      void apply(TypeSpec spec, byte index);
+    }
+
+    void ForEachTypeSpec(TypeSpecFunc f) {
+      for (Integer i : type_specs_.keySet()) {
+        TypeSpec ptr = type_specs_.get(i);
+        if (ptr != null) {
+          byte type_id = ptr.type_spec.id;
+          if (ptr.idmap_entries != null) {
+            type_id = (byte) ptr.idmap_entries.target_type_id;
+          }
+          f.apply(ptr, (byte) (type_id - 1));
+        }
+      }
+    }
+
+    private static LoadedPackage emptyBraces() {
+      return new LoadedPackage();
+    }
+  }
+
+  // Gets a pointer to the package with the specified package ID, or nullptr if no such package
+  // exists.
+  LoadedPackage GetPackageById(int package_id) {
+    for (LoadedPackage loaded_package : packages_) {
+      if (loaded_package.GetPackageId() == package_id) {
+        return loaded_package;
+      }
+    }
+    return null;
+  }
+
+  boolean LoadTable(Chunk chunk, LoadedIdmap loaded_idmap,
+      boolean load_as_shared_library) {
+    // ResTable_header header = chunk.header<ResTable_header>();
+    ResTable_header header = chunk.asResTable_header();
+    if (header == null) {
+      logError("RES_TABLE_TYPE too small.");
+      return false;
+    }
+
+    int package_count = dtohl(header.packageCount);
+    int packages_seen = 0;
+
+    // packages_.reserve(package_count);
+
+    Chunk.Iterator iter = new Iterator(chunk.data_ptr(), chunk.data_size());
+    while (iter.HasNext()) {
+      Chunk child_chunk = iter.Next();
+      switch (child_chunk.type()) {
+        case RES_STRING_POOL_TYPE:
+          // Only use the first string pool. Ignore others.
+          if (global_string_pool_.getError() == NO_INIT) {
+            ResStringPool_header resStringPool_header = child_chunk.asResStringPool_header();
+            int err = global_string_pool_.setTo(resStringPool_header.myBuf(),
+                resStringPool_header.myOffset(),
+                child_chunk.size(), false);
+            if (err != NO_ERROR) {
+              logError("RES_STRING_POOL_TYPE corrupt.");
+              return false;
+            }
+          } else {
+            logWarning("Multiple RES_STRING_POOL_TYPEs found in RES_TABLE_TYPE.");
+          }
+          break;
+
+        case RES_TABLE_PACKAGE_TYPE: {
+          if (packages_seen + 1 > package_count) {
+            logError("More package chunks were found than the " + package_count
+                + " declared in the header.");
+            return false;
+          }
+          packages_seen++;
+
+          LoadedPackage loaded_package =
+              LoadedPackage.Load(child_chunk, loaded_idmap, system_, load_as_shared_library);
+          if (!isTruthy(loaded_package)) {
+            return false;
+          }
+          packages_.add(loaded_package);
+        } break;
+
+        default:
+          logWarning(String.format("Unknown chunk type '%02x'.", chunk.type()));
+          break;
+      }
+    }
+
+    if (iter.HadError()) {
+      logError(iter.GetLastError());
+      if (iter.HadFatalError()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  // Read-only view into a resource table. This class validates all data
+// when loading, including offsets and lengths.
+//class LoadedArsc {
+// public:
+  // Load a resource table from memory pointed to by `data` of size `len`.
+  // The lifetime of `data` must out-live the LoadedArsc returned from this method.
+  // If `system` is set to true, the LoadedArsc is considered as a system provided resource.
+  // If `load_as_shared_library` is set to true, the application package (0x7f) is treated
+  // as a shared library (0x00). When loaded into an AssetManager, the package will be assigned an
+  // ID.
+  static LoadedArsc Load(StringPiece data,
+      LoadedIdmap loaded_idmap /* = null */, boolean system /* = false */,
+      boolean load_as_shared_library /* = false */) {
+    // ATRACE_NAME("LoadedArsc::LoadTable");
+
+    // Not using make_unique because the constructor is private.
+    LoadedArsc loaded_arsc = new LoadedArsc();
+    loaded_arsc.system_ = system;
+
+    Chunk.Iterator iter = new Iterator(data, data.size());
+    while (iter.HasNext()) {
+      Chunk chunk = iter.Next();
+      switch (chunk.type()) {
+        case RES_TABLE_TYPE:
+          if (!loaded_arsc.LoadTable(chunk, loaded_idmap, load_as_shared_library)) {
+            return emptyBraces();
+          }
+          break;
+
+        default:
+          logWarning(String.format("Unknown chunk type '%02x'.", chunk.type()));
+          break;
+      }
+    }
+
+    if (iter.HadError()) {
+      logError(iter.GetLastError());
+      if (iter.HadFatalError()) {
+        return emptyBraces();
+      }
+    }
+
+    // Need to force a move for mingw32.
+    // return std.move(loaded_arsc);
+    return loaded_arsc;
+  }
+
+  // Create an empty LoadedArsc. This is used when an APK has no resources.arsc.
+  static LoadedArsc CreateEmpty() {
+    return new LoadedArsc();
+  }
+
+  // Populates a set of ResTable_config structs, possibly excluding configurations defined for
+  // the mipmap type.
+  // void CollectConfigurations(boolean exclude_mipmap, Set<ResTable_config> out_configs);
+
+  // Populates a set of strings representing locales.
+  // If `canonicalize` is set to true, each locale is transformed into its canonical format
+  // before being inserted into the set. This may cause some equivalent locales to de-dupe.
+  // void CollectLocales(boolean canonicalize, Set<String> out_locales);
+
+  private static LoadedArsc emptyBraces() {
+    return new LoadedArsc();
+  }
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/LocaleData.java b/resources/src/main/java/org/robolectric/res/android/LocaleData.java
new file mode 100644
index 0000000..0fe00f5
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/LocaleData.java
@@ -0,0 +1,233 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.LocaleDataTables.LIKELY_SCRIPTS;
+import static org.robolectric.res.android.LocaleDataTables.MAX_PARENT_DEPTH;
+import static org.robolectric.res.android.LocaleDataTables.REPRESENTATIVE_LOCALES;
+import static org.robolectric.res.android.LocaleDataTables.SCRIPT_CODES;
+import static org.robolectric.res.android.LocaleDataTables.SCRIPT_PARENTS;
+
+import java.util.Arrays;
+import java.util.Map;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/LocaleData.cpp
+public class LocaleData {
+
+  private static int packLocale(final byte[] language, final byte[] region) {
+    return ((language[0] & 0xff) << 24) | ((language[1] & 0xff) << 16) |
+        ((region[0] & 0xff) << 8) | (region[1] & 0xff);
+  }
+
+  private static int dropRegion(int packed_locale) {
+    return packed_locale & 0xFFFF0000;
+  }
+
+  private static boolean hasRegion(int packed_locale) {
+    return (packed_locale & 0x0000FFFF) != 0;
+  }
+
+  static final int SCRIPT_LENGTH = 4;
+  private static final int PACKED_ROOT = 0; // to represent the root locale
+
+  private static int findParent(int packed_locale, final String script) {
+    if (hasRegion(packed_locale)) {
+      for (Map.Entry<String, Map<Integer, Integer>> entry : SCRIPT_PARENTS.entrySet()) {
+        if (script.equals(entry.getKey())) {
+          Map<Integer, Integer> map = entry.getValue();
+          Integer lookup_result = map.get(packed_locale);
+          if (lookup_result != null) {
+            return lookup_result;
+          }
+          break;
+        }
+      }
+      return dropRegion(packed_locale);
+    }
+    return PACKED_ROOT;
+  }
+
+  // Find the ancestors of a locale, and fill 'out' with it (assumes out has enough
+  // space). If any of the members of stop_list was seen, write it in the
+  // output but stop afterwards.
+  //
+  // This also outputs the index of the last written ancestor in the stop_list
+  // to stop_list_index, which will be -1 if it is not found in the stop_list.
+  //
+  // Returns the number of ancestors written in the output, which is always
+  // at least one.
+  //
+  // (If 'out' is null, we do everything the same way but we simply don't write
+  // any results in 'out'.)
+  static int findAncestors(int[] out, Ref<Long> stop_list_index,
+      int packed_locale, final String script,
+      final int[] stop_list, int stop_set_length) {
+    int ancestor = packed_locale;
+    int count = 0;
+    do {
+      if (out != null) {
+        out[count] = ancestor;
+      }
+      count++;
+      for (int i = 0; i < stop_set_length; i++) {
+        if (stop_list[i] == ancestor) {
+          stop_list_index.set((long) i);
+          return count;
+        }
+      }
+      ancestor = findParent(ancestor, script);
+    } while (ancestor != PACKED_ROOT);
+    stop_list_index.set((long) -1);
+    return count;
+  }
+
+  static int findDistance(int supported,
+      final String script,
+      final int[] request_ancestors,
+      int request_ancestors_count) {
+    final Ref<Long> request_ancestors_indexRef = new Ref<>(null);
+    final int supported_ancestor_count = findAncestors(
+        null, request_ancestors_indexRef,
+        supported, script,
+        request_ancestors, request_ancestors_count);
+    // Since both locales share the same root, there will always be a shared
+    // ancestor, so the distance in the parent tree is the sum of the distance
+    // of 'supported' to the lowest common ancestor (number of ancestors
+    // written for 'supported' minus 1) plus the distance of 'request' to the
+    // lowest common ancestor (the index of the ancestor in request_ancestors).
+    return (int) (supported_ancestor_count + request_ancestors_indexRef.get() - 1);
+  }
+
+  static boolean isRepresentative(int language_and_region, final String script) {
+    final long packed_locale = (
+        (((long) language_and_region) << 32) |
+            (((long) script.charAt(0) & 0xff) << 24) |
+            (((long) script.charAt(1) & 0xff) << 16) |
+            (((long) script.charAt(2) & 0xff) << 8) |
+            ((long) script.charAt(3) & 0xff));
+    return (REPRESENTATIVE_LOCALES.contains(packed_locale));
+  }
+
+  private static final int US_SPANISH = 0x65735553; // es-US
+  private static final int MEXICAN_SPANISH = 0x65734D58; // es-MX
+  private static final int LATIN_AMERICAN_SPANISH = 0x6573A424; // es-419
+
+  // The two locales es-US and es-MX are treated as special fallbacks for es-419.
+// If there is no es-419, they are considered its equivalent.
+  private static boolean isSpecialSpanish(int language_and_region) {
+    return (language_and_region == US_SPANISH || language_and_region == MEXICAN_SPANISH);
+  }
+
+  static int localeDataCompareRegions(
+      final byte[] left_region, final byte[] right_region,
+      final byte[] requested_language, final String requested_script,
+      final byte[] requested_region) {
+    if (left_region[0] == right_region[0] && left_region[1] == right_region[1]) {
+      return 0;
+    }
+    int left = packLocale(requested_language, left_region);
+    int right = packLocale(requested_language, right_region);
+    final int request = packLocale(requested_language, requested_region);
+
+    // If one and only one of the two locales is a special Spanish locale, we
+    // replace it with es-419. We don't do the replacement if the other locale
+    // is already es-419, or both locales are special Spanish locales (when
+    // es-US is being compared to es-MX).
+    final boolean leftIsSpecialSpanish = isSpecialSpanish(left);
+    final boolean rightIsSpecialSpanish = isSpecialSpanish(right);
+    if (leftIsSpecialSpanish && !rightIsSpecialSpanish && right != LATIN_AMERICAN_SPANISH) {
+      left = LATIN_AMERICAN_SPANISH;
+    } else if (rightIsSpecialSpanish && !leftIsSpecialSpanish && left != LATIN_AMERICAN_SPANISH) {
+      right = LATIN_AMERICAN_SPANISH;
+    }
+
+    int[] request_ancestors = new int[MAX_PARENT_DEPTH + 1];
+    final Ref<Long> left_right_indexRef = new Ref<Long>(null);
+    // Find the parents of the request, but stop as soon as we saw left or right
+    final int left_and_right[] = {left, right};
+    final int ancestor_count = findAncestors(
+        request_ancestors, left_right_indexRef,
+        request, requested_script,
+        left_and_right, sizeof(left_and_right));
+    if (left_right_indexRef.get() == 0) { // We saw left earlier
+      return 1;
+    }
+    if (left_right_indexRef.get() == 1) { // We saw right earlier
+      return -1;
+    }
+    // If we are here, neither left nor right are an ancestor of the
+    // request. This means that all the ancestors have been computed and
+    // the last ancestor is just the language by itself. We will use the
+    // distance in the parent tree for determining the better match.
+    final int left_distance = findDistance(
+        left, requested_script, request_ancestors, ancestor_count);
+    final int right_distance = findDistance(
+        right, requested_script, request_ancestors, ancestor_count);
+    if (left_distance != right_distance) {
+      return (int) right_distance - (int) left_distance; // smaller distance is better
+    }
+    // If we are here, left and right are equidistant from the request. We will
+    // try and see if any of them is a representative locale.
+    final boolean left_is_representative = isRepresentative(left, requested_script);
+    final boolean right_is_representative = isRepresentative(right, requested_script);
+    if (left_is_representative != right_is_representative) {
+      return (left_is_representative ? 1 : 0) - (right_is_representative ? 1 : 0);
+    }
+    // We have no way of figuring out which locale is a better match. For
+    // the sake of stability, we consider the locale with the lower region
+    // code (in dictionary order) better, with two-letter codes before
+    // three-digit codes (since two-letter codes are more specific).
+    return right - left;
+  }
+
+  static void localeDataComputeScript(byte[] out, final byte[] language, final byte[] region) {
+    if (language[0] == '\0') {
+//      memset(out, '\0', SCRIPT_LENGTH);
+      Arrays.fill(out, (byte) 0);
+      return;
+    }
+    int lookup_key = packLocale(language, region);
+    Byte lookup_result = LIKELY_SCRIPTS.get(lookup_key);
+    if (lookup_result == null) {
+      // We couldn't find the locale. Let's try without the region
+      if (region[0] != '\0') {
+        lookup_key = dropRegion(lookup_key);
+        lookup_result = LIKELY_SCRIPTS.get(lookup_key);
+        if (lookup_result != null) {
+//          memcpy(out, SCRIPT_CODES[lookup_result.second], SCRIPT_LENGTH);
+          System.arraycopy(SCRIPT_CODES[lookup_result], 0, out, 0, SCRIPT_LENGTH);
+          return;
+        }
+      }
+      // We don't know anything about the locale
+//      memset(out, '\0', SCRIPT_LENGTH);
+      Arrays.fill(out, (byte) 0);
+      return;
+    } else {
+      // We found the locale.
+//      memcpy(out, SCRIPT_CODES[lookup_result.second], SCRIPT_LENGTH);
+      System.arraycopy(SCRIPT_CODES[lookup_result], 0, out, 0, SCRIPT_LENGTH);
+    }
+  }
+
+  static final int[] ENGLISH_STOP_LIST = {
+      0x656E0000, // en
+      0x656E8400, // en-001
+  };
+
+  static final byte[] ENGLISH_CHARS = {'e', 'n'};
+
+  static final String LATIN_CHARS = "Latn";
+
+  static boolean localeDataIsCloseToUsEnglish(final byte[] region) {
+    final int locale = packLocale(ENGLISH_CHARS, region);
+    final Ref<Long> stop_list_indexRef = new Ref<>(null);
+    findAncestors(null, stop_list_indexRef, locale, LATIN_CHARS, ENGLISH_STOP_LIST, 2);
+    // A locale is like US English if we see "en" before "en-001" in its ancestor list.
+    return stop_list_indexRef.get() == 0; // 'en' is first in ENGLISH_STOP_LIST
+  }
+
+
+  private static int sizeof(int[] array) {
+    return array.length;
+  }
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/LocaleDataTables.java b/resources/src/main/java/org/robolectric/res/android/LocaleDataTables.java
new file mode 100644
index 0000000..56c2e62
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/LocaleDataTables.java
@@ -0,0 +1,2387 @@
+package org.robolectric.res.android;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/LocaleDataTables.cpp
+public class LocaleDataTables {
+
+  // Auto-generated by ./tools/localedata/extract_icu_data.py
+  static final byte[][] SCRIPT_CODES = {
+    /* 0  */ {'A', 'h', 'o', 'm'},
+    /* 1  */ {'A', 'r', 'a', 'b'},
+    /* 2  */ {'A', 'r', 'm', 'i'},
+    /* 3  */ {'A', 'r', 'm', 'n'},
+    /* 4  */ {'A', 'v', 's', 't'},
+    /* 5  */ {'B', 'a', 'm', 'u'},
+    /* 6  */ {'B', 'a', 's', 's'},
+    /* 7  */ {'B', 'e', 'n', 'g'},
+    /* 8  */ {'B', 'r', 'a', 'h'},
+    /* 9  */ {'C', 'a', 'n', 's'},
+    /* 10 */ {'C', 'a', 'r', 'i'},
+    /* 11 */ {'C', 'h', 'a', 'm'},
+    /* 12 */ {'C', 'h', 'e', 'r'},
+    /* 13 */ {'C', 'o', 'p', 't'},
+    /* 14 */ {'C', 'p', 'r', 't'},
+    /* 15 */ {'C', 'y', 'r', 'l'},
+    /* 16 */ {'D', 'e', 'v', 'a'},
+    /* 17 */ {'E', 'g', 'y', 'p'},
+    /* 18 */ {'E', 't', 'h', 'i'},
+    /* 19 */ {'G', 'e', 'o', 'r'},
+    /* 20 */ {'G', 'o', 't', 'h'},
+    /* 21 */ {'G', 'r', 'e', 'k'},
+    /* 22 */ {'G', 'u', 'j', 'r'},
+    /* 23 */ {'G', 'u', 'r', 'u'},
+    /* 24 */ {'H', 'a', 'n', 's'},
+    /* 25 */ {'H', 'a', 'n', 't'},
+    /* 26 */ {'H', 'a', 't', 'r'},
+    /* 27 */ {'H', 'e', 'b', 'r'},
+    /* 28 */ {'H', 'l', 'u', 'w'},
+    /* 29 */ {'H', 'm', 'n', 'g'},
+    /* 30 */ {'I', 't', 'a', 'l'},
+    /* 31 */ {'J', 'p', 'a', 'n'},
+    /* 32 */ {'K', 'a', 'l', 'i'},
+    /* 33 */ {'K', 'a', 'n', 'a'},
+    /* 34 */ {'K', 'h', 'a', 'r'},
+    /* 35 */ {'K', 'h', 'm', 'r'},
+    /* 36 */ {'K', 'n', 'd', 'a'},
+    /* 37 */ {'K', 'o', 'r', 'e'},
+    /* 38 */ {'L', 'a', 'n', 'a'},
+    /* 39 */ {'L', 'a', 'o', 'o'},
+    /* 40 */ {'L', 'a', 't', 'n'},
+    /* 41 */ {'L', 'e', 'p', 'c'},
+    /* 42 */ {'L', 'i', 'n', 'a'},
+    /* 43 */ {'L', 'i', 's', 'u'},
+    /* 44 */ {'L', 'y', 'c', 'i'},
+    /* 45 */ {'L', 'y', 'd', 'i'},
+    /* 46 */ {'M', 'a', 'n', 'd'},
+    /* 47 */ {'M', 'a', 'n', 'i'},
+    /* 48 */ {'M', 'e', 'r', 'c'},
+    /* 49 */ {'M', 'l', 'y', 'm'},
+    /* 50 */ {'M', 'o', 'n', 'g'},
+    /* 51 */ {'M', 'r', 'o', 'o'},
+    /* 52 */ {'M', 'y', 'm', 'r'},
+    /* 53 */ {'N', 'a', 'r', 'b'},
+    /* 54 */ {'N', 'k', 'o', 'o'},
+    /* 55 */ {'O', 'g', 'a', 'm'},
+    /* 56 */ {'O', 'r', 'k', 'h'},
+    /* 57 */ {'O', 'r', 'y', 'a'},
+    /* 58 */ {'O', 's', 'g', 'e'},
+    /* 59 */ {'P', 'a', 'u', 'c'},
+    /* 60 */ {'P', 'h', 'l', 'i'},
+    /* 61 */ {'P', 'h', 'n', 'x'},
+    /* 62 */ {'P', 'l', 'r', 'd'},
+    /* 63 */ {'P', 'r', 't', 'i'},
+    /* 64 */ {'R', 'u', 'n', 'r'},
+    /* 65 */ {'S', 'a', 'm', 'r'},
+    /* 66 */ {'S', 'a', 'r', 'b'},
+    /* 67 */ {'S', 'a', 'u', 'r'},
+    /* 68 */ {'S', 'g', 'n', 'w'},
+    /* 69 */ {'S', 'i', 'n', 'h'},
+    /* 70 */ {'S', 'o', 'r', 'a'},
+    /* 71 */ {'S', 'y', 'r', 'c'},
+    /* 72 */ {'T', 'a', 'l', 'e'},
+    /* 73 */ {'T', 'a', 'l', 'u'},
+    /* 74 */ {'T', 'a', 'm', 'l'},
+    /* 75 */ {'T', 'a', 'n', 'g'},
+    /* 76 */ {'T', 'a', 'v', 't'},
+    /* 77 */ {'T', 'e', 'l', 'u'},
+    /* 78 */ {'T', 'f', 'n', 'g'},
+    /* 79 */ {'T', 'h', 'a', 'a'},
+    /* 80 */ {'T', 'h', 'a', 'i'},
+    /* 81 */ {'T', 'i', 'b', 't'},
+    /* 82 */ {'U', 'g', 'a', 'r'},
+    /* 83 */ {'V', 'a', 'i', 'i'},
+    /* 84 */ {'X', 'p', 'e', 'o'},
+    /* 85 */ {'X', 's', 'u', 'x'},
+    /* 86 */ {'Y', 'i', 'i', 'i'},
+    /* 87 */ {'~', '~', '~', 'A'},
+    /* 88 */ {'~', '~', '~', 'B'},
+  };
+
+  static final Map<Integer, Byte> LIKELY_SCRIPTS;
+
+  static {
+    int[][] entries = {
+        {0x61610000, 40}, // aa -> Latn
+        {0xA0000000, 40}, // aai -> Latn
+        {0xA8000000, 40}, // aak -> Latn
+        {0xD0000000, 40}, // aau -> Latn
+        {0x61620000, 15}, // ab -> Cyrl
+        {0xA0200000, 40}, // abi -> Latn
+        {0xC4200000, 40}, // abr -> Latn
+        {0xCC200000, 40}, // abt -> Latn
+        {0xE0200000, 40}, // aby -> Latn
+        {0x8C400000, 40}, // acd -> Latn
+        {0x90400000, 40}, // ace -> Latn
+        {0x9C400000, 40}, // ach -> Latn
+        {0x80600000, 40}, // ada -> Latn
+        {0x90600000, 40}, // ade -> Latn
+        {0xA4600000, 40}, // adj -> Latn
+        {0xE0600000, 15}, // ady -> Cyrl
+        {0xE4600000, 40}, // adz -> Latn
+        {0x61650000,  4}, // ae -> Avst
+        {0x84800000,  1}, // aeb -> Arab
+        {0xE0800000, 40}, // aey -> Latn
+        {0x61660000, 40}, // af -> Latn
+        {0x88C00000, 40}, // agc -> Latn
+        {0x8CC00000, 40}, // agd -> Latn
+        {0x98C00000, 40}, // agg -> Latn
+        {0xB0C00000, 40}, // agm -> Latn
+        {0xB8C00000, 40}, // ago -> Latn
+        {0xC0C00000, 40}, // agq -> Latn
+        {0x80E00000, 40}, // aha -> Latn
+        {0xACE00000, 40}, // ahl -> Latn
+        {0xB8E00000,  0}, // aho -> Ahom
+        {0x99200000, 40}, // ajg -> Latn
+        {0x616B0000, 40}, // ak -> Latn
+        {0xA9400000, 85}, // akk -> Xsux
+        {0x81600000, 40}, // ala -> Latn
+        {0xA1600000, 40}, // ali -> Latn
+        {0xB5600000, 40}, // aln -> Latn
+        {0xCD600000, 15}, // alt -> Cyrl
+        {0x616D0000, 18}, // am -> Ethi
+        {0xB1800000, 40}, // amm -> Latn
+        {0xB5800000, 40}, // amn -> Latn
+        {0xB9800000, 40}, // amo -> Latn
+        {0xBD800000, 40}, // amp -> Latn
+        {0x89A00000, 40}, // anc -> Latn
+        {0xA9A00000, 40}, // ank -> Latn
+        {0xB5A00000, 40}, // ann -> Latn
+        {0xE1A00000, 40}, // any -> Latn
+        {0xA5C00000, 40}, // aoj -> Latn
+        {0xB1C00000, 40}, // aom -> Latn
+        {0xE5C00000, 40}, // aoz -> Latn
+        {0x89E00000,  1}, // apc -> Arab
+        {0x8DE00000,  1}, // apd -> Arab
+        {0x91E00000, 40}, // ape -> Latn
+        {0xC5E00000, 40}, // apr -> Latn
+        {0xC9E00000, 40}, // aps -> Latn
+        {0xE5E00000, 40}, // apz -> Latn
+        {0x61720000,  1}, // ar -> Arab
+        {0x61725842, 88}, // ar-XB -> ~~~B
+        {0x8A200000,  2}, // arc -> Armi
+        {0x9E200000, 40}, // arh -> Latn
+        {0xB6200000, 40}, // arn -> Latn
+        {0xBA200000, 40}, // aro -> Latn
+        {0xC2200000,  1}, // arq -> Arab
+        {0xE2200000,  1}, // ary -> Arab
+        {0xE6200000,  1}, // arz -> Arab
+        {0x61730000,  7}, // as -> Beng
+        {0x82400000, 40}, // asa -> Latn
+        {0x92400000, 68}, // ase -> Sgnw
+        {0x9A400000, 40}, // asg -> Latn
+        {0xBA400000, 40}, // aso -> Latn
+        {0xCE400000, 40}, // ast -> Latn
+        {0x82600000, 40}, // ata -> Latn
+        {0x9A600000, 40}, // atg -> Latn
+        {0xA6600000, 40}, // atj -> Latn
+        {0xE2800000, 40}, // auy -> Latn
+        {0x61760000, 15}, // av -> Cyrl
+        {0xAEA00000,  1}, // avl -> Arab
+        {0xB6A00000, 40}, // avn -> Latn
+        {0xCEA00000, 40}, // avt -> Latn
+        {0xD2A00000, 40}, // avu -> Latn
+        {0x82C00000, 16}, // awa -> Deva
+        {0x86C00000, 40}, // awb -> Latn
+        {0xBAC00000, 40}, // awo -> Latn
+        {0xDEC00000, 40}, // awx -> Latn
+        {0x61790000, 40}, // ay -> Latn
+        {0x87000000, 40}, // ayb -> Latn
+        {0x617A0000, 40}, // az -> Latn
+        {0x617A4951,  1}, // az-IQ -> Arab
+        {0x617A4952,  1}, // az-IR -> Arab
+        {0x617A5255, 15}, // az-RU -> Cyrl
+        {0x62610000, 15}, // ba -> Cyrl
+        {0xAC010000,  1}, // bal -> Arab
+        {0xB4010000, 40}, // ban -> Latn
+        {0xBC010000, 16}, // bap -> Deva
+        {0xC4010000, 40}, // bar -> Latn
+        {0xC8010000, 40}, // bas -> Latn
+        {0xD4010000, 40}, // bav -> Latn
+        {0xDC010000,  5}, // bax -> Bamu
+        {0x80210000, 40}, // bba -> Latn
+        {0x84210000, 40}, // bbb -> Latn
+        {0x88210000, 40}, // bbc -> Latn
+        {0x8C210000, 40}, // bbd -> Latn
+        {0xA4210000, 40}, // bbj -> Latn
+        {0xBC210000, 40}, // bbp -> Latn
+        {0xC4210000, 40}, // bbr -> Latn
+        {0x94410000, 40}, // bcf -> Latn
+        {0x9C410000, 40}, // bch -> Latn
+        {0xA0410000, 40}, // bci -> Latn
+        {0xB0410000, 40}, // bcm -> Latn
+        {0xB4410000, 40}, // bcn -> Latn
+        {0xB8410000, 40}, // bco -> Latn
+        {0xC0410000, 18}, // bcq -> Ethi
+        {0xD0410000, 40}, // bcu -> Latn
+        {0x8C610000, 40}, // bdd -> Latn
+        {0x62650000, 15}, // be -> Cyrl
+        {0x94810000, 40}, // bef -> Latn
+        {0x9C810000, 40}, // beh -> Latn
+        {0xA4810000,  1}, // bej -> Arab
+        {0xB0810000, 40}, // bem -> Latn
+        {0xCC810000, 40}, // bet -> Latn
+        {0xD8810000, 40}, // bew -> Latn
+        {0xDC810000, 40}, // bex -> Latn
+        {0xE4810000, 40}, // bez -> Latn
+        {0x8CA10000, 40}, // bfd -> Latn
+        {0xC0A10000, 74}, // bfq -> Taml
+        {0xCCA10000,  1}, // bft -> Arab
+        {0xE0A10000, 16}, // bfy -> Deva
+        {0x62670000, 15}, // bg -> Cyrl
+        {0x88C10000, 16}, // bgc -> Deva
+        {0xB4C10000,  1}, // bgn -> Arab
+        {0xDCC10000, 21}, // bgx -> Grek
+        {0x84E10000, 16}, // bhb -> Deva
+        {0x98E10000, 40}, // bhg -> Latn
+        {0xA0E10000, 16}, // bhi -> Deva
+        {0xA8E10000, 40}, // bhk -> Latn
+        {0xACE10000, 40}, // bhl -> Latn
+        {0xB8E10000, 16}, // bho -> Deva
+        {0xE0E10000, 40}, // bhy -> Latn
+        {0x62690000, 40}, // bi -> Latn
+        {0x85010000, 40}, // bib -> Latn
+        {0x99010000, 40}, // big -> Latn
+        {0xA9010000, 40}, // bik -> Latn
+        {0xB1010000, 40}, // bim -> Latn
+        {0xB5010000, 40}, // bin -> Latn
+        {0xB9010000, 40}, // bio -> Latn
+        {0xC1010000, 40}, // biq -> Latn
+        {0x9D210000, 40}, // bjh -> Latn
+        {0xA1210000, 18}, // bji -> Ethi
+        {0xA5210000, 16}, // bjj -> Deva
+        {0xB5210000, 40}, // bjn -> Latn
+        {0xB9210000, 40}, // bjo -> Latn
+        {0xC5210000, 40}, // bjr -> Latn
+        {0xE5210000, 40}, // bjz -> Latn
+        {0x89410000, 40}, // bkc -> Latn
+        {0xB1410000, 40}, // bkm -> Latn
+        {0xC1410000, 40}, // bkq -> Latn
+        {0xD1410000, 40}, // bku -> Latn
+        {0xD5410000, 40}, // bkv -> Latn
+        {0xCD610000, 76}, // blt -> Tavt
+        {0x626D0000, 40}, // bm -> Latn
+        {0x9D810000, 40}, // bmh -> Latn
+        {0xA9810000, 40}, // bmk -> Latn
+        {0xC1810000, 40}, // bmq -> Latn
+        {0xD1810000, 40}, // bmu -> Latn
+        {0x626E0000,  7}, // bn -> Beng
+        {0x99A10000, 40}, // bng -> Latn
+        {0xB1A10000, 40}, // bnm -> Latn
+        {0xBDA10000, 40}, // bnp -> Latn
+        {0x626F0000, 81}, // bo -> Tibt
+        {0xA5C10000, 40}, // boj -> Latn
+        {0xB1C10000, 40}, // bom -> Latn
+        {0xB5C10000, 40}, // bon -> Latn
+        {0xE1E10000,  7}, // bpy -> Beng
+        {0x8A010000, 40}, // bqc -> Latn
+        {0xA2010000,  1}, // bqi -> Arab
+        {0xBE010000, 40}, // bqp -> Latn
+        {0xD6010000, 40}, // bqv -> Latn
+        {0x62720000, 40}, // br -> Latn
+        {0x82210000, 16}, // bra -> Deva
+        {0x9E210000,  1}, // brh -> Arab
+        {0xDE210000, 16}, // brx -> Deva
+        {0xE6210000, 40}, // brz -> Latn
+        {0x62730000, 40}, // bs -> Latn
+        {0xA6410000, 40}, // bsj -> Latn
+        {0xC2410000,  6}, // bsq -> Bass
+        {0xCA410000, 40}, // bss -> Latn
+        {0xCE410000, 18}, // bst -> Ethi
+        {0xBA610000, 40}, // bto -> Latn
+        {0xCE610000, 40}, // btt -> Latn
+        {0xD6610000, 16}, // btv -> Deva
+        {0x82810000, 15}, // bua -> Cyrl
+        {0x8A810000, 40}, // buc -> Latn
+        {0x8E810000, 40}, // bud -> Latn
+        {0x9A810000, 40}, // bug -> Latn
+        {0xAA810000, 40}, // buk -> Latn
+        {0xB2810000, 40}, // bum -> Latn
+        {0xBA810000, 40}, // buo -> Latn
+        {0xCA810000, 40}, // bus -> Latn
+        {0xD2810000, 40}, // buu -> Latn
+        {0x86A10000, 40}, // bvb -> Latn
+        {0x8EC10000, 40}, // bwd -> Latn
+        {0xC6C10000, 40}, // bwr -> Latn
+        {0x9EE10000, 40}, // bxh -> Latn
+        {0x93010000, 40}, // bye -> Latn
+        {0xB7010000, 18}, // byn -> Ethi
+        {0xC7010000, 40}, // byr -> Latn
+        {0xCB010000, 40}, // bys -> Latn
+        {0xD7010000, 40}, // byv -> Latn
+        {0xDF010000, 40}, // byx -> Latn
+        {0x83210000, 40}, // bza -> Latn
+        {0x93210000, 40}, // bze -> Latn
+        {0x97210000, 40}, // bzf -> Latn
+        {0x9F210000, 40}, // bzh -> Latn
+        {0xDB210000, 40}, // bzw -> Latn
+        {0x63610000, 40}, // ca -> Latn
+        {0xB4020000, 40}, // can -> Latn
+        {0xA4220000, 40}, // cbj -> Latn
+        {0x9C420000, 40}, // cch -> Latn
+        {0xBC420000,  7}, // ccp -> Beng
+        {0x63650000, 15}, // ce -> Cyrl
+        {0x84820000, 40}, // ceb -> Latn
+        {0x80A20000, 40}, // cfa -> Latn
+        {0x98C20000, 40}, // cgg -> Latn
+        {0x63680000, 40}, // ch -> Latn
+        {0xA8E20000, 40}, // chk -> Latn
+        {0xB0E20000, 15}, // chm -> Cyrl
+        {0xB8E20000, 40}, // cho -> Latn
+        {0xBCE20000, 40}, // chp -> Latn
+        {0xC4E20000, 12}, // chr -> Cher
+        {0x81220000,  1}, // cja -> Arab
+        {0xB1220000, 11}, // cjm -> Cham
+        {0xD5220000, 40}, // cjv -> Latn
+        {0x85420000,  1}, // ckb -> Arab
+        {0xAD420000, 40}, // ckl -> Latn
+        {0xB9420000, 40}, // cko -> Latn
+        {0xE1420000, 40}, // cky -> Latn
+        {0x81620000, 40}, // cla -> Latn
+        {0x91820000, 40}, // cme -> Latn
+        {0x636F0000, 40}, // co -> Latn
+        {0xBDC20000, 13}, // cop -> Copt
+        {0xC9E20000, 40}, // cps -> Latn
+        {0x63720000,  9}, // cr -> Cans
+        {0xA6220000,  9}, // crj -> Cans
+        {0xAA220000,  9}, // crk -> Cans
+        {0xAE220000,  9}, // crl -> Cans
+        {0xB2220000,  9}, // crm -> Cans
+        {0xCA220000, 40}, // crs -> Latn
+        {0x63730000, 40}, // cs -> Latn
+        {0x86420000, 40}, // csb -> Latn
+        {0xDA420000,  9}, // csw -> Cans
+        {0x8E620000, 59}, // ctd -> Pauc
+        {0x63750000, 15}, // cu -> Cyrl
+        {0x63760000, 15}, // cv -> Cyrl
+        {0x63790000, 40}, // cy -> Latn
+        {0x64610000, 40}, // da -> Latn
+        {0x8C030000, 40}, // dad -> Latn
+        {0x94030000, 40}, // daf -> Latn
+        {0x98030000, 40}, // dag -> Latn
+        {0x9C030000, 40}, // dah -> Latn
+        {0xA8030000, 40}, // dak -> Latn
+        {0xC4030000, 15}, // dar -> Cyrl
+        {0xD4030000, 40}, // dav -> Latn
+        {0x8C230000, 40}, // dbd -> Latn
+        {0xC0230000, 40}, // dbq -> Latn
+        {0x88430000,  1}, // dcc -> Arab
+        {0xB4630000, 40}, // ddn -> Latn
+        {0x64650000, 40}, // de -> Latn
+        {0x8C830000, 40}, // ded -> Latn
+        {0xB4830000, 40}, // den -> Latn
+        {0x80C30000, 40}, // dga -> Latn
+        {0x9CC30000, 40}, // dgh -> Latn
+        {0xA0C30000, 40}, // dgi -> Latn
+        {0xACC30000,  1}, // dgl -> Arab
+        {0xC4C30000, 40}, // dgr -> Latn
+        {0xE4C30000, 40}, // dgz -> Latn
+        {0x81030000, 40}, // dia -> Latn
+        {0x91230000, 40}, // dje -> Latn
+        {0xA5A30000, 40}, // dnj -> Latn
+        {0x85C30000, 40}, // dob -> Latn
+        {0xA1C30000,  1}, // doi -> Arab
+        {0xBDC30000, 40}, // dop -> Latn
+        {0xD9C30000, 40}, // dow -> Latn
+        {0xA2230000, 40}, // dri -> Latn
+        {0xCA230000, 18}, // drs -> Ethi
+        {0x86430000, 40}, // dsb -> Latn
+        {0xB2630000, 40}, // dtm -> Latn
+        {0xBE630000, 40}, // dtp -> Latn
+        {0xCA630000, 40}, // dts -> Latn
+        {0xE2630000, 16}, // dty -> Deva
+        {0x82830000, 40}, // dua -> Latn
+        {0x8A830000, 40}, // duc -> Latn
+        {0x8E830000, 40}, // dud -> Latn
+        {0x9A830000, 40}, // dug -> Latn
+        {0x64760000, 79}, // dv -> Thaa
+        {0x82A30000, 40}, // dva -> Latn
+        {0xDAC30000, 40}, // dww -> Latn
+        {0xBB030000, 40}, // dyo -> Latn
+        {0xD3030000, 40}, // dyu -> Latn
+        {0x647A0000, 81}, // dz -> Tibt
+        {0x9B230000, 40}, // dzg -> Latn
+        {0xD0240000, 40}, // ebu -> Latn
+        {0x65650000, 40}, // ee -> Latn
+        {0xA0A40000, 40}, // efi -> Latn
+        {0xACC40000, 40}, // egl -> Latn
+        {0xE0C40000, 17}, // egy -> Egyp
+        {0xE1440000, 32}, // eky -> Kali
+        {0x656C0000, 21}, // el -> Grek
+        {0x81840000, 40}, // ema -> Latn
+        {0xA1840000, 40}, // emi -> Latn
+        {0x656E0000, 40}, // en -> Latn
+        {0x656E5841, 87}, // en-XA -> ~~~A
+        {0xB5A40000, 40}, // enn -> Latn
+        {0xC1A40000, 40}, // enq -> Latn
+        {0x656F0000, 40}, // eo -> Latn
+        {0xA2240000, 40}, // eri -> Latn
+        {0x65730000, 40}, // es -> Latn
+        {0xD2440000, 40}, // esu -> Latn
+        {0x65740000, 40}, // et -> Latn
+        {0xC6640000, 40}, // etr -> Latn
+        {0xCE640000, 30}, // ett -> Ital
+        {0xD2640000, 40}, // etu -> Latn
+        {0xDE640000, 40}, // etx -> Latn
+        {0x65750000, 40}, // eu -> Latn
+        {0xBAC40000, 40}, // ewo -> Latn
+        {0xCEE40000, 40}, // ext -> Latn
+        {0x66610000,  1}, // fa -> Arab
+        {0x80050000, 40}, // faa -> Latn
+        {0x84050000, 40}, // fab -> Latn
+        {0x98050000, 40}, // fag -> Latn
+        {0xA0050000, 40}, // fai -> Latn
+        {0xB4050000, 40}, // fan -> Latn
+        {0x66660000, 40}, // ff -> Latn
+        {0xA0A50000, 40}, // ffi -> Latn
+        {0xB0A50000, 40}, // ffm -> Latn
+        {0x66690000, 40}, // fi -> Latn
+        {0x81050000,  1}, // fia -> Arab
+        {0xAD050000, 40}, // fil -> Latn
+        {0xCD050000, 40}, // fit -> Latn
+        {0x666A0000, 40}, // fj -> Latn
+        {0xC5650000, 40}, // flr -> Latn
+        {0xBD850000, 40}, // fmp -> Latn
+        {0x666F0000, 40}, // fo -> Latn
+        {0x8DC50000, 40}, // fod -> Latn
+        {0xB5C50000, 40}, // fon -> Latn
+        {0xC5C50000, 40}, // for -> Latn
+        {0x91E50000, 40}, // fpe -> Latn
+        {0xCA050000, 40}, // fqs -> Latn
+        {0x66720000, 40}, // fr -> Latn
+        {0x8A250000, 40}, // frc -> Latn
+        {0xBE250000, 40}, // frp -> Latn
+        {0xC6250000, 40}, // frr -> Latn
+        {0xCA250000, 40}, // frs -> Latn
+        {0x86850000,  1}, // fub -> Arab
+        {0x8E850000, 40}, // fud -> Latn
+        {0x92850000, 40}, // fue -> Latn
+        {0x96850000, 40}, // fuf -> Latn
+        {0x9E850000, 40}, // fuh -> Latn
+        {0xC2850000, 40}, // fuq -> Latn
+        {0xC6850000, 40}, // fur -> Latn
+        {0xD6850000, 40}, // fuv -> Latn
+        {0xE2850000, 40}, // fuy -> Latn
+        {0xC6A50000, 40}, // fvr -> Latn
+        {0x66790000, 40}, // fy -> Latn
+        {0x67610000, 40}, // ga -> Latn
+        {0x80060000, 40}, // gaa -> Latn
+        {0x94060000, 40}, // gaf -> Latn
+        {0x98060000, 40}, // gag -> Latn
+        {0x9C060000, 40}, // gah -> Latn
+        {0xA4060000, 40}, // gaj -> Latn
+        {0xB0060000, 40}, // gam -> Latn
+        {0xB4060000, 24}, // gan -> Hans
+        {0xD8060000, 40}, // gaw -> Latn
+        {0xE0060000, 40}, // gay -> Latn
+        {0x94260000, 40}, // gbf -> Latn
+        {0xB0260000, 16}, // gbm -> Deva
+        {0xE0260000, 40}, // gby -> Latn
+        {0xE4260000,  1}, // gbz -> Arab
+        {0xC4460000, 40}, // gcr -> Latn
+        {0x67640000, 40}, // gd -> Latn
+        {0x90660000, 40}, // gde -> Latn
+        {0xB4660000, 40}, // gdn -> Latn
+        {0xC4660000, 40}, // gdr -> Latn
+        {0x84860000, 40}, // geb -> Latn
+        {0xA4860000, 40}, // gej -> Latn
+        {0xAC860000, 40}, // gel -> Latn
+        {0xE4860000, 18}, // gez -> Ethi
+        {0xA8A60000, 40}, // gfk -> Latn
+        {0xB4C60000, 16}, // ggn -> Deva
+        {0xC8E60000, 40}, // ghs -> Latn
+        {0xAD060000, 40}, // gil -> Latn
+        {0xB1060000, 40}, // gim -> Latn
+        {0xA9260000,  1}, // gjk -> Arab
+        {0xB5260000, 40}, // gjn -> Latn
+        {0xD1260000,  1}, // gju -> Arab
+        {0xB5460000, 40}, // gkn -> Latn
+        {0xBD460000, 40}, // gkp -> Latn
+        {0x676C0000, 40}, // gl -> Latn
+        {0xA9660000,  1}, // glk -> Arab
+        {0xB1860000, 40}, // gmm -> Latn
+        {0xD5860000, 18}, // gmv -> Ethi
+        {0x676E0000, 40}, // gn -> Latn
+        {0x8DA60000, 40}, // gnd -> Latn
+        {0x99A60000, 40}, // gng -> Latn
+        {0x8DC60000, 40}, // god -> Latn
+        {0x95C60000, 18}, // gof -> Ethi
+        {0xA1C60000, 40}, // goi -> Latn
+        {0xB1C60000, 16}, // gom -> Deva
+        {0xB5C60000, 77}, // gon -> Telu
+        {0xC5C60000, 40}, // gor -> Latn
+        {0xC9C60000, 40}, // gos -> Latn
+        {0xCDC60000, 20}, // got -> Goth
+        {0x8A260000, 14}, // grc -> Cprt
+        {0xCE260000,  7}, // grt -> Beng
+        {0xDA260000, 40}, // grw -> Latn
+        {0xDA460000, 40}, // gsw -> Latn
+        {0x67750000, 22}, // gu -> Gujr
+        {0x86860000, 40}, // gub -> Latn
+        {0x8A860000, 40}, // guc -> Latn
+        {0x8E860000, 40}, // gud -> Latn
+        {0xC6860000, 40}, // gur -> Latn
+        {0xDA860000, 40}, // guw -> Latn
+        {0xDE860000, 40}, // gux -> Latn
+        {0xE6860000, 40}, // guz -> Latn
+        {0x67760000, 40}, // gv -> Latn
+        {0x96A60000, 40}, // gvf -> Latn
+        {0xC6A60000, 16}, // gvr -> Deva
+        {0xCAA60000, 40}, // gvs -> Latn
+        {0x8AC60000,  1}, // gwc -> Arab
+        {0xA2C60000, 40}, // gwi -> Latn
+        {0xCEC60000,  1}, // gwt -> Arab
+        {0xA3060000, 40}, // gyi -> Latn
+        {0x68610000, 40}, // ha -> Latn
+        {0x6861434D,  1}, // ha-CM -> Arab
+        {0x68615344,  1}, // ha-SD -> Arab
+        {0x98070000, 40}, // hag -> Latn
+        {0xA8070000, 24}, // hak -> Hans
+        {0xB0070000, 40}, // ham -> Latn
+        {0xD8070000, 40}, // haw -> Latn
+        {0xE4070000,  1}, // haz -> Arab
+        {0x84270000, 40}, // hbb -> Latn
+        {0xE0670000, 18}, // hdy -> Ethi
+        {0x68650000, 27}, // he -> Hebr
+        {0xE0E70000, 40}, // hhy -> Latn
+        {0x68690000, 16}, // hi -> Deva
+        {0x81070000, 40}, // hia -> Latn
+        {0x95070000, 40}, // hif -> Latn
+        {0x99070000, 40}, // hig -> Latn
+        {0x9D070000, 40}, // hih -> Latn
+        {0xAD070000, 40}, // hil -> Latn
+        {0x81670000, 40}, // hla -> Latn
+        {0xD1670000, 28}, // hlu -> Hluw
+        {0x8D870000, 62}, // hmd -> Plrd
+        {0xCD870000, 40}, // hmt -> Latn
+        {0x8DA70000,  1}, // hnd -> Arab
+        {0x91A70000, 16}, // hne -> Deva
+        {0xA5A70000, 29}, // hnj -> Hmng
+        {0xB5A70000, 40}, // hnn -> Latn
+        {0xB9A70000,  1}, // hno -> Arab
+        {0x686F0000, 40}, // ho -> Latn
+        {0x89C70000, 16}, // hoc -> Deva
+        {0xA5C70000, 16}, // hoj -> Deva
+        {0xCDC70000, 40}, // hot -> Latn
+        {0x68720000, 40}, // hr -> Latn
+        {0x86470000, 40}, // hsb -> Latn
+        {0xB6470000, 24}, // hsn -> Hans
+        {0x68740000, 40}, // ht -> Latn
+        {0x68750000, 40}, // hu -> Latn
+        {0xA2870000, 40}, // hui -> Latn
+        {0x68790000,  3}, // hy -> Armn
+        {0x687A0000, 40}, // hz -> Latn
+        {0x69610000, 40}, // ia -> Latn
+        {0xB4080000, 40}, // ian -> Latn
+        {0xC4080000, 40}, // iar -> Latn
+        {0x80280000, 40}, // iba -> Latn
+        {0x84280000, 40}, // ibb -> Latn
+        {0xE0280000, 40}, // iby -> Latn
+        {0x80480000, 40}, // ica -> Latn
+        {0x9C480000, 40}, // ich -> Latn
+        {0x69640000, 40}, // id -> Latn
+        {0x8C680000, 40}, // idd -> Latn
+        {0xA0680000, 40}, // idi -> Latn
+        {0xD0680000, 40}, // idu -> Latn
+        {0x69670000, 40}, // ig -> Latn
+        {0x84C80000, 40}, // igb -> Latn
+        {0x90C80000, 40}, // ige -> Latn
+        {0x69690000, 86}, // ii -> Yiii
+        {0xA5280000, 40}, // ijj -> Latn
+        {0x696B0000, 40}, // ik -> Latn
+        {0xA9480000, 40}, // ikk -> Latn
+        {0xCD480000, 40}, // ikt -> Latn
+        {0xD9480000, 40}, // ikw -> Latn
+        {0xDD480000, 40}, // ikx -> Latn
+        {0xB9680000, 40}, // ilo -> Latn
+        {0xB9880000, 40}, // imo -> Latn
+        {0x696E0000, 40}, // in -> Latn
+        {0x9DA80000, 15}, // inh -> Cyrl
+        {0xD1C80000, 40}, // iou -> Latn
+        {0xA2280000, 40}, // iri -> Latn
+        {0x69730000, 40}, // is -> Latn
+        {0x69740000, 40}, // it -> Latn
+        {0x69750000,  9}, // iu -> Cans
+        {0x69770000, 27}, // iw -> Hebr
+        {0xB2C80000, 40}, // iwm -> Latn
+        {0xCAC80000, 40}, // iws -> Latn
+        {0x9F280000, 40}, // izh -> Latn
+        {0xA3280000, 40}, // izi -> Latn
+        {0x6A610000, 31}, // ja -> Jpan
+        {0x84090000, 40}, // jab -> Latn
+        {0xB0090000, 40}, // jam -> Latn
+        {0xD0290000, 40}, // jbu -> Latn
+        {0xB4890000, 40}, // jen -> Latn
+        {0xA8C90000, 40}, // jgk -> Latn
+        {0xB8C90000, 40}, // jgo -> Latn
+        {0x6A690000, 27}, // ji -> Hebr
+        {0x85090000, 40}, // jib -> Latn
+        {0x89890000, 40}, // jmc -> Latn
+        {0xAD890000, 16}, // jml -> Deva
+        {0x82290000, 40}, // jra -> Latn
+        {0xCE890000, 40}, // jut -> Latn
+        {0x6A760000, 40}, // jv -> Latn
+        {0x6A770000, 40}, // jw -> Latn
+        {0x6B610000, 19}, // ka -> Geor
+        {0x800A0000, 15}, // kaa -> Cyrl
+        {0x840A0000, 40}, // kab -> Latn
+        {0x880A0000, 40}, // kac -> Latn
+        {0x8C0A0000, 40}, // kad -> Latn
+        {0xA00A0000, 40}, // kai -> Latn
+        {0xA40A0000, 40}, // kaj -> Latn
+        {0xB00A0000, 40}, // kam -> Latn
+        {0xB80A0000, 40}, // kao -> Latn
+        {0x8C2A0000, 15}, // kbd -> Cyrl
+        {0xB02A0000, 40}, // kbm -> Latn
+        {0xBC2A0000, 40}, // kbp -> Latn
+        {0xC02A0000, 40}, // kbq -> Latn
+        {0xDC2A0000, 40}, // kbx -> Latn
+        {0xE02A0000,  1}, // kby -> Arab
+        {0x984A0000, 40}, // kcg -> Latn
+        {0xA84A0000, 40}, // kck -> Latn
+        {0xAC4A0000, 40}, // kcl -> Latn
+        {0xCC4A0000, 40}, // kct -> Latn
+        {0x906A0000, 40}, // kde -> Latn
+        {0x9C6A0000,  1}, // kdh -> Arab
+        {0xAC6A0000, 40}, // kdl -> Latn
+        {0xCC6A0000, 80}, // kdt -> Thai
+        {0x808A0000, 40}, // kea -> Latn
+        {0xB48A0000, 40}, // ken -> Latn
+        {0xE48A0000, 40}, // kez -> Latn
+        {0xB8AA0000, 40}, // kfo -> Latn
+        {0xC4AA0000, 16}, // kfr -> Deva
+        {0xE0AA0000, 16}, // kfy -> Deva
+        {0x6B670000, 40}, // kg -> Latn
+        {0x90CA0000, 40}, // kge -> Latn
+        {0x94CA0000, 40}, // kgf -> Latn
+        {0xBCCA0000, 40}, // kgp -> Latn
+        {0x80EA0000, 40}, // kha -> Latn
+        {0x84EA0000, 73}, // khb -> Talu
+        {0xB4EA0000, 16}, // khn -> Deva
+        {0xC0EA0000, 40}, // khq -> Latn
+        {0xC8EA0000, 40}, // khs -> Latn
+        {0xCCEA0000, 52}, // kht -> Mymr
+        {0xD8EA0000,  1}, // khw -> Arab
+        {0xE4EA0000, 40}, // khz -> Latn
+        {0x6B690000, 40}, // ki -> Latn
+        {0xA50A0000, 40}, // kij -> Latn
+        {0xD10A0000, 40}, // kiu -> Latn
+        {0xD90A0000, 40}, // kiw -> Latn
+        {0x6B6A0000, 40}, // kj -> Latn
+        {0x8D2A0000, 40}, // kjd -> Latn
+        {0x992A0000, 39}, // kjg -> Laoo
+        {0xC92A0000, 40}, // kjs -> Latn
+        {0xE12A0000, 40}, // kjy -> Latn
+        {0x6B6B0000, 15}, // kk -> Cyrl
+        {0x6B6B4146,  1}, // kk-AF -> Arab
+        {0x6B6B434E,  1}, // kk-CN -> Arab
+        {0x6B6B4952,  1}, // kk-IR -> Arab
+        {0x6B6B4D4E,  1}, // kk-MN -> Arab
+        {0x894A0000, 40}, // kkc -> Latn
+        {0xA54A0000, 40}, // kkj -> Latn
+        {0x6B6C0000, 40}, // kl -> Latn
+        {0xB56A0000, 40}, // kln -> Latn
+        {0xC16A0000, 40}, // klq -> Latn
+        {0xCD6A0000, 40}, // klt -> Latn
+        {0xDD6A0000, 40}, // klx -> Latn
+        {0x6B6D0000, 35}, // km -> Khmr
+        {0x858A0000, 40}, // kmb -> Latn
+        {0x9D8A0000, 40}, // kmh -> Latn
+        {0xB98A0000, 40}, // kmo -> Latn
+        {0xC98A0000, 40}, // kms -> Latn
+        {0xD18A0000, 40}, // kmu -> Latn
+        {0xD98A0000, 40}, // kmw -> Latn
+        {0x6B6E0000, 36}, // kn -> Knda
+        {0xBDAA0000, 40}, // knp -> Latn
+        {0x6B6F0000, 37}, // ko -> Kore
+        {0xA1CA0000, 15}, // koi -> Cyrl
+        {0xA9CA0000, 16}, // kok -> Deva
+        {0xADCA0000, 40}, // kol -> Latn
+        {0xC9CA0000, 40}, // kos -> Latn
+        {0xE5CA0000, 40}, // koz -> Latn
+        {0x91EA0000, 40}, // kpe -> Latn
+        {0x95EA0000, 40}, // kpf -> Latn
+        {0xB9EA0000, 40}, // kpo -> Latn
+        {0xC5EA0000, 40}, // kpr -> Latn
+        {0xDDEA0000, 40}, // kpx -> Latn
+        {0x860A0000, 40}, // kqb -> Latn
+        {0x960A0000, 40}, // kqf -> Latn
+        {0xCA0A0000, 40}, // kqs -> Latn
+        {0xE20A0000, 18}, // kqy -> Ethi
+        {0x8A2A0000, 15}, // krc -> Cyrl
+        {0xA22A0000, 40}, // kri -> Latn
+        {0xA62A0000, 40}, // krj -> Latn
+        {0xAE2A0000, 40}, // krl -> Latn
+        {0xCA2A0000, 40}, // krs -> Latn
+        {0xD22A0000, 16}, // kru -> Deva
+        {0x6B730000,  1}, // ks -> Arab
+        {0x864A0000, 40}, // ksb -> Latn
+        {0x8E4A0000, 40}, // ksd -> Latn
+        {0x964A0000, 40}, // ksf -> Latn
+        {0x9E4A0000, 40}, // ksh -> Latn
+        {0xA64A0000, 40}, // ksj -> Latn
+        {0xC64A0000, 40}, // ksr -> Latn
+        {0x866A0000, 18}, // ktb -> Ethi
+        {0xB26A0000, 40}, // ktm -> Latn
+        {0xBA6A0000, 40}, // kto -> Latn
+        {0x6B750000, 40}, // ku -> Latn
+        {0x6B754952,  1}, // ku-IR -> Arab
+        {0x6B754C42,  1}, // ku-LB -> Arab
+        {0x868A0000, 40}, // kub -> Latn
+        {0x8E8A0000, 40}, // kud -> Latn
+        {0x928A0000, 40}, // kue -> Latn
+        {0xA68A0000, 40}, // kuj -> Latn
+        {0xB28A0000, 15}, // kum -> Cyrl
+        {0xB68A0000, 40}, // kun -> Latn
+        {0xBE8A0000, 40}, // kup -> Latn
+        {0xCA8A0000, 40}, // kus -> Latn
+        {0x6B760000, 15}, // kv -> Cyrl
+        {0x9AAA0000, 40}, // kvg -> Latn
+        {0xC6AA0000, 40}, // kvr -> Latn
+        {0xDEAA0000,  1}, // kvx -> Arab
+        {0x6B770000, 40}, // kw -> Latn
+        {0xA6CA0000, 40}, // kwj -> Latn
+        {0xBACA0000, 40}, // kwo -> Latn
+        {0x82EA0000, 40}, // kxa -> Latn
+        {0x8AEA0000, 18}, // kxc -> Ethi
+        {0xB2EA0000, 80}, // kxm -> Thai
+        {0xBEEA0000,  1}, // kxp -> Arab
+        {0xDAEA0000, 40}, // kxw -> Latn
+        {0xE6EA0000, 40}, // kxz -> Latn
+        {0x6B790000, 15}, // ky -> Cyrl
+        {0x6B79434E,  1}, // ky-CN -> Arab
+        {0x6B795452, 40}, // ky-TR -> Latn
+        {0x930A0000, 40}, // kye -> Latn
+        {0xDF0A0000, 40}, // kyx -> Latn
+        {0xC72A0000, 40}, // kzr -> Latn
+        {0x6C610000, 40}, // la -> Latn
+        {0x840B0000, 42}, // lab -> Lina
+        {0x8C0B0000, 27}, // lad -> Hebr
+        {0x980B0000, 40}, // lag -> Latn
+        {0x9C0B0000,  1}, // lah -> Arab
+        {0xA40B0000, 40}, // laj -> Latn
+        {0xC80B0000, 40}, // las -> Latn
+        {0x6C620000, 40}, // lb -> Latn
+        {0x902B0000, 15}, // lbe -> Cyrl
+        {0xD02B0000, 40}, // lbu -> Latn
+        {0xD82B0000, 40}, // lbw -> Latn
+        {0xB04B0000, 40}, // lcm -> Latn
+        {0xBC4B0000, 80}, // lcp -> Thai
+        {0x846B0000, 40}, // ldb -> Latn
+        {0x8C8B0000, 40}, // led -> Latn
+        {0x908B0000, 40}, // lee -> Latn
+        {0xB08B0000, 40}, // lem -> Latn
+        {0xBC8B0000, 41}, // lep -> Lepc
+        {0xC08B0000, 40}, // leq -> Latn
+        {0xD08B0000, 40}, // leu -> Latn
+        {0xE48B0000, 15}, // lez -> Cyrl
+        {0x6C670000, 40}, // lg -> Latn
+        {0x98CB0000, 40}, // lgg -> Latn
+        {0x6C690000, 40}, // li -> Latn
+        {0x810B0000, 40}, // lia -> Latn
+        {0x8D0B0000, 40}, // lid -> Latn
+        {0x950B0000, 16}, // lif -> Deva
+        {0x990B0000, 40}, // lig -> Latn
+        {0x9D0B0000, 40}, // lih -> Latn
+        {0xA50B0000, 40}, // lij -> Latn
+        {0xC90B0000, 43}, // lis -> Lisu
+        {0xBD2B0000, 40}, // ljp -> Latn
+        {0xA14B0000,  1}, // lki -> Arab
+        {0xCD4B0000, 40}, // lkt -> Latn
+        {0x916B0000, 40}, // lle -> Latn
+        {0xB56B0000, 40}, // lln -> Latn
+        {0xB58B0000, 77}, // lmn -> Telu
+        {0xB98B0000, 40}, // lmo -> Latn
+        {0xBD8B0000, 40}, // lmp -> Latn
+        {0x6C6E0000, 40}, // ln -> Latn
+        {0xC9AB0000, 40}, // lns -> Latn
+        {0xD1AB0000, 40}, // lnu -> Latn
+        {0x6C6F0000, 39}, // lo -> Laoo
+        {0xA5CB0000, 40}, // loj -> Latn
+        {0xA9CB0000, 40}, // lok -> Latn
+        {0xADCB0000, 40}, // lol -> Latn
+        {0xC5CB0000, 40}, // lor -> Latn
+        {0xC9CB0000, 40}, // los -> Latn
+        {0xE5CB0000, 40}, // loz -> Latn
+        {0x8A2B0000,  1}, // lrc -> Arab
+        {0x6C740000, 40}, // lt -> Latn
+        {0x9A6B0000, 40}, // ltg -> Latn
+        {0x6C750000, 40}, // lu -> Latn
+        {0x828B0000, 40}, // lua -> Latn
+        {0xBA8B0000, 40}, // luo -> Latn
+        {0xE28B0000, 40}, // luy -> Latn
+        {0xE68B0000,  1}, // luz -> Arab
+        {0x6C760000, 40}, // lv -> Latn
+        {0xAECB0000, 80}, // lwl -> Thai
+        {0x9F2B0000, 24}, // lzh -> Hans
+        {0xE72B0000, 40}, // lzz -> Latn
+        {0x8C0C0000, 40}, // mad -> Latn
+        {0x940C0000, 40}, // maf -> Latn
+        {0x980C0000, 16}, // mag -> Deva
+        {0xA00C0000, 16}, // mai -> Deva
+        {0xA80C0000, 40}, // mak -> Latn
+        {0xB40C0000, 40}, // man -> Latn
+        {0xB40C474E, 54}, // man-GN -> Nkoo
+        {0xC80C0000, 40}, // mas -> Latn
+        {0xD80C0000, 40}, // maw -> Latn
+        {0xE40C0000, 40}, // maz -> Latn
+        {0x9C2C0000, 40}, // mbh -> Latn
+        {0xB82C0000, 40}, // mbo -> Latn
+        {0xC02C0000, 40}, // mbq -> Latn
+        {0xD02C0000, 40}, // mbu -> Latn
+        {0xD82C0000, 40}, // mbw -> Latn
+        {0xA04C0000, 40}, // mci -> Latn
+        {0xBC4C0000, 40}, // mcp -> Latn
+        {0xC04C0000, 40}, // mcq -> Latn
+        {0xC44C0000, 40}, // mcr -> Latn
+        {0xD04C0000, 40}, // mcu -> Latn
+        {0x806C0000, 40}, // mda -> Latn
+        {0x906C0000,  1}, // mde -> Arab
+        {0x946C0000, 15}, // mdf -> Cyrl
+        {0x9C6C0000, 40}, // mdh -> Latn
+        {0xA46C0000, 40}, // mdj -> Latn
+        {0xC46C0000, 40}, // mdr -> Latn
+        {0xDC6C0000, 18}, // mdx -> Ethi
+        {0x8C8C0000, 40}, // med -> Latn
+        {0x908C0000, 40}, // mee -> Latn
+        {0xA88C0000, 40}, // mek -> Latn
+        {0xB48C0000, 40}, // men -> Latn
+        {0xC48C0000, 40}, // mer -> Latn
+        {0xCC8C0000, 40}, // met -> Latn
+        {0xD08C0000, 40}, // meu -> Latn
+        {0x80AC0000,  1}, // mfa -> Arab
+        {0x90AC0000, 40}, // mfe -> Latn
+        {0xB4AC0000, 40}, // mfn -> Latn
+        {0xB8AC0000, 40}, // mfo -> Latn
+        {0xC0AC0000, 40}, // mfq -> Latn
+        {0x6D670000, 40}, // mg -> Latn
+        {0x9CCC0000, 40}, // mgh -> Latn
+        {0xACCC0000, 40}, // mgl -> Latn
+        {0xB8CC0000, 40}, // mgo -> Latn
+        {0xBCCC0000, 16}, // mgp -> Deva
+        {0xE0CC0000, 40}, // mgy -> Latn
+        {0x6D680000, 40}, // mh -> Latn
+        {0xA0EC0000, 40}, // mhi -> Latn
+        {0xACEC0000, 40}, // mhl -> Latn
+        {0x6D690000, 40}, // mi -> Latn
+        {0x950C0000, 40}, // mif -> Latn
+        {0xB50C0000, 40}, // min -> Latn
+        {0xC90C0000, 26}, // mis -> Hatr
+        {0xD90C0000, 40}, // miw -> Latn
+        {0x6D6B0000, 15}, // mk -> Cyrl
+        {0xA14C0000,  1}, // mki -> Arab
+        {0xAD4C0000, 40}, // mkl -> Latn
+        {0xBD4C0000, 40}, // mkp -> Latn
+        {0xD94C0000, 40}, // mkw -> Latn
+        {0x6D6C0000, 49}, // ml -> Mlym
+        {0x916C0000, 40}, // mle -> Latn
+        {0xBD6C0000, 40}, // mlp -> Latn
+        {0xC96C0000, 40}, // mls -> Latn
+        {0xB98C0000, 40}, // mmo -> Latn
+        {0xD18C0000, 40}, // mmu -> Latn
+        {0xDD8C0000, 40}, // mmx -> Latn
+        {0x6D6E0000, 15}, // mn -> Cyrl
+        {0x6D6E434E, 50}, // mn-CN -> Mong
+        {0x81AC0000, 40}, // mna -> Latn
+        {0x95AC0000, 40}, // mnf -> Latn
+        {0xA1AC0000,  7}, // mni -> Beng
+        {0xD9AC0000, 52}, // mnw -> Mymr
+        {0x81CC0000, 40}, // moa -> Latn
+        {0x91CC0000, 40}, // moe -> Latn
+        {0x9DCC0000, 40}, // moh -> Latn
+        {0xC9CC0000, 40}, // mos -> Latn
+        {0xDDCC0000, 40}, // mox -> Latn
+        {0xBDEC0000, 40}, // mpp -> Latn
+        {0xC9EC0000, 40}, // mps -> Latn
+        {0xCDEC0000, 40}, // mpt -> Latn
+        {0xDDEC0000, 40}, // mpx -> Latn
+        {0xAE0C0000, 40}, // mql -> Latn
+        {0x6D720000, 16}, // mr -> Deva
+        {0x8E2C0000, 16}, // mrd -> Deva
+        {0xA62C0000, 15}, // mrj -> Cyrl
+        {0xBA2C0000, 51}, // mro -> Mroo
+        {0x6D730000, 40}, // ms -> Latn
+        {0x6D734343,  1}, // ms-CC -> Arab
+        {0x6D734944,  1}, // ms-ID -> Arab
+        {0x6D740000, 40}, // mt -> Latn
+        {0x8A6C0000, 40}, // mtc -> Latn
+        {0x966C0000, 40}, // mtf -> Latn
+        {0xA26C0000, 40}, // mti -> Latn
+        {0xC66C0000, 16}, // mtr -> Deva
+        {0x828C0000, 40}, // mua -> Latn
+        {0xC68C0000, 40}, // mur -> Latn
+        {0xCA8C0000, 40}, // mus -> Latn
+        {0x82AC0000, 40}, // mva -> Latn
+        {0xB6AC0000, 40}, // mvn -> Latn
+        {0xE2AC0000,  1}, // mvy -> Arab
+        {0xAACC0000, 40}, // mwk -> Latn
+        {0xC6CC0000, 16}, // mwr -> Deva
+        {0xD6CC0000, 40}, // mwv -> Latn
+        {0x8AEC0000, 40}, // mxc -> Latn
+        {0xB2EC0000, 40}, // mxm -> Latn
+        {0x6D790000, 52}, // my -> Mymr
+        {0xAB0C0000, 40}, // myk -> Latn
+        {0xB30C0000, 18}, // mym -> Ethi
+        {0xD70C0000, 15}, // myv -> Cyrl
+        {0xDB0C0000, 40}, // myw -> Latn
+        {0xDF0C0000, 40}, // myx -> Latn
+        {0xE70C0000, 46}, // myz -> Mand
+        {0xAB2C0000, 40}, // mzk -> Latn
+        {0xB32C0000, 40}, // mzm -> Latn
+        {0xB72C0000,  1}, // mzn -> Arab
+        {0xBF2C0000, 40}, // mzp -> Latn
+        {0xDB2C0000, 40}, // mzw -> Latn
+        {0xE72C0000, 40}, // mzz -> Latn
+        {0x6E610000, 40}, // na -> Latn
+        {0x880D0000, 40}, // nac -> Latn
+        {0x940D0000, 40}, // naf -> Latn
+        {0xA80D0000, 40}, // nak -> Latn
+        {0xB40D0000, 24}, // nan -> Hans
+        {0xBC0D0000, 40}, // nap -> Latn
+        {0xC00D0000, 40}, // naq -> Latn
+        {0xC80D0000, 40}, // nas -> Latn
+        {0x6E620000, 40}, // nb -> Latn
+        {0x804D0000, 40}, // nca -> Latn
+        {0x904D0000, 40}, // nce -> Latn
+        {0x944D0000, 40}, // ncf -> Latn
+        {0x9C4D0000, 40}, // nch -> Latn
+        {0xB84D0000, 40}, // nco -> Latn
+        {0xD04D0000, 40}, // ncu -> Latn
+        {0x6E640000, 40}, // nd -> Latn
+        {0x886D0000, 40}, // ndc -> Latn
+        {0xC86D0000, 40}, // nds -> Latn
+        {0x6E650000, 16}, // ne -> Deva
+        {0x848D0000, 40}, // neb -> Latn
+        {0xD88D0000, 16}, // new -> Deva
+        {0xDC8D0000, 40}, // nex -> Latn
+        {0xC4AD0000, 40}, // nfr -> Latn
+        {0x6E670000, 40}, // ng -> Latn
+        {0x80CD0000, 40}, // nga -> Latn
+        {0x84CD0000, 40}, // ngb -> Latn
+        {0xACCD0000, 40}, // ngl -> Latn
+        {0x84ED0000, 40}, // nhb -> Latn
+        {0x90ED0000, 40}, // nhe -> Latn
+        {0xD8ED0000, 40}, // nhw -> Latn
+        {0x950D0000, 40}, // nif -> Latn
+        {0xA10D0000, 40}, // nii -> Latn
+        {0xA50D0000, 40}, // nij -> Latn
+        {0xB50D0000, 40}, // nin -> Latn
+        {0xD10D0000, 40}, // niu -> Latn
+        {0xE10D0000, 40}, // niy -> Latn
+        {0xE50D0000, 40}, // niz -> Latn
+        {0xB92D0000, 40}, // njo -> Latn
+        {0x994D0000, 40}, // nkg -> Latn
+        {0xB94D0000, 40}, // nko -> Latn
+        {0x6E6C0000, 40}, // nl -> Latn
+        {0x998D0000, 40}, // nmg -> Latn
+        {0xE58D0000, 40}, // nmz -> Latn
+        {0x6E6E0000, 40}, // nn -> Latn
+        {0x95AD0000, 40}, // nnf -> Latn
+        {0x9DAD0000, 40}, // nnh -> Latn
+        {0xA9AD0000, 40}, // nnk -> Latn
+        {0xB1AD0000, 40}, // nnm -> Latn
+        {0x6E6F0000, 40}, // no -> Latn
+        {0x8DCD0000, 38}, // nod -> Lana
+        {0x91CD0000, 16}, // noe -> Deva
+        {0xB5CD0000, 64}, // non -> Runr
+        {0xBDCD0000, 40}, // nop -> Latn
+        {0xD1CD0000, 40}, // nou -> Latn
+        {0xBA0D0000, 54}, // nqo -> Nkoo
+        {0x6E720000, 40}, // nr -> Latn
+        {0x862D0000, 40}, // nrb -> Latn
+        {0xAA4D0000,  9}, // nsk -> Cans
+        {0xB64D0000, 40}, // nsn -> Latn
+        {0xBA4D0000, 40}, // nso -> Latn
+        {0xCA4D0000, 40}, // nss -> Latn
+        {0xB26D0000, 40}, // ntm -> Latn
+        {0xC66D0000, 40}, // ntr -> Latn
+        {0xA28D0000, 40}, // nui -> Latn
+        {0xBE8D0000, 40}, // nup -> Latn
+        {0xCA8D0000, 40}, // nus -> Latn
+        {0xD68D0000, 40}, // nuv -> Latn
+        {0xDE8D0000, 40}, // nux -> Latn
+        {0x6E760000, 40}, // nv -> Latn
+        {0x86CD0000, 40}, // nwb -> Latn
+        {0xC2ED0000, 40}, // nxq -> Latn
+        {0xC6ED0000, 40}, // nxr -> Latn
+        {0x6E790000, 40}, // ny -> Latn
+        {0xB30D0000, 40}, // nym -> Latn
+        {0xB70D0000, 40}, // nyn -> Latn
+        {0xA32D0000, 40}, // nzi -> Latn
+        {0x6F630000, 40}, // oc -> Latn
+        {0x88CE0000, 40}, // ogc -> Latn
+        {0xC54E0000, 40}, // okr -> Latn
+        {0xD54E0000, 40}, // okv -> Latn
+        {0x6F6D0000, 40}, // om -> Latn
+        {0x99AE0000, 40}, // ong -> Latn
+        {0xB5AE0000, 40}, // onn -> Latn
+        {0xC9AE0000, 40}, // ons -> Latn
+        {0xB1EE0000, 40}, // opm -> Latn
+        {0x6F720000, 57}, // or -> Orya
+        {0xBA2E0000, 40}, // oro -> Latn
+        {0xD22E0000,  1}, // oru -> Arab
+        {0x6F730000, 15}, // os -> Cyrl
+        {0x824E0000, 58}, // osa -> Osge
+        {0x826E0000,  1}, // ota -> Arab
+        {0xAA6E0000, 56}, // otk -> Orkh
+        {0xB32E0000, 40}, // ozm -> Latn
+        {0x70610000, 23}, // pa -> Guru
+        {0x7061504B,  1}, // pa-PK -> Arab
+        {0x980F0000, 40}, // pag -> Latn
+        {0xAC0F0000, 60}, // pal -> Phli
+        {0xB00F0000, 40}, // pam -> Latn
+        {0xBC0F0000, 40}, // pap -> Latn
+        {0xD00F0000, 40}, // pau -> Latn
+        {0xA02F0000, 40}, // pbi -> Latn
+        {0x8C4F0000, 40}, // pcd -> Latn
+        {0xB04F0000, 40}, // pcm -> Latn
+        {0x886F0000, 40}, // pdc -> Latn
+        {0xCC6F0000, 40}, // pdt -> Latn
+        {0x8C8F0000, 40}, // ped -> Latn
+        {0xB88F0000, 84}, // peo -> Xpeo
+        {0xDC8F0000, 40}, // pex -> Latn
+        {0xACAF0000, 40}, // pfl -> Latn
+        {0xACEF0000,  1}, // phl -> Arab
+        {0xB4EF0000, 61}, // phn -> Phnx
+        {0xAD0F0000, 40}, // pil -> Latn
+        {0xBD0F0000, 40}, // pip -> Latn
+        {0x814F0000,  8}, // pka -> Brah
+        {0xB94F0000, 40}, // pko -> Latn
+        {0x706C0000, 40}, // pl -> Latn
+        {0x816F0000, 40}, // pla -> Latn
+        {0xC98F0000, 40}, // pms -> Latn
+        {0x99AF0000, 40}, // png -> Latn
+        {0xB5AF0000, 40}, // pnn -> Latn
+        {0xCDAF0000, 21}, // pnt -> Grek
+        {0xB5CF0000, 40}, // pon -> Latn
+        {0xB9EF0000, 40}, // ppo -> Latn
+        {0x822F0000, 34}, // pra -> Khar
+        {0x8E2F0000,  1}, // prd -> Arab
+        {0x9A2F0000, 40}, // prg -> Latn
+        {0x70730000,  1}, // ps -> Arab
+        {0xCA4F0000, 40}, // pss -> Latn
+        {0x70740000, 40}, // pt -> Latn
+        {0xBE6F0000, 40}, // ptp -> Latn
+        {0xD28F0000, 40}, // puu -> Latn
+        {0x82CF0000, 40}, // pwa -> Latn
+        {0x71750000, 40}, // qu -> Latn
+        {0x8A900000, 40}, // quc -> Latn
+        {0x9A900000, 40}, // qug -> Latn
+        {0xA0110000, 40}, // rai -> Latn
+        {0xA4110000, 16}, // raj -> Deva
+        {0xB8110000, 40}, // rao -> Latn
+        {0x94510000, 40}, // rcf -> Latn
+        {0xA4910000, 40}, // rej -> Latn
+        {0xAC910000, 40}, // rel -> Latn
+        {0xC8910000, 40}, // res -> Latn
+        {0xB4D10000, 40}, // rgn -> Latn
+        {0x98F10000,  1}, // rhg -> Arab
+        {0x81110000, 40}, // ria -> Latn
+        {0x95110000, 78}, // rif -> Tfng
+        {0x95114E4C, 40}, // rif-NL -> Latn
+        {0xC9310000, 16}, // rjs -> Deva
+        {0xCD510000,  7}, // rkt -> Beng
+        {0x726D0000, 40}, // rm -> Latn
+        {0x95910000, 40}, // rmf -> Latn
+        {0xB9910000, 40}, // rmo -> Latn
+        {0xCD910000,  1}, // rmt -> Arab
+        {0xD1910000, 40}, // rmu -> Latn
+        {0x726E0000, 40}, // rn -> Latn
+        {0x81B10000, 40}, // rna -> Latn
+        {0x99B10000, 40}, // rng -> Latn
+        {0x726F0000, 40}, // ro -> Latn
+        {0x85D10000, 40}, // rob -> Latn
+        {0x95D10000, 40}, // rof -> Latn
+        {0xB9D10000, 40}, // roo -> Latn
+        {0xBA310000, 40}, // rro -> Latn
+        {0xB2710000, 40}, // rtm -> Latn
+        {0x72750000, 15}, // ru -> Cyrl
+        {0x92910000, 15}, // rue -> Cyrl
+        {0x9A910000, 40}, // rug -> Latn
+        {0x72770000, 40}, // rw -> Latn
+        {0xAAD10000, 40}, // rwk -> Latn
+        {0xBAD10000, 40}, // rwo -> Latn
+        {0xD3110000, 33}, // ryu -> Kana
+        {0x73610000, 16}, // sa -> Deva
+        {0x94120000, 40}, // saf -> Latn
+        {0x9C120000, 15}, // sah -> Cyrl
+        {0xC0120000, 40}, // saq -> Latn
+        {0xC8120000, 40}, // sas -> Latn
+        {0xCC120000, 40}, // sat -> Latn
+        {0xE4120000, 67}, // saz -> Saur
+        {0x80320000, 40}, // sba -> Latn
+        {0x90320000, 40}, // sbe -> Latn
+        {0xBC320000, 40}, // sbp -> Latn
+        {0x73630000, 40}, // sc -> Latn
+        {0xA8520000, 16}, // sck -> Deva
+        {0xAC520000,  1}, // scl -> Arab
+        {0xB4520000, 40}, // scn -> Latn
+        {0xB8520000, 40}, // sco -> Latn
+        {0xC8520000, 40}, // scs -> Latn
+        {0x73640000,  1}, // sd -> Arab
+        {0x88720000, 40}, // sdc -> Latn
+        {0x9C720000,  1}, // sdh -> Arab
+        {0x73650000, 40}, // se -> Latn
+        {0x94920000, 40}, // sef -> Latn
+        {0x9C920000, 40}, // seh -> Latn
+        {0xA0920000, 40}, // sei -> Latn
+        {0xC8920000, 40}, // ses -> Latn
+        {0x73670000, 40}, // sg -> Latn
+        {0x80D20000, 55}, // sga -> Ogam
+        {0xC8D20000, 40}, // sgs -> Latn
+        {0xD8D20000, 18}, // sgw -> Ethi
+        {0xE4D20000, 40}, // sgz -> Latn
+        {0x73680000, 40}, // sh -> Latn
+        {0xA0F20000, 78}, // shi -> Tfng
+        {0xA8F20000, 40}, // shk -> Latn
+        {0xB4F20000, 52}, // shn -> Mymr
+        {0xD0F20000,  1}, // shu -> Arab
+        {0x73690000, 69}, // si -> Sinh
+        {0x8D120000, 40}, // sid -> Latn
+        {0x99120000, 40}, // sig -> Latn
+        {0xAD120000, 40}, // sil -> Latn
+        {0xB1120000, 40}, // sim -> Latn
+        {0xC5320000, 40}, // sjr -> Latn
+        {0x736B0000, 40}, // sk -> Latn
+        {0x89520000, 40}, // skc -> Latn
+        {0xC5520000,  1}, // skr -> Arab
+        {0xC9520000, 40}, // sks -> Latn
+        {0x736C0000, 40}, // sl -> Latn
+        {0x8D720000, 40}, // sld -> Latn
+        {0xA1720000, 40}, // sli -> Latn
+        {0xAD720000, 40}, // sll -> Latn
+        {0xE1720000, 40}, // sly -> Latn
+        {0x736D0000, 40}, // sm -> Latn
+        {0x81920000, 40}, // sma -> Latn
+        {0xA5920000, 40}, // smj -> Latn
+        {0xB5920000, 40}, // smn -> Latn
+        {0xBD920000, 65}, // smp -> Samr
+        {0xC1920000, 40}, // smq -> Latn
+        {0xC9920000, 40}, // sms -> Latn
+        {0x736E0000, 40}, // sn -> Latn
+        {0x89B20000, 40}, // snc -> Latn
+        {0xA9B20000, 40}, // snk -> Latn
+        {0xBDB20000, 40}, // snp -> Latn
+        {0xDDB20000, 40}, // snx -> Latn
+        {0xE1B20000, 40}, // sny -> Latn
+        {0x736F0000, 40}, // so -> Latn
+        {0xA9D20000, 40}, // sok -> Latn
+        {0xC1D20000, 40}, // soq -> Latn
+        {0xD1D20000, 80}, // sou -> Thai
+        {0xE1D20000, 40}, // soy -> Latn
+        {0x8DF20000, 40}, // spd -> Latn
+        {0xADF20000, 40}, // spl -> Latn
+        {0xC9F20000, 40}, // sps -> Latn
+        {0x73710000, 40}, // sq -> Latn
+        {0x73720000, 15}, // sr -> Cyrl
+        {0x73724D45, 40}, // sr-ME -> Latn
+        {0x7372524F, 40}, // sr-RO -> Latn
+        {0x73725255, 40}, // sr-RU -> Latn
+        {0x73725452, 40}, // sr-TR -> Latn
+        {0x86320000, 70}, // srb -> Sora
+        {0xB6320000, 40}, // srn -> Latn
+        {0xC6320000, 40}, // srr -> Latn
+        {0xDE320000, 16}, // srx -> Deva
+        {0x73730000, 40}, // ss -> Latn
+        {0x8E520000, 40}, // ssd -> Latn
+        {0x9A520000, 40}, // ssg -> Latn
+        {0xE2520000, 40}, // ssy -> Latn
+        {0x73740000, 40}, // st -> Latn
+        {0xAA720000, 40}, // stk -> Latn
+        {0xC2720000, 40}, // stq -> Latn
+        {0x73750000, 40}, // su -> Latn
+        {0x82920000, 40}, // sua -> Latn
+        {0x92920000, 40}, // sue -> Latn
+        {0xAA920000, 40}, // suk -> Latn
+        {0xC6920000, 40}, // sur -> Latn
+        {0xCA920000, 40}, // sus -> Latn
+        {0x73760000, 40}, // sv -> Latn
+        {0x73770000, 40}, // sw -> Latn
+        {0x86D20000,  1}, // swb -> Arab
+        {0x8AD20000, 40}, // swc -> Latn
+        {0x9AD20000, 40}, // swg -> Latn
+        {0xBED20000, 40}, // swp -> Latn
+        {0xD6D20000, 16}, // swv -> Deva
+        {0xB6F20000, 40}, // sxn -> Latn
+        {0xDAF20000, 40}, // sxw -> Latn
+        {0xAF120000,  7}, // syl -> Beng
+        {0xC7120000, 71}, // syr -> Syrc
+        {0xAF320000, 40}, // szl -> Latn
+        {0x74610000, 74}, // ta -> Taml
+        {0xA4130000, 16}, // taj -> Deva
+        {0xAC130000, 40}, // tal -> Latn
+        {0xB4130000, 40}, // tan -> Latn
+        {0xC0130000, 40}, // taq -> Latn
+        {0x88330000, 40}, // tbc -> Latn
+        {0x8C330000, 40}, // tbd -> Latn
+        {0x94330000, 40}, // tbf -> Latn
+        {0x98330000, 40}, // tbg -> Latn
+        {0xB8330000, 40}, // tbo -> Latn
+        {0xD8330000, 40}, // tbw -> Latn
+        {0xE4330000, 40}, // tbz -> Latn
+        {0xA0530000, 40}, // tci -> Latn
+        {0xE0530000, 36}, // tcy -> Knda
+        {0x8C730000, 72}, // tdd -> Tale
+        {0x98730000, 16}, // tdg -> Deva
+        {0x9C730000, 16}, // tdh -> Deva
+        {0x74650000, 77}, // te -> Telu
+        {0x8C930000, 40}, // ted -> Latn
+        {0xB0930000, 40}, // tem -> Latn
+        {0xB8930000, 40}, // teo -> Latn
+        {0xCC930000, 40}, // tet -> Latn
+        {0xA0B30000, 40}, // tfi -> Latn
+        {0x74670000, 15}, // tg -> Cyrl
+        {0x7467504B,  1}, // tg-PK -> Arab
+        {0x88D30000, 40}, // tgc -> Latn
+        {0xB8D30000, 40}, // tgo -> Latn
+        {0xD0D30000, 40}, // tgu -> Latn
+        {0x74680000, 80}, // th -> Thai
+        {0xACF30000, 16}, // thl -> Deva
+        {0xC0F30000, 16}, // thq -> Deva
+        {0xC4F30000, 16}, // thr -> Deva
+        {0x74690000, 18}, // ti -> Ethi
+        {0x95130000, 40}, // tif -> Latn
+        {0x99130000, 18}, // tig -> Ethi
+        {0xA9130000, 40}, // tik -> Latn
+        {0xB1130000, 40}, // tim -> Latn
+        {0xB9130000, 40}, // tio -> Latn
+        {0xD5130000, 40}, // tiv -> Latn
+        {0x746B0000, 40}, // tk -> Latn
+        {0xAD530000, 40}, // tkl -> Latn
+        {0xC5530000, 40}, // tkr -> Latn
+        {0xCD530000, 16}, // tkt -> Deva
+        {0x746C0000, 40}, // tl -> Latn
+        {0x95730000, 40}, // tlf -> Latn
+        {0xDD730000, 40}, // tlx -> Latn
+        {0xE1730000, 40}, // tly -> Latn
+        {0x9D930000, 40}, // tmh -> Latn
+        {0xE1930000, 40}, // tmy -> Latn
+        {0x746E0000, 40}, // tn -> Latn
+        {0x9DB30000, 40}, // tnh -> Latn
+        {0x746F0000, 40}, // to -> Latn
+        {0x95D30000, 40}, // tof -> Latn
+        {0x99D30000, 40}, // tog -> Latn
+        {0xC1D30000, 40}, // toq -> Latn
+        {0xA1F30000, 40}, // tpi -> Latn
+        {0xB1F30000, 40}, // tpm -> Latn
+        {0xE5F30000, 40}, // tpz -> Latn
+        {0xBA130000, 40}, // tqo -> Latn
+        {0x74720000, 40}, // tr -> Latn
+        {0xD2330000, 40}, // tru -> Latn
+        {0xD6330000, 40}, // trv -> Latn
+        {0xDA330000,  1}, // trw -> Arab
+        {0x74730000, 40}, // ts -> Latn
+        {0x8E530000, 21}, // tsd -> Grek
+        {0x96530000, 16}, // tsf -> Deva
+        {0x9A530000, 40}, // tsg -> Latn
+        {0xA6530000, 81}, // tsj -> Tibt
+        {0xDA530000, 40}, // tsw -> Latn
+        {0x74740000, 15}, // tt -> Cyrl
+        {0x8E730000, 40}, // ttd -> Latn
+        {0x92730000, 40}, // tte -> Latn
+        {0xA6730000, 40}, // ttj -> Latn
+        {0xC6730000, 40}, // ttr -> Latn
+        {0xCA730000, 80}, // tts -> Thai
+        {0xCE730000, 40}, // ttt -> Latn
+        {0x9E930000, 40}, // tuh -> Latn
+        {0xAE930000, 40}, // tul -> Latn
+        {0xB2930000, 40}, // tum -> Latn
+        {0xC2930000, 40}, // tuq -> Latn
+        {0x8EB30000, 40}, // tvd -> Latn
+        {0xAEB30000, 40}, // tvl -> Latn
+        {0xD2B30000, 40}, // tvu -> Latn
+        {0x9ED30000, 40}, // twh -> Latn
+        {0xC2D30000, 40}, // twq -> Latn
+        {0x9AF30000, 75}, // txg -> Tang
+        {0x74790000, 40}, // ty -> Latn
+        {0x83130000, 40}, // tya -> Latn
+        {0xD7130000, 15}, // tyv -> Cyrl
+        {0xB3330000, 40}, // tzm -> Latn
+        {0xD0340000, 40}, // ubu -> Latn
+        {0xB0740000, 15}, // udm -> Cyrl
+        {0x75670000,  1}, // ug -> Arab
+        {0x75674B5A, 15}, // ug-KZ -> Cyrl
+        {0x75674D4E, 15}, // ug-MN -> Cyrl
+        {0x80D40000, 82}, // uga -> Ugar
+        {0x756B0000, 15}, // uk -> Cyrl
+        {0xA1740000, 40}, // uli -> Latn
+        {0x85940000, 40}, // umb -> Latn
+        {0xC5B40000,  7}, // unr -> Beng
+        {0xC5B44E50, 16}, // unr-NP -> Deva
+        {0xDDB40000,  7}, // unx -> Beng
+        {0x75720000,  1}, // ur -> Arab
+        {0xA2340000, 40}, // uri -> Latn
+        {0xCE340000, 40}, // urt -> Latn
+        {0xDA340000, 40}, // urw -> Latn
+        {0x82540000, 40}, // usa -> Latn
+        {0xC6740000, 40}, // utr -> Latn
+        {0x9EB40000, 40}, // uvh -> Latn
+        {0xAEB40000, 40}, // uvl -> Latn
+        {0x757A0000, 40}, // uz -> Latn
+        {0x757A4146,  1}, // uz-AF -> Arab
+        {0x757A434E, 15}, // uz-CN -> Cyrl
+        {0x98150000, 40}, // vag -> Latn
+        {0xA0150000, 83}, // vai -> Vaii
+        {0xB4150000, 40}, // van -> Latn
+        {0x76650000, 40}, // ve -> Latn
+        {0x88950000, 40}, // vec -> Latn
+        {0xBC950000, 40}, // vep -> Latn
+        {0x76690000, 40}, // vi -> Latn
+        {0x89150000, 40}, // vic -> Latn
+        {0xD5150000, 40}, // viv -> Latn
+        {0xC9750000, 40}, // vls -> Latn
+        {0x95950000, 40}, // vmf -> Latn
+        {0xD9950000, 40}, // vmw -> Latn
+        {0x766F0000, 40}, // vo -> Latn
+        {0xCDD50000, 40}, // vot -> Latn
+        {0xBA350000, 40}, // vro -> Latn
+        {0xB6950000, 40}, // vun -> Latn
+        {0xCE950000, 40}, // vut -> Latn
+        {0x77610000, 40}, // wa -> Latn
+        {0x90160000, 40}, // wae -> Latn
+        {0xA4160000, 40}, // waj -> Latn
+        {0xAC160000, 18}, // wal -> Ethi
+        {0xB4160000, 40}, // wan -> Latn
+        {0xC4160000, 40}, // war -> Latn
+        {0xBC360000, 40}, // wbp -> Latn
+        {0xC0360000, 77}, // wbq -> Telu
+        {0xC4360000, 16}, // wbr -> Deva
+        {0xA0560000, 40}, // wci -> Latn
+        {0xC4960000, 40}, // wer -> Latn
+        {0xA0D60000, 40}, // wgi -> Latn
+        {0x98F60000, 40}, // whg -> Latn
+        {0x85160000, 40}, // wib -> Latn
+        {0xD1160000, 40}, // wiu -> Latn
+        {0xD5160000, 40}, // wiv -> Latn
+        {0x81360000, 40}, // wja -> Latn
+        {0xA1360000, 40}, // wji -> Latn
+        {0xC9760000, 40}, // wls -> Latn
+        {0xB9960000, 40}, // wmo -> Latn
+        {0x89B60000, 40}, // wnc -> Latn
+        {0xA1B60000,  1}, // wni -> Arab
+        {0xD1B60000, 40}, // wnu -> Latn
+        {0x776F0000, 40}, // wo -> Latn
+        {0x85D60000, 40}, // wob -> Latn
+        {0xC9D60000, 40}, // wos -> Latn
+        {0xCA360000, 40}, // wrs -> Latn
+        {0xAA560000, 40}, // wsk -> Latn
+        {0xB2760000, 16}, // wtm -> Deva
+        {0xD2960000, 24}, // wuu -> Hans
+        {0xD6960000, 40}, // wuv -> Latn
+        {0x82D60000, 40}, // wwa -> Latn
+        {0xD4170000, 40}, // xav -> Latn
+        {0xA0370000, 40}, // xbi -> Latn
+        {0xC4570000, 10}, // xcr -> Cari
+        {0xC8970000, 40}, // xes -> Latn
+        {0x78680000, 40}, // xh -> Latn
+        {0x81770000, 40}, // xla -> Latn
+        {0x89770000, 44}, // xlc -> Lyci
+        {0x8D770000, 45}, // xld -> Lydi
+        {0x95970000, 19}, // xmf -> Geor
+        {0xB5970000, 47}, // xmn -> Mani
+        {0xC5970000, 48}, // xmr -> Merc
+        {0x81B70000, 53}, // xna -> Narb
+        {0xC5B70000, 16}, // xnr -> Deva
+        {0x99D70000, 40}, // xog -> Latn
+        {0xB5D70000, 40}, // xon -> Latn
+        {0xC5F70000, 63}, // xpr -> Prti
+        {0x86370000, 40}, // xrb -> Latn
+        {0x82570000, 66}, // xsa -> Sarb
+        {0xA2570000, 40}, // xsi -> Latn
+        {0xB2570000, 40}, // xsm -> Latn
+        {0xC6570000, 16}, // xsr -> Deva
+        {0x92D70000, 40}, // xwe -> Latn
+        {0xB0180000, 40}, // yam -> Latn
+        {0xB8180000, 40}, // yao -> Latn
+        {0xBC180000, 40}, // yap -> Latn
+        {0xC8180000, 40}, // yas -> Latn
+        {0xCC180000, 40}, // yat -> Latn
+        {0xD4180000, 40}, // yav -> Latn
+        {0xE0180000, 40}, // yay -> Latn
+        {0xE4180000, 40}, // yaz -> Latn
+        {0x80380000, 40}, // yba -> Latn
+        {0x84380000, 40}, // ybb -> Latn
+        {0xE0380000, 40}, // yby -> Latn
+        {0xC4980000, 40}, // yer -> Latn
+        {0xC4D80000, 40}, // ygr -> Latn
+        {0xD8D80000, 40}, // ygw -> Latn
+        {0x79690000, 27}, // yi -> Hebr
+        {0xB9580000, 40}, // yko -> Latn
+        {0x91780000, 40}, // yle -> Latn
+        {0x99780000, 40}, // ylg -> Latn
+        {0xAD780000, 40}, // yll -> Latn
+        {0xAD980000, 40}, // yml -> Latn
+        {0x796F0000, 40}, // yo -> Latn
+        {0xB5D80000, 40}, // yon -> Latn
+        {0x86380000, 40}, // yrb -> Latn
+        {0x92380000, 40}, // yre -> Latn
+        {0xAE380000, 40}, // yrl -> Latn
+        {0xCA580000, 40}, // yss -> Latn
+        {0x82980000, 40}, // yua -> Latn
+        {0x92980000, 25}, // yue -> Hant
+        {0x9298434E, 24}, // yue-CN -> Hans
+        {0xA6980000, 40}, // yuj -> Latn
+        {0xCE980000, 40}, // yut -> Latn
+        {0xDA980000, 40}, // yuw -> Latn
+        {0x7A610000, 40}, // za -> Latn
+        {0x98190000, 40}, // zag -> Latn
+        {0xA4790000,  1}, // zdj -> Arab
+        {0x80990000, 40}, // zea -> Latn
+        {0x9CD90000, 78}, // zgh -> Tfng
+        {0x7A680000, 24}, // zh -> Hans
+        {0x7A684155, 25}, // zh-AU -> Hant
+        {0x7A68424E, 25}, // zh-BN -> Hant
+        {0x7A684742, 25}, // zh-GB -> Hant
+        {0x7A684746, 25}, // zh-GF -> Hant
+        {0x7A68484B, 25}, // zh-HK -> Hant
+        {0x7A684944, 25}, // zh-ID -> Hant
+        {0x7A684D4F, 25}, // zh-MO -> Hant
+        {0x7A684D59, 25}, // zh-MY -> Hant
+        {0x7A685041, 25}, // zh-PA -> Hant
+        {0x7A685046, 25}, // zh-PF -> Hant
+        {0x7A685048, 25}, // zh-PH -> Hant
+        {0x7A685352, 25}, // zh-SR -> Hant
+        {0x7A685448, 25}, // zh-TH -> Hant
+        {0x7A685457, 25}, // zh-TW -> Hant
+        {0x7A685553, 25}, // zh-US -> Hant
+        {0x7A68564E, 25}, // zh-VN -> Hant
+        {0x81190000, 40}, // zia -> Latn
+        {0xB1790000, 40}, // zlm -> Latn
+        {0xA1990000, 40}, // zmi -> Latn
+        {0x91B90000, 40}, // zne -> Latn
+        {0x7A750000, 40}, // zu -> Latn
+        {0x83390000, 40}, // zza -> Latn
+    };
+
+    Map<Integer, Byte> buildMap = new HashMap<>();
+    for (int[] entry : entries) {
+      buildMap.put(entry[0], (byte) entry[1]);
+    }
+    LIKELY_SCRIPTS = Collections.unmodifiableMap(buildMap);
+  }
+
+  static final Set<Long> REPRESENTATIVE_LOCALES;
+
+  static {
+    long[] entries = {
+        0x616145544C61746EL, // aa_Latn_ET
+        0x616247454379726CL, // ab_Cyrl_GE
+        0xC42047484C61746EL, // abr_Latn_GH
+        0x904049444C61746EL, // ace_Latn_ID
+        0x9C4055474C61746EL, // ach_Latn_UG
+        0x806047484C61746EL, // ada_Latn_GH
+        0xE06052554379726CL, // ady_Cyrl_RU
+        0x6165495241767374L, // ae_Avst_IR
+        0x8480544E41726162L, // aeb_Arab_TN
+        0x61665A414C61746EL, // af_Latn_ZA
+        0xC0C0434D4C61746EL, // agq_Latn_CM
+        0xB8E0494E41686F6DL, // aho_Ahom_IN
+        0x616B47484C61746EL, // ak_Latn_GH
+        0xA940495158737578L, // akk_Xsux_IQ
+        0xB560584B4C61746EL, // aln_Latn_XK
+        0xCD6052554379726CL, // alt_Cyrl_RU
+        0x616D455445746869L, // am_Ethi_ET
+        0xB9804E474C61746EL, // amo_Latn_NG
+        0xE5C049444C61746EL, // aoz_Latn_ID
+        0x8DE0544741726162L, // apd_Arab_TG
+        0x6172454741726162L, // ar_Arab_EG
+        0x8A20495241726D69L, // arc_Armi_IR
+        0x8A204A4F4E626174L, // arc_Nbat_JO
+        0x8A20535950616C6DL, // arc_Palm_SY
+        0xB620434C4C61746EL, // arn_Latn_CL
+        0xBA20424F4C61746EL, // aro_Latn_BO
+        0xC220445A41726162L, // arq_Arab_DZ
+        0xE2204D4141726162L, // ary_Arab_MA
+        0xE620454741726162L, // arz_Arab_EG
+        0x6173494E42656E67L, // as_Beng_IN
+        0x8240545A4C61746EL, // asa_Latn_TZ
+        0x9240555353676E77L, // ase_Sgnw_US
+        0xCE4045534C61746EL, // ast_Latn_ES
+        0xA66043414C61746EL, // atj_Latn_CA
+        0x617652554379726CL, // av_Cyrl_RU
+        0x82C0494E44657661L, // awa_Deva_IN
+        0x6179424F4C61746EL, // ay_Latn_BO
+        0x617A495241726162L, // az_Arab_IR
+        0x617A415A4C61746EL, // az_Latn_AZ
+        0x626152554379726CL, // ba_Cyrl_RU
+        0xAC01504B41726162L, // bal_Arab_PK
+        0xB40149444C61746EL, // ban_Latn_ID
+        0xBC014E5044657661L, // bap_Deva_NP
+        0xC40141544C61746EL, // bar_Latn_AT
+        0xC801434D4C61746EL, // bas_Latn_CM
+        0xDC01434D42616D75L, // bax_Bamu_CM
+        0x882149444C61746EL, // bbc_Latn_ID
+        0xA421434D4C61746EL, // bbj_Latn_CM
+        0xA04143494C61746EL, // bci_Latn_CI
+        0x626542594379726CL, // be_Cyrl_BY
+        0xA481534441726162L, // bej_Arab_SD
+        0xB0815A4D4C61746EL, // bem_Latn_ZM
+        0xD88149444C61746EL, // bew_Latn_ID
+        0xE481545A4C61746EL, // bez_Latn_TZ
+        0x8CA1434D4C61746EL, // bfd_Latn_CM
+        0xC0A1494E54616D6CL, // bfq_Taml_IN
+        0xCCA1504B41726162L, // bft_Arab_PK
+        0xE0A1494E44657661L, // bfy_Deva_IN
+        0x626742474379726CL, // bg_Cyrl_BG
+        0x88C1494E44657661L, // bgc_Deva_IN
+        0xB4C1504B41726162L, // bgn_Arab_PK
+        0xDCC154524772656BL, // bgx_Grek_TR
+        0x84E1494E44657661L, // bhb_Deva_IN
+        0xA0E1494E44657661L, // bhi_Deva_IN
+        0xA8E150484C61746EL, // bhk_Latn_PH
+        0xB8E1494E44657661L, // bho_Deva_IN
+        0x626956554C61746EL, // bi_Latn_VU
+        0xA90150484C61746EL, // bik_Latn_PH
+        0xB5014E474C61746EL, // bin_Latn_NG
+        0xA521494E44657661L, // bjj_Deva_IN
+        0xB52149444C61746EL, // bjn_Latn_ID
+        0xB141434D4C61746EL, // bkm_Latn_CM
+        0xD14150484C61746EL, // bku_Latn_PH
+        0xCD61564E54617674L, // blt_Tavt_VN
+        0x626D4D4C4C61746EL, // bm_Latn_ML
+        0xC1814D4C4C61746EL, // bmq_Latn_ML
+        0x626E424442656E67L, // bn_Beng_BD
+        0x626F434E54696274L, // bo_Tibt_CN
+        0xE1E1494E42656E67L, // bpy_Beng_IN
+        0xA201495241726162L, // bqi_Arab_IR
+        0xD60143494C61746EL, // bqv_Latn_CI
+        0x627246524C61746EL, // br_Latn_FR
+        0x8221494E44657661L, // bra_Deva_IN
+        0x9E21504B41726162L, // brh_Arab_PK
+        0xDE21494E44657661L, // brx_Deva_IN
+        0x627342414C61746EL, // bs_Latn_BA
+        0xC2414C5242617373L, // bsq_Bass_LR
+        0xCA41434D4C61746EL, // bss_Latn_CM
+        0xBA6150484C61746EL, // bto_Latn_PH
+        0xD661504B44657661L, // btv_Deva_PK
+        0x828152554379726CL, // bua_Cyrl_RU
+        0x8A8159544C61746EL, // buc_Latn_YT
+        0x9A8149444C61746EL, // bug_Latn_ID
+        0xB281434D4C61746EL, // bum_Latn_CM
+        0x86A147514C61746EL, // bvb_Latn_GQ
+        0xB701455245746869L, // byn_Ethi_ER
+        0xD701434D4C61746EL, // byv_Latn_CM
+        0x93214D4C4C61746EL, // bze_Latn_ML
+        0x636145534C61746EL, // ca_Latn_ES
+        0x9C424E474C61746EL, // cch_Latn_NG
+        0xBC42494E42656E67L, // ccp_Beng_IN
+        0xBC42424443616B6DL, // ccp_Cakm_BD
+        0x636552554379726CL, // ce_Cyrl_RU
+        0x848250484C61746EL, // ceb_Latn_PH
+        0x98C255474C61746EL, // cgg_Latn_UG
+        0x636847554C61746EL, // ch_Latn_GU
+        0xA8E2464D4C61746EL, // chk_Latn_FM
+        0xB0E252554379726CL, // chm_Cyrl_RU
+        0xB8E255534C61746EL, // cho_Latn_US
+        0xBCE243414C61746EL, // chp_Latn_CA
+        0xC4E2555343686572L, // chr_Cher_US
+        0x81224B4841726162L, // cja_Arab_KH
+        0xB122564E4368616DL, // cjm_Cham_VN
+        0x8542495141726162L, // ckb_Arab_IQ
+        0x636F46524C61746EL, // co_Latn_FR
+        0xBDC24547436F7074L, // cop_Copt_EG
+        0xC9E250484C61746EL, // cps_Latn_PH
+        0x6372434143616E73L, // cr_Cans_CA
+        0xA622434143616E73L, // crj_Cans_CA
+        0xAA22434143616E73L, // crk_Cans_CA
+        0xAE22434143616E73L, // crl_Cans_CA
+        0xB222434143616E73L, // crm_Cans_CA
+        0xCA2253434C61746EL, // crs_Latn_SC
+        0x6373435A4C61746EL, // cs_Latn_CZ
+        0x8642504C4C61746EL, // csb_Latn_PL
+        0xDA42434143616E73L, // csw_Cans_CA
+        0x8E624D4D50617563L, // ctd_Pauc_MM
+        0x637552554379726CL, // cu_Cyrl_RU
+        0x63754247476C6167L, // cu_Glag_BG
+        0x637652554379726CL, // cv_Cyrl_RU
+        0x637947424C61746EL, // cy_Latn_GB
+        0x6461444B4C61746EL, // da_Latn_DK
+        0xA80355534C61746EL, // dak_Latn_US
+        0xC40352554379726CL, // dar_Cyrl_RU
+        0xD4034B454C61746EL, // dav_Latn_KE
+        0x8843494E41726162L, // dcc_Arab_IN
+        0x646544454C61746EL, // de_Latn_DE
+        0xB48343414C61746EL, // den_Latn_CA
+        0xC4C343414C61746EL, // dgr_Latn_CA
+        0x91234E454C61746EL, // dje_Latn_NE
+        0xA5A343494C61746EL, // dnj_Latn_CI
+        0xA1C3494E41726162L, // doi_Arab_IN
+        0x864344454C61746EL, // dsb_Latn_DE
+        0xB2634D4C4C61746EL, // dtm_Latn_ML
+        0xBE634D594C61746EL, // dtp_Latn_MY
+        0xE2634E5044657661L, // dty_Deva_NP
+        0x8283434D4C61746EL, // dua_Latn_CM
+        0x64764D5654686161L, // dv_Thaa_MV
+        0xBB03534E4C61746EL, // dyo_Latn_SN
+        0xD30342464C61746EL, // dyu_Latn_BF
+        0x647A425454696274L, // dz_Tibt_BT
+        0xD0244B454C61746EL, // ebu_Latn_KE
+        0x656547484C61746EL, // ee_Latn_GH
+        0xA0A44E474C61746EL, // efi_Latn_NG
+        0xACC449544C61746EL, // egl_Latn_IT
+        0xE0C4454745677970L, // egy_Egyp_EG
+        0xE1444D4D4B616C69L, // eky_Kali_MM
+        0x656C47524772656BL, // el_Grek_GR
+        0x656E47424C61746EL, // en_Latn_GB
+        0x656E55534C61746EL, // en_Latn_US
+        0x656E474253686177L, // en_Shaw_GB
+        0x657345534C61746EL, // es_Latn_ES
+        0x65734D584C61746EL, // es_Latn_MX
+        0x657355534C61746EL, // es_Latn_US
+        0xD24455534C61746EL, // esu_Latn_US
+        0x657445454C61746EL, // et_Latn_EE
+        0xCE6449544974616CL, // ett_Ital_IT
+        0x657545534C61746EL, // eu_Latn_ES
+        0xBAC4434D4C61746EL, // ewo_Latn_CM
+        0xCEE445534C61746EL, // ext_Latn_ES
+        0x6661495241726162L, // fa_Arab_IR
+        0xB40547514C61746EL, // fan_Latn_GQ
+        0x6666474E41646C6DL, // ff_Adlm_GN
+        0x6666534E4C61746EL, // ff_Latn_SN
+        0xB0A54D4C4C61746EL, // ffm_Latn_ML
+        0x666946494C61746EL, // fi_Latn_FI
+        0x8105534441726162L, // fia_Arab_SD
+        0xAD0550484C61746EL, // fil_Latn_PH
+        0xCD0553454C61746EL, // fit_Latn_SE
+        0x666A464A4C61746EL, // fj_Latn_FJ
+        0x666F464F4C61746EL, // fo_Latn_FO
+        0xB5C5424A4C61746EL, // fon_Latn_BJ
+        0x667246524C61746EL, // fr_Latn_FR
+        0x8A2555534C61746EL, // frc_Latn_US
+        0xBE2546524C61746EL, // frp_Latn_FR
+        0xC62544454C61746EL, // frr_Latn_DE
+        0xCA2544454C61746EL, // frs_Latn_DE
+        0x8685434D41726162L, // fub_Arab_CM
+        0x8E8557464C61746EL, // fud_Latn_WF
+        0x9685474E4C61746EL, // fuf_Latn_GN
+        0xC2854E454C61746EL, // fuq_Latn_NE
+        0xC68549544C61746EL, // fur_Latn_IT
+        0xD6854E474C61746EL, // fuv_Latn_NG
+        0xC6A553444C61746EL, // fvr_Latn_SD
+        0x66794E4C4C61746EL, // fy_Latn_NL
+        0x676149454C61746EL, // ga_Latn_IE
+        0x800647484C61746EL, // gaa_Latn_GH
+        0x98064D444C61746EL, // gag_Latn_MD
+        0xB406434E48616E73L, // gan_Hans_CN
+        0xE00649444C61746EL, // gay_Latn_ID
+        0xB026494E44657661L, // gbm_Deva_IN
+        0xE426495241726162L, // gbz_Arab_IR
+        0xC44647464C61746EL, // gcr_Latn_GF
+        0x676447424C61746EL, // gd_Latn_GB
+        0xE486455445746869L, // gez_Ethi_ET
+        0xB4C64E5044657661L, // ggn_Deva_NP
+        0xAD064B494C61746EL, // gil_Latn_KI
+        0xA926504B41726162L, // gjk_Arab_PK
+        0xD126504B41726162L, // gju_Arab_PK
+        0x676C45534C61746EL, // gl_Latn_ES
+        0xA966495241726162L, // glk_Arab_IR
+        0x676E50594C61746EL, // gn_Latn_PY
+        0xB1C6494E44657661L, // gom_Deva_IN
+        0xB5C6494E54656C75L, // gon_Telu_IN
+        0xC5C649444C61746EL, // gor_Latn_ID
+        0xC9C64E4C4C61746EL, // gos_Latn_NL
+        0xCDC65541476F7468L, // got_Goth_UA
+        0x8A26435943707274L, // grc_Cprt_CY
+        0x8A2647524C696E62L, // grc_Linb_GR
+        0xCE26494E42656E67L, // grt_Beng_IN
+        0xDA4643484C61746EL, // gsw_Latn_CH
+        0x6775494E47756A72L, // gu_Gujr_IN
+        0x868642524C61746EL, // gub_Latn_BR
+        0x8A86434F4C61746EL, // guc_Latn_CO
+        0xC68647484C61746EL, // gur_Latn_GH
+        0xE6864B454C61746EL, // guz_Latn_KE
+        0x6776494D4C61746EL, // gv_Latn_IM
+        0xC6A64E5044657661L, // gvr_Deva_NP
+        0xA2C643414C61746EL, // gwi_Latn_CA
+        0x68614E474C61746EL, // ha_Latn_NG
+        0xA807434E48616E73L, // hak_Hans_CN
+        0xD80755534C61746EL, // haw_Latn_US
+        0xE407414641726162L, // haz_Arab_AF
+        0x6865494C48656272L, // he_Hebr_IL
+        0x6869494E44657661L, // hi_Deva_IN
+        0x9507464A4C61746EL, // hif_Latn_FJ
+        0xAD0750484C61746EL, // hil_Latn_PH
+        0xD1675452486C7577L, // hlu_Hluw_TR
+        0x8D87434E506C7264L, // hmd_Plrd_CN
+        0x8DA7504B41726162L, // hnd_Arab_PK
+        0x91A7494E44657661L, // hne_Deva_IN
+        0xA5A74C41486D6E67L, // hnj_Hmng_LA
+        0xB5A750484C61746EL, // hnn_Latn_PH
+        0xB9A7504B41726162L, // hno_Arab_PK
+        0x686F50474C61746EL, // ho_Latn_PG
+        0x89C7494E44657661L, // hoc_Deva_IN
+        0xA5C7494E44657661L, // hoj_Deva_IN
+        0x687248524C61746EL, // hr_Latn_HR
+        0x864744454C61746EL, // hsb_Latn_DE
+        0xB647434E48616E73L, // hsn_Hans_CN
+        0x687448544C61746EL, // ht_Latn_HT
+        0x687548554C61746EL, // hu_Latn_HU
+        0x6879414D41726D6EL, // hy_Armn_AM
+        0x687A4E414C61746EL, // hz_Latn_NA
+        0x696146524C61746EL, // ia_Latn_FR
+        0x80284D594C61746EL, // iba_Latn_MY
+        0x84284E474C61746EL, // ibb_Latn_NG
+        0x696449444C61746EL, // id_Latn_ID
+        0x69674E474C61746EL, // ig_Latn_NG
+        0x6969434E59696969L, // ii_Yiii_CN
+        0x696B55534C61746EL, // ik_Latn_US
+        0xCD4843414C61746EL, // ikt_Latn_CA
+        0xB96850484C61746EL, // ilo_Latn_PH
+        0x696E49444C61746EL, // in_Latn_ID
+        0x9DA852554379726CL, // inh_Cyrl_RU
+        0x697349534C61746EL, // is_Latn_IS
+        0x697449544C61746EL, // it_Latn_IT
+        0x6975434143616E73L, // iu_Cans_CA
+        0x6977494C48656272L, // iw_Hebr_IL
+        0x9F2852554C61746EL, // izh_Latn_RU
+        0x6A614A504A70616EL, // ja_Jpan_JP
+        0xB0094A4D4C61746EL, // jam_Latn_JM
+        0xB8C9434D4C61746EL, // jgo_Latn_CM
+        0x8989545A4C61746EL, // jmc_Latn_TZ
+        0xAD894E5044657661L, // jml_Deva_NP
+        0xCE89444B4C61746EL, // jut_Latn_DK
+        0x6A7649444C61746EL, // jv_Latn_ID
+        0x6A7749444C61746EL, // jw_Latn_ID
+        0x6B61474547656F72L, // ka_Geor_GE
+        0x800A555A4379726CL, // kaa_Cyrl_UZ
+        0x840A445A4C61746EL, // kab_Latn_DZ
+        0x880A4D4D4C61746EL, // kac_Latn_MM
+        0xA40A4E474C61746EL, // kaj_Latn_NG
+        0xB00A4B454C61746EL, // kam_Latn_KE
+        0xB80A4D4C4C61746EL, // kao_Latn_ML
+        0x8C2A52554379726CL, // kbd_Cyrl_RU
+        0xE02A4E4541726162L, // kby_Arab_NE
+        0x984A4E474C61746EL, // kcg_Latn_NG
+        0xA84A5A574C61746EL, // kck_Latn_ZW
+        0x906A545A4C61746EL, // kde_Latn_TZ
+        0x9C6A544741726162L, // kdh_Arab_TG
+        0xCC6A544854686169L, // kdt_Thai_TH
+        0x808A43564C61746EL, // kea_Latn_CV
+        0xB48A434D4C61746EL, // ken_Latn_CM
+        0xB8AA43494C61746EL, // kfo_Latn_CI
+        0xC4AA494E44657661L, // kfr_Deva_IN
+        0xE0AA494E44657661L, // kfy_Deva_IN
+        0x6B6743444C61746EL, // kg_Latn_CD
+        0x90CA49444C61746EL, // kge_Latn_ID
+        0xBCCA42524C61746EL, // kgp_Latn_BR
+        0x80EA494E4C61746EL, // kha_Latn_IN
+        0x84EA434E54616C75L, // khb_Talu_CN
+        0xB4EA494E44657661L, // khn_Deva_IN
+        0xC0EA4D4C4C61746EL, // khq_Latn_ML
+        0xCCEA494E4D796D72L, // kht_Mymr_IN
+        0xD8EA504B41726162L, // khw_Arab_PK
+        0x6B694B454C61746EL, // ki_Latn_KE
+        0xD10A54524C61746EL, // kiu_Latn_TR
+        0x6B6A4E414C61746EL, // kj_Latn_NA
+        0x992A4C414C616F6FL, // kjg_Laoo_LA
+        0x6B6B434E41726162L, // kk_Arab_CN
+        0x6B6B4B5A4379726CL, // kk_Cyrl_KZ
+        0xA54A434D4C61746EL, // kkj_Latn_CM
+        0x6B6C474C4C61746EL, // kl_Latn_GL
+        0xB56A4B454C61746EL, // kln_Latn_KE
+        0x6B6D4B484B686D72L, // km_Khmr_KH
+        0x858A414F4C61746EL, // kmb_Latn_AO
+        0x6B6E494E4B6E6461L, // kn_Knda_IN
+        0x6B6F4B524B6F7265L, // ko_Kore_KR
+        0xA1CA52554379726CL, // koi_Cyrl_RU
+        0xA9CA494E44657661L, // kok_Deva_IN
+        0xC9CA464D4C61746EL, // kos_Latn_FM
+        0x91EA4C524C61746EL, // kpe_Latn_LR
+        0x8A2A52554379726CL, // krc_Cyrl_RU
+        0xA22A534C4C61746EL, // kri_Latn_SL
+        0xA62A50484C61746EL, // krj_Latn_PH
+        0xAE2A52554C61746EL, // krl_Latn_RU
+        0xD22A494E44657661L, // kru_Deva_IN
+        0x6B73494E41726162L, // ks_Arab_IN
+        0x864A545A4C61746EL, // ksb_Latn_TZ
+        0x964A434D4C61746EL, // ksf_Latn_CM
+        0x9E4A44454C61746EL, // ksh_Latn_DE
+        0x6B75495141726162L, // ku_Arab_IQ
+        0x6B7554524C61746EL, // ku_Latn_TR
+        0xB28A52554379726CL, // kum_Cyrl_RU
+        0x6B7652554379726CL, // kv_Cyrl_RU
+        0xC6AA49444C61746EL, // kvr_Latn_ID
+        0xDEAA504B41726162L, // kvx_Arab_PK
+        0x6B7747424C61746EL, // kw_Latn_GB
+        0xB2EA544854686169L, // kxm_Thai_TH
+        0xBEEA504B41726162L, // kxp_Arab_PK
+        0x6B79434E41726162L, // ky_Arab_CN
+        0x6B794B474379726CL, // ky_Cyrl_KG
+        0x6B7954524C61746EL, // ky_Latn_TR
+        0x6C6156414C61746EL, // la_Latn_VA
+        0x840B47524C696E61L, // lab_Lina_GR
+        0x8C0B494C48656272L, // lad_Hebr_IL
+        0x980B545A4C61746EL, // lag_Latn_TZ
+        0x9C0B504B41726162L, // lah_Arab_PK
+        0xA40B55474C61746EL, // laj_Latn_UG
+        0x6C624C554C61746EL, // lb_Latn_LU
+        0x902B52554379726CL, // lbe_Cyrl_RU
+        0xD82B49444C61746EL, // lbw_Latn_ID
+        0xBC4B434E54686169L, // lcp_Thai_CN
+        0xBC8B494E4C657063L, // lep_Lepc_IN
+        0xE48B52554379726CL, // lez_Cyrl_RU
+        0x6C6755474C61746EL, // lg_Latn_UG
+        0x6C694E4C4C61746EL, // li_Latn_NL
+        0x950B4E5044657661L, // lif_Deva_NP
+        0x950B494E4C696D62L, // lif_Limb_IN
+        0xA50B49544C61746EL, // lij_Latn_IT
+        0xC90B434E4C697375L, // lis_Lisu_CN
+        0xBD2B49444C61746EL, // ljp_Latn_ID
+        0xA14B495241726162L, // lki_Arab_IR
+        0xCD4B55534C61746EL, // lkt_Latn_US
+        0xB58B494E54656C75L, // lmn_Telu_IN
+        0xB98B49544C61746EL, // lmo_Latn_IT
+        0x6C6E43444C61746EL, // ln_Latn_CD
+        0x6C6F4C414C616F6FL, // lo_Laoo_LA
+        0xADCB43444C61746EL, // lol_Latn_CD
+        0xE5CB5A4D4C61746EL, // loz_Latn_ZM
+        0x8A2B495241726162L, // lrc_Arab_IR
+        0x6C744C544C61746EL, // lt_Latn_LT
+        0x9A6B4C564C61746EL, // ltg_Latn_LV
+        0x6C7543444C61746EL, // lu_Latn_CD
+        0x828B43444C61746EL, // lua_Latn_CD
+        0xBA8B4B454C61746EL, // luo_Latn_KE
+        0xE28B4B454C61746EL, // luy_Latn_KE
+        0xE68B495241726162L, // luz_Arab_IR
+        0x6C764C564C61746EL, // lv_Latn_LV
+        0xAECB544854686169L, // lwl_Thai_TH
+        0x9F2B434E48616E73L, // lzh_Hans_CN
+        0xE72B54524C61746EL, // lzz_Latn_TR
+        0x8C0C49444C61746EL, // mad_Latn_ID
+        0x940C434D4C61746EL, // maf_Latn_CM
+        0x980C494E44657661L, // mag_Deva_IN
+        0xA00C494E44657661L, // mai_Deva_IN
+        0xA80C49444C61746EL, // mak_Latn_ID
+        0xB40C474D4C61746EL, // man_Latn_GM
+        0xB40C474E4E6B6F6FL, // man_Nkoo_GN
+        0xC80C4B454C61746EL, // mas_Latn_KE
+        0xE40C4D584C61746EL, // maz_Latn_MX
+        0x946C52554379726CL, // mdf_Cyrl_RU
+        0x9C6C50484C61746EL, // mdh_Latn_PH
+        0xC46C49444C61746EL, // mdr_Latn_ID
+        0xB48C534C4C61746EL, // men_Latn_SL
+        0xC48C4B454C61746EL, // mer_Latn_KE
+        0x80AC544841726162L, // mfa_Arab_TH
+        0x90AC4D554C61746EL, // mfe_Latn_MU
+        0x6D674D474C61746EL, // mg_Latn_MG
+        0x9CCC4D5A4C61746EL, // mgh_Latn_MZ
+        0xB8CC434D4C61746EL, // mgo_Latn_CM
+        0xBCCC4E5044657661L, // mgp_Deva_NP
+        0xE0CC545A4C61746EL, // mgy_Latn_TZ
+        0x6D684D484C61746EL, // mh_Latn_MH
+        0x6D694E5A4C61746EL, // mi_Latn_NZ
+        0xB50C49444C61746EL, // min_Latn_ID
+        0xC90C495148617472L, // mis_Hatr_IQ
+        0x6D6B4D4B4379726CL, // mk_Cyrl_MK
+        0x6D6C494E4D6C796DL, // ml_Mlym_IN
+        0xC96C53444C61746EL, // mls_Latn_SD
+        0x6D6E4D4E4379726CL, // mn_Cyrl_MN
+        0x6D6E434E4D6F6E67L, // mn_Mong_CN
+        0xA1AC494E42656E67L, // mni_Beng_IN
+        0xD9AC4D4D4D796D72L, // mnw_Mymr_MM
+        0x91CC43414C61746EL, // moe_Latn_CA
+        0x9DCC43414C61746EL, // moh_Latn_CA
+        0xC9CC42464C61746EL, // mos_Latn_BF
+        0x6D72494E44657661L, // mr_Deva_IN
+        0x8E2C4E5044657661L, // mrd_Deva_NP
+        0xA62C52554379726CL, // mrj_Cyrl_RU
+        0xBA2C42444D726F6FL, // mro_Mroo_BD
+        0x6D734D594C61746EL, // ms_Latn_MY
+        0x6D744D544C61746EL, // mt_Latn_MT
+        0xC66C494E44657661L, // mtr_Deva_IN
+        0x828C434D4C61746EL, // mua_Latn_CM
+        0xCA8C55534C61746EL, // mus_Latn_US
+        0xE2AC504B41726162L, // mvy_Arab_PK
+        0xAACC4D4C4C61746EL, // mwk_Latn_ML
+        0xC6CC494E44657661L, // mwr_Deva_IN
+        0xD6CC49444C61746EL, // mwv_Latn_ID
+        0x8AEC5A574C61746EL, // mxc_Latn_ZW
+        0x6D794D4D4D796D72L, // my_Mymr_MM
+        0xD70C52554379726CL, // myv_Cyrl_RU
+        0xDF0C55474C61746EL, // myx_Latn_UG
+        0xE70C49524D616E64L, // myz_Mand_IR
+        0xB72C495241726162L, // mzn_Arab_IR
+        0x6E614E524C61746EL, // na_Latn_NR
+        0xB40D434E48616E73L, // nan_Hans_CN
+        0xBC0D49544C61746EL, // nap_Latn_IT
+        0xC00D4E414C61746EL, // naq_Latn_NA
+        0x6E624E4F4C61746EL, // nb_Latn_NO
+        0x9C4D4D584C61746EL, // nch_Latn_MX
+        0x6E645A574C61746EL, // nd_Latn_ZW
+        0x886D4D5A4C61746EL, // ndc_Latn_MZ
+        0xC86D44454C61746EL, // nds_Latn_DE
+        0x6E654E5044657661L, // ne_Deva_NP
+        0xD88D4E5044657661L, // new_Deva_NP
+        0x6E674E414C61746EL, // ng_Latn_NA
+        0xACCD4D5A4C61746EL, // ngl_Latn_MZ
+        0x90ED4D584C61746EL, // nhe_Latn_MX
+        0xD8ED4D584C61746EL, // nhw_Latn_MX
+        0xA50D49444C61746EL, // nij_Latn_ID
+        0xD10D4E554C61746EL, // niu_Latn_NU
+        0xB92D494E4C61746EL, // njo_Latn_IN
+        0x6E6C4E4C4C61746EL, // nl_Latn_NL
+        0x998D434D4C61746EL, // nmg_Latn_CM
+        0x6E6E4E4F4C61746EL, // nn_Latn_NO
+        0x9DAD434D4C61746EL, // nnh_Latn_CM
+        0x6E6F4E4F4C61746EL, // no_Latn_NO
+        0x8DCD54484C616E61L, // nod_Lana_TH
+        0x91CD494E44657661L, // noe_Deva_IN
+        0xB5CD534552756E72L, // non_Runr_SE
+        0xBA0D474E4E6B6F6FL, // nqo_Nkoo_GN
+        0x6E725A414C61746EL, // nr_Latn_ZA
+        0xAA4D434143616E73L, // nsk_Cans_CA
+        0xBA4D5A414C61746EL, // nso_Latn_ZA
+        0xCA8D53534C61746EL, // nus_Latn_SS
+        0x6E7655534C61746EL, // nv_Latn_US
+        0xC2ED434E4C61746EL, // nxq_Latn_CN
+        0x6E794D574C61746EL, // ny_Latn_MW
+        0xB30D545A4C61746EL, // nym_Latn_TZ
+        0xB70D55474C61746EL, // nyn_Latn_UG
+        0xA32D47484C61746EL, // nzi_Latn_GH
+        0x6F6346524C61746EL, // oc_Latn_FR
+        0x6F6D45544C61746EL, // om_Latn_ET
+        0x6F72494E4F727961L, // or_Orya_IN
+        0x6F7347454379726CL, // os_Cyrl_GE
+        0x824E55534F736765L, // osa_Osge_US
+        0xAA6E4D4E4F726B68L, // otk_Orkh_MN
+        0x7061504B41726162L, // pa_Arab_PK
+        0x7061494E47757275L, // pa_Guru_IN
+        0x980F50484C61746EL, // pag_Latn_PH
+        0xAC0F495250686C69L, // pal_Phli_IR
+        0xAC0F434E50686C70L, // pal_Phlp_CN
+        0xB00F50484C61746EL, // pam_Latn_PH
+        0xBC0F41574C61746EL, // pap_Latn_AW
+        0xD00F50574C61746EL, // pau_Latn_PW
+        0x8C4F46524C61746EL, // pcd_Latn_FR
+        0xB04F4E474C61746EL, // pcm_Latn_NG
+        0x886F55534C61746EL, // pdc_Latn_US
+        0xCC6F43414C61746EL, // pdt_Latn_CA
+        0xB88F49525870656FL, // peo_Xpeo_IR
+        0xACAF44454C61746EL, // pfl_Latn_DE
+        0xB4EF4C4250686E78L, // phn_Phnx_LB
+        0x814F494E42726168L, // pka_Brah_IN
+        0xB94F4B454C61746EL, // pko_Latn_KE
+        0x706C504C4C61746EL, // pl_Latn_PL
+        0xC98F49544C61746EL, // pms_Latn_IT
+        0xCDAF47524772656BL, // pnt_Grek_GR
+        0xB5CF464D4C61746EL, // pon_Latn_FM
+        0x822F504B4B686172L, // pra_Khar_PK
+        0x8E2F495241726162L, // prd_Arab_IR
+        0x7073414641726162L, // ps_Arab_AF
+        0x707442524C61746EL, // pt_Latn_BR
+        0xD28F47414C61746EL, // puu_Latn_GA
+        0x717550454C61746EL, // qu_Latn_PE
+        0x8A9047544C61746EL, // quc_Latn_GT
+        0x9A9045434C61746EL, // qug_Latn_EC
+        0xA411494E44657661L, // raj_Deva_IN
+        0x945152454C61746EL, // rcf_Latn_RE
+        0xA49149444C61746EL, // rej_Latn_ID
+        0xB4D149544C61746EL, // rgn_Latn_IT
+        0x8111494E4C61746EL, // ria_Latn_IN
+        0x95114D4154666E67L, // rif_Tfng_MA
+        0xC9314E5044657661L, // rjs_Deva_NP
+        0xCD51424442656E67L, // rkt_Beng_BD
+        0x726D43484C61746EL, // rm_Latn_CH
+        0x959146494C61746EL, // rmf_Latn_FI
+        0xB99143484C61746EL, // rmo_Latn_CH
+        0xCD91495241726162L, // rmt_Arab_IR
+        0xD19153454C61746EL, // rmu_Latn_SE
+        0x726E42494C61746EL, // rn_Latn_BI
+        0x99B14D5A4C61746EL, // rng_Latn_MZ
+        0x726F524F4C61746EL, // ro_Latn_RO
+        0x85D149444C61746EL, // rob_Latn_ID
+        0x95D1545A4C61746EL, // rof_Latn_TZ
+        0xB271464A4C61746EL, // rtm_Latn_FJ
+        0x727552554379726CL, // ru_Cyrl_RU
+        0x929155414379726CL, // rue_Cyrl_UA
+        0x9A9153424C61746EL, // rug_Latn_SB
+        0x727752574C61746EL, // rw_Latn_RW
+        0xAAD1545A4C61746EL, // rwk_Latn_TZ
+        0xD3114A504B616E61L, // ryu_Kana_JP
+        0x7361494E44657661L, // sa_Deva_IN
+        0x941247484C61746EL, // saf_Latn_GH
+        0x9C1252554379726CL, // sah_Cyrl_RU
+        0xC0124B454C61746EL, // saq_Latn_KE
+        0xC81249444C61746EL, // sas_Latn_ID
+        0xCC12494E4C61746EL, // sat_Latn_IN
+        0xE412494E53617572L, // saz_Saur_IN
+        0xBC32545A4C61746EL, // sbp_Latn_TZ
+        0x736349544C61746EL, // sc_Latn_IT
+        0xA852494E44657661L, // sck_Deva_IN
+        0xB45249544C61746EL, // scn_Latn_IT
+        0xB85247424C61746EL, // sco_Latn_GB
+        0xC85243414C61746EL, // scs_Latn_CA
+        0x7364504B41726162L, // sd_Arab_PK
+        0x7364494E44657661L, // sd_Deva_IN
+        0x7364494E4B686F6AL, // sd_Khoj_IN
+        0x7364494E53696E64L, // sd_Sind_IN
+        0x887249544C61746EL, // sdc_Latn_IT
+        0x9C72495241726162L, // sdh_Arab_IR
+        0x73654E4F4C61746EL, // se_Latn_NO
+        0x949243494C61746EL, // sef_Latn_CI
+        0x9C924D5A4C61746EL, // seh_Latn_MZ
+        0xA0924D584C61746EL, // sei_Latn_MX
+        0xC8924D4C4C61746EL, // ses_Latn_ML
+        0x736743464C61746EL, // sg_Latn_CF
+        0x80D249454F67616DL, // sga_Ogam_IE
+        0xC8D24C544C61746EL, // sgs_Latn_LT
+        0xA0F24D4154666E67L, // shi_Tfng_MA
+        0xB4F24D4D4D796D72L, // shn_Mymr_MM
+        0x73694C4B53696E68L, // si_Sinh_LK
+        0x8D1245544C61746EL, // sid_Latn_ET
+        0x736B534B4C61746EL, // sk_Latn_SK
+        0xC552504B41726162L, // skr_Arab_PK
+        0x736C53494C61746EL, // sl_Latn_SI
+        0xA172504C4C61746EL, // sli_Latn_PL
+        0xE17249444C61746EL, // sly_Latn_ID
+        0x736D57534C61746EL, // sm_Latn_WS
+        0x819253454C61746EL, // sma_Latn_SE
+        0xA59253454C61746EL, // smj_Latn_SE
+        0xB59246494C61746EL, // smn_Latn_FI
+        0xBD92494C53616D72L, // smp_Samr_IL
+        0xC99246494C61746EL, // sms_Latn_FI
+        0x736E5A574C61746EL, // sn_Latn_ZW
+        0xA9B24D4C4C61746EL, // snk_Latn_ML
+        0x736F534F4C61746EL, // so_Latn_SO
+        0xD1D2544854686169L, // sou_Thai_TH
+        0x7371414C4C61746EL, // sq_Latn_AL
+        0x737252534379726CL, // sr_Cyrl_RS
+        0x737252534C61746EL, // sr_Latn_RS
+        0x8632494E536F7261L, // srb_Sora_IN
+        0xB63253524C61746EL, // srn_Latn_SR
+        0xC632534E4C61746EL, // srr_Latn_SN
+        0xDE32494E44657661L, // srx_Deva_IN
+        0x73735A414C61746EL, // ss_Latn_ZA
+        0xE25245524C61746EL, // ssy_Latn_ER
+        0x73745A414C61746EL, // st_Latn_ZA
+        0xC27244454C61746EL, // stq_Latn_DE
+        0x737549444C61746EL, // su_Latn_ID
+        0xAA92545A4C61746EL, // suk_Latn_TZ
+        0xCA92474E4C61746EL, // sus_Latn_GN
+        0x737653454C61746EL, // sv_Latn_SE
+        0x7377545A4C61746EL, // sw_Latn_TZ
+        0x86D2595441726162L, // swb_Arab_YT
+        0x8AD243444C61746EL, // swc_Latn_CD
+        0x9AD244454C61746EL, // swg_Latn_DE
+        0xD6D2494E44657661L, // swv_Deva_IN
+        0xB6F249444C61746EL, // sxn_Latn_ID
+        0xAF12424442656E67L, // syl_Beng_BD
+        0xC712495153797263L, // syr_Syrc_IQ
+        0xAF32504C4C61746EL, // szl_Latn_PL
+        0x7461494E54616D6CL, // ta_Taml_IN
+        0xA4134E5044657661L, // taj_Deva_NP
+        0xD83350484C61746EL, // tbw_Latn_PH
+        0xE053494E4B6E6461L, // tcy_Knda_IN
+        0x8C73434E54616C65L, // tdd_Tale_CN
+        0x98734E5044657661L, // tdg_Deva_NP
+        0x9C734E5044657661L, // tdh_Deva_NP
+        0x7465494E54656C75L, // te_Telu_IN
+        0xB093534C4C61746EL, // tem_Latn_SL
+        0xB89355474C61746EL, // teo_Latn_UG
+        0xCC93544C4C61746EL, // tet_Latn_TL
+        0x7467504B41726162L, // tg_Arab_PK
+        0x7467544A4379726CL, // tg_Cyrl_TJ
+        0x7468544854686169L, // th_Thai_TH
+        0xACF34E5044657661L, // thl_Deva_NP
+        0xC0F34E5044657661L, // thq_Deva_NP
+        0xC4F34E5044657661L, // thr_Deva_NP
+        0x7469455445746869L, // ti_Ethi_ET
+        0x9913455245746869L, // tig_Ethi_ER
+        0xD5134E474C61746EL, // tiv_Latn_NG
+        0x746B544D4C61746EL, // tk_Latn_TM
+        0xAD53544B4C61746EL, // tkl_Latn_TK
+        0xC553415A4C61746EL, // tkr_Latn_AZ
+        0xCD534E5044657661L, // tkt_Deva_NP
+        0x746C50484C61746EL, // tl_Latn_PH
+        0xE173415A4C61746EL, // tly_Latn_AZ
+        0x9D934E454C61746EL, // tmh_Latn_NE
+        0x746E5A414C61746EL, // tn_Latn_ZA
+        0x746F544F4C61746EL, // to_Latn_TO
+        0x99D34D574C61746EL, // tog_Latn_MW
+        0xA1F350474C61746EL, // tpi_Latn_PG
+        0x747254524C61746EL, // tr_Latn_TR
+        0xD23354524C61746EL, // tru_Latn_TR
+        0xD63354574C61746EL, // trv_Latn_TW
+        0x74735A414C61746EL, // ts_Latn_ZA
+        0x8E5347524772656BL, // tsd_Grek_GR
+        0x96534E5044657661L, // tsf_Deva_NP
+        0x9A5350484C61746EL, // tsg_Latn_PH
+        0xA653425454696274L, // tsj_Tibt_BT
+        0x747452554379726CL, // tt_Cyrl_RU
+        0xA67355474C61746EL, // ttj_Latn_UG
+        0xCA73544854686169L, // tts_Thai_TH
+        0xCE73415A4C61746EL, // ttt_Latn_AZ
+        0xB2934D574C61746EL, // tum_Latn_MW
+        0xAEB354564C61746EL, // tvl_Latn_TV
+        0xC2D34E454C61746EL, // twq_Latn_NE
+        0x9AF3434E54616E67L, // txg_Tang_CN
+        0x747950464C61746EL, // ty_Latn_PF
+        0xD71352554379726CL, // tyv_Cyrl_RU
+        0xB3334D414C61746EL, // tzm_Latn_MA
+        0xB07452554379726CL, // udm_Cyrl_RU
+        0x7567434E41726162L, // ug_Arab_CN
+        0x75674B5A4379726CL, // ug_Cyrl_KZ
+        0x80D4535955676172L, // uga_Ugar_SY
+        0x756B55414379726CL, // uk_Cyrl_UA
+        0xA174464D4C61746EL, // uli_Latn_FM
+        0x8594414F4C61746EL, // umb_Latn_AO
+        0xC5B4494E42656E67L, // unr_Beng_IN
+        0xC5B44E5044657661L, // unr_Deva_NP
+        0xDDB4494E42656E67L, // unx_Beng_IN
+        0x7572504B41726162L, // ur_Arab_PK
+        0x757A414641726162L, // uz_Arab_AF
+        0x757A555A4C61746EL, // uz_Latn_UZ
+        0xA0154C5256616969L, // vai_Vaii_LR
+        0x76655A414C61746EL, // ve_Latn_ZA
+        0x889549544C61746EL, // vec_Latn_IT
+        0xBC9552554C61746EL, // vep_Latn_RU
+        0x7669564E4C61746EL, // vi_Latn_VN
+        0x891553584C61746EL, // vic_Latn_SX
+        0xC97542454C61746EL, // vls_Latn_BE
+        0x959544454C61746EL, // vmf_Latn_DE
+        0xD9954D5A4C61746EL, // vmw_Latn_MZ
+        0xCDD552554C61746EL, // vot_Latn_RU
+        0xBA3545454C61746EL, // vro_Latn_EE
+        0xB695545A4C61746EL, // vun_Latn_TZ
+        0x776142454C61746EL, // wa_Latn_BE
+        0x901643484C61746EL, // wae_Latn_CH
+        0xAC16455445746869L, // wal_Ethi_ET
+        0xC41650484C61746EL, // war_Latn_PH
+        0xBC3641554C61746EL, // wbp_Latn_AU
+        0xC036494E54656C75L, // wbq_Telu_IN
+        0xC436494E44657661L, // wbr_Deva_IN
+        0xC97657464C61746EL, // wls_Latn_WF
+        0xA1B64B4D41726162L, // wni_Arab_KM
+        0x776F534E4C61746EL, // wo_Latn_SN
+        0xB276494E44657661L, // wtm_Deva_IN
+        0xD296434E48616E73L, // wuu_Hans_CN
+        0xD41742524C61746EL, // xav_Latn_BR
+        0xC457545243617269L, // xcr_Cari_TR
+        0x78685A414C61746EL, // xh_Latn_ZA
+        0x897754524C796369L, // xlc_Lyci_TR
+        0x8D7754524C796469L, // xld_Lydi_TR
+        0x9597474547656F72L, // xmf_Geor_GE
+        0xB597434E4D616E69L, // xmn_Mani_CN
+        0xC59753444D657263L, // xmr_Merc_SD
+        0x81B753414E617262L, // xna_Narb_SA
+        0xC5B7494E44657661L, // xnr_Deva_IN
+        0x99D755474C61746EL, // xog_Latn_UG
+        0xC5F7495250727469L, // xpr_Prti_IR
+        0x8257594553617262L, // xsa_Sarb_YE
+        0xC6574E5044657661L, // xsr_Deva_NP
+        0xB8184D5A4C61746EL, // yao_Latn_MZ
+        0xBC18464D4C61746EL, // yap_Latn_FM
+        0xD418434D4C61746EL, // yav_Latn_CM
+        0x8438434D4C61746EL, // ybb_Latn_CM
+        0x796F4E474C61746EL, // yo_Latn_NG
+        0xAE3842524C61746EL, // yrl_Latn_BR
+        0x82984D584C61746EL, // yua_Latn_MX
+        0x9298434E48616E73L, // yue_Hans_CN
+        0x9298484B48616E74L, // yue_Hant_HK
+        0x7A61434E4C61746EL, // za_Latn_CN
+        0x981953444C61746EL, // zag_Latn_SD
+        0xA4794B4D41726162L, // zdj_Arab_KM
+        0x80994E4C4C61746EL, // zea_Latn_NL
+        0x9CD94D4154666E67L, // zgh_Tfng_MA
+        0x7A685457426F706FL, // zh_Bopo_TW
+        0x7A68545748616E62L, // zh_Hanb_TW
+        0x7A68434E48616E73L, // zh_Hans_CN
+        0x7A68545748616E74L, // zh_Hant_TW
+        0xB17954474C61746EL, // zlm_Latn_TG
+        0xA1994D594C61746EL, // zmi_Latn_MY
+        0x7A755A414C61746EL, // zu_Latn_ZA
+        0x833954524C61746EL, // zza_Latn_TR
+    };
+
+    HashSet<Long> buildSet = new HashSet<>();
+    for (long entry : entries) {
+      buildSet.add(entry);
+    }
+    REPRESENTATIVE_LOCALES = Collections.unmodifiableSet(buildSet);
+  }
+
+  static final Map<Integer, Integer> ARAB_PARENTS;
+
+  static {
+    int[][] entries = {
+        {0x6172445A, 0x61729420}, // ar-DZ -> ar-015
+        {0x61724548, 0x61729420}, // ar-EH -> ar-015
+        {0x61724C59, 0x61729420}, // ar-LY -> ar-015
+        {0x61724D41, 0x61729420}, // ar-MA -> ar-015
+        {0x6172544E, 0x61729420}, // ar-TN -> ar-015
+    };
+
+    Map<Integer, Integer> buildMap = new HashMap<>();
+    for (int[] entry : entries) {
+      buildMap.put(entry[0], entry[1]);
+    }
+    ARAB_PARENTS = Collections.unmodifiableMap(buildMap);
+  }
+
+  static final Map<Integer, Integer> HANT_PARENTS;
+
+  static {
+    int[][] entries = {
+        {0x7A684D4F, 0x7A68484B}, // zh-Hant-MO -> zh-Hant-HK
+    };
+
+    Map<Integer, Integer> buildMap = new HashMap<>();
+    for (int[] entry : entries) {
+      buildMap.put(entry[0], entry[1]);
+    }
+    HANT_PARENTS = Collections.unmodifiableMap(buildMap);
+  }
+
+  static final Map<Integer, Integer> LATN_PARENTS;
+
+  static {
+    int[][] entries = {
+        {0x656E80A1, 0x656E8400}, // en-150 -> en-001
+        {0x656E4147, 0x656E8400}, // en-AG -> en-001
+        {0x656E4149, 0x656E8400}, // en-AI -> en-001
+        {0x656E4154, 0x656E80A1}, // en-AT -> en-150
+        {0x656E4155, 0x656E8400}, // en-AU -> en-001
+        {0x656E4242, 0x656E8400}, // en-BB -> en-001
+        {0x656E4245, 0x656E8400}, // en-BE -> en-001
+        {0x656E424D, 0x656E8400}, // en-BM -> en-001
+        {0x656E4253, 0x656E8400}, // en-BS -> en-001
+        {0x656E4257, 0x656E8400}, // en-BW -> en-001
+        {0x656E425A, 0x656E8400}, // en-BZ -> en-001
+        {0x656E4341, 0x656E8400}, // en-CA -> en-001
+        {0x656E4343, 0x656E8400}, // en-CC -> en-001
+        {0x656E4348, 0x656E80A1}, // en-CH -> en-150
+        {0x656E434B, 0x656E8400}, // en-CK -> en-001
+        {0x656E434D, 0x656E8400}, // en-CM -> en-001
+        {0x656E4358, 0x656E8400}, // en-CX -> en-001
+        {0x656E4359, 0x656E8400}, // en-CY -> en-001
+        {0x656E4445, 0x656E80A1}, // en-DE -> en-150
+        {0x656E4447, 0x656E8400}, // en-DG -> en-001
+        {0x656E444B, 0x656E80A1}, // en-DK -> en-150
+        {0x656E444D, 0x656E8400}, // en-DM -> en-001
+        {0x656E4552, 0x656E8400}, // en-ER -> en-001
+        {0x656E4649, 0x656E80A1}, // en-FI -> en-150
+        {0x656E464A, 0x656E8400}, // en-FJ -> en-001
+        {0x656E464B, 0x656E8400}, // en-FK -> en-001
+        {0x656E464D, 0x656E8400}, // en-FM -> en-001
+        {0x656E4742, 0x656E8400}, // en-GB -> en-001
+        {0x656E4744, 0x656E8400}, // en-GD -> en-001
+        {0x656E4747, 0x656E8400}, // en-GG -> en-001
+        {0x656E4748, 0x656E8400}, // en-GH -> en-001
+        {0x656E4749, 0x656E8400}, // en-GI -> en-001
+        {0x656E474D, 0x656E8400}, // en-GM -> en-001
+        {0x656E4759, 0x656E8400}, // en-GY -> en-001
+        {0x656E484B, 0x656E8400}, // en-HK -> en-001
+        {0x656E4945, 0x656E8400}, // en-IE -> en-001
+        {0x656E494C, 0x656E8400}, // en-IL -> en-001
+        {0x656E494D, 0x656E8400}, // en-IM -> en-001
+        {0x656E494E, 0x656E8400}, // en-IN -> en-001
+        {0x656E494F, 0x656E8400}, // en-IO -> en-001
+        {0x656E4A45, 0x656E8400}, // en-JE -> en-001
+        {0x656E4A4D, 0x656E8400}, // en-JM -> en-001
+        {0x656E4B45, 0x656E8400}, // en-KE -> en-001
+        {0x656E4B49, 0x656E8400}, // en-KI -> en-001
+        {0x656E4B4E, 0x656E8400}, // en-KN -> en-001
+        {0x656E4B59, 0x656E8400}, // en-KY -> en-001
+        {0x656E4C43, 0x656E8400}, // en-LC -> en-001
+        {0x656E4C52, 0x656E8400}, // en-LR -> en-001
+        {0x656E4C53, 0x656E8400}, // en-LS -> en-001
+        {0x656E4D47, 0x656E8400}, // en-MG -> en-001
+        {0x656E4D4F, 0x656E8400}, // en-MO -> en-001
+        {0x656E4D53, 0x656E8400}, // en-MS -> en-001
+        {0x656E4D54, 0x656E8400}, // en-MT -> en-001
+        {0x656E4D55, 0x656E8400}, // en-MU -> en-001
+        {0x656E4D57, 0x656E8400}, // en-MW -> en-001
+        {0x656E4D59, 0x656E8400}, // en-MY -> en-001
+        {0x656E4E41, 0x656E8400}, // en-NA -> en-001
+        {0x656E4E46, 0x656E8400}, // en-NF -> en-001
+        {0x656E4E47, 0x656E8400}, // en-NG -> en-001
+        {0x656E4E4C, 0x656E80A1}, // en-NL -> en-150
+        {0x656E4E52, 0x656E8400}, // en-NR -> en-001
+        {0x656E4E55, 0x656E8400}, // en-NU -> en-001
+        {0x656E4E5A, 0x656E8400}, // en-NZ -> en-001
+        {0x656E5047, 0x656E8400}, // en-PG -> en-001
+        {0x656E5048, 0x656E8400}, // en-PH -> en-001
+        {0x656E504B, 0x656E8400}, // en-PK -> en-001
+        {0x656E504E, 0x656E8400}, // en-PN -> en-001
+        {0x656E5057, 0x656E8400}, // en-PW -> en-001
+        {0x656E5257, 0x656E8400}, // en-RW -> en-001
+        {0x656E5342, 0x656E8400}, // en-SB -> en-001
+        {0x656E5343, 0x656E8400}, // en-SC -> en-001
+        {0x656E5344, 0x656E8400}, // en-SD -> en-001
+        {0x656E5345, 0x656E80A1}, // en-SE -> en-150
+        {0x656E5347, 0x656E8400}, // en-SG -> en-001
+        {0x656E5348, 0x656E8400}, // en-SH -> en-001
+        {0x656E5349, 0x656E80A1}, // en-SI -> en-150
+        {0x656E534C, 0x656E8400}, // en-SL -> en-001
+        {0x656E5353, 0x656E8400}, // en-SS -> en-001
+        {0x656E5358, 0x656E8400}, // en-SX -> en-001
+        {0x656E535A, 0x656E8400}, // en-SZ -> en-001
+        {0x656E5443, 0x656E8400}, // en-TC -> en-001
+        {0x656E544B, 0x656E8400}, // en-TK -> en-001
+        {0x656E544F, 0x656E8400}, // en-TO -> en-001
+        {0x656E5454, 0x656E8400}, // en-TT -> en-001
+        {0x656E5456, 0x656E8400}, // en-TV -> en-001
+        {0x656E545A, 0x656E8400}, // en-TZ -> en-001
+        {0x656E5547, 0x656E8400}, // en-UG -> en-001
+        {0x656E5643, 0x656E8400}, // en-VC -> en-001
+        {0x656E5647, 0x656E8400}, // en-VG -> en-001
+        {0x656E5655, 0x656E8400}, // en-VU -> en-001
+        {0x656E5753, 0x656E8400}, // en-WS -> en-001
+        {0x656E5A41, 0x656E8400}, // en-ZA -> en-001
+        {0x656E5A4D, 0x656E8400}, // en-ZM -> en-001
+        {0x656E5A57, 0x656E8400}, // en-ZW -> en-001
+        {0x65734152, 0x6573A424}, // es-AR -> es-419
+        {0x6573424F, 0x6573A424}, // es-BO -> es-419
+        {0x65734252, 0x6573A424}, // es-BR -> es-419
+        {0x6573434C, 0x6573A424}, // es-CL -> es-419
+        {0x6573434F, 0x6573A424}, // es-CO -> es-419
+        {0x65734352, 0x6573A424}, // es-CR -> es-419
+        {0x65734355, 0x6573A424}, // es-CU -> es-419
+        {0x6573444F, 0x6573A424}, // es-DO -> es-419
+        {0x65734543, 0x6573A424}, // es-EC -> es-419
+        {0x65734754, 0x6573A424}, // es-GT -> es-419
+        {0x6573484E, 0x6573A424}, // es-HN -> es-419
+        {0x65734D58, 0x6573A424}, // es-MX -> es-419
+        {0x65734E49, 0x6573A424}, // es-NI -> es-419
+        {0x65735041, 0x6573A424}, // es-PA -> es-419
+        {0x65735045, 0x6573A424}, // es-PE -> es-419
+        {0x65735052, 0x6573A424}, // es-PR -> es-419
+        {0x65735059, 0x6573A424}, // es-PY -> es-419
+        {0x65735356, 0x6573A424}, // es-SV -> es-419
+        {0x65735553, 0x6573A424}, // es-US -> es-419
+        {0x65735559, 0x6573A424}, // es-UY -> es-419
+        {0x65735645, 0x6573A424}, // es-VE -> es-419
+        {0x7074414F, 0x70745054}, // pt-AO -> pt-PT
+        {0x70744348, 0x70745054}, // pt-CH -> pt-PT
+        {0x70744356, 0x70745054}, // pt-CV -> pt-PT
+        {0x70744751, 0x70745054}, // pt-GQ -> pt-PT
+        {0x70744757, 0x70745054}, // pt-GW -> pt-PT
+        {0x70744C55, 0x70745054}, // pt-LU -> pt-PT
+        {0x70744D4F, 0x70745054}, // pt-MO -> pt-PT
+        {0x70744D5A, 0x70745054}, // pt-MZ -> pt-PT
+        {0x70745354, 0x70745054}, // pt-ST -> pt-PT
+        {0x7074544C, 0x70745054}, // pt-TL -> pt-PT
+    };
+    Map<Integer, Integer> buildMap = new HashMap<>();
+    for (int[] entry : entries) {
+      buildMap.put(entry[0], entry[1]);
+    }
+    LATN_PARENTS = Collections.unmodifiableMap(buildMap);
+  }
+
+  static final Map<String, Map<Integer, Integer>> SCRIPT_PARENTS;
+
+  static {
+    Map<String, Map<Integer, Integer>> buildMap = new HashMap<>();
+    buildMap.put("Arab", ARAB_PARENTS);
+    buildMap.put("Hant", HANT_PARENTS);
+    buildMap.put("Latn", LATN_PARENTS);
+    SCRIPT_PARENTS = Collections.unmodifiableMap(buildMap);
+  }
+
+  static final int MAX_PARENT_DEPTH = 3;
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/NativeObjRegistry.java b/resources/src/main/java/org/robolectric/res/android/NativeObjRegistry.java
new file mode 100644
index 0000000..2b675c1
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/NativeObjRegistry.java
@@ -0,0 +1,191 @@
+package org.robolectric.res.android;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A unique id per object registry. Used to emulate android platform behavior of storing a long
+ * which represents a pointer to an object.
+ */
+public class NativeObjRegistry<T> {
+
+  private static final int INITIAL_ID = 1;
+
+  private final String name;
+  private final boolean debug;
+  private final BiMap<Long, T> nativeObjToIdMap = HashBiMap.create();
+  private final Map<Long, DebugInfo> idToDebugInfoMap;
+
+  private long nextId = INITIAL_ID;
+
+  public NativeObjRegistry(Class<T> theClass) {
+    this(theClass, false);
+  }
+
+  public NativeObjRegistry(Class<T> theClass, boolean debug) {
+    this(theClass.getSimpleName(), debug);
+  }
+
+  public NativeObjRegistry(String name) {
+    this(name, false);
+  }
+
+  public NativeObjRegistry(String name, boolean debug) {
+    this.name = name;
+    this.debug = debug;
+    this.idToDebugInfoMap = debug ? new HashMap<>() : null;
+  }
+
+  /**
+   * Retrieve the native id for given object. Assigns a new unique id to the object if not
+   * previously registered.
+   *
+   * @deprecated Use {@link #register(Object)} instead.
+   */
+  @Deprecated
+  public synchronized long getNativeObjectId(T o) {
+    checkNotNull(o);
+    Long nativeId = nativeObjToIdMap.inverse().get(o);
+    if (nativeId == null) {
+      nativeId = nextId;
+      if (debug) {
+        System.out.printf("NativeObjRegistry %s: register %d -> %s%n", name, nativeId, o);
+      }
+      nativeObjToIdMap.put(nativeId, o);
+      nextId++;
+    }
+    return nativeId;
+  }
+
+  /**
+   * Register and assign a new unique native id for given object (representing a C memory pointer).
+   *
+   * @throws IllegalStateException if the object was previously registered
+   */
+  public synchronized long register(T o) {
+    checkNotNull(o);
+    Long nativeId = nativeObjToIdMap.inverse().get(o);
+    if (nativeId != null) {
+      if (debug) {
+        DebugInfo debugInfo = idToDebugInfoMap.get(nativeId);
+        if (debugInfo != null) {
+          System.out.printf(
+              "NativeObjRegistry %s: register %d -> %s already registered:%n", name, nativeId, o);
+          debugInfo.registrationTrace.printStackTrace(System.out);
+        }
+      }
+      throw new IllegalStateException("Object was previously registered with id " + nativeId);
+    }
+
+    nativeId = nextId;
+    if (debug) {
+      System.out.printf("NativeObjRegistry %s: register %d -> %s%n", name, nativeId, o);
+      idToDebugInfoMap.put(nativeId, new DebugInfo(new Trace()));
+    }
+    nativeObjToIdMap.put(nativeId, o);
+    nextId++;
+    return nativeId;
+  }
+
+  /**
+   * Unregister an object previously registered with {@link #register(Object)}.
+   *
+   * @param nativeId the unique id (representing a C memory pointer) of the object to unregister.
+   * @throws IllegalStateException if the object was never registered, or was previously
+   *     unregistered.
+   */
+  public synchronized T unregister(long nativeId) {
+    T o = nativeObjToIdMap.remove(nativeId);
+    if (debug) {
+      System.out.printf("NativeObjRegistry %s: unregister %d -> %s%n", name, nativeId, o);
+      new RuntimeException("unregister debug").printStackTrace(System.out);
+    }
+    if (o == null) {
+      if (debug) {
+        DebugInfo debugInfo = idToDebugInfoMap.get(nativeId);
+        debugInfo.unregistrationTraces.add(new Trace());
+        if (debugInfo.unregistrationTraces.size() > 1) {
+          System.out.format("NativeObjRegistry %s: Too many unregistrations:%n", name);
+          for (Trace unregistration : debugInfo.unregistrationTraces) {
+            unregistration.printStackTrace(System.out);
+          }
+        }
+      }
+      throw new IllegalStateException(
+          nativeId + " has already been removed (or was never registered)");
+    }
+    return o;
+  }
+
+  /**
+   * @deprecated Use {@link #unregister(long)} instead.
+   */
+  @Deprecated
+  public synchronized void unregister(T removed) {
+    nativeObjToIdMap.inverse().remove(removed);
+  }
+
+  /** Retrieve the native object for given id. Throws if object with that id cannot be found */
+  public synchronized T getNativeObject(long nativeId) {
+    T object = nativeObjToIdMap.get(nativeId);
+    if (object != null) {
+      return object;
+    } else {
+      throw new NullPointerException(
+          String.format(
+              "Could not find object with nativeId: %d. Currently registered ids: %s",
+              nativeId, nativeObjToIdMap.keySet()));
+    }
+  }
+
+  /**
+   * Updates the native object for the given id.
+   *
+   * @throws IllegalStateException if no object was registered with the given id before
+   */
+  public synchronized void update(long nativeId, T o) {
+    T previous = nativeObjToIdMap.get(nativeId);
+    if (previous == null) {
+      throw new IllegalStateException("Native id " + nativeId + " was never registered");
+    }
+    if (debug) {
+      System.out.printf("NativeObjRegistry %s: update %d -> %s%n", name, nativeId, o);
+      idToDebugInfoMap.put(nativeId, new DebugInfo(new Trace()));
+    }
+    nativeObjToIdMap.put(nativeId, o);
+  }
+
+  /**
+   * Similar to {@link #getNativeObject(long)} but returns null if object with given id cannot be
+   * found.
+   */
+  public synchronized T peekNativeObject(long nativeId) {
+    return nativeObjToIdMap.get(nativeId);
+  }
+
+  /** WARNING -- dangerous! Call {@link #unregister(long)} instead! */
+  public synchronized void clear() {
+    nextId = INITIAL_ID;
+    nativeObjToIdMap.clear();
+  }
+
+  private static class DebugInfo {
+    final Trace registrationTrace;
+    final List<Trace> unregistrationTraces = new ArrayList<>();
+
+    public DebugInfo(Trace trace) {
+      registrationTrace = trace;
+    }
+  }
+
+  private static class Trace extends Throwable {
+
+    private Trace() {}
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Ref.java b/resources/src/main/java/org/robolectric/res/android/Ref.java
new file mode 100644
index 0000000..3219088
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Ref.java
@@ -0,0 +1,23 @@
+package org.robolectric.res.android;
+
+public class Ref<T> {
+
+  private T t;
+
+  public Ref(T t) {
+    this.t = t;
+  }
+
+  public T get() {
+    return t;
+  }
+
+  public void set(T t) {
+    this.t = t;
+  }
+
+  @Override
+  public String toString() {
+    return "Ref<" + t + '>';
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Registries.java b/resources/src/main/java/org/robolectric/res/android/Registries.java
new file mode 100644
index 0000000..2408f63
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Registries.java
@@ -0,0 +1,24 @@
+package org.robolectric.res.android;
+
+import java.lang.ref.WeakReference;
+import org.robolectric.res.android.CppAssetManager2.Theme;
+
+public class Registries {
+
+  public static final NativeObjRegistry<Asset> NATIVE_ASSET_REGISTRY =
+      new NativeObjRegistry<>(Asset.class);
+  public static final NativeObjRegistry<CppAssetManager2> NATIVE_ASSET_MANAGER_REGISTRY =
+      new NativeObjRegistry<>(CppAssetManager2.class);
+  public static final NativeObjRegistry<CppApkAssets> NATIVE_APK_ASSETS_REGISTRY =
+      new NativeObjRegistry<>(CppApkAssets.class);
+  public static final NativeObjRegistry<ResTableTheme> NATIVE_THEME_REGISTRY =
+      new NativeObjRegistry<>(ResTableTheme.class);
+  public static final NativeObjRegistry<ResXMLTree> NATIVE_RES_XML_TREES =
+      new NativeObjRegistry<>(ResXMLTree.class);
+  public static final NativeObjRegistry<ResXMLParser> NATIVE_RES_XML_PARSERS =
+          new NativeObjRegistry<>(ResXMLParser.class);
+  static final NativeObjRegistry<WeakReference<ResStringPool>> NATIVE_STRING_POOLS =
+      new NativeObjRegistry<>("ResStringPool");
+  public static final NativeObjRegistry<Theme> NATIVE_THEME9_REGISTRY =
+      new NativeObjRegistry<>(Theme.class);
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResStringPool.java b/resources/src/main/java/org/robolectric/res/android/ResStringPool.java
new file mode 100644
index 0000000..9817e08
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResStringPool.java
@@ -0,0 +1,565 @@
+package org.robolectric.res.android;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceTypes.cpp
+//   and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h
+
+import static org.robolectric.res.android.Errors.BAD_TYPE;
+import static org.robolectric.res.android.Errors.NAME_NOT_FOUND;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Errors.NO_INIT;
+import static org.robolectric.res.android.ResourceString.decodeString;
+import static org.robolectric.res.android.ResourceTypes.validate_chunk;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.SIZEOF_INT;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.lang.ref.WeakReference;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Objects;
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_header;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_header.Writer;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_ref;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_span;
+import org.robolectric.res.android.ResourceTypes.WithOffset;
+
+/**
+ * Convenience class for accessing data in a ResStringPool resource.
+ */
+@SuppressWarnings("NewApi")
+public class ResStringPool {
+
+  private static boolean kDebugStringPoolNoisy = false;
+
+  private final long myNativePtr;
+
+  private int                    mError;
+
+   byte[]                       mOwnedData;
+  //private Object mOwnedData;
+
+  private ResStringPool_header mHeader;
+  private int                      mSize;
+//    private mutable Mutex               mDecodeLock;
+//    const uint32_t*             mEntries;
+  private IntArray             mEntries;
+//    const uint32_t*             mEntryStyles;
+    private IntArray             mEntryStyles;
+//    const void*                 mStrings;
+    private int                 mStrings;
+  //private List<String> mStrings;
+  //private String[] mCache;
+  //private char16_t mutable**          mCache;
+    private int                    mStringPoolSize;    // number of uint16_t
+//    const uint32_t*             mStyles;
+    private int             mStyles;
+    private int                    mStylePoolSize;    // number of int
+
+  public ResStringPool() {
+    mError = NO_INIT;
+    myNativePtr = Registries.NATIVE_STRING_POOLS.register(new WeakReference<>(this));
+  }
+
+  @Override
+  protected void finalize() throws Throwable {
+    Registries.NATIVE_STRING_POOLS.unregister(myNativePtr);
+  }
+
+  public long getNativePtr() {
+    return myNativePtr;
+  }
+
+  public static ResStringPool getNativeObject(long nativeId) {
+    return Registries.NATIVE_STRING_POOLS.getNativeObject(nativeId).get();
+  }
+
+  static class IntArray extends WithOffset {
+    IntArray(ByteBuffer buf, int offset) {
+      super(buf, offset);
+    }
+
+    int get(int idx) {
+      return myBuf().getInt(myOffset() + idx * SIZEOF_INT);
+    }
+  }
+
+  void setToEmpty()
+  {
+    uninit();
+
+    ByteBuffer buf = ByteBuffer.allocate(16 * 1024).order(ByteOrder.LITTLE_ENDIAN);
+    Writer resStringPoolWriter = new Writer();
+    resStringPoolWriter.write(buf);
+    mOwnedData = new byte[buf.position()];
+    buf.position();
+    buf.get(mOwnedData);
+
+    ResStringPool_header header = new ResStringPool_header(buf, 0);
+    mSize = 0;
+    mEntries = null;
+    mStrings = 0;
+    mStringPoolSize = 0;
+    mEntryStyles = null;
+    mStyles = 0;
+    mStylePoolSize = 0;
+    mHeader = header;
+  }
+
+  //  status_t setTo(const void* data, size_t size, bool copyData=false);
+  public int setTo(ByteBuffer buf, int offset, int size, boolean copyData) {
+    if (!isTruthy(buf) || !isTruthy(size)) {
+      return (mError=BAD_TYPE);
+    }
+
+    uninit();
+
+    // The chunk must be at least the size of the string pool header.
+    if (size < ResStringPool_header.SIZEOF) {
+      ALOGW("Bad string block: data size %d is too small to be a string block", size);
+      return (mError=BAD_TYPE);
+    }
+
+    // The data is at least as big as a ResChunk_header, so we can safely validate the other
+    // header fields.
+    // `data + size` is safe because the source of `size` comes from the kernel/filesystem.
+    if (validate_chunk(new ResChunk_header(buf, offset), ResStringPool_header.SIZEOF,
+        size,
+        "ResStringPool_header") != NO_ERROR) {
+      ALOGW("Bad string block: malformed block dimensions");
+      return (mError=BAD_TYPE);
+    }
+
+//    final boolean notDeviceEndian = htods((short) 0xf0) != 0xf0;
+//
+//    if (copyData || notDeviceEndian) {
+//      mOwnedData = data;
+//      if (mOwnedData == null) {
+//        return (mError=NO_MEMORY);
+//      }
+////      memcpy(mOwnedData, data, size);
+//      data = mOwnedData;
+//    }
+
+    // The size has been checked, so it is safe to read the data in the ResStringPool_header
+    // data structure.
+    mHeader = new ResStringPool_header(buf, offset);
+
+//    if (notDeviceEndian) {
+//      ResStringPool_header h = final_cast<ResStringPool_header*>(mHeader);
+//      h.header.headerSize = dtohs(mHeader.header.headerSize);
+//      h.header.type = dtohs(mHeader.header.type);
+//      h.header.size = dtohl(mHeader.header.size);
+//      h.stringCount = dtohl(mHeader.stringCount);
+//      h.styleCount = dtohl(mHeader.styleCount);
+//      h.flags = dtohl(mHeader.flags);
+//      h.stringsStart = dtohl(mHeader.stringsStart);
+//      h.stylesStart = dtohl(mHeader.stylesStart);
+//    }
+
+    if (mHeader.header.headerSize > mHeader.header.size
+        || mHeader.header.size > size) {
+      ALOGW("Bad string block: header size %d or total size %d is larger than data size %d\n",
+          (int)mHeader.header.headerSize, (int)mHeader.header.size, (int)size);
+      return (mError=BAD_TYPE);
+    }
+    mSize = mHeader.header.size;
+    mEntries = new IntArray(mHeader.myBuf(), mHeader.myOffset() + mHeader.header.headerSize);
+
+    if (mHeader.stringCount > 0) {
+      if ((mHeader.stringCount*4 /*sizeof(uint32_t)*/ < mHeader.stringCount)  // uint32 overflow?
+          || (mHeader.header.headerSize+(mHeader.stringCount*4 /*sizeof(uint32_t)*/))
+          > size) {
+        ALOGW("Bad string block: entry of %d items extends past data size %d\n",
+            (int)(mHeader.header.headerSize+(mHeader.stringCount*4/*sizeof(uint32_t)*/)),
+            (int)size);
+        return (mError=BAD_TYPE);
+      }
+
+      int charSize;
+      if (isTruthy(mHeader.flags & ResStringPool_header.UTF8_FLAG)) {
+        charSize = 1 /*sizeof(uint8_t)*/;
+      } else {
+        charSize = 2 /*sizeof(uint16_t)*/;
+      }
+
+      // There should be at least space for the smallest string
+      // (2 bytes length, null terminator).
+      if (mHeader.stringsStart >= (mSize - 2 /*sizeof(uint16_t)*/)) {
+        ALOGW("Bad string block: string pool starts at %d, after total size %d\n",
+            (int)mHeader.stringsStart, (int)mHeader.header.size);
+        return (mError=BAD_TYPE);
+      }
+
+      mStrings = mHeader.stringsStart;
+
+      if (mHeader.styleCount == 0) {
+        mStringPoolSize = (mSize - mHeader.stringsStart) / charSize;
+      } else {
+        // check invariant: styles starts before end of data
+        if (mHeader.stylesStart >= (mSize - 2 /*sizeof(uint16_t)*/)) {
+          ALOGW("Bad style block: style block starts at %d past data size of %d\n",
+              (int)mHeader.stylesStart, (int)mHeader.header.size);
+          return (mError=BAD_TYPE);
+        }
+        // check invariant: styles follow the strings
+        if (mHeader.stylesStart <= mHeader.stringsStart) {
+          ALOGW("Bad style block: style block starts at %d, before strings at %d\n",
+              (int)mHeader.stylesStart, (int)mHeader.stringsStart);
+          return (mError=BAD_TYPE);
+        }
+        mStringPoolSize =
+            (mHeader.stylesStart-mHeader.stringsStart)/charSize;
+      }
+
+      // check invariant: stringCount > 0 requires a string pool to exist
+      if (mStringPoolSize == 0) {
+        ALOGW("Bad string block: stringCount is %d but pool size is 0\n", (int)mHeader.stringCount);
+        return (mError=BAD_TYPE);
+      }
+
+      //      if (notDeviceEndian) {
+      //        int i;
+      //        uint32_t* e = final_cast<uint32_t*>(mEntries);
+      //        for (i=0; i<mHeader.stringCount; i++) {
+      //          e[i] = dtohl(mEntries[i]);
+      //        }
+      //        if (!(mHeader.flags&ResStringPool_header::UTF8_FLAG)) {
+      //                final uint16_t* strings = (final uint16_t*)mStrings;
+      //          uint16_t* s = final_cast<uint16_t*>(strings);
+      //          for (i=0; i<mStringPoolSize; i++) {
+      //            s[i] = dtohs(strings[i]);
+      //          }
+      //        }
+      //      }
+
+      //      if ((mHeader->flags&ResStringPool_header::UTF8_FLAG &&
+      //          ((uint8_t*)mStrings)[mStringPoolSize-1] != 0) ||
+      //      (!(mHeader->flags&ResStringPool_header::UTF8_FLAG) &&
+      //          ((uint16_t*)mStrings)[mStringPoolSize-1] != 0)) {
+
+      if ((isTruthy(mHeader.flags & ResStringPool_header.UTF8_FLAG)
+              && (mHeader.getByte(mStrings + mStringPoolSize - 1) != 0))
+          || (!isTruthy(mHeader.flags & ResStringPool_header.UTF8_FLAG)
+              && (mHeader.getShort(mStrings + mStringPoolSize * 2 - 2) != 0))) {
+        ALOGW("Bad string block: last string is not 0-terminated\n");
+        return (mError=BAD_TYPE);
+      }
+    } else {
+      mStrings = -1;
+      mStringPoolSize = 0;
+    }
+
+    if (mHeader.styleCount > 0) {
+      mEntryStyles = new IntArray(mEntries.myBuf(), mEntries.myOffset() + mHeader.stringCount * SIZEOF_INT);
+      // invariant: integer overflow in calculating mEntryStyles
+      if (mEntryStyles.myOffset() < mEntries.myOffset()) {
+        ALOGW("Bad string block: integer overflow finding styles\n");
+        return (mError=BAD_TYPE);
+      }
+
+//      if (((const uint8_t*)mEntryStyles-(const uint8_t*)mHeader) > (int)size) {
+      if ((mEntryStyles.myOffset() - mHeader.myOffset()) > (int)size) {
+        ALOGW(
+            "Bad string block: entry of %d styles extends past data size %d\n",
+            (int) mEntryStyles.myOffset(), (int) size);
+        return (mError=BAD_TYPE);
+      }
+      mStyles = mHeader.stylesStart;
+      if (mHeader.stylesStart >= mHeader.header.size) {
+        ALOGW("Bad string block: style pool starts %d, after total size %d\n",
+            (int)mHeader.stylesStart, (int)mHeader.header.size);
+        return (mError=BAD_TYPE);
+      }
+      mStylePoolSize =
+          (mHeader.header.size-mHeader.stylesStart) /* / sizeof(uint32_t)*/;
+
+//      if (notDeviceEndian) {
+//        size_t i;
+//        uint32_t* e = final_cast<uint32_t*>(mEntryStyles);
+//        for (i=0; i<mHeader.styleCount; i++) {
+//          e[i] = dtohl(mEntryStyles[i]);
+//        }
+//        uint32_t* s = final_cast<uint32_t*>(mStyles);
+//        for (i=0; i<mStylePoolSize; i++) {
+//          s[i] = dtohl(mStyles[i]);
+//        }
+//      }
+
+//        final ResStringPool_span endSpan = {
+//          { htodl(ResStringPool_span.END) },
+//          htodl(ResStringPool_span.END), htodl(ResStringPool_span.END)
+//      };
+//      if (memcmp(&mStyles[mStylePoolSize-(sizeof(endSpan)/sizeof(uint32_t))],
+//                   &endSpan, sizeof(endSpan)) != 0) {
+      ResStringPool_span endSpan = new ResStringPool_span(buf,
+          mHeader.myOffset() + mStyles + (mStylePoolSize - ResStringPool_span.SIZEOF /* / 4 */));
+      if (!endSpan.isEnd()) {
+        ALOGW("Bad string block: last style is not 0xFFFFFFFF-terminated\n");
+        return (mError=BAD_TYPE);
+      }
+    } else {
+      mEntryStyles = null;
+      mStyles = 0;
+      mStylePoolSize = 0;
+    }
+
+    return (mError=NO_ERROR);
+  }
+
+//  public void setTo(XmlResStringPool xmlStringPool) {
+//    this.mHeader = new ResStringPoolHeader();
+//    this.mStrings = new ArrayList<>();
+//    Collections.addAll(mStrings, xmlStringPool.strings());
+//  }
+
+  private int setError(int error) {
+    mError = error;
+    return mError;
+  }
+
+  void uninit() {
+    setError(NO_INIT);
+    mHeader = null;
+  }
+
+  public String stringAt(int idx) {
+    if (mError == NO_ERROR && idx < mHeader.stringCount) {
+        final boolean isUTF8 = (mHeader.flags&ResStringPool_header.UTF8_FLAG) != 0;
+//        const uint32_t off = mEntries[idx]/(isUTF8?sizeof(uint8_t):sizeof(uint16_t));
+      ByteBuffer buf = mHeader.myBuf();
+      int bufOffset = mHeader.myOffset();
+      // const uint32_t off = mEntries[idx]/(isUTF8?sizeof(uint8_t):sizeof(uint16_t));
+      final int off = mEntries.get(idx)
+            /(isUTF8?1/*sizeof(uint8_t)*/:2/*sizeof(uint16_t)*/);
+      if (off < (mStringPoolSize-1)) {
+        if (!isUTF8) {
+          final int strings = mStrings;
+          final int str = strings+off*2;
+          return decodeString(buf, bufOffset + str, ResourceString.Type.UTF16);
+//          int u16len = decodeLengthUTF16(buf, bufOffset + str);
+//          if ((str+u16len*2-strings) < mStringPoolSize) {
+//            // Reject malformed (non null-terminated) strings
+//            if (buf.getShort(bufOffset + str + u16len*2) != 0x0000) {
+//              ALOGW("Bad string block: string #%d is not null-terminated",
+//                  (int)idx);
+//              return null;
+//            }
+//            byte[] bytes = new byte[u16len * 2];
+//            buf.position(bufOffset + str);
+//            buf.get(bytes);
+//               // Reject malformed (non null-terminated) strings
+//               if (str[encLen] != 0x00) {
+//                   ALOGW("Bad string block: string #%d is not null-terminated",
+//                         (int)idx);
+//                   return NULL;
+//               }
+//            return new String(bytes, StandardCharsets.UTF_16);
+//          } else {
+//            ALOGW("Bad string block: string #%d extends to %d, past end at %d\n",
+//                (int)idx, (int)(str+u16len-strings), (int)mStringPoolSize);
+//          }
+        } else {
+          final int strings = mStrings;
+          final int u8str = strings+off;
+          return decodeString(buf, bufOffset + u8str, ResourceString.Type.UTF8);
+
+//                *u16len = decodeLength(&u8str);
+//          size_t u8len = decodeLength(&u8str);
+//
+//          // encLen must be less than 0x7FFF due to encoding.
+//          if ((uint32_t)(u8str+u8len-strings) < mStringPoolSize) {
+//            AutoMutex lock(mDecodeLock);
+//
+//            if (mCache != NULL && mCache[idx] != NULL) {
+//              return mCache[idx];
+//            }
+//
+//            // Retrieve the actual length of the utf8 string if the
+//            // encoded length was truncated
+//            if (stringDecodeAt(idx, u8str, u8len, &u8len) == NULL) {
+//                return NULL;
+//            }
+//
+//            // Since AAPT truncated lengths longer than 0x7FFF, check
+//            // that the bits that remain after truncation at least match
+//            // the bits of the actual length
+//            ssize_t actualLen = utf8_to_utf16_length(u8str, u8len);
+//            if (actualLen < 0 || ((size_t)actualLen & 0x7FFF) != *u16len) {
+//              ALOGW("Bad string block: string #%lld decoded length is not correct "
+//                  "%lld vs %llu\n",
+//                  (long long)idx, (long long)actualLen, (long long)*u16len);
+//              return NULL;
+//            }
+//
+//            utf8_to_utf16(u8str, u8len, u16str, *u16len + 1);
+//
+//            if (mCache == NULL) {
+// #ifndef __ANDROID__
+//                if (kDebugStringPoolNoisy) {
+//                    ALOGI("CREATING STRING CACHE OF %zu bytes",
+//                          mHeader->stringCount*sizeof(char16_t**));
+//                }
+// #else
+//                // We do not want to be in this case when actually running Android.
+//                ALOGW("CREATING STRING CACHE OF %zu bytes",
+//                        static_cast<size_t>(mHeader->stringCount*sizeof(char16_t**)));
+// #endif
+//                mCache = (char16_t**)calloc(mHeader->stringCount, sizeof(char16_t*));
+//                if (mCache == NULL) {
+//                    ALOGW("No memory trying to allocate decode cache table of %d bytes\n",
+//                          (int)(mHeader->stringCount*sizeof(char16_t**)));
+//                    return NULL;
+//                }
+//            }
+//            *u16len = (size_t) actualLen;
+//            char16_t *u16str = (char16_t *)calloc(*u16len+1, sizeof(char16_t));
+//            if (!u16str) {
+//              ALOGW("No memory when trying to allocate decode cache for string #%d\n",
+//                  (int)idx);
+//              return NULL;
+//            }
+//
+//            if (kDebugStringPoolNoisy) {
+//              ALOGI("Caching UTF8 string: %s", u8str);
+//            }
+//
+//            mCache[idx] = u16str;
+//            return u16str;
+//          } else {
+//            ALOGW("Bad string block: string #%lld extends to %lld, past end at %lld\n",
+//                (long long)idx, (long long)(u8str+u8len-strings),
+//                (long long)mStringPoolSize);
+//          }
+        }
+      } else {
+        ALOGW("Bad string block: string #%d entry is at %d, past end at %d\n",
+            (int)idx, (int)(off*2/*sizeof(uint16_t)*/),
+            (int)(mStringPoolSize*2/*sizeof(uint16_t)*/));
+      }
+    }
+    return null;
+  }
+
+  String stringAt(int idx, Ref<Integer> outLen) {
+    String s = stringAt(idx);
+    if (s != null && outLen != null) {
+      outLen.set(s.length());
+    }
+    return s;
+  }
+
+  public String string8At(int id, Ref<Integer> outLen) {
+    return stringAt(id, outLen);
+  }
+
+  final ResStringPool_span styleAt(final ResStringPool_ref ref) {
+    return styleAt(ref.index);
+  }
+
+  public final ResStringPool_span styleAt(int idx) {
+    if (mError == NO_ERROR && idx < mHeader.styleCount) {
+      // const uint32_t off = (mEntryStyles[idx]/sizeof(uint32_t));
+      final int off = mEntryStyles.get(idx) / SIZEOF_INT;
+      if (off < mStylePoolSize) {
+        // return (const ResStringPool_span*)(mStyles+off);
+        return new ResStringPool_span(
+            mHeader.myBuf(), mHeader.myOffset() + mStyles + off * SIZEOF_INT);
+      } else {
+        ALOGW("Bad string block: style #%d entry is at %d, past end at %d\n",
+            (int)idx, (int)(off*SIZEOF_INT),
+            (int)(mStylePoolSize*SIZEOF_INT));
+      }
+    }
+    return null;
+  }
+
+  public int indexOfString(String str) {
+    if (mError != NO_ERROR) {
+      return mError;
+    }
+
+    if (kDebugStringPoolNoisy) {
+      ALOGI("indexOfString : %s", str);
+    }
+
+    if ( (mHeader.flags&ResStringPoolHeader.SORTED_FLAG) != 0) {
+      // Do a binary search for the string...  this is a little tricky,
+      // because the strings are sorted with strzcmp16().  So to match
+      // the ordering, we need to convert strings in the pool to UTF-16.
+      // But we don't want to hit the cache, so instead we will have a
+      // local temporary allocation for the conversions.
+      int l = 0;
+      int h = mHeader.stringCount-1;
+
+      int mid;
+      while (l <= h) {
+        mid = l + (h - l)/2;
+        String s = stringAt(mid);
+        int c = s != null ? s.compareTo(str) : -1;
+        if (kDebugStringPoolNoisy) {
+          ALOGI("Looking at %s, cmp=%d, l/mid/h=%d/%d/%d\n",
+              s, c, (int)l, (int)mid, (int)h);
+        }
+        if (c == 0) {
+          if (kDebugStringPoolNoisy) {
+            ALOGI("MATCH!");
+          }
+          return mid;
+        } else if (c < 0) {
+          l = mid + 1;
+        } else {
+          h = mid - 1;
+        }
+      }
+    } else {
+      // It is unusual to get the ID from an unsorted string block...
+      // most often this happens because we want to get IDs for style
+      // span tags; since those always appear at the end of the string
+      // block, start searching at the back.
+      for (int i = mHeader.stringCount; i>=0; i--) {
+        String s = stringAt(i);
+        if (kDebugStringPoolNoisy) {
+          ALOGI("Looking at %s, i=%d\n", s, i);
+        }
+        if (Objects.equals(s, str)) {
+          if (kDebugStringPoolNoisy) {
+            ALOGI("MATCH!");
+          }
+          return i;
+        }
+      }
+    }
+
+    return NAME_NOT_FOUND;
+  }
+//
+    public int size() {
+      return mError == NO_ERROR ? mHeader.stringCount : 0;
+    }
+
+    int styleCount() {
+      return mError == NO_ERROR ? mHeader.styleCount : 0;
+    }
+
+    int bytes() {
+      return mError == NO_ERROR ? mHeader.header.size : 0;
+    }
+
+  public boolean isUTF8() {
+    return true;
+  }
+
+  public int getError() {
+    return mError;
+  }
+
+//    int styleCount() final;
+//    int bytes() final;
+//
+//    boolean isSorted() final;
+//    boolean isUTF8() final;
+//
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResStringPoolHeader.java b/resources/src/main/java/org/robolectric/res/android/ResStringPoolHeader.java
new file mode 100644
index 0000000..308ab68
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResStringPoolHeader.java
@@ -0,0 +1,45 @@
+package org.robolectric.res.android;
+
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+
+/**
+ * Definition for a pool of strings.  The data of this chunk is an
+ * array of uint32_t providing indices into the pool, relative to
+ * stringsStart.  At stringsStart are all of the UTF-16 strings
+ * concatenated together; each starts with a uint16_t of the string's
+ * length and each ends with a 0x0000 terminator.  If a string is >
+ * 32767 characters, the high bit of the length is set meaning to take
+ * those 15 bits as a high word and it will be followed by another
+ * uint16_t containing the low word.
+ *
+ * If styleCount is not zero, then immediately following the array of
+ * uint32_t indices into the string table is another array of indices
+ * into a style table starting at stylesStart.  Each entry in the
+ * style table is an array of ResStringPool_span structures.
+ */
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h#434
+public class ResStringPoolHeader {
+  public static final int SIZEOF = ResChunk_header.SIZEOF + 20;
+
+  ResChunk_header header;
+  // Number of strings in this pool (number of uint32_t indices that follow
+  // in the data).
+  int stringCount;
+  // Number of style span arrays in the pool (number of uint32_t indices
+  // follow the string indices).
+  int styleCount;
+
+  // Flags.
+
+  // If set, the string index is sorted by the string values (based
+  // on strcmp16()).
+  public static final int SORTED_FLAG = 1<<0;
+  // String pool is encoded in UTF-8
+  public static final int UTF8_FLAG = 1<<8;
+  int flags;
+
+  // Index from header of the string data.
+  int stringsStart;
+  // Index from header of the style data.
+  int stylesStart;
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResStringPoolRef.java b/resources/src/main/java/org/robolectric/res/android/ResStringPoolRef.java
new file mode 100644
index 0000000..b748d7e
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResStringPoolRef.java
@@ -0,0 +1,15 @@
+package org.robolectric.res.android;
+
+/**
+ * Reference to a string in a string pool.
+ */
+class ResStringPoolRef {
+    // Index into the string pool table (uint32_t-offset from the indices
+    // immediately after ResStringPool_header) at which to find the location
+    // of the string data in the pool.
+    int index;
+
+    ResStringPoolRef(int index) {
+        this.index = index;
+    }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResTable.java b/resources/src/main/java/org/robolectric/res/android/ResTable.java
new file mode 100644
index 0000000..627a169
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResTable.java
@@ -0,0 +1,3076 @@
+package org.robolectric.res.android;
+
+import static com.google.common.primitives.UnsignedBytes.max;
+import static org.robolectric.res.android.Errors.BAD_INDEX;
+import static org.robolectric.res.android.Errors.BAD_TYPE;
+import static org.robolectric.res.android.Errors.BAD_VALUE;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Errors.NO_MEMORY;
+import static org.robolectric.res.android.Errors.UNKNOWN_ERROR;
+import static org.robolectric.res.android.ResourceTypes.RES_STRING_POOL_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_LIBRARY_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_PACKAGE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_TYPE_SPEC_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_TABLE_TYPE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.validate_chunk;
+import static org.robolectric.res.android.Util.ALOGD;
+import static org.robolectric.res.android.Util.ALOGE;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.ALOGV;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.LOG_FATAL_IF;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.htodl;
+import static org.robolectric.res.android.Util.htods;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.Semaphore;
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_header;
+import org.robolectric.res.android.ResourceTypes.ResTable_map;
+import org.robolectric.res.android.ResourceTypes.ResTable_map_entry;
+import org.robolectric.res.android.ResourceTypes.ResTable_package;
+import org.robolectric.res.android.ResourceTypes.ResTable_sparseTypeEntry;
+import org.robolectric.res.android.ResourceTypes.ResTable_type;
+import org.robolectric.res.android.ResourceTypes.ResTable_typeSpec;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceTypes.cpp
+//   and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h
+@SuppressWarnings("NewApi")
+public class ResTable {
+
+  @SuppressWarnings("unused")
+  private static final int IDMAP_MAGIC = 0x504D4449;
+
+  @SuppressWarnings("unused")
+  private static final int IDMAP_CURRENT_VERSION = 0x00000001;
+
+  static final int APP_PACKAGE_ID      = 0x7f;
+  static final int SYS_PACKAGE_ID      = 0x01;
+
+  static final boolean kDebugStringPoolNoisy = false;
+  static final boolean kDebugXMLNoisy = false;
+  static final boolean kDebugTableNoisy = false;
+  static final boolean kDebugTableGetEntry = false;
+  static final boolean kDebugTableSuperNoisy = false;
+  static final boolean kDebugLoadTableNoisy = false;
+  static final boolean kDebugLoadTableSuperNoisy = false;
+  static final boolean kDebugTableTheme = false;
+  static final boolean kDebugResXMLTree = false;
+  static final boolean kDebugLibNoisy = false;
+
+  private static final Object NULL = null;
+  public static final bag_set SENTINEL_BAG_SET = new bag_set(1);
+
+  final Semaphore mLock = new Semaphore(1);
+
+  // Mutex that controls access to the list of pre-filtered configurations
+  // to check when looking up entries.
+  // When iterating over a bag, the mLock mutex is locked. While mLock is locked,
+  // we do resource lookups.
+  // Mutex is not reentrant, so we must use a different lock than mLock.
+  final Object               mFilteredConfigLock = new Object();
+
+  // type defined in Errors
+  int mError;
+
+  ResTable_config mParams;
+
+  // Array of all resource tables.
+  final List<Header>             mHeaders = new ArrayList<>();
+
+  // Array of packages in all resource tables.
+  final Map<Integer, PackageGroup> mPackageGroups = new HashMap<>();
+
+  // Mapping from resource package IDs to indices into the internal
+  // package array.
+  final byte[]                     mPackageMap = new byte[256];
+
+  byte                     mNextPackageId;
+
+  static boolean Res_CHECKID(int resid) { return ((resid&0xFFFF0000) != 0);}
+  static int Res_GETPACKAGE(int id) {
+    return ((id>>24)-1);
+  }
+  public static int Res_GETTYPE(int id) {
+    return (((id>>16)&0xFF)-1);
+  }
+  static int Res_GETENTRY(int id) {
+    return (id&0xFFFF);
+  }
+  static int Res_MAKEARRAY(int entry) { return (0x02000000 | (entry&0xFFFF)); }
+  static boolean Res_INTERNALID(int resid) { return ((resid&0xFFFF0000) != 0 && (resid&0xFF0000) == 0); }
+
+  int getResourcePackageIndex(int resID)
+  {
+    return Res_GETPACKAGE(resID) + 1;
+    //return mPackageMap[Res_GETPACKAGE(resID)+1]-1;
+  }
+
+  int getResourcePackageIndexFromPackage(byte packageID) {
+    return ((int)mPackageMap[packageID])-1;
+  }
+
+  //  Errors add(final Object data, int size, final int cookie, boolean copyData) {
+//    return addInternal(data, size, NULL, 0, false, cookie, copyData);
+//  }
+//
+//  Errors add(final Object data, int size, final Object idmapData, int idmapDataSize,
+//        final int cookie, boolean copyData, boolean appAsLib) {
+//    return addInternal(data, size, idmapData, idmapDataSize, appAsLib, cookie, copyData);
+//  }
+//
+//  Errors add(Asset asset, final int cookie, boolean copyData) {
+//    final Object data = asset.getBuffer(true);
+//    if (data == NULL) {
+//      ALOGW("Unable to get buffer of resource asset file");
+//      return UNKNOWN_ERROR;
+//    }
+//
+//    return addInternal(data, static_cast<int>(asset.getLength()), NULL, false, 0, cookie,
+//        copyData);
+//  }
+
+//  status_t add(Asset* asset, Asset* idmapAsset, const int32_t cookie=-1, bool copyData=false,
+//      bool appAsLib=false, bool isSystemAsset=false);
+  int add(
+      Asset asset, Asset idmapAsset, final int cookie, boolean copyData,
+      boolean appAsLib, boolean isSystemAsset) {
+    final byte[] data = asset.getBuffer(true);
+    if (data == NULL) {
+      ALOGW("Unable to get buffer of resource asset file");
+      return UNKNOWN_ERROR;
+    }
+
+    int idmapSize = 0;
+    Object idmapData = NULL;
+    if (idmapAsset != NULL) {
+      idmapData = idmapAsset.getBuffer(true);
+      if (idmapData == NULL) {
+        ALOGW("Unable to get buffer of idmap asset file");
+        return UNKNOWN_ERROR;
+      }
+      idmapSize = (int) idmapAsset.getLength();
+    }
+
+    return addInternal(data, (int) asset.getLength(),
+        idmapData, idmapSize, appAsLib, cookie, copyData, isSystemAsset);
+  }
+
+  int add(ResTable src, boolean isSystemAsset)
+  {
+    mError = src.mError;
+
+    for (int i=0; i < src.mHeaders.size(); i++) {
+      mHeaders.add(src.mHeaders.get(i));
+    }
+
+    for (PackageGroup srcPg : src.mPackageGroups.values()) {
+      PackageGroup pg = new PackageGroup(this, srcPg.name, srcPg.id,
+          false /* appAsLib */, isSystemAsset || srcPg.isSystemAsset, srcPg.isDynamic);
+      for (int j=0; j<srcPg.packages.size(); j++) {
+        pg.packages.add(srcPg.packages.get(j));
+      }
+
+      for (Integer typeId : srcPg.types.keySet()) {
+        List<Type> typeList = computeIfAbsent(pg.types, typeId, key -> new ArrayList<>());
+        typeList.addAll(srcPg.types.get(typeId));
+      }
+      pg.dynamicRefTable.addMappings(srcPg.dynamicRefTable);
+      pg.largestTypeId = max(pg.largestTypeId, srcPg.largestTypeId);
+      mPackageGroups.put(pg.id, pg);
+    }
+
+//    memcpy(mPackageMap, src->mPackageMap, sizeof(mPackageMap));
+    System.arraycopy(src.mPackageMap, 0, mPackageMap, 0, mPackageMap.length);
+
+    return mError;
+  }
+
+  int addEmpty(final int cookie) {
+    Header header = new Header(this);
+    header.index = mHeaders.size();
+    header.cookie = cookie;
+    header.values.setToEmpty();
+    header.ownedData = new byte[ResTable_header.SIZEOF];
+
+    ByteBuffer buf = ByteBuffer.wrap(header.ownedData).order(ByteOrder.LITTLE_ENDIAN);
+    ResChunk_header.write(buf, (short) RES_TABLE_TYPE, () -> {}, () -> {});
+
+    ResTable_header resHeader = new ResTable_header(buf, 0);
+//    resHeader.header.type = RES_TABLE_TYPE;
+//    resHeader.header.headerSize = sizeof(ResTable_header);
+//    resHeader.header.size = sizeof(ResTable_header);
+
+    header.header = resHeader;
+    mHeaders.add(header);
+    return (mError=NO_ERROR);
+  }
+
+//  status_t addInternal(const void* data, size_t size, const void* idmapData, size_t idmapDataSize,
+//      bool appAsLib, const int32_t cookie, bool copyData, bool isSystemAsset=false);
+  int addInternal(byte[] data, int dataSize, final Object idmapData, int idmapDataSize,
+      boolean appAsLib, final int cookie, boolean copyData, boolean isSystemAsset)
+  {
+    if (!isTruthy(data)) {
+      return NO_ERROR;
+    }
+
+    if (dataSize < ResTable_header.SIZEOF) {
+      ALOGE("Invalid data. Size(%d) is smaller than a ResTable_header(%d).",
+          (int) dataSize, (int) ResTable_header.SIZEOF);
+      return UNKNOWN_ERROR;
+    }
+
+    Header header = new Header(this);
+    header.index = mHeaders.size();
+    header.cookie = cookie;
+    if (idmapData != NULL) {
+      header.resourceIDMap = new int[idmapDataSize / 4];
+      if (header.resourceIDMap == NULL) {
+//        delete header;
+        return (mError = NO_MEMORY);
+      }
+//      memcpy(header.resourceIDMap, idmapData, idmapDataSize);
+//      header.resourceIDMapSize = idmapDataSize;
+    }
+    mHeaders.add(header);
+
+    final boolean notDeviceEndian = htods((short) 0xf0) != 0xf0;
+
+    if (kDebugLoadTableNoisy) {
+      ALOGV("Adding resources to ResTable: data=%s, size=0x%x, cookie=%d, copy=%b " +
+          "idmap=%s\n", data, dataSize, cookie, copyData, idmapData);
+    }
+
+    if (copyData || notDeviceEndian) {
+      header.ownedData = data; // malloc(dataSize);
+      if (header.ownedData == NULL) {
+        return (mError=NO_MEMORY);
+      }
+//      memcpy(header.ownedData, data, dataSize);
+      data = header.ownedData;
+    }
+
+    ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
+//    header->header = (const ResTable_header*)data;
+    header.header = new ResTable_header(buf, 0);
+    header.size = dtohl(header.header.header.size);
+    if (kDebugLoadTableSuperNoisy) {
+      ALOGI("Got size 0x%x, again size 0x%x, raw size 0x%x\n", header.size,
+          dtohl(header.header.header.size), header.header.header.size);
+    }
+    if (kDebugLoadTableNoisy) {
+      ALOGV("Loading ResTable @%s:\n", header.header);
+    }
+    if (dtohs(header.header.header.headerSize) > header.size
+        || header.size > dataSize) {
+      ALOGW(
+          "Bad resource table: header size 0x%x or total size 0x%x is larger than data size 0x%x\n",
+          (int) dtohs(header.header.header.headerSize), (int) header.size, (int) dataSize);
+      return (mError=BAD_TYPE);
+    }
+    if (((dtohs(header.header.header.headerSize)|header.size)&0x3) != 0) {
+      ALOGW(
+          "Bad resource table: header size 0x%x or total size 0x%x is not on an integer boundary\n",
+          (int) dtohs(header.header.header.headerSize), (int) header.size);
+      return (mError=BAD_TYPE);
+    }
+//    header->dataEnd = ((const uint8_t*)header->header) + header->size;
+    header.dataEnd = header.size;
+
+    // Iterate through all chunks.
+    int curPackage = 0;
+
+//    const ResChunk_header* chunk =
+//      (const ResChunk_header*)(((const uint8_t*)header->header)
+//    + dtohs(header->header->header.headerSize));
+    ResChunk_header chunk =
+      new ResChunk_header(buf, dtohs(header.header.header.headerSize));
+    while (chunk != null
+        && (chunk.myOffset() <= (header.dataEnd - ResChunk_header.SIZEOF)
+            && chunk.myOffset() <= (header.dataEnd - dtohl(chunk.size)))) {
+    int err = validate_chunk(chunk, ResChunk_header.SIZEOF, header.dataEnd, "ResTable");
+    if (err != NO_ERROR) {
+      return (mError=err);
+    }
+    if (kDebugTableNoisy) {
+        ALOGV(
+            "Chunk: type=0x%x, headerSize=0x%x, size=0x%x, pos=%s\n",
+            dtohs(chunk.type),
+            dtohs(chunk.headerSize),
+            dtohl(chunk.size),
+            (Object) (chunk.myOffset() - header.header.myOffset()));
+    }
+    final int csize = dtohl(chunk.size);
+    final int ctype = dtohs(chunk.type);
+    if (ctype == RES_STRING_POOL_TYPE) {
+      if (header.values.getError() != NO_ERROR) {
+        // Only use the first string chunk; ignore any others that
+        // may appear.
+        err = header.values.setTo(chunk.myBuf(), chunk.myOffset(), csize, false);
+        if (err != NO_ERROR) {
+          return (mError=err);
+        }
+      } else {
+        ALOGW("Multiple string chunks found in resource table.");
+      }
+    } else if (ctype == RES_TABLE_PACKAGE_TYPE) {
+      if (curPackage >= dtohl(header.header.packageCount)) {
+        ALOGW("More package chunks were found than the %d declared in the header.",
+            dtohl(header.header.packageCount));
+        return (mError=BAD_TYPE);
+      }
+
+      if (parsePackage(
+          new ResTable_package(chunk.myBuf(), chunk.myOffset()), header, appAsLib, isSystemAsset) != NO_ERROR) {
+        return mError;
+      }
+      curPackage++;
+    } else {
+        ALOGW(
+            "Unknown chunk type 0x%x in table at 0x%x.\n",
+            ctype, chunk.myOffset() - header.header.myOffset());
+    }
+    chunk = chunk.myOffset() + csize < header.dataEnd
+        ? new ResChunk_header(chunk.myBuf(), chunk.myOffset() + csize)
+        : null;
+  }
+
+    if (curPackage < dtohl(header.header.packageCount)) {
+      ALOGW("Fewer package chunks (%d) were found than the %d declared in the header.",
+          (int)curPackage, dtohl(header.header.packageCount));
+      return (mError=BAD_TYPE);
+    }
+    mError = header.values.getError();
+    if (mError != NO_ERROR) {
+      ALOGW("No string values found in resource table!");
+    }
+
+    if (kDebugTableNoisy) {
+      ALOGV("Returning from add with mError=%d\n", mError);
+    }
+    return mError;
+  }
+
+  public final int getResource(int resID, Ref<Res_value> outValue, boolean mayBeBag, int density,
+      final Ref<Integer> outSpecFlags, Ref<ResTable_config> outConfig)
+  {
+    if (mError != NO_ERROR) {
+      return mError;
+    }
+    final int p = getResourcePackageIndex(resID);
+    final int t = Res_GETTYPE(resID);
+    final int e = Res_GETENTRY(resID);
+    if (p < 0) {
+      if (Res_GETPACKAGE(resID)+1 == 0) {
+        ALOGW("No package identifier when getting value for resource number 0x%08x", resID);
+      } else {
+        ALOGW("No known package when getting value for resource number 0x%08x", resID);
+      }
+      return BAD_INDEX;
+    }
+
+    if (t < 0) {
+      ALOGW("No type identifier when getting value for resource number 0x%08x", resID);
+      return BAD_INDEX;
+    }
+    final PackageGroup grp = mPackageGroups.get(p);
+    if (grp == NULL) {
+      ALOGW("Bad identifier when getting value for resource number 0x%08x", resID);
+      return BAD_INDEX;
+    }
+    // Allow overriding density
+    ResTable_config desiredConfig = mParams;
+    if (density > 0) {
+      desiredConfig.density = density;
+    }
+    Entry entry = new Entry();
+    int err = getEntry(grp, t, e, desiredConfig, entry);
+    if (err != NO_ERROR) {
+      // Only log the failure when we're not running on the host as
+      // part of a tool. The caller will do its own logging.
+      return err;
+    }
+
+    if ((entry.entry.flags & ResTable_entry.FLAG_COMPLEX) != 0) {
+      if (!mayBeBag) {
+        ALOGW("Requesting resource 0x%08x failed because it is complex\n", resID);
+      }
+      return BAD_VALUE;
+    }
+
+//    const Res_value* value = reinterpret_cast<const Res_value*>(
+//      reinterpret_cast<const uint8_t*>(entry.entry) + entry.entry->size);
+    Res_value value = new Res_value(entry.entry.myBuf(), entry.entry.myOffset() + entry.entry.size);
+
+//    outValue.size = dtohs(value.size);
+//    outValue.res0 = value.res0;
+//    outValue.dataType = value.dataType;
+//    outValue.data = dtohl(value.data);
+    outValue.set(value);
+
+    // The reference may be pointing to a resource in a shared library. These
+    // references have build-time generated package IDs. These ids may not match
+    // the actual package IDs of the corresponding packages in this ResTable.
+    // We need to fix the package ID based on a mapping.
+    if (grp.dynamicRefTable.lookupResourceValue(outValue) != NO_ERROR) {
+      ALOGW("Failed to resolve referenced package: 0x%08x", outValue.get().data);
+      return BAD_VALUE;
+    }
+
+//    if (kDebugTableNoisy) {
+//      size_t len;
+//      printf("Found value: pkg=0x%x, type=%d, str=%s, int=%d\n",
+//          entry.package.header.index,
+//          outValue.dataType,
+//          outValue.dataType == Res_value::TYPE_STRING ?
+//              String8(entry.package.header.values.stringAt(outValue.data, &len)).string() :
+//      "",
+//          outValue.data);
+//    }
+
+    if (outSpecFlags != null) {
+        outSpecFlags.set(entry.specFlags);
+    }
+    if (outConfig != null) {
+        outConfig.set(entry.config);
+    }
+    return entry._package_.header.index;
+  }
+
+  public final int resolveReference(Ref<Res_value> value, int blockIndex,
+      final Ref<Integer> outLastRef) {
+    return resolveReference(value, blockIndex, outLastRef, null, null);
+  }
+
+  public final int resolveReference(Ref<Res_value> value, int blockIndex,
+      final Ref<Integer> outLastRef, Ref<Integer> inoutTypeSpecFlags) {
+    return resolveReference(value, blockIndex, outLastRef, inoutTypeSpecFlags, null);
+  }
+
+  public final int resolveReference(Ref<Res_value> value, int blockIndex,
+      final Ref<Integer> outLastRef, Ref<Integer> inoutTypeSpecFlags,
+      final Ref<ResTable_config> outConfig)
+  {
+    int count=0;
+    while (blockIndex >= 0 && value.get().dataType == DataType.REFERENCE.code()
+        && value.get().data != 0 && count < 20) {
+      if (outLastRef != null) {
+        outLastRef.set(value.get().data);
+      }
+      final Ref<Integer> newFlags = new Ref<>(0);
+      final int newIndex = getResource(value.get().data, value, true, 0,
+          newFlags, outConfig);
+      if (newIndex == BAD_INDEX) {
+        return BAD_INDEX;
+      }
+      if (kDebugTableTheme) {
+        ALOGI("Resolving reference 0x%x: newIndex=%d, type=0x%x, data=0x%x\n",
+            value.get().data, (int)newIndex, (int)value.get().dataType, value.get().data);
+      }
+      //printf("Getting reference 0x%08x: newIndex=%d\n", value.data, newIndex);
+      if (inoutTypeSpecFlags != null) {
+        inoutTypeSpecFlags.set(inoutTypeSpecFlags.get() | newFlags.get());
+      }
+      if (newIndex < 0) {
+        // This can fail if the resource being referenced is a style...
+        // in this case, just return the reference, and expect the
+        // caller to deal with.
+        return blockIndex;
+      }
+      blockIndex = newIndex;
+      count++;
+    }
+    return blockIndex;
+  }
+
+  private interface Compare {
+    boolean compare(ResTable_sparseTypeEntry a, ResTable_sparseTypeEntry b);
+  }
+
+  ResTable_sparseTypeEntry lower_bound(ResTable_sparseTypeEntry first, ResTable_sparseTypeEntry last,
+                                       ResTable_sparseTypeEntry value,
+                                       Compare comparator) {
+    int count = (last.myOffset() - first.myOffset()) / ResTable_sparseTypeEntry.SIZEOF;
+    int itOffset;
+    int step;
+    while (count > 0) {
+      itOffset = first.myOffset();
+      step = count / 2;
+      itOffset += step * ResTable_sparseTypeEntry.SIZEOF;
+      if (comparator.compare(new ResTable_sparseTypeEntry(first.myBuf(), itOffset), value)) {
+        itOffset += ResTable_sparseTypeEntry.SIZEOF;
+        first = new ResTable_sparseTypeEntry(first.myBuf(), itOffset);
+      } else {
+        count = step;
+      }
+    }
+    return first;
+  }
+
+
+  private int getEntry(
+      final PackageGroup packageGroup, int typeIndex, int entryIndex,
+      final ResTable_config config,
+      Entry outEntry)
+  {
+    final List<Type> typeList = getOrDefault(packageGroup.types, typeIndex, Collections.emptyList());
+    if (typeList.isEmpty()) {
+      ALOGV("Skipping entry type index 0x%02x because type is NULL!\n", typeIndex);
+      return BAD_TYPE;
+    }
+
+    ResTable_type bestType = null;
+    int bestOffset = ResTable_type.NO_ENTRY;
+    ResTablePackage bestPackage = null;
+    int specFlags = 0;
+    byte actualTypeIndex = (byte) typeIndex;
+    ResTable_config bestConfig = null;
+//    memset(&bestConfig, 0, sizeof(bestConfig));
+
+    // Iterate over the Types of each package.
+    final int typeCount = typeList.size();
+    for (int i = 0; i < typeCount; i++) {
+      final Type typeSpec = typeList.get(i);
+
+      int realEntryIndex = entryIndex;
+      int realTypeIndex = typeIndex;
+      boolean currentTypeIsOverlay = false;
+
+      // Runtime overlay packages provide a mapping of app resource
+      // ID to package resource ID.
+      if (typeSpec.idmapEntries.hasEntries()) {
+        final Ref<Short> overlayEntryIndex = new Ref<>((short) 0);
+        if (typeSpec.idmapEntries.lookup(entryIndex, overlayEntryIndex) != NO_ERROR) {
+          // No such mapping exists
+          continue;
+        }
+        realEntryIndex = overlayEntryIndex.get();
+        realTypeIndex = typeSpec.idmapEntries.overlayTypeId() - 1;
+        currentTypeIsOverlay = true;
+      }
+
+      // Check that the entry idx is within range of the declared entry count (ResTable_typeSpec).
+      // Particular types (ResTable_type) may be encoded with sparse entries, and so their
+      // entryCount do not need to match.
+      if (((int) realEntryIndex) >= typeSpec.entryCount) {
+        ALOGW("For resource 0x%08x, entry index(%d) is beyond type entryCount(%d)",
+            Res_MAKEID(packageGroup.id - 1, typeIndex, entryIndex),
+            entryIndex, ((int) typeSpec.entryCount));
+        // We should normally abort here, but some legacy apps declare
+        // resources in the 'android' package (old bug in AAPT).
+        continue;
+      }
+
+      // Aggregate all the flags for each package that defines this entry.
+      if (typeSpec.typeSpecFlags != null) {
+        specFlags |= dtohl(typeSpec.typeSpecFlags[realEntryIndex]);
+      } else {
+        specFlags = -1;
+      }
+
+      List<ResTable_type> candidateConfigs = typeSpec.configs;
+
+//      List<ResTable_type> filteredConfigs;
+//      if (isTruthy(config) && Objects.equals(mParams, config)) {
+//        // Grab the lock first so we can safely get the current filtered list.
+//        synchronized (mFilteredConfigLock) {
+//          // This configuration is equal to the one we have previously cached for,
+//          // so use the filtered configs.
+//
+//          final TypeCacheEntry cacheEntry = packageGroup.typeCacheEntries.get(typeIndex);
+//          if (i < cacheEntry.filteredConfigs.size()) {
+//            if (isTruthy(cacheEntry.filteredConfigs.get(i))) {
+//              // Grab a reference to the shared_ptr so it doesn't get destroyed while
+//              // going through this list.
+//              filteredConfigs = cacheEntry.filteredConfigs.get(i);
+//
+//              // Use this filtered list.
+//              candidateConfigs = filteredConfigs;
+//            }
+//          }
+//        }
+//      }
+
+      final int numConfigs = candidateConfigs.size();
+      for (int c = 0; c < numConfigs; c++) {
+        final ResTable_type thisType = candidateConfigs.get(c);
+        if (thisType == NULL) {
+          continue;
+        }
+
+        final ResTable_config thisConfig;
+//        thisConfig.copyFromDtoH(thisType.config);
+        thisConfig = ResTable_config.fromDtoH(thisType.config);
+
+        // Check to make sure this one is valid for the current parameters.
+        if (config != NULL && !thisConfig.match(config)) {
+          continue;
+        }
+
+        // const uint32_t* const eindex = reinterpret_cast<const uint32_t*>(
+        // reinterpret_cast<const uint8_t*>(thisType) + dtohs(thisType->header.headerSize));
+
+        final int eindex = thisType.myOffset() + dtohs(thisType.header.headerSize);
+
+        int thisOffset;
+
+        // Check if there is the desired entry in this type.
+        if (isTruthy(thisType.flags & ResTable_type.FLAG_SPARSE)) {
+          // This is encoded as a sparse map, so perform a binary search.
+          final ByteBuffer buf = thisType.myBuf();
+          ResTable_sparseTypeEntry sparseIndices = new ResTable_sparseTypeEntry(buf, eindex);
+          ResTable_sparseTypeEntry result =
+              lower_bound(
+                  sparseIndices,
+                  new ResTable_sparseTypeEntry(
+                      buf, sparseIndices.myOffset() + dtohl(thisType.entryCount)),
+                  new ResTable_sparseTypeEntry(buf, realEntryIndex),
+                  (a, b) -> dtohs(a.idx) < dtohs(b.idx));
+          //          if (result == sparseIndices + dtohl(thisType.entryCount)
+          //              || dtohs(result.idx) != realEntryIndex) {
+          if (result.myOffset() == sparseIndices.myOffset() + dtohl(thisType.entryCount)
+              || dtohs(result.idx) != realEntryIndex) {
+            // No entry found.
+            continue;
+          }
+          // Extract the offset from the entry. Each offset must be a multiple of 4
+          // so we store it as the real offset divided by 4.
+          //          thisOffset = dtohs(result->offset) * 4u;
+          thisOffset = dtohs(result.offset) * 4;
+        } else {
+          if (realEntryIndex >= dtohl(thisType.entryCount)) {
+            // Entry does not exist.
+            continue;
+          }
+//          thisOffset = dtohl(eindex[realEntryIndex]);
+          thisOffset = thisType.entryOffset(realEntryIndex);
+        }
+
+        if (thisOffset == ResTable_type.NO_ENTRY) {
+          // There is no entry for this index and configuration.
+          continue;
+        }
+
+        if (bestType != NULL) {
+          // Check if this one is less specific than the last found.  If so,
+          // we will skip it.  We check starting with things we most care
+          // about to those we least care about.
+          if (!thisConfig.isBetterThan(bestConfig, config)) {
+            if (!currentTypeIsOverlay || thisConfig.compare(bestConfig) != 0) {
+              continue;
+            }
+          }
+        }
+
+        bestType = thisType;
+        bestOffset = thisOffset;
+        bestConfig = thisConfig;
+        bestPackage = typeSpec._package_;
+        actualTypeIndex = (byte) realTypeIndex;
+
+        // If no config was specified, any type will do, so skip
+        if (config == NULL) {
+          break;
+        }
+      }
+    }
+
+    if (bestType == NULL) {
+      return BAD_INDEX;
+    }
+
+    bestOffset += dtohl(bestType.entriesStart);
+
+//    if (bestOffset > (dtohl(bestType->header.size)-sizeof(ResTable_entry))) {
+    if (bestOffset > (dtohl(bestType.header.size)- ResTable_entry.SIZEOF)) {
+      ALOGW("ResTable_entry at 0x%x is beyond type chunk data 0x%x",
+          bestOffset, dtohl(bestType.header.size));
+      return BAD_TYPE;
+    }
+    if ((bestOffset & 0x3) != 0) {
+      ALOGW("ResTable_entry at 0x%x is not on an integer boundary", bestOffset);
+      return BAD_TYPE;
+    }
+
+//    const ResTable_entry* const entry = reinterpret_cast<const ResTable_entry*>(
+//      reinterpret_cast<const uint8_t*>(bestType) + bestOffset);
+    final ResTable_entry entry = new ResTable_entry(bestType.myBuf(),
+        bestType.myOffset() + bestOffset);
+    if (dtohs(entry.size) < ResTable_entry.SIZEOF) {
+      ALOGW("ResTable_entry size 0x%x is too small", dtohs(entry.size));
+      return BAD_TYPE;
+    }
+    
+    if (outEntry != null) {
+      outEntry.entry = entry;
+      outEntry.config = bestConfig;
+      outEntry.type = bestType;
+      outEntry.specFlags = specFlags;
+      outEntry._package_ = bestPackage;
+      outEntry.typeStr = new StringPoolRef(bestPackage.typeStrings, actualTypeIndex - bestPackage.typeIdOffset);
+      outEntry.keyStr = new StringPoolRef(bestPackage.keyStrings, dtohl(entry.key.index));
+    }
+    return NO_ERROR;
+  }
+
+  int parsePackage(ResTable_package pkg,
+                                Header header, boolean appAsLib, boolean isSystemAsset)
+  {
+    int base = pkg.myOffset();
+    int err = validate_chunk(pkg.header, ResTable_package.SIZEOF - 4 /*sizeof(pkg.typeIdOffset)*/,
+      header.dataEnd, "ResTable_package");
+    if (err != NO_ERROR) {
+      return (mError=err);
+    }
+
+    final int pkgSize = dtohl(pkg.header.size);
+
+    if (dtohl(pkg.typeStrings) >= pkgSize) {
+      ALOGW("ResTable_package type strings at 0x%x are past chunk size 0x%x.",
+          dtohl(pkg.typeStrings), pkgSize);
+      return (mError=BAD_TYPE);
+    }
+    if ((dtohl(pkg.typeStrings)&0x3) != 0) {
+      ALOGW("ResTable_package type strings at 0x%x is not on an integer boundary.",
+          dtohl(pkg.typeStrings));
+      return (mError=BAD_TYPE);
+    }
+    if (dtohl(pkg.keyStrings) >= pkgSize) {
+      ALOGW("ResTable_package key strings at 0x%x are past chunk size 0x%x.",
+          dtohl(pkg.keyStrings), pkgSize);
+      return (mError=BAD_TYPE);
+    }
+    if ((dtohl(pkg.keyStrings)&0x3) != 0) {
+      ALOGW("ResTable_package key strings at 0x%x is not on an integer boundary.",
+          dtohl(pkg.keyStrings));
+      return (mError=BAD_TYPE);
+    }
+
+    int id = dtohl(pkg.id);
+    final Map<Byte, IdmapEntries> idmapEntries = new HashMap<>();
+
+    if (header.resourceIDMap != NULL) {
+//      byte targetPackageId = 0;
+//      int err = parseIdmap(header.resourceIDMap, header.resourceIDMapSize, &targetPackageId, &idmapEntries);
+//      if (err != NO_ERROR) {
+//        ALOGW("Overlay is broken");
+//        return (mError=err);
+//      }
+//      id = targetPackageId;
+    }
+
+    boolean isDynamic = false;
+    if (id >= 256) {
+//      LOG_ALWAYS_FATAL("Package id out of range");
+      throw new IllegalStateException("Package id out of range");
+//      return NO_ERROR;
+    } else if (id == 0 || (id == 0x7f && appAsLib) || isSystemAsset) {
+      // This is a library or a system asset, so assign an ID
+      id = mNextPackageId++;
+      isDynamic = true;
+    }
+
+    PackageGroup group = null;
+    ResTablePackage _package = new ResTablePackage(this, header, pkg);
+    if (_package == NULL) {
+    return (mError=NO_MEMORY);
+  }
+
+//    err = package->typeStrings.setTo(base+dtohl(pkg->typeStrings),
+//      header->dataEnd-(base+dtohl(pkg->typeStrings)));
+    err = _package.typeStrings.setTo(pkg.myBuf(), base+dtohl(pkg.typeStrings),
+      header.dataEnd -(base+dtohl(pkg.typeStrings)), false);
+    if (err != NO_ERROR) {
+//      delete group;
+//      delete _package;
+      return (mError=err);
+    }
+
+//    err = package->keyStrings.setTo(base+dtohl(pkg->keyStrings),
+//      header->dataEnd-(base+dtohl(pkg->keyStrings)));
+    err = _package.keyStrings.setTo(pkg.myBuf(), base+dtohl(pkg.keyStrings),
+      header.dataEnd -(base+dtohl(pkg.keyStrings)), false);
+    if (err != NO_ERROR) {
+//      delete group;
+//      delete _package;
+      return (mError=err);
+    }
+
+    int idx = mPackageMap[id];
+    if (idx == 0) {
+      idx = mPackageGroups.size() + 1;
+
+//      char[] tmpName = new char[pkg.name.length /*sizeof(pkg.name)/sizeof(pkg.name[0])*/];
+//      strcpy16_dtoh(tmpName, pkg.name, sizeof(pkg.name)/sizeof(pkg.name[0]));
+      group = new PackageGroup(this, new String(pkg.name), id, appAsLib, isSystemAsset, isDynamic);
+      if (group == NULL) {
+//        delete _package;
+        return (mError=NO_MEMORY);
+      }
+
+      mPackageGroups.put(group.id, group);
+//      if (err < NO_ERROR) {
+//        return (mError=err);
+//      }
+
+      mPackageMap[id] = (byte) idx;
+
+      // Find all packages that reference this package
+//      int N = mPackageGroups.size();
+//      for (int i = 0; i < N; i++) {
+      for (PackageGroup packageGroup : mPackageGroups.values()) {
+        packageGroup.dynamicRefTable.addMapping(
+            group.name, (byte) group.id);
+      }
+    } else {
+      group = mPackageGroups.get(idx - 1);
+      if (group == NULL) {
+        return (mError=UNKNOWN_ERROR);
+      }
+    }
+
+    group.packages.add(_package);
+//    if (err < NO_ERROR) {
+//      return (mError=err);
+//    }
+
+    // Iterate through all chunks.
+    ResChunk_header chunk =
+      new ResChunk_header(pkg.myBuf(), pkg.myOffset() + dtohs(pkg.header.headerSize));
+    //      const uint8_t* endPos = ((const uint8_t*)pkg) + dtohs(pkg->header.size);
+    final int endPos = pkg.myOffset() + pkg.header.size;
+    //    while (((const uint8_t*)chunk) <= (endPos-sizeof(ResChunk_header)) &&
+    //      ((const uint8_t*)chunk) <= (endPos-dtohl(chunk->size))) {
+    while (chunk != null
+        && chunk.myOffset() <= (endPos - ResChunk_header.SIZEOF)
+        && chunk.myOffset() <= (endPos - dtohl(chunk.size))) {
+    if (kDebugTableNoisy) {
+        ALOGV(
+            "PackageChunk: type=0x%x, headerSize=0x%x, size=0x%x, pos=%s\n",
+            dtohs(chunk.type),
+            dtohs(chunk.headerSize),
+            dtohl(chunk.size),
+            (chunk.myOffset() - header.header.myOffset()));
+    }
+        final int csize = dtohl(chunk.size);
+        final short ctype = dtohs(chunk.type);
+    if (ctype == RES_TABLE_TYPE_SPEC_TYPE) {
+            final ResTable_typeSpec typeSpec = new ResTable_typeSpec(chunk.myBuf(), chunk.myOffset());
+      err = validate_chunk(typeSpec.header, ResTable_typeSpec.SIZEOF,
+      endPos, "ResTable_typeSpec");
+      if (err != NO_ERROR) {
+        return (mError=err);
+      }
+
+            final int typeSpecSize = dtohl(typeSpec.header.size);
+            final int newEntryCount = dtohl(typeSpec.entryCount);
+
+      if (kDebugLoadTableNoisy) {
+        ALOGI("TypeSpec off %s: type=0x%x, headerSize=0x%x, size=%s\n",
+            (base-chunk.myOffset()),
+        dtohs(typeSpec.header.type),
+            dtohs(typeSpec.header.headerSize),
+            typeSpecSize);
+      }
+      // look for block overrun or int overflow when multiplying by 4
+      if ((dtohl(typeSpec.entryCount) > (Integer.MAX_VALUE/4 /*sizeof(int)*/)
+          || dtohs(typeSpec.header.headerSize)+(4 /*sizeof(int)*/*newEntryCount)
+          > typeSpecSize)) {
+        ALOGW("ResTable_typeSpec entry index to %s extends beyond chunk end %s.",
+            (dtohs(typeSpec.header.headerSize) + (4 /*sizeof(int)*/*newEntryCount)),
+            typeSpecSize);
+        return (mError=BAD_TYPE);
+      }
+
+      if (typeSpec.id == 0) {
+        ALOGW("ResTable_type has an id of 0.");
+        return (mError=BAD_TYPE);
+      }
+
+      if (newEntryCount > 0) {
+        boolean addToType = true;
+        byte typeIndex = (byte) (typeSpec.id - 1);
+        IdmapEntries idmapEntry = idmapEntries.get(typeSpec.id);
+        if (idmapEntry != null) {
+          typeIndex = (byte) (idmapEntry.targetTypeId() - 1);
+        } else if (header.resourceIDMap != NULL) {
+          // This is an overlay, but the types in this overlay are not
+          // overlaying anything according to the idmap. We can skip these
+          // as they will otherwise conflict with the other resources in the package
+          // without a mapping.
+          addToType = false;
+        }
+
+        if (addToType) {
+          List<Type> typeList = computeIfAbsent(group.types, (int) typeIndex, k -> new ArrayList<>());
+          if (!typeList.isEmpty()) {
+            final Type existingType = typeList.get(0);
+            if (existingType.entryCount != newEntryCount && idmapEntry == null) {
+              ALOGW("ResTable_typeSpec entry count inconsistent: given %d, previously %d",
+                  (int) newEntryCount, (int) existingType.entryCount);
+              // We should normally abort here, but some legacy apps declare
+              // resources in the 'android' package (old bug in AAPT).
+            }
+          }
+
+          Type t = new Type(header, _package, newEntryCount);
+          t.typeSpec = typeSpec;
+          t.typeSpecFlags = typeSpec.getSpecFlags();
+          if (idmapEntry != null) {
+            t.idmapEntries = idmapEntry;
+          }
+          typeList.add(t);
+          group.largestTypeId = max(group.largestTypeId, typeSpec.id);
+        }
+      } else {
+        ALOGV("Skipping empty ResTable_typeSpec for type %d", typeSpec.id);
+      }
+
+    } else if (ctype == RES_TABLE_TYPE_TYPE) {
+            ResTable_type type = new ResTable_type(chunk.myBuf(), chunk.myOffset());
+      err = validate_chunk(type.header, ResTable_type.SIZEOF_WITHOUT_CONFIG/*-sizeof(ResTable_config)*/+4,
+          endPos, "ResTable_type");
+      if (err != NO_ERROR) {
+        return (mError=err);
+      }
+
+            final int typeSize = dtohl(type.header.size);
+            final int newEntryCount = dtohl(type.entryCount);
+
+      if (kDebugLoadTableNoisy) {
+        System.out.println(String.format("Type off 0x%x: type=0x%x, headerSize=0x%x, size=%d\n",
+            base-chunk.myOffset(),
+        dtohs(type.header.type),
+            dtohs(type.header.headerSize),
+            typeSize));
+      }
+      if (dtohs(type.header.headerSize)+(4/*sizeof(int)*/*newEntryCount) > typeSize) {
+        ALOGW("ResTable_type entry index to %s extends beyond chunk end 0x%x.",
+            (dtohs(type.header.headerSize) + (4/*sizeof(int)*/*newEntryCount)),
+            typeSize);
+        return (mError=BAD_TYPE);
+      }
+
+      if (newEntryCount != 0
+          && dtohl(type.entriesStart) > (typeSize- ResTable_entry.SIZEOF)) {
+        ALOGW("ResTable_type entriesStart at 0x%x extends beyond chunk end 0x%x.",
+            dtohl(type.entriesStart), typeSize);
+        return (mError=BAD_TYPE);
+      }
+
+      if (type.id == 0) {
+        ALOGW("ResTable_type has an id of 0.");
+        return (mError=BAD_TYPE);
+      }
+
+      if (newEntryCount > 0) {
+        boolean addToType = true;
+        byte typeIndex = (byte) (type.id - 1);
+        IdmapEntries idmapEntry = idmapEntries.get(type.id);
+        if (idmapEntry != null) {
+          typeIndex = (byte) (idmapEntry.targetTypeId() - 1);
+        } else if (header.resourceIDMap != NULL) {
+          // This is an overlay, but the types in this overlay are not
+          // overlaying anything according to the idmap. We can skip these
+          // as they will otherwise conflict with the other resources in the package
+          // without a mapping.
+          addToType = false;
+        }
+
+        if (addToType) {
+          List<Type> typeList = getOrDefault(group.types, (int) typeIndex, Collections.emptyList());
+          if (typeList.isEmpty()) {
+            ALOGE("No TypeSpec for type %d", type.id);
+            return (mError = BAD_TYPE);
+          }
+
+            Type t = typeList.get(typeList.size() - 1);
+          if (t._package_ != _package) {
+            ALOGE("No TypeSpec for type %d", type.id);
+            return (mError = BAD_TYPE);
+          }
+
+          t.configs.add(type);
+
+          if (kDebugTableGetEntry) {
+            ResTable_config thisConfig = ResTable_config.fromDtoH(type.config);
+            ALOGI("Adding config to type %d: %s\n", type.id,
+                thisConfig.toString());
+          }
+        }
+      } else {
+        ALOGV("Skipping empty ResTable_type for type %d", type.id);
+      }
+
+    } else if (ctype == RES_TABLE_LIBRARY_TYPE) {
+      if (group.dynamicRefTable.entries().isEmpty()) {
+        throw new UnsupportedOperationException("libraries not supported yet");
+//       const ResTable_lib_header* lib = (const ResTable_lib_header*) chunk;
+//       status_t err = validate_chunk(&lib->header, sizeof(*lib),
+//       endPos, "ResTable_lib_header");
+//       if (err != NO_ERROR) {
+//         return (mError=err);
+//       }
+//
+//       err = group->dynamicRefTable.load(lib);
+//       if (err != NO_ERROR) {
+//          return (mError=err);
+//        }
+//
+//        // Fill in the reference table with the entries we already know about.
+//        size_t N = mPackageGroups.size();
+//        for (size_t i = 0; i < N; i++) {
+//          group.dynamicRefTable.addMapping(mPackageGroups[i].name, mPackageGroups[i].id);
+//        }
+      } else {
+        ALOGW("Found multiple library tables, ignoring...");
+      }
+    } else {
+      err = validate_chunk(chunk, ResChunk_header.SIZEOF,
+          endPos, "ResTable_package:unknown");
+      if (err != NO_ERROR) {
+        return (mError=err);
+      }
+    }
+      chunk = chunk.myOffset() + csize < endPos ? new ResChunk_header(chunk.myBuf(), chunk.myOffset() + csize) : null;
+  }
+
+    return NO_ERROR;
+  }
+
+  public int getTableCookie(int index) {
+    return mHeaders.get(index).cookie;
+  }
+
+  void setParameters(ResTable_config params)
+  {
+//    AutoMutex _lock(mLock);
+//    AutoMutex _lock2(mFilteredConfigLock);
+    synchronized (mLock) {
+      synchronized (mFilteredConfigLock) {
+        if (kDebugTableGetEntry) {
+          ALOGI("Setting parameters: %s\n", params.toString());
+        }
+        mParams = params;
+        for (PackageGroup packageGroup : mPackageGroups.values()) {
+          if (kDebugTableNoisy) {
+            ALOGI("CLEARING BAGS FOR GROUP 0x%x!", packageGroup.id);
+          }
+          packageGroup.clearBagCache();
+
+          // Find which configurations match the set of parameters. This allows for a much
+          // faster lookup in getEntry() if the set of values is narrowed down.
+          //for (int t = 0; t < packageGroup.types.size(); t++) {
+            //if (packageGroup.types.get(t).isEmpty()) {
+            //   continue;
+            // }
+            //
+            // List<Type> typeList = packageGroup.types.get(t);
+        for (List<Type> typeList : packageGroup.types.values()) {
+          if (typeList.isEmpty()) {
+               continue;
+            }
+
+          // Retrieve the cache entry for this type.
+            //TypeCacheEntry cacheEntry = packageGroup.typeCacheEntries.editItemAt(t);
+
+            for (int ts = 0; ts < typeList.size(); ts++) {
+              Type type = typeList.get(ts);
+
+//              std::shared_ptr<Vector<const ResTable_type*>> newFilteredConfigs =
+//                  std::make_shared<Vector<const ResTable_type*>>();
+              List<ResTable_type> newFilteredConfigs = new ArrayList<>();
+
+              for (int ti = 0; ti < type.configs.size(); ti++) {
+                ResTable_config config = ResTable_config.fromDtoH(type.configs.get(ti).config);
+
+                if (config.match(mParams)) {
+                  newFilteredConfigs.add(type.configs.get(ti));
+                }
+              }
+
+              if (kDebugTableNoisy) {
+                ALOGD("Updating pkg=0x%x type=0x%x with 0x%x filtered configs",
+                    packageGroup.id, ts, newFilteredConfigs.size());
+              }
+
+              // todo: implement cache
+//              cacheEntry.filteredConfigs.add(newFilteredConfigs);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  ResTable_config getParameters()
+  {
+//    mLock.lock();
+    synchronized (mLock) {
+      return mParams;
+    }
+//    mLock.unlock();
+  }
+
+  private static final Map<String, Integer> sInternalNameToIdMap = new HashMap<>();
+  static {
+    sInternalNameToIdMap.put("^type", ResTable_map.ATTR_TYPE);
+    sInternalNameToIdMap.put("^l10n", ResTable_map.ATTR_L10N);
+    sInternalNameToIdMap.put("^min" , ResTable_map.ATTR_MIN);
+    sInternalNameToIdMap.put("^max", ResTable_map.ATTR_MAX);
+    sInternalNameToIdMap.put("^other", ResTable_map.ATTR_OTHER);
+    sInternalNameToIdMap.put("^zero", ResTable_map.ATTR_ZERO);
+    sInternalNameToIdMap.put("^one", ResTable_map.ATTR_ONE);
+    sInternalNameToIdMap.put("^two", ResTable_map.ATTR_TWO);
+    sInternalNameToIdMap.put("^few", ResTable_map.ATTR_FEW);
+    sInternalNameToIdMap.put("^many", ResTable_map.ATTR_MANY);
+  }
+
+  public int identifierForName(String name, String type, String packageName) {
+    return identifierForName(name, type, packageName, null);
+  }
+
+  public int identifierForName(String nameString, String type, String packageName,
+      final Ref<Integer> outTypeSpecFlags) {
+//    if (kDebugTableSuperNoisy) {
+//      printf("Identifier for name: error=%d\n", mError);
+//    }
+//    // Check for internal resource identifier as the very first thing, so
+//    // that we will always find them even when there are no resources.
+    if (nameString.startsWith("^")) {
+      if (sInternalNameToIdMap.containsKey(nameString)) {
+        if (outTypeSpecFlags != null) {
+          outTypeSpecFlags.set(ResTable_typeSpec.SPEC_PUBLIC);
+        }
+        return sInternalNameToIdMap.get(nameString);
+      }
+      if (nameString.length() > 7)
+        if (nameString.substring(1, 6).equals("index_")) {
+          int index = Integer.getInteger(nameString.substring(7));
+          if (Res_CHECKID(index)) {
+            ALOGW("Array resource index: %d is too large.",
+                index);
+            return 0;
+          }
+          if (outTypeSpecFlags != null) {
+            outTypeSpecFlags.set(ResTable_typeSpec.SPEC_PUBLIC);
+          }
+          return  Res_MAKEARRAY(index);
+        }
+
+      return 0;
+    }
+
+    if (mError != NO_ERROR) {
+      return 0;
+    }
+
+
+    // Figure out the package and type we are looking in...
+    // TODO(BC): The following code block was a best effort attempt to directly transliterate
+    // C++ code which uses pointer artihmetic. Consider replacing with simpler logic
+
+    boolean fakePublic = false;
+    char[] name = nameString.toCharArray();
+    int packageEnd = -1;
+    int typeEnd = -1;
+    int nameEnd = name.length;
+    int pIndex = 0;
+    while (pIndex < nameEnd) {
+      char p = name[pIndex];
+      if (p == ':') packageEnd = pIndex;
+      else if (p == '/') typeEnd = pIndex;
+      pIndex++;
+    }
+    int nameIndex = 0;
+    if (name[nameIndex] == '@') {
+      nameIndex++;
+      if (name[nameIndex] == '*') {
+        fakePublic = true;
+        nameIndex++;
+    }
+  }
+    if (nameIndex >= nameEnd) {
+      return 0;
+    }
+    if (packageEnd != -1) {
+        packageName = nameString.substring(nameIndex, packageEnd);
+        nameIndex = packageEnd+1;
+    } else if (packageName == null) {
+      return 0;
+    }
+    if (typeEnd != -1) {
+      type = nameString.substring(nameIndex, typeEnd);
+      nameIndex = typeEnd+1;
+    } else if (type == null) {
+      return 0;
+    }
+    if (nameIndex >= nameEnd) {
+      return 0;
+    }
+    nameString = nameString.substring(nameIndex, nameEnd);
+
+//    nameLen = nameEnd-name;
+//    if (kDebugTableNoisy) {
+//      printf("Looking for identifier: type=%s, name=%s, package=%s\n",
+//          String8(type, typeLen).string(),
+//          String8(name, nameLen).string(),
+//          String8(package, packageLen).string());
+//    }
+    final String attr = "attr";
+    final String attrPrivate = "^attr-private";
+    for (PackageGroup group : mPackageGroups.values()) {
+      if (!Objects.equals(packageName.trim(), group.name.trim())) {
+        if (kDebugTableNoisy) {
+           System.out.println(String.format("Skipping package group: %s\n", group.name));
+        }
+        continue;
+      }
+      for (ResTablePackage pkg : group.packages) {
+        String targetType = type;
+
+        do {
+          int ti = pkg.typeStrings.indexOfString(targetType);
+          if (ti < 0) {
+            continue;
+          }
+          ti += pkg.typeIdOffset;
+          int identifier = findEntry(group, ti, nameString, outTypeSpecFlags);
+          if (identifier != 0) {
+            if (fakePublic && outTypeSpecFlags != null) {
+                        outTypeSpecFlags.set(outTypeSpecFlags.get() | ResTable_typeSpec.SPEC_PUBLIC);
+            }
+            return identifier;
+          }
+        } while (attr.compareTo(targetType) == 0
+            && ((targetType = attrPrivate) != null)
+            );
+      }
+      break;
+    }
+    return 0;
+  }
+
+  int findEntry(PackageGroup group, int typeIndex, String name, Ref<Integer> outTypeSpecFlags) {
+    List<Type> typeList = getOrDefault(group.types, typeIndex, Collections.emptyList());
+    for (Type type : typeList) {
+      int ei = type._package_.keyStrings.indexOfString(name);
+      if (ei < 0) {
+        continue;
+      }
+      for (ResTable_type resTableType : type.configs) {
+        int entryIndex = resTableType.findEntryByResName(ei);
+        if (entryIndex >= 0) {
+          int resId = Res_MAKEID(group.id - 1, typeIndex, entryIndex);
+          if (outTypeSpecFlags != null) {
+            Entry result = new Entry();
+            if (getEntry(group, typeIndex, entryIndex, null, result) != NO_ERROR) {
+              ALOGW("Failed to find spec flags for 0x%08x", resId);
+              return 0;
+            }
+            outTypeSpecFlags.set(result.specFlags);
+          }
+          return resId;
+        }
+      }
+    }
+    return 0;
+  }
+
+//bool ResTable::expandResourceRef(const char16_t* refStr, size_t refLen,
+//                                 String16* outPackage,
+//                                 String16* outType,
+//                                 String16* outName,
+//                                 const String16* defType,
+//                                 const String16* defPackage,
+//                                 const char** outErrorMsg,
+//                                 bool* outPublicOnly)
+//{
+//    const char16_t* packageEnd = NULL;
+//    const char16_t* typeEnd = NULL;
+//    const char16_t* p = refStr;
+//    const char16_t* const end = p + refLen;
+//    while (p < end) {
+//        if (*p == ':') packageEnd = p;
+//        else if (*p == '/') {
+//            typeEnd = p;
+//            break;
+//        }
+//        p++;
+//    }
+//    p = refStr;
+//    if (*p == '@') p++;
+//
+//    if (outPublicOnly != NULL) {
+//        *outPublicOnly = true;
+//    }
+//    if (*p == '*') {
+//        p++;
+//        if (outPublicOnly != NULL) {
+//            *outPublicOnly = false;
+//        }
+//    }
+//
+//    if (packageEnd) {
+//        *outPackage = String16(p, packageEnd-p);
+//        p = packageEnd+1;
+//    } else {
+//        if (!defPackage) {
+//            if (outErrorMsg) {
+//                *outErrorMsg = "No resource package specified";
+//            }
+//            return false;
+//        }
+//        *outPackage = *defPackage;
+//    }
+//    if (typeEnd) {
+//        *outType = String16(p, typeEnd-p);
+//        p = typeEnd+1;
+//    } else {
+//        if (!defType) {
+//            if (outErrorMsg) {
+//                *outErrorMsg = "No resource type specified";
+//            }
+//            return false;
+//        }
+//        *outType = *defType;
+//    }
+//    *outName = String16(p, end-p);
+//    if(**outPackage == 0) {
+//        if(outErrorMsg) {
+//            *outErrorMsg = "Resource package cannot be an empty string";
+//        }
+//        return false;
+//    }
+//    if(**outType == 0) {
+//        if(outErrorMsg) {
+//            *outErrorMsg = "Resource type cannot be an empty string";
+//        }
+//        return false;
+//    }
+//    if(**outName == 0) {
+//        if(outErrorMsg) {
+//            *outErrorMsg = "Resource id cannot be an empty string";
+//        }
+//        return false;
+//    }
+//    return true;
+//}
+//
+//static uint32_t get_hex(char c, bool* outError)
+//{
+//    if (c >= '0' && c <= '9') {
+//        return c - '0';
+//    } else if (c >= 'a' && c <= 'f') {
+//        return c - 'a' + 0xa;
+//    } else if (c >= 'A' && c <= 'F') {
+//        return c - 'A' + 0xa;
+//    }
+//    *outError = true;
+//    return 0;
+//}
+//
+//struct unit_entry
+//{
+//    const char* name;
+//    size_t len;
+//    uint8_t type;
+//    uint32_t unit;
+//    float scale;
+//};
+//
+//static const unit_entry unitNames[] = {
+//    { "px", strlen("px"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_PX, 1.0f },
+//    { "dip", strlen("dip"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_DIP, 1.0f },
+//    { "dp", strlen("dp"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_DIP, 1.0f },
+//    { "sp", strlen("sp"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_SP, 1.0f },
+//    { "pt", strlen("pt"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_PT, 1.0f },
+//    { "in", strlen("in"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_IN, 1.0f },
+//    { "mm", strlen("mm"), Res_value::TYPE_DIMENSION, Res_value::COMPLEX_UNIT_MM, 1.0f },
+//    { "%", strlen("%"), Res_value::TYPE_FRACTION, Res_value::COMPLEX_UNIT_FRACTION, 1.0f/100 },
+//    { "%s", strlen("%s"), Res_value::TYPE_FRACTION, Res_value::COMPLEX_UNIT_FRACTION_PARENT, 1.0f/100 },
+//    { NULL, 0, 0, 0, 0 }
+//};
+//
+//static bool parse_unit(const char* str, Res_value* outValue,
+//                       float* outScale, const char** outEnd)
+//{
+//    const char* end = str;
+//    while (*end != 0 && !isspace((unsigned char)*end)) {
+//        end++;
+//    }
+//    const size_t len = end-str;
+//
+//    const char* realEnd = end;
+//    while (*realEnd != 0 && isspace((unsigned char)*realEnd)) {
+//        realEnd++;
+//    }
+//    if (*realEnd != 0) {
+//        return false;
+//    }
+//
+//    const unit_entry* cur = unitNames;
+//    while (cur->name) {
+//        if (len == cur->len && strncmp(cur->name, str, len) == 0) {
+//            outValue->dataType = cur->type;
+//            outValue->data = cur->unit << Res_value::COMPLEX_UNIT_SHIFT;
+//            *outScale = cur->scale;
+//            *outEnd = end;
+//            //printf("Found unit %s for %s\n", cur->name, str);
+//            return true;
+//        }
+//        cur++;
+//    }
+//
+//    return false;
+//}
+//
+//bool U16StringToInt(const char16_t* s, size_t len, Res_value* outValue)
+//{
+//    while (len > 0 && isspace16(*s)) {
+//        s++;
+//        len--;
+//    }
+//
+//    if (len <= 0) {
+//        return false;
+//    }
+//
+//    size_t i = 0;
+//    int64_t val = 0;
+//    bool neg = false;
+//
+//    if (*s == '-') {
+//        neg = true;
+//        i++;
+//    }
+//
+//    if (s[i] < '0' || s[i] > '9') {
+//        return false;
+//    }
+//
+//    static_assert(std::is_same<uint32_t, Res_value::data_type>::value,
+//                  "Res_value::data_type has changed. The range checks in this "
+//                  "function are no longer correct.");
+//
+//    // Decimal or hex?
+//    bool isHex;
+//    if (len > 1 && s[i] == '0' && s[i+1] == 'x') {
+//        isHex = true;
+//        i += 2;
+//
+//        if (neg) {
+//            return false;
+//        }
+//
+//        if (i == len) {
+//            // Just u"0x"
+//            return false;
+//        }
+//
+//        bool error = false;
+//        while (i < len && !error) {
+//            val = (val*16) + get_hex(s[i], &error);
+//            i++;
+//
+//            if (val > std::numeric_limits<uint32_t>::max()) {
+//                return false;
+//            }
+//        }
+//        if (error) {
+//            return false;
+//        }
+//    } else {
+//        isHex = false;
+//        while (i < len) {
+//            if (s[i] < '0' || s[i] > '9') {
+//                return false;
+//            }
+//            val = (val*10) + s[i]-'0';
+//            i++;
+//
+//            if ((neg && -val < std::numeric_limits<int32_t>::min()) ||
+//                (!neg && val > std::numeric_limits<int32_t>::max())) {
+//                return false;
+//            }
+//        }
+//    }
+//
+//    if (neg) val = -val;
+//
+//    while (i < len && isspace16(s[i])) {
+//        i++;
+//    }
+//
+//    if (i != len) {
+//        return false;
+//    }
+//
+//    if (outValue) {
+//        outValue->dataType =
+//            isHex ? outValue->TYPE_INT_HEX : outValue->TYPE_INT_DEC;
+//        outValue->data = static_cast<Res_value::data_type>(val);
+//    }
+//    return true;
+//}
+//
+//bool ResTable::stringToInt(const char16_t* s, size_t len, Res_value* outValue)
+//{
+//    return U16StringToInt(s, len, outValue);
+//}
+//
+//bool ResTable::stringToFloat(const char16_t* s, size_t len, Res_value* outValue)
+//{
+//    while (len > 0 && isspace16(*s)) {
+//        s++;
+//        len--;
+//    }
+//
+//    if (len <= 0) {
+//        return false;
+//    }
+//
+//    char buf[128];
+//    int i=0;
+//    while (len > 0 && *s != 0 && i < 126) {
+//        if (*s > 255) {
+//            return false;
+//        }
+//        buf[i++] = *s++;
+//        len--;
+//    }
+//
+//    if (len > 0) {
+//        return false;
+//    }
+//    if ((buf[0] < '0' || buf[0] > '9') && buf[0] != '.' && buf[0] != '-' && buf[0] != '+') {
+//        return false;
+//    }
+//
+//    buf[i] = 0;
+//    const char* end;
+//    float f = strtof(buf, (char**)&end);
+//
+//    if (*end != 0 && !isspace((unsigned char)*end)) {
+//        // Might be a unit...
+//        float scale;
+//        if (parse_unit(end, outValue, &scale, &end)) {
+//            f *= scale;
+//            const bool neg = f < 0;
+//            if (neg) f = -f;
+//            uint64_t bits = (uint64_t)(f*(1<<23)+.5f);
+//            uint32_t radix;
+//            uint32_t shift;
+//            if ((bits&0x7fffff) == 0) {
+//                // Always use 23p0 if there is no fraction, just to make
+//                // things easier to read.
+//                radix = Res_value::COMPLEX_RADIX_23p0;
+//                shift = 23;
+//            } else if ((bits&0xffffffffff800000LL) == 0) {
+//                // Magnitude is zero -- can fit in 0 bits of precision.
+//                radix = Res_value::COMPLEX_RADIX_0p23;
+//                shift = 0;
+//            } else if ((bits&0xffffffff80000000LL) == 0) {
+//                // Magnitude can fit in 8 bits of precision.
+//                radix = Res_value::COMPLEX_RADIX_8p15;
+//                shift = 8;
+//            } else if ((bits&0xffffff8000000000LL) == 0) {
+//                // Magnitude can fit in 16 bits of precision.
+//                radix = Res_value::COMPLEX_RADIX_16p7;
+//                shift = 16;
+//            } else {
+//                // Magnitude needs entire range, so no fractional part.
+//                radix = Res_value::COMPLEX_RADIX_23p0;
+//                shift = 23;
+//            }
+//            int32_t mantissa = (int32_t)(
+//                (bits>>shift) & Res_value::COMPLEX_MANTISSA_MASK);
+//            if (neg) {
+//                mantissa = (-mantissa) & Res_value::COMPLEX_MANTISSA_MASK;
+//            }
+//            outValue->data |=
+//                (radix<<Res_value::COMPLEX_RADIX_SHIFT)
+//                | (mantissa<<Res_value::COMPLEX_MANTISSA_SHIFT);
+//            //printf("Input value: %f 0x%016Lx, mult: %f, radix: %d, shift: %d, final: 0x%08x\n",
+//            //       f * (neg ? -1 : 1), bits, f*(1<<23),
+//            //       radix, shift, outValue->data);
+//            return true;
+//        }
+//        return false;
+//    }
+//
+//    while (*end != 0 && isspace((unsigned char)*end)) {
+//        end++;
+//    }
+//
+//    if (*end == 0) {
+//        if (outValue) {
+//            outValue->dataType = outValue->TYPE_FLOAT;
+//            *(float*)(&outValue->data) = f;
+//            return true;
+//        }
+//    }
+//
+//    return false;
+//}
+//
+//bool ResTable::stringToValue(Res_value* outValue, String16* outString,
+//                             const char16_t* s, size_t len,
+//                             bool preserveSpaces, bool coerceType,
+//                             uint32_t attrID,
+//                             const String16* defType,
+//                             const String16* defPackage,
+//                             Accessor* accessor,
+//                             void* accessorCookie,
+//                             uint32_t attrType,
+//                             bool enforcePrivate) const
+//{
+//    bool localizationSetting = accessor != NULL && accessor->getLocalizationSetting();
+//    const char* errorMsg = NULL;
+//
+//    outValue->size = sizeof(Res_value);
+//    outValue->res0 = 0;
+//
+//    // First strip leading/trailing whitespace.  Do this before handling
+//    // escapes, so they can be used to force whitespace into the string.
+//    if (!preserveSpaces) {
+//        while (len > 0 && isspace16(*s)) {
+//            s++;
+//            len--;
+//        }
+//        while (len > 0 && isspace16(s[len-1])) {
+//            len--;
+//        }
+//        // If the string ends with '\', then we keep the space after it.
+//        if (len > 0 && s[len-1] == '\\' && s[len] != 0) {
+//            len++;
+//        }
+//    }
+//
+//    //printf("Value for: %s\n", String8(s, len).string());
+//
+//    uint32_t l10nReq = ResTable_map::L10N_NOT_REQUIRED;
+//    uint32_t attrMin = 0x80000000, attrMax = 0x7fffffff;
+//    bool fromAccessor = false;
+//    if (attrID != 0 && !Res_INTERNALID(attrID)) {
+//        const ssize_t p = getResourcePackageIndex(attrID);
+//        const bag_entry* bag;
+//        ssize_t cnt = p >= 0 ? lockBag(attrID, &bag) : -1;
+//        //printf("For attr 0x%08x got bag of %d\n", attrID, cnt);
+//        if (cnt >= 0) {
+//            while (cnt > 0) {
+//                //printf("Entry 0x%08x = 0x%08x\n", bag->map.name.ident, bag->map.value.data);
+//                switch (bag->map.name.ident) {
+//                case ResTable_map::ATTR_TYPE:
+//                    attrType = bag->map.value.data;
+//                    break;
+//                case ResTable_map::ATTR_MIN:
+//                    attrMin = bag->map.value.data;
+//                    break;
+//                case ResTable_map::ATTR_MAX:
+//                    attrMax = bag->map.value.data;
+//                    break;
+//                case ResTable_map::ATTR_L10N:
+//                    l10nReq = bag->map.value.data;
+//                    break;
+//                }
+//                bag++;
+//                cnt--;
+//            }
+//            unlockBag(bag);
+//        } else if (accessor && accessor->getAttributeType(attrID, &attrType)) {
+//            fromAccessor = true;
+//            if (attrType == ResTable_map::TYPE_ENUM
+//                    || attrType == ResTable_map::TYPE_FLAGS
+//                    || attrType == ResTable_map::TYPE_INTEGER) {
+//                accessor->getAttributeMin(attrID, &attrMin);
+//                accessor->getAttributeMax(attrID, &attrMax);
+//            }
+//            if (localizationSetting) {
+//                l10nReq = accessor->getAttributeL10N(attrID);
+//            }
+//        }
+//    }
+//
+//    const bool canStringCoerce =
+//        coerceType && (attrType&ResTable_map::TYPE_STRING) != 0;
+//
+//    if (*s == '@') {
+//        outValue->dataType = outValue->TYPE_REFERENCE;
+//
+//        // Note: we don't check attrType here because the reference can
+//        // be to any other type; we just need to count on the client making
+//        // sure the referenced type is correct.
+//
+//        //printf("Looking up ref: %s\n", String8(s, len).string());
+//
+//        // It's a reference!
+//        if (len == 5 && s[1]=='n' && s[2]=='u' && s[3]=='l' && s[4]=='l') {
+//            // Special case @null as undefined. This will be converted by
+//            // AssetManager to TYPE_NULL with data DATA_NULL_UNDEFINED.
+//            outValue->data = 0;
+//            return true;
+//        } else if (len == 6 && s[1]=='e' && s[2]=='m' && s[3]=='p' && s[4]=='t' && s[5]=='y') {
+//            // Special case @empty as explicitly defined empty value.
+//            outValue->dataType = Res_value::TYPE_NULL;
+//            outValue->data = Res_value::DATA_NULL_EMPTY;
+//            return true;
+//        } else {
+//            bool createIfNotFound = false;
+//            const char16_t* resourceRefName;
+//            int resourceNameLen;
+//            if (len > 2 && s[1] == '+') {
+//                createIfNotFound = true;
+//                resourceRefName = s + 2;
+//                resourceNameLen = len - 2;
+//            } else if (len > 2 && s[1] == '*') {
+//                enforcePrivate = false;
+//                resourceRefName = s + 2;
+//                resourceNameLen = len - 2;
+//            } else {
+//                createIfNotFound = false;
+//                resourceRefName = s + 1;
+//                resourceNameLen = len - 1;
+//            }
+//            String16 package, type, name;
+//            if (!expandResourceRef(resourceRefName,resourceNameLen, &package, &type, &name,
+//                                   defType, defPackage, &errorMsg)) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, errorMsg);
+//                }
+//                return false;
+//            }
+//
+//            uint32_t specFlags = 0;
+//            uint32_t rid = identifierForName(name.string(), name.size(), type.string(),
+//                    type.size(), package.string(), package.size(), &specFlags);
+//            if (rid != 0) {
+//                if (enforcePrivate) {
+//                    if (accessor == NULL || accessor->getAssetsPackage() != package) {
+//                        if ((specFlags&ResTable_typeSpec::SPEC_PUBLIC) == 0) {
+//                            if (accessor != NULL) {
+//                                accessor->reportError(accessorCookie, "Resource is not public.");
+//                            }
+//                            return false;
+//                        }
+//                    }
+//                }
+//
+//                if (accessor) {
+//                    rid = Res_MAKEID(
+//                        accessor->getRemappedPackage(Res_GETPACKAGE(rid)),
+//                        Res_GETTYPE(rid), Res_GETENTRY(rid));
+//                    if (kDebugTableNoisy) {
+//                        ALOGI("Incl %s:%s/%s: 0x%08x\n",
+//                                String8(package).string(), String8(type).string(),
+//                                String8(name).string(), rid);
+//                    }
+//                }
+//
+//                uint32_t packageId = Res_GETPACKAGE(rid) + 1;
+//                if (packageId != APP_PACKAGE_ID && packageId != SYS_PACKAGE_ID) {
+//                    outValue->dataType = Res_value::TYPE_DYNAMIC_REFERENCE;
+//                }
+//                outValue->data = rid;
+//                return true;
+//            }
+//
+//            if (accessor) {
+//                uint32_t rid = accessor->getCustomResourceWithCreation(package, type, name,
+//                                                                       createIfNotFound);
+//                if (rid != 0) {
+//                    if (kDebugTableNoisy) {
+//                        ALOGI("Pckg %s:%s/%s: 0x%08x\n",
+//                                String8(package).string(), String8(type).string(),
+//                                String8(name).string(), rid);
+//                    }
+//                    uint32_t packageId = Res_GETPACKAGE(rid) + 1;
+//                    if (packageId == 0x00) {
+//                        outValue->data = rid;
+//                        outValue->dataType = Res_value::TYPE_DYNAMIC_REFERENCE;
+//                        return true;
+//                    } else if (packageId == APP_PACKAGE_ID || packageId == SYS_PACKAGE_ID) {
+//                        // We accept packageId's generated as 0x01 in order to support
+//                        // building the android system resources
+//                        outValue->data = rid;
+//                        return true;
+//                    }
+//                }
+//            }
+//        }
+//
+//        if (accessor != NULL) {
+//            accessor->reportError(accessorCookie, "No resource found that matches the given name");
+//        }
+//        return false;
+//    }
+//
+//    // if we got to here, and localization is required and it's not a reference,
+//    // complain and bail.
+//    if (l10nReq == ResTable_map::L10N_SUGGESTED) {
+//        if (localizationSetting) {
+//            if (accessor != NULL) {
+//                accessor->reportError(accessorCookie, "This attribute must be localized.");
+//            }
+//        }
+//    }
+//
+//    if (*s == '#') {
+//        // It's a color!  Convert to an integer of the form 0xaarrggbb.
+//        uint32_t color = 0;
+//        bool error = false;
+//        if (len == 4) {
+//            outValue->dataType = outValue->TYPE_INT_COLOR_RGB4;
+//            color |= 0xFF000000;
+//            color |= get_hex(s[1], &error) << 20;
+//            color |= get_hex(s[1], &error) << 16;
+//            color |= get_hex(s[2], &error) << 12;
+//            color |= get_hex(s[2], &error) << 8;
+//            color |= get_hex(s[3], &error) << 4;
+//            color |= get_hex(s[3], &error);
+//        } else if (len == 5) {
+//            outValue->dataType = outValue->TYPE_INT_COLOR_ARGB4;
+//            color |= get_hex(s[1], &error) << 28;
+//            color |= get_hex(s[1], &error) << 24;
+//            color |= get_hex(s[2], &error) << 20;
+//            color |= get_hex(s[2], &error) << 16;
+//            color |= get_hex(s[3], &error) << 12;
+//            color |= get_hex(s[3], &error) << 8;
+//            color |= get_hex(s[4], &error) << 4;
+//            color |= get_hex(s[4], &error);
+//        } else if (len == 7) {
+//            outValue->dataType = outValue->TYPE_INT_COLOR_RGB8;
+//            color |= 0xFF000000;
+//            color |= get_hex(s[1], &error) << 20;
+//            color |= get_hex(s[2], &error) << 16;
+//            color |= get_hex(s[3], &error) << 12;
+//            color |= get_hex(s[4], &error) << 8;
+//            color |= get_hex(s[5], &error) << 4;
+//            color |= get_hex(s[6], &error);
+//        } else if (len == 9) {
+//            outValue->dataType = outValue->TYPE_INT_COLOR_ARGB8;
+//            color |= get_hex(s[1], &error) << 28;
+//            color |= get_hex(s[2], &error) << 24;
+//            color |= get_hex(s[3], &error) << 20;
+//            color |= get_hex(s[4], &error) << 16;
+//            color |= get_hex(s[5], &error) << 12;
+//            color |= get_hex(s[6], &error) << 8;
+//            color |= get_hex(s[7], &error) << 4;
+//            color |= get_hex(s[8], &error);
+//        } else {
+//            error = true;
+//        }
+//        if (!error) {
+//            if ((attrType&ResTable_map::TYPE_COLOR) == 0) {
+//                if (!canStringCoerce) {
+//                    if (accessor != NULL) {
+//                        accessor->reportError(accessorCookie,
+//                                "Color types not allowed");
+//                    }
+//                    return false;
+//                }
+//            } else {
+//                outValue->data = color;
+//                //printf("Color input=%s, output=0x%x\n", String8(s, len).string(), color);
+//                return true;
+//            }
+//        } else {
+//            if ((attrType&ResTable_map::TYPE_COLOR) != 0) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, "Color value not valid --"
+//                            " must be #rgb, #argb, #rrggbb, or #aarrggbb");
+//                }
+//                #if 0
+//                fprintf(stderr, "%s: Color ID %s value %s is not valid\n",
+//                        "Resource File", //(const char*)in->getPrintableSource(),
+//                        String8(*curTag).string(),
+//                        String8(s, len).string());
+//                #endif
+//                return false;
+//            }
+//        }
+//    }
+//
+//    if (*s == '?') {
+//        outValue->dataType = outValue->TYPE_ATTRIBUTE;
+//
+//        // Note: we don't check attrType here because the reference can
+//        // be to any other type; we just need to count on the client making
+//        // sure the referenced type is correct.
+//
+//        //printf("Looking up attr: %s\n", String8(s, len).string());
+//
+//        static const String16 attr16("attr");
+//        String16 package, type, name;
+//        if (!expandResourceRef(s+1, len-1, &package, &type, &name,
+//                               &attr16, defPackage, &errorMsg)) {
+//            if (accessor != NULL) {
+//                accessor->reportError(accessorCookie, errorMsg);
+//            }
+//            return false;
+//        }
+//
+//        //printf("Pkg: %s, Type: %s, Name: %s\n",
+//        //       String8(package).string(), String8(type).string(),
+//        //       String8(name).string());
+//        uint32_t specFlags = 0;
+//        uint32_t rid =
+//            identifierForName(name.string(), name.size(),
+//                              type.string(), type.size(),
+//                              package.string(), package.size(), &specFlags);
+//        if (rid != 0) {
+//            if (enforcePrivate) {
+//                if ((specFlags&ResTable_typeSpec::SPEC_PUBLIC) == 0) {
+//                    if (accessor != NULL) {
+//                        accessor->reportError(accessorCookie, "Attribute is not public.");
+//                    }
+//                    return false;
+//                }
+//            }
+//
+//            if (accessor) {
+//                rid = Res_MAKEID(
+//                    accessor->getRemappedPackage(Res_GETPACKAGE(rid)),
+//                    Res_GETTYPE(rid), Res_GETENTRY(rid));
+//            }
+//
+//            uint32_t packageId = Res_GETPACKAGE(rid) + 1;
+//            if (packageId != APP_PACKAGE_ID && packageId != SYS_PACKAGE_ID) {
+//                outValue->dataType = Res_value::TYPE_DYNAMIC_ATTRIBUTE;
+//            }
+//            outValue->data = rid;
+//            return true;
+//        }
+//
+//        if (accessor) {
+//            uint32_t rid = accessor->getCustomResource(package, type, name);
+//            if (rid != 0) {
+//                uint32_t packageId = Res_GETPACKAGE(rid) + 1;
+//                if (packageId == 0x00) {
+//                    outValue->data = rid;
+//                    outValue->dataType = Res_value::TYPE_DYNAMIC_ATTRIBUTE;
+//                    return true;
+//                } else if (packageId == APP_PACKAGE_ID || packageId == SYS_PACKAGE_ID) {
+//                    // We accept packageId's generated as 0x01 in order to support
+//                    // building the android system resources
+//                    outValue->data = rid;
+//                    return true;
+//                }
+//            }
+//        }
+//
+//        if (accessor != NULL) {
+//            accessor->reportError(accessorCookie, "No resource found that matches the given name");
+//        }
+//        return false;
+//    }
+//
+//    if (stringToInt(s, len, outValue)) {
+//        if ((attrType&ResTable_map::TYPE_INTEGER) == 0) {
+//            // If this type does not allow integers, but does allow floats,
+//            // fall through on this error case because the float type should
+//            // be able to accept any integer value.
+//            if (!canStringCoerce && (attrType&ResTable_map::TYPE_FLOAT) == 0) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, "Integer types not allowed");
+//                }
+//                return false;
+//            }
+//        } else {
+//            if (((int32_t)outValue->data) < ((int32_t)attrMin)
+//                    || ((int32_t)outValue->data) > ((int32_t)attrMax)) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, "Integer value out of range");
+//                }
+//                return false;
+//            }
+//            return true;
+//        }
+//    }
+//
+//    if (stringToFloat(s, len, outValue)) {
+//        if (outValue->dataType == Res_value::TYPE_DIMENSION) {
+//            if ((attrType&ResTable_map::TYPE_DIMENSION) != 0) {
+//                return true;
+//            }
+//            if (!canStringCoerce) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, "Dimension types not allowed");
+//                }
+//                return false;
+//            }
+//        } else if (outValue->dataType == Res_value::TYPE_FRACTION) {
+//            if ((attrType&ResTable_map::TYPE_FRACTION) != 0) {
+//                return true;
+//            }
+//            if (!canStringCoerce) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, "Fraction types not allowed");
+//                }
+//                return false;
+//            }
+//        } else if ((attrType&ResTable_map::TYPE_FLOAT) == 0) {
+//            if (!canStringCoerce) {
+//                if (accessor != NULL) {
+//                    accessor->reportError(accessorCookie, "Float types not allowed");
+//                }
+//                return false;
+//            }
+//        } else {
+//            return true;
+//        }
+//    }
+//
+//    if (len == 4) {
+//        if ((s[0] == 't' || s[0] == 'T') &&
+//            (s[1] == 'r' || s[1] == 'R') &&
+//            (s[2] == 'u' || s[2] == 'U') &&
+//            (s[3] == 'e' || s[3] == 'E')) {
+//            if ((attrType&ResTable_map::TYPE_BOOLEAN) == 0) {
+//                if (!canStringCoerce) {
+//                    if (accessor != NULL) {
+//                        accessor->reportError(accessorCookie, "Boolean types not allowed");
+//                    }
+//                    return false;
+//                }
+//            } else {
+//                outValue->dataType = outValue->TYPE_INT_BOOLEAN;
+//                outValue->data = (uint32_t)-1;
+//                return true;
+//            }
+//        }
+//    }
+//
+//    if (len == 5) {
+//        if ((s[0] == 'f' || s[0] == 'F') &&
+//            (s[1] == 'a' || s[1] == 'A') &&
+//            (s[2] == 'l' || s[2] == 'L') &&
+//            (s[3] == 's' || s[3] == 'S') &&
+//            (s[4] == 'e' || s[4] == 'E')) {
+//            if ((attrType&ResTable_map::TYPE_BOOLEAN) == 0) {
+//                if (!canStringCoerce) {
+//                    if (accessor != NULL) {
+//                        accessor->reportError(accessorCookie, "Boolean types not allowed");
+//                    }
+//                    return false;
+//                }
+//            } else {
+//                outValue->dataType = outValue->TYPE_INT_BOOLEAN;
+//                outValue->data = 0;
+//                return true;
+//            }
+//        }
+//    }
+//
+//    if ((attrType&ResTable_map::TYPE_ENUM) != 0) {
+//        const ssize_t p = getResourcePackageIndex(attrID);
+//        const bag_entry* bag;
+//        ssize_t cnt = p >= 0 ? lockBag(attrID, &bag) : -1;
+//        //printf("Got %d for enum\n", cnt);
+//        if (cnt >= 0) {
+//            resource_name rname;
+//            while (cnt > 0) {
+//                if (!Res_INTERNALID(bag->map.name.ident)) {
+//                    //printf("Trying attr #%08x\n", bag->map.name.ident);
+//                    if (getResourceName(bag->map.name.ident, false, &rname)) {
+//                        #if 0
+//                        printf("Matching %s against %s (0x%08x)\n",
+//                               String8(s, len).string(),
+//                               String8(rname.name, rname.nameLen).string(),
+//                               bag->map.name.ident);
+//                        #endif
+//                        if (strzcmp16(s, len, rname.name, rname.nameLen) == 0) {
+//                            outValue->dataType = bag->map.value.dataType;
+//                            outValue->data = bag->map.value.data;
+//                            unlockBag(bag);
+//                            return true;
+//                        }
+//                    }
+//
+//                }
+//                bag++;
+//                cnt--;
+//            }
+//            unlockBag(bag);
+//        }
+//
+//        if (fromAccessor) {
+//            if (accessor->getAttributeEnum(attrID, s, len, outValue)) {
+//                return true;
+//            }
+//        }
+//    }
+//
+//    if ((attrType&ResTable_map::TYPE_FLAGS) != 0) {
+//        const ssize_t p = getResourcePackageIndex(attrID);
+//        const bag_entry* bag;
+//        ssize_t cnt = p >= 0 ? lockBag(attrID, &bag) : -1;
+//        //printf("Got %d for flags\n", cnt);
+//        if (cnt >= 0) {
+//            bool failed = false;
+//            resource_name rname;
+//            outValue->dataType = Res_value::TYPE_INT_HEX;
+//            outValue->data = 0;
+//            const char16_t* end = s + len;
+//            const char16_t* pos = s;
+//            while (pos < end && !failed) {
+//                const char16_t* start = pos;
+//                pos++;
+//                while (pos < end && *pos != '|') {
+//                    pos++;
+//                }
+//                //printf("Looking for: %s\n", String8(start, pos-start).string());
+//                const bag_entry* bagi = bag;
+//                ssize_t i;
+//                for (i=0; i<cnt; i++, bagi++) {
+//                    if (!Res_INTERNALID(bagi->map.name.ident)) {
+//                        //printf("Trying attr #%08x\n", bagi->map.name.ident);
+//                        if (getResourceName(bagi->map.name.ident, false, &rname)) {
+//                            #if 0
+//                            printf("Matching %s against %s (0x%08x)\n",
+//                                   String8(start,pos-start).string(),
+//                                   String8(rname.name, rname.nameLen).string(),
+//                                   bagi->map.name.ident);
+//                            #endif
+//                            if (strzcmp16(start, pos-start, rname.name, rname.nameLen) == 0) {
+//                                outValue->data |= bagi->map.value.data;
+//                                break;
+//                            }
+//                        }
+//                    }
+//                }
+//                if (i >= cnt) {
+//                    // Didn't find this flag identifier.
+//                    failed = true;
+//                }
+//                if (pos < end) {
+//                    pos++;
+//                }
+//            }
+//            unlockBag(bag);
+//            if (!failed) {
+//                //printf("Final flag value: 0x%lx\n", outValue->data);
+//                return true;
+//            }
+//        }
+//
+//
+//        if (fromAccessor) {
+//            if (accessor->getAttributeFlags(attrID, s, len, outValue)) {
+//                //printf("Final flag value: 0x%lx\n", outValue->data);
+//                return true;
+//            }
+//        }
+//    }
+//
+//    if ((attrType&ResTable_map::TYPE_STRING) == 0) {
+//        if (accessor != NULL) {
+//            accessor->reportError(accessorCookie, "String types not allowed");
+//        }
+//        return false;
+//    }
+//
+//    // Generic string handling...
+//    outValue->dataType = outValue->TYPE_STRING;
+//    if (outString) {
+//        bool failed = collectString(outString, s, len, preserveSpaces, &errorMsg);
+//        if (accessor != NULL) {
+//            accessor->reportError(accessorCookie, errorMsg);
+//        }
+//        return failed;
+//    }
+//
+//    return true;
+//}
+//
+//bool ResTable::collectString(String16* outString,
+//                             const char16_t* s, size_t len,
+//                             bool preserveSpaces,
+//                             const char** outErrorMsg,
+//                             bool append)
+//{
+//    String16 tmp;
+//
+//    char quoted = 0;
+//    const char16_t* p = s;
+//    while (p < (s+len)) {
+//        while (p < (s+len)) {
+//            const char16_t c = *p;
+//            if (c == '\\') {
+//                break;
+//            }
+//            if (!preserveSpaces) {
+//                if (quoted == 0 && isspace16(c)
+//                    && (c != ' ' || isspace16(*(p+1)))) {
+//                    break;
+//                }
+//                if (c == '"' && (quoted == 0 || quoted == '"')) {
+//                    break;
+//                }
+//                if (c == '\'' && (quoted == 0 || quoted == '\'')) {
+//                    /*
+//                     * In practice, when people write ' instead of \'
+//                     * in a string, they are doing it by accident
+//                     * instead of really meaning to use ' as a quoting
+//                     * character.  Warn them so they don't lose it.
+//                     */
+//                    if (outErrorMsg) {
+//                        *outErrorMsg = "Apostrophe not preceded by \\";
+//                    }
+//                    return false;
+//                }
+//            }
+//            p++;
+//        }
+//        if (p < (s+len)) {
+//            if (p > s) {
+//                tmp.append(String16(s, p-s));
+//            }
+//            if (!preserveSpaces && (*p == '"' || *p == '\'')) {
+//                if (quoted == 0) {
+//                    quoted = *p;
+//                } else {
+//                    quoted = 0;
+//                }
+//                p++;
+//            } else if (!preserveSpaces && isspace16(*p)) {
+//                // Space outside of a quote -- consume all spaces and
+//                // leave a single plain space char.
+//                tmp.append(String16(" "));
+//                p++;
+//                while (p < (s+len) && isspace16(*p)) {
+//                    p++;
+//                }
+//            } else if (*p == '\\') {
+//                p++;
+//                if (p < (s+len)) {
+//                    switch (*p) {
+//                    case 't':
+//                        tmp.append(String16("\t"));
+//                        break;
+//                    case 'n':
+//                        tmp.append(String16("\n"));
+//                        break;
+//                    case '#':
+//                        tmp.append(String16("#"));
+//                        break;
+//                    case '@':
+//                        tmp.append(String16("@"));
+//                        break;
+//                    case '?':
+//                        tmp.append(String16("?"));
+//                        break;
+//                    case '"':
+//                        tmp.append(String16("\""));
+//                        break;
+//                    case '\'':
+//                        tmp.append(String16("'"));
+//                        break;
+//                    case '\\':
+//                        tmp.append(String16("\\"));
+//                        break;
+//                    case 'u':
+//                    {
+//                        char16_t chr = 0;
+//                        int i = 0;
+//                        while (i < 4 && p[1] != 0) {
+//                            p++;
+//                            i++;
+//                            int c;
+//                            if (*p >= '0' && *p <= '9') {
+//                                c = *p - '0';
+//                            } else if (*p >= 'a' && *p <= 'f') {
+//                                c = *p - 'a' + 10;
+//                            } else if (*p >= 'A' && *p <= 'F') {
+//                                c = *p - 'A' + 10;
+//                            } else {
+//                                if (outErrorMsg) {
+//                                    *outErrorMsg = "Bad character in \\u unicode escape sequence";
+//                                }
+//                                return false;
+//                            }
+//                            chr = (chr<<4) | c;
+//                        }
+//                        tmp.append(String16(&chr, 1));
+//                    } break;
+//                    default:
+//                        // ignore unknown escape chars.
+//                        break;
+//                    }
+//                    p++;
+//                }
+//            }
+//            len -= (p-s);
+//            s = p;
+//        }
+//    }
+//
+//    if (tmp.size() != 0) {
+//        if (len > 0) {
+//            tmp.append(String16(s, len));
+//        }
+//        if (append) {
+//            outString->append(tmp);
+//        } else {
+//            outString->setTo(tmp);
+//        }
+//    } else {
+//        if (append) {
+//            outString->append(String16(s, len));
+//        } else {
+//            outString->setTo(s, len);
+//        }
+//    }
+//
+//    return true;
+//}
+
+  public int getBasePackageCount()
+  {
+    if (mError != NO_ERROR) {
+      return 0;
+    }
+    return mPackageGroups.size();
+  }
+
+  public String getBasePackageName(int idx)
+  {
+    if (mError != NO_ERROR) {
+      return null;
+    }
+    LOG_FATAL_IF(idx >= mPackageGroups.size(),
+        "Requested package index %d past package count %d",
+        (int)idx, (int)mPackageGroups.size());
+    return mPackageGroups.get(keyFor(idx)).name;
+  }
+
+  public int getBasePackageId(int idx)
+  {
+    if (mError != NO_ERROR) {
+      return 0;
+    }
+    LOG_FATAL_IF(idx >= mPackageGroups.size(),
+        "Requested package index %d past package count %d",
+        (int)idx, (int)mPackageGroups.size());
+    return mPackageGroups.get(keyFor(idx)).id;
+  }
+
+  int getLastTypeIdForPackage(int idx)
+  {
+    if (mError != NO_ERROR) {
+      return 0;
+    }
+    LOG_FATAL_IF(idx >= mPackageGroups.size(),
+        "Requested package index %d past package count %d",
+        (int)idx, (int)mPackageGroups.size());
+    PackageGroup group = mPackageGroups.get(keyFor(idx));
+    return group.largestTypeId;
+  }
+
+  int keyFor(int idx) {
+    ArrayList<Integer> keys = new ArrayList<>(mPackageGroups.keySet());
+    Collections.sort(keys);
+    return keys.get(idx);
+  }
+
+  public int getTableCount() {
+    return mHeaders.size();
+  }
+
+  public ResStringPool getTableStringBlock(int index) {
+    return mHeaders.get(index).values;
+  }
+
+  public DynamicRefTable getDynamicRefTableForCookie(int cookie) {
+    for (PackageGroup pg : mPackageGroups.values()) {
+      int M = pg.packages.size();
+      for (int j = 0; j < M; j++) {
+        if (pg.packages.get(j).header.cookie == cookie) {
+          return pg.dynamicRefTable;
+        }
+      }
+    }
+    return null;
+  }
+
+  public boolean getResourceName(int resID, boolean allowUtf8, ResourceName outName) {
+    if (mError != NO_ERROR) {
+      return false;
+    }
+
+    final int p = getResourcePackageIndex(resID);
+    final int t = Res_GETTYPE(resID);
+    final int e = Res_GETENTRY(resID);
+
+    if (p < 0) {
+      if (Res_GETPACKAGE(resID)+1 == 0) {
+        ALOGW("No package identifier when getting name for resource number 0x%08x", resID);
+      }
+      return false;
+    }
+    if (t < 0) {
+      ALOGW("No type identifier when getting name for resource number 0x%08x", resID);
+      return false;
+    }
+
+    final PackageGroup grp = mPackageGroups.get(p);
+    if (grp == NULL) {
+      ALOGW("Bad identifier when getting name for resource number 0x%08x", resID);
+      return false;
+    }
+
+    Entry entry = new Entry();
+    int err = getEntry(grp, t, e, null, entry);
+    if (err != NO_ERROR) {
+      return false;
+    }
+
+    outName.packageName = grp.name;
+    outName.type = entry.typeStr.string();
+    if (outName.type == null) {
+      return false;
+    }
+    outName.name = entry.keyStr.string();
+    if (outName.name == null) {
+      return false;
+    }
+
+    return true;
+  }
+
+  String getResourceName(int resId) {
+    ResourceName outName = new ResourceName();
+    if (getResourceName(resId, true, outName)) {
+      return outName.toString();
+    }
+    throw new IllegalArgumentException("Unknown resource id " + resId);
+  }
+
+  // A group of objects describing a particular resource package.
+  // The first in 'package' is always the root object (from the resource
+  // table that defined the package); the ones after are skins on top of it.
+  // from ResourceTypes.cpp struct ResTable::PackageGroup
+  public static class PackageGroup
+  {
+    public PackageGroup(
+        ResTable _owner, final String _name, int _id,
+        boolean appAsLib, boolean _isSystemAsset, boolean _isDynamic)
+//        : owner(_owner)
+//        , name(_name)
+//        , id(_id)
+//        , largestTypeId(0)
+//        , dynamicRefTable(static_cast<uint8_t>(_id), appAsLib)
+//        , isSystemAsset(_isSystemAsset)
+    {
+      this.owner = _owner;
+      this.name = _name;
+      this.id = _id;
+      this.dynamicRefTable = new DynamicRefTable((byte) _id, appAsLib);
+      this.isSystemAsset = _isSystemAsset;
+      this.isDynamic = _isDynamic;
+    }
+
+//    ~PackageGroup() {
+//      clearBagCache();
+//      final int numTypes = types.size();
+//      for (int i = 0; i < numTypes; i++) {
+//        final List<DataType> typeList = types.get(i);
+//        final int numInnerTypes = typeList.size();
+//        for (int j = 0; j < numInnerTypes; j++) {
+//          if (typeList.get(j)._package_.owner == owner) {
+//            delete typeList[j];
+//          }
+//        }
+//        typeList.clear();
+//      }
+//
+//      final int N = packages.size();
+//      for (int i=0; i<N; i++) {
+//        ResTable_package pkg = packages[i];
+//        if (pkg.owner == owner) {
+//          delete pkg;
+//        }
+//      }
+//    }
+
+    /**
+     * Clear all cache related data that depends on parameters/configuration.
+     * This includes the bag caches and filtered types.
+     */
+    void clearBagCache() {
+//      for (int i = 0; i < typeCacheEntries.size(); i++) {
+//        if (kDebugTableNoisy) {
+//          printf("type=0x%x\n", i);
+//        }
+//        final List<DataType> typeList = types.get(i);
+//        if (!typeList.isEmpty()) {
+//          TypeCacheEntry cacheEntry = typeCacheEntries.editItemAt(i);
+//
+//          // Reset the filtered configurations.
+//          cacheEntry.filteredConfigs.clear();
+//
+//          bag_set[][] typeBags = cacheEntry.cachedBags;
+//          if (kDebugTableNoisy) {
+//            printf("typeBags=%s\n", typeBags);
+//          }
+//
+//          if (isTruthy(typeBags)) {
+//            final int N = typeList.get(0).entryCount;
+//            if (kDebugTableNoisy) {
+//              printf("type.entryCount=0x%x\n", N);
+//            }
+//            for (int j = 0; j < N; j++) {
+//              if (typeBags[j] && typeBags[j] != (bag_set *) 0xFFFFFFFF){
+//                free(typeBags[j]);
+//              }
+//            }
+//            free(typeBags);
+//            cacheEntry.cachedBags = NULL;
+//          }
+//        }
+//      }
+    }
+
+    //    long findType16(final String type, int len) {
+    //      final int N = packages.size();
+    //      for (int i = 0; i < N; i++) {
+    //        sint index = packages[i].typeStrings.indexOfString(type, len);
+    //        if (index >= 0) {
+    //          return index + packages[i].typeIdOffset;
+    //        }
+    //      }
+    //      return -1;
+    //    }
+
+    final ResTable owner;
+    final String name;
+    final int id;
+
+    // This is mainly used to keep track of the loaded packages
+    // and to clean them up properly. Accessing resources happens from
+    // the 'types' array.
+    List<ResTablePackage> packages = new ArrayList<>();
+
+    public final Map<Integer, List<Type>> types = new HashMap<>();
+
+    byte largestTypeId;
+
+    // Cached objects dependent on the parameters/configuration of this ResTable.
+    // Gets cleared whenever the parameters/configuration changes.
+    // These are stored here in a parallel structure because the data in `types` may
+    // be shared by other ResTable's (framework resources are shared this way).
+    ByteBucketArray<TypeCacheEntry> typeCacheEntries =
+        new ByteBucketArray<TypeCacheEntry>(new TypeCacheEntry()) {
+          @Override
+          TypeCacheEntry newInstance() {
+            return new TypeCacheEntry();
+          }
+        };
+
+    // The table mapping dynamic references to resolved references for
+    // this package group.
+    // TODO: We may be able to support dynamic references in overlays
+    // by having these tables in a per-package scope rather than
+    // per-package-group.
+    DynamicRefTable dynamicRefTable;
+
+    // If the package group comes from a system asset. Used in
+    // determining non-system locales.
+    final boolean isSystemAsset;
+    final boolean isDynamic;
+  }
+
+  // --------------------------------------------------------------------
+// --------------------------------------------------------------------
+// --------------------------------------------------------------------
+
+//  struct ResTable::Header
+  public static class Header
+  {
+//    Header(ResTable* _owner) : owner(_owner), ownedData(NULL), header(NULL),
+//      resourceIDMap(NULL), resourceIDMapSize(0) { }
+
+    public Header(ResTable owner) {
+      this.owner = owner;
+    }
+
+//    ~Header()
+//    {
+//      free(resourceIDMap);
+//    }
+
+    ResTable            owner;
+    byte[]                           ownedData;
+    ResTable_header header;
+    int                          size;
+    int                  dataEnd;
+    int                          index;
+    int                         cookie;
+
+    ResStringPool                   values = new ResStringPool();
+    int[]                       resourceIDMap;
+    int                          resourceIDMapSize;
+  };
+
+  public static class Entry {
+    ResTable_config config;
+    ResTable_entry entry;
+    ResTable_type type;
+    int specFlags;
+    ResTablePackage _package_;
+
+    StringPoolRef typeStr;
+    StringPoolRef keyStr;
+  }
+
+  // struct ResTable::DataType
+  public static class Type {
+
+    final Header header;
+    final ResTablePackage _package_;
+    public final int entryCount;
+    public ResTable_typeSpec typeSpec;
+    public int[] typeSpecFlags;
+    public IdmapEntries idmapEntries = new IdmapEntries();
+    public List<ResTable_type> configs;
+
+    public Type(final Header _header, final ResTablePackage _package, int count)
+          //        : header(_header), package(_package), entryCount(count),
+          //  typeSpec(NULL), typeSpecFlags(NULL) { }
+        {
+      this.header = _header;
+      _package_ = _package;
+      this.entryCount = count;
+      this.typeSpec = null;
+      this.typeSpecFlags = null;
+      this.configs = new ArrayList<>();
+    }
+  }
+
+  //  struct ResTable::Package
+  public static class ResTablePackage {
+    //    Package(ResTable* _owner, final Header* _header, final ResTable_package* _package)
+    //        : owner(_owner), header(_header), package(_package), typeIdOffset(0) {
+    //    if (dtohs(package.header.headerSize) == sizeof(package)) {
+    //      // The package structure is the same size as the definition.
+    //      // This means it contains the typeIdOffset field.
+    //      typeIdOffset = package.typeIdOffset;
+    //    }
+
+    public ResTablePackage(ResTable owner, Header header, ResTable_package _package) {
+      this.owner = owner;
+      this.header = header;
+      this._package_ = _package;
+    }
+
+    final ResTable owner;
+    final Header header;
+    final ResTable_package _package_;
+
+    ResStringPool typeStrings = new ResStringPool();
+    ResStringPool keyStrings = new ResStringPool();
+
+    int typeIdOffset;
+  };
+
+  public static class bag_entry {
+    public int stringBlock;
+    public ResTable_map map = new ResTable_map();
+  }
+
+  public void lock() {
+    try {
+      mLock.acquire();
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public void unlock() {
+    mLock.release();
+  }
+
+  public int lockBag(int resID, Ref<bag_entry[]> outBag) {
+    lock();
+
+    int err = getBagLocked(resID, outBag, null);
+    if (err < NO_ERROR) {
+      //printf("*** get failed!  unlocking\n");
+      mLock.release();
+    }
+    return err;
+  }
+
+  public int getBagLocked(int resID, Ref<bag_entry[]> outBag, Ref<Integer> outTypeSpecFlags) {
+    if (mError != NO_ERROR) {
+      return mError;
+    }
+
+    final int p = getResourcePackageIndex(resID);
+    final int t = Res_GETTYPE(resID);
+    final int e = Res_GETENTRY(resID);
+
+    if (p < 0) {
+      ALOGW("Invalid package identifier when getting bag for resource number 0x%08x", resID);
+      return BAD_INDEX;
+    }
+    if (t < 0) {
+      ALOGW("No type identifier when getting bag for resource number 0x%08x", resID);
+      return BAD_INDEX;
+    }
+
+    //printf("Get bag: id=0x%08x, p=%d, t=%d\n", resID, p, t);
+    PackageGroup grp = mPackageGroups.get(p);
+    if (grp == NULL) {
+      ALOGW("Bad identifier when getting bag for resource number 0x%08x", resID);
+      return BAD_INDEX;
+    }
+
+    final List<Type> typeConfigs = getOrDefault(grp.types, t, Collections.emptyList());
+    if (typeConfigs.isEmpty()) {
+      ALOGW("Type identifier 0x%x does not exist.", t+1);
+      return BAD_INDEX;
+    }
+
+    final int NENTRY = typeConfigs.get(0).entryCount;
+    if (e >= (int)NENTRY) {
+      ALOGW("Entry identifier 0x%x is larger than entry count 0x%x",
+          e, (int)typeConfigs.get(0).entryCount);
+      return BAD_INDEX;
+    }
+
+    // First see if we've already computed this bag...
+    TypeCacheEntry cacheEntry = grp.typeCacheEntries.editItemAt(t);
+    bag_set[] typeSet = cacheEntry.cachedBags;
+    // todo cache
+//    if (isTruthy(typeSet)) {
+//      bag_set set = typeSet[e];
+//      if (isTruthy(set)) {
+//        if (set != (bag_set) 0xFFFFFFFF){
+//        if (set != SENTINEL_BAG_SET){
+//          if (outTypeSpecFlags != NULL) {
+//                    outTypeSpecFlags.set(set.typeSpecFlags);
+//          }
+//          outBag.set((bag_entry *) (set + 1);
+//          if (kDebugTableSuperNoisy) {
+//            ALOGI("Found existing bag for: 0x%x\n", resID);
+//          }
+//          return set.numAttrs;
+//        }
+//        ALOGW("Attempt to retrieve bag 0x%08x which is invalid or in a cycle.",
+//            resID);
+//        return BAD_INDEX;
+//      }
+//    }
+//
+    // Bag not found, we need to compute it!
+    if (!isTruthy(typeSet)) {
+      typeSet = new bag_set[NENTRY]; // (bag_set**)calloc(NENTRY, sizeof(bag_set*));
+      //cacheEntry.cachedBags = typeSet;
+    }
+//
+//    // Mark that we are currently working on this one.
+//    typeSet[e] = (bag_set*)0xFFFFFFFF;
+//    typeSet[e] = SENTINEL_BAG_SET;
+
+    if (kDebugTableNoisy) {
+      ALOGI("Building bag: %x\n", resID);
+    }
+
+    // Now collect all bag attributes
+    Entry entry = new Entry();
+    int err = getEntry(grp, t, e, mParams, entry);
+    if (err != NO_ERROR) {
+      return err;
+    }
+    final short entrySize = dtohs(entry.entry.size);
+//    const uint32_t parent = entrySize >= sizeof(ResTable_map_entry)
+//        ? dtohl(((const ResTable_map_entry*)entry.entry)->parent.ident) : 0;
+//    const uint32_t count = entrySize >= sizeof(ResTable_map_entry)
+//        ? dtohl(((const ResTable_map_entry*)entry.entry)->count) : 0;
+    ResTable_map_entry mapEntry = entrySize >= ResTable_map_entry.BASE_SIZEOF ?
+        new ResTable_map_entry(entry.entry.myBuf(), entry.entry.myOffset()) : null;
+    final int parent = mapEntry != null ? dtohl(mapEntry.parent.ident) : 0;
+    final int count = mapEntry != null ? dtohl(mapEntry.count) : 0;
+
+    int N = count;
+
+    if (kDebugTableNoisy) {
+      ALOGI("Found map: size=%x parent=%x count=%d\n", entrySize, parent, count);
+
+      // If this map inherits from another, we need to start
+      // with its parent's values.  Otherwise start out empty.
+      ALOGI("Creating new bag, entrySize=0x%08x, parent=0x%08x\n", entrySize, parent);
+    }
+
+    // This is what we are building.
+    bag_set set;
+
+    if (isTruthy(parent)) {
+      final Ref<Integer> resolvedParent = new Ref<>(parent);
+
+      // Bags encode a parent reference without using the standard
+      // Res_value structure. That means we must always try to
+      // resolve a parent reference in case it is actually a
+      // TYPE_DYNAMIC_REFERENCE.
+      err = grp.dynamicRefTable.lookupResourceId(resolvedParent);
+      if (err != NO_ERROR) {
+        ALOGE("Failed resolving bag parent id 0x%08x", parent);
+        return UNKNOWN_ERROR;
+      }
+
+      final Ref<bag_entry[]> parentBag = new Ref<>(null);
+      final Ref<Integer> parentTypeSpecFlags = new Ref<>(0);
+      final int NP = getBagLocked(resolvedParent.get(), parentBag, parentTypeSpecFlags);
+      final int NT = ((NP >= 0) ? NP : 0) + N;
+      set = new bag_set(NT);
+      if (NP > 0) {
+        set.copyFrom(parentBag.get(), NP);
+        set.numAttrs = NP;
+        if (kDebugTableNoisy) {
+          ALOGI("Initialized new bag with %d inherited attributes.\n", NP);
+        }
+      } else {
+        if (kDebugTableNoisy) {
+          ALOGI("Initialized new bag with no inherited attributes.\n");
+        }
+        set.numAttrs = 0;
+      }
+      set.availAttrs = NT;
+      set.typeSpecFlags = parentTypeSpecFlags.get();
+    } else {
+      set = new bag_set(N);
+      set.numAttrs = 0;
+      set.availAttrs = N;
+      set.typeSpecFlags = 0;
+    }
+
+    set.typeSpecFlags |= entry.specFlags;
+
+    // Now merge in the new attributes...
+//    int curOff = (reinterpret_cast<uintptr_t>(entry.entry) - reinterpret_cast<uintptr_t>(entry.type))
+//        + dtohs(entry.entry.size);
+    int curOff = entry.entry.myOffset() - entry.type.myOffset() + entry.entry.size;
+    ResTable_map map;
+//    bag_entry* entries = (bag_entry*)(set+1);
+    bag_entry[] entries = set.bag_entries;
+    int curEntry = 0;
+    int pos = 0;
+    if (kDebugTableNoisy) {
+      ALOGI("Starting with set %s, entries=%s, avail=0x%x\n", set, entries, set.availAttrs);
+    }
+    while (pos < count) {
+      if (kDebugTableNoisy) {
+//        ALOGI("Now at %s\n", curOff);
+        ALOGI("Now at %s\n", curEntry);
+      }
+
+      if (curOff > (dtohl(entry.type.header.size)- ResTable_map.SIZEOF)) {
+        ALOGW("ResTable_map at %d is beyond type chunk data %d",
+            (int)curOff, dtohl(entry.type.header.size));
+        return BAD_TYPE;
+      }
+//      map = (const ResTable_map*)(((const uint8_t*)entry.type) + curOff);
+      map = new ResTable_map(entry.type.myBuf(), entry.type.myOffset() + curOff);
+      N++;
+
+      final Ref<Integer> newName = new Ref<>(htodl(map.name.ident));
+      if (!Res_INTERNALID(newName.get())) {
+        // Attributes don't have a resource id as the name. They specify
+        // other data, which would be wrong to change via a lookup.
+        if (grp.dynamicRefTable.lookupResourceId(newName) != NO_ERROR) {
+          ALOGE("Failed resolving ResTable_map name at %d with ident 0x%08x",
+              (int) curEntry, (int) newName.get());
+          return UNKNOWN_ERROR;
+        }
+      }
+
+      boolean isInside;
+      int oldName = 0;
+      while ((isInside=(curEntry < set.numAttrs))
+          && (oldName=entries[curEntry].map.name.ident) < newName.get()) {
+        if (kDebugTableNoisy) {
+          ALOGI("#0x%x: Keeping existing attribute: 0x%08x\n",
+              curEntry, entries[curEntry].map.name.ident);
+        }
+        curEntry++;
+      }
+
+      if (!isInside || oldName != newName.get()) {
+        // This is a new attribute...  figure out what to do with it.
+        if (set.numAttrs >= set.availAttrs) {
+          // Need to alloc more memory...
+                final int newAvail = set.availAttrs+N;
+//          set = (bag_set[])realloc(set,
+//              sizeof(bag_set)
+//                  + sizeof(bag_entry)*newAvail);
+          set.resizeBagEntries(newAvail);
+          set.availAttrs = newAvail;
+//          entries = (bag_entry*)(set+1);
+          entries = set.bag_entries;
+          if (kDebugTableNoisy) {
+            ALOGI("Reallocated set %s, entries=%s, avail=0x%x\n",
+                set, entries, set.availAttrs);
+          }
+        }
+        if (isInside) {
+          // Going in the middle, need to make space.
+//          memmove(entries+curEntry+1, entries+curEntry,
+//              sizeof(bag_entry)*(set.numAttrs-curEntry));
+          System.arraycopy(entries, curEntry, entries, curEntry + 1, set.numAttrs - curEntry);
+          entries[curEntry] = null;
+          set.numAttrs++;
+        }
+        if (kDebugTableNoisy) {
+          ALOGI("#0x%x: Inserting new attribute: 0x%08x\n", curEntry, newName.get());
+        }
+      } else {
+        if (kDebugTableNoisy) {
+          ALOGI("#0x%x: Replacing existing attribute: 0x%08x\n", curEntry, oldName);
+        }
+      }
+
+      bag_entry cur = entries[curEntry];
+      if (cur == null) {
+        cur = entries[curEntry] = new bag_entry();
+      }
+
+      cur.stringBlock = entry._package_.header.index;
+      cur.map.name.ident = newName.get();
+//      cur->map.value.copyFrom_dtoh(map->value);
+      cur.map.value = map.value;
+      final Ref<Res_value> valueRef = new Ref<>(cur.map.value);
+      err = grp.dynamicRefTable.lookupResourceValue(valueRef);
+      cur.map.value = map.value = valueRef.get();
+      if (err != NO_ERROR) {
+        ALOGE("Reference item(0x%08x) in bag could not be resolved.", cur.map.value.data);
+        return UNKNOWN_ERROR;
+      }
+
+      if (kDebugTableNoisy) {
+        ALOGI("Setting entry #0x%x %s: block=%d, name=0x%08d, type=%d, data=0x%08x\n",
+            curEntry, cur, cur.stringBlock, cur.map.name.ident,
+            cur.map.value.dataType, cur.map.value.data);
+      }
+
+      // On to the next!
+      curEntry++;
+      pos++;
+      final int size = dtohs(map.value.size);
+//      curOff += size + sizeof(*map)-sizeof(map->value);
+      curOff += size + ResTable_map.SIZEOF-Res_value.SIZEOF;
+    };
+
+    if (curEntry > set.numAttrs) {
+      set.numAttrs = curEntry;
+    }
+
+    // And this is it...
+    typeSet[e] = set;
+    if (isTruthy(set)) {
+      if (outTypeSpecFlags != NULL) {
+        outTypeSpecFlags.set(set.typeSpecFlags);
+      }
+      outBag.set(set.bag_entries);
+      if (kDebugTableNoisy) {
+        ALOGI("Returning 0x%x attrs\n", set.numAttrs);
+      }
+      return set.numAttrs;
+    }
+    return BAD_INDEX;
+  }
+
+  public void unlockBag(Ref<bag_entry[]> bag) {
+    unlock();
+  }
+
+  static class bag_set {
+    int numAttrs;    // number in array
+    int availAttrs;  // total space in array
+    int typeSpecFlags;
+    // Followed by 'numAttr' bag_entry structures.
+
+    bag_entry[] bag_entries;
+
+    public bag_set(int entryCount) {
+      bag_entries = new bag_entry[entryCount];
+    }
+
+    public void copyFrom(bag_entry[] parentBag, int count) {
+      for (int i = 0; i < count; i++) {
+        bag_entries[i] = parentBag[i];
+      }
+    }
+
+    public void resizeBagEntries(int newEntryCount) {
+      bag_entry[] newEntries = new bag_entry[newEntryCount];
+      System.arraycopy(bag_entries, 0, newEntries, 0, Math.min(bag_entries.length, newEntryCount));
+      bag_entries = newEntries;
+    }
+  };
+
+  /**
+   * Configuration dependent cached data. This must be cleared when the configuration is
+   * changed (setParameters).
+   */
+  static class TypeCacheEntry {
+//    TypeCacheEntry() : cachedBags(NULL) {}
+
+    // Computed attribute bags for this type.
+//    bag_set** cachedBags;
+    bag_set[] cachedBags;
+
+    // Pre-filtered list of configurations (per asset path) that match the parameters set on this
+    // ResTable.
+    List<List<ResTable_type>> filteredConfigs;
+  };
+
+
+  private int Res_MAKEID(int packageId, int typeId, int entryId) {
+    return (((packageId+1)<<24) | (((typeId+1)&0xFF)<<16) | (entryId&0xFFFF));
+  }
+
+  // struct resource_name
+  public static class ResourceName {
+    public String packageName;
+    public String type;
+    public String name;
+
+    @Override
+    public String toString() {
+      return packageName.trim() + '@' + type + ':' + name;
+    }
+  }
+
+  private interface Function<K, V> {
+    V apply(K key);
+  }
+
+  static <K, V> V computeIfAbsent(Map<K, V> map, K key, Function<K, V> vFunction) {
+    V v = map.get(key);
+    if (v == null) {
+      v = vFunction.apply(key);
+      map.put(key, v);
+    }
+    return v;
+  }
+
+  static <K, V> V getOrDefault(Map<K, V> map, K key, V defaultValue) {
+    V v;
+    return (((v = map.get(key)) != null) || map.containsKey(key)) ? v : defaultValue;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResTableTheme.java b/resources/src/main/java/org/robolectric/res/android/ResTableTheme.java
new file mode 100644
index 0000000..af47122
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResTableTheme.java
@@ -0,0 +1,417 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Errors.BAD_INDEX;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.ResTable.Res_GETENTRY;
+import static org.robolectric.res.android.ResTable.Res_GETPACKAGE;
+import static org.robolectric.res.android.ResTable.Res_GETTYPE;
+import static org.robolectric.res.android.ResTable.getOrDefault;
+import static org.robolectric.res.android.ResourceTypes.Res_value.TYPE_ATTRIBUTE;
+import static org.robolectric.res.android.ResourceTypes.Res_value.TYPE_NULL;
+import static org.robolectric.res.android.Util.ALOGE;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.ALOGV;
+import static org.robolectric.res.android.Util.ALOGW;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.robolectric.res.android.ResTable.PackageGroup;
+import org.robolectric.res.android.ResTable.ResourceName;
+import org.robolectric.res.android.ResTable.bag_entry;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceTypes.cpp and
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/ResourceTypes.h
+
+public class ResTableTheme {
+
+  private final List<AppliedStyle> styles = new ArrayList<>();
+  private static boolean styleDebug = false;
+  private static final type_info EMPTY_TYPE_INFO = new type_info();
+  private static final theme_entry EMPTY_THEME_ENTRY = new theme_entry();
+
+  private class AppliedStyle {
+    private final int styleResId;
+    private final boolean forced;
+
+    public AppliedStyle(int styleResId, boolean forced) {
+      this.styleResId = styleResId;
+      this.forced = forced;
+    }
+
+    @Override
+    public String toString() {
+      ResourceName resourceName = new ResourceName();
+      boolean found = mTable.getResourceName(styleResId, true, resourceName);
+      return (found ? resourceName : "unknown") + (forced ? " (forced)" : "");
+    }
+  }
+
+  @Override
+  public String toString() {
+    if (styles.isEmpty()) {
+      return "theme with no applied styles";
+    } else {
+      return "theme with applied styles: " + styles + "";
+    }
+  }
+
+  private ResTable mTable;
+  private boolean kDebugTableTheme = false;
+  private boolean kDebugTableNoisy = false;
+  private package_info[] mPackages = new package_info[Res_MAXPACKAGE];
+  private Ref<Integer> mTypeSpecFlags = new Ref<>(0);
+
+  public ResTableTheme(ResTable resources) {
+    this.mTable = resources;
+  }
+
+  public ResTable getResTable() {
+    return this.mTable;
+  }
+
+  public int GetAttribute(int resID, Ref<Res_value> valueRef,
+      final Ref<Integer> outTypeSpecFlags) {
+    int cnt = 20;
+
+    if (outTypeSpecFlags != null) outTypeSpecFlags.set(0);
+
+    do {
+      final int p = mTable.getResourcePackageIndex(resID);
+      final int t = Res_GETTYPE(resID);
+      final int e = Res_GETENTRY(resID);
+
+      if (kDebugTableTheme) {
+        ALOGI("Looking up attr 0x%08x in theme %s", resID, this);
+      }
+
+      if (p >= 0) {
+        final package_info pi = mPackages[p];
+        if (kDebugTableTheme) {
+          ALOGI("Found package: %s", pi);
+        }
+        if (pi != null) {
+          if (kDebugTableTheme) {
+            ALOGI("Desired type index is %d in avail %d", t, Res_MAXTYPE + 1);
+          }
+          if (t <= Res_MAXTYPE) {
+            type_info ti = pi.types[t];
+            if (ti == null) {
+              ti = EMPTY_TYPE_INFO;
+            }
+            if (kDebugTableTheme) {
+              ALOGI("Desired entry index is %d in avail %d", e, ti.numEntries);
+            }
+            if (e < ti.numEntries) {
+              theme_entry te = ti.entries[e];
+              if (te == null) {
+                te = EMPTY_THEME_ENTRY;
+              }
+              if (outTypeSpecFlags != null) {
+                outTypeSpecFlags.set(outTypeSpecFlags.get() | te.typeSpecFlags);
+              }
+              if (kDebugTableTheme) {
+                ALOGI("Theme value: type=0x%x, data=0x%08x",
+                    te.value.dataType, te.value.data);
+              }
+              final int type = te.value.dataType;
+              if (type == TYPE_ATTRIBUTE) {
+                if (cnt > 0) {
+                  cnt--;
+                  resID = te.value.data;
+                  continue;
+                }
+                ALOGW("Too many attribute references, stopped at: 0x%08x\n", resID);
+                return BAD_INDEX;
+              } else if (type != TYPE_NULL
+                  || te.value.data == Res_value.DATA_NULL_EMPTY) {
+                valueRef.set(te.value);
+                return te.stringBlock;
+              }
+              return BAD_INDEX;
+            }
+          }
+        }
+      }
+      break;
+
+    } while (true);
+
+    return BAD_INDEX;
+
+  }
+
+  public int resolveAttributeReference(Ref<Res_value> inOutValue,
+      int blockIndex, Ref<Integer> outLastRef,
+      final Ref<Integer> inoutTypeSpecFlags, Ref<ResTable_config> inoutConfig) {
+    //printf("Resolving type=0x%x\n", inOutValue->dataType);
+    if (inOutValue.get().dataType == TYPE_ATTRIBUTE) {
+      final Ref<Integer> newTypeSpecFlags = new Ref<>(0);
+      blockIndex = GetAttribute(inOutValue.get().data, inOutValue, newTypeSpecFlags);
+      if (kDebugTableTheme) {
+        ALOGI("Resolving attr reference: blockIndex=%d, type=0x%x, data=0x%x\n",
+            (int)blockIndex, (int)inOutValue.get().dataType, inOutValue.get().data);
+      }
+      if (inoutTypeSpecFlags != null) inoutTypeSpecFlags.set(inoutTypeSpecFlags.get() | newTypeSpecFlags.get());
+      //printf("Retrieved attribute new type=0x%x\n", inOutValue->dataType);
+      if (blockIndex < 0) {
+        return blockIndex;
+      }
+    }
+    return mTable.resolveReference(inOutValue, blockIndex, outLastRef,
+        inoutTypeSpecFlags, inoutConfig);
+  }
+
+  public int applyStyle(int resID, boolean force) {
+    AppliedStyle newAppliedStyle = new AppliedStyle(resID, force);
+    if (styleDebug) {
+      System.out.println("Apply " + newAppliedStyle + " to " + this);
+    }
+    styles.add(newAppliedStyle);
+
+    final Ref<bag_entry[]> bag = new Ref<>(null);
+    final Ref<Integer> bagTypeSpecFlags = new Ref<>(0);
+    mTable.lock();
+    final int N = mTable.getBagLocked(resID, bag, bagTypeSpecFlags);
+    if (kDebugTableNoisy) {
+      ALOGV("Applying style 0x%08x to theme %s, count=%d", resID, this, N);
+    }
+    if (N < 0) {
+      mTable.unlock();
+      return N;
+    }
+
+    mTypeSpecFlags.set(mTypeSpecFlags.get() | bagTypeSpecFlags.get());
+
+    int curPackage = 0xffffffff;
+    int curPackageIndex = 0;
+    package_info curPI = null;
+    int curType = 0xffffffff;
+    int numEntries = 0;
+    theme_entry[] curEntries = null;
+
+    final int end = N;
+    int bagIndex = 0;
+    while (bagIndex < end) {
+      bag_entry bagEntry = bag.get()[bagIndex];
+      final int attrRes = bagEntry.map.name.ident;
+      final int p = Res_GETPACKAGE(attrRes);
+      final int t = Res_GETTYPE(attrRes);
+      final int e = Res_GETENTRY(attrRes);
+
+      if (curPackage != p) {
+        final int pidx = mTable.getResourcePackageIndex(attrRes);
+        if (pidx < 0) {
+          ALOGE("Style contains key with bad package: 0x%08x\n", attrRes);
+          bagIndex++;
+          continue;
+        }
+        curPackage = p;
+        curPackageIndex = pidx;
+        curPI = mPackages[pidx];
+        if (curPI == null) {
+          curPI = new package_info();
+          mPackages[pidx] = curPI;
+        }
+        curType = 0xffffffff;
+      }
+      if (curType != t) {
+        if (t > Res_MAXTYPE) {
+          ALOGE("Style contains key with bad type: 0x%08x\n", attrRes);
+          bagIndex++;
+          continue;
+        }
+        curType = t;
+        curEntries = curPI.types[t] != null ? curPI.types[t].entries: null;
+        if (curEntries == null) {
+          final PackageGroup grp = mTable.mPackageGroups.get(curPackageIndex);
+          final List<ResTable.Type> typeList = getOrDefault(grp.types, t, Collections.emptyList());
+          int cnt = typeList.isEmpty() ? 0 : typeList.get(0).entryCount;
+          curEntries = new theme_entry[cnt];
+          // memset(curEntries, Res_value::TYPE_NULL, buff_size);
+          curPI.types[t] = new type_info();
+          curPI.types[t].numEntries = cnt;
+          curPI.types[t].entries = curEntries;
+        }
+        numEntries = curPI.types[t].numEntries;
+      }
+      if (e >= numEntries) {
+        ALOGE("Style contains key with bad entry: 0x%08x\n", attrRes);
+        bagIndex++;
+        continue;
+      }
+
+      if (curEntries[e] == null) {
+        curEntries[e] = new theme_entry();
+      }
+      theme_entry curEntry = curEntries[e];
+
+      if (styleDebug) {
+        ResourceName outName = new ResourceName();
+        mTable.getResourceName(attrRes, true, outName);
+        System.out.println("  " + outName + "(" + attrRes + ")" + " := " + bagEntry.map.value);
+      }
+
+      if (kDebugTableNoisy) {
+        ALOGV("Attr 0x%08x: type=0x%x, data=0x%08x; curType=0x%x",
+            attrRes, bag.get()[bagIndex].map.value.dataType, bag.get()[bagIndex].map.value.data,
+            curEntry.value.dataType);
+      }
+      if (force || (curEntry.value.dataType == TYPE_NULL
+          && curEntry.value.data != Res_value.DATA_NULL_EMPTY)) {
+        curEntry.stringBlock = bagEntry.stringBlock;
+        curEntry.typeSpecFlags |= bagTypeSpecFlags.get();
+        curEntry.value = new Res_value(bagEntry.map.value);
+      }
+
+      bagIndex++;
+    }
+
+    mTable.unlock();
+
+    if (kDebugTableTheme) {
+      ALOGI("Applying style 0x%08x (force=%s)  theme %s...\n", resID, force, this);
+      dumpToLog();
+    }
+
+    return NO_ERROR;
+
+  }
+
+  private void dumpToLog() {
+
+  }
+
+  public int setTo(ResTableTheme other) {
+    styles.clear();
+    styles.addAll(other.styles);
+
+    if (kDebugTableTheme) {
+      ALOGI("Setting theme %s from theme %s...\n", this, other);
+      dumpToLog();
+      other.dumpToLog();
+    }
+
+    if (mTable == other.mTable) {
+      for (int i=0; i<Res_MAXPACKAGE; i++) {
+        if (mPackages[i] != null) {
+          mPackages[i] = null;
+        }
+        if (other.mPackages[i] != null) {
+          mPackages[i] = copy_package(other.mPackages[i]);
+        } else {
+          mPackages[i] = null;
+        }
+      }
+    } else {
+      // @todo: need to really implement this, not just copy
+      // the system package (which is still wrong because it isn't
+      // fixing up resource references).
+      for (int i=0; i<Res_MAXPACKAGE; i++) {
+        if (mPackages[i] != null) {
+          mPackages[i] = null;
+        }
+        // todo: C++ code presumably assumes index 0 is system, and only system
+        //if (i == 0 && other.mPackages[i] != null) {
+        if (other.mPackages[i] != null) {
+          mPackages[i] = copy_package(other.mPackages[i]);
+        } else {
+          mPackages[i] = null;
+        }
+      }
+    }
+
+    mTypeSpecFlags = other.mTypeSpecFlags;
+
+    if (kDebugTableTheme) {
+      ALOGI("Final theme:");
+      dumpToLog();
+    }
+
+    return NO_ERROR;
+  }
+
+  private static package_info copy_package(package_info pi) {
+    package_info newpi = new package_info();
+    for (int j = 0; j <= Res_MAXTYPE; j++) {
+      if (pi.types[j] == null) {
+        newpi.types[j] = null;
+        continue;
+      }
+      int cnt = pi.types[j].numEntries;
+      newpi.types[j] = new type_info();
+      newpi.types[j].numEntries = cnt;
+      theme_entry[] te = pi.types[j].entries;
+      if (te != null) {
+        theme_entry[] newte = new theme_entry[cnt];
+        newpi.types[j].entries = newte;
+//        memcpy(newte, te, cnt*sizeof(theme_entry));
+        for (int i = 0; i < newte.length; i++) {
+          newte[i] = te[i] == null ? null : new theme_entry(te[i]); // deep copy
+        }
+      } else {
+        newpi.types[j].entries = null;
+      }
+    }
+    return newpi;
+  }
+
+  static class theme_entry {
+    int stringBlock;
+    int typeSpecFlags;
+    Res_value value = new Res_value();
+
+    theme_entry() {}
+
+    /** copy constructor. Performs a deep copy */
+    public theme_entry(theme_entry src) {
+      if (src != null) {
+        stringBlock = src.stringBlock;
+        typeSpecFlags = src.typeSpecFlags;
+        value = new Res_value(src.value);
+      }
+    }
+  };
+
+  static class type_info {
+    int numEntries;
+    theme_entry[] entries;
+
+    type_info() {}
+
+    /** copy constructor. Performs a deep copy */
+    type_info(type_info src) {
+      numEntries = src.numEntries;
+      entries = new theme_entry[src.entries.length];
+      for (int i=0; i < src.entries.length; i++) {
+        if (src.entries[i] == null) {
+          entries[i] = null;
+        } else {
+          entries[i] = new theme_entry(src.entries[i]);
+        }
+      }
+    }
+  };
+
+  static class package_info {
+    type_info[] types = new type_info[Res_MAXTYPE + 1];
+
+    package_info() {}
+
+    /** copy constructor. Performs a deep copy */
+    package_info(package_info src) {
+      for (int i=0; i < src.types.length; i++) {
+        if (src.types[i] == null) {
+          types[i] = null;
+        } else {
+          types[i] = new type_info(src.types[i]);
+        }
+      }
+    }
+  };
+
+  static final int Res_MAXPACKAGE = 255;
+  static final int Res_MAXTYPE = 255;
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResTable_config.java b/resources/src/main/java/org/robolectric/res/android/ResTable_config.java
new file mode 100644
index 0000000..ba164bd
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResTable_config.java
@@ -0,0 +1,2388 @@
+package org.robolectric.res.android;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_DEFAULT;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_HIGH;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_LOW;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_MEDIUM;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_NONE;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_TV;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_XHIGH;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_XXHIGH;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_DENSITY_XXXHIGH;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_HDR_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_HDR_NO;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_HDR_YES;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_KEYBOARD_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_KEYSHIDDEN_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_KEYSHIDDEN_NO;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_KEYSHIDDEN_SOFT;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_KEYSHIDDEN_YES;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_LAYOUTDIR_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_LAYOUTDIR_LTR;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_LAYOUTDIR_RTL;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_NAVHIDDEN_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_NAVHIDDEN_NO;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_NAVHIDDEN_YES;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_NAVIGATION_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_ORIENTATION_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_ORIENTATION_LAND;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_ORIENTATION_PORT;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_ORIENTATION_SQUARE;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENLONG_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENLONG_NO;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENLONG_YES;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENROUND_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENROUND_NO;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENROUND_YES;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENSIZE_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENSIZE_LARGE;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENSIZE_NORMAL;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENSIZE_SMALL;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_SCREENSIZE_XLARGE;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_TOUCHSCREEN_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_UI_MODE_NIGHT_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_UI_MODE_TYPE_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_UI_MODE_TYPE_NORMAL;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_WIDE_COLOR_GAMUT_ANY;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_WIDE_COLOR_GAMUT_NO;
+import static org.robolectric.res.android.AConfiguration.ACONFIGURATION_WIDE_COLOR_GAMUT_YES;
+import static org.robolectric.res.android.LocaleData.localeDataCompareRegions;
+import static org.robolectric.res.android.LocaleData.localeDataComputeScript;
+import static org.robolectric.res.android.LocaleData.localeDataIsCloseToUsEnglish;
+import static org.robolectric.res.android.ResTable.kDebugTableSuperNoisy;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import com.google.common.primitives.Bytes;
+import com.google.common.primitives.UnsignedBytes;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.annotation.Nonnull;
+
+/**
+ * Describes a particular resource configuration.
+ *
+ * Transliterated from:
+ * * https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceTypes.cpp
+ * * https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/ResourceTypes.h (struct ResTable_config)
+ *
+ * Changes from 8.0.0_r4 partially applied.
+ */
+@SuppressWarnings("NewApi")
+public class ResTable_config {
+
+  // The most specific locale can consist of:
+  //
+  // - a 3 char language code
+  // - a 3 char region code prefixed by a 'r'
+  // - a 4 char script code prefixed by a 's'
+  // - a 8 char variant code prefixed by a 'v'
+  //
+  // each separated by a single char separator, which sums up to a total of 24
+  // chars, (25 include the string terminator) rounded up to 28 to be 4 byte
+  // aligned.
+  public static final int RESTABLE_MAX_LOCALE_LEN = 28;
+
+  /** The minimum size in bytes that this configuration must be to contain screen config info. */
+  private static final int SCREEN_CONFIG_MIN_SIZE = 32;
+
+  /** The minimum size in bytes that this configuration must be to contain screen dp info. */
+  private static final int SCREEN_DP_MIN_SIZE = 36;
+
+  /** The minimum size in bytes that this configuration must be to contain locale info. */
+  private static final int LOCALE_MIN_SIZE = 48;
+
+  /** The minimum size in bytes that this config must be to contain the screenConfig extension. */
+  private static final int SCREEN_CONFIG_EXTENSION_MIN_SIZE = 52;
+
+  public static final int SIZEOF = SCREEN_CONFIG_EXTENSION_MIN_SIZE;
+
+  // Codes for specially handled languages and regions
+  static final byte[] kEnglish = new byte[] {'e', 'n'};  // packed version of "en"
+  static final byte[] kUnitedStates = new byte[] {'U', 'S'};  // packed version of "US"
+  static final byte[] kFilipino = new byte[] {(byte)0xAD, 0x05};  // packed version of "fil" ported from C {'\xAD', '\x05'}
+  static final byte[] kTagalog = new byte[] {'t', 'l'};  // packed version of "tl"
+
+  static ResTable_config createConfig(ByteBuffer buffer) {
+    int startPosition = buffer.position();  // The starting buffer position to calculate bytes read.
+    int size = buffer.getInt();
+    int mcc = buffer.getShort() & 0xFFFF;
+    int mnc = buffer.getShort() & 0xFFFF;
+    byte[] language = new byte[2];
+    buffer.get(language);
+    byte[] region = new byte[2];
+    buffer.get(region);
+    int orientation = UnsignedBytes.toInt(buffer.get());
+    int touchscreen = UnsignedBytes.toInt(buffer.get());
+    int density = buffer.getShort() & 0xFFFF;
+    int keyboard = UnsignedBytes.toInt(buffer.get());
+    int navigation = UnsignedBytes.toInt(buffer.get());
+    int inputFlags = UnsignedBytes.toInt(buffer.get());
+    buffer.get();  // 1 byte of padding
+    int screenWidth = buffer.getShort() & 0xFFFF;
+    int screenHeight = buffer.getShort() & 0xFFFF;
+    int sdkVersion = buffer.getShort() & 0xFFFF;
+    int minorVersion = buffer.getShort() & 0xFFFF;
+
+    // At this point, the configuration's size needs to be taken into account as not all
+    // configurations have all values.
+    int screenLayout = 0;
+    int uiMode = 0;
+    int smallestScreenWidthDp = 0;
+    int screenWidthDp = 0;
+    int screenHeightDp = 0;
+    byte[] localeScript = new byte[4];
+    byte[] localeVariant = new byte[8];
+    byte screenLayout2 = 0;
+    byte screenConfigPad1 = 0;
+    short screenConfigPad2 = 0;
+
+    if (size >= SCREEN_CONFIG_MIN_SIZE) {
+      screenLayout = UnsignedBytes.toInt(buffer.get());
+      uiMode = UnsignedBytes.toInt(buffer.get());
+      smallestScreenWidthDp = buffer.getShort() & 0xFFFF;
+    }
+
+    if (size >= SCREEN_DP_MIN_SIZE) {
+      screenWidthDp = buffer.getShort() & 0xFFFF;
+      screenHeightDp = buffer.getShort() & 0xFFFF;
+    }
+
+    if (size >= LOCALE_MIN_SIZE) {
+      buffer.get(localeScript);
+      buffer.get(localeVariant);
+    }
+
+    if (size >= SCREEN_CONFIG_EXTENSION_MIN_SIZE) {
+      screenLayout2 = (byte) UnsignedBytes.toInt(buffer.get());
+      screenConfigPad1 = buffer.get();  // Reserved padding
+      screenConfigPad2 = buffer.getShort();  // More reserved padding
+    }
+
+    // After parsing everything that's known, account for anything that's unknown.
+    int bytesRead = buffer.position() - startPosition;
+    byte[] unknown = new byte[size - bytesRead];
+    buffer.get(unknown);
+
+    return new ResTable_config(size, mcc, mnc, language, region, orientation,
+        touchscreen, density, keyboard, navigation, inputFlags, screenWidth, screenHeight,
+        sdkVersion, minorVersion, screenLayout, uiMode, smallestScreenWidthDp, screenWidthDp,
+        screenHeightDp, localeScript, localeVariant, screenLayout2, screenConfigPad1, screenConfigPad2, unknown);
+  }
+
+  /**
+   * The different types of configs that can be present in a {@link ResTable_config}.
+   *
+   * The ordering of these types is roughly the same as {@code #isBetterThan}, but is not
+   * guaranteed to be the same.
+   */
+  public enum Type {
+    MCC,
+    MNC,
+    LANGUAGE_STRING,
+    LOCALE_SCRIPT_STRING,
+    REGION_STRING,
+    LOCALE_VARIANT_STRING,
+    SCREEN_LAYOUT_DIRECTION,
+    SMALLEST_SCREEN_WIDTH_DP,
+    SCREEN_WIDTH_DP,
+    SCREEN_HEIGHT_DP,
+    SCREEN_LAYOUT_SIZE,
+    SCREEN_LAYOUT_LONG,
+    SCREEN_LAYOUT_ROUND,
+    COLOR_MODE_WIDE_COLOR_GAMUT, // NB: COLOR_GAMUT takes priority over HDR in #isBetterThan.
+    COLOR_MODE_HDR,
+    ORIENTATION,
+    UI_MODE_TYPE,
+    UI_MODE_NIGHT,
+    DENSITY_DPI,
+    TOUCHSCREEN,
+    KEYBOARD_HIDDEN,
+    KEYBOARD,
+    NAVIGATION_HIDDEN,
+    NAVIGATION,
+    SCREEN_SIZE,
+    SDK_VERSION
+  }
+
+  // screenLayout bits for layout direction.
+//  public static final int MASK_LAYOUTDIR = 0xC0;
+  public static final int SHIFT_LAYOUTDIR = 6;
+  public static final int LAYOUTDIR_ANY = ACONFIGURATION_LAYOUTDIR_ANY << SHIFT_LAYOUTDIR;
+  public static final int LAYOUTDIR_LTR = ACONFIGURATION_LAYOUTDIR_LTR << SHIFT_LAYOUTDIR;
+  public static final int LAYOUTDIR_RTL = ACONFIGURATION_LAYOUTDIR_RTL << SHIFT_LAYOUTDIR;
+
+  public static final int SCREENWIDTH_ANY = 0;
+//  public static final int MASK_SCREENSIZE = 0x0f;
+  public static final int SCREENSIZE_ANY = ACONFIGURATION_SCREENSIZE_ANY;
+  public static final int SCREENSIZE_SMALL = ACONFIGURATION_SCREENSIZE_SMALL;
+  public static final int SCREENSIZE_NORMAL = ACONFIGURATION_SCREENSIZE_NORMAL;
+  public static final int SCREENSIZE_LARGE = ACONFIGURATION_SCREENSIZE_LARGE;
+  public static final int SCREENSIZE_XLARGE = ACONFIGURATION_SCREENSIZE_XLARGE;
+
+  // uiMode bits for the mode type.
+  public static final int MASK_UI_MODE_TYPE = 0x0f;
+  public static final int UI_MODE_TYPE_ANY = ACONFIGURATION_UI_MODE_TYPE_ANY;
+  public static final int UI_MODE_TYPE_NORMAL = ACONFIGURATION_UI_MODE_TYPE_NORMAL;
+
+  // uiMode bits for the night switch;
+  public static final int MASK_UI_MODE_NIGHT = 0x30;
+  public static final int SHIFT_UI_MODE_NIGHT = 4;
+  public static final int UI_MODE_NIGHT_ANY = ACONFIGURATION_UI_MODE_NIGHT_ANY << SHIFT_UI_MODE_NIGHT;
+
+  public static final int DENSITY_DEFAULT = ACONFIGURATION_DENSITY_DEFAULT;
+  public static final int DENSITY_LOW = ACONFIGURATION_DENSITY_LOW;
+  public static final int DENSITY_MEDIUM = ACONFIGURATION_DENSITY_MEDIUM;
+  public static final int DENSITY_TV = ACONFIGURATION_DENSITY_TV;
+  public static final int DENSITY_HIGH = ACONFIGURATION_DENSITY_HIGH;
+  public static final int DENSITY_XHIGH = ACONFIGURATION_DENSITY_XHIGH;
+  public static final int DENSITY_XXHIGH = ACONFIGURATION_DENSITY_XXHIGH;
+  public static final int DENSITY_XXXHIGH = ACONFIGURATION_DENSITY_XXXHIGH;
+  public static final int DENSITY_ANY = ACONFIGURATION_DENSITY_ANY;
+  public static final int DENSITY_NONE = ACONFIGURATION_DENSITY_NONE;
+
+  public static final int TOUCHSCREEN_ANY  = ACONFIGURATION_TOUCHSCREEN_ANY;
+
+  public static final int MASK_KEYSHIDDEN = 0x0003;
+  public static final byte KEYSHIDDEN_ANY = ACONFIGURATION_KEYSHIDDEN_ANY;
+  public static final byte KEYSHIDDEN_NO = ACONFIGURATION_KEYSHIDDEN_NO;
+  public static final byte KEYSHIDDEN_YES = ACONFIGURATION_KEYSHIDDEN_YES;
+  public static final byte KEYSHIDDEN_SOFT = ACONFIGURATION_KEYSHIDDEN_SOFT;
+
+  public static final int KEYBOARD_ANY  = ACONFIGURATION_KEYBOARD_ANY;
+
+  public static final int MASK_NAVHIDDEN = 0x000c;
+  public static final int SHIFT_NAVHIDDEN = 2;
+  public static final byte NAVHIDDEN_ANY = ACONFIGURATION_NAVHIDDEN_ANY << SHIFT_NAVHIDDEN;
+  public static final byte NAVHIDDEN_NO = ACONFIGURATION_NAVHIDDEN_NO << SHIFT_NAVHIDDEN;
+  public static final byte NAVHIDDEN_YES = ACONFIGURATION_NAVHIDDEN_YES << SHIFT_NAVHIDDEN;
+
+  public static final int NAVIGATION_ANY  = ACONFIGURATION_NAVIGATION_ANY;
+
+  public static final int SCREENHEIGHT_ANY = 0;
+
+  public static final int SDKVERSION_ANY = 0;
+  public static final int MINORVERSION_ANY = 0;
+
+  // from https://github.com/google/android-arscblamer/blob/master/java/com/google/devrel/gmscore/tools/apk/arsc/ResourceConfiguration.java
+  /** The below constants are from android.content.res.Configuration. */
+  static final int COLOR_MODE_WIDE_COLOR_GAMUT_MASK = 0x03;
+
+  public static final int WIDE_COLOR_GAMUT_ANY = ACONFIGURATION_WIDE_COLOR_GAMUT_ANY;
+  public static final int WIDE_COLOR_GAMUT_NO = ACONFIGURATION_WIDE_COLOR_GAMUT_NO;
+  public static final int WIDE_COLOR_GAMUT_YES = ACONFIGURATION_WIDE_COLOR_GAMUT_YES;
+  public static final int MASK_WIDE_COLOR_GAMUT = 0x03;
+  static final int COLOR_MODE_WIDE_COLOR_GAMUT_UNDEFINED = 0;
+  static final int COLOR_MODE_WIDE_COLOR_GAMUT_NO = 0x01;
+  static final int COLOR_MODE_WIDE_COLOR_GAMUT_YES = 0x02;
+
+  private static final Map<Integer, String> COLOR_MODE_WIDE_COLOR_GAMUT_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(COLOR_MODE_WIDE_COLOR_GAMUT_UNDEFINED, "");
+    map.put(COLOR_MODE_WIDE_COLOR_GAMUT_NO, "nowidecg");
+    map.put(COLOR_MODE_WIDE_COLOR_GAMUT_YES, "widecg");
+    COLOR_MODE_WIDE_COLOR_GAMUT_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  public static final int HDR_ANY = ACONFIGURATION_HDR_ANY;
+  public static final int HDR_NO = ACONFIGURATION_HDR_NO << 2;
+  public static final int HDR_YES = ACONFIGURATION_HDR_YES << 2;
+  public static final int MASK_HDR = 0x0c;
+  static final int COLOR_MODE_HDR_MASK = 0x0C;
+  static final int COLOR_MODE_HDR_UNDEFINED = 0;
+  static final int COLOR_MODE_HDR_NO = 0x04;
+  static final int COLOR_MODE_HDR_YES = 0x08;
+
+  private static final Map<Integer, String> COLOR_MODE_HDR_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(COLOR_MODE_HDR_UNDEFINED, "");
+    map.put(COLOR_MODE_HDR_NO, "lowdr");
+    map.put(COLOR_MODE_HDR_YES, "highdr");
+    COLOR_MODE_HDR_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  public static final int DENSITY_DPI_UNDEFINED = 0;
+  static final int DENSITY_DPI_LDPI = 120;
+  public static final int DENSITY_DPI_MDPI = 160;
+  static final int DENSITY_DPI_TVDPI = 213;
+  static final int DENSITY_DPI_HDPI = 240;
+  static final int DENSITY_DPI_XHDPI = 320;
+  static final int DENSITY_DPI_XXHDPI = 480;
+  static final int DENSITY_DPI_XXXHDPI = 640;
+  public static final int DENSITY_DPI_ANY  = 0xFFFE;
+  public static final int DENSITY_DPI_NONE = 0xFFFF;
+
+  private static final Map<Integer, String> DENSITY_DPI_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(DENSITY_DPI_UNDEFINED, "");
+    map.put(DENSITY_DPI_LDPI, "ldpi");
+    map.put(DENSITY_DPI_MDPI, "mdpi");
+    map.put(DENSITY_DPI_TVDPI, "tvdpi");
+    map.put(DENSITY_DPI_HDPI, "hdpi");
+    map.put(DENSITY_DPI_XHDPI, "xhdpi");
+    map.put(DENSITY_DPI_XXHDPI, "xxhdpi");
+    map.put(DENSITY_DPI_XXXHDPI, "xxxhdpi");
+    map.put(DENSITY_DPI_ANY, "anydpi");
+    map.put(DENSITY_DPI_NONE, "nodpi");
+    DENSITY_DPI_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int KEYBOARD_NOKEYS = 1;
+  static final int KEYBOARD_QWERTY = 2;
+  static final int KEYBOARD_12KEY  = 3;
+
+  private static final Map<Integer, String> KEYBOARD_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(KEYBOARD_NOKEYS, "nokeys");
+    map.put(KEYBOARD_QWERTY, "qwerty");
+    map.put(KEYBOARD_12KEY, "12key");
+    KEYBOARD_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int KEYBOARDHIDDEN_MASK = 0x03;
+  static final int KEYBOARDHIDDEN_NO   = 1;
+  static final int KEYBOARDHIDDEN_YES  = 2;
+  static final int KEYBOARDHIDDEN_SOFT = 3;
+
+  private static final Map<Integer, String> KEYBOARDHIDDEN_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(KEYBOARDHIDDEN_NO, "keysexposed");
+    map.put(KEYBOARDHIDDEN_YES, "keyshidden");
+    map.put(KEYBOARDHIDDEN_SOFT, "keyssoft");
+    KEYBOARDHIDDEN_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int NAVIGATION_NONAV     = 1;
+  static final int NAVIGATION_DPAD      = 2;
+  static final int NAVIGATION_TRACKBALL = 3;
+  static final int NAVIGATION_WHEEL     = 4;
+
+  private static final Map<Integer, String> NAVIGATION_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(NAVIGATION_NONAV, "nonav");
+    map.put(NAVIGATION_DPAD, "dpad");
+    map.put(NAVIGATION_TRACKBALL, "trackball");
+    map.put(NAVIGATION_WHEEL, "wheel");
+    NAVIGATION_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int NAVIGATIONHIDDEN_MASK  = 0x0C;
+  static final int NAVIGATIONHIDDEN_NO    = 0x04;
+  static final int NAVIGATIONHIDDEN_YES   = 0x08;
+
+  private static final Map<Integer, String> NAVIGATIONHIDDEN_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(NAVIGATIONHIDDEN_NO, "navexposed");
+    map.put(NAVIGATIONHIDDEN_YES, "navhidden");
+    NAVIGATIONHIDDEN_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  public static final int ORIENTATION_ANY  = ACONFIGURATION_ORIENTATION_ANY;
+  public static final int ORIENTATION_PORT = ACONFIGURATION_ORIENTATION_PORT;
+  public static final int ORIENTATION_LAND = ACONFIGURATION_ORIENTATION_LAND;
+  public static final int ORIENTATION_SQUARE = ACONFIGURATION_ORIENTATION_SQUARE;
+  static final int ORIENTATION_PORTRAIT  = 0x01;
+  static final int ORIENTATION_LANDSCAPE = 0x02;
+
+  private static final Map<Integer, String> ORIENTATION_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(ORIENTATION_PORTRAIT, "port");
+    map.put(ORIENTATION_LANDSCAPE, "land");
+    ORIENTATION_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int SCREENLAYOUT_LAYOUTDIR_MASK = 0xC0;
+  static final int SCREENLAYOUT_LAYOUTDIR_LTR  = 0x40;
+  static final int SCREENLAYOUT_LAYOUTDIR_RTL  = 0x80;
+
+  private static final Map<Integer, String> SCREENLAYOUT_LAYOUTDIR_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(SCREENLAYOUT_LAYOUTDIR_LTR, "ldltr");
+    map.put(SCREENLAYOUT_LAYOUTDIR_RTL, "ldrtl");
+    SCREENLAYOUT_LAYOUTDIR_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  // screenLayout bits for wide/long screen variation.
+  public static final int MASK_SCREENLONG = 0x30;
+  public static final int SHIFT_SCREENLONG = 4;
+  public static final int SCREENLONG_ANY = ACONFIGURATION_SCREENLONG_ANY << SHIFT_SCREENLONG;
+  public static final int SCREENLONG_NO = ACONFIGURATION_SCREENLONG_NO << SHIFT_SCREENLONG;
+  public static final int SCREENLONG_YES = ACONFIGURATION_SCREENLONG_YES << SHIFT_SCREENLONG;
+  static final int SCREENLAYOUT_LONG_MASK = 0x30;
+  static final int SCREENLAYOUT_LONG_NO   = 0x10;
+  static final int SCREENLAYOUT_LONG_YES  = 0x20;
+
+  private static final Map<Integer, String> SCREENLAYOUT_LONG_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(SCREENLAYOUT_LONG_NO, "notlong");
+    map.put(SCREENLAYOUT_LONG_YES, "long");
+    SCREENLAYOUT_LONG_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  // screenLayout2 bits for round/notround.
+  static final int MASK_SCREENROUND = 0x03;
+  public static final int SCREENROUND_ANY = ACONFIGURATION_SCREENROUND_ANY;
+  public static final int SCREENROUND_NO = ACONFIGURATION_SCREENROUND_NO;
+  public static final int SCREENROUND_YES = ACONFIGURATION_SCREENROUND_YES;
+
+  static final int SCREENLAYOUT_ROUND_MASK = 0x03;
+  static final int SCREENLAYOUT_ROUND_NO   = 0x01;
+  static final int SCREENLAYOUT_ROUND_YES  = 0x02;
+
+  private static final Map<Integer, String> SCREENLAYOUT_ROUND_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(SCREENLAYOUT_ROUND_NO, "notround");
+    map.put(SCREENLAYOUT_ROUND_YES, "round");
+    SCREENLAYOUT_ROUND_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int SCREENLAYOUT_SIZE_MASK   = 0x0F;
+  static final int SCREENLAYOUT_SIZE_SMALL  = 0x01;
+  static final int SCREENLAYOUT_SIZE_NORMAL = 0x02;
+  static final int SCREENLAYOUT_SIZE_LARGE  = 0x03;
+  static final int SCREENLAYOUT_SIZE_XLARGE = 0x04;
+
+  private static final Map<Integer, String> SCREENLAYOUT_SIZE_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(SCREENLAYOUT_SIZE_SMALL, "small");
+    map.put(SCREENLAYOUT_SIZE_NORMAL, "normal");
+    map.put(SCREENLAYOUT_SIZE_LARGE, "large");
+    map.put(SCREENLAYOUT_SIZE_XLARGE, "xlarge");
+    SCREENLAYOUT_SIZE_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int TOUCHSCREEN_NOTOUCH = 1;
+  @Deprecated static final int TOUCHSCREEN_STYLUS  = 2;
+  public static final int TOUCHSCREEN_FINGER  = 3;
+
+  private static final Map<Integer, String> TOUCHSCREEN_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(TOUCHSCREEN_NOTOUCH, "notouch");
+    map.put(TOUCHSCREEN_FINGER, "finger");
+    TOUCHSCREEN_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int UI_MODE_NIGHT_MASK = 0x30;
+  public static final int UI_MODE_NIGHT_NO   = 0x10;
+  static final int UI_MODE_NIGHT_YES  = 0x20;
+
+  private static final Map<Integer, String> UI_MODE_NIGHT_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(UI_MODE_NIGHT_NO, "notnight");
+    map.put(UI_MODE_NIGHT_YES, "night");
+    UI_MODE_NIGHT_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  static final int UI_MODE_TYPE_MASK       = 0x0F;
+  static final int UI_MODE_TYPE_DESK       = 0x02;
+  static final int UI_MODE_TYPE_CAR        = 0x03;
+  static final int UI_MODE_TYPE_TELEVISION = 0x04;
+  static final int UI_MODE_TYPE_APPLIANCE  = 0x05;
+  static final int UI_MODE_TYPE_WATCH      = 0x06;
+  static final int UI_MODE_TYPE_VR_HEADSET = 0x07;
+
+  private static final Map<Integer, String> UI_MODE_TYPE_VALUES;
+
+  static {
+    Map<Integer, String> map = new HashMap<>();
+    map.put(UI_MODE_TYPE_DESK, "desk");
+    map.put(UI_MODE_TYPE_CAR, "car");
+    map.put(UI_MODE_TYPE_TELEVISION, "television");
+    map.put(UI_MODE_TYPE_APPLIANCE, "appliance");
+    map.put(UI_MODE_TYPE_WATCH, "watch");
+    map.put(UI_MODE_TYPE_VR_HEADSET, "vrheadset");
+    UI_MODE_TYPE_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  /** The number of bytes that this resource configuration takes up. */
+  int size;
+
+  public int mcc;
+  public int mnc;
+
+  /** Returns a packed 2-byte language code. */
+  @SuppressWarnings("mutable")
+  public final byte[] language;
+
+  /** Returns {@link #language} as an unpacked string representation. */
+  @Nonnull
+  public final String languageString() {
+    return unpackLanguage();
+  }
+
+  /** Returns the {@link #localeScript} as a string. */
+  public final String localeScriptString() {
+    return byteArrayToString(localeScript);
+  }
+
+  /** Returns the {@link #localeVariant} as a string. */
+  public final String localeVariantString() {
+    return byteArrayToString(localeVariant);
+  }
+
+  private String byteArrayToString(byte[] data) {
+    int length = Bytes.indexOf(data, (byte) 0);
+    return new String(data, 0, length >= 0 ? length : data.length, US_ASCII);
+  }
+
+  /** Returns the wide color gamut section of {@link #colorMode}. */
+  public final int colorModeWideColorGamut() {
+    return colorMode & COLOR_MODE_WIDE_COLOR_GAMUT_MASK;
+  }
+
+  /** Returns the HDR section of {@link #colorMode}. */
+  public final int colorModeHdr() {
+    return colorMode & COLOR_MODE_HDR_MASK;
+  }
+
+  /** Returns a packed 2-byte country code. */
+  @SuppressWarnings("mutable")
+  public final byte[] country;
+
+  /** Returns {@link #country} as an unpacked string representation. */
+  @Nonnull
+  public final String regionString() {
+    return unpackRegion();
+  }
+
+  public final String scriptString() {
+    if (localeScript[0] != '\0') {
+      return new String(localeScript, UTF_8);
+    } else {
+      return null;
+    }
+  }
+
+  public int orientation;
+  public int touchscreen;
+  public int density;
+  public int keyboard;
+  public int navigation;
+  public int inputFlags;
+
+  public final int keyboardHidden() {
+    return inputFlags & KEYBOARDHIDDEN_MASK;
+  }
+
+  public final void keyboardHidden(int value) {
+    inputFlags = (inputFlags & ~KEYBOARDHIDDEN_MASK) | value;
+  }
+
+  public final int navigationHidden() {
+    return (inputFlags & NAVIGATIONHIDDEN_MASK) >> 2;
+  }
+
+  public final void navigationHidden(int value) {
+    inputFlags = (inputFlags & ~NAVIGATIONHIDDEN_MASK) | value;
+  }
+
+  public int screenWidth;
+  public int screenHeight;
+  public int sdkVersion;
+
+  /**
+   * Returns a copy of this resource configuration with a different {@link #sdkVersion}, or this
+   * configuration if the {@code sdkVersion} is the same.
+   *
+   * @param sdkVersion The SDK version of the returned configuration.
+   * @return A copy of this configuration with the only difference being #sdkVersion.
+   */
+  public final ResTable_config withSdkVersion(int sdkVersion) {
+    if (sdkVersion == this.sdkVersion) {
+      return this;
+    }
+    return new ResTable_config(size, mcc, mnc, language, country,
+        orientation, touchscreen, density, keyboard, navigation, inputFlags,
+        screenWidth, screenHeight, sdkVersion, minorVersion, screenLayout, uiMode,
+        smallestScreenWidthDp, screenWidthDp, screenHeightDp, localeScript, localeVariant,
+        screenLayout2, colorMode, screenConfigPad2, unknown);
+  }
+
+  public ResTable_config(ResTable_config other) {
+    this.size = other.size;
+    this.mcc = other.mcc;
+    this.mnc = other.mnc;
+    this.language = other.language;
+    this.country = other.country;
+    this.orientation = other.orientation;
+    this.touchscreen = other.touchscreen;
+    this.density = other.density;
+    this.keyboard = other.keyboard;
+    this.navigation = other.navigation;
+    this.inputFlags = other.inputFlags;
+    this.screenWidth = other.screenWidth;
+    this.screenHeight = other.screenHeight;
+    this.sdkVersion = other.sdkVersion;
+    this.minorVersion = other.minorVersion;
+    this.screenLayout = other.screenLayout;
+    this.uiMode = other.uiMode;
+    this.smallestScreenWidthDp = other.smallestScreenWidthDp;
+    this.screenWidthDp = other.screenWidthDp;
+    this.screenHeightDp = other.screenHeightDp;
+    this.localeScript = other.localeScript;
+    this.localeVariant = other.localeVariant;
+    this.screenLayout2 = other.screenLayout2;
+    this.colorMode = other.colorMode;
+    this.screenConfigPad2 = other.screenConfigPad2;
+    this.unknown = other.unknown;
+  }
+
+
+  public ResTable_config(int size, int mcc, int mnc, byte[] language, byte[] country,
+      int orientation, int touchscreen, int density, int keyboard, int navigation, int inputFlags,
+      int screenWidth, int screenHeight, int sdkVersion, int minorVersion, int screenLayout,
+      int uiMode, int smallestScreenWidthDp, int screenWidthDp, int screenHeightDp,
+      byte[] localeScript, byte[] localeVariant, byte screenLayout2, byte colorMode,
+      short screenConfigPad2, byte[] unknown) {
+    this.size = size;
+    this.mcc = mcc;
+    this.mnc = mnc;
+    this.language = language;
+    this.country = country;
+    this.orientation = orientation;
+    this.touchscreen = touchscreen;
+    this.density = density;
+    this.keyboard = keyboard;
+    this.navigation = navigation;
+    this.inputFlags = inputFlags;
+    this.screenWidth = screenWidth;
+    this.screenHeight = screenHeight;
+    this.sdkVersion = sdkVersion;
+    this.minorVersion = minorVersion;
+    this.screenLayout = screenLayout;
+    this.uiMode = uiMode;
+    this.smallestScreenWidthDp = smallestScreenWidthDp;
+    this.screenWidthDp = screenWidthDp;
+    this.screenHeightDp = screenHeightDp;
+    this.localeScript = localeScript;
+    this.localeVariant = localeVariant;
+    this.screenLayout2 = screenLayout2;
+    this.colorMode = colorMode;
+    this.screenConfigPad2 = screenConfigPad2;
+    this.unknown = unknown;
+  }
+
+  public ResTable_config() {
+    this.language = new byte[2];
+    this.country = new byte[2];
+    this.localeScript = new byte[LocaleData.SCRIPT_LENGTH];
+    this.localeVariant = new byte[8];
+  }
+
+  public int minorVersion;
+  public int screenLayout;
+
+  public final int screenLayoutDirection() {
+    return screenLayout & SCREENLAYOUT_LAYOUTDIR_MASK;
+  }
+
+  public final void screenLayoutDirection(int value) {
+    screenLayout = (screenLayout & ~SCREENLAYOUT_LAYOUTDIR_MASK) | value;
+  }
+
+  public final int screenLayoutSize() {
+    return screenLayout & SCREENLAYOUT_SIZE_MASK;
+  }
+
+  public final void screenLayoutSize(int value) {
+    screenLayout = (screenLayout & ~SCREENLAYOUT_SIZE_MASK) | value;
+  }
+
+  public final int screenLayoutLong() {
+    return screenLayout & SCREENLAYOUT_LONG_MASK;
+  }
+
+  public final void screenLayoutLong(int value) {
+    screenLayout = (screenLayout & ~SCREENLAYOUT_LONG_MASK) | value;
+  }
+
+  public final int screenLayoutRound() {
+    return screenLayout2 & SCREENLAYOUT_ROUND_MASK;
+  }
+
+  public final void screenLayoutRound(int value) {
+    screenLayout2 = (byte) ((screenLayout2 & ~SCREENLAYOUT_ROUND_MASK) | value);
+  }
+
+  public int uiMode;
+
+  public final int uiModeType() {
+    return uiMode & UI_MODE_TYPE_MASK;
+  }
+
+  public final void uiModeType(int value) {
+    uiMode = (uiMode & ~UI_MODE_TYPE_MASK) | value;
+  }
+
+  public final int uiModeNight() {
+    return uiMode & UI_MODE_NIGHT_MASK;
+  }
+
+  public final void uiModeNight(int value) {
+    uiMode = (uiMode & ~UI_MODE_NIGHT_MASK) | value;
+  }
+
+  public int smallestScreenWidthDp;
+  public int screenWidthDp;
+  public int screenHeightDp;
+
+  /** The ISO-15924 short name for the script corresponding to this configuration. */
+  @SuppressWarnings("mutable")
+  public final byte[] localeScript;
+
+  /** A single BCP-47 variant subtag. */
+  @SuppressWarnings("mutable")
+  public final byte[] localeVariant;
+
+  /** An extension to {@link #screenLayout}. Contains round/notround qualifier. */
+  public byte screenLayout2;        // Contains round/notround qualifier.
+  public byte colorMode;            // Wide-gamut, HDR, etc.
+  public short screenConfigPad2;    // Reserved padding.
+
+  /** Any remaining bytes in this resource configuration that are unaccounted for. */
+  @SuppressWarnings("mutable")
+  public byte[] unknown;
+
+
+  /**
+   *     // An extension of screenConfig.
+   union {
+   struct {
+   uint8_t screenLayout2;      // Contains round/notround qualifier.
+   uint8_t screenConfigPad1;   // Reserved padding.
+   uint16_t screenConfigPad2;  // Reserved padding.
+   };
+   uint32_t screenConfig2;
+   };
+   */
+  private int screenConfig2() {
+    return ((screenLayout2 & 0xff) << 24) | ((colorMode * 0xff) << 16) | (screenConfigPad2 & 0xffff);
+  }
+
+  // If false and localeScript is set, it means that the script of the locale
+  // was explicitly provided.
+  //
+  // If true, it means that localeScript was automatically computed.
+  // localeScript may still not be set in this case, which means that we
+  // tried but could not compute a script.
+  boolean localeScriptWasComputed;
+
+  // The value of BCP 47 Unicode extension for key 'nu' (numbering system).
+  // Varies in length from 3 to 8 chars. Zero-filled value.
+  byte[] localeNumberingSystem = new byte[8];
+
+// --------------------------------------------------------------------
+// --------------------------------------------------------------------
+// --------------------------------------------------------------------
+
+//  void copyFromDeviceNoSwap(final ResTable_config o) {
+//    final int size = dtohl(o.size);
+//    if (size >= sizeof(ResTable_config)) {
+//        *this = o;
+//    } else {
+//      memcpy(this, &o, size);
+//      memset(((uint8_t*)this)+size, 0, sizeof(ResTable_config)-size);
+//    }
+//  }
+
+  @Nonnull
+  private String unpackLanguageOrRegion(byte[] value, int base) {
+    Preconditions.checkState(value.length == 2, "Language or country value must be 2 bytes.");
+    if (value[0] == 0 && value[1] == 0) {
+      return "";
+    }
+    if (isTruthy(UnsignedBytes.toInt(value[0]) & 0x80)) {
+      byte[] result = new byte[3];
+      result[0] = (byte) (base + (value[1] & 0x1F));
+      result[1] = (byte) (base + ((value[1] & 0xE0) >>> 5) + ((value[0] & 0x03) << 3));
+      result[2] = (byte) (base + ((value[0] & 0x7C) >>> 2));
+      return new String(result, US_ASCII);
+    }
+    return new String(value, US_ASCII);
+  }
+
+  /* static */ void packLanguageOrRegion(final String in, final byte base,
+      final byte[] out) {
+    if (in == null) {
+      out[0] = 0;
+      out[1] = 0;
+    } else if (in.length() < 3 || in.charAt(2) == 0 || in.charAt(2) == '-') {
+      out[0] = (byte) in.charAt(0);
+      out[1] = (byte) in.charAt(1);
+    } else {
+      byte first = (byte) ((in.charAt(0) - base) & 0x007f);
+      byte second = (byte) ((in.charAt(1) - base) & 0x007f);
+      byte third = (byte) ((in.charAt(2) - base) & 0x007f);
+
+      out[0] = (byte) (0x80 | (third << 2) | (second >> 3));
+      out[1] = (byte) ((second << 5) | first);
+    }
+  }
+
+  public void packLanguage(final String language) {
+    packLanguageOrRegion(language, (byte) 'a', this.language);
+  }
+
+  public void packRegion(final String region) {
+    packLanguageOrRegion(region, (byte) '0', this.country);
+  }
+
+  @Nonnull
+  private String unpackLanguage() {
+    return unpackLanguageOrRegion(language, 0x61);
+  }
+
+  private String unpackRegion() {
+    return unpackLanguageOrRegion(country, 0x30);
+  }
+
+//  void copyFromDtoH(final ResTable_config o) {
+//    copyFromDeviceNoSwap(o);
+//    size = sizeof(ResTable_config);
+//    mcc = dtohs(mcc);
+//    mnc = dtohs(mnc);
+//    density = dtohs(density);
+//    screenWidth = dtohs(screenWidth);
+//    screenHeight = dtohs(screenHeight);
+//    sdkVersion = dtohs(sdkVersion);
+//    minorVersion = dtohs(minorVersion);
+//    smallestScreenWidthDp = dtohs(smallestScreenWidthDp);
+//    screenWidthDp = dtohs(screenWidthDp);
+//    screenHeightDp = dtohs(screenHeightDp);
+//  }
+
+//  void ResTable_config::copyFromDtoH(const ResTable_config& o) {
+  static ResTable_config fromDtoH(final ResTable_config o) {
+    return new ResTable_config(
+        0 /*sizeof(ResTable_config)*/,
+        dtohs((short) o.mcc) & 0xFFFF,
+        dtohs((short) o.mnc) & 0xFFFF,
+        o.language,
+        o.country,
+        o.orientation,
+        o.touchscreen,
+        dtohl(o.density),
+        o.keyboard,
+        o.navigation,
+        o.inputFlags,
+        dtohs((short) o.screenWidth) & 0xFFFF,
+        dtohs((short) o.screenHeight) & 0xFFFF,
+        dtohs((short) o.sdkVersion) & 0xFFFF,
+        dtohs((short) o.minorVersion) & 0xFFFF,
+        o.screenLayout,
+        o.uiMode,
+        dtohs((short) o.smallestScreenWidthDp) & 0xFFFF,
+        dtohs((short) o.screenWidthDp) & 0xFFFF,
+        dtohs((short) o.screenHeightDp) & 0xFFFF,
+        o.localeScript,
+        o.localeVariant,
+        o.screenLayout2,
+        o.colorMode,
+        o.screenConfigPad2,
+        o.unknown
+    );
+  }
+
+  void swapHtoD() {
+//    size = htodl(size);
+//    mcc = htods(mcc);
+//    mnc = htods(mnc);
+//    density = htods(density);
+//    screenWidth = htods(screenWidth);
+//    screenHeight = htods(screenHeight);
+//    sdkVersion = htods(sdkVersion);
+//    minorVersion = htods(minorVersion);
+//    smallestScreenWidthDp = htods(smallestScreenWidthDp);
+//    screenWidthDp = htods(screenWidthDp);
+//    screenHeightDp = htods(screenHeightDp);
+  }
+
+  static final int compareLocales(final ResTable_config l, final ResTable_config r) {
+    if (l.locale() != r.locale()) {
+      // NOTE: This is the old behaviour with respect to comparison orders.
+      // The diff value here doesn't make much sense (given our bit packing scheme)
+      // but it's stable, and that's all we need.
+      return (l.locale() > r.locale()) ? 1 : -1;
+    }
+
+    // The language & region are equal, so compare the scripts, variants and
+    // numbering systms in this order. Comparison of variants and numbering
+    // systems should happen very infrequently (if at all.)
+    // The comparison code relies on memcmp low-level optimizations that make it
+    // more efficient than strncmp.
+    final byte emptyScript[] = {'\0', '\0', '\0', '\0'};
+    final byte[] lScript = l.localeScriptWasComputed ? emptyScript : l.localeScript;
+    final byte[] rScript = r.localeScriptWasComputed ? emptyScript : r.localeScript;
+//    int script = memcmp(lScript, rScript);
+//    if (script) {
+//      return script;
+//    }
+    int d = arrayCompare(lScript, rScript);
+    if (d != 0) return d;
+
+    int variant = arrayCompare(l.localeVariant, r.localeVariant);
+    if (isTruthy(variant)) {
+      return variant;
+    }
+
+    return arrayCompare(l.localeNumberingSystem, r.localeNumberingSystem);
+  }
+
+  private static int arrayCompare(byte[] l, byte[] r) {
+    for (int i = 0; i < l.length; i++) {
+      byte l0 = l[i];
+      byte r0 = r[i];
+      int d = l0 - r0;
+      if (d != 0) return d;
+    }
+    return 0;
+  }
+
+  // Flags indicating a set of config values.  These flag constants must
+  // match the corresponding ones in android.content.pm.ActivityInfo and
+  // attrs_manifest.xml.
+  private static final int CONFIG_MCC = AConfiguration.ACONFIGURATION_MCC;
+  private static final int CONFIG_MNC = AConfiguration.ACONFIGURATION_MNC;
+  private static final int CONFIG_LOCALE = AConfiguration.ACONFIGURATION_LOCALE;
+  private static final int CONFIG_TOUCHSCREEN = AConfiguration.ACONFIGURATION_TOUCHSCREEN;
+  private static final int CONFIG_KEYBOARD = AConfiguration.ACONFIGURATION_KEYBOARD;
+  private static final int CONFIG_KEYBOARD_HIDDEN = AConfiguration.ACONFIGURATION_KEYBOARD_HIDDEN;
+  private static final int CONFIG_NAVIGATION = AConfiguration.ACONFIGURATION_NAVIGATION;
+  private static final int CONFIG_ORIENTATION = AConfiguration.ACONFIGURATION_ORIENTATION;
+  private static final int CONFIG_DENSITY = AConfiguration.ACONFIGURATION_DENSITY;
+  private static final int CONFIG_SCREEN_SIZE = AConfiguration.ACONFIGURATION_SCREEN_SIZE;
+  private static final int CONFIG_SMALLEST_SCREEN_SIZE = AConfiguration.ACONFIGURATION_SMALLEST_SCREEN_SIZE;
+  private static final int CONFIG_VERSION = AConfiguration.ACONFIGURATION_VERSION;
+  private static final int CONFIG_SCREEN_LAYOUT = AConfiguration.ACONFIGURATION_SCREEN_LAYOUT;
+  private static final int CONFIG_UI_MODE = AConfiguration.ACONFIGURATION_UI_MODE;
+  private static final int CONFIG_LAYOUTDIR = AConfiguration.ACONFIGURATION_LAYOUTDIR;
+  private static final int CONFIG_SCREEN_ROUND = AConfiguration.ACONFIGURATION_SCREEN_ROUND;
+  private static final int CONFIG_COLOR_MODE = AConfiguration.ACONFIGURATION_COLOR_MODE;
+
+  // Compare two configuration, returning CONFIG_* flags set for each value
+  // that is different.
+  int diff(final ResTable_config o) {
+    int diffs = 0;
+    if (mcc != o.mcc) diffs |= CONFIG_MCC;
+    if (mnc != o.mnc) diffs |= CONFIG_MNC;
+    if (orientation != o.orientation) diffs |= CONFIG_ORIENTATION;
+    if (density != o.density) diffs |= CONFIG_DENSITY;
+    if (touchscreen != o.touchscreen) diffs |= CONFIG_TOUCHSCREEN;
+    if (((inputFlags^o.inputFlags)&(MASK_KEYSHIDDEN|MASK_NAVHIDDEN)) != 0)
+      diffs |= CONFIG_KEYBOARD_HIDDEN;
+    if (keyboard != o.keyboard) diffs |= CONFIG_KEYBOARD;
+    if (navigation != o.navigation) diffs |= CONFIG_NAVIGATION;
+    if (screenSize() != o.screenSize()) diffs |= CONFIG_SCREEN_SIZE;
+    if (version() != o.version()) diffs |= CONFIG_VERSION;
+    if ((screenLayout & MASK_LAYOUTDIR) != (o.screenLayout & MASK_LAYOUTDIR)) diffs |= CONFIG_LAYOUTDIR;
+    if ((screenLayout & ~MASK_LAYOUTDIR) != (o.screenLayout & ~MASK_LAYOUTDIR)) diffs |= CONFIG_SCREEN_LAYOUT;
+    if ((screenLayout2 & MASK_SCREENROUND) != (o.screenLayout2 & MASK_SCREENROUND)) diffs |= CONFIG_SCREEN_ROUND;
+    if ((colorMode & MASK_WIDE_COLOR_GAMUT) != (o.colorMode & MASK_WIDE_COLOR_GAMUT)) diffs |= CONFIG_COLOR_MODE;
+    if ((colorMode & MASK_HDR) != (o.colorMode & MASK_HDR)) diffs |= CONFIG_COLOR_MODE;
+    if (uiMode != o.uiMode) diffs |= CONFIG_UI_MODE;
+    if (smallestScreenWidthDp != o.smallestScreenWidthDp) diffs |= CONFIG_SMALLEST_SCREEN_SIZE;
+    if (screenSizeDp() != o.screenSizeDp()) diffs |= CONFIG_SCREEN_SIZE;
+
+    int diff = compareLocales(this, o);
+    if (isTruthy(diff)) diffs |= CONFIG_LOCALE;
+
+    return diffs;
+  }
+
+  // There isn't a well specified "importance" order between variants and
+  // scripts. We can't easily tell whether, say "en-Latn-US" is more or less
+  // specific than "en-US-POSIX".
+  //
+  // We therefore arbitrarily decide to give priority to variants over
+  // scripts since it seems more useful to do so. We will consider
+  // "en-US-POSIX" to be more specific than "en-Latn-US".
+  //
+  // Unicode extension keywords are considered to be less important than
+  // scripts and variants.
+  int getImportanceScoreOfLocale() {
+    return (isTruthy(localeVariant[0]) ? 4 : 0)
+        + (isTruthy(localeScript[0]) && !localeScriptWasComputed ? 2: 0)
+        + (isTruthy(localeNumberingSystem[0]) ? 1: 0);
+  }
+
+  int compare(final ResTable_config o) {
+       if (imsi() != o.imsi()) {
+       return (imsi() > o.imsi()) ? 1 : -1;
+   }
+
+   int diff = compareLocales(this, o);
+   if (diff < 0) {
+       return -1;
+   }
+   if (diff > 0) {
+       return 1;
+   }
+
+   if (screenType() != o.screenType()) {
+       return (screenType() > o.screenType()) ? 1 : -1;
+   }
+   if (input() != o.input()) {
+       return (input() > o.input()) ? 1 : -1;
+   }
+   if (screenSize() != o.screenSize()) {
+       return (screenSize() > o.screenSize()) ? 1 : -1;
+   }
+   if (version() != o.version()) {
+       return (version() > o.version()) ? 1 : -1;
+   }
+   if (screenLayout != o.screenLayout) {
+       return (screenLayout > o.screenLayout) ? 1 : -1;
+   }
+   if (screenLayout2 != o.screenLayout2) {
+       return (screenLayout2 > o.screenLayout2) ? 1 : -1;
+   }
+   if (colorMode != o.colorMode) {
+       return (colorMode > o.colorMode) ? 1 : -1;
+   }
+   if (uiMode != o.uiMode) {
+       return (uiMode > o.uiMode) ? 1 : -1;
+   }
+   if (smallestScreenWidthDp != o.smallestScreenWidthDp) {
+       return (smallestScreenWidthDp > o.smallestScreenWidthDp) ? 1 : -1;
+   }
+   if (screenSizeDp() != o.screenSizeDp()) {
+       return (screenSizeDp() > o.screenSizeDp()) ? 1 : -1;
+   }
+   return 0;
+  }
+
+
+  /** Returns true if this is the default "any" configuration. */
+  public final boolean isDefault() {
+    return mcc == 0
+        && mnc == 0
+        && isZeroes(language)
+        && isZeroes(country)
+        && orientation == 0
+        && touchscreen == 0
+        && density == 0
+        && keyboard == 0
+        && navigation == 0
+        && inputFlags == 0
+        && screenWidth == 0
+        && screenHeight == 0
+        && sdkVersion == 0
+        && minorVersion == 0
+        && screenLayout == 0
+        && uiMode == 0
+        && smallestScreenWidthDp == 0
+        && screenWidthDp == 0
+        && screenHeightDp == 0
+        && isZeroes(localeScript)
+        && isZeroes(localeVariant)
+        && screenLayout2 == 0
+        && colorMode == 0
+        ;
+  }
+
+  private boolean isZeroes(byte[] bytes1) {
+    for (byte b : bytes1) {
+      if (b != 0) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
+  public final String toString() {
+    if (isDefault()) {  // Prevent the default configuration from returning the empty string
+      return "default";
+    }
+    Collection<String> parts = toStringParts().values();
+    parts.removeAll(Collections.singleton(""));
+    return Joiner.on('-').join(parts);
+  }
+
+  /**
+   * Returns a map of the configuration parts for {@link #toString}.
+   *
+   * If a configuration part is not defined for this {@link ResTable_config}, its value
+   * will be the empty string.
+   */
+  public final Map<Type, String> toStringParts() {
+    Map<Type, String> result = new LinkedHashMap<>();  // Preserve order for #toString().
+    result.put(Type.MCC, mcc != 0 ? "mcc" + mcc : "");
+    result.put(Type.MNC, mnc != 0 ? "mnc" + mnc : "");
+    result.put(Type.LANGUAGE_STRING, languageString());
+    result.put(Type.LOCALE_SCRIPT_STRING, localeScriptString());
+    result.put(Type.REGION_STRING, !regionString().isEmpty() ? "r" + regionString() : "");
+    result.put(Type.LOCALE_VARIANT_STRING, localeVariantString());
+    result.put(Type.SCREEN_LAYOUT_DIRECTION,
+        getOrDefault(SCREENLAYOUT_LAYOUTDIR_VALUES, screenLayoutDirection(), ""));
+    result.put(Type.SMALLEST_SCREEN_WIDTH_DP,
+        smallestScreenWidthDp != 0 ? "sw" + smallestScreenWidthDp + "dp" : "");
+    result.put(Type.SCREEN_WIDTH_DP, screenWidthDp != 0 ? "w" + screenWidthDp + "dp" : "");
+    result.put(Type.SCREEN_HEIGHT_DP, screenHeightDp != 0 ? "h" + screenHeightDp + "dp" : "");
+    result.put(Type.SCREEN_LAYOUT_SIZE,
+        getOrDefault(SCREENLAYOUT_SIZE_VALUES, screenLayoutSize(), ""));
+    result.put(Type.SCREEN_LAYOUT_LONG,
+        getOrDefault(SCREENLAYOUT_LONG_VALUES, screenLayoutLong(), ""));
+    result.put(Type.SCREEN_LAYOUT_ROUND,
+        getOrDefault(SCREENLAYOUT_ROUND_VALUES, screenLayoutRound(), ""));
+    result.put(Type.COLOR_MODE_HDR, getOrDefault(COLOR_MODE_HDR_VALUES, colorModeHdr(), ""));
+    result.put(
+        Type.COLOR_MODE_WIDE_COLOR_GAMUT,
+        getOrDefault(COLOR_MODE_WIDE_COLOR_GAMUT_VALUES, colorModeWideColorGamut(), ""));
+    result.put(Type.ORIENTATION, getOrDefault(ORIENTATION_VALUES, orientation, ""));
+    result.put(Type.UI_MODE_TYPE, getOrDefault(UI_MODE_TYPE_VALUES, uiModeType(), ""));
+    result.put(Type.UI_MODE_NIGHT, getOrDefault(UI_MODE_NIGHT_VALUES, uiModeNight(), ""));
+    result.put(Type.DENSITY_DPI, getOrDefault(DENSITY_DPI_VALUES, density, density + "dpi"));
+    result.put(Type.TOUCHSCREEN, getOrDefault(TOUCHSCREEN_VALUES, touchscreen, ""));
+    result.put(Type.KEYBOARD_HIDDEN, getOrDefault(KEYBOARDHIDDEN_VALUES, keyboardHidden(), ""));
+    result.put(Type.KEYBOARD, getOrDefault(KEYBOARD_VALUES, keyboard, ""));
+    result.put(Type.NAVIGATION_HIDDEN,
+        getOrDefault(NAVIGATIONHIDDEN_VALUES, navigationHidden(), ""));
+    result.put(Type.NAVIGATION, getOrDefault(NAVIGATION_VALUES, navigation, ""));
+    result.put(Type.SCREEN_SIZE,
+        screenWidth != 0 || screenHeight != 0 ? screenWidth + "x" + screenHeight : "");
+
+    String sdkVersion = "";
+    if (this.sdkVersion != 0) {
+      sdkVersion = "v" + this.sdkVersion;
+      if (minorVersion != 0) {
+        sdkVersion += "." + minorVersion;
+      }
+    }
+    result.put(Type.SDK_VERSION, sdkVersion);
+    return result;
+  }
+
+  private <K, V> V getOrDefault(Map<K, V> map, K key, V defaultValue) {
+    // TODO(acornwall): Remove this when Java 8's Map#getOrDefault is available.
+    // Null is not returned, even if the map contains a key whose value is null. This is intended.
+    V value = map.get(key);
+    return value != null ? value : defaultValue;
+  }
+
+
+  // constants for isBetterThan...
+  public static final int MASK_LAYOUTDIR = SCREENLAYOUT_LAYOUTDIR_MASK;
+  static final int MASK_SCREENSIZE = SCREENLAYOUT_SIZE_MASK;
+
+  public boolean isBetterThan(
+      ResTable_config o, ResTable_config requested) {
+    if (isTruthy(requested)) {
+      if (isTruthy(imsi()) || isTruthy(o.imsi())) {
+        if ((mcc != o.mcc) && isTruthy(requested.mcc)) {
+          return (isTruthy(mcc));
+        }
+
+        if ((mnc != o.mnc) && isTruthy(requested.mnc)) {
+          return (isTruthy(mnc));
+        }
+      }
+
+      if (isLocaleBetterThan(o, requested)) {
+        return true;
+      }
+
+      if (isTruthy(screenLayout) || isTruthy(o.screenLayout)) {
+        if (isTruthy((screenLayout^o.screenLayout) & MASK_LAYOUTDIR)
+            && isTruthy(requested.screenLayout & MASK_LAYOUTDIR)) {
+          int myLayoutDir = screenLayout & MASK_LAYOUTDIR;
+          int oLayoutDir = o.screenLayout & MASK_LAYOUTDIR;
+          return (myLayoutDir > oLayoutDir);
+        }
+      }
+
+      if (isTruthy(smallestScreenWidthDp) || isTruthy(o.smallestScreenWidthDp)) {
+        // The configuration closest to the actual size is best.
+        // We assume that larger configs have already been filtered
+        // out at this point.  That means we just want the largest one.
+        if (smallestScreenWidthDp != o.smallestScreenWidthDp) {
+          return smallestScreenWidthDp > o.smallestScreenWidthDp;
+        }
+      }
+
+      if (isTruthy(screenSizeDp()) || isTruthy(o.screenSizeDp())) {
+        // "Better" is based on the sum of the difference between both
+        // width and height from the requested dimensions.  We are
+        // assuming the invalid configs (with smaller dimens) have
+        // already been filtered.  Note that if a particular dimension
+        // is unspecified, we will end up with a large value (the
+        // difference between 0 and the requested dimension), which is
+        // good since we will prefer a config that has specified a
+        // dimension value.
+        int myDelta = 0, otherDelta = 0;
+        if (isTruthy(requested.screenWidthDp)) {
+          myDelta += requested.screenWidthDp - screenWidthDp;
+          otherDelta += requested.screenWidthDp - o.screenWidthDp;
+        }
+        if (isTruthy(requested.screenHeightDp)) {
+          myDelta += requested.screenHeightDp - screenHeightDp;
+          otherDelta += requested.screenHeightDp - o.screenHeightDp;
+        }
+
+        if (myDelta != otherDelta) {
+          return myDelta < otherDelta;
+        }
+      }
+
+      if (isTruthy(screenLayout) || isTruthy(o.screenLayout)) {
+        if (isTruthy((screenLayout^o.screenLayout) & MASK_SCREENSIZE)
+            && isTruthy(requested.screenLayout & MASK_SCREENSIZE)) {
+          // A little backwards compatibility here: undefined is
+          // considered equivalent to normal.  But only if the
+          // requested size is at least normal; otherwise, small
+          // is better than the default.
+          int mySL = (screenLayout & MASK_SCREENSIZE);
+          int oSL = (o.screenLayout & MASK_SCREENSIZE);
+          int fixedMySL = mySL;
+          int fixedOSL = oSL;
+          if ((requested.screenLayout & MASK_SCREENSIZE) >= SCREENSIZE_NORMAL) {
+            if (fixedMySL == 0) fixedMySL = SCREENSIZE_NORMAL;
+            if (fixedOSL == 0) fixedOSL = SCREENSIZE_NORMAL;
+          }
+          // For screen size, the best match is the one that is
+          // closest to the requested screen size, but not over
+          // (the not over part is dealt with in match() below).
+          if (fixedMySL == fixedOSL) {
+            // If the two are the same, but 'this' is actually
+            // undefined, then the other is really a better match.
+            if (mySL == 0) return false;
+            return true;
+          }
+          if (fixedMySL != fixedOSL) {
+            return fixedMySL > fixedOSL;
+          }
+        }
+        if (((screenLayout^o.screenLayout) & MASK_SCREENLONG) != 0
+            && isTruthy(requested.screenLayout & MASK_SCREENLONG)) {
+          return isTruthy(screenLayout & MASK_SCREENLONG);
+        }
+      }
+
+      if (isTruthy(screenLayout2) || isTruthy(o.screenLayout2)) {
+        if (((screenLayout2^o.screenLayout2) & MASK_SCREENROUND) != 0 &&
+            isTruthy(requested.screenLayout2 & MASK_SCREENROUND)) {
+          return isTruthy(screenLayout2 & MASK_SCREENROUND);
+        }
+      }
+
+      if (isTruthy(colorMode) || isTruthy(o.colorMode)) {
+        if (((colorMode^o.colorMode) & MASK_WIDE_COLOR_GAMUT) != 0 &&
+            isTruthy((requested.colorMode & MASK_WIDE_COLOR_GAMUT))) {
+          return isTruthy(colorMode & MASK_WIDE_COLOR_GAMUT);
+        }
+        if (((colorMode^o.colorMode) & MASK_HDR) != 0 &&
+            isTruthy((requested.colorMode & MASK_HDR))) {
+          return isTruthy(colorMode & MASK_HDR);
+        }
+      }
+
+      if ((orientation != o.orientation) && isTruthy(requested.orientation)) {
+        return isTruthy(orientation);
+      }
+
+      if (isTruthy(uiMode) || isTruthy(o.uiMode)) {
+        if (((uiMode^o.uiMode) & MASK_UI_MODE_TYPE) != 0
+            && isTruthy(requested.uiMode & MASK_UI_MODE_TYPE)) {
+          return isTruthy(uiMode & MASK_UI_MODE_TYPE);
+        }
+        if (((uiMode^o.uiMode) & MASK_UI_MODE_NIGHT) != 0
+            && isTruthy(requested.uiMode & MASK_UI_MODE_NIGHT)) {
+          return isTruthy(uiMode & MASK_UI_MODE_NIGHT);
+        }
+      }
+
+      if (isTruthy(screenType()) || isTruthy(o.screenType())) {
+        if (density != o.density) {
+          // Use the system default density (DENSITY_MEDIUM, 160dpi) if none specified.
+          final int thisDensity = isTruthy(density) ? density : DENSITY_MEDIUM;
+          final int otherDensity = isTruthy(o.density) ? o.density : DENSITY_MEDIUM;
+
+          // We always prefer DENSITY_ANY over scaling a density bucket.
+          if (thisDensity == DENSITY_ANY) {
+            return true;
+          } else if (otherDensity == DENSITY_ANY) {
+            return false;
+          }
+
+          int requestedDensity = requested.density;
+          if (requested.density == 0 ||
+              requested.density == DENSITY_ANY) {
+            requestedDensity = DENSITY_MEDIUM;
+          }
+
+          // DENSITY_ANY is now dealt with. We should look to
+          // pick a density bucket and potentially scale it.
+          // Any density is potentially useful
+          // because the system will scale it.  Scaling down
+          // is generally better than scaling up.
+          int h = thisDensity;
+          int l = otherDensity;
+          boolean bImBigger = true;
+          if (l > h) {
+            int t = h;
+            h = l;
+            l = t;
+            bImBigger = false;
+          }
+
+          if (requestedDensity >= h) {
+            // requested value higher than both l and h, give h
+            return bImBigger;
+          }
+          if (l >= requestedDensity) {
+            // requested value lower than both l and h, give l
+            return !bImBigger;
+          }
+          // saying that scaling down is 2x better than up
+          if (((2 * l) - requestedDensity) * h > requestedDensity * requestedDensity) {
+            return !bImBigger;
+          } else {
+            return bImBigger;
+          }
+        }
+
+        if ((touchscreen != o.touchscreen) && isTruthy(requested.touchscreen)) {
+          return isTruthy(touchscreen);
+        }
+      }
+
+      if (isTruthy(input()) || isTruthy(o.input())) {
+            final int keysHidden = inputFlags & MASK_KEYSHIDDEN;
+            final int oKeysHidden = o.inputFlags & MASK_KEYSHIDDEN;
+        if (keysHidden != oKeysHidden) {
+                final int reqKeysHidden =
+              requested.inputFlags & MASK_KEYSHIDDEN;
+          if (isTruthy(reqKeysHidden)) {
+
+            if (keysHidden == 0) return false;
+            if (oKeysHidden == 0) return true;
+            // For compatibility, we count KEYSHIDDEN_NO as being
+            // the same as KEYSHIDDEN_SOFT.  Here we disambiguate
+            // these by making an exact match more specific.
+            if (reqKeysHidden == keysHidden) return true;
+            if (reqKeysHidden == oKeysHidden) return false;
+          }
+        }
+
+            final int navHidden = inputFlags & MASK_NAVHIDDEN;
+            final int oNavHidden = o.inputFlags & MASK_NAVHIDDEN;
+        if (navHidden != oNavHidden) {
+                final int reqNavHidden =
+              requested.inputFlags & MASK_NAVHIDDEN;
+          if (isTruthy(reqNavHidden)) {
+
+            if (navHidden == 0) return false;
+            if (oNavHidden == 0) return true;
+          }
+        }
+
+        if ((keyboard != o.keyboard) && isTruthy(requested.keyboard)) {
+          return isTruthy(keyboard);
+        }
+
+        if ((navigation != o.navigation) && isTruthy(requested.navigation)) {
+          return isTruthy(navigation);
+        }
+      }
+
+      if (isTruthy(screenSize()) || isTruthy(o.screenSize())) {
+        // "Better" is based on the sum of the difference between both
+        // width and height from the requested dimensions.  We are
+        // assuming the invalid configs (with smaller sizes) have
+        // already been filtered.  Note that if a particular dimension
+        // is unspecified, we will end up with a large value (the
+        // difference between 0 and the requested dimension), which is
+        // good since we will prefer a config that has specified a
+        // size value.
+        int myDelta = 0, otherDelta = 0;
+        if (isTruthy(requested.screenWidth)) {
+          myDelta += requested.screenWidth - screenWidth;
+          otherDelta += requested.screenWidth - o.screenWidth;
+        }
+        if (isTruthy(requested.screenHeight)) {
+          myDelta += requested.screenHeight - screenHeight;
+          otherDelta += requested.screenHeight - o.screenHeight;
+        }
+        if (myDelta != otherDelta) {
+          return myDelta < otherDelta;
+        }
+      }
+
+      if (isTruthy(version()) || isTruthy(o.version())) {
+        if ((sdkVersion != o.sdkVersion) && isTruthy(requested.sdkVersion)) {
+          return (sdkVersion > o.sdkVersion);
+        }
+
+        if ((minorVersion != o.minorVersion) &&
+            isTruthy(requested.minorVersion)) {
+          return isTruthy(minorVersion);
+        }
+      }
+
+      return false;
+    }
+    return isMoreSpecificThan(o);
+  }
+
+/*
+  boolean match(final ResTable_config settings) {
+    System.out.println(this + ".match(" + settings + ")");
+    boolean result = match_(settings);
+    System.out.println("    -> " + result);
+    return result;
+  }
+*/
+
+  public boolean match(final ResTable_config settings) {
+    if (imsi() != 0) {
+      if (mcc != 0 && mcc != settings.mcc) {
+        return false;
+      }
+      if (mnc != 0 && mnc != settings.mnc) {
+        return false;
+      }
+    }
+    if (locale() != 0) {
+      // Don't consider country and variants when deciding matches.
+      // (Theoretically, the variant can also affect the script. For
+      // example, "ar-alalc97" probably implies the Latin script, but since
+      // CLDR doesn't support getting likely scripts for that, we'll assume
+      // the variant doesn't change the script.)
+      //
+      // If two configs differ only in their country and variant,
+      // they can be weeded out in the isMoreSpecificThan test.
+      if (!langsAreEquivalent(language, settings.language)) {
+        return false;
+      }
+
+      // For backward compatibility and supporting private-use locales, we
+      // fall back to old behavior if we couldn't determine the script for
+      // either of the desired locale or the provided locale. But if we could determine
+      // the scripts, they should be the same for the locales to match.
+      boolean countriesMustMatch = false;
+      byte[] computed_script = new byte[4];
+      byte[] script = null;
+      if (settings.localeScript[0] == '\0') { // could not determine the request's script
+        countriesMustMatch = true;
+      } else {
+        if (localeScript[0] == '\0' && !localeScriptWasComputed) {
+          // script was not provided or computed, so we try to compute it
+          localeDataComputeScript(computed_script, language, country);
+          if (computed_script[0] == '\0') { // we could not compute the script
+            countriesMustMatch = true;
+          } else {
+            script = computed_script;
+          }
+        } else { // script was provided, so just use it
+          script = localeScript;
+        }
+      }
+
+      if (countriesMustMatch) {
+        if (country[0] != '\0' && !areIdentical(country, settings.country)) {
+          return false;
+        }
+      } else {
+        if (!Arrays.equals(script, settings.localeScript)) {
+          return false;
+        }
+      }
+    }
+
+    if (screenConfig() != 0) {
+        final int layoutDir = screenLayout&MASK_LAYOUTDIR;
+        final int setLayoutDir = settings.screenLayout&MASK_LAYOUTDIR;
+      if (layoutDir != 0 && layoutDir != setLayoutDir) {
+        return false;
+      }
+
+        final int screenSize = screenLayout&MASK_SCREENSIZE;
+        final int setScreenSize = settings.screenLayout&MASK_SCREENSIZE;
+      // Any screen sizes for larger screens than the setting do not
+      // match.
+      if (screenSize != 0 && screenSize > setScreenSize) {
+        return false;
+      }
+
+        final int screenLong = screenLayout&MASK_SCREENLONG;
+        final int setScreenLong = settings.screenLayout&MASK_SCREENLONG;
+      if (screenLong != 0 && screenLong != setScreenLong) {
+        return false;
+      }
+
+        final int uiModeType = uiMode&MASK_UI_MODE_TYPE;
+        final int setUiModeType = settings.uiMode&MASK_UI_MODE_TYPE;
+      if (uiModeType != 0 && uiModeType != setUiModeType) {
+        return false;
+      }
+
+        final int uiModeNight = uiMode&MASK_UI_MODE_NIGHT;
+        final int setUiModeNight = settings.uiMode&MASK_UI_MODE_NIGHT;
+      if (uiModeNight != 0 && uiModeNight != setUiModeNight) {
+        return false;
+      }
+
+      if (smallestScreenWidthDp != 0
+          && smallestScreenWidthDp > settings.smallestScreenWidthDp) {
+        return false;
+      }
+    }
+
+    if (screenConfig2() != 0) {
+        final int screenRound = screenLayout2 & MASK_SCREENROUND;
+        final int setScreenRound = settings.screenLayout2 & MASK_SCREENROUND;
+      if (screenRound != 0 && screenRound != setScreenRound) {
+        return false;
+      }
+    }
+
+    final int hdr = colorMode & MASK_HDR;
+    final int setHdr = settings.colorMode & MASK_HDR;
+    if (hdr != 0 && hdr != setHdr) {
+      return false;
+    }
+
+    final int wideColorGamut = colorMode & MASK_WIDE_COLOR_GAMUT;
+    final int setWideColorGamut = settings.colorMode & MASK_WIDE_COLOR_GAMUT;
+    if (wideColorGamut != 0 && wideColorGamut != setWideColorGamut) {
+      return false;
+    }
+
+    if (screenSizeDp() != 0) {
+      if (screenWidthDp != 0 && screenWidthDp > settings.screenWidthDp) {
+        if (kDebugTableSuperNoisy) {
+          ALOGI("Filtering out width %d in requested %d", screenWidthDp,
+              settings.screenWidthDp);
+        }
+        return false;
+      }
+      if (screenHeightDp != 0 && screenHeightDp > settings.screenHeightDp) {
+        if (kDebugTableSuperNoisy) {
+          ALOGI("Filtering out height %d in requested %d", screenHeightDp,
+              settings.screenHeightDp);
+        }
+        return false;
+      }
+    }
+    if (screenType() != 0) {
+      if (orientation != 0 && orientation != settings.orientation) {
+        return false;
+      }
+      // density always matches - we can scale it.  See isBetterThan
+      if (touchscreen != 0 && touchscreen != settings.touchscreen) {
+        return false;
+      }
+    }
+    if (input() != 0) {
+        final int keysHidden = inputFlags&MASK_KEYSHIDDEN;
+        final int setKeysHidden = settings.inputFlags&MASK_KEYSHIDDEN;
+      if (keysHidden != 0 && keysHidden != setKeysHidden) {
+        // For compatibility, we count a request for KEYSHIDDEN_NO as also
+        // matching the more recent KEYSHIDDEN_SOFT.  Basically
+        // KEYSHIDDEN_NO means there is some kind of keyboard available.
+        if (kDebugTableSuperNoisy) {
+          ALOGI("Matching keysHidden: have=%d, config=%d\n", keysHidden, setKeysHidden);
+        }
+        if (keysHidden != KEYSHIDDEN_NO || setKeysHidden != KEYSHIDDEN_SOFT) {
+          if (kDebugTableSuperNoisy) {
+            ALOGI("No match!");
+          }
+          return false;
+        }
+      }
+        final int navHidden = inputFlags&MASK_NAVHIDDEN;
+        final int setNavHidden = settings.inputFlags&MASK_NAVHIDDEN;
+      if (navHidden != 0 && navHidden != setNavHidden) {
+        return false;
+      }
+      if (keyboard != 0 && keyboard != settings.keyboard) {
+        return false;
+      }
+      if (navigation != 0 && navigation != settings.navigation) {
+        return false;
+      }
+    }
+    if (screenSize() != 0) {
+      if (screenWidth != 0 && screenWidth > settings.screenWidth) {
+        return false;
+      }
+      if (screenHeight != 0 && screenHeight > settings.screenHeight) {
+        return false;
+      }
+    }
+    if (version() != 0) {
+      if (sdkVersion != 0 && sdkVersion > settings.sdkVersion) {
+        return false;
+      }
+      if (minorVersion != 0 && minorVersion != settings.minorVersion) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+//  void appendDirLocale(String8& out) const {
+//    if (!language[0]) {
+//      return;
+//    }
+//    const bool scriptWasProvided = localeScript[0] != '\0' && !localeScriptWasComputed;
+//    if (!scriptWasProvided && !localeVariant[0] && !localeNumberingSystem[0]) {
+//      // Legacy format.
+//      if (out.size() > 0) {
+//        out.append("-");
+//      }
+//
+//      char buf[4];
+//      size_t len = unpackLanguage(buf);
+//      out.append(buf, len);
+//
+//      if (country[0]) {
+//        out.append("-r");
+//        len = unpackRegion(buf);
+//        out.append(buf, len);
+//      }
+//      return;
+//    }
+//
+//    // We are writing the modified BCP 47 tag.
+//    // It starts with 'b+' and uses '+' as a separator.
+//
+//    if (out.size() > 0) {
+//      out.append("-");
+//    }
+//    out.append("b+");
+//
+//    char buf[4];
+//    size_t len = unpackLanguage(buf);
+//    out.append(buf, len);
+//
+//    if (scriptWasProvided) {
+//      out.append("+");
+//      out.append(localeScript, sizeof(localeScript));
+//    }
+//
+//    if (country[0]) {
+//      out.append("+");
+//      len = unpackRegion(buf);
+//      out.append(buf, len);
+//    }
+//
+//    if (localeVariant[0]) {
+//      out.append("+");
+//      out.append(localeVariant, strnlen(localeVariant, sizeof(localeVariant)));
+//    }
+//
+//    if (localeNumberingSystem[0]) {
+//      out.append("+u+nu+");
+//      out.append(localeNumberingSystem,
+//                 strnlen(localeNumberingSystem, sizeof(localeNumberingSystem)));
+//    }
+//  }
+
+  // returns string as return value instead of by mutating first arg
+  // void ResTable_config::getBcp47Locale(char str[RESTABLE_MAX_LOCALE_LEN], bool canonicalize) const {
+  String getBcp47Locale(boolean canonicalize) {
+    StringBuilder str = new StringBuilder();
+
+    // This represents the "any" locale value, which has traditionally been
+    // represented by the empty string.
+    if (language[0] == '\0' && country[0] == '\0') {
+      return "";
+    }
+
+    if (language[0] != '\0') {
+      if (canonicalize && areIdentical(language, kTagalog)) {
+        // Replace Tagalog with Filipino if we are canonicalizing
+        str.setLength(0);
+        str.append("fil");// 3-letter code for Filipino
+      } else {
+        str.append(unpackLanguage());
+      }
+    }
+
+    if (isTruthy(localeScript[0]) && !localeScriptWasComputed) {
+      if (str.length() > 0) {
+        str.append('-');
+      }
+      for (byte aLocaleScript : localeScript) {
+        str.append((char) aLocaleScript);
+      }
+    }
+
+    if (country[0] != '\0') {
+      if (str.length() > 0) {
+        str.append('-');
+      }
+      String regionStr = unpackRegion();
+      str.append(regionStr);
+    }
+
+    if (isTruthy(localeVariant[0])) {
+      if (str.length() > 0) {
+        str.append('-');
+      }
+
+      for (byte aLocaleScript : localeVariant) {
+        str.append((char) aLocaleScript);
+      }
+    }
+
+    // Add Unicode extension only if at least one other locale component is present
+    if (localeNumberingSystem[0] != '\0' && str.length() > 0) {
+      String NU_PREFIX = "-u-nu-";
+      str.append(NU_PREFIX);
+      str.append(new String(localeNumberingSystem, UTF_8));
+    }
+
+    return str.toString();
+  }
+
+  enum State {
+    BASE, UNICODE_EXTENSION, IGNORE_THE_REST
+  }
+
+  enum UnicodeState {
+    /* Initial state after the Unicode singleton is detected. Either a keyword
+     * or an attribute is expected. */
+    NO_KEY,
+    /* Unicode extension key (but not attribute) is expected. Next states:
+     * NO_KEY, IGNORE_KEY or NUMBERING_SYSTEM. */
+    EXPECT_KEY,
+    /* A key is detected, however it is not supported for now. Ignore its
+     * value. Next states: IGNORE_KEY or NUMBERING_SYSTEM. */
+    IGNORE_KEY,
+    /* Numbering system key was detected. Store its value in the configuration
+     * localeNumberingSystem field. Next state: EXPECT_KEY */
+    NUMBERING_SYSTEM
+  }
+
+  static class LocaleParserState {
+    State parserState;
+    UnicodeState unicodeState;
+
+    // LocaleParserState(): parserState(BASE), unicodeState(NO_KEY) {}
+    public LocaleParserState() {
+      this.parserState = State.BASE;
+      this.unicodeState = UnicodeState.NO_KEY;
+    }
+  }
+
+  static LocaleParserState assignLocaleComponent(ResTable_config config,
+      final String start, int size, LocaleParserState state) {
+
+    /* It is assumed that this function is not invoked with state.parserState
+     * set to IGNORE_THE_REST. The condition is checked by setBcp47Locale
+     * function. */
+
+    if (state.parserState == State.UNICODE_EXTENSION) {
+      switch (size) {
+        case 1:
+          /* Other BCP 47 extensions are not supported at the moment */
+          state.parserState = State.IGNORE_THE_REST;
+          break;
+        case 2:
+          if (state.unicodeState == UnicodeState.NO_KEY ||
+              state.unicodeState == UnicodeState.EXPECT_KEY) {
+            /* Analyze Unicode extension key. Currently only 'nu'
+             * (numbering system) is supported.*/
+            if ((start.charAt(0) == 'n' || start.charAt(0) == 'N') &&
+                (start.charAt(1) == 'u' || start.charAt(1) == 'U')) {
+              state.unicodeState = UnicodeState.NUMBERING_SYSTEM;
+            } else {
+              state.unicodeState = UnicodeState.IGNORE_KEY;
+            }
+          } else {
+            /* Keys are not allowed in other state allowed, ignore the rest. */
+            state.parserState = State.IGNORE_THE_REST;
+          }
+          break;
+        case 3:
+        case 4:
+        case 5:
+        case 6:
+        case 7:
+        case 8:
+          switch (state.unicodeState) {
+            case NUMBERING_SYSTEM:
+              /* Accept only the first occurrence of the numbering system. */
+              if (config.localeNumberingSystem[0] == '\0') {
+                for (int i = 0; i < size; ++i) {
+                  config.localeNumberingSystem[i] = (byte) Character.toLowerCase(start.charAt(i));
+                }
+                state.unicodeState = UnicodeState.EXPECT_KEY;
+              } else {
+                state.parserState = State.IGNORE_THE_REST;
+              }
+              break;
+            case IGNORE_KEY:
+              /* Unsupported Unicode keyword. Ignore. */
+              state.unicodeState = UnicodeState.EXPECT_KEY;
+              break;
+            case EXPECT_KEY:
+              /* A keyword followed by an attribute is not allowed. */
+              state.parserState = State.IGNORE_THE_REST;
+              break;
+            case NO_KEY:
+              /* Extension attribute. Do nothing. */
+              break;
+          }
+          break;
+        default:
+          /* Unexpected field length - ignore the rest and treat as an error */
+          state.parserState = State.IGNORE_THE_REST;
+      }
+      return state;
+    }
+
+    switch (size) {
+      case 0:
+        state.parserState = State.IGNORE_THE_REST;
+        break;
+      case 1:
+        state.parserState = (start.charAt(0) == 'u' || start.charAt(0) == 'U')
+            ? State.UNICODE_EXTENSION
+            : State.IGNORE_THE_REST;
+        break;
+      case 2:
+      case 3:
+        if (isTruthy(config.language[0])) {
+          config.packRegion(start);
+        } else {
+          config.packLanguage(start);
+        }
+        break;
+      case 4:
+        char start0 = start.charAt(0);
+        if ('0' <= start0 && start0 <= '9') {
+          // this is a variant, so fall through
+        } else {
+          config.localeScript[0] = (byte) Character.toUpperCase(start0);
+          for (int i = 1; i < 4; ++i) {
+            config.localeScript[i] = (byte) Character.toLowerCase(start.charAt(i));
+          }
+          break;
+        }
+        // fall through
+      case 5:
+      case 6:
+      case 7:
+      case 8:
+        for (int i = 0; i < size; ++i) {
+          config.localeVariant[i] = (byte) Character.toLowerCase(start.charAt(i));
+        }
+        break;
+      default:
+        state.parserState = State.IGNORE_THE_REST;
+    }
+
+    return state;
+  }
+
+  public void setBcp47Locale(final String in) {
+    clearLocale();
+
+    int start = 0;
+    LocaleParserState state = new LocaleParserState();
+    int separator;
+    while ((separator = in.indexOf('-', start)) > 0) {
+      final int size = separator - start;
+      state = assignLocaleComponent(this, in.substring(start), size, state);
+      if (state.parserState == State.IGNORE_THE_REST) {
+
+        System.err.println(String.format("Invalid BCP-47 locale string: %s", in));
+        break;
+      }
+
+      start = (separator + 1);
+    }
+
+    if (state.parserState != State.IGNORE_THE_REST) {
+      final int size = in.length() - start;
+      assignLocaleComponent(this, in.substring(start), size, state);
+    }
+
+    localeScriptWasComputed = (localeScript[0] == '\0');
+    if (localeScriptWasComputed) {
+      computeScript();
+    }
+  }
+
+  void clearLocale() {
+//    locale = 0;
+    clear(language);
+    clear(country);
+
+    localeScriptWasComputed = false;
+    clear(localeScript);
+    clear(localeVariant);
+  }
+
+  void computeScript() {
+    localeDataComputeScript(localeScript, language, country);
+  }
+
+  private void clear(byte[] bytes) {
+    for (int i = 0; i < bytes.length; i++) {
+      bytes[i] = 0;
+    }
+  }
+
+
+  /**
+   *     union {
+   struct {
+   // Mobile country code (from SIM).  0 means "any".
+   uint16_t mcc;
+   // Mobile network code (from SIM).  0 means "any".
+   uint16_t mnc;
+   };
+   uint32_t imsi;
+   };
+   */
+  private int imsi() {
+    return ((mcc & 0xffff) << 16) | (mnc & 0xffff);
+  }
+
+  /**
+   *     union {
+   struct {
+   uint16_t screenWidth;
+   uint16_t screenHeight;
+   };
+   uint32_t screenSize;
+   };
+   */
+  private int screenSize() {
+    return ((screenWidth & 0xffff) << 16) | (screenHeight & 0xffff);
+  }
+
+
+  /**
+   union {
+   struct {
+   uint8_t screenLayout;
+   uint8_t uiMode;
+   uint16_t smallestScreenWidthDp;
+   };
+   uint32_t screenConfig;
+   };
+   */
+  private int screenConfig() {
+    return ((screenLayout & 0xff) << 24) | ((uiMode * 0xff) << 16) | (smallestScreenWidthDp & 0xffff);
+  }
+
+
+  /**
+   *     union {
+   struct {
+   uint16_t screenWidthDp;
+   uint16_t screenHeightDp;
+   };
+   uint32_t screenSizeDp;
+   };
+   */
+  private int screenSizeDp() {
+    // screenWidthDp and screenHeightDp are really shorts...
+    return (screenWidthDp & 0xffff) << 16 | (screenHeightDp & 0xffff);
+  }
+
+  /**
+     union {
+     struct {
+     uint8_t orientation;
+     uint8_t touchscreen;
+     uint16_t density;
+     };
+     uint32_t screenType;
+     };
+   */
+  private int screenType() {
+    return ((orientation & 0xff) << 24) | ((touchscreen & 0xff) << 16) | (density & 0xffff);
+  }
+
+  /**
+   *
+   union {
+   struct {
+   uint8_t keyboard;
+   uint8_t navigation;
+   uint8_t inputFlags;
+   uint8_t inputPad0;
+   };
+   uint32_t input;
+   };
+   */
+  private int input() {
+    // TODO is Pad Zeros?
+    return ((keyboard & 0xff) << 24) | ((navigation & 0xff) << 16) | ((inputFlags & 0xff) << 8);
+  }
+
+  /**
+   *     union {
+   struct {
+   uint16_t sdkVersion;
+   // For now minorVersion must always be 0!!!  Its meaning
+   // is currently undefined.
+   uint16_t minorVersion;
+   };
+   uint32_t version;
+   };
+   */
+  private int version() {
+    return ((sdkVersion & 0xffff) << 16) | (minorVersion & 0xffff);
+  }
+
+  /**
+   union {
+   struct {
+   // This field can take three different forms:
+   // - \0\0 means "any".
+   //
+   // - Two 7 bit ascii values interpreted as ISO-639-1 language
+   //   codes ('fr', 'en' etc. etc.). The high bit for both bytes is
+   //   zero.
+   //
+   // - A single 16 bit little endian packed value representing an
+   //   ISO-639-2 3 letter language code. This will be of the form:
+   //
+   //   {1, t, t, t, t, t, s, s, s, s, s, f, f, f, f, f}
+   //
+   //   bit[0, 4] = first letter of the language code
+   //   bit[5, 9] = second letter of the language code
+   //   bit[10, 14] = third letter of the language code.
+   //   bit[15] = 1 always
+   //
+   // For backwards compatibility, languages that have unambiguous
+   // two letter codes are represented in that format.
+   //
+   // The layout is always bigendian irrespective of the runtime
+   // architecture.
+   char language[2];
+
+   // This field can take three different forms:
+   // - \0\0 means "any".
+   //
+   // - Two 7 bit ascii values interpreted as 2 letter country
+   //   codes ('US', 'GB' etc.). The high bit for both bytes is zero.
+   //
+   // - An UN M.49 3 digit country code. For simplicity, these are packed
+   //   in the same manner as the language codes, though we should need
+   //   only 10 bits to represent them, instead of the 15.
+   //
+   // The layout is always bigendian irrespective of the runtime
+   // architecture.
+   char country[2];
+   };
+   uint32_t locale;
+   };
+   */
+  int locale() {
+    return ((language[0] & 0xff) << 24) | ((language[1] & 0xff) << 16) | ((country[0] & 0xff) << 8) | (country[1] & 0xff);
+  }
+
+  private boolean isLocaleBetterThan(ResTable_config o, ResTable_config requested) {
+    if (requested.locale() == 0) {
+      // The request doesn't have a locale, so no resource is better
+      // than the other.
+      return false;
+    }
+
+    if (locale() == 0 && o.locale() == 0) {
+      // The locale part of both resources is empty, so none is better
+      // than the other.
+      return false;
+    }
+
+    // Non-matching locales have been filtered out, so both resources
+    // match the requested locale.
+    //
+    // Because of the locale-related checks in match() and the checks, we know
+    // that:
+    // 1) The resource languages are either empty or match the request;
+    // and
+    // 2) If the request's script is known, the resource scripts are either
+    //    unknown or match the request.
+
+    if (!langsAreEquivalent(language, o.language)) {
+      // The languages of the two resources are not equivalent. If we are
+      // here, we can only assume that the two resources matched the request
+      // because one doesn't have a language and the other has a matching
+      // language.
+      //
+      // We consider the one that has the language specified a better match.
+      //
+      // The exception is that we consider no-language resources a better match
+      // for US English and similar locales than locales that are a descendant
+      // of Internatinal English (en-001), since no-language resources are
+      // where the US English resource have traditionally lived for most apps.
+      if (areIdentical(requested.language, kEnglish)) {
+        if (areIdentical(requested.country, kUnitedStates)) {
+          // For US English itself, we consider a no-locale resource a
+          // better match if the other resource has a country other than
+          // US specified.
+          if (language[0] != '\0') {
+            return country[0] == '\0' || areIdentical(country, kUnitedStates);
+          } else {
+            return !(o.country[0] == '\0' || areIdentical(o.country, kUnitedStates));
+          }
+        } else if (localeDataIsCloseToUsEnglish(requested.country)) {
+          if (language[0] != '\0') {
+            return localeDataIsCloseToUsEnglish(country);
+          } else {
+            return !localeDataIsCloseToUsEnglish(o.country);
+          }
+        }
+      }
+      return (language[0] != '\0');
+    }
+
+    // If we are here, both the resources have an equivalent non-empty language
+    // to the request.
+    //
+    // Because the languages are equivalent, computeScript() always returns a
+    // non-empty script for languages it knows about, and we have passed the
+    // script checks in match(), the scripts are either all unknown or are all
+    // the same. So we can't gain anything by checking the scripts. We need to
+    // check the country and variant.
+
+    // See if any of the regions is better than the other.
+    final int region_comparison = localeDataCompareRegions(
+        country, o.country,
+        requested.language, str(requested.localeScript), requested.country);
+    if (region_comparison != 0) {
+      return (region_comparison > 0);
+    }
+
+    // The regions are the same. Try the variant.
+    final boolean localeMatches = Arrays.equals(localeVariant, requested.localeVariant);
+    final boolean otherMatches = Arrays.equals(o.localeVariant, requested.localeVariant);
+    if (localeMatches != otherMatches) {
+      return localeMatches;
+    }
+
+    // The variants are the same, try numbering system.
+    boolean localeNumsysMatches = arrayCompare(localeNumberingSystem,
+                                             requested.localeNumberingSystem
+                                             ) == 0;
+    boolean otherNumsysMatches = arrayCompare(o.localeNumberingSystem,
+                                            requested.localeNumberingSystem
+                                            ) == 0;
+
+    if (localeNumsysMatches != otherNumsysMatches) {
+        return localeNumsysMatches;
+    }
+
+    // Finally, the languages, although equivalent, may still be different
+    // (like for Tagalog and Filipino). Identical is better than just
+    // equivalent.
+    if (areIdentical(language, requested.language)
+        && !areIdentical(o.language, requested.language)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  private String str(byte[] country) {
+    return new String(country, UTF_8);
+  }
+
+  private boolean langsAreEquivalent(final byte[] lang1, final byte[] lang2) {
+    return areIdentical(lang1, lang2) ||
+        (areIdentical(lang1, kTagalog) && areIdentical(lang2, kFilipino)) ||
+        (areIdentical(lang1, kFilipino) && areIdentical(lang2, kTagalog));
+  }
+
+  // Checks if two language or country codes are identical
+  private boolean  areIdentical(final byte[] code1, final byte[] code2) {
+    return code1[0] == code2[0] && code1[1] == code2[1];
+  }
+
+  int isLocaleMoreSpecificThan(ResTable_config o) {
+    if (isTruthy(locale()) || isTruthy(o.locale())) {
+      if (language[0] != o.language[0]) {
+        if (!isTruthy(language[0])) return -1;
+        if (!isTruthy(o.language[0])) return 1;
+      }
+      if (country[0] != o.country[0]) {
+        if (!isTruthy(country[0])) return -1;
+        if (!isTruthy(o.country[0])) return 1;
+      }
+    }
+    return getImportanceScoreOfLocale() - o.getImportanceScoreOfLocale();
+  }
+
+  private boolean isMoreSpecificThan(ResTable_config o) {
+    // The order of the following tests defines the importance of one
+    // configuration parameter over another.  Those tests first are more
+    // important, trumping any values in those following them.
+    if (isTruthy(imsi()) || isTruthy(o.imsi())) {
+      if (mcc != o.mcc) {
+        if (!isTruthy(mcc)) return false;
+        if (!isTruthy(o.mcc)) return true;
+      }
+      if (mnc != o.mnc) {
+        if (!isTruthy(mnc)) return false;
+        if (!isTruthy(o.mnc)) return true;
+      }
+    }
+    if (isTruthy(locale()) || isTruthy(o.locale())) {
+      int diff = isLocaleMoreSpecificThan(o);
+      if (diff < 0) {
+        return false;
+      }
+      if (diff > 0) {
+        return true;
+      }
+    }
+    if (isTruthy(screenLayout) || isTruthy(o.screenLayout)) {
+      if (((screenLayout^o.screenLayout) & MASK_LAYOUTDIR) != 0) {
+        if (!isTruthy((screenLayout & MASK_LAYOUTDIR))) return false;
+        if (!isTruthy((o.screenLayout & MASK_LAYOUTDIR))) return true;
+      }
+    }
+    if (isTruthy(smallestScreenWidthDp) || isTruthy(o.smallestScreenWidthDp)) {
+      if (smallestScreenWidthDp != o.smallestScreenWidthDp) {
+        if (!isTruthy(smallestScreenWidthDp)) return false;
+        if (!isTruthy(o.smallestScreenWidthDp)) return true;
+      }
+    }
+    if (isTruthy(screenSizeDp()) || isTruthy(o.screenSizeDp())) {
+      if (screenWidthDp != o.screenWidthDp) {
+        if (!isTruthy(screenWidthDp)) return false;
+        if (!isTruthy(o.screenWidthDp)) return true;
+      }
+      if (screenHeightDp != o.screenHeightDp) {
+        if (!isTruthy(screenHeightDp)) return false;
+        if (!isTruthy(o.screenHeightDp)) return true;
+      }
+    }
+    if (isTruthy(screenLayout) || isTruthy(o.screenLayout)) {
+      if (((screenLayout^o.screenLayout) & MASK_SCREENSIZE) != 0) {
+        if (!isTruthy((screenLayout & MASK_SCREENSIZE))) return false;
+        if (!isTruthy((o.screenLayout & MASK_SCREENSIZE))) return true;
+      }
+      if (((screenLayout^o.screenLayout) & MASK_SCREENLONG) != 0) {
+        if (!isTruthy((screenLayout & MASK_SCREENLONG))) return false;
+        if (!isTruthy((o.screenLayout & MASK_SCREENLONG))) return true;
+      }
+    }
+    if (isTruthy(screenLayout2) || isTruthy(o.screenLayout2)) {
+      if (((screenLayout2^o.screenLayout2) & MASK_SCREENROUND) != 0) {
+        if (!isTruthy((screenLayout2 & MASK_SCREENROUND))) return false;
+        if (!isTruthy((o.screenLayout2 & MASK_SCREENROUND))) return true;
+      }
+    }
+
+    if (isTruthy(colorMode) || isTruthy(o.colorMode)) {
+      if (((colorMode^o.colorMode) & MASK_HDR) != 0) {
+        if (!isTruthy((colorMode & MASK_HDR))) return false;
+        if (!isTruthy((o.colorMode & MASK_HDR))) return true;
+      }
+      if (((colorMode^o.colorMode) & MASK_WIDE_COLOR_GAMUT) != 0) {
+        if (!isTruthy((colorMode & MASK_WIDE_COLOR_GAMUT))) return false;
+        if (!isTruthy((o.colorMode & MASK_WIDE_COLOR_GAMUT))) return true;
+      }
+    }
+
+    if (orientation != o.orientation) {
+      if (!isTruthy(orientation)) return false;
+      if (!isTruthy(o.orientation)) return true;
+    }
+    if (isTruthy(uiMode) || isTruthy(o.uiMode)) {
+      if (((uiMode^o.uiMode) & MASK_UI_MODE_TYPE) != 0) {
+        if (!isTruthy((uiMode & MASK_UI_MODE_TYPE))) return false;
+        if (!isTruthy((o.uiMode & MASK_UI_MODE_TYPE))) return true;
+      }
+      if (((uiMode^o.uiMode) & MASK_UI_MODE_NIGHT) != 0) {
+        if (!isTruthy((uiMode & MASK_UI_MODE_NIGHT))) return false;
+        if (!isTruthy((o.uiMode & MASK_UI_MODE_NIGHT))) return true;
+      }
+    }
+    // density is never 'more specific'
+    // as the default just equals 160
+    if (touchscreen != o.touchscreen) {
+      if (!isTruthy(touchscreen)) return false;
+      if (!isTruthy(o.touchscreen)) return true;
+    }
+    if (isTruthy(input()) || isTruthy(o.input())) {
+      if (((inputFlags^o.inputFlags) & MASK_KEYSHIDDEN) != 0) {
+        if (!isTruthy((inputFlags & MASK_KEYSHIDDEN))) return false;
+        if (!isTruthy((o.inputFlags & MASK_KEYSHIDDEN))) return true;
+      }
+      if (((inputFlags^o.inputFlags) & MASK_NAVHIDDEN) != 0) {
+        if (!isTruthy((inputFlags & MASK_NAVHIDDEN))) return false;
+        if (!isTruthy((o.inputFlags & MASK_NAVHIDDEN))) return true;
+      }
+      if (keyboard != o.keyboard) {
+        if (!isTruthy(keyboard)) return false;
+        if (!isTruthy(o.keyboard)) return true;
+      }
+      if (navigation != o.navigation) {
+        if (!isTruthy(navigation)) return false;
+        if (!isTruthy(o.navigation)) return true;
+      }
+    }
+    if (isTruthy(screenSize()) || isTruthy(o.screenSize())) {
+      if (screenWidth != o.screenWidth) {
+        if (!isTruthy(screenWidth)) return false;
+        if (!isTruthy(o.screenWidth)) return true;
+      }
+      if (screenHeight != o.screenHeight) {
+        if (!isTruthy(screenHeight)) return false;
+        if (!isTruthy(o.screenHeight)) return true;
+      }
+    }
+    if (isTruthy(version()) || isTruthy(o.version())) {
+      if (sdkVersion != o.sdkVersion) {
+        if (!isTruthy(sdkVersion)) return false;
+        if (!isTruthy(o.sdkVersion)) return true;
+      }
+      if (minorVersion != o.minorVersion) {
+        if (!isTruthy(minorVersion)) return false;
+        if (!isTruthy(o.minorVersion)) return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResXMLParser.java b/resources/src/main/java/org/robolectric/res/android/ResXMLParser.java
new file mode 100644
index 0000000..39f3b71
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResXMLParser.java
@@ -0,0 +1,618 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Errors.BAD_TYPE;
+import static org.robolectric.res.android.Errors.NAME_NOT_FOUND;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.ResTable.kDebugStringPoolNoisy;
+import static org.robolectric.res.android.ResTable.kDebugXMLNoisy;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.BAD_DOCUMENT;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.END_DOCUMENT;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.END_NAMESPACE;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.END_TAG;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.FIRST_CHUNK_CODE;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.START_DOCUMENT;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.START_NAMESPACE;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.START_TAG;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.TEXT;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_CDATA_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_END_ELEMENT_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_END_NAMESPACE_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_FIRST_CHUNK_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_START_ELEMENT_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_START_NAMESPACE_TYPE;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_attrExt;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_attribute;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_endElementExt;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_node;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+
+public class ResXMLParser {
+
+  static final int SIZEOF_RESXMLTREE_NAMESPACE_EXT = 4;
+  static final int SIZEOF_RESXMLTREE_NODE = ResChunk_header.SIZEOF + 8;
+  static final int SIZEOF_RESXMLTREE_ATTR_EXT = 20;
+  static final int SIZEOF_RESXMLTREE_CDATA_EXT = 4 + ResourceTypes.Res_value.SIZEOF;
+  static final int SIZEOF_CHAR = 2;
+
+  public static class event_code_t {
+    public static final int BAD_DOCUMENT = -1;
+    public static final int START_DOCUMENT = 0;
+    public static final int END_DOCUMENT = 1;
+
+    public static final int FIRST_CHUNK_CODE = RES_XML_FIRST_CHUNK_TYPE;
+ 
+    public static final int START_NAMESPACE = RES_XML_START_NAMESPACE_TYPE;
+    public static final int END_NAMESPACE = RES_XML_END_NAMESPACE_TYPE;
+    public static final int START_TAG = RES_XML_START_ELEMENT_TYPE;
+    public static final int END_TAG = RES_XML_END_ELEMENT_TYPE;
+    public static final int TEXT = RES_XML_CDATA_TYPE;
+  }
+
+  ResXMLTree           mTree;
+  int                mEventCode;
+    ResXMLTree_node      mCurNode;
+    int                 mCurExt;
+  int mSourceResourceId;
+
+  public ResXMLParser(ResXMLTree tree) {
+    this.mTree = tree;
+    this.mEventCode = BAD_DOCUMENT;
+  }
+  
+  public void restart() {
+    mCurNode = null;
+    mEventCode = mTree.mError == NO_ERROR ? START_DOCUMENT : BAD_DOCUMENT;
+  }
+  
+  public ResStringPool getStrings() {
+    return mTree.mStrings;
+  }
+
+  int getEventType()
+  {
+    return mEventCode;
+  }
+
+  public int next()
+  {
+    if (mEventCode == START_DOCUMENT) {
+      mCurNode = mTree.mRootNode;
+      mCurExt = mTree.mRootExt;
+      return (mEventCode=mTree.mRootCode);
+    } else if (mEventCode >= FIRST_CHUNK_CODE) {
+      return nextNode();
+    }
+    return mEventCode;
+  }
+
+  int getCommentID()
+  {
+    return mCurNode != null ? dtohl(mCurNode.comment.index) : -1;
+  }
+
+final String getComment(Ref<Integer> outLen)
+  {
+    int id = getCommentID();
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  public int getLineNumber()
+  {
+    return mCurNode != null ? dtohl(mCurNode.lineNumber) : -1;
+  }
+
+  public int getTextID()
+  {
+    if (mEventCode == TEXT) {
+      return dtohl(new ResourceTypes.ResXMLTree_cdataExt(mTree.mBuffer.buf, mCurExt).data.index);
+    }
+    return -1;
+  }
+
+final String getText(Ref<Integer> outLen)
+  {
+    int id = getTextID();
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  int getTextValue(Res_value outValue)
+  {
+    if (mEventCode == TEXT) {
+      //outValue.copyFrom_dtoh(new ResourceTypes.ResXMLTree_cdataExt(mTree.mBuffer.buf, mCurExt).typedData);
+      return ResourceTypes.Res_value.SIZEOF /* sizeof(Res_value) */;
+    }
+    return BAD_TYPE;
+  }
+
+  int getNamespacePrefixID()
+  {
+    if (mEventCode == START_NAMESPACE || mEventCode == END_NAMESPACE) {
+      return dtohl(new ResourceTypes.ResXMLTree_namespaceExt(mTree.mBuffer.buf, mCurExt).prefix.index);
+    }
+    return -1;
+  }
+
+final String getNamespacePrefix(Ref<Integer> outLen)
+  {
+    int id = getNamespacePrefixID();
+    //printf("prefix=%d  event=%s\n", id, mEventCode);
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  int getNamespaceUriID()
+  {
+    if (mEventCode == START_NAMESPACE || mEventCode == END_NAMESPACE) {
+      return dtohl(new ResourceTypes.ResXMLTree_namespaceExt(mTree.mBuffer.buf, mCurExt).uri.index);
+    }
+    return -1;
+  }
+
+final String getNamespaceUri(Ref<Integer> outLen)
+  {
+    int id = getNamespaceUriID();
+    //printf("uri=%d  event=%s\n", id, mEventCode);
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  public int getElementNamespaceID()
+  {
+    if (mEventCode == START_TAG) {
+      return dtohl(new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt).ns.index);
+    }
+    if (mEventCode == END_TAG) {
+      return dtohl(new ResXMLTree_endElementExt(mTree.mBuffer.buf, mCurExt).ns.index);
+    }
+    return -1;
+  }
+
+final String getElementNamespace(Ref<Integer> outLen)
+  {
+    int id = getElementNamespaceID();
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  public int getElementNameID()
+  {
+    if (mEventCode == START_TAG) {
+      return dtohl(new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt).name.index);
+    }
+    if (mEventCode == END_TAG) {
+      return dtohl(new ResXMLTree_endElementExt(mTree.mBuffer.buf, mCurExt).name.index);
+    }
+    return -1;
+  }
+
+final String getElementName(Ref<Integer> outLen)
+  {
+    int id = getElementNameID();
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  public int getAttributeCount()
+  {
+    if (mEventCode == START_TAG) {
+      return dtohs(new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt).attributeCount);
+    }
+    return 0;
+  }
+
+  public int getAttributeNamespaceID(int idx)
+  {
+    if (mEventCode == START_TAG) {
+        ResXMLTree_attrExt tag = new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt);
+      if (idx < dtohs(tag.attributeCount)) {
+//            final ResXMLTree_attribute attr = (ResXMLTree_attribute)
+//        (((final int8_t*)tag)
+//        + dtohs(tag.attributeStart())
+//            + (dtohs(tag.attributeSize())*idx));
+        ResXMLTree_attribute attr = tag.attributeAt(idx);
+        return dtohl(attr.ns.index);
+      }
+    }
+    return -2;
+  }
+
+final String getAttributeNamespace(int idx, Ref<Integer> outLen)
+  {
+    int id = getAttributeNamespaceID(idx);
+    //printf("attribute namespace=%d  idx=%d  event=%s\n", id, idx, mEventCode);
+    if (kDebugXMLNoisy) {
+      System.out.println(String.format("getAttributeNamespace 0x%x=0x%x\n", idx, id));
+    }
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+final String getAttributeNamespace8(int idx, Ref<Integer> outLen)
+  {
+    int id = getAttributeNamespaceID(idx);
+    //printf("attribute namespace=%d  idx=%d  event=%s\n", id, idx, mEventCode);
+    if (kDebugXMLNoisy) {
+      System.out.println(String.format("getAttributeNamespace 0x%x=0x%x\n", idx, id));
+    }
+    return id >= 0 ? mTree.mStrings.string8At(id, outLen) : null;
+  }
+
+  public int getAttributeNameID(int idx)
+  {
+    if (mEventCode == START_TAG) {
+        ResXMLTree_attrExt tag = new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt);
+      if (idx < dtohs(tag.attributeCount)) {
+//            final ResXMLTree_attribute attr = (ResXMLTree_attribute)
+//        (((final int8_t*)tag)
+//        + dtohs(tag.attributeStart())
+//            + (dtohs(tag.attributeSize())*idx));
+        ResXMLTree_attribute attr = tag.attributeAt(idx);
+        return dtohl(attr.name.index);
+      }
+    }
+    return -1;
+  }
+
+final String getAttributeName(int idx, Ref<Integer> outLen)
+  {
+    int id = getAttributeNameID(idx);
+    //printf("attribute name=%d  idx=%d  event=%s\n", id, idx, mEventCode);
+    if (kDebugXMLNoisy) {
+      System.out.println(String.format("getAttributeName 0x%x=0x%x\n", idx, id));
+    }
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+final String getAttributeName8(int idx, Ref<Integer> outLen)
+  {
+    int id = getAttributeNameID(idx);
+    //printf("attribute name=%d  idx=%d  event=%s\n", id, idx, mEventCode);
+    if (kDebugXMLNoisy) {
+      System.out.println(String.format("getAttributeName 0x%x=0x%x\n", idx, id));
+    }
+    return id >= 0 ? mTree.mStrings.string8At(id, outLen) : null;
+  }
+
+  public int getAttributeNameResID(int idx)
+  {
+    int id = getAttributeNameID(idx);
+    if (id >= 0 && (int)id < mTree.mNumResIds) {
+      int resId = dtohl(mTree.mResIds[id]);
+      if (mTree.mDynamicRefTable != null) {
+        final Ref<Integer> resIdRef = new Ref<>(resId);
+        mTree.mDynamicRefTable.lookupResourceId(resIdRef);
+        resId = resIdRef.get();
+      }
+      return resId;
+    }
+    return 0;
+  }
+
+  public int getAttributeValueStringID(int idx)
+  {
+    if (mEventCode == START_TAG) {
+        ResXMLTree_attrExt tag = new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt);
+      if (idx < dtohs(tag.attributeCount)) {
+//            final ResXMLTree_attribute attr = (ResXMLTree_attribute)
+//        (((final int8_t*)tag)
+//        + dtohs(tag.attributeStart())
+//            + (dtohs(tag.attributeSize())*idx));
+        ResXMLTree_attribute attr = tag.attributeAt(idx);
+        return dtohl(attr.rawValue.index);
+      }
+    }
+    return -1;
+  }
+
+final String getAttributeStringValue(int idx, Ref<Integer> outLen)
+  {
+    int id = getAttributeValueStringID(idx);
+    if (kDebugXMLNoisy) {
+      System.out.println(String.format("getAttributeValue 0x%x=0x%x\n", idx, id));
+    }
+    return id >= 0 ? mTree.mStrings.stringAt(id, outLen) : null;
+  }
+
+  public int getAttributeDataType(int idx)
+  {
+    if (mEventCode == START_TAG) {
+        final ResXMLTree_attrExt tag = new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt);
+      if (idx < dtohs(tag.attributeCount)) {
+//            final ResXMLTree_attribute attr = (ResXMLTree_attribute)
+//        (((final int8_t*)tag)
+//        + dtohs(tag.attributeStart())
+//            + (dtohs(tag.attributeSize())*idx));
+        ResXMLTree_attribute attr = tag.attributeAt(idx);
+        int type = attr.typedValue.dataType;
+        if (type != DataType.DYNAMIC_REFERENCE.code()) {
+          return type;
+        }
+
+        // This is a dynamic reference. We adjust those references
+        // to regular references at this level, so lie to the caller.
+        return DataType.REFERENCE.code();
+      }
+    }
+    return DataType.NULL.code();
+  }
+
+  public int getAttributeData(int idx)
+  {
+    if (mEventCode == START_TAG) {
+        ResXMLTree_attrExt tag = new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt);
+      if (idx < dtohs(tag.attributeCount)) {
+//            final ResXMLTree_attribute attr = (ResXMLTree_attribute)
+//        (((final int8_t*)tag)
+//        + dtohs(tag.attributeStart)
+//            + (dtohs(tag.attributeSize)*idx));
+        ResXMLTree_attribute attr = tag.attributeAt(idx);
+        if (attr.typedValue.dataType != DataType.DYNAMIC_REFERENCE.code() ||
+            mTree.mDynamicRefTable == null) {
+          return dtohl(attr.typedValue.data);
+        }
+
+        final Ref<Integer> data = new Ref<>(dtohl(attr.typedValue.data));
+        if (mTree.mDynamicRefTable.lookupResourceId(data) == NO_ERROR) {
+          return data.get();
+        }
+      }
+    }
+    return 0;
+  }
+
+  public int getAttributeValue(int idx, Ref<Res_value> outValue)
+  {
+    if (mEventCode == START_TAG) {
+      ResXMLTree_attrExt tag = new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt);
+      if (idx < dtohs(tag.attributeCount)) {
+//            final ResXMLTree_attribute attr = (ResXMLTree_attribute)
+//        (((final int8_t*)tag)
+//        + dtohs(tag.attributeStart())
+//            + (dtohs(tag.attributeSize())*idx));
+        ResXMLTree_attribute attr = tag.attributeAt(idx);
+        outValue.set(attr.typedValue);
+        if (mTree.mDynamicRefTable != null &&
+            mTree.mDynamicRefTable.lookupResourceValue(outValue) != NO_ERROR) {
+          return BAD_TYPE;
+        }
+        return ResourceTypes.Res_value.SIZEOF /* sizeof(Res_value) */;
+      }
+    }
+    return BAD_TYPE;
+  }
+
+  int indexOfAttribute(final String ns, final String attr)
+  {
+    String nsStr = ns != null ? ns : "";
+    String attrStr = attr;
+    return indexOfAttribute(isTruthy(ns) ? nsStr : null, isTruthy(ns) ? nsStr.length() : 0,
+        attrStr, attrStr.length());
+  }
+
+  public int indexOfAttribute(final String ns, int nsLen,
+                                       final String attr, int attrLen)
+  {
+    if (mEventCode == START_TAG) {
+      if (attr == null) {
+        return NAME_NOT_FOUND;
+      }
+      final int N = getAttributeCount();
+      if (mTree.mStrings.isUTF8()) {
+        String8 ns8 = null, attr8;
+        if (ns != null) {
+          ns8 = new String8(ns, nsLen);
+        }
+        attr8 = new String8(attr, attrLen);
+        if (kDebugStringPoolNoisy) {
+          ALOGI("indexOfAttribute UTF8 %s (0x%x) / %s (0x%x)", ns8.string(), nsLen,
+              attr8.string(), attrLen);
+        }
+        for (int i=0; i<N; i++) {
+          final Ref<Integer> curNsLen = new Ref<>(0), curAttrLen = new Ref<>(0);
+          final String curNs = getAttributeNamespace8(i, curNsLen);
+          final String curAttr = getAttributeName8(i, curAttrLen);
+          if (kDebugStringPoolNoisy) {
+            ALOGI("  curNs=%s (0x%x), curAttr=%s (0x%x)", curNs, curNsLen.get(), curAttr, curAttrLen.get());
+          }
+          if (curAttr != null && curNsLen.get() == nsLen && curAttrLen.get() == attrLen
+              && memcmp(attr8.string(), curAttr, attrLen) == 0) {
+            if (ns == null) {
+              if (curNs == null) {
+                if (kDebugStringPoolNoisy) {
+                  ALOGI("  FOUND!");
+                }
+                return i;
+              }
+            } else if (curNs != null) {
+              //printf(" -. ns=%s, curNs=%s\n",
+              //       String8(ns).string(), String8(curNs).string());
+              if (memcmp(ns8.string(), curNs, nsLen) == 0) {
+                if (kDebugStringPoolNoisy) {
+                  ALOGI("  FOUND!");
+                }
+                return i;
+              }
+            }
+          }
+        }
+      } else {
+        if (kDebugStringPoolNoisy) {
+          ALOGI("indexOfAttribute UTF16 %s (0x%x) / %s (0x%x)",
+              ns /*String8(ns, nsLen).string()*/, nsLen,
+              attr /*String8(attr, attrLen).string()*/, attrLen);
+        }
+        for (int i=0; i<N; i++) {
+          final Ref<Integer> curNsLen = new Ref<>(0), curAttrLen = new Ref<>(0);
+                final String curNs = getAttributeNamespace(i, curNsLen);
+                final String curAttr = getAttributeName(i, curAttrLen);
+          if (kDebugStringPoolNoisy) {
+            ALOGI("  curNs=%s (0x%x), curAttr=%s (0x%x)",
+                curNs /*String8(curNs, curNsLen).string()*/, curNsLen.get(),
+                curAttr /*String8(curAttr, curAttrLen).string()*/, curAttrLen.get());
+          }
+          if (curAttr != null && curNsLen.get() == nsLen && curAttrLen.get() == attrLen
+              && (memcmp(attr, curAttr, attrLen*SIZEOF_CHAR/*sizeof(char16_t)*/) == 0)) {
+            if (ns == null) {
+              if (curNs == null) {
+                if (kDebugStringPoolNoisy) {
+                  ALOGI("  FOUND!");
+                }
+                return i;
+              }
+            } else if (curNs != null) {
+              //printf(" -. ns=%s, curNs=%s\n",
+              //       String8(ns).string(), String8(curNs).string());
+              if (memcmp(ns, curNs, nsLen*SIZEOF_CHAR/*sizeof(char16_t)*/) == 0) {
+                if (kDebugStringPoolNoisy) {
+                  ALOGI("  FOUND!");
+                }
+                return i;
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return NAME_NOT_FOUND;
+  }
+
+  private int memcmp(String s1, String s2, int len) {
+    for (int i = 0; i < len; i++) {
+      int d = s1.charAt(i) - s2.charAt(i);
+      if (d != 0) {
+        return d;
+      }
+    }
+    return 0;
+  }
+
+  public int indexOfID()
+  {
+    if (mEventCode == START_TAG) {
+      final int idx = dtohs(new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt).idIndex);
+      if (idx > 0) return (idx-1);
+    }
+    return NAME_NOT_FOUND;
+  }
+
+  public int indexOfClass()
+  {
+    if (mEventCode == START_TAG) {
+      final int idx = dtohs(new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt).classIndex);
+      if (idx > 0) return (idx-1);
+    }
+    return NAME_NOT_FOUND;
+  }
+
+  public int indexOfStyle()
+  {
+    if (mEventCode == START_TAG) {
+      final int idx = dtohs(new ResXMLTree_attrExt(mTree.mBuffer.buf, mCurExt).styleIndex);
+      if (idx > 0) return (idx-1);
+    }
+    return NAME_NOT_FOUND;
+  }
+
+  int nextNode() {
+    if (mEventCode < 0) {
+      return mEventCode;
+    }
+
+    do {
+      int nextOffset = mCurNode.myOffset() + dtohl(mCurNode.header.size);
+      if (nextOffset >= mTree.mDataLen) {
+        mCurNode = null;
+        return (mEventCode=END_DOCUMENT);
+      }
+
+//        final ResXMLTree_node next = (ResXMLTree_node)
+//      (((final int8_t*)mCurNode) + dtohl(mCurNode.header.size));
+      ResXMLTree_node next = new ResXMLTree_node(mTree.mBuffer.buf, nextOffset);
+      if (kDebugXMLNoisy) {
+        ALOGI("Next node: prev=%s, next=%s\n", mCurNode, next);
+      }
+
+      if (next.myOffset() >= mTree.mDataLen) {
+        mCurNode = null;
+        return (mEventCode=END_DOCUMENT);
+      }
+
+      if (mTree.validateNode(next) != NO_ERROR) {
+        mCurNode = null;
+        return (mEventCode=BAD_DOCUMENT);
+      }
+
+      mCurNode = next;
+      final int headerSize = dtohs(next.header.headerSize);
+      final int totalSize = dtohl(next.header.size);
+      mCurExt = next.myOffset() + headerSize;
+      int minExtSize = 0;
+      int eventCode = dtohs(next.header.type);
+      switch ((mEventCode=eventCode)) {
+        case RES_XML_START_NAMESPACE_TYPE:
+        case RES_XML_END_NAMESPACE_TYPE:
+          minExtSize = SIZEOF_RESXMLTREE_NAMESPACE_EXT /*sizeof(ResXMLTree_namespaceExt)*/;
+          break;
+        case RES_XML_START_ELEMENT_TYPE:
+          minExtSize = SIZEOF_RESXMLTREE_ATTR_EXT /*sizeof(ResXMLTree_attrExt)*/;
+          break;
+        case RES_XML_END_ELEMENT_TYPE:
+          minExtSize = ResXMLTree_endElementExt.SIZEOF /*sizeof(ResXMLTree_endElementExt)*/;
+          break;
+        case RES_XML_CDATA_TYPE:
+          minExtSize = SIZEOF_RESXMLTREE_CDATA_EXT /*sizeof(ResXMLTree_cdataExt)*/;
+          break;
+        default:
+          ALOGW("Unknown XML block: header type %d in node at %d\n",
+              (int)dtohs(next.header.type),
+              (next.myOffset()-mTree.mHeader.myOffset()));
+          continue;
+      }
+
+      if ((totalSize-headerSize) < minExtSize) {
+        ALOGW("Bad XML block: header type 0x%x in node at 0x%x has size %d, need %d\n",
+            (int)dtohs(next.header.type),
+            (next.myOffset()-mTree.mHeader.myOffset()),
+        (int)(totalSize-headerSize), (int)minExtSize);
+        return (mEventCode=BAD_DOCUMENT);
+      }
+
+      //printf("CurNode=%s, CurExt=%s, headerSize=%d, minExtSize=%d\n",
+      //       mCurNode, mCurExt, headerSize, minExtSize);
+
+      return eventCode;
+    } while (true);
+  }
+
+  void getPosition(ResXMLPosition pos)
+  {
+    pos.eventCode = mEventCode;
+    pos.curNode = mCurNode;
+    pos.curExt = mCurExt;
+  }
+
+  void setPosition(final ResXMLPosition pos)
+  {
+    mEventCode = pos.eventCode;
+    mCurNode = pos.curNode;
+    mCurExt = pos.curExt;
+  }
+
+  public void setSourceResourceId(int resId) {
+    mSourceResourceId = resId;
+  }
+
+  public int getSourceResourceId() {
+    return mSourceResourceId;
+  }
+
+  static class ResXMLPosition
+  {
+    int                eventCode;
+        ResXMLTree_node curNode;
+        int                 curExt;
+  };
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResXMLTree.java b/resources/src/main/java/org/robolectric/res/android/ResXMLTree.java
new file mode 100644
index 0000000..546f372
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResXMLTree.java
@@ -0,0 +1,300 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Errors.BAD_TYPE;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Errors.NO_INIT;
+import static org.robolectric.res.android.ResTable.kDebugResXMLTree;
+import static org.robolectric.res.android.ResTable.kDebugXMLNoisy;
+import static org.robolectric.res.android.ResXMLParser.SIZEOF_RESXMLTREE_ATTR_EXT;
+import static org.robolectric.res.android.ResXMLParser.SIZEOF_RESXMLTREE_NODE;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.BAD_DOCUMENT;
+import static org.robolectric.res.android.ResXMLParser.event_code_t.START_DOCUMENT;
+import static org.robolectric.res.android.ResourceTypes.RES_STRING_POOL_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_FIRST_CHUNK_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_LAST_CHUNK_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_RESOURCE_MAP_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_START_ELEMENT_TYPE;
+import static org.robolectric.res.android.ResourceTypes.validate_chunk;
+import static org.robolectric.res.android.Util.ALOGI;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.SIZEOF_INT;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_attrExt;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_header;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_node;
+
+public class ResXMLTree {
+
+  final DynamicRefTable mDynamicRefTable;
+  public final ResXMLParser mParser;
+
+  int                    mError;
+  byte[]                       mOwnedData;
+  XmlBuffer mBuffer;
+    ResXMLTree_header mHeader;
+  int                      mSize;
+  //    final uint8_t*              mDataEnd;
+  int mDataLen;
+  ResStringPool               mStrings = new ResStringPool();
+    int[]             mResIds;
+  int                      mNumResIds;
+    ResXMLTree_node mRootNode;
+    int                 mRootExt;
+  int                mRootCode;
+
+  static volatile AtomicInteger gCount = new AtomicInteger(0);
+
+  public ResXMLTree(DynamicRefTable dynamicRefTable) {
+    mParser = new ResXMLParser(this);
+
+    mDynamicRefTable = dynamicRefTable;
+    mError = NO_INIT;
+    mOwnedData = null;
+
+    if (kDebugResXMLTree) {
+      ALOGI("Creating ResXMLTree %s #%d\n", this, gCount.getAndIncrement()+1);
+    }
+    mParser.restart();
+  }
+
+//  ResXMLTree() {
+//    this(null);
+//  }
+
+//  ~ResXMLTree()
+//  {
+  @Override
+  protected void finalize() {
+    if (kDebugResXMLTree) {
+      ALOGI("Destroying ResXMLTree in %s #%d\n", this, gCount.getAndDecrement()-1);
+    }
+    uninit();
+  }
+
+  public int setTo(byte[] data, int size, boolean copyData)
+  {
+    uninit();
+    mParser.mEventCode = START_DOCUMENT;
+
+    if (!isTruthy(data) || !isTruthy(size)) {
+      return (mError=BAD_TYPE);
+    }
+
+    if (copyData) {
+      mOwnedData = new byte[size];
+//      if (mOwnedData == null) {
+//        return (mError=NO_MEMORY);
+//      }
+//      memcpy(mOwnedData, data, size);
+      System.arraycopy(data, 0, mOwnedData, 0, size);
+      data = mOwnedData;
+    }
+
+    mBuffer = new XmlBuffer(data);
+    mHeader = new ResXMLTree_header(mBuffer.buf, 0);
+    mSize = dtohl(mHeader.header.size);
+    if (dtohs(mHeader.header.headerSize) > mSize || mSize > size) {
+      ALOGW("Bad XML block: header size %d or total size %d is larger than data size %d\n",
+          (int)dtohs(mHeader.header.headerSize),
+          (int)dtohl(mHeader.header.size), (int)size);
+      mError = BAD_TYPE;
+      mParser.restart();
+      return mError;
+    }
+//    mDataEnd = ((final uint8_t*)mHeader) + mSize;
+    mDataLen = mSize;
+
+    mStrings.uninit();
+    mRootNode = null;
+    mResIds = null;
+    mNumResIds = 0;
+
+    // First look for a couple interesting chunks: the string block
+    // and first XML node.
+    ResChunk_header chunk =
+//      (final ResChunk_header*)(((final uint8_t*)mHeader) + dtohs(mHeader.header.headerSize));
+        new ResChunk_header(mBuffer.buf, mHeader.header.headerSize);
+
+    ResChunk_header lastChunk = chunk;
+    while (chunk.myOffset() /*((final uint8_t*)chunk)*/ < (mDataLen- ResChunk_header.SIZEOF /*sizeof(ResChunk_header)*/) &&
+        chunk.myOffset() /*((final uint8_t*)chunk)*/ < (mDataLen-dtohl(chunk.size))) {
+      int err = validate_chunk(chunk, ResChunk_header.SIZEOF /*sizeof(ResChunk_header)*/, mDataLen, "XML");
+      if (err != NO_ERROR) {
+        mError = err;
+//          goto done;
+        mParser.restart();
+        return mError;
+      }
+      final short type = dtohs(chunk.type);
+      final int size1 = dtohl(chunk.size);
+      if (kDebugXMLNoisy) {
+//        System.out.println(String.format("Scanning @ %s: type=0x%x, size=0x%zx\n",
+//            (void*)(((uintptr_t)chunk)-((uintptr_t)mHeader)), type, size1);
+      }
+      if (type == RES_STRING_POOL_TYPE) {
+        mStrings.setTo(mBuffer.buf, chunk.myOffset(), size, false);
+      } else if (type == RES_XML_RESOURCE_MAP_TYPE) {
+//        mResIds = (final int*)
+//        (((final uint8_t*)chunk)+dtohs(chunk.headerSize()));
+        mNumResIds = (dtohl(chunk.size)-dtohs(chunk.headerSize))/SIZEOF_INT /*sizeof(int)*/;
+        mResIds = new int[mNumResIds];
+        for (int i = 0; i < mNumResIds; i++) {
+          mResIds[i] = mBuffer.buf.getInt(chunk.myOffset() + chunk.headerSize + i * SIZEOF_INT);
+        }
+      } else if (type >= RES_XML_FIRST_CHUNK_TYPE
+          && type <= RES_XML_LAST_CHUNK_TYPE) {
+        if (validateNode(new ResXMLTree_node(mBuffer.buf, chunk)) != NO_ERROR) {
+          mError = BAD_TYPE;
+//          goto done;
+          mParser.restart();
+          return mError;
+        }
+        mParser.mCurNode = new ResXMLTree_node(mBuffer.buf, lastChunk.myOffset());
+        if (mParser.nextNode() == BAD_DOCUMENT) {
+          mError = BAD_TYPE;
+//          goto done;
+          mParser.restart();
+          return mError;
+        }
+        mRootNode = mParser.mCurNode;
+        mRootExt = mParser.mCurExt;
+        mRootCode = mParser.mEventCode;
+        break;
+      } else {
+        if (kDebugXMLNoisy) {
+          System.out.println("Skipping unknown chunk!\n");
+        }
+      }
+      lastChunk = chunk;
+//      chunk = (final ResChunk_header*)
+//      (((final uint8_t*)chunk) + size1);
+      chunk = new ResChunk_header(mBuffer.buf, chunk.myOffset() + size1);
+  }
+
+    if (mRootNode == null) {
+      ALOGW("Bad XML block: no root element node found\n");
+      mError = BAD_TYPE;
+//          goto done;
+      mParser.restart();
+      return mError;
+    }
+
+    mError = mStrings.getError();
+
+  done:
+    mParser.restart();
+    return mError;
+  }
+
+  public int getError()
+  {
+    return mError;
+  }
+
+  void uninit()
+  {
+    mError = NO_INIT;
+    mStrings.uninit();
+    if (isTruthy(mOwnedData)) {
+//      free(mOwnedData);
+      mOwnedData = null;
+    }
+    mParser.restart();
+  }
+
+  int validateNode(final ResXMLTree_node node)
+  {
+    final short eventCode = dtohs(node.header.type);
+
+    int err = validate_chunk(
+        node.header, SIZEOF_RESXMLTREE_NODE /*sizeof(ResXMLTree_node)*/,
+      mDataLen, "ResXMLTree_node");
+
+    if (err >= NO_ERROR) {
+      // Only perform additional validation on START nodes
+      if (eventCode != RES_XML_START_ELEMENT_TYPE) {
+        return NO_ERROR;
+      }
+
+        final short headerSize = dtohs(node.header.headerSize);
+        final int size = dtohl(node.header.size);
+//        final ResXMLTree_attrExt attrExt = (final ResXMLTree_attrExt*)
+//      (((final uint8_t*)node) + headerSize);
+      ResXMLTree_attrExt attrExt = new ResXMLTree_attrExt(mBuffer.buf, node.myOffset() + headerSize);
+      // check for sensical values pulled out of the stream so far...
+      if ((size >= headerSize + SIZEOF_RESXMLTREE_ATTR_EXT /*sizeof(ResXMLTree_attrExt)*/)
+          && (attrExt.myOffset() > node.myOffset())) {
+            final int attrSize = ((int)dtohs(attrExt.attributeSize))
+            * dtohs(attrExt.attributeCount);
+        if ((dtohs(attrExt.attributeStart)+attrSize) <= (size-headerSize)) {
+          return NO_ERROR;
+        }
+        ALOGW("Bad XML block: node attributes use 0x%x bytes, only have 0x%x bytes\n",
+            (int)(dtohs(attrExt.attributeStart)+attrSize),
+            (int)(size-headerSize));
+      }
+        else {
+        ALOGW("Bad XML start block: node header size 0x%x, size 0x%x\n",
+            (int)headerSize, (int)size);
+      }
+      return BAD_TYPE;
+    }
+
+    return err;
+
+//    if (false) {
+//      final boolean isStart = dtohs(node.header().type()) == RES_XML_START_ELEMENT_TYPE;
+//
+//      final short headerSize = dtohs(node.header().headerSize());
+//      final int size = dtohl(node.header().size());
+//
+//      if (headerSize >= (isStart ? sizeof(ResXMLTree_attrNode) : sizeof(ResXMLTree_node))) {
+//        if (size >= headerSize) {
+//          if ((( final uint8_t*)node) <=(mDataEnd - size)){
+//            if (!isStart) {
+//              return NO_ERROR;
+//            }
+//            if ((((int) dtohs(node.attributeSize)) * dtohs(node.attributeCount))
+//                <= (size - headerSize)) {
+//              return NO_ERROR;
+//            }
+//            ALOGW("Bad XML block: node attributes use 0x%x bytes, only have 0x%x bytes\n",
+//                ((int) dtohs(node.attributeSize)) * dtohs(node.attributeCount),
+//                (int) (size - headerSize));
+//            return BAD_TYPE;
+//          }
+//          ALOGW("Bad XML block: node at 0x%x extends beyond data end 0x%x\n",
+//              (int) ((( final uint8_t*)node)-(( final uint8_t*)mHeader)),(int) mSize);
+//          return BAD_TYPE;
+//        }
+//        ALOGW("Bad XML block: node at 0x%x header size 0x%x smaller than total size 0x%x\n",
+//            (int) ((( final uint8_t*)node)-(( final uint8_t*)mHeader)),
+//        (int) headerSize, (int) size);
+//        return BAD_TYPE;
+//      }
+//      ALOGW("Bad XML block: node at 0x%x header size 0x%x too small\n",
+//          (int) ((( final uint8_t*)node)-(( final uint8_t*)mHeader)),
+//      (int) headerSize);
+//      return BAD_TYPE;
+//    }
+  }
+
+  public ResStringPool getStrings() {
+    return mParser.getStrings();
+  }
+
+  static class XmlBuffer {
+    final ByteBuffer buf;
+
+    public XmlBuffer(byte[] data) {
+      this.buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResourceString.java b/resources/src/main/java/org/robolectric/res/android/ResourceString.java
new file mode 100644
index 0000000..b80bba5
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResourceString.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2016 Google Inc. 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 org.robolectric.res.android;
+
+import static java.nio.charset.StandardCharsets.UTF_16LE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.io.ByteArrayDataOutput;
+import com.google.common.io.ByteStreams;
+import com.google.common.primitives.UnsignedBytes;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+
+/** Provides utilities to decode/encode a String packed in an arsc resource file. */
+public final class ResourceString {
+
+  /** Type of {@link ResourceString} to encode / decode. */
+  public enum Type {
+    UTF8(UTF_8),
+    CESU8(Charset.forName("CESU8")),
+    UTF16(UTF_16LE);
+
+    private final Charset charset;
+
+    Type(Charset charset) {
+      this.charset = charset;
+    }
+
+    public Charset charset() {
+      return charset;
+    }
+
+    public CharsetDecoder decoder() {
+      return charset.newDecoder();
+    }
+  }
+
+  private ResourceString() {} // Private constructor
+
+  /**
+   * Given a buffer and an offset into the buffer, returns a String. The {@code offset} is the
+   * 0-based byte offset from the start of the buffer where the string resides. This should be the
+   * location in memory where the string's character count, followed by its byte count, and then
+   * followed by the actual string is located.
+   *
+   * <p>Here's an example UTF-8-encoded string of ab©:
+   *
+   * <pre>
+   * 03 04 61 62 C2 A9 00
+   * ^ Offset should be here
+   * </pre>
+   *
+   * @param buffer The buffer containing the string to decode.
+   * @param offset Offset into the buffer where the string resides.
+   * @param type The encoding type that the {@link ResourceString} is encoded in.
+   * @return The decoded string.
+   */
+  @SuppressWarnings("ByteBufferBackingArray")
+  public static String decodeString(ByteBuffer buffer, int offset, Type type) {
+    int length;
+    int characterCount = decodeLength(buffer, offset, type);
+    offset += computeLengthOffset(characterCount, type);
+    // UTF-8 strings have 2 lengths: the number of characters, and then the encoding length.
+    // UTF-16 strings, however, only have 1 length: the number of characters.
+    if (type == Type.UTF8) {
+      length = decodeLength(buffer, offset, type);
+      offset += computeLengthOffset(length, type);
+    } else {
+      length = characterCount * 2;
+    }
+    ByteBuffer stringBuffer = ByteBuffer.wrap(buffer.array(), offset, length);
+    // Use normal UTF-8 and UTF-16 decoder to decode string
+    try {
+      return type.decoder().decode(stringBuffer).toString();
+    } catch (CharacterCodingException e) {
+      if (type == Type.UTF16) {
+        return null;
+      }
+    }
+    stringBuffer = ByteBuffer.wrap(buffer.array(), offset, length);
+    // Use CESU8 decoder to try decode failed UTF-8 string, especially modified UTF-8.
+    // See
+    // https://source.android.com/devices/tech/dalvik/dex-format?hl=hr-HR&skip_cache=true#mutf-8.
+    try {
+      return Type.CESU8.decoder().decode(stringBuffer).toString();
+    } catch (CharacterCodingException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Encodes a string in either UTF-8 or UTF-16 and returns the bytes of the encoded string. Strings
+   * are prefixed by 2 values. The first is the number of characters in the string. The second is
+   * the encoding length (number of bytes in the string).
+   *
+   * <p>Here's an example UTF-8-encoded string of ab©:
+   *
+   * <pre>03 04 61 62 C2 A9 00</pre>
+   *
+   * @param str The string to be encoded.
+   * @param type The encoding type that the {@link ResourceString} should be encoded in.
+   * @return The encoded string.
+   */
+  public static byte[] encodeString(String str, Type type) {
+    byte[] bytes = str.getBytes(type.charset());
+    // The extra 5 bytes is for metadata (character count + byte count) and the NULL terminator.
+    ByteArrayDataOutput output = ByteStreams.newDataOutput(bytes.length + 5);
+    encodeLength(output, str.length(), type);
+    if (type == Type.UTF8) { // Only UTF-8 strings have the encoding length.
+      encodeLength(output, bytes.length, type);
+    }
+    output.write(bytes);
+    // NULL-terminate the string
+    if (type == Type.UTF8) {
+      output.write(0);
+    } else {
+      output.writeShort(0);
+    }
+    return output.toByteArray();
+  }
+
+  /**
+   * Builds a string from a null-terminated char data.
+   */
+  public static String buildString(char[] data) {
+    int count = 0;
+    for (count=0; count < data.length; count++) {
+      if (data[count] == 0) {
+        break;
+      }
+    }
+    return new String(data, 0, count);
+  }
+
+  private static void encodeLength(ByteArrayDataOutput output, int length, Type type) {
+    if (length < 0) {
+      output.write(0);
+      return;
+    }
+    if (type == Type.UTF8) {
+      if (length > 0x7F) {
+        output.write(((length & 0x7F00) >> 8) | 0x80);
+      }
+      output.write(length & 0xFF);
+    } else {  // UTF-16
+      // TODO(acornwall): Replace output with a little-endian output.
+      if (length > 0x7FFF) {
+        int highBytes = ((length & 0x7FFF0000) >> 16) | 0x8000;
+        output.write(highBytes & 0xFF);
+        output.write((highBytes & 0xFF00) >> 8);
+      }
+      int lowBytes = length & 0xFFFF;
+      output.write(lowBytes & 0xFF);
+      output.write((lowBytes & 0xFF00) >> 8);
+    }
+  }
+
+  static int computeLengthOffset(int length, Type type) {
+    return (type == Type.UTF8 ? 1 : 2) * (length >= (type == Type.UTF8 ? 0x80 : 0x8000) ? 2 : 1);
+  }
+
+  static int decodeLength(ByteBuffer buffer, int offset, Type type) {
+    return type == Type.UTF8 ? decodeLengthUTF8(buffer, offset) : decodeLengthUTF16(buffer, offset);
+  }
+
+  static int decodeLengthUTF8(ByteBuffer buffer, int offset) {
+    // UTF-8 strings use a clever variant of the 7-bit integer for packing the string length.
+    // If the first byte is >= 0x80, then a second byte follows. For these values, the length
+    // is WORD-length in big-endian & 0x7FFF.
+    int length = UnsignedBytes.toInt(buffer.get(offset));
+    if ((length & 0x80) != 0) {
+      length = ((length & 0x7F) << 8) | UnsignedBytes.toInt(buffer.get(offset + 1));
+    }
+    return length;
+  }
+
+  static int decodeLengthUTF16(ByteBuffer buffer, int offset) {
+    // UTF-16 strings use a clever variant of the 7-bit integer for packing the string length.
+    // If the first word is >= 0x8000, then a second word follows. For these values, the length
+    // is DWORD-length in big-endian & 0x7FFFFFFF.
+    int length = (buffer.getShort(offset) & 0xFFFF);
+    if ((length & 0x8000) != 0) {
+      length = ((length & 0x7FFF) << 16) | (buffer.getShort(offset + 2) & 0xFFFF);
+    }
+    return length;
+  }
+}
\ No newline at end of file
diff --git a/resources/src/main/java/org/robolectric/res/android/ResourceTable.java b/resources/src/main/java/org/robolectric/res/android/ResourceTable.java
new file mode 100644
index 0000000..c691157
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResourceTable.java
@@ -0,0 +1,47 @@
+package org.robolectric.res.android;
+
+import org.robolectric.res.android.ResourceTypes.ResTable_map;
+
+public class ResourceTable {
+  public static class flag_entry
+  {
+    public final String name;
+    public final int value;
+    public final String description;
+
+    public flag_entry(String name, int value, String description) {
+      this.name = name;
+      this.value = value;
+      this.description = description;
+    }
+  };
+
+  public static flag_entry[] gFormatFlags = {
+      new flag_entry("reference", ResTable_map.TYPE_REFERENCE,
+          "a reference to another resource, in the form \"<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>\"\n"
+          + "or to a theme attribute in the form \"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>\"."),
+      new flag_entry("string", ResTable_map.TYPE_STRING,
+          "a string value, using '\\\\;' to escape characters such as '\\\\n' or '\\\\uxxxx' for a unicode character."),
+      new flag_entry("integer", ResTable_map.TYPE_INTEGER,
+          "an integer value, such as \"<code>100</code>\"."),
+      new flag_entry("boolean", ResTable_map.TYPE_BOOLEAN,
+          "a boolean value, either \"<code>true</code>\" or \"<code>false</code>\"."),
+      new flag_entry("color", ResTable_map.TYPE_COLOR,
+          "a color value, in the form of \"<code>#<i>rgb</i></code>\", \"<code>#<i>argb</i></code>\",\n"
+          + "\"<code>#<i>rrggbb</i></code>\", or \"<code>#<i>aarrggbb</i></code>\"."),
+      new flag_entry("float", ResTable_map.TYPE_FLOAT,
+          "a floating point value, such as \"<code>1.2</code>\"."),
+      new flag_entry("dimension", ResTable_map.TYPE_DIMENSION,
+          "a dimension value, which is a floating point number appended with a unit such as \"<code>14.5sp</code>\".\n"
+          + "Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),\n"
+          + "in (inches), mm (millimeters)."),
+      new flag_entry("fraction", ResTable_map.TYPE_FRACTION,
+          "a fractional value, which is a floating point number appended with either % or %p, such as \"<code>14.5%</code>\".\n"
+          + "The % suffix always means a percentage of the base size; the optional %p suffix provides a size relative to\n"
+          + "some parent container."),
+      new flag_entry("enum", ResTable_map.TYPE_ENUM, null),
+      new flag_entry("flags", ResTable_map.TYPE_FLAGS, null)
+      // new flag_entry(null, 0, null)
+  };
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResourceTypes.java b/resources/src/main/java/org/robolectric/res/android/ResourceTypes.java
new file mode 100644
index 0000000..7810144
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResourceTypes.java
@@ -0,0 +1,1603 @@
+package org.robolectric.res.android;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.res.android.Errors.BAD_TYPE;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.SIZEOF_INT;
+import static org.robolectric.res.android.Util.SIZEOF_SHORT;
+import static org.robolectric.res.android.Util.dtohl;
+import static org.robolectric.res.android.Util.dtohs;
+
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_header.Writer;
+
+// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceTypes.cpp
+//   and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h
+public class ResourceTypes {
+  public static final String ANDROID_NS = "http://schemas.android.com/apk/res/android";
+  public static final String AUTO_NS = "http://schemas.android.com/apk/res-auto";
+
+  static final int kIdmapMagic = 0x504D4449;
+  static final int kIdmapCurrentVersion = 0x00000001;
+
+  static int validate_chunk(ResChunk_header chunk,
+      int minSize,
+      int dataLen,
+      String name)
+  {
+    final short headerSize = dtohs(chunk.headerSize);
+    final int size = dtohl(chunk.size);
+
+    if (headerSize >= minSize) {
+      if (headerSize <= size) {
+        if (((headerSize|size)&0x3) == 0) {
+          if (size <= dataLen) {
+            return NO_ERROR;
+          }
+          ALOGW("%s data size 0x%x extends beyond resource end.",
+              name, size /*, (dataEnd-((const uint8_t*)chunk))*/);
+          return BAD_TYPE;
+        }
+        ALOGW("%s size 0x%x or headerSize 0x%x is not on an integer boundary.",
+            name, (int)size, (int)headerSize);
+        return BAD_TYPE;
+      }
+      ALOGW("%s size 0x%x is smaller than header size 0x%x.",
+          name, size, headerSize);
+      return BAD_TYPE;
+    }
+    ALOGW("%s header size 0x%04x is too small.",
+        name, headerSize);
+    return BAD_TYPE;
+  }
+
+  static class WithOffset {
+    private final ByteBuffer buf;
+    private final int offset;
+
+    WithOffset(ByteBuffer buf, int offset) {
+      this.buf = buf;
+      this.offset = offset;
+    }
+
+    public final ByteBuffer myBuf() {
+      return buf;
+    }
+
+    public final int myOffset() {
+      return offset;
+    }
+
+    @Override
+    public String toString() {
+      return "{buf+" + offset + '}';
+    }
+  }
+
+  /** ********************************************************************
+   *  Base Types
+   *
+   *  These are standard types that are shared between multiple specific
+   *  resource types.
+   *
+   *********************************************************************** */
+
+  /**
+   * Header that appears at the front of every data chunk in a resource.
+   */
+  public static class ResChunk_header extends WithOffset
+  {
+    static int SIZEOF = 8;
+
+    // Type identifier for this chunk.  The meaning of this value depends
+    // on the containing chunk.
+    final short type;
+
+    // Size of the chunk header (in bytes).  Adding this value to
+    // the address of the chunk allows you to find its associated data
+    // (if any).
+    final short headerSize;
+
+    // Total size of this chunk (in bytes).  This is the chunkSize plus
+    // the size of any data associated with the chunk.  Adding this value
+    // to the chunk allows you to completely skip its contents (including
+    // any child chunks).  If this value is the same as chunkSize, there is
+    // no data associated with the chunk.
+    final int size;
+
+    public ResChunk_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+      this.type = buf.getShort(offset);
+      this.headerSize = buf.getShort(offset + 2);
+      this.size = buf.getInt(offset + 4);
+    }
+
+    public static void write(ByteBuffer buf, short type, Runnable header, Runnable contents) {
+      int startPos = buf.position();
+      buf.putShort(type);
+      ShortWriter headerSize = new ShortWriter(buf);
+      IntWriter size = new IntWriter(buf);
+
+      header.run();
+      headerSize.write((short) (buf.position() - startPos));
+
+      contents.run();
+
+      // pad to next int boundary
+      int len = buf.position() - startPos;
+      while ((len & 0x3) != 0) {
+        buf.put((byte) 0);
+        len++;
+      }
+      size.write(len);
+    }
+  }
+
+  public static final int RES_NULL_TYPE               = 0x0000;
+  public static final int RES_STRING_POOL_TYPE        = 0x0001;
+  public static final int RES_TABLE_TYPE              = 0x0002;
+  public static final int RES_XML_TYPE                = 0x0003;
+
+  // Chunk types in RES_XML_TYPE
+  public static final int RES_XML_FIRST_CHUNK_TYPE    = 0x0100;
+  public static final int RES_XML_START_NAMESPACE_TYPE= 0x0100;
+  public static final int RES_XML_END_NAMESPACE_TYPE  = 0x0101;
+  public static final int RES_XML_START_ELEMENT_TYPE  = 0x0102;
+  public static final int RES_XML_END_ELEMENT_TYPE    = 0x0103;
+  public static final int RES_XML_CDATA_TYPE          = 0x0104;
+  public static final int RES_XML_LAST_CHUNK_TYPE     = 0x017f;
+  // This contains a uint32_t array mapping strings in the string
+  // pool back to resource identifiers.  It is optional.
+  public static final int RES_XML_RESOURCE_MAP_TYPE   = 0x0180;
+
+  // Chunk types in RES_TABLE_TYPE
+  public static final int RES_TABLE_PACKAGE_TYPE      = 0x0200;
+  public static final int RES_TABLE_TYPE_TYPE         = 0x0201;
+  public static final int RES_TABLE_TYPE_SPEC_TYPE    = 0x0202;
+  public static final int RES_TABLE_LIBRARY_TYPE      = 0x0203;
+
+  /**
+   * Macros for building/splitting resource identifiers.
+   */
+//#define Res_VALIDID(resid) (resid != 0)
+//#define Res_CHECKID(resid) ((resid&0xFFFF0000) != 0)
+//#define Res_MAKEID(package, type, entry) \
+//(((package+1)<<24) | (((type+1)&0xFF)<<16) | (entry&0xFFFF))
+//#define Res_GETPACKAGE(id) ((id>>24)-1)
+//#define Res_GETTYPE(id) (((id>>16)&0xFF)-1)
+//#define Res_GETENTRY(id) (id&0xFFFF)
+
+//#define Res_INTERNALID(resid) ((resid&0xFFFF0000) != 0 && (resid&0xFF0000) == 0)
+  private static int Res_MAKEINTERNAL(int entry) {
+    return (0x01000000 | (entry & 0xFFFF));
+  }
+//#define Res_MAKEARRAY(entry) (0x02000000 | (entry&0xFFFF))
+
+//  static const size_t Res_MAXPACKAGE = 255;
+//  static const size_t Res_MAXTYPE = 255;
+
+  /**
+   * Representation of a value in a resource, supplying type
+   * information.
+   */
+  public static class Res_value
+  {
+    static final int SIZEOF = 8;
+
+    // Number of bytes in this structure.
+    final short size;
+
+    // Always set to 0.
+//    byte res0;
+
+    // Type of the data value.
+//    enum {
+    // The 'data' is either 0 or 1, specifying this resource is either
+    // undefined or empty, respectively.
+    public static final int TYPE_NULL = 0x00;
+    // The 'data' holds a ResTable_ref, a reference to another resource
+    // table entry.
+    public static final int TYPE_REFERENCE = 0x01;
+    // The 'data' holds an attribute resource identifier.
+    public static final int TYPE_ATTRIBUTE = 0x02;
+    // The 'data' holds an index into the containing resource table's
+    // global value string pool.
+    public static final int TYPE_STRING = 0x03;
+    // The 'data' holds a single-precision floating point number.
+    public static final int TYPE_FLOAT = 0x04;
+    // The 'data' holds a complex number encoding a dimension value,
+    // such as "100in".
+    public static final int TYPE_DIMENSION = 0x05;
+    // The 'data' holds a complex number encoding a fraction of a
+    // container.
+    public static final int TYPE_FRACTION = 0x06;
+    // The 'data' holds a dynamic ResTable_ref, which needs to be
+    // resolved before it can be used like a TYPE_REFERENCE.
+    public static final int TYPE_DYNAMIC_REFERENCE = 0x07;
+    // The 'data' holds an attribute resource identifier, which needs to be resolved
+    // before it can be used like a TYPE_ATTRIBUTE.
+    public static final int TYPE_DYNAMIC_ATTRIBUTE = 0x08;
+
+    // Beginning of integer flavors...
+    public static final int TYPE_FIRST_INT = 0x10;
+
+    // The 'data' is a raw integer value of the form n..n.
+    public static final int TYPE_INT_DEC = 0x10;
+    // The 'data' is a raw integer value of the form 0xn..n.
+    public static final int TYPE_INT_HEX = 0x11;
+    // The 'data' is either 0 or 1, for input "false" or "true" respectively.
+    public static final int TYPE_INT_BOOLEAN = 0x12;
+
+    // Beginning of color integer flavors...
+    public static final int TYPE_FIRST_COLOR_INT = 0x1c;
+
+    // The 'data' is a raw integer value of the form #aarrggbb.
+    public static final int TYPE_INT_COLOR_ARGB8 = 0x1c;
+    // The 'data' is a raw integer value of the form #rrggbb.
+    public static final int TYPE_INT_COLOR_RGB8 = 0x1d;
+    // The 'data' is a raw integer value of the form #argb.
+    public static final int TYPE_INT_COLOR_ARGB4 = 0x1e;
+    // The 'data' is a raw integer value of the form #rgb.
+    public static final int TYPE_INT_COLOR_RGB4 = 0x1f;
+
+    // ...end of integer flavors.
+    public static final int TYPE_LAST_COLOR_INT = 0x1f;
+
+    // ...end of integer flavors.
+    public static final int TYPE_LAST_INT = 0x1f;
+//  };
+
+    public final byte dataType;
+
+    // Structure of complex data values (TYPE_UNIT and TYPE_FRACTION)
+//    enum {
+    // Where the unit type information is.  This gives us 16 possible
+    // types, as defined below.
+    public static final int COMPLEX_UNIT_SHIFT = 0;
+    public static final int COMPLEX_UNIT_MASK = 0xf;
+
+    // TYPE_DIMENSION: Value is raw pixels.
+    public static final int COMPLEX_UNIT_PX = 0;
+    // TYPE_DIMENSION: Value is Device Independent Pixels.
+    public static final int COMPLEX_UNIT_DIP = 1;
+    // TYPE_DIMENSION: Value is a Scaled device independent Pixels.
+    public static final int COMPLEX_UNIT_SP = 2;
+    // TYPE_DIMENSION: Value is in points.
+    public static final int COMPLEX_UNIT_PT = 3;
+    // TYPE_DIMENSION: Value is in inches.
+    public static final int COMPLEX_UNIT_IN = 4;
+    // TYPE_DIMENSION: Value is in millimeters.
+    public static final int COMPLEX_UNIT_MM = 5;
+
+    // TYPE_FRACTION: A basic fraction of the overall size.
+    public static final int COMPLEX_UNIT_FRACTION = 0;
+    // TYPE_FRACTION: A fraction of the parent size.
+    public static final int COMPLEX_UNIT_FRACTION_PARENT = 1;
+
+    // Where the radix information is, telling where the decimal place
+    // appears in the mantissa.  This give us 4 possible fixed point
+    // representations as defined below.
+    public static final int COMPLEX_RADIX_SHIFT = 4;
+    public static final int COMPLEX_RADIX_MASK = 0x3;
+
+    // The mantissa is an integral number -- i.e., 0xnnnnnn.0
+    public static final int COMPLEX_RADIX_23p0 = 0;
+    // The mantissa magnitude is 16 bits -- i.e, 0xnnnn.nn
+    public static final int COMPLEX_RADIX_16p7 = 1;
+    // The mantissa magnitude is 8 bits -- i.e, 0xnn.nnnn
+    public static final int COMPLEX_RADIX_8p15 = 2;
+    // The mantissa magnitude is 0 bits -- i.e, 0x0.nnnnnn
+    public static final int COMPLEX_RADIX_0p23 = 3;
+
+    // Where the actual value is.  This gives us 23 bits of
+    // precision.  The top bit is the sign.
+    public static final int COMPLEX_MANTISSA_SHIFT = 8;
+    public static final int COMPLEX_MANTISSA_MASK = 0xffffff;
+//  };
+
+    // Possible data values for TYPE_NULL.
+//    enum {
+    // The value is not defined.
+    public static final int DATA_NULL_UNDEFINED = 0;
+    // The value is explicitly defined as empty.
+    public static final int DATA_NULL_EMPTY = 1;
+//  };
+
+    public static final Res_value NULL_VALUE = new Res_value((byte) TYPE_NULL, DATA_NULL_UNDEFINED);
+
+    // The data for this item, as interpreted according to dataType.
+//    typedef uint32_t data_type;
+    public final int data;
+
+    public Res_value() {
+      this.size = 0;
+//      this.res0 = 0;
+      this.dataType = 0;
+      this.data = 0;
+    }
+
+    public Res_value(ByteBuffer buf, int offset) {
+      this.size = buf.getShort(offset);
+      byte res0 = buf.get(offset + 2);
+      this.dataType = buf.get(offset + 3);
+      this.data = buf.getInt(offset + 4);
+
+      if (res0 != 0) {
+        throw new IllegalStateException("res0 != 0 (" + res0 + ")");
+      }
+    }
+
+    public Res_value(Res_value other) {
+      this.size = other.size;
+//      this.res0 = other.res0;
+      this.dataType = other.dataType;
+      this.data = other.data;
+    }
+
+    public Res_value(byte dataType, int data) {
+      this.size = 0;
+//      this.res0 = 0;
+      this.dataType = dataType;
+      this.data = data;
+    }
+
+    public static void write(ByteBuffer buf, int dataType, int data) {
+      buf.putShort((short) SIZEOF); // size
+      buf.put((byte) 0); // res0
+      buf.put((byte) dataType); // dataType
+      buf.putInt(data); // data
+    }
+
+    public Res_value withType(byte dataType) {
+      return new Res_value(dataType, data);
+    }
+
+    public Res_value withData(int data) {
+      return new Res_value(dataType, data);
+    }
+
+//    public void copyFrom_dtoh(Res_value other) {
+//      this.size = other.size;
+// //      this.res0 = other.res0;
+//      this.dataType = other.dataType;
+//      this.data = other.data;
+//    }
+
+    public Res_value copy() {
+      return new Res_value(this);
+    }
+
+    @Override
+    public String toString() {
+      return "Res_value{dataType=" + dataType + ", data=" + data + '}';
+    }
+  }
+
+/**
+ *  This is a reference to a unique entry (a ResTable_entry structure)
+ *  in a resource table.  The value is structured as: 0xpptteeee,
+ *  where pp is the package index, tt is the type index in that
+ *  package, and eeee is the entry index in that type.  The package
+ *  and type values start at 1 for the first item, to help catch cases
+ *  where they have not been supplied.
+ */
+public static class ResTable_ref
+    {
+      public static final int SIZEOF = 4;
+
+      public int ident;
+
+      public ResTable_ref(ByteBuffer buf, int offset) {
+        ident = buf.getInt(offset);
+      }
+
+      public ResTable_ref() {
+        ident = 0;
+      }
+
+      @Override
+      public String toString() {
+        return "ResTable_ref{ident=" + ident + '}';
+      }
+    };
+
+  /**
+   * Reference to a string in a string pool.
+   */
+  public static class ResStringPool_ref
+  {
+    public static final int SIZEOF = 4;
+
+    // Index into the string pool table (uint32_t-offset from the indices
+    // immediately after ResStringPool_header) at which to find the location
+    // of the string data in the pool.
+    public final int index;
+
+    public ResStringPool_ref(ByteBuffer buf, int offset) {
+      this.index = buf.getInt(offset);
+    }
+
+    public static void write(ByteBuffer buf, int value) {
+      buf.putInt(value);
+    }
+
+    @Override
+    public String toString() {
+      return "ResStringPool_ref{index=" + index + '}';
+    }
+  }
+
+/** ********************************************************************
+ *  String Pool
+ *
+ *  A set of strings that can be references by others through a
+ *  ResStringPool_ref.
+ *
+ *********************************************************************** */
+
+
+/**
+   * Definition for a pool of strings.  The data of this chunk is an
+   * array of uint32_t providing indices into the pool, relative to
+   * stringsStart.  At stringsStart are all of the UTF-16 strings
+   * concatenated together; each starts with a uint16_t of the string's
+   * length and each ends with a 0x0000 terminator.  If a string is >
+   * 32767 characters, the high bit of the length is set meaning to take
+   * those 15 bits as a high word and it will be followed by another
+   * uint16_t containing the low word.
+   *
+   * If styleCount is not zero, then immediately following the array of
+   * uint32_t indices into the string table is another array of indices
+   * into a style table starting at stylesStart.  Each entry in the
+   * style table is an array of ResStringPool_span structures.
+   */
+  public static class ResStringPool_header extends WithOffset
+  {
+    public static final int SIZEOF = ResChunk_header.SIZEOF + 20;
+
+    final ResChunk_header header;
+
+    // Number of strings in this pool (number of uint32_t indices that follow
+    // in the data).
+    final int stringCount;
+
+    // Number of style span arrays in the pool (number of uint32_t indices
+    // follow the string indices).
+    final int styleCount;
+
+    // Flags.
+//    enum {
+    // If set, the string index is sorted by the string values (based
+    // on strcmp16()).
+    public static final int SORTED_FLAG = 1<<0;
+
+        // String pool is encoded in UTF-8
+        public static final int UTF8_FLAG = 1<<8;
+//  };
+    final int flags;
+
+    // Index from header of the string data.
+    final int stringsStart;
+
+    // Index from header of the style data.
+    final int stylesStart;
+
+    public ResStringPool_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      this.header = new ResChunk_header(buf, offset);
+      this.stringCount = buf.getInt(offset + ResChunk_header.SIZEOF);
+      this.styleCount = buf.getInt(offset + ResChunk_header.SIZEOF + 4);
+      this.flags = buf.getInt(offset + ResChunk_header.SIZEOF + 8);
+      this.stringsStart = buf.getInt(offset + ResChunk_header.SIZEOF + 12);
+      this.stylesStart = buf.getInt(offset + ResChunk_header.SIZEOF + 16);
+    }
+
+    public int getByte(int i) {
+      return myBuf().get(myOffset() + i);
+    }
+
+    public int getShort(int i) {
+      return myBuf().getShort(myOffset() + i);
+    }
+
+    public static class Writer {
+
+      private final List<String> strings = new ArrayList<>();
+      private final List<byte[]> stringsAsBytes = new ArrayList<>();
+      private final Map<String, Integer> stringIds = new HashMap<>();
+
+      private boolean frozen;
+
+      public int string(String s) {
+        if (frozen) {
+          throw new IllegalStateException("string pool is frozen!");
+        }
+
+        if (s == null) {
+          return -1;
+        }
+
+        Integer id = stringIds.get(s);
+        if (id == null) {
+          id = strings.size();
+          strings.add(s);
+          stringsAsBytes.add(s.getBytes(UTF_8));
+          stringIds.put(s, id);
+        }
+        return id;
+      }
+
+      public int uniqueString(String s) {
+        if (frozen) {
+          throw new IllegalStateException("string pool is frozen!");
+        }
+
+        if (s == null) {
+          return -1;
+        }
+
+        int id = strings.size();
+        strings.add(s);
+        stringsAsBytes.add(s.getBytes(UTF_8));
+        return id;
+      }
+
+      public void write(ByteBuffer buf) {
+        freeze();
+
+        ResChunk_header.write(buf, (short) RES_STRING_POOL_TYPE, () -> {
+          // header
+          int startPos = buf.position();
+          int stringCount = strings.size();
+
+          // begin string pool...
+          buf.putInt(stringCount); // stringCount
+          buf.putInt(0); // styleCount
+          buf.putInt(UTF8_FLAG); // flags
+          IntWriter stringStart = new IntWriter(buf);
+          buf.putInt(0); // stylesStart
+
+          stringStart.write(buf.position() - startPos);
+        }, () -> {
+          // contents
+          int stringOffset = /*buf.position() + */8 + 4 * stringsAsBytes.size();
+          for (int i = 0; i < stringsAsBytes.size(); i++) {
+            String string = strings.get(i);
+            byte[] bytes = stringsAsBytes.get(i);
+            buf.putInt(stringOffset);
+            stringOffset += lenLen(string.length()) + lenLen(bytes.length) + bytes.length + 1;
+          }
+
+          for (int i = 0; i < stringsAsBytes.size(); i++) {
+            // number of chars
+            writeLen(buf, strings.get(i).length());
+
+            // number of bytes
+            writeLen(buf, stringsAsBytes.get(i).length);
+
+            // bytes
+            buf.put(stringsAsBytes.get(i));
+            // null terminator
+            buf.put((byte) '\0');
+          }
+        });
+      }
+
+      private int lenLen(int length) {
+        return length > 0x7f ? 2 : 1;
+      }
+
+      private void writeLen(ByteBuffer buf, int length) {
+        if (length <= 0x7f) {
+          buf.put((byte) length);
+        } else {
+          buf.put((byte) ((length >> 8) | 0x80));
+          buf.put((byte) (length & 0x7f));
+        }
+      }
+
+      public void freeze() {
+        frozen = true;
+      }
+    }
+  }
+
+  /**
+   * This structure defines a span of style information associated with
+   * a string in the pool.
+   */
+  public static class ResStringPool_span extends WithOffset
+  {
+    public static final int SIZEOF = ResStringPool_ref.SIZEOF + 8;
+
+    //    enum {
+    public static final int END = 0xFFFFFFFF;
+//  };
+
+    // This is the name of the span -- that is, the name of the XML
+    // tag that defined it.  The special value END (0xFFFFFFFF) indicates
+    // the end of an array of spans.
+    public final ResStringPool_ref name;
+
+    // The range of characters in the string that this span applies to.
+    final int firstChar;
+    final int lastChar;
+
+    public ResStringPool_span(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      name = new ResStringPool_ref(buf, offset);
+      firstChar = buf.getInt(offset + ResStringPool_ref.SIZEOF);
+      lastChar = buf.getInt(offset + ResStringPool_ref.SIZEOF + 4);
+    }
+
+    public boolean isEnd() {
+      return name.index == END && firstChar == END && lastChar == END;
+    }
+  };
+
+
+  /** ********************************************************************
+   *  XML Tree
+   *
+   *  Binary representation of an XML document.  This is designed to
+   *  express everything in an XML document, in a form that is much
+   *  easier to parse on the device.
+   *
+   *********************************************************************** */
+
+  /**
+   * XML tree header.  This appears at the front of an XML tree,
+   * describing its content.  It is followed by a flat array of
+   * ResXMLTree_node structures; the hierarchy of the XML document
+   * is described by the occurrance of RES_XML_START_ELEMENT_TYPE
+   * and corresponding RES_XML_END_ELEMENT_TYPE nodes in the array.
+   */
+  public static class ResXMLTree_header extends WithOffset
+  {
+    public final ResChunk_header header;
+
+    ResXMLTree_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+      header = new ResChunk_header(buf, offset);
+    }
+
+    public static void write(ByteBuffer buf, Writer resStringPoolWriter, Runnable contents) {
+      ResChunk_header.write(buf, (short) RES_XML_TYPE, ()-> {}, () -> {
+        resStringPoolWriter.write(buf);
+        contents.run();
+      });
+    }
+  }
+
+  /**
+   * Basic XML tree node.  A single item in the XML document.  Extended info
+   * about the node can be found after header.headerSize.
+   */
+  public static class ResXMLTree_node extends WithOffset
+  {
+    final ResChunk_header header;
+
+    // Line number in original source file at which this element appeared.
+    final int lineNumber;
+
+    // Optional XML comment that was associated with this element; -1 if none.
+    final ResStringPool_ref comment;
+
+    ResXMLTree_node(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      this.header = new ResChunk_header(buf, offset);
+      this.lineNumber = buf.getInt(offset + ResChunk_header.SIZEOF);
+      this.comment = new ResStringPool_ref(buf, offset + 12);
+    }
+
+    ResXMLTree_node(ByteBuffer buf, ResChunk_header header) {
+      super(buf, header.myOffset());
+
+      this.header = header;
+      this.lineNumber = buf.getInt(myOffset() + ResChunk_header.SIZEOF);
+      this.comment = new ResStringPool_ref(buf, myOffset() + ResChunk_header.SIZEOF + 4);
+    }
+
+    public static void write(ByteBuffer buf, int type, Runnable contents) {
+      ResChunk_header.write(buf, (short) type, () -> {
+        buf.putInt(-1); // lineNumber
+        ResStringPool_ref.write(buf, -1); // comment
+      }, contents);
+    }
+  };
+
+  /**
+   * Extended XML tree node for CDATA tags -- includes the CDATA string.
+   * Appears header.headerSize bytes after a ResXMLTree_node.
+   */
+  static class ResXMLTree_cdataExt
+  {
+    // The raw CDATA character data.
+    final ResStringPool_ref data;
+
+    // The typed value of the character data if this is a CDATA node.
+    final Res_value typedData;
+
+    public ResXMLTree_cdataExt(ByteBuffer buf, int offset) {
+      this.data = new ResStringPool_ref(buf, offset);
+
+      int dataType = buf.getInt(offset + 4);
+      int data = buf.getInt(offset + 8);
+      this.typedData = new Res_value((byte) dataType, data);
+    }
+  };
+
+  /**
+   * Extended XML tree node for namespace start/end nodes.
+   * Appears header.headerSize bytes after a ResXMLTree_node.
+   */
+  static class ResXMLTree_namespaceExt
+  {
+    // The prefix of the namespace.
+    final ResStringPool_ref prefix;
+
+    // The URI of the namespace.
+    final ResStringPool_ref uri;
+
+    public ResXMLTree_namespaceExt(ByteBuffer buf, int offset) {
+      this.prefix = new ResStringPool_ref(buf, offset);
+      this.uri = new ResStringPool_ref(buf, offset + 4);
+    }
+  };
+
+  /**
+   * Extended XML tree node for element start/end nodes.
+   * Appears header.headerSize bytes after a ResXMLTree_node.
+   */
+  public static class ResXMLTree_endElementExt
+  {
+    static final int SIZEOF = 8;
+
+    // String of the full namespace of this element.
+    final ResStringPool_ref ns;
+
+    // String name of this node if it is an ELEMENT; the raw
+    // character data if this is a CDATA node.
+    final ResStringPool_ref name;
+
+    public ResXMLTree_endElementExt(ByteBuffer buf, int offset) {
+      this.ns = new ResStringPool_ref(buf, offset);
+      this.name = new ResStringPool_ref(buf, offset + ResStringPool_ref.SIZEOF);
+    }
+
+    public static class Writer {
+      private final ByteBuffer buf;
+      private final int ns;
+      private final int name;
+
+      public Writer(ByteBuffer buf, ResStringPool_header.Writer resStringPoolWriter,
+          String ns, String name) {
+        this.buf = buf;
+        this.ns = resStringPoolWriter.string(ns);
+        this.name = resStringPoolWriter.string(name);
+      }
+
+      public void write() {
+        ResStringPool_ref.write(buf, ns);
+        ResStringPool_ref.write(buf, name);
+      }
+    }
+  };
+
+  /**
+   * Extended XML tree node for start tags -- includes attribute
+   * information.
+   * Appears header.headerSize bytes after a ResXMLTree_node.
+   */
+  public static class ResXMLTree_attrExt extends WithOffset
+  {
+    private final ByteBuffer buf;
+
+    // String of the full namespace of this element.
+    final ResStringPool_ref ns;
+
+    // String name of this node if it is an ELEMENT; the raw
+    // character data if this is a CDATA node.
+    final ResStringPool_ref name;
+
+    // Byte offset from the start of this structure where the attributes start.
+    final short attributeStart;
+
+    // Size of the ResXMLTree_attribute structures that follow.
+    final short attributeSize;
+
+    // Number of attributes associated with an ELEMENT.  These are
+    // available as an array of ResXMLTree_attribute structures
+    // immediately following this node.
+    final short attributeCount;
+
+    // Index (1-based) of the "id" attribute. 0 if none.
+    final short idIndex;
+
+    // Index (1-based) of the "class" attribute. 0 if none.
+    final short classIndex;
+
+    // Index (1-based) of the "style" attribute. 0 if none.
+    final short styleIndex;
+
+    public ResXMLTree_attrExt(ByteBuffer buf, int offset) {
+      super(buf, offset);
+      this.buf = buf;
+
+      this.ns = new ResStringPool_ref(buf, offset);
+      this.name = new ResStringPool_ref(buf, offset + 4);
+      this.attributeStart = buf.getShort(offset + 8);
+      this.attributeSize = buf.getShort(offset + 10);
+      this.attributeCount = buf.getShort(offset + 12);
+      this.idIndex = buf.getShort(offset + 14);
+      this.classIndex = buf.getShort(offset + 16);
+      this.styleIndex = buf.getShort(offset + 18);
+    }
+
+    ResXMLTree_attribute attributeAt(int idx) {
+      return new ResXMLTree_attribute(buf,
+          myOffset() + dtohs(attributeStart) + dtohs(attributeSize) * idx);
+    }
+
+    public static class Writer {
+      private final ByteBuffer buf;
+      private final int ns;
+      private final int name;
+
+      private short idIndex;
+      private short classIndex;
+      private short styleIndex;
+
+      private final List<Attr> attrs = new ArrayList<>();
+
+      public Writer(ByteBuffer buf, ResStringPool_header.Writer resStringPoolWriter,
+          String ns, String name) {
+        this.buf = buf;
+        this.ns = resStringPoolWriter.string(ns);
+        this.name = resStringPoolWriter.string(name);
+      }
+
+      public void attr(int ns, int name, int value, Res_value resValue, String fullName) {
+        attrs.add(new Attr(ns, name, value, resValue, fullName));
+      }
+
+      public void write() {
+        int startPos = buf.position();
+        int attributeCount = attrs.size();
+
+        ResStringPool_ref.write(buf, ns);
+        ResStringPool_ref.write(buf, name);
+        ShortWriter attributeStartWriter = new ShortWriter(buf);
+        buf.putShort((short) ResXMLTree_attribute.SIZEOF); // attributeSize
+        buf.putShort((short) attributeCount); // attributeCount
+        ShortWriter idIndexWriter = new ShortWriter(buf);
+        ShortWriter classIndexWriter = new ShortWriter(buf);
+        ShortWriter styleIndexWriter = new ShortWriter(buf);
+
+        attributeStartWriter.write((short) (buf.position() - startPos));
+        for (int i = 0; i < attributeCount; i++) {
+          Attr attr = attrs.get(i);
+
+          switch (attr.fullName) {
+            case ":id":
+              idIndex = (short) (i + 1);
+              break;
+            case ":style":
+              styleIndex = (short) (i + 1);
+              break;
+            case ":class":
+              classIndex = (short) (i + 1);
+              break;
+          }
+
+          attr.write(buf);
+        }
+
+        idIndexWriter.write(idIndex);
+        classIndexWriter.write(classIndex);
+        styleIndexWriter.write(styleIndex);
+      }
+
+      private static class Attr {
+        final int ns;
+        final int name;
+        final int value;
+        final int resValueDataType;
+        final int resValueData;
+        final String fullName;
+
+        public Attr(int ns, int name, int value, Res_value resValue, String fullName) {
+          this.ns = ns;
+          this.name = name;
+          this.value = value;
+          this.resValueDataType = resValue.dataType;
+          this.resValueData = resValue.data;
+          this.fullName = fullName;
+        }
+
+        public void write(ByteBuffer buf) {
+          ResXMLTree_attribute.write(buf, ns, name, value, resValueDataType, resValueData);
+        }
+      }
+    }
+  };
+
+  static class ResXMLTree_attribute
+  {
+    public static final int SIZEOF = 12+ ResourceTypes.Res_value.SIZEOF;
+
+    // Namespace of this attribute.
+    final ResStringPool_ref ns;
+
+    // Name of this attribute.
+    final ResStringPool_ref name;
+
+    // The original raw string value of this attribute.
+    final ResStringPool_ref rawValue;
+
+    // Processesd typed value of this attribute.
+    final Res_value typedValue;
+
+    public ResXMLTree_attribute(ByteBuffer buf, int offset) {
+      this.ns = new ResStringPool_ref(buf, offset);
+      this.name = new ResStringPool_ref(buf, offset + 4);
+      this.rawValue = new ResStringPool_ref(buf, offset + 8);
+      this.typedValue = new Res_value(buf, offset + 12);
+    }
+
+    public static void write(ByteBuffer buf, int ns, int name, int value, int resValueDataType,
+        int resValueData) {
+      ResStringPool_ref.write(buf, ns);
+      ResStringPool_ref.write(buf, name);
+      ResStringPool_ref.write(buf, value);
+      ResourceTypes.Res_value.write(buf, resValueDataType, resValueData);
+    }
+  };
+
+/** ********************************************************************
+ *  RESOURCE TABLE
+ *
+ *********************************************************************** */
+
+  /**
+   * Header for a resource table.  Its data contains a series of
+   * additional chunks:
+   *   * A ResStringPool_header containing all table values.  This string pool
+   *     contains all of the string values in the entire resource table (not
+   *     the names of entries or type identifiers however).
+   *   * One or more ResTable_package chunks.
+   *
+   * Specific entries within a resource table can be uniquely identified
+   * with a single integer as defined by the ResTable_ref structure.
+   */
+  static class ResTable_header extends WithOffset
+  {
+    public static final int SIZEOF = ResChunk_header.SIZEOF + 4;
+
+    final ResChunk_header header;
+
+    // The number of ResTable_package structures.
+    final int packageCount;
+
+    public ResTable_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+      this.header = new ResChunk_header(buf, offset);
+      this.packageCount = buf.getInt(offset + ResChunk_header.SIZEOF);
+    }
+  }
+
+  /**
+   * A collection of resource data types within a package.  Followed by
+   * one or more ResTable_type and ResTable_typeSpec structures containing the
+   * entry values for each resource type.
+   */
+  static class ResTable_package extends WithOffset
+  {
+    public static final int SIZEOF = ResChunk_header.SIZEOF + 4 + 128 + 20;
+
+    final ResChunk_header header;
+
+    // If this is a base package, its ID.  Package IDs start
+    // at 1 (corresponding to the value of the package bits in a
+    // resource identifier).  0 means this is not a base package.
+    public final int id;
+
+    // Actual name of this package, \0-terminated.
+    public final char[] name = new char[128];
+
+    // Offset to a ResStringPool_header defining the resource
+    // type symbol table.  If zero, this package is inheriting from
+    // another base package (overriding specific values in it).
+    public final int typeStrings;
+
+    // Last index into typeStrings that is for public use by others.
+    public final int lastPublicType;
+
+    // Offset to a ResStringPool_header defining the resource
+    // key symbol table.  If zero, this package is inheriting from
+    // another base package (overriding specific values in it).
+    public final int keyStrings;
+
+    // Last index into keyStrings that is for public use by others.
+    public final int lastPublicKey;
+
+    public final int typeIdOffset;
+
+    public ResTable_package(ByteBuffer buf, int offset) {
+      super(buf, offset);
+      header = new ResChunk_header(buf, offset);
+      id = buf.getInt(offset + ResChunk_header.SIZEOF);
+      for (int i = 0; i < name.length; i++) {
+        name[i] = buf.getChar(offset + ResChunk_header.SIZEOF + 4 + i * 2);
+      }
+      typeStrings = buf.getInt(offset + ResChunk_header.SIZEOF + 4 + 256);
+      lastPublicType = buf.getInt(offset + ResChunk_header.SIZEOF + 4 + 256 + 4);
+      keyStrings = buf.getInt(offset + ResChunk_header.SIZEOF + 4 + 256 + 8);
+      lastPublicKey = buf.getInt(offset + ResChunk_header.SIZEOF + 4 + 256 + 12);
+      typeIdOffset = buf.getInt(offset + ResChunk_header.SIZEOF + 4 + 256 + 16);
+    }
+  };
+
+  // The most specific locale can consist of:
+  //
+  // - a 3 char language code
+  // - a 3 char region code prefixed by a 'r'
+  // - a 4 char script code prefixed by a 's'
+  // - a 8 char variant code prefixed by a 'v'
+  //
+// each separated by a single char separator, which sums up to a total of 24
+// chars, (25 include the string terminator). Numbering system specificator,
+// if present, can add up to 14 bytes (-u-nu-xxxxxxxx), giving 39 bytes,
+// or 40 bytes to make it 4 bytes aligned.
+  public static final int RESTABLE_MAX_LOCALE_LEN = 40;
+
+  /**
+   * A specification of the resources defined by a particular type.
+   *
+   * There should be one of these chunks for each resource type.
+   *
+   * This structure is followed by an array of integers providing the set of
+   * configuration change flags (ResTable_config::CONFIG_*) that have multiple
+   * resources for that configuration.  In addition, the high bit is set if that
+   * resource has been made public.
+   */
+  static class ResTable_typeSpec extends WithOffset
+  {
+    public static final int SIZEOF = ResChunk_header.SIZEOF + 8;
+
+    final ResChunk_header header;
+
+    // The type identifier this chunk is holding.  Type IDs start
+    // at 1 (corresponding to the value of the type bits in a
+    // resource identifier).  0 is invalid.
+    final byte id;
+
+    // Must be 0.
+    final byte res0;
+    // Must be 0.
+    final short res1;
+
+    // Number of uint32_t entry configuration masks that follow.
+    final int entryCount;
+
+    //enum : uint32_t {
+    // Additional flag indicating an entry is public.
+    static final int SPEC_PUBLIC = 0x40000000;
+
+    // Additional flag indicating an entry is overlayable at runtime.
+    // Added in Android-P.
+    static final int SPEC_OVERLAYABLE = 0x80000000;
+//    };
+
+    public ResTable_typeSpec(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      header = new ResChunk_header(buf, offset);
+      id = buf.get(offset + ResChunk_header.SIZEOF);
+      res0 = buf.get(offset + ResChunk_header.SIZEOF + 1);
+      res1 = buf.getShort(offset + ResChunk_header.SIZEOF + 2);
+      entryCount = buf.getInt(offset + ResChunk_header.SIZEOF + 4);
+    }
+
+    public int[] getSpecFlags() {
+      int[] ints = new int[(header.size - header.headerSize) / 4];
+      for (int i = 0; i < ints.length; i++) {
+        ints[i] = myBuf().getInt(myOffset() + header.headerSize + i * 4);
+
+      }
+      return ints;
+    }
+  };
+
+  /**
+   * A collection of resource entries for a particular resource data
+   * type.
+   *
+   * If the flag FLAG_SPARSE is not set in `flags`, then this struct is
+   * followed by an array of uint32_t defining the resource
+   * values, corresponding to the array of type strings in the
+   * ResTable_package::typeStrings string block. Each of these hold an
+   * index from entriesStart; a value of NO_ENTRY means that entry is
+   * not defined.
+   *
+   * If the flag FLAG_SPARSE is set in `flags`, then this struct is followed
+   * by an array of ResTable_sparseTypeEntry defining only the entries that
+   * have values for this type. Each entry is sorted by their entry ID such
+   * that a binary search can be performed over the entries. The ID and offset
+   * are encoded in a uint32_t. See ResTabe_sparseTypeEntry.
+   *
+   * There may be multiple of these chunks for a particular resource type,
+   * supply different configuration variations for the resource values of
+   * that type.
+   *
+   * It would be nice to have an additional ordered index of entries, so
+   * we can do a binary search if trying to find a resource by string name.
+   */
+  static class ResTable_type extends WithOffset
+  {
+    //      public static final int SIZEOF = ResChunk_header.SIZEOF + 12 + ResTable_config.SIZ;
+    public static final int SIZEOF_WITHOUT_CONFIG = ResChunk_header.SIZEOF + 12;
+
+    final ResChunk_header header;
+
+    //enum {
+    public static final int NO_ENTRY = 0xFFFFFFFF;
+//    };
+
+    // The type identifier this chunk is holding.  Type IDs start
+    // at 1 (corresponding to the value of the type bits in a
+    // resource identifier).  0 is invalid.
+    final byte id;
+
+    //      enum {
+    // If set, the entry is sparse, and encodes both the entry ID and offset into each entry,
+    // and a binary search is used to find the key. Only available on platforms >= O.
+    // Mark any types that use this with a v26 qualifier to prevent runtime issues on older
+    // platforms.
+    public static final int FLAG_SPARSE = 0x01;
+    //    };
+    final byte flags;
+
+    // Must be 0.
+    final short reserved;
+
+    // Number of uint32_t entry indices that follow.
+    final int entryCount;
+
+    // Offset from header where ResTable_entry data starts.
+    final int entriesStart;
+
+    // Configuration this collection of entries is designed for. This must always be last.
+    final ResTable_config config;
+
+    ResTable_type(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      header = new ResChunk_header(buf, offset);
+      id = buf.get(offset + ResChunk_header.SIZEOF);
+      flags = buf.get(offset + ResChunk_header.SIZEOF + 1);
+      reserved = buf.getShort(offset + ResChunk_header.SIZEOF + 2);
+      entryCount = buf.getInt(offset + ResChunk_header.SIZEOF + 4);
+      entriesStart = buf.getInt(offset + ResChunk_header.SIZEOF + 8);
+
+      // Cast to Buffer because generated covariant return type that returns ByteBuffer is not
+      // available on Java 8
+      ((Buffer) buf).position(offset + ResChunk_header.SIZEOF + 12);
+      config = ResTable_config.createConfig(buf);
+    }
+
+    public int findEntryByResName(int stringId) {
+      for (int i = 0; i < entryCount; i++) {
+        if (entryNameIndex(i) == stringId) {
+          return i;
+        }
+      }
+      return -1;
+    }
+
+    int entryOffset(int entryIndex) {
+      ByteBuffer byteBuffer = myBuf();
+      int offset = myOffset();
+
+      // from ResTable cpp:
+//            const uint32_t* const eindex = reinterpret_cast<const uint32_t*>(
+//            reinterpret_cast<const uint8_t*>(thisType) + dtohs(thisType->header.headerSize));
+//
+//        uint32_t thisOffset = dtohl(eindex[realEntryIndex]);
+      return byteBuffer.getInt(offset + header.headerSize + entryIndex * 4);
+    }
+
+    private int entryNameIndex(int entryIndex) {
+      ByteBuffer byteBuffer = myBuf();
+      int offset = myOffset();
+
+      // from ResTable cpp:
+//            const uint32_t* const eindex = reinterpret_cast<const uint32_t*>(
+//            reinterpret_cast<const uint8_t*>(thisType) + dtohs(thisType->header.headerSize));
+//
+//        uint32_t thisOffset = dtohl(eindex[realEntryIndex]);
+      int entryOffset = byteBuffer.getInt(offset + header.headerSize + entryIndex * 4);
+      if (entryOffset == -1) {
+        return -1;
+      }
+
+      int STRING_POOL_REF_OFFSET = 4;
+      return dtohl(byteBuffer.getInt(offset + entriesStart + entryOffset + STRING_POOL_REF_OFFSET));
+    }
+  };
+
+  // The minimum size required to read any version of ResTable_type.
+//   constexpr size_t kResTableTypeMinSize =
+//   sizeof(ResTable_type) - sizeof(ResTable_config) + sizeof(ResTable_config::size);
+  static final int kResTableTypeMinSize =
+      ResTable_type.SIZEOF_WITHOUT_CONFIG - ResTable_config.SIZEOF + SIZEOF_INT /*sizeof(ResTable_config::size)*/;
+
+  /**
+   * An entry in a ResTable_type with the flag `FLAG_SPARSE` set.
+   */
+  static class ResTable_sparseTypeEntry extends WithOffset {
+    public static final int SIZEOF = 4;
+
+    // Holds the raw uint32_t encoded value. Do not read this.
+    // int entry;
+
+    short idx;
+    short offset;
+//    struct {
+      // The index of the entry.
+//      uint16_t idx;
+
+      // The offset from ResTable_type::entriesStart, divided by 4.
+//      uint16_t offset;
+//    };
+
+    public ResTable_sparseTypeEntry(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      this.idx = buf.getShort(offset);
+      this.offset = buf.getShort(offset + 2);
+    }
+  };
+
+  /**
+   * This is the beginning of information about an entry in the resource
+   * table.  It holds the reference to the name of this entry, and is
+   * immediately followed by one of:
+   *   * A Res_value structure, if FLAG_COMPLEX is -not- set.
+   *   * An array of ResTable_map structures, if FLAG_COMPLEX is set.
+   *     These supply a set of name/value mappings of data.
+   */
+  static class ResTable_entry extends WithOffset
+  {
+    public static final int SIZEOF = 4 + ResStringPool_ref.SIZEOF;
+
+    // Number of bytes in this structure.
+    final short size;
+
+    //enum {
+    // If set, this is a complex entry, holding a set of name/value
+    // mappings.  It is followed by an array of ResTable_map structures.
+    public static final int FLAG_COMPLEX = 0x0001;
+    // If set, this resource has been declared public, so libraries
+    // are allowed to reference it.
+    public static final int FLAG_PUBLIC = 0x0002;
+    // If set, this is a weak resource and may be overriden by strong
+    // resources of the same name/type. This is only useful during
+    // linking with other resource tables.
+    public static final int FLAG_WEAK = 0x0004;
+    //    };
+    final short flags;
+
+    // Reference into ResTable_package::keyStrings identifying this entry.
+    final ResStringPool_ref key;
+
+    ResTable_entry(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      size = buf.getShort(offset);
+      flags = buf.getShort(offset + 2);
+      key = new ResStringPool_ref(buf, offset + 4);
+    }
+
+    public Res_value getResValue() {
+      // something like:
+
+      // final Res_value device_value = reinterpret_cast<final Res_value>(
+      //     reinterpret_cast<final byte*>(entry) + dtohs(entry.size));
+
+      return new Res_value(myBuf(), myOffset() + dtohs(size));
+    }
+  }
+
+  /**
+   * Extended form of a ResTable_entry for map entries, defining a parent map
+   * resource from which to inherit values.
+   */
+  static class ResTable_map_entry extends ResTable_entry
+  {
+
+    /**
+     * Indeterminate size, calculate using {@link #size} instead.
+     */
+    public static final Void SIZEOF = null;
+
+    public static final int BASE_SIZEOF = ResTable_entry.SIZEOF + 8;
+
+    // Resource identifier of the parent mapping, or 0 if there is none.
+    // This is always treated as a TYPE_DYNAMIC_REFERENCE.
+    ResTable_ref parent;
+    // Number of name/value pairs that follow for FLAG_COMPLEX.
+    int count;
+
+    ResTable_map_entry(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      parent = new ResTable_ref(buf, offset + ResTable_entry.SIZEOF);
+      count = buf.getInt(offset + ResTable_entry.SIZEOF + ResTable_ref.SIZEOF);
+    }
+  };
+
+  /**
+   * A single name/value mapping that is part of a complex resource
+   * entry.
+   */
+  public static class ResTable_map extends WithOffset
+  {
+    public static final int SIZEOF = ResTable_ref.SIZEOF + ResourceTypes.Res_value.SIZEOF;
+
+    // The resource identifier defining this mapping's name.  For attribute
+    // resources, 'name' can be one of the following special resource types
+    // to supply meta-data about the attribute; for all other resource types
+    // it must be an attribute resource.
+    public final ResTable_ref name;
+
+    // Special values for 'name' when defining attribute resources.
+//enum {
+    // This entry holds the attribute's type code.
+    public static final int ATTR_TYPE = Res_MAKEINTERNAL(0);
+
+    // For integral attributes, this is the minimum value it can hold.
+    public static final int ATTR_MIN = Res_MAKEINTERNAL(1);
+
+    // For integral attributes, this is the maximum value it can hold.
+    public static final int ATTR_MAX = Res_MAKEINTERNAL(2);
+
+    // Localization of this resource is can be encouraged or required with
+    // an aapt flag if this is set
+    public static final int ATTR_L10N = Res_MAKEINTERNAL(3);
+
+    // for plural support, see android.content.res.PluralRules#attrForQuantity(int)
+    public static final int ATTR_OTHER = Res_MAKEINTERNAL(4);
+    public static final int ATTR_ZERO = Res_MAKEINTERNAL(5);
+    public static final int ATTR_ONE = Res_MAKEINTERNAL(6);
+    public static final int ATTR_TWO = Res_MAKEINTERNAL(7);
+    public static final int ATTR_FEW = Res_MAKEINTERNAL(8);
+    public static final int ATTR_MANY = Res_MAKEINTERNAL(9);
+
+//    };
+
+    // Bit mask of allowed types, for use with ATTR_TYPE.
+//enum {
+    // No type has been defined for this attribute, use generic
+    // type handling.  The low 16 bits are for types that can be
+    // handled generically; the upper 16 require additional information
+    // in the bag so can not be handled generically for TYPE_ANY.
+    public static final int TYPE_ANY = 0x0000FFFF;
+
+    // Attribute holds a references to another resource.
+    public static final int TYPE_REFERENCE = 1<<0;
+
+    // Attribute holds a generic string.
+    public static final int TYPE_STRING = 1<<1;
+
+    // Attribute holds an integer value.  ATTR_MIN and ATTR_MIN can
+    // optionally specify a constrained range of possible integer values.
+    public static final int TYPE_INTEGER = 1<<2;
+
+    // Attribute holds a boolean integer.
+    public static final int TYPE_BOOLEAN = 1<<3;
+
+    // Attribute holds a color value.
+    public static final int TYPE_COLOR = 1<<4;
+
+    // Attribute holds a floating point value.
+    public static final int TYPE_FLOAT = 1<<5;
+
+    // Attribute holds a dimension value, such as "20px".
+    public static final int TYPE_DIMENSION = 1<<6;
+
+    // Attribute holds a fraction value, such as "20%".
+    public static final int TYPE_FRACTION = 1<<7;
+
+    // Attribute holds an enumeration.  The enumeration values are
+    // supplied as additional entries in the map.
+    public static final int TYPE_ENUM = 1<<16;
+
+    // Attribute holds a bitmaks of flags.  The flag bit values are
+    // supplied as additional entries in the map.
+    public static final int TYPE_FLAGS = 1<<17;
+//    };
+
+    // Enum of localization modes, for use with ATTR_L10N.
+//enum {
+    public static final int L10N_NOT_REQUIRED = 0;
+    public static final int L10N_SUGGESTED    = 1;
+//    };
+
+    // This mapping's value.
+    public Res_value value;
+
+    public ResTable_map(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      name = new ResTable_ref(buf, offset);
+      value = new Res_value(buf, offset + ResTable_ref.SIZEOF);
+    }
+
+    public ResTable_map() {
+      super(null, 0);
+      this.name = new ResTable_ref();
+      this.value = new Res_value();
+    }
+
+    @Override
+    public String toString() {
+      return "ResTable_map{" + "name=" + name + ", value=" + value + '}';
+    }
+  };
+
+  /**
+   * A package-id to package name mapping for any shared libraries used
+   * in this resource table. The package-id's encoded in this resource
+   * table may be different than the id's assigned at runtime. We must
+   * be able to translate the package-id's based on the package name.
+   */
+  static class ResTable_lib_header extends WithOffset
+  {
+    static final int SIZEOF = ResChunk_header.SIZEOF + 4;
+
+    ResChunk_header header;
+
+    // The number of shared libraries linked in this resource table.
+    int count;
+
+    ResTable_lib_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      header = new ResChunk_header(buf, offset);
+      count = buf.getInt(offset + ResChunk_header.SIZEOF);
+    }
+  };
+
+  /**
+   * A shared library package-id to package name entry.
+   */
+  static class ResTable_lib_entry extends WithOffset
+  {
+    public static final int SIZEOF = 4 + 128 * SIZEOF_SHORT;
+
+    // The package-id this shared library was assigned at build time.
+    // We use a uint32 to keep the structure aligned on a uint32 boundary.
+    int packageId;
+
+    // The package name of the shared library. \0 terminated.
+    char[] packageName = new char[128];
+
+    ResTable_lib_entry(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      packageId = buf.getInt(offset);
+
+      for (int i = 0; i < packageName.length; i++) {
+        packageName[i] = buf.getChar(offset + 4 + i * SIZEOF_SHORT);
+      }
+    }
+  };
+
+  // struct alignas(uint32_t) Idmap_header {
+  static class Idmap_header extends WithOffset {
+    // Always 0x504D4449 ('IDMP')
+    int magic;
+
+    int version;
+
+    int target_crc32;
+    int overlay_crc32;
+
+    final byte[] target_path = new byte[256];
+    final byte[] overlay_path = new byte[256];
+
+    short target_package_id;
+    short type_count;
+
+    Idmap_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      magic = buf.getInt(offset);
+      version = buf.getInt(offset + 4);
+      target_crc32 = buf.getInt(offset + 8);
+      overlay_crc32 = buf.getInt(offset + 12);
+
+      buf.get(target_path, offset + 16, 256);
+      buf.get(overlay_path, offset + 16 + 256, 256);
+
+      target_package_id = buf.getShort(offset + 16 + 256 + 256);
+      type_count = buf.getShort(offset + 16 + 256 + 256 + 2);
+    }
+  } // __attribute__((packed));
+
+  // struct alignas(uint32_t) IdmapEntry_header {
+  static class IdmapEntry_header extends WithOffset {
+    static final int SIZEOF = 2 * 4;
+
+    short target_type_id;
+    short overlay_type_id;
+    short entry_count;
+    short entry_id_offset;
+    int entries[];
+
+    IdmapEntry_header(ByteBuffer buf, int offset) {
+      super(buf, offset);
+
+      target_type_id = buf.getShort(offset);
+      overlay_type_id = buf.getShort(offset + 2);
+      entry_count = buf.getShort(offset + 4);
+      entry_id_offset = buf.getShort(offset + 6);
+      entries = new int[entry_count];
+      for (int i = 0; i < entries.length; i++) {
+        entries[i] = buf.getInt(offset + 8 + i * SIZEOF_INT);
+      }
+    }
+  } // __attribute__((packed));
+
+
+  abstract private static class FutureWriter<T> {
+    protected final ByteBuffer buf;
+    private final int position;
+
+    public FutureWriter(ByteBuffer buf, int size) {
+      this.buf = buf;
+      this.position = buf.position();
+      // Cast to Buffer because generated covariant return type that returns ByteBuffer is not
+      // available on Java 8
+      ((Buffer) buf).position(position + size);
+    }
+
+    abstract protected void put(int position, T value);
+
+    public void write(T value) {
+      put(position, value);
+    }
+  }
+
+  private static class IntWriter extends FutureWriter<Integer> {
+    public IntWriter(ByteBuffer buf) {
+      super(buf, 4);
+    }
+
+    @Override
+    protected void put(int position, Integer value) {
+      buf.putInt(position, value);
+    }
+  }
+
+  private static class ShortWriter extends FutureWriter<Short> {
+    public ShortWriter(ByteBuffer buf) {
+      super(buf, 2);
+    }
+
+    @Override
+    protected void put(int position, Short value) {
+      buf.putShort(position, value);
+    }
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ResourceUtils.java b/resources/src/main/java/org/robolectric/res/android/ResourceUtils.java
new file mode 100644
index 0000000..3391672
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ResourceUtils.java
@@ -0,0 +1,101 @@
+package org.robolectric.res.android;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/include/androidfw/ResourceUtils.h
+// and https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/libs/androidfw/ResourceUtils.cpp
+class ResourceUtils {
+  // Extracts the package, type, and name from a string of the format: [[package:]type/]name
+  // Validation must be performed on each extracted piece.
+  // Returns false if there was a syntax error.
+//  boolean ExtractResourceName(String& str, String* out_package, String* out_type,
+//                           String* out_entry);
+
+  static int fix_package_id(int resid, int package_id) {
+    return (resid & 0x00ffffff) | (package_id << 24);
+  }
+
+  static int get_package_id(int resid) {
+//    return static_cast<int>((resid >> 24) & 0x000000ff);
+    return resid >>> 24;
+  }
+
+  // The type ID is 1-based, so if the returned value is 0 it is invalid.
+  static int get_type_id(int resid) {
+//    return static_cast<int>((resid >> 16) & 0x000000ff);
+    return (resid & 0x00FF0000) >>> 16;
+  }
+
+  static int get_entry_id(int resid) {
+//    return static_cast<uint16_t>(resid & 0x0000ffff);
+    return (short) (resid & 0x0000FFFF);
+  }
+
+  static boolean is_internal_resid(int resid) {
+    return (resid & 0xffff0000) != 0 && (resid & 0x00ff0000) == 0;
+  }
+
+  static boolean is_valid_resid(int resid) {
+    return (resid & 0x00ff0000) != 0 && (resid & 0xff000000) != 0;
+  }
+
+  static int make_resid(byte package_id, byte type_id, short entry_id) {
+//    return (static_cast<int>(package_id) << 24) | (static_cast<int>(type_id) << 16) |
+//        entry_id;
+    return package_id << 24 | type_id << 16 | entry_id;
+  }
+
+//   bool ExtractResourceName(const StringPiece& str, StringPiece* out_package, StringPiece* out_type,
+//                          StringPiece* out_entry) {
+//   *out_package = "";
+//   *out_type = "";
+//   bool has_package_separator = false;
+//   bool has_type_separator = false;
+//   const char* start = str.data();
+//   const char* end = start + str.size();
+//   const char* current = start;
+//   while (current != end) {
+//     if (out_type->size() == 0 && *current == '/') {
+//       has_type_separator = true;
+//       out_type->assign(start, current - start);
+//       start = current + 1;
+//     } else if (out_package->size() == 0 && *current == ':') {
+//       has_package_separator = true;
+//       out_package->assign(start, current - start);
+//       start = current + 1;
+//     }
+//     current++;
+//   }
+//   out_entry->assign(start, end - start);
+//
+//   return !(has_package_separator && out_package->empty()) &&
+//          !(has_type_separator && out_type->empty());
+// }
+
+  static boolean ExtractResourceName(String str, Ref<String> out_package, Ref<String> out_type,
+                           final Ref<String> out_entry) {
+    out_package.set("");
+    out_type.set("");
+    boolean has_package_separator = false;
+    boolean has_type_separator = false;
+    int start = 0;
+    int end = start + str.length();
+    int current = start;
+    while (current != end) {
+      if (out_type.get().length() == 0 && str.charAt(current) == '/') {
+        has_type_separator = true;
+        out_type.set(str.substring(start, current));
+        start = current + 1;
+      } else if (out_package.get().length() == 0 && str.charAt(current) == ':') {
+        has_package_separator = true;
+        out_package.set(str.substring(start, current));
+        start = current + 1;
+      }
+      current++;
+    }
+    out_entry.set(str.substring(start, end));
+
+    return !(has_package_separator && out_package.get().isEmpty()) &&
+           !(has_type_separator && out_type.get().isEmpty());
+  }
+
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/SortedVector.java b/resources/src/main/java/org/robolectric/res/android/SortedVector.java
new file mode 100644
index 0000000..50f3f23
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/SortedVector.java
@@ -0,0 +1,49 @@
+package org.robolectric.res.android;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+// roughly transliterated from system/core/libutils/include/utils/SortedVector.h and
+// system/core/libutils/VectorImpl.cpp
+public class SortedVector<T extends Comparable<T>> {
+
+  // internal storage for the data. Re-sorted on insertion
+  private List<T> mStorage;
+
+  SortedVector(int itemSize) {
+    mStorage = new ArrayList<>(itemSize);
+  }
+
+  SortedVector() {
+    mStorage = new ArrayList<>();
+  }
+
+  public void add(T info) {
+    mStorage.add(info);
+    Collections.sort(mStorage, new Comparator<T>() {
+      @Override
+      public int compare(T t, T t1) {
+        return t.compareTo(t1);
+      }
+    });
+  }
+
+  public int size() {
+    return mStorage.size();
+
+  }
+
+  public T itemAt(int contIdx) {
+    return mStorage.get(contIdx);
+  }
+
+  public int indexOf(T tmpInfo) {
+    return mStorage.indexOf(tmpInfo);
+  }
+
+  public void removeAt(int matchIdx) {
+    mStorage.remove(matchIdx);
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/String8.java b/resources/src/main/java/org/robolectric/res/android/String8.java
new file mode 100644
index 0000000..9eae87e
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/String8.java
@@ -0,0 +1,443 @@
+package org.robolectric.res.android;
+
+import java.io.File;
+import java.nio.file.Path;
+import org.robolectric.res.Fs;
+
+// transliterated from
+// https://android.googlesource.com/platform/system/core/+/android-9.0.0_r12/libutils/String8.cpp
+// and
+// https://android.googlesource.com/platform/system/core/+/android-9.0.0_r12/include/utils/String8.h
+@SuppressWarnings("NewApi")
+public class String8 {
+
+  private StringBuilder mString;
+
+  public String8() {
+    this("");
+  }
+
+  public String8(Path value) {
+    this(Fs.externalize(value));
+  }
+
+  public String8(String value) {
+    mString = new StringBuilder(value);
+  }
+
+  public String8(String8 path) {
+    this(path.string());
+  }
+
+  public String8(String value, int len) {
+    this(value.substring(0, len));
+  }
+
+  int length() {
+    return mString.length();
+  }
+//String8 String8::format(const char* fmt, ...)
+//{
+//    va_list args;
+//    va_start(args, fmt);
+//    String8 result(formatV(fmt, args));
+//    va_end(args);
+//    return result;
+//}
+//String8 String8::formatV(const char* fmt, va_list args)
+//{
+//    String8 result;
+//    result.appendFormatV(fmt, args);
+//    return result;
+//}
+//void String8::clear() {
+//    SharedBuffer::bufferFromData(mString)->release();
+//    mString = getEmptyString();
+//}
+//void String8::setTo(const String8& other)
+//{
+//    SharedBuffer::bufferFromData(other.mString)->acquire();
+//    SharedBuffer::bufferFromData(mString)->release();
+//    mString = other.mString;
+//}
+//status_t String8::setTo(const char* other)
+//{
+//    const char *newString = allocFromUTF8(other, strlen(other));
+//    SharedBuffer::bufferFromData(mString)->release();
+//    mString = newString;
+//    if (mString) return NO_ERROR;
+//    mString = getEmptyString();
+//    return NO_MEMORY;
+//}
+//status_t String8::setTo(const char* other, size_t len)
+//{
+//    const char *newString = allocFromUTF8(other, len);
+//    SharedBuffer::bufferFromData(mString)->release();
+//    mString = newString;
+//    if (mString) return NO_ERROR;
+//    mString = getEmptyString();
+//    return NO_MEMORY;
+//}
+//status_t String8::setTo(const char16_t* other, size_t len)
+//{
+//    const char *newString = allocFromUTF16(other, len);
+//    SharedBuffer::bufferFromData(mString)->release();
+//    mString = newString;
+//    if (mString) return NO_ERROR;
+//    mString = getEmptyString();
+//    return NO_MEMORY;
+//}
+//status_t String8::setTo(const char32_t* other, size_t len)
+//{
+//    const char *newString = allocFromUTF32(other, len);
+//    SharedBuffer::bufferFromData(mString)->release();
+//    mString = newString;
+//    if (mString) return NO_ERROR;
+//    mString = getEmptyString();
+//    return NO_MEMORY;
+//}
+//status_t String8::append(const String8& other)
+//{
+//    const size_t otherLen = other.bytes();
+//    if (bytes() == 0) {
+//        setTo(other);
+//        return NO_ERROR;
+//    } else if (otherLen == 0) {
+//        return NO_ERROR;
+//    }
+//    return real_append(other.string(), otherLen);
+//}
+public String8 append(final String other) {
+  mString.append(other);
+    return this;
+}
+//status_t String8::append(const char* other, size_t otherLen)
+//{
+//    if (bytes() == 0) {
+//        return setTo(other, otherLen);
+//    } else if (otherLen == 0) {
+//        return NO_ERROR;
+//    }
+//    return real_append(other, otherLen);
+//}
+//status_t String8::appendFormat(const char* fmt, ...)
+//{
+//    va_list args;
+//    va_start(args, fmt);
+//    status_t result = appendFormatV(fmt, args);
+//    va_end(args);
+//    return result;
+//}
+//status_t String8::appendFormatV(const char* fmt, va_list args)
+//{
+//    int n, result = NO_ERROR;
+//    va_list tmp_args;
+//    /* args is undefined after vsnprintf.
+//     * So we need a copy here to avoid the
+//     * second vsnprintf access undefined args.
+//     */
+//    va_copy(tmp_args, args);
+//    n = vsnprintf(NULL, 0, fmt, tmp_args);
+//    va_end(tmp_args);
+//    if (n != 0) {
+//        size_t oldLength = length();
+//        char* buf = lockBuffer(oldLength + n);
+//        if (buf) {
+//            vsnprintf(buf + oldLength, n + 1, fmt, args);
+//        } else {
+//            result = NO_MEMORY;
+//        }
+//    }
+//    return result;
+//}
+//status_t String8::real_append(const char* other, size_t otherLen)
+//{
+//    const size_t myLen = bytes();
+//
+//    SharedBuffer* buf = SharedBuffer::bufferFromData(mString)
+//        ->editResize(myLen+otherLen+1);
+//    if (buf) {
+//        char* str = (char*)buf->data();
+//        mString = str;
+//        str += myLen;
+//        memcpy(str, other, otherLen);
+//        str[otherLen] = '\0';
+//        return NO_ERROR;
+//    }
+//    return NO_MEMORY;
+//}
+//char* String8::lockBuffer(size_t size)
+//{
+//    SharedBuffer* buf = SharedBuffer::bufferFromData(mString)
+//        ->editResize(size+1);
+//    if (buf) {
+//        char* str = (char*)buf->data();
+//        mString = str;
+//        return str;
+//    }
+//    return NULL;
+//}
+//void String8::unlockBuffer()
+//{
+//    unlockBuffer(strlen(mString));
+//}
+//status_t String8::unlockBuffer(size_t size)
+//{
+//    if (size != this->size()) {
+//        SharedBuffer* buf = SharedBuffer::bufferFromData(mString)
+//            ->editResize(size+1);
+//        if (! buf) {
+//            return NO_MEMORY;
+//        }
+//        char* str = (char*)buf->data();
+//        str[size] = 0;
+//        mString = str;
+//    }
+//    return NO_ERROR;
+//}
+//ssize_t String8::find(const char* other, size_t start) const
+//{
+//    size_t len = size();
+//    if (start >= len) {
+//        return -1;
+//    }
+//    const char* s = mString+start;
+//    const char* p = strstr(s, other);
+//    return p ? p-mString : -1;
+//}
+//bool String8::removeAll(const char* other) {
+//    ssize_t index = find(other);
+//    if (index < 0) return false;
+//    char* buf = lockBuffer(size());
+//    if (!buf) return false; // out of memory
+//    size_t skip = strlen(other);
+//    size_t len = size();
+//    size_t tail = index;
+//    while (size_t(index) < len) {
+//        ssize_t next = find(other, index + skip);
+//        if (next < 0) {
+//            next = len;
+//        }
+//        memmove(buf + tail, buf + index + skip, next - index - skip);
+//        tail += next - index - skip;
+//        index = next;
+//    }
+//    unlockBuffer(tail);
+//    return true;
+//}
+//void String8::toLower()
+//{
+//    toLower(0, size());
+//}
+//void String8::toLower(size_t start, size_t length)
+//{
+//    const size_t len = size();
+//    if (start >= len) {
+//        return;
+//    }
+//    if (start+length > len) {
+//        length = len-start;
+//    }
+//    char* buf = lockBuffer(len);
+//    buf += start;
+//    while (length > 0) {
+//        *buf = tolower(*buf);
+//        buf++;
+//        length--;
+//    }
+//    unlockBuffer(len);
+//}
+//void String8::toUpper()
+//{
+//    toUpper(0, size());
+//}
+//void String8::toUpper(size_t start, size_t length)
+//{
+//    const size_t len = size();
+//    if (start >= len) {
+//        return;
+//    }
+//    if (start+length > len) {
+//        length = len-start;
+//    }
+//    char* buf = lockBuffer(len);
+//    buf += start;
+//    while (length > 0) {
+//        *buf = toupper(*buf);
+//        buf++;
+//        length--;
+//    }
+//    unlockBuffer(len);
+//}
+//size_t String8::getUtf32Length() const
+//{
+//    return utf8_to_utf32_length(mString, length());
+//}
+//int32_t String8::getUtf32At(size_t index, size_t *next_index) const
+//{
+//    return utf32_from_utf8_at(mString, length(), index, next_index);
+//}
+//void String8::getUtf32(char32_t* dst) const
+//{
+//    utf8_to_utf32(mString, length(), dst);
+//}
+//// ---------------------------------------------------------------------------
+//// Path functions
+//void String8::setPathName(const char* name)
+//{
+//    setPathName(name, strlen(name));
+//}
+//void String8::setPathName(const char* name, size_t len)
+//{
+//    char* buf = lockBuffer(len);
+//    memcpy(buf, name, len);
+//    // remove trailing path separator, if present
+//    if (len > 0 && buf[len-1] == OS_PATH_SEPARATOR)
+//        len--;
+//    buf[len] = '\0';
+//    unlockBuffer(len);
+//}
+String8 getPathLeaf() {
+  final int cp;
+  final String buf = mString.toString();
+  cp = buf.lastIndexOf('/');
+  if (cp == -1) {
+    return new String8(this);
+  } else {
+    return new String8(buf.substring(cp + 1));
+  }
+}
+//String8 String8::getPathDir(void) const
+//{
+//    const char* cp;
+//    const char*const str = mString;
+//    cp = strrchr(str, OS_PATH_SEPARATOR);
+//    if (cp == NULL)
+//        return String8("");
+//    else
+//        return String8(str, cp - str);
+//}
+//String8 String8::walkPath(String8* outRemains) const
+//{
+//    const char* cp;
+//    const char*const str = mString;
+//    const char* buf = str;
+//    cp = strchr(buf, OS_PATH_SEPARATOR);
+//    if (cp == buf) {
+//        // don't include a leading '/'.
+//        buf = buf+1;
+//        cp = strchr(buf, OS_PATH_SEPARATOR);
+//    }
+//    if (cp == NULL) {
+//        String8 res = buf != str ? String8(buf) : *this;
+//        if (outRemains) *outRemains = String8("");
+//        return res;
+//    }
+//    String8 res(buf, cp-buf);
+//    if (outRemains) *outRemains = String8(cp+1);
+//    return res;
+//}
+
+/*
+ * Helper function for finding the start of an extension in a pathname.
+ *
+ * Returns a index inside mString, or -1 if no extension was found.
+ */
+private int find_extension()
+{
+    int lastSlashIndex;
+
+    final StringBuilder str = mString;
+    // only look at the filename
+    lastSlashIndex = str.lastIndexOf(File.pathSeparator);
+    if (lastSlashIndex == -1) {
+      lastSlashIndex = 0;
+    } else {
+      lastSlashIndex++;
+    }
+    // find the last dot
+    return str.lastIndexOf(".", lastSlashIndex);
+}
+
+public String getPathExtension()
+{
+    int extIndex;
+    extIndex = find_extension();
+    if (extIndex != -1) {
+      return mString.substring(extIndex);
+    }
+    else {
+      return "";
+    }
+}
+
+  String8 getBasePath() {
+    int extIndex;
+    extIndex = find_extension();
+    if (extIndex == -1) {
+      return new String8(this);
+    } else {
+      return new String8(mString.substring(extIndex));
+    }
+  }
+
+  public String8 appendPath(String name) {
+    if (name.length() == 0) {
+      // nothing to do
+      return this;
+    }
+    if (name.charAt(0) != '/') {
+      mString.append('/');
+    }
+    mString.append(name);
+    return this;
+}
+
+//String8& String8::convertToResPath()
+//{
+//#if OS_PATH_SEPARATOR != RES_PATH_SEPARATOR
+//    size_t len = length();
+//    if (len > 0) {
+//        char * buf = lockBuffer(len);
+//        for (char * end = buf + len; buf < end; ++buf) {
+//            if (*buf == OS_PATH_SEPARATOR)
+//                *buf = RES_PATH_SEPARATOR;
+//        }
+//        unlockBuffer(len);
+//    }
+//#endif
+//    return *this;
+//}
+//}; // namespace android
+
+  public final String string() {
+    return mString.toString();
+  }
+
+  @Override
+  public String toString() {
+    return mString.toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof String8)) {
+      return false;
+    }
+
+    String8 string8 = (String8) o;
+
+    return mString != null ? mString.toString().equals(string8.mString.toString()) : string8.mString == null;
+  }
+
+  @Override
+  public int hashCode() {
+    return mString != null ? mString.hashCode() : 0;
+  }
+
+}
+
+
diff --git a/resources/src/main/java/org/robolectric/res/android/StringPiece.java b/resources/src/main/java/org/robolectric/res/android/StringPiece.java
new file mode 100644
index 0000000..c7103aa
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/StringPiece.java
@@ -0,0 +1,20 @@
+package org.robolectric.res.android;
+
+import java.nio.ByteBuffer;
+import org.robolectric.res.android.ResourceTypes.Idmap_header;
+import org.robolectric.res.android.ResourceTypes.WithOffset;
+
+public class StringPiece extends WithOffset {
+
+  StringPiece(ByteBuffer buf, int offset) {
+    super(buf, offset);
+  }
+
+  public int size() {
+    return myBuf().capacity() - myOffset();
+  }
+
+  public Idmap_header asIdmap_header() {
+    return new Idmap_header(myBuf(), myOffset());
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/StringPoolRef.java b/resources/src/main/java/org/robolectric/res/android/StringPoolRef.java
new file mode 100644
index 0000000..b56f32f
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/StringPoolRef.java
@@ -0,0 +1,31 @@
+package org.robolectric.res.android;
+
+/**
+ * transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h:541
+ * Wrapper class that allows the caller to retrieve a string from a string pool without knowing
+ * which string pool to look.
+ */
+class StringPoolRef {
+
+  private final ResStringPool mPool;
+  private int mIndex;
+
+  StringPoolRef(final ResStringPool pool, int index) {
+    this.mPool = pool;
+    this.mIndex = index;
+  }
+
+  final String string() {
+    return mPool.stringAt(mIndex);
+  }
+
+  //  final byte[] string8(Ref<Integer> outLen) {
+  //    return null;
+  // //----------------------------------------------------------------------------------------
+  //  }
+  //
+  //  final char[] string16(Ref<Integer> outLen) {
+  //    return null;
+  // //----------------------------------------------------------------------------------------
+  //  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/Util.java b/resources/src/main/java/org/robolectric/res/android/Util.java
new file mode 100644
index 0000000..5b82282
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/Util.java
@@ -0,0 +1,128 @@
+package org.robolectric.res.android;
+
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+import java.nio.ByteOrder;
+
+public class Util {
+
+  public static final boolean JNI_TRUE = true;
+  public static final boolean JNI_FALSE = false;
+
+  public static final int SIZEOF_SHORT = 2;
+  public static final int SIZEOF_INT = 4;
+  public static final int SIZEOF_CPTR = 4;
+  private static boolean littleEndian = ByteOrder.nativeOrder() == ByteOrder.LITTLE_ENDIAN;
+
+  private static final boolean DEBUG = false;
+
+  static short dtohs(short v) {
+    return littleEndian
+        ? v
+        : (short) ((v << 8) | (v >> 8));
+  }
+
+  static char dtohs(char v) {
+    return littleEndian
+        ? v
+        : (char) ((v << 8) | (v >> 8));
+  }
+
+  static int dtohl(int v) {
+    return littleEndian
+        ? v
+        : (v << 24) | ((v << 8) & 0x00FF0000) | ((v >> 8) & 0x0000FF00) | (v >> 24);
+  }
+
+  static short htods(short v) {
+    return littleEndian
+        ? v
+        : (short) ((v << 8) | (v >> 8));
+  }
+
+  static int htodl(int v) {
+    return littleEndian
+        ? v
+        : (v << 24) | ((v << 8) & 0x00FF0000) | ((v >> 8) & 0x0000FF00) | (v >> 24);
+  }
+
+  public static boolean isTruthy(int i) {
+    return i != 0;
+  }
+
+  public static boolean isTruthy(Object o) {
+    return o != null;
+  }
+
+  @FormatMethod
+  static void ALOGD(@FormatString String message, Object... args) {
+    if (DEBUG) {
+      System.out.println("DEBUG: " + String.format(message, args));
+    }
+  }
+
+  @FormatMethod
+  static void ALOGW(@FormatString String message, Object... args) {
+    System.out.println("WARN: " + String.format(message, args));
+  }
+
+  @FormatMethod
+  public static void ALOGV(@FormatString String message, Object... args) {
+    if (DEBUG) {
+      System.out.println("VERBOSE: " + String.format(message, args));
+    }
+  }
+
+  @FormatMethod
+  public static void ALOGI(@FormatString String message, Object... args) {
+    if (DEBUG) {
+      System.out.println("INFO: " + String.format(message, args));
+    }
+  }
+
+  @FormatMethod
+  static void ALOGE(@FormatString String message, Object... args) {
+    System.out.println("ERROR: " + String.format(message, args));
+  }
+
+  @FormatMethod
+  static void LOG_FATAL_IF(boolean assertion, @FormatString String message, Object... args) {
+    assert !assertion : String.format(message, args);
+  }
+
+  static void ATRACE_CALL() {
+  }
+
+  public static void ATRACE_NAME(String s) {
+  }
+
+  static boolean UNLIKELY(boolean b) {
+    return b;
+  }
+
+  public static void CHECK(boolean b) {
+    assert b;
+  }
+
+  static void logError(String s) {
+    System.err.println(s);
+  }
+
+  static void logWarning(String s) {
+    System.err.println("[WARN] " + s);
+  }
+
+  static String ReadUtf16StringFromDevice(char[] src, int len/*, std::string* out*/) {
+    int i = 0;
+    StringBuilder strBuf = new StringBuilder();
+    while (src[i] != '\0' && len != 0) {
+      char c = dtohs(src[i]);
+      // utf16_to_utf8(&c, 1, buf, sizeof(buf));
+      // out->append(buf, strlen(buf));
+      strBuf.append(c);
+      ++i;
+      --len;
+    }
+    return strBuf.toString();
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ZipArchiveHandle.java b/resources/src/main/java/org/robolectric/res/android/ZipArchiveHandle.java
new file mode 100644
index 0000000..9014524
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ZipArchiveHandle.java
@@ -0,0 +1,14 @@
+package org.robolectric.res.android;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.zip.ZipFile;
+
+public class ZipArchiveHandle {
+  final ZipFile zipFile;
+  final ImmutableMap<String, Long> dataOffsets;
+
+  public ZipArchiveHandle(ZipFile zipFile, ImmutableMap<String, Long> dataOffsets) {
+    this.zipFile = zipFile;
+    this.dataOffsets = dataOffsets;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/android/ZipFileRO.java b/resources/src/main/java/org/robolectric/res/android/ZipFileRO.java
new file mode 100644
index 0000000..c1d21e0
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/android/ZipFileRO.java
@@ -0,0 +1,340 @@
+package org.robolectric.res.android;
+
+import static org.robolectric.res.android.Asset.toIntExact;
+import static org.robolectric.res.android.Errors.NAME_NOT_FOUND;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Util.ALOGW;
+import static org.robolectric.res.android.Util.isTruthy;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+public class ZipFileRO {
+
+  static final int kCompressStored = 0;
+  static final int kCompressDeflated = 8;
+
+  final ZipArchiveHandle mHandle;
+  final String mFileName;
+
+  ZipFileRO(ZipArchiveHandle handle, String fileName) {
+    this.mHandle = handle;
+    this.mFileName = fileName;
+  }
+
+  static class ZipEntryRO {
+        ZipEntry entry;
+    String name;
+    long dataOffset;
+    Object cookie;
+
+    ZipEntryRO() {
+    }
+
+    //    ~ZipEntryRO() {
+    @Override
+    protected void finalize() {
+//      EndIteration(cookie);
+    }
+
+//    private:
+//    ZipEntryRO(final ZipEntryRO& other);
+//    ZipEntryRO& operator=(final ZipEntryRO& other);
+  };
+
+//  ~ZipFileRO() {
+  @Override
+  protected void finalize() {
+    CloseArchive();
+//    free(mFileName);
+  }
+
+  static int OpenArchive(String zipFileName, Ref<ZipArchiveHandle> mHandle) {
+    try {
+      File file = new File(zipFileName);
+      // TODO: consider moving away from ZipFile. By using ZipFile and guessDataOffsets, the zip
+      // central directory is being read twice
+      ZipFile zipFile = new ZipFile(file);
+      mHandle.set(
+          new ZipArchiveHandle(zipFile, FileMap.guessDataOffsets(file, (int) file.length())));
+      return NO_ERROR;
+    } catch (IOException e) {
+      return NAME_NOT_FOUND;
+    }
+  }
+
+  private static void CloseArchive() {
+    throw new UnsupportedOperationException();
+  }
+
+  private static String ErrorCodeString(int error) {
+    return "error " + error;
+  }
+
+  static int FindEntry(ZipArchiveHandle mHandle, String name, Ref<ZipEntry> zipEntryRef) {
+    ZipEntry entry = mHandle.zipFile.getEntry(name);
+    zipEntryRef.set(entry);
+    if (entry == null) {
+      return NAME_NOT_FOUND;
+    }
+    return NO_ERROR;
+  }
+
+  /*
+ * Open the specified file read-only.  We memory-map the entire thing and
+ * close the file before returning.
+ */
+/* static */
+  static ZipFileRO open(final String zipFileName)
+  {
+    final Ref<ZipArchiveHandle> handle = new Ref<>(null);
+    final int error = OpenArchive(zipFileName, handle);
+    if (isTruthy(error)) {
+      ALOGW("Error opening archive %s: %s", zipFileName, ErrorCodeString(error));
+      CloseArchive();
+      return null;
+    }
+
+    return new ZipFileRO(handle.get(), zipFileName);
+  }
+
+  // /* static */ ZipFileRO* ZipFileRO::openFd(int fd, String debugFileName,
+  //     boolean assume_ownership)
+  // {
+  //   ZipArchiveHandle handle;
+  //   int error = OpenArchiveFd(fd, debugFileName, &handle, assume_ownership);
+  //   if (error) {
+  //     ALOGW("Error opening archive fd %d %s: %s", fd, debugFileName, ErrorCodeString(error));
+  //     CloseArchive(handle);
+  //     return NULL;
+  //   }
+  //
+  //   return new ZipFileRO(handle, strdup(debugFileName));
+  // }
+
+  org.robolectric.res.android.ZipFileRO.ZipEntryRO findEntryByName(final String entryName)
+  {
+    ZipEntryRO data = new ZipEntryRO();
+    data.name = String(entryName);
+
+    if (mHandle.dataOffsets.get(entryName) == null) {
+      return null;
+    }
+    data.dataOffset = mHandle.dataOffsets.get(entryName);
+
+    final Ref<ZipEntry> zipEntryRef = new Ref<>(data.entry);
+    final int error = FindEntry(mHandle, data.name, zipEntryRef);
+    if (isTruthy(error)) {
+      return null;
+    }
+
+    data.entry = zipEntryRef.get();
+    return data;
+  }
+
+  /*
+   * Get the useful fields from the zip entry.
+   *
+   * Returns "false" if the offsets to the fields or the contents of the fields
+   * appear to be bogus.
+   */
+  boolean getEntryInfo(org.robolectric.res.android.ZipFileRO.ZipEntryRO entry, Ref<Short> pMethod,
+      final Ref<Long> pUncompLen, Ref<Long> pCompLen, Ref<Long> pOffset,
+      final Ref<Long> pModWhen, Ref<Long> pCrc32)
+  {
+    final ZipEntryRO zipEntry = /*reinterpret_cast<ZipEntryRO*>*/(entry);
+    final ZipEntry ze = zipEntry.entry;
+
+    if (pMethod != null) {
+      pMethod.set((short) ze.getMethod());
+    }
+    if (pUncompLen != null) {
+        pUncompLen.set(ze.getSize()); // uncompressed_length
+    }
+    if (pCompLen != null) {
+        pCompLen.set(ze.getCompressedSize());
+    }
+    if (pOffset != null) {
+      throw new UnsupportedOperationException("Figure out offset");
+      //        pOffset = ze.offset;
+    }
+    if (pModWhen != null) {
+        // todo pModWhen.set(ze.getLastModifiedTime().toMillis());
+    }
+    if (pCrc32 != null) {
+      pCrc32.set(ze.getCrc());
+    }
+
+    return true;
+  }
+
+  boolean startIteration(Ref<Enumeration<? extends ZipEntry>> cookie) {
+    return startIteration(cookie, null, null);
+  }
+
+  boolean startIteration(/* void** */ Ref<Enumeration<? extends ZipEntry>> cookie, final String prefix, final String suffix)
+  {
+    cookie.set(this.mHandle.zipFile.entries());
+//    ZipEntryRO* ze = new ZipEntryRO;
+//    String pe(prefix ? prefix : "");
+//    String se(suffix ? suffix : "");
+//    int error = StartIteration(mHandle, &(ze.cookie),
+//    prefix ? &pe : null,
+//      suffix ? &se : null);
+//    if (error) {
+//      ALOGW("Could not start iteration over %s: %s", mFileName, ErrorCodeString(error));
+//      delete ze;
+//      return false;
+//    }
+//
+//    *cookie = ze;
+    return true;
+  }
+
+  org.robolectric.res.android.ZipFileRO.ZipEntryRO nextEntry(/*void* */ Enumeration<? extends ZipEntry> cookie)
+  {
+    if (!cookie.hasMoreElements()) {
+      return null;
+    }
+    ZipEntryRO zipEntryRO = new ZipEntryRO();
+    zipEntryRO.entry = cookie.nextElement();
+    return zipEntryRO;
+//    ZipEntryRO ze = /*reinterpret_cast<ZipEntryRO*>*/(ZipEntryRO) cookie;
+//    int error = Next(ze.cookie, &(ze.entry), &(ze.name));
+//    if (error) {
+//      if (error != -1) {
+//        ALOGW("Error iteration over %s: %s", mFileName, ErrorCodeString(error));
+//      }
+//      return null;
+//    }
+//
+//    return &(ze.entry);
+  }
+
+  void endIteration(/*void**/ Object cookie)
+  {
+//    delete reinterpret_cast<ZipEntryRO*>(cookie);
+  }
+
+  void releaseEntry(org.robolectric.res.android.ZipFileRO.ZipEntryRO entry)
+  {
+//    delete reinterpret_cast<ZipEntryRO*>(entry);
+  }
+
+  /*
+   * Copy the entry's filename to the buffer.
+   */
+  int getEntryFileName(org.robolectric.res.android.ZipFileRO.ZipEntryRO entry, Ref<String> buffer)
+  {
+    buffer.set(entry.entry.getName());
+
+//    final ZipEntryRO* zipEntry = reinterpret_cast<ZipEntryRO*>(entry);
+//    final uint16_t requiredSize = zipEntry.name.name_length + 1;
+//
+//    if (bufLen < requiredSize) {
+//      ALOGW("Buffer too short, requires %d bytes for entry name", requiredSize);
+//      return requiredSize;
+//    }
+//
+//    memcpy(buffer, zipEntry.name.name, requiredSize - 1);
+//    buffer[requiredSize - 1] = '\0';
+//
+    return 0;
+  }
+
+/*
+ * Create a new FileMap object that spans the data in "entry".
+ */
+  /*FileMap*/ ZipFileRO(org.robolectric.res.android.ZipFileRO.ZipEntryRO entry)
+  {
+    throw new UnsupportedOperationException("Implememnt me");
+
+//    final ZipEntryRO *zipEntry = reinterpret_cast<ZipEntryRO*>(entry);
+//    final ZipEntry& ze = zipEntry.entry;
+//    int fd = GetFileDescriptor(mHandle);
+//    size_t actualLen = 0;
+//
+//    if (ze.method == kCompressStored) {
+//      actualLen = ze.uncompressed_length;
+//    } else {
+//      actualLen = ze.compressed_length;
+//    }
+//
+//    FileMap* newMap = new FileMap();
+//    if (!newMap.create(mFileName, fd, ze.offset, actualLen, true)) {
+//      delete newMap;
+//      return null;
+//    }
+//
+//    return newMap;
+  }
+
+  /*
+ * Create a new FileMap object that spans the data in "entry".
+ */
+  FileMap createEntryFileMap(ZipEntryRO entry)
+  {
+    // final _ZipEntryRO *zipEntry = reinterpret_cast<_ZipEntryRO*>(entry);
+    // const ZipEntry& ze = zipEntry->entry;
+    // int fd = GetFileDescriptor(mHandle);
+
+    FileMap newMap = new FileMap();
+    if (!newMap.createFromZip(
+        mFileName,
+        mHandle.zipFile,
+        entry.entry,
+        entry.dataOffset,
+        toIntExact(entry.entry.getCompressedSize()),
+        true)) {
+      // delete newMap;
+      return null;
+    }
+
+    return newMap;
+  }
+
+  /*
+   * Uncompress an entry, in its entirety, into the provided output buffer.
+   *
+   * This doesn't verify the data's CRC, which might be useful for
+   * uncompressed data.  The caller should be able to manage it.
+   */
+  boolean uncompressEntry(org.robolectric.res.android.ZipFileRO.ZipEntryRO entry, Object buffer, int size)
+  {
+    throw new UnsupportedOperationException("Implememnt me");
+//    ZipEntryRO *zipEntry = reinterpret_cast<ZipEntryRO*>(entry);
+//    final int error = ExtractToMemory(mHandle, &(zipEntry.entry),
+//    (uint8_t*) buffer, size);
+//    if (error) {
+//      ALOGW("ExtractToMemory failed with %s", ErrorCodeString(error));
+//      return false;
+//    }
+//
+//    return true;
+  }
+
+  /*
+   * Uncompress an entry, in its entirety, to an open file descriptor.
+   *
+   * This doesn't verify the data's CRC, but probably should.
+   */
+  boolean uncompressEntry(org.robolectric.res.android.ZipFileRO.ZipEntryRO entry, int fd)
+  {
+    throw new UnsupportedOperationException("Implememnt me");
+//    ZipEntryRO *zipEntry = reinterpret_cast<ZipEntryRO*>(entry);
+//    final int error = ExtractEntryToFile(mHandle, &(zipEntry.entry), fd);
+//    if (error) {
+//      ALOGW("ExtractToMemory failed with %s", ErrorCodeString(error));
+//      return false;
+//    }
+//
+//    return true;
+  }
+
+  static String String(String string) {
+    return string;
+  }
+}
diff --git a/resources/src/main/java/org/robolectric/res/builder/XmlBlock.java b/resources/src/main/java/org/robolectric/res/builder/XmlBlock.java
new file mode 100644
index 0000000..f98841e
--- /dev/null
+++ b/resources/src/main/java/org/robolectric/res/builder/XmlBlock.java
@@ -0,0 +1,73 @@
+package org.robolectric.res.builder;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import javax.annotation.Nullable;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.robolectric.res.Fs;
+import org.w3c.dom.Document;
+import org.xml.sax.SAXException;
+
+/**
+ * An XML block is a parsed representation of a resource XML file. Similar in nature
+ * to Android's XmlBlock class.
+ */
+public class XmlBlock {
+
+  private static DocumentBuilder documentBuilder;
+
+  private final Document document;
+  private final Path path;
+  private final String packageName;
+
+  private static synchronized Document parse(Path xmlFile) {
+    InputStream inputStream = null;
+    try {
+      if (documentBuilder == null) {
+        DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
+        documentBuilderFactory.setNamespaceAware(true);
+        documentBuilderFactory.setIgnoringComments(true);
+        documentBuilderFactory.setIgnoringElementContentWhitespace(true);
+        documentBuilder = documentBuilderFactory.newDocumentBuilder();
+      }
+      inputStream = Fs.getInputStream(xmlFile);
+      return documentBuilder.parse(inputStream);
+    } catch (ParserConfigurationException | IOException | SAXException e) {
+      throw new RuntimeException(e);
+    } finally {
+      if (inputStream != null) try {
+        inputStream.close();
+      } catch (IOException e) {
+        // ignore
+      }
+    }
+  }
+
+  @Nullable
+  public static XmlBlock create(Path path, String packageName) {
+    Document document = parse(path);
+
+    return document == null ? null : new XmlBlock(document, path, packageName);
+  }
+
+  private XmlBlock(Document document, Path path, String packageName) {
+    this.document = document;
+    this.path = path;
+    this.packageName = packageName;
+  }
+
+  public Document getDocument() {
+    return document;
+  }
+
+  public Path getPath() {
+    return path;
+  }
+
+  public String getPackageName() {
+    return packageName;
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/RoboSettingsTest.java b/resources/src/test/java/org/robolectric/RoboSettingsTest.java
new file mode 100644
index 0000000..5de517d
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/RoboSettingsTest.java
@@ -0,0 +1,37 @@
+package org.robolectric;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RoboSettingsTest {
+
+  private boolean originalUseGlobalScheduler;
+
+  @Before
+  public void setUp() {
+    originalUseGlobalScheduler = RoboSettings.isUseGlobalScheduler();
+  }
+
+  @After
+  public void tearDown() {
+    RoboSettings.setUseGlobalScheduler(originalUseGlobalScheduler);
+  }
+
+  @Test
+  public void isUseGlobalScheduler_defaultFalse() {
+    assertFalse(RoboSettings.isUseGlobalScheduler());
+  }
+
+  @Test
+  public void setUseGlobalScheduler() {
+    RoboSettings.setUseGlobalScheduler(true);
+    assertTrue(RoboSettings.isUseGlobalScheduler());
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/manifest/ActivityDataTest.java b/resources/src/test/java/org/robolectric/manifest/ActivityDataTest.java
new file mode 100644
index 0000000..4f9b5b5
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/manifest/ActivityDataTest.java
@@ -0,0 +1,32 @@
+package org.robolectric.manifest;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ActivityDataTest {
+
+  @Test
+  public void test_non_android_namespace() {
+    HashMap<String, String> attrs = new HashMap<>();
+    attrs.put("testns:name", ".test.TestActivity");
+    ActivityData activityData = new ActivityData("testns", attrs, new ArrayList<IntentFilterData>());
+
+    assertThat(activityData.getName()).isEqualTo(".test.TestActivity");
+    assertThat(activityData.getAllAttributes().get("android:name")).isNull();
+  }
+
+  @Test
+  public void test_config_changes() {
+    HashMap<String, String> attrs = new HashMap<>();
+    attrs.put("android:configChanges", "mcc|screenLayout|orientation");
+    ActivityData activityData = new ActivityData(attrs, new ArrayList<IntentFilterData>());
+
+    assertThat(activityData.getConfigChanges()).isEqualTo("mcc|screenLayout|orientation");
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/manifest/MetaDataTest.java b/resources/src/test/java/org/robolectric/manifest/MetaDataTest.java
new file mode 100644
index 0000000..aa35ba9
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/manifest/MetaDataTest.java
@@ -0,0 +1,54 @@
+package org.robolectric.manifest;
+
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.res.ResourceTable;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+/**
+ * Tests for {@link MetaData}
+ */
+@RunWith(JUnit4.class)
+public class MetaDataTest {
+
+  @Mock private ResourceTable resourceProvider;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+  }
+
+  @Test
+  public void testNonExistantResource_throwsResourceNotFoundException() throws Exception {
+    Element metaDataElement = createMetaDataNode("aName", "@xml/non_existant_resource");
+
+    MetaData metaData = new MetaData(ImmutableList.<Node>of(metaDataElement));
+
+    assertThrows(RoboNotFoundException.class, () -> metaData.init(resourceProvider, "a.package"));
+  }
+
+  private static Element createMetaDataNode(String name, String value) {
+    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+    Element metaDataElement;
+    try {
+      DocumentBuilder db = dbf.newDocumentBuilder();
+      metaDataElement = db.newDocument().createElement("meta-data");
+      metaDataElement.setAttribute("android:name", name);
+      metaDataElement.setAttribute("android:value", value);
+    } catch (ParserConfigurationException e) {
+      throw new RuntimeException(e);
+    }
+    return metaDataElement;
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/QualifiersTest.java b/resources/src/test/java/org/robolectric/res/QualifiersTest.java
new file mode 100644
index 0000000..1b59e68
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/QualifiersTest.java
@@ -0,0 +1,100 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.res.android.ResTable_config;
+
+@RunWith(JUnit4.class)
+public class QualifiersTest {
+  @Test
+  public void testQualifiers() throws Exception {
+    assertThat(configFrom("values-land-finger")).isEqualTo("land-finger");
+  }
+
+  @Test
+  public void testWhenQualifiersFailToParse() throws Exception {
+    try {
+      configFrom("values-unknown-v23");
+      fail("Expected exception");
+    } catch(IllegalArgumentException expected) {
+      assertThat(expected.getMessage()).contains("failed to parse qualifiers 'unknown-v23");
+    }
+  }
+
+  private String configFrom(String path) {
+    Path xmlFile = Paths.get(path, "whatever.xml");
+    Qualifiers qualifiers = Qualifiers.fromParentDir(xmlFile.getParent());
+
+    ResTable_config config = new XmlContext("package", xmlFile, qualifiers)
+        .getConfig();
+    return config.toString();
+  }
+
+  ///////// deprecated stuff...
+
+  @Test public void addPlatformVersion() throws Exception {
+    assertThat(Qualifiers.addPlatformVersion("", 21)).isEqualTo("v21");
+    assertThat(Qualifiers.addPlatformVersion("v23", 21)).isEqualTo("v23");
+    assertThat(Qualifiers.addPlatformVersion("foo-v14", 21)).isEqualTo("foo-v14");
+  }
+
+  @Test public void addSmallestScreenWidth() throws Exception {
+    assertThat(Qualifiers.addSmallestScreenWidth("", 320)).isEqualTo("sw320dp");
+    assertThat(Qualifiers.addSmallestScreenWidth("sw160dp", 320)).isEqualTo("sw160dp");
+    assertThat(Qualifiers.addSmallestScreenWidth("sw480dp", 320)).isEqualTo("sw480dp");
+    assertThat(Qualifiers.addSmallestScreenWidth("en-v23", 320)).isEqualTo("en-v23-sw320dp"); // todo: order is wrong here
+    assertThat(Qualifiers.addSmallestScreenWidth("en-sw160dp-v23", 320)).isEqualTo("en-sw160dp-v23");
+    assertThat(Qualifiers.addSmallestScreenWidth("en-sw480dp-v23", 320)).isEqualTo("en-sw480dp-v23");
+  }
+
+  @Test public void addScreenWidth() throws Exception {
+    assertThat(Qualifiers.addScreenWidth("", 320)).isEqualTo("w320dp");
+    assertThat(Qualifiers.addScreenWidth("w160dp", 320)).isEqualTo("w160dp");
+    assertThat(Qualifiers.addScreenWidth("w480dp", 320)).isEqualTo("w480dp");
+    assertThat(Qualifiers.addScreenWidth("en-v23", 320)).isEqualTo("en-v23-w320dp"); // todo: order is wrong here
+    assertThat(Qualifiers.addScreenWidth("en-w160dp-v23", 320)).isEqualTo("en-w160dp-v23");
+    assertThat(Qualifiers.addScreenWidth("en-w480dp-v23", 320)).isEqualTo("en-w480dp-v23");
+  }
+
+  @Test public void getSmallestScreenWidth() {
+    assertThat(Qualifiers.getSmallestScreenWidth("sw320dp")).isEqualTo(320);
+    assertThat(Qualifiers.getSmallestScreenWidth("sw320dp-v7")).isEqualTo(320);
+    assertThat(Qualifiers.getSmallestScreenWidth("en-rUS-sw320dp")).isEqualTo(320);
+    assertThat(Qualifiers.getSmallestScreenWidth("en-rUS-sw320dp-v7")).isEqualTo(320);
+    assertThat(Qualifiers.getSmallestScreenWidth("en-rUS-v7")).isEqualTo(-1);
+    assertThat(Qualifiers.getSmallestScreenWidth("en-rUS-w320dp-v7")).isEqualTo(-1);
+  }
+
+  @Test public void getAddSmallestScreenWidth() {
+    assertThat(Qualifiers.addSmallestScreenWidth("v7", 320)).isEqualTo("v7-sw320dp");
+    assertThat(Qualifiers.addSmallestScreenWidth("sw320dp-v7", 480)).isEqualTo("sw320dp-v7");
+  }
+
+  @Test public void getScreenWidth() {
+    assertThat(Qualifiers.getScreenWidth("w320dp")).isEqualTo(320);
+    assertThat(Qualifiers.getScreenWidth("w320dp-v7")).isEqualTo(320);
+    assertThat(Qualifiers.getScreenWidth("en-rUS-w320dp")).isEqualTo(320);
+    assertThat(Qualifiers.getScreenWidth("en-rUS-w320dp-v7")).isEqualTo(320);
+    assertThat(Qualifiers.getScreenWidth("en-rUS-v7")).isEqualTo(-1);
+    assertThat(Qualifiers.getScreenWidth("de-v23-sw320dp-w1024dp")).isEqualTo(1024);
+    assertThat(Qualifiers.getScreenWidth("en-rUS-sw320dp-v7")).isEqualTo(-1);
+  }
+
+  @Test public void getAddScreenWidth() {
+    assertThat(Qualifiers.addScreenWidth("v7", 320)).isEqualTo("v7-w320dp");
+    assertThat(Qualifiers.addScreenWidth("w320dp-v7", 480)).isEqualTo("w320dp-v7");
+  }
+
+  @Test public void getOrientation() {
+    assertThat(Qualifiers.getOrientation("land")).isEqualTo("land");
+    assertThat(Qualifiers.getOrientation("en-rUs-land")).isEqualTo("land");
+    assertThat(Qualifiers.getOrientation("port")).isEqualTo("port");
+    assertThat(Qualifiers.getOrientation("port-v7")).isEqualTo("port");
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/ResTypeTest.java b/resources/src/test/java/org/robolectric/res/ResTypeTest.java
new file mode 100644
index 0000000..a550b2c
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/ResTypeTest.java
@@ -0,0 +1,33 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link ResType}
+ */
+@RunWith(JUnit4.class)
+public class ResTypeTest {
+
+  @Test
+  public void testInferFromValue() {
+
+    assertThat(ResType.inferFromValue("#802C76AD")).isEqualTo(ResType.COLOR);
+
+    assertThat(ResType.inferFromValue("true")).isEqualTo(ResType.BOOLEAN);
+    assertThat(ResType.inferFromValue("false")).isEqualTo(ResType.BOOLEAN);
+
+    assertThat(ResType.inferFromValue("10dp")).isEqualTo(ResType.DIMEN);
+    assertThat(ResType.inferFromValue("10sp")).isEqualTo(ResType.DIMEN);
+    assertThat(ResType.inferFromValue("10pt")).isEqualTo(ResType.DIMEN);
+    assertThat(ResType.inferFromValue("10px")).isEqualTo(ResType.DIMEN);
+    assertThat(ResType.inferFromValue("10in")).isEqualTo(ResType.DIMEN);
+
+    assertThat(ResType.inferFromValue("10")).isEqualTo(ResType.INTEGER);
+
+    assertThat(ResType.inferFromValue("10.9")).isEqualTo(ResType.FRACTION);
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/ResourceIdsTest.java b/resources/src/test/java/org/robolectric/res/ResourceIdsTest.java
new file mode 100644
index 0000000..472c007
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/ResourceIdsTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link ResourceIds}
+ */
+@RunWith(JUnit4.class)
+public class ResourceIdsTest {
+  @Test
+  public void testIsFrameworkResource() {
+    assertThat(ResourceIds.isFrameworkResource(0x01000000)).isTrue();
+    assertThat(ResourceIds.isFrameworkResource(0x7F000000)).isFalse();
+  }
+
+  @Test
+  public void testGetPackageIdentifier() {
+    assertThat(ResourceIds.getPackageIdentifier(0x01000000)).isEqualTo(0x01);
+    assertThat(ResourceIds.getPackageIdentifier(0x7F000000)).isEqualTo(0x7F);
+  }
+
+  @Test
+  public void testGetTypeIdentifier() {
+    assertThat(ResourceIds.getTypeIdentifier(0x01019876)).isEqualTo(0x01);
+    assertThat(ResourceIds.getTypeIdentifier(0x7F781234)).isEqualTo(0x78);
+  }
+
+  @Test
+  public void testGetEntryIdentifier() {
+    assertThat(ResourceIds.getEntryIdentifier(0x01019876)).isEqualTo(0x9876);
+    assertThat(ResourceIds.getEntryIdentifier(0x7F781234)).isEqualTo(0x1234);
+  }
+
+  @Test
+  public void testMakeIdentifier() {
+    assertThat(ResourceIds.makeIdentifer(0x01, 0x01, 0x9876)).isEqualTo(0x01019876);
+    assertThat(ResourceIds.makeIdentifer(0x7F, 0x78, 0x1234)).isEqualTo(0x7F781234);
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/StaxValueLoaderTest.java b/resources/src/test/java/org/robolectric/res/StaxValueLoaderTest.java
new file mode 100644
index 0000000..ca45ed4
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/StaxValueLoaderTest.java
@@ -0,0 +1,68 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.StringReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.res.android.ResTable_config;
+
+@RunWith(JUnit4.class)
+@SuppressWarnings("NewApi")
+public class StaxValueLoaderTest {
+
+  private PackageResourceTable resourceTable;
+  private NodeHandler topLevelNodeHandler;
+  private StaxDocumentLoader staxDocumentLoader;
+
+  @Before
+  public void setUp() throws Exception {
+    resourceTable = new PackageResourceTable("pkg");
+
+    topLevelNodeHandler = new NodeHandler();
+    staxDocumentLoader = new StaxDocumentLoader("pkg", null, topLevelNodeHandler);
+  }
+
+  @Test
+  public void ignoresXliffTags() throws Exception {
+    topLevelNodeHandler.addHandler("resources", new NodeHandler()
+        .addHandler("string", new StaxValueLoader(resourceTable, "string", ResType.CHAR_SEQUENCE))
+    );
+
+    parse("<resources xmlns:xliff=\"urn:oasis:names:tc:xliff:document:1.2\">" +
+        "<string name=\"preposition_for_date\">on <xliff:g id=\"date\" example=\"May 29\">%s</xliff:g></string>" +
+        "</resources>");
+
+    assertThat(resourceTable.getValue(new ResName("pkg:string/preposition_for_date"), new ResTable_config()).getData())
+        .isEqualTo("on %s");
+  }
+
+  @Test
+  public void ignoresBTags() throws Exception {
+    topLevelNodeHandler.addHandler("resources", new NodeHandler()
+        .addHandler("item[@type='string']", new StaxValueLoader(resourceTable, "string", ResType.CHAR_SEQUENCE))
+    );
+
+    parse("<resources xmlns:xliff=\"urn:oasis:names:tc:xliff:document:1.2\">" +
+        "<item type=\"string\" name=\"sms_short_code_details\">This <b>may cause charges</b> on your mobile account.</item>" +
+        "</resources>");
+
+    assertThat(resourceTable.getValue(new ResName("pkg:string/sms_short_code_details"), new ResTable_config()).getData())
+        .isEqualTo("This may cause charges on your mobile account.");
+  }
+
+  private void parse(String xml) throws XMLStreamException {
+    XMLInputFactory factory = XMLInputFactory.newFactory();
+    XMLStreamReader xmlStreamReader = factory.createXMLStreamReader(new StringReader(xml));
+    Path path = Paths.get("/tmp/fake.txt");
+    Qualifiers qualifiers = Qualifiers.fromParentDir(path.getParent());
+    staxDocumentLoader.doParse(xmlStreamReader, new XmlContext("pkg", path, qualifiers));
+  }
+}
\ No newline at end of file
diff --git a/resources/src/test/java/org/robolectric/res/StyleDataTest.java b/resources/src/test/java/org/robolectric/res/StyleDataTest.java
new file mode 100644
index 0000000..26f7cf5
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/StyleDataTest.java
@@ -0,0 +1,65 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class StyleDataTest {
+
+  private final ResName androidSearchViewStyle = new ResName("android", "attr", "searchViewStyle");
+  private final ResName myLibSearchViewStyle = new ResName("library.resource", "attr", "searchViewStyle");
+  private final ResName myAppSearchViewStyle = new ResName("my.app", "attr", "searchViewStyle");
+
+  @Test
+  public void getAttrValue_willFindLibraryResourcesWithSameName() {
+    StyleData styleData = new StyleData("library.resource", "Theme_MyApp", "Theme_Material", asList(
+        new AttributeResource(myLibSearchViewStyle, "lib_value", "library.resource")
+    ));
+
+    assertThat(styleData.getAttrValue(myAppSearchViewStyle).value).isEqualTo("lib_value");
+    assertThat(styleData.getAttrValue(myLibSearchViewStyle).value).isEqualTo("lib_value");
+
+    assertThat(styleData.getAttrValue(androidSearchViewStyle)).isNull();
+  }
+
+  @Test
+  public void getAttrValue_willNotFindFrameworkResourcesWithSameName() {
+    StyleData styleData = new StyleData("android", "Theme_Material", "Theme", asList(
+        new AttributeResource(androidSearchViewStyle, "android_value", "android")
+    ));
+
+    assertThat(styleData.getAttrValue(androidSearchViewStyle).value).isEqualTo("android_value");
+
+    assertThat(styleData.getAttrValue(myAppSearchViewStyle)).isNull();
+    assertThat(styleData.getAttrValue(myLibSearchViewStyle)).isNull();
+  }
+
+  @Test
+  public void getAttrValue_willChooseBetweenAmbiguousAttributes() {
+    StyleData styleData = new StyleData("android", "Theme_Material", "Theme", asList(
+        new AttributeResource(myLibSearchViewStyle, "lib_value", "library.resource"),
+        new AttributeResource(androidSearchViewStyle, "android_value", "android")
+    ));
+
+    assertThat(styleData.getAttrValue(androidSearchViewStyle).value).isEqualTo("android_value");
+    assertThat(styleData.getAttrValue(myLibSearchViewStyle).value).isEqualTo("lib_value");
+
+    // todo: any packageNames that aren't 'android' should be treated as equivalent
+//    assertThat(styleData.getAttrValue(myAppSearchViewStyle).value).isEqualTo("lib_value");
+  }
+
+  @Test
+  public void getAttrValue_willReturnTrimmedAttributeValues() throws Exception {
+    StyleData styleData = new StyleData("library.resource", "Theme_MyApp", "Theme_Material", asList(
+            new AttributeResource(myLibSearchViewStyle, "\n lib_value ", "library.resource")
+    ));
+
+    assertThat(styleData.getAttrValue(myAppSearchViewStyle).value).isEqualTo("\n lib_value ");
+    assertThat(styleData.getAttrValue(myLibSearchViewStyle).trimmedValue).isEqualTo("lib_value");
+  }
+
+}
diff --git a/resources/src/test/java/org/robolectric/res/ThemeStyleSetTest.java b/resources/src/test/java/org/robolectric/res/ThemeStyleSetTest.java
new file mode 100644
index 0000000..dd87b82
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/ThemeStyleSetTest.java
@@ -0,0 +1,55 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ThemeStyleSetTest {
+
+  private ThemeStyleSet themeStyleSet;
+
+  @Before
+  public void setUp() throws Exception {
+    themeStyleSet = new ThemeStyleSet();
+  }
+
+  @Test
+  public void shouldFindAttributesFromAnAppliedStyle() throws Exception {
+    themeStyleSet = new ThemeStyleSet();
+    themeStyleSet.apply(createStyle("style1",
+        createAttribute("string1", "string1 value from style1"),
+        createAttribute("string2", "string2 value from style1")
+    ), false);
+    themeStyleSet.apply(createStyle("style2", createAttribute("string2", "string2 value from style2")), false);
+    assertThat(themeStyleSet.getAttrValue(attrName("string1")).value).isEqualTo("string1 value from style1");
+    assertThat(themeStyleSet.getAttrValue(attrName("string2")).value).isEqualTo("string2 value from style1");
+  }
+
+  @Test
+  public void shouldFindAttributesFromAnAppliedFromForcedStyle() throws Exception {
+    themeStyleSet.apply(createStyle("style1",
+        createAttribute("string1", "string1 value from style1"),
+        createAttribute("string2", "string2 value from style1")
+    ), false);
+    themeStyleSet.apply(createStyle("style2", createAttribute("string1", "string1 value from style2")), true);
+    assertThat(themeStyleSet.getAttrValue(attrName("string1")).value).isEqualTo("string1 value from style2");
+    assertThat(themeStyleSet.getAttrValue(attrName("string2")).value).isEqualTo("string2 value from style1");
+  }
+
+  private StyleData createStyle(String styleName, AttributeResource... attributeResources) {
+    return new StyleData("package", styleName, null, asList(attributeResources));
+  }
+
+  private AttributeResource createAttribute(String attrName, String value) {
+    return new AttributeResource(attrName(attrName), value, "package");
+  }
+
+  private ResName attrName(String attrName) {
+    return new ResName("package", "attr", attrName);
+  }
+}
\ No newline at end of file
diff --git a/resources/src/test/java/org/robolectric/res/android/ConfigDescriptionTest.java b/resources/src/test/java/org/robolectric/res/android/ConfigDescriptionTest.java
new file mode 100644
index 0000000..aa199d5
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/android/ConfigDescriptionTest.java
@@ -0,0 +1,415 @@
+package org.robolectric.res.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.res.android.ResTable_config.DENSITY_ANY;
+import static org.robolectric.res.android.ResTable_config.DENSITY_HIGH;
+import static org.robolectric.res.android.ResTable_config.DENSITY_LOW;
+import static org.robolectric.res.android.ResTable_config.DENSITY_MEDIUM;
+import static org.robolectric.res.android.ResTable_config.DENSITY_NONE;
+import static org.robolectric.res.android.ResTable_config.DENSITY_TV;
+import static org.robolectric.res.android.ResTable_config.DENSITY_XHIGH;
+import static org.robolectric.res.android.ResTable_config.DENSITY_XXHIGH;
+import static org.robolectric.res.android.ResTable_config.DENSITY_XXXHIGH;
+import static org.robolectric.res.android.ResTable_config.KEYBOARD_12KEY;
+import static org.robolectric.res.android.ResTable_config.KEYBOARD_NOKEYS;
+import static org.robolectric.res.android.ResTable_config.KEYBOARD_QWERTY;
+import static org.robolectric.res.android.ResTable_config.KEYSHIDDEN_NO;
+import static org.robolectric.res.android.ResTable_config.KEYSHIDDEN_SOFT;
+import static org.robolectric.res.android.ResTable_config.KEYSHIDDEN_YES;
+import static org.robolectric.res.android.ResTable_config.LAYOUTDIR_ANY;
+import static org.robolectric.res.android.ResTable_config.LAYOUTDIR_LTR;
+import static org.robolectric.res.android.ResTable_config.LAYOUTDIR_RTL;
+import static org.robolectric.res.android.ResTable_config.NAVHIDDEN_NO;
+import static org.robolectric.res.android.ResTable_config.NAVHIDDEN_YES;
+import static org.robolectric.res.android.ResTable_config.NAVIGATION_DPAD;
+import static org.robolectric.res.android.ResTable_config.NAVIGATION_NONAV;
+import static org.robolectric.res.android.ResTable_config.NAVIGATION_TRACKBALL;
+import static org.robolectric.res.android.ResTable_config.NAVIGATION_WHEEL;
+import static org.robolectric.res.android.ResTable_config.ORIENTATION_LAND;
+import static org.robolectric.res.android.ResTable_config.ORIENTATION_PORT;
+import static org.robolectric.res.android.ResTable_config.ORIENTATION_SQUARE;
+import static org.robolectric.res.android.ResTable_config.SCREENLONG_NO;
+import static org.robolectric.res.android.ResTable_config.SCREENROUND_NO;
+import static org.robolectric.res.android.ResTable_config.SCREENROUND_YES;
+import static org.robolectric.res.android.ResTable_config.SCREENSIZE_LARGE;
+import static org.robolectric.res.android.ResTable_config.SCREENSIZE_NORMAL;
+import static org.robolectric.res.android.ResTable_config.SCREENSIZE_SMALL;
+import static org.robolectric.res.android.ResTable_config.SCREENSIZE_XLARGE;
+import static org.robolectric.res.android.ResTable_config.TOUCHSCREEN_FINGER;
+import static org.robolectric.res.android.ResTable_config.TOUCHSCREEN_NOTOUCH;
+import static org.robolectric.res.android.ResTable_config.TOUCHSCREEN_STYLUS;
+import static org.robolectric.res.android.ResTable_config.UI_MODE_NIGHT_NO;
+import static org.robolectric.res.android.ResTable_config.UI_MODE_NIGHT_YES;
+import static org.robolectric.res.android.ResTable_config.UI_MODE_TYPE_APPLIANCE;
+import static org.robolectric.res.android.ResTable_config.UI_MODE_TYPE_CAR;
+import static org.robolectric.res.android.ResTable_config.UI_MODE_TYPE_TELEVISION;
+import static org.robolectric.res.android.ResTable_config.UI_MODE_TYPE_WATCH;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ConfigDescriptionTest {
+
+  @Test
+  public void parse_mcc() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("mcc310", config);
+    assertThat(config.mcc).isEqualTo(310);
+  }
+
+  @Test
+  public void parse_mcc_upperCase() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("MCC310", config);
+    assertThat(config.mcc).isEqualTo(310);
+  }
+
+  @Test
+  public void parse_mcc_mnc_upperCase() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("mcc310-mnc004", config);
+    assertThat(config.mcc).isEqualTo(310);
+    assertThat(config.mnc).isEqualTo(4);
+  }
+
+  @Test
+  public void parse_layoutDirection_leftToRight() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("ldltr", config);
+    assertThat(config.screenLayout).isEqualTo(LAYOUTDIR_LTR);
+  }
+
+  @Test
+  public void parse_layoutDirection_rightToLeft() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("ldrtl", config);
+    assertThat(config.screenLayout).isEqualTo(LAYOUTDIR_RTL);
+  }
+
+  @Test
+  public void parse_layoutDirection_any() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("any", config);
+    assertThat(config.screenLayout).isEqualTo(LAYOUTDIR_ANY);
+  }
+
+  @Test
+  public void parse_screenSize_small() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("small", config);
+    assertThat(config.screenLayout).isEqualTo(SCREENSIZE_SMALL);
+  }
+
+  @Test
+  public void parse_screenSize_normal() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("normal", config);
+    assertThat(config.screenLayout).isEqualTo(SCREENSIZE_NORMAL);
+  }
+
+  @Test
+  public void parse_screenSize_large() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("large", config);
+    assertThat(config.screenLayout).isEqualTo(SCREENSIZE_LARGE);
+  }
+
+  @Test
+  public void parse_screenSize_xlarge() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("xlarge", config);
+    assertThat(config.screenLayout).isEqualTo(SCREENSIZE_XLARGE);
+  }
+
+  @Test
+  public void parse_smallestScreenWidth() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("sw320dp", config);
+    assertThat(config.smallestScreenWidthDp).isEqualTo(320);
+  }
+
+  @Test public void getScreenWidth() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("w480dp", config);
+    assertThat(config.screenWidthDp).isEqualTo(480);
+  }
+
+  @Test public void getScreenHeight() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("h1024dp", config);
+    assertThat(config.screenHeightDp).isEqualTo(1024);
+  }
+
+  @Test public void parse_screenLayoutLong_notlong() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("notlong", config);
+    assertThat(config.screenLayout).isEqualTo(SCREENLONG_NO);
+  }
+
+  @Test public void parse_screenRound_round() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("round", config);
+    assertThat(config.screenLayout2).isEqualTo((byte) SCREENROUND_YES);
+  }
+
+  @Test public void parse_screenRound_notround() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("notround", config);
+    assertThat(config.screenLayout2).isEqualTo((byte) SCREENROUND_NO);
+  }
+
+  @Test public void parse_orientation_port() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("port", config);
+    assertThat(config.orientation).isEqualTo(ORIENTATION_PORT);
+  }
+
+  @Test public void parse_orientation_land() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("land", config);
+    assertThat(config.orientation).isEqualTo(ORIENTATION_LAND);
+  }
+
+  @Test public void parse_orientation_square() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("square", config);
+    assertThat(config.orientation).isEqualTo(ORIENTATION_SQUARE);
+  }
+
+  @Test public void parse_uiModeType_car() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("car", config);
+    assertThat(config.uiMode).isEqualTo(UI_MODE_TYPE_CAR);
+  }
+
+  @Test public void parse_uiModeType_television() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("television", config);
+    assertThat(config.uiMode).isEqualTo(UI_MODE_TYPE_TELEVISION);
+  }
+
+  @Test public void parse_uiModeType_appliance() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("appliance", config);
+    assertThat(config.uiMode).isEqualTo(UI_MODE_TYPE_APPLIANCE);
+  }
+
+  @Test public void parse_uiModeType_watch() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("watch", config);
+    assertThat(config.uiMode).isEqualTo(UI_MODE_TYPE_WATCH);
+  }
+
+  @Test public void parse_uiModeNight_night() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("night", config);
+    assertThat(config.uiMode).isEqualTo(UI_MODE_NIGHT_YES);
+  }
+
+  @Test public void parse_uiModeNight_notnight() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("notnight", config);
+    assertThat(config.uiMode).isEqualTo(UI_MODE_NIGHT_NO);
+  }
+
+  @Test public void parse_density_any() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("anydpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_ANY);
+  }
+
+  @Test public void parse_density_nodpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("nodpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_NONE);
+  }
+
+  @Test public void parse_density_ldpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("ldpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_LOW);
+  }
+
+  @Test public void parse_density_mdpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("mdpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_MEDIUM);
+  }
+
+  @Test public void parse_density_tvdpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("tvdpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_TV);
+  }
+
+  @Test public void parse_density_hdpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("hdpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_HIGH);
+  }
+
+  @Test public void parse_density_xhdpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("xhdpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_XHIGH);
+  }
+
+  @Test public void parse_density_xxhdpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("xxhdpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_XXHIGH);
+  }
+
+  @Test public void parse_density_xxxhdpi() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("xxxhdpi", config);
+    assertThat(config.density).isEqualTo(DENSITY_XXXHIGH);
+  }
+
+  @Test public void parsedensity_specificDpt() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("720dpi", config);
+    assertThat(config.density).isEqualTo(720);
+  }
+
+  @Test public void parse_touchscreen_notouch() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("notouch", config);
+    assertThat(config.touchscreen).isEqualTo(TOUCHSCREEN_NOTOUCH);
+  }
+
+  @Test public void parse_touchscreen_stylus() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("stylus", config);
+    assertThat(config.touchscreen).isEqualTo(TOUCHSCREEN_STYLUS);
+  }
+
+  @Test public void parse_touchscreen_finger() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("finger", config);
+    assertThat(config.touchscreen).isEqualTo(TOUCHSCREEN_FINGER);
+  }
+
+  @Test public void parse_keysHidden_keysexposed() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("keysexposed", config);
+    assertThat(config.inputFlags).isEqualTo(KEYSHIDDEN_NO);
+  }
+
+  @Test public void parse_keysHidden_keyshidden() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("keyshidden", config);
+    assertThat(config.inputFlags).isEqualTo(KEYSHIDDEN_YES);
+  }
+
+  @Test public void parse_keysHidden_keyssoft() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("keyssoft", config);
+    assertThat(config.inputFlags).isEqualTo(KEYSHIDDEN_SOFT);
+  }
+
+  @Test public void parse_keyboard_nokeys() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("nokeys", config);
+    assertThat(config.keyboard).isEqualTo(KEYBOARD_NOKEYS);
+  }
+
+  @Test public void parse_keyboard_qwerty() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("qwerty", config);
+    assertThat(config.keyboard).isEqualTo(KEYBOARD_QWERTY);
+  }
+
+  @Test public void parse_keyboard_12key() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("12key", config);
+    assertThat(config.keyboard).isEqualTo(KEYBOARD_12KEY);
+  }
+
+  @Test public void parse_navHidden_navexposed() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("navexposed", config);
+    assertThat(config.inputFlags).isEqualTo(NAVHIDDEN_NO);
+  }
+
+  @Test public void parse_navHidden_navhidden() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("navhidden", config);
+    assertThat(config.inputFlags).isEqualTo(NAVHIDDEN_YES);
+  }
+
+  @Test public void parse_navigation_nonav() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("nonav", config);
+    assertThat(config.navigation).isEqualTo(NAVIGATION_NONAV);
+  }
+
+  @Test public void parse_navigation_dpad() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("dpad", config);
+    assertThat(config.navigation).isEqualTo(NAVIGATION_DPAD);
+  }
+
+  @Test public void parse_navigation_trackball() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("trackball", config);
+    assertThat(config.navigation).isEqualTo(NAVIGATION_TRACKBALL);
+  }
+
+  @Test public void parse_navigation_wheel() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("wheel", config);
+    assertThat(config.navigation).isEqualTo(NAVIGATION_WHEEL);
+  }
+
+  @Test public void parse_screenSize() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("480x320", config);
+    assertThat(config.screenWidth).isEqualTo(480);
+    assertThat(config.screenHeight).isEqualTo(320);
+  }
+
+  @Test public void parse_screenSize_ignoreWidthLessThanHeight() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("320x480", config);
+    assertThat(config.screenWidth).isEqualTo(0);
+    assertThat(config.screenHeight).isEqualTo(0);
+  }
+
+  @Test public void parse_version() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("v12", config);
+    assertThat(config.sdkVersion).isEqualTo(12);
+    assertThat(config.minorVersion).isEqualTo(0);
+  }
+
+  @Test public void parse_language() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("en", config);
+    assertThat(config.languageString()).isEqualTo("en");
+    assertThat(config.minorVersion).isEqualTo(0);
+  }
+
+  @Test public void parse_languageAndRegion() {
+    ResTable_config config = new ResTable_config();
+    ConfigDescription.parse("fr-rFR", config);
+    assertThat(config.languageString()).isEqualTo("fr");
+    assertThat(config.regionString()).isEqualTo("FR");
+  }
+
+  @Test public void parse_multipleQualifiers() {
+    ResTable_config config = new ResTable_config();
+    assertThat(ConfigDescription.parse("en-rUS-sw320dp-v7", config)).isTrue();
+    assertThat(config.languageString()).isEqualTo("en");
+    assertThat(config.regionString()).isEqualTo("US");
+    assertThat(config.smallestScreenWidthDp).isEqualTo(320);
+    assertThat(config.sdkVersion).isEqualTo(ConfigDescription.SDK_HONEYCOMB_MR2);
+  }
+
+  @Test public void parse_multipleQualifiers_outOfOrder() {
+    ResTable_config config = new ResTable_config();
+    assertThat(ConfigDescription.parse("v7-en-rUS-sw320dp", config)).isFalse();
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/android/ResTableConfigTest.java b/resources/src/test/java/org/robolectric/res/android/ResTableConfigTest.java
new file mode 100644
index 0000000..ad7b5c7
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/android/ResTableConfigTest.java
@@ -0,0 +1,169 @@
+
+package org.robolectric.res.android;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ResTableConfigTest {
+
+  public static final int MCC_US_CARRIER = 310;
+  public static final int MCC_US_VERIZON = 4;
+  public static final byte[] LANGUAGE_FRENCH = new byte[] {'f', 'r'};
+  private static final byte[] LANGUAGE_SPANISH = new byte[]{'e', 's'};
+
+  @Test
+  public void isBetterThan_emptyConfig() {
+    // When a configuration is not specified the result is always false
+    assertThat(newBuilder().build().isBetterThan(newBuilder().build(), newBuilder().build())).isFalse();
+  }
+
+  /**
+   * https://developer.android.com/guide/topics/resources/providing-resources.html#MccQualifier
+   * @see <a href="http://mcc-mnc.com/">http://mcc-mnc.com/</a>
+   */
+  @Test
+  public void isBetterThan_mcc() {
+    // When requested is less of a match
+    assertThat(newBuilder().setMcc(MCC_US_CARRIER).build()
+        .isBetterThan(newBuilder().setMcc(MCC_US_CARRIER).build(), newBuilder().build()))
+        .isFalse();
+
+    // When requested is a better match
+    assertThat(newBuilder().setMcc(MCC_US_CARRIER).build()
+        .isBetterThan(newBuilder().build(), newBuilder().setMcc(MCC_US_CARRIER).build()))
+        .isTrue();
+  }
+
+  /**
+   * https://developer.android.com/guide/topics/resources/providing-resources.html#MccQualifier
+   * @see <a href="http://mcc-mnc.com/">http://mcc-mnc.com/</a>
+   */
+  @Test
+  public void isBetterThan_mnc() {
+    // When a configuration is not specified the result is always false
+    assertThat(newBuilder().build().isBetterThan(newBuilder().build(), newBuilder().build())).isFalse();
+
+    // When requested is less of a match
+    assertThat(newBuilder().setMcc(MCC_US_CARRIER).setMnc(MCC_US_VERIZON).build()
+        .isBetterThan(newBuilder().setMcc(MCC_US_CARRIER).build(), newBuilder().build()))
+        .isFalse();
+
+    // When requested is a better match - any US Carrier is a better match to US + Verizon
+    assertThat(newBuilder().setMcc(MCC_US_CARRIER).setMnc(MCC_US_VERIZON).build()
+        .isBetterThan(newBuilder().build(), newBuilder().setMcc(MCC_US_CARRIER).build()))
+        .isTrue();
+
+    // When requested is a better match - any US Carrier is a better match to US + Verizon
+    assertThat(newBuilder().setMcc(MCC_US_CARRIER).setMnc(MCC_US_VERIZON).build()
+        .isBetterThan(newBuilder().setMcc(MCC_US_CARRIER).build(), newBuilder().setMcc(MCC_US_CARRIER).setMnc(MCC_US_VERIZON).build()))
+        .isTrue();
+
+    // When requested is a better match - any US Carrier is not a better match to US + Verizon
+    assertThat(newBuilder().setMcc(MCC_US_CARRIER).setMnc(MCC_US_VERIZON).build()
+        .isBetterThan(newBuilder().setMcc(MCC_US_CARRIER).setMnc(MCC_US_VERIZON).build(), newBuilder().setMcc(MCC_US_CARRIER).build()))
+        .isFalse();
+  }
+
+  @Test
+  public void isBetterThan_language() {
+    // When requested has no language, is not a better match
+    assertThat(newBuilder().setLanguage(LANGUAGE_FRENCH).build()
+        .isBetterThan(newBuilder().setLanguage(LANGUAGE_FRENCH).build(), newBuilder().build()))
+        .isFalse();
+  }
+
+  @Test
+  public void isBetterThan_language_comparedNotSame_requestedEnglish() {
+    // When requested has no language, is not a better match
+    assertThat(newBuilder().setLanguage(LANGUAGE_FRENCH).build()
+        .isBetterThan(newBuilder().setLanguage(LANGUAGE_SPANISH).build(), newBuilder().setLanguage(
+            ResTable_config.kEnglish).build()))
+        .isTrue();
+  }
+
+  @Test
+  public void isBetterThan_language_comparedNotSame_requestedEnglishUS() {
+    // When requested has no language, is not a better match
+    assertThat(newBuilder().setLanguage(LANGUAGE_FRENCH).build()
+        .isBetterThan(newBuilder().setLanguage(LANGUAGE_SPANISH).build(), newBuilder().setLanguage(
+            ResTable_config.kEnglish).build()))
+        .isTrue();
+  }
+
+  @Test
+  public void isBetterThan_layoutDirection_() {
+    // Requested matches this configuration
+    assertThat(newBuilder().setLayoutDirection(ResTable_config.SCREENLAYOUT_LAYOUTDIR_RTL).build()
+        .isBetterThan(newBuilder().setLayoutDirection(ResTable_config.SCREENLAYOUT_LAYOUTDIR_LTR).build(),
+            newBuilder().setLayoutDirection(ResTable_config.SCREENLAYOUT_LAYOUTDIR_RTL).build()))
+        .isTrue();
+
+    // Requested matches this configuration
+    assertThat(newBuilder().setLayoutDirection(ResTable_config.SCREENLAYOUT_LAYOUTDIR_LTR).build()
+        .isBetterThan(newBuilder().setLayoutDirection(ResTable_config.SCREENLAYOUT_LAYOUTDIR_RTL).build(),
+            newBuilder().setLayoutDirection(ResTable_config.SCREENLAYOUT_LAYOUTDIR_RTL).build()))
+        .isFalse();
+  }
+
+  public static ResTableConfigBuilder newBuilder() {
+    return new ResTableConfigBuilder();
+  }
+
+  private static class ResTableConfigBuilder {
+        int mcc;
+        int mnc;
+        byte[] language = new byte[2];
+        byte[] region = new byte[2];
+        int orientation;
+        int touchscreen;
+        int density;
+        int keyboard;
+        int navigation;
+        int inputFlags;
+        int screenWidth;
+        int screenHeight;
+        int sdkVersion;
+        int minorVersion;
+        int screenLayout;
+        int uiMode;
+        int smallestScreenWidthDp;
+        int screenWidthDp;
+        int screenHeightDp;
+        byte[] localeScript = new byte[4];
+        byte[] localeVariant = new byte[8];
+        byte screenLayout2;
+        byte screenConfigPad1;
+        short screenConfigPad2;
+
+    ResTable_config build() {
+      return new ResTable_config(0, mcc, mnc, language, region, orientation, touchscreen, density, keyboard, navigation, inputFlags, screenWidth,
+          screenHeight, sdkVersion, minorVersion, screenLayout, uiMode, smallestScreenWidthDp, screenWidthDp, screenHeightDp, localeScript, localeVariant, screenLayout2,
+          screenConfigPad1, screenConfigPad2, null
+      );
+    }
+
+    public ResTableConfigBuilder setMcc(int mcc) {
+      this.mcc = mcc;
+      return this;
+    }
+
+    public ResTableConfigBuilder setMnc(int mnc) {
+      this.mnc = mnc;
+      return this;
+    }
+
+    public ResTableConfigBuilder setLanguage(byte[] language) {
+      this.language = language;
+      return this;
+    }
+
+    public ResTableConfigBuilder setLayoutDirection(int layoutDirection) {
+      screenLayout = layoutDirection;
+      return this;
+    }
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/android/ResTable_configTest.java b/resources/src/test/java/org/robolectric/res/android/ResTable_configTest.java
new file mode 100644
index 0000000..501e52d
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/android/ResTable_configTest.java
@@ -0,0 +1,53 @@
+package org.robolectric.res.android;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ResTable_configTest {
+
+  @Test
+  public void testLocale() throws Exception {
+    ResTable_config resTable_config = new ResTable_config();
+    resTable_config.language[0] = 'e';
+    resTable_config.language[1] = 'n';
+    resTable_config.country[0] = 'u';
+    resTable_config.country[1] = 'k';
+
+    assertThat(resTable_config.locale())
+        .isEqualTo(('e' << 24) | ('n' << 16) | ('u' << 8) | 'k');
+  }
+
+  @Test
+  public void getBcp47Locale_shouldReturnCanonicalizedTag() {
+    ResTable_config resTable_config = new ResTable_config();
+    resTable_config.language[0] = 'j';
+    resTable_config.language[1] = 'a';
+    resTable_config.country[0] = 'j';
+    resTable_config.country[1] = 'p';
+
+    assertThat(resTable_config.getBcp47Locale(/* canonicalize= */ true)).isEqualTo("ja-jp");
+  }
+
+  @Test
+  public void getBcp47Locale_philippines_shouldReturnFil() {
+    ResTable_config resTable_config = new ResTable_config();
+    resTable_config.language[0] = 't';
+    resTable_config.language[1] = 'l';
+    resTable_config.country[0] = 'p';
+    resTable_config.country[1] = 'h';
+
+    assertThat(resTable_config.getBcp47Locale(/* canonicalize= */ true)).isEqualTo("fil-ph");
+  }
+
+  @Test
+  public void fromDtoH_preservesMnc() {
+    ResTable_config config = new ResTable_config();
+    config.mnc = 0xFFFF;
+
+    assertThat(ResTable_config.fromDtoH(config).mnc).isEqualTo(0xFFFF);
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/res/android/ZipFileROTest.java b/resources/src/test/java/org/robolectric/res/android/ZipFileROTest.java
new file mode 100644
index 0000000..eebf365
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/res/android/ZipFileROTest.java
@@ -0,0 +1,66 @@
+package org.robolectric.res.android;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.io.ByteStreams;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.util.zip.ZipOutputStream;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit test for {@link ZipFileRO}. */
+@RunWith(JUnit4.class)
+public final class ZipFileROTest {
+
+  /** Verify that ZIP entries can be located when there are gaps between them. */
+  //
+  // The test data (zip_with_gap.zip) was crafted by creating a regular ZIP file with two
+  // uncompressed entries, adding 32 spaces between them, and adjusting offsets accordingly
+  // (in particular, the contents of the Central Directory and EOCD).
+  //
+  // 00000000: 504b 0304 0a00 0000 0000 0000 2100 a865  PK..........!..e
+  // 00000010: 327e 0400 0000 0400 0000 0200 0000 6630  2~............f0
+  // 00000020: 666f 6f0a 2020 2020 2020 2020 2020 2020  foo.
+  // 00000030: 2020 2020 2020 2020 2020 2020 2020 2020
+  // 00000040: 2020 2020 504b 0304 0a00 0000 0000 0000      PK..........
+  // 00000050: 2100 e9b3 a204 0400 0000 0400 0000 0200  !...............
+  // 00000060: 0000 6631 6261 720a 504b 0102 1e03 0a00  ..f1bar.PK......
+  // 00000070: 0000 0000 0000 2100 a865 327e 0400 0000  ......!..e2~....
+  // 00000080: 0400 0000 0200 0000 0000 0000 0000 0000  ................
+  // 00000090: a081 0000 0000 6630 504b 0102 1e03 0a00  ......f0PK......
+  // 000000a0: 0000 0000 0000 2100 e9b3 a204 0400 0000  ......!.........
+  // 000000b0: 0400 0000 0200 0000 0000 0000 0000 0000  ................
+  // 000000c0: a081 4400 0000 6631 504b 0506 0000 0000  ..D...f1PK......
+  // 000000d0: 0200 0200 6000 0000 6800 0000 0000       ....`...h.....
+  @Test
+  public void createEntryFileMap_yieldsCorrectOffset() throws Exception {
+    // Write the test data (provided as a JAR resource) to a regular file.
+    // JAR resources are preferred as input since they don't depend on the working directory, but we
+    // want a real file for testing.
+    File blob = File.createTempFile("prefix", "zip");
+    try (InputStream input = getClass().getResourceAsStream("/zip_with_gap.zip");
+        FileOutputStream output = new FileOutputStream(blob)) {
+      ByteStreams.copy(input, output);
+    }
+
+    ZipFileRO zipFile = ZipFileRO.open(blob.toString());
+    ZipFileRO.ZipEntryRO entry = zipFile.findEntryByName("f1");
+    FileMap fileMap = zipFile.createEntryFileMap(entry);
+
+    // The contents of file "f1" (i.e. "bar") appears at offset 0x64.
+    assertThat(fileMap.getDataOffset()).isEqualTo(0x64);
+  }
+
+  @Test
+  public void open_emptyZip() throws Exception {
+    // ensure ZipFileRO cam handle an empty zip file with no central directory
+    File blob = File.createTempFile("prefix", "zip");
+    try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(blob))) {}
+
+    ZipFileRO zipFile = ZipFileRO.open(blob.toString());
+    assertThat(zipFile).isNotNull();
+  }
+}
diff --git a/resources/src/test/java/org/robolectric/resources/R.java b/resources/src/test/java/org/robolectric/resources/R.java
new file mode 100644
index 0000000..ea007c3
--- /dev/null
+++ b/resources/src/test/java/org/robolectric/resources/R.java
@@ -0,0 +1,25 @@
+/* AUTO-GENERATED FILE.  DO NOT MODIFY.
+ *
+ * This class was automatically generated by the
+ * aapt tool from the resource data it found.  It
+ * should not be modified by hand.
+ */
+
+package org.robolectric.resources;
+
+public final class R {
+    public static final class attr {
+    }
+    public static final class bool {
+        /**  mcc310 = US Carrier mnc=004 = Verizon
+         */
+        public static final int is_verizon=0x7f020000;
+    }
+    public static final class integer {
+        public static final int flock_size=0x7f030000;
+    }
+    public static final class string {
+        public static final int first_string=0x7f040000;
+        public static final int second_string=0x7f040001;
+    }
+}
diff --git a/resources/src/test/resources/binaryresources/resources.ap_ b/resources/src/test/resources/binaryresources/resources.ap_
new file mode 100644
index 0000000..a721c87
--- /dev/null
+++ b/resources/src/test/resources/binaryresources/resources.ap_
Binary files differ
diff --git a/resources/src/test/resources/rawresources/AndroidManifest.xml b/resources/src/test/resources/rawresources/AndroidManifest.xml
new file mode 100644
index 0000000..07842c7
--- /dev/null
+++ b/resources/src/test/resources/rawresources/AndroidManifest.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+      package="org.robolectric.resources">
+  <uses-sdk android:targetSdkVersion="16"/>
+</manifest>
diff --git a/resources/src/test/resources/rawresources/res/values-es/strings.xml b/resources/src/test/resources/rawresources/res/values-es/strings.xml
new file mode 100644
index 0000000..a5d1b0d
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values-es/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="first_string">oveja</string>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values-fil/strings.xml b/resources/src/test/resources/rawresources/res/values-fil/strings.xml
new file mode 100644
index 0000000..6dd6d1a
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values-fil/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="first_string">tupa</string>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values-fr/strings.xml b/resources/src/test/resources/rawresources/res/values-fr/strings.xml
new file mode 100644
index 0000000..d99aac6
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values-fr/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="first_string">mouton</string>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values-large/integers.xml b/resources/src/test/resources/rawresources/res/values-large/integers.xml
new file mode 100644
index 0000000..6ffb16f
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values-large/integers.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <integer name="flock_size">1000000</integer>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values-mcc310-mnc004/booleans.xml b/resources/src/test/resources/rawresources/res/values-mcc310-mnc004/booleans.xml
new file mode 100644
index 0000000..16cc867
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values-mcc310-mnc004/booleans.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- mcc310 = US Carrier mnc=004 = Verizon-->
+    <bool name="is_verizon">true</bool>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values/booleans.xml b/resources/src/test/resources/rawresources/res/values/booleans.xml
new file mode 100644
index 0000000..435a10d
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values/booleans.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <bool name="is_verizon">false</bool>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values/integers.xml b/resources/src/test/resources/rawresources/res/values/integers.xml
new file mode 100644
index 0000000..0de5143
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values/integers.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <integer name="flock_size">1234</integer>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/rawresources/res/values/strings.xml b/resources/src/test/resources/rawresources/res/values/strings.xml
new file mode 100644
index 0000000..902a41d
--- /dev/null
+++ b/resources/src/test/resources/rawresources/res/values/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="first_string">sheep</string>
+  <string name="second_string">goat</string>
+</resources>
\ No newline at end of file
diff --git a/resources/src/test/resources/zip_with_gap.zip b/resources/src/test/resources/zip_with_gap.zip
new file mode 100644
index 0000000..f89d1a5
--- /dev/null
+++ b/resources/src/test/resources/zip_with_gap.zip
Binary files differ
diff --git a/robolectric/build.gradle b/robolectric/build.gradle
new file mode 100644
index 0000000..163dfb7
--- /dev/null
+++ b/robolectric/build.gradle
@@ -0,0 +1,106 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+processResources {
+    filesMatching("**/robolectric-version.properties") {
+        filter { String line ->
+            return line.replaceAll(/\$\{project.version\}/, project.version)
+        }
+    }
+}
+
+configurations {
+    shadow
+}
+
+project.sourceSets.test.compileClasspath += configurations.shadow
+
+dependencies {
+    annotationProcessor "com.google.auto.service:auto-service:$autoServiceVersion"
+    annotationProcessor "com.google.errorprone:error_prone_core:$errorproneVersion"
+
+    api project(":annotations")
+    api project(":junit")
+    api project(":pluginapi")
+    api project(":resources")
+    api project(":sandbox")
+    api project(":utils")
+    api project(":utils:reflector")
+    api project(":plugins:maven-dependency-resolver")
+    api "javax.inject:javax.inject:1"
+    compileOnly "com.google.auto.service:auto-service-annotations:$autoServiceVersion"
+    api "javax.annotation:javax.annotation-api:1.3.2"
+
+    // We need to have shadows-framework.jar on the runtime system classpath so ServiceLoader
+    //   can find its META-INF/services/org.robolectric.shadows.ShadowAdapter.
+    api project(":shadows:framework")
+
+    implementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2'
+    api "org.bouncycastle:bcprov-jdk15on:1.70"
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+    compileOnly "junit:junit:${junitVersion}"
+    api "androidx.test:monitor:$axtMonitorVersion@aar"
+    implementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion@aar"
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "com.google.truth.extensions:truth-java8-extension:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+    testImplementation "org.hamcrest:hamcrest-junit:2.0.0.0"
+    testImplementation "androidx.test:core:$axtCoreVersion@aar"
+    testImplementation "androidx.lifecycle:lifecycle-common:2.5.1"
+    testImplementation "androidx.test.ext:junit:$axtJunitVersion@aar"
+    testImplementation "androidx.test.ext:truth:$axtTruthVersion@aar"
+    testImplementation "androidx.test:runner:$axtRunnerVersion@aar"
+    testImplementation("com.google.guava:guava:$guavaJREVersion")
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates // compile against latest Android SDK
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates // run against whatever this JDK supports
+}
+
+test {
+    if (project.hasProperty('maxParallelForks'))
+        maxParallelForks = project.maxParallelForks as int
+    if (project.hasProperty('forkEvery'))
+        forkEvery = project.forkEvery as int
+}
+
+project.apply plugin: CheckApiChangesPlugin
+
+checkApiChanges {
+    from = [
+            "org.robolectric:robolectric:${apiCompatVersion}@jar",
+            "org.robolectric:annotations:${apiCompatVersion}@jar",
+            "org.robolectric:junit:${apiCompatVersion}@jar",
+            "org.robolectric:resources:${apiCompatVersion}@jar",
+            "org.robolectric:sandbox:${apiCompatVersion}@jar",
+            "org.robolectric:utils:${apiCompatVersion}@jar",
+            "org.robolectric:shadowapi:${apiCompatVersion}@jar",
+            "org.robolectric:shadows-framework:${apiCompatVersion}@jar",
+    ]
+
+    to = [
+            project(":robolectric"),
+            project(":annotations"),
+            project(":junit"),
+            project(":resources"),
+            project(":sandbox"),
+            project(":shadows:framework"),
+            project(":utils"),
+            project(":shadowapi"),
+    ]
+
+    entryPoints += "org.robolectric.RobolectricTestRunner"
+    expectedChanges = [
+            "^org.robolectric.util.ActivityController#",
+            "^org.robolectric.util.ComponentController#",
+            "^org.robolectric.util.ContentProviderController#",
+            "^org.robolectric.util.FragmentController#",
+            "^org.robolectric.util.IntentServiceController#",
+            "^org.robolectric.util.ServiceController#",
+    ]
+}
diff --git a/robolectric/shared_prefs/android.app.Activity.xml b/robolectric/shared_prefs/android.app.Activity.xml
new file mode 100644
index 0000000..ecbc7f6
--- /dev/null
+++ b/robolectric/shared_prefs/android.app.Activity.xml
@@ -0,0 +1,4 @@
+<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
+<map>
+<string name="foo">bar</string>
+</map>
diff --git a/robolectric/shared_prefs/bazBang.xml b/robolectric/shared_prefs/bazBang.xml
new file mode 100644
index 0000000..ecbc7f6
--- /dev/null
+++ b/robolectric/shared_prefs/bazBang.xml
@@ -0,0 +1,4 @@
+<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
+<map>
+<string name="foo">bar</string>
+</map>
diff --git a/robolectric/shared_prefs/filename.xml b/robolectric/shared_prefs/filename.xml
new file mode 100644
index 0000000..c444782
--- /dev/null
+++ b/robolectric/shared_prefs/filename.xml
@@ -0,0 +1,13 @@
+<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
+<map>
+<boolean name="boolean" value="true" />
+<string name="string">foobar</string>
+<set name="stringSet">
+<string>string3</string>
+<string>string1</string>
+<string>string2</string>
+</set>
+<float name="float" value="1.1" />
+<int name="int" value="2" />
+<long name="long" value="3" />
+</map>
diff --git a/robolectric/shared_prefs/org.robolectric_preferences.xml b/robolectric/shared_prefs/org.robolectric_preferences.xml
new file mode 100644
index 0000000..025a225
--- /dev/null
+++ b/robolectric/shared_prefs/org.robolectric_preferences.xml
@@ -0,0 +1,4 @@
+<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
+<map>
+    <boolean name="checkbox" value="true" />
+</map>
diff --git a/robolectric/src/main/java/org/robolectric/ApkLoader.java b/robolectric/src/main/java/org/robolectric/ApkLoader.java
new file mode 100644
index 0000000..ee9c9ea
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/ApkLoader.java
@@ -0,0 +1,42 @@
+package org.robolectric;
+
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.res.PackageResourceTable;
+import org.robolectric.res.ResourceMerger;
+import org.robolectric.res.ResourcePath;
+import org.robolectric.res.ResourceTableFactory;
+
+/**
+ * Mediates loading of "APKs" in legacy mode.
+ */
+public class ApkLoader {
+
+  private final Map<AndroidManifest, PackageResourceTable> appResourceTableCache = new HashMap<>();
+  private PackageResourceTable compiletimeSdkResourceTable;
+
+  synchronized public PackageResourceTable getAppResourceTable(final AndroidManifest appManifest) {
+    PackageResourceTable resourceTable = appResourceTableCache.get(appManifest);
+    if (resourceTable == null) {
+      resourceTable = new ResourceMerger().buildResourceTable(appManifest);
+
+      appResourceTableCache.put(appManifest, resourceTable);
+    }
+    return resourceTable;
+  }
+
+  /**
+   * Returns the ResourceTable for the compile time SDK.
+   */
+  @Nonnull
+  synchronized public PackageResourceTable getCompileTimeSdkResourceTable() {
+    if (compiletimeSdkResourceTable == null) {
+      ResourceTableFactory resourceTableFactory = new ResourceTableFactory();
+      compiletimeSdkResourceTable = resourceTableFactory
+          .newFrameworkResourceTable(new ResourcePath(android.R.class, null, null));
+    }
+    return compiletimeSdkResourceTable;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/ConfigMerger.java b/robolectric/src/main/java/org/robolectric/ConfigMerger.java
new file mode 100644
index 0000000..b2b5a8a
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/ConfigMerger.java
@@ -0,0 +1,152 @@
+package org.robolectric;
+
+import static com.google.common.collect.Lists.reverse;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.Join;
+
+/**
+ * Computes the effective Robolectric configuration for a given test method.
+ *
+ * <p>This class is no longer used directly by Robolectric, but is left for convenience during
+ * migration.
+ *
+ * @deprecated Provide an implementation of {@link javax.inject.Provider<Config>}. This class will
+ *     be removed in Robolectric 4.3.
+ * @see <a href="http://robolectric.org/migrating/#migrating-to-40">Migration Notes</a> for more
+ *     details.
+ */
+@Deprecated
+public class ConfigMerger {
+  private final Map<String, Config> packageConfigCache = new LinkedHashMap<String, Config>() {
+    @Override
+    protected boolean removeEldestEntry(Map.Entry eldest) {
+      return size() > 10;
+    }
+  };
+
+  /**
+   * Calculate the {@link Config} for the given test.
+   *
+   * @param testClass the class containing the test
+   * @param method the test method
+   * @param globalConfig global configuration values
+   * @return the effective configuration
+   * @since 3.2
+   */
+  public Config getConfig(Class<?> testClass, Method method, Config globalConfig) {
+    Config config = Config.Builder.defaults().build();
+    config = override(config, globalConfig);
+
+    for (String packageName : reverse(packageHierarchyOf(testClass))) {
+      Config packageConfig = cachedPackageConfig(packageName);
+      config = override(config, packageConfig);
+    }
+
+    for (Class clazz : reverse(parentClassesFor(testClass))) {
+      Config classConfig = (Config) clazz.getAnnotation(Config.class);
+      config = override(config, classConfig);
+    }
+
+    Config methodConfig = method.getAnnotation(Config.class);
+    config = override(config, methodConfig);
+
+    return config;
+  }
+
+  /**
+   * Generate {@link Config} for the specified package.
+   *
+   * More specific packages, test classes, and test method configurations
+   * will override values provided here.
+   *
+   * The default implementation uses properties provided by {@link #getConfigProperties(String)}.
+   *
+   * The returned object is likely to be reused for many tests.
+   *
+   * @param packageName the name of the package, or empty string ({@code ""}) for the top level package
+   * @return {@link Config} object for the specified package
+   * @since 3.2
+   */
+  @Nullable
+  private Config buildPackageConfig(String packageName) {
+    return Config.Implementation.fromProperties(getConfigProperties(packageName));
+  }
+
+  /**
+   * Return a {@link Properties} file for the given package name, or {@code null} if none is available.
+   *
+   * @since 3.2
+   */
+  protected Properties getConfigProperties(String packageName) {
+    List<String> packageParts = new ArrayList<>(Arrays.asList(packageName.split("\\.")));
+    packageParts.add(RobolectricTestRunner.CONFIG_PROPERTIES);
+    final String resourceName = Join.join("/", packageParts);
+    try (InputStream resourceAsStream = getResourceAsStream(resourceName)) {
+      if (resourceAsStream == null) return null;
+      Properties properties = new Properties();
+      properties.load(resourceAsStream);
+      return properties;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Nonnull @VisibleForTesting
+  List<String> packageHierarchyOf(Class<?> javaClass) {
+    Package aPackage = javaClass.getPackage();
+    String testPackageName = aPackage == null ? "" : aPackage.getName();
+    List<String> packageHierarchy = new ArrayList<>();
+    while (!testPackageName.isEmpty()) {
+      packageHierarchy.add(testPackageName);
+      int lastDot = testPackageName.lastIndexOf('.');
+      testPackageName = lastDot > 1 ? testPackageName.substring(0, lastDot) : "";
+    }
+    packageHierarchy.add("");
+    return packageHierarchy;
+  }
+
+  @Nonnull
+  private List<Class> parentClassesFor(Class testClass) {
+    List<Class> testClassHierarchy = new ArrayList<>();
+    while (testClass != null && !testClass.equals(Object.class)) {
+      testClassHierarchy.add(testClass);
+      testClass = testClass.getSuperclass();
+    }
+    return testClassHierarchy;
+  }
+
+  private Config override(Config config, Config classConfig) {
+    return classConfig != null ? new Config.Builder(config).overlay(classConfig).build() : config;
+  }
+
+  @Nullable
+  private Config cachedPackageConfig(String packageName) {
+    synchronized (packageConfigCache) {
+      Config config = packageConfigCache.get(packageName);
+      if (config == null && !packageConfigCache.containsKey(packageName)) {
+        config = buildPackageConfig(packageName);
+        packageConfigCache.put(packageName, config);
+      }
+      return config;
+    }
+  }
+
+  // visible for testing
+  @SuppressWarnings("WeakerAccess")
+  InputStream getResourceAsStream(String resourceName) {
+    return getClass().getClassLoader().getResourceAsStream(resourceName);
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/DefaultTestLifecycle.java b/robolectric/src/main/java/org/robolectric/DefaultTestLifecycle.java
new file mode 100644
index 0000000..8eceda3
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/DefaultTestLifecycle.java
@@ -0,0 +1,49 @@
+package org.robolectric;
+
+import java.lang.reflect.Method;
+
+/**
+ * The default {@link TestLifecycle} used by Robolectric.
+ *
+ * <p>Owing to tradeoffs, this class is not guaranteed to work with {@link
+ * org.robolectric.annotation.experimental.LazyApplication} enabled on tests where the application
+ * is inferred from the apk (instead of explicitly specified in AndroidManifest.xml).
+ */
+public class DefaultTestLifecycle implements TestLifecycle {
+
+  /**
+   * Called before each test method is run.
+   *
+   * @param method the test method about to be run
+   */
+  @Override
+  public void beforeTest(final Method method) {
+    if (isTestLifecycleApplicationClass(RuntimeEnvironment.getConfiguredApplicationClass())) {
+      ((TestLifecycleApplication) RuntimeEnvironment.getApplication()).beforeTest(method);
+    }
+  }
+
+  @Override
+  public void prepareTest(final Object test) {
+    if (isTestLifecycleApplicationClass(RuntimeEnvironment.getConfiguredApplicationClass())) {
+      ((TestLifecycleApplication) RuntimeEnvironment.getApplication()).prepareTest(test);
+    }
+  }
+
+  /**
+   * Called after each test method is run.
+   *
+   * @param method the test method that just ran.
+   */
+  @Override
+  public void afterTest(final Method method) {
+    if (isTestLifecycleApplicationClass(RuntimeEnvironment.getConfiguredApplicationClass())) {
+      ((TestLifecycleApplication) RuntimeEnvironment.getApplication()).afterTest(method);
+    }
+  }
+
+  private boolean isTestLifecycleApplicationClass(Class<?> applicationClass) {
+    return applicationClass != null
+        && TestLifecycleApplication.class.isAssignableFrom(applicationClass);
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/ParameterizedRobolectricTestRunner.java b/robolectric/src/main/java/org/robolectric/ParameterizedRobolectricTestRunner.java
new file mode 100644
index 0000000..b442768
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/ParameterizedRobolectricTestRunner.java
@@ -0,0 +1,381 @@
+package org.robolectric;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Assert;
+import org.junit.runner.Runner;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Suite;
+import org.junit.runners.model.FrameworkField;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+import org.junit.runners.model.TestClass;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * A Parameterized test runner for Robolectric. Copied from the {@link Parameterized} class, then
+ * modified the custom test runner to extend the {@link RobolectricTestRunner}. The {@link
+ * org.robolectric.RobolectricTestRunner#getHelperTestRunner(Class)} is overridden in order to
+ * create instances of the test class with the appropriate parameters. Merged in the ability to name
+ * your tests through the {@link Parameters#name()} property. Merged in support for {@link
+ * Parameter} annotation alternative to providing a constructor.
+ *
+ * <p>This class takes care of the fact that the test runner and the test class are actually loaded
+ * from different class loaders and therefore parameter objects created by one cannot be assigned to
+ * instances of the other.
+ */
+public final class ParameterizedRobolectricTestRunner extends Suite {
+
+  /**
+   * Annotation for a method which provides parameters to be injected into the test class
+   * constructor by <code>Parameterized</code>
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.METHOD)
+  public @interface Parameters {
+
+    /**
+     * Optional pattern to derive the test's name from the parameters. Use numbers in braces to
+     * refer to the parameters or the additional data as follows:
+     *
+     * <pre>
+     * {index} - the current parameter index
+     * {0} - the first parameter value
+     * {1} - the second parameter value
+     * etc...
+     * </pre>
+     *
+     * <p>Default value is "{index}" for compatibility with previous JUnit versions.
+     *
+     * @return {@link MessageFormat} pattern string, except the index placeholder.
+     * @see MessageFormat
+     */
+    String name() default "{index}";
+  }
+
+  /**
+   * Annotation for fields of the test class which will be initialized by the method annotated by
+   * <code>Parameters</code><br>
+   * By using directly this annotation, the test class constructor isn't needed.<br>
+   * Index range must start at 0. Default value is 0.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.FIELD)
+  public @interface Parameter {
+    /**
+     * Method that returns the index of the parameter in the array returned by the method annotated
+     * by <code>Parameters</code>.<br>
+     * Index range must start at 0. Default value is 0.
+     *
+     * @return the index of the parameter.
+     */
+    int value() default 0;
+  }
+
+  private static class TestClassRunnerForParameters extends RobolectricTestRunner {
+
+    private final int parametersIndex;
+    private final String name;
+
+    TestClassRunnerForParameters(Class<?> type, int parametersIndex, String name)
+        throws InitializationError {
+      super(type);
+      this.parametersIndex = parametersIndex;
+      this.name = name;
+    }
+
+    private Object createTestInstance(Class bootstrappedClass) throws Exception {
+      Constructor<?>[] constructors = bootstrappedClass.getConstructors();
+      Assert.assertEquals(1, constructors.length);
+      if (!fieldsAreAnnotated()) {
+        return constructors[0].newInstance(computeParams(bootstrappedClass.getClassLoader()));
+      } else {
+        Object instance = constructors[0].newInstance();
+        injectParametersIntoFields(instance, bootstrappedClass.getClassLoader());
+        return instance;
+      }
+    }
+
+    private Object[] computeParams(ClassLoader classLoader) throws Exception {
+      // Robolectric uses a different class loader when running the tests, so the parameters objects
+      // created by the test runner are not compatible with the parameters required by the test.
+      // Instead, we compute the parameters within the test's class loader.
+      try {
+        List<Object> parametersList = getParametersList(getTestClass(), classLoader);
+
+        if (parametersIndex >= parametersList.size()) {
+          throw new Exception(
+              "Re-computing the parameter list returned a different number of "
+                  + "parameters values. Is the data() method of your test non-deterministic?");
+        }
+        Object parametersObj = parametersList.get(parametersIndex);
+        return (parametersObj instanceof Object[])
+            ? (Object[]) parametersObj
+            : new Object[] {parametersObj};
+      } catch (ClassCastException e) {
+        throw new Exception(
+            String.format(
+                "%s.%s() must return a Collection of arrays.", getTestClass().getName(), name));
+      } catch (Exception exception) {
+        throw exception;
+      } catch (Throwable throwable) {
+        throw new Exception(throwable);
+      }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void injectParametersIntoFields(Object testClassInstance, ClassLoader classLoader)
+        throws Exception {
+      // Robolectric uses a different class loader when running the tests, so referencing Parameter
+      // directly causes type mismatches. Instead, we find its class within the test's class loader.
+      Class<?> parameterClass = getClassInClassLoader(Parameter.class, classLoader);
+      Object[] parameters = computeParams(classLoader);
+      HashSet<Integer> parameterFieldsFound = new HashSet<>();
+      for (Field field : testClassInstance.getClass().getFields()) {
+        Annotation parameter = field.getAnnotation((Class<Annotation>) parameterClass);
+        if (parameter != null) {
+          int index = ReflectionHelpers.callInstanceMethod(parameter, "value");
+          parameterFieldsFound.add(index);
+          try {
+            field.set(testClassInstance, parameters[index]);
+          } catch (IllegalArgumentException iare) {
+            throw new Exception(
+                getTestClass().getName()
+                    + ": Trying to set "
+                    + field.getName()
+                    + " with the value "
+                    + parameters[index]
+                    + " that is not the right type ("
+                    + parameters[index].getClass().getSimpleName()
+                    + " instead of "
+                    + field.getType().getSimpleName()
+                    + ").",
+                iare);
+          }
+        }
+      }
+      if (parameterFieldsFound.size() != parameters.length) {
+        throw new IllegalStateException(
+            String.format(
+                Locale.US,
+                "Provided %d parameters, but only found fields for parameters: %s",
+                parameters.length,
+                parameterFieldsFound.toString()));
+      }
+    }
+
+    @Override
+    protected String getName() {
+      return name;
+    }
+
+    @Override
+    protected String testName(final FrameworkMethod method) {
+      return method.getName() + getName();
+    }
+
+    @Override
+    protected void validateConstructor(List<Throwable> errors) {
+      validateOnlyOneConstructor(errors);
+      if (fieldsAreAnnotated()) {
+        validateZeroArgConstructor(errors);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "TestClassRunnerForParameters " + name;
+    }
+
+    @Override
+    protected void validateFields(List<Throwable> errors) {
+      super.validateFields(errors);
+      // Ensure that indexes for parameters are correctly defined
+      if (fieldsAreAnnotated()) {
+        List<FrameworkField> annotatedFieldsByParameter = getAnnotatedFieldsByParameter();
+        int[] usedIndices = new int[annotatedFieldsByParameter.size()];
+        for (FrameworkField each : annotatedFieldsByParameter) {
+          int index = each.getField().getAnnotation(Parameter.class).value();
+          if (index < 0 || index > annotatedFieldsByParameter.size() - 1) {
+            errors.add(
+                new Exception(
+                    "Invalid @Parameter value: "
+                        + index
+                        + ". @Parameter fields counted: "
+                        + annotatedFieldsByParameter.size()
+                        + ". Please use an index between 0 and "
+                        + (annotatedFieldsByParameter.size() - 1)
+                        + "."));
+          } else {
+            usedIndices[index]++;
+          }
+        }
+        for (int index = 0; index < usedIndices.length; index++) {
+          int numberOfUse = usedIndices[index];
+          if (numberOfUse == 0) {
+            errors.add(new Exception("@Parameter(" + index + ") is never used."));
+          } else if (numberOfUse > 1) {
+            errors.add(
+                new Exception(
+                    "@Parameter(" + index + ") is used more than once (" + numberOfUse + ")."));
+          }
+        }
+      }
+    }
+
+    @Override
+    protected SandboxTestRunner.HelperTestRunner getHelperTestRunner(Class bootstrappedTestClass) {
+      try {
+        return new HelperTestRunner(bootstrappedTestClass) {
+          @Override
+          protected void validateConstructor(List<Throwable> errors) {
+            TestClassRunnerForParameters.this.validateOnlyOneConstructor(errors);
+          }
+
+          @Override
+          protected Object createTest() throws Exception {
+            return TestClassRunnerForParameters.this.createTestInstance(
+                getTestClass().getJavaClass());
+          }
+
+          @Override
+          protected String testName(FrameworkMethod method) {
+            return TestClassRunnerForParameters.this.testName(method);
+          }
+
+          @Override
+          public String toString() {
+            return "HelperTestRunner for " + TestClassRunnerForParameters.this.toString();
+          }
+        };
+      } catch (InitializationError initializationError) {
+        throw new RuntimeException(initializationError);
+      }
+    }
+
+    private List<FrameworkField> getAnnotatedFieldsByParameter() {
+      return getTestClass().getAnnotatedFields(Parameter.class);
+    }
+
+    private boolean fieldsAreAnnotated() {
+      return !getAnnotatedFieldsByParameter().isEmpty();
+    }
+  }
+
+  private final ArrayList<Runner> runners = new ArrayList<>();
+
+  /*
+   * Only called reflectively. Do not use programmatically.
+   */
+  public ParameterizedRobolectricTestRunner(Class<?> klass) throws Throwable {
+    super(klass, Collections.<Runner>emptyList());
+    TestClass testClass = getTestClass();
+    ClassLoader classLoader = getClass().getClassLoader();
+    Parameters parameters =
+        getParametersMethod(testClass, classLoader).getAnnotation(Parameters.class);
+    List<Object> parametersList = getParametersList(testClass, classLoader);
+    for (int i = 0; i < parametersList.size(); i++) {
+      Object parametersObj = parametersList.get(i);
+      Object[] parameterArray =
+          (parametersObj instanceof Object[])
+              ? (Object[]) parametersObj
+              : new Object[] {parametersObj};
+      runners.add(
+          new TestClassRunnerForParameters(
+              testClass.getJavaClass(), i, nameFor(parameters.name(), i, parameterArray)));
+    }
+  }
+
+  @Override
+  protected List<Runner> getChildren() {
+    return runners;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<Object> getParametersList(TestClass testClass, ClassLoader classLoader)
+      throws Throwable {
+    Object parameters = getParametersMethod(testClass, classLoader).invokeExplosively(null);
+    if (parameters != null && parameters.getClass().isArray()) {
+      return Arrays.asList((Object[]) parameters);
+    } else {
+      return (List<Object>) parameters;
+    }
+  }
+
+  private static FrameworkMethod getParametersMethod(TestClass testClass, ClassLoader classLoader)
+      throws Exception {
+    List<FrameworkMethod> methods = testClass.getAnnotatedMethods(Parameters.class);
+    for (FrameworkMethod each : methods) {
+      int modifiers = each.getMethod().getModifiers();
+      if (Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers)) {
+        return getFrameworkMethodInClassLoader(each, classLoader);
+      }
+    }
+
+    throw new Exception("No public static parameters method on class " + testClass.getName());
+  }
+
+  private static String nameFor(String namePattern, int index, Object[] parameters) {
+    String finalPattern = namePattern.replaceAll("\\{index\\}", Integer.toString(index));
+    String name = MessageFormat.format(finalPattern, parameters);
+    return "[" + name + "]";
+  }
+
+  /**
+   * Returns the {@link FrameworkMethod} object for the given method in the provided class loader.
+   */
+  private static FrameworkMethod getFrameworkMethodInClassLoader(
+      FrameworkMethod method, ClassLoader classLoader)
+      throws ClassNotFoundException, NoSuchMethodException {
+    Method methodInClassLoader = getMethodInClassLoader(method.getMethod(), classLoader);
+    if (methodInClassLoader.equals(method.getMethod())) {
+      // The method was already loaded in the right class loader, return it as is.
+      return method;
+    }
+    return new FrameworkMethod(methodInClassLoader);
+  }
+
+  /** Returns the {@link Method} object for the given method in the provided class loader. */
+  private static Method getMethodInClassLoader(Method method, ClassLoader classLoader)
+      throws ClassNotFoundException, NoSuchMethodException {
+    Class<?> declaringClass = method.getDeclaringClass();
+
+    if (declaringClass.getClassLoader() == classLoader) {
+      // The method was already loaded in the right class loader, return it as is.
+      return method;
+    }
+
+    // Find the class in the class loader corresponding to the declaring class of the method.
+    Class<?> declaringClassInClassLoader = getClassInClassLoader(declaringClass, classLoader);
+
+    // Find the method with the same signature in the class loader.
+    return declaringClassInClassLoader.getMethod(method.getName(), method.getParameterTypes());
+  }
+
+  /** Returns the {@link Class} object for the given class in the provided class loader. */
+  private static Class<?> getClassInClassLoader(Class<?> klass, ClassLoader classLoader)
+      throws ClassNotFoundException {
+    if (klass.getClassLoader() == classLoader) {
+      // The method was already loaded in the right class loader, return it as is.
+      return klass;
+    }
+
+    // Find the class in the class loader corresponding to the declaring class of the method.
+    return classLoader.loadClass(klass.getName());
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/Robolectric.java b/robolectric/src/main/java/org/robolectric/Robolectric.java
new file mode 100644
index 0000000..47a52c5
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/Robolectric.java
@@ -0,0 +1,376 @@
+package org.robolectric;
+
+import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
+
+import android.annotation.IdRes;
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.IntentService;
+import android.app.Service;
+import android.app.backup.BackupAgent;
+import android.content.ContentProvider;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.View;
+import javax.annotation.Nullable;
+import org.robolectric.android.AttributeSetBuilderImpl;
+import org.robolectric.android.AttributeSetBuilderImpl.ArscResourceResolver;
+import org.robolectric.android.AttributeSetBuilderImpl.LegacyResourceResolver;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.android.controller.BackupAgentController;
+import org.robolectric.android.controller.ContentProviderController;
+import org.robolectric.android.controller.FragmentController;
+import org.robolectric.android.controller.IntentServiceController;
+import org.robolectric.android.controller.ServiceController;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Scheduler;
+
+public class Robolectric {
+
+  public static <T extends Service> ServiceController<T> buildService(Class<T> serviceClass) {
+    return buildService(serviceClass, null);
+  }
+
+  public static <T extends Service> ServiceController<T> buildService(Class<T> serviceClass, Intent intent) {
+    return ServiceController.of(ReflectionHelpers.callConstructor(serviceClass), intent);
+  }
+
+  public static <T extends Service> T setupService(Class<T> serviceClass) {
+    return buildService(serviceClass).create().get();
+  }
+
+  public static <T extends IntentService> IntentServiceController<T> buildIntentService(Class<T> serviceClass) {
+    return buildIntentService(serviceClass, null);
+  }
+
+  public static <T extends IntentService> IntentServiceController<T> buildIntentService(Class<T> serviceClass, Intent intent) {
+    return IntentServiceController.of(ReflectionHelpers.callConstructor(serviceClass), intent);
+  }
+
+  public static <T extends IntentService> T setupIntentService(Class<T> serviceClass) {
+    return buildIntentService(serviceClass).create().get();
+  }
+
+  public static <T extends ContentProvider> ContentProviderController<T> buildContentProvider(Class<T> contentProviderClass) {
+    return ContentProviderController.of(ReflectionHelpers.callConstructor(contentProviderClass));
+  }
+
+  public static <T extends ContentProvider> T setupContentProvider(Class<T> contentProviderClass) {
+    return buildContentProvider(contentProviderClass).create().get();
+  }
+
+  public static <T extends ContentProvider> T setupContentProvider(Class<T> contentProviderClass, String authority) {
+    return buildContentProvider(contentProviderClass).create(authority).get();
+  }
+
+  /**
+   * Creates a ActivityController for the given activity class.
+   *
+   * <p>Consider using {@link androidx.test.core.app.ActivityScenario} instead, which provides
+   * higher-level, streamlined APIs to control the lifecycle and it works with instrumentation tests
+   * too.
+   */
+  public static <T extends Activity> ActivityController<T> buildActivity(Class<T> activityClass) {
+    return buildActivity(activityClass, /* intent= */ null, /* activityOptions= */ null);
+  }
+
+  /**
+   * Creates a ActivityController for the given activity class with the intent.
+   *
+   * <p>Note: the activity class is not determined by the intent.
+   *
+   * <p>Consider using {@link androidx.test.core.app.ActivityScenario} instead, which provides
+   * higher-level, streamlined APIs to control the lifecycle and it works with instrumentation tests
+   * too.
+   */
+  public static <T extends Activity> ActivityController<T> buildActivity(
+      Class<T> activityClass, Intent intent) {
+    return buildActivity(activityClass, intent, /* activityOptions= */ null);
+  }
+
+  /**
+   * Creates a ActivityController for the given activity class with the intent and activity options.
+   *
+   * <p>Note: the activity class is not determined by the intent.
+   *
+   * <p>Note: Display ID is the only option currently supported in the options bundle. Other options
+   * are ignored.
+   *
+   * <p>Consider using {@link androidx.test.core.app.ActivityScenario} instead, which provides
+   * higher-level, streamlined APIs to control the lifecycle and it works with instrumentation tests
+   * too.
+   */
+  public static <T extends Activity> ActivityController<T> buildActivity(
+      Class<T> activityClass, Intent intent, @Nullable Bundle activityOptions) {
+    return ActivityController.of(
+        ReflectionHelpers.callConstructor(activityClass), intent, activityOptions);
+  }
+
+  /**
+   * Simulates starting activity with the given class type and returns its reference.
+   *
+   * <p>Use {@link androidx.test.core.app.ActivityScenario} instead, which works with
+   * instrumentation tests too.
+   *
+   * @deprecated use {@link androidx.test.core.app.ActivityScenario}
+   */
+  @Deprecated
+  @SuppressWarnings("InlineMeSuggester")
+  public static final <T extends Activity> T setupActivity(Class<T> activityClass) {
+    return buildActivity(activityClass).setup().get();
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(Class<T> fragmentClass) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass));
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class with the arguments.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass, Bundle arguments) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), arguments);
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class in the specified host activity.
+   *
+   * <p>In general, it's a bad practice to design a fragment having dependency to a specific
+   * activity. Consider removing the dependency and use other {@link #buildFragment} method or
+   * {@link androidx.fragment.app.testing.FragmentScenario}.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass, Class<? extends Activity> activityClass) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), activityClass);
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class. The given intent is set to the host
+   * activity.
+   *
+   * <p>Note: the host activity class is not determined by the intent.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass, Intent intent) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), intent);
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class with the arguments. The given intent
+   * is set to the host activity.
+   *
+   * <p>Note: the host activity class is not determined by the intent.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass, Intent intent, Bundle arguments) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), intent, arguments);
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class in the specified host activity. The
+   * given intent is set to the host activity.
+   *
+   * <p>Note: the host activity class is not determined by the intent.
+   *
+   * <p>In general, it's a bad practice to design a fragment having dependency to a specific
+   * activity. Consider removing the dependency and use other {@link #buildFragment} method or
+   * {@link androidx.fragment.app.testing.FragmentScenario}.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass, Class<? extends Activity> activityClass, Intent intent) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), activityClass, intent);
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class in the specified host activity with
+   * the arguments.
+   *
+   * <p>In general, it's a bad practice to design a fragment having dependency to a specific
+   * activity. Consider removing the dependency and use other {@link #buildFragment} method or
+   * {@link androidx.fragment.app.testing.FragmentScenario}.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass, Class<? extends Activity> activityClass, Bundle arguments) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), activityClass, arguments);
+  }
+
+  /**
+   * Creates a FragmentController for the given fragment class in the specified host activity with
+   * the arguments. The given intent is set to the host activity.
+   *
+   * <p>Note: the host activity class is not determined by the intent.
+   *
+   * <p>In general, it's a bad practice to design a fragment having dependency to a specific
+   * activity. Consider removing the dependency and use other {@link #buildFragment} method or
+   * {@link androidx.fragment.app.testing.FragmentScenario}.
+   *
+   * <p>FragmentController provides low-level APIs to control its lifecycle. Please consider using
+   * {@link androidx.fragment.app.testing.FragmentScenario} instead, which provides higher level
+   * APIs and works with instrumentation tests too.
+   *
+   * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers
+   *     to use androidx fragments, to test these use FragmentScenario.
+   */
+  @Deprecated
+  public static <T extends Fragment> FragmentController<T> buildFragment(
+      Class<T> fragmentClass,
+      Class<? extends Activity> activityClass,
+      Intent intent,
+      Bundle arguments) {
+    return FragmentController.of(ReflectionHelpers.callConstructor(fragmentClass), activityClass, intent, arguments);
+  }
+
+  public static <T extends BackupAgent> BackupAgentController<T> buildBackupAgent(Class<T> backupAgentClass) {
+    return BackupAgentController.of(ReflectionHelpers.callConstructor(backupAgentClass));
+  }
+
+  public static <T extends BackupAgent> T setupBackupAgent(Class<T> backupAgentClass) {
+    return buildBackupAgent(backupAgentClass).create().get();
+  }
+
+  /**
+   * Allows for the programmatic creation of an {@link AttributeSet}.
+   *
+   * Useful for testing {@link View} classes without the need for creating XML snippets.
+   */
+  public static org.robolectric.android.AttributeSetBuilder buildAttributeSet() {
+    if (useLegacy()) {
+      return new AttributeSetBuilderImpl(
+          new LegacyResourceResolver(
+              RuntimeEnvironment.getApplication(),
+              RuntimeEnvironment.getCompileTimeResourceTable())) {};
+    } else {
+      return new AttributeSetBuilderImpl(
+          new ArscResourceResolver(RuntimeEnvironment.getApplication())) {};
+    }
+  }
+
+  /**
+   * Builder of {@link AttributeSet}s.
+   *
+   * @deprecated Use {@link org.robolectric.android.AttributeSetBuilder} instead.
+   */
+  @Deprecated
+  public interface AttributeSetBuilder {
+    /**
+     * Set an attribute to the given value.
+     *
+     * The value will be interpreted according to the attribute's format.
+     *
+     * @param resId The attribute resource id to set.
+     * @param value The value to set.
+     * @return This {@link org.robolectric.android.AttributeSetBuilder}.
+     */
+    AttributeSetBuilder addAttribute(@IdRes int resId, String value);
+
+    /**
+     * Set the style attribute to the given value.
+     *
+     * The value will be interpreted as a resource reference.
+     *
+     * @param value The value for the specified attribute in this {@link AttributeSet}.
+     * @return This {@link org.robolectric.android.AttributeSetBuilder}.
+     */
+    AttributeSetBuilder setStyleAttribute(String value);
+
+    /**
+     * Build an {@link AttributeSet} with the antecedent attributes.
+     *
+     * @return A new {@link AttributeSet}.
+     */
+    AttributeSet build();
+  }
+
+  /**
+   * Return the foreground scheduler (e.g. the UI thread scheduler).
+   *
+   * @return Foreground scheduler.
+   */
+  public static Scheduler getForegroundThreadScheduler() {
+    return RuntimeEnvironment.getMasterScheduler();
+  }
+
+  /**
+   * Execute all runnables that have been enqueued on the foreground scheduler.
+   */
+  public static void flushForegroundThreadScheduler() {
+    getForegroundThreadScheduler().advanceToLastPostedRunnable();
+  }
+
+  /**
+   * Return the background scheduler.
+   *
+   * @return Background scheduler.
+   */
+  public static Scheduler getBackgroundThreadScheduler() {
+    return ShadowApplication.getInstance().getBackgroundThreadScheduler();
+  }
+
+  /**
+   * Execute all runnables that have been enqueued on the background scheduler.
+   */
+  public static void flushBackgroundThreadScheduler() {
+    getBackgroundThreadScheduler().advanceToLastPostedRunnable();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/RobolectricTestRunner.java b/robolectric/src/main/java/org/robolectric/RobolectricTestRunner.java
new file mode 100644
index 0000000..3db82c0
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/RobolectricTestRunner.java
@@ -0,0 +1,759 @@
+package org.robolectric;
+
+import android.os.Build;
+import com.google.auto.service.AutoService;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nonnull;
+import javax.annotation.Priority;
+import org.junit.AssumptionViolatedException;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+import org.junit.runners.model.Statement;
+import org.robolectric.android.AndroidSdkShadowMatcher;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.config.AndroidConfigurer;
+import org.robolectric.interceptors.AndroidInterceptors;
+import org.robolectric.internal.AndroidSandbox;
+import org.robolectric.internal.BuckManifestFactory;
+import org.robolectric.internal.DefaultManifestFactory;
+import org.robolectric.internal.ManifestFactory;
+import org.robolectric.internal.ManifestIdentifier;
+import org.robolectric.internal.MavenManifestFactory;
+import org.robolectric.internal.ResourcesMode;
+import org.robolectric.internal.SandboxManager;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.TestEnvironment;
+import org.robolectric.internal.bytecode.ClassHandler;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.internal.bytecode.Interceptor;
+import org.robolectric.internal.bytecode.Sandbox;
+import org.robolectric.internal.bytecode.SandboxClassLoader;
+import org.robolectric.internal.bytecode.ShadowMap;
+import org.robolectric.internal.bytecode.ShadowWrangler;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkPicker;
+import org.robolectric.pluginapi.config.ConfigurationStrategy;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.pluginapi.config.GlobalConfigProvider;
+import org.robolectric.plugins.HierarchicalConfigurationStrategy.ConfigurationImpl;
+import org.robolectric.util.Logger;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.inject.Injector;
+
+/**
+ * Loads and runs a test in a {@link SandboxClassLoader} in order to provide a simulation of the
+ * Android runtime environment.
+ */
+@SuppressWarnings("NewApi")
+public class RobolectricTestRunner extends SandboxTestRunner {
+
+  public static final String CONFIG_PROPERTIES = "robolectric.properties";
+  private static final Injector DEFAULT_INJECTOR = defaultInjector().build();
+  private static final Map<ManifestIdentifier, AndroidManifest> appManifestsCache = new HashMap<>();
+
+  static {
+    // This starts up the Poller SunPKCS11-Darwin thread early, outside of any Robolectric
+    // classloader.
+    new SecureRandom();
+    // Fixes an issue using AWT-backed graphics shadows when using X11 forwarding.
+    System.setProperty("java.awt.headless", "true");
+  }
+
+  protected static Injector.Builder defaultInjector() {
+    return SandboxTestRunner.defaultInjector().bind(Properties.class, System.getProperties());
+  }
+
+  private final SandboxManager sandboxManager;
+  private final SdkPicker sdkPicker;
+  private final ConfigurationStrategy configurationStrategy;
+  private final AndroidConfigurer androidConfigurer;
+
+  private final ResModeStrategy resModeStrategy = getResModeStrategy();
+  private boolean alwaysIncludeVariantMarkersInName =
+      Boolean.parseBoolean(
+          System.getProperty("robolectric.alwaysIncludeVariantMarkersInTestName", "false"));
+
+  /**
+   * Creates a runner to run {@code testClass}. Use the {@link Config} annotation to configure.
+   *
+   * @param testClass the test class to be run
+   * @throws InitializationError if junit says so
+   */
+  public RobolectricTestRunner(final Class<?> testClass) throws InitializationError {
+    this(testClass, DEFAULT_INJECTOR);
+  }
+
+  protected RobolectricTestRunner(final Class<?> testClass, Injector injector)
+      throws InitializationError {
+    super(testClass, injector);
+
+    if (DeprecatedTestRunnerDefaultConfigProvider.globalConfig == null) {
+      DeprecatedTestRunnerDefaultConfigProvider.globalConfig = buildGlobalConfig();
+    }
+
+    this.sandboxManager = injector.getInstance(SandboxManager.class);
+    this.sdkPicker = injector.getInstance(SdkPicker.class);
+    this.configurationStrategy = injector.getInstance(ConfigurationStrategy.class);
+    this.androidConfigurer = injector.getInstance(AndroidConfigurer.class);
+  }
+
+  /**
+   * Create a {@link ClassHandler} appropriate for the given arguments.
+   *
+   * <p>Robolectric may chose to cache the returned instance, keyed by {@code shadowMap} and {@code
+   * sandbox}.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide alternate
+   * configuration.
+   *
+   * @param shadowMap the {@link ShadowMap} in effect for this test
+   * @param sandbox the {@link Sdk} in effect for this test
+   * @return an appropriate {@link ShadowWrangler}.
+   * @since 2.3
+   */
+  @Override
+  @Nonnull
+  protected ClassHandler createClassHandler(ShadowMap shadowMap, Sandbox sandbox) {
+    int apiLevel = ((AndroidSandbox) sandbox).getSdk().getApiLevel();
+    AndroidSdkShadowMatcher shadowMatcher = new AndroidSdkShadowMatcher(apiLevel);
+    return classHandlerBuilder.build(shadowMap, shadowMatcher, getInterceptors());
+  }
+
+  @Override
+  @Nonnull // todo
+  protected Collection<Interceptor> findInterceptors() {
+    return AndroidInterceptors.all();
+  }
+
+  /**
+   * Create an {@link InstrumentationConfiguration} suitable for the provided {@link
+   * FrameworkMethod}.
+   *
+   * <p>Adds configuration for Android using {@link AndroidConfigurer}.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide additional
+   * configuration.
+   *
+   * @param method the test method that's about to run
+   * @return an {@link InstrumentationConfiguration}
+   */
+  @Override
+  @Nonnull
+  protected InstrumentationConfiguration createClassLoaderConfig(final FrameworkMethod method) {
+    Configuration configuration = ((RobolectricFrameworkMethod) method).getConfiguration();
+    Config config = configuration.get(Config.class);
+
+    InstrumentationConfiguration.Builder builder =
+        new InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method));
+    androidConfigurer.configure(builder, getInterceptors());
+    androidConfigurer.withConfig(builder, config);
+    return builder.build();
+  }
+
+  /**
+   * An instance of the returned class will be created for each test invocation.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide alternate
+   * configuration.
+   *
+   * @return a class which implements {@link TestLifecycle}. This implementation returns a {@link
+   *     DefaultTestLifecycle}.
+   */
+  @Nonnull
+  protected Class<? extends TestLifecycle> getTestLifecycleClass() {
+    return DefaultTestLifecycle.class;
+  }
+
+  enum ResModeStrategy {
+    legacy,
+    binary,
+    best,
+    both;
+
+    static final ResModeStrategy DEFAULT = best;
+
+    private static ResModeStrategy getFromProperties() {
+      String resourcesMode = System.getProperty("robolectric.resourcesMode");
+      return resourcesMode == null ? DEFAULT : valueOf(resourcesMode);
+    }
+
+    boolean includeLegacy(AndroidManifest appManifest) {
+      return appManifest.supportsLegacyResourcesMode()
+          && (this == legacy
+              || (this == best && !appManifest.supportsBinaryResourcesMode())
+              || this == both);
+    }
+
+    boolean includeBinary(AndroidManifest appManifest) {
+      return appManifest.supportsBinaryResourcesMode()
+          && (this == binary || this == best || this == both);
+    }
+  }
+
+  @Override
+  protected List<FrameworkMethod> getChildren() {
+    List<FrameworkMethod> children = new ArrayList<>();
+    for (FrameworkMethod frameworkMethod : super.getChildren()) {
+      try {
+        Configuration configuration = getConfiguration(frameworkMethod.getMethod());
+
+        AndroidManifest appManifest = getAppManifest(configuration);
+
+        List<Sdk> sdksToRun = sdkPicker.selectSdks(configuration, appManifest);
+        RobolectricFrameworkMethod last = null;
+        for (Sdk sdk : sdksToRun) {
+          if (resModeStrategy.includeLegacy(appManifest)) {
+            children.add(
+                last =
+                    new RobolectricFrameworkMethod(
+                        frameworkMethod.getMethod(),
+                        appManifest,
+                        sdk,
+                        configuration,
+                        ResourcesMode.LEGACY,
+                        resModeStrategy,
+                        alwaysIncludeVariantMarkersInName));
+          }
+          if (resModeStrategy.includeBinary(appManifest)) {
+            children.add(
+                last =
+                    new RobolectricFrameworkMethod(
+                        frameworkMethod.getMethod(),
+                        appManifest,
+                        sdk,
+                        configuration,
+                        ResourcesMode.BINARY,
+                        resModeStrategy,
+                        alwaysIncludeVariantMarkersInName));
+          }
+        }
+        if (last != null) {
+          last.dontIncludeVariantMarkersInTestName();
+        }
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException(
+            "failed to configure "
+                + getTestClass().getName()
+                + "."
+                + frameworkMethod.getMethod().getName()
+                + ": "
+                + e.getMessage(),
+            e);
+      }
+    }
+    return children;
+  }
+
+  @Override
+  @Nonnull
+  protected AndroidSandbox getSandbox(FrameworkMethod method) {
+    RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) method;
+    Sdk sdk = roboMethod.getSdk();
+
+    InstrumentationConfiguration classLoaderConfig = createClassLoaderConfig(method);
+    ResourcesMode resourcesMode = roboMethod.getResourcesMode();
+
+    if (resourcesMode == ResourcesMode.LEGACY && sdk.getApiLevel() > Build.VERSION_CODES.P) {
+      System.err.println(
+          "Skip " + method.getName() + " because Robolectric doesn't support legacy mode after P");
+      throw new AssumptionViolatedException("Robolectric doesn't support legacy mode after P");
+    }
+    LooperMode.Mode looperMode =
+        roboMethod.configuration == null
+            ? Mode.LEGACY
+            : roboMethod.configuration.get(LooperMode.Mode.class);
+
+    SQLiteMode.Mode sqliteMode =
+        roboMethod.configuration == null
+            ? SQLiteMode.Mode.LEGACY
+            : roboMethod.configuration.get(SQLiteMode.Mode.class);
+
+    sdk.verifySupportedSdk(method.getDeclaringClass().getName());
+    return sandboxManager.getAndroidSandbox(
+        classLoaderConfig, sdk, resourcesMode, looperMode, sqliteMode);
+  }
+
+  @Override
+  protected void beforeTest(Sandbox sandbox, FrameworkMethod method, Method bootstrappedMethod)
+      throws Throwable {
+    AndroidSandbox androidSandbox = (AndroidSandbox) sandbox;
+    RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) method;
+
+    PerfStatsCollector perfStatsCollector = PerfStatsCollector.getInstance();
+    Sdk sdk = roboMethod.getSdk();
+    perfStatsCollector.putMetadata(
+        AndroidMetadata.class,
+        new AndroidMetadata(
+            ImmutableMap.of("ro.build.version.sdk", "" + sdk.getApiLevel()),
+            roboMethod.resourcesMode.name()));
+
+    Logger.lifecycle(
+        roboMethod.getDeclaringClass().getName()
+            + "."
+            + roboMethod.getMethod().getName()
+            + ": sdk="
+            + sdk.getApiLevel()
+            + "; resources="
+            + roboMethod.resourcesMode);
+
+    if (roboMethod.resourcesMode == ResourcesMode.LEGACY) {
+      Logger.warn(
+          "Legacy resources mode is deprecated; see"
+              + " http://robolectric.org/migrating/#migrating-to-40");
+    }
+
+    roboMethod.setStuff(androidSandbox, androidSandbox.getTestEnvironment());
+    Class<TestLifecycle> cl = androidSandbox.bootstrappedClass(getTestLifecycleClass());
+    roboMethod.testLifecycle = ReflectionHelpers.newInstance(cl);
+
+    AndroidManifest appManifest = roboMethod.getAppManifest();
+
+    roboMethod
+        .getTestEnvironment()
+        .setUpApplicationState(bootstrappedMethod, roboMethod.getConfiguration(), appManifest);
+
+    roboMethod.testLifecycle.beforeTest(bootstrappedMethod);
+  }
+
+  @Override
+  protected void afterTest(FrameworkMethod method, Method bootstrappedMethod) {
+    RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) method;
+    try {
+      roboMethod.getTestEnvironment().tearDownApplication();
+    } finally {
+      roboMethod.testLifecycle.afterTest(bootstrappedMethod);
+    }
+  }
+
+  @Override
+  protected void finallyAfterTest(FrameworkMethod method) {
+    RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) method;
+
+    // If the test was interrupted, it will interfere with new AbstractInterruptibleChannels in
+    // subsequent tests, e.g. created by Files.newInputStream(), so clear it and warn.
+    if (Thread.interrupted()) {
+      Logger.warn("Test thread was interrupted! " + method.toString());
+    }
+
+    try {
+      // reset static state afterward too, so statics don't defeat GC?
+      PerfStatsCollector.getInstance()
+          .measure(
+              "reset Android state (after test)",
+              () -> roboMethod.getTestEnvironment().resetState());
+    } finally {
+      roboMethod.testLifecycle = null;
+      roboMethod.clearContext();
+    }
+  }
+
+  @Override
+  protected SandboxTestRunner.HelperTestRunner getHelperTestRunner(Class<?> bootstrappedTestClass)
+      throws InitializationError {
+    return new HelperTestRunner(bootstrappedTestClass);
+  }
+
+  /**
+   * Detects which build system is in use and returns the appropriate ManifestFactory
+   * implementation.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide alternate
+   * configuration.
+   *
+   * @param config Specification of the SDK version, manifest file, package name, etc.
+   */
+  protected ManifestFactory getManifestFactory(Config config) {
+    Properties buildSystemApiProperties = getBuildSystemApiProperties();
+    if (buildSystemApiProperties != null) {
+      return new DefaultManifestFactory(buildSystemApiProperties);
+    }
+
+    if (BuckManifestFactory.isBuck()) {
+      return new BuckManifestFactory();
+    } else {
+      return new MavenManifestFactory();
+    }
+  }
+
+  protected Properties getBuildSystemApiProperties() {
+    return staticGetBuildSystemApiProperties();
+  }
+
+  protected static Properties staticGetBuildSystemApiProperties() {
+    try (InputStream resourceAsStream =
+        RobolectricTestRunner.class.getResourceAsStream(
+            "/com/android/tools/test_config.properties")) {
+      if (resourceAsStream == null) {
+        return null;
+      }
+
+      Properties properties = new Properties();
+      properties.load(resourceAsStream);
+      return properties;
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  private AndroidManifest getAppManifest(Configuration configuration) {
+    Config config = configuration.get(Config.class);
+    ManifestFactory manifestFactory = getManifestFactory(config);
+    ManifestIdentifier identifier = manifestFactory.identify(config);
+
+    return cachedCreateAppManifest(identifier);
+  }
+
+  private AndroidManifest cachedCreateAppManifest(ManifestIdentifier identifier) {
+    synchronized (appManifestsCache) {
+      AndroidManifest appManifest;
+      appManifest = appManifestsCache.get(identifier);
+      if (appManifest == null) {
+        appManifest = createAndroidManifest(identifier);
+        appManifestsCache.put(identifier, appManifest);
+      }
+
+      return appManifest;
+    }
+  }
+
+  /**
+   * Internal use only.
+   *
+   * @deprecated Do not use.
+   */
+  @Deprecated
+  @VisibleForTesting
+  public static AndroidManifest createAndroidManifest(ManifestIdentifier manifestIdentifier) {
+    List<ManifestIdentifier> libraries = manifestIdentifier.getLibraries();
+
+    List<AndroidManifest> libraryManifests = new ArrayList<>();
+    for (ManifestIdentifier library : libraries) {
+      libraryManifests.add(createAndroidManifest(library));
+    }
+
+    return new AndroidManifest(
+        manifestIdentifier.getManifestFile(),
+        manifestIdentifier.getResDir(),
+        manifestIdentifier.getAssetDir(),
+        libraryManifests,
+        manifestIdentifier.getPackageName(),
+        manifestIdentifier.getApkFile());
+  }
+
+  /**
+   * Compute the effective Robolectric configuration for a given test method.
+   *
+   * <p>Configuration information is collected from package-level {@code robolectric.properties}
+   * files and {@link Config} annotations on test classes, superclasses, and methods.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide alternate
+   * configuration.
+   *
+   * @param method the test method
+   * @return the effective Robolectric configuration for the given test method
+   * @deprecated Provide an implementation of {@link javax.inject.Provider<Config>} instead. This
+   *     method will be removed in Robolectric 4.3.
+   * @since 2.0
+   * @see <a href="http://robolectric.org/migrating/#migrating-to-40">Migration Notes</a> for more
+   *     details.
+   */
+  @Deprecated
+  public Config getConfig(Method method) {
+    throw new UnsupportedOperationException();
+  }
+
+  /** Calculate the configuration for a given test method. */
+  private Configuration getConfiguration(Method method) {
+    Configuration configuration =
+        configurationStrategy.getConfig(getTestClass().getJavaClass(), method);
+
+    // in case #getConfig(Method) has been overridden...
+    try {
+      Config config = getConfig(method);
+      ((ConfigurationImpl) configuration).put(Config.class, config);
+    } catch (UnsupportedOperationException e) {
+      // no problem
+    }
+
+    return configuration;
+  }
+
+  /**
+   * Provides the base Robolectric configuration {@link Config} used for all tests.
+   *
+   * <p>Configuration provided for specific packages, test classes, and test method configurations
+   * will override values provided here.
+   *
+   * <p>Custom TestRunner subclasses may wish to override this method to provide alternate
+   * configuration. Consider using a {@link Config.Builder}.
+   *
+   * <p>The default implementation has appropriate values for most use cases.
+   *
+   * @return global {@link Config} object
+   * @deprecated Provide a service implementation of {@link GlobalConfigProvider} instead. This
+   *     method will be removed in Robolectric 4.3.
+   * @since 3.1.3
+   * @see <a href="http://robolectric.org/migrating/#migrating-to-40">Migration Notes</a> for more
+   *     details.
+   */
+  @Deprecated
+  @SuppressWarnings("InlineMeSuggester")
+  protected Config buildGlobalConfig() {
+    return new Config.Builder().build();
+  }
+
+  @AutoService(GlobalConfigProvider.class)
+  @Priority(Integer.MIN_VALUE)
+  @Deprecated
+  public static class DeprecatedTestRunnerDefaultConfigProvider implements GlobalConfigProvider {
+    static Config globalConfig;
+
+    @Override
+    public Config get() {
+      return globalConfig;
+    }
+  }
+
+  @Override
+  @Nonnull
+  protected Class<?>[] getExtraShadows(FrameworkMethod frameworkMethod) {
+    ArrayList<Class<?>> extraShadows = new ArrayList<>();
+    RobolectricFrameworkMethod roboFrameworkMethod = (RobolectricFrameworkMethod) frameworkMethod;
+    Config config = roboFrameworkMethod.getConfiguration().get(Config.class);
+    Collections.addAll(extraShadows, config.shadows());
+    return extraShadows.toArray(new Class<?>[] {});
+  }
+
+  @Override
+  protected void afterClass() {}
+
+  @Override
+  public Object createTest() throws Exception {
+    throw new UnsupportedOperationException(
+        "this should always be invoked on the HelperTestRunner!");
+  }
+
+  @VisibleForTesting
+  ResModeStrategy getResModeStrategy() {
+    return ResModeStrategy.getFromProperties();
+  }
+
+  public static class HelperTestRunner extends SandboxTestRunner.HelperTestRunner {
+    public HelperTestRunner(Class bootstrappedTestClass) throws InitializationError {
+      super(bootstrappedTestClass);
+    }
+
+    @Override
+    protected Object createTest() throws Exception {
+      Object test = super.createTest();
+      RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) this.frameworkMethod;
+      roboMethod.testLifecycle.prepareTest(test);
+      return test;
+    }
+
+    @Override
+    protected Statement methodBlock(FrameworkMethod method) {
+      RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) this.frameworkMethod;
+      Statement baseStatement = super.methodBlock(method);
+      return new Statement() {
+        @Override
+        public void evaluate() throws Throwable {
+          try {
+            baseStatement.evaluate();
+          } catch (org.junit.internal.AssumptionViolatedException e) {
+            // catch JUnit's internal AssumptionViolatedException that is the ancestor of all
+            // AssumptionViolatedExceptions, including Truth's ThrowableAssumptionViolatedException.
+            throw e;
+          } catch (Throwable t) {
+            roboMethod.getTestEnvironment().checkStateAfterTestFailure(t);
+            throw t;
+          }
+        }
+      };
+    }
+  }
+
+  /**
+   * Fields in this class must be serializable using <a
+   * href="https://x-stream.github.io/">XStream</a>.
+   */
+  public static class RobolectricFrameworkMethod extends FrameworkMethod {
+
+    private static final AtomicInteger NEXT_ID = new AtomicInteger();
+    private static final Map<Integer, TestExecutionContext> CONTEXT = new HashMap<>();
+
+    private final int id;
+
+    private final int apiLevel;
+    @Nonnull private final AndroidManifest appManifest;
+    @Nonnull private final Configuration configuration;
+    @Nonnull private final ResourcesMode resourcesMode;
+    @Nonnull private final ResModeStrategy defaultResModeStrategy;
+    private final boolean alwaysIncludeVariantMarkersInName;
+
+    private boolean includeVariantMarkersInTestName = true;
+    TestLifecycle testLifecycle;
+
+    protected RobolectricFrameworkMethod(RobolectricFrameworkMethod other) {
+      this(
+          other.getMethod(),
+          other.appManifest,
+          other.getSdk(),
+          other.configuration,
+          other.resourcesMode,
+          other.defaultResModeStrategy,
+          other.alwaysIncludeVariantMarkersInName);
+
+      includeVariantMarkersInTestName = other.includeVariantMarkersInTestName;
+      testLifecycle = other.testLifecycle;
+    }
+
+    RobolectricFrameworkMethod(
+        @Nonnull Method method,
+        @Nonnull AndroidManifest appManifest,
+        @Nonnull Sdk sdk,
+        @Nonnull Configuration configuration,
+        @Nonnull ResourcesMode resourcesMode,
+        @Nonnull ResModeStrategy defaultResModeStrategy,
+        boolean alwaysIncludeVariantMarkersInName) {
+      super(method);
+
+      this.apiLevel = sdk.getApiLevel();
+      this.appManifest = appManifest;
+      this.configuration = configuration;
+      this.resourcesMode = resourcesMode;
+      this.defaultResModeStrategy = defaultResModeStrategy;
+      this.alwaysIncludeVariantMarkersInName = alwaysIncludeVariantMarkersInName;
+
+      // external storage for things that can't go through a serialization cycle e.g. for PowerMock.
+      this.id = NEXT_ID.getAndIncrement();
+      CONTEXT.put(id, new TestExecutionContext(sdk));
+    }
+
+    @Override
+    public String getName() {
+      // IDE focused test runs rely on preservation of the test name; we'll use the
+      // latest supported SDK for focused test runs
+      StringBuilder buf = new StringBuilder(super.getName());
+
+      if (includeVariantMarkersInTestName || alwaysIncludeVariantMarkersInName) {
+        buf.append("[").append(getSdk().getApiLevel()).append("]");
+
+        if (defaultResModeStrategy == ResModeStrategy.both) {
+          buf.append("[").append(resourcesMode.name()).append("]");
+        }
+      }
+
+      return buf.toString();
+    }
+
+    void dontIncludeVariantMarkersInTestName() {
+      includeVariantMarkersInTestName = false;
+    }
+
+    @Nonnull
+    AndroidManifest getAppManifest() {
+      return appManifest;
+    }
+
+    @Nonnull
+    public Sdk getSdk() {
+      return getContext().sdk;
+    }
+
+    void setStuff(Sandbox sandbox, TestEnvironment testEnvironment) {
+      TestExecutionContext context = getContext();
+      context.sandbox = sandbox;
+      context.testEnvironment = testEnvironment;
+    }
+
+    Sandbox getSandbox() {
+      return getContext().sandbox;
+    }
+
+    TestEnvironment getTestEnvironment() {
+      TestExecutionContext context = getContext();
+      return context == null ? null : context.testEnvironment;
+    }
+
+    public boolean isLegacy() {
+      return resourcesMode == ResourcesMode.LEGACY;
+    }
+
+    public ResourcesMode getResourcesMode() {
+      return resourcesMode;
+    }
+
+    private TestExecutionContext getContext() {
+      return CONTEXT.get(id);
+    }
+
+    private void clearContext() {
+      CONTEXT.remove(id);
+    }
+
+    @Override
+    protected void finalize() throws Throwable {
+      super.finalize();
+      clearContext();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) return true;
+      if (!(o instanceof RobolectricFrameworkMethod)) return false;
+      if (!super.equals(o)) return false;
+
+      RobolectricFrameworkMethod that = (RobolectricFrameworkMethod) o;
+
+      return apiLevel == that.apiLevel && resourcesMode == that.resourcesMode;
+    }
+
+    @Override
+    public int hashCode() {
+      int result = super.hashCode();
+      result = 31 * result + apiLevel;
+      result = 31 * result + resourcesMode.ordinal();
+      return result;
+    }
+
+    @Override
+    public String toString() {
+      return getName();
+    }
+
+    @Nonnull
+    public Configuration getConfiguration() {
+      return configuration;
+    }
+
+    private static class TestExecutionContext {
+
+      private final Sdk sdk;
+      private Sandbox sandbox;
+      private TestEnvironment testEnvironment;
+
+      TestExecutionContext(Sdk sdk) {
+        this.sdk = sdk;
+      }
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/SdkPicker.java b/robolectric/src/main/java/org/robolectric/SdkPicker.java
new file mode 100644
index 0000000..03a45b0
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/SdkPicker.java
@@ -0,0 +1,18 @@
+package org.robolectric;
+
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import javax.inject.Inject;
+import org.robolectric.plugins.DefaultSdkPicker;
+import org.robolectric.plugins.SdkCollection;
+
+/** @deprecated use {@link org.robolectric.plugins.DefaultSdkPicker} instead. */
+@Deprecated
+public class SdkPicker extends DefaultSdkPicker {
+
+  @Inject
+  public SdkPicker(@Nonnull SdkCollection sdkCollection, Properties systemProperties) {
+    super(sdkCollection, systemProperties);
+  }
+
+}
diff --git a/robolectric/src/main/java/org/robolectric/TestLifecycle.java b/robolectric/src/main/java/org/robolectric/TestLifecycle.java
new file mode 100644
index 0000000..d5abd6b
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/TestLifecycle.java
@@ -0,0 +1,27 @@
+package org.robolectric;
+
+import java.lang.reflect.Method;
+
+public interface TestLifecycle<T> {
+
+  /**
+   * Called before each test method is run.
+   *
+   * @param method the test method about to be run
+   */
+  void beforeTest(Method method);
+
+  /**
+   * Called after each test method is run.
+   *
+   * @param test the instance of the test class that is about to be used
+   */
+  void prepareTest(Object test);
+
+  /**
+   * Called after each test method is run.
+   *
+   * @param method the test method that was just run
+   */
+  void afterTest(Method method);
+}
diff --git a/robolectric/src/main/java/org/robolectric/TestLifecycleApplication.java b/robolectric/src/main/java/org/robolectric/TestLifecycleApplication.java
new file mode 100644
index 0000000..85ed360
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/TestLifecycleApplication.java
@@ -0,0 +1,11 @@
+package org.robolectric;
+
+import java.lang.reflect.Method;
+
+public interface TestLifecycleApplication {
+  void beforeTest(Method method);
+
+  void prepareTest(Object test);
+
+  void afterTest(Method method);
+}
\ No newline at end of file
diff --git a/robolectric/src/main/java/org/robolectric/android/AndroidSdkShadowMatcher.java b/robolectric/src/main/java/org/robolectric/android/AndroidSdkShadowMatcher.java
new file mode 100644
index 0000000..2517f53
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/AndroidSdkShadowMatcher.java
@@ -0,0 +1,47 @@
+package org.robolectric.android;
+
+import java.lang.reflect.Method;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.internal.bytecode.ShadowInfo;
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.util.Logger;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Android-specific rules for matching shadow classes and methods by SDK level.
+ */
+public class AndroidSdkShadowMatcher implements ShadowMatcher {
+  private static final Implementation IMPLEMENTATION_DEFAULTS =
+      ReflectionHelpers.defaultsFor(Implementation.class);
+
+  private final int sdkLevel;
+
+  public AndroidSdkShadowMatcher(int sdkLevel) {
+    this.sdkLevel = sdkLevel;
+  }
+
+  @Override
+  public boolean matches(ShadowInfo shadowInfo) {
+    return shadowInfo.supportsSdk(sdkLevel);
+  }
+
+  @Override
+  public boolean matches(Method method) {
+    Implementation implementation = getImplementationAnnotation(method);
+    return implementation.minSdk() <= sdkLevel &&
+        (implementation.maxSdk() == -1 || implementation.maxSdk() >= sdkLevel);
+  }
+
+  private static Implementation getImplementationAnnotation(Method method) {
+    if (method == null) {
+      return null;
+    }
+    Implementation implementation = method.getAnnotation(Implementation.class);
+    if (implementation == null) {
+      Logger.warn("No @Implementation annotation on " + method);
+    }
+    return implementation == null
+        ? IMPLEMENTATION_DEFAULTS
+        : implementation;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/AttributeSetBuilder.java b/robolectric/src/main/java/org/robolectric/android/AttributeSetBuilder.java
new file mode 100644
index 0000000..2b3484c
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/AttributeSetBuilder.java
@@ -0,0 +1,63 @@
+package org.robolectric.android;
+
+import android.annotation.IdRes;
+import android.util.AttributeSet;
+import org.robolectric.Robolectric;
+
+/**
+ * Builder of {@link AttributeSet}s.
+ */
+public interface AttributeSetBuilder extends Robolectric.AttributeSetBuilder {
+
+  /**
+   * Set an attribute to the given value.
+   *
+   * The value will be interpreted according to the attribute's format.
+   *
+   * @param resId The attribute resource id to set.
+   * @param value The value to set.
+   * @return This {@link AttributeSetBuilder}.
+   */
+  @Override
+  AttributeSetBuilder addAttribute(@IdRes int resId, String value);
+
+  /**
+   * Set the style attribute to the given value.
+   *
+   * The value will be interpreted as a resource reference.
+   *
+   * @param value The value for the specified attribute in this {@link AttributeSet}.
+   * @return This {@link AttributeSetBuilder}.
+   */
+  @Override
+  AttributeSetBuilder setStyleAttribute(String value);
+
+  /**
+   * Set the class attribute to the given value.
+   *
+   * The value will be interpreted as a class name.
+   *
+   * @param value The value for this {@link AttributeSet}'s {@code class} attribute.
+   * @return This {@link AttributeSetBuilder}.
+   */
+  AttributeSetBuilder setClassAttribute(String value);
+
+  /**
+   * Set the id attribute to the given value.
+   *
+   * The value will be interpreted as an element id name.
+   *
+   * @param value The value for this {@link AttributeSet}'s {@code id} attribute.
+   * @return This {@link AttributeSetBuilder}.
+   */
+  AttributeSetBuilder setIdAttribute(String value);
+
+  /**
+   * Build an {@link AttributeSet} with the antecedent attributes.
+   *
+   * @return A new {@link AttributeSet}.
+   */
+  @Override
+  AttributeSet build();
+
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/AttributeSetBuilderImpl.java b/robolectric/src/main/java/org/robolectric/android/AttributeSetBuilderImpl.java
new file mode 100644
index 0000000..00a8fca
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/AttributeSetBuilderImpl.java
@@ -0,0 +1,424 @@
+package org.robolectric.android;
+
+import static org.robolectric.res.android.ResourceTypes.ANDROID_NS;
+import static org.robolectric.res.android.ResourceTypes.AUTO_NS;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_END_ELEMENT_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_RESOURCE_MAP_TYPE;
+import static org.robolectric.res.android.ResourceTypes.RES_XML_START_ELEMENT_TYPE;
+import static org.robolectric.res.android.ResourceTypes.ResTable_map.ATTR_TYPE;
+import static org.robolectric.shadows.ShadowLegacyAssetManager.ATTRIBUTE_TYPE_PRECIDENCE;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import com.google.common.collect.ImmutableMap;
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import org.robolectric.res.AttrData;
+import org.robolectric.res.AttrData.Pair;
+import org.robolectric.res.AttributeResource;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResType;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.TypedResource;
+import org.robolectric.res.android.DataType;
+import org.robolectric.res.android.ResTable;
+import org.robolectric.res.android.ResTable.ResourceName;
+import org.robolectric.res.android.ResourceTable.flag_entry;
+import org.robolectric.res.android.ResourceTypes.ResChunk_header;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_header.Writer;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_attrExt;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_endElementExt;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_header;
+import org.robolectric.res.android.ResourceTypes.ResXMLTree_node;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.Converter;
+import org.robolectric.shadows.Converter2;
+import org.robolectric.shadows.ShadowArscAssetManager;
+import org.robolectric.shadows.ShadowAssetManager;
+import org.robolectric.shadows.ShadowLegacyAssetManager;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+public class AttributeSetBuilderImpl implements AttributeSetBuilder {
+  private static final int STYLE_RES_ID = Integer.MAX_VALUE - 2;
+  private static final int CLASS_RES_ID = Integer.MAX_VALUE - 1;
+  private static final int ID_RES_ID = Integer.MAX_VALUE;
+
+  private static final ImmutableMap<Integer, String> MAGIC_ATTRS = ImmutableMap.of(
+      STYLE_RES_ID, "style",
+      CLASS_RES_ID, "class",
+      ID_RES_ID, "id"
+  );
+
+  private final ResourceResolver resourceResolver;
+  private final Map<Integer, String> attrToValue = new TreeMap<>();
+
+  public interface ResourceResolver {
+
+    String getPackageName();
+
+    String getResourceName(Integer attrId);
+
+    Integer getIdentifier(String name, String type, String packageName);
+
+    void parseValue(Integer attrId, ResName attrResName, AttributeResource attribute,
+        TypedValue outValue);
+  }
+
+  public static class ArscResourceResolver implements ResourceResolver {
+
+    private final Context context;
+    private final ResTable resTable;
+
+    public ArscResourceResolver(Context context) {
+      this.context = context;
+
+      ShadowAssetManager.ArscBase shadowArscAssetManager = Shadow.extract(context.getAssets());
+      this.resTable = shadowArscAssetManager.getCompileTimeResTable();
+    }
+
+    @Override
+    public String getPackageName() {
+      return context.getPackageName();
+    }
+
+    @Override
+    public String getResourceName(Integer attrId) {
+      ResourceName name = new ResourceName();
+      if (!resTable.getResourceName(attrId, true, name)) {
+        return null;
+      }
+
+      StringBuilder str = new StringBuilder();
+      if (name.packageName != null) {
+        str.append(name.packageName.trim());
+      }
+      if (name.type != null) {
+        if (str.length() > 0) {
+          char div = ':';
+          str.append(div);
+        }
+        str.append(name.type);
+      }
+      if (name.name != null) {
+        if (str.length() > 0) {
+          char div = '/';
+          str.append(div);
+        }
+        str.append(name.name);
+      }
+      return str.toString();
+    }
+
+    @Override
+    public Integer getIdentifier(String name, String type, String packageName) {
+      return resTable.identifierForName(name, type, packageName);
+    }
+
+    @Override
+    public void parseValue(Integer attrId, ResName attrResName, AttributeResource attribute,
+        TypedValue outValue) {
+      arscParse(attrId, attrResName, attribute, outValue);
+    }
+
+    private void arscParse(Integer attrId, ResName attrResName, AttributeResource attribute,
+        TypedValue outValue) {
+      String format = ShadowArscAssetManager.getResourceBagValue(attrId, ATTR_TYPE, resTable);
+      Map<String, Integer> map = ShadowArscAssetManager.getResourceBagValues(attrId, resTable);
+      ArrayList<Pair> pairs = new ArrayList<>();
+      for (Entry<String, Integer> e : map.entrySet()) {
+        pairs.add(new Pair(e.getKey(), Integer.toString(e.getValue())));
+      }
+
+      int formatFlags = Integer.parseInt(format);
+      TreeSet<flag_entry> sortedFlags = new TreeSet<>(
+          (a, b) -> ATTRIBUTE_TYPE_PRECIDENCE.compare(a.name, b.name));
+      Collections.addAll(sortedFlags,
+          org.robolectric.res.android.ResourceTable.gFormatFlags);
+
+      for (flag_entry flag : sortedFlags) {
+        if ((formatFlags & flag.value) != 0) {
+          if ("reference".equals(flag.name)) {
+            continue;
+          }
+
+          AttrData attrData = new AttrData(attrResName.getFullyQualifiedName(), flag.name, pairs);
+          Converter2 converter = Converter2.getConverterFor(attrData, flag.name);
+          if (converter.fillTypedValue(attribute.value, outValue, true)) {
+            break;
+          }
+        }
+      }
+    }
+  }
+
+  public static class LegacyResourceResolver implements ResourceResolver {
+
+    private final Context context;
+    private final ResourceTable resourceTable;
+
+    public LegacyResourceResolver(Context context, ResourceTable compileTimeResourceTable) {
+      this.context = context;
+      resourceTable = compileTimeResourceTable;
+    }
+
+    @Override
+    public String getPackageName() {
+      return context.getPackageName();
+    }
+
+    @Override
+    public String getResourceName(Integer attrId) {
+      return resourceTable.getResName(attrId).getFullyQualifiedName();
+    }
+
+    @Override
+    public Integer getIdentifier(String name, String type, String packageName) {
+      Integer resourceId = resourceTable.getResourceId(new ResName(packageName, type, name));
+      if (resourceId == 0) {
+        resourceId = resourceTable.getResourceId(
+            new ResName(packageName, type, name.replace('.', '_')));
+      }
+      return resourceId;
+    }
+
+    @Override
+    public void parseValue(Integer attrId, ResName attrResName, AttributeResource attribute,
+        TypedValue outValue) {
+      ShadowLegacyAssetManager shadowAssetManager = Shadow
+          .extract(context.getResources().getAssets());
+      TypedResource attrTypeData = shadowAssetManager.getAttrTypeData(attribute.resName);
+      if (attrTypeData != null) {
+        AttrData attrData = (AttrData) attrTypeData.getData();
+        String format = attrData.getFormat();
+        String[] types = format.split("\\|");
+        Arrays.sort(types, ATTRIBUTE_TYPE_PRECIDENCE);
+        for (String type : types) {
+          if ("reference".equals(type)) continue; // already handled above
+          Converter2 converter = Converter2.getConverterFor(attrData, type);
+
+          if (converter != null) {
+            if (converter.fillTypedValue(attribute.value, outValue, true)) {
+              break;
+            }
+          }
+
+        }
+        // throw new IllegalArgumentException("wha? " + format);
+      } else {
+      /* In cases where the runtime framework doesn't know this attribute, e.g: viewportHeight (added in 21) on a
+       * KitKat runtine, then infer the attribute type from the value.
+       *
+       * TODO: When we are able to pass the SDK resources from the build environment then we can remove this
+       * and replace the NullResourceLoader with simple ResourceProvider that only parses attribute type information.
+       */
+        ResType resType = ResType.inferFromValue(attribute.value);
+        Converter.getConverter(resType).fillTypedValue(attribute.value, outValue);
+      }
+    }
+  }
+
+  protected AttributeSetBuilderImpl(ResourceResolver resourceResolver) {
+    this.resourceResolver = resourceResolver;
+  }
+
+  // todo rename to setAttribute(), or just set()?
+  @Override
+  public AttributeSetBuilder addAttribute(int resId, String value) {
+    attrToValue.put(resId, value);
+    return this;
+  }
+
+  // todo rename to setStyle()?
+  @Override
+  public AttributeSetBuilder setStyleAttribute(String value) {
+    attrToValue.put(STYLE_RES_ID, value);
+    return this;
+  }
+
+  // todo rename to setClass()?
+  @Override
+  public AttributeSetBuilder setClassAttribute(String value) {
+    attrToValue.put(CLASS_RES_ID, value);
+    return this;
+  }
+
+  // todo rename to setId()?
+  @Override
+  public AttributeSetBuilder setIdAttribute(String value) {
+    attrToValue.put(ID_RES_ID, value);
+    return this;
+  }
+
+  @Override
+  public AttributeSet build() {
+    Class<?> xmlBlockClass = ReflectionHelpers
+        .loadClass(this.getClass().getClassLoader(), "android.content.res.XmlBlock");
+
+    ByteBuffer buf = ByteBuffer.allocate(16 * 1024).order(ByteOrder.LITTLE_ENDIAN);
+    Writer resStringPoolWriter = new Writer();
+
+    final SparseArray<Integer> resIds = new SparseArray<>();
+    final int[] maxAttrNameIndex = new int[] { 0 };
+
+    ResXMLTree_attrExt.Writer dummyStart =
+        new ResXMLTree_attrExt.Writer(buf, resStringPoolWriter, null, "dummy") {
+          {
+            String packageName = resourceResolver.getPackageName();
+
+            for (Entry<Integer, String> entry : attrToValue.entrySet()) {
+              Integer attrId = entry.getKey();
+              String attrNs = "";
+              String attrName;
+              ResName attrResName = null;
+
+              String magicAttr = MAGIC_ATTRS.get(attrId);
+              if (magicAttr != null) {
+                attrId = null;
+                attrName = magicAttr;
+              } else {
+                String attrNameStr = resourceResolver.getResourceName(attrId);
+                attrResName = ResName.qualifyResName(attrNameStr, packageName, "attr");
+                attrNs = attrResName.packageName.equals("android") ? ANDROID_NS : AUTO_NS;
+                attrName = attrResName.name;
+              }
+
+              String value = entry.getValue();
+              DataType type;
+              int valueInt;
+
+              if (value == null || AttributeResource.isNull(value)) {
+                type = DataType.NULL;
+                valueInt = TypedValue.DATA_NULL_EMPTY;
+              } else if (AttributeResource.isResourceReference(value)) {
+                ResName resRef = AttributeResource.getResourceReference(value, packageName, null);
+                Integer valueResId =
+                    resourceResolver.getIdentifier(resRef.name, resRef.type, resRef.packageName);
+                if (valueResId == 0) {
+                  throw new IllegalArgumentException(
+                      "no such resource "
+                          + value
+                          + " while resolving value for "
+                          + (attrResName == null ? attrName : attrResName.getFullyQualifiedName()));
+                }
+                type = DataType.REFERENCE;
+                if (attrResName != null) {
+                  value = "@" + valueResId;
+                }
+                valueInt = valueResId;
+              } else if (AttributeResource.isStyleReference(value)) {
+                ResName resRef = AttributeResource.getStyleReference(value, packageName, "attr");
+                Integer valueResId =
+                    resourceResolver.getIdentifier(resRef.name, resRef.type, resRef.packageName);
+                if (valueResId == 0) {
+                  throw new IllegalArgumentException(
+                      "no such attr "
+                          + value
+                          + " while resolving value for "
+                          + (attrResName == null ? attrName : attrResName.getFullyQualifiedName()));
+                }
+                type = DataType.ATTRIBUTE;
+                valueInt = valueResId;
+              } else if (attrResName == null) { // class, id, or style
+                type = DataType.STRING;
+                valueInt = resStringPoolWriter.string(value);
+              } else {
+                TypedValue outValue = parse(attrId, attrResName, value, packageName);
+                type = DataType.fromCode(outValue.type);
+                value = (String) outValue.string;
+                if (type == DataType.STRING && outValue.data == 0) {
+                  valueInt = resStringPoolWriter.string(value);
+                } else {
+                  valueInt = outValue.data;
+                }
+              }
+
+              Res_value resValue = new Res_value(type.code(), valueInt);
+
+              int attrNameIndex = resStringPoolWriter.uniqueString(attrName);
+              attr(
+                  resStringPoolWriter.string(attrNs),
+                  attrNameIndex,
+                  resStringPoolWriter.string(value),
+                  resValue,
+                  attrNs + ":" + attrName);
+              if (attrId != null) {
+                resIds.put(attrNameIndex, attrId);
+              }
+              maxAttrNameIndex[0] = Math.max(maxAttrNameIndex[0], attrNameIndex);
+            }
+          }
+        };
+
+    ResXMLTree_endElementExt.Writer dummyEnd =
+        new ResXMLTree_endElementExt.Writer(buf, resStringPoolWriter, null, "dummy");
+
+    int finalMaxAttrNameIndex = maxAttrNameIndex[0];
+    ResXMLTree_header.write(buf, resStringPoolWriter, () -> {
+      if (finalMaxAttrNameIndex > 0) {
+        ResChunk_header.write(buf, (short) RES_XML_RESOURCE_MAP_TYPE, () -> {}, () -> {
+          // not particularly compact, but no big deal for our purposes...
+          for (int i = 0; i <= finalMaxAttrNameIndex; i++) {
+            Integer value = resIds.get(i);
+            buf.putInt(value == null ? 0 : value);
+          }
+        });
+      }
+
+      ResXMLTree_node.write(buf, RES_XML_START_ELEMENT_TYPE, dummyStart::write);
+      ResXMLTree_node.write(buf, RES_XML_END_ELEMENT_TYPE, dummyEnd::write);
+    });
+
+    int size = buf.position();
+    byte[] bytes = new byte[size];
+    // Cast to Buffer because generated covariant return type that returns ByteBuffer is not
+    // available on Java 8
+    ((Buffer) buf).position(0);
+    buf.get(bytes, 0, size);
+
+    Object xmlBlockInstance = ReflectionHelpers
+        .callConstructor(xmlBlockClass, ClassParameter.from(byte[].class, bytes));
+
+    AttributeSet parser = ReflectionHelpers.callInstanceMethod(xmlBlockClass, xmlBlockInstance,
+        "newParser");
+    ReflectionHelpers.callInstanceMethod(parser, "next");
+    ReflectionHelpers.callInstanceMethod(parser, "next");
+
+    return parser;
+  }
+
+  private TypedValue parse(Integer attrId, ResName attrResName, String value,
+      String packageName) {
+    AttributeResource attribute =
+        new AttributeResource(attrResName, value, packageName);
+    TypedValue outValue = new TypedValue();
+
+    if (attribute.isResourceReference()) {
+      ResName resourceReference = attribute.getResourceReference();
+      int id = resourceResolver.getIdentifier(resourceReference.name, resourceReference.type,
+          resourceReference.packageName);
+      if (id == 0) {
+        throw new IllegalArgumentException("couldn't resolve " + attribute);
+      }
+
+      outValue.type = Res_value.TYPE_REFERENCE;
+      outValue.data = id;
+      outValue.resourceId = id;
+      outValue.string = "@" + id;
+    } else {
+      resourceResolver.parseValue(attrId, attrResName, attribute, outValue);
+    }
+    return outValue;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java b/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java
new file mode 100755
index 0000000..1189c43
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java
@@ -0,0 +1,711 @@
+package org.robolectric.android.internal;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.shadow.api.Shadow.newInstanceOf;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.SuppressLint;
+import android.app.ActivityThread;
+import android.app.Application;
+import android.app.Instrumentation;
+import android.app.LoadedApk;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.IntentFilter;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageParser;
+import android.content.pm.PackageParser.Package;
+import android.content.res.AssetManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.FontsContract;
+import android.util.DisplayMetrics;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import java.lang.reflect.Method;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.Security;
+import java.util.Locale;
+import javax.annotation.Nonnull;
+import javax.inject.Named;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.conscrypt.OpenSSLProvider;
+import org.robolectric.ApkLoader;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.Bootstrap;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.ConscryptMode;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.internal.ResourcesMode;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.internal.TestEnvironment;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.manifest.BroadcastReceiverData;
+import org.robolectric.manifest.RoboNotFoundException;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.TestEnvironmentLifecyclePlugin;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.res.Fs;
+import org.robolectric.res.PackageResourceTable;
+import org.robolectric.res.ResourcePath;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.ResourceTableFactory;
+import org.robolectric.res.RoutingResourceTable;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ClassNameResolver;
+import org.robolectric.shadows.LegacyManifestParser;
+import org.robolectric.shadows.ShadowActivityThread;
+import org.robolectric.shadows.ShadowActivityThread._ActivityThread_;
+import org.robolectric.shadows.ShadowActivityThread._AppBindData_;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowAssetManager;
+import org.robolectric.shadows.ShadowContextImpl._ContextImpl_;
+import org.robolectric.shadows.ShadowInstrumentation;
+import org.robolectric.shadows.ShadowInstrumentation._Instrumentation_;
+import org.robolectric.shadows.ShadowLegacyLooper;
+import org.robolectric.shadows.ShadowLoadedApk._LoadedApk_;
+import org.robolectric.shadows.ShadowLog;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.ShadowPackageManager;
+import org.robolectric.shadows.ShadowPackageParser;
+import org.robolectric.shadows.ShadowPackageParser._Package_;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.TempDirectory;
+
+@SuppressLint("NewApi")
+public class AndroidTestEnvironment implements TestEnvironment {
+
+  private static final String CONSCRYPT_PROVIDER = "Conscrypt";
+  private final Sdk runtimeSdk;
+  private final Sdk compileSdk;
+
+  private final int apiLevel;
+
+  private boolean loggingInitialized = false;
+  private final Path sdkJarPath;
+  private final ApkLoader apkLoader;
+  private PackageResourceTable systemResourceTable;
+  private final ShadowProvider[] shadowProviders;
+  private final TestEnvironmentLifecyclePlugin[] testEnvironmentLifecyclePlugins;
+  private final Locale initialLocale = Locale.getDefault();
+
+  public AndroidTestEnvironment(
+      @Named("runtimeSdk") Sdk runtimeSdk,
+      @Named("compileSdk") Sdk compileSdk,
+      ResourcesMode resourcesMode,
+      ApkLoader apkLoader,
+      ShadowProvider[] shadowProviders,
+      TestEnvironmentLifecyclePlugin[] lifecyclePlugins) {
+    this.runtimeSdk = runtimeSdk;
+    this.compileSdk = compileSdk;
+
+    apiLevel = runtimeSdk.getApiLevel();
+    this.apkLoader = apkLoader;
+    sdkJarPath = runtimeSdk.getJarPath();
+    this.shadowProviders = shadowProviders;
+    this.testEnvironmentLifecyclePlugins = lifecyclePlugins;
+
+    RuntimeEnvironment.setUseLegacyResources(resourcesMode == ResourcesMode.LEGACY);
+    ReflectionHelpers.setStaticField(RuntimeEnvironment.class, "apiLevel", apiLevel);
+  }
+
+  @Override
+  public void setUpApplicationState(
+      Method method, Configuration configuration, AndroidManifest appManifest) {
+    Config config = configuration.get(Config.class);
+
+    ConfigurationRegistry.instance = new ConfigurationRegistry(configuration.map());
+
+    for (TestEnvironmentLifecyclePlugin e : testEnvironmentLifecyclePlugins) {
+      e.onSetupApplicationState();
+    }
+
+    clearEnvironment();
+    RuntimeEnvironment.setTempDirectory(new TempDirectory(createTestDataDirRootPath(method)));
+    if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) {
+      RuntimeEnvironment.setMasterScheduler(new Scheduler());
+      RuntimeEnvironment.setMainThread(Thread.currentThread());
+      ShadowLegacyLooper.internalInitializeBackgroundThreadScheduler();
+    }
+
+    if (!loggingInitialized) {
+      ShadowLog.setupLogging();
+      loggingInitialized = true;
+    }
+
+    ConscryptMode.Mode conscryptMode = configuration.get(ConscryptMode.Mode.class);
+    Security.removeProvider(CONSCRYPT_PROVIDER);
+    if (conscryptMode != ConscryptMode.Mode.OFF) {
+
+      Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
+      if (Security.getProvider(CONSCRYPT_PROVIDER) == null) {
+        Security.insertProviderAt(new OpenSSLProvider(), 1);
+      }
+    }
+
+    if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+      Security.addProvider(new BouncyCastleProvider());
+    }
+
+    android.content.res.Configuration androidConfiguration =
+        new android.content.res.Configuration();
+    DisplayMetrics displayMetrics = new DisplayMetrics();
+
+    Bootstrap.applyQualifiers(config.qualifiers(), apiLevel, androidConfiguration, displayMetrics);
+
+    if (Boolean.getBoolean("robolectric.nativeruntime.enableGraphics")) {
+      Bitmap.setDefaultDensity(displayMetrics.densityDpi);
+    }
+    Locale locale =
+        apiLevel >= VERSION_CODES.N
+            ? androidConfiguration.getLocales().get(0)
+            : androidConfiguration.locale;
+    Locale.setDefault(locale);
+
+    // Looper needs to be prepared before the activity thread is created
+    if (Looper.myLooper() == null) {
+      Looper.prepareMainLooper();
+    }
+    if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) {
+      ShadowLooper.getShadowMainLooper().resetScheduler();
+    } else {
+      RuntimeEnvironment.setMasterScheduler(new LooperDelegatingScheduler(Looper.getMainLooper()));
+    }
+
+    preloadClasses(apiLevel);
+
+    RuntimeEnvironment.setAndroidFrameworkJarPath(sdkJarPath);
+    Bootstrap.setDisplayConfiguration(androidConfiguration, displayMetrics);
+    RuntimeEnvironment.setActivityThread(ReflectionHelpers.newInstance(ActivityThread.class));
+    ReflectionHelpers.setStaticField(
+        ActivityThread.class, "sMainThreadHandler", new Handler(Looper.myLooper()));
+
+    Instrumentation instrumentation = createInstrumentation();
+    InstrumentationRegistry.registerInstance(instrumentation, new Bundle());
+    Supplier<Application> applicationSupplier =
+        createApplicationSupplier(appManifest, config, androidConfiguration, displayMetrics);
+    RuntimeEnvironment.setApplicationSupplier(applicationSupplier);
+
+    if (configuration.get(LazyLoad.class) == LazyLoad.ON) {
+      RuntimeEnvironment.setConfiguredApplicationClass(
+          getApplicationClass(appManifest, config, new ApplicationInfo()));
+    } else {
+      // force eager load of the application
+      RuntimeEnvironment.getApplication();
+    }
+  }
+
+  // If certain Android classes are required to be loaded in a particular order, do so here.
+  // Android's Zygote has a class preloading mechanism, and there have been obscure crashes caused
+  // by Android bugs requiring a specific initialization order.
+  private void preloadClasses(int apiLevel) {
+    if (apiLevel >= Q) {
+      // Preload URI to avoid a static initializer cycle that can be caused by using Uri.Builder
+      // before Uri.EMPTY.
+      try {
+        Class.forName("android.net.Uri", true, this.getClass().getClassLoader());
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  // TODO Move synchronization logic into its own class for better readability
+  private Supplier<Application> createApplicationSupplier(
+      AndroidManifest appManifest,
+      Config config,
+      android.content.res.Configuration androidConfiguration,
+      DisplayMetrics displayMetrics) {
+    final ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    final _ActivityThread_ _activityThread_ = reflector(_ActivityThread_.class, activityThread);
+    final ShadowActivityThread shadowActivityThread = Shadow.extract(activityThread);
+
+    return Suppliers.memoize(
+        () ->
+            PerfStatsCollector.getInstance()
+                .measure(
+                    "installAndCreateApplication",
+                    () ->
+                        installAndCreateApplication(
+                            appManifest,
+                            config,
+                            androidConfiguration,
+                            displayMetrics,
+                            shadowActivityThread,
+                            _activityThread_,
+                            activityThread.getInstrumentation())));
+  }
+
+  private Application installAndCreateApplication(
+      AndroidManifest appManifest,
+      Config config,
+      android.content.res.Configuration androidConfiguration,
+      DisplayMetrics displayMetrics,
+      ShadowActivityThread shadowActivityThread,
+      _ActivityThread_ activityThreadReflector,
+      Instrumentation androidInstrumentation) {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+
+    Context systemContextImpl = reflector(_ContextImpl_.class).createSystemContext(activityThread);
+    RuntimeEnvironment.systemContext = systemContextImpl;
+
+    Application dummyInitialApplication = new Application();
+    activityThreadReflector.setInitialApplication(dummyInitialApplication);
+    ShadowApplication shadowInitialApplication = Shadow.extract(dummyInitialApplication);
+    shadowInitialApplication.callAttach(systemContextImpl);
+
+    Package parsedPackage = loadAppPackage(config, appManifest);
+
+    ApplicationInfo applicationInfo = parsedPackage.applicationInfo;
+
+    ComponentName actualComponentName =
+        new ComponentName(
+            applicationInfo.packageName, androidInstrumentation.getClass().getSimpleName());
+    ReflectionHelpers.setField(androidInstrumentation, "mComponent", actualComponentName);
+
+    // unclear why, but prior to P the processName wasn't set
+    if (apiLevel < P && applicationInfo.processName == null) {
+      applicationInfo.processName = parsedPackage.packageName;
+    }
+
+    setUpPackageStorage(applicationInfo, parsedPackage);
+
+    // Bit of a hack... Context.createPackageContext() is called before the application is created.
+    // It calls through
+    // to ActivityThread for the package which in turn calls the PackageManagerService directly.
+    // This works for now
+    // but it might be nicer to have ShadowPackageManager implementation move into the service as
+    // there is also lots of
+    // code in there that can be reusable, e.g: the XxxxIntentResolver code.
+    ShadowActivityThread.setApplicationInfo(applicationInfo);
+
+    shadowActivityThread.setCompatConfiguration(androidConfiguration);
+
+    Bootstrap.setUpDisplay();
+    activityThread.applyConfigurationToResources(androidConfiguration);
+
+    Application application = createApplication(appManifest, config, applicationInfo);
+    RuntimeEnvironment.setConfiguredApplicationClass(application.getClass());
+
+    RuntimeEnvironment.application = application;
+
+    if (application != null) {
+      final Class<?> appBindDataClass;
+      try {
+        appBindDataClass = Class.forName("android.app.ActivityThread$AppBindData");
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+      final Object appBindData = ReflectionHelpers.newInstance(appBindDataClass);
+      final _AppBindData_ _appBindData_ = reflector(_AppBindData_.class, appBindData);
+      _appBindData_.setProcessName(parsedPackage.packageName);
+      _appBindData_.setAppInfo(applicationInfo);
+      activityThreadReflector.setBoundApplication(appBindData);
+
+      final LoadedApk loadedApk =
+          activityThread.getPackageInfo(applicationInfo, null, Context.CONTEXT_INCLUDE_CODE);
+      final _LoadedApk_ _loadedApk_ = reflector(_LoadedApk_.class, loadedApk);
+
+      Context contextImpl;
+      if (apiLevel >= VERSION_CODES.LOLLIPOP) {
+        contextImpl = reflector(_ContextImpl_.class).createAppContext(activityThread, loadedApk);
+      } else {
+        try {
+          contextImpl =
+              systemContextImpl.createPackageContext(
+                  applicationInfo.packageName, Context.CONTEXT_INCLUDE_CODE);
+        } catch (PackageManager.NameNotFoundException e) {
+          throw new RuntimeException(e);
+        }
+      }
+      ShadowPackageManager shadowPackageManager = Shadow.extract(contextImpl.getPackageManager());
+      shadowPackageManager.addPackageInternal(parsedPackage);
+      activityThreadReflector.setInitialApplication(application);
+      ShadowApplication shadowApplication = Shadow.extract(application);
+      shadowApplication.callAttach(contextImpl);
+      reflector(_ContextImpl_.class, contextImpl).setOuterContext(application);
+      if (apiLevel >= VERSION_CODES.O) {
+        reflector(_ContextImpl_.class, contextImpl)
+            .setClassLoader(this.getClass().getClassLoader());
+      }
+
+      Resources appResources = application.getResources();
+      _loadedApk_.setResources(appResources);
+      _loadedApk_.setApplication(application);
+      if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.O) {
+        // Preload fonts resources
+        FontsContract.setApplicationContextForResources(application);
+      }
+      registerBroadcastReceivers(application, appManifest);
+
+      appResources.updateConfiguration(androidConfiguration, displayMetrics);
+      // propagate any updates to configuration via RuntimeEnvironment.setQualifiers
+      Bootstrap.updateConfiguration(appResources);
+
+      if (ShadowAssetManager.useLegacy()) {
+        populateAssetPaths(appResources.getAssets(), appManifest);
+      }
+
+      PerfStatsCollector.getInstance()
+          .measure(
+              "application onCreate()",
+              () -> androidInstrumentation.callApplicationOnCreate(application));
+    }
+
+    return application;
+  }
+
+  private Package loadAppPackage(Config config, AndroidManifest appManifest) {
+    return PerfStatsCollector.getInstance()
+        .measure("parse package", () -> loadAppPackage_measured(config, appManifest));
+  }
+
+  private Package loadAppPackage_measured(Config config, AndroidManifest appManifest) {
+
+    Package parsedPackage;
+    if (RuntimeEnvironment.useLegacyResources()) {
+      injectResourceStuffForLegacy(appManifest);
+
+      if (appManifest.getAndroidManifestFile() != null
+          && Files.exists(appManifest.getAndroidManifestFile())) {
+        parsedPackage = LegacyManifestParser.createPackage(appManifest);
+      } else {
+        parsedPackage = new Package("org.robolectric.default");
+        parsedPackage.applicationInfo.targetSdkVersion = appManifest.getTargetSdkVersion();
+      }
+      // Support overriding the package name specified in the Manifest.
+      if (!Config.DEFAULT_PACKAGE_NAME.equals(config.packageName())) {
+        parsedPackage.packageName = config.packageName();
+        parsedPackage.applicationInfo.packageName = config.packageName();
+      } else {
+        parsedPackage.packageName = appManifest.getPackageName();
+        parsedPackage.applicationInfo.packageName = appManifest.getPackageName();
+      }
+    } else {
+      RuntimeEnvironment.compileTimeSystemResourcesFile = compileSdk.getJarPath();
+
+      Path packageFile = appManifest.getApkFile();
+      parsedPackage = ShadowPackageParser.callParsePackage(packageFile);
+    }
+    return parsedPackage;
+  }
+
+  private synchronized PackageResourceTable getSystemResourceTable() {
+    if (systemResourceTable == null) {
+      ResourcePath resourcePath = createRuntimeSdkResourcePath();
+      systemResourceTable = new ResourceTableFactory().newFrameworkResourceTable(resourcePath);
+    }
+    return systemResourceTable;
+  }
+
+  @Nonnull
+  private ResourcePath createRuntimeSdkResourcePath() {
+    try {
+      FileSystem zipFs = Fs.forJar(runtimeSdk.getJarPath());
+
+      @SuppressLint("PrivateApi")
+      Class<?> androidInternalRClass = Class.forName("com.android.internal.R");
+
+      // TODO: verify these can be loaded via raw-res path
+      return new ResourcePath(
+          android.R.class,
+          zipFs.getPath("raw-res/res"),
+          zipFs.getPath("raw-res/assets"),
+          androidInternalRClass);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private void injectResourceStuffForLegacy(AndroidManifest appManifest) {
+    PackageResourceTable systemResourceTable = getSystemResourceTable();
+    PackageResourceTable appResourceTable = apkLoader.getAppResourceTable(appManifest);
+    RoutingResourceTable combinedAppResourceTable =
+        new RoutingResourceTable(appResourceTable, systemResourceTable);
+
+    PackageResourceTable compileTimeSdkResourceTable = apkLoader.getCompileTimeSdkResourceTable();
+    ResourceTable combinedCompileTimeResourceTable =
+        new RoutingResourceTable(appResourceTable, compileTimeSdkResourceTable);
+
+    RuntimeEnvironment.setCompileTimeResourceTable(combinedCompileTimeResourceTable);
+    RuntimeEnvironment.setAppResourceTable(combinedAppResourceTable);
+    RuntimeEnvironment.setSystemResourceTable(new RoutingResourceTable(systemResourceTable));
+
+    try {
+      appManifest.initMetaData(combinedAppResourceTable);
+    } catch (RoboNotFoundException e1) {
+      throw new Resources.NotFoundException(e1.getMessage());
+    }
+  }
+
+  private void populateAssetPaths(AssetManager assetManager, AndroidManifest appManifest) {
+    for (AndroidManifest manifest : appManifest.getAllManifests()) {
+      if (manifest.getAssetsDirectory() != null) {
+        assetManager.addAssetPath(Fs.externalize(manifest.getAssetsDirectory()));
+      }
+    }
+  }
+
+  @VisibleForTesting
+  static Application createApplication(
+      AndroidManifest appManifest, Config config, ApplicationInfo applicationInfo) {
+    return ReflectionHelpers.callConstructor(
+        getApplicationClass(appManifest, config, applicationInfo));
+  }
+
+  private static Class<? extends Application> getApplicationClass(
+      AndroidManifest appManifest, Config config, ApplicationInfo applicationInfo) {
+    Class<? extends Application> applicationClass = null;
+    if (config != null && !Config.Builder.isDefaultApplication(config.application())) {
+      if (config.application().getCanonicalName() != null) {
+        try {
+          applicationClass = ClassNameResolver.resolve(null, config.application().getName());
+        } catch (ClassNotFoundException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    } else if (appManifest != null && appManifest.getApplicationName() != null) {
+      try {
+        applicationClass =
+            ClassNameResolver.resolve(
+                appManifest.getPackageName(),
+                getTestApplicationName(appManifest.getApplicationName()));
+      } catch (ClassNotFoundException e) {
+        // no problem
+      }
+
+      if (applicationClass == null) {
+        try {
+          applicationClass =
+              ClassNameResolver.resolve(
+                  appManifest.getPackageName(), appManifest.getApplicationName());
+        } catch (ClassNotFoundException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    } else {
+      if (applicationInfo.className != null) {
+        try {
+          applicationClass =
+              (Class<? extends Application>)
+                  Class.forName(getTestApplicationName(applicationInfo.className));
+        } catch (ClassNotFoundException e) {
+          // no problem
+        }
+
+        if (applicationClass == null) {
+          try {
+            applicationClass =
+                (Class<? extends Application>) Class.forName(applicationInfo.className);
+          } catch (ClassNotFoundException e) {
+            throw new RuntimeException(e);
+          }
+        }
+      } else {
+        applicationClass = Application.class;
+      }
+    }
+
+    return applicationClass;
+  }
+
+  @VisibleForTesting
+  static String getTestApplicationName(String applicationName) {
+    int lastDot = applicationName.lastIndexOf('.');
+    if (lastDot > -1) {
+      return applicationName.substring(0, lastDot)
+          + ".Test"
+          + applicationName.substring(lastDot + 1);
+    } else {
+      return "Test" + applicationName;
+    }
+  }
+
+  private Instrumentation createInstrumentation() {
+    final ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    final _ActivityThread_ activityThreadReflector =
+        reflector(_ActivityThread_.class, activityThread);
+
+    Instrumentation androidInstrumentation = new RoboMonitoringInstrumentation();
+    activityThreadReflector.setInstrumentation(androidInstrumentation);
+
+    Application dummyInitialApplication = new Application();
+    final ComponentName dummyInitialComponent =
+        new ComponentName("", androidInstrumentation.getClass().getSimpleName());
+    // TODO Move the API check into a helper method inside ShadowInstrumentation
+    if (RuntimeEnvironment.getApiLevel() <= VERSION_CODES.JELLY_BEAN_MR1) {
+      reflector(_Instrumentation_.class, androidInstrumentation)
+          .init(
+              activityThread,
+              dummyInitialApplication,
+              dummyInitialApplication,
+              dummyInitialComponent,
+              null);
+    } else {
+      reflector(_Instrumentation_.class, androidInstrumentation)
+          .init(
+              activityThread,
+              dummyInitialApplication,
+              dummyInitialApplication,
+              dummyInitialComponent,
+              null,
+              null);
+    }
+
+    androidInstrumentation.onCreate(new Bundle());
+    return androidInstrumentation;
+  }
+
+  /** Create a file system safe directory path name for the current test. */
+  @SuppressWarnings("DoNotCall")
+  private String createTestDataDirRootPath(Method method) {
+    return method.getClass().getSimpleName()
+        + "_"
+        + method.getName().replaceAll("[^a-zA-Z0-9.-]", "_");
+  }
+
+  @Override
+  public void tearDownApplication() {
+    if (RuntimeEnvironment.application != null) {
+      RuntimeEnvironment.application.onTerminate();
+      ShadowInstrumentation.getInstrumentation().finish(1, new Bundle());
+    }
+  }
+
+  /**
+   * Clear the global variables set and used by AndroidTestEnvironment TODO Move synchronization
+   * logic into its own class for better readability
+   */
+  private void clearEnvironment() {
+    // Need to clear both the application supplier and the instrumentation here *before* clearing
+    // RuntimeEnvironment.application. That way if RuntimeEnvironment.getApplication() or
+    // ApplicationProvider.getApplicationContext() get called in between here and the end of this
+    // method, we don't accidentally trigger application loading with stale references
+    RuntimeEnvironment.setApplicationSupplier(null);
+    InstrumentationRegistry.registerInstance(null, new Bundle());
+    RuntimeEnvironment.setActivityThread(null);
+    RuntimeEnvironment.application = null;
+    RuntimeEnvironment.systemContext = null;
+    Bootstrap.resetDisplayConfiguration();
+  }
+
+  @Override
+  public void checkStateAfterTestFailure(Throwable t) throws Throwable {
+    if (hasUnexecutedRunnables()) {
+      t.addSuppressed(new UnExecutedRunnablesException());
+    }
+    throw t;
+  }
+
+  private static final class UnExecutedRunnablesException extends Exception {
+
+    UnExecutedRunnablesException() {
+      super(
+          "Main looper has queued unexecuted runnables. "
+              + "This might be the cause of the test failure. "
+              + "You might need a shadowOf(Looper.getMainLooper()).idle() call.");
+    }
+
+    @Override
+    public synchronized Throwable fillInStackTrace() {
+      setStackTrace(new StackTraceElement[0]);
+      return this; // no stack trace, wouldn't be useful anyway
+    }
+  }
+
+  private boolean hasUnexecutedRunnables() {
+    ShadowLooper shadowLooper = Shadow.extract(Looper.getMainLooper());
+    return !shadowLooper.isIdle();
+  }
+
+  @Override
+  public void resetState() {
+    Locale.setDefault(initialLocale);
+    for (ShadowProvider provider : shadowProviders) {
+      provider.reset();
+    }
+  }
+
+  // TODO(christianw): reconcile with ShadowPackageManager.setUpPackageStorage
+  private void setUpPackageStorage(
+      ApplicationInfo applicationInfo, PackageParser.Package parsedPackage) {
+    // TempDirectory tempDirectory = RuntimeEnvironment.getTempDirectory();
+    // packageInfo.setVolumeUuid(tempDirectory.createIfNotExists(packageInfo.packageName +
+    // "-dataDir").toAbsolutePath().toString());
+
+    if (RuntimeEnvironment.useLegacyResources()) {
+      applicationInfo.sourceDir = createTempDir(applicationInfo.packageName + "-sourceDir");
+      applicationInfo.publicSourceDir =
+          createTempDir(applicationInfo.packageName + "-publicSourceDir");
+    } else {
+      if (apiLevel <= VERSION_CODES.KITKAT) {
+        String sourcePath = reflector(_Package_.class, parsedPackage).getPath();
+        if (sourcePath == null) {
+          sourcePath = createTempDir("sourceDir");
+        }
+        applicationInfo.publicSourceDir = sourcePath;
+        applicationInfo.sourceDir = sourcePath;
+      } else {
+        applicationInfo.publicSourceDir = parsedPackage.codePath;
+        applicationInfo.sourceDir = parsedPackage.codePath;
+      }
+    }
+
+    applicationInfo.dataDir = createTempDir(applicationInfo.packageName + "-dataDir");
+
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N) {
+      applicationInfo.credentialProtectedDataDir = createTempDir("userDataDir");
+      applicationInfo.deviceProtectedDataDir = createTempDir("deviceDataDir");
+    }
+  }
+
+  private String createTempDir(String name) {
+    return RuntimeEnvironment.getTempDirectory()
+        .createIfNotExists(name)
+        .toAbsolutePath()
+        .toString();
+  }
+
+  // TODO move/replace this with packageManager
+  @VisibleForTesting
+  static void registerBroadcastReceivers(Application application, AndroidManifest androidManifest) {
+    for (BroadcastReceiverData receiver : androidManifest.getBroadcastReceivers()) {
+      IntentFilter filter = new IntentFilter();
+      for (String action : receiver.getActions()) {
+        filter.addAction(action);
+      }
+      String receiverClassName = replaceLastDotWith$IfInnerStaticClass(receiver.getName());
+      application.registerReceiver((BroadcastReceiver) newInstanceOf(receiverClassName), filter);
+    }
+  }
+
+  private static String replaceLastDotWith$IfInnerStaticClass(String receiverClassName) {
+    String[] splits = receiverClassName.split("\\.", 0);
+    String staticInnerClassRegex = "[A-Z][a-zA-Z]*";
+    if (splits.length > 1
+        && splits[splits.length - 1].matches(staticInnerClassRegex)
+        && splits[splits.length - 2].matches(staticInnerClassRegex)) {
+      int lastDotIndex = receiverClassName.lastIndexOf(".");
+      StringBuilder buffer = new StringBuilder(receiverClassName);
+      buffer.setCharAt(lastDotIndex, '$');
+      return buffer.toString();
+    }
+    return receiverClassName;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java b/robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java
new file mode 100644
index 0000000..29de83d
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java
@@ -0,0 +1,24 @@
+package org.robolectric.android.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.annotations.Beta;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Timeout exception thrown when idling resources are not idle for longer than the configured
+ * timeout.
+ *
+ * <p>See {@link androidx.test.espresso.IdlingResourceTimeoutException}.
+ *
+ * <p>Note: This API may be removed in the future in favor of using espresso's exception directly.
+ */
+@Beta
+public final class IdlingResourceTimeoutException extends RuntimeException {
+  public IdlingResourceTimeoutException(List<String> resourceNames) {
+    super(
+        String.format(
+            Locale.ROOT, "Wait for %s to become idle timed out", checkNotNull(resourceNames)));
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LocalActivityInvoker.java b/robolectric/src/main/java/org/robolectric/android/internal/LocalActivityInvoker.java
new file mode 100644
index 0000000..31a53df
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalActivityInvoker.java
@@ -0,0 +1,177 @@
+package org.robolectric.android.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import android.app.Activity;
+import android.app.Instrumentation.ActivityResult;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import androidx.test.internal.platform.app.ActivityInvoker;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.Stage;
+import javax.annotation.Nullable;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowActivity;
+
+/**
+ * An {@link ActivityInvoker} that drives {@link Activity} lifecycles manually.
+ *
+ * <p>All the methods in this class are blocking API.
+ */
+@SuppressWarnings("RestrictTo")
+public class LocalActivityInvoker implements ActivityInvoker {
+
+  @Nullable private ActivityController<? extends Activity> controller;
+
+  private boolean isActivityLaunchedForResult = false;
+
+  @Override
+  public void startActivity(Intent intent, @Nullable Bundle activityOptions) {
+    controller = getInstrumentation().startActivitySyncInternal(intent, activityOptions);
+  }
+
+  @Override
+  public void startActivity(Intent intent) {
+    startActivity(intent, /* activityOptions= */ null);
+  }
+
+  // TODO(paigemca): Omitting @Override until androidx.test.monitor version can be upgraded
+  public void startActivityForResult(Intent intent, @Nullable Bundle activityOptions) {
+    isActivityLaunchedForResult = true;
+    controller = getInstrumentation().startActivitySyncInternal(intent, activityOptions);
+  }
+
+  // TODO(paigemca): Omitting @Override until androidx.test.monitor version can be upgraded
+  public void startActivityForResult(Intent intent) {
+    isActivityLaunchedForResult = true;
+    startActivityForResult(intent, /* activityOptions= */ null);
+  }
+
+  @Override
+  public ActivityResult getActivityResult() {
+    if (!isActivityLaunchedForResult) {
+      throw new IllegalStateException(
+          "You must start Activity first. Make sure you are using launchActivityForResult() to"
+              + " launch an Activity.");
+    }
+    checkNotNull(controller);
+    checkState(controller.get().isFinishing(), "You must finish your Activity first");
+    ShadowActivity shadowActivity = Shadow.extract(controller.get());
+    return new ActivityResult(shadowActivity.getResultCode(), shadowActivity.getResultIntent());
+  }
+
+  @Override
+  public void resumeActivity(Activity activity) {
+    checkNotNull(controller);
+    checkState(controller.get() == activity);
+    Stage stage = ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(activity);
+    switch (stage) {
+      case RESUMED:
+        return;
+      case PAUSED:
+        controller.resume().topActivityResumed(true);
+        return;
+      case STOPPED:
+        controller.restart().resume().topActivityResumed(true);
+        return;
+      default:
+        throw new IllegalStateException(
+            String.format(
+                "Activity's stage must be RESUMED, PAUSED or STOPPED but was %s.", stage));
+    }
+  }
+
+  @Override
+  public void pauseActivity(Activity activity) {
+    checkNotNull(controller);
+    checkState(controller.get() == activity);
+    Stage stage = ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(activity);
+    switch (stage) {
+      case RESUMED:
+        controller.topActivityResumed(false).pause();
+        return;
+      case PAUSED:
+        return;
+      default:
+        throw new IllegalStateException(
+            String.format("Activity's stage must be RESUMED or PAUSED but was %s.", stage));
+    }
+  }
+
+  @Override
+  public void stopActivity(Activity activity) {
+    checkNotNull(controller);
+    checkState(controller.get() == activity);
+    Stage stage = ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(activity);
+    switch (stage) {
+      case RESUMED:
+        controller.topActivityResumed(false).pause().stop();
+        return;
+      case PAUSED:
+        controller.stop();
+        return;
+      case STOPPED:
+        return;
+      default:
+        throw new IllegalStateException(
+            String.format(
+                "Activity's stage must be RESUMED, PAUSED or STOPPED but was %s.", stage));
+    }
+  }
+
+  @Override
+  public void recreateActivity(Activity activity) {
+    checkNotNull(controller);
+    checkState(controller.get() == activity);
+    controller.recreate();
+  }
+
+  @Override
+  public void finishActivity(Activity activity) {
+    checkNotNull(controller);
+    checkState(controller.get() == activity);
+    activity.finish();
+    Stage stage = ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(activity);
+    switch (stage) {
+      case RESUMED:
+        controller.topActivityResumed(false).pause().stop().destroy();
+        return;
+      case PAUSED:
+        controller.stop().destroy();
+        return;
+      case STOPPED:
+        controller.destroy();
+        return;
+      default:
+        throw new IllegalStateException(
+            String.format(
+                "Activity's stage must be RESUMED, PAUSED or STOPPED but was %s.", stage));
+    }
+  }
+
+  // This implementation makes sure, that the activity you are trying to launch exists
+  @Override
+  public Intent getIntentForActivity(Class<? extends Activity> activityClass) {
+    PackageManager packageManager =
+        InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageManager();
+    ComponentName componentName =
+        new ComponentName(
+            InstrumentationRegistry.getInstrumentation().getTargetContext(), activityClass);
+    Intent intent = Intent.makeMainActivity(componentName);
+    if (packageManager.resolveActivity(intent, 0) != null) {
+      return intent;
+    }
+    return Intent.makeMainActivity(
+        new ComponentName(
+            InstrumentationRegistry.getInstrumentation().getContext(), activityClass));
+  }
+
+  private static RoboMonitoringInstrumentation getInstrumentation() {
+    return (RoboMonitoringInstrumentation) InstrumentationRegistry.getInstrumentation();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LocalControlledLooper.java b/robolectric/src/main/java/org/robolectric/android/internal/LocalControlledLooper.java
new file mode 100644
index 0000000..195aa13
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalControlledLooper.java
@@ -0,0 +1,29 @@
+package org.robolectric.android.internal;
+
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.view.View;
+import android.view.ViewRootImpl;
+import androidx.test.internal.platform.os.ControlledLooper;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowViewRootImpl;
+import org.robolectric.util.ReflectionHelpers;
+
+/** A Robolectric implementation for {@link ControlledLooper}. */
+@SuppressWarnings("RestrictTo")
+public class LocalControlledLooper implements ControlledLooper {
+
+  @Override
+  public void drainMainThreadUntilIdle() {
+    shadowMainLooper().idle();
+  }
+
+  @Override
+  public void simulateWindowFocus(View decorView) {
+    ViewRootImpl viewRoot = ReflectionHelpers.callInstanceMethod(decorView, "getViewRootImpl");
+    if (viewRoot != null) {
+      ShadowViewRootImpl shadowViewRoot = Shadow.extract(viewRoot);
+      shadowViewRoot.callWindowFocusChanged(true);
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LocalPermissionGranter.java b/robolectric/src/main/java/org/robolectric/android/internal/LocalPermissionGranter.java
new file mode 100644
index 0000000..ecf021e
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalPermissionGranter.java
@@ -0,0 +1,30 @@
+package org.robolectric.android.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.app.Application;
+import androidx.test.internal.platform.content.PermissionGranter;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowApplication;
+
+/** A {@link PermissionGranter} that runs on a local JVM with Robolectric. */
+@SuppressWarnings("RestrictTo")
+public class LocalPermissionGranter implements PermissionGranter {
+
+  private String[] permissions;
+
+  @Override
+  public void addPermissions(String... permissions) {
+    this.permissions = permissions;
+  }
+
+  @Override
+  public void requestPermissions() {
+    checkNotNull(permissions);
+    Application application =
+        (Application) InstrumentationRegistry.getInstrumentation().getTargetContext();
+    ShadowApplication shadowApplication = Shadow.extract(application);
+    shadowApplication.grantPermissions(permissions);
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java
new file mode 100644
index 0000000..08fcffc
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java
@@ -0,0 +1,355 @@
+package org.robolectric.android.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import androidx.test.espresso.IdlingRegistry;
+import androidx.test.espresso.IdlingResource;
+import androidx.test.platform.ui.InjectEventSecurityException;
+import androidx.test.platform.ui.UiController;
+import com.google.common.annotations.Beta;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.ShadowPausedLooper;
+import org.robolectric.shadows.ShadowUiAutomation;
+
+/** A {@link UiController} that runs on a local JVM with Robolectric. */
+public class LocalUiController implements UiController {
+
+  private static final String TAG = "LocalUiController";
+
+  private static long idlingResourceErrorTimeoutMs = SECONDS.toMillis(26);
+  private final HashSet<IdlingResourceProxyImpl> syncedIdlingResources = new HashSet<>();
+  private final ExecutorService looperIdlingExecutor = Executors.newCachedThreadPool();
+
+  /**
+   * Sets the error timeout for idling resources.
+   *
+   * <p>See {@link androidx.test.espresso.IdlingPolicies#setIdlingResourceTimeout(long, TimeUnit)}.
+   *
+   * <p>Note: This API may be removed in the future in favor of using IdlingPolicies directly.
+   */
+  @Beta
+  public static void setIdlingResourceTimeout(long timeout, TimeUnit unit) {
+    idlingResourceErrorTimeoutMs = unit.toMillis(timeout);
+  }
+
+  @Override
+  public boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException {
+    loopMainThreadUntilIdle();
+    ShadowUiAutomation.injectInputEvent(event);
+    loopMainThreadUntilIdle();
+    return true;
+  }
+
+  @Override
+  public boolean injectKeyEvent(KeyEvent event) throws InjectEventSecurityException {
+    loopMainThreadUntilIdle();
+    ShadowUiAutomation.injectInputEvent(event);
+    loopMainThreadUntilIdle();
+    return true;
+  }
+
+  // TODO: implementation copied from espresso's UIControllerImpl. Refactor code into common
+  // location
+  @Override
+  public boolean injectString(String str) throws InjectEventSecurityException {
+    checkNotNull(str);
+    checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!");
+
+    // No-op if string is empty.
+    if (str.isEmpty()) {
+      Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed).");
+      return true;
+    }
+
+    boolean eventInjected = false;
+    KeyCharacterMap keyCharacterMap = getKeyCharacterMap();
+
+    // TODO: Investigate why not use (as suggested in javadoc of keyCharacterMap.getEvents):
+    // http://developer.android.com/reference/android/view/KeyEvent.html#KeyEvent(long,
+    // java.lang.String, int, int)
+    KeyEvent[] events = keyCharacterMap.getEvents(str.toCharArray());
+    if (events == null) {
+      throw new RuntimeException(
+          String.format(
+              "Failed to get key events for string %s (i.e. current IME does not understand how to"
+                  + " translate the string into key events). As a workaround, you can use"
+                  + " replaceText action to set the text directly in the EditText field.",
+              str));
+    }
+
+    Log.d(TAG, String.format("Injecting string: \"%s\"", str));
+
+    for (KeyEvent event : events) {
+      checkNotNull(
+          event,
+          String.format(
+              "Failed to get event for character (%c) with key code (%s)",
+              event.getKeyCode(), event.getUnicodeChar()));
+
+      eventInjected = false;
+      for (int attempts = 0; !eventInjected && attempts < 4; attempts++) {
+        // We have to change the time of an event before injecting it because
+        // all KeyEvents returned by KeyCharacterMap.getEvents() have the same
+        // time stamp and the system rejects too old events. Hence, it is
+        // possible for an event to become stale before it is injected if it
+        // takes too long to inject the preceding ones.
+        event = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0);
+        eventInjected = injectKeyEvent(event);
+      }
+
+      if (!eventInjected) {
+        Log.e(
+            TAG,
+            String.format(
+                "Failed to inject event for character (%c) with key code (%s)",
+                event.getUnicodeChar(), event.getKeyCode()));
+        break;
+      }
+    }
+
+    return eventInjected;
+  }
+
+  @SuppressLint("InlinedApi")
+  @VisibleForTesting
+  @SuppressWarnings("deprecation")
+  static KeyCharacterMap getKeyCharacterMap() {
+    KeyCharacterMap keyCharacterMap = null;
+
+    // KeyCharacterMap.VIRTUAL_KEYBOARD is present from API11.
+    // For earlier APIs we use KeyCharacterMap.BUILT_IN_KEYBOARD
+    if (Build.VERSION.SDK_INT < 11) {
+      keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.BUILT_IN_KEYBOARD);
+    } else {
+      keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+    }
+    return keyCharacterMap;
+  }
+
+  @Override
+  public void loopMainThreadUntilIdle() {
+    if (!ShadowLooper.looperMode().equals(LooperMode.Mode.PAUSED)) {
+      shadowMainLooper().idle();
+    } else {
+      ImmutableSet<IdlingResourceProxy> idlingResources = syncIdlingResources();
+      if (idlingResources.isEmpty()) {
+        shadowMainLooper().idle();
+      } else {
+        loopMainThreadUntilIdlingResourcesIdle(idlingResources);
+      }
+    }
+  }
+
+  private void loopMainThreadUntilIdlingResourcesIdle(
+      ImmutableSet<IdlingResourceProxy> idlingResources) {
+    Looper mainLooper = Looper.myLooper();
+    ShadowPausedLooper shadowMainLooper = Shadow.extract(mainLooper);
+    Handler handler = new Handler(mainLooper);
+    Set<IdlingResourceProxy> activeResources = new HashSet<>();
+    long startTimeNanos = System.nanoTime();
+
+    shadowMainLooper.idle();
+    while (true) {
+      // Gather the list of resources that are not idling.
+      for (IdlingResourceProxy resource : idlingResources) {
+        // Add the resource as active and check if it's idle, if it is already is will be removed
+        // synchronously. The idle callback is synchronized in the resource which avoids a race
+        // between registering the idle callback and checking the idle state.
+        activeResources.add(resource);
+        resource.notifyOnIdle(
+            () -> {
+              if (Looper.myLooper() == mainLooper) {
+                activeResources.remove(resource);
+              } else {
+                // Post to restart the main thread.
+                handler.post(() -> activeResources.remove(resource));
+              }
+            });
+      }
+      // If all are idle then just return, we're done.
+      if (activeResources.isEmpty()) {
+        break;
+      }
+      // While the resources that weren't idle haven't transitioned to idle continue to loop the
+      // main looper waiting for any new messages. Once all resources have transitioned to idle loop
+      // around again to make sure all resources are idle at the same time.
+      while (!activeResources.isEmpty()) {
+        long elapsedTimeMs = NANOSECONDS.toMillis(System.nanoTime() - startTimeNanos);
+        if (elapsedTimeMs >= idlingResourceErrorTimeoutMs) {
+          throw new IdlingResourceTimeoutException(idlingResourceNames(activeResources));
+        }
+        // Poll the queue and suspend the thread until we get new messages or the idle transition.
+        shadowMainLooper.poll(idlingResourceErrorTimeoutMs - elapsedTimeMs);
+        shadowMainLooper.idle();
+      }
+    }
+  }
+
+  private ImmutableSet<IdlingResourceProxy> syncIdlingResources() {
+    // Collect unique registered idling resources.
+    HashMap<String, IdlingResource> registeredResourceByName = new HashMap<>();
+    for (IdlingResource resource : IdlingRegistry.getInstance().getResources()) {
+      String name = resource.getName();
+      if (registeredResourceByName.containsKey(name)) {
+        logDuplicate(name, registeredResourceByName.get(name), resource);
+      } else {
+        registeredResourceByName.put(name, resource);
+      }
+    }
+    Iterator<IdlingResourceProxyImpl> iterator = syncedIdlingResources.iterator();
+    while (iterator.hasNext()) {
+      IdlingResourceProxyImpl proxy = iterator.next();
+      if (registeredResourceByName.get(proxy.name) == proxy.resource) {
+        // Already registered, don't need to add.
+        registeredResourceByName.remove(proxy.name);
+      } else {
+        // Previously registered, but no longer registered, remove.
+        iterator.remove();
+      }
+    }
+    // Add new idling resources that weren't previously registered.
+    for (Map.Entry<String, IdlingResource> entry : registeredResourceByName.entrySet()) {
+      syncedIdlingResources.add(new IdlingResourceProxyImpl(entry.getKey(), entry.getValue()));
+    }
+
+    return ImmutableSet.<IdlingResourceProxy>builder()
+        .addAll(syncedIdlingResources)
+        .addAll(
+            IdlingRegistry.getInstance().getLoopers().stream()
+                .map(LooperIdlingResource::new)
+                .iterator())
+        .build();
+  }
+
+  private static void logDuplicate(String name, IdlingResource a, IdlingResource b) {
+    Log.e(
+        TAG,
+        String.format(
+            "Attempted to register resource with same names:"
+                + " %s. R1: %s R2: %s.\nDuplicate resource registration will be ignored.",
+            name, a, b));
+  }
+
+  private static List<String> idlingResourceNames(Set<IdlingResourceProxy> idlingResources) {
+    return idlingResources.stream().map(IdlingResourceProxy::getName).collect(toList());
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public void loopMainThreadForAtLeast(long millisDelay) {
+    shadowMainLooper().idleFor(Duration.ofMillis(millisDelay));
+  }
+
+  private interface IdlingResourceProxy {
+    String getName();
+
+    void notifyOnIdle(Runnable idleCallback);
+  }
+
+  private static final class IdlingResourceProxyImpl implements IdlingResourceProxy {
+    private final String name;
+    private final IdlingResource resource;
+
+    private Runnable idleCallback;
+
+    IdlingResourceProxyImpl(String name, IdlingResource resource) {
+      this.name = name;
+      this.resource = resource;
+      resource.registerIdleTransitionCallback(this::onIdle);
+    }
+
+    @Override
+    public String getName() {
+      return this.name;
+    }
+
+    @Override
+    public synchronized void notifyOnIdle(Runnable idleCallback) {
+      if (resource.isIdleNow()) {
+        this.idleCallback = null;
+        idleCallback.run();
+      } else {
+        this.idleCallback = idleCallback;
+      }
+    }
+
+    private synchronized void onIdle() {
+      if (idleCallback != null) {
+        idleCallback.run();
+        idleCallback = null;
+      }
+    }
+  }
+
+  private final class LooperIdlingResource implements IdlingResourceProxy {
+    private final Looper looper;
+    private final ShadowLooper shadowLooper;
+    private Runnable idleCallback;
+
+    LooperIdlingResource(Looper looper) {
+      this.looper = looper;
+      this.shadowLooper = shadowOf(looper);
+    }
+
+    @Override
+    public String getName() {
+      return looper.toString();
+    }
+
+    @Override
+    public synchronized void notifyOnIdle(Runnable idleCallback) {
+      if (shadowLooper.isIdle()) {
+        this.idleCallback = null;
+        idleCallback.run();
+      } else {
+        this.idleCallback = idleCallback;
+        // Note idle() doesn't throw an exception if called from another thread, the looper would
+        // die with an unhandled exception.
+        // TODO(paulsowden): It's not technically necessary to idle the looper from another thread,
+        //  it can be idled from its own thread, however we'll need API access to do this and
+        //  observe the idle state--the idle() api blocks the calling thread by default. Perhaps a
+        //  ListenableFuture idleAsync() variant?
+        looperIdlingExecutor.execute(this::idleLooper);
+      }
+    }
+
+    private void idleLooper() {
+      shadowLooper.idle();
+      synchronized (this) {
+        if (idleCallback != null) {
+          idleCallback.run();
+          idleCallback = null;
+        }
+      }
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LooperDelegatingScheduler.java b/robolectric/src/main/java/org/robolectric/android/internal/LooperDelegatingScheduler.java
new file mode 100644
index 0000000..1fdf728
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/LooperDelegatingScheduler.java
@@ -0,0 +1,168 @@
+package org.robolectric.android.internal;
+
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.util.Scheduler.IdleState.PAUSED;
+
+import android.os.Looper;
+import android.os.SystemClock;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowPausedMessageQueue;
+import org.robolectric.util.Scheduler;
+
+/**
+ * A foreground Scheduler implementation used for {@link LooperMode.Mode#PAUSED}.
+ *
+ * <p>All API calls will delegate to ShadowLooper.
+ */
+@SuppressWarnings("UnsynchronizedOverridesSynchronized")
+public class LooperDelegatingScheduler extends Scheduler {
+
+  private final Looper looper;
+
+  public LooperDelegatingScheduler(Looper looper) {
+    this.looper = looper;
+  }
+
+  @Override
+  public IdleState getIdleState() {
+    return PAUSED;
+  }
+
+  @Override
+  public void setIdleState(IdleState idleState) {
+    throw new UnsupportedOperationException("setIdleState is not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  public long getCurrentTime() {
+    return SystemClock.uptimeMillis();
+  }
+
+  @Override
+  public void pause() {
+    shadowOf(looper).pause();
+  }
+
+  @Override
+  public void unPause() {
+    shadowOf(looper).unPause();
+  }
+
+  @Override
+  public boolean isPaused() {
+    return shadowOf(looper).isPaused();
+  }
+
+  @Override
+  public void post(Runnable runnable) {
+    // this could be supported, but its a deprecated unnecessary API
+    throw new UnsupportedOperationException("post is not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  public void postDelayed(Runnable runnable, long delayMillis) {
+    // this could be supported, but its a deprecated unnecessary API
+    throw new UnsupportedOperationException("post is not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  public void postDelayed(Runnable runnable, long delay, TimeUnit unit) {
+    // this could be supported, but its a deprecated unnecessary API
+    throw new UnsupportedOperationException("post is not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  public void postAtFrontOfQueue(Runnable runnable) {
+    // this could be supported, but its a deprecated unnecessary API
+    throw new UnsupportedOperationException("post is not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  public void remove(Runnable runnable) {
+    // this could be supported, but its a deprecated unnecessary API
+    throw new UnsupportedOperationException("remove is not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public boolean advanceToLastPostedRunnable() {
+    long scheduledTime = getNextScheduledTaskTime().toMillis();
+    shadowOf(looper).runToEndOfTasks();
+    return scheduledTime != 0;
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public boolean advanceToNextPostedRunnable() {
+    long scheduledTime = getNextScheduledTaskTime().toMillis();
+    shadowOf(looper).runToNextTask();
+    return scheduledTime != 0;
+  }
+
+  @Override
+  public boolean advanceBy(long amount, TimeUnit unit) {
+    return advanceTo(SystemClock.uptimeMillis() + unit.toMillis(amount));
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public boolean advanceTo(long endTime) {
+    if (endTime < SystemClock.uptimeMillis()) {
+      return false;
+    }
+    boolean hasQueueTasks = hasTasksScheduledBefore(endTime);
+    shadowOf(looper).idleFor(Duration.ofMillis(endTime - SystemClock.uptimeMillis()));
+    return hasQueueTasks;
+  }
+
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  private boolean hasTasksScheduledBefore(long timeMs) {
+    long scheduledTimeMs = getNextScheduledTaskTime().toMillis();
+    return scheduledTimeMs > 0 && scheduledTimeMs <= timeMs;
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public boolean runOneTask() {
+    long scheduledTime = getNextScheduledTaskTime().toMillis();
+    shadowOf(looper).runOneTask();
+    return scheduledTime != 0;
+  }
+
+  @Override
+  public boolean areAnyRunnable() {
+    return !shadowOf(looper).isIdle();
+  }
+
+  @Override
+  public void reset() {
+    // ignore
+  }
+
+  @Override
+  public int size() {
+    ShadowPausedMessageQueue shadowQueue = Shadow.extract(looper.getQueue());
+    return shadowQueue.internalGetSize();
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public Duration getNextScheduledTaskTime() {
+    return shadowOf(looper).getNextScheduledTaskTime();
+  }
+
+  @Override
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public Duration getLastScheduledTaskTime() {
+    return shadowOf(looper).getLastScheduledTaskTime();
+  }
+
+  @Override
+  @Deprecated
+  public void idleConstantly(boolean shouldIdleConstantly) {
+    throw new UnsupportedOperationException("idleConstantly is not supported in PAUSED LooperMode");
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/NoOpThreadChecker.java b/robolectric/src/main/java/org/robolectric/android/internal/NoOpThreadChecker.java
new file mode 100644
index 0000000..1f7f7c3
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/NoOpThreadChecker.java
@@ -0,0 +1,16 @@
+package org.robolectric.android.internal;
+
+import androidx.test.internal.platform.ThreadChecker;
+
+/**
+ * In Robolectric environment, everything is executed on the main thread except for when you
+ * manually create and run your code on worker thread.
+ */
+@SuppressWarnings("RestrictTo")
+public class NoOpThreadChecker implements ThreadChecker {
+  @Override
+  public void checkMainThread() {}
+
+  @Override
+  public void checkNotMainThread() {}
+}
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java b/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java
new file mode 100644
index 0000000..0d8d15b
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java
@@ -0,0 +1,361 @@
+package org.robolectric.android.internal;
+
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadow.api.Shadow.extract;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.Instrumentation;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.UserHandle;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import androidx.test.internal.runner.intent.IntentMonitorImpl;
+import androidx.test.internal.runner.lifecycle.ActivityLifecycleMonitorImpl;
+import androidx.test.internal.runner.lifecycle.ApplicationLifecycleMonitorImpl;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.runner.intent.IntentMonitorRegistry;
+import androidx.test.runner.intent.IntentStubberRegistry;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.ApplicationLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.ApplicationStage;
+import androidx.test.runner.lifecycle.Stage;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.shadows.ShadowActivity;
+
+/**
+ * A Robolectric instrumentation that acts like a slimmed down {@link
+ * androidx.test.runner.MonitoringInstrumentation} with only the parts needed for Robolectric.
+ */
+@SuppressWarnings("RestrictTo")
+public class RoboMonitoringInstrumentation extends Instrumentation {
+
+  private static final String TAG = "RoboInstrumentation";
+
+  private final Handler mainThreadHandler = new Handler(Looper.getMainLooper());
+  private final ActivityLifecycleMonitorImpl lifecycleMonitor = new ActivityLifecycleMonitorImpl();
+  private final ApplicationLifecycleMonitorImpl applicationMonitor =
+      new ApplicationLifecycleMonitorImpl();
+  private final IntentMonitorImpl intentMonitor = new IntentMonitorImpl();
+  private final List<ActivityController<?>> createdActivities = new ArrayList<>();
+
+  /**
+   * Sets up lifecycle monitoring, and argument registry.
+   *
+   * <p>Subclasses must call up to onCreate(). This onCreate method does not call start() it is the
+   * subclasses responsibility to call start if it desires.
+   */
+  @Override
+  public void onCreate(Bundle arguments) {
+    InstrumentationRegistry.registerInstance(this, arguments);
+    ActivityLifecycleMonitorRegistry.registerInstance(lifecycleMonitor);
+    ApplicationLifecycleMonitorRegistry.registerInstance(applicationMonitor);
+    IntentMonitorRegistry.registerInstance(intentMonitor);
+    if (!Boolean.getBoolean("robolectric.createActivityContexts")) {
+      // To avoid infinite recursion listen to the system resources, this will be updated before
+      // the application resources but because activities use the application resources they will
+      // get updated by the first activity (via updateConfiguration).
+      shadowOf(Resources.getSystem()).addConfigurationChangeListener(this::updateConfiguration);
+    }
+
+    super.onCreate(arguments);
+  }
+
+  @Override
+  public void waitForIdleSync() {
+    shadowMainLooper().idle();
+  }
+
+  @Override
+  public Activity startActivitySync(final Intent intent) {
+    return startActivitySyncInternal(intent).get();
+  }
+
+  public ActivityController<? extends Activity> startActivitySyncInternal(Intent intent) {
+    return startActivitySyncInternal(intent, /* activityOptions= */ null);
+  }
+
+  public ActivityController<? extends Activity> startActivitySyncInternal(
+      Intent intent, @Nullable Bundle activityOptions) {
+    ActivityInfo ai = intent.resolveActivityInfo(getTargetContext().getPackageManager(), 0);
+    if (ai == null) {
+      throw new RuntimeException(
+          "Unable to resolve activity for "
+              + intent
+              + " -- see https://github.com/robolectric/robolectric/pull/4736 for details");
+    }
+
+    Class<? extends Activity> activityClass;
+    String activityClassName = ai.targetActivity != null ? ai.targetActivity : ai.name;
+    try {
+      activityClass = Class.forName(activityClassName).asSubclass(Activity.class);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException("Could not load activity " + ai.name, e);
+    }
+
+    ActivityController<? extends Activity> controller =
+        Robolectric.buildActivity(activityClass, intent, activityOptions).create();
+    if (controller.get().isFinishing()) {
+      controller.destroy();
+    } else {
+      createdActivities.add(controller);
+      controller
+          .start()
+          .postCreate(null)
+          .resume()
+          .visible()
+          .windowFocusChanged(true)
+          .topActivityResumed(true);
+    }
+    return controller;
+  }
+
+  @Override
+  public void callApplicationOnCreate(Application app) {
+    if (Boolean.getBoolean("robolectric.createActivityContexts")) {
+      shadowOf(app.getResources()).addConfigurationChangeListener(this::updateConfiguration);
+    }
+    applicationMonitor.signalLifecycleChange(app, ApplicationStage.PRE_ON_CREATE);
+    super.callApplicationOnCreate(app);
+    applicationMonitor.signalLifecycleChange(app, ApplicationStage.CREATED);
+  }
+
+  @Override
+  public void runOnMainSync(Runnable runner) {
+    shadowMainLooper().idle();
+    runner.run();
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      Activity target,
+      Intent intent,
+      int requestCode,
+      Bundle options) {
+    intentMonitor.signalIntent(intent);
+    ActivityResult ar = stubResultFor(intent);
+    if (ar != null) {
+      Log.i(TAG, String.format("Stubbing intent %s", intent));
+    } else {
+      ar = super.execStartActivity(who, contextThread, token, target, intent, requestCode, options);
+    }
+    if (ar != null && target != null) {
+      ShadowActivity shadowActivity = extract(target);
+      postDispatchActivityResult(shadowActivity, null, requestCode, ar);
+    }
+    return null;
+  }
+
+  /** This API was added in Android API 23 (M) */
+  @Override
+  public ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      String target,
+      Intent intent,
+      int requestCode,
+      Bundle options) {
+    intentMonitor.signalIntent(intent);
+    ActivityResult ar = stubResultFor(intent);
+    if (ar != null) {
+      Log.i(TAG, String.format("Stubbing intent %s", intent));
+    } else {
+      ar = super.execStartActivity(who, contextThread, token, target, intent, requestCode, options);
+    }
+    if (ar != null && who instanceof Activity) {
+      ShadowActivity shadowActivity = extract(who);
+      postDispatchActivityResult(shadowActivity, target, requestCode, ar);
+    }
+    return null;
+  }
+
+  /** This API was added in Android API 17 (JELLY_BEAN_MR1) */
+  @Override
+  public ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      String target,
+      Intent intent,
+      int requestCode,
+      Bundle options,
+      UserHandle user) {
+    ActivityResult ar = stubResultFor(intent);
+    if (ar != null) {
+      Log.i(TAG, String.format("Stubbing intent %s", intent));
+    } else {
+      ar =
+          super.execStartActivity(
+              who, contextThread, token, target, intent, requestCode, options, user);
+    }
+    if (ar != null && target != null) {
+      ShadowActivity shadowActivity = extract(target);
+      postDispatchActivityResult(shadowActivity, null, requestCode, ar);
+    }
+    return null;
+  }
+
+  private void postDispatchActivityResult(
+      ShadowActivity shadowActivity, String target, int requestCode, ActivityResult ar) {
+    mainThreadHandler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            shadowActivity.internalCallDispatchActivityResult(
+                target, requestCode, ar.getResultCode(), ar.getResultData());
+          }
+        });
+  }
+
+  private ActivityResult stubResultFor(Intent intent) {
+    if (IntentStubberRegistry.isLoaded()) {
+      return IntentStubberRegistry.getInstance().getActivityResultForIntent(intent);
+    }
+    return null;
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void execStartActivities(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      Activity target,
+      Intent[] intents,
+      Bundle options) {
+    Log.d(TAG, "execStartActivities(context, ibinder, ibinder, activity, intent[], bundle)");
+    // For requestCode < 0, the caller doesn't expect any result and
+    // in this case we are not expecting any result so selecting
+    // a value < 0.
+    int requestCode = -1;
+    for (Intent intent : intents) {
+      execStartActivity(who, contextThread, token, target, intent, requestCode, options);
+    }
+  }
+
+  @Override
+  public boolean onException(Object obj, Throwable e) {
+    String error =
+        String.format(
+            "Exception encountered by: %s. Dumping thread state to "
+                + "outputs and pining for the fjords.",
+            obj);
+    Log.e(TAG, error, e);
+    Log.e("THREAD_STATE", getThreadState());
+    Log.e(TAG, "Dying now...");
+    return super.onException(obj, e);
+  }
+
+  protected String getThreadState() {
+    Set<Map.Entry<Thread, StackTraceElement[]>> threads = Thread.getAllStackTraces().entrySet();
+    StringBuilder threadState = new StringBuilder();
+    for (Map.Entry<Thread, StackTraceElement[]> threadAndStack : threads) {
+      StringBuilder threadMessage = new StringBuilder("  ").append(threadAndStack.getKey());
+      threadMessage.append("\n");
+      for (StackTraceElement ste : threadAndStack.getValue()) {
+        threadMessage.append(String.format("    %s%n", ste));
+      }
+      threadMessage.append("\n");
+      threadState.append(threadMessage);
+    }
+    return threadState.toString();
+  }
+
+  @Override
+  public void callActivityOnDestroy(Activity activity) {
+    if (activity.isFinishing()) {
+      createdActivities.removeIf(controller -> controller.get() == activity);
+    }
+    super.callActivityOnDestroy(activity);
+    lifecycleMonitor.signalLifecycleChange(Stage.DESTROYED, activity);
+  }
+
+  @Override
+  public void callActivityOnRestart(Activity activity) {
+    super.callActivityOnRestart(activity);
+    lifecycleMonitor.signalLifecycleChange(Stage.RESTARTED, activity);
+  }
+
+  @Override
+  public void callActivityOnCreate(Activity activity, Bundle bundle) {
+    lifecycleMonitor.signalLifecycleChange(Stage.PRE_ON_CREATE, activity);
+    super.callActivityOnCreate(activity, bundle);
+    lifecycleMonitor.signalLifecycleChange(Stage.CREATED, activity);
+  }
+
+  @Override
+  public void callActivityOnStart(Activity activity) {
+    super.callActivityOnStart(activity);
+    lifecycleMonitor.signalLifecycleChange(Stage.STARTED, activity);
+  }
+
+  @Override
+  public void callActivityOnStop(Activity activity) {
+    super.callActivityOnStop(activity);
+    lifecycleMonitor.signalLifecycleChange(Stage.STOPPED, activity);
+  }
+
+  @Override
+  public void callActivityOnResume(Activity activity) {
+    super.callActivityOnResume(activity);
+    lifecycleMonitor.signalLifecycleChange(Stage.RESUMED, activity);
+  }
+
+  @Override
+  public void callActivityOnPause(Activity activity) {
+    super.callActivityOnPause(activity);
+    lifecycleMonitor.signalLifecycleChange(Stage.PAUSED, activity);
+  }
+
+  @Override
+  public void finish(int resultCode, Bundle bundle) {}
+
+  @Override
+  public Context getTargetContext() {
+    return RuntimeEnvironment.getApplication();
+  }
+
+  @Override
+  public Context getContext() {
+    return RuntimeEnvironment.getApplication();
+  }
+
+  private void updateConfiguration(
+      Configuration oldConfig, Configuration newConfig, DisplayMetrics newMetrics) {
+    int changedConfig = oldConfig.diff(newConfig);
+    List<ActivityController<?>> controllers = new ArrayList<>(createdActivities);
+    for (ActivityController<?> controller : controllers) {
+      if (createdActivities.contains(controller)) {
+        Activity activity = controller.get();
+        controller.configurationChange(newConfig, newMetrics, changedConfig);
+        // If the activity is recreated then make the new activity visible, this should be done by
+        // configurationChange but there's a pre-existing TODO to address this and it will require
+        // more work to make it function correctly.
+        if (controller.get() != activity) {
+          controller.visible();
+        }
+      }
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/AndroidSandbox.java b/robolectric/src/main/java/org/robolectric/internal/AndroidSandbox.java
new file mode 100755
index 0000000..69adb67
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/AndroidSandbox.java
@@ -0,0 +1,139 @@
+package org.robolectric.internal;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ThreadFactory;
+import javax.inject.Inject;
+import javax.inject.Named;
+import org.robolectric.ApkLoader;
+import org.robolectric.android.internal.AndroidTestEnvironment;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.internal.bytecode.ClassInstrumentor;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.internal.bytecode.Sandbox;
+import org.robolectric.internal.bytecode.SandboxClassLoader;
+import org.robolectric.internal.bytecode.ShadowProviders;
+import org.robolectric.internal.bytecode.UrlResourceProvider;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.shadows.SQLiteShadowPicker;
+import org.robolectric.util.inject.Injector;
+
+/** Sandbox simulating an Android device. */
+@SuppressWarnings("NewApi")
+public class AndroidSandbox extends Sandbox {
+  private final Sdk sdk;
+  private final TestEnvironment testEnvironment;
+  private final Set<String> modeInvalidatedClasses = new HashSet<>();
+  private SQLiteMode.Mode activeSQLiteMode;
+
+  @Inject
+  public AndroidSandbox(
+      @Named("runtimeSdk") Sdk runtimeSdk,
+      @Named("compileSdk") Sdk compileSdk,
+      ResourcesMode resourcesMode,
+      ApkLoader apkLoader,
+      TestEnvironmentSpec testEnvironmentSpec,
+      SdkSandboxClassLoader sdkSandboxClassLoader,
+      ShadowProviders shadowProviders,
+      SQLiteMode.Mode sqLiteMode) {
+    super(sdkSandboxClassLoader);
+
+    ClassLoader robolectricClassLoader = getRobolectricClassLoader();
+
+    Injector sandboxScope =
+        new Injector.Builder(robolectricClassLoader)
+            .bind(ApkLoader.class, apkLoader) // shared singleton
+            .bind(TestEnvironment.class, bootstrappedClass(testEnvironmentSpec.getClazz()))
+            .bind(new Injector.Key<>(Sdk.class, "runtimeSdk"), runtimeSdk)
+            .bind(new Injector.Key<>(Sdk.class, "compileSdk"), compileSdk)
+            .bind(ResourcesMode.class, resourcesMode)
+            .bind(ShadowProvider[].class, shadowProviders.inClassLoader(robolectricClassLoader))
+            .build();
+
+    sdk = runtimeSdk;
+    activeSQLiteMode = sqLiteMode;
+    this.testEnvironment = runOnMainThread(() -> sandboxScope.getInstance(TestEnvironment.class));
+  }
+
+  @Override
+  protected ThreadFactory mainThreadFactory() {
+    return r -> {
+      String name = "SDK " + sdk.getApiLevel();
+      return new Thread(new ThreadGroup(name), r, name + " Main Thread");
+    };
+  }
+
+  @Override
+  protected Set<String> getModeInvalidatedClasses() {
+    return ImmutableSet.copyOf(modeInvalidatedClasses);
+  }
+
+  @Override
+  protected void clearModeInvalidatedClasses() {
+    modeInvalidatedClasses.clear();
+  }
+
+  public Sdk getSdk() {
+    return sdk;
+  }
+
+  public TestEnvironment getTestEnvironment() {
+    return testEnvironment;
+  }
+
+  @Override
+  public String toString() {
+    return "AndroidSandbox[SDK " + sdk + "]";
+  }
+
+  public void updateModes(SQLiteMode.Mode sqliteMode) {
+    if (activeSQLiteMode != sqliteMode) {
+      this.activeSQLiteMode = sqliteMode;
+      modeInvalidatedClasses.addAll(SQLiteShadowPicker.getAffectedClasses());
+    }
+  }
+
+  /**
+   * Provides a mechanism for tests to inject a different AndroidTestEnvironment. For test use only.
+   */
+  @VisibleForTesting
+  public static class TestEnvironmentSpec {
+
+    private final Class<? extends AndroidTestEnvironment> clazz;
+
+    @Inject
+    public TestEnvironmentSpec() {
+      clazz = AndroidTestEnvironment.class;
+    }
+
+    public TestEnvironmentSpec(Class<? extends AndroidTestEnvironment> clazz) {
+      this.clazz = clazz;
+    }
+
+    public Class<? extends AndroidTestEnvironment> getClazz() {
+      return clazz;
+    }
+  }
+
+  /** Adapter from Sdk to ResourceLoader. */
+  public static class SdkSandboxClassLoader extends SandboxClassLoader {
+
+    public SdkSandboxClassLoader(InstrumentationConfiguration config,
+        @Named("runtimeSdk") Sdk runtimeSdk, ClassInstrumentor classInstrumentor) {
+      super(config, new UrlResourceProvider(toUrl(runtimeSdk.getJarPath())), classInstrumentor);
+    }
+
+    private static URL toUrl(Path path) {
+      try {
+        return path.toUri().toURL();
+      } catch (MalformedURLException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/BuckManifestFactory.java b/robolectric/src/main/java/org/robolectric/internal/BuckManifestFactory.java
new file mode 100644
index 0000000..0178ac8
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/BuckManifestFactory.java
@@ -0,0 +1,88 @@
+package org.robolectric.internal;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.Fs;
+import org.robolectric.util.Util;
+
+@SuppressWarnings("NewApi")
+public class BuckManifestFactory implements ManifestFactory {
+
+  private static final String BUCK_ROBOLECTRIC_RES_DIRECTORIES = "buck.robolectric_res_directories";
+  private static final String BUCK_ROBOLECTRIC_ASSETS_DIRECTORIES = "buck.robolectric_assets_directories";
+  private static final String BUCK_ROBOLECTRIC_MANIFEST = "buck.robolectric_manifest";
+
+  @Override
+  public ManifestIdentifier identify(Config config) {
+    String buckManifest = System.getProperty(BUCK_ROBOLECTRIC_MANIFEST);
+    Path manifestFile = Paths.get(buckManifest);
+
+    String buckResDirs = System.getProperty(BUCK_ROBOLECTRIC_RES_DIRECTORIES);
+    String buckAssetsDirs = System.getProperty(BUCK_ROBOLECTRIC_ASSETS_DIRECTORIES);
+    String packageName = config.packageName();
+
+    final List<Path> buckResources = getDirectoriesFromProperty(buckResDirs);
+    final List<Path> buckAssets = getDirectoriesFromProperty(buckAssetsDirs);
+    final Path resDir =
+        buckResources.isEmpty() ? null : buckResources.get(buckResources.size() - 1);
+    final Path assetsDir = buckAssets.isEmpty() ? null : buckAssets.get(buckAssets.size() - 1);
+    final List<ManifestIdentifier> libraries;
+
+    if (resDir == null && assetsDir == null) {
+      libraries = null;
+    } else {
+      libraries = new ArrayList<>();
+
+      for (int i = 0; i < buckResources.size() - 1; i++) {
+        libraries.add(new ManifestIdentifier((String) null, null, buckResources.get(i), null, null));
+      }
+
+      for (int i = 0; i < buckAssets.size() - 1; i++) {
+        libraries.add(new ManifestIdentifier(null, null, null, buckAssets.get(i), null));
+      }
+    }
+
+    return new ManifestIdentifier(packageName, manifestFile, resDir, assetsDir, libraries);
+  }
+
+  public static boolean isBuck() {
+    return System.getProperty(BUCK_ROBOLECTRIC_MANIFEST) != null;
+  }
+
+  @Nonnull
+  private List<Path> getDirectoriesFromProperty(String property) {
+    if (property == null) {
+      return Collections.emptyList();
+    }
+
+    List<String> dirs;
+    if (property.startsWith("@")) {
+      String filename = property.substring(1);
+      try {
+        dirs = Arrays.asList(
+            new String(Util.readBytes(new FileInputStream(filename)), UTF_8).split("\\n"));
+      } catch (IOException e) {
+        throw new RuntimeException("Cannot read file " + filename);
+      }
+    } else {
+      dirs = Arrays.asList(property.split(File.pathSeparator));
+    }
+
+    List<Path> files = new ArrayList<>();
+    for (String dir : dirs) {
+      files.add(Fs.fromUrl(dir));
+    }
+    return files;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/DefaultManifestFactory.java b/robolectric/src/main/java/org/robolectric/internal/DefaultManifestFactory.java
new file mode 100644
index 0000000..c2da01e
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/DefaultManifestFactory.java
@@ -0,0 +1,74 @@
+package org.robolectric.internal;
+
+import static java.util.Collections.emptyList;
+
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.Fs;
+import org.robolectric.util.Logger;
+
+@SuppressWarnings("NewApi")
+public class DefaultManifestFactory implements ManifestFactory {
+  private Properties properties;
+
+  public DefaultManifestFactory(Properties properties) {
+    this.properties = properties;
+  }
+
+  @Override
+  public ManifestIdentifier identify(Config config) {
+    Path manifestFile = getFileFromProperty("android_merged_manifest");
+    Path resourcesDir = getFileFromProperty("android_merged_resources");
+    Path assetsDir = getFileFromProperty("android_merged_assets");
+    Path apkFile = getFileFromProperty("android_resource_apk");
+    String packageName = properties.getProperty("android_custom_package");
+
+    String manifestConfig = config.manifest();
+    if (Config.NONE.equals(manifestConfig)) {
+      Logger.info("@Config(manifest = Config.NONE) specified while using Build System API, ignoring");
+    } else if (!Config.DEFAULT_MANIFEST_NAME.equals(manifestConfig)) {
+      manifestFile = getResource(manifestConfig);
+    }
+
+    if (!Config.DEFAULT_RES_FOLDER.equals(config.resourceDir())) {
+      resourcesDir = getResource(config.resourceDir());
+    }
+
+    if (!Config.DEFAULT_ASSET_FOLDER.equals(config.assetDir())) {
+      assetsDir = getResource(config.assetDir());
+    }
+
+    if (!Config.DEFAULT_PACKAGE_NAME.equals(config.packageName())) {
+      packageName = config.packageName();
+    }
+
+    List<ManifestIdentifier> libraryDirs = emptyList();
+    if (config.libraries().length > 0) {
+      Logger.info("@Config(libraries) specified while using Build System API, ignoring");
+    }
+
+    return new ManifestIdentifier(packageName, manifestFile, resourcesDir, assetsDir, libraryDirs,
+        apkFile);
+  }
+
+  private Path getResource(String pathStr) {
+    URL manifestUrl = getClass().getClassLoader().getResource(pathStr);
+    if (manifestUrl == null) {
+      throw new IllegalArgumentException("couldn't find '" + pathStr + "'");
+    } else {
+      return Fs.fromUrl(manifestUrl);
+    }
+  }
+
+  private Path getFileFromProperty(String propertyName) {
+    String path = properties.getProperty(propertyName);
+    if (path == null || path.isEmpty()) {
+      return null;
+    }
+
+    return Fs.fromUrl(path);
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/DeprecatedMethodMarkerException.java b/robolectric/src/main/java/org/robolectric/internal/DeprecatedMethodMarkerException.java
new file mode 100644
index 0000000..58068cc
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/DeprecatedMethodMarkerException.java
@@ -0,0 +1,4 @@
+package org.robolectric.internal;
+
+public class DeprecatedMethodMarkerException extends RuntimeException {
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/ManifestFactory.java b/robolectric/src/main/java/org/robolectric/internal/ManifestFactory.java
new file mode 100644
index 0000000..adcbea1
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/ManifestFactory.java
@@ -0,0 +1,22 @@
+package org.robolectric.internal;
+
+import org.robolectric.annotation.Config;
+
+/**
+ * A factory that detects what build system is in use and provides a ManifestFactory that can create
+ * an AndroidManifest for that environment.
+ *
+ * <p>Maven, Gradle, and Buck build systems are currently supported.
+ */
+public interface ManifestFactory {
+
+  /**
+   * Creates a {@link ManifestIdentifier} which represents an Android app, service, or library
+   * under test, indicating its manifest file, resources and assets directories, and optionally
+   * dependency libraries and an overridden package name.
+   *
+   * @param config The merged configuration for the running test.
+   */
+  ManifestIdentifier identify(Config config);
+
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/ManifestIdentifier.java b/robolectric/src/main/java/org/robolectric/internal/ManifestIdentifier.java
new file mode 100644
index 0000000..2077105
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/ManifestIdentifier.java
@@ -0,0 +1,145 @@
+package org.robolectric.internal;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.Config;
+
+@SuppressWarnings("NewApi")
+public class ManifestIdentifier {
+  private final Path manifestFile;
+  private final Path resDir;
+  private final Path assetDir;
+  private final String packageName;
+  private final List<ManifestIdentifier> libraries;
+  private final Path apkFile;
+
+  public ManifestIdentifier(
+      String packageName,
+      Path manifestFile,
+      Path resDir,
+      Path assetDir,
+      List<ManifestIdentifier> libraries) {
+    this(packageName, manifestFile, resDir, assetDir, libraries, null);
+  }
+
+  public ManifestIdentifier(
+      String packageName,
+      Path manifestFile,
+      Path resDir,
+      Path assetDir,
+      List<ManifestIdentifier> libraries,
+      Path apkFile) {
+    this.manifestFile = manifestFile;
+    this.resDir = resDir;
+    this.assetDir = assetDir;
+    this.packageName = packageName;
+    this.libraries = libraries == null ? Collections.emptyList() : libraries;
+    this.apkFile = apkFile;
+  }
+
+  /** @deprecated Use {@link #ManifestIdentifier(String, Path, Path, Path, List)} instead. */
+  @Deprecated
+  public ManifestIdentifier(
+      Path manifestFile, Path resDir, Path assetDir, String packageName, List<Path> libraryDirs) {
+    this.manifestFile = manifestFile;
+    this.resDir = resDir;
+    this.assetDir = assetDir;
+    this.packageName = packageName;
+
+    List<ManifestIdentifier> libraries = new ArrayList<>();
+    if (libraryDirs != null) {
+      for (Path libraryDir : libraryDirs) {
+        libraries.add(
+            new ManifestIdentifier(
+                null,
+                libraryDir.resolve(Config.DEFAULT_MANIFEST_NAME),
+                libraryDir.resolve(Config.DEFAULT_RES_FOLDER),
+                libraryDir.resolve(Config.DEFAULT_ASSET_FOLDER),
+                null));
+      }
+    }
+    this.libraries = Collections.unmodifiableList(libraries);
+    this.apkFile = null;
+  }
+
+  public Path getManifestFile() {
+    return manifestFile;
+  }
+
+  public Path getResDir() {
+    return resDir;
+  }
+
+  public Path getAssetDir() {
+    return assetDir;
+  }
+
+  public String getPackageName() {
+    return packageName;
+  }
+
+  @Nonnull
+  public List<ManifestIdentifier> getLibraries() {
+    return libraries;
+  }
+
+  public Path getApkFile() {
+    return apkFile;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof ManifestIdentifier)) {
+      return false;
+    }
+
+    ManifestIdentifier that = (ManifestIdentifier) o;
+
+    if (manifestFile != null ? !manifestFile.equals(that.manifestFile)
+        : that.manifestFile != null) {
+      return false;
+    }
+    if (resDir != null ? !resDir.equals(that.resDir) : that.resDir != null) {
+      return false;
+    }
+    if (assetDir != null ? !assetDir.equals(that.assetDir) : that.assetDir != null) {
+      return false;
+    }
+    if (packageName != null ? !packageName.equals(that.packageName) : that.packageName != null) {
+      return false;
+    }
+    if (libraries != null ? !libraries.equals(that.libraries) : that.libraries != null) {
+      return false;
+    }
+    return apkFile != null ? apkFile.equals(that.apkFile) : that.apkFile == null;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = manifestFile != null ? manifestFile.hashCode() : 0;
+    result = 31 * result + (resDir != null ? resDir.hashCode() : 0);
+    result = 31 * result + (assetDir != null ? assetDir.hashCode() : 0);
+    result = 31 * result + (packageName != null ? packageName.hashCode() : 0);
+    result = 31 * result + (libraries != null ? libraries.hashCode() : 0);
+    result = 31 * result + (apkFile != null ? apkFile.hashCode() : 0);
+    return result;
+  }
+
+  @Override
+  public String toString() {
+    return "ManifestIdentifier{" +
+        "manifestFile=" + manifestFile +
+        ", resDir=" + resDir +
+        ", assetDir=" + assetDir +
+        ", packageName='" + packageName + '\'' +
+        ", libraries=" + libraries +
+        ", apkFile=" + apkFile +
+        '}';
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/MavenManifestFactory.java b/robolectric/src/main/java/org/robolectric/internal/MavenManifestFactory.java
new file mode 100644
index 0000000..6d08b91
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/MavenManifestFactory.java
@@ -0,0 +1,140 @@
+package org.robolectric.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.Fs;
+
+/**
+ * @deprecated This method of configuration will be removed in a forthcoming release. Build systems
+ *     should follow http://robolectric.org/build-system-integration/ to provide integration with
+ *     Robolectric.
+ */
+@Deprecated
+@SuppressWarnings("NewApi")
+public class MavenManifestFactory implements ManifestFactory {
+
+  @Override
+  public ManifestIdentifier identify(Config config) {
+    final String manifestPath = config.manifest();
+    if (manifestPath.equals(Config.NONE)) {
+      return new ManifestIdentifier((String) null, null, null, null, null);
+    }
+
+    // Try to locate the manifest file as a classpath resource; fallback to using the base dir.
+    final Path manifestFile;
+    final String resourceName = manifestPath.startsWith("/") ? manifestPath : ("/" + manifestPath);
+    final URL resourceUrl = getClass().getResource(resourceName);
+    if (resourceUrl != null && "file".equals(resourceUrl.getProtocol())) {
+      // Construct a path to the manifest file relative to the current working directory.
+      final Path workingDirectory = Paths.get(System.getProperty("user.dir"));
+      final Path absolutePath = Fs.fromUrl(resourceUrl);
+      manifestFile = workingDirectory.relativize(absolutePath);
+    } else {
+      manifestFile = getBaseDir().resolve(manifestPath);
+    }
+
+    final Path baseDir = manifestFile.getParent();
+    final Path resDir = baseDir.resolve(config.resourceDir());
+    final Path assetDir = baseDir.resolve(config.assetDir());
+
+    List<ManifestIdentifier> libraries;
+    if (config.libraries().length == 0) {
+      // If there is no library override, look through subdirectories.
+      try {
+        libraries = findLibraries(resDir);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    } else {
+      libraries = new ArrayList<>();
+      for (String libraryDirName : config.libraries()) {
+        Path libDir = baseDir.resolve(libraryDirName);
+        libraries.add(
+            new ManifestIdentifier(
+                null,
+                libDir.resolve(Config.DEFAULT_MANIFEST_NAME),
+                libDir.resolve(Config.DEFAULT_RES_FOLDER),
+                libDir.resolve(Config.DEFAULT_ASSET_FOLDER),
+                null));
+      }
+    }
+
+    return new ManifestIdentifier(config.packageName(), manifestFile, resDir, assetDir, libraries);
+  }
+
+  Path getBaseDir() {
+    return Paths.get(".");
+  }
+
+  private static Properties getProperties(Path propertiesFile) {
+    Properties properties = new Properties();
+
+    // return an empty Properties object if the propertiesFile does not exist
+    if (!Files.exists(propertiesFile)) return properties;
+
+    InputStream stream;
+    try {
+      stream = Fs.getInputStream(propertiesFile);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    try {
+      try {
+        properties.load(stream);
+      } finally {
+        stream.close();
+      }
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return properties;
+  }
+
+  /**
+   * Find valid library AndroidManifest files referenced from an already loaded AndroidManifest's
+   * {@code project.properties} file, recursively.
+   */
+  private static List<ManifestIdentifier> findLibraries(Path resDirectory) throws IOException {
+    List<ManifestIdentifier> libraryBaseDirs = new ArrayList<>();
+
+    if (resDirectory != null) {
+      Path baseDir = resDirectory.getParent();
+      final Properties properties = getProperties(baseDir.resolve("project.properties"));
+      Properties overrideProperties = getProperties(baseDir.resolve("test-project.properties"));
+      properties.putAll(overrideProperties);
+
+      int libRef = 1;
+      String lib;
+      while ((lib = properties.getProperty("android.library.reference." + libRef)) != null) {
+        Path libraryDir = baseDir.resolve(lib);
+        if (Files.isDirectory(libraryDir)) {
+          // Ignore directories without any files
+          Path[] libraryBaseDirFiles = Fs.listFiles(libraryDir);
+          if (libraryBaseDirFiles != null && libraryBaseDirFiles.length > 0) {
+            List<ManifestIdentifier> libraries =
+                findLibraries(libraryDir.resolve(Config.DEFAULT_RES_FOLDER));
+            libraryBaseDirs.add(
+                new ManifestIdentifier(
+                    null,
+                    libraryDir.resolve(Config.DEFAULT_MANIFEST_NAME),
+                    libraryDir.resolve(Config.DEFAULT_RES_FOLDER),
+                    libraryDir.resolve(Config.DEFAULT_ASSET_FOLDER),
+                    libraries));
+          }
+        }
+
+        libRef++;
+      }
+    }
+    return libraryBaseDirs;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/ResourcesMode.java b/robolectric/src/main/java/org/robolectric/internal/ResourcesMode.java
new file mode 100644
index 0000000..bc2701c
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/ResourcesMode.java
@@ -0,0 +1,6 @@
+package org.robolectric.internal;
+
+public enum ResourcesMode {
+  BINARY,
+  LEGACY
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/SandboxManager.java b/robolectric/src/main/java/org/robolectric/internal/SandboxManager.java
new file mode 100644
index 0000000..73dfae3
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/SandboxManager.java
@@ -0,0 +1,114 @@
+package org.robolectric.internal;
+
+import android.annotation.SuppressLint;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import javax.inject.Inject;
+import javax.inject.Named;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.plugins.SdkCollection;
+import org.robolectric.util.inject.AutoFactory;
+
+/** Manager of sandboxes. */
+@SuppressLint("NewApi")
+public class SandboxManager {
+
+  /**
+   * The factor for cache size. See {@link #sandboxesByKey} for details.
+   */
+  private static final int CACHE_SIZE_FACTOR = 3;
+
+  private final SandboxBuilder sandboxBuilder;
+  private final SdkCollection sdkCollection;
+
+  // Simple LRU Cache. AndroidSandboxes are unique across InstrumentationConfiguration and Sdk
+  private final LinkedHashMap<SandboxKey, AndroidSandbox> sandboxesByKey;
+
+  @Inject
+  public SandboxManager(SandboxBuilder sandboxBuilder, SdkCollection sdkCollection) {
+    this.sandboxBuilder = sandboxBuilder;
+    this.sdkCollection = sdkCollection;
+
+    // We need to set the cache size of class loaders more than the number of supported APIs as
+    // different tests may have different configurations.
+    final int cacheSize = sdkCollection.getSupportedSdks().size() * CACHE_SIZE_FACTOR;
+    sandboxesByKey = new LinkedHashMap<SandboxKey, AndroidSandbox>() {
+      @Override
+      protected boolean removeEldestEntry(Map.Entry<SandboxKey, AndroidSandbox> eldest) {
+        return size() > cacheSize;
+      }
+    };
+  }
+
+  public synchronized AndroidSandbox getAndroidSandbox(
+      InstrumentationConfiguration instrumentationConfig,
+      Sdk sdk,
+      ResourcesMode resourcesMode,
+      LooperMode.Mode looperMode,
+      SQLiteMode.Mode sqliteMode) {
+    SandboxKey key = new SandboxKey(instrumentationConfig, sdk, resourcesMode, looperMode);
+
+    AndroidSandbox androidSandbox = sandboxesByKey.get(key);
+    if (androidSandbox == null) {
+      Sdk compileSdk = sdkCollection.getMaxSupportedSdk();
+      androidSandbox =
+          sandboxBuilder.build(instrumentationConfig, sdk, compileSdk, resourcesMode, sqliteMode);
+      sandboxesByKey.put(key, androidSandbox);
+    }
+    androidSandbox.updateModes(sqliteMode);
+    return androidSandbox;
+  }
+
+  /** Factory interface for AndroidSandbox. */
+  @AutoFactory
+  public interface SandboxBuilder {
+    AndroidSandbox build(
+        InstrumentationConfiguration instrumentationConfig,
+        @Named("runtimeSdk") Sdk runtimeSdk,
+        @Named("compileSdk") Sdk compileSdk,
+        ResourcesMode resourcesMode,
+        SQLiteMode.Mode sqLiteMode);
+  }
+
+  static class SandboxKey {
+    private final Sdk sdk;
+    private final InstrumentationConfiguration instrumentationConfiguration;
+    private final ResourcesMode resourcesMode;
+    private final LooperMode.Mode looperMode;
+
+    public SandboxKey(
+        InstrumentationConfiguration instrumentationConfiguration,
+        Sdk sdk,
+        ResourcesMode resourcesMode,
+        LooperMode.Mode looperMode) {
+      this.sdk = sdk;
+      this.instrumentationConfiguration = instrumentationConfiguration;
+      this.resourcesMode = resourcesMode;
+      this.looperMode = looperMode;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof SandboxKey)) {
+        return false;
+      }
+      SandboxKey that = (SandboxKey) o;
+      return resourcesMode == that.resourcesMode
+          && Objects.equals(sdk, that.sdk)
+          && Objects.equals(instrumentationConfiguration, that.instrumentationConfiguration)
+          && looperMode == that.looperMode;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(sdk, instrumentationConfiguration, resourcesMode, looperMode);
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/TestEnvironment.java b/robolectric/src/main/java/org/robolectric/internal/TestEnvironment.java
new file mode 100644
index 0000000..333a1b1
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/TestEnvironment.java
@@ -0,0 +1,21 @@
+package org.robolectric.internal;
+
+import java.lang.reflect.Method;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+
+/**
+ * An environment for running tests.
+ */
+public interface TestEnvironment {
+
+  void setUpApplicationState(
+      Method method,
+      Configuration config, AndroidManifest appManifest);
+
+  void tearDownApplication();
+
+  void checkStateAfterTestFailure(Throwable t) throws Throwable;
+
+  void resetState();
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/dependency/LocalDependencyResolver.java b/robolectric/src/main/java/org/robolectric/internal/dependency/LocalDependencyResolver.java
new file mode 100644
index 0000000..5a77ede
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/dependency/LocalDependencyResolver.java
@@ -0,0 +1,59 @@
+package org.robolectric.internal.dependency;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+public class LocalDependencyResolver implements DependencyResolver {
+  private File offlineJarDir;
+
+  public LocalDependencyResolver(File offlineJarDir) {
+    super();
+    this.offlineJarDir = offlineJarDir;
+  }
+
+  @Override
+  public URL getLocalArtifactUrl(DependencyJar dependency) {
+    StringBuilder filenameBuilder = new StringBuilder();
+    filenameBuilder.append(dependency.getArtifactId())
+        .append("-")
+        .append(dependency.getVersion());
+
+    if (dependency.getClassifier() != null) {
+      filenameBuilder.append("-")
+          .append(dependency.getClassifier());
+    }
+
+    filenameBuilder.append(".")
+        .append(dependency.getType());
+
+    return fileToUrl(validateFile(new File(offlineJarDir, filenameBuilder.toString())));
+  }
+
+  /**
+   * Validates {@code file} is an existing file that is readable.
+   *
+   * @param file the File to test
+   * @return the provided file, if all validation passes
+   * @throws IllegalArgumentException if validation fails
+   */
+  private static File validateFile(File file) throws IllegalArgumentException {
+    if (!file.isFile()) {
+      throw new IllegalArgumentException("Path is not a file: " + file);
+    }
+    if (!file.canRead()) {
+      throw new IllegalArgumentException("Unable to read file: " + file);
+    }
+    return file;
+  }
+
+  /** Returns the given file as a {@link URL}. */
+  private static URL fileToUrl(File file) {
+    try {
+      return file.toURI().toURL();
+    } catch (MalformedURLException e) {
+      throw new IllegalArgumentException(
+          String.format("File \"%s\" cannot be represented as a URL: %s", file, e));
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/internal/dependency/PropertiesDependencyResolver.java b/robolectric/src/main/java/org/robolectric/internal/dependency/PropertiesDependencyResolver.java
new file mode 100755
index 0000000..837e966
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/internal/dependency/PropertiesDependencyResolver.java
@@ -0,0 +1,63 @@
+package org.robolectric.internal.dependency;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+import org.robolectric.res.Fs;
+
+@SuppressWarnings("NewApi")
+public class PropertiesDependencyResolver implements DependencyResolver {
+  private final Properties properties;
+  private final Path baseDir;
+  private DependencyResolver delegate;
+
+  public PropertiesDependencyResolver(Path propertiesFile) {
+    this(propertiesFile, null);
+  }
+
+  public PropertiesDependencyResolver(Path propertiesPath, DependencyResolver delegate) {
+    this.properties = loadProperties(propertiesPath);
+    this.baseDir = propertiesPath.getParent();
+    this.delegate = delegate;
+  }
+
+  private Properties loadProperties(Path propertiesPath) {
+    final Properties properties = new Properties();
+    try (InputStream stream = Fs.getInputStream(propertiesPath)) {
+      properties.load(stream);
+    } catch (IOException e) {
+      throw new RuntimeException("couldn't read " + propertiesPath, e);
+    }
+    return properties;
+  }
+
+  @Override
+  public URL getLocalArtifactUrl(DependencyJar dependency) {
+    String depShortName = dependency.getShortName();
+    String pathStr = properties.getProperty(depShortName);
+    if (pathStr != null) {
+      if (pathStr.indexOf(File.pathSeparatorChar) != -1) {
+        throw new IllegalArgumentException("didn't expect multiple files for " + dependency
+            + ": " + pathStr);
+      }
+
+      Path path = baseDir.resolve(Paths.get(pathStr));
+      try {
+        return path.toUri().toURL();
+      } catch (MalformedURLException e) {
+        throw new RuntimeException(e);
+      }
+    } else {
+      if (delegate != null) {
+        return delegate.getLocalArtifactUrl(dependency);
+      }
+    }
+
+    throw new RuntimeException("no artifacts found for " + dependency);
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/junit/rules/BackgroundTestRule.java b/robolectric/src/main/java/org/robolectric/junit/rules/BackgroundTestRule.java
new file mode 100644
index 0000000..1a1b7e1
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/junit/rules/BackgroundTestRule.java
@@ -0,0 +1,76 @@
+package org.robolectric.junit.rules;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.util.concurrent.BackgroundExecutor;
+
+/**
+ * Let tests to run on background thread, if it has annotation {@link BackgroundTest}.
+ *
+ * <p>This is useful for testing logic that explicitly forbids being called on the main thread.
+ *
+ * <p>Example usage:
+ *
+ * <pre>
+ * {@literal @}Rule public final BackgroundTestRule backgroundTestRule = new BackgroundTestRule();
+ *
+ * {@literal @}Test
+ * {@literal @}BackgroundTest
+ * public void testInBackground() {
+ *   assertThat(Looper.myLooper()).isNotEqualTo(Looper.getMainLooper());
+ * }
+ *
+ * {@literal @}Test
+ * public void testInForeground() throws Exception {
+ *   assertThat(Looper.myLooper()).isEqualTo(Looper.getMainLooper());
+ * }
+ * </pre>
+ */
+public final class BackgroundTestRule implements TestRule {
+
+  /** Annotation for test methods that need to be executed in a background thread. */
+  @Retention(RUNTIME)
+  @Target({METHOD})
+  public @interface BackgroundTest {}
+
+  @Override
+  public Statement apply(Statement base, Description description) {
+    if (description.getAnnotation(BackgroundTest.class) == null) {
+      return base;
+    }
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        AtomicReference<Throwable> throwable = new AtomicReference<>();
+        // In the @LazyApplication case, Robolectric cannot create the application from the
+        // background thread
+        //
+        // TODO Remove explicit loading when/if background threads can kick off
+        // application loading in the future
+        RuntimeEnvironment.getApplication();
+        BackgroundExecutor.runInBackground(
+            new Runnable() {
+              @Override
+              public void run() {
+                try {
+                  base.evaluate();
+                } catch (Throwable t) {
+                  throwable.set(t);
+                }
+              }
+            });
+        if (throwable.get() != null) {
+          throw throwable.get();
+        }
+      }
+    };
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/junit/rules/CloseGuardRule.java b/robolectric/src/main/java/org/robolectric/junit/rules/CloseGuardRule.java
new file mode 100644
index 0000000..220f9b1
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/junit/rules/CloseGuardRule.java
@@ -0,0 +1,14 @@
+package org.robolectric.junit.rules;
+
+import org.junit.rules.Verifier;
+import org.junit.runners.model.MultipleFailureException;
+import org.robolectric.shadows.ShadowCloseGuard;
+
+/** Rule for failing tests that leave any CloseGuards open. */
+public final class CloseGuardRule extends Verifier {
+
+  @Override
+  public void verify() throws Throwable {
+    MultipleFailureException.assertEmpty(ShadowCloseGuard.getErrors());
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/junit/rules/ExpectedLogMessagesRule.java b/robolectric/src/main/java/org/robolectric/junit/rules/ExpectedLogMessagesRule.java
new file mode 100644
index 0000000..1bcc4cb
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/junit/rules/ExpectedLogMessagesRule.java
@@ -0,0 +1,317 @@
+package org.robolectric.junit.rules;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+
+import android.util.Log;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.hamcrest.Matcher;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+import org.robolectric.shadows.ShadowLog;
+import org.robolectric.shadows.ShadowLog.LogItem;
+
+/**
+ * Allows tests to assert about the presence of log messages, and turns logged errors that are not
+ * explicitly expected into test failures.
+ */
+public final class ExpectedLogMessagesRule implements TestRule {
+  /** Tags that apps can't prevent. We exempt them globally. */
+  private static final ImmutableSet<String> UNPREVENTABLE_TAGS =
+      ImmutableSet.of(
+          "Typeface",
+          "RingtoneManager",
+          // Fails when attempting to preload classes by name
+          "PhonePolicy",
+          // Ignore MultiDex log messages
+          "MultiDex",
+          // Logged starting with Android 33 as:
+          // E/RippleDrawable: The RippleDrawable.STYLE_PATTERNED animation is not supported for a
+          // non-hardware accelerated Canvas. Skipping animation.
+          "RippleDrawable");
+
+  private final Set<ExpectedLogItem> expectedLogs = new HashSet<>();
+  private final Set<LogItem> observedLogs = new HashSet<>();
+  private final Set<LogItem> unexpectedErrorLogs = new HashSet<>();
+  private final Set<String> expectedTags = new HashSet<>();
+  private final Set<String> observedTags = new HashSet<>();
+
+  private boolean shouldIgnoreMissingLoggedTags = false;
+
+  @Override
+  public Statement apply(final Statement base, Description description) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        base.evaluate();
+        List<LogItem> logs = ShadowLog.getLogs();
+        Map<ExpectedLogItem, Boolean> expectedLogItemMap = new HashMap<>();
+        for (ExpectedLogItem item : expectedLogs) {
+          expectedLogItemMap.put(item, false);
+        }
+        for (LogItem log : logs) {
+          LogItem logItem = new LogItem(log.type, log.tag, log.msg, log.throwable);
+          if (updateExpected(logItem, expectedLogItemMap)) {
+            observedLogs.add(logItem);
+            continue;
+          }
+          if (log.type >= Log.ERROR) {
+            if (UNPREVENTABLE_TAGS.contains(log.tag)) {
+              continue;
+            }
+            if (expectedTags.contains(log.tag)) {
+              observedTags.add(log.tag);
+              continue;
+            }
+            unexpectedErrorLogs.add(log);
+          }
+        }
+        if (!unexpectedErrorLogs.isEmpty() || expectedLogItemMap.containsValue(false)) {
+          Set<ExpectedLogItem> unobservedLogs = new HashSet<>();
+          for (Map.Entry<ExpectedLogItem, Boolean> entry : expectedLogItemMap.entrySet()) {
+            if (!entry.getValue()) {
+              unobservedLogs.add(entry.getKey());
+            }
+          }
+          throw new AssertionError(
+              "Expected and observed logs did not match."
+                  + "\nExpected:                   "
+                  + expectedLogs
+                  + "\nExpected, and observed:     "
+                  + observedLogs
+                  + "\nExpected, but not observed: "
+                  + unobservedLogs
+                  + "\nObserved, but not expected: "
+                  + unexpectedErrorLogs);
+        }
+        if (!expectedTags.equals(observedTags) && !shouldIgnoreMissingLoggedTags) {
+          throw new AssertionError(
+              "Expected and observed tags did not match. "
+                  + "Expected tags should not be used to suppress errors, only expect them."
+                  + "\nExpected:                   "
+                  + expectedTags
+                  + "\nExpected, and observed:     "
+                  + observedTags
+                  + "\nExpected, but not observed: "
+                  + Sets.difference(expectedTags, observedTags));
+        }
+      }
+    };
+  }
+
+  /**
+   * Adds an expected log statement. If this log is not printed during test execution, the test case
+   * will fail.
+   *
+   * <p>This will also match any log statement which contain a throwable as well. For verifying the
+   * throwable, please see {@link #expectLogMessageWithThrowable(int, String, String, Throwable)}.
+   *
+   * <p>Do not use this to suppress failures. Use this to test that expected error cases in your
+   * code cause log messages to be printed.
+   */
+  public void expectLogMessage(int level, String tag, String message) {
+    expectedLogs.add(ExpectedLogItem.create(level, tag, message));
+  }
+
+  /**
+   * Adds an expected log statement using a regular expression. If this log is not printed during
+   * test execution, the test case will fail. When possible, log output should be made determinstic
+   * and {@link #expectLogMessage(int, String, String)} used instead.
+   *
+   * <p>This will also match any log statement which contain a throwable as well. For verifying the
+   * throwable, please see {@link #expectLogMessagePatternWithThrowableMatcher}.
+   *
+   * <p>Do not use this to suppress failures. Use this to test that expected error cases in your
+   * code cause log messages to be printed.
+   */
+  public void expectLogMessagePattern(int level, String tag, Pattern messagePattern) {
+    expectedLogs.add(ExpectedLogItem.create(level, tag, messagePattern));
+  }
+
+  /**
+   * Adds an expected log statement using a regular expression, with an extra check of {@link
+   * Matcher<Throwable>}. If this log is not printed during test execution, the test case will fail.
+   * When possible, log output should be made deterministic and {@link #expectLogMessage(int,
+   * String, String)} used instead.
+   *
+   * <p>Do not use this to suppress failures. Use this to test that expected error cases in your
+   * code cause log messages to be printed.
+   */
+  public void expectLogMessagePatternWithThrowableMatcher(
+      int level, String tag, Pattern messagePattern, Matcher<Throwable> throwableMatcher) {
+    expectedLogs.add(ExpectedLogItem.create(level, tag, messagePattern, throwableMatcher));
+  }
+
+  /**
+   * Adds an expected log statement with extra check of {@link Throwable}. If this log is not
+   * printed during test execution, the test case will fail. Do not use this to suppress failures.
+   * Use this to test that expected error cases in your code cause log messages to be printed.
+   */
+  public void expectLogMessageWithThrowable(
+      int level, String tag, String message, Throwable throwable) {
+    expectLogMessageWithThrowableMatcher(level, tag, message, equalTo(throwable));
+  }
+
+  /**
+   * Adds an expected log statement with extra check of {@link Matcher}. If this log is not printed
+   * during test execution, the test case will fail. Do not use this to suppress failures. Use this
+   * to test that expected error cases in your code cause log messages to be printed.
+   */
+  public void expectLogMessageWithThrowableMatcher(
+      int level, String tag, String message, Matcher<Throwable> throwableMatcher) {
+    expectedLogs.add(ExpectedLogItem.create(level, tag, message, throwableMatcher));
+  }
+
+  /**
+   * Blanket suppress test failures due to errors from a tag. If this tag is not printed at
+   * Log.ERROR during test execution, the test case will fail (unless {@link
+   * #ignoreMissingLoggedTags(boolean)} is used).
+   *
+   * <p>Avoid using this method when possible. Prefer to assert on the presence of a specific
+   * message using {@link #expectLogMessage} in test cases that *intentionally* trigger an error.
+   */
+  public void expectErrorsForTag(String tag) {
+    if (UNPREVENTABLE_TAGS.contains(tag)) {
+      throw new AssertionError("Tag `" + tag + "` is already suppressed.");
+    }
+    expectedTags.add(tag);
+  }
+
+  /**
+   * If set true, tests that call {@link #expectErrorsForTag(String)} but do not log errors for the
+   * given tag will not fail. By default this is false.
+   *
+   * <p>Avoid using this method when possible. Prefer tests that print (or do not print) log
+   * messages deterministically.
+   */
+  public void ignoreMissingLoggedTags(boolean shouldIgnore) {
+    shouldIgnoreMissingLoggedTags = shouldIgnore;
+  }
+
+  private static boolean updateExpected(
+      LogItem logItem, Map<ExpectedLogItem, Boolean> expectedLogItemMap) {
+    for (ExpectedLogItem expectedLogItem : expectedLogItemMap.keySet()) {
+      if (expectedLogItem.type == logItem.type
+          && equals(expectedLogItem.tag, logItem.tag)
+          && matchMessage(expectedLogItem, logItem.msg)
+          && matchThrowable(expectedLogItem, logItem.throwable)) {
+        expectedLogItemMap.put(expectedLogItem, true);
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  private static boolean equals(String a, String b) {
+    return a == null ? b == null : a.equals(b);
+  }
+
+  private static boolean matchMessage(ExpectedLogItem logItem, String msg) {
+    if (logItem.msg != null) {
+      return logItem.msg.equals(msg);
+    }
+
+    if (logItem.msgPattern != null) {
+      return logItem.msgPattern.matcher(msg).matches();
+    }
+
+    return msg == null;
+  }
+
+  private static boolean matchThrowable(ExpectedLogItem logItem, Throwable throwable) {
+    if (logItem.throwableMatcher != null) {
+      return logItem.throwableMatcher.matches(throwable);
+    }
+
+    // Return true in case no throwable / throwable-matcher were specified.
+    return true;
+  }
+
+  private static class ExpectedLogItem {
+    final int type;
+    final String tag;
+    final String msg;
+    final Pattern msgPattern;
+    final Matcher<Throwable> throwableMatcher;
+
+    static ExpectedLogItem create(int type, String tag, String msg) {
+      return new ExpectedLogItem(type, tag, msg, null, null);
+    }
+
+    static ExpectedLogItem create(int type, String tag, Pattern msg) {
+      return new ExpectedLogItem(type, tag, null, msg, null);
+    }
+
+    static ExpectedLogItem create(
+        int type, String tag, String msg, Matcher<Throwable> throwableMatcher) {
+      return new ExpectedLogItem(type, tag, msg, null, throwableMatcher);
+    }
+
+    static ExpectedLogItem create(
+        int type, String tag, Pattern pattern, Matcher<Throwable> throwableMatcher) {
+      return new ExpectedLogItem(type, tag, null, pattern, throwableMatcher);
+    }
+
+    private ExpectedLogItem(
+        int type, String tag, String msg, Pattern msgPattern, Matcher<Throwable> throwableMatcher) {
+      this.type = type;
+      this.tag = tag;
+      this.msg = msg;
+      this.msgPattern = msgPattern;
+      this.throwableMatcher = throwableMatcher;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+
+      if (!(o instanceof ExpectedLogItem)) {
+        return false;
+      }
+
+      ExpectedLogItem log = (ExpectedLogItem) o;
+      return type == log.type
+          && !(tag != null ? !tag.equals(log.tag) : log.tag != null)
+          && !(msg != null ? !msg.equals(log.msg) : log.msg != null)
+          && !(msgPattern != null ? !msgPattern.equals(log.msgPattern) : log.msgPattern != null)
+          && !(throwableMatcher != null
+              ? !throwableMatcher.equals(log.throwableMatcher)
+              : log.throwableMatcher != null);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(type, tag, msg, msgPattern, throwableMatcher);
+    }
+
+    @Override
+    public String toString() {
+      String throwableStr = (throwableMatcher == null) ? "" : (", throwable=" + throwableMatcher);
+      return "ExpectedLogItem{"
+          + "timeString='"
+          + null
+          + '\''
+          + ", type="
+          + type
+          + ", tag='"
+          + tag
+          + '\''
+          + ", msg='"
+          + (msg != null ? msg : msgPattern)
+          + '\''
+          + throwableStr
+          + '}';
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/package-info.java b/robolectric/src/main/java/org/robolectric/package-info.java
new file mode 100644
index 0000000..23eab99
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Package containing main Robolectric classes.
+ */
+package org.robolectric;
\ No newline at end of file
diff --git a/robolectric/src/main/java/org/robolectric/plugins/ConfigConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/ConfigConfigurer.java
new file mode 100644
index 0000000..c655d56
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/ConfigConfigurer.java
@@ -0,0 +1,67 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.Config;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.pluginapi.config.Configurer;
+import org.robolectric.pluginapi.config.GlobalConfigProvider;
+
+/** Provides configuration to Robolectric for its &#064;{@link Config} annotation. */
+@AutoService(Configurer.class)
+public class ConfigConfigurer implements Configurer<Config> {
+
+  private final PackagePropertiesLoader packagePropertiesLoader;
+  private final Config defaultConfig;
+
+  public static Config get(Configuration testConfig) {
+    return testConfig.get(Config.class);
+  }
+
+  protected ConfigConfigurer(PackagePropertiesLoader packagePropertiesLoader) {
+    this(packagePropertiesLoader, () -> new Config.Builder().build());
+  }
+
+  public ConfigConfigurer(
+      PackagePropertiesLoader packagePropertiesLoader,
+      GlobalConfigProvider defaultConfigProvider) {
+    this.packagePropertiesLoader = packagePropertiesLoader;
+    this.defaultConfig = Config.Builder.defaults().overlay(defaultConfigProvider.get()).build();
+  }
+
+  @Override
+  public Class<Config> getConfigClass() {
+    return Config.class;
+  }
+
+  @Nonnull
+  @Override
+  public Config defaultConfig() {
+    return defaultConfig;
+  }
+
+  @Override
+  public Config getConfigFor(@Nonnull String packageName) {
+    Properties properties = packagePropertiesLoader.getConfigProperties(packageName);
+    return Config.Implementation.fromProperties(properties);
+  }
+
+  @Override
+  public Config getConfigFor(@Nonnull Class<?> testClass) {
+    return testClass.getAnnotation(Config.class);
+  }
+
+  @Override
+  public Config getConfigFor(@Nonnull Method method) {
+    return method.getAnnotation(Config.class);
+  }
+
+  @Nonnull
+  @Override
+  public Config merge(@Nonnull Config parentConfig, @Nonnull Config childConfig) {
+    return new Config.Builder(parentConfig).overlay(childConfig).build();
+  }
+
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/ConscryptModeConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/ConscryptModeConfigurer.java
new file mode 100644
index 0000000..406ee14
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/ConscryptModeConfigurer.java
@@ -0,0 +1,75 @@
+package org.robolectric.plugins;
+
+import static com.google.common.base.StandardSystemProperty.OS_ARCH;
+import static com.google.common.base.StandardSystemProperty.OS_NAME;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import java.util.Locale;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.ConscryptMode;
+import org.robolectric.annotation.ConscryptMode.Mode;
+import org.robolectric.pluginapi.config.Configurer;
+
+/** Provides configuration to Robolectric for its @{@link ConscryptMode} annotation. */
+@AutoService(Configurer.class)
+public class ConscryptModeConfigurer implements Configurer<ConscryptMode.Mode> {
+
+  private final Properties systemProperties;
+
+  public ConscryptModeConfigurer(Properties systemProperties) {
+    this.systemProperties = systemProperties;
+  }
+
+  @Override
+  public Class<ConscryptMode.Mode> getConfigClass() {
+    return ConscryptMode.Mode.class;
+  }
+
+  @Nonnull
+  @Override
+  public ConscryptMode.Mode defaultConfig() {
+    String defaultValue = "ON";
+    String os = systemProperties.getProperty(OS_NAME.key(), "").toLowerCase(Locale.US);
+    String arch = systemProperties.getProperty(OS_ARCH.key(), "").toLowerCase(Locale.US);
+    if (os.contains("mac") && arch.equals("aarch64")) {
+      defaultValue = "OFF";
+    }
+    return ConscryptMode.Mode.valueOf(
+        systemProperties.getProperty("robolectric.conscryptMode", defaultValue));
+  }
+
+  @Override
+  public ConscryptMode.Mode getConfigFor(@Nonnull String packageName) {
+    try {
+      Package pkg = Class.forName(packageName + ".package-info").getPackage();
+      return valueFrom(pkg.getAnnotation(ConscryptMode.class));
+    } catch (ClassNotFoundException ignored) {
+      // ignore
+    }
+    return null;
+  }
+
+  @Override
+  public ConscryptMode.Mode getConfigFor(@Nonnull Class<?> testClass) {
+    return valueFrom(testClass.getAnnotation(ConscryptMode.class));
+  }
+
+  @Override
+  public ConscryptMode.Mode getConfigFor(@Nonnull Method method) {
+    return valueFrom(method.getAnnotation(ConscryptMode.class));
+  }
+
+  @Nonnull
+  @Override
+  public ConscryptMode.Mode merge(
+      @Nonnull ConscryptMode.Mode parentConfig, @Nonnull ConscryptMode.Mode childConfig) {
+    // just take the childConfig - since ConscryptMode only has a single 'value' attribute
+    return childConfig;
+  }
+
+  private Mode valueFrom(ConscryptMode conscryptMode) {
+    return conscryptMode == null ? null : conscryptMode.value();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkPicker.java b/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkPicker.java
new file mode 100644
index 0000000..02d29f7
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkPicker.java
@@ -0,0 +1,173 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Properties;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.ConfigUtils;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkPicker;
+import org.robolectric.pluginapi.UsesSdk;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+
+/** Robolectric's default {@link SdkPicker}. */
+@SuppressWarnings("NewApi")
+@AutoService(SdkPicker.class)
+@Priority(Integer.MIN_VALUE)
+public class DefaultSdkPicker implements SdkPicker {
+  @Nonnull private final SdkCollection sdkCollection;
+
+  private final Set<Sdk> enabledSdks;
+  @Nonnull private final Sdk minKnownSdk;
+  @Nonnull private final Sdk maxKnownSdk;
+
+  @Inject
+  public DefaultSdkPicker(@Nonnull SdkCollection sdkCollection, Properties systemProperties) {
+    this(sdkCollection,
+        systemProperties == null ? null : systemProperties.getProperty("robolectric.enabledSdks"));
+  }
+
+  @VisibleForTesting
+  protected DefaultSdkPicker(@Nonnull SdkCollection sdkCollection, String enabledSdks) {
+    this.sdkCollection = sdkCollection;
+    this.enabledSdks = enumerateEnabledSdks(sdkCollection, enabledSdks);
+
+    SortedSet<Sdk> sdks = this.sdkCollection.getKnownSdks();
+    try {
+      minKnownSdk = sdks.first();
+      maxKnownSdk = sdks.last();
+    } catch (NoSuchElementException e) {
+      throw new RuntimeException("no SDKs are supported among " + sdkCollection.getKnownSdks(), e);
+    }
+  }
+
+  /**
+   * Enumerate the SDKs to be used for this test.
+   *
+   * @param configuration a collection of configuration objects, including {@link Config}
+   * @param usesSdk the {@link UsesSdk} for the test
+   * @return the list of candidate {@link Sdk}s.
+   * @since 3.9
+   */
+  @Override
+  @Nonnull
+  public List<Sdk> selectSdks(Configuration configuration, UsesSdk usesSdk) {
+    Config config = configuration.get(Config.class);
+    Set<Sdk> sdks = new TreeSet<>(configuredSdks(config, usesSdk));
+    if (enabledSdks != null) {
+      sdks = Sets.intersection(sdks, enabledSdks);
+    }
+    return Lists.newArrayList(sdks);
+  }
+
+  @Nullable
+  protected static Set<Sdk> enumerateEnabledSdks(
+      SdkCollection sdkCollection, String enabledSdksString) {
+    if (enabledSdksString == null || enabledSdksString.isEmpty()) {
+      return null;
+    } else {
+      Set<Sdk> enabledSdks = new HashSet<>();
+      for (int sdk : ConfigUtils.parseSdkArrayProperty(enabledSdksString)) {
+        enabledSdks.add(sdkCollection.getSdk(sdk));
+      }
+      return enabledSdks;
+    }
+  }
+
+  protected Set<Sdk> configuredSdks(Config config, UsesSdk usesSdk) {
+    int appMinSdk = Math.max(usesSdk.getMinSdkVersion(), minKnownSdk.getApiLevel());
+    int appTargetSdk = Math.max(usesSdk.getTargetSdkVersion(), minKnownSdk.getApiLevel());
+    Integer appMaxSdk = usesSdk.getMaxSdkVersion();
+    if (appMaxSdk == null) {
+      appMaxSdk = maxKnownSdk.getApiLevel();
+    }
+
+    // For min/max SDK ranges...
+    int minSdk = config.minSdk();
+    int maxSdk = config.maxSdk();
+    if (minSdk != -1 || maxSdk != -1) {
+      int rangeMin = decodeSdk(minSdk, appMinSdk, appMinSdk, appTargetSdk, appMaxSdk);
+      int rangeMax = decodeSdk(maxSdk, appMaxSdk, appMinSdk, appTargetSdk, appMaxSdk);
+
+      if (rangeMin > rangeMax && (minSdk == -1 || maxSdk == -1)) {
+        return Collections.emptySet();
+      }
+
+      return sdkRange(rangeMin, rangeMax);
+    }
+
+    // For explicitly-enumerated SDKs...
+    if (config.sdk().length == 0) {
+      if (appTargetSdk < appMinSdk) {
+        throw new IllegalArgumentException(
+            "Package targetSdkVersion=" + appTargetSdk + " < minSdkVersion=" + appMinSdk);
+      } else if (appMaxSdk != 0 && appTargetSdk > appMaxSdk) {
+        throw new IllegalArgumentException(
+            "Package targetSdkVersion=" + appTargetSdk + " > maxSdkVersion=" + appMaxSdk);
+      }
+      return Collections.singleton(sdkCollection.getSdk(appTargetSdk));
+    }
+
+    if (config.sdk().length == 1 && config.sdk()[0] == Config.ALL_SDKS) {
+      return sdkRange(appMinSdk, appMaxSdk);
+    }
+
+    Set<Sdk> sdks = new HashSet<>();
+    for (int sdk : config.sdk()) {
+      int decodedApiLevel = decodeSdk(sdk, appTargetSdk, appMinSdk, appTargetSdk, appMaxSdk);
+      sdks.add(sdkCollection.getSdk(decodedApiLevel));
+    }
+    return sdks;
+  }
+
+  protected int decodeSdk(
+      int value, int defaultSdk, int appMinSdk, int appTargetSdk, int appMaxSdk) {
+    if (value == Config.DEFAULT_VALUE_INT) {
+      return defaultSdk;
+    } else if (value == Config.NEWEST_SDK) {
+      return appMaxSdk;
+    } else if (value == Config.OLDEST_SDK) {
+      return appMinSdk;
+    } else if (value == Config.TARGET_SDK) {
+      return appTargetSdk;
+    } else {
+      return value;
+    }
+  }
+
+  @Nonnull
+  protected Set<Sdk> sdkRange(int minSdk, int maxSdk) {
+    if (maxSdk < minSdk) {
+      throw new IllegalArgumentException("minSdk=" + minSdk + " is greater than maxSdk=" + maxSdk);
+    }
+
+    Set<Sdk> sdks = new HashSet<>();
+    for (Sdk knownSdk : sdkCollection.getKnownSdks()) {
+      int apiLevel = knownSdk.getApiLevel();
+      if (apiLevel >= minSdk && knownSdk.getApiLevel() <= maxSdk) {
+        sdks.add(knownSdk);
+      }
+    }
+
+    if (sdks.isEmpty()) {
+      throw new IllegalArgumentException(
+          "No matching SDKs found for minSdk=" + minSdk + ", maxSdk=" + maxSdk);
+    }
+
+    return sdks;
+  }
+
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkProvider.java b/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkProvider.java
new file mode 100644
index 0000000..9a02d6b
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/DefaultSdkProvider.java
@@ -0,0 +1,181 @@
+package org.robolectric.plugins;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+
+import com.google.auto.service.AutoService;
+import com.google.common.base.Preconditions;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Locale;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import org.robolectric.internal.dependency.DependencyJar;
+import org.robolectric.internal.dependency.DependencyResolver;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkProvider;
+import org.robolectric.util.Util;
+
+/**
+ * Robolectric's default {@link SdkProvider}.
+ *
+ * <p>The list of SDKs is hard-coded. SDKs are obtained from the provided {@link
+ * DependencyResolver}.
+ */
+@SuppressWarnings("NewApi")
+@AutoService(SdkProvider.class)
+@Priority(Integer.MIN_VALUE)
+public class DefaultSdkProvider implements SdkProvider {
+
+  private static final int RUNNING_JAVA_VERSION = Util.getJavaVersion();
+
+  private static final int PREINSTRUMENTED_VERSION = 4;
+
+  private final DependencyResolver dependencyResolver;
+
+  private final SortedMap<Integer, Sdk> knownSdks;
+
+  @Inject
+  public DefaultSdkProvider(DependencyResolver dependencyResolver) {
+    this.dependencyResolver = Preconditions.checkNotNull(dependencyResolver);
+    TreeMap<Integer, Sdk> tmpKnownSdks = new TreeMap<>();
+    populateSdks(tmpKnownSdks);
+
+    this.knownSdks = Collections.unmodifiableSortedMap(tmpKnownSdks);
+  }
+
+  protected void populateSdks(TreeMap<Integer, Sdk> knownSdks) {
+    knownSdks.put(JELLY_BEAN, new DefaultSdk(JELLY_BEAN, "4.1.2_r1", "r1", "REL", 8));
+    knownSdks.put(JELLY_BEAN_MR1, new DefaultSdk(JELLY_BEAN_MR1, "4.2.2_r1.2", "r1", "REL", 8));
+    knownSdks.put(JELLY_BEAN_MR2, new DefaultSdk(JELLY_BEAN_MR2, "4.3_r2", "r1", "REL", 8));
+    knownSdks.put(KITKAT, new DefaultSdk(KITKAT, "4.4_r1", "r2", "REL", 8));
+    knownSdks.put(LOLLIPOP, new DefaultSdk(LOLLIPOP, "5.0.2_r3", "r0", "REL", 8));
+    knownSdks.put(LOLLIPOP_MR1, new DefaultSdk(LOLLIPOP_MR1, "5.1.1_r9", "r2", "REL", 8));
+    knownSdks.put(M, new DefaultSdk(M, "6.0.1_r3", "r1", "REL", 8));
+    knownSdks.put(N, new DefaultSdk(N, "7.0.0_r1", "r1", "REL", 8));
+    knownSdks.put(N_MR1, new DefaultSdk(N_MR1, "7.1.0_r7", "r1", "REL", 8));
+    knownSdks.put(O, new DefaultSdk(O, "8.0.0_r4", "r1", "REL", 8));
+    knownSdks.put(O_MR1, new DefaultSdk(O_MR1, "8.1.0", "4611349", "REL", 8));
+    knownSdks.put(P, new DefaultSdk(P, "9", "4913185-2", "REL", 8));
+    knownSdks.put(Q, new DefaultSdk(Q, "10", "5803371", "REL", 9));
+    knownSdks.put(R, new DefaultSdk(R, "11", "6757853", "REL", 9));
+    knownSdks.put(S, new DefaultSdk(S, "12", "7732740", "REL", 9));
+    knownSdks.put(S_V2, new DefaultSdk(S_V2, "12.1", "8229987", "REL", 9));
+    knownSdks.put(TIRAMISU, new DefaultSdk(TIRAMISU, "13", "9030017", "Tiramisu", 9));
+  }
+
+  @Override
+  public Collection<Sdk> getSdks() {
+    return Collections.unmodifiableCollection(knownSdks.values());
+  }
+
+  /** Represents an Android SDK stored at Maven Central. */
+  public class DefaultSdk extends Sdk {
+
+    private final String androidVersion;
+    private final String robolectricVersion;
+    private final String codeName;
+    private final int requiredJavaVersion;
+    private Path jarPath;
+
+    public DefaultSdk(
+        int apiLevel,
+        String androidVersion,
+        String robolectricVersion,
+        String codeName,
+        int requiredJavaVersion) {
+      super(apiLevel);
+      this.androidVersion = androidVersion;
+      this.robolectricVersion = robolectricVersion;
+      this.codeName = codeName;
+      this.requiredJavaVersion = requiredJavaVersion;
+      Preconditions.checkNotNull(dependencyResolver);
+    }
+
+    @Override
+    public String getAndroidVersion() {
+      return androidVersion;
+    }
+
+    @Override
+    public String getAndroidCodeName() {
+      return codeName;
+    }
+
+    private DependencyJar getAndroidSdkDependency() {
+      if (!isSupported()) {
+        throw new UnsupportedClassVersionError(getUnsupportedMessage());
+      }
+
+      if (Boolean.parseBoolean(System.getProperty("robolectric.usePreinstrumentedJars", "true"))) {
+        String version =
+            String.join(
+                "-",
+                getAndroidVersion(),
+                "robolectric",
+                robolectricVersion,
+                "i" + PREINSTRUMENTED_VERSION);
+        return new DependencyJar("org.robolectric", "android-all-instrumented", version, null);
+      } else {
+        String version = String.join("-", getAndroidVersion(), "robolectric", robolectricVersion);
+        return new DependencyJar("org.robolectric", "android-all", version, null);
+      }
+    }
+
+    @Override
+    public synchronized Path getJarPath() {
+      if (jarPath == null) {
+        URL url = dependencyResolver.getLocalArtifactUrl(getAndroidSdkDependency());
+        jarPath = Util.pathFrom(url);
+
+        if (!Files.exists(jarPath)) {
+          throw new RuntimeException("SDK " + getApiLevel() + " jar not present at " + jarPath);
+        }
+      }
+      return jarPath;
+    }
+
+    @Override
+    public boolean isSupported() {
+      return requiredJavaVersion <= RUNNING_JAVA_VERSION;
+    }
+
+    @Override
+    public String getUnsupportedMessage() {
+      return String.format(
+          Locale.getDefault(),
+          "Android SDK %d requires Java %d (have Java %d)",
+          getApiLevel(),
+          requiredJavaVersion,
+          RUNNING_JAVA_VERSION);
+    }
+
+    @Override
+    public void verifySupportedSdk(String testClassName) {
+      if (isKnown() && !isSupported()) {
+        throw new UnsupportedOperationException(
+            "Failed to create a Robolectric sandbox: " + getUnsupportedMessage());
+      }
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/GetInstallerPackageNameModeConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/GetInstallerPackageNameModeConfigurer.java
new file mode 100644
index 0000000..6811e17
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/GetInstallerPackageNameModeConfigurer.java
@@ -0,0 +1,64 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.GetInstallerPackageNameMode;
+import org.robolectric.pluginapi.config.Configurer;
+
+/**
+ * Provides configuration to Robolectric for its &#064;{@link GetInstallerPackageNameMode}
+ * annotation.
+ */
+@AutoService(Configurer.class)
+public class GetInstallerPackageNameModeConfigurer
+    implements Configurer<GetInstallerPackageNameMode.Mode> {
+
+  @Override
+  public Class<GetInstallerPackageNameMode.Mode> getConfigClass() {
+    return GetInstallerPackageNameMode.Mode.class;
+  }
+
+  @Nonnull
+  @Override
+  public GetInstallerPackageNameMode.Mode defaultConfig() {
+    // TODO: switch to REALISTIC
+    return GetInstallerPackageNameMode.Mode.LEGACY;
+  }
+
+  @Override
+  public GetInstallerPackageNameMode.Mode getConfigFor(@Nonnull String packageName) {
+    try {
+      Package pkg = Class.forName(packageName + ".package-info").getPackage();
+      return valueFrom(pkg.getAnnotation(GetInstallerPackageNameMode.class));
+    } catch (ClassNotFoundException e) {
+      // ignore
+    }
+    return null;
+  }
+
+  @Override
+  public GetInstallerPackageNameMode.Mode getConfigFor(@Nonnull Class<?> testClass) {
+    return valueFrom(testClass.getAnnotation(GetInstallerPackageNameMode.class));
+  }
+
+  @Override
+  public GetInstallerPackageNameMode.Mode getConfigFor(@Nonnull Method method) {
+    return valueFrom(method.getAnnotation(GetInstallerPackageNameMode.class));
+  }
+
+  @Nonnull
+  @Override
+  public GetInstallerPackageNameMode.Mode merge(
+      @Nonnull GetInstallerPackageNameMode.Mode parentConfig,
+      @Nonnull GetInstallerPackageNameMode.Mode childConfig) {
+    // just take the childConfig - since GetInstallerPackageNameMode only has a single 'value'
+    // attribute
+    return childConfig;
+  }
+
+  private static GetInstallerPackageNameMode.Mode valueFrom(
+      GetInstallerPackageNameMode looperMode) {
+    return looperMode == null ? null : looperMode.value();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/HierarchicalConfigurationStrategy.java b/robolectric/src/main/java/org/robolectric/plugins/HierarchicalConfigurationStrategy.java
new file mode 100644
index 0000000..cfa250f
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/HierarchicalConfigurationStrategy.java
@@ -0,0 +1,189 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import javax.annotation.Priority;
+import org.robolectric.pluginapi.config.ConfigurationStrategy;
+import org.robolectric.pluginapi.config.Configurer;
+
+/**
+ * Robolectric's default {@link ConfigurationStrategy}.
+ *
+ * @see <a href="http://robolectric.org/configuring/">Configuring Robolectric</a>.
+ */
+@SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
+@AutoService(ConfigurationStrategy.class)
+@Priority(Integer.MIN_VALUE)
+public class HierarchicalConfigurationStrategy implements ConfigurationStrategy {
+
+  /** The cache is sized to avoid repeated resolutions for any node. */
+  private int highWaterMark = 0;
+  private final Map<String, Object[]> cache =
+      new LinkedHashMap<String, Object[]>() {
+        @Override
+        protected boolean removeEldestEntry(Map.Entry<String, Object[]> eldest) {
+          return size() > highWaterMark + 1;
+        }
+      };
+
+  private final Configurer<?>[] configurers;
+  private final Object[] defaultConfigs;
+
+  public HierarchicalConfigurationStrategy(Configurer<?>... configurers) {
+    this.configurers = configurers;
+
+    defaultConfigs = new Object[configurers.length];
+    for (int i = 0; i < configurers.length; i++) {
+      Configurer<?> configurer = configurers[i];
+      defaultConfigs[i] = configurer.defaultConfig();
+    }
+  }
+
+  @Override
+  public ConfigurationImpl getConfig(Class<?> testClass, Method method) {
+    final Counter counter = new Counter();
+    Object[] configs = cache(testClass.getName() + "/" + method.getName(), counter, s -> {
+      counter.incr();
+      Object[] methodConfigs = getConfigs(counter,
+          configurer -> configurer.getConfigFor(method));
+      return merge(getFirstClassConfig(testClass, counter), methodConfigs);
+    });
+
+    ConfigurationImpl testConfig = new ConfigurationImpl();
+    for (int i = 0; i < configurers.length; i++) {
+      put(testConfig, configurers[i].getConfigClass(), configs[i]);
+    }
+
+    return testConfig;
+  }
+
+  private Object[] getFirstClassConfig(Class<?> testClass, Counter counter) {
+    // todo: should parent class configs have lower precedence than package configs?
+    return cache("first:" + testClass, counter, s -> {
+          Object[] configsForClass = getClassConfig(testClass, counter);
+      Package pkg = testClass.getPackage();
+      Object[] configsForPackage = getPackageConfig(pkg == null ? "" : pkg.getName(), counter);
+          return merge(configsForPackage, configsForClass);
+        }
+    );
+  }
+
+  private Object[] getPackageConfig(String packageName, Counter counter) {
+    return cache(packageName, counter, s -> {
+          Object[] packageConfigs = getConfigs(counter,
+              configurer -> configurer.getConfigFor(packageName));
+          String parentPackage = parentPackage(packageName);
+          if (parentPackage == null) {
+            return merge(defaultConfigs, packageConfigs);
+          } else {
+            Object[] packageConfig = getPackageConfig(parentPackage, counter);
+            return merge(packageConfig, packageConfigs);
+          }
+        });
+  }
+
+  private String parentPackage(String name) {
+    if (name.isEmpty()) {
+      return null;
+    }
+    int lastDot = name.lastIndexOf('.');
+    return lastDot > -1 ? name.substring(0, lastDot) : "";
+  }
+
+  private Object[] getClassConfig(Class<?> testClass, Counter counter) {
+    return cache(testClass.getName(), counter, s -> {
+      Object[] classConfigs = getConfigs(counter, configurer -> configurer.getConfigFor(testClass));
+
+      Class<?> superclass = testClass.getSuperclass();
+      if (superclass != Object.class) {
+        Object[] superclassConfigs = getClassConfig(superclass, counter);
+        return merge(superclassConfigs, classConfigs);
+      }
+      return classConfigs;
+    });
+  }
+
+  private Object[] cache(String name, Counter counter, Function<String, Object[]> fn) {
+    // make sure the cache is optimally sized this test suite
+    if (counter.depth > highWaterMark) {
+      highWaterMark = counter.depth;
+    }
+
+    Object[] configs = cache.get(name);
+    if (configs == null) {
+      configs = fn.apply(name);
+      cache.put(name, configs);
+    }
+    return configs;
+  }
+
+  interface GetConfig {
+    Object getConfig(Configurer<?> configurer);
+  }
+
+  private Object[] getConfigs(Counter counter, GetConfig getConfig) {
+    counter.incr();
+
+    Object[] objects = new Object[configurers.length];
+    for (int i = 0; i < configurers.length; i++) {
+      objects[i] = getConfig.getConfig(configurers[i]);
+    }
+    return objects;
+  }
+
+  private void put(ConfigurationImpl testConfig, Class<?> configClass, Object config) {
+    testConfig.put((Class) configClass, config);
+  }
+
+  private Object[] merge(Object[] parentConfigs, Object[] childConfigs) {
+    Object[] objects = new Object[configurers.length];
+    for (int i = 0; i < configurers.length; i++) {
+      Configurer configurer = configurers[i];
+      Object childConfig = childConfigs[i];
+      Object parentConfig = parentConfigs[i];
+      objects[i] = childConfig == null
+          ? parentConfig
+          : parentConfig == null
+              ? childConfig
+              : configurer.merge(parentConfig, childConfig);
+    }
+    return objects;
+  }
+
+  public static class ConfigurationImpl implements Configuration {
+
+    private final Map<Class<?>, Object> configs = new HashMap<>();
+
+    public <T> void put(Class<T> klass, T instance) {
+      configs.put(klass, instance);
+    }
+
+    @Override
+    public <T> T get(Class<T> klass) {
+      return klass.cast(configs.get(klass));
+    }
+
+    @Override
+    public Set<Class<?>> keySet() {
+      return configs.keySet();
+    }
+
+    @Override
+    public Map<Class<?>, Object> map() {
+      return configs;
+    }
+  }
+
+  private static class Counter {
+    private int depth = 0;
+
+    void incr() {
+      depth++;
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/LazyApplicationConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/LazyApplicationConfigurer.java
new file mode 100644
index 0000000..bdec9d2
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/LazyApplicationConfigurer.java
@@ -0,0 +1,67 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+import org.robolectric.pluginapi.config.Configurer;
+
+/**
+ * A {@link org.robolectric.pluginapi.config.Configurer} that reads the {@link LazyApplication} to
+ * dictate whether Robolectric should lazily instantiate the Application under test (as well as the
+ * test Instrumentation).
+ */
+@AutoService(Configurer.class)
+public class LazyApplicationConfigurer implements Configurer<LazyLoad> {
+
+  @Override
+  public Class<LazyLoad> getConfigClass() {
+    return LazyLoad.class;
+  }
+
+  @Nonnull
+  @Override
+  public LazyLoad defaultConfig() {
+    return LazyLoad.OFF;
+  }
+
+  @Nonnull
+  @Override
+  public LazyLoad getConfigFor(@Nonnull String packageName) {
+    try {
+      Package pkg = Class.forName(packageName + ".package-info").getPackage();
+      if (pkg.isAnnotationPresent(LazyApplication.class)) {
+        return pkg.getAnnotation(LazyApplication.class).value();
+      }
+    } catch (ClassNotFoundException e) {
+      // ignore
+    }
+    return null;
+  }
+
+  @Override
+  public LazyLoad getConfigFor(@Nonnull Class<?> testClass) {
+    if (testClass.isAnnotationPresent(LazyApplication.class)) {
+      return testClass.getAnnotation(LazyApplication.class).value();
+    } else {
+      return null;
+    }
+  }
+
+  @Override
+  public LazyLoad getConfigFor(@Nonnull Method method) {
+    if (method.isAnnotationPresent(LazyApplication.class)) {
+      return method.getAnnotation(LazyApplication.class).value();
+    } else {
+      return null;
+    }
+  }
+
+  /** "Merges" two configurations together. Child configuration always overrides the parent */
+  @Nonnull
+  @Override
+  public LazyLoad merge(@Nonnull LazyLoad parentConfig, @Nonnull LazyLoad childConfig) {
+    return childConfig;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/LegacyDependencyResolver.java b/robolectric/src/main/java/org/robolectric/plugins/LegacyDependencyResolver.java
new file mode 100644
index 0000000..aa72656
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/LegacyDependencyResolver.java
@@ -0,0 +1,122 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.File;
+import java.net.URL;
+import java.nio.file.Paths;
+import java.util.Properties;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import org.robolectric.internal.dependency.DependencyJar;
+import org.robolectric.internal.dependency.DependencyResolver;
+import org.robolectric.internal.dependency.LocalDependencyResolver;
+import org.robolectric.internal.dependency.PropertiesDependencyResolver;
+import org.robolectric.res.Fs;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Robolectric's historical dependency resolver (which is currently still the default), which is
+ * used by {@link org.robolectric.plugins.DefaultSdkProvider} to locate SDK jars.
+ *
+ * <p>Robolectric will attempt to find SDK jars in the following order:
+ *
+ * <ol>
+ *   <li>If the system property {@code robolectric-deps.properties} is set, then Robolectric will
+ *       look for a file with the specified path containing SDK references as described {@link
+ *       PropertiesDependencyResolver here}.
+ *   <li>If the system property {@code robolectric.dependency.dir} is set, then Robolectric will
+ *       look for SDK jars in the given directory with Maven artifact-style names (e.g. {@code
+ *       android-all-7.1.0_r7-robolectric-r1.jar}).
+ *   <li>If the system property {@code robolectric.offline} is true, then Robolectric will look for
+ *       SDK jars in the current working directory with Maven artifact-style names.
+ *   <li>If a resource file named {@code robolectric-deps.properties} is found on the classpath,
+ *       then Robolectric will resolve SDKs with that file as described {@link
+ *       PropertiesDependencyResolver here}.
+ *   <li>Otherwise the jars will be downloaded from Maven Central and cached locally.
+ * </ol>
+ *
+ * If you require a hermetic build, we recommend either specifying the {@code
+ * robolectric.dependency.dir} system property, or providing your own {@link
+ * org.robolectric.pluginapi.SdkProvider}.
+ */
+@AutoService(DependencyResolver.class)
+@Priority(Integer.MIN_VALUE)
+@SuppressWarnings("NewApi")
+public class LegacyDependencyResolver implements DependencyResolver {
+
+  private final DependencyResolver delegate;
+
+  @Inject
+  public LegacyDependencyResolver(Properties properties) {
+    this(properties, new MaybeAClassLoader(LegacyDependencyResolver.class.getClassLoader()));
+  }
+
+  @VisibleForTesting
+  LegacyDependencyResolver(Properties properties, DefinitelyNotAClassLoader classLoader) {
+    this.delegate = pickOne(properties, classLoader);
+  }
+
+  private static DependencyResolver pickOne(
+      Properties properties, DefinitelyNotAClassLoader classLoader) {
+    String propPath = properties.getProperty("robolectric-deps.properties");
+    if (propPath != null) {
+      return new PropertiesDependencyResolver(Paths.get(propPath));
+    }
+
+    String dependencyDir = properties.getProperty("robolectric.dependency.dir");
+    if (dependencyDir != null
+        || Boolean.parseBoolean(properties.getProperty("robolectric.offline"))) {
+      return new LocalDependencyResolver(new File(dependencyDir == null ? "." : dependencyDir));
+    }
+
+    URL buildPathPropertiesUrl = classLoader.getResource("robolectric-deps.properties");
+    if (buildPathPropertiesUrl != null) {
+      return new PropertiesDependencyResolver(Paths.get(Fs.toUri(buildPathPropertiesUrl)));
+    }
+
+    Class<?> clazz;
+    try {
+      clazz = classLoader.loadClass("org.robolectric.internal.dependency.MavenDependencyResolver");
+      return (DependencyResolver) ReflectionHelpers.callConstructor(clazz);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public URL getLocalArtifactUrl(DependencyJar dependency) {
+    return delegate.getLocalArtifactUrl(dependency);
+  }
+
+  @Override
+  public URL[] getLocalArtifactUrls(DependencyJar dependency) {
+    return delegate.getLocalArtifactUrls(dependency);
+  }
+
+  interface DefinitelyNotAClassLoader {
+
+    URL getResource(String name);
+
+    Class<?> loadClass(String name) throws ClassNotFoundException;
+  }
+
+  private static class MaybeAClassLoader implements DefinitelyNotAClassLoader {
+
+    private final ClassLoader delegate;
+
+    public MaybeAClassLoader(ClassLoader delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public URL getResource(String name) {
+      return delegate.getResource(name);
+    }
+
+    @Override
+    public Class<?> loadClass(String name) throws ClassNotFoundException {
+      return delegate.loadClass(name);
+    }
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/LooperModeConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/LooperModeConfigurer.java
new file mode 100644
index 0000000..2cb7049
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/LooperModeConfigurer.java
@@ -0,0 +1,66 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.pluginapi.config.Configurer;
+
+/** Provides configuration to Robolectric for its &#064;{@link LooperMode} annotation. */
+@AutoService(Configurer.class)
+public class LooperModeConfigurer implements Configurer<LooperMode.Mode> {
+
+  private Properties systemProperties;
+
+  public LooperModeConfigurer(Properties systemProperties) {
+    this.systemProperties = systemProperties;
+  }
+
+  @Override
+  public Class<LooperMode.Mode> getConfigClass() {
+    return LooperMode.Mode.class;
+  }
+
+  @Nonnull
+  @Override
+  public LooperMode.Mode defaultConfig() {
+    return LooperMode.Mode.valueOf(
+        systemProperties.getProperty("robolectric.looperMode", "PAUSED"));
+  }
+
+  @Override
+  public LooperMode.Mode getConfigFor(@Nonnull String packageName) {
+    try {
+      Package pkg = Class.forName(packageName + ".package-info").getPackage();
+      return valueFrom(pkg.getAnnotation(LooperMode.class));
+    } catch (ClassNotFoundException e) {
+      // ignore
+    }
+    return null;
+  }
+
+  @Override
+  public LooperMode.Mode getConfigFor(@Nonnull Class<?> testClass) {
+    return valueFrom(testClass.getAnnotation(LooperMode.class));
+  }
+
+  @Override
+  public LooperMode.Mode getConfigFor(@Nonnull Method method) {
+    return valueFrom(method.getAnnotation(LooperMode.class));
+  }
+
+  @Nonnull
+  @Override
+  public LooperMode.Mode merge(
+      @Nonnull LooperMode.Mode parentConfig, @Nonnull LooperMode.Mode childConfig) {
+    // just take the childConfig - since LooperMode only has a single 'value'
+    // attribute
+    return childConfig;
+  }
+
+  private Mode valueFrom(LooperMode looperMode) {
+    return looperMode == null ? null : looperMode.value();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/PackagePropertiesLoader.java b/robolectric/src/main/java/org/robolectric/plugins/PackagePropertiesLoader.java
new file mode 100644
index 0000000..e719b87
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/PackagePropertiesLoader.java
@@ -0,0 +1,66 @@
+package org.robolectric.plugins;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.pluginapi.config.Configurer;
+
+/**
+ * Provides cached access to {@code robolectric-properties} files, for all your configuration needs!
+ *
+ * <p>Used by {@link ConfigConfigurer} to support package configuration (see [Configuring
+ * Robolectric](http://robolectric.org/configuring/) but it may be useful for other {@link
+ * Configurer}s as well.
+ */
+@SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
+public class PackagePropertiesLoader {
+
+  /**
+   * We should get very high cache hit rates even with a tiny cache if we're called sequentially
+   * by multiple {@link Configurer}s for the same package.
+   */
+  private final Map<String, Properties> cache = new LinkedHashMap<String, Properties>() {
+    @Override
+    protected boolean removeEldestEntry(Map.Entry<String, Properties> eldest) {
+      return size() > 3;
+    }
+  };
+
+  /**
+   * Return a {@link Properties} file for the given package name, or {@code null} if none is
+   * available.
+   *
+   * @since 3.2
+   */
+  public Properties getConfigProperties(@Nonnull String packageName) {
+    return cache.computeIfAbsent(packageName, s -> {
+      StringBuilder buf = new StringBuilder();
+      if (!packageName.isEmpty()) {
+        buf.append(packageName.replace('.', '/'));
+        buf.append('/');
+      }
+      buf.append(RobolectricTestRunner.CONFIG_PROPERTIES);
+      final String resourceName = buf.toString();
+
+      try (InputStream resourceAsStream = getResourceAsStream(resourceName)) {
+        if (resourceAsStream == null) {
+          return null;
+        }
+        Properties properties = new Properties();
+        properties.load(resourceAsStream);
+        return properties;
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    });
+  }
+
+  // visible for testing
+  InputStream getResourceAsStream(String resourceName) {
+    return getClass().getClassLoader().getResourceAsStream(resourceName);
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/SQLiteModeConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/SQLiteModeConfigurer.java
new file mode 100644
index 0000000..81c70ef
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/SQLiteModeConfigurer.java
@@ -0,0 +1,74 @@
+package org.robolectric.plugins;
+
+import static com.google.common.base.StandardSystemProperty.OS_NAME;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import java.util.Locale;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.annotation.SQLiteMode.Mode;
+import org.robolectric.pluginapi.config.Configurer;
+
+/** Provides configuration to Robolectric for its @{@link SQLiteMode} annotation. */
+@AutoService(Configurer.class)
+public class SQLiteModeConfigurer implements Configurer<SQLiteMode.Mode> {
+
+  private final Properties systemProperties;
+
+  public SQLiteModeConfigurer(Properties systemProperties) {
+    this.systemProperties = systemProperties;
+  }
+
+  @Override
+  public Class<SQLiteMode.Mode> getConfigClass() {
+    return SQLiteMode.Mode.class;
+  }
+
+  @Nonnull
+  @Override
+  public SQLiteMode.Mode defaultConfig() {
+    String defaultValue = "NATIVE";
+    String os = systemProperties.getProperty(OS_NAME.key(), "").toLowerCase(Locale.US);
+    // NATIVE SQLite mode not supported on Windows
+    if (os.contains("win")) {
+      defaultValue = "LEGACY";
+    }
+    return SQLiteMode.Mode.valueOf(
+        systemProperties.getProperty("robolectric.sqliteMode", defaultValue));
+  }
+
+  @Override
+  public SQLiteMode.Mode getConfigFor(@Nonnull String packageName) {
+    try {
+      Package pkg = Class.forName(packageName + ".package-info").getPackage();
+      return valueFrom(pkg.getAnnotation(SQLiteMode.class));
+    } catch (ClassNotFoundException e) {
+      // ignore
+    }
+    return null;
+  }
+
+  @Override
+  public SQLiteMode.Mode getConfigFor(@Nonnull Class<?> testClass) {
+    return valueFrom(testClass.getAnnotation(SQLiteMode.class));
+  }
+
+  @Override
+  public SQLiteMode.Mode getConfigFor(@Nonnull Method method) {
+    return valueFrom(method.getAnnotation(SQLiteMode.class));
+  }
+
+  @Nonnull
+  @Override
+  public SQLiteMode.Mode merge(
+      @Nonnull SQLiteMode.Mode parentConfig, @Nonnull SQLiteMode.Mode childConfig) {
+    // just take the childConfig - since SQLiteMode only has a single 'value' attribute
+    return childConfig;
+  }
+
+  private Mode valueFrom(SQLiteMode sqliteMode) {
+    return sqliteMode == null ? null : sqliteMode.value();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/SdkCollection.java b/robolectric/src/main/java/org/robolectric/plugins/SdkCollection.java
new file mode 100644
index 0000000..c34c9fd
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/SdkCollection.java
@@ -0,0 +1,59 @@
+package org.robolectric.plugins;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import javax.inject.Inject;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkProvider;
+
+/**
+ * Holds and provides details on the list of known SDKs.
+ */
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+public class SdkCollection {
+
+  private final SortedMap<Integer, Sdk> knownSdks = new TreeMap<>();
+  private final SortedSet<Sdk> supportedSdks;
+
+  @Inject
+  public SdkCollection(SdkProvider sdkProvider) {
+    Collection<Sdk> knownSdks = sdkProvider.getSdks();
+    SortedSet<Sdk> supportedSdks = new TreeSet<>();
+    knownSdks.forEach((sdk) -> {
+      if (this.knownSdks.put(sdk.getApiLevel(), sdk) != null) {
+        throw new IllegalArgumentException(
+            String.format("duplicate SDKs for API level %d", sdk.getApiLevel()));
+      }
+
+      if (sdk.isSupported()) {
+        supportedSdks.add(sdk);
+      } else {
+        System.err.printf(
+            "[Robolectric] WARN: %s. Tests won't be run on SDK %d unless explicitly requested.\n",
+            sdk.getUnsupportedMessage(), sdk.getApiLevel());
+      }
+    });
+    this.supportedSdks = Collections.unmodifiableSortedSet(supportedSdks);
+  }
+
+  public Sdk getSdk(int apiLevel) {
+    Sdk sdk = knownSdks.get(apiLevel);
+    return sdk == null ? new UnknownSdk(apiLevel) : sdk;
+  }
+
+  public Sdk getMaxSupportedSdk() {
+    return supportedSdks.last();
+  }
+
+  public SortedSet<Sdk> getKnownSdks() {
+    return new TreeSet<>(knownSdks.values());
+  }
+
+  public SortedSet<Sdk> getSupportedSdks() {
+    return supportedSdks;
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/TextLayoutModeConfigurer.java b/robolectric/src/main/java/org/robolectric/plugins/TextLayoutModeConfigurer.java
new file mode 100644
index 0000000..2c52ad0
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/TextLayoutModeConfigurer.java
@@ -0,0 +1,58 @@
+package org.robolectric.plugins;
+
+import com.google.auto.service.AutoService;
+import java.lang.reflect.Method;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.TextLayoutMode;
+import org.robolectric.annotation.TextLayoutMode.Mode;
+import org.robolectric.pluginapi.config.Configurer;
+
+/** Provides configuration to Robolectric for its &#064;{@link TextLayoutMode} annotation. */
+@AutoService(Configurer.class)
+public class TextLayoutModeConfigurer implements Configurer<TextLayoutMode.Mode> {
+
+  @Override
+  public Class<TextLayoutMode.Mode> getConfigClass() {
+    return TextLayoutMode.Mode.class;
+  }
+
+  @Nonnull
+  @Override
+  public TextLayoutMode.Mode defaultConfig() {
+    return TextLayoutMode.Mode.REALISTIC;
+  }
+
+  @Override
+  public TextLayoutMode.Mode getConfigFor(@Nonnull String packageName) {
+    try {
+      Package pkg = Class.forName(packageName + ".package-info").getPackage();
+      return valueFrom(pkg.getAnnotation(TextLayoutMode.class));
+    } catch (ClassNotFoundException e) {
+      // ignore
+    }
+    return null;
+  }
+
+  @Override
+  public TextLayoutMode.Mode getConfigFor(@Nonnull Class<?> testClass) {
+    return valueFrom(testClass.getAnnotation(TextLayoutMode.class));
+  }
+
+  @Override
+  public TextLayoutMode.Mode getConfigFor(@Nonnull Method method) {
+    return valueFrom(method.getAnnotation(TextLayoutMode.class));
+  }
+
+  @Nonnull
+  @Override
+  public TextLayoutMode.Mode merge(
+      @Nonnull TextLayoutMode.Mode parentConfig, @Nonnull TextLayoutMode.Mode childConfig) {
+    // just take the childConfig - since TextLayoutMode only has a single 'value'
+    // attribute
+    return childConfig;
+  }
+
+  private Mode valueFrom(TextLayoutMode looperMode) {
+    return looperMode == null ? null : looperMode.value();
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/plugins/UnknownSdk.java b/robolectric/src/main/java/org/robolectric/plugins/UnknownSdk.java
new file mode 100644
index 0000000..bb87ebe
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/plugins/UnknownSdk.java
@@ -0,0 +1,47 @@
+package org.robolectric.plugins;
+
+import java.nio.file.Path;
+import java.util.Locale;
+import org.robolectric.pluginapi.Sdk;
+
+class UnknownSdk extends Sdk {
+
+  UnknownSdk(int apiLevel) {
+    super(apiLevel);
+  }
+
+  @Override
+  public String getAndroidVersion() {
+    throw new IllegalArgumentException(getUnsupportedMessage());
+  }
+
+  @Override
+  public String getAndroidCodeName() {
+    throw new IllegalArgumentException(getUnsupportedMessage());
+  }
+
+  @Override
+  public Path getJarPath() {
+    throw new IllegalArgumentException(getUnsupportedMessage());
+  }
+
+  @Override
+  public boolean isSupported() {
+    return false;
+  }
+
+  @Override
+  public String getUnsupportedMessage() {
+    return String.format(Locale.getDefault(), "API level %d is not available", getApiLevel());
+  }
+
+  @Override
+  public boolean isKnown() {
+    return false;
+  }
+
+  @Override
+  public void verifySupportedSdk(String testClassName) {
+    throw new IllegalArgumentException(getUnsupportedMessage());
+  }
+}
diff --git a/robolectric/src/main/java/org/robolectric/util/FragmentTestUtil.java b/robolectric/src/main/java/org/robolectric/util/FragmentTestUtil.java
new file mode 100644
index 0000000..c6ded30
--- /dev/null
+++ b/robolectric/src/main/java/org/robolectric/util/FragmentTestUtil.java
@@ -0,0 +1,59 @@
+package org.robolectric.util;
+
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.os.Bundle;
+import android.widget.LinearLayout;
+import org.robolectric.Robolectric;
+
+/**
+ * @deprecated Please use {@link Robolectric#buildFragment(Class)} instead. This will be
+ * removed in a forthcoming release,
+ */
+@Deprecated
+public final class FragmentTestUtil {
+  
+  public static void startFragment(Fragment fragment) {
+    buildFragmentManager(FragmentUtilActivity.class)
+        .beginTransaction().add(fragment, null).commit();
+    shadowMainLooper().idleIfPaused();
+  }
+
+  public static void startFragment(Fragment fragment, Class<? extends Activity> activityClass) {
+    buildFragmentManager(activityClass)
+        .beginTransaction().add(fragment, null).commit();
+    shadowMainLooper().idleIfPaused();
+  }
+
+  public static void startVisibleFragment(Fragment fragment) {
+    buildFragmentManager(FragmentUtilActivity.class)
+        .beginTransaction().add(1, fragment, null).commit();
+    shadowMainLooper().idleIfPaused();
+  }
+
+  public static void startVisibleFragment(Fragment fragment,
+      Class<? extends Activity> activityClass, int containerViewId) {
+    buildFragmentManager(activityClass)
+        .beginTransaction().add(containerViewId, fragment, null).commit();
+    shadowMainLooper().idleIfPaused();
+  }
+
+  private static FragmentManager buildFragmentManager(Class<? extends Activity> activityClass) {
+    Activity activity = Robolectric.setupActivity(activityClass);
+    return activity.getFragmentManager();
+  }
+
+  private static class FragmentUtilActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      LinearLayout view = new LinearLayout(this);
+      view.setId(1);
+
+      setContentView(view);
+    }
+  }
+}
diff --git a/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.ThreadChecker b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.ThreadChecker
new file mode 100644
index 0000000..55104ea
--- /dev/null
+++ b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.ThreadChecker
@@ -0,0 +1 @@
+org.robolectric.android.internal.NoOpThreadChecker
diff --git a/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.app.ActivityInvoker b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.app.ActivityInvoker
new file mode 100644
index 0000000..e9944b5
--- /dev/null
+++ b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.app.ActivityInvoker
@@ -0,0 +1 @@
+org.robolectric.android.internal.LocalActivityInvoker
diff --git a/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.content.PermissionGranter b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.content.PermissionGranter
new file mode 100644
index 0000000..3bcad1c
--- /dev/null
+++ b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.content.PermissionGranter
@@ -0,0 +1 @@
+org.robolectric.android.internal.LocalPermissionGranter
diff --git a/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.os.ControlledLooper b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.os.ControlledLooper
new file mode 100644
index 0000000..0e99575
--- /dev/null
+++ b/robolectric/src/main/resources/META-INF/services/androidx.test.internal.platform.os.ControlledLooper
@@ -0,0 +1 @@
+org.robolectric.android.internal.LocalControlledLooper
diff --git a/robolectric/src/main/resources/META-INF/services/androidx.test.platform.ui.UiController b/robolectric/src/main/resources/META-INF/services/androidx.test.platform.ui.UiController
new file mode 100644
index 0000000..523d6ee
--- /dev/null
+++ b/robolectric/src/main/resources/META-INF/services/androidx.test.platform.ui.UiController
@@ -0,0 +1 @@
+org.robolectric.android.internal.LocalUiController
diff --git a/robolectric/src/main/resources/robolectric-version.properties b/robolectric/src/main/resources/robolectric-version.properties
new file mode 100644
index 0000000..6c47e07
--- /dev/null
+++ b/robolectric/src/main/resources/robolectric-version.properties
@@ -0,0 +1 @@
+robolectric.version=${project.version}
\ No newline at end of file
diff --git a/robolectric/src/test/AndroidManifest.xml b/robolectric/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..63fd7d5
--- /dev/null
+++ b/robolectric/src/test/AndroidManifest.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Manifest for tests. Required because the AOSP resource merger requires leaf nodes to provide
+  the following elements directly:-
+  <uses-sdk>
+  <uses-permission>
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric">
+  <uses-sdk android:minSdkVersion="14" android:targetSdkVersion="27"/>
+
+  <!-- For SettingsTest -->
+  <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+  <uses-permission android:name="android.permission.GET_TASKS"/>
+</manifest>
diff --git a/robolectric/src/test/java/com/foo/Receiver.java b/robolectric/src/test/java/com/foo/Receiver.java
new file mode 100644
index 0000000..719cf51
--- /dev/null
+++ b/robolectric/src/test/java/com/foo/Receiver.java
@@ -0,0 +1,13 @@
+package com.foo;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class Receiver extends BroadcastReceiver {
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/AttributeSetBuilderTest.java b/robolectric/src/test/java/org/robolectric/AttributeSetBuilderTest.java
new file mode 100644
index 0000000..1837a76
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/AttributeSetBuilderTest.java
@@ -0,0 +1,406 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.fail;
+import static org.robolectric.res.AttributeResource.ANDROID_NS;
+import static org.robolectric.res.AttributeResource.ANDROID_RES_NS_PREFIX;
+import static org.robolectric.res.AttributeResource.RES_AUTO_NS_URI;
+
+import android.util.AttributeSet;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.robolectric.res.AttributeResource;
+
+/** Tests for {@link Robolectric#buildAttributeSet()} */
+@RunWith(AndroidJUnit4.class)
+public class AttributeSetBuilderTest {
+
+  private static final String APP_NS = RES_AUTO_NS_URI;
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Test
+  public void getAttributeResourceValue_shouldReturnTheResourceValue() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.text, "@android:string/ok")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(ANDROID_NS, "text", 0))
+        .isEqualTo(android.R.string.ok);
+  }
+
+  @Test
+  public void getAttributeResourceValueWithLeadingWhitespace_shouldReturnTheResourceValue() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.text, " @android:string/ok")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(ANDROID_NS, "text", 0))
+        .isEqualTo(android.R.string.ok);
+  }
+
+  @Test
+  public void getSystemAttributeResourceValue_shouldReturnDefaultValueForNullResourceId() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.text, AttributeResource.NULL_VALUE)
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(ANDROID_RES_NS_PREFIX + "com.some.namespace", "text", 0))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void getSystemAttributeResourceValue_shouldReturnDefaultValueForNonMatchingNamespaceId() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.id, "@+id/text1")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(ANDROID_RES_NS_PREFIX + "com.some.other.namespace", "id", 0))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void shouldCopeWithDefiningLocalIds() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.id, "@+id/text1")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(ANDROID_NS, "id", 0))
+        .isEqualTo(R.id.text1);
+  }
+
+  @Test
+  public void getAttributeResourceValue_withNamespace_shouldReturnTheResourceValue() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.message, "@string/howdy")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(APP_NS, "message", 0))
+        .isEqualTo(R.string.howdy);
+  }
+
+  @Test
+  public void getAttributeResourceValue_shouldReturnDefaultValueWhenAttributeIsNull() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.text, AttributeResource.NULL_VALUE)
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(APP_NS, "message", -1))
+        .isEqualTo(-1);
+  }
+
+  @Test
+  public void getAttributeResourceValue_shouldReturnDefaultValueWhenNotInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeResourceValue(APP_NS, "message", -1))
+        .isEqualTo(-1);
+  }
+
+  @Test
+  public void getAttributeBooleanValue_shouldGetBooleanValuesFromAttributes() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.isSugary, "true")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeBooleanValue(APP_NS, "isSugary", false))
+        .isTrue();
+  }
+
+  @Test
+  public void getAttributeBooleanValue_withNamespace_shouldGetBooleanValuesFromAttributes() throws Exception {
+    // org.robolectric.lib1.R values should be reconciled to match org.robolectric.R values.
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.isSugary, "true")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeBooleanValue(APP_NS, "isSugary", false)).isTrue();
+  }
+
+  @Test
+  public void getAttributeBooleanValue_shouldReturnDefaultBooleanValueWhenNotInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet =  Robolectric.buildAttributeSet()
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeBooleanValue(ANDROID_RES_NS_PREFIX + "com.some.namespace", "isSugary", true))
+        .isTrue();
+  }
+
+  @Test
+  public void getAttributeValue_byName_shouldReturnValueFromAttribute() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.isSugary, "oh heck yeah")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeValue(APP_NS, "isSugary"))
+        .isEqualTo("false");
+    assertThat(roboAttributeSet.getAttributeBooleanValue(APP_NS, "isSugary", true))
+        .isEqualTo(false);
+    assertThat(roboAttributeSet.getAttributeBooleanValue(APP_NS, "animalStyle", true))
+        .isEqualTo(true);
+  }
+
+  @Test
+  public void getAttributeValue_byNameWithReference_shouldReturnFullyQualifiedValueFromAttribute() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.isSugary, "@string/ok")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeValue(APP_NS, "isSugary"))
+        .isEqualTo("@" + R.string.ok);
+  }
+
+  @Test
+  public void getAttributeValue_byId_shouldReturnValueFromAttribute() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.isSugary, "oh heck yeah")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeValue(0))
+        .isEqualTo("false");
+  }
+
+  @Test
+  public void getAttributeValue_byIdWithReference_shouldReturnValueFromAttribute() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.isSugary, "@string/ok")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeValue(0))
+        .isEqualTo("@" + R.string.ok);
+  }
+
+  @Test
+  public void getAttributeIntValue_shouldReturnValueFromAttribute() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.sugarinessPercent, "100")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "sugarinessPercent", 0))
+        .isEqualTo(100);
+  }
+
+  @Test
+  public void getAttributeIntValue_shouldReturnHexValueFromAttribute() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.sugarinessPercent, "0x10")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "sugarinessPercent", 0))
+        .isEqualTo(16);
+  }
+
+  @Test
+  public void getAttributeIntValue_whenTypeAllowsIntOrEnum_withInt_shouldReturnInt() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.numColumns, "3")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "numColumns", 0))
+        .isEqualTo(3);
+  }
+
+  @Test
+  public void getAttributeIntValue_shouldReturnValueFromAttributeWhenNotInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "sugarinessPercent", 42))
+        .isEqualTo(42);
+  }
+
+  @Test
+  public void getAttributeIntValue_shouldReturnEnumValuesForEnumAttributesWhenNotInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "itemType", 24))
+        .isEqualTo(24);
+  }
+
+  @Test
+  public void getAttributeIntValue_shouldReturnEnumValuesForEnumAttributesInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.itemType, "ungulate")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "itemType", 24))
+        .isEqualTo(1);
+
+    AttributeSet roboAttributeSet2 = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.itemType, "marsupial")
+        .build();
+
+    assertThat(roboAttributeSet2.getAttributeIntValue(APP_NS, "itemType", 24))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void shouldFailOnMissingEnumValue() throws Exception {
+    try {
+      Robolectric.buildAttributeSet()
+          .addAttribute(R.attr.itemType, "simian")
+          .build();
+      fail("should fail");
+    } catch (Exception e) {
+      // expected
+      assertThat(e.getMessage()).contains("no value found for simian");
+    }
+  }
+
+  @Test
+  public void shouldFailOnMissingFlagValue() throws Exception {
+    try {
+      Robolectric.buildAttributeSet()
+          .addAttribute(R.attr.scrollBars, "temporal")
+          .build();
+      fail("should fail");
+    } catch (Exception e) {
+      // expected
+      assertThat(e.getMessage()).contains("no value found for temporal");
+    }
+  }
+
+  @Test
+  public void getAttributeIntValue_shouldReturnFlagValuesForFlagAttributesInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.scrollBars, "horizontal|vertical")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeIntValue(APP_NS, "scrollBars", 24))
+        .isEqualTo(0x100 | 0x200);
+  }
+
+  @Test
+  public void getAttributeFloatValue_shouldGetFloatValuesFromAttributes() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.aspectRatio, "1234.456")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeFloatValue(APP_NS, "aspectRatio", 78.9f))
+        .isEqualTo(1234.456f);
+  }
+
+  @Test
+  public void getAttributeFloatValue_shouldReturnDefaultFloatValueWhenNotInAttributeSet() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeFloatValue(APP_NS, "aspectRatio", 78.9f))
+        .isEqualTo(78.9f);
+  }
+
+  @Test
+  public void getClassAndIdAttribute_returnsZeroWhenNotSpecified() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet().build();
+    assertThat(roboAttributeSet.getClassAttribute()).isNull();
+    assertThat(roboAttributeSet.getIdAttribute()).isNull();
+  }
+
+  @Test
+  public void getClassAndIdAttribute_returnsAttr() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .setIdAttribute("the id")
+        .setClassAttribute("the class")
+        .build();
+    assertThat(roboAttributeSet.getClassAttribute()).isEqualTo("the class");
+    assertThat(roboAttributeSet.getIdAttribute()).isEqualTo("the id");
+  }
+
+  @Test
+  public void getStyleAttribute_returnsZeroWhenNoStyle() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .build();
+
+    assertThat(roboAttributeSet.getStyleAttribute())
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void getStyleAttribute_returnsCorrectValue() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .setStyleAttribute("@style/Gastropod")
+        .build();
+
+    assertThat(roboAttributeSet.getStyleAttribute())
+        .isEqualTo(R.style.Gastropod);
+  }
+
+  @Test
+  public void getStyleAttribute_whenStyleIsBogus() throws Exception {
+    try {
+      Robolectric.buildAttributeSet()
+            .setStyleAttribute("@style/non_existent_style")
+            .build();
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .contains("no such resource @style/non_existent_style while resolving value for style");
+    }
+  }
+
+  @Test
+  public void getAttributeNameResource() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.aspectRatio, "1")
+        .build();
+
+    assertThat(roboAttributeSet.getAttributeNameResource(0))
+        .isEqualTo(R.attr.aspectRatio);
+  }
+
+  @Test
+  public void shouldReturnAttributesInOrderOfNameResId() throws Exception {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.height, "1px")
+        .addAttribute(R.attr.animalStyle, "meow")
+        .addAttribute(android.R.attr.width, "1px")
+        .build();
+
+    assertThat(asList(
+        roboAttributeSet.getAttributeName(0),
+        roboAttributeSet.getAttributeName(1),
+        roboAttributeSet.getAttributeName(2)
+    )).containsExactly("height", "width", "animalStyle");
+
+    assertThat(asList(
+        roboAttributeSet.getAttributeNameResource(0),
+        roboAttributeSet.getAttributeNameResource(1),
+        roboAttributeSet.getAttributeNameResource(2)
+    )).containsExactly(android.R.attr.height, android.R.attr.width, R.attr.animalStyle);
+  }
+
+  @Test
+  public void whenAttrSetAttrSpecifiesUnknownStyle_throwsException() throws Exception {
+    try {
+      Robolectric.buildAttributeSet()
+          .addAttribute(R.attr.string2, "?org.robolectric:attr/noSuchAttr")
+          .build();
+      fail();
+    } catch (Exception e) {
+      assertThat(e.getMessage()).contains("no such attr ?org.robolectric:attr/noSuchAttr");
+      assertThat(e.getMessage()).contains("while resolving value for org.robolectric:attr/string2");
+    }
+  }
+
+  @Test
+  public void whenAttrSetAttrSpecifiesUnknownReference_throwsException() throws Exception {
+    try {
+      Robolectric.buildAttributeSet()
+          .addAttribute(R.attr.string2, "@org.robolectric:attr/noSuchRes")
+          .build();
+      fail();
+    } catch (Exception e) {
+      assertThat(e.getMessage()).contains("no such resource @org.robolectric:attr/noSuchRes");
+      assertThat(e.getMessage()).contains("while resolving value for org.robolectric:attr/string2");
+    }
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/BootstrapDeferringRobolectricTestRunner.java b/robolectric/src/test/java/org/robolectric/BootstrapDeferringRobolectricTestRunner.java
new file mode 100644
index 0000000..72ae8b0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/BootstrapDeferringRobolectricTestRunner.java
@@ -0,0 +1,93 @@
+package org.robolectric;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Field;
+import javax.annotation.Nonnull;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+import org.robolectric.internal.AndroidSandbox.TestEnvironmentSpec;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.util.inject.Injector;
+
+/**
+ * Test runner which prevents full initialization (bootstrap) of the Android process at test setup.
+ */
+public class BootstrapDeferringRobolectricTestRunner extends RobolectricTestRunner {
+
+  private static final Injector DEFAULT_INJECTOR = defaultInjector().build();
+
+  public static BootstrapWrapperI bootstrapWrapperInstance = null;
+
+  protected static Injector.Builder defaultInjector() {
+    return RobolectricTestRunner.defaultInjector()
+        .bind(TestEnvironmentSpec.class, new TestEnvironmentSpec(BootstrapWrapper.class));
+  }
+
+  public BootstrapDeferringRobolectricTestRunner(Class<?> testClass) throws InitializationError {
+    super(testClass, DEFAULT_INJECTOR);
+  }
+
+  @Nonnull
+  @Override
+  protected Class<MyTestLifecycle> getTestLifecycleClass() {
+    return MyTestLifecycle.class;
+  }
+
+  @Nonnull
+  @Override
+  protected InstrumentationConfiguration createClassLoaderConfig(FrameworkMethod method) {
+    return new InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method))
+        .doNotAcquireClass(BootstrapDeferringRobolectricTestRunner.class)
+        .doNotAcquireClass(RoboInject.class)
+        .doNotAcquireClass(MyTestLifecycle.class)
+        .doNotAcquireClass(BootstrapWrapperI.class)
+        .build();
+  }
+
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.FIELD)
+  public @interface RoboInject {
+  }
+
+  public static class MyTestLifecycle extends DefaultTestLifecycle {
+    @Override
+    public void prepareTest(Object test) {
+      super.prepareTest(test);
+      for (Field field : test.getClass().getDeclaredFields()) {
+        if (field.getAnnotation(RoboInject.class) != null) {
+          if (field.getType().isAssignableFrom(BootstrapWrapperI.class)) {
+            field.setAccessible(true);
+            try {
+              field.set(test, bootstrapWrapperInstance);
+            } catch (IllegalAccessException e) {
+              throw new RuntimeException("can't set " + field, e);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  public interface BootstrapWrapperI {
+
+    void callSetUpApplicationState();
+
+    void changeConfig(Configuration config);
+
+    boolean isLegacyResources();
+
+    AndroidManifest getAppManifest();
+
+    void changeAppManifest(AndroidManifest manifest);
+
+    void tearDownApplication();
+
+    void resetState();
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/BootstrapWrapper.java b/robolectric/src/test/java/org/robolectric/BootstrapWrapper.java
new file mode 100644
index 0000000..5e3bfd1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/BootstrapWrapper.java
@@ -0,0 +1,77 @@
+package org.robolectric;
+
+import java.lang.reflect.Method;
+import javax.inject.Named;
+import org.robolectric.BootstrapDeferringRobolectricTestRunner.BootstrapWrapperI;
+import org.robolectric.android.internal.AndroidTestEnvironment;
+import org.robolectric.internal.ResourcesMode;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.TestEnvironmentLifecyclePlugin;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+
+/** Wrapper for testing use of AndroidTestEnvironment. */
+public class BootstrapWrapper extends AndroidTestEnvironment implements BootstrapWrapperI {
+  public AndroidTestEnvironment wrappedTestEnvironment;
+  public boolean legacyResources;
+  public Method method;
+  public Configuration config;
+  public AndroidManifest appManifest;
+
+  public BootstrapWrapper(
+      @Named("runtimeSdk") Sdk runtimeSdk,
+      @Named("compileSdk") Sdk compileSdk,
+      ResourcesMode resourcesMode, ApkLoader apkLoader,
+      ShadowProvider[] shadowProviders,
+      TestEnvironmentLifecyclePlugin[] lifecyclePlugins) {
+    super(runtimeSdk, compileSdk, resourcesMode, apkLoader, shadowProviders, lifecyclePlugins);
+    this.wrappedTestEnvironment = new AndroidTestEnvironment(runtimeSdk, compileSdk, resourcesMode,
+        apkLoader, shadowProviders, lifecyclePlugins);
+  }
+
+  @Override
+  public void setUpApplicationState(Method method, Configuration config,
+      AndroidManifest appManifest) {
+    this.method = method;
+    this.config = config;
+    this.appManifest = appManifest;
+
+    BootstrapDeferringRobolectricTestRunner.bootstrapWrapperInstance = this;
+  }
+
+  @Override
+  public void tearDownApplication() {
+    wrappedTestEnvironment.tearDownApplication();
+  }
+
+  @Override
+  public void callSetUpApplicationState() {
+    wrappedTestEnvironment.setUpApplicationState(method, config, appManifest);
+  }
+
+  @Override
+  public void changeConfig(Configuration config) {
+    this.config = config;
+  }
+
+  @Override
+  public boolean isLegacyResources() {
+    return legacyResources;
+  }
+
+  @Override
+  public AndroidManifest getAppManifest() {
+    return appManifest;
+  }
+
+  @Override
+  public void changeAppManifest(AndroidManifest manifest) {
+    this.appManifest = manifest;
+  }
+
+  @Override
+  public void resetState() {
+    wrappedTestEnvironment.resetState();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ConfigTest.java b/robolectric/src/test/java/org/robolectric/ConfigTest.java
new file mode 100644
index 0000000..1329c2b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ConfigTest.java
@@ -0,0 +1,196 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.Config;
+
+@RunWith(JUnit4.class)
+public class ConfigTest {
+  @Test
+  public void testDefaults() throws Exception {
+    Config defaults = Config.Builder.defaults().build();
+    assertThat(defaults.manifest()).isEqualTo("AndroidManifest.xml");
+    assertThat(defaults.resourceDir()).isEqualTo("res");
+    assertThat(defaults.assetDir()).isEqualTo("assets");
+  }
+
+  @Test
+  public void withOverlay_withBaseSdk() throws Exception {
+    Config.Implementation base = new Config.Builder().setSdk(16, 17, 18).build();
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().build())))
+        .isEqualTo("sdk=[16, 17, 18], minSdk=-1, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setSdk(16).build())))
+        .isEqualTo("sdk=[16], minSdk=-1, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMaxSdk(20).build())))
+        .isEqualTo("sdk=[], minSdk=-1, maxSdk=20");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).setMaxSdk(18).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=18");
+  }
+
+  @Test
+  public void withOverlay_withBaseMinSdk() throws Exception {
+    Config.Implementation base = new Config.Builder().setMinSdk(18).build();
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().build())))
+        .isEqualTo("sdk=[], minSdk=18, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setSdk(16).build())))
+        .isEqualTo("sdk=[16], minSdk=-1, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMaxSdk(20).build())))
+        .isEqualTo("sdk=[], minSdk=18, maxSdk=20");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).setMaxSdk(18).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=18");
+  }
+
+  @Test
+  public void withOverlay_withBaseMaxSdk() throws Exception {
+    Config.Implementation base = new Config.Builder().setMaxSdk(18).build();
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().build())))
+        .isEqualTo("sdk=[], minSdk=-1, maxSdk=18");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setSdk(16).build())))
+        .isEqualTo("sdk=[16], minSdk=-1, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=18");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMaxSdk(20).build())))
+        .isEqualTo("sdk=[], minSdk=-1, maxSdk=20");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).setMaxSdk(18).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=18");
+  }
+
+  @Test
+  public void withOverlay_withBaseMinAndMaxSdk() throws Exception {
+    Config.Implementation base = new Config.Builder().setMinSdk(17).setMaxSdk(18).build();
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().build())))
+        .isEqualTo("sdk=[], minSdk=17, maxSdk=18");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setSdk(16).build())))
+        .isEqualTo("sdk=[16], minSdk=-1, maxSdk=-1");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=18");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMaxSdk(20).build())))
+        .isEqualTo("sdk=[], minSdk=17, maxSdk=20");
+
+    assertThat(sdksIn(overlay(base, new Config.Builder().setMinSdk(16).setMaxSdk(18).build())))
+        .isEqualTo("sdk=[], minSdk=16, maxSdk=18");
+  }
+
+  @Test
+  public void withOverlay_withShadows_maintainsOrder() throws Exception {
+    Config.Implementation base = new Config.Builder().build();
+
+    Config withString =
+        overlay(base, new Config.Builder().setShadows(new Class[] {String.class}).build());
+    assertThat(withString.shadows()).asList().contains(String.class);
+
+    Config withMore =
+        overlay(
+            withString,
+            new Config.Builder().setShadows(new Class[] {Map.class, String.class}).build());
+    assertThat(withMore.shadows()).asList().containsAtLeast(String.class, Map.class, String.class);
+  }
+
+  @Test
+  public void shouldAppendQualifiersStartingWithPlus() throws Exception {
+    Config config = new Config.Builder().setQualifiers("w100dp").build();
+    config = overlay(config, new Config.Builder().setQualifiers("w101dp").build());
+    assertThat(config.qualifiers()).isEqualTo("w101dp");
+
+    config = overlay(config, new Config.Builder().setQualifiers("+w102dp").build());
+    config = overlay(config, new Config.Builder().setQualifiers("+w103dp").build());
+    assertThat(config.qualifiers()).isEqualTo("w101dp +w102dp +w103dp");
+
+    config = overlay(config, new Config.Builder().setQualifiers("+w104dp").build());
+    config = overlay(config, new Config.Builder().setQualifiers("w105dp").build());
+    assertThat(config.qualifiers()).isEqualTo("w105dp");
+  }
+
+  @Test
+  public void sdksFromProperties() throws Exception {
+    Properties properties = new Properties();
+    properties.setProperty("sdk", "1, 2, ALL_SDKS, TARGET_SDK, OLDEST_SDK, NEWEST_SDK, 666");
+    Config config = Config.Implementation.fromProperties(properties);
+    assertThat(sdksIn(config))
+        .isEqualTo("sdk=[1, 2, -2, -3, -4, -5, 666], minSdk=-1, maxSdk=-1");
+  }
+
+  @Test
+  public void minMaxSdksFromProperties() throws Exception {
+    Properties properties = new Properties();
+    properties.setProperty("minSdk", "OLDEST_SDK");
+    properties.setProperty("maxSdk", "NEWEST_SDK");
+    Config config = Config.Implementation.fromProperties(properties);
+    assertThat(sdksIn(config))
+        .isEqualTo("sdk=[], minSdk=-4, maxSdk=-5");
+  }
+
+  @Test
+  public void testIllegalArguments_sdkMutualExclusion() throws Exception {
+    try {
+      new Config.Builder()
+          .setSdk(16, 17, 18).setMinSdk(16).setMaxSdk(18)
+          .build();
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage())
+          .isEqualTo("sdk and minSdk/maxSdk may not be specified together (sdk=[16, 17, 18],"
+                         + " minSdk=16, maxSdk=18)");
+    }
+  }
+
+  @Test
+  public void testIllegalArguments_minMaxSdkRange() throws Exception {
+    try {
+      new Config.Builder()
+          .setMinSdk(18).setMaxSdk(16)
+          .build();
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage())
+          .isEqualTo("minSdk may not be larger than maxSdk (minSdk=18, maxSdk=16)");
+    }
+  }
+
+  //////////////////////////
+
+  private String sdksIn(Config config) {
+    return "sdk="
+        + Arrays.toString(config.sdk())
+        + ", minSdk="
+        + config.minSdk()
+        + ", maxSdk="
+        + config.maxSdk();
+  }
+
+  @Nonnull
+  private Config overlay(Config base, Config.Implementation build) {
+    return new Config.Builder(base).overlay(build).build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ConfigTestReceiver.java b/robolectric/src/test/java/org/robolectric/ConfigTestReceiver.java
new file mode 100644
index 0000000..e2804a1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ConfigTestReceiver.java
@@ -0,0 +1,24 @@
+package org.robolectric;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ConfigTestReceiver extends BroadcastReceiver {
+
+  public List<Intent> intentsReceived = new ArrayList<>();
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+    intentsReceived.add(intent);
+  }
+
+  static public class InnerReceiver extends BroadcastReceiver {
+    @Override
+    public void onReceive(Context context, Intent intent) {
+
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/DirectReflectorTest.java b/robolectric/src/test/java/org/robolectric/DirectReflectorTest.java
new file mode 100644
index 0000000..b82c079
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/DirectReflectorTest.java
@@ -0,0 +1,146 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.DirectReflectorTest.ShadowClass;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Reflector;
+import org.robolectric.util.reflector.Static;
+
+/** Tests for @{@link Direct} annotation incorporated inside {@link Reflector}. */
+@RunWith(AndroidJUnit4.class)
+@Config(shadows = ShadowClass.class)
+public class DirectReflectorTest {
+
+  private SomeClass someClass;
+
+  @Before
+  public void setUp() throws Exception {
+    someClass = new SomeClass();
+  }
+
+  @Test
+  public void shouldCallShadowImplementation_public() {
+    assertThat(someClass.somePublicMethod()).isFalse();
+  }
+
+  @Test
+  public void shouldCallShadowImplementation_private() {
+    assertThat(someClass.somePrivateMethod()).isFalse();
+  }
+
+  @Test
+  public void shouldCallShadowImplementation_static() {
+    assertThat(SomeClass.someStaticMethod()).isFalse();
+  }
+
+  @Test
+  public void classLevelReflector_shouldCallOriginalImplementation_public() {
+    assertThat(reflector(ClassLevelReflector.class, someClass).somePublicMethod()).isTrue();
+  }
+
+  @Test
+  public void classLevelReflector_shouldCallOriginalImplementation_private() {
+    assertThat(reflector(ClassLevelReflector.class, someClass).somePrivateMethod()).isTrue();
+  }
+
+  @Test
+  public void classLevelReflector_shouldCallOriginalImplementation_static() {
+    assertThat(reflector(ClassLevelReflector.class, someClass).someStaticMethod()).isTrue();
+  }
+
+  @Test
+  public void methodLevelReflector_shouldCallOriginalImplementation_public() {
+    assertThat(reflector(MethodLevelReflector.class, someClass).somePublicMethod()).isTrue();
+  }
+
+  @Test
+  public void methodLevelReflector_shouldCallOriginalImplementation_private() {
+    assertThat(reflector(MethodLevelReflector.class, someClass).somePrivateMethod()).isTrue();
+  }
+
+  @Test
+  public void methodLevelReflector_shouldCallOriginalImplementation_static() {
+    assertThat(reflector(MethodLevelReflector.class, someClass).someStaticMethod()).isTrue();
+  }
+
+  /** Basic class to be instrumented for testing. */
+  @Instrument
+  public static class SomeClass {
+
+    public boolean somePublicMethod() {
+      return true;
+    }
+
+    private boolean somePrivateMethod() {
+      return true;
+    }
+
+    public static boolean someStaticMethod() {
+      return true;
+    }
+  }
+
+  /** Shadow of {@link SomeClass} that changes all method implementations. */
+  @Implements(SomeClass.class)
+  public static class ShadowClass {
+
+    @Implementation
+    public boolean somePublicMethod() {
+      return false;
+    }
+
+    @Implementation
+    protected boolean somePrivateMethod() {
+      return false;
+    }
+
+    @Implementation
+    public static boolean someStaticMethod() {
+      return false;
+    }
+  }
+
+  /**
+   * Accessor interface for {@link SomeClass}'s internals with the @{@link Direct} annotation at the
+   * class level.
+   */
+  @ForType(value = SomeClass.class, direct = true)
+  interface ClassLevelReflector {
+
+    boolean somePublicMethod();
+
+    boolean somePrivateMethod();
+
+    @Static
+    boolean someStaticMethod();
+  }
+
+  /**
+   * Accessor interface for {@link SomeClass}'s internals with @{@link Direct} annotations at the
+   * method level.
+   */
+  @ForType(SomeClass.class)
+  interface MethodLevelReflector {
+
+    @Direct
+    boolean somePublicMethod();
+
+    @Direct
+    boolean somePrivateMethod();
+
+    @Static
+    @Direct
+    boolean someStaticMethod();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/DotConfigTestReceiver.java b/robolectric/src/test/java/org/robolectric/DotConfigTestReceiver.java
new file mode 100644
index 0000000..2f0c20f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/DotConfigTestReceiver.java
@@ -0,0 +1,13 @@
+package org.robolectric;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class DotConfigTestReceiver extends BroadcastReceiver {
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/FakeApp.java b/robolectric/src/test/java/org/robolectric/FakeApp.java
new file mode 100644
index 0000000..279596b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/FakeApp.java
@@ -0,0 +1,8 @@
+package org.robolectric;
+
+import android.app.Application;
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class FakeApp extends Application {
+}
diff --git a/robolectric/src/test/java/org/robolectric/IncludedDependenciesTest.java b/robolectric/src/test/java/org/robolectric/IncludedDependenciesTest.java
new file mode 100644
index 0000000..8c8dc73
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/IncludedDependenciesTest.java
@@ -0,0 +1,29 @@
+package org.robolectric;
+
+import static org.junit.Assert.assertEquals;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.StringReader;
+import org.json.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserFactory;
+
+@RunWith(AndroidJUnit4.class)
+public class IncludedDependenciesTest {
+  @Test
+  public void jsonShouldWork() throws Exception {
+    assertEquals("value", new JSONObject("{'name':'value'}").getString("name"));
+  }
+
+  @Test
+  public void xppShouldWork() throws Exception {
+    XmlPullParser xmlPullParser = XmlPullParserFactory.newInstance().newPullParser();
+    xmlPullParser.setInput(new StringReader("<?xml version=\"1.0\" encoding=\"UTF-8\"?><test name=\"value\"/>"));
+    assertEquals(XmlPullParser.START_TAG, xmlPullParser.nextTag());
+    assertEquals(1, xmlPullParser.getAttributeCount());
+    assertEquals("name", xmlPullParser.getAttributeName(0));
+    assertEquals("value", xmlPullParser.getAttributeValue(0));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/InvokeDynamicTest.java b/robolectric/src/test/java/org/robolectric/InvokeDynamicTest.java
new file mode 100644
index 0000000..109b525
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/InvokeDynamicTest.java
@@ -0,0 +1,120 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = Config.NEWEST_SDK)
+public class InvokeDynamicTest {
+  @Test
+  @Config(shadows = {DoNothingShadow.class})
+  public void doNothing() {
+    DoNothing nothing = new DoNothing();
+    assertThat(nothing.identity(5)).isEqualTo(0);
+  }
+
+  @Test
+  @Config(shadows = {RealShadow.class})
+  public void directlyOn() {
+    Real real = new Real();
+    RealShadow shadow = Shadow.extract(real);
+
+    assertThat(real.x).isEqualTo(-1);
+    assertThat(shadow.x).isEqualTo(-2);
+
+    real.setX(5);
+    assertThat(real.x).isEqualTo(-5);
+    assertThat(shadow.x).isEqualTo(5);
+
+    Shadow.directlyOn(real, Real.class).setX(42);
+    assertThat(real.x).isEqualTo(42);
+    assertThat(shadow.x).isEqualTo(5);
+  }
+
+  @Test
+  @Config(shadows = {RealShadow1.class})
+  public void rebindShadow1() {
+    RealCopy real = new RealCopy();
+    real.setX(42);
+    assertThat(real.x).isEqualTo(1);
+  }
+
+  @Test
+  @Config(shadows = {RealShadow2.class})
+  public void rebindShadow2() {
+    RealCopy real = new RealCopy();
+    real.setX(42);
+    assertThat(real.x).isEqualTo(2);
+  }
+
+  @Instrument
+  public static class Real {
+    public int x = -1;
+
+    public void setX(int x) {
+      this.x = x;
+    }
+  }
+
+  @Instrument
+  public static class RealCopy {
+    public int x;
+
+    public void setX(int x) {
+    }
+  }
+
+  @Implements(Real.class)
+  public static class RealShadow {
+    @RealObject Real real;
+
+    public int x = -2;
+
+    @Implementation
+    protected void setX(int x) {
+      this.x = x;
+      real.x = -x;
+    }
+  }
+
+  @Implements(RealCopy.class)
+  public static class RealShadow1 {
+    @RealObject RealCopy real;
+
+    @Implementation
+    protected void setX(int x) {
+      real.x = 1;
+    }
+  }
+
+  @Implements(RealCopy.class)
+  public static class RealShadow2 {
+    @RealObject RealCopy real;
+
+    @Implementation
+    protected void setX(int x) {
+      real.x = 2;
+    }
+  }
+
+  @Instrument
+  public static class DoNothing {
+    public int identity(int x) {
+      return x;
+    }
+  }
+
+  @Implements(value = DoNothing.class, callThroughByDefault = false)
+  public static class DoNothingShadow {
+
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/LazyApplicationClassTest.java b/robolectric/src/test/java/org/robolectric/LazyApplicationClassTest.java
new file mode 100644
index 0000000..bb38fdc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/LazyApplicationClassTest.java
@@ -0,0 +1,28 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.OFF;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.ON;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.experimental.LazyApplication;
+
+/** Test case to make sure the application is lazily loaded when requested at the class level */
+@LazyApplication(ON)
+@RunWith(AndroidJUnit4.class)
+public class LazyApplicationClassTest {
+  @Test
+  public void testLazyLoad() {
+    assertThat(RuntimeEnvironment.application).isNull();
+    assertThat(RuntimeEnvironment.getApplication()).isNotNull();
+    assertThat(RuntimeEnvironment.application).isNotNull();
+  }
+
+  @LazyApplication(OFF)
+  @Test
+  public void testMethodLevelOverride() {
+    assertThat(RuntimeEnvironment.application).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/LazyApplicationMethodTest.java b/robolectric/src/test/java/org/robolectric/LazyApplicationMethodTest.java
new file mode 100644
index 0000000..4c37af8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/LazyApplicationMethodTest.java
@@ -0,0 +1,21 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.ON;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.experimental.LazyApplication;
+
+/** Test case to make sure the application is lazily loaded when requested at the method level */
+@RunWith(AndroidJUnit4.class)
+public class LazyApplicationMethodTest {
+  @LazyApplication(ON)
+  @Test
+  public void testLazyLoad() {
+    assertThat(RuntimeEnvironment.application).isNull();
+    assertThat(RuntimeEnvironment.getApplication()).isNotNull();
+    assertThat(RuntimeEnvironment.application).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/Manifest.java b/robolectric/src/test/java/org/robolectric/Manifest.java
new file mode 100644
index 0000000..bb1a247
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/Manifest.java
@@ -0,0 +1,19 @@
+/* AUTO-GENERATED FILE.  DO NOT MODIFY.
+ *
+ * This class was automatically generated by the
+ * aapt tool from the resource data it found.  It
+ * should not be modified by hand.
+ */
+
+package org.robolectric;
+
+public final class Manifest {
+    public static final class permission {
+        public static final String permission_with_literal_label="permission_with_literal_label";
+        public static final String permission_with_minimal_fields="permission_with_minimal_fields";
+        public static final String some_permission="some_permission";
+    }
+    public static final class permission_group {
+        public static final String package_permission_group="package_permission_group";
+    }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ManifestFactoryTest.java b/robolectric/src/test/java/org/robolectric/ManifestFactoryTest.java
new file mode 100644
index 0000000..107252b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ManifestFactoryTest.java
@@ -0,0 +1,87 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import java.net.URL;
+import java.nio.file.Paths;
+import java.util.Properties;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.Config;
+import org.robolectric.internal.DefaultManifestFactory;
+import org.robolectric.internal.ManifestFactory;
+import org.robolectric.internal.ManifestIdentifier;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.res.Fs;
+
+@RunWith(JUnit4.class)
+public class ManifestFactoryTest {
+
+  @Test
+  public void whenBuildSystemApiPropertiesFileIsPresent_shouldUseDefaultManifestFactory() throws Exception {
+    final Properties properties = new Properties();
+    properties.setProperty("android_sdk_home", "");
+    properties.setProperty("android_merged_manifest", "/path/to/MergedManifest.xml");
+    properties.setProperty("android_merged_resources", "/path/to/merged-resources");
+    properties.setProperty("android_merged_assets", "/path/to/merged-assets");
+
+    RobolectricTestRunner testRunner =
+        new RobolectricTestRunner(ManifestFactoryTest.class) {
+          @Override
+          protected Properties getBuildSystemApiProperties() {
+            return properties;
+          }
+        };
+
+    Config.Implementation config = Config.Builder.defaults().build();
+    ManifestFactory manifestFactory = testRunner.getManifestFactory(config);
+    assertThat(manifestFactory).isInstanceOf(DefaultManifestFactory.class);
+    ManifestIdentifier manifestIdentifier = manifestFactory.identify(config);
+    assertThat(manifestIdentifier.getManifestFile())
+        .isEqualTo(Paths.get("/path/to/MergedManifest.xml"));
+    assertThat(manifestIdentifier.getResDir()).isEqualTo(Paths.get("/path/to/merged-resources"));
+    assertThat(manifestIdentifier.getAssetDir()).isEqualTo(Paths.get("/path/to/merged-assets"));
+    assertThat(manifestIdentifier.getLibraries()).isEmpty();
+    assertThat(manifestIdentifier.getPackageName()).isNull();
+
+    AndroidManifest androidManifest = RobolectricTestRunner
+        .createAndroidManifest(manifestIdentifier);
+    assertThat(androidManifest.getAndroidManifestFile())
+        .isEqualTo(Paths.get("/path/to/MergedManifest.xml"));
+    assertThat(androidManifest.getResDirectory()).isEqualTo(Paths.get("/path/to/merged-resources"));
+    assertThat(androidManifest.getAssetsDirectory()).isEqualTo(Paths.get("/path/to/merged-assets"));
+  }
+
+  @Test
+  public void whenConfigSpecified_overridesValuesFromFile() throws Exception {
+    final Properties properties = new Properties();
+    properties.setProperty("android_sdk_home", "");
+    properties.setProperty("android_merged_manifest", "/path/to/MergedManifest.xml");
+    properties.setProperty("android_merged_resources", "/path/to/merged-resources");
+    properties.setProperty("android_merged_assets", "/path/to/merged-assets");
+
+    RobolectricTestRunner testRunner =
+        new RobolectricTestRunner(ManifestFactoryTest.class) {
+          @Override
+          protected Properties getBuildSystemApiProperties() {
+            return properties;
+          }
+        };
+
+    Config.Implementation config = Config.Builder.defaults()
+        .setManifest("TestAndroidManifest.xml")
+        .setPackageName("another.package")
+        .build();
+    ManifestFactory manifestFactory = testRunner.getManifestFactory(config);
+    assertThat(manifestFactory).isInstanceOf(DefaultManifestFactory.class);
+    ManifestIdentifier manifestIdentifier = manifestFactory.identify(config);
+    URL expectedUrl = getClass().getClassLoader().getResource("TestAndroidManifest.xml");
+    assertThat(manifestIdentifier.getManifestFile()).isEqualTo(Fs.fromUrl(expectedUrl));
+    assertThat(manifestIdentifier.getResDir()).isEqualTo(Paths.get("/path/to/merged-resources"));
+    assertThat(manifestIdentifier.getAssetDir()).isEqualTo(Paths.get("/path/to/merged-assets"));
+    assertThat(manifestIdentifier.getLibraries()).isEmpty();
+    assertThat(manifestIdentifier.getPackageName()).isEqualTo("another.package");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerClassLoaderTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerClassLoaderTest.java
new file mode 100644
index 0000000..aeb8de8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerClassLoaderTest.java
@@ -0,0 +1,38 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/**
+ * Parameterized tests using an Android class originally created outside of the Robolectric classloader.
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class ParameterizedRobolectricTestRunnerClassLoaderTest {
+
+  private final Uri uri;
+
+  public ParameterizedRobolectricTestRunnerClassLoaderTest(Uri uri) {
+    this.uri = uri;
+  }
+
+  @Test
+  @Config(manifest = Config.NONE)
+  public void parse() {
+    Uri currentUri = Uri.parse("http://host/");
+    assertThat(currentUri).isEqualTo(uri);
+  }
+
+  @ParameterizedRobolectricTestRunner.Parameters
+  public static Collection getTestData() {
+    Object[][] data = {
+        { Uri.parse("http://host/") }
+    };
+    return Arrays.asList(data);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerConfigTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerConfigTest.java
new file mode 100644
index 0000000..4045cac
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerConfigTest.java
@@ -0,0 +1,71 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.CursorWrapper;
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadows.ShadowCursorWrapper;
+
+/**
+ * Parameterized tests using custom shadow classes.
+ *
+ * @author John Ferlisi
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class ParameterizedRobolectricTestRunnerConfigTest {
+
+  private final int expectedType;
+
+  public ParameterizedRobolectricTestRunnerConfigTest(int expectedType) {
+    this.expectedType = expectedType;
+  }
+
+  @Test
+  @Config(manifest = Config.NONE, shadows = ShadowCursorWrapper1.class)
+  public void getType1() {
+    assertThat(new CursorWrapper(null).getType(expectedType)).isEqualTo(1);
+  }
+
+  @Test
+  @Config(manifest = Config.NONE, shadows = ShadowCursorWrapperEcho.class)
+  public void getTypeEcho() {
+    assertThat(new CursorWrapper(null).getType(expectedType)).isEqualTo(expectedType);
+  }
+
+  @ParameterizedRobolectricTestRunner.Parameters(name = "ConfigTest: {0}")
+  public static Collection getTestData() {
+    Object[][] data = {
+        { 1 },
+        { 2 },
+        { 3 },
+        { 4 }
+    };
+    return Arrays.asList(data);
+  }
+
+  @Implements(CursorWrapper.class)
+  public static class ShadowCursorWrapper1 extends ShadowCursorWrapper {
+
+    @Implementation
+    @Override
+    public int getType(int columnIndex) {
+      return 1;
+    }
+  }
+
+  @Implements(CursorWrapper.class)
+  public static class ShadowCursorWrapperEcho extends ShadowCursorWrapper {
+
+    @Implementation
+    @Override
+    public int getType(int columnIndex) {
+      return columnIndex;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerIterableSingleParameterTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerIterableSingleParameterTest.java
new file mode 100644
index 0000000..860998a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerIterableSingleParameterTest.java
@@ -0,0 +1,29 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+/**
+ * Tests for the single parameter test with {@link Iterable} as return type.
+ *
+ * <p>See https://github.com/junit-team/junit4/wiki/parameterized-tests#tests-with-single-parameter.
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public class ParameterizedRobolectricTestRunnerIterableSingleParameterTest {
+  @Parameter public int intValue;
+
+  @Test
+  public void parameters_shouldHaveValues() {
+    assertThat(intValue).isNotEqualTo(0);
+  }
+
+  @Parameters
+  public static Iterable<?> parameters() {
+    return Arrays.asList(1, 2, 3, 4, 5);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerNormalTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerNormalTest.java
new file mode 100644
index 0000000..110f3de
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerNormalTest.java
@@ -0,0 +1,74 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/**
+ * Parameterized tests using basic java classes.
+ *
+ * @author John Ferlisi
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class ParameterizedRobolectricTestRunnerNormalTest {
+
+  private final int first;
+  private final int second;
+  private final int expectedSum;
+  private final int expectedDifference;
+  private final int expectedProduct;
+  private final int expectedQuotient;
+
+  public ParameterizedRobolectricTestRunnerNormalTest(int first,
+                                                      int second,
+                                                      int expectedSum,
+                                                      int expectedDifference,
+                                                      int expectedProduct,
+                                                      int expectedQuotient) {
+    this.first = first;
+    this.second = second;
+    this.expectedSum = expectedSum;
+    this.expectedDifference = expectedDifference;
+    this.expectedProduct = expectedProduct;
+    this.expectedQuotient = expectedQuotient;
+  }
+
+  @Test
+  @Config(manifest = Config.NONE)
+  public void add() {
+    assertThat(first + second).isEqualTo(expectedSum);
+  }
+
+  @Test
+  @Config(manifest = Config.NONE)
+  public void subtract() {
+    assertThat(first - second).isEqualTo(expectedDifference);
+  }
+
+  @Test
+  @Config(manifest = Config.NONE)
+  public void multiple() {
+    assertThat(first * second).isEqualTo(expectedProduct);
+  }
+
+  @Test
+  @Config(manifest = Config.NONE)
+  public void divide() {
+    assertThat(first / second).isEqualTo(expectedQuotient);
+  }
+
+  @ParameterizedRobolectricTestRunner.Parameters(name = "Java Math Test: {0}, {1}")
+  public static Collection getTestData() {
+    Object[][] data = {
+        { 1, 1, 2, 0, 1, 1 },
+        { 2, 1, 3, 1, 2, 2 },
+        { 2, 2, 4, 0, 4, 1 },
+        { 4, 4, 8, 0, 16, 1 }
+    };
+    return Arrays.asList(data);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerObjectArraySingleParameterTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerObjectArraySingleParameterTest.java
new file mode 100644
index 0000000..036ef63
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerObjectArraySingleParameterTest.java
@@ -0,0 +1,28 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+/**
+ * Tests for the single parameter test with {@link Object} array as return type.
+ *
+ * <p>See https://github.com/junit-team/junit4/wiki/parameterized-tests#tests-with-single-parameter.
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public class ParameterizedRobolectricTestRunnerObjectArraySingleParameterTest {
+  @Parameter public int intValue;
+
+  @Test
+  public void parameters_shouldHaveValues() {
+    assertThat(intValue).isNotEqualTo(0);
+  }
+
+  @Parameters
+  public static Object[] parameters() {
+    return new Object[] {1, 2, 3, 4, 5};
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerParameterTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerParameterTest.java
new file mode 100644
index 0000000..d8886ff
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerParameterTest.java
@@ -0,0 +1,43 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+import org.robolectric.annotation.Config;
+
+/** Tests for the {@link Parameter} annotation */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public final class ParameterizedRobolectricTestRunnerParameterTest {
+
+  @Parameter(value = 0)
+  public boolean booleanValue;
+
+  @Parameter(value = 1)
+  public int intValue;
+
+  @Parameter(value = 2)
+  public String stringValue;
+
+  @Parameter(value = 3)
+  public String expected;
+
+  @Test
+  public void parameters_shouldHaveValues() {
+    assertThat("" + booleanValue + intValue + stringValue).isEqualTo(expected);
+  }
+
+  @Parameters(name = "{index}: booleanValue = {0}, intValue = {1}, stringValue = {2}")
+  public static Collection<Object[]> parameters() {
+    return Arrays.asList(
+        new Object[][] {
+          {true, 1, "hello", "true1hello"},
+          {false, 2, "robo", "false2robo"},
+        });
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerSingleParameterTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerSingleParameterTest.java
new file mode 100644
index 0000000..b316e23
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerSingleParameterTest.java
@@ -0,0 +1,27 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+/** Tests for the single parameter test. */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class ParameterizedRobolectricTestRunnerSingleParameterTest {
+
+  @Parameter public int intValue;
+
+  @Test
+  public void parameters_shouldHaveValues() {
+    assertThat(intValue).isNotEqualTo(0);
+  }
+
+  @Parameters
+  public static Collection<Integer> parameters() {
+    return Arrays.asList(1, 2, 3, 4, 5);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerUriTest.java b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerUriTest.java
new file mode 100644
index 0000000..81137c7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ParameterizedRobolectricTestRunnerUriTest.java
@@ -0,0 +1,48 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/**
+ * Parameterized tests using an Android class.
+ *
+ * @author John Ferlisi
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class ParameterizedRobolectricTestRunnerUriTest {
+
+  private final String basePath;
+  private final String resourcePath;
+  private final Uri expectedUri;
+
+  public ParameterizedRobolectricTestRunnerUriTest(String basePath,
+                                                   String resourcePath,
+                                                   String expectedUri) {
+    this.basePath = basePath;
+    this.resourcePath = resourcePath;
+    this.expectedUri = Uri.parse(expectedUri);
+  }
+
+  @Test
+  @Config(manifest = Config.NONE)
+  public void parse() {
+    assertThat(Uri.parse(basePath).buildUpon().path(resourcePath).build()).isEqualTo(expectedUri);
+  }
+
+  @ParameterizedRobolectricTestRunner.Parameters(name = "URI Test: {0} + {1}")
+  public static Collection getTestData() {
+    Object[][] data = {
+        { "http://host", "resource", "http://host/resource" },
+        { "http://host/", "resource", "http://host/resource" },
+        { "http://host", "/resource", "http://host/resource" },
+        { "http://host/", "/resource", "http://host/resource" }
+    };
+    return Arrays.asList(data);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/QualifiersTest.java b/robolectric/src/test/java/org/robolectric/QualifiersTest.java
new file mode 100644
index 0000000..de99cdb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/QualifiersTest.java
@@ -0,0 +1,150 @@
+package org.robolectric;
+
+import static android.os.Build.VERSION_CODES.O;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.app.Activity;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Build.VERSION_CODES;
+import android.view.View;
+import android.widget.TextView;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class QualifiersTest {
+
+  private Resources resources;
+
+  @Before
+  public void setUp() throws Exception {
+    resources = getApplicationContext().getResources();
+  }
+
+  @Test
+  @Config(sdk = 26)
+  public void testDefaultQualifiers() throws Exception {
+    assertThat(RuntimeEnvironment.getQualifiers())
+        .isEqualTo(
+            "en-rUS-ldltr-sw320dp-w320dp-h470dp-normal-notlong-notround-nowidecg-lowdr-port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav-v26");
+  }
+
+  @Test
+  @Config(qualifiers = "en", sdk = 26)
+  public void testDefaultQualifiers_withoutRegion() throws Exception {
+    assertThat(RuntimeEnvironment.getQualifiers())
+        .isEqualTo(
+            "en-ldltr-sw320dp-w320dp-h470dp-normal-notlong-notround-nowidecg-lowdr-port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav-v26");
+  }
+
+  @Test
+  @Config(qualifiers = "land")
+  public void orientation() throws Exception {
+    assertThat(resources.getConfiguration().orientation).isEqualTo(Configuration.ORIENTATION_LANDSCAPE);
+  }
+
+  @Config(qualifiers = "en")
+  @Test public void shouldBeEnglish() {
+    Locale locale = resources.getConfiguration().locale;
+    assertThat(locale.getLanguage()).isEqualTo("en");
+  }
+
+  @Config(qualifiers = "ja")
+  @Test public void shouldBeJapanese() {
+    Locale locale = resources.getConfiguration().locale;
+    assertThat(locale.getLanguage()).isEqualTo("ja");
+  }
+
+  @Config(qualifiers = "fr")
+  @Test public void shouldBeFrench() {
+    Locale locale = resources.getConfiguration().locale;
+    assertThat(locale.getLanguage()).isEqualTo("fr");
+  }
+
+  @Test @Config(qualifiers = "fr")
+  public void shouldGetFromMethod() throws Exception {
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("fr");
+  }
+
+  @Test @Config(qualifiers = "de")
+  public void getQuantityString() throws Exception {
+    assertThat(resources.getQuantityString(R.plurals.minute, 2)).isEqualTo(
+        resources.getString(R.string.minute_plural));
+  }
+
+  @Test
+  public void inflateLayout_defaultsTo_sw320dp() throws Exception {
+    View view = Robolectric.setupActivity(Activity.class).getLayoutInflater().inflate(R.layout.layout_smallest_width, null);
+    TextView textView = view.findViewById(R.id.text1);
+    assertThat(textView.getText().toString()).isEqualTo("320");
+
+    assertThat(resources.getConfiguration().smallestScreenWidthDp).isEqualTo(320);
+  }
+
+  @Test @Config(qualifiers = "sw720dp")
+  public void inflateLayout_overridesTo_sw720dp() throws Exception {
+    View view = Robolectric.setupActivity(Activity.class).getLayoutInflater().inflate(R.layout.layout_smallest_width, null);
+    TextView textView = view.findViewById(R.id.text1);
+    assertThat(textView.getText().toString()).isEqualTo("720");
+
+    assertThat(resources.getConfiguration().smallestScreenWidthDp).isEqualTo(720);
+  }
+
+  @Test @Config(qualifiers = "b+sr+Latn", minSdk = VERSION_CODES.LOLLIPOP)
+  public void supportsBcp47() throws Exception {
+    assertThat(resources.getString(R.string.hello)).isEqualTo("Zdravo");
+  }
+
+  @Test
+  public void defaultScreenWidth() {
+    assertThat(resources.getBoolean(R.bool.value_only_present_in_w320dp)).isTrue();
+    assertThat(resources.getConfiguration().screenWidthDp).isEqualTo(320);
+  }
+
+  @Test @Config(qualifiers = "land")
+  public void setQualifiers_updatesSystemAndAppResources() throws Exception {
+    Resources systemResources = Resources.getSystem();
+    Resources appResources = getApplicationContext().getResources();
+
+    assertThat(systemResources.getConfiguration().orientation).isEqualTo(
+        Configuration.ORIENTATION_LANDSCAPE);
+    assertThat(appResources.getConfiguration().orientation).isEqualTo(
+        Configuration.ORIENTATION_LANDSCAPE);
+
+    RuntimeEnvironment.setQualifiers("port");
+    assertThat(systemResources.getConfiguration().orientation).isEqualTo(
+        Configuration.ORIENTATION_PORTRAIT);
+    assertThat(appResources.getConfiguration().orientation).isEqualTo(
+        Configuration.ORIENTATION_PORTRAIT);
+  }
+
+  @Test
+  public void setQualifiers_allowsSameSdkVersion() throws Exception {
+    RuntimeEnvironment.setQualifiers("v" + RuntimeEnvironment.getApiLevel());
+  }
+
+  @Test
+  public void setQualifiers_disallowsOtherSdkVersions() throws Exception {
+    try {
+      RuntimeEnvironment.setQualifiers("v13");
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage())
+          .contains("Cannot specify conflicting platform version in qualifiers");
+    }
+  }
+
+  @Test
+  @Config(minSdk = O, qualifiers = "widecg-highdr-vrheadset")
+  public void testQualifiersNewIn26() throws Exception {
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("-widecg-highdr-");
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("-vrheadset-");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/R.java b/robolectric/src/test/java/org/robolectric/R.java
new file mode 100644
index 0000000..880955a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/R.java
@@ -0,0 +1,1057 @@
+/* AUTO-GENERATED FILE.  DO NOT MODIFY.
+ *
+ * This class was automatically generated by the
+ * aapt tool from the resource data it found.  It
+ * should not be modified by hand.
+ */
+
+package org.robolectric;
+
+public final class R {
+    public static final class anim {
+        public static final int animation_list=0x7f050000;
+        public static final int test_anim_1=0x7f050001;
+    }
+    public static final class animator {
+        public static final int fade=0x7f060000;
+        public static final int spinning=0x7f060001;
+    }
+    public static final class array {
+        public static final int alertDialogTestItems=0x7f0e0006;
+        public static final int emailAddressTypes=0x7f0e0007;
+        public static final int empty_int_array=0x7f0e0001;
+        public static final int greetings=0x7f0e0005;
+        public static final int items=0x7f0e0008;
+        public static final int more_items=0x7f0e0004;
+        public static final int referenced_colors_int_array=0x7f0e0003;
+        public static final int string_array_values=0x7f0e000c;
+        public static final int typed_array_references=0x7f0e000a;
+        public static final int typed_array_values=0x7f0e0009;
+        public static final int typed_array_with_resource_id=0x7f0e000b;
+        public static final int with_references_int_array=0x7f0e0002;
+        public static final int zero_to_four_int_array=0x7f0e0000;
+    }
+    public static final class attr {
+        /** <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+         */
+        public static final int altTitle=0x7f010025;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int anAttribute=0x7f01001a;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int animalStyle=0x7f01000a;
+        /** <p>Must be a floating point value, such as "<code>1.2</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int aspectRatio=0x7f010008;
+        /** <p>Must be a boolean value, either "<code>true</code>" or "<code>false</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int aspectRatioEnabled=0x7f010009;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int attributeReferencingAnAttribute=0x7f01001b;
+        /** <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a dimension value, which is a floating point number appended with a unit such as "<code>14.5sp</code>".
+Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),
+in (inches), mm (millimeters).
+         */
+        public static final int averageSheepWidth=0x7f01001f;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int bar=0x7f010000;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int child_string=0x7f010015;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int child_string2=0x7f010016;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int circularReference=0x7f01001c;
+        /** <p>Must be one or more (separated by '|') of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>center</code></td><td>0x11</td><td></td></tr>
+<tr><td><code>fill_vertical</code></td><td>0x70</td><td></td></tr>
+</table>
+         */
+        public static final int gravity=0x7f01000e;
+        /** <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a boolean value, either "<code>true</code>" or "<code>false</code>".
+         */
+        public static final int isSugary=0x7f010020;
+        /** <p>Must be one of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>marsupial</code></td><td>0</td><td></td></tr>
+<tr><td><code>ungulate</code></td><td>1</td><td></td></tr>
+</table>
+         */
+        public static final int itemType=0x7f010002;
+        /** <p>Must be one of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>KEYCODE_SOFT_RIGHT</code></td><td>2</td><td></td></tr>
+<tr><td><code>KEYCODE_HOME</code></td><td>3</td><td></td></tr>
+</table>
+         */
+        public static final int keycode=0x7f01000f;
+        /** <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a dimension value, which is a floating point number appended with a unit such as "<code>14.5sp</code>".
+Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),
+in (inches), mm (millimeters).
+         */
+        public static final int logoHeight=0x7f010021;
+        /** <p>Must be a dimension value, which is a floating point number appended with a unit such as "<code>14.5sp</code>".
+Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),
+in (inches), mm (millimeters).
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int logoWidth=0x7f010022;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int message=0x7f010003;
+        /** <p>May be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>May be an integer value, such as "<code>100</code>".
+<p>May be a boolean value, either "<code>true</code>" or "<code>false</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int multiformat=0x7f010001;
+        /** <p>May be an integer value, such as "<code>100</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+<p>May be one of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>auto_fit</code></td><td>-1</td><td> Display as many columns as possible to fill the available space. </td></tr>
+</table>
+         */
+        public static final int numColumns=0x7f010006;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int parentStyleReference=0x7f010017;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int parent_string=0x7f010014;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int quitKeyCombo=0x7f010005;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int responses=0x7f010010;
+        /** <p>Must be one or more (separated by '|') of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>horizontal</code></td><td>0x00000100</td><td></td></tr>
+<tr><td><code>vertical</code></td><td>0x00000200</td><td></td></tr>
+<tr><td><code>sideways</code></td><td>0x00000400</td><td></td></tr>
+</table>
+         */
+        public static final int scrollBars=0x7f010004;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int snail=0x7f010024;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int someLayoutOne=0x7f01000c;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int someLayoutTwo=0x7f01000d;
+        /** <p>Must be a boolean value, either "<code>true</code>" or "<code>false</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int stateFoo=0x7f01001e;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int string1=0x7f010011;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int string2=0x7f010012;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int string3=0x7f010013;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int stringReference=0x7f010019;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int styleNotSpecifiedInAnyTheme=0x7f010018;
+        /** <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+         */
+        public static final int styleReference=0x7f010023;
+        /** <p>Must be an integer value, such as "<code>100</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int sugarinessPercent=0x7f010007;
+        /** <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int title=0x7f01001d;
+        /**  Test the same attr name as android namespace with different format 
+         <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+         */
+        public static final int typeface=0x7f01000b;
+    }
+    public static final class bool {
+        public static final int different_resource_boolean=0x7f090000;
+        public static final int false_bool_value=0x7f090001;
+        public static final int reference_to_true=0x7f090003;
+        public static final int true_as_item=0x7f090004;
+        public static final int true_bool_value=0x7f090002;
+        public static final int typed_array_true=0x7f090005;
+        public static final int value_only_present_in_w320dp=0x7f090006;
+    }
+    public static final class color {
+        public static final int android_namespaced_black=0x7f0a0008;
+        public static final int android_namespaced_transparent=0x7f0a0009;
+        public static final int background=0x7f0a0007;
+        public static final int black=0x7f0a0003;
+        public static final int blue=0x7f0a0004;
+        public static final int clear=0x7f0a0001;
+        public static final int color_state_list=0x7f0a0010;
+        public static final int color_with_alpha=0x7f0a0006;
+        public static final int custom_state_view_text_color=0x7f0a0011;
+        public static final int foreground=0x7f0a0000;
+        public static final int grey42=0x7f0a0005;
+        public static final int list_separator=0x7f0a000a;
+        public static final int test_ARGB4=0x7f0a000c;
+        public static final int test_ARGB8=0x7f0a000d;
+        public static final int test_RGB4=0x7f0a000e;
+        public static final int test_RGB8=0x7f0a000f;
+        public static final int typed_array_orange=0x7f0a000b;
+        public static final int white=0x7f0a0002;
+    }
+    public static final class dimen {
+        public static final int ref_to_px_dimen=0x7f0b0007;
+        public static final int test_dip_dimen=0x7f0b0001;
+        public static final int test_dp_dimen=0x7f0b0000;
+        public static final int test_in_dimen=0x7f0b0006;
+        public static final int test_mm_dimen=0x7f0b0005;
+        public static final int test_pt_dimen=0x7f0b0002;
+        public static final int test_px_dimen=0x7f0b0003;
+        public static final int test_sp_dimen=0x7f0b0004;
+    }
+    public static final class drawable {
+        public static final int an_image=0x7f020000;
+        public static final int an_image_or_vector=0x7f020001;
+        public static final int an_other_image=0x7f020002;
+        public static final int drawable_with_nine_patch=0x7f020003;
+        public static final int example_item_drawable=0x7f02001d;
+        public static final int fourth_image=0x7f020004;
+        public static final int image_background=0x7f020005;
+        public static final int l0_red=0x7f020006;
+        public static final int l1_orange=0x7f020007;
+        public static final int l2_yellow=0x7f020008;
+        public static final int l3_green=0x7f020009;
+        public static final int l4_blue=0x7f02000a;
+        public static final int l5_indigo=0x7f02000b;
+        public static final int l6_violet=0x7f02000c;
+        public static final int l7_white=0x7f02000d;
+        public static final int nine_patch_drawable=0x7f02000e;
+        public static final int pure_black=0x7f02000f;
+        public static final int pure_blue=0x7f020010;
+        public static final int pure_green=0x7f020011;
+        public static final int pure_red=0x7f020012;
+        public static final int pure_white=0x7f020013;
+        public static final int rainbow=0x7f020014;
+        public static final int robolectric=0x7f020015;
+        public static final int state_drawable=0x7f020016;
+        public static final int test_jpeg=0x7f020017;
+        public static final int test_webp=0x7f020018;
+        public static final int test_webp_lossless=0x7f020019;
+        public static final int test_webp_lossy=0x7f02001a;
+        public static final int third_image=0x7f02001b;
+        public static final int vector=0x7f02001c;
+    }
+    public static final class fraction {
+        public static final int fifth=0x7f0c0004;
+        public static final int fifth_as_reference=0x7f0c0005;
+        public static final int fifth_of_parent=0x7f0c0006;
+        public static final int fifth_of_parent_as_reference=0x7f0c0007;
+        public static final int half=0x7f0c0000;
+        public static final int half_of_parent=0x7f0c0001;
+        public static final int quarter_as_item=0x7f0c0002;
+        public static final int quarter_of_parent_as_item=0x7f0c0003;
+    }
+    public static final class id {
+        public static final int KEYCODE_HOME=0x7f0d000b;
+        public static final int KEYCODE_SOFT_RIGHT=0x7f0d000c;
+        public static final int action_search=0x7f0d003e;
+        public static final int auto_fit=0x7f0d0008;
+        public static final int black_text_view=0x7f0d0036;
+        public static final int black_text_view_hint=0x7f0d0039;
+        public static final int burritos=0x7f0d0018;
+        public static final int button=0x7f0d0023;
+        public static final int center=0x7f0d0009;
+        public static final int custom_title_text=0x7f0d0012;
+        public static final int custom_view=0x7f0d0011;
+        public static final int declared_id=0x7f0d0002;
+        public static final int default_checkbox=0x7f0d0020;
+        public static final int dynamic_fragment_container=0x7f0d0016;
+        public static final int edit_text=0x7f0d0032;
+        public static final int edit_text2=0x7f0d0033;
+        public static final int false_checkbox=0x7f0d001f;
+        public static final int fill_vertical=0x7f0d000a;
+        public static final int fragment=0x7f0d0015;
+        public static final int fragment_container=0x7f0d0014;
+        public static final int grey_text_view=0x7f0d0038;
+        public static final int grey_text_view_hint=0x7f0d003b;
+        public static final int group_id_1=0x7f0d0041;
+        public static final int hello=0x7f0d000e;
+        public static final int horizontal=0x7f0d0005;
+        public static final int icon=0x7f0d000d;
+        public static final int id_declared_in_item_tag=0x7f0d0000;
+        public static final int id_declared_in_layout=0x7f0d000f;
+        public static final int id_with_string_value=0x7f0d0001;
+        public static final int image=0x7f0d0021;
+        public static final int include_id=0x7f0d0025;
+        public static final int inner_text=0x7f0d0019;
+        public static final int invalid_onclick_button=0x7f0d003d;
+        public static final int landscape=0x7f0d0027;
+        public static final int list_view_with_enum_scrollbar=0x7f0d0029;
+        public static final int main=0x7f0d0035;
+        public static final int map_view=0x7f0d0024;
+        public static final int marsupial=0x7f0d0003;
+        public static final int mipmapImage=0x7f0d0022;
+        public static final int my_fragment=0x7f0d0013;
+        public static final int my_landscape_text=0x7f0d0028;
+        public static final int outer_merge=0x7f0d002a;
+        public static final int portrait=0x7f0d0026;
+        public static final int progress_bar=0x7f0d002b;
+        public static final int remote_view_1=0x7f0d002d;
+        public static final int remote_view_2=0x7f0d002e;
+        public static final int remote_view_3=0x7f0d002f;
+        public static final int remote_views_alt_root=0x7f0d0030;
+        public static final int remote_views_bad_root=0x7f0d0031;
+        public static final int remote_views_root=0x7f0d002c;
+        public static final int sideways=0x7f0d0006;
+        public static final int snippet_text=0x7f0d0034;
+        public static final int subtitle=0x7f0d001d;
+        public static final int tacos=0x7f0d0017;
+        public static final int test_menu_1=0x7f0d003f;
+        public static final int test_menu_2=0x7f0d0040;
+        public static final int test_menu_3=0x7f0d0042;
+        public static final int test_submenu_1=0x7f0d0043;
+        public static final int text1=0x7f0d001a;
+        public static final int time=0x7f0d001b;
+        public static final int title=0x7f0d001c;
+        public static final int true_checkbox=0x7f0d001e;
+        public static final int ungulate=0x7f0d0004;
+        public static final int vertical=0x7f0d0007;
+        public static final int web_view=0x7f0d003c;
+        public static final int white_text_view=0x7f0d0037;
+        public static final int white_text_view_hint=0x7f0d003a;
+        public static final int world=0x7f0d0010;
+    }
+    public static final class integer {
+        public static final int hex_int=0x7f100009;
+        public static final int loneliest_number=0x7f100007;
+        public static final int meaning_of_life=0x7f100006;
+        public static final int meaning_of_life_as_item=0x7f10000b;
+        public static final int reference_to_meaning_of_life=0x7f10000a;
+        public static final int scrollbar_style_ordinal_outside_overlay=0x7f100004;
+        public static final int test_integer1=0x7f100000;
+        public static final int test_integer2=0x7f100001;
+        public static final int test_large_hex=0x7f100002;
+        public static final int test_value_with_zero=0x7f100003;
+        public static final int there_can_be_only=0x7f100008;
+        public static final int typed_array_5=0x7f100005;
+    }
+    public static final class layout {
+        public static final int activity_list_item=0x7f040000;
+        public static final int activity_main=0x7f040001;
+        public static final int activity_main_1=0x7f040002;
+        public static final int custom_layout=0x7f040003;
+        public static final int custom_layout2=0x7f040004;
+        public static final int custom_layout3=0x7f040005;
+        public static final int custom_layout4=0x7f040006;
+        public static final int custom_layout5=0x7f040007;
+        public static final int custom_layout6=0x7f040008;
+        public static final int custom_title=0x7f040009;
+        public static final int different_screen_sizes=0x7f04000a;
+        public static final int edit_text=0x7f04000b;
+        public static final int fragment=0x7f04000c;
+        public static final int fragment_activity=0x7f04000d;
+        public static final int fragment_contents=0x7f04000e;
+        public static final int included_layout_parent=0x7f04000f;
+        public static final int included_linear_layout=0x7f040010;
+        public static final int inner_merge=0x7f040011;
+        public static final int layout_320_smallest_width=0x7f040012;
+        public static final int layout_smallest_width=0x7f040013;
+        public static final int main=0x7f040014;
+        public static final int main_layout=0x7f04002a;
+        public static final int mapview=0x7f040015;
+        public static final int media=0x7f040016;
+        public static final int multi_orientation=0x7f040017;
+        public static final int multiline_layout=0x7f04002b;
+        public static final int ordinal_scrollbar=0x7f040018;
+        public static final int outer=0x7f040019;
+        public static final int override_include=0x7f04001a;
+        public static final int progress_bar=0x7f04001b;
+        public static final int remote_views=0x7f04001c;
+        public static final int remote_views_alt=0x7f04001d;
+        public static final int remote_views_bad=0x7f04001e;
+        public static final int request_focus=0x7f04001f;
+        public static final int request_focus_with_two_edit_texts=0x7f040020;
+        public static final int snippet=0x7f040021;
+        public static final int styles_button_layout=0x7f040022;
+        public static final int styles_button_with_style_layout=0x7f040023;
+        public static final int tab_activity=0x7f040024;
+        public static final int text_views=0x7f040025;
+        public static final int text_views_hints=0x7f040026;
+        public static final int toplevel_merge=0x7f040027;
+        public static final int webview_holder=0x7f040028;
+        public static final int with_invalid_onclick=0x7f040029;
+    }
+    public static final class menu {
+        public static final int action_menu=0x7f130000;
+        public static final int test=0x7f130001;
+        public static final int test_withchilds=0x7f130002;
+        public static final int test_withorder=0x7f130003;
+    }
+    public static final class mipmap {
+        public static final int mipmap_reference=0x7f030002;
+        public static final int mipmap_reference_xml=0x7f030003;
+        public static final int robolectric=0x7f030000;
+        public static final int robolectric_xml=0x7f030001;
+    }
+    public static final class plurals {
+        public static final int beer=0x7f110000;
+        public static final int minute=0x7f110001;
+    }
+    public static final class raw {
+        public static final int raw_no_ext=0x7f080000;
+        public static final int raw_resource=0x7f080001;
+        public static final int sound=0x7f080002;
+    }
+    public static final class string {
+        public static final int activity_name=0x7f0f0015;
+        public static final int app_name=0x7f0f0014;
+        /** 
+    Resources to validate examples from https://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling
+  
+         */
+        public static final int bad_example=0x7f0f0026;
+        public static final int copy=0x7f0f0005;
+        public static final int escaped_apostrophe=0x7f0f000e;
+        public static final int escaped_quotes=0x7f0f000f;
+        public static final int greeting=0x7f0f0001;
+        public static final int hello=0x7f0f0003;
+        public static final int howdy=0x7f0f0002;
+        public static final int in_all_libs=0x7f0f0008;
+        public static final int in_main_and_lib1=0x7f0f0009;
+        public static final int internal_newlines=0x7f0f0025;
+        public static final int internal_whitespace_blocks=0x7f0f0024;
+        public static final int interpolate=0x7f0f000c;
+        public static final int leading_and_trailing_new_lines=0x7f0f0010;
+        public static final int minute_plural=0x7f0f0017;
+        public static final int minute_singular=0x7f0f0016;
+        public static final int new_lines_and_tabs=0x7f0f0011;
+        public static final int non_breaking_space=0x7f0f0012;
+        public static final int not_in_the_r_file=0x7f0f0006;
+        public static final int ok=0x7f0f000a;
+        public static final int ok2=0x7f0f000b;
+        public static final int only_in_main=0x7f0f0007;
+        public static final int preference_resource_default_value=0x7f0f001c;
+        public static final int preference_resource_key=0x7f0f0019;
+        public static final int preference_resource_summary=0x7f0f001b;
+        public static final int preference_resource_title=0x7f0f001a;
+        public static final int say_it_with_item=0x7f0f001e;
+        public static final int some_html=0x7f0f0004;
+        public static final int space=0x7f0f0013;
+        public static final int str_int=0x7f0f0018;
+        public static final int string_with_spaces=0x7f0f0023;
+        public static final int surrounding_quotes=0x7f0f000d;
+        public static final int test_menu_2=0x7f0f001d;
+        public static final int test_non_integer=0x7f0f0000;
+        public static final int test_permission_description=0x7f0f0021;
+        public static final int test_permission_label=0x7f0f0022;
+        public static final int typed_array_a=0x7f0f001f;
+        public static final int typed_array_b=0x7f0f0020;
+    }
+    public static final class style {
+        public static final int Gastropod=0x7f12000f;
+        public static final int IndirectButtonStyle=0x7f120013;
+        public static final int MyBlackTheme=0x7f120010;
+        public static final int MyBlueTheme=0x7f120011;
+        public static final int MyCustomView=0x7f12000c;
+        public static final int SimpleChildWithAdditionalAttributes=0x7f120017;
+        public static final int SimpleChildWithOverride=0x7f120015;
+        /**  Styles for testing inheritance 
+         */
+        public static final int SimpleParent=0x7f120014;
+        public static final int SimpleParent_ImplicitChild=0x7f120016;
+        public static final int Sized=0x7f12000e;
+        public static final int SomeStyleable=0x7f12000d;
+        public static final int StyleA=0x7f120018;
+        public static final int StyleB=0x7f120019;
+        public static final int StyleWithAttributeReference=0x7f12001b;
+        public static final int StyleWithCircularReference=0x7f12001c;
+        public static final int StyleWithMultipleAttributes=0x7f12001d;
+        public static final int StyleWithReference=0x7f12001a;
+        public static final int Theme=0x7f120005;
+        public static final int Theme_AnotherTheme=0x7f120003;
+        public static final int Theme_Robolectric=0x7f120000;
+        public static final int Theme_Robolectric_EmptyParent=0x7f120002;
+        public static final int Theme_Robolectric_ImplicitChild=0x7f120001;
+        public static final int Theme_ThemeContainingStyleReferences=0x7f120007;
+        public static final int Theme_ThemeReferredToByParentAttrReference=0x7f120006;
+        public static final int Theme_ThirdTheme=0x7f120004;
+        public static final int ThemeWithSelfReferencingTextAttr=0x7f120012;
+        public static final int Widget_AnotherTheme_Button=0x7f12000a;
+        public static final int Widget_AnotherTheme_Button_Blarf=0x7f12000b;
+        public static final int Widget_Robolectric_Button=0x7f120009;
+        public static final int YetAnotherStyle=0x7f120008;
+    }
+    public static final class xml {
+        public static final int app_restrictions=0x7f070000;
+        public static final int dialog_preferences=0x7f070001;
+        public static final int has_attribute_resource_value=0x7f070002;
+        public static final int has_id=0x7f070003;
+        public static final int has_parent_style_reference=0x7f070004;
+        public static final int has_style_attribute_reference=0x7f070005;
+        public static final int preferences=0x7f070006;
+        public static final int shortcuts=0x7f070007;
+        public static final int temp=0x7f070008;
+        public static final int temp_parent=0x7f070009;
+        public static final int test_wallpaper=0x7f07000a;
+        public static final int xml_attrs=0x7f07000b;
+    }
+    public static final class styleable {
+        /** Attributes that can be used with a CustomStateView.
+           <p>Includes the following attributes:</p>
+           <table>
+           <colgroup align="left" />
+           <colgroup align="left" />
+           <tr><th>Attribute</th><th>Description</th></tr>
+           <tr><td><code>{@link #CustomStateView_stateFoo org.robolectric:stateFoo}</code></td><td></td></tr>
+           </table>
+           @see #CustomStateView_stateFoo
+         */
+        public static final int[] CustomStateView = {
+            0x7f01001e
+        };
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#stateFoo}
+          attribute's value can be found in the {@link #CustomStateView} array.
+
+
+          <p>Must be a boolean value, either "<code>true</code>" or "<code>false</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:stateFoo
+        */
+        public static final int CustomStateView_stateFoo = 0;
+        /** Attributes that can be used with a CustomView.
+           <p>Includes the following attributes:</p>
+           <table>
+           <colgroup align="left" />
+           <colgroup align="left" />
+           <tr><th>Attribute</th><th>Description</th></tr>
+           <tr><td><code>{@link #CustomView_animalStyle org.robolectric:animalStyle}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_aspectRatio org.robolectric:aspectRatio}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_aspectRatioEnabled org.robolectric:aspectRatioEnabled}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_bar org.robolectric:bar}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_gravity org.robolectric:gravity}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_itemType org.robolectric:itemType}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_keycode org.robolectric:keycode}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_message org.robolectric:message}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_multiformat org.robolectric:multiformat}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_numColumns org.robolectric:numColumns}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_quitKeyCombo org.robolectric:quitKeyCombo}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_scrollBars org.robolectric:scrollBars}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_someLayoutOne org.robolectric:someLayoutOne}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_someLayoutTwo org.robolectric:someLayoutTwo}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_sugarinessPercent org.robolectric:sugarinessPercent}</code></td><td></td></tr>
+           <tr><td><code>{@link #CustomView_typeface org.robolectric:typeface}</code></td><td> Test the same attr name as android namespace with different format </td></tr>
+           </table>
+           @see #CustomView_animalStyle
+           @see #CustomView_aspectRatio
+           @see #CustomView_aspectRatioEnabled
+           @see #CustomView_bar
+           @see #CustomView_gravity
+           @see #CustomView_itemType
+           @see #CustomView_keycode
+           @see #CustomView_message
+           @see #CustomView_multiformat
+           @see #CustomView_numColumns
+           @see #CustomView_quitKeyCombo
+           @see #CustomView_scrollBars
+           @see #CustomView_someLayoutOne
+           @see #CustomView_someLayoutTwo
+           @see #CustomView_sugarinessPercent
+           @see #CustomView_typeface
+         */
+        public static final int[] CustomView = {
+            0x7f010000, 0x7f010001, 0x7f010002, 0x7f010003,
+            0x7f010004, 0x7f010005, 0x7f010006, 0x7f010007,
+            0x7f010008, 0x7f010009, 0x7f01000a, 0x7f01000b,
+            0x7f01000c, 0x7f01000d, 0x7f01000e, 0x7f01000f
+        };
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#animalStyle}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+          @attr name org.robolectric:animalStyle
+        */
+        public static final int CustomView_animalStyle = 10;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#aspectRatio}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a floating point value, such as "<code>1.2</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:aspectRatio
+        */
+        public static final int CustomView_aspectRatio = 8;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#aspectRatioEnabled}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a boolean value, either "<code>true</code>" or "<code>false</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:aspectRatioEnabled
+        */
+        public static final int CustomView_aspectRatioEnabled = 9;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#bar}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:bar
+        */
+        public static final int CustomView_bar = 0;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#gravity}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be one or more (separated by '|') of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>center</code></td><td>0x11</td><td></td></tr>
+<tr><td><code>fill_vertical</code></td><td>0x70</td><td></td></tr>
+</table>
+          @attr name org.robolectric:gravity
+        */
+        public static final int CustomView_gravity = 14;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#itemType}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be one of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>marsupial</code></td><td>0</td><td></td></tr>
+<tr><td><code>ungulate</code></td><td>1</td><td></td></tr>
+</table>
+          @attr name org.robolectric:itemType
+        */
+        public static final int CustomView_itemType = 2;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#keycode}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be one of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>KEYCODE_SOFT_RIGHT</code></td><td>2</td><td></td></tr>
+<tr><td><code>KEYCODE_HOME</code></td><td>3</td><td></td></tr>
+</table>
+          @attr name org.robolectric:keycode
+        */
+        public static final int CustomView_keycode = 15;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#message}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:message
+        */
+        public static final int CustomView_message = 3;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#multiformat}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>May be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>May be an integer value, such as "<code>100</code>".
+<p>May be a boolean value, either "<code>true</code>" or "<code>false</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:multiformat
+        */
+        public static final int CustomView_multiformat = 1;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#numColumns}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>May be an integer value, such as "<code>100</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+<p>May be one of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>auto_fit</code></td><td>-1</td><td> Display as many columns as possible to fill the available space. </td></tr>
+</table>
+          @attr name org.robolectric:numColumns
+        */
+        public static final int CustomView_numColumns = 6;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#quitKeyCombo}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:quitKeyCombo
+        */
+        public static final int CustomView_quitKeyCombo = 5;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#scrollBars}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be one or more (separated by '|') of the following constant values.</p>
+<table>
+<colgroup align="left" />
+<colgroup align="left" />
+<colgroup align="left" />
+<tr><th>Constant</th><th>Value</th><th>Description</th></tr>
+<tr><td><code>horizontal</code></td><td>0x00000100</td><td></td></tr>
+<tr><td><code>vertical</code></td><td>0x00000200</td><td></td></tr>
+<tr><td><code>sideways</code></td><td>0x00000400</td><td></td></tr>
+</table>
+          @attr name org.robolectric:scrollBars
+        */
+        public static final int CustomView_scrollBars = 4;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#someLayoutOne}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+          @attr name org.robolectric:someLayoutOne
+        */
+        public static final int CustomView_someLayoutOne = 12;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#someLayoutTwo}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+          @attr name org.robolectric:someLayoutTwo
+        */
+        public static final int CustomView_someLayoutTwo = 13;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#sugarinessPercent}
+          attribute's value can be found in the {@link #CustomView} array.
+
+
+          <p>Must be an integer value, such as "<code>100</code>".
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:sugarinessPercent
+        */
+        public static final int CustomView_sugarinessPercent = 7;
+        /**
+          <p>
+          @attr description
+           Test the same attr name as android namespace with different format 
+
+
+          <p>Must be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          <p>This is a private symbol.
+          @attr name org.robolectric:typeface
+        */
+        public static final int CustomView_typeface = 11;
+        /** Attributes that can be used with a Theme_AnotherTheme_Attributes.
+           <p>Includes the following attributes:</p>
+           <table>
+           <colgroup align="left" />
+           <colgroup align="left" />
+           <tr><th>Attribute</th><th>Description</th></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_altTitle org.robolectric:altTitle}</code></td><td></td></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_averageSheepWidth org.robolectric:averageSheepWidth}</code></td><td></td></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_isSugary org.robolectric:isSugary}</code></td><td></td></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_logoHeight org.robolectric:logoHeight}</code></td><td></td></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_logoWidth org.robolectric:logoWidth}</code></td><td></td></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_snail org.robolectric:snail}</code></td><td></td></tr>
+           <tr><td><code>{@link #Theme_AnotherTheme_Attributes_styleReference org.robolectric:styleReference}</code></td><td></td></tr>
+           </table>
+           @see #Theme_AnotherTheme_Attributes_altTitle
+           @see #Theme_AnotherTheme_Attributes_averageSheepWidth
+           @see #Theme_AnotherTheme_Attributes_isSugary
+           @see #Theme_AnotherTheme_Attributes_logoHeight
+           @see #Theme_AnotherTheme_Attributes_logoWidth
+           @see #Theme_AnotherTheme_Attributes_snail
+           @see #Theme_AnotherTheme_Attributes_styleReference
+         */
+        public static final int[] Theme_AnotherTheme_Attributes = {
+            0x7f01001f, 0x7f010020, 0x7f010021, 0x7f010022,
+            0x7f010023, 0x7f010024, 0x7f010025
+        };
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#altTitle}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a string value, using '\\;' to escape characters such as '\\n' or '\\uxxxx' for a unicode character.
+          @attr name org.robolectric:altTitle
+        */
+        public static final int Theme_AnotherTheme_Attributes_altTitle = 6;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#averageSheepWidth}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a dimension value, which is a floating point number appended with a unit such as "<code>14.5sp</code>".
+Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),
+in (inches), mm (millimeters).
+          @attr name org.robolectric:averageSheepWidth
+        */
+        public static final int Theme_AnotherTheme_Attributes_averageSheepWidth = 0;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#isSugary}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a boolean value, either "<code>true</code>" or "<code>false</code>".
+          @attr name org.robolectric:isSugary
+        */
+        public static final int Theme_AnotherTheme_Attributes_isSugary = 1;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#logoHeight}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>May be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+<p>May be a dimension value, which is a floating point number appended with a unit such as "<code>14.5sp</code>".
+Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),
+in (inches), mm (millimeters).
+          @attr name org.robolectric:logoHeight
+        */
+        public static final int Theme_AnotherTheme_Attributes_logoHeight = 2;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#logoWidth}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>Must be a dimension value, which is a floating point number appended with a unit such as "<code>14.5sp</code>".
+Available units are: px (pixels), dp (density-independent pixels), sp (scaled pixels based on preferred font size),
+in (inches), mm (millimeters).
+<p>This may also be a reference to a resource (in the form
+"<code>@[<i>package</i>:]<i>type</i>:<i>name</i></code>") or
+theme attribute (in the form
+"<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>")
+containing a value of this type.
+          @attr name org.robolectric:logoWidth
+        */
+        public static final int Theme_AnotherTheme_Attributes_logoWidth = 3;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#snail}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+          @attr name org.robolectric:snail
+        */
+        public static final int Theme_AnotherTheme_Attributes_snail = 5;
+        /**
+          <p>This symbol is the offset where the {@link org.robolectric.R.attr#styleReference}
+          attribute's value can be found in the {@link #Theme_AnotherTheme_Attributes} array.
+
+
+          <p>Must be a reference to another resource, in the form "<code>@[+][<i>package</i>:]<i>type</i>:<i>name</i></code>"
+or to a theme attribute in the form "<code>?[<i>package</i>:][<i>type</i>:]<i>name</i></code>".
+          @attr name org.robolectric:styleReference
+        */
+        public static final int Theme_AnotherTheme_Attributes_styleReference = 4;
+    };
+}
diff --git a/robolectric/src/test/java/org/robolectric/ReflectorObjectTest.java b/robolectric/src/test/java/org/robolectric/ReflectorObjectTest.java
new file mode 100644
index 0000000..a0b8377
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/ReflectorObjectTest.java
@@ -0,0 +1,74 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ReflectorObjectTest.ShadowClass;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Reflector;
+
+/** Tests for @{@link Direct} annotation incorporated inside {@link Reflector}. */
+@RunWith(AndroidJUnit4.class)
+@Config(shadows = ShadowClass.class)
+public class ReflectorObjectTest {
+
+  static final String TEST_STRING = "A test string.";
+
+  private SomeClass someClass;
+  private ShadowClass shadowClass;
+
+  @Before
+  public void setUp() throws Exception {
+    someClass = new SomeClass();
+    shadowClass = Shadow.extract(someClass);
+  }
+
+  @Test
+  public void reflectorObject_shouldCallSameImplementationAsReflector() {
+    SomeClassReflector classReflector = shadowClass.objectReflector;
+    assertThat(classReflector).isNotNull();
+
+    assertThat(classReflector.someMethod()).isEqualTo(TEST_STRING);
+    assertThat(classReflector.someMethod())
+        .isEqualTo(reflector(SomeClassReflector.class, shadowClass.realObject).someMethod());
+  }
+
+  /** Basic class to be instrumented for testing. */
+  @Instrument
+  public static class SomeClass {
+
+    String someMethod() {
+      return TEST_STRING;
+    }
+  }
+
+  /** Shadow of {@link SomeClass} that changes all method implementations. */
+  @Implements(SomeClass.class)
+  public static class ShadowClass {
+
+    @RealObject SomeClass realObject;
+
+    @ReflectorObject SomeClassReflector objectReflector;
+  }
+
+  /**
+   * Accessor interface for {@link SomeClass}'s internals with the @{@link Direct} annotation at the
+   * class level.
+   */
+  @ForType(value = SomeClass.class)
+  interface SomeClassReflector {
+
+    String someMethod();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTest.java
new file mode 100644
index 0000000..b0929b9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RobolectricTest.java
@@ -0,0 +1,142 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewParent;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.ShadowView;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class RobolectricTest {
+
+  private Application context = ApplicationProvider.getApplicationContext();
+
+  @Test
+  public void clickOn_shouldThrowIfViewIsDisabled() throws Exception {
+    View view = new View(context);
+    view.setEnabled(false);
+    assertThrows(RuntimeException.class, () -> ShadowView.clickOn(view));
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void shouldResetBackgroundSchedulerBeforeTests() throws Exception {
+    assertThat(Robolectric.getBackgroundThreadScheduler().isPaused()).isFalse();
+    Robolectric.getBackgroundThreadScheduler().pause();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void shouldResetBackgroundSchedulerAfterTests() throws Exception {
+    assertThat(Robolectric.getBackgroundThreadScheduler().isPaused()).isFalse();
+    Robolectric.getBackgroundThreadScheduler().pause();
+  }
+
+  @Test
+  public void idleMainLooper_executesScheduledTasks() {
+    final boolean[] wasRun = new boolean[] {false};
+    new Handler().postDelayed(() -> wasRun[0] = true, 2000);
+
+    assertFalse(wasRun[0]);
+    ShadowLooper.idleMainLooper(1999, TimeUnit.MILLISECONDS);
+    assertFalse(wasRun[0]);
+    ShadowLooper.idleMainLooper(1, TimeUnit.MILLISECONDS);
+    assertTrue(wasRun[0]);
+  }
+
+  @Test
+  public void clickOn_shouldCallClickListener() throws Exception {
+    View view = new View(context);
+    shadowOf(view).setMyParent(ReflectionHelpers.createNullProxy(ViewParent.class));
+    OnClickListener testOnClickListener = mock(OnClickListener.class);
+    view.setOnClickListener(testOnClickListener);
+    ShadowView.clickOn(view);
+
+    verify(testOnClickListener).onClick(view);
+  }
+
+  @Test
+  public void checkActivities_shouldSetValueOnShadowApplication() throws Exception {
+    ShadowApplication.getInstance().checkActivities(true);
+    assertThrows(
+        ActivityNotFoundException.class,
+        () ->
+            context.startActivity(
+                new Intent("i.dont.exist.activity").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)));
+  }
+
+  @Test
+  @Config(sdk = 16)
+  public void setupActivity_returnsAVisibleActivity() throws Exception {
+    LifeCycleActivity activity = Robolectric.setupActivity(LifeCycleActivity.class);
+
+    assertThat(activity.isCreated()).isTrue();
+    assertThat(activity.isStarted()).isTrue();
+    assertThat(activity.isResumed()).isTrue();
+    assertThat(activity.isVisible()).isTrue();
+  }
+
+  @Implements(View.class)
+  public static class TestShadowView {
+    @Implementation
+    protected Context getContext() {
+      return null;
+    }
+  }
+
+  private static class LifeCycleActivity extends Activity {
+    private boolean created;
+    private boolean started;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      created = true;
+    }
+
+    @Override
+    protected void onStart() {
+      super.onStart();
+      started = true;
+    }
+
+    public boolean isStarted() {
+      return started;
+    }
+
+    public boolean isCreated() {
+      return created;
+    }
+
+    public boolean isVisible() {
+      return getWindow().getDecorView().getWindowToken() != null;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerClassLoaderConfigTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerClassLoaderConfigTest.java
new file mode 100644
index 0000000..bc0d4f9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerClassLoaderConfigTest.java
@@ -0,0 +1,34 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.internal.AndroidSandbox;
+import org.robolectric.internal.bytecode.SandboxClassLoader;
+import org.robolectric.test.DummyClass;
+
+@RunWith(AndroidJUnit4.class)
+public class RobolectricTestRunnerClassLoaderConfigTest {
+
+  @Test
+  public void testUsingClassLoader() {
+    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
+    assertThat(classLoader.getClass().getName())
+        .isEqualTo(AndroidSandbox.SdkSandboxClassLoader.class.getName());
+  }
+
+  @Test
+  public void testGetPackage() {
+    assertThat(DummyClass.class.getClassLoader()).isInstanceOf(SandboxClassLoader.class);
+    assertThat(DummyClass.class.getPackage()).isNotNull();
+    assertThat(DummyClass.class.getName()).startsWith(DummyClass.class.getPackage().getName());
+  }
+
+  @Test public void testPackagesFromParentClassLoaderAreMadeAvailableByName() {
+    assertThat(Test.class.getPackage()).isNotNull();
+    assertThat(Package.getPackage("org.junit")).isNotNull();
+    assertThat(Package.getPackage("org.junit")).isEqualTo(Test.class.getPackage());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerMultiApiTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerMultiApiTest.java
new file mode 100644
index 0000000..1fff273
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerMultiApiTest.java
@@ -0,0 +1,358 @@
+package org.robolectric;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.RobolectricTestRunner.defaultInjector;
+
+import android.os.Build;
+import com.google.common.collect.Range;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runner.notification.RunListener;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.JUnit4;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+import org.robolectric.annotation.Config;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkPicker;
+import org.robolectric.plugins.DefaultSdkPicker;
+import org.robolectric.plugins.SdkCollection;
+import org.robolectric.util.TestUtil;
+import org.robolectric.util.inject.Injector;
+
+@RunWith(JUnit4.class)
+public class RobolectricTestRunnerMultiApiTest {
+
+  private final static int[] APIS_FOR_TEST = {
+      JELLY_BEAN, JELLY_BEAN_MR1, JELLY_BEAN_MR2, KITKAT, LOLLIPOP, LOLLIPOP_MR1, M,
+  };
+
+  private static SdkPicker delegateSdkPicker;
+  private static final Injector INJECTOR = defaultInjector()
+      .bind(SdkPicker.class, (config, usesSdk) -> delegateSdkPicker.selectSdks(config, usesSdk))
+      .build();
+
+  private RobolectricTestRunner runner;
+  private RunNotifier runNotifier;
+  private MyRunListener runListener;
+
+  private int numSupportedApis;
+  private String priorResourcesMode;
+  private String priorAlwaysInclude;
+
+  private SdkCollection sdkCollection;
+
+  @Before
+  public void setUp() {
+    numSupportedApis = APIS_FOR_TEST.length;
+
+    runListener = new MyRunListener();
+    runNotifier = new RunNotifier();
+    runNotifier.addListener(runListener);
+    sdkCollection = new SdkCollection(() -> map(APIS_FOR_TEST));
+    delegateSdkPicker = new DefaultSdkPicker(sdkCollection, null);
+
+    priorResourcesMode = System.getProperty("robolectric.resourcesMode");
+    System.setProperty("robolectric.resourcesMode", "legacy");
+
+    priorAlwaysInclude = System.getProperty("robolectric.alwaysIncludeVariantMarkersInTestName");
+    System.clearProperty("robolectric.alwaysIncludeVariantMarkersInTestName");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    TestUtil.resetSystemProperty(
+        "robolectric.alwaysIncludeVariantMarkersInTestName", priorAlwaysInclude);
+    TestUtil.resetSystemProperty("robolectric.resourcesMode", priorResourcesMode);
+  }
+
+  @Test
+  public void createChildrenForEachSupportedApi() throws Throwable {
+    runner = runnerOf(TestWithNoConfig.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(
+        JELLY_BEAN, JELLY_BEAN_MR1, JELLY_BEAN_MR2, KITKAT, LOLLIPOP, LOLLIPOP_MR1, M);
+  }
+
+  @Test
+  public void withConfigSdkLatest_shouldUseLatestSupported() throws Throwable {
+    runner = runnerOf(TestMethodWithNewestSdk.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(M);
+  }
+
+  @Test
+  public void withConfigSdkAndMinMax_shouldUseMinMax() throws Throwable {
+    runner = runnerOf(TestMethodWithSdkAndMinMax.class);
+    try {
+      runner.getChildren();
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).contains("sdk and minSdk/maxSdk may not be specified together" +
+          " (sdk=[16], minSdk=19, maxSdk=21)");
+    }
+  }
+
+  @Test
+  public void withEnabledSdks_createChildrenForEachSupportedSdk() throws Throwable {
+    delegateSdkPicker = new DefaultSdkPicker(new SdkCollection(() -> map(16, 17)), null);
+
+    runner = runnerOf(TestWithNoConfig.class);
+    assertThat(runner.getChildren()).hasSize(2);
+  }
+
+  @Test
+  public void shouldAddApiLevelToNameOfAllButHighestNumberedMethodName() throws Throwable {
+    runner = runnerOf(TestMethodUpToAndIncludingLollipop.class);
+    assertThat(runner.getChildren().get(0).getName()).isEqualTo("testSomeApiLevel[16]");
+    assertThat(runner.getChildren().get(1).getName()).isEqualTo("testSomeApiLevel[17]");
+    assertThat(runner.getChildren().get(2).getName()).isEqualTo("testSomeApiLevel[18]");
+    assertThat(runner.getChildren().get(3).getName()).isEqualTo("testSomeApiLevel[19]");
+    assertThat(runner.getChildren().get(4).getName()).isEqualTo("testSomeApiLevel");
+  }
+
+  @Test
+  public void noConfig() throws Throwable {
+    runner = runnerOf(TestWithNoConfig.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(
+        JELLY_BEAN, JELLY_BEAN_MR1, JELLY_BEAN_MR2, KITKAT, LOLLIPOP, LOLLIPOP_MR1, M);
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    assertThat(runListener.finished).hasSize(numSupportedApis);
+  }
+
+  @Test
+  public void classConfigWithSdkGroup() throws Throwable {
+    runner = runnerOf(TestClassConfigWithSdkGroup.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(JELLY_BEAN, LOLLIPOP);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    // Test method should be run for JellyBean and Lollipop
+    assertThat(runListener.finished).hasSize(2);
+  }
+
+  @Test
+  public void methodConfigWithSdkGroup() throws Throwable {
+    runner = runnerOf(TestMethodConfigWithSdkGroup.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(JELLY_BEAN, LOLLIPOP);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    // Test method should be run for JellyBean and Lollipop
+    assertThat(runListener.finished).hasSize(2);
+  }
+
+  @Test
+  public void classConfigMinSdk() throws Throwable {
+    runner = runnerOf(TestClassLollipopAndUp.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(LOLLIPOP, LOLLIPOP_MR1, M);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    int sdksAfterAndIncludingLollipop = 3;
+    assertThat(runListener.finished).hasSize(sdksAfterAndIncludingLollipop);
+  }
+
+  @Test
+  public void classConfigMaxSdk() throws Throwable {
+    runner = runnerOf(TestClassUpToAndIncludingLollipop.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(JELLY_BEAN, JELLY_BEAN_MR1, JELLY_BEAN_MR2, KITKAT, LOLLIPOP);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    int sdksUpToAndIncludingLollipop = 5;
+    assertThat(runListener.finished).hasSize(sdksUpToAndIncludingLollipop);
+  }
+
+  @Test
+  public void classConfigWithMinSdkAndMaxSdk() throws Throwable {
+    runner = runnerOf(TestClassBetweenJellyBeanMr2AndLollipop.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(JELLY_BEAN_MR2, KITKAT, LOLLIPOP);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    // Since test method should only be run once
+    int sdksInclusivelyBetweenJellyBeanMr2AndLollipop = 3;
+    assertThat(runListener.finished).hasSize(sdksInclusivelyBetweenJellyBeanMr2AndLollipop);
+  }
+
+  @Test
+  public void methodConfigMinSdk() throws Throwable {
+    runner = runnerOf(TestMethodLollipopAndUp.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(LOLLIPOP, LOLLIPOP_MR1, M);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    int sdksAfterAndIncludingLollipop = 3;
+    assertThat(runListener.finished).hasSize(sdksAfterAndIncludingLollipop);
+  }
+
+  @Test
+  public void methodConfigMaxSdk() throws Throwable {
+    runner = runnerOf(TestMethodUpToAndIncludingLollipop.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(JELLY_BEAN, JELLY_BEAN_MR1, JELLY_BEAN_MR2, KITKAT, LOLLIPOP);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    int sdksUpToAndIncludingLollipop = 5;
+    assertThat(runListener.finished).hasSize(sdksUpToAndIncludingLollipop);
+  }
+
+  @Test
+  public void methodConfigWithMinSdkAndMaxSdk() throws Throwable {
+    runner = runnerOf(TestMethodBetweenJellyBeanMr2AndLollipop.class);
+    assertThat(apisFor(runner.getChildren())).containsExactly(JELLY_BEAN_MR2, KITKAT, LOLLIPOP);
+
+    runner.run(runNotifier);
+
+    assertThat(runListener.ignored).isEmpty();
+    int sdksInclusivelyBetweenJellyBeanMr2AndLollipop = 3;
+    assertThat(runListener.finished).hasSize(sdksInclusivelyBetweenJellyBeanMr2AndLollipop);
+  }
+
+  ///////////////////////////
+
+  @Nonnull
+  private RobolectricTestRunner runnerOf(Class<?> testClass) throws InitializationError {
+    return new RobolectricTestRunner(testClass, INJECTOR);
+  }
+
+  @Config(sdk = Config.ALL_SDKS)
+  public static class TestWithNoConfig {
+    @Test public void test() {}
+  }
+
+  @Config(sdk = {JELLY_BEAN, LOLLIPOP})
+  public static class TestClassConfigWithSdkGroup {
+    @Test public void testShouldRunApi18() {
+      assertThat(Build.VERSION.SDK_INT).isIn(Range.closed(JELLY_BEAN, LOLLIPOP));
+    }
+  }
+
+  @Config(sdk = Config.ALL_SDKS)
+  public static class TestMethodConfigWithSdkGroup {
+    @Config(sdk = {JELLY_BEAN, LOLLIPOP})
+    @Test public void testShouldRunApi16() {
+      assertThat(Build.VERSION.SDK_INT).isIn(Range.closed(JELLY_BEAN, LOLLIPOP));
+    }
+  }
+
+  @Config(minSdk = LOLLIPOP)
+  public static class TestClassLollipopAndUp {
+    @Test public void testSomeApiLevel() {
+      assertThat(Build.VERSION.SDK_INT).isAtLeast(LOLLIPOP);
+    }
+  }
+
+  @Config(maxSdk = LOLLIPOP)
+  public static class TestClassUpToAndIncludingLollipop {
+    @Test public void testSomeApiLevel() {
+      assertThat(Build.VERSION.SDK_INT).isAtMost(LOLLIPOP);
+    }
+  }
+
+  @Config(minSdk = JELLY_BEAN_MR2, maxSdk = LOLLIPOP)
+  public static class TestClassBetweenJellyBeanMr2AndLollipop {
+    @Test public void testSomeApiLevel() {
+      assertThat(Build.VERSION.SDK_INT).isIn(Range.closed(JELLY_BEAN_MR2, LOLLIPOP));
+    }
+  }
+
+  @Config(sdk = Config.ALL_SDKS)
+  public static class TestMethodLollipopAndUp {
+    @Config(minSdk = LOLLIPOP)
+    @Test public void testSomeApiLevel() {
+      assertThat(Build.VERSION.SDK_INT).isAtLeast(LOLLIPOP);
+    }
+  }
+
+  @Config(sdk = Config.ALL_SDKS)
+  public static class TestMethodUpToAndIncludingLollipop {
+    @Config(maxSdk = LOLLIPOP)
+    @Test public void testSomeApiLevel() {
+      assertThat(Build.VERSION.SDK_INT).isAtMost(LOLLIPOP);
+    }
+  }
+
+  @Config(sdk = Config.ALL_SDKS)
+  public static class TestMethodBetweenJellyBeanMr2AndLollipop {
+    @Config(minSdk = JELLY_BEAN_MR2, maxSdk = LOLLIPOP)
+    @Test public void testSomeApiLevel() {
+      assertThat(Build.VERSION.SDK_INT).isIn(Range.closed(JELLY_BEAN_MR2, LOLLIPOP));
+    }
+  }
+
+  public static class TestMethodWithNewestSdk {
+    @Config(sdk = Config.NEWEST_SDK)
+    @Test
+    public void testWithLatest() {
+      assertThat(Build.VERSION.SDK_INT).isEqualTo(M);
+    }
+  }
+
+  @Config(sdk = Config.ALL_SDKS)
+  public static class TestMethodWithSdkAndMinMax {
+    @Config(sdk = JELLY_BEAN, minSdk = KITKAT, maxSdk = LOLLIPOP)
+    @Test public void testWithKitKatAndLollipop() {
+      assertThat(Build.VERSION.SDK_INT).isIn(Range.closed(KITKAT, LOLLIPOP));
+    }
+  }
+
+  private static List<Integer> apisFor(List<FrameworkMethod> children) {
+    List<Integer> apis = new ArrayList<>();
+    for (FrameworkMethod child : children) {
+      apis.add(
+          ((RobolectricTestRunner.RobolectricFrameworkMethod) child).getSdk().getApiLevel());
+    }
+    return apis;
+  }
+
+  private static class MyRunListener extends RunListener {
+    private List<String> started = new ArrayList<>();
+    private List<String> finished = new ArrayList<>();
+    private List<String> ignored = new ArrayList<>();
+
+    @Override
+    public void testStarted(Description description) throws Exception {
+      started.add(description.getDisplayName());
+    }
+
+    @Override
+    public void testFinished(Description description) throws Exception {
+      finished.add(description.getDisplayName());
+    }
+
+    @Override
+    public void testIgnored(Description description) throws Exception {
+      ignored.add(description.getDisplayName());
+    }
+  }
+
+  private List<Sdk> map(int... sdkInts) {
+    SdkCollection allSdks = TestUtil.getSdkCollection();
+    return Arrays.stream(sdkInts).mapToObj(allSdks::getSdk).collect(Collectors.toList());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerSelfTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerSelfTest.java
new file mode 100644
index 0000000..f1e71b9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerSelfTest.java
@@ -0,0 +1,114 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.fail;
+
+import android.app.Application;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Looper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.hamcrest.CoreMatchers;
+import org.junit.AfterClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+@Config(application = RobolectricTestRunnerSelfTest.MyTestApplication.class)
+public class RobolectricTestRunnerSelfTest {
+
+  @Test
+  public void shouldInitializeAndBindApplicationButNotCallOnCreate() {
+    assertWithMessage("application")
+        .that((Application) ApplicationProvider.getApplicationContext())
+        .isInstanceOf(MyTestApplication.class);
+    assertWithMessage("onCreate called")
+        .that(((MyTestApplication) ApplicationProvider.getApplicationContext()).onCreateWasCalled)
+        .isTrue();
+    if (RuntimeEnvironment.useLegacyResources()) {
+      assertWithMessage("Application resource loader")
+          .that(RuntimeEnvironment.getAppResourceTable())
+          .isNotNull();
+    }
+  }
+
+  @Test
+  public void shouldSetUpSystemResources() {
+    Resources systemResources = Resources.getSystem();
+    Resources appResources = ApplicationProvider.getApplicationContext().getResources();
+
+    assertWithMessage("system resources").that(systemResources).isNotNull();
+
+    assertWithMessage("system resource")
+        .that(systemResources.getString(android.R.string.copy))
+        .isEqualTo(appResources.getString(android.R.string.copy));
+
+    assertWithMessage("app resource").that(appResources.getString(R.string.howdy)).isNotNull();
+    try {
+      systemResources.getString(R.string.howdy);
+      fail("Expected Exception not thrown");
+    } catch (Resources.NotFoundException e) {
+    }
+  }
+
+  @Test
+  public void setStaticValue_shouldIgnoreFinalModifier() {
+    ReflectionHelpers.setStaticField(Build.class, "MODEL", "expected value");
+
+    assertThat(Build.MODEL).isEqualTo("expected value");
+  }
+
+  @Test
+  @Config(qualifiers = "fr")
+  public void internalBeforeTest_testValuesResQualifiers() {
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("fr");
+  }
+
+  @Test
+  public void testMethod_shouldBeInvoked_onMainThread() {
+    assertThat(Looper.getMainLooper().getThread()).isSameInstanceAs(Thread.currentThread());
+  }
+
+  @Test(timeout = 1000)
+  public void whenTestHarnessUsesDifferentThread_shouldStillReportAsMainThread() {
+    assertThat(Looper.getMainLooper().getThread()).isSameInstanceAs(Thread.currentThread());
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.KITKAT)
+  public void testVersionConfiguration() {
+    assertThat(Build.VERSION.SDK_INT)
+        .isEqualTo(Build.VERSION_CODES.KITKAT);
+    assertThat(Build.VERSION.RELEASE).isEqualTo("4.4");
+  }
+
+  @Test public void hamcrestMatchersDontBlowUpDuringLinking() throws Exception {
+    org.hamcrest.MatcherAssert.assertThat(true, CoreMatchers.is(true));
+  }
+
+  @AfterClass
+  public static void resetStaticState_shouldBeCalled_onMainThread() {
+    assertThat(onTerminateCalledFromMain).isTrue();
+  }
+
+  private static Boolean onTerminateCalledFromMain = null;
+
+  public static class MyTestApplication extends Application {
+    private boolean onCreateWasCalled;
+
+    @Override
+    public void onCreate() {
+      this.onCreateWasCalled = true;
+    }
+    
+    @Override
+    public void onTerminate() {
+      onTerminateCalledFromMain =
+          Boolean.valueOf(Looper.getMainLooper().getThread() == Thread.currentThread());
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java
new file mode 100644
index 0000000..2d8f358
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java
@@ -0,0 +1,508 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toSet;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.robolectric.RobolectricTestRunner.defaultInjector;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.annotation.SuppressLint;
+import android.app.Application;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import java.io.FileOutputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import javax.annotation.Nonnull;
+import javax.inject.Inject;
+import javax.inject.Named;
+import org.junit.After;
+import org.junit.AssumptionViolatedException;
+import org.junit.Before;
+import org.junit.FixMethodOrder;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.Result;
+import org.junit.runner.RunWith;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.notification.RunListener;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.JUnit4;
+import org.junit.runners.MethodSorters;
+import org.junit.runners.model.FrameworkMethod;
+import org.robolectric.RobolectricTestRunner.ResModeStrategy;
+import org.robolectric.RobolectricTestRunner.RobolectricFrameworkMethod;
+import org.robolectric.android.internal.AndroidTestEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Config.Implementation;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+import org.robolectric.internal.AndroidSandbox.TestEnvironmentSpec;
+import org.robolectric.internal.ResourcesMode;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkProvider;
+import org.robolectric.pluginapi.TestEnvironmentLifecyclePlugin;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.pluginapi.perf.Metric;
+import org.robolectric.pluginapi.perf.PerfStatsReporter;
+import org.robolectric.plugins.DefaultSdkPicker;
+import org.robolectric.plugins.SdkCollection;
+import org.robolectric.plugins.StubSdk;
+import org.robolectric.util.TempDirectory;
+import org.robolectric.util.TestUtil;
+
+@SuppressWarnings("NewApi")
+@RunWith(JUnit4.class)
+public class RobolectricTestRunnerTest {
+
+  private RunNotifier notifier;
+  private List<String> events;
+  private String priorEnabledSdks;
+  private String priorAlwaysInclude;
+  private SdkCollection sdkCollection;
+
+  @Before
+  public void setUp() throws Exception {
+    notifier = new RunNotifier();
+    events = new ArrayList<>();
+    notifier.addListener(new MyRunListener());
+
+    priorEnabledSdks = System.getProperty("robolectric.enabledSdks");
+    System.clearProperty("robolectric.enabledSdks");
+
+    priorAlwaysInclude = System.getProperty("robolectric.alwaysIncludeVariantMarkersInTestName");
+    System.clearProperty("robolectric.alwaysIncludeVariantMarkersInTestName");
+
+    sdkCollection = TestUtil.getSdkCollection();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    TestUtil.resetSystemProperty(
+        "robolectric.alwaysIncludeVariantMarkersInTestName", priorAlwaysInclude);
+    TestUtil.resetSystemProperty("robolectric.enabledSdks", priorEnabledSdks);
+  }
+
+  @Test
+  public void ignoredTestCanSpecifyUnsupportedSdkWithoutExploding() throws Exception {
+    RobolectricTestRunner runner =
+        new RobolectricTestRunner(TestWithOldSdk.class,
+            org.robolectric.RobolectricTestRunner.defaultInjector()
+                .bind(org.robolectric.pluginapi.SdkPicker.class, AllEnabledSdkPicker.class)
+                .build());
+    runner.run(notifier);
+    assertThat(events).containsExactly(
+        "started: oldSdkMethod",
+        "failure: API level 11 is not available",
+        "finished: oldSdkMethod",
+        "ignored: ignoredOldSdkMethod"
+    ).inOrder();
+  }
+
+  @Test
+  public void testsWithUnsupportedSdkShouldBeIgnored() throws Exception {
+    RobolectricTestRunner runner = new RobolectricTestRunner(
+        TestWithTwoMethods.class,
+        defaultInjector()
+            .bind(SdkProvider.class, () ->
+                Arrays.asList(TestUtil.getSdkCollection().getSdk(17),
+                    new StubSdk(18, false)))
+            .build());
+    runner.run(notifier);
+    assertThat(events).containsExactly(
+        "started: first[17]", "finished: first[17]",
+        "started: first",
+        "ignored: first: Failed to create a Robolectric sandbox: unsupported",
+        "finished: first",
+        "started: second[17]", "finished: second[17]",
+        "started: second",
+        "ignored: second: Failed to create a Robolectric sandbox: unsupported",
+        "finished: second"
+    ).inOrder();
+  }
+
+  @Test
+  public void supportsOldGetConfigUntil4dot3() throws Exception {
+    Implementation overriddenConfig = Config.Builder.defaults().build();
+    List<FrameworkMethod> children = new SingleSdkRobolectricTestRunner(TestWithTwoMethods.class) {
+      @Override
+      public Config getConfig(Method method) {
+        return overriddenConfig;
+      }
+    }.getChildren();
+    Config config = ((RobolectricFrameworkMethod) children.get(0))
+        .getConfiguration().get(Config.class);
+    assertThat(config).isSameInstanceAs(overriddenConfig);
+  }
+
+  @Test
+  public void failureInResetterDoesntBreakAllTests() throws Exception {
+    RobolectricTestRunner runner =
+        new SingleSdkRobolectricTestRunner(
+            TestWithTwoMethods.class,
+            SingleSdkRobolectricTestRunner.defaultInjector()
+                .bind(TestEnvironmentSpec.class,
+                    new TestEnvironmentSpec(AndroidTestEnvironmentWithFailingSetUp.class))
+                .build());
+    runner.run(notifier);
+    assertThat(events).containsExactly(
+        "started: first",
+        "failure: fake error in setUpApplicationState",
+        "finished: first",
+        "started: second",
+        "failure: fake error in setUpApplicationState",
+        "finished: second"
+    ).inOrder();
+  }
+
+  @Test
+  public void failureInAppOnCreateDoesntBreakAllTests() throws Exception {
+    RobolectricTestRunner runner = new SingleSdkRobolectricTestRunner(TestWithBrokenAppCreate.class);
+    runner.run(notifier);
+    assertThat(events)
+        .containsExactly(
+            "started: first",
+            "failure: fake error in application.onCreate",
+            "finished: first",
+            "started: second",
+            "failure: fake error in application.onCreate",
+            "finished: second"
+        ).inOrder();
+  }
+
+  @Test
+  public void failureInAppOnTerminateDoesntBreakAllTests() throws Exception {
+    RobolectricTestRunner runner = new SingleSdkRobolectricTestRunner(TestWithBrokenAppTerminate.class);
+    runner.run(notifier);
+    assertThat(events)
+        .containsExactly(
+            "started: first",
+            "failure: fake error in application.onTerminate",
+            "finished: first",
+            "started: second",
+            "failure: fake error in application.onTerminate",
+            "finished: second"
+        ).inOrder();
+  }
+
+  @Test
+  public void equalityOfRobolectricFrameworkMethod() throws Exception {
+    Method method = TestWithTwoMethods.class.getMethod("first");
+    RobolectricFrameworkMethod rfm16 =
+        new RobolectricFrameworkMethod(
+            method,
+            mock(AndroidManifest.class),
+            sdkCollection.getSdk(16),
+            mock(Configuration.class),
+            ResourcesMode.LEGACY,
+            ResModeStrategy.legacy,
+            false);
+    RobolectricFrameworkMethod rfm17 =
+        new RobolectricFrameworkMethod(
+            method,
+            mock(AndroidManifest.class),
+            sdkCollection.getSdk(17),
+            mock(Configuration.class),
+            ResourcesMode.LEGACY,
+            ResModeStrategy.legacy,
+            false);
+    RobolectricFrameworkMethod rfm16b =
+        new RobolectricFrameworkMethod(
+            method,
+            mock(AndroidManifest.class),
+            sdkCollection.getSdk(16),
+            mock(Configuration.class),
+            ResourcesMode.LEGACY,
+            ResModeStrategy.legacy,
+            false);
+    RobolectricFrameworkMethod rfm16c =
+        new RobolectricFrameworkMethod(
+            method,
+            mock(AndroidManifest.class),
+            sdkCollection.getSdk(16),
+            mock(Configuration.class),
+            ResourcesMode.BINARY,
+            ResModeStrategy.legacy,
+            false);
+
+    assertThat(rfm16).isNotEqualTo(rfm17);
+    assertThat(rfm16).isEqualTo(rfm16b);
+    assertThat(rfm16).isNotEqualTo(rfm16c);
+
+    assertThat(rfm16.hashCode()).isEqualTo(rfm16b.hashCode());
+  }
+
+  @Test
+  public void shouldReportPerfStats() throws Exception {
+    List<Metric> metrics = new ArrayList<>();
+    PerfStatsReporter reporter = (metadata, metrics1) -> metrics.addAll(metrics1);
+
+    RobolectricTestRunner runner =
+        new SingleSdkRobolectricTestRunner(
+            TestWithTwoMethods.class,
+            RobolectricTestRunner.defaultInjector()
+                .bind(PerfStatsReporter[].class, new PerfStatsReporter[]{reporter})
+                .build());
+
+    runner.run(notifier);
+
+    Set<String> metricNames = metrics.stream().map(Metric::getName).collect(toSet());
+    assertThat(metricNames).contains("initialization");
+  }
+
+  @Test
+  public void shouldResetThreadInterrupted() throws Exception {
+    RobolectricTestRunner runner = new SingleSdkRobolectricTestRunner(TestWithInterrupt.class);
+    runner.run(notifier);
+    assertThat(events).containsExactly(
+        "started: first",
+        "finished: first",
+        "started: second",
+        "failure: failed for the right reason",
+        "finished: second"
+    );
+  }
+
+  @Test
+  public void shouldDiagnoseUnexecutedRunnables() throws Exception {
+    RobolectricTestRunner runner =
+        new SingleSdkRobolectricTestRunner(TestWithUnexecutedRunnables.class);
+    runner.run(notifier);
+    assertThat(events)
+        .containsExactly(
+            "started: failWithNoRunnables",
+            "failure: failing with no runnables",
+            "finished: failWithNoRunnables",
+            "started: failWithUnexecutedRunnables",
+            "failure: failing with unexecuted runnable\n"
+                + "Suppressed: Main looper has queued unexecuted runnables. "
+                + "This might be the cause of the test failure. "
+                + "You might need a shadowOf(Looper.getMainLooper()).idle() call.",
+            "finished: failWithUnexecutedRunnables",
+            "started: assumptionViolationWithNoRunnables",
+            "ignored: assumptionViolationWithNoRunnables: assumption violated",
+            "finished: assumptionViolationWithNoRunnables",
+            "started: assumptionViolationWithUnexecutedRunnables",
+            "ignored: assumptionViolationWithUnexecutedRunnables: assumption violated",
+            "finished: assumptionViolationWithUnexecutedRunnables");
+  }
+
+  /////////////////////////////
+
+  /** To simulate failures. */
+  public static class AndroidTestEnvironmentWithFailingSetUp extends AndroidTestEnvironment {
+
+    public AndroidTestEnvironmentWithFailingSetUp(
+        @Named("runtimeSdk") Sdk runtimeSdk,
+        @Named("compileSdk") Sdk compileSdk,
+        ResourcesMode resourcesMode, ApkLoader apkLoader, ShadowProvider[] shadowProviders,
+        TestEnvironmentLifecyclePlugin[] lifecyclePlugins) {
+      super(runtimeSdk, compileSdk, resourcesMode, apkLoader, shadowProviders, lifecyclePlugins);
+    }
+
+    @Override
+    public void setUpApplicationState(Method method,
+        Configuration configuration, AndroidManifest appManifest) {
+      throw new RuntimeException("fake error in setUpApplicationState");
+    }
+  }
+
+  @Ignore
+  public static class TestWithOldSdk {
+    @Config(sdk = Build.VERSION_CODES.HONEYCOMB)
+    @Test
+    public void oldSdkMethod() throws Exception {
+      fail("I should not be run!");
+    }
+
+    @Ignore("This test shouldn't run, and shouldn't cause the test runner to fail")
+    @Config(sdk = Build.VERSION_CODES.HONEYCOMB)
+    @Test
+    public void ignoredOldSdkMethod() throws Exception {
+      fail("I should not be run!");
+    }
+  }
+
+  @Ignore
+  @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+  @Config(qualifiers = "w123dp-h456dp-land-hdpi")
+  public static class TestWithTwoMethods {
+    @Test
+    public void first() throws Exception {
+    }
+
+    @Test
+    public void second() throws Exception {
+    }
+  }
+
+  @Ignore
+  @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+  @Config(application = TestWithBrokenAppCreate.MyTestApplication.class)
+  @LazyApplication(LazyLoad.OFF)
+  public static class TestWithBrokenAppCreate {
+    @Test
+    public void first() throws Exception {}
+
+    @Test
+    public void second() throws Exception {}
+
+    public static class MyTestApplication extends Application {
+      @SuppressLint("MissingSuperCall")
+      @Override
+      public void onCreate() {
+        throw new RuntimeException("fake error in application.onCreate");
+      }
+    }
+  }
+
+  @Ignore
+  @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+  @Config(application = TestWithBrokenAppTerminate.MyTestApplication.class)
+  @LazyApplication(LazyLoad.OFF)
+  public static class TestWithBrokenAppTerminate {
+    @Test
+    public void first() throws Exception {}
+
+    @Test
+    public void second() throws Exception {}
+
+    public static class MyTestApplication extends Application {
+      @SuppressLint("MissingSuperCall")
+      @Override
+      public void onTerminate() {
+        throw new RuntimeException("fake error in application.onTerminate");
+      }
+    }
+  }
+
+  @Ignore
+  @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+  public static class TestWithInterrupt {
+    @Test
+    public void first() throws Exception {
+      Thread.currentThread().interrupt();
+    }
+
+    @Test
+    public void second() throws Exception {
+      TempDirectory tempDirectory = new TempDirectory("test");
+
+      try {
+        Path jarPath = tempDirectory.create("some-jar").resolve("some.jar");
+        try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jarPath.toFile()))) {
+          out.putNextEntry(new JarEntry("README.txt"));
+          out.write("hi!".getBytes(StandardCharsets.UTF_8));
+        }
+
+        FileSystemProvider jarFSP = FileSystemProvider.installedProviders().stream()
+            .filter(p -> p.getScheme().equals("jar")).findFirst().get();
+        Path fakeJarFile = Paths.get(jarPath.toUri());
+
+        // if Thread.interrupted() was true, this would fail in AbstractInterruptibleChannel:
+        jarFSP.newFileSystem(fakeJarFile, new HashMap<>());
+      } finally {
+        tempDirectory.destroy();
+      }
+
+      fail("failed for the right reason");
+    }
+  }
+
+  /** Fixture for #shouldDiagnoseUnexecutedRunnables() */
+  @Ignore
+  @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+  public static class TestWithUnexecutedRunnables {
+
+    @Test
+    public void failWithUnexecutedRunnables() {
+      shadowMainLooper().pause();
+      new Handler(Looper.getMainLooper()).post(() -> {});
+      fail("failing with unexecuted runnable");
+    }
+
+    @Test
+    public void failWithNoRunnables() {
+      fail("failing with no runnables");
+    }
+
+    @Test
+    public void assumptionViolationWithUnexecutedRunnables() {
+      shadowMainLooper().pause();
+      new Handler(Looper.getMainLooper()).post(() -> {});
+      throw new AssumptionViolatedException("assumption violated");
+    }
+
+    @Test
+    public void assumptionViolationWithNoRunnables() {
+      throw new AssumptionViolatedException("assumption violated");
+    }
+  }
+
+  /** Ignore the value of --Drobolectric.enabledSdks */
+  public static class AllEnabledSdkPicker extends DefaultSdkPicker {
+    @Inject
+    public AllEnabledSdkPicker(@Nonnull SdkCollection sdkCollection) {
+      super(sdkCollection, (String) null);
+    }
+  }
+
+  private class MyRunListener extends RunListener {
+
+    @Override
+    public void testRunStarted(Description description) {
+      events.add("run started: " + description.getMethodName());
+    }
+
+    @Override
+    public void testRunFinished(Result result) {
+      events.add("run finished: " + result);
+    }
+
+    @Override
+    public void testStarted(Description description) {
+      events.add("started: " + description.getMethodName());
+    }
+
+    @Override
+    public void testFinished(Description description) {
+      events.add("finished: " + description.getMethodName());
+    }
+
+    @Override
+    public void testAssumptionFailure(Failure failure) {
+      events.add(
+          "ignored: " + failure.getDescription().getMethodName() + ": " + failure.getMessage());
+    }
+
+    @Override
+    public void testIgnored(Description description) {
+      events.add("ignored: " + description.getMethodName());
+    }
+
+    @Override
+    public void testFailure(Failure failure) {
+      Throwable exception = failure.getException();
+      String message = exception.getMessage();
+      for (Throwable suppressed : exception.getSuppressed()) {
+        message += "\nSuppressed: " + suppressed.getMessage();
+      }
+      events.add("failure: " + message);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerThreadTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerThreadTest.java
new file mode 100644
index 0000000..f571f89
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerThreadTest.java
@@ -0,0 +1,53 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class RobolectricTestRunnerThreadTest {
+  private static Thread sThread;
+  private static ClassLoader sClassLoader;
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    sThread = Thread.currentThread();
+    sClassLoader = Thread.currentThread().getContextClassLoader();
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    assertThat(Thread.currentThread() == sThread).isTrue();
+    assertThat(Thread.currentThread().getContextClassLoader() == sClassLoader).isTrue();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    assertThat(Thread.currentThread() == sThread).isTrue();
+    assertThat(Thread.currentThread().getContextClassLoader() == sClassLoader).isTrue();
+  }
+
+  @Test
+  public void firstTest() {
+    assertThat(Thread.currentThread() == sThread).isTrue();
+    assertThat(Thread.currentThread().getContextClassLoader() == sClassLoader).isTrue();
+  }
+
+  @Test
+  public void secondTest() {
+    assertThat(Thread.currentThread() == sThread).isTrue();
+    assertThat(Thread.currentThread().getContextClassLoader() == sClassLoader).isTrue();
+  }
+
+  @AfterClass
+  public static void afterClass() throws Exception {
+    assertThat(Thread.currentThread() == sThread).isTrue();
+    assertThat(Thread.currentThread().getContextClassLoader() == sClassLoader).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/RuntimeEnvironmentTest.java b/robolectric/src/test/java/org/robolectric/RuntimeEnvironmentTest.java
new file mode 100644
index 0000000..942b923
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/RuntimeEnvironmentTest.java
@@ -0,0 +1,118 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.app.Application;
+import android.content.res.Configuration;
+import android.view.Surface;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.ShadowDisplay;
+import org.robolectric.util.Scheduler;
+
+@RunWith(AndroidJUnit4.class)
+public class RuntimeEnvironmentTest {
+
+  @Test
+  @LooperMode(LEGACY)
+  public void setMainThread_forCurrentThread() {
+    RuntimeEnvironment.setMainThread(Thread.currentThread());
+    assertThat(RuntimeEnvironment.getMainThread()).isSameInstanceAs(Thread.currentThread());
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void setMainThread_forNewThread() {
+    Thread t = new Thread();
+    RuntimeEnvironment.setMainThread(t);
+    assertThat(RuntimeEnvironment.getMainThread()).isSameInstanceAs(t);
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void isMainThread_forNewThread_withoutSwitch() throws InterruptedException {
+    final AtomicBoolean res = new AtomicBoolean();
+    final CountDownLatch finished = new CountDownLatch(1);
+    Thread t =
+        new Thread() {
+          @Override
+          public void run() {
+            res.set(RuntimeEnvironment.isMainThread());
+            finished.countDown();
+          }
+        };
+    RuntimeEnvironment.setMainThread(Thread.currentThread());
+    t.start();
+    if (!finished.await(1000, MILLISECONDS)) {
+      throw new InterruptedException("Thread " + t + " didn't finish timely");
+    }
+    assertWithMessage("testThread").that(RuntimeEnvironment.isMainThread()).isTrue();
+    assertWithMessage("thread t").that(res.get()).isFalse();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void isMainThread_forNewThread_withSwitch() throws InterruptedException {
+    final AtomicBoolean res = new AtomicBoolean();
+    final CountDownLatch finished = new CountDownLatch(1);
+    Thread t =
+        new Thread(
+            () -> {
+              res.set(RuntimeEnvironment.isMainThread());
+              finished.countDown();
+            });
+    RuntimeEnvironment.setMainThread(t);
+    t.start();
+    if (!finished.await(1000, MILLISECONDS)) {
+      throw new InterruptedException("Thread " + t + " didn't finish timely");
+    }
+    assertWithMessage("testThread").that(RuntimeEnvironment.isMainThread()).isFalse();
+    assertWithMessage("thread t").that(res.get()).isTrue();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void isMainThread_withArg_forNewThread_withSwitch() throws InterruptedException {
+    Thread t = new Thread();
+    RuntimeEnvironment.setMainThread(t);
+    assertThat(RuntimeEnvironment.isMainThread(t)).isTrue();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void getSetMasterScheduler() {
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    assertThat(RuntimeEnvironment.getMasterScheduler()).isSameInstanceAs(s);
+  }
+
+  @Test
+  public void testSetQualifiersAddPropagateToApplicationResources() {
+    RuntimeEnvironment.setQualifiers("+land");
+    Application app = RuntimeEnvironment.getApplication();
+    assertThat(app.getResources().getConfiguration().orientation)
+        .isEqualTo(Configuration.ORIENTATION_LANDSCAPE);
+  }
+
+  @Test
+  public void testSetQualifiersReplacePropagateToApplicationResources() {
+    RuntimeEnvironment.setQualifiers("land");
+    Application app = RuntimeEnvironment.getApplication();
+    assertThat(app.getResources().getConfiguration().orientation)
+        .isEqualTo(Configuration.ORIENTATION_LANDSCAPE);
+  }
+
+  @Test
+  public void testGetRotation() {
+    RuntimeEnvironment.setQualifiers("+land");
+    int screenRotation = ShadowDisplay.getDefaultDisplay().getRotation();
+    assertThat(screenRotation).isEqualTo(Surface.ROTATION_0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/SingleSdkRobolectricTestRunner.java b/robolectric/src/test/java/org/robolectric/SingleSdkRobolectricTestRunner.java
new file mode 100644
index 0000000..33a4bf7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/SingleSdkRobolectricTestRunner.java
@@ -0,0 +1,56 @@
+package org.robolectric;
+
+import android.os.Build;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.junit.runners.model.InitializationError;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkPicker;
+import org.robolectric.pluginapi.UsesSdk;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.util.TestUtil;
+import org.robolectric.util.inject.Injector;
+
+public class SingleSdkRobolectricTestRunner extends RobolectricTestRunner {
+
+  private static final Injector DEFAULT_INJECTOR = defaultInjector().build();
+
+  public static Injector.Builder defaultInjector() {
+    return RobolectricTestRunner.defaultInjector()
+        .bind(SdkPicker.class, SingleSdkPicker.class);
+  }
+
+  public SingleSdkRobolectricTestRunner(Class<?> testClass) throws InitializationError {
+    super(testClass, DEFAULT_INJECTOR);
+  }
+
+  public SingleSdkRobolectricTestRunner(Class<?> testClass, Injector injector)
+      throws InitializationError {
+    super(testClass, injector);
+  }
+
+  @Override
+  ResModeStrategy getResModeStrategy() {
+    return ResModeStrategy.binary;
+  }
+
+  public static class SingleSdkPicker implements SdkPicker {
+
+    private final Sdk sdk;
+
+    public SingleSdkPicker() {
+      this(Build.VERSION_CODES.P);
+    }
+
+    SingleSdkPicker(int apiLevel) {
+      this.sdk = TestUtil.getSdkCollection().getSdk(apiLevel);
+    }
+
+    @Nonnull
+    @Override
+    public List<Sdk> selectSdks(Configuration configuration, UsesSdk usesSdk) {
+      return Collections.singletonList(sdk);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/TemporaryBindingsTest.java b/robolectric/src/test/java/org/robolectric/TemporaryBindingsTest.java
new file mode 100644
index 0000000..de5cf7a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/TemporaryBindingsTest.java
@@ -0,0 +1,41 @@
+package org.robolectric;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowView;
+
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = O) // running on all SDKs is unnecessary and can cause OOM GC overhead issues
+public class TemporaryBindingsTest {
+
+  @Test
+  @Config(shadows = TemporaryShadowView.class)
+  public void overridingShadowBindingsShouldNotAffectBindingsInLaterTests() throws Exception {
+    TemporaryShadowView shadowView =
+        Shadow.extract(new View(ApplicationProvider.getApplicationContext()));
+    assertThat(shadowView.getClass().getSimpleName()).isEqualTo(TemporaryShadowView.class.getSimpleName());
+  }
+
+  @Test
+  public void overridingShadowBindingsShouldNotAffectBindingsInLaterTestsAgain() throws Exception {
+    assertThat(
+            shadowOf(new View(ApplicationProvider.getApplicationContext()))
+                .getClass()
+                .getSimpleName())
+        .isEqualTo(ShadowView.class.getSimpleName());
+  }
+
+  @Implements(View.class)
+  public static class TemporaryShadowView {
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/TestFakeApp.java b/robolectric/src/test/java/org/robolectric/TestFakeApp.java
new file mode 100644
index 0000000..318ef9e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/TestFakeApp.java
@@ -0,0 +1,4 @@
+package org.robolectric;
+
+public class TestFakeApp extends FakeApp {
+}
diff --git a/robolectric/src/test/java/org/robolectric/TestRunnerSequenceTest.java b/robolectric/src/test/java/org/robolectric/TestRunnerSequenceTest.java
new file mode 100644
index 0000000..b35376a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/TestRunnerSequenceTest.java
@@ -0,0 +1,194 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.app.Application;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Result;
+import org.junit.runner.RunWith;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.JUnit4;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.internal.bytecode.Sandbox;
+
+@RunWith(JUnit4.class)
+public class TestRunnerSequenceTest {
+
+  public static class StateHolder {
+    public static List<String> transcript;
+  }
+
+  private String priorResourcesMode;
+
+  @Before
+  public void setUp() throws Exception {
+    StateHolder.transcript = new ArrayList<>();
+
+    priorResourcesMode = System.getProperty("robolectric.resourcesMode");
+    System.setProperty("robolectric.resourcesMode", "legacy");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (priorResourcesMode == null) {
+      System.clearProperty("robolectric.resourcesMode");
+    } else {
+      System.setProperty("robolectric.resourcesMode", priorResourcesMode);
+    }
+  }
+
+  @Test public void shouldRunThingsInTheRightOrder() throws Exception {
+    assertNoFailures(run(new Runner(SimpleTest.class)));
+    assertThat(StateHolder.transcript).containsExactly(
+        "configureSandbox",
+        "application.onCreate",
+        "beforeTest",
+        "application.beforeTest",
+        "prepareTest",
+        "application.prepareTest",
+        "TEST!",
+        "application.onTerminate",
+        "afterTest",
+        "application.afterTest"
+    );
+    StateHolder.transcript.clear();
+  }
+
+  @Test public void whenNoAppManifest_shouldRunThingsInTheRightOrder() throws Exception {
+    assertNoFailures(run(new Runner(SimpleTest.class) {
+    }));
+    assertThat(StateHolder.transcript).containsExactly(
+        "configureSandbox",
+        "application.onCreate",
+        "beforeTest",
+        "application.beforeTest",
+        "prepareTest",
+        "application.prepareTest",
+        "TEST!",
+        "application.onTerminate",
+        "afterTest",
+        "application.afterTest"
+    );
+    StateHolder.transcript.clear();
+  }
+
+  @Test public void shouldReleaseAllStateAfterClassSoWeDontLeakMemory() throws Exception {
+    final List<RobolectricTestRunner.RobolectricFrameworkMethod> methods = new ArrayList<>();
+
+    RobolectricTestRunner robolectricTestRunner = new Runner(SimpleTest.class) {
+      @Override
+      protected void finallyAfterTest(FrameworkMethod method) {
+        super.finallyAfterTest(method);
+
+        RobolectricFrameworkMethod roboMethod = (RobolectricFrameworkMethod) method;
+        assertThat(roboMethod.getTestEnvironment()).isNull();
+        assertThat(roboMethod.testLifecycle).isNull();
+        methods.add(roboMethod);
+      }
+    };
+
+    robolectricTestRunner.run(new RunNotifier());
+    assertThat(methods).isNotEmpty();
+  }
+
+  @Config(application = TestRunnerSequenceTest.MyApplication.class)
+  public static class SimpleTest {
+    @Test public void shouldDoNothingMuch() throws Exception {
+      StateHolder.transcript.add("TEST!");
+    }
+  }
+
+  private Result run(Runner runner) throws InitializationError {
+    RunNotifier notifier = new RunNotifier();
+    Result result = new Result();
+    notifier.addListener(result.createListener());
+    runner.run(notifier);
+    return result;
+  }
+
+  private void assertNoFailures(Result result) {
+    if (!result.wasSuccessful()) {
+      for (Failure failure : result.getFailures()) {
+        fail(failure.getMessage());
+      }
+    }
+  }
+
+  public static class Runner extends SingleSdkRobolectricTestRunner {
+
+    Runner(Class<?> testClass) throws InitializationError {
+      super(testClass);
+    }
+
+    @Nonnull
+    @Override
+    protected InstrumentationConfiguration createClassLoaderConfig(FrameworkMethod method) {
+      InstrumentationConfiguration.Builder builder = new InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method));
+      builder.doNotAcquireClass(StateHolder.class);
+      return builder.build();
+    }
+
+    @Nonnull
+    @Override protected Class<? extends TestLifecycle> getTestLifecycleClass() {
+      return MyTestLifecycle.class;
+    }
+
+    @Override protected void configureSandbox(Sandbox sandbox, FrameworkMethod frameworkMethod) {
+      StateHolder.transcript.add("configureSandbox");
+      super.configureSandbox(sandbox, frameworkMethod);
+    }
+  }
+
+  @DoNotInstrument
+  public static class MyTestLifecycle extends DefaultTestLifecycle {
+
+    @Override public void beforeTest(Method method) {
+      StateHolder.transcript.add("beforeTest");
+      super.beforeTest(method);
+    }
+
+    @Override public void prepareTest(Object test) {
+      StateHolder.transcript.add("prepareTest");
+      super.prepareTest(test);
+    }
+
+    @Override public void afterTest(Method method) {
+      StateHolder.transcript.add("afterTest");
+      super.afterTest(method);
+    }
+  }
+
+  public static class MyApplication extends Application implements TestLifecycleApplication {
+    @Override public void onCreate() {
+      StateHolder.transcript.add("application.onCreate");
+    }
+
+    @Override public void beforeTest(Method method) {
+      StateHolder.transcript.add("application.beforeTest");
+    }
+
+    @Override public void prepareTest(Object test) {
+      StateHolder.transcript.add("application.prepareTest");
+    }
+
+    @Override public void afterTest(Method method) {
+      StateHolder.transcript.add("application.afterTest");
+    }
+
+    @Override public void onTerminate() {
+      StateHolder.transcript.add("application.onTerminate");
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/TestWallpaperService.java b/robolectric/src/test/java/org/robolectric/TestWallpaperService.java
new file mode 100644
index 0000000..5afac8a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/TestWallpaperService.java
@@ -0,0 +1,15 @@
+package org.robolectric;
+
+import android.service.wallpaper.WallpaperService;
+
+/**
+ * An empty implementation of {@link WallpaperService} for testing {@link
+ * org.robolectric.shadows.ShadowWallpaperManager}.
+ */
+public final class TestWallpaperService extends WallpaperService {
+
+  @Override
+  public Engine onCreateEngine() {
+    return null;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/AndroidTranslatorClassInstrumentedTest.java b/robolectric/src/test/java/org/robolectric/android/AndroidTranslatorClassInstrumentedTest.java
new file mode 100644
index 0000000..0ea650b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/AndroidTranslatorClassInstrumentedTest.java
@@ -0,0 +1,116 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Bitmap;
+import android.graphics.Paint;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = Config.NEWEST_SDK)
+public class AndroidTranslatorClassInstrumentedTest {
+
+  @Test
+  @Config(shadows = ShadowPaintForTests.class)
+  public void testNativeMethodsAreDelegated() throws Exception {
+    Paint paint = new Paint();
+    paint.setColor(1234);
+
+    assertThat(paint.getColor()).isEqualTo(1234);
+  }
+
+  @Test
+  @Config(shadows = ShadowClassWithPrivateConstructor.class)
+  public void testClassesWithPrivateDefaultConstructorsCanBeShadowed() {
+    ClassWithPrivateConstructor inst = new ClassWithPrivateConstructor();
+    assertThat(inst.getInt()).isEqualTo(42);
+  }
+
+  @Test
+  public void testEnumConstructorsAreNotRewritten() {
+    // just referencing this enum value would blow up if we rewrite its constructor
+    Bitmap.Config alpha8 = Bitmap.Config.ALPHA_8;
+    assertThat(alpha8.toString()).isEqualTo("ALPHA_8");
+  }
+
+  /*
+   * Test "foreign class" getting its methods shadowed whe it's
+   * in the SandboxClassLoader CustomClassNames arrayList
+   */
+  @Test
+  @Config(shadows = {ShadowCustomPaint.class, ShadowPaintForTests.class})
+  public void testCustomMethodShadowed() throws Exception {
+    CustomPaint customPaint = new CustomPaint();
+    assertThat(customPaint.getColor()).isEqualTo(10);
+    assertThat(customPaint.getColorName()).isEqualTo("rainbow");
+  }
+
+  @Instrument
+  public static class ClassWithPrivateConstructor {
+    private ClassWithPrivateConstructor() {
+    }
+
+    public int getInt() {
+      return 99;
+    }
+  }
+
+  @Implements(ClassWithPrivateConstructor.class)
+  public static class ShadowClassWithPrivateConstructor {
+    @Implementation
+    protected int getInt() {
+      return 42;
+    }
+  }
+
+  @Implements(Paint.class)
+  public static class ShadowPaintForTests {
+    private int color;
+
+    @Implementation
+    protected void setColor(int color) {
+      this.color = color;
+    }
+
+    @Implementation
+    protected int getColor() {
+      return color;
+    }
+  }
+
+  @SuppressWarnings({"UnusedDeclaration"})
+  @Instrument
+  public static class CustomPaint extends Paint {
+    private int customColor;
+
+    @Override
+    public int getColor() {
+      return customColor;
+    }
+
+    public String getColorName() {
+      return Integer.toString(customColor);
+    }
+  }
+
+  @Implements(CustomPaint.class)
+  public static class ShadowCustomPaint extends ShadowPaintForTests {
+
+    @Override
+    @Implementation
+    protected int getColor() {
+      return 10;
+    }
+
+    @Implementation
+    protected String getColorName() {
+      return "rainbow";
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/BootstrapTest.java b/robolectric/src/test/java/org/robolectric/android/BootstrapTest.java
new file mode 100644
index 0000000..f22e139
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/BootstrapTest.java
@@ -0,0 +1,410 @@
+package org.robolectric.android;
+
+import static android.content.res.Configuration.COLOR_MODE_HDR_MASK;
+import static android.content.res.Configuration.COLOR_MODE_HDR_NO;
+import static android.content.res.Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_MASK;
+import static android.content.res.Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_NO;
+import static android.content.res.Configuration.KEYBOARDHIDDEN_SOFT;
+import static android.content.res.Configuration.KEYBOARDHIDDEN_YES;
+import static android.content.res.Configuration.KEYBOARD_12KEY;
+import static android.content.res.Configuration.KEYBOARD_NOKEYS;
+import static android.content.res.Configuration.NAVIGATIONHIDDEN_YES;
+import static android.content.res.Configuration.NAVIGATION_DPAD;
+import static android.content.res.Configuration.NAVIGATION_NONAV;
+import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
+import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
+import static android.content.res.Configuration.SCREENLAYOUT_LAYOUTDIR_LTR;
+import static android.content.res.Configuration.SCREENLAYOUT_LAYOUTDIR_MASK;
+import static android.content.res.Configuration.SCREENLAYOUT_LAYOUTDIR_RTL;
+import static android.content.res.Configuration.SCREENLAYOUT_LONG_MASK;
+import static android.content.res.Configuration.SCREENLAYOUT_LONG_NO;
+import static android.content.res.Configuration.SCREENLAYOUT_LONG_YES;
+import static android.content.res.Configuration.SCREENLAYOUT_ROUND_MASK;
+import static android.content.res.Configuration.SCREENLAYOUT_ROUND_NO;
+import static android.content.res.Configuration.SCREENLAYOUT_ROUND_YES;
+import static android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK;
+import static android.content.res.Configuration.SCREENLAYOUT_SIZE_NORMAL;
+import static android.content.res.Configuration.SCREENLAYOUT_SIZE_XLARGE;
+import static android.content.res.Configuration.TOUCHSCREEN_FINGER;
+import static android.content.res.Configuration.TOUCHSCREEN_NOTOUCH;
+import static android.content.res.Configuration.UI_MODE_NIGHT_MASK;
+import static android.content.res.Configuration.UI_MODE_NIGHT_NO;
+import static android.content.res.Configuration.UI_MODE_NIGHT_YES;
+import static android.content.res.Configuration.UI_MODE_TYPE_APPLIANCE;
+import static android.content.res.Configuration.UI_MODE_TYPE_MASK;
+import static android.content.res.Configuration.UI_MODE_TYPE_NORMAL;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.view.Surface.ROTATION_0;
+import static android.view.Surface.ROTATION_90;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.hardware.display.DisplayManager;
+import android.os.Build;
+import android.os.LocaleList;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.DisplayInfo;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class BootstrapTest {
+
+  private Configuration configuration;
+  private DisplayMetrics displayMetrics;
+  private String optsForO;
+
+  @Before
+  public void setUp() throws Exception {
+    configuration = new Configuration();
+    displayMetrics = new DisplayMetrics();
+
+    optsForO = RuntimeEnvironment.getApiLevel() >= O
+        ? "nowidecg-lowdr-"
+        : "";
+  }
+
+  @Test
+  @Config(qualifiers = "w480dp-h640dp")
+  public void shouldSetUpRealisticDisplay() throws Exception {
+    if (Build.VERSION.SDK_INT > JELLY_BEAN) {
+      DisplayManager displayManager =
+          (DisplayManager)
+              ApplicationProvider.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+      DisplayInfo displayInfo = new DisplayInfo();
+      Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
+      display.getDisplayInfo(displayInfo);
+
+      assertThat(displayInfo.name).isEqualTo("Built-in screen");
+      assertThat(displayInfo.appWidth).isEqualTo(480);
+      assertThat(displayInfo.appHeight).isEqualTo(640);
+      assertThat(displayInfo.smallestNominalAppWidth).isEqualTo(480);
+      assertThat(displayInfo.smallestNominalAppHeight).isEqualTo(480);
+      assertThat(displayInfo.largestNominalAppWidth).isEqualTo(640);
+      assertThat(displayInfo.largestNominalAppHeight).isEqualTo(640);
+      assertThat(displayInfo.logicalWidth).isEqualTo(480);
+      assertThat(displayInfo.logicalHeight).isEqualTo(640);
+      assertThat(displayInfo.rotation).isEqualTo(ROTATION_0);
+      assertThat(displayInfo.logicalDensityDpi).isEqualTo(160);
+      assertThat(displayInfo.physicalXDpi).isEqualTo(160f);
+      assertThat(displayInfo.physicalYDpi).isEqualTo(160f);
+      if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
+        assertThat(displayInfo.state).isEqualTo(Display.STATE_ON);
+      }
+    }
+
+    DisplayMetrics displayMetrics =
+        ApplicationProvider.getApplicationContext().getResources().getDisplayMetrics();
+    assertThat(displayMetrics.widthPixels).isEqualTo(480);
+    assertThat(displayMetrics.heightPixels).isEqualTo(640);
+  }
+
+  @Test
+  @Config(qualifiers = "w480dp-h640dp-land-hdpi")
+  public void shouldSetUpRealisticDisplay_landscapeHighDensity() throws Exception {
+    if (Build.VERSION.SDK_INT > JELLY_BEAN) {
+      DisplayManager displayManager =
+          (DisplayManager)
+              ApplicationProvider.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+      DisplayInfo displayInfo = new DisplayInfo();
+      Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
+      display.getDisplayInfo(displayInfo);
+
+      assertThat(displayInfo.name).isEqualTo("Built-in screen");
+      assertThat(displayInfo.appWidth).isEqualTo(960);
+      assertThat(displayInfo.appHeight).isEqualTo(720);
+      assertThat(displayInfo.smallestNominalAppWidth).isEqualTo(720);
+      assertThat(displayInfo.smallestNominalAppHeight).isEqualTo(720);
+      assertThat(displayInfo.largestNominalAppWidth).isEqualTo(960);
+      assertThat(displayInfo.largestNominalAppHeight).isEqualTo(960);
+      assertThat(displayInfo.logicalWidth).isEqualTo(960);
+      assertThat(displayInfo.logicalHeight).isEqualTo(720);
+      assertThat(displayInfo.rotation).isEqualTo(ROTATION_90);
+      assertThat(displayInfo.logicalDensityDpi).isEqualTo(240);
+      assertThat(displayInfo.physicalXDpi).isEqualTo(240f);
+      assertThat(displayInfo.physicalYDpi).isEqualTo(240f);
+      if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
+        assertThat(displayInfo.state).isEqualTo(Display.STATE_ON);
+      }
+    }
+
+    DisplayMetrics displayMetrics =
+        ApplicationProvider.getApplicationContext().getResources().getDisplayMetrics();
+    assertThat(displayMetrics.widthPixels).isEqualTo(960);
+    assertThat(displayMetrics.heightPixels).isEqualTo(720);
+  }
+
+  @Test
+  public void applyQualifiers_shouldAddDefaults() {
+    Bootstrap.applyQualifiers("", Build.VERSION.RESOURCES_SDK_INT, configuration, displayMetrics);
+    String outQualifiers = ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+
+    assertThat(outQualifiers)
+        .isEqualTo(
+            "en-rUS-ldltr-sw320dp-w320dp-h470dp-normal-notlong-notround-"
+                + optsForO
+                + "port-notnight-mdpi"
+                + "-finger-keyssoft-nokeys-navhidden-nonav-v"
+                + Build.VERSION.RESOURCES_SDK_INT);
+
+    assertThat(configuration.mcc).isEqualTo(0);
+    assertThat(configuration.mnc).isEqualTo(0);
+    assertThat(configuration.locale).isEqualTo(new Locale("en", "US"));
+    assertThat(configuration.screenLayout & SCREENLAYOUT_LAYOUTDIR_MASK).isEqualTo(SCREENLAYOUT_LAYOUTDIR_LTR);
+    assertThat(configuration.smallestScreenWidthDp).isEqualTo(320);
+    assertThat(configuration.screenWidthDp).isEqualTo(320);
+    assertThat(configuration.screenHeightDp).isEqualTo(470);
+    assertThat(configuration.screenLayout & SCREENLAYOUT_SIZE_MASK).isEqualTo(SCREENLAYOUT_SIZE_NORMAL);
+    assertThat(configuration.screenLayout & SCREENLAYOUT_LONG_MASK).isEqualTo(SCREENLAYOUT_LONG_NO);
+    assertThat(configuration.screenLayout & SCREENLAYOUT_ROUND_MASK).isEqualTo(SCREENLAYOUT_ROUND_NO);
+    assertThat(configuration.orientation).isEqualTo(ORIENTATION_PORTRAIT);
+    assertThat(configuration.uiMode & UI_MODE_TYPE_MASK).isEqualTo(UI_MODE_TYPE_NORMAL);
+    assertThat(configuration.uiMode & UI_MODE_NIGHT_MASK).isEqualTo(UI_MODE_NIGHT_NO);
+
+    if (RuntimeEnvironment.getApiLevel() > JELLY_BEAN) {
+      assertThat(configuration.densityDpi).isEqualTo(DisplayMetrics.DENSITY_DEFAULT);
+    } else {
+      assertThat(displayMetrics.densityDpi).isEqualTo(DisplayMetrics.DENSITY_DEFAULT);
+      assertThat(displayMetrics.density).isEqualTo(1.0f);
+    }
+
+    assertThat(configuration.touchscreen).isEqualTo(TOUCHSCREEN_FINGER);
+    assertThat(configuration.keyboardHidden).isEqualTo(KEYBOARDHIDDEN_SOFT);
+    assertThat(configuration.keyboard).isEqualTo(KEYBOARD_NOKEYS);
+    assertThat(configuration.navigationHidden).isEqualTo(NAVIGATIONHIDDEN_YES);
+    assertThat(configuration.navigation).isEqualTo(NAVIGATION_NONAV);
+
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      assertThat(configuration.colorMode & COLOR_MODE_WIDE_COLOR_GAMUT_MASK)
+          .isEqualTo(COLOR_MODE_WIDE_COLOR_GAMUT_NO);
+      assertThat(configuration.colorMode & COLOR_MODE_HDR_MASK)
+          .isEqualTo(COLOR_MODE_HDR_NO);
+    }
+  }
+
+  @Test
+  public void applyQualifiers_shouldHonorSpecifiedQualifiers() {
+    String altOptsForO = RuntimeEnvironment.getApiLevel() >= O
+        ? "-widecg-highdr"
+        : "";
+
+    Bootstrap.applyQualifiers(
+        "mcc310-mnc004-fr-rFR-ldrtl-sw400dp-w480dp-h456dp-"
+            + "xlarge-long-round" + altOptsForO + "-land-appliance-night-hdpi-notouch-"
+            + "keyshidden-12key-navhidden-dpad",
+        Build.VERSION.RESOURCES_SDK_INT,
+        configuration,
+        displayMetrics);
+    String outQualifiers = ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+
+    if (RuntimeEnvironment.getApiLevel() > JELLY_BEAN) {
+      // Setting Locale in > JB results in forcing layout direction to match locale
+      assertThat(outQualifiers).isEqualTo("mcc310-mnc4-fr-rFR-ldltr-sw400dp-w480dp-h456dp"
+          + "-xlarge-long-round" + altOptsForO + "-land-appliance-night-hdpi-notouch-"
+          + "keyshidden-12key-navhidden-dpad-v" + Build.VERSION.RESOURCES_SDK_INT);
+    } else {
+      assertThat(outQualifiers).isEqualTo("mcc310-mnc4-fr-rFR-ldrtl-sw400dp-w480dp-h456dp"
+          + "-xlarge-long-round-land-appliance-night-hdpi-notouch-"
+          + "keyshidden-12key-navhidden-dpad-v" + Build.VERSION.RESOURCES_SDK_INT);
+    }
+
+    assertThat(configuration.mcc).isEqualTo(310);
+    assertThat(configuration.mnc).isEqualTo(4);
+    assertThat(configuration.locale).isEqualTo(new Locale("fr", "FR"));
+    if (RuntimeEnvironment.getApiLevel() > JELLY_BEAN) {
+      // note that locale overrides ltr/rtl
+      assertThat(configuration.screenLayout & SCREENLAYOUT_LAYOUTDIR_MASK)
+          .isEqualTo(SCREENLAYOUT_LAYOUTDIR_LTR);
+    } else {
+      // but not on Jelly Bean...
+      assertThat(configuration.screenLayout & SCREENLAYOUT_LAYOUTDIR_MASK)
+          .isEqualTo(SCREENLAYOUT_LAYOUTDIR_RTL);
+    }
+    assertThat(configuration.smallestScreenWidthDp).isEqualTo(400);
+    assertThat(configuration.screenWidthDp).isEqualTo(480);
+    assertThat(configuration.screenHeightDp).isEqualTo(456);
+    assertThat(configuration.screenLayout & SCREENLAYOUT_SIZE_MASK).isEqualTo(SCREENLAYOUT_SIZE_XLARGE);
+    assertThat(configuration.screenLayout & SCREENLAYOUT_LONG_MASK).isEqualTo(SCREENLAYOUT_LONG_YES);
+    assertThat(configuration.screenLayout & SCREENLAYOUT_ROUND_MASK).isEqualTo(SCREENLAYOUT_ROUND_YES);
+    assertThat(configuration.orientation).isEqualTo(ORIENTATION_LANDSCAPE);
+    assertThat(configuration.uiMode & UI_MODE_TYPE_MASK).isEqualTo(UI_MODE_TYPE_APPLIANCE);
+    assertThat(configuration.uiMode & UI_MODE_NIGHT_MASK).isEqualTo(UI_MODE_NIGHT_YES);
+    if (RuntimeEnvironment.getApiLevel() > JELLY_BEAN) {
+      assertThat(configuration.densityDpi).isEqualTo(DisplayMetrics.DENSITY_HIGH);
+    } else {
+      assertThat(displayMetrics.densityDpi).isEqualTo(DisplayMetrics.DENSITY_HIGH);
+      assertThat(displayMetrics.density).isEqualTo(1.5f);
+    }
+    assertThat(configuration.touchscreen).isEqualTo(TOUCHSCREEN_NOTOUCH);
+    assertThat(configuration.keyboardHidden).isEqualTo(KEYBOARDHIDDEN_YES);
+    assertThat(configuration.keyboard).isEqualTo(KEYBOARD_12KEY);
+    assertThat(configuration.navigationHidden).isEqualTo(NAVIGATIONHIDDEN_YES);
+    assertThat(configuration.navigation).isEqualTo(NAVIGATION_DPAD);
+  }
+
+  @Test
+  public void applyQualifiers_longShouldMakeScreenTaller() throws Exception {
+    Bootstrap.applyQualifiers("long",
+        RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+    assertThat(configuration.smallestScreenWidthDp).isEqualTo(320);
+    assertThat(configuration.screenWidthDp).isEqualTo(320);
+    assertThat(configuration.screenHeightDp).isEqualTo(587);
+    assertThat(configuration.screenLayout & Configuration.SCREENLAYOUT_LONG_MASK)
+        .isEqualTo(Configuration.SCREENLAYOUT_LONG_YES);
+  }
+
+  @Test
+  public void whenScreenRationGreatherThan175Percent_applyQualifiers_ShouldSetLong() throws Exception {
+    Bootstrap.applyQualifiers("w400dp-h200dp",
+        RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+    assertThat(configuration.screenWidthDp).isEqualTo(400);
+    assertThat(configuration.screenHeightDp).isEqualTo(200);
+    assertThat(configuration.screenLayout & Configuration.SCREENLAYOUT_LONG_MASK)
+        .isEqualTo(Configuration.SCREENLAYOUT_LONG_YES);
+  }
+
+  @Test
+  public void applyQualifiers_shouldRejectUnknownQualifiers() {
+    try {
+      Bootstrap.applyQualifiers("notareal-qualifier-sw400dp-w480dp-more-wrong-stuff",
+          RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+      fail("should have thrown");
+    } catch (IllegalArgumentException e) {
+      // expected
+      assertThat(e.getMessage()).contains("notareal");
+    }
+  }
+
+  @Test
+  public void applyQualifiers_shouldRejectSdkVersion() {
+    try {
+      Bootstrap.applyQualifiers("sw400dp-w480dp-v7",
+          RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+      fail("should have thrown");
+    } catch (IllegalArgumentException e) {
+      // expected
+      assertThat(e.getMessage()).contains("Cannot specify conflicting platform version");
+    }
+  }
+
+  @Test
+  public void applyQualifiers_shouldRejectAnydpi() {
+    try {
+      Bootstrap.applyQualifiers("anydpi",
+          RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+      fail("should have thrown");
+    } catch (IllegalArgumentException e) {
+      // expected
+      assertThat(e.getMessage()).contains("'anydpi' isn't actually a dpi");
+    }
+  }
+
+  @Test
+  public void applyQualifiers_shouldRejectNodpi() {
+    try {
+      Bootstrap.applyQualifiers("nodpi",
+          RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+      fail("should have thrown");
+    } catch (IllegalArgumentException e) {
+      // expected
+      assertThat(e.getMessage()).contains("'nodpi' isn't actually a dpi");
+    }
+  }
+
+  @Test
+  @Config(sdk = JELLY_BEAN)
+  public void applyQualifiers_densityOnJellyBean() {
+    Bootstrap.applyQualifiers("hdpi", RuntimeEnvironment.getApiLevel(), configuration,
+        displayMetrics);
+    assertThat(displayMetrics.density).isEqualTo(1.5f);
+    assertThat(displayMetrics.densityDpi).isEqualTo(240);
+  }
+
+  @Test
+  public void applyQualifiers_shouldSetLocaleScript() throws Exception {
+    Bootstrap.applyQualifiers("b+sr+Latn", RuntimeEnvironment.getApiLevel(),
+        configuration, displayMetrics);
+    String outQualifiers = ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+
+    assertThat(configuration.locale.getScript()).isEqualTo("Latn");
+    assertThat(outQualifiers).contains("b+sr+Latn");
+  }
+
+  @Test
+  @Config(sdk = JELLY_BEAN_MR1)
+  public void applyQualifiers_rtlPseudoLocale_shouldSetLayoutDirection() {
+    Bootstrap.applyQualifiers(
+        "ar-rXB", RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+
+    assertThat(configuration.getLayoutDirection()).isEqualTo(View.LAYOUT_DIRECTION_RTL);
+  }
+
+  @Test
+  public void spaceSeparated_applyQualifiers_shouldReplaceQualifiers() throws Exception {
+    Bootstrap.applyQualifiers("ru-rRU-h123dp-large fr-w321dp", RuntimeEnvironment.getApiLevel(),
+        configuration, displayMetrics);
+    String outQualifiers = ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+
+    assertThat(outQualifiers).startsWith("fr-ldltr-sw321dp-w321dp-h470dp-normal");
+  }
+
+  @Test
+  public void whenPrefixedWithPlus_applyQualifiers_shouldOverlayQualifiers() throws Exception {
+    Bootstrap.applyQualifiers(
+        "+en ru-rRU-h123dp-large +fr-w321dp-small",
+        RuntimeEnvironment.getApiLevel(),
+        configuration,
+        displayMetrics);
+    String outQualifiers = ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+
+    assertThat(outQualifiers).startsWith("fr-ldltr-sw321dp-w321dp-h426dp-small");
+  }
+
+  @Test
+  public void whenAllPrefixedWithPlus_applyQualifiers_shouldOverlayQualifiers() throws Exception {
+    Bootstrap.applyQualifiers(
+        "+xxhdpi +ru-rRU-h123dp-large +fr-w321dp-small",
+        RuntimeEnvironment.getApiLevel(),
+        configuration,
+        displayMetrics);
+    String outQualifiers = ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+
+    assertThat(outQualifiers).startsWith("fr-ldltr-sw321dp-w321dp-h426dp-small");
+    assertThat(outQualifiers).contains("-xxhdpi-");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void testUpdateDisplayResourcesWithDifferentLocale() {
+    Locale locale = new Locale("en", "IN");
+    RuntimeEnvironment.setQualifiers("ar");
+    LocaleList originalDefault = LocaleList.getDefault();
+    try {
+      LocaleList.setDefault(new LocaleList(locale));
+      Application app = RuntimeEnvironment.getApplication();
+      String qualifiers =
+          RuntimeEnvironment.getQualifiers(
+              app.getResources().getConfiguration(), app.getResources().getDisplayMetrics());
+      // The idea here is that the application resources should be changed by the setQualifiers
+      // call, but should not be changed by the change to the default Locale.
+      assertThat(qualifiers).doesNotContain("en-rIN");
+      assertThat(qualifiers).contains("ar");
+    } finally {
+      LocaleList.setDefault(originalDefault);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/CustomStateView.java b/robolectric/src/test/java/org/robolectric/android/CustomStateView.java
new file mode 100644
index 0000000..7d18319
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/CustomStateView.java
@@ -0,0 +1,31 @@
+package org.robolectric.android;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+public class CustomStateView extends TextView {
+
+  public Integer extraAttribute;
+
+  public CustomStateView(Context context) {
+    super(context);
+  }
+
+  public CustomStateView(Context context, AttributeSet attrs) {
+    super(context, attrs);
+  }
+
+  public CustomStateView(Context context, AttributeSet attrs, int defStyle) {
+    super(context, attrs, defStyle);
+  }
+
+  @Override
+  protected int[] onCreateDrawableState(int extraSpace) {
+    final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+    if (extraAttribute != null) {
+      mergeDrawableStates(drawableState, new int[]{extraAttribute});
+    }
+    return drawableState;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/CustomView.java b/robolectric/src/test/java/org/robolectric/android/CustomView.java
new file mode 100644
index 0000000..53d9dc3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/CustomView.java
@@ -0,0 +1,21 @@
+package org.robolectric.android;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import org.robolectric.R;
+
+public class CustomView extends LinearLayout {
+  public static final String ROBOLECTRIC_RES_URI = "http://schemas.android.com/apk/res/org.robolectric";
+  public static final String FAKE_URI = "http://example.com/fakens";
+
+  public int attributeResourceValue;
+  public int namespacedResourceValue;
+
+  public CustomView(Context context, AttributeSet attrs) {
+    super(context, attrs);
+    inflate(context, R.layout.inner_merge, this);
+    attributeResourceValue = attrs.getAttributeResourceValue(ROBOLECTRIC_RES_URI, "message", -1);
+    namespacedResourceValue = attrs.getAttributeResourceValue(FAKE_URI, "message", -1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/CustomView2.java b/robolectric/src/test/java/org/robolectric/android/CustomView2.java
new file mode 100644
index 0000000..c79472a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/CustomView2.java
@@ -0,0 +1,17 @@
+package org.robolectric.android;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+public class CustomView2 extends LinearLayout {
+  public int childCountAfterInflate;
+
+  public CustomView2(Context context, AttributeSet attrs) {
+    super(context, attrs);
+  }
+
+  @Override protected void onFinishInflate() {
+    childCountAfterInflate = getChildCount();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/DefaultPackageManagerIntentComparatorTest.java b/robolectric/src/test/java/org/robolectric/android/DefaultPackageManagerIntentComparatorTest.java
new file mode 100644
index 0000000..81bc04c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/DefaultPackageManagerIntentComparatorTest.java
@@ -0,0 +1,37 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Intent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadows.ShadowPackageManager.IntentComparator;
+
+@RunWith(AndroidJUnit4.class)
+public class DefaultPackageManagerIntentComparatorTest {
+
+  @Test
+  public void validCompareResult() {
+    final IntentComparator intentComparator = new IntentComparator();
+
+    assertThat(intentComparator.compare(null, null)).isEqualTo(0);
+    assertThat(intentComparator.compare(new Intent(), null)).isEqualTo(1);
+    assertThat(intentComparator.compare(null, new Intent())).isEqualTo(-1);
+
+    Intent intent1 = new Intent();
+    Intent intent2 = new Intent();
+
+    assertThat(intentComparator.compare(intent1, intent2)).isEqualTo(0);
+  }
+
+  @Test
+  public void canSustainConcurrentModification() {
+    final IntentComparator intentComparator = new IntentComparator();
+
+    Intent intent1 = new Intent("actionstring0");
+    Intent intent2 = new Intent("actionstring1");
+    assertThat(intentComparator.compare(intent1, intent2)).isEqualTo(-1);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/DeviceConfigTest.java b/robolectric/src/test/java/org/robolectric/android/DeviceConfigTest.java
new file mode 100644
index 0000000..9a7fb0d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/DeviceConfigTest.java
@@ -0,0 +1,177 @@
+package org.robolectric.android;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.res.Configuration;
+import android.os.Build.VERSION_CODES;
+import android.util.DisplayMetrics;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.Qualifiers;
+
+@RunWith(AndroidJUnit4.class)
+public class DeviceConfigTest {
+
+  private Configuration configuration;
+  private DisplayMetrics displayMetrics;
+  private int apiLevel;
+  private String optsForO;
+
+  @Before
+  public void setUp() throws Exception {
+    configuration = new Configuration();
+    displayMetrics = new DisplayMetrics();
+    apiLevel = RuntimeEnvironment.getApiLevel();
+
+    optsForO = RuntimeEnvironment.getApiLevel() >= O
+        ? "nowidecg-lowdr-"
+        : "";
+  }
+
+  @Test @Config(minSdk = VERSION_CODES.JELLY_BEAN_MR1)
+  public void applyToConfiguration() throws Exception {
+    applyQualifiers("en-rUS-w400dp-h800dp-notround");
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-w400dp-h800dp-notround");
+  }
+
+  @Test
+  public void applyToConfiguration_isCumulative() throws Exception {
+    applyQualifiers("en-rUS-ldltr-sw400dp-w400dp-h800dp-normal-notlong-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw400dp-w400dp-h800dp-normal-notlong-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+
+    applyQualifiers("fr-land");
+    assertThat(asQualifierString())
+        .isEqualTo("fr-ldltr-sw400dp-w400dp-h800dp-normal-notlong-notround-" + optsForO + "land-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+
+    applyQualifiers("w500dp-large-television-night-xxhdpi-notouch-keyshidden");
+    assertThat(asQualifierString())
+        .isEqualTo("fr-ldltr-sw400dp-w500dp-large-notlong-notround-" + optsForO + "land-television-night-xxhdpi-notouch-keyshidden-nokeys-navhidden-nonav");
+
+    applyQualifiers("long");
+    assertThat(asQualifierString())
+        .isEqualTo("fr-ldltr-sw400dp-w500dp-large-long-notround-" + optsForO + "land-television-night-xxhdpi-notouch-keyshidden-nokeys-navhidden-nonav");
+
+    applyQualifiers("round");
+    assertThat(asQualifierString())
+        .isEqualTo("fr-ldltr-sw400dp-w500dp-large-long-round-" + optsForO + "land-television-night-xxhdpi-notouch-keyshidden-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_defaults() throws Exception {
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw320dp-w320dp-h470dp-normal-notlong-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  // todo: this fails on JELLY_BEAN and LOLLIPOP through M... why?
+  @Test @Config(minSdk = VERSION_CODES.N)
+  public void applyRules_rtlScript() throws Exception {
+    applyQualifiers("he");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("iw-ldrtl-sw320dp-w320dp-h470dp-normal-notlong-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_heightWidth() throws Exception {
+    applyQualifiers("w800dp-h400dp");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw400dp-w800dp-h400dp-normal-long-notround-" + optsForO + "land-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_heightWidthOrientation() throws Exception {
+    applyQualifiers("w800dp-h400dp-port");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw400dp-w400dp-h800dp-normal-long-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_sizeToDimens() throws Exception {
+    applyQualifiers("large-land");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw480dp-w640dp-h480dp-large-notlong-notround-" + optsForO + "land-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_sizeFromDimens() throws Exception {
+    applyQualifiers("w800dp-h640dp");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw640dp-w800dp-h640dp-large-notlong-notround-" + optsForO + "land-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_longIncreasesHeight() throws Exception {
+    applyQualifiers("long");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw320dp-w320dp-h587dp-normal-long-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyRules_greatHeightTriggersLong() throws Exception {
+    applyQualifiers("h590dp");
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw320dp-w320dp-h590dp-normal-long-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Ignore("consider how to reset uiMode type") @Test
+  public void shouldParseButNotDisplayNormal() throws Exception {
+    applyQualifiers("car");
+    applyQualifiers("+normal");
+    assertThat(asQualifierString())
+        .isEqualTo("en-rUS-ldltr-sw320dp-w320dp-h590dp-normal-long-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav");
+  }
+
+  @Test
+  public void applyQualifiers_populatesDisplayMetrics_defaultDensity() {
+    applyQualifiers("w800dp-h640dp-mdpi");
+    assertThat(displayMetrics.widthPixels).isEqualTo(800);
+    assertThat(displayMetrics.heightPixels).isEqualTo(640);
+    assertThat(displayMetrics.density).isEqualTo((float) 1.0);
+    assertThat(displayMetrics.xdpi).isEqualTo((float) 160.0);
+    assertThat(displayMetrics.ydpi).isEqualTo((float) 160.0);
+  }
+
+  @Test
+  public void applyQualifiers_populatesDisplayMetrics_withDensity() {
+    applyQualifiers("w800dp-h640dp-hdpi");
+    assertThat(displayMetrics.widthPixels).isEqualTo(1200);
+    assertThat(displayMetrics.heightPixels).isEqualTo(960);
+    assertThat(displayMetrics.density).isEqualTo((float) 1.5);
+    assertThat(displayMetrics.xdpi).isEqualTo((float) 240.0);
+    assertThat(displayMetrics.ydpi).isEqualTo((float) 240.0);
+  }
+
+  //////////////////////////
+
+  private void applyQualifiers(String qualifiers) {
+    DeviceConfig.applyToConfiguration(Qualifiers.parse(qualifiers),
+        apiLevel, configuration, displayMetrics);
+  }
+
+  private String asQualifierString() {
+    return ConfigurationV25.resourceQualifierString(configuration, displayMetrics, false);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/DrawableResourceLoaderTest.java b/robolectric/src/test/java/org/robolectric/android/DrawableResourceLoaderTest.java
new file mode 100644
index 0000000..87ddb07
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/DrawableResourceLoaderTest.java
@@ -0,0 +1,106 @@
+package org.robolectric.android;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assume.assumeTrue;
+import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
+
+import android.animation.Animator;
+import android.animation.AnimatorInflater;
+import android.content.res.Resources;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.LayerDrawable;
+import android.graphics.drawable.NinePatchDrawable;
+import android.graphics.drawable.VectorDrawable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class DrawableResourceLoaderTest {
+  private Resources resources;
+
+  @Before
+  public void setup() throws Exception {
+    assumeTrue(useLegacy());
+    resources = ApplicationProvider.getApplicationContext().getResources();
+  }
+
+  @Test
+  public void testGetDrawable_rainbow() throws Exception {
+    assertNotNull(
+        ApplicationProvider.getApplicationContext().getResources().getDrawable(R.drawable.rainbow));
+  }
+
+  @Test
+  public void testGetDrawableBundle_shouldWorkWithSystem() throws Exception {
+    assertNotNull(resources.getDrawable(android.R.drawable.ic_popup_sync));
+  }
+
+  @Test
+  public void testGetDrawable_red() throws Exception {
+    assertNotNull(Resources.getSystem().getDrawable(android.R.drawable.ic_menu_help));
+  }
+
+  @Test
+  public void testDrawableTypes() {
+    assertThat(resources.getDrawable(R.drawable.l7_white)).isInstanceOf(BitmapDrawable.class);
+    assertThat(resources.getDrawable(R.drawable.l0_red)).isInstanceOf(BitmapDrawable.class);
+    assertThat(resources.getDrawable(R.drawable.nine_patch_drawable)).isInstanceOf(NinePatchDrawable.class);
+    assertThat(resources.getDrawable(R.drawable.rainbow)).isInstanceOf(LayerDrawable.class);
+  }
+
+  @Test @Config(maxSdk = KITKAT_WATCH)
+  public void testVectorDrawableType_preVectors() {
+    assertThat(resources.getDrawable(R.drawable.an_image_or_vector)).isInstanceOf(BitmapDrawable.class);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void testVectorDrawableType() {
+    assertThat(resources.getDrawable(R.drawable.an_image_or_vector)).isInstanceOf(VectorDrawable.class);
+  }
+
+  @Test
+  @Config(qualifiers = "land")
+  public void testLayerDrawable_xlarge() {
+    assertEquals(
+        6,
+        ((LayerDrawable)
+                ApplicationProvider.getApplicationContext()
+                    .getResources()
+                    .getDrawable(R.drawable.rainbow))
+            .getNumberOfLayers());
+  }
+
+  @Test
+  public void testLayerDrawable() {
+    assertEquals(
+        8,
+        ((LayerDrawable)
+                ApplicationProvider.getApplicationContext()
+                    .getResources()
+                    .getDrawable(R.drawable.rainbow))
+            .getNumberOfLayers());
+  }
+
+  @Test
+  public void shouldCreateAnimators() throws Exception {
+    Animator animator =
+        AnimatorInflater.loadAnimator(RuntimeEnvironment.getApplication(), R.animator.spinning);
+    assertThat(animator).isInstanceOf((Class<? extends Animator>) Animator.class);
+  }
+
+  @Test
+  public void shouldCreateAnimsAndColors() throws Exception {
+    assertThat(resources.getDrawable(R.color.grey42)).isInstanceOf((Class<? extends android.graphics.drawable.Drawable>) ColorDrawable.class);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/FailureListener.java b/robolectric/src/test/java/org/robolectric/android/FailureListener.java
new file mode 100644
index 0000000..28c0b69
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/FailureListener.java
@@ -0,0 +1,33 @@
+package org.robolectric.android;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.notification.RunListener;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.model.InitializationError;
+import org.robolectric.RobolectricTestRunner;
+
+public class FailureListener extends RunListener {
+  @Nonnull
+  public static List<Failure> runTests(Class<?> testClass) throws InitializationError {
+    RunNotifier notifier = new RunNotifier();
+    FailureListener failureListener = new FailureListener();
+    notifier.addListener(failureListener);
+    new RobolectricTestRunner(testClass).run(notifier);
+    return failureListener.failures;
+  }
+
+  public final List<Failure> failures = new ArrayList<>();
+
+  @Override
+  public void testFailure(Failure failure) throws Exception {
+    failures.add(failure);
+  }
+
+  @Override
+  public void testAssumptionFailure(Failure failure) {
+    failures.add(failure);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/FragmentTestUtilTest.java b/robolectric/src/test/java/org/robolectric/android/FragmentTestUtilTest.java
new file mode 100644
index 0000000..9512c5f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/FragmentTestUtilTest.java
@@ -0,0 +1,99 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.FragmentTestUtil.startFragment;
+import static org.robolectric.util.FragmentTestUtil.startVisibleFragment;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class FragmentTestUtilTest {
+  @Test
+  public void startFragment_shouldStartFragment() {
+    final LoginFragment fragment = new LoginFragment();
+    startFragment(fragment);
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+  }
+
+  @Test
+  public void startVisibleFragment_shouldStartFragment() {
+    final LoginFragment fragment = new LoginFragment();
+    startVisibleFragment(fragment);
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+  }
+
+  @Test
+  public void startVisibleFragment_shouldAttachFragmentToActivity() {
+    final LoginFragment fragment = new LoginFragment();
+    startVisibleFragment(fragment);
+
+    assertThat(fragment.getView().getWindowToken()).isNotNull();
+  }
+
+  @Test
+  public void startFragment_shouldStartFragmentWithSpecifiedActivityClass() {
+    final LoginFragment fragment = new LoginFragment();
+    startFragment(fragment, LoginActivity.class);
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+    assertThat(fragment.getActivity()).isInstanceOf(LoginActivity.class);
+  }
+
+  @Test
+  public void startVisibleFragment_shouldStartFragmentWithSpecifiedActivityClass() {
+    final LoginFragment fragment = new LoginFragment();
+    startVisibleFragment(fragment, LoginActivity.class, 1);
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+    assertThat(fragment.getActivity()).isInstanceOf(LoginActivity.class);
+  }
+
+  @Test
+  public void startVisibleFragment_shouldAttachFragmentToActivityWithSpecifiedActivityClass() {
+    final LoginFragment fragment = new LoginFragment();
+    startVisibleFragment(fragment, LoginActivity.class, 1);
+
+    assertThat(fragment.getView().getWindowToken()).isNotNull();
+    assertThat(fragment.getActivity()).isInstanceOf(LoginActivity.class);
+  }
+
+  public static class LoginFragment extends Fragment {
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+      return inflater.inflate(R.layout.fragment_contents, container, false);
+    }
+  }
+
+  private static class LoginActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      LinearLayout view = new LinearLayout(this);
+      view.setId(1);
+
+      setContentView(view);
+    }
+  }
+}
+
diff --git a/robolectric/src/test/java/org/robolectric/android/PreferenceIntegrationTest.java b/robolectric/src/test/java/org/robolectric/android/PreferenceIntegrationTest.java
new file mode 100644
index 0000000..413aaa8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/PreferenceIntegrationTest.java
@@ -0,0 +1,147 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.CheckBoxPreference;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceScreen;
+import android.preference.RingtonePreference;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class PreferenceIntegrationTest {
+
+  @Test
+  public void inflate_shouldCreateCorrectClasses() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    assertThat(screen.getPreference(0)).isInstanceOf(PreferenceCategory.class);
+
+    PreferenceCategory category = (PreferenceCategory) screen.getPreference(0);
+    assertThat(category.getPreference(0)).isInstanceOf(Preference.class);
+
+    PreferenceScreen innerScreen = (PreferenceScreen) screen.getPreference(1);
+    assertThat(innerScreen).isInstanceOf(PreferenceScreen.class);
+    assertThat(innerScreen.getKey()).isEqualTo("screen");
+    assertThat(innerScreen.getTitle().toString()).isEqualTo("Screen Test");
+    assertThat(innerScreen.getSummary().toString()).isEqualTo("Screen summary");
+    assertThat(innerScreen.getPreference(0)).isInstanceOf(Preference.class);
+
+    assertThat(screen.getPreference(2)).isInstanceOf(CheckBoxPreference.class);
+    assertThat(screen.getPreference(3)).isInstanceOf(EditTextPreference.class);
+    assertThat(screen.getPreference(4)).isInstanceOf(ListPreference.class);
+    assertThat(screen.getPreference(5)).isInstanceOf(Preference.class);
+    assertThat(screen.getPreference(6)).isInstanceOf(RingtonePreference.class);
+    assertThat(screen.getPreference(7)).isInstanceOf(Preference.class);
+  }
+
+  @Test
+  public void inflate_shouldParseIntentContainedInPreference() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference intentPreference = screen.findPreference("intent");
+
+    Intent intent = intentPreference.getIntent();
+    assertThat(intent).isNotNull();
+    assertThat(intent.getAction()).isEqualTo("action");
+    assertThat(intent.getData()).isEqualTo(Uri.parse("tel://1235"));
+    assertThat(intent.getType()).isEqualTo("application/text");
+    assertThat(intent.getComponent().getClassName()).isEqualTo("org.robolectric.test.Intent");
+    assertThat(intent.getComponent().getPackageName()).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void inflate_shouldBindPreferencesToPreferenceManager() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference preference = screen.findPreference("preference");
+    assertThat(preference.getPreferenceManager().findPreference("preference")).isNotNull();
+  }
+
+  @Test
+  public void setPersistent_shouldMarkThePreferenceAsPersistent() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference preference = screen.findPreference("preference");
+
+    preference.setPersistent(true);
+    assertThat(preference.isPersistent()).isTrue();
+
+    preference.setPersistent(false);
+    assertThat(preference.isPersistent()).isFalse();
+  }
+
+  @Test
+  public void setEnabled_shouldEnableThePreference() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference preference = screen.findPreference("preference");
+
+    preference.setEnabled(true);
+    assertThat(preference.isEnabled()).isTrue();
+
+    preference.setEnabled(false);
+    assertThat(preference.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void setOrder_shouldSetOrderOnPreference() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference preference = screen.findPreference("preference");
+
+    preference.setOrder(100);
+    assertThat(preference.getOrder()).isEqualTo(100);
+
+    preference.setOrder(50);
+    assertThat(preference.getOrder()).isEqualTo(50);
+  }
+
+  @Test
+  public void setDependency_shouldSetDependencyBetweenPreferences() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference dependant = screen.findPreference("dependant");
+    assertThat(dependant.getDependency()).isEqualTo("preference");
+
+    dependant.setDependency(null);
+    assertThat(dependant.getDependency()).isNull();
+  }
+
+  @Test
+  public void click_shouldCallPreferenceClickListener() throws Exception {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final Preference preference = screen.findPreference("preference");
+
+    boolean[] holder = new boolean[1];
+    preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+      @Override
+      public boolean onPreferenceClick(Preference preference) {
+        holder[0] = true;
+        return true;
+      }
+    });
+
+    shadowOf(preference).click();
+    assertThat(holder[0]).isTrue();
+  }
+
+  private PreferenceScreen inflatePreferenceActivity() {
+    TestPreferenceActivity activity = Robolectric.setupActivity(TestPreferenceActivity.class);
+    return activity.getPreferenceScreen();
+  }
+
+  @SuppressWarnings("FragmentInjection")
+  private static class TestPreferenceActivity extends PreferenceActivity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      addPreferencesFromResource(R.xml.preferences);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/ResourceLoaderTest.java b/robolectric/src/test/java/org/robolectric/android/ResourceLoaderTest.java
new file mode 100644
index 0000000..b895d65
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/ResourceLoaderTest.java
@@ -0,0 +1,108 @@
+package org.robolectric.android;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
+import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResourceTable;
+
+@RunWith(AndroidJUnit4.class)
+public class ResourceLoaderTest {
+
+  private String optsForO;
+
+  @Before
+  public void setUp() {
+    assumeTrue(useLegacy());
+
+    optsForO = RuntimeEnvironment.getApiLevel() >= O
+        ? "nowidecg-lowdr-"
+        : "";
+  }
+
+  @Test
+  @Config(qualifiers="w0dp")
+  public void checkDefaultBooleanValue() throws Exception {
+    assertThat(
+            ApplicationProvider.getApplicationContext()
+                .getResources()
+                .getBoolean(R.bool.different_resource_boolean))
+        .isEqualTo(false);
+  }
+
+  @Test
+  @Config(qualifiers="w820dp")
+  public void checkQualifiedBooleanValue() throws Exception {
+    assertThat(
+            ApplicationProvider.getApplicationContext()
+                .getResources()
+                .getBoolean(R.bool.different_resource_boolean))
+        .isEqualTo(true);
+  }
+  
+  @Test
+  public void checkForPollution1() throws Exception {
+    checkForPollutionHelper();
+  }
+
+  @Test
+  public void checkForPollution2() throws Exception {
+    checkForPollutionHelper();
+  }
+
+  private void checkForPollutionHelper() {
+    assertThat(RuntimeEnvironment.getQualifiers())
+        .isEqualTo("en-rUS-ldltr-sw320dp-w320dp-h470dp-normal-notlong-notround-" + optsForO + "port-notnight-mdpi-finger-keyssoft-nokeys-navhidden-nonav-v" + Build.VERSION.RESOURCES_SDK_INT);
+
+    View view =
+        LayoutInflater.from(ApplicationProvider.getApplicationContext())
+            .inflate(R.layout.different_screen_sizes, null);
+    TextView textView = view.findViewById(android.R.id.text1);
+    assertThat(textView.getText().toString()).isEqualTo("default");
+    RuntimeEnvironment.setQualifiers("fr-land"); // testing if this pollutes the other test
+    Configuration configuration = Resources.getSystem().getConfiguration();
+    if (RuntimeEnvironment.getApiLevel() <= VERSION_CODES.JELLY_BEAN) {
+      configuration.locale = new Locale("fr", "FR");
+    } else {
+      configuration.setLocale(new Locale("fr", "FR"));
+    }
+    configuration.orientation = Configuration.ORIENTATION_LANDSCAPE;
+    Resources.getSystem().updateConfiguration(configuration, null);
+  }
+
+  @Test
+  public void shouldMakeInternalResourcesAvailable() throws Exception {
+    ResourceTable resourceProvider = RuntimeEnvironment.getSystemResourceTable();
+    ResName internalResource = new ResName("android", "string", "badPin");
+    Integer resId = resourceProvider.getResourceId(internalResource);
+    assertThat(resId).isNotNull();
+    assertThat(resourceProvider.getResName(resId)).isEqualTo(internalResource);
+
+    Class<?> internalRIdClass = Robolectric.class.getClassLoader().loadClass("com.android.internal.R$" + internalResource.type);
+    int internalResourceId;
+    internalResourceId = (Integer) internalRIdClass.getDeclaredField(internalResource.name).get(null);
+    assertThat(resId).isEqualTo(internalResourceId);
+
+    assertThat(ApplicationProvider.getApplicationContext().getResources().getString(resId))
+        .isEqualTo("The old PIN you typed isn't correct.");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/ResourceTableFactoryIntegrationTest.java b/robolectric/src/test/java/org/robolectric/android/ResourceTableFactoryIntegrationTest.java
new file mode 100644
index 0000000..0ae4675
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/ResourceTableFactoryIntegrationTest.java
@@ -0,0 +1,25 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
+import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
+
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.ResName;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+public class ResourceTableFactoryIntegrationTest {
+  @Test
+  public void shouldIncludeStyleableAttributesThatDoNotHaveACorrespondingEntryInAttrClass() throws Exception {
+    assumeTrue(useLegacy());
+    // This covers a corner case in Framework resources where an attribute is mentioned in a styleable array, e.g: R.styleable.Toolbar_buttonGravity but there is no corresponding R.attr.buttonGravity
+    assertThat(RuntimeEnvironment.getSystemResourceTable()
+          .getResourceId(new ResName("android", "attr", "buttonGravity"))).isGreaterThan(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/ShadowingTest.java b/robolectric/src/test/java/org/robolectric/android/ShadowingTest.java
new file mode 100644
index 0000000..7644f95
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/ShadowingTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import android.util.Log;
+import android.view.View;
+import android.widget.Toast;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowingTest {
+
+  @Test
+  public void testPrintlnWorks() throws Exception {
+    Log.println(1, "tag", "msg");
+  }
+
+  @Test
+  public void shouldDelegateToObjectToStringIfShadowHasNone() throws Exception {
+    assertThat(new Toast(ApplicationProvider.getApplicationContext()).toString())
+        .startsWith("android.widget.Toast@");
+  }
+
+  @Test
+  public void shouldDelegateToObjectHashCodeIfShadowHasNone() throws Exception {
+    assertFalse(new View(ApplicationProvider.getApplicationContext()).hashCode() == 0);
+  }
+
+  @Test
+  public void shouldDelegateToObjectEqualsIfShadowHasNone() throws Exception {
+    View view = new View(ApplicationProvider.getApplicationContext());
+    assertEquals(view, view);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/TestAnimationListener.java b/robolectric/src/test/java/org/robolectric/android/TestAnimationListener.java
new file mode 100644
index 0000000..5b4371d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/TestAnimationListener.java
@@ -0,0 +1,26 @@
+package org.robolectric.android;
+
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+
+public class TestAnimationListener implements AnimationListener {
+
+  public boolean wasStartCalled = false;
+  public boolean wasEndCalled = false;
+  public boolean wasRepeatCalled = false;
+
+  @Override
+  public void onAnimationStart(Animation animation) {
+    wasStartCalled = true;
+  }
+
+  @Override
+  public void onAnimationEnd(Animation animation) {
+    wasEndCalled = true;
+  }
+
+  @Override
+  public void onAnimationRepeat(Animation animation) {
+    wasRepeatCalled = true;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/XmlResourceParserImplTest.java b/robolectric/src/test/java/org/robolectric/android/XmlResourceParserImplTest.java
new file mode 100644
index 0000000..b57f606
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/XmlResourceParserImplTest.java
@@ -0,0 +1,767 @@
+package org.robolectric.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+
+import android.app.Application;
+import android.content.res.XmlResourceParser;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.RuntimeEnvironment;
+import org.w3c.dom.Document;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+@RunWith(AndroidJUnit4.class)
+public class XmlResourceParserImplTest {
+
+  private static final String RES_AUTO_NS = "http://schemas.android.com/apk/res-auto";
+  private XmlResourceParser parser;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    parser = context.getResources().getXml(R.xml.preferences);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    parser.close();
+  }
+
+  private void parseUntilNext(int event) throws Exception {
+    while (parser.next() != event) {
+      if (parser.getEventType() == XmlResourceParser.END_DOCUMENT) {
+        throw new RuntimeException("Impossible to find: " +
+            event + ". End of document reached.");
+      }
+    }
+  }
+
+  private void forgeAndOpenDocument(String xmlValue) {
+    try {
+      DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+      factory.setNamespaceAware(true);
+      factory.setIgnoringComments(true);
+      factory.setIgnoringElementContentWhitespace(true);
+      DocumentBuilder documentBuilder = factory.newDocumentBuilder();
+      Document document = documentBuilder.parse(
+          new ByteArrayInputStream(xmlValue.getBytes(UTF_8)));
+
+      parser =
+          new XmlResourceParserImpl(
+              document, Paths.get("file"), R.class.getPackage().getName(), "org.robolectric", null);
+      // Navigate to the root element
+      parseUntilNext(XmlResourceParser.START_TAG);
+    } catch (Exception parsingException) {
+      // Wrap XML parsing exception with a runtime
+      // exception for convenience.
+      throw new RuntimeException(
+          "Cannot forge a Document from an invalid XML",
+          parsingException);
+    }
+  }
+
+  private int attributeIndexOutOfIndex() {
+    return parser.getAttributeCount() + 1;
+  }
+
+  @Test
+  public void testGetXmlInt() throws Exception {
+    assertThat(parser).isNotNull();
+    int evt = parser.next();
+    assertThat(evt).isEqualTo(XmlResourceParser.START_DOCUMENT);
+  }
+
+  @Test
+  public void testGetXmlString() {
+    assertThat(parser).isNotNull();
+  }
+
+  @Test
+  public void testSetFeature() throws Exception {
+    for (String feature : XmlResourceParserImpl.AVAILABLE_FEATURES) {
+      parser.setFeature(feature, true);
+      try {
+        parser.setFeature(feature, false);
+        fail(feature + " should be true.");
+      } catch (XmlPullParserException ex) {
+        // pass
+      }
+    }
+
+    for (String feature : XmlResourceParserImpl.UNAVAILABLE_FEATURES) {
+      try {
+        parser.setFeature(feature, false);
+        fail(feature + " should not be true.");
+      } catch (XmlPullParserException ex) {
+        // pass
+      }
+      try {
+        parser.setFeature(feature, true);
+        fail(feature + " should not be true.");
+      } catch (XmlPullParserException ex) {
+        // pass
+      }
+    }
+  }
+
+  @Test
+  public void testGetFeature() {
+    for (String feature : XmlResourceParserImpl.AVAILABLE_FEATURES) {
+      assertThat(parser.getFeature(feature)).isTrue();
+    }
+
+    for (String feature : XmlResourceParserImpl.UNAVAILABLE_FEATURES) {
+      assertThat(parser.getFeature(feature)).isFalse();
+    }
+
+    assertThat(parser.getFeature(null)).isFalse();
+  }
+
+  @Test
+  public void testSetProperty() {
+    try {
+      parser.setProperty("foo", "bar");
+      fail("Properties should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetProperty() {
+    // Properties are not supported
+    assertThat(parser.getProperty("foo")).isNull();
+  }
+
+  @Test
+  public void testSetInput_Reader() {
+    try {
+      parser.setInput(new StringReader(""));
+      fail("This method should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testSetInput_InputStreamString() throws IOException {
+    try (InputStream inputStream =
+        getClass().getResourceAsStream("src/test/resources/res/xml/preferences.xml")) {
+      parser.setInput(inputStream, "UTF-8");
+      fail("This method should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testDefineEntityReplacementText() {
+    try {
+      parser.defineEntityReplacementText("foo", "bar");
+      fail("This method should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetNamespacePrefix() {
+    try {
+      parser.getNamespacePrefix(0);
+      fail("This method should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetInputEncoding() {
+    assertThat(parser.getInputEncoding()).isNull();
+  }
+
+  @Test
+  public void testGetNamespace_String() {
+    try {
+      parser.getNamespace("bar");
+      fail("This method should not be supported");
+    } catch (RuntimeException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetNamespaceCount() {
+    try {
+      parser.getNamespaceCount(0);
+      fail("This method should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetNamespaceUri() {
+    try {
+      parser.getNamespaceUri(0);
+      fail("This method should not be supported");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetColumnNumber() {
+    assertThat(parser.getColumnNumber()).isEqualTo(-1);
+  }
+
+  @Test
+  public void testGetDepth() throws Exception {
+    // Recorded depths from preference file elements
+    List<Integer> expectedDepths = asList(1, 2, 3, 2, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 3);
+    List<Integer> actualDepths = new ArrayList<>();
+    int evt;
+    while ((evt = parser.next()) != XmlResourceParser.END_DOCUMENT) {
+      switch (evt) {
+        case (XmlResourceParser.START_TAG): {
+          actualDepths.add(parser.getDepth());
+          break;
+        }
+      }
+
+    }
+    assertThat(actualDepths).isEqualTo(expectedDepths);
+  }
+
+  @Test
+  public void testGetText() {
+    forgeAndOpenDocument("<foo/>");
+    assertThat(parser.getText()).isEqualTo("");
+
+    forgeAndOpenDocument("<foo>bar</foo>");
+    assertThat(parser.getText()).isEqualTo("bar");
+  }
+
+  @Test
+  public void testGetEventType() throws Exception {
+    int evt;
+    while ((evt = parser.next()) != XmlResourceParser.END_DOCUMENT) {
+      assertThat(parser.getEventType()).isEqualTo(evt);
+    }
+  }
+
+  @Test
+  public void testIsWhitespace() throws Exception {
+    assumeTrue(RuntimeEnvironment.useLegacyResources());
+
+    XmlResourceParserImpl parserImpl = (XmlResourceParserImpl) parser;
+    assertThat(parserImpl.isWhitespace("bar")).isFalse();
+    assertThat(parserImpl.isWhitespace(" ")).isTrue();
+  }
+
+  @Test
+  public void testGetPrefix() {
+    try {
+      parser.getPrefix();
+      fail("This method should not be supported");
+    } catch (RuntimeException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetNamespace() {
+    forgeAndOpenDocument("<foo xmlns=\"http://www.w3.org/1999/xhtml\">bar</foo>");
+    assertThat(parser.getNamespace()).isEqualTo("http://www.w3.org/1999/xhtml");
+  }
+
+  @Test
+  public void testGetName_atStart() throws Exception {
+    assertThat(parser.getName()).isEqualTo(null);
+    parseUntilNext(XmlResourceParser.START_DOCUMENT);
+    assertThat(parser.getName()).isEqualTo(null);
+    parseUntilNext(XmlResourceParser.START_TAG);
+    assertThat(parser.getName()).isEqualTo("PreferenceScreen");
+  }
+
+  @Test
+  public void testGetName() {
+    forgeAndOpenDocument("<foo/>");
+    assertThat(parser.getName()).isEqualTo("foo");
+  }
+
+  @Test
+  public void testGetAttribute() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"bar\"/>");
+    XmlResourceParserImpl parserImpl = (XmlResourceParserImpl) parser;
+    assertThat(parserImpl.getAttribute(RES_AUTO_NS, "bar")).isEqualTo("bar");
+  }
+
+  @Test
+  public void testGetAttributeNamespace() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"bar\"/>");
+    assertThat(parser.getAttributeNamespace(0)).isEqualTo(RES_AUTO_NS);
+  }
+
+  @Test
+  public void testGetAttributeName() {
+    try {
+      parser.getAttributeName(0);
+      fail("Expected exception");
+    } catch (IndexOutOfBoundsException expected) {
+      // Expected
+    }
+
+    forgeAndOpenDocument("<foo bar=\"bar\"/>");
+    assertThat(parser.getAttributeName(0)).isEqualTo("bar");
+
+    try {
+      parser.getAttributeName(attributeIndexOutOfIndex());
+      fail("Expected exception");
+    } catch (IndexOutOfBoundsException expected) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testGetAttributePrefix() throws Exception {
+    parseUntilNext(XmlResourceParser.START_TAG);
+    try {
+      parser.getAttributePrefix(0);
+      fail("This method should not be supported");
+    } catch (RuntimeException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testIsEmptyElementTag() throws Exception {
+    assertWithMessage("Before START_DOCUMENT should return false.")
+        .that(parser.isEmptyElementTag())
+        .isEqualTo(false);
+
+    forgeAndOpenDocument("<foo><bar/></foo>");
+    assertWithMessage("Not empty tag should return false.")
+        .that(parser.isEmptyElementTag())
+        .isEqualTo(false);
+
+    forgeAndOpenDocument("<foo/>");
+    assertWithMessage("In the Android implementation this method always return false.")
+        .that(parser.isEmptyElementTag())
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void testGetAttributeCount() {
+    assertWithMessage("When no node is being explored the number of attributes should be -1.")
+        .that(parser.getAttributeCount())
+        .isEqualTo(-1);
+
+    forgeAndOpenDocument("<foo bar=\"bar\"/>");
+    assertThat(parser.getAttributeCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testGetAttributeValue_Int() {
+    forgeAndOpenDocument("<foo bar=\"bar\"/>");
+    assertThat(parser.getAttributeValue(0)).isEqualTo("bar");
+
+    try {
+      parser.getAttributeValue(attributeIndexOutOfIndex());
+      fail();
+    } catch (IndexOutOfBoundsException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetAttributeEscapedValue() {
+    forgeAndOpenDocument("<foo bar=\"\\'\"/>");
+    assertThat(parser.getAttributeValue(0)).isEqualTo("\'");
+  }
+
+  @Test
+  public void testGetAttributeEntityValue() {
+    forgeAndOpenDocument("<foo bar=\"\\u201e&#92;&#34;\"/>");
+    assertThat(parser.getAttributeValue(0)).isEqualTo("„\"");
+  }
+
+  @Test
+  public void testGetNodeTextEscapedValue() {
+    forgeAndOpenDocument("<foo>\'</foo>");
+    assertThat(parser.getText()).isEqualTo("\'");
+  }
+
+  @Test
+  public void testGetNodeTextEntityValue() {
+    forgeAndOpenDocument("<foo>\\u201e\\&#34;</foo>");
+    assertThat(parser.getText()).isEqualTo("„\"");
+  }
+
+  @Test
+  public void testGetAttributeType() {
+    // Hardcoded to always return CDATA
+    assertThat(parser.getAttributeType(attributeIndexOutOfIndex())).isEqualTo("CDATA");
+  }
+
+  @Test
+  public void testIsAttributeDefault() {
+    assertThat(parser.isAttributeDefault(attributeIndexOutOfIndex())).isFalse();
+  }
+
+  @Test
+  public void testGetAttributeValueStringString() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"bar\"/>");
+    assertThat(parser.getAttributeValue(RES_AUTO_NS, "bar")).isEqualTo("bar");
+  }
+
+  @Test
+  public void testNext() throws Exception {
+    // Recorded events while parsing preferences from Android
+    List<String> expectedEvents = Arrays.asList(
+        "<xml>",
+        "<", // PreferenceScreen
+        "<", // PreferenceCategory
+        "<", // Preference
+        ">",
+        ">",
+
+        "<", // PreferenceScreen
+        "<", // Preference
+        ">",
+        "<", // Preference
+        ">",
+        ">",
+
+        "<", // CheckBoxPreference
+        ">",
+        "<", // EditTextPreference
+        ">",
+        "<", // ListPreference
+        ">",
+        "<", // Preference
+        ">",
+        "<", //RingtonePreference
+        ">",
+        "<", // Preference
+        ">",
+        "<",
+        ">",
+        "<",
+        "<",
+        ">",
+        ">",
+        ">",
+        "</xml>");
+    List<String> actualEvents = new ArrayList<>();
+
+    int evt;
+    do {
+      evt = parser.next();
+      switch (evt) {
+        case XmlPullParser.START_DOCUMENT:
+          actualEvents.add("<xml>");
+          break;
+        case XmlPullParser.END_DOCUMENT:
+          actualEvents.add("</xml>");
+          break;
+        case XmlPullParser.START_TAG:
+          actualEvents.add("<");
+          break;
+        case XmlPullParser.END_TAG:
+          actualEvents.add(">");
+          break;
+      }
+    } while (evt != XmlResourceParser.END_DOCUMENT);
+    assertThat(actualEvents).isEqualTo(expectedEvents);
+  }
+
+  @Test
+  public void testRequire() throws Exception {
+    parseUntilNext(XmlResourceParser.START_TAG);
+    parser.require(XmlResourceParser.START_TAG,
+        parser.getNamespace(), parser.getName());
+
+    try {
+      parser.require(XmlResourceParser.END_TAG,
+          parser.getNamespace(), parser.getName());
+      fail("Require with wrong event should have failed");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+
+    try {
+      parser.require(XmlResourceParser.START_TAG,
+          "foo", parser.getName());
+      fail("Require with wrong namespace should have failed");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+
+    try {
+      parser.require(XmlResourceParser.START_TAG,
+          parser.getNamespace(), "foo");
+      fail("Require with wrong tag name should have failed");
+    } catch (XmlPullParserException ex) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testNextText_noText() throws Exception {
+    forgeAndOpenDocument("<foo><bar/></foo>");
+    try {
+      assertThat(parser.nextText()).isEqualTo(parser.getText());
+      fail("nextText on a document with no text should have failed");
+    } catch (XmlPullParserException ex) {
+      assertThat(parser.getEventType()).isAnyOf(XmlResourceParser.START_TAG, XmlResourceParser.END_DOCUMENT);
+    }
+  }
+
+  /**
+   * Test that next tag will only return tag events.
+   */
+  @Test
+  public void testNextTag() throws Exception {
+    Set<Integer> acceptableTags = new HashSet<>();
+    acceptableTags.add(XmlResourceParser.START_TAG);
+    acceptableTags.add(XmlResourceParser.END_TAG);
+
+    forgeAndOpenDocument("<foo><bar/><text>message</text></foo>");
+    int evt;
+    do {
+      evt = parser.next();
+      assertTrue(acceptableTags.contains(evt));
+    } while (evt == XmlResourceParser.END_TAG &&
+        "foo".equals(parser.getName()));
+  }
+
+  @Test
+  public void testGetAttributeListValue_StringStringStringArrayInt() {
+    String[] options = {"foo", "bar"};
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"bar\"/>");
+    assertThat(parser.getAttributeListValue(RES_AUTO_NS, "bar", options, 0)).isEqualTo(1);
+
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"unexpected\"/>");
+    assertThat(parser.getAttributeListValue(RES_AUTO_NS, "bar", options, 0)).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetAttributeBooleanValue_StringStringBoolean() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"true\"/>");
+    assertThat(parser.getAttributeBooleanValue(RES_AUTO_NS, "bar", false)).isTrue();
+    assertThat(parser.getAttributeBooleanValue(RES_AUTO_NS, "foo", false)).isFalse();
+  }
+
+  @Test
+  public void testGetAttributeBooleanValue_IntBoolean() {
+    forgeAndOpenDocument("<foo bar=\"true\"/>");
+    assertThat(parser.getAttributeBooleanValue(0, false)).isTrue();
+    assertThat(parser.getAttributeBooleanValue(attributeIndexOutOfIndex(), false)).isFalse();
+  }
+
+  @Test
+  public void testGetAttributeResourceValueIntInt() throws Exception {
+    parser = context.getResources().getXml(R.xml.has_attribute_resource_value);
+    parseUntilNext(XmlResourceParser.START_TAG);
+
+    assertThat(parser.getAttributeResourceValue(0, 42)).isEqualTo(R.layout.main);
+  }
+
+  @Test
+  public void testGetAttributeResourceValueStringStringInt() throws Exception {
+    parser = context.getResources().getXml(R.xml.has_attribute_resource_value);
+    parseUntilNext(XmlResourceParser.START_TAG);
+
+    assertThat(parser.getAttributeResourceValue(RES_AUTO_NS, "bar", 42)).isEqualTo(R.layout.main);
+    assertThat(parser.getAttributeResourceValue(RES_AUTO_NS, "foo", 42)).isEqualTo(42);
+  }
+
+  @Test
+  public void testGetAttributeResourceValueWhenNotAResource() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"banana\"/>");
+    assertThat(parser.getAttributeResourceValue(RES_AUTO_NS, "bar", 42)).isEqualTo(42);
+  }
+
+  @Test
+  public void testGetAttributeIntValue_StringStringInt() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"-12\"/>");
+
+    assertThat(parser.getAttributeIntValue(RES_AUTO_NS, "bar", 0)).isEqualTo(-12);
+    assertThat(parser.getAttributeIntValue(RES_AUTO_NS, "foo", 0)).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetAttributeIntValue_IntInt() {
+    forgeAndOpenDocument("<foo bar=\"-12\"/>");
+
+    assertThat(parser.getAttributeIntValue(0, 0)).isEqualTo(-12);
+
+    assertThat(parser.getAttributeIntValue(attributeIndexOutOfIndex(), 0)).isEqualTo(0);
+
+    forgeAndOpenDocument("<foo bar=\"unexpected\"/>");
+    assertThat(parser.getAttributeIntValue(0, 0)).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetAttributeUnsignedIntValue_StringStringInt() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"12\"/>");
+
+    assertThat(parser.getAttributeUnsignedIntValue(RES_AUTO_NS, "bar", 0)).isEqualTo(12);
+
+    assertThat(parser.getAttributeUnsignedIntValue(RES_AUTO_NS, "foo", 0)).isEqualTo(0);
+
+    // Negative unsigned int must be
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"-12\"/>");
+
+    assertWithMessage("Getting a negative number as unsigned should return the default value.")
+        .that(parser.getAttributeUnsignedIntValue(RES_AUTO_NS, "bar", 0))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void testGetAttributeUnsignedIntValue_IntInt() {
+    forgeAndOpenDocument("<foo bar=\"12\"/>");
+
+    assertThat(parser.getAttributeUnsignedIntValue(0, 0)).isEqualTo(12);
+
+    assertThat(parser.getAttributeUnsignedIntValue(attributeIndexOutOfIndex(), 0)).isEqualTo(0);
+
+    // Negative unsigned int must be
+    forgeAndOpenDocument("<foo bar=\"-12\"/>");
+
+    assertWithMessage("Getting a negative number as unsigned should return the default value.")
+        .that(parser.getAttributeUnsignedIntValue(0, 0))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void testGetAttributeFloatValue_StringStringFloat() {
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"12.01\"/>");
+
+    assertThat(parser.getAttributeFloatValue(RES_AUTO_NS, "bar", 0.0f)).isEqualTo(12.01f);
+
+    assertThat(parser.getAttributeFloatValue(RES_AUTO_NS, "foo", 0.0f)).isEqualTo(0.0f);
+
+    forgeAndOpenDocument("<foo bar=\"unexpected\"/>");
+    assertThat(parser.getAttributeFloatValue(RES_AUTO_NS, "bar", 0.0f)).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void testGetAttributeFloatValue_IntFloat() {
+    forgeAndOpenDocument("<foo bar=\"12.01\"/>");
+
+    assertThat(parser.getAttributeFloatValue(0, 0.0f)).isEqualTo(12.01f);
+
+    assertThat(parser.getAttributeFloatValue(
+        attributeIndexOutOfIndex(), 0.0f)).isEqualTo(0.0f);
+
+    forgeAndOpenDocument("<foo bar=\"unexpected\"/>");
+    assertThat(parser.getAttributeFloatValue(0, 0.0f)).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void testGetAttributeListValue_IntStringArrayInt() {
+    String[] options = {"foo", "bar"};
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"bar\"/>");
+    assertThat(parser.getAttributeListValue(0, options, 0)).isEqualTo(1);
+
+    forgeAndOpenDocument("<foo xmlns:app=\"http://schemas.android.com/apk/res-auto\""
+                             + " app:bar=\"unexpected\"/>");
+    assertThat(parser.getAttributeListValue(
+        0, options, 0)).isEqualTo(0);
+
+    assertThat(parser.getAttributeListValue(
+        attributeIndexOutOfIndex(), options, 0)).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetIdAttribute() {
+    forgeAndOpenDocument("<foo/>");
+    assertThat(parser.getIdAttribute()).isEqualTo(null);
+
+    forgeAndOpenDocument("<foo id=\"bar\"/>");
+    assertThat(parser.getIdAttribute()).isEqualTo("bar");
+  }
+
+  @Test
+  public void testGetClassAttribute() {
+    forgeAndOpenDocument("<foo/>");
+    assertThat(parser.getClassAttribute()).isEqualTo(null);
+
+    forgeAndOpenDocument("<foo class=\"bar\"/>");
+    assertThat(parser.getClassAttribute()).isEqualTo("bar");
+  }
+
+  @Test
+  public void testGetIdAttributeResourceValue_defaultValue() throws Exception {
+    assertThat(parser.getIdAttributeResourceValue(12)).isEqualTo(12);
+
+    parser = context.getResources().getXml(R.xml.has_id);
+    parseUntilNext(XmlResourceParser.START_TAG);
+    assertThat(parser.getIdAttributeResourceValue(12)).isEqualTo(R.id.tacos);
+  }
+
+  @Test
+  public void testGetStyleAttribute() {
+    forgeAndOpenDocument("<foo/>");
+    assertThat(parser.getStyleAttribute()).isEqualTo(0);
+  }
+
+  @Test
+  public void getStyleAttribute_allowStyleAttrReference() throws Exception {
+    parser = context.getResources().getXml(R.xml.has_style_attribute_reference);
+    parseUntilNext(XmlResourceParser.START_TAG);
+    assertThat(parser.getStyleAttribute()).isEqualTo(R.attr.parentStyleReference);
+  }
+
+  @Test
+  public void getStyleAttribute_allowStyleAttrReferenceLackingExplicitAttrType() throws Exception {
+    parser = context.getResources().getXml(R.xml.has_parent_style_reference);
+    parseUntilNext(XmlResourceParser.START_TAG);
+    assertThat(parser.getStyleAttribute()).isEqualTo(R.attr.parentStyleReference);
+  }
+
+  @Test
+  public void getStyleAttribute_withMeaninglessString_returnsZero() {
+    forgeAndOpenDocument("<foo style=\"android:style/whatever\"/>");
+    assertThat(parser.getStyleAttribute()).isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/ActivityControllerRecreateTest.java b/robolectric/src/test/java/org/robolectric/android/controller/ActivityControllerRecreateTest.java
new file mode 100644
index 0000000..0be5db7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/ActivityControllerRecreateTest.java
@@ -0,0 +1,34 @@
+package org.robolectric.android.controller;
+
+import android.app.Activity;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+/**
+ * This test captures an issue where {@link ActivityController#recreate()} would throw an {@link
+ * UnsupportedOperationException} if an Activity from a previous test was recreated.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ActivityControllerRecreateTest {
+  private static final AtomicReference<ActivityController<Activity>> createdActivity =
+      new AtomicReference<>();
+
+  @Before
+  public void setUp() {
+    createdActivity.compareAndSet(null, Robolectric.buildActivity(Activity.class).create());
+  }
+
+  @Test
+  public void failsTryingToRecreateActivityFromOtherTest1() {
+    createdActivity.get().recreate();
+  }
+
+  @Test
+  public void failsTryingToRecreateActivityFromOtherTest2() {
+    createdActivity.get().recreate();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/ActivityControllerTest.java b/robolectric/src/test/java/org/robolectric/android/controller/ActivityControllerTest.java
new file mode 100644
index 0000000..a438f4d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/ActivityControllerTest.java
@@ -0,0 +1,669 @@
+package org.robolectric.android.controller;
+
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.ContextThemeWrapper;
+import android.view.ViewRootImpl;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.LinearLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.ShadowWindowManagerImpl;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.TestRunnable;
+
+@RunWith(AndroidJUnit4.class)
+public class ActivityControllerTest {
+  private static final List<String> transcript = new ArrayList<>();
+  private final ComponentName componentName =
+      new ComponentName("org.robolectric", MyActivity.class.getName());
+  private final ActivityController<MyActivity> controller =
+      Robolectric.buildActivity(MyActivity.class);
+
+  @Before
+  public void setUp() throws Exception {
+    transcript.clear();
+  }
+
+  @Test
+  public void canCreateActivityNotListedInManifest() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    assertThat(activity).isNotNull();
+    assertThat(activity.getThemeResId()).isEqualTo(R.style.Theme_Robolectric);
+  }
+
+  public static class TestDelayedPostActivity extends Activity {
+    TestRunnable r1 = new TestRunnable();
+    TestRunnable r2 = new TestRunnable();
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      Handler h = new Handler();
+      h.post(r1);
+      h.postDelayed(r2, 60000);
+    }
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void pendingTasks_areRunEagerly_whenActivityIsStarted_andSchedulerUnPaused() {
+    final Scheduler s = Robolectric.getForegroundThreadScheduler();
+    final long startTime = s.getCurrentTime();
+    TestDelayedPostActivity activity = Robolectric.setupActivity(TestDelayedPostActivity.class);
+    assertWithMessage("immediate task").that(activity.r1.wasRun).isTrue();
+    assertWithMessage("currentTime").that(s.getCurrentTime()).isEqualTo(startTime);
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void delayedTasks_areNotRunEagerly_whenActivityIsStarted_andSchedulerUnPaused() {
+    // Regression test for issue #1509
+    final Scheduler s = Robolectric.getForegroundThreadScheduler();
+    final long startTime = s.getCurrentTime();
+    TestDelayedPostActivity activity = Robolectric.setupActivity(TestDelayedPostActivity.class);
+    assertWithMessage("before flush").that(activity.r2.wasRun).isFalse();
+    assertWithMessage("currentTime before flush").that(s.getCurrentTime()).isEqualTo(startTime);
+    s.advanceToLastPostedRunnable();
+    assertWithMessage("after flush").that(activity.r2.wasRun).isTrue();
+    assertWithMessage("currentTime after flush")
+        .that(s.getCurrentTime())
+        .isEqualTo(startTime + 60000);
+  }
+
+  @Test
+  public void shouldSetIntent() throws Exception {
+    MyActivity myActivity = controller.create().get();
+    assertThat(myActivity.getIntent()).isNotNull();
+    assertThat(myActivity.getIntent().getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void shouldSetIntentComponentWithCustomIntentWithoutComponentSet() throws Exception {
+    MyActivity myActivity =
+        Robolectric.buildActivity(MyActivity.class, new Intent(Intent.ACTION_VIEW)).create().get();
+    assertThat(myActivity.getIntent().getAction()).isEqualTo(Intent.ACTION_VIEW);
+    assertThat(myActivity.getIntent().getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void shouldSetIntentForGivenActivityInstance() throws Exception {
+    ActivityController<MyActivity> activityController =
+        ActivityController.of(new MyActivity()).create();
+    assertThat(activityController.get().getIntent()).isNotNull();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void whenLooperIsNotPaused_shouldCreateWithMainLooperPaused() throws Exception {
+    ShadowLooper.unPauseMainLooper();
+    controller.create();
+    assertThat(shadowOf(Looper.getMainLooper()).isPaused()).isFalse();
+    assertThat(transcript).containsAtLeast("finishedOnCreate", "onCreate");
+  }
+
+  @Test
+  public void whenLooperIsAlreadyPaused_shouldCreateWithMainLooperPaused() throws Exception {
+    shadowMainLooper().pause();
+    controller.create();
+    assertThat(transcript).contains("finishedOnCreate");
+    shadowMainLooper().idle();
+    assertThat(transcript).contains("onCreate");
+  }
+
+  @Test
+  public void visible_addsTheDecorViewToTheWindowManager() {
+    controller.create().visible();
+    assertThat(controller.get().getWindow().getDecorView().getParent().getClass())
+        .isEqualTo(ViewRootImpl.class);
+  }
+
+  @Test
+  public void start_callsPerformStartWhilePaused() {
+    controller.create().start();
+    assertThat(transcript).containsAtLeast("finishedOnStart", "onStart");
+  }
+
+  @Test
+  public void stop_callsPerformStopWhilePaused() {
+    controller.create().start().stop();
+    assertThat(transcript).containsAtLeast("finishedOnStop", "onStop");
+  }
+
+  @Test
+  public void restart_callsPerformRestartWhilePaused() {
+    controller.create().start().stop().restart();
+    assertThat(transcript).containsAtLeast("finishedOnRestart", "onRestart");
+  }
+
+  @Test
+  public void pause_callsPerformPauseWhilePaused() {
+    controller.create().pause();
+    assertThat(transcript).containsAtLeast("finishedOnPause", "onPause");
+  }
+
+  @Test
+  public void resume_callsPerformResumeWhilePaused() {
+    controller.create().start().resume();
+    assertThat(transcript).containsAtLeast("finishedOnResume", "onResume");
+  }
+
+  @Test
+  public void destroy_callsPerformDestroyWhilePaused() {
+    controller.create().destroy();
+    assertThat(transcript).containsAtLeast("finishedOnDestroy", "onDestroy");
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.JELLY_BEAN_MR1)
+  public void destroy_cleansUpWindowManagerState() {
+    WindowManager windowManager = controller.get().getWindowManager();
+    ShadowWindowManagerImpl shadowWindowManager =
+        ((ShadowWindowManagerImpl) shadowOf(windowManager));
+    controller.create().start().resume().visible();
+    assertThat(shadowWindowManager.getViews())
+        .contains(controller.get().getWindow().getDecorView());
+    controller.pause().stop().destroy();
+    assertThat(shadowWindowManager.getViews())
+        .doesNotContain(controller.get().getWindow().getDecorView());
+  }
+
+  @Test
+  public void postCreate_callsOnPostCreateWhilePaused() {
+    controller.create().postCreate(new Bundle());
+    assertThat(transcript).containsAtLeast("finishedOnPostCreate", "onPostCreate");
+  }
+
+  @Test
+  public void postResume_callsOnPostResumeWhilePaused() {
+    controller.create().postResume();
+    assertThat(transcript).containsAtLeast("finishedOnPostResume", "onPostResume");
+  }
+
+  @Test
+  public void restoreInstanceState_callsPerformRestoreInstanceStateWhilePaused() {
+    controller.create().restoreInstanceState(new Bundle());
+    assertThat(transcript)
+        .containsAtLeast("finishedOnRestoreInstanceState", "onRestoreInstanceState");
+  }
+
+  @Test
+  public void newIntent_callsOnNewIntentWhilePaused() {
+    controller.create().newIntent(new Intent(Intent.ACTION_VIEW));
+    assertThat(transcript).containsAtLeast("finishedOnNewIntent", "onNewIntent");
+  }
+
+  @Test
+  public void userLeaving_callsPerformUserLeavingWhilePaused() {
+    controller.create().userLeaving();
+    assertThat(transcript).containsAtLeast("finishedOnUserLeaveHint", "onUserLeaveHint");
+  }
+
+  @Test
+  public void setup_callsLifecycleMethodsAndMakesVisible() {
+    controller.setup();
+    assertThat(transcript)
+        .containsAtLeast("onCreate", "onStart", "onPostCreate", "onResume", "onPostResume");
+    assertThat(controller.get().getWindow().getDecorView().getParent().getClass())
+        .isEqualTo(ViewRootImpl.class);
+  }
+
+  @Test
+  public void setupWithBundle_callsLifecycleMethodsAndMakesVisible() {
+    controller.setup(new Bundle());
+    assertThat(transcript)
+        .containsAtLeast(
+            "onCreate",
+            "onStart",
+            "onRestoreInstanceState",
+            "onPostCreate",
+            "onResume",
+            "onPostResume");
+    assertThat(controller.get().getWindow().getDecorView().getParent().getClass())
+        .isEqualTo(ViewRootImpl.class);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.KITKAT)
+  public void attach_shouldWorkWithAPI19() {
+    MyActivity activity = Robolectric.buildActivity(MyActivity.class).create().get();
+    assertThat(activity).isNotNull();
+  }
+
+  @Test
+  public void configurationChange_callsLifecycleMethodsAndAppliesConfig() {
+    Configuration config =
+        new Configuration(
+            ApplicationProvider.getApplicationContext().getResources().getConfiguration());
+    final float newFontScale = config.fontScale *= 2;
+
+    controller.setup();
+    transcript.clear();
+    controller.configurationChange(config);
+    assertThat(transcript)
+        .containsAtLeast(
+            "onPause",
+            "onStop",
+            "onDestroy",
+            "onCreate",
+            "onStart",
+            "onRestoreInstanceState",
+            "onPostCreate",
+            "onResume",
+            "onPostResume");
+    assertThat(controller.get().getResources().getConfiguration().fontScale)
+        .isEqualTo(newFontScale);
+  }
+
+  @Test
+  public void configurationChange_callsOnConfigurationChangedAndAppliesConfigWhenAllManaged() {
+    Configuration config =
+        new Configuration(
+            ApplicationProvider.getApplicationContext().getResources().getConfiguration());
+    final float newFontScale = config.fontScale *= 2;
+
+    ActivityController<ConfigAwareActivity> configController =
+        Robolectric.buildActivity(ConfigAwareActivity.class).setup();
+    transcript.clear();
+    configController.configurationChange(config);
+    assertThat(transcript).containsAtLeast("onConfigurationChanged", "View.onConfigurationChanged");
+    assertThat(configController.get().getResources().getConfiguration().fontScale)
+        .isEqualTo(newFontScale);
+  }
+
+  @Test
+  @Config(maxSdk = O_MR1)
+  public void configurationChange_callsLifecycleMethodsAndAppliesConfigWhenAnyNonManaged_beforeP() {
+    configurationChange_callsLifecycleMethodsAndAppliesConfigWhenAnyNonManaged(
+        "onSaveInstanceState", "onStop");
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void configurationChange_callsLifecycleMethodsAndAppliesConfigWhenAnyNonManaged_fromP() {
+    configurationChange_callsLifecycleMethodsAndAppliesConfigWhenAnyNonManaged(
+        "onStop", "onSaveInstanceState");
+  }
+
+  @Test
+  @Config(qualifiers = "sw600dp")
+  public void noArgsConfigurationChange_appliesChangedSystemConfiguration() {
+    ActivityController<ConfigAwareActivity> configController =
+        Robolectric.buildActivity(ConfigAwareActivity.class).setup();
+    RuntimeEnvironment.setQualifiers("sw800dp");
+    configController.configurationChange();
+    assertThat(configController.get().newConfig.smallestScreenWidthDp).isEqualTo(800);
+  }
+
+  @Test
+  @Config(qualifiers = "land")
+  public void configurationChange_restoresTheme() {
+    Configuration config =
+        new Configuration(
+            ApplicationProvider.getApplicationContext().getResources().getConfiguration());
+    config.orientation = Configuration.ORIENTATION_PORTRAIT;
+
+    controller.get().setTheme(android.R.style.Theme_Black);
+    controller.setup();
+    transcript.clear();
+    controller.configurationChange(config);
+    int restoredTheme = shadowOf((ContextThemeWrapper) controller.get()).callGetThemeResId();
+    assertThat(restoredTheme).isEqualTo(android.R.style.Theme_Black);
+  }
+
+  @Test
+  @Config(qualifiers = "land")
+  public void configurationChange_reattachesRetainedFragments() {
+    Configuration config =
+        new Configuration(
+            ApplicationProvider.getApplicationContext().getResources().getConfiguration());
+    config.orientation = Configuration.ORIENTATION_PORTRAIT;
+
+    ActivityController<NonConfigStateActivity> configController =
+        Robolectric.buildActivity(NonConfigStateActivity.class).setup();
+    NonConfigStateActivity activity = configController.get();
+    Fragment retainedFragment = activity.retainedFragment;
+    Fragment otherFragment = activity.nonRetainedFragment;
+    configController.configurationChange(config);
+    activity = configController.get();
+
+    assertThat(activity.retainedFragment).isNotNull();
+    assertThat(activity.retainedFragment).isSameInstanceAs(retainedFragment);
+    assertThat(activity.nonRetainedFragment).isNotNull();
+    assertThat(activity.nonRetainedFragment).isNotSameInstanceAs(otherFragment);
+  }
+
+  @Test
+  public void windowFocusChanged() {
+    controller.setup();
+    assertThat(transcript).doesNotContain("finishedOnWindowFocusChanged");
+    assertThat(controller.get().hasWindowFocus()).isFalse();
+
+    transcript.clear();
+
+    controller.windowFocusChanged(true);
+    assertThat(transcript).containsExactly("finishedOnWindowFocusChanged");
+    assertThat(controller.get().hasWindowFocus()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void onTopActivityResumedCalledWithSetup() {
+    controller.setup();
+    assertThat(transcript).contains("finishedOnTopResumedActivityChanged");
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.P)
+  public void onTopActivityResumedNotCalledWithSetupPreQ() {
+    controller.setup();
+    assertThat(transcript).doesNotContain("finishedOnTopResumedActivityChanged");
+  }
+
+  @Test
+  public void close_transitionsActivityStateToDestroyed() {
+    Robolectric.buildActivity(MyActivity.class).close();
+    assertThat(transcript).isEmpty();
+    transcript.clear();
+
+    Robolectric.buildActivity(MyActivity.class).create().close();
+    assertThat(transcript)
+        .containsExactly("onCreate", "finishedOnCreate", "onDestroy", "finishedOnDestroy");
+    transcript.clear();
+
+    Robolectric.buildActivity(MyActivity.class).create().start().close();
+    assertThat(transcript)
+        .containsExactly(
+            "onCreate",
+            "finishedOnCreate",
+            "onStart",
+            "finishedOnStart",
+            "onStop",
+            "finishedOnStop",
+            "onDestroy",
+            "finishedOnDestroy");
+    transcript.clear();
+
+    Robolectric.buildActivity(MyActivity.class).setup().close();
+    List<String> expectedStringList = new ArrayList<>();
+    expectedStringList.add("onCreate");
+    expectedStringList.add("finishedOnCreate");
+    expectedStringList.add("onStart");
+    expectedStringList.add("finishedOnStart");
+    expectedStringList.add("onPostCreate");
+    expectedStringList.add("finishedOnPostCreate");
+    expectedStringList.add("onResume");
+    expectedStringList.add("finishedOnResume");
+    expectedStringList.add("onPostResume");
+    expectedStringList.add("finishedOnPostResume");
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      expectedStringList.add("finishedOnTopResumedActivityChanged");
+    }
+    expectedStringList.add("onPause");
+    expectedStringList.add("finishedOnPause");
+    expectedStringList.add("onStop");
+    expectedStringList.add("finishedOnStop");
+    expectedStringList.add("onDestroy");
+    expectedStringList.add("finishedOnDestroy");
+    assertThat(transcript).containsExactly(expectedStringList.toArray());
+  }
+
+  @Test
+  public void close_tryWithResources_getsDestroyed() {
+    try (ActivityController<MyActivity> ignored =
+        Robolectric.buildActivity(MyActivity.class).setup()) {
+      // no-op
+    }
+    List<String> expectedStringList = new ArrayList<>();
+    expectedStringList.add("onCreate");
+    expectedStringList.add("finishedOnCreate");
+    expectedStringList.add("onStart");
+    expectedStringList.add("finishedOnStart");
+    expectedStringList.add("onPostCreate");
+    expectedStringList.add("finishedOnPostCreate");
+    expectedStringList.add("onResume");
+    expectedStringList.add("finishedOnResume");
+    expectedStringList.add("onPostResume");
+    expectedStringList.add("finishedOnPostResume");
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      expectedStringList.add("finishedOnTopResumedActivityChanged");
+    }
+    expectedStringList.add("onPause");
+    expectedStringList.add("finishedOnPause");
+    expectedStringList.add("onStop");
+    expectedStringList.add("finishedOnStop");
+    expectedStringList.add("onDestroy");
+    expectedStringList.add("finishedOnDestroy");
+    assertThat(transcript).containsExactly(expectedStringList.toArray());
+  }
+
+  private void configurationChange_callsLifecycleMethodsAndAppliesConfigWhenAnyNonManaged(
+      String secondExpected, String thirdExpected) {
+    Configuration config =
+        new Configuration(
+            ApplicationProvider.getApplicationContext().getResources().getConfiguration());
+    final float newFontScale = config.fontScale *= 2;
+    final int newOrientation = config.orientation = (config.orientation + 1) % 3;
+
+    ActivityController<ConfigAwareActivity> configController =
+        Robolectric.buildActivity(ConfigAwareActivity.class).setup();
+    transcript.clear();
+    configController.configurationChange(config);
+    assertThat(transcript)
+        .containsAtLeast(
+            "onPause",
+            secondExpected,
+            thirdExpected,
+            "onDestroy",
+            "onCreate",
+            "onStart",
+            "onResume")
+        .inOrder();
+    assertThat(configController.get().getResources().getConfiguration().fontScale)
+        .isEqualTo(newFontScale);
+    assertThat(configController.get().getResources().getConfiguration().orientation)
+        .isEqualTo(newOrientation);
+  }
+
+  public static class MyActivity extends Activity {
+    @Override
+    protected void onRestoreInstanceState(Bundle savedInstanceState) {
+      super.onRestoreInstanceState(savedInstanceState);
+      transcribeWhilePaused("onRestoreInstanceState");
+      transcript.add("finishedOnRestoreInstanceState");
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+      super.onSaveInstanceState(outState);
+      transcribeWhilePaused("onSaveInstanceState");
+      transcript.add("finishedOnSaveInstanceState");
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
+      setContentView(
+          new LinearLayout(this) {
+            @Override
+            protected void onConfigurationChanged(Configuration configuration) {
+              super.onConfigurationChanged(configuration);
+              transcribeWhilePaused("View.onConfigurationChanged");
+            }
+          });
+      transcribeWhilePaused("onCreate");
+      transcript.add("finishedOnCreate");
+    }
+
+    @Override
+    protected void onPostCreate(Bundle savedInstanceState) {
+      super.onPostCreate(savedInstanceState);
+      transcribeWhilePaused("onPostCreate");
+      transcript.add("finishedOnPostCreate");
+    }
+
+    @Override
+    protected void onPostResume() {
+      super.onPostResume();
+      transcribeWhilePaused("onPostResume");
+      transcript.add("finishedOnPostResume");
+    }
+
+    @Override
+    protected void onDestroy() {
+      super.onDestroy();
+      transcribeWhilePaused("onDestroy");
+      transcript.add("finishedOnDestroy");
+    }
+
+    @Override
+    protected void onStart() {
+      super.onStart();
+      transcribeWhilePaused("onStart");
+      transcript.add("finishedOnStart");
+    }
+
+    @Override
+    protected void onStop() {
+      super.onStop();
+      transcribeWhilePaused("onStop");
+      transcript.add("finishedOnStop");
+    }
+
+    @Override
+    protected void onResume() {
+      super.onResume();
+      transcribeWhilePaused("onResume");
+      transcript.add("finishedOnResume");
+    }
+
+    @Override
+    protected void onRestart() {
+      super.onRestart();
+      transcribeWhilePaused("onRestart");
+      transcript.add("finishedOnRestart");
+    }
+
+    @Override
+    protected void onPause() {
+      super.onPause();
+      transcribeWhilePaused("onPause");
+      transcript.add("finishedOnPause");
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+      super.onNewIntent(intent);
+      transcribeWhilePaused("onNewIntent");
+      transcript.add("finishedOnNewIntent");
+    }
+
+    @Override
+    protected void onUserLeaveHint() {
+      super.onUserLeaveHint();
+      transcribeWhilePaused("onUserLeaveHint");
+      transcript.add("finishedOnUserLeaveHint");
+    }
+
+    @Override
+    public void onConfigurationChanged(Configuration newConfig) {
+      super.onConfigurationChanged(newConfig);
+      transcribeWhilePaused("onConfigurationChanged");
+      transcript.add("finishedOnConfigurationChanged");
+    }
+
+    @Override
+    public void onWindowFocusChanged(boolean newFocus) {
+      super.onWindowFocusChanged(newFocus);
+      transcript.add("finishedOnWindowFocusChanged");
+    }
+
+    @Override
+    public void onTopResumedActivityChanged(boolean isTopResumedActivity) {
+      super.onTopResumedActivityChanged(isTopResumedActivity);
+      transcript.add("finishedOnTopResumedActivityChanged");
+    }
+
+    private void transcribeWhilePaused(final String event) {
+      runOnUiThread(() -> transcript.add(event));
+    }
+  }
+
+  public static class ConfigAwareActivity extends MyActivity {
+
+    Configuration newConfig;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      if (savedInstanceState != null) {
+        assertThat(savedInstanceState.getSerializable("test")).isNotNull();
+      }
+    }
+
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+      super.onSaveInstanceState(outState);
+      outState.putSerializable("test", new Exception());
+    }
+
+    @Override
+    public void onConfigurationChanged(Configuration newConfig) {
+      this.newConfig = new Configuration(newConfig);
+      super.onConfigurationChanged(newConfig);
+    }
+  }
+
+  public static final class NonConfigStateActivity extends Activity {
+    Fragment retainedFragment;
+    Fragment nonRetainedFragment;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      if (savedInstanceState == null) {
+        retainedFragment = new Fragment();
+        retainedFragment.setRetainInstance(true);
+        nonRetainedFragment = new Fragment();
+        getFragmentManager()
+            .beginTransaction()
+            .add(android.R.id.content, retainedFragment, "retained")
+            .add(android.R.id.content, nonRetainedFragment, "non-retained")
+            .commit();
+      } else {
+        retainedFragment = getFragmentManager().findFragmentByTag("retained");
+        nonRetainedFragment = getFragmentManager().findFragmentByTag("non-retained");
+      }
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/BackupAgentControllerTest.java b/robolectric/src/test/java/org/robolectric/android/controller/BackupAgentControllerTest.java
new file mode 100644
index 0000000..6b90ea9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/BackupAgentControllerTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.android.controller;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.app.backup.BackupAgent;
+import android.app.backup.BackupDataInput;
+import android.app.backup.BackupDataOutput;
+import android.os.ParcelFileDescriptor;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class BackupAgentControllerTest {
+  private final BackupAgentController<MyBackupAgent> backupAgentController = Robolectric.buildBackupAgent(MyBackupAgent.class);
+
+  @Test
+  public void shouldSetBaseContext() throws Exception {
+    MyBackupAgent myBackupAgent = backupAgentController.get();
+    assertThat(myBackupAgent.getBaseContext())
+        .isEqualTo(((Application) ApplicationProvider.getApplicationContext()).getBaseContext());
+  }
+
+  public static class MyBackupAgent extends BackupAgent {
+    @Override
+    public void onBackup(ParcelFileDescriptor parcelFileDescriptor, BackupDataOutput backupDataOutput, ParcelFileDescriptor parcelFileDescriptor1) throws IOException {
+      // no op
+    }
+
+    @Override
+    public void onRestore(BackupDataInput backupDataInput, int i, ParcelFileDescriptor parcelFileDescriptor) throws IOException {
+      // no op
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/ContentProviderControllerTest.java b/robolectric/src/test/java/org/robolectric/android/controller/ContentProviderControllerTest.java
new file mode 100644
index 0000000..9cdff63
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/ContentProviderControllerTest.java
@@ -0,0 +1,160 @@
+package org.robolectric.android.controller;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.Application;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.pm.PathPermission;
+import android.content.pm.ProviderInfo;
+import android.net.Uri;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.testing.TestContentProvider1;
+import org.robolectric.shadows.testing.TestContentProvider3And4;
+
+@RunWith(AndroidJUnit4.class)
+public class ContentProviderControllerTest {
+  private final ContentProviderController<TestContentProvider1> controller =
+      Robolectric.buildContentProvider(TestContentProvider1.class);
+
+  private ContentResolver contentResolver;
+
+  @Before
+  public void setUp() throws Exception {
+    contentResolver = ApplicationProvider.getApplicationContext().getContentResolver();
+  }
+
+  @Test
+  public void shouldSetBaseContext() throws Exception {
+    TestContentProvider1 myContentProvider = controller.create().get();
+    assertThat(myContentProvider.getContext())
+        .isEqualTo(((Application) ApplicationProvider.getApplicationContext()).getBaseContext());
+  }
+
+  @Test
+  public void shouldInitializeFromManifestProviderInfo() throws Exception {
+    TestContentProvider1 myContentProvider = controller.create().get();
+    assertThat(myContentProvider.getReadPermission()).isEqualTo("READ_PERMISSION");
+    assertThat(myContentProvider.getWritePermission()).isEqualTo("WRITE_PERMISSION");
+
+    assertThat(myContentProvider.getPathPermissions()).hasLength(1);
+    PathPermission pathPermission = myContentProvider.getPathPermissions()[0];
+    assertThat(pathPermission.getPath()).isEqualTo("/path/*");
+    assertThat(pathPermission.getType()).isEqualTo(PathPermission.PATTERN_SIMPLE_GLOB);
+    assertThat(pathPermission.getReadPermission()).isEqualTo("PATH_READ_PERMISSION");
+    assertThat(pathPermission.getWritePermission()).isEqualTo("PATH_WRITE_PERMISSION");
+  }
+
+  @Test
+  public void shouldRegisterWithContentResolver() throws Exception {
+    controller.create().get();
+
+    ContentProviderClient client =
+        contentResolver.acquireContentProviderClient("org.robolectric.authority1");
+    client.query(Uri.parse("something"), new String[] {"title"}, "*", new String[] {}, "created");
+    assertThat(controller.get().transcript).containsExactly("onCreate", "query for something");
+    close(client);
+  }
+
+  @Test
+  public void shouldResolveProvidersWithMultipleAuthorities() throws Exception {
+    TestContentProvider3And4 contentProvider =
+        Robolectric.buildContentProvider(TestContentProvider3And4.class).create().get();
+
+    ContentProviderClient client =
+        contentResolver.acquireContentProviderClient("org.robolectric.authority3");
+    client.query(Uri.parse("something"), new String[] {"title"}, "*", new String[] {}, "created");
+    assertThat(contentProvider.transcript).containsExactly("onCreate", "query for something");
+    close(client);
+  }
+
+  @Test
+  public void whenNoProviderManifestEntryFound_shouldStillInitialize() throws Exception {
+    TestContentProvider1 myContentProvider = Robolectric.buildContentProvider(NotInManifestContentProvider.class).create().get();
+    assertThat(myContentProvider.getReadPermission()).isNull();
+    assertThat(myContentProvider.getWritePermission()).isNull();
+    assertThat(myContentProvider.getPathPermissions()).isNull();
+  }
+
+  @Test
+  public void create_shouldCallOnCreate() throws Exception {
+    TestContentProvider1 myContentProvider = controller.create().get();
+    assertThat(myContentProvider.transcript).containsExactly("onCreate");
+  }
+
+  @Test
+  public void shutdown_shouldCallShutdown() throws Exception {
+    TestContentProvider1 myContentProvider = controller.shutdown().get();
+    assertThat(myContentProvider.transcript).containsExactly("shutdown");
+  }
+
+  @Test
+  public void withoutManifest_shouldRegisterWithContentResolver() throws Exception {
+    ProviderInfo providerInfo = new ProviderInfo();
+    providerInfo.authority = "some-authority";
+    controller.create(providerInfo);
+
+    ContentProviderClient client =
+        contentResolver.acquireContentProviderClient(providerInfo.authority);
+    client.query(Uri.parse("something"), new String[] {"title"}, "*", new String[] {}, "created");
+    assertThat(controller.get().transcript).containsExactly("onCreate", "query for something");
+    close(client);
+  }
+
+  @Test
+  public void contentProviderShouldBeCreatedBeforeBeingRegistered() throws Exception {
+    XContentProvider xContentProvider =
+        Robolectric.setupContentProvider(XContentProvider.class, "x-authority");
+    assertThat(xContentProvider.transcript).containsExactly("x-authority not registered yet");
+    ContentProviderClient contentProviderClient =
+        contentResolver.acquireContentProviderClient("x-authority");
+    assertThat(contentProviderClient.getLocalContentProvider()).isSameInstanceAs(xContentProvider);
+    close(contentProviderClient);
+  }
+
+  @Test
+  public void createContentProvider_nullAuthority() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            Robolectric.buildContentProvider(XContentProvider.class)
+                .create(new ProviderInfo())
+                .get());
+  }
+
+  static class XContentProvider extends TestContentProvider1 {
+    @Override
+    public boolean onCreate() {
+      ContentProviderClient contentProviderClient =
+          ApplicationProvider.getApplicationContext()
+              .getContentResolver()
+              .acquireContentProviderClient("x-authority");
+      transcript.add(
+          contentProviderClient == null
+              ? "x-authority" + " not registered" + " yet"
+              : "x-authority" + " is registered");
+      if (contentProviderClient != null) {
+        close(contentProviderClient);
+      }
+      return false;
+    }
+  }
+
+  static class NotInManifestContentProvider extends TestContentProvider1 {}
+
+  private static void close(ContentProviderClient client) {
+    if (RuntimeEnvironment.getApiLevel() > Build.VERSION_CODES.M) {
+      client.close();
+    } else {
+      client.release();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/FragmentControllerTest.java b/robolectric/src/test/java/org/robolectric/android/controller/FragmentControllerTest.java
new file mode 100644
index 0000000..e190b25
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/FragmentControllerTest.java
@@ -0,0 +1,225 @@
+package org.robolectric.android.controller;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class FragmentControllerTest {
+
+  private static final int VIEW_ID_CUSTOMIZED_LOGIN_ACTIVITY = 123;
+
+  @Test
+  public void initialNotAttached() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment);
+
+    assertThat(fragment.getView()).isNull();
+    assertThat(fragment.getActivity()).isNull();
+    assertThat(fragment.isAdded()).isFalse();
+  }
+
+  @Test
+  public void initialNotAttached_customActivity() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment, LoginActivity.class);
+
+    assertThat(fragment.getView()).isNull();
+    assertThat(fragment.getActivity()).isNull();
+    assertThat(fragment.isAdded()).isFalse();
+  }
+
+  @Test
+  public void attachedAfterCreate() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment).create();
+
+    shadowMainLooper().idle();
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat(fragment.isAdded()).isTrue();
+    assertThat(fragment.isResumed()).isFalse();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+  }
+
+  @Test
+  public void attachedAfterCreate_customizedViewId() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment, CustomizedViewIdLoginActivity.class).create(VIEW_ID_CUSTOMIZED_LOGIN_ACTIVITY, null);
+    shadowMainLooper().idle();
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat(fragment.isAdded()).isTrue();
+    assertThat(fragment.isResumed()).isFalse();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+  }
+
+  @Test
+  public void attachedAfterCreate_customActivity() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment, LoginActivity.class).create();
+    shadowMainLooper().idle();
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat(fragment.getActivity()).isInstanceOf(LoginActivity.class);
+    assertThat(fragment.isAdded()).isTrue();
+    assertThat(fragment.isResumed()).isFalse();
+    assertThat((TextView) fragment.getView().findViewById(R.id.tacos)).isNotNull();
+  }
+
+  @Test
+  public void isResumed() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment, LoginActivity.class).create().start().resume();
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat(fragment.isAdded()).isTrue();
+    assertThat(fragment.isResumed()).isTrue();
+  }
+
+  @Test
+  public void isPaused() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment, LoginActivity.class).create().start().resume().pause();
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat(fragment.isAdded()).isTrue();
+    assertThat(fragment.isResumed()).isFalse();
+
+    assertThat(fragment.resumeCalled).isTrue();
+    assertThat(fragment.pauseCalled).isTrue();
+  }
+
+  @Test
+  public void isStopped() {
+    final LoginFragment fragment = new LoginFragment();
+    FragmentController.of(fragment, LoginActivity.class).create().start().resume().pause().stop();
+
+    assertThat(fragment.getView()).isNotNull();
+    assertThat(fragment.getActivity()).isNotNull();
+    assertThat(fragment.isAdded()).isTrue();
+    assertThat(fragment.isResumed()).isFalse();
+
+    assertThat(fragment.startCalled).isTrue();
+    assertThat(fragment.resumeCalled).isTrue();
+    assertThat(fragment.pauseCalled).isTrue();
+    assertThat(fragment.stopCalled).isTrue();
+  }
+
+  @Test
+  public void withIntent() {
+    final LoginFragment fragment = new LoginFragment();
+
+    Intent intent = new Intent("test_action");
+    intent.putExtra("test_key", "test_value");
+    FragmentController<LoginFragment> controller = FragmentController.of(fragment, LoginActivity.class, intent).create();
+    shadowMainLooper().idle();
+
+    Intent intentInFragment = controller.get().getActivity().getIntent();
+    assertThat(intentInFragment.getAction()).isEqualTo("test_action");
+    assertThat(intentInFragment.getExtras().getString("test_key")).isEqualTo("test_value");
+  }
+
+  @Test
+  public void withArguments() {
+    final LoginFragment fragment = new LoginFragment();
+
+    Bundle arguments = new Bundle();
+    arguments.putString("test_argument", "test_value");
+    FragmentController<LoginFragment> controller = FragmentController.of(fragment, LoginActivity.class, arguments).create();
+
+    Bundle argumentsInFragment = controller.get().getArguments();
+    assertThat(argumentsInFragment.getString("test_argument")).isEqualTo("test_value");
+  }
+
+  @Test
+  public void visible() {
+    final LoginFragment fragment = new LoginFragment();
+    final FragmentController<LoginFragment> controller = FragmentController.of(fragment, LoginActivity.class);
+
+    controller.create();
+    shadowMainLooper().idle();
+    assertThat(controller.get().getView()).isNotNull();
+    controller.start().resume();
+    assertThat(fragment.isVisible()).isFalse();
+
+    controller.visible();
+    assertThat(fragment.isVisible()).isTrue();
+  }
+
+  public static class LoginFragment extends Fragment {
+
+    boolean resumeCalled = false;
+    boolean pauseCalled = false;
+    boolean startCalled = false;
+    boolean stopCalled = false;
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+      return inflater.inflate(R.layout.fragment_contents, container, false);
+    }
+
+    @Override
+    public void onResume() {
+      super.onResume();
+      resumeCalled = true;
+    }
+
+    @Override
+    public void onPause() {
+      super.onPause();
+      pauseCalled = true;
+    }
+
+    @Override
+    public void onStart() {
+      super.onStart();
+      startCalled = true;
+    }
+
+    @Override
+    public void onStop() {
+      super.onStop();
+      stopCalled = true;
+    }
+  }
+
+  private static class LoginActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      LinearLayout view = new LinearLayout(this);
+      view.setId(1);
+
+      setContentView(view);
+    }
+  }
+
+  private static class CustomizedViewIdLoginActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      LinearLayout view = new LinearLayout(this);
+      view.setId(VIEW_ID_CUSTOMIZED_LOGIN_ACTIVITY);
+
+      setContentView(view);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/IntentServiceControllerTest.java b/robolectric/src/test/java/org/robolectric/android/controller/IntentServiceControllerTest.java
new file mode 100644
index 0000000..04820ac
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/IntentServiceControllerTest.java
@@ -0,0 +1,184 @@
+package org.robolectric.android.controller;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.IntentService;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.ShadowLooper;
+
+@RunWith(AndroidJUnit4.class)
+public class IntentServiceControllerTest {
+  private static final List<String> transcript = new ArrayList<>();
+  private final ComponentName componentName =
+      new ComponentName("org.robolectric", MyService.class.getName());
+  private final IntentServiceController<MyService> controller =
+      Robolectric.buildIntentService(MyService.class, new Intent());
+
+  @Before
+  public void setUp() throws Exception {
+    transcript.clear();
+  }
+
+  @Test
+  public void onBindShouldSetIntent() throws Exception {
+    MyService myService = controller.create().bind().get();
+    assertThat(myService.boundIntent).isNotNull();
+    assertThat(myService.boundIntent.getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void onStartCommandShouldSetIntent() throws Exception {
+    MyService myService = controller.create().startCommand(3, 4).get();
+    assertThat(myService.startIntent).isNotNull();
+    assertThat(myService.startIntent.getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void onBindShouldSetIntentComponentWithCustomIntentWithoutComponentSet() throws Exception {
+    MyService myService =
+        Robolectric.buildIntentService(MyService.class, new Intent(Intent.ACTION_VIEW))
+            .bind()
+            .get();
+    assertThat(myService.boundIntent.getAction()).isEqualTo(Intent.ACTION_VIEW);
+    assertThat(myService.boundIntent.getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void shouldSetIntentForGivenServiceInstance() throws Exception {
+    IntentServiceController<MyService> intentServiceController =
+        IntentServiceController.of(new MyService(), null).bind();
+    assertThat(intentServiceController.get().boundIntent).isNotNull();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void whenLooperIsNotPaused_shouldCreateWithMainLooperPaused() throws Exception {
+    ShadowLooper.unPauseMainLooper();
+    controller.create();
+    assertThat(shadowOf(Looper.getMainLooper()).isPaused()).isFalse();
+    assertThat(transcript).containsExactly("finishedOnCreate", "onCreate");
+  }
+
+  @Test
+  public void whenLooperIsAlreadyPaused_shouldCreateWithMainLooperPaused() throws Exception {
+    shadowMainLooper().pause();
+    controller.create();
+    assertThat(transcript).contains("finishedOnCreate");
+
+    shadowMainLooper().idle();
+    assertThat(transcript).contains("onCreate");
+  }
+
+  @Test
+  public void unbind_callsUnbindWhilePaused() {
+    controller.create().bind().unbind();
+    assertThat(transcript).containsAtLeast("finishedOnUnbind", "onUnbind");
+  }
+
+  @Test
+  public void rebind_callsRebindWhilePaused() {
+    controller.create().bind().unbind().bind().rebind();
+    assertThat(transcript).containsAtLeast("finishedOnRebind", "onRebind");
+  }
+
+  @Test
+  public void destroy_callsOnDestroyWhilePaused() {
+    controller.create().destroy();
+    assertThat(transcript).containsAtLeast("finishedOnDestroy", "onDestroy");
+  }
+
+  @Test
+  public void bind_callsOnBindWhilePaused() {
+    controller.create().bind();
+    assertThat(transcript).containsAtLeast("finishedOnBind", "onBind");
+  }
+
+  @Test
+  public void startCommand_callsOnHandleIntentWhilePaused() {
+    controller.create().startCommand(1, 2);
+    assertThat(transcript).containsAtLeast("finishedOnHandleIntent", "onHandleIntent");
+  }
+
+  public static class MyService extends IntentService {
+    private Handler handler = new Handler(Looper.getMainLooper());
+
+    public Intent boundIntent;
+
+    public Intent reboundIntent;
+    public Intent startIntent;
+
+    public Intent unboundIntent;
+
+    public MyService() {
+      super("ThreadName");
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+      boundIntent = intent;
+      transcribeWhilePaused("onBind");
+      transcript.add("finishedOnBind");
+      return null;
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+      startIntent = intent;
+      transcribeWhilePaused("onHandleIntent");
+      transcript.add("finishedOnHandleIntent");
+    }
+
+    @Override
+    public void onCreate() {
+      super.onCreate();
+      transcribeWhilePaused("onCreate");
+      transcript.add("finishedOnCreate");
+    }
+
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+      transcribeWhilePaused("onDestroy");
+      transcript.add("finishedOnDestroy");
+    }
+
+    @Override
+    public void onRebind(Intent intent) {
+      reboundIntent = intent;
+      transcribeWhilePaused("onRebind");
+      transcript.add("finishedOnRebind");
+    }
+
+    @Override
+    public boolean onUnbind(Intent intent) {
+      unboundIntent = intent;
+      transcribeWhilePaused("onUnbind");
+      transcript.add("finishedOnUnbind");
+      return false;
+    }
+
+    private void transcribeWhilePaused(final String event) {
+      runOnUiThread(() -> transcript.add(event));
+    }
+
+    private void runOnUiThread(Runnable action) {
+      // This is meant to emulate the behavior of Activity.runOnUiThread();
+      handler.post(action);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/controller/ServiceControllerTest.java b/robolectric/src/test/java/org/robolectric/android/controller/ServiceControllerTest.java
new file mode 100644
index 0000000..a9fd2f9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/controller/ServiceControllerTest.java
@@ -0,0 +1,185 @@
+package org.robolectric.android.controller;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.ShadowLooper;
+
+@RunWith(AndroidJUnit4.class)
+public class ServiceControllerTest {
+  private static final List<String> transcript = new ArrayList<>();
+  private final ComponentName componentName =
+      new ComponentName("org.robolectric", MyService.class.getName());
+  private final ServiceController<MyService> controller = Robolectric.buildService(MyService.class);
+
+  @Before
+  public void setUp() throws Exception {
+    transcript.clear();
+  }
+
+  @Test
+  public void onBindShouldSetIntent() throws Exception {
+    MyService myService = controller.create().bind().get();
+    assertThat(myService.boundIntent).isNotNull();
+    assertThat(myService.boundIntent.getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void onStartCommandShouldSetIntentAndFlags() throws Exception {
+    MyService myService = controller.create().startCommand(3, 4).get();
+    assertThat(myService.startIntent).isNotNull();
+    assertThat(myService.startIntent.getComponent()).isEqualTo(componentName);
+    assertThat(myService.startFlags).isEqualTo(3);
+    assertThat(myService.startId).isEqualTo(4);
+  }
+
+  @Test
+  public void onBindShouldSetIntentComponentWithCustomIntentWithoutComponentSet() throws Exception {
+    MyService myService =
+        Robolectric.buildService(MyService.class, new Intent(Intent.ACTION_VIEW)).bind().get();
+    assertThat(myService.boundIntent.getAction()).isEqualTo(Intent.ACTION_VIEW);
+    assertThat(myService.boundIntent.getComponent()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void shouldSetIntentForGivenServiceInstance() throws Exception {
+    ServiceController<MyService> serviceController =
+        ServiceController.of(new MyService(), null).bind();
+    assertThat(serviceController.get().boundIntent).isNotNull();
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void whenLooperIsNotPaused_shouldCreateWithMainLooperPaused() throws Exception {
+    ShadowLooper.unPauseMainLooper();
+    controller.create();
+    assertThat(shadowOf(Looper.getMainLooper()).isPaused()).isFalse();
+    assertThat(transcript).containsExactly("finishedOnCreate", "onCreate");
+  }
+
+  @Test
+  public void whenLooperIsAlreadyPaused_shouldCreateWithMainLooperPaused() throws Exception {
+    shadowMainLooper().pause();
+    controller.create();
+    assertThat(transcript).contains("finishedOnCreate");
+
+    shadowMainLooper().idle();
+    assertThat(transcript).contains("onCreate");
+  }
+
+  @Test
+  public void unbind_callsUnbindWhilePaused() {
+    controller.create().bind().unbind();
+    assertThat(transcript).containsAtLeast("finishedOnUnbind", "onUnbind");
+  }
+
+  @Test
+  public void rebind_callsRebindWhilePaused() {
+    controller.create().bind().unbind().bind().rebind();
+    assertThat(transcript).containsAtLeast("finishedOnRebind", "onRebind");
+  }
+
+  @Test
+  public void destroy_callsOnDestroyWhilePaused() {
+    controller.create().destroy();
+    assertThat(transcript).containsAtLeast("finishedOnDestroy", "onDestroy");
+  }
+
+  @Test
+  public void bind_callsOnBindWhilePaused() {
+    controller.create().bind();
+    assertThat(transcript).containsAtLeast("finishedOnBind", "onBind");
+  }
+
+  @Test
+  public void startCommand_callsOnStartCommandWhilePaused() {
+    controller.create().startCommand(1, 2);
+    assertThat(transcript).containsAtLeast("finishedOnStartCommand", "onStartCommand");
+  }
+
+  public static class MyService extends Service {
+
+    private Handler handler = new Handler(Looper.getMainLooper());
+
+    public Intent boundIntent;
+
+    public Intent reboundIntent;
+    public Intent startIntent;
+    public int startFlags;
+    public int startId;
+
+    public Intent unboundIntent;
+
+    @Override
+    public IBinder onBind(Intent intent) {
+      boundIntent = intent;
+      transcribeWhilePaused("onBind");
+      transcript.add("finishedOnBind");
+      return null;
+    }
+
+    @Override
+    public void onCreate() {
+      super.onCreate();
+      transcribeWhilePaused("onCreate");
+      transcript.add("finishedOnCreate");
+    }
+
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+      transcribeWhilePaused("onDestroy");
+      transcript.add("finishedOnDestroy");
+    }
+
+    @Override
+    public void onRebind(Intent intent) {
+      reboundIntent = intent;
+      transcribeWhilePaused("onRebind");
+      transcript.add("finishedOnRebind");
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+      startIntent = intent;
+      startFlags = flags;
+      this.startId = startId;
+      transcribeWhilePaused("onStartCommand");
+      transcript.add("finishedOnStartCommand");
+      return START_STICKY;
+    }
+
+    @Override
+    public boolean onUnbind(Intent intent) {
+      unboundIntent = intent;
+      transcribeWhilePaused("onUnbind");
+      transcript.add("finishedOnUnbind");
+      return false;
+    }
+
+    private void transcribeWhilePaused(final String event) {
+      runOnUiThread(() -> transcript.add(event));
+    }
+
+    private void runOnUiThread(Runnable action) {
+      // This is meant to emulate the behavior of Activity.runOnUiThread();
+      handler.post(action);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentCreateApplicationTest.java b/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentCreateApplicationTest.java
new file mode 100644
index 0000000..6edc428
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentCreateApplicationTest.java
@@ -0,0 +1,193 @@
+package org.robolectric.android.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.android.internal.AndroidTestEnvironment.registerBroadcastReceivers;
+
+import android.app.Application;
+import android.content.pm.ApplicationInfo;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.Files;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.FakeApp;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.TestFakeApp;
+import org.robolectric.annotation.Config;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.testing.TestApplication;
+
+@RunWith(AndroidJUnit4.class)
+public class AndroidTestEnvironmentCreateApplicationTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void shouldThrowWhenManifestContainsBadApplicationClassName() throws Exception {
+    assertThrows(
+        RuntimeException.class,
+        () ->
+            AndroidTestEnvironment.createApplication(
+                newConfigWith(
+                    "<application android:name=\"org.robolectric.BogusTestApplication\"/>)"),
+                null,
+                null));
+  }
+
+  @Test
+  public void shouldReturnDefaultAndroidApplicationWhenManifestDeclaresNoAppName()
+      throws Exception {
+    Application application = AndroidTestEnvironment.createApplication(newConfigWith(""), null,
+        new ApplicationInfo());
+    assertThat(application.getClass()).isEqualTo(Application.class);
+  }
+
+  @Test
+  public void shouldReturnSpecifiedApplicationWhenManifestDeclaresAppName() throws Exception {
+    Application application =
+        AndroidTestEnvironment.createApplication(
+            newConfigWith(
+                "<application android:name=\"org.robolectric.shadows.testing.TestApplication\"/>"),
+            null, null);
+    assertThat(application.getClass()).isEqualTo(TestApplication.class);
+  }
+
+  @Test
+  public void shouldAssignThePackageNameFromTheManifest() {
+    Application application = ApplicationProvider.getApplicationContext();
+
+    assertThat(application.getPackageName()).isEqualTo("org.robolectric");
+    assertThat(application.getClass()).isEqualTo(TestApplication.class);
+  }
+
+  @Test
+  @SuppressWarnings("RobolectricSystemContext") // preexisting when check was enabled
+  public void shouldRegisterReceiversFromTheManifest() throws Exception {
+    // gross:
+    shadowOf((Application) ApplicationProvider.getApplicationContext()).clearRegisteredReceivers();
+
+    AndroidManifest appManifest =
+        newConfigWith(
+            "<application>"
+                + "    <receiver android:name=\"org.robolectric.fakes.ConfigTestReceiver\">"
+                + "      <intent-filter>\n"
+                + "        <action android:name=\"org.robolectric.ACTION_SUPERSET_PACKAGE\"/>\n"
+                + "      </intent-filter>"
+                + "    </receiver>"
+                + "</application>");
+    Application application = AndroidTestEnvironment.createApplication(appManifest, null,
+        new ApplicationInfo());
+    shadowOf(application).callAttach(RuntimeEnvironment.systemContext);
+    registerBroadcastReceivers(application, appManifest);
+
+    List<ShadowApplication.Wrapper> receivers = shadowOf(application).getRegisteredReceivers();
+    assertThat(receivers).hasSize(1);
+    assertThat(receivers.get(0).intentFilter.matchAction("org.robolectric.ACTION_SUPERSET_PACKAGE"))
+        .isTrue();
+  }
+
+  @Test
+  public void shouldDoTestApplicationNameTransform() {
+    assertThat(AndroidTestEnvironment.getTestApplicationName(".Applicationz"))
+        .isEqualTo(".TestApplicationz");
+    assertThat(AndroidTestEnvironment.getTestApplicationName("Applicationz"))
+        .isEqualTo("TestApplicationz");
+    assertThat(AndroidTestEnvironment.getTestApplicationName("com.foo.Applicationz"))
+        .isEqualTo("com.foo.TestApplicationz");
+  }
+
+  @Test
+  public void shouldLoadConfigApplicationIfSpecified() throws Exception {
+    Application application =
+        AndroidTestEnvironment.createApplication(
+            newConfigWith("<application android:name=\"" + "ClassNameToIgnore" + "\"/>"),
+            new Config.Builder().setApplication(TestFakeApp.class).build(), null);
+    assertThat(application.getClass()).isEqualTo(TestFakeApp.class);
+  }
+
+  @Test
+  public void shouldLoadConfigInnerClassApplication() throws Exception {
+    Application application =
+        AndroidTestEnvironment.createApplication(
+            newConfigWith("<application android:name=\"" + "ClassNameToIgnore" + "\"/>"),
+            new Config.Builder().setApplication(TestFakeAppInner.class).build(), null);
+    assertThat(application.getClass()).isEqualTo(TestFakeAppInner.class);
+  }
+
+  @Test
+  public void shouldLoadTestApplicationIfClassIsPresent() throws Exception {
+    Application application =
+        AndroidTestEnvironment.createApplication(
+            newConfigWith("<application android:name=\"" + FakeApp.class.getName() + "\"/>"),
+            null, null);
+    assertThat(application.getClass()).isEqualTo(TestFakeApp.class);
+  }
+
+  @Test
+  public void shouldLoadPackageApplicationIfClassIsPresent() {
+    final ApplicationInfo applicationInfo = new ApplicationInfo();
+    applicationInfo.className = TestApplication.class.getCanonicalName();
+    Application application = AndroidTestEnvironment.createApplication(null, null, applicationInfo);
+    assertThat(application.getClass()).isEqualTo(TestApplication.class);
+  }
+
+  @Test
+  public void shouldLoadTestPackageApplicationIfClassIsPresent() {
+    final ApplicationInfo applicationInfo = new ApplicationInfo();
+    applicationInfo.className = FakeApp.class.getCanonicalName();
+    Application application = AndroidTestEnvironment.createApplication(null, null, applicationInfo);
+    assertThat(application.getClass()).isEqualTo(TestFakeApp.class);
+  }
+
+  @Test
+  public void shouldThrowWhenPackageContainsBadApplicationClassName() {
+    try {
+      final ApplicationInfo applicationInfo = new ApplicationInfo();
+      applicationInfo.className = "org.robolectric.BogusTestApplication";
+      AndroidTestEnvironment.createApplication(null, null, applicationInfo);
+      fail();
+    } catch (RuntimeException expected) { }
+  }
+
+  @Test
+  public void whenNoAppManifestPresent_shouldCreateGenericApplication() {
+    Application application = AndroidTestEnvironment.createApplication(null, null,
+        new ApplicationInfo());
+    assertThat(application.getClass()).isEqualTo(Application.class);
+  }
+
+  /////////////////////////////
+
+  public AndroidManifest newConfigWith(String contents) throws IOException {
+    return newConfigWith("org.robolectric", contents);
+  }
+
+  private AndroidManifest newConfigWith(String packageName, String contents) throws IOException {
+    String fileContents =
+        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+            + "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n"
+            + "          package=\""
+            + packageName
+            + "\">\n"
+            + "    "
+            + contents
+            + "\n"
+            + "</manifest>\n";
+    File f = temporaryFolder.newFile("whatever.xml");
+
+    Files.asCharSink(f, UTF_8).write(fileContents);
+    return new AndroidManifest(f.toPath(), null, null);
+  }
+
+  public static class TestFakeAppInner extends Application {}
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentLocaleResetTest.java b/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentLocaleResetTest.java
new file mode 100644
index 0000000..3fca721
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentLocaleResetTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.android.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/**
+ * Tests for {@link org.robolectric.android.internal.AndroidTestEnvironment} that verifies the
+ * Locale is reset after the test suite has completed.
+ */
+@RunWith(AndroidJUnit4.class)
+public class AndroidTestEnvironmentLocaleResetTest {
+
+  private static Locale initialLocale;
+
+  @BeforeClass
+  public static void beforeClass() {
+    initialLocale = Locale.getDefault();
+  }
+
+  @AfterClass
+  public static void afterClass() {
+    assertThat(Locale.getDefault()).isEqualTo(initialLocale);
+  }
+
+  @Config(qualifiers = "ar-rEG")
+  @Test
+  public void locale_changed() {}
+
+  @Test
+  public void locale_changed_byTestCode() {
+    Locale.setDefault(Locale.forLanguageTag("ar-EG"));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentTest.java b/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentTest.java
new file mode 100644
index 0000000..ddc3acc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/internal/AndroidTestEnvironmentTest.java
@@ -0,0 +1,333 @@
+package org.robolectric.android.internal;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+import static org.robolectric.annotation.ConscryptMode.Mode.OFF;
+import static org.robolectric.annotation.ConscryptMode.Mode.ON;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.app.Application;
+import android.content.pm.ApplicationInfo;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import androidx.test.core.app.ApplicationProvider;
+import java.io.File;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.crypto.Cipher;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.BootstrapDeferringRobolectricTestRunner;
+import org.robolectric.BootstrapDeferringRobolectricTestRunner.BootstrapWrapperI;
+import org.robolectric.BootstrapDeferringRobolectricTestRunner.RoboInject;
+import org.robolectric.RoboSettings;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.DeviceConfig;
+import org.robolectric.android.DeviceConfig.ScreenSize;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.ConscryptMode;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.manifest.RoboNotFoundException;
+import org.robolectric.plugins.HierarchicalConfigurationStrategy.ConfigurationImpl;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowLooper;
+
+@RunWith(BootstrapDeferringRobolectricTestRunner.class)
+@LooperMode(LEGACY)
+public class AndroidTestEnvironmentTest {
+
+  @RoboInject BootstrapWrapperI bootstrapWrapper;
+
+  @Test
+  public void setUpApplicationState_configuresGlobalScheduler() {
+    bootstrapWrapper.callSetUpApplicationState();
+
+    assertThat(RuntimeEnvironment.getMasterScheduler())
+        .isSameInstanceAs(ShadowLooper.getShadowMainLooper().getScheduler());
+    assertThat(RuntimeEnvironment.getMasterScheduler())
+        .isSameInstanceAs(ShadowApplication.getInstance().getForegroundThreadScheduler());
+  }
+
+  @Test
+  public void setUpApplicationState_setsBackgroundScheduler_toBeSameAsForeground_whenAdvancedScheduling() {
+    RoboSettings.setUseGlobalScheduler(true);
+    try {
+      bootstrapWrapper.callSetUpApplicationState();
+      final ShadowApplication shadowApplication =
+          Shadow.extract(ApplicationProvider.getApplicationContext());
+      assertThat(shadowApplication.getBackgroundThreadScheduler())
+          .isSameInstanceAs(shadowApplication.getForegroundThreadScheduler());
+      assertThat(RuntimeEnvironment.getMasterScheduler())
+          .isSameInstanceAs(RuntimeEnvironment.getMasterScheduler());
+    } finally {
+      RoboSettings.setUseGlobalScheduler(false);
+    }
+  }
+
+  @Test
+  public void setUpApplicationState_setsBackgroundScheduler_toBeDifferentToForeground_byDefault() {
+    bootstrapWrapper.callSetUpApplicationState();
+    final ShadowApplication shadowApplication =
+        Shadow.extract(ApplicationProvider.getApplicationContext());
+    assertThat(shadowApplication.getBackgroundThreadScheduler())
+        .isNotSameInstanceAs(shadowApplication.getForegroundThreadScheduler());
+  }
+
+  @Test
+  public void setUpApplicationState_setsMainThread() {
+    RuntimeEnvironment.setMainThread(new Thread());
+    assertThat(RuntimeEnvironment.isMainThread()).isFalse();
+    bootstrapWrapper.callSetUpApplicationState();
+    assertThat(RuntimeEnvironment.isMainThread()).isTrue();
+  }
+
+  @Test
+  public void setUpApplicationState_setsMainThread_onAnotherThread() throws InterruptedException {
+    final AtomicBoolean res = new AtomicBoolean();
+    Thread t =
+        new Thread(() -> {
+          bootstrapWrapper.callSetUpApplicationState();
+          res.set(RuntimeEnvironment.isMainThread());
+        });
+    t.start();
+    t.join();
+    assertThat(res.get()).isTrue();
+    assertThat(RuntimeEnvironment.isMainThread()).isFalse();
+  }
+
+  /**
+   * Checks that crypto primitives that are available in an Android environment are also available
+   * in Robolectric via {@link BouncyCastleProvider}.
+   */
+  @Test
+  @ConscryptMode(ON)
+  public void testWhenConscryptModeOn_ConscryptInstalled()
+      throws CertificateException, NoSuchAlgorithmException {
+
+    bootstrapWrapper.callSetUpApplicationState();
+    CertificateFactory factory = CertificateFactory.getInstance("X.509");
+    assertThat(factory.getProvider().getName()).isEqualTo("Conscrypt");
+
+    MessageDigest digest = MessageDigest.getInstance("SHA256");
+    assertThat(digest.getProvider().getName()).isEqualTo("Conscrypt");
+  }
+
+  @Test
+  @ConscryptMode(ON)
+  public void testWhenConscryptModeOn_BouncyCastleInstalled() throws GeneralSecurityException {
+    bootstrapWrapper.callSetUpApplicationState();
+    Cipher aesCipher = Cipher.getInstance("RSA/None/OAEPWithSHA-256AndMGF1Padding");
+    assertThat(aesCipher.getProvider().getName()).isEqualTo(BouncyCastleProvider.PROVIDER_NAME);
+  }
+
+  @Test
+  @ConscryptMode(OFF)
+  public void testWhenConscryptModeOff_ConscryptNotInstalled()
+      throws CertificateException, NoSuchAlgorithmException {
+
+    bootstrapWrapper.callSetUpApplicationState();
+    CertificateFactory factory = CertificateFactory.getInstance("X.509");
+    assertThat(factory.getProvider().getName()).isNotEqualTo("Conscrypt");
+
+    MessageDigest digest = MessageDigest.getInstance("SHA256");
+    assertThat(digest.getProvider().getName()).isNotEqualTo("Conscrypt");
+  }
+
+  @Test
+  @ConscryptMode(OFF)
+  public void testWhenConscryptModeOff_BouncyCastleInstalled() throws GeneralSecurityException {
+
+    bootstrapWrapper.callSetUpApplicationState();
+    MessageDigest digest = MessageDigest.getInstance("SHA256");
+    assertThat(digest.getProvider().getName()).isEqualTo(BouncyCastleProvider.PROVIDER_NAME);
+
+    Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
+    assertThat(aesCipher.getProvider().getName()).isEqualTo(BouncyCastleProvider.PROVIDER_NAME);
+  }
+
+  @Test
+  public void setUpApplicationState_setsVersionQualifierFromSdk() {
+    String givenQualifiers = "";
+    ConfigurationImpl config = new ConfigurationImpl();
+    config.put(Config.class, new Config.Builder().setQualifiers(givenQualifiers).build());
+    config.put(LooperMode.Mode.class, LEGACY);
+    bootstrapWrapper.changeConfig(config);
+    bootstrapWrapper.callSetUpApplicationState();
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("v" + Build.VERSION.RESOURCES_SDK_INT);
+  }
+
+  @Test
+  public void setUpApplicationState_setsVersionQualifierFromSdkWithOtherQualifiers() {
+    String givenQualifiers = "large-land";
+    ConfigurationImpl config = new ConfigurationImpl();
+    config.put(Config.class, new Config.Builder().setQualifiers(givenQualifiers).build());
+    config.put(LooperMode.Mode.class, LEGACY);
+    bootstrapWrapper.changeConfig(config);
+
+    bootstrapWrapper.callSetUpApplicationState();
+
+    String optsForO = RuntimeEnvironment.getApiLevel() >= O
+        ? "nowidecg-lowdr-"
+        : "";
+    assertThat(RuntimeEnvironment.getQualifiers())
+        .contains("large-notlong-notround-" + optsForO + "land-notnight-mdpi-finger-keyssoft"
+            + "-nokeys-navhidden-nonav-v"
+            + Build.VERSION.RESOURCES_SDK_INT);
+  }
+
+  @Test
+  public void setUpApplicationState_shouldCreateStorageDirs() throws Exception {
+    bootstrapWrapper.callSetUpApplicationState();
+    ApplicationInfo applicationInfo = ApplicationProvider.getApplicationContext()
+        .getApplicationInfo();
+
+    assertThat(applicationInfo.sourceDir).isNotNull();
+    assertThat(new File(applicationInfo.sourceDir).exists()).isTrue();
+
+    assertThat(applicationInfo.publicSourceDir).isNotNull();
+    assertThat(new File(applicationInfo.publicSourceDir).exists()).isTrue();
+
+    assertThat(applicationInfo.dataDir).isNotNull();
+    assertThat(new File(applicationInfo.dataDir).isDirectory()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void setUpApplicationState_shouldCreateStorageDirs_Nplus() throws Exception {
+    bootstrapWrapper.callSetUpApplicationState();
+    ApplicationInfo applicationInfo = ApplicationProvider.getApplicationContext()
+        .getApplicationInfo();
+
+    assertThat(applicationInfo.credentialProtectedDataDir).isNotNull();
+    assertThat(new File(applicationInfo.credentialProtectedDataDir).isDirectory()).isTrue();
+
+    assertThat(applicationInfo.deviceProtectedDataDir).isNotNull();
+    assertThat(new File(applicationInfo.deviceProtectedDataDir).isDirectory()).isTrue();
+  }
+
+  @Test
+  public void tearDownApplication_invokesOnTerminate() {
+    List<String> events = new ArrayList<>();
+    RuntimeEnvironment.application =
+        new Application() {
+          @Override
+          public void onTerminate() {
+            super.onTerminate();
+            events.add("terminated");
+          }
+        };
+    bootstrapWrapper.tearDownApplication();
+    assertThat(events).containsExactly("terminated");
+  }
+
+  @Test
+  public void testResourceNotFound() {
+    // not relevant for binary resources mode
+    assumeTrue(bootstrapWrapper.isLegacyResources());
+
+    try {
+      bootstrapWrapper.changeAppManifest(new ThrowingManifest(bootstrapWrapper.getAppManifest()));
+      bootstrapWrapper.callSetUpApplicationState();
+      fail("Expected to throw");
+    } catch (Resources.NotFoundException expected) {
+      // expected
+    }
+  }
+
+  /** Can't use Mockito for classloader issues */
+  static class ThrowingManifest extends AndroidManifest {
+    public ThrowingManifest(AndroidManifest androidManifest) {
+      super(
+          androidManifest.getAndroidManifestFile(),
+          androidManifest.getResDirectory(),
+          androidManifest.getAssetsDirectory(),
+          androidManifest.getLibraryManifests(),
+          null,
+          androidManifest.getApkFile());
+    }
+
+    @Override
+    public void initMetaData(ResourceTable resourceTable) throws RoboNotFoundException {
+      throw new RoboNotFoundException("This is just a test");
+    }
+  }
+
+  @Test @Config(qualifiers = "b+fr+Cyrl+UK")
+  public void localeIsSet() throws Exception {
+    bootstrapWrapper.callSetUpApplicationState();
+    assertThat(Locale.getDefault().getLanguage()).isEqualTo("fr");
+    assertThat(Locale.getDefault().getScript()).isEqualTo("Cyrl");
+    assertThat(Locale.getDefault().getCountry()).isEqualTo("UK");
+  }
+
+  @Test @Config(qualifiers = "w123dp-h456dp")
+  public void whenNotPrefixedWithPlus_setQualifiers_shouldNotBeBasedOnPreviousConfig() throws Exception {
+    bootstrapWrapper.callSetUpApplicationState();
+    RuntimeEnvironment.setQualifiers("land");
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("w470dp-h320dp");
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("-land-");
+  }
+
+  @Test @Config(qualifiers = "w100dp-h125dp")
+  public void whenDimensAndSizeSpecified_setQualifiers_should() throws Exception {
+    bootstrapWrapper.callSetUpApplicationState();
+    RuntimeEnvironment.setQualifiers("+xlarge");
+    Configuration configuration = Resources.getSystem().getConfiguration();
+    assertThat(configuration.screenWidthDp).isEqualTo(ScreenSize.xlarge.width);
+    assertThat(configuration.screenHeightDp).isEqualTo(ScreenSize.xlarge.height);
+    assertThat(DeviceConfig.getScreenSize(configuration)).isEqualTo(ScreenSize.xlarge);
+  }
+
+  @Test @Config(qualifiers = "w123dp-h456dp")
+  public void whenPrefixedWithPlus_setQualifiers_shouldBeBasedOnPreviousConfig() throws Exception {
+    bootstrapWrapper.callSetUpApplicationState();
+    RuntimeEnvironment.setQualifiers("+w124dp");
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("w124dp-h456dp");
+  }
+
+  @LazyApplication(LazyLoad.ON)
+  @Test
+  public void resetState_doesNotLoadApplication() {
+    RuntimeEnvironment.application = null;
+    assertThat(RuntimeEnvironment.application).isNull();
+    bootstrapWrapper.resetState();
+    assertThat(RuntimeEnvironment.application).isNull();
+  }
+
+  @LazyApplication(LazyLoad.ON)
+  @Test
+  public void tearDownApplication_doesNotLoadApplication() {
+    bootstrapWrapper.callSetUpApplicationState();
+    RuntimeEnvironment.application = null;
+    bootstrapWrapper.tearDownApplication();
+    assertThat(RuntimeEnvironment.application).isNull();
+  }
+
+  @LazyApplication(LazyLoad.ON)
+  @Test
+  @Config(qualifiers = "w480dp-h640dp-land-hdpi")
+  public void systemResources_getDisplayMetrics_correctValues() {
+    bootstrapWrapper.callSetUpApplicationState();
+    DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics();
+    assertThat(displayMetrics.densityDpi).isEqualTo(DisplayMetrics.DENSITY_HIGH);
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("w640dp-h480dp");
+    assertThat(RuntimeEnvironment.getQualifiers()).contains("land");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/internal/ApplicationContentionTest.java b/robolectric/src/test/java/org/robolectric/android/internal/ApplicationContentionTest.java
new file mode 100644
index 0000000..013d8c3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/internal/ApplicationContentionTest.java
@@ -0,0 +1,45 @@
+package org.robolectric.android.internal;
+
+import android.app.Application;
+import android.os.Build.VERSION_CODES;
+import java.util.Collection;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/**
+ * Test to ensure Robolectric correctly handles calls to getApplication() when there is the
+ * possibility for contention
+ */
+// Sticking to one SDK for simplicity. The code being tested is SDK-agnostic, and this also saves
+// having to worry about increased runtimes as the number of SDKs goes up
+@Config(sdk = VERSION_CODES.Q)
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public class ApplicationContentionTest {
+  private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+
+  @SuppressWarnings("unused") // Just here to create contention on getApplication()
+  private final Application application = RuntimeEnvironment.getApplication();
+
+  @Parameter public Integer n;
+
+  @Parameters(name = "n: {0}")
+  public static Collection<?> parameters() {
+    return IntStream.range(1, 1000).boxed().collect(Collectors.toList());
+  }
+
+  @Test
+  @SuppressWarnings("FutureReturnValueIgnored")
+  public void testDeferGetApplication() {
+    // defer to potentially disrupt subsequent application creation
+    executorService.submit(RuntimeEnvironment::getApplication);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/internal/ClassNameResolverTest.java b/robolectric/src/test/java/org/robolectric/android/internal/ClassNameResolverTest.java
new file mode 100644
index 0000000..c30037b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/internal/ClassNameResolverTest.java
@@ -0,0 +1,50 @@
+package org.robolectric.android.internal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.shadows.ClassNameResolver;
+import org.robolectric.shadows.testing.TestApplication;
+
+@RunWith(JUnit4.class)
+public class ClassNameResolverTest {
+  @Test
+  public void shouldResolveClassesBySimpleName() throws Exception {
+    assertEquals(
+        TestApplication.class,
+        ClassNameResolver.resolve("org.robolectric.shadows.testing", "TestApplication"));
+  }
+
+  @Test
+  public void shouldResolveClassesByDottedSimpleName() throws Exception {
+    assertEquals(
+        TestApplication.class,
+        ClassNameResolver.resolve("org.robolectric.shadows.testing", ".TestApplication"));
+  }
+
+  @Test
+  public void shouldResolveClassesByFullyQualifiedName() throws Exception {
+    assertEquals(
+        TestApplication.class,
+        ClassNameResolver.resolve(
+            "org.robolectric.shadows.testing", "org.robolectric.shadows.testing.TestApplication"));
+  }
+
+  @Test
+  public void shouldResolveClassesByPartiallyQualifiedName() throws Exception {
+    assertEquals(
+        TestApplication.class,
+        ClassNameResolver.resolve("org", ".robolectric.shadows.testing.TestApplication"));
+  }
+
+  @Test
+  public void shouldNotResolveClassesByUndottedPartiallyQualifiedNameBecauseAndroidDoesnt()
+      throws Exception {
+    assertThrows(
+        ClassNotFoundException.class,
+        () -> ClassNameResolver.resolve("org", "robolectric.shadows.testing.TestApplication"));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/internal/LooperDelegatingSchedulerTest.java b/robolectric/src/test/java/org/robolectric/android/internal/LooperDelegatingSchedulerTest.java
new file mode 100644
index 0000000..b3bee0c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/internal/LooperDelegatingSchedulerTest.java
@@ -0,0 +1,77 @@
+package org.robolectric.android.internal;
+
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.os.Handler;
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.util.Scheduler;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(Mode.PAUSED)
+public class LooperDelegatingSchedulerTest {
+
+  private Scheduler scheduler;
+
+  @Before
+  public void setUp() {
+    scheduler = new LooperDelegatingScheduler(getMainLooper());
+  }
+
+  @Test
+  public void runOneTask() {
+    assertThat(scheduler.runOneTask()).isFalse();
+
+    Runnable runnable = mock(Runnable.class);
+    new Handler(getMainLooper()).post(runnable);
+    verify(runnable, times(0)).run();
+
+    assertThat(scheduler.runOneTask()).isTrue();
+    verify(runnable, times(1)).run();
+  }
+
+  @Test
+  public void advanceTo() {
+    assertThat(scheduler.advanceTo(0)).isFalse();
+
+    assertThat(scheduler.advanceTo(SystemClock.uptimeMillis())).isFalse();
+
+    Runnable runnable = mock(Runnable.class);
+    new Handler(getMainLooper()).post(runnable);
+    verify(runnable, times(0)).run();
+
+    assertThat(scheduler.advanceTo(SystemClock.uptimeMillis())).isTrue();
+    verify(runnable, times(1)).run();
+  }
+
+  @Test
+  public void advanceBy() {
+    Runnable runnable = mock(Runnable.class);
+    new Handler(getMainLooper()).postDelayed(runnable, 100);
+    verify(runnable, times(0)).run();
+
+    assertThat(scheduler.advanceBy(100, TimeUnit.MILLISECONDS)).isTrue();
+    verify(runnable, times(1)).run();
+  }
+
+  @Test
+  public void size() {
+    assertThat(scheduler.size()).isEqualTo(0);
+
+    Runnable runnable = mock(Runnable.class);
+    new Handler(getMainLooper()).post(runnable);
+    assertThat(scheduler.size()).isEqualTo(1);
+    assertThat(scheduler.advanceBy(0, TimeUnit.MILLISECONDS)).isTrue();
+    assertThat(scheduler.size()).isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/util/concurrent/BackgroundExecutorTest.java b/robolectric/src/test/java/org/robolectric/android/util/concurrent/BackgroundExecutorTest.java
new file mode 100644
index 0000000..d732988
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/util/concurrent/BackgroundExecutorTest.java
@@ -0,0 +1,82 @@
+package org.robolectric.android.util.concurrent;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.android.util.concurrent.BackgroundExecutor.runInBackground;
+
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.concurrent.Callable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link BackgroundExecutor} */
+@RunWith(AndroidJUnit4.class)
+public class BackgroundExecutorTest {
+
+  @Test
+  public void forRunnable_doesNotRunOnMainLooper() {
+    runInBackground(
+        () -> {
+          assertThat(Thread.currentThread())
+              .isNotSameInstanceAs(Looper.getMainLooper().getThread());
+          assertThat(Looper.myLooper()).isNotSameInstanceAs(Looper.getMainLooper());
+        });
+  }
+
+  @Test
+  public void forRunnable_exceptionsPropogated() {
+    try {
+      runInBackground(
+          (Runnable)
+              () -> {
+                throw new SecurityException("I failed");
+              });
+      fail("Expects SecurityException!");
+    } catch (SecurityException e) {
+      assertThat(e).hasMessageThat().isEqualTo("I failed");
+    }
+  }
+
+  @Test
+  public void forCallable_doesNotRunOnMainLooper() {
+    boolean result =
+        runInBackground(
+            () -> {
+              assertThat(Thread.currentThread())
+                  .isNotSameInstanceAs(Looper.getMainLooper().getThread());
+              assertThat(Looper.myLooper()).isNotSameInstanceAs(Looper.getMainLooper());
+              return true;
+            });
+    assertThat(result).isTrue();
+  }
+
+  @Test
+  public void forCallable_runtimeExceptionsPropogated() {
+    try {
+      runInBackground(
+          (Callable<?>)
+              () -> {
+                throw new SecurityException("I failed");
+              });
+      fail("Expects SecurityException!");
+    } catch (SecurityException e) {
+      assertThat(e).hasMessageThat().isEqualTo("I failed");
+    }
+  }
+
+  @Test
+  public void forCallable_checkedExceptionsWrapped() {
+    try {
+      runInBackground(
+          (Callable<?>)
+              () -> {
+                throw new IOException("I failed");
+              });
+    } catch (RuntimeException e) {
+      assertThat(e).hasCauseThat().isInstanceOf(IOException.class);
+      assertThat(e).hasCauseThat().hasMessageThat().isEqualTo("I failed");
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/util/concurrent/InlineExecutorServiceTest.java b/robolectric/src/test/java/org/robolectric/android/util/concurrent/InlineExecutorServiceTest.java
new file mode 100644
index 0000000..b1063c6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/util/concurrent/InlineExecutorServiceTest.java
@@ -0,0 +1,115 @@
+package org.robolectric.android.util.concurrent;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@link InlineExecutorService}
+ */
+@RunWith(JUnit4.class)
+public class InlineExecutorServiceTest {
+  private List<String> executedTasksRecord;
+  private InlineExecutorService executorService;
+
+  @Before
+  public void setUp() throws Exception {
+    executedTasksRecord = new ArrayList<>();
+    executorService = new InlineExecutorService();
+  }
+
+  @Test
+  public void executionRunsInBackgroundThread() {
+    final Thread testThread = Thread.currentThread();
+    executorService.execute(
+        new Runnable() {
+          @Override
+          public void run() {
+            assertThat(Thread.currentThread()).isNotSameInstanceAs(testThread);
+            executedTasksRecord.add("task ran");
+          }
+        });
+    assertThat(executedTasksRecord).containsExactly("task ran");
+  }
+
+  @Test
+  public void submit() throws Exception {
+    Runnable runnable = () -> executedTasksRecord.add("background event ran");
+    Future<String> future = executorService.submit(runnable, "foo");
+
+    assertThat(executedTasksRecord).containsExactly("background event ran");
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.get()).isEqualTo("foo");
+  }
+
+  @Test
+  public void submitCallable() throws Exception {
+    Runnable runnable = () -> executedTasksRecord.add("background event ran");
+    Future<String> future = executorService.submit(() -> {
+      runnable.run();
+      return "foo";
+    });
+
+    assertThat(executedTasksRecord).containsExactly("background event ran");
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.get()).isEqualTo("foo");
+  }
+
+  @Test
+  public void byDefault_IsNotShutdown() {
+    assertThat(executorService.isShutdown()).isFalse();
+  }
+
+  @Test
+  public void byDefault_IsNotTerminated() {
+    assertThat(executorService.isTerminated()).isFalse();
+  }
+
+  @Test
+  public void whenAwaitingTerminationAfterShutdown_TrueIsReturned() throws InterruptedException {
+    executorService.shutdown();
+
+    assertThat(executorService.awaitTermination(0, TimeUnit.MILLISECONDS)).isTrue();
+  }
+
+  @Test
+  public void exceptionsPropagated() {
+    Callable<Void> throwingCallable = new Callable<Void>() {
+
+      @Override
+      public Void call() throws Exception {
+        throw new IllegalStateException("I failed");
+      }
+    };
+    try {
+      executorService.submit(throwingCallable);
+      fail("did not propagate exception");
+    } catch (IllegalStateException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void postingTasks() throws Exception {
+    Runnable postingRunnable = new Runnable() {
+      @Override
+      public void run() {
+        executedTasksRecord.add("first");
+        executorService.execute(() -> executedTasksRecord.add("third"));
+        executedTasksRecord.add("second");
+      }
+    };
+    executorService.execute(postingRunnable);
+
+    assertThat(executedTasksRecord).containsExactly("first", "second", "third").inOrder();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/util/concurrent/PausedExecutorServiceTest.java b/robolectric/src/test/java/org/robolectric/android/util/concurrent/PausedExecutorServiceTest.java
new file mode 100644
index 0000000..677fa04
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/util/concurrent/PausedExecutorServiceTest.java
@@ -0,0 +1,194 @@
+package org.robolectric.android.util.concurrent;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@link PausedExecutorService}
+ */
+@RunWith(JUnit4.class)
+public class PausedExecutorServiceTest {
+  private List<String> executedTasksRecord;
+  private PausedExecutorService executorService;
+
+  @Before
+  public void setUp() throws Exception {
+    executedTasksRecord = new ArrayList<>();
+    executorService = new PausedExecutorService();
+  }
+
+  @Test
+  public void executionRunsInBackgroundThread() throws ExecutionException, InterruptedException {
+    final Thread testThread = Thread.currentThread();
+    executorService.execute(
+        () -> {
+          assertThat(Thread.currentThread()).isNotSameInstanceAs(testThread);
+          executedTasksRecord.add("task ran");
+        });
+    executorService.runAll();
+    assertThat(executedTasksRecord).containsExactly("task ran");
+  }
+
+  @Test
+  public void runAll() throws Exception {
+    executorService.execute(() -> executedTasksRecord.add("background event ran"));
+
+    assertThat(executedTasksRecord).isEmpty();
+
+    executorService.runAll();
+    assertThat(executedTasksRecord).containsExactly("background event ran");
+  }
+
+  @Test
+  public void runAll_inOrder() throws Exception {
+    executorService.execute(() -> executedTasksRecord.add("first"));
+    executorService.execute(() -> executedTasksRecord.add("second"));
+    assertThat(executedTasksRecord).isEmpty();
+
+    executorService.runAll();
+    assertThat(executedTasksRecord).containsExactly("first", "second").inOrder();
+  }
+
+  @Test
+  public void runNext() throws Exception {
+    executorService.execute(() -> executedTasksRecord.add("first"));
+    executorService.execute(() -> executedTasksRecord.add("second"));
+    assertThat(executedTasksRecord).isEmpty();
+
+    assertThat(executorService.runNext()).isTrue();
+    assertThat(executedTasksRecord).containsExactly("first").inOrder();
+    assertThat(executorService.runNext()).isTrue();
+    assertThat(executedTasksRecord).containsExactly("first", "second").inOrder();
+    assertThat(executorService.runNext()).isFalse();
+  }
+
+  @Test
+  public void runAll_clearsQueuedTasks() throws Exception {
+    executorService.execute(() -> executedTasksRecord.add("background event ran"));
+
+    assertThat(executedTasksRecord).isEmpty();
+
+    executorService.runAll();
+    assertThat(executedTasksRecord).containsExactly("background event ran");
+
+    executedTasksRecord.clear();
+
+    executorService.runAll();
+    assertThat(executedTasksRecord).isEmpty();
+  }
+
+  @Test
+  public void submit() throws Exception {
+    Runnable runnable = () -> executedTasksRecord.add("background event ran");
+    Future<String> future = executorService.submit(runnable, "foo");
+
+    executorService.runAll();
+    assertThat(executedTasksRecord).containsExactly("background event ran");
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.get()).isEqualTo("foo");
+  }
+
+  @Test
+  public void submitCallable() throws Exception {
+    Runnable runnable = () -> executedTasksRecord.add("background event ran");
+    Future<String> future = executorService.submit(() -> {
+      runnable.run();
+      return "foo";
+    });
+    executorService.runAll();
+
+    assertThat(executedTasksRecord).containsExactly("background event ran");
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.get()).isEqualTo("foo");
+  }
+
+  @Test
+  public void byDefault_IsNotShutdown() {
+    assertThat(executorService.isShutdown()).isFalse();
+  }
+
+  @Test
+  public void byDefault_IsNotTerminated() {
+    assertThat(executorService.isTerminated()).isFalse();
+  }
+
+  @Test
+  public void whenShutdownBeforeSubmittedTasksAreExecuted_TaskIsNotInTranscript()
+      throws ExecutionException, InterruptedException {
+    executorService.execute(() -> executedTasksRecord.add("background event ran"));
+
+    executorService.shutdown();
+    executorService.runAll();
+
+    assertThat(executedTasksRecord).isEmpty();
+  }
+
+  @Test
+  public void whenShutdownNow_ReturnedListContainsOneRunnable()
+      throws ExecutionException, InterruptedException {
+    executorService.execute(() -> executedTasksRecord.add("background event ran"));
+
+    List<Runnable> notExecutedRunnables = executorService.shutdownNow();
+    executorService.runAll();
+
+    assertThat(executedTasksRecord).isEmpty();
+    assertThat(notExecutedRunnables).hasSize(1);
+  }
+
+  @Test
+  public void whenAwaitingTerminationAfterShutdown_TrueIsReturned() throws InterruptedException {
+    executorService.shutdown();
+
+    assertThat(executorService.awaitTermination(0, TimeUnit.MILLISECONDS)).isTrue();
+  }
+
+  @Test
+  public void whenAwaitingTermination_AllTasksAreNotRun() throws Exception {
+    executorService.execute(() -> executedTasksRecord.add("background event ran"));
+
+    assertThat(executorService.awaitTermination(500, TimeUnit.MILLISECONDS)).isFalse();
+    assertThat(executedTasksRecord).isEmpty();
+  }
+
+  @Test
+  @SuppressWarnings("FutureReturnValueIgnored")
+  public void exceptionsPropagated() throws ExecutionException, InterruptedException {
+    Callable<Void> throwingCallable = () -> {
+      throw new IllegalStateException("I failed");
+    };
+    try {
+      executorService.submit(throwingCallable);
+      executorService.runAll();
+      fail("did not propagate exception");
+    } catch (IllegalStateException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void postingTasks() throws Exception {
+    Runnable postingRunnable = new Runnable() {
+      @Override
+      public void run() {
+        executedTasksRecord.add("first");
+        executorService.execute(() -> executedTasksRecord.add("third"));
+        executedTasksRecord.add("second");
+      }
+    };
+    executorService.execute(postingRunnable);
+    executorService.runAll();
+
+    assertThat(executedTasksRecord).containsExactly("first", "second", "third").inOrder();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/android/util/concurrent/RoboExecutorServiceTest.java b/robolectric/src/test/java/org/robolectric/android/util/concurrent/RoboExecutorServiceTest.java
new file mode 100644
index 0000000..23bcc72
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/android/util/concurrent/RoboExecutorServiceTest.java
@@ -0,0 +1,134 @@
+package org.robolectric.android.util.concurrent;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.util.Scheduler;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LEGACY)
+public class RoboExecutorServiceTest {
+  private List<String> transcript;
+  private RoboExecutorService executorService;
+  private Scheduler backgroundScheduler;
+  private Runnable runnable;
+
+  @Before
+  public void setUp() throws Exception {
+    transcript = new ArrayList<>();
+    executorService = new RoboExecutorService();
+
+    backgroundScheduler = Robolectric.getBackgroundThreadScheduler();
+
+    backgroundScheduler.pause();
+    runnable = () -> transcript.add("background event ran");
+  }
+
+  @Test
+  public void execute_shouldRunStuffOnBackgroundThread() throws Exception {
+    executorService.execute(runnable);
+
+    assertThat(transcript).isEmpty();
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).containsExactly("background event ran");
+  }
+
+  @Test
+  public void submitRunnable_shouldRunStuffOnBackgroundThread() throws Exception {
+    Future<String> future = executorService.submit(runnable, "foo");
+
+    assertThat(transcript).isEmpty();
+    assertThat(future.isDone()).isFalse();
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).containsExactly("background event ran");
+    assertThat(future.isDone()).isTrue();
+
+    assertThat(future.get()).isEqualTo("foo");
+  }
+
+  @Test
+  public void submitCallable_shouldRunStuffOnBackgroundThread() throws Exception {
+    Future<String> future =
+        executorService.submit(
+            () -> {
+              runnable.run();
+              return "foo";
+            });
+
+    assertThat(transcript).isEmpty();
+    assertThat(future.isDone()).isFalse();
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).containsExactly("background event ran");
+    assertThat(future.isDone()).isTrue();
+
+    assertThat(future.get()).isEqualTo("foo");
+  }
+
+  @Test
+  public void byDefault_IsNotShutdown() {
+    assertThat(executorService.isShutdown()).isFalse();
+  }
+
+  @Test
+  public void byDefault_IsNotTerminated() {
+    assertThat(executorService.isTerminated()).isFalse();
+  }
+
+  @Test
+  public void whenShutdownBeforeSubmittedTasksAreExecuted_TaskIsNotInTranscript() {
+    executorService.execute(runnable);
+
+    executorService.shutdown();
+    ShadowApplication.runBackgroundTasks();
+
+    assertThat(transcript).isEmpty();
+  }
+
+  @Test
+  public void whenShutdownNow_ReturnedListContainsOneRunnable() {
+    executorService.execute(runnable);
+
+    List<Runnable> notExecutedRunnables = executorService.shutdownNow();
+    ShadowApplication.runBackgroundTasks();
+
+    assertThat(transcript).isEmpty();
+    assertThat(notExecutedRunnables).hasSize(1);
+  }
+
+  @Test(timeout = 500)
+  public void whenGettingFutureValue_FutureRunnableIsExecuted() throws Exception {
+    Future<String> future = executorService.submit(runnable, "foo");
+
+    assertThat(future.get()).isEqualTo("foo");
+    assertThat(future.isDone()).isTrue();
+  }
+
+  @Test
+  public void whenAwaitingTerminationAfterShutdown_TrueIsReturned() throws InterruptedException {
+    executorService.shutdown();
+
+    assertThat(executorService.awaitTermination(0, TimeUnit.MILLISECONDS)).isTrue();
+  }
+
+  @Test
+  public void whenAwaitingTermination_AllTasksAreRunByDefault() throws Exception {
+    executorService.execute(runnable);
+
+    assertThat(executorService.awaitTermination(500, TimeUnit.MILLISECONDS)).isFalse();
+    assertThat(transcript).isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/fakes/ConfigTestReceiver.java b/robolectric/src/test/java/org/robolectric/fakes/ConfigTestReceiver.java
new file mode 100644
index 0000000..da67625
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/fakes/ConfigTestReceiver.java
@@ -0,0 +1,12 @@
+package org.robolectric.fakes;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class ConfigTestReceiver extends BroadcastReceiver {
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/fakes/RoboCursorTest.java b/robolectric/src/test/java/org/robolectric/fakes/RoboCursorTest.java
new file mode 100644
index 0000000..1609257
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/fakes/RoboCursorTest.java
@@ -0,0 +1,255 @@
+package org.robolectric.fakes;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class RoboCursorTest {
+  private static final String STRING_COLUMN = "stringColumn";
+  private static final String LONG_COLUMN = "longColumn";
+  private static final String INT_COLUMN = "intColumn";
+  private static final String BLOB_COLUMN = "blobColumn";
+  private static final String SHORT_COLUMN = "shortColumn";
+  private static final String FLOAT_COLUMN = "floatColumn";
+  private static final String DOUBLE_COLUMN = "doubleColumn";
+  private static final String NULL_COLUMN = "nullColumn";
+
+  private final Uri uri = Uri.parse("http://foo");
+  private final RoboCursor cursor = new RoboCursor();
+  private ContentResolver contentResolver;
+
+  @Before
+  public void setup() throws Exception {
+    contentResolver = ApplicationProvider.getApplicationContext().getContentResolver();
+    shadowOf(contentResolver).setCursor(uri, cursor);
+
+    cursor.setColumnNames(asList(
+        STRING_COLUMN,
+        LONG_COLUMN,
+        INT_COLUMN,
+        BLOB_COLUMN,
+        SHORT_COLUMN,
+        FLOAT_COLUMN,
+        DOUBLE_COLUMN,
+        NULL_COLUMN
+    ));
+  }
+
+  @Test
+  public void query_shouldMakeQueryParamsAvailable() throws Exception {
+    contentResolver.query(
+        uri, new String[] {"projection"}, "selection", new String[] {"selection"}, "sortOrder");
+    assertThat(cursor.uri).isEqualTo(uri);
+    assertThat(cursor.projection[0]).isEqualTo("projection");
+    assertThat(cursor.selection).isEqualTo("selection");
+    assertThat(cursor.selectionArgs[0]).isEqualTo("selection");
+    assertThat(cursor.sortOrder).isEqualTo("sortOrder");
+  }
+
+  @Test
+  public void getColumnCount_whenSetColumnNamesHasntBeenCalled_shouldReturnCountFromData() throws Exception {
+    RoboCursor cursor = new RoboCursor();
+    cursor.setResults(new Object[][]{
+        new Object[] {1, 2, 3},
+        new Object[] {1, 2},
+    });
+    assertThat(cursor.getColumnCount()).isEqualTo(3);
+
+    cursor.setColumnNames(asList("a", "b", "c", "d"));
+    assertThat(cursor.getColumnCount()).isEqualTo(4);
+  }
+
+  @Test
+  public void getColumnName_shouldReturnColumnName() throws Exception {
+    assertThat(cursor.getColumnCount()).isEqualTo(8);
+    assertThat(cursor.getColumnName(0)).isEqualTo(STRING_COLUMN);
+    assertThat(cursor.getColumnName(1)).isEqualTo(LONG_COLUMN);
+  }
+
+  @Test
+  public void getType_shouldReturnColumnType() throws Exception {
+    cursor.setResults(new Object[][]{new Object[]{
+        "aString", 1234L, 42, new byte[]{1, 2, 3}, 255, 1.25f, 2.5d, null
+    }});
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.getType(indexOf(STRING_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_STRING);
+    assertThat(cursor.getType(indexOf(LONG_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_INTEGER);
+    assertThat(cursor.getType(indexOf(INT_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_INTEGER);
+    assertThat(cursor.getType(indexOf(BLOB_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_BLOB);
+    assertThat(cursor.getType(indexOf(SHORT_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_INTEGER);
+    assertThat(cursor.getType(indexOf(FLOAT_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_FLOAT);
+    assertThat(cursor.getType(indexOf(DOUBLE_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_FLOAT);
+    assertThat(cursor.getType(indexOf(NULL_COLUMN))).isEqualTo(Cursor.FIELD_TYPE_NULL);
+  }
+
+  @Test
+  public void get_shouldReturnColumnValue() throws Exception {
+    cursor.setResults(new Object[][]{new Object[]{
+        "aString", 1234L, 42, new byte[]{1, 2, 3}, 255, 1.25f, 2.5d, null
+    }});
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getString(indexOf(STRING_COLUMN))).isEqualTo("aString");
+    assertThat(cursor.getLong(indexOf(LONG_COLUMN))).isEqualTo(1234L);
+    assertThat(cursor.getInt(indexOf(INT_COLUMN))).isEqualTo(42);
+    assertThat(cursor.getBlob(indexOf(BLOB_COLUMN))).asList().containsExactly((byte) 1, (byte) 2, (byte) 3);
+    assertThat(cursor.getShort(indexOf(SHORT_COLUMN))).isEqualTo((short) 255);
+    assertThat(cursor.getFloat(indexOf(FLOAT_COLUMN))).isEqualTo(1.25f);
+    assertThat(cursor.getDouble(indexOf(DOUBLE_COLUMN))).isEqualTo(2.5d);
+    assertThat(cursor.isNull(indexOf(NULL_COLUMN))).isTrue();
+  }
+
+  @Test
+  public void get_shouldConvert() throws Exception {
+    cursor.setResults(new Object[][]{new Object[]{
+        "aString", "1234", "42", new byte[]{1, 2, 3}, 255, "1.25", 2.5d, null
+    }});
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getString(indexOf(INT_COLUMN))).isEqualTo("42");
+    assertThat(cursor.getInt(indexOf(INT_COLUMN))).isEqualTo(42);
+    assertThat(cursor.getFloat(indexOf(FLOAT_COLUMN))).isEqualTo(1.25f);
+    assertThat(cursor.isNull(indexOf(INT_COLUMN))).isFalse();
+    assertThat(cursor.isNull(indexOf(FLOAT_COLUMN))).isFalse();
+  }
+
+  @Test
+  public void moveToNext_advancesToNextRow() throws Exception {
+    cursor.setResults(
+        new Object[][]{new Object[]{"aString", 1234L, 41},
+            new Object[]{"anotherString", 5678L, 42}
+        });
+
+    assertThat(cursor.getCount()).isEqualTo(2);
+    assertThat(cursor.getType(0)).isEqualTo(Cursor.FIELD_TYPE_STRING);
+    assertThat(cursor.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getColumnName(0)).isEqualTo(STRING_COLUMN);
+    assertThat(cursor.getColumnName(1)).isEqualTo(LONG_COLUMN);
+    assertThat(cursor.getString(indexOf(STRING_COLUMN))).isEqualTo("anotherString");
+    assertThat(cursor.getLong(indexOf(LONG_COLUMN))).isEqualTo(5678L);
+    assertThat(cursor.getInt(indexOf(INT_COLUMN))).isEqualTo(42);
+  }
+
+  @Test
+  public void moveToPosition_movesToAppropriateRow() throws Exception {
+    cursor.setResults(
+        new Object[][] {
+          new Object[] {"aString", 1234L, 41}, new Object[] {"anotherString", 5678L, 42}
+        });
+
+    assertThat(cursor.moveToPosition(1)).isTrue();
+    assertThat(cursor.getString(indexOf(STRING_COLUMN))).isEqualTo("anotherString");
+    assertThat(cursor.getLong(indexOf(LONG_COLUMN))).isEqualTo(5678L);
+    assertThat(cursor.getInt(indexOf(INT_COLUMN))).isEqualTo(42);
+
+    assertThat(cursor.moveToPosition(0)).isTrue();
+    assertThat(cursor.getString(indexOf(STRING_COLUMN))).isEqualTo("aString");
+    assertThat(cursor.getLong(indexOf(LONG_COLUMN))).isEqualTo(1234L);
+    assertThat(cursor.getInt(indexOf(INT_COLUMN))).isEqualTo(41);
+  }
+
+  @Test
+  public void moveToPosition_checksBounds() {
+    cursor.setResults(
+        new Object[][] {
+          new Object[] {"aString", 1234L, 41}, new Object[] {"anotherString", 5678L, 42}
+        });
+    assertThat(cursor.moveToPosition(2)).isFalse();
+    assertThat(cursor.moveToPosition(-1)).isFalse();
+  }
+
+  @Test
+  public void getCount_shouldReturnNumberOfRows() {
+    cursor.setResults(
+        new Object[][] {
+          new Object[] {"aString", 1234L, 41}, new Object[] {"anotherString", 5678L, 42}
+        });
+    assertThat(cursor.getCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void close_isRemembered() throws Exception {
+    cursor.close();
+    assertThat(cursor.getCloseWasCalled()).isTrue();
+  }
+
+  @Test
+  public void getColumnIndex_shouldReturnColumnIndex() {
+    assertThat(indexOf("invalidColumn")).isEqualTo(-1);
+    assertThat(indexOf(STRING_COLUMN)).isEqualTo(0);
+    assertThat(cursor.getColumnIndexOrThrow(STRING_COLUMN)).isEqualTo(0);
+  }
+
+  @Test
+  public void getColumnIndexOrThrow_shouldThrowException() {
+    assertThrows(
+        IllegalArgumentException.class, () -> cursor.getColumnIndexOrThrow("invalidColumn"));
+  }
+
+  @Test
+  public void isBeforeFirst_shouldReturnTrueForPosition() {
+    cursor.setResults(new Object[][]{new Object[]{"Foo"}});
+
+    assertThat(cursor.isBeforeFirst()).isTrue();
+    cursor.moveToPosition(0);
+    assertThat(cursor.isBeforeFirst()).isFalse();
+  }
+
+  @Test
+  public void isAfterLast_shouldReturnTrueForPosition() {
+    cursor.setResults(new Object[][]{new Object[]{"Foo"}});
+
+    assertThat(cursor.isAfterLast()).isFalse();
+    cursor.moveToPosition(1);
+    assertThat(cursor.isAfterLast()).isTrue();
+  }
+
+  @Test
+  public void isFirst_shouldReturnTrueForPosition() {
+    cursor.setResults(new Object[][]{new Object[]{"Foo"}, new Object[]{"Bar"}});
+
+    cursor.moveToPosition(0);
+    assertThat(cursor.isFirst()).isTrue();
+
+    cursor.moveToPosition(1);
+    assertThat(cursor.isFirst()).isFalse();
+  }
+
+  @Test
+  public void isLast_shouldReturnTrueForPosition() {
+    cursor.setResults(new Object[][]{new Object[]{"Foo"}, new Object[]{"Bar"}});
+
+    cursor.moveToPosition(0);
+    assertThat(cursor.isLast()).isFalse();
+
+    cursor.moveToPosition(1);
+    assertThat(cursor.isLast()).isTrue();
+  }
+
+  @Test
+  public void getExtras_shouldReturnExtras() {
+    Bundle extras = new Bundle();
+    extras.putString("Foo", "Bar");
+    cursor.setExtras(extras);
+    assertThat(cursor.getExtras()).isEqualTo(extras);
+    assertThat(cursor.getExtras().getString("Foo")).isEqualTo("Bar");
+  }
+
+  private int indexOf(String stringColumn) {
+    return cursor.getColumnIndex(stringColumn);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/fakes/RoboMenuItemTest.java b/robolectric/src/test/java/org/robolectric/fakes/RoboMenuItemTest.java
new file mode 100644
index 0000000..7f31ce9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/fakes/RoboMenuItemTest.java
@@ -0,0 +1,179 @@
+package org.robolectric.fakes;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.drawable.Drawable;
+import android.view.MenuItem;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class RoboMenuItemTest {
+  private MenuItem item;
+  private TestOnActionExpandListener listener;
+
+  @Before
+  public void setUp() throws Exception {
+    item = new RoboMenuItem(ApplicationProvider.getApplicationContext());
+    listener =  new TestOnActionExpandListener();
+    item.setOnActionExpandListener(listener);
+  }
+
+  @Test
+  public void shouldCheckTheMenuItem() throws Exception {
+    assertThat(item.isChecked()).isFalse();
+    item.setChecked(true);
+    assertThat(item.isChecked()).isTrue();
+  }
+
+  @Test
+  public void shouldAllowSettingCheckable() throws Exception {
+    assertThat(item.isCheckable()).isFalse();
+    item.setCheckable(true);
+    assertThat(item.isCheckable()).isTrue();
+  }
+
+  @Test
+  public void shouldAllowSettingVisible() throws Exception {
+    assertThat(item.isVisible()).isTrue();
+    item.setVisible(false);
+    assertThat(item.isVisible()).isFalse();
+  }
+
+  @Test
+  public void expandActionView_shouldReturnFalseIfActionViewIsNull() throws Exception {
+    item.setActionView(null);
+    assertThat(item.expandActionView()).isFalse();
+  }
+
+  @Test
+  public void expandActionView_shouldSetExpandedTrue() throws Exception {
+    item.setActionView(new View(ApplicationProvider.getApplicationContext()));
+    assertThat(item.expandActionView()).isTrue();
+    assertThat(item.isActionViewExpanded()).isTrue();
+  }
+
+  @Test
+  public void expandActionView_shouldInvokeListener() throws Exception {
+    item.setActionView(new View(ApplicationProvider.getApplicationContext()));
+    item.expandActionView();
+    assertThat(listener.expanded).isTrue();
+  }
+
+  @Test
+  public void collapseActionView_shouldReturnFalseIfActionViewIsNull() throws Exception {
+    item.setActionView(null);
+    assertThat(item.collapseActionView()).isFalse();
+  }
+
+  @Test
+  public void collapseActionView_shouldSetExpandedFalse() throws Exception {
+    item.setActionView(new View(ApplicationProvider.getApplicationContext()));
+    item.expandActionView();
+    assertThat(item.collapseActionView()).isTrue();
+    assertThat(item.isActionViewExpanded()).isFalse();
+  }
+
+  @Test
+  public void collapseActionView_shouldInvokeListener() throws Exception {
+    item.setActionView(new View(ApplicationProvider.getApplicationContext()));
+    listener.expanded = true;
+    item.collapseActionView();
+    assertThat(listener.expanded).isFalse();
+  }
+
+  @Test
+  public void methodsShouldReturnThis() throws Exception {
+    item = item.setEnabled(true);
+    assertThat(item).isNotNull();
+    item = item.setOnMenuItemClickListener(null);
+    assertThat(item).isNotNull();
+    item = item.setActionProvider(null);
+    assertThat(item).isNotNull();
+    item = item.setActionView(R.layout.custom_layout);
+    assertThat(item).isNotNull();
+    item = item.setActionView(null);
+    assertThat(item).isNotNull();
+    item = item.setAlphabeticShortcut('a');
+    assertThat(item).isNotNull();
+    item = item.setCheckable(false);
+    assertThat(item).isNotNull();
+    item = item.setChecked(true);
+    assertThat(item).isNotNull();
+    item = item.setIcon(null);
+    assertThat(item).isNotNull();
+    item = item.setIcon(0);
+    assertThat(item).isNotNull();
+    item = item.setIntent(null);
+    assertThat(item).isNotNull();
+    item = item.setNumericShortcut('6');
+    assertThat(item).isNotNull();
+    item = item.setOnActionExpandListener(null);
+    assertThat(item).isNotNull();
+    item = item.setShortcut('6', 'z');
+    assertThat(item).isNotNull();
+    item = item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS);
+    assertThat(item).isNotNull();
+    item = item.setTitleCondensed("condensed");
+    assertThat(item).isNotNull();
+    item = item.setVisible(true);
+    assertThat(item).isNotNull();
+    item = item.setTitle("title");
+    assertThat(item).isNotNull();
+    item = item.setTitle(0);
+    assertThat(item).isNotNull();
+  }
+
+  @Test
+  public void setIcon_shouldNullifyOnZero() throws Exception {
+    assertThat(item.getIcon()).isNull();
+    item.setIcon(R.drawable.an_image);
+    assertThat(shadowOf(item.getIcon()).getCreatedFromResId()).isEqualTo(R.drawable.an_image);
+    item.setIcon(0);
+    assertThat(item.getIcon()).isNull();
+  }
+
+  @Test
+  public void getIcon_shouldReturnDrawableFromSetIconDrawable() throws Exception {
+    Drawable testDrawable =
+        ApplicationProvider.getApplicationContext().getResources().getDrawable(R.drawable.an_image);
+    assertThat(testDrawable).isNotNull();
+    assertThat(item.getIcon()).isNull();
+    item.setIcon(testDrawable);
+    assertThat(item.getIcon()).isSameInstanceAs(testDrawable);
+  }
+
+  @Test
+  public void getIcon_shouldReturnDrawableFromSetIconResourceId() throws Exception {
+    assertThat(item.getIcon()).isNull();
+    item.setIcon(R.drawable.an_other_image);
+    assertThat(shadowOf(item.getIcon()).getCreatedFromResId()).isEqualTo(R.drawable.an_other_image);
+  }
+
+  @Test
+  public void setOnActionExpandListener_shouldReturnMenuItem() throws Exception {
+    assertThat(item.setOnActionExpandListener(listener)).isSameInstanceAs(item);
+  }
+
+  static class TestOnActionExpandListener implements MenuItem.OnActionExpandListener {
+    private boolean expanded = false;
+
+    @Override
+    public boolean onMenuItemActionExpand(MenuItem menuItem) {
+      expanded = true;
+      return true;
+    }
+
+    @Override
+    public boolean onMenuItemActionCollapse(MenuItem menuItem) {
+      expanded = false;
+      return true;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/fakes/RoboMenuTest.java b/robolectric/src/test/java/org/robolectric/fakes/RoboMenuTest.java
new file mode 100644
index 0000000..9dd4d4c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/fakes/RoboMenuTest.java
@@ -0,0 +1,81 @@
+package org.robolectric.fakes;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.view.MenuItem;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.shadows.ShadowApplication;
+
+@RunWith(AndroidJUnit4.class)
+public class RoboMenuTest {
+
+  @Test
+  public void addAndRemoveMenuItems() {
+    RoboMenu menu = new RoboMenu(ApplicationProvider.getApplicationContext());
+    menu.add(9, 10, 0, org.robolectric.R.string.ok);
+
+    RoboMenuItem item = (RoboMenuItem) menu.findItem(10);
+
+    assertThat(item.getGroupId()).isEqualTo(9);
+    assertThat(item.getItemId()).isEqualTo(10);
+
+    menu.removeItem(10);
+
+    item = (RoboMenuItem) menu.findItem(10);
+    Assert.assertNull(item);
+  }
+
+  @Test
+  public void addSubMenu() {
+    RoboMenu menu = new RoboMenu(ApplicationProvider.getApplicationContext());
+    menu.addSubMenu(9, 10, 0, org.robolectric.R.string.ok);
+
+    RoboMenuItem item = (RoboMenuItem) menu.findItem(10);
+
+    assertThat(item.getGroupId()).isEqualTo(9);
+    assertThat(item.getItemId()).isEqualTo(10);
+  }
+
+  @Test
+  public void clickWithIntent() {
+    Activity a = Robolectric.buildActivity(Activity.class).get();
+    RoboMenu menu = new RoboMenu(a);
+    menu.add(0, 10, 0, org.robolectric.R.string.ok);
+
+    RoboMenuItem item = (RoboMenuItem) menu.findItem(10);
+    Assert.assertNull(item.getIntent());
+
+    Intent intent = new Intent(a, Activity.class);
+    item.setIntent(intent);
+    item.click();
+
+    Assert.assertNotNull(item);
+
+    Intent startedIntent = ShadowApplication.getInstance().getNextStartedActivity();
+    assertNotNull(startedIntent);
+  }
+
+  @Test
+  public void add_AddsItemsInOrder() {
+    RoboMenu menu = new RoboMenu(ApplicationProvider.getApplicationContext());
+    menu.add(0, 0, 1, "greeting");
+    menu.add(0, 0, 0, "hell0");
+    menu.add(0, 0, 0, "hello");
+
+    MenuItem item = menu.getItem(0);
+    assertEquals("hell0", item.getTitle().toString());
+    item = menu.getItem(1);
+    assertEquals("hello", item.getTitle().toString());
+    item = menu.getItem(2);
+    assertEquals("greeting", item.getTitle().toString());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/fakes/RoboWebMessagePortTest.java b/robolectric/src/test/java/org/robolectric/fakes/RoboWebMessagePortTest.java
new file mode 100644
index 0000000..1722197
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/fakes/RoboWebMessagePortTest.java
@@ -0,0 +1,94 @@
+package org.robolectric.fakes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.webkit.WebMessage;
+import android.webkit.WebMessagePort;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test RoboWebMessagePort. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.M)
+public class RoboWebMessagePortTest {
+  private RoboWebMessagePort[] ports;
+
+  @Before
+  public void setUp() {
+    ports = RoboWebMessagePort.createPair();
+  }
+
+  @Test
+  public void testDefaults() {
+    assertThat(ports[0].getReceivedMessages()).isEmpty();
+    assertThat(ports[0].getConnectedPort()).isNotNull();
+    assertThat(ports[0].getWebMessageCallback()).isNull();
+    assertThat(ports[0].isClosed()).isFalse();
+    assertThat(ports[1].getReceivedMessages()).isEmpty();
+    assertThat(ports[1].getConnectedPort()).isNotNull();
+    assertThat(ports[1].getWebMessageCallback()).isNull();
+    assertThat(ports[1].isClosed()).isFalse();
+  }
+
+  @Test
+  public void testPostMessage() {
+    String message = "message";
+    ports[0].postMessage(new WebMessage(message));
+
+    assertThat(ports[0].getOutgoingMessages()).containsExactly(message);
+    assertThat(ports[1].getReceivedMessages()).containsExactly(message);
+  }
+
+  @Test
+  public void testPostMessageOnClosedPort() {
+    ports[0].close();
+    ports[0].postMessage(new WebMessage("message"));
+
+    assertThat(ports[0].getOutgoingMessages()).isEmpty();
+  }
+
+  @Test
+  public void testSetWebMessageCallback() {
+    WebMessagePort.WebMessageCallback callback =
+        new WebMessagePort.WebMessageCallback() {
+          @Override
+          public void onMessage(WebMessagePort port, WebMessage message) {
+            // some logic
+          }
+        };
+
+    ports[0].setWebMessageCallback(callback);
+
+    assertThat(ports[0].getWebMessageCallback()).isEqualTo(callback);
+  }
+
+  @Test
+  public void testSetConnectedPort() {
+    RoboWebMessagePort port1 = new RoboWebMessagePort();
+    RoboWebMessagePort port2 = new RoboWebMessagePort();
+
+    port1.setConnectedPort(port2);
+    port2.setConnectedPort(port1);
+
+    assertThat(port1.getConnectedPort()).isEqualTo(port2);
+    assertThat(port2.getConnectedPort()).isEqualTo(port1);
+  }
+
+  @Test
+  public void testSetConnectedNullPort() {
+    ports[0].setConnectedPort(null);
+
+    assertThat(ports[0].getConnectedPort()).isNull();
+  }
+
+  @Test
+  public void testClose() {
+    ports[0].close();
+
+    assertThat(ports[0].isClosed()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/fakes/RoboWebSettingsTest.java b/robolectric/src/test/java/org/robolectric/fakes/RoboWebSettingsTest.java
new file mode 100644
index 0000000..79370c3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/fakes/RoboWebSettingsTest.java
@@ -0,0 +1,295 @@
+package org.robolectric.fakes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.webkit.WebSettings;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+@DoNotInstrument
+@RunWith(AndroidJUnit4.class)
+public class RoboWebSettingsTest {
+  private final RoboWebSettings webSettings = new RoboWebSettings();
+  private static final boolean[] TRUE_AND_FALSE = {true, false};
+
+  @Test
+  public void testDefaults() {
+    assertThat(webSettings.getAllowContentAccess()).isTrue();
+    assertThat(webSettings.getAllowFileAccess()).isTrue();
+    assertThat(webSettings.getAppCacheEnabled()).isFalse();
+    assertThat(webSettings.getBlockNetworkImage()).isFalse();
+    assertThat(webSettings.getBlockNetworkLoads()).isFalse();
+    assertThat(webSettings.getBuiltInZoomControls()).isTrue();
+    assertThat(webSettings.getDatabaseEnabled()).isFalse();
+    assertThat(webSettings.getDomStorageEnabled()).isFalse();
+    assertThat(webSettings.getGeolocationEnabled()).isFalse();
+    assertThat(webSettings.getJavaScriptEnabled()).isFalse();
+    assertThat(webSettings.getLightTouchEnabled()).isFalse();
+    assertThat(webSettings.getLoadWithOverviewMode()).isFalse();
+    assertThat(webSettings.getMediaPlaybackRequiresUserGesture()).isTrue();
+    assertThat(webSettings.getPluginState()).isEqualTo(WebSettings.PluginState.OFF);
+    assertThat(webSettings.getSaveFormData()).isFalse();
+    assertThat(webSettings.getTextZoom()).isEqualTo(100);
+    assertThat(webSettings.getDefaultTextEncodingName()).isEqualTo("UTF-8");
+    assertThat(webSettings.getDefaultFontSize()).isEqualTo(16);
+
+    // deprecated methods
+    assertThat(webSettings.getPluginsEnabled()).isFalse();
+
+    // obsoleted methods
+    assertThat(webSettings.getNeedInitialFocus()).isFalse();
+    assertThat(webSettings.getSupportMultipleWindows()).isFalse();
+    assertThat(webSettings.getSupportZoom()).isTrue();
+  }
+
+  @Test
+  public void testAllowContentAccess() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setAllowContentAccess(value);
+      assertThat(webSettings.getAllowContentAccess()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testAllowFileAccess() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setAllowFileAccess(value);
+      assertThat(webSettings.getAllowFileAccess()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testAllowFileAccessFromFileURLs() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setAllowFileAccessFromFileURLs(value);
+      assertThat(webSettings.getAllowFileAccessFromFileURLs()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testAllowUniversalAccessFromFileURLs() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setAllowUniversalAccessFromFileURLs(value);
+      assertThat(webSettings.getAllowUniversalAccessFromFileURLs()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testBlockNetworkImage() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setBlockNetworkImage(value);
+      assertThat(webSettings.getBlockNetworkImage()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testBlockNetworkLoads() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setBlockNetworkLoads(value);
+      assertThat(webSettings.getBlockNetworkLoads()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testBuiltInZoomControls() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setBuiltInZoomControls(value);
+      assertThat(webSettings.getBuiltInZoomControls()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testDatabaseEnabled() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setDatabaseEnabled(value);
+      assertThat(webSettings.getDatabaseEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testDomStorageEnabled() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setDomStorageEnabled(value);
+      assertThat(webSettings.getDomStorageEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testJavaScriptEnabled() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setJavaScriptEnabled(value);
+      assertThat(webSettings.getJavaScriptEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testLightTouchEnabled() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setLightTouchEnabled(value);
+      assertThat(webSettings.getLightTouchEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testLoadWithOverviewMode() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setLoadWithOverviewMode(value);
+      assertThat(webSettings.getLoadWithOverviewMode()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testMediaPlaybackRequiresUserGesture() throws Exception {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setMediaPlaybackRequiresUserGesture(value);
+      assertThat(webSettings.getMediaPlaybackRequiresUserGesture()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testNeedInitialFocus() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setNeedInitialFocus(value);
+      assertThat(webSettings.getNeedInitialFocus()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testPluginsEnabled() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setPluginsEnabled(value);
+      assertThat(webSettings.getPluginsEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testPluginState() {
+
+    for (WebSettings.PluginState state : WebSettings.PluginState.values()) {
+      webSettings.setPluginState(state);
+      assertThat(webSettings.getPluginState()).isEqualTo(state);
+    }
+  }
+
+  @Test
+  public void testSupportMultipleWindows() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setSupportMultipleWindows(value);
+      assertThat(webSettings.getSupportMultipleWindows()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testSupportZoom() {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setSupportZoom(value);
+      assertThat(webSettings.getSupportZoom()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testSetCacheMode() throws Exception {
+    webSettings.setCacheMode(7);
+    assertThat(webSettings.getCacheMode()).isEqualTo(7);
+  }
+
+  @Test
+  public void testSetUseWideViewPort() throws Exception {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setUseWideViewPort(value);
+      assertThat(webSettings.getUseWideViewPort()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testSetAppCacheEnabled() throws Exception {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setAppCacheEnabled(value);
+      assertThat(webSettings.getAppCacheEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testSetGeolocationEnabled() throws Exception {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setGeolocationEnabled(value);
+      assertThat(webSettings.getGeolocationEnabled()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testSetSaveFormData() throws Exception {
+    for (boolean value : TRUE_AND_FALSE) {
+      webSettings.setSaveFormData(value);
+      assertThat(webSettings.getSaveFormData()).isEqualTo(value);
+    }
+  }
+
+  @Test
+  public void testSetDatabasePath() throws Exception {
+    webSettings.setDatabasePath("new_path");
+    assertThat(webSettings.getDatabasePath()).isEqualTo("new_path");
+  }
+
+  @Test
+  public void testSetRenderPriority() throws Exception {
+    webSettings.setRenderPriority(WebSettings.RenderPriority.HIGH);
+    assertThat(webSettings.getRenderPriority()).isEqualTo(WebSettings.RenderPriority.HIGH);
+  }
+
+  @Test
+  public void testSetAppCachePath() throws Exception {
+    webSettings.setAppCachePath("new_path");
+    assertThat(webSettings.getAppCachePath()).isEqualTo("new_path");
+  }
+
+  @Test
+  public void testSetAppCacheMaxSize() throws Exception {
+    webSettings.setAppCacheMaxSize(100);
+    assertThat(webSettings.getAppCacheMaxSize()).isEqualTo(100);
+  }
+
+  @Test
+  public void testSetGeolocationDatabasePath() throws Exception {
+    webSettings.setGeolocationDatabasePath("new_path");
+    assertThat(webSettings.getGeolocationDatabasePath()).isEqualTo("new_path");
+  }
+
+  @Test
+  public void testSetJavascriptCanOpenWindowsAutomaticallyIsTrue() throws Exception {
+    webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
+    assertThat(webSettings.getJavaScriptCanOpenWindowsAutomatically()).isTrue();
+  }
+
+  @Test
+  public void testSetJavascriptCanOpenWindowsAutomaticallyIsFalse() throws Exception {
+    webSettings.setJavaScriptCanOpenWindowsAutomatically(false);
+    assertThat(webSettings.getJavaScriptCanOpenWindowsAutomatically()).isFalse();
+  }
+
+  @Test
+  public void testSetTextZoom() throws Exception {
+    webSettings.setTextZoom(50);
+    assertThat(webSettings.getTextZoom()).isEqualTo(50);
+  }
+
+  @Test
+  public void setDefaultTextEncodingName_shouldGetSetValue() {
+    webSettings.setDefaultTextEncodingName("UTF-16");
+    assertThat(webSettings.getDefaultTextEncodingName()).isEqualTo("UTF-16");
+  }
+
+  @Test
+  public void setDefaultFontSize_shouldGetSetValues() {
+    webSettings.setDefaultFontSize(2);
+    assertThat(webSettings.getDefaultFontSize()).isEqualTo(2);
+  }
+
+  @Test
+  public void testSetForceDark() {
+    webSettings.setForceDark(WebSettings.FORCE_DARK_AUTO);
+    assertThat(webSettings.getForceDark()).isEqualTo(WebSettings.FORCE_DARK_AUTO);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/gradleapp/BuildConfig.java b/robolectric/src/test/java/org/robolectric/gradleapp/BuildConfig.java
new file mode 100644
index 0000000..d70f22b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/gradleapp/BuildConfig.java
@@ -0,0 +1,12 @@
+package org.robolectric.gradleapp;
+
+public class BuildConfig {
+  // The build system creates a BuildConfig for each flavor/build type combination,
+  // in a different intermediate directory in the build tree.
+  // APPLICATION_ID is unique for each combination. The R class is always found
+  // in the same package as BuildConfig.
+  public static final String APPLICATION_ID = "org.robolectric.gradleapp.demo";
+
+  public static final String BUILD_TYPE = "type1";
+  public static final String FLAVOR = "flavor1";
+}
diff --git a/robolectric/src/test/java/org/robolectric/gradleapp/R.java b/robolectric/src/test/java/org/robolectric/gradleapp/R.java
new file mode 100644
index 0000000..53a7021
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/gradleapp/R.java
@@ -0,0 +1,19 @@
+package org.robolectric.gradleapp;
+
+public class R {
+
+  public static class string {
+    public static int from_gradle_output = 0x7f030001;
+    public static int item_from_gradle_output = 0x7f030002;
+  }
+
+  public static class color {
+    public static int example_color = 0x7f030003;
+    public static int example_item_color = 0x7f030004;
+  }
+
+  public static class dimen {
+    public static int example_dimen = 0x7f030005;
+    public static int example_item_dimen = 0x7f030006;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java b/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java
new file mode 100644
index 0000000..e3163cc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java
@@ -0,0 +1,192 @@
+package org.robolectric.interceptors;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayOutputStream;
+import java.io.FileDescriptor;
+import java.io.PrintStream;
+import java.lang.invoke.CallSite;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Field;
+import java.net.Socket;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.internal.bytecode.InvokeDynamicSupport;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.ShadowSystemClock;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Integration tests for Android interceptors. */
+@RunWith(AndroidJUnit4.class)
+public class AndroidInterceptorsIntegrationTest {
+
+  @Test
+  public void systemLog_shouldWriteToStderr() throws Throwable {
+    PrintStream stderr = System.err;
+    ByteArrayOutputStream stream = new ByteArrayOutputStream();
+    PrintStream printStream = new PrintStream(stream);
+    System.setErr(printStream);
+    try {
+      for (String methodName : new String[] {"logE", "logW"}) {
+        stream.reset();
+        // One parameter: [message]
+        invokeDynamic(
+            System.class, methodName, void.class, ClassParameter.from(String.class, "hello"));
+        String expected1 = String.format("System\\.%s: hello", methodName);
+        // Two parameters: [message, throwable]
+        // We verify that the stack trace is dumped by looking for the name of this method.
+        invokeDynamic(
+            System.class,
+            methodName,
+            void.class,
+            ClassParameter.from(String.class, "world"),
+            ClassParameter.from(Throwable.class, new Throwable("message")));
+        String expected2 =
+            String.format(
+                "System.%s: world.*java\\.lang\\.Throwable: message.*"
+                    + "at .*AndroidInterceptorsIntegrationTest\\.systemLog_shouldWriteToStderr",
+                methodName);
+        // Due to the possibility of running tests in Parallel, assertions checking stderr contents
+        // should not assert equality.
+        assertThat(stream.toString())
+            .matches(Pattern.compile(".*" + expected1 + ".*" + expected2 + ".*", Pattern.DOTALL));
+      }
+    } finally {
+      System.setErr(stderr);
+    }
+  }
+
+  @Test
+  public void systemNanoTime_shouldReturnShadowClockTime() throws Throwable {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      SystemClock.setCurrentTimeMillis(200);
+    } else {
+      ShadowSystemClock.setNanoTime(Duration.ofMillis(200).toNanos());
+    }
+
+    long nanoTime = invokeDynamic(System.class, "nanoTime", long.class);
+    assertThat(nanoTime).isEqualTo(Duration.ofMillis(200).toNanos());
+  }
+
+  @Test
+  public void systemCurrentTimeMillis_shouldReturnShadowClockTime() throws Throwable {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      SystemClock.setCurrentTimeMillis(200);
+    } else {
+      ShadowSystemClock.setNanoTime(Duration.ofMillis(200).toNanos());
+    }
+
+    long currentTimeMillis = invokeDynamic(System.class, "currentTimeMillis", long.class);
+    assertThat(currentTimeMillis).isEqualTo(200);
+  }
+
+  /* Creates a FileDescriptor-object using reflection. Note that the "fd"-field is present for
+   * both the Windows and Unix implementations of FileDescriptor in OpenJDK.
+   */
+  private static FileDescriptor createFd(int fd) throws Throwable {
+    FileDescriptor ret = new FileDescriptor();
+    Field accessor = FileDescriptor.class.getDeclaredField("fd");
+    accessor.setAccessible(true);
+    accessor.set(ret, fd);
+    return ret;
+  }
+
+  @Test
+  public void fileDescriptorRelease_isValid_correctResultsAfterRelease() throws Throwable {
+    FileDescriptor original = createFd(42);
+    assertThat(original.valid()).isTrue();
+    FileDescriptor copy =
+        invokeDynamic(
+            FileDescriptor.class,
+            "release$",
+            FileDescriptor.class,
+            ClassParameter.from(FileDescriptor.class, original));
+    assertThat(copy.valid()).isTrue();
+    assertThat(original.valid()).isFalse();
+  }
+
+  @Test
+  public void fileDescriptorRelease_allowsReleaseOnInvalidFd() throws Throwable {
+    FileDescriptor original = new FileDescriptor();
+    assertThat(original.valid()).isFalse();
+    FileDescriptor copy =
+        invokeDynamic(
+            FileDescriptor.class,
+            "release$",
+            FileDescriptor.class,
+            ClassParameter.from(FileDescriptor.class, original));
+    assertThat(copy.valid()).isFalse();
+    assertThat(original.valid()).isFalse();
+  }
+
+  @Test
+  public void fileDescriptorRelease_doubleReleaseReturnsInvalidFd() throws Throwable {
+    FileDescriptor original = createFd(42);
+    assertThat(original.valid()).isTrue();
+    FileDescriptor copy =
+        invokeDynamic(
+            FileDescriptor.class,
+            "release$",
+            FileDescriptor.class,
+            ClassParameter.from(FileDescriptor.class, original));
+    FileDescriptor copy2 =
+        invokeDynamic(
+            FileDescriptor.class,
+            "release$",
+            FileDescriptor.class,
+            ClassParameter.from(FileDescriptor.class, original));
+    assertThat(copy.valid()).isTrue();
+    assertThat(copy2.valid()).isFalse();
+    assertThat(original.valid()).isFalse();
+  }
+
+  @Test
+  public void fileDescriptorRelease_releaseFdCorrect() throws Throwable {
+    FileDescriptor original = createFd(42);
+    FileDescriptor copy =
+        invokeDynamic(
+            FileDescriptor.class,
+            "release$",
+            FileDescriptor.class,
+            ClassParameter.from(FileDescriptor.class, original));
+    Field accessor = FileDescriptor.class.getDeclaredField("fd");
+    accessor.setAccessible(true);
+    assertThat(accessor.get(copy)).isEqualTo(42);
+  }
+
+  @Test
+  public void socketFileDescriptor_returnsNullFileDescriptor() throws Throwable {
+    FileDescriptor fd =
+        invokeDynamic(
+            Socket.class,
+            "getFileDescriptor$",
+            void.class,
+            ClassParameter.from(Socket.class, new Socket()));
+    assertThat(fd).isNull();
+  }
+
+  @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"})
+  private static <T> T invokeDynamic(
+      Class<?> cls, String methodName, Class<?> returnType, ClassParameter<?>... params)
+      throws Throwable {
+    MethodType methodType =
+        MethodType.methodType(
+            returnType, Arrays.stream(params).map(param -> param.clazz).toArray(Class[]::new));
+    CallSite callsite =
+        InvokeDynamicSupport.bootstrapIntrinsic(
+            MethodHandles.lookup(), methodName, methodType, cls.getName());
+    return (T)
+        callsite
+            .dynamicInvoker()
+            .invokeWithArguments(
+                Arrays.stream(params).map(param -> param.val).collect(Collectors.toList()));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsTest.java b/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsTest.java
new file mode 100644
index 0000000..b968534
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsTest.java
@@ -0,0 +1,44 @@
+package org.robolectric.interceptors;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.internal.bytecode.Interceptors;
+import org.robolectric.internal.bytecode.MethodRef;
+
+@RunWith(JUnit4.class)
+public class AndroidInterceptorsTest {
+  @Test
+  public void allMethodRefs() throws Exception {
+    assertThat(new Interceptors(AndroidInterceptors.all()).getAllMethodRefs())
+        .containsAtLeast(
+            new MethodRef("java.util.LinkedHashMap", "eldest"),
+            new MethodRef("java.lang.System", "loadLibrary"),
+            new MethodRef("android.os.StrictMode", "trackActivity"),
+            new MethodRef("android.os.StrictMode", "incrementExpectedActivityCount"),
+            new MethodRef("android.util.LocaleUtil", "getLayoutDirectionFromLocale"),
+            new MethodRef("android.view.FallbackEventHandler", "*"),
+            new MethodRef("java.lang.System", "nanoTime"),
+            new MethodRef("java.lang.System", "currentTimeMillis"),
+            new MethodRef("java.lang.System", "arraycopy"),
+            new MethodRef("java.lang.System", "logE"),
+            new MethodRef("java.util.Locale", "adjustLanguageCode"),
+            new MethodRef("java.io.FileDescriptor", "release$"),
+            new MethodRef("java.io.FileDescriptor", "getInt$"),
+            new MethodRef("java.io.FileDescriptor", "setInt$"));
+  }
+
+  @Test
+  public void localeAdjustCodeInterceptor() throws Exception {
+    assertThat(adjust("EN")).isEqualTo("en");
+    assertThat(adjust("he")).isEqualTo("iw");
+    assertThat(adjust("yi")).isEqualTo("ji");
+    assertThat(adjust("ja")).isEqualTo("ja");
+  }
+
+  private Object adjust(String languageCode) {
+    return AndroidInterceptors.LocaleAdjustLanguageCodeInterceptor.adjustLanguageCode(languageCode);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/BuckManifestFactoryTest.java b/robolectric/src/test/java/org/robolectric/internal/BuckManifestFactoryTest.java
new file mode 100644
index 0000000..f7cd3ed
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/BuckManifestFactoryTest.java
@@ -0,0 +1,102 @@
+package org.robolectric.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.io.Files;
+import java.io.File;
+import java.nio.file.Paths;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.res.ResourcePath;
+
+@RunWith(JUnit4.class)
+public class BuckManifestFactoryTest {
+
+  @Rule
+  public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private Config.Builder configBuilder;
+  private BuckManifestFactory buckManifestFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    configBuilder = Config.Builder.defaults().setPackageName("com.robolectric.buck");
+    System.setProperty("buck.robolectric_manifest", "buck/AndroidManifest.xml");
+    buckManifestFactory = new BuckManifestFactory();
+  }
+
+  @After
+  public void tearDown() {
+    System.clearProperty("buck.robolectric_manifest");
+    System.clearProperty("buck.robolectric_res_directories");
+    System.clearProperty("buck.robolectric_assets_directories");
+  }
+
+  @Test public void identify() throws Exception {
+    ManifestIdentifier manifestIdentifier = buckManifestFactory.identify(configBuilder.build());
+    assertThat(manifestIdentifier.getManifestFile())
+        .isEqualTo(Paths.get("buck/AndroidManifest.xml"));
+    assertThat(manifestIdentifier.getPackageName())
+        .isEqualTo("com.robolectric.buck");
+  }
+
+  @Test public void multiple_res_dirs() throws Exception {
+    System.setProperty("buck.robolectric_res_directories",
+        "buck/res1" + File.pathSeparator + "buck/res2");
+    System.setProperty("buck.robolectric_assets_directories",
+        "buck/assets1" + File.pathSeparator + "buck/assets2");
+
+    ManifestIdentifier manifestIdentifier = buckManifestFactory.identify(configBuilder.build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(manifestIdentifier);
+    assertThat(manifest.getResDirectory()).isEqualTo(Paths.get("buck/res2"));
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("buck/assets2"));
+
+    List<ResourcePath> resourcePathList = manifest.getIncludedResourcePaths();
+    assertThat(resourcePathList.size()).isEqualTo(3);
+    assertThat(resourcePathList)
+        .containsExactly(
+            new ResourcePath(
+                manifest.getRClass(), Paths.get("buck/res2"), Paths.get("buck/assets2")),
+            new ResourcePath(manifest.getRClass(), Paths.get("buck/res1"), null),
+            new ResourcePath(manifest.getRClass(), null, Paths.get("buck/assets1")));
+  }
+
+  @Test public void pass_multiple_res_dirs_in_file() throws Exception {
+    String resDirectoriesFileName = "res-directories";
+    File resDirectoriesFile = tempFolder.newFile(resDirectoriesFileName);
+    Files.asCharSink(resDirectoriesFile, UTF_8).write("buck/res1\nbuck/res2");
+    System.setProperty(
+        "buck.robolectric_res_directories", "@" + resDirectoriesFile.getAbsolutePath());
+
+    String assetDirectoriesFileName = "asset-directories";
+    File assetDirectoriesFile = tempFolder.newFile(assetDirectoriesFileName);
+    Files.asCharSink(assetDirectoriesFile, UTF_8).write("buck/assets1\nbuck/assets2");
+    System.setProperty(
+        "buck.robolectric_assets_directories", "@" + assetDirectoriesFile.getAbsolutePath());
+
+    ManifestIdentifier manifestIdentifier = buckManifestFactory.identify(configBuilder.build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(manifestIdentifier);
+    assertThat(manifest.getResDirectory()).isEqualTo(Paths.get("buck/res2"));
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("buck/assets2"));
+
+    List<ResourcePath> resourcePathList = manifest.getIncludedResourcePaths();
+    assertThat(resourcePathList.size()).isEqualTo(3);
+    assertThat(resourcePathList)
+        .containsExactly(
+            new ResourcePath(
+                manifest.getRClass(), Paths.get("buck/res2"), Paths.get("buck/assets2")),
+            new ResourcePath(manifest.getRClass(), Paths.get("buck/res1"), null),
+            new ResourcePath(manifest.getRClass(), null, Paths.get("buck/assets1")));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/DefaultManifestFactoryTest.java b/robolectric/src/test/java/org/robolectric/internal/DefaultManifestFactoryTest.java
new file mode 100644
index 0000000..c32dc19
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/DefaultManifestFactoryTest.java
@@ -0,0 +1,106 @@
+package org.robolectric.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import java.nio.file.Paths;
+import java.util.Properties;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.manifest.AndroidManifest;
+
+@RunWith(JUnit4.class)
+public class DefaultManifestFactoryTest {
+
+  @Test
+  public void identify() {
+    Properties properties = new Properties();
+    properties.put("android_merged_manifest", "gradle/AndroidManifest.xml");
+    properties.put("android_merged_resources", "gradle/res");
+    properties.put("android_merged_assets", "gradle/assets");
+    DefaultManifestFactory factory = new DefaultManifestFactory(properties);
+    ManifestIdentifier identifier = factory.identify(Config.Builder.defaults().build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(identifier);
+
+    assertThat(manifest.getAndroidManifestFile())
+        .isEqualTo(Paths.get("gradle/AndroidManifest.xml"));
+    assertThat(manifest.getResDirectory()).isEqualTo(Paths.get("gradle/res"));
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("gradle/assets"));
+    assertThat(manifest.getApkFile()).isNull();
+  }
+
+  @Test
+  public void identify_withResourceApk() {
+    Properties properties = new Properties();
+    properties.put("android_merged_manifest", "gradle/AndroidManifest.xml");
+    properties.put("android_merged_resources", "gradle/res");
+    properties.put("android_merged_assets", "gradle/assets");
+    properties.put("android_resource_apk", "gradle/resources.ap_");
+    DefaultManifestFactory factory = new DefaultManifestFactory(properties);
+    ManifestIdentifier identifier = factory.identify(Config.Builder.defaults().build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(identifier);
+
+    assertThat(manifest.getAndroidManifestFile())
+        .isEqualTo(Paths.get("gradle/AndroidManifest.xml"));
+    assertThat(manifest.getResDirectory()).isEqualTo(Paths.get("gradle/res"));
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("gradle/assets"));
+    assertThat(manifest.getApkFile()).isEqualTo(Paths.get("gradle/resources.ap_"));
+  }
+
+  @Test
+  public void identify_withMissingValues() {
+    Properties properties = new Properties();
+    properties.put("android_merged_manifest", "");
+    properties.put("android_merged_assets", "gradle/assets");
+    properties.put("android_resource_apk", "gradle/resources.ap_");
+    DefaultManifestFactory factory = new DefaultManifestFactory(properties);
+    ManifestIdentifier identifier = factory.identify(Config.Builder.defaults().build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(identifier);
+
+    assertThat(manifest.getAndroidManifestFile()).isNull();
+    assertThat(manifest.getResDirectory()).isNull();
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("gradle/assets"));
+    assertThat(manifest.getApkFile()).isEqualTo(Paths.get("gradle/resources.ap_"));
+  }
+
+  @Test
+  public void identify_configNoneShouldBeIgnored() throws Exception {
+    Properties properties = new Properties();
+    properties.put("android_merged_manifest", "gradle/AndroidManifest.xml");
+    properties.put("android_merged_resources", "gradle/res");
+    properties.put("android_merged_assets", "gradle/assets");
+    properties.put("android_custom_package", "com.example.app");
+    DefaultManifestFactory factory = new DefaultManifestFactory(properties);
+    ManifestIdentifier identifier =
+        factory.identify(Config.Builder.defaults().setManifest(Config.NONE).build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(identifier);
+
+    assertThat(manifest.getAndroidManifestFile())
+        .isEqualTo(Paths.get("gradle/AndroidManifest.xml"));
+    assertThat(manifest.getResDirectory()).isEqualTo(Paths.get("gradle/res"));
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("gradle/assets"));
+    assertThat(manifest.getRClassName()).isEqualTo("com.example.app.R");
+  }
+
+  @Test
+  public void identify_packageCanBeOverridenFromConfig() throws Exception {
+    Properties properties = new Properties();
+    properties.put("android_merged_manifest", "gradle/AndroidManifest.xml");
+    properties.put("android_merged_resources", "gradle/res");
+    properties.put("android_merged_assets", "gradle/assets");
+    DefaultManifestFactory factory = new DefaultManifestFactory(properties);
+    ManifestIdentifier identifier =
+        factory.identify(Config.Builder.defaults().setPackageName("overridden.package").build());
+    AndroidManifest manifest = RobolectricTestRunner.createAndroidManifest(identifier);
+
+    assertThat(manifest.getAndroidManifestFile())
+        .isEqualTo(Paths.get("gradle/AndroidManifest.xml"));
+    assertThat(manifest.getResDirectory()).isEqualTo(Paths.get("gradle/res"));
+    assertThat(manifest.getAssetsDirectory()).isEqualTo(Paths.get("gradle/assets"));
+    assertThat(manifest.getRClassName())
+        .isEqualTo("overridden.package.R");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/MavenManifestFactoryTest.java b/robolectric/src/test/java/org/robolectric/internal/MavenManifestFactoryTest.java
new file mode 100755
index 0000000..414fdb0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/MavenManifestFactoryTest.java
@@ -0,0 +1,58 @@
+package org.robolectric.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.Config;
+
+@RunWith(JUnit4.class)
+public class MavenManifestFactoryTest {
+
+  private Config.Builder configBuilder;
+  private MyMavenManifestFactory myMavenManifestFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    configBuilder = Config.Builder.defaults().setManifest("DifferentManifest.xml");
+    myMavenManifestFactory = new MyMavenManifestFactory();
+  }
+
+  @Test public void identify() throws Exception {
+    ManifestIdentifier manifestIdentifier = myMavenManifestFactory.identify(configBuilder.build());
+    assertThat(manifestIdentifier.getManifestFile())
+        .isEqualTo(Paths.get("_fakefs_path").resolve("to").resolve("DifferentManifest.xml"));
+    assertThat(manifestIdentifier.getResDir()).isEqualTo(Paths.get("_fakefs_path/to/res"));
+  }
+
+  @Test public void withDotSlashManifest_identify() throws Exception {
+    configBuilder.setManifest("./DifferentManifest.xml");
+
+    ManifestIdentifier manifestIdentifier = myMavenManifestFactory.identify(configBuilder.build());
+    assertThat(manifestIdentifier.getManifestFile().normalize())
+        .isEqualTo(Paths.get("_fakefs_path/to/DifferentManifest.xml"));
+    assertThat(manifestIdentifier.getResDir().normalize())
+        .isEqualTo(Paths.get("_fakefs_path/to/res"));
+  }
+
+  @Test public void withDotDotSlashManifest_identify() throws Exception {
+    configBuilder.setManifest("../DifferentManifest.xml");
+
+    ManifestIdentifier manifestIdentifier = myMavenManifestFactory.identify(configBuilder.build());
+    assertThat(manifestIdentifier.getManifestFile())
+        .isEqualTo(Paths.get("_fakefs_path/to/../DifferentManifest.xml"));
+    assertThat(manifestIdentifier.getResDir()).isEqualTo(Paths.get("_fakefs_path/to/../res"));
+  }
+
+  private static class MyMavenManifestFactory extends MavenManifestFactory {
+    @Override
+    Path getBaseDir() {
+      return Paths.get("_fakefs_path").resolve("to");
+    }
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/internal/bytecode/InstrumentationConfigurationTest.java b/robolectric/src/test/java/org/robolectric/internal/bytecode/InstrumentationConfigurationTest.java
new file mode 100644
index 0000000..d2a6529
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/bytecode/InstrumentationConfigurationTest.java
@@ -0,0 +1,188 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.config.AndroidConfigurer;
+import org.robolectric.interceptors.AndroidInterceptors;
+
+@RunWith(JUnit4.class)
+public class InstrumentationConfigurationTest {
+  private InstrumentationConfiguration config;
+
+  @Before
+  public void setUp() throws Exception {
+    InstrumentationConfiguration.Builder builder = InstrumentationConfiguration.newBuilder();
+    new AndroidConfigurer(new ShadowProviders(Collections.emptyList()))
+        .configure(builder, new Interceptors(AndroidInterceptors.all()));
+    config = builder.build();
+  }
+
+  @Test
+  public void shouldNotInstrumentAndroidAppClasses() throws Exception {
+    assertThat(config.shouldInstrument(wrap("com.google.android.apps.Foo"))).isFalse();
+  }
+
+  @Test
+  public void shouldInstrumentDalvikClasses() {
+    assertThat(config.shouldInstrument(wrap("dalvik.system.DexFile"))).isTrue();
+  }
+
+  @Test
+  public void shouldNotInstrumentCoreJdkClasses() throws Exception {
+    assertThat(config.shouldInstrument(wrap("java.lang.Object"))).isFalse();
+    assertThat(config.shouldInstrument(wrap("java.lang.String"))).isFalse();
+  }
+
+  @Test
+  public void shouldInstrumentAndroidCoreClasses() throws Exception {
+    assertThat(config.shouldInstrument(wrap("android.content.Intent"))).isTrue();
+    assertThat(config.shouldInstrument(wrap("android.and.now.for.something.completely.different")))
+        .isTrue();
+  }
+
+  @Test
+  public void shouldInstrumentOrgApacheHttpClasses() {
+    assertThat(config.shouldInstrument(wrap("org.apache.http.util.CharArrayBuffer"))).isTrue();
+  }
+
+  @Test
+  public void shouldInstrumentOrgKxmlClasses() {
+    assertThat(config.shouldInstrument(wrap("org.kxml2.io.KXmlParser"))).isTrue();
+  }
+
+  @Test
+  public void shouldAcquireAndroidRClasses() throws Exception {
+    assertThat(config.shouldAcquire("android.Rfoo")).isTrue();
+    assertThat(config.shouldAcquire("android.fooR")).isTrue();
+    assertThat(config.shouldAcquire("android.R")).isTrue();
+    assertThat(config.shouldAcquire("android.R$anything")).isTrue();
+    assertThat(config.shouldAcquire("android.R$anything$else")).isTrue();
+  }
+
+  @Test
+  public void shouldNotAcquireRClasses() throws Exception {
+    assertThat(config.shouldAcquire("com.whatever.Rfoo")).isTrue();
+    assertThat(config.shouldAcquire("com.whatever.fooR")).isTrue();
+    assertThat(config.shouldAcquire("com.whatever.R")).isFalse();
+    assertThat(config.shouldAcquire("com.whatever.R$anything")).isFalse();
+    assertThat(config.shouldAcquire("com.whatever.R$anything$else")).isTrue();
+  }
+
+  @Test
+  public void shouldNotAcquireExcludedPackages() throws Exception {
+    assertThat(config.shouldAcquire("scala.Test")).isFalse();
+    assertThat(config.shouldAcquire("scala.util.Test")).isFalse();
+    assertThat(config.shouldAcquire("org.specs2.whatever.foo")).isFalse();
+    assertThat(config.shouldAcquire("com.almworks.sqlite4java.whatever.Cls$anything$else"))
+        .isFalse();
+  }
+
+  @Test
+  public void shouldNotAcquireShadowClass() throws Exception {
+    assertThat(config.shouldAcquire("org.robolectric.shadow.api.Shadow")).isTrue();
+  }
+
+  @Test
+  public void shouldAcquireDistinguishedNameParser_Issue1864() throws Exception {
+    assertThat(config.shouldAcquire("javax.net.ssl.DistinguishedNameParser")).isTrue();
+  }
+
+  @Test
+  public void shouldAcquireOpenglesGL_Issue2960() throws Exception {
+    assertThat(config.shouldAcquire("javax.microedition.khronos.opengles.GL")).isTrue();
+  }
+
+  @Test
+  public void shouldInstrumentCustomClasses() throws Exception {
+    String instrumentName = "com.whatever.SomeClassNameToInstrument";
+    String notInstrumentName = "com.whatever.DoNotInstrumentMe";
+    InstrumentationConfiguration customConfig = InstrumentationConfiguration.newBuilder().addInstrumentedClass(instrumentName).build();
+    assertThat(customConfig.shouldInstrument(wrap(instrumentName))).isTrue();
+    assertThat(customConfig.shouldInstrument(wrap(notInstrumentName))).isFalse();
+  }
+
+  @Test
+  public void equals_ShouldCheckClassNames() throws Exception {
+    String instrumentName = "com.whatever.SomeClassNameToInstrument";
+    InstrumentationConfiguration baseConfig = InstrumentationConfiguration.newBuilder().build();
+    InstrumentationConfiguration customConfig = InstrumentationConfiguration.newBuilder().addInstrumentedClass(instrumentName).build();
+
+    assertThat(baseConfig).isNotEqualTo(customConfig);
+  }
+
+  @Test
+  public void shouldNotInstrumentListedClasses() throws Exception {
+    String instrumentName = "android.foo.bar";
+    InstrumentationConfiguration customConfig = InstrumentationConfiguration.newBuilder().doNotInstrumentClass(instrumentName).build();
+
+    assertThat(customConfig.shouldInstrument(wrap(instrumentName))).isFalse();
+  }
+
+  @Test
+  public void shouldNotInstrumentPackages() throws Exception {
+    String includedClass = "android.foo.Bar";
+    String excludedClass = "androidx.test.foo.Bar";
+    InstrumentationConfiguration customConfig =
+        InstrumentationConfiguration.newBuilder()
+            .addInstrumentedPackage("android.")
+            .doNotInstrumentPackage("androidx.test.")
+            .build();
+
+    assertThat(customConfig.shouldInstrument(wrap(includedClass))).isTrue();
+    assertThat(customConfig.shouldInstrument(wrap(excludedClass))).isFalse();
+  }
+
+  @Test
+  public void shouldNotInstrumentClassNamesWithNullRegex() throws Exception {
+    InstrumentationConfiguration customConfig =
+        InstrumentationConfiguration.newBuilder()
+            .addInstrumentedPackage("com.random")
+            .setDoNotInstrumentClassRegex(null)
+            .build();
+
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass"))).isTrue();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass_Delegate"))).isTrue();
+  }
+
+  @Test
+  public void shouldNotInstrumentClassNamesWithRegex() throws Exception {
+    InstrumentationConfiguration customConfig =
+        InstrumentationConfiguration.newBuilder()
+            .addInstrumentedPackage("com.random")
+            .setDoNotInstrumentClassRegex(".*_Delegate")
+            .build();
+
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass"))).isTrue();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass_Delegate"))).isFalse();
+  }
+
+  @Test
+  public void shouldNotInstrumentClassNamesWithMultiRegex() throws Exception {
+    InstrumentationConfiguration customConfig =
+        InstrumentationConfiguration.newBuilder()
+            .addInstrumentedPackage("com.random")
+            .setDoNotInstrumentClassRegex(".*_Delegate|.*_BadThings|com\\.random\\.badpackage.*")
+            .build();
+
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass"))).isTrue();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass_Delegate"))).isFalse();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass_BadThings"))).isFalse();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.testclass_GoodThings"))).isTrue();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.badpackage.testclass"))).isFalse();
+    assertThat(customConfig.shouldInstrument(wrap("com.random.goodpackage.testclass"))).isTrue();
+
+  }
+
+  private static ClassDetails wrap(final String className) {
+    ClassDetails info = mock(ClassDetails.class);
+    when(info.getName()).thenReturn(className);
+    return info;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/bytecode/InvocationProfileTest.java b/robolectric/src/test/java/org/robolectric/internal/bytecode/InvocationProfileTest.java
new file mode 100644
index 0000000..9728e3a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/bytecode/InvocationProfileTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.View;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class InvocationProfileTest {
+  @Test
+  public void shouldConvertFromMethodSignature() throws Exception {
+    InvocationProfile profile = new InvocationProfile("android/view/View/invalidate()V", false, getClass().getClassLoader());
+    assertThat(profile.clazz).isEqualTo(View.class);
+    assertThat(profile.methodName).isEqualTo("invalidate");
+    assertThat(profile.isStatic).isEqualTo(false);
+    assertThat(profile.paramTypes).isEmpty();
+  }
+
+  @Test
+  public void shouldHandleParamTypes() throws Exception {
+    InvocationProfile profile = new InvocationProfile("android/view/View/invalidate(I[ZLjava/lang/String;)Lwhatever/Foo;", false, getClass().getClassLoader());
+    assertThat(profile.clazz).isEqualTo(View.class);
+    assertThat(profile.methodName).isEqualTo("invalidate");
+    assertThat(profile.isStatic).isEqualTo(false);
+    assertThat(profile.paramTypes).asList().containsExactly("int", "boolean[]", "java.lang.String");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/bytecode/MethodSignatureTest.java b/robolectric/src/test/java/org/robolectric/internal/bytecode/MethodSignatureTest.java
new file mode 100644
index 0000000..63e98b6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/bytecode/MethodSignatureTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class MethodSignatureTest {
+
+  @Test
+  public void parse_shouldHandlePrimitiveReturnTypes() {
+    final MethodSignature signature = MethodSignature.parse("java/lang/Long/foo(Ljava/lang/Integer;)Z");
+    assertThat(signature.className).isEqualTo("java.lang.Long");
+    assertThat(signature.methodName).isEqualTo("foo");
+    assertThat(signature.paramTypes).asList().contains("java.lang.Integer");
+    assertThat(signature.returnType).isEqualTo("boolean");
+  }
+
+  @Test
+  public void parse_shouldHandleObjectReturnTypes() {
+    final MethodSignature signature = MethodSignature.parse("java/lang/Long/foo(Ljava/lang/Integer;)Ljava/lang/Long;");
+    assertThat(signature.className).isEqualTo("java.lang.Long");
+    assertThat(signature.methodName).isEqualTo("foo");
+    assertThat(signature.paramTypes).asList().contains("java.lang.Integer");
+    assertThat(signature.returnType).isEqualTo("java.lang.Long");
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/internal/bytecode/ShadowMapTest.java b/robolectric/src/test/java/org/robolectric/internal/bytecode/ShadowMapTest.java
new file mode 100644
index 0000000..2e20119
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/bytecode/ShadowMapTest.java
@@ -0,0 +1,161 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.android.AndroidSdkShadowMatcher;
+import org.robolectric.annotation.Implements;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.shadows.ShadowActivity;
+
+@RunWith(JUnit4.class)
+public class ShadowMapTest {
+
+  private static final String A = A.class.getName();
+  private static final String A1 = A1.class.getName();
+  private static final String A2 = A2.class.getName();
+  private static final String B = B.class.getName();
+  private static final String B1 = B1.class.getName();
+  private static final String B2 = B2.class.getName();
+  private static final String C1 = C1.class.getName();
+  private static final String C2 = C2.class.getName();
+  private static final String C3 = C3.class.getName();
+  private static final String X = X.class.getName();
+
+  private ShadowMap baseShadowMap;
+
+  @Before
+  public void setUp() throws Exception {
+    List<ShadowProvider> shadowProviders =
+        Collections.singletonList(
+            new ShadowProvider() {
+              @Override
+              public void reset() {}
+
+              @Override
+              public String[] getProvidedPackageNames() {
+                return new String[0];
+              }
+
+              @Override
+              public List<Map.Entry<String, String>> getShadows() {
+                return Collections.emptyList();
+              }
+            });
+    baseShadowMap = ShadowMap.createFromShadowProviders(shadowProviders);
+  }
+
+  @Test public void shouldLookUpShadowClassesByNamingConvention() throws Exception {
+    ShadowMap map = baseShadowMap.newBuilder().build();
+    assertThat(map.getShadowInfo(Activity.class, ShadowMatcher.MATCH_ALL)).isNull();
+  }
+
+  @Test public void shouldNotReturnMismatchedClassesJustBecauseTheSimpleNameMatches() throws Exception {
+    ShadowMap map = baseShadowMap.newBuilder()
+        .addShadowClasses(ShadowActivity.class)
+        .build();
+    assertThat(map.getShadowInfo(android.app.Activity.class, ShadowMatcher.MATCH_ALL).shadowClassName)
+        .isEqualTo(ShadowActivity.class.getName());
+  }
+
+  @Test public void getInvalidatedClasses_disjoin() {
+    ShadowMap current = baseShadowMap.newBuilder().addShadowClass(A1, A2, true, false).build();
+    ShadowMap previous = baseShadowMap.newBuilder().addShadowClass(B1, B2, true, false).build();
+
+    assertThat(current.getInvalidatedClasses(previous)).containsExactly(A1, B1);
+  }
+
+  @Test public void getInvalidatedClasses_overlap() {
+    ShadowMap current =
+        baseShadowMap
+            .newBuilder()
+            .addShadowClass(A1, A2, true, false)
+            .addShadowClass(C1, C2, true, false)
+            .build();
+    ShadowMap previous =
+        baseShadowMap
+            .newBuilder()
+            .addShadowClass(A1, A2, true, false)
+            .addShadowClass(C1, C3, true, false)
+            .build();
+
+    assertThat(current.getInvalidatedClasses(previous)).containsExactly(C1);
+  }
+
+  @Test public void equalsHashCode() throws Exception {
+    ShadowMap a = baseShadowMap.newBuilder().addShadowClass(A, B, true, false).build();
+    ShadowMap b = baseShadowMap.newBuilder().addShadowClass(A, B, true, false).build();
+    assertThat(a).isEqualTo(b);
+    assertThat(a.hashCode()).isEqualTo(b.hashCode());
+
+    ShadowMap c = b.newBuilder().build();
+    assertThat(c).isEqualTo(b);
+    assertThat(c.hashCode()).isEqualTo(b.hashCode());
+
+    ShadowMap d = baseShadowMap.newBuilder().addShadowClass(A, X, true, false).build();
+    assertThat(d).isNotEqualTo(a);
+    assertThat(d.hashCode()).isNotEqualTo(b.hashCode());
+  }
+
+  @Test
+  public void builtinShadowsForSameClass_differentSdkVersions() {
+    ImmutableMultimap<String, String> builtinShadows =
+        ImmutableMultimap.of(
+            Activity.class.getCanonicalName(),
+            ShadowActivity29.class.getName(),
+            Activity.class.getCanonicalName(),
+            ShadowActivity30.class.getName());
+    ImmutableList<ShadowProvider> shadowProviders =
+        ImmutableList.of(
+            new ShadowProvider() {
+              @Override
+              public void reset() {}
+
+              @Override
+              public String[] getProvidedPackageNames() {
+                return new String[0];
+              }
+
+              @Override
+              public List<Map.Entry<String, String>> getShadows() {
+                return new ArrayList<>(builtinShadows.entries());
+              }
+            });
+    ShadowMatcher sdk29 = new AndroidSdkShadowMatcher(29);
+    ShadowMatcher sdk30 = new AndroidSdkShadowMatcher(30);
+    ShadowMap map = ShadowMap.createFromShadowProviders(shadowProviders);
+    assertThat(map.getShadowInfo(Activity.class, sdk29).shadowClassName)
+        .isEqualTo(ShadowActivity29.class.getName());
+    assertThat(map.getShadowInfo(Activity.class, sdk30).shadowClassName)
+        .isEqualTo(ShadowActivity30.class.getName());
+  }
+
+  static class Activity {}
+
+  static class A {}
+  static class A1 {}
+  static class A2 {}
+  static class B {}
+  static class B1 {}
+  static class B2 {}
+  static class C1 {}
+  static class C2 {}
+  static class C3 {}
+  static class X {}
+
+  @Implements(value = Activity.class, maxSdk = 29)
+  static class ShadowActivity29 {}
+
+  @Implements(value = Activity.class, minSdk = 30)
+  static class ShadowActivity30 {}
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/bytecode/ShadowWranglerUnitTest.java b/robolectric/src/test/java/org/robolectric/internal/bytecode/ShadowWranglerUnitTest.java
new file mode 100644
index 0000000..bbc3596
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/bytecode/ShadowWranglerUnitTest.java
@@ -0,0 +1,142 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.android.AndroidSdkShadowMatcher;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.interceptors.AndroidInterceptors;
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.util.Function;
+
+@SuppressWarnings("unchecked")
+@RunWith(JUnit4.class)
+public class ShadowWranglerUnitTest {
+  private ShadowWrangler shadowWrangler;
+  private Interceptors interceptors;
+  private ShadowMatcher sdk18 = new AndroidSdkShadowMatcher(18);
+  private ShadowMatcher sdk19 = new AndroidSdkShadowMatcher(19);
+  private ShadowMatcher sdk20 = new AndroidSdkShadowMatcher(20);
+  private ShadowMatcher sdk21 = new AndroidSdkShadowMatcher(21);
+  private ShadowMatcher sdk22 = new AndroidSdkShadowMatcher(22);
+  private ShadowMatcher sdk23 = new AndroidSdkShadowMatcher(23);
+
+  @Before
+  public void setup() throws Exception {
+    interceptors = new Interceptors(AndroidInterceptors.all());
+    shadowWrangler = new ShadowWrangler(ShadowMap.EMPTY, sdk23, interceptors);
+  }
+
+  @Test
+  public void getInterceptionHandler_whenCallIsNotRecognized_shouldReturnDoNothingHandler()
+      throws Exception {
+    MethodSignature methodSignature = MethodSignature.parse("java/lang/Object/unknownMethod()V");
+    Function<Object, Object> handler = interceptors.getInterceptionHandler(methodSignature);
+
+    assertThat(handler.call(null, null, new Object[0])).isNull();
+  }
+
+  @Test
+  public void
+      getInterceptionHandler_whenInterceptingElderOnLinkedHashMap_shouldReturnNonDoNothingHandler()
+          throws Exception {
+    MethodSignature methodSignature =
+        MethodSignature.parse("java/util/LinkedHashMap/eldest()Ljava/lang/Object;");
+    Function<Object, Object> handler = interceptors.getInterceptionHandler(methodSignature);
+
+    assertThat(handler).isNotSameInstanceAs(ShadowWrangler.DO_NOTHING_HANDLER);
+  }
+
+  @Test
+  public void intercept_elderOnLinkedHashMapHandler_shouldReturnEldestMemberOfLinkedHashMap()
+      throws Throwable {
+    LinkedHashMap<Integer, String> map = new LinkedHashMap<>(2);
+    map.put(1, "one");
+    map.put(2, "two");
+
+    Map.Entry<Integer, String> result =
+        (Map.Entry<Integer, String>)
+            shadowWrangler.intercept(
+                "java/util/LinkedHashMap/eldest()Ljava/lang/Object;", map, null, getClass());
+
+    Map.Entry<Integer, String> eldestMember = map.entrySet().iterator().next();
+    assertThat(result).isEqualTo(eldestMember);
+    assertThat(result.getKey()).isEqualTo(1);
+    assertThat(result.getValue()).isEqualTo("one");
+  }
+
+  @Test
+  public void intercept_elderOnLinkedHashMapHandler_shouldReturnNullForEmptyMap() throws Throwable {
+    LinkedHashMap<Integer, String> map = new LinkedHashMap<>();
+
+    Map.Entry<Integer, String> result =
+        (Map.Entry<Integer, String>)
+            shadowWrangler.intercept(
+                "java/util/LinkedHashMap/eldest()Ljava/lang/Object;", map, null, getClass());
+
+    assertThat(result).isNull();
+  }
+
+  @Test
+  public void whenChildShadowHasNarrowerSdk_createShadowFor_shouldReturnSuperShadowSometimes()
+      throws Exception {
+    ShadowMap shadowMap =
+        new ShadowMap.Builder()
+            .addShadowClasses(ShadowDummyClass.class, ShadowChildOfDummyClass.class)
+            .build();
+    assertThat(
+            new ShadowWrangler(shadowMap, sdk18, interceptors)
+                .createShadowFor(new ChildOfDummyClass()))
+        .isSameInstanceAs(ShadowWrangler.NO_SHADOW);
+    assertThat(
+            new ShadowWrangler(shadowMap, sdk19, interceptors)
+                .createShadowFor(new ChildOfDummyClass()))
+        .isInstanceOf(ShadowDummyClass.class);
+    assertThat(
+            new ShadowWrangler(shadowMap, sdk20, interceptors)
+                .createShadowFor(new ChildOfDummyClass()))
+        .isInstanceOf(ShadowChildOfDummyClass.class);
+    assertThat(
+            new ShadowWrangler(shadowMap, sdk21, interceptors)
+                .createShadowFor(new ChildOfDummyClass()))
+        .isInstanceOf(ShadowChildOfDummyClass.class);
+    assertThat(
+            new ShadowWrangler(shadowMap, sdk22, interceptors)
+                .createShadowFor(new ChildOfDummyClass()))
+        .isSameInstanceAs(ShadowWrangler.NO_SHADOW);
+  }
+
+  public static class DummyClass {}
+
+  @Implements(value = DummyClass.class, minSdk = 19, maxSdk = 21)
+  public static class ShadowDummyClass {
+    @Implementation(minSdk = 20, maxSdk = 20)
+    protected void __constructor__() {}
+
+    @Implementation
+    protected void methodWithoutRange() {}
+
+    @Implementation(minSdk = 20, maxSdk = 20)
+    protected void methodFor20() {}
+
+    @Implementation(minSdk = 20)
+    protected void methodMin20() {}
+
+    @Implementation(maxSdk = 20)
+    protected void methodMax20() {}
+  }
+
+  public static class ChildOfDummyClass extends DummyClass {}
+
+  @Implements(value = ChildOfDummyClass.class, minSdk = 20, maxSdk = 21)
+  public static class ShadowChildOfDummyClass {
+    @Implementation
+    protected void methodWithoutRange() {}
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/internal/dependency/PropertiesDependencyResolverTest.java b/robolectric/src/test/java/org/robolectric/internal/dependency/PropertiesDependencyResolverTest.java
new file mode 100755
index 0000000..f4562f5
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/internal/dependency/PropertiesDependencyResolverTest.java
@@ -0,0 +1,102 @@
+package org.robolectric.internal.dependency;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Writer;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class PropertiesDependencyResolverTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+  private DependencyJar exampleDep;
+  private DependencyResolver mock;
+  private boolean cColonBackslash;
+
+  @Before
+  public void setUp() throws Exception {
+    exampleDep = new DependencyJar("com.group", "example", "1.3", null);
+    mock = mock(DependencyResolver.class);
+    cColonBackslash = File.separatorChar == '\\';
+  }
+
+  @Test
+  public void whenAbsolutePathIsProvidedInProperties_shouldReturnFileUrl() throws Exception {
+    String absolutePath = cColonBackslash ? "c:\\tmp\\file.jar" : "/tmp/file.jar";
+    DependencyResolver resolver = new PropertiesDependencyResolver(
+        propsFile("com.group:example:1.3", new File(absolutePath).getAbsoluteFile()), mock);
+
+    URL url = resolver.getLocalArtifactUrl(exampleDep);
+    if (cColonBackslash) {
+      assertThat(url).isEqualTo(Paths.get("c:\\tmp\\file.jar").toUri().toURL());
+    } else {
+      assertThat(url).isEqualTo(Paths.get("/tmp/file.jar").toUri().toURL());
+    }
+  }
+
+  @Test
+  public void whenRelativePathIsProvidedInProperties_shouldReturnFileUrl() throws Exception {
+    DependencyResolver resolver = new PropertiesDependencyResolver(
+        propsFile("com.group:example:1.3", new File("path", "1")), mock);
+
+    URL url = resolver.getLocalArtifactUrl(exampleDep);
+    assertThat(url).isEqualTo(
+        temporaryFolder.getRoot().toPath().resolve("path").resolve("1").toUri().toURL());
+  }
+
+  @Test
+  public void whenMissingFromProperties_shouldDelegate() throws Exception {
+    DependencyResolver resolver = new PropertiesDependencyResolver(
+        propsFile("nothing", new File("interesting")), mock);
+
+    when(mock.getLocalArtifactUrl(exampleDep)).thenReturn(new URL("file:///path/3"));
+    URL url = resolver.getLocalArtifactUrl(exampleDep);
+    assertThat(url).isEqualTo(new URL("file:///path/3")
+    );
+  }
+
+  @Test
+  public void whenDelegateIsNull_shouldGiveGoodMessage() throws Exception {
+    DependencyResolver resolver = new PropertiesDependencyResolver(
+        propsFile("nothing", new File("interesting")), null);
+
+    try {
+      resolver.getLocalArtifactUrl(exampleDep);
+      fail("should have failed");
+    } catch (Exception e) {
+      assertThat(e.getMessage()).contains("no artifacts found for " + exampleDep);
+    }
+  }
+
+  //////////////////
+
+  private Path propsFile(String key, File value) throws IOException {
+    Properties properties = new Properties();
+    properties.setProperty(key, value.toString());
+    return propsFile(properties);
+  }
+
+  private Path propsFile(Properties contents) throws IOException {
+    File file = temporaryFolder.newFile("file.properties");
+    try (Writer out = Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8)) {
+      contents.store(out, "for tests");
+    }
+    return file.toPath();
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/json/JSONArrayTest.java b/robolectric/src/test/java/org/robolectric/json/JSONArrayTest.java
new file mode 100644
index 0000000..d6df95a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/json/JSONArrayTest.java
@@ -0,0 +1,18 @@
+package org.robolectric.json;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import org.json.JSONArray;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class JSONArrayTest {
+  @Test
+  public void testEquality() throws Exception {
+    JSONArray array = new JSONArray(Arrays.asList("a", "b"));
+    assertThat(array).isEqualTo(new JSONArray(array.toString()));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/junit/rules/BackgroundTestRuleTest.java b/robolectric/src/test/java/org/robolectric/junit/rules/BackgroundTestRuleTest.java
new file mode 100644
index 0000000..d9f4e16
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/junit/rules/BackgroundTestRuleTest.java
@@ -0,0 +1,41 @@
+package org.robolectric.junit.rules;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.RuleChain;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link BackgroundTestRule}. */
+@RunWith(AndroidJUnit4.class)
+public final class BackgroundTestRuleTest {
+
+  private final BackgroundTestRule rule = new BackgroundTestRule();
+  private final ExpectedException expectedException = ExpectedException.none();
+
+  @Rule public RuleChain chain = RuleChain.outerRule(expectedException).around(rule);
+
+  @Test
+  @BackgroundTestRule.BackgroundTest
+  public void testRunsInBackground() throws Exception {
+    assertThat(Looper.myLooper()).isNotEqualTo(Looper.getMainLooper());
+  }
+
+  @Test
+  public void testNoAnnotation_runsOnMainThread() throws Exception {
+    assertThat(Looper.myLooper()).isEqualTo(Looper.getMainLooper());
+  }
+
+  @Test
+  @BackgroundTestRule.BackgroundTest
+  public void testFailInBackground() throws Exception {
+    Exception exception = new Exception("Fail!");
+    expectedException.expect(is(exception));
+    throw exception;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/junit/rules/CloseGuardRuleTest.java b/robolectric/src/test/java/org/robolectric/junit/rules/CloseGuardRuleTest.java
new file mode 100644
index 0000000..b01b630
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/junit/rules/CloseGuardRuleTest.java
@@ -0,0 +1,71 @@
+package org.robolectric.junit.rules;
+
+import static org.junit.Assert.assertThrows;
+
+import dalvik.system.CloseGuard;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.MultipleFailureException;
+import org.junit.runners.model.Statement;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link CloseGuardRule}. */
+@RunWith(RobolectricTestRunner.class)
+public final class CloseGuardRuleTest {
+
+  @Test
+  public void noCloseGuards_doesNotFail() throws Throwable {
+    new CloseGuardRule()
+        .apply(
+            new Statement() {
+              @Override
+              public void evaluate() {
+                // No CloseGuards used
+              }
+            },
+            Description.EMPTY)
+        .evaluate();
+  }
+
+  @Test
+  public void allCloseGuardsClosed_doesNotFail() throws Throwable {
+    new CloseGuardRule()
+        .apply(
+            new Statement() {
+              @Override
+              public void evaluate() {
+                CloseGuard closeGuard1 = CloseGuard.get();
+                CloseGuard closeGuard2 = CloseGuard.get();
+                closeGuard1.open("foo");
+                closeGuard2.open("bar");
+
+                closeGuard1.close();
+                closeGuard2.close();
+              }
+            },
+            Description.EMPTY)
+        .evaluate();
+  }
+
+  @Test
+  public void closeGuardsOpen_throwsException() {
+    assertThrows(
+        MultipleFailureException.class,
+        () ->
+            new CloseGuardRule()
+                .apply(
+                    new Statement() {
+                      @Override
+                      public void evaluate() {
+                        CloseGuard closeGuard1 = CloseGuard.get();
+                        CloseGuard closeGuard2 = CloseGuard.get();
+
+                        closeGuard1.open("foo");
+                        closeGuard2.open("bar");
+                      }
+                    },
+                    Description.EMPTY)
+                .evaluate());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/junit/rules/ExpectedLogMessagesRuleTest.java b/robolectric/src/test/java/org/robolectric/junit/rules/ExpectedLogMessagesRuleTest.java
new file mode 100644
index 0000000..44e607d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/junit/rules/ExpectedLogMessagesRuleTest.java
@@ -0,0 +1,209 @@
+package org.robolectric.junit.rules;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+
+import android.util.Log;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.RuleChain;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ExpectedLogMessagesRule}. */
+@RunWith(AndroidJUnit4.class)
+public final class ExpectedLogMessagesRuleTest {
+
+  private final ExpectedLogMessagesRule rule = new ExpectedLogMessagesRule();
+  private final ExpectedException expectedException = ExpectedException.none();
+
+  @Rule public RuleChain chain = RuleChain.outerRule(expectedException).around(rule);
+
+  @Test
+  public void testExpectErrorLogDoesNotFail() {
+    Log.e("Mytag", "What's up");
+    rule.expectLogMessage(Log.ERROR, "Mytag", "What's up");
+  }
+
+  @Test
+  public void testExpectWarnLogDoesNotFail() {
+    Log.w("Mytag", "What's up");
+    rule.expectLogMessage(Log.WARN, "Mytag", "What's up");
+  }
+
+  @Test
+  public void testAndroidExpectedLogMessagesFailsWithMessage() {
+    expectedException.expect(AssertionError.class);
+    Log.e("Mytag", "What's up");
+  }
+
+  @Test
+  public void testAndroidExpectedLogMessagesDoesNotFailWithExpected() {
+    rule.expectErrorsForTag("Mytag");
+    Log.e("Mytag", "What's up");
+  }
+
+  @Test
+  public void testNoExpectedMessageFailsTest() {
+    expectedException.expect(AssertionError.class);
+    rule.expectLogMessage(Log.ERROR, "Mytag", "What's up");
+  }
+
+  @Test
+  public void testNoExpectedTagFailsTest() {
+    expectedException.expect(AssertionError.class);
+    rule.expectErrorsForTag("Mytag");
+  }
+
+  @Test
+  public void testExpectLogMessageWithThrowable() {
+    final Throwable throwable = new Throwable("lorem ipsum");
+    Log.e("Mytag", "What's up", throwable);
+    rule.expectLogMessageWithThrowable(Log.ERROR, "Mytag", "What's up", throwable);
+  }
+
+  @Test
+  public void testExpectLogMessageWithThrowableMatcher() {
+    final IllegalArgumentException exception = new IllegalArgumentException("lorem ipsum");
+    Log.e("Mytag", "What's up", exception);
+    rule.expectLogMessageWithThrowableMatcher(
+        Log.ERROR, "Mytag", "What's up", instanceOf(IllegalArgumentException.class));
+  }
+
+  @Test
+  public void testMultipleExpectLogMessagee() {
+    final Throwable throwable = new Throwable("lorem ipsum");
+    Log.e("Mytag", "What's up", throwable);
+    Log.e("Mytag", "Message 2");
+    Log.e("Mytag", "Message 3", throwable);
+    rule.expectLogMessageWithThrowable(Log.ERROR, "Mytag", "What's up", throwable);
+    rule.expectLogMessage(Log.ERROR, "Mytag", "Message 2");
+    rule.expectLogMessage(Log.ERROR, "Mytag", "Message 3");
+  }
+
+  @Test
+  public void testExpectedTagFailureOutput() {
+    Log.e("TAG1", "message1");
+    rule.expectErrorsForTag("TAG1");
+    rule.expectErrorsForTag("TAG3"); // Not logged
+
+    expectedException.expect(
+        new TypeSafeMatcher<AssertionError>() {
+          @Override
+          protected boolean matchesSafely(AssertionError error) {
+            return error.getMessage().contains("Expected, and observed:     [TAG1]")
+                && error.getMessage().contains("Expected, but not observed: [TAG3]");
+          }
+
+          @Override
+          public void describeTo(Description description) {
+            description.appendText("Matches ExpectedLogMessagesRule");
+          }
+        });
+  }
+
+  @Test
+  public void testExpectedLogMessageFailureOutput() {
+    Log.e("Mytag", "message1");
+    Log.e("Mytag", "message2"); // Not expected
+    rule.expectLogMessage(Log.ERROR, "Mytag", "message1");
+    rule.expectLogMessage(Log.ERROR, "Mytag", "message3"); // Not logged
+
+    expectedException.expect(
+        new TypeSafeMatcher<AssertionError>() {
+          @Override
+          protected boolean matchesSafely(AssertionError error) {
+            return error
+                    .getMessage()
+                    .matches(
+                        "[\\s\\S]*Expected, and observed:\\s+\\[LogItem\\{"
+                            + "\\s+timeString='.+'"
+                            + "\\s+type=6"
+                            + "\\s+tag='Mytag'"
+                            + "\\s+msg='message1'"
+                            + "\\s+throwable=null"
+                            + "\\s+}]"
+                            + "[\\s\\S]*")
+                && error
+                    .getMessage()
+                    .matches(
+                        "[\\s\\S]*Observed, but not expected:\\s+\\[LogItem\\{"
+                            + "\\s+timeString='.+'"
+                            + "\\s+type=6"
+                            + "\\s+tag='Mytag'"
+                            + "\\s+msg='message2'"
+                            + "\\s+throwable=null"
+                            + "\\s+}][\\s\\S]*")
+                && error
+                    .getMessage()
+                    .matches(
+                        "[\\s\\S]*Expected, but not observed: \\[ExpectedLogItem\\{timeString='.+',"
+                            + " type=6, tag='Mytag', msg='message3'}]"
+                            + "[\\s\\S]*");
+          }
+
+          @Override
+          public void describeTo(Description description) {
+            description.appendText("Matches ExpectedLogMessagesRule");
+          }
+        });
+  }
+
+  @Test
+  public void testExpectedLogMessageWithMatcherFailureOutput() {
+    Log.e("Mytag", "message1");
+    Log.e("Mytag", "message2", new IllegalArgumentException()); // Not expected
+    rule.expectLogMessage(Log.ERROR, "Mytag", "message1");
+    rule.expectLogMessageWithThrowableMatcher(
+        Log.ERROR,
+        "Mytag",
+        "message2",
+        instanceOf(UnsupportedOperationException.class)); // Not logged
+
+    String expectedAndObservedPattern =
+        "[\\s\\S]*Expected, and observed:\\s+\\[LogItem\\{"
+            + "\\s+timeString='.+'"
+            + "\\s+type=6"
+            + "\\s+tag='Mytag'"
+            + "\\s+msg='message1'"
+            + "\\s+throwable=null"
+            + "\\s+}]"
+            + "[\\s\\S]*";
+    String observedAndNotExpectedPattern =
+        "[\\s\\S]*Observed, but not expected:\\s+\\[LogItem\\{"
+            + "\\s+timeString='.+'"
+            + "\\s+type=6"
+            + "\\s+tag='Mytag'"
+            + "\\s+msg='message2'"
+            + "\\s+throwable=java.lang.IllegalArgumentException"
+            + "(\\s+at .*\\)\\n)+"
+            + "\\s+}][\\s\\S]*";
+    String expectedNotObservedPattern =
+        "[\\s\\S]*Expected, but not observed:"
+            + " \\[ExpectedLogItem\\{timeString='.+',"
+            + " type=6, tag='Mytag', msg='message2', throwable="
+            + ".*UnsupportedOperationException.*}][\\s\\S]*";
+    expectedException.expect(
+        new TypeSafeMatcher<AssertionError>() {
+          @Override
+          protected boolean matchesSafely(AssertionError error) {
+            return error.getMessage().matches(expectedAndObservedPattern)
+                && error.getMessage().matches(observedAndNotExpectedPattern)
+                && error.getMessage().matches(expectedNotObservedPattern);
+          }
+
+          @Override
+          public void describeTo(Description description) {
+            description.appendText(
+                "Matches ExpectedLogMessagesRule:\n"
+                    + expectedAndObservedPattern
+                    + "\n"
+                    + observedAndNotExpectedPattern
+                    + "\n"
+                    + expectedNotObservedPattern);
+          }
+        });
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/lazyloadenabled/LazyApplicationClassOverrideTest.java b/robolectric/src/test/java/org/robolectric/lazyloadenabled/LazyApplicationClassOverrideTest.java
new file mode 100644
index 0000000..c08f414
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/lazyloadenabled/LazyApplicationClassOverrideTest.java
@@ -0,0 +1,23 @@
+package org.robolectric.lazyloadenabled;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.OFF;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.experimental.LazyApplication;
+
+/**
+ * Test case to make sure the application is eagerly loaded when lazy is requested at package level
+ * but eager loading requested at class level
+ */
+@LazyApplication(OFF)
+@RunWith(AndroidJUnit4.class)
+public class LazyApplicationClassOverrideTest {
+  @Test
+  public void testClassLevelOverride() {
+    assertThat(RuntimeEnvironment.application).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/lazyloadenabled/LazyApplicationPackageTest.java b/robolectric/src/test/java/org/robolectric/lazyloadenabled/LazyApplicationPackageTest.java
new file mode 100644
index 0000000..46ae042
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/lazyloadenabled/LazyApplicationPackageTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.lazyloadenabled;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.OFF;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.experimental.LazyApplication;
+
+/** Test case to make sure the application is lazily loaded when requested at the package level */
+@RunWith(AndroidJUnit4.class)
+public class LazyApplicationPackageTest {
+  @Test
+  public void testLazyLoad() {
+    assertThat(RuntimeEnvironment.application).isNull();
+    assertThat(RuntimeEnvironment.getApplication()).isNotNull();
+    assertThat(RuntimeEnvironment.application).isNotNull();
+  }
+
+  @LazyApplication(OFF)
+  @Test
+  public void testMethodLevelOverride() {
+    assertThat(RuntimeEnvironment.application).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/lazyloadenabled/package-info.java b/robolectric/src/test/java/org/robolectric/lazyloadenabled/package-info.java
new file mode 100644
index 0000000..48f1e3d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/lazyloadenabled/package-info.java
@@ -0,0 +1,5 @@
+@LazyApplication(LazyLoad.ON)
+package org.robolectric.lazyloadenabled;
+
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
diff --git a/robolectric/src/test/java/org/robolectric/manifest/AndroidManifestTest.java b/robolectric/src/test/java/org/robolectric/manifest/AndroidManifestTest.java
new file mode 100644
index 0000000..eeb1c5c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/manifest/AndroidManifestTest.java
@@ -0,0 +1,590 @@
+package org.robolectric.manifest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.util.TestUtil.resourceFile;
+
+import android.Manifest;
+import android.content.Intent;
+import android.os.Build.VERSION_CODES;
+import com.google.common.io.Files;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.Config;
+
+@RunWith(JUnit4.class)
+public class AndroidManifestTest {
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void parseManifest_shouldReadContentProviders() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithContentProviders.xml");
+
+    assertThat(config.getContentProviders().get(0).getName())
+        .isEqualTo("org.robolectric.tester.FullyQualifiedClassName");
+    assertThat(config.getContentProviders().get(0).getAuthorities())
+        .isEqualTo("org.robolectric.authority1");
+    assertThat(config.getContentProviders().get(0).isEnabled()).isTrue();
+
+    assertThat(config.getContentProviders().get(1).getName())
+        .isEqualTo("org.robolectric.tester.PartiallyQualifiedClassName");
+    assertThat(config.getContentProviders().get(1).getAuthorities())
+        .isEqualTo("org.robolectric.authority2");
+    assertThat(config.getContentProviders().get(1).isEnabled()).isFalse();
+  }
+
+  @Test
+  public void parseManifest_shouldReadPermissions() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithPermissions.xml");
+
+    assertThat(config.getPermissions().keySet())
+        .containsExactly("some_permission",
+            "permission_with_literal_label",
+            "permission_with_minimal_fields");
+    PermissionItemData permissionItemData = config.getPermissions().get("some_permission");
+    assertThat(permissionItemData.getMetaData().getValueMap())
+        .containsEntry("meta_data_name", "meta_data_value");
+    assertThat(permissionItemData.getName()).isEqualTo("some_permission");
+    assertThat(permissionItemData.getPermissionGroup()).isEqualTo("my_permission_group");
+    assertThat(permissionItemData.getDescription())
+        .isEqualTo("@string/test_permission_description");
+    assertThat(permissionItemData.getProtectionLevel()).isEqualTo("dangerous");
+  }
+
+  @Test
+  public void parseManifest_shouldReadPermissionGroups() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithPermissions.xml");
+
+    assertThat(config.getPermissionGroups().keySet())
+        .contains("permission_group");
+    PermissionGroupItemData permissionGroupItemData =
+        config.getPermissionGroups().get("permission_group");
+    assertThat(permissionGroupItemData.getName()).isEqualTo("permission_group");
+    assertThat(permissionGroupItemData.getDescription())
+        .isEqualTo("@string/test_permission_description");
+  }
+
+  @Test
+  public void parseManifest_shouldReadBroadcastReceivers() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithReceivers.xml");
+    assertThat(config.getBroadcastReceivers()).hasSize(8);
+
+    assertThat(config.getBroadcastReceivers().get(0).getName())
+        .isEqualTo("org.robolectric.ConfigTestReceiver.InnerReceiver");
+    assertThat(config.getBroadcastReceivers().get(0).getActions())
+        .containsExactly("org.robolectric.ACTION1", "org.robolectric.ACTION2");
+
+    assertThat(config.getBroadcastReceivers().get(1).getName())
+        .isEqualTo("org.robolectric.fakes.ConfigTestReceiver");
+    assertThat(config.getBroadcastReceivers().get(1).getActions())
+        .contains("org.robolectric.ACTION_SUPERSET_PACKAGE");
+
+    assertThat(config.getBroadcastReceivers().get(2).getName())
+        .isEqualTo("org.robolectric.ConfigTestReceiver");
+    assertThat(config.getBroadcastReceivers().get(2).getActions())
+        .contains("org.robolectric.ACTION_SUBSET_PACKAGE");
+
+    assertThat(config.getBroadcastReceivers().get(3).getName())
+        .isEqualTo("org.robolectric.DotConfigTestReceiver");
+    assertThat(config.getBroadcastReceivers().get(3).getActions())
+        .contains("org.robolectric.ACTION_DOT_PACKAGE");
+
+    assertThat(config.getBroadcastReceivers().get(4).getName())
+        .isEqualTo("org.robolectric.test.ConfigTestReceiver");
+    assertThat(config.getBroadcastReceivers().get(4).getActions())
+        .contains("org.robolectric.ACTION_DOT_SUBPACKAGE");
+    assertThat(config.getBroadcastReceivers().get(4).isEnabled()).isFalse();
+
+    assertThat(config.getBroadcastReceivers().get(5).getName()).isEqualTo("com.foo.Receiver");
+    assertThat(config.getBroadcastReceivers().get(5).getActions())
+        .contains("org.robolectric.ACTION_DIFFERENT_PACKAGE");
+    assertThat(config.getBroadcastReceivers().get(5).getIntentFilters()).hasSize(1);
+    IntentFilterData filter = config.getBroadcastReceivers().get(5).getIntentFilters().get(0);
+    assertThat(filter.getActions()).containsExactly("org.robolectric.ACTION_DIFFERENT_PACKAGE");
+
+    assertThat(config.getBroadcastReceivers().get(6).getName())
+        .isEqualTo("com.bar.ReceiverWithoutIntentFilter");
+    assertThat(config.getBroadcastReceivers().get(6).getActions()).isEmpty();
+
+    assertThat(config.getBroadcastReceivers().get(7).getName())
+        .isEqualTo("org.robolectric.ConfigTestReceiverPermissionsAndActions");
+    assertThat(config.getBroadcastReceivers().get(7).getActions())
+        .contains("org.robolectric.ACTION_RECEIVER_PERMISSION_PACKAGE");
+  }
+
+  @Test
+  public void parseManifest_shouldReadServices() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithServices.xml");
+    assertThat(config.getServices()).hasSize(2);
+
+    assertThat(config.getServices().get(0).getClassName()).isEqualTo("com.foo.Service");
+    assertThat(config.getServices().get(0).getActions())
+        .contains("org.robolectric.ACTION_DIFFERENT_PACKAGE");
+    assertThat(config.getServices().get(0).getIntentFilters()).isNotEmpty();
+    assertThat(config.getServices().get(0).getIntentFilters().get(0).getMimeTypes())
+        .containsExactly("image/jpeg");
+
+    assertThat(config.getServices().get(1).getClassName())
+        .isEqualTo("com.bar.ServiceWithoutIntentFilter");
+    assertThat(config.getServices().get(1).getActions()).isEmpty();
+    assertThat(config.getServices().get(1).getIntentFilters()).isEmpty();
+
+    assertThat(config.getServiceData("com.foo.Service").getClassName())
+        .isEqualTo("com.foo.Service");
+    assertThat(config.getServiceData("com.bar.ServiceWithoutIntentFilter").getClassName())
+        .isEqualTo("com.bar.ServiceWithoutIntentFilter");
+    assertThat(config.getServiceData("com.foo.Service").getPermission())
+        .isEqualTo("com.foo.Permission");
+
+    assertThat(config.getServiceData("com.foo.Service").isEnabled()).isTrue();
+    assertThat(config.getServiceData("com.bar.ServiceWithoutIntentFilter").isEnabled()).isFalse();
+  }
+
+  @Test
+  public void testManifestWithNoApplicationElement() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestNoApplicationElement.xml");
+    assertThat(config.getPackageName()).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void parseManifest_shouldReadBroadcastReceiversWithMetaData() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithReceivers.xml");
+
+    assertThat(config.getBroadcastReceivers().get(4).getName())
+        .isEqualTo("org.robolectric.test.ConfigTestReceiver");
+    assertThat(config.getBroadcastReceivers().get(4).getActions())
+        .contains("org.robolectric.ACTION_DOT_SUBPACKAGE");
+
+    Map<String, Object> meta = config.getBroadcastReceivers().get(4).getMetaData().getValueMap();
+    Object metaValue = meta.get("org.robolectric.metaName1");
+    assertThat(metaValue).isEqualTo("metaValue1");
+
+    metaValue = meta.get("org.robolectric.metaName2");
+    assertThat(metaValue).isEqualTo("metaValue2");
+
+    metaValue = meta.get("org.robolectric.metaFalse");
+    assertThat(metaValue).isEqualTo("false");
+
+    metaValue = meta.get("org.robolectric.metaTrue");
+    assertThat(metaValue).isEqualTo("true");
+
+    metaValue = meta.get("org.robolectric.metaInt");
+    assertThat(metaValue).isEqualTo("123");
+
+    metaValue = meta.get("org.robolectric.metaFloat");
+    assertThat(metaValue).isEqualTo("1.23");
+
+    metaValue = meta.get("org.robolectric.metaColor");
+    assertThat(metaValue).isEqualTo("#FFFFFF");
+
+    metaValue = meta.get("org.robolectric.metaBooleanFromRes");
+    assertThat(metaValue).isEqualTo("@bool/false_bool_value");
+
+    metaValue = meta.get("org.robolectric.metaIntFromRes");
+    assertThat(metaValue).isEqualTo("@integer/test_integer1");
+
+    metaValue = meta.get("org.robolectric.metaColorFromRes");
+    assertThat(metaValue).isEqualTo("@color/clear");
+
+    metaValue = meta.get("org.robolectric.metaStringFromRes");
+    assertThat(metaValue).isEqualTo("@string/app_name");
+
+    metaValue = meta.get("org.robolectric.metaStringOfIntFromRes");
+    assertThat(metaValue).isEqualTo("@string/str_int");
+
+    metaValue = meta.get("org.robolectric.metaStringRes");
+    assertThat(metaValue).isEqualTo("@string/app_name");
+  }
+
+  @Test
+  public void shouldReadBroadcastReceiverPermissions() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithReceivers.xml");
+
+    assertThat(config.getBroadcastReceivers().get(7).getName())
+        .isEqualTo("org.robolectric.ConfigTestReceiverPermissionsAndActions");
+    assertThat(config.getBroadcastReceivers().get(7).getActions())
+        .contains("org.robolectric.ACTION_RECEIVER_PERMISSION_PACKAGE");
+
+    assertThat(config.getBroadcastReceivers().get(7).getPermission())
+        .isEqualTo("org.robolectric.CUSTOM_PERM");
+  }
+
+  @Test
+  public void shouldReadTargetSdkVersionFromAndroidManifestOrDefaultToMin() throws Exception {
+    assertThat(
+            newConfigWith(
+                    "targetsdk42minsdk6.xml",
+                    "android:targetSdkVersion=\"42\" android:minSdkVersion=\"7\"")
+                .getTargetSdkVersion())
+        .isEqualTo(42);
+    assertThat(newConfigWith("minsdk7.xml", "android:minSdkVersion=\"7\"").getTargetSdkVersion())
+        .isEqualTo(7);
+    assertThat(newConfigWith("noattributes.xml", "").getTargetSdkVersion())
+        .isEqualTo(VERSION_CODES.JELLY_BEAN);
+  }
+
+  @Test
+  public void shouldReadMinSdkVersionFromAndroidManifestOrDefaultToJellyBean() throws Exception {
+    assertThat(newConfigWith("minsdk17.xml", "android:minSdkVersion=\"17\"").getMinSdkVersion())
+        .isEqualTo(17);
+    assertThat(newConfigWith("noattributes.xml", "").getMinSdkVersion())
+        .isEqualTo(VERSION_CODES.JELLY_BEAN);
+  }
+
+  /**
+   * For Android O preview, apps are encouraged to use targetSdkVersion="O".
+   *
+   * @see <a href="http://google.com">https://developer.android.com/preview/migration.html</a>
+   */
+  @Test
+  public void shouldReadTargetSDKVersionOPreview() throws Exception {
+    assertThat(
+            newConfigWith("TestAndroidManifestForPreview.xml", "android:targetSdkVersion=\"O\"")
+                .getTargetSdkVersion())
+        .isEqualTo(26);
+  }
+
+  @Test
+  public void shouldReadProcessFromAndroidManifest() throws Exception {
+    assertThat(newConfig("TestAndroidManifestWithProcess.xml").getProcessName())
+        .isEqualTo("robolectricprocess");
+  }
+
+  @Test
+  public void shouldReturnPackageNameWhenNoProcessIsSpecifiedInTheManifest() {
+    assertThat(newConfig("TestAndroidManifestWithNoProcess.xml").getProcessName())
+        .isEqualTo("org.robolectric");
+  }
+
+  @Test
+  @Config(manifest = "TestAndroidManifestWithAppMetaData.xml")
+  public void shouldReturnApplicationMetaData() throws Exception {
+    Map<String, Object> meta =
+        newConfig("TestAndroidManifestWithAppMetaData.xml").getApplicationMetaData();
+
+    Object metaValue = meta.get("org.robolectric.metaName1");
+    assertThat(metaValue).isEqualTo("metaValue1");
+
+    metaValue = meta.get("org.robolectric.metaName2");
+    assertThat(metaValue).isEqualTo("metaValue2");
+
+    metaValue = meta.get("org.robolectric.metaFalse");
+    assertThat(metaValue).isEqualTo("false");
+
+    metaValue = meta.get("org.robolectric.metaTrue");
+    assertThat(metaValue).isEqualTo("true");
+
+    metaValue = meta.get("org.robolectric.metaInt");
+    assertThat(metaValue).isEqualTo("123");
+
+    metaValue = meta.get("org.robolectric.metaFloat");
+    assertThat(metaValue).isEqualTo("1.23");
+
+    metaValue = meta.get("org.robolectric.metaColor");
+    assertThat(metaValue).isEqualTo("#FFFFFF");
+
+    metaValue = meta.get("org.robolectric.metaBooleanFromRes");
+    assertThat(metaValue).isEqualTo("@bool/false_bool_value");
+
+    metaValue = meta.get("org.robolectric.metaIntFromRes");
+    assertThat(metaValue).isEqualTo("@integer/test_integer1");
+
+    metaValue = meta.get("org.robolectric.metaColorFromRes");
+    assertThat(metaValue).isEqualTo("@color/clear");
+
+    metaValue = meta.get("org.robolectric.metaStringFromRes");
+    assertThat(metaValue).isEqualTo("@string/app_name");
+
+    metaValue = meta.get("org.robolectric.metaStringOfIntFromRes");
+    assertThat(metaValue).isEqualTo("@string/str_int");
+
+    metaValue = meta.get("org.robolectric.metaStringRes");
+    assertThat(metaValue).isEqualTo("@string/app_name");
+  }
+
+  @Test
+  public void shouldTolerateMissingRFile() throws Exception {
+    AndroidManifest appManifest =
+        new AndroidManifest(
+            resourceFile("TestAndroidManifestWithNoRFile.xml"),
+            resourceFile("res"),
+            resourceFile("assets"));
+    assertThat(appManifest.getPackageName()).isEqualTo("org.no.resources.for.me");
+    assertThat(appManifest.getRClass()).isNull();
+  }
+
+  @Test
+  public void whenNullManifestFile_getRClass_shouldComeFromPackageName() throws Exception {
+    AndroidManifest appManifest =
+        new AndroidManifest(null, resourceFile("res"), resourceFile("assets"), "org.robolectric");
+    assertThat(appManifest.getRClass()).isEqualTo(org.robolectric.R.class);
+    assertThat(appManifest.getPackageName()).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void whenMissingManifestFile_getRClass_shouldComeFromPackageName() throws Exception {
+    AndroidManifest appManifest =
+        new AndroidManifest(
+            resourceFile("none.xml"),
+            resourceFile("res"),
+            resourceFile("assets"),
+            "org.robolectric");
+    assertThat(appManifest.getRClass()).isEqualTo(org.robolectric.R.class);
+    assertThat(appManifest.getPackageName()).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void whenMissingManifestFile_getPackageName_shouldBeDefault() throws Exception {
+    AndroidManifest appManifest =
+        new AndroidManifest(null, resourceFile("res"), resourceFile("assets"), null);
+    assertThat(appManifest.getPackageName()).isEqualTo("org.robolectric.default");
+    assertThat(appManifest.getRClass()).isEqualTo(null);
+  }
+
+  @Test
+  public void shouldRead1IntentFilter() {
+    AndroidManifest appManifest = newConfig("TestAndroidManifestForActivitiesWithIntentFilter.xml");
+    appManifest.getMinSdkVersion(); // Force parsing
+
+    ActivityData activityData = appManifest.getActivityData("org.robolectric.shadows.TestActivity");
+    final List<IntentFilterData> ifd = activityData.getIntentFilters();
+    assertThat(ifd).isNotNull();
+    assertThat(ifd.size()).isEqualTo(1);
+
+    final IntentFilterData data = ifd.get(0);
+    assertThat(data.getActions().size()).isEqualTo(1);
+    assertThat(data.getActions().get(0)).isEqualTo(Intent.ACTION_MAIN);
+    assertThat(data.getCategories().size()).isEqualTo(1);
+    assertThat(data.getCategories().get(0)).isEqualTo(Intent.CATEGORY_LAUNCHER);
+  }
+
+  @Test
+  public void shouldReadMultipleIntentFilters() {
+    AndroidManifest appManifest =
+        newConfig("TestAndroidManifestForActivitiesWithMultipleIntentFilters.xml");
+    appManifest.getMinSdkVersion(); // Force parsing
+
+    ActivityData activityData = appManifest.getActivityData("org.robolectric.shadows.TestActivity");
+    final List<IntentFilterData> ifd = activityData.getIntentFilters();
+    assertThat(ifd).isNotNull();
+    assertThat(ifd.size()).isEqualTo(2);
+
+    IntentFilterData data = ifd.get(0);
+    assertThat(data.getActions().size()).isEqualTo(1);
+    assertThat(data.getActions().get(0)).isEqualTo(Intent.ACTION_MAIN);
+    assertThat(data.getCategories().size()).isEqualTo(1);
+    assertThat(data.getCategories().get(0)).isEqualTo(Intent.CATEGORY_LAUNCHER);
+
+    data = ifd.get(1);
+    assertThat(data.getActions().size()).isEqualTo(3);
+    assertThat(data.getActions().get(0)).isEqualTo(Intent.ACTION_VIEW);
+    assertThat(data.getActions().get(1)).isEqualTo(Intent.ACTION_EDIT);
+    assertThat(data.getActions().get(2)).isEqualTo(Intent.ACTION_PICK);
+
+    assertThat(data.getCategories().size()).isEqualTo(3);
+    assertThat(data.getCategories().get(0)).isEqualTo(Intent.CATEGORY_DEFAULT);
+    assertThat(data.getCategories().get(1)).isEqualTo(Intent.CATEGORY_ALTERNATIVE);
+    assertThat(data.getCategories().get(2)).isEqualTo(Intent.CATEGORY_SELECTED_ALTERNATIVE);
+  }
+
+  @Test
+  public void shouldReadTaskAffinity() {
+    AndroidManifest appManifest = newConfig("TestAndroidManifestForActivitiesWithTaskAffinity.xml");
+    assertThat(appManifest.getTargetSdkVersion()).isEqualTo(16);
+
+    ActivityData activityData =
+        appManifest.getActivityData("org.robolectric.shadows.TestTaskAffinityActivity");
+    assertThat(activityData).isNotNull();
+    assertThat(activityData.getTaskAffinity())
+        .isEqualTo("org.robolectric.shadows.TestTaskAffinity");
+  }
+
+  @Test
+  public void shouldReadPermissions() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithPermissions.xml");
+
+    assertThat(config.getUsedPermissions()).hasSize(3);
+    assertThat(config.getUsedPermissions().get(0)).isEqualTo(Manifest.permission.INTERNET);
+    assertThat(config.getUsedPermissions().get(1)).isEqualTo(Manifest.permission.SYSTEM_ALERT_WINDOW);
+    assertThat(config.getUsedPermissions().get(2)).isEqualTo(Manifest.permission.GET_TASKS);
+  }
+
+  @Test
+  public void shouldReadPartiallyQualifiedActivities() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestForActivities.xml");
+    assertThat(config.getActivityDatas()).hasSize(2);
+    assertThat(config.getActivityDatas()).containsKey("org.robolectric.shadows.TestActivity");
+    assertThat(config.getActivityDatas()).containsKey("org.robolectric.shadows.TestActivity2");
+  }
+
+  @Test
+  public void shouldReadActivityAliases() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestForActivityAliases.xml");
+    assertThat(config.getActivityDatas()).hasSize(2);
+    assertThat(config.getActivityDatas()).containsKey("org.robolectric.shadows.TestActivity");
+    assertThat(config.getActivityDatas()).containsKey("org.robolectric.shadows.TestActivityAlias");
+  }
+
+  @Test
+  public void shouldReadIntentFilterWithData() {
+    AndroidManifest appManifest =
+        newConfig("TestAndroidManifestForActivitiesWithIntentFilterWithData.xml");
+    appManifest.getMinSdkVersion(); // Force parsing
+
+    ActivityData activityData = appManifest.getActivityData("org.robolectric.shadows.TestActivity");
+    final List<IntentFilterData> ifd = activityData.getIntentFilters();
+    assertThat(ifd).isNotNull();
+    assertThat(ifd.size()).isEqualTo(1);
+
+    final IntentFilterData intentFilterData = ifd.get(0);
+    assertThat(intentFilterData.getActions().size()).isEqualTo(1);
+    assertThat(intentFilterData.getActions().get(0)).isEqualTo(Intent.ACTION_VIEW);
+    assertThat(intentFilterData.getCategories().size()).isEqualTo(1);
+    assertThat(intentFilterData.getCategories().get(0)).isEqualTo(Intent.CATEGORY_DEFAULT);
+
+    assertThat(intentFilterData.getSchemes().size()).isEqualTo(3);
+    assertThat(intentFilterData.getAuthorities().size()).isEqualTo(3);
+    assertThat(intentFilterData.getMimeTypes().size()).isEqualTo(3);
+    assertThat(intentFilterData.getPaths().size()).isEqualTo(1);
+    assertThat(intentFilterData.getPathPatterns().size()).isEqualTo(1);
+    assertThat(intentFilterData.getPathPrefixes().size()).isEqualTo(1);
+
+
+    assertThat(intentFilterData.getSchemes().get(0)).isEqualTo("content");
+    assertThat(intentFilterData.getPaths().get(0)).isEqualTo("/testPath/test.jpeg");
+    assertThat(intentFilterData.getMimeTypes().get(0)).isEqualTo("video/mpeg");
+    assertThat(intentFilterData.getAuthorities().get(0).getHost()).isEqualTo("testhost1.com");
+    assertThat(intentFilterData.getAuthorities().get(0).getPort()).isEqualTo("1");
+
+    assertThat(intentFilterData.getSchemes().get(1)).isEqualTo("http");
+    assertThat(intentFilterData.getPathPrefixes().get(0)).isEqualTo("/testPrefix");
+    assertThat(intentFilterData.getMimeTypes().get(1)).isEqualTo("image/jpeg");
+    assertThat(intentFilterData.getAuthorities().get(1).getHost()).isEqualTo("testhost2.com");
+    assertThat(intentFilterData.getAuthorities().get(1).getPort()).isEqualTo("2");
+
+    assertThat(intentFilterData.getSchemes().get(2)).isEqualTo("https");
+    assertThat(intentFilterData.getPathPatterns().get(0)).isEqualTo("/.*testPattern");
+    assertThat(intentFilterData.getMimeTypes().get(2)).isEqualTo("image/*");
+    assertThat(intentFilterData.getAuthorities().get(2).getHost()).isEqualTo("testhost3.com");
+    assertThat(intentFilterData.getAuthorities().get(2).getPort()).isEqualTo("3");
+  }
+
+  @Test
+  public void shouldHaveStableHashCode() throws Exception {
+    AndroidManifest manifest = newConfig("TestAndroidManifestWithContentProviders.xml");
+    int hashCode1 = manifest.hashCode();
+    manifest.getServices();
+    int hashCode2 = manifest.hashCode();
+    assertThat(hashCode2).isEqualTo(hashCode1);
+  }
+
+  @Test
+  public void shouldReadApplicationAttrsFromAndroidManifest() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithFlags.xml");
+    assertThat(config.getApplicationAttributes().get("android:allowBackup")).isEqualTo("true");
+  }
+
+  @Test
+  public void allFieldsShouldBePrimitivesOrJavaLangOrRobolectric() throws Exception {
+    List<Field> wrongFields = new ArrayList<>();
+    for (Field field : AndroidManifest.class.getDeclaredFields()) {
+      Class<?> type = field.getType();
+      if (type.isPrimitive()) continue;
+
+      String packageName = type.getPackage().getName();
+      if (packageName.startsWith("java.")
+          || packageName.equals("org.robolectric.res")
+          || packageName.equals("org.robolectric.manifest")
+          ) continue;
+
+      wrongFields.add(field);
+    }
+
+    assertThat(wrongFields).isEmpty();
+  }
+
+  @Test
+  public void activitiesWithoutIntentFiltersNotExportedByDefault() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestForActivities.xml");
+    ActivityData activityData = config.getActivityData("org.robolectric.shadows.TestActivity");
+    assertThat(activityData.isExported()).isFalse();
+  }
+
+  @Test
+  public void activitiesWithIntentFiltersExportedByDefault() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestForActivitiesWithIntentFilter.xml");
+    ActivityData activityData = config.getActivityData("org.robolectric.shadows.TestActivity");
+    assertThat(activityData.isExported()).isTrue();
+  }
+
+  @Test
+  public void servicesWithoutIntentFiltersNotExportedByDefault() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithServices.xml");
+    ServiceData serviceData = config.getServiceData("com.bar.ServiceWithoutIntentFilter");
+    assertThat(serviceData.isExported()).isFalse();
+  }
+
+  @Test
+  public void servicesWithIntentFiltersExportedByDefault() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithServices.xml");
+    ServiceData serviceData = config.getServiceData("com.foo.Service");
+    assertThat(serviceData.isExported()).isTrue();
+  }
+
+  @Test
+  public void receiversWithoutIntentFiltersNotExportedByDefault() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithReceivers.xml");
+    BroadcastReceiverData receiverData =
+        config.getBroadcastReceiver("com.bar.ReceiverWithoutIntentFilter");
+    assertThat(receiverData).isNotNull();
+    assertThat(receiverData.isExported()).isFalse();
+  }
+
+  @Test
+  public void receiversWithIntentFiltersExportedByDefault() throws Exception {
+    AndroidManifest config = newConfig("TestAndroidManifestWithReceivers.xml");
+    BroadcastReceiverData receiverData = config.getBroadcastReceiver("com.foo.Receiver");
+    assertThat(receiverData).isNotNull();
+    assertThat(receiverData.isExported()).isTrue();
+  }
+
+  @Test
+  public void getTransitiveManifests() throws Exception {
+    AndroidManifest lib1 =
+        new AndroidManifest(resourceFile("lib1/AndroidManifest.xml"), null, null);
+    AndroidManifest lib2 = new AndroidManifest(resourceFile("lib2/AndroidManifest.xml"), null, null,
+        Collections.singletonList(lib1), null);
+    AndroidManifest app = new AndroidManifest(
+        resourceFile("TestAndroidManifestWithReceivers.xml"), null, null,
+        Arrays.asList(lib1, lib2), null);
+    assertThat(app.getAllManifests()).containsExactly(app, lib1, lib2);
+  }
+
+  /////////////////////////////
+
+  private AndroidManifest newConfigWith(String fileName, String usesSdkAttrs) throws IOException {
+    String contents = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
+        "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n" +
+        "          package=\"org.robolectric\">\n" +
+        "    <uses-sdk " + usesSdkAttrs + "/>\n" +
+        "</manifest>\n";
+    File f = temporaryFolder.newFile(fileName);
+    Files.asCharSink(f, UTF_8).write(contents);
+    return new AndroidManifest(f.toPath(), null, null);
+  }
+
+  private static AndroidManifest newConfig(String androidManifestFile) {
+    return new AndroidManifest(resourceFile(androidManifestFile), null, null);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/CustomConfigurerTest.java b/robolectric/src/test/java/org/robolectric/plugins/CustomConfigurerTest.java
new file mode 100644
index 0000000..d71b56b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/CustomConfigurerTest.java
@@ -0,0 +1,128 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Properties;
+import javax.annotation.Nonnull;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.JUnit4;
+import org.junit.runners.model.InitializationError;
+import org.robolectric.SingleSdkRobolectricTestRunner;
+import org.robolectric.android.FailureListener;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.pluginapi.config.ConfigurationStrategy;
+import org.robolectric.pluginapi.config.Configurer;
+
+@RunWith(JUnit4.class)
+public class CustomConfigurerTest {
+
+  @Test
+  public void customConfigCanBeAccessedFromWithinSandbox() throws Exception {
+    List<String> failures = runAndGetFailures(TestWithConfig.class);
+    assertThat(failures).containsExactly("someConfig value is the value");
+  }
+
+  /////////////////////
+
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(value = {ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD})
+  public @interface SomeConfig {
+    String value();
+  }
+
+  @Ignore
+  public static class TestWithConfig {
+
+    @Test
+    @SomeConfig(value = "the value")
+    public void shouldHaveValue() throws Exception {
+      SomeConfig someConfig = ConfigurationRegistry.get(SomeConfig.class);
+      fail("someConfig value is " + someConfig.value());
+    }
+  }
+
+  static class SomeConfigConfigurer implements Configurer<SomeConfig> {
+
+    @Override
+    public Class<SomeConfig> getConfigClass() {
+      return SomeConfig.class;
+    }
+
+    @Nonnull
+    @Override
+    public SomeConfig defaultConfig() {
+      return new MySomeConfig();
+    }
+
+    @Override
+    public SomeConfig getConfigFor(@Nonnull String packageName) {
+      return null;
+    }
+
+    @Override
+    public SomeConfig getConfigFor(@Nonnull Class<?> testClass) {
+      return testClass.getAnnotation(SomeConfig.class);
+    }
+
+    @Override
+    public SomeConfig getConfigFor(@Nonnull Method method) {
+      return method.getAnnotation(SomeConfig.class);
+    }
+
+    @Nonnull
+    @Override
+    public SomeConfig merge(@Nonnull SomeConfig parentConfig, @Nonnull SomeConfig childConfig) {
+      return childConfig;
+    }
+
+    @SuppressWarnings("BadAnnotationImplementation")
+    private static class MySomeConfig implements SomeConfig {
+
+      @Override
+      public Class<? extends Annotation> annotationType() {
+        return Annotation.class;
+      }
+
+      @Override
+      public String value() {
+        return "default value";
+      }
+    }
+  }
+
+  private List<String> runAndGetFailures(Class<TestWithConfig> testClass)
+      throws InitializationError {
+    RunNotifier notifier = new RunNotifier();
+    FailureListener failureListener = new FailureListener();
+    notifier.addListener(failureListener);
+
+    HierarchicalConfigurationStrategy configurationStrategy =
+        new HierarchicalConfigurationStrategy(
+            new ConfigConfigurer(new PackagePropertiesLoader()),
+            new LooperModeConfigurer(new Properties()),
+            new SomeConfigConfigurer());
+
+    SingleSdkRobolectricTestRunner testRunner = new SingleSdkRobolectricTestRunner(
+        testClass,
+        SingleSdkRobolectricTestRunner.defaultInjector()
+            .bind(ConfigurationStrategy.class, configurationStrategy)
+            .build());
+
+    testRunner.run(notifier);
+    return failureListener.failures.stream().map(Failure::getMessage).collect(toList());
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/DefaultSdkPickerTest.java b/robolectric/src/test/java/org/robolectric/plugins/DefaultSdkPickerTest.java
new file mode 100644
index 0000000..37199f9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/DefaultSdkPickerTest.java
@@ -0,0 +1,204 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.os.Build.VERSION_CODES;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.ConfigUtils;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkPicker;
+import org.robolectric.pluginapi.UsesSdk;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.plugins.HierarchicalConfigurationStrategy.ConfigurationImpl;
+import org.robolectric.util.TestUtil;
+
+@RunWith(JUnit4.class)
+public class DefaultSdkPickerTest {
+  private static final int[] sdkInts = { 16, 17, 18, 19, 21, 22, 23 };
+  private SdkCollection sdkCollection;
+  private UsesSdk usesSdk;
+  private SdkPicker sdkPicker;
+
+  @Before
+  public void setUp() throws Exception {
+    usesSdk = mock(UsesSdk.class);
+    sdkCollection = new SdkCollection(() -> map(sdkInts));
+    sdkPicker = new DefaultSdkPicker(sdkCollection, "");
+  }
+
+  @Test
+  public void withDefaultSdk_shouldUseTargetSdkFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder()), usesSdk))
+        .containsExactly(sdkCollection.getSdk(22));
+  }
+
+  @Test
+  public void withAllSdksConfig_shouldUseFullSdkRangeFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    when(usesSdk.getMinSdkVersion()).thenReturn(19);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(23);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.ALL_SDKS)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(19), sdkCollection.getSdk(21),
+            sdkCollection.getSdk(22), sdkCollection.getSdk(23));
+  }
+
+  @Test
+  public void withAllSdksConfigAndNoMinSdkVersion_shouldUseFullSdkRangeFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    when(usesSdk.getMinSdkVersion()).thenReturn(1);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(22);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.ALL_SDKS)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(16), sdkCollection.getSdk(17),
+            sdkCollection.getSdk(18), sdkCollection.getSdk(19),
+            sdkCollection.getSdk(21), sdkCollection.getSdk(22));
+  }
+
+  @Test
+  public void withAllSdksConfigAndNoMaxSdkVersion_shouldUseFullSdkRangeFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    when(usesSdk.getMinSdkVersion()).thenReturn(19);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(null);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.ALL_SDKS)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(19), sdkCollection.getSdk(21),
+            sdkCollection.getSdk(22), sdkCollection.getSdk(23));
+  }
+
+  @Test
+  public void withMinSdkHigherThanSupportedRange_shouldReturnNone() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(23);
+    when(usesSdk.getMinSdkVersion()).thenReturn(1);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(null);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setMinSdk(24)), usesSdk))
+        .isEmpty();
+  }
+
+  @Test
+  public void withMinSdkHigherThanMaxSdk_shouldThrowError() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(23);
+    when(usesSdk.getMinSdkVersion()).thenReturn(1);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(null);
+
+    try {
+      sdkPicker.selectSdks(
+          buildConfig(new Config.Builder().setMinSdk(22).setMaxSdk(21)), usesSdk);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("minSdk may not be larger than maxSdk (minSdk=22, maxSdk=21)");
+    }
+  }
+
+  @Test
+  public void withTargetSdkLessThanMinSdk_shouldThrowError() throws Exception {
+    when(usesSdk.getMinSdkVersion()).thenReturn(23);
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+
+    try {
+      sdkPicker.selectSdks(buildConfig(new Config.Builder()), usesSdk);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("Package targetSdkVersion=22 < minSdkVersion=23");
+    }
+  }
+
+  @Test
+  public void withTargetSdkGreaterThanMaxSdk_shouldThrowError() throws Exception {
+    when(usesSdk.getMaxSdkVersion()).thenReturn(21);
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    try {
+      sdkPicker.selectSdks(buildConfig(new Config.Builder()), usesSdk);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("Package targetSdkVersion=22 > maxSdkVersion=21");
+    }
+  }
+
+  @Test
+  public void shouldClipSdkRangeFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(1);
+    when(usesSdk.getMinSdkVersion()).thenReturn(1);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(null);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder()), usesSdk))
+        .containsExactly(sdkCollection.getSdk(16));
+  }
+
+  @Test
+  public void withMinSdk_shouldClipSdkRangeFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    when(usesSdk.getMinSdkVersion()).thenReturn(19);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(23);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setMinSdk(21)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(21), sdkCollection.getSdk(22),
+            sdkCollection.getSdk(23));
+  }
+
+  @Test
+  public void withMaxSdk_shouldUseSdkRangeFromAndroidManifest() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(22);
+    when(usesSdk.getMinSdkVersion()).thenReturn(19);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(23);
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setMaxSdk(21)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(19), sdkCollection.getSdk(21));
+  }
+
+  @Test
+  public void withExplicitSdk_selectSdks() throws Exception {
+    when(usesSdk.getTargetSdkVersion()).thenReturn(21);
+    when(usesSdk.getMinSdkVersion()).thenReturn(19);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(22);
+
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(21)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(21));
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.OLDEST_SDK)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(19));
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.TARGET_SDK)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(21));
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.NEWEST_SDK)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(22));
+
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(16)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(16));
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(23)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(23));
+  }
+
+  @Test
+  public void withEnabledSdks_shouldRestrictAsSpecified() throws Exception {
+    when(usesSdk.getMinSdkVersion()).thenReturn(16);
+    when(usesSdk.getMaxSdkVersion()).thenReturn(23);
+    sdkPicker = new DefaultSdkPicker(sdkCollection, "17,18");
+    assertThat(sdkPicker.selectSdks(buildConfig(new Config.Builder().setSdk(Config.ALL_SDKS)), usesSdk))
+        .containsExactly(sdkCollection.getSdk(17), sdkCollection.getSdk(18));
+  }
+
+  @Test
+  public void shouldParseSdkSpecs() throws Exception {
+    assertThat(ConfigUtils.parseSdkArrayProperty("17,18"))
+        .asList()
+        .containsExactly(VERSION_CODES.JELLY_BEAN_MR1, VERSION_CODES.JELLY_BEAN_MR2);
+    assertThat(ConfigUtils.parseSdkArrayProperty("KITKAT, LOLLIPOP"))
+        .asList()
+        .containsExactly(VERSION_CODES.KITKAT, VERSION_CODES.LOLLIPOP);
+  }
+
+  private Configuration buildConfig(Config.Builder builder) {
+    ConfigurationImpl testConfig = new ConfigurationImpl();
+    testConfig.put(Config.class, builder.build());
+    return testConfig;
+  }
+
+  private List<Sdk> map(int... sdkInts) {
+    SdkCollection allSdks = TestUtil.getSdkCollection();
+    return Arrays.stream(sdkInts).mapToObj(allSdks::getSdk).collect(Collectors.toList());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/HierarchicalConfigurationStrategyTest.java b/robolectric/src/test/java/org/robolectric/plugins/HierarchicalConfigurationStrategyTest.java
new file mode 100644
index 0000000..c1e7c57
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/HierarchicalConfigurationStrategyTest.java
@@ -0,0 +1,695 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.annotation.Config.DEFAULT_APPLICATION;
+
+import android.app.Application;
+import com.google.common.collect.ImmutableMap;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.TestFakeApp;
+import org.robolectric.annotation.Config;
+import org.robolectric.pluginapi.config.ConfigurationStrategy;
+import org.robolectric.pluginapi.config.ConfigurationStrategy.Configuration;
+import org.robolectric.pluginapi.config.Configurer;
+import org.robolectric.shadows.ShadowView;
+import org.robolectric.shadows.ShadowViewGroup;
+import org.robolectric.shadows.testing.TestApplication;
+
+@RunWith(JUnit4.class)
+public class HierarchicalConfigurationStrategyTest {
+
+  @Test public void defaultValuesAreMerged() throws Exception {
+    assertThat(configFor(Test2.class, "withoutAnnotation").manifest())
+        .isEqualTo("AndroidManifest.xml");
+  }
+
+  @Test public void globalValuesAreMerged() throws Exception {
+    assertThat(configFor(Test2.class, "withoutAnnotation",
+        new Config.Builder().setManifest("ManifestFromGlobal.xml").build()).manifest())
+        .isEqualTo("ManifestFromGlobal.xml");
+  }
+
+  @Test
+  public void whenClassHasConfigAnnotation_getConfig_shouldMergeClassAndMethodConfig()
+      throws Exception {
+    assertConfig(
+        configFor(Test1.class, "withoutAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-test",
+        "test/res",
+        "test/assets",
+        new Class<?>[] {Test1.class},
+        new String[] {"com.example.test1"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1.class, "withDefaultsAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-test",
+        "test/res",
+        "test/assets",
+        new Class<?>[] {Test1.class},
+        new String[] {"com.example.test1"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1.class, "withOverrideAnnotation"),
+        new int[] {9},
+        "furf",
+        TestApplication.class,
+        "com.example.method",
+        "from-method",
+        "method/res",
+        "method/assets",
+        new Class<?>[] {Test1.class, Test2.class},
+        new String[] {"com.example.test1", "com.example.method1"},
+        new String[] {"libs/method", "libs/test"});
+  }
+
+  @Test
+  public void whenClassDoesntHaveConfigAnnotation_getConfig_shouldUseMethodConfig()
+      throws Exception {
+    assertConfig(
+        configFor(Test2.class, "withoutAnnotation"),
+        new int[0],
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {});
+
+    assertConfig(
+        configFor(Test2.class, "withDefaultsAnnotation"),
+        new int[0],
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {});
+
+    assertConfig(
+        configFor(Test2.class, "withOverrideAnnotation"),
+        new int[] {9},
+        "furf",
+        TestFakeApp.class,
+        "com.example.method",
+        "from-method",
+        "method/res",
+        "method/assets",
+        new Class<?>[] {Test1.class},
+        new String[] {"com.example.method2"},
+        new String[] {"libs/method"});
+  }
+
+  @Test
+  public void whenClassDoesntHaveConfigAnnotation_getConfig_shouldMergeParentClassAndMethodConfig()
+      throws Exception {
+    assertConfig(
+        configFor(Test1B.class, "withoutAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-test",
+        "test/res",
+        "test/assets",
+        new Class<?>[] {Test1.class, Test1.class},
+        new String[] {"com.example.test1"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1B.class, "withDefaultsAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-test",
+        "test/res",
+        "test/assets",
+        new Class<?>[] {Test1.class, Test1.class},
+        new String[] {"com.example.test1"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1B.class, "withOverrideAnnotation"),
+        new int[] {14},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-method5",
+        "test/res",
+        "method5/assets",
+        new Class<?>[] {Test1.class, Test1.class, Test1B.class},
+        new String[] {"com.example.test1", "com.example.method5"},
+        new String[] {"libs/test"});
+  }
+
+  @Test
+  public void whenClassAndParentClassHaveConfigAnnotation_getConfig_shouldMergeParentClassAndMethodConfig()
+      throws Exception {
+    assertConfig(
+        configFor(Test1C.class, "withoutAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-class6",
+        "class6/res",
+        "test/assets",
+        new Class<?>[] {Test1.class, Test1.class, Test1C.class},
+        new String[] {"com.example.test1", "com.example.test6"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1C.class, "withDefaultsAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-class6",
+        "class6/res",
+        "test/assets",
+        new Class<?>[] {Test1.class, Test1.class, Test1C.class},
+        new String[] {"com.example.test1", "com.example.test6"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1C.class, "withOverrideAnnotation"),
+        new int[] {14},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-method5",
+        "class6/res",
+        "method5/assets",
+        new Class<?>[] {Test1.class, Test1.class, Test1C.class, Test1B.class},
+        new String[] {"com.example.test1", "com.example.method5", "com.example.test6"},
+        new String[] {"libs/test"});
+  }
+
+  @Test
+  public void whenClassAndSubclassHaveConfigAnnotation_getConfig_shouldMergeClassSubclassAndMethodConfig()
+      throws Exception {
+    assertConfig(
+        configFor(Test1A.class, "withoutAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-subclass",
+        "test/res",
+        "test/assets",
+        new Class<?>[] {Test1.class},
+        new String[] {"com.example.test1"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1A.class, "withDefaultsAnnotation"),
+        new int[] {1},
+        "foo",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-subclass",
+        "test/res",
+        "test/assets",
+        new Class<?>[] {Test1.class},
+        new String[] {"com.example.test1"},
+        new String[] {"libs/test"});
+
+    assertConfig(
+        configFor(Test1A.class, "withOverrideAnnotation"),
+        new int[] {9},
+        "furf",
+        TestApplication.class,
+        "com.example.method",
+        "from-method",
+        "method/res",
+        "method/assets",
+        new Class<?>[] {Test1.class, Test2.class},
+        new String[] {"com.example.test1", "com.example.method1"},
+        new String[] {"libs/method", "libs/test"});
+  }
+
+  @Test
+  public void whenClassDoesntHaveConfigAnnotationButSubclassDoes_getConfig_shouldMergeSubclassAndMethodConfig()
+      throws Exception {
+    assertConfig(
+        configFor(Test2A.class, "withoutAnnotation"),
+        new int[0],
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "from-subclass",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {});
+
+    assertConfig(
+        configFor(Test2A.class, "withDefaultsAnnotation"),
+        new int[0],
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "from-subclass",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {});
+
+    assertConfig(
+        configFor(Test2A.class, "withOverrideAnnotation"),
+        new int[] {9},
+        "furf",
+        TestFakeApp.class,
+        "com.example.method",
+        "from-method",
+        "method/res",
+        "method/assets",
+        new Class<?>[] {Test1.class},
+        new String[] {"com.example.method2"},
+        new String[] {"libs/method"});
+  }
+
+  @Test
+  public void shouldLoadDefaultsFromGlobalPropertiesFile() throws Exception {
+    String properties =
+        "sdk: 432\n"
+            + "manifest: --none\n"
+            + "qualifiers: from-properties-file\n"
+            + "resourceDir: from/properties/file/res\n"
+            + "assetDir: from/properties/file/assets\n"
+            + "shadows: org.robolectric.shadows.ShadowView,"
+            + " org.robolectric.shadows.ShadowViewGroup\n"
+            + "application: org.robolectric.TestFakeApp\n"
+            + "packageName: com.example.test\n"
+            + "instrumentedPackages: com.example.test1, com.example.test2\n"
+            + "libraries: libs/test, libs/test2";
+
+    assertConfig(
+        configFor(
+            Test2.class,
+            "withoutAnnotation",
+            ImmutableMap.of("robolectric.properties", properties)),
+        new int[] {432},
+        "--none",
+        TestFakeApp.class,
+        "com.example.test",
+        "from-properties-file",
+        "from/properties/file/res",
+        "from/properties/file/assets",
+        new Class<?>[] {ShadowView.class, ShadowViewGroup.class},
+        new String[] {"com.example.test1", "com.example.test2"},
+        new String[] {"libs/test", "libs/test2"});
+  }
+
+  @Test
+  public void shouldMergeConfigFromTestClassPackageProperties() throws Exception {
+    assertConfig(
+        configFor(
+            Test2.class,
+            "withoutAnnotation",
+            ImmutableMap.of("org/robolectric/robolectric.properties", "sdk: 432\n")),
+        new int[] {432},
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {});
+  }
+
+  @Test
+  public void shouldMergeConfigUpPackageHierarchy() throws Exception {
+    assertConfig(
+        configFor(
+            Test2.class,
+            "withoutAnnotation",
+            ImmutableMap.of(
+                "org/robolectric/robolectric.properties",
+                    "qualifiers: from-org-robolectric\nlibraries: FromOrgRobolectric\n",
+                "org/robolectric.properties",
+                    "sdk: 123\nqualifiers: from-org\nlibraries: FromOrg\n",
+                "robolectric.properties",
+                    "sdk: 456\nqualifiers: from-top-level\nlibraries: FromTopLevel\n")),
+        new int[] {123},
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "from-org-robolectric",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {"FromOrgRobolectric", "FromOrg", "FromTopLevel"});
+  }
+
+  @Test
+  public void withEmptyShadowList_shouldLoadDefaultsFromGlobalPropertiesFile() throws Exception {
+    assertConfig(
+        configFor(
+            Test2.class,
+            "withoutAnnotation",
+            ImmutableMap.of("robolectric.properties", "shadows:")),
+        new int[0],
+        "AndroidManifest.xml",
+        DEFAULT_APPLICATION,
+        "",
+        "",
+        "res",
+        "assets",
+        new Class<?>[] {},
+        new String[] {},
+        new String[] {});
+  }
+
+  @Test public void testPrecedence() throws Exception {
+    SpyConfigurer spyConfigurer = new SpyConfigurer();
+
+    ConfigurationStrategy configStrategy =
+        new HierarchicalConfigurationStrategy(spyConfigurer);
+
+    assertThat(computeConfig(configStrategy, Test1.class, "withoutAnnotation"))
+        .isEqualTo(
+            "default:(top):org:org.robolectric:org.robolectric.plugins"
+                + ":" + Test1.class.getName()
+                + ":withoutAnnotation");
+
+    assertThat(computeConfig(configStrategy, Test1A.class, "withOverrideAnnotation"))
+        .isEqualTo(
+            "default:(top):org:org.robolectric:org.robolectric.plugins"
+                + ":" + Test1.class.getName()
+                + ":" + Test1A.class.getName()
+                + ":withOverrideAnnotation");
+  }
+
+  @Test public void testTestClassMatters() throws Exception {
+    SpyConfigurer spyConfigurer = new SpyConfigurer();
+
+    ConfigurationStrategy configStrategy =
+        new HierarchicalConfigurationStrategy(spyConfigurer);
+
+    assertThat(computeConfig(configStrategy, Test1.class, "withoutAnnotation"))
+        .isEqualTo(
+            "default:(top):org:org.robolectric:org.robolectric.plugins"
+                + ":" + Test1.class.getName()
+                + ":withoutAnnotation");
+
+    assertThat(computeConfig(configStrategy, Test1A.class, "withoutAnnotation"))
+        .isEqualTo(
+            "default:(top):org:org.robolectric:org.robolectric.plugins"
+                + ":" + Test1.class.getName()
+                + ":" + Test1A.class.getName()
+                + ":withoutAnnotation");
+  }
+
+  @Test public void testBigOAndCaching() throws Exception {
+    SpyConfigurer spyConfigurer = new SpyConfigurer();
+    ConfigurationStrategy configStrategy =
+        new HierarchicalConfigurationStrategy(spyConfigurer);
+    computeConfig(configStrategy, Test1A.class, "withoutAnnotation");
+
+    assertThat(spyConfigurer.log).containsExactly(
+        "default",
+        "withoutAnnotation",
+        Test1A.class.getName(),
+        Test1.class.getName(),
+        "org.robolectric.plugins",
+        "org.robolectric",
+        "org",
+        "(top)"
+    ).inOrder();
+
+    spyConfigurer.log.clear();
+    computeConfig(configStrategy, Test1.class, "withoutAnnotation");
+    assertThat(spyConfigurer.log).containsExactly(
+        "withoutAnnotation"
+    ).inOrder();
+
+    spyConfigurer.log.clear();
+    computeConfig(configStrategy, Test2A.class, "withOverrideAnnotation");
+    assertThat(spyConfigurer.log).containsExactly(
+        "withOverrideAnnotation",
+        Test2A.class.getName(),
+        Test2.class.getName()
+    ).inOrder();
+  }
+
+  /////////////////////////////
+
+
+  private String computeConfig(ConfigurationStrategy configStrategy,
+      Class<?> testClass, String methodName)
+      throws NoSuchMethodException {
+    return configStrategy
+        .getConfig(testClass,
+            testClass.getMethod(methodName))
+        .get(String.class);
+  }
+
+  private Config configFor(Class<?> testClass, String methodName,
+      final Map<String, String> configProperties) {
+    return configFor(testClass, methodName, configProperties, null);
+  }
+
+  private Config configFor(Class<?> testClass, String methodName) {
+    Config.Implementation globalConfig = Config.Builder.defaults().build();
+    return configFor(testClass, methodName, globalConfig);
+  }
+
+  private Config configFor(Class<?> testClass, String methodName,
+      Config.Implementation globalConfig) {
+    return configFor(testClass, methodName, new HashMap<>(), globalConfig);
+  }
+
+  private Config configFor(Class<?> testClass, String methodName,
+      final Map<String, String> configProperties, Config.Implementation globalConfig) {
+    Method info = getMethod(testClass, methodName);
+    PackagePropertiesLoader packagePropertiesLoader = new PackagePropertiesLoader() {
+      @Override
+      InputStream getResourceAsStream(String resourceName) {
+        String properties = configProperties.get(resourceName);
+        return properties == null ? null : new ByteArrayInputStream(properties.getBytes(UTF_8));
+      }
+    };
+    ConfigurationStrategy defaultConfigStrategy =
+        new HierarchicalConfigurationStrategy(
+            new ConfigConfigurer(packagePropertiesLoader, () ->
+                globalConfig == null ? Config.Builder.defaults().build() : globalConfig));
+    Configuration config = defaultConfigStrategy.getConfig(testClass, info);
+    return config.get(Config.class);
+  }
+
+  private static Method getMethod(Class<?> testClass, String methodName) {
+    try {
+      return testClass.getMethod(methodName);
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static void assertConfig(
+      Config config,
+      int[] sdk,
+      String manifest,
+      Class<? extends Application> application,
+      String packageName,
+      String qualifiers,
+      String resourceDir,
+      String assetsDir,
+      Class<?>[] shadows,
+      String[] instrumentedPackages,
+      String[] libraries) {
+    assertThat(config.sdk()).isEqualTo(sdk);
+    assertThat(config.manifest()).isEqualTo(manifest);
+    assertThat(config.application()).isEqualTo(application);
+    assertThat(config.packageName()).isEqualTo(packageName);
+    assertThat(config.qualifiers()).isEqualTo(qualifiers);
+    assertThat(config.resourceDir()).isEqualTo(resourceDir);
+    assertThat(config.assetDir()).isEqualTo(assetsDir);
+    assertThat(config.shadows()).asList().containsAtLeastElementsIn(shadows).inOrder();
+    assertThat(config.instrumentedPackages())
+        .asList()
+        .containsAtLeastElementsIn(instrumentedPackages);
+    assertThat(config.libraries()).asList().containsAtLeastElementsIn(libraries);
+  }
+
+  @Ignore
+  @Config(
+      sdk = 1,
+      manifest = "foo",
+      application = TestFakeApp.class,
+      packageName = "com.example.test",
+      shadows = Test1.class,
+      instrumentedPackages = "com.example.test1",
+      libraries = "libs/test",
+      qualifiers = "from-test",
+      resourceDir = "test/res",
+      assetDir = "test/assets")
+  public static class Test1 {
+    @Test
+    public void withoutAnnotation() throws Exception {
+    }
+
+    @Test
+    @Config
+    public void withDefaultsAnnotation() throws Exception {
+    }
+
+    @Test
+    @Config(
+        sdk = 9,
+        manifest = "furf",
+        application = TestApplication.class,
+        packageName = "com.example.method",
+        shadows = Test2.class,
+        instrumentedPackages = "com.example.method1",
+        libraries = "libs/method",
+        qualifiers = "from-method",
+        resourceDir = "method/res",
+        assetDir = "method/assets")
+    public void withOverrideAnnotation() throws Exception {}
+  }
+
+  @Ignore
+  public static class Test2 {
+    @Test
+    public void withoutAnnotation() throws Exception {
+    }
+
+    @Test
+    @Config
+    public void withDefaultsAnnotation() throws Exception {
+    }
+
+    @Test
+    @Config(
+        sdk = 9,
+        manifest = "furf",
+        application = TestFakeApp.class,
+        packageName = "com.example.method",
+        shadows = Test1.class,
+        instrumentedPackages = "com.example.method2",
+        libraries = "libs/method",
+        qualifiers = "from-method",
+        resourceDir = "method/res",
+        assetDir = "method/assets")
+    public void withOverrideAnnotation() throws Exception {}
+  }
+
+  @Ignore
+  @Config(qualifiers = "from-subclass")
+  public static class Test1A extends Test1 {
+  }
+
+  @Ignore
+  @Config(qualifiers = "from-subclass")
+  public static class Test2A extends Test2 {
+  }
+
+  @Ignore
+  public static class Test1B extends Test1 {
+    @Override
+    @Test
+    public void withoutAnnotation() throws Exception {
+    }
+
+    @Override
+    @Test
+    @Config
+    public void withDefaultsAnnotation() throws Exception {
+    }
+
+    @Override
+    @Test
+    @Config(
+        sdk = 14,
+        shadows = Test1B.class,
+        instrumentedPackages = "com.example.method5",
+        packageName = "com.example.test",
+        qualifiers = "from-method5",
+        assetDir = "method5/assets")
+    public void withOverrideAnnotation() throws Exception {}
+  }
+
+  @Ignore
+  @Config(
+      qualifiers = "from-class6",
+      shadows = Test1C.class,
+      instrumentedPackages = "com.example.test6",
+      resourceDir = "class6/res")
+  public static class Test1C extends Test1B {}
+
+  private static class SpyConfigurer implements Configurer<String> {
+
+    final List<String> log = new ArrayList<>();
+
+    @Override
+    public Class<String> getConfigClass() {
+      return String.class;
+    }
+
+    @Nonnull
+    @Override
+    public String defaultConfig() {
+      return log("default");
+    }
+
+    @Override
+    public String getConfigFor(@Nonnull String packageName) {
+      return log(packageName.isEmpty() ? "(top)" : packageName);
+    }
+
+    @Override
+    public String getConfigFor(@Nonnull Class<?> testClass) {
+      return log(testClass.getName());
+    }
+
+    @Override
+    public String getConfigFor(@Nonnull Method method) {
+      return log(method.getName());
+    }
+
+    @Nonnull
+    @Override
+    public String merge(@Nonnull String parentConfig, @Nonnull String childConfig) {
+      return parentConfig + ":" + childConfig;
+    }
+
+    private String log(String s) {
+      log.add(s);
+      return s;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/LazyApplicationConfigurerTest.java b/robolectric/src/test/java/org/robolectric/plugins/LazyApplicationConfigurerTest.java
new file mode 100644
index 0000000..9850817
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/LazyApplicationConfigurerTest.java
@@ -0,0 +1,22 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.OFF;
+import static org.robolectric.annotation.experimental.LazyApplication.LazyLoad.ON;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit test for {@link LazyApplicationConfigurer} */
+@RunWith(JUnit4.class)
+public class LazyApplicationConfigurerTest {
+
+  private LazyApplicationConfigurer configurer = new LazyApplicationConfigurer();
+
+  @Test
+  public void merge_explicitChildConfigOverridesParent() {
+    assertThat(configurer.merge(ON, OFF)).isEqualTo(OFF);
+    assertThat(configurer.merge(OFF, ON)).isEqualTo(ON);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/LegacyDependencyResolverTest.java b/robolectric/src/test/java/org/robolectric/plugins/LegacyDependencyResolverTest.java
new file mode 100644
index 0000000..4cb13a0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/LegacyDependencyResolverTest.java
@@ -0,0 +1,131 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Properties;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.internal.dependency.DependencyJar;
+import org.robolectric.internal.dependency.DependencyResolver;
+import org.robolectric.plugins.LegacyDependencyResolver.DefinitelyNotAClassLoader;
+import org.robolectric.res.Fs;
+import org.robolectric.util.TempDirectory;
+
+@RunWith(JUnit4.class)
+public class LegacyDependencyResolverTest {
+
+  private static final String VERSION = "4.3_r2-robolectric-r1";
+  private static final DependencyJar DEPENDENCY_COORDS =
+      new DependencyJar("org.robolectric", "android-all", VERSION);
+
+  private TempDirectory tempDirectory;
+  private Properties properties;
+  private DefinitelyNotAClassLoader mockClassLoader;
+
+  @Before
+  public void setUp() throws Exception {
+    tempDirectory = new TempDirectory();
+    properties = new Properties();
+    mockClassLoader = mock(DefinitelyNotAClassLoader.class);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    tempDirectory.destroy();
+  }
+
+  @Test
+  public void whenRobolectricDepsPropertiesProperty() throws Exception {
+    Path depsPath = tempDirectory
+        .createFile("deps.properties",
+            "org.robolectric\\:android-all\\:" + VERSION + ": file-123.jar");
+    Path jarPath = tempDirectory.createFile("file-123.jar", "...");
+
+    properties.setProperty("robolectric-deps.properties", depsPath.toString());
+
+    DependencyResolver resolver = new LegacyDependencyResolver(properties, mockClassLoader);
+
+    URL jarUrl = resolver.getLocalArtifactUrl(DEPENDENCY_COORDS);
+    assertThat(Fs.fromUrl(jarUrl)).isEqualTo(jarPath);
+  }
+
+  @Test
+  public void whenRobolectricDepsPropertiesPropertyAndOfflineProperty() throws Exception {
+    Path depsPath = tempDirectory
+        .createFile("deps.properties",
+            "org.robolectric\\:android-all\\:" + VERSION + ": file-123.jar");
+    Path jarPath = tempDirectory.createFile("file-123.jar", "...");
+
+    properties.setProperty("robolectric-deps.properties", depsPath.toString());
+    properties.setProperty("robolectric.offline", "true");
+
+    DependencyResolver resolver = new LegacyDependencyResolver(properties, mockClassLoader);
+
+    URL jarUrl = resolver.getLocalArtifactUrl(DEPENDENCY_COORDS);
+    assertThat(Fs.fromUrl(jarUrl)).isEqualTo(jarPath);
+  }
+
+  @Test
+  public void whenRobolectricDepsPropertiesResource() throws Exception {
+    Path depsPath = tempDirectory
+        .createFile("deps.properties",
+            "org.robolectric\\:android-all\\:" + VERSION + ": file-123.jar");
+
+    when(mockClassLoader.getResource("robolectric-deps.properties")).thenReturn(meh(depsPath));
+    DependencyResolver resolver = new LegacyDependencyResolver(properties, mockClassLoader);
+
+    URL jarUrl = resolver.getLocalArtifactUrl(DEPENDENCY_COORDS);
+    assertThat(Fs.fromUrl(jarUrl).toString()).endsWith("file-123.jar");
+  }
+
+  @Test
+  public void whenRobolectricDependencyDirProperty() throws Exception {
+    Path jarsPath = tempDirectory.create("jars");
+    Path sdkJarPath = tempDirectory.createFile("jars/android-all-" + VERSION + ".jar", "...");
+
+    properties.setProperty("robolectric.dependency.dir", jarsPath.toString());
+
+    DependencyResolver resolver = new LegacyDependencyResolver(properties, mockClassLoader);
+
+    URL jarUrl = resolver.getLocalArtifactUrl(DEPENDENCY_COORDS);
+    assertThat(Fs.fromUrl(jarUrl)).isEqualTo(sdkJarPath);
+  }
+
+  @Test
+  public void whenNoPropertiesOrResourceFile() throws Exception {
+    when(mockClassLoader.getResource("robolectric-deps.properties")).thenReturn(null);
+    when(mockClassLoader.loadClass("org.robolectric.internal.dependency.MavenDependencyResolver"))
+        .thenReturn((Class) FakeMavenDependencyResolver.class);
+
+    DependencyResolver resolver = new LegacyDependencyResolver(properties, mockClassLoader);
+
+    URL jarUrl = resolver.getLocalArtifactUrl(DEPENDENCY_COORDS);
+    assertThat(Fs.fromUrl(jarUrl))
+        .isEqualTo(Paths.get("/some/fake/file.jar").toAbsolutePath());
+  }
+
+  public static class FakeMavenDependencyResolver implements DependencyResolver {
+    @Override
+    public URL getLocalArtifactUrl(DependencyJar dependency) {
+      return meh(Paths.get("/some/fake/file.jar"));
+    }
+  }
+
+  private static URL meh(Path path) {
+    try {
+      return path.toUri().toURL();
+    } catch (MalformedURLException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/LooperModeConfigurerClassTest.java b/robolectric/src/test/java/org/robolectric/plugins/LooperModeConfigurerClassTest.java
new file mode 100644
index 0000000..960f825
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/LooperModeConfigurerClassTest.java
@@ -0,0 +1,52 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowLegacyLooper;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.ShadowPausedLooper;
+
+/**
+ * Unit tests for classes annotated with @LooperMode.
+ */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(Mode.PAUSED)
+public class LooperModeConfigurerClassTest {
+
+  @Test
+  public void defaultsToClass() {
+    assertThat(ConfigurationRegistry.get(LooperMode.Mode.class)).isSameInstanceAs(Mode.PAUSED);
+  }
+
+  @Test
+  @LooperMode(Mode.LEGACY)
+  public void overriddenAtMethod() {
+    assertThat(ConfigurationRegistry.get(LooperMode.Mode.class)).isSameInstanceAs(Mode.LEGACY);
+  }
+
+  @Test
+  @LooperMode(Mode.LEGACY)
+  public void shouldUseLegacyShadows() {
+    assertThat(ConfigurationRegistry.get(LooperMode.Mode.class)).isSameInstanceAs(Mode.LEGACY);
+
+    ShadowLooper looper = Shadow.extract(Looper.getMainLooper());
+    assertThat(looper).isInstanceOf(ShadowLegacyLooper.class);
+  }
+
+  @Test
+  @LooperMode(Mode.PAUSED)
+  public void shouldUseRealisticShadows() {
+    assertThat(ConfigurationRegistry.get(LooperMode.Mode.class)).isSameInstanceAs(Mode.PAUSED);
+
+    ShadowLooper looper = Shadow.extract(Looper.getMainLooper());
+    assertThat(looper).isInstanceOf(ShadowPausedLooper.class);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/LooperModeConfigurerTest.java b/robolectric/src/test/java/org/robolectric/plugins/LooperModeConfigurerTest.java
new file mode 100644
index 0000000..43f5431
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/LooperModeConfigurerTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Properties;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.LooperMode;
+
+/**
+ * Unit tests for methods annotated with @LooperMode.
+ */
+@RunWith(JUnit4.class)
+public class LooperModeConfigurerTest {
+
+  @Test
+  public void defaultConfig() {
+    Properties systemProperties = new Properties();
+    LooperModeConfigurer configurer = new LooperModeConfigurer(systemProperties);
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(LooperMode.Mode.PAUSED);
+
+    systemProperties.setProperty("robolectric.looperMode", "LEGACY");
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(LooperMode.Mode.LEGACY);
+
+    systemProperties.setProperty("robolectric.looperMode", "PAUSED");
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(LooperMode.Mode.PAUSED);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/SQLiteModeConfigurerClassTest.java b/robolectric/src/test/java/org/robolectric/plugins/SQLiteModeConfigurerClassTest.java
new file mode 100644
index 0000000..4f35d4e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/SQLiteModeConfigurerClassTest.java
@@ -0,0 +1,69 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.CursorWindow;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.annotation.SQLiteMode.Mode;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowCursorWindow;
+import org.robolectric.shadows.ShadowLegacyCursorWindow;
+import org.robolectric.shadows.ShadowNativeCursorWindow;
+
+/** Unit tests for classes annotated with @LooperMode. */
+@RunWith(AndroidJUnit4.class)
+@SQLiteMode(Mode.LEGACY)
+public class SQLiteModeConfigurerClassTest {
+
+  @Test
+  public void defaultsToClass() {
+    assertThat(ConfigurationRegistry.get(SQLiteMode.Mode.class)).isSameInstanceAs(Mode.LEGACY);
+  }
+
+  @Test
+  @SQLiteMode(Mode.NATIVE)
+  public void overriddenAtMethod() {
+    assertThat(ConfigurationRegistry.get(Mode.class)).isSameInstanceAs(Mode.NATIVE);
+  }
+
+  @Test
+  @SQLiteMode(Mode.LEGACY)
+  public void shouldUseLegacyShadows() {
+    assertThat(ConfigurationRegistry.get(Mode.class)).isSameInstanceAs(Mode.LEGACY);
+    try (CursorWindow cursorWindow = new CursorWindow("1")) {
+      ShadowCursorWindow shadow = Shadow.extract(cursorWindow);
+      assertThat(shadow).isInstanceOf(ShadowLegacyCursorWindow.class);
+    }
+  }
+
+  @Test
+  @SQLiteMode(Mode.NATIVE)
+  public void shouldUseRealisticShadows() {
+    assertThat(ConfigurationRegistry.get(Mode.class)).isSameInstanceAs(Mode.NATIVE);
+    try (CursorWindow cursorWindow = new CursorWindow("2")) {
+      ShadowCursorWindow shadow = Shadow.extract(cursorWindow);
+      assertThat(shadow).isInstanceOf(ShadowNativeCursorWindow.class);
+    }
+  }
+
+  @Test
+  @SQLiteMode(Mode.NATIVE)
+  @Config(shadows = MyShadowCursorWindow.class)
+  public void shouldPreferCustomShadows() {
+    assertThat(ConfigurationRegistry.get(Mode.class)).isSameInstanceAs(Mode.NATIVE);
+    try (CursorWindow cursorWindow = new CursorWindow("3")) {
+      ShadowCursorWindow shadow = Shadow.extract(cursorWindow);
+      assertThat(shadow).isInstanceOf(MyShadowCursorWindow.class);
+    }
+  }
+
+  /** A custom {@link android.database.CursorWindow} shadow for testing */
+  @Implements(CursorWindow.class)
+  public static class MyShadowCursorWindow extends ShadowLegacyCursorWindow {}
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/SQLiteModeConfigurerTest.java b/robolectric/src/test/java/org/robolectric/plugins/SQLiteModeConfigurerTest.java
new file mode 100644
index 0000000..92a55f1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/SQLiteModeConfigurerTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Properties;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.annotation.SQLiteMode.Mode;
+
+/** Unit tests for methods annotated with @{@link SQLiteMode}. */
+@RunWith(JUnit4.class)
+public class SQLiteModeConfigurerTest {
+
+  @Test
+  public void defaultConfigWithPrePopulatedSQLiteMode() {
+    Properties systemProperties = new Properties();
+    SQLiteModeConfigurer configurer = new SQLiteModeConfigurer(systemProperties);
+
+    systemProperties.setProperty("robolectric.sqliteMode", "LEGACY");
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(Mode.LEGACY);
+
+    systemProperties.setProperty("robolectric.sqliteMode", "NATIVE");
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(Mode.NATIVE);
+  }
+
+  @Test
+  public void osArchSpecificConfig() {
+    Properties systemProperties = new Properties();
+    SQLiteModeConfigurer configurer = new SQLiteModeConfigurer(systemProperties);
+
+    systemProperties.setProperty("os.name", "Mac OS X");
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(Mode.NATIVE);
+
+    systemProperties.setProperty("os.name", "Windows 7");
+    assertThat(configurer.defaultConfig()).isSameInstanceAs(Mode.LEGACY);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/SdkCollectionTest.java b/robolectric/src/test/java/org/robolectric/plugins/SdkCollectionTest.java
new file mode 100644
index 0000000..65db198
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/SdkCollectionTest.java
@@ -0,0 +1,83 @@
+package org.robolectric.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.pluginapi.SdkProvider;
+
+/** Test for {@link SdkCollection}. */
+@RunWith(JUnit4.class)
+public class SdkCollectionTest {
+
+  private SdkProvider mockSdkProvider;
+  private SdkCollection sdkCollection;
+  private Sdk fakeSdk1234;
+  private Sdk fakeSdk1235;
+  private Sdk fakeSdk1236;
+  private Sdk fakeUnsupportedSdk1237;
+
+  @Before
+  public void setUp() throws Exception {
+    mockSdkProvider = mock(SdkProvider.class);
+    fakeSdk1234 = new StubSdk(1234, true);
+    fakeSdk1235 = new StubSdk(1235, true);
+    fakeSdk1236 = new StubSdk(1236, true);
+    fakeUnsupportedSdk1237 = new StubSdk(1237, false);
+    when(mockSdkProvider.getSdks())
+        .thenReturn(Arrays.asList(fakeSdk1235, fakeSdk1234, fakeSdk1236, fakeUnsupportedSdk1237));
+
+    sdkCollection = new SdkCollection(mockSdkProvider);
+  }
+
+  @Test
+  public void shouldComplainAboutDupes() throws Exception {
+    try {
+      new SdkCollection(() -> Arrays.asList(fakeSdk1234, fakeSdk1234));
+      fail();
+    } catch (Exception e) {
+      assertThat(e).hasMessageThat().contains("duplicate SDKs for API level 1234");
+    }
+  }
+
+  @Test
+  public void shouldCacheSdks() throws Exception {
+    assertThat(sdkCollection.getSdk(1234)).isSameInstanceAs(fakeSdk1234);
+    assertThat(sdkCollection.getSdk(1234)).isSameInstanceAs(fakeSdk1234);
+
+    verify(mockSdkProvider, times(1)).getSdks();
+  }
+
+  @Test
+  public void getMaxSupportedSdk() throws Exception {
+    assertThat(sdkCollection.getMaxSupportedSdk()).isSameInstanceAs(fakeSdk1236);
+  }
+
+  @Test
+  public void getSdk_shouldReturnNullObjectForUnknownSdks() throws Exception {
+    assertThat(sdkCollection.getSdk(4321)).isNotNull();
+    assertThat(sdkCollection.getSdk(4321).isKnown()).isFalse();
+  }
+
+  @Test
+  public void getKnownSdks_shouldReturnAll() throws Exception {
+    assertThat(sdkCollection.getKnownSdks())
+        .containsExactly(fakeSdk1234, fakeSdk1235, fakeSdk1236, fakeUnsupportedSdk1237).inOrder();
+  }
+
+  @Test
+  public void getSupportedSdks_shouldReturnOnlySupported() throws Exception {
+    assertThat(sdkCollection.getSupportedSdks())
+        .containsExactly(fakeSdk1234, fakeSdk1235, fakeSdk1236).inOrder();
+  }
+
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/plugins/StubSdk.java b/robolectric/src/test/java/org/robolectric/plugins/StubSdk.java
new file mode 100644
index 0000000..3fbd249
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/StubSdk.java
@@ -0,0 +1,55 @@
+package org.robolectric.plugins;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.AssumptionViolatedException;
+import org.robolectric.pluginapi.Sdk;
+
+/** Stub SDK */
+public class StubSdk extends Sdk {
+
+  private final boolean isSupported;
+
+  public StubSdk(int apiLevel, boolean isSupported) {
+    super(apiLevel);
+    this.isSupported = isSupported;
+  }
+
+  @Override
+  public String getAndroidVersion() {
+    return null;
+  }
+
+  @Override
+  public String getAndroidCodeName() {
+    return null;
+  }
+
+  @Override
+  public Path getJarPath() {
+    return Paths.get("fake/path-" + getApiLevel() + ".jar");
+  }
+
+  @Override
+  public boolean isSupported() {
+    return isSupported;
+  }
+
+  @Override
+  public String getUnsupportedMessage() {
+    return "unsupported";
+  }
+
+  @Override
+  public boolean isKnown() {
+    return true;
+  }
+
+  @Override
+  public void verifySupportedSdk(String testClassName) {
+    if (isKnown() && !isSupported()) {
+      throw new AssumptionViolatedException(
+          "Failed to create a Robolectric sandbox: " + getUnsupportedMessage());
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/config/LooperModeConfigurerPkgTest.java b/robolectric/src/test/java/org/robolectric/plugins/config/LooperModeConfigurerPkgTest.java
new file mode 100644
index 0000000..b1acd52
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/config/LooperModeConfigurerPkgTest.java
@@ -0,0 +1,28 @@
+package org.robolectric.plugins.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.config.ConfigurationRegistry;
+
+/**
+ * Unit tests for packages annotated with @LooperMode.
+ */
+@RunWith(AndroidJUnit4.class)
+public class LooperModeConfigurerPkgTest {
+
+  @Test
+  public void fromPkg() {
+    assertThat(ConfigurationRegistry.get(LooperMode.Mode.class)).isSameInstanceAs(Mode.PAUSED);
+  }
+
+  @Test
+  @LooperMode(Mode.LEGACY)
+  public void overriddenAtMethod() {
+    assertThat(ConfigurationRegistry.get(LooperMode.Mode.class)).isSameInstanceAs(Mode.LEGACY);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/plugins/config/package-info.java b/robolectric/src/test/java/org/robolectric/plugins/config/package-info.java
new file mode 100644
index 0000000..e18a224
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/plugins/config/package-info.java
@@ -0,0 +1,5 @@
+@LooperMode(Mode.PAUSED)
+package org.robolectric.plugins.config;
+
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
diff --git a/robolectric/src/test/java/org/robolectric/res/DrawableResourceLoaderNoRunnerTest.java b/robolectric/src/test/java/org/robolectric/res/DrawableResourceLoaderNoRunnerTest.java
new file mode 100644
index 0000000..e2ecc43
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/DrawableResourceLoaderNoRunnerTest.java
@@ -0,0 +1,38 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.nio.file.Path;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.res.android.ResTable_config;
+
+@RunWith(JUnit4.class)
+public class DrawableResourceLoaderNoRunnerTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private PackageResourceTable resourceTable;
+
+  @Before
+  public void setUp() {
+    resourceTable = new ResourceTableFactory().newResourceTable("org.robolectric");
+  }
+
+  @Test
+  public void shouldFindDrawableResources() throws Exception {
+    Path testBaseDir = temporaryFolder.newFolder("res").toPath();
+    temporaryFolder.newFolder("res", "drawable");
+    temporaryFolder.newFile("res/drawable/foo.png");
+    ResourcePath resourcePath = new ResourcePath(null, testBaseDir, null);
+
+    DrawableResourceLoader testLoader = new DrawableResourceLoader(resourceTable);
+    testLoader.findDrawableResources(resourcePath);
+
+    assertThat(resourceTable.getValue(new ResName("org.robolectric", "drawable", "foo"), new ResTable_config()).isFile()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/RawResourceLoaderTest.java b/robolectric/src/test/java/org/robolectric/res/RawResourceLoaderTest.java
new file mode 100644
index 0000000..219a2fa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/RawResourceLoaderTest.java
@@ -0,0 +1,49 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.TestUtil.testResources;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.R;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.util.TestUtil;
+
+@RunWith(JUnit4.class)
+public class RawResourceLoaderTest {
+
+  private PackageResourceTable resourceTable;
+
+  @Before
+  public void setUp() throws Exception {
+    resourceTable = new ResourceTableFactory().newResourceTable("packageName", testResources());
+    RawResourceLoader rawResourceLoader = new RawResourceLoader(TestUtil.testResources());
+    rawResourceLoader.loadTo(resourceTable);
+  }
+
+  @Test
+  public void shouldReturnRawResourcesWithExtensions() throws Exception {
+    String f = (String) resourceTable.getValue(R.raw.raw_resource, new ResTable_config()).getData();
+    assertThat(f)
+        .isEqualTo(
+            TestUtil.testResources()
+                .getResourceBase()
+                .resolve("raw")
+                .resolve("raw_resource.txt")
+                .toString());
+  }
+
+  @Test
+  public void shouldReturnRawResourcesWithoutExtensions() throws Exception {
+    String f = (String) resourceTable.getValue(R.raw.raw_no_ext, new ResTable_config()).getData();
+    assertThat(f)
+        .isEqualTo(
+            TestUtil.testResources()
+                .getResourceBase()
+                .resolve("raw")
+                .resolve("raw_no_ext")
+                .toString());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResBundleTest.java b/robolectric/src/test/java/org/robolectric/res/ResBundleTest.java
new file mode 100644
index 0000000..0c4927c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResBundleTest.java
@@ -0,0 +1,211 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.base.Strings;
+import javax.annotation.Nonnull;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.res.android.ConfigDescription;
+import org.robolectric.res.android.ResTable_config;
+
+@RunWith(JUnit4.class)
+public class ResBundleTest {
+  private ResBundle.ResMap resMap = new ResBundle.ResMap();
+  private ResName resName;
+
+  @Before
+  public void setUp() throws Exception {
+    resName = new ResName("a:b/c");
+  }
+
+  @Test
+  public void closestMatchIsPicked() {
+    TypedResource<String> val1 = createStringTypedResource("v16");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("v17");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("v18"));
+    assertThat(v).isEqualTo(val2);
+  }
+
+  @Test
+  public void firstValIsPickedWhenNoMatch() {
+    TypedResource<String> val1 = createStringTypedResource("en");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("fr");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("en-v18"));
+    assertThat(v).isEqualTo(val1);
+  }
+
+  @Test
+  public void bestValIsPickedForSdkVersion() {
+    TypedResource<String> val1 = createStringTypedResource("v16");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("v17");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("v26"));
+    assertThat(v).isEqualTo(val2);
+  }
+
+  @Test
+  public void eliminatedValuesAreNotPickedForVersion() {
+    TypedResource<String> val1 = createStringTypedResource("land-v16");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("v17");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("land-v18"));
+    assertThat(v).isEqualTo(val1);
+  }
+
+  @Test
+  public void greaterVersionsAreNotPicked() {
+    TypedResource<String> val1 = createStringTypedResource("v11");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("v19");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("v18"));
+    assertThat(v).isEqualTo(val1);
+  }
+
+  @Test
+  public void greaterVersionsAreNotPickedReordered() {
+    TypedResource<String> val1 = createStringTypedResource("v19");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("v11");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("v18"));
+    assertThat(v).isEqualTo(val2);
+  }
+
+  @Test
+  public void greaterVersionsAreNotPickedMoreQualifiers() {
+    // List the contradicting qualifier first, in case the algorithm has a tendency
+    // to pick the first qualifier when none of the qualifiers are a "perfect" match.
+    TypedResource<String> val1 = createStringTypedResource("anydpi-v21");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("xhdpi-v9");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("v18"));
+    assertThat(v).isEqualTo(val2);
+  }
+
+  @Test
+  public void onlyMatchingVersionsQualifiersWillBePicked() {
+    TypedResource<String> val1 = createStringTypedResource("v16");
+    resMap.put(resName, val1);
+    TypedResource<String> val2 = createStringTypedResource("sw600dp-v17");
+    resMap.put(resName, val2);
+
+    TypedResource v = resMap.pick(resName, from("v18"));
+    assertThat(v).isEqualTo(val1);
+  }
+
+  @Test
+  public void illegalResourceQualifierThrowsException() {
+    TypedResource<String> val1 = createStringTypedResource("en-v12");
+    resMap.put(resName, val1);
+
+    try {
+      resMap.pick(resName, from("nosuchqualifier"));
+      fail("Expected exception to be caught");
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().startsWith("Invalid qualifiers \"nosuchqualifier\"");
+    }
+  }
+
+  @Test
+  public void shouldMatchQualifiersPerAndroidSpec() throws Exception {
+    assertEquals("en-port", asResMap(
+        "",
+        "en",
+        "fr-rCA",
+        "en-port",
+        "en-notouch-12key",
+        "port-ldpi",
+        "land-notouch-12key").pick(resName,
+        from("en-rGB-port-hdpi-notouch-12key-v25")).asString());
+  }
+
+  @Test
+  public void shouldMatchQualifiersInSizeRange() throws Exception {
+    assertEquals("sw300dp-port", asResMap(
+        "",
+        "sw200dp",
+        "sw350dp-port",
+        "sw300dp-port",
+        "sw300dp").pick(resName,
+        from("sw320dp-port-v25")).asString());
+  }
+
+  @Test
+  public void shouldPreferWidthOverHeight() throws Exception {
+    assertEquals("sw300dp-w200dp", asResMap(
+        "",
+        "sw200dp",
+        "sw200dp-w300dp",
+        "sw300dp-w200dp",
+        "w300dp").pick(resName,
+        from("sw320dp-w320dp-v25")).asString());
+  }
+
+  @Test
+  public void shouldNotOverwriteValuesWithMatchingQualifiers() {
+    ResBundle bundle = new ResBundle();
+    XmlContext xmlContext = mock(XmlContext.class);
+    when(xmlContext.getQualifiers()).thenReturn(Qualifiers.parse("--"));
+    when(xmlContext.getConfig()).thenReturn(new ResTable_config());
+    when(xmlContext.getPackageName()).thenReturn("org.robolectric");
+
+    TypedResource firstValue = new TypedResource<>("first_value", ResType.CHAR_SEQUENCE, xmlContext);
+    TypedResource secondValue = new TypedResource<>("second_value", ResType.CHAR_SEQUENCE, xmlContext);
+    bundle.put(new ResName("org.robolectric", "string", "resource_name"), firstValue);
+    bundle.put(new ResName("org.robolectric", "string", "resource_name"), secondValue);
+
+    assertThat(bundle.get(new ResName("org.robolectric", "string", "resource_name"), from("")).getData()).isEqualTo("first_value");
+  }
+
+  private ResBundle.ResMap asResMap(String... qualifierses) {
+    ResBundle.ResMap resMap = new ResBundle.ResMap();
+    for (String qualifiers : qualifierses) {
+      resMap.put(resName, createStringTypedResource(qualifiers, qualifiers));
+    }
+    return resMap;
+  }
+
+  private static TypedResource<String> createStringTypedResource(String qualifiers) {
+    return createStringTypedResource("title from resourceLoader1", qualifiers);
+  }
+
+  @Nonnull
+  private static TypedResource<String> createStringTypedResource(String str, String qualifiersStr) {
+    XmlContext mockXmlContext = mock(XmlContext.class);
+    Qualifiers qualifiers = Qualifiers.parse(qualifiersStr);
+    when(mockXmlContext.getQualifiers()).thenReturn(qualifiers);
+    when(mockXmlContext.getConfig()).thenReturn(qualifiers.getConfig());
+    return new TypedResource<>(str, ResType.CHAR_SEQUENCE, mockXmlContext);
+  }
+
+  private static ResTable_config from(String qualifiers) {
+    ResTable_config config = new ResTable_config();
+    if (!Strings.isNullOrEmpty(qualifiers) &&
+        !ConfigDescription.parse(qualifiers, config, false)) {
+      throw new IllegalArgumentException("Invalid qualifiers \"" + qualifiers + "\"");
+    }
+    return config;
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/res/ResNameTest.java b/robolectric/src/test/java/org/robolectric/res/ResNameTest.java
new file mode 100644
index 0000000..7859bc2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResNameTest.java
@@ -0,0 +1,98 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ResNameTest {
+  @Test public void shouldQualify() throws Exception {
+    assertThat(ResName.qualifyResourceName("some.package:type/name", null, null)).isEqualTo("some.package:type/name");
+    assertThat(ResName.qualifyResourceName("some.package:type/name", "default.package", "deftype")).isEqualTo("some.package:type/name");
+    assertThat(ResName.qualifyResourceName("*android:type/name", "default.package", "deftype"))
+        .isEqualTo("android:type/name");
+    assertThat(ResName.qualifyResourceName("some.package:name", "default.package", "deftype")).isEqualTo("some.package:deftype/name");
+    assertThat(ResName.qualifyResourceName("type/name", "default.package", "deftype")).isEqualTo("default.package:type/name");
+    assertThat(ResName.qualifyResourceName("name", "default.package", "deftype")).isEqualTo("default.package:deftype/name");
+    assertThat(ResName.qualifyResourceName("someRawString", "default.package", null)).isNull();
+  }
+
+  @Test public void shouldQualifyResNameFromString() throws Exception {
+    assertThat(ResName.qualifyResName("some.package:type/name", "default_package", "default_type"))
+        .isEqualTo(new ResName("some.package", "type", "name"));
+    assertThat(ResName.qualifyResName("some.package:name", "default_package", "default_type"))
+        .isEqualTo(new ResName("some.package", "default_type", "name"));
+    assertThat(ResName.qualifyResName("type/name", "default_package", "default_type"))
+        .isEqualTo(new ResName("default_package", "type", "name"));
+    assertThat(ResName.qualifyResName("name", "default_package", "default_type"))
+        .isEqualTo(new ResName("default_package", "default_type", "name"));
+    assertThat(ResName.qualifyResName("type/package:name", "default_package", "default_type"))
+        .isEqualTo(new ResName("package", "type", "name"));
+  }
+
+  @Test
+  public void qualifyFromFilePathShouldExtractResourceTypeAndNameFromUnqualifiedPath() {
+    final ResName actual = ResName.qualifyFromFilePath("some.package", "./res/drawable/icon.png");
+    assertThat(actual.getFullyQualifiedName()).isEqualTo("some.package:drawable/icon");
+  }
+
+  @Test
+  public void qualifyFromFilePathShouldExtractResourceTypeAndNameFromQualifiedPath() {
+    final ResName actual = ResName.qualifyFromFilePath("some.package", "./res/drawable-hdpi/icon.png");
+    assertThat(actual.getFullyQualifiedName()).isEqualTo("some.package:drawable/icon");
+  }
+
+  @Test
+  public void hierarchicalNameHandlesWhiteSpace() {
+    String name = "TextAppearance.AppCompat.Widget.ActionMode.Subtitle\n" +
+        "    ";
+
+    ResName resName = new ResName("org.robolectric.example", "style", name);
+    assertThat(resName.name).isEqualTo("TextAppearance.AppCompat.Widget.ActionMode.Subtitle");
+    assertThat(resName.type).isEqualTo("style");
+    assertThat(resName.packageName).isEqualTo("org.robolectric.example");
+  }
+
+  @Test
+  public void simpleNameHandlesWhiteSpace() {
+    String name = "Subtitle\n" +
+        "    ";
+
+    ResName resName = new ResName("org.robolectric.example", "style", name);
+    assertThat(resName.name).isEqualTo("Subtitle");
+    assertThat(resName.type).isEqualTo("style");
+    assertThat(resName.packageName).isEqualTo("org.robolectric.example");
+  }
+
+  @Test
+  public void fullyQualifiedNameHandlesWhiteSpace() {
+    String name = "android:style/TextAppearance.AppCompat.Widget.ActionMode.Subtitle\n" +
+        "    ";
+
+    ResName resName = new ResName(name);
+    assertThat(resName.name).isEqualTo("TextAppearance.AppCompat.Widget.ActionMode.Subtitle");
+    assertThat(resName.type).isEqualTo("style");
+    assertThat(resName.packageName).isEqualTo("android");
+  }
+
+  @Test
+  public void fullyQualifiedNameWithWhiteSpaceInTypeShouldBeHandledCorrectly() {
+    String name = "android: string/ok";
+    ResName resName = new ResName(name);
+
+    assertThat(resName.name).isEqualTo("ok");
+    assertThat(resName.type).isEqualTo("string");
+    assertThat(resName.packageName).isEqualTo("android");
+  }
+
+  @Test
+  public void resourceNameWithWhiteSpaceInTypeShouldBeHandledCorrectly() {
+    ResName resName = new ResName("android", " string", "ok");
+
+    assertThat(resName.name).isEqualTo("ok");
+    assertThat(resName.type).isEqualTo("string");
+    assertThat(resName.packageName).isEqualTo("android");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResourceIdGeneratorTest.java b/robolectric/src/test/java/org/robolectric/res/ResourceIdGeneratorTest.java
new file mode 100644
index 0000000..e8283d4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResourceIdGeneratorTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ResourceIdGeneratorTest {
+
+  @Test
+  public void shouldGenerateUniqueId() {
+    ResourceIdGenerator generator = new ResourceIdGenerator(0x7F);
+    generator.record(0x7F010001, "string", "some_name");
+    generator.record(0x7F010002, "string", "another_name");
+
+    assertThat(generator.generate("string", "next_name")).isEqualTo(0x7F010003);
+  }
+
+  @Test
+  public void shouldIdForUnseenType() {
+    ResourceIdGenerator generator = new ResourceIdGenerator(0x7F);
+    generator.record(0x7F010001, "string", "some_name");
+    generator.record(0x7F010002, "string", "another_name");
+
+    assertThat(generator.generate("int", "int_name")).isEqualTo(0x7F020001);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResourceParserTest.java b/robolectric/src/test/java/org/robolectric/res/ResourceParserTest.java
new file mode 100644
index 0000000..7b5d59e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResourceParserTest.java
@@ -0,0 +1,69 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.TestUtil.testResources;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.res.android.ResTable_config;
+
+@RunWith(JUnit4.class)
+public class ResourceParserTest {
+
+  private ResourceTable resourceTable;
+  private ResTable_config config;
+
+  @Before
+  public void setUp() {
+    ResourceTableFactory resourceTableFactory = new ResourceTableFactory();
+    resourceTable = resourceTableFactory.newResourceTable("org.robolectric", testResources());
+    config = new ResTable_config();
+  }
+
+  @Test
+  public void shouldLoadDrawableXmlResources() {
+    TypedResource value = resourceTable.getValue(new ResName("org.robolectric", "drawable", "rainbow"), config);
+    assertThat(value).isNotNull();
+    assertThat(value.getResType()).isEqualTo(ResType.DRAWABLE);
+    assertThat(value.isFile()).isTrue();
+    assertThat((String) value.getData()).contains("rainbow.xml");
+  }
+
+  @Test
+  public void shouldLoadDrawableBitmapResources() {
+    TypedResource value = resourceTable.getValue(new ResName("org.robolectric", "drawable", "an_image"), config);
+    assertThat(value).isNotNull();
+    assertThat(value.getResType()).isEqualTo(ResType.DRAWABLE);
+    assertThat(value.isFile()).isTrue();
+    assertThat((String) value.getData()).contains("an_image.png");
+  }
+
+  @Test
+  public void shouldLoadDrawableBitmapResourcesDefinedByItemTag() {
+    TypedResource value = resourceTable.getValue(new ResName("org.robolectric", "drawable", "example_item_drawable"), config);
+    assertThat(value).isNotNull();
+    assertThat(value.getResType()).isEqualTo(ResType.DRAWABLE);
+    assertThat(value.isReference()).isTrue();
+    assertThat((String) value.getData()).isEqualTo("@drawable/an_image");
+  }
+
+  @Test
+  public void shouldLoadIdResourcesDefinedByItemTag() {
+    TypedResource value = resourceTable.getValue(new ResName("org.robolectric", "id", "id_declared_in_item_tag"), config);
+    assertThat(value).isNotNull();
+    assertThat(value.getResType()).isEqualTo(ResType.CHAR_SEQUENCE);
+    assertThat(value.isReference()).isFalse();
+    assertThat(value.asString()).isEmpty();
+    assertThat((String) value.getData()).isEmpty();
+  }
+
+  @Test
+  public void whenIdItemsHaveStringContent_shouldLoadIdResourcesDefinedByItemTag() {
+    TypedResource value =
+        resourceTable.getValue(
+            new ResName("org.robolectric", "id", "id_with_string_value"), config);
+    assertThat(value.asString()).isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResourceRemapperTest.java b/robolectric/src/test/java/org/robolectric/res/ResourceRemapperTest.java
new file mode 100644
index 0000000..4b0d454
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResourceRemapperTest.java
@@ -0,0 +1,143 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ResourceRemapperTest {
+
+  @Test
+  public void forbidFinalRClasses() {
+    ResourceRemapper remapper = new ResourceRemapper(null);
+    assertThrows(IllegalArgumentException.class, () -> remapper.remapRClass(FinalRClass.class));
+  }
+
+  @SuppressWarnings("TruthConstantAsserts")
+  @Test
+  public void testRemap() {
+    ResourceRemapper remapper = new ResourceRemapper(ApplicationRClass.class);
+    remapper.remapRClass(SecondClass.class);
+    remapper.remapRClass(ThirdClass.class);
+
+    // Resource identifiers that are common across libraries should be remapped to the same value.
+    assertThat(ApplicationRClass.string.string_one).isEqualTo(SecondClass.string.string_one);
+    assertThat(ApplicationRClass.string.string_one).isEqualTo(ThirdClass.string.string_one);
+
+    // Resource identifiers that clash across two libraries should be remapped to different values.
+    assertThat(SecondClass.id.id_clash)
+        .isNotEqualTo(ThirdClass.id.another_id_clash);
+
+    // Styleable arrays of values should be updated to match the remapped values.
+    assertThat(ThirdClass.styleable.SomeStyleable).isEqualTo(ApplicationRClass.styleable.SomeStyleable);
+    assertThat(SecondClass.styleable.SomeStyleable).isEqualTo(ApplicationRClass.styleable.SomeStyleable);
+    assertThat(ApplicationRClass.styleable.SomeStyleable).asList().containsExactly(ApplicationRClass.attr.attr_one, ApplicationRClass.attr.attr_two);
+  }
+
+  @Test
+  public void resourcesOfDifferentTypes_shouldHaveDifferentTypeSpaces() {
+    ResourceRemapper remapper = new ResourceRemapper(ApplicationRClass.class);
+    remapper.remapRClass(SecondClass.class);
+    remapper.remapRClass(ThirdClass.class);
+
+    Set<Integer> allIds = new HashSet<>();
+    assertThat(allIds.add(ApplicationRClass.string.string_one)).isTrue();
+    assertThat(allIds.add(ApplicationRClass.string.string_two)).isTrue();
+    assertThat(allIds.add(SecondClass.integer.integer_one)).isTrue();
+    assertThat(allIds.add(SecondClass.integer.integer_two)).isTrue();
+    assertThat(allIds.add(SecondClass.string.string_one)).isFalse();
+    assertThat(allIds.add(SecondClass.string.string_three)).isTrue();
+    assertThat(allIds.add(ThirdClass.raw.raw_one)).isTrue();
+    assertThat(allIds.add(ThirdClass.raw.raw_two)).isTrue();
+
+    assertThat(ResourceIds.getTypeIdentifier(ApplicationRClass.string.string_one)).isEqualTo(ResourceIds.getTypeIdentifier(ApplicationRClass.string.string_two));
+    assertThat(ResourceIds.getTypeIdentifier(ApplicationRClass.string.string_one)).isEqualTo(ResourceIds.getTypeIdentifier(SecondClass.string.string_three));
+
+    assertThat(ResourceIds.getTypeIdentifier(ApplicationRClass.string.string_two)).isNotEqualTo(ResourceIds.getTypeIdentifier(SecondClass.integer.integer_two));
+    assertThat(ResourceIds.getTypeIdentifier(ThirdClass.raw.raw_two)).isNotEqualTo(ResourceIds.getTypeIdentifier(SecondClass.integer.integer_two));
+  }
+
+  public static final class FinalRClass {
+    public static final class string {
+      public static final int a_final_value = 0x7f020001;
+      public static final int another_final_value = 0x7f020002;
+    }
+  }
+
+  public static final class ApplicationRClass {
+    public static final class string {
+      public static final int string_one = 0x7f010001;
+      public static final int string_two = 0x7f010002;
+    }
+
+    public static final class attr {
+      public static int attr_one = 0x7f010008;
+      public static int attr_two = 0x7f010009;
+    }
+
+    public static final class styleable {
+      public static final int[] SomeStyleable = new int[]{ApplicationRClass.attr.attr_one, ApplicationRClass.attr.attr_two};
+      public static final int SomeStyleable_offsetX = 0;
+      public static final int SomeStyleable_offsetY = 1;
+    }
+  }
+
+  public static final class SecondClass {
+    public static final class id {
+      public static int id_clash = 0x7f010001;
+    }
+
+    public static final class integer {
+      public static int integer_one = 0x7f010001;
+      public static int integer_two = 0x7f010002;
+    }
+
+    public static final class string {
+      public static int string_one = 0x7f020001;
+      public static int string_three = 0x7f020002;
+    }
+
+    public static final class attr {
+      public static int attr_one = 0x7f010001;
+      public static int attr_two = 0x7f010002;
+    }
+
+    public static final class styleable {
+      public static final int[] SomeStyleable = new int[]{SecondClass.attr.attr_one, SecondClass.attr.attr_two};
+      public static final int SomeStyleable_offsetX = 0;
+      public static final int SomeStyleable_offsetY = 1;
+    }
+  }
+
+  public static final class ThirdClass {
+    public static final class id {
+      public static int another_id_clash = 0x7f010001;
+    }
+
+    public static final class raw {
+      public static int raw_one = 0x7f010001;
+      public static int raw_two = 0x7f010002;
+    }
+
+    public static final class string {
+      public static int string_one = 0x7f020009;
+    }
+
+    public static final class attr {
+      public static int attr_one = 0x7f010003;
+      public static int attr_two = 0x7f010004;
+    }
+
+    public static final class styleable {
+      public static final int[] SomeStyleable = new int[]{ThirdClass.attr.attr_one, ThirdClass.attr.attr_two};
+      public static final int SomeStyleable_offsetX = 0;
+      public static final int SomeStyleable_offsetY = 1;
+    }
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResourceTableFactoryTest.java b/robolectric/src/test/java/org/robolectric/res/ResourceTableFactoryTest.java
new file mode 100644
index 0000000..f1584f6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResourceTableFactoryTest.java
@@ -0,0 +1,48 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.TestUtil.systemResources;
+import static org.robolectric.util.TestUtil.testResources;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.R;
+
+@RunWith(JUnit4.class)
+public class ResourceTableFactoryTest {
+  private ResourceTable appResourceTable;
+  private ResourceTable systemResourceTable;
+
+  @Before
+  public void setUp() throws Exception {
+    ResourceTableFactory resourceTableFactory = new ResourceTableFactory();
+    appResourceTable = resourceTableFactory.newResourceTable("org.robolectric",
+        testResources());
+
+    systemResourceTable = resourceTableFactory.newFrameworkResourceTable(systemResources());
+  }
+
+  @Test
+  public void shouldHandleMipmapReferences() {
+    assertThat(appResourceTable.getResourceId(new ResName("org.robolectric:mipmap/mipmap_reference"))).isEqualTo(R.mipmap.mipmap_reference);
+  }
+
+  @Test
+  public void shouldHandleStyleable() throws Exception {
+    assertThat(appResourceTable.getResourceId(new ResName("org.robolectric:id/burritos"))).isEqualTo(R.id.burritos);
+    assertThat(appResourceTable.getResourceId(new ResName("org.robolectric:styleable/TitleBar_textStyle"))).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldPrefixAllSystemResourcesWithAndroid() throws Exception {
+    assertThat(systemResourceTable.getResourceId(new ResName("android:id/text1"))).isEqualTo(android.R.id.text1);
+  }
+
+  @Test
+  public void shouldRetainPackageNameForFullyQualifiedQueries() throws Exception {
+    assertThat(systemResourceTable.getResName(android.R.id.text1).getFullyQualifiedName()).isEqualTo("android:id/text1");
+    assertThat(appResourceTable.getResName(R.id.burritos).getFullyQualifiedName()).isEqualTo("org.robolectric:id/burritos");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResourceTableTest.java b/robolectric/src/test/java/org/robolectric/res/ResourceTableTest.java
new file mode 100644
index 0000000..6ccca64
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResourceTableTest.java
@@ -0,0 +1,58 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ResourceTableTest {
+
+  private PackageResourceTable resourceTable;
+
+  @Before
+  public void setUp() {
+    resourceTable = new ResourceTableFactory().newResourceTable("myPackage");
+  }
+
+  @Test
+  public void getPackageName_shouldReturnPackageNameOfItsResources() {
+    resourceTable.addResource(0x02999999, "type", "name");
+
+    assertThat(resourceTable.getPackageName()).isEqualTo("myPackage");
+  }
+
+  @Test
+  public void getPackageIdentifier_shouldReturnPackageIdentiferOfItsResources() {
+    resourceTable.addResource(0x02999999, "type", "name");
+
+    assertThat(resourceTable.getPackageIdentifier()).isEqualTo(0x02);
+  }
+
+  @Test
+  public void addResource_shouldPreventMixedPackageIdentifiers() {
+    resourceTable.addResource(0x02999999, "type", "name");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> resourceTable.addResource(0x03999999, "type", "name"));
+  }
+
+  @Test
+  public void shouldForbidIdClashes() {
+    resourceTable.addResource(0x02888888, "type", "name");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> resourceTable.addResource(0x02999999, "type", "name"));
+  }
+
+  @Test
+  public void shouldForbidDuplicateNames() {
+    resourceTable.addResource(0x02999999, "type", "name");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> resourceTable.addResource(0x02999999, "type", "anotherName"));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/ResourceTestUtil.java b/robolectric/src/test/java/org/robolectric/res/ResourceTestUtil.java
new file mode 100644
index 0000000..aaa289c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/ResourceTestUtil.java
@@ -0,0 +1,79 @@
+package org.robolectric.res;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+public class ResourceTestUtil {
+  void time(String message, Runnable runnable) {
+    long startTime = System.nanoTime();
+    for (int i = 0; i < 10; i++) {
+      runnable.run();
+    }
+    long elapsed = System.nanoTime() - startTime;
+    System.out.println("elapsed " + message + ": " + (elapsed / 1000000.0) + "ms");
+  }
+
+  @SuppressWarnings("rawtypes")
+  static String stringify(ResourceTable resourceTable) {
+    final HashMap<String, List<TypedResource>> map = new HashMap<>();
+    resourceTable.receive(new ResourceTable.Visitor() {
+      @Override
+      public void visit(ResName key, Iterable<TypedResource> values) {
+        List<TypedResource> v = new ArrayList<>();
+        for (TypedResource value : values) {
+          v.add(value);
+        }
+        map.put(key.getFullyQualifiedName(), v);
+      }
+    });
+    StringBuilder buf = new StringBuilder();
+    TreeSet<String> keys = new TreeSet<>(map.keySet());
+    for (String key : keys) {
+      buf.append(key).append(":\n");
+      for (TypedResource typedResource : map.get(key)) {
+        Object data = typedResource.getData();
+        if (data instanceof List) {
+          ArrayList<String> newList = new ArrayList<>();
+          for (Object item : ((List) data)) {
+            if (item.getClass().equals(TypedResource.class)) {
+              TypedResource typedResourceItem = (TypedResource) item;
+              newList.add(
+                  typedResourceItem.getData().toString()
+                      + " ("
+                      + typedResourceItem.getResType()
+                      + ")");
+            } else {
+              newList.add(item.toString());
+            }
+          }
+          data = newList.toString();
+        } else if (data instanceof StyleData) {
+          StyleData styleData = (StyleData) data;
+          final Map<String, String> attrs = new TreeMap<>();
+          styleData.visit(new StyleData.Visitor() {
+            @Override
+            public void visit(AttributeResource attributeResource) {
+              attrs.put(attributeResource.resName.getFullyQualifiedName(), attributeResource.value);
+            }
+          });
+          data = data.toString() + "^" + styleData.getParent() + " " + attrs;
+        }
+        buf.append("  ").append(data).append(" {").append(typedResource.getResType())
+            .append("/").append(typedResource.getConfig()).append(": ")
+            .append(shortContext(typedResource)).append("}").append("\n");
+      }
+    }
+    return buf.toString();
+  }
+
+  static String shortContext(TypedResource<?> typedResource) {
+    return typedResource
+        .getXmlContext()
+        .toString()
+        .replaceAll("jar:/usr/local/google/home/.*\\.jar\\!", "jar:");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/StringResourcesTest.java b/robolectric/src/test/java/org/robolectric/res/StringResourcesTest.java
new file mode 100644
index 0000000..c0b10f7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/StringResourcesTest.java
@@ -0,0 +1,106 @@
+package org.robolectric.res;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class StringResourcesTest {
+  @Test
+  public void escape_shouldEscapeStrings() {
+    assertThat(StringResources.escape("\"This'll work\"")).isEqualTo("This'll work");
+    assertThat(StringResources.escape("This\\'ll also work")).isEqualTo("This'll also work");
+
+    assertThat(StringResources.escape("This is a \\\"good string\\\".")).isEqualTo("This is a \"good string\".");
+    assertThat(StringResources.escape("This is a \"bad string with unescaped double quotes\"."))
+        .isEqualTo("This is a bad string with unescaped double quotes.");
+
+    assertThat(StringResources.escape("Text with escaped backslash followed by an \\\\\"unescaped double quote."))
+        .isEqualTo("Text with escaped backslash followed by an \\unescaped double quote.");
+  }
+
+  @Test
+  public void escape_shouldEscapeCodePoints() {
+    Map<String, String> tests = new HashMap<>();
+    tests.put("\\u0031", "1");
+    tests.put("1\\u0032", "12");
+    tests.put("\\u00312", "12");
+    tests.put("1\\u00323", "123");
+    tests.put("\\u005A", "Z");
+    tests.put("\\u005a", "Z");
+
+    for (Map.Entry<String, String> t : tests.entrySet()) {
+      assertThat(StringResources.processStringResources(t.getKey())).isEqualTo(t.getValue());
+    }
+  }
+
+  @Test
+  public void shouldTrimWhitespace() {
+    assertThat(StringResources.processStringResources("    ")).isEmpty();
+    assertThat(StringResources.processStringResources("Trailingwhitespace    ")).isEqualTo("Trailingwhitespace");
+    assertThat(StringResources.processStringResources("Leadingwhitespace    ")).isEqualTo("Leadingwhitespace");
+  }
+
+  @Test
+  public void shouldCollapseInternalWhiteSpaces() {
+    assertThat(StringResources.processStringResources("Whitespace     in     the          middle")).isEqualTo("Whitespace in the middle");
+    assertThat(StringResources.processStringResources("Some\n\n\n\nNewlines")).isEqualTo("Some Newlines");
+  }
+
+  @Test
+  public void escape_shouldRemoveUnescapedDoubleQuotes() {
+    Map<String, String> tests = new HashMap<>();
+    tests.put("a\\\"b", "a\"b");
+    tests.put("a\\\\\"b", "a\\b");
+    tests.put("a\\\\\\\"b", "a\\\"b");
+    tests.put("a\\\\\\\\\"b", "a\\\\b");
+
+    for (Map.Entry<String, String> t : tests.entrySet()) {
+      assertThat(StringResources.processStringResources(t.getKey())).isEqualTo(t.getValue());
+    }
+  }
+
+  // Unsupported escape codes should be ignored.
+  @Test
+  public void escape_shouldIgnoreUnsupportedEscapeCodes() {
+    assertThat(StringResources.processStringResources("\\ \\a\\b\\c\\d\\e\\ ")).isEqualTo("");
+  }
+
+  @Test
+  public void escape_shouldSupport() {
+    Map<String, String> tests = new HashMap<>();
+    tests.put("\\\\", "\\");
+    tests.put("domain\\\\username", "domain\\username");
+    for (Map.Entry<String, String> t : tests.entrySet()) {
+      assertThat(StringResources.processStringResources(t.getKey())).isEqualTo(t.getValue());
+    }
+  }
+
+  @Test
+  public void testInvalidCodePoints() {
+    List<String> tests = new ArrayList<>();
+    tests.add("\\u");
+    tests.add("\\u0");
+    tests.add("\\u00");
+    tests.add("\\u004");
+    tests.add("\\uzzzz");
+    tests.add("\\u0zzz");
+    tests.add("\\u00zz");
+    tests.add("\\u000z");
+    for (String t : tests) {
+      try {
+        StringResources.processStringResources(t);
+        fail("expected IllegalArgumentException with test '" + t + "'");
+      } catch (IllegalArgumentException expected) {
+        // cool
+      }
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/res/StyleResourceLoaderTest.java b/robolectric/src/test/java/org/robolectric/res/StyleResourceLoaderTest.java
new file mode 100644
index 0000000..8b493ba
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/res/StyleResourceLoaderTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.res;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
+import static org.robolectric.util.TestUtil.sdkResources;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.res.android.ResTable_config;
+
+@RunWith(JUnit4.class)
+public class StyleResourceLoaderTest {
+  private PackageResourceTable resourceTable;
+
+  @Before
+  public void setUp() throws Exception {
+    assumeTrue(RuntimeEnvironment.useLegacyResources());
+    ResourcePath resourcePath = sdkResources(JELLY_BEAN);
+    resourceTable = new ResourceTableFactory().newResourceTable("android", resourcePath);
+  }
+
+  @Test
+  public void testStyleDataIsLoadedCorrectly() throws Exception {
+    TypedResource typedResource = resourceTable.getValue(new ResName("android", "style", "Theme_Holo"), new ResTable_config());
+    StyleData styleData = (StyleData) typedResource.getData();
+    assertThat(styleData.getName()).isEqualTo("Theme_Holo");
+    assertThat(styleData.getParent()).isEqualTo("Theme");
+    assertThat(styleData.getPackageName()).isEqualTo("android");
+    assertThat(styleData.getAttrValue(new ResName("android", "attr", "colorForeground")).value)
+        .isEqualTo("@android:color/bright_foreground_holo_dark");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/AdapterViewBehavior.java b/robolectric/src/test/java/org/robolectric/shadows/AdapterViewBehavior.java
new file mode 100644
index 0000000..d45f613
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/AdapterViewBehavior.java
@@ -0,0 +1,134 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.TextView;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public abstract class AdapterViewBehavior {
+  private AdapterView<ShadowCountingAdapter> adapterView;
+
+  @Before
+  public void setUp() throws Exception {
+    shadowMainLooper().pause();
+    adapterView = createAdapterView();
+  }
+
+  public abstract AdapterView<ShadowCountingAdapter> createAdapterView();
+
+  @Test
+  public void shouldIgnoreSetSelectionCallsWithInvalidPosition() {
+    final List<String> transcript = new ArrayList<>();
+
+    adapterView.setOnItemSelectedListener(
+        new AdapterView.OnItemSelectedListener() {
+          @Override
+          public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+            transcript.add("onItemSelected fired");
+          }
+
+          @Override
+          public void onNothingSelected(AdapterView<?> parent) {}
+        });
+
+    shadowMainLooper().idle();
+    assertThat(transcript).isEmpty();
+    adapterView.setSelection(AdapterView.INVALID_POSITION);
+    shadowMainLooper().idle();
+    assertThat(transcript).isEmpty();
+  }
+
+  @Test
+  public void testSetAdapter_shouldCauseViewsToBeRenderedAsynchronously() throws Exception {
+    adapterView.setAdapter(new ShadowCountingAdapter(2));
+
+    assertThat(adapterView.getCount()).isEqualTo(2);
+    assertThat(adapterView.getChildCount()).isEqualTo(0);
+
+    shadowOf(adapterView).populateItems();
+    assertThat(adapterView.getChildCount()).isEqualTo(2);
+    assertThat(((TextView) adapterView.getChildAt(0)).getText().toString()).isEqualTo("Item 0");
+    assertThat(((TextView) adapterView.getChildAt(1)).getText().toString()).isEqualTo("Item 1");
+  }
+
+  @Test
+  public void testSetEmptyView_shouldHideAdapterViewIfAdapterIsNull() throws Exception {
+    adapterView.setAdapter(null);
+
+    View emptyView = new View(adapterView.getContext());
+    adapterView.setEmptyView(emptyView);
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.GONE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.VISIBLE);
+  }
+
+  @Test
+  public void testSetEmptyView_shouldHideAdapterViewIfAdapterViewIsEmpty() throws Exception {
+    adapterView.setAdapter(new ShadowCountingAdapter(0));
+
+    View emptyView = new View(adapterView.getContext());
+    adapterView.setEmptyView(emptyView);
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.GONE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.VISIBLE);
+  }
+
+  @Test
+  public void testSetEmptyView_shouldHideEmptyViewIfAdapterViewIsNotEmpty() throws Exception {
+    adapterView.setAdapter(new ShadowCountingAdapter(1));
+
+    View emptyView = new View(adapterView.getContext());
+    adapterView.setEmptyView(emptyView);
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.VISIBLE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.GONE);
+  }
+
+  @Test
+  public void testSetEmptyView_shouldHideEmptyViewWhenAdapterGetsNewItem() throws Exception {
+    ShadowCountingAdapter adapter = new ShadowCountingAdapter(0);
+    adapterView.setAdapter(adapter);
+
+    View emptyView = new View(adapterView.getContext());
+    adapterView.setEmptyView(emptyView);
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.GONE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.VISIBLE);
+
+    adapter.setCount(1);
+
+    shadowMainLooper().idle();
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.VISIBLE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.GONE);
+  }
+
+  @Test
+  public void testSetEmptyView_shouldHideAdapterViewWhenAdapterBecomesEmpty() throws Exception {
+    ShadowCountingAdapter adapter = new ShadowCountingAdapter(1);
+    adapterView.setAdapter(adapter);
+
+    View emptyView = new View(adapterView.getContext());
+    adapterView.setEmptyView(emptyView);
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.VISIBLE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.GONE);
+
+    adapter.setCount(0);
+
+    shadowMainLooper().idle();
+
+    assertThat(adapterView.getVisibility()).isEqualTo(View.GONE);
+    assertThat(emptyView.getVisibility()).isEqualTo(View.VISIBLE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/AppWidgetProviderInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/AppWidgetProviderInfoBuilderTest.java
new file mode 100644
index 0000000..dfb47e4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/AppWidgetProviderInfoBuilderTest.java
@@ -0,0 +1,79 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.L;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.os.Parcel;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link AppWidgetProviderInfoBuilder} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN)
+public class AppWidgetProviderInfoBuilderTest {
+  private Context context;
+  private PackageManager packageManager;
+  private AppWidgetProviderInfo appWidgetProviderInfo;
+  private ActivityInfo providerInfo;
+  private ApplicationInfo applicationInfo;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    packageManager = context.getPackageManager();
+    providerInfo = new ActivityInfo();
+    providerInfo.nonLocalizedLabel = "nonLocalizedLabel";
+    providerInfo.icon = -1;
+    AppWidgetProviderInfoBuilder builder = AppWidgetProviderInfoBuilder.newBuilder();
+    if (RuntimeEnvironment.getApiLevel() >= L) {
+      applicationInfo = new ApplicationInfo();
+      applicationInfo.uid = UserHandle.myUserId();
+      providerInfo.applicationInfo = applicationInfo;
+      builder.setProviderInfo(providerInfo);
+    }
+    appWidgetProviderInfo = builder.build();
+  }
+
+  @Test
+  public void appWidgetProviderInfo_canBeBuilt() {
+    appWidgetProviderInfo.icon = 100;
+    Parcel parcel = Parcel.obtain();
+    parcel.writeParcelable(appWidgetProviderInfo, 0);
+    parcel.setDataPosition(0);
+    AppWidgetProviderInfo info2 =
+        parcel.readParcelable(AppWidgetProviderInfo.class.getClassLoader());
+    assertThat(info2).isNotNull();
+    assertThat(info2.icon).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = L)
+  public void getProfile_shouldReturnUserHandleWithAssignedUID() {
+    assertThat(appWidgetProviderInfo.getProfile().getIdentifier()).isEqualTo(applicationInfo.uid);
+  }
+
+  @Test
+  @Config(minSdk = L)
+  public void loadIcon_shouldReturnNonNullIcon() {
+    assertThat(appWidgetProviderInfo.loadIcon(context, 240)).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = L)
+  public void loadLabel_shouldReturnAssignedLabel() {
+    assertThat(appWidgetProviderInfo.loadLabel(packageManager))
+        .isEqualTo(providerInfo.nonLocalizedLabel.toString());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/BarringInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/BarringInfoBuilderTest.java
new file mode 100644
index 0000000..d1649c3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/BarringInfoBuilderTest.java
@@ -0,0 +1,95 @@
+package org.robolectric.shadows;
+
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_CS_VOICE;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_CONDITIONAL;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_NONE;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_UNKNOWN;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.BarringInfo;
+import android.telephony.BarringInfo.BarringServiceInfo;
+import android.telephony.CellIdentityLte;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.BarringInfoBuilder.BarringServiceInfoBuilder;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.R)
+public final class BarringInfoBuilderTest {
+
+  @Test
+  public void buildBarringServiceInfo_noset_fromSdkR() {
+    BarringServiceInfo barringServiceInfo = BarringServiceInfoBuilder.newBuilder().build();
+
+    assertThat(barringServiceInfo).isNotNull();
+    assertThat(barringServiceInfo.getBarringType()).isEqualTo(BARRING_TYPE_NONE);
+    assertThat(barringServiceInfo.isConditionallyBarred()).isFalse();
+    assertThat(barringServiceInfo.isBarred()).isFalse();
+  }
+
+  @Test
+  public void buildBarringInfo_noset_fromSdkR() {
+    BarringInfo barringInfo = BarringInfoBuilder.newBuilder().build();
+    assertThat(barringInfo).isNotNull();
+
+    BarringServiceInfo barringServiceInfo =
+        barringInfo.getBarringServiceInfo(BARRING_SERVICE_TYPE_CS_VOICE);
+    assertThat(barringServiceInfo.getBarringType()).isEqualTo(BARRING_TYPE_UNKNOWN);
+    assertThat(barringServiceInfo.isConditionallyBarred()).isFalse();
+    assertThat(barringServiceInfo.isBarred()).isFalse();
+  }
+
+  @Test
+  public void buildBarringServiceInfo_fromSdkR() {
+    BarringServiceInfo barringServiceInfo =
+        BarringServiceInfoBuilder.newBuilder()
+            .setBarringType(BARRING_TYPE_CONDITIONAL)
+            .setIsConditionallyBarred(true)
+            .setConditionalBarringFactor(20)
+            .setConditionalBarringTimeSeconds(30)
+            .build();
+
+    assertThat(barringServiceInfo).isNotNull();
+    assertThat(barringServiceInfo.getBarringType()).isEqualTo(BARRING_TYPE_CONDITIONAL);
+    assertThat(barringServiceInfo.isConditionallyBarred()).isTrue();
+    assertThat(barringServiceInfo.getConditionalBarringFactor()).isEqualTo(20);
+    assertThat(barringServiceInfo.getConditionalBarringTimeSeconds()).isEqualTo(30);
+    assertThat(barringServiceInfo.isBarred()).isTrue();
+  }
+
+  @Test
+  public void buildBarringInfo_fromSdkR() throws Exception {
+    BarringServiceInfo barringServiceInfo =
+        BarringServiceInfoBuilder.newBuilder()
+            .setBarringType(BARRING_TYPE_CONDITIONAL)
+            .setIsConditionallyBarred(true)
+            .setConditionalBarringFactor(20)
+            .setConditionalBarringTimeSeconds(30)
+            .build();
+    CellIdentityLte cellIdentityLte =
+        ReflectionHelpers.callConstructor(
+            CellIdentityLte.class,
+            ReflectionHelpers.ClassParameter.from(int.class, 310),
+            ReflectionHelpers.ClassParameter.from(int.class, 260),
+            ReflectionHelpers.ClassParameter.from(int.class, 0),
+            ReflectionHelpers.ClassParameter.from(int.class, 0),
+            ReflectionHelpers.ClassParameter.from(int.class, 0));
+    BarringInfo barringInfo =
+        BarringInfoBuilder.newBuilder()
+            .setCellIdentity(cellIdentityLte)
+            .addBarringServiceInfo(BARRING_SERVICE_TYPE_CS_VOICE, barringServiceInfo)
+            .build();
+
+    BarringServiceInfo outBarringServiceInfo =
+        barringInfo.getBarringServiceInfo(BARRING_SERVICE_TYPE_CS_VOICE);
+    assertThat(outBarringServiceInfo.getBarringType()).isEqualTo(BARRING_TYPE_CONDITIONAL);
+    assertThat(outBarringServiceInfo.isConditionallyBarred()).isTrue();
+    assertThat(outBarringServiceInfo.getConditionalBarringFactor()).isEqualTo(20);
+    assertThat(outBarringServiceInfo.getConditionalBarringTimeSeconds()).isEqualTo(30);
+    assertThat(outBarringServiceInfo.isBarred()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/CompatibilityTest.java b/robolectric/src/test/java/org/robolectric/shadows/CompatibilityTest.java
new file mode 100644
index 0000000..20c538e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/CompatibilityTest.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.compat.Compatibility;
+import android.os.Build;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Tests to make sure {@link android.compat.Compatibility} is instrumented correctly */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = Build.VERSION_CODES.S)
+public class CompatibilityTest {
+  @Test
+  public void isChangeEnabled() {
+    assertThat(Compatibility.isChangeEnabled(100)).isTrue();
+  }
+
+  @Test
+  public void reportUnconditionalChange() {
+    // Verify this does not cause a crash due to uninstrumented System.logW.
+    Compatibility.reportUnconditionalChange(100);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ConverterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ConverterTest.java
new file mode 100644
index 0000000..4907e2c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ConverterTest.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.res.Qualifiers;
+import org.robolectric.res.ResType;
+import org.robolectric.res.TypedResource;
+import org.robolectric.res.XmlContext;
+
+@RunWith(AndroidJUnit4.class)
+public class ConverterTest {
+
+  private XmlContext xmlContext;
+
+  @Before
+  public void setUp() throws Exception {
+    Path xmlFile = Paths.get("res/values/foo.xml");
+    Qualifiers qualifiers = Qualifiers.fromParentDir(xmlFile.getParent());
+
+    xmlContext = new XmlContext("", xmlFile, qualifiers);
+  }
+
+  @Test
+  public void fromCharSequence_asInt_shouldHandleSpacesInString() {
+    final TypedResource<String> resource =
+        new TypedResource<>(" 100 ", ResType.CHAR_SEQUENCE, xmlContext);
+    assertThat(Converter.getConverter(ResType.CHAR_SEQUENCE).asInt(resource)).isEqualTo(100);
+  }
+
+  @Test
+  public void fromCharSequence_asCharSequence_shouldHandleSpacesInString() {
+    final TypedResource<String> resource =
+        new TypedResource<>(" Robolectric ", ResType.CHAR_SEQUENCE, xmlContext);
+    assertThat(Converter.getConverter(ResType.CHAR_SEQUENCE).asCharSequence(resource).toString())
+        .isEqualTo("Robolectric");
+  }
+
+  @Test
+  public void fromColor_asInt_shouldHandleSpacesInString() {
+    final TypedResource<String> resource =
+        new TypedResource<>(" #aaaaaa ", ResType.COLOR, xmlContext);
+    assertThat(Converter.getConverter(ResType.COLOR).asInt(resource)).isEqualTo(-5592406);
+  }
+
+  @Test
+  public void fromDrawableValue_asInt_shouldHandleSpacesInString() {
+    final TypedResource<String> resource =
+        new TypedResource<>(" #aaaaaa ", ResType.DRAWABLE, xmlContext);
+    assertThat(Converter.getConverter(ResType.DRAWABLE).asInt(resource)).isEqualTo(-5592406);
+  }
+
+  @Test
+  public void fromInt_asInt_shouldHandleSpacesInString() {
+    final TypedResource<String> resource =
+        new TypedResource<>(" 100 ", ResType.INTEGER, xmlContext);
+    assertThat(Converter.getConverter(ResType.INTEGER).asInt(resource)).isEqualTo(100);
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/DragEventBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/DragEventBuilderTest.java
new file mode 100644
index 0000000..c935c4f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/DragEventBuilderTest.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ClipData;
+import android.view.DragEvent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test for {@link DragEventBuilder}. */
+@RunWith(AndroidJUnit4.class)
+public final class DragEventBuilderTest {
+  @Test
+  public void obtain() {
+    Object localState = new Object();
+    ClipData clipData = ClipData.newPlainText("label", "text");
+
+    DragEvent event =
+        DragEventBuilder.newBuilder()
+            .setAction(DragEvent.ACTION_DRAG_STARTED)
+            .setX(1)
+            .setY(2)
+            .setLocalState(localState)
+            .setClipDescription(clipData.getDescription())
+            .setClipData(clipData)
+            .setResult(true)
+            .build();
+
+    assertThat(event.getAction()).isEqualTo(event.getAction());
+    assertThat(event.getX()).isEqualTo(1);
+    assertThat(event.getY()).isEqualTo(2);
+    assertThat(event.getLocalState()).isEqualTo(localState);
+    assertThat(event.getClipDescription()).isEqualTo(clipData.getDescription());
+    assertThat(event.getClipData()).isEqualTo(clipData);
+    assertThat(event.getResult()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/EpsBearerQosSessionAttributesBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/EpsBearerQosSessionAttributesBuilderTest.java
new file mode 100644
index 0000000..5073fe1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/EpsBearerQosSessionAttributesBuilderTest.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.data.EpsBearerQosSessionAttributes;
+import java.net.InetSocketAddress;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link EpsBearerQosSessionAttributesBuilder}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = VERSION_CODES.S)
+public class EpsBearerQosSessionAttributesBuilderTest {
+
+  @Test
+  public void createDefaultInstance_setsDefaultValues() {
+    EpsBearerQosSessionAttributes epsBearerQosSessionAttributes =
+        EpsBearerQosSessionAttributesBuilder.newBuilder().build();
+    assertThat(epsBearerQosSessionAttributes.getQosIdentifier()).isEqualTo(0);
+    assertThat(epsBearerQosSessionAttributes.getMaxUplinkBitRateKbps()).isEqualTo(0);
+    assertThat(epsBearerQosSessionAttributes.getMaxDownlinkBitRateKbps()).isEqualTo(0);
+    assertThat(epsBearerQosSessionAttributes.getGuaranteedUplinkBitRateKbps()).isEqualTo(0);
+    assertThat(epsBearerQosSessionAttributes.getGuaranteedDownlinkBitRateKbps()).isEqualTo(0);
+    assertThat(epsBearerQosSessionAttributes.getRemoteAddresses()).isEmpty();
+  }
+
+  @Test
+  public void createInstanceWithValues_setsNewValues() {
+    InetSocketAddress remoteAddress = new InetSocketAddress(/* port= */ 0);
+    EpsBearerQosSessionAttributes epsBearerQosSessionAttributes =
+        EpsBearerQosSessionAttributesBuilder.newBuilder()
+            .setQci(1)
+            .setMaxDownlinkBitRate(2)
+            .setMaxUplinkBitRate(3)
+            .setGuaranteedDownlinkBitRate(4)
+            .setGuaranteedUplinkBitRate(5)
+            .addRemoteAddress(remoteAddress)
+            .build();
+    assertThat(epsBearerQosSessionAttributes.getQosIdentifier()).isEqualTo(1);
+    assertThat(epsBearerQosSessionAttributes.getMaxUplinkBitRateKbps()).isEqualTo(2);
+    assertThat(epsBearerQosSessionAttributes.getMaxDownlinkBitRateKbps()).isEqualTo(3);
+    assertThat(epsBearerQosSessionAttributes.getGuaranteedUplinkBitRateKbps()).isEqualTo(4);
+    assertThat(epsBearerQosSessionAttributes.getGuaranteedDownlinkBitRateKbps()).isEqualTo(5);
+    assertThat(epsBearerQosSessionAttributes.getRemoteAddresses()).containsExactly(remoteAddress);
+  }
+
+  @Test
+  public void createInstanceWithMultipleRemoteAddresses_allAreSet() {
+    InetSocketAddress remoteAddress1 = new InetSocketAddress(/* port= */ 0);
+    InetSocketAddress remoteAddress2 = new InetSocketAddress(/* port= */ 1);
+    InetSocketAddress remoteAddress3 = new InetSocketAddress(/* port= */ 2);
+    EpsBearerQosSessionAttributes epsBearerQosSessionAttributes =
+        EpsBearerQosSessionAttributesBuilder.newBuilder()
+            .addRemoteAddress(remoteAddress1)
+            .addRemoteAddress(remoteAddress2)
+            .addRemoteAddress(remoteAddress3)
+            .build();
+    assertThat(epsBearerQosSessionAttributes.getRemoteAddresses())
+        .containsExactly(remoteAddress1, remoteAddress2, remoteAddress3);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/FrameMetricsBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/FrameMetricsBuilderTest.java
new file mode 100644
index 0000000..0386e94
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/FrameMetricsBuilderTest.java
@@ -0,0 +1,111 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.FrameMetrics;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link FrameMetricsBuilder}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = N)
+public class FrameMetricsBuilderTest {
+
+  @Test
+  public void firstDrawFrame() throws Exception {
+    FrameMetrics metrics =
+        new FrameMetricsBuilder().setMetric(FrameMetrics.FIRST_DRAW_FRAME, 1L).build();
+
+    assertThat(metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME)).isEqualTo(1L);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void intendedVsyncTimestamp() throws Exception {
+    FrameMetrics metrics =
+        new FrameMetricsBuilder().setMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP, 123L).build();
+
+    assertThat(metrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).isEqualTo(123L);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void vsyncTimestamp() throws Exception {
+    FrameMetrics metrics =
+        new FrameMetricsBuilder().setMetric(FrameMetrics.VSYNC_TIMESTAMP, 321L).build();
+
+    assertThat(metrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP)).isEqualTo(321L);
+  }
+
+  @Test
+  public void allTimeMetrics() throws Exception {
+    FrameMetrics metrics =
+        new FrameMetricsBuilder()
+            .setMetric(FrameMetrics.UNKNOWN_DELAY_DURATION, 1L)
+            .setMetric(FrameMetrics.INPUT_HANDLING_DURATION, 2L)
+            .setMetric(FrameMetrics.ANIMATION_DURATION, 2L)
+            .setMetric(FrameMetrics.LAYOUT_MEASURE_DURATION, 2L)
+            .setMetric(FrameMetrics.DRAW_DURATION, 2L)
+            .setMetric(FrameMetrics.SYNC_DURATION, 2L)
+            .setMetric(FrameMetrics.COMMAND_ISSUE_DURATION, 2L)
+            .setMetric(FrameMetrics.SWAP_BUFFERS_DURATION, 2L)
+            .setSyncDelayTimeNanos(3L)
+            .build();
+
+    assertThat(metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)).isEqualTo(1L);
+    assertThat(metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)).isEqualTo(2L);
+    assertThat(metrics.getMetric(FrameMetrics.ANIMATION_DURATION)).isEqualTo(2L);
+    assertThat(metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)).isEqualTo(2L);
+    assertThat(metrics.getMetric(FrameMetrics.DRAW_DURATION)).isEqualTo(2L);
+    assertThat(metrics.getMetric(FrameMetrics.SYNC_DURATION)).isEqualTo(2L);
+    assertThat(metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION)).isEqualTo(2L);
+    assertThat(metrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION)).isEqualTo(2L);
+
+    // UNKNOWN_DELAY_DURATION took 1 nanosecond, 7 metrics too 2 nanoseconds each, and the
+    // syncDelayTimeNanos is 3.
+    assertThat(metrics.getMetric(FrameMetrics.TOTAL_DURATION)).isEqualTo(1L + 2L * 7 + 3L);
+  }
+
+  @Test
+  public void totalDurationIsSumOfOtherDurations() throws Exception {
+    long unknownDelay = 1L;
+    long animation = 20L;
+    long inputHandling = 300L;
+
+    assertThat(
+            new FrameMetricsBuilder()
+                .setMetric(FrameMetrics.UNKNOWN_DELAY_DURATION, unknownDelay)
+                .setMetric(FrameMetrics.ANIMATION_DURATION, animation)
+                .setMetric(FrameMetrics.INPUT_HANDLING_DURATION, inputHandling)
+                .build()
+                .getMetric(FrameMetrics.TOTAL_DURATION))
+        .isEqualTo(unknownDelay + animation + inputHandling);
+  }
+
+  @Test
+  public void totalDurationExcludesNonDurationValues() throws Exception {
+    long unknownDelay = 1L;
+    long animation = 20L;
+    long inputHandling = 300L;
+    long deadline = 400L;
+    long largeValue = 40000L;
+    assertThat(
+            new FrameMetricsBuilder()
+                .setMetric(FrameMetrics.UNKNOWN_DELAY_DURATION, unknownDelay)
+                .setMetric(FrameMetrics.ANIMATION_DURATION, animation)
+                .setMetric(FrameMetrics.INPUT_HANDLING_DURATION, inputHandling)
+
+                // metrics that should not impact TOTAL_DURATION
+                .setMetric(FrameMetrics.DEADLINE, deadline)
+                .setMetric(FrameMetrics.FIRST_DRAW_FRAME, 1)
+                .setMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP, largeValue)
+                .setMetric(FrameMetrics.VSYNC_TIMESTAMP, largeValue)
+                .build()
+                .getMetric(FrameMetrics.TOTAL_DURATION))
+        .isEqualTo(unknownDelay + animation + inputHandling);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/GnssStatusBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/GnssStatusBuilderTest.java
new file mode 100644
index 0000000..8a7a794
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/GnssStatusBuilderTest.java
@@ -0,0 +1,137 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.location.GnssStatus;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.GnssStatusBuilder.GnssSatelliteInfo;
+
+/** Tests for {@link GnssStatusBuilder}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = N, maxSdk = Q)
+public class GnssStatusBuilderTest {
+
+  private static final int SVID = 42;
+  private static final float CN0 = 33.25f;
+  private static final float ELEVATION = 45.0f;
+  private static final float AZIMUTH = 90.0f;
+  private static final boolean HAS_EPHEMERIS = false;
+  private static final boolean HAS_ALMANAC = true;
+  private static final boolean USED_IN_FIX = true;
+
+  @Test
+  public void emptyBuilder() {
+    GnssStatus status = GnssStatusBuilder.create().build();
+    assertThat(status.getSatelliteCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void builder_addSatellite() {
+    GnssStatusBuilder builder = GnssStatusBuilder.create();
+    builder.addSatellite(
+        GnssSatelliteInfo.builder()
+            .setConstellation(GnssStatus.CONSTELLATION_GPS)
+            .setSvid(SVID)
+            .setCn0DbHz(CN0)
+            .setElevation(ELEVATION)
+            .setAzimuth(AZIMUTH)
+            .setHasEphemeris(HAS_EPHEMERIS)
+            .setHasAlmanac(HAS_ALMANAC)
+            .setUsedInFix(USED_IN_FIX)
+            .build());
+    GnssStatus status = builder.build();
+
+    assertThat(status.getSatelliteCount()).isEqualTo(1);
+    assertThat(status.getConstellationType(0)).isEqualTo(GnssStatus.CONSTELLATION_GPS);
+    assertThat(status.getSvid(0)).isEqualTo(SVID);
+    assertThat(status.getCn0DbHz(0)).isEqualTo(CN0);
+    assertThat(status.getElevationDegrees(0)).isEqualTo(ELEVATION);
+    assertThat(status.getAzimuthDegrees(0)).isEqualTo(AZIMUTH);
+    assertThat(status.hasEphemerisData(0)).isEqualTo(HAS_EPHEMERIS);
+    assertThat(status.hasAlmanacData(0)).isEqualTo(HAS_ALMANAC);
+    assertThat(status.usedInFix(0)).isEqualTo(USED_IN_FIX);
+  }
+
+  @Test
+  public void builder_addAll() {
+    GnssSatelliteInfo.Builder infoBuilder =
+        GnssSatelliteInfo.builder()
+            .setConstellation(GnssStatus.CONSTELLATION_GPS)
+            .setCn0DbHz(CN0)
+            .setElevation(ELEVATION)
+            .setAzimuth(AZIMUTH)
+            .setHasEphemeris(HAS_EPHEMERIS)
+            .setHasAlmanac(HAS_ALMANAC)
+            .setUsedInFix(USED_IN_FIX);
+
+
+    List<GnssSatelliteInfo> satelliteInfos = new ArrayList<>();
+    satelliteInfos.add(infoBuilder.setSvid(SVID).build());
+    satelliteInfos.add(infoBuilder.setSvid(SVID + 1).build());
+    satelliteInfos.add(infoBuilder.setSvid(SVID - 1).build());
+
+    GnssStatus status = GnssStatusBuilder.create().addAllSatellites(satelliteInfos).build();
+    assertThat(status.getSatelliteCount()).isEqualTo(3);
+    assertThat(status.getSvid(0)).isEqualTo(SVID);
+    assertThat(status.getSvid(1)).isEqualTo(SVID + 1);
+    assertThat(status.getSvid(2)).isEqualTo(SVID - 1);
+  }
+
+  @Test
+  public void builder_buildFrom() {
+    GnssSatelliteInfo.Builder infoBuilder =
+        GnssSatelliteInfo.builder()
+            .setConstellation(GnssStatus.CONSTELLATION_GPS)
+            .setCn0DbHz(CN0)
+            .setElevation(ELEVATION)
+            .setAzimuth(AZIMUTH)
+            .setHasEphemeris(HAS_EPHEMERIS)
+            .setHasAlmanac(HAS_ALMANAC)
+            .setUsedInFix(USED_IN_FIX);
+
+    GnssSatelliteInfo info1 = infoBuilder.setSvid(SVID).build();
+    GnssSatelliteInfo info2 = infoBuilder.setSvid(SVID * 2).build();
+
+    GnssStatus status = GnssStatusBuilder.buildFrom(info1, info2);
+
+    assertThat(status.getSatelliteCount()).isEqualTo(2);
+    assertThat(status.getSvid(0)).isEqualTo(SVID);
+    assertThat(status.getSvid(1)).isEqualTo(SVID * 2);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void addSatellite_carrierFrequency() {
+    GnssSatelliteInfo.Builder infoBuilder =
+    GnssSatelliteInfo.builder()
+        .setConstellation(GnssStatus.CONSTELLATION_GPS)
+        .setCn0DbHz(CN0)
+        .setElevation(ELEVATION)
+        .setAzimuth(AZIMUTH)
+        .setHasEphemeris(HAS_EPHEMERIS)
+        .setHasAlmanac(HAS_ALMANAC)
+        .setUsedInFix(USED_IN_FIX);
+
+    GnssStatus status = GnssStatusBuilder.create()
+        .addSatellite(infoBuilder.setSvid(SVID).build())
+        .addSatellite(infoBuilder.setSvid(SVID + 1).setCarrierFrequencyHz(null).build())
+        .addSatellite(infoBuilder.setSvid(SVID - 1).setCarrierFrequencyHz(1575.42f).build())
+        .build();
+
+    assertThat(status.getSatelliteCount()).isEqualTo(3);
+    assertThat(status.hasCarrierFrequencyHz(0)).isFalse();
+    assertThat(status.getCarrierFrequencyHz(0)).isEqualTo(0.0f);
+    assertThat(status.hasCarrierFrequencyHz(1)).isFalse();
+    assertThat(status.getCarrierFrequencyHz(1)).isEqualTo(0.0f);
+    assertThat(status.hasCarrierFrequencyHz(2)).isTrue();
+    assertThat(status.getCarrierFrequencyHz(2)).isEqualTo(1575.42f);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/LazyApplicationShadowTest.java b/robolectric/src/test/java/org/robolectric/shadows/LazyApplicationShadowTest.java
new file mode 100644
index 0000000..ced4b07
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/LazyApplicationShadowTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import android.content.res.Resources;
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+
+/** Tests for interactions with shadows when lazily loading application */
+@LazyApplication(LazyLoad.ON)
+@RunWith(AndroidJUnit4.class)
+public class LazyApplicationShadowTest {
+
+  /**
+   * Test to make sure that (Shadow)Resources.getSystem can safely be called when lazy loading is
+   * turned on
+   */
+  @Test
+  public void testResourcesGetSystem_doesNotCrash_whenLazyLoading() {
+    Resources.getSystem();
+  }
+
+  @Test
+  public void testShadowContentResolverGetProvider_doesNotCrash_whenLazyLoading() {
+    ShadowContentResolver.getProvider(Uri.parse("content://my.provider"));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/LegacyManifestParserTest.java b/robolectric/src/test/java/org/robolectric/shadows/LegacyManifestParserTest.java
new file mode 100644
index 0000000..7307d9b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/LegacyManifestParserTest.java
@@ -0,0 +1,78 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.TestUtil.resourceFile;
+
+import android.content.pm.PackageParser.Package;
+import android.content.pm.PackageParser.Permission;
+import android.content.pm.PermissionInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.manifest.AndroidManifest;
+
+/** Unit test for {@link org.robolectric.shadows.LegacyManifestParser}. */
+@RunWith(AndroidJUnit4.class)
+public class LegacyManifestParserTest {
+
+  private AndroidManifest androidManifest;
+
+  @Before
+  public void setUp() {
+    androidManifest =
+        new AndroidManifest(
+            resourceFile("TestAndroidManifestWithProtectionLevels.xml"),
+            resourceFile("res"),
+            resourceFile("assets"));
+  }
+
+  @Test
+  public void createPackage_signatureOrPrivileged_shouldParseCorrectFlags() {
+    Package parsedPackage = LegacyManifestParser.createPackage(androidManifest);
+    int protectionLevel =
+        getPermissionInfo(parsedPackage.permissions, "signature_or_privileged_permission")
+            .protectionLevel;
+    assertThat(protectionLevel)
+        .isEqualTo(PermissionInfo.PROTECTION_SIGNATURE | PermissionInfo.PROTECTION_FLAG_PRIVILEGED);
+  }
+
+  @Test
+  public void createPackage_protectionLevelNotDeclated_shouldParseToNormal() {
+    Package parsedPackage = LegacyManifestParser.createPackage(androidManifest);
+    int protectionLevel =
+        getPermissionInfo(parsedPackage.permissions, "permission_with_minimal_fields")
+            .protectionLevel;
+    assertThat(protectionLevel).isEqualTo(PermissionInfo.PROTECTION_NORMAL);
+  }
+
+  @Test
+  public void createPackage_protectionLevelVendorOrOem_shouldParseCorrectFlags() {
+    Package parsedPackage = LegacyManifestParser.createPackage(androidManifest);
+    int protectionLevel =
+        getPermissionInfo(parsedPackage.permissions, "vendor_privileged_or_oem_permission")
+            .protectionLevel;
+    assertThat(protectionLevel)
+        .isEqualTo(
+            PermissionInfo.PROTECTION_FLAG_VENDOR_PRIVILEGED | PermissionInfo.PROTECTION_FLAG_OEM);
+  }
+
+  @Test
+  public void createPackage_protectionLevelDangerous_shouldParseCorrectFlags() {
+    Package parsedPackage = LegacyManifestParser.createPackage(androidManifest);
+    int protectionLevel =
+        getPermissionInfo(parsedPackage.permissions, "dangerous_permission").protectionLevel;
+    assertThat(protectionLevel).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS);
+  }
+
+  private PermissionInfo getPermissionInfo(List<Permission> permissions, String name) {
+    name = "org.robolectric." + name;
+    for (Permission permission : permissions) {
+      if (name.equals(permission.info.name)) {
+        return permission.info;
+      }
+    }
+    return null;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java
new file mode 100644
index 0000000..27e635c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java
@@ -0,0 +1,356 @@
+package org.robolectric.shadows;
+
+import static android.media.MediaFormat.MIMETYPE_AUDIO_AAC;
+import static android.media.MediaFormat.MIMETYPE_AUDIO_OPUS;
+import static android.media.MediaFormat.MIMETYPE_VIDEO_AVC;
+import static android.media.MediaFormat.MIMETYPE_VIDEO_VP9;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link MediaCodecInfoBuilder}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class MediaCodecInfoBuilderTest {
+
+  private static final String AAC_ENCODER_NAME = "test.encoder.aac";
+  private static final String VP9_DECODER_NAME = "test.decoder.vp9";
+  private static final String MULTIFORMAT_ENCODER_NAME = "test.encoder.multiformat";
+
+  private static final MediaFormat AAC_MEDIA_FORMAT =
+      createMediaFormat(
+          MIMETYPE_AUDIO_AAC, new String[] {CodecCapabilities.FEATURE_DynamicTimestamp});
+  private static final MediaFormat OPUS_MEDIA_FORMAT =
+      createMediaFormat(
+          MIMETYPE_AUDIO_OPUS, new String[] {CodecCapabilities.FEATURE_AdaptivePlayback});
+  private static final MediaFormat AVC_MEDIA_FORMAT =
+      createMediaFormat(MIMETYPE_VIDEO_AVC, new String[] {CodecCapabilities.FEATURE_IntraRefresh});
+  private static final MediaFormat VP9_MEDIA_FORMAT =
+      createMediaFormat(
+          MIMETYPE_VIDEO_VP9,
+          new String[] {
+            CodecCapabilities.FEATURE_SecurePlayback, CodecCapabilities.FEATURE_MultipleFrames
+          });
+
+  private static final CodecProfileLevel[] AAC_PROFILE_LEVELS =
+      new CodecProfileLevel[] {
+        createCodecProfileLevel(CodecProfileLevel.AACObjectELD, 0),
+        createCodecProfileLevel(CodecProfileLevel.AACObjectHE, 1)
+      };
+  private static final CodecProfileLevel[] AVC_PROFILE_LEVELS =
+      new CodecProfileLevel[] {
+        createCodecProfileLevel(CodecProfileLevel.AVCProfileMain, CodecProfileLevel.AVCLevel12)
+      };
+  private static final CodecProfileLevel[] VP9_PROFILE_LEVELS =
+      new CodecProfileLevel[] {
+        createCodecProfileLevel(CodecProfileLevel.VP9Profile3, CodecProfileLevel.VP9Level52)
+      };
+
+  private static final int[] AVC_COLOR_FORMATS =
+      new int[] {
+        CodecCapabilities.COLOR_FormatYUV420Flexible, CodecCapabilities.COLOR_FormatYUV420Planar
+      };
+  private static final int[] VP9_COLOR_FORMATS =
+      new int[] {
+        CodecCapabilities.COLOR_FormatYUV422Flexible, CodecCapabilities.COLOR_Format32bitABGR8888
+      };
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateAudioEncoderCapabilities() {
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(AAC_MEDIA_FORMAT)
+            .setIsEncoder(true)
+            .setProfileLevels(AAC_PROFILE_LEVELS)
+            .build();
+
+    assertThat(codecCapabilities.getMimeType()).isEqualTo(MIMETYPE_AUDIO_AAC);
+    assertThat(codecCapabilities.getAudioCapabilities()).isNotNull();
+    assertThat(codecCapabilities.getVideoCapabilities()).isNull();
+    assertThat(codecCapabilities.getEncoderCapabilities()).isNotNull();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_DynamicTimestamp))
+        .isTrue();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_FrameParsing))
+        .isFalse();
+    assertThat(codecCapabilities.profileLevels).hasLength(AAC_PROFILE_LEVELS.length);
+    assertThat(codecCapabilities.profileLevels).isEqualTo(AAC_PROFILE_LEVELS);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateAudioDecoderCapabilities() {
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(OPUS_MEDIA_FORMAT)
+            .setProfileLevels(new CodecProfileLevel[0])
+            .build();
+
+    assertThat(codecCapabilities.getMimeType()).isEqualTo(MIMETYPE_AUDIO_OPUS);
+    assertThat(codecCapabilities.getAudioCapabilities()).isNotNull();
+    assertThat(codecCapabilities.getVideoCapabilities()).isNull();
+    assertThat(codecCapabilities.getEncoderCapabilities()).isNull();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback))
+        .isTrue();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_MultipleFrames))
+        .isFalse();
+    assertThat(codecCapabilities.profileLevels).hasLength(0);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateVideoEncoderCapabilities() {
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(AVC_MEDIA_FORMAT)
+            .setIsEncoder(true)
+            .setProfileLevels(AVC_PROFILE_LEVELS)
+            .setColorFormats(AVC_COLOR_FORMATS)
+            .build();
+
+    assertThat(codecCapabilities.getMimeType()).isEqualTo(MIMETYPE_VIDEO_AVC);
+    assertThat(codecCapabilities.getAudioCapabilities()).isNull();
+    assertThat(codecCapabilities.getVideoCapabilities()).isNotNull();
+    assertThat(codecCapabilities.getEncoderCapabilities()).isNotNull();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_IntraRefresh))
+        .isTrue();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_MultipleFrames))
+        .isFalse();
+    assertThat(codecCapabilities.profileLevels).hasLength(AVC_PROFILE_LEVELS.length);
+    assertThat(codecCapabilities.profileLevels).isEqualTo(AVC_PROFILE_LEVELS);
+    assertThat(codecCapabilities.colorFormats).hasLength(AVC_COLOR_FORMATS.length);
+    assertThat(codecCapabilities.colorFormats).isEqualTo(AVC_COLOR_FORMATS);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateVideoDecoderCapabilities() {
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(VP9_MEDIA_FORMAT)
+            .setProfileLevels(VP9_PROFILE_LEVELS)
+            .setColorFormats(VP9_COLOR_FORMATS)
+            .build();
+
+    assertThat(codecCapabilities.getMimeType()).isEqualTo(MIMETYPE_VIDEO_VP9);
+    assertThat(codecCapabilities.getAudioCapabilities()).isNull();
+    assertThat(codecCapabilities.getVideoCapabilities()).isNotNull();
+    assertThat(codecCapabilities.getEncoderCapabilities()).isNull();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_SecurePlayback))
+        .isTrue();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_MultipleFrames))
+        .isTrue();
+    assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_DynamicTimestamp))
+        .isFalse();
+    assertThat(codecCapabilities.profileLevels).hasLength(VP9_PROFILE_LEVELS.length);
+    assertThat(codecCapabilities.profileLevels).isEqualTo(VP9_PROFILE_LEVELS);
+    assertThat(codecCapabilities.colorFormats).hasLength(VP9_COLOR_FORMATS.length);
+    assertThat(codecCapabilities.colorFormats).isEqualTo(VP9_COLOR_FORMATS);
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void setMediaFormatToNullThrowsException() {
+    MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder().setMediaFormat(null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void setMediaFormatWithoutMimeThrowsException() {
+    MediaFormat mediaFormat = new MediaFormat();
+    MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder().setMediaFormat(mediaFormat);
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void setProfileLevelsToNullThrowsException() {
+    MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder().setProfileLevels(null);
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void buildWithoutSettingMediaFormatThrowsException() {
+    MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder().build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void buildWithoutSettingColorFormatThrowsException() {
+    MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+        .setMediaFormat(AVC_MEDIA_FORMAT)
+        .setProfileLevels(AVC_PROFILE_LEVELS)
+        .build();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateMediaCodecInfoForEncoder() {
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(AAC_MEDIA_FORMAT)
+            .setIsEncoder(true)
+            .setProfileLevels(AAC_PROFILE_LEVELS)
+            .build();
+
+    MediaCodecInfo mediaCodecInfo =
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(AAC_ENCODER_NAME)
+            .setIsEncoder(true)
+            .setIsVendor(true)
+            .setCapabilities(codecCapabilities)
+            .build();
+
+    assertThat(mediaCodecInfo.getName()).isEqualTo(AAC_ENCODER_NAME);
+    assertThat(mediaCodecInfo.isEncoder()).isTrue();
+    assertThat(mediaCodecInfo.isVendor()).isTrue();
+    assertThat(mediaCodecInfo.isSoftwareOnly()).isFalse();
+    assertThat(mediaCodecInfo.isHardwareAccelerated()).isFalse();
+    assertThat(mediaCodecInfo.getSupportedTypes()).asList().containsExactly(MIMETYPE_AUDIO_AAC);
+    assertThat(mediaCodecInfo.getCapabilitiesForType(MIMETYPE_AUDIO_AAC)).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void isVendor_properlySet() {
+    MediaCodecInfo mediaCodecInfo =
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(AAC_ENCODER_NAME)
+            .setIsEncoder(false)
+            .setIsVendor(true)
+            .build();
+
+    assertThat(mediaCodecInfo.getName()).isEqualTo(AAC_ENCODER_NAME);
+    assertThat(mediaCodecInfo.isEncoder()).isFalse();
+    assertThat(mediaCodecInfo.isVendor()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateMediaCodecInfoForDecoder() {
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(VP9_MEDIA_FORMAT)
+            .setProfileLevels(VP9_PROFILE_LEVELS)
+            .setColorFormats(VP9_COLOR_FORMATS)
+            .build();
+
+    MediaCodecInfo mediaCodecInfo =
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(VP9_DECODER_NAME)
+            .setIsSoftwareOnly(true)
+            .setIsHardwareAccelerated(true)
+            .setCapabilities(codecCapabilities)
+            .build();
+
+    assertThat(mediaCodecInfo.getName()).isEqualTo(VP9_DECODER_NAME);
+    assertThat(mediaCodecInfo.isEncoder()).isFalse();
+    assertThat(mediaCodecInfo.isVendor()).isFalse();
+    assertThat(mediaCodecInfo.isSoftwareOnly()).isTrue();
+    assertThat(mediaCodecInfo.isHardwareAccelerated()).isTrue();
+    assertThat(mediaCodecInfo.getSupportedTypes()).asList().containsExactly(MIMETYPE_VIDEO_VP9);
+    assertThat(mediaCodecInfo.getCapabilitiesForType(MIMETYPE_VIDEO_VP9)).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void canCreateMediaCodecInfoWithMultipleFormats() {
+    CodecCapabilities avcEncoderCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(AVC_MEDIA_FORMAT)
+            .setIsEncoder(true)
+            .setProfileLevels(AVC_PROFILE_LEVELS)
+            .setColorFormats(AVC_COLOR_FORMATS)
+            .build();
+
+    CodecCapabilities vp9EncoderCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(VP9_MEDIA_FORMAT)
+            .setIsEncoder(true)
+            .setProfileLevels(VP9_PROFILE_LEVELS)
+            .setColorFormats(VP9_COLOR_FORMATS)
+            .build();
+
+    MediaCodecInfo mediaCodecInfo =
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(MULTIFORMAT_ENCODER_NAME)
+            .setIsEncoder(true)
+            .setCapabilities(avcEncoderCapabilities, vp9EncoderCapabilities)
+            .build();
+
+    assertThat(mediaCodecInfo.getName()).isEqualTo(MULTIFORMAT_ENCODER_NAME);
+    assertThat(mediaCodecInfo.isEncoder()).isTrue();
+    assertThat(mediaCodecInfo.isVendor()).isFalse();
+    assertThat(mediaCodecInfo.isSoftwareOnly()).isFalse();
+    assertThat(mediaCodecInfo.isHardwareAccelerated()).isFalse();
+    assertThat(mediaCodecInfo.getSupportedTypes()).asList().contains(MIMETYPE_VIDEO_AVC);
+    assertThat(mediaCodecInfo.getSupportedTypes()).asList().contains(MIMETYPE_VIDEO_VP9);
+    assertThat(mediaCodecInfo.getCapabilitiesForType(MIMETYPE_VIDEO_AVC)).isNotNull();
+    assertThat(mediaCodecInfo.getCapabilitiesForType(MIMETYPE_VIDEO_VP9)).isNotNull();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void setNameToNullThrowsException() {
+    MediaCodecInfoBuilder.newBuilder().setName(null);
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void buildWithoutSettingNameThrowsException() {
+    MediaCodecInfoBuilder.newBuilder().build();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void mediaCodecInfo_preQ() {
+    if (RuntimeEnvironment.getApiLevel() <= M) {
+      MediaCodecList.getCodecCount();
+    }
+    CodecCapabilities codecCapabilities =
+        MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+            .setMediaFormat(AAC_MEDIA_FORMAT)
+            .setIsEncoder(true)
+            .setProfileLevels(AAC_PROFILE_LEVELS)
+            .build();
+
+    MediaCodecInfo mediaCodecInfo =
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(AAC_ENCODER_NAME)
+            .setIsEncoder(true)
+            .setCapabilities(codecCapabilities)
+            .build();
+
+    assertThat(mediaCodecInfo.getName()).isEqualTo(AAC_ENCODER_NAME);
+    assertThat(mediaCodecInfo.isEncoder()).isTrue();
+    assertThat(mediaCodecInfo.getSupportedTypes()).asList().containsExactly(MIMETYPE_AUDIO_AAC);
+    assertThat(mediaCodecInfo.getCapabilitiesForType(MIMETYPE_AUDIO_AAC)).isNotNull();
+  }
+
+  /** Create a sample {@link CodecProfileLevel}. */
+  private static CodecProfileLevel createCodecProfileLevel(int profile, int level) {
+    CodecProfileLevel profileLevel = new CodecProfileLevel();
+    profileLevel.profile = profile;
+    profileLevel.level = level;
+    return profileLevel;
+  }
+
+  /**
+   * Create a sample {@link MediaFormat}.
+   *
+   * @param mime one of MIMETYPE_* from {@link MediaFormat}.
+   * @param features an array of CodecCapabilities.FEATURE_ features to be enabled.
+   */
+  private static MediaFormat createMediaFormat(String mime, String[] features) {
+    MediaFormat mediaFormat = new MediaFormat();
+    mediaFormat.setString(MediaFormat.KEY_MIME, mime);
+    for (String feature : features) {
+      mediaFormat.setFeatureEnabled(feature, true);
+    }
+    return mediaFormat;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/NrQosSessionAttributesBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/NrQosSessionAttributesBuilderTest.java
new file mode 100644
index 0000000..e93f5c8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/NrQosSessionAttributesBuilderTest.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.data.NrQosSessionAttributes;
+import java.net.InetSocketAddress;
+import java.time.Duration;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link NrQosSessionAttributesBuilder}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = VERSION_CODES.S)
+public class NrQosSessionAttributesBuilderTest {
+
+  @Test
+  public void createDefaultInstance_setsDefaultValues() {
+    NrQosSessionAttributes nrQosSessionAttributes =
+        NrQosSessionAttributesBuilder.newBuilder().build();
+    assertThat(nrQosSessionAttributes.getQosIdentifier()).isEqualTo(0);
+    assertThat(nrQosSessionAttributes.getQosFlowIdentifier()).isEqualTo(0);
+    assertThat(nrQosSessionAttributes.getMaxDownlinkBitRateKbps()).isEqualTo(0);
+    assertThat(nrQosSessionAttributes.getMaxUplinkBitRateKbps()).isEqualTo(0);
+    assertThat(nrQosSessionAttributes.getGuaranteedDownlinkBitRateKbps()).isEqualTo(0);
+    assertThat(nrQosSessionAttributes.getGuaranteedUplinkBitRateKbps()).isEqualTo(0);
+    assertThat(nrQosSessionAttributes.getBitRateWindowDuration()).isEqualTo(Duration.ZERO);
+    assertThat(nrQosSessionAttributes.getRemoteAddresses()).isEmpty();
+  }
+
+  @Test
+  public void createInstanceWithValues_setsNewValues() {
+    InetSocketAddress remoteAddress = new InetSocketAddress(/* port= */ 0);
+    NrQosSessionAttributes nrQosSessionAttributes =
+        NrQosSessionAttributesBuilder.newBuilder()
+            .setFiveQi(1)
+            .setQfi(2)
+            .setMaxDownlinkBitRate(3)
+            .setMaxUplinkBitRate(4)
+            .setGuaranteedDownlinkBitRate(5)
+            .setGuaranteedUplinkBitRate(6)
+            .setAveragingWindow(7)
+            .addRemoteAddress(remoteAddress)
+            .build();
+    assertThat(nrQosSessionAttributes.getQosIdentifier()).isEqualTo(1);
+    assertThat(nrQosSessionAttributes.getQosFlowIdentifier()).isEqualTo(2);
+    assertThat(nrQosSessionAttributes.getMaxDownlinkBitRateKbps()).isEqualTo(3);
+    assertThat(nrQosSessionAttributes.getGuaranteedDownlinkBitRateKbps()).isEqualTo(5);
+    assertThat(nrQosSessionAttributes.getGuaranteedUplinkBitRateKbps()).isEqualTo(6);
+    assertThat(nrQosSessionAttributes.getBitRateWindowDuration()).isEqualTo(Duration.ofMillis(7));
+    assertThat(nrQosSessionAttributes.getRemoteAddresses()).contains(remoteAddress);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/PackageRollbackInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/PackageRollbackInfoBuilderTest.java
new file mode 100644
index 0000000..d7a60df
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/PackageRollbackInfoBuilderTest.java
@@ -0,0 +1,158 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.pm.VersionedPackage;
+import android.content.rollback.PackageRollbackInfo;
+import android.os.Build;
+import android.util.IntArray;
+import android.util.SparseLongArray;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Unit tests for {@link PackageRollbackInfoBuilder}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.Q)
+public final class PackageRollbackInfoBuilderTest {
+  private static final int BACKUP_ID = 1;
+  private static final int INSTALLED_USER_ID = 10;
+  private static final int SNAPSHOTTED_USER_ID = 11;
+  private static final int RESTORE_INFO_USER_ID = 2;
+  private static final int RESTORE_INFO_APP_ID = 3;
+  private static final String RESTORE_INFO_SEINFO = "fake_seinfo";
+  private static final VersionedPackage packageRolledBackFrom =
+      new VersionedPackage("test_package", 123);
+  private static final VersionedPackage packageRolledBackTo =
+      new VersionedPackage("test_package", 345);
+  private static final SparseLongArray ceSnapshotInodes = new SparseLongArray();
+
+  @Before
+  public void setUp() {
+    ceSnapshotInodes.append(1, 1L);
+  }
+
+  @Test
+  public void build_throwsError_whenMissingPackageRolledBackFrom() {
+    PackageRollbackInfoBuilder packageRollbackInfoBuilder = PackageRollbackInfoBuilder.newBuilder();
+
+    NullPointerException expectedException =
+        assertThrows(NullPointerException.class, packageRollbackInfoBuilder::build);
+    assertThat(expectedException)
+        .hasMessageThat()
+        .isEqualTo("Mandatory field 'packageRolledBackFrom' missing.");
+  }
+
+  @Test
+  public void build_throwsError_whenMissingPackageRolledBackTo() {
+    PackageRollbackInfoBuilder packageRollbackInfoBuilder =
+        PackageRollbackInfoBuilder.newBuilder().setPackageRolledBackFrom(packageRolledBackFrom);
+
+    NullPointerException expectedException =
+        assertThrows(NullPointerException.class, packageRollbackInfoBuilder::build);
+    assertThat(expectedException)
+        .hasMessageThat()
+        .isEqualTo("Mandatory field 'packageRolledBackTo' missing.");
+  }
+
+  @Test
+  public void build_withBasicFields() {
+    PackageRollbackInfo packageRollbackInfo =
+        PackageRollbackInfoBuilder.newBuilder()
+            .setPackageRolledBackFrom(packageRolledBackFrom)
+            .setPackageRolledBackTo(packageRolledBackTo)
+            .build();
+
+    assertThat(packageRollbackInfo).isNotNull();
+    assertThat(packageRollbackInfo.getVersionRolledBackFrom()).isEqualTo(packageRolledBackFrom);
+    assertThat(packageRollbackInfo.getVersionRolledBackTo()).isEqualTo(packageRolledBackTo);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.Q)
+  public void build_onQ() {
+    PackageRollbackInfo packageRollbackInfo =
+        PackageRollbackInfoBuilder.newBuilder()
+            .setPackageRolledBackFrom(packageRolledBackFrom)
+            .setPackageRolledBackTo(packageRolledBackTo)
+            .addPendingBackup(BACKUP_ID)
+            .addPendingRestore(RESTORE_INFO_USER_ID, RESTORE_INFO_APP_ID, RESTORE_INFO_SEINFO)
+            .setIsApex(true)
+            .addInstalledUser(INSTALLED_USER_ID)
+            .setCeSnapshotInodes(ceSnapshotInodes)
+            .build();
+
+    assertThat(packageRollbackInfo).isNotNull();
+    assertThat(packageRollbackInfo.getVersionRolledBackFrom()).isEqualTo(packageRolledBackFrom);
+    assertThat(packageRollbackInfo.getVersionRolledBackTo()).isEqualTo(packageRolledBackTo);
+    int[] pendingBackups =
+        ((IntArray) ReflectionHelpers.callInstanceMethod(packageRollbackInfo, "getPendingBackups"))
+            .toArray();
+    assertThat(pendingBackups).asList().containsExactly(BACKUP_ID);
+    assertThat(packageRollbackInfo.getPendingRestores()).hasSize(1);
+    assertThat(packageRollbackInfo.getPendingRestores().get(0).userId)
+        .isEqualTo(RESTORE_INFO_USER_ID);
+    assertThat(packageRollbackInfo.getPendingRestores().get(0).appId)
+        .isEqualTo(RESTORE_INFO_APP_ID);
+    assertThat(packageRollbackInfo.getPendingRestores().get(0).seInfo)
+        .isEqualTo(RESTORE_INFO_SEINFO);
+    assertThat(packageRollbackInfo.isApex()).isTrue();
+    IntArray installedUsers =
+        ReflectionHelpers.callInstanceMethod(packageRollbackInfo, "getInstalledUsers");
+    assertThat(installedUsers.toArray()).hasLength(1);
+    assertThat(installedUsers.get(0)).isEqualTo(INSTALLED_USER_ID);
+    if (RuntimeEnvironment.getApiLevel() <= Build.VERSION_CODES.R) {
+      assertThat(
+              (SparseLongArray)
+                  ReflectionHelpers.callInstanceMethod(packageRollbackInfo, "getCeSnapshotInodes"))
+          .isEqualTo(ceSnapshotInodes);
+    }
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.R)
+  public void build_onR() {
+    PackageRollbackInfo packageRollbackInfo =
+        PackageRollbackInfoBuilder.newBuilder()
+            .setPackageRolledBackFrom(packageRolledBackFrom)
+            .setPackageRolledBackTo(packageRolledBackTo)
+            .addPendingBackup(BACKUP_ID)
+            .addPendingRestore(RESTORE_INFO_USER_ID, RESTORE_INFO_APP_ID, RESTORE_INFO_SEINFO)
+            .setIsApex(true)
+            .setIsApkInApex(true)
+            .addSnapshottedUser(SNAPSHOTTED_USER_ID)
+            .setCeSnapshotInodes(ceSnapshotInodes)
+            .build();
+
+    assertThat(packageRollbackInfo).isNotNull();
+    assertThat(packageRollbackInfo.getVersionRolledBackFrom()).isEqualTo(packageRolledBackFrom);
+    assertThat(packageRollbackInfo.getVersionRolledBackTo()).isEqualTo(packageRolledBackTo);
+    int[] pendingBackups =
+        ((IntArray) ReflectionHelpers.callInstanceMethod(packageRollbackInfo, "getPendingBackups"))
+            .toArray();
+    assertThat(pendingBackups).asList().containsExactly(BACKUP_ID);
+    assertThat(packageRollbackInfo.getPendingRestores()).hasSize(1);
+    assertThat(packageRollbackInfo.getPendingRestores().get(0).userId)
+        .isEqualTo(RESTORE_INFO_USER_ID);
+    assertThat(packageRollbackInfo.getPendingRestores().get(0).appId)
+        .isEqualTo(RESTORE_INFO_APP_ID);
+    assertThat(packageRollbackInfo.getPendingRestores().get(0).seInfo)
+        .isEqualTo(RESTORE_INFO_SEINFO);
+    assertThat(packageRollbackInfo.isApex()).isTrue();
+    assertThat(packageRollbackInfo.isApkInApex()).isTrue();
+    int[] snapshottedUsers =
+        ((IntArray)
+                ReflectionHelpers.callInstanceMethod(packageRollbackInfo, "getSnapshottedUsers"))
+            .toArray();
+    assertThat(snapshottedUsers).asList().containsExactly(SNAPSHOTTED_USER_ID);
+    assertThat(
+            (SparseLongArray)
+                ReflectionHelpers.callInstanceMethod(packageRollbackInfo, "getCeSnapshotInodes"))
+        .isEqualTo(ceSnapshotInodes);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/PhoneAccountBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/PhoneAccountBuilderTest.java
new file mode 100644
index 0000000..aa31a6c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/PhoneAccountBuilderTest.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link PhoneAccountBuilder}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class PhoneAccountBuilderTest {
+  private PhoneAccount phoneAccount;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+
+    PhoneAccountHandle phoneAccountHandle =
+        new PhoneAccountHandle(new ComponentName(context.getPackageName(), "CLASS"), "id");
+
+    phoneAccount = new PhoneAccountBuilder(phoneAccountHandle, "phoneAccount").build();
+  }
+
+  @Test
+  public void enabled() {
+    assertThat(phoneAccount.isEnabled()).isFalse();
+
+    phoneAccount = new PhoneAccountBuilder(phoneAccount).setIsEnabled(true).build();
+
+    assertThat(phoneAccount.isEnabled()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/PlaybackInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/PlaybackInfoBuilderTest.java
new file mode 100644
index 0000000..d59dc0b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/PlaybackInfoBuilderTest.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.media.AudioAttributes;
+import android.media.session.MediaController.PlaybackInfo;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link PlaybackInfoBuilder} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+public class PlaybackInfoBuilderTest {
+  @Test
+  public void build_playbackInfo() {
+    PlaybackInfo playbackInfo =
+        PlaybackInfoBuilder.newBuilder()
+            .setVolumeType(PlaybackInfo.PLAYBACK_TYPE_LOCAL)
+            .setVolumeControl(1)
+            .setCurrentVolume(2)
+            .setMaxVolume(3)
+            .setAudioAttributes(new AudioAttributes.Builder().build())
+            .build();
+
+    assertThat(playbackInfo).isNotNull();
+    assertThat(playbackInfo.getPlaybackType()).isEqualTo(PlaybackInfo.PLAYBACK_TYPE_LOCAL);
+    assertThat(playbackInfo.getVolumeControl()).isEqualTo(1);
+    assertThat(playbackInfo.getCurrentVolume()).isEqualTo(2);
+    assertThat(playbackInfo.getMaxVolume()).isEqualTo(3);
+    assertThat(playbackInfo.getAudioAttributes()).isEqualTo(new AudioAttributes.Builder().build());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/PreciseDataConnectionStateBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/PreciseDataConnectionStateBuilderTest.java
new file mode 100644
index 0000000..26d92bc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/PreciseDataConnectionStateBuilderTest.java
@@ -0,0 +1,52 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build;
+import android.telephony.DataFailCause;
+import android.telephony.PreciseDataConnectionState;
+import android.telephony.TelephonyManager;
+import android.telephony.data.ApnSetting;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link PreciseDataConnectionStateBuilder} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.R)
+public class PreciseDataConnectionStateBuilderTest {
+  @Test
+  public void build_preciseDataConnectionState() {
+    ApnSetting apnSetting = new ApnSetting.Builder().setApnName("apnName").build();
+    PreciseDataConnectionState state =
+        PreciseDataConnectionStateBuilder.newBuilder()
+            .setDataState(TelephonyManager.DATA_DISCONNECTED)
+            .setNetworkType(TelephonyManager.NETWORK_TYPE_LTE)
+            .setApnSetting(apnSetting)
+            .setDataFailCause(DataFailCause.IMEI_NOT_ACCEPTED)
+            .build();
+
+    assertThat(state).isNotNull();
+    assertThat(state.getState()).isEqualTo(TelephonyManager.DATA_DISCONNECTED);
+    assertThat(state.getNetworkType()).isEqualTo(TelephonyManager.NETWORK_TYPE_LTE);
+    assertThat(state.getLastCauseCode()).isEqualTo(DataFailCause.IMEI_NOT_ACCEPTED);
+    assertThat(state.getApnSetting()).isEqualTo(apnSetting);
+  }
+
+  @Test
+  public void build_preciseDataConnectionState_nullApnSetting() {
+    PreciseDataConnectionState state =
+        PreciseDataConnectionStateBuilder.newBuilder()
+            .setDataState(TelephonyManager.DATA_DISCONNECTED)
+            .setNetworkType(TelephonyManager.NETWORK_TYPE_LTE)
+            .setDataFailCause(DataFailCause.IMEI_NOT_ACCEPTED)
+            .build();
+
+    assertThat(state).isNotNull();
+    assertThat(state.getState()).isEqualTo(TelephonyManager.DATA_DISCONNECTED);
+    assertThat(state.getNetworkType()).isEqualTo(TelephonyManager.NETWORK_TYPE_LTE);
+    assertThat(state.getLastCauseCode()).isEqualTo(DataFailCause.IMEI_NOT_ACCEPTED);
+    assertThat(state.getApnSetting()).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ResourceHelperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ResourceHelperTest.java
new file mode 100644
index 0000000..9edaac1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ResourceHelperTest.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.TypedValue;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ResourceHelper}. */
+@RunWith(AndroidJUnit4.class)
+public class ResourceHelperTest {
+
+  @Test
+  @Config(sdk = Config.NEWEST_SDK)
+  public void parseFloatAttribute() {
+    TypedValue out = new TypedValue();
+    ResourceHelper.parseFloatAttribute(null, "0.16", out, false);
+    assertThat(out.getFloat()).isEqualTo(0.16f);
+
+    out = new TypedValue();
+    ResourceHelper.parseFloatAttribute(null, ".16", out, false);
+    assertThat(out.getFloat()).isEqualTo(0.16f);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/RollbackInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/RollbackInfoBuilderTest.java
new file mode 100644
index 0000000..5f1398b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/RollbackInfoBuilderTest.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.pm.VersionedPackage;
+import android.content.rollback.PackageRollbackInfo;
+import android.content.rollback.RollbackInfo;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link RollbackInfoBuilder}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.Q)
+public final class RollbackInfoBuilderTest {
+  @Test
+  public void build_withNoSetFields() {
+    RollbackInfo rollbackInfo = RollbackInfoBuilder.newBuilder().build();
+
+    assertThat(rollbackInfo).isNotNull();
+  }
+
+  @Test
+  public void build() {
+    VersionedPackage packageRolledBackFrom = new VersionedPackage("test_package", 123);
+    VersionedPackage packageRolledBackTo = new VersionedPackage("test_package", 345);
+    PackageRollbackInfo packageRollbackInfo =
+        PackageRollbackInfoBuilder.newBuilder()
+            .setPackageRolledBackFrom(packageRolledBackFrom)
+            .setPackageRolledBackTo(packageRolledBackTo)
+            .build();
+    RollbackInfo rollbackInfo =
+        RollbackInfoBuilder.newBuilder()
+            .setRollbackId(1)
+            .setPackages(ImmutableList.of(packageRollbackInfo))
+            .setIsStaged(true)
+            .setCausePackages(ImmutableList.of(packageRolledBackFrom))
+            .setCommittedSessionId(10)
+            .build();
+
+    assertThat(rollbackInfo).isNotNull();
+    assertThat(rollbackInfo.getRollbackId()).isEqualTo(1);
+    assertThat(rollbackInfo.getPackages()).containsExactly(packageRollbackInfo);
+    assertThat(rollbackInfo.isStaged()).isTrue();
+    assertThat(rollbackInfo.getCausePackages()).containsExactly(packageRolledBackFrom);
+    assertThat(rollbackInfo.getCommittedSessionId()).isEqualTo(10);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/RunningTaskInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/RunningTaskInfoBuilderTest.java
new file mode 100644
index 0000000..73d7b43
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/RunningTaskInfoBuilderTest.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.ActivityManager.RunningTaskInfo;
+import android.content.ComponentName;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link RunningTaskInfoBuilder}. */
+@RunWith(AndroidJUnit4.class)
+public final class RunningTaskInfoBuilderTest {
+  @Test
+  public void build() {
+    ComponentName baseActivity = new ComponentName("package", "BaseActivity");
+    ComponentName topActivity = new ComponentName("package", "TopActivity");
+
+    RunningTaskInfo info =
+        RunningTaskInfoBuilder.newBuilder()
+            .setBaseActivity(baseActivity)
+            .setTopActivity(topActivity)
+            .build();
+    assertThat(info.baseActivity).isEqualTo(baseActivity);
+    assertThat(info.topActivity).isEqualTo(topActivity);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void build_taskId() {
+    RunningTaskInfo info = RunningTaskInfoBuilder.newBuilder().setTaskId(100).build();
+    assertThat(info.taskId).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void build_isVisible() {
+    RunningTaskInfo info = RunningTaskInfoBuilder.newBuilder().setIsVisible(true).build();
+    assertThat(info.isVisible).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/SQLiteCursorTest.java b/robolectric/src/test/java/org/robolectric/shadows/SQLiteCursorTest.java
new file mode 100644
index 0000000..c648eb3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/SQLiteCursorTest.java
@@ -0,0 +1,500 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteCursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SQLiteCursorTest {
+
+  private SQLiteDatabase database;
+  private Cursor cursor;
+
+  @Before
+  public void setUp() throws Exception {
+    database = SQLiteDatabase.create(null);
+
+    database.execSQL("CREATE TABLE table_name(" +
+        "id INTEGER PRIMARY KEY, " +
+        "name VARCHAR(255), " +
+        "long_value BIGINT," +
+        "float_value REAL," +
+        "double_value DOUBLE, " +
+        "blob_value BINARY, " +
+        "clob_value CLOB );");
+
+    addPeople();
+    cursor = createCursor();
+  }
+
+  @After
+  public void tearDown() {
+    database.close();
+    cursor.close();
+  }
+
+  @Test
+  public void testGetColumnNames() {
+    String[] columnNames = cursor.getColumnNames();
+
+    assertColumnNames(columnNames);
+  }
+
+  @Test
+  public void testGetColumnNamesEmpty() throws Exception {
+    setupEmptyResult();
+    String[] columnNames = cursor.getColumnNames();
+
+    // Column names are present even with an empty result.
+    assertThat(columnNames).isNotNull();
+    assertColumnNames(columnNames);
+  }
+
+  @Test
+  public void testGetColumnIndex() {
+    assertThat(cursor.getColumnIndex("id")).isEqualTo(0);
+    assertThat(cursor.getColumnIndex("name")).isEqualTo(1);
+  }
+
+  @Test
+  public void testGetColumnIndexNotFound() {
+    assertThat(cursor.getColumnIndex("Fred")).isEqualTo(-1);
+  }
+
+  @Test
+  public void testGetColumnIndexEmpty() throws Exception {
+    setupEmptyResult();
+
+    assertThat(cursor.getColumnIndex("id")).isEqualTo(0);
+    assertThat(cursor.getColumnIndex("name")).isEqualTo(1);
+  }
+
+  @Test
+  public void testGetColumnIndexOrThrow() {
+    assertThat(cursor.getColumnIndexOrThrow("id")).isEqualTo(0);
+    assertThat(cursor.getColumnIndexOrThrow("name")).isEqualTo(1);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testGetColumnIndexOrThrowNotFound() {
+    cursor.getColumnIndexOrThrow("Fred");
+  }
+
+  @Test
+  public void testGetColumnIndexOrThrowEmpty() throws Exception {
+    setupEmptyResult();
+
+    assertThat(cursor.getColumnIndexOrThrow("name")).isEqualTo(1);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testGetColumnIndexOrThrowNotFoundEmpty() throws Exception {
+    setupEmptyResult();
+
+    cursor.getColumnIndexOrThrow("Fred");
+  }
+
+  @Test
+  public void testMoveToFirst() {
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(1234);
+    assertThat(cursor.getString(1)).isEqualTo("Chuck");
+  }
+
+  @Test
+  public void testMoveToFirstEmpty() throws Exception {
+    setupEmptyResult();
+
+    assertThat(cursor.moveToFirst()).isFalse();
+  }
+
+  @Test
+  public void testMoveToNext() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(1235);
+    assertThat(cursor.getString(1)).isEqualTo("Julie");
+  }
+
+  @Test
+  public void testMoveToNextPastEnd() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.moveToNext()).isFalse();
+  }
+
+  @Test
+  public void testMoveBackwards() {
+    assertThat(cursor.getPosition()).isEqualTo(-1);
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(0);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(2);
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(0);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(2);
+
+    assertThat(cursor.moveToPosition(1)).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+  }
+
+  @Test
+  public void testMoveToNextEmpty() throws Exception {
+    setupEmptyResult();
+
+    assertThat(cursor.moveToFirst()).isFalse();
+    assertThat(cursor.moveToNext()).isFalse();
+  }
+
+  @Test
+  public void testMoveToPrevious() {
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.moveToNext()).isTrue();
+
+    assertThat(cursor.moveToPrevious()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(1234);
+    assertThat(cursor.getString(1)).isEqualTo("Chuck");
+  }
+
+  @Test
+  public void testMoveToPreviousPastStart() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    // Impossible to move cursor before the first item
+    assertThat(cursor.moveToPrevious()).isFalse();
+  }
+
+  @Test
+  public void testMoveToPreviousEmpty() throws Exception {
+    setupEmptyResult();
+    assertThat(cursor.moveToFirst()).isFalse();
+
+    assertThat(cursor.moveToPrevious()).isFalse();
+  }
+
+  @Test
+  public void testGetPosition() {
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(0);
+
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+  }
+
+  @Test
+  public void testGetBlob() {
+    String sql = "UPDATE table_name set blob_value=? where id=1234";
+    byte[] byteData = sql.getBytes(UTF_8);
+
+    database.execSQL(sql, new Object[]{byteData});
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    byte[] retrievedByteData = cursor.getBlob(5);
+    assertThat(byteData.length).isEqualTo(retrievedByteData.length);
+
+    for (int i = 0; i < byteData.length; i++) {
+      assertThat(byteData[i]).isEqualTo(retrievedByteData[i]);
+    }
+  }
+
+  @Test
+  public void testGetClob() {
+    String sql = "UPDATE table_name set clob_value=? where id=1234";
+    String s = "Don't CLOBber my data, please. Thank you.";
+
+    database.execSQL(sql, new Object[]{s});
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    String actual = cursor.getString(6);
+    assertThat(s).isEqualTo(actual);
+  }
+
+  @Test
+  public void testGetString() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    String[] data = {"Chuck", "Julie", "Chris"};
+
+    for (String aData : data) {
+      assertThat(cursor.getString(1)).isEqualTo(aData);
+      cursor.moveToNext();
+    }
+  }
+
+  @Test
+  public void testGetStringWhenInteger() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getString(0)).isEqualTo("1234");
+  }
+
+  @Test
+  public void testGetStringWhenLong() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getString(2)).isEqualTo("3463");
+  }
+
+  @Test
+  public void testGetStringWhenFloat() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getString(3)).isEqualTo("1.5");
+  }
+
+  @Test
+  public void testGetStringWhenDouble() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getString(4)).isEqualTo("3.14159");
+  }
+
+  @Test(expected = SQLiteException.class)
+  public void testGetStringWhenBlob() {
+    String sql = "UPDATE table_name set blob_value=? where id=1234";
+    byte[] byteData = sql.getBytes(UTF_8);
+
+    database.execSQL(sql, new Object[]{byteData});
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    cursor.getString(5);
+  }
+
+  @Test(expected = SQLiteException.class)
+  public void testGetIntWhenBlob() {
+    String sql = "UPDATE table_name set blob_value=? where id=1234";
+    byte[] byteData = sql.getBytes(UTF_8);
+
+    database.execSQL(sql, new Object[]{byteData});
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    cursor.getInt(5);
+  }
+
+  @Test
+  public void testGetStringWhenNull() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getString(5)).isNull();
+  }
+
+  @Test
+  public void testGetInt() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    int[] data = {1234, 1235, 1236};
+
+    for (int aData : data) {
+      assertThat(cursor.getInt(0)).isEqualTo(aData);
+      cursor.moveToNext();
+    }
+  }
+
+  @Test
+  public void testGetNumbersFromStringField() {
+    database.execSQL("update table_name set name = '1.2'");
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getInt(1)).isEqualTo(1);
+    assertThat(cursor.getDouble(1)).isEqualTo(1.2d);
+    assertThat(cursor.getFloat(1)).isEqualTo(1.2f);
+  }
+
+  @Test
+  public void testGetNumbersFromBlobField() {
+    database.execSQL("update table_name set name = '1.2'");
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getInt(1)).isEqualTo(1);
+    assertThat(cursor.getDouble(1)).isEqualTo(1.2d);
+    assertThat(cursor.getFloat(1)).isEqualTo(1.2f);
+  }
+
+  @Test
+  public void testGetLong() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getLong(2)).isEqualTo(3463L);
+  }
+
+  @Test
+  public void testGetFloat() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getFloat(3)).isEqualTo((float) 1.5);
+  }
+
+  @Test
+  public void testGetDouble() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getDouble(4)).isEqualTo(3.14159);
+  }
+
+  @Test
+  public void testClose() {
+    assertThat(cursor.isClosed()).isFalse();
+    cursor.close();
+    assertThat(cursor.isClosed()).isTrue();
+  }
+
+  @Test
+  public void testIsNullWhenNull() {
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.moveToNext()).isTrue();
+
+    assertThat(cursor.isNull(cursor.getColumnIndex("id"))).isFalse();
+    assertThat(cursor.isNull(cursor.getColumnIndex("name"))).isFalse();
+
+    assertThat(cursor.isNull(cursor.getColumnIndex("long_value"))).isTrue();
+    assertThat(cursor.isNull(cursor.getColumnIndex("float_value"))).isTrue();
+    assertThat(cursor.isNull(cursor.getColumnIndex("double_value"))).isTrue();
+  }
+
+  @Test
+  public void testIsNullWhenNotNull() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    for (int i = 0; i < 5; i++) {
+      assertThat(cursor.isNull(i)).isFalse();
+    }
+  }
+
+  @Test
+  public void testIsNullWhenIndexOutOfBounds() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    // column index 5 is out-of-bounds
+    assertThat(cursor.isNull(5)).isTrue();
+  }
+
+  @Test
+  public void testGetTypeWhenInteger() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(0)).isEqualTo(Cursor.FIELD_TYPE_INTEGER);
+  }
+
+  @Test
+  public void testGetTypeWhenString() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(1)).isEqualTo(Cursor.FIELD_TYPE_STRING);
+  }
+
+  @Test
+  public void testGetTypeWhenLong() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(2)).isEqualTo(Cursor.FIELD_TYPE_INTEGER);
+  }
+
+  @Test
+  public void testGetTypeWhenFloat() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(3)).isEqualTo(Cursor.FIELD_TYPE_FLOAT);
+  }
+
+  @Test
+  public void testGetTypeWhenDouble() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(4)).isEqualTo(Cursor.FIELD_TYPE_FLOAT);
+  }
+
+  @Test
+  public void testGetTypeWhenBlob() {
+    String sql = "UPDATE table_name set blob_value=? where id=1234";
+    byte[] byteData = sql.getBytes(UTF_8);
+
+    database.execSQL(sql, new Object[]{byteData});
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getType(5)).isEqualTo(Cursor.FIELD_TYPE_BLOB);
+  }
+
+  @Test
+  public void testGetTypeWhenNull() {
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(5)).isEqualTo(Cursor.FIELD_TYPE_NULL);
+  }
+
+  @Test
+  public void testGetNullNumberValues() {
+    String sql = "UPDATE table_name set long_value=NULL, float_value=NULL, double_value=NULL";
+    database.execSQL(sql);
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    assertThat(cursor.getType(2)).isEqualTo(Cursor.FIELD_TYPE_NULL);
+    assertThat(cursor.getLong(2)).isEqualTo(0);
+
+    assertThat(cursor.getType(3)).isEqualTo(Cursor.FIELD_TYPE_NULL);
+    assertThat(cursor.getFloat(3)).isEqualTo(0f);
+
+    assertThat(cursor.getType(4)).isEqualTo(Cursor.FIELD_TYPE_NULL);
+    assertThat(cursor.getDouble(4)).isEqualTo(0d);
+  }
+
+  private void addPeople() {
+    String[] inserts = {
+      "INSERT INTO table_name (id, name, long_value, float_value, double_value) VALUES(1234,"
+          + " 'Chuck', 3463, 1.5, 3.14159);",
+      "INSERT INTO table_name (id, name) VALUES(1235, 'Julie');",
+      "INSERT INTO table_name (id, name) VALUES(1236, 'Chris');"
+    };
+
+    for (String insert : inserts) {
+      database.execSQL(insert);
+    }
+  }
+
+  private Cursor createCursor() {
+    String sql ="SELECT * FROM table_name;";
+    Cursor cursor = database.rawQuery(sql, null);
+    assertThat(cursor).isInstanceOf(SQLiteCursor.class);
+    return cursor;
+  }
+
+  private void setupEmptyResult() {
+    database.execSQL("DELETE FROM table_name;");
+    cursor.close();
+    cursor = createCursor();
+  }
+
+  private void assertColumnNames(String[] columnNames) {
+    assertThat(columnNames.length).isEqualTo(7);
+    assertThat(columnNames[0]).isEqualTo("id");
+    assertThat(columnNames[1]).isEqualTo("name");
+    assertThat(columnNames[2]).isEqualTo("long_value");
+    assertThat(columnNames[3]).isEqualTo("float_value");
+    assertThat(columnNames[4]).isEqualTo("double_value");
+    assertThat(columnNames[5]).isEqualTo("blob_value");
+    assertThat(columnNames[6]).isEqualTo("clob_value");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/SQLiteDatabaseTest.java b/robolectric/src/test/java/org/robolectric/shadows/SQLiteDatabaseTest.java
new file mode 100644
index 0000000..b1c496c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/SQLiteDatabaseTest.java
@@ -0,0 +1,1044 @@
+package org.robolectric.shadows;
+
+import static android.database.sqlite.SQLiteDatabase.OPEN_READWRITE;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteGlobal;
+import android.os.CancellationSignal;
+import android.os.OperationCanceledException;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SQLiteDatabaseTest {
+  private SQLiteDatabase database;
+  private List<SQLiteDatabase> openDatabases = new ArrayList<>();
+  private static final String ANY_VALID_SQL = "SELECT 1";
+  private File databasePath;
+
+  @Before
+  public void setUp() throws Exception {
+    databasePath = ApplicationProvider.getApplicationContext().getDatabasePath("database.db");
+    databasePath.getParentFile().mkdirs();
+
+    database = openOrCreateDatabase(databasePath);
+    database.execSQL(
+        "CREATE TABLE table_name (\n"
+            + "  id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
+            + "  first_column VARCHAR(255),\n"
+            + "  second_column BINARY,\n"
+            + "  name VARCHAR(255),\n"
+            + "  big_int INTEGER\n"
+            + ");");
+
+    database.execSQL(
+        "CREATE TABLE rawtable (\n"
+            + "  id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
+            + "  first_column VARCHAR(255),\n"
+            + "  second_column BINARY,\n"
+            + "  name VARCHAR(255),\n"
+            + "  big_int INTEGER\n"
+            + ");");
+
+    database.execSQL(
+        "CREATE TABLE exectable (\n"
+            + "  id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
+            + "  first_column VARCHAR(255),\n"
+            + "  second_column BINARY,\n"
+            + "  name VARCHAR(255),\n"
+            + "  big_int INTEGER\n"
+            + ");");
+
+    database.execSQL(
+        "CREATE TABLE blob_table (\n" + "  id INTEGER PRIMARY KEY,\n" + "  blob_col BLOB\n" + ");");
+
+    String stringColumnValue = "column_value";
+    byte[] byteColumnValue = new byte[] {1, 2, 3};
+
+    ContentValues values = new ContentValues();
+
+    values.put("first_column", stringColumnValue);
+    values.put("second_column", byteColumnValue);
+
+    database.insert("rawtable", null, values);
+    ////////////////////////////////////////////////
+    String stringColumnValue2 = "column_value2";
+    byte[] byteColumnValue2 = new byte[] {4, 5, 6};
+    ContentValues values2 = new ContentValues();
+
+    values2.put("first_column", stringColumnValue2);
+    values2.put("second_column", byteColumnValue2);
+
+    database.insert("rawtable", null, values2);
+  }
+
+  @After
+  public void tearDown() {
+    for (SQLiteDatabase openDatabase : openDatabases) {
+      openDatabase.close();
+    }
+  }
+
+  @Test
+  public void testInsertAndQuery() {
+    String stringColumnValue = "column_value";
+    byte[] byteColumnValue = new byte[] {1, 2, 3};
+
+    ContentValues values = new ContentValues();
+
+    values.put("first_column", stringColumnValue);
+    values.put("second_column", byteColumnValue);
+
+    database.insert("table_name", null, values);
+
+    Cursor cursor =
+        database.query(
+            "table_name",
+            new String[] {"second_column", "first_column"},
+            null,
+            null,
+            null,
+            null,
+            null);
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    byte[] byteValueFromDatabase = cursor.getBlob(0);
+    String stringValueFromDatabase = cursor.getString(1);
+
+    assertThat(stringValueFromDatabase).isEqualTo(stringColumnValue);
+    assertThat(byteValueFromDatabase).isEqualTo(byteColumnValue);
+    cursor.close();
+  }
+
+  @Test
+  public void testInsertAndRawQuery() {
+    String stringColumnValue = "column_value";
+    byte[] byteColumnValue = new byte[] {1, 2, 3};
+
+    ContentValues values = new ContentValues();
+
+    values.put("first_column", stringColumnValue);
+    values.put("second_column", byteColumnValue);
+
+    database.insert("table_name", null, values);
+
+    Cursor cursor =
+        database.rawQuery("select second_column, first_column from" + " table_name", null);
+
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    byte[] byteValueFromDatabase = cursor.getBlob(0);
+    String stringValueFromDatabase = cursor.getString(1);
+
+    assertThat(stringValueFromDatabase).isEqualTo(stringColumnValue);
+    assertThat(byteValueFromDatabase).isEqualTo(byteColumnValue);
+    cursor.close();
+  }
+
+  @Test(expected = android.database.SQLException.class)
+  public void testInsertOrThrowWithSQLException() {
+    ContentValues values = new ContentValues();
+    values.put("id", 1);
+
+    database.insertOrThrow("table_name", null, values);
+    database.insertOrThrow("table_name", null, values);
+  }
+
+  @Test
+  public void testInsertOrThrow() {
+    String stringColumnValue = "column_value";
+    byte[] byteColumnValue = new byte[] {1, 2, 3};
+    ContentValues values = new ContentValues();
+    values.put("first_column", stringColumnValue);
+    values.put("second_column", byteColumnValue);
+    database.insertOrThrow("table_name", null, values);
+
+    Cursor cursor =
+        database.rawQuery("select second_column, first_column from" + " table_name", null);
+    assertThat(cursor.moveToFirst()).isTrue();
+    byte[] byteValueFromDatabase = cursor.getBlob(0);
+    String stringValueFromDatabase = cursor.getString(1);
+    assertThat(stringValueFromDatabase).isEqualTo(stringColumnValue);
+    assertThat(byteValueFromDatabase).isEqualTo(byteColumnValue);
+    cursor.close();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testRawQueryThrowsIndex0NullException() {
+    database.rawQuery(
+        "select second_column, first_column from rawtable" + " WHERE `id` = ?",
+        new String[] {null});
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testRawQueryThrowsIndex0NullException2() {
+    database.rawQuery("select second_column, first_column from rawtable", new String[] {null});
+  }
+
+  @Test
+  public void testRawQueryCountWithOneArgument() {
+    Cursor cursor =
+        database.rawQuery(
+            "select second_column, first_column from rawtable WHERE" + " `id` = ?",
+            new String[] {"1"});
+    assertThat(cursor.getCount()).isEqualTo(1);
+    cursor.close();
+  }
+
+  @Test
+  public void testRawQueryCountWithNullArgs() {
+    Cursor cursor = database.rawQuery("select second_column, first_column from rawtable", null);
+    assertThat(cursor.getCount()).isEqualTo(2);
+    cursor.close();
+  }
+
+  @Test
+  public void testRawQueryCountWithEmptyArguments() {
+    Cursor cursor =
+        database.rawQuery(
+            "select second_column, first_column" + " from" + " rawtable", new String[] {});
+    assertThat(cursor.getCount()).isEqualTo(2);
+    cursor.close();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldThrowWhenArgumentsDoNotMatchQuery() {
+    database.rawQuery("select second_column, first_column from rawtable", new String[] {"1"});
+  }
+
+  @Test
+  public void testInsertWithException() {
+    ContentValues values = new ContentValues();
+
+    assertEquals(-1, database.insert("table_that_doesnt_exist", null, values));
+  }
+
+  @Test
+  public void testEmptyTable() {
+    Cursor cursor =
+        database.query(
+            "table_name",
+            new String[] {"second_column", "first_column"},
+            null,
+            null,
+            null,
+            null,
+            null);
+
+    assertThat(cursor.moveToFirst()).isFalse();
+    cursor.close();
+  }
+
+  @Test
+  public void testInsertRowIdGeneration() {
+    ContentValues values = new ContentValues();
+    values.put("name", "Chuck");
+
+    long id = database.insert("table_name", null, values);
+
+    assertThat(id).isNotEqualTo(0L);
+  }
+
+  @Test
+  public void testInsertKeyGeneration() {
+    ContentValues values = new ContentValues();
+    values.put("name", "Chuck");
+
+    long key =
+        database.insertWithOnConflict("table_name", null, values, SQLiteDatabase.CONFLICT_IGNORE);
+
+    assertThat(key).isNotEqualTo(0L);
+  }
+
+  @Test
+  public void testInsertDuplicatedKeyGeneration() {
+    ContentValues values = new ContentValues();
+    values.put("id", 123);
+    values.put("name", "Chuck");
+
+    long firstKey =
+        database.insertWithOnConflict("table_name", null, values, SQLiteDatabase.CONFLICT_IGNORE);
+
+    assertThat(firstKey).isEqualTo(123L);
+
+    long duplicateKey =
+        database.insertWithOnConflict("table_name", null, values, SQLiteDatabase.CONFLICT_IGNORE);
+
+    assertThat(duplicateKey).isEqualTo(-1L);
+  }
+
+  @Test
+  public void testInsertEmptyBlobArgument() {
+    ContentValues emptyBlobValues = new ContentValues();
+    emptyBlobValues.put("id", 1);
+    emptyBlobValues.put("blob_col", new byte[] {});
+
+    ContentValues nullBlobValues = new ContentValues();
+    nullBlobValues.put("id", 2);
+    nullBlobValues.put("blob_col", (byte[]) null);
+
+    long key =
+        database.insertWithOnConflict(
+            "blob_table", null, emptyBlobValues, SQLiteDatabase.CONFLICT_FAIL);
+    assertThat(key).isNotEqualTo(0L);
+    key =
+        database.insertWithOnConflict(
+            "blob_table", null, nullBlobValues, SQLiteDatabase.CONFLICT_FAIL);
+    assertThat(key).isNotEqualTo(0L);
+
+    Cursor cursor =
+        database.query("blob_table", new String[] {"blob_col"}, "id=1", null, null, null, null);
+    try {
+      assertThat(cursor.moveToFirst()).isTrue();
+      assertThat(cursor.getBlob(cursor.getColumnIndexOrThrow("blob_col"))).isNotNull();
+    } finally {
+      cursor.close();
+    }
+
+    cursor =
+        database.query("blob_table", new String[] {"blob_col"}, "id=2", null, null, null, null);
+    try {
+      assertThat(cursor.moveToFirst()).isTrue();
+      assertThat(cursor.getBlob(cursor.getColumnIndexOrThrow("blob_col"))).isNull();
+    } finally {
+      cursor.close();
+    }
+  }
+
+  @Test
+  public void testUpdate() {
+    addChuck();
+
+    assertThat(updateName(1234L, "Buster")).isEqualTo(1);
+
+    Cursor cursor =
+        database.query("table_name", new String[] {"id", "name"}, null, null, null, null, null);
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(1);
+
+    assertIdAndName(cursor, 1234L, "Buster");
+    cursor.close();
+  }
+
+  @Test
+  public void testUpdateNoMatch() {
+    addChuck();
+
+    assertThat(updateName(5678L, "Buster")).isEqualTo(0);
+
+    Cursor cursor =
+        database.query("table_name", new String[] {"id", "name"}, null, null, null, null, null);
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(1);
+
+    assertIdAndName(cursor, 1234L, "Chuck");
+    cursor.close();
+  }
+
+  @Test
+  public void testUpdateAll() {
+    addChuck();
+    addJulie();
+
+    assertThat(updateName("Belvedere")).isEqualTo(2);
+
+    Cursor cursor =
+        database.query("table_name", new String[] {"id", "name"}, null, null, null, null, null);
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(2);
+
+    assertIdAndName(cursor, 1234L, "Belvedere");
+    assertThat(cursor.moveToNext()).isTrue();
+
+    assertIdAndName(cursor, 1235L, "Belvedere");
+    assertThat(cursor.isLast()).isTrue();
+    assertThat(cursor.moveToNext()).isFalse();
+    assertThat(cursor.isAfterLast()).isTrue();
+    assertThat(cursor.moveToNext()).isFalse();
+    cursor.close();
+  }
+
+  @Test
+  public void testDelete() {
+    addChuck();
+
+    int deleted = database.delete("table_name", "id=1234", null);
+    assertThat(deleted).isEqualTo(1);
+
+    assertEmptyDatabase();
+  }
+
+  @Test
+  public void testDeleteNoMatch() {
+    addChuck();
+
+    int deleted = database.delete("table_name", "id=5678", null);
+    assertThat(deleted).isEqualTo(0);
+
+    assertNonEmptyDatabase();
+  }
+
+  @Test
+  public void testDeleteAll() {
+    addChuck();
+    addJulie();
+
+    int deleted = database.delete("table_name", "1", null);
+    assertThat(deleted).isEqualTo(2);
+
+    assertEmptyDatabase();
+  }
+
+  @Test
+  public void testExecSQL() {
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(1234, 'Chuck');");
+
+    Cursor cursor = database.rawQuery("SELECT COUNT(*) FROM table_name", null);
+    assertThat(cursor).isNotNull();
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(1);
+    cursor.close();
+
+    cursor = database.rawQuery("SELECT * FROM table_name", null);
+    assertThat(cursor).isNotNull();
+    assertThat(cursor.moveToNext()).isTrue();
+
+    assertThat(cursor.getInt(cursor.getColumnIndex("id"))).isEqualTo(1234);
+    assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Chuck");
+    cursor.close();
+  }
+
+  @Test
+  public void testExecSQLParams() {
+    database.execSQL(
+        "CREATE TABLE `routine` (`id` INTEGER PRIMARY KEY AUTOINCREMENT , `name`"
+            + " VARCHAR , `lastUsed` INTEGER DEFAULT 0 , "
+            + " UNIQUE (`name`)) ",
+        new Object[] {});
+    database.execSQL(
+        "INSERT INTO `routine` (`name`" + " ,`lastUsed`" + " ) VALUES" + " (?,?)",
+        new Object[] {"Leg Press", 0});
+    database.execSQL(
+        "INSERT INTO `routine` (`name`" + " ,`lastUsed`" + " ) VALUES" + " (?,?)",
+        new Object[] {"Bench" + " Press", 1});
+
+    Cursor cursor = database.rawQuery("SELECT COUNT(*) FROM `routine`", null);
+    assertThat(cursor).isNotNull();
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(2);
+    cursor.close();
+
+    cursor = database.rawQuery("SELECT `id`, `name` ,`lastUsed` FROM `routine`", null);
+    assertThat(cursor).isNotNull();
+    assertThat(cursor.moveToNext()).isTrue();
+
+    assertThat(cursor.getInt(cursor.getColumnIndex("id"))).isEqualTo(1);
+    assertThat(cursor.getInt(cursor.getColumnIndex("lastUsed"))).isEqualTo(0);
+    assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Leg Press");
+
+    assertThat(cursor.moveToNext()).isTrue();
+
+    assertThat(cursor.getInt(cursor.getColumnIndex("id"))).isEqualTo(2);
+    assertThat(cursor.getInt(cursor.getColumnIndex("lastUsed"))).isEqualTo(1);
+    assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Bench Press");
+    cursor.close();
+  }
+
+  @Test(expected = SQLiteException.class)
+  public void execSqlShouldThrowOnBadQuery() {
+    database.execSQL("INSERT INTO table_name;"); // invalid SQL
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testExecSQLExceptionParametersWithoutArguments() {
+    database.execSQL("insert into exectable (first_column) values (?);", null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testExecSQLWithNullBindArgs() {
+    database.execSQL("insert into exectable (first_column) values ('sdfsfs');", null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testExecSQLTooManyBindArguments() {
+    database.execSQL(
+        "insert into exectable (first_column) values" + " ('kjhk');", new String[] {"xxxx"});
+  }
+
+  @Test
+  public void testExecSQLWithEmptyBindArgs() {
+    database.execSQL("insert into exectable (first_column) values ('eff');", new String[] {});
+  }
+
+  @Test
+  public void testExecSQLInsertNull() {
+    String name = "nullone";
+
+    database.execSQL(
+        "insert into exectable (first_column, name)" + " values" + " (?,?);",
+        new String[] {null, name});
+
+    Cursor cursor =
+        database.rawQuery(
+            "select * from exectable WHERE" + " `name`" + " = ?", new String[] {name});
+    cursor.moveToFirst();
+    int firstIndex = cursor.getColumnIndex("first_column");
+    int nameIndex = cursor.getColumnIndex("name");
+    assertThat(cursor.getString(nameIndex)).isEqualTo(name);
+    assertThat(cursor.getString(firstIndex)).isEqualTo(null);
+    cursor.close();
+  }
+
+  @Test
+  public void testExecSQLAutoIncrementSQLite() {
+    database.execSQL(
+        "CREATE TABLE auto_table (id INTEGER PRIMARY KEY AUTOINCREMENT, name" + " VARCHAR(255));");
+
+    ContentValues values = new ContentValues();
+    values.put("name", "Chuck");
+
+    long key = database.insert("auto_table", null, values);
+    assertThat(key).isNotEqualTo(0L);
+
+    long key2 = database.insert("auto_table", null, values);
+    assertThat(key2).isNotEqualTo(key);
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testClose() {
+    database.close();
+
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(1234, 'Chuck');");
+  }
+
+  @Test
+  public void testIsOpen() {
+    assertThat(database.isOpen()).isTrue();
+    database.close();
+    assertThat(database.isOpen()).isFalse();
+  }
+
+  @Test
+  public void shouldStoreGreatBigHonkingIntegersCorrectly() {
+    database.execSQL("INSERT INTO table_name(big_int) VALUES(1234567890123456789);");
+    Cursor cursor =
+        database.query("table_name", new String[] {"big_int"}, null, null, null, null, null);
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertEquals(1234567890123456789L, cursor.getLong(0));
+    cursor.close();
+  }
+
+  @Test
+  public void testSuccessTransaction() {
+    database.beginTransaction();
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(1234, 'Chuck');");
+    database.setTransactionSuccessful();
+    database.endTransaction();
+
+    Cursor cursor = database.rawQuery("SELECT COUNT(*) FROM table_name", null);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(1);
+    cursor.close();
+  }
+
+  @Test
+  public void testFailureTransaction() {
+    database.beginTransaction();
+
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(1234, 'Chuck');");
+
+    final String select = "SELECT COUNT(*) FROM table_name";
+
+    Cursor cursor = database.rawQuery(select, null);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(1);
+    cursor.close();
+
+    database.endTransaction();
+
+    cursor = database.rawQuery(select, null);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(0);
+    cursor.close();
+  }
+
+  @Test
+  public void testSuccessNestedTransaction() {
+    database.beginTransaction();
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(1234, 'Chuck');");
+    database.beginTransaction();
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(12345, 'Julie');");
+    database.setTransactionSuccessful();
+    database.endTransaction();
+    database.setTransactionSuccessful();
+    database.endTransaction();
+
+    Cursor cursor = database.rawQuery("SELECT COUNT(*) FROM table_name", null);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(2);
+    cursor.close();
+  }
+
+  @Test
+  public void testFailureNestedTransaction() {
+    database.beginTransaction();
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(1234, 'Chuck');");
+    database.beginTransaction();
+    database.execSQL("INSERT INTO table_name (id, name) VALUES(12345, 'Julie');");
+    database.endTransaction();
+    database.setTransactionSuccessful();
+    database.endTransaction();
+
+    Cursor cursor = database.rawQuery("SELECT COUNT(*) FROM table_name", null);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getInt(0)).isEqualTo(0);
+    cursor.close();
+  }
+
+  @Test
+  public void testTransactionAlreadySuccessful() {
+    database.beginTransaction();
+    database.setTransactionSuccessful();
+    try {
+      database.setTransactionSuccessful();
+      fail("didn't receive the expected IllegalStateException");
+    } catch (IllegalStateException e) {
+      assertThat(e.getMessage()).contains("transaction");
+      assertThat(e.getMessage()).contains("successful");
+    } finally {
+      database.endTransaction();
+    }
+  }
+
+  @Test
+  public void testInTransaction() {
+    assertThat(database.inTransaction()).isFalse();
+    database.beginTransaction();
+    assertThat(database.inTransaction()).isTrue();
+    database.endTransaction();
+    assertThat(database.inTransaction()).isFalse();
+  }
+
+  @Test
+  public void testReplace() {
+    long id = addChuck();
+    assertThat(id).isNotEqualTo(-1L);
+
+    ContentValues values = new ContentValues();
+    values.put("id", id);
+    values.put("name", "Norris");
+
+    long replaceId = database.replace("table_name", null, values);
+    assertThat(replaceId).isEqualTo(id);
+
+    String query = "SELECT name FROM table_name where id = " + id;
+    Cursor cursor = executeQuery(query);
+
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Norris");
+    cursor.close();
+  }
+
+  @Test
+  public void testReplaceIsReplacing() {
+    final String query = "SELECT first_column FROM table_name WHERE id = ";
+    String stringValueA = "column_valueA";
+    String stringValueB = "column_valueB";
+    long id = 1;
+
+    ContentValues valuesA = new ContentValues();
+    valuesA.put("id", id);
+    valuesA.put("first_column", stringValueA);
+
+    ContentValues valuesB = new ContentValues();
+    valuesB.put("id", id);
+    valuesB.put("first_column", stringValueB);
+
+    long firstId = database.replaceOrThrow("table_name", null, valuesA);
+    Cursor firstCursor = executeQuery(query + firstId);
+    assertThat(firstCursor.moveToNext()).isTrue();
+    long secondId = database.replaceOrThrow("table_name", null, valuesB);
+    Cursor secondCursor = executeQuery(query + secondId);
+    assertThat(secondCursor.moveToNext()).isTrue();
+
+    assertThat(firstId).isEqualTo(id);
+    assertThat(secondId).isEqualTo(id);
+    assertThat(firstCursor.getString(0)).isEqualTo(stringValueA);
+    assertThat(secondCursor.getString(0)).isEqualTo(stringValueB);
+    firstCursor.close();
+    secondCursor.close();
+  }
+
+  @Test
+  public void shouldCreateDefaultCursorFactoryWhenNullFactoryPassedToRawQuery() {
+    Cursor cursor = database.rawQueryWithFactory(null, ANY_VALID_SQL, null, null);
+    cursor.close();
+  }
+
+  @Test
+  public void shouldCreateDefaultCursorFactoryWhenNullFactoryPassedToQuery() {
+    Cursor cursor =
+        database.queryWithFactory(
+            null, false, "table_name", null, null, null, null, null, null, null);
+    cursor.close();
+  }
+
+  @Test
+  public void shouldOpenExistingDatabaseFromFileSystemIfFileExists() {
+    database.close();
+
+    SQLiteDatabase db =
+        SQLiteDatabase.openDatabase(databasePath.getAbsolutePath(), null, OPEN_READWRITE);
+    Cursor c = db.rawQuery("select * from rawtable", null);
+    assertThat(c).isNotNull();
+    assertThat(c.getCount()).isEqualTo(2);
+    c.close();
+    assertThat(db.isOpen()).isTrue();
+    db.close();
+    assertThat(db.isOpen()).isFalse();
+
+    SQLiteDatabase reopened =
+        SQLiteDatabase.openDatabase(databasePath.getAbsolutePath(), null, OPEN_READWRITE);
+    assertThat(reopened).isNotSameInstanceAs(db);
+    assertThat(reopened.isOpen()).isTrue();
+    reopened.close();
+  }
+
+  @Test(expected = SQLiteException.class)
+  public void shouldThrowIfFileDoesNotExist() {
+    File testDb = new File("/i/do/not/exist");
+    assertThat(testDb.exists()).isFalse();
+    SQLiteDatabase.openOrCreateDatabase(testDb.getAbsolutePath(), null);
+  }
+
+  @Test
+  public void shouldUseInMemoryDatabaseWhenCallingCreate() {
+    SQLiteDatabase db = SQLiteDatabase.create(null);
+    assertThat(db.isOpen()).isTrue();
+    assertThat(db.getPath()).isEqualTo(":memory:");
+    db.close();
+  }
+
+  @Test
+  public void shouldSetAndGetVersion() {
+    assertThat(database.getVersion()).isEqualTo(0);
+    database.setVersion(20);
+    assertThat(database.getVersion()).isEqualTo(20);
+  }
+
+  @Test
+  public void testTwoConcurrentDbConnections() {
+    SQLiteDatabase db1 = openOrCreateDatabase("db1");
+    SQLiteDatabase db2 = openOrCreateDatabase("db2");
+
+    db1.execSQL("CREATE TABLE foo(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT);");
+    db2.execSQL("CREATE TABLE bar(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT);");
+
+    ContentValues d1 = new ContentValues();
+    d1.put("data", "d1");
+
+    ContentValues d2 = new ContentValues();
+    d2.put("data", "d2");
+
+    db1.insert("foo", null, d1);
+    db2.insert("bar", null, d2);
+
+    Cursor c = db1.rawQuery("select * from foo", null);
+    assertThat(c).isNotNull();
+    assertThat(c.getCount()).isEqualTo(1);
+    assertThat(c.moveToNext()).isTrue();
+    assertThat(c.getString(c.getColumnIndex("data"))).isEqualTo("d1");
+    c.close();
+
+    c = db2.rawQuery("select * from bar", null);
+    assertThat(c).isNotNull();
+    assertThat(c.getCount()).isEqualTo(1);
+    assertThat(c.moveToNext()).isTrue();
+    assertThat(c.getString(c.getColumnIndex("data"))).isEqualTo("d2");
+    c.close();
+  }
+
+  @Test(expected = SQLiteException.class)
+  public void testQueryThrowsSQLiteException() {
+    SQLiteDatabase db1 = openOrCreateDatabase("db1");
+    db1.query("FOO", null, null, null, null, null, null);
+  }
+
+  @Test(expected = SQLiteException.class)
+  public void testShouldThrowSQLiteExceptionIfOpeningNonexistentDatabase() {
+    SQLiteDatabase.openDatabase("/does/not/exist", null, OPEN_READWRITE);
+  }
+
+  @Test
+  public void testCreateAndDropTable() throws Exception {
+    SQLiteDatabase db = openOrCreateDatabase("db1");
+    db.execSQL("CREATE TABLE foo(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT);");
+    Cursor c = db.query("FOO", null, null, null, null, null, null);
+    assertThat(c).isNotNull();
+    c.close();
+    db.close();
+    db = openOrCreateDatabase("db1");
+    db.execSQL("DROP TABLE IF EXISTS foo;");
+    try {
+      c = db.query("FOO", null, null, null, null, null, null);
+      fail("expected no such table exception");
+    } catch (SQLiteException e) {
+      // TODO
+    }
+    db.close();
+  }
+
+  @Test
+  public void testCreateAndAlterTable() {
+    SQLiteDatabase db = openOrCreateDatabase("db1");
+    db.execSQL("CREATE TABLE foo(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT);");
+    Cursor c = db.query("FOO", null, null, null, null, null, null);
+    assertThat(c).isNotNull();
+    c.close();
+    db.close();
+    db = openOrCreateDatabase("db1");
+    db.execSQL("ALTER TABLE foo ADD COLUMN more TEXT NULL;");
+    c = db.query("FOO", null, null, null, null, null, null);
+    assertThat(c).isNotNull();
+    int moreIndex = c.getColumnIndex("more");
+    assertThat(moreIndex).isAtLeast(0);
+    c.close();
+  }
+
+  @Test
+  public void testDataInMemoryDatabaseIsPersistentAfterClose() {
+    SQLiteDatabase db1 = openOrCreateDatabase("db1");
+    db1.execSQL("CREATE TABLE foo(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT);");
+    ContentValues d1 = new ContentValues();
+    d1.put("data", "d1");
+    db1.insert("foo", null, d1);
+    db1.close();
+
+    SQLiteDatabase db2 = openOrCreateDatabase("db1");
+    Cursor c = db2.rawQuery("select * from foo", null);
+    assertThat(c).isNotNull();
+    assertThat(c.getCount()).isEqualTo(1);
+    assertThat(c.moveToNext()).isTrue();
+    assertThat(c.getString(c.getColumnIndex("data"))).isEqualTo("d1");
+    c.close();
+  }
+
+  @Test
+  public void testRawQueryWithFactoryAndCancellationSignal() {
+    CancellationSignal signal = new CancellationSignal();
+
+    Cursor cursor =
+        database.rawQueryWithFactory(null, "select * from" + " table_name", null, null, signal);
+    assertThat(cursor).isNotNull();
+    assertThat(cursor.getColumnCount()).isEqualTo(5);
+    assertThat(cursor.isClosed()).isFalse();
+
+    signal.cancel();
+
+    try {
+      cursor.moveToNext();
+      fail("did not get cancellation signal");
+    } catch (OperationCanceledException e) {
+      // expected
+    }
+    cursor.close();
+  }
+
+  @Test
+  public void testRawQueryWithCommonTableExpression() {
+    try (Cursor cursor =
+        database.rawQuery(
+            "WITH RECURSIVE\n"
+                + "  cnt(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM cnt WHERE x<100)\n"
+                + "SELECT COUNT(*) FROM cnt;",
+            null)) {
+      assertThat(cursor).isNotNull();
+      assertThat(cursor.moveToNext()).isTrue();
+      assertThat(cursor.getCount()).isEqualTo(1);
+      assertThat(cursor.isNull(0)).isFalse();
+      assertThat(cursor.getLong(0)).isEqualTo(100);
+    }
+  }
+
+  @Test
+  public void shouldBeAbleToBeUsedFromDifferentThread() {
+    final CountDownLatch sync = new CountDownLatch(1);
+    final Throwable[] error = {null};
+
+    new Thread() {
+      @Override
+      public void run() {
+        try (Cursor c = executeQuery("select * from table_name")) {
+        } catch (Throwable e) {
+          e.printStackTrace();
+          error[0] = e;
+        } finally {
+          sync.countDown();
+        }
+      }
+    }.start();
+
+    try {
+      sync.await();
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+
+    assertThat(error[0]).isNull();
+  }
+
+  private Cursor executeQuery(String query) {
+    return database.rawQuery(query, null);
+  }
+
+  private long addChuck() {
+    return addPerson(1234L, "Chuck");
+  }
+
+  private long addJulie() {
+    return addPerson(1235L, "Julie");
+  }
+
+  private long addPerson(long id, String name) {
+    ContentValues values = new ContentValues();
+    values.put("id", id);
+    values.put("name", name);
+    return database.insert("table_name", null, values);
+  }
+
+  private int updateName(long id, String name) {
+    ContentValues values = new ContentValues();
+    values.put("name", name);
+    return database.update("table_name", values, "id=" + id, null);
+  }
+
+  private int updateName(String name) {
+    ContentValues values = new ContentValues();
+    values.put("name", name);
+    return database.update("table_name", values, null, null);
+  }
+
+  private void assertIdAndName(Cursor cursor, long id, String name) {
+    long idValueFromDatabase;
+    String stringValueFromDatabase;
+
+    idValueFromDatabase = cursor.getLong(0);
+    stringValueFromDatabase = cursor.getString(1);
+    assertThat(idValueFromDatabase).isEqualTo(id);
+    assertThat(stringValueFromDatabase).isEqualTo(name);
+  }
+
+  private void assertEmptyDatabase() {
+    Cursor cursor =
+        database.query("table_name", new String[] {"id", "name"}, null, null, null, null, null);
+    assertThat(cursor.moveToFirst()).isFalse();
+    assertThat(cursor.isClosed()).isFalse();
+    assertThat(cursor.getCount()).isEqualTo(0);
+    cursor.close();
+  }
+
+  private void assertNonEmptyDatabase() {
+    Cursor cursor =
+        database.query("table_name", new String[] {"id", "name"}, null, null, null, null, null);
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isNotEqualTo(0);
+    cursor.close();
+  }
+
+  @Test
+  public void shouldAlwaysReturnCorrectIdFromInsert() {
+    database.execSQL(
+        "CREATE TABLE table_A (\n"
+            + "  _id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
+            + "  id INTEGER DEFAULT 0\n"
+            + ");");
+
+    database.execSQL("CREATE VIRTUAL TABLE new_search USING fts3 (id);");
+
+    database.execSQL(
+        "CREATE TRIGGER t1 AFTER INSERT ON table_A WHEN new.id=0 BEGIN UPDATE"
+            + " table_A SET id=-new._id WHERE _id=new._id AND id=0; END;");
+    database.execSQL(
+        "CREATE TRIGGER t2 AFTER INSERT ON table_A BEGIN INSERT INTO new_search"
+            + " (id) VALUES (new._id); END;");
+    database.execSQL(
+        "CREATE TRIGGER t3 BEFORE UPDATE ON table_A BEGIN DELETE FROM new_search"
+            + " WHERE id MATCH old._id; END;");
+    database.execSQL(
+        "CREATE TRIGGER t4 AFTER UPDATE ON table_A BEGIN INSERT INTO new_search"
+            + " (id) VALUES (new._id); END;");
+
+    long[] returnedIds =
+        new long[] {
+          database.insert("table_A", "id", new ContentValues()),
+          database.insert("table_A", "id", new ContentValues())
+        };
+
+    Cursor c = database.query("table_A", new String[] {"_id"}, null, null, null, null, null);
+    assertThat(c).isNotNull();
+
+    long[] actualIds = new long[c.getCount()];
+    for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) {
+      actualIds[c.getPosition()] = c.getLong(c.getColumnIndexOrThrow("_id"));
+    }
+    c.close();
+
+    assertThat(returnedIds).isEqualTo(actualIds);
+  }
+
+  @Test
+  public void shouldCorrectlyReturnNullValues() {
+    database.execSQL(
+        "CREATE TABLE null_test (col_int INTEGER, col_text TEXT, col_real REAL,"
+            + " col_blob BLOB)");
+
+    ContentValues data = new ContentValues();
+    data.putNull("col_int");
+    data.putNull("col_text");
+    data.putNull("col_real");
+    data.putNull("col_blob");
+    assertThat(database.insert("null_test", null, data)).isAtLeast(0L);
+
+    Cursor nullValuesCursor = database.query("null_test", null, null, null, null, null, null);
+    nullValuesCursor.moveToFirst();
+    final int colsCount = 4;
+    for (int i = 0; i < colsCount; i++) {
+      assertThat(nullValuesCursor.getType(i)).isEqualTo(Cursor.FIELD_TYPE_NULL);
+      assertThat(nullValuesCursor.getString(i)).isNull();
+    }
+    assertThat(nullValuesCursor.getBlob(3)).isNull();
+    nullValuesCursor.close();
+  }
+
+  @Test
+  public void sqliteGlobal_defaults() {
+    assertThat(SQLiteGlobal.getDefaultSyncMode()).isEqualTo("OFF");
+    assertThat(SQLiteGlobal.getWALSyncMode()).isEqualTo("OFF");
+    assertThat(SQLiteGlobal.getDefaultJournalMode()).isEqualTo("MEMORY");
+  }
+
+  private SQLiteDatabase openOrCreateDatabase(String name) {
+    return openOrCreateDatabase(ApplicationProvider.getApplicationContext().getDatabasePath(name));
+  }
+
+  private SQLiteDatabase openOrCreateDatabase(File databasePath) {
+    SQLiteDatabase database = SQLiteDatabase.openOrCreateDatabase(databasePath, null);
+    openDatabases.add(database);
+    return database;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/SQLiteOpenHelperTest.java b/robolectric/src/test/java/org/robolectric/shadows/SQLiteOpenHelperTest.java
new file mode 100644
index 0000000..63e8f21
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/SQLiteOpenHelperTest.java
@@ -0,0 +1,273 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDatabase.CursorFactory;
+import android.database.sqlite.SQLiteOpenHelper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SQLiteOpenHelperTest {
+
+  private TestOpenHelper helper;
+
+  @Before
+  public void setUp() {
+    helper = new TestOpenHelper(ApplicationProvider.getApplicationContext(), "path", null, 1);
+  }
+
+  @After
+  public void tearDown() {
+    helper.close();
+  }
+
+  @Test
+  public void testConstructorWithNullPathShouldCreateInMemoryDatabase() {
+    TestOpenHelper helper = new TestOpenHelper(null, null, null, 1);
+    SQLiteDatabase database = helper.getReadableDatabase();
+    assertDatabaseOpened(database, helper);
+    assertInitialDB(database, helper);
+    helper.close();
+  }
+
+  @Test
+  public void testInitialGetReadableDatabase() {
+    SQLiteDatabase database = helper.getReadableDatabase();
+    assertInitialDB(database, helper);
+  }
+
+  @Test
+  public void testSubsequentGetReadableDatabase() {
+    helper.getReadableDatabase();
+    helper.close();
+    SQLiteDatabase database = helper.getReadableDatabase();
+
+    assertSubsequentDB(database, helper);
+  }
+
+  @Test
+  public void testSameDBInstanceSubsequentGetReadableDatabase() {
+    SQLiteDatabase db1 = helper.getReadableDatabase();
+    SQLiteDatabase db2 = helper.getReadableDatabase();
+
+    assertThat(db1).isSameInstanceAs(db2);
+  }
+
+  @Test
+  public void testInitialGetWritableDatabase() {
+    SQLiteDatabase database = helper.getWritableDatabase();
+    assertInitialDB(database, helper);
+  }
+
+  @Test
+  public void testSubsequentGetWritableDatabase() {
+    helper.getWritableDatabase();
+    helper.close();
+
+    assertSubsequentDB(helper.getWritableDatabase(), helper);
+  }
+
+  @Test
+  public void testSameDBInstanceSubsequentGetWritableDatabase() {
+    SQLiteDatabase db1 = helper.getWritableDatabase();
+    SQLiteDatabase db2 = helper.getWritableDatabase();
+
+    assertThat(db1).isSameInstanceAs(db2);
+  }
+
+  @Test
+  public void testClose() {
+    SQLiteDatabase database = helper.getWritableDatabase();
+
+    assertThat(database.isOpen()).isTrue();
+    helper.close();
+    assertThat(database.isOpen()).isFalse();
+  }
+
+  @Test
+  public void testGetPath() {
+    final String path1 = "path1", path2 = "path2";
+
+    TestOpenHelper helper1 =
+        new TestOpenHelper(ApplicationProvider.getApplicationContext(), path1, null, 1);
+    String expectedPath1 =
+        ApplicationProvider.getApplicationContext().getDatabasePath(path1).getAbsolutePath();
+    assertThat(helper1.getReadableDatabase().getPath()).isEqualTo(expectedPath1);
+
+    TestOpenHelper helper2 =
+        new TestOpenHelper(ApplicationProvider.getApplicationContext(), path2, null, 1);
+    String expectedPath2 =
+        ApplicationProvider.getApplicationContext().getDatabasePath(path2).getAbsolutePath();
+    assertThat(helper2.getReadableDatabase().getPath()).isEqualTo(expectedPath2);
+    helper1.close();
+    helper2.close();
+  }
+
+  @Test
+  public void testCloseMultipleDbs() {
+    TestOpenHelper helper2 =
+        new TestOpenHelper(ApplicationProvider.getApplicationContext(), "path2", null, 1);
+    SQLiteDatabase database1 = helper.getWritableDatabase();
+    SQLiteDatabase database2 = helper2.getWritableDatabase();
+    assertThat(database1.isOpen()).isTrue();
+    assertThat(database2.isOpen()).isTrue();
+    helper.close();
+    assertThat(database1.isOpen()).isFalse();
+    assertThat(database2.isOpen()).isTrue();
+    helper2.close();
+    assertThat(database2.isOpen()).isFalse();
+  }
+
+  @Test
+  public void testOpenMultipleDbsOnCreate() {
+    TestOpenHelper helper2 =
+        new TestOpenHelper(ApplicationProvider.getApplicationContext(), "path2", null, 1);
+    assertThat(helper.onCreateCalled).isFalse();
+    assertThat(helper2.onCreateCalled).isFalse();
+    helper.getWritableDatabase();
+    assertThat(helper.onCreateCalled).isTrue();
+    assertThat(helper2.onCreateCalled).isFalse();
+    helper2.getWritableDatabase();
+    assertThat(helper.onCreateCalled).isTrue();
+    assertThat(helper2.onCreateCalled).isTrue();
+    helper.close();
+    helper2.close();
+  }
+
+  private void setupTable(SQLiteDatabase db, String table) {
+    db.execSQL("CREATE TABLE " + table + " (" +
+        "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+        "testVal INTEGER DEFAULT 0" +
+        ");");
+  }
+
+  private void insertData(SQLiteDatabase db, String table, int[] values) {
+    for (int i : values) {
+      ContentValues cv = new ContentValues();
+      cv.put("testVal", i);
+      db.insert(table, null, cv);
+    }
+  }
+
+  private void verifyData(SQLiteDatabase db, String table, int expectedVals) {
+    try (Cursor cursor = db.query(table, null, null, null, null, null, null)) {
+      assertThat(cursor.getCount()).isEqualTo(expectedVals);
+    }
+  }
+
+  @Test
+  public void testMultipleDbsPreserveData() {
+    final String TABLE_NAME1 = "fart", TABLE_NAME2 = "fart2";
+    SQLiteDatabase db1 = helper.getWritableDatabase();
+    setupTable(db1, TABLE_NAME1);
+    insertData(db1, TABLE_NAME1, new int[]{1, 2});
+    TestOpenHelper helper2 =
+        new TestOpenHelper(ApplicationProvider.getApplicationContext(), "path2", null, 1);
+    SQLiteDatabase db2 = helper2.getWritableDatabase();
+    setupTable(db2, TABLE_NAME2);
+    insertData(db2, TABLE_NAME2, new int[]{4, 5, 6});
+    verifyData(db1, TABLE_NAME1, 2);
+    verifyData(db2, TABLE_NAME2, 3);
+    helper2.close();
+  }
+
+  @Test
+  public void testCloseOneDbKeepsDataForOther() {
+    final String TABLE_NAME1 = "fart", TABLE_NAME2 = "fart2";
+    TestOpenHelper helper2 =
+        new TestOpenHelper(ApplicationProvider.getApplicationContext(), "path2", null, 1);
+    SQLiteDatabase db1 = helper.getWritableDatabase();
+    SQLiteDatabase db2 = helper2.getWritableDatabase();
+    setupTable(db1, TABLE_NAME1);
+    setupTable(db2, TABLE_NAME2);
+    insertData(db1, TABLE_NAME1, new int[]{1, 2});
+    insertData(db2, TABLE_NAME2, new int[]{4, 5, 6});
+    verifyData(db1, TABLE_NAME1, 2);
+    verifyData(db2, TABLE_NAME2, 3);
+    db1.close();
+    verifyData(db2, TABLE_NAME2, 3);
+    db1 = helper.getWritableDatabase();
+    verifyData(db1, TABLE_NAME1, 2);
+    verifyData(db2, TABLE_NAME2, 3);
+    helper2.close();
+  }
+
+  @Test
+  public void testCreateAndDropTable() {
+    SQLiteDatabase database = helper.getWritableDatabase();
+    database.execSQL("CREATE TABLE foo(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT);");
+    database.execSQL("DROP TABLE IF EXISTS foo;");
+  }
+
+  @Test
+  public void testCloseThenOpen() {
+    final String TABLE_NAME1 = "fart";
+    SQLiteDatabase db1 = helper.getWritableDatabase();
+    setupTable(db1, TABLE_NAME1);
+    insertData(db1, TABLE_NAME1, new int[]{1, 2});
+    verifyData(db1, TABLE_NAME1, 2);
+    db1.close();
+    db1 = helper.getWritableDatabase();
+    assertThat(db1.isOpen()).isTrue();
+  }
+
+  private static void assertInitialDB(SQLiteDatabase database, TestOpenHelper helper) {
+    assertDatabaseOpened(database, helper);
+    assertThat(helper.onCreateCalled).isTrue();
+  }
+
+  private static void assertSubsequentDB(SQLiteDatabase database, TestOpenHelper helper) {
+    assertDatabaseOpened(database, helper);
+    assertThat(helper.onCreateCalled).isFalse();
+  }
+
+  private static void assertDatabaseOpened(SQLiteDatabase database, TestOpenHelper helper) {
+    assertThat(database).isNotNull();
+    assertThat(database.isOpen()).isTrue();
+    assertThat(helper.onOpenCalled).isTrue();
+    assertThat(helper.onUpgradeCalled).isFalse();
+  }
+
+  private static class TestOpenHelper extends SQLiteOpenHelper {
+    public boolean onCreateCalled;
+    public boolean onUpgradeCalled;
+    public boolean onOpenCalled;
+
+    public TestOpenHelper(Context context, String name, CursorFactory factory, int version) {
+      super(context, name, factory, version);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase database) {
+        onCreateCalled = true;
+      }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) {
+      onUpgradeCalled = true;
+    }
+
+    @Override
+    public void onOpen(SQLiteDatabase database) {
+      onOpenCalled = true;
+    }
+
+    @Override
+    public synchronized void close() {
+      onCreateCalled = false;
+      onUpgradeCalled = false;
+      onOpenCalled = false;
+
+      super.close();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/SQLiteQueryBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/SQLiteQueryBuilderTest.java
new file mode 100644
index 0000000..fe683b3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/SQLiteQueryBuilderTest.java
@@ -0,0 +1,97 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SQLiteQueryBuilderTest {
+
+  private static final String TABLE_NAME = "sqlBuilderTest";
+  private static final String COL_VALUE = "valueCol";
+  private static final String COL_GROUP = "groupCol";
+  
+  private SQLiteDatabase database;
+  private SQLiteQueryBuilder builder;
+
+  private long firstRecordId;
+
+  @Before
+  public void setUp() throws Exception {
+    database = SQLiteDatabase.create(null);
+
+    database.execSQL("create table " + TABLE_NAME + " ("
+        + COL_VALUE + " TEXT, "
+        + COL_GROUP + " INTEGER"
+        + ")");
+
+    ContentValues values = new ContentValues();
+    values.put(COL_VALUE, "record1");
+    values.put(COL_GROUP, 1);
+    firstRecordId = database.insert(TABLE_NAME, null, values);
+    assertThat(firstRecordId).isGreaterThan(0L);
+
+    values.clear();
+    values.put(COL_VALUE, "record2");
+    values.put(COL_GROUP, 1);
+    long secondRecordId = database.insert(TABLE_NAME, null, values);
+    assertThat(secondRecordId).isGreaterThan(0L);
+    assertThat(secondRecordId).isNotEqualTo(firstRecordId);
+
+    values.clear();
+    values.put(COL_VALUE, "won't be selected");
+    values.put(COL_GROUP, 2);
+    database.insert(TABLE_NAME, null, values);
+
+    builder = new SQLiteQueryBuilder();
+    builder.setTables(TABLE_NAME);
+    builder.appendWhere(COL_VALUE + " <> ");
+    builder.appendWhereEscapeString("won't be selected");
+  }
+
+  @After
+  public void tearDown() {
+    database.close();
+  }
+
+  @Test
+  public void shouldBeAbleToMakeQueries() {
+    Cursor cursor = builder.query(database, new String[] {"rowid"}, null, null, null, null, null);
+    assertThat(cursor.getCount()).isEqualTo(2);
+    cursor.close();
+  }
+
+  @Test
+  public void shouldBeAbleToMakeQueriesWithSelection() {
+    Cursor cursor =
+        builder.query(
+            database,
+            new String[] {"rowid"},
+            COL_VALUE + "=?",
+            new String[] {"record1"},
+            null,
+            null,
+            null);
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getLong(0)).isEqualTo(firstRecordId);
+    cursor.close();
+  }
+
+  @Test
+  public void shouldBeAbleToMakeQueriesWithGrouping() {
+    Cursor cursor =
+        builder.query(database, new String[] {"rowid"}, null, null, COL_GROUP, null, null);
+    assertThat(cursor.getCount()).isEqualTo(1);
+    cursor.close();
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/SQLiteStatementTest.java b/robolectric/src/test/java/org/robolectric/shadows/SQLiteStatementTest.java
new file mode 100644
index 0000000..987829b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/SQLiteStatementTest.java
@@ -0,0 +1,186 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDoneException;
+import android.database.sqlite.SQLiteStatement;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SQLiteStatementTest {
+  private SQLiteDatabase database;
+
+  @Before
+  public void setUp() throws Exception {
+    final File databasePath = ApplicationProvider.getApplicationContext().getDatabasePath("path");
+    databasePath.getParentFile().mkdirs();
+
+    database = SQLiteDatabase.openOrCreateDatabase(databasePath.getPath(), null);
+    SQLiteStatement createStatement =
+        database.compileStatement(
+            "CREATE TABLE `routine` (`id` INTEGER PRIMARY KEY AUTOINCREMENT , `name` VARCHAR ,"
+                + " `lastUsed` INTEGER DEFAULT 0 ,  UNIQUE (`name`)) ;");
+    createStatement.execute();
+
+    SQLiteStatement createStatement2 =
+        database.compileStatement(
+            "CREATE TABLE `countme` (`id` INTEGER PRIMARY KEY AUTOINCREMENT , `name` VARCHAR ,"
+                + " `lastUsed` INTEGER DEFAULT 0 ,  UNIQUE (`name`)) ;");
+    createStatement2.execute();
+  }
+
+  @After
+  public void tearDown() {
+    database.close();
+  }
+
+  @Test
+  public void testExecuteInsert() {
+    SQLiteStatement insertStatement =
+        database.compileStatement("INSERT INTO `routine` (`name` ,`lastUsed` ) VALUES (?,?)");
+    insertStatement.bindString(1, "Leg Press");
+    insertStatement.bindLong(2, 0);
+    long pkeyOne = insertStatement.executeInsert();
+    insertStatement.clearBindings();
+    insertStatement.bindString(1, "Bench Press");
+    insertStatement.bindLong(2, 1);
+    long pkeyTwo = insertStatement.executeInsert();
+
+    assertThat(pkeyOne).isEqualTo(1L);
+    assertThat(pkeyTwo).isEqualTo(2L);
+
+    Cursor dataCursor = database.rawQuery("SELECT COUNT(*) FROM `routine`", null);
+    assertThat(dataCursor.moveToFirst()).isTrue();
+    assertThat(dataCursor.getInt(0)).isEqualTo(2);
+    dataCursor.close();
+
+    dataCursor = database.rawQuery("SELECT `id`, `name` ,`lastUsed` FROM `routine`", null);
+    assertThat(dataCursor.moveToNext()).isTrue();
+    assertThat(dataCursor.getInt(0)).isEqualTo(1);
+    assertThat(dataCursor.getString(1)).isEqualTo("Leg Press");
+    assertThat(dataCursor.getInt(2)).isEqualTo(0);
+    assertThat(dataCursor.moveToNext()).isTrue();
+    assertThat(dataCursor.getLong(0)).isEqualTo(2L);
+    assertThat(dataCursor.getString(1)).isEqualTo("Bench Press");
+    assertThat(dataCursor.getInt(2)).isEqualTo(1);
+    dataCursor.close();
+  }
+
+  @Test
+  public void testExecuteInsertShouldCloseGeneratedKeysResultSet() {
+    // NOTE:
+    // As a side-effect we will get "database locked" exception
+    // on rollback if generatedKeys wasn't closed
+    //
+    // Don't know how suitable to use Mockito here, but
+    // it will be a little bit simpler to test ShadowSQLiteStatement
+    // if actualDBStatement will be mocked
+    database.beginTransaction();
+    try {
+      SQLiteStatement insertStatement =
+          database.compileStatement(
+              "INSERT INTO `routine` " + "(`name` ,`lastUsed`) VALUES ('test',0)");
+      try {
+        insertStatement.executeInsert();
+      } finally {
+        insertStatement.close();
+      }
+    } finally {
+      database.endTransaction();
+    }
+  }
+
+  @Test
+  public void testExecuteUpdateDelete() {
+
+    SQLiteStatement insertStatement =
+        database.compileStatement("INSERT INTO `routine` (`name`) VALUES (?)");
+    insertStatement.bindString(1, "Hand Press");
+    long pkeyOne = insertStatement.executeInsert();
+    assertThat(pkeyOne).isEqualTo(1);
+
+    SQLiteStatement updateStatement =
+        database.compileStatement("UPDATE `routine` SET `name`=? WHERE `id`=?");
+    updateStatement.bindString(1, "Head Press");
+    updateStatement.bindLong(2, pkeyOne);
+    assertThat(updateStatement.executeUpdateDelete()).isEqualTo(1);
+
+    Cursor dataCursor = database.rawQuery("SELECT `name` FROM `routine`", null);
+    assertThat(dataCursor.moveToNext()).isTrue();
+    assertThat(dataCursor.getString(0)).isEqualTo("Head Press");
+    dataCursor.close();
+  }
+
+  @Test
+  public void simpleQueryTest() {
+
+    SQLiteStatement stmt = database.compileStatement("SELECT count(*) FROM `countme`");
+    assertThat(stmt.simpleQueryForLong()).isEqualTo(0L);
+    assertThat(stmt.simpleQueryForString()).isEqualTo("0");
+
+    SQLiteStatement insertStatement =
+        database.compileStatement("INSERT INTO `countme` (`name` ,`lastUsed` ) VALUES (?,?)");
+    insertStatement.bindString(1, "Leg Press");
+    insertStatement.bindLong(2, 0);
+    insertStatement.executeInsert();
+    assertThat(stmt.simpleQueryForLong()).isEqualTo(1L);
+    assertThat(stmt.simpleQueryForString()).isEqualTo("1");
+    insertStatement.bindString(1, "Bench Press");
+    insertStatement.bindLong(2, 1);
+    insertStatement.executeInsert();
+    assertThat(stmt.simpleQueryForLong()).isEqualTo(2L);
+    assertThat(stmt.simpleQueryForString()).isEqualTo("2");
+  }
+
+  @Test
+  public void simpleQueryTestWithCommonTableExpression() {
+    try (SQLiteStatement statement =
+        database.compileStatement(
+            "WITH RECURSIVE\n"
+                + "  cnt(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM cnt WHERE x<100)\n"
+                + "SELECT COUNT(*) FROM cnt;")) {
+      assertThat(statement).isNotNull();
+      assertThat(statement.simpleQueryForLong()).isEqualTo(100);
+    }
+  }
+
+  @Test(expected = SQLiteDoneException.class)
+  public void simpleQueryForStringThrowsSQLiteDoneExceptionTest() {
+    // throw SQLiteDOneException if no rows returned.
+    SQLiteStatement stmt =
+        database.compileStatement("SELECT * FROM `countme` where `name`= 'cessationoftime'");
+
+    assertThat(stmt.simpleQueryForString()).isEqualTo("0");
+  }
+
+  @Test(expected = SQLiteDoneException.class)
+  public void simpleQueryForLongThrowsSQLiteDoneExceptionTest() {
+    // throw SQLiteDOneException if no rows returned.
+    SQLiteStatement stmt =
+        database.compileStatement("SELECT * FROM `countme` where `name`= 'cessationoftime'");
+    stmt.simpleQueryForLong();
+  }
+
+  @Test
+  public void testCloseShouldCloseUnderlyingPreparedStatement() {
+    SQLiteStatement insertStatement =
+        database.compileStatement("INSERT INTO `routine` (`name`) VALUES (?)");
+    insertStatement.bindString(1, "Hand Press");
+    insertStatement.close();
+    try {
+      insertStatement.executeInsert();
+      fail();
+    } catch (Exception e) {
+      assertThat(e).isInstanceOf(IllegalStateException.class);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsSpinnerAdapterViewBehaviorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsSpinnerAdapterViewBehaviorTest.java
new file mode 100644
index 0000000..082f723
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsSpinnerAdapterViewBehaviorTest.java
@@ -0,0 +1,14 @@
+package org.robolectric.shadows;
+
+import android.widget.AdapterView;
+import android.widget.Gallery;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAbsSpinnerAdapterViewBehaviorTest extends AdapterViewBehavior {
+  @Override public AdapterView createAdapterView() {
+    return new Gallery(ApplicationProvider.getApplicationContext());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsSpinnerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsSpinnerTest.java
new file mode 100644
index 0000000..ec4b985
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsSpinnerTest.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Spinner;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAbsSpinnerTest {
+  private Spinner spinner;
+  private ShadowAbsSpinner shadowSpinner;
+  private ArrayAdapter<String> arrayAdapter;
+
+  @Before
+  public void setUp() throws Exception {
+    Context context = ApplicationProvider.getApplicationContext();
+    spinner = new Spinner(context);
+    shadowSpinner = shadowOf(spinner);
+    String [] testItems = {"foo", "bar"};
+    arrayAdapter = new MyArrayAdapter(context, testItems);
+  }
+
+  @Test
+  public void checkSetAdapter() {
+    spinner.setAdapter(arrayAdapter);
+  }
+
+  @Test
+  public void getSelectedItemShouldReturnCorrectValue(){
+    spinner.setAdapter(arrayAdapter);
+    spinner.setSelection(0);
+    assertThat((String) spinner.getSelectedItem()).isEqualTo("foo");
+    assertThat((String) spinner.getSelectedItem()).isNotEqualTo("bar");
+
+    spinner.setSelection(1);
+    assertThat((String) spinner.getSelectedItem()).isEqualTo("bar");
+    assertThat((String) spinner.getSelectedItem()).isNotEqualTo("foo");
+  }
+
+  @Test
+  public void getSelectedItemShouldReturnNull_NoAdapterSet(){
+    assertThat(spinner.getSelectedItem()).isNull();
+  }
+
+  @Test
+  public void setSelectionWithAnimatedTransition() {
+    spinner.setAdapter(arrayAdapter);
+    spinner.setSelection(0, true);
+
+    assertThat((String) spinner.getSelectedItem()).isEqualTo("foo");
+    assertThat((String) spinner.getSelectedItem()).isNotEqualTo("bar");
+
+    assertThat(shadowSpinner.isAnimatedTransition()).isTrue();
+  }
+
+  private static class MyArrayAdapter extends ArrayAdapter<String> {
+    public MyArrayAdapter(Context context, String[] testItems) {
+      super(context, android.R.layout.simple_spinner_item, testItems);
+    }
+
+    @Override public View getView(int position, View convertView, ViewGroup parent) {
+      return new View(getContext());
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsoluteLayoutTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsoluteLayoutTest.java
new file mode 100644
index 0000000..d3101ad
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbsoluteLayoutTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.ViewGroup;
+import android.widget.AbsoluteLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAbsoluteLayoutTest {
+
+  @Test
+  public void getLayoutParams_shouldReturnAbsoluteLayoutParams() throws Exception {
+    ViewGroup.LayoutParams layoutParams =
+        new AbsoluteLayout(ApplicationProvider.getApplicationContext()) {
+          @Override
+          protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
+            return super.generateDefaultLayoutParams();
+          }
+        }.generateDefaultLayoutParams();
+
+    assertThat(layoutParams).isInstanceOf(AbsoluteLayout.LayoutParams.class);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAbstractCursorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbstractCursorTest.java
new file mode 100644
index 0000000..100f840
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAbstractCursorTest.java
@@ -0,0 +1,278 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.AbstractCursor;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAbstractCursorTest {
+
+  private TestCursor cursor;
+
+  @Before
+  public void setUp() throws Exception {
+    cursor = new TestCursor();
+  }
+
+  @After
+  public void tearDown() {
+    cursor.close();
+  }
+
+  @Test
+  public void testMoveToFirst() {
+    cursor.theTable.add("Foobar");
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testMoveToFirstEmptyList() {
+    assertThat(cursor.moveToFirst()).isFalse();
+    assertThat(cursor.getCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void testMoveToLast() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+
+    assertThat(cursor.moveToLast()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void testMoveToLastEmptyList() {
+    assertThat(cursor.moveToLast()).isFalse();
+    assertThat(cursor.getCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetPosition() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(2);
+    assertThat(cursor.getPosition()).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetPositionSingleEntry() {
+    cursor.theTable.add("Foobar");
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.getPosition()).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetPositionEmptyList() {
+    assertThat(cursor.moveToFirst()).isFalse();
+    assertThat(cursor.getCount()).isEqualTo(0);
+    assertThat(cursor.getPosition()).isEqualTo(0);
+  }
+
+  @Test
+  public void testMoveToNext() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(2);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+  }
+
+  @Test
+  public void testAttemptToMovePastEnd() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(2);
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+    assertThat(cursor.isLast()).isTrue();
+    assertThat(cursor.moveToNext()).isFalse();
+    assertThat(cursor.isAfterLast()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(2);
+  }
+
+  @Test
+  public void testAttemptToMovePastSingleEntry() {
+    cursor.theTable.add("Foobar");
+
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isFalse();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+  }
+
+  @Test
+  public void testAttemptToMovePastEmptyList() {
+    assertThat(cursor.moveToFirst()).isFalse();
+    assertThat(cursor.getCount()).isEqualTo(0);
+    assertThat(cursor.moveToNext()).isFalse();
+    assertThat(cursor.getPosition()).isEqualTo(0);
+  }
+
+  @Test
+  public void testMoveToPrevious() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.moveToNext()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(1);
+    assertThat(cursor.moveToPrevious()).isTrue();
+    assertThat(cursor.getPosition()).isEqualTo(0);
+  }
+
+  @Test
+  public void testAttemptToMovePastStart() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.moveToPrevious()).isFalse();
+    assertThat(cursor.getPosition()).isEqualTo(-1);
+  }
+
+  @Test
+  public void testIsFirst() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.isFirst()).isTrue();
+    cursor.moveToNext();
+    assertThat(cursor.isFirst()).isFalse();
+    cursor.moveToFirst();
+    cursor.moveToPrevious();
+    assertThat(cursor.isFirst()).isFalse();
+  }
+
+  @Test
+  public void testIsLast() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+    assertThat(cursor.moveToFirst()).isTrue();
+    cursor.moveToNext();
+    assertThat(cursor.isLast()).isTrue();
+    cursor.moveToPrevious();
+    assertThat(cursor.isLast()).isFalse();
+    cursor.moveToFirst();
+    cursor.moveToNext();
+    assertThat(cursor.isLast()).isTrue();
+  }
+
+  @Test
+  public void testIsBeforeFirst() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+    assertThat(cursor.moveToFirst()).isTrue();
+    cursor.moveToNext();
+    assertThat(cursor.isLast()).isTrue();
+    cursor.moveToPrevious();
+    assertThat(cursor.isLast()).isFalse();
+    cursor.moveToPrevious();
+    assertThat(cursor.isFirst()).isFalse();
+    cursor.moveToPrevious();
+    assertThat(cursor.isBeforeFirst()).isTrue();
+  }
+
+  @Test
+  public void testIsAfterLast() {
+    cursor.theTable.add("Foobar");
+    cursor.theTable.add("Bletch");
+    assertThat(cursor.moveToFirst()).isTrue();
+    cursor.moveToNext();
+    assertThat(cursor.isLast()).isTrue();
+    cursor.moveToNext();
+    assertThat(cursor.isAfterLast()).isTrue();
+    cursor.moveToPrevious();
+    assertThat(cursor.isLast()).isTrue();
+    cursor.moveToPrevious();
+    assertThat(cursor.isLast()).isFalse();
+    cursor.moveToFirst();
+    cursor.moveToNext();
+    assertThat(cursor.isAfterLast()).isFalse();
+    cursor.moveToNext();
+    assertThat(cursor.isAfterLast()).isTrue();
+  }
+
+  @Test
+  public void testGetNotificationUri() {
+    Uri uri = Uri.parse("content://foo.com");
+    ShadowAbstractCursor shadow = Shadows.shadowOf(cursor);
+    assertThat(shadow.getNotificationUri_Compatibility()).isNull();
+    cursor.setNotificationUri(
+        ApplicationProvider.getApplicationContext().getContentResolver(), uri);
+    assertThat(shadow.getNotificationUri_Compatibility()).isEqualTo(uri);
+  }
+
+  @Test
+  public void testIsClosedWhenAfterCallingClose() {
+    assertThat(cursor.isClosed()).isFalse();
+    cursor.close();
+    assertThat(cursor.isClosed()).isTrue();
+  }
+
+  private static class TestCursor extends AbstractCursor {
+
+    public List<Object> theTable = new ArrayList<>();
+
+    @Override
+    public int getCount() {
+      return theTable.size();
+    }
+
+    @Override
+    public String[] getColumnNames() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public double getDouble(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public float getFloat(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getInt(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getLong(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public short getShort(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getString(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean isNull(int columnIndex) {
+      throw new UnsupportedOperationException();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityButtonControllerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityButtonControllerTest.java
new file mode 100644
index 0000000..43d9cc1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityButtonControllerTest.java
@@ -0,0 +1,68 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.accessibilityservice.AccessibilityButtonController;
+import android.accessibilityservice.AccessibilityService;
+import android.view.accessibility.AccessibilityEvent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowAccessibilityButtonController}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = P)
+public class ShadowAccessibilityButtonControllerTest {
+
+  private AccessibilityButtonController accessibilityButtonController;
+
+  private boolean isClicked;
+
+  @Before
+  public void setUp() {
+    MyService service = Robolectric.setupService(MyService.class);
+    accessibilityButtonController = service.getAccessibilityButtonController();
+  }
+
+  @Test
+  public void shouldAccessibilityButtonClickedTriggered() {
+    createAndRegisterAccessibilityButtonCallback();
+    shadowOf(accessibilityButtonController).performAccessibilityButtonClick();
+    assertThat(isClicked).isTrue();
+  }
+
+  private void createAndRegisterAccessibilityButtonCallback() {
+    isClicked = false;
+    AccessibilityButtonController.AccessibilityButtonCallback accessibilityButtonCallback =
+        new AccessibilityButtonController.AccessibilityButtonCallback() {
+          @Override
+          public void onClicked(AccessibilityButtonController controller) {
+            isClicked = true;
+          }
+        };
+    accessibilityButtonController.registerAccessibilityButtonCallback(accessibilityButtonCallback);
+  }
+
+  /** AccessibilityService for {@link ShadowAccessibilityButtonControllerTest} */
+  public static class MyService extends AccessibilityService {
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+    }
+
+    @Override
+    public void onAccessibilityEvent(AccessibilityEvent arg0) {
+      // Do nothing
+    }
+
+    @Override
+    public void onInterrupt() {
+      // Do nothing
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityEventTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityEventTest.java
new file mode 100644
index 0000000..a975e96
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityEventTest.java
@@ -0,0 +1,109 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Notification;
+import android.os.Parcel;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccessibilityEventTest {
+
+  private AccessibilityEvent event;
+
+  @Before
+  public void setUp() {
+    event = AccessibilityEvent.obtain();
+  }
+
+  @Test
+  public void shouldRecordParcelables() {
+    final Notification notification = new Notification();
+    event.setParcelableData(notification);
+    AccessibilityEvent anotherEvent = AccessibilityEvent.obtain(event);
+    assertThat(anotherEvent.getParcelableData()).isInstanceOf(Notification.class);
+    assertThat(anotherEvent.getParcelableData()).isEqualTo(notification);
+    anotherEvent.recycle();
+  }
+
+  @Test
+  public void shouldBeEqualToClonedEvent() {
+    event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT);
+    AccessibilityEvent newEvent = AccessibilityEvent.obtain(event);
+    assertThat(event.getEventType()).isEqualTo(newEvent.getEventType());
+    assertThat(event.isEnabled()).isEqualTo(newEvent.isEnabled());
+    assertThat(nullOrString(event.getContentDescription()))
+        .isEqualTo(nullOrString(newEvent.getContentDescription()));
+    assertThat(nullOrString(event.getPackageName()))
+        .isEqualTo(nullOrString(newEvent.getPackageName()));
+    assertThat(nullOrString(event.getClassName())).isEqualTo(nullOrString(newEvent.getClassName()));
+    assertThat(event.getParcelableData()).isEqualTo(newEvent.getParcelableData());
+
+    newEvent.recycle();
+  }
+
+  @Test
+  public void shouldWriteAndReadFromParcelCorrectly() {
+    Parcel p = Parcel.obtain();
+    event.setContentDescription("test");
+    event.writeToParcel(p, 0);
+    p.setDataPosition(0);
+    AccessibilityEvent anotherEvent = AccessibilityEvent.CREATOR.createFromParcel(p);
+    assertThat(anotherEvent.getEventType()).isEqualTo(event.getEventType());
+    assertThat(anotherEvent.isEnabled()).isEqualTo(event.isEnabled());
+    assertThat(nullOrString(anotherEvent.getContentDescription()))
+        .isEqualTo(nullOrString(event.getContentDescription()));
+    assertThat(nullOrString(anotherEvent.getPackageName()))
+        .isEqualTo(nullOrString(event.getPackageName()));
+    assertThat(nullOrString(anotherEvent.getClassName()))
+        .isEqualTo(nullOrString(event.getClassName()));
+    assertThat(anotherEvent.getParcelableData()).isEqualTo(event.getParcelableData());
+    anotherEvent.setContentDescription(null);
+    anotherEvent.recycle();
+  }
+
+  @Test
+  public void shouldHaveCurrentSourceId() {
+    TextView rootView = new TextView(ApplicationProvider.getApplicationContext());
+    event.setSource(rootView);
+    assertThat(shadowOf(event).getSourceRoot()).isEqualTo(rootView);
+    assertThat(shadowOf(event).getVirtualDescendantId())
+        .isEqualTo(ShadowAccessibilityRecord.NO_VIRTUAL_ID);
+    event.setSource(rootView, 1);
+    assertThat(shadowOf(event).getVirtualDescendantId()).isEqualTo(1);
+  }
+
+  @Test
+  public void setSourceNode() {
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
+    shadowOf(event).setSourceNode(node);
+    assertThat(event.getSource()).isEqualTo(node);
+    node.recycle();
+  }
+
+  @Test
+  public void setWindowId() {
+    int id = 2;
+    shadowOf(event).setWindowId(id);
+    assertThat(event.getWindowId()).isEqualTo(id);
+  }
+
+  @After
+  public void tearDown() {
+    event.recycle();
+  }
+
+  /** Some CharSequence objects are null, so we need a null check wrapper */
+  private String nullOrString(CharSequence charSequence) {
+    return charSequence == null ? null : charSequence.toString();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityManagerTest.java
new file mode 100644
index 0000000..eff1ef7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityManagerTest.java
@@ -0,0 +1,226 @@
+package org.robolectric.shadows;
+
+import static android.content.Context.ACCESSIBILITY_SERVICE;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.content.Context;
+import android.content.pm.ServiceInfo;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccessibilityManagerTest {
+
+  private AccessibilityManager accessibilityManager;
+
+  @Before
+  public void setUp() throws Exception {
+    accessibilityManager =
+        (AccessibilityManager)
+            ApplicationProvider.getApplicationContext().getSystemService(ACCESSIBILITY_SERVICE);
+  }
+
+  @Test
+  public void shouldReturnTrueWhenEnabled() {
+    shadowOf(accessibilityManager).setEnabled(true);
+    assertThat(accessibilityManager.isEnabled()).isTrue();
+    assertThat(getAccessibilityManagerInstance().isEnabled()).isTrue();
+  }
+
+  // Emulates Android framework behavior, e.g.,
+  // AccessibilityManager.getInstance(context).isEnabled().
+  private static AccessibilityManager getAccessibilityManagerInstance() {
+    return ReflectionHelpers.callStaticMethod(
+        AccessibilityManager.class,
+        "getInstance",
+        ReflectionHelpers.ClassParameter.from(
+            Context.class, ApplicationProvider.getApplicationContext()));
+  }
+
+  @Test
+  public void shouldReturnTrueForTouchExplorationWhenEnabled() {
+    shadowOf(accessibilityManager).setTouchExplorationEnabled(true);
+    assertThat(accessibilityManager.isTouchExplorationEnabled()).isTrue();
+  }
+
+  @Test
+  public void shouldReturnExpectedEnabledServiceList() {
+    List<AccessibilityServiceInfo> expected =
+        new ArrayList<>(Collections.singletonList(new AccessibilityServiceInfo()));
+    shadowOf(accessibilityManager).setEnabledAccessibilityServiceList(expected);
+    assertThat(accessibilityManager.getEnabledAccessibilityServiceList(0)).isEqualTo(expected);
+  }
+
+  @Test
+  public void shouldReturnExpectedInstalledServiceList() {
+    List<AccessibilityServiceInfo> expected =
+        new ArrayList<>(Collections.singletonList(new AccessibilityServiceInfo()));
+    shadowOf(accessibilityManager).setInstalledAccessibilityServiceList(expected);
+    assertThat(accessibilityManager.getInstalledAccessibilityServiceList()).isEqualTo(expected);
+  }
+
+  @Test
+  public void shouldReturnExpectedAccessibilityServiceList() {
+    List<ServiceInfo> expected = new ArrayList<>(Collections.singletonList(new ServiceInfo()));
+    shadowOf(accessibilityManager).setAccessibilityServiceList(expected);
+    assertThat(accessibilityManager.getAccessibilityServiceList()).isEqualTo(expected);
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void isAccessibilityButtonSupported() {
+    assertThat(AccessibilityManager.isAccessibilityButtonSupported()).isTrue();
+
+    ShadowAccessibilityManager.setAccessibilityButtonSupported(false);
+    assertThat(AccessibilityManager.isAccessibilityButtonSupported()).isFalse();
+
+    ShadowAccessibilityManager.setAccessibilityButtonSupported(true);
+    assertThat(AccessibilityManager.isAccessibilityButtonSupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void performAccessibilityShortcut_shouldEnableAccessibilityAndTouchExploration() {
+    accessibilityManager.performAccessibilityShortcut();
+
+    assertThat(accessibilityManager.isEnabled()).isTrue();
+    assertThat(accessibilityManager.isTouchExplorationEnabled()).isTrue();
+  }
+
+  @Test
+  public void getSentAccessibilityEvents_returnsEmptyInitially() {
+    assertThat(shadowOf(accessibilityManager).getSentAccessibilityEvents()).isEmpty();
+  }
+
+  @Test
+  public void getSentAccessibilityEvents_returnsAllSentAccessibilityEventsInOrder() {
+    AccessibilityEvent event1 = AccessibilityEvent.obtain();
+    event1.setEventType(AccessibilityEvent.TYPE_VIEW_CLICKED);
+
+    AccessibilityEvent event2 = AccessibilityEvent.obtain();
+    event2.setEventType(AccessibilityEvent.TYPE_VIEW_FOCUSED);
+
+    AccessibilityEvent event3 = AccessibilityEvent.obtain();
+    event3.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT);
+
+    shadowOf(accessibilityManager).setEnabled(true);
+    accessibilityManager.sendAccessibilityEvent(event1);
+    accessibilityManager.sendAccessibilityEvent(event2);
+    accessibilityManager.sendAccessibilityEvent(event3);
+
+    assertThat(shadowOf(accessibilityManager).getSentAccessibilityEvents())
+        .containsExactly(event1, event2, event3)
+        .inOrder();
+  }
+
+  @Test
+  public void addAccessibilityStateChangeListener_shouldAddListener() {
+    TestAccessibilityStateChangeListener listener1 = new TestAccessibilityStateChangeListener();
+    TestAccessibilityStateChangeListener listener2 = new TestAccessibilityStateChangeListener();
+
+    accessibilityManager.addAccessibilityStateChangeListener(listener1);
+    accessibilityManager.addAccessibilityStateChangeListener(listener2);
+
+    shadowOf(accessibilityManager).setEnabled(true);
+
+    assertThat(listener1.isEnabled()).isTrue();
+    assertThat(listener2.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void removeAccessibilityStateChangeListener_shouldRemoveListeners() {
+    // Add two different callbacks.
+    TestAccessibilityStateChangeListener listener1 = new TestAccessibilityStateChangeListener();
+    TestAccessibilityStateChangeListener listener2 = new TestAccessibilityStateChangeListener();
+    accessibilityManager.addAccessibilityStateChangeListener(listener1);
+    accessibilityManager.addAccessibilityStateChangeListener(listener2);
+
+    shadowOf(accessibilityManager).setEnabled(true);
+
+    assertThat(listener1.isEnabled()).isTrue();
+    assertThat(listener2.isEnabled()).isTrue();
+    // Remove one at the time.
+    accessibilityManager.removeAccessibilityStateChangeListener(listener2);
+
+    shadowOf(accessibilityManager).setEnabled(false);
+
+    assertThat(listener1.isEnabled()).isFalse();
+    assertThat(listener2.isEnabled()).isTrue();
+
+    accessibilityManager.removeAccessibilityStateChangeListener(listener1);
+
+    shadowOf(accessibilityManager).setEnabled(true);
+
+    assertThat(listener1.isEnabled()).isFalse();
+    assertThat(listener2.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void removeAccessibilityStateChangeListener_returnsTrueIfRemoved() {
+    TestAccessibilityStateChangeListener listener = new TestAccessibilityStateChangeListener();
+    accessibilityManager.addAccessibilityStateChangeListener(listener);
+
+    assertThat(accessibilityManager.removeAccessibilityStateChangeListener(listener)).isTrue();
+  }
+
+  @Test
+  public void removeAccessibilityStateChangeListener_returnsFalseIfNotRegistered() {
+    assertThat(
+            accessibilityManager.removeAccessibilityStateChangeListener(
+                new TestAccessibilityStateChangeListener()))
+        .isFalse();
+    assertThat(accessibilityManager.removeAccessibilityStateChangeListener(null)).isFalse();
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void setTouchExplorationEnabled_invokesCallbacks() {
+    AtomicBoolean enabled = new AtomicBoolean(false);
+    accessibilityManager.addTouchExplorationStateChangeListener(val -> enabled.set(val));
+    shadowOf(accessibilityManager).setTouchExplorationEnabled(true);
+    assertThat(enabled.get()).isEqualTo(true);
+    shadowOf(accessibilityManager).setTouchExplorationEnabled(false);
+    assertThat(enabled.get()).isEqualTo(false);
+  }
+
+  private static class TestAccessibilityStateChangeListener
+      implements AccessibilityManager.AccessibilityStateChangeListener {
+
+    private boolean enabled = false;
+
+    @Override
+    public void onAccessibilityStateChanged(boolean enabled) {
+      this.enabled = enabled;
+    }
+
+    public boolean isEnabled() {
+      return enabled;
+    }
+  }
+
+  @Test
+  public void getAccessibilityServiceList_doesNotNPE() {
+    assertThat(accessibilityManager.getAccessibilityServiceList()).isEmpty();
+    assertThat(accessibilityManager.getInstalledAccessibilityServiceList()).isEmpty();
+    assertThat(
+            accessibilityManager.getEnabledAccessibilityServiceList(
+                AccessibilityServiceInfo.FEEDBACK_SPOKEN))
+        .isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityNodeInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityNodeInfoTest.java
new file mode 100644
index 0000000..4ac1f2e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityNodeInfoTest.java
@@ -0,0 +1,283 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.Rect;
+import android.os.Parcel;
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
+import android.view.accessibility.AccessibilityWindowInfo;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccessibilityNodeInfoTest {
+
+  private AccessibilityNodeInfo node;
+
+  private ShadowAccessibilityNodeInfo shadow;
+
+  @Before
+  public void setUp() {
+    ShadowAccessibilityNodeInfo.resetObtainedInstances();
+    assertThat(ShadowAccessibilityNodeInfo.areThereUnrecycledNodes(true)).isEqualTo(false);
+    node = AccessibilityNodeInfo.obtain();
+  }
+
+  @Test
+  public void shouldHaveObtainedNode() {
+    assertThat(ShadowAccessibilityNodeInfo.areThereUnrecycledNodes(false)).isEqualTo(true);
+  }
+
+  @Test
+  public void shouldHaveZeroBounds() {
+    Rect outBounds = new Rect();
+    node.getBoundsInParent(outBounds);
+    assertThat(outBounds.left).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldHaveClonedCorrectly() {
+    node.setAccessibilityFocused(true);
+    node.setBoundsInParent(new Rect(0, 0, 100, 100));
+    node.setContentDescription("test");
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      node.setTextEntryKey(true);
+    }
+    AccessibilityNodeInfo anotherNode = AccessibilityNodeInfo.obtain(node);
+    assertThat(anotherNode).isEqualTo(node);
+    assertThat(anotherNode.getContentDescription().toString()).isEqualTo("test");
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      assertThat(anotherNode.isTextEntryKey()).isTrue();
+    }
+  }
+
+  @Test
+  public void shouldWriteAndReadFromParcelCorrectly() {
+    Parcel p = Parcel.obtain();
+    node.setContentDescription("test");
+    node.writeToParcel(p, 0);
+    p.setDataPosition(0);
+    AccessibilityNodeInfo anotherNode = AccessibilityNodeInfo.CREATOR.createFromParcel(p);
+    assertThat(node).isEqualTo(anotherNode);
+    node.setContentDescription(null);
+  }
+
+  @Test
+  public void shouldNotHaveInfiniteLoopWithSameLoopedChildren() {
+    node = AccessibilityNodeInfo.obtain();
+    AccessibilityNodeInfo child = AccessibilityNodeInfo.obtain();
+    shadowOf(node).addChild(child);
+    shadowOf(child).addChild(node);
+    AccessibilityNodeInfo anotherNode = AccessibilityNodeInfo.obtain(node);
+    assertThat(node).isEqualTo(anotherNode);
+  }
+
+  @Test
+  public void shouldNotHaveInfiniteLoopWithDifferentLoopedChildren() {
+    node = AccessibilityNodeInfo.obtain();
+    shadow = shadowOf(node);
+    AccessibilityNodeInfo child1 = AccessibilityNodeInfo.obtain();
+    shadow.addChild(child1);
+    ShadowAccessibilityNodeInfo child1Shadow = shadowOf(child1);
+    child1Shadow.addChild(node);
+    AccessibilityNodeInfo anotherNode = ShadowAccessibilityNodeInfo.obtain();
+    AccessibilityNodeInfo child2 = ShadowAccessibilityNodeInfo.obtain();
+    child2.setText("test");
+    ShadowAccessibilityNodeInfo child2Shadow = shadowOf(child2);
+    ShadowAccessibilityNodeInfo anotherNodeShadow = shadowOf(anotherNode);
+    anotherNodeShadow.addChild(child2);
+    child2Shadow.addChild(anotherNode);
+    assertThat(node).isNotEqualTo(anotherNode);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldRecordFlagsProperly() {
+    node = AccessibilityNodeInfo.obtain();
+    node.setClickable(false);
+    shadow = shadowOf(node);
+    shadow.setPasteable(false);
+    assertThat(node.isClickable()).isEqualTo(false);
+    assertThat(shadow.isPasteable()).isEqualTo(false);
+    node.setText("Test");
+    shadow.setTextSelectionSetable(true);
+    node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
+    node.setTextSelection(0, 1);
+    assertThat(node.getActions()).isEqualTo(AccessibilityNodeInfo.ACTION_SET_SELECTION);
+    assertThat(node.getTextSelectionStart()).isEqualTo(0);
+    assertThat(node.getTextSelectionEnd()).isEqualTo(1);
+    AccessibilityWindowInfo window = ShadowAccessibilityWindowInfo.obtain();
+    shadow.setAccessibilityWindowInfo(window);
+    assertThat(node.getWindow()).isEqualTo(window);
+    shadow.setAccessibilityWindowInfo(null);
+    // Remove action was added in API 21
+    node.removeAction(AccessibilityAction.ACTION_SET_SELECTION);
+    shadow.setPasteable(true);
+    shadow.setTextSelectionSetable(false);
+    node.addAction(AccessibilityNodeInfo.ACTION_PASTE);
+    assertThat(node.getActions()).isEqualTo(AccessibilityNodeInfo.ACTION_PASTE);
+    node.setClickable(true);
+    assertThat(node.isClickable()).isEqualTo(true);
+    node.setClickable(false);
+    shadow.setPasteable(false);
+    node.removeAction(AccessibilityNodeInfo.ACTION_PASTE);
+    node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+    assertThat(node.getActions()).isEqualTo(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+    node.removeAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+  }
+
+  @Test
+  public void shouldRecordActionsPerformed() {
+    node.setClickable(true);
+    node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+    shadow = shadowOf(node);
+    shadow.setOnPerformActionListener(
+        (action, arguments) -> action == AccessibilityNodeInfo.ACTION_CLICK);
+
+    boolean clickResult = node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
+    assertThat(clickResult).isEqualTo(true);
+    assertThat(shadow.getPerformedActions().isEmpty()).isEqualTo(false);
+    assertThat(shadow.getPerformedActions().get(0)).isEqualTo(AccessibilityNodeInfo.ACTION_CLICK);
+    boolean longClickResult = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
+    assertThat(longClickResult).isEqualTo(false);
+    assertThat(shadow.getPerformedActions().size()).isEqualTo(2);
+    assertThat(shadow.getPerformedActions().get(1))
+        .isEqualTo(AccessibilityNodeInfo.ACTION_LONG_CLICK);
+  }
+
+  @Test
+  public void equalsTest_unrelatedNodesAreUnequal() {
+    AccessibilityNodeInfo nodeA = AccessibilityNodeInfo.obtain();
+    AccessibilityNodeInfo nodeB = AccessibilityNodeInfo.obtain();
+    nodeA.setText("test");
+    nodeB.setText("test");
+
+    assertThat(nodeA).isNotEqualTo(nodeB);
+  }
+
+  @Test
+  public void equalsTest_nodesFromTheSameViewAreEqual() {
+    View view = new View(ApplicationProvider.getApplicationContext());
+    AccessibilityNodeInfo nodeA = AccessibilityNodeInfo.obtain(view);
+    AccessibilityNodeInfo nodeB = AccessibilityNodeInfo.obtain(view);
+    nodeA.setText("tomato");
+    nodeB.setText("tomatoe");
+
+    assertThat(nodeA).isEqualTo(nodeB);
+  }
+
+  @Test
+  public void equalsTest_nodesFromDifferentViewsAreNotEqual() {
+    View viewA = new View(ApplicationProvider.getApplicationContext());
+    View viewB = new View(ApplicationProvider.getApplicationContext());
+    AccessibilityNodeInfo nodeA = AccessibilityNodeInfo.obtain(viewA);
+    AccessibilityNodeInfo nodeB = AccessibilityNodeInfo.obtain(viewB);
+    nodeA.setText("test");
+    nodeB.setText("test");
+
+    assertThat(nodeA).isNotEqualTo(nodeB);
+  }
+
+  @Test
+  public void equalsTest_nodeIsEqualToItsClone_evenWhenModified() {
+    node = AccessibilityNodeInfo.obtain();
+    AccessibilityNodeInfo clone = AccessibilityNodeInfo.obtain(node);
+    clone.setText("test");
+
+    assertThat(node).isEqualTo(clone);
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void shouldCloneExtrasCorrectly() {
+    node.getExtras().putString("key", "value");
+
+    AccessibilityNodeInfo nodeCopy = AccessibilityNodeInfo.obtain(node);
+
+    assertThat(nodeCopy.getExtras().getString("key")).isEqualTo("value");
+  }
+
+  @Config(minSdk = N)
+  @Test
+  public void shouldClonePreserveImportance() {
+    node.setImportantForAccessibility(true);
+
+    AccessibilityNodeInfo clone = AccessibilityNodeInfo.obtain(node);
+
+    assertThat(clone.isImportantForAccessibility()).isTrue();
+  }
+
+  @Config(minSdk = O)
+  @Test
+  public void clone_preservesHintText() {
+    String hintText = "tooltip hint";
+    node.setHintText(hintText);
+
+    AccessibilityNodeInfo clone = AccessibilityNodeInfo.obtain(node);
+
+    assertThat(clone.getHintText().toString()).isEqualTo(hintText);
+  }
+
+  @Config(minSdk = P)
+  @Test
+  public void clone_preservesTooltipText() {
+    String tooltipText = "tooltip text";
+    node.setTooltipText(tooltipText);
+
+    AccessibilityNodeInfo clone = AccessibilityNodeInfo.obtain(node);
+
+    assertThat(clone.getTooltipText().toString()).isEqualTo(tooltipText);
+  }
+
+  @Test
+  public void testGetBoundsInScreen() {
+    AccessibilityNodeInfo root = AccessibilityNodeInfo.obtain();
+    Rect expected = new Rect(0, 0, 100, 100);
+    root.setBoundsInScreen(expected);
+    Rect actual = new Rect();
+    root.getBoundsInScreen(actual);
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void testIsHeading() {
+    AccessibilityNodeInfo root = AccessibilityNodeInfo.obtain();
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
+    shadowOf(root).addChild(node);
+    node.setHeading(true);
+    assertThat(node.isHeading()).isTrue();
+    assertThat(root.getChild(0).isHeading()).isTrue();
+  }
+
+  @Test
+  public void testConstructor() {
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
+    assertThat(node.getWindowId()).isEqualTo(AccessibilityWindowInfo.UNDEFINED_WINDOW_ID);
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      // This constant does not exists pre-O.
+      assertThat(node.getSourceNodeId()).isEqualTo(AccessibilityNodeInfo.UNDEFINED_NODE_ID);
+    }
+  }
+
+  @After
+  public void tearDown() {
+    ShadowAccessibilityNodeInfo.resetObtainedInstances();
+    assertThat(ShadowAccessibilityNodeInfo.areThereUnrecycledNodes(true)).isEqualTo(false);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityRecordTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityRecordTest.java
new file mode 100644
index 0000000..809170b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityRecordTest.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import static android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for ShadowAccessibilityRecord. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccessibilityRecordTest {
+
+  @Test
+  public void init_shouldCopyBothRealAndShadowFields() {
+    AccessibilityNodeInfo source = AccessibilityNodeInfo.obtain();
+    source.setClassName("fakeClassName");
+
+    AccessibilityEvent event = AccessibilityEvent.obtain(TYPE_WINDOW_CONTENT_CHANGED);
+    shadowOf(event).setSourceNode(source);
+    final int fromIndex = 5;
+    event.setFromIndex(fromIndex);
+
+    AccessibilityEvent eventCopy = AccessibilityEvent.obtain(event);
+    assertThat(eventCopy.getSource()).isEqualTo(source);
+    assertThat(eventCopy.getFromIndex()).isEqualTo(fromIndex);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityServiceTest.java
new file mode 100644
index 0000000..9cabcdf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityServiceTest.java
@@ -0,0 +1,289 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityService.GestureResultCallback;
+import android.accessibilityservice.AccessibilityService.ScreenshotResult;
+import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback;
+import android.accessibilityservice.GestureDescription;
+import android.accessibilityservice.GestureDescription.StrokeDescription;
+import android.graphics.Path;
+import android.view.Display;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import androidx.annotation.NonNull;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccessibilityServiceTest {
+  private MyService service;
+  private ShadowAccessibilityService shadow;
+
+  @Before
+  public void setUp() {
+    service = Robolectric.setupService(MyService.class);
+    shadow = shadowOf(service);
+  }
+
+  /** After performing a global action, it should be recorded. */
+  @Test
+  public void shouldRecordPerformedAction() {
+    service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK);
+    assertThat(shadow.getGlobalActionsPerformed().size()).isEqualTo(1);
+    assertThat(shadow.getGlobalActionsPerformed().get(0)).isEqualTo(1);
+  }
+
+  /**
+   * The AccessibilityService shadow should return an empty list if no window data is provided.
+   */
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldReturnEmptyListIfNoWindowDataProvided() {
+    assertThat(service.getWindows()).isEmpty();
+  }
+
+  /**
+   * The AccessibilityService shadow should return an empty list if null window data is provided.
+   */
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldReturnEmptyListIfNullWindowDataProvided() {
+    shadow.setWindows(null);
+    assertThat(service.getWindows()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getGesturesDispatched_returnsNothingInitially() {
+    assertThat(shadow.getGesturesDispatched()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getGesturesDispatched_returnsFirstGestureDescription() {
+    GestureDescription gestureDescription = createTestGesture();
+    GestureResultCallback gestureResultCallback = createEmptyGestureResultCallback();
+
+    service.dispatchGesture(gestureDescription, gestureResultCallback, /*handler=*/ null);
+
+    assertThat(shadow.getGesturesDispatched().get(0).description())
+        .isSameInstanceAs(gestureDescription);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getGesturesDispatched_returnsFirstGestureResultCallback() {
+    GestureDescription gestureDescription = createTestGesture();
+    GestureResultCallback gestureResultCallback = createEmptyGestureResultCallback();
+
+    service.dispatchGesture(gestureDescription, gestureResultCallback, /*handler=*/ null);
+
+    assertThat(shadow.getGesturesDispatched().get(0).callback())
+        .isSameInstanceAs(gestureResultCallback);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setCanDispatchGestures_false_causesDispatchGestureToReturnFalse() {
+    GestureDescription gestureDescription = createTestGesture();
+    GestureResultCallback gestureResultCallback = createEmptyGestureResultCallback();
+
+    shadow.setCanDispatchGestures(false);
+
+    assertThat(
+            service.dispatchGesture(gestureDescription, gestureResultCallback, /*handler=*/ null))
+        .isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setCanDispatchGestures_false_stopsRecordingDispatchedGestures() {
+    GestureDescription gestureDescription = createTestGesture();
+    GestureResultCallback gestureResultCallback = createEmptyGestureResultCallback();
+
+    shadow.setCanDispatchGestures(false);
+    service.dispatchGesture(gestureDescription, gestureResultCallback, /*handler=*/ null);
+
+    assertThat(shadow.getGesturesDispatched()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setCanDispatchGestures_true_causesDispatchGestureToReturnTrue() {
+    GestureDescription gestureDescription = createTestGesture();
+    GestureResultCallback gestureResultCallback = createEmptyGestureResultCallback();
+    shadow.setCanDispatchGestures(false);
+
+    shadow.setCanDispatchGestures(true);
+
+    assertThat(
+            service.dispatchGesture(gestureDescription, gestureResultCallback, /*handler=*/ null))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void takeScreenshot_byDefault_immediatelyReturnsSuccessfully() {
+    AtomicReference<ScreenshotResult> screenshotResultAtomicReference = new AtomicReference<>(null);
+    TakeScreenshotCallback takeScreenshotCallback =
+        new TakeScreenshotCallback() {
+          @Override
+          public void onSuccess(@NonNull ScreenshotResult screenshotResult) {
+            screenshotResultAtomicReference.set(screenshotResult);
+          }
+
+          @Override
+          public void onFailure(int i) {}
+        };
+
+    service.takeScreenshot(
+        /*displayId=*/ Display.DEFAULT_DISPLAY,
+        MoreExecutors.directExecutor(),
+        takeScreenshotCallback);
+
+    assertThat(screenshotResultAtomicReference.get()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void takeScreenshot_afterSettingErrorCode_returnsErrorCode() {
+    shadow.setTakeScreenshotErrorCode(
+        AccessibilityService.ERROR_TAKE_SCREENSHOT_INTERVAL_TIME_SHORT);
+    AtomicReference<Integer> errorCodeAtomicReference = new AtomicReference<>(-1);
+    TakeScreenshotCallback takeScreenshotCallback =
+        new TakeScreenshotCallback() {
+          @Override
+          public void onSuccess(@NonNull ScreenshotResult screenshotResult) {}
+
+          @Override
+          public void onFailure(int errorCode) {
+            errorCodeAtomicReference.set(errorCode);
+          }
+        };
+
+    service.takeScreenshot(
+        /*displayId=*/ Display.DEFAULT_DISPLAY,
+        MoreExecutors.directExecutor(),
+        takeScreenshotCallback);
+
+    assertThat(errorCodeAtomicReference.get())
+        .isEqualTo(AccessibilityService.ERROR_TAKE_SCREENSHOT_INTERVAL_TIME_SHORT);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void takeScreenshot_afterUnsettingErrorCode_immediatelyReturnsSuccessfully() {
+    AtomicReference<ScreenshotResult> screenshotResultAtomicReference = new AtomicReference<>(null);
+    TakeScreenshotCallback takeScreenshotCallback =
+        new TakeScreenshotCallback() {
+          @Override
+          public void onSuccess(@NonNull ScreenshotResult screenshotResult) {
+            screenshotResultAtomicReference.set(screenshotResult);
+          }
+
+          @Override
+          public void onFailure(int i) {}
+        };
+    shadow.setTakeScreenshotErrorCode(
+        AccessibilityService.ERROR_TAKE_SCREENSHOT_INTERVAL_TIME_SHORT);
+    shadow.unsetTakeScreenshotErrorCode();
+
+    service.takeScreenshot(
+        /*displayId=*/ Display.DEFAULT_DISPLAY,
+        MoreExecutors.directExecutor(),
+        takeScreenshotCallback);
+
+    assertThat(screenshotResultAtomicReference.get()).isNotNull();
+  }
+
+  /**
+   * The AccessibilityService shadow should return consistent window data.
+   */
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldReturnPopulatedWindowData() {
+    AccessibilityWindowInfo w1 = AccessibilityWindowInfo.obtain();
+    w1.setId(1);
+    AccessibilityWindowInfo w2 = AccessibilityWindowInfo.obtain();
+    w2.setId(2);
+    AccessibilityWindowInfo w3 = AccessibilityWindowInfo.obtain();
+    w3.setId(3);
+    shadow.setWindows(Arrays.asList(w1, w2, w3));
+    assertThat(service.getWindows()).hasSize(3);
+    assertThat(service.getWindows()).containsExactly(w1, w2, w3).inOrder();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getSystemActions_returnsNull() {
+    assertThat(service.getSystemActions()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getSystemActions_returnsSetValue() {
+    ImmutableList<AccessibilityNodeInfo.AccessibilityAction> actions =
+        ImmutableList.of(
+            new AccessibilityNodeInfo.AccessibilityAction(
+                AccessibilityService.GLOBAL_ACTION_BACK, "Go back"),
+            new AccessibilityNodeInfo.AccessibilityAction(
+                AccessibilityService.GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS, "All apps"));
+
+    shadow.setSystemActions(actions);
+
+    assertThat(service.getSystemActions()).isEqualTo(actions);
+  }
+
+  private static GestureDescription createTestGesture() {
+    Path path = new Path();
+    path.moveTo(/*x=*/ 100, /*y=*/ 200);
+    path.lineTo(/*x=*/ 100, /*y=*/ 800);
+    return new GestureDescription.Builder()
+        .addStroke(new StrokeDescription(path, /*startTime=*/ 0, /*duration=*/ 100))
+        .build();
+  }
+
+  private static GestureResultCallback createEmptyGestureResultCallback() {
+    return new GestureResultCallback() {
+      @Override
+      public void onCompleted(GestureDescription description) {}
+
+      @Override
+      public void onCancelled(GestureDescription description) {}
+    };
+  }
+
+  public static class MyService extends AccessibilityService {
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+    }
+
+    @Override
+    public void onAccessibilityEvent(AccessibilityEvent arg0) {
+      //Do nothing
+    }
+
+    @Override
+    public void onInterrupt() {
+      //Do nothing
+    }
+  }
+}
+
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityWindowInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityWindowInfoTest.java
new file mode 100644
index 0000000..7e5b783
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccessibilityWindowInfoTest.java
@@ -0,0 +1,62 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowAccessibilityWindowInfoTest {
+  private ShadowAccessibilityWindowInfo shadow;
+
+  @Before
+  public void setUp() {
+    ShadowAccessibilityWindowInfo.resetObtainedInstances();
+    assertThat(ShadowAccessibilityWindowInfo.areThereUnrecycledWindows(true)).isEqualTo(false);
+    AccessibilityWindowInfo window = ShadowAccessibilityWindowInfo.obtain();
+    assertThat(window).isNotNull();
+    shadow = shadowOf(window);
+  }
+
+  @Test
+  public void shouldNotHaveRootNode() {
+    assertThat(shadow.getRoot() == null).isEqualTo(true);
+  }
+
+  @Test
+  public void shouldHaveAssignedRoot() {
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
+    shadow.setRoot(node);
+    assertThat(shadow.getRoot()).isEqualTo(node);
+  }
+
+  @Test
+  public void testSetAnchor() {
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
+    shadow.setAnchor(node);
+    assertThat(shadow.getAnchor()).isEqualTo(node);
+  }
+
+  @Test
+  public void testSetTitle() {
+    assertThat(shadow.getTitle()).isNull();
+    CharSequence title = "Title";
+    shadow.setTitle(title);
+    assertThat(shadow.getTitle().toString()).isEqualTo(title.toString());
+  }
+
+  @Test
+  public void testSetChild() {
+    AccessibilityWindowInfo window = AccessibilityWindowInfo.obtain();
+    shadow.addChild(window);
+    assertThat(shadow.getChild(0)).isEqualTo(window);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccountManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccountManagerTest.java
new file mode 100644
index 0000000..4f9f0b2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccountManagerTest.java
@@ -0,0 +1,1132 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerCallback;
+import android.accounts.AccountManagerFuture;
+import android.accounts.AuthenticatorDescription;
+import android.accounts.AuthenticatorException;
+import android.accounts.OnAccountsUpdateListener;
+import android.accounts.OperationCanceledException;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccountManagerTest {
+  private AccountManager am;
+  private Activity activity;
+
+  @Before
+  public void setUp() throws Exception {
+    am = AccountManager.get(ApplicationProvider.getApplicationContext());
+    activity = new Activity();
+  }
+
+  @Test
+  public void testGet() {
+    assertThat(am).isNotNull();
+    assertThat(am)
+        .isSameInstanceAs(AccountManager.get(ApplicationProvider.getApplicationContext()));
+
+    AccountManager activityAM = AccountManager.get(ApplicationProvider.getApplicationContext());
+    assertThat(activityAM).isNotNull();
+    assertThat(activityAM).isSameInstanceAs(am);
+  }
+
+  @Test
+  public void testGetAccounts() {
+    assertThat(am.getAccounts()).isNotNull();
+    assertThat(am.getAccounts().length).isEqualTo(0);
+
+    Account a1 = new Account("name_a", "type_a");
+    shadowOf(am).addAccount(a1);
+    assertThat(am.getAccounts()).isNotNull();
+    assertThat(am.getAccounts().length).isEqualTo(1);
+    assertThat(am.getAccounts()[0]).isSameInstanceAs(a1);
+
+    Account a2 = new Account("name_b", "type_b");
+    shadowOf(am).addAccount(a2);
+    assertThat(am.getAccounts()).isNotNull();
+    assertThat(am.getAccounts().length).isEqualTo(2);
+    assertThat(am.getAccounts()[1]).isSameInstanceAs(a2);
+  }
+
+  @Test
+  public void getAccountsByType_nullTypeReturnsAllAccounts() {
+    shadowOf(am).addAccount(new Account("name_1", "type_1"));
+    shadowOf(am).addAccount(new Account("name_2", "type_2"));
+    shadowOf(am).addAccount(new Account("name_3", "type_3"));
+
+    assertThat(am.getAccountsByType(null)).asList().containsAtLeastElementsIn(am.getAccounts());
+  }
+
+  @Test
+  public void testGetAccountsByType() {
+    assertThat(am.getAccountsByType("name_a")).isNotNull();
+    assertThat(am.getAccounts().length).isEqualTo(0);
+
+    Account a1 = new Account("name_a", "type_a");
+    shadowOf(am).addAccount(a1);
+    Account[] accounts = am.getAccountsByType("type_a");
+    assertThat(accounts).isNotNull();
+    assertThat(accounts.length).isEqualTo(1);
+    assertThat(accounts[0]).isSameInstanceAs(a1);
+
+    Account a2 = new Account("name_b", "type_b");
+    shadowOf(am).addAccount(a2);
+    accounts = am.getAccountsByType("type_a");
+    assertThat(accounts).isNotNull();
+    assertThat(accounts.length).isEqualTo(1);
+    assertThat(accounts[0]).isSameInstanceAs(a1);
+
+    Account a3 = new Account("name_c", "type_a");
+    shadowOf(am).addAccount(a3);
+    accounts = am.getAccountsByType("type_a");
+    assertThat(accounts).isNotNull();
+    assertThat(accounts.length).isEqualTo(2);
+    assertThat(accounts[0]).isSameInstanceAs(a1);
+    assertThat(accounts[1]).isSameInstanceAs(a3);
+  }
+
+  @Test
+  public void addAuthToken() {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    am.setAuthToken(account, "token_type_1", "token1");
+    am.setAuthToken(account, "token_type_2", "token2");
+
+    assertThat(am.peekAuthToken(account, "token_type_1")).isEqualTo("token1");
+    assertThat(am.peekAuthToken(account, "token_type_2")).isEqualTo("token2");
+  }
+
+  @Test
+  public void setAuthToken_shouldNotAddTokenIfAccountNotPresent() {
+    Account account = new Account("name", "type");
+    am.setAuthToken(account, "token_type_1", "token1");
+    assertThat(am.peekAuthToken(account, "token_type_1")).isNull();
+  }
+
+  @Test
+  public void testAddAccountExplicitly_noPasswordNoExtras() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, null, null);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getAccountsByType("type").length).isEqualTo(1);
+    assertThat(am.getAccountsByType("type")[0].name).isEqualTo("name");
+
+    boolean accountAddedTwice = am.addAccountExplicitly(account, null, null);
+    assertThat(accountAddedTwice).isFalse();
+
+    account = new Account("another_name", "type");
+    accountAdded = am.addAccountExplicitly(account, null, null);
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getAccountsByType("type").length).isEqualTo(2);
+    assertThat(am.getAccountsByType("type")[0].name).isEqualTo("name");
+    assertThat(am.getAccountsByType("type")[1].name).isEqualTo("another_name");
+    assertThat(am.getPassword(account)).isNull();
+
+    try {
+      am.addAccountExplicitly(null, null, null);
+      fail("An illegal argument exception should have been thrown when trying to add a null"
+               + " account");
+    } catch (IllegalArgumentException iae) {
+      // NOP
+    }
+  }
+
+  @Test
+  public void testAddAccountExplicitly_withPassword() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, "passwd", null);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getPassword(account)).isEqualTo("passwd");
+  }
+
+  @Test
+  public void testAddAccountExplicitly_withExtras() {
+    Account account = new Account("name", "type");
+    Bundle extras = new Bundle();
+    extras.putString("key123", "value123");
+    boolean accountAdded = am.addAccountExplicitly(account, null, extras);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getUserData(account, "key123")).isEqualTo("value123");
+    assertThat(am.getUserData(account, "key456")).isNull();
+  }
+
+  @Test
+  public void testAddAccountExplicitly_notifiesListenersIfSuccessful() {
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, "passwd", null);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testAddAccountExplicitly_doesNotNotifyListenersIfUnsuccessful() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, "passwd", null);
+    assertThat(accountAdded).isTrue();
+
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    // This account is added already, so it'll fail
+    boolean accountAdded2 = am.addAccountExplicitly(account, "passwd", null);
+    assertThat(accountAdded2).isFalse();
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetSetUserData_addToInitiallyEmptyExtras() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, null, null);
+
+    assertThat(accountAdded).isTrue();
+
+    am.setUserData(account, "key123", "value123");
+    assertThat(am.getUserData(account, "key123")).isEqualTo("value123");
+  }
+
+  @Test
+  public void testGetSetUserData_overwrite() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, null, null);
+
+    assertThat(accountAdded).isTrue();
+
+    am.setUserData(account, "key123", "value123");
+    assertThat(am.getUserData(account, "key123")).isEqualTo("value123");
+
+    am.setUserData(account, "key123", "value456");
+    assertThat(am.getUserData(account, "key123")).isEqualTo("value456");
+  }
+
+  @Test
+  public void testGetSetUserData_remove() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, null, null);
+
+    assertThat(accountAdded).isTrue();
+
+    am.setUserData(account, "key123", "value123");
+    assertThat(am.getUserData(account, "key123")).isEqualTo("value123");
+
+    am.setUserData(account, "key123", null);
+    assertThat(am.getUserData(account, "key123")).isNull();
+  }
+
+  @Test
+  public void testGetSetPassword_setInAccountInitiallyWithNoPassword() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, null, null);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getPassword(account)).isNull();
+
+    am.setPassword(account, "passwd");
+    assertThat(am.getPassword(account)).isEqualTo("passwd");
+  }
+
+  @Test
+  public void testGetSetPassword_overwrite() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, "passwd1", null);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getPassword(account)).isEqualTo("passwd1");
+
+    am.setPassword(account, "passwd2");
+    assertThat(am.getPassword(account)).isEqualTo("passwd2");
+  }
+
+  @Test
+  public void testGetSetPassword_remove() {
+    Account account = new Account("name", "type");
+    boolean accountAdded = am.addAccountExplicitly(account, "passwd1", null);
+
+    assertThat(accountAdded).isTrue();
+    assertThat(am.getPassword(account)).isEqualTo("passwd1");
+
+    am.setPassword(account, null);
+    assertThat(am.getPassword(account)).isNull();
+  }
+
+  @Test
+  public void testBlockingGetAuthToken() throws AuthenticatorException, OperationCanceledException, IOException {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    am.setAuthToken(account, "token_type_1", "token1");
+    am.setAuthToken(account, "token_type_2", "token2");
+
+    assertThat(am.blockingGetAuthToken(account, "token_type_1", false)).isEqualTo("token1");
+    assertThat(am.blockingGetAuthToken(account, "token_type_2", false)).isEqualTo("token2");
+
+    try {
+      am.blockingGetAuthToken(null, "token_type_1", false);
+      fail("blockingGetAuthToken() should throw an illegal argument exception if the account is"
+               + " null");
+    } catch (IllegalArgumentException iae) {
+      // Expected
+    }
+    try {
+      am.blockingGetAuthToken(account, null, false);
+      fail("blockingGetAuthToken() should throw an illegal argument exception if the auth token"
+               + " type is null");
+    } catch (IllegalArgumentException iae) {
+      // Expected
+    }
+
+    Account account1 = new Account("unknown", "type");
+    assertThat(am.blockingGetAuthToken(account1, "token_type_1", false)).isNull();
+  }
+
+  @Test
+  public void removeAccount_throwsIllegalArgumentException_whenPassedNullAccount() {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    try {
+      am.removeAccount(null, null, null);
+      fail("removeAccount() should throw an illegal argument exception if the account is null");
+    } catch (IllegalArgumentException iae) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void removeAccount_doesNotRemoveAccountOfDifferentName() throws Exception {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    Account wrongAccount = new Account("wrong_name", "type");
+    AccountManagerFuture<Boolean> future = am.removeAccount(wrongAccount, null, null);
+    assertThat(future.getResult()).isFalse();
+    assertThat(am.getAccountsByType("type")).isNotEmpty();
+  }
+
+  @Test
+  public void removeAccount_does() throws Exception {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    TestAccountManagerCallback<Boolean> testAccountManagerCallback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Boolean> future = am.removeAccount(account, testAccountManagerCallback, null);
+    assertThat(future.getResult()).isTrue();
+    assertThat(am.getAccountsByType("type")).isEmpty();
+
+    shadowMainLooper().idle();
+    assertThat(testAccountManagerCallback.accountManagerFuture).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void removeAccount_withActivity() throws Exception {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    TestAccountManagerCallback<Bundle> testAccountManagerCallback =
+        new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.removeAccount(account, activity, testAccountManagerCallback, null);
+    Bundle result = future.getResult();
+
+    assertThat(result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)).isTrue();
+    Intent removeAccountIntent = result.getParcelable(AccountManager.KEY_INTENT);
+    assertThat(removeAccountIntent).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void removeAccount_withActivity_doesNotRemoveButReturnsIntent() throws Exception {
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+    Intent intent = new Intent().setAction("remove-account-action");
+    shadowOf(am).setRemoveAccountIntent(intent);
+
+    TestAccountManagerCallback<Bundle> testAccountManagerCallback =
+        new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.removeAccount(account, activity, testAccountManagerCallback, null);
+    Bundle result = future.getResult();
+
+    assertThat(result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)).isFalse();
+    Intent removeAccountIntent = result.getParcelable(AccountManager.KEY_INTENT);
+    assertThat(removeAccountIntent.getAction()).isEqualTo(intent.getAction());
+  }
+
+  @Test
+  public void removeAccount_notifiesListenersIfSuccessful() {
+    Account account = new Account("name", "type");
+    am.addAccountExplicitly(account, "passwd", null);
+
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    am.removeAccount(account, null, null);
+
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void removeAccount_doesNotNotifyIfUnuccessful() {
+    Account account = new Account("name", "type");
+
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    // The account has not been added
+    am.removeAccount(account, null, null);
+
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+  }
+
+  private static class TestOnAccountsUpdateListener implements OnAccountsUpdateListener {
+    private int invocationCount = 0;
+    private Account[] updatedAccounts;
+
+    @Override
+    public void onAccountsUpdated(Account[] accounts) {
+      invocationCount++;
+      updatedAccounts = accounts;
+    }
+
+    public int getInvocationCount() {
+      return invocationCount;
+    }
+
+    public Account[] getUpdatedAccounts() {
+      return updatedAccounts;
+    }
+  }
+
+  @Test
+  public void testAccountsUpdateListener() {
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testAccountsUpdateListener_duplicate() {
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testAccountsUpdateListener_updateImmediately() {
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, true);
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void addOnAccountsUpdatedListener_notifiesListenersForAccountType() {
+    Account accountOne = new Account("name", "typeOne");
+    Account accountTwo = new Account("name", "typeTwo");
+    TestOnAccountsUpdateListener typeOneListener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(
+        typeOneListener,
+        /* handler= */ null,
+        /* updateImmediately= */ false,
+        new String[] {"typeOne"});
+    TestOnAccountsUpdateListener typeTwoListener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(
+        typeTwoListener, /* handler= */ null, /* updateImmediately= */ false);
+
+    shadowOf(am).addAccount(accountOne);
+    shadowOf(am).addAccount(accountTwo);
+
+    assertThat(typeOneListener.getInvocationCount()).isEqualTo(1);
+    assertThat(typeTwoListener.getInvocationCount()).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void addOnAccountsUpdatedListener_updateImmediately_notifiesListenerSelectively() {
+    Account accountOne = new Account("name", "typeOne");
+    Account accountTwo = new Account("name", "typeTwo");
+    shadowOf(am).addAccount(accountOne);
+    shadowOf(am).addAccount(accountTwo);
+    TestOnAccountsUpdateListener typeOneListener = new TestOnAccountsUpdateListener();
+
+    am.addOnAccountsUpdatedListener(
+        typeOneListener,
+        /* handler= */ null,
+        /* updateImmediately= */ true,
+        new String[] {"typeOne"});
+
+    assertThat(Arrays.asList(typeOneListener.getUpdatedAccounts())).containsExactly(accountOne);
+  }
+
+  @Test
+  public void testAccountsUpdateListener_listenerNotInvokedAfterRemoval() {
+    TestOnAccountsUpdateListener listener = new TestOnAccountsUpdateListener();
+    am.addOnAccountsUpdatedListener(listener, null, false);
+    assertThat(listener.getInvocationCount()).isEqualTo(0);
+
+    Account account = new Account("name", "type");
+    shadowOf(am).addAccount(account);
+
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+
+    am.removeOnAccountsUpdatedListener(listener);
+
+    shadowOf(am).addAccount(account);
+
+    assertThat(listener.getInvocationCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testAddAuthenticator() {
+    shadowOf(am).addAuthenticator("type");
+    AuthenticatorDescription[] result = am.getAuthenticatorTypes();
+    assertThat(result.length).isEqualTo(1);
+    assertThat(result[0].type).isEqualTo("type");
+  }
+
+  @Test
+  public void invalidateAuthToken_noAccount() {
+    am.invalidateAuthToken("type1", "token1");
+  }
+
+  @Test
+  public void invalidateAuthToken_noToken() {
+    Account account1 = new Account("name", "type1");
+    shadowOf(am).addAccount(account1);
+    am.invalidateAuthToken("type1", "token1");
+  }
+
+  @Test
+  public void invalidateAuthToken_multipleAccounts() {
+    Account account1 = new Account("name", "type1");
+    shadowOf(am).addAccount(account1);
+
+    Account account2 = new Account("name", "type2");
+    shadowOf(am).addAccount(account2);
+
+    am.setAuthToken(account1, "token_type_1", "token1");
+    am.setAuthToken(account2, "token_type_1", "token1");
+
+    assertThat(am.peekAuthToken(account1, "token_type_1")).isEqualTo("token1");
+    assertThat(am.peekAuthToken(account2, "token_type_1")).isEqualTo("token1");
+
+    // invalidate token for type1 account
+    am.invalidateAuthToken("type1", "token1");
+    assertThat(am.peekAuthToken(account1, "token_type_1")).isNull();
+    assertThat(am.peekAuthToken(account2, "token_type_1")).isEqualTo("token1");
+
+    // invalidate token for type2 account
+    am.invalidateAuthToken("type2", "token1");
+    assertThat(am.peekAuthToken(account1, "token_type_1")).isNull();
+    assertThat(am.peekAuthToken(account2, "token_type_1")).isNull();
+  }
+
+  @Test
+  public void invalidateAuthToken_multipleTokens() {
+    Account account = new Account("name", "type1");
+    shadowOf(am).addAccount(account);
+
+    am.setAuthToken(account, "token_type_1", "token1");
+    am.setAuthToken(account, "token_type_2", "token2");
+
+    assertThat(am.peekAuthToken(account, "token_type_1")).isEqualTo("token1");
+    assertThat(am.peekAuthToken(account, "token_type_2")).isEqualTo("token2");
+
+    // invalidate token1
+    am.invalidateAuthToken("type1", "token1");
+    assertThat(am.peekAuthToken(account, "token_type_1")).isNull();
+    assertThat(am.peekAuthToken(account, "token_type_2")).isEqualTo("token2");
+
+    // invalidate token2
+    am.invalidateAuthToken("type1", "token2");
+    assertThat(am.peekAuthToken(account, "token_type_1")).isNull();
+    assertThat(am.peekAuthToken(account, "token_type_2")).isNull();
+  }
+
+  @Test
+  public void invalidateAuthToken_multipleTokenTypesSameToken() {
+    Account account = new Account("name", "type1");
+    shadowOf(am).addAccount(account);
+
+    am.setAuthToken(account, "token_type_1", "token1");
+    am.setAuthToken(account, "token_type_2", "token1");
+
+    assertThat(am.peekAuthToken(account, "token_type_1")).isEqualTo("token1");
+    assertThat(am.peekAuthToken(account, "token_type_2")).isEqualTo("token1");
+
+    // invalidate token1
+    am.invalidateAuthToken("type1", "token1");
+    assertThat(am.peekAuthToken(account, "token_type_1")).isNull();
+    assertThat(am.peekAuthToken(account, "token_type_2")).isNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void startAddAccountSession_withNonNullActivity() throws Exception {
+    shadowOf(am).addAuthenticator("google.com");
+
+    TestAccountManagerCallback<Bundle> testAccountManagerCallback =
+        new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.startAddAccountSession(
+            "google.com",
+            /* authTokenType= */ null,
+            /* requiredFeatures= */ null,
+            /* options= */ null,
+            activity,
+            testAccountManagerCallback,
+            /* handler= */ null);
+
+    shadowMainLooper().idle();
+    assertThat(testAccountManagerCallback.hasBeenCalled()).isTrue();
+
+    Bundle result = future.getResult();
+    assertThat(result.getBundle(AccountManager.KEY_ACCOUNT_SESSION_BUNDLE)).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void startAddAccountSession_withNullActivity() throws Exception {
+    shadowOf(am).addAuthenticator("google.com");
+
+    TestAccountManagerCallback<Bundle> testAccountManagerCallback =
+        new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.startAddAccountSession(
+            "google.com",
+            /* authTokenType= */ null,
+            /* requiredFeatures= */ null,
+            /* options= */ null,
+            /* activity= */ null,
+            testAccountManagerCallback,
+            /* handler= */ null);
+
+    shadowMainLooper().idle();
+    assertThat(testAccountManagerCallback.hasBeenCalled()).isTrue();
+
+    Bundle result = future.getResult();
+    assertThat((Intent) result.getParcelable(AccountManager.KEY_INTENT)).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void startAddAccountSession_missingAuthenticator() throws Exception {
+    TestAccountManagerCallback<Bundle> testAccountManagerCallback =
+        new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.startAddAccountSession(
+            "google.com",
+            /* authTokenType= */ null,
+            /* requiredFeatures= */ null,
+            /* options= */ null,
+            activity,
+            testAccountManagerCallback,
+            /* handler= */ null);
+
+    shadowMainLooper().idle();
+    assertThat(testAccountManagerCallback.hasBeenCalled()).isTrue();
+
+    try {
+      future.getResult();
+      fail(
+          "startAddAccountSession() should throw an authenticator exception if no authenticator "
+              + " was registered for this account type");
+    } catch (AuthenticatorException e) {
+      // Expected
+    }
+    assertThat(future.isDone()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void finishSession() throws Exception {
+    Bundle sessionBundle = new Bundle();
+    sessionBundle.putString(AccountManager.KEY_ACCOUNT_NAME, "name");
+    sessionBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, "google.com");
+
+    TestAccountManagerCallback<Bundle> testAccountManagerCallback =
+        new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.finishSession(sessionBundle, activity, testAccountManagerCallback, null);
+
+    shadowMainLooper().idle();
+    assertThat(testAccountManagerCallback.hasBeenCalled()).isTrue();
+
+    Bundle result = future.getResult();
+    assertThat(result.getString(AccountManager.KEY_ACCOUNT_NAME)).isEqualTo("name");
+    assertThat(result.getString(AccountManager.KEY_ACCOUNT_TYPE)).isEqualTo("google.com");
+  }
+
+  @Test
+  public void addAccount_noActivitySpecified() throws Exception {
+    shadowOf(am).addAuthenticator("google.com");
+
+    AccountManagerFuture<Bundle> result =
+        am.addAccount("google.com", "auth_token_type", null, null, null, null, null);
+
+    Bundle resultBundle = result.getResult();
+
+    assertThat((Intent) resultBundle.getParcelable(AccountManager.KEY_INTENT)).isNotNull();
+  }
+
+  @Test
+  public void addAccount_activitySpecified() throws Exception {
+    shadowOf(am).addAuthenticator("google.com");
+
+    AccountManagerFuture<Bundle> result =
+        am.addAccount("google.com", "auth_token_type", null, null, activity, null, null);
+    Bundle resultBundle = result.getResult();
+
+    assertThat(resultBundle.getString(AccountManager.KEY_ACCOUNT_TYPE)).isEqualTo("google.com");
+    assertThat(resultBundle.getString(AccountManager.KEY_ACCOUNT_NAME))
+        .isEqualTo("some_user@gmail.com");
+  }
+
+  @Test
+  public void addAccount_shouldCallCallback() throws Exception {
+    shadowOf(am).addAuthenticator("google.com");
+
+    TestAccountManagerCallback<Bundle> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> result =
+        am.addAccount(
+            "google.com", "auth_token_type", null, null, activity, callback, new Handler());
+
+    assertThat(callback.hasBeenCalled()).isFalse();
+    assertThat(result.isDone()).isFalse();
+
+    shadowOf(am).addAccount(new Account("thebomb@google.com", "google.com"));
+    shadowMainLooper().idle();
+    assertThat(result.isDone()).isTrue();
+    assertThat(callback.accountManagerFuture).isNotNull();
+
+    Bundle resultBundle = callback.getResult();
+    assertThat(resultBundle.getString(AccountManager.KEY_ACCOUNT_TYPE)).isEqualTo("google.com");
+    assertThat(resultBundle.getString(AccountManager.KEY_ACCOUNT_NAME))
+        .isEqualTo("thebomb@google.com");
+  }
+
+  @Test
+  public void addAccount_whenSchedulerPaused_shouldCallCallbackAfterSchedulerUnpaused() throws Exception {
+    shadowMainLooper().pause();
+    shadowOf(am).addAuthenticator("google.com");
+
+    TestAccountManagerCallback<Bundle> callback = new TestAccountManagerCallback<>();
+    am.addAccount("google.com", "auth_token_type", null, null, activity, callback, new Handler());
+    assertThat(callback.hasBeenCalled()).isFalse();
+
+    shadowOf(am).addAccount(new Account("thebomb@google.com", "google.com"));
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+
+    Bundle resultBundle = callback.getResult();
+    assertThat(resultBundle.getString(AccountManager.KEY_ACCOUNT_TYPE)).isEqualTo("google.com");
+    assertThat(resultBundle.getString(AccountManager.KEY_ACCOUNT_NAME))
+        .isEqualTo("thebomb@google.com");
+  }
+
+  @Test
+  public void addAccount_noAuthenticatorDefined() throws Exception {
+    AccountManagerFuture<Bundle> future =
+        am.addAccount("unknown_account_type", "auth_token_type", null, null, activity, null, null);
+    try {
+      future.getResult();
+      fail("addAccount() should throw an authenticator exception if no authenticator was"
+               + " registered for this account type");
+    } catch(AuthenticatorException e) {
+      // Expected
+    }
+    assertThat(future.isDone()).isTrue();
+  }
+
+  @Test
+  public void addAccount_withOptionsShouldSupportGetNextAddAccountOptions() throws Exception {
+    assertThat(shadowOf(am).getNextAddAccountOptions()).isNull();
+
+    shadowOf(am).addAuthenticator("google.com");
+
+    Bundle expectedAddAccountOptions = new Bundle();
+    expectedAddAccountOptions.putString("option", "value");
+
+    am.addAccount(
+        "google.com", "auth_token_type", null, expectedAddAccountOptions, activity, null, null);
+
+    Bundle actualAddAccountOptions = shadowOf(am).getNextAddAccountOptions();
+    assertThat(shadowOf(am).getNextAddAccountOptions()).isNull();
+    assertThat(actualAddAccountOptions).isEqualTo(expectedAddAccountOptions);
+  }
+
+  @Test
+  public void addAccount_withOptionsShouldSupportPeekNextAddAccountOptions() throws Exception {
+    assertThat(shadowOf(am).peekNextAddAccountOptions()).isNull();
+
+    shadowOf(am).addAuthenticator("google.com");
+
+    Bundle expectedAddAccountOptions = new Bundle();
+    expectedAddAccountOptions.putString("option", "value");
+    am.addAccount(
+        "google.com", "auth_token_type", null, expectedAddAccountOptions, activity, null, null);
+
+    Bundle actualAddAccountOptions = shadowOf(am).peekNextAddAccountOptions();
+    assertThat(shadowOf(am).peekNextAddAccountOptions()).isNotNull();
+    assertThat(actualAddAccountOptions).isEqualTo(expectedAddAccountOptions);
+  }
+
+  @Test
+  public void addAccount_withNoAuthenticatorForType_throwsExceptionInGetResult() throws Exception {
+    assertThat(shadowOf(am).peekNextAddAccountOptions()).isNull();
+
+    AccountManagerFuture<Bundle> futureResult =
+        am.addAccount("google.com", "auth_token_type", null, null, activity, null, null);
+    try {
+      futureResult.getResult();
+      fail("should have thrown");
+    } catch (AuthenticatorException expected) { }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void addPreviousAccount() {
+    Account account = new Account("name_a", "type_a");
+    shadowOf(am).setPreviousAccountName(account, "old_name");
+    assertThat(am.getPreviousName(account)).isEqualTo("old_name");
+  }
+
+  @Test
+  public void testGetAsSystemService() throws Exception {
+    AccountManager systemService =
+        (AccountManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.ACCOUNT_SERVICE);
+    assertThat(systemService).isNotNull();
+    assertThat(am).isEqualTo(systemService);
+  }
+
+  @Test
+  public void getAuthToken_withActivity_returnsCorrectToken() throws Exception {
+    Account account = new Account("name", "google.com");
+    shadowOf(am).addAccount(account);
+    shadowOf(am).addAuthenticator("google.com");
+
+    am.setAuthToken(account, "auth_token_type", "token1");
+
+    TestAccountManagerCallback<Bundle> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future = am.getAuthToken(account,
+        "auth_token_type",
+        new Bundle(),
+        activity,
+        callback,
+        new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_NAME)).isEqualTo(account.name);
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_TYPE)).isEqualTo(account.type);
+    assertThat(future.getResult().getString(AccountManager.KEY_AUTHTOKEN)).isEqualTo("token1");
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getAuthToken_withActivity_returnsAuthIntent() throws Exception {
+    Account account = new Account("name", "google.com");
+    shadowOf(am).addAccount(account);
+    shadowOf(am).addAuthenticator("google.com");
+
+    TestAccountManagerCallback<Bundle> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future = am.getAuthToken(account,
+        "auth_token_type",
+        new Bundle(),
+        activity,
+        callback,
+        new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_NAME))
+        .isEqualTo(account.name);
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_TYPE))
+        .isEqualTo(account.type);
+    assertThat(future.getResult().getString(AccountManager.KEY_AUTHTOKEN)).isNull();
+    assertThat((Intent) future.getResult().getParcelable(AccountManager.KEY_INTENT)).isNotNull();
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getAuthToken_withNotifyAuthFailureSetToFalse_returnsCorrectToken() throws Exception {
+    Account account = new Account("name", "google.com");
+    shadowOf(am).addAccount(account);
+    shadowOf(am).addAuthenticator("google.com");
+
+    am.setAuthToken(account, "auth_token_type", "token1");
+
+    TestAccountManagerCallback<Bundle> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.getAuthToken(
+            account,
+            "auth_token_type",
+            new Bundle(),
+            /* notifyAuthFailure= */ false,
+            callback,
+            new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_NAME))
+        .isEqualTo(account.name);
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_TYPE))
+        .isEqualTo(account.type);
+    assertThat(future.getResult().getString(AccountManager.KEY_AUTHTOKEN)).isEqualTo("token1");
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getAuthToken_withNotifyAuthFailureSetToFalse_returnsAuthIntent() throws Exception {
+    Account account = new Account("name", "google.com");
+    shadowOf(am).addAccount(account);
+    shadowOf(am).addAuthenticator("google.com");
+
+    TestAccountManagerCallback<Bundle> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Bundle> future =
+        am.getAuthToken(
+            account,
+            "auth_token_type",
+            new Bundle(),
+            /* notifyAuthFailure= */ false,
+            callback,
+            new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_NAME))
+        .isEqualTo(account.name);
+    assertThat(future.getResult().getString(AccountManager.KEY_ACCOUNT_TYPE))
+        .isEqualTo(account.type);
+    assertThat(future.getResult().getString(AccountManager.KEY_AUTHTOKEN)).isNull();
+    assertThat((Intent) future.getResult().getParcelable(AccountManager.KEY_INTENT)).isNotNull();
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getHasFeatures_returnsTrueWhenAllFeaturesSatisfied() throws Exception {
+    Account account = new Account("name", "google.com");
+    shadowOf(am).addAccount(account);
+    shadowOf(am).setFeatures(account, new String[] { "FEATURE_1", "FEATURE_2" });
+
+    TestAccountManagerCallback<Boolean> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Boolean> future =
+        am.hasFeatures(account, new String[] {"FEATURE_1", "FEATURE_2"}, callback, new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult().booleanValue()).isEqualTo(true);
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getHasFeatures_returnsFalseWhenAllFeaturesNotSatisfied() throws Exception {
+    Account account = new Account("name", "google.com");
+    shadowOf(am).addAccount(account);
+    shadowOf(am).setFeatures(account, new String[] { "FEATURE_1" });
+
+    TestAccountManagerCallback<Boolean> callback = new TestAccountManagerCallback<>();
+    AccountManagerFuture<Boolean> future =
+        am.hasFeatures(account, new String[] {"FEATURE_1", "FEATURE_2"}, callback, new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult().booleanValue()).isEqualTo(false);
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getAccountsByTypeAndFeatures() throws Exception {
+
+    Account accountWithCorrectTypeAndFeatures = new Account("account_1", "google.com");
+    shadowOf(am).addAccount(accountWithCorrectTypeAndFeatures);
+    shadowOf(am)
+        .setFeatures(accountWithCorrectTypeAndFeatures, new String[] {"FEATURE_1", "FEATURE_2"});
+
+    Account accountWithCorrectTypeButNotFeatures = new Account("account_2", "google.com");
+    shadowOf(am).addAccount(accountWithCorrectTypeButNotFeatures);
+    shadowOf(am).setFeatures(accountWithCorrectTypeButNotFeatures, new String[] { "FEATURE_1" });
+
+    Account accountWithCorrectTypeButEmptyFeatures = new Account("account_3", "google.com");
+    shadowOf(am).addAccount(accountWithCorrectTypeButEmptyFeatures);
+
+    Account accountWithCorrectFeaturesButNotType = new Account("account_4", "facebook.com");
+    shadowOf(am).addAccount(accountWithCorrectFeaturesButNotType);
+    shadowOf(am)
+        .setFeatures(accountWithCorrectFeaturesButNotType, new String[] {"FEATURE_1", "FEATURE_2"});
+
+    TestAccountManagerCallback<Account[]> callback = new TestAccountManagerCallback<>();
+
+    AccountManagerFuture<Account[]> future =
+        am.getAccountsByTypeAndFeatures(
+            "google.com", new String[] {"FEATURE_1", "FEATURE_2"}, callback, new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult()).asList().containsExactly(accountWithCorrectTypeAndFeatures);
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  public void getAccountsByTypeAndFeatures_returnsAllAccountsForNullFeature() throws Exception {
+
+    Account accountWithCorrectTypeAndFeatures = new Account("account_1", "google.com");
+    shadowOf(am).addAccount(accountWithCorrectTypeAndFeatures);
+    shadowOf(am).setFeatures(
+        accountWithCorrectTypeAndFeatures, new String[] { "FEATURE_1", "FEATURE_2" });
+
+    Account accountWithCorrectTypeButNotFeatures = new Account("account_2", "google.com");
+    shadowOf(am).addAccount(accountWithCorrectTypeButNotFeatures);
+    shadowOf(am).setFeatures(accountWithCorrectTypeButNotFeatures, new String[] { "FEATURE_1" });
+
+    Account accountWithCorrectFeaturesButNotType = new Account("account_3", "facebook.com");
+    shadowOf(am).addAccount(accountWithCorrectFeaturesButNotType);
+    shadowOf(am).setFeatures(
+        accountWithCorrectFeaturesButNotType, new String[] { "FEATURE_1", "FEATURE_2" });
+
+
+    TestAccountManagerCallback<Account[]> callback = new TestAccountManagerCallback<>();
+
+    AccountManagerFuture<Account[]> future =
+        am.getAccountsByTypeAndFeatures("google.com", null, callback, new Handler());
+
+    assertThat(future.isDone()).isTrue();
+    assertThat(future.getResult()).asList()
+        .containsExactly(accountWithCorrectTypeAndFeatures, accountWithCorrectTypeButNotFeatures);
+
+    shadowMainLooper().idle();
+    assertThat(callback.hasBeenCalled()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getAccountsByTypeForPackage() {
+    Account[] accountsByTypeForPackage = am.getAccountsByTypeForPackage(null, "org.somepackage");
+
+    assertThat(accountsByTypeForPackage).isEmpty();
+
+    Account accountVisibleToPackage = new Account("user@gmail.com", "gmail.com");
+    shadowOf(am).addAccount(accountVisibleToPackage, "org.somepackage");
+
+    accountsByTypeForPackage = am.getAccountsByTypeForPackage("other_type", "org.somepackage");
+    assertThat(accountsByTypeForPackage).isEmpty();
+
+    accountsByTypeForPackage = am.getAccountsByTypeForPackage("gmail.com", "org.somepackage");
+    assertThat(accountsByTypeForPackage).asList().containsExactly(accountVisibleToPackage);
+
+    accountsByTypeForPackage = am.getAccountsByTypeForPackage(null, "org.somepackage");
+    assertThat(accountsByTypeForPackage).asList().containsExactly(accountVisibleToPackage);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void removeAccountExplicitly() {
+    assertThat(
+            am.removeAccountExplicitly(new Account("non_existant_account@gmail.com", "gmail.com")))
+        .isFalse();
+    assertThat(am.removeAccountExplicitly(null)).isFalse();
+
+    Account account = new Account("name@gmail.com", "gmail.com");
+    shadowOf(am).addAccount(account);
+
+    assertThat(am.removeAccountExplicitly(account)).isTrue();
+  }
+
+  @Test
+  public void removeAllAccounts() throws Exception {
+
+    Account account = new Account("name@gmail.com", "gmail.com");
+    shadowOf(am).addAccount(account);
+
+    assertThat(am.getAccounts()).isNotEmpty();
+
+    shadowOf(am).removeAllAccounts();
+
+    assertThat(am.getAccounts()).isEmpty();
+  }
+
+  @Test
+  public void testSetAuthenticationErrorOnNextResponse()
+      throws AuthenticatorException, IOException, OperationCanceledException {
+
+    shadowOf(am).setAuthenticationErrorOnNextResponse(true);
+
+    try {
+      am.getAccountsByTypeAndFeatures(null, null, null, null).getResult();
+      fail("should have thrown");
+    } catch (AuthenticatorException expected) {
+      // Expected
+    }
+
+    am.getAccountsByTypeAndFeatures(null, null, null, null).getResult();
+  }
+
+  private static class TestAccountManagerCallback<T> implements AccountManagerCallback<T> {
+    private AccountManagerFuture<T> accountManagerFuture;
+
+    @Override
+    public void run(AccountManagerFuture<T> accountManagerFuture) {
+      this.accountManagerFuture = accountManagerFuture;
+    }
+
+    boolean hasBeenCalled() {
+      return accountManagerFuture != null;
+    }
+
+    T getResult() throws Exception {
+      return accountManagerFuture.getResult();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAccountTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccountTest.java
new file mode 100644
index 0000000..c6f5a46
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAccountTest.java
@@ -0,0 +1,66 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.accounts.Account;
+import android.os.Parcel;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAccountTest {
+
+  @Test
+  public void shouldHaveStringsConstructor() {
+    Account account = new Account("name", "type");
+
+    assertThat(account.name).isEqualTo("name");
+    assertThat(account.type).isEqualTo("type");
+  }
+
+  @Test
+  public void shouldHaveParcelConstructor() {
+    Account expected = new Account("name", "type");
+    Parcel p = Parcel.obtain();
+    expected.writeToParcel(p, 0);
+    p.setDataPosition(0);
+
+    Account actual = new Account(p);
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void shouldBeParcelable() {
+    Account expected = new Account("name", "type");
+    Parcel p = Parcel.obtain();
+    expected.writeToParcel(p, 0);
+    p.setDataPosition(0);
+    Account actual = Account.CREATOR.createFromParcel(p);
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldThrowIfNameIsEmpty() {
+    new Account("", "type");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldThrowIfTypeIsEmpty() {
+    new Account("name", "");
+  }
+
+  @Test
+  public void shouldHaveToString() {
+    Account account = new Account("name", "type");
+    assertThat(account.toString()).isEqualTo("Account {name=name, type=type}");
+  }
+
+  @Test
+  public void shouldProvideEqualAndHashCode() {
+    assertThat(new Account("a", "b")).isEqualTo(new Account("a", "b"));
+    assertThat(new Account("a", "b")).isNotEqualTo(new Account("c", "b"));
+    assertThat(new Account("a", "b").hashCode()).isEqualTo(new Account("a", "b").hashCode());
+    assertThat(new Account("a", "b").hashCode()).isNotEqualTo(new Account("c", "b").hashCode());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityGroupTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityGroupTest.java
new file mode 100644
index 0000000..31b5c18
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityGroupTest.java
@@ -0,0 +1,23 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.ActivityGroup;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowActivityGroupTest {
+
+  @Test
+  public void getCurrentActivity_shouldReturnTheProvidedCurrentActivity() {
+  ActivityGroup activityGroup = new ActivityGroup();
+  Activity activity = new Activity();
+  shadowOf(activityGroup).setCurrentActivity(activity);
+
+    assertThat(activityGroup.getCurrentActivity()).isSameInstanceAs(activity);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityManagerTest.java
new file mode 100644
index 0000000..4201239
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityManagerTest.java
@@ -0,0 +1,472 @@
+package org.robolectric.shadows;
+
+import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
+import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE;
+import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE;
+import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.ActivityManager;
+import android.app.ActivityManager.AppTask;
+import android.app.Application;
+import android.app.ApplicationExitInfo;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ConfigurationInfo;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.system.OsConstants;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.collect.Lists;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowActivityManagerTest {
+
+  private static final String PROCESS_NAME = "com.google.android.apps.app";
+
+  private ActivityManager activityManager;
+  private Application application;
+  private Context context;
+  private ShadowActivityManager shadowActivityManager;
+  private UserManager userManager;
+
+  @Before
+  public void setUp() {
+    application = ApplicationProvider.getApplicationContext();
+    context = application;
+    activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+    shadowActivityManager = Shadow.extract(activityManager);
+    userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
+  }
+
+  @Test
+  public void getMemoryInfo_canGetMemoryInfoForOurProcess() {
+    ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
+    memoryInfo.availMem = 12345;
+    memoryInfo.lowMemory = true;
+    memoryInfo.threshold = 10000;
+    memoryInfo.totalMem = 55555;
+    shadowActivityManager.setMemoryInfo(memoryInfo);
+    ActivityManager.MemoryInfo fetchedMemoryInfo = new ActivityManager.MemoryInfo();
+    activityManager.getMemoryInfo(fetchedMemoryInfo);
+    assertThat(fetchedMemoryInfo.availMem).isEqualTo(12345);
+    assertThat(fetchedMemoryInfo.lowMemory).isTrue();
+    assertThat(fetchedMemoryInfo.threshold).isEqualTo(10000);
+    assertThat(fetchedMemoryInfo.totalMem).isEqualTo(55555);
+  }
+
+  @Test
+  public void getMemoryInfo_canGetMemoryInfoEvenWhenWeDidNotSetIt() {
+    ActivityManager.MemoryInfo fetchedMemoryInfo = new ActivityManager.MemoryInfo();
+    activityManager.getMemoryInfo(fetchedMemoryInfo);
+    assertThat(fetchedMemoryInfo.lowMemory).isFalse();
+  }
+
+  @Test
+  public void getRunningTasks_shouldReturnTaskList() {
+    final ActivityManager.RunningTaskInfo task1 =
+        buildTaskInfo(new ComponentName("org.robolectric", "Task 1"));
+    final ActivityManager.RunningTaskInfo task2 =
+        buildTaskInfo(new ComponentName("org.robolectric", "Task 2"));
+
+    assertThat(activityManager.getRunningTasks(Integer.MAX_VALUE)).isEmpty();
+    shadowActivityManager.setTasks(Lists.newArrayList(task1, task2));
+    assertThat(activityManager.getRunningTasks(Integer.MAX_VALUE)).containsExactly(task1, task2);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAppTasks_shouldReturnAppTaskList() {
+    final AppTask task1 = ShadowAppTask.newInstance();
+    final AppTask task2 = ShadowAppTask.newInstance();
+
+    assertThat(activityManager.getAppTasks()).isEmpty();
+    shadowActivityManager.setAppTasks(Lists.newArrayList(task1, task2));
+    assertThat(activityManager.getAppTasks()).containsExactly(task1, task2);
+  }
+
+  @Test
+  public void getRunningAppProcesses_shouldReturnProcessList() {
+    final ActivityManager.RunningAppProcessInfo process1 =
+        buildProcessInfo(new ComponentName("org.robolectric", "Process 1"));
+    final ActivityManager.RunningAppProcessInfo process2 =
+        buildProcessInfo(new ComponentName("org.robolectric", "Process 2"));
+
+    assertThat(activityManager.getRunningAppProcesses().size()).isEqualTo(1);
+    ActivityManager.RunningAppProcessInfo myInfo = activityManager.getRunningAppProcesses().get(0);
+    assertThat(myInfo.pid).isEqualTo(android.os.Process.myPid());
+    assertThat(myInfo.uid).isEqualTo(android.os.Process.myUid());
+    assertThat(myInfo.processName).isEqualTo(application.getBaseContext().getPackageName());
+    shadowActivityManager.setProcesses(Lists.newArrayList(process1, process2));
+    assertThat(activityManager.getRunningAppProcesses()).containsExactly(process1, process2);
+  }
+
+  @Test
+  public void getRunningServices_shouldReturnServiceList() {
+    final ActivityManager.RunningServiceInfo service1 =
+        buildServiceInfo(new ComponentName("org.robolectric", "Service 1"));
+    final ActivityManager.RunningServiceInfo service2 =
+        buildServiceInfo(new ComponentName("org.robolectric", "Service 2"));
+
+    assertThat(activityManager.getRunningServices(Integer.MAX_VALUE)).isEmpty();
+    shadowActivityManager.setServices(Lists.newArrayList(service1, service2));
+    assertThat(activityManager.getRunningServices(Integer.MAX_VALUE))
+        .containsExactly(service1, service2);
+  }
+
+  @Test
+  public void getMemoryClass_shouldWork() {
+    assertThat(activityManager.getMemoryClass()).isEqualTo(16);
+
+    shadowActivityManager.setMemoryClass(42);
+    assertThat(activityManager.getMemoryClass()).isEqualTo(42);
+  }
+
+  @Test
+  public void killBackgroundProcesses_shouldWork() {
+    assertThat(shadowActivityManager.getBackgroundPackage()).isNull();
+
+    activityManager.killBackgroundProcesses("org.robolectric");
+    assertThat(shadowActivityManager.getBackgroundPackage()).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void getLauncherLargeIconDensity_shouldWork() {
+    assertThat(activityManager.getLauncherLargeIconDensity()).isGreaterThan(0);
+  }
+
+  @Test
+  public void isUserAMonkey_shouldReturnFalse() {
+    assertThat(ActivityManager.isUserAMonkey()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void setIsLowRamDevice() {
+    shadowActivityManager.setIsLowRamDevice(true);
+    assertThat(activityManager.isLowRamDevice()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getLockTaskModeState() {
+    assertThat(activityManager.getLockTaskModeState())
+        .isEqualTo(ActivityManager.LOCK_TASK_MODE_NONE);
+
+    shadowActivityManager.setLockTaskModeState(ActivityManager.LOCK_TASK_MODE_LOCKED);
+    assertThat(activityManager.getLockTaskModeState())
+        .isEqualTo(ActivityManager.LOCK_TASK_MODE_LOCKED);
+    assertThat(activityManager.isInLockTaskMode()).isTrue();
+  }
+
+  @Test
+  public void getMyMemoryState() {
+    ActivityManager.RunningAppProcessInfo inState = new ActivityManager.RunningAppProcessInfo();
+    ActivityManager.getMyMemoryState(inState);
+    assertThat(inState.uid).isEqualTo(Process.myUid());
+    assertThat(inState.pid).isEqualTo(Process.myPid());
+    assertThat(inState.importanceReasonCode).isEqualTo(0);
+    ActivityManager.RunningAppProcessInfo setState = new ActivityManager.RunningAppProcessInfo();
+    setState.uid = Process.myUid();
+    setState.pid = Process.myPid();
+    setState.importanceReasonCode = ActivityManager.RunningAppProcessInfo.REASON_PROVIDER_IN_USE;
+    shadowActivityManager.setProcesses(ImmutableList.of(setState));
+    inState = new ActivityManager.RunningAppProcessInfo();
+    ActivityManager.getMyMemoryState(inState);
+    assertThat(inState.importanceReasonCode)
+        .isEqualTo(ActivityManager.RunningAppProcessInfo.REASON_PROVIDER_IN_USE);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void switchUser() {
+    shadowOf(application).setSystemService(Context.USER_SERVICE, userManager);
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    activityManager.switchUser(10);
+    assertThat(UserHandle.myUserId()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void switchUser_withUserHandle_shouldAbleToSwitchUser() {
+    UserHandle userHandle = shadowOf(userManager).addUser(10, "secondary_user", 0);
+    activityManager.switchUser(userHandle);
+    assertThat(UserHandle.myUserId()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getCurrentUser_default_returnZero() {
+    assertThat(ActivityManager.getCurrentUser()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getCurrentUser_nonDefault_returnValueSet() {
+    shadowOf(application).setSystemService(Context.USER_SERVICE, userManager);
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    activityManager.switchUser(10);
+
+    assertThat(ActivityManager.getCurrentUser()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void onUidImportanceListener() {
+    ActivityManager.OnUidImportanceListener listener =
+        mock(ActivityManager.OnUidImportanceListener.class);
+    InOrder inOrder = inOrder(listener);
+
+    activityManager.addOnUidImportanceListener(listener, IMPORTANCE_FOREGROUND_SERVICE);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND);
+    inOrder.verify(listener).onUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_VISIBLE);
+    inOrder.verify(listener).onUidImportance(Process.myUid(), IMPORTANCE_VISIBLE);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND_SERVICE);
+    inOrder.verify(listener).onUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND_SERVICE);
+
+    activityManager.removeOnUidImportanceListener(listener);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_VISIBLE);
+    inOrder.verifyNoMoreInteractions();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getUidImportance() {
+    assertThat(activityManager.getUidImportance(Process.myUid())).isEqualTo(IMPORTANCE_GONE);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND);
+    assertThat(activityManager.getUidImportance(Process.myUid())).isEqualTo(IMPORTANCE_FOREGROUND);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_VISIBLE);
+    assertThat(activityManager.getUidImportance(Process.myUid())).isEqualTo(IMPORTANCE_VISIBLE);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND_SERVICE);
+    assertThat(activityManager.getUidImportance(Process.myUid()))
+        .isEqualTo(IMPORTANCE_FOREGROUND_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPackageImportance() {
+    assertThat(activityManager.getPackageImportance(context.getPackageName()))
+        .isEqualTo(IMPORTANCE_GONE);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND);
+    assertThat(activityManager.getPackageImportance(context.getPackageName()))
+        .isEqualTo(IMPORTANCE_FOREGROUND);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_VISIBLE);
+    assertThat(activityManager.getPackageImportance(context.getPackageName()))
+        .isEqualTo(IMPORTANCE_VISIBLE);
+
+    shadowOf(activityManager).setUidImportance(Process.myUid(), IMPORTANCE_FOREGROUND_SERVICE);
+    assertThat(activityManager.getPackageImportance(context.getPackageName()))
+        .isEqualTo(IMPORTANCE_FOREGROUND_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void isBackgroundRestricted_returnsValueSet() {
+    shadowActivityManager.setBackgroundRestricted(true);
+
+    assertThat(activityManager.isBackgroundRestricted()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getHistoricalProcessExitReasons_noRecord_emptyListReturned() {
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(context.getPackageName(), 0, 0);
+
+    assertThat(applicationExitInfoList).isEmpty();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getHistoricalProcessExitReasons_recordsRetunredInCorrectOrder() {
+    addApplicationExitInfo(/* pid= */ 1);
+    addApplicationExitInfo(/* pid= */ 2);
+    addApplicationExitInfo(/* pid= */ 3);
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 0, /* maxNum= */ 0);
+
+    assertThat(applicationExitInfoList).hasSize(3);
+    assertThat(applicationExitInfoList.get(0).getPid()).isEqualTo(3);
+    assertThat(applicationExitInfoList.get(1).getPid()).isEqualTo(2);
+    assertThat(applicationExitInfoList.get(2).getPid()).isEqualTo(1);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getHistoricalProcessExitReasons_pidSpecified_correctRecordReturned() {
+    addApplicationExitInfo(/* pid= */ 1);
+    addApplicationExitInfo(/* pid= */ 2);
+    addApplicationExitInfo(/* pid= */ 3);
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 2, /* maxNum= */ 0);
+
+    assertThat(applicationExitInfoList).hasSize(1);
+    assertThat(applicationExitInfoList.get(0).getPid()).isEqualTo(2);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getHistoricalProcessExitReasons_maxNumSpecified_correctNumberOfRecordsReturned() {
+    addApplicationExitInfo(/* pid= */ 1);
+    addApplicationExitInfo(/* pid= */ 2);
+    addApplicationExitInfo(/* pid= */ 3);
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 0, /* maxNum= */ 2);
+
+    assertThat(applicationExitInfoList).hasSize(2);
+    assertThat(applicationExitInfoList.get(0).getPid()).isEqualTo(3);
+    assertThat(applicationExitInfoList.get(1).getPid()).isEqualTo(2);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void addApplicationExitInfo_reasonSet() {
+    addApplicationExitInfo(/* pid= */ 1, ApplicationExitInfo.REASON_ANR, /* status= */ 0);
+    addApplicationExitInfo(/* pid= */ 2, ApplicationExitInfo.REASON_CRASH, /* status= */ 0);
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 0, /* maxNum= */ 0);
+
+    assertThat(applicationExitInfoList).hasSize(2);
+    assertThat(applicationExitInfoList.get(0).getReason())
+        .isEqualTo(ApplicationExitInfo.REASON_CRASH);
+    assertThat(applicationExitInfoList.get(1).getReason())
+        .isEqualTo(ApplicationExitInfo.REASON_ANR);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void addApplicationExitInfo_statusSet() {
+    addApplicationExitInfo(/* pid= */ 1, ApplicationExitInfo.REASON_SIGNALED, OsConstants.SIGABRT);
+    addApplicationExitInfo(/* pid= */ 2, ApplicationExitInfo.REASON_CRASH, /* status= */ 0);
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 0, /* maxNum= */ 0);
+
+    assertThat(applicationExitInfoList).hasSize(2);
+    assertThat(applicationExitInfoList.get(0).getStatus()).isEqualTo(0);
+    assertThat(applicationExitInfoList.get(1).getStatus()).isEqualTo(OsConstants.SIGABRT);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void addApplicationExitInfo_processNameSet() {
+    addApplicationExitInfo(/* pid= */ 1);
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 0, /* maxNum= */ 0);
+
+    assertThat(applicationExitInfoList).hasSize(1);
+    assertThat(applicationExitInfoList.get(0).getProcessName()).isEqualTo(PROCESS_NAME);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void addApplicationExitInfo_timestampSet() {
+    shadowActivityManager.addApplicationExitInfo(
+        ShadowActivityManager.ApplicationExitInfoBuilder.newBuilder().setTimestamp(123).build());
+    shadowActivityManager.addApplicationExitInfo(
+        ShadowActivityManager.ApplicationExitInfoBuilder.newBuilder().setTimestamp(456).build());
+
+    List<ApplicationExitInfo> applicationExitInfoList =
+        activityManager.getHistoricalProcessExitReasons(
+            context.getPackageName(), /* pid= */ 0, /* maxNum= */ 0);
+
+    assertThat(applicationExitInfoList).hasSize(2);
+    assertThat(applicationExitInfoList.get(0).getTimestamp()).isEqualTo(456);
+    assertThat(applicationExitInfoList.get(1).getTimestamp()).isEqualTo(123);
+  }
+
+  @Test
+  public void getDeviceConfigurationInfo_returnsValueSet() {
+    ConfigurationInfo configurationInfo = new ConfigurationInfo();
+    shadowActivityManager.setDeviceConfigurationInfo(configurationInfo);
+
+    assertThat(activityManager.getDeviceConfigurationInfo()).isEqualTo(configurationInfo);
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void isApplicationUserDataCleared_returnsDefaultFalse() {
+    assertThat(shadowActivityManager.isApplicationUserDataCleared()).isFalse();
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void isApplicationUserDataCleared_returnsTrue() {
+    activityManager.clearApplicationUserData();
+    assertThat(shadowActivityManager.isApplicationUserDataCleared()).isTrue();
+  }
+
+  private void addApplicationExitInfo(int pid) {
+    addApplicationExitInfo(
+        /* pid= */ pid, ApplicationExitInfo.REASON_SIGNALED, /* status= */ OsConstants.SIGKILL);
+  }
+
+  private void addApplicationExitInfo(int pid, int reason, int status) {
+    shadowActivityManager.addApplicationExitInfo(
+        ShadowActivityManager.ApplicationExitInfoBuilder.newBuilder()
+            .setProcessName(PROCESS_NAME)
+            .setPid(pid)
+            .setReason(reason)
+            .setStatus(status)
+            .build());
+  }
+
+  private ActivityManager.RunningTaskInfo buildTaskInfo(ComponentName name) {
+    final ActivityManager.RunningTaskInfo info = new ActivityManager.RunningTaskInfo();
+    info.baseActivity = name;
+    return info;
+  }
+
+  private ActivityManager.RunningServiceInfo buildServiceInfo(ComponentName name) {
+    final ActivityManager.RunningServiceInfo info = new ActivityManager.RunningServiceInfo();
+    info.service = name;
+    return info;
+  }
+
+  private ActivityManager.RunningAppProcessInfo buildProcessInfo(ComponentName name) {
+    final ActivityManager.RunningAppProcessInfo info = new ActivityManager.RunningAppProcessInfo();
+    info.importanceReasonComponent = name;
+    return info;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityTest.java
new file mode 100644
index 0000000..c3250a7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityTest.java
@@ -0,0 +1,1665 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Robolectric.setupActivity;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.RuntimeEnvironment.systemContext;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.Manifest;
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.ActivityOptions;
+import android.app.Application;
+import android.app.Dialog;
+import android.app.DirectAction;
+import android.app.Fragment;
+import android.app.PendingIntent;
+import android.app.PictureInPictureParams;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.IntentSender;
+import android.content.LocusId;
+import android.content.SharedPreferences;
+import android.content.pm.ActivityInfo;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.view.Display;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewRootImpl;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.SearchView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.fakes.RoboSplashScreen;
+import org.robolectric.shadows.ShadowActivity.IntentSenderRequest;
+import org.robolectric.util.TestRunnable;
+
+/** Test of ShadowActivity. */
+@RunWith(AndroidJUnit4.class)
+@SuppressWarnings("RobolectricSystemContext") // preexisting when check was enabled
+public class ShadowActivityTest {
+  private Activity activity;
+
+  @Test
+  public void shouldUseApplicationLabelFromManifestAsTitleForActivity() throws Exception {
+    activity = Robolectric.setupActivity(LabelTestActivity1.class);
+    assertThat(activity.getTitle()).isNotNull();
+    assertThat(activity.getTitle().toString()).isEqualTo(activity.getString(R.string.app_name));
+  }
+
+  @Test
+  public void shouldUseActivityLabelFromManifestAsTitleForActivity() throws Exception {
+    activity = Robolectric.setupActivity(LabelTestActivity2.class);
+    assertThat(activity.getTitle()).isNotNull();
+    assertThat(activity.getTitle().toString())
+        .isEqualTo(activity.getString(R.string.activity_name));
+  }
+
+  @Test
+  public void shouldUseActivityLabelFromManifestAsTitleForActivityWithShortName() throws Exception {
+    activity = Robolectric.setupActivity(LabelTestActivity3.class);
+    assertThat(activity.getTitle()).isNotNull();
+    assertThat(activity.getTitle().toString())
+        .isEqualTo(activity.getString(R.string.activity_name));
+  }
+
+  @Test
+  public void createActivity_noDisplayFinished_shouldFinishActivity() {
+    ActivityController<Activity> controller = Robolectric.buildActivity(Activity.class);
+    controller.get().setTheme(android.R.style.Theme_NoDisplay);
+    controller.create();
+    controller.get().finish();
+    controller.start().visible().resume();
+
+    activity = controller.get();
+    assertThat(activity.isFinishing()).isTrue();
+  }
+
+  @Config(minSdk = M)
+  @Test
+  public void createActivity_noDisplayNotFinished_shouldThrowIllegalStateException() {
+    try {
+      ActivityController<Activity> controller = Robolectric.buildActivity(Activity.class);
+      controller.get().setTheme(android.R.style.Theme_NoDisplay);
+      controller.setup();
+
+      // For apps targeting above Lollipop MR1, an exception "Activity <activity> did not call
+      // finish() prior to onResume() completing" will be thrown
+      fail("IllegalStateException should be thrown");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+  }
+
+  public static final class LabelTestActivity1 extends Activity {}
+
+  public static final class LabelTestActivity2 extends Activity {}
+
+  public static final class LabelTestActivity3 extends Activity {}
+
+  @Test
+  public void
+      shouldNotComplainIfActivityIsDestroyedWhileAnotherActivityHasRegisteredBroadcastReceivers()
+          throws Exception {
+    ActivityController<DialogCreatingActivity> controller =
+        Robolectric.buildActivity(DialogCreatingActivity.class);
+    activity = controller.get();
+
+    DialogLifeCycleActivity activity2 = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    activity2.registerReceiver(new AppWidgetProvider(), new IntentFilter());
+
+    controller.destroy();
+  }
+
+  @Test
+  public void shouldNotRegisterNullBroadcastReceiver() {
+    ActivityController<DialogCreatingActivity> controller =
+        Robolectric.buildActivity(DialogCreatingActivity.class);
+    activity = controller.get();
+    activity.registerReceiver(null, new IntentFilter());
+
+    controller.destroy();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldReportDestroyedStatus() {
+    ActivityController<DialogCreatingActivity> controller =
+        Robolectric.buildActivity(DialogCreatingActivity.class);
+    activity = controller.get();
+
+    controller.destroy();
+    assertThat(activity.isDestroyed()).isTrue();
+  }
+
+  @Test
+  public void startActivity_shouldDelegateToStartActivityForResult() {
+
+    TranscriptActivity activity = Robolectric.setupActivity(TranscriptActivity.class);
+
+    activity.startActivity(new Intent().setType("image/*"));
+
+    shadowOf(activity)
+        .receiveResult(
+            new Intent().setType("image/*"),
+            Activity.RESULT_OK,
+            new Intent().setData(Uri.parse("content:foo")));
+    assertThat(activity.transcript)
+        .containsExactly(
+            "onActivityResult called with requestCode -1, resultCode -1, intent data content:foo");
+  }
+
+  public static class TranscriptActivity extends Activity {
+    final List<String> transcript = new ArrayList<>();
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+      transcript.add(
+          "onActivityResult called with requestCode "
+              + requestCode
+              + ", resultCode "
+              + resultCode
+              + ", intent data "
+              + data.getData());
+    }
+  }
+
+  @Test
+  public void startActivities_shouldStartAllActivities() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+
+    final Intent view = new Intent(Intent.ACTION_VIEW);
+    final Intent pick = new Intent(Intent.ACTION_PICK);
+    activity.startActivities(new Intent[] {view, pick});
+
+    assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(pick);
+    assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(view);
+  }
+
+  @Test
+  public void startActivities_withBundle_shouldStartAllActivities() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+
+    final Intent view = new Intent(Intent.ACTION_VIEW);
+    final Intent pick = new Intent(Intent.ACTION_PICK);
+    activity.startActivities(new Intent[] {view, pick}, new Bundle());
+
+    assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(pick);
+    assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(view);
+  }
+
+  @Test
+  public void startActivityForResultAndReceiveResult_shouldSendResponsesBackToActivity() {
+    TranscriptActivity activity = Robolectric.setupActivity(TranscriptActivity.class);
+    activity.startActivityForResult(new Intent().setType("audio/*"), 123);
+    activity.startActivityForResult(new Intent().setType("image/*"), 456);
+
+    shadowOf(activity)
+        .receiveResult(
+            new Intent().setType("image/*"),
+            Activity.RESULT_OK,
+            new Intent().setData(Uri.parse("content:foo")));
+    assertThat(activity.transcript)
+        .containsExactly(
+            "onActivityResult called with requestCode 456, resultCode -1, intent data content:foo");
+  }
+
+  @Test
+  public void startActivityForResultAndReceiveResult_whenNoIntentMatches_shouldThrowException() {
+    ThrowOnResultActivity activity = Robolectric.buildActivity(ThrowOnResultActivity.class).get();
+    activity.startActivityForResult(new Intent().setType("audio/*"), 123);
+    activity.startActivityForResult(new Intent().setType("image/*"), 456);
+
+    Intent requestIntent = new Intent().setType("video/*");
+    try {
+      shadowOf(activity)
+          .receiveResult(
+              requestIntent, Activity.RESULT_OK, new Intent().setData(Uri.parse("content:foo")));
+      fail();
+    } catch (Exception e) {
+      assertThat(e.getMessage()).startsWith("No intent matches " + requestIntent);
+    }
+  }
+
+  public static class ThrowOnResultActivity extends Activity {
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+      throw new IllegalStateException("should not be called");
+    }
+  }
+
+  @Test
+  public void shouldSupportStartActivityForResult() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    Intent intent = new Intent().setClass(activity, DialogLifeCycleActivity.class);
+    assertThat(shadowOf(activity).getNextStartedActivity()).isNull();
+
+    activity.startActivityForResult(intent, 142);
+
+    Intent startedIntent = shadowOf(activity).getNextStartedActivity();
+    assertThat(startedIntent).isNotNull();
+    assertThat(startedIntent).isSameInstanceAs(intent);
+  }
+
+  @Test
+  public void shouldSupportGetStartedActivitiesForResult() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    Intent intent = new Intent().setClass(activity, DialogLifeCycleActivity.class);
+
+    activity.startActivityForResult(intent, 142);
+
+    ShadowActivity.IntentForResult intentForResult =
+        shadowOf(activity).getNextStartedActivityForResult();
+    assertThat(intentForResult).isNotNull();
+    assertThat(shadowOf(activity).getNextStartedActivityForResult()).isNull();
+    assertThat(intentForResult.intent).isNotNull();
+    assertThat(intentForResult.intent).isSameInstanceAs(intent);
+    assertThat(intentForResult.requestCode).isEqualTo(142);
+  }
+
+  @Test
+  public void shouldSupportPeekStartedActivitiesForResult() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    Intent intent = new Intent().setClass(activity, DialogLifeCycleActivity.class);
+
+    activity.startActivityForResult(intent, 142);
+
+    ShadowActivity.IntentForResult intentForResult =
+        shadowOf(activity).peekNextStartedActivityForResult();
+    assertThat(intentForResult).isNotNull();
+    assertThat(shadowOf(activity).peekNextStartedActivityForResult())
+        .isSameInstanceAs(intentForResult);
+    assertThat(intentForResult.intent).isNotNull();
+    assertThat(intentForResult.intent).isSameInstanceAs(intent);
+    assertThat(intentForResult.requestCode).isEqualTo(142);
+  }
+
+  @Test
+  public void onContentChangedShouldBeCalledAfterContentViewIsSet() throws RuntimeException {
+    final List<String> transcript = new ArrayList<>();
+    ActivityWithContentChangedTranscript customActivity =
+        Robolectric.setupActivity(ActivityWithContentChangedTranscript.class);
+    customActivity.setTranscript(transcript);
+    customActivity.setContentView(R.layout.main);
+    assertThat(transcript).containsExactly("onContentChanged was called; title is \"Main Layout\"");
+  }
+
+  @Test
+  public void shouldRetrievePackageNameFromTheManifest() {
+    assertThat(Robolectric.setupActivity(Activity.class).getPackageName())
+        .isEqualTo(ApplicationProvider.getApplicationContext().getPackageName());
+  }
+
+  @Test
+  public void shouldRunUiTasksImmediatelyByDefault() {
+    TestRunnable runnable = new TestRunnable();
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    activity.runOnUiThread(runnable);
+    assertTrue(runnable.wasRun);
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void shouldQueueUiTasksWhenUiThreadIsPaused() {
+    shadowOf(getMainLooper()).pause();
+
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    TestRunnable runnable = new TestRunnable();
+    activity.runOnUiThread(runnable);
+    assertFalse(runnable.wasRun);
+
+    shadowOf(getMainLooper()).idle();
+    assertTrue(runnable.wasRun);
+  }
+
+  /**
+   * The legacy behavior spec-ed in {@link #shouldQueueUiTasksWhenUiThreadIsPaused()} is actually
+   * incorrect. The {@link Activity#runOnUiThread} will execute posted tasks inline.
+   */
+  @Test
+  @LooperMode(Mode.PAUSED)
+  public void shouldExecutePostedUiTasksInRealisticLooper() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    TestRunnable runnable = new TestRunnable();
+    activity.runOnUiThread(runnable);
+    assertTrue(runnable.wasRun);
+  }
+
+  @Test
+  public void showDialog_shouldCreatePrepareAndShowDialog() {
+    final DialogLifeCycleActivity activity =
+        Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    final AtomicBoolean dialogWasShown = new AtomicBoolean(false);
+
+    new Dialog(activity) {
+      {
+        activity.dialog = this;
+      }
+
+      @Override
+      public void show() {
+        dialogWasShown.set(true);
+      }
+    };
+
+    activity.showDialog(1);
+
+    assertTrue(activity.createdDialog);
+    assertTrue(activity.preparedDialog);
+    assertTrue(dialogWasShown.get());
+  }
+
+  @Test
+  public void showDialog_shouldCreatePrepareAndShowDialogWithBundle() {
+    final DialogLifeCycleActivity activity =
+        Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    final AtomicBoolean dialogWasShown = new AtomicBoolean(false);
+
+    new Dialog(activity) {
+      {
+        activity.dialog = this;
+      }
+
+      @Override
+      public void show() {
+        dialogWasShown.set(true);
+      }
+    };
+
+    activity.showDialog(1, new Bundle());
+
+    assertTrue(activity.createdDialog);
+    assertTrue(activity.preparedDialogWithBundle);
+    assertTrue(dialogWasShown.get());
+  }
+
+  @Test
+  public void showDialog_shouldReturnFalseIfDialogDoesNotExist() {
+    final DialogLifeCycleActivity activity =
+        Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    boolean dialogCreated = activity.showDialog(97, new Bundle());
+
+    assertThat(dialogCreated).isFalse();
+    assertThat(activity.createdDialog).isTrue();
+    assertThat(activity.preparedDialogWithBundle).isFalse();
+  }
+
+  @Test
+  public void showDialog_shouldReuseDialogs() {
+    final DialogCreatingActivity activity = Robolectric.setupActivity(DialogCreatingActivity.class);
+    activity.showDialog(1);
+    Dialog firstDialog = ShadowDialog.getLatestDialog();
+    activity.showDialog(1);
+
+    Dialog secondDialog = ShadowDialog.getLatestDialog();
+    assertSame("dialogs should be the same instance", firstDialog, secondDialog);
+  }
+
+  @Test
+  public void showDialog_shouldShowDialog() {
+    final DialogCreatingActivity activity = Robolectric.setupActivity(DialogCreatingActivity.class);
+    activity.showDialog(1);
+    Dialog dialog = ShadowDialog.getLatestDialog();
+    assertTrue(dialog.isShowing());
+  }
+
+  @Test
+  public void dismissDialog_shouldDismissPreviouslyShownDialog() {
+    final DialogCreatingActivity activity = Robolectric.setupActivity(DialogCreatingActivity.class);
+    activity.showDialog(1);
+    activity.dismissDialog(1);
+    Dialog dialog = ShadowDialog.getLatestDialog();
+    assertFalse(dialog.isShowing());
+  }
+
+  @Test
+  public void dismissDialog_shouldThrowExceptionIfDialogWasNotPreviouslyShown() {
+    final DialogCreatingActivity activity = Robolectric.setupActivity(DialogCreatingActivity.class);
+    try {
+      activity.dismissDialog(1);
+    } catch (Throwable expected) {
+      assertThat(expected).isInstanceOf(IllegalArgumentException.class);
+    }
+  }
+
+  @Test
+  public void removeDialog_shouldCreateDialogAgain() {
+    final DialogCreatingActivity activity = Robolectric.setupActivity(DialogCreatingActivity.class);
+    activity.showDialog(1);
+    Dialog firstDialog = ShadowDialog.getLatestDialog();
+
+    activity.removeDialog(1);
+    assertNull(shadowOf(activity).getDialogById(1));
+
+    activity.showDialog(1);
+    Dialog secondDialog = ShadowDialog.getLatestDialog();
+
+    assertNotSame("dialogs should not be the same instance", firstDialog, secondDialog);
+  }
+
+  @Test
+  public void shouldCallOnCreateDialogFromShowDialog() {
+    ActivityWithOnCreateDialog activity =
+        Robolectric.setupActivity(ActivityWithOnCreateDialog.class);
+    activity.showDialog(123);
+    assertTrue(activity.onCreateDialogWasCalled);
+    assertThat(ShadowDialog.getLatestDialog()).isNotNull();
+  }
+
+  @Test
+  public void shouldCallFinishInOnBackPressed() {
+    Activity activity = new Activity();
+    activity.onBackPressed();
+
+    assertTrue(activity.isFinishing());
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN)
+  public void shouldCallFinishOnFinishAffinity() {
+    Activity activity = new Activity();
+    activity.finishAffinity();
+
+    assertTrue(activity.isFinishing());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldCallFinishOnFinishAndRemoveTask() {
+    Activity activity = new Activity();
+    activity.finishAndRemoveTask();
+
+    assertTrue(activity.isFinishing());
+  }
+
+  @Test
+  public void shouldCallFinishOnFinish() {
+    Activity activity = new Activity();
+    activity.finish();
+
+    assertTrue(activity.isFinishing());
+  }
+
+  @Test
+  public void shouldSupportCurrentFocus() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+
+    assertNull(activity.getCurrentFocus());
+    View view = new View(activity);
+    shadowOf(activity).setCurrentFocus(view);
+    assertEquals(view, activity.getCurrentFocus());
+  }
+
+  @Test
+  public void shouldSetOrientation() {
+    activity = Robolectric.setupActivity(DialogLifeCycleActivity.class);
+    activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+    assertThat(activity.getRequestedOrientation())
+        .isEqualTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+  }
+
+  @Test
+  public void setDefaultKeyMode_shouldSetKeyMode() {
+    int[] modes = {
+      Activity.DEFAULT_KEYS_DISABLE,
+      Activity.DEFAULT_KEYS_SHORTCUT,
+      Activity.DEFAULT_KEYS_DIALER,
+      Activity.DEFAULT_KEYS_SEARCH_LOCAL,
+      Activity.DEFAULT_KEYS_SEARCH_GLOBAL
+    };
+    Activity activity = new Activity();
+
+    for (int mode : modes) {
+      activity.setDefaultKeyMode(mode);
+      assertWithMessage("Unexpected key mode")
+          .that(shadowOf(activity).getDefaultKeymode())
+          .isEqualTo(mode);
+    }
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void setShowWhenLocked_shouldSetShowWhenLocked() {
+    try (ActivityController<Activity> controller = Robolectric.buildActivity(Activity.class)) {
+      activity = controller.create().get();
+      ShadowActivity shadowActivity = shadowOf(activity);
+      assertThat(shadowActivity.getShowWhenLocked()).isFalse();
+      activity.setShowWhenLocked(true);
+      assertThat(shadowActivity.getShowWhenLocked()).isTrue();
+      activity.setShowWhenLocked(false);
+      assertThat(shadowActivity.getShowWhenLocked()).isFalse();
+    }
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void setTurnScreenOn_shouldSetTurnScreenOn() {
+    try (ActivityController<Activity> controller = Robolectric.buildActivity(Activity.class)) {
+      activity = controller.create().get();
+      ShadowActivity shadowActivity = shadowOf(activity);
+      assertThat(shadowActivity.getTurnScreenOn()).isFalse();
+      activity.setTurnScreenOn(true);
+      assertThat(shadowActivity.getTurnScreenOn()).isTrue();
+      activity.setTurnScreenOn(false);
+      assertThat(shadowActivity.getTurnScreenOn()).isFalse();
+    }
+  }
+
+  @Test // unclear what the correct behavior should be here...
+  public void shouldPopulateWindowDecorViewWithMergeLayoutContents() {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    activity.setContentView(R.layout.toplevel_merge);
+
+    View contentView = activity.findViewById(android.R.id.content);
+    assertThat(((ViewGroup) contentView).getChildCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void setContentView_shouldReplaceOldContentView() {
+    View view1 = new View(getApplication());
+    view1.setId(R.id.burritos);
+    View view2 = new View(getApplication());
+    view2.setId(R.id.button);
+
+    Activity activity = buildActivity(Activity.class).create().get();
+    activity.setContentView(view1);
+    assertSame(view1, activity.findViewById(R.id.burritos));
+
+    activity.setContentView(view2);
+    assertNull(activity.findViewById(R.id.burritos));
+    assertSame(view2, activity.findViewById(R.id.button));
+  }
+
+  @Test
+  public void onKeyUp_callsOnBackPressedWhichFinishesTheActivity() {
+    OnBackPressedActivity activity = buildActivity(OnBackPressedActivity.class).setup().get();
+    boolean downConsumed =
+        activity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK));
+    boolean upConsumed =
+        activity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK));
+
+    assertTrue(downConsumed);
+    assertTrue(upConsumed);
+    assertTrue(activity.onBackPressedCalled);
+    assertTrue(activity.isFinishing());
+  }
+
+  @Test
+  public void shouldGiveSharedPreferences() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
+    assertNotNull(preferences);
+    preferences.edit().putString("foo", "bar").commit();
+    assertThat(activity.getPreferences(Context.MODE_PRIVATE).getString("foo", null))
+        .isEqualTo("bar");
+  }
+
+  @Test
+  public void shouldFindContentViewContainerWithChild() {
+    Activity activity = buildActivity(Activity.class).create().get();
+    View contentView = new View(activity);
+    activity.setContentView(contentView);
+
+    FrameLayout contentViewContainer = (FrameLayout) activity.findViewById(android.R.id.content);
+    assertThat(contentViewContainer.getChildAt(0)).isSameInstanceAs(contentView);
+  }
+
+  @Test
+  public void shouldFindContentViewContainerWithoutChild() {
+    Activity activity = buildActivity(Activity.class).create().get();
+
+    FrameLayout contentViewContainer = (FrameLayout) activity.findViewById(android.R.id.content);
+    assertThat(contentViewContainer.getId()).isEqualTo(android.R.id.content);
+  }
+
+  @Test
+  public void recreateGoesThroughFullLifeCycle() {
+    ActivityController<TestActivity> activityController =
+        buildActivity(TestActivity.class).create();
+    TestActivity oldActivity = activityController.get();
+
+    // Recreate should create new instance.
+    activityController.recreate();
+
+    assertThat(activityController.get()).isNotSameInstanceAs(oldActivity);
+
+    assertThat(oldActivity.transcript)
+        .containsExactly(
+            "onCreate",
+            "onStart",
+            "onPostCreate",
+            "onResume",
+            "onPause",
+            "onStop",
+            "onSaveInstanceState",
+            "onRetainNonConfigurationInstance",
+            "onDestroy")
+        .inOrder();
+    assertThat(activityController.get().transcript)
+        .containsExactly(
+            "onCreate", "onStart", "onRestoreInstanceState", "onPostCreate", "onResume")
+        .inOrder();
+  }
+
+  @Test
+  public void recreateBringsBackTheOriginalLifeCycleStateAfterRecreate_resumed() {
+    ActivityController<TestActivity> activityController = buildActivity(TestActivity.class).setup();
+    TestActivity oldActivity = activityController.get();
+
+    // Recreate the paused activity.
+    activityController.recreate();
+
+    assertThat(activityController.get()).isNotSameInstanceAs(oldActivity);
+
+    assertThat(oldActivity.transcript)
+        .containsExactly(
+            "onCreate",
+            "onStart",
+            "onPostCreate",
+            "onResume",
+            "onPause",
+            "onStop",
+            "onSaveInstanceState",
+            "onRetainNonConfigurationInstance",
+            "onDestroy")
+        .inOrder();
+    assertThat(activityController.get().transcript)
+        .containsExactly(
+            "onCreate", "onStart", "onRestoreInstanceState", "onPostCreate", "onResume")
+        .inOrder();
+  }
+
+  @Test
+  public void recreateBringsBackTheOriginalLifeCycleStateAfterRecreate_paused() {
+    ActivityController<TestActivity> activityController = buildActivity(TestActivity.class).setup();
+    TestActivity oldActivity = activityController.get();
+
+    // Recreate the paused activity.
+    activityController.pause();
+    activityController.recreate();
+
+    assertThat(activityController.get()).isNotSameInstanceAs(oldActivity);
+
+    assertThat(oldActivity.transcript)
+        .containsExactly(
+            "onCreate",
+            "onStart",
+            "onPostCreate",
+            "onResume",
+            "onPause",
+            "onStop",
+            "onSaveInstanceState",
+            "onRetainNonConfigurationInstance",
+            "onDestroy")
+        .inOrder();
+    assertThat(activityController.get().transcript)
+        .containsExactly(
+            "onCreate", "onStart", "onRestoreInstanceState", "onPostCreate", "onResume", "onPause")
+        .inOrder();
+  }
+
+  @Test
+  public void recreateBringsBackTheOriginalLifeCycleStateAfterRecreate_stopped() {
+    ActivityController<TestActivity> activityController = buildActivity(TestActivity.class).setup();
+    TestActivity oldActivity = activityController.get();
+
+    // Recreate the stopped activity.
+    activityController.pause().stop();
+    activityController.recreate();
+
+    assertThat(activityController.get()).isNotSameInstanceAs(oldActivity);
+
+    assertThat(oldActivity.transcript)
+        .containsExactly(
+            "onCreate",
+            "onStart",
+            "onPostCreate",
+            "onResume",
+            "onPause",
+            "onStop",
+            "onSaveInstanceState",
+            "onRetainNonConfigurationInstance",
+            "onDestroy")
+        .inOrder();
+    assertThat(activityController.get().transcript)
+        .containsExactly(
+            "onCreate",
+            "onStart",
+            "onRestoreInstanceState",
+            "onPostCreate",
+            "onResume",
+            "onPause",
+            "onStop")
+        .inOrder();
+  }
+
+  @Test
+  public void startAndStopManagingCursorTracksCursors() {
+    TestActivity activity = new TestActivity();
+
+    assertThat(shadowOf(activity).getManagedCursors()).isNotNull();
+    assertThat(shadowOf(activity).getManagedCursors()).isEmpty();
+
+    Cursor c = new MatrixCursor(new String[] {"a"});
+    activity.startManagingCursor(c);
+
+    assertThat(shadowOf(activity).getManagedCursors()).isNotNull();
+    assertThat(shadowOf(activity).getManagedCursors()).hasSize(1);
+    assertThat(shadowOf(activity).getManagedCursors().get(0)).isSameInstanceAs(c);
+
+    activity.stopManagingCursor(c);
+
+    assertThat(shadowOf(activity).getManagedCursors()).isNotNull();
+    assertThat(shadowOf(activity).getManagedCursors()).isEmpty();
+    c.close();
+  }
+
+  @Test
+  public void setVolumeControlStream_setsTheSpecifiedStreamType() {
+    TestActivity activity = new TestActivity();
+    activity.setVolumeControlStream(AudioManager.STREAM_ALARM);
+    assertThat(activity.getVolumeControlStream()).isEqualTo(AudioManager.STREAM_ALARM);
+  }
+
+  @Test
+  public void decorViewSizeEqualToDisplaySize() {
+    Activity activity = buildActivity(Activity.class).create().visible().get();
+    View decorView = activity.getWindow().getDecorView();
+    assertThat(decorView).isNotEqualTo(null);
+    ViewRootImpl root = decorView.getViewRootImpl();
+    assertThat(root).isNotEqualTo(null);
+    assertThat(decorView.getWidth()).isNotEqualTo(0);
+    assertThat(decorView.getHeight()).isNotEqualTo(0);
+    Display display = ShadowDisplay.getDefaultDisplay();
+    assertThat(decorView.getWidth()).isEqualTo(display.getWidth());
+    assertThat(decorView.getHeight()).isEqualTo(display.getHeight());
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void requestsPermissions() {
+    TestActivity activity = Robolectric.setupActivity(TestActivity.class);
+    activity.requestPermissions(new String[] {Manifest.permission.CAMERA}, 1007);
+  }
+
+  private static class TestActivity extends Activity {
+    List<String> transcript = new ArrayList<>();
+
+    private boolean isRecreating = false;
+    private boolean returnMalformedDirectAction = false;
+
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+      isRecreating = true;
+      transcript.add("onSaveInstanceState");
+      outState.putString("TestActivityKey", "TestActivityValue");
+      super.onSaveInstanceState(outState);
+    }
+
+    @Override
+    public void onRestoreInstanceState(Bundle savedInstanceState) {
+      transcript.add("onRestoreInstanceState");
+      assertTrue(savedInstanceState.containsKey("TestActivityKey"));
+      assertEquals("TestActivityValue", savedInstanceState.getString("TestActivityKey"));
+      super.onRestoreInstanceState(savedInstanceState);
+    }
+
+    @Override
+    public Object onRetainNonConfigurationInstance() {
+      transcript.add("onRetainNonConfigurationInstance");
+      return 5;
+    }
+
+    @Override
+    public void onPause() {
+      transcript.add("onPause");
+      super.onPause();
+    }
+
+    @Override
+    public void onDestroy() {
+      transcript.add("onDestroy");
+      super.onDestroy();
+    }
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+      transcript.add("onCreate");
+
+      if (isRecreating) {
+        assertTrue(savedInstanceState.containsKey("TestActivityKey"));
+        assertEquals("TestActivityValue", savedInstanceState.getString("TestActivityKey"));
+      }
+
+      super.onCreate(savedInstanceState);
+    }
+
+    @Override
+    public void onStart() {
+      transcript.add("onStart");
+      super.onStart();
+    }
+
+    @Override
+    public void onPostCreate(Bundle savedInstanceState) {
+      transcript.add("onPostCreate");
+      super.onPostCreate(savedInstanceState);
+    }
+
+    @Override
+    public void onStop() {
+      transcript.add("onStop");
+      super.onStop();
+    }
+
+    @Override
+    public void onRestart() {
+      transcript.add("onRestart");
+      super.onRestart();
+    }
+
+    @Override
+    public void onResume() {
+      transcript.add("onResume");
+      super.onResume();
+    }
+
+    void setReturnMalformedDirectAction(boolean returnMalformedDirectAction) {
+      this.returnMalformedDirectAction = returnMalformedDirectAction;
+    }
+
+    DirectAction getDirectActionForTesting() {
+      Bundle extras = new Bundle();
+      extras.putParcelable("componentName", this.getComponentName());
+      return new DirectAction.Builder("testDirectAction")
+          .setExtras(extras)
+          .setLocusId(new LocusId("unused"))
+          .build();
+    }
+
+    DirectAction getMalformedDirectAction() {
+      return new DirectAction.Builder("malformedDirectAction").build();
+    }
+
+    @Override
+    public void onGetDirectActions(
+        CancellationSignal cancellationSignal, Consumer<List<DirectAction>> callback) {
+      if (returnMalformedDirectAction) {
+        callback.accept(Collections.singletonList(getMalformedDirectAction()));
+      } else {
+        callback.accept(Collections.singletonList(getDirectActionForTesting()));
+      }
+    }
+  }
+
+  @Test
+  public void getAndSetParentActivity_shouldWorkForTestingPurposes() {
+    Activity parentActivity = new Activity();
+    Activity activity = new Activity();
+    shadowOf(activity).setParent(parentActivity);
+    assertSame(parentActivity, activity.getParent());
+  }
+
+  @Test
+  public void getAndSetRequestedOrientation_shouldRemember() {
+    Activity activity = new Activity();
+    activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+    assertEquals(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, activity.getRequestedOrientation());
+  }
+
+  @Test
+  public void getAndSetRequestedOrientation_shouldDelegateToParentIfPresent() {
+    Activity parentActivity = new Activity();
+    Activity activity = new Activity();
+    shadowOf(activity).setParent(parentActivity);
+    parentActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+    assertEquals(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, activity.getRequestedOrientation());
+    activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
+    assertEquals(
+        ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE,
+        parentActivity.getRequestedOrientation());
+  }
+
+  @Test
+  public void shouldSupportIsTaskRoot() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    assertTrue(
+        activity.isTaskRoot()); // as implemented, Activities are considered task roots by default
+
+    shadowOf(activity).setIsTaskRoot(false);
+    assertFalse(activity.isTaskRoot());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void shouldSupportIsInMultiWindowMode() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    assertThat(activity.isInMultiWindowMode())
+        .isFalse(); // Activity is not in multi window mode by default.
+    shadowOf(activity).setInMultiWindowMode(true);
+
+    assertThat(activity.isInMultiWindowMode()).isTrue();
+  }
+
+  @Test
+  public void getPendingTransitionEnterAnimationResourceId_should() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.overridePendingTransition(15, 2);
+    assertThat(shadowOf(activity).getPendingTransitionEnterAnimationResourceId()).isEqualTo(15);
+  }
+
+  @Test
+  public void getPendingTransitionExitAnimationResourceId_should() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.overridePendingTransition(15, 2);
+    assertThat(shadowOf(activity).getPendingTransitionExitAnimationResourceId()).isEqualTo(2);
+  }
+
+  @Test
+  public void getActionBar_shouldWorkIfActivityHasAnAppropriateTheme() {
+    ActionBarThemedActivity myActivity =
+        Robolectric.buildActivity(ActionBarThemedActivity.class).create().get();
+    ActionBar actionBar = myActivity.getActionBar();
+    assertThat(actionBar).isNotNull();
+  }
+
+  public static class ActionBarThemedActivity extends Activity {
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      setTheme(android.R.style.Theme_Holo_Light);
+      setContentView(new LinearLayout(this));
+    }
+  }
+
+  @Test
+  public void canGetOptionsMenu() {
+    Activity activity = buildActivity(OptionsMenuActivity.class).create().visible().get();
+    Menu optionsMenu = shadowOf(activity).getOptionsMenu();
+    assertThat(optionsMenu).isNotNull();
+    assertThat(optionsMenu.getItem(0).getTitle().toString()).isEqualTo("Algebraic!");
+  }
+
+  @Test
+  public void canGetOptionsMenuWithActionMenu() {
+    ActionMenuActivity activity = buildActivity(ActionMenuActivity.class).create().visible().get();
+
+    SearchView searchView = activity.mSearchView;
+    // This blows up when ShadowPopupMenu existed.
+    searchView.setIconifiedByDefault(false);
+  }
+
+  @Test
+  public void canStartActivityFromFragment() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+
+    Intent intent = new Intent(Intent.ACTION_VIEW);
+    activity.startActivityFromFragment(new Fragment(), intent, 4);
+
+    ShadowActivity.IntentForResult intentForResult =
+        shadowOf(activity).getNextStartedActivityForResult();
+    assertThat(intentForResult.intent).isSameInstanceAs(intent);
+    assertThat(intentForResult.requestCode).isEqualTo(4);
+  }
+
+  @Test
+  public void canStartActivityFromFragment_withBundle() {
+    final Activity activity = buildActivity(Activity.class).create().get();
+
+    Bundle options = new Bundle();
+    Intent intent = new Intent(Intent.ACTION_VIEW);
+    activity.startActivityFromFragment(new Fragment(), intent, 5, options);
+
+    ShadowActivity.IntentForResult intentForResult =
+        shadowOf(activity).getNextStartedActivityForResult();
+    assertThat(intentForResult.intent).isSameInstanceAs(intent);
+    assertThat(intentForResult.options).isSameInstanceAs(options);
+    assertThat(intentForResult.requestCode).isEqualTo(5);
+  }
+
+  @Test
+  public void shouldUseAnimationOverride() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    Intent intent = new Intent(activity, OptionsMenuActivity.class);
+
+    Bundle animationBundle =
+        ActivityOptions.makeCustomAnimation(activity, R.anim.test_anim_1, R.anim.test_anim_1)
+            .toBundle();
+    activity.startActivity(intent, animationBundle);
+    assertThat(shadowOf(activity).getNextStartedActivityForResult().options)
+        .isSameInstanceAs(animationBundle);
+  }
+
+  @Test
+  public void shouldCallActivityLifecycleCallbacks() {
+    final List<String> transcript = new ArrayList<>();
+    final ActivityController<Activity> controller = buildActivity(Activity.class);
+    Application applicationContext = ApplicationProvider.getApplicationContext();
+    applicationContext.registerActivityLifecycleCallbacks(
+        new ActivityLifecycleCallbacks(transcript));
+
+    controller.create();
+    assertThat(transcript).containsExactly("onActivityCreated");
+    transcript.clear();
+
+    controller.start();
+    assertThat(transcript).containsExactly("onActivityStarted");
+    transcript.clear();
+
+    controller.resume();
+    assertThat(transcript).containsExactly("onActivityResumed");
+    transcript.clear();
+
+    controller.saveInstanceState(new Bundle());
+    assertThat(transcript).containsExactly("onActivitySaveInstanceState");
+    transcript.clear();
+
+    controller.pause();
+    assertThat(transcript).containsExactly("onActivityPaused");
+    transcript.clear();
+
+    controller.stop();
+    assertThat(transcript).containsExactly("onActivityStopped");
+    transcript.clear();
+
+    controller.destroy();
+    assertThat(transcript).containsExactly("onActivityDestroyed");
+  }
+
+  /** Activity for testing */
+  public static class ChildActivity extends Activity {}
+
+  /** Activity for testing */
+  public static class ParentActivity extends Activity {}
+
+  @Test
+  public void getParentActivityIntent() {
+    Activity activity = setupActivity(ChildActivity.class);
+
+    assertThat(activity.getParentActivityIntent().getComponent().getClassName())
+        .isEqualTo(ParentActivity.class.getName());
+  }
+
+  @Test
+  public void getCallingActivity_defaultsToNull() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    assertNull(activity.getCallingActivity());
+  }
+
+  @Test
+  public void getCallingActivity_returnsSetValue() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    ComponentName componentName = new ComponentName("com.example.package", "SomeActivity");
+
+    shadowOf(activity).setCallingActivity(componentName);
+
+    assertEquals(componentName, activity.getCallingActivity());
+  }
+
+  @Test
+  public void getCallingActivity_returnsValueSetInPackage() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    String packageName = "com.example.package";
+
+    shadowOf(activity).setCallingPackage(packageName);
+
+    assertEquals(packageName, activity.getCallingActivity().getPackageName());
+  }
+
+  @Test
+  public void getCallingActivity_notOverwrittenByPackage() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    ComponentName componentName = new ComponentName("com.example.package", "SomeActivity");
+
+    shadowOf(activity).setCallingActivity(componentName);
+    shadowOf(activity).setCallingPackage(componentName.getPackageName());
+
+    assertEquals(componentName, activity.getCallingActivity());
+  }
+
+  @Test
+  public void getCallingPackage_defaultsToNull() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    assertNull(activity.getCallingPackage());
+  }
+
+  @Test
+  public void getCallingPackage_returnsSetValue() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    String packageName = "com.example.package";
+
+    shadowOf(activity).setCallingPackage(packageName);
+
+    assertEquals(packageName, activity.getCallingPackage());
+  }
+
+  @Test
+  public void getCallingPackage_returnsValueSetInActivity() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    ComponentName componentName = new ComponentName("com.example.package", "SomeActivity");
+
+    shadowOf(activity).setCallingActivity(componentName);
+
+    assertEquals(componentName.getPackageName(), activity.getCallingPackage());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void lockTask() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    assertThat(shadowOf(activity).isLockTask()).isFalse();
+
+    activity.startLockTask();
+    assertThat(shadowOf(activity).isLockTask()).isTrue();
+
+    activity.stopLockTask();
+    assertThat(shadowOf(activity).isLockTask()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPermission_shouldReturnRequestedPermissions() {
+    // GIVEN
+    String[] permission = {Manifest.permission.CAMERA};
+    int requestCode = 1007;
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    // WHEN
+    activity.requestPermissions(permission, requestCode);
+
+    // THEN
+    ShadowActivity.PermissionsRequest request = shadowOf(activity).getLastRequestedPermission();
+    assertThat(request.requestCode).isEqualTo(requestCode);
+    assertThat(request.requestedPermissions).isEqualTo(permission);
+  }
+
+  @Test
+  public void getLastIntentSenderRequest() throws IntentSender.SendIntentException {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    int requestCode = 108;
+    Intent intent = new Intent("action");
+    Intent fillInIntent = new Intent();
+    PendingIntent pendingIntent = PendingIntent.getActivity(systemContext, requestCode, intent, 0);
+
+    Bundle options = new Bundle();
+    int flagsMask = 1;
+    int flagsValues = 2;
+    int extraFlags = 3;
+    IntentSender intentSender = pendingIntent.getIntentSender();
+    activity.startIntentSenderForResult(
+        intentSender, requestCode, fillInIntent, flagsMask, flagsValues, extraFlags, options);
+
+    IntentSenderRequest lastIntentSenderRequest = shadowOf(activity).getLastIntentSenderRequest();
+    assertThat(lastIntentSenderRequest.intentSender).isEqualTo(intentSender);
+    assertThat(lastIntentSenderRequest.fillInIntent).isEqualTo(fillInIntent);
+    assertThat(lastIntentSenderRequest.requestCode).isEqualTo(requestCode);
+    assertThat(lastIntentSenderRequest.flagsMask).isEqualTo(flagsMask);
+    assertThat(lastIntentSenderRequest.flagsValues).isEqualTo(flagsValues);
+    assertThat(lastIntentSenderRequest.extraFlags).isEqualTo(extraFlags);
+    assertThat(lastIntentSenderRequest.options).isEqualTo(options);
+  }
+
+  @Test
+  public void getLastIntentSenderRequest_sendWithRequestCode()
+      throws IntentSender.SendIntentException {
+    TranscriptActivity activity = Robolectric.setupActivity(TranscriptActivity.class);
+    int requestCode = 108;
+    Intent intent = new Intent("action");
+    Intent fillInIntent = new Intent();
+    PendingIntent pendingIntent =
+        PendingIntent.getActivity(getApplication(), requestCode, intent, 0);
+
+    IntentSender intentSender = pendingIntent.getIntentSender();
+    activity.startIntentSenderForResult(intentSender, requestCode, fillInIntent, 0, 0, 0, null);
+
+    shadowOf(activity).receiveResult(intent, Activity.RESULT_OK, intent);
+    assertThat(activity.transcript)
+        .containsExactly(
+            "onActivityResult called with requestCode 108, resultCode -1, intent data null");
+  }
+
+  @Test
+  public void startIntentSenderForResult_throwsException() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    shadowOf(activity).setThrowIntentSenderException(true);
+    IntentSender intentSender =
+        PendingIntent.getActivity(systemContext, 0, new Intent("action"), 0).getIntentSender();
+
+    try {
+      activity.startIntentSenderForResult(intentSender, 0, null, 0, 0, 0);
+      fail("An IntentSender.SendIntentException should have been thrown");
+    } catch (IntentSender.SendIntentException e) {
+      // NOP
+    }
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void reportFullyDrawn_reported() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.reportFullyDrawn();
+    assertThat(shadowOf(activity).getReportFullyDrawn()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void enterPip() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    assertThat(activity.isInPictureInPictureMode()).isFalse();
+    activity.enterPictureInPictureMode();
+    assertThat(activity.isInPictureInPictureMode()).isTrue();
+    activity.moveTaskToBack(false);
+    assertThat(activity.isInPictureInPictureMode()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void enterPipWithParams() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    assertThat(activity.isInPictureInPictureMode()).isFalse();
+    activity.enterPictureInPictureMode(new PictureInPictureParams.Builder().build());
+    assertThat(activity.isInPictureInPictureMode()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void initializeVoiceInteractor_succeeds() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    shadowOf(activity).initializeVoiceInteractor();
+    assertThat(activity.getVoiceInteractor()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void buildActivity_noOptionsBundle_launchesOnDefaultDisplay() {
+    Activity activity = Robolectric.buildActivity(Activity.class, null).setup().get();
+
+    assertThat(activity.getWindowManager().getDefaultDisplay().getDisplayId())
+        .isEqualTo(Display.DEFAULT_DISPLAY);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void buildActivity_optionBundleWithNoDisplaySet_launchesOnDefaultDisplay() {
+    Activity activity =
+        Robolectric.buildActivity(Activity.class, null, ActivityOptions.makeBasic().toBundle())
+            .setup()
+            .get();
+
+    assertThat(activity.getWindowManager().getDefaultDisplay().getDisplayId())
+        .isEqualTo(Display.DEFAULT_DISPLAY);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void buildActivity_optionBundleWithDefaultDisplaySet_launchesOnDefaultDisplay() {
+    Activity activity =
+        Robolectric.buildActivity(
+                Activity.class,
+                null,
+                ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY).toBundle())
+            .setup()
+            .get();
+
+    assertThat(activity.getWindowManager().getDefaultDisplay().getDisplayId())
+        .isEqualTo(Display.DEFAULT_DISPLAY);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void buildActivity_optionBundleWithValidNonDefaultDisplaySet_launchesOnSpecifiedDisplay() {
+    int displayId = ShadowDisplayManager.addDisplay("");
+
+    Activity activity =
+        Robolectric.buildActivity(
+                Activity.class,
+                null,
+                ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
+            .setup()
+            .get();
+
+    assertThat(activity.getWindowManager().getDefaultDisplay().getDisplayId())
+        .isNotEqualTo(Display.DEFAULT_DISPLAY);
+    assertThat(activity.getWindowManager().getDefaultDisplay().getDisplayId()).isEqualTo(displayId);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void buildActivity_optionBundleWithInvalidNonDefaultDisplaySet_launchesOnDefaultDisplay() {
+    Activity activity =
+        Robolectric.buildActivity(
+                Activity.class,
+                null,
+                ActivityOptions.makeBasic().setLaunchDisplayId(123).toBundle())
+            .setup()
+            .get();
+
+    assertThat(activity.getWindowManager().getDefaultDisplay().getDisplayId())
+        .isEqualTo(Display.DEFAULT_DISPLAY);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void callOnGetDirectActions_succeeds() {
+    ActivityController<TestActivity> controller = Robolectric.buildActivity(TestActivity.class);
+    TestActivity testActivity = controller.setup().get();
+    Consumer<List<DirectAction>> testConsumer =
+        (directActions) -> {
+          assertThat(directActions.size()).isEqualTo(1);
+          DirectAction action = directActions.get(0);
+          assertThat(action.getId()).isEqualTo(testActivity.getDirectActionForTesting().getId());
+          ComponentName componentName = action.getExtras().getParcelable("componentName");
+          assertThat(componentName.compareTo(testActivity.getComponentName())).isEqualTo(0);
+        };
+    shadowOf(testActivity).callOnGetDirectActions(new CancellationSignal(), testConsumer);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void callOnGetDirectActions_malformedDirectAction_fails() {
+    ActivityController<TestActivity> controller = Robolectric.buildActivity(TestActivity.class);
+    TestActivity testActivity = controller.setup().get();
+    // malformed DirectAction has missing LocusId
+    testActivity.setReturnMalformedDirectAction(true);
+    assertThrows(
+        NullPointerException.class,
+        () -> {
+          shadowOf(testActivity).callOnGetDirectActions(new CancellationSignal(), (unused) -> {});
+        });
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void splashScreen_setThemeId_succeeds() {
+    int splashScreenThemeId = 173;
+    Activity activity = Robolectric.buildActivity(Activity.class, null).setup().get();
+
+    activity.getSplashScreen().setSplashScreenTheme(splashScreenThemeId);
+
+    RoboSplashScreen roboSplashScreen = (RoboSplashScreen) activity.getSplashScreen();
+    assertThat(roboSplashScreen.getSplashScreenTheme()).isEqualTo(splashScreenThemeId);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void splashScreen_instanceOfRoboSplashScreen_succeeds() {
+    Activity activity = Robolectric.buildActivity(Activity.class, null).setup().get();
+
+    assertThat(activity.getSplashScreen()).isInstanceOf(RoboSplashScreen.class);
+  }
+
+  @Test
+  public void applicationWindow_hasCorrectWindowTokens() {
+    Activity activity = Robolectric.buildActivity(TestActivity.class).setup().get();
+    View activityView = activity.getWindow().getDecorView();
+    WindowManager.LayoutParams activityLp =
+        (WindowManager.LayoutParams) activityView.getLayoutParams();
+
+    View windowView = new View(activity);
+    WindowManager.LayoutParams windowViewLp = new WindowManager.LayoutParams();
+    windowViewLp.type = WindowManager.LayoutParams.TYPE_APPLICATION;
+    ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE))
+        .addView(windowView, windowViewLp);
+    ShadowLooper.idleMainLooper();
+
+    assertThat(activityLp.token).isNotNull();
+    assertThat(windowViewLp.token).isEqualTo(activityLp.token);
+  }
+
+  @Test
+  public void subWindow_hasCorrectWindowTokens() {
+    Activity activity = Robolectric.buildActivity(TestActivity.class).setup().get();
+    View activityView = activity.getWindow().getDecorView();
+    WindowManager.LayoutParams activityLp =
+        (WindowManager.LayoutParams) activityView.getLayoutParams();
+
+    View windowView = new View(activity);
+    WindowManager.LayoutParams windowViewLp = new WindowManager.LayoutParams();
+    windowViewLp.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;
+    ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE))
+        .addView(windowView, windowViewLp);
+    ShadowLooper.idleMainLooper();
+
+    assertThat(activityLp.token).isNotNull();
+    assertThat(windowViewLp.token).isEqualTo(activityView.getWindowToken());
+    assertThat(windowView.getApplicationWindowToken()).isEqualTo(activityView.getWindowToken());
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void getDisplay_succeeds() {
+    try {
+      System.setProperty("robolectric.createActivityContexts", "true");
+
+      Activity activity = Robolectric.setupActivity(Activity.class);
+      assertThat(activity.getDisplay()).isNotNull();
+    } finally {
+      System.setProperty("robolectric.createActivityContexts", "false");
+    }
+  }
+
+  /////////////////////////////
+
+  private static class DialogCreatingActivity extends Activity {
+    @Override
+    protected Dialog onCreateDialog(int id) {
+      return new Dialog(this);
+    }
+  }
+
+  private static class OptionsMenuActivity extends Activity {
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      // Requesting the action bar causes it to be properly initialized when the Activity becomes
+      // visible
+      getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
+      setContentView(new FrameLayout(this));
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+      super.onCreateOptionsMenu(menu);
+      menu.add("Algebraic!");
+      return true;
+    }
+  }
+
+  private static class ActionMenuActivity extends Activity {
+    SearchView mSearchView;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
+      setContentView(new FrameLayout(this));
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+      MenuInflater inflater = getMenuInflater();
+      inflater.inflate(R.menu.action_menu, menu);
+
+      MenuItem searchMenuItem = menu.findItem(R.id.action_search);
+      mSearchView = (SearchView) searchMenuItem.getActionView();
+      return true;
+    }
+  }
+
+  private static class DialogLifeCycleActivity extends Activity {
+    public boolean createdDialog = false;
+    public boolean preparedDialog = false;
+    public boolean preparedDialogWithBundle = false;
+    public Dialog dialog = null;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      setContentView(new FrameLayout(this));
+    }
+
+    @Override
+    protected void onDestroy() {
+      super.onDestroy();
+    }
+
+    @Override
+    protected Dialog onCreateDialog(int id) {
+      createdDialog = true;
+      return dialog;
+    }
+
+    @Override
+    protected void onPrepareDialog(int id, Dialog dialog) {
+      preparedDialog = true;
+    }
+
+    @Override
+    protected void onPrepareDialog(int id, Dialog dialog, Bundle bundle) {
+      preparedDialogWithBundle = true;
+    }
+  }
+
+  private static class ActivityWithOnCreateDialog extends Activity {
+    boolean onCreateDialogWasCalled = false;
+
+    @Override
+    protected Dialog onCreateDialog(int id) {
+      onCreateDialogWasCalled = true;
+      return new Dialog(this);
+    }
+  }
+
+  private static class ActivityWithContentChangedTranscript extends Activity {
+    private List<String> transcript;
+
+    @Override
+    public void onContentChanged() {
+      transcript.add(
+          "onContentChanged was called; title is \""
+              + shadowOf((View) findViewById(R.id.title)).innerText()
+              + "\"");
+    }
+
+    private void setTranscript(List<String> transcript) {
+      this.transcript = transcript;
+    }
+  }
+
+  private static class OnBackPressedActivity extends Activity {
+    public boolean onBackPressedCalled = false;
+
+    @Override
+    public void onBackPressed() {
+      onBackPressedCalled = true;
+      super.onBackPressed();
+    }
+  }
+
+  private static class ActivityLifecycleCallbacks
+      implements Application.ActivityLifecycleCallbacks {
+    private final List<String> transcript;
+
+    public ActivityLifecycleCallbacks(List<String> transcript) {
+      this.transcript = transcript;
+    }
+
+    @Override
+    public void onActivityCreated(Activity activity, Bundle bundle) {
+      transcript.add("onActivityCreated");
+    }
+
+    @Override
+    public void onActivityStarted(Activity activity) {
+      transcript.add("onActivityStarted");
+    }
+
+    @Override
+    public void onActivityResumed(Activity activity) {
+      transcript.add("onActivityResumed");
+    }
+
+    @Override
+    public void onActivityPaused(Activity activity) {
+      transcript.add("onActivityPaused");
+    }
+
+    @Override
+    public void onActivityStopped(Activity activity) {
+      transcript.add("onActivityStopped");
+    }
+
+    @Override
+    public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
+      transcript.add("onActivitySaveInstanceState");
+    }
+
+    @Override
+    public void onActivityDestroyed(Activity activity) {
+      transcript.add("onActivityDestroyed");
+    }
+  }
+
+  /** Activity for testing */
+  public static class TestActivityWithAnotherTheme
+      extends org.robolectric.shadows.testing.TestActivity {}
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityThreadTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityThreadTest.java
new file mode 100644
index 0000000..6969136
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowActivityThreadTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.ActivityThread;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+
+/** Tests for the ShadowActivityThread class. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowActivityThreadTest {
+
+  @LazyApplication(LazyLoad.ON)
+  @Test
+  public void currentApplicationIsLazyLoaded() {
+    RuntimeEnvironment.application = null;
+    assertThat(ShadowActivityThread.currentApplication()).isNotNull();
+  }
+
+  @Test
+  public void getApplication() {
+    ActivityThread activityThread = (ActivityThread) ShadowActivityThread.currentActivityThread();
+    assertThat(activityThread.getApplication()).isEqualTo(RuntimeEnvironment.getApplication());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAlarmManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAlarmManagerTest.java
new file mode 100644
index 0000000..c8e1152
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAlarmManagerTest.java
@@ -0,0 +1,460 @@
+package org.robolectric.shadows;
+
+import static android.app.AlarmManager.INTERVAL_HOUR;
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.AlarmManager;
+import android.app.AlarmManager.AlarmClockInfo;
+import android.app.AlarmManager.OnAlarmListener;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Date;
+import java.util.TimeZone;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAlarmManagerTest {
+
+  private Context context;
+  private Activity activity;
+  private AlarmManager alarmManager;
+  private ShadowAlarmManager shadowAlarmManager;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+    shadowAlarmManager = shadowOf(alarmManager);
+    activity = Robolectric.setupActivity(Activity.class);
+
+    TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("America/Los_Angeles");
+  }
+
+  @Test
+  public void setTimeZone_UTC_acceptAlways() {
+    alarmManager.setTimeZone("UTC");
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("UTC");
+  }
+
+  @Test
+  public void setTimeZone_OlsonTimeZone_acceptAlways() {
+    alarmManager.setTimeZone("America/Sao_Paulo");
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("America/Sao_Paulo");
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void setTimeZone_abbreviateTimeZone_ignore() {
+    try {
+      alarmManager.setTimeZone("PST");
+      fail("IllegalArgumentException not thrown");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("America/Los_Angeles");
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.LOLLIPOP_MR1)
+  public void setTimeZone_abbreviateTimezoneId_accept() {
+    alarmManager.setTimeZone("PST");
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("PST");
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void setTimeZone_invalidTimeZone_ignore() {
+    try {
+      alarmManager.setTimeZone("-07:00");
+      fail("IllegalArgumentException not thrown");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("America/Los_Angeles");
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.LOLLIPOP_MR1)
+  public void setTimeZone_invalidTimeZone_fallbackToGMT() {
+    alarmManager.setTimeZone("-07:00");
+    assertThat(TimeZone.getDefault().getID()).isEqualTo("GMT");
+  }
+
+  @Test
+  public void set_shouldRegisterAlarm() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    alarmManager.set(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+
+    ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm();
+    assertThat(scheduledAlarm).isNotNull();
+    assertThat(scheduledAlarm.allowWhileIdle).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void set_shouldRegisterAlarm_forApi24() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    OnAlarmListener listener = () -> {};
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME, 0, "tag", listener, null);
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setAndAllowWhileIdle_shouldRegisterAlarm() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    alarmManager.setAndAllowWhileIdle(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+
+    ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm();
+    assertThat(scheduledAlarm).isNotNull();
+    assertThat(scheduledAlarm.allowWhileIdle).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setExactAndAllowWhileIdle_shouldRegisterAlarm() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    alarmManager.setExactAndAllowWhileIdle(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+
+    ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm();
+    assertThat(scheduledAlarm).isNotNull();
+    assertThat(scheduledAlarm.allowWhileIdle).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void setExact_shouldRegisterAlarm_forApi19() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    alarmManager.setExact(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setExact_shouldRegisterAlarm_forApi124() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    OnAlarmListener listener = () -> {};
+    alarmManager.setExact(AlarmManager.ELAPSED_REALTIME, 0, "tag", listener, null);
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void setWindow_shouldRegisterAlarm_forApi19() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    alarmManager.setWindow(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        1,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setWindow_shouldRegisterAlarm_forApi24() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    OnAlarmListener listener = () -> {};
+    alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME, 0, 1, "tag", listener, null);
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNotNull();
+  }
+
+  @Test
+  public void setRepeating_shouldRegisterAlarm() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    alarmManager.setRepeating(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        INTERVAL_HOUR,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNotNull();
+  }
+
+  @Test
+  public void set_shouldReplaceAlarmsWithSameIntentReceiver() {
+    alarmManager.set(
+        AlarmManager.ELAPSED_REALTIME,
+        500,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    alarmManager.set(
+        AlarmManager.ELAPSED_REALTIME,
+        1000,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+  }
+
+  @Test
+  public void set_shouldReplaceDuplicates() {
+    alarmManager.set(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    alarmManager.set(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+  }
+
+  @Test
+  public void setRepeating_shouldReplaceDuplicates() {
+    alarmManager.setRepeating(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        INTERVAL_HOUR,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    alarmManager.setRepeating(
+        AlarmManager.ELAPSED_REALTIME,
+        0,
+        INTERVAL_HOUR,
+        PendingIntent.getActivity(activity, 0, new Intent(activity, activity.getClass()), 0));
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+  }
+
+  @Test
+  @SuppressWarnings("JavaUtilDate")
+  public void shouldSupportGetNextScheduledAlarm() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+
+    long now = new Date().getTime();
+    Intent intent = new Intent(activity, activity.getClass());
+    PendingIntent pendingIntent = PendingIntent.getActivity(activity, 0, intent, 0);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME, now, pendingIntent);
+
+    ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm();
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    assertScheduledAlarm(now, pendingIntent, scheduledAlarm);
+  }
+
+  @Test
+  @SuppressWarnings("JavaUtilDate")
+  public void getNextScheduledAlarm_shouldReturnRepeatingAlarms() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+
+    long now = new Date().getTime();
+    Intent intent = new Intent(activity, activity.getClass());
+    PendingIntent pendingIntent = PendingIntent.getActivity(activity, 0, intent, 0);
+    alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME, now, INTERVAL_HOUR, pendingIntent);
+
+    ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm();
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+    assertRepeatingScheduledAlarm(now, INTERVAL_HOUR, pendingIntent, scheduledAlarm);
+  }
+
+  @Test
+  @SuppressWarnings("JavaUtilDate")
+  public void peekNextScheduledAlarm_shouldReturnNextAlarm() {
+    assertThat(shadowAlarmManager.getNextScheduledAlarm()).isNull();
+
+    long now = new Date().getTime();
+    Intent intent = new Intent(activity, activity.getClass());
+    PendingIntent pendingIntent = PendingIntent.getActivity(activity, 0, intent, 0);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME, now, pendingIntent);
+
+    ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.peekNextScheduledAlarm();
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm()).isNotNull();
+    assertScheduledAlarm(now, pendingIntent, scheduledAlarm);
+  }
+
+  @Test
+  public void cancel_removesMatchingPendingIntents() {
+    Intent intent = new Intent(context, String.class);
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(context, 0, intent, FLAG_UPDATE_CURRENT);
+    alarmManager.set(AlarmManager.RTC, 1337, pendingIntent);
+
+    Intent intent2 = new Intent(context, Integer.class);
+    PendingIntent pendingIntent2 =
+        PendingIntent.getBroadcast(context, 0, intent2, FLAG_UPDATE_CURRENT);
+    alarmManager.set(AlarmManager.RTC, 1337, pendingIntent2);
+
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(2);
+
+    Intent intent3 = new Intent(context, String.class);
+    PendingIntent pendingIntent3 =
+        PendingIntent.getBroadcast(context, 0, intent3, FLAG_UPDATE_CURRENT);
+    alarmManager.cancel(pendingIntent3);
+
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+  }
+
+  @Test
+  public void cancel_removesMatchingPendingIntentsWithActions() {
+    Intent newIntent = new Intent("someAction");
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, newIntent, 0);
+
+    alarmManager.set(AlarmManager.RTC, 1337, pendingIntent);
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+
+    alarmManager.cancel(PendingIntent.getBroadcast(context, 0, new Intent("anotherAction"), 0));
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+
+    alarmManager.cancel(PendingIntent.getBroadcast(context, 0, new Intent("someAction"), 0));
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(0);
+  }
+
+  @Test
+  public void schedule_useRequestCodeToMatchExistingPendingIntents() {
+    Intent intent = new Intent("ACTION!");
+    PendingIntent pI = PendingIntent.getService(context, 1, intent, 0);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 10, pI);
+
+    PendingIntent pI2 = PendingIntent.getService(context, 2, intent, 0);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 10, pI2);
+
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(2);
+  }
+
+  @Test
+  public void cancel_useRequestCodeToMatchExistingPendingIntents() {
+    Intent intent = new Intent("ACTION!");
+    PendingIntent pI = PendingIntent.getService(context, 1, intent, 0);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 10, pI);
+
+    PendingIntent pI2 = PendingIntent.getService(context, 2, intent, 0);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 10, pI2);
+
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(2);
+
+    alarmManager.cancel(pI);
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(1);
+    assertThat(shadowAlarmManager.getNextScheduledAlarm().operation).isEqualTo(pI2);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void cancel_removesMatchingListeners() {
+    Intent intent = new Intent("ACTION!");
+    PendingIntent pI = PendingIntent.getService(context, 1, intent, 0);
+    OnAlarmListener listener1 = () -> {};
+    OnAlarmListener listener2 = () -> {};
+    Handler handler = new Handler();
+
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 20, "tag", listener1, handler);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 30, "tag", listener2, handler);
+    alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, 40, pI);
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(3);
+
+    alarmManager.cancel(listener1);
+    assertThat(shadowAlarmManager.getScheduledAlarms()).hasSize(2);
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm().onAlarmListener).isEqualTo(listener2);
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm().handler).isEqualTo(handler);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getNextAlarmClockInfo() {
+    assertThat(alarmManager.getNextAlarmClock()).isNull();
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm()).isNull();
+
+    // Schedule an alarm.
+    PendingIntent show = PendingIntent.getBroadcast(context, 0, new Intent("showAction"), 0);
+    PendingIntent operation = PendingIntent.getBroadcast(context, 0, new Intent("opAction"), 0);
+    AlarmClockInfo info = new AlarmClockInfo(1000, show);
+    alarmManager.setAlarmClock(info, operation);
+
+    AlarmClockInfo next = alarmManager.getNextAlarmClock();
+    assertThat(next).isNotNull();
+    assertThat(next.getTriggerTime()).isEqualTo(1000);
+    assertThat(next.getShowIntent()).isSameInstanceAs(show);
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm().operation).isSameInstanceAs(operation);
+
+    // Schedule another alarm sooner.
+    PendingIntent show2 = PendingIntent.getBroadcast(context, 0, new Intent("showAction2"), 0);
+    PendingIntent operation2 = PendingIntent.getBroadcast(context, 0, new Intent("opAction2"), 0);
+    AlarmClockInfo info2 = new AlarmClockInfo(500, show2);
+    alarmManager.setAlarmClock(info2, operation2);
+
+    next = alarmManager.getNextAlarmClock();
+    assertThat(next).isNotNull();
+    assertThat(next.getTriggerTime()).isEqualTo(500);
+    assertThat(next.getShowIntent()).isSameInstanceAs(show2);
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm().operation).isSameInstanceAs(operation2);
+
+    // Remove the soonest alarm.
+    alarmManager.cancel(operation2);
+
+    next = alarmManager.getNextAlarmClock();
+    assertThat(next).isNotNull();
+    assertThat(next.getTriggerTime()).isEqualTo(1000);
+    assertThat(next.getShowIntent()).isSameInstanceAs(show);
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm().operation).isSameInstanceAs(operation);
+
+    // Remove the sole alarm.
+    alarmManager.cancel(operation);
+
+    assertThat(alarmManager.getNextAlarmClock()).isNull();
+    assertThat(shadowAlarmManager.peekNextScheduledAlarm()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void canScheduleExactAlarms_default_returnsTrue() {
+    assertThat(alarmManager.canScheduleExactAlarms()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void canScheduleExactAlarms_setCanScheduleExactAlarms_returnsTrue() {
+    ShadowAlarmManager.setCanScheduleExactAlarms(true);
+
+    assertThat(alarmManager.canScheduleExactAlarms()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void canScheduleExactAlarms_setCannotScheduleExactAlarms_returnsFalse() {
+    ShadowAlarmManager.setCanScheduleExactAlarms(false);
+
+    assertThat(alarmManager.canScheduleExactAlarms()).isFalse();
+  }
+
+  private void assertScheduledAlarm(
+      long now, PendingIntent pendingIntent, ShadowAlarmManager.ScheduledAlarm scheduledAlarm) {
+    assertRepeatingScheduledAlarm(now, 0L, pendingIntent, scheduledAlarm);
+  }
+
+  private void assertRepeatingScheduledAlarm(
+      long now,
+      long interval,
+      PendingIntent pendingIntent,
+      ShadowAlarmManager.ScheduledAlarm scheduledAlarm) {
+    assertThat(scheduledAlarm).isNotNull();
+    assertThat(scheduledAlarm.operation).isNotNull();
+    assertThat(scheduledAlarm.operation).isSameInstanceAs(pendingIntent);
+    assertThat(scheduledAlarm.type).isEqualTo(AlarmManager.ELAPSED_REALTIME);
+    assertThat(scheduledAlarm.triggerAtTime).isEqualTo(now);
+    assertThat(scheduledAlarm.interval).isEqualTo(interval);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAlertDialogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAlertDialogTest.java
new file mode 100644
index 0000000..00c2327
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAlertDialogTest.java
@@ -0,0 +1,324 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Application;
+import android.app.Dialog;
+import android.content.ContextWrapper;
+import android.content.DialogInterface;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.android.CustomView;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAlertDialogTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void testBuilder() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setTitle("title").setMessage("message");
+    builder.setCancelable(true);
+    AlertDialog alert = builder.create();
+    alert.show();
+
+    assertThat(alert.isShowing()).isTrue();
+
+    ShadowAlertDialog shadowAlertDialog = shadowOf(alert);
+    assertThat(shadowAlertDialog.getTitle().toString()).isEqualTo("title");
+    assertThat(shadowAlertDialog.getMessage().toString()).isEqualTo("message");
+    assertThat(shadowAlertDialog.isCancelable()).isTrue();
+    assertThat(shadowOf(ShadowAlertDialog.getLatestAlertDialog()))
+        .isSameInstanceAs(shadowAlertDialog);
+    assertThat(ShadowAlertDialog.getLatestAlertDialog()).isSameInstanceAs(alert);
+  }
+
+  @Test
+  public void nullTitleAndMessageAreOkay() {
+    AlertDialog.Builder builder =
+        new AlertDialog.Builder(getApplication()) //
+            .setTitle(null) //
+            .setMessage(null);
+    ShadowAlertDialog shadowAlertDialog = shadowOf(builder.create());
+    assertThat(shadowAlertDialog.getTitle().toString()).isEqualTo("");
+    assertThat(shadowAlertDialog.getMessage().toString()).isEqualTo("");
+  }
+
+  @Test
+  public void getLatestAlertDialog_shouldReturnARealAlertDialog() {
+    assertThat(ShadowAlertDialog.getLatestAlertDialog()).isNull();
+
+    AlertDialog dialog = new AlertDialog.Builder(getApplication()).show();
+    assertThat(ShadowAlertDialog.getLatestAlertDialog()).isSameInstanceAs(dialog);
+  }
+
+  @Test
+  public void shouldOnlyCreateRequestedButtons() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setPositiveButton("OK", null);
+    AlertDialog dialog = builder.create();
+    dialog.show();
+    assertThat(dialog.getButton(AlertDialog.BUTTON_POSITIVE).getVisibility())
+        .isEqualTo(View.VISIBLE);
+    assertThat(dialog.getButton(AlertDialog.BUTTON_NEGATIVE).getVisibility()).isEqualTo(View.GONE);
+  }
+
+  @Test
+  public void shouldAllowNullButtonListeners() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setPositiveButton("OK", null);
+    AlertDialog dialog = builder.create();
+    dialog.show();
+    ShadowView.clickOn(dialog.getButton(AlertDialog.BUTTON_POSITIVE));
+  }
+
+  @Test
+  public void testSetMessageAfterCreation() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setTitle("title").setMessage("message");
+    AlertDialog alert = builder.create();
+
+    ShadowAlertDialog shadowAlertDialog = shadowOf(alert);
+    assertThat(shadowAlertDialog.getMessage().toString()).isEqualTo("message");
+
+    alert.setMessage("new message");
+    assertThat(shadowAlertDialog.getMessage().toString()).isEqualTo("new message");
+
+    alert.setMessage(null);
+    assertThat(shadowAlertDialog.getMessage().toString()).isEqualTo("");
+  }
+
+  @Test
+  public void shouldSetMessageFromResourceId() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setTitle("title").setMessage(R.string.hello);
+
+    AlertDialog alert = builder.create();
+    ShadowAlertDialog shadowAlertDialog = shadowOf(alert);
+    assertThat(shadowAlertDialog.getMessage().toString()).isEqualTo("Hello");
+  }
+
+  @Test
+  public void shouldSetView() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    EditText view = new EditText(getApplication());
+    builder.setView(view);
+
+    AlertDialog alert = builder.create();
+    assertThat(shadowOf(alert).getView()).isEqualTo(view);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldSetView_withLayoutId() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setView(R.layout.custom_layout);
+
+    AlertDialog alert = builder.create();
+    View view = shadowOf(alert).getView();
+    assertThat(view.getClass()).isEqualTo(CustomView.class);
+  }
+
+  @Test
+  public void shouldSetCustomTitleView() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    View view = new View(getApplication());
+    assertThat(builder.setCustomTitle(view)).isSameInstanceAs(builder);
+
+    AlertDialog alert = builder.create();
+    assertThat(shadowOf(alert).getCustomTitleView()).isEqualTo(view);
+  }
+
+  @Test
+  public void clickingPositiveButtonDismissesDialog() {
+    AlertDialog alertDialog =
+        new AlertDialog.Builder(getApplication()).setPositiveButton("Positive", null).create();
+    alertDialog.show();
+
+    assertTrue(alertDialog.isShowing());
+    ShadowView.clickOn(alertDialog.getButton(AlertDialog.BUTTON_POSITIVE));
+    assertFalse(alertDialog.isShowing());
+  }
+
+  @Test
+  public void clickingNeutralButtonDismissesDialog() {
+    AlertDialog alertDialog =
+        new AlertDialog.Builder(getApplication())
+            .setNeutralButton("Neutral", (dialog, which) -> {})
+            .create();
+    alertDialog.show();
+
+    assertTrue(alertDialog.isShowing());
+    ShadowView.clickOn(alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL));
+    assertFalse(alertDialog.isShowing());
+  }
+
+  @Test
+  public void clickingNegativeButtonDismissesDialog() {
+    AlertDialog alertDialog =
+        new AlertDialog.Builder(getApplication())
+            .setNegativeButton("Negative", (dialog, which) -> {})
+            .create();
+    alertDialog.show();
+
+    assertTrue(alertDialog.isShowing());
+    ShadowView.clickOn(alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE));
+    assertFalse(alertDialog.isShowing());
+  }
+
+  @Test
+  public void testBuilderWithItemArrayViaResourceId() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(new ContextWrapper(context));
+
+    builder.setTitle("title");
+    builder.setItems(R.array.alertDialogTestItems, new TestDialogOnClickListener());
+    AlertDialog alert = builder.create();
+    alert.show();
+
+    assertThat(alert.isShowing()).isTrue();
+
+    ShadowAlertDialog shadowAlertDialog = shadowOf(alert);
+    assertThat(shadowAlertDialog.getTitle().toString()).isEqualTo("title");
+    assertThat(shadowAlertDialog.getItems().length).isEqualTo(2);
+    assertThat(shadowAlertDialog.getItems()[0].toString()).isEqualTo("Aloha");
+    assertThat(shadowOf(ShadowAlertDialog.getLatestAlertDialog()))
+        .isSameInstanceAs(shadowAlertDialog);
+    assertThat(ShadowAlertDialog.getLatestAlertDialog()).isSameInstanceAs(alert);
+  }
+
+  @Test
+  public void testBuilderWithAdapter() {
+    List<Integer> list = new ArrayList<>();
+    list.add(99);
+    list.add(88);
+    list.add(77);
+    ArrayAdapter<Integer> adapter = new ArrayAdapter<>(context, R.layout.main, R.id.title, list);
+
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setSingleChoiceItems(adapter, -1, (dialog, item) -> dialog.dismiss());
+    AlertDialog alert = builder.create();
+    alert.show();
+
+    assertTrue(alert.isShowing());
+    ShadowAlertDialog shadowAlertDialog = shadowOf(alert);
+    assertThat(shadowAlertDialog.getAdapter().getCount()).isEqualTo(3);
+    assertThat(shadowAlertDialog.getAdapter().getItem(0)).isEqualTo(99);
+  }
+
+  @Test
+  public void show_setsLatestAlertDialogAndLatestDialog() {
+    AlertDialog alertDialog = new AlertDialog.Builder(context).create();
+    assertNull(ShadowDialog.getLatestDialog());
+    assertNull(ShadowAlertDialog.getLatestAlertDialog());
+
+    alertDialog.show();
+
+    assertEquals(alertDialog, ShadowDialog.getLatestDialog());
+    assertEquals(alertDialog, ShadowAlertDialog.getLatestAlertDialog());
+  }
+
+  @Test
+  public void shouldCallTheClickListenerOfTheCheckedAdapterInASingleChoiceDialog() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(new ContextWrapper(context));
+
+    TestDialogOnClickListener listener = new TestDialogOnClickListener();
+    List<Integer> list = new ArrayList<>();
+    list.add(1);
+    list.add(2);
+    list.add(3);
+    ArrayAdapter<Integer> arrayAdapter =
+        new ArrayAdapter<>(context, R.layout.main, R.id.title, list);
+    builder.setSingleChoiceItems(arrayAdapter, 1, listener);
+
+    AlertDialog alert = builder.create();
+    alert.show();
+
+    ShadowAlertDialog shadowAlertDialog = shadowOf(alert);
+    shadowAlertDialog.clickOnItem(0);
+    assertThat(listener.transcript).containsExactly("clicked on 0");
+    listener.transcript.clear();
+
+    shadowAlertDialog.clickOnItem(1);
+    assertThat(listener.transcript).containsExactly("clicked on 1");
+
+  }
+
+  @Test
+  public void shouldDelegateToDialogFindViewByIdIfViewIsNull() {
+    AlertDialog dialog = new AlertDialog(context) {};
+
+    assertThat((View) dialog.findViewById(99)).isNull();
+
+    dialog.setContentView(R.layout.main);
+    assertNotNull(dialog.findViewById(R.id.title));
+  }
+
+  @Test
+  public void shouldNotExplodeWhenNestingAlerts() {
+    final Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    final AlertDialog nestedDialog = new AlertDialog.Builder(activity)
+        .setTitle("Dialog 2")
+        .setMessage("Another dialog")
+        .setPositiveButton("OK", null)
+        .create();
+
+    final AlertDialog dialog =
+        new AlertDialog.Builder(activity)
+            .setTitle("Dialog 1")
+            .setMessage("A dialog")
+            .setPositiveButton("Button 1", (dialog1, which) -> nestedDialog.show())
+            .create();
+
+    dialog.show();
+    assertThat(ShadowDialog.getLatestDialog()).isEqualTo(dialog);
+
+    ShadowView.clickOn(dialog.getButton(Dialog.BUTTON_POSITIVE));
+    assertThat(ShadowDialog.getLatestDialog()).isEqualTo(nestedDialog);
+  }
+
+  @Test
+  public void alertControllerShouldStoreCorrectIconIdFromBuilder() {
+    AlertDialog.Builder builder = new AlertDialog.Builder(getApplication());
+    builder.setIcon(R.drawable.an_image);
+
+    AlertDialog alertDialog = builder.create();
+    assertThat(shadowOf(alertDialog).getIconId()).isEqualTo(R.drawable.an_image);
+  }
+
+  private static class TestDialogOnClickListener implements DialogInterface.OnClickListener {
+
+    private final ArrayList<String> transcript = new ArrayList<>();
+
+    @Override
+    public void onClick(DialogInterface dialog, int item) {
+      transcript.add("clicked on " + item);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAmbientContextManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAmbientContextManagerTest.java
new file mode 100644
index 0000000..4281e89
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAmbientContextManagerTest.java
@@ -0,0 +1,184 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.PendingIntent;
+import android.app.ambientcontext.AmbientContextEvent;
+import android.app.ambientcontext.AmbientContextEventRequest;
+import android.app.ambientcontext.AmbientContextManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build.VERSION_CODES;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowAmbientContextManager}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.TIRAMISU)
+public class ShadowAmbientContextManagerTest {
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void default_shouldNotStoreAnyRequest() throws Exception {
+    assertThat(
+            ((ShadowAmbientContextManager)
+                    Shadow.extract(context.getSystemService(AmbientContextManager.class)))
+                .getLastRegisterObserverRequest())
+        .isNull();
+  }
+
+  @Test
+  public void registerObserver_shouldStoreLastRequest() throws Exception {
+    AmbientContextManager ambientContextManager =
+        context.getSystemService(AmbientContextManager.class);
+    AmbientContextEventRequest request =
+        new AmbientContextEventRequest.Builder()
+            .addEventType(AmbientContextEvent.EVENT_COUGH)
+            .addEventType(AmbientContextEvent.EVENT_SNORE)
+            .build();
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(
+            context, /* requestCode= */ 0, new Intent(), PendingIntent.FLAG_IMMUTABLE);
+
+    ambientContextManager.registerObserver(
+        request,
+        pendingIntent,
+        MoreExecutors.directExecutor(),
+        (Integer status) -> {
+          /* Do nothing. */
+        });
+
+    assertThat(
+            ((ShadowAmbientContextManager) Shadow.extract(ambientContextManager))
+                .getLastRegisterObserverRequest())
+        .isEqualTo(request);
+  }
+
+  @Test
+  public void registerObserver_thenUnregister_shouldClearLastRequest() throws Exception {
+    AmbientContextManager ambientContextManager =
+        context.getSystemService(AmbientContextManager.class);
+    AmbientContextEventRequest request =
+        new AmbientContextEventRequest.Builder()
+            .addEventType(AmbientContextEvent.EVENT_COUGH)
+            .addEventType(AmbientContextEvent.EVENT_SNORE)
+            .build();
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(
+            context, /* requestCode= */ 0, new Intent(), PendingIntent.FLAG_IMMUTABLE);
+    ambientContextManager.registerObserver(
+        request,
+        pendingIntent,
+        MoreExecutors.directExecutor(),
+        (Integer status) -> {
+          /* Do nothing. */
+        });
+
+    ambientContextManager.unregisterObserver();
+
+    assertThat(
+            ((ShadowAmbientContextManager) Shadow.extract(ambientContextManager))
+                .getLastRegisterObserverRequest())
+        .isNull();
+  }
+
+  @Test
+  public void registerObserver_statusSetToSuccess_shouldNotifyConsumerWithStoredStatus()
+      throws Exception {
+    AmbientContextManager ambientContextManager =
+        context.getSystemService(AmbientContextManager.class);
+    ((ShadowAmbientContextManager) Shadow.extract(ambientContextManager))
+        .setAmbientContextServiceStatus(AmbientContextManager.STATUS_SUCCESS);
+    AmbientContextEventRequest request =
+        new AmbientContextEventRequest.Builder()
+            .addEventType(AmbientContextEvent.EVENT_COUGH)
+            .addEventType(AmbientContextEvent.EVENT_SNORE)
+            .build();
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(
+            context, /* requestCode= */ 0, new Intent(), PendingIntent.FLAG_IMMUTABLE);
+
+    SettableFuture<Integer> statusFuture = SettableFuture.create();
+    ambientContextManager.registerObserver(
+        request, pendingIntent, MoreExecutors.directExecutor(), statusFuture::set);
+
+    assertThat(statusFuture.get()).isEqualTo(AmbientContextManager.STATUS_SUCCESS);
+  }
+
+  @Test
+  public void queryAmbientContextServiceStatus_statusSetToSuccess_shouldReturnSuccess()
+      throws Exception {
+    AmbientContextManager ambientContextManager =
+        context.getSystemService(AmbientContextManager.class);
+    ((ShadowAmbientContextManager) Shadow.extract(ambientContextManager))
+        .setAmbientContextServiceStatus(AmbientContextManager.STATUS_SUCCESS);
+
+    SettableFuture<Integer> statusFuture = SettableFuture.create();
+    ambientContextManager.queryAmbientContextServiceStatus(
+        ImmutableSet.of(AmbientContextEvent.EVENT_SNORE),
+        MoreExecutors.directExecutor(),
+        statusFuture::set);
+
+    assertThat(statusFuture.get()).isEqualTo(AmbientContextManager.STATUS_SUCCESS);
+  }
+
+  @Test
+  public void queryAmbientContextServiceStatus_statusSetToNotSupported_shouldReturnNotSupported()
+      throws Exception {
+    AmbientContextManager ambientContextManager =
+        context.getSystemService(AmbientContextManager.class);
+    ((ShadowAmbientContextManager) Shadow.extract(ambientContextManager))
+        .setAmbientContextServiceStatus(AmbientContextManager.STATUS_NOT_SUPPORTED);
+
+    SettableFuture<Integer> statusFuture = SettableFuture.create();
+    ambientContextManager.queryAmbientContextServiceStatus(
+        ImmutableSet.of(AmbientContextEvent.EVENT_SNORE),
+        MoreExecutors.directExecutor(),
+        statusFuture::set);
+
+    assertThat(statusFuture.get()).isEqualTo(AmbientContextManager.STATUS_NOT_SUPPORTED);
+  }
+
+  @Test
+  public void
+      getLastRequestedEventCodesForConsentActivity_consentActivityNeverStarted_shouldReturnNull()
+          throws Exception {
+    Set<Integer> lastRequestedEventCodes =
+        ((ShadowAmbientContextManager)
+                Shadow.extract(context.getSystemService(AmbientContextManager.class)))
+            .getLastRequestedEventCodesForConsentActivity();
+
+    assertThat(lastRequestedEventCodes).isNull();
+  }
+
+  @Test
+  public void
+      getLastRequestedEventCodesForConsentActivity_consentActivityStarted_shouldReturnRequestedEventCodes()
+          throws Exception {
+    AmbientContextManager ambientContextManager =
+        context.getSystemService(AmbientContextManager.class);
+    ImmutableSet<Integer> requestedEventCodes =
+        ImmutableSet.of(AmbientContextEvent.EVENT_SNORE, AmbientContextEvent.EVENT_COUGH);
+    ambientContextManager.startConsentActivity(requestedEventCodes);
+
+    Set<Integer> lastRequestedEventCodes =
+        ((ShadowAmbientContextManager) Shadow.extract(ambientContextManager))
+            .getLastRequestedEventCodesForConsentActivity();
+
+    assertThat(lastRequestedEventCodes).containsExactlyElementsIn(requestedEventCodes);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAnimationSetTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAnimationSetTest.java
new file mode 100644
index 0000000..c23a27f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAnimationSetTest.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.view.animation.RotateAnimation;
+import android.view.animation.TranslateAnimation;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAnimationSetTest {
+  final Animation.AnimationListener moveListener = mock(Animation.AnimationListener.class);
+  final Animation.AnimationListener spinListener = mock(Animation.AnimationListener.class);
+
+  @Test @Ignore("Needs additional work")
+  public void start_shouldRunAnimation() {
+    final AnimationSet set = new AnimationSet(true);
+
+    final Animation move = new TranslateAnimation(0, 100, 0, 100);
+    move.setDuration(1000);
+    move.setAnimationListener(moveListener);
+
+    final Animation spin = new RotateAnimation(0, 360);
+    spin.setDuration(1000);
+    spin.setStartOffset(1000);
+    spin.setAnimationListener(spinListener);
+
+    set.start();
+
+    verify(moveListener).onAnimationStart(move);
+
+    Robolectric.flushForegroundThreadScheduler();
+
+    verify(moveListener).onAnimationEnd(move);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAnimationUtilsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAnimationUtilsTest.java
new file mode 100644
index 0000000..7ecc1f8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAnimationUtilsTest.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.R;
+import android.app.Activity;
+import android.view.animation.AnimationUtils;
+import android.view.animation.LayoutAnimationController;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAnimationUtilsTest {
+
+  @Test
+  public void loadAnimation_shouldCreateAnimation() {
+    assertThat(AnimationUtils.loadAnimation(Robolectric.setupActivity(Activity.class), R.anim.fade_in)).isNotNull();
+  }
+
+  @Test
+  public void loadLayoutAnimation_shouldCreateAnimation() {
+    assertThat(AnimationUtils.loadLayoutAnimation(Robolectric.setupActivity(Activity.class), 1)).isNotNull();
+  }
+
+  @Test
+  public void getLoadedFromResourceId_forAnimationController_shouldReturnAnimationResourceId() {
+    final LayoutAnimationController anim = AnimationUtils.loadLayoutAnimation(Robolectric.setupActivity(Activity.class), R.anim.fade_in);
+    assertThat(Shadows.shadowOf(anim).getLoadedFromResourceId()).isEqualTo(R.anim.fade_in);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAppOpsManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppOpsManagerTest.java
new file mode 100644
index 0000000..d94c275
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppOpsManagerTest.java
@@ -0,0 +1,724 @@
+package org.robolectric.shadows;
+
+import static android.app.AppOpsManager.MODE_ALLOWED;
+import static android.app.AppOpsManager.MODE_DEFAULT;
+import static android.app.AppOpsManager.MODE_ERRORED;
+import static android.app.AppOpsManager.MODE_FOREGROUND;
+import static android.app.AppOpsManager.MODE_IGNORED;
+import static android.app.AppOpsManager.OPSTR_BODY_SENSORS;
+import static android.app.AppOpsManager.OPSTR_COARSE_LOCATION;
+import static android.app.AppOpsManager.OPSTR_FINE_LOCATION;
+import static android.app.AppOpsManager.OPSTR_GPS;
+import static android.app.AppOpsManager.OPSTR_READ_PHONE_STATE;
+import static android.app.AppOpsManager.OPSTR_RECORD_AUDIO;
+import static android.app.AppOpsManager.OP_FINE_LOCATION;
+import static android.app.AppOpsManager.OP_GPS;
+import static android.app.AppOpsManager.OP_SEND_SMS;
+import static android.app.AppOpsManager.OP_VIBRATE;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowAppOpsManager.DURATION;
+import static org.robolectric.shadows.ShadowAppOpsManager.OP_TIME;
+
+import android.app.AppOpsManager;
+import android.app.AppOpsManager.OnOpChangedListener;
+import android.app.AppOpsManager.OpEntry;
+import android.app.AppOpsManager.PackageOps;
+import android.app.SyncNotedAppOp;
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.os.Binder;
+import android.os.Build.VERSION_CODES;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowAppOpsManager.ModeAndException;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Unit tests for {@link ShadowAppOpsManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = KITKAT)
+public class ShadowAppOpsManagerTest {
+
+  private static final String PACKAGE_NAME1 = "com.company1.pkg1";
+  private static final String PACKAGE_NAME2 = "com.company2.pkg2";
+  private static final int UID_1 = 10000;
+  private static final int UID_2 = 10001;
+
+  // Can be used as an argument of getOpsForPackage().
+  private static final int[] NO_OP_FILTER_BY_NUMBER = null;
+  private static final String[] NO_OP_FILTER_BY_NAME = null;
+
+  private AppOpsManager appOps;
+
+  @Before
+  public void setUp() {
+    appOps =
+        (AppOpsManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.APP_OPS_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void checkOpNoThrow_noModeSet_atLeastP_shouldReturnModeAllowed() {
+    assertThat(appOps.checkOpNoThrow(OPSTR_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void setMode_withModeDefault_atLeastP_checkOpNoThrow_shouldReturnModeDefault() {
+    ReflectionHelpers.callInstanceMethod(
+        appOps,
+        "setMode",
+        ClassParameter.from(int.class, OP_GPS),
+        ClassParameter.from(int.class, UID_1),
+        ClassParameter.from(String.class, PACKAGE_NAME1),
+        ClassParameter.from(int.class, MODE_DEFAULT));
+    assertThat(appOps.checkOpNoThrow(OPSTR_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_DEFAULT);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.KITKAT)
+  public void checkOpNoThrow_noModeSet_atLeastKitKat_shouldReturnModeAllowed() {
+    assertThat(appOps.checkOpNoThrow(/* op= */ 2, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.KITKAT)
+  public void setMode_withModeDefault_atLeastKitKat_checkOpNoThrow_shouldReturnModeDefault() {
+    appOps.setMode(/* op= */ 2, UID_1, PACKAGE_NAME1, MODE_DEFAULT);
+    assertThat(appOps.checkOpNoThrow(/* op= */ 2, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_DEFAULT);
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.O_MR1)
+  public void setMode_checkOpNoThrow_belowP() {
+    assertThat(appOps.checkOpNoThrow(OP_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+    appOps.setMode(OP_GPS, UID_1, PACKAGE_NAME1, MODE_ERRORED);
+    assertThat(appOps.checkOpNoThrow(OP_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void setMode_checkOpNoThrow_atLeastP() {
+    assertThat(appOps.checkOpNoThrow(OPSTR_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+    appOps.setMode(OPSTR_GPS, UID_1, PACKAGE_NAME1, MODE_ERRORED);
+    assertThat(appOps.checkOpNoThrow(OPSTR_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O_MR1, maxSdk = VERSION_CODES.Q)
+  public void noModeSet_atLeastO_noteProxyOpNoThrow_shouldReturnModeAllowed() {
+    int result =
+        ReflectionHelpers.callInstanceMethod(
+            appOps,
+            "noteProxyOpNoThrow",
+            ClassParameter.from(int.class, OP_GPS),
+            ClassParameter.from(String.class, PACKAGE_NAME1));
+    assertThat(result).isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.Q)
+  public void noModeSet_q_noteProxyOpNoThrow_withproxiedUid_shouldReturnModeAllowed() {
+    int result = appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid());
+    assertThat(result).isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void noModeSet_atLeastR_noteProxyOpNoThrow_shouldReturnModeAllowed() {
+    int result =
+        appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid(), null, null);
+    assertThat(result).isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O_MR1, maxSdk = VERSION_CODES.Q)
+  public void setMode_withModeDefault_atLeastO_noteProxyOpNoThrow_shouldReturnModeDefault() {
+    ReflectionHelpers.callInstanceMethod(
+        appOps,
+        "setMode",
+        ClassParameter.from(int.class, OP_GPS),
+        ClassParameter.from(int.class, Binder.getCallingUid()),
+        ClassParameter.from(String.class, PACKAGE_NAME1),
+        ClassParameter.from(int.class, MODE_DEFAULT));
+    int result =
+        ReflectionHelpers.callInstanceMethod(
+            appOps,
+            "noteProxyOpNoThrow",
+            ClassParameter.from(int.class, OP_GPS),
+            ClassParameter.from(String.class, PACKAGE_NAME1));
+    assertThat(result).isEqualTo(MODE_DEFAULT);
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.Q)
+  public void
+      setMode_withModeDefault_q_noteProxyOpNoThrow_withProxiedUid_shouldReturnModeDefault() {
+    appOps.setMode(OP_GPS, Binder.getCallingUid(), PACKAGE_NAME1, MODE_DEFAULT);
+    assertThat(appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid()))
+        .isEqualTo(MODE_DEFAULT);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void setMode_withModeDefault_atLeastR_noteProxyOpNoThrow_shouldReturnModeDefault() {
+    appOps.setMode(OP_GPS, Binder.getCallingUid(), PACKAGE_NAME1, MODE_DEFAULT);
+    assertThat(
+            appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid(), null, null))
+        .isEqualTo(MODE_DEFAULT);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P, maxSdk = VERSION_CODES.Q)
+  public void setMode_noteProxyOpNoThrow_atLeastO() {
+    assertThat(appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+    appOps.setMode(OP_GPS, Binder.getCallingUid(), PACKAGE_NAME1, MODE_ERRORED);
+    assertThat(appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1)).isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.Q)
+  public void setMode_noteProxyOpNoThrow_withProxiedUid_q() {
+    assertThat(appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid()))
+        .isEqualTo(MODE_ALLOWED);
+    appOps.setMode(OP_GPS, Binder.getCallingUid(), PACKAGE_NAME1, MODE_ERRORED);
+    assertThat(appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid()))
+        .isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void setMode_noteProxyOpNoThrow_atLeastR() {
+    assertThat(
+            appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid(), null, null))
+        .isEqualTo(MODE_ALLOWED);
+    appOps.setMode(OP_GPS, Binder.getCallingUid(), PACKAGE_NAME1, MODE_ERRORED);
+    assertThat(
+            appOps.noteProxyOpNoThrow(OPSTR_GPS, PACKAGE_NAME1, Binder.getCallingUid(), null, null))
+        .isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.KITKAT)
+  public void startStopWatchingMode() {
+    OnOpChangedListener callback = mock(OnOpChangedListener.class);
+    appOps.startWatchingMode(OPSTR_FINE_LOCATION, PACKAGE_NAME1, callback);
+    appOps.setMode(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1, MODE_ERRORED);
+    verify(callback).onOpChanged(OPSTR_FINE_LOCATION, PACKAGE_NAME1);
+
+    appOps.stopWatchingMode(callback);
+    appOps.setMode(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1, MODE_ALLOWED);
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void startStopWatchingModeFlags() {
+    OnOpChangedListener callback = mock(OnOpChangedListener.class);
+    appOps.startWatchingMode(OPSTR_FINE_LOCATION, PACKAGE_NAME1, 0, callback);
+    appOps.setMode(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1, MODE_ERRORED);
+    verify(callback).onOpChanged(OPSTR_FINE_LOCATION, PACKAGE_NAME1);
+
+    appOps.stopWatchingMode(callback);
+    appOps.setMode(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1, MODE_ALLOWED);
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  public void noteOp() {
+    assertThat(appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+    // Use same op more than once
+    assertThat(appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+
+    assertThat(appOps.noteOp(OP_SEND_SMS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void getOpsForPackageStr_noOps() {
+    List<PackageOps> results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NAME);
+    assertOps(results);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void getOpsForPackageStr_hasOps() {
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    appOps.noteOp(OP_SEND_SMS, UID_1, PACKAGE_NAME1);
+
+    // PACKAGE_NAME1 has ops.
+    List<PackageOps> results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NAME);
+    assertOps(results, OP_GPS, OP_SEND_SMS);
+
+    // PACKAGE_NAME2 has no ops.
+    results = appOps.getOpsForPackage(UID_2, PACKAGE_NAME2, NO_OP_FILTER_BY_NAME);
+    assertOps(results);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void getOpsForPackageStr_withOpFilter() {
+    List<PackageOps> results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, OPSTR_GPS);
+    assertOps(results);
+
+    appOps.noteOp(OP_SEND_SMS, UID_1, PACKAGE_NAME1);
+    results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, OPSTR_GPS);
+    assertOps(results);
+
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, OPSTR_GPS);
+    assertOps(results, OP_GPS);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void getOpsForPackageStr_withOpFilter_withMeaninglessString() {
+    List<PackageOps> results =
+        appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, OPSTR_GPS, "something");
+    assertOps(results);
+
+    appOps.noteOp(OP_SEND_SMS, UID_1, PACKAGE_NAME1);
+    results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, OPSTR_GPS, "something");
+    assertOps(results);
+
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, OPSTR_GPS, "something");
+    assertOps(results, OP_GPS);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void getOpsForPackageStr_ensureTime() {
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    List<PackageOps> results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NAME);
+    assertThat(results.get(0).getOps().get(0).getTime()).isEqualTo(OP_TIME);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q) // Earlier versions return int rather than long for duration.
+  public void getOpsForPackageStr_ensureDuration() {
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    List<PackageOps> results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NAME);
+    assertThat(results.get(0).getOps().get(0).getDuration()).isEqualTo(DURATION);
+  }
+
+  @Test
+  public void getOpsForPackage_noOps() {
+    List<PackageOps> results =
+        appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NUMBER);
+    assertOps(results);
+  }
+
+  @Test
+  public void getOpsForPackage_hasOps() {
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    appOps.noteOp(OP_SEND_SMS, UID_1, PACKAGE_NAME1);
+
+    // PACKAGE_NAME1 has ops.
+    List<PackageOps> results =
+        appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NUMBER);
+    assertOps(results, OP_GPS, OP_SEND_SMS);
+
+    // PACKAGE_NAME2 has no ops.
+    results = appOps.getOpsForPackage(UID_2, PACKAGE_NAME2, NO_OP_FILTER_BY_NUMBER);
+    assertOps(results);
+  }
+
+  @Test
+  public void getOpsForPackage_withOpFilter() {
+    List<PackageOps> results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, new int[] {OP_GPS});
+    assertOps(results);
+
+    appOps.noteOp(OP_SEND_SMS, UID_1, PACKAGE_NAME1);
+    results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, new int[] {OP_GPS});
+    assertOps(results);
+
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    results = appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, new int[] {OP_GPS});
+    assertOps(results, OP_GPS);
+  }
+
+  @Test
+  public void getOpsForPackage_hasNoThrowOps() {
+    appOps.noteOpNoThrow(OP_GPS, UID_1, PACKAGE_NAME1);
+    appOps.noteOpNoThrow(OP_SEND_SMS, UID_1, PACKAGE_NAME1);
+
+    assertOps(
+        appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NUMBER), OP_GPS, OP_SEND_SMS);
+
+    assertOps(appOps.getOpsForPackage(UID_2, PACKAGE_NAME2, NO_OP_FILTER_BY_NUMBER));
+
+    appOps.setMode(OP_GPS, UID_1, PACKAGE_NAME1, MODE_ERRORED);
+    assertThat(appOps.noteOpNoThrow(OP_GPS, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  public void getOpsForPackage_ensureTime() {
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    List<PackageOps> results =
+        appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NUMBER);
+    assertThat(results.get(0).getOps().get(0).getTime()).isEqualTo(OP_TIME);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q) // Earlier versions return int rather than long for duration.
+  public void getOpsForPackage_ensureDuration() {
+    appOps.noteOp(OP_GPS, UID_1, PACKAGE_NAME1);
+    List<PackageOps> results =
+        appOps.getOpsForPackage(UID_1, PACKAGE_NAME1, NO_OP_FILTER_BY_NUMBER);
+    assertThat(results.get(0).getOps().get(0).getDuration()).isEqualTo(DURATION);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOp_opActive() {
+    appOps.startOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, null, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOp_finishOp_opNotActive() {
+    appOps.startOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, null, null);
+    appOps.finishOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOp_anotherOp_opNotActive() {
+    appOps.startOp(OPSTR_READ_PHONE_STATE, UID_1, PACKAGE_NAME1, null, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOp_anotherUid_opNotActive() {
+    appOps.startOp(OPSTR_RECORD_AUDIO, UID_2, PACKAGE_NAME1, null, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOp_anotherPackage_opNotActive() {
+    appOps.startOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME2, null, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.KITKAT, maxSdk = VERSION_CODES.Q)
+  public void startOpNoThrow_setModeAllowed() {
+    appOps.setMode(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1, MODE_ALLOWED);
+
+    assertThat(appOps.startOpNoThrow(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1))
+        .isEqualTo(MODE_ALLOWED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.KITKAT, maxSdk = VERSION_CODES.Q)
+  public void startOpNoThrow_setModeErrored() {
+    appOps.setMode(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1, MODE_ERRORED);
+
+    assertThat(appOps.startOpNoThrow(OP_FINE_LOCATION, UID_1, PACKAGE_NAME1))
+        .isEqualTo(MODE_ERRORED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOpNoThrow_withAttr_opActive() {
+    appOps.startOpNoThrow(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, null, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void startOpNoThrow_finishOp_opNotActive() {
+    appOps.startOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, null, null);
+    appOps.finishOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, null);
+
+    assertThat(appOps.isOpActive(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void checkOp_ignoreModeSet_returnIgnored() {
+    appOps.setMode(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1, MODE_IGNORED);
+
+    assertThat(appOps.checkOp(OPSTR_RECORD_AUDIO, UID_1, PACKAGE_NAME1)).isEqualTo(MODE_IGNORED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void setRestrictions() {
+    appOps.setRestriction(
+        OP_VIBRATE, AudioAttributes.USAGE_NOTIFICATION, MODE_ERRORED, new String[] {PACKAGE_NAME1});
+
+    ModeAndException modeAndException =
+        shadowOf(appOps).getRestriction(OP_VIBRATE, AudioAttributes.USAGE_NOTIFICATION);
+    assertThat(modeAndException.mode).isEqualTo(MODE_ERRORED);
+    assertThat(modeAndException.exceptionPackages).containsExactly(PACKAGE_NAME1);
+  }
+
+  @Test
+  public void checkPackage_doesntExist() {
+    try {
+      appOps.checkPackage(123, PACKAGE_NAME1);
+      fail();
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void checkPackage_doesntBelong() {
+    shadowOf(ApplicationProvider.getApplicationContext().getPackageManager())
+        .setPackagesForUid(111, PACKAGE_NAME1);
+    try {
+      appOps.checkPackage(123, PACKAGE_NAME1);
+      fail();
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void checkPackage_belongs() {
+    shadowOf(ApplicationProvider.getApplicationContext().getPackageManager())
+        .setPackagesForUid(123, PACKAGE_NAME1);
+    appOps.checkPackage(123, PACKAGE_NAME1);
+    // check passes without exception
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void getPackageForOps_setNone_getNull() {
+    int[] intNull = null;
+    List<PackageOps> packageOps = appOps.getPackagesForOps(intNull);
+    assertThat(packageOps).isNull();
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void getPackageForOps_setOne_getOne() {
+    String packageName = "com.android.package";
+    int uid = 111;
+    appOps.setMode(0, uid, packageName, MODE_ALLOWED);
+
+    int[] intNull = null;
+    List<PackageOps> packageOps = appOps.getPackagesForOps(intNull);
+    assertThat(containsPackageOpPair(packageOps, packageName, 0, MODE_ALLOWED)).isTrue();
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void getPackageForOps_setMultiple_getMultiple() {
+    String packageName1 = "com.android.package";
+    String packageName2 = "com.android.other";
+    int uid1 = 111;
+    int uid2 = 112;
+    appOps.setMode(0, uid1, packageName1, MODE_ALLOWED);
+    appOps.setMode(1, uid1, packageName1, MODE_DEFAULT);
+    appOps.setMode(2, uid1, packageName1, MODE_ERRORED);
+    appOps.setMode(3, uid1, packageName1, MODE_FOREGROUND);
+    appOps.setMode(4, uid1, packageName1, MODE_IGNORED);
+    appOps.setMode(0, uid2, packageName2, MODE_ALLOWED);
+
+    int[] intNull = null;
+    List<PackageOps> packageOps = appOps.getPackagesForOps(intNull);
+    assertThat(containsPackageOpPair(packageOps, packageName1, 0, MODE_ALLOWED)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 1, MODE_DEFAULT)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 2, MODE_ERRORED)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 3, MODE_FOREGROUND)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 4, MODE_IGNORED)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName2, 0, MODE_ALLOWED)).isTrue();
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void getPackageForOps_setMultiple_onlyGetThoseAskedFor() {
+    String packageName1 = "com.android.package";
+    String packageName2 = "com.android.other";
+    int uid1 = 111;
+    int uid2 = 112;
+    appOps.setMode(0, uid1, packageName1, MODE_ALLOWED);
+    appOps.setMode(1, uid1, packageName1, MODE_DEFAULT);
+    appOps.setMode(2, uid1, packageName1, MODE_ERRORED);
+    appOps.setMode(3, uid1, packageName1, MODE_FOREGROUND);
+    appOps.setMode(4, uid1, packageName1, MODE_IGNORED);
+    appOps.setMode(0, uid2, packageName2, MODE_ALLOWED);
+
+    List<PackageOps> packageOps = appOps.getPackagesForOps(new int[] {0, 1});
+    assertThat(containsPackageOpPair(packageOps, packageName1, 0, MODE_ALLOWED)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 1, MODE_DEFAULT)).isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 2, MODE_ERRORED)).isFalse();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 3, MODE_FOREGROUND)).isFalse();
+    assertThat(containsPackageOpPair(packageOps, packageName1, 4, MODE_IGNORED)).isFalse();
+    assertThat(containsPackageOpPair(packageOps, packageName2, 0, MODE_ALLOWED)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setOnApNotedCallback_isCalled() {
+    AppOpsManager.OnOpNotedCallback callback = mock(AppOpsManager.OnOpNotedCallback.class);
+    appOps.setOnOpNotedCallback(directExecutor(), callback);
+    ArgumentCaptor<SyncNotedAppOp> captor = ArgumentCaptor.forClass(SyncNotedAppOp.class);
+    appOps.noteOp(
+        AppOpsManager.OPSTR_MONITOR_LOCATION,
+        android.os.Process.myUid(),
+        ApplicationProvider.getApplicationContext().getPackageName(),
+        "tag",
+        "message");
+    verify(callback).onSelfNoted(captor.capture());
+    assertThat(captor.getValue().getOp()).isEqualTo(AppOpsManager.OPSTR_MONITOR_LOCATION);
+    assertThat(captor.getValue().getAttributionTag()).isEqualTo("tag");
+  }
+
+  @Config(minSdk = Q)
+  @Test
+  public void getPackageForOpsStr_setNone_getEmptyList() {
+    String[] stringNull = null;
+    List<PackageOps> packageOps = appOps.getPackagesForOps(stringNull);
+    assertThat(packageOps).isEmpty();
+  }
+
+  @Config(minSdk = Q)
+  @Test
+  public void getPackageForOpsStr_setOne_getOne() {
+    String packageName = "com.android.package";
+    int uid = 111;
+    appOps.setMode(AppOpsManager.OPSTR_COARSE_LOCATION, uid, packageName, MODE_ALLOWED);
+
+    String[] stringNull = null;
+    List<PackageOps> packageOps = appOps.getPackagesForOps(stringNull);
+    assertThat(containsPackageOpPair(packageOps, packageName, OPSTR_COARSE_LOCATION, MODE_ALLOWED))
+        .isTrue();
+  }
+
+  @Config(minSdk = Q)
+  @Test
+  public void getPackageForOpsStr_setMultiple_getMultiple() {
+    String packageName1 = "com.android.package";
+    String packageName2 = "com.android.other";
+    int uid1 = 111;
+    int uid2 = 112;
+    appOps.setMode(OPSTR_COARSE_LOCATION, uid1, packageName1, MODE_ALLOWED);
+    appOps.setMode(OPSTR_FINE_LOCATION, uid1, packageName1, MODE_DEFAULT);
+    appOps.setMode(OPSTR_READ_PHONE_STATE, uid1, packageName1, MODE_ERRORED);
+    appOps.setMode(OPSTR_RECORD_AUDIO, uid1, packageName1, MODE_FOREGROUND);
+    appOps.setMode(OPSTR_BODY_SENSORS, uid1, packageName1, MODE_IGNORED);
+    appOps.setMode(OPSTR_COARSE_LOCATION, uid2, packageName2, MODE_ALLOWED);
+
+    String[] stringNull = null;
+    List<PackageOps> packageOps = appOps.getPackagesForOps(stringNull);
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_COARSE_LOCATION, MODE_ALLOWED))
+        .isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_FINE_LOCATION, MODE_DEFAULT))
+        .isTrue();
+    assertThat(
+            containsPackageOpPair(packageOps, packageName1, OPSTR_READ_PHONE_STATE, MODE_ERRORED))
+        .isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_RECORD_AUDIO, MODE_FOREGROUND))
+        .isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_BODY_SENSORS, MODE_IGNORED))
+        .isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName2, OPSTR_COARSE_LOCATION, MODE_ALLOWED))
+        .isTrue();
+  }
+
+  @Config(minSdk = Q)
+  @Test
+  public void getPackageForOpsStr_setMultiple_onlyGetThoseAskedFor() {
+    String packageName1 = "com.android.package";
+    String packageName2 = "com.android.other";
+    int uid1 = 111;
+    int uid2 = 112;
+    appOps.setMode(OPSTR_COARSE_LOCATION, uid1, packageName1, MODE_ALLOWED);
+    appOps.setMode(OPSTR_FINE_LOCATION, uid1, packageName1, MODE_DEFAULT);
+    appOps.setMode(OPSTR_READ_PHONE_STATE, uid1, packageName1, MODE_ERRORED);
+    appOps.setMode(OPSTR_RECORD_AUDIO, uid1, packageName1, MODE_FOREGROUND);
+    appOps.setMode(OPSTR_BODY_SENSORS, uid1, packageName1, MODE_IGNORED);
+    appOps.setMode(OPSTR_COARSE_LOCATION, uid2, packageName2, MODE_ALLOWED);
+
+    List<PackageOps> packageOps =
+        appOps.getPackagesForOps(new String[] {OPSTR_COARSE_LOCATION, OPSTR_FINE_LOCATION});
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_COARSE_LOCATION, MODE_ALLOWED))
+        .isTrue();
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_FINE_LOCATION, MODE_DEFAULT))
+        .isTrue();
+    assertThat(
+            containsPackageOpPair(packageOps, packageName1, OPSTR_READ_PHONE_STATE, MODE_ERRORED))
+        .isFalse();
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_RECORD_AUDIO, MODE_FOREGROUND))
+        .isFalse();
+    assertThat(containsPackageOpPair(packageOps, packageName1, OPSTR_BODY_SENSORS, MODE_IGNORED))
+        .isFalse();
+    assertThat(containsPackageOpPair(packageOps, packageName2, OPSTR_COARSE_LOCATION, MODE_ALLOWED))
+        .isTrue();
+  }
+
+  /** Assert that the results contain the expected op codes. */
+  private void assertOps(List<PackageOps> pkgOps, Integer... expectedOps) {
+    Set<Integer> actualOps = new HashSet<>();
+    for (PackageOps pkgOp : pkgOps) {
+      for (OpEntry entry : pkgOp.getOps()) {
+        actualOps.add(entry.getOp());
+      }
+    }
+
+    assertThat(actualOps).containsAtLeastElementsIn(expectedOps);
+  }
+
+  /** True if the given (package, op, mode) tuple is present in the given list. */
+  private boolean containsPackageOpPair(
+      List<PackageOps> pkgOps, String packageName, int op, int mode) {
+    for (PackageOps pkgOp : pkgOps) {
+      for (OpEntry entry : pkgOp.getOps()) {
+        if (packageName.equals(pkgOp.getPackageName())
+            && entry.getOp() == op
+            && entry.getMode() == mode) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  /** True if the given (package, op, mode) tuple is present in the given list. */
+  private boolean containsPackageOpPair(
+      List<PackageOps> pkgOps, String packageName, String op, int mode) {
+    for (PackageOps pkgOp : pkgOps) {
+      for (OpEntry entry : pkgOp.getOps()) {
+        if (packageName.equals(pkgOp.getPackageName())
+            && op.equals(entry.getOpStr())
+            && entry.getMode() == mode) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAppTaskTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppTaskTest.java
new file mode 100644
index 0000000..db17e6e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppTaskTest.java
@@ -0,0 +1,91 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.ActivityManager.AppTask;
+import android.app.ActivityManager.RecentTaskInfo;
+import android.content.Intent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowAppTaskTest {
+
+  @Test
+  public void finishAndRemoveTask_marksTaskFinished() {
+    final AppTask appTask = ShadowAppTask.newInstance();
+
+    appTask.finishAndRemoveTask();
+
+    assertThat(shadowOf(appTask).isFinishedAndRemoved()).isTrue();
+  }
+
+  @Test
+  public void taskIsNotFinishedInitially() {
+    assertThat(shadowOf(ShadowAppTask.newInstance()).isFinishedAndRemoved()).isFalse();
+  }
+
+  @Test
+  public void getTaskInfo_returnsNullInitially() {
+    assertThat(ShadowAppTask.newInstance().getTaskInfo()).isNull();
+  }
+
+  @Test
+  public void getTaskInfo_returnsCorrectValue() {
+    final AppTask appTask = ShadowAppTask.newInstance();
+    final RecentTaskInfo recentTaskInfo = new RecentTaskInfo();
+    recentTaskInfo.description = "com.google.test";
+
+    shadowOf(appTask).setTaskInfo(recentTaskInfo);
+
+    assertThat(appTask.getTaskInfo()).isSameInstanceAs(recentTaskInfo);
+  }
+
+  @Test
+  public void moveToFront_movesTaskToFront() {
+    final AppTask appTask = ShadowAppTask.newInstance();
+
+    appTask.moveToFront();
+
+    assertThat(shadowOf(appTask).hasMovedToFront()).isTrue();
+  }
+
+  @Test
+  public void taskIsNotMovedToFrontInitially() {
+    assertThat(shadowOf(ShadowAppTask.newInstance()).hasMovedToFront()).isFalse();
+  }
+
+  @Test
+  public void startActivity() {
+    final AppTask appTask = ShadowAppTask.newInstance();
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final Intent intent = new Intent(Intent.ACTION_VIEW);
+
+    appTask.startActivity(activity, intent, null);
+
+    assertThat(shadowOf(activity).peekNextStartedActivity()).isNotNull();
+    assertThat(shadowOf(activity).peekNextStartedActivity().getAction())
+        .isEqualTo(Intent.ACTION_VIEW);
+  }
+
+  @Test
+  public void setExcludeFromRecents_excludesFromRecents() {
+    final AppTask appTask = ShadowAppTask.newInstance();
+
+    appTask.setExcludeFromRecents(true);
+
+    assertThat(shadowOf(appTask).isExcludedFromRecents()).isTrue();
+  }
+
+  @Test
+  public void taskIsNotExcludedFromRecentsInitially() {
+    assertThat(shadowOf(ShadowAppTask.newInstance()).isExcludedFromRecents()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetHostTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetHostTest.java
new file mode 100644
index 0000000..135330c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetHostTest.java
@@ -0,0 +1,79 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.appwidget.AppWidgetHost;
+import android.appwidget.AppWidgetHostView;
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAppWidgetHostTest {
+  private AppWidgetHost appWidgetHost;
+  private ShadowAppWidgetHost shadowAppWidgetHost;
+  private Context context;
+
+  @Before
+  public void setup() {
+    context = ApplicationProvider.getApplicationContext();
+    appWidgetHost = new AppWidgetHost(context, 404);
+    shadowAppWidgetHost = shadowOf(appWidgetHost);
+  }
+
+  @Test
+  public void shouldKnowItsContext() {
+    assertThat(shadowAppWidgetHost.getContext()).isSameInstanceAs(context);
+  }
+
+  @Test
+  public void shouldKnowItsHostId() {
+    assertThat(shadowAppWidgetHost.getHostId()).isEqualTo(404);
+  }
+
+  @Test
+  public void createView_shouldReturnAppWidgetHostView() {
+    AppWidgetHostView hostView = appWidgetHost.createView(context, 0, null);
+    assertNotNull(hostView);
+  }
+
+  @Test
+  public void createView_shouldSetViewsContext() {
+    AppWidgetHostView hostView = appWidgetHost.createView(context, 0, null);
+    assertThat(hostView.getContext()).isSameInstanceAs(context);
+  }
+
+  @Test
+  public void createView_shouldSetViewsAppWidgetId() {
+    AppWidgetHostView hostView = appWidgetHost.createView(context, 765, null);
+    assertThat(hostView.getAppWidgetId()).isEqualTo(765);
+  }
+
+  @Test
+  public void createView_shouldSetViewsAppWidgetInfo() {
+    AppWidgetProviderInfo info = new AppWidgetProviderInfo();
+    AppWidgetHostView hostView = appWidgetHost.createView(context, 0, info);
+    assertThat(hostView.getAppWidgetInfo()).isSameInstanceAs(info);
+  }
+
+  @Test
+  public void createView_shouldSetHostViewsHost() {
+    AppWidgetHostView hostView = appWidgetHost.createView(context, 0, null);
+    assertThat(shadowOf(hostView).getHost()).isSameInstanceAs(appWidgetHost);
+  }
+
+  @Test
+  public void shouldKnowIfItIsListening() {
+    assertThat(shadowAppWidgetHost.isListening()).isFalse();
+    appWidgetHost.startListening();
+    assertThat(shadowAppWidgetHost.isListening()).isTrue();
+    appWidgetHost.stopListening();
+    assertThat(shadowAppWidgetHost.isListening()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetHostViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetHostViewTest.java
new file mode 100644
index 0000000..ac6ddb0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetHostViewTest.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.appwidget.AppWidgetHost;
+import android.appwidget.AppWidgetHostView;
+import android.appwidget.AppWidgetProviderInfo;
+import android.widget.RemoteViews;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAppWidgetHostViewTest {
+  private AppWidgetHostView appWidgetHostView;
+  private ShadowAppWidgetHostView shadowAppWidgetHostView;
+
+  @Before
+  public void setUp() throws Exception {
+    appWidgetHostView = new AppWidgetHostView(ApplicationProvider.getApplicationContext());
+    shadowAppWidgetHostView = shadowOf(appWidgetHostView);
+  }
+
+  @Test
+  public void shouldKnowItsWidgetId() {
+    appWidgetHostView.setAppWidget(789, null);
+    assertThat(appWidgetHostView.getAppWidgetId()).isEqualTo(789);
+  }
+
+  @Test
+  public void shouldKnowItsAppWidgetProviderInfo() {
+    AppWidgetProviderInfo providerInfo = new AppWidgetProviderInfo();
+    appWidgetHostView.setAppWidget(0, providerInfo);
+    assertThat(appWidgetHostView.getAppWidgetInfo()).isSameInstanceAs(providerInfo);
+  }
+
+  @Test
+  public void shouldHaveNullHost() {
+    assertThat(shadowAppWidgetHostView.getHost()).isNull();
+  }
+
+  @Test
+  public void shouldBeAbleToHaveHostSet() {
+    AppWidgetHost host = new AppWidgetHost(ApplicationProvider.getApplicationContext(), 0);
+    shadowAppWidgetHostView.setHost(host);
+    assertThat(shadowAppWidgetHostView.getHost()).isSameInstanceAs(host);
+  }
+
+  @Test
+  public void shouldBeAbleToAddRemoteViews() {
+    RemoteViews remoteViews =
+        new RemoteViews(
+            ApplicationProvider.getApplicationContext().getPackageName(), R.layout.main);
+    remoteViews.setTextViewText(R.id.subtitle, "Hola");
+    appWidgetHostView.updateAppWidget(remoteViews);
+    assertThat(((TextView) appWidgetHostView.findViewById(R.id.subtitle)).getText().toString())
+        .isEqualTo("Hola");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetManagerTest.java
new file mode 100644
index 0000000..3774edb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAppWidgetManagerTest.java
@@ -0,0 +1,566 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.L;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Looper.getMainLooper;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Resources.NotFoundException;
+import android.os.Build.VERSION;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.view.View;
+import android.widget.RemoteViews;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAppWidgetManagerTest {
+  private Context context;
+  private AppWidgetManager appWidgetManager;
+  private ShadowAppWidgetManager shadowAppWidgetManager;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    appWidgetManager = AppWidgetManager.getInstance(context);
+    shadowAppWidgetManager = shadowOf(appWidgetManager);
+  }
+
+  @Test
+  public void createWidget_shouldInflateViewAndAssignId() {
+    int widgetId =
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views);
+    View widgetView = shadowAppWidgetManager.getViewFor(widgetId);
+
+    assertEquals("Hola", ((TextView) widgetView.findViewById(R.id.subtitle)).getText().toString());
+  }
+
+  @Test
+  public void getViewFor_shouldReturnSameViewEveryTimeForGivenWidgetId() {
+    int widgetId =
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views);
+    View widgetView = shadowAppWidgetManager.getViewFor(widgetId);
+
+    assertNotNull(widgetView);
+    assertSame(widgetView, shadowAppWidgetManager.getViewFor(widgetId));
+  }
+
+  @Test
+  public void createWidget_shouldAllowForMultipleInstancesOfWidgets() {
+    int widgetId =
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views);
+    View widgetView = shadowAppWidgetManager.getViewFor(widgetId);
+
+    assertNotSame(
+        widgetId,
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views));
+    assertNotSame(
+        widgetView,
+        shadowAppWidgetManager.getViewFor(
+            shadowAppWidgetManager.createWidget(
+                SpanishTestAppWidgetProvider.class, R.layout.remote_views)));
+  }
+
+  @Test
+  public void shouldReplaceLayoutIfAndOnlyIfLayoutIdIsDifferent() {
+    int widgetId =
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views);
+    View originalWidgetView = shadowAppWidgetManager.getViewFor(widgetId);
+    assertViewId(R.id.remote_views_root, originalWidgetView);
+
+    appWidgetManager.updateAppWidget(
+        widgetId,
+        new RemoteViews(
+            ApplicationProvider.getApplicationContext().getPackageName(), R.layout.remote_views));
+    assertSame(originalWidgetView, shadowAppWidgetManager.getViewFor(widgetId));
+
+    appWidgetManager.updateAppWidget(
+        widgetId,
+        new RemoteViews(
+            ApplicationProvider.getApplicationContext().getPackageName(),
+            R.layout.remote_views_alt));
+    assertNotSame(originalWidgetView, shadowAppWidgetManager.getViewFor(widgetId));
+
+    View altWidgetView = shadowAppWidgetManager.getViewFor(widgetId);
+    assertViewId(R.id.remote_views_alt_root, altWidgetView);
+  }
+
+  @Test
+  public void getAppWidgetIds() {
+    int expectedWidgetId =
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views);
+
+    int[] appWidgetIds =
+        appWidgetManager.getAppWidgetIds(
+            new ComponentName(
+                ApplicationProvider.getApplicationContext(),
+                SpanishTestAppWidgetProvider.class.getName()));
+
+    assertEquals(1, appWidgetIds.length);
+    assertEquals(expectedWidgetId, appWidgetIds[0]);
+  }
+
+  @Test
+  public void getAppWidgetInfo_shouldReturnSpecifiedAppWidgetInfo() {
+    AppWidgetProviderInfo expectedWidgetInfo = new AppWidgetProviderInfo();
+    shadowAppWidgetManager.addBoundWidget(26, expectedWidgetInfo);
+
+    assertEquals(expectedWidgetInfo, appWidgetManager.getAppWidgetInfo(26));
+    assertNull(appWidgetManager.getAppWidgetInfo(27));
+  }
+
+  @Test
+  public void bindAppWidgetIdIfAllowed_shouldReturnThePresetBoolean() {
+    shadowAppWidgetManager.setAllowedToBindAppWidgets(false);
+    assertFalse(shadowAppWidgetManager.bindAppWidgetIdIfAllowed(12345, new ComponentName("", "")));
+    shadowAppWidgetManager.setAllowedToBindAppWidgets(true);
+    assertTrue(shadowAppWidgetManager.bindAppWidgetIdIfAllowed(12345, new ComponentName("", "")));
+  }
+
+  @Test
+  public void bindAppWidgetIdIfAllowed_shouldRecordTheBinding() {
+    ComponentName provider = new ComponentName("A", "B");
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+    assertArrayEquals(new int[] {789}, appWidgetManager.getAppWidgetIds(provider));
+  }
+
+  @Test
+  public void bindAppWidgetIdIfAllowed_shouldSetEmptyOptionsBundleIfNotProvided() {
+    ComponentName provider = new ComponentName("A", "B");
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+    assertEquals(0, appWidgetManager.getAppWidgetOptions(789).size());
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void bindAppWidgetIdIfAllowed_shouldSetOptionsBundle() {
+    ComponentName provider = new ComponentName("A", "B");
+    Bundle options = new Bundle();
+    options.putString("key", "value");
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider, options);
+    assertEquals("value", appWidgetManager.getAppWidgetOptions(789).getString("key"));
+  }
+
+  @Test
+  public void bindAppWidgetId_shouldRecordAppWidgetInfo() {
+    ComponentName provider = new ComponentName("abc", "123");
+    AppWidgetProviderInfo providerInfo = new AppWidgetProviderInfo();
+    providerInfo.provider = provider;
+    shadowAppWidgetManager.addInstalledProvider(providerInfo);
+
+    appWidgetManager.bindAppWidgetIdIfAllowed(90210, provider);
+
+    assertSame(providerInfo, appWidgetManager.getAppWidgetInfo(90210));
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void bindAppWidgetIdIfAllowed_shouldThrowIllegalArgumentExceptionWhenPrompted() {
+    shadowAppWidgetManager.setValidWidgetProviderComponentName(false);
+    shadowAppWidgetManager.bindAppWidgetIdIfAllowed(12345, new ComponentName("", ""));
+  }
+
+  @Test
+  public void getInstalledProviders_returnsWidgetList() {
+    AppWidgetProviderInfo info1 = new AppWidgetProviderInfo();
+    info1.label = "abc";
+    AppWidgetProviderInfo info2 = new AppWidgetProviderInfo();
+    info2.label = "def";
+    shadowAppWidgetManager.addInstalledProvider(info1);
+    shadowAppWidgetManager.addInstalledProvider(info2);
+    List<AppWidgetProviderInfo> installedProviders = appWidgetManager.getInstalledProviders();
+    assertEquals(2, installedProviders.size());
+    assertEquals(info1, installedProviders.get(0));
+    assertEquals(info2, installedProviders.get(1));
+  }
+
+  @Test
+  public void removeInstalledProviders_returnsWidgetList() {
+    AppWidgetProviderInfo info1 = new AppWidgetProviderInfo();
+    info1.label = "abc";
+    AppWidgetProviderInfo info2 = new AppWidgetProviderInfo();
+    info2.label = "def";
+    shadowAppWidgetManager.addInstalledProvider(info1);
+    shadowAppWidgetManager.addInstalledProvider(info2);
+    List<AppWidgetProviderInfo> installedProviders = appWidgetManager.getInstalledProviders();
+    assertEquals(2, installedProviders.size());
+    assertEquals(info1, installedProviders.get(0));
+    assertEquals(info2, installedProviders.get(1));
+    assertTrue(shadowAppWidgetManager.removeInstalledProvider(info1));
+    installedProviders = appWidgetManager.getInstalledProviders();
+    assertEquals(1, installedProviders.size());
+    assertEquals(info2, installedProviders.get(0));
+  }
+
+  @Test
+  public void tryRemoveNotInstalledProviders_noChange() {
+    AppWidgetProviderInfo info1 = new AppWidgetProviderInfo();
+    info1.label = "abc";
+    AppWidgetProviderInfo info2 = new AppWidgetProviderInfo();
+    info2.label = "def";
+    AppWidgetProviderInfo info3 = new AppWidgetProviderInfo();
+    info2.label = "efa";
+    shadowAppWidgetManager.addInstalledProvider(info1);
+    shadowAppWidgetManager.addInstalledProvider(info2);
+    List<AppWidgetProviderInfo> installedProviders = appWidgetManager.getInstalledProviders();
+    assertEquals(2, installedProviders.size());
+    assertEquals(info1, installedProviders.get(0));
+    assertEquals(info2, installedProviders.get(1));
+    assertFalse(shadowAppWidgetManager.removeInstalledProvider(info3));
+    installedProviders = appWidgetManager.getInstalledProviders();
+    assertEquals(2, installedProviders.size());
+    assertEquals(info1, installedProviders.get(0));
+    assertEquals(info2, installedProviders.get(1));
+  }
+
+  @Test
+  @Config(minSdk = L)
+  public void getInstalledProvidersForProfile_returnsWidgetList() {
+    UserHandle userHandle = UserHandle.CURRENT;
+    assertTrue(appWidgetManager.getInstalledProvidersForProfile(userHandle).isEmpty());
+
+    AppWidgetProviderInfo info1 = new AppWidgetProviderInfo();
+    info1.label = "abc";
+    AppWidgetProviderInfo info2 = new AppWidgetProviderInfo();
+    info2.label = "def";
+    shadowAppWidgetManager.addInstalledProvidersForProfile(userHandle, info1);
+    shadowAppWidgetManager.addInstalledProvidersForProfile(userHandle, info2);
+    List<AppWidgetProviderInfo> installedProvidersForProfile =
+        appWidgetManager.getInstalledProvidersForProfile(userHandle);
+    assertEquals(2, installedProvidersForProfile.size());
+    assertTrue(installedProvidersForProfile.contains(info1));
+    assertTrue(installedProvidersForProfile.contains(info2));
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getInstalledProvidersForPackage_returnsWidgetList() {
+    UserHandle userHandle = UserHandle.CURRENT;
+    String packageName = "com.google.fakeapp";
+
+    assertTrue(appWidgetManager.getInstalledProvidersForPackage(packageName, userHandle).isEmpty());
+
+    AppWidgetProviderInfo info1 = new AppWidgetProviderInfo();
+    info1.label = "abc";
+    info1.provider = new ComponentName(packageName, "123");
+    AppWidgetProviderInfo info2 = new AppWidgetProviderInfo();
+    info2.label = "def";
+    info2.provider = new ComponentName(packageName, "456");
+    shadowAppWidgetManager.addInstalledProvidersForProfile(userHandle, info1);
+    shadowAppWidgetManager.addInstalledProvidersForProfile(userHandle, info2);
+    List<AppWidgetProviderInfo> installedProvidersForProfile =
+        appWidgetManager.getInstalledProvidersForPackage(packageName, userHandle);
+
+    assertEquals(2, installedProvidersForProfile.size());
+    assertTrue(installedProvidersForProfile.contains(info1));
+    assertTrue(installedProvidersForProfile.contains(info2));
+  }
+
+  @Test
+  public void updateAppWidget_landscapeAndPortrait() {
+    ComponentName provider = new ComponentName(context, SpanishTestAppWidgetProvider.class);
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+
+    RemoteViews landscape = new RemoteViews(provider.getPackageName(), R.layout.remote_views);
+    RemoteViews portrait = new RemoteViews(provider.getPackageName(), R.layout.remote_views_alt);
+    RemoteViews combined = new RemoteViews(landscape, portrait);
+    appWidgetManager.updateAppWidget(789, combined);
+
+    assertViewId(R.id.remote_views_alt_root, shadowAppWidgetManager.getViewFor(789));
+  }
+
+  @Test
+  public void updateAppWidget_landscapeAndPortrait_canReapplySameView() {
+    ComponentName provider = new ComponentName(context, SpanishTestAppWidgetProvider.class);
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+
+    RemoteViews landscape = new RemoteViews(provider.getPackageName(), R.layout.remote_views);
+    RemoteViews portrait = new RemoteViews(provider.getPackageName(), R.layout.remote_views_alt);
+    RemoteViews combined = new RemoteViews(landscape, portrait);
+    appWidgetManager.updateAppWidget(789, combined);
+    View originalView = shadowAppWidgetManager.getViewFor(789);
+
+    RemoteViews landscape2 = new RemoteViews(provider.getPackageName(), R.layout.remote_views);
+    RemoteViews portrait2 = new RemoteViews(provider.getPackageName(), R.layout.remote_views_alt);
+    RemoteViews combined2 = new RemoteViews(landscape2, portrait2);
+    appWidgetManager.updateAppWidget(789, combined2);
+
+    // A bug around reapplying RemoteViews with landscape and portrait layouts was fixed in API 25.
+    if (VERSION.SDK_INT >= 25) {
+      assertSame(originalView, shadowAppWidgetManager.getViewFor(789));
+    } else {
+      assertNotSame(originalView, shadowAppWidgetManager.getViewFor(789));
+    }
+  }
+
+  @Test
+  public void updateAppWidget_landscapeAndPortrait_doesntReapplyDifferntViews() {
+    ComponentName provider = new ComponentName(context, SpanishTestAppWidgetProvider.class);
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+
+    RemoteViews landscape = new RemoteViews(provider.getPackageName(), R.layout.remote_views);
+    RemoteViews portrait = new RemoteViews(provider.getPackageName(), R.layout.remote_views_alt);
+    RemoteViews combined = new RemoteViews(landscape, portrait);
+    appWidgetManager.updateAppWidget(789, combined);
+    View originalView = shadowAppWidgetManager.getViewFor(789);
+
+    RemoteViews landscape2 = new RemoteViews(provider.getPackageName(), R.layout.remote_views_alt);
+    RemoteViews portrait2 = new RemoteViews(provider.getPackageName(), R.layout.remote_views);
+    RemoteViews combined2 = new RemoteViews(landscape2, portrait2);
+    appWidgetManager.updateAppWidget(789, combined2);
+
+    assertNotSame(originalView, shadowAppWidgetManager.getViewFor(789));
+  }
+
+  @Test
+  public void updateAppWidget_invalidViewsInLayout_shouldThrow() {
+    ComponentName provider = new ComponentName(context, SpanishTestAppWidgetProvider.class);
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+
+    RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.remote_views_bad);
+
+    assertThrows(RuntimeException.class, () -> appWidgetManager.updateAppWidget(789, remoteViews));
+  }
+
+  @Test
+  public void updateAppWidgetOptions_shouldSetOptionsBundle() {
+    ComponentName provider = new ComponentName("A", "B");
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+    Bundle options = new Bundle();
+    options.putString("key", "value");
+
+    appWidgetManager.updateAppWidgetOptions(789, options);
+
+    assertEquals("value", appWidgetManager.getAppWidgetOptions(789).getString("key"));
+  }
+
+  @Test
+  public void updateAppWidgetOptions_shouldMergeOptionsBundleIfAlreadyExists() {
+    ComponentName provider = new ComponentName("A", "B");
+    appWidgetManager.bindAppWidgetIdIfAllowed(789, provider);
+    Bundle options = new Bundle();
+    options.putString("key", "value");
+    Bundle newOptions = new Bundle();
+    options.putString("key2", "value2");
+
+    appWidgetManager.updateAppWidgetOptions(789, options);
+    appWidgetManager.updateAppWidgetOptions(789, newOptions);
+
+    Bundle retrievedOptions = appWidgetManager.getAppWidgetOptions(789);
+    assertEquals(2, retrievedOptions.size());
+    assertEquals("value", retrievedOptions.getString("key"));
+    assertEquals("value2", retrievedOptions.getString("key2"));
+  }
+
+  @Test
+  public void updateAppWidgetOptions_triggersOnAppWidgetOptionsUpdated() {
+    int widgetId =
+        shadowAppWidgetManager.createWidget(
+            SpanishTestAppWidgetProvider.class, R.layout.remote_views);
+
+    appWidgetManager.updateAppWidgetOptions(widgetId, new Bundle());
+    View widgetView = shadowAppWidgetManager.getViewFor(widgetId);
+
+    assertEquals(
+        "Actualizar", ((TextView) widgetView.findViewById(R.id.subtitle)).getText().toString());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isRequestPinAppWidgetSupported_shouldReturnThePresetBoolean() {
+    shadowAppWidgetManager.setRequestPinAppWidgetSupported(false);
+    assertFalse(shadowAppWidgetManager.isRequestPinAppWidgetSupported());
+    shadowAppWidgetManager.setRequestPinAppWidgetSupported(true);
+    assertTrue(shadowAppWidgetManager.isRequestPinAppWidgetSupported());
+  }
+
+  @SuppressWarnings("PendingIntentMutability")
+  @Test
+  @Config(minSdk = O)
+  public void
+      requestPinAppWidget_isRequestPinAppWidgetSupportedFalse_shouldNotBindAndReturnFalse() {
+    shadowAppWidgetManager.setRequestPinAppWidgetSupported(false);
+
+    String intentAction = "some_action";
+    PendingIntent testSuccessIntent =
+        PendingIntent.getBroadcast(
+            ApplicationProvider.getApplicationContext(), 0, new Intent(intentAction), 0);
+
+    AtomicBoolean successCallbackCalled = new AtomicBoolean(false);
+    ApplicationProvider.getApplicationContext()
+        .registerReceiver(
+            new BroadcastReceiver() {
+              @Override
+              public void onReceive(Context context, Intent intent) {
+                successCallbackCalled.set(true);
+              }
+            },
+            new IntentFilter(intentAction));
+
+    assertFalse(
+        shadowAppWidgetManager.requestPinAppWidget(
+            new ComponentName("A", "B"), null, testSuccessIntent));
+    assertFalse(successCallbackCalled.get());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestPinAppWidget_isRequestPinAppWidgetSupportedTrue_shouldBindWidget() {
+    shadowAppWidgetManager.setRequestPinAppWidgetSupported(true);
+
+    ComponentName provider = new ComponentName("A", "B");
+
+    shadowAppWidgetManager.requestPinAppWidget(provider, null, null);
+    shadowOf(getMainLooper()).idle();
+
+    assertEquals(1, shadowAppWidgetManager.getAppWidgetIds(provider).length);
+  }
+
+  @SuppressWarnings("PendingIntentMutability")
+  @Test
+  @Config(minSdk = O)
+  public void
+      requestPinAppWidget_isRequestPinAppWidgetSupportedTrue_shouldExecuteCallbackWithOriginalIntentAndAppWidgetIdExtra() {
+    shadowAppWidgetManager.setRequestPinAppWidgetSupported(true);
+
+    String intentAction = "some_action";
+    Intent originalIntent = new Intent(intentAction);
+    originalIntent.setPackage(ApplicationProvider.getApplicationContext().getPackageName());
+    originalIntent.setComponent(
+        new ComponentName(
+            ApplicationProvider.getApplicationContext(), ShadowAppWidgetManagerTest.class));
+    originalIntent.putExtra("some_extra", "my_value");
+
+    PendingIntent testSuccessIntent =
+        PendingIntent.getBroadcast(
+            ApplicationProvider.getApplicationContext(), 0, originalIntent, 0);
+
+    AtomicReference<Intent> callbackIntent = new AtomicReference<>();
+    ApplicationProvider.getApplicationContext()
+        .registerReceiver(
+            new BroadcastReceiver() {
+              @Override
+              public void onReceive(Context context, Intent intent) {
+                callbackIntent.set(intent);
+              }
+            },
+            new IntentFilter(intentAction));
+
+    shadowAppWidgetManager.requestPinAppWidget(
+        new ComponentName("A", "B"), null, testSuccessIntent);
+    shadowOf(getMainLooper()).idle();
+
+    assertNotNull(callbackIntent);
+
+    // Original intent fields still exist.
+    assertEquals("my_value", callbackIntent.get().getStringExtra("some_extra"));
+    assertEquals(intentAction, callbackIntent.get().getAction());
+
+    // Additionally, the newly created appwidget id is added to the extras.
+    assertEquals(1, callbackIntent.get().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1));
+  }
+
+  @SuppressWarnings("PendingIntentMutability")
+  @Test
+  @Config(minSdk = O)
+  public void requestPinAppWidget_isRequestPinAppWidgetSupportedTrue_shouldUseUniqueWidgetIds() {
+    shadowAppWidgetManager.setRequestPinAppWidgetSupported(true);
+
+    String intentAction = "some_action";
+    PendingIntent testSuccessIntent =
+        PendingIntent.getBroadcast(
+            ApplicationProvider.getApplicationContext(), 0, new Intent(intentAction), 0);
+
+    AtomicInteger callbackAppWidgetId = new AtomicInteger();
+    ApplicationProvider.getApplicationContext()
+        .registerReceiver(
+            new BroadcastReceiver() {
+              @Override
+              public void onReceive(Context context, Intent intent) {
+                callbackAppWidgetId.set(
+                    intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1));
+              }
+            },
+            new IntentFilter(intentAction));
+
+    // Create first widget.
+    shadowAppWidgetManager.requestPinAppWidget(
+        new ComponentName("A", "B"), null, testSuccessIntent);
+    shadowOf(getMainLooper()).idle();
+    assertEquals(1, callbackAppWidgetId.get());
+
+    // Create a second widget. It should have a different ID than the first.
+    shadowAppWidgetManager.requestPinAppWidget(
+        new ComponentName("C", "D"), null, testSuccessIntent);
+    shadowOf(getMainLooper()).idle();
+    assertEquals(2, callbackAppWidgetId.get());
+  }
+
+  /**
+   * Asserts that the id of {@code view} matches {@code id}. Asserts on the string name, which
+   * provides a more useful error message than the int value.
+   */
+  private void assertViewId(int id, View view) {
+    assertEquals(getResourceName(id), getResourceName(view.getId()));
+  }
+
+  private String getResourceName(int id) {
+    try {
+      return context.getResources().getResourceName(id);
+    } catch (NotFoundException e) {
+      return String.valueOf(id);
+    }
+  }
+
+  public static class SpanishTestAppWidgetProvider extends AppWidgetProvider {
+    @Override
+    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+      RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.remote_views);
+      remoteViews.setTextViewText(R.id.subtitle, "Hola");
+      appWidgetManager.updateAppWidget(appWidgetIds, remoteViews);
+    }
+
+    @Override
+    public void onAppWidgetOptionsChanged(
+        Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
+      RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.remote_views);
+      remoteViews.setTextViewText(R.id.subtitle, "Actualizar");
+      appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowApplicationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowApplicationTest.java
new file mode 100644
index 0000000..dd36759
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowApplicationTest.java
@@ -0,0 +1,943 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.RestrictionsManager;
+import android.content.ServiceConnection;
+import android.hardware.SystemSensorManager;
+import android.hardware.fingerprint.FingerprintManager;
+import android.media.session.MediaSessionManager;
+import android.net.nsd.NsdManager;
+import android.os.BatteryManager;
+import android.os.Binder;
+import android.os.Build.VERSION_CODES;
+import android.os.IBinder;
+import android.os.UserManager;
+import android.os.Vibrator;
+import android.print.PrintManager;
+import android.telephony.SubscriptionManager;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.CaptioningManager;
+import android.view.autofill.AutofillManager;
+import android.view.textclassifier.TextClassificationManager;
+import android.widget.LinearLayout;
+import android.widget.PopupWindow;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadows.testing.TestActivity;
+import org.robolectric.util.Scheduler;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowApplicationTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void shouldBeAContext() throws Exception {
+    assertThat(Robolectric.setupActivity(Activity.class).getApplication())
+        .isSameInstanceAs(ApplicationProvider.getApplicationContext());
+    assertThat(Robolectric.setupActivity(Activity.class).getApplication().getApplicationContext())
+        .isSameInstanceAs(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void shouldProvideServices() throws Exception {
+    assertThat(context.getSystemService(Context.ACTIVITY_SERVICE))
+        .isInstanceOf(android.app.ActivityManager.class);
+    assertThat(context.getSystemService(Context.POWER_SERVICE))
+        .isInstanceOf(android.os.PowerManager.class);
+    assertThat(context.getSystemService(Context.ALARM_SERVICE))
+        .isInstanceOf(android.app.AlarmManager.class);
+    assertThat(context.getSystemService(Context.NOTIFICATION_SERVICE))
+        .isInstanceOf(android.app.NotificationManager.class);
+    assertThat(context.getSystemService(Context.KEYGUARD_SERVICE))
+        .isInstanceOf(android.app.KeyguardManager.class);
+    assertThat(context.getSystemService(Context.LOCATION_SERVICE))
+        .isInstanceOf(android.location.LocationManager.class);
+    assertThat(context.getSystemService(Context.SEARCH_SERVICE))
+        .isInstanceOf(android.app.SearchManager.class);
+    assertThat(context.getSystemService(Context.SENSOR_SERVICE))
+        .isInstanceOf(SystemSensorManager.class);
+    assertThat(context.getSystemService(Context.STORAGE_SERVICE))
+        .isInstanceOf(android.os.storage.StorageManager.class);
+    assertThat(context.getSystemService(Context.VIBRATOR_SERVICE)).isInstanceOf(Vibrator.class);
+    assertThat(context.getSystemService(Context.CONNECTIVITY_SERVICE))
+        .isInstanceOf(android.net.ConnectivityManager.class);
+    assertThat(context.getSystemService(Context.WIFI_SERVICE))
+        .isInstanceOf(android.net.wifi.WifiManager.class);
+    assertThat(context.getSystemService(Context.AUDIO_SERVICE))
+        .isInstanceOf(android.media.AudioManager.class);
+    assertThat(context.getSystemService(Context.TELEPHONY_SERVICE))
+        .isInstanceOf(android.telephony.TelephonyManager.class);
+    assertThat(context.getSystemService(Context.INPUT_METHOD_SERVICE))
+        .isInstanceOf(android.view.inputmethod.InputMethodManager.class);
+    assertThat(context.getSystemService(Context.UI_MODE_SERVICE))
+        .isInstanceOf(android.app.UiModeManager.class);
+    assertThat(context.getSystemService(Context.DOWNLOAD_SERVICE))
+        .isInstanceOf(android.app.DownloadManager.class);
+    assertThat(context.getSystemService(Context.DEVICE_POLICY_SERVICE))
+        .isInstanceOf(android.app.admin.DevicePolicyManager.class);
+    assertThat(context.getSystemService(Context.DROPBOX_SERVICE))
+        .isInstanceOf(android.os.DropBoxManager.class);
+    assertThat(context.getSystemService(Context.MEDIA_ROUTER_SERVICE))
+        .isInstanceOf(android.media.MediaRouter.class);
+    assertThat(context.getSystemService(Context.ACCESSIBILITY_SERVICE))
+        .isInstanceOf(AccessibilityManager.class);
+    assertThat(context.getSystemService(Context.NSD_SERVICE)).isInstanceOf(NsdManager.class);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldProvideServicesIntroducedInJellyBeanMr1() throws Exception {
+    assertThat(context.getSystemService(Context.DISPLAY_SERVICE))
+        .isInstanceOf(android.hardware.display.DisplayManager.class);
+    assertThat(context.getSystemService(Context.USER_SERVICE)).isInstanceOf(UserManager.class);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void shouldProvideServicesIntroducedInKitKat() throws Exception {
+    assertThat(context.getSystemService(Context.PRINT_SERVICE)).isInstanceOf(PrintManager.class);
+    assertThat(context.getSystemService(Context.CAPTIONING_SERVICE))
+        .isInstanceOf(CaptioningManager.class);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldProvideServicesIntroducedInLollipop() throws Exception {
+    assertThat(context.getSystemService(Context.MEDIA_SESSION_SERVICE))
+        .isInstanceOf(MediaSessionManager.class);
+    assertThat(context.getSystemService(Context.BATTERY_SERVICE))
+        .isInstanceOf(BatteryManager.class);
+    assertThat(context.getSystemService(Context.RESTRICTIONS_SERVICE))
+        .isInstanceOf(RestrictionsManager.class);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void shouldProvideServicesIntroducedInLollipopMr1() throws Exception {
+    assertThat(context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE))
+        .isInstanceOf(SubscriptionManager.class);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldProvideServicesIntroducedMarshmallow() throws Exception {
+    assertThat(context.getSystemService(Context.FINGERPRINT_SERVICE))
+        .isInstanceOf(FingerprintManager.class);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldProvideServicesIntroducedOreo() throws Exception {
+    // Context.AUTOFILL_MANAGER_SERVICE is marked @hide and this is the documented way to obtain
+    // this service.
+    AutofillManager autofillManager = context.getSystemService(AutofillManager.class);
+    assertThat(autofillManager).isNotNull();
+
+    assertThat(context.getSystemService(Context.TEXT_CLASSIFICATION_SERVICE))
+        .isInstanceOf(TextClassificationManager.class);
+  }
+
+  @Test
+  public void shouldProvideLayoutInflater() throws Exception {
+    Object systemService = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+    assertThat(systemService).isInstanceOf(LayoutInflater.class);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void shouldCorrectlyInstantiatedAccessibilityService() throws Exception {
+    AccessibilityManager accessibilityManager =
+        (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+    AccessibilityManager.TouchExplorationStateChangeListener listener = enabled -> {};
+    assertThat(accessibilityManager.addTouchExplorationStateChangeListener(listener)).isTrue();
+    assertThat(accessibilityManager.removeTouchExplorationStateChangeListener(listener)).isTrue();
+  }
+
+  @Test
+  public void bindServiceShouldThrowIfSetToThrow() {
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    SecurityException expectedException = new SecurityException("expected");
+    Shadows.shadowOf(context).setThrowInBindService(expectedException);
+
+    try {
+      context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+      fail("bindService should throw SecurityException!");
+    } catch (SecurityException thrownException) {
+      assertThat(thrownException).isEqualTo(expectedException);
+    }
+  }
+
+  @Test
+  public void
+      setBindServiceCallsOnServiceConnectedDirectly_setToTrue_onServiceConnectedCalledDuringCall() {
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).setBindServiceCallsOnServiceConnectedDirectly(true);
+
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+
+    assertThat(service.service).isNotNull();
+  }
+
+  @Test
+  public void
+      setBindServiceCallsOnServiceConnectedDirectly_setToTrue_locksUntilBound_onServiceConnectedCalledDuringCall()
+          throws InterruptedException {
+    final CountDownLatch latch = new CountDownLatch(1);
+    TestService service =
+        new TestService() {
+          @Override
+          public void onServiceConnected(ComponentName name, IBinder service) {
+            super.onServiceConnected(name, service);
+            latch.countDown();
+          }
+        };
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).setBindServiceCallsOnServiceConnectedDirectly(true);
+
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+
+    // Lock waiting for onService connected to finish
+    assertThat(latch.await(1000, MILLISECONDS)).isTrue();
+    assertThat(service.service).isNotNull();
+  }
+
+  @Test
+  public void
+      setBindServiceCallsOnServiceConnectedDirectly_setToFalse_onServiceConnectedNotCalledDuringCall() {
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).setBindServiceCallsOnServiceConnectedDirectly(false);
+
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+
+    assertThat(service.service).isNull();
+  }
+
+  @Test
+  public void
+      setBindServiceCallsOnServiceConnectedDirectly_setToFalse_locksUntilBound_onServiceConnectedCalledDuringCall()
+          throws InterruptedException {
+    final CountDownLatch latch = new CountDownLatch(1);
+    TestService service =
+        new TestService() {
+          @Override
+          public void onServiceConnected(ComponentName name, IBinder service) {
+            super.onServiceConnected(name, service);
+            latch.countDown();
+          }
+        };
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).setBindServiceCallsOnServiceConnectedDirectly(false);
+
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+
+    // Lock waiting for onService connected to finish
+    assertThat(latch.await(1000, MILLISECONDS)).isFalse();
+    assertThat(service.service).isNull();
+
+    // After idling the callback has been made.
+    ShadowLooper.idleMainLooper();
+
+    assertThat(latch.await(1000, MILLISECONDS)).isTrue();
+    assertThat(service.service).isNotNull();
+  }
+
+  @Test
+  public void
+      setBindServiceCallsOnServiceConnectedDirectly_notSet_onServiceConnectedNotCalledDuringCall() {
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+
+    assertThat(service.service).isNull();
+  }
+
+  @Test
+  public void bindServiceShouldCallOnServiceConnectedWithDefaultValues_ifFlagUnset() {
+    Shadows.shadowOf(context).setUnbindServiceCallsOnServiceDisconnected(false);
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentName);
+    assertThat(service.service).isEqualTo(expectedBinder);
+    assertThat(service.nameDisconnected).isNull();
+  }
+
+  @Test
+  public void bindServiceShouldCallOnServiceConnectedWithDefaultValues_ifFlagSet() {
+    Shadows.shadowOf(context).setUnbindServiceCallsOnServiceDisconnected(true);
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentName);
+    assertThat(service.service).isEqualTo(expectedBinder);
+    assertThat(service.nameDisconnected).isNull();
+    context.unbindService(service);
+    shadowMainLooper().idle();
+    assertThat(service.nameDisconnected).isEqualTo(expectedComponentName);
+  }
+
+  @Test
+  public void bindServiceShouldCallOnServiceConnectedWithNullValues() {
+    TestService service = new TestService();
+    context.bindService(new Intent("").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+  }
+
+  @Test
+  public void bindServiceShouldCallOnServiceConnectedWhenNotPaused() {
+    shadowMainLooper().pause();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("expected").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntent, expectedComponentName, expectedBinder);
+
+    TestService service = new TestService();
+    assertThat(context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE)).isTrue();
+
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+
+    shadowMainLooper().idle();
+
+    assertThat(service.name).isEqualTo(expectedComponentName);
+    assertThat(service.service).isEqualTo(expectedBinder);
+  }
+
+  @Test
+  public void unbindServiceShouldNotCallOnServiceDisconnected_ifFlagUnset() {
+    Shadows.shadowOf(context).setUnbindServiceCallsOnServiceDisconnected(false);
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("expected").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntent, expectedComponentName, expectedBinder);
+    context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE);
+
+    context.unbindService(service);
+
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentName);
+    assertThat(service.service).isEqualTo(expectedBinder);
+    assertThat(service.nameDisconnected).isNull();
+  }
+
+  @Test
+  public void unbindServiceShouldCallOnServiceDisconnectedWhenNotPaused_ifFlagSet() {
+    Shadows.shadowOf(context).setUnbindServiceCallsOnServiceDisconnected(true);
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("expected").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntent, expectedComponentName, expectedBinder);
+    context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().pause();
+
+    context.unbindService(service);
+    assertThat(service.nameDisconnected).isNull();
+    shadowMainLooper().idle();
+    assertThat(service.nameDisconnected).isEqualTo(expectedComponentName);
+  }
+
+  @Test
+  public void unbindServiceAddsEntryToUnboundServicesCollection() {
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("expected").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntent, expectedComponentName, expectedBinder);
+    context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE);
+    context.unbindService(service);
+    assertThat(Shadows.shadowOf(context).getUnboundServiceConnections()).hasSize(1);
+    assertThat(Shadows.shadowOf(context).getUnboundServiceConnections().get(0))
+        .isSameInstanceAs(service);
+  }
+
+  @Test
+  public void declaringActionUnbindableMakesBindServiceReturnFalse() {
+    shadowMainLooper().pause();
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("", "");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("refuseToBind").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntent, expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).declareActionUnbindable(expectedIntent.getAction());
+    assertFalse(context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE));
+    shadowMainLooper().idle();
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+    assertThat(Shadows.shadowOf(context).peekNextStartedService()).isNull();
+  }
+
+  @Test
+  public void declaringComponentUnbindableMakesBindServiceReturnFalse_intentWithComponent() {
+    shadowMainLooper().pause();
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("unbindable", "service");
+    Intent intent = new Intent("unbindable").setComponent(expectedComponentName);
+    Shadows.shadowOf(context).declareComponentUnbindable(expectedComponentName);
+    assertThat(context.bindService(intent, service, Context.BIND_AUTO_CREATE)).isFalse();
+    shadowMainLooper().idle();
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+    assertThat(Shadows.shadowOf(context).peekNextStartedService()).isNull();
+  }
+
+  @Test
+  public void declaringComponentUnbindableMakesBindServiceReturnFalse_intentWithoutComponent() {
+    shadowMainLooper().pause();
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("unbindable", "service");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("expected").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntent, expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).declareComponentUnbindable(expectedComponentName);
+    assertThat(context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE)).isFalse();
+    shadowMainLooper().idle();
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+    assertThat(Shadows.shadowOf(context).peekNextStartedService()).isNull();
+  }
+
+  @Test
+  public void declaringComponentUnbindableMakesBindServiceReturnFalse_defaultComponent() {
+    shadowMainLooper().pause();
+    TestService service = new TestService();
+    ComponentName expectedComponentName = new ComponentName("unbindable", "service");
+    Binder expectedBinder = new Binder();
+    Intent expectedIntent = new Intent("expected").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindService(expectedComponentName, expectedBinder);
+    Shadows.shadowOf(context).declareComponentUnbindable(expectedComponentName);
+    assertThat(context.bindService(expectedIntent, service, Context.BIND_AUTO_CREATE)).isFalse();
+    shadowMainLooper().idle();
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+    assertThat(Shadows.shadowOf(context).peekNextStartedService()).isNull();
+  }
+
+  @Test
+  public void bindServiceWithMultipleIntentsMapping() {
+    TestService service = new TestService();
+    ComponentName expectedComponentNameOne = new ComponentName("package", "one");
+    Binder expectedBinderOne = new Binder();
+    Intent expectedIntentOne = new Intent("expected_one").setPackage("package");
+    ComponentName expectedComponentNameTwo = new ComponentName("package", "two");
+    Binder expectedBinderTwo = new Binder();
+    Intent expectedIntentTwo = new Intent("expected_two").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntentOne, expectedComponentNameOne, expectedBinderOne);
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntentTwo, expectedComponentNameTwo, expectedBinderTwo);
+    context.bindService(expectedIntentOne, service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentNameOne);
+    assertThat(service.service).isEqualTo(expectedBinderOne);
+    context.bindService(expectedIntentTwo, service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentNameTwo);
+    assertThat(service.service).isEqualTo(expectedBinderTwo);
+  }
+
+  @Test
+  public void bindServiceWithMultipleIntentsMappingWithDefault() {
+    TestService service = new TestService();
+    ComponentName expectedComponentNameOne = new ComponentName("package", "one");
+    Binder expectedBinderOne = new Binder();
+    Intent expectedIntentOne = new Intent("expected_one").setPackage("package");
+    ComponentName expectedComponentNameTwo = new ComponentName("package", "two");
+    Binder expectedBinderTwo = new Binder();
+    Intent expectedIntentTwo = new Intent("expected_two").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntentOne, expectedComponentNameOne, expectedBinderOne);
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntentTwo, expectedComponentNameTwo, expectedBinderTwo);
+    context.bindService(expectedIntentOne, service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentNameOne);
+    assertThat(service.service).isEqualTo(expectedBinderOne);
+    context.bindService(expectedIntentTwo, service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isEqualTo(expectedComponentNameTwo);
+    assertThat(service.service).isEqualTo(expectedBinderTwo);
+    context.bindService(
+        new Intent("unknown").setPackage("package"), service, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(service.name).isNull();
+    assertThat(service.service).isNull();
+  }
+
+  @Test
+  public void unbindServiceWithMultipleIntentsMapping() {
+    TestService serviceOne = new TestService();
+    ComponentName expectedComponentNameOne = new ComponentName("package", "one");
+    Binder expectedBinderOne = new Binder();
+    Intent expectedIntentOne = new Intent("expected_one").setPackage("package");
+    TestService serviceTwo = new TestService();
+    ComponentName expectedComponentNameTwo = new ComponentName("package", "two");
+    Binder expectedBinderTwo = new Binder();
+    Intent expectedIntentTwo = new Intent("expected_two").setPackage("package");
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntentOne, expectedComponentNameOne, expectedBinderOne);
+    Shadows.shadowOf(context)
+        .setComponentNameAndServiceForBindServiceForIntent(
+            expectedIntentTwo, expectedComponentNameTwo, expectedBinderTwo);
+
+    context.bindService(expectedIntentOne, serviceOne, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(serviceOne.nameDisconnected).isNull();
+    context.unbindService(serviceOne);
+    shadowMainLooper().idle();
+    assertThat(serviceOne.name).isEqualTo(expectedComponentNameOne);
+
+    context.bindService(expectedIntentTwo, serviceTwo, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(serviceTwo.nameDisconnected).isNull();
+    context.unbindService(serviceTwo);
+    shadowMainLooper().idle();
+    assertThat(serviceTwo.name).isEqualTo(expectedComponentNameTwo);
+
+    TestService serviceDefault = new TestService();
+    context.bindService(
+        new Intent("default").setPackage("package"), serviceDefault, Context.BIND_AUTO_CREATE);
+    shadowMainLooper().idle();
+    assertThat(serviceDefault.nameDisconnected).isNull();
+    context.unbindService(serviceDefault);
+    shadowMainLooper().idle();
+    assertThat(serviceDefault.name).isNull();
+  }
+
+  @Test
+  public void shouldHaveStoppedServiceIntentAndIndicateServiceWasntRunning() {
+
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    Intent intent = getSomeActionIntent("some.action");
+
+    boolean wasRunning = activity.stopService(intent);
+
+    assertFalse(wasRunning);
+    assertThat(Shadows.shadowOf(context).getNextStoppedService()).isEqualTo(intent);
+  }
+
+  private Intent getSomeActionIntent(String action) {
+    Intent intent = new Intent();
+    intent.setAction(action);
+    intent.setPackage("package");
+    return intent;
+  }
+
+  @Test
+  public void shouldHaveStoppedServiceIntentAndIndicateServiceWasRunning() {
+
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    Intent intent = getSomeActionIntent("some.action");
+
+    activity.startService(intent);
+
+    boolean wasRunning = activity.stopService(intent);
+
+    assertTrue(wasRunning);
+    assertThat(shadowOf(context).getNextStoppedService()).isEqualTo(intent);
+  }
+
+  @Test
+  public void shouldHaveStoppedServiceByStartedComponent() {
+
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    ComponentName componentName = new ComponentName("package.test", "package.test.TestClass");
+    Intent startServiceIntent = new Intent().setComponent(componentName);
+
+    ComponentName startedComponent = activity.startService(startServiceIntent);
+    assertThat(startedComponent.getPackageName()).isEqualTo("package.test");
+    assertThat(startedComponent.getClassName()).isEqualTo("package.test.TestClass");
+
+    Intent stopServiceIntent = new Intent().setComponent(startedComponent);
+    stopServiceIntent.putExtra("someExtra", "someValue");
+    boolean wasRunning = activity.stopService(stopServiceIntent);
+
+    assertTrue(wasRunning);
+    final Intent nextStoppedService = shadowOf(context).getNextStoppedService();
+    assertThat(nextStoppedService.filterEquals(startServiceIntent)).isTrue();
+    assertThat(nextStoppedService.getStringExtra("someExtra")).isEqualTo("someValue");
+  }
+
+  @Test
+  public void shouldClearStartedServiceIntents() {
+    context.startService(getSomeActionIntent("some.action"));
+    context.startService(getSomeActionIntent("another.action"));
+
+    shadowOf(context).clearStartedServices();
+
+    assertNull(shadowOf(context).getNextStartedService());
+  }
+
+  @Test
+  public void getAllStartedServices() {
+    Intent intent1 = getSomeActionIntent("some.action");
+    Intent intent2 = getSomeActionIntent("another.action");
+
+    context.startService(intent1);
+    context.startService(intent2);
+    List<Intent> startedServiceIntents = shadowOf(context).getAllStartedServices();
+
+    assertThat(startedServiceIntents).hasSize(2);
+    assertThat(startedServiceIntents.get(0).filterEquals(intent1)).isTrue();
+    assertThat(startedServiceIntents.get(1).filterEquals(intent2)).isTrue();
+    assertNotNull(shadowOf(context).getNextStartedService());
+    assertNotNull(shadowOf(context).getNextStartedService());
+    assertNull(shadowOf(context).getNextStartedService());
+  }
+
+  @Test
+  public void shouldThrowIfContainsRegisteredReceiverOfAction() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.registerReceiver(new TestBroadcastReceiver(), new IntentFilter("Foo"));
+
+    try {
+      shadowOf(context).assertNoBroadcastListenersOfActionRegistered(activity, "Foo");
+
+      fail("should have thrown IllegalStateException");
+    } catch (IllegalStateException e) {
+      // ok
+    }
+  }
+
+  @Test
+  public void shouldNotThrowIfDoesNotContainsRegisteredReceiverOfAction() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.registerReceiver(new TestBroadcastReceiver(), new IntentFilter("Foo"));
+
+    shadowOf(context).assertNoBroadcastListenersOfActionRegistered(activity, "Bar");
+  }
+
+  @Test
+  public void canAnswerIfReceiverIsRegisteredForIntent() throws Exception {
+    BroadcastReceiver expectedReceiver = new TestBroadcastReceiver();
+    assertFalse(shadowOf(context).hasReceiverForIntent(new Intent("Foo")));
+    context.registerReceiver(expectedReceiver, new IntentFilter("Foo"));
+
+    assertTrue(shadowOf(context).hasReceiverForIntent(new Intent("Foo")));
+  }
+
+  @Test
+  public void canFindAllReceiversForAnIntent() throws Exception {
+    BroadcastReceiver expectedReceiver = new TestBroadcastReceiver();
+    assertFalse(shadowOf(context).hasReceiverForIntent(new Intent("Foo")));
+    context.registerReceiver(expectedReceiver, new IntentFilter("Foo"));
+    context.registerReceiver(expectedReceiver, new IntentFilter("Foo"));
+
+    assertThat(shadowOf(context).getReceiversForIntent(new Intent("Foo"))).hasSize(2);
+  }
+
+  @Test
+  public void broadcasts_shouldBeLogged() {
+    Intent broadcastIntent = new Intent("foo");
+    context.sendBroadcast(broadcastIntent);
+
+    List<Intent> broadcastIntents = shadowOf(context).getBroadcastIntents();
+    assertThat(broadcastIntents).hasSize(1);
+    assertThat(broadcastIntents.get(0)).isEqualTo(broadcastIntent);
+  }
+
+  @Test
+  public void clearRegisteredReceivers_clearsReceivers() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.registerReceiver(new TestBroadcastReceiver(), new IntentFilter("Foo"));
+
+    assertThat(shadowOf(context).getRegisteredReceivers().size()).isAtLeast(1);
+
+    shadowOf(context).clearRegisteredReceivers();
+
+    assertThat(shadowOf(context).getRegisteredReceivers()).isEmpty();
+  }
+
+  @Test
+  public void sendStickyBroadcast() {
+    Intent broadcastIntent = new Intent("Foo");
+    context.sendStickyBroadcast(broadcastIntent);
+
+    // Register after the broadcast has fired. We should immediately get a sticky event.
+    TestBroadcastReceiver receiver = new TestBroadcastReceiver();
+    context.registerReceiver(receiver, new IntentFilter("Foo"));
+    assertTrue(receiver.isSticky);
+
+    // Fire the broadcast again, and we should get a non-sticky event.
+    context.sendStickyBroadcast(broadcastIntent);
+    shadowMainLooper().idle();
+    assertFalse(receiver.isSticky);
+  }
+
+  @Test
+  public void sendBroadcastWithPermission() {
+    Intent broadcastIntent = new Intent("Foo");
+    String permission = "org.robolectric.SOME_PERMISSION";
+
+    TestBroadcastReceiver receiverWithoutPermission = new TestBroadcastReceiver();
+    context.registerReceiver(receiverWithoutPermission, new IntentFilter("Foo"));
+    TestBroadcastReceiver receiverWithPermission = new TestBroadcastReceiver();
+    context.registerReceiver(
+        receiverWithPermission, new IntentFilter("Foo"), permission, /* scheduler= */ null);
+
+    context.sendBroadcast(broadcastIntent);
+    shadowMainLooper().idle();
+    assertThat(receiverWithoutPermission.intent).isEqualTo(broadcastIntent);
+    assertThat(receiverWithPermission.intent).isNull();
+    receiverWithoutPermission.intent = null;
+
+    shadowOf(context).grantPermissions(permission);
+    context.sendBroadcast(broadcastIntent);
+    shadowMainLooper().idle();
+    assertThat(receiverWithoutPermission.intent).isEqualTo(broadcastIntent);
+    assertThat(receiverWithPermission.intent).isEqualTo(broadcastIntent);
+  }
+
+  @Test
+  public void shouldRememberResourcesAfterLazilyLoading() throws Exception {
+    assertSame(context.getResources(), context.getResources());
+  }
+
+  @Test
+  public void startActivity_whenActivityCheckingEnabled_doesntFindResolveInfo() throws Exception {
+    shadowOf(context).checkActivities(true);
+
+    String action = "com.does.not.exist.android.app.v2.mobile";
+
+    try {
+      context.startActivity(new Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+      fail("Expected startActivity to throw ActivityNotFoundException!");
+    } catch (ActivityNotFoundException e) {
+      assertThat(e.getMessage()).contains(action);
+      assertThat(shadowOf(context).getNextStartedActivity()).isNull();
+    }
+  }
+
+  @Test
+  public void startActivity_whenActivityCheckingEnabled_findsResolveInfo() throws Exception {
+    shadowOf(context).checkActivities(true);
+
+    context.startActivity(
+        new Intent()
+            .setClassName(context, TestActivity.class.getName())
+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+
+    assertThat(shadowOf(context).getNextStartedActivity()).isNotNull();
+  }
+
+  @Test
+  public void bindServiceShouldAddServiceConnectionToListOfBoundServiceConnections() {
+    final ServiceConnection expectedServiceConnection = new EmptyServiceConnection();
+
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections()).hasSize(0);
+    assertThat(
+            context.bindService(
+                new Intent("connect").setPackage("dummy.package"), expectedServiceConnection, 0))
+        .isTrue();
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections()).hasSize(1);
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections().get(0))
+        .isSameInstanceAs(expectedServiceConnection);
+  }
+
+  @Test
+  public void
+      bindServiceShouldAddServiceConnectionToListOfBoundServiceConnectionsEvenIfServiceUnbindable() {
+    final ServiceConnection expectedServiceConnection = new EmptyServiceConnection();
+    final String unboundableAction = "refuse";
+    final Intent serviceIntent = new Intent(unboundableAction).setPackage("dummy.package");
+    Shadows.shadowOf(context).declareActionUnbindable(unboundableAction);
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections()).hasSize(0);
+    assertThat(context.bindService(serviceIntent, expectedServiceConnection, 0)).isFalse();
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections()).hasSize(1);
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections().get(0))
+        .isSameInstanceAs(expectedServiceConnection);
+  }
+
+  @Test
+  public void unbindServiceShouldRemoveServiceConnectionFromListOfBoundServiceConnections() {
+    final ServiceConnection expectedServiceConnection = new EmptyServiceConnection();
+
+    assertThat(
+            context.bindService(
+                new Intent("connect").setPackage("dummy.package"), expectedServiceConnection, 0))
+        .isTrue();
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections()).hasSize(1);
+    assertThat(Shadows.shadowOf(context).getUnboundServiceConnections()).hasSize(0);
+    context.unbindService(expectedServiceConnection);
+    assertThat(Shadows.shadowOf(context).getBoundServiceConnections()).hasSize(0);
+    assertThat(Shadows.shadowOf(context).getUnboundServiceConnections()).hasSize(1);
+    assertThat(Shadows.shadowOf(context).getUnboundServiceConnections().get(0))
+        .isSameInstanceAs(expectedServiceConnection);
+  }
+
+  @Test
+  public void getForegroundThreadScheduler_shouldMatchRobolectricValue() {
+    assertThat(Shadows.shadowOf(context).getForegroundThreadScheduler())
+        .isSameInstanceAs(Robolectric.getForegroundThreadScheduler());
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void getBackgroundThreadScheduler_shouldMatchRobolectricValue() {
+    assertThat(Shadows.shadowOf(context).getBackgroundThreadScheduler())
+        .isSameInstanceAs(Robolectric.getBackgroundThreadScheduler());
+  }
+
+  @Test
+  public void getForegroundThreadScheduler_shouldMatchRuntimeEnvironment() {
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    assertThat(Shadows.shadowOf(context).getForegroundThreadScheduler()).isSameInstanceAs(s);
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void getBackgroundThreadScheduler_shouldDifferFromRuntimeEnvironment_byDefault() {
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    assertThat(Shadows.shadowOf(context).getBackgroundThreadScheduler())
+        .isNotSameInstanceAs(RuntimeEnvironment.getMasterScheduler());
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void
+      getBackgroundThreadScheduler_shouldDifferFromRuntimeEnvironment_withAdvancedScheduling() {
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    assertThat(Shadows.shadowOf(context).getBackgroundThreadScheduler()).isNotSameInstanceAs(s);
+  }
+
+  @Test
+  public void getLatestPopupWindow() {
+    PopupWindow pw = new PopupWindow(new LinearLayout(context));
+
+    pw.showAtLocation(new LinearLayout(context), Gravity.CENTER, 0, 0);
+
+    PopupWindow latestPopupWindow =
+        Shadows.shadowOf(RuntimeEnvironment.getApplication()).getLatestPopupWindow();
+    assertThat(latestPopupWindow).isSameInstanceAs(pw);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void shouldReturnNonDefaultProcessName() {
+    ShadowApplication.setProcessName("org.foo:bar");
+    assertThat(Application.getProcessName()).isEqualTo("org.foo:bar");
+  }
+
+  /////////////////////////////
+
+  private static class EmptyServiceConnection implements ServiceConnection {
+    @Override
+    public void onServiceConnected(ComponentName name, IBinder service) {}
+
+    @Override
+    public void onServiceDisconnected(ComponentName name) {}
+  }
+
+  public static class TestBroadcastReceiver extends BroadcastReceiver {
+    public Context context;
+    public Intent intent;
+    public boolean isSticky;
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      this.context = context;
+      this.intent = intent;
+      this.isSticky = isInitialStickyBroadcast();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowArrayAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowArrayAdapterTest.java
new file mode 100644
index 0000000..4578b6d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowArrayAdapterTest.java
@@ -0,0 +1,109 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import android.app.Application;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowArrayAdapterTest {
+  private ArrayAdapter<Integer> arrayAdapter;
+  private Application context;
+
+  @Before public void setUp() throws Exception {
+    List<Integer> list = new ArrayList<>();
+    list.add(1);
+    list.add(2);
+    list.add(3);
+
+    context = ApplicationProvider.getApplicationContext();
+    arrayAdapter = new ArrayAdapter<>(context, 0, list);
+  }
+
+  @Test
+  public void verifyContext() {
+    assertThat(arrayAdapter.getContext()).isSameInstanceAs(context);
+  }
+
+  @Test
+  @SuppressWarnings("BoxedPrimitiveConstructor")
+  public void verifyListContent() {
+    assertEquals(3, arrayAdapter.getCount());
+    assertEquals(Integer.valueOf(1), arrayAdapter.getItem(0));
+    assertEquals(Integer.valueOf(2), arrayAdapter.getItem(1));
+    assertEquals(Integer.valueOf(3), arrayAdapter.getItem(2));
+  }
+
+  @Test
+  public void usesTextViewResourceIdToSetTextWithinListItemView() {
+    ListView parent = new ListView(context);
+    ArrayAdapter<String> arrayAdapter =
+        new ArrayAdapter<>(context, R.layout.main, R.id.title, new String[] {"first value"});
+    View listItemView = arrayAdapter.getView(0, null, parent);
+    TextView titleTextView = listItemView.findViewById(R.id.title);
+    assertEquals("first value", titleTextView.getText().toString());
+  }
+
+  @Test
+  public void hasTheCorrectConstructorResourceIDs() {
+    ArrayAdapter<String> arrayAdapter =
+        new ArrayAdapter<>(context, R.id.title, new String[] {"first value"});
+
+    //this assertion may look a little backwards since R.id.title is labeled
+    //textViewResourceId in the constructor parameter list, but the output is correct.
+    assertThat(Shadows.shadowOf(arrayAdapter).getResourceId()).isEqualTo(R.id.title);
+    assertThat(Shadows.shadowOf(arrayAdapter).getTextViewResourceId()).isNotEqualTo(R.id.title);
+    assertThat(Shadows.shadowOf(arrayAdapter).getTextViewResourceId()).isEqualTo(0);
+
+    ArrayAdapter<String> arrayAdapter2 = new ArrayAdapter<>(context, R.id.title);
+
+    //this assertion may look a little backwards since R.id.title is labeled
+    //textViewResourceId in the constructor parameter list, but the output is correct.
+    assertThat(Shadows.shadowOf(arrayAdapter2).getResourceId()).isEqualTo(R.id.title);
+    assertThat(Shadows.shadowOf(arrayAdapter2).getTextViewResourceId()).isNotEqualTo(R.id.title);
+    assertThat(Shadows.shadowOf(arrayAdapter2).getTextViewResourceId()).isEqualTo(0);
+
+    ArrayAdapter<String> arrayAdapter3 =
+        new ArrayAdapter<>(context, R.id.title, Collections.singletonList("first value"));
+
+    //this assertion may look a little backwards since R.id.title is labeled
+    //textViewResourceId in the constructor parameter list, but the output is correct.
+    assertThat(Shadows.shadowOf(arrayAdapter3).getResourceId()).isEqualTo(R.id.title);
+    assertThat(Shadows.shadowOf(arrayAdapter3).getTextViewResourceId()).isNotEqualTo(R.id.title);
+    assertThat(Shadows.shadowOf(arrayAdapter3).getTextViewResourceId()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldClear() {
+    arrayAdapter.clear();
+    assertEquals(0, arrayAdapter.getCount());
+  }
+
+  @Test
+  @SuppressWarnings("BoxedPrimitiveConstructor")
+  public void test_remove() {
+    Integer firstItem = arrayAdapter.getItem(0);
+    assertEquals(3, arrayAdapter.getCount());
+    assertEquals(Integer.valueOf(1), firstItem);
+
+    arrayAdapter.remove(firstItem);
+
+    assertEquals(2, arrayAdapter.getCount());
+    assertEquals(Integer.valueOf(2), arrayAdapter.getItem(0));
+    assertEquals(Integer.valueOf(3), arrayAdapter.getItem(1));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAssetManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAssetManagerTest.java
new file mode 100644
index 0000000..9ecb63d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAssetManagerTest.java
@@ -0,0 +1,225 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.robolectric.shadows.ShadowAssetManager.legacyShadowOf;
+import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
+
+import android.content.res.AssetManager;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowResources.ShadowLegacyTheme;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAssetManagerTest {
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private AssetManager assetManager;
+  private Resources resources;
+
+  @Before
+  public void setUp() throws Exception {
+    resources = ApplicationProvider.getApplicationContext().getResources();
+    assetManager = resources.getAssets();
+  }
+
+  @Test
+  public void openFd_shouldProvideFileDescriptorForDeflatedAsset() throws Exception {
+    assumeTrue(!useLegacy());
+    expectedException.expect(FileNotFoundException.class);
+    expectedException.expectMessage(
+        "This file can not be opened as a file descriptor; it is probably compressed");
+
+    assetManager.openFd("deflatedAsset.xml");
+  }
+
+  @Test
+  public void openNonAssetShouldOpenRealAssetFromResources() throws IOException {
+    InputStream inputStream = assetManager.openNonAsset(0, "res/drawable/an_image.png", 0);
+
+    // expect different sizes in binary vs file resources
+    int bytes = countBytes(inputStream);
+    if (bytes != 6559 && bytes != 5138) {
+      fail("Expected 5138 or 6559 bytes for image but got " + bytes);
+    }
+  }
+
+  @Test
+  public void openNonAssetShouldOpenFileFromAndroidJar() throws IOException {
+    String fileName = "res/raw/fallbackring.ogg";
+    if (useLegacy()) {
+      // Not the real full path (it's in .m2/repository), but it only cares about the last folder and file name;
+      // retrieves the uncompressed, un-version-qualified file from raw-res/...
+      fileName = "jar:" + fileName;
+    }
+    InputStream inputStream = assetManager.openNonAsset(0, fileName, 0);
+    assertThat(countBytes(inputStream)).isEqualTo(14611);
+  }
+
+  @Test
+  public void openNonAssetShouldThrowExceptionWhenFileDoesNotExist() throws IOException {
+    assumeTrue(useLegacy());
+
+    expectedException.expect(IOException.class);
+    expectedException.expectMessage(
+        "res/drawable/does_not_exist.png");
+
+    assetManager.openNonAsset(0, "res/drawable/does_not_exist.png", 0);
+  }
+
+  @Test
+  public void unknownResourceIdsShouldReportPackagesSearched() throws IOException {
+    assumeTrue(useLegacy());
+
+    expectedException.expect(Resources.NotFoundException.class);
+    expectedException.expectMessage("Resource ID #0xffffffff");
+
+    resources.newTheme().applyStyle(-1, false);
+    assetManager.openNonAsset(0, "res/drawable/does_not_exist.png", 0);
+  }
+
+  @Test
+  public void forSystemResources_unknownResourceIdsShouldReportPackagesSearched()
+      throws IOException {
+    if (!useLegacy()) return;
+    expectedException.expect(Resources.NotFoundException.class);
+    expectedException.expectMessage("Resource ID #0xffffffff");
+
+    Resources.getSystem().newTheme().applyStyle(-1, false);
+    assetManager.openNonAsset(0, "res/drawable/does_not_exist.png", 0);
+  }
+
+  @Test
+  @Config(qualifiers = "mdpi")
+  public void openNonAssetShouldOpenCorrectAssetBasedOnQualifierMdpi() throws IOException {
+    if (!useLegacy()) return;
+
+    InputStream inputStream = assetManager.openNonAsset(0, "res/drawable/robolectric.png", 0);
+    assertThat(countBytes(inputStream)).isEqualTo(8141);
+  }
+
+  @Test
+  @Config(qualifiers = "hdpi")
+  public void openNonAssetShouldOpenCorrectAssetBasedOnQualifierHdpi() throws IOException {
+    if (!useLegacy()) return;
+
+    InputStream inputStream = assetManager.openNonAsset(0, "res/drawable/robolectric.png", 0);
+    assertThat(countBytes(inputStream)).isEqualTo(23447);
+  }
+
+  // todo: port to ResourcesTest
+  @Test
+  public void multiFormatAttributes_integerDecimalValue() {
+    AttributeSet attributeSet =
+        Robolectric.buildAttributeSet().addAttribute(R.attr.multiformat, "16").build();
+    TypedArray typedArray =
+        resources.obtainAttributes(attributeSet, new int[] {R.attr.multiformat});
+    TypedValue outValue = new TypedValue();
+    typedArray.getValue(0, outValue);
+    assertThat(outValue.type).isEqualTo(TypedValue.TYPE_INT_DEC);
+  }
+
+  // todo: port to ResourcesTest
+  @Test
+  public void multiFormatAttributes_integerHexValue() {
+    AttributeSet attributeSet =
+        Robolectric.buildAttributeSet().addAttribute(R.attr.multiformat, "0x10").build();
+    TypedArray typedArray =
+        resources.obtainAttributes(attributeSet, new int[] {R.attr.multiformat});
+    TypedValue outValue = new TypedValue();
+    typedArray.getValue(0, outValue);
+    assertThat(outValue.type).isEqualTo(TypedValue.TYPE_INT_HEX);
+  }
+
+  // todo: port to ResourcesTest
+  @Test
+  public void multiFormatAttributes_stringValue() {
+    AttributeSet attributeSet =
+        Robolectric.buildAttributeSet().addAttribute(R.attr.multiformat, "Hello World").build();
+    TypedArray typedArray =
+        resources.obtainAttributes(attributeSet, new int[] {R.attr.multiformat});
+    TypedValue outValue = new TypedValue();
+    typedArray.getValue(0, outValue);
+    assertThat(outValue.type).isEqualTo(TypedValue.TYPE_STRING);
+  }
+
+  // todo: port to ResourcesTest
+  @Test
+  public void multiFormatAttributes_booleanValue() {
+    AttributeSet attributeSet =
+        Robolectric.buildAttributeSet().addAttribute(R.attr.multiformat, "true").build();
+    TypedArray typedArray =
+        resources.obtainAttributes(attributeSet, new int[] {R.attr.multiformat});
+    TypedValue outValue = new TypedValue();
+    typedArray.getValue(0, outValue);
+    assertThat(outValue.type).isEqualTo(TypedValue.TYPE_INT_BOOLEAN);
+  }
+
+  @Test
+  public void attrsToTypedArray_shouldAllowMockedAttributeSets() {
+    if (!useLegacy()) return;
+
+    AttributeSet mockAttributeSet = mock(AttributeSet.class);
+    when(mockAttributeSet.getAttributeCount()).thenReturn(1);
+    when(mockAttributeSet.getAttributeNameResource(0)).thenReturn(android.R.attr.windowBackground);
+    when(mockAttributeSet.getAttributeName(0)).thenReturn("android:windowBackground");
+    when(mockAttributeSet.getAttributeValue(0)).thenReturn("value");
+
+    resources.obtainAttributes(mockAttributeSet, new int[]{android.R.attr.windowBackground});
+  }
+
+  @Test
+  public void whenStyleAttrResolutionFails_attrsToTypedArray_returnsNiceErrorMessage() {
+    if (!useLegacy()) return;
+    expectedException.expect(RuntimeException.class);
+    expectedException.expectMessage(
+        "no value for org.robolectric:attr/styleNotSpecifiedInAnyTheme in theme with applied"
+            + " styles: [Style org.robolectric:Theme.Robolectric (and parents)]");
+
+   Resources.Theme theme = resources.newTheme();
+   theme.applyStyle(R.style.Theme_Robolectric, false);
+
+    legacyShadowOf(assetManager)
+        .attrsToTypedArray(
+            resources,
+            Robolectric.buildAttributeSet()
+                .setStyleAttribute("?attr/styleNotSpecifiedInAnyTheme")
+                .build(),
+            new int[] {R.attr.string1},
+            0,
+            ((ShadowLegacyTheme) Shadow.extract(theme)).getNativePtr(),
+            0);
+  }
+
+  ///////////////////////////////
+
+  private static int countBytes(InputStream i) throws IOException {
+    int count = 0;
+    while (i.read() != -1) {
+      count++;
+    }
+    i.close();
+    return count;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAsyncQueryHandlerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAsyncQueryHandlerTest.java
new file mode 100644
index 0000000..4440ef9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAsyncQueryHandlerTest.java
@@ -0,0 +1,137 @@
+package org.robolectric.shadows;
+
+import static android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.AsyncQueryHandler;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.fakes.RoboCursor;
+
+/** Unit tests for {@link ShadowAsyncQueryHandler}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowAsyncQueryHandlerTest {
+
+  private static final int TOKEN = 22;
+  private static final Object COOKIE = new Object();
+  private static final RoboCursor CURSOR = new RoboCursor();
+
+  private ContentResolver contentResolver;
+
+  @Before
+  public void setUp() {
+    contentResolver = ApplicationProvider.getApplicationContext().getContentResolver();
+  }
+
+  @Test
+  public void startQuery_callbackIsCalled() {
+    FakeAsyncQueryHandler asyncQueryHandler = new FakeAsyncQueryHandler(contentResolver);
+    shadowOf(contentResolver).setCursor(EXTERNAL_CONTENT_URI, CURSOR);
+
+    asyncQueryHandler.startQuery(
+        TOKEN,
+        COOKIE,
+        EXTERNAL_CONTENT_URI,
+        null /* projection */,
+        null /* selection */,
+        null /* selectionArgs */,
+        null /* orderBy */);
+
+    assertThat(asyncQueryHandler.token).isEqualTo(TOKEN);
+    assertThat(asyncQueryHandler.cookie).isEqualTo(COOKIE);
+    assertThat(asyncQueryHandler.cursor).isEqualTo(CURSOR);
+  }
+
+  @Test
+  public void startInsert_callbackIsCalled() {
+    FakeAsyncQueryHandler asyncQueryHandler = new FakeAsyncQueryHandler(contentResolver);
+
+    asyncQueryHandler.startInsert(TOKEN, COOKIE, EXTERNAL_CONTENT_URI, null /* initialValues */);
+
+    assertThat(asyncQueryHandler.token).isEqualTo(TOKEN);
+    assertThat(asyncQueryHandler.cookie).isEqualTo(COOKIE);
+    assertThat(asyncQueryHandler.uri)
+        .isEqualTo(ContentUris.withAppendedId(EXTERNAL_CONTENT_URI, 1));
+  }
+
+  @Test
+  public void startUpdate_callbackIsCalled() {
+    FakeAsyncQueryHandler asyncQueryHandler = new FakeAsyncQueryHandler(contentResolver);
+    contentResolver.insert(EXTERNAL_CONTENT_URI, new ContentValues());
+
+    asyncQueryHandler.startUpdate(
+        TOKEN,
+        COOKIE,
+        EXTERNAL_CONTENT_URI,
+        null /* values */,
+        null /* selection */,
+        null /* selectionArgs */);
+
+    assertThat(asyncQueryHandler.token).isEqualTo(TOKEN);
+    assertThat(asyncQueryHandler.cookie).isEqualTo(COOKIE);
+    assertThat(asyncQueryHandler.result).isEqualTo(1);
+  }
+
+  @Test
+  public void startDelete_callbackIsCalled() {
+    FakeAsyncQueryHandler asyncQueryHandler = new FakeAsyncQueryHandler(contentResolver);
+    contentResolver.insert(EXTERNAL_CONTENT_URI, new ContentValues());
+
+    asyncQueryHandler.startDelete(
+        TOKEN, COOKIE, EXTERNAL_CONTENT_URI, null /* selection */, null /* selectionArgs */);
+
+    assertThat(asyncQueryHandler.token).isEqualTo(TOKEN);
+    assertThat(asyncQueryHandler.cookie).isEqualTo(COOKIE);
+    assertThat(asyncQueryHandler.result).isEqualTo(1);
+  }
+
+  private static class FakeAsyncQueryHandler extends AsyncQueryHandler {
+
+    int token;
+    Object cookie;
+    Cursor cursor;
+    Uri uri;
+    int result;
+
+    FakeAsyncQueryHandler(ContentResolver contentResolver) {
+      super(contentResolver);
+    }
+
+    @Override
+    protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
+      this.token = token;
+      this.cookie = cookie;
+      this.cursor = cursor;
+    }
+
+    @Override
+    protected void onInsertComplete(int token, Object cookie, Uri uri) {
+      this.token = token;
+      this.cookie = cookie;
+      this.uri = uri;
+    }
+
+    @Override
+    protected void onUpdateComplete(int token, Object cookie, int result) {
+      this.token = token;
+      this.cookie = cookie;
+      this.result = result;
+    }
+
+    @Override
+    protected void onDeleteComplete(int token, Object cookie, int result) {
+      this.token = token;
+      this.cookie = cookie;
+      this.result = result;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioEffectTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioEffectTest.java
new file mode 100644
index 0000000..7c53a73
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioEffectTest.java
@@ -0,0 +1,194 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.media.audiofx.AudioEffect;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.UUID;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowAudioEffect}. */
+@Config(maxSdk = VERSION_CODES.Q)
+@RunWith(AndroidJUnit4.class)
+public class ShadowAudioEffectTest {
+  private static final UUID EFFECT_TYPE_NULL =
+      UUID.fromString("ec7178ec-e5e1-4432-a3f4-4657e6795210");
+
+  @Test
+  public void queryEffects() {
+
+    AudioEffect.Descriptor descriptor = new AudioEffect.Descriptor();
+    descriptor.type = AudioEffect.EFFECT_TYPE_AEC;
+    ShadowAudioEffect.addEffect(descriptor);
+
+    AudioEffect.Descriptor[] descriptors = AudioEffect.queryEffects();
+
+    assertThat(descriptors).asList().hasSize(1);
+    assertThat(descriptors[0].type).isEqualTo(AudioEffect.EFFECT_TYPE_AEC);
+  }
+
+  @Test
+  public void getAudioEffects_noAudioEffects_returnsNoEffects() {
+    assertThat(ShadowAudioEffect.getAudioEffects()).isEmpty();
+  }
+
+  @Test
+  public void getAudioEffects_newAudioEffect_returnsAudioEffect() {
+    int priority = 100;
+    int audioSession = 500;
+    new AudioEffect(
+        AudioEffect.EFFECT_TYPE_AEC, /* uuid= */ EFFECT_TYPE_NULL, priority, audioSession);
+
+    ImmutableList<AudioEffect> actualEffects = ShadowAudioEffect.getAudioEffects();
+
+    assertThat(actualEffects).hasSize(1);
+    ShadowAudioEffect actualEffect = shadowOf(actualEffects.get(0));
+    assertThat(actualEffect.getPriority()).isEqualTo(priority);
+    assertThat(actualEffect.getAudioSession()).isEqualTo(audioSession);
+  }
+
+  @Test
+  public void getAudioEffects_audioEffectReleased_returnsNoEffect() {
+    AudioEffect effect = createAudioEffect();
+
+    effect.release();
+
+    assertThat(ShadowAudioEffect.getAudioEffects()).isEmpty();
+  }
+
+  @Test
+  public void getPriority_returnsPriorityFromCtor() {
+    int priority = 100;
+    AudioEffect audioEffect =
+        new AudioEffect(
+            AudioEffect.EFFECT_TYPE_AEC, EFFECT_TYPE_NULL, priority, /* audioSession= */ 0);
+
+    assertThat(shadowOf(audioEffect).getPriority()).isEqualTo(priority);
+  }
+
+  @Test
+  public void getAudioSession_returnsAudioSessionFromCtor() {
+    int audioSession = 100;
+    AudioEffect audioEffect =
+        new AudioEffect(
+            AudioEffect.EFFECT_TYPE_AEC, EFFECT_TYPE_NULL, /* priority= */ 0, audioSession);
+
+    assertThat(shadowOf(audioEffect).getAudioSession()).isEqualTo(audioSession);
+  }
+
+  @Test
+  public void getEnabled_returnsFalseByDefault() {
+    AudioEffect audioEffect = createAudioEffect();
+
+    assertThat(audioEffect.getEnabled()).isFalse();
+  }
+
+  @Test
+  public void getEnabled_setEnabledTrue_returnsTrue() {
+    AudioEffect audioEffect = createAudioEffect();
+
+    audioEffect.setEnabled(true);
+
+    assertThat(audioEffect.getEnabled()).isTrue();
+  }
+
+  @Test
+  public void getEnabled_setEnabledTrueThenFalse_returnsFalse() {
+    AudioEffect audioEffect = createAudioEffect();
+
+    audioEffect.setEnabled(true);
+    audioEffect.setEnabled(false);
+
+    assertThat(audioEffect.getEnabled()).isFalse();
+  }
+
+  @Test
+  public void setEnabled_errorCodeSet_returnsError() {
+    AudioEffect audioEffect = createAudioEffect();
+    shadowOf(audioEffect).setErrorCode(AudioEffect.ERROR);
+
+    assertThat(audioEffect.setEnabled(true)).isEqualTo(AudioEffect.ERROR);
+  }
+
+  @Test
+  public void getParameter_errorCodeSet_returnsError() {
+    AudioEffect audioEffect = createAudioEffect();
+    shadowOf(audioEffect).setErrorCode(AudioEffect.ERROR);
+
+    assertThat(audioEffect.getParameter(/* param= */ 1, /* value= */ new int[1]))
+        .isEqualTo(AudioEffect.ERROR);
+  }
+
+  @Test
+  public void setParameter_errorCodeSet_returnsError() {
+    AudioEffect audioEffect = createAudioEffect();
+    shadowOf(audioEffect).setErrorCode(AudioEffect.ERROR);
+
+    assertThat(audioEffect.setParameter(/* param= */ 1, /* value= */ 2))
+        .isEqualTo(AudioEffect.ERROR);
+  }
+
+  @Test
+  public void getEnabled_audioEffectUninitialized_throwsException() {
+    AudioEffect audioEffect = createAudioEffect();
+    shadowOf(audioEffect).setInitialized(false);
+
+    assertThrows(IllegalStateException.class, audioEffect::getEnabled);
+  }
+
+  @Test
+  public void setEnabled_audioEffectUninitialized_throwsException() {
+    AudioEffect audioEffect = createAudioEffect();
+    shadowOf(audioEffect).setInitialized(false);
+
+    assertThrows(IllegalStateException.class, () -> audioEffect.setEnabled(true));
+  }
+
+  @Test
+  public void release_callSetEnabledAfterwards_throwsException() {
+    AudioEffect audioEffect = createAudioEffect();
+    audioEffect.release();
+
+    assertThrows(IllegalStateException.class, () -> audioEffect.setEnabled(true));
+  }
+
+  @Test
+  public void release_callGetEnabledAfterwards_throwsException() {
+    AudioEffect audioEffect = createAudioEffect();
+    audioEffect.release();
+
+    assertThrows(IllegalStateException.class, audioEffect::getEnabled);
+  }
+
+  @Test
+  public void ctor_nullType_throwsException() {
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            new AudioEffect(
+                /* type= */ null, EFFECT_TYPE_NULL, /* priority= */ 0, /* audioSession= */ 0));
+  }
+
+  @Test
+  public void ctor_nullUuid_throwsException() {
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            new AudioEffect(
+                AudioEffect.EFFECT_TYPE_AEC,
+                /* uuid= */ null,
+                /* priority= */ 0,
+                /* audioSession= */ 0));
+  }
+
+  private static AudioEffect createAudioEffect() {
+    return new AudioEffect(
+        AudioEffect.EFFECT_TYPE_AEC, EFFECT_TYPE_NULL, /* priority= */ 0, /* audioSession= */ 0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java
new file mode 100644
index 0000000..f5a29a8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java
@@ -0,0 +1,771 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.media.AudioDeviceInfo;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioPlaybackConfiguration;
+import android.media.AudioRecordingConfiguration;
+import android.media.MediaRecorder.AudioSource;
+import android.media.audiopolicy.AudioPolicy;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAudioManagerTest {
+  private static final float FAULT_TOLERANCE = 0.00001f;
+  private final AudioManager.OnAudioFocusChangeListener listener = focusChange -> {};
+
+  private Context appContext;
+  private AudioManager audioManager;
+
+  @Before
+  public void setUp() {
+    appContext = ApplicationProvider.getApplicationContext();
+    audioManager = new AudioManager(appContext);
+  }
+
+  @Test
+  public void requestAudioFocus_shouldRecordArgumentsOfMostRecentCall() {
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest()).isNull();
+    audioManager.requestAudioFocus(listener, 999, 888);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().listener)
+        .isSameInstanceAs(listener);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().streamType).isEqualTo(999);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().durationHint).isEqualTo(888);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().audioFocusRequest).isNull();
+  }
+
+  @Test
+  public void requestAudioFocus_shouldReturnTheSpecifiedValue() {
+    int value = audioManager.requestAudioFocus(listener, 999, 888);
+    assertThat(value).isEqualTo(AudioManager.AUDIOFOCUS_REQUEST_GRANTED);
+
+    shadowOf(audioManager).setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_FAILED);
+
+    value = audioManager.requestAudioFocus(listener, 999, 888);
+    assertThat(value).isEqualTo(AudioManager.AUDIOFOCUS_REQUEST_FAILED);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestAudioFocus2_shouldRecordArgumentsOfMostRecentCall() {
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest()).isNull();
+
+    AudioAttributes atts =
+        new AudioAttributes.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build();
+    android.media.AudioFocusRequest request =
+        new android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+            .setOnAudioFocusChangeListener(listener)
+            .setAudioAttributes(atts)
+            .build();
+
+    audioManager.requestAudioFocus(request);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().listener)
+        .isSameInstanceAs(listener);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().streamType)
+        .isEqualTo(AudioManager.STREAM_MUSIC);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().durationHint)
+        .isEqualTo(AudioManager.AUDIOFOCUS_GAIN);
+    assertThat(shadowOf(audioManager).getLastAudioFocusRequest().audioFocusRequest)
+        .isEqualTo(request);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestAudioFocus2_shouldReturnTheSpecifiedValue() {
+    int value =
+        audioManager.requestAudioFocus(
+            new android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).build());
+    assertThat(value).isEqualTo(AudioManager.AUDIOFOCUS_REQUEST_GRANTED);
+
+    shadowOf(audioManager).setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_FAILED);
+
+    value =
+        audioManager.requestAudioFocus(
+            new android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).build());
+    assertThat(value).isEqualTo(AudioManager.AUDIOFOCUS_REQUEST_FAILED);
+  }
+
+  @Test
+  public void abandonAudioFocus_shouldRecordTheListenerOfTheMostRecentCall() {
+    audioManager.abandonAudioFocus(null);
+    assertThat(shadowOf(audioManager).getLastAbandonedAudioFocusListener()).isNull();
+
+    audioManager.abandonAudioFocus(listener);
+    assertThat(shadowOf(audioManager).getLastAbandonedAudioFocusListener())
+        .isSameInstanceAs(listener);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void abandonAudioFocusRequest_shouldRecordTheListenerOfTheMostRecentCall() {
+    android.media.AudioFocusRequest request =
+        new android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+            .setOnAudioFocusChangeListener(listener)
+            .build();
+    audioManager.abandonAudioFocusRequest(request);
+    assertThat(shadowOf(audioManager).getLastAbandonedAudioFocusRequest())
+        .isSameInstanceAs(request);
+    assertThat(shadowOf(audioManager).getLastAbandonedAudioFocusListener())
+        .isSameInstanceAs(listener);
+  }
+
+  @Test
+  public void getStreamMaxVolume_shouldReturnMaxVolume() throws Exception {
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      switch (stream) {
+        case AudioManager.STREAM_MUSIC:
+        case AudioManager.STREAM_DTMF:
+          assertThat(audioManager.getStreamMaxVolume(stream))
+              .isEqualTo(ShadowAudioManager.MAX_VOLUME_MUSIC_DTMF);
+          break;
+
+        case AudioManager.STREAM_ALARM:
+        case AudioManager.STREAM_NOTIFICATION:
+        case AudioManager.STREAM_RING:
+        case AudioManager.STREAM_SYSTEM:
+        case AudioManager.STREAM_VOICE_CALL:
+          assertThat(audioManager.getStreamMaxVolume(stream))
+              .isEqualTo(ShadowAudioManager.DEFAULT_MAX_VOLUME);
+          break;
+
+        default:
+          throw new Exception("Unexpected audio stream requested.");
+      }
+    }
+  }
+
+  @Test
+  public void setStreamVolume_shouldSetVolume() {
+    int vol = 1;
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      audioManager.setStreamVolume(stream, vol, 0);
+      vol++;
+      if (vol > ShadowAudioManager.DEFAULT_MAX_VOLUME) {
+        vol = 1;
+      }
+    }
+
+    vol = 1;
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      assertThat(audioManager.getStreamVolume(stream)).isEqualTo(vol);
+      vol++;
+      if (vol > ShadowAudioManager.DEFAULT_MAX_VOLUME) {
+        vol = 1;
+      }
+    }
+  }
+
+  @Test
+  public void setStreamMaxVolume_shouldSetMaxVolumeForAllStreams() {
+    final int newMaxVol = 31;
+    shadowOf(audioManager).setStreamMaxVolume(newMaxVol);
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      assertThat(audioManager.getStreamMaxVolume(stream)).isEqualTo(newMaxVol);
+    }
+  }
+
+  @Test
+  public void setStreamVolume_shouldSetVolumeForAllStreams() {
+    final int newVol = 3;
+    shadowOf(audioManager).setStreamVolume(newVol);
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      assertThat(audioManager.getStreamVolume(stream)).isEqualTo(newVol);
+    }
+  }
+
+  @Test
+  public void setStreamVolume_shouldNotAllowNegativeValues() {
+    final int newVol = -3;
+
+    shadowOf(audioManager).setStreamVolume(newVol);
+
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      assertThat(audioManager.getStreamVolume(stream)).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void setStreamVolume_shouldNotExceedMaxVolume() throws Exception {
+    final int newVol = 31;
+    shadowOf(audioManager).setStreamVolume(newVol);
+    for (int stream : ShadowAudioManager.ALL_STREAMS) {
+      switch (stream) {
+        case AudioManager.STREAM_MUSIC:
+        case AudioManager.STREAM_DTMF:
+          assertThat(audioManager.getStreamVolume(stream))
+              .isEqualTo(ShadowAudioManager.MAX_VOLUME_MUSIC_DTMF);
+          break;
+
+        case AudioManager.STREAM_ALARM:
+        case AudioManager.STREAM_NOTIFICATION:
+        case AudioManager.STREAM_RING:
+        case AudioManager.STREAM_SYSTEM:
+        case AudioManager.STREAM_VOICE_CALL:
+          assertThat(audioManager.getStreamVolume(stream))
+              .isEqualTo(ShadowAudioManager.DEFAULT_MAX_VOLUME);
+          break;
+
+        default:
+          throw new Exception("Unexpected audio stream requested.");
+      }
+    }
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getStreamVolumeDb_maxVolume_returnsZero() {
+    float volumeDb =
+        audioManager.getStreamVolumeDb(
+            AudioManager.STREAM_MUSIC,
+            ShadowAudioManager.MAX_VOLUME_MUSIC_DTMF,
+            /* deviceType= */ 0);
+
+    assertThat(volumeDb).isWithin(FAULT_TOLERANCE).of(0);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getStreamVolumeDb_minVolume_returnsNegativeInf() {
+    float volumeDb =
+        audioManager.getStreamVolumeDb(
+            AudioManager.STREAM_MUSIC, ShadowAudioManager.MIN_VOLUME, /* deviceType= */ 0);
+
+    assertThat(volumeDb).isNegativeInfinity();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getStreamVolumeDb_mediumVolumes_returnsDecrementingNegativeValues() {
+    int maxVolume = ShadowAudioManager.MAX_VOLUME_MUSIC_DTMF;
+    int minVolume = ShadowAudioManager.MIN_VOLUME;
+    float lastVolumeDb =
+        audioManager.getStreamVolumeDb(AudioManager.STREAM_MUSIC, maxVolume, /* deviceType= */ 0);
+
+    for (int volume = maxVolume - 1; volume > minVolume; volume--) {
+      float volumeDb =
+          audioManager.getStreamVolumeDb(AudioManager.STREAM_MUSIC, volume, /* deviceType= */ 0);
+
+      assertThat(volumeDb).isLessThan(0);
+      assertThat(volumeDb).isLessThan(lastVolumeDb);
+    }
+  }
+
+  @Test
+  public void getRingerMode_default() {
+    int ringerMode = audioManager.getRingerMode();
+    assertThat(ringerMode).isEqualTo(AudioManager.RINGER_MODE_NORMAL);
+  }
+
+  @Test
+  public void setRingerMode_shouldSetMode() {
+    for (int rm = AudioManager.RINGER_MODE_SILENT; rm <= AudioManager.RINGER_MODE_NORMAL; rm++) {
+      audioManager.setRingerMode(rm);
+      assertThat(audioManager.getRingerMode()).isEqualTo(rm);
+    }
+  }
+
+  @Test
+  public void setRingerMode_shouldNotChangeOnInvalidValue() {
+    audioManager.setRingerMode(AudioManager.RINGER_MODE_VIBRATE);
+    assertThat(audioManager.getRingerMode()).isEqualTo(AudioManager.RINGER_MODE_VIBRATE);
+    audioManager.setRingerMode(AudioManager.RINGER_MODE_NORMAL + 1);
+    assertThat(audioManager.getRingerMode()).isEqualTo(AudioManager.RINGER_MODE_VIBRATE);
+  }
+
+  @Test
+  public void getMode_default() {
+    assertThat(audioManager.getMode()).isEqualTo(AudioManager.MODE_NORMAL);
+  }
+
+  @Test
+  public void setMode_shouldSetAudioMode() {
+    audioManager.setMode(AudioManager.MODE_RINGTONE);
+    assertThat(audioManager.getMode()).isEqualTo(AudioManager.MODE_RINGTONE);
+  }
+
+  @Test
+  public void isSpeakerphoneOn_shouldReturnSpeakerphoneState() {
+    assertThat(audioManager.isSpeakerphoneOn()).isFalse();
+    audioManager.setSpeakerphoneOn(true);
+    assertThat(audioManager.isSpeakerphoneOn()).isTrue();
+  }
+
+  @Test
+  public void microphoneShouldMute() {
+    // Should not be muted by default
+    assertThat(audioManager.isMicrophoneMute()).isFalse();
+    audioManager.setMicrophoneMute(true);
+    assertThat(audioManager.isMicrophoneMute()).isTrue();
+  }
+
+  @Test
+  public void setBluetoothScoOn() {
+    assertThat(audioManager.isBluetoothScoOn()).isFalse();
+    audioManager.setBluetoothScoOn(true);
+    assertThat(audioManager.isBluetoothScoOn()).isTrue();
+  }
+
+  @Test
+  public void isMusicActive() {
+    assertThat(audioManager.isMusicActive()).isFalse();
+    shadowOf(audioManager).setIsMusicActive(true);
+    assertThat(audioManager.isMusicActive()).isTrue();
+  }
+
+  @Test
+  public void isBluetoothScoAvailableOffCall() {
+    assertThat(audioManager.isBluetoothScoAvailableOffCall()).isFalse();
+    shadowOf(audioManager).setIsBluetoothScoAvailableOffCall(true);
+    assertThat(audioManager.isBluetoothScoAvailableOffCall()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getDevicesForAttributes_returnsEmptyListByDefault() {
+    AudioAttributes movieAttribute =
+        new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build();
+
+    assertThat(shadowOf(audioManager).getDevicesForAttributes(movieAttribute)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setDevicesForAttributes_updatesDevicesForAttributes() {
+    AudioAttributes movieAttribute =
+        new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build();
+    ImmutableList<Object> newDevices = ImmutableList.of(new Object());
+
+    shadowOf(audioManager).setDevicesForAttributes(movieAttribute, newDevices);
+
+    assertThat(shadowOf(audioManager).getDevicesForAttributes(movieAttribute))
+        .isEqualTo(newDevices);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setDefaultDevicesForAttributes_updatesDevicesForAttributes() {
+    AudioAttributes movieAttribute =
+        new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build();
+    ImmutableList<Object> newDevices = ImmutableList.of(new Object());
+
+    shadowOf(audioManager).setDefaultDevicesForAttributes(newDevices);
+
+    assertThat(shadowOf(audioManager).getDevicesForAttributes(movieAttribute))
+        .isEqualTo(newDevices);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setDevicesForAttributes_overridesSetDefaultDevicesForAttributes() {
+    AudioAttributes movieAttribute =
+        new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build();
+    shadowOf(audioManager).setDefaultDevicesForAttributes(ImmutableList.of(new Object()));
+    ImmutableList<Object> newDevices = ImmutableList.of(new Object(), new Object());
+
+    shadowOf(audioManager).setDevicesForAttributes(movieAttribute, newDevices);
+
+    assertThat(shadowOf(audioManager).getDevicesForAttributes(movieAttribute))
+        .isEqualTo(newDevices);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getDevices_criteriaInputs_getsAllInputDevices() throws Exception {
+    AudioDeviceInfo scoDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO);
+    AudioDeviceInfo a2dpDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
+    shadowOf(audioManager).setInputDevices(ImmutableList.of(scoDevice));
+    shadowOf(audioManager).setOutputDevices(ImmutableList.of(a2dpDevice));
+
+    assertThat(Arrays.stream(shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_INPUTS)))
+        .containsExactly(scoDevice);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getDevices_criteriaOutputs_getsAllOutputDevices() throws Exception {
+    AudioDeviceInfo scoDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO);
+    AudioDeviceInfo a2dpDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
+    shadowOf(audioManager).setInputDevices(ImmutableList.of(scoDevice));
+    shadowOf(audioManager).setOutputDevices(ImmutableList.of(a2dpDevice));
+
+    assertThat(Arrays.stream(shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS)))
+        .containsExactly(a2dpDevice);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getDevices_criteriaInputsAndOutputs_getsAllDevices() throws Exception {
+    AudioDeviceInfo scoDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO);
+    AudioDeviceInfo a2dpDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
+    shadowOf(audioManager).setInputDevices(ImmutableList.of(scoDevice));
+    shadowOf(audioManager).setOutputDevices(ImmutableList.of(a2dpDevice));
+
+    assertThat(Arrays.stream(shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_ALL)))
+        .containsExactly(scoDevice, a2dpDevice);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void setCommunicationDevice_updatesCommunicationDevice() throws Exception {
+    AudioDeviceInfo scoDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO);
+    shadowOf(audioManager).setCommunicationDevice(scoDevice);
+
+    assertThat(audioManager.getCommunicationDevice()).isEqualTo(scoDevice);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void clearCommunicationDevice_clearsCommunicationDevice() throws Exception {
+    AudioDeviceInfo scoDevice = createAudioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO);
+    shadowOf(audioManager).setCommunicationDevice(scoDevice);
+    assertThat(audioManager.getCommunicationDevice()).isEqualTo(scoDevice);
+
+    shadowOf(audioManager).clearCommunicationDevice();
+    assertThat(audioManager.getCommunicationDevice()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getActivePlaybackConfigurations() {
+    assertThat(audioManager.getActivePlaybackConfigurations()).isEmpty();
+    AudioAttributes movieAttribute =
+        new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build();
+    AudioAttributes musicAttribute =
+        new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build();
+    shadowOf(audioManager)
+        .setActivePlaybackConfigurationsFor(Arrays.asList(movieAttribute, musicAttribute));
+    List<AudioPlaybackConfiguration> playbackConfigurations =
+        audioManager.getActivePlaybackConfigurations();
+    assertThat(playbackConfigurations).hasSize(2);
+    assertThat(playbackConfigurations.get(0).getAudioAttributes()).isEqualTo(movieAttribute);
+    assertThat(playbackConfigurations.get(1).getAudioAttributes()).isEqualTo(musicAttribute);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setActivePlaybackConfigurations_withCallbackRegistered_notifiesCallback() {
+    AudioManager.AudioPlaybackCallback callback = mock(AudioManager.AudioPlaybackCallback.class);
+    audioManager.registerAudioPlaybackCallback(callback, null);
+
+    List<AudioAttributes> audioAttributes = new ArrayList<>();
+    shadowOf(audioManager).setActivePlaybackConfigurationsFor(audioAttributes, true);
+
+    verify(callback).onPlaybackConfigChanged(any());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void unregisterAudioPlaybackCallback_removesCallback() {
+    AudioManager.AudioPlaybackCallback callback = mock(AudioManager.AudioPlaybackCallback.class);
+    audioManager.registerAudioPlaybackCallback(callback, null);
+
+    audioManager.unregisterAudioPlaybackCallback(callback);
+    List<AudioAttributes> audioAttributes = new ArrayList<>();
+    shadowOf(audioManager).setActivePlaybackConfigurationsFor(audioAttributes, true);
+
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void setParameters_mustNotBeEmpty() {
+    audioManager.setParameters("");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void setParameters_mustEndInSemicolon() {
+    audioManager.setParameters("foo=bar");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void setParameters_mustHaveEquals() {
+    audioManager.setParameters("foobar;");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void setParameters_crazyInput() {
+    audioManager.setParameters("foo=bar=baz;");
+  }
+
+  @Test
+  public void setParameters() {
+    audioManager.setParameters("foo=bar;");
+    assertThat(shadowOf(audioManager).getParameter("foo")).isEqualTo("bar");
+  }
+
+  @Test
+  public void getParameters() {
+    assertThat(audioManager.getParameters("")).isNull();
+  }
+
+  @Test
+  public void setParameters_multipleParametersOk() {
+    audioManager.setParameters("foo=bar;baz=bar;");
+    assertThat(shadowOf(audioManager).getParameter("foo")).isEqualTo("bar");
+    assertThat(shadowOf(audioManager).getParameter("baz")).isEqualTo("bar");
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void adjustStreamVolume_mute() {
+    assertThat(audioManager.isStreamMute(AudioManager.STREAM_VOICE_CALL)).isFalse();
+
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_VOICE_CALL, AudioManager.ADJUST_MUTE, /* flags= */ 0);
+    assertThat(audioManager.isStreamMute(AudioManager.STREAM_VOICE_CALL)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void adjustStreamVolume_unmute() {
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_VOICE_CALL, AudioManager.ADJUST_MUTE, /* flags= */ 0);
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_VOICE_CALL, AudioManager.ADJUST_UNMUTE, /* flags= */ 0);
+
+    assertThat(audioManager.isStreamMute(AudioManager.STREAM_VOICE_CALL)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void adjustStreamVolume_lower() {
+    shadowOf(audioManager).setStreamVolume(7);
+    int volumeBefore = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, /* flags= */ 0);
+
+    int volumeAfter = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+    assertThat(volumeAfter).isLessThan(volumeBefore);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void adjustStreamVolume_lowerAtMinVolume_remainsSame() {
+    shadowOf(audioManager).setStreamVolume(1);
+    int volumeBefore = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, /* flags= */ 0);
+
+    int volumeAfter = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+    assertThat(volumeAfter).isEqualTo(volumeBefore);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void adjustStreamVolume_raise() {
+    shadowOf(audioManager).setStreamVolume(7);
+    int volumeBefore = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, /* flags= */ 0);
+
+    int volumeAfter = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+    assertThat(volumeAfter).isGreaterThan(volumeBefore);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void adjustStreamVolume_raiseAtMaxVolume_remainsSame() {
+    shadowOf(audioManager).setStreamVolume(7);
+    shadowOf(audioManager).setStreamMaxVolume(7);
+    int volumeBefore = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+
+    audioManager.adjustStreamVolume(
+        AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, /* flags= */ 0);
+
+    int volumeAfter = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+    assertThat(volumeAfter).isEqualTo(volumeBefore);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isStreamMute_defaultFalse() {
+    assertThat(audioManager.isStreamMute(AudioManager.STREAM_VOICE_CALL)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getActiveRecordingConfigurations_defaultEmptyList() {
+    assertThat(audioManager.getActiveRecordingConfigurations()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getActiveRecordingConfigurations_returnsSpecifiedList() {
+    ArrayList<AudioRecordingConfiguration> configurations = new ArrayList<>();
+    configurations.add(
+        shadowOf(audioManager)
+            .createActiveRecordingConfiguration(
+                0, AudioSource.VOICE_RECOGNITION, "com.example.android.application"));
+    shadowOf(audioManager).setActiveRecordingConfigurations(configurations, true);
+
+    assertThat(audioManager.getActiveRecordingConfigurations()).isEqualTo(configurations);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setActiveRecordingConfigurations_notifiesCallback() {
+    AudioManager.AudioRecordingCallback callback = mock(AudioManager.AudioRecordingCallback.class);
+    audioManager.registerAudioRecordingCallback(callback, null);
+
+    ArrayList<AudioRecordingConfiguration> configurations = new ArrayList<>();
+    configurations.add(
+        shadowOf(audioManager)
+            .createActiveRecordingConfiguration(
+                0, AudioSource.VOICE_RECOGNITION, "com.example.android.application"));
+    shadowOf(audioManager).setActiveRecordingConfigurations(configurations, true);
+
+    verify(callback).onRecordingConfigChanged(configurations);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void unregisterAudioRecordingCallback_removesCallback() {
+    AudioManager.AudioRecordingCallback callback = mock(AudioManager.AudioRecordingCallback.class);
+    audioManager.registerAudioRecordingCallback(callback, null);
+
+    audioManager.unregisterAudioRecordingCallback(callback);
+
+    ArrayList<AudioRecordingConfiguration> configurations = new ArrayList<>();
+    configurations.add(
+        shadowOf(audioManager)
+            .createActiveRecordingConfiguration(
+                0, AudioSource.VOICE_RECOGNITION, "com.example.android.application"));
+    shadowOf(audioManager).setActiveRecordingConfigurations(configurations, true);
+
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void createActiveRecordingConfiguration_createsProperConfiguration() {
+    AudioRecordingConfiguration configuration =
+        shadowOf(audioManager)
+            .createActiveRecordingConfiguration(
+                12345, AudioSource.VOICE_RECOGNITION, "com.example.android.application");
+
+    assertThat(configuration.getClientAudioSessionId()).isEqualTo(12345);
+    assertThat(configuration.getClientAudioSource()).isEqualTo(AudioSource.VOICE_RECOGNITION);
+    assertThat(configuration.getClientFormat().getEncoding())
+        .isEqualTo(AudioFormat.ENCODING_PCM_16BIT);
+    assertThat(configuration.getClientFormat().getSampleRate()).isEqualTo(16000);
+    assertThat(configuration.getClientFormat().getChannelMask())
+        .isEqualTo(AudioFormat.CHANNEL_OUT_MONO);
+    assertThat(configuration.getFormat().getEncoding()).isEqualTo(AudioFormat.ENCODING_PCM_16BIT);
+    assertThat(configuration.getFormat().getSampleRate()).isEqualTo(16000);
+    assertThat(configuration.getFormat().getChannelMask()).isEqualTo(AudioFormat.CHANNEL_OUT_MONO);
+  }
+
+  @Test(expected = NullPointerException.class)
+  @Config(minSdk = P)
+  public void registerAudioPolicy_nullAudioPolicy_throwsException() {
+    audioManager.registerAudioPolicy(null);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void registerAudioPolicy_alreadyRegistered_returnsError() {
+    AudioPolicy audioPolicy = new AudioPolicy.Builder(appContext).build();
+    audioManager.registerAudioPolicy(audioPolicy);
+
+    assertThat(audioManager.registerAudioPolicy(audioPolicy)).isEqualTo(AudioManager.ERROR);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void registerAudioPolicy_noPreviouslyRegistered_returnsSuccess() {
+    AudioPolicy audioPolicy = new AudioPolicy.Builder(appContext).build();
+
+    assertThat(audioManager.registerAudioPolicy(audioPolicy)).isEqualTo(AudioManager.SUCCESS);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void isAnyAudioPolicyRegistered_noPoliciesRegistered_returnsFalse() {
+    assertThat(shadowOf(audioManager).isAnyAudioPolicyRegistered()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void isAnyAudioPolicyRegistered_afterPolicyRegistered_returnsTrue() {
+    AudioPolicy audioPolicy = new AudioPolicy.Builder(appContext).build();
+
+    audioManager.registerAudioPolicy(audioPolicy);
+
+    assertThat(shadowOf(audioManager).isAnyAudioPolicyRegistered()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void isAnyAudioPolicyRegistered_afterPolicyRegisteredAndUnregistered_returnsFalse() {
+    AudioPolicy audioPolicy = new AudioPolicy.Builder(appContext).build();
+
+    audioManager.registerAudioPolicy(audioPolicy);
+    audioManager.unregisterAudioPolicy(audioPolicy);
+
+    assertThat(shadowOf(audioManager).isAnyAudioPolicyRegistered()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void generateAudioSessionId_returnsPositiveValues() {
+    int audioSessionId = audioManager.generateAudioSessionId();
+    int audioSessionId2 = audioManager.generateAudioSessionId();
+
+    assertThat(audioSessionId).isGreaterThan(0);
+    assertThat(audioSessionId2).isGreaterThan(0);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void generateAudioSessionId_returnsDistinctValues() {
+    int audioSessionId = audioManager.generateAudioSessionId();
+    int audioSessionId2 = audioManager.generateAudioSessionId();
+
+    assertThat(audioSessionId).isNotEqualTo(audioSessionId2);
+  }
+
+  private static AudioDeviceInfo createAudioDevice(int type) throws ReflectiveOperationException {
+    AudioDeviceInfo info = Shadow.newInstanceOf(AudioDeviceInfo.class);
+    Field portField = AudioDeviceInfo.class.getDeclaredField("mPort");
+    portField.setAccessible(true);
+    Object port = Shadow.newInstanceOf("android.media.AudioDevicePort");
+    portField.set(info, port);
+
+    Field typeField = port.getClass().getDeclaredField("mType");
+    typeField.setAccessible(true);
+    typeField.set(port, type);
+
+    return info;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioRecordTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioRecordTest.java
new file mode 100644
index 0000000..6da0d17
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioRecordTest.java
@@ -0,0 +1,362 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static java.lang.Math.min;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.media.AudioFormat;
+import android.media.AudioRecord;
+import android.media.MediaRecorder.AudioSource;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowAudioRecord.AudioRecordSource;
+
+/** Tests for {@link ShadowAudioRecord}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowAudioRecordTest {
+
+  @Test
+  public void startReturnsSuccess() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.getRecordingState()).isEqualTo(AudioRecord.RECORDSTATE_RECORDING);
+  }
+
+  @Test
+  public void setSourceProvider() {
+    byte[] firstAudioRecordInput = new byte[] {1, 2, 3};
+    AudioRecordSource firstAudioRecordSource = createAudioRecordSource(firstAudioRecordInput);
+    AudioRecord firstAudioRecord = createAudioRecord();
+    byte[] secondAudioRecordInput = new byte[] {4, 5, 6, 7, 8};
+    AudioRecordSource subsequentAudioRecordSource = createAudioRecordSource(secondAudioRecordInput);
+    AudioRecord secondAudioRecord = createAudioRecord();
+    ShadowAudioRecord.setSourceProvider(
+        audioRecord -> {
+          if (audioRecord == firstAudioRecord) {
+            return firstAudioRecordSource;
+          }
+          return subsequentAudioRecordSource;
+        });
+
+    firstAudioRecord.startRecording();
+    byte[] firstAudioRecordData = new byte[100];
+    int firstAudioRecordBytesRead = firstAudioRecord.read(firstAudioRecordData, 0, 100);
+    firstAudioRecord.stop();
+    firstAudioRecord.release();
+    // Read from second AudioRecord.
+    secondAudioRecord.startRecording();
+    byte[] secondAudioRecordData = new byte[100];
+    int secondAudioRecordBytesRead = secondAudioRecord.read(secondAudioRecordData, 0, 100);
+    secondAudioRecord.stop();
+    secondAudioRecord.release();
+
+    assertThat(firstAudioRecordBytesRead).isEqualTo(firstAudioRecordInput.length);
+    assertThat(Arrays.copyOf(firstAudioRecordData, firstAudioRecordInput.length))
+        .isEqualTo(firstAudioRecordInput);
+    assertThat(secondAudioRecordBytesRead).isEqualTo(secondAudioRecordInput.length);
+    assertThat(Arrays.copyOf(secondAudioRecordData, secondAudioRecordInput.length))
+        .isEqualTo(secondAudioRecordInput);
+  }
+
+  @Test
+  public void setSourceProvider_readBytesSlowly() {
+    byte[] audioRecordInput = new byte[] {1, 2, 3, 4, 5, 6, 7, 8};
+    AudioRecordSource audioRecordSource = createAudioRecordSource(audioRecordInput);
+    ShadowAudioRecord.setSourceProvider(audioRecord -> audioRecordSource);
+
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+    byte[] audioRecordData = new byte[100];
+    int audioRecordBytesFirstRead = audioRecord.read(audioRecordData, 0, 3);
+    int audioRecordBytesSecondRead = audioRecord.read(audioRecordData, 3, 3);
+    int audioRecordBytesThirdRead = audioRecord.read(audioRecordData, 6, 94);
+    audioRecord.stop();
+    audioRecord.release();
+
+    assertThat(audioRecordBytesFirstRead).isEqualTo(3);
+    assertThat(audioRecordBytesSecondRead).isEqualTo(3);
+    assertThat(audioRecordBytesThirdRead).isEqualTo(2);
+    assertThat(Arrays.copyOf(audioRecordData, audioRecordInput.length)).isEqualTo(audioRecordInput);
+  }
+
+  @Test
+  public void setSource_instanceCreatedBeforeSetSourceIsCalled() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+    byte[] audioRecordInput = new byte[] {1, 2, 3, 4, 5, 6, 7, 8};
+    ShadowAudioRecord.setSource(createAudioRecordSource(audioRecordInput));
+
+    byte[] audioRecordData = new byte[100];
+    int audioRecordBytesRead = audioRecord.read(audioRecordData, 0, 100);
+    audioRecord.stop();
+    audioRecord.release();
+
+    assertThat(audioRecordBytesRead).isEqualTo(audioRecordInput.length);
+    assertThat(Arrays.copyOf(audioRecordData, audioRecordInput.length)).isEqualTo(audioRecordInput);
+  }
+
+  @Test
+  public void nativeReadByteFillsAudioDataByDefault() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(new byte[100], 0, 100)).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadByteFillsAudioDataByDefaultMOnwards() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(new byte[100], 0, 100, AudioRecord.READ_BLOCKING)).isEqualTo(100);
+  }
+
+  @Test
+  public void nativeReadByteCallsAudioRecordSourceWhenSet() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(new byte[100], 0, 100);
+
+    verify(source).readInByteArray(any(byte[].class), eq(0), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadByteCallsAudioRecordSourceWhenSetBlockingMOnwards() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(new byte[100], 0, 100, AudioRecord.READ_BLOCKING);
+
+    verify(source).readInByteArray(any(byte[].class), eq(0), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadByteCallsAudioRecordSourceWhenSetNonBlockingMOnwards() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(new byte[100], 0, 100, AudioRecord.READ_NON_BLOCKING);
+
+    verify(source).readInByteArray(any(byte[].class), eq(0), eq(100), /* isBlocking=*/ eq(false));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  public void nativeReadShortFillsAudioDataByDefault() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(new short[100], 0, 100)).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadShortFillsAudioDataByDefaultMOnwards() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(new short[100], 0, 100, AudioRecord.READ_BLOCKING)).isEqualTo(100);
+  }
+
+  @Test
+  public void nativeReadShortCallsAudioRecordSourceWhenSet() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(new short[100], 0, 100);
+
+    verify(source).readInShortArray(any(short[].class), eq(0), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadShortCallsAudioRecordSourceWhenSetBlockingMOnwards() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(new short[100], 0, 100, AudioRecord.READ_BLOCKING);
+
+    verify(source).readInShortArray(any(short[].class), eq(0), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadShortCallsAudioRecordSourceWhenSetNonBlockingMOnwards() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(new short[100], 0, 100, AudioRecord.READ_NON_BLOCKING);
+
+    verify(source).readInShortArray(any(short[].class), eq(0), eq(100), /* isBlocking=*/ eq(false));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadFloatFillsAudioDataByDefaultMOnwards() {
+    AudioRecord audioRecord =
+        new AudioRecord(
+            AudioSource.MIC,
+            16000,
+            AudioFormat.CHANNEL_IN_MONO,
+            AudioFormat.ENCODING_PCM_FLOAT,
+            1024);
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(new float[100], 0, 100, AudioRecord.READ_BLOCKING)).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadFloatCallsAudioRecordSourceWhenSetBlocking() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord =
+        new AudioRecord(
+            AudioSource.MIC,
+            16000,
+            AudioFormat.CHANNEL_IN_MONO,
+            AudioFormat.ENCODING_PCM_FLOAT,
+            1024);
+    audioRecord.startRecording();
+
+    audioRecord.read(new float[100], 0, 100, AudioRecord.READ_BLOCKING);
+
+    verify(source).readInFloatArray(any(float[].class), eq(0), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadFloatCallsAudioRecordSourceWhenSetNonBlocking() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord =
+        new AudioRecord(
+            AudioSource.MIC,
+            16000,
+            AudioFormat.CHANNEL_IN_MONO,
+            AudioFormat.ENCODING_PCM_FLOAT,
+            1024);
+    audioRecord.startRecording();
+
+    audioRecord.read(new float[100], 0, 100, AudioRecord.READ_NON_BLOCKING);
+
+    verify(source).readInFloatArray(any(float[].class), eq(0), eq(100), /* isBlocking=*/ eq(false));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  public void nativeReadByteBufferFillsAudioDataByDefault() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(ByteBuffer.allocate(100), 100)).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadByteBufferFillsAudioDataByDefaultMOnwards() {
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    assertThat(audioRecord.read(ByteBuffer.allocate(100), 100, AudioRecord.READ_BLOCKING))
+        .isEqualTo(100);
+  }
+
+  @Test
+  public void nativeReadByteBufferCallsAudioRecordSourceWhenSet() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(ByteBuffer.allocate(100), 100);
+
+    verify(source).readInDirectBuffer(any(ByteBuffer.class), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadByteBufferCallsAudioRecordSourceWhenSetBlockingMOnwards() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(ByteBuffer.allocate(100), 100, AudioRecord.READ_BLOCKING);
+
+    verify(source).readInDirectBuffer(any(ByteBuffer.class), eq(100), /* isBlocking=*/ eq(true));
+    verifyNoMoreInteractions(source);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void nativeReadByteBufferCallsAudioRecordSourceWhenSetNonBlockingMOnwards() {
+    AudioRecordSource source = Mockito.mock(AudioRecordSource.class);
+    ShadowAudioRecord.setSource(source);
+    AudioRecord audioRecord = createAudioRecord();
+    audioRecord.startRecording();
+
+    audioRecord.read(ByteBuffer.allocate(100), 100, AudioRecord.READ_NON_BLOCKING);
+
+    verify(source).readInDirectBuffer(any(ByteBuffer.class), eq(100), /* isBlocking=*/ eq(false));
+    verifyNoMoreInteractions(source);
+  }
+
+  private static AudioRecord createAudioRecord() {
+    return new AudioRecord(
+        AudioSource.MIC, 16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, 1024);
+  }
+
+  private static AudioRecordSource createAudioRecordSource(byte[] bytes) {
+    return new AudioRecordSource() {
+      int bytesRead = 0;
+
+      @Override
+      public int readInByteArray(
+          byte[] audioData, int offsetInBytes, int sizeInBytes, boolean isBlocking) {
+        int availableBytesToBeRead = min(bytes.length - bytesRead, sizeInBytes);
+        if (availableBytesToBeRead <= 0) {
+          return -1;
+        }
+        System.arraycopy(bytes, bytesRead, audioData, offsetInBytes, availableBytesToBeRead);
+        bytesRead += availableBytesToBeRead;
+        return availableBytesToBeRead;
+      }
+    };
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java
new file mode 100644
index 0000000..adffa01
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java
@@ -0,0 +1,198 @@
+package org.robolectric.shadows;
+
+import static android.media.AudioTrack.ERROR_BAD_VALUE;
+import static android.media.AudioTrack.WRITE_BLOCKING;
+import static android.media.AudioTrack.WRITE_NON_BLOCKING;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowAudioTrack}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowAudioTrackTest implements ShadowAudioTrack.OnAudioDataWrittenListener {
+
+  private static final int SAMPLE_RATE_IN_HZ = 44100;
+  private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_STEREO;
+  private static final int AUDIO_ENCODING_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
+  private ShadowAudioTrack shadowAudioTrack;
+  private byte[] dataWrittenToShadowAudioTrack;
+
+  @Test
+  public void multichannelAudio_isSupported() {
+    AudioFormat format =
+        new AudioFormat.Builder()
+            .setChannelMask(
+                AudioFormat.CHANNEL_OUT_FRONT_CENTER
+                    | AudioFormat.CHANNEL_OUT_FRONT_LEFT
+                    | AudioFormat.CHANNEL_OUT_FRONT_RIGHT
+                    | AudioFormat.CHANNEL_OUT_BACK_LEFT
+                    | AudioFormat.CHANNEL_OUT_BACK_RIGHT
+                    | AudioFormat.CHANNEL_OUT_LOW_FREQUENCY)
+            .setEncoding(AUDIO_ENCODING_FORMAT)
+            .setSampleRate(SAMPLE_RATE_IN_HZ)
+            .build();
+
+    // 2s buffer
+    int bufferSizeBytes =
+        2 * SAMPLE_RATE_IN_HZ * 6 * AudioFormat.getBytesPerSample(AUDIO_ENCODING_FORMAT);
+
+    // Ensure the constructor doesn't throw an exception.
+    new AudioTrack(
+        new AudioAttributes.Builder().build(),
+        format,
+        bufferSizeBytes,
+        AudioTrack.MODE_STREAM,
+        AudioManager.AUDIO_SESSION_ID_GENERATE);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setMinBufferSize() {
+    int originalMinBufferSize =
+        AudioTrack.getMinBufferSize(SAMPLE_RATE_IN_HZ, CHANNEL_CONFIG, AUDIO_ENCODING_FORMAT);
+    ShadowAudioTrack.setMinBufferSize(512);
+    int newMinBufferSize =
+        AudioTrack.getMinBufferSize(SAMPLE_RATE_IN_HZ, CHANNEL_CONFIG, AUDIO_ENCODING_FORMAT);
+
+    assertThat(originalMinBufferSize).isEqualTo(ShadowAudioTrack.DEFAULT_MIN_BUFFER_SIZE);
+    assertThat(newMinBufferSize).isEqualTo(512);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeByteArray_blocking() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+
+    int written = audioTrack.write(new byte[] {0, 0, 0, 0}, 0, 2);
+
+    assertThat(written).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeByteArray_nonBlocking() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+
+    int written = audioTrack.write(new byte[] {0, 0, 0, 0}, 0, 2, WRITE_NON_BLOCKING);
+
+    assertThat(written).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeByteBuffer_blocking() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+    ByteBuffer byteBuffer = ByteBuffer.allocate(4);
+
+    int written = audioTrack.write(byteBuffer, 2, WRITE_BLOCKING);
+
+    assertThat(written).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeByteBuffer_nonBlocking() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+    ByteBuffer byteBuffer = ByteBuffer.allocate(4);
+
+    int written = audioTrack.write(byteBuffer, 2, WRITE_NON_BLOCKING);
+
+    assertThat(written).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeByteBuffer_correctBytesWritten() {
+    ShadowAudioTrack.addAudioDataListener(this);
+    AudioTrack audioTrack = getSampleAudioTrack();
+
+    ByteBuffer byteBuffer = ByteBuffer.allocate(4);
+    byte[] dataToWrite = new byte[] {1, 2, 3, 4};
+    byteBuffer.put(dataToWrite);
+    byteBuffer.flip();
+
+    audioTrack.write(byteBuffer, 4, WRITE_NON_BLOCKING);
+
+    assertThat(dataWrittenToShadowAudioTrack).isEqualTo(dataToWrite);
+    assertThat(shadowAudioTrack.getPlaybackHeadPosition()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeDirectByteBuffer_blocking() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4);
+
+    int written = audioTrack.write(byteBuffer, 2, WRITE_BLOCKING);
+
+    assertThat(written).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeDirectByteBuffer_nonBlocking() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4);
+
+    int written = audioTrack.write(byteBuffer, 2, WRITE_NON_BLOCKING);
+
+    assertThat(written).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeDirectByteBuffer_invalidWriteMode() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4);
+
+    int written = audioTrack.write(byteBuffer, 2, 5);
+
+    assertThat(written).isEqualTo(ERROR_BAD_VALUE);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void writeDirectByteBuffer_invalidSize() {
+    AudioTrack audioTrack = getSampleAudioTrack();
+    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4);
+
+    int written = audioTrack.write(byteBuffer, 10, WRITE_NON_BLOCKING);
+
+    assertThat(written).isEqualTo(ERROR_BAD_VALUE);
+  }
+
+  @Override
+  @Config(minSdk = Q)
+  public void onAudioDataWritten(
+      ShadowAudioTrack audioTrack, byte[] audioData, AudioFormat format) {
+    shadowAudioTrack = audioTrack;
+    dataWrittenToShadowAudioTrack = audioData;
+  }
+
+  private static AudioTrack getSampleAudioTrack() {
+    return new AudioTrack.Builder()
+        .setAudioAttributes(
+            new AudioAttributes.Builder()
+                .setUsage(AudioAttributes.USAGE_ALARM)
+                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+                .build())
+        .setAudioFormat(
+            new AudioFormat.Builder()
+                .setEncoding(AUDIO_ENCODING_FORMAT)
+                .setSampleRate(SAMPLE_RATE_IN_HZ)
+                .setChannelMask(CHANNEL_CONFIG)
+                .build())
+        .build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAutoCompleteTextViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAutoCompleteTextViewTest.java
new file mode 100644
index 0000000..898caf6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAutoCompleteTextViewTest.java
@@ -0,0 +1,66 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.content.Context;
+import android.widget.ArrayAdapter;
+import android.widget.AutoCompleteTextView;
+import android.widget.Filter;
+import android.widget.Filterable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowAutoCompleteTextViewTest {
+  private final AutoCompleteAdapter adapter =
+      new AutoCompleteAdapter(ApplicationProvider.getApplicationContext());
+
+  @Test
+  public void shouldInvokeFilter() {
+    shadowMainLooper().pause();
+    AutoCompleteTextView view =
+        new AutoCompleteTextView(ApplicationProvider.getApplicationContext());
+    view.setAdapter(adapter);
+
+    view.setText("Foo");
+    assertThat(adapter.getCount()).isEqualTo(2);
+  }
+
+  private class AutoCompleteAdapter extends ArrayAdapter<String> implements Filterable {
+    public AutoCompleteAdapter(Context context) {
+      super(context, android.R.layout.simple_list_item_1);
+    }
+
+    @Override
+    public Filter getFilter() {
+      return new AutoCompleteFilter();
+    }
+  }
+
+  private class AutoCompleteFilter extends Filter {
+    @Override
+    protected FilterResults performFiltering(CharSequence text) {
+      FilterResults results = new FilterResults();
+      if (text != null) {
+        results.count = 2;
+        results.values = new ArrayList<>(Arrays.asList("Foo", "Bar"));
+      }
+      return results;
+    }
+
+    @Override
+    protected void publishResults(CharSequence text, FilterResults results) {
+      if (results != null) {
+        adapter.clear();
+        adapter.addAll((List<String>) results.values);
+        adapter.notifyDataSetChanged();
+      }
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAutofillManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAutofillManagerTest.java
new file mode 100644
index 0000000..419923b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAutofillManagerTest.java
@@ -0,0 +1,58 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.view.autofill.AutofillManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit test for {@link ShadowAutofillManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O)
+public class ShadowAutofillManagerTest {
+  private final Context context = ApplicationProvider.getApplicationContext();
+  private final AutofillManager autofillManager = context.getSystemService(AutofillManager.class);
+
+  @Test
+  @Config(minSdk = P)
+  public void setAutofillServiceComponentName() {
+    assertThat(autofillManager.getAutofillServiceComponentName()).isNull();
+
+    ComponentName componentName = new ComponentName("package", "class");
+    shadowOf(autofillManager).setAutofillServiceComponentName(componentName);
+    assertThat(autofillManager.getAutofillServiceComponentName()).isEqualTo(componentName);
+
+    shadowOf(autofillManager).setAutofillServiceComponentName(null);
+    assertThat(autofillManager.getAutofillServiceComponentName()).isNull();
+  }
+
+  @Test
+  public void setAutofillSupported() {
+    assertThat(autofillManager.isAutofillSupported()).isFalse();
+
+    shadowOf(autofillManager).setAutofillSupported(true);
+    assertThat(autofillManager.isAutofillSupported()).isTrue();
+
+    shadowOf(autofillManager).setAutofillSupported(false);
+    assertThat(autofillManager.isAutofillSupported()).isFalse();
+  }
+
+  @Test
+  public void setEnabled() {
+    assertThat(autofillManager.isEnabled()).isFalse();
+
+    shadowOf(autofillManager).setEnabled(true);
+    assertThat(autofillManager.isEnabled()).isTrue();
+
+    shadowOf(autofillManager).setEnabled(false);
+    assertThat(autofillManager.isEnabled()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBackdropFrameRendererTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBackdropFrameRendererTest.java
new file mode 100644
index 0000000..83af4fa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBackdropFrameRendererTest.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.graphics.Insets;
+import android.graphics.Rect;
+import android.view.ThreadedRenderer;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.android.internal.policy.BackdropFrameRenderer;
+import com.android.internal.policy.DecorView;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Tests for {@link ShadowBackdropFrameRenderer} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = S)
+public class ShadowBackdropFrameRendererTest {
+
+  @Test
+  public void releaseRenderer_afterCreate_doesNotLeakThread() throws Exception {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    DecorView decorView = (DecorView) activity.getWindow().getDecorView();
+    for (int i = 0; i < 100; i++) {
+      BackdropFrameRenderer r =
+          new BackdropFrameRenderer(
+              decorView,
+              ThreadedRenderer.create(RuntimeEnvironment.getApplication(), false, "renderer"),
+              new Rect(0, 0, 0, 0),
+              null,
+              null,
+              null,
+              Color.BLUE,
+              Color.BLUE,
+              false,
+              Insets.of(0, 0, 0, 0));
+      ReflectionHelpers.callInstanceMethod(r, "releaseRenderer");
+      // Without the ShadowBackdropFrameRenderer.run override, the call to join would hang
+      // indefinitely.
+      r.join();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBackupManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBackupManagerTest.java
new file mode 100644
index 0000000..b7c8cfa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBackupManagerTest.java
@@ -0,0 +1,219 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Application;
+import android.app.backup.BackupManager;
+import android.app.backup.RestoreObserver;
+import android.app.backup.RestoreSession;
+import android.app.backup.RestoreSet;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.truth.Correspondence;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Objects;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Unit tests for {@link ShadowBackupManager}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowBackupManagerTest {
+  private BackupManager backupManager;
+  @Mock private TestRestoreObserver restoreObserver;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+
+    shadowMainLooper().pause();
+
+    shadowOf((Application) ApplicationProvider.getApplicationContext())
+        .grantPermissions(android.Manifest.permission.BACKUP);
+    backupManager = new BackupManager(ApplicationProvider.getApplicationContext());
+
+    shadowOf(backupManager).addAvailableRestoreSets(123L, Arrays.asList("foo.bar", "bar.baz"));
+    shadowOf(backupManager).addAvailableRestoreSets(456L, Collections.singletonList("hello.world"));
+  }
+
+  @Test
+  public void dataChanged() {
+    assertThat(shadowOf(backupManager).isDataChanged()).isFalse();
+    assertThat(shadowOf(backupManager).getDataChangedCount()).isEqualTo(0);
+
+    for (int i = 1; i <= 3; i++) {
+      backupManager.dataChanged();
+
+      assertThat(shadowOf(backupManager).isDataChanged()).isTrue();
+      assertThat(shadowOf(backupManager).getDataChangedCount()).isEqualTo(i);
+    }
+
+    ShadowBackupManager.reset();
+    assertThat(shadowOf(backupManager).isDataChanged()).isFalse();
+    assertThat(shadowOf(backupManager).getDataChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setBackupEnabled_setToTrue_shouldEnableBackup() {
+    backupManager.setBackupEnabled(true);
+    assertThat(backupManager.isBackupEnabled()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setBackupEnabled_multipleInstances_shouldBeEnabled() {
+    // BackupManager is used by creating new instances, but all of them talk to the same
+    // BackupManagerService in Android, so methods that route through the service will share states.
+    backupManager.setBackupEnabled(true);
+    assertThat(new BackupManager(ApplicationProvider.getApplicationContext()).isBackupEnabled())
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setBackupEnabled_setToFalse_shouldDisableBackup() {
+    backupManager.setBackupEnabled(false);
+    assertThat(backupManager.isBackupEnabled()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isBackupEnabled_noPermission_shouldThrowSecurityException() {
+    shadowOf((Application) ApplicationProvider.getApplicationContext())
+        .denyPermissions(android.Manifest.permission.BACKUP);
+    try {
+      backupManager.isBackupEnabled();
+      fail("SecurityException should be thrown");
+    } catch (SecurityException e) {
+      // pass
+    }
+  }
+
+  @Test
+  public void getAvailableRestoreSets_shouldCallbackToRestoreSetsAvailable() {
+    RestoreSession restoreSession = backupManager.beginRestoreSession();
+    int result = restoreSession.getAvailableRestoreSets(restoreObserver);
+
+    assertThat(result).isEqualTo(BackupManager.SUCCESS);
+    shadowMainLooper().idle();
+    ArgumentCaptor<RestoreSet[]> restoreSetArg = ArgumentCaptor.forClass(RestoreSet[].class);
+    verify(restoreObserver).restoreSetsAvailable(restoreSetArg.capture());
+
+    RestoreSet[] restoreSets = restoreSetArg.getValue();
+    assertThat(restoreSets).hasLength(2);
+    assertThat(restoreSets)
+        .asList()
+        .comparingElementsUsing(fieldCorrespondence("token"))
+        .containsExactly(123L, 456L);
+  }
+
+  @Test
+  public void restoreAll_shouldRestoreData() {
+    RestoreSession restoreSession = backupManager.beginRestoreSession();
+    int result = restoreSession.restoreAll(123L, restoreObserver);
+
+    assertThat(result).isEqualTo(BackupManager.SUCCESS);
+    shadowMainLooper().idle();
+
+    verify(restoreObserver).restoreStarting(eq(2));
+    verify(restoreObserver).restoreFinished(eq(BackupManager.SUCCESS));
+
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("foo.bar")).isEqualTo(123L);
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("bar.baz")).isEqualTo(123L);
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("hello.world")).isEqualTo(0L);
+  }
+
+  @Test
+  public void restoreSome_shouldRestoreSpecifiedPackages() {
+    RestoreSession restoreSession = backupManager.beginRestoreSession();
+    int result = restoreSession.restoreSome(123L, restoreObserver, new String[] {"bar.baz"});
+
+    assertThat(result).isEqualTo(BackupManager.SUCCESS);
+
+    shadowMainLooper().idle();
+    verify(restoreObserver).restoreStarting(eq(1));
+    verify(restoreObserver).restoreFinished(eq(BackupManager.SUCCESS));
+
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("foo.bar")).isEqualTo(0L);
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("bar.baz")).isEqualTo(123L);
+  }
+
+  @Test
+  public void restorePackage_shouldRestoreSpecifiedPackage() {
+    RestoreSession restoreSession = backupManager.beginRestoreSession();
+
+    restoreSession.restoreSome(123L, restoreObserver, new String[0]);
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("bar.baz")).isEqualTo(0L);
+    restoreSession.endRestoreSession();
+    shadowMainLooper().idle();
+    Mockito.reset(restoreObserver);
+
+    restoreSession = backupManager.beginRestoreSession();
+    int result = restoreSession.restorePackage("bar.baz", restoreObserver);
+
+    assertThat(result).isEqualTo(BackupManager.SUCCESS);
+    shadowMainLooper().idle();
+
+    verify(restoreObserver).restoreStarting(eq(1));
+    verify(restoreObserver).restoreFinished(eq(BackupManager.SUCCESS));
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("bar.baz")).isEqualTo(123L);
+  }
+
+  @Test
+  public void restorePackage_noRestoreToken_shouldReturnFailure() {
+    RestoreSession restoreSession = backupManager.beginRestoreSession();
+    int result = restoreSession.restorePackage("bar.baz", restoreObserver);
+    assertThat(result).isEqualTo(-1);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getAvailableRestoreToken_noRestoreToken_returnsDefaultValue() {
+    long defaultValue = 0L;
+    assertThat(shadowOf(backupManager).getPackageRestoreToken("foo.bar")).isEqualTo(defaultValue);
+    assertThat(backupManager.getAvailableRestoreToken("foo.bar")).isEqualTo(defaultValue);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getAvailableRestoreToken_restoreTokenAvailableForSomePackages_returnsCorrectValues() {
+    long defaultVal = 0L;
+    long restoreToken = 123L;
+    RestoreSession restoreSession = backupManager.beginRestoreSession();
+    int result =
+        restoreSession.restoreSome(restoreToken, restoreObserver, new String[] {"bar.baz"});
+
+    assertThat(result).isEqualTo(BackupManager.SUCCESS);
+
+    shadowMainLooper().idle();
+    verify(restoreObserver).restoreStarting(eq(1));
+    verify(restoreObserver).restoreFinished(eq(BackupManager.SUCCESS));
+
+    assertThat(backupManager.getAvailableRestoreToken("foo.bar")).isEqualTo(defaultVal);
+    assertThat(backupManager.getAvailableRestoreToken("bar.baz")).isEqualTo(restoreToken);
+  }
+
+  private static <T, F> Correspondence<T, F> fieldCorrespondence(String fieldName) {
+    return Correspondence.from(
+        (actual, expected) ->
+            Objects.equals(ReflectionHelpers.getField(actual, fieldName), expected),
+        "field \"" + fieldName + "\" matches");
+  }
+
+  private static class TestRestoreObserver extends RestoreObserver {}
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBaseAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBaseAdapterTest.java
new file mode 100644
index 0000000..dbd57f9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBaseAdapterTest.java
@@ -0,0 +1,52 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBaseAdapterTest {
+  @Test
+  public void shouldRecordNotifyDataSetChanged() {
+    BaseAdapter adapter = new TestBaseAdapter();
+    adapter.notifyDataSetChanged();
+    assertTrue(shadowOf(adapter).wasNotifyDataSetChangedCalled());
+  }
+
+  @Test
+  public void canResetNotifyDataSetChangedFlag() {
+    BaseAdapter adapter = new TestBaseAdapter();
+    adapter.notifyDataSetChanged();
+    shadowOf(adapter).clearWasDataSetChangedCalledFlag();
+    assertFalse(shadowOf(adapter).wasNotifyDataSetChangedCalled());
+  }
+
+  private static class TestBaseAdapter extends BaseAdapter {
+    @Override
+    public int getCount() {
+      return 0;
+    }
+
+    @Override
+    public Object getItem(int position) {
+      return null;
+    }
+
+    @Override
+    public long getItemId(int position) {
+      return 0;
+    }
+
+    @Override
+    public View getView(int position, View convertView, ViewGroup parent) {
+      return null;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBasicTagTechnologyTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBasicTagTechnologyTest.java
new file mode 100644
index 0000000..d3918cc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBasicTagTechnologyTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nfc.tech.IsoDep;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowBasicTagTechnology}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = KITKAT)
+public final class ShadowBasicTagTechnologyTest {
+
+  // IsoDep extends BasicTagTechnology, which is otherwise a package-protected class.
+  private IsoDep basicTagTechnology;
+
+  @Before
+  public void setUp() {
+    basicTagTechnology = ShadowIsoDep.newInstance();
+  }
+
+  @Test
+  public void connect() throws Exception {
+    assertThat(basicTagTechnology.isConnected()).isFalse();
+    basicTagTechnology.connect();
+    assertThat(basicTagTechnology.isConnected()).isTrue();
+  }
+
+  @Test
+  public void close() throws Exception {
+    basicTagTechnology.connect();
+    basicTagTechnology.close();
+    assertThat(basicTagTechnology.isConnected()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBatteryManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBatteryManagerTest.java
new file mode 100644
index 0000000..0b8986f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBatteryManagerTest.java
@@ -0,0 +1,73 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.os.BatteryManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowBatteryManagerTest {
+  private BatteryManager batteryManager;
+  private ShadowBatteryManager shadowBatteryManager;
+  private static final int TEST_ID = 123;
+
+  @Before
+  public void before() {
+    batteryManager =
+        (BatteryManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.BATTERY_SERVICE);
+    shadowBatteryManager = shadowOf(batteryManager);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testIsCharging() {
+    assertThat(batteryManager.isCharging()).isFalse();
+
+    shadowBatteryManager.setIsCharging(true);
+
+    assertThat(batteryManager.isCharging()).isTrue();
+
+    shadowBatteryManager.setIsCharging(false);
+
+    assertThat(batteryManager.isCharging()).isFalse();
+  }
+
+  @Test
+  public void testGetIntProperty() {
+    assertThat(batteryManager.getIntProperty(TEST_ID)).isEqualTo(Integer.MIN_VALUE);
+
+    shadowBatteryManager.setIntProperty(TEST_ID, 5);
+    assertThat(batteryManager.getIntProperty(TEST_ID)).isEqualTo(5);
+
+    shadowBatteryManager.setIntProperty(TEST_ID, 0);
+    assertThat(batteryManager.getIntProperty(TEST_ID)).isEqualTo(0);
+
+    shadowBatteryManager.setIntProperty(TEST_ID, Integer.MAX_VALUE);
+    assertThat(batteryManager.getIntProperty(TEST_ID)).isEqualTo(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void testGetLongProperty() {
+    assertThat(batteryManager.getLongProperty(TEST_ID)).isEqualTo(Long.MIN_VALUE);
+
+    shadowBatteryManager.setLongProperty(TEST_ID, 5L);
+    assertThat(batteryManager.getLongProperty(TEST_ID)).isEqualTo(5L);
+
+    shadowBatteryManager.setLongProperty(TEST_ID, 0);
+    assertThat(batteryManager.getLongProperty(TEST_ID)).isEqualTo(0);
+
+    shadowBatteryManager.setLongProperty(TEST_ID, Long.MAX_VALUE);
+    assertThat(batteryManager.getLongProperty(TEST_ID)).isEqualTo(Long.MAX_VALUE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBinderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBinderTest.java
new file mode 100644
index 0000000..b02e066
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBinderTest.java
@@ -0,0 +1,170 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.os.Binder;
+import android.os.Parcel;
+import android.os.UserHandle;
+import android.os.UserManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBinderTest {
+
+  private UserManager userManager;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
+  }
+
+  @Test
+  public void transactCallsOnTransact() throws Exception {
+    TestBinder testBinder = new TestBinder();
+    Parcel data = Parcel.obtain();
+    Parcel reply = Parcel.obtain();
+    data.writeString("Hello Robolectric");
+    assertTrue(testBinder.transact(2, data, reply, 3));
+    assertThat(testBinder.code).isEqualTo(2);
+    assertThat(testBinder.data).isSameInstanceAs(data);
+    assertThat(testBinder.reply).isSameInstanceAs(reply);
+    assertThat(testBinder.flags).isEqualTo(3);
+    reply.readException();
+    assertThat(reply.readString()).isEqualTo("Hello Robolectric");
+  }
+
+  static class TestBinder extends Binder {
+    int code;
+    Parcel data;
+    Parcel reply;
+    int flags;
+
+    @Override
+    protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
+      this.code = code;
+      this.data = data;
+      this.reply = reply;
+      this.flags = flags;
+      String string = data.readString();
+      reply.writeNoException();
+      reply.writeString(string);
+      return true;
+    }
+  }
+
+  @Test
+  public void thrownExceptionIsParceled() throws Exception {
+    TestThrowingBinder testThrowingBinder = new TestThrowingBinder();
+    Parcel data = Parcel.obtain();
+    Parcel reply = Parcel.obtain();
+    testThrowingBinder.transact(2, data, reply, 3);
+    try {
+      reply.readException();
+      fail(); // Expect thrown
+    } catch (SecurityException e) {
+      assertThat(e.getMessage()).isEqualTo("Halt! Who goes there?");
+    }
+  }
+
+  static class TestThrowingBinder extends Binder {
+
+    @Override
+    protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
+      throw new SecurityException("Halt! Who goes there?");
+    }
+  }
+
+  @Test
+  public void testSetCallingUid() {
+    ShadowBinder.setCallingUid(37);
+    assertThat(Binder.getCallingUid()).isEqualTo(37);
+  }
+
+  @Test
+  public void testSetCallingPid() {
+    ShadowBinder.setCallingPid(25);
+    assertThat(Binder.getCallingPid()).isEqualTo(25);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testSetCallingUserHandle() {
+    UserHandle newUser = shadowOf(userManager).addUser(10, "secondary_user", 0);
+    ShadowBinder.setCallingUserHandle(newUser);
+    assertThat(Binder.getCallingUserHandle()).isEqualTo(newUser);
+  }
+
+  @Test
+  public void testGetCallingUidShouldUseProcessUidByDefault() {
+    assertThat(Binder.getCallingUid()).isEqualTo(android.os.Process.myUid());
+  }
+
+  @Test
+  public void testGetCallingPidShouldUseProcessPidByDefault() {
+    assertThat(Binder.getCallingPid()).isEqualTo(android.os.Process.myPid());
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testGetCallingUidOrThrowWithValueSet() {
+    ShadowBinder.setCallingUid(123);
+    assertThat(Binder.getCallingUidOrThrow()).isEqualTo(123);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testGetCallingUidOrThrowWithValueNotSet() {
+    ShadowBinder.reset();
+    IllegalStateException ex =
+        assertThrows(IllegalStateException.class, () -> Binder.getCallingUidOrThrow());
+
+    // Typo in "transaction" is intentional to match platform
+    assertThat(ex).hasMessageThat().isEqualTo("Thread is not in a binder transcation");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testGetCallingUserHandleShouldUseThatOfProcessByDefault() {
+    assertThat(Binder.getCallingUserHandle()).isEqualTo(android.os.Process.myUserHandle());
+  }
+
+  @Test
+  public void testResetUpdatesCallingUidAndPid() {
+    ShadowBinder.setCallingPid(48);
+    ShadowBinder.setCallingUid(49);
+    ShadowBinder.reset();
+    assertThat(Binder.getCallingPid()).isEqualTo(android.os.Process.myPid());
+    assertThat(Binder.getCallingUid()).isEqualTo(android.os.Process.myUid());
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testResetUpdatesGetCallingUidOrThrow() {
+    ShadowBinder.setCallingUid(123);
+    ShadowBinder.reset();
+
+    assertThrows(IllegalStateException.class, () -> Binder.getCallingUidOrThrow());
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testResetUpdatesCallingUserHandle() {
+    UserHandle newUser = shadowOf(userManager).addUser(10, "secondary_user", 0);
+    ShadowBinder.setCallingUserHandle(newUser);
+    ShadowBinder.reset();
+    assertThat(Binder.getCallingUserHandle()).isEqualTo(android.os.Process.myUserHandle());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBiometricManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBiometricManagerTest.java
new file mode 100644
index 0000000..801eb9d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBiometricManagerTest.java
@@ -0,0 +1,104 @@
+package org.robolectric.shadows;
+
+import static android.hardware.biometrics.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE;
+import static android.hardware.biometrics.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED;
+import static android.hardware.biometrics.BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE;
+import static android.hardware.biometrics.BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED;
+import static android.hardware.biometrics.BiometricManager.BIOMETRIC_SUCCESS;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.biometrics.BiometricManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit test for {@link ShadowBiometricManager} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public class ShadowBiometricManagerTest {
+  private BiometricManager biometricManager;
+
+  @Before
+  public void setUp() {
+    biometricManager =
+        ApplicationProvider.getApplicationContext().getSystemService(BiometricManager.class);
+    assertThat(biometricManager).isNotNull();
+  }
+
+  @Test
+  public void testCanAuthenticate_serviceNotConnected_canNotAuthenticate() {
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(false);
+
+    assertThat(biometricManager.canAuthenticate()).isEqualTo(BIOMETRIC_ERROR_NO_HARDWARE);
+  }
+
+  @Test
+  public void testCanAuthenticate_serviceConnected_canAuthenticate() {
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(true);
+
+    assertThat(biometricManager.canAuthenticate()).isEqualTo(BIOMETRIC_SUCCESS);
+  }
+
+  @Test
+  public void testCanAuthenticate_serviceNotConnected_noEnrolledBiometric_biometricNotEnrolled() {
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(false);
+    shadowBiometricManager.setAuthenticatorType(BIOMETRIC_ERROR_NONE_ENROLLED);
+
+    assertThat(biometricManager.canAuthenticate()).isEqualTo(BIOMETRIC_ERROR_NONE_ENROLLED);
+  }
+
+  @Test
+  public void testCanAuthenticate_serviceNotConnected_noHardware_biometricHwUnavailable() {
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(false);
+    shadowBiometricManager.setAuthenticatorType(BIOMETRIC_ERROR_HW_UNAVAILABLE);
+
+    assertThat(biometricManager.canAuthenticate()).isEqualTo(BIOMETRIC_ERROR_HW_UNAVAILABLE);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void
+      testCanAuthenticate_serviceNotConnected_securityUpdateRequired_biometricErrorSecurityUpdateRequired() {
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(false);
+    shadowBiometricManager.setAuthenticatorType(BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED);
+
+    assertThat(biometricManager.canAuthenticate())
+        .isEqualTo(BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void
+      testCanAuthenticateBiometricWeak_serviceConnected_noWeakButHaveStrongEntrolled_canAuthenticate() {
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(true);
+    shadowBiometricManager.setAuthenticatorType(BiometricManager.Authenticators.BIOMETRIC_STRONG);
+
+    assertThat(biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK))
+        .isEqualTo(BIOMETRIC_SUCCESS);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void testCanAuthenticateBiometricWeakDeviceCredential_serviceConnected_canAuthenticate() {
+    final int authenticators =
+        BiometricManager.Authenticators.BIOMETRIC_WEAK
+            | BiometricManager.Authenticators.DEVICE_CREDENTIAL;
+    ShadowBiometricManager shadowBiometricManager = Shadow.extract(biometricManager);
+    shadowBiometricManager.setCanAuthenticate(true);
+    shadowBiometricManager.setAuthenticatorType(authenticators);
+
+    assertThat(biometricManager.canAuthenticate(authenticators)).isEqualTo(BIOMETRIC_SUCCESS);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapDrawableTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapDrawableTest.java
new file mode 100644
index 0000000..19e92ae
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapDrawableTest.java
@@ -0,0 +1,97 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorMatrix;
+import android.graphics.ColorMatrixColorFilter;
+import android.graphics.Shader;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Shadows;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBitmapDrawableTest {
+  private final Resources resources = ApplicationProvider.getApplicationContext().getResources();
+
+  @Test
+  public void constructors_shouldSetBitmap() {
+    Bitmap bitmap = Shadow.newInstanceOf(Bitmap.class);
+    BitmapDrawable drawable = new BitmapDrawable(bitmap);
+    assertThat(drawable.getBitmap()).isEqualTo(bitmap);
+
+    drawable = new BitmapDrawable(resources, bitmap);
+    assertThat(drawable.getBitmap()).isEqualTo(bitmap);
+  }
+
+  @Test
+  public void getBitmap_shouldReturnBitmapUsedToDraw() {
+    BitmapDrawable drawable = (BitmapDrawable) resources.getDrawable(R.drawable.an_image);
+    assertThat(shadowOf(drawable.getBitmap()).getDescription())
+        .isEqualTo("Bitmap for" + " resource:org.robolectric:drawable/an_image");
+  }
+
+  @Test
+  public void draw_shouldCopyDescriptionToCanvas() {
+    BitmapDrawable drawable = (BitmapDrawable) resources.getDrawable(R.drawable.an_image);
+    Canvas canvas = new Canvas();
+    drawable.draw(canvas);
+
+    assertThat(shadowOf(canvas).getDescription())
+        .isEqualTo("Bitmap for" + " resource:org.robolectric:drawable/an_image");
+  }
+
+  @Test
+  public void withColorFilterSet_draw_shouldCopyDescriptionToCanvas() {
+    BitmapDrawable drawable = (BitmapDrawable) resources.getDrawable(R.drawable.an_image);
+    drawable.setColorFilter(new ColorMatrixColorFilter(new ColorMatrix()));
+    Canvas canvas = new Canvas();
+    drawable.draw(canvas);
+
+    assertThat(shadowOf(canvas).getDescription())
+        .isEqualTo(
+            "Bitmap for"
+                + " resource:org.robolectric:drawable/an_image with ColorMatrixColorFilter");
+  }
+
+  @Test
+  public void shouldStillHaveShadow() {
+    Drawable drawable = resources.getDrawable(R.drawable.an_image);
+    assertThat(Shadows.shadowOf(drawable).getCreatedFromResId()).isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void shouldSetTileModeXY() {
+    BitmapDrawable drawable = (BitmapDrawable) resources.getDrawable(R.drawable.an_image);
+    drawable.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.MIRROR);
+    assertThat(drawable.getTileModeX()).isEqualTo(Shader.TileMode.REPEAT);
+    assertThat(drawable.getTileModeY()).isEqualTo(Shader.TileMode.MIRROR);
+  }
+
+  @Test
+  public void constructor_shouldSetTheIntrinsicWidthAndHeightToTheWidthAndHeightOfTheBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(5, 10, Bitmap.Config.ARGB_8888);
+    BitmapDrawable drawable =
+        new BitmapDrawable(ApplicationProvider.getApplicationContext().getResources(), bitmap);
+    assertThat(drawable.getIntrinsicWidth()).isEqualTo(5);
+    assertThat(drawable.getIntrinsicHeight()).isEqualTo(10);
+  }
+
+  @Test
+  public void constructor_shouldAcceptNullBitmap() {
+    assertThat(
+            new BitmapDrawable(
+                ApplicationProvider.getApplicationContext().getResources(), (Bitmap) null))
+        .isNotNull();
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapFactoryTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapFactoryTest.java
new file mode 100644
index 0000000..7128bd7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapFactoryTest.java
@@ -0,0 +1,565 @@
+package org.robolectric.shadows;
+
+import static com.google.common.io.Resources.toByteArray;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.MediaStore;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.ByteStreams;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBitmapFactoryTest {
+  private static final int TEST_JPEG_WIDTH = 50;
+  private static final int TEST_JPEG_HEIGHT = 50;
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void decodeResource_shouldSetDescriptionAndCreatedFrom() {
+    Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.an_image);
+    ShadowBitmap shadowBitmap = shadowOf(bitmap);
+    assertEquals(
+        "Bitmap for resource:org.robolectric:drawable/an_image", shadowBitmap.getDescription());
+    assertEquals(R.drawable.an_image, shadowBitmap.getCreatedFromResId());
+    assertEquals(64, bitmap.getWidth());
+    assertEquals(53, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeResource_shouldSetDefaultBitmapConfig() {
+    Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.an_image);
+    assertThat(bitmap.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.getRowBytes()).isNotEqualTo(0);
+  }
+
+  @Test
+  public void withResId0_decodeResource_shouldReturnNull() {
+    assertThat(BitmapFactory.decodeResource(context.getResources(), 0)).isNull();
+  }
+
+  @Test
+  public void decodeResource_shouldPassABitmapConfig() {
+    BitmapFactory.Options options = new BitmapFactory.Options();
+    options.inPreferredConfig = Bitmap.Config.ALPHA_8;
+    Bitmap bitmap =
+        BitmapFactory.decodeResource(context.getResources(), R.drawable.an_image, options);
+    assertThat(bitmap.getConfig()).isEqualTo(Bitmap.Config.ALPHA_8);
+  }
+
+  @Test
+  public void decodeResource_sameAs() throws IOException {
+    Resources resources = context.getResources();
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image);
+    File tmp = Files.createTempFile("BitmapTest", null).toFile();
+    try (FileOutputStream stream = new FileOutputStream(tmp)) {
+      bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
+    }
+    FileInputStream fileInputStream = new FileInputStream(tmp);
+    Bitmap bitmap2 = BitmapFactory.decodeStream(fileInputStream);
+    assertThat(bitmap.sameAs(bitmap2)).isTrue();
+  }
+
+  @Test
+  public void decodeResource_shouldGetCorrectColorFromPngImage() {
+    Resources resources = context.getResources();
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image, opts);
+    assertThat(bitmap.getPixel(0, 0) != 0).isTrue();
+  }
+
+  @Test
+  public void decodeFile_shouldSetDescriptionAndCreatedFrom() {
+    Bitmap bitmap = BitmapFactory.decodeFile("/some/file.jpg");
+    ShadowBitmap shadowBitmap = shadowOf(bitmap);
+    assertEquals("Bitmap for file:/some/file.jpg", shadowBitmap.getDescription());
+    assertEquals("/some/file.jpg", shadowBitmap.getCreatedFromPath());
+    assertEquals(100, bitmap.getWidth());
+    assertEquals(100, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeStream_shouldSetDescriptionAndCreatedFrom() throws Exception {
+    InputStream inputStream =
+        context.getContentResolver().openInputStream(Uri.parse("content:/path"));
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+    ShadowBitmap shadowBitmap = shadowOf(bitmap);
+    assertEquals("Bitmap for content:/path", shadowBitmap.getDescription());
+    assertEquals(inputStream, shadowBitmap.getCreatedFromStream());
+    assertEquals(100, bitmap.getWidth());
+    assertEquals(100, bitmap.getHeight());
+    bitmap.getPixels(
+        new int[bitmap.getHeight() * bitmap.getWidth()],
+        0,
+        0,
+        0,
+        0,
+        bitmap.getWidth(),
+        bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeBytes_shouldSetDescriptionAndCreatedFrom() throws Exception {
+    byte[] yummyBites = "Hi!".getBytes("UTF-8");
+    Bitmap bitmap = BitmapFactory.decodeByteArray(yummyBites, 100, 100);
+    ShadowBitmap shadowBitmap = shadowOf(bitmap);
+    assertEquals("Bitmap for 3 bytes 100..100", shadowBitmap.getDescription());
+    assertEquals(yummyBites, shadowBitmap.getCreatedFromBytes());
+    assertEquals(100, bitmap.getWidth());
+    assertEquals(100, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeBytes_shouldSetDescriptionAndCreatedFromWithOptions() throws Exception {
+    byte[] yummyBites = "Hi!".getBytes("UTF-8");
+    BitmapFactory.Options options = new BitmapFactory.Options();
+    Bitmap bitmap = BitmapFactory.decodeByteArray(yummyBites, 100, 100, options);
+    ShadowBitmap shadowBitmap = shadowOf(bitmap);
+    assertEquals("Bitmap for 3 bytes 100..100", shadowBitmap.getDescription());
+    assertEquals(yummyBites, shadowBitmap.getCreatedFromBytes());
+    assertEquals(100, bitmap.getWidth());
+    assertEquals(100, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeStream_shouldSetDescriptionWithNullOptions() throws Exception {
+    InputStream inputStream =
+        context.getContentResolver().openInputStream(Uri.parse("content:/path"));
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, null);
+    assertEquals("Bitmap for content:/path", shadowOf(bitmap).getDescription());
+    assertEquals(100, bitmap.getWidth());
+    assertEquals(100, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeResource_shouldGetWidthAndHeightFromHints() {
+    ShadowBitmapFactory.provideWidthAndHeightHints(R.drawable.an_image, 123, 456);
+
+    Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.an_image);
+    assertEquals(
+        "Bitmap for resource:org.robolectric:drawable/an_image", shadowOf(bitmap).getDescription());
+    assertEquals(123, bitmap.getWidth());
+    assertEquals(456, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeResource_canTakeOptions() {
+    BitmapFactory.Options options = new BitmapFactory.Options();
+    options.inSampleSize = 100;
+    Bitmap bitmap =
+        BitmapFactory.decodeResource(context.getResources(), R.drawable.an_image, options);
+    assertEquals(true, shadowOf(bitmap).getDescription().contains("inSampleSize=100"));
+  }
+
+  @Test
+  public void decodeResourceStream_canTakeOptions() throws Exception {
+    BitmapFactory.Options options = new BitmapFactory.Options();
+    InputStream inputStream =
+        context.getContentResolver().openInputStream(Uri.parse("content:/path"));
+    options.inSampleSize = 100;
+    Bitmap bitmap =
+        BitmapFactory.decodeResourceStream(
+            context.getResources(), null, inputStream, null, options);
+    assertEquals(true, shadowOf(bitmap).getDescription().contains("inSampleSize=100"));
+  }
+
+  @Test
+  public void decodeResourceStream_shouldGetCorrectColorFromPngImage() {
+    assertEquals(Color.BLACK, getPngImageColorFromResourceStream("res/drawable/pure_black.png"));
+    assertEquals(Color.BLUE, getPngImageColorFromResourceStream("res/drawable/pure_blue.png"));
+    assertEquals(Color.GREEN, getPngImageColorFromResourceStream("res/drawable/pure_green.png"));
+    assertEquals(Color.RED, getPngImageColorFromResourceStream("res/drawable/pure_red.png"));
+    assertEquals(Color.WHITE, getPngImageColorFromResourceStream("res/drawable/pure_white.png"));
+  }
+
+  @Test
+  public void decodeFile_shouldGetWidthAndHeightFromHints() {
+    ShadowBitmapFactory.provideWidthAndHeightHints("/some/file.jpg", 123, 456);
+
+    Bitmap bitmap = BitmapFactory.decodeFile("/some/file.jpg");
+    assertEquals("Bitmap for file:/some/file.jpg", shadowOf(bitmap).getDescription());
+    assertEquals(123, bitmap.getWidth());
+    assertEquals(456, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeFileEtc_shouldSetOptionsOutWidthAndOutHeightFromHints() {
+    ShadowBitmapFactory.provideWidthAndHeightHints("/some/file.jpg", 123, 456);
+
+    BitmapFactory.Options options = new BitmapFactory.Options();
+    BitmapFactory.decodeFile("/some/file.jpg", options);
+    assertEquals(123, options.outWidth);
+    assertEquals(456, options.outHeight);
+  }
+
+  @Test
+  public void decodeUri_shouldGetWidthAndHeightFromHints() throws Exception {
+    ShadowBitmapFactory.provideWidthAndHeightHints(Uri.parse("content:/path"), 123, 456);
+
+    Bitmap bitmap =
+        MediaStore.Images.Media.getBitmap(context.getContentResolver(), Uri.parse("content:/path"));
+    assertEquals("Bitmap for content:/path", shadowOf(bitmap).getDescription());
+    assertEquals(123, bitmap.getWidth());
+    assertEquals(456, bitmap.getHeight());
+  }
+
+  @SuppressWarnings("ObjectToString")
+  @Test
+  public void decodeFileDescriptor_shouldGetWidthAndHeightFromHints() throws Exception {
+    File tmpFile = File.createTempFile("BitmapFactoryTest", null);
+    try {
+      tmpFile.deleteOnExit();
+      try (FileInputStream is = new FileInputStream(tmpFile)) {
+        FileDescriptor fd = is.getFD();
+        ShadowBitmapFactory.provideWidthAndHeightHints(fd, 123, 456);
+
+        Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fd);
+        assertEquals("Bitmap for fd:" + fd, shadowOf(bitmap).getDescription());
+        assertEquals(123, bitmap.getWidth());
+        assertEquals(456, bitmap.getHeight());
+      }
+    } finally {
+      tmpFile.delete();
+    }
+  }
+
+  @Test
+  public void decodeByteArray_shouldGetWidthAndHeightFromHints() {
+    String data = "arbitrary bytes";
+    ShadowBitmapFactory.provideWidthAndHeightHints(Uri.parse(data), 123, 456);
+
+    byte[] bytes = data.getBytes(UTF_8);
+    Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+    assertEquals("Bitmap for " + bytes.length + " bytes", shadowOf(bitmap).getDescription());
+    assertEquals(123, bitmap.getWidth());
+    assertEquals(456, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeByteArray_shouldIncludeOffsets() {
+    String data = "arbitrary bytes";
+    ShadowBitmapFactory.provideWidthAndHeightHints(Uri.parse(data), 123, 456);
+
+    byte[] bytes = data.getBytes(UTF_8);
+    Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 1, bytes.length - 2);
+    assertEquals("Bitmap for " + bytes.length + " bytes 1..13", shadowOf(bitmap).getDescription());
+  }
+
+  @Test
+  public void decodeByteArray_shouldGetCorrectColorFromPngImage() {
+    assertEquals(Color.BLACK, getPngImageColorFromByteArray("res/drawable/pure_black.png"));
+    assertEquals(Color.BLUE, getPngImageColorFromByteArray("res/drawable/pure_blue.png"));
+    assertEquals(Color.GREEN, getPngImageColorFromByteArray("res/drawable/pure_green.png"));
+    assertEquals(Color.RED, getPngImageColorFromByteArray("res/drawable/pure_red.png"));
+    assertEquals(Color.WHITE, getPngImageColorFromByteArray("res/drawable/pure_white.png"));
+  }
+
+  @Test
+  public void decodeStream_shouldGetWidthAndHeightFromHints() throws Exception {
+    ShadowBitmapFactory.provideWidthAndHeightHints(Uri.parse("content:/path"), 123, 456);
+
+    InputStream inputStream =
+        context.getContentResolver().openInputStream(Uri.parse("content:/path"));
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+    assertEquals("Bitmap for content:/path", shadowOf(bitmap).getDescription());
+    assertEquals(123, bitmap.getWidth());
+    assertEquals(456, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeStream_shouldGetWidthAndHeightFromActualImage() {
+    InputStream inputStream =
+        getClass().getClassLoader().getResourceAsStream("res/drawable/fourth_image.jpg");
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+    assertEquals("Bitmap", shadowOf(bitmap).getDescription());
+    assertEquals(160, bitmap.getWidth());
+    assertEquals(107, bitmap.getHeight());
+  }
+
+  @Test
+  public void decodeStream_shouldGetCorrectMimeTypeFromJpegImage() throws Exception {
+    InputStream inputStream =
+        new BufferedInputStream(
+            getClass().getClassLoader().getResourceAsStream("res/drawable/fourth_image.jpg"));
+    inputStream.mark(inputStream.available());
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    BitmapFactory.decodeStream(inputStream, /* outPadding= */ null, opts);
+    assertEquals("image/jpeg", opts.outMimeType);
+  }
+
+  @Test
+  public void decodeStream_shouldGetCorrectMimeTypeFromPngImage() throws Exception {
+    InputStream inputStream =
+        new BufferedInputStream(
+            getClass().getClassLoader().getResourceAsStream("res/drawable/an_image.png"));
+    inputStream.mark(inputStream.available());
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    BitmapFactory.decodeStream(inputStream, /* outPadding= */ null, opts);
+    assertEquals("image/png", opts.outMimeType);
+  }
+
+  @Test
+  public void decodeStream_shouldGetCorrectMimeTypeFromGifImage() throws Exception {
+    InputStream inputStream =
+        new BufferedInputStream(
+            getClass().getClassLoader().getResourceAsStream("res/drawable/an_other_image.gif"));
+    inputStream.mark(inputStream.available());
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    BitmapFactory.decodeStream(inputStream, /* outPadding= */ null, opts);
+    assertEquals("image/gif", opts.outMimeType);
+  }
+
+  @Test
+  public void decodeStream_shouldGetCorrectColorFromPngImage() throws Exception {
+    assertEquals(Color.BLACK, getPngImageColorFromStream("res/drawable/pure_black.png"));
+    assertEquals(Color.BLUE, getPngImageColorFromStream("res/drawable/pure_blue.png"));
+    assertEquals(Color.GREEN, getPngImageColorFromStream("res/drawable/pure_green.png"));
+    assertEquals(Color.RED, getPngImageColorFromStream("res/drawable/pure_red.png"));
+    assertEquals(Color.WHITE, getPngImageColorFromStream("res/drawable/pure_white.png"));
+  }
+
+  @Test
+  public void decodeStream_shouldSameAsCompressedBefore() {
+    Bitmap bitmap = Bitmap.createBitmap(/* width= */ 10, /* height= */ 10, Bitmap.Config.ARGB_8888);
+    ByteArrayOutputStream outStream = new ByteArrayOutputStream();
+    bitmap.compress(Bitmap.CompressFormat.PNG, /* quality= */ 100, outStream);
+    byte[] outBytes = outStream.toByteArray();
+    ByteArrayInputStream inStream = new ByteArrayInputStream(outBytes);
+    Bitmap newBitmap = BitmapFactory.decodeStream(inStream);
+    assertThat(bitmap.sameAs(newBitmap)).isTrue();
+  }
+
+  @Test
+  public void decodeWithDifferentSampleSize() {
+    String name = "test";
+    BitmapFactory.Options options = new BitmapFactory.Options();
+
+    options.inSampleSize = 0;
+    Bitmap bm = ShadowBitmapFactory.create(name, options, null);
+    assertThat(bm.getWidth()).isEqualTo(100);
+    assertThat(bm.getHeight()).isEqualTo(100);
+
+    options.inSampleSize = 2;
+    bm = ShadowBitmapFactory.create(name, options, null);
+    assertThat(bm.getWidth()).isEqualTo(50);
+    assertThat(bm.getHeight()).isEqualTo(50);
+
+    options.inSampleSize = 101;
+    bm = ShadowBitmapFactory.create(name, options, null);
+    assertThat(bm.getWidth()).isEqualTo(1);
+    assertThat(bm.getHeight()).isEqualTo(1);
+  }
+
+  @Test
+  public void decodeFile_shouldGetCorrectColorFromPngImage() throws IOException {
+    int color = Color.RED;
+    File file = getBitmapFileFromResourceStream("res/drawable/pure_red.png");
+    Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
+    assertEquals(color, bitmap.getPixel(0, 0));
+    bitmap.recycle();
+  }
+
+  @Test
+  public void decodeFile_shouldHaveCorrectWidthAndHeight() throws IOException {
+    Bitmap bitmap = getBitmapFromResourceStream("res/drawable/test_jpeg.jpg");
+    assertThat(bitmap.getWidth()).isEqualTo(TEST_JPEG_WIDTH);
+    assertThat(bitmap.getHeight()).isEqualTo(TEST_JPEG_HEIGHT);
+    File tmpFile = File.createTempFile("ShadowBitmapFactoryTest", ".jpg");
+    tmpFile.deleteOnExit();
+    try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile)) {
+      bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fileOutputStream);
+    }
+    bitmap.recycle();
+    Bitmap loadedBitmap = BitmapFactory.decodeFile(tmpFile.getAbsolutePath());
+    assertThat(loadedBitmap.getWidth()).isEqualTo(TEST_JPEG_WIDTH);
+    assertThat(loadedBitmap.getHeight()).isEqualTo(TEST_JPEG_HEIGHT);
+    loadedBitmap.recycle();
+  }
+
+  @Test
+  public void decodeFile_shouldGetCorrectColorFromCompressedPngFile() throws IOException {
+    decodeFile_shouldGetCorrectColorFromCompressedFile(
+        Bitmap.CompressFormat.PNG,
+        getBitmapByteArrayFromResourceStream("res/drawable/an_image.png"));
+  }
+
+  @Test
+  public void decodeFile_shouldGetCorrectColorFromCompressedWebpFile() throws IOException {
+    decodeFile_shouldGetCorrectColorFromCompressedFile(
+        Bitmap.CompressFormat.WEBP,
+        getBitmapByteArrayFromResourceStream("res/drawable/test_webp.webp"));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.R)
+  public void decodeFile_shouldGetCorrectColorFromCompressedWebpLossyFile() throws IOException {
+    decodeFile_shouldGetCorrectColorFromCompressedFile(
+        Bitmap.CompressFormat.WEBP_LOSSY,
+        getBitmapByteArrayFromResourceStream("res/drawable/test_webp_lossy.webp"));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.R)
+  public void decodeFile_shouldGetCorrectColorFromCompressedWebpLosslessFile() throws IOException {
+    decodeFile_shouldGetCorrectColorFromCompressedFile(
+        Bitmap.CompressFormat.WEBP_LOSSLESS,
+        getBitmapByteArrayFromResourceStream("res/drawable/test_webp_lossless.webp"));
+  }
+
+  @Test
+  public void decodeFileDescriptor_shouldHaveCorrectWidthAndHeight() throws IOException {
+    Bitmap bitmap = getBitmapFromResourceStream("res/drawable/test_jpeg.jpg");
+    assertThat(bitmap.getWidth()).isEqualTo(TEST_JPEG_WIDTH);
+    assertThat(bitmap.getHeight()).isEqualTo(TEST_JPEG_HEIGHT);
+
+    File tmpFile = File.createTempFile("ShadowBitmapFactoryTest", ".jpg");
+    tmpFile.deleteOnExit();
+    try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile)) {
+      bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fileOutputStream);
+    }
+    bitmap.recycle();
+    try (FileInputStream fileInputStream = new FileInputStream(tmpFile)) {
+      Bitmap loadedBitmap = BitmapFactory.decodeFileDescriptor(fileInputStream.getFD());
+      assertThat(loadedBitmap.getWidth()).isEqualTo(TEST_JPEG_WIDTH);
+      assertThat(loadedBitmap.getHeight()).isEqualTo(TEST_JPEG_HEIGHT);
+      loadedBitmap.recycle();
+    }
+  }
+
+  @Test
+  public void decodeFileDescriptor_shouldGetCorrectColorFromPngImage() throws IOException {
+    assertEquals(Color.BLACK, getPngImageColorFromFileDescriptor("res/drawable/pure_black.png"));
+    assertEquals(Color.BLUE, getPngImageColorFromFileDescriptor("res/drawable/pure_blue.png"));
+    assertEquals(Color.GREEN, getPngImageColorFromFileDescriptor("res/drawable/pure_green.png"));
+    assertEquals(Color.RED, getPngImageColorFromFileDescriptor("res/drawable/pure_red.png"));
+    assertEquals(Color.WHITE, getPngImageColorFromFileDescriptor("res/drawable/pure_white.png"));
+  }
+
+  /**
+   * When methods such as {@link BitmapFactory#decodeStream(InputStream, android.graphics.Rect,
+   * android.graphics.BitmapFactory.Options)} are called with invalid Bitmap data, the return value
+   * should be null, and {@link BitmapFactory.Options#outWidth} and {@link
+   * BitmapFactory.Options#outHeight} should be set to -1.
+   */
+  @Test
+  public void decodeStream_options_setsOutWidthToMinusOne() {
+    ShadowBitmapFactory.setAllowInvalidImageData(false);
+    byte[] invalidBitmapPixels = "invalid bitmap pixels".getBytes(UTF_8);
+    ByteArrayInputStream inputStream = new ByteArrayInputStream(invalidBitmapPixels);
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    Bitmap result = BitmapFactory.decodeStream(inputStream, null, opts);
+    assertThat(result).isEqualTo(null);
+    assertThat(opts.outWidth).isEqualTo(-1);
+    assertThat(opts.outHeight).isEqualTo(-1);
+  }
+
+  private void decodeFile_shouldGetCorrectColorFromCompressedFile(
+      Bitmap.CompressFormat format, byte[] bitmapData) throws IOException {
+    Bitmap oldBitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length);
+    Path tempFile = Files.createTempFile("bitmap", null);
+    FileOutputStream fileOutputStream = new FileOutputStream(tempFile.toFile());
+    // lossless compression
+    oldBitmap.compress(format, 100, fileOutputStream);
+    fileOutputStream.close();
+    Bitmap newBitmap = BitmapFactory.decodeFile(tempFile.toAbsolutePath().toString());
+
+    ByteBuffer oldBuffer = ByteBuffer.allocate(oldBitmap.getHeight() * oldBitmap.getRowBytes());
+    oldBitmap.copyPixelsToBuffer(oldBuffer);
+
+    ByteBuffer newBuffer = ByteBuffer.allocate(newBitmap.getHeight() * newBitmap.getRowBytes());
+    newBitmap.copyPixelsToBuffer(newBuffer);
+    assertThat(oldBuffer.array()).isEqualTo(newBuffer.array());
+  }
+
+  private int getPngImageColorFromStream(String pngImagePath) throws IOException {
+    InputStream inputStream =
+        new BufferedInputStream(getClass().getClassLoader().getResourceAsStream(pngImagePath));
+    inputStream.mark(inputStream.available());
+    BitmapFactory.Options opts = new BitmapFactory.Options();
+    Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, opts);
+    return bitmap.getPixel(0, 0);
+  }
+
+  private int getPngImageColorFromFileDescriptor(String pngImagePath) throws IOException {
+    File file = getBitmapFileFromResourceStream(pngImagePath);
+    FileInputStream fileInputStream = new FileInputStream(file);
+    Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fileInputStream.getFD());
+    int color = bitmap.getPixel(0, 0);
+    bitmap.recycle();
+    return color;
+  }
+
+  private File getBitmapFileFromResourceStream(String imagePath) throws IOException {
+    InputStream inputStream = com.google.common.io.Resources.getResource(imagePath).openStream();
+    File tempFile = Files.createTempFile("ShadowBitmapFactoryTest", null).toFile();
+    tempFile.deleteOnExit();
+    ByteStreams.copy(inputStream, new FileOutputStream(tempFile));
+    return tempFile;
+  }
+
+  private int getPngImageColorFromByteArray(String pngImagePath) {
+    try (InputStream inputStream =
+        new BufferedInputStream(getClass().getClassLoader().getResourceAsStream(pngImagePath))) {
+      inputStream.mark(inputStream.available());
+      byte[] array = new byte[inputStream.available()];
+      inputStream.read(array);
+      Bitmap bitmap = BitmapFactory.decodeByteArray(array, 0, array.length);
+      return bitmap.getPixel(0, 0);
+    } catch (IOException e) {
+      return Integer.MIN_VALUE;
+    }
+  }
+
+  private int getPngImageColorFromResourceStream(String pngImagePath) {
+    Bitmap bitmap = getBitmapFromResourceStream(pngImagePath);
+    return bitmap == null ? Integer.MIN_VALUE : bitmap.getPixel(0, 0);
+  }
+
+  private Bitmap getBitmapFromResourceStream(String imagePath) {
+    try (InputStream inputStream =
+        new BufferedInputStream(getClass().getClassLoader().getResourceAsStream(imagePath))) {
+      inputStream.mark(inputStream.available());
+      BitmapFactory.Options opts = new BitmapFactory.Options();
+      Resources resources = context.getResources();
+      return BitmapFactory.decodeResourceStream(resources, null, inputStream, null, opts);
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  private byte[] getBitmapByteArrayFromResourceStream(String imagePath) throws IOException {
+    return toByteArray(com.google.common.io.Resources.getResource(imagePath));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapRegionDecoderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapRegionDecoderTest.java
new file mode 100644
index 0000000..172cf5e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapRegionDecoderTest.java
@@ -0,0 +1,97 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.res.AssetFileDescriptor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Rect;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.ByteStreams;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import javax.imageio.ImageIO;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(qualifiers = "hdpi")
+public class ShadowBitmapRegionDecoderTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void testNewInstance() throws Exception {
+    assertThat(BitmapRegionDecoder.newInstance(ByteStreams.toByteArray(getImageInputStream()), 0, 0, false))
+        .isNotNull();
+    try (AssetFileDescriptor afd = getImageFd()) {
+      assertThat(BitmapRegionDecoder.newInstance(afd.getFileDescriptor(), false)).isNotNull();
+    }
+    try (InputStream inputStream = getImageInputStream()) {
+      assertThat(BitmapRegionDecoder.newInstance(inputStream, false)).isNotNull();
+    }
+    assertThat(BitmapRegionDecoder.newInstance(getGeneratedImageFile(), false))
+        .isNotNull();
+  }
+
+  @Test
+  public void getWidthAndGetHeight_shouldReturnCorrectValuesForImage() throws Exception {
+    BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(getImageInputStream(), true);
+    assertThat(decoder.getWidth()).isEqualTo(297);
+    assertThat(decoder.getHeight()).isEqualTo(251);
+  }
+
+  @Test
+  public void testDecodeRegionReturnsExpectedSize() throws IOException {
+    BitmapRegionDecoder bitmapRegionDecoder = BitmapRegionDecoder.newInstance(getImageInputStream(), false);
+    Bitmap bitmap = bitmapRegionDecoder.decodeRegion(new Rect(10, 20, 110, 220), new BitmapFactory.Options());
+    assertThat(bitmap.getWidth())
+        .isEqualTo(100);
+    assertThat(bitmap.getHeight())
+        .isEqualTo(200);
+  }
+
+  @Test
+  public void testDecodeRegionReturnsExpectedConfig() throws IOException {
+    try (InputStream inputStream = getImageInputStream()) {
+      BitmapRegionDecoder bitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream, false);
+      BitmapFactory.Options options = new BitmapFactory.Options();
+      assertThat(bitmapRegionDecoder.decodeRegion(new Rect(0, 0, 1, 1), options).getConfig())
+          .isEqualTo(Bitmap.Config.ARGB_8888);
+      options.inPreferredConfig = null;
+      assertThat(bitmapRegionDecoder.decodeRegion(new Rect(0, 0, 1, 1), options).getConfig())
+          .isEqualTo(Bitmap.Config.ARGB_8888);
+      options.inPreferredConfig = Bitmap.Config.RGB_565;
+      assertThat(bitmapRegionDecoder.decodeRegion(new Rect(0, 0, 1, 1), options).getConfig())
+          .isEqualTo(Bitmap.Config.RGB_565);
+    }
+  }
+
+  private static InputStream getImageInputStream() {
+    return ApplicationProvider.getApplicationContext()
+        .getResources()
+        .openRawResource(R.drawable.robolectric);
+  }
+
+  private static AssetFileDescriptor getImageFd() throws Exception {
+    return ApplicationProvider.getApplicationContext()
+        .getResources()
+        .getAssets()
+        .openFd("robolectric.png");
+  }
+
+  private String getGeneratedImageFile() throws Exception {
+    BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
+    File tempImage = temporaryFolder.newFile();
+    ImageIO.write(img, "png", tempImage);
+    return tempImage.getPath();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapTest.java
new file mode 100644
index 0000000..2e5be13
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapTest.java
@@ -0,0 +1,836 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorMatrix;
+import android.graphics.ColorMatrixColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Parcel;
+import android.util.DisplayMetrics;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.IntBuffer;
+import java.nio.LongBuffer;
+import java.nio.ShortBuffer;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBitmapTest {
+  @Test
+  public void shouldCreateScaledBitmap() {
+    Bitmap originalBitmap = create("Original bitmap");
+    Bitmap scaledBitmap = Bitmap.createScaledBitmap(originalBitmap, 100, 200, false);
+    assertThat(shadowOf(scaledBitmap).getDescription())
+        .isEqualTo("Original bitmap scaled to 100 x 200");
+    assertThat(scaledBitmap.getWidth()).isEqualTo(100);
+    assertThat(scaledBitmap.getHeight()).isEqualTo(200);
+    scaledBitmap.getPixels(new int[20000], 0, 0, 0, 0, 100, 200);
+  }
+
+  @Test
+  public void createScaledBitmap_succeedForLargeBitmapWithFilter() {
+    createScaledBitmap_succeedForLargeBitmap(true);
+  }
+
+  @Test
+  public void createScaledBitmap_succeedForLargeBitmapWithoutFilter() {
+    createScaledBitmap_succeedForLargeBitmap(false);
+  }
+
+  @Test
+  public void createScaledBitmap_modifiesPixelsWithFilter() {
+    createScaledBitmap_modifiesPixels(true);
+  }
+
+  @Test
+  public void createScaledBitmap_modifiesPixelsWithoutFilter() {
+    createScaledBitmap_modifiesPixels(false);
+  }
+
+  @Test
+  public void createScaledBitmap_expectedUpSizeWithFilter() {
+    createScaledBitmap_expectedUpSize(true);
+  }
+
+  @Test
+  public void createScaledBitmap_expectedUpSizeWithoutFilter() {
+    createScaledBitmap_expectedUpSize(false);
+  }
+
+  @Test
+  public void createScaledBitmap_expectedDownSizeWithFilter() {
+    createScaledBitmap_expectedDownSize(true);
+  }
+
+  @Test
+  public void createScaledBitmap_expectedDownSizeWithoutFilter() {
+    createScaledBitmap_expectedDownSize(false);
+  }
+
+  @Test
+  public void createScaledBitmap_drawOnScaledWithFilter() {
+    createScaledBitmap_drawOnScaled(true);
+  }
+
+  @Test
+  public void createScaledBitmap_drawOnScaledWithoutFilter() {
+    createScaledBitmap_drawOnScaled(false);
+  }
+
+  @Test
+  public void shouldCreateActiveBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.isRecycled()).isFalse();
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(0);
+    assertThat(bitmap.getWidth()).isEqualTo(100);
+    assertThat(bitmap.getHeight()).isEqualTo(200);
+    assertThat(bitmap.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888);
+  }
+
+  @Test
+  public void hasAlpha() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.hasAlpha()).isTrue();
+    bitmap.setHasAlpha(false);
+    assertThat(bitmap.hasAlpha()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void hasMipmap() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.hasMipMap()).isFalse();
+    bitmap.setHasMipMap(true);
+    assertThat(bitmap.hasMipMap()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void getAllocationByteCount() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.getAllocationByteCount()).isGreaterThan(0);
+  }
+
+  @Test
+  public void shouldCreateBitmapWithColors() {
+    int[] colors = new int[] {
+        Color.parseColor("#ff0000"), Color.parseColor("#00ff00"), Color.parseColor("#0000ff"),
+        Color.parseColor("#990000"), Color.parseColor("#009900"), Color.parseColor("#000099")
+    };
+    Bitmap bitmap = Bitmap.createBitmap(colors, 3, 2, Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.getWidth()).isEqualTo(3);
+    assertThat(bitmap.getHeight()).isEqualTo(2);
+    assertThat(bitmap.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(Color.parseColor("#ff0000"));
+    assertThat(bitmap.getPixel(0, 1)).isEqualTo(Color.parseColor("#990000"));
+    assertThat(bitmap.getPixel(1, 0)).isEqualTo(Color.parseColor("#00ff00"));
+    assertThat(bitmap.getPixel(1, 1)).isEqualTo(Color.parseColor("#009900"));
+    assertThat(bitmap.getPixel(2, 0)).isEqualTo(Color.parseColor("#0000ff"));
+    assertThat(bitmap.getPixel(2, 1)).isEqualTo(Color.parseColor("#000099"));
+  }
+
+  @Test
+  public void shouldCreateBitmapWithMatrix() {
+    Bitmap originalBitmap = create("Original bitmap");
+    shadowOf(originalBitmap).setWidth(200);
+    shadowOf(originalBitmap).setHeight(200);
+    Matrix m = new Matrix();
+    m.postRotate(90);
+    Bitmap newBitmap = Bitmap.createBitmap(originalBitmap, 0, 0, 100, 50, m, true);
+
+    ShadowBitmap shadowBitmap = shadowOf(newBitmap);
+    assertThat(shadowBitmap.getDescription())
+        .isEqualTo(
+            "Original bitmap at (0,0) with width 100 and height 50"
+                + " using matrix Matrix[pre=[], set={}, post=[rotate 90.0]] with filter");
+    assertThat(shadowBitmap.getCreatedFromBitmap()).isEqualTo(originalBitmap);
+    assertThat(shadowBitmap.getCreatedFromX()).isEqualTo(0);
+    assertThat(shadowBitmap.getCreatedFromY()).isEqualTo(0);
+    assertThat(shadowBitmap.getCreatedFromWidth()).isEqualTo(100);
+    assertThat(shadowBitmap.getCreatedFromHeight()).isEqualTo(50);
+    assertThat(shadowBitmap.getCreatedFromMatrix()).isEqualTo(m);
+    assertThat(shadowBitmap.getCreatedFromFilter()).isEqualTo(true);
+    assertThat(shadowBitmap.getWidth()).isEqualTo(50);
+    assertThat(shadowBitmap.getHeight()).isEqualTo(100);
+  }
+
+  @Test
+  public void shouldCreateMutableBitmap() {
+    Bitmap mutableBitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888);
+    assertThat(mutableBitmap.isMutable()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldCreateMutableBitmapWithDisplayMetrics() {
+    final DisplayMetrics metrics = new DisplayMetrics();
+    metrics.densityDpi = 1000;
+
+    final Bitmap bitmap = Bitmap.createBitmap(metrics, 100, 100, Bitmap.Config.ARGB_8888);
+    assertThat(bitmap.isMutable()).isTrue();
+    assertThat(bitmap.getDensity()).isEqualTo(1000);
+  }
+
+  @Test
+  public void shouldRecycleBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 200, Bitmap.Config.ARGB_8888);
+    bitmap.recycle();
+    assertThat(bitmap.isRecycled()).isTrue();
+  }
+
+  @Test
+  public void shouldReceiveDescriptionWhenDrawingToCanvas() {
+    Bitmap bitmap1 = create("Bitmap One");
+    Bitmap bitmap2 = create("Bitmap Two");
+
+    Canvas canvas = new Canvas(bitmap1);
+    canvas.drawBitmap(bitmap2, 0, 0, null);
+
+    assertThat(shadowOf(bitmap1).getDescription()).isEqualTo("Bitmap One\nBitmap Two");
+  }
+
+  @Test
+  public void shouldReceiveDescriptionWhenDrawingToCanvasWithBitmapAndMatrixAndPaint() {
+    Bitmap bitmap1 = create("Bitmap One");
+    Bitmap bitmap2 = create("Bitmap Two");
+
+    Canvas canvas = new Canvas(bitmap1);
+    canvas.drawBitmap(bitmap2, new Matrix(), null);
+
+    assertThat(shadowOf(bitmap1).getDescription())
+        .isEqualTo("Bitmap One\nBitmap Two transformed by Matrix[pre=[], set={}, post=[]]");
+  }
+
+  @Test
+  public void shouldReceiveDescriptionWhenDrawABitmapToCanvasWithAPaintEffect() {
+    Bitmap bitmap1 = create("Bitmap One");
+    Bitmap bitmap2 = create("Bitmap Two");
+
+    Canvas canvas = new Canvas(bitmap1);
+    Paint paint = new Paint();
+    paint.setColorFilter(new ColorMatrixColorFilter(new ColorMatrix()));
+    canvas.drawBitmap(bitmap2, new Matrix(), paint);
+
+    assertThat(shadowOf(bitmap1).getDescription())
+        .isEqualTo(
+            "Bitmap One\n"
+                + "Bitmap Two with ColorMatrixColorFilter"
+                + " transformed by Matrix[pre=[], set={}, post=[]]");
+  }
+
+  @Test
+  public void visualize_shouldReturnDescription() {
+    Bitmap bitmap = create("Bitmap One");
+    assertThat(ShadowBitmap.visualize(bitmap))
+        .isEqualTo("Bitmap One");
+  }
+
+  @Test
+  public void shouldCopyBitmap() {
+    Bitmap bitmap = Shadow.newInstanceOf(Bitmap.class);
+    Bitmap bitmapCopy = bitmap.copy(Bitmap.Config.ARGB_8888, true);
+    assertThat(shadowOf(bitmapCopy).getConfig()).isEqualTo(Bitmap.Config.ARGB_8888);
+    assertThat(shadowOf(bitmapCopy).isMutable()).isTrue();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void rowBytesIsAccurate() {
+    Bitmap b1 = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    assertThat(b1.getRowBytes()).isEqualTo(40);
+    Bitmap b2 = Bitmap.createBitmap(10, 10, Bitmap.Config.RGB_565);
+    assertThat(b2.getRowBytes()).isEqualTo(20);
+
+    // Null Bitmap.Config is not allowed.
+    Bitmap b3 = Bitmap.createBitmap(10, 10, null);
+    b3.getRowBytes();
+  }
+
+  @Test(expected = NullPointerException.class)
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void byteCountIsAccurate() {
+    Bitmap b1 = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    assertThat(b1.getByteCount()).isEqualTo(400);
+    Bitmap b2 = Bitmap.createBitmap(10, 10, Bitmap.Config.RGB_565);
+    assertThat(b2.getByteCount()).isEqualTo(200);
+
+    // Null Bitmap.Config is not allowed.
+    Bitmap b3 = Bitmap.createBitmap(10, 10, null);
+    b3.getByteCount();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldSetDensity() {
+    final Bitmap bitmap = Bitmap.createBitmap(new DisplayMetrics(), 100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.setDensity(1000);
+    assertThat(bitmap.getDensity()).isEqualTo(1000);
+  }
+
+  @Test
+  public void shouldSetPixel() {
+    Bitmap bitmap = Bitmap.createBitmap(new int[] { 1 }, 1, 1, Bitmap.Config.ARGB_8888);
+    shadowOf(bitmap).setMutable(true);
+    bitmap.setPixel(0, 0, 2);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(2);
+    assertThat(shadowOf(bitmap).getCreatedFromColors()).isEqualTo(new int[] { 1 });
+  }
+
+  @Test
+  public void shouldSetPixel_allocateOnTheFly() {
+    Bitmap bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+    shadowOf(bitmap).setMutable(true);
+    bitmap.setPixel(0, 0, 2);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(2);
+    assertThat(shadowOf(bitmap).getCreatedFromColors()).isNull();
+  }
+
+  @Test
+  public void testGetPixels() {
+    // Create a dummy bitmap.
+    Bitmap bmp = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    for (int y = 0; y < bmp.getHeight(); ++y) {
+      for (int x = 0; x < bmp.getWidth(); ++x) {
+        bmp.setPixel(x, y, packRGB(x, y, 0));
+      }
+    }
+
+    // Use getPixels to get pixels as an array (getPixels was the missing Shadowed Function).
+    int[] pixels = new int[bmp.getWidth() * bmp.getHeight()];
+    bmp.getPixels(pixels, 0, bmp.getWidth(), 0, 0, bmp.getWidth(), bmp.getHeight());
+
+    // Every entry should match regardless of accessing it by getPixel vs getPixels.
+    for (int y = 0; y < bmp.getHeight(); ++y) {
+      for (int x = 0; x < bmp.getWidth(); ++x) {
+        assertThat(bmp.getPixel(x, y)).isEqualTo(pixels[y * bmp.getWidth() + x]);
+      }
+    }
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void shouldThrowExceptionForSetPixelOnImmutableBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(new int[] {1}, 1, 1, Bitmap.Config.ARGB_8888);
+    bitmap.setPixel(0, 0, 2);
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void shouldThrowExceptionForSetPixelsOnImmutableBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(new int[] {1}, 1, 1, Bitmap.Config.ARGB_8888);
+    bitmap.setPixels(new int[] {1}, 0, 0, 0, 0, 1, 1);
+  }
+
+  @Test
+  public void bitmapsAreReused() {
+    Bitmap b = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    Bitmap b1 = Bitmap.createBitmap(b, 0, 0, 10, 10);
+    assertThat(b1).isSameInstanceAs(b);
+    Bitmap b2 = Bitmap.createBitmap(b, 0, 0, 10, 10, null, false);
+    assertThat(b2).isSameInstanceAs(b);
+    Bitmap b3 = Bitmap.createScaledBitmap(b, 10, 10, false);
+    assertThat(b3).isSameInstanceAs(b);
+  }
+
+  @Test
+  public void equalsSizeTransformReturnsOriginal() {
+    Bitmap b1 = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    Bitmap b2 = Bitmap.createBitmap(b1, 0, 0, 10, 10, null, false);
+    assertThat(b1).isSameInstanceAs(b2);
+    Bitmap b3 = Bitmap.createBitmap(b1, 0, 0, 10, 10, null, true);
+    assertThat(b1).isSameInstanceAs(b3);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void throwsExceptionForInvalidDimensions() {
+    Bitmap b = Bitmap.createBitmap(10, 20, Bitmap.Config.ARGB_8888);
+    Bitmap.createBitmap(b, 0, 0, 20, 10, null, false);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void throwsExceptionForNegativeWidth() {
+    Bitmap.createBitmap(-100, 10, Bitmap.Config.ARGB_8888);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void throwsExceptionForZeroHeight() {
+    Bitmap.createBitmap(100, 0, Bitmap.Config.ARGB_8888);
+  }
+
+  @Test
+  public void shouldGetPixelsFromAnyNonNullableCreatedBitmap() {
+    Bitmap bitmap;
+    int width = 10;
+    int height = 10;
+
+    int[] pixels = new int[width * height];
+    bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+
+    bitmap = Bitmap.createBitmap(bitmap);
+    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+
+    bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height);
+    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+
+    bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, new Matrix(), false);
+    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+
+    bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+
+    bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888);
+    bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
+  }
+
+  @Test
+  public void shouldGetPixelsFromSubsetOfBitmap() {
+    int width = 10;
+    int height = 10;
+    Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+    int offset = 7;
+    int subWidth = 3;
+    int subHeight = 4;
+    int x = 2;
+    int y = 5;
+
+    // Fill a region of the bitmap with increasing redness.
+    int r = 0;
+    for (int y0 = y; y0 < y + subHeight; y0++) {
+      for (int x0 = x; x0 < x + subWidth; x0++) {
+        bitmap.setPixel(x0, y0, packRGB(r++, 0, 0));
+      }
+    }
+
+    // Get the pixels from that region.
+    int[] pixels = new int[offset + subWidth * subHeight];
+    bitmap.getPixels(pixels, offset, subWidth, x, y, subWidth, subHeight);
+
+    // Verify that pixels contains the expected colors.
+    r = 0;
+    int index = offset;
+    for (int y0 = 0; y0 < subHeight; y0++) {
+      for (int x0 = 0; x0 < subWidth; x0++) {
+        assertThat(pixels[index++]).isEqualTo(packRGB(r++, 0, 0));
+      }
+    }
+  }
+
+  @Test
+  public void shouldAdjustDimensionsForMatrix() {
+    Bitmap transformedBitmap;
+    int width = 10;
+    int height = 20;
+
+    Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+    Matrix matrix = new Matrix();
+    transformedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
+    assertThat(transformedBitmap.getWidth())
+        .isEqualTo(width);
+    assertThat(transformedBitmap.getHeight())
+        .isEqualTo(height);
+
+    matrix.setRotate(90);
+    transformedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
+    assertThat(transformedBitmap.getWidth())
+        .isEqualTo(height);
+    assertThat(transformedBitmap.getHeight())
+        .isEqualTo(width);
+
+    matrix.setScale(2, 3);
+    transformedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
+    assertThat(transformedBitmap.getWidth())
+        .isEqualTo(width * 2);
+    assertThat(transformedBitmap.getHeight())
+        .isEqualTo(height * 3);
+  }
+
+  @Test
+  public void shouldWriteToParcelAndReconstruct() {
+    Bitmap bitmapOriginal;
+    int originalWidth = 10;
+    int originalHeight = 10;
+
+    bitmapOriginal = Bitmap.createBitmap(originalWidth, originalHeight, Bitmap.Config.ARGB_8888);
+
+    Parcel parcel = Parcel.obtain();
+    bitmapOriginal.writeToParcel(parcel, 0);
+
+    parcel.setDataPosition(0);
+
+    Bitmap bitmapReconstructed = Bitmap.CREATOR.createFromParcel(parcel);
+
+    // get reconstructed properties
+    int reconstructedHeight = bitmapReconstructed.getHeight();
+    int reconstructedWidth = bitmapReconstructed.getWidth();
+
+    //compare bitmap properties
+    assertThat(originalHeight).isEqualTo(reconstructedHeight);
+    assertThat(originalWidth).isEqualTo(reconstructedWidth);
+    assertThat(bitmapOriginal.getConfig()).isEqualTo(bitmapReconstructed.getConfig());
+
+    int[] pixelsOriginal = new int[originalWidth * originalHeight];
+    bitmapOriginal.getPixels(pixelsOriginal, 0, originalWidth, 0, 0, originalWidth, originalHeight);
+
+    int[] pixelsReconstructed = new int[reconstructedWidth * reconstructedHeight];
+    bitmapReconstructed.getPixels(pixelsReconstructed, 0, reconstructedWidth, 0, 0,
+        reconstructedWidth, reconstructedHeight);
+
+    assertThat(Arrays.equals(pixelsOriginal, pixelsReconstructed)).isTrue();
+  }
+
+  @Test
+  public void shouldCopyPixelsToBufferAndReconstruct() {
+    int width = 10;
+    int height = 10;
+
+    Bitmap bitmapOriginal = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+    bitmapOriginal.setPixel(0, 0, 123);
+    bitmapOriginal.setPixel(1, 1, 456);
+    bitmapOriginal.setPixel(2, 2, 789);
+    int[] pixelsOriginal = new int[width * height];
+    bitmapOriginal.getPixels(pixelsOriginal, 0, width, 0, 0, width, height);
+
+    ByteBuffer buffer = ByteBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsToBuffer(buffer);
+    assertThat(buffer.position()).isEqualTo(bitmapOriginal.getByteCount());
+
+    buffer.rewind();
+    Bitmap bitmapReconstructed = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+    // Set some random pixels to ensure that they're properly overwritten.
+    bitmapReconstructed.setPixel(1, 1, 999);
+    bitmapReconstructed.setPixel(4, 4, 999);
+    bitmapReconstructed.copyPixelsFromBuffer(buffer);
+    assertThat(buffer.position()).isEqualTo(bitmapOriginal.getByteCount());
+
+    assertThat(bitmapReconstructed.getPixel(0, 0)).isEqualTo(123);
+    assertThat(bitmapReconstructed.getPixel(1, 1)).isEqualTo(456);
+    assertThat(bitmapReconstructed.getPixel(2, 2)).isEqualTo(789);
+
+    int[] pixelsReconstructed = new int[width * height];
+    bitmapReconstructed.getPixels(pixelsReconstructed, 0, width, 0, 0, width, height);
+    assertThat(Arrays.equals(pixelsOriginal, pixelsReconstructed)).isTrue();
+  }
+
+  @Test
+  public void compress_shouldLessThanBeforeForWebp() {
+    Bitmap bitmap = Bitmap.createBitmap(400, 200, Bitmap.Config.ARGB_8888);
+    ByteArrayOutputStream stream = new ByteArrayOutputStream();
+    bitmap.compress(Bitmap.CompressFormat.WEBP, 75, stream);
+    byte[] compressedImageByteArray = stream.toByteArray();
+    assertThat(compressedImageByteArray.length).isLessThan(bitmap.getByteCount());
+  }
+
+  @Test
+  public void compress_shouldSucceedForNullPixelData() {
+    Bitmap bitmap = Shadow.newInstanceOf(Bitmap.class);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.setWidth(100);
+    shadowBitmap.setHeight(100);
+    ByteArrayOutputStream stream = new ByteArrayOutputStream();
+    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
+  }
+
+  @Config(sdk = O)
+  @Test
+  public void getBytesPerPixel_O() {
+    assertThat(ShadowBitmap.getBytesPerPixel(Bitmap.Config.RGBA_F16)).isEqualTo(8);
+  }
+
+  @Test
+  public void getBytesPerPixel_preO() {
+    assertThat(ShadowBitmap.getBytesPerPixel(Bitmap.Config.ARGB_8888)).isEqualTo(4);
+    assertThat(ShadowBitmap.getBytesPerPixel(Bitmap.Config.RGB_565)).isEqualTo(2);
+    assertThat(ShadowBitmap.getBytesPerPixel(Bitmap.Config.ARGB_4444)).isEqualTo(2);
+    assertThat(ShadowBitmap.getBytesPerPixel(Bitmap.Config.ALPHA_8)).isEqualTo(1);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsToShortBuffer() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    ShortBuffer buffer = ShortBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsToBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsToLongBuffer() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    LongBuffer buffer = LongBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsToBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsToBufferTooSmall() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    ByteBuffer buffer = ByteBuffer.allocate(bitmapOriginal.getByteCount() - 1);
+    bitmapOriginal.copyPixelsToBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsToBufferNonArgb8888() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_4444);
+    ByteBuffer buffer = ByteBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsToBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsFromShortBuffer() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    ShortBuffer buffer = ShortBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsFromBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsFromIntBufferTooSmall() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    IntBuffer buffer =
+        IntBuffer.allocate(bitmapOriginal.getWidth() * bitmapOriginal.getHeight() - 1);
+    bitmapOriginal.copyPixelsFromBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsFromLongBuffer() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    LongBuffer buffer = LongBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsFromBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsFromBufferTooSmall() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    ByteBuffer buffer = ByteBuffer.allocate(bitmapOriginal.getByteCount() - 1);
+    bitmapOriginal.copyPixelsFromBuffer(buffer);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void throwsExceptionCopyPixelsFromBufferNonArgb8888() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_4444);
+    ByteBuffer buffer = ByteBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.copyPixelsFromBuffer(buffer);
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void throwsExceptionCopyPixelsFromBufferRecycled() {
+    Bitmap bitmapOriginal = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    ByteBuffer buffer = ByteBuffer.allocate(bitmapOriginal.getByteCount());
+    bitmapOriginal.recycle();
+    bitmapOriginal.copyPixelsFromBuffer(buffer);
+  }
+
+  @Config(sdk = Build.VERSION_CODES.KITKAT)
+  @Test
+  public void reconfigure_withArgb8888Bitmap_validDimensionsAndConfig_doesNotThrow() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    original.reconfigure(100, 100, Bitmap.Config.ARGB_8888);
+  }
+
+  @Config(sdk = O)
+  @Test(expected = IllegalStateException.class)
+  public void reconfigure_withHardwareBitmap_validDimensionsAndConfig_throws() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    ShadowBitmap shadowBitmap = Shadow.extract(original);
+    shadowBitmap.setConfig(Bitmap.Config.HARDWARE);
+
+    original.reconfigure(100, 100, Bitmap.Config.ARGB_8888);
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  @Test
+  public void isPremultiplied_argb888_defaultsTrue() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+
+    assertThat(original.isPremultiplied()).isTrue();
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  @Test
+  public void isPremultiplied_argb888_noAlpha_defaultsFalse() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    original.setHasAlpha(false);
+
+    assertThat(original.isPremultiplied()).isFalse();
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  @Test
+  public void isPremultiplied_rgb565_defaultsFalse() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.RGB_565);
+
+    assertThat(original.isPremultiplied()).isFalse();
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  @Test
+  public void setPremultiplied_argb888_isFalse() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    original.setPremultiplied(false);
+
+    assertThat(original.isPremultiplied()).isFalse();
+  }
+
+
+  @Test
+  public void sameAs_bitmapsDifferentWidth() {
+    Bitmap original1 = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Bitmap original2 = Bitmap.createBitmap(101, 100, Bitmap.Config.ARGB_8888);
+    assertThat(original1.sameAs(original2)).isFalse();
+  }
+
+  @Test
+  public void sameAs_bitmapsDifferentHeight() {
+    Bitmap original1 = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Bitmap original2 = Bitmap.createBitmap(100, 101, Bitmap.Config.ARGB_8888);
+    assertThat(original1.sameAs(original2)).isFalse();
+  }
+
+  @Test
+  public void sameAs_bitmapsDifferentConfig() {
+    Bitmap original1 = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Bitmap original2 = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_4444);
+    assertThat(original1.sameAs(original2)).isFalse();
+  }
+
+  @Test
+  public void sameAs_bitmapsDifferentPixels() {
+    int[] pixels1 = new int[] {0, 1, 2, 3};
+    Bitmap original1 = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888);
+    original1.setPixels(pixels1, 0, 1, 0, 0, 2, 2);
+
+    int[] pixels2 = new int[] {3, 2, 1, 0};
+    Bitmap original2 = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888);
+    original2.setPixels(pixels2, 0, 1, 0, 0, 2, 2);
+    assertThat(original1.sameAs(original2)).isFalse();
+  }
+
+  @Test
+  public void sameAs_bitmapsSamePixels() {
+    int[] pixels = new int[] {0, 1, 2, 3};
+    Bitmap original1 = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888);
+    original1.setPixels(pixels, 0, 1, 0, 0, 2, 2);
+
+    Bitmap original2 = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888);
+    original2.setPixels(pixels, 0, 1, 0, 0, 2, 2);
+    assertThat(original1.sameAs(original2)).isTrue();
+  }
+
+  @Test
+  public void extractAlpha() {
+    int[] pixels = new int[] {0xFF123456, 0x00123456, 0x88999999, 0x12345678};
+    Bitmap bitmap = Bitmap.createBitmap(/* width= */ 2, /* height= */ 2, Bitmap.Config.ARGB_8888);
+    bitmap.setPixels(
+        pixels,
+        /* offset= */ 0,
+        /* stride= */ 2,
+        /* x= */ 0,
+        /* y= */ 0,
+        /* width= */ 2,
+        /* height= */ 2);
+
+    Bitmap alpha = bitmap.extractAlpha();
+
+    assertThat(alpha.getPixel(0, 0)).isEqualTo(0xFF000000);
+    assertThat(alpha.getPixel(1, 0)).isEqualTo(0x00000000);
+    assertThat(alpha.getPixel(0, 1)).isEqualTo(0x88000000);
+    assertThat(alpha.getPixel(1, 1)).isEqualTo(0x12000000);
+  }
+
+  @Test
+  public void extractAlpha_withArgs() {
+    int[] pixels = new int[] {0xFF123456, 0x00123456, 0x88999999, 0x12345678};
+    Bitmap bitmap = Bitmap.createBitmap(/* width= */ 2, /* height= */ 2, Bitmap.Config.ARGB_8888);
+    bitmap.setPixels(
+        pixels,
+        /* offset= */ 0,
+        /* stride= */ 2,
+        /* x= */ 0,
+        /* y= */ 0,
+        /* width= */ 2,
+        /* height= */ 2);
+
+    Bitmap alpha = bitmap.extractAlpha(/* paint= */ null, /* offsetXY= */ new int[2]);
+
+    assertThat(alpha.getPixel(0, 0)).isEqualTo(0xFF000000);
+    assertThat(alpha.getPixel(1, 0)).isEqualTo(0x00000000);
+    assertThat(alpha.getPixel(0, 1)).isEqualTo(0x88000000);
+    assertThat(alpha.getPixel(1, 1)).isEqualTo(0x12000000);
+  }
+
+  @Test
+  public void eraseColor_clearsDescription() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Bitmap scaled = Bitmap.createScaledBitmap(original, 200, 200, false);
+    scaled.eraseColor(Color.TRANSPARENT);
+    String description = Shadows.shadowOf(scaled).getDescription();
+    assertThat(description).isEqualTo("Bitmap (200, 200)");
+    scaled.eraseColor(Color.BLUE);
+    description = Shadows.shadowOf(scaled).getDescription();
+    assertThat(description).isEqualTo("Bitmap (200, 200) erased with 0xff0000ff");
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void asShared_shouldReturnImmutableInstance() {
+    Bitmap original = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+
+    assertThat(original.asShared().isMutable()).isFalse();
+  }
+
+  private static Bitmap create(String name) {
+    Bitmap bitmap = Shadow.newInstanceOf(Bitmap.class);
+    shadowOf(bitmap).appendDescription(name);
+    return bitmap;
+  }
+
+  private static int packRGB(int r, int g, int b) {
+    return 0xff000000 | r << 16 | g << 8 | b;
+  }
+
+  private void createScaledBitmap_succeedForLargeBitmap(boolean filter) {
+    Bitmap bitmap = Bitmap.createBitmap(100000, 10, Bitmap.Config.ARGB_8888);
+    Bitmap.createScaledBitmap(bitmap, 480000, 48, filter);
+  }
+
+  private void createScaledBitmap_modifiesPixels(boolean filter) {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(Color.BLUE);
+    Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, 50, 50, filter);
+    assertThat(scaledBitmap.getPixel(0, 0)).isEqualTo(Color.BLUE);
+  }
+
+  private void createScaledBitmap_expectedUpSize(boolean filter) {
+    Bitmap bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, 32, 32, filter);
+    assertThat(Shadows.shadowOf(scaledBitmap).getBufferedImage().getWidth()).isEqualTo(32);
+    assertThat(Shadows.shadowOf(scaledBitmap).getBufferedImage().getHeight()).isEqualTo(32);
+  }
+
+  private void createScaledBitmap_expectedDownSize(boolean filter) {
+    Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
+    Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, 10, 10, filter);
+    assertThat(Shadows.shadowOf(scaledBitmap).getBufferedImage().getWidth()).isEqualTo(10);
+    assertThat(Shadows.shadowOf(scaledBitmap).getBufferedImage().getHeight()).isEqualTo(10);
+  }
+
+  private void createScaledBitmap_drawOnScaled(boolean filter) {
+    Bitmap original = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    Bitmap scaled = Bitmap.createScaledBitmap(original, 32, 32, filter);
+    Canvas canvas = new Canvas(scaled);
+    Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
+    p.setColor(Color.BLACK);
+    canvas.drawRect(new Rect(0, 0, 32, 32), p);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothA2dpTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothA2dpTest.java
new file mode 100644
index 0000000..c599599
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothA2dpTest.java
@@ -0,0 +1,171 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Intent;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBluetoothA2dpTest {
+  private BluetoothDevice connectedBluetoothDevice;
+  private BluetoothDevice disConnectedBluetoothDevice;
+  private BluetoothA2dp bluetoothA2dp;
+  private ShadowBluetoothA2dp shadowBluetoothA2dp;
+  private Application applicationContext;
+
+  @Before
+  public void setUp() throws Exception {
+    BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+    connectedBluetoothDevice = bluetoothAdapter.getRemoteDevice("00:11:22:33:AA:BB");
+    disConnectedBluetoothDevice = bluetoothAdapter.getRemoteDevice("11:22:33:AA:BB:00");
+
+    bluetoothA2dp = Shadow.newInstanceOf(BluetoothA2dp.class);
+    shadowBluetoothA2dp = Shadow.extract(bluetoothA2dp);
+    applicationContext = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void getConnectedDevices_bluetoothConnected_reflectsAddDevice() {
+    assertThat(bluetoothA2dp.getConnectedDevices()).isEmpty();
+
+    shadowBluetoothA2dp.addDevice(connectedBluetoothDevice, BluetoothProfile.STATE_CONNECTED);
+    assertThat(bluetoothA2dp.getConnectedDevices()).containsExactly(connectedBluetoothDevice);
+  }
+
+  @Test
+  public void getConnectedDevices_bluetoothConnected_reflectsRemoveDevice() {
+    assertThat(bluetoothA2dp.getConnectedDevices()).isEmpty();
+    shadowBluetoothA2dp.addDevice(connectedBluetoothDevice, BluetoothProfile.STATE_CONNECTED);
+    assertThat(bluetoothA2dp.getConnectedDevices()).isNotEmpty();
+
+    shadowBluetoothA2dp.removeDevice(connectedBluetoothDevice);
+    assertThat(bluetoothA2dp.getConnectedDevices()).doesNotContain(connectedBluetoothDevice);
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_connectedState_returnsOnlyConnectedDevices() {
+    shadowBluetoothA2dp.addDevice(connectedBluetoothDevice, BluetoothProfile.STATE_CONNECTED);
+    shadowBluetoothA2dp.addDevice(disConnectedBluetoothDevice, BluetoothProfile.STATE_DISCONNECTED);
+
+    assertThat(
+            bluetoothA2dp.getDevicesMatchingConnectionStates(
+                new int[] {BluetoothProfile.STATE_CONNECTED}))
+        .containsExactly(connectedBluetoothDevice);
+  }
+
+  @Test
+  public void
+      getDevicesMatchingConnectionStates_disConnectedState_returnsOnlyDisconnectedDevices() {
+    shadowBluetoothA2dp.addDevice(connectedBluetoothDevice, BluetoothProfile.STATE_CONNECTED);
+    shadowBluetoothA2dp.addDevice(disConnectedBluetoothDevice, BluetoothProfile.STATE_DISCONNECTED);
+
+    assertThat(
+            bluetoothA2dp.getDevicesMatchingConnectionStates(
+                new int[] {BluetoothProfile.STATE_DISCONNECTED}))
+        .containsExactly(disConnectedBluetoothDevice);
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_reflectsRemoveDevice() {
+    shadowBluetoothA2dp.addDevice(connectedBluetoothDevice, BluetoothProfile.STATE_CONNECTED);
+    shadowBluetoothA2dp.removeDevice(connectedBluetoothDevice);
+
+    assertThat(
+            bluetoothA2dp.getDevicesMatchingConnectionStates(
+                new int[] {BluetoothProfile.STATE_CONNECTED, BluetoothProfile.STATE_DISCONNECTED}))
+        .doesNotContain(connectedBluetoothDevice);
+  }
+
+  @Test
+  public void getConnectionState_deviceFound_returnsDeviceConnectionState() {
+    shadowBluetoothA2dp.addDevice(connectedBluetoothDevice, BluetoothProfile.STATE_CONNECTED);
+
+    assertThat(bluetoothA2dp.getConnectionState(connectedBluetoothDevice))
+        .isEqualTo(BluetoothProfile.STATE_CONNECTED);
+  }
+
+  @Test
+  public void getConnectionState_deviceNotFound_returnsDisconnectedState() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance("11:22:33:AA:BB:00");
+
+    assertThat(bluetoothA2dp.getConnectionState(bluetoothDevice))
+        .isEqualTo(BluetoothProfile.STATE_DISCONNECTED);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getDynamicBufferSupport_defaultIsNone() {
+    assertThat(bluetoothA2dp.getDynamicBufferSupport())
+        .isEqualTo(BluetoothA2dp.DYNAMIC_BUFFER_SUPPORT_NONE);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getDynamicBufferSupport_returnValueFromSetter() {
+    shadowBluetoothA2dp.setDynamicBufferSupport(BluetoothA2dp.DYNAMIC_BUFFER_SUPPORT_A2DP_OFFLOAD);
+
+    assertThat(bluetoothA2dp.getDynamicBufferSupport())
+        .isEqualTo(BluetoothA2dp.DYNAMIC_BUFFER_SUPPORT_A2DP_OFFLOAD);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getBufferLengthMillisArray_defaultIsZero() {
+    for (int i = 0; i < 6; i++) {
+      assertThat(shadowBluetoothA2dp.getBufferLengthMillis(i)).isEqualTo(0);
+    }
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getBufferLengthMillisArray_returnValueFromSetter() {
+    assertThat(bluetoothA2dp.setBufferLengthMillis(0, 123)).isTrue();
+
+    assertThat(shadowBluetoothA2dp.getBufferLengthMillis(0)).isEqualTo(123);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void setBufferLengthMillis_invalidValue_shouldReturnFalse() {
+    assertThat(bluetoothA2dp.setBufferLengthMillis(1, -1)).isFalse();
+
+    assertThat(shadowBluetoothA2dp.getBufferLengthMillis(1)).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setActiveDevice_setNull_shouldSaveNull() {
+    assertThat(bluetoothA2dp.setActiveDevice(null)).isTrue();
+
+    assertThat(bluetoothA2dp.getActiveDevice()).isNull();
+    Intent intent = shadowOf(applicationContext).getBroadcastIntents().get(0);
+    assertThat(intent.getAction()).isEqualTo(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED);
+    assertThat((BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getActiveDevice_returnValueFromSetter() {
+    assertThat(bluetoothA2dp.setActiveDevice(connectedBluetoothDevice)).isTrue();
+
+    assertThat(bluetoothA2dp.getActiveDevice()).isEqualTo(connectedBluetoothDevice);
+    Intent intent = shadowOf(applicationContext).getBroadcastIntents().get(0);
+    assertThat(intent.getAction()).isEqualTo(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED);
+    assertThat((BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
+        .isEqualTo(connectedBluetoothDevice);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothAdapterTest.java
new file mode 100644
index 0000000..69d7e7b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothAdapterTest.java
@@ -0,0 +1,745 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothSocket;
+import android.bluetooth.BluetoothStatusCodes;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.content.Intent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Unit tests for {@link ShadowBluetoothAdapter} */
+@RunWith(AndroidJUnit4.class)
+public class ShadowBluetoothAdapterTest {
+  private static final int MOCK_PROFILE1 = 17;
+  private static final int MOCK_PROFILE2 = 21;
+  private static final String MOCK_MAC_ADDRESS = "00:11:22:33:AA:BB";
+
+  private static final UUID UUID1 = UUID.fromString("3e9507d3-20c9-4b1a-a75d-30c795334389");
+  private static final UUID UUID2 = UUID.fromString("cdba7974-3e3f-476a-9119-0d1be6b0e548");
+  private static final UUID UUID3 = UUID.fromString("4524c169-531b-4f27-8097-fd9f19e0c788");
+  private static final UUID UUID4 = UUID.fromString("468c2e72-8d89-43e3-b153-940c8ddee1da");
+  private static final UUID UUID5 = UUID.fromString("19ad4589-af27-4ccc-8cfb-de1bf2212298");
+  private static final Intent testIntent = new Intent("com.test.action.DUMMY_ACTION");
+
+  private BluetoothAdapter bluetoothAdapter;
+
+  @Before
+  public void setUp() throws Exception {
+    bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+  }
+
+  @Test
+  public void testAdapterBluetoothSupported() {
+    assertThat(BluetoothAdapter.getDefaultAdapter()).isNotNull();
+
+    ShadowBluetoothAdapter.setIsBluetoothSupported(false);
+    assertThat(BluetoothAdapter.getDefaultAdapter()).isNull();
+
+    ShadowBluetoothAdapter.reset();
+    assertThat(BluetoothAdapter.getDefaultAdapter()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void testIsLeExtendedAdvertisingSupported() {
+    assertThat(bluetoothAdapter.isLeExtendedAdvertisingSupported()).isTrue();
+
+    shadowOf(bluetoothAdapter).setIsLeExtendedAdvertisingSupported(false);
+
+    assertThat(bluetoothAdapter.isLeExtendedAdvertisingSupported()).isFalse();
+  }
+
+  @Test
+  public void testAdapterDefaultsDisabled() {
+    assertThat(bluetoothAdapter.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void testAdapterCanBeEnabled_forTesting() {
+    shadowOf(bluetoothAdapter).setEnabled(true);
+    assertThat(bluetoothAdapter.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void canGetAndSetAddress() throws Exception {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+    shadowOf(adapter).setAddress("expected");
+    assertThat(adapter.getAddress()).isEqualTo("expected");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void canGetBluetoothLeScanner() {
+    if (RuntimeEnvironment.getApiLevel() < M) {
+      // On SDK < 23, bluetooth has to be in STATE_ON in order to get a BluetoothLeScanner.
+      shadowOf(bluetoothAdapter).setState(BluetoothAdapter.STATE_ON);
+    }
+    BluetoothLeScanner bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
+    assertThat(bluetoothLeScanner).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void canGetBluetoothLeAdvertiser() throws Exception {
+    // bluetooth needs to be ON in APIS 21 and 22 for getBluetoothLeAdvertiser to return a
+    // non null value
+    bluetoothAdapter.enable();
+    assertThat(bluetoothAdapter.getBluetoothLeAdvertiser()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void canGetAndSetBleScanAlwaysAvailable() throws Exception {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+    // By default, scanning with BT is not supported.
+    assertThat(adapter.isBleScanAlwaysAvailable()).isTrue();
+
+    // Flipping it on should update state accordingly.
+    shadowOf(adapter).setBleScanAlwaysAvailable(false);
+    assertThat(adapter.isBleScanAlwaysAvailable()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void canGetAndSetMultipleAdvertisementSupport() throws Exception {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+    // By default, multiple advertising is supported.
+    assertThat(adapter.isMultipleAdvertisementSupported()).isTrue();
+
+    // Flipping it off should update state accordingly.
+    shadowOf(adapter).setIsMultipleAdvertisementSupported(false);
+    assertThat(adapter.isMultipleAdvertisementSupported()).isFalse();
+  }
+
+  @Test
+  public void canEnable_withAndroidApi() throws Exception {
+    bluetoothAdapter.enable();
+    assertThat(bluetoothAdapter.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void getState_afterEnable() {
+    bluetoothAdapter.enable();
+    assertThat(bluetoothAdapter.getState()).isEqualTo(BluetoothAdapter.STATE_ON);
+    bluetoothAdapter.disable();
+    assertThat(bluetoothAdapter.getState()).isEqualTo(BluetoothAdapter.STATE_OFF);
+  }
+
+  @Test
+  public void isEnabled_afterSetState() {
+    assertThat(bluetoothAdapter.isEnabled()).isFalse();
+    shadowOf(bluetoothAdapter).setState(BluetoothAdapter.STATE_ON);
+    assertThat(bluetoothAdapter.isEnabled()).isTrue();
+    shadowOf(bluetoothAdapter).setState(BluetoothAdapter.STATE_DISCONNECTING);
+    assertThat(bluetoothAdapter.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void canDisable_withAndroidApi() throws Exception {
+    shadowOf(bluetoothAdapter).setEnabled(true);
+    bluetoothAdapter.disable();
+    assertThat(bluetoothAdapter.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void name_getAndSet() throws Exception {
+    // The name shouldn't be null, even before we set anything.
+    assertThat(bluetoothAdapter.getName()).isNotNull();
+
+    bluetoothAdapter.setName("Foo");
+    assertThat(bluetoothAdapter.getName()).isEqualTo("Foo");
+  }
+
+  @Test
+  @Config(maxSdk = S_V2)
+  public void scanMode_getAndSet_connectable() {
+    boolean result =
+        ReflectionHelpers.callInstanceMethod(
+            bluetoothAdapter,
+            "setScanMode",
+            ClassParameter.from(int.class, BluetoothAdapter.SCAN_MODE_CONNECTABLE));
+    assertThat(result).isTrue();
+    assertThat(bluetoothAdapter.getScanMode()).isEqualTo(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
+  }
+
+  @Test
+  @Config(maxSdk = S_V2)
+  public void scanMode_getAndSet_discoverable() {
+    boolean result =
+        ReflectionHelpers.callInstanceMethod(
+            bluetoothAdapter,
+            "setScanMode",
+            ClassParameter.from(int.class, BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE));
+    assertThat(result).isTrue();
+    assertThat(bluetoothAdapter.getScanMode())
+        .isEqualTo(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
+  }
+
+  @Test
+  @Config(maxSdk = S_V2)
+  public void scanMode_getAndSet_none() throws Exception {
+    boolean result =
+        ReflectionHelpers.callInstanceMethod(
+            bluetoothAdapter,
+            "setScanMode",
+            ClassParameter.from(int.class, BluetoothAdapter.SCAN_MODE_NONE));
+    assertThat(result).isTrue();
+    assertThat(bluetoothAdapter.getScanMode()).isEqualTo(BluetoothAdapter.SCAN_MODE_NONE);
+  }
+
+  @Test
+  @Config(maxSdk = S_V2)
+  public void scanMode_getAndSet_invalid() throws Exception {
+    boolean result =
+        ReflectionHelpers.callInstanceMethod(
+            bluetoothAdapter, "setScanMode", ClassParameter.from(int.class, 9999));
+    assertThat(result).isFalse();
+  }
+
+  @Config(maxSdk = Q)
+  @Test
+  public void scanMode_withDiscoverableTimeout() {
+    assertThat(
+            (boolean)
+                ReflectionHelpers.callInstanceMethod(
+                    bluetoothAdapter,
+                    "setScanMode",
+                    ClassParameter.from(
+                        int.class, BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE),
+                    ClassParameter.from(int.class, 42)))
+        .isTrue();
+    assertThat(bluetoothAdapter.getScanMode())
+        .isEqualTo(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
+    int result = ReflectionHelpers.callInstanceMethod(bluetoothAdapter, "getDiscoverableTimeout");
+    assertThat(result).isEqualTo(42);
+  }
+
+  @Config(minSdk = R, maxSdk = S_V2)
+  @Test
+  public void scanMode_withDiscoverableTimeout_R() {
+    assertThat(
+            (boolean)
+                ReflectionHelpers.callInstanceMethod(
+                    bluetoothAdapter,
+                    "setScanMode",
+                    ClassParameter.from(
+                        int.class, BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE),
+                    ClassParameter.from(long.class, 42_000L)))
+        .isTrue();
+    assertThat(bluetoothAdapter.getScanMode())
+        .isEqualTo(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
+    int result = ReflectionHelpers.callInstanceMethod(bluetoothAdapter, "getDiscoverableTimeout");
+    assertThat(result).isEqualTo(42);
+  }
+
+  @Test
+  @Config(maxSdk = S)
+  public void discoverableTimeout_getAndSet() {
+    ReflectionHelpers.callInstanceMethod(
+        bluetoothAdapter,
+        "setDiscoverableTimeout",
+        ClassParameter.from(int.class, 60 /* seconds */));
+    int result = ReflectionHelpers.callInstanceMethod(bluetoothAdapter, "getDiscoverableTimeout");
+    assertThat(result).isEqualTo(60);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void scanMode_getAndSet_connectable_T() throws Exception {
+    assertThat(bluetoothAdapter.setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE))
+        .isEqualTo(BluetoothStatusCodes.SUCCESS);
+    assertThat(bluetoothAdapter.getScanMode()).isEqualTo(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void scanMode_getAndSet_discoverable_T() throws Exception {
+    assertThat(bluetoothAdapter.setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE))
+        .isEqualTo(BluetoothStatusCodes.SUCCESS);
+    assertThat(bluetoothAdapter.getScanMode())
+        .isEqualTo(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void scanMode_getAndSet_none_T() throws Exception {
+    assertThat(bluetoothAdapter.setScanMode(BluetoothAdapter.SCAN_MODE_NONE))
+        .isEqualTo(BluetoothStatusCodes.SUCCESS);
+    assertThat(bluetoothAdapter.getScanMode()).isEqualTo(BluetoothAdapter.SCAN_MODE_NONE);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void scanMode_getAndSet_invalid_T() throws Exception {
+    assertThat(bluetoothAdapter.setScanMode(9999)).isEqualTo(BluetoothStatusCodes.ERROR_UNKNOWN);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isLeEnabled() throws Exception {
+    // Le is enabled when either BT or BLE is enabled. Check all states.
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+    // Both BT and BLE enabled.
+    adapter.enable();
+    shadowOf(adapter).setBleScanAlwaysAvailable(true);
+    assertThat(adapter.isLeEnabled()).isTrue();
+
+    // BT enabled, BLE disabled.
+    adapter.enable();
+    shadowOf(adapter).setBleScanAlwaysAvailable(false);
+    assertThat(adapter.isLeEnabled()).isTrue();
+
+    // BT disabled, BLE enabled.
+    adapter.disable();
+    shadowOf(adapter).setBleScanAlwaysAvailable(true);
+    assertThat(adapter.isLeEnabled()).isTrue();
+
+    // BT disabled, BLE disabled.
+    adapter.disable();
+    shadowOf(adapter).setBleScanAlwaysAvailable(false);
+    assertThat(adapter.isLeEnabled()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void testLeScan() {
+    BluetoothAdapter.LeScanCallback callback1 = newLeScanCallback();
+    BluetoothAdapter.LeScanCallback callback2 = newLeScanCallback();
+
+    bluetoothAdapter.startLeScan(callback1);
+    assertThat(shadowOf(bluetoothAdapter).getLeScanCallbacks()).containsExactly(callback1);
+    bluetoothAdapter.startLeScan(callback2);
+    assertThat(shadowOf(bluetoothAdapter).getLeScanCallbacks())
+        .containsExactly(callback1, callback2);
+
+    bluetoothAdapter.stopLeScan(callback1);
+    assertThat(shadowOf(bluetoothAdapter).getLeScanCallbacks()).containsExactly(callback2);
+    bluetoothAdapter.stopLeScan(callback2);
+    assertThat(shadowOf(bluetoothAdapter).getLeScanCallbacks()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void testGetSingleLeScanCallback() {
+    BluetoothAdapter.LeScanCallback callback1 = newLeScanCallback();
+    BluetoothAdapter.LeScanCallback callback2 = newLeScanCallback();
+
+    bluetoothAdapter.startLeScan(callback1);
+    assertThat(shadowOf(bluetoothAdapter).getSingleLeScanCallback()).isEqualTo(callback1);
+
+    bluetoothAdapter.startLeScan(callback2);
+    IllegalStateException expected =
+        assertThrows(
+            IllegalStateException.class,
+            () -> shadowOf(bluetoothAdapter).getSingleLeScanCallback());
+    assertThat(expected).hasMessageThat().isEqualTo("There are 2 callbacks");
+  }
+
+  /**
+   * Verifies that the state of any specific remote device is global. Although in robolectric this
+   * is accomplished by caching the same instance, in android, multiple unique instances
+   * nevertheless return the same state so long as they point to the same address.
+   */
+  @Test
+  public void testGetRemoteDevice_sameState() {
+    BluetoothDevice remoteDevice1 = bluetoothAdapter.getRemoteDevice(MOCK_MAC_ADDRESS);
+    BluetoothDevice remoteDevice2 = bluetoothAdapter.getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(remoteDevice2.getBondState()).isEqualTo(BluetoothDevice.BOND_NONE);
+    shadowOf(remoteDevice1).setBondState(BluetoothDevice.BOND_BONDED);
+    assertThat(remoteDevice2.getBondState()).isEqualTo(BluetoothDevice.BOND_BONDED);
+  }
+
+  @Test
+  public void insecureRfcomm_notNull() throws Exception {
+    assertThat(
+            bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
+                "serviceName", UUID.randomUUID()))
+        .isNotNull();
+  }
+
+  @Test
+  public void secureRfcomm_notNull() throws Exception {
+    assertThat(
+            bluetoothAdapter.listenUsingRfcommWithServiceRecord(
+                    "serviceName", UUID.randomUUID()))
+            .isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void insecureL2capChannel_notNull() throws Exception {
+    assertThat(bluetoothAdapter.listenUsingInsecureL2capChannel()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void l2capChannel_notNull() throws Exception {
+    assertThat(bluetoothAdapter.listenUsingL2capChannel()).isNotNull();
+  }
+
+  @Test
+  public void canGetProfileConnectionState() throws Exception {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+    assertThat(adapter.getProfileConnectionState(BluetoothProfile.HEADSET))
+        .isEqualTo(BluetoothProfile.STATE_DISCONNECTED);
+    shadowOf(adapter)
+        .setProfileConnectionState(BluetoothProfile.HEADSET, BluetoothProfile.STATE_CONNECTED);
+    assertThat(adapter.getProfileConnectionState(BluetoothProfile.HEADSET))
+        .isEqualTo(BluetoothProfile.STATE_CONNECTED);
+    assertThat(adapter.getProfileConnectionState(BluetoothProfile.A2DP))
+        .isEqualTo(BluetoothProfile.STATE_DISCONNECTED);
+  }
+
+  @Test
+  public void getProfileProxy_afterSetProfileProxy_callsServiceListener() {
+    BluetoothProfile mockProxy = mock(BluetoothProfile.class);
+    BluetoothProfile.ServiceListener mockServiceListener =
+        mock(BluetoothProfile.ServiceListener.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, mockProxy);
+
+    boolean result =
+        bluetoothAdapter.getProfileProxy(
+            RuntimeEnvironment.getApplication(), mockServiceListener, MOCK_PROFILE1);
+
+    assertThat(result).isTrue();
+    verify(mockServiceListener).onServiceConnected(MOCK_PROFILE1, mockProxy);
+  }
+
+  @Test
+  public void getProfileProxy_afterSetProfileProxyWithNullArgument_doesNotCallServiceListener() {
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, null);
+    BluetoothProfile.ServiceListener mockServiceListener =
+        mock(BluetoothProfile.ServiceListener.class);
+
+    boolean result =
+        bluetoothAdapter.getProfileProxy(
+            RuntimeEnvironment.getApplication(), mockServiceListener, MOCK_PROFILE1);
+
+    assertThat(result).isFalse();
+    verifyNoMoreInteractions(mockServiceListener);
+  }
+
+  @Test
+  public void getProfileProxy_afterSetProfileProxy_forMultipleProfiles() {
+    BluetoothProfile mockProxy1 = mock(BluetoothProfile.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, mockProxy1);
+    BluetoothProfile mockProxy2 = mock(BluetoothProfile.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE2, mockProxy2);
+    BluetoothProfile.ServiceListener mockServiceListener =
+        mock(BluetoothProfile.ServiceListener.class);
+
+    boolean result1 =
+        bluetoothAdapter.getProfileProxy(
+            RuntimeEnvironment.getApplication(), mockServiceListener, MOCK_PROFILE1);
+    boolean result2 =
+        bluetoothAdapter.getProfileProxy(
+            RuntimeEnvironment.getApplication(), mockServiceListener, MOCK_PROFILE2);
+
+    assertThat(result1).isTrue();
+    assertThat(result2).isTrue();
+    verify(mockServiceListener).onServiceConnected(MOCK_PROFILE1, mockProxy1);
+    verify(mockServiceListener).onServiceConnected(MOCK_PROFILE2, mockProxy2);
+  }
+
+  @Test
+  public void hasActiveProfileProxy_reflectsSetProfileProxy() {
+    BluetoothProfile mockProxy = mock(BluetoothProfile.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, mockProxy);
+
+    assertThat(shadowOf(bluetoothAdapter).hasActiveProfileProxy(MOCK_PROFILE1)).isTrue();
+    assertThat(shadowOf(bluetoothAdapter).hasActiveProfileProxy(MOCK_PROFILE2)).isFalse();
+  }
+
+  @Test
+  public void hasActiveProfileProxy_afterSetProfileProxyWithNullArgument_returnsFalse() {
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, null);
+
+    assertThat(shadowOf(bluetoothAdapter).hasActiveProfileProxy(MOCK_PROFILE1)).isFalse();
+  }
+
+  @Test
+  public void closeProfileProxy_reversesSetProfileProxy() {
+    BluetoothProfile mockProxy = mock(BluetoothProfile.class);
+    BluetoothProfile.ServiceListener mockServiceListener =
+        mock(BluetoothProfile.ServiceListener.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, mockProxy);
+
+    bluetoothAdapter.closeProfileProxy(MOCK_PROFILE1, mockProxy);
+    boolean result =
+        bluetoothAdapter.getProfileProxy(
+            RuntimeEnvironment.getApplication(), mockServiceListener, MOCK_PROFILE1);
+
+    assertThat(result).isFalse();
+    verifyNoMoreInteractions(mockServiceListener);
+    assertThat(shadowOf(bluetoothAdapter).hasActiveProfileProxy(MOCK_PROFILE1)).isFalse();
+  }
+
+  @Test
+  public void closeProfileProxy_afterSetProfileProxy_mismatchedProxy_noOp() {
+    BluetoothProfile mockProxy1 = mock(BluetoothProfile.class);
+    BluetoothProfile mockProxy2 = mock(BluetoothProfile.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, mockProxy1);
+
+    bluetoothAdapter.closeProfileProxy(MOCK_PROFILE1, mockProxy2);
+
+    assertThat(shadowOf(bluetoothAdapter).hasActiveProfileProxy(MOCK_PROFILE1)).isTrue();
+  }
+
+  @Test
+  public void closeProfileProxy_afterSetProfileProxyWithNullArgument_noOp() {
+    BluetoothProfile mockProxy = mock(BluetoothProfile.class);
+    shadowOf(bluetoothAdapter).setProfileProxy(MOCK_PROFILE1, null);
+
+    bluetoothAdapter.closeProfileProxy(MOCK_PROFILE1, mockProxy);
+
+    assertThat(shadowOf(bluetoothAdapter).hasActiveProfileProxy(MOCK_PROFILE1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getLeMaximumAdvertisingDataLength_nonZero() {
+    assertThat(bluetoothAdapter.getLeMaximumAdvertisingDataLength()).isEqualTo(1650);
+
+    shadowOf(bluetoothAdapter).setIsLeExtendedAdvertisingSupported(false);
+
+    assertThat(bluetoothAdapter.getLeMaximumAdvertisingDataLength()).isEqualTo(31);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void startRfcommServer_mutablePendingIntent_throwsIllegalArgumentException() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            bluetoothAdapter.startRfcommServer(
+                "testname",
+                UUID.randomUUID(),
+                PendingIntent.getBroadcast(
+                    getApplicationContext(),
+                    /* requestCode= */ 0,
+                    new Intent("com.dummy.action.DUMMY_ACTION"),
+                    /* flags= */ PendingIntent.FLAG_MUTABLE)));
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void startRfcommServer_newUuid_success() {
+    PendingIntent rfcommServerIntent =
+        PendingIntent.getBroadcast(
+            getApplicationContext(),
+            /* requestCode= */ 0,
+            new Intent("com.dummy.action.DUMMY_ACTION"),
+            /* flags= */ PendingIntent.FLAG_IMMUTABLE);
+
+    assertThat(
+            bluetoothAdapter.startRfcommServer("testname", UUID.randomUUID(), rfcommServerIntent))
+        .isEqualTo(BluetoothStatusCodes.SUCCESS);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void startRfcommServer_existingUuid_fails() {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+
+    assertThat(bluetoothAdapter.startRfcommServer("newtestname", UUID1, rfcommServerIntent))
+        .isEqualTo(ShadowBluetoothAdapter.RFCOMM_LISTENER_START_FAILED_UUID_IN_USE);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void stopRfcommServer_existingUuid_succeeds() {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+
+    assertThat(bluetoothAdapter.stopRfcommServer(UUID1)).isEqualTo(BluetoothStatusCodes.SUCCESS);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void stopRfcommServer_noExistingUuid_fails() {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+
+    assertThat(bluetoothAdapter.stopRfcommServer(UUID2))
+        .isEqualTo(
+            ShadowBluetoothAdapter.RFCOMM_LISTENER_OPERATION_FAILED_NO_MATCHING_SERVICE_RECORD);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void retrieveConnectedRfcommSocket_noPendingSocket_returnsNull() {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+
+    assertThat(bluetoothAdapter.retrieveConnectedRfcommSocket(UUID1)).isNull();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void retrieveConnectedRfcommSocket_pendingSocket_returnsSocket() throws Exception {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+    ((ShadowBluetoothAdapter) Shadow.extract(bluetoothAdapter))
+        .addIncomingRfcommConnection(bluetoothAdapter.getRemoteDevice("AB:CD:EF:12:34:56"), UUID1);
+
+    assertThat(bluetoothAdapter.retrieveConnectedRfcommSocket(UUID1)).isNotNull();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void retrieveConnectedRfcommSocket_pendingSocket_wrongUuid_returnsNull() throws Exception {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+    bluetoothAdapter.startRfcommServer("othertestname", UUID2, rfcommServerIntent);
+    ((ShadowBluetoothAdapter) Shadow.extract(bluetoothAdapter))
+        .addIncomingRfcommConnection(bluetoothAdapter.getRemoteDevice("AB:CD:EF:12:34:56"), UUID1);
+
+    assertThat(bluetoothAdapter.retrieveConnectedRfcommSocket(UUID2)).isNull();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void addIncomingRfcommConnection_pendingIntentFired() throws Exception {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+    ((ShadowBluetoothAdapter) Shadow.extract(bluetoothAdapter))
+        .addIncomingRfcommConnection(bluetoothAdapter.getRemoteDevice("AB:CD:EF:12:34:56"), UUID1);
+
+    assertThat(shadowOf((Application) getApplicationContext()).getBroadcastIntents())
+        .contains(testIntent);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void addIncomingRfcommConnection_socketRetrieved_canCommunicate() throws Exception {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    bluetoothAdapter.startRfcommServer("testname", UUID1, rfcommServerIntent);
+    ((ShadowBluetoothAdapter) Shadow.extract(bluetoothAdapter))
+        .addIncomingRfcommConnection(bluetoothAdapter.getRemoteDevice("AB:CD:EF:12:34:56"), UUID1);
+    BluetoothSocket socket = bluetoothAdapter.retrieveConnectedRfcommSocket(UUID1);
+
+    byte[] testBytes = "i can haz test string".getBytes(UTF_8);
+
+    shadowOf(socket).getInputStreamFeeder().write(testBytes);
+    shadowOf(socket).getInputStreamFeeder().flush();
+    shadowOf(socket).getInputStreamFeeder().close();
+    socket.getOutputStream().write(testBytes);
+    socket.getOutputStream().flush();
+    socket.getOutputStream().close();
+
+    assertThat(socket.getInputStream().readAllBytes()).isEqualTo(testBytes);
+    assertThat(shadowOf(socket).getOutputStreamSink().readAllBytes()).isEqualTo(testBytes);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  @SuppressWarnings("JdkImmutableCollections")
+  public void getResgisteredUuids_returnsRegisteredServers() {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    Set<UUID> serverUuids = Set.of(UUID1, UUID2, UUID3, UUID4, UUID5);
+
+    serverUuids.forEach(
+        uuid -> bluetoothAdapter.startRfcommServer(uuid.toString(), uuid, rfcommServerIntent));
+
+    assertThat(
+            ((ShadowBluetoothAdapter) Shadow.extract(bluetoothAdapter))
+                .getRegisteredRfcommServerUuids())
+        .containsExactlyElementsIn(serverUuids);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  @SuppressWarnings("JdkImmutableCollections")
+  public void getRegisteredUuids_serversStopped_doesNotReturnStoppedServerUuids() {
+    PendingIntent rfcommServerIntent = createTestPendingIntent(testIntent);
+
+    Set<UUID> serverUuids = Set.of(UUID1, UUID2, UUID3, UUID4, UUID5);
+
+    serverUuids.forEach(
+        uuid -> bluetoothAdapter.startRfcommServer(uuid.toString(), uuid, rfcommServerIntent));
+
+    bluetoothAdapter.stopRfcommServer(UUID4);
+
+    assertThat(
+            ((ShadowBluetoothAdapter) Shadow.extract(bluetoothAdapter))
+                .getRegisteredRfcommServerUuids())
+        .containsExactly(UUID1, UUID2, UUID3, UUID5);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setDiscoverableTimeout() {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+    adapter.enable();
+    assertThat(adapter.setDiscoverableTimeout(Duration.ofSeconds(1000)))
+        .isEqualTo(BluetoothStatusCodes.SUCCESS);
+    assertThat(adapter.getDiscoverableTimeout().toSeconds()).isEqualTo(1000);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setDiscoverableTimeout_adapterNotEnabled() {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+    assertThat(adapter.setDiscoverableTimeout(Duration.ZERO))
+        .isEqualTo(BluetoothStatusCodes.ERROR_BLUETOOTH_NOT_ENABLED);
+  }
+
+  private PendingIntent createTestPendingIntent(Intent intent) {
+    return PendingIntent.getBroadcast(
+        getApplicationContext(), /* requestCode= */ 0, intent, PendingIntent.FLAG_IMMUTABLE);
+  }
+
+  private BluetoothAdapter.LeScanCallback newLeScanCallback() {
+    return new BluetoothAdapter.LeScanCallback() {
+      @Override
+      public void onLeScan(BluetoothDevice bluetoothDevice, int i, byte[] bytes) {}
+    };
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothDeviceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothDeviceTest.java
new file mode 100644
index 0000000..cde0a48
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothDeviceTest.java
@@ -0,0 +1,588 @@
+package org.robolectric.shadows;
+
+import static android.Manifest.permission.BLUETOOTH_CONNECT;
+import static android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_HEADPHONES;
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+import static android.bluetooth.BluetoothDevice.DEVICE_TYPE_CLASSIC;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothSocket;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.ParcelUuid;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBluetoothDeviceTest {
+
+  private static final String MOCK_MAC_ADDRESS = "00:11:22:33:AA:BB";
+  private final Application application = ApplicationProvider.getApplicationContext();
+
+  @Test
+  public void canCreateBluetoothDeviceViaNewInstance() throws Exception {
+    // This test passes as long as no Exception is thrown. It tests if the constructor can be
+    // executed without throwing an Exception when getService() is called inside.
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    assertThat(bluetoothDevice).isNotNull();
+  }
+
+  @Test
+  public void canSetAndGetUuids() throws Exception {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    ParcelUuid[] uuids =
+        new ParcelUuid[] {
+          ParcelUuid.fromString("00000000-1111-2222-3333-000000000011"),
+          ParcelUuid.fromString("00000000-1111-2222-3333-0000000000aa")
+        };
+
+    shadowOf(device).setUuids(uuids);
+    assertThat(device.getUuids()).isEqualTo(uuids);
+  }
+
+  @Test
+  public void getUuids_setUuidsNotCalled_shouldReturnNull() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    assertThat(device.getUuids()).isNull();
+  }
+
+  @Test
+  public void canSetAndGetBondState() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(device.getBondState()).isEqualTo(BOND_NONE);
+
+    shadowOf(device).setBondState(BOND_BONDED);
+    assertThat(device.getBondState()).isEqualTo(BOND_BONDED);
+  }
+
+  @Test
+  public void canSetAndGetCreatedBond() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(device.createBond()).isFalse();
+
+    shadowOf(device).setCreatedBond(true);
+    assertThat(device.createBond()).isTrue();
+  }
+
+  @Test
+  public void canSetAndGetPin() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(shadowOf(device).getPin()).isNull();
+
+    byte[] pin = new byte[] { 1, 2, 3, 4 };
+    device.setPin(pin);
+    assertThat(shadowOf(device).getPin()).isEqualTo(pin);
+  }
+
+  @Test
+  public void canSetAndGetPairingConfirmation() {
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(shadowOf(device).getPairingConfirmation()).isNull();
+
+    device.setPairingConfirmation(true);
+    assertThat(shadowOf(device).getPairingConfirmation()).isTrue();
+
+    device.setPairingConfirmation(false);
+    assertThat(shadowOf(device).getPairingConfirmation()).isFalse();
+  }
+
+  @Test
+  public void canSetAndGetFetchUuidsWithSdpResult() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    assertThat(device.fetchUuidsWithSdp()).isFalse();
+
+    shadowOf(device).setFetchUuidsWithSdpResult(true);
+    assertThat(device.fetchUuidsWithSdp()).isTrue();
+  }
+
+  @Test
+  public void canSetAndGetBluetoothClass() throws Exception {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(shadowOf(device).getBluetoothClass()).isNull();
+
+    BluetoothClass bluetoothClass =
+        BluetoothClass.class.getConstructor(int.class).newInstance(AUDIO_VIDEO_HEADPHONES);
+    shadowOf(device).setBluetoothClass(bluetoothClass);
+    assertThat(shadowOf(device).getBluetoothClass()).isEqualTo(bluetoothClass);
+  }
+
+  @Test
+  public void canCreateAndRemoveBonds() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(device.createBond()).isFalse();
+
+    shadowOf(device).setCreatedBond(true);
+    assertThat(shadowOf(device).removeBond()).isTrue();
+    assertThat(device.createBond()).isFalse();
+    assertThat(shadowOf(device).removeBond()).isFalse();
+  }
+
+  @Test
+  public void getCorrectFetchUuidsWithSdpCount() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    assertThat(shadowOf(device).getFetchUuidsWithSdpCount()).isEqualTo(0);
+
+    device.fetchUuidsWithSdp();
+    assertThat(shadowOf(device).getFetchUuidsWithSdpCount()).isEqualTo(1);
+
+    device.fetchUuidsWithSdp();
+    assertThat(shadowOf(device).getFetchUuidsWithSdpCount()).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void connectGatt_doesntCrash() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    assertThat(
+            bluetoothDevice.connectGatt(
+                ApplicationProvider.getApplicationContext(), false, new BluetoothGattCallback() {}))
+        .isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void connectGatt_withTransport_doesntCrash() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    assertThat(
+            bluetoothDevice.connectGatt(
+                ApplicationProvider.getApplicationContext(),
+                false,
+                new BluetoothGattCallback() {},
+                BluetoothDevice.TRANSPORT_LE))
+        .isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void connectGatt_withTransportPhy_doesntCrash() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    assertThat(
+        bluetoothDevice.connectGatt(
+            ApplicationProvider.getApplicationContext(),
+            false,
+            new BluetoothGattCallback() {},
+            BluetoothDevice.TRANSPORT_LE,
+            BluetoothDevice.PHY_LE_1M_MASK))
+        .isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void connectGatt_withTransportPhyHandler_doesntCrash() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    assertThat(
+        bluetoothDevice.connectGatt(
+            ApplicationProvider.getApplicationContext(),
+            false,
+            new BluetoothGattCallback() {},
+            BluetoothDevice.TRANSPORT_LE,
+            BluetoothDevice.PHY_LE_1M_MASK,
+            new Handler()))
+        .isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void canSetAndGetType() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    shadowOf(device).setType(DEVICE_TYPE_CLASSIC);
+    assertThat(device.getType()).isEqualTo(DEVICE_TYPE_CLASSIC);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void canGetBluetoothGatts() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    List<BluetoothGatt> createdGatts = new ArrayList<>();
+
+    createdGatts.add(
+        device.connectGatt(
+            ApplicationProvider.getApplicationContext(), false, new BluetoothGattCallback() {}));
+    createdGatts.add(
+        device.connectGatt(
+            ApplicationProvider.getApplicationContext(), false, new BluetoothGattCallback() {}));
+
+    assertThat(shadowOf(device).getBluetoothGatts()).isEqualTo(createdGatts);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void connectGatt_setsBluetoothGattCallback() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    BluetoothGattCallback callback = new BluetoothGattCallback() {};
+
+    BluetoothGatt bluetoothGatt =
+        device.connectGatt(ApplicationProvider.getApplicationContext(), false, callback);
+
+    assertThat(shadowOf(bluetoothGatt).getGattCallback())
+        .isEqualTo(callback);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void canSimulateGattConnectionChange() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    BluetoothGattCallback callback = mock(BluetoothGattCallback.class);
+    BluetoothGatt bluetoothGatt =
+        device.connectGatt(ApplicationProvider.getApplicationContext(), false, callback);
+    int status = 4;
+    int newState = 2;
+
+    shadowOf(device).simulateGattConnectionChange(status, newState);
+
+    verify(callback).onConnectionStateChange(bluetoothGatt, status, newState);
+  }
+
+  @Test
+  public void createRfcommSocketToServiceRecord_returnsSocket() throws Exception {
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+
+    BluetoothSocket socket = device.createRfcommSocketToServiceRecord(UUID.randomUUID());
+    assertThat(socket).isNotNull();
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.R)
+  public void getSetAlias() {
+    String aliasName = "alias";
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    shadowOf(device).setAlias(aliasName);
+
+    // getAlias is accessed by reflection
+    try {
+      Method getAliasName = android.bluetooth.BluetoothDevice.class.getMethod("getAlias");
+      assertThat((String) getAliasName.invoke(device)).isEqualTo(aliasName);
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError("Failure accessing getAlias via reflection", e);
+    }
+  }
+
+  @Test
+  @Config(maxSdk = Q)
+  public void getAliasName() {
+    String aliasName = "alias";
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    shadowOf(device).setAlias(aliasName);
+
+    // getAliasName is accessed by reflection
+    try {
+      Method getAliasName = android.bluetooth.BluetoothDevice.class.getMethod("getAliasName");
+      assertThat((String) getAliasName.invoke(device)).isEqualTo(aliasName);
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError("Failure accessing getAliasName via reflection", e);
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getAliasName_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    String aliasName = "alias";
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setAlias(aliasName);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getAlias);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getUuids_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getUuids);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getName_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getName);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getType_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getType);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void fetchUuidsWithSdp_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::fetchUuidsWithSdp);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getBluetoothClass_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getBluetoothClass);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getBondState_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getBondState);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getPin_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    byte[] pin = new byte[] {};
+    assertThrows(SecurityException.class, () -> device.setPin(pin));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void createBond_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::createBond);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void createInsecureL2capChannel_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, () -> device.createInsecureL2capChannel(0));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void createL2capChannel_noBluetoothConnectPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, () -> device.createL2capChannel(0));
+  }
+
+  @Test
+  public void getAliasName_hasPermission_noExceptionThrown() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    String aliasName = "alias";
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setAlias(aliasName);
+    shadowDevice.setShouldThrowSecurityExceptions(true);
+
+    String retrievedAlias = device.getAlias();
+    assertThat(retrievedAlias).isNotNull();
+  }
+
+  @Test
+  public void
+      getAliasName_noBluetoothConnectPermission_shouldThrowSecurityExceptionsFalse_noExceptionThrown() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    String aliasName = "alias";
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    ShadowBluetoothDevice shadowDevice = shadowOf(device);
+    shadowDevice.setAlias(aliasName);
+    shadowDevice.setShouldThrowSecurityExceptions(false);
+
+    String retrievedAlias = device.getAlias();
+    assertThat(retrievedAlias).isNotNull();
+  }
+
+  @Test
+  @Config(maxSdk = Q)
+  public void getAliasName_aliasNull() {
+    String deviceName = "device name";
+    BluetoothDevice device = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    shadowOf(device).setName(deviceName);
+
+    // getAliasName is accessed by reflection
+    try {
+      Method getAliasName = android.bluetooth.BluetoothDevice.class.getMethod("getAliasName");
+      // Expect the name if alias is null.
+      assertThat((String) getAliasName.invoke(device)).isEqualTo(deviceName);
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError("Failure accessing getAliasName via reflection", e);
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getMetadata_noPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    shadowOf(device).setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, () -> device.getMetadata(22));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void setMetadata_noPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    shadowOf(device).setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, () -> device.setMetadata(22, new byte[] {123}));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void setMetadata_shouldSaveToMap() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(device.setMetadata(22, new byte[] {123})).isTrue();
+    assertThat(device.getMetadata(22)).isEqualTo(new byte[] {123});
+    assertThat(device.getMetadata(11)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void getBatteryLevel_noPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    shadowOf(device).setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::getBatteryLevel);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O_MR1)
+  public void getBatteryLevel_default() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(device.getBatteryLevel()).isEqualTo(BluetoothDevice.BATTERY_LEVEL_BLUETOOTH_OFF);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O_MR1)
+  public void setBatteryLevel_shouldBeSaved() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    shadowOf(device).setBatteryLevel(66);
+
+    assertThat(device.getBatteryLevel()).isEqualTo(66);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void isInSilenceMode_noPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    shadowOf(device).setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, device::isInSilenceMode);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void isInSilenceMode_defaultFalse() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(device.isInSilenceMode()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void setSilenceMode_noPermission_throwsException() {
+    shadowOf(application).denyPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+    shadowOf(device).setShouldThrowSecurityExceptions(true);
+
+    assertThrows(SecurityException.class, () -> device.setSilenceMode(true));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void setSilenceMode_shouldBeSaved() {
+    shadowOf(application).grantPermissions(BLUETOOTH_CONNECT);
+    BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(MOCK_MAC_ADDRESS);
+
+    assertThat(shadowOf(device).setSilenceMode(true)).isTrue();
+
+    assertThat(device.isInSilenceMode()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java
new file mode 100644
index 0000000..a76dbc7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java
@@ -0,0 +1,46 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowBluetoothGatt}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN_MR2)
+public class ShadowBluetoothGattTest {
+
+  private static final String MOCK_MAC_ADDRESS = "00:11:22:33:AA:BB";
+
+  @Test
+  public void canCreateBluetoothGattViaNewInstance() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    BluetoothGatt bluetoothGatt = ShadowBluetoothGatt.newInstance(bluetoothDevice);
+    assertThat(bluetoothGatt).isNotNull();
+  }
+
+  @Test
+  public void canSetAndGetGattCallback() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    BluetoothGatt bluetoothGatt = ShadowBluetoothGatt.newInstance(bluetoothDevice);
+    BluetoothGattCallback callback = new BluetoothGattCallback() {};
+
+    shadowOf(bluetoothGatt).setGattCallback(callback);
+
+    assertThat(shadowOf(bluetoothGatt).getGattCallback()).isEqualTo(callback);
+  }
+
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void connect_returnsTrue() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance(MOCK_MAC_ADDRESS);
+    BluetoothGatt bluetoothGatt = ShadowBluetoothGatt.newInstance(bluetoothDevice);
+    assertThat(bluetoothGatt.connect()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java
new file mode 100644
index 0000000..9a13954
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java
@@ -0,0 +1,231 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothHeadset;
+import android.bluetooth.BluetoothProfile;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Test for {@link ShadowBluetoothHeadset} */
+@RunWith(AndroidJUnit4.class)
+public class ShadowBluetoothHeadsetTest {
+  private BluetoothDevice device1;
+  private BluetoothDevice device2;
+  private BluetoothHeadset bluetoothHeadset;
+  private Application context;
+
+  @Rule public ExpectedException thrown = ExpectedException.none();
+
+  @Before
+  public void setUp() throws Exception {
+    device1 = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("00:11:22:33:AA:BB");
+    device2 = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("11:22:33:AA:BB:00");
+    bluetoothHeadset = Shadow.newInstanceOf(BluetoothHeadset.class);
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void getConnectedDevices_defaultsToEmptyList() {
+    assertThat(bluetoothHeadset.getConnectedDevices()).isEmpty();
+  }
+
+  @Test
+  public void getConnectedDevices_canBeSetUpWithAddConnectedDevice() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    shadowOf(bluetoothHeadset).addConnectedDevice(device2);
+
+    assertThat(bluetoothHeadset.getConnectedDevices()).containsExactly(device1, device2);
+  }
+
+  @Test
+  public void getConnectionState_defaultsToDisconnected() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    shadowOf(bluetoothHeadset).addConnectedDevice(device2);
+
+    assertThat(bluetoothHeadset.getConnectionState(device1))
+        .isEqualTo(BluetoothProfile.STATE_CONNECTED);
+    assertThat(bluetoothHeadset.getConnectionState(device2))
+        .isEqualTo(BluetoothProfile.STATE_CONNECTED);
+  }
+
+  @Test
+  public void getConnectionState_canBeSetUpWithAddConnectedDevice() {
+    assertThat(bluetoothHeadset.getConnectionState(device1))
+        .isEqualTo(BluetoothProfile.STATE_DISCONNECTED);
+  }
+
+  @Test
+  public void isAudioConnected_defaultsToFalse() {
+    assertThat(bluetoothHeadset.isAudioConnected(device1)).isFalse();
+  }
+
+  @Test
+  public void isAudioConnected_canBeSetUpWithStartVoiceRecognition() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+
+    bluetoothHeadset.startVoiceRecognition(device1);
+
+    assertThat(bluetoothHeadset.isAudioConnected(device1)).isTrue();
+  }
+
+  @Test
+  public void isAudioConnected_isFalseAfterStopVoiceRecognition() {
+    bluetoothHeadset.startVoiceRecognition(device1);
+    bluetoothHeadset.stopVoiceRecognition(device1);
+
+    assertThat(bluetoothHeadset.isAudioConnected(device1)).isFalse();
+  }
+
+  @Test
+  public void startVoiceRecogntion_shouldEmitBroadcast() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    IntentFilter intentFilter = new IntentFilter(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
+    List<Integer> extraStateList = new ArrayList<>();
+    BroadcastReceiver receiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            extraStateList.add(intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1));
+          }
+        };
+    context.registerReceiver(receiver, intentFilter);
+
+    bluetoothHeadset.startVoiceRecognition(device1);
+    shadowOf(getMainLooper()).idle();
+
+    assertThat(extraStateList)
+        .containsExactly(
+            BluetoothHeadset.STATE_AUDIO_CONNECTING, BluetoothHeadset.STATE_AUDIO_CONNECTED);
+  }
+
+  @Test
+  public void startVoiceRecogniton_returnsFalseIfAlreadyStarted() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    shadowOf(bluetoothHeadset).addConnectedDevice(device2);
+
+    bluetoothHeadset.startVoiceRecognition(device1);
+
+    assertThat(bluetoothHeadset.startVoiceRecognition(device2)).isFalse();
+  }
+
+  @Test
+  public void startVoiceRecogntion_stopsAlreadyStartedRecognition() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    shadowOf(bluetoothHeadset).addConnectedDevice(device2);
+
+    bluetoothHeadset.startVoiceRecognition(device1);
+    bluetoothHeadset.startVoiceRecognition(device2);
+
+    assertThat(bluetoothHeadset.isAudioConnected(device1)).isFalse();
+  }
+
+  @Test
+  public void stopVoiceRecognition_returnsFalseIfNoVoiceRecognitionStarted() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+
+    assertThat(bluetoothHeadset.stopVoiceRecognition(device1)).isFalse();
+  }
+
+  @Test
+  public void stopVoiceRecognition_shouldEmitBroadcast() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    IntentFilter intentFilter = new IntentFilter(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
+    List<Integer> extraStateList = new ArrayList<>();
+    BroadcastReceiver receiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            extraStateList.add(intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1));
+          }
+        };
+    context.registerReceiver(receiver, intentFilter);
+
+    bluetoothHeadset.startVoiceRecognition(device1);
+    bluetoothHeadset.stopVoiceRecognition(device1);
+    shadowOf(getMainLooper()).idle();
+
+    assertThat(extraStateList)
+        .containsExactly(
+            BluetoothHeadset.STATE_AUDIO_CONNECTING,
+            BluetoothHeadset.STATE_AUDIO_CONNECTED,
+            BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void sendVendorSpecificResultCode_defaultsToTrueForConnectedDevice() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+
+    assertThat(bluetoothHeadset.sendVendorSpecificResultCode(device1, "command", "arg")).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void sendVendorSpecificResultCode_alwaysFalseForDisconnectedDevice() {
+    assertThat(bluetoothHeadset.sendVendorSpecificResultCode(device1, "command", "arg")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void sendVendorSpecificResultCode_canBeForcedToFalseForConnectedDevice() {
+    shadowOf(bluetoothHeadset).addConnectedDevice(device1);
+    shadowOf(bluetoothHeadset).setAllowsSendVendorSpecificResultCode(false);
+
+    assertThat(bluetoothHeadset.sendVendorSpecificResultCode(device1, "command", "arg")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void sendVendorSpecificResultCode_throwsOnNullCommand() {
+    try {
+      bluetoothHeadset.sendVendorSpecificResultCode(device1, null, "arg");
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setActiveDevice_setNull_shouldSaveNull() {
+    assertThat(bluetoothHeadset.setActiveDevice(null)).isTrue();
+
+    assertThat(bluetoothHeadset.getActiveDevice()).isNull();
+    Intent intent = shadowOf(context).getBroadcastIntents().get(0);
+    assertThat(intent.getAction()).isEqualTo(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED);
+    assertThat((BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getActiveDevice_returnValueFromSetter() {
+    assertThat(bluetoothHeadset.setActiveDevice(device1)).isTrue();
+
+    assertThat(bluetoothHeadset.getActiveDevice()).isEqualTo(device1);
+    Intent intent = shadowOf(context).getBroadcastIntents().get(0);
+    assertThat(intent.getAction()).isEqualTo(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED);
+    assertThat((BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE))
+        .isEqualTo(device1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiserTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiserTest.java
new file mode 100644
index 0000000..cd8759c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiserTest.java
@@ -0,0 +1,453 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.os.ParcelUuid;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowBluetoothLeAdvertiser}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = O)
+public class ShadowBluetoothLeAdvertiserTest {
+
+  private static final String ADVERTISE_DATA_UUID1 = "00000000-0000-0000-0000-0000000000A1";
+  private static final String ADVERTISE_DATA_UUID2 = "00000000-0000-0000-0000-0000000000A2";
+  private static final String SCAN_RESPONSE_UUID1 = "00000000-0000-0000-0000-0000000000B1";
+  private static final String SCAN_RESPONSE_UUID2 = "00000000-0000-0000-0000-0000000000B2";
+  private static final String CALLBACK1_SUCCESS_RESULT = "c1s";
+  private static final String CALLBACK1_FAILURE_RESULT = "c1f";
+  private static final String CALLBACK2_SUCCESS_RESULT = "c2s";
+  private static final String CALLBACK2_FAILURE_RESULT = "c2f";
+
+  private BluetoothLeAdvertiser bluetoothLeAdvertiser;
+  private BluetoothLeAdvertiser bluetoothLeAdvertiserNameSet;
+  private AdvertiseSettings advertiseSettings1;
+  private AdvertiseSettings advertiseSettings2;
+  private AdvertiseData advertiseData1;
+  private AdvertiseData advertiseData2;
+  private AdvertiseData scanResponse1;
+  private AdvertiseData scanResponse2;
+  private AdvertiseCallback advertiseCallback1;
+  private AdvertiseCallback advertiseCallback2;
+
+  private String result;
+  private int error;
+  private AdvertiseSettings settings;
+
+  @Before
+  public void setUp() throws Exception {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+    shadowOf(adapter).setState(BluetoothAdapter.STATE_ON);
+    bluetoothLeAdvertiser = adapter.getBluetoothLeAdvertiser();
+
+    BluetoothAdapter adapter2 = BluetoothAdapter.getDefaultAdapter();
+    adapter2.setName("B");
+    bluetoothLeAdvertiserNameSet = adapter.getBluetoothLeAdvertiser();
+
+    advertiseCallback1 =
+        new AdvertiseCallback() {
+          @Override
+          public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+            result = CALLBACK1_SUCCESS_RESULT;
+            settings = settingsInEffect;
+          }
+
+          @Override
+          public void onStartFailure(int errorCode) {
+            result = CALLBACK1_FAILURE_RESULT;
+            error = errorCode;
+          }
+        };
+    advertiseCallback2 =
+        new AdvertiseCallback() {
+          @Override
+          public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+            result = CALLBACK2_SUCCESS_RESULT;
+            settings = settingsInEffect;
+          }
+
+          @Override
+          public void onStartFailure(int errorCode) {
+            result = CALLBACK2_FAILURE_RESULT;
+            error = errorCode;
+          }
+        };
+
+    advertiseData1 =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString(ADVERTISE_DATA_UUID1))
+            .build();
+    advertiseData2 =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString(ADVERTISE_DATA_UUID2))
+            .build();
+
+    scanResponse1 =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString(SCAN_RESPONSE_UUID1))
+            .build();
+    scanResponse2 =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString(SCAN_RESPONSE_UUID2))
+            .build();
+
+    advertiseSettings1 =
+        new AdvertiseSettings.Builder()
+            .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
+            .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
+            .setConnectable(true)
+            .build();
+    advertiseSettings2 =
+        new AdvertiseSettings.Builder()
+            .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+            .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW)
+            .setConnectable(false)
+            .build();
+  }
+
+  @Test
+  public void startAdvertising_nullCallback() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            shadowOf(bluetoothLeAdvertiser)
+                .startAdvertising(advertiseSettings1, advertiseData1, null));
+  }
+
+  @Test
+  public void stopAdvertising_nullCallback() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> shadowOf(bluetoothLeAdvertiser).stopAdvertising(null));
+  }
+
+  @Test
+  public void getAdvertisementRequests_neverStartedAdvertising() {
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void stopAdvertising_neverStartedAdvertising() {
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+    bluetoothLeAdvertiser.stopAdvertising(advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void startAdvertising_oneAdvertisement_withNoScanResponse() {
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings1, advertiseData1, advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+    assertThat(result).isEqualTo(CALLBACK1_SUCCESS_RESULT);
+  }
+
+  @Test
+  public void startAdvertising_oneAdvertisement_withScanResponse() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+    assertThat(result).isEqualTo(CALLBACK1_SUCCESS_RESULT);
+    assertThat(settings).isEqualTo(advertiseSettings1);
+  }
+
+  @Test
+  public void startAdvertising_twoAdvertisements() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings2, advertiseData2, advertiseCallback2);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(2);
+    assertThat(result).isEqualTo(CALLBACK2_SUCCESS_RESULT);
+    assertThat(settings).isEqualTo(advertiseSettings2);
+  }
+
+  @Test
+  public void startAdvertising_twoAdvertisements_sameCallback() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings2, advertiseData2, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void startAdvertising_noDataAndoverSizedData_failure() {
+    AdvertiseData oversizedData =
+        new AdvertiseData.Builder()
+            .addServiceUuid(ParcelUuid.fromString("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+            .addServiceUuid(ParcelUuid.fromString("EEEEEEEE-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, null, oversizedData, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, oversizedData, null, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+  }
+
+  @Test
+  public void startAdvertising_validSizeUsing16BitUuids() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("00001602-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001604-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001606-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001608-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001610-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001612-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001614-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001616-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001618-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001620-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001622-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001624-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001626-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001628-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings2, data, advertiseCallback1);
+    assertThat(result).isEqualTo(CALLBACK1_SUCCESS_RESULT);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void startAdvertising_validSizedUsing32BitUuids() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("F0003204-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003208-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003212-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003216-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003220-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003224-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003228-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings2, data, advertiseCallback1);
+    assertThat(result).isEqualTo(CALLBACK1_SUCCESS_RESULT);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void startAdvertising_validSizeUsing128BitUuids() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .addServiceUuid(ParcelUuid.fromString("F0012816-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings1, data, advertiseCallback1);
+    assertThat(result).isEqualTo(CALLBACK1_SUCCESS_RESULT);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void startAdvertising_oversizedUsing16BitUuids() {
+    AdvertiseData oversizedData =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("00001602-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001604-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001606-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001608-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001610-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001612-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001614-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001616-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001618-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001620-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001622-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001624-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001626-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001628-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001630-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings2, oversizedData, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void startAdvertising_oversizedUsing32BitUuids() {
+    AdvertiseData oversizedData =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("F0003204-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003208-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003212-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003216-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003220-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003224-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003228-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003232-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings2, oversizedData, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void startAdvertising_oversizedWithConnectable() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("F0003204-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003208-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003212-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003216-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003220-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003224-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003228-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings1, data, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void startAdvertising_oversizedUsing128BitUuids() {
+    AdvertiseData oversizedData =
+        new AdvertiseData.Builder()
+            .addServiceUuid(ParcelUuid.fromString("F0012816-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+            .addServiceUuid(ParcelUuid.fromString("F0012832-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings1, oversizedData, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void startAdvertising_16BitUuids_oversizedDueToConnectable() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(false)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("00001602-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001604-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001606-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001608-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001610-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001612-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001614-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001616-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001618-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001620-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001622-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001624-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001626-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("00001628-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiser.startAdvertising(advertiseSettings1, data, advertiseCallback1);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void startAdvertising_validSizeWithNameSet() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(true)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("F0003204-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiserNameSet.startAdvertising(advertiseSettings2, data, advertiseCallback1);
+    assertThat(result).isEqualTo(CALLBACK1_SUCCESS_RESULT);
+    assertThat(shadowOf(bluetoothLeAdvertiserNameSet).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void startAdvertising_oversizedWithNameSet() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(true)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("F0003204-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003208-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003212-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003216-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003220-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003224-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003228-0000-1000-8000-00805F9B34FB"))
+            .build();
+    bluetoothLeAdvertiserNameSet.startAdvertising(advertiseSettings2, data, advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiserNameSet).getAdvertisementRequestCount()).isEqualTo(0);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+  }
+
+  @Test
+  public void startAdvertising_oversizedWithServiceData() {
+    AdvertiseData data =
+        new AdvertiseData.Builder()
+            .setIncludeDeviceName(true)
+            .setIncludeTxPowerLevel(false)
+            .addServiceUuid(ParcelUuid.fromString("F0003204-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003208-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003212-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003216-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003220-0000-1000-8000-00805F9B34FB"))
+            .addServiceUuid(ParcelUuid.fromString("F0003224-0000-1000-8000-00805F9B34FB"))
+            .addServiceData(
+                ParcelUuid.fromString("F0012832-FFFF-FFFF-FFFF-FFFFFFFFFFFF"), new byte[] {1, 2, 3})
+            .build();
+    bluetoothLeAdvertiserNameSet.startAdvertising(advertiseSettings2, data, advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiserNameSet).getAdvertisementRequestCount()).isEqualTo(0);
+    assertThat(error).isEqualTo(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+  }
+
+  @Test
+  public void stopAdvertising_afterStartingAdvertising() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    bluetoothLeAdvertiser.stopAdvertising(advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void stopAdvertising_afterStartingAdvertisingTwice_stoppingFirst() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings2, advertiseData2, scanResponse2, advertiseCallback2);
+    bluetoothLeAdvertiser.stopAdvertising(advertiseCallback1);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void stopAdvertising_afterStartingAdvertisingTwice_stoppingSecond() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings2, advertiseData2, scanResponse2, advertiseCallback2);
+    bluetoothLeAdvertiser.stopAdvertising(advertiseCallback2);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void stopAdvertising_afterStartingAdvertising_stoppingAdvertisementWasntStarted() {
+    bluetoothLeAdvertiser.startAdvertising(
+        advertiseSettings1, advertiseData1, scanResponse1, advertiseCallback1);
+    bluetoothLeAdvertiser.stopAdvertising(advertiseCallback2);
+    assertThat(shadowOf(bluetoothLeAdvertiser).getAdvertisementRequestCount()).isEqualTo(1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothLeScannerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothLeScannerTest.java
new file mode 100644
index 0000000..f4a4d71
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothLeScannerTest.java
@@ -0,0 +1,162 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Intent;
+import android.os.ParcelUuid;
+import androidx.test.core.app.ApplicationProvider;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowBluetoothLeScanner}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowBluetoothLeScannerTest {
+  private BluetoothLeScanner bluetoothLeScanner;
+  private List<ScanFilter> scanFilters;
+  private ScanSettings scanSettings;
+  private ScanCallback scanCallback;
+  private PendingIntent pendingIntent;
+
+  @Before
+  public void setUp() throws Exception {
+    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+    if (RuntimeEnvironment.getApiLevel() < M) {
+      // On SDK < 23, bluetooth has to be in STATE_ON in order to get a BluetoothLeScanner.
+      shadowOf(adapter).setState(BluetoothAdapter.STATE_ON);
+    }
+    bluetoothLeScanner = adapter.getBluetoothLeScanner();
+
+    ParcelUuid serviceUuid =
+        new ParcelUuid(UUID.fromString("12345678-90AB-CDEF-1234-567890ABCDEF"));
+    byte[] serviceData = new byte[] {0x01, 0x02, 0x03};
+
+    scanFilters =
+        Collections.singletonList(
+            new ScanFilter.Builder()
+                .setServiceUuid(serviceUuid)
+                .setServiceData(serviceUuid, serviceData)
+                .build());
+    scanSettings =
+        new ScanSettings.Builder()
+            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+            .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
+            .setReportDelay(0)
+            .build();
+    scanCallback =
+        new ScanCallback() {
+          @Override
+          public void onScanResult(int callbackType, ScanResult scanResult) {}
+
+          @Override
+          public void onScanFailed(int errorCode) {}
+        };
+    pendingIntent =
+        PendingIntent.getBroadcast(
+            ApplicationProvider.getApplicationContext(), 0, new Intent("SCAN_CALLBACK"), 0);
+  }
+
+  @Test
+  public void startScanning() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback);
+    assertThat(shadowOf(bluetoothLeScanner).getScanCallbacks()).containsExactly(scanCallback);
+  }
+
+  @Test
+  public void startScanning_withNullParameters() {
+    bluetoothLeScanner.startScan(null, null, scanCallback);
+    assertThat(shadowOf(bluetoothLeScanner).getScanCallbacks()).containsExactly(scanCallback);
+  }
+
+  @Test
+  public void stopScanning() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback);
+    assertThat(shadowOf(bluetoothLeScanner).getScanCallbacks()).containsExactly(scanCallback);
+    bluetoothLeScanner.stopScan(scanCallback);
+    assertThat(shadowOf(bluetoothLeScanner).getScanCallbacks()).isEmpty();
+  }
+
+  @Test
+  public void stopScanning_neverStarted() {
+    bluetoothLeScanner.stopScan(scanCallback);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void startScanning_forPendingIntent() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, pendingIntent);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).pendingIntent())
+        .isEqualTo(pendingIntent);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void startScanning_forPendingIntent_withNullParameters() {
+    bluetoothLeScanner.startScan(null, null, pendingIntent);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).pendingIntent())
+        .isEqualTo(pendingIntent);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void stopScanning_forPendingIntent() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, pendingIntent);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).pendingIntent())
+        .isEqualTo(pendingIntent);
+    bluetoothLeScanner.stopScan(pendingIntent);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans()).isEmpty();
+  }
+
+  @Test
+  public void getScanFilters_forScanCallback_isPresent() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).scanFilters())
+        .containsExactlyElementsIn(scanFilters);
+  }
+
+  @Test
+  public void getActiveScans_noScans_isEmpty() {
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getScanFilters_forPendingIntent_isPresent() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, pendingIntent);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).scanFilters())
+        .containsExactlyElementsIn(scanFilters);
+  }
+
+  @Test
+  public void getScanSettings_forScanCallback_isPresent() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).scanSettings())
+        .isEqualTo(scanSettings);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getScanSettings_forPendingIntent_isPresent() {
+    bluetoothLeScanner.startScan(scanFilters, scanSettings, pendingIntent);
+    assertThat(shadowOf(bluetoothLeScanner).getActiveScans().get(0).scanSettings())
+        .isEqualTo(scanSettings);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothManagerTest.java
new file mode 100644
index 0000000..a2c5f5f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothManagerTest.java
@@ -0,0 +1,138 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN_MR2)
+public class ShadowBluetoothManagerTest {
+  private static final String DEVICE_ADDRESS_1 = "00:11:22:AA:BB:CC";
+  private static final String DEVICE_ADDRESS_2 = "11:22:33:BB:CC:DD";
+  private static final int INVALID_PROFILE = -1;
+  private static final int INVALID_STATE = -1;
+  private static final int PROFILE_GATT = BluetoothProfile.GATT;
+  private static final int PROFILE_GATT_SERVER = BluetoothProfile.GATT_SERVER;
+  private static final int PROFILE_STATE_CONNECTED = BluetoothProfile.STATE_CONNECTED;
+  private static final int PROFILE_STATE_CONNECTING = BluetoothProfile.STATE_CONNECTING;
+  private static final int[] CONNECTED_STATES = new int[] {PROFILE_STATE_CONNECTED};
+
+  private BluetoothManager manager;
+  private BluetoothAdapter adapter;
+  private ShadowBluetoothManager shadowManager;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+    adapter = manager.getAdapter();
+    shadowManager = shadowOf(manager);
+  }
+
+  @Test
+  public void getAdapter_shouldReturnBluetoothAdapter() {
+    assertThat(adapter).isSameInstanceAs(BluetoothAdapter.getDefaultAdapter());
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_invalidProfile_throwsIllegalArgumentException() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> manager.getDevicesMatchingConnectionStates(INVALID_PROFILE, CONNECTED_STATES));
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_nullStates_returnsEmptyList() {
+    assertThat(manager.getDevicesMatchingConnectionStates(PROFILE_GATT, null)).isEmpty();
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_noDevicesRegistered_returnsEmptyList() {
+    assertThat(manager.getDevicesMatchingConnectionStates(PROFILE_GATT, CONNECTED_STATES))
+        .isEmpty();
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_invalidStateRegistered_returnsEmptyList() {
+    shadowManager.addDevice(PROFILE_GATT, INVALID_STATE, createBluetoothDevice(DEVICE_ADDRESS_1));
+
+    List<BluetoothDevice> result =
+        manager.getDevicesMatchingConnectionStates(PROFILE_GATT, new int[] {INVALID_STATE});
+
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_invalidProfileRegistered_throwsException() {
+    shadowManager.addDevice(
+        INVALID_PROFILE, PROFILE_STATE_CONNECTED, createBluetoothDevice(DEVICE_ADDRESS_1));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> manager.getDevicesMatchingConnectionStates(INVALID_PROFILE, CONNECTED_STATES));
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_subsetMatched_returnsBluetoothDeviceList() {
+    BluetoothDevice match = createBluetoothDevice(DEVICE_ADDRESS_1);
+    shadowManager.addDevice(PROFILE_GATT, PROFILE_STATE_CONNECTED, match);
+    BluetoothDevice noMatchState = createBluetoothDevice(DEVICE_ADDRESS_2);
+    shadowManager.addDevice(PROFILE_GATT, PROFILE_STATE_CONNECTING, noMatchState);
+    BluetoothDevice noMatchProfile = createBluetoothDevice(DEVICE_ADDRESS_2);
+    shadowManager.addDevice(PROFILE_GATT_SERVER, PROFILE_STATE_CONNECTED, noMatchProfile);
+    ImmutableList<BluetoothDevice> expected = ImmutableList.of(match);
+
+    List<BluetoothDevice> result =
+        manager.getDevicesMatchingConnectionStates(PROFILE_GATT, CONNECTED_STATES);
+
+    assertThat(result).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_multiStatesMatched_returnsBluetoothDeviceList() {
+    BluetoothDevice match1 = createBluetoothDevice(DEVICE_ADDRESS_1);
+    shadowManager.addDevice(PROFILE_GATT, PROFILE_STATE_CONNECTED, match1);
+    BluetoothDevice match2 = createBluetoothDevice(DEVICE_ADDRESS_2);
+    shadowManager.addDevice(PROFILE_GATT, PROFILE_STATE_CONNECTING, match2);
+    ImmutableList<BluetoothDevice> expected = ImmutableList.of(match1, match2);
+
+    List<BluetoothDevice> result =
+        manager.getDevicesMatchingConnectionStates(
+            PROFILE_GATT, new int[] {PROFILE_STATE_CONNECTED, PROFILE_STATE_CONNECTING});
+
+    assertThat(result).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void getDevicesMatchingConnectionStates_multiProfilesMatched_returnsBluetoothDeviceList() {
+    BluetoothDevice match1 = createBluetoothDevice(DEVICE_ADDRESS_1);
+    shadowManager.addDevice(PROFILE_GATT_SERVER, PROFILE_STATE_CONNECTED, match1);
+    BluetoothDevice match2 = createBluetoothDevice(DEVICE_ADDRESS_2);
+    shadowManager.addDevice(PROFILE_GATT_SERVER, PROFILE_STATE_CONNECTED, match2);
+    ImmutableList<BluetoothDevice> expected = ImmutableList.of(match1, match2);
+
+    List<BluetoothDevice> result =
+        manager.getDevicesMatchingConnectionStates(PROFILE_GATT_SERVER, CONNECTED_STATES);
+
+    assertThat(result).containsExactlyElementsIn(expected);
+  }
+
+  private BluetoothDevice createBluetoothDevice(String address) {
+    return adapter.getRemoteDevice(address);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothServerSocketTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothServerSocketTest.java
new file mode 100644
index 0000000..3066619
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothServerSocketTest.java
@@ -0,0 +1,62 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.os.ParcelUuid;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.UUID;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link ShadowBluetoothServerSocket}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowBluetoothServerSocketTest {
+
+  private static final UUID DUMMY_UUID = UUID.fromString("00000000-1111-2222-3333-444444444444");
+
+  private BluetoothServerSocket serverSocket;
+
+  @Before
+  public void setUp() {
+    serverSocket = ShadowBluetoothServerSocket.newInstance(BluetoothSocket.TYPE_RFCOMM,
+            /*auth=*/ false, /*encrypt=*/ false, new ParcelUuid(DUMMY_UUID));
+  }
+
+  @Test
+  public void accept() throws Exception {
+    BluetoothDevice btDevice = ShadowBluetoothDevice.newInstance("DE:AD:BE:EE:EE:EF");
+    shadowOf(serverSocket).deviceConnected(btDevice);
+
+    BluetoothSocket clientSocket = serverSocket.accept();
+    assertThat(clientSocket.getRemoteDevice()).isSameInstanceAs(btDevice);
+  }
+
+  @Test
+  public void accept_timeout() {
+    try {
+      serverSocket.accept(200);
+      fail();
+    } catch (IOException expected) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void close() throws Exception {
+    serverSocket.close();
+
+    try {
+      serverSocket.accept();
+      fail();
+    } catch (IOException expected) {
+      // Expected.
+    }
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothSocketTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothSocketTest.java
new file mode 100644
index 0000000..4e2c6cb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothSocketTest.java
@@ -0,0 +1,87 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothSocket;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PipedOutputStream;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBluetoothSocketTest {
+  BluetoothSocket bluetoothSocket;
+
+  private static final byte[] DATA = new byte[] {1, 2, 3, 42, 96, 127};
+
+  @Before
+  public void setUp() throws Exception {
+    bluetoothSocket = Shadow.newInstanceOf(BluetoothSocket.class);
+  }
+
+  @Test
+  public void getInputStreamFeeder() throws Exception {
+    shadowOf(bluetoothSocket).getInputStreamFeeder().write(DATA);
+
+    InputStream inputStream = bluetoothSocket.getInputStream();
+    byte[] b = new byte[1024];
+    int len = inputStream.read(b);
+    assertThat(Arrays.copyOf(b, len)).isEqualTo(DATA);
+  }
+
+  @Test
+  public void getOutputStreamSink() throws Exception {
+    bluetoothSocket.getOutputStream().write(DATA);
+
+    byte[] b = new byte[1024];
+    int len = shadowOf(bluetoothSocket).getOutputStreamSink().read(b);
+    assertThat(Arrays.copyOf(b, len)).isEqualTo(DATA);
+  }
+
+  private static class SocketVerifier extends PipedOutputStream {
+    boolean success = false;
+
+    @Override
+    public void write(byte[] b, int off, int len) {
+      success = true;
+    }
+  }
+
+  @Test
+  public void setOutputStream_withWrite_observable() throws Exception {
+    SocketVerifier socketVerifier = new SocketVerifier();
+    shadowOf(bluetoothSocket).setOutputStream(socketVerifier);
+
+    bluetoothSocket.getOutputStream().write(DATA);
+
+    assertThat(socketVerifier.success).isTrue();
+  }
+
+  @Test
+  public void close() throws Exception {
+    bluetoothSocket.close();
+
+    try {
+      bluetoothSocket.connect();
+      fail();
+    } catch (IOException expected) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void connect() throws Exception {
+    assertThat(bluetoothSocket.isConnected()).isFalse();
+    bluetoothSocket.connect();
+    assertThat(bluetoothSocket.isConnected()).isTrue();
+    bluetoothSocket.close();
+    assertThat(bluetoothSocket.isConnected()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastPendingResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastPendingResultTest.java
new file mode 100644
index 0000000..9adc9f2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastPendingResultTest.java
@@ -0,0 +1,17 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBroadcastPendingResultTest {
+  @Test
+  public void testCreate() {
+    assertThat(ShadowBroadcastPendingResult.create(1, "result", new Bundle(), true))
+        .isNotNull();
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastReceiverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastReceiverTest.java
new file mode 100644
index 0000000..e4bb933
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastReceiverTest.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.BroadcastReceiver;
+import android.content.BroadcastReceiver.PendingResult;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ShadowBroadcastReceiver} */
+@RunWith(AndroidJUnit4.class)
+public class ShadowBroadcastReceiverTest {
+
+  private BroadcastReceiver receiver;
+  private PendingResult pendingResult;
+
+  @Before
+  public void setup() {
+    receiver = new MyBroadcastReceiver();
+    pendingResult = ShadowBroadcastPendingResult.create(1, "result", new Bundle(), true);
+    receiver.setPendingResult(pendingResult);
+  }
+
+  @Test
+  public void testWithoutGoAsync() {
+    assertThat(shadowOf(receiver).wentAsync()).isFalse();
+    assertThat(shadowOf(receiver).getOriginalPendingResult()).isSameInstanceAs(pendingResult);
+  }
+
+  @Test
+  public void testWithGoAsync() {
+    final PendingResult pendingResultFromGoAsync = receiver.goAsync();
+    assertThat(shadowOf(receiver).wentAsync()).isTrue();
+    assertThat(pendingResultFromGoAsync).isEqualTo(pendingResult);
+    assertThat(shadowOf(receiver).getOriginalPendingResult()).isSameInstanceAs(pendingResult);
+  }
+
+  private static class MyBroadcastReceiver extends BroadcastReceiver {
+    @Override
+    public void onReceive(Context context, Intent intent) {}
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastResponseStatsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastResponseStatsTest.java
new file mode 100644
index 0000000..ec180d1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBroadcastResponseStatsTest.java
@@ -0,0 +1,62 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.usage.BroadcastResponseStats;
+import android.os.Build.VERSION_CODES;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.TIRAMISU)
+public class ShadowBroadcastResponseStatsTest {
+  private static final String TEST_PACKAGE = "test.app";
+  private static final int BUCKET_ID = 10;
+
+  @Test
+  public void incrementNotificationsCancelledCount() {
+    int count = 42;
+    BroadcastResponseStats stats = new BroadcastResponseStats(TEST_PACKAGE, BUCKET_ID);
+
+    ShadowBroadcastResponseStats statsShadow = Shadow.extract(stats);
+    statsShadow.incrementNotificationsCancelledCount(count);
+
+    assertThat(stats.getNotificationsCancelledCount()).isEqualTo(count);
+  }
+
+  @Test
+  public void incrementNotificationsPostedCount() {
+    int count = 42;
+    BroadcastResponseStats stats = new BroadcastResponseStats(TEST_PACKAGE, BUCKET_ID);
+
+    ShadowBroadcastResponseStats statsShadow = Shadow.extract(stats);
+    statsShadow.incrementNotificationsPostedCount(count);
+
+    assertThat(stats.getNotificationsPostedCount()).isEqualTo(count);
+  }
+
+  @Test
+  public void incrementNotificationsUpdatedCount() {
+    int count = 42;
+    BroadcastResponseStats stats = new BroadcastResponseStats(TEST_PACKAGE, BUCKET_ID);
+
+    ShadowBroadcastResponseStats statsShadow = Shadow.extract(stats);
+    statsShadow.incrementNotificationsUpdatedCount(count);
+
+    assertThat(stats.getNotificationsUpdatedCount()).isEqualTo(count);
+  }
+
+  @Test
+  public void incrementBroadcastsDispatchedCount() {
+    int count = 42;
+    BroadcastResponseStats stats = new BroadcastResponseStats(TEST_PACKAGE, BUCKET_ID);
+
+    ShadowBroadcastResponseStats statsShadow = Shadow.extract(stats);
+    statsShadow.incrementBroadcastsDispatchedCount(count);
+
+    assertThat(stats.getBroadcastsDispatchedCount()).isEqualTo(count);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBugreportManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBugreportManagerTest.java
new file mode 100644
index 0000000..624508b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBugreportManagerTest.java
@@ -0,0 +1,305 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.content.Context;
+import android.os.BugreportManager.BugreportCallback;
+import android.os.BugreportParams;
+import android.os.ParcelFileDescriptor;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowBugreportManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public final class ShadowBugreportManagerTest {
+
+  private ShadowBugreportManager shadowBugreportManager;
+  private final Context context = ApplicationProvider.getApplicationContext();
+  private final List<ParcelFileDescriptor> openFds = new ArrayList<>();
+
+  @Before
+  public void setUp() {
+    shadowBugreportManager = Shadow.extract(context.getSystemService(Context.BUGREPORT_SERVICE));
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    for (ParcelFileDescriptor pfd : openFds) {
+      pfd.close();
+    }
+  }
+
+  @Test
+  public void requestBugreport() {
+    String title = "title";
+    String description = "description";
+    shadowBugreportManager.requestBugreport(
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_INTERACTIVE), title, description);
+
+    assertThat(shadowBugreportManager.wasBugreportRequested()).isTrue();
+    assertThat(shadowBugreportManager.getShareTitle().toString()).isEqualTo(title);
+    assertThat(shadowBugreportManager.getShareDescription().toString()).isEqualTo(description);
+  }
+
+  @Test
+  public void startBugreport() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+  }
+
+  @Test
+  public void startBugreport_noPermission() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.setHasPermission(false);
+
+    assertThrows(
+        SecurityException.class,
+        () -> {
+          shadowBugreportManager.startBugreport(
+              createWriteFile("bugreport"),
+              createWriteFile("screenshot"),
+              new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+              directExecutor(),
+              callback);
+        });
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  public void startTwoBugreports() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    BugreportCallback newCallback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport_new"),
+        createWriteFile("screenshot_new"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        newCallback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(newCallback).onError(BugreportCallback.BUGREPORT_ERROR_ANOTHER_REPORT_IN_PROGRESS);
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.executeOnFinished();
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verify(callback).onFinished();
+    verify(callback, never()).onError(anyInt());
+  }
+
+  @Test
+  public void cancelBugreport() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.cancelBugreport();
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verify(callback).onError(BugreportCallback.BUGREPORT_ERROR_RUNTIME);
+  }
+
+  @Test
+  public void cancelBugreport_noPermission() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+    // Loss of permission between start and cancel is theoretically possible, particularly if using
+    // carrier privileges instead of DUMP.
+    shadowBugreportManager.setHasPermission(false);
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+
+    assertThrows(SecurityException.class, shadowBugreportManager::cancelBugreport);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  public void executeOnError() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.executeOnError(BugreportCallback.BUGREPORT_ERROR_INVALID_INPUT);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verify(callback).onError(BugreportCallback.BUGREPORT_ERROR_INVALID_INPUT);
+  }
+
+  @Test
+  public void executeOnFinished() throws Exception {
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.executeOnFinished();
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verify(callback).onFinished();
+    verify(callback, never()).onError(anyInt());
+  }
+
+  @Test
+  public void executeOnProgress() throws Exception {
+    // Not reported without a callback attached.
+    shadowBugreportManager.executeOnProgress(0.0f);
+
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onProgress(anyFloat());
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.executeOnProgress(50.0f);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback).onProgress(50.0f);
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.executeOnFinished();
+    shadowMainLooper().idle();
+    // Won't be reported after the callback is notified with #onFinished.
+    shadowBugreportManager.executeOnProgress(101.0f);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verify(callback).onFinished();
+    verify(callback, never()).onError(anyInt());
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  public void isBugreportInProgress() throws Exception {
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+
+    BugreportCallback callback = mock(BugreportCallback.class);
+    shadowBugreportManager.startBugreport(
+        createWriteFile("bugreport"),
+        createWriteFile("screenshot"),
+        new BugreportParams(BugreportParams.BUGREPORT_MODE_FULL),
+        directExecutor(),
+        callback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isTrue();
+    verify(callback, never()).onFinished();
+    verify(callback, never()).onError(anyInt());
+
+    shadowBugreportManager.executeOnFinished();
+    shadowMainLooper().idle();
+
+    assertThat(shadowBugreportManager.isBugreportInProgress()).isFalse();
+    verify(callback).onFinished();
+    verify(callback, never()).onError(anyInt());
+  }
+
+  private ParcelFileDescriptor createWriteFile(String fileName) throws IOException {
+    File f = new File(context.getFilesDir(), fileName);
+    if (f.exists()) {
+      f.delete();
+    }
+    f.createNewFile();
+    ParcelFileDescriptor pfd =
+        ParcelFileDescriptor.open(
+            f, ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_APPEND);
+    openFds.add(pfd);
+    return pfd;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBuildTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBuildTest.java
new file mode 100644
index 0000000..6586ad6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBuildTest.java
@@ -0,0 +1,135 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build;
+import android.os.Build.VERSION;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBuildTest {
+
+  @Test
+  public void setDevice() {
+    ShadowBuild.setDevice("test_device");
+    assertThat(Build.DEVICE).isEqualTo("test_device");
+  }
+
+  @Test
+  public void setFingerprint() {
+    ShadowBuild.setFingerprint("test_fingerprint");
+    assertThat(Build.FINGERPRINT).isEqualTo("test_fingerprint");
+  }
+
+  @Test
+  public void getRadioVersion() {
+    ShadowBuild.setRadioVersion("robo_radio");
+    assertThat(Build.getRadioVersion()).isEqualTo("robo_radio");
+  }
+
+  @Test
+  public void setId() {
+    ShadowBuild.setId("robo_id");
+    assertThat(Build.ID).isEqualTo("robo_id");
+  }
+
+  @Test
+  public void setProduct() {
+    ShadowBuild.setProduct("robo_product");
+    assertThat(Build.PRODUCT).isEqualTo("robo_product");
+  }
+
+  @Test
+  public void setVersionRelease() {
+    ShadowBuild.setVersionRelease("robo_release");
+    assertThat(VERSION.RELEASE).isEqualTo("robo_release");
+  }
+
+  @Test
+  public void setVersionIncremental() {
+    ShadowBuild.setVersionIncremental("robo_incremental");
+    assertThat(VERSION.INCREMENTAL).isEqualTo("robo_incremental");
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void setVersionMediaPerformanceClass() {
+    ShadowBuild.setVersionMediaPerformanceClass(R);
+    assertThat(VERSION.MEDIA_PERFORMANCE_CLASS).isEqualTo(R);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setVersionSecurityPatch() {
+    ShadowBuild.setVersionSecurityPatch("2019-02-05");
+    assertThat(VERSION.SECURITY_PATCH).isEqualTo("2019-02-05");
+  }
+
+  @Test
+  public void setModel() {
+    ShadowBuild.setModel("robo_model");
+    assertThat(Build.MODEL).isEqualTo("robo_model");
+  }
+
+  @Test
+  public void setManufacturer() {
+    ShadowBuild.setManufacturer("robo_manufacturer");
+    assertThat(Build.MANUFACTURER).isEqualTo("robo_manufacturer");
+  }
+
+  @Test
+  public void setBrand() {
+    ShadowBuild.setBrand("robo_brand");
+    assertThat(Build.BRAND).isEqualTo("robo_brand");
+  }
+
+  @Test
+  public void setHardware() {
+    ShadowBuild.setHardware("robo_hardware");
+    assertThat(Build.HARDWARE).isEqualTo("robo_hardware");
+  }
+
+  @Test
+  public void setTags() {
+    ShadowBuild.setTags("robo_tags");
+    assertThat(Build.TAGS).isEqualTo("robo_tags");
+  }
+
+  @Test
+  public void setType() {
+    ShadowBuild.setType("robo_type");
+    assertThat(Build.TYPE).isEqualTo("robo_type");
+  }
+
+  @Test
+  public void resetPerTest() {
+    checkValues();
+  }
+
+  @Test
+  public void resetPerTest2() {
+    checkValues();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getSerial() {
+    assertThat(Build.getSerial()).isEqualTo("unknown");
+    ShadowBuild.setSerial("robo_serial");
+    assertThat(Build.getSerial()).isEqualTo("robo_serial");
+  }
+
+  /** Verifies that each test gets a fresh set of Build values. */
+  private void checkValues() {
+    assertThat(Build.FINGERPRINT).isEqualTo("robolectric");
+    // set fingerprint value here. It should be reset before next test executes.
+    ShadowBuild.setFingerprint("test_fingerprint");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBundleTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBundleTest.java
new file mode 100644
index 0000000..4a079c0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBundleTest.java
@@ -0,0 +1,225 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
+import android.os.Parcelable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowBundleTest {
+  private final Bundle bundle = new Bundle();
+
+  @Test
+  public void containsKey() {
+    assertThat(bundle.containsKey("foo")).isFalse();
+    bundle.putString("foo", "bar");
+    assertThat(bundle.containsKey("foo")).isTrue();
+  }
+
+  @Test
+  public void getInt() {
+    bundle.putInt("foo", 5);
+    assertThat(bundle.getInt("foo")).isEqualTo(5);
+    assertThat(bundle.getInt("bar")).isEqualTo(0);
+    assertThat(bundle.getInt("bar", 7)).isEqualTo(7);
+  }
+
+  @Test
+  public void size() {
+    assertThat(bundle.size()).isEqualTo(0);
+    bundle.putInt("foo", 5);
+    assertThat(bundle.size()).isEqualTo(1);
+    bundle.putInt("bar", 5);
+    assertThat(bundle.size()).isEqualTo(2);
+  }
+
+  @Test
+  public void getLong() {
+    bundle.putLong("foo", 5);
+    assertThat(bundle.getLong("foo")).isEqualTo(5);
+    assertThat(bundle.getLong("bar")).isEqualTo(0);
+    assertThat(bundle.getLong("bar", 7)).isEqualTo(7);
+  }
+
+  @Test
+  public void getDouble() {
+    bundle.putDouble("foo", 5);
+    assertThat(bundle.getDouble("foo")).isEqualTo(5.0);
+    assertThat(bundle.getDouble("bar")).isEqualTo(0.0);
+    assertThat(bundle.getDouble("bar", 7)).isEqualTo(7.0);
+  }
+
+  @Test
+  public void getBoolean() {
+    bundle.putBoolean("foo", true);
+    assertThat(bundle.getBoolean("foo")).isTrue();
+    assertThat(bundle.getBoolean("bar")).isFalse();
+    assertThat(bundle.getBoolean("bar", true)).isTrue();
+  }
+
+  @Test
+  public void getFloat() {
+    bundle.putFloat("foo", 5f);
+    assertThat(bundle.getFloat("foo")).isEqualTo(5.0f);
+    assertThat(bundle.getFloat("bar")).isEqualTo(0.0f);
+    assertThat(bundle.getFloat("bar", 7)).isEqualTo(7.0f);
+  }
+
+  @Test
+  public void getWrongType() {
+    bundle.putFloat("foo", 5f);
+    assertThat(bundle.getCharArray("foo")).isNull();
+    assertThat(bundle.getInt("foo")).isEqualTo(0);
+    assertThat(bundle.getIntArray("foo")).isNull();
+    assertThat(bundle.getIntegerArrayList("foo")).isNull();
+    assertThat(bundle.getShort("foo")).isEqualTo((short) 0);
+    assertThat(bundle.getShortArray("foo")).isNull();
+    assertThat(bundle.getBoolean("foo")).isFalse();
+    assertThat(bundle.getBooleanArray("foo")).isNull();
+    assertThat(bundle.getLong("foo")).isEqualTo(0);
+    assertThat(bundle.getLongArray("foo")).isNull();
+    assertThat(bundle.getFloatArray("foo")).isNull();
+    assertThat(bundle.getDouble("foo")).isEqualTo(0.0);
+    assertThat(bundle.getDoubleArray("foo")).isNull();
+    assertThat(bundle.getString("foo")).isNull();
+    assertThat(bundle.getStringArray("foo")).isNull();
+    assertThat(bundle.getStringArrayList("foo")).isNull();
+    assertThat(bundle.getBundle("foo")).isNull();
+    assertThat((Parcelable) bundle.getParcelable("foo")).isNull();
+    assertThat(bundle.getParcelableArray("foo")).isNull();
+    assertThat(bundle.getParcelableArrayList("foo")).isNull();
+
+    bundle.putInt("foo", 1);
+    assertThat(bundle.getFloat("foo")).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void remove() {
+    bundle.putFloat("foo", 5f);
+    bundle.putFloat("foo2", 5f);
+    bundle.remove("foo");
+
+    assertThat(bundle.containsKey("foo")).isFalse();
+    assertThat(bundle.containsKey("foo2")).isTrue();
+  }
+
+  @Test
+  public void clear() {
+    bundle.putFloat("foo", 5f);
+    bundle.clear();
+
+    assertThat(bundle.size()).isEqualTo(0);
+  }
+
+  @Test
+  public void isEmpty() {
+    assertThat(bundle.isEmpty()).isTrue();
+    bundle.putBoolean("foo", true);
+    assertThat(bundle.isEmpty()).isFalse();
+  }
+
+  @Test
+  public void stringArray() {
+    bundle.putStringArray("foo", new String[] { "a" });
+    assertThat(bundle.getStringArray("foo")).isEqualTo(new String[]{"a"});
+    assertThat(bundle.getStringArray("bar")).isNull();
+  }
+
+  @Test
+  public void stringArrayList() {
+    ArrayList<String> list = new ArrayList<>();
+    list.add("a");
+
+    bundle.putStringArrayList("foo", new ArrayList<>(list));
+    assertThat(bundle.getStringArrayList("foo")).isEqualTo(list);
+    assertThat(bundle.getStringArrayList("bar")).isNull();
+  }
+
+  @Test
+  public void intArrayList() {
+    ArrayList<Integer> list = new ArrayList<>();
+    list.add(100);
+
+    bundle.putIntegerArrayList("foo", new ArrayList<>(list));
+    assertThat(bundle.getIntegerArrayList("foo")).isEqualTo(list);
+    assertThat(bundle.getIntegerArrayList("bar")).isNull();
+  }
+
+  @Test
+  public void booleanArray() {
+    boolean [] arr = new boolean[] { false, true };
+    bundle.putBooleanArray("foo", arr);
+
+    assertThat(bundle.getBooleanArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getBooleanArray("bar")).isNull();
+  }
+
+  @Test
+  public void byteArray() {
+    byte [] arr = new byte[] { 12, 24 };
+    bundle.putByteArray("foo", arr);
+
+    assertThat(bundle.getByteArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getByteArray("bar")).isNull();
+  }
+
+  @Test
+  public void charArray() {
+    char [] arr = new char[] { 'c', 'j' };
+    bundle.putCharArray("foo", arr);
+
+    assertThat(bundle.getCharArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getCharArray("bar")).isNull();
+  }
+
+  @Test
+  public void doubleArray() {
+    double [] arr = new double[] { 1.2, 3.4 };
+    bundle.putDoubleArray("foo", arr);
+
+    assertThat(bundle.getDoubleArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getDoubleArray("bar")).isNull();
+  }
+
+  @Test
+  public void intArray() {
+    int [] arr = new int[] { 87, 65 };
+    bundle.putIntArray("foo", arr);
+
+    assertThat(bundle.getIntArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getIntArray("bar")).isNull();
+  }
+
+  @Test
+  public void longArray() {
+    long [] arr = new long[] { 23, 11 };
+    bundle.putLongArray("foo", arr);
+
+    assertThat(bundle.getLongArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getLongArray("bar")).isNull();
+  }
+
+  @Test
+  public void shortArray() {
+    short [] arr = new short[] { 89, 37 };
+    bundle.putShortArray("foo", arr);
+
+    assertThat(bundle.getShortArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getShortArray("bar")).isNull();
+  }
+
+  @Test
+  public void parcelableArray() {
+    Bundle innerBundle = new Bundle();
+    innerBundle.putInt("value", 1);
+    Parcelable[] arr = new Parcelable[] { innerBundle };
+    bundle.putParcelableArray("foo", arr);
+
+    assertThat(bundle.getParcelableArray("foo")).isEqualTo(arr);
+    assertThat(bundle.getParcelableArray("bar")).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCallLogCallsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCallLogCallsTest.java
new file mode 100644
index 0000000..80452e2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCallLogCallsTest.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.provider.CallLog;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test for {@link ShadowCallLogCalls} */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowCallLogCallsTest {
+  private static final String TEST_LAST_CALL = "test last call";
+
+  @Test
+  public void getLastOutgoingCall_default() {
+    assertThat(CallLog.Calls.getLastOutgoingCall(ApplicationProvider.getApplicationContext()))
+        .isNull();
+  }
+
+  @Test
+  public void getLastOutgoingCall_withValue() {
+    ShadowCallLogCalls.setLastOutgoingCall(TEST_LAST_CALL);
+    assertThat(CallLog.Calls.getLastOutgoingCall(ApplicationProvider.getApplicationContext()))
+        .isEqualTo(TEST_LAST_CALL);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCallScreeningServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCallScreeningServiceTest.java
new file mode 100644
index 0000000..523545a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCallScreeningServiceTest.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build;
+import android.telecom.Call;
+import android.telecom.CallScreeningService;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit test for {@link ShadowCallScreeningService}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.N)
+public class ShadowCallScreeningServiceTest {
+  private TestCallScreeningService callScreeningService;
+  private ShadowCallScreeningService shadowCallScreeningService;
+
+  @Before
+  public void setUp() {
+    callScreeningService = new TestCallScreeningService();
+    shadowCallScreeningService = Shadow.extract(callScreeningService);
+  }
+
+  @Test
+  public void getLastRespondToCallInput_whenRespondToCallNotCalled_shouldReturnEmptyOptional() {
+    Optional<ShadowCallScreeningService.RespondToCallInput> lastRespondToCallInputOptional =
+        shadowCallScreeningService.getLastRespondToCallInput();
+    assertThat(lastRespondToCallInputOptional.isPresent()).isFalse();
+  }
+
+  @Test
+  public void getLastRespondToCallInput_shouldReturnTestCallDetails() {
+    // testing with null since instantiating a Call.Details object is tedious and brittle
+    Call.Details testCallDetails = null;
+    callScreeningService.onScreenCall(testCallDetails);
+
+    Optional<ShadowCallScreeningService.RespondToCallInput> lastRespondToCallInputOptional =
+        shadowCallScreeningService.getLastRespondToCallInput();
+    assertThat(lastRespondToCallInputOptional.isPresent()).isTrue();
+    ShadowCallScreeningService.RespondToCallInput respondToCallInput =
+        shadowCallScreeningService.getLastRespondToCallInput().get();
+    assertThat(respondToCallInput.getCallDetails()).isNull();
+  }
+
+  @Test
+  public void getLastRespondToCallInput_shouldReturnTestCallResponse() {
+    Call.Details testCallDetails = null;
+    callScreeningService.onScreenCall(testCallDetails);
+
+    assertThat(shadowCallScreeningService.getLastRespondToCallInput().isPresent()).isTrue();
+    ShadowCallScreeningService.RespondToCallInput respondToCallInput =
+        shadowCallScreeningService.getLastRespondToCallInput().get();
+    assertThat(respondToCallInput.getCallResponse().getRejectCall()).isTrue();
+  }
+
+  private static class TestCallScreeningService extends CallScreeningService {
+    @Override
+    public void onScreenCall(Call.Details details) {
+      CallResponse callResponse =
+          new CallResponse.Builder()
+              .setDisallowCall(true)
+              .setRejectCall(true)
+              .setSkipNotification(true)
+              .build();
+
+      respondToCall(details, callResponse);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCallTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCallTest.java
new file mode 100644
index 0000000..0d7c051
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCallTest.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.os.Build.VERSION_CODES;
+import android.telecom.Call;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Test of ShadowCall. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+public final class ShadowCallTest {
+  Call call;
+  ShadowCall shadowCall;
+
+  @Before
+  public void setUp() throws Exception {
+    call = ReflectionHelpers.callConstructor(Call.class);
+    shadowCall = shadowOf(call);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void sendRttRequest() {
+    call.sendRttRequest();
+
+    assertThat(shadowCall.hasSentRttRequest()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void clearHasSentRttRequest() {
+    call.sendRttRequest();
+
+    shadowCall.clearHasSentRttRequest();
+
+    assertThat(shadowCall.hasSentRttRequest()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void hasRespondedToRttRequest() {
+    call.respondToRttRequest(0, true);
+
+    assertThat(shadowCall.hasRespondedToRttRequest()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraCharacteristicsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraCharacteristicsTest.java
new file mode 100644
index 0000000..22ab0ca
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraCharacteristicsTest.java
@@ -0,0 +1,47 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.hardware.camera2.CameraCharacteristics;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowCameraCharacteristics}. */
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public class ShadowCameraCharacteristicsTest {
+
+  private final CameraCharacteristics.Key key0 =
+      new CameraCharacteristics.Key("key0", Integer.class);
+  private final CameraCharacteristics cameraCharacteristics =
+      ShadowCameraCharacteristics.newCameraCharacteristics();
+
+  @Test
+  public void testSetExistingKey() {
+    shadowOf(cameraCharacteristics).set(key0, 1);
+
+    try {
+      shadowOf(cameraCharacteristics).set(key0, 1);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testGetUnrecognizedKey() {
+    assertThat(cameraCharacteristics.get(key0)).isNull();
+  }
+
+  @Test
+  public void testGetRecognizedKey() {
+    shadowOf(cameraCharacteristics).set(key0, 1);
+
+    assertThat(cameraCharacteristics.get(key0)).isEqualTo(1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraDeviceImplTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraDeviceImplTest.java
new file mode 100644
index 0000000..cbc79a4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraDeviceImplTest.java
@@ -0,0 +1,246 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraCaptureSession.CaptureCallback;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.TotalCaptureResult;
+import android.hardware.camera2.params.OutputConfiguration;
+import android.hardware.camera2.params.SessionConfiguration;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Surface;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.ArrayList;
+import java.util.Collections;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowCameraDeviceImpl}. */
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public final class ShadowCameraDeviceImplTest {
+  private static final String CAMERA_ID_0 = "cameraId0";
+  private final CameraManager cameraManager =
+      (CameraManager)
+          ApplicationProvider.getApplicationContext().getSystemService(Context.CAMERA_SERVICE);
+
+  private final CameraCharacteristics characteristics =
+      ShadowCameraCharacteristics.newCameraCharacteristics();
+  private CameraDevice cameraDevice;
+  private CameraCaptureSession captureSession;
+  private CaptureRequest.Builder builder;
+  private CameraDevice.StateCallback stateCallback;
+
+  @Before
+  public void setUp() throws CameraAccessException {
+    stateCallback = createMockCameraDeviceCallback();
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    cameraManager.openCamera(CAMERA_ID_0, stateCallback, new Handler());
+    shadowOf(Looper.getMainLooper()).idle();
+  }
+
+  @After
+  public void tearDown() throws CameraAccessException {
+    cameraDevice.close();
+    if (captureSession != null) {
+      captureSession.close();
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP, maxSdk = VERSION_CODES.Q)
+  public void createCaptureRequest() throws CameraAccessException {
+    builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
+    CaptureRequest request = builder.build();
+    assertThat(request).isNotNull();
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void createCaptureRequest_throwsIllegalStateExceptionAfterClose()
+      throws CameraAccessException {
+    cameraDevice.close();
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () -> cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD));
+    assertThat(thrown).hasMessageThat().contains("CameraDevice was already closed");
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void createCaptureSession() throws CameraAccessException {
+    builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
+    cameraDevice.createCaptureSession(
+        new ArrayList<>(), new CaptureSessionCallback(/*useExecutor=*/ false), new Handler());
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void createCaptureSession_configuration() throws CameraAccessException {
+    Surface mockSurface = mock(Surface.class);
+    builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
+    builder.addTarget(mockSurface);
+    SessionConfiguration configuration =
+        new SessionConfiguration(
+            SessionConfiguration.SESSION_REGULAR,
+            Collections.singletonList(new OutputConfiguration(mockSurface)),
+            MoreExecutors.directExecutor(),
+            new CaptureSessionCallback(/*useExecutor=*/ true));
+    cameraDevice.createCaptureSession(configuration);
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void createCaptureSession_throwsIllegalStateExceptionAfterClose()
+      throws CameraAccessException {
+    cameraDevice.close();
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                cameraDevice.createCaptureSession(
+                    new ArrayList<>(),
+                    new CaptureSessionCallback(/*useExecutor=*/ false),
+                    new Handler()));
+    assertThat(thrown).hasMessageThat().contains("CameraDevice was already closed");
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void createCaptureSession_configuration_throwsIllegalStateExceptionAfterClose()
+      throws CameraAccessException {
+    cameraDevice.close();
+
+    SessionConfiguration configuration =
+        new SessionConfiguration(
+            SessionConfiguration.SESSION_REGULAR,
+            Collections.singletonList(new OutputConfiguration(mock(Surface.class))),
+            MoreExecutors.directExecutor(),
+            new CaptureSessionCallback(/*useExecutor=*/ true));
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class, () -> cameraDevice.createCaptureSession(configuration));
+    assertThat(thrown).hasMessageThat().contains("CameraDevice was already closed");
+  }
+
+  @Test
+  public void close() {
+    cameraDevice.close();
+    shadowOf(Looper.getMainLooper()).idle();
+    verify(stateCallback).onClosed(eq(cameraDevice));
+  }
+
+  private CameraDevice.StateCallback createMockCameraDeviceCallback() {
+    CameraDevice.StateCallback mockCallback = mock(CameraDevice.StateCallback.class);
+    doAnswer(
+            args -> {
+              cameraDevice = args.getArgument(0);
+              return null;
+            })
+        .when(mockCallback)
+        .onOpened(any(CameraDevice.class));
+    doAnswer(
+            args -> {
+              fail();
+              return null;
+            })
+        .when(mockCallback)
+        .onDisconnected(any(CameraDevice.class));
+    doAnswer(
+            args -> {
+              fail();
+              return null;
+            })
+        .when(mockCallback)
+        .onError(any(CameraDevice.class), anyInt());
+
+    return mockCallback;
+  }
+
+  private class CaptureSessionCallback extends CameraCaptureSession.StateCallback {
+    private final boolean useExecutor;
+
+    /**
+     * Creates a capture session callback that tests capture methods.
+     *
+     * @param useExecutor if true will test the Executor flavor of capture methods, otherwise will
+     *     test the Handler flavor.
+     */
+    public CaptureSessionCallback(boolean useExecutor) {
+      this.useExecutor = useExecutor;
+    }
+
+    @Override
+    public void onConfigured(CameraCaptureSession cameraCaptureSession) {
+      captureSession = cameraCaptureSession;
+      assertThat(captureSession.getDevice().getId()).isEqualTo(CAMERA_ID_0);
+
+      CaptureCallback captureCallback =
+          new CaptureCallback() {
+            @Override
+            public void onCaptureCompleted(
+                CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result) {}
+          };
+
+      try {
+        final int repeatingResponse;
+        if (useExecutor) {
+          repeatingResponse =
+              captureSession.setSingleRepeatingRequest(
+                  builder.build(), MoreExecutors.directExecutor(), captureCallback);
+        } else {
+          repeatingResponse =
+              captureSession.setRepeatingRequest(builder.build(), captureCallback, new Handler());
+        }
+        assertThat(repeatingResponse).isEqualTo(1);
+
+        final int captureResponse;
+        if (useExecutor) {
+          captureResponse =
+              captureSession.captureSingleRequest(
+                  builder.build(), MoreExecutors.directExecutor(), captureCallback);
+        } else {
+          captureResponse = captureSession.capture(builder.build(), captureCallback, new Handler());
+        }
+        assertThat(captureResponse).isEqualTo(1);
+      } catch (CameraAccessException e) {
+        throw new AssertionError("Got CameraAccessException when testing onConfigured", e);
+      }
+    }
+
+    @Override
+    public void onClosed(CameraCaptureSession session) {
+      assertThat(session.getDevice().getId()).isEqualTo(CAMERA_ID_0);
+    }
+
+    @Override
+    public void onConfigureFailed(final CameraCaptureSession cameraCaptureSession) {
+      fail();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraManagerTest.java
new file mode 100644
index 0000000..e6b3caa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraManagerTest.java
@@ -0,0 +1,339 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Looper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowCameraManager}. */
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public class ShadowCameraManagerTest {
+
+  private static final String CAMERA_ID_0 = "cameraId0";
+  private static final String CAMERA_ID_1 = "cameraId1";
+
+  private static final boolean ENABLE = true;
+
+  private final CameraManager cameraManager =
+      (CameraManager)
+          ApplicationProvider.getApplicationContext().getSystemService(Context.CAMERA_SERVICE);
+
+  private final CameraCharacteristics characteristics =
+      ShadowCameraCharacteristics.newCameraCharacteristics();
+
+  @Test
+  public void testAddCameraNullCameraId() {
+    try {
+      shadowOf(cameraManager).addCamera(null, characteristics);
+      fail();
+    } catch (NullPointerException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testAddCameraNullCharacteristics() {
+    try {
+      shadowOf(cameraManager).addCamera(CAMERA_ID_0, null);
+      fail();
+    } catch (NullPointerException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testAddCameraExistingId() {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    try {
+      shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testGetCameraIdListNoCameras() throws CameraAccessException {
+    assertThat(cameraManager.getCameraIdList()).isEmpty();
+  }
+
+  @Test
+  public void testGetCameraIdListSingleCamera() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    assertThat(cameraManager.getCameraIdList()).asList().containsExactly(CAMERA_ID_0);
+  }
+
+  @Test
+  public void testGetCameraIdListInOrderOfAdd() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    shadowOf(cameraManager).addCamera(CAMERA_ID_1, characteristics);
+
+    assertThat(cameraManager.getCameraIdList()[0]).isEqualTo(CAMERA_ID_0);
+    assertThat(cameraManager.getCameraIdList()[1]).isEqualTo(CAMERA_ID_1);
+  }
+
+  @Test
+  public void testGetCameraCharacteristicsNullCameraId() throws CameraAccessException {
+    try {
+      cameraManager.getCameraCharacteristics(null);
+      fail();
+    } catch (NullPointerException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testGetCameraCharacteristicsUnrecognizedCameraId() throws CameraAccessException {
+    try {
+      cameraManager.getCameraCharacteristics(CAMERA_ID_0);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testGetCameraCharacteristicsRecognizedCameraId() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    assertThat(cameraManager.getCameraCharacteristics(CAMERA_ID_0))
+        .isSameInstanceAs(characteristics);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testSetTorchModeInvalidCameraId() throws CameraAccessException {
+    try {
+      cameraManager.setTorchMode(CAMERA_ID_0, ENABLE);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetTorchModeNullCameraId() {
+    try {
+      shadowOf(cameraManager).getTorchMode(null);
+      fail();
+    } catch (NullPointerException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetTorchModeInvalidCameraId() {
+    try {
+      shadowOf(cameraManager).getTorchMode(CAMERA_ID_0);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetTorchModeCameraTorchModeNotSet() {
+    try {
+      shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+      shadowOf(cameraManager).getTorchMode(CAMERA_ID_0);
+    } catch (NullPointerException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetTorchModeCameraTorchModeSet() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    cameraManager.setTorchMode(CAMERA_ID_0, ENABLE);
+    assertThat(shadowOf(cameraManager).getTorchMode(CAMERA_ID_0)).isEqualTo(ENABLE);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void openCamera() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    CameraDevice.StateCallback mockCallback = mock(CameraDevice.StateCallback.class);
+    cameraManager.openCamera(CAMERA_ID_0, mockCallback, new Handler());
+    shadowOf(Looper.myLooper()).idle();
+    verify(mockCallback).onOpened(any(CameraDevice.class));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void triggerDisconnect() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    CameraDevice.StateCallback mockCallback = mock(CameraDevice.StateCallback.class);
+    cameraManager.openCamera(CAMERA_ID_0, mockCallback, new Handler());
+    shadowOf(Looper.myLooper()).idle();
+    ArgumentCaptor<CameraDevice> deviceCaptor = ArgumentCaptor.forClass(CameraDevice.class);
+    verify(mockCallback).onOpened(deviceCaptor.capture());
+    verify(mockCallback, never()).onDisconnected(any(CameraDevice.class));
+
+    shadowOf(cameraManager).triggerDisconnect();
+    shadowOf(Looper.myLooper()).idle();
+    verify(mockCallback).onDisconnected(deviceCaptor.getValue());
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void triggerDisconnect_noCameraOpen() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    shadowOf(cameraManager).triggerDisconnect();
+    // Nothing should happen - just make sure we don't crash.
+  }
+
+  @Test
+  public void testRemoveCameraNullCameraId() {
+    try {
+      shadowOf(cameraManager).removeCamera(null);
+      fail();
+    } catch (NullPointerException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testRemoveCameraNoExistingId() {
+    try {
+      shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void testRemoveCameraAddCameraSucceedsAfterwards() {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+
+    // Repeated call to add CAMERA_ID_0 succeeds and does not throw IllegalArgumentException.
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+  }
+
+  @Test
+  public void testRemoveCameraRemovedCameraIsNotInCameraIdList() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    shadowOf(cameraManager).addCamera(CAMERA_ID_1, characteristics);
+
+    shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+
+    assertThat(cameraManager.getCameraIdList()).hasLength(1);
+    assertThat(cameraManager.getCameraIdList()[0]).isEqualTo(CAMERA_ID_1);
+  }
+
+  @Test
+  public void resetter_closesCameras() throws Exception {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    CameraDevice.StateCallback mockCallback = mock(CameraDevice.StateCallback.class);
+    cameraManager.openCamera(CAMERA_ID_0, mockCallback, new Handler());
+    shadowOf(Looper.myLooper()).idle();
+    ArgumentCaptor<CameraDevice> cameraDeviceCaptor = ArgumentCaptor.forClass(CameraDevice.class);
+    verify(mockCallback).onOpened(cameraDeviceCaptor.capture());
+    ShadowCameraManager.reset();
+    shadowOf(Looper.myLooper()).idle();
+    verify(mockCallback).onClosed(cameraDeviceCaptor.getValue());
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void registerCallbackAvailable() throws CameraAccessException {
+    CameraManager.AvailabilityCallback mockCallback =
+        mock(CameraManager.AvailabilityCallback.class);
+    // Verify adding the camera triggers the callback
+    cameraManager.registerAvailabilityCallback(mockCallback, /* handler = */ null);
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    verify(mockCallback).onCameraAvailable(CAMERA_ID_0);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void unregisterCallbackAvailable() throws CameraAccessException {
+    CameraManager.AvailabilityCallback mockCallback =
+        mock(CameraManager.AvailabilityCallback.class);
+
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+    cameraManager.registerAvailabilityCallback(mockCallback, /* handler = */ null);
+    cameraManager.unregisterAvailabilityCallback(mockCallback);
+
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+
+    verify(mockCallback, never()).onCameraAvailable(CAMERA_ID_0);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void registerCallbackUnavailable() throws CameraAccessException {
+    CameraManager.AvailabilityCallback mockCallback =
+        mock(CameraManager.AvailabilityCallback.class);
+
+    // Verify that the camera unavailable callback is called when the camera is removed
+    cameraManager.registerAvailabilityCallback(mockCallback, /* handler = */ null);
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+
+    verify(mockCallback).onCameraUnavailable(CAMERA_ID_0);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void unregisterCallbackUnavailable() throws CameraAccessException {
+    CameraManager.AvailabilityCallback mockCallback =
+        mock(CameraManager.AvailabilityCallback.class);
+
+    cameraManager.registerAvailabilityCallback(mockCallback, /* handler = */ null);
+    cameraManager.unregisterAvailabilityCallback(mockCallback);
+
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+
+    verify(mockCallback, never()).onCameraUnavailable(CAMERA_ID_0);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void registerCallbackUnavailableInvalidCameraId() throws CameraAccessException {
+    CameraManager.AvailabilityCallback mockCallback =
+        mock(CameraManager.AvailabilityCallback.class);
+
+    // Verify that the callback is not triggered for a camera that was never added
+    cameraManager.registerAvailabilityCallback(mockCallback, /* handler = */ null);
+    try {
+      shadowOf(cameraManager).removeCamera(CAMERA_ID_0);
+    } catch (IllegalArgumentException e) {
+      // Expected path for a bad cameraId
+    }
+
+    verify(mockCallback, never()).onCameraUnavailable(CAMERA_ID_0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraParametersTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraParametersTest.java
new file mode 100644
index 0000000..44d64c7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraParametersTest.java
@@ -0,0 +1,261 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.ImageFormat;
+import android.graphics.Rect;
+import android.hardware.Camera;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Lists;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCameraParametersTest {
+
+  private Camera.Parameters parameters;
+
+  @Before
+  public void setUp() throws Exception {
+    parameters = Shadow.newInstanceOf(Camera.Parameters.class);
+  }
+
+  @Test
+  public void testPictureSize() {
+    assertThat(Shadows.shadowOf(parameters).getPictureHeight()).isNotEqualTo(600);
+    assertThat(Shadows.shadowOf(parameters).getPictureWidth()).isNotEqualTo(800);
+    parameters.setPictureSize(800, 600);
+    Camera.Size pictureSize = parameters.getPictureSize();
+    assertThat(pictureSize.width).isEqualTo(800);
+    assertThat(pictureSize.height).isEqualTo(600);
+    assertThat(Shadows.shadowOf(parameters).getPictureHeight()).isEqualTo(600);
+    assertThat(Shadows.shadowOf(parameters).getPictureWidth()).isEqualTo(800);
+  }
+
+  @Test
+  public void testPreviewFpsRange() {
+    int[] fpsRange = new int[2];
+    parameters.getPreviewFpsRange(fpsRange);
+    assertThat(fpsRange[1]).isNotEqualTo(15);
+    assertThat(fpsRange[0]).isNotEqualTo(25);
+    parameters.setPreviewFpsRange(15, 25);
+    parameters.getPreviewFpsRange(fpsRange);
+    assertThat(fpsRange[1]).isEqualTo(25);
+    assertThat(fpsRange[0]).isEqualTo(15);
+  }
+
+  @Test
+  public void testPreviewFrameRate() {
+    assertThat(parameters.getPreviewFrameRate()).isNotEqualTo(15);
+    parameters.setPreviewFrameRate(15);
+    assertThat(parameters.getPreviewFrameRate()).isEqualTo(15);
+  }
+
+  @Test
+  public void testPreviewSize() {
+    assertThat(Shadows.shadowOf(parameters).getPreviewWidth()).isNotEqualTo(320);
+    assertThat(Shadows.shadowOf(parameters).getPreviewHeight()).isNotEqualTo(240);
+    parameters.setPreviewSize(320, 240);
+    Camera.Size size = parameters.getPreviewSize();
+    assertThat(size.width).isEqualTo(320);
+    assertThat(size.height).isEqualTo(240);
+    assertThat(Shadows.shadowOf(parameters).getPreviewWidth()).isEqualTo(320);
+    assertThat(Shadows.shadowOf(parameters).getPreviewHeight()).isEqualTo(240);
+  }
+
+  @Test
+  public void testPreviewFormat() {
+    assertThat(parameters.getPreviewFormat()).isEqualTo(ImageFormat.NV21);
+    parameters.setPreviewFormat(ImageFormat.JPEG);
+    assertThat(parameters.getPreviewFormat()).isEqualTo(ImageFormat.JPEG);
+  }
+
+  @Test
+  public void testGetSupportedPreviewFormats() {
+    List<Integer> supportedFormats = parameters.getSupportedPreviewFormats();
+    assertThat(supportedFormats).isNotNull();
+    assertThat(supportedFormats.size()).isNotEqualTo(0);
+    assertThat(supportedFormats).contains(ImageFormat.NV21);
+  }
+
+  @Test
+  public void testGetSupportedPictureFormats() {
+    List<Integer> supportedFormats = parameters.getSupportedPictureFormats();
+    assertThat(supportedFormats).isNotNull();
+    assertThat(supportedFormats.size()).isEqualTo(2);
+    assertThat(supportedFormats).contains(ImageFormat.NV21);
+  }
+
+  @Test
+  public void testGetSupportedPictureSizes() {
+    List<Camera.Size> supportedSizes = parameters.getSupportedPictureSizes();
+    assertThat(supportedSizes).isNotNull();
+    assertThat(supportedSizes.size()).isEqualTo(3);
+    assertThat(supportedSizes.get(0).width).isEqualTo(320);
+    assertThat(supportedSizes.get(0).height).isEqualTo(240);
+  }
+
+  @Test
+  public void testGetSupportedPreviewSizes() {
+    List<Camera.Size> supportedSizes = parameters.getSupportedPreviewSizes();
+    assertThat(supportedSizes).isNotNull();
+    assertThat(supportedSizes.size()).isEqualTo(2);
+    assertThat(supportedSizes.get(0).width).isEqualTo(320);
+    assertThat(supportedSizes.get(0).height).isEqualTo(240);
+  }
+
+  @Test
+  public void testInitSupportedPreviewSizes() {
+    Shadows.shadowOf(parameters).initSupportedPreviewSizes();
+    assertThat(parameters.getSupportedPreviewSizes()).isNotNull();
+    assertThat(parameters.getSupportedPreviewSizes()).isEmpty();
+  }
+
+  @Test
+  public void testAddSupportedPreviewSizes() {
+    Shadows.shadowOf(parameters).initSupportedPreviewSizes();
+    Shadows.shadowOf(parameters).addSupportedPreviewSize(320, 240);
+    List<Camera.Size> supportedSizes = parameters.getSupportedPreviewSizes();
+    assertThat(supportedSizes).isNotNull();
+    assertThat(supportedSizes).hasSize(1);
+    assertThat(supportedSizes.get(0).width).isEqualTo(320);
+    assertThat(supportedSizes.get(0).height).isEqualTo(240);
+  }
+
+  @Test
+  public void testGetSupportedPreviewFpsRange() {
+    List<int[]> supportedRanges = parameters.getSupportedPreviewFpsRange();
+    assertThat(supportedRanges).isNotNull();
+    assertThat(supportedRanges.size()).isEqualTo(2);
+    assertThat(supportedRanges.get(0)[0]).isEqualTo(15000);
+    assertThat(supportedRanges.get(0)[1]).isEqualTo(15000);
+    assertThat(supportedRanges.get(1)[0]).isEqualTo(10000);
+    assertThat(supportedRanges.get(1)[1]).isEqualTo(30000);
+  }
+
+  @Test
+  public void testGetSupportedPreviewFrameRates() {
+    List<Integer> supportedRates = parameters.getSupportedPreviewFrameRates();
+    assertThat(supportedRates).isNotNull();
+    assertThat(supportedRates.size()).isEqualTo(3);
+    assertThat(supportedRates.get(0)).isEqualTo(10);
+  }
+
+  @Test
+  public void testExposureCompensationLimits() {
+    assertThat(parameters.getMinExposureCompensation()).isEqualTo(-6);
+    assertThat(parameters.getMaxExposureCompensation()).isEqualTo(6);
+    assertThat(parameters.getExposureCompensationStep()).isEqualTo(0.5f);
+  }
+
+  @Test
+  public void testExposureCompensationSetting() {
+    assertThat(parameters.getExposureCompensation()).isEqualTo(0);
+    parameters.setExposureCompensation(5);
+    assertThat(parameters.getExposureCompensation()).isEqualTo(5);
+  }
+
+  @Test
+  public void testGetSupportedFocusModesDefaultValue() {
+    List<String> supportedFocusModes = parameters.getSupportedFocusModes();
+    assertThat(supportedFocusModes).isEmpty();
+  }
+
+  @Test
+  public void testSetSupportedFocusModes() {
+    Shadows.shadowOf(parameters).setSupportedFocusModes("foo", "bar");
+    assertThat(parameters.getSupportedFocusModes()).isEqualTo(Lists.newArrayList("foo", "bar"));
+    Shadows.shadowOf(parameters).setSupportedFocusModes("baz");
+    assertThat(parameters.getSupportedFocusModes()).isEqualTo(Lists.newArrayList("baz"));
+  }
+
+  @Test
+  public void testSetAndGetFocusMode() {
+    parameters.setFocusMode("foo");
+    assertThat(parameters.getFocusMode()).isEqualTo("foo");
+  }
+
+  @Test
+  public void testGetSupportedFlashModesDefaultValue() {
+    List<String> supportedFlashModes = parameters.getSupportedFlashModes();
+    assertThat(supportedFlashModes).isEmpty();
+  }
+
+  @Test
+  public void testSetSupportedFlashModes() {
+    Shadows.shadowOf(parameters).setSupportedFlashModes("foo", "bar");
+    assertThat(parameters.getSupportedFlashModes()).containsExactly("foo", "bar").inOrder();
+    Shadows.shadowOf(parameters).setSupportedFlashModes("baz");
+    assertThat(parameters.getSupportedFlashModes()).containsExactly("baz");
+  }
+
+  @Test
+  public void testSetAndGetFlashMode() {
+    parameters.setFlashMode("foo");
+    assertThat(parameters.getFlashMode()).isEqualTo("foo");
+  }
+
+  @Test
+  public void testGetMaxNumFocusAreasDefaultValue() {
+    assertThat(parameters.getMaxNumFocusAreas()).isEqualTo(0);
+  }
+
+  @Test
+  public void testSetAndGetMaxNumFocusAreas() {
+    Shadows.shadowOf(parameters).setMaxNumFocusAreas(22);
+    assertThat(parameters.getMaxNumFocusAreas()).isEqualTo(22);
+  }
+
+  @Test
+  public void testSetAndGetFocusAreas() {
+    List<Camera.Area> focusAreas1 = Collections.singletonList(new Camera.Area(new Rect(), 1));
+    parameters.setFocusAreas(focusAreas1);
+    assertThat(parameters.getFocusAreas()).isEqualTo(focusAreas1);
+
+    List<Camera.Area> focusAreas2 =
+        Arrays.asList(new Camera.Area(new Rect(), 2), new Camera.Area(new Rect(), 3));
+    parameters.setFocusAreas(focusAreas2);
+    assertThat(parameters.getFocusAreas()).isEqualTo(focusAreas2);
+  }
+
+  @Test
+  public void testGetMaxNumMeteringAreasDefaultValue() {
+    assertThat(parameters.getMaxNumFocusAreas()).isEqualTo(0);
+  }
+
+  @Test
+  public void testSetAndGetMaxNumMeteringAreas() {
+    Shadows.shadowOf(parameters).setMaxNumMeteringAreas(222);
+    assertThat(parameters.getMaxNumMeteringAreas()).isEqualTo(222);
+  }
+
+  @Test
+  public void testSetAndGetMaxMeteringAreas() {
+    List<Camera.Area> meteringAreas1 = Collections.singletonList(new Camera.Area(new Rect(), 1));
+    parameters.setMeteringAreas(meteringAreas1);
+    assertThat(parameters.getMeteringAreas()).isEqualTo(meteringAreas1);
+
+    List<Camera.Area> meteringAreas2 =
+        Arrays.asList(new Camera.Area(new Rect(), 2), new Camera.Area(new Rect(), 3));
+    parameters.setMeteringAreas(meteringAreas2);
+    assertThat(parameters.getMeteringAreas()).isEqualTo(meteringAreas2);
+  }
+
+  @Test
+  public void testSetAndGetCustomParams() {
+    String key = "key";
+    String value1 = "value1";
+    parameters.set(key, value1);
+    assertThat(parameters.get(key)).isEqualTo(value1);
+
+    String value2 = "value2";
+    parameters.set(key, value2);
+    assertThat(parameters.get(key)).isEqualTo(value2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraSizeTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraSizeTest.java
new file mode 100644
index 0000000..243595f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraSizeTest.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.Camera;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCameraSizeTest {
+
+  private Camera.Size cameraSize;
+
+  @Before
+  public void setUp() throws Exception {
+    cameraSize = Shadow.newInstanceOf(Camera.class).new Size(480, 320);
+  }
+
+  @Test
+  public void testConstructor() {
+    assertThat(cameraSize.width).isEqualTo(480);
+    assertThat(cameraSize.height).isEqualTo(320);
+  }
+
+  @Test
+  public void testSetWidth() {
+    assertThat(cameraSize.width).isNotEqualTo(640);
+    cameraSize.width = 640;
+    assertThat(cameraSize.width).isEqualTo(640);
+  }
+
+  @Test
+  public void testSetHeight() {
+    assertThat(cameraSize.height).isNotEqualTo(480);
+    cameraSize.height = 480;
+    assertThat(cameraSize.height).isEqualTo(480);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraTest.java
new file mode 100644
index 0000000..baa064f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCameraTest.java
@@ -0,0 +1,438 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.fail;
+
+import android.graphics.Canvas;
+import android.graphics.ImageFormat;
+import android.graphics.Rect;
+import android.hardware.Camera;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCameraTest {
+
+  private Camera camera;
+  private ShadowCamera shadowCamera;
+
+  @Before
+  public void setUp() throws Exception {
+    camera = Camera.open();
+    shadowCamera = Shadows.shadowOf(camera);
+  }
+
+  @After
+  public void tearDown() {
+    ShadowCamera.clearCameraInfo();
+  }
+
+  @Test
+  public void testOpen() {
+    assertThat(camera).isNotNull();
+    assertThat(ShadowCamera.getLastOpenedCameraId()).isEqualTo(0);
+  }
+
+  @Test
+  public void testOpenWithId() {
+    camera = Camera.open(12);
+    assertThat(camera).isNotNull();
+    assertThat(ShadowCamera.getLastOpenedCameraId()).isEqualTo(12);
+  }
+
+  @Test
+  public void testUnlock() {
+    assertThat(shadowCamera.isLocked()).isTrue();
+    camera.unlock();
+    assertThat(shadowCamera.isLocked()).isFalse();
+  }
+
+  @Test
+  public void testReconnect() throws Exception {
+    camera.unlock();
+    assertThat(shadowCamera.isLocked()).isFalse();
+    camera.reconnect();
+    assertThat(shadowCamera.isLocked()).isTrue();
+  }
+
+  @Test
+  public void testGetParameters() {
+    Camera.Parameters parameters = camera.getParameters();
+    assertThat(parameters).isNotNull();
+    assertThat(parameters.getSupportedPreviewFormats()).isNotNull();
+    assertThat(parameters.getSupportedPreviewFormats().size()).isNotEqualTo(0);
+  }
+
+  @Test
+  public void testSetParameters() {
+    Camera.Parameters parameters = camera.getParameters();
+    assertThat(parameters.getPreviewFormat()).isEqualTo(ImageFormat.NV21);
+    parameters.setPreviewFormat(ImageFormat.JPEG);
+    camera.setParameters(parameters);
+    assertThat(camera.getParameters().getPreviewFormat()).isEqualTo(ImageFormat.JPEG);
+  }
+
+  @Test
+  public void testSetPreviewDisplay() throws Exception {
+    SurfaceHolder previewSurfaceHolder = new TestSurfaceHolder();
+    camera.setPreviewDisplay(previewSurfaceHolder);
+    assertThat(shadowCamera.getPreviewDisplay()).isSameInstanceAs(previewSurfaceHolder);
+  }
+
+  @Test
+  public void testStartPreview() {
+    assertThat(shadowCamera.isPreviewing()).isFalse();
+    camera.startPreview();
+    assertThat(shadowCamera.isPreviewing()).isTrue();
+  }
+
+  @Test
+  public void testStopPreview() {
+    camera.startPreview();
+    assertThat(shadowCamera.isPreviewing()).isTrue();
+    camera.stopPreview();
+    assertThat(shadowCamera.isPreviewing()).isFalse();
+  }
+
+  @Test
+  public void testRelease() {
+    assertThat(shadowCamera.isReleased()).isFalse();
+    camera.release();
+    assertThat(shadowCamera.isReleased()).isTrue();
+  }
+
+  @Test
+  public void testSetPreviewCallbacks() {
+    TestPreviewCallback callback = new TestPreviewCallback();
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+
+    camera.setPreviewCallback(callback);
+    shadowCamera.invokePreviewCallback("foobar".getBytes(UTF_8));
+
+    assertThat(callback.camera).isSameInstanceAs(camera);
+    assertThat(callback.data).isEqualTo("foobar".getBytes(UTF_8));
+  }
+
+  @Test
+  public void testSetOneShotPreviewCallbacks() {
+    TestPreviewCallback callback = new TestPreviewCallback();
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+
+    camera.setOneShotPreviewCallback(callback);
+    shadowCamera.invokePreviewCallback("foobar".getBytes(UTF_8));
+
+    assertThat(callback.camera).isSameInstanceAs(camera);
+    assertThat(callback.data).isEqualTo("foobar".getBytes(UTF_8));
+  }
+
+  @Test
+  public void testPreviewCallbacksWithBuffers() {
+    TestPreviewCallback callback = new TestPreviewCallback();
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+
+    camera.setPreviewCallbackWithBuffer(callback);
+    shadowCamera.invokePreviewCallback("foobar".getBytes(UTF_8));
+
+    assertThat(callback.camera).isSameInstanceAs(camera);
+    assertThat(callback.data).isEqualTo("foobar".getBytes(UTF_8));
+  }
+
+  @Test
+  public void testClearPreviewCallback() {
+    TestPreviewCallback callback = new TestPreviewCallback();
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+
+    camera.setPreviewCallback(callback);
+    camera.setPreviewCallback(null);
+
+    shadowCamera.invokePreviewCallback("foobar".getBytes(UTF_8));
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+
+    camera.setOneShotPreviewCallback(callback);
+    camera.setOneShotPreviewCallback(null);
+
+    shadowCamera.invokePreviewCallback("foobar".getBytes(UTF_8));
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+
+    camera.setPreviewCallbackWithBuffer(callback);
+    camera.setPreviewCallbackWithBuffer(null);
+
+    shadowCamera.invokePreviewCallback("foobar".getBytes(UTF_8));
+    assertThat(callback.camera).isNull();
+    assertThat(callback.data).isNull();
+  }
+
+  @Test
+  public void testAddCallbackBuffer() {
+    byte[] buf1 = new byte[0];
+    byte[] buf2 = new byte[1];
+    camera.addCallbackBuffer(buf1);
+    assertThat(shadowCamera.getAddedCallbackBuffers()).containsExactly(buf1);
+    camera.addCallbackBuffer(buf2);
+    assertThat(shadowCamera.getAddedCallbackBuffers()).containsExactly(buf1, buf2);
+  }
+
+  @Test
+  public void testDisplayOrientation() {
+    camera.setDisplayOrientation(180);
+    assertThat(shadowCamera.getDisplayOrientation()).isEqualTo(180);
+  }
+
+  @Test
+  public void testSetDisplayOrientationUpdatesCameraInfos() {
+    addBackCamera();
+    addFrontCamera();
+
+    camera = Camera.open(1);
+    camera.setDisplayOrientation(180);
+
+    Camera.CameraInfo cameraQuery = new Camera.CameraInfo();
+    Camera.getCameraInfo(ShadowCamera.getLastOpenedCameraId(), cameraQuery);
+    assertThat(cameraQuery.orientation).isEqualTo(180);
+  }
+
+  @Test
+  public void testAutoFocus() {
+    assertThat(shadowCamera.hasRequestedAutoFocus()).isFalse();
+    TestAutoFocusCallback callback = new TestAutoFocusCallback();
+
+    camera.autoFocus(callback);
+
+    assertThat(shadowCamera.hasRequestedAutoFocus()).isTrue();
+    shadowCamera.invokeAutoFocusCallback(true, camera);
+    assertThat(callback.success).isEqualTo(true);
+    assertThat(callback.camera).isEqualTo(camera);
+
+    assertThat(shadowCamera.hasRequestedAutoFocus()).isFalse();
+    try {
+      shadowCamera.invokeAutoFocusCallback(true, camera);
+      fail("expected an IllegalStateException");
+    } catch (IllegalStateException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testInvokeAutoFocusCallbackMissing() {
+    try {
+      shadowCamera.invokeAutoFocusCallback(true, camera);
+      fail("expected an IllegalStateException");
+    } catch (IllegalStateException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testCancelAutoFocus() {
+    assertThat(shadowCamera.hasRequestedAutoFocus()).isFalse();
+    camera.autoFocus(null);
+    assertThat(shadowCamera.hasRequestedAutoFocus()).isTrue();
+    camera.cancelAutoFocus();
+    assertThat(shadowCamera.hasRequestedAutoFocus()).isFalse();
+  }
+
+  @Test
+  public void testCameraInfoNoCameras() {
+    assertThat(Camera.getNumberOfCameras()).isEqualTo(0);
+  }
+
+  @Test
+  public void testCameraInfoBackOnly() {
+    Camera.CameraInfo cameraQuery = new Camera.CameraInfo();
+
+    addBackCamera();
+    Camera.getCameraInfo(0, cameraQuery);
+
+    assertThat(Camera.getNumberOfCameras()).isEqualTo(1);
+    assertThat(cameraQuery.facing).isEqualTo(Camera.CameraInfo.CAMERA_FACING_BACK);
+    assertThat(cameraQuery.orientation).isEqualTo(0);
+  }
+
+  @Test
+  public void testCameraInfoBackAndFront() {
+    Camera.CameraInfo cameraQuery = new Camera.CameraInfo();
+    addBackCamera();
+    addFrontCamera();
+
+    assertThat(Camera.getNumberOfCameras()).isEqualTo(2);
+    Camera.getCameraInfo(0, cameraQuery);
+    assertThat(cameraQuery.facing).isEqualTo(Camera.CameraInfo.CAMERA_FACING_BACK);
+    assertThat(cameraQuery.orientation).isEqualTo(0);
+    Camera.getCameraInfo(1, cameraQuery);
+    assertThat(cameraQuery.facing).isEqualTo(Camera.CameraInfo.CAMERA_FACING_FRONT);
+    assertThat(cameraQuery.orientation).isEqualTo(90);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testCameraInfoShutterSound() {
+    Camera.CameraInfo cameraQueryCannotDisable = new Camera.CameraInfo();
+    Camera.CameraInfo cameraInfoCannotDisable = new Camera.CameraInfo();
+    cameraInfoCannotDisable.canDisableShutterSound = false;
+    ShadowCamera.addCameraInfo(0, cameraInfoCannotDisable);
+
+    Camera.CameraInfo cameraQueryCanDisable = new Camera.CameraInfo();
+    Camera.CameraInfo cameraInfoCanDisable = new Camera.CameraInfo();
+    cameraInfoCanDisable.canDisableShutterSound = true;
+    ShadowCamera.addCameraInfo(1, cameraInfoCanDisable);
+
+    assertThat(Camera.getNumberOfCameras()).isEqualTo(2);
+    Camera.getCameraInfo(0, cameraQueryCannotDisable);
+    assertThat(cameraQueryCannotDisable.canDisableShutterSound).isFalse();
+    Camera.getCameraInfo(1, cameraQueryCanDisable);
+    assertThat(cameraQueryCanDisable.canDisableShutterSound).isTrue();
+  }
+
+  @Test
+  public void testTakePicture() {
+    camera.takePicture(null, null, null);
+
+    TestShutterCallback shutterCallback = new TestShutterCallback();
+    TestPictureCallback rawCallback = new TestPictureCallback();
+    TestPictureCallback jpegCallback = new TestPictureCallback();
+    camera.takePicture(shutterCallback, rawCallback, jpegCallback);
+
+    assertThat(shutterCallback.wasCalled).isTrue();
+    assertThat(rawCallback.wasCalled).isTrue();
+    assertThat(jpegCallback.wasCalled).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testShutterEnabled() {
+    Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
+    cameraInfo.facing = Camera.CameraInfo.CAMERA_FACING_BACK;
+    cameraInfo.canDisableShutterSound = false;
+    ShadowCamera.addCameraInfo(0, cameraInfo);
+
+    assertThat(Camera.getNumberOfCameras()).isEqualTo(1);
+    assertThat(shadowCamera.enableShutterSound(true)).isTrue();
+    assertThat(shadowCamera.enableShutterSound(false)).isFalse();
+
+    cameraInfo.canDisableShutterSound = true;
+    assertThat(shadowCamera.enableShutterSound(true)).isTrue();
+    assertThat(shadowCamera.enableShutterSound(false)).isTrue();
+    assertThat(shadowCamera.enableShutterSound(true)).isTrue();
+  }
+
+  private void addBackCamera() {
+    Camera.CameraInfo backCamera = new Camera.CameraInfo();
+    backCamera.facing = Camera.CameraInfo.CAMERA_FACING_BACK;
+    backCamera.orientation = 0;
+    ShadowCamera.addCameraInfo(0, backCamera);
+  }
+
+  private void addFrontCamera() {
+    Camera.CameraInfo frontCamera = new Camera.CameraInfo();
+    frontCamera.facing = Camera.CameraInfo.CAMERA_FACING_FRONT;
+    frontCamera.orientation = 90;
+    ShadowCamera.addCameraInfo(1, frontCamera);
+  }
+
+  private static class TestPreviewCallback implements Camera.PreviewCallback {
+    public Camera camera = null;
+    public byte[] data = null;
+
+    @Override
+    public void onPreviewFrame(byte[] data, Camera camera) {
+      this.data = data;
+      this.camera = camera;
+    }
+  }
+
+  private static class TestAutoFocusCallback implements Camera.AutoFocusCallback {
+    public boolean success;
+    public Camera camera;
+
+    @Override
+    public void onAutoFocus(boolean success, Camera camera) {
+      this.success = success;
+      this.camera = camera;
+    }
+  }
+
+  private static class TestShutterCallback implements Camera.ShutterCallback {
+    public boolean wasCalled;
+
+    @Override
+    public void onShutter() {
+      wasCalled = true;
+    }
+  }
+
+  private static class TestPictureCallback implements Camera.PictureCallback {
+    public boolean wasCalled;
+
+    @Override
+    public void onPictureTaken(byte[] data, Camera camera) {
+      wasCalled = true;
+    }
+  }
+
+  private static class TestSurfaceHolder implements SurfaceHolder {
+
+    @Override
+    public void addCallback(Callback callback) {}
+
+    @Override
+    public Surface getSurface() {
+      return null;
+    }
+
+    @Override
+    public Rect getSurfaceFrame() {
+      return null;
+    }
+
+    @Override
+    public boolean isCreating() {
+      return false;
+    }
+
+    @Override
+    public Canvas lockCanvas() {
+      return null;
+    }
+
+    @Override
+    public Canvas lockCanvas(Rect dirty) {
+      return null;
+    }
+
+    @Override
+    public void removeCallback(Callback callback) {}
+
+    @Override
+    public void setFixedSize(int width, int height) {}
+
+    @Override
+    public void setFormat(int format) {}
+
+    @Override
+    public void setKeepScreenOn(boolean screenOn) {}
+
+    @Override
+    public void setSizeFromLayout() {}
+
+    @Override
+    public void setType(int type) {}
+
+    @Override
+    public void unlockCanvasAndPost(Canvas canvas) {}
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCanvasTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCanvasTest.java
new file mode 100644
index 0000000..d4f95fa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCanvasTest.java
@@ -0,0 +1,591 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowPath.Point.Type.LINE_TO;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowCanvas.RoundRectPaintHistoryEvent;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCanvasTest {
+  private Bitmap targetBitmap;
+  private Bitmap imageBitmap;
+
+  @Before
+  public void setUp() throws Exception {
+    targetBitmap = Shadow.newInstanceOf(Bitmap.class);
+    imageBitmap = BitmapFactory.decodeFile("/an/image.jpg");
+  }
+
+  @Test
+  public void shouldDescribeBitmapDrawing() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    canvas.drawBitmap(imageBitmap, 1, 2, new Paint());
+    canvas.drawBitmap(imageBitmap, 100, 200, new Paint());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg at (1,2)\n" + "Bitmap for file:/an/image.jpg at (100,200)",
+        shadowOf(canvas).getDescription());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg at (1,2)\n" + "Bitmap for file:/an/image.jpg at (100,200)",
+        shadowOf(targetBitmap).getDescription());
+  }
+
+  @Test
+  public void shouldDescribeBitmapDrawing_withDestinationRect() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    canvas.drawBitmap(imageBitmap, new Rect(1, 2, 3, 4), new Rect(5, 6, 7, 8), new Paint());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg at (5,6) with height=2 and width=2 taken from"
+            + " Rect(1, 2 - 3, 4)",
+        shadowOf(canvas).getDescription());
+  }
+
+  @Test
+  public void shouldDescribeBitmapDrawing_withDestinationRectF() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    canvas.drawBitmap(
+        imageBitmap, new Rect(1, 2, 3, 4), new RectF(5.0f, 6.0f, 7.5f, 8.5f), new Paint());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg at (5.0,6.0) with height=2.5 and width=2.5 taken"
+            + " from Rect(1, 2 - 3, 4)",
+        shadowOf(canvas).getDescription());
+  }
+
+  @Test
+  public void shouldDescribeBitmapDrawing_WithMatrix() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    canvas.drawBitmap(imageBitmap, new Matrix(), new Paint());
+    canvas.drawBitmap(imageBitmap, new Matrix(), new Paint());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg transformed by Matrix[pre=[], set={}, post=[]]\n"
+            + "Bitmap for file:/an/image.jpg transformed by Matrix[pre=[], set={},"
+            + " post=[]]",
+        shadowOf(canvas).getDescription());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg transformed by Matrix[pre=[], set={}, post=[]]\n"
+            + "Bitmap for file:/an/image.jpg transformed by Matrix[pre=[], set={},"
+            + " post=[]]",
+        shadowOf(targetBitmap).getDescription());
+  }
+
+  @Test
+  public void visualize_shouldReturnDescription() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    canvas.drawBitmap(imageBitmap, new Matrix(), new Paint());
+    canvas.drawBitmap(imageBitmap, new Matrix(), new Paint());
+
+    assertEquals(
+        "Bitmap for file:/an/image.jpg transformed by Matrix[pre=[], set={}, post=[]]\n"
+            + "Bitmap for file:/an/image.jpg transformed by Matrix[pre=[], set={},"
+            + " post=[]]",
+        ShadowCanvas.visualize(canvas));
+  }
+
+  @Test
+  public void drawColor_shouldReturnDescription() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    canvas.drawColor(Color.WHITE);
+    canvas.drawColor(Color.GREEN);
+    canvas.drawColor(Color.TRANSPARENT);
+    assertEquals(
+        "draw color -1draw color -16711936draw color 0", shadowOf(canvas).getDescription());
+  }
+
+  @Test
+  public void drawPath_shouldRecordThePathAndThePaint() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    Path path = new Path();
+    path.lineTo(10, 10);
+
+    Paint paint = new Paint();
+    paint.setColor(Color.RED);
+    paint.setAlpha(7);
+    canvas.drawPath(path, paint);
+
+    // changing the values on this Paint shouldn't affect recorded painted path
+    paint.setColor(Color.BLUE);
+    paint.setAlpha(8);
+
+    ShadowCanvas shadow = shadowOf(canvas);
+    assertThat(shadow.getPathPaintHistoryCount()).isEqualTo(1);
+    ShadowPath drawnPath = shadowOf(shadow.getDrawnPath(0));
+    assertEquals(drawnPath.getPoints().get(0), new ShadowPath.Point(10, 10, LINE_TO));
+    Paint drawnPathPaint = shadow.getDrawnPathPaint(0);
+    assertThat(drawnPathPaint.getColor()).isEqualTo(Color.RED);
+    assertThat(drawnPathPaint.getAlpha()).isEqualTo(7);
+  }
+
+  @Test
+  public void drawPath_shouldRecordThePointsOfEachPathEvenWhenItIsTheSameInstance()
+      throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    Paint paint = new Paint();
+    Path path = new Path();
+
+    path.lineTo(10, 10);
+    canvas.drawPath(path, paint);
+
+    path.reset();
+    path.lineTo(20, 20);
+    canvas.drawPath(path, paint);
+
+    ShadowCanvas shadow = shadowOf(canvas);
+    assertThat(shadow.getPathPaintHistoryCount()).isEqualTo(2);
+    assertEquals(
+        shadowOf(shadow.getDrawnPath(0)).getPoints().get(0), new ShadowPath.Point(10, 10, LINE_TO));
+    assertEquals(
+        shadowOf(shadow.getDrawnPath(1)).getPoints().get(0), new ShadowPath.Point(20, 20, LINE_TO));
+  }
+
+  @Test
+  public void drawPath_shouldAppendDescriptionToBitmap() throws Exception {
+    Canvas canvas = new Canvas(targetBitmap);
+    Path path1 = new Path();
+    path1.lineTo(10, 10);
+    path1.moveTo(20, 15);
+    Path path2 = new Path();
+    path2.moveTo(100, 100);
+    path2.lineTo(150, 140);
+
+    Paint paint = new Paint();
+    canvas.drawPath(path1, paint);
+    canvas.drawPath(path2, paint);
+
+    assertEquals(
+        "Path "
+            + shadowOf(path1).getPoints().toString()
+            + "\n"
+            + "Path "
+            + shadowOf(path2).getPoints().toString(),
+        shadowOf(canvas).getDescription());
+
+    assertEquals(
+        "Path "
+            + shadowOf(path1).getPoints().toString()
+            + "\n"
+            + "Path "
+            + shadowOf(path2).getPoints().toString(),
+        shadowOf(targetBitmap).getDescription());
+  }
+
+  @Test
+  public void resetCanvasHistory_shouldClearTheHistoryAndDescription() throws Exception {
+    Canvas canvas = new Canvas();
+    canvas.drawPath(new Path(), new Paint());
+    canvas.drawText("hi", 1, 2, new Paint());
+
+    ShadowCanvas shadow = shadowOf(canvas);
+    shadow.resetCanvasHistory();
+
+    assertThat(shadow.getPathPaintHistoryCount()).isEqualTo(0);
+    assertThat(shadow.getTextHistoryCount()).isEqualTo(0);
+    assertEquals("", shadow.getDescription());
+  }
+
+  @Test
+  public void shouldGetAndSetHeightAndWidth() throws Exception {
+    Canvas canvas = new Canvas();
+    shadowOf(canvas).setWidth(99);
+    shadowOf(canvas).setHeight(42);
+
+    assertEquals(99, canvas.getWidth());
+    assertEquals(42, canvas.getHeight());
+  }
+
+  @Test
+  public void shouldRecordText() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint = new Paint();
+    Paint paint2 = new Paint();
+    paint.setColor(1);
+    paint2.setColor(5);
+    canvas.drawText("hello", 1, 2, paint);
+    canvas.drawText("hello 2", 4, 6, paint2);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getTextHistoryCount()).isEqualTo(2);
+
+    assertEquals(1f, shadowCanvas.getDrawnTextEvent(0).x, 0);
+    assertEquals(2f, shadowCanvas.getDrawnTextEvent(0).y, 0);
+    assertEquals(4f, shadowCanvas.getDrawnTextEvent(1).x, 0);
+    assertEquals(6f, shadowCanvas.getDrawnTextEvent(1).y, 0);
+
+    assertEquals(paint, shadowCanvas.getDrawnTextEvent(0).paint);
+    assertEquals(paint2, shadowCanvas.getDrawnTextEvent(1).paint);
+
+    assertEquals("hello", shadowCanvas.getDrawnTextEvent(0).text);
+    assertEquals("hello 2", shadowCanvas.getDrawnTextEvent(1).text);
+  }
+
+  @Test
+  public void shouldRecordText_charArrayOverload() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint = new Paint();
+    paint.setColor(1);
+    canvas.drawText(new char[] {'h', 'e', 'l', 'l', 'o'}, 2, 3, 1f, 2f, paint);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getTextHistoryCount()).isEqualTo(1);
+
+    assertEquals(1f, shadowCanvas.getDrawnTextEvent(0).x, 0);
+    assertEquals(2f, shadowCanvas.getDrawnTextEvent(0).y, 0);
+
+    assertEquals(paint, shadowCanvas.getDrawnTextEvent(0).paint);
+
+    assertEquals("llo", shadowCanvas.getDrawnTextEvent(0).text);
+  }
+
+  @Test
+  public void shouldRecordText_stringWithRangeOverload() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint = new Paint();
+    paint.setColor(1);
+    canvas.drawText("hello", 1, 4, 1f, 2f, paint);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getTextHistoryCount()).isEqualTo(1);
+
+    assertEquals(1f, shadowCanvas.getDrawnTextEvent(0).x, 0);
+    assertEquals(2f, shadowCanvas.getDrawnTextEvent(0).y, 0);
+
+    assertEquals(paint, shadowCanvas.getDrawnTextEvent(0).paint);
+
+    assertEquals("ell", shadowCanvas.getDrawnTextEvent(0).text);
+  }
+
+  @Test
+  public void shouldRecordText_charSequenceOverload() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint = new Paint();
+    paint.setColor(1);
+    // StringBuilder implements CharSequence:
+    canvas.drawText(new StringBuilder("hello"), 1, 4, 1f, 2f, paint);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getTextHistoryCount()).isEqualTo(1);
+
+    assertEquals(1f, shadowCanvas.getDrawnTextEvent(0).x, 0);
+    assertEquals(2f, shadowCanvas.getDrawnTextEvent(0).y, 0);
+
+    assertEquals(paint, shadowCanvas.getDrawnTextEvent(0).paint);
+
+    assertEquals("ell", shadowCanvas.getDrawnTextEvent(0).text);
+  }
+
+  @Test
+  public void drawCircle_shouldRecordCirclePaintHistoryEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint0 = new Paint();
+    Paint paint1 = new Paint();
+    canvas.drawCircle(1, 2, 3, paint0);
+    canvas.drawCircle(4, 5, 6, paint1);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getDrawnCircle(0).centerX).isEqualTo(1.0f);
+    assertThat(shadowCanvas.getDrawnCircle(0).centerY).isEqualTo(2.0f);
+    assertThat(shadowCanvas.getDrawnCircle(0).radius).isEqualTo(3.0f);
+    assertThat(shadowCanvas.getDrawnCircle(0).paint).isSameInstanceAs(paint0);
+
+    assertThat(shadowCanvas.getDrawnCircle(1).centerX).isEqualTo(4.0f);
+    assertThat(shadowCanvas.getDrawnCircle(1).centerY).isEqualTo(5.0f);
+    assertThat(shadowCanvas.getDrawnCircle(1).radius).isEqualTo(6.0f);
+    assertThat(shadowCanvas.getDrawnCircle(1).paint).isSameInstanceAs(paint1);
+  }
+
+  @Test
+  public void drawArc_shouldRecordArcHistoryEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    RectF oval0 = new RectF();
+    RectF oval1 = new RectF();
+    Paint paint0 = new Paint();
+    Paint paint1 = new Paint();
+    canvas.drawArc(oval0, 1f, 2f, true, paint0);
+    canvas.drawArc(oval1, 3f, 4f, false, paint1);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getDrawnArc(0).oval).isEqualTo(oval0);
+    assertThat(shadowCanvas.getDrawnArc(0).startAngle).isEqualTo(1f);
+    assertThat(shadowCanvas.getDrawnArc(0).sweepAngle).isEqualTo(2f);
+    assertThat(shadowCanvas.getDrawnArc(0).useCenter).isTrue();
+    assertThat(shadowCanvas.getDrawnArc(0).paint).isSameInstanceAs(paint0);
+
+    assertThat(shadowCanvas.getDrawnArc(1).oval).isEqualTo(oval1);
+    assertThat(shadowCanvas.getDrawnArc(1).startAngle).isEqualTo(3f);
+    assertThat(shadowCanvas.getDrawnArc(1).sweepAngle).isEqualTo(4f);
+    assertThat(shadowCanvas.getDrawnArc(1).useCenter).isFalse();
+    assertThat(shadowCanvas.getDrawnArc(1).paint).isSameInstanceAs(paint1);
+  }
+
+  @Test
+  public void getArcHistoryCount_shouldReturnTotalNumberOfDrawArcEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    canvas.drawArc(new RectF(), 0f, 0f, true, new Paint());
+    canvas.drawArc(new RectF(), 0f, 0f, true, new Paint());
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+    assertThat(shadowCanvas.getArcPaintHistoryCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void getRectHistoryCount_shouldReturnTotalNumberOfDrawRectEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    canvas.drawRect(1f, 2f, 3f, 4f, new Paint());
+    canvas.drawRect(1f, 2f, 3f, 4f, new Paint());
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+    assertThat(shadowCanvas.getRectPaintHistoryCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void getRoundRectHistoryCount_shouldReturnTotalNumberOfDrawRoundRectEvents() {
+    Canvas canvas = new Canvas();
+    canvas.drawRoundRect(new RectF(), 1f, 1f, new Paint());
+    canvas.drawRoundRect(new RectF(), 1f, 1f, new Paint());
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+    assertThat(shadowCanvas.getRoundRectPaintHistoryCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void getOvalHistoryCount_shouldReturnTotalNumberOfDrawOvalEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    canvas.drawOval(new RectF(), new Paint());
+    canvas.drawOval(new RectF(), new Paint());
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+    assertThat(shadowCanvas.getOvalPaintHistoryCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void getLineHistoryCount_shouldReturnTotalNumberOfDrawLineEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    canvas.drawLine(0f, 1f, 2f, 3f, new Paint());
+    canvas.drawLine(0f, 1f, 2f, 3f, new Paint());
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+    assertThat(shadowCanvas.getLinePaintHistoryCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void drawLine_shouldRecordLineHistoryEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint0 = new Paint();
+    paint0.setColor(Color.RED);
+    paint0.setStrokeWidth(1.0f);
+    Paint paint1 = new Paint();
+    paint1.setColor(Color.WHITE);
+    paint1.setStrokeWidth(2.0f);
+
+    canvas.drawLine(0f, 2f, 3f, 4f, paint0);
+    canvas.drawLine(5f, 6f, 7f, 8f, paint1);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getDrawnLine(0).startX).isEqualTo(0f);
+    assertThat(shadowCanvas.getDrawnLine(0).startY).isEqualTo(2f);
+    assertThat(shadowCanvas.getDrawnLine(0).stopX).isEqualTo(3f);
+    assertThat(shadowCanvas.getDrawnLine(0).stopY).isEqualTo(4f);
+    assertThat(shadowCanvas.getDrawnLine(0).paint.getColor()).isEqualTo(Color.RED);
+    assertThat(shadowCanvas.getDrawnLine(0).paint.getStrokeWidth()).isEqualTo(1.0f);
+
+    assertThat(shadowCanvas.getDrawnLine(1).startX).isEqualTo(5f);
+    assertThat(shadowCanvas.getDrawnLine(1).startY).isEqualTo(6f);
+    assertThat(shadowCanvas.getDrawnLine(1).stopX).isEqualTo(7f);
+    assertThat(shadowCanvas.getDrawnLine(1).stopY).isEqualTo(8f);
+    assertThat(shadowCanvas.getDrawnLine(1).paint.getColor()).isEqualTo(Color.WHITE);
+    assertThat(shadowCanvas.getDrawnLine(1).paint.getStrokeWidth()).isEqualTo(2.0f);
+  }
+
+  @Test
+  public void drawOval_shouldRecordOvalHistoryEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    RectF oval0 = new RectF();
+    RectF oval1 = new RectF();
+    Paint paint0 = new Paint();
+    paint0.setColor(Color.RED);
+    Paint paint1 = new Paint();
+    paint1.setColor(Color.WHITE);
+
+    canvas.drawOval(oval0, paint0);
+    canvas.drawOval(oval1, paint1);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getDrawnOval(0).oval).isEqualTo(oval0);
+    assertThat(shadowCanvas.getDrawnOval(0).paint.getColor()).isEqualTo(Color.RED);
+
+    assertThat(shadowCanvas.getDrawnOval(1).oval).isEqualTo(oval1);
+    assertThat(shadowCanvas.getDrawnOval(1).paint.getColor()).isEqualTo(Color.WHITE);
+  }
+
+  @Test
+  public void drawRect_shouldRecordRectHistoryEvents() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint0 = new Paint();
+    paint0.setColor(Color.WHITE);
+    Paint paint1 = new Paint();
+    paint1.setColor(Color.BLACK);
+    RectF rect0 = new RectF(0f, 2f, 3f, 4f);
+    RectF rect1 = new RectF(5f, 6f, 7f, 8f);
+
+    canvas.drawRect(0f, 2f, 3f, 4f, paint0);
+    canvas.drawRect(5f, 6f, 7f, 8f, paint1);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    assertThat(shadowCanvas.getDrawnRect(0).left).isEqualTo(0f);
+    assertThat(shadowCanvas.getDrawnRect(0).top).isEqualTo(2f);
+    assertThat(shadowCanvas.getDrawnRect(0).right).isEqualTo(3f);
+    assertThat(shadowCanvas.getDrawnRect(0).bottom).isEqualTo(4f);
+    assertThat(shadowCanvas.getDrawnRect(0).rect).isEqualTo(rect0);
+    assertThat(shadowCanvas.getDrawnRect(0).paint.getColor()).isEqualTo(Color.WHITE);
+
+    assertThat(shadowCanvas.getDrawnRect(1).left).isEqualTo(5f);
+    assertThat(shadowCanvas.getDrawnRect(1).top).isEqualTo(6f);
+    assertThat(shadowCanvas.getDrawnRect(1).right).isEqualTo(7f);
+    assertThat(shadowCanvas.getDrawnRect(1).bottom).isEqualTo(8f);
+    assertThat(shadowCanvas.getDrawnRect(1).rect).isEqualTo(rect1);
+    assertThat(shadowCanvas.getDrawnRect(1).paint.getColor()).isEqualTo(Color.BLACK);
+  }
+
+  @Test
+  public void drawRoundRect_shouldRecordRoundRectHistoryEvents() {
+    Canvas canvas = new Canvas();
+    Paint paint0 = new Paint();
+    paint0.setColor(Color.WHITE);
+    Paint paint1 = new Paint();
+    paint1.setColor(Color.BLACK);
+    RectF rect0 = new RectF(0f, 0f, 5f, 5f);
+    RectF rect1 = new RectF(5f, 5f, 15f, 15f);
+
+    canvas.drawRoundRect(rect0, 1f, 2f, paint0);
+    canvas.drawRoundRect(rect1, 2f, 2f, paint1);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    RoundRectPaintHistoryEvent roundRectPaintHistoryEvent = shadowCanvas.getDrawnRoundRect(0);
+    assertThat(roundRectPaintHistoryEvent.left).isEqualTo(0f);
+    assertThat(roundRectPaintHistoryEvent.top).isEqualTo(0f);
+    assertThat(roundRectPaintHistoryEvent.right).isEqualTo(5f);
+    assertThat(roundRectPaintHistoryEvent.bottom).isEqualTo(5f);
+    assertThat(roundRectPaintHistoryEvent.rx).isEqualTo(1f);
+    assertThat(roundRectPaintHistoryEvent.ry).isEqualTo(2f);
+    assertThat(roundRectPaintHistoryEvent.rect).isEqualTo(rect0);
+    assertThat(roundRectPaintHistoryEvent.paint.getColor()).isEqualTo(Color.WHITE);
+
+    roundRectPaintHistoryEvent = shadowCanvas.getDrawnRoundRect(1);
+    assertThat(roundRectPaintHistoryEvent.left).isEqualTo(5f);
+    assertThat(roundRectPaintHistoryEvent.top).isEqualTo(5f);
+    assertThat(roundRectPaintHistoryEvent.right).isEqualTo(15f);
+    assertThat(roundRectPaintHistoryEvent.bottom).isEqualTo(15f);
+    assertThat(roundRectPaintHistoryEvent.rx).isEqualTo(2f);
+    assertThat(roundRectPaintHistoryEvent.ry).isEqualTo(2f);
+    assertThat(roundRectPaintHistoryEvent.rect).isEqualTo(rect1);
+    assertThat(roundRectPaintHistoryEvent.paint.getColor()).isEqualTo(Color.BLACK);
+  }
+
+  @Test
+  public void getLastDrawnRoundRect_getsLastRecordedRoundRectHistoryEvent() {
+    Canvas canvas = new Canvas();
+    Paint paint0 = new Paint();
+    paint0.setColor(Color.WHITE);
+    RectF rect0 = new RectF(0f, 0f, 5f, 5f);
+
+    canvas.drawRoundRect(rect0, 1f, 2f, paint0);
+
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+    RoundRectPaintHistoryEvent roundRectPaintHistoryEvent = shadowCanvas.getLastDrawnRoundRect();
+    assertThat(roundRectPaintHistoryEvent.left).isEqualTo(0f);
+    assertThat(roundRectPaintHistoryEvent.top).isEqualTo(0f);
+    assertThat(roundRectPaintHistoryEvent.right).isEqualTo(5f);
+    assertThat(roundRectPaintHistoryEvent.bottom).isEqualTo(5f);
+    assertThat(roundRectPaintHistoryEvent.rx).isEqualTo(1f);
+    assertThat(roundRectPaintHistoryEvent.ry).isEqualTo(2f);
+    assertThat(roundRectPaintHistoryEvent.rect).isEqualTo(rect0);
+    assertThat(roundRectPaintHistoryEvent.paint.getColor()).isEqualTo(Color.WHITE);
+  }
+
+  @Test
+  public void save() {
+    Canvas canvas = new Canvas();
+
+    int save1 = canvas.save();
+    int save2 = canvas.save();
+
+    assertThat(save2).isGreaterThan(save1);
+    assertThat(canvas.getSaveCount()).isGreaterThan(save2);
+  }
+
+  @Test
+  public void restore() {
+    Canvas canvas = new Canvas();
+
+    int save1 = canvas.save();
+    canvas.restore();
+
+    assertThat(canvas.getSaveCount()).isEqualTo(save1);
+  }
+
+  @Test
+  public void restoreToCount() {
+    Canvas canvas = new Canvas();
+
+    int save1 = canvas.save();
+    canvas.save();
+    canvas.save();
+    canvas.restoreToCount(save1);
+
+    assertThat(canvas.getSaveCount()).isEqualTo(save1);
+  }
+
+  @Test
+  public void drawRect_withPureFloatPosition() {
+    Bitmap bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    bitmap.eraseColor(Color.BLACK);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(Color.BLACK);
+
+    Canvas canvas = new Canvas(bitmap);
+    Paint paint = new Paint();
+    paint.setStyle(Paint.Style.FILL);
+    paint.setAntiAlias(true);
+    paint.setColor(Color.WHITE);
+    canvas.drawRect(0, 0, 10, 10, paint);
+    assertThat(bitmap.getPixel(0, 0)).isEqualTo(Color.WHITE);
+  }
+
+  @Test
+  public void drawRect_withRectF() throws Exception {
+    Canvas canvas = new Canvas();
+    Paint paint = new Paint();
+    paint.setColor(Color.WHITE);
+    RectF rect = new RectF(2f, 4f, 10f, 10f);
+    canvas.drawRect(2f, 4f, 10f, 10f, paint);
+    ShadowCanvas shadowCanvas = shadowOf(canvas);
+
+    ShadowCanvas.RectPaintHistoryEvent drawRect = shadowCanvas.getDrawnRect(0);
+    assertThat(drawRect.left).isEqualTo(2f);
+    assertThat(drawRect.top).isEqualTo(4f);
+    assertThat(drawRect.right).isEqualTo(10f);
+    assertThat(drawRect.bottom).isEqualTo(10f);
+    assertThat(drawRect.rect).isEqualTo(rect);
+    assertThat(drawRect.paint.getColor()).isEqualTo(Color.WHITE);
+  }
+
+  @Test
+  public void getClipBounds_nullBounds_throwsNPE() {
+    assertThrows(NullPointerException.class, () -> new Canvas().getClipBounds(null));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java
new file mode 100644
index 0000000..470eeff
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java
@@ -0,0 +1,120 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.view.accessibility.CaptioningManager;
+import android.view.accessibility.CaptioningManager.CaptioningChangeListener;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+/** Tests for the ShadowCaptioningManager. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = 19)
+public final class ShadowCaptioningManagerTest {
+
+  @Mock private CaptioningChangeListener captioningChangeListener;
+
+  private CaptioningManager captioningManager;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    captioningManager =
+        (CaptioningManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.CAPTIONING_SERVICE);
+  }
+
+  @Test
+  public void setEnabled_true() {
+    assertThat(captioningManager.isEnabled()).isFalse();
+
+    shadowOf(captioningManager).setEnabled(true);
+
+    assertThat(captioningManager.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void setEnabled_false() {
+    shadowOf(captioningManager).setEnabled(false);
+
+    assertThat(captioningManager.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void setFontScale_changesValueOfGetFontScale() {
+    float fontScale = 1.5f;
+    shadowOf(captioningManager).setFontScale(fontScale);
+
+    assertThat(captioningManager.getFontScale()).isWithin(0.001f).of(fontScale);
+  }
+
+  @Test
+  public void setFontScale_notifiesObservers() {
+    float fontScale = 1.5f;
+    captioningManager.addCaptioningChangeListener(captioningChangeListener);
+
+    shadowOf(captioningManager).setFontScale(fontScale);
+
+    verify(captioningChangeListener).onFontScaleChanged(fontScale);
+  }
+
+  @Test
+  public void addCaptioningChangeListener_doesNotRegisterSameListenerTwice() {
+    float fontScale = 1.5f;
+    captioningManager.addCaptioningChangeListener(captioningChangeListener);
+
+    captioningManager.addCaptioningChangeListener(captioningChangeListener);
+
+    shadowOf(captioningManager).setFontScale(fontScale);
+    verify(captioningChangeListener).onFontScaleChanged(fontScale);
+  }
+
+  @Test
+  public void removeCaptioningChangeListener_unregistersFontScaleListener() {
+    captioningManager.addCaptioningChangeListener(captioningChangeListener);
+
+    captioningManager.removeCaptioningChangeListener(captioningChangeListener);
+
+    shadowOf(captioningManager).setFontScale(1.5f);
+    verifyNoMoreInteractions(captioningChangeListener);
+  }
+
+  @Test
+  public void setLocale_nonNull() {
+    Locale locale = Locale.US;
+    assertThat(captioningManager.getLocale()).isNull();
+
+    shadowOf(captioningManager).setLocale(locale);
+
+    assertThat(captioningManager.getLocale()).isEqualTo(locale);
+  }
+
+  @Test
+  public void setLocale_null() {
+    shadowOf(captioningManager).setLocale(null);
+
+    assertThat(captioningManager.getLocale()).isNull();
+  }
+
+  @Test
+  public void setLocale_notifiesObservers() {
+    Locale locale = Locale.US;
+    captioningManager.addCaptioningChangeListener(captioningChangeListener);
+
+    shadowOf(captioningManager).setLocale(locale);
+
+    verify(captioningChangeListener).onLocaleChanged(locale);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptureRequestBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptureRequestBuilderTest.java
new file mode 100644
index 0000000..e604981
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptureRequestBuilderTest.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.CaptureRequest;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Looper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowCaptureRequestBuilder}. */
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public class ShadowCaptureRequestBuilderTest {
+
+  private static final String CAMERA_ID_0 = "cameraId0";
+
+  private final CameraCharacteristics characteristics =
+      ShadowCameraCharacteristics.newCameraCharacteristics();
+  private final CameraManager cameraManager =
+      (CameraManager)
+          ApplicationProvider.getApplicationContext().getSystemService(Context.CAMERA_SERVICE);
+  private CameraDevice cameraDevice;
+  private CaptureRequest.Builder builder;
+
+  @Before
+  public void setUp() throws CameraAccessException {
+    shadowOf(cameraManager).addCamera(CAMERA_ID_0, characteristics);
+    cameraManager.openCamera(CAMERA_ID_0, new CameraStateCallback(), new Handler());
+    shadowOf(Looper.getMainLooper()).idle();
+
+    builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW /* ignored */);
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void testGetAndSet() {
+    builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF);
+    builder.set(CaptureRequest.COLOR_CORRECTION_MODE, CaptureRequest.COLOR_CORRECTION_MODE_FAST);
+    assertThat(builder.get(CaptureRequest.CONTROL_AF_MODE))
+        .isEqualTo(CaptureRequest.CONTROL_AF_MODE_OFF);
+    assertThat(builder.get(CaptureRequest.COLOR_CORRECTION_MODE))
+        .isEqualTo(CaptureRequest.COLOR_CORRECTION_MODE_FAST);
+  }
+
+  private class CameraStateCallback extends CameraDevice.StateCallback {
+    @Override
+    public void onOpened(CameraDevice camera) {
+      cameraDevice = camera;
+    }
+
+    @Override
+    public void onDisconnected(CameraDevice camera) {
+      fail();
+    }
+
+    @Override
+    public void onError(CameraDevice camera, int error) {
+      fail();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptureResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptureResultTest.java
new file mode 100644
index 0000000..348f8e0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptureResultTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.hardware.camera2.CaptureResult;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowCaptureResult}. */
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public class ShadowCaptureResultTest {
+
+  private final CaptureResult.Key<Long> timestampKey = CaptureResult.SENSOR_TIMESTAMP;
+  private final CaptureResult captureResult = ShadowCaptureResult.newCaptureResult();
+
+  @Test
+  public void testSetExistingKey() {
+    shadowOf(captureResult).set(timestampKey, 1L);
+    try {
+      shadowOf(captureResult).set(timestampKey, 2L);
+      fail();
+    } catch (IllegalArgumentException exception) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testGetUnrecongizedKey() {
+    assertThat(captureResult.get(timestampKey)).isNull();
+  }
+
+  @Test
+  public void testGetRecognizedKey() {
+    shadowOf(captureResult).set(timestampKey, 1L);
+    assertThat(captureResult.get(timestampKey)).isEqualTo(1L);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCardEmulationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCardEmulationTest.java
new file mode 100644
index 0000000..c6fe1eb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCardEmulationTest.java
@@ -0,0 +1,76 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.nfc.NfcAdapter;
+import android.nfc.cardemulation.CardEmulation;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/** Test the shadow implementation of {@link CardEmulation}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowCardEmulationTest {
+
+  private static final String TEST_CATEGORY = "test_category";
+
+  private Activity activity;
+  private CardEmulation cardEmulation;
+  private ComponentName service;
+
+  @Before
+  public void setUp() throws Exception {
+    Application context = ApplicationProvider.getApplicationContext();
+    shadowOf(context.getPackageManager())
+        .setSystemFeature(PackageManager.FEATURE_NFC, /* supported= */ true);
+    shadowOf(context.getPackageManager())
+        .setSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION, /* supported= */ true);
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context);
+    cardEmulation = CardEmulation.getInstance(adapter);
+    service = new ComponentName(context, "my_service");
+    activity = Robolectric.buildActivity(Activity.class).setup().get();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+  public void isDefaultServiceForCategory_canOverride() {
+    assertThat(cardEmulation.isDefaultServiceForCategory(service, TEST_CATEGORY)).isFalse();
+    ShadowCardEmulation.setDefaultServiceForCategory(service, TEST_CATEGORY);
+    assertThat(cardEmulation.isDefaultServiceForCategory(service, TEST_CATEGORY)).isTrue();
+    ShadowCardEmulation.setDefaultServiceForCategory(null, TEST_CATEGORY);
+    assertThat(cardEmulation.isDefaultServiceForCategory(service, TEST_CATEGORY)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+  public void setPreferredService_canCapture() {
+    assertThat(ShadowCardEmulation.getPreferredService() == null).isTrue();
+    cardEmulation.setPreferredService(activity, service);
+    assertThat(ShadowCardEmulation.getPreferredService().equals(service)).isTrue();
+    cardEmulation.unsetPreferredService(activity);
+    assertThat(ShadowCardEmulation.getPreferredService() == null).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+  public void categoryAllowsForegroundPreference_canSet() {
+    assertThat(cardEmulation.categoryAllowsForegroundPreference(CardEmulation.CATEGORY_PAYMENT))
+        .isFalse();
+    ShadowCardEmulation.setCategoryPaymentAllowsForegroundPreference(true);
+    assertThat(cardEmulation.categoryAllowsForegroundPreference(CardEmulation.CATEGORY_PAYMENT))
+        .isTrue();
+    ShadowCardEmulation.setCategoryPaymentAllowsForegroundPreference(false);
+    assertThat(cardEmulation.categoryAllowsForegroundPreference(CardEmulation.CATEGORY_PAYMENT))
+        .isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCarrierConfigManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCarrierConfigManagerTest.java
new file mode 100644
index 0000000..acbc590
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCarrierConfigManagerTest.java
@@ -0,0 +1,120 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.os.PersistableBundle;
+import android.telephony.CarrierConfigManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Junit test for {@link ShadowCarrierConfigManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowCarrierConfigManagerTest {
+
+  private CarrierConfigManager carrierConfigManager;
+
+  private static final int TEST_ID = 123;
+  private static final String STRING_KEY = "key1";
+  private static final String STRING_VALUE = "test";
+  private static final String STRING_OVERRIDE_VALUE = "override";
+  private static final String INT_KEY = "key2";
+  private static final int INT_VALUE = 100;
+  private static final String BOOLEAN_KEY = "key3";
+  private static final boolean BOOLEAN_VALUE = true;
+
+  @Before
+  public void setUp() {
+    carrierConfigManager =
+        (CarrierConfigManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.CARRIER_CONFIG_SERVICE);
+  }
+
+  @Test
+  public void getConfigForSubId_shouldReturnNonNullValue() {
+    PersistableBundle persistableBundle = carrierConfigManager.getConfigForSubId(-1);
+    assertThat(persistableBundle).isNotNull();
+  }
+
+  @Test
+  public void testGetConfigForSubId() {
+    PersistableBundle persistableBundle = new PersistableBundle();
+    persistableBundle.putString(STRING_KEY, STRING_VALUE);
+    persistableBundle.putInt(INT_KEY, INT_VALUE);
+    persistableBundle.putBoolean(BOOLEAN_KEY, BOOLEAN_VALUE);
+
+    shadowOf(carrierConfigManager).setConfigForSubId(TEST_ID, persistableBundle);
+
+    PersistableBundle verifyBundle = carrierConfigManager.getConfigForSubId(TEST_ID);
+    assertThat(verifyBundle).isNotNull();
+
+    assertThat(verifyBundle.get(STRING_KEY)).isEqualTo(STRING_VALUE);
+    assertThat(verifyBundle.getInt(INT_KEY)).isEqualTo(INT_VALUE);
+    assertThat(verifyBundle.getBoolean(BOOLEAN_KEY)).isEqualTo(BOOLEAN_VALUE);
+  }
+
+  @Test
+  public void getConfigForSubId_defaultsToEmpty() {
+    PersistableBundle persistableBundle = carrierConfigManager.getConfigForSubId(99999);
+    assertThat(persistableBundle).isNotNull();
+  }
+
+  @Test
+  public void getConfigForSubId_afterSetNullConfig_shouldReturnNullValue() {
+    shadowOf(carrierConfigManager).setConfigForSubId(TEST_ID, null);
+    PersistableBundle persistableBundle = carrierConfigManager.getConfigForSubId(TEST_ID);
+    assertThat(persistableBundle).isNull();
+  }
+
+  @Test
+  public void overrideConfig_setNullConfig_removesOverride() {
+    // Set value
+    PersistableBundle existingBundle = new PersistableBundle();
+    existingBundle.putString(STRING_KEY, STRING_VALUE);
+    shadowOf(carrierConfigManager).setConfigForSubId(TEST_ID, existingBundle);
+    // Set override value
+    PersistableBundle overrideBundle = new PersistableBundle();
+    overrideBundle.putString(STRING_KEY, STRING_OVERRIDE_VALUE);
+    shadowOf(carrierConfigManager).overrideConfig(TEST_ID, overrideBundle);
+    // Assert override is applied
+    assertThat(carrierConfigManager.getConfigForSubId(TEST_ID).get(STRING_KEY))
+        .isEqualTo(STRING_OVERRIDE_VALUE);
+
+    shadowOf(carrierConfigManager).overrideConfig(TEST_ID, null);
+
+    assertThat(carrierConfigManager.getConfigForSubId(TEST_ID).get(STRING_KEY))
+        .isEqualTo(STRING_VALUE);
+  }
+
+  @Test
+  public void overrideConfig_setBundleWithValues_overridesExistingConfig() {
+    PersistableBundle existingBundle = new PersistableBundle();
+    existingBundle.putString(STRING_KEY, STRING_VALUE);
+    shadowOf(carrierConfigManager).setConfigForSubId(TEST_ID, existingBundle);
+    assertThat(carrierConfigManager.getConfigForSubId(TEST_ID)).isNotNull();
+    assertThat(carrierConfigManager.getConfigForSubId(TEST_ID).get(STRING_KEY))
+        .isEqualTo(STRING_VALUE);
+    assertThat(carrierConfigManager.getConfigForSubId(TEST_ID).getInt(INT_KEY)).isEqualTo(0);
+    assertThat(carrierConfigManager.getConfigForSubId(TEST_ID).getBoolean(BOOLEAN_KEY)).isFalse();
+
+    PersistableBundle overrideBundle = new PersistableBundle();
+    overrideBundle.putString(STRING_KEY, STRING_OVERRIDE_VALUE);
+    overrideBundle.putInt(INT_KEY, INT_VALUE);
+    overrideBundle.putBoolean(BOOLEAN_KEY, BOOLEAN_VALUE);
+    shadowOf(carrierConfigManager).overrideConfig(TEST_ID, overrideBundle);
+
+    PersistableBundle verifyBundle = carrierConfigManager.getConfigForSubId(TEST_ID);
+    assertThat(verifyBundle).isNotNull();
+    assertThat(verifyBundle.get(STRING_KEY)).isEqualTo(STRING_OVERRIDE_VALUE);
+    assertThat(verifyBundle.getInt(INT_KEY)).isEqualTo(INT_VALUE);
+    assertThat(verifyBundle.getBoolean(BOOLEAN_KEY)).isEqualTo(BOOLEAN_VALUE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCheckBoxTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCheckBoxTest.java
new file mode 100644
index 0000000..fa60547
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCheckBoxTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.widget.CheckBox;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCheckBoxTest {
+  @Test
+  public void testWorks() {
+    CheckBox checkBox = new CheckBox(ApplicationProvider.getApplicationContext());
+    assertThat(checkBox.isChecked()).isFalse();
+
+    checkBox.setChecked(true);
+    assertThat(checkBox.isChecked()).isTrue();
+
+    checkBox.toggle();
+    assertThat(checkBox.isChecked()).isFalse();
+
+    checkBox.performClick();  // Used to support performClick(), but Android doesn't. Sigh.
+//        assertThat(checkBox.isChecked()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCheckedTextViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCheckedTextViewTest.java
new file mode 100644
index 0000000..dac1e28
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCheckedTextViewTest.java
@@ -0,0 +1,50 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.widget.CheckedTextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCheckedTextViewTest {
+
+  private CheckedTextView checkedTextView;
+
+  @Before
+  public void beforeTests() {
+    checkedTextView = new CheckedTextView(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void testToggle() {
+    assertFalse(checkedTextView.isChecked());
+
+    checkedTextView.toggle();
+
+    assertTrue(checkedTextView.isChecked());
+  }
+
+  @Test
+  public void testSetChecked() {
+    assertFalse(checkedTextView.isChecked());
+
+    checkedTextView.setChecked(true);
+
+    assertTrue(checkedTextView.isChecked());
+  }
+
+  @Test
+  public void toggle_shouldChangeCheckedness() {
+    CheckedTextView view = new CheckedTextView(ApplicationProvider.getApplicationContext());
+    assertFalse(view.isChecked());
+    view.toggle();
+    assertTrue(view.isChecked());
+    view.toggle();  // Used to support performClick(), but Android doesn't. Sigh.
+    assertFalse(view.isChecked());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowChoreographerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowChoreographerTest.java
new file mode 100644
index 0000000..2b40685
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowChoreographerTest.java
@@ -0,0 +1,84 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.Choreographer;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link ShadowChoreographer}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowChoreographerTest {
+
+  @Test
+  public void setPaused_isPaused_doesntRun() {
+    ShadowChoreographer.setPaused(true);
+    long startTime = ShadowSystem.nanoTime();
+    AtomicBoolean didRun = new AtomicBoolean();
+
+    Choreographer.getInstance().postFrameCallback(frameTimeNanos -> didRun.set(true));
+    ShadowLooper.idleMainLooper();
+
+    assertThat(ShadowSystem.nanoTime()).isEqualTo(startTime);
+    assertThat(didRun.get()).isFalse();
+  }
+
+  @Test
+  public void setPaused_isPaused_doesntRunWhenClockAdancedLessThanFrameDelay() {
+    ShadowChoreographer.setPaused(true);
+    ShadowChoreographer.setFrameDelay(Duration.ofMillis(15));
+    AtomicBoolean didRun = new AtomicBoolean();
+
+    Choreographer.getInstance().postFrameCallback(frameTimeNanos -> didRun.set(true));
+    ShadowSystemClock.advanceBy(Duration.ofMillis(14));
+    ShadowLooper.idleMainLooper();
+
+    assertThat(didRun.get()).isFalse();
+  }
+
+  @Test
+  public void setPaused_isPaused_runsWhenClockAdvanced() {
+    ShadowChoreographer.setPaused(true);
+    ShadowChoreographer.setFrameDelay(Duration.ofMillis(15));
+    long startTime = ShadowSystem.nanoTime();
+    AtomicLong frameTimeNanos = new AtomicLong();
+
+    Choreographer.getInstance().postFrameCallback(frameTimeNanos::set);
+    ShadowSystemClock.advanceBy(Duration.ofMillis(15));
+    ShadowLooper.idleMainLooper();
+
+    assertThat(frameTimeNanos.get()).isEqualTo(startTime + Duration.ofMillis(15).toNanos());
+  }
+
+  @Test
+  public void setPaused_isNotPaused_advancesClockAndRuns() {
+    ShadowChoreographer.setPaused(false);
+    ShadowChoreographer.setFrameDelay(Duration.ofMillis(15));
+    long startTime = ShadowSystem.nanoTime();
+    AtomicBoolean didRun = new AtomicBoolean();
+
+    Choreographer.getInstance().postFrameCallback(frameTimeNanos -> didRun.set(true));
+    ShadowLooper.idleMainLooper();
+
+    assertThat(ShadowSystem.nanoTime()).isEqualTo(startTime + Duration.ofMillis(15).toNanos());
+    assertThat(didRun.get()).isTrue();
+  }
+
+  @Test
+  public void setFrameDelay() {
+    ShadowChoreographer.setPaused(false);
+    ShadowChoreographer.setFrameDelay(Duration.ofMillis(30));
+    long startTime = ShadowSystem.nanoTime();
+    AtomicBoolean didRun = new AtomicBoolean();
+
+    Choreographer.getInstance().postFrameCallback(frameTimeNanos -> didRun.set(true));
+    ShadowLooper.idleMainLooper();
+
+    assertThat(ShadowSystem.nanoTime()).isEqualTo(startTime + Duration.ofMillis(30).toNanos());
+    assertThat(didRun.get()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowClipboardManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowClipboardManagerTest.java
new file mode 100644
index 0000000..10f1fdb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowClipboardManagerTest.java
@@ -0,0 +1,107 @@
+package org.robolectric.shadows;
+
+import static android.content.ClipboardManager.OnPrimaryClipChangedListener;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowClipboardManagerTest {
+
+  private ClipboardManager clipboardManager;
+
+  @Before public void setUp() throws Exception {
+    clipboardManager =
+        (ClipboardManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE);
+  }
+
+  @Test
+  public void shouldStoreText() {
+    clipboardManager.setText("BLARG!!!");
+    assertThat(clipboardManager.getText().toString()).isEqualTo("BLARG!!!");
+  }
+
+  @Test
+  public void shouldNotHaveTextIfTextIsNull() {
+    clipboardManager.setText(null);
+    assertThat(clipboardManager.hasText()).isFalse();
+  }
+
+  @Test
+  public void shouldNotHaveTextIfTextIsEmpty() {
+    clipboardManager.setText("");
+    assertThat(clipboardManager.hasText()).isFalse();
+  }
+
+  @Test
+  public void shouldHaveTextIfEmptyString() {
+    clipboardManager.setText(" ");
+    assertThat(clipboardManager.hasText()).isTrue();
+  }
+
+  @Test
+  public void shouldHaveTextIfString() {
+    clipboardManager.setText("BLARG");
+    assertThat(clipboardManager.hasText()).isTrue();
+  }
+
+  @Test
+  public void shouldStorePrimaryClip() {
+    ClipData clip = ClipData.newPlainText(null, "BLARG?");
+    clipboardManager.setPrimaryClip(clip);
+    assertThat(clipboardManager.getPrimaryClip()).isEqualTo(clip);
+  }
+
+  @Test
+  public void shouldNotHaveTextIfPrimaryClipIsNull() {
+    clipboardManager.setPrimaryClip(null);
+    assertThat(clipboardManager.hasText()).isFalse();
+  }
+
+  @Test
+  public void shouldNotHaveTextIfPrimaryClipIsEmpty() {
+    clipboardManager.setPrimaryClip(ClipData.newPlainText(null, ""));
+    assertThat(clipboardManager.hasText()).isFalse();
+  }
+
+  @Test
+  public void shouldHaveTextIfEmptyPrimaryClip() {
+    clipboardManager.setPrimaryClip(ClipData.newPlainText(null, " "));
+    assertThat(clipboardManager.hasText()).isTrue();
+  }
+
+  @Test
+  public void shouldHaveTextIfPrimaryClip() {
+    clipboardManager.setPrimaryClip(ClipData.newPlainText(null, "BLARG?"));
+    assertThat(clipboardManager.hasText()).isTrue();
+  }
+
+  @Test
+  public void shouldHavePrimaryClipIfText() {
+    clipboardManager.setText("BLARG?");
+    assertThat(clipboardManager.hasPrimaryClip()).isTrue();
+  }
+
+  @Test
+  public void shouldFireListeners() {
+    OnPrimaryClipChangedListener listener = mock(OnPrimaryClipChangedListener.class);
+    clipboardManager.addPrimaryClipChangedListener(listener);
+    clipboardManager.setPrimaryClip(ClipData.newPlainText(null, "BLARG?"));
+    verify(listener).onPrimaryClipChanged();
+
+    clipboardManager.removePrimaryClipChangedListener(listener);
+    clipboardManager.setPrimaryClip(ClipData.newPlainText(null, "BLARG?"));
+    verifyNoMoreInteractions(listener);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCloseGuardTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCloseGuardTest.java
new file mode 100644
index 0000000..42bea0e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCloseGuardTest.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import dalvik.system.CloseGuard;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowCloseGuard}. */
+@RunWith(RobolectricTestRunner.class)
+public final class ShadowCloseGuardTest {
+
+  @Test
+  public void noCloseGuardsOpened_noErrors() {
+    assertThat(ShadowCloseGuard.getErrors()).isEmpty();
+  }
+
+  @Test
+  public void closeGuardsOpened_addedToErrors() {
+    CloseGuard closeGuard1 = CloseGuard.get();
+    CloseGuard closeGuard2 = CloseGuard.get();
+
+    closeGuard1.open("foo");
+    closeGuard2.open("bar");
+
+    assertThat(ShadowCloseGuard.getErrors()).hasSize(2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  // On < P nothing will be tracked when not enabled. On >= P the String passed in to open() will be
+  // tracked when not enabled.
+  public void closeGuardsOpened_stackAndTrackingNotEnabled_addedToErrors() {
+    CloseGuard.setEnabled(false);
+    CloseGuard closeGuard1 = CloseGuard.get();
+    CloseGuard closeGuard2 = CloseGuard.get();
+
+    closeGuard1.open("foo");
+    closeGuard2.open("bar");
+
+    assertThat(ShadowCloseGuard.getErrors()).hasSize(2);
+  }
+
+  @Test
+  public void closeGuardsClosed_removedFromErrors() {
+    CloseGuard closeGuard1 = CloseGuard.get();
+    CloseGuard closeGuard2 = CloseGuard.get();
+    closeGuard1.open("foo");
+    closeGuard2.open("bar");
+
+    closeGuard1.close();
+    closeGuard2.close();
+
+    assertThat(ShadowCloseGuard.getErrors()).isEmpty();
+  }
+
+  @Test
+  public void closeGuardsOpenedWarnedAndClosed_addedToErrors() {
+    CloseGuard closeGuard1 = CloseGuard.get();
+    CloseGuard closeGuard2 = CloseGuard.get();
+    closeGuard1.open("foo");
+    closeGuard2.open("bar");
+
+    closeGuard1.warnIfOpen();
+    closeGuard2.warnIfOpen();
+    closeGuard1.close();
+    closeGuard2.close();
+
+    assertThat(ShadowCloseGuard.getErrors()).hasSize(2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowColorDisplayManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowColorDisplayManagerTest.java
new file mode 100644
index 0000000..a390236
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowColorDisplayManagerTest.java
@@ -0,0 +1,140 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadow.api.Shadow.extract;
+
+import android.hardware.display.ColorDisplayManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for ShadowColorDisplayManager. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public class ShadowColorDisplayManagerTest {
+
+  private static final String PACKAGE_NAME = "test_package_name";
+
+  // Must be optional to avoid ClassNotFoundException
+  Optional<ColorDisplayManager> instance;
+
+  @Before
+  public void setUp() throws Exception {
+    instance =
+        Optional.of(
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(ColorDisplayManager.class));
+  }
+
+  @Test
+  public void getSaturationLevel_defaultValue_shouldReturnHundred() {
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(100);
+  }
+
+  @Test
+  public void getSaturationLevel_setToZero_shouldReturnZero() {
+    instance.get().setSaturationLevel(0);
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(0);
+  }
+
+  @Test
+  public void getSaturationLevel_setToHalf_shouldReturnHalf() {
+    instance.get().setSaturationLevel(50);
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(50);
+  }
+
+  @Test
+  public void getSaturationLevel_setToHundred_shouldReturnHundred() {
+    instance.get().setSaturationLevel(0);
+    instance.get().setSaturationLevel(100);
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(100);
+  }
+
+  @Test
+  public void getSaturationLevel_setToZeroViaShadow_shouldReturnZero() {
+    getShadowColorDisplayManager().setSaturationLevel(0);
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(0);
+  }
+
+  @Test
+  public void getSaturationLevel_setToHalfViaShadow_shouldReturnHalf() {
+    getShadowColorDisplayManager().setSaturationLevel(50);
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(50);
+  }
+
+  @Test
+  public void getSaturationLevel_setToHundredViaShadow_shouldReturnHundred() {
+    getShadowColorDisplayManager().setSaturationLevel(0);
+    getShadowColorDisplayManager().setSaturationLevel(100);
+    assertThat(getShadowColorDisplayManager().getSaturationLevel()).isEqualTo(100);
+  }
+
+  @Test
+  public void getAppSaturationLevel_defaultValue_shouldReturnHundred() {
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(100);
+  }
+
+  @Test
+  public void getAppSaturationLevel_setToZero_shouldReturnZero() {
+    instance.get().setAppSaturationLevel(PACKAGE_NAME, 0);
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(0);
+  }
+
+  @Test
+  public void getAppSaturationLevel_setToHalf_shouldReturnHalf() {
+    instance.get().setAppSaturationLevel(PACKAGE_NAME, 50);
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(50);
+  }
+
+  @Test
+  public void getAppSaturationLevel_setToHundred_shouldReturnHundred() {
+    instance.get().setAppSaturationLevel(PACKAGE_NAME, 0);
+    instance.get().setAppSaturationLevel(PACKAGE_NAME, 100);
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(100);
+  }
+
+  @Test
+  public void getAppSaturationLevel_setToZeroViaShadow_shouldReturnZero() {
+    getShadowColorDisplayManager().setAppSaturationLevel(PACKAGE_NAME, 0);
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(0);
+  }
+
+  @Test
+  public void getAppSaturationLevel_setToHalfViaShadow_shouldReturnHalf() {
+    getShadowColorDisplayManager().setAppSaturationLevel(PACKAGE_NAME, 50);
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(50);
+  }
+
+  @Test
+  public void getAppSaturationLevel_setToHundredViaShadow_shouldReturnHundred() {
+    getShadowColorDisplayManager().setAppSaturationLevel(PACKAGE_NAME, 0);
+    getShadowColorDisplayManager().setAppSaturationLevel(PACKAGE_NAME, 100);
+    assertThat(getShadowColorDisplayManager().getAppSaturationLevel(PACKAGE_NAME)).isEqualTo(100);
+  }
+
+  @Test
+  public void getTransformCapabilities_defaultNone_shouldReturnNoCapabilities() {
+    assertThat(getShadowColorDisplayManager().getTransformCapabilities()).isEqualTo(0x0);
+  }
+
+  @Test
+  public void getTransformCapabilities_setToFull_shouldReturnFullCapabilities() {
+    getShadowColorDisplayManager().setTransformCapabilities(0x4);
+    assertThat(getShadowColorDisplayManager().getTransformCapabilities()).isEqualTo(0x4);
+  }
+
+  @Test
+  public void getTransformCapabilities_setToZero_shouldReturnNoCapabilities() {
+    getShadowColorDisplayManager().setTransformCapabilities(0x0);
+    assertThat(getShadowColorDisplayManager().getTransformCapabilities()).isEqualTo(0x0);
+  }
+
+  private ShadowColorDisplayManager getShadowColorDisplayManager() {
+    return (ShadowColorDisplayManager) extract(instance.get());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowColorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowColorTest.java
new file mode 100644
index 0000000..46387d6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowColorTest.java
@@ -0,0 +1,77 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Color;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowColorTest {
+
+  @Test
+  public void testRgb() {
+    int color = Color.rgb(160, 160, 160);
+    assertThat(color).isEqualTo(-6250336);
+  }
+
+  @Test
+  public void testArgb() {
+    int color = Color.argb(100, 160, 160, 160);
+    assertThat(color).isEqualTo(1688248480);
+  }
+
+  @Test
+  public void testParseColor() {
+    assertThat(Color.parseColor("#ffffffff")).isEqualTo(-1);
+    assertThat(Color.parseColor("#00000000")).isEqualTo(0);
+
+    assertThat(Color.parseColor("#ffaabbcc")).isEqualTo(-5588020);
+  }
+
+  @Test
+  public void testParseColorWithStringName() {
+    assertThat(Color.parseColor("blue")).isEqualTo(-16776961);
+    assertThat(Color.parseColor("black")).isEqualTo(-16777216);
+    assertThat(Color.parseColor("green")).isEqualTo(-16711936);
+  }
+
+  @Test
+  public void colorToHSVShouldBeCorrectForBlue() {
+    float[] hsv = new float[3];
+    Color.colorToHSV(Color.BLUE, hsv);
+
+    assertThat(hsv[0]).isEqualTo(240f);
+    assertThat(hsv[1]).isEqualTo(1.0f);
+    assertThat(hsv[2]).isEqualTo(1.0f);
+  }
+
+  @Test
+  public void colorToHSVShouldBeCorrectForBlack() {
+    float[] hsv = new float[3];
+    Color.colorToHSV(Color.BLACK, hsv);
+
+    assertThat(hsv[0]).isEqualTo(0f);
+    assertThat(hsv[1]).isEqualTo(0f);
+    assertThat(hsv[2]).isEqualTo(0f);
+  }
+
+  @Test
+  public void RGBToHSVShouldBeCorrectForBlue() {
+    float[] hsv = new float[3];
+    Color.RGBToHSV(0, 0, 255, hsv);
+
+    assertThat(hsv[0]).isEqualTo(240f);
+    assertThat(hsv[1]).isEqualTo(1.0f);
+    assertThat(hsv[2]).isEqualTo(1.0f);
+  }
+
+  @Test
+  public void HSVToColorShouldReverseColorToHSV() {
+      float[] hsv = new float[3];
+      Color.colorToHSV(Color.RED, hsv);
+
+      assertThat(Color.HSVToColor(hsv)).isEqualTo(Color.RED);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCompanionDeviceManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCompanionDeviceManagerTest.java
new file mode 100644
index 0000000..8437c7a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCompanionDeviceManagerTest.java
@@ -0,0 +1,200 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.companion.AssociationInfo;
+import android.companion.AssociationRequest;
+import android.companion.CompanionDeviceManager;
+import android.content.ComponentName;
+import android.content.IntentSender;
+import android.net.MacAddress;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.Executors;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit test for ShadowCompanionDeviceManager. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O)
+public class ShadowCompanionDeviceManagerTest {
+
+  private static final String MAC_ADDRESS = "AA:BB:CC:DD:FF:EE";
+
+  private CompanionDeviceManager companionDeviceManager;
+  private ShadowCompanionDeviceManager shadowCompanionDeviceManager;
+  private ComponentName componentName;
+
+  @Before
+  public void setUp() throws Exception {
+    companionDeviceManager = getApplicationContext().getSystemService(CompanionDeviceManager.class);
+    shadowCompanionDeviceManager = shadowOf(companionDeviceManager);
+    componentName = new ComponentName(getApplicationContext(), Application.class);
+  }
+
+  @Test
+  public void testAddAssociation() {
+    assertThat(companionDeviceManager.getAssociations()).isEmpty();
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+    assertThat(companionDeviceManager.getAssociations()).contains(MAC_ADDRESS);
+  }
+
+  @Test
+  public void testDisassociate() {
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+    companionDeviceManager.disassociate(MAC_ADDRESS);
+    assertThat(companionDeviceManager.getAssociations()).isEmpty();
+  }
+
+  @Test
+  public void testDisassociate_throwsIfNotFound() {
+    assertThrows(Exception.class, () -> companionDeviceManager.disassociate(MAC_ADDRESS));
+  }
+
+  @Test
+  public void testHasNotificationAccess() {
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+
+    assertThat(companionDeviceManager.hasNotificationAccess(componentName)).isFalse();
+    shadowCompanionDeviceManager.setNotificationAccess(componentName, true);
+    assertThat(companionDeviceManager.hasNotificationAccess(componentName)).isTrue();
+
+    shadowCompanionDeviceManager.setNotificationAccess(componentName, false);
+    assertThat(companionDeviceManager.hasNotificationAccess(componentName)).isFalse();
+  }
+
+  @Test
+  public void testHasNotificationAccess_throwsIfNotAssociated() {
+    assertThrows(
+        Exception.class, () -> companionDeviceManager.hasNotificationAccess(componentName));
+  }
+
+  @Test
+  public void testRequestNotificationAccess() {
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+
+    companionDeviceManager.requestNotificationAccess(componentName);
+    assertThat(shadowCompanionDeviceManager.getLastRequestedNotificationAccess())
+        .isEqualTo(componentName);
+  }
+
+  @Test
+  public void testRequestNotificationAccess_throwsIfNotAssociated() {
+    assertThrows(
+        Exception.class, () -> companionDeviceManager.requestNotificationAccess(componentName));
+  }
+
+  @Test
+  public void testAssociate() {
+    AssociationRequest request = new AssociationRequest.Builder().build();
+    CompanionDeviceManager.Callback callback =
+        new CompanionDeviceManager.Callback() {
+          @Override
+          public void onDeviceFound(IntentSender chooserLauncher) {}
+
+          @Override
+          public void onFailure(CharSequence error) {}
+        };
+    companionDeviceManager.associate(request, callback, null);
+
+    assertThat(shadowCompanionDeviceManager.getLastAssociationRequest()).isSameInstanceAs(request);
+    assertThat(shadowCompanionDeviceManager.getLastAssociationCallback())
+        .isSameInstanceAs(callback);
+  }
+
+  @Test
+  public void testAddAssociation_byMacAddress() {
+    assertThat(companionDeviceManager.getAssociations()).isEmpty();
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+    assertThat(companionDeviceManager.getAssociations()).contains(MAC_ADDRESS);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.TIRAMISU)
+  public void testAddAssociation_byAssociationInfo() {
+    AssociationInfo info =
+        new AssociationInfo(
+            /* id= */ 1,
+            /* userId= */ 1,
+            "packageName",
+            MacAddress.fromString(MAC_ADDRESS),
+            "displayName",
+            "deviceProfile",
+            /* selfManaged= */ false,
+            /* notifyOnDeviceNearby= */ false,
+            /* timeApprovedMs= */ 0,
+            /* lastTimeConnectedMs= */ 0);
+    assertThat(companionDeviceManager.getAssociations()).isEmpty();
+    shadowCompanionDeviceManager.addAssociation(info);
+    assertThat(companionDeviceManager.getMyAssociations()).contains(info);
+  }
+
+  @Test
+  public void testDisassociate_byMacAddress() {
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+    companionDeviceManager.disassociate(MAC_ADDRESS);
+    assertThat(companionDeviceManager.getAssociations()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.TIRAMISU)
+  public void testDisassociate_byId() {
+    shadowCompanionDeviceManager.addAssociation(MAC_ADDRESS);
+    // default ID is 1
+    companionDeviceManager.disassociate(1);
+    assertThat(companionDeviceManager.getAssociations()).isEmpty();
+    assertThat(companionDeviceManager.getMyAssociations()).isEmpty();
+  }
+
+  @Test
+  public void testDisassociate_byMacAddress_throwsIfNotFound() {
+    assertThrows(Exception.class, () -> companionDeviceManager.disassociate(MAC_ADDRESS));
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.TIRAMISU)
+  public void testDisassociate_byId_throwsIfNotFound() {
+    assertThrows(Exception.class, () -> companionDeviceManager.disassociate(0));
+  }
+
+  @Test
+  public void testAssociate_handlerVariant_updatesShadow() {
+    AssociationRequest request = new AssociationRequest.Builder().build();
+    CompanionDeviceManager.Callback callback = createCallback();
+
+    companionDeviceManager.associate(request, callback, null);
+
+    assertThat(shadowCompanionDeviceManager.getLastAssociationRequest()).isSameInstanceAs(request);
+    assertThat(shadowCompanionDeviceManager.getLastAssociationCallback())
+        .isSameInstanceAs(callback);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.TIRAMISU)
+  public void testAssociate_executorVariant_updatesShadow() {
+    AssociationRequest request = new AssociationRequest.Builder().build();
+    CompanionDeviceManager.Callback callback = createCallback();
+    companionDeviceManager.associate(request, Executors.newSingleThreadExecutor(), callback);
+
+    assertThat(shadowCompanionDeviceManager.getLastAssociationRequest()).isSameInstanceAs(request);
+    assertThat(shadowCompanionDeviceManager.getLastAssociationCallback())
+        .isSameInstanceAs(callback);
+  }
+
+  private CompanionDeviceManager.Callback createCallback() {
+    return new CompanionDeviceManager.Callback() {
+      @Override
+      public void onDeviceFound(IntentSender chooserLauncher) {}
+
+      @Override
+      public void onFailure(CharSequence error) {}
+    };
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowConfigurationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowConfigurationTest.java
new file mode 100644
index 0000000..79de3de
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowConfigurationTest.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import static android.content.res.Configuration.SCREENLAYOUT_UNDEFINED;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.res.Configuration;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowConfigurationTest {
+
+  private Configuration configuration;
+
+  @Before
+  public void setUp() throws Exception {
+    configuration = new Configuration();
+  }
+
+  @Test
+  public void setToDefaultsShouldSetRealDefaults() {
+    configuration.setToDefaults();
+    assertThat(configuration.fontScale).isEqualTo(1.0f);
+    assertThat(configuration.screenLayout).isEqualTo(SCREENLAYOUT_UNDEFINED);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1)
+  public void testSetLocale() {
+    configuration.setLocale( Locale.US );
+    assertThat(configuration.locale).isEqualTo(Locale.US);
+
+    configuration.setLocale( Locale.FRANCE);
+    assertThat(configuration.locale).isEqualTo(Locale.FRANCE);
+  }
+
+  @Test
+  public void testConstructCopy() {
+    configuration.setToDefaults();
+    Configuration clone = new Configuration(configuration);
+    assertThat(configuration).isEqualTo(clone);
+  }
+
+  @Test
+  public void testToString_shouldntExplode() {
+    assertThat(new Configuration().toString()).contains("mcc");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowConnectionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowConnectionTest.java
new file mode 100644
index 0000000..cf22c88
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowConnectionTest.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.telecom.Connection;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for ShadowConnection. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = N_MR1)
+public class ShadowConnectionTest {
+  static class FakeConnection extends Connection {}
+
+  @Test
+  public void testGetMostRecentEvent() {
+    Connection connection = new FakeConnection();
+    connection.sendConnectionEvent("TEST_EVENT", null);
+
+    Optional<String> eventOptional = shadowOf(connection).getLastConnectionEvent();
+
+    assertThat(eventOptional.isPresent()).isTrue();
+
+    assertThat(eventOptional.get()).isEqualTo("TEST_EVENT");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowConnectivityManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowConnectivityManagerTest.java
new file mode 100644
index 0000000..dd8da17
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowConnectivityManagerTest.java
@@ -0,0 +1,667 @@
+package org.robolectric.shadows;
+
+import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
+import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
+import static android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.net.NetworkRequest;
+import android.net.ProxyInfo;
+import android.os.Handler;
+import android.provider.Settings;
+import android.telephony.TelephonyManager;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowConnectivityManagerTest {
+  private ConnectivityManager connectivityManager;
+  private ShadowNetworkInfo shadowOfActiveNetworkInfo;
+
+  @Before
+  public void setUp() throws Exception {
+    connectivityManager =
+        (ConnectivityManager)
+            getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
+    shadowOfActiveNetworkInfo = shadowOf(connectivityManager.getActiveNetworkInfo());
+  }
+
+  @Test
+  public void getActiveNetworkInfo_shouldInitializeItself() {
+    assertThat(connectivityManager.getActiveNetworkInfo()).isNotNull();
+  }
+
+  @Test
+  public void getActiveNetworkInfo_shouldReturnTrueCorrectly() {
+    shadowOfActiveNetworkInfo.setConnectionStatus(NetworkInfo.State.CONNECTED);
+    assertThat(connectivityManager.getActiveNetworkInfo().isConnectedOrConnecting()).isTrue();
+    assertThat(connectivityManager.getActiveNetworkInfo().isConnected()).isTrue();
+
+    shadowOfActiveNetworkInfo.setConnectionStatus(NetworkInfo.State.CONNECTING);
+    assertThat(connectivityManager.getActiveNetworkInfo().isConnectedOrConnecting()).isTrue();
+    assertThat(connectivityManager.getActiveNetworkInfo().isConnected()).isFalse();
+
+    shadowOfActiveNetworkInfo.setConnectionStatus(NetworkInfo.State.DISCONNECTED);
+    assertThat(connectivityManager.getActiveNetworkInfo().isConnectedOrConnecting()).isFalse();
+    assertThat(connectivityManager.getActiveNetworkInfo().isConnected()).isFalse();
+  }
+
+  @Test
+  public void getNetworkInfo_shouldReturnDefaultNetworks() throws Exception {
+    NetworkInfo wifi = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+    assertThat(wifi.getDetailedState()).isEqualTo(NetworkInfo.DetailedState.DISCONNECTED);
+
+    NetworkInfo mobile = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
+    assertThat(mobile.getDetailedState()).isEqualTo(NetworkInfo.DetailedState.CONNECTED);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getNetworkInfo_shouldReturnSomeForAllNetworks() throws Exception {
+    Network[] allNetworks = connectivityManager.getAllNetworks();
+    for (Network network: allNetworks) {
+      NetworkInfo networkInfo = connectivityManager.getNetworkInfo(network);
+      assertThat(networkInfo).isNotNull();
+    }
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getNetworkInfo_shouldReturnAddedNetwork() throws Exception {
+    Network vpnNetwork = ShadowNetwork.newInstance(123);
+    NetworkInfo vpnNetworkInfo =
+        ShadowNetworkInfo.newInstance(
+            NetworkInfo.DetailedState.CONNECTED,
+            ConnectivityManager.TYPE_VPN,
+            0,
+            true,
+            NetworkInfo.State.CONNECTED);
+    shadowOf(connectivityManager).addNetwork(vpnNetwork, vpnNetworkInfo);
+
+    NetworkInfo returnedNetworkInfo = connectivityManager.getNetworkInfo(vpnNetwork);
+    assertThat(returnedNetworkInfo).isSameInstanceAs(vpnNetworkInfo);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getNetworkInfo_shouldNotReturnRemovedNetwork() throws Exception {
+    Network wifiNetwork = ShadowNetwork.newInstance(ShadowConnectivityManager.NET_ID_WIFI);
+    shadowOf(connectivityManager).removeNetwork(wifiNetwork);
+
+    NetworkInfo returnedNetworkInfo = connectivityManager.getNetworkInfo(wifiNetwork);
+    assertThat(returnedNetworkInfo).isNull();
+  }
+
+  @Test
+  public void setConnectionType_shouldReturnTypeCorrectly() {
+    shadowOfActiveNetworkInfo.setConnectionType(ConnectivityManager.TYPE_MOBILE);
+    assertThat(shadowOfActiveNetworkInfo.getType()).isEqualTo(ConnectivityManager.TYPE_MOBILE);
+
+    shadowOfActiveNetworkInfo.setConnectionType(ConnectivityManager.TYPE_WIFI);
+    assertThat(shadowOfActiveNetworkInfo.getType()).isEqualTo(ConnectivityManager.TYPE_WIFI);
+  }
+
+  @Test
+  public void shouldGetAndSetBackgroundDataSetting() throws Exception {
+    assertThat(connectivityManager.getBackgroundDataSetting()).isFalse();
+    shadowOf(connectivityManager).setBackgroundDataSetting(true);
+    assertThat(connectivityManager.getBackgroundDataSetting()).isTrue();
+  }
+
+  @Test
+  public void setActiveNetworkInfo_shouldSetActiveNetworkInfo() throws Exception {
+    shadowOf(connectivityManager).setActiveNetworkInfo(null);
+    assertThat(connectivityManager.getActiveNetworkInfo()).isNull();
+    shadowOf(connectivityManager)
+        .setActiveNetworkInfo(
+            ShadowNetworkInfo.newInstance(
+                null,
+                ConnectivityManager.TYPE_MOBILE_HIPRI,
+                TelephonyManager.NETWORK_TYPE_EDGE,
+                true,
+                NetworkInfo.State.DISCONNECTED));
+
+    NetworkInfo info = connectivityManager.getActiveNetworkInfo();
+
+    assertThat(info.getType()).isEqualTo(ConnectivityManager.TYPE_MOBILE_HIPRI);
+    assertThat(info.getSubtype()).isEqualTo(TelephonyManager.NETWORK_TYPE_EDGE);
+    assertThat(info.isAvailable()).isTrue();
+    assertThat(info.isConnected()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getActiveNetwork_shouldInitializeItself() {
+    assertThat(connectivityManager.getActiveNetwork()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getActiveNetwork_nullIfNetworkNotActive() {
+    shadowOf(connectivityManager).setDefaultNetworkActive(false);
+    assertThat(connectivityManager.getActiveNetwork()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setActiveNetworkInfo_shouldSetActiveNetwork() throws Exception {
+    shadowOf(connectivityManager).setActiveNetworkInfo(null);
+    assertThat(connectivityManager.getActiveNetworkInfo()).isNull();
+    shadowOf(connectivityManager)
+        .setActiveNetworkInfo(
+            ShadowNetworkInfo.newInstance(
+                null,
+                ConnectivityManager.TYPE_MOBILE_HIPRI,
+                TelephonyManager.NETWORK_TYPE_EDGE,
+                true,
+                NetworkInfo.State.DISCONNECTED));
+
+    NetworkInfo info = connectivityManager.getActiveNetworkInfo();
+
+    assertThat(info.getType()).isEqualTo(ConnectivityManager.TYPE_MOBILE_HIPRI);
+    assertThat(info.getSubtype()).isEqualTo(TelephonyManager.NETWORK_TYPE_EDGE);
+    assertThat(info.isAvailable()).isTrue();
+    assertThat(info.isConnected()).isFalse();
+    assertThat(shadowOf(connectivityManager.getActiveNetwork()).getNetId()).isEqualTo(info.getType());
+  }
+
+  @Test
+  public void getAllNetworkInfo_shouldReturnAllNetworkInterfaces() throws Exception {
+    NetworkInfo[] infos = connectivityManager.getAllNetworkInfo();
+    assertThat(infos).asList().hasSize(2);
+    assertThat(infos).asList().contains(connectivityManager.getActiveNetworkInfo());
+
+    shadowOf(connectivityManager).setActiveNetworkInfo(null);
+    assertThat(connectivityManager.getAllNetworkInfo()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAllNetworkInfo_shouldEqualGetAllNetworks() throws Exception {
+    // Update the active network so that we're no longer in the default state.
+    NetworkInfo networkInfo =
+        ShadowNetworkInfo.newInstance(
+            NetworkInfo.DetailedState.CONNECTED,
+            ConnectivityManager.TYPE_WIFI,
+            0 /* subType */,
+            true /* isAvailable */,
+            true /* isConnected */);
+    shadowOf(connectivityManager).setActiveNetworkInfo(networkInfo);
+
+    // Verify that getAllNetworks and getAllNetworkInfo match.
+    Network[] networks = connectivityManager.getAllNetworks();
+    NetworkInfo[] networkInfos = new NetworkInfo[networks.length];
+    for (int i = 0; i < networks.length; i++) {
+      networkInfos[i] = connectivityManager.getNetworkInfo(networks[i]);
+      assertThat(connectivityManager.getAllNetworkInfo()).asList().contains(networkInfos[i]);
+    }
+    assertThat(networkInfos).hasLength(connectivityManager.getAllNetworkInfo().length);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAllNetworkInfo_nullIfNetworkNotActive() {
+    shadowOf(connectivityManager).setDefaultNetworkActive(false);
+    assertThat(connectivityManager.getAllNetworkInfo()).isNull();
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getAllNetworks_shouldReturnAllNetworks() throws Exception {
+    Network[] networks = connectivityManager.getAllNetworks();
+    assertThat(networks).asList().hasSize(2);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getAllNetworks_shouldReturnNoNetworksWhenCleared() throws Exception {
+    shadowOf(connectivityManager).clearAllNetworks();
+    Network[] networks = connectivityManager.getAllNetworks();
+    assertThat(networks).isEmpty();
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getAllNetworks_shouldReturnAddedNetworks() throws Exception {
+    // Let's start clear.
+    shadowOf(connectivityManager).clearAllNetworks();
+
+    // Add a "VPN network".
+    Network vpnNetwork = ShadowNetwork.newInstance(123);
+    NetworkInfo vpnNetworkInfo =
+        ShadowNetworkInfo.newInstance(
+            NetworkInfo.DetailedState.CONNECTED,
+            ConnectivityManager.TYPE_VPN,
+            0,
+            true,
+            NetworkInfo.State.CONNECTED);
+    shadowOf(connectivityManager).addNetwork(vpnNetwork, vpnNetworkInfo);
+
+    Network[] networks = connectivityManager.getAllNetworks();
+    assertThat(networks).asList().hasSize(1);
+
+    Network returnedNetwork = networks[0];
+    assertThat(returnedNetwork).isSameInstanceAs(vpnNetwork);
+
+    NetworkInfo returnedNetworkInfo = connectivityManager.getNetworkInfo(returnedNetwork);
+    assertThat(returnedNetworkInfo).isSameInstanceAs(vpnNetworkInfo);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getAllNetworks_shouldNotReturnRemovedNetworks() throws Exception {
+    Network wifiNetwork = ShadowNetwork.newInstance(ShadowConnectivityManager.NET_ID_WIFI);
+    shadowOf(connectivityManager).removeNetwork(wifiNetwork);
+
+    Network[] networks = connectivityManager.getAllNetworks();
+    assertThat(networks).asList().hasSize(1);
+
+    Network returnedNetwork = networks[0];
+    ShadowNetwork shadowReturnedNetwork = shadowOf(returnedNetwork);
+    assertThat(shadowReturnedNetwork.getNetId()).isNotEqualTo(ShadowConnectivityManager.NET_ID_WIFI);
+  }
+
+  @Test
+  public void getNetworkPreference_shouldGetDefaultValue() throws Exception {
+    assertThat(connectivityManager.getNetworkPreference()).isEqualTo(ConnectivityManager.DEFAULT_NETWORK_PREFERENCE);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getReportedNetworkConnectivity() throws Exception {
+    Network wifiNetwork = ShadowNetwork.newInstance(ShadowConnectivityManager.NET_ID_WIFI);
+    connectivityManager.reportNetworkConnectivity(wifiNetwork, true);
+
+    Map<Network, Boolean> reportedNetworks =
+        shadowOf(connectivityManager).getReportedNetworkConnectivity();
+    assertThat(reportedNetworks.size()).isEqualTo(1);
+    assertThat(reportedNetworks.get(wifiNetwork)).isTrue();
+
+    // Update the status.
+    connectivityManager.reportNetworkConnectivity(wifiNetwork, false);
+    reportedNetworks = shadowOf(connectivityManager).getReportedNetworkConnectivity();
+    assertThat(reportedNetworks.size()).isEqualTo(1);
+    assertThat(reportedNetworks.get(wifiNetwork)).isFalse();
+  }
+
+  @Test
+  public void setNetworkPreference_shouldSetDefaultValue() throws Exception {
+    connectivityManager.setNetworkPreference(ConnectivityManager.TYPE_MOBILE);
+    assertThat(connectivityManager.getNetworkPreference()).isEqualTo(connectivityManager.getNetworkPreference());
+    connectivityManager.setNetworkPreference(ConnectivityManager.TYPE_WIFI);
+    assertThat(connectivityManager.getNetworkPreference()).isEqualTo(ConnectivityManager.TYPE_WIFI);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void getNetworkCallbacks_shouldHaveEmptyDefault() throws Exception {
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).isEmpty();
+  }
+
+  private static ConnectivityManager.NetworkCallback createSimpleCallback() {
+    return new ConnectivityManager.NetworkCallback() {
+      @Override
+      public void onAvailable(Network network) {}
+      @Override
+      public void onLost(Network network) {}
+    };
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void requestNetwork_shouldAddCallback() throws Exception {
+    NetworkRequest.Builder builder = new NetworkRequest.Builder();
+    ConnectivityManager.NetworkCallback callback = createSimpleCallback();
+    connectivityManager.requestNetwork(builder.build(), callback);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void registerCallback_shouldAddCallback() throws Exception {
+    NetworkRequest.Builder builder = new NetworkRequest.Builder();
+    ConnectivityManager.NetworkCallback callback = createSimpleCallback();
+    connectivityManager.registerNetworkCallback(builder.build(), callback);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestNetwork_withTimeout_shouldAddCallback() throws Exception {
+    NetworkRequest.Builder builder = new NetworkRequest.Builder();
+    ConnectivityManager.NetworkCallback callback = createSimpleCallback();
+    connectivityManager.requestNetwork(builder.build(), callback, 0);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestNetwork_withHandler_shouldAddCallback() throws Exception {
+    NetworkRequest.Builder builder = new NetworkRequest.Builder();
+    ConnectivityManager.NetworkCallback callback = createSimpleCallback();
+    connectivityManager.requestNetwork(builder.build(), callback, new Handler());
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestNetwork_withHandlerAndTimer_shouldAddCallback() throws Exception {
+    NetworkRequest.Builder builder = new NetworkRequest.Builder();
+    ConnectivityManager.NetworkCallback callback = createSimpleCallback();
+    connectivityManager.requestNetwork(builder.build(), callback, new Handler(), 0);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void registerDefaultCallback_shouldAddCallback() throws Exception {
+    ConnectivityManager.NetworkCallback callback = createSimpleCallback();
+    connectivityManager.registerDefaultNetworkCallback(callback);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+  }
+
+  @Test @Config(minSdk = LOLLIPOP)
+  public void unregisterCallback_shouldRemoveCallbacks() throws Exception {
+    NetworkRequest.Builder builder = new NetworkRequest.Builder();
+    // Add two different callbacks.
+    ConnectivityManager.NetworkCallback callback1 = createSimpleCallback();
+    ConnectivityManager.NetworkCallback callback2 = createSimpleCallback();
+    connectivityManager.registerNetworkCallback(builder.build(), callback1);
+    connectivityManager.registerNetworkCallback(builder.build(), callback2);
+    // Remove one at the time.
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(2);
+    connectivityManager.unregisterNetworkCallback(callback2);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).hasSize(1);
+    connectivityManager.unregisterNetworkCallback(callback1);
+    assertThat(shadowOf(connectivityManager).getNetworkCallbacks()).isEmpty();
+  }
+
+  @Test(expected=IllegalArgumentException.class) @Config(minSdk = LOLLIPOP)
+  public void unregisterCallback_shouldNotAllowNullCallback() throws Exception {
+    // Verify that exception is thrown.
+    connectivityManager.unregisterNetworkCallback((ConnectivityManager.NetworkCallback) null);
+  }
+
+  @Test
+  public void isActiveNetworkMetered_defaultsToTrue() {
+    assertThat(connectivityManager.isActiveNetworkMetered()).isTrue();
+  }
+
+  @Test
+  public void isActiveNetworkMetered_mobileIsMetered() {
+    shadowOf(connectivityManager)
+        .setActiveNetworkInfo(connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE));
+    assertThat(connectivityManager.isActiveNetworkMetered()).isTrue();
+  }
+
+  @Test
+  public void isActiveNetworkMetered_nonMobileIsUnmetered() {
+    shadowOf(connectivityManager)
+        .setActiveNetworkInfo(connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI));
+    assertThat(connectivityManager.isActiveNetworkMetered()).isFalse();
+  }
+
+  @Test
+  public void isActiveNetworkMetered_noActiveNetwork() {
+    shadowOf(connectivityManager).setActiveNetworkInfo(null);
+    assertThat(connectivityManager.isActiveNetworkMetered()).isFalse();
+  }
+
+  @Test
+  public void isActiveNetworkMetered_noDefaultNetworkActive() {
+    shadowOf(connectivityManager).setDefaultNetworkActive(false);
+    assertThat(connectivityManager.isActiveNetworkMetered()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void bindProcessToNetwork_shouldGetBoundNetworkForProcess() {
+    Network network = ShadowNetwork.newInstance(789);
+    connectivityManager.bindProcessToNetwork(network);
+    assertThat(connectivityManager.getBoundNetworkForProcess()).isSameInstanceAs(network);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isDefaultNetworkActive_defaultActive() {
+    assertThat(shadowOf(connectivityManager).isDefaultNetworkActive()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isDefaultNetworkActive_notActive() {
+    shadowOf(connectivityManager).setDefaultNetworkActive(false);
+    assertThat(shadowOf(connectivityManager).isDefaultNetworkActive()).isFalse();
+  }
+
+  private static ConnectivityManager.OnNetworkActiveListener createSimpleOnNetworkActiveListener() {
+    return new ConnectivityManager.OnNetworkActiveListener() {
+      @Override
+      public void onNetworkActive() {}
+    };
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void addDefaultNetworkActiveListener_shouldAddListener() throws Exception {
+    ConnectivityManager.OnNetworkActiveListener listener1 =
+        spy(createSimpleOnNetworkActiveListener());
+    ConnectivityManager.OnNetworkActiveListener listener2 =
+        spy(createSimpleOnNetworkActiveListener());
+    connectivityManager.addDefaultNetworkActiveListener(listener1);
+    connectivityManager.addDefaultNetworkActiveListener(listener2);
+
+    shadowOf(connectivityManager).setDefaultNetworkActive(true);
+
+    verify(listener1).onNetworkActive();
+    verify(listener2).onNetworkActive();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void removeDefaultNetworkActiveListener_shouldRemoveListeners() throws Exception {
+    // Add two different callbacks.
+    ConnectivityManager.OnNetworkActiveListener listener1 =
+        spy(createSimpleOnNetworkActiveListener());
+    ConnectivityManager.OnNetworkActiveListener listener2 =
+        spy(createSimpleOnNetworkActiveListener());
+    connectivityManager.addDefaultNetworkActiveListener(listener1);
+    connectivityManager.addDefaultNetworkActiveListener(listener2);
+
+    shadowOf(connectivityManager).setDefaultNetworkActive(true);
+
+    verify(listener1).onNetworkActive();
+    verify(listener2).onNetworkActive();
+    // Remove one at the time.
+    connectivityManager.removeDefaultNetworkActiveListener(listener2);
+
+    shadowOf(connectivityManager).setDefaultNetworkActive(true);
+
+    verify(listener1, times(2)).onNetworkActive();
+    verify(listener2).onNetworkActive();
+
+    connectivityManager.removeDefaultNetworkActiveListener(listener1);
+
+    shadowOf(connectivityManager).setDefaultNetworkActive(true);
+
+    verify(listener1, times(2)).onNetworkActive();
+    verify(listener2).onNetworkActive();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  @Config(minSdk = LOLLIPOP)
+  public void removeDefaultNetworkActiveListener_shouldNotAllowNullListener() throws Exception {
+    // Verify that exception is thrown.
+    connectivityManager.removeDefaultNetworkActiveListener(null);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getNetworkCapabilities() throws Exception {
+    NetworkCapabilities nc = ShadowNetworkCapabilities.newInstance();
+    shadowOf(nc).addCapability(NetworkCapabilities.NET_CAPABILITY_MMS);
+
+    shadowOf(connectivityManager).setNetworkCapabilities(
+        shadowOf(connectivityManager).getActiveNetwork(), nc);
+
+    assertThat(
+            shadowOf(connectivityManager)
+                .getNetworkCapabilities(shadowOf(connectivityManager).getActiveNetwork())
+                .hasCapability(NetworkCapabilities.NET_CAPABILITY_MMS))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getNetworkCapabilities_shouldReturnDefaultCapabilities() throws Exception {
+    for (Network network : connectivityManager.getAllNetworks()) {
+      NetworkCapabilities nc = connectivityManager.getNetworkCapabilities(network);
+      assertThat(nc).isNotNull();
+
+      int netId = shadowOf(network).getNetId();
+      if (netId == ShadowConnectivityManager.NET_ID_WIFI) {
+        assertThat(nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).isTrue();
+      }
+      if (netId == ShadowConnectivityManager.NET_ID_MOBILE) {
+        assertThat(nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).isTrue();
+      }
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getCaptivePortalServerUrl_shouldReturnAddedUrl() {
+    assertThat(connectivityManager.getCaptivePortalServerUrl()).isEqualTo("http://10.0.0.2");
+
+    shadowOf(connectivityManager).setCaptivePortalServerUrl("http://10.0.0.1");
+    assertThat(connectivityManager.getCaptivePortalServerUrl()).isEqualTo("http://10.0.0.1");
+
+    shadowOf(connectivityManager).setCaptivePortalServerUrl("http://10.0.0.2");
+    assertThat(connectivityManager.getCaptivePortalServerUrl()).isEqualTo("http://10.0.0.2");
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void setAirplaneMode() {
+    connectivityManager.setAirplaneMode(false);
+    assertThat(
+            Settings.Global.getInt(
+                getApplicationContext().getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, -1))
+        .isEqualTo(0);
+    connectivityManager.setAirplaneMode(true);
+    assertThat(
+            Settings.Global.getInt(
+                getApplicationContext().getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, -1))
+        .isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getLinkProperties() {
+    Network network = shadowOf(connectivityManager).getActiveNetwork();
+    LinkProperties lp = ReflectionHelpers.callConstructor(LinkProperties.class);
+    shadowOf(connectivityManager).setLinkProperties(network, lp);
+
+    assertThat(connectivityManager.getLinkProperties(network)).isEqualTo(lp);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getLinkProperties_shouldReturnNull() {
+    Network network = shadowOf(connectivityManager).getActiveNetwork();
+    shadowOf(connectivityManager).setLinkProperties(network, null);
+
+    assertThat(connectivityManager.getLinkProperties(network)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setRestrictBackgroundStatus() {
+    shadowOf(connectivityManager).setRestrictBackgroundStatus(1);
+    assertThat(connectivityManager.getRestrictBackgroundStatus())
+        .isEqualTo(RESTRICT_BACKGROUND_STATUS_DISABLED);
+
+    shadowOf(connectivityManager).setRestrictBackgroundStatus(2);
+    assertThat(connectivityManager.getRestrictBackgroundStatus())
+        .isEqualTo(RESTRICT_BACKGROUND_STATUS_WHITELISTED);
+
+    shadowOf(connectivityManager).setRestrictBackgroundStatus(3);
+    assertThat(connectivityManager.getRestrictBackgroundStatus())
+        .isEqualTo(RESTRICT_BACKGROUND_STATUS_ENABLED);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setRestrictBackgroundStatus_defaultValueIsDisabled() {
+    assertThat(connectivityManager.getRestrictBackgroundStatus())
+        .isEqualTo(RESTRICT_BACKGROUND_STATUS_DISABLED);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  @Config(minSdk = N)
+  public void setRestrictBackgroundStatus_throwsExceptionOnIncorrectStatus0() throws Exception{
+    shadowOf(connectivityManager).setRestrictBackgroundStatus(0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  @Config(minSdk = N)
+  public void setRestrictBackgroundStatus_throwsExceptionOnIncorrectStatus4() throws Exception{
+    shadowOf(connectivityManager).setRestrictBackgroundStatus(4);
+  }
+
+  @Test
+  public void checkPollingTetherThreadNotCreated() throws Exception {
+    for (StackTraceElement[] elements : Thread.getAllStackTraces().values()) {
+      for (StackTraceElement element : elements) {
+        if (element.toString().contains("android.net.TetheringManager")) {
+          throw new RuntimeException("Found polling thread " + Arrays.toString(elements));
+        }
+      }
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getProxyForNetwork() {
+    Network network = connectivityManager.getActiveNetwork();
+    connectivityManager.bindProcessToNetwork(network);
+    ProxyInfo proxyInfo = ProxyInfo.buildDirectProxy("10.11.12.13", 1234);
+
+    shadowOf(connectivityManager).setProxyForNetwork(network, proxyInfo);
+
+    assertThat(connectivityManager.getProxyForNetwork(network)).isEqualTo(proxyInfo);
+    assertThat(connectivityManager.getDefaultProxy()).isEqualTo(proxyInfo);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getProxyForNetwork_shouldReturnNullByDefaultWithBoundProcess() {
+    Network network = connectivityManager.getActiveNetwork();
+    connectivityManager.bindProcessToNetwork(network);
+
+    assertThat(connectivityManager.getProxyForNetwork(network)).isNull();
+    assertThat(connectivityManager.getDefaultProxy()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getProxyForNetwork_shouldReturnNullByDefaultNoBoundProcess() {
+    Network network = connectivityManager.getActiveNetwork();
+
+    assertThat(connectivityManager.getProxyForNetwork(network)).isNull();
+    assertThat(connectivityManager.getDefaultProxy()).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentCaptureManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentCaptureManagerTest.java
new file mode 100644
index 0000000..67f16b5
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentCaptureManagerTest.java
@@ -0,0 +1,116 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.content.ComponentName;
+import android.content.LocusId;
+import android.os.ParcelFileDescriptor;
+import android.view.contentcapture.ContentCaptureCondition;
+import android.view.contentcapture.ContentCaptureManager;
+import android.view.contentcapture.DataShareWriteAdapter;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.HashSet;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Test for {@link ShadowContentCaptureManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = R)
+public final class ShadowContentCaptureManagerTest {
+
+  private ShadowContentCaptureManager instance;
+
+  @Before
+  public void setUp() throws Exception {
+    ContentCaptureManager contentCaptureManagerShadow =
+        Shadow.newInstanceOf(ContentCaptureManager.class);
+    instance = Shadow.extract(contentCaptureManagerShadow);
+
+    assertThat(instance).isNotNull();
+  }
+
+  @Test
+  public void getContentCaptureConditions() {
+    assertThat(instance.getContentCaptureConditions()).isNull();
+  }
+
+  @Test
+  public void getContentCaptureConditions_withContentCaptureConditions() {
+    Set<ContentCaptureCondition> contentCaptureConditions = new HashSet<>();
+    contentCaptureConditions.add(new ContentCaptureCondition(new LocusId("fake locusId"), 0));
+
+    instance.setContentCaptureConditions(contentCaptureConditions);
+    assertThat(instance.getContentCaptureConditions()).isEqualTo(contentCaptureConditions);
+  }
+
+  @Test
+  public void getServiceComponentName() {
+    assertThat(instance.getServiceComponentName()).isNull();
+  }
+
+  @Test
+  public void getServiceComponentName_nonNull() {
+    ComponentName componentName = new ComponentName("fake pkg", "fake cls");
+
+    instance.setServiceComponentName(componentName);
+    assertThat(instance.getServiceComponentName()).isEqualTo(componentName);
+  }
+
+  @Test
+  public void isContentCaptureEnabled() {
+    assertThat(instance.isContentCaptureEnabled()).isFalse();
+  }
+
+  @Test
+  public void isContentCaptureEnabled_setToTrue() {
+    instance.setContentCaptureEnabled(true);
+    assertThat(instance.isContentCaptureEnabled()).isTrue();
+  }
+
+  @Test
+  public void shareData() {
+    DataShareWriteAdapter adapter = mock(DataShareWriteAdapter.class);
+
+    instance.shareData(null, null, adapter);
+
+    verify(adapter).onWrite(null);
+  }
+
+  @Test
+  public void shareData_parcelFileDescriptorSpecified() {
+    DataShareWriteAdapter adapter = mock(DataShareWriteAdapter.class);
+    ParcelFileDescriptor parcelFileDescriptor = mock(ParcelFileDescriptor.class);
+
+    instance.setShareDataParcelFileDescriptor(parcelFileDescriptor);
+    instance.shareData(null, null, adapter);
+
+    verify(adapter).onWrite(parcelFileDescriptor);
+  }
+
+  @Test
+  public void shareData_withRejection() {
+    DataShareWriteAdapter adapter = mock(DataShareWriteAdapter.class);
+
+    instance.setShouldRejectRequest(true);
+    instance.shareData(null, null, adapter);
+
+    verify(adapter).onRejected();
+  }
+
+  @Test
+  public void shareData_withDataShareErrorCode() {
+    DataShareWriteAdapter adapter = mock(DataShareWriteAdapter.class);
+
+    instance.setDataShareErrorCode(ContentCaptureManager.DATA_SHARE_ERROR_CONCURRENT_REQUEST);
+    instance.shareData(null, null, adapter);
+
+    verify(adapter).onError(ContentCaptureManager.DATA_SHARE_ERROR_CONCURRENT_REQUEST);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentObserverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentObserverTest.java
new file mode 100644
index 0000000..d2ae2ca
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentObserverTest.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentObserverTest {
+
+  private TestContentObserver observer;
+
+  @Before
+  public void setUp() throws Exception {
+    observer = new TestContentObserver(null);
+  }
+
+  @Test
+  public void testDispatchChangeBooleanUri() {
+    assertThat(observer.changed).isFalse();
+    assertThat(observer.selfChange).isFalse();
+    assertThat(observer.uri).isNull();
+
+    Uri uri = Uri.parse("http://www.somewhere.com");
+    observer.dispatchChange(true, uri);
+
+    assertThat(observer.changed).isTrue();
+    assertThat(observer.selfChange).isTrue();
+    assertThat(observer.uri).isSameInstanceAs(uri);
+  }
+
+  @Test
+  public void testDispatchChangeBoolean() {
+    assertThat(observer.changed).isFalse();
+    assertThat(observer.selfChange).isFalse();
+
+    observer.dispatchChange(true);
+
+    assertThat(observer.changed).isTrue();
+    assertThat(observer.selfChange).isTrue();
+  }
+
+  private static class TestContentObserver extends ContentObserver {
+
+    public TestContentObserver(Handler handler) {
+      super(handler);
+    }
+
+    public boolean changed = false;
+    public boolean selfChange = false;
+    public Uri uri = null;
+
+    @Override
+    public void onChange(boolean selfChange) {
+      changed = true;
+      this.selfChange = selfChange;
+    }
+
+    @Override
+    public void onChange(boolean selfChange, Uri uri) {
+      changed = true;
+      this.selfChange = selfChange;
+      this.uri = uri;
+    }
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderClientTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderClientTest.java
new file mode 100644
index 0000000..a50e9c7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderClientTest.java
@@ -0,0 +1,82 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ContentProvider;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.controller.ContentProviderController;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.testing.TestContentProvider1;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentProviderClientTest {
+
+  private static final String AUTHORITY = "org.robolectric";
+
+  private final ContentProviderController<TestContentProvider1> controller =
+      Robolectric.buildContentProvider(TestContentProvider1.class);
+
+  ContentProvider provider = controller.create().get();
+
+  ContentResolver contentResolver =
+      ApplicationProvider.getApplicationContext().getContentResolver();
+
+  ContentProviderClient client;
+
+  @Before
+  public void setUp() {
+    ShadowContentResolver.registerProviderInternal(AUTHORITY, provider);
+  }
+
+  @After
+  public void tearDown() {
+    if (client != null) {
+      if (RuntimeEnvironment.getApiLevel() > M) {
+        client.close();
+      } else {
+        client.release();
+      }
+    }
+  }
+
+  @Test
+  public void acquireContentProviderClient_isStable() {
+    client = contentResolver.acquireContentProviderClient(AUTHORITY);
+    assertThat(shadowOf(client).isStable()).isTrue();
+  }
+
+  @Test
+  public void acquireUnstableContentProviderClient_isUnstable() {
+    client = contentResolver.acquireUnstableContentProviderClient(AUTHORITY);
+    assertThat(shadowOf(client).isStable()).isFalse();
+  }
+
+  @Test
+  public void release_shouldRelease() {
+    ContentProviderClient client = contentResolver.acquireContentProviderClient(AUTHORITY);
+    ShadowContentProviderClient shadow = shadowOf(client);
+    assertThat(shadow.isReleased()).isFalse();
+    client.release();
+    assertThat(shadow.isReleased()).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = M)
+  public void release_shouldFailWhenCalledTwice() {
+    ContentProviderClient client = contentResolver.acquireContentProviderClient(AUTHORITY);
+    client.release();
+    assertThrows(IllegalStateException.class, () -> client.release());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderOperationBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderOperationBuilderTest.java
new file mode 100644
index 0000000..60cb76d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderOperationBuilderTest.java
@@ -0,0 +1,90 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentProviderOperationBuilderTest {
+
+  @Test
+  public void build() throws Exception {
+    Uri uri = Uri.parse("content://authority/path");
+
+    ContentProviderOperation.Builder builder = ContentProviderOperation.newUpdate(uri);
+    builder.withSelection("a=?", new String[] {"a"});
+    builder.withValue("k1", "v1");
+    ContentValues cv = new ContentValues();
+    cv.put("k2", "v2");
+    builder.withValues(cv);
+    ContentProviderOperation op = builder.build();
+
+    assertThat(op).isNotNull();
+    assertThat(op.getUri()).isEqualTo(uri);
+
+    final ContentRequest request = new ContentRequest();
+    ContentProvider provider = new ContentProvider() {
+      @Override
+      public boolean onCreate() {
+        return true;
+      }
+
+      @Override
+      public Cursor query(Uri uri, String[] projection, String selection,
+          String[] selectionArgs, String sortOrder) {
+        return null;
+      }
+
+      @Override
+      public String getType(Uri uri) {
+        return null;
+      }
+
+      @Override
+      public Uri insert(Uri uri, ContentValues values) {
+        return null;
+      }
+
+      @Override
+      public int delete(Uri uri, String selection, String[] selectionArgs) {
+        return 0;
+      }
+
+      @Override
+      public int update(Uri uri, ContentValues values, String selection,
+          String[] selectionArgs) {
+        request.uri = uri;
+        request.values = values;
+        request.selection = selection;
+        request.selectionArgs = selectionArgs;
+        return 0;
+      }
+
+    };
+
+    op.apply(provider, null, 0);
+
+    assertThat(request.uri).isEqualTo(uri);
+    assertThat(request.selection).isEqualTo("a=?");
+    assertThat(request.selectionArgs).isEqualTo(new String[] {"a"});
+
+    assertThat(request.values.containsKey("k1")).isTrue();
+    assertThat(request.values.containsKey("k2")).isTrue();
+
+  }
+
+  static class ContentRequest {
+    Uri uri;
+    String selection;
+    String[] selectionArgs;
+    ContentValues values;
+  }
+
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderOperationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderOperationTest.java
new file mode 100644
index 0000000..d87e9b4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderOperationTest.java
@@ -0,0 +1,60 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentProviderOperation;
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowContentProviderOperation}. */
+@RunWith(AndroidJUnit4.class)
+@Config(maxSdk = Q)
+public class ShadowContentProviderOperationTest {
+
+  @Test
+  public void reflectionShouldWork() {
+    final Uri uri = Uri.parse("content://authority/path");
+
+    ContentProviderOperation op = ContentProviderOperation.newInsert(uri)
+        .withValue("insertKey", "insertValue")
+        .withValueBackReference("backKey", 2)
+        .build();
+
+    // insert and values back references
+    assertThat(op.getUri()).isEqualTo(uri);
+    ShadowContentProviderOperation shadow = Shadows.shadowOf(op);
+    assertThat(shadow.getType()).isEqualTo(ShadowContentProviderOperation.TYPE_INSERT);
+    assertThat(shadow.getContentValues().getAsString("insertKey")).isEqualTo("insertValue");
+    assertThat(shadow.getValuesBackReferences().getAsInteger("backKey")).isEqualTo(2);
+
+    // update and selection back references
+    op = ContentProviderOperation.newUpdate(uri)
+        .withValue("updateKey", "updateValue")
+        .withSelection("a=? and b=?", new String[] {"abc"})
+        .withSelectionBackReference(1, 3)
+        .build();
+    assertThat(op.getUri()).isEqualTo(uri);
+    shadow = Shadows.shadowOf(op);
+    assertThat(shadow.getType()).isEqualTo(ShadowContentProviderOperation.TYPE_UPDATE);
+    assertThat(shadow.getContentValues().getAsString("updateKey")).isEqualTo("updateValue");
+    assertThat(shadow.getSelection()).isEqualTo("a=? and b=?");
+    assertThat(shadow.getSelectionArgs()).asList().containsExactly("abc");
+    assertThat(shadow.getSelectionArgsBackReferences()).isEqualTo(Collections.singletonMap(1, 3));
+
+    // delete and expected count
+    op = ContentProviderOperation.newDelete(uri)
+        .withExpectedCount(1)
+        .build();
+    assertThat(op.getUri()).isEqualTo(uri);
+    shadow = Shadows.shadowOf(op);
+    assertThat(shadow.getType()).isEqualTo(ShadowContentProviderOperation.TYPE_DELETE);
+    assertThat(shadow.getExpectedCount()).isEqualTo(1);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderResultTest.java
new file mode 100644
index 0000000..40a47b9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderResultTest.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentProviderResult;
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentProviderResultTest {
+  @Test
+  public void count() {
+    ContentProviderResult result = new ContentProviderResult(5);
+    assertThat(result.count).isEqualTo(5);
+  }
+
+  @Test
+  public void uri() {
+    Uri uri = Uri.parse("content://org.robolectric");
+    ContentProviderResult result = new ContentProviderResult(uri);
+    assertThat(result.uri).isEqualTo(uri);
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderTest.java
new file mode 100644
index 0000000..ebfaf57
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentProviderTest.java
@@ -0,0 +1,23 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ContentProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.testing.TestContentProvider1;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentProviderTest {
+  @Config(minSdk = KITKAT)
+  @Test
+  public void testSetCallingPackage() {
+    ContentProvider provider = new TestContentProvider1();
+    shadowOf(provider).setCallingPackage("calling-package");
+    assertThat(provider.getCallingPackage()).isEqualTo("calling-package");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentResolverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentResolverTest.java
new file mode 100644
index 0000000..c77bb1e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentResolverTest.java
@@ -0,0 +1,1183 @@
+package org.robolectric.shadows;
+
+import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+import static android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.Config.NONE;
+
+import android.accounts.Account;
+import android.annotation.SuppressLint;
+import android.app.Application;
+import android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.OperationApplicationException;
+import android.content.PeriodicSync;
+import android.content.SyncAdapterType;
+import android.content.SyncInfo;
+import android.content.UriPermission;
+import android.content.pm.ProviderInfo;
+import android.content.res.AssetFileDescriptor;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Iterables;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.fakes.BaseCursor;
+import org.robolectric.util.NamedStream;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentResolverTest {
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private static final String AUTHORITY = "org.robolectric";
+
+  private ContentResolver contentResolver;
+  private ShadowContentResolver shadowContentResolver;
+  private Uri uri21;
+  private Uri uri22;
+  private Account a, b;
+
+  @Before
+  public void setUp() {
+    contentResolver = ApplicationProvider.getApplicationContext().getContentResolver();
+    shadowContentResolver = shadowOf(contentResolver);
+    uri21 = Uri.parse(EXTERNAL_CONTENT_URI.toString() + "/21");
+    uri22 = Uri.parse(EXTERNAL_CONTENT_URI.toString() + "/22");
+
+    a = new Account("a", "type");
+    b = new Account("b", "type");
+  }
+
+  @Test
+  public void insert_shouldReturnIncreasingUris() {
+    shadowContentResolver.setNextDatabaseIdForInserts(20);
+
+    assertThat(contentResolver.insert(EXTERNAL_CONTENT_URI, new ContentValues())).isEqualTo(uri21);
+    assertThat(contentResolver.insert(EXTERNAL_CONTENT_URI, new ContentValues())).isEqualTo(uri22);
+  }
+
+  @Test
+  public void getType_shouldDefaultToNull() {
+    assertThat(contentResolver.getType(uri21)).isNull();
+  }
+
+  @Test
+  public void getType_shouldReturnProviderValue() {
+    ShadowContentResolver.registerProviderInternal(
+        AUTHORITY,
+        new ContentProvider() {
+          @Override
+          public boolean onCreate() {
+            return false;
+          }
+
+          @Override
+          public Cursor query(
+              Uri uri,
+              String[] projection,
+              String selection,
+              String[] selectionArgs,
+              String sortOrder) {
+            return new BaseCursor();
+          }
+
+          @Override
+          public Uri insert(Uri uri, ContentValues values) {
+            return null;
+          }
+
+          @Override
+          public int delete(Uri uri, String selection, String[] selectionArgs) {
+            return -1;
+          }
+
+          @Override
+          public int update(
+              Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+            return -1;
+          }
+
+          @Override
+          public String getType(Uri uri) {
+            return "mytype";
+          }
+        });
+    final Uri uri = Uri.parse("content://" + AUTHORITY + "/some/path");
+    assertThat(contentResolver.getType(uri)).isEqualTo("mytype");
+  }
+
+  @Test
+  public void insert_shouldTrackInsertStatements() {
+    ContentValues contentValues = new ContentValues();
+    contentValues.put("foo", "bar");
+    contentResolver.insert(EXTERNAL_CONTENT_URI, contentValues);
+    assertThat(shadowContentResolver.getInsertStatements().size()).isEqualTo(1);
+    assertThat(shadowContentResolver.getInsertStatements().get(0).getUri())
+        .isEqualTo(EXTERNAL_CONTENT_URI);
+    assertThat(
+            shadowContentResolver
+                .getInsertStatements()
+                .get(0)
+                .getContentValues()
+                .getAsString("foo"))
+        .isEqualTo("bar");
+
+    contentValues = new ContentValues();
+    contentValues.put("hello", "world");
+    contentResolver.insert(EXTERNAL_CONTENT_URI, contentValues);
+    assertThat(shadowContentResolver.getInsertStatements().size()).isEqualTo(2);
+    assertThat(
+            shadowContentResolver
+                .getInsertStatements()
+                .get(1)
+                .getContentValues()
+                .getAsString("hello"))
+        .isEqualTo("world");
+  }
+
+  @Test
+  public void insert_shouldTrackUpdateStatements() {
+    ContentValues contentValues = new ContentValues();
+    contentValues.put("foo", "bar");
+    contentResolver.update(
+        EXTERNAL_CONTENT_URI, contentValues, "robolectric", new String[] {"awesome"});
+    assertThat(shadowContentResolver.getUpdateStatements().size()).isEqualTo(1);
+    assertThat(shadowContentResolver.getUpdateStatements().get(0).getUri())
+        .isEqualTo(EXTERNAL_CONTENT_URI);
+    assertThat(
+            shadowContentResolver
+                .getUpdateStatements()
+                .get(0)
+                .getContentValues()
+                .getAsString("foo"))
+        .isEqualTo("bar");
+    assertThat(shadowContentResolver.getUpdateStatements().get(0).getWhere())
+        .isEqualTo("robolectric");
+    assertThat(shadowContentResolver.getUpdateStatements().get(0).getSelectionArgs())
+        .isEqualTo(new String[] {"awesome"});
+
+    contentValues = new ContentValues();
+    contentValues.put("hello", "world");
+    contentResolver.update(EXTERNAL_CONTENT_URI, contentValues, null, null);
+    assertThat(shadowContentResolver.getUpdateStatements().size()).isEqualTo(2);
+    assertThat(shadowContentResolver.getUpdateStatements().get(1).getUri())
+        .isEqualTo(EXTERNAL_CONTENT_URI);
+    assertThat(
+            shadowContentResolver
+                .getUpdateStatements()
+                .get(1)
+                .getContentValues()
+                .getAsString("hello"))
+        .isEqualTo("world");
+    assertThat(shadowContentResolver.getUpdateStatements().get(1).getWhere()).isNull();
+    assertThat(shadowContentResolver.getUpdateStatements().get(1).getSelectionArgs()).isNull();
+  }
+
+  @Test
+  public void insert_supportsNullContentValues() {
+    contentResolver.insert(EXTERNAL_CONTENT_URI, null);
+    assertThat(shadowContentResolver.getInsertStatements().get(0).getContentValues()).isNull();
+  }
+
+  @Test
+  public void update_supportsNullContentValues() {
+    contentResolver.update(EXTERNAL_CONTENT_URI, null, null, null);
+    assertThat(shadowContentResolver.getUpdateStatements().get(0).getContentValues()).isNull();
+  }
+
+  @Test
+  public void delete_shouldTrackDeletedUris() {
+    assertThat(shadowContentResolver.getDeletedUris().size()).isEqualTo(0);
+
+    assertThat(contentResolver.delete(uri21, null, null)).isEqualTo(1);
+    assertThat(shadowContentResolver.getDeletedUris()).contains(uri21);
+    assertThat(shadowContentResolver.getDeletedUris().size()).isEqualTo(1);
+
+    assertThat(contentResolver.delete(uri22, null, null)).isEqualTo(1);
+    assertThat(shadowContentResolver.getDeletedUris()).contains(uri22);
+    assertThat(shadowContentResolver.getDeletedUris().size()).isEqualTo(2);
+  }
+
+  @Test
+  public void delete_shouldTrackDeletedStatements() {
+    assertThat(shadowContentResolver.getDeleteStatements().size()).isEqualTo(0);
+
+    assertThat(contentResolver.delete(uri21, "id", new String[] {"5"})).isEqualTo(1);
+    assertThat(shadowContentResolver.getDeleteStatements().size()).isEqualTo(1);
+    assertThat(shadowContentResolver.getDeleteStatements().get(0).getUri()).isEqualTo(uri21);
+    assertThat(shadowContentResolver.getDeleteStatements().get(0).getContentProvider()).isNull();
+    assertThat(shadowContentResolver.getDeleteStatements().get(0).getWhere()).isEqualTo("id");
+    assertThat(shadowContentResolver.getDeleteStatements().get(0).getSelectionArgs()[0])
+        .isEqualTo("5");
+
+    assertThat(contentResolver.delete(uri21, "foo", new String[] {"bar"})).isEqualTo(1);
+    assertThat(shadowContentResolver.getDeleteStatements().size()).isEqualTo(2);
+    assertThat(shadowContentResolver.getDeleteStatements().get(1).getUri()).isEqualTo(uri21);
+    assertThat(shadowContentResolver.getDeleteStatements().get(1).getWhere()).isEqualTo("foo");
+    assertThat(shadowContentResolver.getDeleteStatements().get(1).getSelectionArgs()[0])
+        .isEqualTo("bar");
+  }
+
+  @Test
+  public void whenCursorHasBeenSet_query_shouldReturnTheCursor() {
+    assertThat(shadowContentResolver.query(null, null, null, null, null)).isNull();
+    BaseCursor cursor = new BaseCursor();
+    shadowContentResolver.setCursor(cursor);
+    assertThat((BaseCursor) shadowContentResolver.query(null, null, null, null, null))
+        .isSameInstanceAs(cursor);
+  }
+
+  @Test
+  public void whenCursorHasBeenSet_queryWithCancellationSignal_shouldReturnTheCursor() {
+    assertThat(shadowContentResolver.query(null, null, null, null, null, new CancellationSignal()))
+        .isNull();
+    BaseCursor cursor = new BaseCursor();
+    shadowContentResolver.setCursor(cursor);
+    assertThat(
+            (BaseCursor)
+                shadowContentResolver.query(null, null, null, null, null, new CancellationSignal()))
+        .isSameInstanceAs(cursor);
+  }
+
+  @Test
+  public void query_shouldReturnSpecificCursorsForSpecificUris() {
+    assertThat(shadowContentResolver.query(uri21, null, null, null, null)).isNull();
+    assertThat(shadowContentResolver.query(uri22, null, null, null, null)).isNull();
+
+    BaseCursor cursor21 = new BaseCursor();
+    BaseCursor cursor22 = new BaseCursor();
+    shadowContentResolver.setCursor(uri21, cursor21);
+    shadowContentResolver.setCursor(uri22, cursor22);
+
+    assertThat((BaseCursor) shadowContentResolver.query(uri21, null, null, null, null))
+        .isSameInstanceAs(cursor21);
+    assertThat((BaseCursor) shadowContentResolver.query(uri22, null, null, null, null))
+        .isSameInstanceAs(cursor22);
+  }
+
+  @Test
+  public void query_shouldKnowWhatItsParamsWere() {
+    String[] projection = {};
+    String selection = "select";
+    String[] selectionArgs = {};
+    String sortOrder = "order";
+
+    QueryParamTrackingCursor testCursor = new QueryParamTrackingCursor();
+
+    shadowContentResolver.setCursor(testCursor);
+    Cursor cursor =
+        shadowContentResolver.query(uri21, projection, selection, selectionArgs, sortOrder);
+    assertThat((QueryParamTrackingCursor) cursor).isEqualTo(testCursor);
+    assertThat(testCursor.uri).isEqualTo(uri21);
+    assertThat(testCursor.projection).isEqualTo(projection);
+    assertThat(testCursor.selection).isEqualTo(selection);
+    assertThat(testCursor.selectionArgs).isEqualTo(selectionArgs);
+    assertThat(testCursor.sortOrder).isEqualTo(sortOrder);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void query_shouldKnowWhatIsInBundle() {
+    String[] projection = {};
+    String selection = "select";
+    String[] selectionArgs = {};
+    String sortOrder = "order";
+    Bundle queryArgs = new Bundle();
+    queryArgs.putString(QUERY_ARG_SQL_SELECTION, selection);
+    queryArgs.putStringArray(QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs);
+    queryArgs.putString(QUERY_ARG_SQL_SORT_ORDER, sortOrder);
+
+    QueryParamTrackingCursor testCursor = new QueryParamTrackingCursor();
+    shadowContentResolver.setCursor(testCursor);
+    Cursor cursor = shadowContentResolver.query(uri21, projection, queryArgs, null);
+    assertThat((QueryParamTrackingCursor) cursor).isEqualTo(testCursor);
+    assertThat(testCursor.uri).isEqualTo(uri21);
+    assertThat(testCursor.projection).isEqualTo(projection);
+    assertThat(testCursor.selection).isEqualTo(selection);
+    assertThat(testCursor.selectionArgs).isEqualTo(selectionArgs);
+    assertThat(testCursor.sortOrder).isEqualTo(sortOrder);
+  }
+
+  @Test
+  public void acquireUnstableProvider_shouldDefaultToNull() {
+    assertThat(contentResolver.acquireUnstableProvider(uri21)).isNull();
+  }
+
+  @Test
+  public void acquireUnstableProvider_shouldReturnWithUri() {
+    ContentProvider cp = mock(ContentProvider.class);
+    ShadowContentResolver.registerProviderInternal(AUTHORITY, cp);
+    final Uri uri = Uri.parse("content://" + AUTHORITY);
+    assertThat(contentResolver.acquireUnstableProvider(uri))
+        .isSameInstanceAs(cp.getIContentProvider());
+  }
+
+  @Test
+  public void acquireUnstableProvider_shouldReturnWithString() {
+    ContentProvider cp = mock(ContentProvider.class);
+    ShadowContentResolver.registerProviderInternal(AUTHORITY, cp);
+    assertThat(contentResolver.acquireUnstableProvider(AUTHORITY))
+        .isSameInstanceAs(cp.getIContentProvider());
+  }
+
+  @Test
+  public void call_shouldCallProvider() {
+    final String METHOD = "method";
+    final String ARG = "arg";
+    final Bundle EXTRAS = new Bundle();
+    final Uri uri = Uri.parse("content://" + AUTHORITY);
+
+    ContentProvider provider = mock(ContentProvider.class);
+    doReturn(null).when(provider).call(METHOD, ARG, EXTRAS);
+    ShadowContentResolver.registerProviderInternal(AUTHORITY, provider);
+
+    contentResolver.call(uri, METHOD, ARG, EXTRAS);
+    verify(provider).call(METHOD, ARG, EXTRAS);
+  }
+
+  @Test
+  public void registerProvider_shouldAttachProviderInfo() {
+    ContentProvider mock = mock(ContentProvider.class);
+
+    ProviderInfo providerInfo0 = new ProviderInfo();
+    providerInfo0.authority = "the-authority"; // todo: support multiple authorities
+    providerInfo0.grantUriPermissions = true;
+    mock.attachInfo(ApplicationProvider.getApplicationContext(), providerInfo0);
+    mock.onCreate();
+
+    ArgumentCaptor<ProviderInfo> captor = ArgumentCaptor.forClass(ProviderInfo.class);
+    verify(mock)
+        .attachInfo(
+            same((Application) ApplicationProvider.getApplicationContext()), captor.capture());
+    ProviderInfo providerInfo = captor.getValue();
+
+    assertThat(providerInfo.authority).isEqualTo("the-authority");
+    assertThat(providerInfo.grantUriPermissions).isEqualTo(true);
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void openInputStream_shouldReturnAnInputStreamThatExceptionsOnRead() throws Exception {
+    InputStream inputStream = contentResolver.openInputStream(uri21);
+    inputStream.read();
+  }
+
+  @Test
+  public void openInputStream_returnsPreRegisteredStream() throws Exception {
+    shadowContentResolver.registerInputStream(
+        uri21, new ByteArrayInputStream("ourStream".getBytes(UTF_8)));
+    InputStream inputStream = contentResolver.openInputStream(uri21);
+    byte[] data = new byte[9];
+    inputStream.read(data);
+    assertThat(new String(data, UTF_8)).isEqualTo("ourStream");
+  }
+
+  @Test
+  public void openInputStream_returnsNewStreamEachTimeFromRegisteredSupplier() throws Exception {
+    shadowContentResolver.registerInputStreamSupplier(
+        uri21, () -> new ByteArrayInputStream("ourStream".getBytes(UTF_8)));
+    InputStream inputStream1 = contentResolver.openInputStream(uri21);
+    byte[] data1 = new byte[9];
+    inputStream1.read(data1);
+    inputStream1.close();
+    InputStream inputStream2 = contentResolver.openInputStream(uri21);
+    byte[] data2 = new byte[9];
+    inputStream2.read(data2);
+    inputStream2.close();
+    assertThat(new String(data1, UTF_8)).isEqualTo("ourStream");
+    assertThat(new String(data2, UTF_8)).isEqualTo("ourStream");
+  }
+
+  @Test
+  public void openInputStream_returnsResourceUriStream() throws Exception {
+    InputStream inputStream =
+        contentResolver.openInputStream(
+            new Uri.Builder()
+                .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+                .authority(ApplicationProvider.getApplicationContext().getPackageName())
+                .appendPath(String.valueOf(R.drawable.an_image))
+                .build());
+    assertThat(inputStream).isNotNull();
+    inputStream.read();
+  }
+
+  @SuppressLint("NewApi")
+  @Test
+  public void openInputStream_returnsFileUriStream() throws Exception {
+    File file = temporaryFolder.newFile();
+    try (FileOutputStream out = new FileOutputStream(file)) {
+      out.write("foo".getBytes(UTF_8));
+    }
+
+    InputStream inputStream = contentResolver.openInputStream(Uri.fromFile(file));
+
+    assertThat(inputStream).isNotNull();
+    assertThat(new String(inputStream.readAllBytes(), UTF_8)).isEqualTo("foo");
+  }
+
+  @Test
+  public void openInputStream_returnsProviderInputStream() throws Exception {
+    ProviderInfo info = new ProviderInfo();
+    info.authority = AUTHORITY;
+    ContentProvider myContentProvider = new MyContentProvider();
+    myContentProvider.attachInfo(ApplicationProvider.getApplicationContext(), info);
+    ShadowContentResolver.registerProviderInternal(AUTHORITY, myContentProvider);
+
+    Uri uri = Uri.parse("content://" + AUTHORITY + "/some/path");
+    InputStream actualInputStream = contentResolver.openInputStream(uri);
+    // Registered provider does not return named stream
+    assertThat(actualInputStream).isNotInstanceOf(NamedStream.class);
+
+    Uri otherUri = Uri.parse("content://otherAuthority/some/path");
+    InputStream secondInputStream = contentResolver.openInputStream(otherUri);
+    // No registered provider results in named stream
+    assertThat(secondInputStream).isInstanceOf(NamedStream.class);
+
+    shadowContentResolver.registerInputStreamSupplier(
+        uri, () -> new ByteArrayInputStream("ourStream".getBytes(UTF_8)));
+    InputStream registeredInputStream = contentResolver.openInputStream(uri);
+    byte[] byteArray = new byte[registeredInputStream.available()];
+    registeredInputStream.read(byteArray);
+    // Explicitly registered stream takes precedence
+    assertThat(byteArray).isEqualTo("ourStream".getBytes(UTF_8));
+  }
+
+  @Test
+  public void openOutputStream_shouldReturnAnOutputStream() throws Exception {
+    assertThat(contentResolver.openOutputStream(uri21)).isInstanceOf(OutputStream.class);
+  }
+
+  @Test
+  public void openOutputStream_shouldReturnRegisteredStream() throws Exception {
+    final Uri uri = Uri.parse("content://registeredProvider/path");
+
+    AtomicInteger callCount = new AtomicInteger();
+    OutputStream outputStream =
+        new OutputStream() {
+
+          @Override
+          public void write(int arg0) throws IOException {
+            callCount.incrementAndGet();
+          }
+
+          @Override
+          public String toString() {
+            return "outputstream for " + uri;
+          }
+        };
+
+    shadowOf(contentResolver).registerOutputStream(uri, outputStream);
+
+    assertThat(callCount.get()).isEqualTo(0);
+    contentResolver.openOutputStream(uri).write(5);
+    assertThat(callCount.get()).isEqualTo(1);
+
+    contentResolver.openOutputStream(uri21).write(5);
+    assertThat(callCount.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void openOutputStream_shouldReturnNewStreamFromRegisteredSupplier() throws Exception {
+    final Uri uri = Uri.parse("content://registeredProvider/path");
+
+    AtomicInteger streamCreateCount = new AtomicInteger();
+    shadowOf(contentResolver)
+        .registerOutputStreamSupplier(
+            uri,
+            () -> {
+              streamCreateCount.incrementAndGet();
+              AtomicBoolean isClosed = new AtomicBoolean();
+              isClosed.set(false);
+              OutputStream outputStream =
+                  new OutputStream() {
+                    @Override
+                    public void close() {
+                      isClosed.set(true);
+                    }
+
+                    @Override
+                    public void write(int arg0) throws IOException {
+                      if (isClosed.get()) {
+                        throw new IOException();
+                      }
+                    }
+
+                    @Override
+                    public String toString() {
+                      return "outputstream for " + uri;
+                    }
+                  };
+              return outputStream;
+            });
+
+    assertThat(streamCreateCount.get()).isEqualTo(0);
+    OutputStream outputStream1 = contentResolver.openOutputStream(uri);
+    outputStream1.close();
+    assertThat(streamCreateCount.get()).isEqualTo(1);
+
+    contentResolver.openOutputStream(uri).write(5);
+    assertThat(streamCreateCount.get()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldTrackNotifiedUris() {
+    contentResolver.notifyChange(Uri.parse("foo"), null, true);
+    contentResolver.notifyChange(Uri.parse("bar"), null);
+
+    assertThat(shadowContentResolver.getNotifiedUris().size()).isEqualTo(2);
+    ShadowContentResolver.NotifiedUri uri = shadowContentResolver.getNotifiedUris().get(0);
+
+    assertThat(uri.uri.toString()).isEqualTo("foo");
+    assertThat(uri.syncToNetwork).isTrue();
+    assertThat(uri.observer).isNull();
+
+    uri = shadowContentResolver.getNotifiedUris().get(1);
+
+    assertThat(uri.uri.toString()).isEqualTo("bar");
+    assertThat(uri.syncToNetwork).isFalse();
+    assertThat(uri.observer).isNull();
+  }
+
+  @SuppressWarnings("serial")
+  @Test
+  public void applyBatchForRegisteredProvider()
+      throws RemoteException, OperationApplicationException {
+    final List<String> operations = new ArrayList<>();
+    ShadowContentResolver.registerProviderInternal(
+        "registeredProvider",
+        new ContentProvider() {
+          @Override
+          public boolean onCreate() {
+            return true;
+          }
+
+          @Override
+          public Cursor query(
+              Uri uri,
+              String[] projection,
+              String selection,
+              String[] selectionArgs,
+              String sortOrder) {
+            operations.add("query");
+            MatrixCursor cursor = new MatrixCursor(new String[] {"a"});
+            cursor.addRow(new Object[] {"b"});
+            return cursor;
+          }
+
+          @Override
+          public String getType(Uri uri) {
+            return null;
+          }
+
+          @Override
+          public Uri insert(Uri uri, ContentValues values) {
+            operations.add("insert");
+            return ContentUris.withAppendedId(uri, 1);
+          }
+
+          @Override
+          public int delete(Uri uri, String selection, String[] selectionArgs) {
+            operations.add("delete");
+            return 0;
+          }
+
+          @Override
+          public int update(
+              Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+            operations.add("update");
+            return 0;
+          }
+        });
+
+    final Uri uri = Uri.parse("content://registeredProvider/path");
+    List<ContentProviderOperation> contentProviderOperations =
+        Arrays.asList(
+            ContentProviderOperation.newInsert(uri).withValue("a", "b").build(),
+            ContentProviderOperation.newUpdate(uri).withValue("a", "b").build(),
+            ContentProviderOperation.newDelete(uri).build(),
+            ContentProviderOperation.newAssertQuery(uri).withValue("a", "b").build());
+    contentResolver.applyBatch("registeredProvider", new ArrayList<>(contentProviderOperations));
+
+    assertThat(operations).containsExactly("insert", "update", "delete", "query");
+  }
+
+  @Test
+  public void applyBatchForUnregisteredProvider()
+      throws RemoteException, OperationApplicationException {
+    List<ContentProviderOperation> resultOperations =
+        shadowContentResolver.getContentProviderOperations(AUTHORITY);
+    assertThat(resultOperations).isNotNull();
+    assertThat(resultOperations.size()).isEqualTo(0);
+
+    ContentProviderResult[] contentProviderResults =
+        new ContentProviderResult[] {
+          new ContentProviderResult(1), new ContentProviderResult(1),
+        };
+    shadowContentResolver.setContentProviderResult(contentProviderResults);
+    Uri uri = Uri.parse("content://org.robolectric");
+    ArrayList<ContentProviderOperation> operations = new ArrayList<>();
+    operations.add(
+        ContentProviderOperation.newInsert(uri)
+            .withValue("column1", "foo")
+            .withValue("column2", 5)
+            .build());
+    operations.add(
+        ContentProviderOperation.newUpdate(uri)
+            .withSelection("id_column", new String[] {"99"})
+            .withValue("column1", "bar")
+            .build());
+    operations.add(
+        ContentProviderOperation.newDelete(uri)
+            .withSelection("id_column", new String[] {"11"})
+            .build());
+    ContentProviderResult[] result = contentResolver.applyBatch(AUTHORITY, operations);
+
+    resultOperations = shadowContentResolver.getContentProviderOperations(AUTHORITY);
+    assertThat(resultOperations).isEqualTo(operations);
+    assertThat(result).isEqualTo(contentProviderResults);
+  }
+
+  @Test
+  public void shouldKeepTrackOfSyncRequests() {
+    ShadowContentResolver.Status status = ShadowContentResolver.getStatus(a, AUTHORITY, true);
+    assertThat(status).isNotNull();
+    assertThat(status.syncRequests).isEqualTo(0);
+    ContentResolver.requestSync(a, AUTHORITY, new Bundle());
+    assertThat(status.syncRequests).isEqualTo(1);
+    assertThat(status.syncExtras).isNotNull();
+  }
+
+  @Test
+  public void shouldKnowIfSyncIsActive() {
+    assertThat(ContentResolver.isSyncActive(a, AUTHORITY)).isFalse();
+    ContentResolver.requestSync(a, AUTHORITY, new Bundle());
+    assertThat(ContentResolver.isSyncActive(a, AUTHORITY)).isTrue();
+  }
+
+  @Test
+  public void shouldGetCurrentSyncs() {
+    ContentResolver.requestSync(a, AUTHORITY, new Bundle());
+    ContentResolver.requestSync(b, AUTHORITY, new Bundle());
+
+    List<SyncInfo> syncs = ContentResolver.getCurrentSyncs();
+    assertThat(syncs.size()).isEqualTo(2);
+
+    SyncInfo syncA = Iterables.find(syncs, s -> s.account.equals(a));
+    assertThat(syncA.account).isEqualTo(a);
+    assertThat(syncA.authority).isEqualTo(AUTHORITY);
+
+    SyncInfo syncB = Iterables.find(syncs, s -> s.account.equals(b));
+    assertThat(syncB.account).isEqualTo(b);
+    assertThat(syncB.authority).isEqualTo(AUTHORITY);
+
+    ContentResolver.cancelSync(a, AUTHORITY);
+    List<SyncInfo> syncsAgain = ContentResolver.getCurrentSyncs();
+    assertThat(syncsAgain.size()).isEqualTo(1);
+
+    SyncInfo firstAgain = syncsAgain.get(0);
+    assertThat(firstAgain.account).isEqualTo(b);
+    assertThat(firstAgain.authority).isEqualTo(AUTHORITY);
+
+    ContentResolver.cancelSync(b, AUTHORITY);
+    List<SyncInfo> s = ContentResolver.getCurrentSyncs();
+    assertThat(s.size()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldCancelSync() {
+    ContentResolver.requestSync(a, AUTHORITY, new Bundle());
+    ContentResolver.requestSync(b, AUTHORITY, new Bundle());
+    assertThat(ContentResolver.isSyncActive(a, AUTHORITY)).isTrue();
+    assertThat(ContentResolver.isSyncActive(b, AUTHORITY)).isTrue();
+
+    ContentResolver.cancelSync(a, AUTHORITY);
+    assertThat(ContentResolver.isSyncActive(a, AUTHORITY)).isFalse();
+    assertThat(ContentResolver.isSyncActive(b, AUTHORITY)).isTrue();
+  }
+
+  @Test
+  public void shouldSetIsSyncable() {
+    assertThat(ContentResolver.getIsSyncable(a, AUTHORITY)).isEqualTo(-1);
+    assertThat(ContentResolver.getIsSyncable(b, AUTHORITY)).isEqualTo(-1);
+    ContentResolver.setIsSyncable(a, AUTHORITY, 1);
+    ContentResolver.setIsSyncable(b, AUTHORITY, 2);
+    assertThat(ContentResolver.getIsSyncable(a, AUTHORITY)).isEqualTo(1);
+    assertThat(ContentResolver.getIsSyncable(b, AUTHORITY)).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldSetSyncAutomatically() {
+    assertThat(ContentResolver.getSyncAutomatically(a, AUTHORITY)).isFalse();
+    ContentResolver.setSyncAutomatically(a, AUTHORITY, true);
+    assertThat(ContentResolver.getSyncAutomatically(a, AUTHORITY)).isTrue();
+  }
+
+  @Test
+  public void shouldAddPeriodicSync() {
+    Bundle fooBar = new Bundle();
+    fooBar.putString("foo", "bar");
+    Bundle fooBaz = new Bundle();
+    fooBaz.putString("foo", "baz");
+
+    ContentResolver.addPeriodicSync(a, AUTHORITY, fooBar, 6000L);
+    ContentResolver.addPeriodicSync(a, AUTHORITY, fooBaz, 6000L);
+    ContentResolver.addPeriodicSync(b, AUTHORITY, fooBar, 6000L);
+    ContentResolver.addPeriodicSync(b, AUTHORITY, fooBaz, 6000L);
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(a, AUTHORITY, fooBar, 6000L),
+            new PeriodicSync(a, AUTHORITY, fooBaz, 6000L));
+    assertThat(ShadowContentResolver.getPeriodicSyncs(b, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(b, AUTHORITY, fooBar, 6000L),
+            new PeriodicSync(b, AUTHORITY, fooBaz, 6000L));
+
+    // If same extras, but different time, simply update the time.
+    ContentResolver.addPeriodicSync(a, AUTHORITY, fooBar, 42L);
+    ContentResolver.addPeriodicSync(b, AUTHORITY, fooBaz, 42L);
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(a, AUTHORITY, fooBar, 42L),
+            new PeriodicSync(a, AUTHORITY, fooBaz, 6000L));
+    assertThat(ShadowContentResolver.getPeriodicSyncs(b, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(b, AUTHORITY, fooBar, 6000L),
+            new PeriodicSync(b, AUTHORITY, fooBaz, 42L));
+  }
+
+  @Test
+  public void shouldRemovePeriodSync() {
+    Bundle fooBar = new Bundle();
+    fooBar.putString("foo", "bar");
+    Bundle fooBaz = new Bundle();
+    fooBaz.putString("foo", "baz");
+    Bundle foo42 = new Bundle();
+    foo42.putInt("foo", 42);
+    assertThat(ShadowContentResolver.getPeriodicSyncs(b, AUTHORITY)).isEmpty();
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY)).isEmpty();
+
+    ContentResolver.addPeriodicSync(a, AUTHORITY, fooBar, 6000L);
+    ContentResolver.addPeriodicSync(a, AUTHORITY, fooBaz, 6000L);
+    ContentResolver.addPeriodicSync(a, AUTHORITY, foo42, 6000L);
+
+    ContentResolver.addPeriodicSync(b, AUTHORITY, fooBar, 6000L);
+    ContentResolver.addPeriodicSync(b, AUTHORITY, fooBaz, 6000L);
+    ContentResolver.addPeriodicSync(b, AUTHORITY, foo42, 6000L);
+
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(a, AUTHORITY, fooBar, 6000L),
+            new PeriodicSync(a, AUTHORITY, fooBaz, 6000L),
+            new PeriodicSync(a, AUTHORITY, foo42, 6000L));
+
+    ContentResolver.removePeriodicSync(a, AUTHORITY, fooBar);
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(a, AUTHORITY, fooBaz, 6000L),
+            new PeriodicSync(a, AUTHORITY, foo42, 6000L));
+
+    ContentResolver.removePeriodicSync(a, AUTHORITY, fooBaz);
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY))
+        .containsExactly(new PeriodicSync(a, AUTHORITY, foo42, 6000L));
+
+    ContentResolver.removePeriodicSync(a, AUTHORITY, foo42);
+    assertThat(ShadowContentResolver.getPeriodicSyncs(a, AUTHORITY)).isEmpty();
+    assertThat(ShadowContentResolver.getPeriodicSyncs(b, AUTHORITY))
+        .containsExactly(
+            new PeriodicSync(b, AUTHORITY, fooBar, 6000L),
+            new PeriodicSync(b, AUTHORITY, fooBaz, 6000L),
+            new PeriodicSync(b, AUTHORITY, foo42, 6000L));
+  }
+
+  @Test
+  public void shouldGetPeriodSyncs() {
+    assertThat(ContentResolver.getPeriodicSyncs(a, AUTHORITY).size()).isEqualTo(0);
+    ContentResolver.addPeriodicSync(a, AUTHORITY, new Bundle(), 6000L);
+
+    List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(a, AUTHORITY);
+    assertThat(syncs.size()).isEqualTo(1);
+
+    PeriodicSync first = syncs.get(0);
+    assertThat(first.account).isEqualTo(a);
+    assertThat(first.authority).isEqualTo(AUTHORITY);
+    assertThat(first.period).isEqualTo(6000L);
+    assertThat(first.extras).isNotNull();
+  }
+
+  @Test
+  public void shouldValidateSyncExtras() {
+    Bundle bundle = new Bundle();
+    bundle.putString("foo", "strings");
+    bundle.putLong("long", 10L);
+    bundle.putDouble("double", 10.0d);
+    bundle.putFloat("float", 10.0f);
+    bundle.putInt("int", 10);
+    bundle.putParcelable("account", a);
+    ContentResolver.validateSyncExtrasBundle(bundle);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldValidateSyncExtrasAndThrow() {
+    Bundle bundle = new Bundle();
+    bundle.putParcelable("intent", new Intent());
+    ContentResolver.validateSyncExtrasBundle(bundle);
+  }
+
+  @Test
+  public void shouldSetMasterSyncAutomatically() {
+    assertThat(ContentResolver.getMasterSyncAutomatically()).isFalse();
+    ContentResolver.setMasterSyncAutomatically(true);
+    assertThat(ContentResolver.getMasterSyncAutomatically()).isTrue();
+  }
+
+  @Test
+  public void shouldDelegateCallsToRegisteredProvider() {
+    ShadowContentResolver.registerProviderInternal(
+        AUTHORITY,
+        new ContentProvider() {
+          @Override
+          public boolean onCreate() {
+            return false;
+          }
+
+          @Override
+          public Cursor query(
+              Uri uri,
+              String[] projection,
+              String selection,
+              String[] selectionArgs,
+              String sortOrder) {
+            return new BaseCursor();
+          }
+
+          @Override
+          public Uri insert(Uri uri, ContentValues values) {
+            return null;
+          }
+
+          @Override
+          public int delete(Uri uri, String selection, String[] selectionArgs) {
+            return -1;
+          }
+
+          @Override
+          public int update(
+              Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+            return -1;
+          }
+
+          @Override
+          public String getType(Uri uri) {
+            return null;
+          }
+        });
+    final Uri uri = Uri.parse("content://" + AUTHORITY + "/some/path");
+    final Uri unrelated = Uri.parse("content://unrelated/some/path");
+
+    assertThat(contentResolver.query(uri, null, null, null, null)).isNotNull();
+    assertThat(contentResolver.insert(uri, new ContentValues())).isNull();
+
+    assertThat(contentResolver.delete(uri, null, null)).isEqualTo(-1);
+    assertThat(contentResolver.update(uri, new ContentValues(), null, null)).isEqualTo(-1);
+
+    assertThat(contentResolver.query(unrelated, null, null, null, null)).isNull();
+    assertThat(contentResolver.insert(unrelated, new ContentValues())).isNotNull();
+    assertThat(contentResolver.delete(unrelated, null, null)).isEqualTo(1);
+    assertThat(contentResolver.update(unrelated, new ContentValues(), null, null)).isEqualTo(1);
+  }
+
+  @Test
+  public void shouldThrowConfiguredExceptionWhenRegisteringContentObservers() {
+    ShadowContentResolver scr = shadowOf(contentResolver);
+    scr.setRegisterContentProviderException(EXTERNAL_CONTENT_URI, new SecurityException());
+    try {
+      contentResolver.registerContentObserver(
+          EXTERNAL_CONTENT_URI, true, new TestContentObserver(null));
+      fail();
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  public void shouldClearConfiguredExceptionForRegisteringContentObservers() {
+    ShadowContentResolver scr = shadowOf(contentResolver);
+    scr.setRegisterContentProviderException(EXTERNAL_CONTENT_URI, new SecurityException());
+    scr.clearRegisterContentProviderException(EXTERNAL_CONTENT_URI);
+    // Should not throw the SecurityException.
+    contentResolver.registerContentObserver(
+        EXTERNAL_CONTENT_URI, true, new TestContentObserver(null));
+  }
+
+  @Test
+  public void shouldRegisterContentObservers() {
+    TestContentObserver co = new TestContentObserver(null);
+    ShadowContentResolver scr = shadowOf(contentResolver);
+
+    assertThat(scr.getContentObservers(EXTERNAL_CONTENT_URI)).isEmpty();
+
+    contentResolver.registerContentObserver(EXTERNAL_CONTENT_URI, true, co);
+
+    assertThat(scr.getContentObservers(EXTERNAL_CONTENT_URI)).containsExactly((ContentObserver) co);
+
+    assertThat(co.changed).isFalse();
+    contentResolver.notifyChange(EXTERNAL_CONTENT_URI, null);
+    assertThat(co.changed).isTrue();
+
+    contentResolver.unregisterContentObserver(co);
+    assertThat(scr.getContentObservers(EXTERNAL_CONTENT_URI)).isEmpty();
+  }
+
+  @Test
+  public void shouldUnregisterContentObservers() {
+    TestContentObserver co = new TestContentObserver(null);
+    ShadowContentResolver scr = shadowOf(contentResolver);
+    contentResolver.registerContentObserver(EXTERNAL_CONTENT_URI, true, co);
+    assertThat(scr.getContentObservers(EXTERNAL_CONTENT_URI)).contains(co);
+
+    contentResolver.unregisterContentObserver(co);
+    assertThat(scr.getContentObservers(EXTERNAL_CONTENT_URI)).isEmpty();
+
+    assertThat(co.changed).isFalse();
+    contentResolver.notifyChange(EXTERNAL_CONTENT_URI, null);
+    assertThat(co.changed).isFalse();
+  }
+
+  @Test
+  public void shouldNotifyChildContentObservers() throws Exception {
+    TestContentObserver co1 = new TestContentObserver(null);
+    TestContentObserver co2 = new TestContentObserver(null);
+
+    Uri childUri = EXTERNAL_CONTENT_URI.buildUpon().appendPath("path").build();
+
+    contentResolver.registerContentObserver(EXTERNAL_CONTENT_URI, true, co1);
+    contentResolver.registerContentObserver(childUri, false, co2);
+
+    co1.changed = co2.changed = false;
+    contentResolver.notifyChange(childUri, null);
+    assertThat(co1.changed).isTrue();
+    assertThat(co2.changed).isTrue();
+
+    co1.changed = co2.changed = false;
+    contentResolver.notifyChange(EXTERNAL_CONTENT_URI, null);
+    assertThat(co1.changed).isTrue();
+    assertThat(co2.changed).isFalse();
+
+    co1.changed = co2.changed = false;
+    contentResolver.notifyChange(childUri.buildUpon().appendPath("extra").build(), null);
+    assertThat(co1.changed).isTrue();
+    assertThat(co2.changed).isFalse();
+  }
+
+  @Test
+  public void getProvider_shouldCreateProviderFromManifest() throws Exception {
+    Uri uri = Uri.parse("content://org.robolectric.authority1/shadows");
+    ContentProvider provider = ShadowContentResolver.getProvider(uri);
+    assertThat(provider).isNotNull();
+    assertThat(provider.getReadPermission()).isEqualTo("READ_PERMISSION");
+    assertThat(provider.getWritePermission()).isEqualTo("WRITE_PERMISSION");
+    assertThat(provider.getPathPermissions()).asList().hasSize(1);
+
+    // unfortunately, there is no direct way of testing if authority is set or not
+    // however, it's checked in ContentProvider.Transport method calls (validateIncomingUri), so
+    // it's the closest we can test against
+    provider.getIContentProvider().getType(uri); // should not throw
+  }
+
+  @Test
+  @Config(manifest = NONE)
+  @SuppressWarnings("RobolectricSystemContext") // preexisting when check was enabled
+  public void getProvider_shouldNotReturnAnyProviderWhenManifestIsNull() {
+    Application application = new Application();
+    shadowOf(application).callAttach(RuntimeEnvironment.systemContext);
+    assertThat(ShadowContentResolver.getProvider(Uri.parse("content://"))).isNull();
+  }
+
+  @Test
+  public void openTypedAssetFileDescriptor_shouldOpenDescriptor()
+      throws IOException, RemoteException {
+    Robolectric.setupContentProvider(MyContentProvider.class, AUTHORITY);
+
+    try (AssetFileDescriptor afd =
+        contentResolver.openTypedAssetFileDescriptor(
+            Uri.parse("content://" + AUTHORITY + "/whatever"), "*/*", null)) {
+
+      FileDescriptor descriptor = afd.getFileDescriptor();
+      assertThat(descriptor).isNotNull();
+    }
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void takeAndReleasePersistableUriPermissions() {
+    List<UriPermission> permissions = contentResolver.getPersistedUriPermissions();
+    assertThat(permissions).isEmpty();
+
+    // Take the read permission for the uri.
+    Uri uri = Uri.parse("content://" + AUTHORITY + "/whatever");
+    contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+    assertThat(permissions).hasSize(1);
+    assertThat(permissions.get(0).getUri()).isSameInstanceAs(uri);
+    assertThat(permissions.get(0).isReadPermission()).isTrue();
+    assertThat(permissions.get(0).isWritePermission()).isFalse();
+
+    // Take the write permission for the uri.
+    contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+    assertThat(permissions).hasSize(1);
+    assertThat(permissions.get(0).getUri()).isSameInstanceAs(uri);
+    assertThat(permissions.get(0).isReadPermission()).isTrue();
+    assertThat(permissions.get(0).isWritePermission()).isTrue();
+
+    // Release the read permission for the uri.
+    contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+    assertThat(permissions).hasSize(1);
+    assertThat(permissions.get(0).getUri()).isSameInstanceAs(uri);
+    assertThat(permissions.get(0).isReadPermission()).isFalse();
+    assertThat(permissions.get(0).isWritePermission()).isTrue();
+
+    // Release the write permission for the uri.
+    contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+    assertThat(permissions).isEmpty();
+  }
+
+  @Test
+  public void getSyncAdapterTypes() {
+    SyncAdapterType[] syncAdapterTypes =
+        new SyncAdapterType[] {
+          new SyncAdapterType(
+              "authority1", "accountType1", /* userVisible=*/ false, /* supportsUploading=*/ false),
+          new SyncAdapterType(
+              "authority2", "accountType2", /* userVisible=*/ true, /* supportsUploading=*/ false),
+          new SyncAdapterType(
+              "authority3", "accountType3", /* userVisible=*/ true, /* supportsUploading=*/ true)
+        };
+
+    ShadowContentResolver.setSyncAdapterTypes(syncAdapterTypes);
+    assertThat(ContentResolver.getSyncAdapterTypes()).isEqualTo(syncAdapterTypes);
+  }
+
+  private static class QueryParamTrackingCursor extends BaseCursor {
+    public Uri uri;
+    public String[] projection;
+    public String selection;
+    public String[] selectionArgs;
+    public String sortOrder;
+
+    @Override
+    public void setQuery(
+        Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+      this.uri = uri;
+      this.projection = projection;
+      this.selection = selection;
+      this.selectionArgs = selectionArgs;
+      this.sortOrder = sortOrder;
+    }
+  }
+
+  private static class TestContentObserver extends ContentObserver {
+    public TestContentObserver(Handler handler) {
+      super(handler);
+    }
+
+    public boolean changed = false;
+
+    @Override
+    public void onChange(boolean selfChange) {
+      changed = true;
+    }
+
+    @Override
+    public void onChange(boolean selfChange, Uri uri) {
+      changed = true;
+    }
+  }
+
+  /** Provider that opens a temporary file. */
+  public static class MyContentProvider extends ContentProvider {
+    @Override
+    public boolean onCreate() {
+      return true;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] strings, String s, String[] strings1, String s1) {
+      return null;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+      return null;
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues contentValues) {
+      return null;
+    }
+
+    @Override
+    public int delete(Uri uri, String s, String[] strings) {
+      return 0;
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues contentValues, String s, String[] strings) {
+      return 0;
+    }
+
+    @Override
+    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+      final File file =
+          new File(ApplicationProvider.getApplicationContext().getFilesDir(), "test_file");
+      try {
+        file.createNewFile();
+      } catch (IOException e) {
+        throw new RuntimeException("error creating new file", e);
+      }
+      return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentUrisTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentUrisTest.java
new file mode 100644
index 0000000..99f3c04
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentUrisTest.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentUris;
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentUrisTest {
+  Uri URI;
+
+  @Before
+  public void setUp() throws Exception {
+    URI = Uri.parse("content://foo.com");
+  }
+
+  @Test public void canAppendId() {
+    assertThat(ContentUris.withAppendedId(URI, 1)).isEqualTo(Uri.parse("content://foo.com/1"));
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void appendIdThrowsNullPointerException() {
+    ContentUris.withAppendedId(null, 1);
+  }
+
+  @Test public void canParseId() {
+    assertThat(ContentUris.parseId(Uri.withAppendedPath(URI, "1"))).isEqualTo(1L);
+    assertThat(ContentUris.parseId(URI)).isEqualTo(-1L);
+  }
+
+  @Test(expected = NumberFormatException.class)
+  public void parseIdThrowsNumberFormatException() {
+    ContentUris.parseId(Uri.withAppendedPath(URI, "bar"));
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void parseIdThrowsUnsupportedException() {
+    ContentUris.parseId(Uri.parse("mailto:bar@foo.com"));
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContentValuesTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentValuesTest.java
new file mode 100644
index 0000000..7e2e5cd
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContentValuesTest.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentValues;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContentValuesTest {
+  private static final String KEY = "key";
+
+  private ContentValues contentValues;
+
+  @Before
+  public void setUp() {
+    contentValues = new ContentValues();
+  }
+
+  @Test
+  public void shouldBeEqualIfBothContentValuesAreEmpty() {
+    ContentValues valuesA = new ContentValues();
+    ContentValues valuesB = new ContentValues();
+    assertThat(valuesA).isEqualTo(valuesB);
+  }
+
+  @Test
+  public void shouldBeEqualIfBothContentValuesHaveSameValues() {
+    ContentValues valuesA = new ContentValues();
+    valuesA.put("String", "A");
+    valuesA.put("Integer", 23);
+    valuesA.put("Boolean", false);
+    ContentValues valuesB = new ContentValues();
+    valuesB.putAll(valuesA);
+    assertThat(valuesA).isEqualTo(valuesB);
+  }
+
+  @Test
+  public void shouldNotBeEqualIfContentValuesHaveDifferentValue() {
+    ContentValues valuesA = new ContentValues();
+    valuesA.put("String", "A");
+    ContentValues valuesB = new ContentValues();
+    assertThat(valuesA).isNotEqualTo(valuesB);
+    valuesB.put("String", "B");
+    assertThat(valuesA).isNotEqualTo(valuesB);
+  }
+
+  @Test
+  public void getAsBoolean_zero() {
+    contentValues.put(KEY, 0);
+    assertThat(contentValues.getAsBoolean(KEY)).isFalse();
+  }
+
+  @Test
+  public void getAsBoolean_one() {
+    contentValues.put(KEY, 1);
+    assertThat(contentValues.getAsBoolean(KEY)).isTrue();
+  }
+
+  @Test
+  public void getAsBoolean_false() {
+    contentValues.put(KEY, false);
+    assertThat(contentValues.getAsBoolean(KEY)).isFalse();
+  }
+
+  @Test
+  public void getAsBoolean_true() {
+    contentValues.put(KEY, true);
+    assertThat(contentValues.getAsBoolean(KEY)).isTrue();
+  }
+
+  @Test
+  public void getAsBoolean_falseString() {
+    contentValues.put(KEY, "false");
+    assertThat(contentValues.getAsBoolean(KEY)).isFalse();
+  }
+
+  @Test
+  public void getAsBoolean_trueString() {
+    contentValues.put(KEY, "true");
+    assertThat(contentValues.getAsBoolean(KEY)).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContextHubManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextHubManagerTest.java
new file mode 100644
index 0000000..a663b2b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextHubManagerTest.java
@@ -0,0 +1,158 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.Context;
+import android.hardware.location.ContextHubClient;
+import android.hardware.location.ContextHubInfo;
+import android.hardware.location.ContextHubManager;
+import android.hardware.location.ContextHubTransaction;
+import android.hardware.location.NanoAppInstanceInfo;
+import android.hardware.location.NanoAppState;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowContextHubManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.N)
+public class ShadowContextHubManagerTest {
+  // Do not reference a non-public field in a test, because those get loaded outside the Robolectric
+  // sandbox
+  // DO NOT DO: private ContextHubManager contextHubManager;
+
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void getContextHubs_returnsValidList() {
+    ContextHubManager contextHubManager =
+        (ContextHubManager) context.getSystemService(Context.CONTEXTHUB_SERVICE);
+    List<ContextHubInfo> contextHubInfoList = contextHubManager.getContextHubs();
+    assertThat(contextHubInfoList).isNotNull();
+    assertThat(contextHubInfoList).isNotEmpty();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void createClient_returnsValidClient() {
+    ContextHubManager contextHubManager =
+        (ContextHubManager) context.getSystemService(Context.CONTEXTHUB_SERVICE);
+    ContextHubClient contextHubClient = contextHubManager.createClient(null, null);
+    assertThat(contextHubClient).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void queryNanoApps_returnsValidNanoApps() throws Exception {
+    ContextHubManager contextHubManager = context.getSystemService(ContextHubManager.class);
+    ShadowContextHubManager shadowManager = Shadow.extract(contextHubManager);
+    List<ContextHubInfo> contextHubInfoList = contextHubManager.getContextHubs();
+    long nanoAppId = 5;
+    int nanoAppVersion = 1;
+    shadowManager.addNanoApp(
+        contextHubInfoList.get(0), 0 /* nanoAppUid */, nanoAppId, nanoAppVersion);
+
+    ContextHubTransaction<List<NanoAppState>> transaction =
+        contextHubManager.queryNanoApps(contextHubInfoList.get(0));
+
+    assertThat(transaction.getType()).isEqualTo(ContextHubTransaction.TYPE_QUERY_NANOAPPS);
+    ContextHubTransaction.Response<List<NanoAppState>> response =
+        transaction.waitForResponse(1, SECONDS);
+    assertThat(response.getResult()).isEqualTo(ContextHubTransaction.RESULT_SUCCESS);
+    List<NanoAppState> states = response.getContents();
+    assertThat(states).isNotNull();
+    assertThat(states).hasSize(1);
+    NanoAppState state = states.get(0);
+    assertThat(state.getNanoAppId()).isEqualTo(nanoAppId);
+    assertThat(state.getNanoAppVersion()).isEqualTo(nanoAppVersion);
+    assertThat(state.isEnabled()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void queryNanoApps_noNanoAppsAdded() throws Exception {
+    ContextHubManager contextHubManager = context.getSystemService(ContextHubManager.class);
+    List<ContextHubInfo> contextHubInfoList = contextHubManager.getContextHubs();
+
+    ContextHubTransaction<List<NanoAppState>> transaction =
+        contextHubManager.queryNanoApps(contextHubInfoList.get(0));
+
+    assertThat(transaction.getType()).isEqualTo(ContextHubTransaction.TYPE_QUERY_NANOAPPS);
+    ContextHubTransaction.Response<List<NanoAppState>> response =
+        transaction.waitForResponse(1, SECONDS);
+    assertThat(response.getResult()).isEqualTo(ContextHubTransaction.RESULT_SUCCESS);
+    List<NanoAppState> states = response.getContents();
+    assertThat(states).isNotNull();
+    assertThat(states).isEmpty();
+  }
+
+  @Test
+  public void getContextHubHandles_returnsValidArray() {
+    ContextHubManager contextHubManager =
+        (ContextHubManager) context.getSystemService(Context.CONTEXTHUB_SERVICE);
+    int[] handles = contextHubManager.getContextHubHandles();
+    assertThat(handles).isNotNull();
+    assertThat(handles).isNotEmpty();
+  }
+
+  @Test
+  public void getContextHubInfo_returnsValidInfo() {
+    ContextHubManager contextHubManager =
+        (ContextHubManager) context.getSystemService(Context.CONTEXTHUB_SERVICE);
+    int[] handles = contextHubManager.getContextHubHandles();
+    assertThat(handles).isNotNull();
+    for (int handle : handles) {
+      assertThat(contextHubManager.getContextHubInfo(handle)).isNotNull();
+    }
+  }
+
+  @Test
+  public void getContextHubInfo_returnsInvalidInfo() {
+    ContextHubManager contextHubManager =
+        (ContextHubManager) context.getSystemService(Context.CONTEXTHUB_SERVICE);
+    int[] handles = contextHubManager.getContextHubHandles();
+    assertThat(handles).isNotNull();
+    assertThat(contextHubManager.getContextHubInfo(-1)).isNull();
+    assertThat(contextHubManager.getContextHubInfo(handles.length)).isNull();
+  }
+
+  @Test
+  public void getNanoAppInstanceInfo_returnsValidInfo() {
+    ContextHubManager contextHubManager = context.getSystemService(ContextHubManager.class);
+    ShadowContextHubManager shadowManager = Shadow.extract(contextHubManager);
+    int[] handles = contextHubManager.getContextHubHandles();
+    ContextHubInfo hubInfo = contextHubManager.getContextHubInfo(handles[0]);
+    long nanoAppId = 5;
+    int nanoAppVersion = 1;
+    int nanoAppUid = 0;
+    shadowManager.addNanoApp(hubInfo, nanoAppUid, nanoAppId, nanoAppVersion);
+
+    NanoAppInstanceInfo info = contextHubManager.getNanoAppInstanceInfo(nanoAppUid);
+
+    assertThat(info).isNotNull();
+    assertThat(info.getAppId()).isEqualTo(nanoAppId);
+    assertThat(info.getAppVersion()).isEqualTo(nanoAppVersion);
+  }
+
+  @Test
+  public void getNanoAppInstanceInfo_noNanoAppsAdded() {
+    ContextHubManager contextHubManager = context.getSystemService(ContextHubManager.class);
+
+    NanoAppInstanceInfo info = contextHubManager.getNanoAppInstanceInfo(0 /* nanoAppUid */);
+
+    assertThat(info).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContextImplTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextImplTest.java
new file mode 100644
index 0000000..12d92c0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextImplTest.java
@@ -0,0 +1,413 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.PendingIntent;
+import android.app.WallpaperManager;
+import android.bluetooth.BluetoothManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.Process;
+import android.os.UserHandle;
+import android.view.LayoutInflater;
+import android.widget.FrameLayout;
+import android.widget.RemoteViews;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.android.util.concurrent.PausedExecutorService;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowContextImplTest {
+  private final Application context = ApplicationProvider.getApplicationContext();
+  private final ShadowContextImpl shadowContext = Shadow.extract(context.getBaseContext());
+  private final PausedExecutorService executorService = new PausedExecutorService();
+
+  @Test
+  @Config(minSdk = N)
+  public void deviceProtectedContext() {
+    // Regular context should be credential protected, not device protected.
+    assertThat(context.isDeviceProtectedStorage()).isFalse();
+    assertThat(context.isCredentialProtectedStorage()).isFalse();
+
+    // Device protected storage context should have device protected rather than credential
+    // protected storage.
+    Context deviceProtectedStorageContext = context.createDeviceProtectedStorageContext();
+    assertThat(deviceProtectedStorageContext.isDeviceProtectedStorage()).isTrue();
+    assertThat(deviceProtectedStorageContext.isCredentialProtectedStorage()).isFalse();
+
+    // Data dirs of these two contexts must be different locations.
+    assertThat(context.getDataDir()).isNotEqualTo(deviceProtectedStorageContext.getDataDir());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void testMoveSharedPreferencesFrom() {
+    String PREFS = "PREFS";
+    String PREF_NAME = "TOKEN_PREF";
+
+    context
+        .getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+        .edit()
+        .putString(PREF_NAME, "token")
+        .commit();
+
+    Context dpContext = context.createDeviceProtectedStorageContext();
+
+    assertThat(dpContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).contains(PREF_NAME))
+        .isFalse();
+    assertThat(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).contains(PREF_NAME))
+        .isTrue();
+
+    assertThat(dpContext.moveSharedPreferencesFrom(context, PREFS)).isTrue();
+
+    assertThat(dpContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).contains(PREF_NAME))
+        .isTrue();
+    assertThat(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).contains(PREF_NAME))
+        .isFalse();
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void getExternalFilesDirs() {
+    File[] dirs = context.getExternalFilesDirs("something");
+    assertThat(dirs).asList().hasSize(1);
+    assertThat(dirs[0].isDirectory()).isTrue();
+    assertThat(dirs[0].canWrite()).isTrue();
+    assertThat(dirs[0].getName()).isEqualTo("something");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getSystemService_shouldReturnBluetoothAdapter() {
+    assertThat(context.getSystemService(Context.BLUETOOTH_SERVICE))
+        .isInstanceOf(BluetoothManager.class);
+  }
+
+  @Test
+  public void getSystemService_shouldReturnWallpaperManager() {
+    assertThat(context.getSystemService(Context.WALLPAPER_SERVICE))
+        .isInstanceOf(WallpaperManager.class);
+  }
+
+  @Test
+  public void removeSystemService_getSystemServiceReturnsNull() {
+    shadowContext.removeSystemService(Context.WALLPAPER_SERVICE);
+    assertThat(context.getSystemService(Context.WALLPAPER_SERVICE)).isNull();
+  }
+
+  @Test
+  public void startIntentSender_activityIntent() throws IntentSender.SendIntentException {
+    PendingIntent intent =
+        PendingIntent.getActivity(
+            context,
+            0,
+            new Intent()
+                .setClassName(context, "ActivityIntent")
+                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+            PendingIntent.FLAG_UPDATE_CURRENT);
+
+    context.startIntentSender(intent.getIntentSender(), null, 0, 0, 0);
+
+    assertThat(shadowOf(context).getNextStartedActivity().getComponent().getClassName())
+        .isEqualTo("ActivityIntent");
+  }
+
+  @Test
+  public void startIntentSender_broadcastIntent() throws IntentSender.SendIntentException {
+    PendingIntent intent =
+        PendingIntent.getBroadcast(
+            context,
+            0,
+            new Intent().setClassName(context, "BroadcastIntent"),
+            PendingIntent.FLAG_UPDATE_CURRENT);
+
+    context.startIntentSender(intent.getIntentSender(), null, 0, 0, 0);
+
+    assertThat(shadowOf(context).getBroadcastIntents().get(0).getComponent().getClassName())
+        .isEqualTo("BroadcastIntent");
+  }
+
+  @Test
+  public void startIntentSender_serviceIntent() throws IntentSender.SendIntentException {
+    PendingIntent intent =
+        PendingIntent.getService(
+            context,
+            0,
+            new Intent().setClassName(context, "ServiceIntent"),
+            PendingIntent.FLAG_UPDATE_CURRENT);
+
+    context.startIntentSender(intent.getIntentSender(), null, 0, 0, 0);
+
+    assertThat(shadowOf(context).getNextStartedService().getComponent().getClassName())
+        .isEqualTo("ServiceIntent");
+  }
+
+  @Test
+  public void createPackageContext() throws Exception {
+    Context packageContext = context.createPackageContext(context.getPackageName(), 0);
+
+    LayoutInflater inflater =
+        (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+    inflater.cloneInContext(packageContext);
+
+    inflater.inflate(R.layout.remote_views, new FrameLayout(context), false);
+  }
+
+  @Test
+  public void createPackageContextRemoteViews() {
+    RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.remote_views);
+    remoteViews.apply(context, new FrameLayout(context));
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void bindServiceAsUser() {
+    Intent serviceIntent = new Intent().setPackage("dummy.package");
+    ServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    assertThat(
+            context.bindServiceAsUser(
+                serviceIntent, serviceConnection, flags, Process.myUserHandle()))
+        .isTrue();
+
+    assertThat(shadowOf(context).getBoundServiceConnections()).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void bindServiceAsUser_shouldThrowOnImplicitIntent() {
+    Intent serviceIntent = new Intent();
+    ServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    try {
+      context.bindServiceAsUser(serviceIntent, serviceConnection, flags, Process.myUserHandle());
+      fail("bindServiceAsUser should throw IllegalArgumentException!");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void bindService() {
+    Intent serviceIntent = new Intent().setPackage("dummy.package");
+    ServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    assertThat(context.bindService(serviceIntent, serviceConnection, flags)).isTrue();
+
+    assertThat(shadowOf(context).getBoundServiceConnections()).hasSize(1);
+  }
+
+  @Test
+  public void bindService_shouldAllowImplicitIntentPreLollipop() {
+    context.getApplicationInfo().targetSdkVersion = KITKAT;
+    Intent serviceIntent = new Intent();
+    ServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    assertThat(context.bindService(serviceIntent, serviceConnection, flags)).isTrue();
+
+    assertThat(shadowOf(context).getBoundServiceConnections()).hasSize(1);
+  }
+
+  @Test
+  public void bindService_shouldThrowOnImplicitIntentOnLollipop() {
+    Intent serviceIntent = new Intent();
+    ServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    try {
+      context.bindService(serviceIntent, serviceConnection, flags);
+      fail("bindService should throw IllegalArgumentException!");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void bindService_unbindable() {
+    String action = "foo-action";
+    Intent serviceIntent = new Intent(action).setPackage("dummy.package");
+    ServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+    shadowOf(context).declareActionUnbindable(action);
+
+    assertThat(context.bindService(serviceIntent, serviceConnection, flags)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void bindService_withExecutor_callsServiceConnectedOnExecutor() {
+    Intent serviceIntent = new Intent().setPackage("dummy.package");
+    TestServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    assertThat(context.bindService(serviceIntent, flags, executorService, serviceConnection))
+        .isTrue();
+    assertThat(serviceConnection.isConnected).isFalse();
+    executorService.runAll();
+
+    assertThat(shadowOf(context).getBoundServiceConnections()).hasSize(1);
+    assertThat(serviceConnection.isConnected).isTrue();
+  }
+
+  @Test
+  public void bindService_noSpecifiedExecutor_callsServiceConnectedOnHandler() {
+    Intent serviceIntent = new Intent().setPackage("dummy.package");
+    TestServiceConnection serviceConnection = buildServiceConnection();
+    int flags = 0;
+
+    assertThat(context.bindService(serviceIntent, serviceConnection, flags)).isTrue();
+    assertThat(serviceConnection.isConnected).isFalse();
+    ShadowLooper.idleMainLooper();
+
+    assertThat(shadowOf(context).getBoundServiceConnections()).hasSize(1);
+    assertThat(serviceConnection.isConnected).isTrue();
+  }
+
+  @Test
+  public void startService_shouldAllowImplicitIntentPreLollipop() {
+    context.getApplicationInfo().targetSdkVersion = KITKAT;
+    context.startService(new Intent("dummy_action"));
+    assertThat(shadowOf(context).getNextStartedService().getAction()).isEqualTo("dummy_action");
+  }
+
+  @Test
+  public void startService_shouldThrowOnImplicitIntentOnLollipop() {
+    try {
+      context.startService(new Intent("dummy_action"));
+      fail("startService should throw IllegalArgumentException!");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void stopService_shouldAllowImplicitIntentPreLollipop() {
+    context.getApplicationInfo().targetSdkVersion = KITKAT;
+    context.stopService(new Intent("dummy_action"));
+  }
+
+  @Test
+  public void stopService_shouldThrowOnImplicitIntentOnLollipop() {
+    try {
+      context.stopService(new Intent("dummy_action"));
+      fail("stopService should throw IllegalArgumentException!");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void sendBroadcastAsUser_sendBroadcast() {
+    UserHandle userHandle = Process.myUserHandle();
+    String action = "foo-action";
+    Intent intent = new Intent(action);
+    context.sendBroadcastAsUser(intent, userHandle);
+
+    assertThat(shadowOf(context).getBroadcastIntents().get(0).getAction()).isEqualTo(action);
+    assertThat(shadowOf(context).getBroadcastIntentsForUser(userHandle).get(0).getAction())
+        .isEqualTo(action);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void sendOrderedBroadcastAsUser_sendsBroadcast() {
+    UserHandle userHandle = Process.myUserHandle();
+    String action = "foo-action";
+    Intent intent = new Intent(action);
+    context.sendOrderedBroadcastAsUser(
+        intent,
+        userHandle,
+        /*receiverPermission=*/ null,
+        /*resultReceiver=*/ null,
+        /*scheduler=*/ null,
+        /*initialCode=*/ 0,
+        /*initialData=*/ null,
+        /*initialExtras=*/ null);
+
+    assertThat(shadowOf(context).getBroadcastIntents().get(0).getAction()).isEqualTo(action);
+    assertThat(shadowOf(context).getBroadcastIntentsForUser(userHandle).get(0).getAction())
+        .isEqualTo(action);
+  }
+
+  @Test
+  public void createPackageContext_absent() {
+    try {
+      context.createPackageContext("doesnt.exist", 0);
+      fail("Should throw NameNotFoundException");
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void startActivityAsUser() {
+    Intent intent = new Intent();
+    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    Bundle options = new Bundle();
+
+    context.startActivityAsUser(intent, options, Process.myUserHandle());
+
+    Intent launchedActivityIntent = shadowOf(context).getNextStartedActivity();
+    assertThat(launchedActivityIntent).isEqualTo(intent);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getUserId_returns0() {
+    assertThat(context.getUserId()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getUserId_userIdHasBeenSet_returnsCorrectUserId() {
+    int userId = 10;
+    shadowContext.setUserId(userId);
+
+    assertThat(context.getUserId()).isEqualTo(userId);
+  }
+
+  private TestServiceConnection buildServiceConnection() {
+    return new TestServiceConnection();
+  }
+
+  private static class TestServiceConnection implements ServiceConnection {
+    boolean isConnected;
+
+    @Override
+    public void onServiceConnected(ComponentName name, IBinder service) {
+      isConnected = true;
+    }
+
+    @Override
+    public void onServiceDisconnected(ComponentName name) {
+      isConnected = false;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContextTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextTest.java
new file mode 100644
index 0000000..366eeaa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextTest.java
@@ -0,0 +1,295 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.content.res.TypedArray;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.file.Files;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/**
+ * Tests of the {@link ShadowContextImpl} class
+ */
+@RunWith(AndroidJUnit4.class)
+public class ShadowContextTest {
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void createConfigurationContext() {
+    Configuration configuration = new Configuration(context.getResources().getConfiguration());
+    configuration.mcc = 234;
+
+    Context configurationContext = context.createConfigurationContext(configuration);
+
+    assertThat(configurationContext).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O)
+  public void startForegroundService() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    context.startForegroundService(intent);
+    assertThat(ShadowApplication.getInstance().getNextStartedService()).isEqualTo(intent);
+  }
+
+  @Test
+  public void shouldGetApplicationDataDirectory() {
+    File dataDir = context.getDir("data", Context.MODE_PRIVATE);
+    assertThat(dataDir.exists()).isTrue();
+  }
+
+  @Test
+  public void shouldCreateIfDoesNotExistAndGetApplicationDataDirectory() throws Exception {
+    File dataDir = new File(context.getPackageManager()
+        .getPackageInfo("org.robolectric", 0).applicationInfo.dataDir, "data");
+
+    assertThat(dataDir.exists()).isFalse();
+
+    dataDir = context.getDir("data", Context.MODE_PRIVATE);
+    assertThat(dataDir.exists()).isTrue();
+  }
+
+  @Test
+  public void shouldStubThemeStuff() {
+    assertThat(context.obtainStyledAttributes(new int[0])).isNotNull();
+    assertThat(context.obtainStyledAttributes(0, new int[0])).isNotNull();
+    assertThat(context.obtainStyledAttributes(null, new int[0])).isNotNull();
+    assertThat(context.obtainStyledAttributes(null, new int[0], 0, 0)).isNotNull();
+  }
+
+  @Test
+  public void getCacheDir_shouldCreateDirectory() {
+    assertThat(context.getCacheDir().exists()).isTrue();
+  }
+
+  @Test
+  public void getExternalCacheDir_shouldCreateDirectory() {
+    assertThat(context.getExternalCacheDir().exists()).isTrue();
+  }
+
+  @Test
+  public void shouldWriteToCacheDir() throws Exception {
+    assertThat(context.getCacheDir()).isNotNull();
+    File cacheTest = new File(context.getCacheDir(), "__test__");
+
+    assertThat(cacheTest.getAbsolutePath())
+      .startsWith(System.getProperty("java.io.tmpdir"));
+    assertThat(cacheTest.getAbsolutePath())
+        .endsWith(File.separator + "__test__");
+
+    try (FileOutputStream fos = new FileOutputStream(cacheTest)) {
+      fos.write("test".getBytes(UTF_8));
+    }
+    assertThat(cacheTest.exists()).isTrue();
+  }
+
+  @Test
+  public void shouldWriteToExternalCacheDir() throws Exception {
+    assertThat(context.getExternalCacheDir()).isNotNull();
+    File cacheTest = new File(context.getExternalCacheDir(), "__test__");
+
+    assertThat(cacheTest.getAbsolutePath())
+      .startsWith(System.getProperty("java.io.tmpdir"));
+    assertThat(cacheTest.getAbsolutePath())
+      .endsWith(File.separator + "__test__");
+
+    try (FileOutputStream fos = new FileOutputStream(cacheTest)) {
+      fos.write("test".getBytes(UTF_8));
+    }
+
+    assertThat(cacheTest.exists()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void getExternalCacheDirs_nonEmpty() {
+    assertThat(context.getExternalCacheDirs()).isNotEmpty();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void getExternalCacheDirs_createsDirectories() {
+    File[] externalCacheDirs = context.getExternalCacheDirs();
+    for (File d : externalCacheDirs) {
+      assertThat(d.exists()).isTrue();
+    }
+  }
+
+  @Test
+  public void getFilesDir_shouldCreateDirectory() {
+    assertThat(context.getFilesDir().exists()).isTrue();
+  }
+
+  @Test
+  public void fileList() {
+    assertThat(context.fileList()).isEqualTo(context.getFilesDir().list());
+  }
+
+  @Test
+  public void getExternalFilesDir_shouldCreateDirectory() {
+    assertThat(context.getExternalFilesDir(null).exists()).isTrue();
+  }
+
+  @Test
+  public void getExternalFilesDir_shouldCreateNamedDirectory() {
+    File f = context.getExternalFilesDir("__test__");
+    assertThat(f.exists()).isTrue();
+    assertThat(f.getAbsolutePath()).endsWith("__test__");
+  }
+
+  @Test
+  public void getDatabasePath_shouldAllowAbsolutePaths() {
+      String testDbName;
+
+      if (System.getProperty("os.name").startsWith("Windows")) {
+        testDbName = "C:\\absolute\\full\\path\\to\\db\\abc.db";
+      } else {
+        testDbName = "/absolute/full/path/to/db/abc.db";
+      }
+      File dbFile = context.getDatabasePath(testDbName);
+      assertThat(dbFile).isEqualTo(new File(testDbName));
+  }
+
+  @Test
+  public void openFileInput_shouldReturnAFileInputStream() throws Exception {
+    String fileContents = "blah";
+
+    File file = new File(context.getFilesDir(), "__test__");
+    try (Writer fileWriter = Files.newBufferedWriter(file.toPath(), UTF_8)) {
+      fileWriter.write(fileContents);
+    }
+
+    try (FileInputStream fileInputStream = context.openFileInput("__test__")) {
+      byte[] bytes = new byte[fileContents.length()];
+      fileInputStream.read(bytes);
+      assertThat(bytes).isEqualTo(fileContents.getBytes(UTF_8));
+    }
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void openFileInput_shouldNotAcceptPathsWithSeparatorCharacters() throws Exception {
+    try (FileInputStream fileInputStream =
+        context.openFileInput("data" + File.separator + "test")) {}
+  }
+
+  @Test
+  public void openFileOutput_shouldReturnAFileOutputStream() throws Exception {
+    File file = new File("__test__");
+    String fileContents = "blah";
+    try (FileOutputStream fileOutputStream =
+        context.openFileOutput("__test__", Context.MODE_PRIVATE)) {
+      fileOutputStream.write(fileContents.getBytes(UTF_8));
+    }
+    try (FileInputStream fileInputStream = new FileInputStream(new File(context.getFilesDir(), file.getName()))) {
+      byte[] readBuffer = new byte[fileContents.length()];
+      fileInputStream.read(readBuffer);
+      assertThat(new String(readBuffer, UTF_8)).isEqualTo(fileContents);
+    }
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void openFileOutput_shouldNotAcceptPathsWithSeparatorCharacters() throws Exception {
+    try (FileOutputStream fos =
+        context.openFileOutput(
+            File.separator + "data" + File.separator + "test" + File.separator + "hi", 0)) {}
+  }
+
+  @Test
+  public void openFileOutput_shouldAppendData() throws Exception {
+    File file = new File("__test__");
+    String initialFileContents = "foo";
+    String appendedFileContents = "bar";
+    String finalFileContents = initialFileContents + appendedFileContents;
+    try (FileOutputStream fileOutputStream =
+        context.openFileOutput("__test__", Context.MODE_APPEND)) {
+      fileOutputStream.write(initialFileContents.getBytes(UTF_8));
+    }
+    try (FileOutputStream fileOutputStream =
+        context.openFileOutput("__test__", Context.MODE_APPEND)) {
+      fileOutputStream.write(appendedFileContents.getBytes(UTF_8));
+    }
+    try (FileInputStream fileInputStream = new FileInputStream(new File(context.getFilesDir(), file.getName()))) {
+      byte[] readBuffer = new byte[finalFileContents.length()];
+      fileInputStream.read(readBuffer);
+      assertThat(new String(readBuffer, UTF_8)).isEqualTo(finalFileContents);
+    }
+  }
+
+  @Test
+  public void openFileOutput_shouldOverwriteData() throws Exception {
+    File file = new File("__test__");
+    String initialFileContents = "foo";
+    String newFileContents = "bar";
+    try (FileOutputStream fileOutputStream = context.openFileOutput("__test__", 0)) {
+      fileOutputStream.write(initialFileContents.getBytes(UTF_8));
+    }
+    try (FileOutputStream fileOutputStream = context.openFileOutput("__test__", 0)) {
+      fileOutputStream.write(newFileContents.getBytes(UTF_8));
+    }
+    try (FileInputStream fileInputStream = new FileInputStream(new File(context.getFilesDir(), file.getName()))) {
+      byte[] readBuffer = new byte[newFileContents.length()];
+      fileInputStream.read(readBuffer);
+      assertThat(new String(readBuffer, UTF_8)).isEqualTo(newFileContents);
+    }
+  }
+
+  @Test
+  public void deleteFile_shouldReturnTrue() throws IOException {
+    File filesDir = context.getFilesDir();
+    File file = new File(filesDir, "test.txt");
+    boolean successfully = file.createNewFile();
+    assertThat(successfully).isTrue();
+    successfully = context.deleteFile(file.getName());
+    assertThat(successfully).isTrue();
+  }
+
+  @Test
+  public void deleteFile_shouldReturnFalse() {
+    File filesDir = context.getFilesDir();
+    File file = new File(filesDir, "test.txt");
+    boolean successfully = context.deleteFile(file.getName());
+    assertThat(successfully).isFalse();
+  }
+
+  @Test
+  public void obtainStyledAttributes_shouldExtractAttributesFromAttributeSet() {
+    AttributeSet roboAttributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(R.attr.itemType, "ungulate")
+        .addAttribute(R.attr.scrollBars, "horizontal|vertical")
+        .addAttribute(R.attr.quitKeyCombo, "^q")
+        .addAttribute(R.attr.aspectRatio, "1.5")
+        .addAttribute(R.attr.aspectRatioEnabled, "true")
+        .build();
+
+    TypedArray a = context.obtainStyledAttributes(roboAttributeSet, R.styleable.CustomView);
+    assertThat(a.getInt(R.styleable.CustomView_itemType, -1234)).isEqualTo(1 /* ungulate */);
+    assertThat(a.getInt(R.styleable.CustomView_scrollBars, -1234)).isEqualTo(0x300);
+    assertThat(a.getString(R.styleable.CustomView_quitKeyCombo)).isEqualTo("^q");
+    assertThat(a.getText(R.styleable.CustomView_quitKeyCombo).toString()).isEqualTo("^q");
+    assertThat(a.getFloat(R.styleable.CustomView_aspectRatio, 1f)).isEqualTo(1.5f);
+    assertThat(a.getBoolean(R.styleable.CustomView_aspectRatioEnabled, false)).isTrue();
+
+    TypedArray typedArray = context.obtainStyledAttributes(roboAttributeSet, new int[]{R.attr.quitKeyCombo, R.attr.itemType});
+    assertThat(typedArray.getString(0)).isEqualTo("^q");
+    assertThat(typedArray.getInt(1, -1234)).isEqualTo(1 /* ungulate */);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowContextWrapperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextWrapperTest.java
new file mode 100644
index 0000000..02b149d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowContextWrapperTest.java
@@ -0,0 +1,940 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Application;
+import android.appwidget.AppWidgetProvider;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.UserHandle;
+import android.view.LayoutInflater;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.truth.IterableSubject;
+import com.google.common.util.concurrent.SettableFuture;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ConfigTestReceiver;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowActivity.IntentForResult;
+import org.robolectric.shadows.ShadowApplication.Wrapper;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Tests {@link ShadowContextWrapper} */
+@Config(manifest = "TestAndroidManifestWithReceivers.xml")
+@RunWith(AndroidJUnit4.class)
+public class ShadowContextWrapperTest {
+  public ArrayList<String> transcript;
+  private ContextWrapper contextWrapper;
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+  private final ShadowContextWrapper shadowContextWrapper = Shadow.extract(context);
+
+  @Before
+  public void setUp() throws Exception {
+    transcript = new ArrayList<>();
+    contextWrapper = new ContextWrapper(context);
+  }
+
+  @Test
+  public void sendBroadcast_shouldSendToManifestReceiver() throws Exception {
+    ConfigTestReceiver receiver = getReceiverOfClass(ConfigTestReceiver.class);
+
+    contextWrapper.sendBroadcast(new Intent(context, ConfigTestReceiver.class));
+    ShadowLooper.shadowMainLooper().idle();
+
+    assertThat(receiver.intentsReceived).hasSize(1);
+  }
+
+  @Test
+  public void sendBroadcastWithData_shouldSendToManifestReceiver() throws Exception {
+    ConfigTestReceiver receiver = getReceiverOfClass(ConfigTestReceiver.class);
+
+    contextWrapper.sendBroadcast(
+        new Intent(context, ConfigTestReceiver.class).setData(Uri.parse("http://google.com")));
+    ShadowLooper.shadowMainLooper().idle();
+
+    assertThat(receiver.intentsReceived).hasSize(1);
+  }
+
+  @Test
+  public void registerReceiver_shouldRegisterForAllIntentFilterActions() throws Exception {
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+    contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"));
+
+    contextWrapper.sendBroadcast(new Intent("foo"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+    transcript.clear();
+
+    contextWrapper.sendBroadcast(new Intent("womp"));
+    asyncAssertThat(transcript).isEmpty();
+
+    contextWrapper.sendBroadcast(new Intent("baz"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of baz");
+  }
+
+  @Test
+  public void sendBroadcast_shouldSendIntentToEveryInterestedReceiver() throws Exception {
+    BroadcastReceiver larryReceiver = broadcastReceiver("Larry");
+    contextWrapper.registerReceiver(larryReceiver, intentFilter("foo", "baz"));
+
+    BroadcastReceiver bobReceiver = broadcastReceiver("Bob");
+    contextWrapper.registerReceiver(bobReceiver, intentFilter("foo"));
+
+    contextWrapper.sendBroadcast(new Intent("foo"));
+    shadowMainLooper().idle();
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo", "Bob notified of foo");
+    transcript.clear();
+
+    contextWrapper.sendBroadcast(new Intent("womp"));
+    shadowMainLooper().idle();
+    asyncAssertThat(transcript).isEmpty();
+
+    contextWrapper.sendBroadcast(new Intent("baz"));
+    shadowMainLooper().idle();
+    asyncAssertThat(transcript).containsExactly("Larry notified of baz");
+  }
+
+  @Test
+  public void sendBroadcast_supportsLegacyExactPermissionMatch() {
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+    contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"), "validPermission", null);
+
+    contextWrapper.sendBroadcast(new Intent("foo"));
+    asyncAssertThat(transcript).isEmpty();
+
+    contextWrapper.sendBroadcast(new Intent("foo"), null);
+    asyncAssertThat(transcript).isEmpty();
+
+    contextWrapper.sendBroadcast(new Intent("foo"), "wrongPermission");
+    asyncAssertThat(transcript).isEmpty();
+
+    contextWrapper.sendBroadcast(new Intent("foo"), "validPermission");
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+    transcript.clear();
+
+    contextWrapper.sendBroadcast(new Intent("baz"), "validPermission");
+    asyncAssertThat(transcript).containsExactly("Larry notified of baz");
+  }
+
+  @Test
+  public void sendBroadcast_shouldOnlySendIntentWhenReceiverHasPermission() throws Exception {
+    Context receiverWithPermission = contextWithPermission("larryPackage", "larryPermission");
+    receiverWithPermission.registerReceiver(
+        broadcastReceiver("Larry"),
+        intentFilter("foo"),
+        /* broadcastPermission= */ null,
+        /* scheduler= */ null);
+
+    Context receiverWithoutPermission = contextWithPermission("bobPackage", "bobPermission");
+    receiverWithoutPermission.registerReceiver(
+        broadcastReceiver("Bob"),
+        intentFilter("foo"),
+        /* broadcastPermission= */ null,
+        /* scheduler= */ null);
+
+    contextWrapper.sendBroadcast(new Intent("foo"), /*receiverPermission=*/ "larryPermission");
+
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+  }
+
+  @Test
+  public void sendBroadcast_shouldOnlySendIntentWhenBroadcasterHasPermission() throws Exception {
+    contextWrapper.registerReceiver(
+        broadcastReceiver("Larry"),
+        intentFilter("foo"),
+        /* broadcastPermission= */ "larryPermission",
+        /* scheduler= */ null);
+
+    contextWrapper.registerReceiver(
+        broadcastReceiver("Bob"),
+        intentFilter("foo"),
+        /* broadcastPermission= */ "bobPermission",
+        /* scheduler= */ null);
+
+    Context broadcaster = contextWithPermission("broadcasterPackage", "larryPermission");
+    broadcaster.sendBroadcast(new Intent("foo"), /*receiverPermission=*/ null);
+
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+  }
+
+  private Context contextWithPermission(String packageName, String permission)
+      throws NameNotFoundException {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = packageName;
+    packageInfo.requestedPermissions = new String[] {permission};
+    shadowOf(contextWrapper.getPackageManager()).installPackage(packageInfo);
+    return contextWrapper.createPackageContext(packageInfo.packageName, 0);
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void sendBroadcast_shouldSendIntentUsingHandlerIfOneIsProvided_legacy() {
+    HandlerThread handlerThread = new HandlerThread("test");
+    handlerThread.start();
+
+    Handler handler = new Handler(handlerThread.getLooper());
+    assertNotSame(handler.getLooper(), Looper.getMainLooper());
+
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+    contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"), null, handler);
+
+    assertThat(shadowOf(handler.getLooper()).getScheduler().size()).isEqualTo(0);
+    contextWrapper.sendBroadcast(new Intent("foo"));
+    assertThat(shadowOf(handler.getLooper()).getScheduler().size()).isEqualTo(1);
+    shadowOf(handlerThread.getLooper()).idle();
+    assertThat(shadowOf(handler.getLooper()).getScheduler().size()).isEqualTo(0);
+
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+  }
+
+  @Test
+  @LooperMode(PAUSED)
+  public void sendBroadcast_shouldSendIntentUsingHandlerIfOneIsProvided()
+      throws InterruptedException {
+    HandlerThread handlerThread = new HandlerThread("test");
+    handlerThread.start();
+
+    Handler handler = new Handler(handlerThread.getLooper());
+    assertNotSame(handler.getLooper(), Looper.getMainLooper());
+
+    BroadcastReceiver receiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            transcript.add(
+                "notified of "
+                    + intent.getAction()
+                    + " on thread "
+                    + Thread.currentThread().getName());
+          }
+        };
+    contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"), null, handler);
+
+    assertThat(transcript).isEmpty();
+
+    contextWrapper.sendBroadcast(new Intent("foo"));
+
+    shadowOf(handlerThread.getLooper()).idle();
+    assertThat(transcript).containsExactly("notified of foo on thread " + handlerThread.getName());
+
+    handlerThread.quit();
+  }
+
+  @Test
+  public void sendBroadcast_withClassSet_shouldSendIntentToSpecifiedReceiver() throws Exception {
+    BroadcastReceiver larryReceiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            transcript.add("Larry notified of " + intent.getAction());
+          }
+        };
+    contextWrapper.registerReceiver(larryReceiver, intentFilter("foo"));
+
+    BroadcastReceiver bobReceiver = broadcastReceiver("Bob");
+    contextWrapper.registerReceiver(bobReceiver, intentFilter("foo"));
+
+    contextWrapper.sendBroadcast(
+        new Intent("baz").setClass(contextWrapper, larryReceiver.getClass()));
+
+    asyncAssertThat(transcript).containsExactly("Larry notified of baz");
+  }
+
+  @Test
+  public void sendOrderedBroadcast_shouldReturnValues() throws Exception {
+    String action = "test";
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(2);
+    BroadcastReceiver highReceiver = broadcastReceiver("High");
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    final FooReceiver resultReceiver = new FooReceiver();
+    contextWrapper.sendOrderedBroadcast(
+        new Intent(action), null, resultReceiver, null, 1, "initial", null);
+    asyncAssertThat(transcript).containsExactly("High notified of test", "Low notified of test");
+    assertThat(resultReceiver.resultCode).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void sendOrderedBroadcastAsUser_shouldReturnValues() throws Exception {
+    String action = "test";
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(2);
+    BroadcastReceiver highReceiver = broadcastReceiver("High");
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    final FooReceiver resultReceiver = new FooReceiver();
+    contextWrapper.sendOrderedBroadcastAsUser(
+        new Intent(action), null, null, resultReceiver, null, 1, "initial", null);
+    asyncAssertThat(transcript).containsExactly("High notified of test", "Low notified of test");
+    assertThat(resultReceiver.resultCode).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void sendOrderedBroadcastAsUser_withAppOp_shouldReturnValues() throws Exception {
+    String action = "test";
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(2);
+    BroadcastReceiver highReceiver = broadcastReceiver("High");
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    final FooReceiver resultReceiver = new FooReceiver();
+
+    ReflectionHelpers.callInstanceMethod(
+        contextWrapper,
+        "sendOrderedBroadcastAsUser",
+        ClassParameter.from(Intent.class, new Intent(action)),
+        ClassParameter.from(UserHandle.class, null),
+        ClassParameter.from(String.class, null),
+        ClassParameter.from(int.class, 1),
+        ClassParameter.from(BroadcastReceiver.class, resultReceiver),
+        ClassParameter.from(Handler.class, null),
+        ClassParameter.from(int.class, 1),
+        ClassParameter.from(String.class, "initial"),
+        ClassParameter.from(Bundle.class, null));
+
+    asyncAssertThat(transcript).containsExactly("High notified of test", "Low notified of test");
+    assertThat(resultReceiver.resultCode).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void sendOrderedBroadcastAsUser_withAppOpAndOptions_shouldReturnValues() throws Exception {
+    String action = "test";
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(2);
+    BroadcastReceiver highReceiver = broadcastReceiver("High");
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    final FooReceiver resultReceiver = new FooReceiver();
+
+    ReflectionHelpers.callInstanceMethod(
+        contextWrapper,
+        "sendOrderedBroadcastAsUser",
+        ClassParameter.from(Intent.class, new Intent(action)),
+        ClassParameter.from(UserHandle.class, null),
+        ClassParameter.from(String.class, null),
+        ClassParameter.from(int.class, 1),
+        ClassParameter.from(Bundle.class, null),
+        ClassParameter.from(BroadcastReceiver.class, resultReceiver),
+        ClassParameter.from(Handler.class, null),
+        ClassParameter.from(int.class, 1),
+        ClassParameter.from(String.class, "initial"),
+        ClassParameter.from(Bundle.class, null));
+
+    asyncAssertThat(transcript).containsExactly("High notified of test", "Low notified of test");
+    assertThat(resultReceiver.resultCode).isEqualTo(1);
+  }
+
+  private static final class FooReceiver extends BroadcastReceiver {
+    private int resultCode;
+    private SettableFuture<Void> settableFuture = SettableFuture.create();
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      resultCode = getResultCode();
+      settableFuture.set(null);
+    }
+  }
+
+  @Test
+  public void sendOrderedBroadcast_shouldExecuteSerially() {
+    String action = "test";
+    AtomicReference<BroadcastReceiver.PendingResult> midResult = new AtomicReference<>();
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter midFilter = new IntentFilter(action);
+    midFilter.setPriority(2);
+    AsyncReceiver midReceiver = new AsyncReceiver(midResult);
+    contextWrapper.registerReceiver(midReceiver, midFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(3);
+    BroadcastReceiver highReceiver = broadcastReceiver("High");
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    contextWrapper.sendOrderedBroadcast(new Intent(action), null);
+    asyncAssertThat(transcript).containsExactly("High notified of test", "Mid notified of test");
+    transcript.clear();
+    assertThat(midResult.get()).isNotNull();
+    midResult.get().finish();
+
+    asyncAssertThat(transcript).containsExactly("Low notified of test");
+  }
+
+  private class AsyncReceiver extends BroadcastReceiver {
+    private final AtomicReference<PendingResult> reference;
+
+    private AsyncReceiver(AtomicReference<PendingResult> reference) {
+      this.reference = reference;
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      reference.set(goAsync());
+      transcript.add("Mid notified of " + intent.getAction());
+    }
+  }
+
+  @Test
+  public void sendOrderedBroadcast_shouldSendByPriority() throws Exception {
+    String action = "test";
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(2);
+    BroadcastReceiver highReceiver = broadcastReceiver("High");
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    contextWrapper.sendOrderedBroadcast(new Intent(action), null);
+    shadowMainLooper().idle();
+    asyncAssertThat(transcript).containsExactly("High notified of test", "Low notified of test");
+  }
+
+  @Test
+  public void orderedBroadcasts_shouldAbort() throws Exception {
+    String action = "test";
+
+    IntentFilter lowFilter = new IntentFilter(action);
+    lowFilter.setPriority(1);
+    BroadcastReceiver lowReceiver = broadcastReceiver("Low");
+    contextWrapper.registerReceiver(lowReceiver, lowFilter);
+
+    IntentFilter highFilter = new IntentFilter(action);
+    highFilter.setPriority(2);
+    BroadcastReceiver highReceiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            transcript.add("High" + " notified of " + intent.getAction());
+            abortBroadcast();
+          }
+        };
+    contextWrapper.registerReceiver(highReceiver, highFilter);
+
+    contextWrapper.sendOrderedBroadcast(new Intent(action), null);
+    asyncAssertThat(transcript).containsExactly("High notified of test");
+  }
+
+  @Test
+  public void unregisterReceiver_shouldUnregisterReceiver() throws Exception {
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+
+    contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"));
+    contextWrapper.unregisterReceiver(receiver);
+
+    contextWrapper.sendBroadcast(new Intent("foo"));
+    asyncAssertThat(transcript).isEmpty();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void unregisterReceiver_shouldThrowExceptionWhenReceiverIsNotRegistered()
+      throws Exception {
+    contextWrapper.unregisterReceiver(new AppWidgetProvider());
+  }
+
+  @Test
+  public void broadcastReceivers_shouldBeSharedAcrossContextsPerApplicationContext()
+      throws Exception {
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+
+    Application application = ApplicationProvider.getApplicationContext();
+    new ContextWrapper(application).registerReceiver(receiver, intentFilter("foo", "baz"));
+    new ContextWrapper(application).sendBroadcast(new Intent("foo"));
+    application.sendBroadcast(new Intent("baz"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo", "Larry notified of baz");
+
+    new ContextWrapper(application).unregisterReceiver(receiver);
+  }
+
+  private static IterableSubject asyncAssertThat(ArrayList<String> transcript) {
+    shadowMainLooper().idle();
+    return assertThat(transcript);
+  }
+
+  @Test
+  public void broadcasts_shouldBeLogged() {
+    Intent broadcastIntent = new Intent("foo");
+    contextWrapper.sendBroadcast(broadcastIntent);
+
+    List<Intent> broadcastIntents = shadowOf(contextWrapper).getBroadcastIntents();
+    assertTrue(broadcastIntents.size() == 1);
+    assertEquals(broadcastIntent, broadcastIntents.get(0));
+  }
+
+  @Test
+  public void clearBroadcastIntents_clearsBroadcastIntents() {
+    Intent broadcastIntent = new Intent("foo");
+    contextWrapper.sendBroadcast(broadcastIntent);
+
+    assertThat(shadowOf(contextWrapper).getBroadcastIntents()).hasSize(1);
+
+    shadowOf(contextWrapper).clearBroadcastIntents();
+
+    assertThat(shadowOf(contextWrapper).getBroadcastIntents()).isEmpty();
+  }
+
+  @Test
+  public void sendStickyBroadcast_shouldDeliverIntentToAllRegisteredReceivers() {
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+    contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"));
+
+    contextWrapper.sendStickyBroadcast(new Intent("foo"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+    transcript.clear();
+
+    contextWrapper.sendStickyBroadcast(new Intent("womp"));
+    asyncAssertThat(transcript).isEmpty();
+
+    contextWrapper.sendStickyBroadcast(new Intent("baz"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of baz");
+  }
+
+  @Test
+  public void sendStickyBroadcast_shouldStickSentIntent() {
+    contextWrapper.sendStickyBroadcast(new Intent("foo"));
+    asyncAssertThat(transcript).isEmpty();
+
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+    Intent sticker = contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo");
+    assertThat(sticker).isNotNull();
+    assertThat(sticker.getAction()).isEqualTo("foo");
+  }
+
+  @Test
+  public void afterSendStickyBroadcast_allSentIntentsShouldBeDeliveredToNewRegistrants() {
+    contextWrapper.sendStickyBroadcast(new Intent("foo"));
+    contextWrapper.sendStickyBroadcast(new Intent("baz"));
+    asyncAssertThat(transcript).isEmpty();
+
+    BroadcastReceiver receiver = broadcastReceiver("Larry");
+    Intent sticker = contextWrapper.registerReceiver(receiver, intentFilter("foo", "baz"));
+    asyncAssertThat(transcript).containsExactly("Larry notified of foo", "Larry notified of baz");
+
+    /*
+      Note: we do not strictly test what is returned by the method in this case
+            because there no guaranties what particular Intent will be returned by Android system
+    */
+    assertThat(sticker).isNotNull();
+  }
+
+  @Test
+  public void shouldReturnSameApplicationEveryTime() throws Exception {
+    Activity activity = new Activity();
+    assertThat(activity.getApplication()).isSameInstanceAs(activity.getApplication());
+
+    assertThat(activity.getApplication()).isSameInstanceAs(new Activity().getApplication());
+  }
+
+  @Test
+  public void shouldReturnSameApplicationContextEveryTime() throws Exception {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    assertThat(activity.getApplicationContext()).isSameInstanceAs(activity.getApplicationContext());
+
+    assertThat(activity.getApplicationContext())
+        .isSameInstanceAs(Robolectric.setupActivity(Activity.class).getApplicationContext());
+  }
+
+  @Test
+  public void shouldReturnApplicationContext_forViewContextInflatedWithApplicationContext()
+      throws Exception {
+    View view =
+        LayoutInflater.from(ApplicationProvider.getApplicationContext())
+            .inflate(R.layout.custom_layout, null);
+    Context viewContext = new ContextWrapper(view.getContext());
+    assertThat(viewContext.getApplicationContext())
+        .isEqualTo(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void shouldReturnSameContentResolverEveryTime() throws Exception {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    assertThat(activity.getContentResolver()).isSameInstanceAs(activity.getContentResolver());
+
+    assertThat(activity.getContentResolver())
+        .isSameInstanceAs(Robolectric.setupActivity(Activity.class).getContentResolver());
+  }
+
+  @Test
+  public void shouldReturnSameLocationManagerEveryTime() throws Exception {
+    assertSameInstanceEveryTime(Context.LOCATION_SERVICE);
+  }
+
+  @Test
+  public void shouldReturnSameWifiManagerEveryTime() throws Exception {
+    assertSameInstanceEveryTime(Context.WIFI_SERVICE);
+  }
+
+  @Test
+  public void shouldReturnSameAlarmServiceEveryTime() throws Exception {
+    assertSameInstanceEveryTime(Context.ALARM_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = 23)
+  public void checkSelfPermission() {
+    assertThat(contextWrapper.checkSelfPermission("MY_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+
+    shadowContextWrapper.grantPermissions("MY_PERMISSON");
+
+    assertThat(contextWrapper.checkSelfPermission("MY_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkSelfPermission("UNKNOWN_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+
+  @Test
+  public void checkPermissionUidPid() {
+    assertThat(contextWrapper.checkPermission("MY_PERMISSON", 1, 1))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+
+    shadowContextWrapper.grantPermissions(1, 1, "MY_PERMISSON");
+
+    assertThat(contextWrapper.checkPermission("MY_PERMISSON", 2, 1))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+
+    assertThat(contextWrapper.checkPermission("MY_PERMISSON", 1, 1))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+  }
+
+  @Test
+  @Config(minSdk = 23)
+  public void checkAdditionalSelfPermission() {
+    shadowContextWrapper.grantPermissions("MY_PERMISSON");
+    assertThat(contextWrapper.checkSelfPermission("MY_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkSelfPermission("ANOTHER_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+
+    shadowContextWrapper.grantPermissions("ANOTHER_PERMISSON");
+    assertThat(contextWrapper.checkSelfPermission("ANOTHER_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+  }
+
+  @Test
+  @Config(minSdk = 23)
+  public void revokeSelfPermission() {
+    shadowContextWrapper.grantPermissions("MY_PERMISSON");
+
+    assertThat(contextWrapper.checkSelfPermission("MY_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+    shadowContextWrapper.denyPermissions("MY_PERMISSON");
+
+    assertThat(contextWrapper.checkSelfPermission("MY_PERMISSON"))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+
+  @Test
+  public void revokePermissionUidPid() {
+    shadowContextWrapper.grantPermissions(1, 1, "MY_PERMISSON");
+
+    assertThat(contextWrapper.checkPermission("MY_PERMISSON", 1, 1))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+    shadowContextWrapper.denyPermissions(1, 1, "MY_PERMISSON");
+
+    assertThat(contextWrapper.checkPermission("MY_PERMISSON", 1, 1))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+
+  private void assertSameInstanceEveryTime(String serviceName) {
+    Activity activity1 = buildActivity(Activity.class).create().get();
+    Activity activity2 = buildActivity(Activity.class).create().get();
+    assertThat(activity1.getSystemService(serviceName))
+        .isSameInstanceAs(activity1.getSystemService(serviceName));
+    assertThat(activity1.getSystemService(serviceName))
+        .isSameInstanceAs(activity2.getSystemService(serviceName));
+  }
+
+  @Test
+  public void bindServiceDelegatesToShadowApplication() {
+    contextWrapper.bindService(
+        new Intent("foo").setPackage("dummy.package"), new TestService(), Context.BIND_AUTO_CREATE);
+    assertEquals(
+        "foo",
+        shadowOf((Application) ApplicationProvider.getApplicationContext())
+            .getNextStartedService()
+            .getAction());
+  }
+
+  @Test
+  public void startActivities_shouldStartAllActivities() {
+    final Intent view = new Intent(Intent.ACTION_VIEW).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    final Intent pick = new Intent(Intent.ACTION_PICK).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    contextWrapper.startActivities(new Intent[] {view, pick});
+
+    assertThat(shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity())
+        .isEqualTo(pick);
+    assertThat(shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity())
+        .isEqualTo(view);
+  }
+
+  @Test
+  public void startActivities_withBundle_shouldStartAllActivities() {
+    final Intent view = new Intent(Intent.ACTION_VIEW).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    final Intent pick = new Intent(Intent.ACTION_PICK).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    contextWrapper.startActivities(new Intent[] {view, pick}, new Bundle());
+
+    assertThat(shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity())
+        .isEqualTo(pick);
+    assertThat(shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity())
+        .isEqualTo(view);
+  }
+
+  @Test
+  public void startActivities_canGetNextStartedActivityForResult() {
+    final Intent view = new Intent(Intent.ACTION_VIEW).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    final Intent pick = new Intent(Intent.ACTION_PICK).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    contextWrapper.startActivities(new Intent[] {view, pick});
+
+    IntentForResult second =
+        shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivityForResult();
+    IntentForResult first =
+        shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivityForResult();
+
+    assertThat(second.intent).isEqualTo(pick);
+    assertThat(second.options).isNull();
+
+    assertThat(first.intent).isEqualTo(view);
+    assertThat(first.options).isNull();
+  }
+
+  @Test
+  public void startActivities_withBundle_canGetNextStartedActivityForResult() {
+    final Intent view = new Intent(Intent.ACTION_VIEW).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    final Intent pick = new Intent(Intent.ACTION_PICK).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    Bundle options = new Bundle();
+    options.putString("foo", "bar");
+    contextWrapper.startActivities(new Intent[] {view, pick}, options);
+
+    IntentForResult second =
+        shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivityForResult();
+    IntentForResult first =
+        shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivityForResult();
+
+    assertThat(second.intent).isEqualTo(pick);
+    assertThat(second.options).isEqualTo(options);
+
+    assertThat(first.intent).isEqualTo(view);
+    assertThat(first.options).isEqualTo(options);
+  }
+
+  private BroadcastReceiver broadcastReceiver(final String name) {
+    return new BroadcastReceiver() {
+      @Override
+      public void onReceive(Context context, Intent intent) {
+        transcript.add(name + " notified of " + intent.getAction());
+      }
+    };
+  }
+
+  private IntentFilter intentFilter(String... actions) {
+    IntentFilter larryIntentFilter = new IntentFilter();
+    for (String action : actions) {
+      larryIntentFilter.addAction(action);
+    }
+    return larryIntentFilter;
+  }
+
+  private <T> T getReceiverOfClass(Class<T> receiverClass) {
+    ShadowApplication app = shadowOf((Application) context);
+    List<Wrapper> receivers = app.getRegisteredReceivers();
+    for (Wrapper wrapper : receivers) {
+      if (receiverClass.isInstance(wrapper.getBroadcastReceiver())) {
+        return receiverClass.cast(wrapper.getBroadcastReceiver());
+      }
+    }
+
+    return null;
+  }
+
+  @Test
+  public void packageManagerShouldNotBeNullWhenWrappingAnApplication() {
+    assertThat(ApplicationProvider.getApplicationContext().getPackageManager()).isNotNull();
+  }
+
+  @Test
+  public void checkCallingPermissionsShouldReturnPermissionGrantedToAddedPermissions()
+      throws Exception {
+    shadowOf(contextWrapper).grantPermissions("foo", "bar");
+    assertThat(contextWrapper.checkCallingPermission("foo")).isEqualTo(PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkCallingPermission("bar")).isEqualTo(PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkCallingPermission("baz")).isEqualTo(PERMISSION_DENIED);
+  }
+
+  @Test
+  public void checkCallingOrSelfPermissionsShouldReturnPermissionGrantedToAddedPermissions()
+      throws Exception {
+    shadowOf(contextWrapper).grantPermissions("foo", "bar");
+    assertThat(contextWrapper.checkCallingOrSelfPermission("foo")).isEqualTo(PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkCallingOrSelfPermission("bar")).isEqualTo(PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkCallingOrSelfPermission("baz")).isEqualTo(PERMISSION_DENIED);
+  }
+
+  @Test
+  public void checkCallingPermission_shouldReturnPermissionDeniedForRemovedPermissions()
+      throws Exception {
+    shadowOf(contextWrapper).grantPermissions("foo", "bar");
+    shadowOf(contextWrapper).denyPermissions("foo", "qux");
+    assertThat(contextWrapper.checkCallingPermission("foo")).isEqualTo(PERMISSION_DENIED);
+    assertThat(contextWrapper.checkCallingPermission("bar")).isEqualTo(PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkCallingPermission("baz")).isEqualTo(PERMISSION_DENIED);
+    assertThat(contextWrapper.checkCallingPermission("qux")).isEqualTo(PERMISSION_DENIED);
+  }
+
+  @Test
+  public void checkCallingOrSelfPermission_shouldReturnPermissionDeniedForRemovedPermissions()
+      throws Exception {
+    shadowOf(contextWrapper).grantPermissions("foo", "bar");
+    shadowOf(contextWrapper).denyPermissions("foo", "qux");
+    assertThat(contextWrapper.checkCallingOrSelfPermission("foo")).isEqualTo(PERMISSION_DENIED);
+    assertThat(contextWrapper.checkCallingOrSelfPermission("bar")).isEqualTo(PERMISSION_GRANTED);
+    assertThat(contextWrapper.checkCallingOrSelfPermission("baz")).isEqualTo(PERMISSION_DENIED);
+    assertThat(contextWrapper.checkCallingOrSelfPermission("qux")).isEqualTo(PERMISSION_DENIED);
+  }
+
+  @Test
+  public void getSharedPreferencesShouldReturnSameInstanceWhenSameNameIsSupplied() {
+    final SharedPreferences pref1 =
+        contextWrapper.getSharedPreferences("pref", Context.MODE_PRIVATE);
+    final SharedPreferences pref2 =
+        contextWrapper.getSharedPreferences("pref", Context.MODE_PRIVATE);
+
+    assertThat(pref1).isSameInstanceAs(pref2);
+  }
+
+  @Test
+  public void getSharedPreferencesShouldReturnDifferentInstancesWhenDifferentNameIsSupplied() {
+    final SharedPreferences pref1 =
+        contextWrapper.getSharedPreferences("pref1", Context.MODE_PRIVATE);
+    final SharedPreferences pref2 =
+        contextWrapper.getSharedPreferences("pref2", Context.MODE_PRIVATE);
+
+    assertThat(pref1).isNotSameInstanceAs(pref2);
+  }
+
+  @Test
+  public void sendBroadcast_shouldOnlySendIntentWithTypeWhenReceiverMatchesType()
+      throws IntentFilter.MalformedMimeTypeException {
+
+    final BroadcastReceiver viewAllTypesReceiver =
+        broadcastReceiver("ViewActionWithAnyTypeReceiver");
+    final IntentFilter allTypesIntentFilter = intentFilter("view");
+    allTypesIntentFilter.addDataType("*/*");
+    contextWrapper.registerReceiver(viewAllTypesReceiver, allTypesIntentFilter);
+
+    final BroadcastReceiver imageReceiver = broadcastReceiver("ImageReceiver");
+    final IntentFilter imageIntentFilter = intentFilter("view");
+    imageIntentFilter.addDataType("img/*");
+    contextWrapper.registerReceiver(imageReceiver, imageIntentFilter);
+
+    final BroadcastReceiver videoReceiver = broadcastReceiver("VideoReceiver");
+    final IntentFilter videoIntentFilter = intentFilter("view");
+    videoIntentFilter.addDataType("video/*");
+    contextWrapper.registerReceiver(videoReceiver, videoIntentFilter);
+
+    final BroadcastReceiver viewReceiver = broadcastReceiver("ViewActionReceiver");
+    final IntentFilter viewIntentFilter = intentFilter("view");
+    contextWrapper.registerReceiver(viewReceiver, viewIntentFilter);
+
+    final Intent imageIntent = new Intent("view");
+    imageIntent.setType("img/jpeg");
+    contextWrapper.sendBroadcast(imageIntent);
+
+    final Intent videoIntent = new Intent("view");
+    videoIntent.setType("video/mp4");
+    contextWrapper.sendBroadcast(videoIntent);
+
+    asyncAssertThat(transcript)
+        .containsExactly(
+            "ViewActionWithAnyTypeReceiver notified of view",
+            "ImageReceiver notified of view",
+            "ViewActionWithAnyTypeReceiver notified of view",
+            "VideoReceiver notified of view");
+  }
+
+  @Test
+  public void getApplicationInfo_shouldReturnApplicationInfoForApplicationPackage() {
+    final ApplicationInfo info = contextWrapper.getApplicationInfo();
+    assertThat(info.packageName).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void removeSystemService_getSystemServiceReturnsNull() {
+    shadowContextWrapper.removeSystemService(Context.WALLPAPER_SERVICE);
+    assertThat(context.getSystemService(Context.WALLPAPER_SERVICE)).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCookieManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCookieManagerTest.java
new file mode 100644
index 0000000..660982c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCookieManagerTest.java
@@ -0,0 +1,310 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.webkit.CookieManager;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.base.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCookieManagerTest {
+  private final String url = "robolectric.org/";
+  private final String httpUrl = "http://robolectric.org/";
+  private final String httpsUrl = "https://robolectric.org/";
+  private final CookieManager cookieManager = CookieManager.getInstance();
+
+  private Optional<Boolean> cookiesSet = Optional.absent();
+  private Optional<Boolean> cookiesRemoved = Optional.absent();
+
+  @Test
+  public void shouldGetASingletonInstance() {
+    assertThat(CookieManager.getInstance()).isNotNull();
+    assertThat(CookieManager.getInstance()).isSameInstanceAs(CookieManager.getInstance());
+  }
+
+  @Test
+  public void shouldSetAndGetACookie() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String url = "http://www.google.com";
+    String value = "my cookie";
+    cookieManager.setCookie(url, value);
+    assertThat(cookieManager.getCookie(url)).isEqualTo(value);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldGetCookieWhenSetAsyncWithNormalCallback() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String url = "http://www.google.com";
+    String value = "my cookie";
+
+    cookieManager.setCookie(
+        url,
+        value,
+        result -> {
+          cookiesSet = Optional.of(result);
+        });
+
+    assertThat(cookiesSet).hasValue(true);
+    assertThat(cookieManager.getCookie(url)).isEqualTo(value);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldGetCookieWhenSetAsyncWithNullCallback() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String url = "http://www.google.com";
+    String value = "my cookie";
+    cookieManager.setCookie(url, value, null);
+    assertThat(cookieManager.getCookie(url)).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldGetCookieForUrl() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String url1 = "http://www.google.com";
+    String value1 = "my cookie";
+    cookieManager.setCookie(url1, value1);
+
+    String url2 = "http://www.hotbot.com";
+    String value2 = "some special value: thing";
+    cookieManager.setCookie(url2, value2);
+
+    assertThat(cookieManager.getCookie("http://www.google.com")).isEqualTo(value1);
+    assertThat(cookieManager.getCookie(url2)).isEqualTo(value2);
+  }
+
+  @Test
+  public void shouldGetCookieForHostInDomain() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String value1 = "my cookie";
+    cookieManager.setCookie("foo.com/this%20is%20a%20test", value1);
+
+    assertThat(cookieManager.getCookie(".foo.com")).isEqualTo(value1);
+  }
+
+  @Test
+  public void shouldNotGetCookieForHostNotInDomain() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String value1 = "my cookie";
+    cookieManager.setCookie("bar.foo.com/this%20is%20a%20test", value1);
+
+    assertThat(cookieManager.getCookie(".bar.com")).isNull();
+  }
+
+  @Test
+  public void shouldGetCookieForHostInSubDomain() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String value1 = "my cookie";
+    cookieManager.setCookie("host.in.subdomain.bar.com", value1);
+
+    assertThat(cookieManager.getCookie(".bar.com")).isEqualTo(value1);
+  }
+
+  @Test
+  public void shouldGetCookieForHostInDomainDefinedWithProtocol() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    String value1 = "my cookie";
+    cookieManager.setCookie("qutz.com/", value1);
+
+    assertThat(cookieManager.getCookie("http://.qutz.com")).isEqualTo(value1);
+  }
+
+  @Test
+  public void shouldRecordAcceptCookie() {
+    CookieManager cookieManager = CookieManager.getInstance();
+    cookieManager.setCookie("foo", "bar");
+    cookieManager.setCookie("baz", "qux");
+    assertThat(cookieManager.getCookie("foo")).isNotNull();
+    cookieManager.removeAllCookie();
+    assertThat(cookieManager.getCookie("foo")).isNull();
+    assertThat(cookieManager.getCookie("baz")).isNull();
+  }
+
+  @Test
+  public void shouldHaveCookieWhenCookieIsSet() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    assertThat(cookieManager.hasCookies()).isEqualTo(true);
+  }
+
+  @Test
+  public void shouldNotHaveCookieWhenCookieIsNotSet() {
+    assertThat(cookieManager.hasCookies()).isEqualTo(false);
+  }
+
+  @Test
+  public void shouldGetNullWhenCookieIsNotPresent() {
+    assertThat(cookieManager.getCookie(url)).isNull();
+  }
+
+  @Test
+  public void shouldGetNullWhenCookieIsNotPresentInUrl() {
+    cookieManager.setCookie(httpUrl, "name=value; Expires=Wed, 11 Jul 2035 08:12:26 GMT");
+    assertThat(cookieManager.getCookie("http://google.com")).isNull();
+  }
+
+  @Test
+  public void shouldSetAndGetOneCookie() {
+    cookieManager.setCookie(httpUrl, "name=value; Expires=Wed, 11 Jul 2035 08:12:26 GMT");
+    assertThat(cookieManager.getCookie(httpUrl)).isEqualTo("name=value");
+  }
+
+  @Test
+  public void shouldSetWithHttpAndGetWithoutHttp() {
+    cookieManager.setCookie(httpUrl, "name=value; Expires=Wed, 11 Jul 2035 08:12:26 GMT");
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  public void shouldSetWithHttpAndGetWithHttps() {
+    cookieManager.setCookie(httpUrl, "name=value; Expires=Wed, 11 Jul 2035 08:12:26 GMT");
+    assertThat(cookieManager.getCookie(httpsUrl)).isEqualTo("name=value");
+  }
+
+  @Test
+  public void shouldSetTwoCookies() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value; name2=value2");
+  }
+
+  @Test
+  public void shouldSetCookieWithInvalidExpiesValue() {
+    cookieManager.setCookie(httpUrl, "name=value; Expires=3234asdfasdf10:18:14 GMT");
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  public void shouldSetCookieWithoutValue() {
+    cookieManager.setCookie(httpUrl, "name=");
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=");
+  }
+
+  @Test
+  public void shouldSetCookieWithNameOnly() {
+    cookieManager.setCookie(httpUrl, "name");
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name");
+  }
+
+  @Test
+  public void testSetAndGetCookieWhenAcceptCookieIsFalse() {
+    cookieManager.setAcceptCookie(false);
+    cookieManager.setCookie(httpUrl, "name=value; Expires=3234asdfasdf10:18:14 GMT");
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+    assertThat(cookieManager.acceptCookie()).isEqualTo(false);
+  }
+
+  @Test
+  public void shouldRemoveAllCookie() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2;");
+    cookieManager.removeAllCookie();
+    assertThat(cookieManager.getCookie(url)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldRemoveAllCookiesWithCallback() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2;");
+
+    cookieManager.removeAllCookies(
+        ok -> {
+          cookiesRemoved = Optional.of(ok);
+        });
+    assertThat(cookiesRemoved).hasValue(true);
+    assertThat(cookieManager.getCookie(url)).isNull();
+  }
+
+  @Test
+  public void shouldRemoveExpiredCookie() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 11 Jul 2035 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2; Expires=Wed, 13 Jul 2011 10:18:14 GMT");
+    cookieManager.removeExpiredCookie();
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  public void shouldRemoveSessionCookie() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2;");
+    cookieManager.removeSessionCookie();
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldRemoveSessionCookiesWithNormalCallback() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2;");
+
+    cookieManager.removeSessionCookies(
+        ok -> {
+          cookiesRemoved = Optional.of(ok);
+        });
+    assertThat(cookiesRemoved).hasValue(true);
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldRemoveSessionCookiesWithNullCallback() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+    cookieManager.setCookie(url, "name2=value2;");
+
+    cookieManager.removeSessionCookies(null);
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldRemoveSessionCookiesWhenSessionCookieIsNoPresent() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09 Jun 2121 10:18:14 GMT");
+
+    cookieManager.removeSessionCookies(
+        ok -> {
+          cookiesRemoved = Optional.of(ok);
+        });
+    assertThat(cookiesRemoved).hasValue(false);
+    assertThat(cookieManager.getCookie(url)).isEqualTo("name=value");
+  }
+
+  @Test
+  public void shouldIgnoreCookiesSetInThePast() {
+    cookieManager.setCookie(url, "name=value; Expires=Wed, 09-Jun-2000 10:18:14 GMT");
+
+    String url2 = "http://android.com";
+    cookieManager.setCookie(url2, "name2=value2; Expires=Wed, 09 Jun 2000 10:18:14 GMT");
+
+    assertThat(cookieManager.getCookie(url)).isNull();
+    assertThat(cookieManager.getCookie(url2)).isNull();
+  }
+
+  @Test
+  public void shouldRespectSecureCookies() {
+    cookieManager.setCookie(httpsUrl, "name1=value1;secure");
+    cookieManager.setCookie(httpUrl, "name2=value2;");
+
+    String cookie = cookieManager.getCookie(httpUrl);
+    assertThat(cookie.contains("name2=value2")).isTrue();
+    assertThat(cookie.contains("name1=value1")).isFalse();
+  }
+
+  @Test
+  public void shouldIgnoreExtraKeysInCookieString() {
+    cookieManager.setCookie(httpsUrl, "name1=value1; name2=value2; Secure");
+
+    String cookie = cookieManager.getCookie(httpsUrl);
+    assertThat(cookie).contains("name1=value1");
+    assertThat(cookie).doesNotContain("name2=value2");
+  }
+
+  @Test
+  public void shouldIgnoreEmptyURLs() {
+    assertThat(cookieManager.getCookie("")).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCookieSyncManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCookieSyncManagerTest.java
new file mode 100644
index 0000000..3beee3c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCookieSyncManagerTest.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.webkit.CookieSyncManager;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCookieSyncManagerTest {
+
+  @Test
+  public void testCreateInstance() {
+    assertThat(CookieSyncManager.createInstance(new Activity())).isNotNull();
+  }
+
+  @Test
+  public void testGetInstance() {
+    CookieSyncManager.createInstance(new Activity());
+    assertThat(CookieSyncManager.getInstance()).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCountDownTimerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCountDownTimerTest.java
new file mode 100644
index 0000000..7807773
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCountDownTimerTest.java
@@ -0,0 +1,76 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.CountDownTimer;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCountDownTimerTest {
+
+  private ShadowCountDownTimer shadowCountDownTimer;
+  private final long millisInFuture = 2000;
+  private final long countDownInterval = 1000;
+  private String msg = null;
+
+  @Before
+  public void setUp() throws Exception {
+
+    CountDownTimer countDownTimer =
+        new CountDownTimer(millisInFuture, countDownInterval) {
+
+          @Override
+          public void onFinish() {
+            msg = "onFinish() is called";
+          }
+
+          @Override
+          public void onTick(long millisUnitilFinished) {
+            msg = "onTick() is called";
+          }
+        };
+    shadowCountDownTimer = Shadows.shadowOf(countDownTimer);
+  }
+
+
+  @Test
+  public void testInvokeOnTick() {
+    assertThat(msg).isNotEqualTo("onTick() is called");
+    shadowCountDownTimer.invokeTick(countDownInterval);
+    assertThat(msg).isEqualTo("onTick() is called");
+  }
+
+  @Test
+  public void testInvokeOnFinish() {
+    assertThat(msg).isNotEqualTo("onFinish() is called");
+    shadowCountDownTimer.invokeFinish();
+    assertThat(msg).isEqualTo("onFinish() is called");
+  }
+
+  @Test
+  public void testStart() {
+    assertThat(shadowCountDownTimer.hasStarted()).isFalse();
+    CountDownTimer timer = shadowCountDownTimer.start();
+    assertThat(timer).isNotNull();
+    assertThat(shadowCountDownTimer.hasStarted()).isTrue();
+  }
+
+  @Test
+  public void testCancel() {
+    CountDownTimer timer = shadowCountDownTimer.start();
+    assertThat(timer).isNotNull();
+    assertThat(shadowCountDownTimer.hasStarted()).isTrue();
+    shadowCountDownTimer.cancel();
+    assertThat(shadowCountDownTimer.hasStarted()).isFalse();
+  }
+
+  @Test
+  public void testAccessors() {
+    assertThat(shadowCountDownTimer.getCountDownInterval()).isEqualTo(countDownInterval);
+    assertThat(shadowCountDownTimer.getMillisInFuture()).isEqualTo(millisInFuture);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCountingAdapter.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCountingAdapter.java
new file mode 100644
index 0000000..7768230
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCountingAdapter.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+
+class ShadowCountingAdapter extends BaseAdapter {
+  private int itemCount;
+
+  public ShadowCountingAdapter(int itemCount) {
+    this.itemCount = itemCount;
+  }
+
+  public void setCount(int itemCount) {
+    this.itemCount = itemCount;
+    notifyDataSetChanged();
+  }
+
+  @Override
+  public int getCount() {
+    return itemCount;
+  }
+
+  @Override
+  public Object getItem(int position) {
+    return null;
+  }
+
+  @Override
+  public long getItemId(int position) {
+    return 0;
+  }
+
+  @Override
+  public View getView(int position, View convertView, ViewGroup parent) {
+    TextView textView = new TextView(ApplicationProvider.getApplicationContext());
+    textView.setText("Item " + position);
+    return textView;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCrossProfileAppsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCrossProfileAppsTest.java
new file mode 100644
index 0000000..6507e5b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCrossProfileAppsTest.java
@@ -0,0 +1,688 @@
+package org.robolectric.shadows;
+
+import static android.Manifest.permission.INTERACT_ACROSS_PROFILES;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.Manifest.permission;
+import android.app.Activity;
+import android.app.AppOpsManager;
+import android.app.Application;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.CrossProfileApps;
+import android.content.pm.PackageInfo;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Looper;
+import android.os.Process;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowCrossProfileApps.StartedActivity;
+import org.robolectric.shadows.ShadowCrossProfileApps.StartedMainActivity;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = P)
+public class ShadowCrossProfileAppsTest {
+  private final Application application = ApplicationProvider.getApplicationContext();
+  private final UserHandle userHandle1 = UserHandle.of(10);
+  private final UserHandle userHandle2 = UserHandle.of(11);
+
+  private CrossProfileApps crossProfileApps = application.getSystemService(CrossProfileApps.class);
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    crossProfileApps = context.getSystemService(CrossProfileApps.class);
+  }
+
+  @Test
+  public void getTargetUserProfiles_noProfilesAdded_shouldReturnEmpty() {
+    assertThat(crossProfileApps.getTargetUserProfiles()).isEmpty();
+  }
+
+  @Test
+  public void getTargetUserProfiles_oneProfileAdded_shouldReturnProfileAdded() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    assertThat(crossProfileApps.getTargetUserProfiles()).containsExactly(userHandle1);
+  }
+
+  @Test
+  public void getTargetUserProfiles_oneProfileAddedTwice_shouldReturnSingleProfileAdded() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    assertThat(crossProfileApps.getTargetUserProfiles()).containsExactly(userHandle1);
+  }
+
+  @Test
+  public void getTargetUserProfiles_multipleProfilesAdded_shouldReturnAllProfilesAddedInOrder() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle2);
+
+    assertThat(crossProfileApps.getTargetUserProfiles())
+        .containsExactly(userHandle1, userHandle2)
+        .inOrder();
+  }
+
+  @Test
+  public void
+      getTargetUserProfiles_multipleProfilesAddedInAlternateOrder_shouldReturnAllProfilesAddedInOrder() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle2);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    assertThat(crossProfileApps.getTargetUserProfiles())
+        .containsExactly(userHandle2, userHandle1)
+        .inOrder();
+  }
+
+  @Test
+  public void
+      getTargetUserProfiles_multipleProfilesAddedAndFirstRemoved_shouldReturnSecondProfile() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle2);
+    shadowOf(crossProfileApps).removeTargetUserProfile(userHandle1);
+
+    assertThat(crossProfileApps.getTargetUserProfiles()).containsExactly(userHandle2);
+  }
+
+  @Test
+  public void getTargetUserProfiles_multipleProfilesAddedAndCleared_shouldReturnEmpty() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle2);
+    shadowOf(crossProfileApps).clearTargetUserProfiles();
+
+    assertThat(crossProfileApps.getTargetUserProfiles()).isEmpty();
+  }
+
+  @Test
+  public void getProfileSwitchingLabel_shouldNotBeEmpty() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    CharSequence label = crossProfileApps.getProfileSwitchingLabel(userHandle1);
+    assertThat(label.toString()).isNotEmpty();
+  }
+
+  @Test
+  public void getProfileSwitchingLabel_shouldBeDifferentForDifferentProfiles() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle2);
+
+    assertThat(crossProfileApps.getProfileSwitchingLabel(userHandle1).toString())
+        .isNotEqualTo(crossProfileApps.getProfileSwitchingLabel(userHandle2).toString());
+  }
+
+  @Test
+  public void getProfileSwitchingLabel_userNotAvailable_shouldThrowSecurityException() {
+    assertThrowsSecurityException(() -> crossProfileApps.getProfileSwitchingLabel(userHandle1));
+  }
+
+  @Test
+  public void getProfileSwitchingIconDrawable_shouldNotBeNull() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    Drawable icon = crossProfileApps.getProfileSwitchingIconDrawable(userHandle1);
+    assertThat(icon).isNotNull();
+  }
+
+  @Test
+  public void getProfileSwitchingIconDrawable_shouldBeDifferentForDifferentProfiles() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle2);
+
+    assertThat(crossProfileApps.getProfileSwitchingIconDrawable(userHandle1))
+        .isNotEqualTo(crossProfileApps.getProfileSwitchingIconDrawable(userHandle2));
+  }
+
+  @Test
+  public void getProfileSwitchingIconDrawable_userNotAvailable_shouldThrowSecurityException() {
+    assertThrowsSecurityException(
+        () -> crossProfileApps.getProfileSwitchingIconDrawable(userHandle1));
+  }
+
+  @Test
+  public void startMainActivity_launcherActivityInManifest_shouldSucceed() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startMainActivity(component, userHandle1);
+
+    StartedActivity startedActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+    assertThat(startedActivity.getComponentName()).isEqualTo(component);
+    assertThat(startedActivity.getUserHandle()).isEqualTo(userHandle1);
+    assertThat(startedActivity).isEqualTo(new StartedActivity(component, userHandle1));
+  }
+
+  @Test
+  public void
+      startMainActivity_launcherActivityInManifest_withoutCrossProfilePermission_shouldSucceed() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(application).denyPermissions(INTERACT_ACROSS_PROFILES);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startMainActivity(component, userHandle1);
+
+    StartedActivity startedActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+    assertThat(startedActivity.getComponentName()).isEqualTo(component);
+    assertThat(startedActivity.getUserHandle()).isEqualTo(userHandle1);
+    assertThat(startedActivity).isEqualTo(new StartedActivity(component, userHandle1));
+  }
+
+  @Test
+  public void startMainActivity_launcherActivityInManifest_shouldStillAddStartedMainActivity() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startMainActivity(component, userHandle1);
+
+    StartedMainActivity startedMainActivity =
+        shadowOf(crossProfileApps).peekNextStartedMainActivity();
+    assertThat(startedMainActivity.getComponentName()).isEqualTo(component);
+    assertThat(startedMainActivity.getUserHandle()).isEqualTo(userHandle1);
+    assertThat(startedMainActivity).isEqualTo(new StartedMainActivity(component, userHandle1));
+  }
+
+  @Test
+  public void startMainActivity_nonLauncherActivityInManifest_shouldThrowSecurityException() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    ComponentName component = ComponentName.createRelative(application, ".shadows.TestActivity");
+    assertThrowsSecurityException(() -> crossProfileApps.startMainActivity(component, userHandle1));
+  }
+
+  @Test
+  public void startMainActivity_nonExistentActivity_shouldThrowSecurityException() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.FakeTestActivity");
+    assertThrowsSecurityException(() -> crossProfileApps.startMainActivity(component, userHandle1));
+  }
+
+  @Test
+  public void startMainActivity_userNotAvailable_shouldThrowSecurityException() {
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    assertThrowsSecurityException(() -> crossProfileApps.startMainActivity(component, userHandle1));
+  }
+
+  @Test
+  @Config(sdk = Q)
+  public void startActivity_launcherActivityInManifest_shouldSucceed() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(application).grantPermissions(INTERACT_ACROSS_PROFILES);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startActivity(component, userHandle1);
+
+    StartedActivity startedMainActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+    assertThat(startedMainActivity.getComponentName()).isEqualTo(component);
+    assertThat(startedMainActivity.getUserHandle()).isEqualTo(userHandle1);
+    assertThat(startedMainActivity).isEqualTo(new StartedActivity(component, userHandle1));
+  }
+
+  @Test
+  @Config(sdk = Q)
+  public void startActivity_nonLauncherActivityInManifest_shouldSucceed() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(application).grantPermissions(INTERACT_ACROSS_PROFILES);
+
+    ComponentName component = ComponentName.createRelative(application, ".shadows.TestActivity");
+
+    crossProfileApps.startActivity(component, userHandle1);
+
+    StartedActivity startedMainActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+    assertThat(startedMainActivity.getComponentName()).isEqualTo(component);
+    assertThat(startedMainActivity.getUserHandle()).isEqualTo(userHandle1);
+    assertThat(startedMainActivity).isEqualTo(new StartedActivity(component, userHandle1));
+  }
+
+  @Test
+  @Config(sdk = Q)
+  public void startActivity_nonExistentActivity_shouldThrowSecurityException() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(application).grantPermissions(INTERACT_ACROSS_PROFILES);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.FakeTestActivity");
+    assertThrowsSecurityException(() -> crossProfileApps.startActivity(component, userHandle1));
+  }
+
+  @Test
+  @Config(sdk = Q)
+  public void startActivity_userNotAvailable_shouldThrowSecurityException() {
+    shadowOf(application).grantPermissions(INTERACT_ACROSS_PROFILES);
+
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    assertThrowsSecurityException(() -> crossProfileApps.startActivity(component, userHandle1));
+  }
+
+  @Test
+  @Config(sdk = Q)
+  public void startActivity_withoutPermission_shouldThrowSecurityException() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    shadowOf(application).denyPermissions(INTERACT_ACROSS_PROFILES);
+
+    ComponentName component = ComponentName.createRelative(application, ".shadows.TestActivity");
+
+    assertThrowsSecurityException(() -> crossProfileApps.startActivity(component, userHandle1));
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void startActivityWithIntent_noComponent_throws() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    setPermissions(INTERACT_ACROSS_PROFILES);
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> crossProfileApps.startActivity(new Intent(), userHandle1, /* activity= */ null));
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void startActivityWithIntent_startActivityContainsIntent() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    setPermissions(INTERACT_ACROSS_PROFILES);
+    ComponentName component = ComponentName.createRelative(application, ".shadows.TestActivity");
+    Intent intent = new Intent().setComponent(component);
+
+    crossProfileApps.startActivity(intent, userHandle1, /* activity */ null);
+    StartedActivity startedActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+
+    assertThat(startedActivity).isEqualTo(new StartedActivity(component, userHandle1));
+    assertThat(startedActivity.getIntent()).isSameInstanceAs(intent);
+    assertThat(startedActivity.getActivity()).isNull();
+    assertThat(startedActivity.getOptions()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void startActivityWithIntentAndOptions_startActivityContainsIntentAndOptions() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    setPermissions(INTERACT_ACROSS_PROFILES);
+    ComponentName component = ComponentName.createRelative(application, ".shadows.TestActivity");
+    Intent intent = new Intent().setComponent(component);
+    Activity activity = new Activity();
+    Bundle options = new Bundle();
+
+    crossProfileApps.startActivity(intent, userHandle1, activity, options);
+    StartedActivity startedActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+
+    assertThat(startedActivity).isEqualTo(new StartedActivity(component, userHandle1));
+    assertThat(startedActivity.getIntent()).isSameInstanceAs(intent);
+    assertThat(startedActivity.getActivity()).isSameInstanceAs(activity);
+    assertThat(startedActivity.getOptions()).isSameInstanceAs(options);
+  }
+
+  @Test
+  public void addTargetProfile_currentUserHandle_shouldThrowIllegalArgumentException() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> shadowOf(crossProfileApps).addTargetUserProfile(Process.myUserHandle()));
+  }
+
+  @Test
+  public void peekNextStartedActivity_activityStarted_shouldReturnAndNotConsumeActivity() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startMainActivity(component, userHandle1);
+
+    StartedActivity startedActivity = shadowOf(crossProfileApps).peekNextStartedActivity();
+
+    assertThat(startedActivity).isEqualTo(new StartedActivity(component, userHandle1));
+    assertThat(shadowOf(crossProfileApps).peekNextStartedActivity())
+        .isSameInstanceAs(startedActivity);
+  }
+
+  @Test
+  public void peekNextStartedActivity_activityNotStarted_shouldReturnNull() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    assertThat(shadowOf(crossProfileApps).peekNextStartedActivity()).isNull();
+  }
+
+  @Test
+  public void getNextStartedActivity_activityStarted_shouldReturnAndConsumeActivity() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startMainActivity(component, userHandle1);
+
+    StartedActivity startedActivity = shadowOf(crossProfileApps).getNextStartedActivity();
+
+    assertThat(startedActivity).isEqualTo(new StartedActivity(component, userHandle1));
+    assertThat(shadowOf(crossProfileApps).peekNextStartedActivity()).isNull();
+  }
+
+  @Test
+  public void getNextStartedActivity_activityNotStarted_shouldReturnNull() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    assertThat(shadowOf(crossProfileApps).getNextStartedActivity()).isNull();
+  }
+
+  @Test
+  public void
+      clearNextStartedActivities_activityStarted_shouldClearReferencesToStartedActivities() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+    ComponentName component =
+        ComponentName.createRelative(application, ".shadows.TestActivityAlias");
+    crossProfileApps.startMainActivity(component, userHandle1);
+
+    shadowOf(crossProfileApps).clearNextStartedActivities();
+
+    assertThat(shadowOf(crossProfileApps).peekNextStartedActivity()).isNull();
+  }
+
+  @Test
+  public void clearNextStartedActivities_activityNotStarted_shouldBeNoOp() {
+    shadowOf(crossProfileApps).addTargetUserProfile(userHandle1);
+
+    shadowOf(crossProfileApps).clearNextStartedActivities();
+
+    assertThat(shadowOf(crossProfileApps).peekNextStartedActivity()).isNull();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void
+      canInteractAcrossProfile_withInteractAcrossProfilesPermissionAndProfile_shouldReturnTrue() {
+    Shadows.shadowOf(crossProfileApps).addTargetUserProfile(UserHandle.of(10));
+    setPermissions(permission.INTERACT_ACROSS_PROFILES);
+
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void
+      canInteractAcrossProfile_withInteractAcrossUsersPermissionAndProfile_shouldReturnTrue() {
+    Shadows.shadowOf(crossProfileApps).addTargetUserProfile(UserHandle.of(10));
+    setPermissions(permission.INTERACT_ACROSS_USERS);
+
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void
+      canInteractAcrossProfile_withInteractAcrossUsersFullPermissionAndProfile_shouldReturnTrue() {
+    Shadows.shadowOf(crossProfileApps).addTargetUserProfile(UserHandle.of(10));
+    setPermissions(permission.INTERACT_ACROSS_USERS_FULL);
+
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canInteractAcrossProfile_withAppOpsOnly_shouldReturnTrue() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ALLOWED);
+
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canInteractAcrossProfile_withoutPermission_shouldReturnFalse() {
+    Shadows.shadowOf(crossProfileApps).addTargetUserProfile(UserHandle.of(10));
+
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canInteractAcrossProfile_withoutProfile_shouldReturnFalse() {
+    setPermissions(permission.INTERACT_ACROSS_PROFILES);
+
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void setInteractAcrossProfilesAppOp_withoutPermissions_shouldThrowException() {
+    try {
+      crossProfileApps.setInteractAcrossProfilesAppOp(
+          context.getPackageName(), AppOpsManager.MODE_ALLOWED);
+      fail("Should throw SecurityException");
+    } catch (SecurityException ex) {
+      // Exactly what we would expect!
+    }
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void setInteractAcrossProfilesAppOp_withPermissions_shouldChangeAppOpsAndSendBroadcast() {
+    AtomicBoolean receivedBroadcast = new AtomicBoolean(false);
+    context.registerReceiver(
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            if (CrossProfileApps.ACTION_CAN_INTERACT_ACROSS_PROFILES_CHANGED.equals(
+                intent.getAction())) {
+              receivedBroadcast.set(true);
+            }
+          }
+        },
+        new IntentFilter(CrossProfileApps.ACTION_CAN_INTERACT_ACROSS_PROFILES_CHANGED));
+    Shadows.shadowOf(crossProfileApps).addTargetUserProfile(UserHandle.of(10));
+    setPermissions(permission.INTERACT_ACROSS_USERS, permission.CONFIGURE_INTERACT_ACROSS_PROFILES);
+    crossProfileApps.setInteractAcrossProfilesAppOp(
+        context.getPackageName(), AppOpsManager.MODE_ALLOWED);
+    // Remove permissions, or canInteractAcrossProfiles will return true without the AppOps.
+    setPermissions();
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isTrue();
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(receivedBroadcast.get()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void setInteractAcrossProfilesAppOp_onShadow_shouldChangeAppOpsAndSendBroadcast() {
+    AtomicBoolean receivedBroadcast = new AtomicBoolean(false);
+    context.registerReceiver(
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            if (CrossProfileApps.ACTION_CAN_INTERACT_ACROSS_PROFILES_CHANGED.equals(
+                intent.getAction())) {
+              receivedBroadcast.set(true);
+            }
+          }
+        },
+        new IntentFilter(CrossProfileApps.ACTION_CAN_INTERACT_ACROSS_PROFILES_CHANGED));
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ALLOWED);
+    assertThat(crossProfileApps.canInteractAcrossProfiles()).isTrue();
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(receivedBroadcast.get()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canRequestInteractAcrossProfile_withPermission_withTargetProfile_shouldReturnTrue() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.setHasRequestedInteractAcrossProfiles(true);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+
+    assertThat(crossProfileApps.canRequestInteractAcrossProfiles()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canRequestInteractAcrossProfile_withPermission_withoutTarget_shouldReturnFalse() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.setHasRequestedInteractAcrossProfiles(true);
+
+    assertThat(crossProfileApps.canRequestInteractAcrossProfiles()).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canRequestInteractAcrossProfile_withoutPermission_withTarget_shouldReturnFalse() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+
+    assertThat(crossProfileApps.canRequestInteractAcrossProfiles()).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canRequestInteractAcrossProfile_withoutPermissionOrTarget_shouldReturnFalse() {
+    assertThat(crossProfileApps.canRequestInteractAcrossProfiles()).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void canRequestInteractAcrossProfile_withTarget_requestedAppOp_shouldReturnTrue() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ALLOWED);
+
+    assertThat(crossProfileApps.canRequestInteractAcrossProfiles()).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void
+      createRequestInteractAcrossProfilesIntent_withPermissionAndTarget_shouldReturnRecognisedIntent() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ALLOWED);
+
+    Intent intent = crossProfileApps.createRequestInteractAcrossProfilesIntent();
+
+    assertThat(shadowCrossProfileApps.isRequestInteractAcrossProfilesIntent(intent)).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void createRequestInteractAcrossProfilesIntent_withoutPermission_shouldThrowException() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(UserHandle.of(10));
+
+    try {
+      crossProfileApps.createRequestInteractAcrossProfilesIntent();
+      fail("SecurityException was expected.");
+    } catch (SecurityException ex) {
+      // Exactly what we would expect!
+    }
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void createRequestInteractAcrossProfilesIntent_withoutTarget_shouldThrowException() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ALLOWED);
+
+    try {
+      crossProfileApps.createRequestInteractAcrossProfilesIntent();
+      fail("SecurityException was expected.");
+    } catch (SecurityException ex) {
+      // Exactly what we would expect!
+    }
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void isRequestInteractAcrossProfilesIntent_fromBadIntents_shouldReturnFalse() {
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+
+    assertThat(shadowCrossProfileApps.isRequestInteractAcrossProfilesIntent(new Intent()))
+        .isFalse();
+    assertThat(
+            shadowCrossProfileApps.isRequestInteractAcrossProfilesIntent(
+                new Intent(Intent.ACTION_SHOW_APP_INFO)))
+        .isFalse();
+  }
+
+  @Ignore("Requires an exported activity in a manifest")
+  @Config(minSdk = R)
+  @Test
+  public void startActivity_withAppOps_shouldStartActivityForUser() {
+    UserHandle handle = UserHandle.of(10);
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(handle);
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ALLOWED);
+
+    ComponentName componentName =
+        ComponentName.createRelative(context, ".ShadowCrossProfileAppRTest$TestActivity");
+    crossProfileApps.startActivity(componentName, handle);
+
+    assertThat(shadowCrossProfileApps.peekNextStartedActivity())
+        .isEqualTo(new StartedActivity(componentName, handle));
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void startActivity_withoutAppOps_shouldThrowException() {
+    UserHandle handle = UserHandle.of(10);
+    ShadowCrossProfileApps shadowCrossProfileApps = Shadow.extract(crossProfileApps);
+    shadowCrossProfileApps.addTargetUserProfile(handle);
+    shadowCrossProfileApps.setInteractAcrossProfilesAppOp(AppOpsManager.MODE_ERRORED);
+
+    ComponentName componentName =
+        ComponentName.createRelative(context, ".ShadowCrossProfileAppRTest$TestActivity");
+
+    try {
+      crossProfileApps.startActivity(componentName, handle);
+      fail("SecurityException was expected.");
+    } catch (SecurityException ex) {
+      // Exactly what we would expect!
+    }
+  }
+
+  private void setPermissions(String... permissions) {
+    PackageInfo packageInfo =
+        shadowOf(context.getPackageManager())
+            .getInternalMutablePackageInfo(context.getPackageName());
+    packageInfo.requestedPermissions = permissions;
+  }
+
+  private static void assertThrowsSecurityException(Runnable runnable) {
+    assertThrows(SecurityException.class, runnable);
+  }
+
+  private static <T extends Throwable> void assertThrows(Class<T> clazz, Runnable runnable) {
+    try {
+      runnable.run();
+    } catch (Throwable t) {
+      if (clazz.isInstance(t)) {
+        // expected
+        return;
+      } else {
+        fail("did not throw " + clazz.getName() + ", threw " + t + " instead");
+      }
+    }
+    fail("did not throw " + clazz.getName());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorAdapterTest.java
new file mode 100644
index 0000000..88c5987
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorAdapterTest.java
@@ -0,0 +1,126 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCursorAdapterTest {
+
+  private Cursor curs;
+  private CursorAdapter adapter;
+  private SQLiteDatabase database;
+
+  @Before
+  public void setUp() throws Exception {
+    database = SQLiteDatabase.create(null);
+    database.execSQL("CREATE TABLE table_name(_id INT PRIMARY KEY, name VARCHAR(255));");
+    String[] inserts = {
+        "INSERT INTO table_name (_id, name) VALUES(1234, 'Chuck');",
+        "INSERT INTO table_name (_id, name) VALUES(1235, 'Julie');",
+        "INSERT INTO table_name (_id, name) VALUES(1236, 'Chris');",
+        "INSERT INTO table_name (_id, name) VALUES(1237, 'Brenda');",
+        "INSERT INTO table_name (_id, name) VALUES(1238, 'Jane');"
+    };
+
+    for (String insert : inserts) {
+      database.execSQL(insert);
+    }
+
+    String sql = "SELECT * FROM table_name;";
+    curs = database.rawQuery(sql, null);
+
+    adapter = new TestAdapter(curs);
+  }
+
+  @After
+  public void tearDown() {
+    database.close();
+    curs.close();
+  }
+
+  @Test
+  public void testChangeCursor() {
+    assertThat(adapter.getCursor()).isNotNull();
+    assertThat(adapter.getCursor()).isSameInstanceAs(curs);
+
+    adapter.changeCursor(null);
+
+    assertThat(curs.isClosed()).isTrue();
+    assertThat(adapter.getCursor()).isNull();
+  }
+
+  @Test
+  public void testSwapCursor() {
+    assertThat(adapter.getCursor()).isNotNull();
+    assertThat(adapter.getCursor()).isSameInstanceAs(curs);
+
+    Cursor oldCursor = adapter.swapCursor(null);
+
+    assertThat(oldCursor).isSameInstanceAs(curs);
+    assertThat(curs.isClosed()).isFalse();
+    assertThat(adapter.getCursor()).isNull();
+  }
+
+  @Test
+  public void testCount() {
+    assertThat(adapter.getCount()).isEqualTo(curs.getCount());
+    adapter.changeCursor(null);
+    assertThat(adapter.getCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void testGetItemId() {
+    for (int i = 0; i < 5; i++) {
+      assertThat(adapter.getItemId(i)).isEqualTo((long) 1234 + i);
+    }
+  }
+
+  @Test public void shouldNotErrorOnCursorChangeWhenNoFlagsAreSet() throws Exception {
+    try (Cursor newCursor = database.rawQuery("SELECT * FROM table_name;", null)) {
+      adapter = new TestAdapterWithFlags(curs, 0);
+      adapter.changeCursor(newCursor);
+      assertThat(adapter.getCursor()).isNotSameInstanceAs(curs);
+    }
+  }
+
+  private static class TestAdapter extends CursorAdapter {
+
+    public TestAdapter(Cursor curs) {
+      super(ApplicationProvider.getApplicationContext(), curs, false);
+    }
+
+    @Override
+    public void bindView(View view, Context context, Cursor cursor) {
+    }
+
+    @Override
+    public View newView(Context context, Cursor cursor, ViewGroup parent) {
+      return null;
+    }
+  }
+
+  private static class TestAdapterWithFlags extends CursorAdapter {
+    public TestAdapterWithFlags(Cursor c, int flags) {
+      super(ApplicationProvider.getApplicationContext(), c, flags);
+    }
+
+    @Override public View newView(Context context, Cursor cursor, ViewGroup parent) {
+      return null;
+    }
+
+    @Override public void bindView(View view, Context context, Cursor cursor) {
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorWindowTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorWindowTest.java
new file mode 100644
index 0000000..f21dcdf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorWindowTest.java
@@ -0,0 +1,70 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.database.CursorWindow;
+import android.database.DatabaseUtils;
+import android.database.MatrixCursor;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCursorWindowTest {
+
+  @Test
+  public void shouldCreateWindowWithName() {
+    CursorWindow window = new CursorWindow("name");
+    assertThat(window.getName()).isEqualTo("name");
+    window.close();
+  }
+
+  @Test
+  public void shouldFillWindowWithCursor() {
+    CursorWindow window = new CursorWindow("name");
+    MatrixCursor testCursor = new MatrixCursor(new String[] { "a", "b", "c", "d"});
+    testCursor.addRow(
+        new Object[] {
+          12, "hello", null, new byte[] {(byte) 0xba, (byte) 0xdc, (byte) 0xaf, (byte) 0xfe}
+        });
+    testCursor.addRow(new Object[] { 34, "baz",   1.2,  null  });
+    testCursor.addRow(new Object[] { 46, "foo",   2.4,  new byte[]{}  });
+
+    DatabaseUtils.cursorFillWindow(testCursor, 0, window);
+
+    assertThat(window.getNumRows()).isEqualTo(3);
+
+    assertThat(window.getInt(0, 0)).isEqualTo(12);
+    assertThat(window.getString(0, 1)).isEqualTo("hello");
+    assertThat(window.getString(0, 2)).isNull();
+    assertThat(window.getBlob(0, 3)).isEqualTo(new byte[] {(byte) 0xba, (byte) 0xdc, (byte) 0xaf, (byte) 0xfe});
+
+    assertThat(window.getInt(1, 0)).isEqualTo(34);
+    assertThat(window.getString(1, 1)).isEqualTo("baz");
+    assertThat(window.getFloat(1, 2)).isEqualTo(1.2f);
+    assertThat(window.getBlob(1, 3)).isEqualTo(null);
+
+    assertThat(window.getBlob(2, 3)).isEqualTo(new byte[]{});
+    testCursor.close();
+    window.close();
+  }
+
+  /** Real Android will crash in native code if putBlob is called with a null value. */
+  @Test
+  public void putBlobNullValueThrowsNPE() {
+    CursorWindow cursorWindow = new CursorWindow("test");
+    cursorWindow.allocRow();
+    assertThrows(NullPointerException.class, () -> cursorWindow.putBlob(null, 0, 0));
+    cursorWindow.close();
+  }
+
+  /** Real Android will crash in native code if putString is called with a null value. */
+  @Test
+  public void putStringNullValueThrowsNPE() {
+    CursorWindow cursorWindow = new CursorWindow("test");
+    cursorWindow.allocRow();
+    assertThrows(NullPointerException.class, () -> cursorWindow.putString(null, 0, 0));
+    cursorWindow.close();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorWrapperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorWrapperTest.java
new file mode 100644
index 0000000..78bbe8f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCursorWrapperTest.java
@@ -0,0 +1,110 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.content.ContentResolver;
+import android.database.CharArrayBuffer;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.database.DataSetObserver;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowCursorWrapperTest {
+
+  private static class ForwardVerifier {
+
+    final Cursor mockCursor;
+    final CursorWrapper cursorWrapper;
+    final HashMap<String, Method> cursorMethod;
+
+    public ForwardVerifier() {
+      mockCursor = mock(Cursor.class);
+      cursorWrapper = new CursorWrapper(mockCursor);
+      cursorMethod = new HashMap<>();
+
+      // This works because no two methods in the Cursor interface have the same name
+      for (Method m : Cursor.class.getMethods()) {
+        cursorMethod.put(m.getName(), m);
+      }
+    }
+
+    public void verifyForward(String methodName, Object... params) throws Exception {
+      assertThat(cursorMethod.keySet()).contains(methodName);
+
+      Method method = cursorMethod.get(methodName);
+      method.invoke(cursorWrapper, params);
+      method.invoke(verify(mockCursor, times(1)), params);
+      Mockito.verifyNoMoreInteractions(mockCursor);
+    }
+
+  }
+
+  @Test
+  public void testCursorMethodsAreForwarded() throws Exception {
+    ForwardVerifier v = new ForwardVerifier();
+
+    v.verifyForward("close");
+    v.verifyForward("copyStringToBuffer", 1, mock(CharArrayBuffer.class));
+    v.verifyForward("deactivate");
+    v.verifyForward("getBlob", 2);
+    v.verifyForward("getColumnCount");
+    v.verifyForward("getColumnIndex", "foo");
+    v.verifyForward("getColumnIndexOrThrow", "foo");
+    v.verifyForward("getColumnName", 3);
+    v.verifyForward("getColumnNames");
+    v.verifyForward("getCount");
+    v.verifyForward("getDouble", 12);
+    v.verifyForward("getExtras");
+    v.verifyForward("getFloat", 4);
+    v.verifyForward("getInt", 5);
+    v.verifyForward("getLong", 6);
+    v.verifyForward("getPosition");
+    v.verifyForward("getShort", 7);
+    v.verifyForward("getString", 8);
+    v.verifyForward("getType", 9);
+    v.verifyForward("getWantsAllOnMoveCalls");
+    v.verifyForward("isAfterLast");
+    v.verifyForward("isBeforeFirst");
+    v.verifyForward("isClosed");
+    v.verifyForward("isFirst");
+    v.verifyForward("isLast");
+    v.verifyForward("isNull", 10);
+    v.verifyForward("move", 11);
+    v.verifyForward("moveToFirst");
+    v.verifyForward("moveToLast");
+    v.verifyForward("moveToNext");
+    v.verifyForward("moveToPosition", 13);
+    v.verifyForward("moveToPrevious");
+    v.verifyForward("registerContentObserver", mock(ContentObserver.class));
+    v.verifyForward("registerDataSetObserver", mock(DataSetObserver.class));
+    v.verifyForward("requery");
+    v.verifyForward("respond", mock(Bundle.class));
+    v.verifyForward("setNotificationUri", mock(ContentResolver.class), mock(Uri.class));
+    v.verifyForward("unregisterContentObserver", mock(ContentObserver.class));
+    v.verifyForward("unregisterDataSetObserver", mock(DataSetObserver.class));
+
+  }
+
+  @Test
+  public void getWrappedCursor() {
+    Cursor mockCursor = mock(Cursor.class);
+    CursorWrapper cursorWrapper = new CursorWrapper(mockCursor);
+    ShadowCursorWrapper shadow = Shadows.shadowOf(cursorWrapper);
+
+    assertThat(shadow.getWrappedCursor()).isSameInstanceAs(mockCursor);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDatabaseUtilsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDatabaseUtilsTest.java
new file mode 100644
index 0000000..f19a7f8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDatabaseUtilsTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.DatabaseUtils;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDatabaseUtilsTest {
+
+  @Test
+  public void testQuote() {
+    assertThat(DatabaseUtils.sqlEscapeString("foobar")).isEqualTo("'foobar'");
+    assertThat(DatabaseUtils.sqlEscapeString("Rich's")).isEqualTo("'Rich''s'");
+  }
+
+  @Test
+  public void testQuoteWithBuilder() {
+    StringBuilder builder = new StringBuilder();
+    DatabaseUtils.appendEscapedSQLString(builder, "foobar");
+    assertThat(builder.toString()).isEqualTo("'foobar'");
+
+    builder = new StringBuilder();
+    DatabaseUtils.appendEscapedSQLString(builder, "Blundell's");
+    assertThat(builder.toString()).isEqualTo("'Blundell''s'");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDateIntervalFormatTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDateIntervalFormatTest.java
new file mode 100644
index 0000000..bde0e72
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDateIntervalFormatTest.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.icu.text.DateFormat;
+import android.icu.text.SimpleDateFormat;
+import android.icu.util.TimeZone;
+import android.icu.util.ULocale;
+import android.text.format.DateUtils;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.text.ParseException;
+import java.util.Calendar;
+import java.util.Date;
+import libcore.icu.DateIntervalFormat;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowDateIntervalFormatTest {
+  @Test
+  public void testDateInterval_FormatDateRange() throws ParseException {
+    Calendar calendar = Calendar.getInstance();
+    calendar.set(Calendar.YEAR, 2013);
+    calendar.set(Calendar.MONTH, Calendar.JANUARY);
+    calendar.set(Calendar.DAY_OF_MONTH, 20);
+
+    long timeInMillis = calendar.getTimeInMillis();
+    String actual = DateIntervalFormat.formatDateRange(ULocale.getDefault(), TimeZone.getDefault(), timeInMillis, timeInMillis, DateUtils.FORMAT_NUMERIC_DATE);
+
+    DateFormat format = new SimpleDateFormat("MM/dd/yyyy", ULocale.getDefault());
+    Date date = format.parse(actual);
+
+    assertThat(date).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDatePickerDialogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDatePickerDialogTest.java
new file mode 100644
index 0000000..50feac2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDatePickerDialogTest.java
@@ -0,0 +1,71 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.DatePickerDialog;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDatePickerDialogTest {
+
+  @Test
+  public void testGettersReturnInitialConstructorValues() {
+    Locale.setDefault(Locale.US);
+    DatePickerDialog datePickerDialog =
+        new DatePickerDialog(ApplicationProvider.getApplicationContext(), null, 2012, 6, 7);
+    assertThat(shadowOf(datePickerDialog).getYear()).isEqualTo(2012);
+    assertThat(shadowOf(datePickerDialog).getMonthOfYear()).isEqualTo(6);
+    assertThat(shadowOf(datePickerDialog).getDayOfMonth()).isEqualTo(7);
+  }
+
+  @Test
+  public void updateDate_shouldUpdateYearMonthAndDay() {
+    Locale.setDefault(Locale.US);
+    DatePickerDialog datePickerDialog =
+        new DatePickerDialog(ApplicationProvider.getApplicationContext(), null, 2012, 6, 7);
+    datePickerDialog.updateDate(2021, 11, 10);
+
+    assertThat(shadowOf(datePickerDialog).getYear()).isEqualTo(2021);
+    assertThat(shadowOf(datePickerDialog).getMonthOfYear()).isEqualTo(11);
+    assertThat(shadowOf(datePickerDialog).getDayOfMonth()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void updateListener_shouldUpdateTheListenerPassedInto() {
+    DatePickerDialog.OnDateSetListener mockCallBack =
+        mock(DatePickerDialog.OnDateSetListener.class);
+    DatePickerDialog datePickerDialog =
+        new DatePickerDialog(ApplicationProvider.getApplicationContext(), null, 2012, 6, 7);
+    assertThat(shadowOf(datePickerDialog).getOnDateSetListenerCallback()).isNull();
+
+    // setOnDateSetListener added in Android Nougat
+    datePickerDialog.setOnDateSetListener(mockCallBack);
+
+    assertThat(shadowOf(datePickerDialog).getOnDateSetListenerCallback()).isEqualTo(mockCallBack);
+  }
+
+  @Test
+  public void savesTheCallback() {
+    DatePickerDialog.OnDateSetListener expectedDateSetListener =
+        (datePicker, i, i1, i2) -> {
+          // ignored
+        };
+
+    DatePickerDialog datePickerDialog =
+        new DatePickerDialog(
+            ApplicationProvider.getApplicationContext(), expectedDateSetListener, 2012, 6, 7);
+
+    ShadowDatePickerDialog shadowDatePickerDialog = shadowOf(datePickerDialog);
+    assertThat(shadowDatePickerDialog.getOnDateSetListenerCallback()).isEqualTo(expectedDateSetListener);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDateUtilsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDateUtilsTest.java
new file mode 100644
index 0000000..5cc9e8b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDateUtilsTest.java
@@ -0,0 +1,97 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.app.Application;
+import android.os.SystemClock;
+import android.text.format.DateUtils;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Calendar;
+import java.util.TimeZone;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDateUtilsTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT, maxSdk = LOLLIPOP_MR1)
+  public void formatDateTime_withCurrentYear_worksSinceKitKat() {
+    final long millisAtStartOfYear = getMillisAtStartOfYear();
+
+    String actual =
+        DateUtils.formatDateTime(context, millisAtStartOfYear, DateUtils.FORMAT_NUMERIC_DATE);
+    assertThat(actual).isEqualTo("1/1");
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void formatDateTime_withCurrentYear_worksSinceM() {
+    final long millisAtStartOfYear = getMillisAtStartOfYear();
+
+    // starting with M, sometimes the year is there, sometimes it's missing, unless you specify
+    // FORMAT_SHOW_YEAR
+    String actual =
+        DateUtils.formatDateTime(
+            context,
+            millisAtStartOfYear,
+            DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_NUMERIC_DATE);
+    final int currentYear = Calendar.getInstance().get(Calendar.YEAR);
+    assertThat(actual).isEqualTo("1/1/" + currentYear);
+  }
+
+  @Test
+  @Config(maxSdk = JELLY_BEAN_MR2)
+  public void formatDateTime_withCurrentYear_worksPreKitKat() {
+    Calendar calendar = Calendar.getInstance();
+    final int currentYear = calendar.get(Calendar.YEAR);
+    final long millisAtStartOfYear = getMillisAtStartOfYear();
+
+    String actual =
+        DateUtils.formatDateTime(context, millisAtStartOfYear, DateUtils.FORMAT_NUMERIC_DATE);
+    assertThat(actual).isEqualTo("1/1/" + currentYear);
+  }
+
+  @Test
+  public void formatDateTime_withPastYear() {
+    String actual =
+        DateUtils.formatDateTime(context, 1420099200000L, DateUtils.FORMAT_NUMERIC_DATE);
+    assertThat(actual).isEqualTo("1/1/2015");
+  }
+
+  @Test
+  @LooperMode(LEGACY)
+  public void isToday_shouldReturnFalseForNotToday() {
+    long today = java.util.Calendar.getInstance().getTimeInMillis();
+    SystemClock.setCurrentTimeMillis(today);
+
+    assertThat(DateUtils.isToday(today)).isTrue();
+    assertThat(DateUtils.isToday(today + (86400 * 1000) /* 24 hours */)).isFalse();
+    assertThat(DateUtils.isToday(today + (86400 * 10000) /* 240 hours */)).isFalse();
+  }
+
+  private long getMillisAtStartOfYear() {
+    Calendar calendar = Calendar.getInstance();
+    final int currentYear = calendar.get(Calendar.YEAR);
+    calendar.setTimeZone(TimeZone.getTimeZone("GMT-8:00"));
+    calendar.set(currentYear, Calendar.JANUARY, 1, 0, 0, 0);
+
+    return calendar.getTimeInMillis();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDebugTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDebugTest.java
new file mode 100644
index 0000000..1b5a52a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDebugTest.java
@@ -0,0 +1,78 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+import android.os.Debug;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDebugTest {
+
+  private static final String TRACE_FILENAME = "dmtrace.trace";
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void initNoCrash() {
+    assertThat(Debug.getNativeHeapAllocatedSize()).isAtLeast(0L);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getRuntimeStats() {
+    assertThat(Debug.getRuntimeStats()).isNotNull();
+  }
+
+  @Test
+  public void startStopTracingShouldWriteFile() {
+    Debug.startMethodTracing(TRACE_FILENAME);
+    Debug.stopMethodTracing();
+
+    assertThat(new File(context.getExternalFilesDir(null), TRACE_FILENAME).exists()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void startStopTracingSamplingShouldWriteFile() {
+    Debug.startMethodTracingSampling(TRACE_FILENAME, 100, 100);
+    Debug.stopMethodTracing();
+
+    assertThat(new File(context.getExternalFilesDir(null), TRACE_FILENAME).exists()).isTrue();
+  }
+
+  @Test
+  public void startTracingShouldThrowIfAlreadyStarted() {
+    Debug.startMethodTracing(TRACE_FILENAME);
+
+    try {
+      Debug.startMethodTracing(TRACE_FILENAME);
+      fail("RuntimeException not thrown.");
+    } catch (RuntimeException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void stopTracingShouldThrowIfNotStarted() {
+    try {
+      Debug.stopMethodTracing();
+      fail("RuntimeException not thrown.");
+    } catch (RuntimeException e) {
+      // expected
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDeviceConfigTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDeviceConfigTest.java
new file mode 100644
index 0000000..af5d764
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDeviceConfigTest.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowDeviceConfig} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public class ShadowDeviceConfigTest {
+  @Test
+  public void testReset() {
+    ShadowDeviceConfig.reset();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDevicePolicyManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDevicePolicyManagerTest.java
new file mode 100644
index 0000000..09a3b54
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDevicePolicyManagerTest.java
@@ -0,0 +1,2244 @@
+package org.robolectric.shadows;
+
+import static android.app.admin.DeviceAdminInfo.USES_ENCRYPTED_STORAGE;
+import static android.app.admin.DeviceAdminInfo.USES_POLICY_DISABLE_CAMERA;
+import static android.app.admin.DeviceAdminInfo.USES_POLICY_EXPIRE_PASSWORD;
+import static android.app.admin.DevicePolicyManager.ENCRYPTION_STATUS_ACTIVATING;
+import static android.app.admin.DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE;
+import static android.app.admin.DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY;
+import static android.app.admin.DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER;
+import static android.app.admin.DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE;
+import static android.app.admin.DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED;
+import static android.app.admin.DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH;
+import static android.app.admin.DevicePolicyManager.PERMISSION_POLICY_AUTO_GRANT;
+import static android.app.admin.DevicePolicyManager.STATE_USER_SETUP_COMPLETE;
+import static android.app.admin.DevicePolicyManager.STATE_USER_SETUP_FINALIZED;
+import static android.app.admin.DevicePolicyManager.STATE_USER_SETUP_INCOMPLETE;
+import static android.app.admin.DevicePolicyManager.STATE_USER_UNMANAGED;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.accounts.Account;
+import android.app.Application;
+import android.app.KeyguardManager;
+import android.app.admin.DevicePolicyManager;
+import android.app.admin.SystemUpdatePolicy;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.PersistableBundle;
+import android.os.UserHandle;
+import android.os.UserManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit tests for {@link ShadowDevicePolicyManager}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowDevicePolicyManagerTest {
+
+  private static final byte[] PASSWORD_TOKEN = new byte[32];
+
+  private Application context;
+  private DevicePolicyManager devicePolicyManager;
+  private ShadowDevicePolicyManager shadowDevicePolicyManager;
+  private UserManager userManager;
+  private ComponentName testComponent;
+  private PackageManager packageManager;
+  private KeyguardManager keyguardManager;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    shadowDevicePolicyManager = Shadow.extract(devicePolicyManager);
+    userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
+    keyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE);
+
+    testComponent = new ComponentName("com.example.app", "DeviceAdminReceiver");
+
+    packageManager = context.getPackageManager();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void isDeviceOwnerAppShouldReturnFalseForNonDeviceOwnerApp() {
+    // GIVEN an test package which is not the device owner app of the device
+    String testPackage = testComponent.getPackageName();
+
+    // WHEN DevicePolicyManager#isDeviceOwnerApp is called with it
+    // THEN the method should return false
+    assertThat(devicePolicyManager.isDeviceOwnerApp(testPackage)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isDeviceOwnerShouldReturnFalseForProfileOwner() {
+    // GIVEN an test package which is the profile owner app of the device
+    String testPackage = testComponent.getPackageName();
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN DevicePolicyManager#isDeviceOwnerApp is called with it
+    // THEN the method should return false
+    assertThat(devicePolicyManager.isDeviceOwnerApp(testPackage)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void isDeviceOwnerShouldReturnTrueForDeviceOwner() {
+    // GIVEN an test package which is the device owner app of the device
+    String testPackage = testComponent.getPackageName();
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN DevicePolicyManager#isDeviceOwnerApp is called with it
+    // THEN the method should return true
+    assertThat(devicePolicyManager.isDeviceOwnerApp(testPackage)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getDeviceOwnerShouldReturnDeviceOwnerPackageName() {
+    // GIVEN an test package which is the device owner app of the device
+    String testPackage = testComponent.getPackageName();
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN DevicePolicyManager#getDeviceOwner is called
+    // THEN the method should return the package name
+    assertThat(devicePolicyManager.getDeviceOwner()).isEqualTo(testPackage);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getDeviceOwnerShouldReturnNullWhenThereIsNoDeviceOwner() {
+    // WHEN DevicePolicyManager#getProfileOwner is called without a device owner
+    // THEN the method should return null
+    assertThat(devicePolicyManager.getDeviceOwner()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isDeviceManagedShouldReturnTrueWhenThereIsADeviceOwner() {
+    // GIVEN a test component is the device owner app of the device
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN DevicePolicyManager#isDeviceManaged is called
+    // THEN the method should return true
+    assertThat(devicePolicyManager.isDeviceManaged()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isDeviceManagedShouldReturnFalseWhenThereIsNoDeviceOwner() {
+    // WHEN DevicePolicyManager#isDeviceManaged is called without a device owner
+    // THEN the method should return false
+    assertThat(devicePolicyManager.isDeviceManaged()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isProfileOwnerAppShouldReturnFalseForNonProfileOwnerApp() {
+    // GIVEN an test package which is not the profile owner app of the device
+    String testPackage = testComponent.getPackageName();
+
+    // WHEN DevicePolicyManager#isProfileOwnerApp is called with it
+    // THEN the method should return false
+    assertThat(devicePolicyManager.isProfileOwnerApp(testPackage)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isProfileOwnerShouldReturnFalseForDeviceOwner() {
+    // GIVEN an test package which is the device owner app of the device
+    String testPackage = testComponent.getPackageName();
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN DevicePolicyManager#isProfileOwnerApp is called with it
+    // THEN the method should return false
+    assertThat(devicePolicyManager.isProfileOwnerApp(testPackage)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isProfileOwnerShouldReturnTrueForProfileOwner() {
+    // GIVEN an test package which is the profile owner app of the device
+    String testPackage = testComponent.getPackageName();
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN DevicePolicyManager#isProfileOwnerApp is called with it
+    // THEN the method should return true
+    assertThat(devicePolicyManager.isProfileOwnerApp(testPackage)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getProfileOwnerShouldReturnDeviceOwnerComponentName() {
+    // GIVEN an test package which is the profile owner app of the device
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN DevicePolicyManager#getProfileOwner is called
+    // THEN the method should return the component
+    assertThat(devicePolicyManager.getProfileOwner()).isEqualTo(testComponent);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getProfileOwnerShouldReturnNullWhenThereIsNoProfileOwner() {
+    // WHEN DevicePolicyManager#getProfileOwner is called without a profile owner
+    // THEN the method should return null
+    assertThat(devicePolicyManager.getProfileOwner()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void transferOwnershipShouldTransferOwnership() {
+    ComponentName otherComponent = new ComponentName("new.owner", "Receiver");
+    shadowOf(packageManager).addReceiverIfNotPresent(otherComponent);
+    shadowOf(devicePolicyManager).setActiveAdmin(otherComponent);
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    PersistableBundle bundle = new PersistableBundle();
+
+    devicePolicyManager.transferOwnership(testComponent, otherComponent, bundle);
+
+    devicePolicyManager.isProfileOwnerApp("new.owner");
+    assertThat(devicePolicyManager.getTransferOwnershipBundle()).isEqualTo(bundle);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void transferOwnershipShouldFailForNotOwner() {
+    ComponentName otherComponent = new ComponentName("new.owner", "Receiver");
+    shadowOf(packageManager).addReceiverIfNotPresent(otherComponent);
+    shadowOf(devicePolicyManager).setActiveAdmin(otherComponent);
+    PersistableBundle bundle = new PersistableBundle();
+
+    try {
+      devicePolicyManager.transferOwnership(testComponent, otherComponent, bundle);
+      fail("Should throw");
+    } catch (SecurityException e) {
+      // expected
+    }
+    assertThat(devicePolicyManager.getTransferOwnershipBundle()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void transferOwnershipShouldFailForNotTarget() {
+    ComponentName otherComponent = new ComponentName("new.owner", "Receiver");
+    PersistableBundle bundle = new PersistableBundle();
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    try {
+      devicePolicyManager.transferOwnership(testComponent, otherComponent, bundle);
+      fail("Should throw");
+    } catch (IllegalArgumentException e) {
+      // expected
+    }
+    assertThat(devicePolicyManager.getTransferOwnershipBundle()).isNull();
+  }
+
+  @Test
+  public void isAdminActiveShouldReturnFalseForNonAdminDevice() {
+    // GIVEN a test component which is not an active admin of the device
+    // WHEN DevicePolicyManager#isAdminActive is called with it
+    // THEN the method should return false
+    assertThat(devicePolicyManager.isAdminActive(testComponent)).isFalse();
+  }
+
+  @Test
+  public void isAdminActiveShouldReturnTrueForAnyDeviceAdminDevice() {
+    // GIVEN a test component which is an active admin of the device
+    shadowOf(devicePolicyManager).setActiveAdmin(testComponent);
+
+    // WHEN DevicePolicyManager#isAdminActive is called with it
+    // THEN the method should return true
+    assertThat(devicePolicyManager.isAdminActive(testComponent)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getActiveAdminsShouldReturnDeviceOwner() {
+    // GIVEN an test package which is the device owner app of the device
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN DevicePolicyManager#getActiveAdmins is called
+    // THEN the return of the method should include the device owner app
+    assertThat(devicePolicyManager.getActiveAdmins()).contains(testComponent);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getActiveAdminsShouldReturnProfileOwner() {
+    // GIVEN an test package which is the profile owner app of the device
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN DevicePolicyManager#getActiveAdmins is called
+    // THEN the return of the method should include the profile owner app
+    assertThat(devicePolicyManager.getActiveAdmins()).contains(testComponent);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void addUserRestrictionShouldWorkAsIntendedForDeviceOwner() {
+    // GIVEN a user restriction to set
+    String restrictionKey = "restriction key";
+
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN DevicePolicyManager#addUserRestriction is called with the key
+    devicePolicyManager.addUserRestriction(testComponent, restrictionKey);
+
+    // THEN the restriction should be set for the current user
+    Bundle restrictions = userManager.getUserRestrictions();
+    assertThat(restrictions.getBoolean(restrictionKey)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void addUserRestrictionShouldWorkAsIntendedForProfileOwner() {
+    // GIVEN a user restriction to set
+    String restrictionKey = "restriction key";
+
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN DevicePolicyManager#addUserRestriction is called with the key
+    devicePolicyManager.addUserRestriction(testComponent, restrictionKey);
+
+    // THEN the restriction should be set for the current user
+    Bundle restrictions = userManager.getUserRestrictions();
+    assertThat(restrictions.getBoolean(restrictionKey)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void clearUserRestrictionShouldWorkAsIntendedForActiveAdmins() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN a user restriction has set
+    String restrictionKey = "restriction key";
+    devicePolicyManager.addUserRestriction(testComponent, restrictionKey);
+
+    // WHEN DevicePolicyManager#clearUserRestriction is called with the key
+    devicePolicyManager.clearUserRestriction(testComponent, restrictionKey);
+
+    // THEN the restriction should be cleared for the current user
+    Bundle restrictions = userManager.getUserRestrictions();
+    assertThat(restrictions.getBoolean(restrictionKey)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isApplicationHiddenShouldReturnTrueForNotExistingApps() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN package that is not installed
+    String app = "com.example.non.existing";
+
+    // WHEN DevicePolicyManager#isApplicationHidden is called on the app
+    // THEN it should return true
+    assertThat(devicePolicyManager.isApplicationHidden(testComponent, app)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isApplicationHiddenShouldReturnFalseForAppsByDefault() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it's never be set hidden or non hidden
+    String app = "com.example.non.hidden";
+    shadowOf(packageManager).addPackage(app);
+
+    // WHEN DevicePolicyManager#isApplicationHidden is called on the app
+    // THEN it should return false
+    assertThat(devicePolicyManager.isApplicationHidden(testComponent, app)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isApplicationHiddenShouldReturnTrueForHiddenApps() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it is hidden
+    String hiddenApp = "com.example.hidden";
+    shadowOf(packageManager).addPackage(hiddenApp);
+    devicePolicyManager.setApplicationHidden(testComponent, hiddenApp, true);
+
+    // WHEN DevicePolicyManager#isApplicationHidden is called on the app
+    // THEN it should return true
+    assertThat(devicePolicyManager.isApplicationHidden(testComponent, hiddenApp)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isApplicationHiddenShouldReturnFalseForNonHiddenApps() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it is not hidden
+    String nonHiddenApp = "com.example.non.hidden";
+    shadowOf(packageManager).addPackage(nonHiddenApp);
+    devicePolicyManager.setApplicationHidden(testComponent, nonHiddenApp, false);
+
+    // WHEN DevicePolicyManager#isApplicationHidden is called on the app
+    // THEN it should return false
+    assertThat(devicePolicyManager.isApplicationHidden(testComponent, nonHiddenApp)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setApplicationHiddenShouldBeAbleToUnhideHiddenApps() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it is hidden
+    String app = "com.example.hidden";
+    shadowOf(packageManager).addPackage(app);
+    devicePolicyManager.setApplicationHidden(testComponent, app, true);
+
+    // WHEN DevicePolicyManager#setApplicationHidden is called on the app to unhide it
+    devicePolicyManager.setApplicationHidden(testComponent, app, false);
+
+    // THEN the app shouldn't be hidden anymore
+    assertThat(devicePolicyManager.isApplicationHidden(testComponent, app)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setApplicationHiddenShouldReturnFalseForNotExistingApps() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN an app is not installed
+    String app = "com.example.not.installed";
+
+    // THEN DevicePolicyManager#setApplicationHidden returns false
+    assertThat(devicePolicyManager.setApplicationHidden(testComponent, app, true)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void wasPackageEverHiddenShouldReturnFalseForPackageNeverHidden() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it's never be set hidden or non hidden
+    String app = "com.example.non.hidden";
+    shadowOf(packageManager).addPackage(app);
+
+    // WHEN ShadowDevicePolicyManager#wasPackageEverHidden is called with the app
+    // THEN it should return false
+    assertThat(shadowOf(devicePolicyManager).wasPackageEverHidden(app)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void wasPackageEverHiddenShouldReturnTrueForPackageWhichIsHidden() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it's hidden
+    String hiddenApp = "com.example.hidden";
+    shadowOf(packageManager).addPackage(hiddenApp);
+    devicePolicyManager.setApplicationHidden(testComponent, hiddenApp, true);
+
+    // WHEN ShadowDevicePolicyManager#wasPackageEverHidden is called with the app
+    // THEN it should return true
+    assertThat(shadowOf(devicePolicyManager).wasPackageEverHidden(hiddenApp)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void wasPackageEverHiddenShouldReturnTrueForPackageWhichWasHidden() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app and it was hidden
+    String app = "com.example.hidden";
+    shadowOf(packageManager).addPackage(app);
+    devicePolicyManager.setApplicationHidden(testComponent, app, true);
+    devicePolicyManager.setApplicationHidden(testComponent, app, false);
+
+    // WHEN ShadowDevicePolicyManager#wasPackageEverHidden is called with the app
+    // THEN it should return true
+    assertThat(shadowOf(devicePolicyManager).wasPackageEverHidden(app)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void enableSystemAppShouldWorkForActiveAdmins() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN a system app
+    String app = "com.example.system";
+
+    // WHEN DevicePolicyManager#enableSystemApp is called with the app
+    devicePolicyManager.enableSystemApp(testComponent, app);
+
+    // THEN the app should be enabled
+    assertThat(shadowOf(devicePolicyManager).wasSystemAppEnabled(app)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isUninstallBlockedShouldReturnFalseForAppsNeverBeingBlocked() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app
+    String app = "com.example.app";
+
+    // WHEN DevicePolicyManager#isUninstallBlocked is called with the app
+    // THEN it should return false
+    assertThat(devicePolicyManager.isUninstallBlocked(testComponent, app)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isUninstallBlockedShouldReturnTrueForAppsBeingUnblocked() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app which is blocked from being uninstalled
+    String app = "com.example.app";
+    devicePolicyManager.setUninstallBlocked(testComponent, app, true);
+
+    // WHEN DevicePolicyManager#UninstallBlocked is called with the app
+    // THEN it should return true
+    assertThat(devicePolicyManager.isUninstallBlocked(testComponent, app)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isUninstallBlockedShouldReturnFalseForAppsBeingBlocked() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app which is unblocked from being uninstalled
+    String app = "com.example.app";
+    devicePolicyManager.setUninstallBlocked(testComponent, app, true);
+    devicePolicyManager.setUninstallBlocked(testComponent, app, false);
+
+    // WHEN DevicePolicyManager#UninstallBlocked is called with the app
+    // THEN it should return false
+    assertThat(devicePolicyManager.isUninstallBlocked(testComponent, app)).isFalse();
+  }
+
+  @Test
+  @Config(sdk = LOLLIPOP)
+  public void isUninstallBlockedWithNullAdminShouldThrowNullPointerExceptionOnLollipop() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app which is blocked from being uninstalled
+    String app = "com.example.app";
+    devicePolicyManager.setUninstallBlocked(testComponent, app, true);
+
+    // WHEN DevicePolicyManager#UninstallBlocked is called with null admin
+    // THEN it should throw NullPointerException
+    try {
+      devicePolicyManager.isUninstallBlocked(/* admin= */ null, app);
+      fail("expected NullPointerException");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void
+      isUninstallBlockedWithNullAdminShouldNotThrowNullPointerExceptionOnLollipopMr1AndAbove() {
+    // GIVEN the caller is the device owner, and thus an active admin
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app which is blocked from being uninstalled
+    String app = "com.example.app";
+    devicePolicyManager.setUninstallBlocked(testComponent, app, true);
+
+    // WHEN DevicePolicyManager#UninstallBlocked is called with null admin
+    // THEN it should not throw NullPointerException
+    assertThat(devicePolicyManager.isUninstallBlocked(/* admin= */ null, app)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isUniqueDeviceAttestationSupported() {
+    shadowOf(devicePolicyManager).setIsUniqueDeviceAttestationSupported(true);
+
+    assertThat(devicePolicyManager.isUniqueDeviceAttestationSupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setApplicationRestrictionsShouldWorkAsIntendedForDeviceOwner() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an application restriction bundle
+    Bundle restrictions = new Bundle();
+    restrictions.putString("key", "value");
+
+    // GIVEN an app which the restriction is set to
+    String app = "com.example.app";
+
+    // WHEN DevicePolicyManager#setApplicationRestrictions is called to set the restrictions to the
+    // app
+    devicePolicyManager.setApplicationRestrictions(testComponent, app, restrictions);
+
+    // THEN the restrictions should be set correctly
+    Bundle actualRestrictions = devicePolicyManager.getApplicationRestrictions(testComponent, app);
+    assertThat(actualRestrictions.getString("key", "default value")).isEqualTo("value");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setApplicationRestrictionsShouldWorkAsIntendedForProfileOwner() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // GIVEN an application restriction bundle
+    Bundle restrictions = new Bundle();
+    restrictions.putString("key", "value");
+
+    // GIVEN an app which the restriction is set to
+    String app = "com.example.app";
+
+    // WHEN DevicePolicyManager#setApplicationRestrictions is called to set the restrictions to the
+    // app
+    devicePolicyManager.setApplicationRestrictions(testComponent, app, restrictions);
+
+    // THEN the restrictions should be set correctly
+    Bundle actualRestrictions = devicePolicyManager.getApplicationRestrictions(testComponent, app);
+    assertThat(actualRestrictions.getString("key", "default value")).isEqualTo("value");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getApplicationRestrictionsShouldReturnEmptyBundleIfAppHasNone() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN an app has no restrictions
+    String app = "com.example.app";
+
+    // WHEN DevicePolicyManager#getApplicationRestrictions is called to get the restrictions of the
+    // app
+    // THEN it should return the empty bundle
+    assertThat(devicePolicyManager.getApplicationRestrictions(testComponent, app).isEmpty())
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAccountTypesWithManagementDisabledShouldReturnNothingWhenNoAccountIsDislabed() {
+    // GIVEN no account type has ever been disabled
+
+    // WHEN get disabled account types using
+    // DevicePolicyManager#getAccountTypesWithManagementDisabled
+    // THEN it should be empty
+    assertThat(devicePolicyManager.getAccountTypesWithManagementDisabled()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAccountTypesWithManagementDisabledShouldReturnDisabledAccountTypesIfAny() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN a disabled account type
+    String disabledAccountType = "com.example.account.type";
+    devicePolicyManager.setAccountManagementDisabled(testComponent, disabledAccountType, true);
+
+    // WHEN get disabled account types using
+    // DevicePolicyManager#getAccountTypesWithManagementDisabled
+    // THEN it should contain the disabled account type
+    assertThat(devicePolicyManager.getAccountTypesWithManagementDisabled())
+        .isEqualTo(new String[] {disabledAccountType});
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAccountTypesWithManagementDisabledShouldNotReturnReenabledAccountTypesIfAny() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // GIVEN a re-enabled account type
+    String reenabledAccountType = "com.example.account.type";
+    devicePolicyManager.setAccountManagementDisabled(testComponent, reenabledAccountType, true);
+    devicePolicyManager.setAccountManagementDisabled(testComponent, reenabledAccountType, false);
+
+    // WHEN get disabled account types using
+    // DevicePolicyManager#getAccountTypesWithManagementDisabled
+    // THEN it should not contain the re-enabled account type
+    assertThat(devicePolicyManager.getAccountTypesWithManagementDisabled()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setOrganizationNameShouldWorkForPoSinceN() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setting an organization name
+    String organizationName = "TestOrg";
+    devicePolicyManager.setOrganizationName(testComponent, organizationName);
+
+    // THEN the name should be set properly
+    assertThat(devicePolicyManager.getOrganizationName(testComponent).toString())
+        .isEqualTo(organizationName);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setOrganizationNameShouldClearNameWithEmptyNameForPoSinceN() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // GIVEN that the profile has already set the name TestOrg
+    String organizationName = "TestOrg";
+    devicePolicyManager.setOrganizationName(testComponent, organizationName);
+
+    // WHEN setting an organization name to empty
+    devicePolicyManager.setOrganizationName(testComponent, "");
+
+    // THEN the name should be cleared
+    assertThat(devicePolicyManager.getOrganizationName(testComponent)).isNull();
+  }
+
+  @Test
+  @Config(sdk = N)
+  public void setOrganizationNameShouldNotWorkForDoInN() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setting an organization name
+    // THEN the method should throw SecurityException
+    String organizationName = "TestOrg";
+    try {
+      devicePolicyManager.setOrganizationName(testComponent, organizationName);
+      fail("expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setOrganizationNameShouldWorkForDoSinceO() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setting an organization name
+    String organizationName = "TestOrg";
+    devicePolicyManager.setOrganizationName(testComponent, organizationName);
+
+    // THEN the name should be set properly
+    assertThat(devicePolicyManager.getOrganizationName(testComponent).toString())
+        .isEqualTo(organizationName);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setOrganizationColorShouldWorkForPoSinceN() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setting an organization color
+    int color = 0xFFFF00FF;
+    devicePolicyManager.setOrganizationColor(testComponent, color);
+
+    // THEN the color should be set properly
+    assertThat(devicePolicyManager.getOrganizationColor(testComponent)).isEqualTo(color);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getOrganizationColorShouldReturnDefaultColorIfNothingSet() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN getting an organization color without setting it
+    // THEN the color returned should be the default color
+    assertThat(devicePolicyManager.getOrganizationColor(testComponent)).isEqualTo(0xFF008080);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setOrganizationColorShouldNotWorkForDo() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setting an organization color
+    // THEN the method should throw SecurityException
+    int color = 0xFFFF00FF;
+    try {
+      devicePolicyManager.setOrganizationColor(testComponent, color);
+      fail("expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAutoTimeRequiredShouldWorkAsIntendedForDeviceOwner() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setAutoTimeRequired is called with true
+    devicePolicyManager.setAutoTimeRequired(testComponent, true);
+
+    // THEN getAutoTimeRequired should return true
+    assertThat(devicePolicyManager.getAutoTimeRequired()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAutoTimeRequiredShouldWorkAsIntendedForProfileOwner() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setAutoTimeRequired is called with false
+    devicePolicyManager.setAutoTimeRequired(testComponent, false);
+
+    // THEN getAutoTimeRequired should return false
+    assertThat(devicePolicyManager.getAutoTimeRequired()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getAutoTimeRequiredShouldReturnFalseIfNotSet() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setAutoTimeRequired has not been called
+    // THEN getAutoTimeRequired should return false
+    assertThat(devicePolicyManager.getAutoTimeRequired()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setAutoTimeZoneEnabledShouldFailIfNotDeviceOrProfileOwner() {
+    // GIVEN the caller is not a device or profile owner
+    // WHEN setAutoTimeZoneEnabled is called
+    // THEN a SecurityException should be thrown
+    assertThrows(
+        SecurityException.class,
+        () -> devicePolicyManager.setAutoTimeZoneEnabled(testComponent, false));
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getAutoTimeZoneEnabledShouldFailIfNotDeviceOrProfileOwner() {
+    // GIVEN the caller is not a device or profile owner
+    // WHEN getAutoTimeZoneEnabled is called
+    // THEN a SecurityException should be thrown
+    assertThrows(
+        SecurityException.class, () -> devicePolicyManager.getAutoTimeZoneEnabled(testComponent));
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getAutoTimeZoneEnabledShouldWorkAsIntendedForDeviceOwner() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setAutoTimeZoneEnabled is called with true
+    devicePolicyManager.setAutoTimeZoneEnabled(testComponent, true);
+
+    // THEN getAutoTimeZoneEnabled should return true
+    assertThat(devicePolicyManager.getAutoTimeZoneEnabled(testComponent)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getAutoTimeZoneEnabledShouldWorkAsIntendedForProfileOwner() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setAutoTimeZoneEnabled is called with true
+    devicePolicyManager.setAutoTimeZoneEnabled(testComponent, true);
+
+    // THEN getAutoTimeZoneEnabled should return true
+    assertThat(devicePolicyManager.getAutoTimeZoneEnabled(testComponent)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getAutoTimeZoneEnabledShouldReturnFalseIfNotSet() {
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setAutoTimeZoneEnabled has not been called
+    // THEN getAutoTimeZoneEnabled should return false
+    assertThat(devicePolicyManager.getAutoTimeZoneEnabled(testComponent)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setTimeZoneShouldFailIfNotDeviceOrProfileOwner() {
+    // GIVEN the caller is not a device or profile owner
+    // WHEN setTimeZone is called
+    // THEN a SecurityException should be thrown
+    assertThrows(
+        SecurityException.class,
+        () -> devicePolicyManager.setTimeZone(testComponent, "America/New_York"));
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setTimeZoneShouldReturnTrueIfAutoTimeZoneNotEnabled() {
+    String testTimeZone = "America/New_York";
+
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // GIVEN auto time zone is not enabled
+    devicePolicyManager.setAutoTimeZoneEnabled(testComponent, false);
+
+    // WHEN setTimeZone is called with "America/New_York"
+    // THEN setTimeZone should return false
+    assertThat(devicePolicyManager.setTimeZone(testComponent, testTimeZone)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setTimeZoneShouldReturnFalseIfAutoTimeZoneEnabled() {
+    String testTimeZone = "America/New_York";
+
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // GIVEN auto time zone is enabled
+    devicePolicyManager.setAutoTimeZoneEnabled(testComponent, true);
+
+    // WHEN setTimeZone is called with "America/New_York"
+    // THEN setTimeZone should return false
+    assertThat(devicePolicyManager.setTimeZone(testComponent, testTimeZone)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getTimeZoneShouldWorkAsIntendedForDeviceOwner() {
+    String testTimeZone = "America/New_York";
+
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setTimeZone is called with "America/New_York"
+    devicePolicyManager.setTimeZone(testComponent, testTimeZone);
+
+    // THEN getTimeZone should return "America/New_York"
+    assertThat(shadowOf(devicePolicyManager).getTimeZone()).isEqualTo(testTimeZone);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getTimeZoneShouldWorkAsIntendedForProfileOwner() {
+    String testTimeZone = "America/New_York";
+
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setTimeZone is called with "America/New_York"
+    devicePolicyManager.setTimeZone(testComponent, testTimeZone);
+
+    // THEN getTimeZone should return "America/New_York"
+    assertThat(shadowOf(devicePolicyManager).getTimeZone()).isEqualTo(testTimeZone);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getTimeZoneShouldReturnNullIfSetTimeZoneHasNotBeenCalled() {
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setTimeZone is not called
+    // THEN getTimeZone should return null
+    assertThat(shadowOf(devicePolicyManager).getTimeZone()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getPermittedAccessibilityServicesShouldWorkAsIntendedForDeviceOwner() {
+    List<String> accessibilityServices =
+        Arrays.asList("com.example.accessibility1", "com.example.accessibility2");
+
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setPermittedAccessibilityServices is called with a valid list
+    devicePolicyManager.setPermittedAccessibilityServices(testComponent, accessibilityServices);
+
+    // THEN getAutoTimeRequired should return the list
+    assertThat(devicePolicyManager.getPermittedAccessibilityServices(testComponent))
+        .isEqualTo(accessibilityServices);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getPermittedAccessibilityServicesShouldWorkAsIntendedForProfileOwner() {
+    List<String> accessibilityServices = new ArrayList<>();
+
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setPermittedAccessibilityServices is called with an empty list
+    devicePolicyManager.setPermittedAccessibilityServices(testComponent, accessibilityServices);
+
+    // THEN getAutoTimeRequired should return an empty list
+    assertThat(devicePolicyManager.getPermittedAccessibilityServices(testComponent)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getPermittedAccessibilityServicesShouldReturnNullIfNullIsSet() {
+    List<String> accessibilityServices = null;
+
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setPermittedAccessibilityServices is called with a null list
+    devicePolicyManager.setPermittedAccessibilityServices(testComponent, accessibilityServices);
+
+    // THEN getAutoTimeRequired should return null
+    assertThat(devicePolicyManager.getPermittedAccessibilityServices(testComponent)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getPermittedInputMethodsShouldWorkAsIntendedForDeviceOwner() {
+    List<String> inputMethods = Arrays.asList("com.example.input1", "com.example.input2");
+
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setPermittedInputMethods is called with a valid list
+    devicePolicyManager.setPermittedInputMethods(testComponent, inputMethods);
+
+    // THEN getAutoTimeRequired should return the list
+    assertThat(devicePolicyManager.getPermittedInputMethods(testComponent)).isEqualTo(inputMethods);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getPermittedInputMethodsShouldWorkAsIntendedForProfileOwner() {
+    List<String> inputMethods = new ArrayList<>();
+
+    // GIVEN the caller is the profile owner
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    // WHEN setPermittedInputMethods is called with an empty list
+    devicePolicyManager.setPermittedInputMethods(testComponent, inputMethods);
+
+    // THEN getAutoTimeRequired should return an empty list
+    assertThat(devicePolicyManager.getPermittedInputMethods(testComponent)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getPermittedInputMethodsShouldReturnNullIfNullIsSet() {
+    List<String> inputMethods = null;
+
+    // GIVEN the caller is the device owner
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    // WHEN setPermittedInputMethods is called with a null list
+    devicePolicyManager.setPermittedInputMethods(testComponent, inputMethods);
+
+    // THEN getAutoTimeRequired should return null
+    assertThat(devicePolicyManager.getPermittedInputMethods(testComponent)).isNull();
+  }
+
+  @Test
+  public void getStorageEncryptionStatus_defaultValueIsUnsupported() {
+    final int status = devicePolicyManager.getStorageEncryptionStatus();
+    assertThat(status).isEqualTo(ENCRYPTION_STATUS_UNSUPPORTED);
+  }
+
+  @Test
+  public void setStorageEncryptionStatus_IllegalValue() {
+    try {
+      shadowOf(devicePolicyManager).setStorageEncryptionStatus(-1);
+      fail("Expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      assertThat(e.getMessage()).isEqualTo("Unknown status: -1");
+    }
+  }
+
+  @Test
+  public void setStorageEncryptionStatus_Unsupported() {
+    shadowOf(devicePolicyManager).setStorageEncryptionStatus(ENCRYPTION_STATUS_UNSUPPORTED);
+    assertThat(devicePolicyManager.getStorageEncryptionStatus())
+        .isEqualTo(ENCRYPTION_STATUS_UNSUPPORTED);
+  }
+
+  @Test
+  public void setStorageEncryptionStatus_Active() {
+    shadowOf(devicePolicyManager).setStorageEncryptionStatus(ENCRYPTION_STATUS_ACTIVE);
+    assertThat(devicePolicyManager.getStorageEncryptionStatus())
+        .isEqualTo(ENCRYPTION_STATUS_ACTIVE);
+  }
+
+  @Test
+  public void setStorageEncryptionStatus_Inactive() {
+    shadowOf(devicePolicyManager).setStorageEncryptionStatus(ENCRYPTION_STATUS_INACTIVE);
+    assertThat(devicePolicyManager.getStorageEncryptionStatus())
+        .isEqualTo(ENCRYPTION_STATUS_INACTIVE);
+  }
+
+  @Test
+  public void setStorageEncryptionStatus_Activating() {
+    shadowOf(devicePolicyManager).setStorageEncryptionStatus(ENCRYPTION_STATUS_ACTIVATING);
+    assertThat(devicePolicyManager.getStorageEncryptionStatus())
+        .isEqualTo(ENCRYPTION_STATUS_ACTIVATING);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setStorageEncryptionStatus_ActiveDefaultKey() {
+    shadowOf(devicePolicyManager).setStorageEncryptionStatus(ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY);
+    assertThat(devicePolicyManager.getStorageEncryptionStatus())
+        .isEqualTo(ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setStorageEncryptionStatus_ActivePerUser() {
+    shadowOf(devicePolicyManager).setStorageEncryptionStatus(ENCRYPTION_STATUS_ACTIVE_PER_USER);
+    assertThat(devicePolicyManager.getStorageEncryptionStatus())
+        .isEqualTo(ENCRYPTION_STATUS_ACTIVE_PER_USER);
+  }
+
+  @Test
+  public void setPasswordQuality_Complex() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    devicePolicyManager.setPasswordQuality(
+        testComponent, DevicePolicyManager.PASSWORD_QUALITY_COMPLEX);
+    devicePolicyManager.setPasswordMinimumLength(testComponent, 7);
+    devicePolicyManager.setPasswordMinimumLetters(testComponent, 2);
+    devicePolicyManager.setPasswordMinimumUpperCase(testComponent, 1);
+
+    assertThat(devicePolicyManager.resetPassword("aaaa", 0)).isFalse();
+    assertThat(devicePolicyManager.resetPassword("aA2!", 0)).isFalse();
+    assertThat(devicePolicyManager.resetPassword("aaaA123", 0)).isFalse();
+    assertThat(devicePolicyManager.resetPassword("AAAA123", 0)).isFalse();
+    assertThat(devicePolicyManager.resetPassword("!!AAAaaa", 0)).isFalse();
+    assertThat(devicePolicyManager.resetPassword("aaAA123!", 0)).isTrue();
+  }
+
+  @Test
+  public void setPasswordQuality() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    devicePolicyManager.setPasswordQuality(
+        testComponent, DevicePolicyManager.PASSWORD_QUALITY_COMPLEX);
+
+    assertThat(devicePolicyManager.getPasswordQuality(testComponent))
+        .isEqualTo(DevicePolicyManager.PASSWORD_QUALITY_COMPLEX);
+  }
+
+  @Test
+  public void getPasswordQuality_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    devicePolicyManager.setPasswordQuality(
+        testComponent, DevicePolicyManager.PASSWORD_QUALITY_COMPLEX);
+
+    assertThat(devicePolicyManager.getPasswordQuality(/* admin= */ null))
+        .isEqualTo(DevicePolicyManager.PASSWORD_QUALITY_COMPLEX);
+  }
+
+  @Test
+  public void setPasswordMinimumLength() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int length = 6;
+    devicePolicyManager.setPasswordMinimumLength(testComponent, length);
+
+    assertThat(devicePolicyManager.getPasswordMinimumLength(testComponent)).isEqualTo(length);
+  }
+
+  @Test
+  public void getPasswordMinimumLength_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int length = 6;
+    devicePolicyManager.setPasswordMinimumLength(testComponent, length);
+
+    assertThat(devicePolicyManager.getPasswordMinimumLength(/* admin= */ null)).isEqualTo(length);
+  }
+
+  @Test
+  public void setPasswordMinimumLetters() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minLetters = 3;
+    devicePolicyManager.setPasswordMinimumLetters(testComponent, minLetters);
+
+    assertThat(devicePolicyManager.getPasswordMinimumLetters(testComponent)).isEqualTo(minLetters);
+  }
+
+  @Test
+  public void getPasswordMinimumLetters_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minLetters = 3;
+    devicePolicyManager.setPasswordMinimumLetters(testComponent, minLetters);
+
+    assertThat(devicePolicyManager.getPasswordMinimumLetters(/* admin= */ null))
+        .isEqualTo(minLetters);
+  }
+
+  @Test
+  public void setPasswordMinimumLowerCase() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minLowerCase = 3;
+    devicePolicyManager.setPasswordMinimumLowerCase(testComponent, minLowerCase);
+
+    assertThat(devicePolicyManager.getPasswordMinimumLowerCase(testComponent))
+        .isEqualTo(minLowerCase);
+  }
+
+  @Test
+  public void getPasswordMinimumLowerCase_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minLowerCase = 3;
+    devicePolicyManager.setPasswordMinimumLowerCase(testComponent, minLowerCase);
+
+    assertThat(devicePolicyManager.getPasswordMinimumLowerCase(/* admin= */ null))
+        .isEqualTo(minLowerCase);
+  }
+
+  @Test
+  public void setPasswordMinimumUpperCase() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minUpperCase = 3;
+    devicePolicyManager.setPasswordMinimumUpperCase(testComponent, minUpperCase);
+
+    assertThat(devicePolicyManager.getPasswordMinimumUpperCase(testComponent))
+        .isEqualTo(minUpperCase);
+  }
+
+  @Test
+  public void getPasswordMinimumUpperCase_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minUpperCase = 3;
+    devicePolicyManager.setPasswordMinimumUpperCase(testComponent, minUpperCase);
+
+    assertThat(devicePolicyManager.getPasswordMinimumUpperCase(/* admin= */ null))
+        .isEqualTo(minUpperCase);
+  }
+
+  @Test
+  public void setPasswordMinimumNonLetter() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minNonLetters = 1;
+    devicePolicyManager.setPasswordMinimumNonLetter(testComponent, minNonLetters);
+
+    assertThat(devicePolicyManager.getPasswordMinimumNonLetter(testComponent))
+        .isEqualTo(minNonLetters);
+  }
+
+  @Test
+  public void getPasswordMinimumNonLetter_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minNonLetters = 1;
+    devicePolicyManager.setPasswordMinimumNonLetter(testComponent, minNonLetters);
+
+    assertThat(devicePolicyManager.getPasswordMinimumNonLetter(/* admin= */ null))
+        .isEqualTo(minNonLetters);
+  }
+
+  @Test
+  public void setPasswordMinimumNumeric() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minNumeric = 5;
+    devicePolicyManager.setPasswordMinimumNumeric(testComponent, minNumeric);
+
+    assertThat(devicePolicyManager.getPasswordMinimumNumeric(testComponent)).isEqualTo(minNumeric);
+  }
+
+  @Test
+  public void getPasswordMinimumNumeric_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minNumeric = 5;
+    devicePolicyManager.setPasswordMinimumNumeric(testComponent, minNumeric);
+
+    assertThat(devicePolicyManager.getPasswordMinimumNumeric(/* admin= */ null))
+        .isEqualTo(minNumeric);
+  }
+
+  @Test
+  public void setPasswordMinimumSymbols() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minSymbols = 1;
+    devicePolicyManager.setPasswordMinimumSymbols(testComponent, minSymbols);
+
+    assertThat(devicePolicyManager.getPasswordMinimumSymbols(testComponent)).isEqualTo(minSymbols);
+  }
+
+  @Test
+  public void getPasswordMinimumSymbols_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int minSymbols = 1;
+    devicePolicyManager.setPasswordMinimumSymbols(testComponent, minSymbols);
+
+    assertThat(devicePolicyManager.getPasswordMinimumSymbols(/* admin= */ null))
+        .isEqualTo(minSymbols);
+  }
+
+  @Test
+  public void setMaximumFailedPasswordsForWipe() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int maxAttempts = 10;
+    devicePolicyManager.setMaximumFailedPasswordsForWipe(testComponent, maxAttempts);
+
+    assertThat(devicePolicyManager.getMaximumFailedPasswordsForWipe(testComponent))
+        .isEqualTo(maxAttempts);
+  }
+
+  @Test
+  public void getMaximumFailedPasswordsForWipe_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int maxAttempts = 10;
+    devicePolicyManager.setMaximumFailedPasswordsForWipe(testComponent, maxAttempts);
+
+    assertThat(devicePolicyManager.getMaximumFailedPasswordsForWipe(/* admin= */ null))
+        .isEqualTo(maxAttempts);
+  }
+
+  @Test
+  public void setCameraDisabled() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    devicePolicyManager.setCameraDisabled(testComponent, true);
+
+    assertThat(devicePolicyManager.getCameraDisabled(testComponent)).isTrue();
+  }
+
+  @Test
+  public void getCameraDisabled_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    devicePolicyManager.setCameraDisabled(testComponent, true);
+
+    assertThat(devicePolicyManager.getCameraDisabled(/* admin= */ null)).isTrue();
+  }
+
+  @Test
+  public void setPasswordExpirationTimeout() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    long timeMs = 600000;
+    devicePolicyManager.setPasswordExpirationTimeout(testComponent, timeMs);
+
+    assertThat(devicePolicyManager.getPasswordExpirationTimeout(testComponent)).isEqualTo(timeMs);
+  }
+
+  @Test
+  public void getPasswordExpirationTimeout_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    long timeMs = 600000;
+    devicePolicyManager.setPasswordExpirationTimeout(testComponent, timeMs);
+
+    assertThat(devicePolicyManager.getPasswordExpirationTimeout(/* admin= */ null))
+        .isEqualTo(timeMs);
+  }
+
+  @Test
+  public void getPasswordExpiration() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    long timeMs = 600000;
+    shadowOf(devicePolicyManager).setPasswordExpiration(testComponent, timeMs);
+
+    assertThat(devicePolicyManager.getPasswordExpiration(testComponent)).isEqualTo(timeMs);
+  }
+
+  @Test
+  public void getPasswordExpiration_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    long timeMs = 600000;
+    shadowOf(devicePolicyManager).setPasswordExpiration(testComponent, timeMs);
+
+    assertThat(devicePolicyManager.getPasswordExpiration(/* admin= */ null)).isEqualTo(timeMs);
+  }
+
+  @Test
+  public void setMaximumTimeToLock() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    long timeMs = 600000;
+    devicePolicyManager.setMaximumTimeToLock(testComponent, timeMs);
+
+    assertThat(devicePolicyManager.getMaximumTimeToLock(testComponent)).isEqualTo(timeMs);
+  }
+
+  @Test
+  public void getMaximumTimeToLock_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    long timeMs = 600000;
+    devicePolicyManager.setMaximumTimeToLock(testComponent, timeMs);
+
+    assertThat(devicePolicyManager.getMaximumTimeToLock(/* admin= */ null)).isEqualTo(timeMs);
+  }
+
+  @Test
+  public void setPasswordHistoryLength() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int length = 100;
+    devicePolicyManager.setPasswordHistoryLength(testComponent, length);
+
+    assertThat(devicePolicyManager.getPasswordHistoryLength(testComponent)).isEqualTo(length);
+  }
+
+  @Test
+  public void getPasswordHistoryLength_nullAdmin() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    int length = 100;
+    devicePolicyManager.setPasswordHistoryLength(testComponent, length);
+
+    assertThat(devicePolicyManager.getPasswordHistoryLength(/* admin= */ null)).isEqualTo(length);
+  }
+
+  @Test
+  public void isActivePasswordSufficient() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(devicePolicyManager).setActivePasswordSufficient(true);
+
+    assertThat(devicePolicyManager.isActivePasswordSufficient()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isDeviceProvisioned() {
+    shadowOf(devicePolicyManager).setDeviceProvisioned(true);
+
+    assertThat(devicePolicyManager.isDeviceProvisioned()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isDeviceProvisioningConfigApplied() {
+    devicePolicyManager.setDeviceProvisioningConfigApplied();
+
+    assertThat(devicePolicyManager.isDeviceProvisioningConfigApplied()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getPasswordComplexity() {
+    shadowOf(devicePolicyManager).setPasswordComplexity(PASSWORD_COMPLEXITY_HIGH);
+
+    assertThat(devicePolicyManager.getPasswordComplexity()).isEqualTo(PASSWORD_COMPLEXITY_HIGH);
+  }
+
+  @Test
+  public void setStorageEncryption() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    assertThat(devicePolicyManager.getStorageEncryption(testComponent)).isFalse();
+
+    devicePolicyManager.setStorageEncryption(testComponent, true);
+
+    assertThat(devicePolicyManager.getStorageEncryption(testComponent)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setPackagesSuspended_suspendsPossible() throws Exception {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(packageManager).addPackage("installed");
+    String[] packages = new String[] {"installed", "not.installed"};
+
+    assertThat(devicePolicyManager.setPackagesSuspended(testComponent, packages, true))
+        .isEqualTo(new String[] {"not.installed"});
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setPackagesSuspended_activateActive() throws Exception {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(packageManager).addPackage("package");
+
+    assertThat(
+            devicePolicyManager.setPackagesSuspended(
+                testComponent, new String[] {"package"}, false))
+        .isEmpty();
+    assertThat(devicePolicyManager.isPackageSuspended(testComponent, "package")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setPackagesSuspended_cycleSuspension() throws Exception {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(packageManager).addPackage("package");
+
+    devicePolicyManager.setPackagesSuspended(testComponent, new String[] {"package"}, true);
+    devicePolicyManager.setPackagesSuspended(testComponent, new String[] {"package"}, false);
+
+    assertThat(devicePolicyManager.isPackageSuspended(testComponent, "package")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isPackagesSuspended_defaultsFalse() throws Exception {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(packageManager).addPackage("package");
+
+    assertThat(devicePolicyManager.isPackageSuspended(testComponent, "package")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isPackagesSuspended_trueForSuspended() throws Exception {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(packageManager).addPackage("package");
+
+    devicePolicyManager.setPackagesSuspended(testComponent, new String[] {"package"}, true);
+
+    assertThat(devicePolicyManager.isPackageSuspended(testComponent, "package")).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isPackagesSuspended_notInstalledPackage() throws Exception {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    try {
+      devicePolicyManager.isPackageSuspended(testComponent, "not.installed");
+      fail("expected NameNotFoundException");
+    } catch (NameNotFoundException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isLinkedUser() {
+    assertThat(devicePolicyManager.getUserProvisioningState()).isEqualTo(STATE_USER_UNMANAGED);
+
+    shadowOf(devicePolicyManager).setUserProvisioningState(STATE_USER_SETUP_COMPLETE);
+    assertThat(devicePolicyManager.getUserProvisioningState()).isEqualTo(STATE_USER_SETUP_COMPLETE);
+
+    shadowOf(devicePolicyManager).setUserProvisioningState(STATE_USER_SETUP_INCOMPLETE);
+    assertThat(devicePolicyManager.getUserProvisioningState())
+        .isEqualTo(STATE_USER_SETUP_INCOMPLETE);
+
+    shadowOf(devicePolicyManager).setUserProvisioningState(STATE_USER_UNMANAGED);
+    assertThat(devicePolicyManager.getUserProvisioningState()).isEqualTo(STATE_USER_UNMANAGED);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getProfileOwnerNameAsUser() {
+    int userId = 0;
+    String orgName = "organization";
+    assertThat(devicePolicyManager.getProfileOwnerNameAsUser(userId)).isNull();
+
+    shadowOf(devicePolicyManager).setProfileOwnerName(userId, orgName);
+
+    assertThat(devicePolicyManager.getProfileOwnerNameAsUser(userId)).isEqualTo(orgName);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setPersistentPreferrecActivity_exists() {
+    ComponentName randomActivity = new ComponentName("random.package", "Activity");
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.activityInfo = new ActivityInfo();
+    resolveInfo.activityInfo.name = randomActivity.getClassName();
+    resolveInfo.activityInfo.applicationInfo = new ApplicationInfo();
+    resolveInfo.activityInfo.applicationInfo.packageName = randomActivity.getPackageName();
+
+    ResolveInfo resolveInfo2 = new ResolveInfo();
+    resolveInfo2.activityInfo = new ActivityInfo(resolveInfo.activityInfo);
+    resolveInfo.activityInfo.name = "OtherActivity";
+    shadowOf(packageManager)
+        .setResolveInfosForIntent(
+            new Intent(Intent.ACTION_MAIN), Arrays.asList(resolveInfo, resolveInfo2));
+    shadowOf(packageManager).setShouldShowActivityChooser(true);
+
+    ResolveInfo resolvedActivity =
+        packageManager.resolveActivity(new Intent(Intent.ACTION_MAIN), 0);
+
+    assertThat(resolvedActivity.activityInfo.packageName)
+        .isNotEqualTo(randomActivity.getPackageName());
+
+    devicePolicyManager.addPersistentPreferredActivity(
+        testComponent, new IntentFilter(Intent.ACTION_MAIN), randomActivity);
+
+    resolvedActivity = packageManager.resolveActivity(new Intent(Intent.ACTION_MAIN), 0);
+
+    assertThat(resolvedActivity.activityInfo.packageName)
+        .isEqualTo(randomActivity.getPackageName());
+    assertThat(resolvedActivity.activityInfo.name).isEqualTo(randomActivity.getClassName());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void clearPersistentPreferredActivity_packageNotAdded() {
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+    devicePolicyManager.clearPackagePersistentPreferredActivities(testComponent, "package");
+
+    int preferredActivitiesCount =
+        shadowOf(packageManager)
+            .getPersistentPreferredActivities(
+                new ArrayList<>(), new ArrayList<>(), testComponent.getPackageName());
+
+    assertThat(preferredActivitiesCount).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void clearPersistentPreferredActivity_packageAdded() {
+    shadowOf(devicePolicyManager).setDeviceOwner(testComponent);
+    ComponentName randomActivity = new ComponentName("random.package", "Activity");
+    devicePolicyManager.addPersistentPreferredActivity(
+        testComponent, new IntentFilter("Action"), randomActivity);
+
+    int countOfPreferred =
+        shadowOf(packageManager)
+            .getPersistentPreferredActivities(new ArrayList<>(), new ArrayList<>(), null);
+
+    assertThat(countOfPreferred).isEqualTo(1);
+
+    devicePolicyManager.clearPackagePersistentPreferredActivities(
+        testComponent, randomActivity.getPackageName());
+
+    countOfPreferred =
+        shadowOf(packageManager)
+            .getPersistentPreferredActivities(new ArrayList<>(), new ArrayList<>(), null);
+    assertThat(countOfPreferred).isEqualTo(0);
+  }
+
+  @Test
+  public void grantPolicy_true_onePolicy() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(devicePolicyManager).grantPolicy(testComponent, USES_ENCRYPTED_STORAGE);
+
+    assertThat(devicePolicyManager.hasGrantedPolicy(testComponent, USES_ENCRYPTED_STORAGE))
+        .isTrue();
+  }
+
+  @Test
+  public void grantPolicy_true_twoPolicy() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(devicePolicyManager).grantPolicy(testComponent, USES_ENCRYPTED_STORAGE);
+    shadowOf(devicePolicyManager).grantPolicy(testComponent, USES_POLICY_EXPIRE_PASSWORD);
+
+    assertThat(devicePolicyManager.hasGrantedPolicy(testComponent, USES_ENCRYPTED_STORAGE))
+        .isTrue();
+    assertThat(devicePolicyManager.hasGrantedPolicy(testComponent, USES_POLICY_EXPIRE_PASSWORD))
+        .isTrue();
+    // USES_POLICY_DISABLE_CAMERA was not granted
+    assertThat(devicePolicyManager.hasGrantedPolicy(testComponent, USES_POLICY_DISABLE_CAMERA))
+        .isFalse();
+  }
+
+  @Test
+  public void grantPolicy_false() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    assertThat(devicePolicyManager.hasGrantedPolicy(testComponent, USES_ENCRYPTED_STORAGE))
+        .isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getLockTaskPackages_notOwner() {
+    try {
+      devicePolicyManager.getLockTaskPackages(testComponent);
+      fail();
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setLockTaskPackages_notOwner() {
+    try {
+      devicePolicyManager.setLockTaskPackages(testComponent, new String[] {"allowed.package"});
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getSetLockTaskPackages() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    assertThat(devicePolicyManager.getLockTaskPackages(testComponent)).isEmpty();
+
+    devicePolicyManager.setLockTaskPackages(testComponent, new String[] {"allowed.package"});
+
+    assertThat(devicePolicyManager.getLockTaskPackages(testComponent))
+        .asList()
+        .containsExactly("allowed.package");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isLockTaskPermitted() {
+    assertThat(devicePolicyManager.isLockTaskPermitted("allowed.package")).isFalse();
+
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    devicePolicyManager.setLockTaskPackages(testComponent, new String[] {"allowed.package"});
+
+    assertThat(devicePolicyManager.isLockTaskPermitted("allowed.package")).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getAffiliationIds_notDeviceOrProfileOwner_throwsSecurityException() {
+    try {
+      devicePolicyManager.getAffiliationIds(testComponent);
+      fail("Expected SecurityException");
+    } catch (SecurityException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setAffiliationIds_notDeviceOrProfileOwner_throwsSecurityException() {
+    try {
+      Set<String> affiliationIds = ImmutableSet.of("test id");
+      devicePolicyManager.setAffiliationIds(testComponent, affiliationIds);
+      fail("Expected SecurityException");
+    } catch (SecurityException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setAffiliationIds_isProfileOwner_setsAffiliationIdsCorrectly() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    Set<String> affiliationIds = ImmutableSet.of("test id");
+
+    devicePolicyManager.setAffiliationIds(testComponent, affiliationIds);
+
+    assertThat(devicePolicyManager.getAffiliationIds(testComponent)).isEqualTo(affiliationIds);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPermissionPolicy_notDeviceOrProfileOwner_throwsSecurityException() {
+    try {
+      devicePolicyManager.getPermissionPolicy(testComponent);
+      fail("Expected SecurityException");
+    } catch (SecurityException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setPermissionPolicy_notDeviceOrProfileOwner_throwsSecurityException() {
+    try {
+      devicePolicyManager.setPermissionPolicy(testComponent, PERMISSION_POLICY_AUTO_GRANT);
+      fail("Expected SecurityException");
+    } catch (SecurityException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setPermissionPolicy_isProfileOwner_setsPermissionPolicyCorrectly() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    devicePolicyManager.setPermissionPolicy(testComponent, PERMISSION_POLICY_AUTO_GRANT);
+
+    assertThat(devicePolicyManager.getPermissionPolicy(testComponent))
+        .isEqualTo(PERMISSION_POLICY_AUTO_GRANT);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getSystemUpdatePolicyShouldReturnCorrectSetValue_nullAdmin() {
+    SystemUpdatePolicy policy = SystemUpdatePolicy.createAutomaticInstallPolicy();
+    devicePolicyManager.setSystemUpdatePolicy(null, policy);
+
+    assertThat(devicePolicyManager.getSystemUpdatePolicy()).isEqualTo(policy);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getSystemUpdatePolicyShouldReturnCorrectSetValue_nonNullAdmin() {
+    SystemUpdatePolicy policy = SystemUpdatePolicy.createAutomaticInstallPolicy();
+    devicePolicyManager.setSystemUpdatePolicy(new ComponentName("testPkg", "testCls"), policy);
+
+    assertThat(devicePolicyManager.getSystemUpdatePolicy()).isEqualTo(policy);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getSystemUpdatePolicyShouldReturnCorrectDefaultValue() {
+    assertThat(devicePolicyManager.getSystemUpdatePolicy()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getSystemUpdatePolicyShadowShouldReturnCorrectSetValue() {
+    SystemUpdatePolicy policy = SystemUpdatePolicy.createAutomaticInstallPolicy();
+    shadowOf(devicePolicyManager).setSystemUpdatePolicy(policy);
+
+    assertThat(devicePolicyManager.getSystemUpdatePolicy()).isEqualTo(policy);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getBindDeviceAdminTargetUsers_returnsEmptyByDefault() {
+    assertThat(devicePolicyManager.getBindDeviceAdminTargetUsers(null)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getBindDeviceAdminTargetUsers_returnsSetValue() {
+    List<UserHandle> targetUsers = Collections.singletonList(UserHandle.of(10));
+    shadowOf(devicePolicyManager).setBindDeviceAdminTargetUsers(targetUsers);
+
+    assertThat(devicePolicyManager.getBindDeviceAdminTargetUsers(null))
+        .containsExactlyElementsIn(targetUsers);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void bindDeviceAdminServiceAsUser_invalidUserHandle_throwsSecurityException() {
+    UserHandle targetUser = UserHandle.of(10);
+
+    Intent serviceIntent = new Intent().setPackage("dummy.package");
+    ServiceConnection conn = buildServiceConnection();
+    int flags = 0;
+
+    try {
+      devicePolicyManager.bindDeviceAdminServiceAsUser(
+          null, serviceIntent, conn, flags, targetUser);
+      fail("Expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+    assertThat(shadowOf(context).getBoundServiceConnections()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void bindDeviceAdminServiceAsUser_validUserHandle_binds() {
+    UserHandle targetUser = UserHandle.of(10);
+    shadowOf(devicePolicyManager)
+        .setBindDeviceAdminTargetUsers(Collections.singletonList(targetUser));
+
+    Intent serviceIntent = new Intent().setPackage("dummy.package");
+    ServiceConnection conn = buildServiceConnection();
+    int flags = 0;
+
+    assertThat(
+            devicePolicyManager.bindDeviceAdminServiceAsUser(
+                null, serviceIntent, conn, flags, targetUser))
+        .isTrue();
+
+    assertThat(shadowOf(context).getBoundServiceConnections()).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void addResetPasswordToken() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    boolean result =
+        shadowOf(devicePolicyManager).setResetPasswordToken(testComponent, PASSWORD_TOKEN);
+
+    assertThat(result).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void addResetPasswordToken_badToken() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    try {
+      shadowOf(devicePolicyManager).setResetPasswordToken(testComponent, new byte[13]);
+      fail("Should fail on too short token");
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isResetPasswordTokenActive() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(devicePolicyManager).setResetPasswordToken(testComponent, PASSWORD_TOKEN);
+
+    assertThat(shadowOf(devicePolicyManager).isResetPasswordTokenActive(testComponent)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isResetPasswordTokenActive_passwordSet() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    shadowOf(keyguardManager).setIsDeviceSecure(true);
+    shadowOf(devicePolicyManager).setResetPasswordToken(testComponent, PASSWORD_TOKEN);
+
+    assertThat(shadowOf(devicePolicyManager).isResetPasswordTokenActive(testComponent)).isFalse();
+
+    shadowOf(devicePolicyManager).activateResetToken(testComponent);
+
+    assertThat(shadowOf(devicePolicyManager).isResetPasswordTokenActive(testComponent)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void resetPasswordWithToken() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(devicePolicyManager).setResetPasswordToken(testComponent, PASSWORD_TOKEN);
+
+    boolean result =
+        shadowOf(devicePolicyManager)
+            .resetPasswordWithToken(testComponent, "password", PASSWORD_TOKEN, 0);
+
+    assertThat(result).isTrue();
+    assertThat(shadowOf(devicePolicyManager).getLastSetPassword()).isEqualTo("password");
+    assertThat(keyguardManager.isDeviceSecure()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void resetPasswordWithToken_noToken() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+
+    try {
+      shadowOf(devicePolicyManager)
+          .resetPasswordWithToken(testComponent, "password", PASSWORD_TOKEN, 0);
+      fail("Reset token not set");
+    } catch (IllegalStateException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void resetPasswordWithToken_noActiveToken() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(keyguardManager).setIsDeviceSecure(true);
+    shadowOf(devicePolicyManager).setResetPasswordToken(testComponent, PASSWORD_TOKEN);
+
+    try {
+      shadowOf(devicePolicyManager)
+          .resetPasswordWithToken(testComponent, "password", PASSWORD_TOKEN, 0);
+      fail("Should fail as token not activated");
+    } catch (IllegalStateException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void resetPasswordWithToken_tokenActivated() {
+    shadowOf(devicePolicyManager).setProfileOwner(testComponent);
+    shadowOf(keyguardManager).setIsDeviceSecure(true);
+    devicePolicyManager.setResetPasswordToken(testComponent, PASSWORD_TOKEN);
+    shadowOf(devicePolicyManager).activateResetToken(testComponent);
+
+    boolean result =
+        shadowOf(devicePolicyManager)
+            .resetPasswordWithToken(testComponent, "password", PASSWORD_TOKEN, 0);
+
+    assertThat(result).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setShortSupportMessage_notActiveAdmin_throwsSecurityException() {
+    try {
+      devicePolicyManager.setShortSupportMessage(testComponent, "TEST SHORT SUPPORT MESSAGE");
+      fail("expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setShortSupportMessage_messageSet() {
+    final CharSequence testMessage = "TEST SHORT SUPPORT MESSAGE";
+    shadowOf(devicePolicyManager).setActiveAdmin(testComponent);
+
+    devicePolicyManager.setShortSupportMessage(testComponent, testMessage);
+
+    assertThat(
+            devicePolicyManager
+                .getShortSupportMessage(testComponent)
+                .toString()
+                .contentEquals(testMessage))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getShortSupportMessage_notActiveAdmin_throwsSecurityException() {
+    try {
+      devicePolicyManager.getShortSupportMessage(testComponent);
+      fail("expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setLongSupportMessage_notActivieAdmin_throwsSecurityException() {
+    try {
+      devicePolicyManager.setLongSupportMessage(testComponent, "TEST LONG SUPPORT MESSAGE");
+      fail("expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setLongSupportMessage_messageSet() {
+    final CharSequence testMessage = "TEST LONG SUPPORT MESSAGE";
+    shadowOf(devicePolicyManager).setActiveAdmin(testComponent);
+
+    devicePolicyManager.setLongSupportMessage(testComponent, testMessage);
+
+    assertThat(
+            devicePolicyManager
+                .getLongSupportMessage(testComponent)
+                .toString()
+                .contentEquals(testMessage))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getLongSupportMessage_notActiveAdmin_throwsSecurityException() {
+    try {
+      devicePolicyManager.getLongSupportMessage(testComponent);
+      fail("expected SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void isOrganizationOwnedDeviceWithManagedProfile_shouldBeFalseByDefault() {
+    assertThat(devicePolicyManager.isOrganizationOwnedDeviceWithManagedProfile()).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void isOrganizationOwnedDeviceWithManagedProfile_setToTrueIfSet() {
+    Shadow.<ShadowDevicePolicyManager>extract(devicePolicyManager)
+        .setOrganizationOwnedDeviceWithManagedProfile(true);
+
+    assertThat(devicePolicyManager.isOrganizationOwnedDeviceWithManagedProfile()).isTrue();
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void getNearbyNotificationStreamingPolicy_shouldReturnSetValue() {
+    devicePolicyManager.setNearbyNotificationStreamingPolicy(
+        DevicePolicyManager.NEARBY_STREAMING_ENABLED);
+
+    assertThat(devicePolicyManager.getNearbyNotificationStreamingPolicy())
+        .isEqualTo(DevicePolicyManager.NEARBY_STREAMING_ENABLED);
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void getNearbyAppStreamingPolicy_shouldReturnSetValue() {
+    devicePolicyManager.setNearbyAppStreamingPolicy(DevicePolicyManager.NEARBY_STREAMING_DISABLED);
+
+    assertThat(devicePolicyManager.getNearbyAppStreamingPolicy())
+        .isEqualTo(DevicePolicyManager.NEARBY_STREAMING_DISABLED);
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void isUsbDataSignalingEnabled_shouldReturnSetValue() {
+    assertThat(devicePolicyManager.isUsbDataSignalingEnabled()).isTrue();
+    shadowOf(devicePolicyManager).setIsUsbDataSignalingEnabled(false);
+    assertThat(devicePolicyManager.isUsbDataSignalingEnabled()).isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getDevicePolicyManagementRoleHolderPackage_shouldReturnSetValue() {
+    shadowDevicePolicyManager.setDevicePolicyManagementRoleHolderPackage("dpm_role_holder");
+    assertThat(devicePolicyManager.getDevicePolicyManagementRoleHolderPackage())
+        .isEqualTo("dpm_role_holder");
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getDevicePolicyManagementRoleHolderPackage_defaultValue_shouldReturnNull() {
+    assertThat(devicePolicyManager.getDevicePolicyManagementRoleHolderPackage()).isNull();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void isWorkProfileProvisioningFinalized_paramsMatch_returnTrue() {
+    UserHandle userHandle = UserHandle.SYSTEM;
+    Account account = new Account("name", "type");
+    devicePolicyManager.finalizeWorkProfileProvisioning(userHandle, account);
+    assertThat(shadowDevicePolicyManager.isWorkProfileProvisioningFinalized(userHandle, account))
+        .isTrue();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void isWorkProfileProvisioningFinalized_paramsMatchWithNullMigratedAccount_returnTrue() {
+    UserHandle userHandle = UserHandle.SYSTEM;
+    devicePolicyManager.finalizeWorkProfileProvisioning(userHandle, /* migratedAccount= */ null);
+    assertThat(
+            shadowDevicePolicyManager.isWorkProfileProvisioningFinalized(
+                userHandle, /* migratedAccount= */ null))
+        .isTrue();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void isWorkProfileProvisioningFinalized_migratedAccountMismatch_returnFalse() {
+    UserHandle userHandle = UserHandle.SYSTEM;
+    Account account = new Account("name", "type");
+    devicePolicyManager.finalizeWorkProfileProvisioning(userHandle, account);
+    assertThat(
+            shadowDevicePolicyManager.isWorkProfileProvisioningFinalized(
+                userHandle, /* migratedAccount= */ null))
+        .isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void isWorkProfileProvisioningFinalized_userHandleMismatch_returnFalse() {
+    UserHandle userHandle = UserHandle.SYSTEM;
+    Account account = new Account("name", "type");
+    devicePolicyManager.finalizeWorkProfileProvisioning(userHandle, account);
+    assertThat(
+            shadowDevicePolicyManager.isWorkProfileProvisioningFinalized(
+                UserHandle.of(123), account))
+        .isFalse();
+  }
+
+  @Config(minSdk = N)
+  @Test
+  public void isWorkProfileProvisioningFinalized_defaultValue_returnFalse() {
+    UserHandle userHandle = UserHandle.SYSTEM;
+    Account account = new Account("name", "type");
+    assertThat(shadowDevicePolicyManager.isWorkProfileProvisioningFinalized(userHandle, account))
+        .isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getPolicyManagedProfiles_shouldReturnSetVal() {
+    List<UserHandle> policyManagedProfiles = Arrays.asList(UserHandle.SYSTEM);
+    shadowDevicePolicyManager.setPolicyManagedProfiles(policyManagedProfiles);
+    assertThat(devicePolicyManager.getPolicyManagedProfiles(UserHandle.SYSTEM))
+        .isEqualTo(policyManagedProfiles);
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void getPolicyManagedProfiles_defaultValue_shouldReturnEmptyList() {
+    assertThat(devicePolicyManager.getPolicyManagedProfiles(UserHandle.SYSTEM)).isEmpty();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getUserProvisioningStateForUser_shouldReturnSetValue() {
+    devicePolicyManager.setUserProvisioningState(STATE_USER_SETUP_FINALIZED, UserHandle.SYSTEM);
+    assertThat(
+            shadowDevicePolicyManager.getUserProvisioningStateForUser(
+                UserHandle.SYSTEM.getIdentifier()))
+        .isEqualTo(STATE_USER_SETUP_FINALIZED);
+  }
+
+  @Config(minSdk = N)
+  @Test
+  public void getUserProvisioningStateForUser_defaultValue_shouldReturnUnmanagedState() {
+    assertThat(
+            shadowDevicePolicyManager.getUserProvisioningStateForUser(
+                UserHandle.SYSTEM.getIdentifier()))
+        .isEqualTo(STATE_USER_UNMANAGED);
+  }
+
+  @Config(minSdk = N)
+  @Test
+  public void getUserProvisioningState_returnsSetUserProvisioningState() {
+    assertThat(devicePolicyManager.getUserProvisioningState()).isEqualTo(STATE_USER_UNMANAGED);
+
+    shadowDevicePolicyManager.setUserProvisioningState(STATE_USER_SETUP_COMPLETE);
+    assertThat(devicePolicyManager.getUserProvisioningState()).isEqualTo(STATE_USER_SETUP_COMPLETE);
+
+    shadowDevicePolicyManager.setUserProvisioningState(STATE_USER_SETUP_INCOMPLETE);
+    assertThat(devicePolicyManager.getUserProvisioningState())
+        .isEqualTo(STATE_USER_SETUP_INCOMPLETE);
+
+    shadowDevicePolicyManager.setUserProvisioningState(STATE_USER_UNMANAGED);
+    assertThat(devicePolicyManager.getUserProvisioningState()).isEqualTo(STATE_USER_UNMANAGED);
+  }
+
+  private ServiceConnection buildServiceConnection() {
+    return new ServiceConnection() {
+      @Override
+      public void onServiceConnected(ComponentName name, IBinder service) {}
+
+      @Override
+      public void onServiceDisconnected(ComponentName name) {}
+    };
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDevicePolicyResourcesManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDevicePolicyResourcesManagerTest.java
new file mode 100644
index 0000000..82b370f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDevicePolicyResourcesManagerTest.java
@@ -0,0 +1,47 @@
+package org.robolectric.shadows;
+
+import static android.content.Context.DEVICE_POLICY_SERVICE;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.admin.DevicePolicyManager;
+import android.app.admin.DevicePolicyResourcesManager;
+import android.os.Build.VERSION_CODES;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit tests for {@link ShadowDevicePolicyManager}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.TIRAMISU)
+public class ShadowDevicePolicyResourcesManagerTest {
+
+  private DevicePolicyResourcesManager devicePolicyResourcesManager;
+  private ShadowDevicePolicyResourcesManager shadowDevicePolicyResourcesManager;
+
+  @Before
+  public void setUp() {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) getApplicationContext().getSystemService(DEVICE_POLICY_SERVICE);
+    devicePolicyResourcesManager = devicePolicyManager.getResources();
+    shadowDevicePolicyResourcesManager = Shadow.extract(devicePolicyResourcesManager);
+  }
+
+  @Test
+  public void getString_returnsUserSetString() {
+    shadowDevicePolicyResourcesManager.setString("stringId", "value");
+    assertThat(devicePolicyResourcesManager.getString("stringId", () -> "default"))
+        .isEqualTo("value");
+  }
+
+  @Test
+  public void getString_whenStringSetThenUnset_doesntReturnIt() {
+    shadowDevicePolicyResourcesManager.setString("stringId", "value");
+    shadowDevicePolicyResourcesManager.setString("stringId", null);
+    assertThat(devicePolicyResourcesManager.getString("stringId", () -> "default"))
+        .isNotEqualTo("value");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDialogPreferenceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDialogPreferenceTest.java
new file mode 100644
index 0000000..65023fb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDialogPreferenceTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
+import android.preference.DialogPreference;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceScreen;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDialogPreferenceTest {
+
+  @Test
+  public void inflate_shouldCreateDialogPreference() {
+    final PreferenceScreen screen = inflatePreferenceActivity();
+    final DialogPreference preference = (DialogPreference) screen.findPreference("dialog");
+
+    assertThat(preference.getTitle().toString()).isEqualTo("Dialog Preference");
+    assertThat(preference.getSummary().toString()).isEqualTo("This is the dialog summary");
+    assertThat(preference.getDialogMessage().toString()).isEqualTo("This is the dialog message");
+    assertThat(preference.getPositiveButtonText().toString()).isEqualTo("YES");
+    assertThat(preference.getNegativeButtonText().toString()).isEqualTo("NO");
+  }
+
+  private PreferenceScreen inflatePreferenceActivity() {
+    PreferenceActivity activity = Robolectric.setupActivity(TestPreferenceActivity.class);
+    return activity.getPreferenceScreen();
+  }
+
+  @SuppressWarnings("FragmentInjection")
+  private static class TestPreferenceActivity extends PreferenceActivity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      addPreferencesFromResource(R.xml.dialog_preferences);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDialogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDialogTest.java
new file mode 100644
index 0000000..f1270f3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDialogTest.java
@@ -0,0 +1,233 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Application;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDialogTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void shouldCallOnDismissListener() {
+    final List<String> transcript = new ArrayList<>();
+
+    final Dialog dialog = new Dialog(context);
+    dialog.show();
+    dialog.setOnDismissListener(
+        dialogInListener -> {
+          assertThat(dialogInListener).isSameInstanceAs(dialog);
+          transcript.add("onDismiss called!");
+        });
+
+    dialog.dismiss();
+    shadowMainLooper().idle();
+
+    assertThat(transcript).containsExactly("onDismiss called!");
+  }
+
+  @Test
+  public void setContentViewWithViewAllowsFindById() {
+    final int viewId = 1234;
+    final Dialog dialog = new Dialog(context);
+    final View view = new View(context);
+    view.setId(viewId);
+    dialog.setContentView(view);
+
+    assertSame(view, dialog.findViewById(viewId));
+  }
+
+  @Test
+  public void shouldGetLayoutInflater() {
+    Dialog dialog = new Dialog(context);
+    assertNotNull(dialog.getLayoutInflater());
+  }
+
+  @Test
+  public void shouldCallOnStartFromShow() {
+    TestDialog dialog = new TestDialog(context);
+    dialog.show();
+
+    assertTrue(dialog.onStartCalled);
+  }
+
+  @Test
+  public void shouldSetCancelable() {
+    Dialog dialog = new Dialog(context);
+    ShadowDialog shadow = shadowOf(dialog);
+
+    dialog.setCancelable(false);
+    assertThat(shadow.isCancelable()).isFalse();
+  }
+
+  @Test
+  public void shouldDismissTheRealDialogWhenCancelled() {
+    TestDialog dialog = new TestDialog(context);
+    dialog.cancel();
+    assertThat(dialog.wasDismissed).isTrue();
+  }
+
+  @Test
+  public void shouldDefaultCancelableToTrueAsTheSDKDoes() {
+    Dialog dialog = new Dialog(context);
+    ShadowDialog shadow = shadowOf(dialog);
+
+    assertThat(shadow.isCancelable()).isTrue();
+  }
+
+  @Test
+  public void shouldOnlyCallOnCreateOnce() {
+    final List<String> transcript = new ArrayList<>();
+
+    Dialog dialog =
+        new Dialog(context) {
+          @Override
+          protected void onCreate(Bundle savedInstanceState) {
+            super.onCreate(savedInstanceState);
+            transcript.add("onCreate called");
+          }
+        };
+
+    dialog.show();
+    assertThat(transcript).containsExactly("onCreate called");
+    transcript.clear();
+
+    dialog.dismiss();
+    dialog.show();
+    assertThat(transcript).isEmpty();
+  }
+
+  @Test
+  public void show_setsLatestDialog() {
+    Dialog dialog = new Dialog(context);
+    assertNull(ShadowDialog.getLatestDialog());
+
+    dialog.show();
+
+    assertSame(dialog, ShadowDialog.getLatestDialog());
+    assertNull(ShadowAlertDialog.getLatestAlertDialog());
+  }
+
+  @Test
+  public void getLatestDialog_shouldReturnARealDialog() {
+    assertThat(ShadowDialog.getLatestDialog()).isNull();
+
+    Dialog dialog = new Dialog(context);
+    dialog.show();
+    assertThat(ShadowDialog.getLatestDialog()).isSameInstanceAs(dialog);
+  }
+
+  @Test
+  public void shouldKeepListOfOpenedDialogs() {
+    assertEquals(0, ShadowDialog.getShownDialogs().size());
+
+    TestDialog dialog = new TestDialog(context);
+    dialog.show();
+
+    assertEquals(1, ShadowDialog.getShownDialogs().size());
+    assertEquals(dialog, ShadowDialog.getShownDialogs().get(0));
+
+    TestDialog dialog2 = new TestDialog(context);
+    dialog2.show();
+
+    assertEquals(2, ShadowDialog.getShownDialogs().size());
+    assertEquals(dialog2, ShadowDialog.getShownDialogs().get(1));
+
+    dialog.dismiss();
+
+    assertEquals(2, ShadowDialog.getShownDialogs().size());
+
+    ShadowDialog.reset();
+
+    assertEquals(0, ShadowDialog.getShownDialogs().size());
+  }
+
+  @Test
+  public void shouldPopulateListOfRecentDialogsInCorrectOrder() {
+    new NestingTestDialog().show();
+
+    assertEquals(TestDialog.class, ShadowDialog.getLatestDialog().getClass());
+  }
+
+  @Test
+  public void shouldFindViewsWithinAContentViewThatWasPreviouslySet() {
+    Dialog dialog = new Dialog(context);
+    dialog.setContentView(dialog.getLayoutInflater().inflate(R.layout.main, null));
+    assertThat(dialog.<TextView>findViewById(R.id.title)).isInstanceOf(TextView.class);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void show_shouldWorkWithAPI19() {
+    Dialog dialog = new Dialog(context);
+    dialog.show();
+  }
+
+  @Test
+  public void canSetAndGetOnCancelListener() {
+    Dialog dialog = new Dialog(context);
+    DialogInterface.OnCancelListener onCancelListener = dialog1 -> {};
+    dialog.setOnCancelListener(onCancelListener);
+    assertThat(onCancelListener).isSameInstanceAs(shadowOf(dialog).getOnCancelListener());
+  }
+
+  private static class TestDialog extends Dialog {
+    boolean onStartCalled = false;
+    boolean wasDismissed = false;
+
+    public TestDialog(Context context) {
+      super(context);
+    }
+
+    @Override
+    protected void onStart() {
+      onStartCalled = true;
+    }
+
+    @Override public void dismiss() {
+      super.dismiss();
+      wasDismissed = true;
+    }
+  }
+
+  private static class NestingTestDialog extends Dialog {
+    public NestingTestDialog() {
+      super(ApplicationProvider.getApplicationContext());
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      new TestDialog(getContext()).show();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDiscoverySessionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDiscoverySessionTest.java
new file mode 100644
index 0000000..4a6fcdd
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDiscoverySessionTest.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.wifi.aware.DiscoverySession;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowDiscoverySession}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O)
+public class ShadowDiscoverySessionTest {
+
+  @Test
+  public void canCreateDiscoverySessionViaNewInstance() {
+    DiscoverySession discoverySession = ShadowDiscoverySession.newInstance();
+    assertThat(discoverySession).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayEventReceiverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayEventReceiverTest.java
new file mode 100644
index 0000000..d3bc403
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayEventReceiverTest.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Looper;
+import android.view.DisplayEventReceiver;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import dalvik.system.CloseGuard;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowDisplayEventReceiver}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowDisplayEventReceiverTest {
+
+  static class MyDisplayEventReceiver extends DisplayEventReceiver {
+
+    public MyDisplayEventReceiver(Looper looper) {
+      super(looper);
+    }
+
+    @Override
+    protected void finalize() throws Throwable {
+      super.finalize();
+    }
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN)
+  public void closeGuard_autoCloses() throws Throwable {
+    final AtomicBoolean closeGuardWarned = new AtomicBoolean(false);
+    CloseGuard.Reporter originalReporter = CloseGuard.getReporter();
+    try {
+      CloseGuard.setReporter((s, throwable) -> closeGuardWarned.set(true));
+      new MyDisplayEventReceiver(Looper.getMainLooper()).finalize();
+      assertThat(closeGuardWarned.get()).isFalse();
+    } finally {
+      CloseGuard.setReporter(originalReporter);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayHashManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayHashManagerTest.java
new file mode 100644
index 0000000..9d8a038
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayHashManagerTest.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Parcel;
+import android.view.displayhash.DisplayHash;
+import android.view.displayhash.DisplayHashManager;
+import android.view.displayhash.VerifiedDisplayHash;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowDisplayHashManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = 31)
+public final class ShadowDisplayHashManagerTest {
+
+  private DisplayHashManager displayHashManager;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+
+    displayHashManager = context.getSystemService(DisplayHashManager.class);
+  }
+
+  @Test
+  public void getSupportedHashAlgorithms() {
+    assertThat(displayHashManager.getSupportedHashAlgorithms()).containsExactly("PHASH");
+  }
+
+  @Test
+  public void verifyDisplayHash() {
+    Parcel parcel = Parcel.obtain();
+    parcel.writeLong(12345L);
+    parcel.writeTypedObject(new Rect(0, 0, 100, 100), 0);
+    parcel.writeString("PHASH");
+    parcel.writeByteArray(new byte[15]);
+    parcel.writeByteArray(new byte[21]);
+    parcel.setDataPosition(0);
+    DisplayHash displayHash = DisplayHash.CREATOR.createFromParcel(parcel);
+
+    assertThat(displayHashManager.verifyDisplayHash(displayHash)).isNull();
+
+    VerifiedDisplayHash verifiedDisplayHash =
+        new VerifiedDisplayHash(54321L, new Rect(0, 0, 100, 100), "PHASH", new byte[8]);
+    ShadowDisplayHashManager.setVerifyDisplayHashResult(verifiedDisplayHash);
+    assertThat(displayHashManager.verifyDisplayHash(displayHash)).isEqualTo(verifiedDisplayHash);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayManagerGlobalTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayManagerGlobalTest.java
new file mode 100644
index 0000000..530dccf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayManagerGlobalTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.display.DisplayManagerGlobal;
+import android.view.Display;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+
+/** Unit tests for {@link ShadowDisplayManagerGlobal} */
+@RunWith(AndroidJUnit4.class)
+public class ShadowDisplayManagerGlobalTest {
+
+  @LazyApplication(LazyLoad.ON)
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testDisplayManagerGlobalIsLazyLoaded() {
+    assertThat(ShadowDisplayManagerGlobal.getGlobalInstance()).isNull();
+    assertThat(DisplayManagerGlobal.getInstance().getRealDisplay(Display.DEFAULT_DISPLAY))
+        .isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayManagerTest.java
new file mode 100644
index 0000000..5ab3cbd
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayManagerTest.java
@@ -0,0 +1,483 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowDisplayManagerTest.HideFromJB.getGlobal;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.hardware.display.BrightnessChangeEvent;
+import android.hardware.display.BrightnessConfiguration;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayManagerGlobal;
+import android.os.Build;
+import android.view.Display;
+import android.view.DisplayInfo;
+import android.view.Surface;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDisplayManagerTest {
+
+  private DisplayManager instance;
+
+  @Before
+  public void setUp() throws Exception {
+    instance =
+        (DisplayManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+  }
+
+  @Test
+  @Config(maxSdk = JELLY_BEAN)
+  public void notSupportedInJellyBean() {
+    try {
+      ShadowDisplayManager.removeDisplay(0);
+      fail("Expected Exception thrown");
+    } catch (UnsupportedOperationException e) {
+      assertThat(e).hasMessageThat().contains("displays not supported in Jelly Bean");
+    }
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getDisplayInfo_shouldReturnCopy() {
+    DisplayInfo displayInfo = getGlobal().getDisplayInfo(Display.DEFAULT_DISPLAY);
+    int origAppWidth = displayInfo.appWidth;
+    displayInfo.appWidth++;
+    assertThat(getGlobal().getDisplayInfo(Display.DEFAULT_DISPLAY).appWidth)
+        .isEqualTo(origAppWidth);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void forNonexistentDisplay_getDisplayInfo_shouldReturnNull() {
+    assertThat(getGlobal().getDisplayInfo(3)).isEqualTo(null);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void forNonexistentDisplay_changeDisplay_shouldThrow() {
+    try {
+      ShadowDisplayManager.changeDisplay(3, "");
+      fail("Expected Exception thrown");
+    } catch (IllegalStateException e) {
+      assertThat(e).hasMessageThat().contains("no display 3");
+    }
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void forNonexistentDisplay_removeDisplay_shouldThrow() {
+    try {
+      ShadowDisplayManager.removeDisplay(3);
+      fail("Expected Exception thrown");
+    } catch (IllegalStateException e) {
+      assertThat(e).hasMessageThat().contains("no display 3");
+    }
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void addDisplay() {
+    int displayId = ShadowDisplayManager.addDisplay("w100dp-h200dp");
+    assertThat(displayId).isGreaterThan(0);
+
+    DisplayInfo di = getGlobal().getDisplayInfo(displayId);
+    assertThat(di.appWidth).isEqualTo(100);
+    assertThat(di.appHeight).isEqualTo(200);
+
+    Display display = instance.getDisplay(displayId);
+    assertThat(display.getDisplayId()).isEqualTo(displayId);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void addDisplay_shouldNotifyListeners() {
+    List<String> events = new ArrayList<>();
+    instance.registerDisplayListener(new MyDisplayListener(events), null);
+    int displayId = ShadowDisplayManager.addDisplay("w100dp-h200dp");
+    assertThat(events).containsExactly("Added " + displayId);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void changeDisplay_shouldUpdateSmallestAndLargestNominalWidthAndHeight() {
+    Point smallest = new Point();
+    Point largest = new Point();
+
+    ShadowDisplay.getDefaultDisplay().getCurrentSizeRange(smallest, largest);
+    assertThat(smallest).isEqualTo(new Point(320, 320));
+    assertThat(largest).isEqualTo(new Point(470, 470));
+
+    Display display = ShadowDisplay.getDefaultDisplay();
+    ShadowDisplay shadowDisplay = Shadow.extract(display);
+    shadowDisplay.setWidth(display.getWidth() - 10);
+    shadowDisplay.setHeight(display.getHeight() - 10);
+
+    ShadowDisplay.getDefaultDisplay().getCurrentSizeRange(smallest, largest);
+    assertThat(smallest).isEqualTo(new Point(310, 310));
+    assertThat(largest).isEqualTo(new Point(460, 460));
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void withQualifiers_changeDisplay_shouldUpdateSmallestAndLargestNominalWidthAndHeight() {
+    Point smallest = new Point();
+    Point largest = new Point();
+
+    Display display = ShadowDisplay.getDefaultDisplay();
+    display.getCurrentSizeRange(smallest, largest);
+    assertThat(smallest).isEqualTo(new Point(320, 320));
+    assertThat(largest).isEqualTo(new Point(470, 470));
+
+    ShadowDisplayManager.changeDisplay(display.getDisplayId(), "w310dp-h460dp");
+
+    display.getCurrentSizeRange(smallest, largest);
+    assertThat(smallest).isEqualTo(new Point(310, 310));
+    assertThat(largest).isEqualTo(new Point(460, 460));
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void changeAndRemoveDisplay_shouldNotifyListeners() {
+    List<String> events = new ArrayList<>();
+    instance.registerDisplayListener(new MyDisplayListener(events), null);
+    int displayId = ShadowDisplayManager.addDisplay("w100dp-h200dp");
+
+    ShadowDisplayManager.changeDisplay(displayId, "w300dp-h400dp");
+
+    Display display = getGlobal().getRealDisplay(displayId);
+    assertThat(display.getWidth()).isEqualTo(300);
+    assertThat(display.getHeight()).isEqualTo(400);
+    assertThat(display.getOrientation()).isEqualTo(Surface.ROTATION_0);
+
+    ShadowDisplayManager.removeDisplay(displayId);
+
+    assertThat(events).containsExactly(
+        "Added " + displayId,
+        "Changed " + displayId,
+        "Removed " + displayId);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void changeDisplay_shouldAllowPartialChanges() {
+    List<String> events = new ArrayList<>();
+    instance.registerDisplayListener(new MyDisplayListener(events), null);
+    int displayId = ShadowDisplayManager.addDisplay("w100dp-h200dp");
+
+    ShadowDisplayManager.changeDisplay(displayId, "+h201dp-land");
+
+    Display display = getGlobal().getRealDisplay(displayId);
+    assertThat(display.getWidth()).isEqualTo(201);
+    assertThat(display.getHeight()).isEqualTo(100);
+    assertThat(display.getOrientation()).isEqualTo(Surface.ROTATION_90);
+
+    assertThat(events).containsExactly(
+        "Added " + displayId,
+        "Changed " + displayId);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void modeBuilder_setsModeParameters() {
+    int modeId = 5;
+    int width = 500;
+    int height = 1000;
+    float refreshRate = 60.f;
+    Display.Mode mode =
+        ShadowDisplayManager.ModeBuilder.modeBuilder(modeId)
+            .setWidth(width)
+            .setHeight(height)
+            .setRefreshRate(refreshRate)
+            .build();
+    assertThat(mode.getPhysicalWidth()).isEqualTo(width);
+    assertThat(mode.getPhysicalHeight()).isEqualTo(height);
+    assertThat(mode.getModeId()).isEqualTo(modeId);
+    assertThat(mode.getRefreshRate()).isEqualTo(refreshRate);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void setSupportedModes_addsOneDisplayMode() {
+    List<String> events = new ArrayList<>();
+    instance.registerDisplayListener(new MyDisplayListener(events), /* handler= */ null);
+    int displayId = ShadowDisplayManager.addDisplay(/* qualifiersStr= */ "w100dp-h200dp");
+
+    Display.Mode mode =
+        ShadowDisplayManager.ModeBuilder.modeBuilder(0)
+            .setWidth(500)
+            .setHeight(500)
+            .setRefreshRate(60)
+            .build();
+    ShadowDisplayManager.setSupportedModes(displayId, mode);
+
+    Display.Mode[] modes = getGlobal().getRealDisplay(displayId).getSupportedModes();
+    assertThat(modes).hasLength(1);
+    assertThat(modes).asList().containsExactly(mode);
+
+    assertThat(events).containsExactly("Added " + displayId, "Changed " + displayId);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void setSupportedModes_addsMultipleDisplayModes() {
+    List<String> events = new ArrayList<>();
+    instance.registerDisplayListener(new MyDisplayListener(events), /* handler= */ null);
+    int displayId = ShadowDisplayManager.addDisplay(/* qualifiersStr= */ "w100dp-h200dp");
+
+    Display.Mode[] modesToSet =
+        new Display.Mode[] {
+          ShadowDisplayManager.ModeBuilder.modeBuilder(0)
+              .setWidth(500)
+              .setHeight(500)
+              .setRefreshRate(60)
+              .build(),
+          ShadowDisplayManager.ModeBuilder.modeBuilder(0)
+              .setWidth(1000)
+              .setHeight(1500)
+              .setRefreshRate(120)
+              .build()
+        };
+    ShadowDisplayManager.setSupportedModes(displayId, modesToSet);
+
+    Display.Mode[] modes = getGlobal().getRealDisplay(displayId).getSupportedModes();
+    assertThat(modes).hasLength(modesToSet.length);
+    assertThat(modes).asList().containsExactlyElementsIn(modesToSet);
+
+    assertThat(events).containsExactly("Added " + displayId, "Changed " + displayId);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_defaultValue_shouldReturnOne() {
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(1.0f);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_setToZero_shouldReturnZero() {
+    instance.setSaturationLevel(0.0f);
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(0.0f);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_setToHalf_shouldReturnHalf() {
+    instance.setSaturationLevel(0.5f);
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(0.5f);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_setToOne_shouldReturnOne() {
+    instance.setSaturationLevel(0.0f);
+    instance.setSaturationLevel(1.0f);
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(1.0f);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_setToZeroViaShadow_shouldReturnZero() {
+    shadowOf(instance).setSaturationLevel(0.0f);
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(0.0f);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_setToHalfViaShadow_shouldReturnHalf() {
+    shadowOf(instance).setSaturationLevel(0.5f);
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(0.5f);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getSaturationLevel_setToOneViaShadow_shouldReturnOne() {
+    shadowOf(instance).setSaturationLevel(0.0f);
+    shadowOf(instance).setSaturationLevel(1.0f);
+    assertThat(shadowOf(instance).getSaturationLevel()).isEqualTo(1.0f);
+  }
+
+  @Test @Config(minSdk = P)
+  public void setSaturationLevel_setToValueGreaterThanOne_shouldThrow() {
+    try {
+      instance.setSaturationLevel(1.1f);
+      fail("Expected IllegalArgumentException thrown");
+    } catch (IllegalArgumentException expected) {}
+  }
+
+  @Test @Config(minSdk = P)
+  public void setSaturationLevel_setToNegativeValue_shouldThrow() {
+    try {
+      instance.setSaturationLevel(-0.1f);
+      fail("Expected IllegalArgumentException thrown");
+    } catch (IllegalArgumentException expected) {}
+  }
+
+  @Test @Config(minSdk = P)
+  public void setSaturationLevel_setToValueGreaterThanOneViaShadow_shouldThrow() {
+    try {
+      shadowOf(instance).setSaturationLevel(1.1f);
+      fail("Expected IllegalArgumentException thrown");
+    } catch (IllegalArgumentException expected) {}
+  }
+
+  @Test @Config(minSdk = P)
+  public void setSaturationLevel_setToNegativevalueViaShadow_shouldThrow() {
+    try {
+      shadowOf(instance).setSaturationLevel(-0.1f);
+      fail("Expected IllegalArgumentException thrown");
+    } catch (IllegalArgumentException expected) {}
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getDefaultBrightnessConfiguration_notSetViaShadow_shouldReturnNull() {
+    assertThat(instance.getDefaultBrightnessConfiguration()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getDefaultBrightnessConfiguration_setViaShadow_shouldReturnValueSet() {
+    BrightnessConfiguration config =
+        new BrightnessConfiguration.Builder(
+                /* lux= */ new float[] {0.0f, 5000.0f}, /* nits= */ new float[] {2.0f, 400.0f})
+            .build();
+    ShadowDisplayManager.setDefaultBrightnessConfiguration(config);
+    assertThat(instance.getDefaultBrightnessConfiguration()).isEqualTo(config);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getBrightnessConfiguration_unset_shouldReturnDefault() {
+    BrightnessConfiguration config =
+        new BrightnessConfiguration.Builder(
+                /* lux= */ new float[] {0.0f, 5000.0f}, /* nits= */ new float[] {2.0f, 400.0f})
+            .build();
+    ShadowDisplayManager.setDefaultBrightnessConfiguration(config);
+    assertThat(instance.getBrightnessConfiguration()).isEqualTo(config);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getBrightnessConfiguration_setToNull_shouldReturnDefault() {
+    BrightnessConfiguration config =
+        new BrightnessConfiguration.Builder(
+                /* lux= */ new float[] {0.0f, 5000.0f}, /* nits= */ new float[] {2.0f, 400.0f})
+            .build();
+    ShadowDisplayManager.setDefaultBrightnessConfiguration(config);
+    instance.setBrightnessConfiguration(null);
+    assertThat(instance.getBrightnessConfiguration()).isEqualTo(config);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getBrightnessConfiguration_setToValue_shouldReturnValue() {
+    BrightnessConfiguration defaultConfig =
+        new BrightnessConfiguration.Builder(
+                /* lux= */ new float[] {0.0f, 5000.0f}, /* nits= */ new float[] {2.0f, 400.0f})
+            .build();
+    BrightnessConfiguration setConfig =
+        new BrightnessConfiguration.Builder(
+                /* lux= */ new float[] {0.0f, 2500.0f, 6000.0f},
+                /* nits= */ new float[] {10.0f, 300.0f, 450.0f})
+            .build();
+    ShadowDisplayManager.setDefaultBrightnessConfiguration(defaultConfig);
+    instance.setBrightnessConfiguration(setConfig);
+    assertThat(instance.getBrightnessConfiguration()).isEqualTo(setConfig);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getBrightnessEvent_unset_shouldReturnEmpty() {
+    assertThat(instance.getBrightnessEvents()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getBrightnessEvent_setToValue_shouldReturnValue() {
+    List<BrightnessChangeEvent> events = new ArrayList<>();
+    events.add(
+        new BrightnessChangeEventBuilder()
+            .setBrightness(230)
+            .setTimeStamp(999123L)
+            .setPackageName("somepackage.com")
+            .setUserId(0)
+            .setLuxValues(new float[] {1.0f, 2.0f, 3.0f, 4.0f})
+            .setLuxTimestamps(new long[] {1000L, 2000L, 3000L, 4000L})
+            .setBatteryLevel(0.8f)
+            .setPowerBrightnessFactor(1.0f)
+            .setNightMode(false)
+            .setColorTemperature(0)
+            .setLastBrightness(100)
+            .setIsDefaultBrightnessConfig(true)
+            .setUserBrightnessPoint(false)
+            .setColorValues(new long[] {35L, 45L, 25L, 10L}, 10000L)
+            .build());
+    events.add(
+        new BrightnessChangeEventBuilder()
+            .setBrightness(1000)
+            .setTimeStamp(1000123L)
+            .setPackageName("anotherpackage.com")
+            .setUserId(0)
+            .setLuxValues(new float[] {1.0f, 2.0f, 3.0f, 4.0f})
+            .setLuxTimestamps(new long[] {1000L, 2000L, 3000L, 4000L})
+            .setBatteryLevel(0.8f)
+            .setPowerBrightnessFactor(1.0f)
+            .setNightMode(false)
+            .setColorTemperature(0)
+            .setLastBrightness(300)
+            .setIsDefaultBrightnessConfig(true)
+            .setUserBrightnessPoint(true)
+            .setColorValues(new long[] {35L, 45L, 25L, 10L}, 10000L)
+            .build());
+
+    ShadowDisplayManager.setBrightnessEvents(events);
+    assertThat(instance.getBrightnessEvents()).containsExactlyElementsIn(events);
+  }
+
+  // because DisplayManagerGlobal don't exist in Jelly Bean,
+  // and we don't want them resolved as part of the test class.
+  static class HideFromJB {
+    public static DisplayManagerGlobal getGlobal() {
+      return DisplayManagerGlobal.getInstance();
+    }
+  }
+
+  private static class MyDisplayListener implements DisplayManager.DisplayListener {
+    private final List<String> events;
+
+    MyDisplayListener(List<String> events) {
+      this.events = events;
+    }
+
+    @Override
+    public void onDisplayAdded(int displayId) {
+      events.add("Added " + displayId);
+    }
+
+    @Override
+    public void onDisplayRemoved(int displayId) {
+      events.add("Removed " + displayId);
+    }
+
+    @Override
+    public void onDisplayChanged(int displayId) {
+      events.add("Changed " + displayId);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayTest.java
new file mode 100644
index 0000000..762cb68
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDisplayTest.java
@@ -0,0 +1,219 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import android.graphics.Insets;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.hardware.display.DisplayManagerGlobal;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.Display.HdrCapabilities;
+import android.view.DisplayCutout;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN_MR1)
+public class ShadowDisplayTest {
+
+  private Display display;
+  private ShadowDisplay shadow;
+
+  @Before
+  public void setUp() throws Exception {
+    display = DisplayManagerGlobal.getInstance().getRealDisplay(Display.DEFAULT_DISPLAY);
+    shadow = Shadows.shadowOf(display);
+  }
+
+  @Test
+  public void shouldProvideDisplayMetrics() {
+    shadow.setDensity(1.5f);
+    shadow.setDensityDpi(DisplayMetrics.DENSITY_HIGH);
+    shadow.setScaledDensity(1.6f);
+    shadow.setWidth(1024);
+    shadow.setHeight(600);
+    shadow.setRealWidth(1400);
+    shadow.setRealHeight(900);
+    shadow.setXdpi(183.0f);
+    shadow.setYdpi(184.0f);
+    shadow.setRefreshRate(123f);
+
+    DisplayMetrics metrics = new DisplayMetrics();
+
+    display.getMetrics(metrics);
+
+    assertEquals(1.5f, metrics.density, 0.05);
+    assertEquals(DisplayMetrics.DENSITY_HIGH, metrics.densityDpi);
+    assertEquals(1.6f, metrics.scaledDensity, 0.05);
+    assertEquals(1024, metrics.widthPixels);
+    assertEquals(600, metrics.heightPixels);
+    assertEquals(183.0f, metrics.xdpi, 0.05);
+    assertEquals(184.0f, metrics.ydpi, 0.05);
+
+    metrics = new DisplayMetrics();
+
+    display.getRealMetrics(metrics);
+
+    assertEquals(1.5f, metrics.density, 0.05);
+    assertEquals(DisplayMetrics.DENSITY_HIGH, metrics.densityDpi);
+    assertEquals(1.6f, metrics.scaledDensity, 0.05);
+    assertEquals(1400, metrics.widthPixels);
+    assertEquals(900, metrics.heightPixels);
+    assertEquals(183.0f, metrics.xdpi, 0.05);
+    assertEquals(184.0f, metrics.ydpi, 0.05);
+
+    assertEquals(0, 123f, display.getRefreshRate());
+  }
+
+  @Test
+  public void changedStateShouldApplyToOtherInstancesOfSameDisplay() {
+    shadow.setName("another name");
+    shadow.setWidth(1024);
+    shadow.setHeight(600);
+
+    display = DisplayManagerGlobal.getInstance().getRealDisplay(Display.DEFAULT_DISPLAY);
+    assertEquals(1024, display.getWidth());
+    assertEquals(600, display.getHeight());
+    assertEquals("another name", display.getName());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void stateChangeShouldApplyToOtherInstancesOfSameDisplay_postKitKatFields() {
+    shadow.setState(Display.STATE_DOZE_SUSPEND);
+
+    display = DisplayManagerGlobal.getInstance().getRealDisplay(Display.DEFAULT_DISPLAY);
+    assertEquals(Display.STATE_DOZE_SUSPEND, display.getState());
+  }
+
+  @Test
+  public void shouldProvideDisplaySize() {
+    Point outSmallestSize = new Point();
+    Point outLargestSize = new Point();
+    Point outSize = new Point();
+    Rect outRect = new Rect();
+
+    shadow.setWidth(400);
+    shadow.setHeight(600);
+    shadow.setRealWidth(480);
+    shadow.setRealHeight(800);
+
+    display.getCurrentSizeRange(outSmallestSize, outLargestSize);
+    assertEquals(400, outSmallestSize.x);
+    assertEquals(400, outSmallestSize.y);
+    assertEquals(600, outLargestSize.x);
+    assertEquals(600, outLargestSize.y);
+
+    display.getSize(outSize);
+    assertEquals(400, outSize.x);
+    assertEquals(600, outSize.y);
+
+    display.getRectSize(outRect);
+    assertEquals(400, outRect.width());
+    assertEquals(600, outRect.height());
+
+    display.getRealSize(outSize);
+    assertEquals(480, outSize.x);
+    assertEquals(800, outSize.y);
+  }
+
+  @Test
+  public void shouldProvideWeirdDisplayInformation() {
+    shadow.setName("foo");
+    shadow.setFlags(123);
+
+    assertEquals("foo", display.getName());
+    assertEquals(123, display.getFlags());
+
+    display = DisplayManagerGlobal.getInstance().getRealDisplay(Display.DEFAULT_DISPLAY);
+    assertEquals(123, display.getFlags());
+  }
+
+  /**
+   * The {@link android.view.Display#getOrientation()} method is deprecated, but for
+   * testing purposes, return the value gotten from {@link android.view.Display#getRotation()}
+   */
+  @Test
+  public void deprecatedGetOrientation_returnsGetRotation() {
+    int testValue = 33;
+    shadow.setRotation(testValue);
+    assertEquals(testValue, display.getOrientation());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setDisplayHdrCapabilities_shouldReturnHdrCapabilities() {
+    Display display = ShadowDisplay.getDefaultDisplay();
+    int[] hdrCapabilities =
+        new int[] {HdrCapabilities.HDR_TYPE_HDR10, HdrCapabilities.HDR_TYPE_DOLBY_VISION};
+    shadow.setDisplayHdrCapabilities(
+        display.getDisplayId(),
+        /* maxLuminance= */ 100f,
+        /* maxAverageLuminance= */ 100f,
+        /* minLuminance= */ 100f,
+        hdrCapabilities);
+
+    HdrCapabilities capabilities = display.getHdrCapabilities();
+
+    assertThat(capabilities).isNotNull();
+    assertThat(capabilities.getSupportedHdrTypes()).isEqualTo(hdrCapabilities);
+    assertThat(capabilities.getDesiredMaxAverageLuminance()).isEqualTo(100f);
+    assertThat(capabilities.getDesiredMaxLuminance()).isEqualTo(100f);
+    assertThat(capabilities.getDesiredMinLuminance()).isEqualTo(100f);
+  }
+
+  @Test
+  @Config(maxSdk = M)
+  public void setDisplayHdrCapabilities_shouldThrowUnSupportedOperationExceptionPreN() {
+    Display display = ShadowDisplay.getDefaultDisplay();
+    int[] hdrCapabilities =
+        new int[] {HdrCapabilities.HDR_TYPE_HDR10, HdrCapabilities.HDR_TYPE_DOLBY_VISION};
+    try {
+      shadow.setDisplayHdrCapabilities(
+          display.getDisplayId(),
+          /* maxLuminance= */ 100f,
+          /* maxAverageLuminance= */ 100f,
+          /* minLuminance= */ 100f,
+          hdrCapabilities);
+      fail();
+    } catch (UnsupportedOperationException e) {
+      assertThat(e).hasMessageThat().contains("HDR capabilities are not supported below Android N");
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setDisplayHdrCapabilities_shouldntThrowUnSupportedOperationExceptionNPlus() {
+    Display display = ShadowDisplay.getDefaultDisplay();
+    int[] hdrCapabilities =
+        new int[] {HdrCapabilities.HDR_TYPE_HDR10, HdrCapabilities.HDR_TYPE_DOLBY_VISION};
+
+    shadow.setDisplayHdrCapabilities(
+        display.getDisplayId(),
+        /* maxLuminance= */ 100f,
+        /* maxAverageLuminance= */ 100f,
+        /* minLuminance= */ 100f,
+        hdrCapabilities);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setDisplayCutout_returnsCutout() {
+    DisplayCutout cutout = new DisplayCutout(Insets.of(0, 100, 0, 100), null, null, null, null);
+    shadow.setDisplayCutout(cutout);
+
+    assertEquals(cutout, display.getCutout());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDownloadManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDownloadManagerTest.java
new file mode 100644
index 0000000..914c8eb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDownloadManagerTest.java
@@ -0,0 +1,397 @@
+package org.robolectric.shadows;
+
+import static android.app.DownloadManager.Request;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.DownloadManager;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Pair;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowDownloadManager.CompletedDownload;
+import org.robolectric.shadows.ShadowDownloadManager.ShadowRequest;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDownloadManagerTest {
+
+  private final Uri uri = Uri.parse("http://example.com/foo.mp4");
+  private final Uri destination = Uri.parse("file:///storage/foo.mp4");
+  private final Request request = new Request(uri);
+  private final ShadowRequest shadow = shadowOf(request);
+
+  @Test
+  public void request_shouldGetUri() {
+    assertThat(shadow.getUri().toString()).isEqualTo("http://example.com/foo.mp4");
+  }
+
+  @Test
+  public void request_shouldGetDestinationUri() {
+    request.setDestinationUri(Uri.parse("/storage/media/foo.mp4"));
+    assertThat(shadow.getDestination().toString()).isEqualTo("/storage/media/foo.mp4");
+  }
+
+  @Test
+  public void request_shouldGetTitle() {
+    request.setTitle("Title");
+    assertThat(shadow.getTitle().toString()).isEqualTo("Title");
+  }
+
+  @Test
+  public void request_shouldGetDescription() {
+    request.setDescription("Description");
+    assertThat(shadow.getDescription().toString()).isEqualTo("Description");
+  }
+
+  @Test
+  public void request_shouldGetMimeType() {
+    request.setMimeType("application/json");
+    assertThat(shadow.getMimeType().toString()).isEqualTo("application/json");
+  }
+
+  @Test
+  public void request_shouldGetRequestHeaders() {
+    request.addRequestHeader("Authorization", "Bearer token");
+    List<Pair<String, String>> headers = shadow.getRequestHeaders();
+    assertThat(headers).hasSize(1);
+    assertThat(headers.get(0).first).isEqualTo("Authorization");
+    assertThat(headers.get(0).second).isEqualTo("Bearer token");
+  }
+
+  @Test
+  public void request_shouldGetNotificationVisibility() {
+    request.setNotificationVisibility(Request.VISIBILITY_VISIBLE);
+    assertThat(shadow.getNotificationVisibility()).isEqualTo(Request.VISIBILITY_VISIBLE);
+  }
+
+  @Test
+  public void request_shouldGetAllowedNetworkTypes() {
+    request.setAllowedNetworkTypes(Request.NETWORK_BLUETOOTH);
+    assertThat(shadow.getAllowedNetworkTypes()).isEqualTo(Request.NETWORK_BLUETOOTH);
+  }
+
+  @Test
+  public void request_shouldGetAllowedOverRoaming() {
+    request.setAllowedOverRoaming(true);
+    assertThat(shadow.getAllowedOverRoaming()).isTrue();
+  }
+
+  @Test
+  public void request_shouldGetAllowedOverMetered() {
+    request.setAllowedOverMetered(true);
+    assertThat(shadow.getAllowedOverMetered()).isTrue();
+  }
+
+  @Test
+  public void request_shouldGetVisibleInDownloadsUi() {
+    request.setVisibleInDownloadsUi(true);
+    assertThat(shadow.getVisibleInDownloadsUi()).isTrue();
+  }
+
+  @Test
+  public void enqueue_shouldAddRequest() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id = manager.enqueue(request);
+
+    assertThat(manager.getRequestCount()).isEqualTo(1);
+    assertThat(manager.getRequest(id)).isEqualTo(request);
+  }
+
+  @Test
+  public void query_shouldReturnCursor() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id = manager.enqueue(request);
+
+    Cursor cursor = manager.query(new DownloadManager.Query().setFilterById(id));
+    assertThat(cursor.getCount()).isEqualTo(1);
+    assertThat(cursor.moveToNext()).isTrue();
+  }
+
+  @Test
+  public void query_shouldReturnColumnIndices() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id = manager.enqueue(request.setDestinationUri(destination));
+    Cursor cursor = manager.query(new DownloadManager.Query().setFilterById(id));
+
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_URI)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_FILENAME)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)).isAtLeast(0);
+    assertThat(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)).isAtLeast(0);
+  }
+
+  @Test
+  public void query_shouldReturnColumnValues() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id = manager.enqueue(request.setDestinationUri(destination));
+    Cursor cursor = manager.query(new DownloadManager.Query().setFilterById(id));
+
+    cursor.moveToNext();
+    assertThat(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI)))
+        .isEqualTo(uri.toString());
+    assertThat(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)))
+        .isEqualTo(destination.toString());
+  }
+
+  @Test
+  public void query_shouldHandleEmptyIds() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    assertThat(manager.query(new DownloadManager.Query())).isNotNull();
+  }
+
+  @Test
+  public void query_shouldReturnAll() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    manager.enqueue(request.setDestinationUri(destination));
+    Uri secondUri = Uri.parse("http://example.com/foo2.mp4");
+    Uri secondDestination = Uri.parse("file:///storage/foo2.mp4");
+    Request secondRequest = new Request(secondUri);
+    manager.enqueue(secondRequest.setDestinationUri(secondDestination));
+    Cursor cursor = manager.query(new DownloadManager.Query());
+
+    cursor.moveToNext();
+    assertThat(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI)))
+        .isEqualTo(uri.toString());
+    assertThat(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)))
+        .isEqualTo(destination.toString());
+
+    cursor.moveToNext();
+    assertThat(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI)))
+        .isEqualTo(secondUri.toString());
+    assertThat(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)))
+        .isEqualTo(secondDestination.toString());
+  }
+
+  @Test
+  public void query_shouldGetTotalSizeAndBytesSoFar() {
+    long currentBytes = 500L;
+    long totalSize = 1000L;
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id = manager.enqueue(request.setDestinationUri(destination));
+    shadow.setTotalSize(totalSize);
+    shadow.setBytesSoFar(currentBytes);
+    Cursor cursor = manager.query(new DownloadManager.Query().setFilterById(id));
+
+    cursor.moveToNext();
+    assertThat(cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)))
+        .isEqualTo(totalSize);
+    assertThat(
+            cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)))
+        .isEqualTo(currentBytes);
+  }
+
+  @Test
+  public void request_shouldSetDestinationInExternalPublicDir_publicDirectories() throws Exception {
+    shadow.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "foo.mp4");
+
+    assertThat(shadow.getDestination().getLastPathSegment()).isEqualTo("foo.mp4");
+  }
+
+  @Config(minSdk = Q)
+  @Test(expected = IllegalStateException.class)
+  public void request_shouldNotSetDestinationInExternalPublicDir_privateDirectories()
+      throws Exception {
+    shadow.setDestinationInExternalPublicDir("bar", "foo.mp4");
+  }
+
+  @Test
+  public void getRequest_doesNotReturnRemovedRequests() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id = manager.enqueue(request);
+
+    manager.remove(id);
+
+    assertThat(manager.getRequest(id)).isNull();
+  }
+
+  @Test
+  public void addCompletedDownload_requiresNonNullNonEmptyTitle() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                null,
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                "//storage/path/to/Title.pdf",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "",
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                "//storage/path/to/Title.pdf",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+  }
+
+  @Test
+  public void addCompletedDownload_requiresNonNullNonEmptyDescription() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                null,
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                "//storage/path/to/Title.pdf",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                "",
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                "//storage/path/to/Title.pdf",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+  }
+
+  @Test
+  public void addCompletedDownload_requiresNonNullNonEmptyPath() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                null,
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                "",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+  }
+
+  @Test
+  public void addCompletedDownload_requiresNonNullNonEmptyMimeType() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                null,
+                "//storage/path/to/Title.pdf",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                "",
+                "//storage/path/to/Title.pdf",
+                /* length= */ 1024L,
+                /* showNotification= */ true));
+  }
+
+  @Test
+  public void addCompletedDownload_requiresPositiveLength() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            manager.addCompletedDownload(
+                "Title",
+                "Description",
+                /* isMediaScannerScannable= */ true,
+                "application/pdf",
+                "//storage/path/to/Title.pdf",
+                /* length= */ -1L,
+                /* showNotification= */ true));
+  }
+
+  @Test
+  public void getCompletedDownload_returnsExactCompletedDownload() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    long id =
+        manager.addCompletedDownload(
+            "Title",
+            "Description",
+            /* isMediaScannerScannable= */ true,
+            "application/pdf",
+            "//storage/path/to/Title.pdf",
+            /* length= */ 1024L,
+            /* showNotification= */ true);
+
+    CompletedDownload capturedDownload = manager.getCompletedDownload(id);
+
+    assertThat(capturedDownload.getTitle()).isEqualTo("Title");
+    assertThat(capturedDownload.getDescription()).isEqualTo("Description");
+    assertThat(capturedDownload.isMediaScannerScannable()).isTrue();
+    assertThat(capturedDownload.getMimeType()).isEqualTo("application/pdf");
+    assertThat(capturedDownload.getPath()).isEqualTo("//storage/path/to/Title.pdf");
+    assertThat(capturedDownload.getLength()).isEqualTo(1024L);
+    assertThat(capturedDownload.showNotification()).isTrue();
+  }
+
+  @Test
+  public void getRequestCount_doesNotIncludeCompletedDownloads() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    manager.addCompletedDownload(
+        "Title",
+        "Description",
+        /* isMediaScannerScannable= */ true,
+        "application/pdf",
+        "//storage/path/to/Title.pdf",
+        /* length= */ 1024L,
+        /* showNotification= */ true);
+
+    assertThat(manager.getRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void getCompletedDownloadsCount_doesNotIncludeRequests() {
+    ShadowDownloadManager manager = new ShadowDownloadManager();
+    manager.enqueue(request);
+
+    assertThat(manager.getCompletedDownloadsCount()).isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDrawableTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDrawableTest.java
new file mode 100644
index 0000000..504993c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDrawableTest.java
@@ -0,0 +1,185 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static org.junit.Assert.assertNotNull;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.VectorDrawable;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowDrawableTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void createFromResourceStream_shouldWorkWithoutSourceName() {
+    Drawable drawable =
+        Drawable.createFromResourceStream(
+            context.getResources(), null, createImageStream(), null, new BitmapFactory.Options());
+    assertNotNull(drawable);
+  }
+
+  @Test
+  public void copyBoundsWithPassedRect() {
+    Drawable drawable = Drawable.createFromStream(createImageStream(), "my_source");
+    drawable.setBounds(1, 2, 3, 4);
+    Rect r = new Rect();
+    drawable.copyBounds(r);
+    assertThat(r.left).isEqualTo(1);
+    assertThat(r.top).isEqualTo(2);
+    assertThat(r.right).isEqualTo(3);
+    assertThat(r.bottom).isEqualTo(4);
+  }
+
+  @Test
+  public void copyBoundsToReturnedRect() {
+    Drawable drawable = Drawable.createFromStream(createImageStream(), "my_source");
+    drawable.setBounds(1, 2, 3, 4);
+    Rect r = drawable.copyBounds();
+    assertThat(r.left).isEqualTo(1);
+    assertThat(r.top).isEqualTo(2);
+    assertThat(r.right).isEqualTo(3);
+    assertThat(r.bottom).isEqualTo(4);
+  }
+
+  @Test
+  public void testGetLoadedFromResourceId_shouldDefaultToNegativeOne() {
+    Drawable drawable = new TestDrawable();
+    assertThat(shadowOf(drawable).getCreatedFromResId()).isEqualTo(-1);
+  }
+
+  @Test
+  public void testCreateFromResourceId_shouldSetTheId() {
+    Drawable drawable = ShadowDrawable.createFromResourceId(34758);
+    ShadowDrawable shadowDrawable = shadowOf(drawable);
+    assertThat(shadowDrawable.getCreatedFromResId()).isEqualTo(34758);
+  }
+
+  @Test
+  public void testWasSelfInvalidated() {
+    Drawable drawable = ShadowDrawable.createFromResourceId(34758);
+    ShadowDrawable shadowDrawable = shadowOf(drawable);
+    assertThat(shadowDrawable.wasInvalidated()).isFalse();
+    drawable.invalidateSelf();
+    assertThat(shadowDrawable.wasInvalidated()).isTrue();
+  }
+
+  @Test
+  public void shouldLoadNinePatchFromDrawableXml() {
+    assertThat(context.getResources().getDrawable(R.drawable.drawable_with_nine_patch)).isNotNull();
+  }
+
+  @Test public void settingBoundsShouldInvokeCallback() {
+    TestDrawable drawable = new TestDrawable();
+    assertThat(drawable.boundsChanged).isFalse();
+    drawable.setBounds(0, 0, 10, 10);
+    assertThat(drawable.boundsChanged).isTrue();
+  }
+
+  @Test
+  public void drawableIntrinsicWidthAndHeightShouldBeCorrect() {
+    final Drawable anImage = context.getResources().getDrawable(R.drawable.an_image);
+
+    assertThat(anImage.getIntrinsicHeight()).isEqualTo(53);
+    assertThat(anImage.getIntrinsicWidth()).isEqualTo(64);
+  }
+
+  @Test
+  @Config(qualifiers = "mdpi")
+  public void drawableShouldLoadImageOfCorrectSizeWithMdpiQualifier() {
+    final Drawable anImage = context.getResources().getDrawable(R.drawable.robolectric);
+
+    assertThat(anImage.getIntrinsicHeight()).isEqualTo(167);
+    assertThat(anImage.getIntrinsicWidth()).isEqualTo(198);
+  }
+
+  @Test
+  @Config(qualifiers = "hdpi")
+  public void drawableShouldLoadImageOfCorrectSizeWithHdpiQualifier() {
+    if (Build.VERSION.SDK_INT >= 28) {
+      // getDrawable depends on ImageDecoder, which depends on binary resources
+      assume().that(ShadowAssetManager.useLegacy()).isFalse();
+    }
+
+    final Drawable anImage = context.getResources().getDrawable(R.drawable.robolectric);
+
+    assertThat(anImage.getIntrinsicHeight()).isEqualTo(251);
+    assertThat(anImage.getIntrinsicWidth()).isEqualTo(297);
+  }
+
+  @Test
+  @Config(maxSdk = KITKAT_WATCH)
+  public void testGetBitmapOrVectorDrawableAt19() {
+    // at API 21+ and mdpi, the drawable-anydpi-v21/image_or_vector.xml should be loaded instead
+    // of drawable/image_or_vector.png
+    final Drawable aDrawable = context.getResources().getDrawable(R.drawable.an_image_or_vector);
+    assertThat(aDrawable).isInstanceOf(BitmapDrawable.class);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testGetBitmapOrVectorDrawableAt21() {
+    final Drawable aDrawable = context.getResources().getDrawable(R.drawable.an_image_or_vector);
+    assertThat(aDrawable).isInstanceOf(VectorDrawable.class);
+  }
+
+  private static class TestDrawable extends Drawable {
+    public boolean boundsChanged;
+
+    @Override
+    public void draw(Canvas canvas) {
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+    }
+
+    @Override
+    public int getOpacity() {
+      return 0;
+    }
+
+    @Override protected void onBoundsChange(Rect bounds) {
+      boundsChanged = true;
+      super.onBoundsChange(bounds);
+    }
+  }
+
+  private static ByteArrayInputStream createImageStream() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+    bitmap.compress(CompressFormat.PNG, 100, outputStream);
+    return new ByteArrayInputStream(outputStream.toByteArray());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDropBoxManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDropBoxManagerTest.java
new file mode 100644
index 0000000..d51bc80
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDropBoxManagerTest.java
@@ -0,0 +1,118 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.os.DropBoxManager;
+import android.os.DropBoxManager.Entry;
+import android.os.SystemClock;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@see ShadowDropboxManager}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowDropBoxManagerTest {
+
+  private static final String TAG = "TAG";
+  private static final String ANOTHER_TAG = "ANOTHER_TAG";
+  private static final byte[] DATA = "HELLO WORLD".getBytes(UTF_8);
+
+  private DropBoxManager manager;
+  private ShadowDropBoxManager shadowDropBoxManager;
+
+  @Before
+  public void setup() {
+    manager =
+        (DropBoxManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.DROPBOX_SERVICE);
+    shadowDropBoxManager = shadowOf(manager);
+  }
+
+  @Test
+  public void emptyDropbox() {
+    assertThat(manager.getNextEntry(null, 0)).isNull();
+  }
+
+  @Test
+  public void dataExpected() throws Exception {
+    shadowDropBoxManager.addData(TAG, 1, DATA);
+
+    Entry entry = manager.getNextEntry(null, 0);
+    assertThat(entry).isNotNull();
+    assertThat(entry.getTag()).isEqualTo(TAG);
+    assertThat(entry.getTimeMillis()).isEqualTo(1);
+    assertThat(new BufferedReader(new InputStreamReader(entry.getInputStream(), UTF_8)).readLine())
+        .isEqualTo(new String(DATA, UTF_8));
+    assertThat(entry.getText(100)).isEqualTo(new String(DATA, UTF_8));
+  }
+
+  /** Checks that we retrieve the first entry <em>after</em> the specified time. */
+  @Test
+  public void dataNotExpected_timestampSameAsEntry() {
+    shadowDropBoxManager.addData(TAG, 1, DATA);
+
+    assertThat(manager.getNextEntry(null, 1)).isNull();
+  }
+
+  @Test
+  public void dataNotExpected_timestampAfterEntry() {
+    shadowDropBoxManager.addData(TAG, 1, DATA);
+
+    assertThat(manager.getNextEntry(null, 2)).isNull();
+  }
+
+  @Test
+  public void dataNotExpected_wrongTag() {
+    shadowDropBoxManager.addData(TAG, 1, DATA);
+
+    assertThat(manager.getNextEntry(ANOTHER_TAG, 0)).isNull();
+  }
+
+  @Test
+  public void dataExpectedWithSort() {
+    shadowDropBoxManager.addData(TAG, 3, DATA);
+    shadowDropBoxManager.addData(TAG, 1, new byte[] {(byte) 0x0});
+
+    Entry entry = manager.getNextEntry(null, 2);
+    assertThat(entry).isNotNull();
+    assertThat(entry.getText(100)).isEqualTo(new String(DATA, UTF_8));
+    assertThat(entry.getTimeMillis()).isEqualTo(3);
+  }
+
+  @Test()
+  public void resetClearsData() {
+    shadowDropBoxManager.addData(TAG, 1, DATA);
+
+    shadowDropBoxManager.reset();
+
+    assertThat(manager.getNextEntry(null, 0)).isNull();
+  }
+
+  @Test
+  public void testAddText() {
+    long baseTimestamp = 55000L;
+    SystemClock.setCurrentTimeMillis(baseTimestamp);
+    manager.addText(TAG, "HELLO WORLD");
+    SystemClock.setCurrentTimeMillis(baseTimestamp + 100);
+    manager.addText(TAG, "GOODBYE WORLD");
+
+    Entry entry = manager.getNextEntry(null, 0);
+    assertThat(entry).isNotNull();
+    assertThat(entry.getText(1024)).isEqualTo("HELLO WORLD");
+    assertThat(entry.getTimeMillis()).isEqualTo(baseTimestamp);
+
+    entry = manager.getNextEntry(null, baseTimestamp + 1);
+    assertThat(entry.getText(1024)).isEqualTo("GOODBYE WORLD");
+    assertThat(entry.getTimeMillis()).isEqualTo(baseTimestamp + 100);
+
+    assertThat(manager.getNextEntry(null, baseTimestamp + 99)).isNotNull();
+    assertThat(manager.getNextEntry(null, baseTimestamp + 100)).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowDynamicsProcessingTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowDynamicsProcessingTest.java
new file mode 100644
index 0000000..792d86a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowDynamicsProcessingTest.java
@@ -0,0 +1,172 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.media.audiofx.DynamicsProcessing;
+import android.media.audiofx.DynamicsProcessing.Eq;
+import android.media.audiofx.DynamicsProcessing.EqBand;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowDynamicsProcessing}. */
+@Config(minSdk = VERSION_CODES.P)
+@RunWith(AndroidJUnit4.class)
+public class ShadowDynamicsProcessingTest {
+
+  @Test
+  public void getConfig_sameAsInCtor() {
+    DynamicsProcessing.Config oneChannelConfig = createConfig(/* numChannels= */ 1);
+    DynamicsProcessing dynamicsProcessing =
+        new DynamicsProcessing(/* priority= */ 0, /* audioSession= */ 0, oneChannelConfig);
+
+    assertConfigBandCountsEquals(dynamicsProcessing.getConfig(), oneChannelConfig);
+  }
+
+  @Test
+  public void getConfig_configNullInCtor_returnsNonNull() {
+    DynamicsProcessing dynamicsProcessing =
+        new DynamicsProcessing(/* priority= */ 0, /* audioSession= */ 0, /* cfg= */ null);
+
+    DynamicsProcessing.Config config = dynamicsProcessing.getConfig();
+
+    assertThat(config).isNotNull();
+  }
+
+  @Test
+  public void getPreEqByChannelIndex_returnsSamePreEqAsConfigFromCtor() {
+    DynamicsProcessing.Config preEqConfig =
+        createSingleChannelPreEqConfig(new float[] {0.1f, 1f, 10f, 500f});
+    DynamicsProcessing dynamicsProcessing =
+        new DynamicsProcessing(/* priority= */ 0, /* audioSession= */ 0, preEqConfig);
+
+    Eq preEq = dynamicsProcessing.getPreEqByChannelIndex(/* channelIndex= */ 0);
+
+    assertThat(preEq).isNotNull();
+    assertThat(preEq.getBandCount()).isEqualTo(preEqConfig.getPreEqBandCount());
+    for (int bandIndex = 0; bandIndex < preEq.getBandCount(); bandIndex++) {
+      EqBand actualBand = preEq.getBand(bandIndex);
+      EqBand expectedBand =
+          preEqConfig.getPreEqBandByChannelIndex(/* channelIndex= */ 0, /* band= */ bandIndex);
+      assertEqBandEquals(actualBand, expectedBand);
+    }
+  }
+
+  @Test
+  public void getPreEqBandByChannelIndex_returnsSameBandAsConfigFromCtor() {
+    DynamicsProcessing.Config preEqConfig =
+        createSingleChannelPreEqConfig(new float[] {0.1f, 1f, 10f, 500f});
+    DynamicsProcessing dynamicsProcessing =
+        new DynamicsProcessing(/* priority= */ 0, /* audioSession= */ 0, /* cfg= */ preEqConfig);
+
+    for (int bandIndex = 0; bandIndex < preEqConfig.getPreEqBandCount(); bandIndex++) {
+      EqBand actualBand =
+          dynamicsProcessing.getPreEqBandByChannelIndex(
+              /* channelIndex= */ 0, /* band= */ bandIndex);
+      EqBand expectedBand =
+          preEqConfig.getPreEqBandByChannelIndex(/* channelIndex= */ 0, /* band= */ bandIndex);
+      assertEqBandEquals(actualBand, expectedBand);
+    }
+  }
+
+  @Test
+  public void setPreEqBandAllChannelsTo_updatesPreEqBand() {
+    DynamicsProcessing.Config preEqConfig =
+        createSingleChannelPreEqConfig(new float[] {0.1f, 1f, 10f, 500f});
+    DynamicsProcessing dynamicsProcessing =
+        new DynamicsProcessing(/* priority= */ 0, /* audioSession= */ 0, /* cfg= */ preEqConfig);
+    int replacedBandIndex = 2;
+    EqBand newBand = new EqBand(/* enabled= */ true, /* cutoffFrequency= */ 25f, /* gain= */ 5);
+
+    dynamicsProcessing.setPreEqBandAllChannelsTo(replacedBandIndex, newBand);
+
+    for (int bandIndex = 0; bandIndex < preEqConfig.getPreEqBandCount(); bandIndex++) {
+      EqBand actualBand =
+          dynamicsProcessing.getPreEqBandByChannelIndex(
+              /* channelIndex= */ 0, /* band= */ bandIndex);
+      EqBand expectedBand =
+          bandIndex == replacedBandIndex
+              ? newBand
+              : preEqConfig.getPreEqBandByChannelIndex(
+                  /* channelIndex= */ 0, /* band= */ bandIndex);
+      assertEqBandEquals(actualBand, expectedBand);
+    }
+  }
+
+  @Test
+  public void setPreEqBandAllChannelsTo_errorCodeSet_throwsException() {
+    DynamicsProcessing dynamicsProcessing = createDynamicsProcessing();
+    EqBand eqBand = new EqBand(/* enabled= */ true, /* cutoffFrequency= */ 25f, /* gain= */ 5);
+    shadowOf(dynamicsProcessing).setErrorCode(DynamicsProcessing.ERROR);
+
+    assertThrows(
+        RuntimeException.class, () -> dynamicsProcessing.setPreEqBandAllChannelsTo(0, eqBand));
+  }
+
+  private static DynamicsProcessing createDynamicsProcessing() {
+    DynamicsProcessing.Config config = createConfig(/* numChannels= */ 1);
+    return new DynamicsProcessing(/* priority= */ 0, /* audioSession= */ 0, /* cfg= */ config);
+  }
+
+  private static DynamicsProcessing.Config createConfig(int numChannels) {
+    return new DynamicsProcessing.Config.Builder(
+            /* variant= */ DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
+            /* channelCount= */ numChannels,
+            /* preEqInUse= */ true,
+            /* preEqBandCount= */ 1,
+            /* mbcInUse= */ true,
+            /* mbcBandCount= */ 2,
+            /* postEqInUse= */ true,
+            /* postEqBandCount= */ 3,
+            /* limiterInUse= */ true)
+        .build();
+  }
+
+  private static DynamicsProcessing.Config createSingleChannelPreEqConfig(
+      float[] cutoffFrequencies) {
+    DynamicsProcessing.Config.Builder configBuilder =
+        new DynamicsProcessing.Config.Builder(
+            /* variant= */ DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
+            /* channelCount= */ 1,
+            /* preEqInUse= */ true,
+            /* preEqBandCount= */ cutoffFrequencies.length,
+            /* mbcInUse= */ false,
+            /* mbcBandCount= */ 0,
+            /* postEqInUse= */ false,
+            /* postEqBandCount= */ 0,
+            /* limiterInUse= */ false);
+    configBuilder.setPreEqAllChannelsTo(createEqWithZeroGains(cutoffFrequencies));
+    return configBuilder.build();
+  }
+
+  private static Eq createEqWithZeroGains(float[] cutoffFrequencies) {
+    DynamicsProcessing.Eq eq =
+        new DynamicsProcessing.Eq(/* inUse= */ true, /* enabled= */ true, cutoffFrequencies.length);
+    for (int i = 0; i < cutoffFrequencies.length; i++) {
+      DynamicsProcessing.EqBand eqBand =
+          new DynamicsProcessing.EqBand(true, cutoffFrequencies[i], /* gain= */ 0);
+      eq.setBand(i, eqBand);
+    }
+    return eq;
+  }
+
+  private static void assertEqBandEquals(EqBand actual, EqBand expected) {
+    if (actual == null || expected == null) {
+      assertThat(actual).isSameInstanceAs(expected);
+    }
+    assertThat(actual.isEnabled()).isEqualTo(expected.isEnabled());
+    assertThat(actual.getCutoffFrequency()).isEqualTo(expected.getCutoffFrequency());
+    assertThat(actual.getGain()).isEqualTo(expected.getGain());
+  }
+
+  private static void assertConfigBandCountsEquals(
+      DynamicsProcessing.Config actual, DynamicsProcessing.Config expected) {
+    assertThat(actual.getMbcBandCount()).isEqualTo(expected.getMbcBandCount());
+    assertThat(actual.getPostEqBandCount()).isEqualTo(expected.getPostEqBandCount());
+    assertThat(actual.getPreEqBandCount()).isEqualTo(expected.getPreEqBandCount());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java
new file mode 100644
index 0000000..e5d5c9d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java
@@ -0,0 +1,66 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.opengl.EGL14;
+import android.opengl.EGLConfig;
+import android.opengl.EGLContext;
+import android.opengl.EGLDisplay;
+import android.os.Build.VERSION_CODES;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowEGL14Test} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+public final class ShadowEGL14Test {
+  @Test
+  public void eglGetDisplay() {
+    assertThat(EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)).isNotNull();
+  }
+
+  @Test
+  public void eglChooseConfig() {
+    EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+    EGLConfig[] configs = new EGLConfig[1];
+    int[] numConfig = new int[1];
+    assertThat(EGL14.eglChooseConfig(display, new int[0], 0, configs, 0, 1, numConfig, 0)).isTrue();
+    assertThat(numConfig[0]).isGreaterThan(0);
+    assertThat(configs[0]).isNotNull();
+  }
+
+  @Test
+  public void eglCreateContext_v2() {
+    EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+    int[] attribList = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
+    EGLContext context = EGL14.eglCreateContext(display, createEglConfig(), null, attribList, 0);
+    assertThat(context).isNotNull();
+    int[] values = new int[1];
+    EGL14.eglQueryContext(display, context, EGL14.EGL_CONTEXT_CLIENT_VERSION, values, 0);
+    assertThat(values[0]).isEqualTo(2);
+  }
+
+  @Test
+  public void eglCreatePbufferSurface() {
+    EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+    assertThat(EGL14.eglCreatePbufferSurface(display, createEglConfig(), new int[0], 0))
+        .isNotNull();
+  }
+
+  @Test
+  public void eglCreateWindowSurface() {
+    EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+    assertThat(EGL14.eglCreateWindowSurface(display, createEglConfig(), null, new int[0], 0))
+        .isNotNull();
+  }
+
+  private EGLConfig createEglConfig() {
+    EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+    EGLConfig[] configs = new EGLConfig[1];
+    int[] numConfig = new int[1];
+    EGL14.eglChooseConfig(display, new int[0], 0, configs, 0, 1, numConfig, 0);
+    return configs[0];
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEditTextPreferenceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEditTextPreferenceTest.java
new file mode 100644
index 0000000..2eac622
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEditTextPreferenceTest.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import android.content.Context;
+import android.preference.EditTextPreference;
+import android.widget.EditText;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowEditTextPreferenceTest {
+
+  private static final String SOME_TEXT = "some text";
+  private EditTextPreference preference;
+
+  private Context context;
+
+  @Before
+  public void setup() {
+    context = ApplicationProvider.getApplicationContext();
+    preference = new EditTextPreference(context);
+  }
+
+  @Test
+  public void testConstructor() {
+    preference = new EditTextPreference(context);
+    assertNotNull(preference.getEditText());
+  }
+
+  @Test
+  public void setTextInEditTextShouldStoreText() {
+    final EditText editText = preference.getEditText();
+    editText.setText(SOME_TEXT);
+
+    assertThat(editText.getText().toString()).isEqualTo(SOME_TEXT);
+  }
+
+  @Test
+  public void setTextShouldStoreText() {
+    preference.setText("some other text");
+    assertThat(preference.getText()).isEqualTo("some other text");
+  }
+
+  @Test
+  public void setTextShouldStoreNull() {
+    preference.setText(null);
+    assertNull(preference.getText());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEditTextTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEditTextTest.java
new file mode 100644
index 0000000..1044671
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEditTextTest.java
@@ -0,0 +1,117 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.EditText;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Random;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowEditTextTest {
+  private EditText editText;
+  private Application context;
+
+  @Before
+  public void setup() {
+    AttributeSet attributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.maxLength, "5")
+        .build();
+
+    context = ApplicationProvider.getApplicationContext();
+    editText = new EditText(context, attributeSet);
+  }
+
+  @Test
+  public void shouldRespectMaxLength() {
+    editText.setText("0123456678");
+    assertThat(editText.getText().toString()).isEqualTo("01234");
+  }
+
+  @Test
+  public void shouldAcceptNullStrings() {
+    editText.setText(null);
+    assertThat(editText.getText().toString()).isEqualTo("");
+  }
+
+  @Test
+  public void givenInitializingWithAttributeSet_whenMaxLengthDefined_thenRestrictTextLengthToMaxLength() {
+    int maxLength = anyInteger();
+    AttributeSet attrs = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.maxLength, maxLength + "")
+        .build();
+
+    EditText editText = new EditText(context, attrs);
+    String excessiveInput = stringOfLength(maxLength * 2);
+
+    editText.setText(excessiveInput);
+
+    assertThat(editText.getText().toString())
+        .isEqualTo(excessiveInput.subSequence(0, maxLength).toString());
+  }
+
+  @Test
+  public void givenInitializingWithAttributeSet_whenMaxLengthNotDefined_thenTextLengthShouldHaveNoRestrictions() {
+    AttributeSet attrs = Robolectric.buildAttributeSet().build();
+    EditText editText = new EditText(context, attrs);
+    String input = anyString();
+
+    editText.setText(input);
+
+    assertThat(editText.getText().toString()).isEqualTo(input);
+  }
+
+  @Test
+  public void whenInitializingWithoutAttributeSet_thenTextLengthShouldHaveNoRestrictions() {
+    EditText editText = new EditText(context);
+    String input = anyString();
+
+    editText.setText(input);
+
+    assertThat(editText.getText().toString()).isEqualTo(input);
+  }
+
+  @Test
+  public void testSelectAll() {
+    EditText editText = new EditText(context);
+    editText.setText("foo");
+
+    editText.selectAll();
+
+    assertThat(editText.getSelectionStart()).isEqualTo(0);
+    assertThat(editText.getSelectionEnd()).isEqualTo(3);
+  }
+
+  @Test
+  public void shouldGetHintFromXml() {
+    LayoutInflater inflater = LayoutInflater.from(context);
+    EditText editText = (EditText) inflater.inflate(R.layout.edit_text, null);
+    assertThat(editText.getHint().toString()).isEqualTo("Hello, Hint");
+  }
+
+  private String anyString() {
+    return stringOfLength(anyInteger());
+  }
+
+  private String stringOfLength(int length) {
+    StringBuilder stringBuilder = new StringBuilder();
+
+    for (int i = 0; i < length; i++)
+      stringBuilder.append('x');
+
+    return stringBuilder.toString();
+  }
+
+  private int anyInteger() {
+    return new Random().nextInt(1000) + 1;
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEnvironmentTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEnvironmentTest.java
new file mode 100644
index 0000000..3cb4ae9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEnvironmentTest.java
@@ -0,0 +1,258 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import android.os.Environment;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import org.junit.After;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowEnvironmentTest {
+
+  @After
+  public void tearDown() throws Exception {
+    ShadowEnvironment.reset();
+  }
+
+  @Test
+  public void getExternalStorageState_shouldReturnStorageState() {
+    assertThat(Environment.getExternalStorageState()).isEqualTo(Environment.MEDIA_REMOVED);
+    ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED);
+    assertThat(Environment.getExternalStorageState()).isEqualTo(Environment.MEDIA_MOUNTED);
+  }
+
+  @Test
+  public void getExternalStorageDirectory_shouldReturnDirectory() {
+    assertThat(Environment.getExternalStorageDirectory().exists()).isTrue();
+  }
+
+  @Test
+  public void setExternalStorageDirectory_shouldReturnDirectory() {
+    // state prior to override
+    File defaultDir = Environment.getExternalStorageDirectory();
+    // override
+    Path expectedPath = FileSystems.getDefault().getPath("/tmp", "foo");
+    ShadowEnvironment.setExternalStorageDirectory(expectedPath);
+    File override = Environment.getExternalStorageDirectory();
+    assertThat(override.getAbsolutePath()).isEqualTo(expectedPath.toAbsolutePath().toString());
+
+    // restore default value by supplying {@code null}
+    ShadowEnvironment.setExternalStorageDirectory(null);
+
+    // verify default
+    assertThat(defaultDir).isEqualTo(Environment.getExternalStorageDirectory());
+  }
+
+  @Test
+  public void getExternalStoragePublicDirectory_shouldReturnDirectory() {
+    final File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
+    assertThat(path.exists()).isTrue();
+    assertThat(path).isEqualTo(new File(ShadowEnvironment.EXTERNAL_FILES_DIR.toFile(), Environment.DIRECTORY_MOVIES));
+  }
+
+  @Test
+  public void getExternalStoragePublicDirectory_shouldReturnSameDirectory() {
+    File path1 = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
+    File path2 = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
+
+    assertThat(path1).isEqualTo(path2);
+  }
+
+  @Test
+  public void getExternalStoragePublicDirectory_unknownMediaState_returnsNull() {
+    ShadowEnvironment.setExternalStorageState(Environment.MEDIA_UNKNOWN);
+    assertThat(Environment.getExternalStoragePublicDirectory(null)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isExternalStorageRemovable_primaryShouldReturnSavedValue() {
+    assertThat(Environment.isExternalStorageRemovable()).isFalse();
+    ShadowEnvironment.setExternalStorageRemovable(Environment.getExternalStorageDirectory(), true);
+    assertThat(Environment.isExternalStorageRemovable()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isExternalStorageRemovable_shouldReturnSavedValue() {
+    final File file = new File("/mnt/media/file");
+    assertThat(Environment.isExternalStorageRemovable(file)).isFalse();
+    ShadowEnvironment.setExternalStorageRemovable(file, true);
+    assertThat(Environment.isExternalStorageRemovable(file)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isExternalStorageEmulated_shouldReturnSavedValue() {
+    final File file = new File("/mnt/media/file");
+    assertThat(Environment.isExternalStorageEmulated(file)).isFalse();
+    ShadowEnvironment.setExternalStorageEmulated(file, true);
+    assertThat(Environment.isExternalStorageEmulated(file)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void isExternalStorageLegacy_shouldReturnSavedValue() {
+    final File file = new File("/mnt/media/file");
+    assertThat(Environment.isExternalStorageLegacy(file)).isFalse();
+    ShadowEnvironment.setIsExternalStorageLegacy(true);
+    assertThat(Environment.isExternalStorageLegacy(file)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void storageIsLazy() {
+    assertNull(ShadowEnvironment.EXTERNAL_CACHE_DIR);
+    assertNull(ShadowEnvironment.EXTERNAL_FILES_DIR);
+
+    Environment.getExternalStorageDirectory();
+    Environment.getExternalStoragePublicDirectory(null);
+
+    assertNotNull(ShadowEnvironment.EXTERNAL_CACHE_DIR);
+    assertNotNull(ShadowEnvironment.EXTERNAL_FILES_DIR);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void reset_shouldClearRemovableFiles() {
+    final File file = new File("foo");
+    ShadowEnvironment.setExternalStorageRemovable(file, true);
+
+    assertThat(Environment.isExternalStorageRemovable(file)).isTrue();
+    ShadowEnvironment.reset();
+    assertThat(Environment.isExternalStorageRemovable(file)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void reset_shouldClearEmulatedFiles() {
+    final File file = new File("foo");
+    ShadowEnvironment.setExternalStorageEmulated(file, true);
+
+    assertThat(Environment.isExternalStorageEmulated(file)).isTrue();
+    ShadowEnvironment.reset();
+    assertThat(Environment.isExternalStorageEmulated(file)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void reset_shouldResetExternalStorageState() {
+    ShadowEnvironment.setExternalStorageState(Environment.MEDIA_UNKNOWN);
+    ShadowEnvironment.reset();
+    assertThat(Environment.getExternalStorageState()).isEqualTo(Environment.MEDIA_REMOVED);
+  }
+
+  @Test
+  public void isExternalStorageEmulatedNoArg_shouldReturnSavedValue() {
+    ShadowEnvironment.setIsExternalStorageEmulated(true);
+    assertThat(Environment.isExternalStorageEmulated()).isTrue();
+    ShadowEnvironment.reset();
+    assertThat(Environment.isExternalStorageEmulated()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void isExternalStorageLegacyNoArg_shouldReturnSavedValue() {
+    ShadowEnvironment.setIsExternalStorageLegacy(true);
+    assertThat(Environment.isExternalStorageLegacy()).isTrue();
+    ShadowEnvironment.reset();
+    assertThat(Environment.isExternalStorageLegacy()).isFalse();
+  }
+
+  // TODO: failing test
+  @Ignore
+  @Test
+  @Config(minSdk = KITKAT)
+  public void getExternalFilesDirs() throws Exception {
+    ShadowEnvironment.addExternalDir("external_dir_1");
+    ShadowEnvironment.addExternalDir("external_dir_2");
+
+    File[] externalFilesDirs =
+        ApplicationProvider.getApplicationContext()
+            .getExternalFilesDirs(Environment.DIRECTORY_MOVIES);
+
+    assertThat(externalFilesDirs).isNotEmpty();
+    assertThat(externalFilesDirs[0].getCanonicalPath()).contains("external_dir_1");
+    assertThat(externalFilesDirs[1].getCanonicalPath()).contains("external_dir_2");
+
+    // TODO(jongerrish): This fails because ShadowContext overwrites getExternalFilesDir.
+    //
+    // assertThat(RuntimeEnvironment.getApplication().getExternalFilesDir(Environment.DIRECTORY_MOVIES)
+    //         .getCanonicalPath()).contains("external_dir_1");
+  }
+
+  @Test
+  @Config(sdk = JELLY_BEAN_MR1)
+  public void getExternalStorageStateJB() {
+    ShadowEnvironment.setExternalStorageState("blah");
+    assertThat(ShadowEnvironment.getExternalStorageState()).isEqualTo("blah");
+  }
+
+  @Test
+  @Config(minSdk = KITKAT, maxSdk = LOLLIPOP)
+  public void getExternalStorageStatePreLollipopMR1() {
+    File storageDir1 = ShadowEnvironment.addExternalDir("dir1");
+    File storageDir2 = ShadowEnvironment.addExternalDir("dir2");
+    ShadowEnvironment.setExternalStorageState(storageDir1, Environment.MEDIA_MOUNTED);
+    ShadowEnvironment.setExternalStorageState(storageDir2, Environment.MEDIA_REMOVED);
+    ShadowEnvironment.setExternalStorageState("blah");
+
+    assertThat(ShadowEnvironment.getStorageState(storageDir1))
+        .isEqualTo(Environment.MEDIA_MOUNTED);
+    assertThat(ShadowEnvironment.getStorageState(storageDir2))
+        .isEqualTo(Environment.MEDIA_REMOVED);
+    assertThat(ShadowEnvironment.getStorageState(new File(storageDir1, "subpath")))
+        .isEqualTo(Environment.MEDIA_MOUNTED);
+    assertThat(ShadowEnvironment.getExternalStorageState()).isEqualTo("blah");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void getExternalStorageState() {
+    File storageDir1 = ShadowEnvironment.addExternalDir("dir1");
+    File storageDir2 = ShadowEnvironment.addExternalDir("dir2");
+    ShadowEnvironment.setExternalStorageState(storageDir1, Environment.MEDIA_MOUNTED);
+    ShadowEnvironment.setExternalStorageState(storageDir2, Environment.MEDIA_REMOVED);
+    ShadowEnvironment.setExternalStorageState("blah");
+
+    assertThat(ShadowEnvironment.getExternalStorageState(storageDir1))
+        .isEqualTo(Environment.MEDIA_MOUNTED);
+    assertThat(ShadowEnvironment.getStorageState(storageDir1))
+        .isEqualTo(Environment.MEDIA_MOUNTED);
+    assertThat(ShadowEnvironment.getExternalStorageState(storageDir2))
+        .isEqualTo(Environment.MEDIA_REMOVED);
+    assertThat(ShadowEnvironment.getStorageState(storageDir2))
+        .isEqualTo(Environment.MEDIA_REMOVED);
+    assertThat(ShadowEnvironment.getExternalStorageState(new File(storageDir1, "subpath")))
+        .isEqualTo(Environment.MEDIA_MOUNTED);
+    assertThat(ShadowEnvironment.getStorageState(new File(storageDir1, "subpath")))
+        .isEqualTo(Environment.MEDIA_MOUNTED);
+    assertThat(ShadowEnvironment.getExternalStorageState()).isEqualTo("blah");
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isExternalStorageEmulated() {
+    ShadowEnvironment.setIsExternalStorageEmulated(true);
+    assertThat(Environment.isExternalStorageEmulated()).isTrue();
+
+    ShadowEnvironment.setIsExternalStorageEmulated(false);
+    assertThat(Environment.isExternalStorageEmulated()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEuiccManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEuiccManagerTest.java
new file mode 100644
index 0000000..fb1e6c9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEuiccManagerTest.java
@@ -0,0 +1,60 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.telephony.euicc.EuiccManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Junit test for {@link ShadowEuiccManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = P)
+public class ShadowEuiccManagerTest {
+  private EuiccManager euiccManager;
+
+  @Before
+  public void setUp() {
+    euiccManager = ApplicationProvider.getApplicationContext().getSystemService(EuiccManager.class);
+  }
+
+  @Test
+  public void isEnabled() {
+    shadowOf(euiccManager).setIsEnabled(true);
+
+    assertThat(euiccManager.isEnabled()).isTrue();
+  }
+
+  @Test
+  public void isEnabled_whenSetToFalse() {
+    shadowOf(euiccManager).setIsEnabled(false);
+
+    assertThat(euiccManager.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void getEid() {
+    String eid = "testEid";
+    shadowOf(euiccManager).setEid(eid);
+
+    assertThat(euiccManager.getEid()).isEqualTo(eid);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void createForCardId() {
+    int cardId = 1;
+    EuiccManager mockEuiccManager = mock(EuiccManager.class);
+
+    shadowOf(euiccManager).setEuiccManagerForCardId(cardId, mockEuiccManager);
+
+    assertThat(euiccManager.createForCardId(cardId)).isEqualTo(mockEuiccManager);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEventLogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEventLogTest.java
new file mode 100644
index 0000000..639d91b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEventLogTest.java
@@ -0,0 +1,217 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowEventLog.NULL_PLACE_HOLDER;
+
+import android.os.Build.VERSION_CODES;
+import android.util.EventLog;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test ShadowEventLog */
+@RunWith(AndroidJUnit4.class)
+public class ShadowEventLogTest {
+
+  private static final String TEST_STRING1 = "hello";
+  private static final String TEST_STRING2 = "world";
+  private static final int TEST_INT = 123;
+  private static final long TEST_LONG = 456L;
+  private static final float TEST_FLOAT = 0.789f;
+
+  private static final int TEST_TAG = 1;
+  private static final int TEST_PROCESS_ID = 2;
+  private static final int TEST_THREAD_ID = 3;
+  private static final long TEST_TIME_NANOS = 3L;
+
+  @Test
+  public void testAddEvent_testStringLog() throws Exception {
+    EventLog.Event event =
+        new ShadowEventLog.EventBuilder(TEST_TAG, TEST_STRING1)
+            .setProcessId(TEST_PROCESS_ID)
+            .setThreadId(TEST_THREAD_ID)
+            .setTimeNanos(TEST_TIME_NANOS)
+            .build();
+    ShadowEventLog.addEvent(event);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat(events.get(0).getProcessId()).isEqualTo(TEST_PROCESS_ID);
+    assertThat(events.get(0).getThreadId()).isEqualTo(TEST_THREAD_ID);
+    assertThat(events.get(0).getTimeNanos()).isEqualTo(TEST_TIME_NANOS);
+    assertThat((String) events.get(0).getData()).isEqualTo(TEST_STRING1);
+  }
+
+  @Test
+  public void testAddEvent_testIntLog()  throws Exception {
+    EventLog.Event event =
+        new ShadowEventLog.EventBuilder(TEST_TAG, TEST_INT)
+            .setProcessId(TEST_PROCESS_ID)
+            .setThreadId(TEST_THREAD_ID)
+            .setTimeNanos(TEST_TIME_NANOS)
+            .build();
+    ShadowEventLog.addEvent(event);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat(events.get(0).getProcessId()).isEqualTo(TEST_PROCESS_ID);
+    assertThat(events.get(0).getThreadId()).isEqualTo(TEST_THREAD_ID);
+    assertThat(events.get(0).getTimeNanos()).isEqualTo(TEST_TIME_NANOS);
+    assertThat((int) events.get(0).getData()).isEqualTo(TEST_INT);
+  }
+
+  @Test
+  public void testAddEvent_testLongLog() throws Exception {
+    EventLog.Event event =
+        new ShadowEventLog.EventBuilder(TEST_TAG, TEST_LONG)
+            .setProcessId(TEST_PROCESS_ID)
+            .setThreadId(TEST_THREAD_ID)
+            .setTimeNanos(TEST_TIME_NANOS)
+            .build();
+    ShadowEventLog.addEvent(event);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat(events.get(0).getProcessId()).isEqualTo(TEST_PROCESS_ID);
+    assertThat(events.get(0).getThreadId()).isEqualTo(TEST_THREAD_ID);
+    assertThat(events.get(0).getTimeNanos()).isEqualTo(TEST_TIME_NANOS);
+    assertThat((long) events.get(0).getData()).isEqualTo(TEST_LONG);
+  }
+
+  @Test
+  public void testAddEvent_testFloatLog() throws Exception {
+    EventLog.Event event =
+        new ShadowEventLog.EventBuilder(TEST_TAG, TEST_FLOAT)
+            .setProcessId(TEST_PROCESS_ID)
+            .setThreadId(TEST_THREAD_ID)
+            .setTimeNanos(TEST_TIME_NANOS)
+            .build();
+    ShadowEventLog.addEvent(event);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat(events.get(0).getProcessId()).isEqualTo(TEST_PROCESS_ID);
+    assertThat(events.get(0).getThreadId()).isEqualTo(TEST_THREAD_ID);
+    assertThat(events.get(0).getTimeNanos()).isEqualTo(TEST_TIME_NANOS);
+    assertThat((float) events.get(0).getData()).isEqualTo(TEST_FLOAT);
+  }
+
+  @Test
+  public void testAddEvent_testListLog() throws Exception {
+    EventLog.Event event =
+        new ShadowEventLog.EventBuilder(TEST_TAG, new String[] {TEST_STRING1, TEST_STRING2})
+            .setProcessId(TEST_PROCESS_ID)
+            .setThreadId(TEST_THREAD_ID)
+            .setTimeNanos(TEST_TIME_NANOS)
+            .build();
+    ShadowEventLog.addEvent(event);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat(events.get(0).getProcessId()).isEqualTo(TEST_PROCESS_ID);
+    assertThat(events.get(0).getThreadId()).isEqualTo(TEST_THREAD_ID);
+    assertThat(((String[]) events.get(0).getData())[0]).isEqualTo(TEST_STRING1);
+    assertThat(((String[]) events.get(0).getData())[1]).isEqualTo(TEST_STRING2);
+  }
+
+  @Test
+  public void testWriteEvent_string() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, TEST_STRING1);
+    assertThat(bytes).isEqualTo(Integer.BYTES + TEST_STRING1.length());
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((String) events.get(0).getData()).isEqualTo(TEST_STRING1);
+  }
+
+  @Test
+  public void testWriteEvent_nullString() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, (String) null);
+    assertThat(bytes).isEqualTo(Integer.BYTES + NULL_PLACE_HOLDER.length());
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((String) events.get(0).getData()).isEqualTo(NULL_PLACE_HOLDER);
+  }
+
+  @Test
+  public void testWriteEvent_int() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, TEST_INT);
+    assertThat(bytes).isEqualTo(Integer.BYTES + Integer.BYTES);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((int) events.get(0).getData()).isEqualTo(TEST_INT);
+  }
+
+  @Test
+  public void testWriteEvent_list() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, TEST_STRING1, TEST_STRING2);
+    assertThat(bytes).isEqualTo(Integer.BYTES + 2 * Integer.BYTES);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((Object[]) events.get(0).getData())
+        .asList()
+        .containsExactly(TEST_STRING1, TEST_STRING2)
+        .inOrder();
+  }
+
+  @Test
+  public void testWriteEvent_nullList() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, (Object[]) null);
+    assertThat(bytes).isEqualTo(Integer.BYTES + NULL_PLACE_HOLDER.length());
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((String) events.get(0).getData()).isEqualTo(NULL_PLACE_HOLDER);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testWriteEvent_float() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, TEST_FLOAT);
+    assertThat(bytes).isEqualTo(Integer.BYTES + Float.BYTES);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((float) events.get(0).getData()).isEqualTo(TEST_FLOAT);
+  }
+
+  @Test
+  public void testWriteEvent_long() throws Exception {
+    int bytes = EventLog.writeEvent(TEST_TAG, TEST_LONG);
+    assertThat(bytes).isEqualTo(Integer.BYTES + Long.BYTES);
+
+    ArrayList<EventLog.Event> events = new ArrayList<>();
+    EventLog.readEvents(new int[] {TEST_TAG}, events);
+    assertThat(events).hasSize(1);
+    assertThat(events.get(0).getTag()).isEqualTo(TEST_TAG);
+    assertThat((long) events.get(0).getData()).isEqualTo(TEST_LONG);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowExpandableListViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowExpandableListViewTest.java
new file mode 100644
index 0000000..249e11e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowExpandableListViewTest.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import android.widget.ExpandableListView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowExpandableListViewTest {
+
+  private ExpandableListView expandableListView;
+
+  @Before
+  public void setUp() {
+    expandableListView = new ExpandableListView(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void shouldTolerateNullChildClickListener() {
+    expandableListView.performItemClick(null, 6, -1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowFileObserverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowFileObserverTest.java
new file mode 100644
index 0000000..017d96f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowFileObserverTest.java
@@ -0,0 +1,160 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+import android.os.FileObserver;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ShadowFileObserver} */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowFileObserverTest {
+  private File testDir;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    File cacheDir = context.getCacheDir();
+    testDir = new File(cacheDir, "test");
+    testDir.mkdirs();
+  }
+
+  private static class Expectation {
+    private int expectedEvent;
+    @Nullable private String expectedPath;
+    private CountDownLatch latch = null;
+
+    public Expectation(int expectedEvent, @Nullable String expectedPath) {
+      reset(expectedEvent, expectedPath);
+    }
+
+    public void reset(int expectedEvent, @Nullable String expectedPath) {
+      this.expectedEvent = expectedEvent;
+      this.expectedPath = expectedPath;
+      this.latch = new CountDownLatch(1);
+    }
+
+    public void check(int event, @Nullable String path) {
+      if (this.expectedEvent == event
+          && ((this.expectedPath == null && path == null)
+              || (this.expectedPath != null && this.expectedPath.equals(path)))) {
+        this.latch.countDown();
+      }
+    }
+
+    public void expect() throws InterruptedException {
+      this.latch.await(30, TimeUnit.SECONDS);
+    }
+
+    public boolean hasBeenMet() {
+      return this.latch == null || this.latch.getCount() == 0;
+    }
+  }
+
+  @Test
+  public void monitorDirectory() throws Exception {
+    File newFile = new File(testDir, "new.file");
+    Expectation expectation = new Expectation(FileObserver.CREATE, newFile.getName());
+
+    FileObserver fileObserver =
+        new FileObserver(testDir.getAbsolutePath()) {
+          @Override
+          public void onEvent(int event, @Nullable String path) {
+            if (!expectation.hasBeenMet()) {
+              expectation.check(event, path);
+            }
+          }
+        };
+
+    fileObserver.startWatching();
+
+    newFile.createNewFile();
+    expectation.expect();
+
+    expectation.reset(FileObserver.MODIFY, newFile.getName());
+    try (Writer myWriter = Files.newBufferedWriter(newFile.toPath(), UTF_8)) {
+      myWriter.write("Some Content.");
+    }
+    expectation.expect();
+
+    expectation.reset(FileObserver.DELETE, newFile.getName());
+    newFile.delete();
+    expectation.expect();
+
+    File secondFile = new File(testDir, "second.file");
+    expectation.reset(FileObserver.CREATE, secondFile.getName());
+    secondFile.createNewFile();
+    expectation.expect();
+
+    fileObserver.stopWatching();
+  }
+
+  @Test
+  public void monitorFile() throws Exception {
+    File newFile = new File(testDir, "new.file");
+    File secondFile = new File(testDir, "second.file");
+    Expectation expectation = new Expectation(FileObserver.CREATE, newFile.getName());
+
+    FileObserver fileObserver =
+        new FileObserver(newFile.getAbsolutePath()) {
+          @Override
+          public void onEvent(int event, @Nullable String path) {
+            assertThat(path).isNotEqualTo(secondFile.getName());
+            if (!expectation.hasBeenMet()) {
+              expectation.check(event, path);
+            }
+          }
+        };
+
+    fileObserver.startWatching();
+
+    newFile.createNewFile();
+    expectation.expect();
+
+    expectation.reset(FileObserver.MODIFY, newFile.getName());
+    try (Writer myWriter = Files.newBufferedWriter(newFile.toPath(), UTF_8)) {
+      myWriter.write("Some Content.");
+    }
+    expectation.expect();
+
+    // The event handler is set to assert if it ever encounters anything about this second file.
+    secondFile.createNewFile();
+    try (Writer secondWriter = Files.newBufferedWriter(secondFile.toPath(), UTF_8)) {
+      secondWriter.write("Some other content.");
+    }
+
+    expectation.reset(FileObserver.DELETE, newFile.getName());
+    newFile.delete();
+    expectation.expect();
+
+    fileObserver.stopWatching();
+  }
+
+  @Test
+  public void nothingToMonitor_regression() {
+    // Tests that this implementation works even if there is nothing supported to monitor.
+    File newFile = new File(testDir, "new.file");
+    FileObserver fileObserver =
+        new FileObserver(newFile.getAbsolutePath(), FileObserver.ATTRIB) {
+          @Override
+          public void onEvent(int event, @Nullable String path) {
+            fail("Not expecting any events");
+          }
+        };
+
+    fileObserver.startWatching();
+    fileObserver.stopWatching();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowFilterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowFilterTest.java
new file mode 100644
index 0000000..a1c548a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowFilterTest.java
@@ -0,0 +1,104 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.widget.Filter;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowFilterTest {
+
+  @Test
+  public void testFilterShouldCallPerformFilteringAndPublishResults() {
+    final AtomicBoolean performFilteringCalled = new AtomicBoolean(false);
+    final AtomicBoolean publishResultsCalled = new AtomicBoolean(false);
+    Filter filter = new Filter() {
+      @Override
+      protected FilterResults performFiltering(CharSequence charSequence) {
+        performFilteringCalled.set(true);
+        return null;
+      }
+
+      @Override
+      protected void publishResults(CharSequence charSequence, FilterResults filterResults) {
+        assertThat(filterResults).isNull();
+        publishResultsCalled.set(true);
+      }
+    };
+    filter.filter("");
+    assertThat(performFilteringCalled.get()).isTrue();
+    assertThat(publishResultsCalled.get()).isTrue();
+  }
+
+  @Test
+  public void testFilterShouldCallListenerWithCorrectCount() {
+    final AtomicBoolean listenerCalled = new AtomicBoolean(false);
+    Filter filter = new Filter() {
+      @Override
+      protected FilterResults performFiltering(CharSequence charSequence) {
+        FilterResults results = new FilterResults();
+        results.values = null;
+        results.count = 4;
+        return results;
+      }
+
+      @Override
+      protected void publishResults(CharSequence charSequence, FilterResults filterResults) {
+        assertThat(filterResults.values).isNull();
+        assertThat(filterResults.count).isEqualTo(4);
+      }
+    };
+    filter.filter(
+        "",
+        i -> {
+          assertThat(i).isEqualTo(4);
+          listenerCalled.set(true);
+        });
+    assertThat(listenerCalled.get()).isTrue();
+  }
+
+  @Test
+  public void testFilter_whenNullResults_ShouldCallListenerWithMinusOne() {
+    final AtomicBoolean listenerCalled = new AtomicBoolean(false);
+    Filter filter = new Filter() {
+      @Override
+      protected FilterResults performFiltering(CharSequence charSequence) {
+        return null;
+      }
+
+      @Override
+      protected void publishResults(CharSequence charSequence, FilterResults filterResults) {}
+    };
+    filter.filter(
+        "",
+        i -> {
+          assertThat(i).isEqualTo(-1);
+          listenerCalled.set(true);
+        });
+    assertThat(listenerCalled.get()).isTrue();
+  }
+
+  @Test
+  public void testFilter_whenExceptionThrown_ShouldReturn() {
+    final AtomicBoolean listenerCalled = new AtomicBoolean(false);
+    Filter filter = new Filter() {
+      @Override
+      protected FilterResults performFiltering(CharSequence charSequence) {
+        throw new RuntimeException("unchecked exception during filtering");
+      }
+
+      @Override
+      protected void publishResults(CharSequence charSequence, FilterResults filterResults) {}
+    };
+    filter.filter(
+        "",
+        resultCount -> {
+          assertThat(resultCount).isEqualTo(0);
+          listenerCalled.set(true);
+        });
+    assertThat(listenerCalled.get()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowFingerprintManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowFingerprintManagerTest.java
new file mode 100644
index 0000000..964c04a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowFingerprintManagerTest.java
@@ -0,0 +1,115 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.fingerprint.FingerprintManager;
+import android.hardware.fingerprint.FingerprintManager.AuthenticationCallback;
+import android.hardware.fingerprint.FingerprintManager.AuthenticationResult;
+import android.hardware.fingerprint.FingerprintManager.CryptoObject;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.security.Signature;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowFingerprintManagerTest {
+
+  private FingerprintManager manager;
+
+  @Before
+  public void setUp() {
+    manager =
+        (FingerprintManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.FINGERPRINT_SERVICE);
+  }
+
+  @Test
+  public void authenticate_success() {
+
+    AuthenticationCallback mockCallback = mock(AuthenticationCallback.class);
+
+    CryptoObject cryptoObject = new CryptoObject(mock(Signature.class));
+
+    manager.authenticate(cryptoObject, null, 0, mockCallback, null);
+
+    shadowOf(manager).authenticationSucceeds();
+
+    ArgumentCaptor<AuthenticationResult> result = ArgumentCaptor.forClass(AuthenticationResult.class);
+    verify(mockCallback).onAuthenticationSucceeded(result.capture());
+
+    assertThat(result.getValue().getCryptoObject()).isEqualTo(cryptoObject);
+  }
+
+  @Test
+  public void authenticate_failure() {
+
+    AuthenticationCallback mockCallback = mock(AuthenticationCallback.class);
+
+    CryptoObject cryptoObject = new CryptoObject(mock(Signature.class));
+
+    manager.authenticate(cryptoObject, null, 0, mockCallback, null);
+
+    shadowOf(manager).authenticationFails();
+
+    verify(mockCallback).onAuthenticationFailed();
+  }
+
+  @Test
+  public void hasEnrolledFingerprints() {
+    assertThat(manager.hasEnrolledFingerprints()).isFalse();
+
+    shadowOf(manager).setHasEnrolledFingerprints(true);
+
+    assertThat(manager.hasEnrolledFingerprints()).isTrue();
+  }
+
+  @Test
+  public void setDefaultFingerprints() {
+    assertThat(shadowOf(manager).getEnrolledFingerprints()).isEmpty();
+
+    shadowOf(manager).setDefaultFingerprints(1);
+    assertThat(manager.getEnrolledFingerprints().get(0).getName().toString())
+        .isEqualTo("Fingerprint 0");
+
+    assertThat(shadowOf(manager).getFingerprintId(0)).isEqualTo(0);
+    assertThat(manager.hasEnrolledFingerprints()).isTrue();
+
+    shadowOf(manager).setDefaultFingerprints(0);
+    assertThat(manager.getEnrolledFingerprints()).isEmpty();
+    assertThat(manager.hasEnrolledFingerprints()).isFalse();
+  }
+
+  @Test
+  public void setHasEnrolledFingerprints_shouldSetNumberOfFingerprints() {
+    assertThat(shadowOf(manager).getEnrolledFingerprints()).isEmpty();
+
+    shadowOf(manager).setHasEnrolledFingerprints(true);
+
+    assertThat(manager.getEnrolledFingerprints()).hasSize(1);
+    assertThat(manager.hasEnrolledFingerprints()).isTrue();
+
+    shadowOf(manager).setHasEnrolledFingerprints(false);
+    assertThat(manager.getEnrolledFingerprints()).isEmpty();
+    assertThat(manager.hasEnrolledFingerprints()).isFalse();
+  }
+
+  @Test
+  public void isHardwareDetected() {
+    assertThat(manager.isHardwareDetected()).isFalse();
+
+    shadowOf(manager).setIsHardwareDetected(true);
+
+    assertThat(manager.isHardwareDetected()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowFloatMathTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowFloatMathTest.java
new file mode 100644
index 0000000..65afb9c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowFloatMathTest.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.FloatMath;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/**
+ * Tests for {@link FloatMath}. On SDKs < 23, {@link FloatMath} was implemented using native
+ * methods.
+ */
+@Config(minSdk = JELLY_BEAN)
+@RunWith(AndroidJUnit4.class)
+public class ShadowFloatMathTest {
+
+  @Test
+  public void testFloor() {
+    assertThat(FloatMath.floor(1.1f)).isEqualTo(1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowFontBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowFontBuilderTest.java
new file mode 100644
index 0000000..9f3daf2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowFontBuilderTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.fonts.Font;
+import android.graphics.fonts.FontStyle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.IOException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.TestUtil;
+
+/** Tests for {@link org.robolectric.shadows.ShadowFontBuilder} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public class ShadowFontBuilderTest {
+  private File fontFile;
+
+  @Before
+  public void setup() {
+    fontFile = TestUtil.resourcesBaseDir().resolve("assets/myFont.ttf").toFile();
+  }
+
+  @Test
+  public void fontBuilder_defaultWeightAndSlant() throws IOException {
+    Font font = new Font.Builder(fontFile).build();
+    assertThat(font.getStyle().getWeight()).isEqualTo(FontStyle.FONT_WEIGHT_NORMAL);
+    assertThat(font.getStyle().getSlant()).isEqualTo(FontStyle.FONT_SLANT_UPRIGHT);
+  }
+
+  @Test
+  public void fontBuilder_toString() throws IOException {
+    Font font = new Font.Builder(fontFile).build();
+    assertThat(font.toString()).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowFrameLayoutTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowFrameLayoutTest.java
new file mode 100644
index 0000000..ea4787c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowFrameLayoutTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+
+import android.view.View;
+import android.widget.FrameLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowFrameLayoutTest {
+
+  private FrameLayout frameLayout;
+
+  @Before
+  public void setUp() throws Exception {
+    frameLayout = new FrameLayout(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void testNotNull() {
+    assertNotNull(frameLayout);
+  }
+
+  @Test
+  public void onMeasure_shouldNotLayout() {
+    assertThat(frameLayout.getHeight()).isEqualTo(0);
+    assertThat(frameLayout.getWidth()).isEqualTo(0);
+
+    frameLayout.measure(View.MeasureSpec.makeMeasureSpec(150, View.MeasureSpec.AT_MOST),
+        View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.AT_MOST));
+
+    assertThat(frameLayout.getHeight()).isEqualTo(0);
+    assertThat(frameLayout.getWidth()).isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowGLES20Test.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowGLES20Test.java
new file mode 100644
index 0000000..0ac6d81
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowGLES20Test.java
@@ -0,0 +1,56 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.opengl.GLES20;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test for {@link GLES20} */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowGLES20Test {
+
+  @Test
+  public void glGenFramebuffers() {
+    int[] framebuffers = new int[1];
+    GLES20.glGenFramebuffers(1, framebuffers, 0);
+    assertThat(framebuffers[0]).isAtLeast(1);
+  }
+
+  @Test
+  public void glGenTextures() {
+    int[] textures = new int[1];
+    GLES20.glGenTextures(1, textures, 0);
+    assertThat(textures[0]).isAtLeast(1);
+  }
+
+  @Test
+  public void glCreateShader_invalidEnum() {
+    assertThat(GLES20.glCreateShader(-99999)).isEqualTo(GLES20.GL_INVALID_ENUM);
+  }
+
+  @Test
+  public void glCreateShader_validEnum() {
+    assertThat(GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER)).isAtLeast(1);
+  }
+
+  @Test
+  public void glCreateProgram() {
+    assertThat(GLES20.glCreateProgram()).isAtLeast(1);
+  }
+
+  @Test
+  public void glGetShaderiv_compileStatus() {
+    int[] params = new int[1];
+    GLES20.glGetShaderiv(1, GLES20.GL_COMPILE_STATUS, params, 0);
+    assertThat(params[0]).isEqualTo(GLES20.GL_TRUE);
+  }
+
+  @Test
+  public void glGetProgramiv_compileStatus() {
+    int[] params = new int[1];
+    GLES20.glGetProgramiv(1, GLES20.GL_LINK_STATUS, params, 0);
+    assertThat(params[0]).isEqualTo(GLES20.GL_TRUE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowGeocoderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowGeocoderTest.java
new file mode 100644
index 0000000..d925a61
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowGeocoderTest.java
@@ -0,0 +1,92 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.location.Address;
+import android.location.Geocoder;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link ShadowGeocoder}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowGeocoderTest {
+
+  private Geocoder geocoder;
+
+  @Before
+  public void setUp() throws Exception {
+    Context context = ApplicationProvider.getApplicationContext();
+    geocoder = new Geocoder(context);
+  }
+
+  @Test
+  public void isPresentReturnsTrueByDefault() {
+    assertThat(Geocoder.isPresent()).isTrue();
+  }
+
+  @Test
+  public void isPresentReturnsFalseWhenOverridden() {
+    ShadowGeocoder.setIsPresent(false);
+
+    assertThat(Geocoder.isPresent()).isFalse();
+  }
+
+  @Test
+  public void getFromLocationReturnsAnEmptyArrayByDefault() throws IOException {
+    assertThat(geocoder.getFromLocation(90.0,90.0,1)).hasSize(0);
+  }
+
+  @Test
+  public void getFromLocationReturnsTheOverwrittenListLimitingByMaxResults() throws IOException {
+    ShadowGeocoder shadowGeocoder = shadowOf(geocoder);
+
+    List<Address> list = Arrays.asList(new Address(Locale.getDefault()), new Address(Locale.CANADA));
+    shadowGeocoder.setFromLocation(list);
+
+    List<Address> result = geocoder.getFromLocation(90.0, 90.0, 1);
+    assertThat(result).hasSize(1);
+
+    result = geocoder.getFromLocation(90.0, 90.0, 2);
+    assertThat(result).hasSize(2);
+
+    result = geocoder.getFromLocation(90.0, 90.0, 3);
+    assertThat(result).hasSize(2);
+  }
+
+  @Test
+  public void getFromLocation_throwsExceptionForInvalidLatitude() throws IOException {
+    try {
+      geocoder.getFromLocation(91.0, 90.0, 1);
+      fail("IllegalArgumentException not thrown");
+    } catch (IllegalArgumentException thrown) {
+      assertThat(thrown).hasMessageThat().contains(Double.toString(91.0));
+    }
+  }
+
+  @Test
+  public void getFromLocation_throwsExceptionForInvalidLongitude() throws IOException {
+    try {
+      geocoder.getFromLocation(15.0, -211.0, 1);
+      fail("IllegalArgumentException not thrown");
+    } catch (IllegalArgumentException thrown) {
+      assertThat(thrown).hasMessageThat().contains(Double.toString(-211.0));
+    }
+  }
+
+  @Test
+  public void resettingShadowRestoresDefaultValueForIsPresent() {
+    ShadowGeocoder.setIsPresent(false);
+    ShadowGeocoder.reset();
+    assertThat(Geocoder.isPresent()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowGestureDetectorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowGestureDetectorTest.java
new file mode 100644
index 0000000..10ced80
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowGestureDetectorTest.java
@@ -0,0 +1,130 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.core.view.MotionEventBuilder;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowGestureDetectorTest {
+
+  private GestureDetector detector;
+  private MotionEvent motionEvent;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    detector = new GestureDetector(new TestOnGestureListener());
+    motionEvent =
+        MotionEventBuilder.newBuilder()
+            .setAction(MotionEvent.ACTION_UP)
+            .setPointer(100, 30)
+            .build();
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void test_getOnTouchEventMotionEvent() {
+    detector.onTouchEvent(motionEvent);
+    assertSame(motionEvent, shadowOf(detector).getOnTouchEventMotionEvent());
+  }
+
+  @Test
+  public void test_reset() {
+    detector.onTouchEvent(motionEvent);
+    assertSame(motionEvent, shadowOf(detector).getOnTouchEventMotionEvent());
+
+    shadowOf(detector).reset();
+    assertNull(shadowOf(detector).getOnTouchEventMotionEvent());
+  }
+
+  @Test
+  public void test_getListener() {
+    TestOnGestureListener listener = new TestOnGestureListener();
+    assertSame(listener, shadowOf(new GestureDetector(listener)).getListener());
+    assertSame(listener, shadowOf(new GestureDetector(null, listener)).getListener());
+  }
+
+  @Test
+  public void canAnswerLastGestureDetector() {
+    GestureDetector newDetector = new GestureDetector(context, new TestOnGestureListener());
+    assertNotSame(newDetector, ShadowGestureDetector.getLastActiveDetector());
+    newDetector.onTouchEvent(motionEvent);
+    assertSame(newDetector, ShadowGestureDetector.getLastActiveDetector());
+  }
+
+  @Test
+  public void getOnDoubleTapListener_shouldReturnSetDoubleTapListener() {
+    GestureDetector subject = new GestureDetector(context, new TestOnGestureListener());
+    GestureDetector.OnDoubleTapListener onDoubleTapListener = new GestureDetector.OnDoubleTapListener() {
+      @Override
+      public boolean onSingleTapConfirmed(MotionEvent e) {
+        return false;
+      }
+
+      @Override
+      public boolean onDoubleTap(MotionEvent e) {
+        return false;
+      }
+
+      @Override
+      public boolean onDoubleTapEvent(MotionEvent e) {
+        return false;
+      }
+    };
+
+    subject.setOnDoubleTapListener(onDoubleTapListener);
+    assertEquals(shadowOf(subject).getOnDoubleTapListener(), onDoubleTapListener);
+
+    subject.setOnDoubleTapListener(null);
+    assertNull(shadowOf(subject).getOnDoubleTapListener());
+  }
+
+  @Test
+  public void getOnDoubleTapListener_shouldReturnOnGestureListenerFromConstructor() {
+    GestureDetector.OnGestureListener onGestureListener = new GestureDetector.SimpleOnGestureListener();
+    GestureDetector subject = new GestureDetector(context, onGestureListener);
+    assertEquals(shadowOf(subject).getOnDoubleTapListener(), onGestureListener);
+  }
+
+  private static class TestOnGestureListener implements GestureDetector.OnGestureListener {
+    @Override
+    public boolean onDown(MotionEvent e) {
+      return false;
+    }
+
+    @Override
+    public void onShowPress(MotionEvent e) {
+    }
+
+    @Override
+    public boolean onSingleTapUp(MotionEvent e) {
+      return false;
+    }
+
+    @Override
+    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+      return false;
+    }
+
+    @Override
+    public void onLongPress(MotionEvent e) {
+    }
+
+    @Override
+    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+      return false;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowGradientDrawableTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowGradientDrawableTest.java
new file mode 100644
index 0000000..667e301
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowGradientDrawableTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.drawable.GradientDrawable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowGradientDrawableTest {
+  @Test
+  public void testGetLastSetColor_returnsColor() {
+    GradientDrawable gradientDrawable = new GradientDrawable();
+    ShadowGradientDrawable shadowGradientDrawable = shadowOf(gradientDrawable);
+    int color = 123;
+    gradientDrawable.setColor(color);
+    assertThat(shadowGradientDrawable.getLastSetColor()).isEqualTo(color);
+  }
+
+  @Test
+  public void testGetStrokeWidth_returnsStrokeWidth() {
+    int strokeWidth = 123;
+    GradientDrawable gradientDrawable = new GradientDrawable();
+
+    gradientDrawable.setStroke(strokeWidth, /* color= */ 456);
+
+    ShadowGradientDrawable shadowGradientDrawable = shadowOf(gradientDrawable);
+    assertThat(shadowGradientDrawable.getStrokeWidth()).isEqualTo(strokeWidth);
+  }
+
+  @Test
+  public void testGetStrokeColor_returnsStrokeColor() {
+    int stokeColor = 123;
+    GradientDrawable gradientDrawable = new GradientDrawable();
+
+    gradientDrawable.setStroke(/* width= */ 456, stokeColor);
+
+    ShadowGradientDrawable shadowGradientDrawable = shadowOf(gradientDrawable);
+    assertThat(shadowGradientDrawable.getStrokeColor()).isEqualTo(stokeColor);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowHandlerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowHandlerTest.java
new file mode 100644
index 0000000..e139cab
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowHandlerTest.java
@@ -0,0 +1,530 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.TestRunnable;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LEGACY)
+public class ShadowHandlerTest {
+  private List<String> transcript;
+  TestRunnable scratchRunnable = new TestRunnable();
+
+  private final Handler.Callback callback =
+      new Handler.Callback() {
+        @Override
+        public boolean handleMessage(Message msg) {
+          hasHandlerCallbackHandledMessage = true;
+          return false;
+        }
+      };
+
+  private Boolean hasHandlerCallbackHandledMessage = false;
+
+  @Before
+  public void setUp() throws Exception {
+    transcript = new ArrayList<>();
+  }
+
+  @Test
+  public void testInsertsRunnablesBasedOnLooper() {
+    Looper looper = newLooper(false);
+
+    Handler handler1 = new Handler(looper);
+    handler1.post(new Say("first thing"));
+
+    Handler handler2 = new Handler(looper);
+    handler2.post(new Say("second thing"));
+
+    shadowOf(looper).idle();
+
+    assertThat(transcript).containsExactly("first thing", "second thing");
+  }
+
+  @Test
+  public void testDefaultConstructorUsesDefaultLooper() {
+    Handler handler1 = new Handler();
+    handler1.post(new Say("first thing"));
+
+    Handler handler2 = new Handler(Looper.myLooper());
+    handler2.post(new Say("second thing"));
+
+    shadowOf(Looper.myLooper()).idle();
+
+    assertThat(transcript).containsExactly("first thing", "second thing");
+  }
+
+  private static Looper newLooper(boolean canQuit) {
+    return ReflectionHelpers.callConstructor(
+        Looper.class, ClassParameter.from(boolean.class, canQuit));
+  }
+
+  @Test
+  public void testDifferentLoopersGetDifferentQueues() {
+    Looper looper1 = newLooper(true);
+    ShadowLooper.pauseLooper(looper1);
+
+    Looper looper2 = newLooper(true);
+    ShadowLooper.pauseLooper(looper2);
+    // Make sure looper has a different scheduler to the first
+    shadowOf(looper2.getQueue()).setScheduler(new Scheduler());
+
+    Handler handler1 = new Handler(looper1);
+    handler1.post(new Say("first thing"));
+
+    Handler handler2 = new Handler(looper2);
+    handler2.post(new Say("second thing"));
+
+    shadowOf(looper2).idle();
+
+    assertThat(transcript).containsExactly("second thing");
+  }
+
+  @Test
+  public void shouldCallProvidedHandlerCallback() {
+    Handler handler = new Handler(callback);
+    handler.sendMessage(new Message());
+    assertTrue(hasHandlerCallbackHandledMessage);
+  }
+
+  @Test
+  public void testPostAndIdleMainLooper() {
+    new Handler().post(scratchRunnable);
+    ShadowLooper.idleMainLooper();
+    assertThat(scratchRunnable.wasRun).isTrue();
+  }
+
+  @Test
+  public void postDelayedThenIdleMainLooper_shouldNotRunRunnable() {
+    new Handler().postDelayed(scratchRunnable, 1);
+    ShadowLooper.idleMainLooper();
+    assertThat(scratchRunnable.wasRun).isFalse();
+  }
+
+  @Test
+  public void testPostDelayedThenRunMainLooperOneTask() {
+    new Handler().postDelayed(scratchRunnable, 1);
+    ShadowLooper.runMainLooperOneTask();
+    assertThat(scratchRunnable.wasRun).isTrue();
+  }
+
+  @Test
+  public void testRemoveCallbacks() {
+    Handler handler = new Handler();
+    ShadowLooper shadowLooper = shadowOf(handler.getLooper());
+    shadowLooper.pause();
+    handler.post(scratchRunnable);
+    handler.removeCallbacks(scratchRunnable);
+
+    shadowLooper.unPause();
+
+    assertThat(scratchRunnable.wasRun).isFalse();
+  }
+
+  @Test
+  public void testPostDelayedThenRunMainLooperToNextTask_shouldRunOneTask() {
+    new Handler().postDelayed(scratchRunnable, 1);
+    ShadowLooper.runMainLooperToNextTask();
+    assertThat(scratchRunnable.wasRun).isTrue();
+  }
+
+  @Test
+  public void testPostDelayedTwiceThenRunMainLooperToNextTask_shouldRunMultipleTasks() {
+    TestRunnable task1 = new TestRunnable();
+    TestRunnable task2 = new TestRunnable();
+
+    new Handler().postDelayed(task1, 1);
+    new Handler().postDelayed(task2, 1);
+
+    ShadowLooper.runMainLooperToNextTask();
+    assertThat(task1.wasRun).isTrue();
+    assertThat(task2.wasRun).isTrue();
+  }
+
+  @Test
+  public void testPostDelayedTwiceThenRunMainLooperOneTask_shouldRunOnlyOneTask() {
+    TestRunnable task1 = new TestRunnable();
+    TestRunnable task2 = new TestRunnable();
+
+    new Handler().postDelayed(task1, 1);
+    new Handler().postDelayed(task2, 1);
+
+    ShadowLooper.runMainLooperOneTask();
+    assertThat(task1.wasRun).isTrue();
+    assertThat(task2.wasRun).isFalse();
+  }
+
+  @Test
+  public void testPostDelayedMultipleThenRunMainLooperOneTask_shouldRunMultipleTask() {
+    TestRunnable task1 = new TestRunnable();
+    TestRunnable task2 = new TestRunnable();
+    TestRunnable task3 = new TestRunnable();
+
+    new Handler().postDelayed(task1, 1);
+    new Handler().postDelayed(task2, 10);
+    new Handler().postDelayed(task3, 100);
+
+    ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+    assertThat(task1.wasRun).isTrue();
+    assertThat(task2.wasRun).isTrue();
+    assertThat(task3.wasRun).isTrue();
+  }
+
+  @Test
+  public void
+      testPostAtFrontOfQueueThenRunMainLooperOneTaskAtATime_shouldRunFrontOfQueueTaskFirst() {
+    TestRunnable task1 = new TestRunnable();
+    TestRunnable task2 = new TestRunnable();
+
+    ShadowLooper.pauseMainLooper();
+    new Handler().post(task1);
+    boolean result = new Handler().postAtFrontOfQueue(task2);
+
+    assertTrue(result);
+
+    ShadowLooper.runMainLooperOneTask();
+    assertThat(task2.wasRun).isTrue();
+    assertThat(task1.wasRun).isFalse();
+    ShadowLooper.runMainLooperOneTask();
+    assertThat(task1.wasRun).isTrue();
+  }
+
+  @Test
+  public void testNestedPost_shouldRunLast() {
+    ShadowLooper.pauseMainLooper();
+    final List<Integer> order = new ArrayList<>();
+    final Handler h = new Handler();
+    h.post(
+        () -> {
+          order.add(1);
+          h.post(() -> order.add(3));
+        });
+    h.post(() -> order.add(2));
+    ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+    assertThat(order).containsExactly(1, 2, 3);
+  }
+
+  @Test
+  public void
+      testSendMessageAtFrontOfQueueThenRunMainLooperOneMsgAtATime_shouldRunFrontOfQueueMsgFirst() {
+    Handler handler = new Handler();
+
+    ShadowLooper.pauseMainLooper();
+    // Post two messages to handler. Handle first message and confirm that msg posted
+    // to front is removed.
+    handler.obtainMessage(123).sendToTarget();
+    Message frontMsg = handler.obtainMessage(345);
+    boolean result = handler.sendMessageAtFrontOfQueue(frontMsg);
+
+    assertTrue(result);
+
+    assertTrue(handler.hasMessages(123));
+    assertTrue(handler.hasMessages(345));
+    ShadowLooper.runMainLooperOneTask();
+    assertTrue(handler.hasMessages(123));
+    assertFalse(handler.hasMessages(345));
+    ShadowLooper.runMainLooperOneTask();
+    assertFalse(handler.hasMessages(123));
+    assertFalse(handler.hasMessages(345));
+  }
+
+  @Test
+  public void sendEmptyMessage_addMessageToQueue() {
+    ShadowLooper.pauseMainLooper();
+    Handler handler = new Handler();
+    assertThat(handler.hasMessages(123)).isFalse();
+    handler.sendEmptyMessage(123);
+    assertThat(handler.hasMessages(456)).isFalse();
+    assertThat(handler.hasMessages(123)).isTrue();
+    ShadowLooper.idleMainLooper(0, TimeUnit.MILLISECONDS);
+    assertThat(handler.hasMessages(123)).isFalse();
+  }
+
+  @Test
+  public void sendEmptyMessageDelayed_sendsMessageAtCorrectTime() {
+    ShadowLooper.pauseMainLooper();
+    Handler handler = new Handler();
+    handler.sendEmptyMessageDelayed(123, 500);
+    assertThat(handler.hasMessages(123)).isTrue();
+    ShadowLooper.idleMainLooper(100, TimeUnit.MILLISECONDS);
+    assertThat(handler.hasMessages(123)).isTrue();
+    ShadowLooper.idleMainLooper(400, TimeUnit.MILLISECONDS);
+    assertThat(handler.hasMessages(123)).isFalse();
+  }
+
+  @Test
+  public void sendMessageAtTime_sendsMessageAtCorrectTime() {
+    ShadowLooper.pauseMainLooper();
+    Handler handler = new Handler();
+    Message message = handler.obtainMessage(123);
+    handler.sendMessageAtTime(message, 500);
+    assertThat(handler.hasMessages(123)).isTrue();
+    ShadowLooper.idleMainLooper(100, TimeUnit.MILLISECONDS);
+    assertThat(handler.hasMessages(123)).isTrue();
+    ShadowLooper.idleMainLooper(400, TimeUnit.MILLISECONDS);
+    assertThat(handler.hasMessages(123)).isFalse();
+  }
+
+  @Test
+  public void removeMessages_takesMessageOutOfQueue() {
+    ShadowLooper.pauseMainLooper();
+    Handler handler = new Handler();
+    handler.sendEmptyMessageDelayed(123, 500);
+    handler.removeMessages(123);
+    assertThat(handler.hasMessages(123)).isFalse();
+  }
+
+  @Test
+  public void removeMessage_withSpecifiedObject() {
+    ShadowLooper.pauseMainLooper();
+    Handler handler = new Handler();
+    Message.obtain(handler, 123, "foo").sendToTarget();
+    Message.obtain(handler, 123, "bar").sendToTarget();
+
+    assertThat(handler.hasMessages(123)).isTrue();
+    assertThat(handler.hasMessages(123, "foo")).isTrue();
+    assertThat(handler.hasMessages(123, "bar")).isTrue();
+    assertThat(handler.hasMessages(123, "baz")).isFalse();
+
+    handler.removeMessages(123, "foo");
+    assertThat(handler.hasMessages(123)).isTrue();
+
+    handler.removeMessages(123, "bar");
+    assertThat(handler.hasMessages(123)).isFalse();
+  }
+
+  @Test
+  public void testHasMessagesWithWhatAndObject() {
+    ShadowLooper.pauseMainLooper();
+    Object testObject = new Object();
+    Handler handler = new Handler();
+    Message message = handler.obtainMessage(123, testObject);
+
+    assertFalse(handler.hasMessages(123, testObject));
+
+    handler.sendMessage(message);
+
+    assertTrue(handler.hasMessages(123, testObject));
+  }
+
+  @Test
+  public void testSendToTarget() {
+    ShadowLooper.pauseMainLooper();
+    Object testObject = new Object();
+    Handler handler = new Handler();
+    Message message = handler.obtainMessage(123, testObject);
+
+    assertThat(handler).isEqualTo(message.getTarget());
+
+    message.sendToTarget();
+
+    assertTrue(handler.hasMessages(123, testObject));
+  }
+
+  @Test
+  public void removeMessages_removesFromLooperQueueAsWell() {
+    final boolean[] wasRun = new boolean[1];
+    ShadowLooper.pauseMainLooper();
+    Handler handler =
+        new Handler() {
+          @Override
+          public void handleMessage(Message msg) {
+            wasRun[0] = true;
+          }
+        };
+    handler.sendEmptyMessageDelayed(123, 500);
+    handler.removeMessages(123);
+    ShadowLooper.unPauseMainLooper();
+    assertThat(wasRun[0]).isFalse();
+  }
+
+  @Test
+  public void scheduler_wontDispatchRemovedMessage_evenIfMessageReused() {
+    final ArrayList<Long> runAt = new ArrayList<>();
+    ShadowLooper.pauseMainLooper();
+    Handler handler =
+        new Handler() {
+          @Override
+          public void handleMessage(Message msg) {
+            runAt.add(shadowOf(Looper.myLooper()).getScheduler().getCurrentTime());
+          }
+        };
+
+    final long startTime = Robolectric.getForegroundThreadScheduler().getCurrentTime();
+    Message msg = handler.obtainMessage(123);
+    handler.sendMessageDelayed(msg, 200);
+    handler.removeMessages(123);
+    Message newMsg = handler.obtainMessage(123);
+    assertWithMessage("new message").that(newMsg).isSameInstanceAs(msg);
+    handler.sendMessageDelayed(newMsg, 400);
+    ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+    // Original implementation had a bug which caused reused messages to still
+    // be invoked at their original post time.
+    assertWithMessage("handledAt").that(runAt).containsExactly(startTime + 400L);
+  }
+
+  @Test
+  public void shouldRemoveAllCallbacksAndMessages() {
+    final boolean[] wasRun = new boolean[1];
+    ShadowLooper.pauseMainLooper();
+    Handler handler =
+        new Handler() {
+          @Override
+          public void handleMessage(Message msg) {
+            wasRun[0] = true;
+          }
+        };
+    handler.sendEmptyMessage(0);
+    handler.post(scratchRunnable);
+
+    handler.removeCallbacksAndMessages(null);
+    ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+
+    assertWithMessage("Message").that(wasRun[0]).isFalse();
+    assertWithMessage("Callback").that(scratchRunnable.wasRun).isFalse();
+  }
+
+  @Test
+  public void shouldRemoveSingleMessage() {
+    final List<Object> objects = new ArrayList<>();
+    ShadowLooper.pauseMainLooper();
+
+    Handler handler =
+        new Handler() {
+          @Override
+          public void handleMessage(Message msg) {
+            objects.add(msg.obj);
+          }
+        };
+
+    Object firstObj = new Object();
+    handler.sendMessage(handler.obtainMessage(0, firstObj));
+
+    Object secondObj = new Object();
+    handler.sendMessage(handler.obtainMessage(0, secondObj));
+
+    handler.removeCallbacksAndMessages(secondObj);
+    ShadowLooper.unPauseMainLooper();
+
+    assertThat(objects).containsExactly(firstObj);
+  }
+
+  @Test
+  public void shouldRemoveTaggedCallback() {
+    ShadowLooper.pauseMainLooper();
+    Handler handler = new Handler();
+
+    final int[] count = new int[1];
+    Runnable r = () -> count[0]++;
+
+    String tag1 = "tag1", tag2 = "tag2";
+
+    handler.postAtTime(r, tag1, 100);
+    handler.postAtTime(r, tag2, 105);
+
+    handler.removeCallbacks(r, tag2);
+    ShadowLooper.unPauseMainLooper();
+
+    assertWithMessage("run count").that(count[0]).isEqualTo(1);
+    // This assertion proves that it was the first runnable that ran,
+    // which proves that the correctly tagged runnable was removed.
+    assertWithMessage("currentTime")
+        .that(shadowOf(handler.getLooper()).getScheduler().getCurrentTime())
+        .isEqualTo(100);
+  }
+
+  @Test
+  public void shouldObtainMessage() {
+    Message m0 = new Handler().obtainMessage();
+    assertThat(m0.what).isEqualTo(0);
+    assertThat(m0.obj).isNull();
+
+    Message m1 = new Handler().obtainMessage(1);
+    assertThat(m1.what).isEqualTo(1);
+    assertThat(m1.obj).isNull();
+
+    Message m2 = new Handler().obtainMessage(1, "foo");
+    assertThat(m2.what).isEqualTo(1);
+    assertThat(m2.obj).isEqualTo((Object) "foo");
+
+    Message m3 = new Handler().obtainMessage(1, 2, 3);
+    assertThat(m3.what).isEqualTo(1);
+    assertThat(m3.arg1).isEqualTo(2);
+    assertThat(m3.arg2).isEqualTo(3);
+    assertThat(m3.obj).isNull();
+
+    Message m4 = new Handler().obtainMessage(1, 2, 3, "foo");
+    assertThat(m4.what).isEqualTo(1);
+    assertThat(m4.arg1).isEqualTo(2);
+    assertThat(m4.arg2).isEqualTo(3);
+    assertThat(m4.obj).isEqualTo((Object) "foo");
+  }
+
+  @Test
+  public void shouldSetWhenOnMessage() {
+    final List<Long> whens = new ArrayList<>();
+    Handler h =
+        new Handler(
+            msg -> {
+              whens.add(msg.getWhen());
+              return false;
+            });
+
+    final long startTime = Robolectric.getForegroundThreadScheduler().getCurrentTime();
+    h.sendEmptyMessage(0);
+    h.sendEmptyMessageDelayed(0, 4000L);
+    Robolectric.getForegroundThreadScheduler().advanceToLastPostedRunnable();
+    h.sendEmptyMessageDelayed(0, 12000L);
+    Robolectric.getForegroundThreadScheduler().advanceToLastPostedRunnable();
+
+    assertWithMessage("whens")
+        .that(whens)
+        .containsExactly(startTime, startTime + 4000, startTime + 16000);
+  }
+
+  @Test
+  public void shouldRemoveMessageFromQueueBeforeDispatching() {
+    Handler h =
+        new Handler(Looper.myLooper()) {
+          @Override
+          public void handleMessage(Message msg) {
+            assertFalse(hasMessages(0));
+          }
+        };
+    h.sendEmptyMessage(0);
+    h.sendMessageAtFrontOfQueue(h.obtainMessage());
+  }
+
+  private class Say implements Runnable {
+    private String event;
+
+    public Say(String event) {
+      this.event = event;
+    }
+
+    @Override
+    public void run() {
+      transcript.add(event);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowHandlerThreadTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowHandlerThreadTest.java
new file mode 100644
index 0000000..a64729b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowHandlerThreadTest.java
@@ -0,0 +1,100 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.os.HandlerThread;
+import android.os.Looper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowHandlerThreadTest {
+
+  private HandlerThread handlerThread;
+
+  @After
+  public void tearDown() throws Exception {
+    // Try to ensure we've exited the thread at the end of each test
+    if ( handlerThread != null ) {
+      handlerThread.quit();
+      handlerThread.join();
+    }
+  }
+
+  @Test
+  public void shouldReturnLooper() {
+    handlerThread = new HandlerThread("test");
+    handlerThread.start();
+    assertNotNull(handlerThread.getLooper());
+    assertNotSame(
+        handlerThread.getLooper(), ApplicationProvider.getApplicationContext().getMainLooper());
+  }
+
+  @Test
+  public void shouldReturnNullIfThreadHasNotBeenStarted() {
+    handlerThread = new HandlerThread("test");
+    assertNull(handlerThread.getLooper());
+  }
+
+  @Test
+  public void shouldQuitLooperAndThread() throws Exception {
+    handlerThread = new HandlerThread("test");
+    Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
+    handlerThread.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
+    handlerThread.start();
+    assertTrue(handlerThread.isAlive());
+    assertTrue(handlerThread.quit());
+    handlerThread.join();
+    assertFalse(handlerThread.isAlive());
+    handlerThread = null;
+  }
+
+  @Test
+  public void shouldStopThreadIfLooperIsQuit() throws Exception {
+    handlerThread = new HandlerThread("test1");
+    handlerThread.start();
+    Looper looper = handlerThread.getLooper();
+
+    looper.quit();
+    handlerThread.join();
+    assertFalse(handlerThread.isAlive());
+    handlerThread = null;
+  }
+
+  @Test
+  public void shouldCallOnLooperPrepared() throws Exception {
+    final Boolean[] wasCalled = new Boolean[] { false };
+    final CountDownLatch latch = new CountDownLatch(1);
+    handlerThread = new HandlerThread("test") {
+      @Override
+      protected void onLooperPrepared() {
+        wasCalled[0] = true;
+        latch.countDown();
+      }
+    };
+    handlerThread.start();
+    try {
+      assertNotNull(handlerThread.getLooper());
+      latch.await(1, TimeUnit.SECONDS);
+      assertTrue(wasCalled[0]);
+    } finally {
+      handlerThread.quit();
+    }
+  }
+
+  private static class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
+    @Override
+    public void uncaughtException(Thread t, Throwable e) {
+      e.printStackTrace();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowHardwareBufferTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowHardwareBufferTest.java
new file mode 100644
index 0000000..35d16b6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowHardwareBufferTest.java
@@ -0,0 +1,287 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.hardware.HardwareBuffer;
+import android.os.Parcel;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for org.robolectric.shadows.ShadowHardwareBuffer. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowHardwareBufferTest {
+  private static final int INVALID_WIDTH = 0;
+  private static final int INVALID_HEIGHT = 0;
+  private static final int INVALID_LAYERS = 0;
+  private static final int INVALID_FORMAT = -1;
+  private static final long INVALID_USAGE_FLAGS = -1;
+  private static final int VALID_WIDTH = 16;
+  private static final int VALID_HEIGHT = 32;
+  private static final int VALID_LAYERS = 2;
+
+  private static final ImmutableList<Integer> VALID_FORMATS_O =
+      ImmutableList.of(
+          HardwareBuffer.RGBA_8888,
+          HardwareBuffer.RGBA_FP16,
+          HardwareBuffer.RGBA_1010102,
+          HardwareBuffer.RGBX_8888,
+          HardwareBuffer.RGB_888,
+          HardwareBuffer.RGB_565,
+          HardwareBuffer.BLOB);
+
+  private static final ImmutableList<Integer> VALID_FORMATS_P =
+      ImmutableList.of(
+          HardwareBuffer.D_16,
+          HardwareBuffer.D_24,
+          HardwareBuffer.D_FP32,
+          HardwareBuffer.DS_24UI8,
+          HardwareBuffer.DS_FP32UI8,
+          HardwareBuffer.S_UI8);
+
+  private static final long VALID_USAGE_FLAGS_O =
+      HardwareBuffer.USAGE_CPU_READ_RARELY
+          | HardwareBuffer.USAGE_CPU_READ_OFTEN
+          | HardwareBuffer.USAGE_CPU_WRITE_RARELY
+          | HardwareBuffer.USAGE_CPU_WRITE_OFTEN
+          | HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
+          | HardwareBuffer.USAGE_GPU_COLOR_OUTPUT
+          | HardwareBuffer.USAGE_PROTECTED_CONTENT
+          | HardwareBuffer.USAGE_VIDEO_ENCODE
+          | HardwareBuffer.USAGE_GPU_DATA_BUFFER
+          | HardwareBuffer.USAGE_SENSOR_DIRECT_DATA;
+
+  private static final long VALID_USAGE_FLAGS_P =
+      HardwareBuffer.USAGE_GPU_CUBE_MAP | HardwareBuffer.USAGE_GPU_MIPMAP_COMPLETE;
+
+  @Test
+  @Config(minSdk = O)
+  public void createInvalidWidthThrows() {
+    try {
+      HardwareBuffer.create(
+          INVALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+      fail("IllegalArgumentException should be thrown with invalid width.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void createInvalidHeightThrows() {
+    try {
+      HardwareBuffer.create(
+          VALID_WIDTH, INVALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+      fail("IllegalArgumentException should be thrown with invalid height.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void createInvalidFormatThrows() {
+    try {
+      HardwareBuffer.create(
+          VALID_WIDTH, VALID_HEIGHT, INVALID_FORMAT, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+      fail("IllegalArgumentException should be thrown with invalid format.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void createInvalidLayersThrows() {
+    try {
+      HardwareBuffer.create(
+          VALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, INVALID_LAYERS, VALID_USAGE_FLAGS_O);
+      fail("IllegalArgumentException should be thrown with invalid layer count.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void createInvalidUsageFlagsThrows() {
+    try {
+      HardwareBuffer.create(
+          VALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, INVALID_USAGE_FLAGS);
+      fail("IllegalArgumentException should be thrown with invalid usage flags.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O, maxSdk = O)
+  public void createWithPFormatsFailsOnO() {
+    for (int format : VALID_FORMATS_P) {
+      try {
+        HardwareBuffer.create(
+            VALID_WIDTH,
+            format == HardwareBuffer.BLOB ? 1 : VALID_HEIGHT,
+            format,
+            VALID_LAYERS,
+            VALID_USAGE_FLAGS_O);
+        fail(
+            "IllegalArgumentException should be thrown when using Android P formats on Android O.");
+      } catch (IllegalArgumentException e) {
+        // pass
+      }
+    }
+  }
+
+  @Test
+  @Config(minSdk = O, maxSdk = O)
+  public void createWithPFlagsThrowsOnO() {
+    try {
+      HardwareBuffer.create(
+          VALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, VALID_USAGE_FLAGS_P);
+      fail("IllegalArgumentException should be thrown when using Android P flags on Android O.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void createWithBlobFormatInvalidHeightThrows() {
+    try {
+      HardwareBuffer.create(VALID_WIDTH, 0, HardwareBuffer.BLOB, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+      fail("IllegalArgumentException should be thrown when creating a BLOB buffer with height 0.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+
+    try {
+      HardwareBuffer.create(VALID_WIDTH, 2, HardwareBuffer.BLOB, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+      fail("IllegalArgumentException should be thrown when creating a BLOB buffer with height 2.");
+    } catch (IllegalArgumentException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void createWithOFormatsAndFlagsSucceedsOnOAndLater() {
+    for (int format : VALID_FORMATS_O) {
+      int height = format == HardwareBuffer.BLOB ? 1 : VALID_HEIGHT;
+      try (HardwareBuffer buffer =
+          HardwareBuffer.create(VALID_WIDTH, height, format, VALID_LAYERS, VALID_USAGE_FLAGS_O)) {
+        assertNotNull(buffer);
+      }
+    }
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void createWithPFormatsAndFlagsSucceedsOnPAndLater() {
+    for (int format : VALID_FORMATS_P) {
+      try (HardwareBuffer buffer =
+          HardwareBuffer.create(
+              VALID_WIDTH, VALID_HEIGHT, format, VALID_LAYERS, VALID_USAGE_FLAGS_P)) {
+        assertNotNull(buffer);
+      }
+    }
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void createWithPFlagsSucceedsOnPAndLater() {
+    try (HardwareBuffer buffer =
+        HardwareBuffer.create(
+            VALID_WIDTH,
+            VALID_HEIGHT,
+            HardwareBuffer.RGBA_8888,
+            VALID_LAYERS,
+            VALID_USAGE_FLAGS_P)) {
+      assertNotNull(buffer);
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void gettersOnHardwareBufferAreCorrect() {
+    HardwareBuffer buffer =
+        HardwareBuffer.create(
+            VALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+    assertNotNull(buffer);
+    assertEquals(VALID_WIDTH, buffer.getWidth());
+    assertEquals(VALID_HEIGHT, buffer.getHeight());
+    assertEquals(HardwareBuffer.RGBA_8888, buffer.getFormat());
+    assertEquals(VALID_LAYERS, buffer.getLayers());
+    assertEquals(VALID_USAGE_FLAGS_O, buffer.getUsage());
+    buffer.close();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void gettersOnClosedHardwareBufferThrows() {
+    HardwareBuffer buffer =
+        HardwareBuffer.create(
+            VALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+    assertNotNull(buffer);
+    buffer.close();
+    assertTrue(buffer.isClosed());
+    try {
+      buffer.getWidth();
+      fail("IllegalStateException should be thrown when accessing getWidth on a closed buffer.");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+    try {
+      buffer.getHeight();
+      fail("IllegalStateException should be thrown when accessing getHeight on a closed buffer.");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+    try {
+      buffer.getFormat();
+      fail("IllegalStateException should be thrown when accessing getWidth on a closed buffer.");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+    try {
+      buffer.getLayers();
+      fail("IllegalStateException should be thrown when accessing getLayers on a closed buffer.");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+    try {
+      buffer.getLayers();
+      fail("IllegalStateException should be thrown when accessing getLayers on a closed buffer.");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void gettersOnParceledBufferAreCorrect() {
+    HardwareBuffer buffer =
+        HardwareBuffer.create(
+            VALID_WIDTH, VALID_HEIGHT, HardwareBuffer.RGBA_8888, VALID_LAYERS, VALID_USAGE_FLAGS_O);
+    assertNotNull(buffer);
+    final Parcel parcel = Parcel.obtain();
+    buffer.writeToParcel(parcel, 0);
+    parcel.setDataPosition(0);
+
+    HardwareBuffer otherBuffer = HardwareBuffer.CREATOR.createFromParcel(parcel);
+    assertEquals(VALID_WIDTH, otherBuffer.getWidth());
+    assertEquals(VALID_HEIGHT, otherBuffer.getHeight());
+    assertEquals(HardwareBuffer.RGBA_8888, otherBuffer.getFormat());
+    assertEquals(VALID_LAYERS, otherBuffer.getLayers());
+    assertEquals(VALID_USAGE_FLAGS_O, otherBuffer.getUsage());
+    buffer.close();
+    otherBuffer.close();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowHtmlTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowHtmlTest.java
new file mode 100644
index 0000000..cbd7a38
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowHtmlTest.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.text.Html;
+import android.text.Spanned;
+import android.widget.EditText;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowHtmlTest {
+  private static final String HTML_SHORT = "<img src='foo.png'>";
+  private static final String HTML_LONG = String.format("<img src='%s.png'>",
+      String.join("", Collections.nCopies(100, "foo")));
+
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void shouldBeAbleToGetTextFromTextViewAfterUsingSetTextWithHtmlDotFromHtml() {
+    TextView textView = new TextView(context);
+    textView.setText(Html.fromHtml("<b>some</b> html text"));
+    assertThat(textView.getText().toString()).isEqualTo("some html text");
+  }
+
+  @Test
+  public void shouldBeAbleToGetTextFromEditTextAfterUsingSetTextWithHtmlDotFromHtml() {
+    EditText editText = new EditText(context);
+    editText.setText(Html.fromHtml("<b>some</b> html text"));
+    assertThat(editText.getText().toString()).isEqualTo("some html text");
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void shouldThrowNullPointerExceptionWhenNullStringEncountered() {
+    Html.fromHtml(null);
+  }
+
+  @Test
+  public void fromHtml_shouldJustReturnArgByDefault() {
+    String text = "<b>foo</b>";
+    Spanned spanned = Html.fromHtml(text);
+    assertThat(spanned.toString()).isEqualTo("foo");
+  }
+
+  @Config(maxSdk = M)
+  @Test public void testArraycopyLegacyShort() {
+    //noinspection deprecation
+    Html.fromHtml(HTML_SHORT, null, null);
+  }
+
+  @Config(maxSdk = M)
+  @Test public void testArraycopyLegacyLong() {
+    //noinspection deprecation
+    Html.fromHtml(HTML_LONG, null, null);
+  }
+
+  @TargetApi(N) @Config(minSdk = N)
+  @Test public void testArraycopyShort() {
+    Html.fromHtml(HTML_SHORT, Html.FROM_HTML_MODE_LEGACY, null, null);
+  }
+
+  /**
+   * this test requires that {@link org.ccil.cowan.tagsoup.HTMLScanner} be instrumented.
+   */
+  @TargetApi(N) @Config(minSdk = N)
+  @Test public void testArraycopyLong() {
+    Html.fromHtml(HTML_LONG, Html.FROM_HTML_MODE_LEGACY, null, null);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowHttpResponseCacheTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowHttpResponseCacheTest.java
new file mode 100644
index 0000000..26e88fb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowHttpResponseCacheTest.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.http.HttpResponseCache;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowHttpResponseCacheTest {
+  @Before
+  public void setUp() {
+    // If someone else installed a cache, clear it.
+    ShadowHttpResponseCache.installed = null;
+  }
+
+  @After
+  public void tearDown() {
+    // Ensure we don't leak an installed cache from a test.
+    ShadowHttpResponseCache.installed = null;
+  }
+
+  @Test
+  public void installedCacheIsReturned() throws Exception {
+    assertThat(HttpResponseCache.getInstalled()).isNull();
+    HttpResponseCache cache = HttpResponseCache.install(File.createTempFile("foo", "bar"), 42);
+    HttpResponseCache installed = HttpResponseCache.getInstalled();
+    assertThat(installed).isSameInstanceAs(cache);
+    assertThat(installed.maxSize()).isEqualTo(42);
+  }
+
+  @Test
+  public void countsStartAtZero() throws Exception {
+    HttpResponseCache cache = HttpResponseCache.install(File.createTempFile("foo", "bar"), 42);
+    assertThat(cache.getHitCount()).isEqualTo(0);
+    assertThat(cache.getNetworkCount()).isEqualTo(0);
+    assertThat(cache.getRequestCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void deleteRemovesReference() throws Exception {
+    HttpResponseCache cache = HttpResponseCache.install(File.createTempFile("foo", "bar"), 42);
+    cache.delete();
+    assertThat(HttpResponseCache.getInstalled()).isNull();
+  }
+
+  @Test
+  public void closeRemovesReference() throws Exception {
+    HttpResponseCache cache = HttpResponseCache.install(File.createTempFile("foo", "bar"), 42);
+    cache.close();
+    assertThat(HttpResponseCache.getInstalled()).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowICUTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowICUTest.java
new file mode 100644
index 0000000..4f32159
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowICUTest.java
@@ -0,0 +1,86 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.DatePicker;
+import android.widget.LinearLayout;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import libcore.icu.ICU;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowICUTest {
+
+  @Test
+  @Config(minSdk = N)
+  public void addLikelySubtags_afterN_shouldReturnExpandedLocale() {
+    assertThat(ICU.addLikelySubtags("zh-HK")).isEqualTo("zh-Hant-HK");
+  }
+
+  @Test
+  @Config(maxSdk = M)
+  public void addLikelySubtags_preN_shouldReturnInputLocale() {
+    assertThat(ICU.addLikelySubtags("zh-HK")).isEqualTo("zh-HK");
+  }
+
+  @Test
+  @Config(maxSdk = M)
+  public void addLikelySubtags_preN_ar_shouldReturnExpandedLocale() {
+    assertThat(ICU.addLikelySubtags("ar-XB")).isEqualTo("ar-Arab-XB");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getBestDateTimePattern_returnsReasonableValue() {
+    assertThat(ICU.getBestDateTimePattern("hm", null)).isEqualTo("hm");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getBestDateTimePattern_returns_jmm_US() {
+    assertThat(ICU.getBestDateTimePattern("jmm", Locale.US)).isEqualTo("h:mm a");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getBestDateTimePattern_returns_jmm_UK() {
+    assertThat(ICU.getBestDateTimePattern("jmm", Locale.UK)).isEqualTo("H:mm");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getBestDateTimePattern_returns_jmm_ptBR() {
+    assertThat(ICU.getBestDateTimePattern("jmm", new Locale("pt", "BR"))).isEqualTo("H:mm");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void datePickerShouldNotCrashWhenAskingForBestDateTimePattern() {
+    ActivityController<DatePickerActivity> activityController =
+        Robolectric.buildActivity(DatePickerActivity.class);
+    activityController.setup();
+  }
+
+  private static class DatePickerActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      LinearLayout view = new LinearLayout(this);
+      view.setId(1);
+      DatePicker datePicker = new DatePicker(this);
+      view.addView(datePicker);
+
+      setContentView(view);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIconTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIconTest.java
new file mode 100644
index 0000000..f670d37
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIconTest.java
@@ -0,0 +1,107 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowIconTest {
+
+  private static final int MSG_ICON_LOADED = 312;
+
+  public static final int TYPE_BITMAP = 1;
+  public static final int TYPE_RESOURCE = 2;
+  public static final int TYPE_DATA = 3;
+  public static final int TYPE_URI = 4;
+
+  @Nullable private Drawable loadedDrawable;
+
+  private final Context appContext = ApplicationProvider.getApplicationContext();
+  private final Handler mainHandler =
+      new Handler(Looper.getMainLooper()) {
+        @Override
+        public void handleMessage(Message msg) {
+          if (msg.what == MSG_ICON_LOADED) {
+            loadedDrawable = (Drawable) msg.obj;
+          }
+        }
+      };
+
+  @Test
+  public void testGetRes() {
+    Icon icon = Icon.createWithResource(appContext, android.R.drawable.ic_delete);
+    assertThat(shadowOf(icon).getType()).isEqualTo(TYPE_RESOURCE);
+    assertThat(shadowOf(icon).getResId()).isEqualTo(android.R.drawable.ic_delete);
+  }
+
+  @Test
+  public void testGetBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    Icon icon = Icon.createWithBitmap(bitmap);
+    assertThat(shadowOf(icon).getType()).isEqualTo(TYPE_BITMAP);
+    assertThat(shadowOf(icon).getBitmap().sameAs(bitmap)).isTrue();
+  }
+
+  @Test
+  public void testGetData() {
+    byte[] data = new byte[1000];
+    Icon icon = Icon.createWithData(data, 100, 200);
+    assertThat(shadowOf(icon).getType()).isEqualTo(TYPE_DATA);
+    assertThat(shadowOf(icon).getDataBytes()).isEqualTo(data);
+    assertThat(shadowOf(icon).getDataOffset()).isEqualTo(100);
+    assertThat(shadowOf(icon).getDataLength()).isEqualTo(200);
+  }
+
+  @Test
+  public void testGetUri() {
+    Uri uri = Uri.parse("content://icons/icon");
+    Icon icon = Icon.createWithContentUri(uri);
+    assertThat(shadowOf(icon).getType()).isEqualTo(TYPE_URI);
+    assertThat(shadowOf(icon).getUri()).isEqualTo(uri);
+  }
+
+  @Test
+  public void testLoadDrawableAsyncWithMessage() {
+    ShadowIcon.overrideExecutor(directExecutor());
+
+    Icon icon = Icon.createWithResource(appContext, android.R.drawable.ic_delete);
+
+    Message andThen = Message.obtain(mainHandler, MSG_ICON_LOADED);
+
+    icon.loadDrawableAsync(appContext, andThen);
+    ShadowLooper.idleMainLooper();
+
+    assertThat(shadowOf(loadedDrawable).getCreatedFromResId())
+        .isEqualTo(android.R.drawable.ic_delete);
+  }
+
+  @Test
+  public void testLoadDrawableAsyncWithListener() {
+    ShadowIcon.overrideExecutor(directExecutor());
+
+    Icon icon = Icon.createWithResource(appContext, android.R.drawable.ic_delete);
+
+    icon.loadDrawableAsync(appContext, drawable -> this.loadedDrawable = drawable, mainHandler);
+    ShadowLooper.idleMainLooper();
+
+    assertThat(shadowOf(loadedDrawable).getCreatedFromResId())
+        .isEqualTo(android.R.drawable.ic_delete);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowImageReaderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowImageReaderTest.java
new file mode 100644
index 0000000..acc8d88
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowImageReaderTest.java
@@ -0,0 +1,154 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.graphics.ImageFormat;
+import android.graphics.Rect;
+import android.media.Image;
+import android.media.ImageReader;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Surface;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowImageReader}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = KITKAT)
+public class ShadowImageReaderTest {
+  private static final int WIDTH = 640;
+  private static final int HEIGHT = 480;
+  private static final int IMAGE_FORMAT = ImageFormat.YUV_420_888;
+  private static final int MAX_IMAGES = 4;
+  private final ImageReader imageReader =
+      ImageReader.newInstance(WIDTH, HEIGHT, IMAGE_FORMAT, MAX_IMAGES);
+
+  @Test
+  public void newInstance_returnsImageReader() {
+    assertThat(imageReader).isNotNull();
+  }
+
+  @Test
+  public void getWidth_returnsWidth() {
+    assertThat(imageReader.getWidth()).isEqualTo(WIDTH);
+  }
+
+  @Test
+  public void getHeight_returnsHeight() {
+    assertThat(imageReader.getHeight()).isEqualTo(HEIGHT);
+  }
+
+  @Test
+  public void getFormat_returnsFormat() {
+    assertThat(imageReader.getImageFormat()).isEqualTo(IMAGE_FORMAT);
+  }
+
+  @Test
+  public void getMaxImages_returnsMaxImages() {
+    assertThat(imageReader.getMaxImages()).isEqualTo(MAX_IMAGES);
+  }
+
+  @Test
+  public void getSurface_returnsValidSurface() {
+    assertThat(imageReader.getSurface()).isNotNull();
+    assertThat(imageReader.getSurface().isValid()).isTrue();
+  }
+
+  @Test
+  public void setOnImageAvailableListener_isInvokedWhenSurfaceIsUpdated() throws Exception {
+    AtomicBoolean listenerCalled = new AtomicBoolean(false);
+    CountDownLatch latch = new CountDownLatch(1);
+    HandlerThread cbHandlerThread = new HandlerThread("CallBackHandlerThread");
+    cbHandlerThread.start();
+    Handler handler = new Handler(cbHandlerThread.getLooper());
+
+    imageReader.setOnImageAvailableListener(
+        (reader) -> {
+          listenerCalled.set(true);
+          latch.countDown();
+        },
+        handler);
+    postUpdateOnSurface();
+    latch.await();
+
+    assertThat(listenerCalled.get()).isTrue();
+  }
+
+  private void postUpdateOnSurface() {
+    Surface surface = imageReader.getSurface();
+    surface.unlockCanvasAndPost(surface.lockCanvas(new Rect()));
+  }
+
+  @Test
+  public void acquireNextImage_returnsNullImageWithoutSurfaceUpdate() {
+    assertThat(imageReader.acquireNextImage()).isNull();
+  }
+
+  @Test
+  public void acquireLatestImage_returnsNullImageWithoutSurfaceUpdate() {
+    assertThat(imageReader.acquireLatestImage()).isNull();
+  }
+
+  @Test
+  public void acquireNextImage_returnsValidImageWithSurfaceUpdate() {
+    postUpdateOnSurface();
+    Image image = imageReader.acquireNextImage();
+
+    assertThat(image).isNotNull();
+    assertThat(image.getWidth()).isEqualTo(WIDTH);
+    assertThat(image.getHeight()).isEqualTo(HEIGHT);
+    assertThat(image.getFormat()).isEqualTo(IMAGE_FORMAT);
+    assertThat(image.getTimestamp()).isGreaterThan(0);
+  }
+
+  @Test
+  public void acquireNextImage_throwsWhenImageReaderIsClosed() {
+    imageReader.close();
+
+    assertThrows(IllegalStateException.class, imageReader::acquireNextImage);
+  }
+
+  @Test
+  public void acquireLatestImage_throwsWhenImageReaderIsClosed() {
+    imageReader.close();
+
+    assertThrows(IllegalStateException.class, imageReader::acquireLatestImage);
+  }
+
+  @Test
+  public void acquireNextImage_closingImage() {
+    for (int i = 0; i < MAX_IMAGES; i++) {
+      postUpdateOnSurface();
+      imageReader.acquireNextImage().close();
+    }
+
+    postUpdateOnSurface();
+
+    assertThat(imageReader.acquireNextImage()).isNotNull();
+  }
+
+  @Test
+  public void acquireNextImage_throwsWhenAllImagesAreUsed() {
+    for (int i = 0; i < MAX_IMAGES; i++) {
+      postUpdateOnSurface();
+      imageReader.acquireNextImage();
+    }
+
+    assertThrows(IllegalStateException.class, imageReader::acquireNextImage);
+  }
+
+  @Test
+  public void acquireLatestImage_returnsLatestImage() {
+    for (int i = 0; i < MAX_IMAGES; i++) {
+      postUpdateOnSurface();
+    }
+
+    assertThat(imageReader.acquireLatestImage().getTimestamp()).isEqualTo(MAX_IMAGES);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowImageViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowImageViewTest.java
new file mode 100644
index 0000000..b8d4438
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowImageViewTest.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.widget.ImageView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowImageViewTest {
+
+  @Test
+  public void getDrawableResourceId_shouldWorkWhenTheDrawableWasCreatedFromAResource() {
+    Resources resources = ApplicationProvider.getApplicationContext().getResources();
+    Bitmap bitmap = BitmapFactory.decodeResource(resources, R.drawable.an_image);
+    ImageView imageView = new ImageView(ApplicationProvider.getApplicationContext());
+    imageView.setImageBitmap(bitmap);
+
+    imageView.setImageResource(R.drawable.an_image);
+    assertThat(shadowOf(imageView.getDrawable()).getCreatedFromResId()).isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void imageView_draw_drawsToCanvasBitmap() {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Config.ARGB_8888);
+    bitmap.eraseColor(Color.RED);
+    ImageView imageView = new ImageView(ApplicationProvider.getApplicationContext());
+    imageView.setImageBitmap(bitmap);
+    Bitmap output = Bitmap.createBitmap(100, 100, Config.ARGB_8888);
+    Canvas canvas = new Canvas(output);
+    imageView.draw(canvas);
+    assertThat(output.getPixel(0, 0)).isEqualTo(Color.RED);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowImsMmTelManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowImsMmTelManagerTest.java
new file mode 100644
index 0000000..d31d185
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowImsMmTelManagerTest.java
@@ -0,0 +1,339 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.annotation.SuppressLint;
+import android.os.Build.VERSION_CODES;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ImsMmTelManager;
+import android.telephony.ims.ImsMmTelManager.CapabilityCallback;
+import android.telephony.ims.ImsMmTelManager.RegistrationCallback;
+import android.telephony.ims.ImsReasonInfo;
+import android.telephony.ims.feature.MmTelFeature.MmTelCapabilities;
+import android.telephony.ims.stub.ImsRegistrationImplBase;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowImsMmTelManager} */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.Q)
+public class ShadowImsMmTelManagerTest {
+
+  private ShadowImsMmTelManager shadowImsMmTelManager;
+
+  @Before
+  public void setup() {
+    shadowImsMmTelManager = new ShadowImsMmTelManager();
+  }
+
+  @Test
+  public void registerImsRegistrationCallback_imsRegistering_onRegisteringInvoked()
+      throws ImsException {
+    RegistrationCallback registrationCallback = mock(RegistrationCallback.class);
+    shadowImsMmTelManager.registerImsRegistrationCallback(Runnable::run, registrationCallback);
+    shadowImsMmTelManager.setImsRegistering(ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
+
+    verify(registrationCallback).onRegistering(ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
+
+    shadowImsMmTelManager.unregisterImsRegistrationCallback(registrationCallback);
+    shadowImsMmTelManager.setImsRegistering(ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN);
+
+    verifyNoMoreInteractions(registrationCallback);
+  }
+
+  @Test
+  public void registerImsRegistrationCallback_imsRegistered_onRegisteredInvoked()
+      throws ImsException {
+    RegistrationCallback registrationCallback = mock(RegistrationCallback.class);
+    shadowImsMmTelManager.registerImsRegistrationCallback(Runnable::run, registrationCallback);
+    shadowImsMmTelManager.setImsRegistered(ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN);
+
+    verify(registrationCallback).onRegistered(ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN);
+
+    shadowImsMmTelManager.unregisterImsRegistrationCallback(registrationCallback);
+    shadowImsMmTelManager.setImsRegistered(ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
+
+    verifyNoMoreInteractions(registrationCallback);
+  }
+
+  @Test
+  public void registerImsRegistrationCallback_imsUnregistered_onUnregisteredInvoked()
+      throws ImsException {
+    RegistrationCallback registrationCallback = mock(RegistrationCallback.class);
+    shadowImsMmTelManager.registerImsRegistrationCallback(Runnable::run, registrationCallback);
+    ImsReasonInfo imsReasonInfoWithCallbackRegistered = new ImsReasonInfo();
+    shadowImsMmTelManager.setImsUnregistered(imsReasonInfoWithCallbackRegistered);
+
+    verify(registrationCallback).onUnregistered(imsReasonInfoWithCallbackRegistered);
+
+    ImsReasonInfo imsReasonInfoAfterUnregisteringCallback = new ImsReasonInfo();
+    shadowImsMmTelManager.unregisterImsRegistrationCallback(registrationCallback);
+    shadowImsMmTelManager.setImsUnregistered(imsReasonInfoAfterUnregisteringCallback);
+
+    verifyNoMoreInteractions(registrationCallback);
+  }
+
+  @Test
+  public void registerImsRegistrationCallback_imsNotSupported_imsExceptionThrown() {
+    shadowImsMmTelManager.setImsAvailableOnDevice(false);
+    try {
+      shadowImsMmTelManager.registerImsRegistrationCallback(
+          Runnable::run, mock(RegistrationCallback.class));
+      assertWithMessage("Expected ImsException was not thrown").fail();
+    } catch (ImsException e) {
+      assertThat(e.getCode()).isEqualTo(ImsException.CODE_ERROR_UNSUPPORTED_OPERATION);
+      assertThat(e).hasMessageThat().contains("IMS not available on device.");
+    }
+  }
+
+  @Test
+  public void
+      registerMmTelCapabilityCallback_imsRegistered_availabilityChange_onCapabilitiesStatusChangedInvoked()
+          throws ImsException {
+    MmTelCapabilities[] mmTelCapabilities = new MmTelCapabilities[1];
+    CapabilityCallback capabilityCallback = new CapabilityCallback() {
+          @Override
+          public void onCapabilitiesStatusChanged(MmTelCapabilities capabilities) {
+            super.onCapabilitiesStatusChanged(capabilities);
+            mmTelCapabilities[0] = capabilities;
+          }
+        };
+
+    shadowImsMmTelManager.setImsRegistered(ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
+    shadowImsMmTelManager.registerMmTelCapabilityCallback(Runnable::run, capabilityCallback);
+
+    MmTelCapabilities mmTelCapabilitiesWithCallbackRegistered = new MmTelCapabilities();
+    mmTelCapabilitiesWithCallbackRegistered.addCapabilities(
+        MmTelCapabilities.CAPABILITY_TYPE_VIDEO);
+    mmTelCapabilitiesWithCallbackRegistered.addCapabilities(
+        MmTelCapabilities.CAPABILITY_TYPE_VOICE);
+    shadowImsMmTelManager.setMmTelCapabilitiesAvailable(mmTelCapabilitiesWithCallbackRegistered);
+
+    assertThat(mmTelCapabilities[0]).isNotNull();
+    assertThat(mmTelCapabilities[0]).isEqualTo(mmTelCapabilitiesWithCallbackRegistered);
+
+    shadowImsMmTelManager.unregisterMmTelCapabilityCallback(capabilityCallback);
+    shadowImsMmTelManager.setMmTelCapabilitiesAvailable(new MmTelCapabilities());
+    assertThat(mmTelCapabilities[0]).isEqualTo(mmTelCapabilitiesWithCallbackRegistered);
+  }
+
+  @Test
+  public void
+      registerMmTelCapabilityCallback_imsNotRegistered_availabilityChange_onCapabilitiesStatusChangedNotInvoked()
+          throws ImsException {
+    MmTelCapabilities[] mmTelCapabilities = new MmTelCapabilities[1];
+    CapabilityCallback capabilityCallback = new CapabilityCallback() {
+          @Override
+          public void onCapabilitiesStatusChanged(MmTelCapabilities capabilities) {
+            super.onCapabilitiesStatusChanged(capabilities);
+            mmTelCapabilities[0] = capabilities;
+          }
+        };
+
+    shadowImsMmTelManager.registerMmTelCapabilityCallback(Runnable::run, capabilityCallback);
+    shadowImsMmTelManager.setMmTelCapabilitiesAvailable(new MmTelCapabilities());
+
+    assertThat(mmTelCapabilities[0]).isNull();
+  }
+
+  @Test
+  public void registerMmTelCapabilityCallback_imsNotSupported_imsExceptionThrown() {
+    shadowImsMmTelManager.setImsAvailableOnDevice(false);
+    try {
+      shadowImsMmTelManager.registerMmTelCapabilityCallback(
+          Runnable::run, new CapabilityCallback());
+      assertWithMessage("Expected ImsException was not thrown").fail();
+    } catch (ImsException e) {
+      assertThat(e.getCode()).isEqualTo(ImsException.CODE_ERROR_UNSUPPORTED_OPERATION);
+      assertThat(e).hasMessageThat().contains("IMS not available on device.");
+    }
+  }
+
+  @Test
+  public void isAvailable_mmTelCapabilitiesNeverSet_noneAvailable() {
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_SMS,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_SMS,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VIDEO,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VIDEO,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_UT,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_UT,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VOICE,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VOICE,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+  }
+
+  @Test
+  public void
+      isAvailable_imsRegisteredWifi_voiceAndVideoMmTelCapabilitiesSet_voiceAndVideoOverWifiAvailable() {
+    shadowImsMmTelManager.setImsRegistered(ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN);
+
+    MmTelCapabilities voiceAndVideoMmTelCapabilities = new MmTelCapabilities();
+    voiceAndVideoMmTelCapabilities.addCapabilities(MmTelCapabilities.CAPABILITY_TYPE_VOICE);
+    voiceAndVideoMmTelCapabilities.addCapabilities(MmTelCapabilities.CAPABILITY_TYPE_VIDEO);
+
+    shadowImsMmTelManager.setMmTelCapabilitiesAvailable(voiceAndVideoMmTelCapabilities);
+
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VIDEO,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isTrue();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VOICE,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isTrue();
+
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_SMS,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_SMS,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VIDEO,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_UT,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_UT,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VOICE,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+  }
+
+  @Test
+  public void isAvailable_imsNotRegistered_voiceAndVideoMmTelCapabilitiesSet_noneAvailable() {
+    MmTelCapabilities voiceAndVideoMmTelCapabilities = new MmTelCapabilities();
+    voiceAndVideoMmTelCapabilities.addCapabilities(MmTelCapabilities.CAPABILITY_TYPE_VOICE);
+    voiceAndVideoMmTelCapabilities.addCapabilities(MmTelCapabilities.CAPABILITY_TYPE_VIDEO);
+
+    shadowImsMmTelManager.setMmTelCapabilitiesAvailable(voiceAndVideoMmTelCapabilities);
+
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_SMS,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_SMS,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VIDEO,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VIDEO,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_UT,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_UT,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VOICE,
+                ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN))
+        .isFalse();
+    assertThat(
+            shadowImsMmTelManager.isAvailable(
+                MmTelCapabilities.CAPABILITY_TYPE_VOICE,
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE))
+        .isFalse();
+  }
+
+  @Test
+  @SuppressLint("NewApi")
+  public void createForSubscriptionId_invalidSubscriptionId_throwsIllegalArgumentException() {
+    try {
+      ShadowImsMmTelManager.createForSubscriptionId(-5);
+      assertWithMessage("Expected IllegalArgumentException was not thrown").fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("Invalid subscription ID");
+    }
+  }
+
+  @Test
+  @SuppressLint("NewApi")
+  public void createForSubscriptionId_multipleValidSubscriptionIds_sharesInstances() {
+    ImsMmTelManager imsMmTelManager1 = ShadowImsMmTelManager.createForSubscriptionId(1);
+    ImsMmTelManager imsMmTelManager2 = ShadowImsMmTelManager.createForSubscriptionId(2);
+
+    assertThat(imsMmTelManager1).isNotEqualTo(imsMmTelManager2);
+    assertThat(imsMmTelManager1).isEqualTo(ShadowImsMmTelManager.createForSubscriptionId(1));
+    assertThat(imsMmTelManager2).isEqualTo(ShadowImsMmTelManager.createForSubscriptionId(2));
+
+    ShadowImsMmTelManager.clearExistingInstances();
+
+    assertThat(imsMmTelManager1).isNotEqualTo(ShadowImsMmTelManager.createForSubscriptionId(1));
+    assertThat(imsMmTelManager2).isNotEqualTo(ShadowImsMmTelManager.createForSubscriptionId(2));
+  }
+
+  @Test
+  public void getSubscriptionId() {
+    shadowImsMmTelManager.__constructor__(5);
+    assertThat(shadowImsMmTelManager.getSubscriptionId()).isEqualTo(5);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInCallAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInCallAdapterTest.java
new file mode 100644
index 0000000..13f20ef
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInCallAdapterTest.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.telecom.CallAudioState;
+import android.telecom.InCallAdapter;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Robolectric test for {@link ShadowInCallAdapter}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowInCallAdapterTest {
+
+  @Test
+  public void setAudioRoute_getAudioRoute() {
+    testSetAudioGetAudio(CallAudioState.ROUTE_EARPIECE);
+    testSetAudioGetAudio(CallAudioState.ROUTE_SPEAKER);
+    testSetAudioGetAudio(CallAudioState.ROUTE_BLUETOOTH);
+    testSetAudioGetAudio(CallAudioState.ROUTE_WIRED_HEADSET);
+  }
+
+  private static void testSetAudioGetAudio(int audioRoute) {
+    InCallAdapter adapter = Shadow.newInstanceOf(InCallAdapter.class);
+    adapter.setAudioRoute(audioRoute);
+    assertThat(((ShadowInCallAdapter) Shadow.extract(adapter)).getAudioRoute())
+        .isEqualTo(audioRoute);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInCallServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInCallServiceTest.java
new file mode 100644
index 0000000..daff15f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInCallServiceTest.java
@@ -0,0 +1,134 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothDevice;
+import android.telecom.Call;
+import android.telecom.CallAudioState;
+import android.telecom.InCallService;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Robolectric test for {@link ShadowInCallService}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowInCallServiceTest {
+
+  private InCallService inCallService;
+
+  @Before
+  public void setUp() {
+    inCallService = new InCallService() {};
+  }
+
+  @Test
+  public void setCallListEmpty_getCallListEmpty() {
+    Call[] calls = new Call[] {};
+    testSetCallListGetCallList(calls);
+  }
+
+  @Test
+  public void setCallListOne_getCallListOne() {
+    Call call = Shadow.newInstanceOf(Call.class);
+    Call[] calls = new Call[] {call};
+    testSetCallListGetCallList(calls);
+  }
+
+  @Test
+  public void setCallListTwo_getCallListTwo() {
+    Call call1 = Shadow.newInstanceOf(Call.class);
+    Call call2 = Shadow.newInstanceOf(Call.class);
+    Call[] calls = new Call[] {call1, call2};
+    testSetCallListGetCallList(calls);
+  }
+
+  @Test
+  public void addTwoCalls_removeOne_getCallListOne() {
+    Call call1 = Shadow.newInstanceOf(Call.class);
+    Call call2 = Shadow.newInstanceOf(Call.class);
+    ShadowInCallService shadowInCallService = shadowOf(inCallService);
+
+    shadowInCallService.addCall(call1);
+    shadowInCallService.addCall(call2);
+    shadowInCallService.removeCall(call2);
+
+    assertThat(inCallService.getCalls()).containsExactly(call1);
+  }
+
+  @Test
+  public void setCanAddCall_canAddCall() {
+    testSetCanAddCallGetCanAddCall(true);
+    testSetCanAddCallGetCanAddCall(false);
+  }
+
+  @Test
+  public void setMuted_getMuted() {
+    testSetMutedGetMuted(true);
+    testSetMutedGetMuted(false);
+  }
+
+  @Test
+  public void setAudioRoute_getCallAudioState() {
+    testSetAudioRouteGetCallAudioState(CallAudioState.ROUTE_EARPIECE);
+    testSetAudioRouteGetCallAudioState(CallAudioState.ROUTE_SPEAKER);
+    testSetAudioRouteGetCallAudioState(CallAudioState.ROUTE_BLUETOOTH);
+    testSetAudioRouteGetCallAudioState(CallAudioState.ROUTE_WIRED_HEADSET);
+  }
+
+  @Test
+  @TargetApi(P)
+  @Config(
+      minSdk = P,
+      shadows = {ShadowBluetoothDevice.class})
+  public void requestBluetoothAudio_getBluetoothAudio() {
+    BluetoothDevice bluetoothDevice = ShadowBluetoothDevice.newInstance("00:11:22:33:AA:BB");
+    ShadowInCallService shadowInCallService = shadowOf(inCallService);
+
+    inCallService.requestBluetoothAudio(bluetoothDevice);
+
+    assertThat(shadowInCallService.getBluetoothAudio()).isEqualTo(bluetoothDevice);
+  }
+
+  public void testSetCallListGetCallList(Call[] calls) {
+    ShadowInCallService shadowInCallService = shadowOf(inCallService);
+
+    for (Call call : calls) {
+      shadowInCallService.addCall(call);
+    }
+
+    List<Call> callList = inCallService.getCalls();
+
+    for (int i = 0; i < calls.length; i++) {
+      assertThat(callList.get(i)).isEqualTo(calls[i]);
+    }
+  }
+
+  private void testSetCanAddCallGetCanAddCall(boolean canAddCall) {
+    ShadowInCallService shadowInCallService = shadowOf(inCallService);
+
+    shadowInCallService.setCanAddCall(canAddCall);
+
+    assertThat(inCallService.canAddCall()).isEqualTo(canAddCall);
+  }
+
+  private void testSetMutedGetMuted(boolean muted) {
+    inCallService.setMuted(muted);
+
+    assertThat(inCallService.getCallAudioState().isMuted()).isEqualTo(muted);
+  }
+
+  private void testSetAudioRouteGetCallAudioState(int audioRoute) {
+    inCallService.setAudioRoute(audioRoute);
+
+    assertThat(inCallService.getCallAudioState().getRoute()).isEqualTo(audioRoute);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIncidentManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIncidentManagerTest.java
new file mode 100644
index 0000000..8c3e8a3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIncidentManagerTest.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import android.os.Build.VERSION_CODES;
+import android.os.IncidentManager.IncidentReport;
+import android.os.Parcel;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.R)
+public final class ShadowIncidentManagerTest {
+
+  @Test
+  public void expectedResult() {
+    ShadowIncidentManager shadowIncidentManager = new ShadowIncidentManager();
+    shadowIncidentManager.addIncidentReport(
+        Uri.parse("content://foo.com/1"), new IncidentReport(Parcel.obtain()));
+    assertThat(shadowIncidentManager.getIncidentReportList("test_caller")).hasSize(1);
+    assertThat(shadowIncidentManager.getIncidentReport(Uri.parse("content://foo.com/1")))
+        .isNotNull();
+
+    shadowIncidentManager.deleteIncidentReports(Uri.parse("content://foo.com/1"));
+    assertThat(shadowIncidentManager.getIncidentReportList("test_caller")).isEmpty();
+    assertThat(shadowIncidentManager.getIncidentReport(Uri.parse("content://foo.com/1"))).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInetAddressUtilsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInetAddressUtilsTest.java
new file mode 100644
index 0000000..851ad5b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInetAddressUtilsTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.net.InetAddress;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for ShadowInetAddressUtils to check addresses are parsed to InetAddress. */
+@RunWith(JUnit4.class)
+public final class ShadowInetAddressUtilsTest {
+
+  @Test
+  public void parseNumericAddress_ipv4() {
+    String input = "192.168.0.1";
+    InetAddress result = ShadowInetAddressUtils.parseNumericAddressNoThrow(input);
+    assertThat(result).isNotNull();
+  }
+
+  @Test
+  public void parseNumericAddress_ipv6() {
+    String input = "2001:4860:800d::68";
+    InetAddress result = ShadowInetAddressUtils.parseNumericAddressNoThrow(input);
+    assertThat(result).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputDeviceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputDeviceTest.java
new file mode 100644
index 0000000..7ddb3f7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputDeviceTest.java
@@ -0,0 +1,46 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.InputDevice;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowInputDeviceTest {
+  @Test
+  public void canConstructInputDeviceWithName() {
+    InputDevice inputDevice = ShadowInputDevice.makeInputDeviceNamed("foo");
+    assertThat(inputDevice.getName()).isEqualTo("foo");
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void canChangeProductId() {
+    InputDevice inputDevice = ShadowInputDevice.makeInputDeviceNamed("foo");
+    ShadowInputDevice shadowInputDevice = Shadow.extract(inputDevice);
+    shadowInputDevice.setProductId(1337);
+
+    assertThat(inputDevice.getProductId()).isEqualTo(1337);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void canChangeVendorId() {
+    InputDevice inputDevice = ShadowInputDevice.makeInputDeviceNamed("foo");
+    ShadowInputDevice shadowInputDevice = Shadow.extract(inputDevice);
+    shadowInputDevice.setVendorId(1337);
+
+    assertThat(inputDevice.getVendorId()).isEqualTo(1337);
+  }
+
+  @Test
+  public void getDeviceIds() {
+    int[] deviceIds = InputDevice.getDeviceIds();
+    assertThat(deviceIds).hasLength(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputEventReceiverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputEventReceiverTest.java
new file mode 100644
index 0000000..2a4219b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputEventReceiverTest.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Looper;
+import android.view.InputChannel;
+import android.view.InputEventReceiver;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import dalvik.system.CloseGuard;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowInputEventReceiver}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowInputEventReceiverTest {
+
+  static class MyInputEventReceiver extends InputEventReceiver {
+
+    public MyInputEventReceiver(InputChannel inputChannel, Looper looper) {
+      super(inputChannel, looper);
+    }
+
+    @Override
+    protected void finalize() throws Throwable {
+      super.finalize();
+    }
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN)
+  public void closeGuard_autoCloses() throws Throwable {
+    final AtomicBoolean closeGuardWarned = new AtomicBoolean(false);
+    CloseGuard.Reporter originalReporter = CloseGuard.getReporter();
+    try {
+      CloseGuard.setReporter((s, throwable) -> closeGuardWarned.set(true));
+      new MyInputEventReceiver(new InputChannel(), Looper.getMainLooper()).finalize();
+      assertThat(closeGuardWarned.get()).isFalse();
+    } finally {
+      CloseGuard.setReporter(originalReporter);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputEventTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputEventTest.java
new file mode 100644
index 0000000..75e0bad
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputEventTest.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowInputEventTest {
+  @Test
+  public void canSetInputDeviceOnKeyEvent() {
+    InputDevice myDevice = ShadowInputDevice.makeInputDeviceNamed("myDevice");
+    KeyEvent keyEvent = new KeyEvent(1, 2);
+    shadowOf(keyEvent).setDevice(myDevice);
+    assertThat(keyEvent.getDevice().getName()).isEqualTo("myDevice");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java
new file mode 100644
index 0000000..8ee669a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.view.MotionEvent;
+import android.view.VerifiedMotionEvent;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowInputManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = R)
+public class ShadowInputManagerTest {
+  private InputManager inputManager;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    inputManager = context.getSystemService(InputManager.class);
+  }
+
+  @Test
+  public void verifyMotionEvent() {
+    MotionEvent motionEvent =
+        MotionEvent.obtain(12345, 23456, MotionEvent.ACTION_UP, 30.0f, 40.0f, 0);
+    VerifiedMotionEvent verifiedMotionEvent =
+        (VerifiedMotionEvent) inputManager.verifyInputEvent(motionEvent);
+
+    assertThat(verifiedMotionEvent.getRawX()).isEqualTo(30.0f);
+    assertThat(verifiedMotionEvent.getRawY()).isEqualTo(40.0f);
+    assertThat(verifiedMotionEvent.getEventTimeNanos()).isEqualTo(23456000000L);
+    assertThat(verifiedMotionEvent.getDownTimeNanos()).isEqualTo(12345000000L);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputMethodManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputMethodManagerTest.java
new file mode 100644
index 0000000..69a0e2f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputMethodManagerTest.java
@@ -0,0 +1,172 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.ResultReceiver;
+import android.view.View;
+import android.view.inputmethod.InputMethodInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.view.inputmethod.InputMethodSubtype;
+import android.view.inputmethod.InputMethodSubtype.InputMethodSubtypeBuilder;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowInputMethodManagerTest {
+
+  private InputMethodManager manager;
+  private ShadowInputMethodManager shadow;
+
+  @Before
+  public void setUp() throws Exception {
+    manager =
+        (InputMethodManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Activity.INPUT_METHOD_SERVICE);
+    shadow = Shadows.shadowOf(manager);
+  }
+
+  @Test
+  public void shouldRecordSoftInputVisibility() {
+    assertThat(shadow.isSoftInputVisible()).isFalse();
+
+    manager.showSoftInput(null, 0);
+    assertThat(shadow.isSoftInputVisible()).isTrue();
+
+    manager.hideSoftInputFromWindow(null, 0);
+    assertThat(shadow.isSoftInputVisible()).isFalse();
+  }
+
+  @Test
+  public void hideSoftInputFromWindow_shouldNotifiyResult_hidden() {
+    manager.showSoftInput(null, 0);
+
+    CapturingResultReceiver resultReceiver =
+        new CapturingResultReceiver(new Handler(Looper.getMainLooper()));
+    manager.hideSoftInputFromWindow(null, 0, resultReceiver);
+    assertThat(resultReceiver.resultCode).isEqualTo(InputMethodManager.RESULT_HIDDEN);
+  }
+
+  @Test
+  public void hideSoftInputFromWindow_shouldNotifiyResult_alreadyHidden() {
+    CapturingResultReceiver resultReceiver =
+        new CapturingResultReceiver(new Handler(Looper.getMainLooper()));
+    manager.hideSoftInputFromWindow(null, 0, resultReceiver);
+    assertThat(resultReceiver.resultCode).isEqualTo(InputMethodManager.RESULT_UNCHANGED_HIDDEN);
+  }
+
+  @Test
+  public void shouldToggleSoftInputVisibility() {
+    assertThat(shadow.isSoftInputVisible()).isFalse();
+
+    manager.toggleSoftInput(0, 0);
+    assertThat(shadow.isSoftInputVisible()).isTrue();
+
+    manager.toggleSoftInput(0, 0);
+    assertThat(shadow.isSoftInputVisible()).isFalse();
+  }
+
+  @Test
+  public void shouldNotifyHandlerWhenVisibilityChanged() {
+    ShadowInputMethodManager.SoftInputVisibilityChangeHandler mockHandler =
+        mock(ShadowInputMethodManager.SoftInputVisibilityChangeHandler.class);
+    shadow.setSoftInputVisibilityHandler(mockHandler);
+    assertThat(shadow.isSoftInputVisible()).isFalse();
+
+    manager.toggleSoftInput(0, 0);
+    verify(mockHandler).handleSoftInputVisibilityChange(true);
+  }
+
+  @Test
+  public void shouldUpdateInputMethodList() {
+    InputMethodInfo inputMethodInfo =
+        new InputMethodInfo("pkg", "ClassName", "customIME", "customImeSettingsActivity");
+
+    shadow.setInputMethodInfoList(ImmutableList.of(inputMethodInfo));
+
+    assertThat(shadow.getInputMethodList()).containsExactly(inputMethodInfo);
+  }
+
+  @Test
+  public void getInputMethodListReturnsEmptyListByDefault() {
+    assertThat(shadow.getInputMethodList()).isEmpty();
+  }
+
+  @Test
+  public void shouldUpdateEnabledInputMethodList() {
+    InputMethodInfo inputMethodInfo =
+        new InputMethodInfo("pkg", "ClassName", "customIME", "customImeSettingsActivity");
+
+    shadow.setEnabledInputMethodInfoList(ImmutableList.of(inputMethodInfo));
+
+    assertThat(shadow.getEnabledInputMethodList()).containsExactly(inputMethodInfo);
+  }
+
+  @Test
+  public void getEnabledInputMethodListReturnsEmptyListByDefault() {
+    assertThat(shadow.getEnabledInputMethodList()).isEmpty();
+  }
+
+  @Test
+  public void getCurrentInputMethodSubtype_returnsNullByDefault() {
+    assertThat(shadow.getCurrentInputMethodSubtype()).isNull();
+  }
+
+  /** The builder is only available for 19+. */
+  @Config(minSdk = KITKAT)
+  @Test
+  public void setCurrentInputMethodSubtype_isReturned() {
+    InputMethodSubtype inputMethodSubtype = new InputMethodSubtypeBuilder().build();
+    shadow.setCurrentInputMethodSubtype(inputMethodSubtype);
+    assertThat(manager.getCurrentInputMethodSubtype()).isEqualTo(inputMethodSubtype);
+  }
+
+  @Test
+  public void sendAppPrivateCommandListenerIsNotified() {
+    View expectedView = new View(ApplicationProvider.getApplicationContext());
+    String expectedAction = "action";
+    Bundle expectedBundle = new Bundle();
+
+    ShadowInputMethodManager.PrivateCommandListener listener =
+        new ShadowInputMethodManager.PrivateCommandListener() {
+          @Override
+          public void onPrivateCommand(View view, String action, Bundle data) {
+            assertThat(view).isEqualTo(expectedView);
+            assertThat(action).isEqualTo(expectedAction);
+            assertThat(data).isEqualTo(expectedBundle);
+          }
+        };
+
+    shadow.setAppPrivateCommandListener(listener);
+
+    shadow.sendAppPrivateCommand(expectedView, expectedAction, expectedBundle);
+  }
+
+  private static class CapturingResultReceiver extends ResultReceiver {
+
+    private int resultCode = -1;
+
+    public CapturingResultReceiver(Handler handler) {
+      super(handler);
+    }
+
+    @Override
+    protected void onReceiveResult(int resultCode, Bundle resultData) {
+      super.onReceiveResult(resultCode, resultData);
+      this.resultCode = resultCode;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInstrumentationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInstrumentationTest.java
new file mode 100644
index 0000000..c611a40
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInstrumentationTest.java
@@ -0,0 +1,153 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.ActivityThread;
+import android.app.Application;
+import android.app.Instrumentation;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.UserHandle;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import java.lang.reflect.Method;
+import java.util.concurrent.CountDownLatch;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Tests for the ShadowInstrumentation class. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowInstrumentationTest {
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1, maxSdk = N_MR1)
+  public void testExecStartActivity_handledProperlyForSDK17to25() throws Exception {
+    Instrumentation instrumentation =
+        ((ActivityThread) RuntimeEnvironment.getActivityThread()).getInstrumentation();
+
+    Intent expectedIntent = new Intent("do_the_thing");
+
+    // Use reflection since the method doesn't exist in the latest SDK.
+    Method method =
+        Instrumentation.class.getMethod(
+            "execStartActivity",
+            Context.class,
+            IBinder.class,
+            IBinder.class,
+            Activity.class,
+            Intent.class,
+            int.class,
+            Bundle.class,
+            UserHandle.class);
+    method.invoke(
+        instrumentation,
+        instrumentation.getContext(),
+        null,
+        null,
+        null,
+        expectedIntent,
+        0,
+        null,
+        null);
+
+    Intent actualIntent =
+        shadowOf((Application) ApplicationProvider.getApplicationContext())
+            .getNextStartedActivity();
+
+    assertThat(actualIntent).isEqualTo(expectedIntent);
+  }
+
+  /**
+   * This tests that concurrent startService operations are handled correctly.
+   *
+   * <p>It runs 2 concurrent threads, each of which call startService 10000 times. This should be
+   * enough to trigger any issues related to concurrent state modifications in the
+   * ShadowInstrumentation implementation.
+   */
+  @Test
+  @Config(minSdk = N)
+  public void concurrentStartService_hasCorrectStartServiceCount() throws InterruptedException {
+
+    HandlerThread background1Thread = new HandlerThread("background1");
+    background1Thread.start();
+    Handler handler1 = new Handler(background1Thread.getLooper());
+    HandlerThread background2Thread = new HandlerThread("background2");
+    background2Thread.start();
+    Handler handler2 = new Handler(background2Thread.getLooper());
+
+    Intent intent = new Intent("do_the_thing");
+    intent.setClassName("com.blah", "com.blah.service");
+    Context context = ApplicationProvider.getApplicationContext();
+    Runnable startServicesTask =
+        () -> {
+          for (int i = 0; i < 10000; i++) {
+            context.startService(intent);
+          }
+        };
+
+    CountDownLatch finishedLatch = new CountDownLatch(2);
+
+    handler1.post(startServicesTask);
+    handler2.post(startServicesTask);
+    handler1.post(finishedLatch::countDown);
+    handler2.post(finishedLatch::countDown);
+
+    assertThat(finishedLatch.await(10, SECONDS)).isTrue();
+
+    int intentCount = 0;
+
+    while (true) {
+      Intent serviceIntent =
+          shadowOf((Application) ApplicationProvider.getApplicationContext())
+              .getNextStartedService();
+      if (serviceIntent == null) {
+        break;
+      }
+      intentCount++;
+    }
+
+    assertThat(intentCount).isEqualTo(20000);
+  }
+
+  @Test
+  public void setInTouchMode_setFalse() {
+    InstrumentationRegistry.getInstrumentation().setInTouchMode(false);
+
+    View decorView =
+        Robolectric.buildActivity(Activity.class).setup().get().getWindow().getDecorView();
+
+    assertThat(decorView.isInTouchMode()).isFalse();
+  }
+
+  @Test
+  public void setInTouchMode_setTrue() {
+    InstrumentationRegistry.getInstrumentation().setInTouchMode(true);
+
+    View decorView =
+        Robolectric.buildActivity(Activity.class).setup().get().getWindow().getDecorView();
+
+    assertThat(decorView.isInTouchMode()).isTrue();
+  }
+
+  @Config(minSdk = JELLY_BEAN_MR2)
+  @Test
+  public void getUiAutomation() {
+    assertThat(InstrumentationRegistry.getInstrumentation().getUiAutomation()).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentFilterAuthorityEntryTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentFilterAuthorityEntryTest.java
new file mode 100644
index 0000000..80022d8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentFilterAuthorityEntryTest.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.IntentFilter;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowIntentFilterAuthorityEntryTest {
+  @Test(expected = NumberFormatException.class)
+  public void constructor_shouldThrowAnExceptionIfPortIsNotAValidNumber() {
+    new IntentFilter.AuthorityEntry("", "not a number");
+  }
+
+  @Test
+  public void constructor_shouldAllowNullPortAndSetToNegativeOne() {
+    IntentFilter.AuthorityEntry authorityEntry = new IntentFilter.AuthorityEntry("host", null);
+    assertThat(authorityEntry.getPort()).isEqualTo(-1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentFilterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentFilterTest.java
new file mode 100644
index 0000000..8cd3b7c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentFilterTest.java
@@ -0,0 +1,241 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.IntentFilter;
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowIntentFilterTest {
+  @Test
+  public void copyConstructorTest() {
+    String action = "test";
+    IntentFilter intentFilter = new IntentFilter(action);
+    IntentFilter copy = new IntentFilter(intentFilter);
+    assertThat(copy.hasAction("test")).isTrue();
+  }
+
+  @Test
+  public void setsPriority() {
+    IntentFilter filter = new IntentFilter();
+    filter.setPriority(123);
+    assertThat(filter.getPriority()).isEqualTo(123);
+  }
+
+  @Test
+  public void addDataScheme_shouldAddTheDataScheme() {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataScheme("http");
+    intentFilter.addDataScheme("ftp");
+
+    assertThat(intentFilter.getDataScheme(0)).isEqualTo("http");
+    assertThat(intentFilter.getDataScheme(1)).isEqualTo("ftp");
+  }
+
+  @Test
+  public void addDataAuthority_shouldAddTheDataAuthority() {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataAuthority("test.com", "8080");
+    intentFilter.addDataAuthority("example.com", "42");
+
+    assertThat(intentFilter.getDataAuthority(0).getHost()).isEqualTo("test.com");
+    assertThat(intentFilter.getDataAuthority(0).getPort()).isEqualTo(8080);
+    assertThat(intentFilter.getDataAuthority(1).getHost()).isEqualTo("example.com");
+    assertThat(intentFilter.getDataAuthority(1).getPort()).isEqualTo(42);
+  }
+
+  @Test
+  public void addDataType_shouldAddTheDataType() throws Exception {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataType("image/test");
+
+    assertThat(intentFilter.getDataType(0)).isEqualTo("image/test");
+  }
+
+  @Test
+  public void hasAction() {
+    IntentFilter intentFilter = new IntentFilter();
+    assertThat(intentFilter.hasAction("test")).isFalse();
+    intentFilter.addAction("test");
+
+    assertThat(intentFilter.hasAction("test")).isTrue();
+  }
+
+  @Test
+  public void hasDataScheme() {
+    IntentFilter intentFilter = new IntentFilter();
+    assertThat(intentFilter.hasDataScheme("test")).isFalse();
+    intentFilter.addDataScheme("test");
+
+    assertThat(intentFilter.hasDataScheme("test")).isTrue();
+  }
+
+  @Test
+  public void hasDataType() throws IntentFilter.MalformedMimeTypeException{
+    IntentFilter intentFilter = new IntentFilter();
+    assertThat(intentFilter.hasDataType("image/test")).isFalse();
+    intentFilter.addDataType("image/test");
+
+    assertThat(intentFilter.hasDataType("image/test")).isTrue();
+  }
+
+  @Test
+  public void matchDataAuthority_matchHostAndPort() {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataAuthority("testHost1", "1");
+    intentFilter.addDataAuthority("testHost2", "2");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:1");
+    Uri uriTest2 = Uri.parse("http://testHost2:2");
+    assertThat(intentFilter.matchDataAuthority(uriTest1)).isEqualTo(IntentFilter.MATCH_CATEGORY_PORT);
+    assertThat(intentFilter.matchDataAuthority(uriTest2)).isEqualTo(IntentFilter.MATCH_CATEGORY_PORT);
+  }
+
+  @Test
+  public void matchDataAuthority_matchHostWithNoPort() {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataAuthority("testHost1", "-1");
+    intentFilter.addDataAuthority("testHost2", "-1");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:100");
+    Uri uriTest2 = Uri.parse("http://testHost2:200");
+    assertThat(intentFilter.matchDataAuthority(uriTest1)).isEqualTo(IntentFilter.MATCH_CATEGORY_HOST);
+    assertThat(intentFilter.matchDataAuthority(uriTest2)).isEqualTo(IntentFilter.MATCH_CATEGORY_HOST);
+  }
+
+  @Test
+  public void matchDataAuthority_NoMatch() {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataAuthority("testHost1", "1");
+    intentFilter.addDataAuthority("testHost2", "2");
+
+    // Port doesn't match
+    Uri uriTest1 = Uri.parse("http://testHost1:2");
+    // Host doesn't match
+    Uri uriTest2 = Uri.parse("http://testHost3:2");
+    assertThat(intentFilter.matchDataAuthority(uriTest1)).isEqualTo(
+        IntentFilter.NO_MATCH_DATA);
+    assertThat(intentFilter.matchDataAuthority(uriTest2)).isEqualTo(
+        IntentFilter.NO_MATCH_DATA);
+  }
+
+  @Test
+  public void matchData_MatchAll() throws IntentFilter.MalformedMimeTypeException{
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataType("image/test");
+    intentFilter.addDataScheme("http");
+    intentFilter.addDataAuthority("testHost1", "1");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:1");
+    assertThat(intentFilter.matchData("image/test", "http", uriTest1))
+        .isAtLeast(0);
+  }
+
+  @Test
+  public void matchData_MatchType() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataType("image/test");
+    intentFilter.addDataScheme("http");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:1");
+    assertThat(intentFilter.matchData("image/test", "http", uriTest1))
+        .isAtLeast(0);
+  }
+
+  @Test
+  public void matchData_MatchScheme() {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataScheme("http");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:1");
+    assertThat(intentFilter.matchData(null, "http", uriTest1))
+        .isAtLeast(0);
+  }
+
+  @Test
+  public void matchData_MatchEmpty() {
+    IntentFilter intentFilter = new IntentFilter();
+
+    assertThat(intentFilter.matchData(null, "noscheme", null))
+        .isAtLeast(0);
+  }
+
+  @Test
+  public void matchData_NoMatchType() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataType("image/testFail");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:1");
+    assertThat(intentFilter.matchData("image/test", "http", uriTest1))
+        .isLessThan(0);
+  }
+
+  @Test
+  public void matchData_NoMatchScheme() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataScheme("http");
+    intentFilter.addDataType("image/test");
+
+    Uri uriTest1 = Uri.parse("https://testHost1:1");
+    assertThat(intentFilter.matchData("image/test", "https", uriTest1))
+        .isLessThan(0);
+  }
+
+  @Test
+  public void matchData_NoMatchDataAuthority() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataType("image/test");
+    intentFilter.addDataScheme("http");
+    intentFilter.addDataAuthority("testHost1", "1");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:2");
+    assertThat(intentFilter.matchData("image/test", "http", uriTest1))
+        .isLessThan(0);
+  }
+
+  @Test
+  public void matchData_MatchSchemeNoMatchType() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataScheme("http");
+    intentFilter.addDataType("image/testFail");
+
+    Uri uriTest1 = Uri.parse("http://testHost1:1");
+    assertThat(intentFilter.matchData("image/test", "http", uriTest1))
+        .isLessThan(0);
+  }
+
+  @Test
+  public void matchData_MatchesPartialType() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataScheme("content");
+    intentFilter.addDataType("image/*");
+
+    Uri uri = Uri.parse("content://authority/images");
+    assertThat(intentFilter.matchData("image/test", "content", uri)).isAtLeast(0);
+    assertThat(intentFilter.matchData("video/test", "content", uri)).isLessThan(0);
+  }
+
+  @Test
+  public void matchData_MatchesAnyTypeAndSubtype() throws IntentFilter.MalformedMimeTypeException {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataScheme("content");
+    intentFilter.addDataType("*/*");
+
+    Uri uri = Uri.parse("content://authority/images");
+    assertThat(intentFilter.matchData("image/test", "content", uri)).isAtLeast(0);
+    assertThat(intentFilter.matchData("image/*", "content", uri)).isAtLeast(0);
+    assertThat(intentFilter.matchData("video/test", "content", uri)).isAtLeast(0);
+    assertThat(intentFilter.matchData("video/*", "content", uri)).isAtLeast(0);
+  }
+
+  @Test
+  public void testCountDataTypes() throws Exception {
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addDataType("image/*");
+    intentFilter.addDataType("audio/*");
+    assertThat(intentFilter.countDataTypes()).isEqualTo(2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentServiceTest.java
new file mode 100644
index 0000000..86d6bf4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentServiceTest.java
@@ -0,0 +1,34 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.IntentService;
+import android.content.Intent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowIntentServiceTest {
+  @Test
+  public void shouldSetIntentRedelivery() {
+    IntentService intentService = new TestIntentService();
+    ShadowIntentService shadowIntentService = shadowOf(intentService);
+    assertThat(shadowIntentService.getIntentRedelivery()).isFalse();
+    intentService.setIntentRedelivery(true);
+    assertThat(shadowIntentService.getIntentRedelivery()).isTrue();
+    intentService.setIntentRedelivery(false);
+    assertThat(shadowIntentService.getIntentRedelivery()).isFalse();
+  }
+
+  private static class TestIntentService extends IntentService {
+    public TestIntentService() {
+      super("TestIntentService");
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentTest.java
new file mode 100644
index 0000000..6c07ffb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIntentTest.java
@@ -0,0 +1,521 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowIntentTest {
+  private static final String TEST_ACTIVITY_CLASS_NAME = "org.robolectric.shadows.TestActivity";
+
+  @Test
+  public void resolveActivityInfo_shouldReturnActivityInfoForExistingActivity() {
+    Context context = ApplicationProvider.getApplicationContext();
+      PackageManager packageManager = context.getPackageManager();
+
+      Intent intent = new Intent();
+      intent.setClassName(context, TEST_ACTIVITY_CLASS_NAME);
+      ActivityInfo activityInfo = intent.resolveActivityInfo(packageManager, PackageManager.GET_ACTIVITIES);
+      assertThat(activityInfo).isNotNull();
+  }
+
+  @Test
+  public void testGetExtraReturnsNull_whenThereAreNoExtrasAdded() {
+    Intent intent = new Intent();
+    assertEquals(intent.getExtras(), null);
+  }
+
+  @Test
+  public void testStringExtra() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.putExtra("foo", "bar"));
+    assertEquals("bar", intent.getExtras().get("foo"));
+  }
+
+  @Test
+  public void testCharSequenceExtra() {
+    Intent intent = new Intent();
+    CharSequence cs = new TestCharSequence("bar");
+    assertSame(intent, intent.putExtra("foo", cs));
+    assertSame(cs, intent.getExtras().get("foo"));
+  }
+
+  @Test
+  public void testIntExtra() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.putExtra("foo", 2));
+    assertEquals(2, intent.getExtras().get("foo"));
+    assertEquals(2, intent.getIntExtra("foo", -1));
+  }
+
+  @Test
+  public void testDoubleExtra() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.putExtra("foo", 2d));
+    assertEquals(2d, intent.getExtras().get("foo"));
+    assertThat(intent.getDoubleExtra("foo", -1)).isEqualTo(2d);
+  }
+
+  @Test
+  public void testFloatExtra() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.putExtra("foo", 2f));
+    assertThat(intent.getExtras().get("foo")).isEqualTo(2f);
+    assertThat(intent.getFloatExtra("foo", -1)).isEqualTo(2f);
+  }
+
+  @Test
+  public void testIntArrayExtra() {
+    Intent intent = new Intent();
+    int[] array = new int[2];
+    array[0] = 1;
+    array[1] = 2;
+    assertSame(intent, intent.putExtra("foo", array));
+    assertEquals(1, intent.getIntArrayExtra("foo")[0]);
+    assertEquals(2, intent.getIntArrayExtra("foo")[1]);
+  }
+
+  @Test
+  public void testLongArrayExtra() {
+    Intent intent = new Intent();
+    long[] array = new long[2];
+    array[0] = 1L;
+    array[1] = 2L;
+    assertSame(intent, intent.putExtra("foo", array));
+    assertEquals(1L, intent.getLongArrayExtra("foo")[0]);
+    assertEquals(2L, intent.getLongArrayExtra("foo")[1]);
+  }
+
+  @Test
+  public void testSerializableExtra() {
+    Intent intent = new Intent();
+    TestSerializable serializable = new TestSerializable("some string");
+    assertSame(intent, intent.putExtra("foo", serializable));
+    assertEquals(serializable, intent.getExtras().get("foo"));
+    assertEquals(serializable, intent.getSerializableExtra("foo"));
+  }
+
+  @Test
+  public void testSerializableOfParcelableExtra() {
+    Intent intent = new Intent();
+    ArrayList<Parcelable> serializable = new ArrayList<>();
+    serializable.add(new TestParcelable(12));
+    assertSame(intent, intent.putExtra("foo", serializable));
+    assertEquals(serializable, intent.getExtras().get("foo"));
+    assertEquals(serializable, intent.getSerializableExtra("foo"));
+  }
+
+  @Test
+  public void testParcelableExtra() {
+    Intent intent = new Intent();
+    Parcelable parcelable = new TestParcelable(44);
+    assertSame(intent, intent.putExtra("foo", parcelable));
+    assertSame(parcelable, intent.getExtras().get("foo"));
+    assertSame(parcelable, intent.getParcelableExtra("foo"));
+  }
+
+  @Test
+  public void testParcelableArrayExtra() {
+    Intent intent = new Intent();
+    Parcelable parcelable = new TestParcelable(11);
+    intent.putExtra("foo", parcelable);
+    assertSame(null, intent.getParcelableArrayExtra("foo"));
+    Parcelable[] parcelables = {new TestParcelable(12), new TestParcelable(13)};
+    assertSame(intent, intent.putExtra("bar", parcelables));
+    assertSame(parcelables, intent.getParcelableArrayExtra("bar"));
+  }
+
+  @Test
+  public void testParcelableArrayListExtra() {
+    Intent intent = new Intent();
+    Parcelable parcel1 = new TestParcelable(22);
+    Parcelable parcel2 = new TestParcelable(23);
+    ArrayList<Parcelable> parcels = new ArrayList<>();
+    parcels.add(parcel1);
+    parcels.add(parcel2);
+
+    assertSame(intent, intent.putParcelableArrayListExtra("foo", parcels));
+    assertSame(parcels, intent.getParcelableArrayListExtra("foo"));
+    assertSame(parcel1, intent.getParcelableArrayListExtra("foo").get(0));
+    assertSame(parcel2, intent.getParcelableArrayListExtra("foo").get(1));
+    assertSame(parcels, intent.getExtras().getParcelableArrayList("foo"));
+  }
+
+  @Test
+  public void testLongExtra() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.putExtra("foo", 2L));
+    assertEquals(2L, intent.getExtras().get("foo"));
+    assertEquals(2L, intent.getLongExtra("foo", -1));
+    assertEquals(-1L, intent.getLongExtra("bar", -1));
+  }
+
+  @Test
+  public void testBundleExtra() {
+    Intent intent = new Intent();
+    Bundle bundle = new Bundle();
+    bundle.putInt("bar", 5);
+    assertSame(intent, intent.putExtra("foo", bundle));
+    assertEquals(5, intent.getBundleExtra("foo").getInt("bar"));
+  }
+
+  @Test
+  public void testHasExtra() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.putExtra("foo", ""));
+    assertTrue(intent.hasExtra("foo"));
+    assertFalse(intent.hasExtra("bar"));
+  }
+
+  @Test
+  public void testGetActionReturnsWhatWasSet() {
+    Intent intent = new Intent();
+    assertSame(intent, intent.setAction("foo"));
+    assertEquals("foo", intent.getAction());
+  }
+
+  @Test
+  public void testSetData() {
+    Intent intent = new Intent();
+    Uri uri = Uri.parse("content://this/and/that");
+    intent.setType("abc");
+    assertSame(intent, intent.setData(uri));
+    assertSame(uri, intent.getData());
+    assertNull(intent.getType());
+  }
+
+  @Test
+  public void testGetScheme() {
+    Intent intent = new Intent();
+    Uri uri = Uri.parse("http://robolectric.org");
+    assertSame(intent, intent.setData(uri));
+    assertSame(uri, intent.getData());
+    assertEquals("http", intent.getScheme());
+  }
+
+  @Test
+  public void testSetType() {
+    Intent intent = new Intent();
+    intent.setData(Uri.parse("content://this/and/that"));
+    assertSame(intent, intent.setType("def"));
+    assertNull(intent.getData());
+    assertEquals("def", intent.getType());
+  }
+
+  @Test
+  public void testSetDataAndType() {
+    Intent intent = new Intent();
+    Uri uri = Uri.parse("content://this/and/that");
+    assertSame(intent, intent.setDataAndType(uri, "ghi"));
+    assertSame(uri, intent.getData());
+    assertEquals("ghi", intent.getType());
+  }
+
+  @Test
+  public void testSetClass() {
+    Intent intent = new Intent();
+    Class<? extends ShadowIntentTest> thisClass = getClass();
+    Intent output = intent.setClass(ApplicationProvider.getApplicationContext(), thisClass);
+
+    assertSame(output, intent);
+    assertThat(intent.getComponent().getClassName()).isEqualTo(thisClass.getName());
+  }
+
+  @Test
+  public void testSetClassName() {
+    Intent intent = new Intent();
+    Class<? extends ShadowIntentTest> thisClass = getClass();
+    intent.setClassName("package.name", thisClass.getName());
+    assertSame(thisClass.getName(), intent.getComponent().getClassName());
+    assertEquals("package.name", intent.getComponent().getPackageName());
+    assertSame(intent.getComponent().getClassName(), thisClass.getName());
+  }
+
+  @Test
+  public void testSetClassThroughConstructor() {
+    Intent intent = new Intent(ApplicationProvider.getApplicationContext(), getClass());
+    assertThat(intent.getComponent().getClassName()).isEqualTo(getClass().getName());
+  }
+
+  @Test
+  public void shouldSetFlags() {
+    Intent intent = new Intent();
+    Intent self = intent.setFlags(1234);
+    assertEquals(1234, intent.getFlags());
+    assertSame(self, intent);
+  }
+
+  @Test
+  public void shouldAddFlags() {
+    Intent intent = new Intent();
+    Intent self = intent.addFlags(4);
+    self.addFlags(8);
+    assertEquals(12, intent.getFlags());
+    assertSame(self, intent);
+  }
+
+  @Test
+  public void shouldSupportCategories() {
+    Intent intent = new Intent();
+    Intent self = intent.addCategory("category.name.1");
+    intent.addCategory("category.name.2");
+
+    assertTrue(intent.hasCategory("category.name.1"));
+    assertTrue(intent.hasCategory("category.name.2"));
+
+    Set<String> categories = intent.getCategories();
+    assertTrue(categories.contains("category.name.1"));
+    assertTrue(categories.contains("category.name.2"));
+
+    intent.removeCategory("category.name.1");
+    assertFalse(intent.hasCategory("category.name.1"));
+    assertTrue(intent.hasCategory("category.name.2"));
+
+    intent.removeCategory("category.name.2");
+    assertFalse(intent.hasCategory("category.name.2"));
+
+    assertThat(intent.getCategories()).isNull();
+
+    assertSame(self, intent);
+  }
+
+  @Test
+  public void shouldAddCategories() {
+    Intent intent = new Intent();
+    Intent self = intent.addCategory("foo");
+    assertTrue(intent.getCategories().contains("foo"));
+    assertSame(self, intent);
+  }
+
+  @Test
+  public void shouldFillIn() {
+    Intent intentA = new Intent();
+    Intent intentB = new Intent();
+
+    intentB.setAction("foo");
+    Uri uri = Uri.parse("http://www.foo.com");
+    intentB.setDataAndType(uri, "text/html");
+    String category = "category";
+    intentB.addCategory(category);
+    intentB.setPackage("com.foobar.app");
+    ComponentName cn = new ComponentName("com.foobar.app", "fragmentActivity");
+    intentB.setComponent(cn);
+    intentB.putExtra("FOO", 23);
+
+    int flags = Intent.FILL_IN_ACTION |
+        Intent.FILL_IN_DATA |
+        Intent.FILL_IN_CATEGORIES |
+        Intent.FILL_IN_PACKAGE |
+        Intent.FILL_IN_COMPONENT;
+
+    int result = intentA.fillIn(intentB, flags);
+    assertEquals("foo", intentA.getAction());
+    assertSame(uri, intentA.getData());
+    assertEquals("text/html", intentA.getType());
+    assertTrue(intentA.getCategories().contains(category));
+    assertEquals("com.foobar.app", intentA.getPackage());
+    assertSame(cn, intentA.getComponent());
+    assertEquals(23, intentA.getIntExtra("FOO", -1));
+    assertEquals(result, flags);
+  }
+
+  @Test
+  public void createChooser_shouldWrapIntent() {
+    Intent originalIntent = new Intent(Intent.ACTION_BATTERY_CHANGED, Uri.parse("foo://blah"));
+    Intent chooserIntent = Intent.createChooser(originalIntent, "The title");
+    assertThat(chooserIntent.getAction()).isEqualTo(Intent.ACTION_CHOOSER);
+    assertThat(chooserIntent.getStringExtra(Intent.EXTRA_TITLE)).isEqualTo("The title");
+    assertThat((Intent) chooserIntent.getParcelableExtra(Intent.EXTRA_INTENT))
+        .isSameInstanceAs(originalIntent);
+  }
+
+  @Test
+  public void setUri_setsUri() {
+    Intent intent = new Intent();
+    intent.setData(Uri.parse("http://foo"));
+    assertThat(intent.getData()).isEqualTo(Uri.parse("http://foo"));
+  }
+
+  @Test
+  public void setUri_shouldReturnUriString() {
+    Intent intent = new Intent();
+    intent.setData(Uri.parse("http://foo"));
+    assertThat(intent.getDataString()).isEqualTo("http://foo");
+  }
+
+  @Test
+  public void setUri_shouldReturnNullUriString() {
+    Intent intent = new Intent();
+    assertThat(intent.getDataString()).isNull();
+  }
+
+  @Test
+  public void putStringArrayListExtra_addsListToExtras() {
+    Intent intent = new Intent();
+    final ArrayList<String> strings = new ArrayList<>(Arrays.asList("hi", "there"));
+
+    intent.putStringArrayListExtra("KEY", strings);
+    assertThat(intent.getStringArrayListExtra("KEY")).isEqualTo(strings);
+    assertThat(intent.getExtras().getStringArrayList("KEY")).isEqualTo(strings);
+  }
+
+  @Test
+  public void putIntegerArrayListExtra_addsListToExtras() {
+    Intent intent = new Intent();
+    final ArrayList<Integer> integers = new ArrayList<>(Arrays.asList(100, 200, 300));
+
+    intent.putIntegerArrayListExtra("KEY", integers);
+    assertThat(intent.getIntegerArrayListExtra("KEY")).isEqualTo(integers);
+    assertThat(intent.getExtras().getIntegerArrayList("KEY")).isEqualTo(integers);
+  }
+
+  @Test
+  public void constructor_shouldSetComponentAndActionAndData() {
+    Intent intent =
+        new Intent(
+            "roboaction",
+            Uri.parse("http://www.robolectric.org"),
+            ApplicationProvider.getApplicationContext(),
+            Activity.class);
+    assertThat(intent.getComponent())
+        .isEqualTo(new ComponentName("org.robolectric", "android.app.Activity"));
+    assertThat(intent.getAction()).isEqualTo("roboaction");
+    assertThat(intent.getData()).isEqualTo(Uri.parse("http://www.robolectric.org"));
+  }
+
+  @Test
+  public void putExtra_shouldBeChainable() {
+    // Ensure that all putExtra methods return the Intent properly and can therefore be chained
+    // without causing NPE's
+    Intent intent = new Intent();
+
+    assertThat(intent.putExtra("double array", new double[] { 0.0 })).isEqualTo(intent);
+    assertThat(intent.putExtra("int", 0)).isEqualTo(intent);
+    assertThat(intent.putExtra("CharSequence", new TestCharSequence("test"))).isEqualTo(intent);
+    assertThat(intent.putExtra("char", 'a')).isEqualTo(intent);
+    assertThat(intent.putExtra("Bundle", new Bundle())).isEqualTo(intent);
+    assertThat(intent.putExtra("Parcelable array", new Parcelable[] { new TestParcelable(0) }))
+        .isEqualTo(intent);
+    assertThat(intent.putExtra("Serializable", new TestSerializable("test"))).isEqualTo(intent);
+    assertThat(intent.putExtra("int array", new int[] { 0 })).isEqualTo(intent);
+    assertThat(intent.putExtra("float", 0f)).isEqualTo(intent);
+    assertThat(intent.putExtra("byte array", new byte[] { 0 })).isEqualTo(intent);
+    assertThat(intent.putExtra("long array", new long[] { 0L })).isEqualTo(intent);
+    assertThat(intent.putExtra("Parcelable", new TestParcelable(0))).isEqualTo(intent);
+    assertThat(intent.putExtra("float array", new float[] { 0f })).isEqualTo(intent);
+    assertThat(intent.putExtra("long", 0L)).isEqualTo(intent);
+    assertThat(intent.putExtra("String array", new String[] { "test" })).isEqualTo(intent);
+    assertThat(intent.putExtra("boolean", true)).isEqualTo(intent);
+    assertThat(intent.putExtra("boolean array", new boolean[] { true })).isEqualTo(intent);
+    assertThat(intent.putExtra("short", (short) 0)).isEqualTo(intent);
+    assertThat(intent.putExtra("double", 0.0)).isEqualTo(intent);
+    assertThat(intent.putExtra("short array", new short[] { 0 })).isEqualTo(intent);
+    assertThat(intent.putExtra("String", "test")).isEqualTo(intent);
+    assertThat(intent.putExtra("byte", (byte) 0)).isEqualTo(intent);
+    assertThat(intent.putExtra("char array", new char[] { 'a' })).isEqualTo(intent);
+    assertThat(intent.putExtra("CharSequence array",
+        new CharSequence[] { new TestCharSequence("test") }))
+        .isEqualTo(intent);
+  }
+
+  @Test
+  public void equals_shouldOnlyBeIdentity() {
+    assertThat(new Intent()).isNotEqualTo(new Intent());
+  }
+
+  @Test
+  public void cloneFilter_shouldIncludeAction() {
+    Intent intent = new Intent("FOO");
+    intent.cloneFilter();
+    assertThat(intent.getAction()).isEqualTo("FOO");
+  }
+
+  @Test
+  public void getExtra_shouldWorkAfterParcel() {
+    ComponentName componentName = new ComponentName("barcomponent", "compclass");
+    Uri parsed = Uri.parse("https://foo.bar");
+    Intent intent = new Intent();
+    intent.putExtra("key", 123);
+    intent.setAction("Foo");
+    intent.setComponent(componentName);
+    intent.setData(parsed);
+    Parcel parcel = Parcel.obtain();
+    parcel.writeParcelable(intent, 0);
+    parcel.setDataPosition(0);
+    intent = parcel.readParcelable(getClass().getClassLoader());
+    assertThat(intent.getIntExtra("key", 0)).isEqualTo(123);
+    assertThat(intent.getAction()).isEqualTo("Foo");
+    assertThat(intent.getComponent()).isEqualTo(componentName);
+    assertThat(intent.getData()).isEqualTo(parsed);
+  }
+
+  private static class TestSerializable implements Serializable {
+    private String someValue;
+
+    public TestSerializable(String someValue) {
+      this.someValue = someValue;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) return true;
+      if (!(o instanceof TestSerializable)) return false;
+
+      TestSerializable that = (TestSerializable) o;
+
+      return Objects.equals(someValue, that.someValue);
+    }
+
+    @Override
+    public int hashCode() {
+      return someValue != null ? someValue.hashCode() : 0;
+    }
+  }
+
+  private static class TestCharSequence implements CharSequence {
+    String s;
+
+    public TestCharSequence(String s) {
+      this.s = s;
+    }
+
+    @Override
+    public char charAt(int index) {
+      return s.charAt(index);
+    }
+
+    @Override
+    public int length() {
+      return s.length();
+    }
+
+    @Override
+    public CharSequence subSequence(int start, int end) {
+      return s.subSequence(start, end);
+    }
+
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIoUtilsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIoUtilsTest.java
new file mode 100644
index 0000000..05a189b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIoUtilsTest.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.Files;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import libcore.io.IoUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowIoUtilsTest {
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void ioUtils() throws Exception {
+
+    File file = temporaryFolder.newFile("test_file.txt");
+    Files.asCharSink(file, StandardCharsets.UTF_8).write("some contents");
+
+    String contents = IoUtils.readFileAsString(file.getAbsolutePath());
+    assertThat(contents).isEqualTo("some contents");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowIsoDepTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowIsoDepTest.java
new file mode 100644
index 0000000..3d7636f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowIsoDepTest.java
@@ -0,0 +1,75 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.nfc.tech.IsoDep;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.concurrent.Callable;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowIsoDep}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = KITKAT)
+public final class ShadowIsoDepTest {
+
+  private IsoDep isoDep;
+
+  @Before
+  public void setUp() {
+    isoDep = ShadowIsoDep.newInstance();
+  }
+
+  @Test
+  public void transceive() throws Exception {
+    shadowOf(isoDep).setTransceiveResponse(new byte[] {1, 2, 3});
+    assertThat(isoDep.transceive(new byte[0])).isEqualTo(new byte[] {1, 2, 3});
+  }
+
+  @Test
+  public void nextTransceive() throws Exception {
+    shadowOf(isoDep).setNextTransceiveResponse(new byte[] {1, 2, 3});
+    assertThat(isoDep.transceive(new byte[0])).isEqualTo(new byte[] {1, 2, 3});
+    assertThrows(IOException.class, () -> isoDep.transceive(new byte[0]));
+  }
+
+  @Test
+  public void timeout() {
+    isoDep.setTimeout(1000);
+    assertThat(isoDep.getTimeout()).isEqualTo(1000);
+  }
+
+  @Test
+  public void maxTransceiveLength() {
+    shadowOf(isoDep).setMaxTransceiveLength(1000);
+    assertThat(isoDep.getMaxTransceiveLength()).isEqualTo(1000);
+  }
+
+  @Test
+  public void isExtendedLengthApduSupported() {
+    shadowOf(isoDep).setExtendedLengthApduSupported(true);
+    assertThat(isoDep.isExtendedLengthApduSupported()).isTrue();
+    shadowOf(isoDep).setExtendedLengthApduSupported(false);
+    assertThat(isoDep.isExtendedLengthApduSupported()).isFalse();
+  }
+
+  private static <T extends Throwable> void assertThrows(Class<T> clazz, Callable<?> callable) {
+    try {
+      callable.call();
+    } catch (Throwable t) {
+      if (clazz.isInstance(t)) {
+        // expected
+        return;
+      } else {
+        fail("did not throw " + clazz.getName() + ", threw " + t + " instead");
+      }
+    }
+    fail("did not throw " + clazz.getName());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowJobSchedulerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowJobSchedulerTest.java
new file mode 100644
index 0000000..06d0d26
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowJobSchedulerTest.java
@@ -0,0 +1,231 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.app.job.JobWorkItem;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowJobSchedulerTest {
+
+  private JobScheduler jobScheduler;
+  private Application context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+  }
+
+  @Test
+  public void getAllPendingJobs() {
+    JobInfo jobInfo =
+        new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build();
+    jobScheduler.schedule(jobInfo);
+
+    assertThat(jobScheduler.getAllPendingJobs()).contains(jobInfo);
+  }
+
+  @Test
+  public void cancelAll() {
+    jobScheduler.schedule(
+        new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build());
+    jobScheduler.schedule(
+        new JobInfo.Builder(33, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build());
+
+    assertThat(jobScheduler.getAllPendingJobs()).hasSize(2);
+
+    jobScheduler.cancelAll();
+
+    assertThat(jobScheduler.getAllPendingJobs()).isEmpty();
+  }
+
+  @Test
+  public void cancelSingleJob() {
+    jobScheduler.schedule(
+        new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build());
+
+    assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty();
+
+    jobScheduler.cancel(99);
+
+    assertThat(jobScheduler.getAllPendingJobs()).isEmpty();
+  }
+
+  @Test
+  public void cancelNonExistentJob() {
+    jobScheduler.schedule(
+        new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build());
+
+    assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty();
+
+    jobScheduler.cancel(33);
+
+    assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty();
+  }
+
+  @Test
+  public void schedule_success() {
+    int result =
+        jobScheduler.schedule(
+            new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+                .setPeriodic(1000)
+                .build());
+    assertThat(result).isEqualTo(JobScheduler.RESULT_SUCCESS);
+  }
+
+  @Test
+  public void schedule_fail() {
+    shadowOf(jobScheduler).failOnJob(99);
+
+    int result =
+        jobScheduler.schedule(
+            new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+                .setPeriodic(1000)
+                .build());
+
+    assertThat(result).isEqualTo(JobScheduler.RESULT_FAILURE);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void scheduleAsPackage_success() {
+    int result =
+        jobScheduler.scheduleAsPackage(
+            new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+                .setPeriodic(1000)
+                .build(),
+            "package.name",
+            0,
+            "TAG");
+    assertThat(result).isEqualTo(JobScheduler.RESULT_SUCCESS);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void scheduleAsPackage_fail() {
+    shadowOf(jobScheduler).failOnJob(99);
+
+    int result =
+        jobScheduler.scheduleAsPackage(
+            new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+                .setPeriodic(1000)
+                .build(),
+            "package.name",
+            0,
+            "TAG");
+
+    assertThat(result).isEqualTo(JobScheduler.RESULT_FAILURE);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getPendingJob_withValidId() {
+    int jobId = 99;
+    JobInfo originalJobInfo =
+        new JobInfo.Builder(jobId, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build();
+
+    jobScheduler.schedule(originalJobInfo);
+
+    JobInfo retrievedJobInfo = jobScheduler.getPendingJob(jobId);
+
+    assertThat(retrievedJobInfo).isEqualTo(originalJobInfo);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getPendingJob_withInvalidId() {
+    int jobId = 99;
+    int invalidJobId = 100;
+    JobInfo originalJobInfo =
+        new JobInfo.Builder(jobId, new ComponentName(context, "component_class_name"))
+            .setPeriodic(1000)
+            .build();
+
+    jobScheduler.schedule(originalJobInfo);
+
+    JobInfo retrievedJobInfo = jobScheduler.getPendingJob(invalidJobId);
+
+    assertThat(retrievedJobInfo).isNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void enqueue_success() {
+    int result =
+        jobScheduler.enqueue(
+            new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+                .setPeriodic(1000)
+                .build(),
+            new JobWorkItem(new Intent()));
+    assertThat(result).isEqualTo(JobScheduler.RESULT_SUCCESS);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void enqueue_fail() {
+    shadowOf(jobScheduler).failOnJob(99);
+
+    int result =
+        jobScheduler.enqueue(
+            new JobInfo.Builder(99, new ComponentName(context, "component_class_name"))
+                .setPeriodic(1000)
+                .build(),
+            new JobWorkItem(new Intent()));
+
+    assertThat(result).isEqualTo(JobScheduler.RESULT_FAILURE);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void scheduleExpeditedJob_success() {
+    int result =
+        jobScheduler.schedule(
+            new JobInfo.Builder(0, new ComponentName(context, "component_class_name"))
+                .setExpedited(true)
+                .build());
+    assertThat(result).isEqualTo(JobScheduler.RESULT_SUCCESS);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void scheduleExpeditedJob_fail() {
+    shadowOf(jobScheduler).failExpeditedJob(true);
+
+    int result =
+        jobScheduler.schedule(
+            new JobInfo.Builder(0, new ComponentName(context, "component_class_name"))
+                .setExpedited(true)
+                .build());
+    assertThat(result).isEqualTo(JobScheduler.RESULT_FAILURE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowJobServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowJobServiceTest.java
new file mode 100644
index 0000000..8fca386
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowJobServiceTest.java
@@ -0,0 +1,60 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+/** Robolectric test for {@link ShadowJobService}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowJobServiceTest {
+
+  private JobService jobService;
+  @Mock
+  private JobParameters params;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    jobService = new JobService() {
+      @Override
+      public boolean onStartJob(JobParameters params) {
+        return false;
+      }
+
+      @Override
+      public boolean onStopJob(JobParameters params) {
+        return false;
+      }
+    };
+  }
+
+  @Test
+  public void jobFinishedInitiallyFalse() {
+    assertThat(shadowOf(jobService).getIsJobFinished()).isFalse();
+  }
+
+  @Test
+  public void jobIsRescheduleNeededInitiallyFalse() {
+    assertThat(shadowOf(jobService).getIsRescheduleNeeded()).isFalse();
+  }
+
+  @Test
+  public void jobFinished_updatesFieldsCorrectly() {
+    jobService.jobFinished(params, true /* wantsReschedule */);
+    ShadowJobService shadow = shadowOf(jobService);
+
+    assertThat(shadow.getIsRescheduleNeeded()).isTrue();
+    assertThat(shadow.getIsJobFinished()).isTrue();
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowJsPromptResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowJsPromptResultTest.java
new file mode 100644
index 0000000..becea82
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowJsPromptResultTest.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertNotNull;
+
+import android.webkit.JsPromptResult;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowJsPromptResultTest {
+
+  @Test
+  public void shouldConstruct() {
+    JsPromptResult result = ShadowJsPromptResult.newInstance();
+    assertNotNull(result);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowJsResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowJsResultTest.java
new file mode 100644
index 0000000..e469242
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowJsResultTest.java
@@ -0,0 +1,27 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.webkit.JsResult;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowJsResultTest {
+
+  @Test
+  public void shouldRecordCanceled() {
+    JsResult jsResult = Shadow.newInstanceOf(JsResult.class);
+
+    assertFalse(shadowOf(jsResult).wasCancelled());
+
+    jsResult.cancel();
+    assertTrue(shadowOf(jsResult).wasCancelled());
+
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowJsonReaderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowJsonReaderTest.java
new file mode 100644
index 0000000..8a6f716
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowJsonReaderTest.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.JsonReader;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.StringReader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowJsonReaderTest {
+  @Test public void shouldWork() throws Exception {
+    JsonReader jsonReader = new JsonReader(new StringReader("{\"abc\": \"def\"}"));
+    jsonReader.beginObject();
+    assertThat(jsonReader.nextName()).isEqualTo("abc");
+    assertThat(jsonReader.nextString()).isEqualTo("def");
+    jsonReader.endObject();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowKeyguardManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowKeyguardManagerTest.java
new file mode 100644
index 0000000..074740a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowKeyguardManagerTest.java
@@ -0,0 +1,194 @@
+package org.robolectric.shadows;
+
+import static android.content.Context.KEYGUARD_SERVICE;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.KeyguardManager;
+import android.app.KeyguardManager.KeyguardDismissCallback;
+import android.content.Intent;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowKeyguardManagerTest {
+  private static final int USER_ID = 1001;
+
+  private KeyguardManager manager;
+
+  @Before
+  public void setUp() {
+    manager =
+        (KeyguardManager)
+            ApplicationProvider.getApplicationContext().getSystemService(KEYGUARD_SERVICE);
+  }
+
+  @Test
+  public void testIsInRestrictedInputMode() {
+    assertThat(manager.inKeyguardRestrictedInputMode()).isFalse();
+    shadowOf(manager).setinRestrictedInputMode(true);
+    assertThat(manager.inKeyguardRestrictedInputMode()).isTrue();
+  }
+
+  @Test
+  public void testIsKeyguardLocked() {
+    assertThat(manager.isKeyguardLocked()).isFalse();
+    shadowOf(manager).setKeyguardLocked(true);
+    assertThat(manager.isKeyguardLocked()).isTrue();
+  }
+
+  @Test
+  public void testShouldBeAbleToDisableTheKeyguardLock() throws Exception {
+    KeyguardManager.KeyguardLock lock = manager.newKeyguardLock(KEYGUARD_SERVICE);
+    assertThat(shadowOf(lock).isEnabled()).isTrue();
+
+    lock.disableKeyguard();
+    assertThat(shadowOf(lock).isEnabled()).isFalse();
+
+    lock.reenableKeyguard();
+    assertThat(shadowOf(lock).isEnabled()).isTrue();
+  }
+
+  @Test
+  public void isKeyguardSecure() {
+    assertThat(manager.isKeyguardSecure()).isFalse();
+
+    shadowOf(manager).setIsKeyguardSecure(true);
+
+    assertThat(manager.isKeyguardSecure()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isDeviceSecure() {
+    assertThat(manager.isDeviceSecure()).isFalse();
+
+    shadowOf(manager).setIsDeviceSecure(true);
+
+    assertThat(manager.isDeviceSecure()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isDeviceSecureByUserId() {
+    assertThat(manager.isDeviceSecure(USER_ID)).isFalse();
+
+    shadowOf(manager).setIsDeviceSecure(USER_ID, true);
+
+    assertThat(manager.isDeviceSecure(USER_ID)).isTrue();
+    assertThat(manager.isDeviceSecure(USER_ID + 1)).isFalse();
+
+    shadowOf(manager).setIsDeviceSecure(USER_ID, false);
+    assertThat(manager.isDeviceSecure(USER_ID)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void isDeviceLocked() {
+    assertThat(manager.isDeviceLocked()).isFalse();
+
+    shadowOf(manager).setIsDeviceLocked(true);
+
+    assertThat(manager.isDeviceLocked()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void isDeviceLockedByUserId() {
+    assertThat(manager.isDeviceLocked(USER_ID)).isFalse();
+
+    shadowOf(manager).setIsDeviceLocked(USER_ID, true);
+    assertThat(manager.isDeviceLocked(USER_ID)).isTrue();
+    assertThat(manager.isDeviceLocked(USER_ID + 1)).isFalse();
+
+    shadowOf(manager).setIsDeviceLocked(USER_ID, false);
+    assertThat(manager.isDeviceLocked(USER_ID)).isFalse();
+  }
+
+
+  @Test
+  @Config(minSdk = O)
+  public void requestDismissKeyguard_dismissCancelled() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    KeyguardDismissCallback mockCallback = mock(KeyguardDismissCallback.class);
+
+    shadowOf(manager).setKeyguardLocked(true);
+
+    manager.requestDismissKeyguard(activity, mockCallback);
+
+    // Keep the keyguard locked
+    shadowOf(manager).setKeyguardLocked(true);
+
+    verify(mockCallback).onDismissCancelled();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void requestDismissKeyguard_dismissSucceeded() {
+    Activity activity = Robolectric.setupActivity(Activity.class);
+
+    KeyguardDismissCallback mockCallback = mock(KeyguardDismissCallback.class);
+
+    shadowOf(manager).setKeyguardLocked(true);
+
+    manager.requestDismissKeyguard(activity, mockCallback);
+
+    // Unlock the keyguard
+    shadowOf(manager).setKeyguardLocked(false);
+
+    verify(mockCallback).onDismissSucceeded();
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void testCreateConfirmFactoryResetCredentialIntent_nullIntent() {
+    assertThat(manager.isDeviceLocked()).isFalse();
+
+    shadowOf(manager).setConfirmFactoryResetCredentialIntent(null);
+
+    assertThat(manager.createConfirmFactoryResetCredentialIntent(null, null, null)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void testCreateConfirmFactoryResetCredentialIntent() {
+    assertThat(manager.isDeviceLocked()).isFalse();
+
+    Intent intent = new Intent();
+    shadowOf(manager).setConfirmFactoryResetCredentialIntent(intent);
+
+    assertThat(manager.createConfirmFactoryResetCredentialIntent(null, null, null))
+        .isEqualTo(intent);
+  }
+
+  /**
+   * On Android L and below, calling {@link android.content.Context#getSystemService(String)} for
+   * {@link android.content.Context#KEYGUARD_SERVICE} will return a new instance each time.
+   */
+  @Test
+  public void isKeyguardLocked_retainedAcrossMultipleInstances() {
+    assertThat(manager.isKeyguardLocked()).isFalse();
+    shadowOf(manager).setKeyguardLocked(true);
+    KeyguardManager manager2 =
+        (KeyguardManager)
+            ApplicationProvider.getApplicationContext().getSystemService(KEYGUARD_SERVICE);
+    assertThat(manager2.isKeyguardLocked()).isTrue();
+    assertThat(shadowOf(manager.newKeyguardLock("tag")).isEnabled()).isTrue();
+    KeyguardManager.KeyguardLock keyguardLock = manager2.newKeyguardLock("tag");
+    keyguardLock.disableKeyguard();
+    assertThat(shadowOf(manager.newKeyguardLock("tag")).isEnabled()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLauncherAppsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLauncherAppsTest.java
new file mode 100644
index 0000000..edff632
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLauncherAppsTest.java
@@ -0,0 +1,440 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.L;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.LauncherActivityInfo;
+import android.content.pm.LauncherActivityInfoInternal;
+import android.content.pm.LauncherApps;
+import android.content.pm.LauncherApps.ShortcutQuery;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ShortcutInfo;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Process;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Robolectric test for {@link ShadowLauncherApps}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O_MR1)
+public class ShadowLauncherAppsTest {
+  private static final String TEST_PACKAGE_NAME = "test-package";
+  private static final String TEST_PACKAGE_NAME_2 = "test-package2";
+  private static final String TEST_PACKAGE_NAME_3 = "test-package3";
+  private static final UserHandle USER_HANDLE = UserHandle.CURRENT;
+  private LauncherApps launcherApps;
+
+  private static class DefaultCallback extends LauncherApps.Callback {
+    @Override
+    public void onPackageRemoved(String packageName, UserHandle user) {}
+
+    @Override
+    public void onPackageAdded(String packageName, UserHandle user) {}
+
+    @Override
+    public void onPackageChanged(String packageName, UserHandle user) {}
+
+    @Override
+    public void onPackagesAvailable(String[] packageNames, UserHandle user, boolean replacing) {}
+
+    @Override
+    public void onPackagesUnavailable(String[] packageNames, UserHandle user, boolean replacing) {}
+  }
+
+  @Before
+  public void setup() {
+    launcherApps =
+        (LauncherApps)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.LAUNCHER_APPS_SERVICE);
+  }
+
+  @ForType(ShortcutInfo.class)
+  private interface ReflectorShortcutInfo {
+    @Accessor("mPackageName")
+    void setPackage(String packageName);
+  }
+
+  private ShadowLooper shadowLooper(Looper looper) {
+    return Shadow.extract(looper);
+  }
+
+  @Test
+  public void testIsPackageEnabled() {
+    assertThat(launcherApps.isPackageEnabled(TEST_PACKAGE_NAME, USER_HANDLE)).isFalse();
+
+    shadowOf(launcherApps).addEnabledPackage(USER_HANDLE, TEST_PACKAGE_NAME);
+    assertThat(launcherApps.isPackageEnabled(TEST_PACKAGE_NAME, USER_HANDLE)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O, maxSdk = R)
+  public void getShortcutConfigActivityList_getsShortcutsForPackageName() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME_2, USER_HANDLE);
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo2);
+
+    assertThat(launcherApps.getShortcutConfigActivityList(TEST_PACKAGE_NAME, USER_HANDLE))
+        .contains(launcherActivityInfo1);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getShortcutConfigActivityListS_getsShortcutsForPackageName() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME_2, USER_HANDLE);
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo2);
+
+    assertThat(launcherApps.getShortcutConfigActivityList(TEST_PACKAGE_NAME, USER_HANDLE))
+        .contains(launcherActivityInfo1);
+  }
+
+  @Test
+  @Config(minSdk = O, maxSdk = R)
+  public void getShortcutConfigActivityList_getsShortcutsForUserHandle() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME, UserHandle.of(10));
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addShortcutConfigActivity(UserHandle.of(10), launcherActivityInfo2);
+
+    assertThat(launcherApps.getShortcutConfigActivityList(TEST_PACKAGE_NAME, UserHandle.of(10)))
+        .contains(launcherActivityInfo2);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getShortcutConfigActivityListS_getsShortcutsForUserHandle() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME, UserHandle.of(10));
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addShortcutConfigActivity(UserHandle.of(10), launcherActivityInfo2);
+
+    assertThat(launcherApps.getShortcutConfigActivityList(TEST_PACKAGE_NAME, UserHandle.of(10)))
+        .contains(launcherActivityInfo2);
+  }
+
+  @Test
+  @Config(minSdk = O, maxSdk = R)
+  public void getShortcutConfigActivityList_packageNull_getsShortcutFromAllPackagesForUser() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME_2, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo3 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME_3, UserHandle.of(10));
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo2);
+    shadowOf(launcherApps).addShortcutConfigActivity(UserHandle.of(10), launcherActivityInfo3);
+
+    assertThat(launcherApps.getShortcutConfigActivityList(null, USER_HANDLE))
+        .containsExactly(launcherActivityInfo1, launcherActivityInfo2);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getShortcutConfigActivityListS_packageNull_getsShortcutFromAllPackagesForUser() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME_2, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo3 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME_3, UserHandle.of(10));
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addShortcutConfigActivity(USER_HANDLE, launcherActivityInfo2);
+    shadowOf(launcherApps).addShortcutConfigActivity(UserHandle.of(10), launcherActivityInfo3);
+
+    assertThat(launcherApps.getShortcutConfigActivityList(null, USER_HANDLE))
+        .containsExactly(launcherActivityInfo1, launcherActivityInfo2);
+  }
+
+  @Test
+  @Config(minSdk = L, maxSdk = M)
+  public void testGetActivityListPreN() {
+    assertThat(launcherApps.getActivityList(TEST_PACKAGE_NAME, USER_HANDLE)).isEmpty();
+
+    ResolveInfo info =
+        ShadowResolveInfo.newResolveInfo(TEST_PACKAGE_NAME, TEST_PACKAGE_NAME, TEST_PACKAGE_NAME);
+    LauncherActivityInfo launcherActivityInfo =
+        ReflectionHelpers.callConstructor(
+            LauncherActivityInfo.class,
+            ClassParameter.from(Context.class, ApplicationProvider.getApplicationContext()),
+            ClassParameter.from(ResolveInfo.class, info),
+            ClassParameter.from(UserHandle.class, USER_HANDLE),
+            ClassParameter.from(long.class, System.currentTimeMillis()));
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo);
+    assertThat(launcherApps.getActivityList(TEST_PACKAGE_NAME, USER_HANDLE))
+        .contains(launcherActivityInfo);
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = R)
+  public void testGetActivityList() {
+    assertThat(launcherApps.getActivityList(TEST_PACKAGE_NAME, USER_HANDLE)).isEmpty();
+
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME, USER_HANDLE);
+
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo1);
+    assertThat(launcherApps.getActivityList(TEST_PACKAGE_NAME, USER_HANDLE))
+        .contains(launcherActivityInfo1);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void testGetActivityListS() {
+    LauncherActivityInfo launcherActivityInfo =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME, USER_HANDLE);
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo);
+
+    assertThat(launcherApps.getActivityList(TEST_PACKAGE_NAME, USER_HANDLE))
+        .contains(launcherActivityInfo);
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = R)
+  public void testGetActivityList_packageNull_getsActivitiesFromAllPackagesForUser() {
+    LauncherActivityInfo launcherActivityInfo1 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME_2, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo3 =
+        createLauncherActivityInfoPostN(TEST_PACKAGE_NAME_3, UserHandle.of(10));
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo1);
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo2);
+    shadowOf(launcherApps).addActivity(UserHandle.of(10), launcherActivityInfo3);
+
+    assertThat(launcherApps.getActivityList(null, USER_HANDLE))
+        .containsExactly(launcherActivityInfo1, launcherActivityInfo2);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void testGetActivityListS_getsActivitiesFromAllPackagesForUser() {
+    LauncherActivityInfo launcherActivityInfo =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME, USER_HANDLE);
+    LauncherActivityInfo launcherActivityInfo2 =
+        createLauncherActivityInfoS(TEST_PACKAGE_NAME_2, USER_HANDLE);
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo);
+    shadowOf(launcherApps).addActivity(USER_HANDLE, launcherActivityInfo2);
+
+    assertThat(launcherApps.getActivityList(null, USER_HANDLE))
+        .containsExactly(launcherActivityInfo, launcherActivityInfo2);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void testGetApplicationInfo_packageNotFound() throws Exception {
+    Throwable throwable =
+        assertThrows(
+            NameNotFoundException.class,
+            () -> launcherApps.getApplicationInfo(TEST_PACKAGE_NAME, 0, USER_HANDLE));
+
+    assertThat(throwable)
+        .hasMessageThat()
+        .isEqualTo(
+            "Package " + TEST_PACKAGE_NAME + " not found for user " + USER_HANDLE.getIdentifier());
+  }
+
+  @Test
+  public void testGetApplicationInfo_incorrectPackage() throws Exception {
+    ApplicationInfo applicationInfo = new ApplicationInfo();
+    applicationInfo.name = "Test app";
+    shadowOf(launcherApps).addApplicationInfo(USER_HANDLE, TEST_PACKAGE_NAME_2, applicationInfo);
+
+    Throwable throwable =
+        assertThrows(
+            NameNotFoundException.class,
+            () -> launcherApps.getApplicationInfo(TEST_PACKAGE_NAME, 0, USER_HANDLE));
+
+    assertThat(throwable)
+        .hasMessageThat()
+        .isEqualTo(
+            "Package " + TEST_PACKAGE_NAME + " not found for user " + USER_HANDLE.getIdentifier());
+  }
+
+  @Test
+  public void testGetApplicationInfo_findsApplicationInfo() throws Exception {
+    ApplicationInfo applicationInfo = new ApplicationInfo();
+    applicationInfo.name = "Test app";
+    shadowOf(launcherApps).addApplicationInfo(USER_HANDLE, TEST_PACKAGE_NAME, applicationInfo);
+
+    assertThat(launcherApps.getApplicationInfo(TEST_PACKAGE_NAME, 0, USER_HANDLE))
+        .isEqualTo(applicationInfo);
+  }
+
+  @Test
+  public void testCallbackFiresWhenShortcutAddedOrRemoved() throws Exception {
+    final Boolean[] wasCalled = new Boolean[] {false};
+    final CountDownLatch latch1 = new CountDownLatch(1);
+    final CountDownLatch latch2 = new CountDownLatch(2);
+
+    final String packageName = ApplicationProvider.getApplicationContext().getPackageName();
+
+    HandlerThread handlerThread = new HandlerThread("test");
+    handlerThread.start();
+    try {
+      LauncherApps.Callback callback =
+          new DefaultCallback() {
+            @Override
+            public void onShortcutsChanged(
+                String packageName, List<ShortcutInfo> shortcuts, UserHandle user) {
+              assertEquals(shortcuts.get(0).getPackage(), packageName);
+              wasCalled[0] = true;
+              latch1.countDown();
+              latch2.countDown();
+            }
+          };
+      launcherApps.registerCallback(callback, new Handler(handlerThread.getLooper()));
+      shadowOf(launcherApps)
+          .addDynamicShortcut(
+              new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), "ID").build());
+      shadowLooper(handlerThread.getLooper()).idle();
+
+      latch1.await(1, TimeUnit.SECONDS);
+      assertTrue(wasCalled[0]);
+
+      wasCalled[0] = false;
+      launcherApps.pinShortcuts(packageName, new ArrayList<>(), Process.myUserHandle());
+      shadowLooper(handlerThread.getLooper()).idle();
+      latch2.await(1, TimeUnit.SECONDS);
+      assertTrue(wasCalled[0]);
+    } finally {
+      handlerThread.quit();
+    }
+  }
+
+  @Test
+  public void testGetShortcuts() {
+    final ShortcutInfo shortcut1 =
+        new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), "ID1").build();
+    final ShortcutInfo shortcut2 =
+        new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), "ID2").build();
+
+    shadowOf(launcherApps).addDynamicShortcut(shortcut1);
+    shadowOf(launcherApps).addDynamicShortcut(shortcut2);
+
+    assertThat(getPinnedShortcuts(null, null)).containsExactly(shortcut1, shortcut2);
+  }
+
+  @Test
+  public void testGetShortcutsWithFilters() {
+    String myPackage = ApplicationProvider.getApplicationContext().getPackageName();
+    String otherPackage = "other";
+    ComponentName c1 = new ComponentName(ApplicationProvider.getApplicationContext(), "Activity1");
+    ComponentName c2 = new ComponentName(ApplicationProvider.getApplicationContext(), "Activity2");
+    ComponentName c3 = new ComponentName(otherPackage, "Activity1");
+
+    final ShortcutInfo shortcut1 =
+        new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), "ID1")
+            .setActivity(c1)
+            .build();
+    final ShortcutInfo shortcut2 =
+        new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), "ID2")
+            .setActivity(c2)
+            .build();
+    final ShortcutInfo shortcut3 =
+        new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), "ID3")
+            .setActivity(c3)
+            .build();
+    reflector(ReflectorShortcutInfo.class, shortcut3).setPackage(otherPackage);
+
+    shadowOf(launcherApps).addDynamicShortcut(shortcut1);
+    shadowOf(launcherApps).addDynamicShortcut(shortcut2);
+    shadowOf(launcherApps).addDynamicShortcut(shortcut3);
+
+    assertThat(getPinnedShortcuts(otherPackage, null)).containsExactly(shortcut3);
+    assertThat(getPinnedShortcuts(myPackage, null)).containsExactly(shortcut1, shortcut2);
+    assertThat(getPinnedShortcuts(null, c1)).containsExactly(shortcut1);
+    assertThat(getPinnedShortcuts(null, c2)).containsExactly(shortcut2);
+    assertThat(getPinnedShortcuts(null, c3)).containsExactly(shortcut3);
+  }
+
+  @Test
+  public void testHasShortcutHostPermission() {
+    shadowOf(launcherApps).setHasShortcutHostPermission(true);
+    assertThat(launcherApps.hasShortcutHostPermission()).isTrue();
+  }
+
+  @Test
+  public void testHasShortcutHostPermission_returnsFalseByDefault() {
+    assertThat(launcherApps.hasShortcutHostPermission()).isFalse();
+  }
+
+  private List<ShortcutInfo> getPinnedShortcuts(String packageName, ComponentName activity) {
+    ShortcutQuery query = new ShortcutQuery();
+    query.setQueryFlags(ShortcutQuery.FLAG_MATCH_DYNAMIC | ShortcutQuery.FLAG_MATCH_PINNED);
+    query.setPackage(packageName);
+    query.setActivity(activity);
+    return launcherApps.getShortcuts(query, Process.myUserHandle());
+  }
+
+  private LauncherActivityInfo createLauncherActivityInfoS(String packageName, UserHandle user) {
+    ActivityInfo info = new ActivityInfo();
+    info.packageName = packageName;
+    info.name = packageName;
+    info.nonLocalizedLabel = packageName;
+    LauncherActivityInfoInternal launcherActivityInfoInternal =
+        new LauncherActivityInfoInternal(info, null);
+
+    return ReflectionHelpers.callConstructor(
+        LauncherActivityInfo.class,
+        ClassParameter.from(Context.class, ApplicationProvider.getApplicationContext()),
+        ClassParameter.from(UserHandle.class, user),
+        ClassParameter.from(LauncherActivityInfoInternal.class, launcherActivityInfoInternal));
+  }
+
+  private LauncherActivityInfo createLauncherActivityInfoPostN(
+      String packageName, UserHandle userHandle) {
+    ActivityInfo info = new ActivityInfo();
+    info.packageName = packageName;
+    info.name = packageName;
+    info.nonLocalizedLabel = packageName;
+    return ReflectionHelpers.callConstructor(
+        LauncherActivityInfo.class,
+        ClassParameter.from(Context.class, ApplicationProvider.getApplicationContext()),
+        ClassParameter.from(ActivityInfo.class, info),
+        ClassParameter.from(UserHandle.class, userHandle));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLayerDrawableTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayerDrawableTest.java
new file mode 100644
index 0000000..e923f9d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayerDrawableTest.java
@@ -0,0 +1,98 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.res.Resources;
+import android.graphics.BitmapFactory;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLayerDrawableTest {
+  /**
+   * drawables
+   */
+  protected Drawable drawable1000;
+  protected Drawable drawable2000;
+  protected Drawable drawable3000;
+  protected Drawable drawable4000;
+
+  /**
+   * drawables
+   */
+  protected Drawable[] drawables;
+
+  @Before
+  public void setUp() {
+    Resources resources = ApplicationProvider.getApplicationContext().getResources();
+    drawable1000 = new BitmapDrawable(BitmapFactory.decodeResource(resources, R.drawable.an_image));
+    drawable2000 =
+        new BitmapDrawable(BitmapFactory.decodeResource(resources, R.drawable.an_other_image));
+    drawable3000 =
+        new BitmapDrawable(BitmapFactory.decodeResource(resources, R.drawable.third_image));
+    drawable4000 =
+        new BitmapDrawable(BitmapFactory.decodeResource(resources, R.drawable.fourth_image));
+
+    drawables = new Drawable[]{drawable1000, drawable2000, drawable3000};
+  }
+
+  @Test
+  public void testGetNumberOfLayers() {
+    LayerDrawable layerDrawable = new LayerDrawable(drawables);
+    assertEquals("count", 3, layerDrawable.getNumberOfLayers());
+  }
+
+  @Test
+  public void testSetDrawableByLayerId1() {
+    LayerDrawable layerDrawable = new LayerDrawable(drawables);
+    int index = 1;
+    int layerId = 345;
+    layerDrawable.setId(index, layerId);
+
+    layerDrawable.setDrawableByLayerId(layerId, drawable4000);
+
+    assertEquals(shadowOf(drawable4000).getCreatedFromResId(),
+        shadowOf(layerDrawable.getDrawable(index)).getCreatedFromResId());
+  }
+
+  @Test
+  public void testSetDrawableByLayerId2() {
+    LayerDrawable layerDrawable = new LayerDrawable(drawables);
+    int index = 0;
+    int layerId = 345;
+    layerDrawable.setId(index, layerId);
+
+    layerDrawable.setDrawableByLayerId(layerId, drawable4000);
+
+    assertEquals(shadowOf(drawable4000).getCreatedFromResId(),
+        shadowOf(layerDrawable.getDrawable(index)).getCreatedFromResId());
+  }
+
+  @Test
+  public void setDrawableByLayerId_shouldReturnFalseIfIdNotFound() {
+    LayerDrawable layerDrawable = new LayerDrawable(drawables);
+    boolean ret = layerDrawable.setDrawableByLayerId(123, drawable4000);
+    assertFalse(ret);
+  }
+
+  @Test
+  public void setDrawableByLayerId_shouldReturnTrueIfIdWasFound() {
+    LayerDrawable layerDrawable = new LayerDrawable(drawables);
+    int index = 0;
+    int layerId = 345;
+    layerDrawable.setId(index, layerId);
+
+    boolean ret = layerDrawable.setDrawableByLayerId(layerId, drawable4000);
+    assertTrue(ret);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutAnimationControllerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutAnimationControllerTest.java
new file mode 100644
index 0000000..75a1c8d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutAnimationControllerTest.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.animation.LayoutAnimationController;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLayoutAnimationControllerTest {
+  private ShadowLayoutAnimationController shadow;
+
+  @Before
+  public void setup() {
+    LayoutAnimationController controller =
+        new LayoutAnimationController(ApplicationProvider.getApplicationContext(), null);
+    shadow = Shadows.shadowOf(controller);
+  }
+
+  @Test
+  public void testResourceId() {
+    int id = 1;
+    shadow.setLoadedFromResourceId(1);
+    assertThat(shadow.getLoadedFromResourceId()).isEqualTo(id);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutInflaterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutInflaterTest.java
new file mode 100644
index 0000000..49dd6d3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutInflaterTest.java
@@ -0,0 +1,499 @@
+package org.robolectric.shadows;
+
+import static android.view.Display.DEFAULT_DISPLAY;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.hardware.display.DisplayManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.WindowManager.LayoutParams;
+import android.webkit.WebView;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.R.layout;
+import org.robolectric.android.CustomStateView;
+import org.robolectric.android.CustomView;
+import org.robolectric.android.CustomView2;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLayoutInflaterTest {
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void testCreatesCorrectClasses() {
+    ViewGroup view = inflate(R.layout.media);
+    assertThat(view).isInstanceOf(LinearLayout.class);
+
+    assertSame(context, view.getContext());
+  }
+
+  @Test
+  public void testChoosesLayoutBasedOnDefaultScreenSize() {
+    ViewGroup view = inflate(R.layout.different_screen_sizes);
+    TextView textView = view.findViewById(android.R.id.text1);
+    assertThat(textView.getText().toString()).isEqualTo("default");
+  }
+
+  @Test
+  @Config(qualifiers = "xlarge")
+  public void testChoosesLayoutBasedOnScreenSize() {
+    ViewGroup view = inflate(R.layout.different_screen_sizes);
+    TextView textView = view.findViewById(android.R.id.text1);
+    assertThat(textView.getText().toString()).isEqualTo("xlarge");
+  }
+
+  @Test
+  @Config(qualifiers = "land")
+  public void testChoosesLayoutBasedOnQualifiers() {
+    ViewGroup view = inflate(R.layout.different_screen_sizes);
+    TextView textView = view.findViewById(android.R.id.text1);
+    assertThat(textView.getText().toString()).isEqualTo("land");
+  }
+
+  @Test
+  public void testWebView() {
+    ViewGroup view = inflate(R.layout.webview_holder);
+    WebView webView = view.findViewById(R.id.web_view);
+
+    webView.loadUrl("www.example.com");
+
+    assertThat(shadowOf(webView).getLastLoadedUrl()).isEqualTo("www.example.com");
+  }
+
+  @Test
+  public void testAddsChildren() {
+    ViewGroup view = inflate(R.layout.media);
+    assertTrue(view.getChildCount() > 0);
+
+    assertSame(context, view.getChildAt(0).getContext());
+  }
+
+  @Test
+  public void testFindsChildrenById() {
+    ViewGroup mediaView = inflate(R.layout.media);
+    assertThat(mediaView.<TextView>findViewById(R.id.title)).isInstanceOf(TextView.class);
+
+    ViewGroup mainView = inflate(R.layout.main);
+    assertThat(mainView.<View>findViewById(R.id.title)).isInstanceOf(View.class);
+  }
+
+  @Test
+  public void testInflatingConflictingSystemAndLocalViewsWorks() {
+    ViewGroup view = inflate(R.layout.activity_list_item);
+    assertThat(view.<ImageView>findViewById(R.id.icon)).isInstanceOf(ImageView.class);
+
+    view = inflate(android.R.layout.activity_list_item);
+    assertThat(view.<ImageView>findViewById(android.R.id.icon)).isInstanceOf(ImageView.class);
+  }
+
+  @Test
+  public void testInclude() {
+    ViewGroup mediaView = inflate(R.layout.media);
+    assertThat(mediaView.<TextView>findViewById(R.id.include_id)).isInstanceOf(TextView.class);
+  }
+
+  @Test
+  public void testIncludeShouldRetainAttributes() {
+    ViewGroup mediaView = inflate(R.layout.media);
+    assertThat(mediaView.findViewById(R.id.include_id).getVisibility()).isEqualTo(View.GONE);
+  }
+
+  @Test
+  public void shouldOverwriteIdOnIncludedNonMerge() {
+    ViewGroup mediaView = inflate(R.layout.media);
+    assertNull(mediaView.findViewById(R.id.snippet_text));
+  }
+
+  @Test
+  public void shouldRetainIdOnIncludedMergeWhenIncludeSpecifiesNoId() {
+    ViewGroup mediaView = inflate(R.layout.override_include);
+    assertThat(mediaView.<TextView>findViewById(R.id.inner_text)).isInstanceOf(TextView.class);
+  }
+
+  @Test
+  public void shouldRetainIdOnIncludedNonMergeWhenIncludeSpecifiesNoId() {
+    ViewGroup mediaView = inflate(R.layout.override_include);
+    assertThat(mediaView.<TextView>findViewById(R.id.snippet_text)).isInstanceOf(TextView.class);
+  }
+
+  @Test
+  public void testIncludedIdShouldNotBeFoundWhenIncludedIsMerge() {
+    ViewGroup overrideIncludeView = inflate(R.layout.outer);
+    assertThat(overrideIncludeView.<LinearLayout>findViewById(R.id.outer_merge))
+        .isInstanceOf(LinearLayout.class);
+    assertThat(overrideIncludeView.<TextView>findViewById(R.id.inner_text))
+        .isInstanceOf(TextView.class);
+    assertNull(overrideIncludeView.findViewById(R.id.include_id));
+    assertEquals(1, overrideIncludeView.getChildCount());
+  }
+
+  @Test
+  public void testIncludeShouldOverrideAttributesOfIncludedRootNode() {
+    ViewGroup overrideIncludeView = inflate(R.layout.override_include);
+    assertThat(overrideIncludeView.findViewById(R.id.snippet_text).getVisibility())
+        .isEqualTo(View.INVISIBLE);
+  }
+
+  @Test
+  public void shouldNotCountRequestFocusElementAsChild() {
+    ViewGroup viewGroup = inflate(R.layout.request_focus);
+    ViewGroup frameLayout = (ViewGroup) viewGroup.getChildAt(1);
+    assertEquals(0, frameLayout.getChildCount());
+  }
+
+  @Test
+  public void focusRequest_shouldNotExplodeOnViewRootImpl() {
+    LinearLayout parent = new LinearLayout(context);
+    shadowOf(parent).setMyParent(ReflectionHelpers.createNullProxy(ViewParent.class));
+    LayoutInflater.from(context).inflate(R.layout.request_focus, parent);
+  }
+
+  @Test
+  public void shouldGiveFocusToElementContainingRequestFocusElement() {
+    ViewGroup viewGroup = inflate(R.layout.request_focus);
+    EditText editText = viewGroup.findViewById(R.id.edit_text);
+    assertFalse(editText.isFocused());
+  }
+
+  @Test
+  public void testMerge() {
+    ViewGroup mediaView = inflate(R.layout.outer);
+    assertThat(mediaView.<TextView>findViewById(R.id.inner_text)).isInstanceOf(TextView.class);
+  }
+
+  @Test
+  public void mergeIncludesShouldNotCreateAncestryLoops() {
+    ViewGroup mediaView = inflate(R.layout.outer);
+    mediaView.hasFocus();
+  }
+
+  @Test
+  public void testViewGroupsLooksAtItsOwnId() {
+    TextView mediaView = inflate(layout.snippet);
+    assertSame(mediaView, mediaView.findViewById(R.id.snippet_text));
+  }
+
+  @Test
+  public void shouldConstructCustomViewsWithAttributesConstructor() {
+    CustomView view = inflate(layout.custom_layout);
+    assertThat(view.attributeResourceValue).isEqualTo(R.string.hello);
+  }
+
+  @Test
+  public void shouldConstructCustomViewsWithCustomState() {
+    CustomStateView view = inflate(layout.custom_layout6);
+    assertThat(view.getDrawableState()).asList().doesNotContain(R.attr.stateFoo);
+
+    view.extraAttribute = R.attr.stateFoo;
+    view.refreshDrawableState();
+
+    assertThat(view.getDrawableState()).asList().contains(R.attr.stateFoo);
+  }
+
+  @Test
+  public void shouldConstructCustomViewsWithAttributesInResAutoNamespace() {
+    CustomView view = inflate(layout.custom_layout5);
+    assertThat(view.attributeResourceValue).isEqualTo(R.string.hello);
+  }
+
+  @Test
+  public void shouldConstructCustomViewsWithAttributesWithURLEncodedNamespaces() {
+    CustomView view = inflate(layout.custom_layout4).findViewById(R.id.custom_view);
+    assertThat(view.namespacedResourceValue).isEqualTo(R.layout.text_views);
+  }
+
+  @Test
+  public void testViewVisibilityIsSet() {
+    View mediaView = inflate(layout.media);
+    assertThat(mediaView.findViewById(R.id.title).getVisibility()).isEqualTo(View.VISIBLE);
+    assertThat(mediaView.findViewById(R.id.subtitle).getVisibility()).isEqualTo(View.GONE);
+  }
+
+  @Test
+  public void testTextViewTextIsSet() {
+    View mediaView = inflate(layout.main);
+    assertThat(((TextView) mediaView.findViewById(R.id.title)).getText().toString())
+        .isEqualTo("Main Layout");
+    assertThat(((TextView) mediaView.findViewById(R.id.subtitle)).getText().toString())
+        .isEqualTo("Hello");
+  }
+
+  @Test
+  public void testTextViewCompoundDrawablesAreSet() {
+    View mediaView = inflate(layout.main);
+    TextView view = mediaView.findViewById(R.id.title);
+
+    Drawable[] drawables = view.getCompoundDrawables();
+    assertThat(shadowOf(drawables[0]).getCreatedFromResId()).isEqualTo(R.drawable.fourth_image);
+    assertThat(shadowOf(drawables[1]).getCreatedFromResId()).isEqualTo(R.drawable.an_image);
+    assertThat(shadowOf(drawables[2]).getCreatedFromResId()).isEqualTo(R.drawable.an_other_image);
+    assertThat(shadowOf(drawables[3]).getCreatedFromResId()).isEqualTo(R.drawable.third_image);
+  }
+
+  @Test
+  public void testCheckBoxCheckedIsSet() {
+    View mediaView = inflate(layout.main);
+    assertThat(((CheckBox) mediaView.findViewById(R.id.true_checkbox)).isChecked()).isTrue();
+    assertThat(((CheckBox) mediaView.findViewById(R.id.false_checkbox)).isChecked()).isFalse();
+    assertThat(((CheckBox) mediaView.findViewById(R.id.default_checkbox)).isChecked()).isFalse();
+  }
+
+  @Test
+  public void testImageViewSrcIsSet() {
+    View mediaView = inflate(layout.main);
+    ImageView imageView = mediaView.findViewById(R.id.image);
+    BitmapDrawable drawable = (BitmapDrawable) imageView.getDrawable();
+    assertThat(shadowOf(drawable.getBitmap()).getCreatedFromResId()).isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void testImageViewSrcIsSetFromMipmap() {
+    View mediaView = inflate(layout.main);
+    ImageView imageView = mediaView.findViewById(R.id.mipmapImage);
+    BitmapDrawable drawable = (BitmapDrawable) imageView.getDrawable();
+    assertThat(shadowOf(drawable.getBitmap()).getCreatedFromResId())
+        .isEqualTo(R.mipmap.robolectric);
+  }
+
+  @Test
+  public void shouldInflateMergeLayoutIntoParent() {
+    LinearLayout linearLayout = new LinearLayout(context);
+    LayoutInflater.from(context).inflate(R.layout.inner_merge, linearLayout);
+    assertThat(linearLayout.getChildAt(0)).isInstanceOf(TextView.class);
+  }
+
+  @Test
+  public void testMultiOrientation() {
+    Activity activity = buildActivity(Activity.class).create().start().resume().get();
+
+    // Default screen orientation should be portrait.
+    ViewGroup view =
+        (ViewGroup) LayoutInflater.from(activity).inflate(layout.multi_orientation, null);
+    assertThat(view).isInstanceOf(LinearLayout.class);
+    assertThat(view.getId()).isEqualTo(R.id.portrait);
+    assertSame(activity, view.getContext());
+
+    // Confirm explicit "orientation = portrait" works.
+    activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+    int layoutResId = R.layout.multi_orientation;
+    view = (ViewGroup) LayoutInflater.from(activity).inflate(layoutResId, null);
+    assertThat(view).isInstanceOf(LinearLayout.class);
+    assertThat(view.getId()).isEqualTo(R.id.portrait);
+    assertSame(activity, view.getContext());
+  }
+
+  @Test
+  @Config(qualifiers = "land")
+  public void testMultiOrientation_explicitLandscape() {
+    Activity activity = buildActivity(Activity.class).create().start().resume().get();
+
+    // Confirm explicit "orientation = landscape" works.
+    activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
+    ViewGroup view =
+        (ViewGroup) LayoutInflater.from(activity).inflate(layout.multi_orientation, null);
+    assertThat(view.getId()).isEqualTo(R.id.landscape);
+    assertThat(view).isInstanceOf(LinearLayout.class);
+  }
+
+  @Test
+  @Config(qualifiers = "w0dp")
+  public void testSetContentViewByItemResource() {
+    Activity activity = buildActivity(Activity.class).create().get();
+    activity.setContentView(R.layout.main_layout);
+
+    TextView tv1 = activity.findViewById(R.id.hello);
+    TextView tv2 = activity.findViewById(R.id.world);
+    assertNotNull(tv1);
+    assertNull(tv2);
+  }
+
+  @Test
+  @Config(qualifiers = "w820dp")
+  public void testSetContentViewByItemResourceWithW820dp() {
+    Activity activity = buildActivity(Activity.class).create().get();
+    activity.setContentView(R.layout.main_layout);
+
+    TextView tv1 = activity.findViewById(R.id.hello);
+    TextView tv2 = activity.findViewById(R.id.world);
+    assertNotNull(tv1);
+    assertNotNull(tv2);
+  }
+
+  @Test
+  public void testViewEnabled() {
+    View mediaView = inflate(layout.main);
+    assertThat(mediaView.findViewById(R.id.time).isEnabled()).isFalse();
+  }
+
+  @Test
+  public void testContentDescriptionIsSet() {
+    View mediaView = inflate(layout.main);
+    assertThat(mediaView.findViewById(R.id.time).getContentDescription().toString())
+        .isEqualTo("Howdy");
+  }
+
+  @Test
+  public void testAlphaIsSet() {
+    View mediaView = inflate(layout.main);
+    assertThat(mediaView.findViewById(R.id.time).getAlpha()).isEqualTo(.3f);
+  }
+
+  @Test
+  public void testViewBackgroundIdIsSet() {
+    View mediaView = inflate(layout.main);
+    ImageView imageView = mediaView.findViewById(R.id.image);
+
+    assertThat(shadowOf(imageView.getBackground()).getCreatedFromResId())
+        .isEqualTo(R.drawable.image_background);
+  }
+
+  @Test
+  public void testOnClickAttribute() {
+    ClickActivity activity = buildActivity(ClickActivity.class).create().get();
+
+    assertThat(activity.clicked).isFalse();
+
+    Button button = activity.findViewById(R.id.button);
+    button.performClick();
+
+    assertThat(activity.clicked).isTrue();
+  }
+
+  @Test
+  public void testInvalidOnClickAttribute() {
+    Activity activity = buildActivity(Activity.class).create().get();
+    activity.setContentView(R.layout.with_invalid_onclick);
+
+    Button button = activity.findViewById(R.id.invalid_onclick_button);
+
+    IllegalStateException exception = null;
+    try {
+      button.performClick();
+    } catch (IllegalStateException e) {
+      exception = e;
+    }
+    assertNotNull(exception);
+    assertWithMessage("The error message should contain the id name of the faulty button")
+        .that(exception.getMessage())
+        .contains("invalid_onclick_button");
+  }
+
+  @Test
+  public void shouldInvokeOnFinishInflate() {
+    int layoutResId = R.layout.custom_layout2;
+    CustomView2 outerCustomView = inflate(layoutResId);
+    CustomView2 innerCustomView = (CustomView2) outerCustomView.getChildAt(0);
+    assertThat(outerCustomView.childCountAfterInflate).isEqualTo(1);
+    assertThat(innerCustomView.childCountAfterInflate).isEqualTo(3);
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static class CustomView3 extends TextView {
+    public CustomView3(Context context) {
+      super(context);
+    }
+
+    public CustomView3(Context context, AttributeSet attrs) {
+      super(context, attrs);
+    }
+
+    public CustomView3(Context context, AttributeSet attrs, int defStyle) {
+      super(context, attrs, defStyle);
+    }
+  }
+
+  @Test
+  public void shouldInflateViewsWithClassAttr() {
+    CustomView3 outerCustomView = inflate(layout.custom_layout3);
+    assertThat(outerCustomView.getText().toString()).isEqualTo("Hello bonjour");
+  }
+
+  @Test
+  public void testIncludesLinearLayoutsOnlyOnce() {
+    ViewGroup parentView = inflate(R.layout.included_layout_parent);
+    assertEquals(1, parentView.getChildCount());
+  }
+
+  @Test
+  public void testConverterAcceptsEnumOrdinal() {
+    ViewGroup view = inflate(R.layout.ordinal_scrollbar);
+    assertThat(view).isInstanceOf(RelativeLayout.class);
+    ListView listView = view.findViewById(R.id.list_view_with_enum_scrollbar);
+    assertThat(listView).isInstanceOf(ListView.class);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.R)
+  public void layoutInflater_fromWindowContext() {
+    Context windowContext =
+        context
+            .createDisplayContext(
+                context.getSystemService(DisplayManager.class).getDisplay(DEFAULT_DISPLAY))
+            .createWindowContext(LayoutParams.TYPE_APPLICATION_OVERLAY, /* options= */ null);
+
+    ViewGroup viewGroup =
+        (ViewGroup) LayoutInflater.from(windowContext).inflate(layout.progress_bar, null);
+    ProgressBar progressBar = viewGroup.findViewById(R.id.progress_bar);
+    assertThat(progressBar).isInstanceOf(ProgressBar.class);
+  }
+
+  /////////////////////////
+
+  @SuppressWarnings("TypeParameterUnusedInFormals")
+  private <T extends View> T inflate(int layoutResId) {
+    return (T) LayoutInflater.from(context).inflate(layoutResId, null);
+  }
+
+  public static class ClickActivity extends Activity {
+    public boolean clicked = false;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      setContentView(R.layout.main);
+    }
+
+    public void onButtonClick(View v) {
+      clicked = true;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutParamsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutParamsTest.java
new file mode 100644
index 0000000..1d1ff1e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLayoutParamsTest.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.Gallery;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLayoutParamsTest {
+  @Test
+  public void testConstructor() {
+    Gallery.LayoutParams layoutParams = new Gallery.LayoutParams(123, 456);
+    assertThat(layoutParams.width).isEqualTo(123);
+    assertThat(layoutParams.height).isEqualTo(456);
+  }
+
+  @Test
+  public void constructor_canTakeSourceLayoutParams() {
+    ViewGroup.LayoutParams sourceLayoutParams = new ViewGroup.LayoutParams(123, 456);
+    ViewGroup.LayoutParams layoutParams1 = new ViewGroup.LayoutParams(sourceLayoutParams);
+    FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(sourceLayoutParams);
+    assertThat(layoutParams1.height).isEqualTo(456);
+    assertThat(layoutParams1.width).isEqualTo(123);
+    assertThat(layoutParams2.height).isEqualTo(456);
+    assertThat(layoutParams2.width).isEqualTo(123);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyAsyncTaskLoaderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyAsyncTaskLoaderTest.java
new file mode 100644
index 0000000..5fffcb8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyAsyncTaskLoaderTest.java
@@ -0,0 +1,92 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.content.AsyncTaskLoader;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.LooperMode;
+
+/**
+ * Unit tests for {@link ShadowLegacyAsyncTaskLoader}.
+ */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LEGACY)
+public class ShadowLegacyAsyncTaskLoaderTest {
+  private final List<String> transcript = new ArrayList<>();
+
+  @Before
+  public void setUp() {
+    Robolectric.getForegroundThreadScheduler().pause();
+    Robolectric.getBackgroundThreadScheduler().pause();
+  }
+
+  @Test
+  public void forceLoad_shouldEnqueueWorkOnSchedulers() {
+    new TestLoader(42).forceLoad();
+    assertThat(transcript).isEmpty();
+
+    Robolectric.flushBackgroundThreadScheduler();
+    assertThat(transcript).containsExactly("loadInBackground");
+    transcript.clear();
+
+    Robolectric.flushForegroundThreadScheduler();
+    assertThat(transcript).containsExactly("deliverResult 42");
+  }
+
+  @Test
+  public void forceLoad_multipleLoads() {
+    TestLoader testLoader = new TestLoader(42);
+    testLoader.forceLoad();
+    assertThat(transcript).isEmpty();
+
+    Robolectric.flushBackgroundThreadScheduler();
+    assertThat(transcript).containsExactly("loadInBackground");
+    transcript.clear();
+
+    Robolectric.flushForegroundThreadScheduler();
+    assertThat(transcript).containsExactly("deliverResult 42");
+
+    testLoader.setData(43);
+    transcript.clear();
+    testLoader.forceLoad();
+
+    Robolectric.flushBackgroundThreadScheduler();
+    assertThat(transcript).containsExactly("loadInBackground");
+    transcript.clear();
+
+    Robolectric.flushForegroundThreadScheduler();
+    assertThat(transcript).containsExactly("deliverResult 43");
+  }
+
+  class TestLoader extends AsyncTaskLoader<Integer> {
+    private Integer data;
+
+    public TestLoader(Integer data) {
+      super(ApplicationProvider.getApplicationContext());
+      this.data = data;
+    }
+
+    @Override
+    public Integer loadInBackground() {
+      transcript.add("loadInBackground");
+      return data;
+    }
+
+    @Override
+    public void deliverResult(Integer data) {
+      transcript.add("deliverResult " + data.toString());
+    }
+
+    public void setData(int newData) {
+      this.data = newData;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyAsyncTaskTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyAsyncTaskTest.java
new file mode 100644
index 0000000..852ccb8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyAsyncTaskTest.java
@@ -0,0 +1,224 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.os.AsyncTask;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.util.Join;
+
+/**
+ * Unit tests for {@link ShadowLegacyAsyncTask}.
+ */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LEGACY)
+public class ShadowLegacyAsyncTaskTest {
+  private List<String> transcript;
+
+  @Before
+  public void setUp() throws Exception {
+    transcript = new ArrayList<>();
+    Robolectric.getBackgroundThreadScheduler().pause();
+    Robolectric.getForegroundThreadScheduler().pause();
+  }
+
+  @Test
+  public void testNormalFlow() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new MyAsyncTask();
+
+    asyncTask.execute("a", "b");
+    assertThat(transcript).containsExactly("onPreExecute");
+    transcript.clear();
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).containsExactly("doInBackground a, b");
+    transcript.clear();
+    assertEquals(
+        "Result should get stored in the AsyncTask",
+        "c",
+        asyncTask.get(100, TimeUnit.MILLISECONDS));
+
+    ShadowLooper.runUiThreadTasks();
+    assertThat(transcript).containsExactly("onPostExecute c");
+  }
+
+  @Test
+  public void testCancelBeforeBackground() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new MyAsyncTask();
+
+    asyncTask.execute("a", "b");
+    assertThat(transcript).containsExactly("onPreExecute");
+    transcript.clear();
+
+    assertTrue(asyncTask.cancel(true));
+    assertTrue(asyncTask.isCancelled());
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).isEmpty();
+
+    ShadowLooper.runUiThreadTasks();
+    assertThat(transcript).containsExactly("onCancelled null", "onCancelled");
+  }
+
+  @Test
+  public void testCancelBeforePostExecute() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new MyAsyncTask();
+
+    asyncTask.execute("a", "b");
+    assertThat(transcript).containsExactly("onPreExecute");
+    transcript.clear();
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).containsExactly("doInBackground a, b");
+    transcript.clear();
+    assertEquals(
+        "Result should get stored in the AsyncTask",
+        "c",
+        asyncTask.get(100, TimeUnit.MILLISECONDS));
+
+    assertFalse(asyncTask.cancel(true));
+    assertFalse(asyncTask.isCancelled());
+
+    ShadowLooper.runUiThreadTasks();
+    assertThat(transcript).containsExactly("onPostExecute c");
+  }
+
+  @Test
+  public void progressUpdatesAreQueuedUntilBackgroundThreadFinishes() throws Exception {
+    AsyncTask<String, String, String> asyncTask =
+        new MyAsyncTask() {
+          @Override
+          protected String doInBackground(String... strings) {
+            publishProgress("33%");
+            publishProgress("66%");
+            publishProgress("99%");
+            return "done";
+          }
+        };
+
+    asyncTask.execute("a", "b");
+    assertThat(transcript).containsExactly("onPreExecute");
+    transcript.clear();
+
+    ShadowApplication.runBackgroundTasks();
+    assertThat(transcript).isEmpty();
+    assertEquals(
+        "Result should get stored in the AsyncTask",
+        "done",
+        asyncTask.get(100, TimeUnit.MILLISECONDS));
+
+    ShadowLooper.runUiThreadTasks();
+    assertThat(transcript)
+        .containsExactly(
+            "onProgressUpdate 33%",
+            "onProgressUpdate 66%", "onProgressUpdate 99%", "onPostExecute done");
+  }
+
+  @Test
+  public void executeReturnsAsyncTask() throws Exception {
+    Robolectric.getBackgroundThreadScheduler().unPause();
+    AsyncTask<String, String, String> asyncTask = new MyAsyncTask();
+    assertThat(asyncTask.execute("a", "b").get()).isEqualTo("c");
+  }
+
+  @Test
+  public void shouldGetStatusForAsyncTask() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new MyAsyncTask();
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.PENDING);
+    asyncTask.execute("a");
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.RUNNING);
+    Robolectric.getBackgroundThreadScheduler().unPause();
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.FINISHED);
+  }
+
+  @Test
+  public void onPostExecute_doesNotSwallowExceptions() {
+    Robolectric.getBackgroundThreadScheduler().unPause();
+    Robolectric.getForegroundThreadScheduler().unPause();
+
+    AsyncTask<Void, Void, Void> asyncTask =
+        new AsyncTask<Void, Void, Void>() {
+          @Override
+          protected Void doInBackground(Void... params) {
+            return null;
+          }
+
+          @Override
+          protected void onPostExecute(Void aVoid) {
+            throw new RuntimeException("Don't swallow me!");
+          }
+        };
+
+    try {
+      asyncTask.execute();
+      fail("Task swallowed onPostExecute() exception!");
+    } catch (RuntimeException e) {
+      assertThat(e.getCause().getMessage()).isEqualTo("Don't swallow me!");
+    }
+  }
+
+  @Test
+  public void executeOnExecutor_usesPassedExecutor() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new MyAsyncTask();
+
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.PENDING);
+
+    asyncTask.executeOnExecutor(MoreExecutors.directExecutor(), "a", "b");
+
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.FINISHED);
+    assertThat(transcript).containsExactly("onPreExecute", "doInBackground a, b");
+    transcript.clear();
+    assertEquals("Result should get stored in the AsyncTask", "c", asyncTask.get());
+
+    ShadowLooper.runUiThreadTasks();
+    assertThat(transcript).containsExactly("onPostExecute c");
+  }
+
+  private class MyAsyncTask extends AsyncTask<String, String, String> {
+    @Override
+    protected void onPreExecute() {
+      transcript.add("onPreExecute");
+    }
+
+    @Override
+    protected String doInBackground(String... strings) {
+      transcript.add("doInBackground " + Join.join(", ", (Object[]) strings));
+      return "c";
+    }
+
+    @Override
+    protected void onProgressUpdate(String... values) {
+      transcript.add("onProgressUpdate " + Join.join(", ", (Object[]) values));
+    }
+
+    @Override
+    protected void onPostExecute(String s) {
+      transcript.add("onPostExecute " + s);
+    }
+
+    @Override
+    protected void onCancelled(String result) {
+      transcript.add("onCancelled " + result);
+      // super should call onCancelled() without arguments
+      super.onCancelled(result);
+    }
+
+    @Override
+    protected void onCancelled() {
+      transcript.add("onCancelled");
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyChoreographerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyChoreographerTest.java
new file mode 100644
index 0000000..4dfd3fd
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyChoreographerTest.java
@@ -0,0 +1,54 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import android.view.Choreographer;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.util.TimeUtils;
+
+/**
+ * Unit tests for {@link ShadowLegacyChoreographer}.
+ */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(Mode.LEGACY)
+public class ShadowLegacyChoreographerTest {
+
+  @Test
+  public void setFrameInterval_shouldUpdateFrameInterval() {
+    final long frameInterval = 10 * TimeUtils.NANOS_PER_MS;
+    ShadowLegacyChoreographer.setFrameInterval(frameInterval);
+
+    final Choreographer instance = ShadowLegacyChoreographer.getInstance();
+    long time1 = instance.getFrameTimeNanos();
+    long time2 = instance.getFrameTimeNanos();
+
+    assertThat(time2 - time1).isEqualTo(frameInterval);
+  }
+
+  @Test
+  public void removeFrameCallback_shouldRemoveCallback() {
+    Choreographer instance = ShadowLegacyChoreographer.getInstance();
+    Choreographer.FrameCallback callback = mock(Choreographer.FrameCallback.class);
+    instance.postFrameCallbackDelayed(callback, 1000);
+    instance.removeFrameCallback(callback);
+    ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable();
+    verify(callback, never()).doFrame(anyLong());
+  }
+
+  @Test
+  public void reset_shouldResetFrameInterval() {
+    ShadowLegacyChoreographer.setFrameInterval(1);
+    assertThat(ShadowLegacyChoreographer.getFrameInterval()).isEqualTo(1);
+
+    ShadowLegacyChoreographer.reset();
+    assertThat(ShadowLegacyChoreographer.getFrameInterval()).isEqualTo(10 * TimeUtils.NANOS_PER_MS);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyLooperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyLooperTest.java
new file mode 100644
index 0000000..eda3b13
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyLooperTest.java
@@ -0,0 +1,557 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Application;
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.SystemClock;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.robolectric.RoboSettings;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Scheduler;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LEGACY)
+public class ShadowLegacyLooperTest {
+
+  // testName is used when creating background threads. Makes it
+  // easier to debug exceptions on background threads when you
+  // know what test they are associated with.
+  @Rule public TestName testName = new TestName();
+
+  // Helper method that starts the thread with the same name as the
+  // current test, so that you will know which test invoked it if
+  // it has an exception.
+  private HandlerThread getHandlerThread() {
+    HandlerThread ht = new HandlerThread(testName.getMethodName());
+    ht.start();
+    return ht;
+  }
+
+  // Useful class for checking that a thread's loop() has exited.
+  private class QuitThread extends Thread {
+    private boolean hasContinued = false;
+    private Looper looper;
+    private CountDownLatch started = new CountDownLatch(1);
+
+    public QuitThread() {
+      super(testName.getMethodName());
+    }
+
+    @Override
+    public void run() {
+      Looper.prepare();
+      looper = Looper.myLooper();
+      started.countDown();
+      Looper.loop();
+      hasContinued = true;
+    }
+  }
+
+  private QuitThread getQuitThread() throws InterruptedException {
+    QuitThread qt = new QuitThread();
+    qt.start();
+    qt.started.await();
+    return qt;
+  }
+
+  @Test
+  public void mainLooper_andMyLooper_shouldBeSame_onMainThread() {
+    assertThat(Looper.myLooper()).isSameInstanceAs(Looper.getMainLooper());
+  }
+
+  @Test
+  public void differentThreads_getDifferentLoopers() {
+    HandlerThread ht = getHandlerThread();
+    assertThat(ht.getLooper()).isNotSameInstanceAs(Looper.getMainLooper());
+  }
+
+  @Test
+  public void mainLooperThread_shouldBeTestThread() {
+    assertThat(Looper.getMainLooper().getThread()).isSameInstanceAs(Thread.currentThread());
+  }
+
+  @Test
+  public void shadowMainLooper_shouldBeShadowOfMainLooper() {
+    assertThat(ShadowLooper.getShadowMainLooper())
+        .isSameInstanceAs(shadowOf(Looper.getMainLooper()));
+  }
+
+  @Test
+  public void getLooperForThread_returnsLooperForAThreadThatHasOne() throws InterruptedException {
+    QuitThread qt = getQuitThread();
+    assertThat(ShadowLooper.getLooperForThread(qt)).isSameInstanceAs(qt.looper);
+  }
+
+  @Test
+  public void getLooperForThread_returnsLooperForMainThread() {
+    assertThat(ShadowLooper.getLooperForThread(Thread.currentThread()))
+        .isSameInstanceAs(Looper.getMainLooper());
+  }
+
+  @Test
+  public void getAllLoopers_shouldContainMainAndHandlerThread() throws InterruptedException {
+    Looper looper = getQuitThread().looper;
+
+    assertThat(ShadowLooper.getAllLoopers()).contains(Looper.getMainLooper());
+    assertThat(ShadowLooper.getAllLoopers()).contains(looper);
+  }
+
+  @Test
+  public void idleMainLooper_executesScheduledTasks() {
+    final boolean[] wasRun = new boolean[] {false};
+    new Handler()
+        .postDelayed(
+            new Runnable() {
+              @Override
+              public void run() {
+                wasRun[0] = true;
+              }
+            },
+            2000);
+
+    assertWithMessage("first").that(wasRun[0]).isFalse();
+    ShadowLooper.idleMainLooper(1999, TimeUnit.MILLISECONDS);
+    assertWithMessage("second").that(wasRun[0]).isFalse();
+    ShadowLooper.idleMainLooper(1, TimeUnit.MILLISECONDS);
+    assertWithMessage("last").that(wasRun[0]).isTrue();
+  }
+
+  @Test
+  public void idleConstantly_runsPostDelayedTasksImmediately() {
+    ShadowLooper.idleMainLooperConstantly(true);
+    final boolean[] wasRun = new boolean[] {false};
+    new Handler()
+        .postDelayed(
+            new Runnable() {
+              @Override
+              public void run() {
+                wasRun[0] = true;
+              }
+            },
+            2000);
+
+    assertThat(wasRun[0]).isTrue();
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void shouldThrowRuntimeExceptionIfTryingToQuitMainLooper() {
+    Looper.getMainLooper().quit();
+  }
+
+  @Test
+  public void shouldNotQueueMessagesIfLooperIsQuit() {
+    HandlerThread ht = getHandlerThread();
+    Looper looper = ht.getLooper();
+    looper.quit();
+    assertWithMessage("hasQuit").that(shadowOf(looper).hasQuit()).isTrue();
+    assertWithMessage("post")
+        .that(
+            shadowOf(looper)
+                .post(
+                    new Runnable() {
+                      @Override
+                      public void run() {}
+                    },
+                    0))
+        .isFalse();
+
+    assertWithMessage("postAtFrontOfQueue")
+        .that(
+            shadowOf(looper)
+                .postAtFrontOfQueue(
+                    new Runnable() {
+                      @Override
+                      public void run() {}
+                    }))
+        .isFalse();
+    assertWithMessage("areAnyRunnable")
+        .that(shadowOf(looper).getScheduler().areAnyRunnable())
+        .isFalse();
+  }
+
+  @Test
+  public void shouldThrowawayRunnableQueueIfLooperQuits() {
+    HandlerThread ht = getHandlerThread();
+    Looper looper = ht.getLooper();
+    shadowOf(looper).pause();
+    shadowOf(looper)
+        .post(
+            new Runnable() {
+              @Override
+              public void run() {}
+            },
+            0);
+    looper.quit();
+    assertWithMessage("hasQuit").that(shadowOf(looper).hasQuit()).isTrue();
+    assertWithMessage("areAnyRunnable")
+        .that(shadowOf(looper).getScheduler().areAnyRunnable())
+        .isFalse();
+    assertWithMessage("queue").that(shadowOf(looper.getQueue()).getHead()).isNull();
+  }
+
+  @Test
+  public void threadShouldContinue_whenLooperQuits() throws InterruptedException {
+    QuitThread test = getQuitThread();
+    assertWithMessage("beforeJoin").that(test.hasContinued).isFalse();
+    test.looper.quit();
+    test.join(5000);
+    assertWithMessage("afterJoin").that(test.hasContinued).isTrue();
+  }
+
+  @Test
+  public void shouldResetQueue_whenLooperIsReset() {
+    HandlerThread ht = getHandlerThread();
+    Looper looper = ht.getLooper();
+    Handler h = new Handler(looper);
+    ShadowLooper sLooper = shadowOf(looper);
+    sLooper.pause();
+    h.post(
+        new Runnable() {
+          @Override
+          public void run() {}
+        });
+    assertWithMessage("queue").that(shadowOf(looper.getQueue()).getHead()).isNotNull();
+    sLooper.reset();
+    assertWithMessage("areAnyRunnable").that(sLooper.getScheduler().areAnyRunnable()).isFalse();
+    assertWithMessage("queue").that(shadowOf(looper.getQueue()).getHead()).isNull();
+  }
+
+  @Test
+  public void shouldSetNewScheduler_whenLooperIsReset() {
+    HandlerThread ht = getHandlerThread();
+    Looper looper = ht.getLooper();
+    ShadowLooper sLooper = shadowOf(looper);
+    Scheduler old = sLooper.getScheduler();
+    sLooper.reset();
+    assertThat(old).isNotSameInstanceAs(sLooper.getScheduler());
+  }
+
+  @Test
+  public void resetThreadLoopers_shouldQuitAllNonMainLoopers() throws InterruptedException {
+    QuitThread test = getQuitThread();
+    assertWithMessage("hasContinued:before").that(test.hasContinued).isFalse();
+    ShadowLooper.resetThreadLoopers();
+    test.join(5000);
+    assertWithMessage("hasContinued:after").that(test.hasContinued).isTrue();
+  }
+
+  @Test(timeout = 1000)
+  public void whenTestHarnessUsesDifferentThread_shouldStillHaveMainLooper() {
+    assertThat(Looper.myLooper()).isSameInstanceAs(Looper.getMainLooper());
+  }
+
+  @Test
+  public void resetThreadLoopers_fromNonMainThread_shouldThrowISE() throws InterruptedException {
+    final AtomicReference<Throwable> ex = new AtomicReference<>();
+    Thread t =
+        new Thread() {
+          @Override
+          public void run() {
+            try {
+              ShadowLooper.resetThreadLoopers();
+            } catch (Throwable t) {
+              ex.set(t);
+            }
+          }
+        };
+    t.start();
+    t.join();
+    assertThat(ex.get()).isInstanceOf(IllegalStateException.class);
+  }
+
+  @Test
+  public void
+      soStaticRefsToLoopersInAppWorksAcrossTests_shouldRetainSameLooperForMainThreadBetweenResetsButGiveItAFreshScheduler() {
+    Looper mainLooper = Looper.getMainLooper();
+    Scheduler scheduler = shadowOf(mainLooper).getScheduler();
+    ShadowLegacyLooper shadowLooper = Shadow.extract(mainLooper);
+    shadowLooper.quit = true;
+
+    assertThat(ApplicationProvider.getApplicationContext().getMainLooper())
+        .isSameInstanceAs(mainLooper);
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    ShadowLooper.resetThreadLoopers();
+    Application application = new Application();
+    ReflectionHelpers.callInstanceMethod(
+        application,
+        "attach",
+        ReflectionHelpers.ClassParameter.from(
+            Context.class,
+            ((Application) ApplicationProvider.getApplicationContext()).getBaseContext()));
+
+    assertWithMessage("Looper.getMainLooper()")
+        .that(Looper.getMainLooper())
+        .isSameInstanceAs(mainLooper);
+    assertWithMessage("app.getMainLooper()")
+        .that(application.getMainLooper())
+        .isSameInstanceAs(mainLooper);
+    assertWithMessage("scheduler")
+        .that(shadowOf(mainLooper).getScheduler())
+        .isNotSameInstanceAs(scheduler);
+    assertWithMessage("scheduler").that(shadowOf(mainLooper).getScheduler()).isSameInstanceAs(s);
+    assertWithMessage("quit").that(shadowOf(mainLooper).hasQuit()).isFalse();
+  }
+
+  @Test
+  public void getMainLooperReturnsNonNullOnMainThreadWhenRobolectricApplicationIsNull() {
+    RuntimeEnvironment.application = null;
+    assertThat(Looper.getMainLooper()).isNotNull();
+  }
+
+  private void setAdvancedScheduling() {
+    RoboSettings.setUseGlobalScheduler(true);
+  }
+
+  @Test
+  public void reset_setsGlobalScheduler_forMainLooper_byDefault() {
+    ShadowLooper sMainLooper = ShadowLooper.getShadowMainLooper();
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    sMainLooper.reset();
+    assertThat(sMainLooper.getScheduler()).isSameInstanceAs(s);
+  }
+
+  @Test
+  public void reset_setsGlobalScheduler_forMainLooper_withAdvancedScheduling() {
+    setAdvancedScheduling();
+    ShadowLooper sMainLooper = ShadowLooper.getShadowMainLooper();
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    sMainLooper.reset();
+    assertThat(sMainLooper.getScheduler()).isSameInstanceAs(s);
+  }
+
+  @Test
+  public void reset_setsNewScheduler_forNonMainLooper_byDefault() {
+    HandlerThread ht = getHandlerThread();
+    ShadowLooper sLooper = shadowOf(ht.getLooper());
+    Scheduler old = sLooper.getScheduler();
+    sLooper.reset();
+    assertThat(sLooper.getScheduler()).isNotSameInstanceAs(old);
+    assertThat(sLooper.getScheduler()).isNotSameInstanceAs(RuntimeEnvironment.getMasterScheduler());
+  }
+
+  @Test
+  public void reset_setsSchedulerToMaster_forNonMainLooper_withAdvancedScheduling() {
+    HandlerThread ht = getHandlerThread();
+    ShadowLooper sLooper = shadowOf(ht.getLooper());
+    Scheduler s = new Scheduler();
+    RuntimeEnvironment.setMasterScheduler(s);
+    setAdvancedScheduling();
+    sLooper.reset();
+    assertThat(sLooper.getScheduler()).isSameInstanceAs(s);
+  }
+
+  @Test
+  public void resetThreadLoopers_resets_background_thread_schedulers() {
+    HandlerThread backgroundThread = new HandlerThread("resetTest");
+    backgroundThread.start();
+    Looper backgroundLooper = backgroundThread.getLooper();
+    Handler handler = new Handler(backgroundLooper);
+    Runnable empty =
+        new Runnable() {
+          @Override
+          public void run() {}
+        };
+    // There should be at least two iterations of this loop because resetThreadLoopers calls
+    // 'quit' on background loopers once, which also resets the scheduler.
+    for (int i = 0; i < 5; i++) {
+      assertThat(shadowOf(backgroundLooper).getScheduler().size()).isEqualTo(0);
+      assertThat(shadowOf(backgroundLooper).getScheduler().getCurrentTime()).isEqualTo(100L);
+      handler.post(empty);
+      handler.postDelayed(empty, 5000);
+      // increment scheduler's time by 5000
+      shadowOf(backgroundLooper).runToEndOfTasks();
+      assertThat(shadowOf(backgroundLooper).getScheduler().getCurrentTime()).isEqualTo(5100L);
+      ShadowLooper.resetThreadLoopers();
+    }
+  }
+
+  @Test
+  public void myLooper_returnsMainLooper_ifMainThreadIsSwitched() throws InterruptedException {
+    final AtomicReference<Looper> myLooper = new AtomicReference<>();
+    Thread t =
+        new Thread(testName.getMethodName()) {
+          @Override
+          public void run() {
+            myLooper.set(Looper.myLooper());
+          }
+        };
+    RuntimeEnvironment.setMainThread(t);
+    t.start();
+    try {
+      t.join(1000);
+      assertThat(myLooper.get()).isSameInstanceAs(Looper.getMainLooper());
+    } finally {
+      RuntimeEnvironment.setMainThread(Thread.currentThread());
+    }
+  }
+
+  @Test
+  public void
+      getMainLooper_shouldBeInitialized_onBackgroundThread_evenWhenRobolectricApplicationIsNull()
+          throws Exception {
+    RuntimeEnvironment.application = null;
+    final AtomicReference<Looper> mainLooperAtomicReference = new AtomicReference<>();
+
+    Thread backgroundThread =
+        new Thread(
+            new Runnable() {
+              @Override
+              public void run() {
+                Looper mainLooper = Looper.getMainLooper();
+                mainLooperAtomicReference.set(mainLooper);
+              }
+            },
+            testName.getMethodName());
+    backgroundThread.start();
+    backgroundThread.join();
+
+    assertWithMessage("mainLooper")
+        .that(mainLooperAtomicReference.get())
+        .isSameInstanceAs(Looper.getMainLooper());
+  }
+
+  @Test
+  public void schedulerOnAnotherLooper_shouldNotBeMaster_byDefault() {
+    HandlerThread ht = getHandlerThread();
+    assertThat(shadowOf(ht.getLooper()).getScheduler())
+        .isNotSameInstanceAs(RuntimeEnvironment.getMasterScheduler());
+  }
+
+  @Test
+  public void schedulerOnAnotherLooper_shouldBeMaster_ifAdvancedSchedulingEnabled() {
+    setAdvancedScheduling();
+    HandlerThread ht = getHandlerThread();
+    assertThat(shadowOf(ht.getLooper()).getScheduler())
+        .isSameInstanceAs(RuntimeEnvironment.getMasterScheduler());
+  }
+
+  @Test
+  public void
+      withAdvancedScheduling_shouldDispatchMessagesOnBothLoopers_whenAdvancingForegroundThread() {
+    setAdvancedScheduling();
+    ShadowLooper.pauseMainLooper();
+    HandlerThread ht = getHandlerThread();
+    Handler handler1 = new Handler(ht.getLooper());
+    Handler handler2 = new Handler();
+    final ArrayList<String> events = new ArrayList<>();
+    handler1.postDelayed(
+        new Runnable() {
+          @Override
+          public void run() {
+            events.add("handler1");
+          }
+        },
+        100);
+    handler2.postDelayed(
+        new Runnable() {
+          @Override
+          public void run() {
+            events.add("handler2");
+          }
+        },
+        200);
+    assertWithMessage("start").that(events).isEmpty();
+    Scheduler s = ShadowLooper.getShadowMainLooper().getScheduler();
+    assertThat(s).isSameInstanceAs(RuntimeEnvironment.getMasterScheduler());
+    assertThat(s).isSameInstanceAs(shadowOf(ht.getLooper()).getScheduler());
+    final long startTime = s.getCurrentTime();
+    s.runOneTask();
+    assertWithMessage("firstEvent").that(events).containsExactly("handler1");
+    assertWithMessage("firstEvent:time").that(s.getCurrentTime()).isEqualTo(100 + startTime);
+    s.runOneTask();
+    assertWithMessage("secondEvent").that(events).containsExactly("handler1", "handler2");
+    assertWithMessage("secondEvent:time").that(s.getCurrentTime()).isEqualTo(200 + startTime);
+  }
+
+  @Test
+  public void resetThreadLoopers_clears_messages() {
+    HandlerThread backgroundThread = new HandlerThread("resetTest");
+    backgroundThread.start();
+    Looper backgroundLooper = backgroundThread.getLooper();
+    Handler handler = new Handler(backgroundLooper);
+    for (int i = 0; i < 5; i++) {
+      handler.sendEmptyMessageDelayed(1, 100);
+      ShadowLegacyLooper.resetThreadLoopers();
+      assertThat(handler.hasMessages(1)).isFalse();
+    }
+  }
+
+  @Test
+  public void isIdle() {
+    ShadowLooper.pauseMainLooper();
+    assertThat(shadowMainLooper().isIdle()).isTrue();
+    Handler mainHandler = new Handler();
+    mainHandler.post(() -> {});
+    assertThat(shadowMainLooper().isIdle()).isFalse();
+    shadowMainLooper().idle();
+    assertThat(shadowMainLooper().isIdle()).isTrue();
+  }
+
+  @Test
+  public void getNextScheduledTime() {
+    ShadowLooper.pauseMainLooper();
+    assertThat(shadowMainLooper().getNextScheduledTaskTime()).isEqualTo(Duration.ZERO);
+    Handler mainHandler = new Handler();
+    mainHandler.postDelayed(() -> {}, 100);
+    assertThat(shadowMainLooper().getNextScheduledTaskTime().toMillis())
+        .isEqualTo(SystemClock.uptimeMillis() + 100);
+  }
+
+  @Test
+  public void getLastScheduledTime() {
+    ShadowLooper.pauseMainLooper();
+    assertThat(shadowMainLooper().getLastScheduledTaskTime()).isEqualTo(Duration.ZERO);
+    Handler mainHandler = new Handler();
+    mainHandler.postDelayed(() -> {}, 200);
+    mainHandler.postDelayed(() -> {}, 100);
+    assertThat(shadowMainLooper().getLastScheduledTaskTime().toMillis())
+        .isEqualTo(SystemClock.uptimeMillis() + 200);
+  }
+
+  @Test
+  public void backgroundSchedulerInBackgroundThread_isDeferred() throws Exception {
+    ExecutorService executorService = Executors.newSingleThreadExecutor();
+    AtomicBoolean ran = new AtomicBoolean(false);
+    executorService
+        .submit(() -> ShadowLegacyLooper.getBackgroundThreadScheduler().post(() -> ran.set(true)))
+        .get();
+
+    assertThat(ran.get()).isFalse();
+    Robolectric.flushBackgroundThreadScheduler();
+    assertThat(ran.get()).isTrue();
+  }
+
+  @After
+  public void tearDown() {
+    RoboSettings.setUseGlobalScheduler(false);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyMessageQueueTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyMessageQueueTest.java
new file mode 100644
index 0000000..89892fa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyMessageQueueTest.java
@@ -0,0 +1,274 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.fail;
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+import static org.robolectric.util.ReflectionHelpers.callInstanceMethod;
+import static org.robolectric.util.ReflectionHelpers.setField;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowMessage.MessageReflector;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.Scheduler;
+
+/** Unit tests for {@link ShadowLegacyMessageQueue}. */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(Mode.LEGACY)
+public class ShadowLegacyMessageQueueTest {
+  private Looper looper;
+  private MessageQueue queue;
+  private ShadowLegacyMessageQueue shadowQueue;
+  private Message testMessage;
+  private TestHandler handler;
+  private Scheduler scheduler;
+  private String quitField;
+  
+  private static class TestHandler extends Handler {
+    public List<Message> handled = new ArrayList<>();
+    
+    public TestHandler(Looper looper) {
+      super(looper);
+    }
+    
+    @Override
+    public void handleMessage(Message msg) {
+      handled.add(msg);
+    }
+  }
+  
+  private static Looper newLooper() {
+    return newLooper(true);
+  }
+  
+  private static Looper newLooper(boolean canQuit) {
+    return callConstructor(Looper.class, ClassParameter.from(boolean.class, canQuit));
+  }
+  
+  @Before
+  public void setUp() throws Exception {
+    // Queues and loopers are closely linked; can't easily test one without the other.
+    looper = newLooper();
+    handler = new TestHandler(looper);
+    queue = looper.getQueue();
+    shadowQueue = Shadow.extract(queue);
+    scheduler = shadowQueue.getScheduler();
+    scheduler.pause();
+    testMessage = handler.obtainMessage();
+    quitField = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ? "mQuitting" : "mQuiting";
+  }
+
+  @Test
+  public void test_setGetHead() {
+    shadowQueue.setHead(testMessage);
+    assertWithMessage("getHead()").that(shadowQueue.getHead()).isSameInstanceAs(testMessage);
+  }
+
+  private boolean enqueueMessage(Message msg, long when) {
+    return callInstanceMethod(
+        queue,
+        "enqueueMessage",
+        ClassParameter.from(Message.class, msg),
+        ClassParameter.from(long.class, when));
+  }
+
+  private void removeMessages(Handler handler, int what, Object token) {
+    callInstanceMethod(
+        queue,
+        "removeMessages",
+        ClassParameter.from(Handler.class, handler),
+        ClassParameter.from(int.class, what),
+        ClassParameter.from(Object.class, token));
+  }
+  
+  @Test
+  public void enqueueMessage_setsHead() {
+    enqueueMessage(testMessage, 100);
+    assertWithMessage("head").that(shadowQueue.getHead()).isSameInstanceAs(testMessage);
+  }
+
+  @Test
+  public void enqueueMessage_returnsTrue() {
+    assertWithMessage("retval").that(enqueueMessage(testMessage, 100)).isTrue();
+  }
+
+  @Test
+  public void enqueueMessage_setsWhen() {
+    enqueueMessage(testMessage, 123);
+    assertWithMessage("when").that(testMessage.getWhen()).isEqualTo(123);
+  }
+  
+  @Test
+  public void enqueueMessage_returnsFalse_whenQuitting() {
+    setField(queue, quitField, true);
+    assertWithMessage("enqueueMessage()").that(enqueueMessage(testMessage, 1)).isFalse();
+  }
+
+  @Test
+  public void enqueueMessage_doesntSchedule_whenQuitting() {
+    setField(queue, quitField, true);
+    enqueueMessage(testMessage, 1);
+    assertWithMessage("scheduler_size").that(scheduler.size()).isEqualTo(0);
+  }
+  
+  @Test
+  public void enqueuedMessage_isSentToHandler() {
+    enqueueMessage(testMessage, 200);
+    scheduler.advanceTo(199);
+    assertWithMessage("handled:before").that(handler.handled).isEmpty();
+    scheduler.advanceTo(200);
+    assertWithMessage("handled:after").that(handler.handled).containsExactly(testMessage);
+  }
+  
+  @Test
+  public void removedMessage_isNotSentToHandler() {
+    enqueueMessage(testMessage, 200);
+    assertWithMessage("scheduler size:before").that(scheduler.size()).isEqualTo(1);
+    removeMessages(handler, testMessage.what, null);
+    scheduler.advanceToLastPostedRunnable();
+    assertWithMessage("scheduler size:after").that(scheduler.size()).isEqualTo(0);
+    assertWithMessage("handled").that(handler.handled).isEmpty();
+  }
+
+  @Test
+  public void enqueueMessage_withZeroWhen_postsAtFront() {
+    enqueueMessage(testMessage, 0);
+    Message m2 = handler.obtainMessage(2);
+    enqueueMessage(m2, 0);
+    scheduler.advanceToLastPostedRunnable();
+    assertWithMessage("handled").that(handler.handled).containsExactly(m2, testMessage);
+  }
+  
+  @Test
+  public void dispatchedMessage_isMarkedInUse_andRecycled() {
+    Handler handler =
+        new Handler(looper) {
+          @Override
+          public void handleMessage(Message msg) {
+            boolean inUse = callInstanceMethod(msg, "isInUse");
+            assertWithMessage(msg.what + ":inUse").that(inUse).isTrue();
+            Message next = reflector(MessageReflector.class, msg).getNext();
+            assertWithMessage(msg.what + ":next").that(next).isNull();
+          }
+        };
+    Message msg = handler.obtainMessage(1);
+    enqueueMessage(msg, 200);
+    Message msg2 = handler.obtainMessage(2);
+    enqueueMessage(msg2, 205);
+    scheduler.advanceToNextPostedRunnable();
+
+    // Check that it's been properly recycled.
+    assertWithMessage("msg.what").that(msg.what).isEqualTo(0);
+
+    scheduler.advanceToNextPostedRunnable();
+
+    assertWithMessage("msg2.what").that(msg2.what).isEqualTo(0);
+  }
+  
+  @Test 
+  public void reset_shouldClearMessageQueue() {
+    Message msg  = handler.obtainMessage(1234);
+    Message msg2 = handler.obtainMessage(5678);
+    handler.sendMessage(msg);
+    handler.sendMessage(msg2);
+    assertWithMessage("before-1234").that(handler.hasMessages(1234)).isTrue();
+    assertWithMessage("before-5678").that(handler.hasMessages(5678)).isTrue();
+    shadowQueue.reset();
+    assertWithMessage("after-1234").that(handler.hasMessages(1234)).isFalse();
+    assertWithMessage("after-5678").that(handler.hasMessages(5678)).isFalse();
+  }
+
+  @Test
+  public void postAndRemoveSyncBarrierToken() {
+    int token = postSyncBarrier(queue);
+    removeSyncBarrier(queue, token);
+  }
+
+  @Test
+  // TODO(https://github.com/robolectric/robolectric/issues/6852): enable once workaround is removed
+  @Ignore
+  public void removeInvalidSyncBarrierToken() {
+    try {
+      removeSyncBarrier(queue, 99);
+      fail("Expected exception when sync barrier not present on MessageQueue");
+    } catch (IllegalStateException expected) {
+    }
+  }
+
+  @Test
+  public void postAndRemoveSyncBarrierToken_messageBefore() {
+    enqueueMessage(testMessage, SystemClock.uptimeMillis());
+    int token = postSyncBarrier(queue);
+    removeSyncBarrier(queue, token);
+
+    assertThat(shadowQueue.getHead()).isEqualTo(testMessage);
+  }
+
+  @Test
+  public void postAndRemoveSyncBarrierToken_messageBeforeConsumed() {
+    enqueueMessage(testMessage, SystemClock.uptimeMillis());
+    int token = postSyncBarrier(queue);
+    scheduler.advanceToLastPostedRunnable();
+    removeSyncBarrier(queue, token);
+    assertThat(shadowQueue.getHead()).isNull();
+    assertWithMessage("handled:after").that(handler.handled).containsExactly(testMessage);
+  }
+
+  @Test
+  public void postAndRemoveSyncBarrierToken_messageAfter() {
+    enqueueMessage(testMessage, SystemClock.uptimeMillis() + 100);
+    int token = postSyncBarrier(queue);
+    removeSyncBarrier(queue, token);
+
+    assertThat(shadowQueue.getHead()).isEqualTo(testMessage);
+    scheduler.advanceToLastPostedRunnable();
+    assertThat(shadowQueue.getHead()).isNull();
+    assertWithMessage("handled:after").that(handler.handled).containsExactly(testMessage);
+  }
+
+  @Test
+  public void postAndRemoveSyncBarrierToken_syncBefore() {
+    int token = postSyncBarrier(queue);
+    enqueueMessage(testMessage, SystemClock.uptimeMillis());
+    scheduler.advanceToLastPostedRunnable();
+    removeSyncBarrier(queue, token);
+    assertThat(shadowQueue.getHead()).isNull();
+    assertWithMessage("handled:after").that(handler.handled).containsExactly(testMessage);
+  }
+
+  private static void removeSyncBarrier(MessageQueue queue, int token) {
+    ReflectionHelpers.callInstanceMethod(
+        MessageQueue.class, queue, "removeSyncBarrier", ClassParameter.from(int.class, token));
+  }
+
+  private static int postSyncBarrier(MessageQueue queue) {
+    if (RuntimeEnvironment.getApiLevel() >= M) {
+      return queue.postSyncBarrier();
+    } else {
+      return ReflectionHelpers.callInstanceMethod(
+          MessageQueue.class,
+          queue,
+          "enqueueSyncBarrier",
+          ClassParameter.from(long.class, SystemClock.uptimeMillis()));
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyMessageTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyMessageTest.java
new file mode 100644
index 0000000..a1f1c2d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacyMessageTest.java
@@ -0,0 +1,240 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Scheduler;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(Mode.LEGACY)
+public class ShadowLegacyMessageTest {
+
+  @Test
+  public void testGetDataShouldLazilyCreateBundle() {
+    assertThat(new Message().getData()).isNotNull();
+    assertThat(new Message().getData().isEmpty()).isTrue();
+  }
+
+  @Test
+  public void testGetData() {
+    Message m = new Message();
+    Bundle b = new Bundle();
+    m.setData(b);
+    assertThat(m.getData()).isEqualTo(b);
+  }
+
+  @Test
+  public void testPeekData() {
+    assertThat(new Message().peekData()).isNull();
+
+    Message m = new Message();
+    Bundle b = new Bundle();
+    m.setData(b);
+    assertThat(m.peekData()).isEqualTo(b);
+  }
+
+  @Test
+  public void testGetTarget() {
+    Message m = new Message();
+    Handler h = new Handler();
+    m.setTarget(h);
+    assertThat(m.getTarget()).isEqualTo(h);
+  }
+
+  @Test
+  public void testCopyFrom() {
+    Bundle b = new Bundle();
+    Message m = new Message();
+    m.arg1 = 10;
+    m.arg2 = 42;
+    m.obj = "obj";
+    m.what = 24;
+    m.setData(b);
+    m.setTarget(new Handler());
+    Message m2 = new Message();
+    m2.copyFrom(m);
+
+    assertThat(m2.arg1).isEqualTo(m.arg1);
+    assertThat(m2.arg2).isEqualTo(m.arg2);
+    assertThat(m2.obj).isEqualTo(m.obj);
+    assertThat(m2.what).isEqualTo(m.what);
+    assertThat(m2.getTarget()).isNull();
+    assertThat(m2.getData()).isNotNull();
+    assertThat(m2.getData().isEmpty()).isTrue();
+  }
+
+  @Test
+  public void testObtain() {
+    Message m = Message.obtain();
+    assertThat(m).isNotNull();
+  }
+
+  @Test
+  public void testObtainWithHandler() {
+    Handler h = new Handler();
+    Message m = Message.obtain(h);
+    assertThat(m.getTarget()).isEqualTo(h);
+  }
+
+  @Test
+  public void testObtainWithHandlerAndWhat() {
+    Handler h = new Handler();
+    int what = 10;
+    Message m = Message.obtain(h, what);
+
+    assertThat(m.getTarget()).isEqualTo(h);
+    assertThat(m.what).isEqualTo(what);
+    assertThat(m.getTarget()).isEqualTo(h);
+  }
+
+  @Test
+  public void testObtainWithHandlerWhatAndObject() {
+    Handler h = new Handler();
+    int what = 10;
+    Object obj = "test";
+    Message m = Message.obtain(h, what, obj);
+
+    assertThat(m.getTarget()).isEqualTo(h);
+    assertThat(m.what).isEqualTo(what);
+    assertThat(m.getTarget()).isEqualTo(h);
+    assertThat(m.obj).isEqualTo(obj);
+  }
+
+  @Test
+  public void testObtainWithHandlerWhatAndTwoArgs() {
+    Handler h = new Handler();
+    int what = 2;
+    int arg1 = 3;
+    int arg2 = 5;
+    Message m = Message.obtain(h, what, arg1, arg2);
+
+    assertThat(m.getTarget()).isEqualTo(h);
+    assertThat(m.what).isEqualTo(what);
+    assertThat(m.arg1).isEqualTo(arg1);
+    assertThat(m.arg2).isEqualTo(arg2);
+  }
+
+  @Test
+  public void testObtainWithHandlerWhatTwoArgsAndObj() {
+    Handler h = new Handler();
+    int what = 2;
+    int arg1 = 3;
+    int arg2 = 5;
+    Object obj = "test";
+    Message m = Message.obtain(h, what, arg1, arg2, obj);
+
+    assertThat(m.getTarget()).isEqualTo(h);
+    assertThat(m.what).isEqualTo(what);
+    assertThat(m.arg1).isEqualTo(arg1);
+    assertThat(m.arg2).isEqualTo(arg2);
+    assertThat(m.obj).isEqualTo(obj);
+  }
+
+  @Test
+  public void testObtainWithMessage() {
+    Bundle b = new Bundle();
+    Message m = new Message();
+    m.arg1 = 10;
+    m.arg2 = 42;
+    m.obj = "obj";
+    m.what = 24;
+    m.setData(b);
+    m.setTarget(new Handler());
+    Message m2 = Message.obtain(m);
+
+    assertThat(m2.arg1).isEqualTo(m.arg1);
+    assertThat(m2.arg2).isEqualTo(m.arg2);
+    assertThat(m2.obj).isEqualTo(m.obj);
+    assertThat(m2.what).isEqualTo(m.what);
+    assertThat(m2.getTarget()).isEqualTo(m.getTarget());
+    assertThat(m2.getData()).isNotNull();
+    assertThat(m2.getData().isEmpty()).isTrue();
+  }
+
+  @Test
+  public void testSendToTarget() {
+    ShadowLooper.pauseMainLooper();
+    Handler h = new Handler();
+    Message.obtain(h, 123).sendToTarget();
+    assertThat(h.hasMessages(123)).isTrue();
+  }
+  
+  @Test
+  public void testSetGetNext() {
+    Message msg = Message.obtain();
+    Message msg2 = Message.obtain();
+    ShadowLegacyMessage sMsg = Shadow.extract(msg);
+    sMsg.setNext(msg2);
+    assertThat(sMsg.getNext()).isSameInstanceAs(msg2);
+  }
+
+  @Test
+  @Config(maxSdk = KITKAT_WATCH)
+  public void recycle_shouldInvokeRealObject19() {
+    recycle_shouldInvokeRealObject("recycle");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void recycle_shouldInvokeRealObject21() {
+    recycle_shouldInvokeRealObject("recycleUnchecked");
+  }
+  
+  private void recycle_shouldInvokeRealObject(String recycleMethod) {
+    Handler h = new Handler();
+    Message msg = Message.obtain(h, 234);
+    ReflectionHelpers.callInstanceMethod(msg, recycleMethod);
+    assertThat(msg.what).isEqualTo(0);
+  }
+  
+  @Test
+  @Config(maxSdk = KITKAT_WATCH)
+  public void recycle_shouldRemoveMessageFromScheduler19() {
+    recycle_shouldRemoveMessageFromScheduler();
+  }
+  
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void recycle_shouldRemoveMessageFromScheduler21() {
+    recycle_shouldRemoveMessageFromScheduler();
+  }
+  
+  private void recycle_shouldRemoveMessageFromScheduler() {
+    ShadowLooper.pauseMainLooper();
+    Handler h = new Handler();
+    Message msg = Message.obtain(h, 234);
+    msg.sendToTarget();
+    Scheduler scheduler = Robolectric.getForegroundThreadScheduler();
+    assertWithMessage("before recycle").that(scheduler.size()).isEqualTo(1);
+    shadowOf(msg).recycleUnchecked();
+    assertWithMessage("after recycle").that(scheduler.size()).isEqualTo(0);
+  }
+  
+  @Test
+  public void reset_shouldEmptyMessagePool() {
+    Message dummy1 = Message.obtain();
+    shadowOf(dummy1).recycleUnchecked();
+    Message dummy2 = Message.obtain();
+    assertWithMessage("before resetting").that(dummy2).isSameInstanceAs(dummy1);
+
+    shadowOf(dummy2).recycleUnchecked();
+    ShadowLegacyMessage.reset();
+    dummy1 = Message.obtain();
+    assertWithMessage("after resetting").that(dummy1).isNotSameInstanceAs(dummy2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacySystemClockTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacySystemClockTest.java
new file mode 100644
index 0000000..529c656
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLegacySystemClockTest.java
@@ -0,0 +1,103 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.DateTimeException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.experimental.LazyApplication;
+import org.robolectric.annotation.experimental.LazyApplication.LazyLoad;
+import org.robolectric.internal.bytecode.RobolectricInternals;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LEGACY)
+public class ShadowLegacySystemClockTest {
+
+  @Test
+  public void shouldAllowForFakingOfTime() {
+    assertThat(SystemClock.uptimeMillis()).isNotEqualTo(1000);
+    Robolectric.getForegroundThreadScheduler().advanceTo(1000);
+    assertThat(SystemClock.uptimeMillis()).isEqualTo(1000);
+  }
+
+  @Test
+  public void sleep() {
+    Robolectric.getForegroundThreadScheduler().advanceTo(1000);
+    SystemClock.sleep(34);
+    assertThat(SystemClock.uptimeMillis()).isEqualTo(1034);
+  }
+
+  @Test
+  public void testSetCurrentTime() {
+    Robolectric.getForegroundThreadScheduler().advanceTo(1000);
+    assertThat(ShadowLegacySystemClock.now()).isEqualTo(1000);
+    assertTrue(SystemClock.setCurrentTimeMillis(1034));
+    assertThat(ShadowLegacySystemClock.now()).isEqualTo(1034);
+    assertFalse(SystemClock.setCurrentTimeMillis(1000));
+    assertThat(ShadowLegacySystemClock.now()).isEqualTo(1034);
+  }
+
+  @Test
+  public void testElapsedRealtime() {
+    Robolectric.getForegroundThreadScheduler().advanceTo(1000);
+    assertThat(SystemClock.elapsedRealtime()).isEqualTo(1000);
+    Robolectric.getForegroundThreadScheduler().advanceTo(1034);
+    assertThat(SystemClock.elapsedRealtime()).isEqualTo(1034);
+  }
+
+  @Test @Config(minSdk = JELLY_BEAN_MR1)
+  public void testElapsedRealtimeNanos() {
+    Robolectric.getForegroundThreadScheduler().advanceTo(1000);
+    assertThat(SystemClock.elapsedRealtimeNanos()).isEqualTo(1000000000);
+    Robolectric.getForegroundThreadScheduler().advanceTo(1034);
+    assertThat(SystemClock.elapsedRealtimeNanos()).isEqualTo(1034000000);
+  }
+
+  @Test
+  public void shouldInterceptSystemTimeCalls() throws Throwable {
+    ShadowSystemClock.setNanoTime(3141592L);
+    long systemNanoTime = (Long) RobolectricInternals.intercept(
+        "java/lang/System/nanoTime()J", null, null, getClass());
+    assertThat(systemNanoTime).isEqualTo(3141592L);
+    long systemMilliTime = (Long) RobolectricInternals.intercept(
+        "java/lang/System/currentTimeMillis()J", null, null, getClass());
+    assertThat(systemMilliTime).isEqualTo(3L);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void currentNetworkTimeMillis_networkTimeAvailable_shouldReturnCurrentTime() {
+    ShadowSystemClock.setNanoTime(123456000000L);
+    assertThat(SystemClock.currentNetworkTimeMillis()).isEqualTo(123456);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void currentNetworkTimeMillis_networkTimeNotAvailable_shouldThrowDateTimeException() {
+    ShadowSystemClock.setNetworkTimeAvailable(false);
+    try {
+      SystemClock.currentNetworkTimeMillis();
+      fail("Trying to get currentNetworkTimeMillis without network time should throw");
+    } catch (DateTimeException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @LazyApplication(LazyLoad.ON)
+  public void systemClockWorksWithLazyApplication() {
+    SystemClock.setCurrentTimeMillis(10000);
+    assertThat(ShadowSystemClock.currentTimeMillis()).isEqualTo(10000);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLinearLayoutTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLinearLayoutTest.java
new file mode 100644
index 0000000..a7f5c46
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLinearLayoutTest.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertSame;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.Gravity;
+import android.widget.LinearLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLinearLayoutTest {
+  private LinearLayout linearLayout;
+  private ShadowLinearLayout shadow;
+
+  @Before
+  public void setup() {
+    linearLayout = new LinearLayout(ApplicationProvider.getApplicationContext());
+    shadow = shadowOf(linearLayout);
+  }
+
+  @Test
+  public void getLayoutParams_shouldReturnTheSameLinearLayoutParamsFromTheSetter() {
+    LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(1, 2);
+    linearLayout.setLayoutParams(params);
+
+    assertSame(params, linearLayout.getLayoutParams());
+  }
+
+  @Test
+  public void canAnswerOrientation() {
+    assertThat(linearLayout.getOrientation()).isEqualTo(LinearLayout.HORIZONTAL);
+    linearLayout.setOrientation(LinearLayout.VERTICAL);
+    assertThat(linearLayout.getOrientation()).isEqualTo(LinearLayout.VERTICAL);
+    linearLayout.setOrientation(LinearLayout.HORIZONTAL);
+    assertThat(linearLayout.getOrientation()).isEqualTo(LinearLayout.HORIZONTAL);
+  }
+
+  @Test
+  public void canAnswerGravity() {
+    assertThat(shadow.getGravity()).isEqualTo(Gravity.TOP | Gravity.START);
+    linearLayout.setGravity(Gravity.CENTER_VERTICAL); // Only affects horizontal.
+    assertThat(shadow.getGravity()).isEqualTo(Gravity.CENTER_VERTICAL | Gravity.START);
+    linearLayout.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL); // Affects both directions.
+    assertThat(shadow.getGravity()).isEqualTo(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLinkMovementMethodTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLinkMovementMethodTest.java
new file mode 100644
index 0000000..8fd537a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLinkMovementMethodTest.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.text.method.LinkMovementMethod;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLinkMovementMethodTest {
+
+  @Test
+  public void getInstance_shouldReturnAnInstanceOf_LinkedMovementMethod() {
+    assertThat(LinkMovementMethod.getInstance()).isInstanceOf(LinkMovementMethod.class);
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLinuxTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLinuxTest.java
new file mode 100644
index 0000000..bea5e63
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLinuxTest.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.system.StructStat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.time.Duration;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for ShadowLinux to check values returned from stat() call. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O)
+public final class ShadowLinuxTest {
+  private File file;
+  private String path;
+  private ShadowLinux shadowLinux;
+
+  @Before
+  public void setUp() throws Exception {
+    shadowLinux = new ShadowLinux();
+    file = File.createTempFile("ShadowLinuxTest", null);
+    path = file.getAbsolutePath();
+    try (FileOutputStream outputStream = new FileOutputStream(file)) {
+      outputStream.write(1234);
+    }
+  }
+
+  @Test
+  public void getStat_returnCorrectMode() throws Exception {
+    StructStat stat = shadowLinux.stat(path);
+    assertThat(stat.st_mode).isEqualTo(OsConstantsValues.S_IFREG_VALUE);
+  }
+
+  @Test
+  public void getStat_returnCorrectSize() throws Exception {
+    StructStat stat = shadowLinux.stat(path);
+    assertThat(stat.st_size).isEqualTo(file.length());
+  }
+
+  @Test
+  public void getStat_returnCorrectModifiedTime() throws Exception {
+    StructStat stat = shadowLinux.stat(path);
+    assertThat(stat.st_mtime).isEqualTo(Duration.ofMillis(file.lastModified()).getSeconds());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowListPopupWindowTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowListPopupWindowTest.java
new file mode 100644
index 0000000..07bbd87
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowListPopupWindowTest.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.ListPopupWindow;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowListPopupWindowTest {
+  @Test
+  public void show_setsLastListPopupWindow() {
+    Context context = ApplicationProvider.getApplicationContext();
+    ListPopupWindow popupWindow = new ListPopupWindow(context);
+    assertThat(ShadowListPopupWindow.getLatestListPopupWindow()).isNull();
+    popupWindow.setAnchorView(new View(context));
+    popupWindow.show();
+    assertThat(ShadowListPopupWindow.getLatestListPopupWindow()).isSameInstanceAs(popupWindow);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowListPreferenceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowListPreferenceTest.java
new file mode 100644
index 0000000..93e6b45
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowListPreferenceTest.java
@@ -0,0 +1,62 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Robolectric.buildActivity;
+
+import android.app.Activity;
+import android.preference.ListPreference;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowListPreferenceTest {
+
+  private ListPreference listPreference;
+
+  @Before
+  public void setUp() throws Exception {
+    listPreference = new ListPreference(buildActivity(Activity.class).create().get());
+  }
+
+  @Test
+  public void shouldHaveEntries() {
+    CharSequence[] entries = { "this", "is", "only", "a", "test" };
+
+    assertThat(listPreference.getEntries()).isNull();
+    listPreference.setEntries(entries);
+    assertThat(listPreference.getEntries()).isSameInstanceAs(entries);
+  }
+
+  @Test
+  public void shouldSetEntriesByResourceId() {
+    assertThat(listPreference.getEntries()).isNull();
+    listPreference.setEntries(R.array.greetings);
+    assertThat(listPreference.getEntries()).isNotNull();
+  }
+
+  @Test
+  public void shouldHaveEntryValues() {
+    CharSequence[] entryValues = { "this", "is", "only", "a", "test" };
+
+    assertThat(listPreference.getEntryValues()).isNull();
+    listPreference.setEntryValues(entryValues);
+    assertThat(listPreference.getEntryValues()).isSameInstanceAs(entryValues);
+  }
+
+  @Test
+  public void shouldSetEntryValuesByResourceId() {
+    assertThat(listPreference.getEntryValues()).isNull();
+    listPreference.setEntryValues(R.array.greetings);
+    assertThat(listPreference.getEntryValues()).isNotNull();
+  }
+
+  @Test
+  public void shouldSetValue() {
+    assertThat(listPreference.getValue()).isNull();
+    listPreference.setValue("testing");
+    assertThat(listPreference.getValue()).isEqualTo("testing");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowListViewAdapterViewBehaviorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowListViewAdapterViewBehaviorTest.java
new file mode 100644
index 0000000..54e4aa2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowListViewAdapterViewBehaviorTest.java
@@ -0,0 +1,14 @@
+package org.robolectric.shadows;
+
+import android.widget.AdapterView;
+import android.widget.ListView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowListViewAdapterViewBehaviorTest extends AdapterViewBehavior {
+  @Override public AdapterView createAdapterView() {
+    return new ListView(ApplicationProvider.getApplicationContext());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowListViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowListViewTest.java
new file mode 100644
index 0000000..3f089aa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowListViewTest.java
@@ -0,0 +1,414 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.util.SparseBooleanArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.BaseAdapter;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowListViewTest {
+
+  private List<String> transcript;
+  private ListView listView;
+  private int checkedItemPosition;
+  private SparseBooleanArray checkedItemPositions;
+  private int lastCheckedPosition;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    transcript = new ArrayList<>();
+    context = ApplicationProvider.getApplicationContext();
+    listView = new ListView(context);
+  }
+
+  @Test
+  public void addHeaderView_ShouldRecordHeaders() {
+    View view0 = new View(context);
+    view0.setId(0);
+    View view1 = new View(context);
+    view1.setId(1);
+    View view2 = new View(context);
+    view2.setId(2);
+    View view3 = new View(context);
+    view3.setId(3);
+    listView.addHeaderView(view0);
+    listView.addHeaderView(view1);
+    listView.addHeaderView(view2, null, false);
+    listView.addHeaderView(view3, null, false);
+    listView.setAdapter(new ShadowCountingAdapter(2));
+    assertThat(listView.getHeaderViewsCount()).isEqualTo(4);
+    assertThat(shadowOf(listView).getHeaderViews().get(0)).isSameInstanceAs(view0);
+    assertThat(shadowOf(listView).getHeaderViews().get(1)).isSameInstanceAs(view1);
+    assertThat(shadowOf(listView).getHeaderViews().get(2)).isSameInstanceAs(view2);
+    assertThat(shadowOf(listView).getHeaderViews().get(3)).isSameInstanceAs(view3);
+
+    assertThat((View) listView.findViewById(0)).isNotNull();
+    assertThat((View) listView.findViewById(1)).isNotNull();
+    assertThat((View) listView.findViewById(2)).isNotNull();
+    assertThat((View) listView.findViewById(3)).isNotNull();
+  }
+
+  @Test
+  public void addHeaderView_shouldAttachTheViewToTheList() {
+    View view = new View(context);
+    view.setId(42);
+
+    listView.addHeaderView(view);
+
+    assertThat((View) listView.findViewById(42)).isSameInstanceAs(view);
+  }
+
+  @Test
+  public void addFooterView_ShouldRecordFooters() {
+    View view0 = new View(context);
+    View view1 = new View(context);
+    listView.addFooterView(view0);
+    listView.addFooterView(view1);
+    listView.setAdapter(new ShadowCountingAdapter(3));
+    assertThat(shadowOf(listView).getFooterViews().get(0)).isSameInstanceAs(view0);
+    assertThat(shadowOf(listView).getFooterViews().get(1)).isSameInstanceAs(view1);
+  }
+
+  @Test
+  public void addFooterView_shouldAttachTheViewToTheList() {
+    View view = new View(context);
+    view.setId(42);
+
+    listView.addFooterView(view);
+
+    assertThat((View) listView.findViewById(42)).isSameInstanceAs(view);
+  }
+
+  @Test
+  public void setAdapter_shouldNotClearHeaderOrFooterViews() {
+    View header = new View(context);
+    listView.addHeaderView(header);
+    View footer = new View(context);
+    listView.addFooterView(footer);
+
+    prepareListWithThreeItems();
+
+    assertThat(listView.getChildCount()).isEqualTo(5);
+    assertThat(listView.getChildAt(0)).isSameInstanceAs(header);
+    assertThat(listView.getChildAt(4)).isSameInstanceAs(footer);
+  }
+
+  @Test
+  public void testGetFooterViewsCount() {
+    listView.addHeaderView(new View(context));
+    listView.addFooterView(new View(context));
+    listView.addFooterView(new View(context));
+
+    prepareListWithThreeItems();
+
+    assertThat(listView.getFooterViewsCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void smoothScrollBy_shouldBeRecorded() {
+    listView.smoothScrollBy(42, 420);
+    assertThat(shadowOf(listView).getLastSmoothScrollByDistance()).isEqualTo(42);
+    assertThat(shadowOf(listView).getLastSmoothScrollByDuration()).isEqualTo(420);
+  }
+
+  @Test
+  public void testPerformItemClick_ShouldFireOnItemClickListener() {
+    listView.setOnItemClickListener(
+        (parent, view, position, id) -> transcript.add("item was clicked: " + position));
+
+    listView.performItemClick(null, 0, -1);
+    assertThat(transcript).containsExactly("item was clicked: 0");
+  }
+
+  @Test
+  public void testSetSelection_WhenNoItemSelectedListenerIsSet_ShouldDoNothing() {
+    listView.setSelection(0);
+  }
+
+  @Test
+  public void findItemContainingText_shouldFindChildByString() {
+    ShadowListView shadowListView = prepareListWithThreeItems();
+    View item1 = shadowListView.findItemContainingText("Item 1");
+    assertThat(item1).isSameInstanceAs(listView.getChildAt(1));
+  }
+
+  @Test
+  public void findItemContainingText_shouldReturnNullIfNotFound() {
+    ShadowListView shadowListView = prepareListWithThreeItems();
+    assertThat(shadowListView.findItemContainingText("Non-existent item")).isNull();
+  }
+
+  @Test
+  public void clickItemContainingText_shouldPerformItemClickOnList() {
+    ShadowListView shadowListView = prepareListWithThreeItems();
+    listView.setOnItemClickListener(
+        (parent, view, position, id) -> transcript.add("clicked on item " + position));
+    shadowListView.clickFirstItemContainingText("Item 1");
+    assertThat(transcript).containsExactly("clicked on item 1");
+  }
+
+  @Test
+  public void clickItemContainingText_shouldPerformItemClickOnList_arrayAdapter() {
+    ArrayList<String> adapterFileList = new ArrayList<>();
+    adapterFileList.add("Item 1");
+    adapterFileList.add("Item 2");
+    adapterFileList.add("Item 3");
+    final ArrayAdapter<String> adapter =
+        new ArrayAdapter<>(getApplication(), android.R.layout.simple_list_item_1, adapterFileList);
+    listView.setAdapter(adapter);
+    shadowOf(listView).populateItems();
+    ShadowListView shadowListView = shadowOf(listView);
+    listView.setOnItemClickListener(
+        (parent, view, position, id) ->
+            transcript.add("clicked on item " + adapter.getItem(position)));
+    shadowListView.clickFirstItemContainingText("Item 3");
+    assertThat(transcript).containsExactly("clicked on item Item 3");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void clickItemContainingText_shouldThrowExceptionIfNotFound() {
+    ShadowListView shadowListView = prepareListWithThreeItems();
+    shadowListView.clickFirstItemContainingText("Non-existant item");
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void removeAllViews_shouldThrowAnException() {
+    listView.removeAllViews();
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void removeView_shouldThrowAnException() {
+    listView.removeView(new View(context));
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void removeViewAt_shouldThrowAnException() {
+    listView.removeViewAt(0);
+  }
+
+  @Test
+  public void getPositionForView_shouldReturnThePositionInTheListForTheView() {
+    prepareWithListAdapter();
+    View childViewOfListItem = ((ViewGroup) listView.getChildAt(1)).getChildAt(0);
+    assertThat(listView.getPositionForView(childViewOfListItem)).isEqualTo(1);
+  }
+
+  @Test
+  public void getPositionForView_shouldReturnInvalidPositionForViewThatIsNotFound() {
+    prepareWithListAdapter();
+    View view = new View(context);
+    shadowOf(view).setMyParent(ReflectionHelpers.createNullProxy(ViewParent.class)); // Android implementation requires the item have a parent
+    assertThat(listView.getPositionForView(view)).isEqualTo(AdapterView.INVALID_POSITION);
+  }
+
+  @Test
+  public void shouldRecordLatestCallToSmoothScrollToPostion() {
+    listView.smoothScrollToPosition(10);
+    assertThat(shadowOf(listView).getSmoothScrolledPosition()).isEqualTo(10);
+  }
+
+  @Test
+  public void givenChoiceModeIsSingle_whenGettingCheckedItemPosition_thenReturnPosition() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_SINGLE).withAnyItemChecked();
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(checkedItemPosition);
+  }
+
+  @Test
+  public void givenChoiceModeIsMultiple_whenGettingCheckedItemPosition_thenReturnInvalidPosition() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_MULTIPLE).withAnyItemChecked();
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(ListView.INVALID_POSITION);
+  }
+
+  @Test
+  public void givenChoiceModeIsNone_whenGettingCheckedItemPosition_thenReturnInvalidPosition() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_NONE);
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(ListView.INVALID_POSITION);
+  }
+
+  @Test
+  public void givenNoItemsChecked_whenGettingCheckedItemOisition_thenReturnInvalidPosition() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_SINGLE);
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(ListView.INVALID_POSITION);
+  }
+
+  @Test
+  public void givenChoiceModeIsSingleAndAnItemIsChecked_whenSettingChoiceModeToNone_thenGetCheckedItemPositionShouldReturnInvalidPosition() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_SINGLE).withAnyItemChecked();
+
+    listView.setChoiceMode(ListView.CHOICE_MODE_NONE);
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(ListView.INVALID_POSITION);
+  }
+
+  @Test
+  public void givenChoiceModeIsMultipleAndMultipleItemsAreChecked_whenGettingCheckedItemPositions_thenReturnCheckedPositions() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_MULTIPLE).withAnyItemsChecked();
+
+    assertThat(listView.getCheckedItemCount()).isEqualTo(checkedItemPositions.size());
+    for (int i = 0; i < checkedItemPositions.size(); i++) {
+      assertThat(listView.getCheckedItemPositions().get(i)).isTrue();
+    }
+  }
+
+  @Test
+  public void givenChoiceModeIsSingleAndMultipleItemsAreChecked_whenGettingCheckedItemPositions_thenReturnOnlyTheLastCheckedPosition() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_SINGLE).withAnyItemsChecked();
+
+    assertThat(listView.getCheckedItemPositions().get(lastCheckedPosition)).isTrue();
+    assertThat(listView.getCheckedItemCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void givenChoiceModeIsNoneAndMultipleItemsAreChecked_whenGettingCheckedItemPositions_thenReturnNull() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_NONE).withAnyItemsChecked();
+
+    assertNull(listView.getCheckedItemPositions());
+  }
+
+  @Test
+  public void givenItemIsNotCheckedAndChoiceModeIsSingle_whenPerformingItemClick_thenItemShouldBeChecked() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_SINGLE);
+    int positionToClick = anyListIndex();
+
+    listView.performItemClick(null, positionToClick, 0);
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(positionToClick);
+  }
+
+  @Test
+  public void givenItemIsCheckedAndChoiceModeIsSingle_whenPerformingItemClick_thenItemShouldBeChecked() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_SINGLE).withAnyItemChecked();
+
+    listView.performItemClick(null, checkedItemPosition, 0);
+
+    assertThat(listView.getCheckedItemPosition()).isEqualTo(checkedItemPosition);
+  }
+
+  @Test
+  public void givenItemIsNotCheckedAndChoiceModeIsMultiple_whenPerformingItemClick_thenItemShouldBeChecked() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+    int positionToClick = anyListIndex();
+
+    listView.performItemClick(null, positionToClick, 0);
+
+    assertThat(listView.getCheckedItemPositions().get(positionToClick)).isTrue();
+    assertThat(listView.getCheckedItemCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void givenItemIsCheckedAndChoiceModeIsMultiple_whenPerformingItemClick_thenItemShouldNotBeChecked() {
+    prepareListAdapter().withChoiceMode(ListView.CHOICE_MODE_MULTIPLE).withAnyItemChecked();
+
+    listView.performItemClick(null, checkedItemPosition, 0);
+
+    assertFalse(listView.getCheckedItemPositions().get(checkedItemPosition));
+  }
+
+  private ListAdapterBuilder prepareListAdapter() {
+    return new ListAdapterBuilder();
+  }
+
+  private ListAdapter prepareWithListAdapter() {
+    ListAdapter adapter = new ListAdapter("a", "b", "c");
+    listView.setAdapter(adapter);
+    shadowOf(listView).populateItems();
+    return adapter;
+  }
+
+  private ShadowListView prepareListWithThreeItems() {
+    listView.setAdapter(new ShadowCountingAdapter(3));
+    shadowOf(listView).populateItems();
+
+    return shadowOf(listView);
+  }
+
+  private int anyListIndex() {
+    return new Random().nextInt(3);
+  }
+
+  private static class ListAdapter extends BaseAdapter {
+    public List<String> items = new ArrayList<>();
+
+    public ListAdapter(String... items) {
+      this.items.addAll(asList(items));
+    }
+
+    @Override
+    public int getCount() {
+      return items.size();
+    }
+
+    @Override
+    public Object getItem(int position) {
+      return items.get(position);
+    }
+
+    @Override
+    public long getItemId(int position) {
+      return 0;
+    }
+
+    @Override
+    public View getView(int position, View convertView, ViewGroup parent) {
+      LinearLayout linearLayout = new LinearLayout(ApplicationProvider.getApplicationContext());
+      linearLayout.addView(new View(ApplicationProvider.getApplicationContext()));
+      return linearLayout;
+    }
+  }
+
+  public class ListAdapterBuilder {
+
+    public ListAdapterBuilder() {
+      prepareListWithThreeItems();
+    }
+
+    public ListAdapterBuilder withChoiceMode(int choiceMode) {
+      listView.setChoiceMode(choiceMode);
+      return this;
+    }
+
+    public ListAdapterBuilder withAnyItemChecked() {
+      checkedItemPosition = anyListIndex();
+      listView.setItemChecked(checkedItemPosition, true);
+      return this;
+    }
+
+    public void withAnyItemsChecked() {
+      checkedItemPositions = new SparseBooleanArray();
+      int numberOfSelections = anyListIndex() + 1;
+      for (int i = 0; i < numberOfSelections; i++) {
+        checkedItemPositions.put(i, true);
+        listView.setItemChecked(i, true);
+        lastCheckedPosition = i;
+      }
+
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleDataTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleDataTest.java
new file mode 100644
index 0000000..862d2a6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleDataTest.java
@@ -0,0 +1,288 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import libcore.icu.LocaleData;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@RunWith(AndroidJUnit4.class)
+@Config(maxSdk = S_V2)
+public class ShadowLocaleDataTest {
+
+  @Test
+  public void shouldSupportLocaleEn_US() throws NoSuchFieldException, IllegalAccessException {
+    LocaleData localeData = LocaleData.get(Locale.US);
+    LocaleDataReflector localeDataReflector = reflector(LocaleDataReflector.class, localeData);
+    assertThat(localeData.amPm).isEqualTo(new String[] {"AM", "PM"});
+    assertThat(localeData.eras).isEqualTo(new String[] {"BC", "AD"});
+
+    assertThat(localeData.firstDayOfWeek).isEqualTo(1);
+    assertThat(localeData.minimalDaysInFirstWeek).isEqualTo(1);
+
+    assertThat(localeData.longMonthNames)
+        .isEqualTo(
+            new String[] {
+              "January",
+              "February",
+              "March",
+              "April",
+              "May",
+              "June",
+              "July",
+              "August",
+              "September",
+              "October",
+              "November",
+              "December"
+            });
+    assertThat(localeData.shortMonthNames)
+        .isEqualTo(
+            new String[] {
+              "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+            });
+
+    assertThat(localeData.longStandAloneMonthNames).isEqualTo(localeData.longMonthNames);
+    assertThat(localeData.shortStandAloneMonthNames).isEqualTo(localeData.shortMonthNames);
+
+    assertThat(localeData.longWeekdayNames)
+        .isEqualTo(
+            new String[] {
+              "", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
+            });
+    assertThat(localeData.shortWeekdayNames)
+        .isEqualTo(new String[] {"", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"});
+
+    assertThat(localeData.longStandAloneWeekdayNames).isEqualTo(localeData.longWeekdayNames);
+    assertThat(localeData.shortStandAloneWeekdayNames).isEqualTo(localeData.shortWeekdayNames);
+
+    assertThat(localeDataReflector.getFullTimeFormat()).isEqualTo("h:mm:ss a zzzz");
+    assertThat(localeDataReflector.getLongTimeFormat()).isEqualTo("h:mm:ss a z");
+    assertThat(localeDataReflector.getMediumTimeFormat()).isEqualTo("h:mm:ss a");
+    assertThat(localeDataReflector.getShortTimeFormat()).isEqualTo("h:mm a");
+
+    assertThat(localeDataReflector.getFullDateFormat()).isEqualTo("EEEE, MMMM d, y");
+    assertThat(localeDataReflector.getLongDateFormat()).isEqualTo("MMMM d, y");
+    assertThat(localeDataReflector.getMediumDateFormat()).isEqualTo("MMM d, y");
+    assertThat(localeDataReflector.getShortDateFormat()).isEqualTo("M/d/yy");
+
+    assertThat(localeData.zeroDigit).isEqualTo('0');
+    assertThat(localeDataReflector.getDecimalSeparator()).isEqualTo('.');
+    assertThat(localeDataReflector.getGroupingSeparator()).isEqualTo(',');
+    assertThat(localeDataReflector.getPatternSeparator()).isEqualTo(';');
+
+    assertThat(localeDataReflector.getMonetarySeparator()).isEqualTo('.');
+
+    assertThat(localeDataReflector.getExponentSeparator()).isEqualTo("E");
+    assertThat(localeDataReflector.getInfinity()).isEqualTo("∞");
+    assertThat(localeDataReflector.getNaN()).isEqualTo("NaN");
+
+    if (getApiLevel() <= R) {
+      assertThat(localeDataReflector.getCurrencySymbol()).isEqualTo("$");
+      assertThat(localeDataReflector.getInternationalCurrencySymbol()).isEqualTo("USD");
+    }
+
+    assertThat(localeDataReflector.getNumberPattern()).isEqualTo("#,##0.###");
+    assertThat(localeDataReflector.getIntegerPattern()).isEqualTo("#,##0");
+    assertThat(localeDataReflector.getCurrencyPattern()).isEqualTo("¤#,##0.00;(¤#,##0.00)");
+    assertThat(localeDataReflector.getPercentPattern()).isEqualTo("#,##0%");
+  }
+
+  @Test
+  @Config(maxSdk = Build.VERSION_CODES.O)
+  public void shouldSupportLocaleEn_US_perMill() {
+    LocaleData localeData = LocaleData.get(Locale.US);
+    LocaleDataReflector localeDataReflector = reflector(LocaleDataReflector.class, localeData);
+    assertThat(localeDataReflector.getPerMill()).isEqualTo('‰');
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void shouldSupportLocaleEn_US_perMillPostP() {
+    LocaleData localeData = LocaleData.get(Locale.US);
+    LocaleDataReflector localeDataReflector = reflector(LocaleDataReflector.class, localeData);
+    assertThat(localeDataReflector.getPerMillString()).isEqualTo("‰");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void shouldSupportLocaleEn_US_percentPost22() {
+    LocaleData localeData = LocaleData.get(Locale.US);
+    LocaleDataReflector localeDataReflector = reflector(LocaleDataReflector.class, localeData);
+    assertThat(localeDataReflector.getPercentString()).isEqualTo("%");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldSupportLocaleEn_US_since_jelly_bean_mr1()
+      throws NoSuchFieldException, IllegalAccessException {
+    LocaleData localeData = LocaleData.get(Locale.US);
+    LocaleDataReflector localeDataReflector = reflector(LocaleDataReflector.class, localeData);
+
+    assertThat(localeData.tinyMonthNames)
+        .isEqualTo(new String[] {"J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"});
+    assertThat(localeData.tinyStandAloneMonthNames).isEqualTo(localeData.tinyMonthNames);
+    assertThat(localeData.tinyWeekdayNames)
+        .isEqualTo(new String[] {"", "S", "M", "T", "W", "T", "F", "S"});
+    assertThat(localeData.tinyStandAloneWeekdayNames).isEqualTo(localeData.tinyWeekdayNames);
+
+    if (getApiLevel() <= R) {
+      assertThat(localeDataReflector.getYesterday()).isEqualTo("Yesterday");
+    }
+    assertThat(localeData.today).isEqualTo("Today");
+    assertThat(localeData.tomorrow).isEqualTo("Tomorrow");
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldSupportLocaleEn_US_since_m() {
+    LocaleData localeData = LocaleData.get(Locale.US);
+
+    assertThat(localeData.timeFormat_Hm).isEqualTo("HH:mm");
+    assertThat(localeData.timeFormat_hm).isEqualTo("h:mm a");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldSupportLocaleEn_US_since_lollipop() {
+    LocaleData localeData = LocaleData.get(Locale.US);
+    LocaleDataReflector localeDataReflector = reflector(LocaleDataReflector.class, localeData);
+    assertThat(localeDataReflector.getMinusSignString()).isEqualTo("-");
+  }
+
+  @Test
+  public void shouldDefaultToTheDefaultLocale() {
+    Locale.setDefault(Locale.US);
+    LocaleData localeData = LocaleData.get(null);
+
+    assertThat(localeData.amPm).isEqualTo(new String[] {"AM", "PM"});
+  }
+
+  /** Accessor interface for {@link LocaleData}'s internals. */
+  @ForType(LocaleData.class)
+  interface LocaleDataReflector {
+
+    @Accessor("minusSign")
+    char getMinusSign();
+
+    @Accessor("percent")
+    char getPercent();
+
+    @Accessor("perMill")
+    char getPerMill();
+
+    // <= R
+    @Accessor("yesterday")
+    String getYesterday();
+
+    // <= R
+    @Accessor("currencySymbol")
+    String getCurrencySymbol();
+
+    // <= R
+    @Accessor("internationalCurrencySymbol")
+    String getInternationalCurrencySymbol();
+
+    // <= S_V2
+    @Accessor("fullTimeFormat")
+    String getFullTimeFormat();
+
+    // <= S_V2
+    @Accessor("longTimeFormat")
+    String getLongTimeFormat();
+
+    // <= S_V2
+    @Accessor("mediumTimeFormat")
+    String getMediumTimeFormat();
+
+    // <= S_V2
+    @Accessor("shortTimeFormat")
+    String getShortTimeFormat();
+
+    // <= S_V2
+    @Accessor("fullDateFormat")
+    String getFullDateFormat();
+
+    // <= S_V2
+    @Accessor("longDateFormat")
+    String getLongDateFormat();
+
+    // <= S_V2
+    @Accessor("mediumDateFormat")
+    String getMediumDateFormat();
+
+    // <= S_V2
+    @Accessor("shortDateFormat")
+    String getShortDateFormat();
+
+    // <= S_V2
+    @Accessor("decimalSeparator")
+    char getDecimalSeparator();
+
+    // <= S_V2
+    @Accessor("groupingSeparator")
+    char getGroupingSeparator();
+
+    // <= S_V2
+    @Accessor("patternSeparator")
+    char getPatternSeparator();
+
+    // <= S_V2
+    @Accessor("percent")
+    String getPercentString();
+
+    // <= S_V2
+    @Accessor("perMill")
+    String getPerMillString();
+
+    // <= S_V2
+    @Accessor("monetarySeparator")
+    char getMonetarySeparator();
+
+    // <= S_V2
+    @Accessor("minusSign")
+    String getMinusSignString();
+
+    // <= S_V2
+    @Accessor("exponentSeparator")
+    String getExponentSeparator();
+
+    // <= S_V2
+    @Accessor("infinity")
+    String getInfinity();
+
+    // <= S_V2
+    @Accessor("NaN")
+    String getNaN();
+
+    // <= S_V2
+    @Accessor("numberPattern")
+    String getNumberPattern();
+
+    // <= S_V2
+    @Accessor("integerPattern")
+    String getIntegerPattern();
+
+    // <= S_V2
+    @Accessor("currencyPattern")
+    String getCurrencyPattern();
+
+    // <= S_V2
+    @Accessor("percentPattern")
+    String getPercentPattern();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleListTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleListTest.java
new file mode 100644
index 0000000..b06bbf0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleListTest.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build;
+import android.os.LocaleList;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link org.robolectric.shadows.ShadowLocaleList} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.N)
+public class ShadowLocaleListTest {
+
+  @Before
+  public void setUp() {
+    assertThat(LocaleList.getDefault().size()).isEqualTo(1);
+  }
+
+  @Test
+  public void testResetter() {
+    assertThat(LocaleList.getDefault().toLanguageTags()).doesNotContain("IN");
+
+    Locale locale = new Locale("en", "IN");
+    LocaleList.setDefault(new LocaleList(locale));
+    assertThat(LocaleList.getDefault().toLanguageTags()).contains("IN");
+
+    ShadowLocaleList.reset();
+
+    Locale.setDefault(Locale.US);
+    assertThat(LocaleList.getDefault().toLanguageTags()).doesNotContain("IN");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleManagerTest.java
new file mode 100644
index 0000000..6c889e2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocaleManagerTest.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.LocaleManager;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.os.LocaleList;
+import androidx.test.core.app.ApplicationProvider;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.TIRAMISU)
+public final class ShadowLocaleManagerTest {
+  private static final String DEFAULT_PACKAGE_NAME = "my.app";
+  private static final LocaleList DEFAULT_LOCALES = LocaleList.forLanguageTags("en-XC,ar-XB");
+
+  private Context context;
+  private ShadowLocaleManager localeManager;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    localeManager = Shadow.extract(context.getSystemService(LocaleManager.class));
+  }
+
+  @Test
+  public void setApplicationLocales_updatesMap() {
+    // empty map before set is called.
+    assertThat(localeManager.getApplicationLocales(DEFAULT_PACKAGE_NAME))
+        .isEqualTo(LocaleList.getEmptyLocaleList());
+
+    localeManager.setApplicationLocales(DEFAULT_PACKAGE_NAME, DEFAULT_LOCALES);
+
+    localeManager.enforceInstallerCheck(false);
+    assertThat(localeManager.getApplicationLocales(DEFAULT_PACKAGE_NAME))
+        .isEqualTo(DEFAULT_LOCALES);
+  }
+
+  @Test
+  public void getApplicationLocales_fetchAsInstaller_returnsLocales() {
+    localeManager.setApplicationLocales(DEFAULT_PACKAGE_NAME, DEFAULT_LOCALES);
+    localeManager.setCallerAsInstallerForPackage(DEFAULT_PACKAGE_NAME);
+    localeManager.enforceInstallerCheck(true);
+
+    assertThat(localeManager.getApplicationLocales(DEFAULT_PACKAGE_NAME))
+        .isEqualTo(DEFAULT_LOCALES);
+  }
+
+  @Test
+  public void getApplicationLocales_fetchAsInstaller_throwsSecurityExceptionIfIncorrectInstaller() {
+    localeManager.setApplicationLocales(DEFAULT_PACKAGE_NAME, DEFAULT_LOCALES);
+    localeManager.enforceInstallerCheck(true);
+
+    assertThrows(
+        SecurityException.class, () -> localeManager.getApplicationLocales(DEFAULT_PACKAGE_NAME));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLocationManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocationManagerTest.java
new file mode 100644
index 0000000..5f46bba
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLocationManagerTest.java
@@ -0,0 +1,1674 @@
+package org.robolectric.shadows;
+
+import static android.location.LocationManager.GPS_PROVIDER;
+import static android.location.LocationManager.MODE_CHANGED_ACTION;
+import static android.location.LocationManager.NETWORK_PROVIDER;
+import static android.location.LocationManager.PASSIVE_PROVIDER;
+import static android.location.LocationManager.PROVIDERS_CHANGED_ACTION;
+import static android.provider.Settings.Secure.LOCATION_MODE;
+import static android.provider.Settings.Secure.LOCATION_MODE_BATTERY_SAVING;
+import static android.provider.Settings.Secure.LOCATION_MODE_HIGH_ACCURACY;
+import static android.provider.Settings.Secure.LOCATION_MODE_OFF;
+import static android.provider.Settings.Secure.LOCATION_MODE_SENSORS_ONLY;
+import static android.provider.Settings.Secure.LOCATION_PROVIDERS_ALLOWED;
+import static androidx.test.ext.truth.location.LocationCorrespondences.equality;
+import static androidx.test.ext.truth.location.LocationSubject.assertThat;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.location.Criteria;
+import android.location.GnssAntennaInfo;
+import android.location.GnssAntennaInfo.PhaseCenterOffset;
+import android.location.GnssStatus;
+import android.location.GpsStatus;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.location.LocationProvider;
+import android.location.LocationRequest;
+import android.location.OnNmeaMessageListener;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Process;
+import android.provider.Settings.Secure;
+import android.text.TextUtils;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Consumer;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.shadows.ShadowLocationManager.ProviderProperties;
+import org.robolectric.shadows.ShadowLocationManager.RoboLocationRequest;
+
+/** Tests for {@link ShadowLocationManager}. */
+@SuppressWarnings("deprecation")
+@RunWith(AndroidJUnit4.class)
+@LooperMode(Mode.PAUSED)
+public class ShadowLocationManagerTest {
+
+  private static final String MY_PROVIDER = "myProvider";
+
+  private LocationManager locationManager;
+  private ShadowLocationManager shadowLocationManager;
+  private Application context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
+    shadowLocationManager = shadowOf(locationManager);
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.O)
+  public void testInitializationState_PreP() {
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_SENSORS_ONLY);
+    assertThat(getProvidersAllowed()).containsExactly(GPS_PROVIDER);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void testInitializationState_PPlus() {
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    assertThat(getLocationMode()).isNotEqualTo(LOCATION_MODE_OFF);
+    assertThat(getProvidersAllowed()).containsExactly(GPS_PROVIDER);
+  }
+
+  @Test
+  public void testGetAllProviders() {
+    assertThat(locationManager.getAllProviders())
+        .containsExactly(GPS_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER);
+    shadowLocationManager.setProviderProperties(MY_PROVIDER, (ProviderProperties) null);
+    assertThat(locationManager.getAllProviders())
+        .containsExactly(MY_PROVIDER, GPS_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.KITKAT)
+  public void testGetProvider() {
+    LocationProvider p;
+
+    p = locationManager.getProvider(GPS_PROVIDER);
+    assertThat(p).isNotNull();
+    assertThat(p.getName()).isEqualTo(GPS_PROVIDER);
+
+    p = locationManager.getProvider(NETWORK_PROVIDER);
+    assertThat(p).isNotNull();
+    assertThat(p.getName()).isEqualTo(NETWORK_PROVIDER);
+
+    p = locationManager.getProvider(PASSIVE_PROVIDER);
+    assertThat(p).isNotNull();
+    assertThat(p.getName()).isEqualTo(PASSIVE_PROVIDER);
+
+    shadowLocationManager.setProviderProperties(
+        MY_PROVIDER,
+        new ProviderProperties(
+            true,
+            false,
+            true,
+            false,
+            true,
+            false,
+            true,
+            Criteria.POWER_HIGH,
+            Criteria.ACCURACY_COARSE));
+
+    p = locationManager.getProvider(MY_PROVIDER);
+    assertThat(p).isNotNull();
+    assertThat(p.getName()).isEqualTo(MY_PROVIDER);
+    assertThat(p.requiresNetwork()).isTrue();
+    assertThat(p.requiresSatellite()).isFalse();
+    assertThat(p.requiresCell()).isTrue();
+    assertThat(p.hasMonetaryCost()).isFalse();
+    assertThat(p.supportsAltitude()).isTrue();
+    assertThat(p.supportsSpeed()).isFalse();
+    assertThat(p.supportsBearing()).isTrue();
+    assertThat(p.getPowerRequirement()).isEqualTo(Criteria.POWER_HIGH);
+    assertThat(p.getAccuracy()).isEqualTo(Criteria.ACCURACY_COARSE);
+
+    p = locationManager.getProvider("noProvider");
+    assertThat(p).isNull();
+  }
+
+  @Test
+  public void testGetProviders_Enabled() {
+    assertThat(locationManager.getProviders(false))
+        .containsExactly(GPS_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER);
+    assertThat(locationManager.getProviders(true)).containsExactly(GPS_PROVIDER, PASSIVE_PROVIDER);
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    assertThat(locationManager.getProviders(false))
+        .containsExactly(MY_PROVIDER, GPS_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER);
+    assertThat(locationManager.getProviders(true))
+        .containsExactly(MY_PROVIDER, NETWORK_PROVIDER, PASSIVE_PROVIDER);
+  }
+
+  @Test
+  public void testGetProviders_Criteria() {
+    Criteria network = new Criteria();
+    network.setPowerRequirement(Criteria.POWER_LOW);
+
+    Criteria gps = new Criteria();
+    gps.setAccuracy(Criteria.ACCURACY_FINE);
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, true);
+
+    assertThat(locationManager.getProviders(network, true)).containsExactly(NETWORK_PROVIDER);
+    assertThat(locationManager.getProviders(gps, true)).containsExactly(GPS_PROVIDER);
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, false);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+
+    assertThat(locationManager.getProviders(network, true)).isEmpty();
+    assertThat(locationManager.getProviders(gps, true)).isEmpty();
+    assertThat(locationManager.getProviders(network, false)).containsExactly(NETWORK_PROVIDER);
+    assertThat(locationManager.getProviders(gps, false)).containsExactly(GPS_PROVIDER);
+  }
+
+  @Test
+  public void testGetBestProvider() {
+    Criteria all = new Criteria();
+
+    Criteria none = new Criteria();
+    none.setPowerRequirement(Criteria.POWER_LOW);
+    none.setAccuracy(Criteria.ACCURACY_FINE);
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, true);
+    assertThat(locationManager.getBestProvider(all, true)).isEqualTo(GPS_PROVIDER);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+    assertThat(locationManager.getBestProvider(all, true)).isEqualTo(NETWORK_PROVIDER);
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, false);
+    shadowLocationManager.setProviderEnabled(PASSIVE_PROVIDER, false);
+    assertThat(locationManager.getBestProvider(all, true)).isEqualTo(MY_PROVIDER);
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(PASSIVE_PROVIDER, true);
+    assertThat(locationManager.getBestProvider(none, true)).isEqualTo(GPS_PROVIDER);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+    assertThat(locationManager.getBestProvider(none, true)).isEqualTo(NETWORK_PROVIDER);
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, false);
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, false);
+    assertThat(locationManager.getBestProvider(none, true)).isEqualTo(PASSIVE_PROVIDER);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testGetProviderProperties() {
+    android.location.provider.ProviderProperties p;
+
+    shadowLocationManager.setProviderProperties(
+        MY_PROVIDER,
+        new ProviderProperties(
+            true,
+            false,
+            true,
+            false,
+            true,
+            false,
+            true,
+            Criteria.POWER_HIGH,
+            Criteria.ACCURACY_COARSE));
+
+    p = locationManager.getProviderProperties(MY_PROVIDER);
+    assertThat(p).isNotNull();
+    assertThat(p.hasNetworkRequirement()).isTrue();
+    assertThat(p.hasSatelliteRequirement()).isFalse();
+    assertThat(p.hasCellRequirement()).isTrue();
+    assertThat(p.hasMonetaryCost()).isFalse();
+    assertThat(p.hasAltitudeSupport()).isTrue();
+    assertThat(p.hasSpeedSupport()).isFalse();
+    assertThat(p.hasBearingSupport()).isTrue();
+    assertThat(p.getPowerUsage()).isEqualTo(Criteria.POWER_HIGH);
+    assertThat(p.getAccuracy()).isEqualTo(Criteria.ACCURACY_COARSE);
+
+    shadowLocationManager.setProviderProperties(
+        MY_PROVIDER,
+        new ProviderProperties(
+            false,
+            true,
+            false,
+            true,
+            false,
+            true,
+            false,
+            android.location.provider.ProviderProperties.POWER_USAGE_LOW,
+            android.location.provider.ProviderProperties.ACCURACY_FINE));
+
+    p = locationManager.getProviderProperties(MY_PROVIDER);
+    assertThat(p).isNotNull();
+    assertThat(p.hasNetworkRequirement()).isFalse();
+    assertThat(p.hasSatelliteRequirement()).isTrue();
+    assertThat(p.hasCellRequirement()).isFalse();
+    assertThat(p.hasMonetaryCost()).isTrue();
+    assertThat(p.hasAltitudeSupport()).isFalse();
+    assertThat(p.hasSpeedSupport()).isTrue();
+    assertThat(p.hasBearingSupport()).isFalse();
+    assertThat(p.getPowerUsage())
+        .isEqualTo(android.location.provider.ProviderProperties.POWER_USAGE_LOW);
+    assertThat(p.getAccuracy())
+        .isEqualTo(android.location.provider.ProviderProperties.ACCURACY_FINE);
+
+    p = locationManager.getProviderProperties("noProvider");
+    assertThat(p).isNull();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testHasProvider() {
+    assertThat(locationManager.hasProvider(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.hasProvider(NETWORK_PROVIDER)).isTrue();
+    assertThat(locationManager.hasProvider(PASSIVE_PROVIDER)).isTrue();
+
+    assertThat(locationManager.hasProvider(MY_PROVIDER)).isFalse();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    assertThat(locationManager.hasProvider(MY_PROVIDER)).isTrue();
+  }
+
+  @Test
+  public void testIsProviderEnabled() {
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isFalse();
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, false);
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isFalse();
+  }
+
+  @Test
+  public void testIsProviderEnabled_Passive() {
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    shadowLocationManager.setProviderEnabled(PASSIVE_PROVIDER, false);
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isFalse();
+    shadowLocationManager.setProviderEnabled(PASSIVE_PROVIDER, true);
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.O)
+  public void testSetProviderEnabled_Mode() {
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, false);
+    assertNotBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_OFF);
+
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, true);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, false);
+    assertNotBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_SENSORS_ONLY);
+
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_BATTERY_SAVING);
+
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, true);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    assertNotBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_HIGH_ACCURACY);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testSetProviderEnabled_RPlus() {
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setProviderEnabled(NETWORK_PROVIDER, true);
+    assertBroadcast(new Intent(PROVIDERS_CHANGED_ACTION));
+    assertNotBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+  }
+
+  @Test
+  public void testSetProviderEnabled_Listeners() {
+    TestLocationListener myListener = new TestLocationListener();
+    TestLocationReceiver gpsListener = new TestLocationReceiver(context);
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, myListener, null);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, gpsListener.pendingIntent);
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, false);
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, true);
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(myListener.providerEnableds).containsExactly(false, true, false, true).inOrder();
+    assertThat(gpsListener.providerEnableds).containsExactly(false).inOrder();
+  }
+
+  @Test
+  public void testRemoveProvider() {
+    TestLocationListener myListener = new TestLocationListener();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, myListener, null);
+
+    shadowLocationManager.removeProvider(MY_PROVIDER);
+
+    shadowLocationManager.simulateLocation(createLocation(MY_PROVIDER));
+    assertThat(myListener.locations).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void testSetLocationEnabled() {
+    shadowLocationManager.setLocationEnabled(false);
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setLocationEnabled(false);
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+  }
+
+  @Test
+  @Config(sdk = VERSION_CODES.P)
+  public void testIsLocationEnabled_POnly() {
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    shadowLocationManager.setLocationEnabled(false);
+    assertThat(locationManager.isLocationEnabled()).isFalse();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_OFF);
+
+    shadowLocationManager.setLocationEnabled(true);
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isTrue();
+    assertThat(getLocationMode()).isNotEqualTo(LOCATION_MODE_OFF);
+
+    locationManager.setLocationEnabledForUser(false, Process.myUserHandle());
+    assertThat(locationManager.isLocationEnabled()).isFalse();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_OFF);
+
+    locationManager.setLocationEnabledForUser(true, Process.myUserHandle());
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isTrue();
+    assertThat(getLocationMode()).isNotEqualTo(LOCATION_MODE_OFF);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void testIsLocationEnabled_QPlus() {
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    shadowLocationManager.setLocationEnabled(false);
+    assertThat(locationManager.isLocationEnabled()).isFalse();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_OFF);
+
+    shadowLocationManager.setLocationEnabled(true);
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isNotEqualTo(LOCATION_MODE_OFF);
+
+    locationManager.setLocationEnabledForUser(false, Process.myUserHandle());
+    assertThat(locationManager.isLocationEnabled()).isFalse();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_OFF);
+
+    locationManager.setLocationEnabledForUser(true, Process.myUserHandle());
+    assertThat(locationManager.isLocationEnabled()).isTrue();
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isNotEqualTo(LOCATION_MODE_OFF);
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.O)
+  public void testSetLocationMode() {
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    shadowLocationManager.setLocationMode(LOCATION_MODE_OFF);
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_OFF);
+    assertThat(getProvidersAllowed()).containsExactly(MY_PROVIDER);
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setLocationMode(LOCATION_MODE_SENSORS_ONLY);
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isFalse();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_SENSORS_ONLY);
+    assertThat(getProvidersAllowed()).containsExactly(MY_PROVIDER, GPS_PROVIDER);
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setLocationMode(LOCATION_MODE_BATTERY_SAVING);
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isFalse();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isTrue();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_BATTERY_SAVING);
+    assertThat(getProvidersAllowed()).containsExactly(MY_PROVIDER, NETWORK_PROVIDER);
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowLocationManager.setLocationMode(LOCATION_MODE_HIGH_ACCURACY);
+    assertThat(locationManager.isProviderEnabled(MY_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(PASSIVE_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(GPS_PROVIDER)).isTrue();
+    assertThat(locationManager.isProviderEnabled(NETWORK_PROVIDER)).isTrue();
+    assertThat(getLocationMode()).isEqualTo(LOCATION_MODE_HIGH_ACCURACY);
+    assertThat(getProvidersAllowed()).containsExactly(MY_PROVIDER, GPS_PROVIDER, NETWORK_PROVIDER);
+    assertBroadcast(new Intent(MODE_CHANGED_ACTION));
+    shadowOf(context).clearBroadcastIntents();
+  }
+
+  @Test
+  public void testGetLastKnownLocation() {
+    Location loc;
+
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isNull();
+    assertThat(locationManager.getLastKnownLocation(PASSIVE_PROVIDER)).isNull();
+    assertThat(locationManager.getLastKnownLocation(GPS_PROVIDER)).isNull();
+    assertThat(locationManager.getLastKnownLocation(NETWORK_PROVIDER)).isNull();
+
+    loc = createLocation(GPS_PROVIDER);
+    shadowLocationManager.simulateLocation(loc);
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isNull();
+    assertThat(locationManager.getLastKnownLocation(PASSIVE_PROVIDER)).isEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(GPS_PROVIDER)).isEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(NETWORK_PROVIDER)).isNull();
+
+    loc = createLocation(MY_PROVIDER);
+    shadowLocationManager.simulateLocation(loc);
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(PASSIVE_PROVIDER)).isEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(GPS_PROVIDER)).isNotEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(NETWORK_PROVIDER)).isNull();
+
+    shadowLocationManager.setLastKnownLocation(PASSIVE_PROVIDER, null);
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(PASSIVE_PROVIDER)).isNull();
+    assertThat(locationManager.getLastKnownLocation(GPS_PROVIDER)).isNotEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(NETWORK_PROVIDER)).isNull();
+
+    loc = createLocation(NETWORK_PROVIDER);
+    shadowLocationManager.setLastKnownLocation(NETWORK_PROVIDER, loc);
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isNotEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(PASSIVE_PROVIDER)).isNull();
+    assertThat(locationManager.getLastKnownLocation(GPS_PROVIDER)).isNotEqualTo(loc);
+    assertThat(locationManager.getLastKnownLocation(NETWORK_PROVIDER)).isEqualTo(loc);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testGetCurrentLocation() {
+    Location loc = createLocation(MY_PROVIDER);
+
+    TestLocationConsumer consumer = new TestLocationConsumer();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    locationManager.getCurrentLocation(MY_PROVIDER, null, Runnable::run, consumer);
+
+    shadowLocationManager.simulateLocation(loc);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(consumer.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testGetCurrentLocation_LocationRequest() {
+    Location loc = createLocation(MY_PROVIDER);
+
+    TestLocationConsumer consumer = new TestLocationConsumer();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    locationManager.getCurrentLocation(
+        MY_PROVIDER, new LocationRequest.Builder(0).build(), null, Runnable::run, consumer);
+
+    shadowLocationManager.simulateLocation(loc);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(consumer.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testGetCurrentLocation_ProviderDisabled() {
+    TestLocationConsumer consumer1 = new TestLocationConsumer();
+    TestLocationConsumer consumer2 = new TestLocationConsumer();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, false);
+
+    locationManager.getCurrentLocation(GPS_PROVIDER, null, Runnable::run, consumer1);
+    locationManager.getCurrentLocation(MY_PROVIDER, null, Runnable::run, consumer2);
+
+    shadowLocationManager.setProviderEnabled(GPS_PROVIDER, false);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(consumer1.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly((Location) null)
+        .inOrder();
+    assertThat(consumer2.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly((Location) null)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testGetCurrentLocation_Timeout() {
+    TestLocationConsumer consumer = new TestLocationConsumer();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    locationManager.getCurrentLocation(MY_PROVIDER, null, Runnable::run, consumer);
+
+    shadowOf(Looper.getMainLooper())
+        .idleFor(shadowOf(Looper.getMainLooper()).getLastScheduledTaskTime());
+
+    assertThat(consumer.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly((Location) null)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testGetCurrentLocation_Cancel() {
+    Location loc = createLocation(MY_PROVIDER);
+
+    TestLocationConsumer consumer = new TestLocationConsumer();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    CancellationSignal cs = new CancellationSignal();
+    locationManager.getCurrentLocation(MY_PROVIDER, cs, Runnable::run, consumer);
+
+    cs.cancel();
+    shadowLocationManager.simulateLocation(loc);
+
+    assertThat(consumer.locations).isEmpty();
+  }
+
+  @Test
+  public void testRequestSingleUpdate_Provider_Listener() {
+    Location loc1 = createLocation(GPS_PROVIDER);
+    Location loc2 = createLocation(MY_PROVIDER);
+
+    TestLocationListener gpsListener = new TestLocationListener();
+    TestLocationListener myListener = new TestLocationListener();
+    TestLocationListener passiveListener = new TestLocationListener();
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    locationManager.requestSingleUpdate(GPS_PROVIDER, gpsListener, null);
+    locationManager.requestSingleUpdate(MY_PROVIDER, myListener, null);
+    locationManager.requestSingleUpdate(PASSIVE_PROVIDER, passiveListener, null);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(gpsListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1)
+        .inOrder();
+    assertThat(myListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc2)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestSingleUpdate_Provider_PendingIntent() {
+    Location loc1 = createLocation(GPS_PROVIDER);
+    Location loc2 = createLocation(MY_PROVIDER);
+
+    TestLocationReceiver gpsListener = new TestLocationReceiver(context);
+    TestLocationReceiver myListener = new TestLocationReceiver(context);
+    TestLocationReceiver passiveListener = new TestLocationReceiver(context);
+
+    shadowLocationManager.setProviderEnabled(MY_PROVIDER, true);
+
+    locationManager.requestSingleUpdate(GPS_PROVIDER, gpsListener.pendingIntent);
+    locationManager.requestSingleUpdate(MY_PROVIDER, myListener.pendingIntent);
+    locationManager.requestSingleUpdate(PASSIVE_PROVIDER, passiveListener.pendingIntent);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(gpsListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1)
+        .inOrder();
+    assertThat(myListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc2)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_Provider_Listener() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationListener networkListener = new TestLocationListener();
+    TestLocationListener passiveListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(NETWORK_PROVIDER, 0, 0, networkListener, null);
+    locationManager.requestLocationUpdates(PASSIVE_PROVIDER, 0, 0, passiveListener, null);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(passiveListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2, loc3)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testRequestLocationUpdates_Provider_Listener_Executor() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationListener networkListener = new TestLocationListener();
+    TestLocationListener passiveListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(NETWORK_PROVIDER, 0, 0, Runnable::run, networkListener);
+    locationManager.requestLocationUpdates(PASSIVE_PROVIDER, 0, 0, Runnable::run, passiveListener);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(passiveListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2, loc3)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_Provider_PendingIntent() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationReceiver networkListener = new TestLocationReceiver(context);
+    TestLocationReceiver passiveListener = new TestLocationReceiver(context);
+
+    locationManager.requestLocationUpdates(NETWORK_PROVIDER, 0, 0, networkListener.pendingIntent);
+    locationManager.requestLocationUpdates(PASSIVE_PROVIDER, 0, 0, passiveListener.pendingIntent);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener.pendingIntent);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(passiveListener.pendingIntent);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2, loc3)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_Criteria_Listener() {
+    Location loc = createLocation(GPS_PROVIDER);
+    TestLocationListener gpsListener = new TestLocationListener();
+    Criteria gps = new Criteria();
+    gps.setAccuracy(Criteria.ACCURACY_FINE);
+
+    locationManager.requestLocationUpdates(0, 0, gps, gpsListener, null);
+
+    shadowLocationManager.simulateLocation(loc);
+    locationManager.removeUpdates(gpsListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(gpsListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_Criteria_PendingIntent() {
+    Location loc = createLocation(GPS_PROVIDER);
+    TestLocationReceiver gpsReceiver = new TestLocationReceiver(context);
+    Criteria gps = new Criteria();
+    gps.setAccuracy(Criteria.ACCURACY_FINE);
+
+    locationManager.requestLocationUpdates(0, 0, gps, gpsReceiver.pendingIntent);
+
+    shadowLocationManager.simulateLocation(loc);
+    locationManager.removeUpdates(gpsReceiver.pendingIntent);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(gpsReceiver.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void testRequestLocationUpdates_LocationRequest() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationListener networkListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(
+        LocationRequest.createFromDeprecatedProvider(NETWORK_PROVIDER, 0, 0, false),
+        networkListener,
+        null);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener);
+    shadowLocationManager.simulateLocation(loc3);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testRequestLocationUpdates_LocationRequest_Executor() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationListener networkListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(
+        LocationRequest.createFromDeprecatedProvider(NETWORK_PROVIDER, 0, 0, false),
+        Runnable::run,
+        networkListener);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener);
+    shadowLocationManager.simulateLocation(loc3);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testRequestLocationUpdates_Provider_LocationRequest_Executor() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationListener networkListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(
+        NETWORK_PROVIDER, new LocationRequest.Builder(0).build(), Runnable::run, networkListener);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener);
+    shadowLocationManager.simulateLocation(loc3);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testRequestLocationUpdates_Provider_LocationRequest_PendingIntent() {
+    Location loc1 = createLocation(NETWORK_PROVIDER);
+    Location loc2 = createLocation(NETWORK_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+    TestLocationReceiver networkListener = new TestLocationReceiver(context);
+    TestLocationReceiver passiveListener = new TestLocationReceiver(context);
+
+    locationManager.requestLocationUpdates(
+        NETWORK_PROVIDER, new LocationRequest.Builder(0).build(), networkListener.pendingIntent);
+    locationManager.requestLocationUpdates(
+        PASSIVE_PROVIDER, new LocationRequest.Builder(0).build(), passiveListener.pendingIntent);
+
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(networkListener.pendingIntent);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(passiveListener.pendingIntent);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(networkListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2, loc3)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_MultipleProviders_Listener() {
+    Location loc1 = createLocation(MY_PROVIDER);
+    Location loc2 = createLocation(GPS_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+
+    TestLocationListener myListener = new TestLocationListener();
+    TestLocationListener passiveListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, myListener, null);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, myListener, null);
+    locationManager.requestLocationUpdates(PASSIVE_PROVIDER, 0, 0, passiveListener, null);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(myListener);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(passiveListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(myListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2, loc3, loc1, loc2)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_MultipleProviders_PendingIntent() {
+    Location loc1 = createLocation(MY_PROVIDER);
+    Location loc2 = createLocation(GPS_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+
+    TestLocationReceiver myListener = new TestLocationReceiver(context);
+    TestLocationReceiver passiveListener = new TestLocationReceiver(context);
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, myListener.pendingIntent);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, myListener.pendingIntent);
+    locationManager.requestLocationUpdates(PASSIVE_PROVIDER, 0, 0, passiveListener.pendingIntent);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(myListener.pendingIntent);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(passiveListener.pendingIntent);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(myListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2, loc3, loc1, loc2)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_MultipleProviders_Mixed() {
+    Location loc1 = createLocation(MY_PROVIDER);
+    Location loc2 = createLocation(GPS_PROVIDER);
+    Location loc3 = createLocation(NETWORK_PROVIDER);
+
+    TestLocationReceiver myListener1 = new TestLocationReceiver(context);
+    TestLocationListener myListener2 = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, myListener1.pendingIntent);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, myListener1.pendingIntent);
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, myListener2, null);
+    locationManager.requestLocationUpdates(NETWORK_PROVIDER, 0, 0, myListener2, null);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(myListener1.pendingIntent);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    shadowLocationManager.simulateLocation(loc3);
+    locationManager.removeUpdates(myListener2);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(myListener1.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc2)
+        .inOrder();
+    assertThat(myListener2.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1, loc3, loc1, loc3)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_DoublePassive() {
+    Location loc = createLocation(PASSIVE_PROVIDER);
+    TestLocationListener passiveListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(PASSIVE_PROVIDER, 0, 0, passiveListener);
+    shadowLocationManager.simulateLocation(loc);
+    locationManager.removeUpdates(passiveListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_Duplicate() {
+    Location loc = createLocation(GPS_PROVIDER);
+    TestLocationListener passiveListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, passiveListener);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, passiveListener);
+    shadowLocationManager.simulateLocation(loc);
+    locationManager.removeUpdates(passiveListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(passiveListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  public void testRequestLocationUpdates_SelfRemoval() {
+    Location loc = createLocation(NETWORK_PROVIDER);
+
+    TestLocationListener listener = new TestLocationListenerSelfRemoval(locationManager);
+
+    locationManager.requestLocationUpdates(NETWORK_PROVIDER, 0, 0, listener);
+    shadowLocationManager.simulateLocation(loc);
+    shadowOf(Looper.getMainLooper()).idle();
+    shadowLocationManager.simulateLocation(loc);
+    locationManager.removeUpdates(listener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(listener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc)
+        .inOrder();
+  }
+
+  @Test
+  public void testSimulateLocation_FastestInterval() {
+    Location loc1 = createLocation(MY_PROVIDER);
+    loc1.setTime(1);
+    Location loc2 = createLocation(MY_PROVIDER);
+    loc2.setTime(10);
+
+    TestLocationListener myListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 10, 0, myListener);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(myListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(myListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1)
+        .inOrder();
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isEqualTo(loc2);
+  }
+
+  @Test
+  public void testSimulateLocation_MinDistance() {
+    Location loc1 = createLocation(MY_PROVIDER, 1, 2);
+    Location loc2 = createLocation(MY_PROVIDER, 1.5, 2.5);
+
+    TestLocationListener myListener = new TestLocationListener();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 200000, myListener);
+    shadowLocationManager.simulateLocation(loc1);
+    shadowLocationManager.simulateLocation(loc2);
+    locationManager.removeUpdates(myListener);
+
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(myListener.locations)
+        .comparingElementsUsing(equality())
+        .containsExactly(loc1)
+        .inOrder();
+    assertThat(locationManager.getLastKnownLocation(MY_PROVIDER)).isEqualTo(loc2);
+  }
+
+  @Test
+  public void testLocationUpdates_NullListener() {
+    try {
+      locationManager.requestSingleUpdate(GPS_PROVIDER, null, null);
+      fail();
+    } catch (Exception e) {
+      // pass
+    }
+
+    try {
+      locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, (LocationListener) null, null);
+      fail();
+    } catch (Exception e) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testLocationUpdates_NullPendingIntent() {
+    try {
+      locationManager.requestSingleUpdate(GPS_PROVIDER, null);
+      fail();
+    } catch (Exception e) {
+      // pass
+    }
+
+    try {
+      locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, (PendingIntent) null);
+      fail();
+    } catch (Exception e) {
+      // pass
+    }
+  }
+
+  @Test
+  public void testGetLocationUpdateListeners() {
+    TestLocationListener listener1 = new TestLocationListener();
+    TestLocationListener listener2 = new TestLocationListener();
+
+    assertThat(shadowLocationManager.getLocationUpdateListeners()).isEmpty();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, listener1);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener1);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener2);
+    assertThat(shadowLocationManager.getLocationUpdateListeners())
+        .containsExactly(listener1, listener2);
+  }
+
+  @Test
+  public void testGetLocationUpdateListeners_Provider() {
+    TestLocationListener listener1 = new TestLocationListener();
+    TestLocationListener listener2 = new TestLocationListener();
+
+    assertThat(shadowLocationManager.getLocationUpdateListeners(MY_PROVIDER)).isEmpty();
+    assertThat(shadowLocationManager.getLocationUpdateListeners(GPS_PROVIDER)).isEmpty();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, listener1);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener1);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener2);
+    assertThat(shadowLocationManager.getLocationUpdateListeners(MY_PROVIDER))
+        .containsExactly(listener1);
+    assertThat(shadowLocationManager.getLocationUpdateListeners(GPS_PROVIDER))
+        .containsExactly(listener1, listener2);
+
+    locationManager.removeUpdates(listener1);
+    locationManager.removeUpdates(listener2);
+    assertThat(shadowLocationManager.getLocationUpdateListeners(MY_PROVIDER)).isEmpty();
+    assertThat(shadowLocationManager.getLocationUpdateListeners(GPS_PROVIDER)).isEmpty();
+  }
+
+  @Test
+  public void testGetLocationUpdatePendingIntents() {
+    TestLocationReceiver listener1 = new TestLocationReceiver(context);
+    TestLocationReceiver listener2 = new TestLocationReceiver(context);
+
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents()).isEmpty();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, listener1.pendingIntent);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener1.pendingIntent);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener2.pendingIntent);
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents())
+        .containsExactly(listener1.pendingIntent, listener2.pendingIntent);
+  }
+
+  @Test
+  public void testGetLocationUpdatePendingIntents_Duplicate() {
+    Intent intent = new Intent("myAction");
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, intent, 0);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, intent, 0);
+
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents()).isEmpty();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, pendingIntent1);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, pendingIntent1);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, pendingIntent2);
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents())
+        .containsExactly(pendingIntent2);
+
+    locationManager.removeUpdates(pendingIntent1);
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents()).isEmpty();
+  }
+
+  @Test
+  public void testGetLocationUpdatePendingIntents_Provider() {
+    TestLocationReceiver listener1 = new TestLocationReceiver(context);
+    TestLocationReceiver listener2 = new TestLocationReceiver(context);
+
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents(MY_PROVIDER)).isEmpty();
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents(GPS_PROVIDER)).isEmpty();
+
+    locationManager.requestLocationUpdates(MY_PROVIDER, 0, 0, listener1.pendingIntent);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener1.pendingIntent);
+    locationManager.requestLocationUpdates(GPS_PROVIDER, 0, 0, listener2.pendingIntent);
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents(MY_PROVIDER))
+        .containsExactly(listener1.pendingIntent);
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents(GPS_PROVIDER))
+        .containsExactly(listener1.pendingIntent, listener2.pendingIntent);
+
+    locationManager.removeUpdates(listener1.pendingIntent);
+    locationManager.removeUpdates(listener2.pendingIntent);
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents(MY_PROVIDER)).isEmpty();
+    assertThat(shadowLocationManager.getLocationUpdatePendingIntents(GPS_PROVIDER)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void testGetGnssHardwareModelName() {
+    assertThat(locationManager.getGnssHardwareModelName()).isNull();
+    shadowLocationManager.setGnssHardwareModelName("test");
+    assertThat(locationManager.getGnssHardwareModelName()).isEqualTo("test");
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.P)
+  public void testGetGnssYearOfHardware() {
+    assertThat(locationManager.getGnssYearOfHardware()).isEqualTo(0);
+    shadowLocationManager.setGnssYearOfHardware(3000);
+    assertThat(locationManager.getGnssYearOfHardware()).isEqualTo(3000);
+  }
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.R)
+  public void testGpsStatusListener() {
+    GpsStatus.Listener listener = mock(GpsStatus.Listener.class);
+
+    assertThat(shadowLocationManager.getGpsStatusListeners()).isEmpty();
+    locationManager.addGpsStatusListener(listener);
+    assertThat(shadowLocationManager.getGpsStatusListeners()).containsExactly(listener);
+    locationManager.removeGpsStatusListener(listener);
+    assertThat(shadowLocationManager.getGpsStatusListeners()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testGnssStatusCallback() {
+    GnssStatus.Callback listener1 = mock(GnssStatus.Callback.class);
+    GnssStatus.Callback listener2 = mock(GnssStatus.Callback.class);
+    InOrder inOrder1 = Mockito.inOrder(listener1);
+    InOrder inOrder2 = Mockito.inOrder(listener2);
+
+    GnssStatus status1 = new GnssStatus.Builder().build();
+    GnssStatus status2 = new GnssStatus.Builder().build();
+
+    locationManager.registerGnssStatusCallback(listener1, new Handler(Looper.getMainLooper()));
+    locationManager.registerGnssStatusCallback(Runnable::run, listener2);
+
+    shadowLocationManager.simulateGnssStatusStarted();
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onStarted();
+    inOrder2.verify(listener2).onStarted();
+
+    shadowLocationManager.simulateGnssStatusFirstFix(1);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onFirstFix(1);
+    inOrder2.verify(listener2).onFirstFix(1);
+
+    shadowLocationManager.simulateGnssStatus(status1);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onSatelliteStatusChanged(status1);
+    inOrder2.verify(listener2).onSatelliteStatusChanged(status1);
+
+    locationManager.unregisterGnssStatusCallback(listener2);
+
+    shadowLocationManager.sendGnssStatus(status2);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onSatelliteStatusChanged(status2);
+    inOrder2.verify(listener2, never()).onSatelliteStatusChanged(status2);
+
+    shadowLocationManager.simulateGnssStatusStopped();
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onStopped();
+    inOrder2.verify(listener2, never()).onStopped();
+
+    locationManager.unregisterGnssStatusCallback(listener1);
+
+    shadowLocationManager.sendGnssStatus(status2);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1, never()).onSatelliteStatusChanged(status2);
+    inOrder2.verify(listener2, never()).onSatelliteStatusChanged(status2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.N)
+  public void testGnssNmeaMessageListener() {
+    OnNmeaMessageListener listener1 = mock(OnNmeaMessageListener.class);
+    OnNmeaMessageListener listener2 = mock(OnNmeaMessageListener.class);
+    InOrder inOrder1 = Mockito.inOrder(listener1);
+    InOrder inOrder2 = Mockito.inOrder(listener2);
+
+    locationManager.addNmeaListener(listener1, new Handler(Looper.getMainLooper()));
+    if (VERSION.SDK_INT >= VERSION_CODES.R) {
+      locationManager.addNmeaListener(Runnable::run, listener2);
+    } else {
+      locationManager.addNmeaListener(listener2, new Handler(Looper.getMainLooper()));
+    }
+
+    shadowLocationManager.simulateNmeaMessage("message1", 1);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onNmeaMessage("message1", 1);
+    inOrder2.verify(listener2).onNmeaMessage("message1", 1);
+
+    locationManager.removeNmeaListener(listener2);
+
+    shadowLocationManager.simulateNmeaMessage("message2", 2);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1).onNmeaMessage("message2", 2);
+    inOrder2.verify(listener2, never()).onNmeaMessage("message2", 2);
+
+    locationManager.removeNmeaListener(listener1);
+
+    shadowLocationManager.simulateNmeaMessage("message3", 3);
+    shadowOf(Looper.getMainLooper()).idle();
+    inOrder1.verify(listener1, never()).onNmeaMessage("message3", 3);
+    inOrder2.verify(listener2, never()).onNmeaMessage("message3", 3);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void testGnssAntennaInfoListener() {
+    GnssAntennaInfo.Listener listener1 = mock(GnssAntennaInfo.Listener.class);
+    GnssAntennaInfo.Listener listener2 = mock(GnssAntennaInfo.Listener.class);
+    InOrder inOrder1 = Mockito.inOrder(listener1);
+    InOrder inOrder2 = Mockito.inOrder(listener2);
+
+    List<GnssAntennaInfo> events1 =
+        Collections.singletonList(
+            new GnssAntennaInfo.Builder()
+                .setPhaseCenterOffset(new PhaseCenterOffset(0, 0, 0, 0, 0, 0))
+                .build());
+    List<GnssAntennaInfo> events2 =
+        Collections.singletonList(
+            new GnssAntennaInfo.Builder()
+                .setPhaseCenterOffset(new PhaseCenterOffset(1, 1, 1, 1, 1, 1))
+                .build());
+
+    locationManager.registerAntennaInfoListener(Runnable::run, listener1);
+    locationManager.registerAntennaInfoListener(Runnable::run, listener2);
+
+    shadowLocationManager.simulateGnssAntennaInfo(events1);
+    inOrder1.verify(listener1).onGnssAntennaInfoReceived(events1);
+    inOrder2.verify(listener2).onGnssAntennaInfoReceived(events1);
+
+    locationManager.unregisterAntennaInfoListener(listener2);
+
+    shadowLocationManager.simulateGnssAntennaInfo(events2);
+    inOrder1.verify(listener1).onGnssAntennaInfoReceived(events2);
+    inOrder2.verify(listener2, never()).onGnssAntennaInfoReceived(events2);
+
+    locationManager.unregisterAntennaInfoListener(listener1);
+
+    shadowLocationManager.simulateGnssAntennaInfo(events1);
+    inOrder1.verify(listener1, never()).onGnssAntennaInfoReceived(events1);
+    inOrder2.verify(listener2, never()).onGnssAntennaInfoReceived(events1);
+  }
+
+  @Test
+  public void testRoboLocationRequest_legacyConstructor() {
+    RoboLocationRequest r = new RoboLocationRequest(GPS_PROVIDER, 0, 0, false);
+    assertThat(r.getIntervalMillis()).isEqualTo(0);
+    assertThat(r.getMinUpdateDistanceMeters()).isEqualTo(0);
+    assertThat(r.getMaxUpdates()).isEqualTo(Integer.MAX_VALUE);
+
+    r = new RoboLocationRequest(GPS_PROVIDER, 10000, 10, true);
+    assertThat(r.getIntervalMillis()).isEqualTo(10000);
+    assertThat(r.getMinUpdateDistanceMeters()).isEqualTo(10);
+    assertThat(r.getMaxUpdates()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testRoboLocationRequest_constructor() {
+    RoboLocationRequest r = new RoboLocationRequest(new LocationRequest.Builder(0).build());
+    assertThat(r.getIntervalMillis()).isEqualTo(0);
+    assertThat(r.getMinUpdateDistanceMeters()).isEqualTo(0);
+    assertThat(r.getMaxUpdates()).isEqualTo(Integer.MAX_VALUE);
+
+    r =
+        new RoboLocationRequest(
+            new LocationRequest.Builder(10000)
+                .setMinUpdateDistanceMeters(10)
+                .setMaxUpdates(1)
+                .build());
+    assertThat(r.getIntervalMillis()).isEqualTo(10000);
+    assertThat(r.getMinUpdateDistanceMeters()).isEqualTo(10);
+    assertThat(r.getMaxUpdates()).isEqualTo(1);
+  }
+
+  @Test
+  public void testRoboLocationRequest_legacyEquality() {
+    RoboLocationRequest r1 = new RoboLocationRequest(GPS_PROVIDER, 0, 0, false);
+    RoboLocationRequest r2 = new RoboLocationRequest(GPS_PROVIDER, 0, 0, false);
+    RoboLocationRequest r3 = new RoboLocationRequest(GPS_PROVIDER, 10000, 10, true);
+    RoboLocationRequest r4 = new RoboLocationRequest(GPS_PROVIDER, 10000, 10, true);
+
+    assertThat(r1).isEqualTo(r2);
+    assertThat(r3).isEqualTo(r4);
+    assertThat(r1).isNotEqualTo(r3);
+    assertThat(r4).isNotEqualTo(r2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.S)
+  public void testRoboLocationRequest_equality() {
+    RoboLocationRequest r1 = new RoboLocationRequest(new LocationRequest.Builder(0).build());
+    RoboLocationRequest r2 = new RoboLocationRequest(new LocationRequest.Builder(0).build());
+    RoboLocationRequest r3 =
+        new RoboLocationRequest(
+            new LocationRequest.Builder(10000)
+                .setMinUpdateDistanceMeters(10)
+                .setMaxUpdates(1)
+                .build());
+    RoboLocationRequest r4 =
+        new RoboLocationRequest(
+            new LocationRequest.Builder(10000)
+                .setMinUpdateDistanceMeters(10)
+                .setMaxUpdates(1)
+                .build());
+
+    assertThat(r1).isEqualTo(r2);
+    assertThat(r3).isEqualTo(r4);
+    assertThat(r1).isNotEqualTo(r3);
+    assertThat(r4).isNotEqualTo(r2);
+  }
+
+  private static final Random random = new Random(101);
+
+  private static Location createLocation(String provider) {
+    return createLocation(provider, random.nextDouble() * 180 - 90, random.nextDouble() * 180 - 90);
+  }
+
+  private static Location createLocation(String provider, double lat, double lon) {
+    Location location = new Location(provider);
+    location.setLatitude(lat);
+    location.setLongitude(lon);
+    return location;
+  }
+
+  private int getLocationMode() {
+    return Secure.getInt(context.getContentResolver(), LOCATION_MODE, LOCATION_MODE_OFF);
+  }
+
+  private Set<String> getProvidersAllowed() {
+    String providersAllowed =
+        Secure.getString(context.getContentResolver(), LOCATION_PROVIDERS_ALLOWED);
+    if (TextUtils.isEmpty(providersAllowed)) {
+      return Collections.emptySet();
+    }
+
+    return new HashSet<>(Arrays.asList(providersAllowed.split(",")));
+  }
+
+  private void assertBroadcast(Intent... intents) {
+    for (Intent intent : intents) {
+      boolean found = false;
+      for (Intent broadcast : shadowOf(context).getBroadcastIntents()) {
+        if (broadcast.filterEquals(intent)) {
+          found = true;
+          break;
+        }
+      }
+
+      if (!found) {
+        assertThat(shadowOf(context).getBroadcastIntents()).contains(intent);
+      }
+    }
+  }
+
+  private void assertNotBroadcast(Intent... intents) {
+    for (Intent intent : intents) {
+      for (Intent broadcast : shadowOf(context).getBroadcastIntents()) {
+        if (broadcast.filterEquals(intent)) {
+          assertThat(shadowOf(context).getBroadcastIntents()).doesNotContain(broadcast);
+        }
+      }
+    }
+  }
+
+  private static class TestLocationReceiver extends BroadcastReceiver {
+    private final PendingIntent pendingIntent;
+    private final ArrayList<Boolean> providerEnableds = new ArrayList<>();
+    private final ArrayList<Location> locations = new ArrayList<>();
+
+    private TestLocationReceiver(Context context) {
+      Intent intent = new Intent(Integer.toString(random.nextInt()));
+      pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0);
+      context.registerReceiver(this, new IntentFilter(intent.getAction()));
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      if (intent.hasExtra(LocationManager.KEY_LOCATION_CHANGED)) {
+        locations.add(intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED));
+      }
+      if (intent.hasExtra(LocationManager.KEY_PROVIDER_ENABLED)) {
+        providerEnableds.add(intent.getBooleanExtra(LocationManager.KEY_PROVIDER_ENABLED, false));
+      }
+    }
+  }
+
+  private static class TestLocationConsumer implements Consumer<Location> {
+    final ArrayList<Location> locations = new ArrayList<>();
+
+    @Override
+    public void accept(Location location) {
+      locations.add(location);
+    }
+  }
+
+  private static class TestLocationListener implements LocationListener {
+    final ArrayList<Boolean> providerEnableds = new ArrayList<>();
+    final ArrayList<Location> locations = new ArrayList<>();
+
+    @Override
+    public void onLocationChanged(Location location) {
+      locations.add(location);
+    }
+
+    @Override
+    public void onStatusChanged(String s, int i, Bundle bundle) {
+    }
+
+    @Override
+    public void onProviderEnabled(String s) {
+      providerEnableds.add(true);
+    }
+
+    @Override
+    public void onProviderDisabled(String s) {
+      providerEnableds.add(false);
+    }
+  }
+
+  private static class TestLocationListenerSelfRemoval extends TestLocationListener {
+
+    private final LocationManager locationManager;
+
+    public TestLocationListenerSelfRemoval(LocationManager locationManager) {
+      this.locationManager = locationManager;
+    }
+
+    @Override
+    public void onLocationChanged(Location location) {
+      locationManager.removeUpdates(this);
+      super.onLocationChanged(location);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLogTest.java
new file mode 100644
index 0000000..ee6d271
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLogTest.java
@@ -0,0 +1,328 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.StandardSystemProperty.LINE_SEPARATOR;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.util.Log;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Iterables;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadows.ShadowLog.LogItem;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLogTest {
+
+  @Test
+  public void d_shouldLogAppropriately() {
+    Log.d("tag", "msg");
+
+    assertLogged(Log.DEBUG, "tag", "msg", null);
+  }
+
+  @Test
+  public void d_shouldLogAppropriately_withThrowable() {
+    Throwable throwable = new Throwable();
+
+    Log.d("tag", "msg", throwable);
+
+    assertLogged(Log.DEBUG, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void e_shouldLogAppropriately() {
+    Log.e("tag", "msg");
+
+    assertLogged(Log.ERROR, "tag", "msg", null);
+  }
+
+  @Test
+  public void e_shouldLogAppropriately_withThrowable() {
+    Throwable throwable = new Throwable();
+
+    Log.e("tag", "msg", throwable);
+
+    assertLogged(Log.ERROR, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void i_shouldLogAppropriately() {
+    Log.i("tag", "msg");
+
+    assertLogged(Log.INFO, "tag", "msg", null);
+  }
+
+  @Test
+  public void i_shouldLogAppropriately_withThrowable() {
+    Throwable throwable = new Throwable();
+
+    Log.i("tag", "msg", throwable);
+
+    assertLogged(Log.INFO, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void v_shouldLogAppropriately() {
+    Log.v("tag", "msg");
+
+    assertLogged(Log.VERBOSE, "tag", "msg", null);
+  }
+
+  @Test
+  public void v_shouldLogAppropriately_withThrowable() {
+    Throwable throwable = new Throwable();
+
+    Log.v("tag", "msg", throwable);
+
+    assertLogged(Log.VERBOSE, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void w_shouldLogAppropriately() {
+    Log.w("tag", "msg");
+
+    assertLogged(Log.WARN, "tag", "msg", null);
+  }
+
+  @Test
+  public void w_shouldLogAppropriately_withThrowable() {
+    Throwable throwable = new Throwable();
+
+    Log.w("tag", "msg", throwable);
+
+    assertLogged(Log.WARN, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void w_shouldLogAppropriately_withJustThrowable() {
+    Throwable throwable = new Throwable();
+    Log.w("tag", throwable);
+    assertLogged(Log.WARN, "tag", null, throwable);
+  }
+
+  @Test
+  public void wtf_shouldLogAppropriately() {
+    Log.wtf("tag", "msg");
+
+    assertLogged(Log.ASSERT, "tag", "msg", null);
+  }
+
+  @Test
+  public void wtf_shouldLogAppropriately_withThrowable() {
+    Throwable throwable = new Throwable();
+
+    Log.wtf("tag", "msg", throwable);
+
+    assertLogged(Log.ASSERT, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void wtf_wtfIsFatalIsSet_shouldThrowTerribleFailure() {
+    ShadowLog.setWtfIsFatal(true);
+
+    Throwable throwable = new Throwable();
+    try {
+      Log.wtf("tag", "msg", throwable);
+      fail("TerribleFailure should be thrown");
+    } catch (ShadowLog.TerribleFailure e) {
+      // pass
+    }
+    assertLogged(Log.ASSERT, "tag", "msg", throwable);
+  }
+
+  @Test
+  public void println_shouldLogAppropriately() {
+    int len = Log.println(Log.ASSERT, "tag", "msg");
+    assertLogged(Log.ASSERT, "tag", "msg", null);
+    assertThat(len).isEqualTo(11);
+  }
+
+  @Test
+  public void println_shouldLogNullTagAppropriately() {
+    int len = Log.println(Log.ASSERT, null, "msg");
+    assertLogged(Log.ASSERT, null, "msg", null);
+    assertThat(len).isEqualTo(8);
+  }
+
+  @Test
+  public void println_shouldLogNullMessageAppropriately() {
+    int len = Log.println(Log.ASSERT, "tag", null);
+    assertLogged(Log.ASSERT, "tag", null, null);
+    assertThat(len).isEqualTo(8);
+  }
+
+  @Test
+  public void println_shouldLogNullTagAndNullMessageAppropriately() {
+    int len = Log.println(Log.ASSERT, null, null);
+    assertLogged(Log.ASSERT, null, null, null);
+    assertThat(len).isEqualTo(5);
+  }
+
+  @Test
+  public void shouldLogToProvidedStream() throws Exception {
+    final ByteArrayOutputStream bos = new ByteArrayOutputStream();
+    PrintStream old = ShadowLog.stream;
+    try {
+      ShadowLog.stream = new PrintStream(bos);
+      Log.d("tag", "msg");
+      assertThat(new String(bos.toByteArray(), UTF_8))
+          .isEqualTo("D/tag: msg" + System.getProperty("line.separator"));
+
+      Log.w("tag", new RuntimeException());
+      assertTrue(new String(bos.toByteArray(), UTF_8).contains("RuntimeException"));
+    } finally {
+      ShadowLog.stream = old;
+    }
+  }
+
+  private static RuntimeException specificMethodName() {
+    return new RuntimeException();
+  }
+
+  @Test
+  public void shouldLogAccordingToTag() throws Exception {
+    ShadowLog.reset();
+    Log.d("tag1", "1");
+    Log.i("tag2", "2");
+    Log.e("tag3", "3");
+    Log.w("tag1", "4");
+    Log.i("tag1", "5");
+    Log.d("tag2", "6");
+    Log.d("throwable", "7", specificMethodName());
+
+    List<LogItem> allItems = ShadowLog.getLogs();
+    assertThat(allItems.size()).isEqualTo(7);
+    int i = 1;
+    for (LogItem item : allItems) {
+      assertThat(item.msg).isEqualTo(Integer.toString(i));
+      assertThat(item.toString()).contains(item.msg);
+      i++;
+    }
+    assertThat(allItems.get(6).toString()).contains("specificMethodName");
+    assertUniformLogsForTag("tag1", 3);
+    assertUniformLogsForTag("tag2", 2);
+    assertUniformLogsForTag("tag3", 1);
+  }
+
+  private static void assertUniformLogsForTag(String tag, int count) {
+    List<LogItem> tag1Items = ShadowLog.getLogsForTag(tag);
+    assertThat(tag1Items.size()).isEqualTo(count);
+    int last = -1;
+    for (LogItem item : tag1Items) {
+      assertThat(item.tag).isEqualTo(tag);
+      int current = Integer.parseInt(item.msg);
+      assertThat(current > last).isTrue();
+      last = current;
+    }
+  }
+
+  @Test
+  public void infoIsDefaultLoggableLevel() throws Exception {
+    PrintStream old = ShadowLog.stream;
+    ShadowLog.stream = null;
+    assertFalse(Log.isLoggable("FOO", Log.VERBOSE));
+    assertFalse(Log.isLoggable("FOO", Log.DEBUG));
+
+    assertTrue(Log.isLoggable("FOO", Log.INFO));
+    assertTrue(Log.isLoggable("FOO", Log.WARN));
+    assertTrue(Log.isLoggable("FOO", Log.ERROR));
+    assertTrue(Log.isLoggable("FOO", Log.ASSERT));
+    ShadowLog.stream = old;
+  }
+
+  private static void assertLogged(int type, String tag, String msg, Throwable throwable) {
+    LogItem lastLog = Iterables.getLast(ShadowLog.getLogsForTag(tag));
+    assertEquals(type, lastLog.type);
+    assertEquals(msg, lastLog.msg);
+    assertEquals(tag, lastLog.tag);
+    assertEquals(throwable, lastLog.throwable);
+  }
+
+  private static void assertLogged(
+      String timeString, int type, String tag, String msg, Throwable throwable) {
+    LogItem lastLog = Iterables.getLast(ShadowLog.getLogsForTag(tag));
+    assertEquals(timeString, lastLog.timeString);
+    assertLogged(type, tag, msg, throwable);
+  }
+
+  @Test
+  public void identicalLogItemInstancesAreEqual() {
+    LogItem item1 = new LogItem(Log.VERBOSE, "Foo", "Bar", null);
+    LogItem item2 = new LogItem(Log.VERBOSE, "Foo", "Bar", null);
+    assertThat(item1).isEqualTo(item2);
+    assertThat(item2).isEqualTo(item1);
+  }
+
+  @Test
+  public void logsAfterSetLoggable() {
+    ShadowLog.setLoggable("Foo", Log.VERBOSE);
+    assertTrue(Log.isLoggable("Foo", Log.DEBUG));
+  }
+
+  @Test
+  public void noLogAfterSetLoggable() {
+    PrintStream old = ShadowLog.stream;
+    ShadowLog.stream = new PrintStream(new ByteArrayOutputStream());
+    ShadowLog.setLoggable("Foo", Log.DEBUG);
+    assertFalse(Log.isLoggable("Foo", Log.VERBOSE));
+    ShadowLog.stream = old;
+  }
+
+  @Test
+  public void getLogs_shouldReturnCopy() {
+    Log.d("tag1", "1");
+    assertThat(ShadowLog.getLogs()).isNotSameInstanceAs(ShadowLog.getLogs());
+    assertThat(ShadowLog.getLogs()).isEqualTo(ShadowLog.getLogs());
+  }
+
+  @Test
+  public void getLogsForTag_empty() {
+    assertThat(ShadowLog.getLogsForTag("non_existent")).isEmpty();
+  }
+
+  @Test
+  public void clear() {
+    assertThat(ShadowLog.getLogsForTag("tag1")).isEmpty();
+    Log.d("tag1", "1");
+    assertThat(ShadowLog.getLogsForTag("tag1")).isNotEmpty();
+    ShadowLog.clear();
+    assertThat(ShadowLog.getLogsForTag("tag1")).isEmpty();
+    assertThat(ShadowLog.getLogs()).isEmpty();
+  }
+
+  @Test
+  public void shouldLogTimeWithTimeSupplier() {
+    ShadowLog.setTimeSupplier(() -> "20 July 1969 20:17");
+
+    Log.d("tag", "msg");
+
+    assertLogged("20 July 1969 20:17", Log.DEBUG, "tag", "msg", null);
+  }
+
+  @Test
+  public void shouldLogToProvidedStreamWithTimeSupplier() {
+    ShadowLog.setTimeSupplier(() -> "20 July 1969 20:17");
+
+    ByteArrayOutputStream bos = new ByteArrayOutputStream();
+    PrintStream old = ShadowLog.stream;
+    try {
+      ShadowLog.stream = new PrintStream(bos);
+      Log.d("tag", "msg");
+      assertThat(new String(bos.toByteArray(), UTF_8))
+          .isEqualTo("20 July 1969 20:17 D/tag: msg" + LINE_SEPARATOR.value());
+
+      Log.w("tag", new RuntimeException());
+      assertThat(new String(bos.toByteArray(), UTF_8)).contains("RuntimeException");
+    } finally {
+      ShadowLog.stream = old;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowLruTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowLruTest.java
new file mode 100644
index 0000000..bf9e468
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowLruTest.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.LruCache;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowLruTest {
+
+  @Test
+  public void shouldLru() {
+    LruCache<Integer, String> lruCache = new LruCache<>(2);
+    lruCache.put(1, "one");
+    lruCache.put(2, "two");
+    lruCache.put(3, "three");
+
+    assertThat(lruCache.size()).isEqualTo(2);
+    assertThat(lruCache.get(1)).isNull();
+    assertThat(lruCache.get(2)).isEqualTo("two");
+    assertThat(lruCache.get(3)).isEqualTo("three");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMagnificationControllerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMagnificationControllerTest.java
new file mode 100644
index 0000000..cdd46bc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMagnificationControllerTest.java
@@ -0,0 +1,194 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityService.MagnificationController;
+import android.graphics.Region;
+import android.os.Looper;
+import android.view.accessibility.AccessibilityEvent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/** Test for ShadowMagnificationController. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = N)
+public final class ShadowMagnificationControllerTest {
+
+  private MagnificationController magnificationController;
+
+  @Before
+  public void setUp() {
+    MyService myService = Robolectric.setupService(MyService.class);
+    magnificationController = myService.getMagnificationController();
+  }
+
+  @Test
+  public void getCenterX_byDefault_returns0() {
+    assertThat(magnificationController.getCenterX()).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void getCenterY_byDefault_returns0() {
+    assertThat(magnificationController.getCenterY()).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void getScale_byDefault_returns1() {
+    assertThat(magnificationController.getScale()).isEqualTo(1.0f);
+  }
+
+  @Test
+  public void setCenter_setsCenterX() {
+    float newCenterX = 450.0f;
+
+    magnificationController.setCenter(newCenterX, /*centerY=*/ 0.0f, /*animate=*/ false);
+
+    assertThat(magnificationController.getCenterX()).isEqualTo(newCenterX);
+  }
+
+  @Test
+  public void setCenter_setsCenterY() {
+    float newCenterY = 250.0f;
+
+    magnificationController.setCenter(/*centerX=*/ 0.0f, newCenterY, /*animate=*/ false);
+
+    assertThat(magnificationController.getCenterY()).isEqualTo(newCenterY);
+  }
+
+  @Test
+  public void setCenter_notifiesListener() {
+    float centerX = 55f;
+    float centerY = 22.5f;
+    TestListener testListener = new TestListener();
+    magnificationController.addListener(testListener);
+
+    magnificationController.setCenter(centerX, centerY, /*animate=*/ false);
+
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(testListener.invoked).isTrue();
+    assertThat(testListener.centerX).isEqualTo(centerX);
+    assertThat(testListener.centerY).isEqualTo(centerY);
+  }
+
+  @Test
+  public void setScale_setsScale() {
+    float newScale = 5.0f;
+
+    magnificationController.setScale(newScale, /*animate=*/ false);
+
+    assertThat(magnificationController.getScale()).isEqualTo(newScale);
+  }
+
+  @Test
+  public void setScale_notifiesListener() {
+    float scale = 5.0f;
+    TestListener testListener = new TestListener();
+    magnificationController.addListener(testListener);
+
+    magnificationController.setScale(scale, /*animate=*/ false);
+
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(testListener.invoked).isTrue();
+    assertThat(testListener.scale).isEqualTo(scale);
+  }
+
+  @Test
+  public void reset_resetsCenterX() {
+    magnificationController.setCenter(/*centerX=*/ 100.0f, /*centerY=*/ 0.0f, /*animate=*/ false);
+
+    magnificationController.reset(/*animate=*/ false);
+
+    assertThat(magnificationController.getCenterX()).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void reset_resetsCenterY() {
+    magnificationController.setCenter(/*centerX=*/ 0.0f, /*centerY=*/ 100.0f, /*animate=*/ false);
+
+    magnificationController.reset(/*animate=*/ false);
+
+    assertThat(magnificationController.getCenterY()).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void reset_resetsScale() {
+    magnificationController.setScale(5.0f, /*animate=*/ false);
+
+    magnificationController.reset(/*animate=*/ false);
+
+    assertThat(magnificationController.getScale()).isEqualTo(1.0f);
+  }
+
+  @Test
+  public void reset_notifiesListener() {
+    magnificationController.setCenter(/*centerX=*/ 150.5f, /*centerY=*/ 11.5f, /*animate=*/ false);
+    magnificationController.setScale(/*scale=*/ 5.0f, /*animate=*/ false);
+    TestListener testListener = new TestListener();
+    magnificationController.addListener(testListener);
+
+    magnificationController.reset(/*animate=*/ false);
+
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(testListener.invoked).isTrue();
+    assertThat(testListener.centerX).isEqualTo(0.0f);
+    assertThat(testListener.centerY).isEqualTo(0.0f);
+    assertThat(testListener.scale).isEqualTo(1.0f);
+  }
+
+  @Test
+  public void removeListener_removesListener() {
+    float scale = 5.0f;
+    TestListener testListener = new TestListener();
+    magnificationController.addListener(testListener);
+
+    magnificationController.removeListener(testListener);
+
+    magnificationController.setScale(scale, /*animate=*/ false);
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(testListener.invoked).isFalse();
+  }
+
+  /** Test OnMagnificationChangedListener that records when it's invoked. */
+  private static class TestListener
+      implements MagnificationController.OnMagnificationChangedListener {
+
+    private boolean invoked = false;
+    private float scale = -1f;
+    private float centerX = -1f;
+    private float centerY = -1f;
+
+    @Override
+    public void onMagnificationChanged(
+        MagnificationController controller,
+        Region region,
+        float scale,
+        float centerX,
+        float centerY) {
+      this.invoked = true;
+      this.scale = scale;
+      this.centerX = centerX;
+      this.centerY = centerY;
+    }
+  }
+
+  /** Empty implementation of AccessibilityService, for test purposes. */
+  private static class MyService extends AccessibilityService {
+
+    @Override
+    public void onAccessibilityEvent(AccessibilityEvent arg0) {
+      // Do nothing
+    }
+
+    @Override
+    public void onInterrupt() {
+      // Do nothing
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMarginLayoutParamsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMarginLayoutParamsTest.java
new file mode 100644
index 0000000..e9aedf4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMarginLayoutParamsTest.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.ViewGroup;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMarginLayoutParamsTest {
+
+  @Test
+  public void testSetMargins() {
+    ViewGroup.MarginLayoutParams marginLayoutParams = new ViewGroup.MarginLayoutParams(0, 0);
+    marginLayoutParams.setMargins(1, 2, 3, 4);
+    assertThat(marginLayoutParams.leftMargin).isEqualTo(1);
+    assertThat(marginLayoutParams.topMargin).isEqualTo(2);
+    assertThat(marginLayoutParams.rightMargin).isEqualTo(3);
+    assertThat(marginLayoutParams.bottomMargin).isEqualTo(4);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMatrixCursorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMatrixCursorTest.java
new file mode 100644
index 0000000..019a9e6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMatrixCursorTest.java
@@ -0,0 +1,193 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import android.database.CursorIndexOutOfBoundsException;
+import android.database.MatrixCursor;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMatrixCursorTest {
+
+  private MatrixCursor singleColumnSingleNullValueMatrixCursor;
+
+  @Before
+  public void setUp() throws Exception {
+    singleColumnSingleNullValueMatrixCursor = new MatrixCursor(new String[]{"a"});
+    singleColumnSingleNullValueMatrixCursor.addRow(new Object[]{null});
+    singleColumnSingleNullValueMatrixCursor.moveToFirst();
+  }
+
+  @After
+  public void tearDown() {
+    singleColumnSingleNullValueMatrixCursor.close();
+  }
+
+  @Test
+  public void shouldAddObjectArraysAsRows() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    cursor.addRow(new Object[]{"foo", 10L, 0.1f});
+    cursor.addRow(new Object[]{"baz", 20L, null});
+    assertThat(cursor.getCount()).isEqualTo(2);
+
+    assertTrue(cursor.moveToFirst());
+
+    assertThat(cursor.getString(0)).isEqualTo("foo");
+    assertThat(cursor.getLong(1)).isEqualTo(10L);
+    assertThat(cursor.getFloat(2)).isEqualTo(0.1f);
+
+    assertTrue(cursor.moveToNext());
+
+    assertThat(cursor.getString(0)).isEqualTo("baz");
+    assertThat(cursor.getLong(1)).isEqualTo(20L);
+    assertTrue(cursor.isNull(2));
+
+    assertFalse(cursor.moveToNext());
+    cursor.close();
+  }
+
+  @Test
+  public void shouldAddIterablesAsRows() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    cursor.addRow(Arrays.asList("foo", 10L, 0.1f));
+    cursor.addRow(Arrays.asList("baz", 20L, null));
+    assertThat(cursor.getCount()).isEqualTo(2);
+
+    assertTrue(cursor.moveToFirst());
+
+    assertThat(cursor.getString(0)).isEqualTo("foo");
+    assertThat(cursor.getLong(1)).isEqualTo(10L);
+    assertThat(cursor.getFloat(2)).isEqualTo(0.1f);
+
+    assertTrue(cursor.moveToNext());
+
+    assertThat(cursor.getString(0)).isEqualTo("baz");
+    assertThat(cursor.getLong(1)).isEqualTo(20L);
+    assertTrue(cursor.isNull(2));
+
+    assertFalse(cursor.moveToNext());
+    cursor.close();
+  }
+
+  @Test
+  public void shouldDefineColumnNames() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+
+    assertThat(cursor.getColumnCount()).isEqualTo(3);
+
+    assertThat(cursor.getColumnName(0)).isEqualTo("a");
+    assertThat(cursor.getColumnName(1)).isEqualTo("b");
+    assertThat(cursor.getColumnName(2)).isEqualTo("c");
+
+    assertThat(cursor.getColumnNames()).isEqualTo(new String[]{"a", "b", "c"});
+
+    assertThat(cursor.getColumnIndex("b")).isEqualTo(1);
+    assertThat(cursor.getColumnIndex("z")).isEqualTo(-1);
+    cursor.close();
+  }
+
+  @Test
+  public void shouldDefineGetBlob() {
+    byte[] blob = {1, 2, 3, 4};
+
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a"});
+    cursor.addRow(new Object[]{blob});
+    assertTrue(cursor.moveToFirst());
+
+    assertThat(cursor.getBlob(0)).isEqualTo(blob);
+  }
+
+  @Test
+  public void shouldAllowTypeFlexibility() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    cursor.addRow(new Object[]{42, 3.3, 'a'});
+    assertTrue(cursor.moveToFirst());
+
+    assertThat(cursor.getString(0)).isEqualTo("42");
+    assertThat(cursor.getShort(0)).isEqualTo((short) 42);
+    assertThat(cursor.getInt(0)).isEqualTo(42);
+    assertThat(cursor.getLong(0)).isEqualTo(42L);
+    assertThat(cursor.getFloat(0)).isEqualTo(42.0F);
+    assertThat(cursor.getDouble(0)).isEqualTo(42.0);
+
+    assertThat(cursor.getString(1)).isEqualTo("3.3");
+    assertThat(cursor.getShort(1)).isEqualTo((short) 3);
+    assertThat(cursor.getInt(1)).isEqualTo(3);
+    assertThat(cursor.getLong(1)).isEqualTo(3L);
+    assertThat(cursor.getFloat(1)).isEqualTo(3.3F);
+    assertThat(cursor.getDouble(1)).isEqualTo(3.3);
+
+    assertThat(cursor.getString(2)).isEqualTo("a");
+    cursor.close();
+  }
+
+  @Test
+  public void shouldDefineGetColumnNameOrThrow() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    assertThrows(IllegalArgumentException.class, () -> cursor.getColumnIndexOrThrow("z"));
+    cursor.close();
+  }
+
+  @Test
+  public void shouldThrowIndexOutOfBoundsExceptionWithoutData() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    assertThrows(CursorIndexOutOfBoundsException.class, () -> cursor.getString(0));
+    cursor.close();
+  }
+
+  @Test
+  public void shouldThrowIndexOutOfBoundsExceptionForInvalidColumn() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    cursor.addRow(new Object[]{"foo", 10L, 0.1f});
+    assertThrows(CursorIndexOutOfBoundsException.class, () -> cursor.getString(3));
+    cursor.close();
+  }
+
+  @Test
+  public void shouldThrowIndexOutOfBoundsExceptionForInvalidColumnLastRow() {
+    MatrixCursor cursor = new MatrixCursor(new String[]{"a", "b", "c"});
+    cursor.addRow(new Object[]{"foo", 10L, 0.1f});
+    cursor.moveToFirst();
+    cursor.moveToNext();
+    assertThrows(CursorIndexOutOfBoundsException.class, () -> cursor.getString(0));
+    cursor.close();
+  }
+
+  @Test
+  public void returnsNullWhenGettingStringFromNullColumn() {
+    assertThat(singleColumnSingleNullValueMatrixCursor.getString(0)).isNull();
+  }
+
+  @Test
+  public void returnsZeroWhenGettingIntFromNullColumn() {
+    assertThat(singleColumnSingleNullValueMatrixCursor.getInt(0)).isEqualTo(0);
+  }
+
+  @Test
+  public void returnsZeroWhenGettingLongFromNullColumn() {
+    assertThat(singleColumnSingleNullValueMatrixCursor.getLong(0)).isEqualTo(0L);
+  }
+
+  @Test
+  public void returnsZeroWhenGettingShortFromNullColumn() {
+    assertThat(singleColumnSingleNullValueMatrixCursor.getShort(0)).isEqualTo((short) 0);
+  }
+
+  @Test
+  public void returnsZeroWhenGettingFloatFromNullColumn() {
+    assertThat(singleColumnSingleNullValueMatrixCursor.getFloat(0)).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void returnsZeroWhenGettingDoubleFromNullColumn() {
+    assertThat(singleColumnSingleNullValueMatrixCursor.getDouble(0)).isEqualTo(0.0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMatrixTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMatrixTest.java
new file mode 100644
index 0000000..8ee1f6f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMatrixTest.java
@@ -0,0 +1,509 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.Matrix;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMatrixTest {
+  private static final float EPSILON = 1e-7f;
+
+  @Test
+  public void preOperationsAreStacked() {
+    Matrix m = new Matrix();
+    m.preRotate(4, 8, 15);
+    m.preTranslate(16, 23);
+    m.preSkew(42, 108);
+
+    assertThat(shadowOf(m).getPreOperations())
+        .containsExactly("skew 42.0 108.0", "translate 16.0 23.0", "rotate 4.0 8.0 15.0");
+  }
+
+  @Test
+  public void postOperationsAreQueued() {
+    Matrix m = new Matrix();
+    m.postRotate(4, 8, 15);
+    m.postTranslate(16, 23);
+    m.postSkew(42, 108);
+
+    assertThat(shadowOf(m).getPostOperations())
+        .containsExactly("rotate 4.0 8.0 15.0", "translate 16.0 23.0", "skew 42.0 108.0");
+  }
+
+  @Test
+  public void setOperationsOverride() {
+    Matrix m = new Matrix();
+    m.setRotate(4);
+    m.setRotate(8);
+    m.setRotate(15);
+    m.setRotate(16);
+    m.setRotate(23);
+    m.setRotate(42);
+    m.setRotate(108);
+
+    assertThat(shadowOf(m).getSetOperations()).containsEntry("rotate", "108.0");
+  }
+
+  @Test
+  public void set_shouldAddOpsToMatrix() {
+    final Matrix matrix = new Matrix();
+    matrix.setScale(1, 1);
+    matrix.preScale(2, 2, 2, 2);
+    matrix.postScale(3, 3, 3, 3);
+
+    final ShadowMatrix shadow = shadowOf(matrix);
+    assertThat(shadow.getSetOperations().get("scale")).isEqualTo("1.0 1.0");
+    assertThat(shadow.getPreOperations().get(0)).isEqualTo("scale 2.0 2.0 2.0 2.0");
+    assertThat(shadow.getPostOperations().get(0)).isEqualTo("scale 3.0 3.0 3.0 3.0");
+  }
+
+  @Test
+  public void setScale_shouldAddOpsToMatrix() {
+    final Matrix matrix = new Matrix();
+    matrix.setScale(1, 2, 3, 4);
+
+    final ShadowMatrix shadow = shadowOf(matrix);
+    assertThat(shadow.getSetOperations().get("scale")).isEqualTo("1.0 2.0 3.0 4.0");
+  }
+
+  @Test
+  public void set_shouldOverrideValues() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.setScale(1, 2);
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.setScale(3, 4);
+    matrix2.set(matrix1);
+
+    final ShadowMatrix shadow = shadowOf(matrix2);
+    assertThat(shadow.getSetOperations().get("scale")).isEqualTo("1.0 2.0");
+  }
+
+  @Test
+  public void set_whenNull_shouldReset() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.setScale(1, 2);
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.set(matrix1);
+    matrix2.set(null);
+
+    final ShadowMatrix shadow = shadowOf(matrix2);
+    assertThat(shadow.getSetOperations()).isEmpty();
+  }
+
+  @Test
+  public void testIsIdentity() {
+    final Matrix matrix = new Matrix();
+    assertThat(matrix.isIdentity()).isTrue();
+
+    matrix.postScale(2.0f, 2.0f);
+    assertThat(matrix.isIdentity()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testIsAffine() {
+    final Matrix matrix = new Matrix();
+    assertThat(matrix.isAffine()).isTrue();
+
+    matrix.postScale(2.0f, 2.0f);
+    assertThat(matrix.isAffine()).isTrue();
+    matrix.postTranslate(1.0f, 2.0f);
+    assertThat(matrix.isAffine()).isTrue();
+    matrix.postRotate(45.0f);
+    assertThat(matrix.isAffine()).isTrue();
+
+    matrix.setValues(new float[] {1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 2.0f});
+    assertThat(matrix.isAffine()).isFalse();
+  }
+
+  @Test
+  public void testRectStaysRect() {
+    final Matrix matrix = new Matrix();
+    assertThat(matrix.rectStaysRect()).isTrue();
+
+    matrix.postScale(2.0f, 2.0f);
+    assertThat(matrix.rectStaysRect()).isTrue();
+    matrix.postTranslate(1.0f, 2.0f);
+    assertThat(matrix.rectStaysRect()).isTrue();
+    matrix.postRotate(45.0f);
+    assertThat(matrix.rectStaysRect()).isFalse();
+    matrix.postRotate(45.0f);
+    assertThat(matrix.rectStaysRect()).isTrue();
+  }
+
+  @Test
+  public void testGetSetValues() {
+    final Matrix matrix = new Matrix();
+    final float[] values = {0.0f, 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f};
+    matrix.setValues(values);
+    final float[] matrixValues = new float[9];
+    matrix.getValues(matrixValues);
+    assertThat(matrixValues).isEqualTo(values);
+  }
+
+  @Test
+  public void testSet() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.postScale(2.0f, 2.0f);
+    matrix1.postTranslate(1.0f, 2.0f);
+    matrix1.postRotate(45.0f);
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.set(matrix1);
+    assertThat(matrix1).isEqualTo(matrix2);
+
+    matrix2.set(null);
+    assertThat(matrix2.isIdentity()).isTrue();
+  }
+
+  @Test
+  public void testReset() {
+    final Matrix matrix = new Matrix();
+    matrix.postScale(2.0f, 2.0f);
+    matrix.postTranslate(1.0f, 2.0f);
+    matrix.postRotate(45.0f);
+    matrix.reset();
+    assertThat(matrix.isIdentity()).isTrue();
+  }
+
+  @Test
+  public void testSetTranslate() {
+    final Matrix matrix = new Matrix();
+    matrix.setTranslate(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix, 1.0f, 1.0f), new PointF(3.0f, 3.0f));
+    matrix.setTranslate(-2.0f, -2.0f);
+    assertPointsEqual(mapPoint(matrix, 1.0f, 1.0f), new PointF(-1.0f, -1.0f));
+  }
+
+  @Test
+  public void testPostTranslate() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.postTranslate(1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(2.0f, 2.0f));
+
+    matrix1.postTranslate(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(4.0f, 4.0f));
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.setScale(2.0f, 2.0f);
+    matrix2.postTranslate(-5.0f, 10.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(-3.0f, 12.0f));
+  }
+
+  @Test
+  public void testPreTranslate() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.preTranslate(1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(2.0f, 2.0f));
+
+    matrix1.preTranslate(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(4.0f, 4.0f));
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.setScale(2.0f, 2.0f);
+    matrix2.preTranslate(-5.0f, 10.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(-8.0f, 22.0f));
+  }
+
+  @Test
+  public void testSetScale() {
+    final Matrix matrix = new Matrix();
+    matrix.setScale(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix, 1.0f, 1.0f), new PointF(2.0f, 2.0f));
+    matrix.setScale(-2.0f, -3.0f);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 3.0f), new PointF(-4.0f, -9.0f));
+    matrix.setScale(-2.0f, -3.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 3.0f), new PointF(-1.0f, -5.0f));
+  }
+
+  @Test
+  public void testPostScale() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.postScale(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(2.0f, 2.0f));
+
+    matrix1.postScale(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(4.0f, 4.0f));
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.postScale(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(1.0f, 1.0f));
+
+    matrix2.setTranslate(1.0f, 2.0f);
+    matrix2.postScale(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(3.0f, 5.0f));
+  }
+
+  @Test
+  public void testPreScale() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.preScale(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(2.0f, 2.0f));
+
+    matrix1.preScale(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(4.0f, 4.0f));
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.preScale(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(1.0f, 1.0f));
+
+    matrix2.setTranslate(1.0f, 2.0f);
+    matrix2.preScale(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(2.0f, 3.0f));
+  }
+
+  @Test
+  public void testSetRotate() {
+    final Matrix matrix = new Matrix();
+    matrix.setRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(-1.0f, 0.0f));
+    matrix.setRotate(180.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, -1.0f));
+    matrix.setRotate(270.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(1.0f, 0.0f));
+    matrix.setRotate(360.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, 1.0f));
+
+    matrix.setRotate(45.0f, 0.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, 1.0f));
+  }
+
+  @Test
+  public void testPostRotate() {
+    final Matrix matrix = new Matrix();
+    matrix.postRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(-1.0f, 0.0f));
+    matrix.postRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, -1.0f));
+    matrix.postRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(1.0f, 0.0f));
+    matrix.postRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, 1.0f));
+
+    matrix.setTranslate(1.0f, 2.0f);
+    matrix.postRotate(45.0f, 0.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(-0.70710677f, 3.1213202f));
+  }
+
+  @Test
+  public void testPreRotate() {
+    final Matrix matrix = new Matrix();
+    matrix.preRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(-1.0f, 0.0f));
+    matrix.preRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, -1.0f));
+    matrix.preRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(1.0f, 0.0f));
+    matrix.preRotate(90.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, 1.0f));
+
+    matrix.setTranslate(1.0f, 2.0f);
+    matrix.preRotate(45.0f, 0.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(1.0f, 3.0f));
+  }
+
+  @Test
+  public void testSetSinCos() {
+    final Matrix matrix = new Matrix();
+    matrix.setSinCos(1.0f, 0.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(-1.0f, 0.0f));
+    matrix.setSinCos(0.0f, -1.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, -1.0f));
+    matrix.setSinCos(-1.0f, 0.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(1.0f, 0.0f));
+    matrix.setSinCos(0.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, 1.0f));
+
+    final float sinCos = (float) Math.sqrt(2) / 2;
+    matrix.setSinCos(sinCos, sinCos, 0.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 0.0f, 1.0f), new PointF(0.0f, 1.0f));
+  }
+
+  @Test
+  public void testSetSkew() {
+    final Matrix matrix = new Matrix();
+    matrix.setSkew(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix, 1.0f, 1.0f), new PointF(3.0f, 3.0f));
+    matrix.setSkew(-2.0f, -3.0f);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 3.0f), new PointF(-4.0f, -3.0f));
+    matrix.setSkew(-2.0f, -3.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 3.0f), new PointF(-2.0f, 0.0f));
+  }
+
+  @Test
+  public void testPostSkew() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.postSkew(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(3.0f, 3.0f));
+
+    matrix1.postSkew(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(9.0f, 9.0f));
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.postSkew(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(1.0f, 1.0f));
+
+    matrix2.setTranslate(1.0f, 2.0f);
+    matrix2.postSkew(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(6.0f, 5.0f));
+  }
+
+  @Test
+  public void testPreSkew() {
+    final Matrix matrix1 = new Matrix();
+    matrix1.preSkew(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(3.0f, 3.0f));
+
+    matrix1.preSkew(2.0f, 2.0f);
+    assertPointsEqual(mapPoint(matrix1, 1.0f, 1.0f), new PointF(9.0f, 9.0f));
+
+    final Matrix matrix2 = new Matrix();
+    matrix2.preSkew(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(1.0f, 1.0f));
+
+    matrix2.setTranslate(1.0f, 2.0f);
+    matrix2.preSkew(2.0f, 2.0f, 1.0f, 1.0f);
+    assertPointsEqual(mapPoint(matrix2, 1.0f, 1.0f), new PointF(2.0f, 3.0f));
+  }
+
+  @Test
+  public void testSetConcat() {
+    final Matrix scaleMatrix = new Matrix();
+    scaleMatrix.setScale(2.0f, 3.0f);
+    final Matrix translateMatrix = new Matrix();
+    translateMatrix.postTranslate(5.0f, 7.0f);
+    final Matrix matrix = new Matrix();
+    matrix.setConcat(translateMatrix, scaleMatrix);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 2.0f), new PointF(9.0f, 13.0f));
+
+    final Matrix rotateMatrix = new Matrix();
+    rotateMatrix.postRotate(90.0f);
+    matrix.setConcat(rotateMatrix, matrix);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 2.0f), new PointF(-13.0f, 9.0f));
+  }
+
+  @Test
+  public void testPostConcat() {
+    final Matrix matrix = new Matrix();
+    matrix.postScale(2.0f, 3.0f);
+    final Matrix translateMatrix = new Matrix();
+    translateMatrix.postTranslate(5.0f, 7.0f);
+    matrix.postConcat(translateMatrix);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 2.0f), new PointF(9.0f, 13.0f));
+
+    final Matrix rotateMatrix = new Matrix();
+    rotateMatrix.postRotate(90.0f);
+    matrix.postConcat(rotateMatrix);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 2.0f), new PointF(-13.0f, 9.0f));
+  }
+
+  @Test
+  public void testPreConcat() {
+    final Matrix matrix = new Matrix();
+    matrix.preScale(2.0f, 3.0f);
+    final Matrix translateMatrix = new Matrix();
+    translateMatrix.setTranslate(5.0f, 7.0f);
+    matrix.preConcat(translateMatrix);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 2.0f), new PointF(14.0f, 27.0f));
+
+    final Matrix rotateMatrix = new Matrix();
+    rotateMatrix.setRotate(90.0f);
+    matrix.preConcat(rotateMatrix);
+    assertPointsEqual(mapPoint(matrix, 2.0f, 2.0f), new PointF(6.0f, 27.0f));
+  }
+
+  @Test
+  public void testInvert() {
+    final Matrix matrix = new Matrix();
+    final Matrix inverse = new Matrix();
+    matrix.setScale(0.0f, 1.0f);
+    assertThat(matrix.invert(inverse)).isFalse();
+    matrix.setScale(1.0f, 0.0f);
+    assertThat(matrix.invert(inverse)).isFalse();
+
+    matrix.setScale(1.0f, 1.0f);
+    checkInverse(matrix);
+    matrix.setScale(-3.0f, 5.0f);
+    checkInverse(matrix);
+    matrix.setTranslate(5.0f, 2.0f);
+    checkInverse(matrix);
+    matrix.setScale(-3.0f, 5.0f);
+    matrix.postTranslate(5.0f, 2.0f);
+    checkInverse(matrix);
+    matrix.setScale(-3.0f, 5.0f);
+    matrix.postRotate(-30f, 1.0f, 2.0f);
+    matrix.postTranslate(5.0f, 2.0f);
+    checkInverse(matrix);
+  }
+
+  @Test
+  public void testMapRect() {
+    final Matrix matrix = new Matrix();
+    matrix.postScale(2.0f, 3.0f);
+    final RectF input = new RectF(1.0f, 1.0f, 2.0f, 2.0f);
+    final RectF output1 = new RectF();
+    matrix.mapRect(output1, input);
+    assertThat(output1).isEqualTo(new RectF(2.0f, 3.0f, 4.0f, 6.0f));
+
+    matrix.postScale(-1.0f, -1.0f);
+    final RectF output2 = new RectF();
+    matrix.mapRect(output2, input);
+    assertThat(output2).isEqualTo(new RectF(-4.0f, -6.0f, -2.0f, -3.0f));
+  }
+
+  @Test
+  public void testMapPoints() {
+    final Matrix matrix = new Matrix();
+    matrix.postTranslate(-1.0f, -2.0f);
+    matrix.postScale(2.0f, 3.0f);
+    final float[] input = {
+      0.0f, 0.0f,
+      1.0f, 2.0f
+    };
+    final float[] output = new float[input.length];
+    matrix.mapPoints(output, input);
+    assertThat(output).usingExactEquality().containsExactly(-2.0f, -6.0f, 0.0f, 0.0f);
+  }
+
+  @Test
+  public void testMapVectors() {
+    final Matrix matrix = new Matrix();
+    matrix.postTranslate(-1.0f, -2.0f);
+    matrix.postScale(2.0f, 3.0f);
+    final float[] input = {
+      0.0f, 0.0f,
+      1.0f, 2.0f
+    };
+    final float[] output = new float[input.length];
+    matrix.mapVectors(output, input);
+    assertThat(output).usingExactEquality().containsExactly(0.0f, 0.0f, 2.0f, 6.0f);
+  }
+
+  private static PointF mapPoint(Matrix matrix, float x, float y) {
+    float[] pf = new float[] {x, y};
+    matrix.mapPoints(pf);
+    return new PointF(pf[0], pf[1]);
+  }
+
+  private static void assertPointsEqual(PointF actual, PointF expected) {
+    assertThat(actual.x).isWithin(EPSILON).of(expected.x);
+    assertThat(actual.y).isWithin(EPSILON).of(expected.y);
+  }
+
+  private static void checkInverse(Matrix matrix) {
+    final Matrix inverse = new Matrix();
+    assertThat(matrix.invert(inverse)).isTrue();
+    matrix.postConcat(inverse);
+    assertThat(matrix.isIdentity()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaActionSoundTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaActionSoundTest.java
new file mode 100644
index 0000000..a240ab2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaActionSoundTest.java
@@ -0,0 +1,77 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.media.MediaActionSound;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link org.robolectric.shadows.ShadowMediaActionSound}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.JELLY_BEAN)
+public final class ShadowMediaActionSoundTest {
+  @Test
+  public void getPlayCount_noShutterClickPlayed_zero() {
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.SHUTTER_CLICK)).isEqualTo(0);
+  }
+
+  @Test
+  public void getPlayCount_noFocusCompletePlayed_zero() {
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.FOCUS_COMPLETE)).isEqualTo(0);
+  }
+
+  @Test
+  public void getPlayCount_noStartVideoPlayed_zero() {
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.START_VIDEO_RECORDING))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void getPlayCount_noStopVideoPlayed_zero() {
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.STOP_VIDEO_RECORDING))
+        .isEqualTo(0);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void getPlayCount_negativeSoundName_exception() {
+    ShadowMediaActionSound.getPlayCount(-1);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void getPlayCount_invalidPositiveSoundName_exception() {
+    ShadowMediaActionSound.getPlayCount(4);
+  }
+
+  @Test
+  public void getPlayCount_playedOnce() {
+    MediaActionSound mediaActionSound = new MediaActionSound();
+    mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);
+
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.SHUTTER_CLICK)).isEqualTo(1);
+  }
+
+  @Test
+  public void getPlayCount_playedDifferentSounds_correctCount() {
+    MediaActionSound mediaActionSound = new MediaActionSound();
+    mediaActionSound.play(MediaActionSound.START_VIDEO_RECORDING);
+    mediaActionSound.play(MediaActionSound.STOP_VIDEO_RECORDING);
+
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.START_VIDEO_RECORDING))
+        .isEqualTo(1);
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.STOP_VIDEO_RECORDING))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void getPlayCount_playedMoreThanOnce_correctCount() {
+    MediaActionSound mediaActionSound = new MediaActionSound();
+    mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);
+    mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);
+    mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);
+
+    assertThat(ShadowMediaActionSound.getPlayCount(MediaActionSound.SHUTTER_CLICK)).isEqualTo(3);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaCodecListTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaCodecListTest.java
new file mode 100644
index 0000000..7a789c0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaCodecListTest.java
@@ -0,0 +1,118 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowMediaCodecList}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public class ShadowMediaCodecListTest {
+
+  private static final MediaCodecInfo AAC_ENCODER_INFO =
+      MediaCodecInfoBuilder.newBuilder()
+          .setName("shadow.test.decoder.aac")
+          .setIsEncoder(true)
+          .setIsVendor(true)
+          .setCapabilities(
+              MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+                  .setMediaFormat(createMediaFormat(MediaFormat.MIMETYPE_AUDIO_AAC))
+                  .setIsEncoder(true)
+                  .setProfileLevels(
+                      new CodecProfileLevel[] {
+                        createCodecProfileLevel(CodecProfileLevel.AACObjectELD, 0),
+                        createCodecProfileLevel(CodecProfileLevel.AACObjectHE, 0)
+                      })
+                  .build())
+          .build();
+
+  private static final MediaCodecInfo VP9_DECODER_INFO =
+      MediaCodecInfoBuilder.newBuilder()
+          .setName("shadow.test.decoder.vp9")
+          .setIsHardwareAccelerated(true)
+          .setCapabilities(
+              MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+                  .setMediaFormat(createMediaFormat(MediaFormat.MIMETYPE_VIDEO_VP9))
+                  .setIsEncoder(true)
+                  .setProfileLevels(
+                      new CodecProfileLevel[] {
+                        createCodecProfileLevel(
+                            CodecProfileLevel.VP9Profile3, CodecProfileLevel.VP9Level52)
+                      })
+                  .setColorFormats(
+                      new int[] {
+                        CodecCapabilities.COLOR_FormatYUV420Flexible,
+                        CodecCapabilities.COLOR_FormatYUV420Planar
+                      })
+                  .build())
+          .build();
+
+  private static MediaFormat createMediaFormat(String mime) {
+    MediaFormat mediaFormat = new MediaFormat();
+    mediaFormat.setString(MediaFormat.KEY_MIME, mime);
+    return mediaFormat;
+  }
+
+  private static CodecProfileLevel createCodecProfileLevel(int profile, int level) {
+    CodecProfileLevel profileLevel = new CodecProfileLevel();
+    profileLevel.profile = profile;
+    profileLevel.level = level;
+    return profileLevel;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    ShadowMediaCodecList.addCodec(AAC_ENCODER_INFO);
+    ShadowMediaCodecList.addCodec(VP9_DECODER_INFO);
+  }
+
+  @Test
+  public void getCodecInfosLength() {
+    MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+    assertThat(mediaCodecList.getCodecInfos()).hasLength(2);
+  }
+
+  @Test
+  public void aacEncoderInfo() {
+    MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+    assertThat(mediaCodecList.getCodecInfos()[0]).isEqualTo(AAC_ENCODER_INFO);
+  }
+
+  @Test
+  public void vp9DecoderInfo() {
+    MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+    assertThat(mediaCodecList.getCodecInfos()[1]).isEqualTo(VP9_DECODER_INFO);
+  }
+
+  @Test
+  public void testReset() {
+    ShadowMediaCodecList.reset();
+    MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+    assertThat(mediaCodecList.getCodecInfos()).hasLength(0);
+  }
+
+  @Test
+  public void codecCapabilities_createFromProfileLevel() {
+    CodecCapabilities codecCapabilities =
+        CodecCapabilities.createFromProfileLevel(
+            MediaFormat.MIMETYPE_VIDEO_AVC,
+            CodecProfileLevel.AVCProfileBaseline,
+            CodecProfileLevel.AVCLevel2);
+    CodecProfileLevel expected = new CodecProfileLevel();
+    expected.profile = CodecProfileLevel.AVCProfileBaseline;
+    expected.level = CodecProfileLevel.AVCLevel2;
+    assertThat(codecCapabilities.getMimeType()).isEqualTo(MediaFormat.MIMETYPE_VIDEO_AVC);
+    assertThat(codecCapabilities.profileLevels).hasLength(1);
+    assertThat(codecCapabilities.profileLevels[0]).isEqualTo(expected);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaCodecTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaCodecTest.java
new file mode 100644
index 0000000..1fcfcc6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaCodecTest.java
@@ -0,0 +1,751 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.copyOfRange;
+import static java.util.Collections.max;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CodecException;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.view.Surface;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.verification.VerificationMode;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowMediaCodec.CodecConfig;
+import org.robolectric.shadows.ShadowMediaCodec.CodecConfig.Codec;
+
+/** Tests for {@link ShadowMediaCodec}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public final class ShadowMediaCodecTest {
+  private static final String AUDIO_MIME = "audio/fake";
+  private static final String AUDIO_DECODER_NAME = "audio-fake.decoder";
+  private static final String AUDIO_ENCODER_NAME = "audio-fake.encoder";
+  private static final int WITHOUT_TIMEOUT = -1;
+
+  private MediaCodec.Callback callback;
+
+  @After
+  public void tearDown() throws Exception {
+    ShadowMediaCodec.clearCodecs();
+  }
+
+  @Test
+  public void constructShadowMediaCodec_byDecoderName_succeeds() throws Exception {
+    // Add an audio decoder to the MediaCodecList.
+    MediaFormat mediaFormat = new MediaFormat();
+    mediaFormat.setString(MediaFormat.KEY_MIME, AUDIO_MIME);
+    ShadowMediaCodecList.addCodec(
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(AUDIO_DECODER_NAME)
+            .setCapabilities(
+                MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+                    .setMediaFormat(mediaFormat)
+                    .build())
+            .build());
+    ShadowMediaCodec.addDecoder(
+        AUDIO_DECODER_NAME,
+        new CodecConfig(/* inputBufferSize= */ 0, /* outputBufferSize= */ 0, (in, out) -> {}));
+
+    MediaCodec codec = MediaCodec.createByCodecName(AUDIO_DECODER_NAME);
+
+    assertThat(codec.getCodecInfo().getName()).isEqualTo(AUDIO_DECODER_NAME);
+    assertThat(codec.getCodecInfo().isEncoder()).isFalse();
+  }
+
+  @Test
+  public void constructShadowMediaCodec_byEncoderName_succeeds() throws Exception {
+    // Add an audio encoder to the MediaCodecList.
+    MediaFormat mediaFormat = new MediaFormat();
+    mediaFormat.setString(MediaFormat.KEY_MIME, AUDIO_MIME);
+    ShadowMediaCodecList.addCodec(
+        MediaCodecInfoBuilder.newBuilder()
+            .setName(AUDIO_ENCODER_NAME)
+            .setIsEncoder(true)
+            .setCapabilities(
+                MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder()
+                    .setMediaFormat(mediaFormat)
+                    .setIsEncoder(true)
+                    .build())
+            .build());
+    ShadowMediaCodec.addEncoder(
+        AUDIO_ENCODER_NAME,
+        new CodecConfig(/* inputBufferSize= */ 0, /* outputBufferSize= */ 0, (in, out) -> {}));
+
+    MediaCodec codec = MediaCodec.createByCodecName(AUDIO_ENCODER_NAME);
+
+    assertThat(codec.getCodecInfo().getName()).isEqualTo(AUDIO_ENCODER_NAME);
+    assertThat(codec.getCodecInfo().isEncoder()).isTrue();
+  }
+
+  @Test
+  public void dequeueInputBuffer_inAsyncMode_throws() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+
+    try {
+      codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+      fail();
+    } catch (IllegalStateException expected) {
+    }
+  }
+
+  @Test
+  public void dequeueOutputBuffer_inASyncMode_throws() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+
+    try {
+      codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0);
+      fail();
+    } catch (IllegalStateException expected) {
+    }
+  }
+
+  @Test
+  public void dequeueAllInputBuffersThenReleaseOutputBuffer_allowsDequeueInputBuffer()
+      throws IOException {
+    MediaCodec codec = createSyncEncoder();
+    int bufferIndex;
+    ByteBuffer buffer;
+
+    for (int i = 0; i < ShadowMediaCodec.BUFFER_COUNT; i++) {
+      bufferIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+      assertThat(bufferIndex).isNotEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER);
+      buffer = codec.getInputBuffer(bufferIndex);
+
+      int start = buffer.position();
+      // "Write" to the buffer.
+      buffer.position(buffer.limit());
+      codec.queueInputBuffer(
+          bufferIndex,
+          /* offset= */ start,
+          /* size= */ buffer.position() - start,
+          /* presentationTimeUs= */ 0,
+          /* flags= */ 0);
+    }
+
+    // Cannot dequeue buffer after all available buffers are dequeued.
+    bufferIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+    assertThat(bufferIndex).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER);
+
+    // The first dequeueOutputBuffer should return MediaCodec.INFO_OUTPUT_FORMAT_CHANGED
+    codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0);
+    bufferIndex = codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0);
+
+    codec.releaseOutputBuffer(bufferIndex, /* render= */ false);
+    // We should be able to dequeue the corresponding input buffer.
+    int dequeuedInputbufferIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+    assertThat(dequeuedInputbufferIndex).isEqualTo(bufferIndex);
+  }
+
+  @Test
+  public void getInputBuffer_withInvalidIndex_returnsNull() throws IOException {
+    List<Integer> inputBuffers = new ArrayList<>();
+    MediaCodecCallback callback =
+        new MediaCodecCallback() {
+          @Override
+          public void onInputBufferAvailable(MediaCodec codec, int inputBufferId) {
+            inputBuffers.add(inputBufferId);
+          }
+        };
+    MediaCodec codec = createAsyncEncoder(callback);
+    int invalidInputIndex = inputBuffers.isEmpty() ? 0 : max(inputBuffers) + 1;
+
+    assertThat(codec.getInputBuffer(invalidInputIndex)).isNull();
+  }
+
+  @Test
+  public void queueInputBuffer_withInvalidIndex_throws() throws IOException {
+    List<Integer> inputBuffers = new ArrayList<>();
+    MediaCodecCallback callback =
+        new MediaCodecCallback() {
+          @Override
+          public void onInputBufferAvailable(MediaCodec codec, int inputBufferId) {
+            inputBuffers.add(inputBufferId);
+          }
+        };
+
+    MediaCodec codec = createAsyncEncoder(callback);
+    int invalidInputIndex = inputBuffers.isEmpty() ? 0 : max(inputBuffers) + 1;
+
+    try {
+      codec.queueInputBuffer(
+          invalidInputIndex,
+          /* offset= */ 0,
+          /* size= */ 128,
+          /* presentationTimeUs= */ 0,
+          /* flags= */ 0);
+      fail();
+    } catch (CodecException expected) {
+    }
+  }
+
+  @Test
+  public void getOutputBuffer_withInvalidIndex_returnsNull() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+
+    assertThat(codec.getOutputBuffer(/* index= */ -1)).isNull();
+  }
+
+  @Test
+  public void formatChangeReported() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    verify(callback).onOutputFormatChanged(same(codec), any());
+  }
+
+  @Test
+  public void presentsInputBuffer() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    verify(callback).onInputBufferAvailable(same(codec), anyInt());
+  }
+
+  @Test
+  public void providesValidInputBuffer() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
+    verify(callback).onInputBufferAvailable(same(codec), indexCaptor.capture());
+
+    ByteBuffer buffer = codec.getInputBuffer(indexCaptor.getValue());
+
+    assertThat(buffer.remaining()).isGreaterThan(0);
+  }
+
+  @Test
+  public void presentsOutputBufferAfterQueuingInputBuffer() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
+    verify(callback).onInputBufferAvailable(same(codec), indexCaptor.capture());
+
+    ByteBuffer buffer = codec.getInputBuffer(indexCaptor.getValue());
+
+    int start = buffer.position();
+    // "Write" to the buffer.
+    buffer.position(buffer.limit());
+
+    codec.queueInputBuffer(
+        indexCaptor.getValue(),
+        /* offset= */ start,
+        /* size= */ buffer.position() - start,
+        /* presentationTimeUs= */ 0,
+        /* flags= */ 0);
+
+    asyncVerify(callback).onOutputBufferAvailable(same(codec), anyInt(), any());
+  }
+
+  @Test
+  public void providesValidOutputBuffer() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
+    verify(callback).onInputBufferAvailable(same(codec), indexCaptor.capture());
+
+    ByteBuffer buffer = codec.getInputBuffer(indexCaptor.getValue());
+
+    int start = buffer.position();
+    // "Write" to the buffer.
+    buffer.position(buffer.limit());
+
+    codec.queueInputBuffer(
+        indexCaptor.getValue(),
+        /* offset= */ start,
+        /* size= */ buffer.position() - start,
+        /* presentationTimeUs= */ 0,
+        /* flags= */ 0);
+
+    asyncVerify(callback).onOutputBufferAvailable(same(codec), indexCaptor.capture(), any());
+
+    buffer = codec.getOutputBuffer(indexCaptor.getValue());
+
+    assertThat(buffer.remaining()).isGreaterThan(0);
+  }
+
+  @Test
+  public void presentsInputBufferAfterReleasingOutputBufferWhenNotFinished() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
+    verify(callback).onInputBufferAvailable(same(codec), indexCaptor.capture());
+
+    ByteBuffer buffer = codec.getInputBuffer(indexCaptor.getValue());
+
+    int start = buffer.position();
+    // "Write" to the buffer.
+    buffer.position(buffer.limit());
+
+    codec.queueInputBuffer(
+        indexCaptor.getValue(),
+        /* offset= */ start,
+        /* size= */ buffer.position() - start,
+        /* presentationTimeUs= */ 0,
+        /* flags= */ 0);
+
+    asyncVerify(callback).onOutputBufferAvailable(same(codec), indexCaptor.capture(), any());
+
+    codec.releaseOutputBuffer(indexCaptor.getValue(), /* render= */ false);
+
+    asyncVerify(callback, times(2)).onInputBufferAvailable(same(codec), anyInt());
+  }
+
+  @Test
+  public void doesNotPresentInputBufferAfterReleasingOutputBufferFinished() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
+    verify(callback).onInputBufferAvailable(same(codec), indexCaptor.capture());
+
+    ByteBuffer buffer = codec.getInputBuffer(indexCaptor.getValue());
+
+    int start = buffer.position();
+    // "Write" to the buffer.
+    buffer.position(buffer.limit());
+
+    codec.queueInputBuffer(
+        indexCaptor.getValue(),
+        /* offset= */ start,
+        /* size= */ buffer.position() - start,
+        /* presentationTimeUs= */ 0,
+        /* flags= */ MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+
+    asyncVerify(callback).onOutputBufferAvailable(same(codec), indexCaptor.capture(), any());
+
+    codec.releaseOutputBuffer(indexCaptor.getValue(), /* render= */ false);
+
+    asyncVerify(callback, times(2)).onInputBufferAvailable(same(codec), anyInt());
+  }
+
+  @Test
+  public void passesEndOfStreamFlagWithFinalOutputBuffer() throws IOException {
+    MediaCodec codec = createAsyncEncoder();
+    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
+    verify(callback).onInputBufferAvailable(same(codec), indexCaptor.capture());
+
+    ByteBuffer buffer = codec.getInputBuffer(indexCaptor.getValue());
+
+    int start = buffer.position();
+    // "Write" to the buffer.
+    buffer.position(buffer.limit());
+
+    codec.queueInputBuffer(
+        indexCaptor.getValue(),
+        /* offset= */ start,
+        /* size= */ buffer.position() - start,
+        /* presentationTimeUs= */ 0,
+        /* flags= */ MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+
+    ArgumentCaptor<BufferInfo> infoCaptor = ArgumentCaptor.forClass(BufferInfo.class);
+
+    asyncVerify(callback)
+        .onOutputBufferAvailable(same(codec), indexCaptor.capture(), infoCaptor.capture());
+
+    assertThat(infoCaptor.getValue().flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM).isNotEqualTo(0);
+  }
+
+  @Test
+  public void whenCustomCodec_InputBufferIsOfExpectedSize() throws Exception {
+    int inputBufferSize = 1000;
+    CodecConfig config = new CodecConfig(inputBufferSize, /*outputBufferSize=*/ 0, (in, out) -> {});
+    ShadowMediaCodec.addEncoder(AUDIO_MIME, config);
+
+    MediaCodec codec = createSyncEncoder();
+
+    ByteBuffer inputBuffer = codec.getInputBuffer(codec.dequeueInputBuffer(0));
+    assertThat(inputBuffer.capacity()).isEqualTo(inputBufferSize);
+  }
+
+  @Test
+  public void whenCustomCodec_OutputBufferIsOfExpectedSize() throws Exception {
+    int outputBufferSize = 1000;
+    CodecConfig config = new CodecConfig(/*inputBufferSize=*/ 0, outputBufferSize, (in, out) -> {});
+    ShadowMediaCodec.addEncoder(AUDIO_MIME, config);
+    MediaCodec codec = createSyncEncoder();
+
+    int inputBuffer = codec.dequeueInputBuffer(/*timeoutUs=*/ 0);
+    codec.queueInputBuffer(
+        inputBuffer, /* offset=*/ 0, /* size=*/ 0, /* presentationTimeUs=*/ 0, /* flags=*/ 0);
+
+    assertThat(codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0))
+        .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED);
+
+    ByteBuffer outputBuffer =
+        codec.getOutputBuffer(codec.dequeueOutputBuffer(new BufferInfo(), /*timeoutUs=*/ 0));
+    assertThat(outputBuffer.capacity()).isEqualTo(outputBufferSize);
+  }
+
+  @Test
+  public void customDecoder_setsCorrectOutputFormat() throws IOException {
+    Codec mockDecoder = mock(Codec.class);
+    ShadowMediaCodec.addDecoder(
+        AUDIO_MIME,
+        new ShadowMediaCodec.CodecConfig(
+            /* inputBufferSize= */ 10, /* outputBufferSize= */ 10, mockDecoder));
+
+    // Creates decoder and configures with AAC format.
+    MediaFormat outputFormat = getBasicAacFormat();
+    MediaCodec codec = MediaCodec.createDecoderByType(AUDIO_MIME);
+    codec.configure(outputFormat, /* surface= */ null, /* crypto= */ null, /* flags= */ 0);
+    codec.start();
+
+    ArgumentCaptor<MediaFormat> mediaFormatCaptor = ArgumentCaptor.forClass(MediaFormat.class);
+    verify(mockDecoder)
+        .onConfigured(
+            mediaFormatCaptor.capture(),
+            nullable(Surface.class),
+            nullable(MediaCrypto.class),
+            anyInt());
+    MediaFormat capturedFormat = mediaFormatCaptor.getValue();
+
+    assertThat(capturedFormat.getString(MediaFormat.KEY_MIME))
+        .isEqualTo(outputFormat.getString(MediaFormat.KEY_MIME));
+    assertThat(capturedFormat.getInteger(MediaFormat.KEY_BIT_RATE))
+        .isEqualTo(outputFormat.getInteger(MediaFormat.KEY_BIT_RATE));
+    assertThat(capturedFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT))
+        .isEqualTo(outputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT));
+    assertThat(capturedFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE))
+        .isEqualTo(outputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
+    assertThat(capturedFormat.getInteger(MediaFormat.KEY_AAC_PROFILE))
+        .isEqualTo(outputFormat.getInteger(MediaFormat.KEY_AAC_PROFILE));
+  }
+
+  @Test
+  public void inSyncMode_outputBufferInfoPopulated() throws Exception {
+    MediaCodec codec = createSyncEncoder();
+    int inputBuffer = codec.dequeueInputBuffer(/*timeoutUs=*/ 0);
+    codec.getInputBuffer(inputBuffer).put(ByteBuffer.allocateDirect(512));
+    codec.queueInputBuffer(
+        inputBuffer,
+        /* offset= */ 0,
+        /* size= */ 512,
+        /* presentationTimeUs= */ 123456,
+        /* flags= */ MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+    BufferInfo info = new BufferInfo();
+
+    assertThat(codec.dequeueOutputBuffer(info, /* timeoutUs= */ 0))
+        .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED);
+
+    codec.dequeueOutputBuffer(info, /* timeoutUs= */ 0);
+
+    assertThat(info.offset).isEqualTo(0);
+    assertThat(info.size).isEqualTo(512);
+    assertThat(info.flags).isEqualTo(MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+    assertThat(info.presentationTimeUs).isEqualTo(123456);
+  }
+
+  @Test
+  public void inSyncMode_encodedDataIsCorrect() throws Exception {
+    ByteBuffer src = ByteBuffer.wrap(generateByteArray(512));
+    ByteBuffer dst = ByteBuffer.wrap(new byte[512]);
+
+    MediaCodec codec = createSyncEncoder();
+    process(codec, src, dst);
+
+    src.clear();
+    dst.clear();
+    assertThat(dst.array()).isEqualTo(generateByteArray(512));
+  }
+
+  @Test
+  public void inSyncMode_encodedDataIsCorrectForCustomCodec() throws Exception {
+    ShadowMediaCodec.addEncoder(
+        AUDIO_MIME,
+        new CodecConfig(
+            1000,
+            100,
+            (in, out) -> {
+              ByteBuffer inClone = in.duplicate();
+              inClone.limit(in.remaining() / 10);
+              out.put(inClone);
+            }));
+    byte[] input = generateByteArray(4000);
+    ByteBuffer src = ByteBuffer.wrap(input);
+    ByteBuffer dst = ByteBuffer.wrap(new byte[400]);
+
+    MediaCodec codec = createSyncEncoder();
+    process(codec, src, dst);
+
+    assertThat(Arrays.copyOf(dst.array(), 100)).isEqualTo(copyOfRange(input, 0, 100));
+    assertThat(copyOfRange(dst.array(), 100, 200)).isEqualTo(copyOfRange(input, 1000, 1100));
+    assertThat(copyOfRange(dst.array(), 200, 300)).isEqualTo(copyOfRange(input, 2000, 2100));
+    assertThat(copyOfRange(dst.array(), 300, 400)).isEqualTo(copyOfRange(input, 3000, 3100));
+  }
+
+  @Test
+  public void inSyncMode_codecInitiallyOutputsConfiguredFormat() throws Exception {
+    MediaCodec codec = createSyncEncoder();
+    assertThat(codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0))
+        .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED);
+
+    MediaFormat codecFormat = codec.getOutputFormat();
+    MediaFormat basicAacFormat = getBasicAacFormat();
+    assertThat(codecFormat.getString(MediaFormat.KEY_MIME))
+        .isEqualTo(basicAacFormat.getString(MediaFormat.KEY_MIME));
+    assertThat(codecFormat.getInteger(MediaFormat.KEY_BIT_RATE))
+        .isEqualTo(basicAacFormat.getInteger(MediaFormat.KEY_BIT_RATE));
+    assertThat(codecFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT))
+        .isEqualTo(basicAacFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT));
+    assertThat(codecFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE))
+        .isEqualTo(basicAacFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
+    assertThat(codecFormat.getInteger(MediaFormat.KEY_AAC_PROFILE))
+        .isEqualTo(basicAacFormat.getInteger(MediaFormat.KEY_AAC_PROFILE));
+  }
+
+  @Test
+  public void inSyncMode_getInputBufferWithoutDequeue_returnsNull() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+
+    assertThat(codec.getInputBuffer(/* index= */ 0)).isNull();
+  }
+
+  @Test
+  public void inSyncMode_queueingInputBufferWithoutDequeue_throws() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+
+    try {
+      codec.queueInputBuffer(
+          /* index= */ 0,
+          /* offset= */ 0,
+          /* size= */ 0,
+          /* presentationTimeUs= */ 0,
+          /* flags= */ 0);
+      fail();
+    } catch (CodecException expected) {
+    }
+  }
+
+  @Test
+  public void inSyncMode_queueInputBufferTwice_throws() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+
+    int inputIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+    codec.getInputBuffer(inputIndex).put(generateByteArray(/* size= */ 128));
+    codec.queueInputBuffer(
+        inputIndex, /* offset= */ 0, /* size= */ 128, /* presentationTimeUs= */ 0, /* flags= */ 0);
+
+    try {
+      codec.queueInputBuffer(
+          inputIndex,
+          /* offset= */ 0,
+          /* size= */ 128,
+          /* presentationTimeUs= */ 0,
+          /* flags= */ 0);
+      fail();
+    } catch (CodecException expected) {
+    }
+  }
+
+  @Test
+  public void inSyncMode_flushDiscardsQueuedInputBuffer() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+    // Dequeue the output format
+    codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0);
+
+    int inputBufferIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+    codec.getInputBuffer(inputBufferIndex).put(generateByteArray(/* size= */ 128));
+    codec.queueInputBuffer(
+        inputBufferIndex,
+        /* offset= */ 0,
+        /* size= */ 128,
+        /* presentationTimeUs= */ 123456,
+        /* flags= */ 0);
+    codec.flush();
+
+    assertThat(codec.dequeueOutputBuffer(new BufferInfo(), /* timeoutUs= */ 0))
+        .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER);
+    assertThat(codec.dequeueInputBuffer(/* timeoutUs= */ 0)).isEqualTo(inputBufferIndex);
+    assertThat(codec.getInputBuffer(inputBufferIndex).position()).isEqualTo(0);
+  }
+
+  @Test
+  public void inSyncMode_flushProvidesInputBuffersAgain() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+
+    // Dequeue all input buffers
+    while (codec.dequeueInputBuffer(/* timeoutUs= */ 0) != MediaCodec.INFO_TRY_AGAIN_LATER) {}
+    codec.flush();
+
+    assertThat(codec.dequeueInputBuffer(/* timeoutUs= */ 0)).isAtLeast(0);
+  }
+
+  @Test
+  public void inSyncMode_afterFlushGetInputBuffer_returnsNull() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+
+    int inputIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+    codec.flush();
+
+    assertThat(codec.getInputBuffer(inputIndex)).isNull();
+  }
+
+  @Test
+  public void inSyncMode_afterFlushCannotQueueInputBufferThatIsNotDequeued() throws IOException {
+    MediaCodec codec = createSyncEncoder();
+
+    int inputIndex = codec.dequeueInputBuffer(/* timeoutUs= */ 0);
+    codec.getInputBuffer(inputIndex).put(generateByteArray(/* size= */ 128));
+    codec.flush();
+
+    try {
+      codec.queueInputBuffer(
+          inputIndex,
+          /* offset= */ 0,
+          /* size= */ 128,
+          /* presentationTimeUs= */ 0,
+          /* flags= */ 0);
+      fail();
+    } catch (CodecException expected) {
+    }
+  }
+
+  public static <T> T asyncVerify(T mock) {
+    shadowMainLooper().idle();
+    return verify(mock);
+  }
+
+  public static <T> T asyncVerify(T mock, VerificationMode mode) {
+    shadowMainLooper().idle();
+    return verify(mock, mode);
+  }
+
+  private MediaCodec createAsyncEncoder() throws IOException {
+    callback = mock(MediaCodecCallback.class);
+    return createAsyncEncoder(callback);
+  }
+
+  private static MediaCodec createAsyncEncoder(MediaCodec.Callback callback) throws IOException {
+    MediaCodec codec = MediaCodec.createEncoderByType(AUDIO_MIME);
+    codec.setCallback(callback);
+
+    codec.configure(
+        getBasicAacFormat(),
+        /* surface= */ null,
+        /* crypto= */ null,
+        MediaCodec.CONFIGURE_FLAG_ENCODE);
+    codec.start();
+
+    shadowMainLooper().idle();
+
+    return codec;
+  }
+
+  private static MediaCodec createSyncEncoder() throws IOException {
+    MediaCodec codec = MediaCodec.createEncoderByType(AUDIO_MIME);
+    codec.configure(
+        getBasicAacFormat(),
+        /* surface= */ null,
+        /* crypto= */ null,
+        MediaCodec.CONFIGURE_FLAG_ENCODE);
+    codec.start();
+
+    shadowMainLooper().idle();
+
+    return codec;
+  }
+
+  private static MediaFormat getBasicAacFormat() {
+    MediaFormat format = new MediaFormat();
+    format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_AAC);
+    format.setInteger(MediaFormat.KEY_BIT_RATE, 96000);
+    format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1);
+    format.setInteger(MediaFormat.KEY_SAMPLE_RATE, 16000);
+    format.setInteger(MediaFormat.KEY_AAC_PROFILE, CodecProfileLevel.AACObjectLC);
+
+    return format;
+  }
+
+  /** Concrete class extending MediaCodec.Callback to facilitate mocking. */
+  public static class MediaCodecCallback extends MediaCodec.Callback {
+
+    @Override
+    public void onInputBufferAvailable(MediaCodec codec, int inputBufferId) {}
+
+    @Override
+    public void onOutputBufferAvailable(MediaCodec codec, int outputBufferId, BufferInfo info) {}
+
+    @Override
+    public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {}
+
+    @Override
+    public void onError(MediaCodec codec, MediaCodec.CodecException e) {}
+  }
+
+  /**
+   * A pure function which generates a byte[] of a given size contain values between {@link
+   * Byte#MIN_VALUE} and {@link Byte#MAX_VALUE},
+   */
+  private static byte[] generateByteArray(int size) {
+    byte[] array = new byte[size];
+    for (int i = 0; i < size; i++) {
+      array[i] = (byte) (i % 255 - Byte.MIN_VALUE);
+    }
+    return array;
+  }
+
+  /**
+   * Simply moves the data in the {@code src} buffer across a given {@link MediaCodec} and stores
+   * the output in {@code dst}.
+   */
+  private static void process(MediaCodec codec, ByteBuffer src, ByteBuffer dst) {
+    while (true) {
+      if (src.hasRemaining()) {
+        writeToInputBuffer(codec, src);
+        if (!src.hasRemaining()) {
+          writeEndOfInput(codec);
+        }
+      }
+
+      BufferInfo info = new BufferInfo();
+      int outputBufferId = codec.dequeueOutputBuffer(info, 0);
+      if (outputBufferId >= 0) {
+        ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
+        dst.put(outputBuffer);
+        codec.releaseOutputBuffer(outputBufferId, false);
+      }
+
+      if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+        break;
+      }
+    }
+    codec.stop();
+    codec.release();
+  }
+
+  /** Writes as much of {@code src} to the next available input buffer. */
+  private static void writeToInputBuffer(MediaCodec codec, ByteBuffer src) {
+    int inputBufferId = codec.dequeueInputBuffer(WITHOUT_TIMEOUT);
+    ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);
+    // API versions lower than 21 don't clear the buffer before returning it.
+    if (Build.VERSION.SDK_INT < 21) {
+      inputBuffer.clear();
+    }
+    int srcLimit = src.limit();
+    int numberOfBytesToWrite = Math.min(src.remaining(), inputBuffer.remaining());
+    src.limit(src.position() + numberOfBytesToWrite);
+    inputBuffer.put(src);
+    src.limit(srcLimit);
+    codec.queueInputBuffer(inputBufferId, 0, numberOfBytesToWrite, 0, 0);
+  }
+
+  /** Writes end of input to the next available input buffer */
+  private static void writeEndOfInput(MediaCodec codec) {
+    int inputBufferId = codec.dequeueInputBuffer(WITHOUT_TIMEOUT);
+    codec.queueInputBuffer(inputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaControllerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaControllerTest.java
new file mode 100644
index 0000000..2bb0dbf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaControllerTest.java
@@ -0,0 +1,231 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.media.MediaMetadata;
+import android.media.Rating;
+import android.media.session.ISessionController;
+import android.media.session.MediaController;
+import android.media.session.MediaController.PlaybackInfo;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Tests for {@link ShadowMediaController}. */
+@RunWith(AndroidJUnit4.class)
+@Config(maxSdk = Q)
+public final class ShadowMediaControllerTest {
+
+  private MediaController mediaController;
+  private ShadowMediaController shadowMediaController;
+  private final String testPackageName = "FOO";
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    ISessionController binder = mock(ISessionController.class);
+    MediaSession.Token token =
+        ReflectionHelpers.callConstructor(
+            MediaSession.Token.class, ClassParameter.from(ISessionController.class, binder));
+    mediaController = new MediaController(context, token);
+    shadowMediaController = Shadow.extract(mediaController);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setPackageName() {
+    shadowMediaController.setPackageName(testPackageName);
+    assertEquals(testPackageName, mediaController.getPackageName());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setAndGetPlaybackState() {
+    PlaybackState playbackState = createPlaybackState();
+    shadowMediaController.setPlaybackState(playbackState);
+    assertEquals(playbackState, mediaController.getPlaybackState());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setAndGetMetadata() {
+    MediaMetadata metadata = createMetadata("test");
+    shadowMediaController.setMetadata(metadata);
+    assertEquals(metadata, mediaController.getMetadata());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setAndGetPlaybackInfo() {
+    PlaybackInfo playbackInfo =
+        PlaybackInfoBuilder.newBuilder()
+            .setVolumeType(PlaybackInfo.PLAYBACK_TYPE_LOCAL)
+            .setVolumeControl(0)
+            .setMaxVolume(0)
+            .setCurrentVolume(0)
+            .setAudioAttributes(null)
+            .build();
+    shadowMediaController.setPlaybackInfo(playbackInfo);
+    assertEquals(playbackInfo, mediaController.getPlaybackInfo());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setInvalidRatingType() {
+    int ratingType = Rating.RATING_PERCENTAGE + 1;
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> shadowMediaController.setRatingType(ratingType));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Invalid RatingType value "
+                + ratingType
+                + ". The valid range is from 0 to "
+                + Rating.RATING_PERCENTAGE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getDefaultRatingType() {
+    assertThat(mediaController.getRatingType()).isEqualTo(Rating.RATING_NONE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setAndGetRatingType() {
+    int ratingType = Rating.RATING_HEART;
+    shadowMediaController.setRatingType(ratingType);
+    assertThat(mediaController.getRatingType()).isEqualTo(ratingType);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setAndGetSessionActivity() {
+    Context context = ApplicationProvider.getApplicationContext();
+    Intent intent = new Intent("testIntent");
+    PendingIntent pi = PendingIntent.getActivity(context, 555, intent, 0);
+    shadowMediaController.setSessionActivity(pi);
+    assertEquals(pi, mediaController.getSessionActivity());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void registerAndGetCallback() {
+    List<MediaController.Callback> mockCallbacks = new ArrayList<>();
+    assertEquals(mockCallbacks, shadowMediaController.getCallbacks());
+
+    MediaController.Callback mockCallback1 = mock(MediaController.Callback.class);
+    mockCallbacks.add(mockCallback1);
+    mediaController.registerCallback(mockCallback1);
+    assertEquals(mockCallbacks, shadowMediaController.getCallbacks());
+
+    MediaController.Callback mockCallback2 = mock(MediaController.Callback.class);
+    mockCallbacks.add(mockCallback2);
+    mediaController.registerCallback(mockCallback2);
+    assertEquals(mockCallbacks, shadowMediaController.getCallbacks());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void unregisterCallback() {
+    List<MediaController.Callback> mockCallbacks = new ArrayList<>();
+    MediaController.Callback mockCallback1 = mock(MediaController.Callback.class);
+    mockCallbacks.add(mockCallback1);
+    mediaController.registerCallback(mockCallback1);
+    MediaController.Callback mockCallback2 = mock(MediaController.Callback.class);
+    mockCallbacks.add(mockCallback2);
+    mediaController.registerCallback(mockCallback2);
+    assertEquals(mockCallbacks, shadowMediaController.getCallbacks());
+
+    mockCallbacks.remove(mockCallback1);
+    mediaController.unregisterCallback(mockCallback1);
+    assertEquals(mockCallbacks, shadowMediaController.getCallbacks());
+
+    mockCallbacks.remove(mockCallback2);
+    mediaController.unregisterCallback(mockCallback2);
+    assertEquals(mockCallbacks, shadowMediaController.getCallbacks());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void executeOnPlaybackStateChanged() {
+    ArgumentCaptor<PlaybackState> argument = ArgumentCaptor.forClass(PlaybackState.class);
+    MediaController.Callback mockCallback = mock(MediaController.Callback.class);
+    PlaybackState playbackState = createPlaybackState();
+
+    mediaController.registerCallback(mockCallback);
+    shadowMediaController.executeOnPlaybackStateChanged(playbackState);
+
+    shadowOf(getMainLooper()).idle();
+
+    verify(mockCallback, times(1)).onPlaybackStateChanged(argument.capture());
+    assertEquals(argument.getValue(), playbackState);
+    assertEquals(mediaController.getPlaybackState(), playbackState);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void executeOnMetadataChanged() {
+    ArgumentCaptor<MediaMetadata> argument = ArgumentCaptor.forClass(MediaMetadata.class);
+    MediaController.Callback mockCallback = mock(MediaController.Callback.class);
+    MediaMetadata metadata = createMetadata("test");
+
+    mediaController.registerCallback(mockCallback);
+    shadowMediaController.executeOnMetadataChanged(metadata);
+
+    shadowOf(getMainLooper()).idle();
+
+    verify(mockCallback, times(1)).onMetadataChanged(argument.capture());
+    assertEquals(argument.getValue(), metadata);
+    assertEquals(mediaController.getMetadata(), metadata);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void executeOnSessionDestroyed() {
+    MediaController.Callback mockCallback = mock(MediaController.Callback.class);
+
+    mediaController.registerCallback(mockCallback);
+    shadowMediaController.executeOnSessionDestroyed();
+
+    shadowOf(getMainLooper()).idle();
+
+    verify(mockCallback, times(1)).onSessionDestroyed();
+  }
+
+  private static PlaybackState createPlaybackState() {
+    return new PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 0L, 0f).build();
+  }
+
+  private static MediaMetadata createMetadata(String title) {
+    MediaMetadata.Builder builder = new MediaMetadata.Builder();
+
+    builder.putString(MediaMetadata.METADATA_KEY_TITLE, title);
+
+    return builder.build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaExtractorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaExtractorTest.java
new file mode 100644
index 0000000..7a9f61a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaExtractorTest.java
@@ -0,0 +1,177 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Random;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadows.util.DataSource;
+
+/** Tests for ShadowMediaExtractor */
+@RunWith(AndroidJUnit4.class)
+public class ShadowMediaExtractorTest {
+  private final String path = "/media/foo.mp4";
+  private final DataSource dataSource = DataSource.toDataSource(path);
+  private final MediaFormat audioMediaFormat =
+      MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_FLAC, 44100, 2);
+  private final MediaFormat videoMediaFormat =
+      MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_VP8, 320, 240);
+
+  private byte[] generateSampleData(int length) {
+    byte[] data = new byte[length];
+    new Random().nextBytes(data);
+    return data;
+  }
+
+  @Test
+  public void getTrackFormat_returnsTrackFormat() throws IOException {
+
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, new byte[0]);
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+
+    assertThat(mediaExtractor.getTrackCount()).isEqualTo(1);
+    assertThat(mediaExtractor.getTrackFormat(0)).isEqualTo(audioMediaFormat);
+  }
+
+  @Test
+  public void getSampleTrackIndex_returnsSelectedTrack() throws IOException {
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, new byte[0]);
+    ShadowMediaExtractor.addTrack(dataSource, videoMediaFormat, new byte[0]);
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+    mediaExtractor.selectTrack(0);
+
+    assertThat(mediaExtractor.getSampleTrackIndex()).isEqualTo(0);
+  }
+
+  @Test
+  public void getSampleTrackIndex_returnsSecondSelectedTrack() throws IOException {
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, new byte[0]);
+    ShadowMediaExtractor.addTrack(dataSource, videoMediaFormat, new byte[0]);
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+    mediaExtractor.selectTrack(0);
+    mediaExtractor.unselectTrack(0);
+    mediaExtractor.selectTrack(1);
+
+    assertThat(mediaExtractor.getSampleTrackIndex()).isEqualTo(1);
+  }
+
+  @Test
+  public void selectTrack_onlyOneAtATime() throws IOException {
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, new byte[0]);
+    ShadowMediaExtractor.addTrack(dataSource, videoMediaFormat, new byte[0]);
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+    mediaExtractor.selectTrack(0);
+
+    assertThrows(IllegalStateException.class, () -> mediaExtractor.selectTrack(1));
+  }
+
+  @Test
+  public void selectTrack_outOfBounds() throws IOException {
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, new byte[0]);
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+
+    assertThrows(ArrayIndexOutOfBoundsException.class, () -> mediaExtractor.selectTrack(1));
+  }
+
+  @Test
+  public void readSampleData_returnsSampleData() throws IOException {
+    byte[] sampleData = generateSampleData(4096);
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, sampleData);
+
+    int byteBufferSize = 1024;
+    ByteBuffer byteBuffer = ByteBuffer.allocate(byteBufferSize);
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+    mediaExtractor.selectTrack(0);
+
+    while (true) {
+      int length = mediaExtractor.readSampleData(byteBuffer, 0);
+      if (length == -1) {
+        break;
+      }
+      outputStream.write(byteBuffer.array());
+      byteBuffer.rewind();
+      mediaExtractor.advance();
+      assertThat(length).isEqualTo(byteBufferSize);
+    }
+
+    // Check that the read data matches the injected data.
+    assertThat(outputStream.toByteArray()).isEqualTo(sampleData);
+  }
+
+  @Test
+  public void readSampleData_returnsSampleDataForTwoTracks() throws IOException {
+    byte[] audioSampleData = generateSampleData(4096);
+    ShadowMediaExtractor.addTrack(dataSource, audioMediaFormat, audioSampleData);
+    byte[] videoSampleData = generateSampleData(8192);
+    ShadowMediaExtractor.addTrack(dataSource, videoMediaFormat, videoSampleData);
+
+    int byteBufferSize = 1024;
+    ByteBuffer byteBuffer = ByteBuffer.allocate(byteBufferSize);
+    ByteArrayOutputStream audioDataOutputStream = new ByteArrayOutputStream();
+    ByteArrayOutputStream videoDataOutputStream = new ByteArrayOutputStream();
+
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+    mediaExtractor.selectTrack(0);
+
+    // Read data from the audio track into audioDataOutputStream.
+    while (true) {
+      int length = mediaExtractor.readSampleData(byteBuffer, 0);
+      if (length == -1) {
+        break;
+      }
+      audioDataOutputStream.write(byteBuffer.array());
+      byteBuffer.rewind();
+      mediaExtractor.advance();
+      assertThat(length).isEqualTo(byteBufferSize);
+    }
+
+    // Read data from the video track into videoDataOutputStream.
+    mediaExtractor.unselectTrack(0);
+    mediaExtractor.selectTrack(1);
+
+    while (true) {
+      int length = mediaExtractor.readSampleData(byteBuffer, 0);
+      if (length == -1) {
+        break;
+      }
+      videoDataOutputStream.write(byteBuffer.array());
+      byteBuffer.rewind();
+      mediaExtractor.advance();
+      assertThat(length).isEqualTo(byteBufferSize);
+    }
+
+    // Check that the read data matches the injected data.
+    assertThat(audioDataOutputStream.toByteArray()).isEqualTo(audioSampleData);
+    assertThat(videoDataOutputStream.toByteArray()).isEqualTo(videoSampleData);
+  }
+
+  @Test
+  public void setDataSource_emptyTracksWhenNotAdded() throws IOException {
+    // Note: no data source data has been set with ShadowMediaExtractor.addTrack().
+    MediaExtractor mediaExtractor = new MediaExtractor();
+    mediaExtractor.setDataSource(path);
+
+    assertThat(mediaExtractor.getTrackCount()).isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaMetadataRetrieverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaMetadataRetrieverTest.java
new file mode 100644
index 0000000..b727f9c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaMetadataRetrieverTest.java
@@ -0,0 +1,252 @@
+package org.robolectric.shadows;
+
+import static android.media.MediaMetadataRetriever.METADATA_KEY_ALBUM;
+import static android.media.MediaMetadataRetriever.METADATA_KEY_ARTIST;
+import static android.media.MediaMetadataRetriever.METADATA_KEY_TITLE;
+import static android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.fail;
+import static org.robolectric.shadows.ShadowMediaMetadataRetriever.addException;
+import static org.robolectric.shadows.ShadowMediaMetadataRetriever.addFrame;
+import static org.robolectric.shadows.ShadowMediaMetadataRetriever.addMetadata;
+import static org.robolectric.shadows.ShadowMediaMetadataRetriever.addScaledFrame;
+import static org.robolectric.shadows.util.DataSource.toDataSource;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.media.MediaDataSource;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.FileDescriptor;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.util.DataSource;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMediaMetadataRetrieverTest {
+  private final String path = "/media/foo.mp3";
+  private final String path2 = "/media/foo2.mp3";
+  private final MediaMetadataRetriever retriever = new MediaMetadataRetriever();
+  private final MediaMetadataRetriever retriever2 = new MediaMetadataRetriever();
+  private final Bitmap bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+  private final Bitmap bitmap2 = Bitmap.createBitmap(11, 11, Bitmap.Config.ARGB_8888);
+  private FileDescriptor fd = new FileDescriptor();
+
+  @Test
+  public void extractMetadata_shouldReturnValue() {
+    addMetadata(path, METADATA_KEY_ARTIST, "The Rolling Stones");
+    addMetadata(path, METADATA_KEY_ALBUM, "Sticky Fingers");
+    addMetadata(path, METADATA_KEY_TITLE, "Brown Sugar");
+
+    retriever.setDataSource(path);
+    assertThat(retriever.extractMetadata(METADATA_KEY_ARTIST)).isEqualTo("The Rolling Stones");
+    assertThat(retriever.extractMetadata(METADATA_KEY_ALBUM)).isEqualTo("Sticky Fingers");
+    assertThat(retriever.extractMetadata(METADATA_KEY_TITLE)).isEqualTo("Brown Sugar");
+  }
+
+  @Test
+  public void getFrameAtTime_shouldDependOnDataSource() {
+    addFrame(path, 1, bitmap);
+    addFrame(path2, 1, bitmap2);
+    retriever.setDataSource(path);
+    retriever2.setDataSource(path2);
+    assertThat(retriever.getFrameAtTime(1)).isEqualTo(bitmap);
+    assertThat(retriever.getFrameAtTime(1)).isNotEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(1)).isEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(1)).isNotEqualTo(bitmap);
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void getScaledFrameAtTime_shouldDependOnDataSource() {
+    addScaledFrame(toDataSource(path), 1, 1024, 768, bitmap);
+    addScaledFrame(toDataSource(path2), 1, 320, 640, bitmap2);
+    retriever.setDataSource(path);
+    retriever2.setDataSource(path2);
+    assertThat(retriever.getScaledFrameAtTime(1, OPTION_CLOSEST_SYNC, 1024, 768)).isEqualTo(bitmap);
+    assertThat(retriever.getScaledFrameAtTime(1, OPTION_CLOSEST_SYNC, 1024, 768))
+        .isNotEqualTo(bitmap2);
+    assertThat(retriever2.getScaledFrameAtTime(1, OPTION_CLOSEST_SYNC, 320, 640))
+        .isEqualTo(bitmap2);
+    assertThat(retriever2.getScaledFrameAtTime(1, OPTION_CLOSEST_SYNC, 320, 640))
+        .isNotEqualTo(bitmap);
+  }
+
+  @Test
+  public void setDataSource_usersSameDataSourceForFileDescriptors() {
+    addFrame(fd, 1, bitmap);
+    addFrame(fd, 0, 0, 1, bitmap2);
+    retriever.setDataSource(fd);
+    assertThat(retriever.getFrameAtTime(1)).isEqualTo(bitmap2);
+  }
+
+  @Test
+  public void setDataSource_fdsWithDifferentOffsetsAreDifferentDataSources() {
+    addFrame(fd, 1, bitmap);
+    addFrame(fd, 1, 0, 1, bitmap2);
+    retriever.setDataSource(fd);
+    retriever2.setDataSource(fd, 1, 0);
+    assertThat(retriever.getFrameAtTime(1)).isEqualTo(bitmap);
+    assertThat(retriever.getFrameAtTime(1)).isNotEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(1)).isEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(1)).isNotEqualTo(bitmap);
+  }
+
+  @Test
+  public void setDataSource_noFdTransform_differentFdsAreDifferentDataSources() {
+    FileDescriptor fd2 = new FileDescriptor();
+    addFrame(fd, 1, bitmap);
+
+    retriever.setDataSource(fd2);
+
+    assertThat(retriever.getFrameAtTime(1)).isNotEqualTo(bitmap);
+  }
+
+  @Test
+  public void setDataSource_withFdTransform_differentFdsSameFileAreSameDataSource() {
+    DataSource.setFileDescriptorTransform((fd, offset) -> "bytesextractedfromfile");
+    addFrame(fd, 1, bitmap);
+
+    FileDescriptor fd2 = new FileDescriptor();
+    retriever.setDataSource(fd2);
+
+    assertThat(retriever.getFrameAtTime(1)).isEqualTo(bitmap);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setDataSource_withDifferentMediaDataSourceAreSameDataSources() {
+    MediaDataSource mediaDataSource1 =
+        new MediaDataSource() {
+          @Override
+          public int readAt(final long l, final byte[] bytes, final int i, final int i1) {
+            return 0;
+          }
+
+          @Override
+          public long getSize() {
+            return 0;
+          }
+
+          @Override
+          public void close() {}
+        };
+    MediaDataSource mediaDataSource2 =
+        new MediaDataSource() {
+          @Override
+          public int readAt(final long l, final byte[] bytes, final int i, final int i1) {
+            return 0;
+          }
+
+          @Override
+          public long getSize() {
+            return 0;
+          }
+
+          @Override
+          public void close() {}
+        };
+    addFrame(DataSource.toDataSource(mediaDataSource1), 1, bitmap);
+    addFrame(DataSource.toDataSource(mediaDataSource2), 1, bitmap2);
+    retriever.setDataSource(mediaDataSource1);
+    retriever2.setDataSource(mediaDataSource2);
+    assertThat(retriever.getFrameAtTime(1)).isEqualTo(bitmap2);
+    assertThat(retriever.getFrameAtTime(1)).isNotEqualTo(bitmap);
+    assertThat(retriever2.getFrameAtTime(1)).isEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(1)).isNotEqualTo(bitmap);
+  }
+
+  @Test
+  public void getFrameAtTime_shouldDependOnTime() {
+    Context context = ApplicationProvider.getApplicationContext();
+    Uri uri = Uri.parse(path);
+    addFrame(context, uri, 12, bitmap);
+    addFrame(context, uri, 13, bitmap2);
+    retriever.setDataSource(context, uri);
+    assertThat(retriever.getFrameAtTime(12)).isEqualTo(bitmap);
+    assertThat(retriever.getFrameAtTime(13)).isNotEqualTo(bitmap);
+    assertThat(retriever.getFrameAtTime(12)).isNotEqualTo(bitmap2);
+    assertThat(retriever.getFrameAtTime(13)).isEqualTo(bitmap2);
+  }
+
+  @Test
+  @Config(minSdk = O_MR1)
+  public void getScaledFrameAtTime_shouldDependOnTime() {
+    Context context = ApplicationProvider.getApplicationContext();
+    Uri uri = Uri.parse(path);
+    addScaledFrame(toDataSource(context, uri), 12, 1024, 768, bitmap);
+    addScaledFrame(toDataSource(context, uri), 13, 320, 640, bitmap2);
+    retriever.setDataSource(context, uri);
+    assertThat(retriever.getScaledFrameAtTime(12, OPTION_CLOSEST_SYNC, 1024, 768))
+        .isEqualTo(bitmap);
+    assertThat(retriever.getScaledFrameAtTime(13, OPTION_CLOSEST_SYNC, 1024, 768))
+        .isNotEqualTo(bitmap);
+    assertThat(retriever.getScaledFrameAtTime(12, OPTION_CLOSEST_SYNC, 320, 640))
+        .isNotEqualTo(bitmap2);
+    assertThat(retriever.getScaledFrameAtTime(13, OPTION_CLOSEST_SYNC, 320, 640))
+        .isEqualTo(bitmap2);
+  }
+
+  @Test
+  public void setDataSource_ignoresHeadersWhenShadowed() {
+    Context context = ApplicationProvider.getApplicationContext();
+    Uri uri = Uri.parse(path);
+    Map<String, String> headers = new HashMap<>();
+    headers.put("cookie", "nomnomnom");
+    retriever.setDataSource(context, uri);
+    retriever2.setDataSource(uri.toString(), headers);
+    addFrame(context, uri, 10, bitmap);
+    addFrame(uri.toString(), headers, 13, bitmap2);
+    assertThat(retriever.getFrameAtTime(10)).isEqualTo(bitmap);
+    assertThat(retriever.getFrameAtTime(13)).isEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(13)).isEqualTo(bitmap2);
+    assertThat(retriever2.getFrameAtTime(10)).isEqualTo(bitmap);
+  }
+
+  @Test
+  public void reset_clearsStaticValues() {
+    addMetadata(path, METADATA_KEY_ARTIST, "The Rolling Stones");
+    addFrame(path, 1, bitmap);
+    addException(toDataSource(path2), new IllegalArgumentException());
+    retriever.setDataSource(path);
+    assertThat(retriever.extractMetadata(METADATA_KEY_ARTIST)).isEqualTo("The Rolling Stones");
+    assertThat(retriever.getFrameAtTime(1)).isSameInstanceAs(bitmap);
+    try {
+      retriever2.setDataSource(path2);
+      fail("Expected exception");
+    } catch (Exception caught) {
+      assertThat(caught).isInstanceOf(IllegalArgumentException.class);
+    }
+    ShadowMediaMetadataRetriever.reset();
+    assertThat(retriever.extractMetadata(METADATA_KEY_ARTIST)).isNull();
+    assertThat(retriever.getFrameAtTime(1)).isNull();
+    try {
+      retriever2.setDataSource(path2);
+    } catch (IllegalArgumentException e) {
+      throw new RuntimeException("Shouldn't throw exception after reset", e);
+    }
+  }
+
+  @Test
+  public void setDataSourceException_withAllowedException() {
+    RuntimeException e = new RuntimeException("some dummy message");
+    addException(toDataSource(path), e);
+    try {
+      retriever.setDataSource(path);
+      fail("Expected exception");
+    } catch (Exception caught) {
+      assertThat(caught).isSameInstanceAs(e);
+      assertWithMessage("Stack trace should originate in Shadow")
+          .that(e.getStackTrace()[0].getClassName())
+          .isEqualTo(ShadowMediaMetadataRetriever.class.getName());
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaMuxerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaMuxerTest.java
new file mode 100644
index 0000000..894b518
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaMuxerTest.java
@@ -0,0 +1,102 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Random;
+import java.util.UUID;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.TempDirectory;
+
+/** Tests for {@link ShadowMediaMuxer}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowMediaMuxerTest {
+  private static final int INPUT_SIZE = 512;
+  private TempDirectory tempDirectory;
+
+  @Before
+  public void setUp() {
+    tempDirectory = new TempDirectory();
+  }
+
+  @After
+  public void tearDown() {
+    tempDirectory.destroy();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void basicMuxingFlow_sameZeroOffset() throws IOException {
+    basicMuxingFlow(0, 0, INPUT_SIZE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void basicMuxingFlow_sameNonZeroOffset() throws IOException {
+    basicMuxingFlow(10, 10, INPUT_SIZE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void basicMuxingFlow_nonSameButSmallerOffset() throws IOException {
+    basicMuxingFlow(0, 10, INPUT_SIZE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void basicMuxingFlow_nonSameButLargerOffset() throws IOException {
+    basicMuxingFlow(10, 0, INPUT_SIZE);
+  }
+
+  private void basicMuxingFlow(int bufInfoOffset, int bufOffset, int inputSize) throws IOException {
+    String tempFilePath =
+        tempDirectory.create("dir").resolve(UUID.randomUUID().toString()).toString();
+    MediaMuxer muxer = new MediaMuxer(tempFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+
+    MediaFormat format = new MediaFormat();
+    format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_AAC);
+
+    int trackIndex = muxer.addTrack(format);
+    muxer.start();
+
+    byte[] inputBytes = new byte[inputSize];
+    new Random().nextBytes(inputBytes);
+    ByteBuffer inputBuffer = ByteBuffer.wrap(inputBytes);
+    inputBuffer.position(bufOffset);
+    MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+    int outputSize = inputSize - bufInfoOffset;
+    bufferInfo.set(bufInfoOffset, outputSize, 0, 0);
+
+    muxer.writeSampleData(trackIndex, inputBuffer, bufferInfo);
+    muxer.stop();
+
+    // Read in what was muxed.
+    byte[] outputBytes = new byte[outputSize];
+    FileInputStream tempFile = new FileInputStream(tempFilePath);
+
+    int offset = 0;
+    int bytesRead = 0;
+    while (outputSize - offset > 0
+        && (bytesRead = tempFile.read(outputBytes, offset, outputSize - offset)) != -1) {
+      offset += bytesRead;
+    }
+
+    assertThat(outputBytes)
+        .isEqualTo(Arrays.copyOfRange(inputBytes, bufInfoOffset, inputBytes.length));
+    new File(tempFilePath).deleteOnExit();
+    muxer.release();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaPlayerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaPlayerTest.java
new file mode 100644
index 0000000..c05b6e9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaPlayerTest.java
@@ -0,0 +1,1603 @@
+package org.robolectric.shadows;
+
+import static android.media.AudioPort.ROLE_SINK;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.END;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.ERROR;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.IDLE;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.INITIALIZED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PAUSED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PLAYBACK_COMPLETED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PREPARED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PREPARING;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.STARTED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.STOPPED;
+import static org.robolectric.shadows.ShadowMediaPlayer.addException;
+import static org.robolectric.shadows.util.DataSource.toDataSource;
+
+import android.app.Application;
+import android.content.res.AssetFileDescriptor;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.media.MediaDataSource;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Looper;
+import android.os.SystemClock;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowMediaPlayer.InvalidStateBehavior;
+import org.robolectric.shadows.ShadowMediaPlayer.MediaEvent;
+import org.robolectric.shadows.ShadowMediaPlayer.MediaInfo;
+import org.robolectric.shadows.ShadowMediaPlayer.State;
+import org.robolectric.shadows.util.DataSource;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMediaPlayerTest {
+
+  private static final String DUMMY_SOURCE = "dummy-source";
+
+  private MediaPlayer mediaPlayer;
+  private ShadowMediaPlayer shadowMediaPlayer;
+  private MediaPlayer.OnCompletionListener completionListener;
+  private MediaPlayer.OnErrorListener errorListener;
+  private MediaPlayer.OnInfoListener infoListener;
+  private MediaPlayer.OnPreparedListener preparedListener;
+  private MediaPlayer.OnSeekCompleteListener seekListener;
+  private MediaInfo info;
+  private DataSource defaultSource;
+
+  @Before
+  public void setUp() {
+    mediaPlayer = Shadow.newInstanceOf(MediaPlayer.class);
+    shadowMediaPlayer = shadowOf(mediaPlayer);
+
+    completionListener = Mockito.mock(MediaPlayer.OnCompletionListener.class);
+    mediaPlayer.setOnCompletionListener(completionListener);
+
+    preparedListener = Mockito.mock(MediaPlayer.OnPreparedListener.class);
+    mediaPlayer.setOnPreparedListener(preparedListener);
+
+    errorListener = Mockito.mock(MediaPlayer.OnErrorListener.class);
+    mediaPlayer.setOnErrorListener(errorListener);
+
+    infoListener = Mockito.mock(MediaPlayer.OnInfoListener.class);
+    mediaPlayer.setOnInfoListener(infoListener);
+
+    seekListener = Mockito.mock(MediaPlayer.OnSeekCompleteListener.class);
+    mediaPlayer.setOnSeekCompleteListener(seekListener);
+
+    shadowMainLooper().pause();
+
+    defaultSource = toDataSource(DUMMY_SOURCE);
+    info = new MediaInfo();
+    ShadowMediaPlayer.addMediaInfo(defaultSource, info);
+    shadowMediaPlayer.doSetDataSource(defaultSource);
+  }
+
+  @Test
+  public void create_withResourceId_shouldSetDataSource() {
+    Application context = ApplicationProvider.getApplicationContext();
+    ShadowMediaPlayer.addMediaInfo(
+        DataSource.toDataSource("android.resource://" + context.getPackageName() + "/123"),
+        new ShadowMediaPlayer.MediaInfo(100, 10));
+
+    MediaPlayer mp = MediaPlayer.create(context, 123);
+    ShadowMediaPlayer shadow = shadowOf(mp);
+    assertThat(shadow.getDataSource())
+        .isEqualTo(
+            DataSource.toDataSource("android.resource://" + context.getPackageName() + "/123"));
+  }
+
+  @Test
+  public void testInitialState() {
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(IDLE);
+  }
+
+  @Test
+  public void testCreateListener() {
+    ShadowMediaPlayer.CreateListener createListener = Mockito
+        .mock(ShadowMediaPlayer.CreateListener.class);
+    ShadowMediaPlayer.setCreateListener(createListener);
+
+    MediaPlayer newPlayer = new MediaPlayer();
+    ShadowMediaPlayer shadow = shadowOf(newPlayer);
+
+    Mockito.verify(createListener).onCreate(newPlayer, shadow);
+  }
+
+  @Test
+  public void testResetResetsPosition() {
+    shadowMediaPlayer.setCurrentPosition(300);
+    mediaPlayer.reset();
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw())
+      .isEqualTo(0);
+  }
+
+  @Test
+  public void testPrepare() throws IOException {
+    int[] testDelays = { 0, 10, 100, 1500 };
+
+    for (int delay : testDelays) {
+      final long startTime = SystemClock.uptimeMillis();
+      info.setPreparationDelay(delay);
+      shadowMediaPlayer.setState(INITIALIZED);
+      mediaPlayer.prepare();
+
+      assertThat(shadowMediaPlayer.getState()).isEqualTo(PREPARED);
+      assertThat(SystemClock.uptimeMillis()).isEqualTo(startTime + delay);
+    }
+  }
+
+  @Test
+  public void testSetDataSourceString() throws IOException {
+    DataSource ds = toDataSource("dummy");
+    ShadowMediaPlayer.addMediaInfo(ds, info);
+    mediaPlayer.setDataSource("dummy");
+    assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+  }
+
+  @Test
+  public void testSetDataSource_withUri() throws IOException {
+    Uri uri = Uri.parse("file:/test");
+    DataSource ds = toDataSource(ApplicationProvider.getApplicationContext(), uri);
+    ShadowMediaPlayer.addMediaInfo(ds, info);
+
+    mediaPlayer.setDataSource(ApplicationProvider.getApplicationContext(), uri);
+
+    assertWithMessage("sourceUri").that(shadowMediaPlayer.getSourceUri()).isSameInstanceAs(uri);
+    assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+  }
+
+  @Test
+  public void testSetDataSource_withUriAndHeaders() throws IOException {
+    Map<String, String> headers = new HashMap<>();
+    Uri uri = Uri.parse("file:/test");
+    DataSource ds = toDataSource(ApplicationProvider.getApplicationContext(), uri, headers);
+    ShadowMediaPlayer.addMediaInfo(ds, info);
+
+    mediaPlayer.setDataSource(ApplicationProvider.getApplicationContext(), uri, headers);
+
+    assertWithMessage("sourceUri").that(shadowMediaPlayer.getSourceUri()).isSameInstanceAs(uri);
+    assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+  }
+
+  @Test
+  public void testSetDataSourceFD() throws IOException {
+    File tmpFile = File.createTempFile("MediaPlayerTest", null);
+    try {
+      tmpFile.deleteOnExit();
+      FileInputStream is = new FileInputStream(tmpFile);
+      try {
+        FileDescriptor fd = is.getFD();
+        DataSource ds = toDataSource(fd, 23, 524);
+        ShadowMediaPlayer.addMediaInfo(ds, info);
+        mediaPlayer.setDataSource(fd, 23, 524);
+        assertWithMessage("sourceUri").that(shadowMediaPlayer.getSourceUri()).isNull();
+        assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+      } finally {
+        is.close();
+      }
+    } finally {
+      tmpFile.delete();
+    }
+  }
+
+  @Config(minSdk = M)
+  @Test
+  public void testSetDataSourceMediaDataSource() {
+    MediaDataSource mediaDataSource = new MediaDataSource() {
+      @Override
+      public void close() {}
+
+      @Override
+      public int readAt(long position, byte[] buffer, int offset, int size) {
+        return 0;
+      }
+
+      @Override
+      public long getSize() {
+        return 0;
+      }
+    };
+    DataSource ds = toDataSource(mediaDataSource);
+    ShadowMediaPlayer.addMediaInfo(ds, info);
+    mediaPlayer.setDataSource(mediaDataSource);
+    assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+  }
+
+  @Config(minSdk = N)
+  @Test
+  public void testSetDataSourceAssetFileDescriptorDataSource() throws IOException {
+    Application context = ApplicationProvider.getApplicationContext();
+    try (AssetFileDescriptor fd = context.getResources().openRawResourceFd(R.drawable.an_image)) {
+      DataSource ds = toDataSource(fd);
+      ShadowMediaPlayer.addMediaInfo(ds, info);
+      mediaPlayer.setDataSource(fd);
+      assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+    }
+  }
+
+  @Test
+  public void testSetDataSourceUsesCustomMediaInfoProvider() throws Exception {
+    MediaInfo mediaInfo = new MediaInfo();
+    ShadowMediaPlayer.setMediaInfoProvider(unused -> mediaInfo);
+    String path = "data_source_path";
+    DataSource ds = toDataSource(path);
+    mediaPlayer.setDataSource(path);
+    assertWithMessage("dataSource").that(shadowMediaPlayer.getDataSource()).isEqualTo(ds);
+    assertWithMessage("mediaInfo")
+        .that(shadowMediaPlayer.getMediaInfo())
+        .isSameInstanceAs(mediaInfo);
+  }
+
+  @Test
+  public void testPrepareAsyncAutoCallback() {
+    mediaPlayer.setOnPreparedListener(preparedListener);
+    int[] testDelays = { 0, 10, 100, 1500 };
+
+    for (int delay : testDelays) {
+      info.setPreparationDelay(delay);
+      shadowMediaPlayer.setState(INITIALIZED);
+      final long startTime = SystemClock.uptimeMillis();
+      mediaPlayer.prepareAsync();
+
+      assertThat(shadowMediaPlayer.getState()).isEqualTo(PREPARING);
+      Mockito.verifyNoMoreInteractions(preparedListener);
+      shadowMainLooper().idleFor(Duration.ofMillis(delay));
+      assertThat(SystemClock.uptimeMillis()).isEqualTo(startTime + delay);
+      assertThat(shadowMediaPlayer.getState()).isEqualTo(PREPARED);
+      Mockito.verify(preparedListener).onPrepared(mediaPlayer);
+      Mockito.verifyNoMoreInteractions(preparedListener);
+      Mockito.reset(preparedListener);
+    }
+  }
+
+  @Test
+  public void testPrepareAsyncManualCallback() {
+    mediaPlayer.setOnPreparedListener(preparedListener);
+    info.setPreparationDelay(-1);
+
+    shadowMediaPlayer.setState(INITIALIZED);
+    final long startTime = SystemClock.uptimeMillis();
+    mediaPlayer.prepareAsync();
+
+    assertThat(SystemClock.uptimeMillis()).isEqualTo(startTime);
+    assertThat(shadowMediaPlayer.getState()).isSameInstanceAs(PREPARING);
+    Mockito.verifyNoMoreInteractions(preparedListener);
+    shadowMediaPlayer.invokePreparedListener();
+    assertThat(shadowMediaPlayer.getState()).isSameInstanceAs(PREPARED);
+    Mockito.verify(preparedListener).onPrepared(mediaPlayer);
+    Mockito.verifyNoMoreInteractions(preparedListener);
+  }
+
+  @Test
+  public void testDefaultPreparationDelay() {
+    assertWithMessage("preparationDelay").that(info.getPreparationDelay()).isEqualTo(0);
+  }
+
+  @Test
+  public void testIsPlaying() {
+    EnumSet<State> nonPlayingStates = EnumSet.of(IDLE, INITIALIZED, PREPARED,
+        PAUSED, STOPPED, PLAYBACK_COMPLETED);
+    for (State state : nonPlayingStates) {
+      shadowMediaPlayer.setState(state);
+      assertThat(mediaPlayer.isPlaying()).isFalse();
+    }
+    shadowMediaPlayer.setState(STARTED);
+    assertThat(mediaPlayer.isPlaying()).isTrue();
+  }
+
+  @Test
+  public void testIsPrepared() {
+    EnumSet<State> prepStates = EnumSet.of(PREPARED, STARTED, PAUSED,
+        PLAYBACK_COMPLETED);
+
+    for (State state : State.values()) {
+      shadowMediaPlayer.setState(state);
+      if (prepStates.contains(state)) {
+        assertThat(shadowMediaPlayer.isPrepared()).isTrue();
+      } else {
+        assertThat(shadowMediaPlayer.isPrepared()).isFalse();
+      }
+    }
+  }
+
+  @Test
+  public void testPlaybackProgress() {
+    shadowMediaPlayer.setState(PREPARED);
+    // This time offset is just to make sure that it doesn't work by
+    // accident because the offsets are calculated relative to 0.
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+
+    mediaPlayer.start();
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(0);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(STARTED);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(500));
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(500);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(STARTED);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(499));
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(999);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(STARTED);
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(1000);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PLAYBACK_COMPLETED);
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(1000);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PLAYBACK_COMPLETED);
+    Mockito.verifyNoMoreInteractions(completionListener);
+  }
+
+  @Test
+  public void testStop() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(300));
+
+    mediaPlayer.stop();
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(300);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(400));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(300);
+  }
+
+  @Test
+  public void testPauseReschedulesCompletionCallback() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    mediaPlayer.pause();
+    shadowMainLooper().idleFor(Duration.ofMillis(800));
+
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(799));
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    assertNoPostedTasks();
+    Mockito.verifyNoMoreInteractions(completionListener);
+  }
+
+  @Test
+  public void testPauseUpdatesPosition() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    mediaPlayer.pause();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PAUSED);
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(200);
+
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(STARTED);
+    assertThat(shadowMediaPlayer.getCurrentPosition()).isEqualTo(400);
+  }
+
+  @Test
+  public void testSeekDuringPlaybackReschedulesCompletionCallback() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(300));
+    mediaPlayer.seekTo(400);
+    shadowMainLooper().idleFor(Duration.ofMillis(599));
+    Mockito.verifyNoMoreInteractions(completionListener);
+    shadowMainLooper().idleFor(Duration.ofMinutes(1));
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+    Mockito.verifyNoMoreInteractions(completionListener);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PLAYBACK_COMPLETED);
+
+    assertNoPostedTasks();
+    Mockito.verifyNoMoreInteractions(completionListener);
+  }
+
+  @Test
+  public void testSeekDuringPlaybackUpdatesPosition() {
+    shadowMediaPlayer.setState(PREPARED);
+
+    // This time offset is just to make sure that it doesn't work by
+    // accident because the offsets are calculated relative to 0.
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(400));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(400);
+
+    mediaPlayer.seekTo(600);
+    shadowMainLooper().idleFor(Duration.ofMillis(0));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(600);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(300));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(900);
+
+    mediaPlayer.seekTo(100);
+    shadowMainLooper().idleFor(Duration.ofMillis(0));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(100);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(900));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(1000);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PLAYBACK_COMPLETED);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(1000);
+  }
+
+  @Config(minSdk = O)
+  @Test
+  public void testSeekToMode() {
+    shadowMediaPlayer.setState(PREPARED);
+
+    // This time offset is just to make sure that it doesn't work by
+    // accident because the offsets are calculated relative to 0.
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(400));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(400);
+
+    mediaPlayer.seekTo(600, MediaPlayer.SEEK_CLOSEST);
+    shadowMainLooper().idleFor(Duration.ofMillis(0));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(600);
+  }
+
+  @Test
+  public void testPendingEventsRemovedOnError() {
+    Mockito.when(errorListener.onError(mediaPlayer, 2, 3)).thenReturn(true);
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+
+    // We should have a pending completion callback.
+
+    shadowMediaPlayer.invokeErrorListener(2, 3);
+    Mockito.verifyNoMoreInteractions(completionListener);
+  }
+
+  @Test
+  public void testAttachAuxEffectStates() {
+    testStates(new MethodSpec("attachAuxEffect", 37), EnumSet.of(IDLE, ERROR),
+        onErrorTester, null);
+  }
+
+  private static final EnumSet<State> emptyStateSet = EnumSet
+      .noneOf(State.class);
+
+  @Test
+  public void testGetAudioSessionIdStates() {
+    testStates("getAudioSessionId", emptyStateSet, onErrorTester, null);
+  }
+
+  @Test
+  public void testGetCurrentPositionStates() {
+    testStates("getCurrentPosition", EnumSet.of(IDLE, ERROR), onErrorTester,
+        null);
+  }
+
+  @Test
+  public void testGetDurationStates() {
+    testStates("getDuration", EnumSet.of(IDLE, INITIALIZED, ERROR),
+        onErrorTester, null);
+  }
+
+  @Test
+  public void testGetVideoHeightAndWidthStates() {
+    testStates("getVideoHeight", EnumSet.of(IDLE, ERROR), logTester, null);
+    testStates("getVideoWidth", EnumSet.of(IDLE, ERROR), logTester, null);
+  }
+
+  @Test
+  public void testIsLoopingStates() {
+    // isLooping is quite unique as it throws ISE when in END state,
+    // even though every other state is legal.
+    testStates("isLooping", EnumSet.of(END), iseTester, null);
+  }
+
+  @Test
+  public void testIsPlayingStates() {
+    testStates("isPlaying", EnumSet.of(ERROR), onErrorTester, null);
+  }
+
+  @Test
+  public void testPauseStates() {
+    testStates("pause",
+        EnumSet.of(IDLE, INITIALIZED, PREPARED, STOPPED, ERROR), onErrorTester,
+        PAUSED);
+  }
+
+  @Test
+  public void testPrepareStates() {
+    testStates("prepare",
+        EnumSet.of(IDLE, PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED, ERROR),
+        PREPARED);
+  }
+
+  @Test
+  public void testPrepareAsyncStates() {
+    testStates("prepareAsync",
+        EnumSet.of(IDLE, PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED, ERROR),
+        PREPARING);
+  }
+
+  @Test
+  public void testReleaseStates() {
+    testStates("release", emptyStateSet, END);
+  }
+
+  @Test
+  public void testResetStates() {
+    testStates("reset", EnumSet.of(END), IDLE);
+  }
+
+  @Test
+  public void testSeekToStates() {
+    testStates(new MethodSpec("seekTo", 38),
+        EnumSet.of(IDLE, INITIALIZED, STOPPED, ERROR), onErrorTester, null);
+  }
+
+  @Test
+  public void testSetAudioSessionIdStates() {
+    testStates(new MethodSpec("setAudioSessionId", 40), EnumSet.of(INITIALIZED,
+        PREPARED, STARTED, PAUSED, STOPPED, PLAYBACK_COMPLETED, ERROR),
+        onErrorTester, null);
+  }
+
+  // NOTE: This test diverges from the spec in the MediaPlayer
+  // doc, which says that setAudioStreamType() is valid to call
+  // from any state other than ERROR. It mentions that
+  // unless you call it before prepare it won't be effective.
+  // However, by inspection I found that it actually calls onError
+  // and moves into the ERROR state unless invoked from IDLE state,
+  // so that is what I have emulated.
+  @Test
+  public void testSetAudioStreamTypeStates() {
+    testStates(new MethodSpec("setAudioStreamType", AudioManager.STREAM_MUSIC),
+        EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED, ERROR),
+        onErrorTester, null);
+  }
+
+  @Test
+  public void testSetLoopingStates() {
+    testStates(new MethodSpec("setLooping", true), EnumSet.of(ERROR),
+        onErrorTester, null);
+  }
+
+  @Test
+  public void testSetVolumeStates() {
+    testStates(new MethodSpec("setVolume", new Class<?>[] { float.class,
+        float.class }, new Object[] { 1.0f, 1.0f }), EnumSet.of(ERROR),
+        onErrorTester, null);
+  }
+
+  @Test
+  public void testSetDataSourceStates() {
+    final EnumSet<State> invalidStates = EnumSet.of(INITIALIZED, PREPARED,
+        STARTED, PAUSED, PLAYBACK_COMPLETED, STOPPED, ERROR);
+
+    testStates(
+        new MethodSpec("setDataSource", DUMMY_SOURCE), invalidStates, iseTester, INITIALIZED);
+  }
+
+  @Test
+  public void testStartStates() {
+    testStates("start",
+        EnumSet.of(IDLE, INITIALIZED, PREPARING, STOPPED, ERROR),
+        onErrorTester, STARTED);
+  }
+
+  @Test
+  public void testStopStates() {
+    testStates("stop", EnumSet.of(IDLE, INITIALIZED, ERROR), onErrorTester,
+        STOPPED);
+  }
+
+  @Test
+  public void testCurrentPosition() {
+    int[] positions = { 0, 1, 2, 1024 };
+    for (int position : positions) {
+      shadowMediaPlayer.setCurrentPosition(position);
+      assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(position);
+    }
+  }
+
+  @Test
+  public void testInitialAudioSessionIdIsNotZero() {
+    assertWithMessage("initial audioSessionId")
+        .that(mediaPlayer.getAudioSessionId())
+        .isNotEqualTo(0);
+  }
+
+  private Tester onErrorTester = new OnErrorTester(-38, 0);
+  private Tester iseTester = new ExceptionTester(IllegalStateException.class);
+  private Tester logTester = new LogTester(null);
+  private Tester assertTester = new ExceptionTester(AssertionError.class);
+
+  private void testStates(String methodName, EnumSet<State> invalidStates,
+      State nextState) {
+    testStates(new MethodSpec(methodName), invalidStates, iseTester, nextState);
+  }
+
+  public class MethodSpec {
+    public Method method;
+    // public String method;
+    public Class<?>[] argTypes;
+    public Object[] args;
+
+    public MethodSpec(String method) {
+      this(method, (Class<?>[]) null, (Object[]) null);
+    }
+
+    public MethodSpec(String method, Class<?>[] argTypes, Object[] args) {
+      try {
+        this.method = MediaPlayer.class.getDeclaredMethod(method, argTypes);
+        this.args = args;
+      } catch (NoSuchMethodException e) {
+        throw new AssertionError("Method lookup failed: " + method, e);
+      }
+    }
+
+    public MethodSpec(String method, int arg) {
+      this(method, new Class<?>[] { int.class }, new Object[] { arg });
+    }
+
+    public MethodSpec(String method, boolean arg) {
+      this(method, new Class<?>[] { boolean.class }, new Object[] { arg });
+    }
+
+    public MethodSpec(String method, Class<?> c) {
+      this(method, new Class<?>[] { c }, new Object[] { null });
+    }
+
+    public MethodSpec(String method, Object o) {
+      this(method, new Class<?>[] { o.getClass() }, new Object[] { o });
+    }
+
+    public <T> MethodSpec(String method, T o, Class<T> c) {
+      this(method, new Class<?>[] { c }, new Object[] { o });
+    }
+
+    public void invoke() throws InvocationTargetException {
+      try {
+        method.invoke(mediaPlayer, args);
+      } catch (IllegalAccessException e) {
+        throw new AssertionError(e);
+      }
+    }
+
+    @Override public String toString() {
+      return method.toString();
+    }
+  }
+
+  private void testStates(String method, EnumSet<State> invalidStates,
+      Tester tester, State next) {
+    testStates(new MethodSpec(method), invalidStates, tester, next);
+  }
+
+  private void testStates(MethodSpec method, EnumSet<State> invalidStates,
+      Tester tester, State next) {
+    final EnumSet<State> invalid = EnumSet.copyOf(invalidStates);
+
+    // The documentation specifies that the behavior of calling any
+    // function while in the PREPARING state is undefined. I tried
+    // to play it safe but reasonable, by looking at whether the PREPARED or
+    // INITIALIZED are allowed (ie, the two states that PREPARING
+    // sites between). Only if both these states are allowed is
+    // PREPARING allowed too, if either PREPARED or INITALIZED is
+    // disallowed then so is PREPARING.
+    if (invalid.contains(PREPARED) || invalid.contains(INITIALIZED)) {
+      invalid.add(PREPARING);
+    }
+    shadowMediaPlayer.setInvalidStateBehavior(InvalidStateBehavior.SILENT);
+    for (State state : State.values()) {
+      shadowMediaPlayer.setState(state);
+      testMethodSuccess(method, next);
+    }
+
+    shadowMediaPlayer.setInvalidStateBehavior(InvalidStateBehavior.EMULATE);
+    for (State state : invalid) {
+      shadowMediaPlayer.setState(state);
+      tester.test(method);
+    }
+    for (State state : EnumSet.complementOf(invalid)) {
+      if (state == END) {
+        continue;
+      }
+      shadowMediaPlayer.setState(state);
+      testMethodSuccess(method, next);
+    }
+
+    // END state: by inspection we determined that if a method
+    // doesn't raise any kind of error in any other state then neither
+    // will it raise one in the END state; however if it raises errors
+    // in other states of any kind then it will throw
+    // IllegalArgumentException when in END.
+    shadowMediaPlayer.setState(END);
+    if (invalid.isEmpty()) {
+      testMethodSuccess(method, END);
+    } else {
+      iseTester.test(method);
+    }
+
+    shadowMediaPlayer.setInvalidStateBehavior(InvalidStateBehavior.ASSERT);
+    for (State state : invalid) {
+      shadowMediaPlayer.setState(state);
+      assertTester.test(method);
+    }
+    for (State state : EnumSet.complementOf(invalid)) {
+      if (state == END) {
+        continue;
+      }
+      shadowMediaPlayer.setState(state);
+      testMethodSuccess(method, next);
+    }
+    shadowMediaPlayer.setState(END);
+    if (invalid.isEmpty()) {
+      testMethodSuccess(method, END);
+    } else {
+      assertTester.test(method);
+    }
+  }
+
+  private interface Tester {
+    void test(MethodSpec method);
+  }
+
+  private class OnErrorTester implements Tester {
+    private int what;
+    private int extra;
+
+    public OnErrorTester(int what, int extra) {
+      this.what = what;
+      this.extra = extra;
+    }
+
+    @Override
+    public void test(MethodSpec method) {
+      final State state = shadowMediaPlayer.getState();
+      shadowMainLooper().pause();
+      try {
+        method.invoke();
+      } catch (InvocationTargetException e) {
+        throw new RuntimeException("Expected <" + method
+            + "> to call onError rather than throw <" + e.getTargetException()
+            + "> when called from <" + state + ">", e);
+      }
+      Mockito.verifyNoMoreInteractions(errorListener);
+      final State finalState = shadowMediaPlayer.getState();
+      assertThat(finalState).isSameInstanceAs(ERROR);
+      shadowMainLooper().idle();
+      Mockito.verify(errorListener).onError(mediaPlayer, what, extra);
+      Mockito.reset(errorListener);
+    }
+  }
+
+  private class ExceptionTester implements Tester {
+    private Class<? extends Throwable> eClass;
+
+    public ExceptionTester(Class<? extends Throwable> eClass) {
+      this.eClass = eClass;
+    }
+
+    @Override
+    @SuppressWarnings("MissingFail")
+    public void test(MethodSpec method) {
+      final State state = shadowMediaPlayer.getState();
+      boolean success = false;
+      try {
+        method.invoke();
+        success = true;
+      } catch (InvocationTargetException e) {
+        Throwable cause = e.getTargetException();
+        assertThat(cause).isInstanceOf(eClass);
+        final State finalState = shadowMediaPlayer.getState();
+        assertThat(finalState).isSameInstanceAs(state);
+      }
+      assertThat(success).isFalse();
+    }
+  }
+
+  private class LogTester implements Tester {
+    private State next;
+
+    public LogTester(State next) {
+      this.next = next;
+    }
+
+    @Override
+    public void test(MethodSpec method) {
+      testMethodSuccess(method, next);
+    }
+  }
+
+  private void testMethodSuccess(MethodSpec method, State next) {
+    final State state = shadowMediaPlayer.getState();
+    try {
+      method.invoke();
+      final State finalState = shadowMediaPlayer.getState();
+      if (next == null) {
+        assertThat(finalState).isEqualTo(state);
+      } else {
+        assertThat(finalState).isEqualTo(next);
+      }
+    } catch (InvocationTargetException e) {
+      Throwable cause = e.getTargetException();
+          fail("<" + method + "> should not throw exception when in state <"
+              + state + ">" + cause);
+    }
+  }
+
+  private static final State[] seekableStates = { PREPARED, PAUSED,
+      PLAYBACK_COMPLETED, STARTED };
+
+  // It is not 100% clear from the docs if seeking to < 0 should
+  // invoke an error. I have assumed from the documentation
+  // which says "Successful invoke of this method in a valid
+  // state does not change the state" that it doesn't invoke an
+  // error. Rounding the seek up to 0 seems to be the sensible
+  // alternative behavior.
+  @Test
+  public void testSeekBeforeStart() {
+    shadowMediaPlayer.setSeekDelay(-1);
+    for (State state : seekableStates) {
+      shadowMediaPlayer.setState(state);
+      shadowMediaPlayer.setCurrentPosition(500);
+
+      mediaPlayer.seekTo(-1);
+      shadowMediaPlayer.invokeSeekCompleteListener();
+
+      assertWithMessage("Current postion while " + state)
+          .that(mediaPlayer.getCurrentPosition())
+          .isEqualTo(0);
+      assertWithMessage("Final state " + state).that(shadowMediaPlayer.getState()).isEqualTo(state);
+    }
+  }
+
+  // Similar comments apply to this test as to
+  // testSeekBeforeStart().
+  @Test
+  public void testSeekPastEnd() {
+    shadowMediaPlayer.setSeekDelay(-1);
+    for (State state : seekableStates) {
+      shadowMediaPlayer.setState(state);
+      shadowMediaPlayer.setCurrentPosition(500);
+      mediaPlayer.seekTo(1001);
+      shadowMediaPlayer.invokeSeekCompleteListener();
+
+      assertWithMessage("Current postion while " + state)
+          .that(mediaPlayer.getCurrentPosition())
+          .isEqualTo(1000);
+      assertWithMessage("Final state " + state).that(shadowMediaPlayer.getState()).isEqualTo(state);
+    }
+  }
+
+  @Test
+  public void testCompletionListener() {
+    shadowMediaPlayer.invokeCompletionListener();
+
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+  }
+
+  @Test
+  public void testCompletionWithoutListenerDoesNotThrowException() {
+    mediaPlayer.setOnCompletionListener(null);
+    shadowMediaPlayer.invokeCompletionListener();
+
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PLAYBACK_COMPLETED);
+    Mockito.verifyNoMoreInteractions(completionListener);
+  }
+
+  @Test
+  public void testSeekListener() {
+    shadowMediaPlayer.invokeSeekCompleteListener();
+
+    Mockito.verify(seekListener).onSeekComplete(mediaPlayer);
+  }
+
+  @Test
+  public void testSeekWithoutListenerDoesNotThrowException() {
+    mediaPlayer.setOnSeekCompleteListener(null);
+    shadowMediaPlayer.invokeSeekCompleteListener();
+
+    Mockito.verifyNoMoreInteractions(seekListener);
+  }
+
+  @Test
+  public void testSeekDuringPlaybackDelayedCallback() {
+    shadowMediaPlayer.setState(PREPARED);
+    shadowMediaPlayer.setSeekDelay(100);
+
+    assertThat(shadowMediaPlayer.getSeekDelay()).isEqualTo(100);
+
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(200);
+    mediaPlayer.seekTo(450);
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(200);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(99));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(200);
+    Mockito.verifyNoMoreInteractions(seekListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(450);
+    Mockito.verify(seekListener).onSeekComplete(mediaPlayer);
+
+    Mockito.verifyNoMoreInteractions(seekListener);
+  }
+
+  @Test
+  public void testSeekWhilePausedDelayedCallback() {
+    shadowMediaPlayer.setState(PAUSED);
+    shadowMediaPlayer.setSeekDelay(100);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    mediaPlayer.seekTo(450);
+    shadowMainLooper().idleFor(Duration.ofMillis(99));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(0);
+    Mockito.verifyNoMoreInteractions(seekListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(450);
+    Mockito.verify(seekListener).onSeekComplete(mediaPlayer);
+    // Check that no completion callback or alternative
+    // seek callbacks have been scheduled.
+    assertNoPostedTasks();
+  }
+
+  @Test
+  public void testSeekWhileSeekingWhilePaused() {
+    shadowMediaPlayer.setState(PAUSED);
+    shadowMediaPlayer.setSeekDelay(100);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    mediaPlayer.seekTo(450);
+    shadowMainLooper().idleFor(Duration.ofMillis(50));
+    mediaPlayer.seekTo(600);
+    shadowMainLooper().idleFor(Duration.ofMillis(99));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(0);
+    Mockito.verifyNoMoreInteractions(seekListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(600);
+    Mockito.verify(seekListener).onSeekComplete(mediaPlayer);
+    // Check that no completion callback or alternative
+    // seek callbacks have been scheduled.
+    assertNoPostedTasks();
+  }
+
+  @Test
+  public void testSeekWhileSeekingWhilePlaying() {
+    shadowMediaPlayer.setState(PREPARED);
+    shadowMediaPlayer.setSeekDelay(100);
+
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    mediaPlayer.seekTo(450);
+    shadowMainLooper().idleFor(Duration.ofMillis(50));
+    mediaPlayer.seekTo(600);
+    shadowMainLooper().idleFor(Duration.ofMillis(99));
+
+    // Not sure of the correct behavior to emulate here, as the MediaPlayer
+    // documentation is not detailed enough. There are three possibilities:
+    // 1. Playback is paused for the entire time that a seek is in progress.
+    // 2. Playback continues normally until the seek is complete.
+    // 3. Somewhere between these two extremes - playback continues for
+    // a while and then pauses until the seek is complete.
+    // I have decided to emulate the first. I don't think that
+    // implementations should depend on any of these particular behaviors
+    // and consider the behavior indeterminate.
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(200);
+    Mockito.verifyNoMoreInteractions(seekListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(600);
+    Mockito.verify(seekListener).onSeekComplete(mediaPlayer);
+    // Check that the completion callback is scheduled properly
+    // but no alternative seek callbacks.
+    shadowMainLooper().idleFor(Duration.ofMillis(400));
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+    Mockito.verifyNoMoreInteractions(seekListener);
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(1000);
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PLAYBACK_COMPLETED);
+  }
+
+  @Test
+  public void testSimulatenousEventsAllRun() {
+    // Simultaneous events should all run even if
+    // one of them stops playback.
+    MediaEvent e1 = (mp, smp) -> smp.doStop();
+    MediaEvent e2 = Mockito.mock(MediaEvent.class);
+
+    info.scheduleEventAtOffset(100, e1);
+    info.scheduleEventAtOffset(100, e2);
+
+    shadowMediaPlayer.setState(INITIALIZED);
+    shadowMediaPlayer.doStart();
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+    // Verify that the first event ran
+    assertThat(shadowMediaPlayer.isReallyPlaying()).isFalse();
+    Mockito.verify(e2).run(mediaPlayer, shadowMediaPlayer);
+  }
+
+  @Test
+  public void testResetCancelsCallbacks() {
+    shadowMediaPlayer.setState(STARTED);
+    mediaPlayer.seekTo(100);
+    MediaEvent e = Mockito.mock(MediaEvent.class);
+    shadowMediaPlayer.postEventDelayed(e, 200);
+    mediaPlayer.reset();
+
+    assertNoPostedTasks();
+  }
+
+  @Test
+  public void testReleaseCancelsSeekCallback() {
+    shadowMediaPlayer.setState(STARTED);
+    mediaPlayer.seekTo(100);
+    MediaEvent e = Mockito.mock(MediaEvent.class);
+    shadowMediaPlayer.postEventDelayed(e, 200);
+    mediaPlayer.release();
+
+    assertNoPostedTasks();
+  }
+
+  @Test
+  public void testSeekManualCallback() {
+    // Need to put the player into a state where seeking is allowed
+    shadowMediaPlayer.setState(STARTED);
+    // seekDelay of -1 signifies that OnSeekComplete won't be
+    // invoked automatically by the shadow player itself.
+    shadowMediaPlayer.setSeekDelay(-1);
+
+    assertWithMessage("pendingSeek before").that(shadowMediaPlayer.getPendingSeek()).isEqualTo(-1);
+    int[] positions = { 0, 5, 2, 999 };
+    int prevPos = 0;
+    for (int position : positions) {
+      mediaPlayer.seekTo(position);
+
+      assertWithMessage("pendingSeek").that(shadowMediaPlayer.getPendingSeek()).isEqualTo(position);
+      assertWithMessage("pendingSeekCurrentPos")
+          .that(mediaPlayer.getCurrentPosition())
+          .isEqualTo(prevPos);
+
+      shadowMediaPlayer.invokeSeekCompleteListener();
+
+      assertThat(shadowMediaPlayer.getPendingSeek()).isEqualTo(-1);
+      assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(position);
+      prevPos = position;
+    }
+  }
+
+  @Test
+  public void testPreparedListenerCalled() {
+    shadowMediaPlayer.invokePreparedListener();
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PREPARED);
+    Mockito.verify(preparedListener).onPrepared(mediaPlayer);
+  }
+
+  @Test
+  public void testPreparedWithoutListenerDoesNotThrowException() {
+    mediaPlayer.setOnPreparedListener(null);
+    shadowMediaPlayer.invokePreparedListener();
+
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(PREPARED);
+    Mockito.verifyNoMoreInteractions(preparedListener);
+  }
+
+  @Test
+  public void testInfoListenerCalled() {
+    shadowMediaPlayer.invokeInfoListener(21, 32);
+    Mockito.verify(infoListener).onInfo(mediaPlayer, 21, 32);
+  }
+
+  @Test
+  public void testInfoWithoutListenerDoesNotThrowException() {
+    mediaPlayer.setOnInfoListener(null);
+    shadowMediaPlayer.invokeInfoListener(3, 44);
+
+    Mockito.verifyNoMoreInteractions(infoListener);
+  }
+
+  @Test
+  public void testErrorListenerCalledNoOnCompleteCalledWhenReturnTrue() {
+    Mockito.when(errorListener.onError(mediaPlayer, 112, 221)).thenReturn(true);
+
+    shadowMediaPlayer.invokeErrorListener(112, 221);
+
+    assertThat(shadowMediaPlayer.getState()).isEqualTo(ERROR);
+    Mockito.verify(errorListener).onError(mediaPlayer, 112, 221);
+    Mockito.verifyNoMoreInteractions(completionListener);
+  }
+
+  @Test
+  public void testErrorListenerCalledOnCompleteCalledWhenReturnFalse() {
+    Mockito.when(errorListener.onError(mediaPlayer, 0, 0)).thenReturn(false);
+
+    shadowMediaPlayer.invokeErrorListener(321, 11);
+
+    Mockito.verify(errorListener).onError(mediaPlayer, 321, 11);
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+  }
+
+  @Test
+  public void testErrorCausesOnCompleteCalledWhenNoErrorListener() {
+    mediaPlayer.setOnErrorListener(null);
+
+    shadowMediaPlayer.invokeErrorListener(321, 21);
+
+    Mockito.verifyNoMoreInteractions(errorListener);
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+  }
+
+  @Test
+  public void testReleaseStopsScheduler() {
+    shadowMediaPlayer.doStart();
+    mediaPlayer.release();
+    assertNoPostedTasks();
+  }
+
+  protected void assertNoPostedTasks() {
+    assertThat(shadowMainLooper().getNextScheduledTaskTime()).isEqualTo(Duration.ZERO);
+  }
+
+  @Test
+  public void testResetStopsScheduler() {
+    shadowMediaPlayer.doStart();
+    mediaPlayer.reset();
+    assertNoPostedTasks();
+  }
+
+  @Test
+  public void testDoStartStop() {
+    assertThat(shadowMediaPlayer.isReallyPlaying()).isFalse();
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+    shadowMediaPlayer.doStart();
+    assertThat(shadowMediaPlayer.isReallyPlaying()).isTrue();
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(0);
+    assertThat(shadowMediaPlayer.getState()).isSameInstanceAs(IDLE);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(100);
+
+    shadowMediaPlayer.doStop();
+    assertThat(shadowMediaPlayer.isReallyPlaying()).isFalse();
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(100);
+    assertThat(shadowMediaPlayer.getState()).isSameInstanceAs(IDLE);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(50));
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(100);
+  }
+
+  @Test
+  public void testScheduleErrorAtOffsetWhileNotPlaying() {
+    info.scheduleErrorAtOffset(500, 1, 3);
+    shadowMediaPlayer.setState(INITIALIZED);
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(499));
+    Mockito.verifyNoMoreInteractions(errorListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    Mockito.verify(errorListener).onError(mediaPlayer, 1, 3);
+    assertThat(shadowMediaPlayer.getState()).isSameInstanceAs(ERROR);
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(500);
+  }
+
+  @Test
+  public void testScheduleErrorAtOffsetInPast() {
+    info.scheduleErrorAtOffset(200, 1, 2);
+    shadowMediaPlayer.setState(INITIALIZED);
+    shadowMediaPlayer.setCurrentPosition(400);
+    shadowMediaPlayer.setState(PAUSED);
+    mediaPlayer.start();
+    Mockito.verifyNoMoreInteractions(errorListener);
+  }
+
+  @Test
+  public void testScheduleBufferUnderrunAtOffset() {
+    info.scheduleBufferUnderrunAtOffset(100, 50);
+    shadowMediaPlayer.setState(INITIALIZED);
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(99));
+
+    Mockito.verifyNoMoreInteractions(infoListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    Mockito.verify(infoListener).onInfo(mediaPlayer,
+        MediaPlayer.MEDIA_INFO_BUFFERING_START, 0);
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(100);
+    assertThat(shadowMediaPlayer.isReallyPlaying()).isFalse();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(49));
+    Mockito.verifyNoMoreInteractions(infoListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(100);
+    Mockito.verify(infoListener).onInfo(mediaPlayer,
+        MediaPlayer.MEDIA_INFO_BUFFERING_END, 0);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(100));
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(200);
+  }
+
+  @Test
+  public void testRemoveEventAtOffset() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+
+    MediaEvent e = info.scheduleInfoAtOffset(
+        500, 1, 3);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(299));
+    info.removeEventAtOffset(500, e);
+    Mockito.verifyNoMoreInteractions(infoListener);
+  }
+
+  @Test
+  public void testRemoveEvent() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+
+    MediaEvent e = info.scheduleInfoAtOffset(500, 1, 3);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(299));
+    shadowMediaPlayer.doStop();
+    info.removeEvent(e);
+    shadowMediaPlayer.doStart();
+    Mockito.verifyNoMoreInteractions(infoListener);
+  }
+
+  @Test
+  public void testScheduleMultipleRunnables() {
+    shadowMediaPlayer.setState(PREPARED);
+    shadowMainLooper().idleFor(Duration.ofMillis(25));
+    mediaPlayer.start();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    // assertThat(scheduler.size()).isEqualTo(1);
+    shadowMediaPlayer.doStop();
+    info.scheduleInfoAtOffset(250, 2, 4);
+    shadowMediaPlayer.doStart();
+    // assertThat(scheduler.size()).isEqualTo(1);
+
+    MediaEvent e1 = Mockito.mock(MediaEvent.class);
+
+    shadowMediaPlayer.doStop();
+    info.scheduleEventAtOffset(400, e1);
+    shadowMediaPlayer.doStart();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(49));
+    Mockito.verifyNoMoreInteractions(infoListener);
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    Mockito.verify(infoListener).onInfo(mediaPlayer, 2, 4);
+    shadowMainLooper().idleFor(Duration.ofMillis(149));
+    shadowMediaPlayer.doStop();
+    info.scheduleErrorAtOffset(675, 32, 22);
+    shadowMediaPlayer.doStart();
+    Mockito.verifyNoMoreInteractions(e1);
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    Mockito.verify(e1).run(mediaPlayer, shadowMediaPlayer);
+
+    mediaPlayer.pause();
+    assertNoPostedTasks();
+    shadowMainLooper().idleFor(Duration.ofMillis(324));
+    MediaEvent e2 = Mockito.mock(MediaEvent.class);
+    info.scheduleEventAtOffset(680, e2);
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(274));
+    Mockito.verifyNoMoreInteractions(errorListener);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    Mockito.verify(errorListener).onError(mediaPlayer, 32, 22);
+    assertNoPostedTasks();
+    assertThat(shadowMediaPlayer.getCurrentPositionRaw()).isEqualTo(675);
+    assertThat(shadowMediaPlayer.getState()).isSameInstanceAs(ERROR);
+    Mockito.verifyNoMoreInteractions(e2);
+  }
+
+  @Test
+  public void testSetDataSourceExceptionWithWrongExceptionTypeAsserts() {
+    boolean fail = false;
+    Map<DataSource, Exception> exceptions =
+        ReflectionHelpers.getStaticField(ShadowMediaPlayer.class, "exceptions");
+    DataSource ds = toDataSource("dummy");
+    Exception e = new CloneNotSupportedException(); // just a convenient, non-RuntimeException in java.lang
+    exceptions.put(ds, e);
+
+    try {
+      shadowMediaPlayer.setDataSource(ds);
+      fail = true;
+    } catch (AssertionError a) {
+    } catch (IOException ioe) {
+      fail("Got exception <" + ioe + ">; expecting assertion");
+    }
+    if (fail) {
+      fail("setDataSource() should assert with non-IOException,non-RuntimeException");
+    }
+  }
+
+  @Test
+  public void testSetDataSourceCustomExceptionOverridesIllegalState() {
+    shadowMediaPlayer.setState(PREPARED);
+    ShadowMediaPlayer.addException(toDataSource("dummy"), new IOException());
+    try {
+      mediaPlayer.setDataSource("dummy");
+      fail("Expecting IOException to be thrown");
+    } catch (IOException eThrown) {
+    } catch (Exception eThrown) {
+      fail(eThrown + " was thrown, expecting IOException");
+    }
+  }
+
+  @Test
+  public void testGetSetLooping() {
+    assertThat(mediaPlayer.isLooping()).isFalse();
+    mediaPlayer.setLooping(true);
+    assertThat(mediaPlayer.isLooping()).isTrue();
+    mediaPlayer.setLooping(false);
+    assertThat(mediaPlayer.isLooping()).isFalse();
+  }
+
+  /**
+   * If the looping mode was being set to {@code true}
+   * {@link MediaPlayer#setLooping(boolean)}, the MediaPlayer object shall
+   * remain in the Started state.
+   */
+  @Test
+  public void testSetLoopingCalledWhilePlaying() {
+    shadowMediaPlayer.setState(PREPARED);
+    mediaPlayer.start();
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+
+    mediaPlayer.setLooping(true);
+    shadowMainLooper().idleFor(Duration.ofMillis(1100));
+
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    mediaPlayer.setLooping(false);
+    shadowMainLooper().idleFor(Duration.ofMillis(699));
+    Mockito.verifyNoMoreInteractions(completionListener);
+
+    shadowMainLooper().idleFor(Duration.ofMinutes(1));
+    Mockito.verify(completionListener).onCompletion(mediaPlayer);
+  }
+
+  @Test
+  public void testSetLoopingCalledWhileStartable() {
+    final State[] startableStates = { PREPARED, PAUSED };
+    for (State state : startableStates) {
+      shadowMediaPlayer.setCurrentPosition(500);
+      shadowMediaPlayer.setState(state);
+
+      mediaPlayer.setLooping(true);
+      mediaPlayer.start();
+
+      shadowMainLooper().idleFor(Duration.ofMillis(700));
+      Mockito.verifyNoMoreInteractions(completionListener);
+    }
+  }
+
+  /**
+   * While in the PlaybackCompleted state, calling start() can restart the
+   * playback from the beginning of the audio/video source.
+   */
+  @Test
+  public void testStartAfterPlaybackCompleted() {
+    shadowMediaPlayer.setState(PLAYBACK_COMPLETED);
+    shadowMediaPlayer.setCurrentPosition(1000);
+
+    mediaPlayer.start();
+    assertThat(mediaPlayer.getCurrentPosition()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void testNativeSetOutputDevice_setPreferredDevice_succeeds() {
+    // native_setOutputDevice is a private method used by the public setPreferredDevice() method;
+    // test through the public method.
+    assertThat(mediaPlayer.setPreferredDevice(createAudioDeviceInfo(ROLE_SINK))).isTrue();
+  }
+
+  private static AudioDeviceInfo createAudioDeviceInfo(int role) {
+    AudioDeviceInfo info = Shadow.newInstanceOf(AudioDeviceInfo.class);
+    try {
+      Field portField = AudioDeviceInfo.class.getDeclaredField("mPort");
+      portField.setAccessible(true);
+      Object port = Shadow.newInstanceOf("android.media.AudioDevicePort");
+      portField.set(info, port);
+      Field roleField = port.getClass().getSuperclass().getDeclaredField("mRole");
+      roleField.setAccessible(true);
+      roleField.set(port, role);
+      Field handleField = port.getClass().getSuperclass().getDeclaredField("mHandle");
+      handleField.setAccessible(true);
+      Object handle = Shadow.newInstanceOf("android.media.AudioHandle");
+      handleField.set(port, handle);
+      Field idField = handle.getClass().getDeclaredField("mId");
+      idField.setAccessible(true);
+      idField.setInt(handle, /* id= */ 1);
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError(e.getMessage(), e);
+    }
+    return info;
+  }
+
+  @Test
+  public void testResetStaticState() {
+    ShadowMediaPlayer.CreateListener createListener = Mockito
+        .mock(ShadowMediaPlayer.CreateListener.class);
+    ShadowMediaPlayer.setCreateListener(createListener);
+    assertWithMessage("createListener")
+        .that(ShadowMediaPlayer.createListener)
+        .isSameInstanceAs(createListener);
+    DataSource dummy = toDataSource("stuff");
+    IOException e = new IOException();
+    addException(dummy, e);
+
+    try {
+      shadowMediaPlayer.setState(IDLE);
+      shadowMediaPlayer.setDataSource(dummy);
+      fail("Expected exception thrown");
+    } catch (IOException e2) {
+      assertWithMessage("thrown exception").that(e2).isSameInstanceAs(e);
+    }
+    // Check that the mediaInfo was cleared
+    shadowMediaPlayer.doSetDataSource(defaultSource);
+    assertWithMessage("mediaInfo:before").that(shadowMediaPlayer.getMediaInfo()).isNotNull();
+
+    ShadowMediaPlayer.resetStaticState();
+
+    // Check that the listener was cleared.
+    assertWithMessage("createListener").that(ShadowMediaPlayer.createListener).isNull();
+
+    // Check that the mediaInfo was cleared.
+    try {
+      shadowMediaPlayer.doSetDataSource(defaultSource);
+      fail("Expected exception thrown");
+    } catch (IllegalArgumentException ie) {
+      // We expect this if the static state has been cleared.
+    }
+
+    // Check that the exception was cleared.
+    try {
+      shadowMediaPlayer.setState(IDLE);
+      ShadowMediaPlayer.addMediaInfo(dummy, info);
+      shadowMediaPlayer.setDataSource(dummy);
+    } catch (IOException e2) {
+      fail("Exception was not cleared by resetStaticState() for <" + dummy + ">" + e2);
+    }
+  }
+
+  @Test
+  public void setDataSourceException_withRuntimeException() {
+    RuntimeException e = new RuntimeException("some dummy message");
+    addException(toDataSource("dummy"), e);
+    try {
+      mediaPlayer.setDataSource("dummy");
+      fail("Expected exception thrown");
+    } catch (Exception caught) {
+      assertThat(caught).isSameInstanceAs(e);
+      assertWithMessage("Stack trace should originate in Shadow")
+          .that(e.getStackTrace()[0].getClassName())
+          .isEqualTo(ShadowMediaPlayer.class.getName());
+    }
+  }
+
+  @Test
+  public void setDataSourceException_withIOException() {
+    IOException e = new IOException("some dummy message");
+    addException(toDataSource("dummy"), e);
+    shadowMediaPlayer.setState(IDLE);
+    try {
+      mediaPlayer.setDataSource("dummy");
+      fail("Expected exception thrown");
+    } catch (Exception caught) {
+      assertThat(caught).isSameInstanceAs(e);
+      assertWithMessage("Stack trace should originate in Shadow")
+          .that(e.getStackTrace()[0].getClassName())
+          .isEqualTo(ShadowMediaPlayer.class.getName());
+      assertWithMessage("State after " + e + " thrown should be unchanged")
+          .that(shadowMediaPlayer.getState())
+          .isSameInstanceAs(IDLE);
+    }
+  }
+
+  @Test
+  public void setDataSource_forNoDataSource_asserts() {
+    try {
+      mediaPlayer.setDataSource("some unspecified data source");
+      fail("Expected exception thrown");
+    } catch (IllegalArgumentException a) {
+      assertWithMessage("assertionMessage").that(a.getMessage()).contains("addException");
+      assertWithMessage("assertionMessage").that(a.getMessage()).contains("addMediaInfo");
+    } catch (Exception e) {
+      throw new RuntimeException("Unexpected exception", e);
+    }
+  }
+
+  @Test
+  public void instantiateOnBackgroundThread() throws ExecutionException, InterruptedException {
+    ShadowMediaPlayer shadowMediaPlayer =
+        Executors.newSingleThreadExecutor()
+            .submit(
+                () -> {
+                  // This thread does not have a prepared looper, so the main looper is used
+                  MediaPlayer mediaPlayer = Shadow.newInstanceOf(MediaPlayer.class);
+                  return shadowOf(mediaPlayer);
+                })
+            .get();
+    AtomicBoolean ran = new AtomicBoolean(false);
+    shadowMediaPlayer.postEvent(
+        new MediaEvent() {
+          @Override
+          public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+            assertThat(Looper.myLooper()).isSameInstanceAs(Looper.getMainLooper());
+            ran.set(true);
+          }
+        });
+    shadowMainLooper().idle();
+    assertThat(ran.get()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaRecorderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaRecorderTest.java
new file mode 100644
index 0000000..28be1b6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaRecorderTest.java
@@ -0,0 +1,269 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.hardware.Camera;
+import android.media.MediaRecorder;
+import android.os.Build.VERSION_CODES;
+import android.view.Surface;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMediaRecorderTest {
+
+  private MediaRecorder mediaRecorder;
+  private ShadowMediaRecorder shadowMediaRecorder;
+
+  @Before
+  public void setUp() throws Exception {
+    mediaRecorder = new MediaRecorder();
+    shadowMediaRecorder = Shadows.shadowOf(mediaRecorder);
+  }
+
+  @Test
+  public void testAudioChannels() {
+    assertThat(shadowMediaRecorder.getAudioChannels()).isNotEqualTo(2);
+    mediaRecorder.setAudioChannels(2);
+    assertThat(shadowMediaRecorder.getAudioChannels()).isEqualTo(2);
+  }
+
+  @Test
+  public void testAudioEncoder() {
+    assertThat(shadowMediaRecorder.getAudioEncoder()).isNotEqualTo(MediaRecorder.AudioEncoder.AMR_NB);
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);
+    assertThat(shadowMediaRecorder.getAudioEncoder()).isEqualTo(MediaRecorder.AudioEncoder.AMR_NB);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testAudioEncodingBitRate() {
+    assertThat(shadowMediaRecorder.getAudioEncodingBitRate()).isNotEqualTo(128000);
+    mediaRecorder.setAudioEncodingBitRate(128000);
+    assertThat(shadowMediaRecorder.getAudioEncodingBitRate()).isEqualTo(128000);
+  }
+
+  @Test
+  public void testAudioSamplingRate() {
+    assertThat(shadowMediaRecorder.getAudioSamplingRate()).isNotEqualTo(22050);
+    mediaRecorder.setAudioSamplingRate(22050);
+    assertThat(shadowMediaRecorder.getAudioSamplingRate()).isEqualTo(22050);
+  }
+
+  @Test
+  public void testAudioSource() {
+    assertThat(shadowMediaRecorder.getAudioSource()).isNotEqualTo(MediaRecorder.AudioSource.CAMCORDER);
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_INITIALIZED);
+    mediaRecorder.setAudioSource(MediaRecorder.AudioSource.CAMCORDER);
+    assertThat(shadowMediaRecorder.getAudioSource()).isEqualTo(MediaRecorder.AudioSource.CAMCORDER);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_INITIALIZED);
+  }
+
+  @Test
+  public void testCamera() {
+    assertThat(shadowMediaRecorder.getCamera()).isNull();
+    Camera c = Shadow.newInstanceOf(Camera.class);
+    mediaRecorder.setCamera(c);
+    assertThat(shadowMediaRecorder.getCamera()).isNotNull();
+    assertThat(shadowMediaRecorder.getCamera()).isSameInstanceAs(c);
+  }
+
+  @Test
+  public void testMaxDuration() {
+    assertThat(shadowMediaRecorder.getMaxDuration()).isNotEqualTo(30000);
+    mediaRecorder.setMaxDuration(30000);
+    assertThat(shadowMediaRecorder.getMaxDuration()).isEqualTo(30000);
+  }
+
+  @Test
+  public void testMaxFileSize() {
+    assertThat(shadowMediaRecorder.getMaxFileSize()).isNotEqualTo(512000L);
+    mediaRecorder.setMaxFileSize(512000);
+    assertThat(shadowMediaRecorder.getMaxFileSize()).isEqualTo(512000L);
+  }
+
+  @Test
+  public void testOnErrorListener() {
+    assertThat(shadowMediaRecorder.getErrorListener()).isNull();
+    TestErrorListener listener = new TestErrorListener();
+    mediaRecorder.setOnErrorListener(listener);
+    assertThat(shadowMediaRecorder.getErrorListener()).isNotNull();
+    assertThat(shadowMediaRecorder.getErrorListener()).isSameInstanceAs(listener);
+  }
+
+  @Test
+  public void testOnInfoListener() {
+    assertThat(shadowMediaRecorder.getInfoListener()).isNull();
+    TestInfoListener listener = new TestInfoListener();
+    mediaRecorder.setOnInfoListener(listener);
+    assertThat(shadowMediaRecorder.getInfoListener()).isNotNull();
+    assertThat(shadowMediaRecorder.getInfoListener()).isSameInstanceAs(listener);
+  }
+
+  @Test
+  public void testOutputFile() {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    assertThat(shadowMediaRecorder.getOutputPath()).isNull();
+    mediaRecorder.setOutputFile("/dev/null");
+    assertThat(shadowMediaRecorder.getOutputPath()).isEqualTo("/dev/null");
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testOutputFormat() {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    assertThat(shadowMediaRecorder.getOutputFormat()).isNotEqualTo(MediaRecorder.OutputFormat.MPEG_4);
+    mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
+    assertThat(shadowMediaRecorder.getOutputFormat()).isEqualTo(MediaRecorder.OutputFormat.MPEG_4);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testPreviewDisplay() {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    assertThat(shadowMediaRecorder.getPreviewDisplay()).isNull();
+    Surface surface = Shadow.newInstanceOf(Surface.class);
+    mediaRecorder.setPreviewDisplay(surface);
+    assertThat(shadowMediaRecorder.getPreviewDisplay()).isNotNull();
+    assertThat(shadowMediaRecorder.getPreviewDisplay()).isSameInstanceAs(surface);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testVideoEncoder() {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    assertThat(shadowMediaRecorder.getVideoEncoder()).isNotEqualTo(MediaRecorder.VideoEncoder.H264);
+    mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);
+    assertThat(shadowMediaRecorder.getVideoEncoder()).isEqualTo(MediaRecorder.VideoEncoder.H264);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testVideoEncodingBitRate() {
+    assertThat(shadowMediaRecorder.getVideoEncodingBitRate()).isNotEqualTo(320000);
+    mediaRecorder.setVideoEncodingBitRate(320000);
+    assertThat(shadowMediaRecorder.getVideoEncodingBitRate()).isEqualTo(320000);
+  }
+
+  @Test
+  public void testVideoFrameRate() {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    assertThat(shadowMediaRecorder.getVideoFrameRate()).isNotEqualTo(30);
+    mediaRecorder.setVideoFrameRate(30);
+    assertThat(shadowMediaRecorder.getVideoFrameRate()).isEqualTo(30);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testVideoSize() {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+    assertThat(shadowMediaRecorder.getVideoWidth()).isNotEqualTo(640);
+    assertThat(shadowMediaRecorder.getVideoHeight()).isNotEqualTo(480);
+    mediaRecorder.setVideoSize(640, 480);
+    assertThat(shadowMediaRecorder.getVideoWidth()).isEqualTo(640);
+    assertThat(shadowMediaRecorder.getVideoHeight()).isEqualTo(480);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_DATA_SOURCE_CONFIGURED);
+  }
+
+  @Test
+  public void testVideoSource() {
+    assertThat(shadowMediaRecorder.getVideoSource()).isNotEqualTo(MediaRecorder.VideoSource.CAMERA);
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_INITIALIZED);
+    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);
+    assertThat(shadowMediaRecorder.getVideoSource()).isEqualTo(MediaRecorder.VideoSource.CAMERA);
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_INITIALIZED);
+  }
+
+  @Test
+  public void testPrepare() throws Exception {
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_PREPARED);
+    mediaRecorder.prepare();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_PREPARED);
+  }
+
+  @Test
+  public void testStart() throws Exception {
+    mediaRecorder.prepare();
+    assertThat(shadowMediaRecorder.getState()).isNotEqualTo(ShadowMediaRecorder.STATE_RECORDING);
+    mediaRecorder.start();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_RECORDING);
+  }
+
+  @Test
+  public void testStop() {
+    mediaRecorder.start();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_RECORDING);
+    mediaRecorder.stop();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_INITIAL);
+  }
+
+  @Test
+  public void testReset() {
+    mediaRecorder.start();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_RECORDING);
+    mediaRecorder.reset();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_INITIAL);
+  }
+
+  @Test
+  public void testRelease() {
+    mediaRecorder.start();
+    mediaRecorder.reset();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_INITIAL);
+    mediaRecorder.release();
+    assertThat(shadowMediaRecorder.getState()).isEqualTo(ShadowMediaRecorder.STATE_RELEASED);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void testGetSurface() throws Exception {
+    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
+    mediaRecorder.prepare();
+    assertThat(mediaRecorder.getSurface()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void testGetSurface_beforePrepare() {
+    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
+    assertThrows(IllegalStateException.class, () -> mediaRecorder.getSurface());
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void testGetSurface_afterStop() throws Exception {
+    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
+    mediaRecorder.prepare();
+    mediaRecorder.start();
+    mediaRecorder.stop();
+    assertThrows(IllegalStateException.class, () -> mediaRecorder.getSurface());
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.LOLLIPOP)
+  public void testGetSurface_wrongVideoSource() throws Exception {
+    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);
+    mediaRecorder.prepare();
+    assertThrows(IllegalStateException.class, () -> mediaRecorder.getSurface());
+  }
+
+  private static class TestErrorListener implements MediaRecorder.OnErrorListener {
+    @Override
+    public void onError(MediaRecorder arg0, int arg1, int arg2) {
+    }
+  }
+
+  private static class TestInfoListener implements MediaRecorder.OnInfoListener {
+    @Override
+    public void onInfo(MediaRecorder mr, int what, int extra) {
+    }
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaRouterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaRouterTest.java
new file mode 100644
index 0000000..15f1be2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaRouterTest.java
@@ -0,0 +1,147 @@
+package org.robolectric.shadows;
+
+import static android.media.MediaRouter.ROUTE_TYPE_LIVE_AUDIO;
+import static android.media.MediaRouter.ROUTE_TYPE_LIVE_VIDEO;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.media.MediaRouter;
+import android.media.MediaRouter.RouteInfo;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowMediaRouter}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowMediaRouterTest {
+  private MediaRouter mediaRouter;
+
+  @Before
+  public void setUp() {
+    mediaRouter =
+        (MediaRouter)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.MEDIA_ROUTER_SERVICE);
+  }
+
+  @Test
+  public void testAddBluetoothRoute_additionalRouteAvailable() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    assertThat(mediaRouter.getRouteCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void testAddBluetoothRoute_bluetoothRouteSelected() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+    assertThat(mediaRouter.getSelectedRoute(ROUTE_TYPE_LIVE_AUDIO)).isEqualTo(bluetoothRoute);
+  }
+
+  @Test
+  public void testAddBluetoothRoute_checkBluetoothRouteProperties() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+    assertThat(bluetoothRoute.getName().toString())
+        .isEqualTo(ShadowMediaRouter.BLUETOOTH_DEVICE_NAME);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void testAddBluetoothRoute_checkBluetoothRouteProperties_apiJbMr2() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+    assertThat(bluetoothRoute.getDescription().toString()).isEqualTo("Bluetooth audio");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void testAddBluetoothRoute_checkBluetoothRouteProperties_apiN() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+    assertThat(bluetoothRoute.getDeviceType()).isEqualTo(RouteInfo.DEVICE_TYPE_BLUETOOTH);
+  }
+
+  @Test
+  public void testSelectBluetoothRoute_getsSetAsSelectedRoute() {
+    // Although this isn't something faked out by the shadow we should ensure that the Bluetooth
+    // route can be selected after it's been added.
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+
+    mediaRouter.selectRoute(ROUTE_TYPE_LIVE_AUDIO, bluetoothRoute);
+
+    assertThat(mediaRouter.getSelectedRoute(ROUTE_TYPE_LIVE_AUDIO)).isEqualTo(bluetoothRoute);
+  }
+
+  @Test
+  public void testRemoveBluetoothRoute_whenBluetoothSelected_defaultRouteAvailableAndSelected() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+
+    shadowOf(mediaRouter).removeBluetoothRoute();
+
+    assertThat(mediaRouter.getRouteCount()).isEqualTo(1);
+    assertThat(mediaRouter.getSelectedRoute(ROUTE_TYPE_LIVE_AUDIO)).isEqualTo(getDefaultRoute());
+  }
+
+  @Test
+  public void testRemoveBluetoothRoute_whenDefaultSelected_defaultRouteAvailableAndSelected() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+    mediaRouter.selectRoute(ROUTE_TYPE_LIVE_AUDIO, bluetoothRoute);
+
+    shadowOf(mediaRouter).removeBluetoothRoute();
+
+    assertThat(mediaRouter.getRouteCount()).isEqualTo(1);
+    assertThat(mediaRouter.getSelectedRoute(ROUTE_TYPE_LIVE_AUDIO)).isEqualTo(getDefaultRoute());
+  }
+
+  @Test
+  public void testIsBluetoothRouteSelected_bluetoothRouteNotAdded_returnsFalse() {
+    assertThat(shadowOf(mediaRouter).isBluetoothRouteSelected(ROUTE_TYPE_LIVE_AUDIO)).isFalse();
+  }
+
+  // Pre-API 18, non-user routes weren't able to be selected.
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void testIsBluetoothRouteSelected_bluetoothRouteAddedButNotSelected_returnsFalse() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    mediaRouter.selectRoute(ROUTE_TYPE_LIVE_AUDIO, getDefaultRoute());
+    assertThat(shadowOf(mediaRouter).isBluetoothRouteSelected(ROUTE_TYPE_LIVE_AUDIO)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testIsBluetoothRouteSelected_bluetoothRouteSelectedForDifferentType_returnsFalse() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+
+    // Select the Bluetooth route for AUDIO and the default route for AUDIO.
+    mediaRouter.selectRoute(ROUTE_TYPE_LIVE_AUDIO, bluetoothRoute);
+    mediaRouter.selectRoute(ROUTE_TYPE_LIVE_VIDEO, getDefaultRoute());
+
+    assertThat(shadowOf(mediaRouter).isBluetoothRouteSelected(ROUTE_TYPE_LIVE_VIDEO)).isFalse();
+  }
+
+  @Test
+  public void testIsBluetoothRouteSelected_bluetoothRouteSelected_returnsTrue() {
+    shadowOf(mediaRouter).addBluetoothRoute();
+    RouteInfo bluetoothRoute = mediaRouter.getRouteAt(1);
+    mediaRouter.selectRoute(ROUTE_TYPE_LIVE_AUDIO, bluetoothRoute);
+    assertThat(shadowOf(mediaRouter).isBluetoothRouteSelected(ROUTE_TYPE_LIVE_AUDIO)).isTrue();
+  }
+
+  private RouteInfo getDefaultRoute() {
+    if (RuntimeEnvironment.getApiLevel() >= JELLY_BEAN_MR2) {
+      return mediaRouter.getDefaultRoute();
+    }
+    return mediaRouter.getRouteAt(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaScannerConnectionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaScannerConnectionTest.java
new file mode 100644
index 0000000..e199faa
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaScannerConnectionTest.java
@@ -0,0 +1,34 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for ShadowMediaScannerConnection */
+@RunWith(AndroidJUnit4.class)
+public class ShadowMediaScannerConnectionTest {
+  private static final String[] paths = {"a", "b"};
+  private static final String[] mimeTypes = {"c", "d"};
+
+  @Test
+  public void scanFile_validParameters_shouldContainsSamePaths() {
+    ShadowMediaScannerConnection.scanFile(null, paths, mimeTypes, null);
+
+    assertThat(ShadowMediaScannerConnection.getSavedPaths()).containsExactlyElementsIn(paths);
+    assertThat(ShadowMediaScannerConnection.getSavedMimeTypes())
+        .containsExactlyElementsIn(mimeTypes);
+  }
+
+  @Test
+  public void scanFile_nullParameters_shouldContainsSamePaths() {
+    int pathsSize = ShadowMediaScannerConnection.getSavedPaths().size();
+    int mimeTypesSize = ShadowMediaScannerConnection.getSavedMimeTypes().size();
+
+    ShadowMediaScannerConnection.scanFile(null, null, null, null);
+
+    assertThat(ShadowMediaScannerConnection.getSavedPaths()).hasSize(pathsSize);
+    assertThat(ShadowMediaScannerConnection.getSavedMimeTypes()).hasSize(mimeTypesSize);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaSessionManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaSessionManagerTest.java
new file mode 100644
index 0000000..dbd3721
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaSessionManagerTest.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.media.session.MediaSessionManager;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowMediaSessionManager} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+public class ShadowMediaSessionManagerTest {
+
+  private MediaSessionManager mediaSessionManager;
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Before
+  public void setUp() throws Exception {
+    mediaSessionManager =
+        (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE);
+  }
+
+  @Test
+  public void getActiveSessions_returnsAddedControllers() {
+    MediaSession mediaSession = new MediaSession(context, "tag");
+    MediaController mediaController = new MediaController(context, mediaSession.getSessionToken());
+    Shadows.shadowOf(mediaSessionManager).addController(mediaController);
+    List<MediaController> mediaControllers = mediaSessionManager.getActiveSessions(null);
+    assertThat(mediaControllers).containsExactly(mediaController);
+  }
+
+  @Test
+  public void getActiveSessions_callsActiveSessionListeners() {
+    MediaSession mediaSession = new MediaSession(context, "tag");
+    MediaController mediaController = new MediaController(context, mediaSession.getSessionToken());
+    final List<MediaController> changedMediaControllers = new ArrayList<>();
+    Shadows.shadowOf(mediaSessionManager)
+        .addOnActiveSessionsChangedListener(changedMediaControllers::addAll, null);
+    Shadows.shadowOf(mediaSessionManager).addController(mediaController);
+    assertThat(changedMediaControllers).containsExactly(mediaController);
+  }
+
+  @Test
+  public void getActiveSessions_callsActiveSessionListenersWithProvidedHandler() {
+    MediaSession mediaSession = new MediaSession(context, "tag");
+    MediaController mediaController = new MediaController(context, mediaSession.getSessionToken());
+    final List<MediaController> changedMediaControllers = new ArrayList<>();
+    Shadows.shadowOf(mediaSessionManager)
+        .addOnActiveSessionsChangedListener(changedMediaControllers::addAll, null, null);
+    Shadows.shadowOf(mediaSessionManager).addController(mediaController);
+    assertThat(changedMediaControllers).containsExactly(mediaController);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaSessionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaSessionTest.java
new file mode 100644
index 0000000..3f1e486
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaSessionTest.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import android.media.session.MediaSession;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for robolectric functionality around {@link MediaSession}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+public class ShadowMediaSessionTest {
+  @Test
+  public void mediaSessionCompat_creation() {
+    // Should not result in an exception.
+    new MediaSession(ApplicationProvider.getApplicationContext(), "test");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaStoreTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaStoreTest.java
new file mode 100644
index 0000000..a359e1e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMediaStoreTest.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import static android.provider.MediaStore.Images;
+import static android.provider.MediaStore.Video;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMediaStoreTest {
+  @Test
+  public void shouldInitializeFields() {
+    assertThat(Images.Media.EXTERNAL_CONTENT_URI.toString())
+        .isEqualTo("content://media/external/images/media");
+    assertThat(Images.Media.INTERNAL_CONTENT_URI.toString())
+        .isEqualTo("content://media/internal/images/media");
+    assertThat(Video.Media.EXTERNAL_CONTENT_URI.toString())
+        .isEqualTo("content://media/external/video/media");
+    assertThat(Video.Media.INTERNAL_CONTENT_URI.toString())
+        .isEqualTo("content://media/internal/video/media");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMergeCursorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMergeCursorTest.java
new file mode 100644
index 0000000..f8e3c50
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMergeCursorTest.java
@@ -0,0 +1,287 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.database.Cursor;
+import android.database.MergeCursor;
+import android.database.sqlite.SQLiteCursor;
+import android.database.sqlite.SQLiteDatabase;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import dalvik.system.CloseGuard;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMergeCursorTest {
+
+  private SQLiteDatabase database;
+  private MergeCursor cursor;
+  private SQLiteCursor dbCursor1;
+  private SQLiteCursor dbCursor2;
+
+  private static String[] TABLE_1_INSERTS = {
+    "INSERT INTO table_1 (id, name_1, value_1, float_value_1, double_value_1) VALUES(1234,"
+        + " 'Chuck', 3463, 1.5, 3.14159);",
+    "INSERT INTO table_1 (id, name_1) VALUES(1235, 'Julie');",
+    "INSERT INTO table_1 (id, name_1) VALUES(1236, 'Chris');"
+  };
+
+  private static String[] TABLE_2_INSERTS = {
+    "INSERT INTO table_2 (id, name_2, value_2, float_value_2, double_value_2) VALUES(4321, 'Mary',"
+        + " 3245, 5.4, 2.7818);",
+    "INSERT INTO table_2 (id, name_2) VALUES(4322, 'Elizabeth');",
+    "INSERT INTO table_2 (id, name_2) VALUES(4323, 'Chester');"
+  };
+
+  @Before
+  public void setUp() throws Exception {
+    database = SQLiteDatabase.create(null);
+    dbCursor1 =
+        setupTable(
+            "CREATE TABLE table_1(id INTEGER PRIMARY KEY, name_1 VARCHAR(255), value_1"
+                + " INTEGER,float_value_1 REAL, double_value_1 DOUBLE, blob_value_1 BINARY,"
+                + " clob_value_1 CLOB );",
+            TABLE_1_INSERTS,
+            "SELECT * FROM table_1;");
+    dbCursor2 =
+        setupTable(
+            "CREATE TABLE table_2(id INTEGER PRIMARY KEY, name_2 VARCHAR(255), value_2"
+                + " INTEGER,float_value_2 REAL, double_value_2 DOUBLE, blob_value_2 BINARY,"
+                + " clob_value_2 CLOB );",
+            TABLE_2_INSERTS,
+            "SELECT * FROM table_2;");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    database.close();
+    if (cursor != null) {
+      cursor.close();
+    }
+    dbCursor1.close();
+    dbCursor2.close();
+  }
+
+  private SQLiteCursor setupTable(final String createSql, final String[] insertions, final String selectSql) {
+    database.execSQL(createSql);
+
+    for (String insert : insertions) {
+      database.execSQL(insert);
+    }
+
+    Cursor cursor = database.rawQuery(selectSql, null);
+    assertThat(cursor).isInstanceOf(SQLiteCursor.class);
+
+    return (SQLiteCursor) cursor;
+  }
+
+  @Test
+  public void shouldThrowIfConstructorArgumentIsNull() {
+    CloseGuard.Reporter originalReporter = CloseGuard.getReporter();
+    try {
+      // squelch spurious CloseGuard error
+      CloseGuard.setReporter((s, throwable) -> {});
+      assertThrows(NullPointerException.class, () -> new MergeCursor(null));
+    } finally {
+      CloseGuard.setReporter(originalReporter);
+    }
+  }
+
+  @Test
+  public void testEmptyCursors() {
+    // cursor list with null contents
+    cursor = new MergeCursor( new Cursor[1] );
+    assertThat(cursor.getCount()).isEqualTo(0);
+    assertThat(cursor.moveToFirst()).isFalse();
+    assertThat(cursor.getColumnNames()).isNotNull();
+    cursor.close();
+
+    // cursor list with partially null contents
+    Cursor[] cursors = new Cursor[2];
+    cursors[0] = null;
+    cursors[1] = dbCursor1;
+    cursor = new MergeCursor( cursors );
+    assertThat(cursor.getCount()).isEqualTo(TABLE_1_INSERTS.length);
+    assertThat(cursor.moveToFirst()).isTrue();
+    assertThat(cursor.getColumnNames()).isNotNull();
+  }
+
+  @Test
+  public void testMoveToPositionEmptyCursor() {
+    Cursor[] cursors = new Cursor[2];
+    cursors[0] = null;
+    cursors[1] = null;
+
+    cursor = new MergeCursor( cursors );
+    assertThat(cursor.getCount()).isEqualTo(0);
+    assertThat(cursor.getColumnNames()).isNotNull();
+
+    cursor.moveToPosition(0);
+
+    assertThat(cursor.getColumnNames()).isNotNull();
+  }
+
+  @Test
+  public void testBoundsSingleCursor() {
+    Cursor[] cursors = new Cursor[1];
+    cursors[0] = dbCursor1;
+
+    assertBounds( cursors, TABLE_1_INSERTS.length );
+  }
+
+  @Test
+  public void testBoundsMultipleCursor() {
+    Cursor[] cursors = new Cursor[2];
+    cursors[0] = dbCursor1;
+    cursors[1] = dbCursor2;
+
+    assertBounds( cursors, TABLE_1_INSERTS.length + TABLE_2_INSERTS.length );
+  }
+
+  private void assertBounds( Cursor[] cursors, int expectedLength ) {
+    cursor = new MergeCursor( cursors );
+
+    assertThat(cursor.getCount()).isEqualTo(expectedLength);
+    assertThat(cursor.moveToFirst()).isTrue();
+
+    for ( int i = 0; i < expectedLength; i++ ) {
+      assertThat(cursor.moveToPosition(i)).isTrue();
+      assertThat(cursor.isAfterLast()).isFalse();
+    }
+    assertThat(cursor.moveToNext()).isFalse();
+    assertThat(cursor.isAfterLast()).isTrue();
+    assertThat(cursor.moveToPosition(expectedLength)).isFalse();
+  }
+
+  @Test
+  public void testGetDataSingleCursor() throws Exception {
+    Cursor[] cursors = new Cursor[1];
+    cursors[0] = dbCursor1;
+    cursor = new MergeCursor( cursors );
+
+    cursor.moveToFirst();
+    assertDataCursor1();
+  }
+
+  @Test
+  public void testGetDataMultipleCursor() throws Exception {
+    Cursor[] cursors = new Cursor[2];
+    cursors[0] = dbCursor1;
+    cursors[1] = dbCursor2;
+    cursor = new MergeCursor( cursors );
+
+    cursor.moveToFirst();
+    assertDataCursor1();
+    cursor.moveToNext();
+    assertDataCursor2();
+  }
+
+  private void assertDataCursor1() {
+    assertThat(cursor.getInt(0)).isEqualTo(1234);
+    assertThat(cursor.getString(1)).isEqualTo("Chuck");
+    assertThat(cursor.getInt(2)).isEqualTo(3463);
+    assertThat(cursor.getFloat(3)).isEqualTo(1.5f);
+    assertThat(cursor.getDouble(4)).isEqualTo(3.14159);
+
+    cursor.moveToNext();
+    assertThat(cursor.getInt(0)).isEqualTo(1235);
+    assertThat(cursor.getString(1)).isEqualTo("Julie");
+
+    cursor.moveToNext();
+    assertThat(cursor.getInt(0)).isEqualTo(1236);
+    assertThat(cursor.getString(1)).isEqualTo("Chris");
+  }
+
+  private void assertDataCursor2() {
+    assertThat(cursor.getInt(0)).isEqualTo(4321);
+    assertThat(cursor.getString(1)).isEqualTo("Mary");
+    assertThat(cursor.getInt(2)).isEqualTo(3245);
+    assertThat(cursor.getFloat(3)).isEqualTo(5.4f);
+    assertThat(cursor.getDouble(4)).isEqualTo(2.7818);
+
+    cursor.moveToNext();
+    assertThat(cursor.getInt(0)).isEqualTo(4322);
+    assertThat(cursor.getString(1)).isEqualTo("Elizabeth");
+
+    cursor.moveToNext();
+    assertThat(cursor.getInt(0)).isEqualTo(4323);
+    assertThat(cursor.getString(1)).isEqualTo("Chester");
+  }
+
+  @Test
+  public void testColumnNamesSingleCursor() {
+    Cursor[] cursors = new Cursor[1];
+    cursors[0] = dbCursor1;
+    cursor = new MergeCursor( cursors );
+
+    for ( int i = 0; i < TABLE_1_INSERTS.length; i++ ) {
+      cursor.moveToPosition(i);
+      String[] columnNames = cursor.getColumnNames();
+      assertColumnNamesCursor1(columnNames);
+    }
+  }
+
+  @Test
+  public void testColumnNamesMultipleCursors() {
+    Cursor[] cursors = new Cursor[2];
+    cursors[0] = dbCursor1;
+    cursors[1] = dbCursor2;
+    cursor = new MergeCursor( cursors );
+
+    for ( int i = 0; i < TABLE_1_INSERTS.length; i++ ) {
+      cursor.moveToPosition(i);
+      String[] columnNames = cursor.getColumnNames();
+      assertColumnNamesCursor1(columnNames);
+    }
+
+    for ( int i = 0; i < TABLE_2_INSERTS.length; i++ ) {
+      cursor.moveToPosition(i + TABLE_1_INSERTS.length);
+      String[] columnNames = cursor.getColumnNames();
+      assertColumnNamesCursor2(columnNames);
+    }
+  }
+
+  private void assertColumnNamesCursor1(String[] columnNames) {
+    assertThat(columnNames.length).isEqualTo(7);
+    assertThat(columnNames[0]).isEqualTo("id");
+    assertThat(columnNames[1]).isEqualTo("name_1");
+    assertThat(columnNames[2]).isEqualTo("value_1");
+    assertThat(columnNames[3]).isEqualTo("float_value_1");
+    assertThat(columnNames[4]).isEqualTo("double_value_1");
+    assertThat(columnNames[5]).isEqualTo("blob_value_1");
+    assertThat(columnNames[6]).isEqualTo("clob_value_1");
+  }
+
+  private void assertColumnNamesCursor2(String[] columnNames) {
+    assertThat(columnNames.length).isEqualTo(7);
+    assertThat(columnNames[0]).isEqualTo("id");
+    assertThat(columnNames[1]).isEqualTo("name_2");
+    assertThat(columnNames[2]).isEqualTo("value_2");
+    assertThat(columnNames[3]).isEqualTo("float_value_2");
+    assertThat(columnNames[4]).isEqualTo("double_value_2");
+    assertThat(columnNames[5]).isEqualTo("blob_value_2");
+    assertThat(columnNames[6]).isEqualTo("clob_value_2");
+  }
+
+  @Test
+  public void testCloseCursors() {
+    Cursor[] cursors = new Cursor[2];
+    cursors[0] = dbCursor1;
+    cursors[1] = dbCursor2;
+    cursor = new MergeCursor( cursors );
+
+    assertThat(cursor.isClosed()).isFalse();
+    assertThat(dbCursor1.isClosed()).isFalse();
+    assertThat(dbCursor2.isClosed()).isFalse();
+
+    cursor.close();
+
+    assertThat(cursor.isClosed()).isTrue();
+    assertThat(dbCursor1.isClosed()).isTrue();
+    assertThat(dbCursor2.isClosed()).isTrue();
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMessengerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMessengerTest.java
new file mode 100644
index 0000000..69f0bbe
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMessengerTest.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.os.Handler;
+import android.os.Message;
+import android.os.Messenger;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMessengerTest {
+
+  @Test
+  public void testMessengerSend() throws Exception {
+    Handler handler = new Handler();
+    Messenger messenger = new Messenger(handler);
+
+    shadowMainLooper().pause();
+    Message msg = Message.obtain(null, 123);
+    messenger.send(msg);
+
+    assertThat(handler.hasMessages(123)).isTrue();
+
+    shadowMainLooper().idle();
+    assertThat(handler.hasMessages(123)).isFalse();
+  }
+
+  @Test
+  public void getLastMessageSentShouldWork() throws Exception {
+    Handler handler = new Handler();
+    Messenger messenger = new Messenger(handler);
+    Message msg = Message.obtain(null, 123);
+    Message originalMessage = Message.obtain(msg);
+    messenger.send(msg);
+
+    assertThat(ShadowMessenger.getLastMessageSent().what).isEqualTo(originalMessage.what);
+  }
+
+  @Test
+  public void createMessengerWithBinder_getLastMessageSentShouldWork() throws Exception {
+    Handler handler = new Handler();
+    Messenger messenger = new Messenger(new Messenger(handler).getBinder());
+
+    Message msg = Message.obtain(null, 123);
+    Message originalMessage = Message.obtain(msg);
+    messenger.send(msg);
+
+    assertThat(ShadowMessenger.getLastMessageSent().what).isEqualTo(originalMessage.what);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMimeTypeMapTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMimeTypeMapTest.java
new file mode 100644
index 0000000..36386f5
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMimeTypeMapTest.java
@@ -0,0 +1,101 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.webkit.MimeTypeMap;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMimeTypeMapTest {
+
+  private static final String IMAGE_EXTENSION = "jpg";
+  private static final String VIDEO_EXTENSION = "mp4";
+  private static final String VIDEO_MIMETYPE = "video/mp4";
+  private static final String IMAGE_MIMETYPE = "image/jpeg";
+
+  @Test
+  public void shouldResetStaticStateBetweenTests() {
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(VIDEO_EXTENSION));
+    shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping(VIDEO_EXTENSION, VIDEO_MIMETYPE);
+  }
+
+  @Test
+  public void shouldResetStaticStateBetweenTests_anotherTime() {
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(VIDEO_EXTENSION));
+    shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping(VIDEO_EXTENSION, VIDEO_MIMETYPE);
+  }
+
+  @Test
+  public void getSingletonShouldAlwaysReturnSameInstance() {
+    MimeTypeMap firstInstance = MimeTypeMap.getSingleton();
+    MimeTypeMap secondInstance = MimeTypeMap.getSingleton();
+
+    assertSame(firstInstance, secondInstance);
+  }
+
+  @Test
+  public void byDefaultThereShouldBeNoMapping() {
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(VIDEO_EXTENSION));
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(IMAGE_EXTENSION));
+  }
+
+  @Test
+  public void addingMappingShouldWorkCorrectly() {
+    ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton());
+    shadowMimeTypeMap.addExtensionMimeTypMapping(VIDEO_EXTENSION, VIDEO_MIMETYPE);
+    shadowMimeTypeMap.addExtensionMimeTypMapping(IMAGE_EXTENSION, IMAGE_MIMETYPE);
+
+    assertTrue(MimeTypeMap.getSingleton().hasExtension(VIDEO_EXTENSION));
+    assertTrue(MimeTypeMap.getSingleton().hasExtension(IMAGE_EXTENSION));
+    assertTrue(MimeTypeMap.getSingleton().hasMimeType(VIDEO_MIMETYPE));
+    assertTrue(MimeTypeMap.getSingleton().hasMimeType(IMAGE_MIMETYPE));
+
+    assertEquals(IMAGE_EXTENSION, MimeTypeMap.getSingleton().getExtensionFromMimeType(IMAGE_MIMETYPE));
+    assertEquals(VIDEO_EXTENSION, MimeTypeMap.getSingleton().getExtensionFromMimeType(VIDEO_MIMETYPE));
+
+    assertEquals(IMAGE_MIMETYPE, MimeTypeMap.getSingleton().getMimeTypeFromExtension(IMAGE_EXTENSION));
+    assertEquals(VIDEO_MIMETYPE, MimeTypeMap.getSingleton().getMimeTypeFromExtension(VIDEO_EXTENSION));
+  }
+
+  @Test
+  public void clearMappingsShouldRemoveAllMappings() {
+    ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton());
+    shadowMimeTypeMap.addExtensionMimeTypMapping(VIDEO_EXTENSION, VIDEO_MIMETYPE);
+    shadowMimeTypeMap.addExtensionMimeTypMapping(IMAGE_EXTENSION, IMAGE_MIMETYPE);
+
+    shadowMimeTypeMap.clearMappings();
+
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(VIDEO_EXTENSION));
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(IMAGE_EXTENSION));
+    assertFalse(MimeTypeMap.getSingleton().hasMimeType(VIDEO_MIMETYPE));
+    assertFalse(MimeTypeMap.getSingleton().hasExtension(IMAGE_MIMETYPE));
+  }
+
+  @Test
+  public void unknownExtensionShouldProvideNothing() {
+    ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton());
+    shadowMimeTypeMap.addExtensionMimeTypMapping(VIDEO_EXTENSION, VIDEO_MIMETYPE);
+    shadowMimeTypeMap.addExtensionMimeTypMapping(IMAGE_EXTENSION, IMAGE_MIMETYPE);
+
+    assertFalse(MimeTypeMap.getSingleton().hasExtension("foo"));
+    assertNull(MimeTypeMap.getSingleton().getMimeTypeFromExtension("foo"));
+  }
+
+  @Test
+  public void unknownMimeTypeShouldProvideNothing() {
+    ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton());
+    shadowMimeTypeMap.addExtensionMimeTypMapping(VIDEO_EXTENSION, VIDEO_MIMETYPE);
+    shadowMimeTypeMap.addExtensionMimeTypMapping(IMAGE_EXTENSION, IMAGE_MIMETYPE);
+
+    assertFalse(MimeTypeMap.getSingleton().hasMimeType("foo/bar"));
+    assertNull(MimeTypeMap.getSingleton().getExtensionFromMimeType("foo/bar"));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowMotionEventTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowMotionEventTest.java
new file mode 100644
index 0000000..7826950
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowMotionEventTest.java
@@ -0,0 +1,96 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.MotionEvent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowMotionEventTest {
+  private MotionEvent event;
+  private ShadowMotionEvent shadowMotionEvent;
+
+  @Before
+  public void setUp() throws Exception {
+    event = MotionEvent.obtain(100, 200, MotionEvent.ACTION_MOVE, 5.0f, 10.0f, 0);
+    shadowMotionEvent = shadowOf(event);
+  }
+
+  @Test
+  public void addingSecondPointerSetsCount() {
+    assertThat(event.getX(0)).isEqualTo(5.0f);
+    assertThat(event.getY(0)).isEqualTo(10.0f);
+    assertThat(event.getPointerCount()).isEqualTo(1);
+
+    shadowMotionEvent.setPointer2(20.0f, 30.0f);
+
+    assertThat(event.getX(1)).isEqualTo(20.0f);
+    assertThat(event.getY(1)).isEqualTo(30.0f);
+    assertThat(event.getPointerCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void canSetPointerIdsByIndex() {
+    shadowMotionEvent.setPointer2(20.0f, 30.0f);
+    shadowMotionEvent.setPointerIds(2, 5);
+    assertEquals(2, event.getPointerId(0));
+    assertEquals(5, event.getPointerId(1));
+  }
+
+  @Test
+  public void indexShowsUpInAction() {
+    shadowMotionEvent.setPointerIndex(1);
+    assertEquals(1 << MotionEvent.ACTION_POINTER_ID_SHIFT | MotionEvent.ACTION_MOVE, event.getAction());
+  }
+
+  @Test
+  public void canGetActionIndex() {
+    assertEquals(0, event.getActionIndex());
+    shadowMotionEvent.setPointerIndex(1);
+    assertEquals(1, event.getActionIndex());
+  }
+
+  @Test
+  public void getActionMaskedStripsPointerIndexFromAction() {
+    assertEquals(MotionEvent.ACTION_MOVE, event.getActionMasked());
+    shadowMotionEvent.setPointerIndex(1);
+    assertEquals(MotionEvent.ACTION_MOVE, event.getActionMasked());
+  }
+
+  @Test
+  public void canFindPointerIndexFromId() {
+    shadowMotionEvent.setPointer2(20.0f, 30.0f);
+    shadowMotionEvent.setPointerIds(2, 1);
+    assertEquals(0, event.findPointerIndex(2));
+    assertEquals(1, event.findPointerIndex(1));
+    assertEquals(-1, event.findPointerIndex(3));
+  }
+
+  @Test
+  public void obtainEventsWithDistinctPointerIds() {
+    int[] event1Ids = {88};
+    MotionEvent.PointerCoords[] event1Coords = {createCoords(5.0f, 10.0f)};
+    MotionEvent event1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 1, event1Ids, event1Coords, 0, 1.0f, 1.0f, 0, 0, 0, 0);
+
+    int[] event2Ids = {99};
+    MotionEvent.PointerCoords[] event2Coords = {createCoords(20.0f, 30.0f)};
+    MotionEvent event2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 1, event2Ids, event2Coords, 0, 1.0f, 1.0f, 0, 0, 0, 0);
+
+    assertEquals(1, event1.getPointerCount());
+    assertEquals(88, event1.getPointerId(0));
+    assertEquals(1, event2.getPointerCount());
+    assertEquals(99, event2.getPointerId(0));
+  }
+
+  private static MotionEvent.PointerCoords createCoords(float x, float y) {
+    MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+    coords.x = x;
+    coords.y = y;
+    return coords;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkCapabilitiesTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkCapabilitiesTest.java
new file mode 100644
index 0000000..cb46834
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkCapabilitiesTest.java
@@ -0,0 +1,110 @@
+package org.robolectric.shadows;
+
+import static android.net.NetworkCapabilities.NET_CAPABILITY_MMS;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.mockito.Mockito.mock;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.net.NetworkCapabilities;
+import android.net.NetworkSpecifier;
+import android.net.wifi.WifiInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowNetworkCapabilitiesTest {
+
+  @Test
+  public void hasTransport_shouldReturnAsPerAssignedTransportTypes() {
+    NetworkCapabilities networkCapabilities = ShadowNetworkCapabilities.newInstance();
+
+    // Assert default false state.
+    assertThat(networkCapabilities.hasTransport(TRANSPORT_WIFI)).isFalse();
+
+    shadowOf(networkCapabilities).addTransportType(TRANSPORT_WIFI);
+    shadowOf(networkCapabilities).addTransportType(TRANSPORT_CELLULAR);
+    assertThat(networkCapabilities.hasTransport(TRANSPORT_WIFI)).isTrue();
+    assertThat(networkCapabilities.hasTransport(TRANSPORT_CELLULAR)).isTrue();
+
+    shadowOf(networkCapabilities).removeTransportType(TRANSPORT_WIFI);
+    assertThat(networkCapabilities.hasTransport(TRANSPORT_WIFI)).isFalse();
+    assertThat(networkCapabilities.hasTransport(TRANSPORT_CELLULAR)).isTrue();
+  }
+
+  @Test
+  public void hasCapability_shouldReturnAsPerAssignedCapabilities() {
+    NetworkCapabilities networkCapabilities = ShadowNetworkCapabilities.newInstance();
+
+    // Assert default capabilities
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_NOT_RESTRICTED)).isTrue();
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_TRUSTED)).isTrue();
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_NOT_VPN)).isTrue();
+
+    shadowOf(networkCapabilities).addCapability(NET_CAPABILITY_MMS);
+
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_MMS)).isTrue();
+
+    shadowOf(networkCapabilities).removeCapability(NET_CAPABILITY_MMS);
+
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_MMS)).isFalse();
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_NOT_RESTRICTED)).isTrue();
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_TRUSTED)).isTrue();
+    assertThat(networkCapabilities.hasCapability(NET_CAPABILITY_NOT_VPN)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getNetworkSpecifier_shouldReturnTheSpecifiedValue_fromO() {
+    NetworkCapabilities networkCapabilities = ShadowNetworkCapabilities.newInstance();
+    // Required to set NetworkSpecifier
+    shadowOf(networkCapabilities).addTransportType(TRANSPORT_WIFI);
+
+    NetworkSpecifier testNetworkSpecifier = mock(NetworkSpecifier.class);
+    shadowOf(networkCapabilities).setNetworkSpecifier(testNetworkSpecifier);
+    assertThat(networkCapabilities.getNetworkSpecifier()).isEqualTo(testNetworkSpecifier);
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1)
+  public void getNetworkSpecifier_shouldReturnTheSpecifiedValue_beforeO() {
+    NetworkCapabilities networkCapabilities = ShadowNetworkCapabilities.newInstance();
+    // Required to set NetworkSpecifier
+    shadowOf(networkCapabilities).addTransportType(TRANSPORT_WIFI);
+
+    String testNetworkSpecifier = "testNetworkSpecifier";
+    shadowOf(networkCapabilities).setNetworkSpecifier(testNetworkSpecifier);
+    String checkedNetworkSpecifier =
+        ReflectionHelpers.callInstanceMethod(networkCapabilities, "getNetworkSpecifier");
+    assertThat(checkedNetworkSpecifier).isEqualTo(testNetworkSpecifier);
+  }
+
+  @Config(minSdk = S)
+  public void setTransportInfo_shouldSetTransportInfo() {
+    NetworkCapabilities networkCapabilities = ShadowNetworkCapabilities.newInstance();
+
+    String fakeBssid = "00:00:00:00:00:00";
+    String fakeSsid = "test wifi";
+    shadowOf(networkCapabilities)
+        .setTransportInfo(
+            new WifiInfo.Builder().setSsid(fakeSsid.getBytes(UTF_8)).setBssid(fakeBssid).build());
+
+    WifiInfo wifiInfo = (WifiInfo) networkCapabilities.getTransportInfo();
+    assertThat(wifiInfo.getSSID()).isEqualTo(String.format("\"%s\"", fakeSsid));
+    assertThat(wifiInfo.getBSSID()).isEqualTo(fakeBssid);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkInfoTest.java
new file mode 100644
index 0000000..ea2d5b2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkInfoTest.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.net.NetworkInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowNetworkInfoTest {
+
+  @Test
+  public void getDetailedState_shouldReturnTheAssignedState() {
+    NetworkInfo networkInfo = Shadow.newInstanceOf(NetworkInfo.class);
+    shadowOf(networkInfo).setDetailedState(NetworkInfo.DetailedState.SCANNING);
+    assertThat(networkInfo.getDetailedState()).isEqualTo(NetworkInfo.DetailedState.SCANNING);
+  }
+
+  @Test
+  public void getExtraInfo_shouldReturnTheSetValue() {
+    NetworkInfo networkInfo = Shadow.newInstanceOf(NetworkInfo.class);
+    shadowOf(networkInfo).setExtraInfo("some_extra_info");
+    assertThat(networkInfo.getExtraInfo()).isEqualTo("some_extra_info");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkScoreManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkScoreManagerTest.java
new file mode 100644
index 0000000..8524eb0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkScoreManagerTest.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.net.NetworkScoreManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** ShadowNetworkScoreManagerTest tests {@link ShadowNetworkScoreManager}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowNetworkScoreManagerTest {
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testGetActiveScorerPackage() {
+    Context context = ApplicationProvider.getApplicationContext();
+    NetworkScoreManager networkScoreManager =
+        (NetworkScoreManager) context.getSystemService(Context.NETWORK_SCORE_SERVICE);
+
+    String testPackage = "com.package.test";
+    networkScoreManager.setActiveScorer(testPackage);
+    assertThat(networkScoreManager.getActiveScorerPackage()).isEqualTo(testPackage);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testIsScoringEnabled() {
+    Context context = ApplicationProvider.getApplicationContext();
+    NetworkScoreManager networkScoreManager =
+        (NetworkScoreManager) context.getSystemService(Context.NETWORK_SCORE_SERVICE);
+    networkScoreManager.disableScoring();
+    ShadowNetworkScoreManager m = Shadow.extract(networkScoreManager);
+    assertThat(m.isScoringEnabled()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkTest.java
new file mode 100644
index 0000000..d774872
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNetworkTest.java
@@ -0,0 +1,87 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Network;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.FileDescriptor;
+import java.net.DatagramSocket;
+import java.net.Socket;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowNetworkTest {
+  @Test
+  public void getNetId_shouldReturnConstructorNetId() {
+    final int netId = 123;
+
+    Network network = ShadowNetwork.newInstance(netId);
+    ShadowNetwork shadowNetwork = Shadows.shadowOf(network);
+    assertThat(shadowNetwork.getNetId()).isEqualTo(netId);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void bindSocketDatagramSocket_shouldNotCrash() throws Exception {
+    Network network = ShadowNetwork.newInstance(0);
+    network.bindSocket(new DatagramSocket());
+  }
+
+  @Test
+  public void bindSocketSocket_shouldNotCrash() throws Exception {
+    Network network = ShadowNetwork.newInstance(0);
+    network.bindSocket(new Socket());
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void bindSocketFileDescriptor_shouldNotCrash() throws Exception {
+    Network network = ShadowNetwork.newInstance(0);
+    network.bindSocket(new FileDescriptor());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void isSocketBoundSocketDatagramSocket() throws Exception {
+    Network network = ShadowNetwork.newInstance(0);
+    DatagramSocket datagramSocket = new DatagramSocket();
+
+    assertThat(Shadows.shadowOf(network).boundSocketCount()).isEqualTo(0);
+
+    network.bindSocket(datagramSocket);
+    assertThat(Shadows.shadowOf(network).isSocketBound(datagramSocket)).isTrue();
+    assertThat(Shadows.shadowOf(network).boundSocketCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void isSocketBoundSocket() throws Exception {
+    Network network = ShadowNetwork.newInstance(0);
+    Socket socket = new Socket();
+
+    assertThat(Shadows.shadowOf(network).boundSocketCount()).isEqualTo(0);
+
+    network.bindSocket(socket);
+    assertThat(Shadows.shadowOf(network).isSocketBound(socket)).isTrue();
+    assertThat(Shadows.shadowOf(network).boundSocketCount()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isSocketBoundFileDescriptor() throws Exception {
+    Network network = ShadowNetwork.newInstance(0);
+    FileDescriptor fileDescriptor = new FileDescriptor();
+
+    assertThat(Shadows.shadowOf(network).boundSocketCount()).isEqualTo(0);
+
+    network.bindSocket(fileDescriptor);
+    assertThat(Shadows.shadowOf(network).isSocketBound(fileDescriptor)).isTrue();
+    assertThat(Shadows.shadowOf(network).boundSocketCount()).isEqualTo(1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNfcAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNfcAdapterTest.java
new file mode 100644
index 0000000..6b849c2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNfcAdapterTest.java
@@ -0,0 +1,214 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.pm.PackageManager;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowNfcAdapterTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = RuntimeEnvironment.getApplication();
+    shadowOf(context.getPackageManager())
+        .setSystemFeature(PackageManager.FEATURE_NFC, /* supported= */ true);
+  }
+
+  @Test
+  public void setNdefPushMesageCallback_shouldUseCallback() {
+    final NfcAdapter.CreateNdefMessageCallback callback =
+        mock(NfcAdapter.CreateNdefMessageCallback.class);
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+
+    adapter.setNdefPushMessageCallback(callback, activity);
+    assertThat(shadowOf(adapter).getNdefPushMessageCallback()).isSameInstanceAs(callback);
+  }
+
+  @Test
+  public void setOnNdefPushCompleteCallback_shouldUseCallback() {
+    final NfcAdapter.OnNdefPushCompleteCallback callback =
+        mock(NfcAdapter.OnNdefPushCompleteCallback.class);
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+
+    adapter.setOnNdefPushCompleteCallback(callback, activity);
+    assertThat(shadowOf(adapter).getOnNdefPushCompleteCallback()).isSameInstanceAs(callback);
+  }
+
+  @Test
+  public void setOnNdefPushCompleteCallback_throwsOnNullActivity() {
+    final NfcAdapter.OnNdefPushCompleteCallback callback =
+        mock(NfcAdapter.OnNdefPushCompleteCallback.class);
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final Activity nullActivity = null;
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("activity cannot be null");
+    adapter.setOnNdefPushCompleteCallback(callback, nullActivity);
+  }
+
+  @Test
+  public void setOnNdefPushCompleteCallback_throwsOnNullInActivities() {
+    final NfcAdapter.OnNdefPushCompleteCallback callback =
+        mock(NfcAdapter.OnNdefPushCompleteCallback.class);
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final Activity nullActivity = null;
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("activities cannot contain null");
+
+    adapter.setOnNdefPushCompleteCallback(callback, activity, nullActivity);
+  }
+
+  @Test
+  public void isEnabled_shouldReturnEnabledState() {
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context);
+    assertThat(adapter.isEnabled()).isFalse();
+
+    shadowOf(adapter).setEnabled(true);
+    assertThat(adapter.isEnabled()).isTrue();
+
+    shadowOf(adapter).setEnabled(false);
+    assertThat(adapter.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void getNfcAdapter_returnsNonNull() {
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context);
+    assertThat(adapter).isNotNull();
+  }
+
+  @Test
+  public void getNfcAdapter_hardwareExists_returnsNonNull() {
+    ShadowNfcAdapter.setNfcHardwareExists(true);
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context);
+    assertThat(adapter).isNotNull();
+  }
+
+  @Test
+  public void getNfcAdapter_hardwareDoesNotExist_returnsNull() {
+    ShadowNfcAdapter.setNfcHardwareExists(false);
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(context);
+    assertThat(adapter).isNull();
+  }
+
+  @Test
+  public void setNdefPushMessage_setsNullMessage() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+
+    adapter.setNdefPushMessage(null, activity);
+
+    assertThat(shadowOf(adapter).getNdefPushMessage()).isNull();
+  }
+
+  @Test
+  public void setNdefPushMessage_setsNonNullMessage() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+    final NdefMessage message =
+        new NdefMessage(new NdefRecord[] {new NdefRecord(NdefRecord.TNF_EMPTY, null, null, null)});
+
+    adapter.setNdefPushMessage(message, activity);
+
+    assertThat(shadowOf(adapter).getNdefPushMessage()).isSameInstanceAs(message);
+  }
+
+  @Test
+  public void getNdefPushMessage_messageNotSet_throwsIllegalStateException() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    final NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+
+    expectedException.expect(IllegalStateException.class);
+
+    shadowOf(adapter).getNdefPushMessage();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  public void isInReaderMode_beforeEnableReaderMode_shouldReturnFalse() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+    assertThat(shadowOf(adapter).isInReaderMode()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  public void isInReaderMode_afterEnableReaderMode_shouldReturnTrue() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+    NfcAdapter.ReaderCallback callback = mock(NfcAdapter.ReaderCallback.class);
+    adapter.enableReaderMode(
+        activity,
+        callback,
+        NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
+        /* extras= */ null);
+
+    assertThat(shadowOf(adapter).isInReaderMode()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  public void isInReaderMode_afterDisableReaderMode_shouldReturnFalse() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+    NfcAdapter.ReaderCallback callback = mock(NfcAdapter.ReaderCallback.class);
+    adapter.enableReaderMode(
+        activity,
+        callback,
+        NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
+        /* extras= */ null);
+    adapter.disableReaderMode(activity);
+
+    assertThat(shadowOf(adapter).isInReaderMode()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  public void dispatchTagDiscovered_shouldDispatchTagToCallback() {
+    final Activity activity = Robolectric.setupActivity(Activity.class);
+    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity);
+    NfcAdapter.ReaderCallback callback = mock(NfcAdapter.ReaderCallback.class);
+    adapter.enableReaderMode(
+        activity,
+        callback,
+        NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
+        /* extras= */ null);
+    Tag tag = createMockTag();
+    shadowOf(adapter).dispatchTagDiscovered(tag);
+
+    verify(callback).onTagDiscovered(same(tag));
+  }
+
+  private static Tag createMockTag() {
+    return Tag.createMockTag(new byte[0], new int[0], new Bundle[0]);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilder25Test.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilder25Test.java
new file mode 100644
index 0000000..de671e9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilder25Test.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowNotificationBuilder25Test extends ShadowNotificationBuilderTestBase {
+
+  /**
+   * run 'em all again with android:targetSdkVersion=25 - behavior of NotificationBuilder
+   * varies based on version specified in Manifest rather than runtime framework version.
+   */
+  @Before
+  public void setup() throws Exception {
+    ApplicationProvider.getApplicationContext()
+            .getPackageManager()
+            .getPackageInfo("org.robolectric", 0)
+            .applicationInfo
+            .targetSdkVersion =
+        Build.VERSION_CODES.N_MR1;
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilderTest.java
new file mode 100644
index 0000000..5b30405
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilderTest.java
@@ -0,0 +1,9 @@
+package org.robolectric.shadows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowNotificationBuilderTest extends ShadowNotificationBuilderTestBase {
+  // inherit test methods from parent class
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilderTestBase.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilderTestBase.java
new file mode 100644
index 0000000..6274b67
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationBuilderTestBase.java
@@ -0,0 +1,250 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.drawable.Icon;
+import android.text.SpannableString;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public abstract class ShadowNotificationBuilderTestBase {
+  private final Notification.Builder builder =
+      new Notification.Builder(ApplicationProvider.getApplicationContext());
+
+  @Test
+  public void build_setsContentTitleOnNotification() {
+    Notification notification = builder.setContentTitle("Hello").build();
+    assertThat(shadowOf(notification).getContentTitle().toString()).isEqualTo("Hello");
+  }
+
+  @Test
+  public void build_whenSetOngoingNotSet_leavesSetOngoingAsFalse() {
+    Notification notification = builder.build();
+    assertThat(shadowOf(notification).isOngoing()).isFalse();
+  }
+
+  @Test
+  public void build_whenSetOngoing_setsOngoingToTrue() {
+    Notification notification = builder.setOngoing(true).build();
+    assertThat(shadowOf(notification).isOngoing()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void build_whenShowWhenNotSet_setsShowWhenOnNotificationToTrue() {
+    Notification notification = builder.setWhen(100).setShowWhen(true).build();
+
+    assertThat(shadowOf(notification).isWhenShown()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void build_setShowWhenOnNotification() {
+    Notification notification = builder.setShowWhen(false).build();
+
+    assertThat(shadowOf(notification).isWhenShown()).isFalse();
+  }
+
+  @Test
+  public void build_setsContentTextOnNotification() {
+    Notification notification = builder.setContentText("Hello Text").build();
+
+    assertThat(shadowOf(notification).getContentText().toString()).isEqualTo("Hello Text");
+  }
+
+  @Test
+  public void build_setsTickerOnNotification() {
+    Notification notification = builder.setTicker("My ticker").build();
+
+    assertThat(notification.tickerText.toString()).isEqualTo("My ticker");
+  }
+
+  @Test
+  public void build_setsContentInfoOnNotification() {
+    builder.setContentInfo("11");
+    Notification notification = builder.build();
+    assertThat(shadowOf(notification).getContentInfo().toString()).isEqualTo("11");
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void build_setsIconOnNotification() {
+    Notification notification = builder.setSmallIcon(R.drawable.an_image).build();
+
+    assertThat(notification.getSmallIcon().getResId()).isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void build_setsWhenOnNotification() {
+    Notification notification = builder.setWhen(11L).build();
+
+    assertThat(notification.when).isEqualTo(11L);
+  }
+
+  @Test
+  public void build_setsProgressOnNotification_true() {
+    Notification notification = builder.setProgress(36, 57, true).build();
+    // If indeterminate then max and progress values are ignored.
+    assertThat(shadowOf(notification).isIndeterminate()).isTrue();
+  }
+
+  @Test
+  public void build_setsProgressOnNotification_false() {
+    Notification notification = builder.setProgress(50, 10, false).build();
+
+    assertThat(shadowOf(notification).getMax()).isEqualTo(50);
+    assertThat(shadowOf(notification).getProgress()).isEqualTo(10);
+    assertThat(shadowOf(notification).isIndeterminate()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void build_setsUsesChronometerOnNotification_true() {
+    Notification notification = builder.setUsesChronometer(true).setWhen(10).setShowWhen(true).build();
+
+    assertThat(shadowOf(notification).usesChronometer()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void build_setsUsesChronometerOnNotification_false() {
+    Notification notification = builder.setUsesChronometer(false).setWhen(10).setShowWhen(true).build();
+
+    assertThat(shadowOf(notification).usesChronometer()).isFalse();
+  }
+
+  @Test @Config(maxSdk = M)
+  public void build_handlesNullContentTitle() {
+    Notification notification = builder.setContentTitle(null).build();
+
+    assertThat(shadowOf(notification).getContentTitle().toString()).isEmpty();
+  }
+
+  @Test @Config(minSdk = N)
+  public void build_handlesNullContentTitle_atLeastN() {
+    Notification notification = builder.setContentTitle(null).build();
+
+    assertThat(shadowOf(notification).getContentTitle()).isNull();
+  }
+
+  @Test @Config(maxSdk = M)
+  public void build_handlesNullContentText() {
+    Notification notification = builder.setContentText(null).build();
+
+    assertThat(shadowOf(notification).getContentText().toString()).isEmpty();
+  }
+
+  @Test @Config(minSdk = N)
+  public void build_handlesNullContentText_atLeastN() {
+    Notification notification = builder.setContentText(null).build();
+
+    assertThat(shadowOf(notification).getContentText()).isNull();
+  }
+
+  @Test
+  public void build_handlesNullTicker() {
+    Notification notification = builder.setTicker(null).build();
+
+    assertThat(notification.tickerText).isNull();
+  }
+
+  @Test @Config(maxSdk = M)
+  public void build_handlesNullContentInfo() {
+    Notification notification = builder.setContentInfo(null).build();
+
+    assertThat(shadowOf(notification).getContentInfo().toString()).isEmpty();
+  }
+
+  @Test @Config(minSdk = N)
+  public void build_handlesNullContentInfo_atLeastN() {
+    Notification notification = builder.setContentInfo(null).build();
+
+    assertThat(shadowOf(notification).getContentInfo()).isNull();
+  }
+
+  @Test
+  @Config(maxSdk = M)
+  public void build_handlesNonStringContentText() {
+    Notification notification = builder.setContentText(new SpannableString("Hello")).build();
+
+    assertThat(shadowOf(notification).getContentText().toString()).isEqualTo("Hello");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void build_handlesNonStringContentText_atLeastN() {
+    Notification notification = builder.setContentText(new SpannableString("Hello")).build();
+
+    assertThat(shadowOf(notification).getContentText().toString()).isEqualTo("Hello");
+  }
+
+  @Test
+  @Config(maxSdk = M)
+  public void build_handlesNonStringContentTitle() {
+    Notification notification = builder.setContentTitle(new SpannableString("My title")).build();
+
+    assertThat(shadowOf(notification).getContentTitle().toString()).isEqualTo("My title");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void build_handlesNonStringContentTitle_atLeastN() {
+    Notification notification = builder.setContentTitle(new SpannableString("My title")).build();
+
+    assertThat(shadowOf(notification).getContentTitle().toString()).isEqualTo("My title");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void build_addsActionToNotification() throws Exception {
+    PendingIntent action =
+        PendingIntent.getBroadcast(ApplicationProvider.getApplicationContext(), 0, null, 0);
+    Notification notification = builder.addAction(0, "Action", action).build();
+
+    assertThat(notification.actions[0].actionIntent).isEqualTo(action);
+  }
+
+  @Test
+  public void withBigTextStyle() {
+    Notification notification = builder.setStyle(new Notification.BigTextStyle(builder)
+        .bigText("BigText")
+        .setBigContentTitle("Title")
+        .setSummaryText("Summary"))
+        .build();
+
+    assertThat(shadowOf(notification).getBigText().toString()).isEqualTo("BigText");
+    assertThat(shadowOf(notification).getBigContentTitle().toString()).isEqualTo("Title");
+    assertThat(shadowOf(notification).getBigContentText().toString()).isEqualTo("Summary");
+    assertThat(shadowOf(notification).getBigPicture()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void withBigPictureStyle() {
+    Bitmap bigPicture =
+        BitmapFactory.decodeResource(
+            ApplicationProvider.getApplicationContext().getResources(), R.drawable.an_image);
+
+    Icon bigLargeIcon = Icon.createWithBitmap(bigPicture);
+    Notification notification = builder.setStyle(new Notification.BigPictureStyle(builder)
+        .bigPicture(bigPicture)
+        .bigLargeIcon(bigLargeIcon))
+        .build();
+
+    assertThat(shadowOf(notification).getBigPicture().sameAs(bigPicture)).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationListenerServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationListenerServiceTest.java
new file mode 100644
index 0000000..3a249a8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationListenerServiceTest.java
@@ -0,0 +1,213 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Notification;
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/** Test for ShadowNotificationListenerService. */
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public final class ShadowNotificationListenerServiceTest {
+
+  /** NotificationListenerService needs to be extended to function. */
+  private static final class TestNotificationListenerService extends NotificationListenerService {
+    TestNotificationListenerService() {}
+  }
+
+  private static final String DEFAULT_PACKAGE = "com.google.android.apps.example";
+  private static final int DEFAULT_ID = 0;
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  private NotificationListenerService service;
+  private ShadowNotificationListenerService shadowService;
+
+  @Before
+  public void setUp() {
+    service = Robolectric.buildService(TestNotificationListenerService.class).get();
+    shadowService = shadowOf(service);
+    ShadowNotificationListenerService.reset();
+  }
+
+  @Test
+  public void getActiveNotification_noActiveNotification_returnsEmptyResult() {
+    StatusBarNotification[] activeNotifications = service.getActiveNotifications();
+
+    assertThat(activeNotifications).isEmpty();
+  }
+
+  @Test
+  public void getActiveNotification_oneActiveNotification_returnsOneNotification() {
+    Notification dummyNotification = createDummyNotification();
+    shadowService.addActiveNotification(DEFAULT_PACKAGE, DEFAULT_ID, dummyNotification);
+
+    StatusBarNotification[] activeNotifications = service.getActiveNotifications();
+
+    assertThat(activeNotifications).hasLength(1);
+    assertThat(activeNotifications[0].getPackageName()).isEqualTo(DEFAULT_PACKAGE);
+    assertThat(activeNotifications[0].getNotification()).isEqualTo(dummyNotification);
+  }
+
+  @Test
+  public void getActiveNotification_multipleActiveNotifications_returnsAllNotifications() {
+    ImmutableList<Notification> dummyNotifications =
+        ImmutableList.of(
+            createDummyNotification(), createDummyNotification(), createDummyNotification());
+    dummyNotifications.stream()
+        .forEach(
+            notification ->
+                shadowService.addActiveNotification(DEFAULT_PACKAGE, DEFAULT_ID, notification));
+
+    StatusBarNotification[] activeNotifications = service.getActiveNotifications();
+
+    assertThat(activeNotifications).hasLength(dummyNotifications.size());
+    for (int i = 0; i < dummyNotifications.size(); i++) {
+      assertThat(activeNotifications[i].getPackageName()).isEqualTo(DEFAULT_PACKAGE);
+      assertThat(activeNotifications[i].getNotification()).isEqualTo(dummyNotifications.get(i));
+    }
+  }
+
+  @Test
+  public void
+      getActiveNotifications_activeNotificationsFromMultiplePackages_returnsAllNotifications() {
+    Notification dummyNotification1 = createDummyNotification();
+    Notification dummyNotification2 = createDummyNotification();
+    String package1 = "com.google.android.app.first";
+    String package2 = "com.google.android.app.second";
+    shadowService.addActiveNotification(package1, DEFAULT_ID, dummyNotification1);
+    shadowService.addActiveNotification(package2, DEFAULT_ID, dummyNotification2);
+
+    StatusBarNotification[] activeNotifications = service.getActiveNotifications();
+
+    assertThat(activeNotifications).hasLength(2);
+    assertThat(activeNotifications[0].getPackageName()).isEqualTo(package1);
+    assertThat(activeNotifications[0].getNotification()).isEqualTo(dummyNotification1);
+    assertThat(activeNotifications[1].getPackageName()).isEqualTo(package2);
+    assertThat(activeNotifications[1].getNotification()).isEqualTo(dummyNotification2);
+  }
+
+  @Test
+  public void getActiveNotifications_statusBarNotificationObjects_returnsAllNotifications() {
+    StatusBarNotification sbn1 = mock(StatusBarNotification.class);
+    StatusBarNotification sbn2 = mock(StatusBarNotification.class);
+    shadowService.addActiveNotification(sbn1);
+    shadowService.addActiveNotification(sbn2);
+
+    StatusBarNotification[] activeNotifications = service.getActiveNotifications();
+
+    assertThat(activeNotifications).hasLength(2);
+    assertThat(activeNotifications[0]).isSameInstanceAs(sbn1);
+    assertThat(activeNotifications[1]).isSameInstanceAs(sbn2);
+  }
+
+  @Test
+  public void getActiveNotification_filterByKeys_returnsAllMatchedNotifications() {
+    ImmutableList<Notification> dummyNotifications =
+        ImmutableList.of(
+            createDummyNotification(),
+            createDummyNotification(),
+            createDummyNotification(),
+            createDummyNotification());
+    String[] keys = new String[dummyNotifications.size()];
+    for (int i = 0; i < dummyNotifications.size(); i++) {
+      keys[i] = shadowService.addActiveNotification(DEFAULT_PACKAGE, i, dummyNotifications.get(i));
+    }
+
+    StatusBarNotification[] activeNotifications =
+        service.getActiveNotifications(new String[] {keys[0], keys[2]});
+
+    assertThat(activeNotifications).hasLength(2);
+    assertThat(activeNotifications[0].getNotification()).isEqualTo(dummyNotifications.get(0));
+    assertThat(activeNotifications[1].getNotification()).isEqualTo(dummyNotifications.get(2));
+  }
+
+  @Test
+  public void cancelNotification_keyFound_removesActiveNotification() {
+    String key =
+        shadowService.addActiveNotification(DEFAULT_PACKAGE, DEFAULT_ID, createDummyNotification());
+
+    service.cancelNotification(key);
+
+    assertThat(service.getActiveNotifications()).isEmpty();
+  }
+
+  @Test
+  public void cancelNotification_keyNotFound_noOp() {
+    shadowService.addActiveNotification(DEFAULT_PACKAGE, DEFAULT_ID, createDummyNotification());
+
+    service.cancelNotification("made_up_key");
+
+    assertThat(service.getActiveNotifications()).hasLength(1);
+  }
+
+  @Test
+  public void cancelAllNotifications_removesAllActiveNotifications() {
+    ImmutableList<Notification> dummyNotifications =
+        ImmutableList.of(
+            createDummyNotification(), createDummyNotification(), createDummyNotification());
+    dummyNotifications.stream()
+        .forEach(
+            notification ->
+                shadowService.addActiveNotification(DEFAULT_PACKAGE, DEFAULT_ID, notification));
+
+    service.cancelAllNotifications();
+
+    assertThat(service.getActiveNotifications()).isEmpty();
+  }
+
+  @Test
+  public void requestInterruptionFilter_updatesInterruptionFilter() {
+    service.requestInterruptionFilter(NotificationListenerService.INTERRUPTION_FILTER_ALARMS);
+
+    assertThat(service.getCurrentInterruptionFilter())
+        .isEqualTo(NotificationListenerService.INTERRUPTION_FILTER_ALARMS);
+  }
+
+  @Test
+  public void requestListenerHints_updatesListenerHints() {
+    service.requestListenerHints(NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS);
+
+    assertThat(service.getCurrentListenerHints())
+        .isEqualTo(NotificationListenerService.HINT_HOST_DISABLE_CALL_EFFECTS);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.N)
+  public void requestRebind_incrementsCounter() {
+    TestNotificationListenerService.requestRebind(
+        ComponentName.createRelative(DEFAULT_PACKAGE, "Test"));
+    TestNotificationListenerService.requestRebind(
+        ComponentName.createRelative(DEFAULT_PACKAGE, "Test"));
+
+    assertThat(ShadowNotificationListenerService.getRebindRequestCount()).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.N)
+  public void requestUnbind_incrementsCounter() {
+    service.requestUnbind();
+    service.requestUnbind();
+
+    assertThat(shadowService.getUnbindRequestCount()).isEqualTo(2);
+  }
+
+  private Notification createDummyNotification() {
+    return new Notification.Builder(context).build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationManagerTest.java
new file mode 100644
index 0000000..e2bf423
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationManagerTest.java
@@ -0,0 +1,807 @@
+package org.robolectric.shadows;
+
+import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL;
+import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import android.app.AutomaticZenRule;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationChannelGroup;
+import android.app.NotificationManager;
+import android.app.NotificationManager.Policy;
+import android.content.ComponentName;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.service.notification.StatusBarNotification;
+import android.service.notification.ZenPolicy;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(PAUSED)
+public class ShadowNotificationManagerTest {
+  private NotificationManager notificationManager;
+  private Notification notification1 = new Notification();
+  private Notification notification2 = new Notification();
+
+  @Before
+  public void setUp() {
+    notificationManager =
+        (NotificationManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.NOTIFICATION_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void getCurrentInterruptionFilter() {
+    // Sensible default
+    assertThat(notificationManager.getCurrentInterruptionFilter())
+        .isEqualTo(INTERRUPTION_FILTER_ALL);
+
+    notificationManager.setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY);
+    assertThat(notificationManager.getCurrentInterruptionFilter())
+        .isEqualTo(INTERRUPTION_FILTER_PRIORITY);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void getNotificationPolicy() {
+    assertThat(notificationManager.getNotificationPolicy()).isNull();
+
+    final Policy policy = new Policy(0, 0, 0);
+    notificationManager.setNotificationPolicy(policy);
+    assertThat(notificationManager.getNotificationPolicy()).isEqualTo(policy);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void createNotificationChannel() {
+    notificationManager.createNotificationChannel(new NotificationChannel("id", "name", 1));
+
+    assertThat(shadowOf(notificationManager).getNotificationChannels()).hasSize(1);
+    NotificationChannel channel =
+        (NotificationChannel) shadowOf(notificationManager).getNotificationChannel("id");
+    assertThat(channel.getName().toString()).isEqualTo("name");
+    assertThat(channel.getImportance()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void createNotificationChannel_updateChannel() {
+    NotificationChannel channel = new NotificationChannel("id", "name", 1);
+    channel.setDescription("description");
+    channel.setGroup("channelGroupId");
+    channel.setLightColor(7);
+
+    NotificationChannel channelUpdate = new NotificationChannel("id", "newName", 2);
+    channelUpdate.setDescription("newDescription");
+    channelUpdate.setGroup("newChannelGroupId");
+    channelUpdate.setLightColor(15);
+
+    notificationManager.createNotificationChannel(channel);
+    notificationManager.createNotificationChannel(channelUpdate);
+
+    assertThat(shadowOf(notificationManager).getNotificationChannels()).hasSize(1);
+    NotificationChannel resultChannel =
+        (NotificationChannel) shadowOf(notificationManager).getNotificationChannel("id");
+    assertThat(resultChannel.getName().toString()).isEqualTo("newName");
+    assertThat(resultChannel.getDescription()).isEqualTo("newDescription");
+    // No importance upgrade.
+    assertThat(resultChannel.getImportance()).isEqualTo(1);
+    // No group resultChannel.
+    assertThat(resultChannel.getGroup()).isEqualTo("channelGroupId");
+    // Other settings are unchanged as well.
+    assertThat(resultChannel.getLightColor()).isEqualTo(7);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void createNotificationChannel_downGradeImportanceSetGroup() {
+    NotificationChannel channel = new NotificationChannel("id", "name", 1);
+    channel.setDescription("description");
+
+    NotificationChannel channelUpdate = new NotificationChannel("id", "newName", 0);
+    channelUpdate.setDescription("newDescription");
+    channelUpdate.setGroup("newChannelGroupId");
+
+    notificationManager.createNotificationChannel(channel);
+    notificationManager.createNotificationChannel(channelUpdate);
+
+    assertThat(shadowOf(notificationManager).getNotificationChannels()).hasSize(1);
+    NotificationChannel resultChannel =
+        (NotificationChannel) shadowOf(notificationManager).getNotificationChannel("id");
+    assertThat(resultChannel.getName().toString()).isEqualTo("newName");
+    assertThat(resultChannel.getDescription()).isEqualTo("newDescription");
+    assertThat(resultChannel.getImportance()).isEqualTo(0);
+    assertThat(resultChannel.getGroup()).isEqualTo("newChannelGroupId");
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void createNotificationChannelGroup() {
+    notificationManager.createNotificationChannelGroup(new NotificationChannelGroup("id", "name"));
+
+    assertThat(shadowOf(notificationManager).getNotificationChannelGroups()).hasSize(1);
+    NotificationChannelGroup group =
+        (NotificationChannelGroup) shadowOf(notificationManager).getNotificationChannelGroup("id");
+    assertThat(group.getName().toString()).isEqualTo("name");
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void createNotificationChannels() {
+    NotificationChannel channel1 = new NotificationChannel("id", "name", 1);
+    NotificationChannel channel2 = new NotificationChannel("id2", "name2", 1);
+
+    notificationManager.createNotificationChannels(ImmutableList.of(channel1, channel2));
+
+    assertThat(shadowOf(notificationManager).getNotificationChannels()).hasSize(2);
+    NotificationChannel channel =
+        (NotificationChannel) shadowOf(notificationManager).getNotificationChannel("id");
+    assertThat(channel.getName().toString()).isEqualTo("name");
+    assertThat(channel.getImportance()).isEqualTo(1);
+    channel = (NotificationChannel) shadowOf(notificationManager).getNotificationChannel("id2");
+    assertThat(channel.getName().toString()).isEqualTo("name2");
+    assertThat(channel.getImportance()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void deleteNotificationChannel() {
+    final String channelId = "channelId";
+    assertThat(shadowOf(notificationManager).isChannelDeleted(channelId)).isFalse();
+    notificationManager.createNotificationChannel(new NotificationChannel(channelId, "name", 1));
+    assertThat(shadowOf(notificationManager).isChannelDeleted(channelId)).isFalse();
+    notificationManager.deleteNotificationChannel(channelId);
+    assertThat(shadowOf(notificationManager).isChannelDeleted(channelId)).isTrue();
+    assertThat(notificationManager.getNotificationChannel(channelId)).isNull();
+    // Per documentation, recreating a deleted channel should have the same settings as the old
+    // deleted channel except of name, description, importance downgrade or setting a group if group
+    // was not previously set.
+    notificationManager.createNotificationChannel(
+        new NotificationChannel(channelId, "otherName", 2));
+    assertThat(shadowOf(notificationManager).isChannelDeleted(channelId)).isFalse();
+    NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
+    // Name is changed.
+    assertThat(channel.getName().toString()).isEqualTo("otherName");
+    // Original importance.
+    assertThat(channel.getImportance()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void deleteNotificationChannelGroup() {
+    final String channelId = "channelId";
+    final String channelGroupId = "channelGroupId";
+    notificationManager.createNotificationChannelGroup(
+        new NotificationChannelGroup(channelGroupId, "groupName"));
+    NotificationChannel channel = new NotificationChannel(channelId, "channelName", 1);
+    channel.setGroup(channelGroupId);
+    notificationManager.createNotificationChannel(channel);
+    assertThat(shadowOf(notificationManager).isChannelDeleted(channelId)).isFalse();
+    notificationManager.deleteNotificationChannelGroup(channelGroupId);
+    assertThat(shadowOf(notificationManager).getNotificationChannelGroup(channelGroupId)).isNull();
+    // Per documentation, deleting a channel group also deletes all associated channels.
+    assertThat(shadowOf(notificationManager).isChannelDeleted(channelId)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void areNotificationsEnabled() {
+    shadowOf(notificationManager).setNotificationsEnabled(true);
+    assertThat(notificationManager.areNotificationsEnabled()).isTrue();
+    shadowOf(notificationManager).setNotificationsEnabled(false);
+    assertThat(notificationManager.areNotificationsEnabled()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void setAndGetImportance() {
+    shadowOf(notificationManager).setImportance(NotificationManager.IMPORTANCE_DEFAULT);
+    assertThat(notificationManager.getImportance())
+        .isEqualTo(NotificationManager.IMPORTANCE_DEFAULT);
+
+    shadowOf(notificationManager).setImportance(NotificationManager.IMPORTANCE_NONE);
+    assertThat(notificationManager.getImportance()).isEqualTo(NotificationManager.IMPORTANCE_NONE);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void isNotificationPolicyAccessGranted() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    assertThat(notificationManager.isNotificationPolicyAccessGranted()).isTrue();
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(false);
+    assertThat(notificationManager.isNotificationPolicyAccessGranted()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void
+      setNotificationPolicyAccessGranted_temporarilyDenyAccess_shouldClearAutomaticZenRules() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    String id = notificationManager.addAutomaticZenRule(rule);
+
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(false);
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+
+    assertThat(notificationManager.getAutomaticZenRule(id)).isNull();
+    assertThat(notificationManager.getAutomaticZenRules()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O_MR1)
+  public void isNotificationListenerAccessGranted() {
+    ComponentName componentName = new ComponentName("pkg", "cls");
+    shadowOf(notificationManager).setNotificationListenerAccessGranted(componentName, true);
+    assertThat(notificationManager.isNotificationListenerAccessGranted(componentName)).isTrue();
+    shadowOf(notificationManager).setNotificationListenerAccessGranted(componentName, false);
+    assertThat(notificationManager.isNotificationListenerAccessGranted(componentName)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void getAutomaticZenRule_notificationAccessDenied_shouldThrowSecurityException() {
+    try {
+      notificationManager.getAutomaticZenRule("some_id");
+      fail("Should have thrown SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void getAutomaticZenRule_nonexistentId_shouldReturnNull() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    String id = notificationManager.addAutomaticZenRule(rule);
+
+    String nonexistentId = "id_different_from_" + id;
+    assertThat(notificationManager.getAutomaticZenRule(nonexistentId)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void getAutomaticZenRules_notificationAccessDenied_shouldThrowSecurityException() {
+    try {
+      notificationManager.getAutomaticZenRules();
+      fail("Should have thrown SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void getAutomaticZenRules_noRulesAdded_shouldReturnEmptyMap() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    assertThat(notificationManager.getAutomaticZenRules()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void addAutomaticZenRule_notificationAccessDenied_shouldThrowSecurityException() {
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    try {
+      notificationManager.addAutomaticZenRule(rule);
+      fail("Should have thrown SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void addAutomaticZenRule_oneRuleWithConfigurationActivity_shouldAddRuleAndReturnId() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            /* owner= */ null,
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            new ZenPolicy.Builder().build(),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    String id = notificationManager.addAutomaticZenRule(rule);
+
+    assertThat(id).isNotEmpty();
+    assertThat(notificationManager.getAutomaticZenRule(id)).isEqualTo(rule);
+    assertThat(notificationManager.getAutomaticZenRules()).containsExactly(id, rule);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void addAutomaticZenRule_oneRule_shouldAddRuleAndReturnId() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    String id = notificationManager.addAutomaticZenRule(rule);
+
+    assertThat(id).isNotEmpty();
+    assertThat(notificationManager.getAutomaticZenRule(id)).isEqualTo(rule);
+    assertThat(notificationManager.getAutomaticZenRules()).containsExactly(id, rule);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void addAutomaticZenRule_twoRules_shouldAddBothRulesAndReturnDifferentIds() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+
+    AutomaticZenRule rule1 =
+        new AutomaticZenRule(
+            "name1",
+            new ComponentName("pkg1", "cls1"),
+            Uri.parse("condition://id1"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    AutomaticZenRule rule2 =
+        new AutomaticZenRule(
+            "name2",
+            new ComponentName("pkg2", "cls2"),
+            Uri.parse("condition://id2"),
+            NotificationManager.INTERRUPTION_FILTER_ALARMS,
+            /* enabled= */ false);
+    String id1 = notificationManager.addAutomaticZenRule(rule1);
+    String id2 = notificationManager.addAutomaticZenRule(rule2);
+
+    assertThat(id2).isNotEqualTo(id1);
+    assertThat(notificationManager.getAutomaticZenRule(id1)).isEqualTo(rule1);
+    assertThat(notificationManager.getAutomaticZenRule(id2)).isEqualTo(rule2);
+    assertThat(notificationManager.getAutomaticZenRules()).containsExactly(id1, rule1, id2, rule2);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void updateAutomaticZenRule_notificationAccessDenied_shouldThrowSecurityException() {
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    try {
+      notificationManager.updateAutomaticZenRule("some_id", rule);
+      fail("Should have thrown SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void updateAutomaticZenRule_nonexistentId_shouldThrowSecurityException() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    String id = notificationManager.addAutomaticZenRule(rule);
+
+    String nonexistentId = "id_different_from_" + id;
+    AutomaticZenRule updatedRule =
+        new AutomaticZenRule(
+            "updated_name",
+            new ComponentName("updated_pkg", "updated_cls"),
+            Uri.parse("condition://updated_id"),
+            NotificationManager.INTERRUPTION_FILTER_ALL,
+            /* enabled= */ false);
+    try {
+      assertThat(notificationManager.updateAutomaticZenRule(nonexistentId, updatedRule)).isTrue();
+      fail("Should have thrown SecurityException");
+    } catch (SecurityException expected) {
+    }
+
+    assertThat(notificationManager.getAutomaticZenRule(id)).isEqualTo(rule);
+    assertThat(notificationManager.getAutomaticZenRule(nonexistentId)).isNull();
+    assertThat(notificationManager.getAutomaticZenRules()).containsExactly(id, rule);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void updateAutomaticZenRule_existingId_shouldUpdateRuleAndReturnTrue() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule1 =
+        new AutomaticZenRule(
+            "name1",
+            new ComponentName("pkg1", "cls1"),
+            Uri.parse("condition://id1"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    AutomaticZenRule rule2 =
+        new AutomaticZenRule(
+            "name2",
+            new ComponentName("pkg2", "cls2"),
+            Uri.parse("condition://id2"),
+            NotificationManager.INTERRUPTION_FILTER_ALARMS,
+            /* enabled= */ false);
+    String id1 = notificationManager.addAutomaticZenRule(rule1);
+    String id2 = notificationManager.addAutomaticZenRule(rule2);
+
+    AutomaticZenRule updatedRule =
+        new AutomaticZenRule(
+            "updated_name",
+            new ComponentName("updated_pkg", "updated_cls"),
+            Uri.parse("condition://updated_id"),
+            NotificationManager.INTERRUPTION_FILTER_ALL,
+            /* enabled= */ false);
+    assertThat(notificationManager.updateAutomaticZenRule(id2, updatedRule)).isTrue();
+
+    assertThat(notificationManager.getAutomaticZenRule(id1)).isEqualTo(rule1);
+    assertThat(notificationManager.getAutomaticZenRule(id2)).isEqualTo(updatedRule);
+    assertThat(notificationManager.getAutomaticZenRules())
+        .containsExactly(id1, rule1, id2, updatedRule);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void updateAutomaticZenRule_nullOwnerWithConfigurationActivity_updateRuleAndReturnTrue() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule1 =
+        new AutomaticZenRule(
+            "name1",
+            /* owner= */ null,
+            new ComponentName("pkg1", "cls1"),
+            Uri.parse("condition://id1"),
+            new ZenPolicy.Builder().build(),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    AutomaticZenRule rule2 =
+        new AutomaticZenRule(
+            "name2",
+            /* owner= */ null,
+            new ComponentName("pkg2", "cls2"),
+            Uri.parse("condition://id2"),
+            new ZenPolicy.Builder().build(),
+            NotificationManager.INTERRUPTION_FILTER_ALARMS,
+            /* enabled= */ false);
+    String id1 = notificationManager.addAutomaticZenRule(rule1);
+    String id2 = notificationManager.addAutomaticZenRule(rule2);
+
+    AutomaticZenRule updatedRule =
+        new AutomaticZenRule(
+            "updated_name",
+            /* owner= */ null,
+            new ComponentName("updated_pkg", "updated_cls"),
+            Uri.parse("condition://updated_id"),
+            new ZenPolicy.Builder().build(),
+            NotificationManager.INTERRUPTION_FILTER_ALL,
+            /* enabled= */ false);
+    assertThat(notificationManager.updateAutomaticZenRule(id2, updatedRule)).isTrue();
+
+    assertThat(notificationManager.getAutomaticZenRule(id1)).isEqualTo(rule1);
+    assertThat(notificationManager.getAutomaticZenRule(id2)).isEqualTo(updatedRule);
+    assertThat(notificationManager.getAutomaticZenRules())
+        .containsExactly(id1, rule1, id2, updatedRule);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void removeAutomaticZenRule_notificationAccessDenied_shouldThrowSecurityException() {
+    try {
+      notificationManager.removeAutomaticZenRule("some_id");
+      fail("Should have thrown SecurityException");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void removeAutomaticZenRule_nonexistentId_shouldAndReturnFalse() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule =
+        new AutomaticZenRule(
+            "name",
+            new ComponentName("pkg", "cls"),
+            Uri.parse("condition://id"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    String id = notificationManager.addAutomaticZenRule(rule);
+
+    String nonexistentId = "id_different_from_" + id;
+    assertThat(notificationManager.removeAutomaticZenRule(nonexistentId)).isFalse();
+    // The rules stored in NotificationManager should remain unchanged.
+    assertThat(notificationManager.getAutomaticZenRules()).containsExactly(id, rule);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void removeAutomaticZenRule_existingId_shouldRemoveRuleAndReturnTrue() {
+    shadowOf(notificationManager).setNotificationPolicyAccessGranted(true);
+    AutomaticZenRule rule1 =
+        new AutomaticZenRule(
+            "name1",
+            new ComponentName("pkg1", "cls1"),
+            Uri.parse("condition://id1"),
+            NotificationManager.INTERRUPTION_FILTER_PRIORITY,
+            /* enabled= */ true);
+    AutomaticZenRule rule2 =
+        new AutomaticZenRule(
+            "name2",
+            new ComponentName("pkg2", "cls2"),
+            Uri.parse("condition://id2"),
+            NotificationManager.INTERRUPTION_FILTER_ALARMS,
+            /* enabled= */ false);
+    String id1 = notificationManager.addAutomaticZenRule(rule1);
+    String id2 = notificationManager.addAutomaticZenRule(rule2);
+
+    assertThat(notificationManager.removeAutomaticZenRule(id1)).isTrue();
+
+    assertThat(notificationManager.getAutomaticZenRule(id1)).isNull();
+    assertThat(notificationManager.getAutomaticZenRule(id2)).isEqualTo(rule2);
+    assertThat(notificationManager.getAutomaticZenRules()).containsExactly(id2, rule2);
+  }
+
+  @Test
+  public void testNotify() {
+    notificationManager.notify(1, notification1);
+    assertEquals(1, shadowOf(notificationManager).size());
+    assertEquals(notification1, shadowOf(notificationManager).getNotification(null, 1));
+
+    notificationManager.notify(31, notification2);
+    assertEquals(2, shadowOf(notificationManager).size());
+    assertEquals(notification2, shadowOf(notificationManager).getNotification(null, 31));
+  }
+
+  @Test
+  public void testNotifyReplaces() {
+    notificationManager.notify(1, notification1);
+
+    notificationManager.notify(1, notification2);
+    assertEquals(1, shadowOf(notificationManager).size());
+    assertEquals(notification2, shadowOf(notificationManager).getNotification(null, 1));
+  }
+
+  @Test
+  public void testNotifyWithTag() {
+    notificationManager.notify("a tag", 1, notification1);
+    assertEquals(1, shadowOf(notificationManager).size());
+    assertEquals(notification1, shadowOf(notificationManager).getNotification("a tag", 1));
+  }
+
+  @Test
+  public void notifyWithTag_shouldReturnNullForNullTag() {
+    notificationManager.notify("a tag", 1, notification1);
+    assertEquals(1, shadowOf(notificationManager).size());
+    assertNull(shadowOf(notificationManager).getNotification(null, 1));
+  }
+
+  @Test
+  public void notifyWithTag_shouldReturnNullForUnknownTag() {
+    notificationManager.notify("a tag", 1, notification1);
+    assertEquals(1, shadowOf(notificationManager).size());
+    assertNull(shadowOf(notificationManager).getNotification("unknown tag", 1));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void testNotify_setsPostTime() {
+    long startTimeMillis = ShadowSystem.currentTimeMillis();
+
+    ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); // Now startTimeMillis + 1000.
+    notificationManager.notify(1, notification1);
+    ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); // Now startTimeMillis + 2000.
+    notificationManager.notify(2, notification2);
+
+    assertThat(getStatusBarNotification(1).getPostTime()).isEqualTo(startTimeMillis + 1000);
+    assertThat(getStatusBarNotification(2).getPostTime()).isEqualTo(startTimeMillis + 2000);
+  }
+
+  @Test
+  public void testNotify_withLimitEnforced() {
+    shadowOf(notificationManager).setEnforceMaxNotificationLimit(true);
+
+    for (int i = 0; i < 25; i++) {
+      Notification notification = new Notification();
+      notificationManager.notify(i, notification);
+    }
+    assertEquals(25, shadowOf(notificationManager).size());
+    notificationManager.notify("26tag", 26, notification1);
+    assertEquals(25, shadowOf(notificationManager).size());
+    assertNull(shadowOf(notificationManager).getNotification("26tag", 26));
+
+    shadowOf(notificationManager).setEnforceMaxNotificationLimit(false);
+  }
+
+  @Test
+  public void testNotify_withLimitNotEnforced() {
+    for (int i = 0; i < 25; i++) {
+      Notification notification = new Notification();
+      notificationManager.notify(i, notification);
+    }
+    assertEquals(25, shadowOf(notificationManager).size());
+    notificationManager.notify("26tag", 26, notification1);
+    assertEquals(26, shadowOf(notificationManager).size());
+    assertEquals(notification1, shadowOf(notificationManager).getNotification("26tag", 26));
+  }
+
+  @Test
+  public void testCancel() {
+    notificationManager.notify(1, notification1);
+    notificationManager.cancel(1);
+
+    assertEquals(0, shadowOf(notificationManager).size());
+    assertNull(shadowOf(notificationManager).getNotification(null, 1));
+  }
+
+  @Test
+  public void testCancelWithTag() {
+    notificationManager.notify("a tag", 1, notification1);
+    notificationManager.cancel("a tag", 1);
+
+    assertEquals(0, shadowOf(notificationManager).size());
+    assertNull(shadowOf(notificationManager).getNotification(null, 1));
+    assertNull(shadowOf(notificationManager).getNotification("a tag", 1));
+  }
+
+  @Test
+  public void testCancelAll() {
+    notificationManager.notify(1, notification1);
+    notificationManager.notify(31, notification2);
+    notificationManager.cancelAll();
+
+    assertEquals(0, shadowOf(notificationManager).size());
+    assertNull(shadowOf(notificationManager).getNotification(null, 1));
+    assertNull(shadowOf(notificationManager).getNotification(null, 31));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void testGetActiveNotifications() {
+    notificationManager.notify(1, notification1);
+    notificationManager.notify(31, notification2);
+
+    StatusBarNotification[] statusBarNotifications =
+        shadowOf(notificationManager).getActiveNotifications();
+
+    assertThat(asNotificationList(statusBarNotifications))
+        .containsExactly(notification1, notification2);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testSetNotificationDelegate() {
+    notificationManager.setNotificationDelegate("com.example.myapp");
+
+    assertThat(notificationManager.getNotificationDelegate()).isEqualTo("com.example.myapp");
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testSetNotificationDelegate_null() {
+    notificationManager.setNotificationDelegate("com.example.myapp");
+    notificationManager.setNotificationDelegate(null);
+
+    assertThat(notificationManager.getNotificationDelegate()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testCanNotifyAsPackage_isFalseWhenNoDelegateIsSet() {
+    assertThat(notificationManager.canNotifyAsPackage("some.package")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testCanNotifyAsPackage_isTrueWhenDelegateIsSet() {
+    String pkg = "some.package";
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg, true);
+    assertThat(notificationManager.canNotifyAsPackage(pkg)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testCanNotifyAsPackage_isFalseWhenDelegateIsUnset() {
+    String pkg = "some.package";
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg, true);
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg, false);
+    assertThat(notificationManager.canNotifyAsPackage(pkg)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testCanNotifyAsPackage_isFalseWhenOtherDelegateIsSet() {
+    shadowOf(notificationManager).setCanNotifyAsPackage("other.package", true);
+    assertThat(notificationManager.canNotifyAsPackage("some.package")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testCanNotifyAsPackage_workAsExpectedWhenMultipleDelegatesSetAndUnset() {
+    String pkg1 = "some.package";
+    String pkg2 = "another.package";
+    // When pkg1 and pkg2 where set for delegation
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg1, true);
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg2, true);
+    assertThat(notificationManager.canNotifyAsPackage(pkg1)).isTrue();
+    assertThat(notificationManager.canNotifyAsPackage(pkg2)).isTrue();
+    // When pkg1 unset
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg1, false);
+    assertThat(notificationManager.canNotifyAsPackage(pkg1)).isFalse();
+    assertThat(notificationManager.canNotifyAsPackage(pkg2)).isTrue();
+    // When pkg2 unset
+    shadowOf(notificationManager).setCanNotifyAsPackage(pkg2, false);
+    assertThat(notificationManager.canNotifyAsPackage(pkg1)).isFalse();
+    assertThat(notificationManager.canNotifyAsPackage(pkg2)).isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getNotificationChannel() {
+    NotificationChannel notificationChannel = new NotificationChannel("id", "name", 1);
+    String conversationId = "conversation_id";
+    String parentChannelId = "parent_channel_id";
+    notificationChannel.setConversationId(parentChannelId, conversationId);
+    notificationManager.createNotificationChannel(notificationChannel);
+
+    assertThat(notificationManager.getNotificationChannels()).hasSize(1);
+    NotificationChannel channel =
+        notificationManager.getNotificationChannel(parentChannelId, conversationId);
+    assertThat(channel.getName().toString()).isEqualTo("name");
+    assertThat(channel.getImportance()).isEqualTo(1);
+  }
+
+  private static List<Notification> asNotificationList(
+      StatusBarNotification[] statusBarNotifications) {
+    List<Notification> notificationList = new ArrayList<>(statusBarNotifications.length);
+    for (StatusBarNotification statusBarNotification : statusBarNotifications) {
+      notificationList.add(statusBarNotification.getNotification());
+    }
+    return notificationList;
+  }
+
+  private StatusBarNotification getStatusBarNotification(int id) {
+    for (StatusBarNotification statusBarNotification :
+        shadowOf(notificationManager).getActiveNotifications()) {
+      if (statusBarNotification.getTag() == null && statusBarNotification.getId() == id) {
+        return statusBarNotification;
+      }
+    }
+    return null;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationTest.java
new file mode 100644
index 0000000..29dc324
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNotificationTest.java
@@ -0,0 +1,23 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Intent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowNotificationTest {
+
+  @Test
+  public void setLatestEventInfo__shouldCaptureContentIntent() {
+    PendingIntent pendingIntent = PendingIntent.getActivity(getApplication(), 0, new Intent(), 0);
+    Notification notification = new Notification();
+    notification.setLatestEventInfo(getApplication(), "title", "content", pendingIntent);
+    assertThat(notification.contentIntent).isSameInstanceAs(pendingIntent);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowNumberPickerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowNumberPickerTest.java
new file mode 100644
index 0000000..fe4bf8b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowNumberPickerTest.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.widget.NumberPicker;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowNumberPickerTest {
+
+  @Test
+  public void shouldFireListeners() {
+    NumberPicker picker = new NumberPicker(ApplicationProvider.getApplicationContext());
+
+    NumberPicker.OnValueChangeListener listener = mock(NumberPicker.OnValueChangeListener.class);
+    picker.setOnValueChangedListener(listener);
+
+    ShadowNumberPicker shadowNumberPicker = Shadows.shadowOf(picker);
+    shadowNumberPicker.getOnValueChangeListener().onValueChange(picker, 5, 10);
+
+    verify(listener).onValueChange(picker, 5, 10);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowObjectAnimatorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowObjectAnimatorTest.java
new file mode 100644
index 0000000..72654a7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowObjectAnimatorTest.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowObjectAnimatorTest {
+  private final AnimatorTarget target = new AnimatorTarget();
+  private List<String> listenerEvents = new ArrayList<>();
+
+  private final Animator.AnimatorListener listener = new Animator.AnimatorListener() {
+    @Override
+    public void onAnimationStart(Animator animation) {
+      listenerEvents.add("started");
+    }
+
+    @Override
+    public void onAnimationEnd(Animator animation) {
+      listenerEvents.add("ended");
+    }
+
+    @Override
+    public void onAnimationCancel(Animator animation) {
+      listenerEvents.add("cancelled");
+    }
+
+    @Override
+    public void onAnimationRepeat(Animator animation) {
+      listenerEvents.add("repeated");
+    }
+  };
+
+  @Test
+  public void start_shouldRunAnimation() {
+    final ObjectAnimator animator = ObjectAnimator.ofInt(target, "transparency", 0, 1, 2, 3, 4);
+
+    shadowMainLooper().pause();
+    animator.setDuration(1000);
+    animator.addListener(listener);
+    animator.start();
+
+    assertThat(listenerEvents).containsExactly("started");
+    assertThat(target.getTransparency()).isEqualTo(0);
+
+    shadowMainLooper().idleFor(Duration.ofSeconds(1));
+
+    assertThat(listenerEvents).containsExactly("started", "ended");
+    assertThat(target.getTransparency()).isEqualTo(4);
+  }
+
+  @SuppressWarnings("unused")
+  public static class AnimatorTarget {
+    private int transparency;
+
+    public void setTransparency(int transparency) {
+      this.transparency = transparency;
+    }
+
+    public int getTransparency() {
+      return transparency;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowOpenGLMatrixTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowOpenGLMatrixTest.java
new file mode 100644
index 0000000..38fbdeb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowOpenGLMatrixTest.java
@@ -0,0 +1,573 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.opengl.Matrix;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowOpenGLMatrixTest {
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfResIsNull() {
+    Matrix.multiplyMM(null, 0, new float[16], 0, new float[16], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfLhsIsNull() {
+    Matrix.multiplyMM(new float[16], 0, null, 0, new float[16], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfRhsIsNull() {
+    Matrix.multiplyMM(new float[16], 0, new float[16], 0, null, 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfResIsSmall() {
+    Matrix.multiplyMM(new float[15], 0, new float[16], 0, new float[16], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfLhsIsSmall() {
+    Matrix.multiplyMM(new float[16], 0, new float[15], 0, new float[16], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfRhsIsSmall() {
+    Matrix.multiplyMM(new float[16], 0, new float[16], 0, new float[15], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfResOffsetIsOutOfBounds() {
+    Matrix.multiplyMM(new float[32], 30, new float[16], 0, new float[16], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfLhsOffsetIsOutOfBounds() {
+    Matrix.multiplyMM(new float[16], 0, new float[32], 30, new float[16], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMM_failIfRhsOffsetIsOutOfBounds() {
+    Matrix.multiplyMM(new float[16], 0, new float[16], 0, new float[32], 30);
+  }
+
+  @Test
+  public void multiplyIdentity() {
+    final float[] res = new float[16];
+    final float[] i = new float[16];
+    Matrix.setIdentityM(i, 0);
+    final float[] m1 = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    Matrix.multiplyMM(res, 0, m1, 0, i, 0);
+    assertThat(res).usingExactEquality().containsAtLeast(m1);
+
+    Matrix.multiplyMM(res, 0, i, 0, m1, 0);
+    assertThat(res).usingExactEquality().containsAtLeast(m1);
+  }
+
+  @Test
+  public void multiplyIdentityWithOffset() {
+    final float[] res = new float[32];
+    final float[] i = new float[32];
+    Matrix.setIdentityM(i, 16);
+    final float[] m1 = new float[]{
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    Matrix.multiplyMM(res, 16, m1, 16, i, 16);
+    assertThat(res).usingExactEquality().containsAtLeast(m1);
+
+    Matrix.multiplyMM(res, 16, i, 16, m1, 16);
+    assertThat(res).usingExactEquality().containsAtLeast(m1);
+  }
+
+  @Test
+  public void multiplyMM() {
+    final float[] res = new float[16];
+    final float[] m1 = new float[]{
+            0, 1, 2, 3,
+            4, 5, 6, 7,
+            8, 9, 10, 11,
+            12, 13, 14, 15
+    };
+    final float[] m2 = new float[]{
+            0, 1, 2, 3,
+            4, 5, 6, 7,
+            8, 9, 10, 11,
+            12, 13, 14, 15
+    };
+
+    final float[] expected = new float[]{
+            56, 62, 68, 74,
+            152, 174, 196, 218,
+            248, 286, 324, 362,
+            344, 398, 452, 506
+    };
+
+
+    Matrix.multiplyMM(res, 0, m1, 0, m2, 0);
+    assertThat(res).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void multiplyMMWithOffset() {
+    final float[] res = new float[32];
+    final float[] m1 = new float[]{
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+
+            0, 1, 2, 3,
+            4, 5, 6, 7,
+            8, 9, 10, 11,
+            12, 13, 14, 15
+    };
+    final float[] m2 = new float[]{
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            56, 62, 68, 74,
+            152, 174, 196, 218,
+            248, 286, 324, 362,
+            344, 398, 452, 506
+    };
+    final float[] expected = new float[]{
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            0, 0, 0, 0,
+            1680, 1940, 2200, 2460,
+            4880, 5620, 6360, 7100,
+            8080, 9300, 10520, 11740,
+            11280, 12980, 14680, 16380
+    };
+
+    Matrix.multiplyMM(res, 16, m1, 16, m2, 16);
+    assertThat(res).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void multiplyMMRandom() {
+    final float[] m1 = new float[]{
+            0.730964f, 0.006556f, 0.999294f, 0.886486f,
+            0.703636f, 0.865595f, 0.464857f, 0.861619f,
+            0.304945f, 0.740410f, 0.059668f, 0.876067f,
+            0.048256f, 0.259968f, 0.915555f, 0.356720f,
+    };
+    final float[] m2 = new float[]{
+            0.462205f, 0.868120f, 0.520904f, 0.959729f,
+            0.531887f, 0.882446f, 0.293452f, 0.878477f,
+            0.938628f, 0.796945f, 0.757566f, 0.983955f,
+            0.346051f, 0.972866f, 0.773706f, 0.895736f,
+    };
+
+    final float[] expected = new float[]{
+            1.153855f, 1.389652f, 1.775197f, 1.956428f,
+            1.141589f, 1.212979f, 1.763527f, 1.802296f,
+            1.525360f, 1.512691f, 2.254498f, 2.533418f,
+            1.216656f, 1.650098f, 1.664312f, 2.142354f,
+    };
+    final float[] res = new float[16];
+    Matrix.multiplyMM(res, 0, m1, 0, m2, 0);
+    assertMatrixWithPrecision(res, expected, 0.0001f);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfResIsNull() {
+    Matrix.multiplyMV(null, 0, new float[16], 0, new float[4], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfLhsIsNull() {
+    Matrix.multiplyMV(new float[4], 0, null, 0, new float[4], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfRhsIsNull() {
+    Matrix.multiplyMV(new float[4], 0, new float[16], 0, null, 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfResIsSmall() {
+    Matrix.multiplyMV(new float[3], 0, new float[16], 0, new float[4], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfLhsIsSmall() {
+    Matrix.multiplyMV(new float[4], 0, new float[15], 0, new float[4], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfRhsIsSmall() {
+    Matrix.multiplyMV(new float[4], 0, new float[16], 0, new float[3], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfResOffsetIsOutOfBounds() {
+    Matrix.multiplyMV(new float[4], 1, new float[16], 0, new float[4], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfLhsOffsetIsOutOfBounds() {
+    Matrix.multiplyMV(new float[4], 0, new float[16], 1, new float[4], 0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void multiplyMVFailsIfRhsOffsetIsOutOfBounds() {
+    Matrix.multiplyMV(new float[4], 0, new float[16], 0, new float[4], 1);
+  }
+
+  @Test
+  public void multiplyMVIdentity() {
+    final float[] res = new float[4];
+    final float[] i = new float[16];
+    Matrix.setIdentityM(i, 0);
+    float[] v1 = new float[]{1, 2, 3, 4};
+    Matrix.multiplyMV(res, 0, i, 0, v1, 0);
+    assertThat(res).usingExactEquality().containsAtLeast(v1);
+  }
+
+  @Test
+  public void multiplyMV() {
+    final float[] res = new float[4];
+    final float[] m1 = new float[]{
+            0, 1, 2, 3,
+            4, 5, 6, 7,
+            8, 9, 10, 11,
+            12, 13, 14, 15
+    };
+
+    float[] v1 = new float[]{42, 239, 128, 1024};
+    float[] expected = new float[]{14268, 15701, 17134, 18567};
+    Matrix.multiplyMV(res, 0, m1, 0, v1, 0);
+    assertThat(res).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void multiplyMVWithOffset() {
+    final float[] res = new float[5];
+    final float[] m1 = new float[]{
+            0, 0, 0, 0,
+            0, 1, 2, 3,
+            4, 5, 6, 7,
+            8, 9, 10, 11,
+            12, 13, 14, 15
+    };
+
+    float[] v1 = new float[]{
+            0, 0,
+            42, 239, 128, 1024
+    };
+    float[] expected = new float[]{
+            0,
+            14268, 15701, 17134, 18567
+    };
+    Matrix.multiplyMV(res, 1, m1, 4, v1, 2);
+    assertThat(res).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void multiplyMVRandom() {
+    final float[] m1 = new float[]{
+            0.575544f, 0.182558f, 0.097663f, 0.413832f,
+            0.781248f, 0.466904f, 0.353418f, 0.790540f,
+            0.074133f, 0.690470f, 0.619758f, 0.191669f,
+            0.953532f, 0.018836f, 0.336544f, 0.972782f,
+    };
+    final float[] v2 = new float[]{
+            0.573973f, 0.096736f, 0.330662f, 0.758732f,
+    };
+    final float[] expected = new float[]{
+            1.153910f, 0.392554f, 0.550521f, 1.115460f,
+    };
+    final float[] res = new float[4];
+    Matrix.multiplyMV(res, 0, m1, 0, v2, 0);
+    assertMatrixWithPrecision(res, expected, 0.0001f);
+  }
+
+  @Test
+  public void testLength() {
+    assertThat(Matrix.length(3, 4, 5)).isWithin(0.001f).of(7.071f);
+  }
+
+  @Test
+  public void testInvertM() {
+    float[] matrix = new float[]{
+            10, 0, 0, 0,
+            0, 20, 0, 0,
+            0, 0, 30, 0,
+            40, 50, 60, 1
+    };
+
+    float[] inverse = new float[]{
+            0.1f, 0, 0, 0,
+            0, 0.05f, 0, 0,
+            0, 0, 0.03333f, 0,
+            -4, -2.5f, -2, 1
+    };
+    float[] output = new float[16];
+    assertThat(Matrix.invertM(output, 0, matrix, 0)).isTrue();
+
+    assertMatrixWithPrecision(output, inverse, 0.0001f);
+  }
+
+  @Test
+  public void testMultiplyMM() {
+    float[] matrix1 = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] matrix2 = new float[]{
+            16, 15, 14, 13,
+            12, 11, 10, 9,
+            8, 7, 6, 5,
+            4, 3, 2, 1
+    };
+    float[] expected = new float[]{
+            386, 444, 502, 560,
+            274, 316, 358, 400,
+            162, 188, 214, 240,
+            50, 60, 70, 80,
+    };
+
+    float[] output = new float[16];
+    Matrix.multiplyMM(output, 0, matrix1, 0, matrix2, 0);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN, maxSdk = JELLY_BEAN)
+  public void testFrustumM() {
+    // this is actually a bug
+    // https://android.googlesource.com/platform/frameworks/base/+/0a088f5d4681fd2da6f610de157bf905df787bf7
+    // expected[8] should be 1.5
+    // see testFrustumJB_MR1 below
+    float[] expected = new float[]{
+            0.005f, 0, 0, 0,
+            0, 0.02f, 0, 0,
+            3f, 5, -1.020202f, -1,
+            0, 0, -2.020202f, 0,
+    };
+    float[] output = new float[16];
+    Matrix.frustumM(output, 0, 100, 500, 200, 300, 1, 100);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testFrustumJB_MR1() {
+    float[] expected = new float[]{
+            0.005f, 0, 0, 0,
+            0, 0.02f, 0, 0,
+            1.5f, 5, -1.020202f, -1,
+            0, 0, -2.020202f, 0,
+    };
+    float[] output = new float[16];
+    Matrix.frustumM(output, 0, 100, 500, 200, 300, 1, 100);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testPerspectiveM() {
+    float[] expected = new float[]{
+            1145.9144f, 0, 0, 0,
+            0, 572.9572f, 0, 0,
+            0, 0, -1.020202f, -1,
+            0, 0, -2.020202f, 0,
+    };
+    float[] output = new float[16];
+    Matrix.perspectiveM(output, 0, 0.2f, 0.5f, 1, 100);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testMultiplyMV() {
+    float[] matrix = new float[]{
+            2, 0, 0, 0,
+            0, 4, 0, 0,
+            0, 0, 6, 0,
+            1, 2, 3, 1
+    };
+
+    float[] vector = new float[]{5, 7, 9, 1};
+    float[] expected = new float[]{11, 30, 57, 1};
+    float[] output = new float[4];
+    Matrix.multiplyMV(output, 0, matrix, 0, vector, 0);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testSetIdentityM() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            1, 0, 0, 0,
+            0, 1, 0, 0,
+            0, 0, 1, 0,
+            0, 0, 0, 1
+    };
+    Matrix.setIdentityM(matrix, 0);
+    assertThat(matrix).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testScaleM() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            2, 4, 6, 8,
+            20, 24, 28, 32,
+            54, 60, 66, 72,
+            13, 14, 15, 16
+    };
+    float[] output = new float[16];
+    Matrix.scaleM(output, 0, matrix, 0, 2, 4, 6);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testScaleMInPlace() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            2, 4, 6, 8,
+            20, 24, 28, 32,
+            54, 60, 66, 72,
+            13, 14, 15, 16
+    };
+    Matrix.scaleM(matrix, 0, 2, 4, 6);
+    assertThat(matrix).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testTranslateM() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            89, 102, 115, 128
+    };
+    float[] output = new float[16];
+    Matrix.translateM(output, 0, matrix, 0, 2, 4, 6);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testTranslateMInPlace() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            89, 102, 115, 128
+    };
+    Matrix.translateM(matrix, 0, 2, 4, 6);
+    assertThat(matrix).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testRotateM() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            0.95625275f, 1.9625025f, 2.968752f, 3.9750016f,
+            5.0910234f, 6.07802f, 7.0650167f, 8.052013f,
+            8.953606f, 9.960234f, 10.966862f, 11.973489f,
+            13, 14, 15, 16
+    };
+    float[] output = new float[16];
+    Matrix.rotateM(output, 0, matrix, 0, 2, 4, 6, 8);
+    assertThat(output).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testRotateMInPlace() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            0.95625275f, 1.9625025f, 2.968752f, 3.9750016f,
+            5.0910234f, 6.07802f, 7.0650167f, 8.052013f,
+            8.953606f, 9.960234f, 10.966862f, 11.973489f,
+            13, 14, 15, 16
+    };
+    Matrix.rotateM(matrix, 0, 2, 4, 6, 8);
+    assertThat(matrix).usingExactEquality().containsAtLeast(expected);
+  }
+
+  @Test
+  public void testSetRotateM() {
+    float[] matrix = new float[]{
+            1, 2, 3, 4,
+            5, 6, 7, 8,
+            9, 10, 11, 12,
+            13, 14, 15, 16
+    };
+    float[] expected = new float[]{
+            0.9998687f, 0.01299483f, -0.00968048f, 0,
+            -0.012931813f, 0.999895f, 0.006544677f, 0,
+            0.009764502f, -0.006418644f, 0.99993175f, 0,
+            0, 0, 0, 1
+    };
+    Matrix.setRotateM(matrix, 0, 1, 2, 3, 4);
+    assertThat(matrix).usingExactEquality().containsAtLeast(expected);
+  }
+
+  private static void assertMatrixWithPrecision(float[] actual, float[] expected, float precision) {
+    assertThat(actual).hasLength(expected.length);
+    for (int i = 0; i < actual.length; i++) {
+      assertThat(actual[i]).isWithin(precision).of(expected[i]);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowOsConstantsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowOsConstantsTest.java
new file mode 100644
index 0000000..bfc4e6d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowOsConstantsTest.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.OsConstantsValues.OPEN_MODE_VALUES;
+
+import android.system.OsConstants;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowOsConstants}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowOsConstantsTest {
+
+  @Config(minSdk = LOLLIPOP)
+  @Test
+  public void valuesAreDistinct() throws Exception {
+    assertThat(OsConstants.errnoName(OsConstants.EAGAIN)).isEqualTo("EAGAIN");
+    assertThat(OsConstants.errnoName(OsConstants.EBADF)).isEqualTo("EBADF");
+  }
+
+  @Config(minSdk = LOLLIPOP)
+  @Test
+  public void valuesAreExpected() {
+    assertThat(OsConstants.S_IFMT).isEqualTo(OsConstantsValues.S_IFMT_VALUE);
+    assertThat(OsConstants.S_IFDIR).isEqualTo(OsConstantsValues.S_IFDIR_VALUE);
+    assertThat(OsConstants.S_IFREG).isEqualTo(OsConstantsValues.S_IFREG_VALUE);
+    assertThat(OsConstants.S_IFLNK).isEqualTo(OsConstantsValues.S_IFLNK_VALUE);
+
+    assertThat(OsConstants.O_RDONLY).isEqualTo(OPEN_MODE_VALUES.get("O_RDONLY"));
+    assertThat(OsConstants.O_WRONLY).isEqualTo(OPEN_MODE_VALUES.get("O_WRONLY"));
+    assertThat(OsConstants.O_RDWR).isEqualTo(OPEN_MODE_VALUES.get("O_RDWR"));
+    assertThat(OsConstants.O_ACCMODE).isEqualTo(OPEN_MODE_VALUES.get("O_ACCMODE"));
+    assertThat(OsConstants.O_CREAT).isEqualTo(OPEN_MODE_VALUES.get("O_CREAT"));
+    assertThat(OsConstants.O_EXCL).isEqualTo(OPEN_MODE_VALUES.get("O_EXCL"));
+    assertThat(OsConstants.O_TRUNC).isEqualTo(OPEN_MODE_VALUES.get("O_TRUNC"));
+    assertThat(OsConstants.O_APPEND).isEqualTo(OPEN_MODE_VALUES.get("O_APPEND"));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowOutlineTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowOutlineTest.java
new file mode 100644
index 0000000..7870d7c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowOutlineTest.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.graphics.Outline;
+import android.graphics.Path;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowOutlineTest {
+
+    @Test
+    public void setConvexPath_doesNothing() {
+        final Outline outline = new Outline();
+        outline.setConvexPath(new Path());
+    }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPackageInstallerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPackageInstallerTest.java
new file mode 100644
index 0000000..075d3c1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPackageInstallerTest.java
@@ -0,0 +1,221 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.content.IIntentSender;
+import android.content.IntentSender;
+import android.content.pm.PackageInstaller;
+import android.os.Handler;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.OutputStream;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowPackageInstallerTest {
+
+  private PackageInstaller packageInstaller;
+
+  @Before
+  public void setUp() {
+    packageInstaller =
+        ApplicationProvider.getApplicationContext().getPackageManager().getPackageInstaller();
+  }
+
+  @Test
+  public void shouldBeNoInProcessSessionsOnRobolectricStartup() {
+    assertThat(packageInstaller.getAllSessions()).isEmpty();
+  }
+
+  @Test
+  public void packageInstallerCreateSession() throws Exception {
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+
+    assertThat(sessionId).isNotEqualTo(0);
+  }
+
+  @Test
+  public void packageInstallerCreateAndGetSession() throws Exception {
+    // Act.
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+
+    // Assert.
+    List<PackageInstaller.SessionInfo> sessions;
+    sessions = packageInstaller.getMySessions();
+    assertThat(sessions).hasSize(1);
+    assertThat(sessions.get(0).getSessionId()).isEqualTo(sessionId);
+
+    sessions = packageInstaller.getAllSessions();
+    assertThat(sessions).hasSize(1);
+    assertThat(sessions.get(0).getSessionId()).isEqualTo(sessionId);
+
+    assertThat(packageInstaller.getSessionInfo(sessionId)).isNotNull();
+  }
+
+  @Test
+  public void packageInstallerCreateAndAbandonSession() throws Exception {
+    PackageInstaller.SessionCallback mockCallback = mock(PackageInstaller.SessionCallback.class);
+    packageInstaller.registerSessionCallback(mockCallback, new Handler());
+
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    shadowMainLooper().idle();
+
+    PackageInstaller.SessionInfo sessionInfo = packageInstaller.getSessionInfo(sessionId);
+    assertThat(sessionInfo.isActive()).isTrue();
+
+    assertThat(sessionInfo.appPackageName).isEqualTo("packageName");
+
+    packageInstaller.abandonSession(sessionId);
+    shadowMainLooper().idle();
+
+    assertThat(packageInstaller.getSessionInfo(sessionId)).isNull();
+    assertThat(packageInstaller.getAllSessions()).isEmpty();
+
+    verify(mockCallback).onCreated(sessionId);
+    verify(mockCallback).onFinished(sessionId, false);
+  }
+
+  @Test
+  public void packageInstallerOpenSession() throws Exception {
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    assertThat(session).isNotNull();
+  }
+
+  @Test
+  public void shouldBeNoSessionCallbacksOnRobolectricStartup() {
+    assertThat(shadowOf(packageInstaller).getAllSessionCallbacks()).isEmpty();
+  }
+
+  @Test
+  public void shouldBeSessionCallbacksWhenRegistered() {
+    PackageInstaller.SessionCallback mockCallback = mock(PackageInstaller.SessionCallback.class);
+
+    packageInstaller.registerSessionCallback(mockCallback);
+    shadowMainLooper().idle();
+
+    assertThat(shadowOf(packageInstaller).getAllSessionCallbacks()).containsExactly(mockCallback);
+  }
+
+  @Test(expected = SecurityException.class)
+  public void packageInstallerOpenSession_nonExistantSessionThrowsException() throws Exception {
+    packageInstaller.openSession(-99);
+  }
+
+  @Test // TODO: Initial implementation has a no-op OutputStream - complete this implementation.
+  public void sessionOpenWriteDoesNotThrowException() throws Exception {
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    OutputStream filename = session.openWrite("filename", 0, 0);
+    filename.write(10);
+  }
+
+  @Test
+  public void sessionCommitSession_streamProperlyClosed() throws Exception {
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    OutputStream outputStream = session.openWrite("filename", 0, 0);
+    outputStream.close();
+
+    session.commit(new IntentSender(ReflectionHelpers.createNullProxy(IIntentSender.class)));
+  }
+
+  @Test(expected = SecurityException.class)
+  public void sessionCommitSession_streamStillOpen() throws Exception {
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    session.openWrite("filename", 0, 0);
+
+    session.commit(new IntentSender(ReflectionHelpers.createNullProxy(IIntentSender.class)));
+  }
+
+  @Test
+  public void registerSessionCallback_sessionFails() throws Exception {
+    PackageInstaller.SessionCallback mockCallback = mock(PackageInstaller.SessionCallback.class);
+    packageInstaller.registerSessionCallback(mockCallback, new Handler());
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    shadowMainLooper().idle();
+    verify(mockCallback).onCreated(sessionId);
+
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    OutputStream outputStream = session.openWrite("filename", 0, 0);
+    outputStream.close();
+
+    session.abandon();
+    shadowMainLooper().idle();
+
+    assertThat(packageInstaller.getAllSessions()).isEmpty();
+
+    verify(mockCallback).onFinished(sessionId, false);
+  }
+
+  @Test
+  public void registerSessionCallback_sessionSucceeds() throws Exception {
+    PackageInstaller.SessionCallback mockCallback = mock(PackageInstaller.SessionCallback.class);
+    packageInstaller.registerSessionCallback(mockCallback, new Handler());
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    shadowMainLooper().idle();
+    verify(mockCallback).onCreated(sessionId);
+
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    OutputStream outputStream = session.openWrite("filename", 0, 0);
+    outputStream.close();
+
+    session.commit(new IntentSender(ReflectionHelpers.createNullProxy(IIntentSender.class)));
+
+    shadowOf(packageInstaller).setSessionProgress(sessionId, 50.0f);
+    shadowMainLooper().idle();
+    verify(mockCallback).onProgressChanged(sessionId, 50.0f);
+
+    verify(mockCallback).onFinished(sessionId, true);
+  }
+
+  @Test
+  public void sessionActiveStateChanged_receivingOnActiveChangedCallback() throws Exception {
+    PackageInstaller.SessionCallback mockCallback = mock(PackageInstaller.SessionCallback.class);
+    packageInstaller.registerSessionCallback(mockCallback);
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    shadowMainLooper().idle();
+
+    shadowOf(packageInstaller).setSessionActiveState(sessionId, false);
+    shadowMainLooper().idle();
+
+    verify(mockCallback).onActiveChanged(sessionId, false);
+  }
+
+  @Test
+  public void unregisterSessionCallback() throws Exception {
+    PackageInstaller.SessionCallback mockCallback = mock(PackageInstaller.SessionCallback.class);
+    packageInstaller.registerSessionCallback(mockCallback, new Handler());
+    packageInstaller.unregisterSessionCallback(mockCallback);
+
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+    shadowMainLooper().idle();
+    verify(mockCallback, never()).onCreated(sessionId);
+  }
+
+  private static PackageInstaller.SessionParams createSessionParams(String appPackageName) {
+    PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
+    params.setAppPackageName(appPackageName);
+    return params;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPackageManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPackageManagerTest.java
new file mode 100644
index 0000000..870bb1d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPackageManagerTest.java
@@ -0,0 +1,4226 @@
+package org.robolectric.shadows;
+
+import static android.Manifest.permission.READ_CONTACTS;
+import static android.Manifest.permission.READ_SMS;
+import static android.Manifest.permission.SUSPEND_APPS;
+import static android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP;
+import static android.content.pm.ApplicationInfo.FLAG_ALLOW_CLEAR_USER_DATA;
+import static android.content.pm.ApplicationInfo.FLAG_ALLOW_TASK_REPARENTING;
+import static android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE;
+import static android.content.pm.ApplicationInfo.FLAG_HAS_CODE;
+import static android.content.pm.ApplicationInfo.FLAG_RESIZEABLE_FOR_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_LARGE_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_NORMAL_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_SCREEN_DENSITIES;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_SMALL_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_VM_SAFE_MODE;
+import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED;
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+import static android.content.pm.PackageManager.FLAG_PERMISSION_GRANTED_BY_DEFAULT;
+import static android.content.pm.PackageManager.FLAG_PERMISSION_SYSTEM_FIXED;
+import static android.content.pm.PackageManager.FLAG_PERMISSION_USER_FIXED;
+import static android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS;
+import static android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.content.pm.PackageManager.SIGNATURE_FIRST_NOT_SIGNED;
+import static android.content.pm.PackageManager.SIGNATURE_MATCH;
+import static android.content.pm.PackageManager.SIGNATURE_NEITHER_SIGNED;
+import static android.content.pm.PackageManager.SIGNATURE_NO_MATCH;
+import static android.content.pm.PackageManager.SIGNATURE_SECOND_NOT_SIGNED;
+import static android.content.pm.PackageManager.SIGNATURE_UNKNOWN_PACKAGE;
+import static android.content.pm.PackageManager.VERIFICATION_ALLOW;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Robolectric.setupActivity;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.Manifest;
+import android.Manifest.permission_group;
+import android.app.Activity;
+import android.app.Application;
+import android.app.admin.DevicePolicyManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.ChangedPackages;
+import android.content.pm.FeatureInfo;
+import android.content.pm.IPackageDeleteObserver;
+import android.content.pm.IPackageStatsObserver;
+import android.content.pm.InstallSourceInfo;
+import android.content.pm.ModuleInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageInstaller;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.PackageManager.OnPermissionsChangedListener;
+import android.content.pm.PackageManager.PackageInfoFlags;
+import android.content.pm.PackageManager.ResolveInfoFlags;
+import android.content.pm.PackageParser.Package;
+import android.content.pm.PackageParser.PermissionGroup;
+import android.content.pm.PackageStats;
+import android.content.pm.PathPermission;
+import android.content.pm.PermissionGroupInfo;
+import android.content.pm.PermissionInfo;
+import android.content.pm.ProviderInfo;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.content.pm.Signature;
+import android.content.pm.SuspendDialogInfo;
+import android.content.res.XmlResourceParser;
+import android.graphics.Color;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.provider.DocumentsContract;
+import android.telecom.TelecomManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.core.content.pm.ApplicationInfoBuilder;
+import androidx.test.core.content.pm.PackageInfoBuilder;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import javax.annotation.Nullable;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.R;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.GetInstallerPackageNameMode;
+import org.robolectric.annotation.GetInstallerPackageNameMode.Mode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowPackageManager.PackageSetting;
+import org.robolectric.shadows.ShadowPackageManager.ResolveInfoComparator;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.TestUtil;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPackageManagerTest {
+
+  private static final String TEST_PACKAGE_NAME = "com.some.other.package";
+  private static final String TEST_PACKAGE_LABEL = "My Little App";
+  private static final String TEST_APP_PATH = "/values/app/application.apk";
+  private static final String TEST_PACKAGE2_NAME = "com.a.second.package";
+  private static final String TEST_PACKAGE2_LABEL = "A Second App";
+  private static final String TEST_APP2_PATH = "/values/app/application2.apk";
+  private static final Object USER_ID = 1;
+  private static final String REAL_TEST_APP_ASSET_PATH = "assets/exampleapp.apk";
+  private static final String REAL_TEST_APP_PACKAGE_NAME = "org.robolectric.exampleapp";
+  private static final String TEST_PACKAGE3_NAME = "com.a.third.package";
+  private static final int TEST_PACKAGE_VERSION_CODE = 10000;
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+  private Context context;
+  private PackageManager packageManager;
+
+  private final ArgumentCaptor<PackageStats> packageStatsCaptor =
+      ArgumentCaptor.forClass(PackageStats.class);
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    packageManager = context.getPackageManager();
+  }
+
+  @After
+  public void tearDown() {
+    ShadowPackageManager.reset();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void packageInstallerCreateSession() throws Exception {
+    PackageInstaller packageInstaller = context.getPackageManager().getPackageInstaller();
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+
+    PackageInstaller.SessionInfo sessionInfo = packageInstaller.getSessionInfo(sessionId);
+    assertThat(sessionInfo.isActive()).isTrue();
+
+    assertThat(sessionInfo.appPackageName).isEqualTo("packageName");
+
+    packageInstaller.abandonSession(sessionId);
+
+    assertThat(packageInstaller.getSessionInfo(sessionId)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void packageInstallerOpenSession() throws Exception {
+    PackageInstaller packageInstaller = context.getPackageManager().getPackageInstaller();
+    int sessionId = packageInstaller.createSession(createSessionParams("packageName"));
+
+    PackageInstaller.Session session = packageInstaller.openSession(sessionId);
+
+    assertThat(session).isNotNull();
+  }
+
+  private static PackageInstaller.SessionParams createSessionParams(String appPackageName) {
+    PackageInstaller.SessionParams params =
+        new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
+    params.setAppPackageName(appPackageName);
+    return params;
+  }
+
+  @Test
+  public void packageInstallerAndGetPackageArchiveInfo() {
+    shadowOf(packageManager).installPackage(generateTestPackageInfo());
+    verifyTestPackageInfo(packageManager.getPackageArchiveInfo(TEST_APP_PATH, 0));
+  }
+
+  @Test
+  public void packageInstallerAndGetPackageInfo() throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(generateTestPackageInfo());
+    verifyTestPackageInfo(packageManager.getPackageInfo(TEST_PACKAGE_NAME, 0));
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void packageInstallerAndGetPackageInfo_T() throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(generateTestPackageInfo());
+    verifyTestPackageInfo(packageManager.getPackageInfo(TEST_PACKAGE_NAME, PackageInfoFlags.of(0)));
+  }
+
+  @Test
+  public void applicationFlags() throws Exception {
+    int flags = packageManager.getApplicationInfo("org.robolectric", 0).flags;
+    assertThat((flags & FLAG_ALLOW_BACKUP)).isEqualTo(FLAG_ALLOW_BACKUP);
+    assertThat((flags & FLAG_ALLOW_CLEAR_USER_DATA)).isEqualTo(FLAG_ALLOW_CLEAR_USER_DATA);
+    assertThat((flags & FLAG_ALLOW_TASK_REPARENTING)).isEqualTo(FLAG_ALLOW_TASK_REPARENTING);
+    assertThat((flags & FLAG_DEBUGGABLE)).isEqualTo(FLAG_DEBUGGABLE);
+    assertThat((flags & FLAG_HAS_CODE)).isEqualTo(FLAG_HAS_CODE);
+    assertThat((flags & FLAG_RESIZEABLE_FOR_SCREENS)).isEqualTo(FLAG_RESIZEABLE_FOR_SCREENS);
+    assertThat((flags & FLAG_SUPPORTS_LARGE_SCREENS)).isEqualTo(FLAG_SUPPORTS_LARGE_SCREENS);
+    assertThat((flags & FLAG_SUPPORTS_NORMAL_SCREENS)).isEqualTo(FLAG_SUPPORTS_NORMAL_SCREENS);
+    assertThat((flags & FLAG_SUPPORTS_SCREEN_DENSITIES)).isEqualTo(FLAG_SUPPORTS_SCREEN_DENSITIES);
+    assertThat((flags & FLAG_SUPPORTS_SMALL_SCREENS)).isEqualTo(FLAG_SUPPORTS_SMALL_SCREENS);
+    assertThat((flags & FLAG_VM_SAFE_MODE)).isEqualTo(FLAG_VM_SAFE_MODE);
+  }
+
+  /**
+   * Tests the permission grants of this test package.
+   *
+   * <p>These grants are defined in the test package's AndroidManifest.xml.
+   */
+  @Test
+  public void testCheckPermission_thisPackage() {
+    String thisPackage = context.getPackageName();
+    assertEquals(
+        PERMISSION_GRANTED,
+        packageManager.checkPermission("android.permission.INTERNET", thisPackage));
+    assertEquals(
+        PERMISSION_GRANTED,
+        packageManager.checkPermission("android.permission.SYSTEM_ALERT_WINDOW", thisPackage));
+    assertEquals(
+        PERMISSION_GRANTED,
+        packageManager.checkPermission("android.permission.GET_TASKS", thisPackage));
+
+    assertEquals(
+        PERMISSION_DENIED,
+        packageManager.checkPermission("android.permission.ACCESS_FINE_LOCATION", thisPackage));
+    assertEquals(
+        PERMISSION_DENIED,
+        packageManager.checkPermission(
+            "android.permission.ACCESS_FINE_LOCATION", "random-package"));
+  }
+
+  /**
+   * Tests the permission grants of other packages. These packages are added to the PackageManager
+   * by calling {@link ShadowPackageManager#addPackage}.
+   */
+  @Test
+  public void testCheckPermission_otherPackages() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.requestedPermissions =
+        new String[] {"android.permission.INTERNET", "android.permission.SEND_SMS"};
+    // Grant one of the permissions.
+    packageInfo.requestedPermissionsFlags =
+        new int[] {REQUESTED_PERMISSION_GRANTED, 0 /* this permission isn't granted */};
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    assertEquals(
+        PERMISSION_GRANTED,
+        packageManager.checkPermission("android.permission.INTERNET", TEST_PACKAGE_NAME));
+    assertEquals(
+        PERMISSION_DENIED,
+        packageManager.checkPermission("android.permission.SEND_SMS", TEST_PACKAGE_NAME));
+    assertEquals(
+        PERMISSION_DENIED,
+        packageManager.checkPermission("android.permission.READ_SMS", TEST_PACKAGE_NAME));
+  }
+
+  /**
+   * Tests the permission grants of other packages. These packages are added to the PackageManager
+   * by calling {@link ShadowPackageManager#addPackage}.
+   */
+  @Test
+  public void testCheckPermission_otherPackages_grantedByDefault() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.requestedPermissions =
+        new String[] {"android.permission.INTERNET", "android.permission.SEND_SMS"};
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    // Because we didn't specify permission grant state in the PackageInfo object, all requested
+    // permissions are automatically granted. See ShadowPackageManager.grantPermissionsByDefault()
+    // for the explanation.
+    assertEquals(
+        PERMISSION_GRANTED,
+        packageManager.checkPermission("android.permission.INTERNET", TEST_PACKAGE_NAME));
+    assertEquals(
+        PERMISSION_GRANTED,
+        packageManager.checkPermission("android.permission.SEND_SMS", TEST_PACKAGE_NAME));
+    assertEquals(
+        PERMISSION_DENIED,
+        packageManager.checkPermission("android.permission.READ_SMS", TEST_PACKAGE_NAME));
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testGrantRuntimePermission() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.requestedPermissions =
+        new String[] {"android.permission.SEND_SMS", "android.permission.READ_SMS"};
+    packageInfo.requestedPermissionsFlags = new int[] {0, 0}; // Not granted by default
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.uid = 12345;
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    OnPermissionsChangedListener listener = mock(OnPermissionsChangedListener.class);
+    packageManager.addOnPermissionsChangeListener(listener);
+
+    packageManager.grantRuntimePermission(
+        TEST_PACKAGE_NAME, "android.permission.SEND_SMS", Process.myUserHandle());
+
+    verify(listener, times(1)).onPermissionsChanged(12345);
+    assertThat(packageInfo.requestedPermissionsFlags[0]).isEqualTo(REQUESTED_PERMISSION_GRANTED);
+    assertThat(packageInfo.requestedPermissionsFlags[1]).isEqualTo(0);
+
+    packageManager.grantRuntimePermission(
+        TEST_PACKAGE_NAME, "android.permission.READ_SMS", Process.myUserHandle());
+
+    verify(listener, times(2)).onPermissionsChanged(12345);
+    assertThat(packageInfo.requestedPermissionsFlags[0]).isEqualTo(REQUESTED_PERMISSION_GRANTED);
+    assertThat(packageInfo.requestedPermissionsFlags[1]).isEqualTo(REQUESTED_PERMISSION_GRANTED);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testGrantRuntimePermission_packageNotFound() {
+    try {
+      packageManager.grantRuntimePermission(
+          "com.unknown.package", "android.permission.SEND_SMS", Process.myUserHandle());
+      fail("Exception expected");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testGrantRuntimePermission_doesntRequestPermission() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.requestedPermissions =
+        new String[] {"android.permission.SEND_SMS", "android.permission.READ_SMS"};
+    packageInfo.requestedPermissionsFlags = new int[] {0, 0}; // Not granted by default
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    try {
+      packageManager.grantRuntimePermission(
+          // This permission is not granted to the package.
+          TEST_PACKAGE_NAME, "android.permission.RECEIVE_SMS", Process.myUserHandle());
+      fail("Exception expected");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testRevokeRuntimePermission() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.requestedPermissions =
+        new String[] {"android.permission.SEND_SMS", "android.permission.READ_SMS"};
+    packageInfo.requestedPermissionsFlags =
+        new int[] {REQUESTED_PERMISSION_GRANTED, REQUESTED_PERMISSION_GRANTED};
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.uid = 12345;
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    OnPermissionsChangedListener listener = mock(OnPermissionsChangedListener.class);
+    packageManager.addOnPermissionsChangeListener(listener);
+
+    packageManager.revokeRuntimePermission(
+        TEST_PACKAGE_NAME, "android.permission.SEND_SMS", Process.myUserHandle());
+
+    verify(listener, times(1)).onPermissionsChanged(12345);
+    assertThat(packageInfo.requestedPermissionsFlags[0]).isEqualTo(0);
+    assertThat(packageInfo.requestedPermissionsFlags[1]).isEqualTo(REQUESTED_PERMISSION_GRANTED);
+
+    packageManager.revokeRuntimePermission(
+        TEST_PACKAGE_NAME, "android.permission.READ_SMS", Process.myUserHandle());
+
+    verify(listener, times(2)).onPermissionsChanged(12345);
+    assertThat(packageInfo.requestedPermissionsFlags[0]).isEqualTo(0);
+    assertThat(packageInfo.requestedPermissionsFlags[1]).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPermissionFlags_whenNoPackagePermissionFlagsProvided_returnsZero() {
+    // Don't add any permission flags
+    int flags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+
+    assertThat(flags).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPermissionFlags_whenPackagePermissionFlagsProvided_returnsPermissionFlags() {
+    // Add the SYSTEM_FIXED permission flag
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        Process.myUserHandle());
+
+    int flags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+
+    assertThat(flags).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPermissionFlags_whenPackagePermissionFlagsProvidedForDiffPermission_returnsZero() {
+    // Add the SYSTEM_FIXED permission flag to the READ_SMS permission
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        Process.myUserHandle());
+
+    int flags =
+        packageManager.getPermissionFlags(READ_CONTACTS, TEST_PACKAGE_NAME, Process.myUserHandle());
+
+    assertThat(flags).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getPermissionFlags_whenPermissionFlagsProvidedForDifferentPackage_returnsZero() {
+    // Add the SYSTEM_FIXED permission flag to the READ_SMS permission for TEST_PACKAGE_NAME
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        Process.myUserHandle());
+
+    int flags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE2_NAME, Process.myUserHandle());
+
+    assertThat(flags).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void updatePermissionFlags_whenNoFlagMaskProvided_doesNotUpdateFlags() {
+    // Check that we have no permission flags set beforehand
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags).isEqualTo(0);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        /* flagMask= */ 0,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(newFlags).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void updatePermissionFlags_whenPackageHasOnePermissionFlagTurnedOn_updatesFlagToBeOn() {
+    // Check that we have no permission flags set beforehand
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags).isEqualTo(0);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(newFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void updatePermissionFlags_whenPackageHasOnePermissionFlagTurnedOff_updatesFlagToBeOff() {
+    // Check that we have one permission flag set beforehand
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        Process.myUserHandle());
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        /* flagValues= */ 0,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(newFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void
+      updatePermissionFlags_whenPackageHasMultiplePermissionFlagsTurnedOn_updatesFlagsToBeOn() {
+    // Check that we have no permission flags set beforehand
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags).isEqualTo(0);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(newFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+    assertThat(newFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void
+      updatePermissionFlags_whenPackageHasMultiplePermissionFlagsTurnedOff_updatesFlagsToBeOff() {
+    // Check that we have one permission flag set beforehand
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        Process.myUserHandle());
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+    assertThat(oldFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        /* flagValues= */ 0,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(newFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(0);
+    assertThat(newFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void
+      updatePermissionFlags_whenPackageHasMultiplePermissionFlagsTurnedOn_turnOneFlagOff_onlyAffectsOneFlag() {
+    // Check that we have one permission flag set beforehand
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        Process.myUserHandle());
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+    assertThat(oldFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED,
+        /* flagValues= */ 0,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(newFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(0);
+    // The GRANTED_BY_DEFAULT flag should be untouched
+    assertThat(newFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void
+      updatePermissionFlags_whenPackageHasMultiplePermissionFlagsTurnedOn_turnDiffFlagOn_doesNotAffectOtherFlags() {
+    // Check that we have one permission flag set beforehand
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        Process.myUserHandle());
+    int oldFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+    assertThat(oldFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_USER_FIXED,
+        FLAG_PERMISSION_USER_FIXED,
+        Process.myUserHandle());
+
+    int newFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    // The SYSTEM_FIXED and GRANTED_BY_DEFAULT flags should not be affected
+    assertThat(newFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+    assertThat(newFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+    assertThat(newFlags & FLAG_PERMISSION_USER_FIXED).isEqualTo(FLAG_PERMISSION_USER_FIXED);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void updatePermissionFlags_forDifferentPermission_doesNotAffectOriginalPermissionFlags() {
+    // Check that we have one permission flag set beforehand
+    packageManager.updatePermissionFlags(
+        READ_SMS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT,
+        Process.myUserHandle());
+    int oldSmsFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(oldSmsFlags & FLAG_PERMISSION_SYSTEM_FIXED).isEqualTo(FLAG_PERMISSION_SYSTEM_FIXED);
+    assertThat(oldSmsFlags & FLAG_PERMISSION_GRANTED_BY_DEFAULT)
+        .isEqualTo(FLAG_PERMISSION_GRANTED_BY_DEFAULT);
+
+    packageManager.updatePermissionFlags(
+        READ_CONTACTS,
+        TEST_PACKAGE_NAME,
+        FLAG_PERMISSION_USER_FIXED,
+        FLAG_PERMISSION_USER_FIXED,
+        Process.myUserHandle());
+
+    int newSmsFlags =
+        packageManager.getPermissionFlags(READ_SMS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    // Check we haven't changed the permission flags of the READ_SMS permission
+    assertThat(oldSmsFlags).isEqualTo(newSmsFlags);
+    int contactsFlags =
+        packageManager.getPermissionFlags(READ_CONTACTS, TEST_PACKAGE_NAME, Process.myUserHandle());
+    assertThat(contactsFlags & FLAG_PERMISSION_USER_FIXED).isEqualTo(FLAG_PERMISSION_USER_FIXED);
+  }
+
+  @Test
+  public void testQueryBroadcastReceiverSucceeds() {
+    Intent intent = new Intent("org.robolectric.ACTION_RECEIVER_PERMISSION_PACKAGE");
+    intent.setPackage(context.getPackageName());
+
+    List<ResolveInfo> receiverInfos =
+        packageManager.queryBroadcastReceivers(intent, PackageManager.GET_RESOLVED_FILTER);
+    assertThat(receiverInfos).isNotEmpty();
+    assertThat(receiverInfos.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.ConfigTestReceiverPermissionsAndActions");
+    assertThat(receiverInfos.get(0).activityInfo.permission)
+        .isEqualTo("org.robolectric.CUSTOM_PERM");
+    assertThat(receiverInfos.get(0).filter.getAction(0))
+        .isEqualTo("org.robolectric.ACTION_RECEIVER_PERMISSION_PACKAGE");
+  }
+
+  @Test
+  public void testQueryBroadcastReceiverFailsForMissingPackageName() {
+    Intent intent = new Intent("org.robolectric.ACTION_ONE_MORE_PACKAGE");
+    List<ResolveInfo> receiverInfos =
+        packageManager.queryBroadcastReceivers(intent, PackageManager.GET_RESOLVED_FILTER);
+    assertThat(receiverInfos).isEmpty();
+  }
+
+  @Test
+  public void testQueryBroadcastReceiver_matchAllWithoutIntentFilter() {
+    Intent intent = new Intent();
+    intent.setPackage(context.getPackageName());
+    List<ResolveInfo> receiverInfos =
+        packageManager.queryBroadcastReceivers(intent, PackageManager.GET_INTENT_FILTERS);
+    assertThat(receiverInfos).hasSize(7);
+
+    for (ResolveInfo receiverInfo : receiverInfos) {
+      assertThat(receiverInfo.activityInfo.name)
+          .isNotEqualTo("com.bar.ReceiverWithoutIntentFilter");
+    }
+  }
+
+  @Test
+  public void testGetPackageInfo_ForReceiversSucceeds() throws Exception {
+    PackageInfo receiverInfos =
+        packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_RECEIVERS);
+
+    assertThat(receiverInfos.receivers).isNotEmpty();
+    assertThat(receiverInfos.receivers[0].name)
+        .isEqualTo("org.robolectric.ConfigTestReceiver.InnerReceiver");
+    assertThat(receiverInfos.receivers[0].permission).isEqualTo("com.ignored.PERM");
+  }
+
+  private static class ActivityWithConfigChanges extends Activity {}
+
+  @Test
+  public void getActivityMetaData_configChanges() throws Exception {
+    Activity activity = setupActivity(ShadowPackageManagerTest.ActivityWithConfigChanges.class);
+
+    ActivityInfo activityInfo =
+        activity.getPackageManager().getActivityInfo(activity.getComponentName(), 0);
+
+    int configChanges = activityInfo.configChanges;
+    assertThat(configChanges & ActivityInfo.CONFIG_SCREEN_LAYOUT)
+        .isEqualTo(ActivityInfo.CONFIG_SCREEN_LAYOUT);
+    assertThat(configChanges & ActivityInfo.CONFIG_ORIENTATION)
+        .isEqualTo(ActivityInfo.CONFIG_ORIENTATION);
+
+    // Spot check a few other possible values that shouldn't be in the flags.
+    assertThat(configChanges & ActivityInfo.CONFIG_FONT_SCALE).isEqualTo(0);
+    assertThat(configChanges & ActivityInfo.CONFIG_SCREEN_SIZE).isEqualTo(0);
+  }
+
+  /** MCC + MNC are always present in config changes since Oreo. */
+  @Test
+  @Config(minSdk = O)
+  public void getActivityMetaData_configChangesAlwaysIncludesMccAndMnc() throws Exception {
+    Activity activity = setupActivity(ShadowPackageManagerTest.ActivityWithConfigChanges.class);
+
+    ActivityInfo activityInfo =
+        activity.getPackageManager().getActivityInfo(activity.getComponentName(), 0);
+
+    int configChanges = activityInfo.configChanges;
+    assertThat(configChanges & ActivityInfo.CONFIG_MCC).isEqualTo(ActivityInfo.CONFIG_MCC);
+    assertThat(configChanges & ActivityInfo.CONFIG_MNC).isEqualTo(ActivityInfo.CONFIG_MNC);
+  }
+
+  @Test
+  public void getPermissionInfo_withMinimalFields() throws Exception {
+    PermissionInfo permission =
+        packageManager.getPermissionInfo("org.robolectric.permission_with_minimal_fields", 0);
+    assertThat(permission.labelRes).isEqualTo(0);
+    assertThat(permission.descriptionRes).isEqualTo(0);
+    assertThat(permission.protectionLevel).isEqualTo(PermissionInfo.PROTECTION_NORMAL);
+  }
+
+  @Test
+  public void getPermissionInfo_addedPermissions() throws Exception {
+    PermissionInfo permissionInfo = new PermissionInfo();
+    permissionInfo.name = "manually_added_permission";
+    shadowOf(packageManager).addPermissionInfo(permissionInfo);
+    PermissionInfo permission = packageManager.getPermissionInfo("manually_added_permission", 0);
+    assertThat(permission.name).isEqualTo("manually_added_permission");
+  }
+
+  @Test
+  public void getPermissionGroupInfo_fromManifest() throws Exception {
+    PermissionGroupInfo permissionGroupInfo =
+        context
+            .getPackageManager()
+            .getPermissionGroupInfo("org.robolectric.package_permission_group", 0);
+    assertThat(permissionGroupInfo.name).isEqualTo("org.robolectric.package_permission_group");
+  }
+
+  @Test
+  public void getPermissionGroupInfo_extraPermissionGroup() throws Exception {
+    PermissionGroupInfo newCameraPermission = new PermissionGroupInfo();
+    newCameraPermission.name = permission_group.CAMERA;
+    shadowOf(packageManager).addPermissionGroupInfo(newCameraPermission);
+
+    assertThat(packageManager.getPermissionGroupInfo(permission_group.CAMERA, 0).name)
+        .isEqualTo(newCameraPermission.name);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getGroupOfPlatformPermission_fromManifest() throws Exception {
+    String permissionName = "org.robolectric.some_permission";
+    CountDownLatch waitForCallback = new CountDownLatch(1);
+    ShadowApplicationPackageManager pm = (ShadowApplicationPackageManager) shadowOf(packageManager);
+    String[] permissionGroupArg = new String[1];
+    pm.getGroupOfPlatformPermission(
+        permissionName,
+        Executors.newSingleThreadExecutor(),
+        (group) -> {
+          permissionGroupArg[0] = group;
+          waitForCallback.countDown();
+        });
+    assertThat(waitForCallback.await(2, SECONDS)).isTrue();
+    assertThat(permissionGroupArg[0]).isEqualTo("my_permission_group");
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getGroupOfPlatformPermission_unknown() throws Exception {
+    String permissionName = "unknown_permission";
+    CountDownLatch waitForCallback = new CountDownLatch(1);
+    ShadowApplicationPackageManager pm = (ShadowApplicationPackageManager) shadowOf(packageManager);
+    String[] permissionGroupArg = new String[1];
+    pm.getGroupOfPlatformPermission(
+        permissionName,
+        Executors.newSingleThreadExecutor(),
+        (group) -> {
+          permissionGroupArg[0] = group;
+          waitForCallback.countDown();
+        });
+    assertThat(waitForCallback.await(2, SECONDS)).isTrue();
+    assertThat(permissionGroupArg[0]).isNull();
+  }
+
+  @Test
+  public void getAllPermissionGroups_fromManifest() {
+    List<PermissionGroupInfo> allPermissionGroups = packageManager.getAllPermissionGroups(0);
+    assertThat(allPermissionGroups).hasSize(1);
+    assertThat(allPermissionGroups.get(0).name)
+        .isEqualTo("org.robolectric.package_permission_group");
+  }
+
+  @Test
+  public void getAllPermissionGroups_duplicateInExtraPermissions() {
+    assertThat(packageManager.getAllPermissionGroups(0)).hasSize(1);
+
+    PermissionGroupInfo overriddenPermission = new PermissionGroupInfo();
+    overriddenPermission.name = "org.robolectric.package_permission_group";
+    shadowOf(packageManager).addPermissionGroupInfo(overriddenPermission);
+    PermissionGroupInfo newCameraPermission = new PermissionGroupInfo();
+    newCameraPermission.name = permission_group.CAMERA;
+    shadowOf(packageManager).addPermissionGroupInfo(newCameraPermission);
+
+    List<PermissionGroupInfo> allPermissionGroups = packageManager.getAllPermissionGroups(0);
+    assertThat(allPermissionGroups).hasSize(2);
+  }
+
+  @Test
+  public void getAllPermissionGroups_duplicatePermission() {
+    assertThat(packageManager.getAllPermissionGroups(0)).hasSize(1);
+
+    // Package 1
+    Package pkg = new Package(TEST_PACKAGE_NAME);
+    ApplicationInfo appInfo = pkg.applicationInfo;
+    appInfo.flags = ApplicationInfo.FLAG_INSTALLED;
+    appInfo.packageName = TEST_PACKAGE_NAME;
+    appInfo.sourceDir = TEST_APP_PATH;
+    appInfo.name = TEST_PACKAGE_LABEL;
+    PermissionGroupInfo contactsPermissionGroupInfoApp1 = new PermissionGroupInfo();
+    contactsPermissionGroupInfoApp1.name = Manifest.permission_group.CONTACTS;
+    PermissionGroup contactsPermissionGroupApp1 =
+        new PermissionGroup(pkg, contactsPermissionGroupInfoApp1);
+    pkg.permissionGroups.add(contactsPermissionGroupApp1);
+    PermissionGroupInfo storagePermissionGroupInfoApp1 = new PermissionGroupInfo();
+    storagePermissionGroupInfoApp1.name = permission_group.STORAGE;
+    PermissionGroup storagePermissionGroupApp1 =
+        new PermissionGroup(pkg, storagePermissionGroupInfoApp1);
+    pkg.permissionGroups.add(storagePermissionGroupApp1);
+
+    shadowOf(packageManager).addPackageInternal(pkg);
+
+    // Package 2, contains one permission group that is the same
+    Package pkg2 = new Package(TEST_PACKAGE2_NAME);
+    ApplicationInfo appInfo2 = pkg2.applicationInfo;
+    appInfo2.flags = ApplicationInfo.FLAG_INSTALLED;
+    appInfo2.packageName = TEST_PACKAGE2_NAME;
+    appInfo2.sourceDir = TEST_APP2_PATH;
+    appInfo2.name = TEST_PACKAGE2_LABEL;
+    PermissionGroupInfo contactsPermissionGroupInfoApp2 = new PermissionGroupInfo();
+    contactsPermissionGroupInfoApp2.name = Manifest.permission_group.CONTACTS;
+    PermissionGroup contactsPermissionGroupApp2 =
+        new PermissionGroup(pkg2, contactsPermissionGroupInfoApp2);
+    pkg2.permissionGroups.add(contactsPermissionGroupApp2);
+    PermissionGroupInfo calendarPermissionGroupInfoApp2 = new PermissionGroupInfo();
+    calendarPermissionGroupInfoApp2.name = permission_group.CALENDAR;
+    PermissionGroup calendarPermissionGroupApp2 =
+        new PermissionGroup(pkg2, calendarPermissionGroupInfoApp2);
+    pkg2.permissionGroups.add(calendarPermissionGroupApp2);
+
+    shadowOf(packageManager).addPackageInternal(pkg2);
+
+    // Make sure that the duplicate permission group does not show up in the list
+    // Total list should be: contacts, storage, calendar, "org.robolectric.package_permission_group"
+    List<PermissionGroupInfo> allPermissionGroups = packageManager.getAllPermissionGroups(0);
+    assertThat(allPermissionGroups).hasSize(4);
+  }
+
+  @Test
+  public void getPackageArchiveInfo() {
+    ApplicationInfo appInfo = new ApplicationInfo();
+    appInfo.flags = ApplicationInfo.FLAG_INSTALLED;
+    appInfo.packageName = TEST_PACKAGE_NAME;
+    appInfo.sourceDir = TEST_APP_PATH;
+    appInfo.name = TEST_PACKAGE_LABEL;
+
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo = appInfo;
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    PackageInfo packageInfoResult = packageManager.getPackageArchiveInfo(TEST_APP_PATH, 0);
+    assertThat(packageInfoResult).isNotNull();
+    ApplicationInfo applicationInfo = packageInfoResult.applicationInfo;
+    assertThat(applicationInfo).isInstanceOf(ApplicationInfo.class);
+    assertThat(applicationInfo.packageName).isEqualTo(TEST_PACKAGE_NAME);
+    assertThat(applicationInfo.sourceDir).isEqualTo(TEST_APP_PATH);
+  }
+
+  @Test
+  public void getPackageArchiveInfo_ApkNotInstalled() {
+    File testApk = TestUtil.resourcesBaseDir().resolve(REAL_TEST_APP_ASSET_PATH).toFile();
+
+    PackageInfo packageInfo = packageManager.getPackageArchiveInfo(testApk.getAbsolutePath(), 0);
+
+    String resourcesMode = System.getProperty("robolectric.resourcesMode");
+    if (resourcesMode != null && resourcesMode.equals("legacy")) {
+      assertThat(packageInfo).isNull();
+    } else {
+      assertThat(packageInfo).isNotNull();
+      ApplicationInfo applicationInfo = packageInfo.applicationInfo;
+      assertThat(applicationInfo.packageName).isEqualTo(REAL_TEST_APP_PACKAGE_NAME);
+
+      // double-check that Robolectric doesn't consider this package to be installed
+      try {
+        packageManager.getPackageInfo(packageInfo.packageName, 0);
+        Assert.fail("Package not expected to be installed.");
+      } catch (NameNotFoundException e) {
+        // expected exception
+      }
+    }
+  }
+
+  @Test
+  public void getApplicationInfo_ThisApplication() throws Exception {
+    ApplicationInfo info = packageManager.getApplicationInfo(context.getPackageName(), 0);
+    assertThat(info).isNotNull();
+    assertThat(info.packageName).isEqualTo(context.getPackageName());
+    assertThat(info.processName).isEqualTo(info.packageName);
+  }
+
+  @Test
+  public void getApplicationInfo_uninstalledApplication_includeUninstalled() throws Exception {
+    shadowOf(packageManager).deletePackage(context.getPackageName());
+
+    ApplicationInfo info =
+        packageManager.getApplicationInfo(context.getPackageName(), MATCH_UNINSTALLED_PACKAGES);
+    assertThat(info).isNotNull();
+    assertThat(info.packageName).isEqualTo(context.getPackageName());
+  }
+
+  @Test
+  public void getApplicationInfo_uninstalledApplication_dontIncludeUninstalled() {
+    shadowOf(packageManager).deletePackage(context.getPackageName());
+
+    try {
+      packageManager.getApplicationInfo(context.getPackageName(), 0);
+      fail("PackageManager.NameNotFoundException not thrown");
+    } catch (PackageManager.NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test(expected = PackageManager.NameNotFoundException.class)
+  public void getApplicationInfo_whenUnknown_shouldThrowNameNotFoundException() throws Exception {
+    try {
+      packageManager.getApplicationInfo("unknown_package", 0);
+      fail("should have thrown NameNotFoundException");
+    } catch (PackageManager.NameNotFoundException e) {
+      assertThat(e.getMessage()).contains("unknown_package");
+      throw e;
+    }
+  }
+
+  @Test
+  public void getApplicationInfo_OtherApplication() throws Exception {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo.name = TEST_PACKAGE_LABEL;
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    ApplicationInfo info = packageManager.getApplicationInfo(TEST_PACKAGE_NAME, 0);
+    assertThat(info).isNotNull();
+    assertThat(info.packageName).isEqualTo(TEST_PACKAGE_NAME);
+    assertThat(packageManager.getApplicationLabel(info).toString()).isEqualTo(TEST_PACKAGE_LABEL);
+  }
+
+  @Test
+  public void getApplicationInfo_readsValuesFromSetPackageArchiveInfo() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "some.package.name";
+    String archiveFilePath = "some/file/path";
+    shadowOf(packageManager).setPackageArchiveInfo(archiveFilePath, packageInfo);
+
+    assertThat(packageManager.getPackageArchiveInfo(archiveFilePath, /* flags= */ 0))
+        .isEqualTo(packageInfo);
+  }
+
+  @Test
+  public void removePackage_shouldHideItFromGetApplicationInfo() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo.name = TEST_PACKAGE_LABEL;
+    shadowOf(packageManager).installPackage(packageInfo);
+    shadowOf(packageManager).removePackage(TEST_PACKAGE_NAME);
+
+    try {
+      packageManager.getApplicationInfo(TEST_PACKAGE_NAME, 0);
+      fail("NameNotFoundException not thrown");
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void queryIntentActivities_EmptyResult() {
+    Intent i = new Intent(Intent.ACTION_APP_ERROR, null);
+    i.addCategory(Intent.CATEGORY_APP_BROWSER);
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isEmpty();
+  }
+
+  @Test
+  public void queryIntentActivities_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+    info.activityInfo = new ActivityInfo();
+    info.activityInfo.name = "name";
+    info.activityInfo.packageName = TEST_PACKAGE_NAME;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(2);
+    assertThat(activities.get(0).nonLocalizedLabel.toString()).isEqualTo(TEST_PACKAGE_LABEL);
+  }
+
+  @Test
+  public void queryIntentActivities_ServiceMatch() {
+    Intent i = new Intent("SomeStrangeAction");
+
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+    info.serviceInfo = new ServiceInfo();
+    info.serviceInfo.name = "name";
+    info.serviceInfo.packageName = TEST_PACKAGE_NAME;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isNotNull();
+    assertThat(activities).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void queryIntentActivitiesAsUser_EmptyResult() {
+    Intent i = new Intent(Intent.ACTION_APP_ERROR, null);
+    i.addCategory(Intent.CATEGORY_APP_BROWSER);
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivitiesAsUser(i, 0, 0);
+    assertThat(activities).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void queryIntentActivitiesAsUser_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivitiesAsUser(i, 0, 0);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(2);
+    assertThat(activities.get(0).nonLocalizedLabel.toString()).isEqualTo(TEST_PACKAGE_LABEL);
+  }
+
+  @Test
+  public void queryIntentActivities_launcher() {
+    Intent intent = new Intent(Intent.ACTION_MAIN);
+    intent.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    List<ResolveInfo> resolveInfos =
+        packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL);
+    assertThat(resolveInfos).hasSize(1);
+
+    assertThat(resolveInfos.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.shadows.TestActivityAlias");
+    assertThat(resolveInfos.get(0).activityInfo.targetActivity)
+        .isEqualTo("org.robolectric.shadows.TestActivity");
+  }
+
+  @Test
+  public void queryIntentActivities_MatchSystemOnly() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    ResolveInfo info1 = ShadowResolveInfo.newResolveInfo(TEST_PACKAGE_LABEL, TEST_PACKAGE_NAME);
+    ResolveInfo info2 = ShadowResolveInfo.newResolveInfo("System App", "system.launcher");
+    info2.activityInfo.applicationInfo.flags |= ApplicationInfo.FLAG_SYSTEM;
+    info2.nonLocalizedLabel = "System App";
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info1);
+    shadowOf(packageManager).addResolveInfoForIntent(i, info2);
+
+    List<ResolveInfo> activities =
+        packageManager.queryIntentActivities(i, PackageManager.MATCH_SYSTEM_ONLY);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).nonLocalizedLabel.toString()).isEqualTo("System App");
+  }
+
+  @Test
+  public void queryIntentActivities_EmptyResultWithNoMatchingImplicitIntents() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+    i.setDataAndType(Uri.parse("content://testhost1.com:1/testPath/test.jpeg"), "image/jpeg");
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isEmpty();
+  }
+
+  @Test
+  public void queryIntentActivities_MatchWithExplicitIntent() {
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.TestActivity");
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).resolvePackageName).isEqualTo("org.robolectric");
+    assertThat(activities.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.shadows.TestActivity");
+  }
+
+  @Test
+  public void queryIntentActivities_MatchWithImplicitIntents() {
+    Uri uri = Uri.parse("content://testhost1.com:1/testPath/test.jpeg");
+    Intent i = new Intent(Intent.ACTION_VIEW);
+    i.addCategory(Intent.CATEGORY_DEFAULT);
+    i.setDataAndType(uri, "image/jpeg");
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).resolvePackageName).isEqualTo("org.robolectric");
+    assertThat(activities.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.shadows.TestActivity");
+  }
+
+  @Test
+  public void queryIntentActivities_MatchWithAliasIntents() {
+    Intent i = new Intent(Intent.ACTION_MAIN);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    List<ResolveInfo> activities = packageManager.queryIntentActivities(i, 0);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).resolvePackageName).isEqualTo("org.robolectric");
+    assertThat(activities.get(0).activityInfo.targetActivity)
+        .isEqualTo("org.robolectric.shadows.TestActivity");
+    assertThat(activities.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.shadows.TestActivityAlias");
+  }
+
+  @Test
+  public void queryIntentActivities_DisabledComponentExplicitIntent() {
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.DisabledActivity");
+
+    List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(i, 0);
+    assertThat(resolveInfos).isEmpty();
+  }
+
+  @Test
+  public void queryIntentActivities_MatchDisabledComponents() {
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.DisabledActivity");
+
+    List<ResolveInfo> resolveInfos =
+        packageManager.queryIntentActivities(i, PackageManager.MATCH_DISABLED_COMPONENTS);
+    assertThat(resolveInfos).isNotNull();
+    assertThat(resolveInfos).hasSize(1);
+    assertThat(resolveInfos.get(0).activityInfo.enabled).isFalse();
+  }
+
+  @Test
+  public void queryIntentActivities_DisabledComponentViaPmExplicitIntent() {
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.TestActivity");
+
+    ComponentName componentToDisable =
+        new ComponentName(context, "org.robolectric.shadows.TestActivity");
+    packageManager.setComponentEnabledSetting(
+        componentToDisable,
+        PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+        PackageManager.DONT_KILL_APP);
+
+    List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(i, 0);
+    assertThat(resolveInfos).isEmpty();
+  }
+
+  @Test
+  public void queryIntentActivities_DisabledComponentEnabledViaPmExplicitIntent() {
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.DisabledActivity");
+
+    ComponentName componentToDisable =
+        new ComponentName(context, "org.robolectric.shadows.DisabledActivity");
+    packageManager.setComponentEnabledSetting(
+        componentToDisable,
+        PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+        PackageManager.DONT_KILL_APP);
+
+    List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(i, 0);
+    assertThat(resolveInfos).hasSize(1);
+    assertThat(resolveInfos.get(0).activityInfo.enabled).isFalse();
+  }
+
+  @Test
+  public void queryIntentActivities_DisabledComponentViaPmImplicitIntent() {
+    Uri uri = Uri.parse("content://testhost1.com:1/testPath/test.jpeg");
+    Intent i = new Intent(Intent.ACTION_VIEW);
+    i.addCategory(Intent.CATEGORY_DEFAULT);
+    i.setDataAndType(uri, "image/jpeg");
+
+    ComponentName componentToDisable =
+        new ComponentName(context, "org.robolectric.shadows.TestActivity");
+    packageManager.setComponentEnabledSetting(
+        componentToDisable,
+        PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+        PackageManager.DONT_KILL_APP);
+
+    List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(i, 0);
+    assertThat(resolveInfos).isEmpty();
+  }
+
+  @Test
+  public void queryIntentActivities_MatchDisabledViaPmComponents() {
+    Uri uri = Uri.parse("content://testhost1.com:1/testPath/test.jpeg");
+    Intent i = new Intent(Intent.ACTION_VIEW);
+    i.addCategory(Intent.CATEGORY_DEFAULT);
+    i.setDataAndType(uri, "image/jpeg");
+
+    ComponentName componentToDisable =
+        new ComponentName(context, "org.robolectric.shadows.TestActivity");
+    packageManager.setComponentEnabledSetting(
+        componentToDisable,
+        PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+        PackageManager.DONT_KILL_APP);
+
+    List<ResolveInfo> resolveInfos =
+        packageManager.queryIntentActivities(i, PackageManager.MATCH_DISABLED_COMPONENTS);
+    assertThat(resolveInfos).isNotNull();
+    assertThat(resolveInfos).hasSize(1);
+    assertThat(resolveInfos.get(0).activityInfo.enabled).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentActivities_appHidden_includeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.TestActivity");
+
+    List<ResolveInfo> activities =
+        packageManager.queryIntentActivities(i, MATCH_UNINSTALLED_PACKAGES);
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).resolvePackageName).isEqualTo(packageName);
+    assertThat(activities.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.shadows.TestActivity");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentActivities_appHidden_dontIncludeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.shadows.TestActivity");
+
+    assertThat(packageManager.queryIntentActivities(i, /* flags= */ 0)).isEmpty();
+  }
+
+  @Test
+  public void resolveActivity_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null).addCategory(Intent.CATEGORY_LAUNCHER);
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+    info.activityInfo = new ActivityInfo();
+    info.activityInfo.name = "name";
+    info.activityInfo.packageName = TEST_PACKAGE_NAME;
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    assertThat(packageManager.resolveActivity(i, 0)).isNotNull();
+    assertThat(packageManager.resolveActivity(i, 0).activityInfo.name).isEqualTo("name");
+    assertThat(packageManager.resolveActivity(i, 0).activityInfo.packageName)
+        .isEqualTo(TEST_PACKAGE_NAME);
+  }
+
+  @Test
+  public void addIntentFilterForComponent() throws Exception {
+    ComponentName testComponent = new ComponentName("package", "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+    intentFilter.addCategory(Intent.CATEGORY_DEFAULT);
+    intentFilter.addCategory(Intent.CATEGORY_APP_CALENDAR);
+
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent();
+
+    intent.setAction("ACTION");
+    assertThat(intent.resolveActivity(packageManager)).isEqualTo(testComponent);
+
+    intent.setPackage("package");
+    assertThat(intent.resolveActivity(packageManager)).isEqualTo(testComponent);
+
+    intent.addCategory(Intent.CATEGORY_APP_CALENDAR);
+    assertThat(intent.resolveActivity(packageManager)).isEqualTo(testComponent);
+
+    intent.putExtra("key", "value");
+    assertThat(intent.resolveActivity(packageManager)).isEqualTo(testComponent);
+
+    intent.setData(Uri.parse("content://boo")); // data matches only if it is in the filter
+    assertThat(intent.resolveActivity(packageManager)).isNull();
+
+    intent.setData(null).setAction("BOO"); // different action
+    assertThat(intent.resolveActivity(packageManager)).isNull();
+  }
+
+  @Test
+  public void resolveActivity_NoMatch() {
+    Intent i = new Intent();
+    i.setComponent(new ComponentName("foo.bar", "No Activity"));
+    assertThat(packageManager.resolveActivity(i, 0)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void resolveActivityAsUser_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null).addCategory(Intent.CATEGORY_LAUNCHER);
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+    info.activityInfo = new ActivityInfo();
+    info.activityInfo.name = "name";
+    info.activityInfo.packageName = TEST_PACKAGE_NAME;
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    ResolveInfo resolvedActivity =
+        ReflectionHelpers.callInstanceMethod(
+            packageManager,
+            "resolveActivityAsUser",
+            ClassParameter.from(Intent.class, i),
+            ClassParameter.from(int.class, 0),
+            ClassParameter.from(int.class, USER_ID));
+
+    assertThat(resolvedActivity).isNotNull();
+    assertThat(resolvedActivity.activityInfo.name).isEqualTo("name");
+    assertThat(resolvedActivity.activityInfo.packageName).isEqualTo(TEST_PACKAGE_NAME);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void resolveActivityAsUser_NoMatch() {
+    Intent i = new Intent();
+    i.setComponent(new ComponentName("foo.bar", "No Activity"));
+
+    ResolveInfo resolvedActivity =
+        ReflectionHelpers.callInstanceMethod(
+            packageManager,
+            "resolveActivityAsUser",
+            ClassParameter.from(Intent.class, i),
+            ClassParameter.from(int.class, 0),
+            ClassParameter.from(int.class, USER_ID));
+
+    assertThat(resolvedActivity).isNull();
+  }
+
+  @Test
+  public void resolveExplicitIntent_sameApp() throws Exception {
+    ComponentName testComponent = new ComponentName(RuntimeEnvironment.getApplication(), "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent().setComponent(testComponent);
+    ResolveInfo resolveInfo = packageManager.resolveActivity(intent, 0);
+    assertThat(resolveInfo).isNotNull();
+    assertThat(resolveInfo.activityInfo.name).isEqualTo("name");
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void resolveExplicitIntent_filterMatch() throws Exception {
+    ComponentName testComponent = new ComponentName("some.other.package", "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent("ACTION").setComponent(testComponent);
+    ResolveInfo resolveInfo = packageManager.resolveActivity(intent, ResolveInfoFlags.of(0));
+    assertThat(resolveInfo).isNotNull();
+    assertThat(resolveInfo.activityInfo.name).isEqualTo("name");
+    assertThat(resolveInfo.activityInfo.packageName).isEqualTo("some.other.package");
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void resolveExplicitIntent_noFilterMatch() throws Exception {
+    ComponentName testComponent = new ComponentName("some.other.package", "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent("OTHER_ACTION").setComponent(testComponent);
+    assertThat(packageManager.resolveActivity(intent, ResolveInfoFlags.of(0))).isNull();
+  }
+
+  @Test
+  @Config(maxSdk = S)
+  public void resolveExplicitIntent_noFilterMatch_belowT() throws Exception {
+    ComponentName testComponent = new ComponentName("some.other.package", "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent("OTHER_ACTION").setComponent(testComponent);
+    assertThat(packageManager.resolveActivity(intent, 0)).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void resolveExplicitIntent_noFilterMatch_targetBelowT() throws Exception {
+    PackageInfo testPackage =
+        PackageInfoBuilder.newBuilder().setPackageName("some.other.package").build();
+    testPackage.applicationInfo.targetSdkVersion = S;
+    ComponentName testComponent = new ComponentName("some.other.package", "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+
+    shadowOf(packageManager).installPackage(testPackage);
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent("OTHER_ACTION").setComponent(testComponent);
+    assertThat(packageManager.resolveActivity(intent, ResolveInfoFlags.of(0))).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void resolveExplicitIntent_noAction() throws Exception {
+    ComponentName testComponent = new ComponentName("some.other.package", "name");
+    IntentFilter intentFilter = new IntentFilter("ACTION");
+
+    shadowOf(packageManager).addActivityIfNotPresent(testComponent);
+    shadowOf(packageManager).addIntentFilterForActivity(testComponent, intentFilter);
+    Intent intent = new Intent().setComponent(testComponent);
+    ResolveInfo resolveInfo = packageManager.resolveActivity(intent, ResolveInfoFlags.of(0));
+    assertThat(resolveInfo).isNotNull();
+    assertThat(resolveInfo.activityInfo.name).isEqualTo("name");
+  }
+
+  @Test
+  public void queryIntentServices_EmptyResult() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    List<ResolveInfo> activities = packageManager.queryIntentServices(i, 0);
+    assertThat(activities).isEmpty();
+  }
+
+  @Test
+  public void queryIntentServices_MatchWithExplicitIntent() {
+    Intent i = new Intent();
+    i.setClassName(context, "com.foo.Service");
+
+    List<ResolveInfo> services = packageManager.queryIntentServices(i, 0);
+    assertThat(services).isNotNull();
+    assertThat(services).hasSize(1);
+    assertThat(services.get(0).resolvePackageName).isEqualTo("org.robolectric");
+    assertThat(services.get(0).serviceInfo.name).isEqualTo("com.foo.Service");
+  }
+
+  @Test
+  public void queryIntentServices_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+
+    ResolveInfo info = new ResolveInfo();
+    info.serviceInfo = new ServiceInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    List<ResolveInfo> services = packageManager.queryIntentServices(i, 0);
+    assertThat(services).hasSize(1);
+    assertThat(services.get(0).nonLocalizedLabel.toString()).isEqualTo(TEST_PACKAGE_LABEL);
+  }
+
+  @Test
+  public void queryIntentServices_fromManifest() {
+    Intent i = new Intent("org.robolectric.ACTION_DIFFERENT_PACKAGE");
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+    i.setType("image/jpeg");
+    List<ResolveInfo> services = packageManager.queryIntentServices(i, 0);
+    assertThat(services).isNotEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentServices_appHidden_includeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent();
+    i.setClassName(context, "com.foo.Service");
+
+    List<ResolveInfo> services = packageManager.queryIntentServices(i, MATCH_UNINSTALLED_PACKAGES);
+    assertThat(services).hasSize(1);
+    assertThat(services.get(0).resolvePackageName).isEqualTo(packageName);
+    assertThat(services.get(0).serviceInfo.name).isEqualTo("com.foo.Service");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentServices_appHidden_dontIncludeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent();
+    i.setClassName(context, "com.foo.Service");
+
+    assertThat(packageManager.queryIntentServices(i, /* flags= */ 0)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void queryIntentServicesAsUser() {
+    Intent i = new Intent("org.robolectric.ACTION_DIFFERENT_PACKAGE");
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+    i.setType("image/jpeg");
+    List<ResolveInfo> services = packageManager.queryIntentServicesAsUser(i, 0, 0);
+    assertThat(services).isNotEmpty();
+  }
+
+  @Test
+  public void queryBroadcastReceivers_EmptyResult() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    i.addCategory(Intent.CATEGORY_LAUNCHER);
+
+    List<ResolveInfo> broadCastReceivers = packageManager.queryBroadcastReceivers(i, 0);
+    assertThat(broadCastReceivers).isEmpty();
+  }
+
+  @Test
+  public void queryBroadcastReceivers_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+
+    List<ResolveInfo> broadCastReceivers = packageManager.queryBroadcastReceivers(i, 0);
+    assertThat(broadCastReceivers).hasSize(1);
+    assertThat(broadCastReceivers.get(0).nonLocalizedLabel.toString())
+        .isEqualTo(TEST_PACKAGE_LABEL);
+  }
+
+  @Test
+  public void queryBroadcastReceivers_MatchWithExplicitIntent() {
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.fakes.ConfigTestReceiver");
+
+    List<ResolveInfo> receivers = packageManager.queryBroadcastReceivers(i, 0);
+    assertThat(receivers).isNotNull();
+    assertThat(receivers).hasSize(1);
+    assertThat(receivers.get(0).resolvePackageName).isEqualTo("org.robolectric");
+    assertThat(receivers.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.fakes.ConfigTestReceiver");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryBroadcastReceivers_appHidden_includeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.fakes.ConfigTestReceiver");
+
+    List<ResolveInfo> activities =
+        packageManager.queryBroadcastReceivers(i, MATCH_UNINSTALLED_PACKAGES);
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).resolvePackageName).isEqualTo(packageName);
+    assertThat(activities.get(0).activityInfo.name)
+        .isEqualTo("org.robolectric.fakes.ConfigTestReceiver");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryBroadcastReceivers_appHidden_dontIncludeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent();
+    i.setClassName(context, "org.robolectric.fakes.ConfigTestReceiver");
+
+    assertThat(packageManager.queryBroadcastReceivers(i, /* flags= */ 0)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentContentProviders_EmptyResult() {
+    Intent i = new Intent(DocumentsContract.PROVIDER_INTERFACE);
+
+    List<ResolveInfo> broadCastReceivers = packageManager.queryIntentContentProviders(i, 0);
+    assertThat(broadCastReceivers).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentContentProviders_Match() {
+    Intent i = new Intent(DocumentsContract.PROVIDER_INTERFACE);
+
+    ResolveInfo resolveInfo = new ResolveInfo();
+    ProviderInfo providerInfo = new ProviderInfo();
+    providerInfo.authority = "com.robolectric";
+    resolveInfo.providerInfo = providerInfo;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, resolveInfo);
+
+    List<ResolveInfo> contentProviders = packageManager.queryIntentContentProviders(i, 0);
+    assertThat(contentProviders).hasSize(1);
+    assertThat(contentProviders.get(0).providerInfo.authority).isEqualTo(providerInfo.authority);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentContentProviders_MatchSystemOnly() {
+    Intent i = new Intent(DocumentsContract.PROVIDER_INTERFACE);
+
+    ResolveInfo info1 = new ResolveInfo();
+    info1.providerInfo = new ProviderInfo();
+    info1.providerInfo.applicationInfo = new ApplicationInfo();
+
+    ResolveInfo info2 = new ResolveInfo();
+    info2.providerInfo = new ProviderInfo();
+    info2.providerInfo.applicationInfo = new ApplicationInfo();
+    info2.providerInfo.applicationInfo.flags |= ApplicationInfo.FLAG_SYSTEM;
+    info2.nonLocalizedLabel = "System App";
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, info1);
+    shadowOf(packageManager).addResolveInfoForIntent(i, info2);
+
+    List<ResolveInfo> activities =
+        packageManager.queryIntentContentProviders(i, PackageManager.MATCH_SYSTEM_ONLY);
+    assertThat(activities).isNotNull();
+    assertThat(activities).hasSize(1);
+    assertThat(activities.get(0).nonLocalizedLabel.toString()).isEqualTo("System App");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentContentProviders_MatchDisabledComponents() {
+    Intent i = new Intent(DocumentsContract.PROVIDER_INTERFACE);
+
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.providerInfo = new ProviderInfo();
+    resolveInfo.providerInfo.applicationInfo = new ApplicationInfo();
+    resolveInfo.providerInfo.applicationInfo.packageName =
+        "org.robolectric.shadows.TestPackageName";
+    resolveInfo.providerInfo.name = "org.robolectric.shadows.TestProvider";
+    resolveInfo.providerInfo.enabled = false;
+
+    shadowOf(packageManager).addResolveInfoForIntent(i, resolveInfo);
+
+    List<ResolveInfo> resolveInfos = packageManager.queryIntentContentProviders(i, 0);
+    assertThat(resolveInfos).isEmpty();
+
+    resolveInfos =
+        packageManager.queryIntentContentProviders(i, PackageManager.MATCH_DISABLED_COMPONENTS);
+    assertThat(resolveInfos).isNotNull();
+    assertThat(resolveInfos).hasSize(1);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queryIntentContentProviders_appHidden_includeUninstalled() {
+    String packageName = context.getPackageName();
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    Intent i = new Intent(DocumentsContract.PROVIDER_INTERFACE);
+    i.setClassName(context, "org.robolectric.shadows.testing.TestContentProvider1");
+
+    List<ResolveInfo> resolveInfos = packageManager.queryIntentContentProviders(i, 0);
+    assertThat(resolveInfos).isEmpty();
+
+    resolveInfos = packageManager.queryIntentContentProviders(i, MATCH_UNINSTALLED_PACKAGES);
+
+    assertThat(resolveInfos).hasSize(1);
+    assertThat(resolveInfos.get(0).providerInfo.applicationInfo.packageName).isEqualTo(packageName);
+    assertThat(resolveInfos.get(0).providerInfo.name)
+        .isEqualTo("org.robolectric.shadows.testing.TestContentProvider1");
+  }
+
+  @Test
+  public void resolveService_Match() {
+    Intent i = new Intent(Intent.ACTION_MAIN, null);
+    ResolveInfo info = new ResolveInfo();
+    info.serviceInfo = new ServiceInfo();
+    info.serviceInfo.name = "name";
+    shadowOf(packageManager).addResolveInfoForIntent(i, info);
+    assertThat(packageManager.resolveService(i, 0)).isNotNull();
+    assertThat(packageManager.resolveService(i, 0).serviceInfo.name).isEqualTo("name");
+  }
+
+  @Test
+  public void removeResolveInfosForIntent_shouldCauseResolveActivityToReturnNull() {
+    Intent intent =
+        new Intent(Intent.ACTION_APP_ERROR, null).addCategory(Intent.CATEGORY_APP_BROWSER);
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+    info.activityInfo = new ActivityInfo();
+    info.activityInfo.packageName = "com.org";
+    shadowOf(packageManager).addResolveInfoForIntent(intent, info);
+
+    shadowOf(packageManager).removeResolveInfosForIntent(intent, "com.org");
+
+    assertThat(packageManager.resolveActivity(intent, 0)).isNull();
+  }
+
+  @Test
+  public void removeResolveInfosForIntent_forService() {
+    Intent intent =
+        new Intent(Intent.ACTION_APP_ERROR, null).addCategory(Intent.CATEGORY_APP_BROWSER);
+    ResolveInfo info = new ResolveInfo();
+    info.nonLocalizedLabel = TEST_PACKAGE_LABEL;
+    info.serviceInfo = new ServiceInfo();
+    info.serviceInfo.packageName = "com.org";
+    shadowOf(packageManager).addResolveInfoForIntent(intent, info);
+
+    shadowOf(packageManager).removeResolveInfosForIntent(intent, "com.org");
+
+    assertThat(packageManager.resolveService(intent, 0)).isNull();
+  }
+
+  @Test
+  public void resolveService_NoMatch() {
+    Intent i = new Intent();
+    i.setComponent(new ComponentName("foo.bar", "No Activity"));
+    assertThat(packageManager.resolveService(i, 0)).isNull();
+  }
+
+  @Test
+  public void queryActivityIcons_Match() throws Exception {
+    Intent i = new Intent();
+    i.setComponent(new ComponentName(TEST_PACKAGE_NAME, ""));
+    Drawable d = new BitmapDrawable();
+
+    shadowOf(packageManager).addActivityIcon(i, d);
+
+    assertThat(packageManager.getActivityIcon(i)).isSameInstanceAs(d);
+    assertThat(packageManager.getActivityIcon(i.getComponent())).isSameInstanceAs(d);
+  }
+
+  @Test
+  public void getApplicationIcon_componentName_matches() throws Exception {
+    Intent i = new Intent();
+    i.setComponent(new ComponentName(TEST_PACKAGE_NAME, ""));
+    Drawable d = new BitmapDrawable();
+
+    shadowOf(packageManager).setApplicationIcon(TEST_PACKAGE_NAME, d);
+
+    assertThat(packageManager.getApplicationIcon(TEST_PACKAGE_NAME)).isSameInstanceAs(d);
+  }
+
+  @Test
+  public void getApplicationIcon_applicationInfo_matches() {
+    Intent i = new Intent();
+    i.setComponent(new ComponentName(TEST_PACKAGE_NAME, ""));
+    Drawable d = new BitmapDrawable();
+
+    shadowOf(packageManager).setApplicationIcon(TEST_PACKAGE_NAME, d);
+
+    ApplicationInfo applicationInfo = new ApplicationInfo();
+    applicationInfo.packageName = TEST_PACKAGE_NAME;
+
+    assertThat(packageManager.getApplicationIcon(applicationInfo)).isSameInstanceAs(d);
+  }
+
+  @Test
+  public void hasSystemFeature() {
+    // uninitialized
+    assertThat(packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)).isFalse();
+
+    // positive
+    shadowOf(packageManager).setSystemFeature(PackageManager.FEATURE_CAMERA, true);
+    assertThat(packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)).isTrue();
+
+    // negative
+    shadowOf(packageManager).setSystemFeature(PackageManager.FEATURE_CAMERA, false);
+    assertThat(packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)).isFalse();
+  }
+
+  @Test
+  public void addSystemSharedLibraryName() {
+    shadowOf(packageManager).addSystemSharedLibraryName("com.foo.system_library_1");
+    shadowOf(packageManager).addSystemSharedLibraryName("com.foo.system_library_2");
+
+    assertThat(packageManager.getSystemSharedLibraryNames())
+        .asList()
+        .containsExactly("com.foo.system_library_1", "com.foo.system_library_2");
+  }
+
+  @Test
+  public void clearSystemSharedLibraryName() {
+    shadowOf(packageManager).addSystemSharedLibraryName("com.foo.system_library_1");
+    shadowOf(packageManager).clearSystemSharedLibraryNames();
+
+    assertThat(packageManager.getSystemSharedLibraryNames()).isEmpty();
+  }
+
+  @Test
+  public void getPackageInfo_shouldReturnActivityInfos() throws Exception {
+    PackageInfo packageInfo =
+        packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_ACTIVITIES);
+    ActivityInfo activityInfoWithFilters =
+        findActivity(packageInfo.activities, ActivityWithFilters.class.getName());
+    assertThat(activityInfoWithFilters.packageName).isEqualTo("org.robolectric");
+    assertThat(activityInfoWithFilters.exported).isEqualTo(true);
+    assertThat(activityInfoWithFilters.permission).isEqualTo("com.foo.MY_PERMISSION");
+  }
+
+  private static ActivityInfo findActivity(ActivityInfo[] activities, String name) {
+    for (ActivityInfo activityInfo : activities) {
+      if (activityInfo.name.equals(name)) {
+        return activityInfo;
+      }
+    }
+    return null;
+  }
+
+  @Test
+  public void getPackageInfo_getProvidersShouldReturnProviderInfos() throws Exception {
+    PackageInfo packageInfo =
+        packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_PROVIDERS);
+    ProviderInfo[] providers = packageInfo.providers;
+    assertThat(providers).isNotEmpty();
+    assertThat(providers.length).isEqualTo(3);
+    assertThat(providers[0].packageName).isEqualTo("org.robolectric");
+    assertThat(providers[1].packageName).isEqualTo("org.robolectric");
+    assertThat(providers[2].packageName).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void getProviderInfo_shouldReturnProviderInfos() throws Exception {
+    ProviderInfo providerInfo1 =
+        packageManager.getProviderInfo(
+            new ComponentName(context, "org.robolectric.shadows.testing.TestContentProvider1"), 0);
+    assertThat(providerInfo1.packageName).isEqualTo("org.robolectric");
+    assertThat(providerInfo1.authority).isEqualTo("org.robolectric.authority1");
+
+    ProviderInfo providerInfo2 =
+        packageManager.getProviderInfo(
+            new ComponentName(context, "org.robolectric.shadows.testing.TestContentProvider2"), 0);
+    assertThat(providerInfo2.packageName).isEqualTo("org.robolectric");
+    assertThat(providerInfo2.authority).isEqualTo("org.robolectric.authority2");
+  }
+
+  @Test
+  public void getProviderInfo_packageNotFoundShouldThrowException() {
+    try {
+      packageManager.getProviderInfo(
+          new ComponentName("non.existent.package", ".tester.DoesntExist"), 0);
+      fail("should have thrown NameNotFoundException");
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getProviderInfo_shouldPopulatePermissionsInProviderInfos() throws Exception {
+    ProviderInfo providerInfo =
+        packageManager.getProviderInfo(
+            new ComponentName(context, "org.robolectric.shadows.testing.TestContentProvider1"), 0);
+    assertThat(providerInfo.authority).isEqualTo("org.robolectric.authority1");
+
+    assertThat(providerInfo.readPermission).isEqualTo("READ_PERMISSION");
+    assertThat(providerInfo.writePermission).isEqualTo("WRITE_PERMISSION");
+
+    assertThat(providerInfo.pathPermissions).asList().hasSize(1);
+    assertThat(providerInfo.pathPermissions[0].getType())
+        .isEqualTo(PathPermission.PATTERN_SIMPLE_GLOB);
+    assertThat(providerInfo.pathPermissions[0].getPath()).isEqualTo("/path/*");
+    assertThat(providerInfo.pathPermissions[0].getReadPermission())
+        .isEqualTo("PATH_READ_PERMISSION");
+    assertThat(providerInfo.pathPermissions[0].getWritePermission())
+        .isEqualTo("PATH_WRITE_PERMISSION");
+  }
+
+  @Test
+  public void getProviderInfo_shouldMetaDataInProviderInfos() throws Exception {
+    ProviderInfo providerInfo =
+        packageManager.getProviderInfo(
+            new ComponentName(context, "org.robolectric.shadows.testing.TestContentProvider1"),
+            PackageManager.GET_META_DATA);
+    assertThat(providerInfo.authority).isEqualTo("org.robolectric.authority1");
+
+    assertThat(providerInfo.metaData.getString("greeting")).isEqualTo("Hello");
+  }
+
+  @Test
+  public void resolveContentProvider_shouldResolveByPackageName() {
+    ProviderInfo providerInfo =
+        packageManager.resolveContentProvider("org.robolectric.authority1", 0);
+    assertThat(providerInfo.packageName).isEqualTo("org.robolectric");
+    assertThat(providerInfo.authority).isEqualTo("org.robolectric.authority1");
+  }
+
+  @Test
+  public void resolveContentProvider_multiAuthorities() {
+    ProviderInfo providerInfo =
+        packageManager.resolveContentProvider("org.robolectric.authority3", 0);
+    assertThat(providerInfo.packageName).isEqualTo("org.robolectric");
+    assertThat(providerInfo.authority)
+        .isEqualTo("org.robolectric.authority3;org.robolectric.authority4");
+  }
+
+  @Test
+  public void testReceiverInfo() throws Exception {
+    ActivityInfo info =
+        packageManager.getReceiverInfo(
+            new ComponentName(context, "org.robolectric.test.ConfigTestReceiver"),
+            PackageManager.GET_META_DATA);
+    assertThat(info.metaData.getInt("numberOfSheep")).isEqualTo(42);
+  }
+
+  @Test
+  public void testGetPackageInfo_ForReceiversIncorrectPackage() {
+    try {
+      packageManager.getPackageInfo("unknown_package", PackageManager.GET_RECEIVERS);
+      fail("should have thrown NameNotFoundException");
+    } catch (PackageManager.NameNotFoundException e) {
+      assertThat(e.getMessage()).contains("unknown_package");
+    }
+  }
+
+  @Test
+  public void getPackageInfo_shouldReturnRequestedPermissions() throws Exception {
+    PackageInfo packageInfo =
+        packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_PERMISSIONS);
+    String[] permissions = packageInfo.requestedPermissions;
+    assertThat(permissions).isNotNull();
+    assertThat(permissions.length).isEqualTo(4);
+  }
+
+  @Test
+  public void getPackageInfo_uninstalledPackage_includeUninstalled() throws Exception {
+    String packageName = context.getPackageName();
+    shadowOf(packageManager).deletePackage(packageName);
+
+    PackageInfo info = packageManager.getPackageInfo(packageName, MATCH_UNINSTALLED_PACKAGES);
+    assertThat(info).isNotNull();
+    assertThat(info.packageName).isEqualTo(packageName);
+  }
+
+  @Test
+  public void getPackageInfo_uninstalledPackage_dontIncludeUninstalled() {
+    String packageName = context.getPackageName();
+    shadowOf(packageManager).deletePackage(packageName);
+
+    try {
+      PackageInfo info = packageManager.getPackageInfo(packageName, 0);
+      fail("should have thrown NameNotFoundException:" + info.applicationInfo.flags);
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getPackageInfo_disabledPackage_includeDisabled() throws Exception {
+    packageManager.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DISABLED, 0);
+    PackageInfo info =
+        packageManager.getPackageInfo(context.getPackageName(), MATCH_DISABLED_COMPONENTS);
+    assertThat(info).isNotNull();
+    assertThat(info.packageName).isEqualTo(context.getPackageName());
+  }
+
+  @Test
+  public void getInstalledPackages_uninstalledPackage_includeUninstalled() {
+    shadowOf(packageManager).deletePackage(context.getPackageName());
+
+    assertThat(packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES)).isNotEmpty();
+    assertThat(packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).get(0).packageName)
+        .isEqualTo(context.getPackageName());
+  }
+
+  @Test
+  public void getInstalledPackages_uninstalledPackage_dontIncludeUninstalled() {
+    shadowOf(packageManager).deletePackage(context.getPackageName());
+
+    assertThat(packageManager.getInstalledPackages(0)).isEmpty();
+  }
+
+  @Test
+  public void getInstalledPackages_disabledPackage_includeDisabled() {
+    packageManager.setApplicationEnabledSetting(
+        context.getPackageName(), COMPONENT_ENABLED_STATE_DISABLED, 0);
+
+    assertThat(packageManager.getInstalledPackages(MATCH_DISABLED_COMPONENTS)).isNotEmpty();
+    assertThat(packageManager.getInstalledPackages(MATCH_DISABLED_COMPONENTS).get(0).packageName)
+        .isEqualTo(context.getPackageName());
+  }
+
+  @Test
+  public void testGetPreferredActivities() {
+    final String packageName = "com.example.dummy";
+    ComponentName name = new ComponentName(packageName, "LauncherActivity");
+
+    // Setup an intentfilter and add to packagemanager
+    IntentFilter filter = new IntentFilter(Intent.ACTION_MAIN);
+    filter.addCategory(Intent.CATEGORY_HOME);
+    packageManager.addPreferredActivity(filter, 0, null, name);
+
+    // Test match
+    List<IntentFilter> filters = new ArrayList<>();
+    List<ComponentName> activities = new ArrayList<>();
+    int filterCount = packageManager.getPreferredActivities(filters, activities, null);
+
+    assertThat(filterCount).isEqualTo(1);
+    assertThat(activities.size()).isEqualTo(1);
+    assertThat(activities.get(0).getPackageName()).isEqualTo(packageName);
+    assertThat(filters.size()).isEqualTo(1);
+
+    filterCount = packageManager.getPreferredActivities(filters, activities, "other");
+
+    assertThat(filterCount).isEqualTo(0);
+  }
+
+  @Test
+  public void resolveActivity_preferred() {
+    ComponentName preferredName = new ComponentName("preferred", "LauncherActivity");
+    ComponentName otherName = new ComponentName("other", "LauncherActivity");
+    Intent homeIntent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME);
+    shadowOf(packageManager)
+        .setResolveInfosForIntent(
+            homeIntent,
+            ImmutableList.of(
+                ShadowResolveInfo.newResolveInfo(
+                    "label1", otherName.getPackageName(), otherName.getClassName()),
+                ShadowResolveInfo.newResolveInfo(
+                    "label2", preferredName.getPackageName(), preferredName.getClassName())));
+
+    ResolveInfo resolveInfo = packageManager.resolveActivity(homeIntent, 0);
+    assertThat(resolveInfo.activityInfo.packageName).isEqualTo(otherName.getPackageName());
+
+    // Setup an intentfilter and add to packagemanager
+    IntentFilter filter = new IntentFilter(Intent.ACTION_MAIN);
+    filter.addCategory(Intent.CATEGORY_HOME);
+    packageManager.addPreferredActivity(filter, 0, null, preferredName);
+
+    resolveInfo = packageManager.resolveActivity(homeIntent, 0);
+    assertThat(resolveInfo.activityInfo.packageName).isEqualTo(preferredName.getPackageName());
+  }
+
+  @Test
+  public void canResolveDrawableGivenPackageAndResourceId() {
+    Drawable drawable =
+        Drawable.createFromStream(new ByteArrayInputStream(new byte[0]), "my_source");
+    shadowOf(packageManager).addDrawableResolution("com.example.foo", 4334, drawable);
+    Drawable actual = packageManager.getDrawable("com.example.foo", 4334, null);
+    assertThat(actual).isSameInstanceAs(drawable);
+  }
+
+  @Test
+  public void shouldAssignTheApplicationClassNameFromTheManifest() throws Exception {
+    ApplicationInfo applicationInfo = packageManager.getApplicationInfo("org.robolectric", 0);
+    assertThat(applicationInfo.className)
+        .isEqualTo("org.robolectric.shadows.testing.TestApplication");
+  }
+
+  @Test
+  @Config(minSdk = N_MR1)
+  public void shouldAssignTheApplicationNameFromTheManifest() throws Exception {
+    ApplicationInfo applicationInfo = packageManager.getApplicationInfo("org.robolectric", 0);
+    assertThat(applicationInfo.name).isEqualTo("org.robolectric.shadows.testing.TestApplication");
+  }
+
+  @Test
+  public void testLaunchIntentForPackage() {
+    Intent intent = packageManager.getLaunchIntentForPackage(TEST_PACKAGE_LABEL);
+    assertThat(intent).isNull();
+
+    Intent launchIntent = new Intent(Intent.ACTION_MAIN);
+    launchIntent.setPackage(TEST_PACKAGE_LABEL);
+    launchIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.activityInfo = new ActivityInfo();
+    resolveInfo.activityInfo.packageName = TEST_PACKAGE_LABEL;
+    resolveInfo.activityInfo.name = "LauncherActivity";
+    shadowOf(packageManager).addResolveInfoForIntent(launchIntent, resolveInfo);
+
+    intent = packageManager.getLaunchIntentForPackage(TEST_PACKAGE_LABEL);
+    assertThat(intent.getComponent().getClassName()).isEqualTo("LauncherActivity");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testLeanbackLaunchIntentForPackage() {
+    Intent intent = packageManager.getLeanbackLaunchIntentForPackage(TEST_PACKAGE_LABEL);
+    assertThat(intent).isNull();
+
+    Intent launchIntent = new Intent(Intent.ACTION_MAIN);
+    launchIntent.setPackage(TEST_PACKAGE_LABEL);
+    launchIntent.addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER);
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.activityInfo = new ActivityInfo();
+    resolveInfo.activityInfo.packageName = TEST_PACKAGE_LABEL;
+    resolveInfo.activityInfo.name = "LauncherActivity";
+    shadowOf(packageManager).addResolveInfoForIntent(launchIntent, resolveInfo);
+
+    intent = packageManager.getLeanbackLaunchIntentForPackage(TEST_PACKAGE_LABEL);
+    assertThat(intent.getComponent().getClassName()).isEqualTo("LauncherActivity");
+  }
+
+  @Test
+  public void shouldAssignTheAppMetaDataFromTheManifest() throws Exception {
+    ApplicationInfo info = packageManager.getApplicationInfo(context.getPackageName(), 0);
+    Bundle meta = info.metaData;
+
+    assertThat(meta.getString("org.robolectric.metaName1")).isEqualTo("metaValue1");
+    assertThat(meta.getString("org.robolectric.metaName2")).isEqualTo("metaValue2");
+
+    assertThat(meta.getBoolean("org.robolectric.metaFalseLiteral")).isEqualTo(false);
+    assertThat(meta.getBoolean("org.robolectric.metaTrueLiteral")).isEqualTo(true);
+
+    assertThat(meta.getInt("org.robolectric.metaInt")).isEqualTo(123);
+    assertThat(meta.getFloat("org.robolectric.metaFloat")).isEqualTo(1.23f);
+
+    assertThat(meta.getInt("org.robolectric.metaColor")).isEqualTo(Color.WHITE);
+
+    assertThat(meta.getBoolean("org.robolectric.metaBooleanFromRes"))
+        .isEqualTo(context.getResources().getBoolean(R.bool.false_bool_value));
+
+    assertThat(meta.getInt("org.robolectric.metaIntFromRes"))
+        .isEqualTo(context.getResources().getInteger(R.integer.test_integer1));
+
+    assertThat(meta.getInt("org.robolectric.metaColorFromRes"))
+        .isEqualTo(context.getResources().getColor(R.color.clear));
+
+    assertThat(meta.getString("org.robolectric.metaStringFromRes"))
+        .isEqualTo(context.getString(R.string.app_name));
+
+    assertThat(meta.getString("org.robolectric.metaStringOfIntFromRes"))
+        .isEqualTo(context.getString(R.string.str_int));
+
+    assertThat(meta.getInt("org.robolectric.metaStringRes")).isEqualTo(R.string.app_name);
+  }
+
+  @Test
+  public void testResolveDifferentIntentObjects() {
+    Intent intent1 = new Intent(Intent.ACTION_MAIN);
+    intent1.setPackage(TEST_PACKAGE_LABEL);
+    intent1.addCategory(Intent.CATEGORY_APP_BROWSER);
+
+    assertThat(packageManager.resolveActivity(intent1, 0)).isNull();
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.activityInfo = new ActivityInfo();
+    resolveInfo.activityInfo.packageName = TEST_PACKAGE_LABEL;
+    resolveInfo.activityInfo.name = "BrowserActivity";
+    shadowOf(packageManager).addResolveInfoForIntent(intent1, resolveInfo);
+
+    // the original intent object should yield a result
+    ResolveInfo result = packageManager.resolveActivity(intent1, 0);
+    assertThat(result.activityInfo.name).isEqualTo("BrowserActivity");
+
+    // AND a new, functionally equivalent intent should also yield a result
+    Intent intent2 = new Intent(Intent.ACTION_MAIN);
+    intent2.setPackage(TEST_PACKAGE_LABEL);
+    intent2.addCategory(Intent.CATEGORY_APP_BROWSER);
+    result = packageManager.resolveActivity(intent2, 0);
+    assertThat(result.activityInfo.name).isEqualTo("BrowserActivity");
+  }
+
+  @Test
+  public void testResolvePartiallySimilarIntents() {
+    Intent intent1 = new Intent(Intent.ACTION_APP_ERROR);
+    intent1.setPackage(TEST_PACKAGE_LABEL);
+    intent1.addCategory(Intent.CATEGORY_APP_BROWSER);
+
+    assertThat(packageManager.resolveActivity(intent1, 0)).isNull();
+
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.activityInfo = new ActivityInfo();
+    resolveInfo.activityInfo.packageName = TEST_PACKAGE_LABEL;
+    resolveInfo.activityInfo.name = "BrowserActivity";
+    shadowOf(packageManager).addResolveInfoForIntent(intent1, resolveInfo);
+
+    // the original intent object should yield a result
+    ResolveInfo result = packageManager.resolveActivity(intent1, 0);
+    assertThat(result.activityInfo.name).isEqualTo("BrowserActivity");
+
+    // an intent with just the same action should not be considered the same
+    Intent intent2 = new Intent(Intent.ACTION_APP_ERROR);
+    result = packageManager.resolveActivity(intent2, 0);
+    assertThat(result).isNull();
+
+    // an intent with just the same category should not be considered the same
+    Intent intent3 = new Intent();
+    intent3.addCategory(Intent.CATEGORY_APP_BROWSER);
+    result = packageManager.resolveActivity(intent3, 0);
+    assertThat(result).isNull();
+
+    // an intent without the correct package restriction should not be the same
+    Intent intent4 = new Intent(Intent.ACTION_APP_ERROR);
+    intent4.addCategory(Intent.CATEGORY_APP_BROWSER);
+    result = packageManager.resolveActivity(intent4, 0);
+    assertThat(result).isNull();
+  }
+
+  @Test
+  public void testSetApplicationEnabledSetting() {
+    assertThat(packageManager.getApplicationEnabledSetting("org.robolectric"))
+        .isEqualTo(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT);
+
+    packageManager.setApplicationEnabledSetting(
+        "org.robolectric", PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 0);
+
+    assertThat(packageManager.getApplicationEnabledSetting("org.robolectric"))
+        .isEqualTo(PackageManager.COMPONENT_ENABLED_STATE_DISABLED);
+  }
+
+  private static class ActivityWithMetadata extends Activity {}
+
+  @Test
+  public void getActivityMetaData() throws Exception {
+    Activity activity = setupActivity(ActivityWithMetadata.class);
+
+    ActivityInfo activityInfo =
+        packageManager.getActivityInfo(
+            activity.getComponentName(),
+            PackageManager.GET_ACTIVITIES | PackageManager.GET_META_DATA);
+    assertThat(activityInfo.metaData.get("someName")).isEqualTo("someValue");
+  }
+
+  @Test
+  public void shouldAssignLabelResFromTheManifest() throws Exception {
+    ApplicationInfo applicationInfo = packageManager.getApplicationInfo("org.robolectric", 0);
+    assertThat(applicationInfo.labelRes).isEqualTo(R.string.app_name);
+    assertThat(applicationInfo.nonLocalizedLabel).isNull();
+  }
+
+  @Test
+  public void getServiceInfo_shouldReturnServiceInfoIfExists() throws Exception {
+    ServiceInfo serviceInfo =
+        packageManager.getServiceInfo(new ComponentName("org.robolectric", "com.foo.Service"), 0);
+    assertThat(serviceInfo.packageName).isEqualTo("org.robolectric");
+    assertThat(serviceInfo.name).isEqualTo("com.foo.Service");
+    assertThat(serviceInfo.permission).isEqualTo("com.foo.MY_PERMISSION");
+    assertThat(serviceInfo.applicationInfo).isNotNull();
+  }
+
+  @Test
+  public void getServiceInfo_shouldReturnServiceInfoWithMetaDataWhenFlagsSet() throws Exception {
+    ServiceInfo serviceInfo =
+        packageManager.getServiceInfo(
+            new ComponentName("org.robolectric", "com.foo.Service"), PackageManager.GET_META_DATA);
+    assertThat(serviceInfo.metaData).isNotNull();
+  }
+
+  @Test
+  public void getServiceInfo_shouldReturnServiceInfoWithoutMetaDataWhenFlagsNotSet()
+      throws Exception {
+    ComponentName component = new ComponentName("org.robolectric", "com.foo.Service");
+    ServiceInfo serviceInfo = packageManager.getServiceInfo(component, 0);
+    assertThat(serviceInfo.metaData).isNull();
+  }
+
+  @Test
+  public void getServiceInfo_shouldThrowNameNotFoundExceptionIfNotExist() {
+    ComponentName nonExistComponent =
+        new ComponentName("org.robolectric", "com.foo.NonExistService");
+    try {
+      packageManager.getServiceInfo(nonExistComponent, PackageManager.GET_SERVICES);
+      fail("should have thrown NameNotFoundException");
+    } catch (PackageManager.NameNotFoundException e) {
+      assertThat(e.getMessage()).contains("com.foo.NonExistService");
+    }
+  }
+
+  @Test
+  public void getServiceInfo_shouldFindServiceIfAddedInResolveInfo() throws Exception {
+    ComponentName componentName = new ComponentName("com.test", "com.test.ServiceName");
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.serviceInfo = new ServiceInfo();
+    resolveInfo.serviceInfo.name = componentName.getClassName();
+    resolveInfo.serviceInfo.applicationInfo = new ApplicationInfo();
+    resolveInfo.serviceInfo.applicationInfo.packageName = componentName.getPackageName();
+    shadowOf(packageManager).addResolveInfoForIntent(new Intent("RANDOM_ACTION"), resolveInfo);
+
+    ServiceInfo serviceInfo = packageManager.getServiceInfo(componentName, 0);
+    assertThat(serviceInfo).isNotNull();
+  }
+
+  @Test
+  public void getNameForUid() {
+    assertThat(packageManager.getNameForUid(10)).isNull();
+
+    shadowOf(packageManager).setNameForUid(10, "a_name");
+
+    assertThat(packageManager.getNameForUid(10)).isEqualTo("a_name");
+  }
+
+  @Test
+  public void getPackagesForUid() {
+    assertThat(packageManager.getPackagesForUid(10)).isNull();
+
+    shadowOf(packageManager).setPackagesForUid(10, new String[] {"a_name"});
+
+    assertThat(packageManager.getPackagesForUid(10)).asList().containsExactly("a_name");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getPackageUid() throws NameNotFoundException {
+    shadowOf(packageManager).setPackagesForUid(10, new String[] {"a_name"});
+    assertThat(packageManager.getPackageUid("a_name", 0)).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getPackageUid_shouldThrowNameNotFoundExceptionIfNotExist() {
+    try {
+      packageManager.getPackageUid("a_name", 0);
+      fail("should have thrown NameNotFoundException");
+    } catch (PackageManager.NameNotFoundException e) {
+      assertThat(e.getMessage()).contains("a_name");
+    }
+  }
+
+  @Test
+  public void getPackagesForUid_shouldReturnSetPackageName() {
+    shadowOf(packageManager).setPackagesForUid(10, new String[] {"a_name"});
+    assertThat(packageManager.getPackagesForUid(10)).asList().containsExactly("a_name");
+  }
+
+  @Test
+  public void getResourcesForApplication_currentApplication() throws Exception {
+    assertThat(
+            packageManager
+                .getResourcesForApplication("org.robolectric")
+                .getString(R.string.app_name))
+        .isEqualTo(context.getString(R.string.app_name));
+  }
+
+  @Test
+  public void getResourcesForApplication_unknownPackage() {
+    try {
+      packageManager.getResourcesForApplication("non.existent.package");
+      fail("should have thrown NameNotFoundException");
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getResourcesForApplication_anotherPackage() throws Exception {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "another.package";
+
+    ApplicationInfo applicationInfo = new ApplicationInfo();
+    applicationInfo.packageName = "another.package";
+    packageInfo.applicationInfo = applicationInfo;
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    assertThat(packageManager.getResourcesForApplication("another.package")).isNotNull();
+    assertThat(packageManager.getResourcesForApplication("another.package"))
+        .isNotEqualTo(context.getResources());
+  }
+
+  private void verifyApkNotInstalled(String packageName) {
+    try {
+      packageManager.getPackageInfo(packageName, 0);
+      Assert.fail("Package not expected to be installed.");
+    } catch (NameNotFoundException e) {
+      // expected exception
+    }
+  }
+
+  @Test
+  public void getResourcesForApplication_ApkNotInstalled() throws NameNotFoundException {
+    assume().that(RuntimeEnvironment.useLegacyResources()).isFalse();
+
+    File testApk = TestUtil.resourcesBaseDir().resolve(REAL_TEST_APP_ASSET_PATH).toFile();
+
+    PackageInfo packageInfo = packageManager.getPackageArchiveInfo(testApk.getAbsolutePath(), 0);
+
+    assertThat(packageInfo).isNotNull();
+    ApplicationInfo applicationInfo = packageInfo.applicationInfo;
+    assertThat(applicationInfo.packageName).isEqualTo(REAL_TEST_APP_PACKAGE_NAME);
+
+    // double-check that Robolectric doesn't consider this package to be installed
+    verifyApkNotInstalled(packageInfo.packageName);
+
+    applicationInfo.sourceDir = applicationInfo.publicSourceDir = testApk.getAbsolutePath();
+    assertThat(packageManager.getResourcesForApplication(applicationInfo)).isNotNull();
+  }
+
+  @Test
+  public void getResourcesForApplication_ApkNotPresent() {
+    ApplicationInfo applicationInfo =
+        ApplicationInfoBuilder.newBuilder().setPackageName("com.not.present").build();
+    applicationInfo.sourceDir = applicationInfo.publicSourceDir = "/some/nonexistant/path";
+
+    try {
+      packageManager.getResourcesForApplication(applicationInfo);
+      Assert.fail("Expected NameNotFoundException not thrown");
+    } catch (NameNotFoundException ex) {
+      // Expected exception
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldShowRequestPermissionRationale() {
+    assertThat(packageManager.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA))
+        .isFalse();
+
+    shadowOf(packageManager)
+        .setShouldShowRequestPermissionRationale(Manifest.permission.CAMERA, true);
+
+    assertThat(packageManager.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA))
+        .isTrue();
+  }
+
+  @Test
+  public void getSystemAvailableFeatures() {
+    assertThat(packageManager.getSystemAvailableFeatures()).isNull();
+
+    FeatureInfo feature = new FeatureInfo();
+    feature.reqGlEsVersion = 0x20000;
+    feature.flags = FeatureInfo.FLAG_REQUIRED;
+    shadowOf(packageManager).addSystemAvailableFeature(feature);
+
+    assertThat(packageManager.getSystemAvailableFeatures()).asList().contains(feature);
+
+    shadowOf(packageManager).clearSystemAvailableFeatures();
+
+    assertThat(packageManager.getSystemAvailableFeatures()).isNull();
+  }
+
+  @Test
+  public void verifyPendingInstall() {
+    packageManager.verifyPendingInstall(1234, VERIFICATION_ALLOW);
+
+    assertThat(shadowOf(packageManager).getVerificationResult(1234)).isEqualTo(VERIFICATION_ALLOW);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void extendPendingInstallTimeout() {
+    packageManager.extendVerificationTimeout(1234, 0, 1000);
+
+    assertThat(shadowOf(packageManager).getVerificationExtendedTimeout(1234)).isEqualTo(1000);
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1) // Functionality removed in O
+  public void whenPackageNotPresent_getPackageSizeInfo_callsBackWithFailure() throws Exception {
+    IPackageStatsObserver packageStatsObserver = mock(IPackageStatsObserver.class);
+    packageManager.getPackageSizeInfo("nonexistant.package", packageStatsObserver);
+    shadowMainLooper().idle();
+
+    verify(packageStatsObserver).onGetStatsCompleted(packageStatsCaptor.capture(), eq(false));
+    assertThat(packageStatsCaptor.getValue()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1) // Functionality removed in O
+  public void whenPackageNotPresentAndPaused_getPackageSizeInfo_callsBackWithFailure()
+      throws Exception {
+    shadowMainLooper().pause();
+    IPackageStatsObserver packageStatsObserver = mock(IPackageStatsObserver.class);
+    packageManager.getPackageSizeInfo("nonexistant.package", packageStatsObserver);
+
+    verifyNoMoreInteractions(packageStatsObserver);
+
+    shadowMainLooper().idle();
+    verify(packageStatsObserver).onGetStatsCompleted(packageStatsCaptor.capture(), eq(false));
+    assertThat(packageStatsCaptor.getValue()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1) // Functionality removed in O
+  public void whenNotPreconfigured_getPackageSizeInfo_callsBackWithDefaults() throws Exception {
+    IPackageStatsObserver packageStatsObserver = mock(IPackageStatsObserver.class);
+    packageManager.getPackageSizeInfo("org.robolectric", packageStatsObserver);
+    shadowMainLooper().idle();
+
+    verify(packageStatsObserver).onGetStatsCompleted(packageStatsCaptor.capture(), eq(true));
+    assertThat(packageStatsCaptor.getValue().packageName).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1) // Functionality removed in O
+  public void whenPreconfigured_getPackageSizeInfo_callsBackWithConfiguredValues()
+      throws Exception {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "org.robolectric";
+    PackageStats packageStats = new PackageStats("org.robolectric");
+    shadowOf(packageManager).addPackage(packageInfo, packageStats);
+
+    IPackageStatsObserver packageStatsObserver = mock(IPackageStatsObserver.class);
+    packageManager.getPackageSizeInfo("org.robolectric", packageStatsObserver);
+    shadowMainLooper().idle();
+
+    verify(packageStatsObserver).onGetStatsCompleted(packageStatsCaptor.capture(), eq(true));
+    assertThat(packageStatsCaptor.getValue().packageName).isEqualTo("org.robolectric");
+    assertThat(packageStatsCaptor.getValue().toString()).isEqualTo(packageStats.toString());
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1) // Functionality removed in O
+  public void whenPreconfiguredForAnotherPackage_getPackageSizeInfo_callsBackWithConfiguredValues()
+      throws Exception {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "org.other";
+    PackageStats packageStats = new PackageStats("org.other");
+    shadowOf(packageManager).addPackage(packageInfo, packageStats);
+
+    IPackageStatsObserver packageStatsObserver = mock(IPackageStatsObserver.class);
+    packageManager.getPackageSizeInfo("org.other", packageStatsObserver);
+    shadowMainLooper().idle();
+
+    verify(packageStatsObserver).onGetStatsCompleted(packageStatsCaptor.capture(), eq(true));
+    assertThat(packageStatsCaptor.getValue().packageName).isEqualTo("org.other");
+    assertThat(packageStatsCaptor.getValue().toString()).isEqualTo(packageStats.toString());
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = N_MR1) // Functionality removed in O
+  public void whenPaused_getPackageSizeInfo_callsBackWithConfiguredValuesAfterIdle()
+      throws Exception {
+    shadowMainLooper().pause();
+
+    IPackageStatsObserver packageStatsObserver = mock(IPackageStatsObserver.class);
+    packageManager.getPackageSizeInfo("org.robolectric", packageStatsObserver);
+
+    verifyNoMoreInteractions(packageStatsObserver);
+
+    shadowMainLooper().idle();
+    verify(packageStatsObserver).onGetStatsCompleted(packageStatsCaptor.capture(), eq(true));
+    assertThat(packageStatsCaptor.getValue().packageName).isEqualTo("org.robolectric");
+  }
+
+  @Test
+  public void addCurrentToCannonicalName() {
+    shadowOf(packageManager).addCurrentToCannonicalName("current_name_1", "canonical_name_1");
+    shadowOf(packageManager).addCurrentToCannonicalName("current_name_2", "canonical_name_2");
+
+    assertThat(
+            packageManager.currentToCanonicalPackageNames(
+                new String[] {"current_name_1", "current_name_2", "some_other_name"}))
+        .asList()
+        .containsExactly("canonical_name_1", "canonical_name_2", "some_other_name")
+        .inOrder();
+  }
+
+  @Test
+  public void addCanonicalName() {
+    shadowOf(packageManager).addCanonicalName("current_name_1", "canonical_name_1");
+    shadowOf(packageManager).addCanonicalName("current_name_2", "canonical_name_2");
+
+    assertThat(
+            packageManager.canonicalToCurrentPackageNames(
+                new String[] {"canonical_name_1", "canonical_name_2", "some_other_name"}))
+        .asList()
+        .containsExactly("current_name_1", "current_name_2", "some_other_name")
+        .inOrder();
+    assertThat(
+            packageManager.currentToCanonicalPackageNames(
+                new String[] {"current_name_1", "current_name_2", "some_other_name"}))
+        .asList()
+        .containsExactly("canonical_name_1", "canonical_name_2", "some_other_name")
+        .inOrder();
+  }
+
+  @Test
+  public void getInstalledApplications() {
+    List<ApplicationInfo> installedApplications = packageManager.getInstalledApplications(0);
+
+    // Default should include the application under test
+    assertThat(installedApplications).hasSize(1);
+    assertThat(installedApplications.get(0).packageName).isEqualTo("org.robolectric");
+
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "org.other";
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.packageName = "org.other";
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    installedApplications = packageManager.getInstalledApplications(0);
+    assertThat(installedApplications).hasSize(2);
+    assertThat(installedApplications.get(1).packageName).isEqualTo("org.other");
+  }
+
+  @Test
+  public void getPermissionInfo() throws Exception {
+    PermissionInfo permission =
+        context.getPackageManager().getPermissionInfo("org.robolectric.some_permission", 0);
+    assertThat(permission.labelRes).isEqualTo(R.string.test_permission_label);
+    assertThat(permission.descriptionRes).isEqualTo(R.string.test_permission_description);
+    assertThat(permission.name).isEqualTo("org.robolectric.some_permission");
+  }
+
+  @Test
+  public void checkSignatures_same() {
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("first.package", new Signature("00000000")));
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("second.package", new Signature("00000000")));
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_MATCH);
+  }
+
+  @Test
+  public void checkSignatures_firstNotSigned() {
+    shadowOf(packageManager).installPackage(newPackageInfo("first.package", (Signature[]) null));
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("second.package", new Signature("00000000")));
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_FIRST_NOT_SIGNED);
+  }
+
+  @Test
+  public void checkSignatures_secondNotSigned() {
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("first.package", new Signature("00000000")));
+    shadowOf(packageManager).installPackage(newPackageInfo("second.package", (Signature[]) null));
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_SECOND_NOT_SIGNED);
+  }
+
+  @Test
+  public void checkSignatures_neitherSigned() {
+    shadowOf(packageManager).installPackage(newPackageInfo("first.package", (Signature[]) null));
+    shadowOf(packageManager).installPackage(newPackageInfo("second.package", (Signature[]) null));
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_NEITHER_SIGNED);
+  }
+
+  @Test
+  public void checkSignatures_noMatch() {
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("first.package", new Signature("00000000")));
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("second.package", new Signature("FFFFFFFF")));
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_NO_MATCH);
+  }
+
+  @Test
+  public void checkSignatures_noMatch_mustBeExact() {
+    shadowOf(packageManager)
+        .installPackage(newPackageInfo("first.package", new Signature("00000000")));
+    shadowOf(packageManager)
+        .installPackage(
+            newPackageInfo("second.package", new Signature("00000000"), new Signature("FFFFFFFF")));
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_NO_MATCH);
+  }
+
+  @Test
+  public void checkSignatures_unknownPackage() {
+    assertThat(packageManager.checkSignatures("first.package", "second.package"))
+        .isEqualTo(SIGNATURE_UNKNOWN_PACKAGE);
+  }
+
+  private static PackageInfo newPackageInfo(String packageName, Signature... signatures) {
+    PackageInfo firstPackageInfo = new PackageInfo();
+    firstPackageInfo.packageName = packageName;
+    firstPackageInfo.signatures = signatures;
+    return firstPackageInfo;
+  }
+
+  @Test
+  public void getPermissionInfo_notFound() {
+    try {
+      packageManager.getPermissionInfo("non_existant_permission", 0);
+      fail("should have thrown NameNotFoundException");
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getPermissionInfo_noMetaData() throws Exception {
+    PermissionInfo permission =
+        packageManager.getPermissionInfo("org.robolectric.some_permission", 0);
+    assertThat(permission.metaData).isNull();
+    assertThat(permission.name).isEqualTo("org.robolectric.some_permission");
+    assertThat(permission.descriptionRes).isEqualTo(R.string.test_permission_description);
+    assertThat(permission.labelRes).isEqualTo(R.string.test_permission_label);
+    assertThat(permission.nonLocalizedLabel).isNull();
+    assertThat(permission.group).isEqualTo("my_permission_group");
+    assertThat(permission.protectionLevel).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS);
+  }
+
+  @Test
+  public void getPermissionInfo_withMetaData() throws Exception {
+    PermissionInfo permission =
+        packageManager.getPermissionInfo(
+            "org.robolectric.some_permission", PackageManager.GET_META_DATA);
+    assertThat(permission.metaData).isNotNull();
+    assertThat(permission.metaData.getString("meta_data_name")).isEqualTo("meta_data_value");
+  }
+
+  @Test
+  public void getPermissionInfo_withLiteralLabel() throws Exception {
+    PermissionInfo permission =
+        packageManager.getPermissionInfo("org.robolectric.permission_with_literal_label", 0);
+    assertThat(permission.labelRes).isEqualTo(0);
+    assertThat(permission.nonLocalizedLabel.toString()).isEqualTo("Literal label");
+    assertThat(permission.protectionLevel).isEqualTo(PermissionInfo.PROTECTION_NORMAL);
+  }
+
+  @Test
+  public void queryPermissionsByGroup_groupNotFound() {
+    try {
+      packageManager.queryPermissionsByGroup("nonexistent_permission_group", 0);
+      fail("Exception expected");
+    } catch (NameNotFoundException expected) {
+    }
+  }
+
+  @Test
+  public void queryPermissionsByGroup_noMetaData() throws Exception {
+    List<PermissionInfo> permissions =
+        packageManager.queryPermissionsByGroup("my_permission_group", 0);
+    assertThat(permissions).hasSize(1);
+
+    PermissionInfo permission = permissions.get(0);
+
+    assertThat(permission.group).isEqualTo("my_permission_group");
+    assertThat(permission.name).isEqualTo("org.robolectric.some_permission");
+    assertThat(permission.metaData).isNull();
+  }
+
+  @Test
+  public void queryPermissionsByGroup_withMetaData() throws Exception {
+    List<PermissionInfo> permissions =
+        packageManager.queryPermissionsByGroup("my_permission_group", PackageManager.GET_META_DATA);
+    assertThat(permissions).hasSize(1);
+
+    PermissionInfo permission = permissions.get(0);
+
+    assertThat(permission.group).isEqualTo("my_permission_group");
+    assertThat(permission.name).isEqualTo("org.robolectric.some_permission");
+    assertThat(permission.metaData).isNotNull();
+    assertThat(permission.metaData.getString("meta_data_name")).isEqualTo("meta_data_value");
+  }
+
+  @Test
+  public void queryPermissionsByGroup_nullMatchesPermissionsNotAssociatedWithGroup()
+      throws Exception {
+    List<PermissionInfo> permissions = packageManager.queryPermissionsByGroup(null, 0);
+
+    assertThat(Iterables.transform(permissions, getPermissionNames()))
+        .containsExactly(
+            "org.robolectric.permission_with_minimal_fields",
+            "org.robolectric.permission_with_literal_label");
+  }
+
+  @Test
+  public void
+      queryPermissionsByGroup_nullMatchesPermissionsNotAssociatedWithGroup_with_addPermissionInfo()
+          throws Exception {
+    PermissionInfo permissionInfo = new PermissionInfo();
+    permissionInfo.name = "some_name";
+    shadowOf(packageManager).addPermissionInfo(permissionInfo);
+
+    List<PermissionInfo> permissions = packageManager.queryPermissionsByGroup(null, 0);
+    assertThat(permissions).isNotEmpty();
+
+    assertThat(permissions.get(0).name).isEqualTo(permissionInfo.name);
+  }
+
+  @Test
+  public void queryPermissionsByGroup_with_addPermissionInfo() throws Exception {
+    PermissionInfo permissionInfo = new PermissionInfo();
+    permissionInfo.name = "some_name";
+    permissionInfo.group = "some_group";
+    shadowOf(packageManager).addPermissionInfo(permissionInfo);
+
+    List<PermissionInfo> permissions =
+        packageManager.queryPermissionsByGroup(permissionInfo.group, 0);
+    assertThat(permissions).hasSize(1);
+
+    assertThat(permissions.get(0).name).isEqualTo(permissionInfo.name);
+    assertThat(permissions.get(0).group).isEqualTo(permissionInfo.group);
+  }
+
+  @Test
+  public void getDefaultActivityIcon() {
+    assertThat(packageManager.getDefaultActivityIcon()).isNotNull();
+  }
+
+  @Test
+  public void addPackageShouldUseUidToProvidePackageName() {
+    PackageInfo packageInfoOne = new PackageInfo();
+    packageInfoOne.packageName = "package.one";
+    packageInfoOne.applicationInfo = new ApplicationInfo();
+    packageInfoOne.applicationInfo.uid = 1234;
+    packageInfoOne.applicationInfo.packageName = packageInfoOne.packageName;
+    shadowOf(packageManager).installPackage(packageInfoOne);
+
+    PackageInfo packageInfoTwo = new PackageInfo();
+    packageInfoTwo.packageName = "package.two";
+    packageInfoTwo.applicationInfo = new ApplicationInfo();
+    packageInfoTwo.applicationInfo.uid = 1234;
+    packageInfoTwo.applicationInfo.packageName = packageInfoTwo.packageName;
+    shadowOf(packageManager).installPackage(packageInfoTwo);
+
+    assertThat(packageManager.getPackagesForUid(1234))
+        .asList()
+        .containsExactly("package.one", "package.two");
+  }
+
+  @Test
+  public void installerPackageName() {
+    packageManager.setInstallerPackageName("target.package", "installer.package");
+
+    assertThat(packageManager.getInstallerPackageName("target.package"))
+        .isEqualTo("installer.package");
+  }
+
+  @Test
+  @GetInstallerPackageNameMode(Mode.LEGACY)
+  public void installerPackageName_notInstalledAndLegacySettings() {
+    String packageName = packageManager.getInstallerPackageName("target.package");
+    assertThat(packageName).isNull();
+  }
+
+  @Test
+  @GetInstallerPackageNameMode(Mode.REALISTIC)
+  public void installerPackageName_notInstalledAndRealisticSettings() {
+    try {
+      packageManager.getInstallerPackageName("target.package");
+      fail("Exception expected");
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("target.package");
+    }
+  }
+
+  @Test
+  @GetInstallerPackageNameMode(Mode.REALISTIC)
+  public void installerPackageName_uninstalledAndRealisticSettings() {
+    try {
+      packageManager.setInstallerPackageName(context.getPackageName(), "installer.pkg");
+      shadowOf(packageManager).deletePackage(context.getPackageName());
+      packageManager.getInstallerPackageName(context.getPackageName());
+      fail("Exception expected");
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains(context.getPackageName());
+    }
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void installerSourceInfo() throws Exception {
+    shadowOf(packageManager)
+        .setInstallSourceInfo("target.package", "initiating.package", "installing.package");
+
+    InstallSourceInfo info = packageManager.getInstallSourceInfo("target.package");
+    assertThat(info.getInitiatingPackageName()).isEqualTo("initiating.package");
+    assertThat(info.getInstallingPackageName()).isEqualTo("installing.package");
+  }
+
+  @Test
+  public void getXml() {
+    XmlResourceParser in =
+        packageManager.getXml(
+            context.getPackageName(), R.xml.dialog_preferences, context.getApplicationInfo());
+    assertThat(in).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void addPackageShouldNotCreateSessions() {
+
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "test.package";
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    assertThat(packageManager.getPackageInstaller().getAllSessions()).isEmpty();
+  }
+
+  @Test
+  public void installPackage_defaults() {
+    PackageInfo info = new PackageInfo();
+    info.packageName = "name";
+    info.activities = new ActivityInfo[] {new ActivityInfo()};
+
+    shadowOf(packageManager).installPackage(info);
+
+    PackageInfo installed = shadowOf(packageManager).getInternalMutablePackageInfo("name");
+    ActivityInfo activity = installed.activities[0];
+    assertThat(installed.applicationInfo).isNotNull();
+    assertThat(installed.applicationInfo.packageName).isEqualTo("name");
+    assertWithMessage("%s is installed", installed.applicationInfo)
+        .that((installed.applicationInfo.flags & ApplicationInfo.FLAG_INSTALLED) != 0)
+        .isTrue();
+    assertThat(activity.packageName).isEqualTo("name");
+    // this should be really equal in parcel sense as ApplicationInfo doesn't implement equals().
+    assertThat(activity.applicationInfo).isEqualTo(installed.applicationInfo);
+    assertThat(installed.applicationInfo.processName).isEqualTo("name");
+    assertThat(activity.name).isNotEmpty();
+  }
+
+  @Test
+  public void addPackageMultipleTimesShouldWork() {
+    shadowOf(packageManager).addPackage("test.package");
+
+    // Shouldn't throw exception
+    shadowOf(packageManager).addPackage("test.package");
+  }
+
+  @Test
+  public void addPackageSetsStorage() throws Exception {
+    shadowOf(packageManager).addPackage("test.package");
+
+    PackageInfo packageInfo = packageManager.getPackageInfo("test.package", 0);
+    assertThat(packageInfo.applicationInfo.sourceDir).isNotNull();
+    assertThat(new File(packageInfo.applicationInfo.sourceDir).exists()).isTrue();
+    assertThat(packageInfo.applicationInfo.publicSourceDir)
+        .isEqualTo(packageInfo.applicationInfo.sourceDir);
+  }
+
+  @Test
+  public void addComponent_noData() {
+    try {
+      shadowOf(packageManager).addOrUpdateActivity(new ActivityInfo());
+      fail();
+    } catch (IllegalArgumentException e) {
+      // should throw
+    }
+  }
+
+  @Test
+  public void addActivity() throws Exception {
+    ActivityInfo activityInfo = new ActivityInfo();
+    activityInfo.name = "name";
+    activityInfo.packageName = "package";
+
+    shadowOf(packageManager).addOrUpdateActivity(activityInfo);
+
+    assertThat(packageManager.getActivityInfo(new ComponentName("package", "name"), 0)).isNotNull();
+  }
+
+  @Test
+  public void addService() throws Exception {
+    ServiceInfo serviceInfo = new ServiceInfo();
+    serviceInfo.name = "name";
+    serviceInfo.packageName = "package";
+
+    shadowOf(packageManager).addOrUpdateService(serviceInfo);
+
+    assertThat(packageManager.getServiceInfo(new ComponentName("package", "name"), 0)).isNotNull();
+  }
+
+  @Test
+  public void addProvider() throws Exception {
+    ProviderInfo providerInfo = new ProviderInfo();
+    providerInfo.name = "name";
+    providerInfo.packageName = "package";
+
+    shadowOf(packageManager).addOrUpdateProvider(providerInfo);
+
+    assertThat(packageManager.getProviderInfo(new ComponentName("package", "name"), 0)).isNotNull();
+  }
+
+  @Test
+  public void addReceiver() throws Exception {
+    ActivityInfo receiverInfo = new ActivityInfo();
+    receiverInfo.name = "name";
+    receiverInfo.packageName = "package";
+
+    shadowOf(packageManager).addOrUpdateReceiver(receiverInfo);
+
+    assertThat(packageManager.getReceiverInfo(new ComponentName("package", "name"), 0)).isNotNull();
+  }
+
+  @Test
+  public void addActivity_addsNewPackage() throws Exception {
+    ActivityInfo activityInfo = new ActivityInfo();
+    activityInfo.name = "name";
+    activityInfo.packageName = "package";
+
+    shadowOf(packageManager).addOrUpdateActivity(activityInfo);
+    PackageInfo packageInfo =
+        packageManager.getPackageInfo("package", PackageManager.GET_ACTIVITIES);
+
+    assertThat(packageInfo.packageName).isEqualTo("package");
+    assertThat(packageInfo.applicationInfo.packageName).isEqualTo("package");
+    assertWithMessage("applicationInfo is installed")
+        .that((packageInfo.applicationInfo.flags & ApplicationInfo.FLAG_INSTALLED) != 0)
+        .isTrue();
+    assertThat(packageInfo.activities).hasLength(1);
+    ActivityInfo addedInfo = packageInfo.activities[0];
+    assertThat(addedInfo.name).isEqualTo("name");
+    assertThat(addedInfo.applicationInfo).isNotNull();
+    assertThat(addedInfo.applicationInfo.packageName).isEqualTo("package");
+  }
+
+  @Test
+  public void addActivity_usesExistingPackage() throws Exception {
+    String packageName = context.getPackageName();
+    int originalActivitiesCount =
+        packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES).activities.length;
+    ActivityInfo activityInfo = new ActivityInfo();
+    activityInfo.name = "name";
+    activityInfo.packageName = packageName;
+
+    shadowOf(packageManager).addOrUpdateActivity(activityInfo);
+    PackageInfo packageInfo =
+        packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES);
+
+    assertThat(packageInfo.activities).hasLength(originalActivitiesCount + 1);
+    ActivityInfo addedInfo = packageInfo.activities[originalActivitiesCount];
+    assertThat(addedInfo.name).isEqualTo("name");
+    assertThat(addedInfo.applicationInfo).isNotNull();
+    assertThat(addedInfo.applicationInfo.packageName).isEqualTo(packageName);
+  }
+
+  @Test
+  public void removeActivity() {
+    ComponentName componentName =
+        new ComponentName(context, "org.robolectric.shadows.TestActivity");
+
+    ActivityInfo removed = shadowOf(packageManager).removeActivity(componentName);
+
+    assertThat(removed).isNotNull();
+    try {
+      packageManager.getActivityInfo(componentName, 0);
+      // for now it goes here because package manager autocreates activities...
+      // fail();
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void removeService() {
+    ComponentName componentName = new ComponentName(context, "com.foo.Service");
+
+    ServiceInfo removed = shadowOf(packageManager).removeService(componentName);
+
+    assertThat(removed).isNotNull();
+    try {
+      packageManager.getServiceInfo(componentName, 0);
+      fail();
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void removeProvider() {
+    ComponentName componentName =
+        new ComponentName(context, "org.robolectric.shadows.testing.TestContentProvider1");
+
+    ProviderInfo removed = shadowOf(packageManager).removeProvider(componentName);
+
+    assertThat(removed).isNotNull();
+    try {
+      packageManager.getProviderInfo(componentName, 0);
+      fail();
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void removeReceiver() {
+    ComponentName componentName =
+        new ComponentName(context, "org.robolectric.fakes.ConfigTestReceiver");
+
+    ActivityInfo removed = shadowOf(packageManager).removeReceiver(componentName);
+
+    assertThat(removed).isNotNull();
+    try {
+      packageManager.getReceiverInfo(componentName, 0);
+      fail();
+    } catch (NameNotFoundException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void removeNonExistingComponent() {
+    ComponentName componentName = new ComponentName(context, "org.robolectric.DoesnExist");
+
+    ActivityInfo removed = shadowOf(packageManager).removeReceiver(componentName);
+
+    assertThat(removed).isNull();
+  }
+
+  @Test
+  public void deletePackage() throws Exception {
+    // Apps must have the android.permission.DELETE_PACKAGES set to delete packages.
+    PackageManager packageManager = context.getPackageManager();
+    shadowOf(packageManager)
+            .getInternalMutablePackageInfo(context.getPackageName())
+            .requestedPermissions =
+        new String[] {android.Manifest.permission.DELETE_PACKAGES};
+
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "test.package";
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    IPackageDeleteObserver mockObserver = mock(IPackageDeleteObserver.class);
+    packageManager.deletePackage(packageInfo.packageName, mockObserver, 0);
+
+    shadowOf(packageManager).doPendingUninstallCallbacks();
+
+    assertThat(shadowOf(packageManager).getDeletedPackages()).contains(packageInfo.packageName);
+    verify(mockObserver).packageDeleted(packageInfo.packageName, PackageManager.DELETE_SUCCEEDED);
+  }
+
+  @Test
+  public void deletePackage_missingRequiredPermission() throws Exception {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "test.package";
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    IPackageDeleteObserver mockObserver = mock(IPackageDeleteObserver.class);
+    packageManager.deletePackage(packageInfo.packageName, mockObserver, 0);
+
+    shadowOf(packageManager).doPendingUninstallCallbacks();
+
+    assertThat(shadowOf(packageManager).getDeletedPackages()).hasSize(0);
+    verify(mockObserver)
+        .packageDeleted(packageInfo.packageName, PackageManager.DELETE_FAILED_INTERNAL_ERROR);
+  }
+
+  private static class ActivityWithFilters extends Activity {}
+
+  @Test
+  public void getIntentFiltersForComponent() throws Exception {
+    List<IntentFilter> intentFilters =
+        shadowOf(packageManager)
+            .getIntentFiltersForActivity(new ComponentName(context, ActivityWithFilters.class));
+    assertThat(intentFilters).hasSize(1);
+    IntentFilter intentFilter = intentFilters.get(0);
+    assertThat(intentFilter.getCategory(0)).isEqualTo(Intent.CATEGORY_DEFAULT);
+    assertThat(intentFilter.getAction(0)).isEqualTo(Intent.ACTION_VIEW);
+    assertThat(intentFilter.getDataPath(0).getPath()).isEqualTo("/testPath/test.jpeg");
+  }
+
+  @Test
+  public void getPackageInfo_shouldHaveWritableDataDirs() throws Exception {
+    PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
+
+    File dataDir = new File(packageInfo.applicationInfo.dataDir);
+    assertThat(dataDir.isDirectory()).isTrue();
+    assertThat(dataDir.exists()).isTrue();
+  }
+
+  private static Function<PermissionInfo, String> getPermissionNames() {
+    return new Function<PermissionInfo, String>() {
+      @Nullable
+      @Override
+      public String apply(@Nullable PermissionInfo permissionInfo) {
+        return permissionInfo.name;
+      }
+    };
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getApplicationHiddenSettingAsUser_hidden() {
+    String packageName = context.getPackageName();
+
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    assertThat(packageManager.getApplicationHiddenSettingAsUser(packageName, /* user= */ null))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getApplicationHiddenSettingAsUser_notHidden() {
+    String packageName = context.getPackageName();
+
+    assertThat(packageManager.getApplicationHiddenSettingAsUser(packageName, /* user= */ null))
+        .isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getApplicationHiddenSettingAsUser_unknownPackage() {
+    assertThat(packageManager.getApplicationHiddenSettingAsUser("not.a.package", /* user= */ null))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setApplicationHiddenSettingAsUser_includeUninstalled() throws Exception {
+    String packageName = context.getPackageName();
+
+    packageManager.setApplicationHiddenSettingAsUser(
+        packageName, /* hidden= */ true, /* user= */ null);
+
+    assertThat(packageManager.getPackageInfo(packageName, MATCH_UNINSTALLED_PACKAGES)).isNotNull();
+    assertThat(packageManager.getApplicationInfo(packageName, MATCH_UNINSTALLED_PACKAGES))
+        .isNotNull();
+    List<PackageInfo> installedPackages =
+        packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES);
+    assertThat(installedPackages).hasSize(1);
+    assertThat(installedPackages.get(0).packageName).isEqualTo(packageName);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void setApplicationHiddenSettingAsUser_dontIncludeUninstalled() {
+    String packageName = context.getPackageName();
+
+    boolean success =
+        packageManager.setApplicationHiddenSettingAsUser(
+            packageName, /* hidden= */ true, /* user= */ null);
+
+    assertThat(success).isTrue();
+
+    try {
+      PackageInfo info = packageManager.getPackageInfo(packageName, /* flags= */ 0);
+      fail(
+          "PackageManager.NameNotFoundException not thrown. Returned app with flags: "
+              + info.applicationInfo.flags);
+    } catch (NameNotFoundException e) {
+      // Expected
+    }
+
+    try {
+      packageManager.getApplicationInfo(packageName, /* flags= */ 0);
+      fail("PackageManager.NameNotFoundException not thrown");
+    } catch (NameNotFoundException e) {
+      // Expected
+    }
+
+    assertThat(packageManager.getInstalledPackages(/* flags= */ 0)).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void setUnbadgedApplicationIcon() throws Exception {
+    String packageName = context.getPackageName();
+    Drawable d = new BitmapDrawable();
+
+    shadowOf(packageManager).setUnbadgedApplicationIcon(packageName, d);
+
+    assertThat(
+            packageManager
+                .getApplicationInfo(packageName, PackageManager.GET_SHARED_LIBRARY_FILES)
+                .loadUnbadgedIcon(packageManager))
+        .isSameInstanceAs(d);
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void isPackageSuspended_nonExistentPackage_shouldThrow() {
+    try {
+      packageManager.isPackageSuspended(TEST_PACKAGE_NAME);
+      fail("Should have thrown NameNotFoundException");
+    } catch (Exception expected) {
+      // The compiler thinks that isPackageSuspended doesn't throw NameNotFoundException because the
+      // test is compiled against the publicly released SDK.
+      assertThat(expected).isInstanceOf(NameNotFoundException.class);
+    }
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void isPackageSuspended_callersPackage_shouldReturnFalse() throws NameNotFoundException {
+    assertThat(packageManager.isPackageSuspended(context.getPackageName())).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void isPackageSuspended_installedNeverSuspendedPackage_shouldReturnFalse()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void isPackageSuspended_installedSuspendedPackage_shouldReturnTrue()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ true,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* dialogMessage= */ (String) null);
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void isPackageSuspended_installedSuspendedPackage_suspendDialogInfo_shouldReturnTrue()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    packageManager.setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ true,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void isPackageSuspended_installedUnsuspendedPackage_shouldReturnFalse()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ true,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* dialogMessage= */ (String) null);
+    setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ false,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* dialogMessage= */ (String) null);
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void isPackageSuspended_installedUnsuspendedPackage_suspendDialogInfo_shouldReturnFalse()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    packageManager.setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ true,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+    packageManager.setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ false,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void setPackagesSuspended_withProfileOwner_shouldThrow() {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    shadowOf(devicePolicyManager)
+        .setProfileOwner(new ComponentName("com.profile.owner", "ProfileOwnerClass"));
+    try {
+      setPackagesSuspended(
+          new String[] {TEST_PACKAGE_NAME},
+          /* suspended= */ true,
+          /* appExtras= */ null,
+          /* launcherExtras= */ null,
+          /* dialogMessage= */ (String) null);
+      fail("Should have thrown UnsupportedOperationException");
+    } catch (UnsupportedOperationException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void setPackagesSuspended_withProfileOwner_suspendDialogInfo_shouldThrow() {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    shadowOf(devicePolicyManager)
+        .setProfileOwner(new ComponentName("com.profile.owner", "ProfileOwnerClass"));
+    try {
+      packageManager.setPackagesSuspended(
+          new String[] {TEST_PACKAGE_NAME},
+          /* suspended= */ true,
+          /* appExtras= */ null,
+          /* launcherExtras= */ null,
+          /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+      fail("Should have thrown UnsupportedOperationException");
+    } catch (UnsupportedOperationException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void setPackagesSuspended_isProfileOwner_suspendDialogInfo() throws NameNotFoundException {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    String packageName = context.getPackageName();
+    ComponentName componentName =
+        new ComponentName(packageName, ActivityWithFilters.class.getName());
+    shadowOf(devicePolicyManager).setProfileOwner(componentName);
+
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    packageManager.setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ true,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void setPackagesSuspended_withDeviceOwner_shouldThrow() {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    shadowOf(devicePolicyManager)
+        .setDeviceOwner(new ComponentName("com.device.owner", "DeviceOwnerClass"));
+    // Robolectric uses a random UID (see ShadowProcess#getRandomApplicationUid) that falls within
+    // the range of the system user, so the device owner is on the current user and hence apps
+    // cannot be suspended.
+    try {
+      setPackagesSuspended(
+          new String[] {TEST_PACKAGE_NAME},
+          /* suspended= */ true,
+          /* appExtras= */ null,
+          /* launcherExtras= */ null,
+          /* dialogMessage= */ (String) null);
+      fail("Should have thrown UnsupportedOperationException");
+    } catch (UnsupportedOperationException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void setPackagesSuspended_withDeviceOwner_suspendDialogInfo_shouldThrow() {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    shadowOf(devicePolicyManager)
+        .setDeviceOwner(new ComponentName("com.device.owner", "DeviceOwnerClass"));
+    // Robolectric uses a random UID (see ShadowProcess#getRandomApplicationUid) that falls within
+    // the range of the system user, so the device owner is on the current user and hence apps
+    // cannot be suspended.
+    try {
+      packageManager.setPackagesSuspended(
+          new String[] {TEST_PACKAGE_NAME},
+          /* suspended= */ true,
+          /* appExtras= */ null,
+          /* launcherExtras= */ null,
+          /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+      fail("Should have thrown UnsupportedOperationException");
+    } catch (UnsupportedOperationException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void setPackagesSuspended_isDeviceOwner_suspendDialogInfo() throws NameNotFoundException {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+    String packageName = context.getPackageName();
+    ComponentName componentName =
+        new ComponentName(packageName, ActivityWithFilters.class.getName());
+    shadowOf(devicePolicyManager).setDeviceOwner(componentName);
+
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    packageManager.setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME},
+        /* suspended= */ true,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* suspendDialogInfo= */ (SuspendDialogInfo) null);
+    assertThat(packageManager.isPackageSuspended(TEST_PACKAGE_NAME)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void setPackagesSuspended_shouldSuspendSuspendablePackagesAndReturnTheRest()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName("android"));
+    shadowOf(packageManager)
+        .installPackage(createPackageInfoWithPackageName("com.suspendable.package1"));
+    shadowOf(packageManager)
+        .installPackage(createPackageInfoWithPackageName("com.suspendable.package2"));
+
+    assertThat(
+            setPackagesSuspended(
+                new String[] {
+                  "com.nonexistent.package", // Unsuspenable (app doesn't exist).
+                  "com.suspendable.package1",
+                  "android", // Unsuspendable (platform package).
+                  "com.suspendable.package2",
+                  context.getPackageName() // Unsuspendable (caller's package).
+                },
+                /* suspended= */ true,
+                /* appExtras= */ null,
+                /* launcherExtras= */ null,
+                /* dialogMessage= */ (String) null))
+        .asList()
+        .containsExactly("com.nonexistent.package", "android", context.getPackageName());
+
+    assertThat(packageManager.isPackageSuspended("com.suspendable.package1")).isTrue();
+    assertThat(packageManager.isPackageSuspended("android")).isFalse();
+    assertThat(packageManager.isPackageSuspended("com.suspendable.package2")).isTrue();
+    assertThat(packageManager.isPackageSuspended(context.getPackageName())).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void
+      setPackagesSuspended_suspendDialogInfo_shouldSuspendSuspendablePackagesAndReturnTheRest()
+          throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName("android"));
+    shadowOf(packageManager)
+        .installPackage(createPackageInfoWithPackageName("com.suspendable.package1"));
+    shadowOf(packageManager)
+        .installPackage(createPackageInfoWithPackageName("com.suspendable.package2"));
+
+    assertThat(
+            packageManager.setPackagesSuspended(
+                new String[] {
+                  "com.nonexistent.package", // Unsuspenable (app doesn't exist).
+                  "com.suspendable.package1",
+                  "android", // Unsuspendable (platform package).
+                  "com.suspendable.package2",
+                  context.getPackageName() // Unsuspendable (caller's package).
+                },
+                /* suspended= */ true,
+                /* appExtras= */ null,
+                /* launcherExtras= */ null,
+                /* suspendDialogInfo= */ (SuspendDialogInfo) null))
+        .asList()
+        .containsExactly("com.nonexistent.package", "android", context.getPackageName());
+
+    assertThat(packageManager.isPackageSuspended("com.suspendable.package1")).isTrue();
+    assertThat(packageManager.isPackageSuspended("android")).isFalse();
+    assertThat(packageManager.isPackageSuspended("com.suspendable.package2")).isTrue();
+    assertThat(packageManager.isPackageSuspended(context.getPackageName())).isFalse();
+  }
+
+  @Test(expected = SecurityException.class)
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void getUnsuspendablePackages_withoutSuspendAppsPermission_shouldThrow() {
+    shadowOf(ApplicationProvider.<Application>getApplicationContext())
+        .denyPermissions(SUSPEND_APPS);
+
+    packageManager.getUnsuspendablePackages(new String[] {TEST_PACKAGE_NAME});
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void getUnsuspendablePackages_allPackagesSuspendable_shouldReturnEmpty() {
+    shadowOf(ApplicationProvider.<Application>getApplicationContext())
+        .grantPermissions(SUSPEND_APPS);
+
+    assertThat(packageManager.getUnsuspendablePackages(new String[] {TEST_PACKAGE_NAME})).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void
+      getUnsuspendablePackages_somePackagesSuspendableAndSomeNot_shouldReturnUnsuspendablePackages() {
+    String dialerPackage = "dialer";
+    String platformPackage = "android";
+    shadowOf((Application) context).grantPermissions(SUSPEND_APPS);
+    shadowOf(context.getSystemService(TelecomManager.class)).setDefaultDialerPackage(dialerPackage);
+
+    assertThat(
+            packageManager.getUnsuspendablePackages(
+                new String[] {
+                  "some.suspendable.app",
+                  dialerPackage,
+                  "some.other.suspendable.app",
+                  platformPackage
+                }))
+        .asList()
+        .containsExactly(dialerPackage, platformPackage);
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.O)
+  public void getChangedPackages_negativeSequenceNumber_returnsNull() {
+    shadowOf(packageManager).addChangedPackage(-5, TEST_PACKAGE_NAME);
+
+    assertThat(packageManager.getChangedPackages(-5)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.O)
+  public void getChangedPackages_validSequenceNumber_withChangedPackages() {
+    shadowOf(packageManager).addChangedPackage(0, TEST_PACKAGE_NAME);
+    shadowOf(packageManager).addChangedPackage(0, TEST_PACKAGE2_NAME);
+    shadowOf(packageManager).addChangedPackage(1, "appPackageName");
+
+    ChangedPackages changedPackages = packageManager.getChangedPackages(0);
+    assertThat(changedPackages.getSequenceNumber()).isEqualTo(1);
+    assertThat(changedPackages.getPackageNames())
+        .containsExactly(TEST_PACKAGE_NAME, TEST_PACKAGE2_NAME);
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.O)
+  public void getChangedPackages_validSequenceNumber_noChangedPackages() {
+    assertThat(packageManager.getChangedPackages(0)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void setPackagesSuspended_shouldUnsuspendSuspendablePackagesAndReturnTheRest()
+      throws NameNotFoundException {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName("android"));
+    shadowOf(packageManager)
+        .installPackage(createPackageInfoWithPackageName("com.suspendable.package1"));
+    shadowOf(packageManager)
+        .installPackage(createPackageInfoWithPackageName("com.suspendable.package2"));
+    setPackagesSuspended(
+        new String[] {
+          "com.suspendable.package1", "com.suspendable.package2",
+        },
+        /* suspended= */ false,
+        /* appExtras= */ null,
+        /* launcherExtras= */ null,
+        /* dialogMessage= */ (String) null);
+
+    assertThat(
+            setPackagesSuspended(
+                new String[] {
+                  "com.nonexistent.package", // Unsuspenable (app doesn't exist).
+                  "com.suspendable.package1",
+                  "android", // Unsuspendable (platform package).
+                  "com.suspendable.package2",
+                  context.getPackageName() // Unsuspendable (caller's package).
+                },
+                /* suspended= */ false,
+                /* appExtras= */ null,
+                /* launcherExtras= */ null,
+                /* dialogMessage= */ (String) null))
+        .asList()
+        .containsExactly("com.nonexistent.package", "android", context.getPackageName());
+
+    assertThat(packageManager.isPackageSuspended("com.suspendable.package1")).isFalse();
+    assertThat(packageManager.isPackageSuspended("android")).isFalse();
+    assertThat(packageManager.isPackageSuspended("com.suspendable.package2")).isFalse();
+    assertThat(packageManager.isPackageSuspended(context.getPackageName())).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void getPackageSetting_nonExistentPackage_shouldReturnNull() {
+    assertThat(shadowOf(packageManager).getPackageSetting(TEST_PACKAGE_NAME)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void getPackageSetting_removedPackage_shouldReturnNull() {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    shadowOf(packageManager).removePackage(TEST_PACKAGE_NAME);
+
+    assertThat(shadowOf(packageManager).getPackageSetting(TEST_PACKAGE_NAME)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void getPackageSetting_installedNeverSuspendedPackage_shouldReturnUnsuspendedSetting() {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+
+    PackageSetting setting = shadowOf(packageManager).getPackageSetting(TEST_PACKAGE_NAME);
+
+    assertThat(setting.isSuspended()).isFalse();
+    assertThat(setting.getDialogMessage()).isNull();
+    assertThat(setting.getSuspendedAppExtras()).isNull();
+    assertThat(setting.getSuspendedLauncherExtras()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void getPackageSetting_installedSuspendedPackage_shouldReturnSuspendedSetting() {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    PersistableBundle appExtras = new PersistableBundle();
+    appExtras.putString("key", "value");
+    PersistableBundle launcherExtras = new PersistableBundle();
+    launcherExtras.putInt("number", 7);
+    setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME}, true, appExtras, launcherExtras, "Dialog message");
+
+    PackageSetting setting = shadowOf(packageManager).getPackageSetting(TEST_PACKAGE_NAME);
+
+    assertThat(setting.isSuspended()).isTrue();
+    assertThat(setting.getDialogMessage()).isEqualTo("Dialog message");
+    assertThat(setting.getSuspendedAppExtras().getString("key")).isEqualTo("value");
+    assertThat(setting.getSuspendedLauncherExtras().getInt("number")).isEqualTo(7);
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void
+      getPackageSetting_installedSuspendedPackage_dialogInfo_shouldReturnSuspendedSetting() {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    PersistableBundle appExtras = new PersistableBundle();
+    appExtras.putString("key", "value");
+    PersistableBundle launcherExtras = new PersistableBundle();
+    launcherExtras.putInt("number", 7);
+    SuspendDialogInfo suspendDialogInfo =
+        new SuspendDialogInfo.Builder()
+            .setIcon(R.drawable.an_image)
+            .setMessage("Dialog message")
+            .setTitle(R.string.greeting)
+            .setNeutralButtonText(R.string.copy)
+            .build();
+
+    packageManager.setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME}, true, appExtras, launcherExtras, suspendDialogInfo);
+
+    PackageSetting setting = shadowOf(packageManager).getPackageSetting(TEST_PACKAGE_NAME);
+
+    assertThat(setting.isSuspended()).isTrue();
+    assertThat(setting.getDialogMessage()).isNull();
+    assertThat(setting.getDialogInfo()).isEqualTo(suspendDialogInfo);
+    assertThat(setting.getSuspendedAppExtras().getString("key")).isEqualTo("value");
+    assertThat(setting.getSuspendedLauncherExtras().getInt("number")).isEqualTo(7);
+
+    ShadowSuspendDialogInfo shadowDialogInfo = Shadow.extract(setting.getDialogInfo());
+    assertThat(shadowDialogInfo.getDialogMessage()).isEqualTo("Dialog message");
+    assertThat(shadowDialogInfo.getIconResId()).isEqualTo(R.drawable.an_image);
+    assertThat(shadowDialogInfo.getTitleResId()).isEqualTo(R.string.greeting);
+    assertThat(shadowDialogInfo.getNeutralButtonTextResId()).isEqualTo(R.string.copy);
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.P)
+  public void getPackageSetting_installedUnsuspendedPackage_shouldReturnUnsuspendedSetting() {
+    shadowOf(packageManager).installPackage(createPackageInfoWithPackageName(TEST_PACKAGE_NAME));
+    PersistableBundle appExtras = new PersistableBundle();
+    appExtras.putString("key", "value");
+    PersistableBundle launcherExtras = new PersistableBundle();
+    launcherExtras.putInt("number", 7);
+    setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME}, true, appExtras, launcherExtras, "Dialog message");
+    setPackagesSuspended(
+        new String[] {TEST_PACKAGE_NAME}, false, appExtras, launcherExtras, "Dialog message");
+
+    PackageSetting setting = shadowOf(packageManager).getPackageSetting(TEST_PACKAGE_NAME);
+
+    assertThat(setting.isSuspended()).isFalse();
+    assertThat(setting.getDialogMessage()).isNull();
+    assertThat(setting.getSuspendedAppExtras()).isNull();
+    assertThat(setting.getSuspendedLauncherExtras()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.O)
+  public void canRequestPackageInstalls_shouldReturnFalseByDefault() {
+    assertThat(packageManager.canRequestPackageInstalls()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.O)
+  public void canRequestPackageInstalls_shouldReturnTrue_whenSetToTrue() {
+    shadowOf(packageManager).setCanRequestPackageInstalls(true);
+    assertThat(packageManager.canRequestPackageInstalls()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.O)
+  public void canRequestPackageInstalls_shouldReturnFalse_whenSetToFalse() {
+    shadowOf(packageManager).setCanRequestPackageInstalls(false);
+    assertThat(packageManager.canRequestPackageInstalls()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void getModule() throws Exception {
+    ModuleInfo sentModuleInfo =
+        ModuleInfoBuilder.newBuilder()
+            .setName("test.module.name")
+            .setPackageName("test.module.package.name")
+            .setHidden(false)
+            .build();
+    shadowOf(packageManager).installModule(sentModuleInfo);
+
+    ModuleInfo receivedModuleInfo =
+        packageManager.getModuleInfo(sentModuleInfo.getPackageName(), 0);
+    assertThat(receivedModuleInfo.getName().toString().contentEquals(sentModuleInfo.getName()))
+        .isTrue();
+    assertThat(receivedModuleInfo.getPackageName().equals(sentModuleInfo.getPackageName()))
+        .isTrue();
+    assertThat(receivedModuleInfo.isHidden()).isSameInstanceAs(sentModuleInfo.isHidden());
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void getInstalledModules() {
+    List<ModuleInfo> sentModuleInfos = new ArrayList<>();
+    sentModuleInfos.add(
+        ModuleInfoBuilder.newBuilder()
+            .setName("test.module.name.one")
+            .setPackageName("test.module.package.name.one")
+            .setHidden(false)
+            .build());
+    sentModuleInfos.add(
+        ModuleInfoBuilder.newBuilder()
+            .setName("test.module.name.two")
+            .setPackageName("test.module.package.name.two")
+            .setHidden(false)
+            .build());
+
+    for (ModuleInfo sentModuleInfo : sentModuleInfos) {
+      shadowOf(packageManager).installModule(sentModuleInfo);
+    }
+
+    List<ModuleInfo> receivedModuleInfos = packageManager.getInstalledModules(0);
+
+    for (int i = 0; i < receivedModuleInfos.size(); i++) {
+      assertThat(
+              receivedModuleInfos
+                  .get(i)
+                  .getName()
+                  .toString()
+                  .contentEquals(sentModuleInfos.get(i).getName()))
+          .isTrue();
+      assertThat(
+              receivedModuleInfos
+                  .get(i)
+                  .getPackageName()
+                  .equals(sentModuleInfos.get(i).getPackageName()))
+          .isTrue();
+      assertThat(receivedModuleInfos.get(i).isHidden())
+          .isSameInstanceAs(sentModuleInfos.get(i).isHidden());
+    }
+  }
+
+  @Test
+  @Config(minSdk = android.os.Build.VERSION_CODES.Q)
+  public void deleteModule() throws Exception {
+    ModuleInfo sentModuleInfo =
+        ModuleInfoBuilder.newBuilder()
+            .setName("test.module.name")
+            .setPackageName("test.module.package.name")
+            .setHidden(false)
+            .build();
+    shadowOf(packageManager).installModule(sentModuleInfo);
+
+    ModuleInfo receivedModuleInfo =
+        packageManager.getModuleInfo(sentModuleInfo.getPackageName(), 0);
+
+    assertThat(receivedModuleInfo.getPackageName().equals(sentModuleInfo.getPackageName()))
+        .isTrue();
+
+    ModuleInfo deletedModuleInfo =
+        (ModuleInfo) shadowOf(packageManager).deleteModule(sentModuleInfo.getPackageName());
+
+    assertThat(deletedModuleInfo.getName().toString().contentEquals(sentModuleInfo.getName()))
+        .isTrue();
+    assertThat(deletedModuleInfo.getPackageName().equals(sentModuleInfo.getPackageName())).isTrue();
+    assertThat(deletedModuleInfo.isHidden()).isSameInstanceAs(sentModuleInfo.isHidden());
+  }
+
+  @Test
+  public void loadIcon_default() {
+    ActivityInfo info = new ActivityInfo();
+    info.applicationInfo = new ApplicationInfo();
+    info.packageName = "testPackage";
+    info.name = "testName";
+
+    Drawable icon = info.loadIcon(packageManager);
+
+    assertThat(icon).isNotNull();
+  }
+
+  @Test
+  public void loadIcon_specified() {
+    ActivityInfo info = new ActivityInfo();
+    info.applicationInfo = new ApplicationInfo();
+    info.packageName = "testPackage";
+    info.name = "testName";
+    info.icon = R.drawable.an_image;
+
+    Drawable icon = info.loadIcon(packageManager);
+
+    assertThat(icon).isNotNull();
+  }
+
+  @Test
+  public void resolveInfoComparator() {
+    ResolveInfo priority = new ResolveInfo();
+    priority.priority = 100;
+    ResolveInfo preferredOrder = new ResolveInfo();
+    preferredOrder.preferredOrder = 100;
+    ResolveInfo defaultResolveInfo = new ResolveInfo();
+
+    ResolveInfo[] array = new ResolveInfo[] {priority, preferredOrder, defaultResolveInfo};
+    Arrays.sort(array, new ResolveInfoComparator());
+
+    assertThat(array)
+        .asList()
+        .containsExactly(preferredOrder, priority, defaultResolveInfo)
+        .inOrder();
+  }
+
+  private static PackageInfo createPackageInfoWithPackageName(String packageName) {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = packageName;
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.packageName = packageName;
+    packageInfo.applicationInfo.name = TEST_PACKAGE_LABEL;
+    return packageInfo;
+  }
+
+  @Test
+  public void addActivityIfNotPresent_newPackage() throws Exception {
+    ComponentName componentName = new ComponentName("test.package", "Activity");
+    shadowOf(packageManager).addActivityIfNotPresent(componentName);
+
+    ActivityInfo activityInfo = packageManager.getActivityInfo(componentName, 0);
+
+    assertThat(activityInfo).isNotNull();
+    assertThat(activityInfo.packageName).isEqualTo("test.package");
+    assertThat(activityInfo.name).isEqualTo("Activity");
+  }
+
+  @Test
+  public void addActivityIfNotPresent_existing() throws Exception {
+    String packageName = context.getPackageName();
+    ComponentName componentName =
+        new ComponentName(packageName, ActivityWithFilters.class.getName());
+    shadowOf(packageManager).addActivityIfNotPresent(componentName);
+
+    ActivityInfo activityInfo = packageManager.getActivityInfo(componentName, 0);
+
+    assertThat(activityInfo).isNotNull();
+    assertThat(activityInfo.packageName).isEqualTo(packageName);
+    assertThat(activityInfo.name).isEqualTo(ActivityWithFilters.class.getName());
+  }
+
+  @Test
+  public void addActivityIfNotPresent_newActivity() throws Exception {
+    String packageName = context.getPackageName();
+    ComponentName componentName = new ComponentName(packageName, "NewActivity");
+    shadowOf(packageManager).addActivityIfNotPresent(componentName);
+
+    ActivityInfo activityInfo = packageManager.getActivityInfo(componentName, 0);
+
+    assertThat(activityInfo).isNotNull();
+    assertThat(activityInfo.packageName).isEqualTo(packageName);
+    assertThat(activityInfo.name).isEqualTo("NewActivity");
+  }
+
+  @Test
+  public void setSafeMode() {
+    assertThat(packageManager.isSafeMode()).isFalse();
+
+    shadowOf(packageManager).setSafeMode(true);
+    assertThat(packageManager.isSafeMode()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setDistractingPackageRestrictions() {
+    assertThat(
+            packageManager.setDistractingPackageRestrictions(
+                new String[] {TEST_PACKAGE_NAME, TEST_PACKAGE2_NAME},
+                PackageManager.RESTRICTION_HIDE_FROM_SUGGESTIONS))
+        .isEmpty();
+
+    assertThat(shadowOf(packageManager).getDistractingPackageRestrictions(TEST_PACKAGE_NAME))
+        .isEqualTo(PackageManager.RESTRICTION_HIDE_FROM_SUGGESTIONS);
+    assertThat(shadowOf(packageManager).getDistractingPackageRestrictions(TEST_PACKAGE2_NAME))
+        .isEqualTo(PackageManager.RESTRICTION_HIDE_FROM_SUGGESTIONS);
+    assertThat(shadowOf(packageManager).getDistractingPackageRestrictions(TEST_PACKAGE3_NAME))
+        .isEqualTo(PackageManager.RESTRICTION_NONE);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getPackagesHoldingPermissions_returnPackages() {
+    String permissionA = "com.android.providers.permission.test.a";
+    String permissionB = "com.android.providers.permission.test.b";
+
+    PackageInfo packageInfoA = new PackageInfo();
+    packageInfoA.packageName = TEST_PACKAGE_NAME;
+    packageInfoA.applicationInfo = new ApplicationInfo();
+    packageInfoA.applicationInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfoA.requestedPermissions = new String[] {permissionA};
+
+    PackageInfo packageInfoB = new PackageInfo();
+    packageInfoB.packageName = TEST_PACKAGE2_NAME;
+    packageInfoB.applicationInfo = new ApplicationInfo();
+    packageInfoB.applicationInfo.packageName = TEST_PACKAGE2_NAME;
+    packageInfoB.requestedPermissions = new String[] {permissionB};
+
+    shadowOf(packageManager).installPackage(packageInfoA);
+    shadowOf(packageManager).installPackage(packageInfoB);
+
+    List<PackageInfo> result =
+        packageManager.getPackagesHoldingPermissions(new String[] {permissionA, permissionB}, 0);
+
+    assertThat(result).containsExactly(packageInfoA, packageInfoB);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getPackagesHoldingPermissions_returnsEmpty() {
+    String permissionA = "com.android.providers.permission.test.a";
+
+    PackageInfo packageInfoA = new PackageInfo();
+    packageInfoA.packageName = TEST_PACKAGE_NAME;
+    packageInfoA.applicationInfo = new ApplicationInfo();
+    packageInfoA.applicationInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfoA.requestedPermissions = new String[] {permissionA};
+
+    shadowOf(packageManager).installPackage(packageInfoA);
+
+    List<PackageInfo> result =
+        packageManager.getPackagesHoldingPermissions(
+            new String[] {"com.android.providers.permission.test.b"}, 0);
+
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isInstantApp() throws Exception {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo
+        .applicationInfo
+        .getClass()
+        .getDeclaredField("privateFlags")
+        .setInt(packageInfo.applicationInfo, /*ApplicationInfo.PRIVATE_FLAG_INSTANT*/ 1 << 7);
+
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    assertThat(packageManager.isInstantApp(TEST_PACKAGE_NAME)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isInstantApp_falseDefault() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo = new ApplicationInfo();
+    packageInfo.applicationInfo.packageName = TEST_PACKAGE_NAME;
+
+    shadowOf(packageManager).installPackage(packageInfo);
+
+    assertThat(packageManager.isInstantApp(TEST_PACKAGE_NAME)).isFalse();
+  }
+
+  @Test
+  public void getText_stringAdded_originalStringExists_returnsUserAddedString() {
+    shadowOf(packageManager)
+        .addStringResource(context.getPackageName(), R.string.hello, "fake hello");
+
+    assertThat(
+            packageManager
+                .getText(context.getPackageName(), R.string.hello, context.getApplicationInfo())
+                .toString())
+        .isEqualTo("fake hello");
+  }
+
+  @Test
+  public void getText_stringAdded_originalStringDoesNotExists_returnsUserAddedString() {
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE_NAME, 1, "package1 resId1");
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE_NAME, 2, "package1 resId2");
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE2_NAME, 1, "package2 resId1");
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE2_NAME, 3, "package2 resId3");
+
+    assertThat(packageManager.getText(TEST_PACKAGE_NAME, 1, new ApplicationInfo()).toString())
+        .isEqualTo("package1 resId1");
+    assertThat(packageManager.getText(TEST_PACKAGE_NAME, 2, new ApplicationInfo()).toString())
+        .isEqualTo("package1 resId2");
+    assertThat(packageManager.getText(TEST_PACKAGE2_NAME, 1, new ApplicationInfo()).toString())
+        .isEqualTo("package2 resId1");
+    assertThat(packageManager.getText(TEST_PACKAGE2_NAME, 3, new ApplicationInfo()).toString())
+        .isEqualTo("package2 resId3");
+  }
+
+  @Test
+  public void getText_stringAddedTwice_originalStringDoesNotExists_returnsNewlyUserAddedString() {
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE_NAME, 1, "package1 resId1");
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE_NAME, 1, "package1 resId2 new");
+
+    assertThat(packageManager.getText(TEST_PACKAGE_NAME, 1, new ApplicationInfo()).toString())
+        .isEqualTo("package1 resId2 new");
+  }
+
+  @Test
+  public void getText_stringNotAdded_originalStringExists_returnsOriginalText() {
+    shadowOf(packageManager).addStringResource(context.getPackageName(), 1, "fake");
+    shadowOf(packageManager).addStringResource(TEST_PACKAGE_NAME, R.string.hello, "fake hello");
+
+    assertThat(
+            packageManager
+                .getText(context.getPackageName(), R.string.hello, context.getApplicationInfo())
+                .toString())
+        .isEqualTo(context.getString(R.string.hello));
+  }
+
+  @Test
+  public void getText_stringNotAdded_originalStringDoesNotExists_returnsNull() {
+    assertThat(packageManager.getText(context.getPackageName(), 1, context.getApplicationInfo()))
+        .isNull();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void setAutoRevokeWhitelisted() {
+    assertThat(packageManager.isAutoRevokeWhitelisted()).isFalse();
+
+    shadowOf(packageManager).setAutoRevokeWhitelisted(true);
+    assertThat(packageManager.isAutoRevokeWhitelisted()).isTrue();
+  }
+
+  @Test
+  public void hasSystemFeature_default() {
+    for (String feature : SystemFeatureListInitializer.getSystemFeatures().keySet()) {
+      assertThat(packageManager.hasSystemFeature(feature)).isTrue();
+    }
+  }
+
+  @Test
+  public void reset_setsSystemFeatureListToDefaults() {
+    shadowOf(packageManager).setSystemFeature(PackageManager.FEATURE_CAMERA, true);
+    ShadowPackageManager.reset();
+    for (String feature : SystemFeatureListInitializer.getSystemFeatures().keySet()) {
+      assertThat(packageManager.hasSystemFeature(feature)).isTrue();
+    }
+    assertThat(packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)).isFalse();
+  }
+
+  public String[] setPackagesSuspended(
+      String[] packageNames,
+      boolean suspended,
+      PersistableBundle appExtras,
+      PersistableBundle launcherExtras,
+      String dialogMessage) {
+    return packageManager.setPackagesSuspended(
+        packageNames, suspended, appExtras, launcherExtras, dialogMessage);
+  }
+
+  private PackageInfo generateTestPackageInfo() {
+    ApplicationInfo appInfo = new ApplicationInfo();
+    appInfo.flags = ApplicationInfo.FLAG_INSTALLED;
+    appInfo.packageName = TEST_PACKAGE_NAME;
+    appInfo.sourceDir = TEST_APP_PATH;
+    appInfo.name = TEST_PACKAGE_LABEL;
+
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = TEST_PACKAGE_NAME;
+    packageInfo.applicationInfo = appInfo;
+    packageInfo.versionCode = TEST_PACKAGE_VERSION_CODE;
+    return packageInfo;
+  }
+
+  private void verifyTestPackageInfo(PackageInfo packageInfo) {
+    assertThat(packageInfo).isNotNull();
+    assertThat(packageInfo.versionCode).isEqualTo(TEST_PACKAGE_VERSION_CODE);
+    ApplicationInfo applicationInfo = packageInfo.applicationInfo;
+    assertThat(applicationInfo).isInstanceOf(ApplicationInfo.class);
+    assertThat(applicationInfo.packageName).isEqualTo(TEST_PACKAGE_NAME);
+    assertThat(applicationInfo.sourceDir).isEqualTo(TEST_APP_PATH);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPaintTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPaintTest.java
new file mode 100644
index 0000000..6ae57f9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPaintTest.java
@@ -0,0 +1,139 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.Color;
+import android.graphics.Paint;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPaintTest {
+
+  @Test
+  public void shouldGetIsDitherInfo() {
+    Paint paint = new Paint(0);
+    assertFalse(paint.isAntiAlias());
+    ShadowPaint shadowPaint = shadowOf(paint);
+    shadowPaint.setAntiAlias(true);
+    assertTrue(paint.isAntiAlias());
+  }
+
+  @Test
+  public void shouldGetIsAntiAlias() {
+    Paint paint = new Paint(0);
+    assertFalse(paint.isAntiAlias());
+    ShadowPaint shadowPaint = shadowOf(paint);
+    shadowPaint.setAntiAlias(true);
+    assertTrue(paint.isAntiAlias());
+    shadowPaint.setAntiAlias(false);
+    assertFalse(paint.isAntiAlias());
+  }
+
+  @Test
+  public void testCtor() {
+    assertThat(new Paint(Paint.ANTI_ALIAS_FLAG).isAntiAlias()).isTrue();
+    assertThat(new Paint(0).isAntiAlias()).isFalse();
+  }
+
+  @Test
+  public void testCtorWithPaint() {
+    Paint paint = new Paint();
+    paint.setColor(Color.RED);
+    paint.setAlpha(72);
+    paint.setFlags(2345);
+
+    Paint other = new Paint(paint);
+    assertThat(other.getColor()).isEqualTo(Color.RED);
+    assertThat(other.getAlpha()).isEqualTo(72);
+    assertThat(other.getFlags()).isEqualTo(2345);
+  }
+
+  @Test
+  public void shouldGetAndSetTextAlignment() {
+    Paint paint = new Paint();
+    assertThat(paint.getTextAlign()).isEqualTo(Paint.Align.LEFT);
+    paint.setTextAlign(Paint.Align.CENTER);
+    assertThat(paint.getTextAlign()).isEqualTo(Paint.Align.CENTER);
+  }
+
+  @Test
+  public void shouldSetUnderlineText() {
+    Paint paint = new Paint();
+    paint.setUnderlineText(true);
+    assertThat(paint.isUnderlineText()).isTrue();
+    paint.setUnderlineText(false);
+    assertThat(paint.isUnderlineText()).isFalse();
+  }
+
+  @Test
+  public void measureTextActuallyMeasuresLength() {
+    Paint paint = new Paint();
+    assertThat(paint.measureText("Hello")).isEqualTo(5.0f);
+    assertThat(paint.measureText("Hello", 1, 3)).isEqualTo(2.0f);
+    assertThat(paint.measureText(new StringBuilder("Hello"), 1, 4)).isEqualTo(3.0f);
+  }
+
+  @Test
+  public void measureTextUsesTextScaleX() {
+    Paint paint = new Paint();
+    paint.setTextScaleX(1.5f);
+    assertThat(paint.measureText("Hello")).isEqualTo(7.5f);
+    assertThat(paint.measureText("Hello", 1, 3)).isEqualTo(3.0f);
+    assertThat(paint.measureText(new StringBuilder("Hello"), 1, 4)).isEqualTo(4.5f);
+  }
+
+  @Test
+  public void textWidthWithNegativeScaleXIsZero() {
+    Paint paint = new Paint();
+    paint.setTextScaleX(-1.5f);
+    assertThat(paint.measureText("Hello")).isEqualTo(0f);
+    assertThat(paint.measureText("Hello", 1, 3)).isEqualTo(0f);
+    assertThat(paint.measureText(new StringBuilder("Hello"), 1, 4)).isEqualTo(0f);
+  }
+
+  @Test
+  public void createPaintFromPaint() {
+    Paint origPaint = new Paint();
+    assertThat(new Paint(origPaint).getTextLocale()).isSameInstanceAs(origPaint.getTextLocale());
+  }
+
+  @Test
+  public void breakTextReturnsNonZeroResult() {
+    Paint paint = new Paint();
+    assertThat(
+            paint.breakText(
+                new char[] {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd'},
+                /*index=*/ 0,
+                /*count=*/ 11,
+                /*maxWidth=*/ 100,
+                /*measuredWidth=*/ null))
+        .isGreaterThan(0);
+    assertThat(
+            paint.breakText(
+                "Hello World",
+                /*start=*/ 0,
+                /*end=*/ 11,
+                /*measureForwards=*/ true,
+                /*maxWidth=*/ 100,
+                /*measuredWidth=*/ null))
+        .isGreaterThan(0);
+    assertThat(
+            paint.breakText(
+                "Hello World",
+                /*measureForwards=*/ true,
+                /*maxWidth=*/ 100,
+                /*measuredWidth=*/ null))
+        .isGreaterThan(0);
+  }
+
+  @Test
+  public void defaultTextScaleXIsOne() {
+    Paint paint = new Paint();
+    assertThat(paint.getTextScaleX()).isEqualTo(1f);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPairTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPairTest.java
new file mode 100644
index 0000000..68e0814
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPairTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.util.Pair;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPairTest {
+
+  @Test
+  public void testConstructor() {
+    Pair<String, Integer> pair = new Pair<>("a", 1);
+    assertThat(pair.first).isEqualTo("a");
+    assertThat(pair.second).isEqualTo(1);
+  }
+
+  @Test
+  public void testStaticCreate() {
+    Pair<String, String> p = Pair.create("Foo", "Bar");
+    assertThat(p.first).isEqualTo("Foo");
+    assertThat(p.second).isEqualTo("Bar");
+  }
+
+  @Test
+  public void testEquals() {
+    assertThat(Pair.create("1", 2)).isEqualTo(Pair.create("1", 2));
+  }
+
+  @Test
+  public void testHash() {
+    assertThat(Pair.create("1", 2).hashCode()).isEqualTo(Pair.create("1", 2).hashCode());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowParcelFileDescriptorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowParcelFileDescriptorTest.java
new file mode 100644
index 0000000..d61a565
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowParcelFileDescriptorTest.java
@@ -0,0 +1,347 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.ParcelFileDescriptor;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.hamcrest.Matchers;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowParcelFileDescriptorTest {
+
+  private static final int READ_ONLY_FILE_CONTENTS = 42;
+
+  private File file;
+  private File readOnlyFile;
+  private ParcelFileDescriptor pfd;
+
+  @Before
+  public void setUp() throws Exception {
+    file = new File(ApplicationProvider.getApplicationContext().getFilesDir(), "test");
+    FileOutputStream os = new FileOutputStream(file);
+    os.close();
+    readOnlyFile =
+        new File(ApplicationProvider.getApplicationContext().getFilesDir(), "test_readonly");
+    os = new FileOutputStream(readOnlyFile);
+    os.write(READ_ONLY_FILE_CONTENTS);
+    os.close();
+    assertThat(readOnlyFile.setReadOnly()).isTrue();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (pfd != null) {
+      pfd.close();
+    }
+  }
+
+  @Test
+  public void testOpens() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+  }
+
+  @Test
+  public void testOpens_canReadReadOnlyFile() throws Exception {
+    pfd = ParcelFileDescriptor.open(readOnlyFile, ParcelFileDescriptor.MODE_READ_ONLY);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    FileInputStream is = new FileInputStream(pfd.getFileDescriptor());
+    assertThat(is.read()).isEqualTo(READ_ONLY_FILE_CONTENTS);
+  }
+
+  @Test
+  public void testOpens_canWriteWritableFile() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    FileOutputStream os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(5);
+    os.close();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void testOpenWithOnCloseListener_nullHandler() throws Exception {
+    final AtomicBoolean onCloseCalled = new AtomicBoolean(false);
+    ParcelFileDescriptor.OnCloseListener onCloseListener =
+        new ParcelFileDescriptor.OnCloseListener() {
+          @Override
+          public void onClose(IOException e) {
+            onCloseCalled.set(true);
+          }
+        };
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ParcelFileDescriptor.open(
+                file, ParcelFileDescriptor.MODE_READ_WRITE, null, onCloseListener));
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void testOpenWithOnCloseListener_nullOnCloseListener() throws Exception {
+    HandlerThread handlerThread = new HandlerThread("test");
+    handlerThread.start();
+    Handler handler = new Handler(handlerThread.getLooper());
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE, handler, null));
+    handlerThread.quit();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void testOpenWithOnCloseListener_callsListenerOnClose() throws Exception {
+    HandlerThread handlerThread = new HandlerThread("test");
+    handlerThread.start();
+    Handler handler = new Handler(handlerThread.getLooper());
+    final AtomicBoolean onCloseCalled = new AtomicBoolean(false);
+    ParcelFileDescriptor.OnCloseListener onCloseListener =
+        new ParcelFileDescriptor.OnCloseListener() {
+          @Override
+          public void onClose(IOException e) {
+            onCloseCalled.set(true);
+          }
+        };
+    pfd =
+        ParcelFileDescriptor.open(
+            file, ParcelFileDescriptor.MODE_READ_WRITE, handler, onCloseListener);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    pfd.close();
+    shadowOf(handlerThread.getLooper()).idle();
+    assertThat(onCloseCalled.get()).isTrue();
+    handlerThread.quit();
+  }
+
+  @Test
+  public void testStatSize_emptyFile() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    assertThat(pfd.getStatSize()).isEqualTo(0);
+  }
+
+  @Test
+  public void testStatSize_writtenFile() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    FileOutputStream os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(5);
+    assertThat(pfd.getStatSize()).isEqualTo(1); // One byte.
+    os.close();
+  }
+
+  @Test
+  public void testAppend() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    FileOutputStream os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(5);
+    assertThat(pfd.getStatSize()).isEqualTo(1); // One byte.
+    os.close();
+    pfd.close();
+
+    pfd =
+        ParcelFileDescriptor.open(
+            file, ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_APPEND);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(5);
+    assertThat(pfd.getStatSize()).isEqualTo(2); // Two bytes.
+    os.close();
+  }
+
+  @Test
+  public void testTruncate() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    FileOutputStream os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(1);
+    os.write(2);
+    os.write(3);
+    assertThat(pfd.getStatSize()).isEqualTo(3); // Three bytes.
+    os.close();
+
+    try (FileInputStream in = new FileInputStream(file)) {
+      byte[] buffer = new byte[3];
+      assertThat(in.read(buffer)).isEqualTo(3);
+      assertThat(buffer).isEqualTo(new byte[] {1, 2, 3});
+      assertThat(in.available()).isEqualTo(0);
+    }
+
+    pfd.close();
+    pfd =
+        ParcelFileDescriptor.open(
+            file, ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_TRUNCATE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(4);
+    assertThat(pfd.getStatSize()).isEqualTo(1); // One byte.
+    os.close();
+
+    try (FileInputStream in = new FileInputStream(file)) {
+      assertThat(in.read()).isEqualTo(4);
+      assertThat(in.available()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void testWriteTwiceNoTruncate() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    FileOutputStream os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(1);
+    os.write(2);
+    os.write(3);
+    assertThat(pfd.getStatSize()).isEqualTo(3); // Three bytes.
+    os.close();
+
+    try (FileInputStream in = new FileInputStream(file)) {
+      byte[] buffer = new byte[3];
+      assertThat(in.read(buffer)).isEqualTo(3);
+      assertThat(buffer).isEqualTo(new byte[] {1, 2, 3});
+      assertThat(in.available()).isEqualTo(0);
+    }
+    pfd.close();
+
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+    os = new FileOutputStream(pfd.getFileDescriptor());
+    os.write(4);
+    assertThat(pfd.getStatSize()).isEqualTo(3); // One byte.
+    os.close();
+
+    try (FileInputStream in = new FileInputStream(file)) {
+      byte[] buffer = new byte[3];
+      assertThat(in.read(buffer)).isEqualTo(3);
+      assertThat(buffer).isEqualTo(new byte[] {4, 2, 3});
+      assertThat(in.available()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void testCloses() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, -1);
+    pfd.close();
+    assertThat(pfd.getFileDescriptor().valid()).isFalse();
+  }
+
+  @Test
+  public void testClose_twice() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, -1);
+    pfd.close();
+    assertThat(pfd.getFileDescriptor().valid()).isFalse();
+
+    pfd.close();
+    assertThat(pfd.getFileDescriptor().valid()).isFalse();
+  }
+
+  @Test
+  public void testCloses_getStatSize_returnsInvalidLength() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, -1);
+    pfd.close();
+    assertThat(pfd.getStatSize()).isEqualTo(-1);
+  }
+
+  @Test
+  public void testAutoCloseInputStream() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, -1);
+    ParcelFileDescriptor.AutoCloseInputStream is =
+        new ParcelFileDescriptor.AutoCloseInputStream(pfd);
+    is.close();
+    assertThat(pfd.getFileDescriptor().valid()).isFalse();
+  }
+
+  @Test
+  public void testAutoCloseOutputStream() throws Exception {
+    File f = new File(ApplicationProvider.getApplicationContext().getFilesDir(), "outfile");
+    ParcelFileDescriptor pfd = ParcelFileDescriptor.open(f, -1);
+    ParcelFileDescriptor.AutoCloseOutputStream os =
+        new ParcelFileDescriptor.AutoCloseOutputStream(pfd);
+    os.close();
+    assertThat(pfd.getFileDescriptor().valid()).isFalse();
+  }
+
+  @Test
+  public void testCreatePipe() throws IOException {
+    ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
+    ParcelFileDescriptor readSide = pipe[0];
+    ParcelFileDescriptor writeSide = pipe[1];
+    byte[] dataToWrite = new byte[] {0, 1, 2, 3, 4};
+    ParcelFileDescriptor.AutoCloseInputStream inputStream =
+        new ParcelFileDescriptor.AutoCloseInputStream(readSide);
+    ParcelFileDescriptor.AutoCloseOutputStream outputStream =
+        new ParcelFileDescriptor.AutoCloseOutputStream(writeSide);
+    outputStream.write(dataToWrite);
+    byte[] read = new byte[dataToWrite.length];
+    int byteCount = inputStream.read(read);
+    inputStream.close();
+    outputStream.close();
+    assertThat(byteCount).isEqualTo(dataToWrite.length);
+    assertThat(read).isEqualTo(dataToWrite);
+  }
+
+  @Test
+  public void testCreatePipeTwice() throws IOException {
+    testCreatePipe();
+    testCreatePipe();
+  }
+
+  @Test
+  public void testGetFd_canRead() throws IOException {
+    assumeThat("Windows is an affront to decency.",
+        File.separator, Matchers.equalTo("/"));
+
+    pfd = ParcelFileDescriptor.open(readOnlyFile, ParcelFileDescriptor.MODE_READ_ONLY);
+    int fd = pfd.getFd();
+    assertThat(fd).isGreaterThan(0);
+
+    final FileDescriptor fileDescriptor = pfd.getFileDescriptor();
+    assertThat(fileDescriptor.valid()).isTrue();
+    assertThat(fd).isEqualTo(ReflectionHelpers.getField(fileDescriptor, "fd"));
+
+    FileInputStream is = new FileInputStream(fileDescriptor);
+    assertThat(is.read()).isEqualTo(READ_ONLY_FILE_CONTENTS);
+    is.close();
+  }
+
+  @Test
+  public void testGetFd_alreadyClosed() throws Exception {
+    pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    assertThat(pfd).isNotNull();
+    assertThat(pfd.getFileDescriptor().valid()).isTrue();
+
+    pfd.close();
+
+    assertThrows(IllegalStateException.class, () -> pfd.getFd());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowParcelTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowParcelTest.java
new file mode 100644
index 0000000..dd01e26
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowParcelTest.java
@@ -0,0 +1,1394 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.accounts.Account;
+import android.content.Intent;
+import android.os.BadParcelableException;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.Parcel;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowParcel.UnreliableBehaviorError;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowParcelTest {
+
+  private Parcel parcel;
+
+  @Before
+  public void setup() {
+    parcel = Parcel.obtain();
+  }
+
+  @After
+  public void tearDown() {
+    parcel.recycle();
+  }
+
+  @Test
+  public void testObtain() {
+    assertThat(parcel).isNotNull();
+  }
+
+  @Test
+  public void testReadIntWhenEmpty() {
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertThat(parcel.dataPosition()).isEqualTo(0);
+    assertInvariants();
+  }
+
+  @Test
+  public void testReadLongWhenEmpty() {
+    assertThat(parcel.readLong()).isEqualTo(0);
+    assertThat(parcel.dataPosition()).isEqualTo(0);
+    assertInvariants();
+  }
+
+  @Test
+  public void testReadStringWhenEmpty() {
+    assertThat(parcel.readString()).isNull();
+    assertInvariants();
+  }
+
+  @Test
+  public void testReadStrongBinderWhenEmpty() {
+    assertThat(parcel.readStrongBinder()).isNull();
+  }
+
+  @Test
+  public void testReadWriteNumbers() {
+    parcel.writeInt(Integer.MIN_VALUE);
+    assertThat(parcel.dataSize()).isEqualTo(4);
+    parcel.writeLong(Long.MAX_VALUE);
+    assertThat(parcel.dataSize()).isEqualTo(12);
+    double d = 3.14159;
+    parcel.writeDouble(d);
+    assertThat(parcel.dataSize()).isEqualTo(20);
+    float f = -6.022e23f;
+    parcel.writeFloat(f);
+    assertThat(parcel.dataSize()).isEqualTo(24);
+    assertInvariants();
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readInt()).isEqualTo(Integer.MIN_VALUE);
+    assertThat(parcel.dataPosition()).isEqualTo(4);
+    assertThat(parcel.readLong()).isEqualTo(Long.MAX_VALUE);
+    assertThat(parcel.dataPosition()).isEqualTo(12);
+    assertThat(parcel.readDouble()).isEqualTo(d);
+    assertThat(parcel.dataPosition()).isEqualTo(20);
+    assertThat(parcel.readFloat()).isEqualTo(f);
+    assertThat(parcel.dataPosition()).isEqualTo(24);
+    assertWithMessage("read past end is valid").that(parcel.readInt()).isEqualTo(0);
+    assertThat(parcel.dataPosition()).isEqualTo(24);
+    assertInvariants();
+  }
+
+  @Test
+  public void testReadWriteSingleStringEvenLength() {
+    String val = "test";
+    parcel.writeString(val);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isEqualTo(val);
+    assertWithMessage("4B length + 4*2B data + 2B null char + 2B padding")
+        .that(parcel.dataSize())
+        .isEqualTo(16);
+  }
+
+  @Test
+  public void testReadWriteLongerStringOddLength() {
+    String val = "0123456789abcde";
+    parcel.writeString(val);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isEqualTo(val);
+    assertWithMessage("4B length + 15*2B data + 2B null char")
+        .that(parcel.dataSize())
+        .isEqualTo(36);
+  }
+
+  @Test
+  public void testWriteNullString() {
+    parcel.writeString(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isNull();
+    assertThat(parcel.dataPosition()).isEqualTo(4);
+  }
+
+  @Test
+  public void testWriteEmptyString() {
+    parcel.writeString("");
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isEmpty();
+    assertWithMessage("4B length + 2B null char + 2B padding").that(parcel.dataSize()).isEqualTo(8);
+  }
+
+  @Test
+  public void testReadWriteMultipleStrings() {
+    for (int i = 0; i < 10; ++i) {
+      parcel.writeString(Integer.toString(i));
+      assertInvariants();
+    }
+    parcel.setDataPosition(0);
+    for (int i = 0; i < 10; ++i) {
+      assertThat(parcel.readString()).isEqualTo(Integer.toString(i));
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readString()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testReadWriteSingleStrongBinder() {
+    IBinder binder = new Binder();
+    parcel.writeStrongBinder(binder);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readStrongBinder()).isEqualTo(binder);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testWriteNullStrongBinder() {
+    parcel.writeStrongBinder(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readStrongBinder()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void testReadWriteMultipleStrongBinders() {
+    List<IBinder> binders = new ArrayList<>();
+    for (int i = 0; i < 10; ++i) {
+      IBinder binder = new Binder();
+      binders.add(binder);
+      parcel.writeStrongBinder(binder);
+    }
+    parcel.setDataPosition(0);
+    for (int i = 0; i < 10; ++i) {
+      assertThat(parcel.readStrongBinder()).isEqualTo(binders.get(i));
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readStrongBinder()).isNull();
+  }
+
+  @Test
+  public void testReadWriteSingleInt() {
+    int val = 5;
+    parcel.writeInt(val);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readInt()).isEqualTo(val);
+  }
+
+  @Test
+  public void testFullyOverwritten() {
+    parcel.writeInt(1);
+    // NOTE: Later, this 8-byte long gets chopped up by two 4-byte writes, but it's OK because this
+    // byte range is not read until it has been fully overwritten.
+    parcel.writeLong(5);
+    parcel.writeInt(4);
+    assertInvariants();
+
+    parcel.setDataPosition(4);
+    parcel.writeByte((byte) 55); // Byte and int have the parceled size.
+    parcel.writeString(null); // And so does a null string.
+    assertInvariants();
+
+    parcel.setDataPosition(0);
+    assertWithMessage("readInt@0").that(parcel.readInt()).isEqualTo(1);
+    assertWithMessage("position post-readInt@0").that(parcel.dataPosition()).isEqualTo(4);
+    assertWithMessage("readByte@4").that(parcel.readByte()).isEqualTo(55);
+    assertWithMessage("position post-readByte@4").that(parcel.dataPosition()).isEqualTo(8);
+    assertWithMessage("readString@8").that(parcel.readString()).isNull();
+    assertWithMessage("position post-readString@8").that(parcel.dataPosition()).isEqualTo(12);
+    assertWithMessage("readInt@12").that(parcel.readInt()).isEqualTo(4);
+  }
+
+  @Test
+  public void testReadWithoutRewinding() {
+    parcel.writeInt(123);
+    try {
+      parcel.readInt();
+      fail("should have thrown");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Did you forget to setDataPosition(0) before reading the parcel?");
+    }
+  }
+
+  @Test
+  public void testWriteThenReadIsOkIfNotAtEnd() {
+    parcel.writeInt(1);
+    parcel.writeInt(2);
+    parcel.writeInt(3);
+    parcel.writeInt(4);
+    parcel.setDataPosition(0);
+    parcel.writeInt(5);
+    assertThat(parcel.readInt()).isEqualTo(2);
+    assertThat(parcel.readInt()).isEqualTo(3);
+    assertThat(parcel.readInt()).isEqualTo(4);
+    // This should succeed: while this is weird, the caller didn't clearly forget to reset the data
+    // position, and is reading past the end of the parcel in a normal way.
+    assertThat(parcel.readInt()).isEqualTo(0);
+  }
+
+  @Test
+  public void testInvalidReadFromMiddleOfObject() {
+    parcel.writeLong(111L);
+    parcel.writeLong(222L);
+    parcel.setDataPosition(0);
+
+    parcel.setDataPosition(4);
+    try {
+      parcel.readInt();
+      fail("should have thrown UnreliableBehaviorError");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for Integer at position 4, found Long [111] taking 8 bytes, but "
+                  + "[222] interrupts it at position 8");
+    }
+  }
+
+  @Test
+  public void testInvalidReadFromOverwrittenObject() {
+    parcel.writeString("hello all");
+    parcel.setDataPosition(4);
+    parcel.writeInt(5);
+    parcel.setDataPosition(0);
+
+    try {
+      parcel.readString();
+      fail("should have thrown UnreliableBehaviorError");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for String at position 0, found String [hello all] taking 24 bytes, but "
+                  + "[5] interrupts it at position 4");
+    }
+  }
+
+  @Test
+  public void testZeroCanBeCasted_4ByteTypesCanBeReadAs8Bytes() {
+    parcel.writeByte((byte) 0);
+    parcel.writeByte((byte) 0);
+    parcel.writeInt(0);
+    parcel.writeInt(0);
+    parcel.writeFloat(0.0f);
+    parcel.writeByteArray(new byte[0]);
+    assertWithMessage("total size").that(parcel.dataSize()).isEqualTo(24);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readLong()).isEqualTo(0L);
+    assertWithMessage("long consumes 8B").that(parcel.dataPosition()).isEqualTo(8);
+    assertThat(parcel.readDouble()).isEqualTo(0.0);
+    assertWithMessage("double consumes 8B").that(parcel.dataPosition()).isEqualTo(16);
+    assertThat(parcel.readString()).isEqualTo("");
+    assertWithMessage("empty string 8B").that(parcel.dataPosition()).isEqualTo(24);
+  }
+
+  @Test
+  public void testZeroCanBeCasted_8ByteTypesCanBeReadAs4Bytes() {
+    parcel.writeLong(0);
+    parcel.writeDouble(0.0);
+    parcel.writeLong(0);
+    assertWithMessage("total size").that(parcel.dataSize()).isEqualTo(24);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertThat(parcel.readFloat()).isEqualTo(0.0f);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[0]);
+    assertThat(parcel.dataPosition()).isEqualTo(12);
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertThat(parcel.readFloat()).isEqualTo(0.0f);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[0]);
+    assertThat(parcel.dataPosition()).isEqualTo(24);
+  }
+
+  @Test
+  public void testZeroCanBeCasted_overwrittenValuesAreOk() {
+    parcel.writeByteArray(new byte[8]);
+    assertThat(parcel.dataPosition()).isEqualTo(12);
+    parcel.writeDouble(0.0);
+    assertThat(parcel.dataPosition()).isEqualTo(20);
+    parcel.writeLong(0);
+    parcel.setDataPosition(8);
+    parcel.writeInt(0); // Overwrite the second half of the byte array.
+    parcel.setDataPosition(16);
+    parcel.writeLong(0); // Overwrite the second half of the double and first half of the long.
+    parcel.setDataPosition(20);
+    parcel.writeInt(0); // And overwrite the second half of *that* with an int.
+    assertWithMessage("total size").that(parcel.dataSize()).isEqualTo(28);
+
+    parcel.setDataPosition(0);
+    assertWithMessage("initial array length").that(parcel.readInt()).isEqualTo(8);
+    // After this, we are reading all zeroes.  If we just read them as regular old types, it would
+    // yield errors, but the special-casing for zeroes addresses this.  Make sure each data type
+    // consumes the correct number of bytes.
+    assertThat(parcel.readLong()).isEqualTo(0L);
+    assertThat(parcel.dataPosition()).isEqualTo(12);
+    assertThat(parcel.readString()).isEqualTo("");
+    assertThat(parcel.dataPosition()).isEqualTo(20);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[0]);
+    assertThat(parcel.dataPosition()).isEqualTo(24);
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertThat(parcel.dataPosition()).isEqualTo(28);
+  }
+
+  @Test
+  public void testInvalidReadFromTruncatedObjectEvenAfterBufferRegrows() {
+    parcel.writeString("hello all");
+    parcel.setDataSize(12);
+    // Restore the original size, but the data should be lost.
+    parcel.setDataSize(100);
+    parcel.setDataPosition(0);
+
+    try {
+      parcel.readString();
+      fail("should have thrown UnreliableBehaviorError");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for String at position 0, found String [hello all] taking 24 bytes, but "
+                  + "[uninitialized data or the end of the buffer] interrupts it at position 12");
+    }
+  }
+
+  @Test
+  public void testInvalidReadFromUninitializedData() {
+    // Write two longs with an 8-byte gap in the middle:
+    parcel.writeLong(333L);
+    parcel.setDataSize(parcel.dataSize() + 8);
+    parcel.setDataPosition(parcel.dataSize());
+    parcel.writeLong(444L);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readLong()).isEqualTo(333L);
+    try {
+      parcel.readLong();
+      fail("should have thrown UnreliableBehaviorError");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e).hasMessageThat().isEqualTo("Reading uninitialized data at position 8");
+    }
+  }
+
+  @Test
+  public void testReadWriteIntArray() throws Exception {
+    final int[] ints = { 1, 2 };
+    parcel.writeIntArray(ints);
+    // Make sure a copy was stored.
+    ints[0] = 99;
+    ints[1] = 99;
+    parcel.setDataPosition(0);
+    final int[] ints2 = new int[ints.length];
+    parcel.readIntArray(ints2);
+    assertThat(ints2).isEqualTo(new int[] {1, 2});
+  }
+
+  @Test
+  public void testWriteAndCreateNullIntArray() throws Exception {
+    parcel.writeIntArray(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createIntArray()).isNull();
+  }
+
+  @Test
+  public void testReadWriteLongArray() throws Exception {
+    final long[] longs = { 1, 2 };
+    parcel.writeLongArray(longs);
+    parcel.setDataPosition(0);
+    final long[] longs2 = new long[longs.length];
+    parcel.readLongArray(longs2);
+    assertTrue(Arrays.equals(longs, longs2));
+  }
+
+  @Test
+  public void testWriteAndCreateNullLongArray() throws Exception {
+    parcel.writeLongArray(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createLongArray()).isNull();
+  }
+
+  @Test
+  public void testReadWriteSingleFloat() {
+    float val = 5.2f;
+    parcel.writeFloat(val);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readFloat()).isEqualTo(val);
+  }
+
+  @Test
+  public void testReadWriteFloatArray() throws Exception {
+    final float[] floats = { 1.1f, 2.0f };
+    parcel.writeFloatArray(floats);
+    parcel.setDataPosition(0);
+    final float[] floats2 = new float[floats.length];
+    parcel.readFloatArray(floats2);
+    assertTrue(Arrays.equals(floats, floats2));
+  }
+
+  @Test
+  public void testWriteAndCreateNullFloatArray() throws Exception {
+    parcel.writeFloatArray(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createFloatArray()).isNull();
+  }
+
+  @Test
+  public void testReadWriteDoubleArray() throws Exception {
+    final double[] doubles = { 1.1f, 2.0f };
+    parcel.writeDoubleArray(doubles);
+    parcel.setDataPosition(0);
+    final double[] doubles2 = new double[doubles.length];
+    parcel.readDoubleArray(doubles2);
+    assertTrue(Arrays.equals(doubles, doubles2));
+  }
+
+  @Test
+  public void testWriteAndCreateNullDoubleArray() throws Exception {
+    parcel.writeDoubleArray(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createDoubleArray()).isNull();
+  }
+
+  @Test
+  public void testReadWriteStringArray() throws Exception {
+    final String[] strings = { "foo", "bar" };
+    parcel.writeStringArray(strings);
+    parcel.setDataPosition(0);
+    final String[] strings2 = new String[strings.length];
+    parcel.readStringArray(strings2);
+    assertTrue(Arrays.equals(strings, strings2));
+  }
+
+  @Test
+  public void testWriteAndCreateNullStringArray() throws Exception {
+    parcel.writeStringArray(null);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createStringArray()).isNull();
+  }
+
+  @Test
+  public void testWriteAndCreateByteArray_multipleOf4() {
+    byte[] bytes = new byte[] {-1, 2, 3, 127};
+    parcel.writeByteArray(bytes);
+    // Make sure that the parcel is not storing the original array.
+    bytes[0] = 55;
+    bytes[1] = 55;
+    bytes[2] = 55;
+    bytes[3] = 55;
+    assertWithMessage("4B length + 4B data").that(parcel.dataSize()).isEqualTo(8);
+    parcel.setDataPosition(0);
+    byte[] actualBytes = parcel.createByteArray();
+    assertThat(actualBytes).isEqualTo(new byte[] {-1, 2, 3, 127});
+  }
+
+  @Test
+  public void testWriteAndCreateByteArray_oddLength() {
+    byte[] bytes = new byte[] {-1, 2, 3, 127, -128};
+    parcel.writeByteArray(bytes);
+    assertWithMessage("4B length + 5B data + 3B padding").that(parcel.dataSize()).isEqualTo(12);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createByteArray()).isEqualTo(bytes);
+  }
+
+  @Test
+  public void testByteArrayToleratesZeroes() {
+    parcel.writeInt(19); // Length
+    parcel.writeInt(0); // These are zero
+    parcel.writeLong(0); // This is zero
+    parcel.writeFloat(0.0f); // This is zero
+    parcel.writeByteArray(new byte[0]); // This is also zero
+    assertThat(parcel.dataSize()).isEqualTo(24);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[19]);
+  }
+
+  @Test
+  public void testByteArrayOfZeroesCastedToZeroes() {
+    parcel.writeByteArray(new byte[17]);
+    assertWithMessage("total size").that(parcel.dataSize()).isEqualTo(24);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readInt()).isEqualTo(17);
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertThat(parcel.readFloat()).isEqualTo(0.0f);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[0]);
+    assertThat(parcel.readString()).isEqualTo("");
+  }
+
+  @Test
+  public void testByteArrayOfNonZeroCannotBeCastedToZeroes() {
+    parcel.writeByteArray(new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 1});
+    assertWithMessage("total size").that(parcel.dataSize()).isEqualTo(16);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readInt()).isEqualTo(9);
+    try {
+      assertThat(parcel.readInt()).isEqualTo(0);
+      fail("expected to fail");
+    } catch (RuntimeException e) {
+      assertThat(e)
+          .hasCauseThat()
+          .hasMessageThat()
+          .startsWith("Looking for Integer at position 4, found byte[]");
+      assertThat(e)
+          .hasCauseThat()
+          .hasMessageThat()
+          .endsWith("taking 12 bytes, and it is non-portable to reinterpret it");
+    }
+  }
+
+  @Test
+  public void testByteArrayOfZeroesReadAsZeroes() {
+    parcel.writeByteArray(new byte[15]);
+    assertThat(parcel.dataSize()).isEqualTo(20);
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readInt()).isEqualTo(15);
+    assertThat(parcel.readLong()).isEqualTo(0);
+    assertThat(parcel.readLong()).isEqualTo(0);
+    assertThat(parcel.dataPosition()).isEqualTo(20);
+  }
+
+  @Test
+  public void testWriteAndCreateNullByteArray() throws Exception {
+    parcel.writeByteArray(null);
+    assertThat(parcel.dataSize()).isEqualTo(4);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createByteArray()).isNull();
+  }
+
+  @Test
+  public void testWriteAndCreateByteArray_lengthZero() {
+    byte[] bytes = new byte[] {};
+    parcel.writeByteArray(bytes);
+    assertThat(parcel.dataSize()).isEqualTo(4);
+    parcel.setDataPosition(0);
+    byte[] actualBytes = parcel.createByteArray();
+    assertTrue(Arrays.equals(bytes, actualBytes));
+  }
+
+  @Test
+  public void testWriteAndReadByteArray_overwrittenLength() {
+    byte[] bytes = new byte[] {-1, 2, 3, 127};
+    parcel.writeByteArray(bytes);
+    assertThat(parcel.dataSize()).isEqualTo(8);
+    parcel.setDataPosition(0);
+    parcel.writeInt(3);
+    parcel.setDataPosition(0);
+    try {
+      parcel.createByteArray();
+      fail("expected exception");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Byte array's length prefix is 3 but real length is 4");
+    }
+  }
+
+  @Test
+  public void testWriteAndReadByteArray_justLengthButNoContents() {
+    parcel.writeInt(3);
+    parcel.setDataPosition(0);
+    try {
+      parcel.createByteArray();
+      fail("expected exception");
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Byte array's length prefix is 3 but real length is 0");
+    }
+  }
+
+  @Test
+  public void testWriteAndReadByteArray_empty() {
+    parcel.writeInt(0);
+    parcel.setDataPosition(0);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[0]);
+  }
+
+  @Test
+  public void testWriteAndReadByteArray() {
+    byte[] bytes = new byte[] { -1, 2, 3, 127 };
+    parcel.writeByteArray(bytes);
+    assertThat(parcel.dataSize()).isEqualTo(8);
+    parcel.setDataPosition(0);
+    byte[] actualBytes = new byte[bytes.length];
+    parcel.readByteArray(actualBytes);
+    assertTrue(Arrays.equals(bytes, actualBytes));
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testWriteAndReadByteArray_badLength() {
+    byte[] bytes = new byte[] { -1, 2, 3, 127 };
+    parcel.writeByteArray(bytes);
+    assertThat(parcel.dataSize()).isEqualTo(8);
+    parcel.setDataPosition(0);
+    byte[] actualBytes = new byte[1];
+    parcel.readByteArray(actualBytes);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testWriteAndReadByteArray_nullNotAllowed() {
+    parcel.writeByteArray(null);
+    assertThat(parcel.dataSize()).isEqualTo(4);
+    parcel.setDataPosition(0);
+    byte[] actualBytes = new byte[1];
+    parcel.readByteArray(actualBytes);
+  }
+
+  @Test
+  public void testReadWriteMultipleInts() {
+    for (int i = 0; i < 10; ++i) {
+      parcel.writeInt(i);
+    }
+    parcel.setDataPosition(0);
+    for (int i = 0; i < 10; ++i) {
+      assertThat(parcel.readInt()).isEqualTo(i);
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readInt()).isEqualTo(0);
+  }
+
+  @Test
+  public void testReadWriteSingleByte() {
+    byte val = 1;
+    parcel.writeByte(val);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readByte()).isEqualTo(val);
+  }
+
+  @Test
+  public void testReadWriteMultipleBytes() {
+    for (byte i = Byte.MIN_VALUE; i < Byte.MAX_VALUE; ++i) {
+      parcel.writeByte(i);
+    }
+    parcel.setDataPosition(0);
+    for (byte i = Byte.MIN_VALUE; i < Byte.MAX_VALUE; ++i) {
+      assertThat(parcel.readByte()).isEqualTo(i);
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readByte()).isEqualTo((byte) 0);
+  }
+
+  @Test
+  public void testReadWriteStringInt() {
+    for (int i = 0; i < 10; ++i) {
+      parcel.writeString(Integer.toString(i));
+      parcel.writeInt(i);
+    }
+    parcel.setDataPosition(0);
+    for (int i = 0; i < 10; ++i) {
+      assertThat(parcel.readString()).isEqualTo(Integer.toString(i));
+      assertThat(parcel.readInt()).isEqualTo(i);
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readString()).isNull();
+    assertThat(parcel.readInt()).isEqualTo(0);
+  }
+
+  @Test
+  public void testWriteStringReadInt() {
+    String val = "test";
+    parcel.writeString(val);
+    parcel.setDataPosition(0);
+    try {
+      parcel.readInt();
+      fail("should have thrown");
+    } catch (RuntimeException e) {
+      assertThat(e)
+          .hasCauseThat()
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for Integer at position 0, found String [test] taking 16 bytes, "
+                  + "and it is non-portable to reinterpret it");
+    }
+  }
+
+  @Test
+  public void testWriteIntReadString() {
+    int val = 9;
+    parcel.writeInt(val);
+    parcel.setDataPosition(0);
+    try {
+      parcel.readString();
+      fail("should have thrown");
+    } catch (RuntimeException e) {
+      assertThat(e)
+          .hasCauseThat()
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for String at position 0, found Integer [9] taking 4 bytes, "
+                  + "and it is non-portable to reinterpret it");
+    }
+  }
+
+  @Test
+  public void testReadWriteSingleLong() {
+    long val = 5;
+    parcel.writeLong(val);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readLong()).isEqualTo(val);
+  }
+
+  @Test
+  public void testReadWriteMultipleLongs() {
+    for (long i = 0; i < 10; ++i) {
+      parcel.writeLong(i);
+    }
+    parcel.setDataPosition(0);
+    for (long i = 0; i < 10; ++i) {
+      assertThat(parcel.readLong()).isEqualTo(i);
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readLong()).isEqualTo(0L);
+  }
+
+  @Test
+  public void testReadWriteStringLong() {
+    for (long i = 0; i < 10; ++i) {
+      parcel.writeString(Long.toString(i));
+      parcel.writeLong(i);
+    }
+    parcel.setDataPosition(0);
+    for (long i = 0; i < 10; ++i) {
+      assertThat(parcel.readString()).isEqualTo(Long.toString(i));
+      assertThat(parcel.readLong()).isEqualTo(i);
+    }
+    // now try to read past the number of items written and see what happens
+    assertThat(parcel.readString()).isNull();
+    assertThat(parcel.readLong()).isEqualTo(0L);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testWriteStringReadLong() {
+    String val = "test";
+    parcel.writeString(val);
+    parcel.setDataPosition(0);
+    parcel.readLong();
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testWriteLongReadString() {
+    long val = 9;
+    parcel.writeLong(val);
+    parcel.setDataPosition(0);
+    parcel.readString();
+  }
+
+  @Test
+  public void testReadWriteParcelable() {
+    Account a1 = new Account("name", "type");
+    parcel.writeParcelable(a1, 0);
+    parcel.setDataPosition(0);
+
+    Account a2 = parcel.readParcelable(Account.class.getClassLoader());
+    assertEquals(a1, a2);
+  }
+
+  @Test
+  public void testReadWriteBundle() {
+    Bundle b1 = new Bundle();
+    b1.putString("hello", "world");
+    parcel.writeBundle(b1);
+    parcel.setDataPosition(0);
+    Bundle b2 = parcel.readBundle();
+
+    assertEquals("world", b2.getString("hello"));
+
+    parcel.setDataPosition(0);
+    parcel.writeBundle(b1);
+    parcel.setDataPosition(0);
+    b2 = parcel.readBundle(null /* ClassLoader */);
+    assertEquals("world", b2.getString("hello"));
+  }
+
+  @Test
+  public void testCreateStringArrayList() throws Exception {
+    parcel.writeStringList(Arrays.asList("str1", "str2"));
+    parcel.setDataPosition(0);
+
+    List<String> actual = parcel.createStringArrayList();
+    assertEquals(2, actual.size());
+    assertEquals("str1", actual.get(0));
+    assertEquals("str2", actual.get(1));
+  }
+
+  @Test
+  public void testWriteTypedListAndCreateTypedArrayList() throws Exception {
+    TestParcelable normal = new TestParcelable(23);
+    ArrayList<TestParcelable> normals = new ArrayList<>();
+    normals.add(normal);
+
+    parcel.writeTypedList(normals);
+    parcel.setDataPosition(0);
+    List<org.robolectric.shadows.TestParcelable> rehydrated = parcel
+        .createTypedArrayList(TestParcelable.CREATOR);
+
+    assertEquals(1, rehydrated.size());
+    assertEquals(23, rehydrated.get(0).contents);
+  }
+
+  @Test
+  public void testParcelableWithPackageProtected() throws Exception {
+    TestParcelablePackage normal = new TestParcelablePackage(23);
+
+    parcel.writeParcelable(normal, 0);
+    parcel.setDataPosition(0);
+
+    TestParcelablePackage rehydrated = parcel.readParcelable(TestParcelablePackage.class.getClassLoader());
+
+    assertEquals(normal.contents, rehydrated.contents);
+  }
+
+  @Test
+  public void testParcelableWithBase() throws Exception {
+    TestParcelableImpl normal = new TestParcelableImpl(23);
+
+    parcel.writeParcelable(normal, 0);
+    parcel.setDataPosition(0);
+
+    TestParcelableImpl rehydrated = parcel.readParcelable(TestParcelableImpl.class.getClassLoader());
+
+    assertEquals(normal.contents, rehydrated.contents);
+  }
+
+  @Test
+  public void testParcelableWithPublicClass() throws Exception {
+    TestParcelable normal = new TestParcelable(23);
+
+    parcel.writeParcelable(normal, 0);
+    parcel.setDataPosition(0);
+
+    TestParcelable rehydrated = parcel.readParcelable(TestParcelable.class.getClassLoader());
+
+    assertEquals(normal.contents, rehydrated.contents);
+  }
+
+  @Test
+  public void testReadAndWriteStringList() throws Exception {
+    ArrayList<String> original = new ArrayList<>();
+    List<String> rehydrated = new ArrayList<>();
+    original.add("str1");
+    original.add("str2");
+    parcel.writeStringList(original);
+    parcel.setDataPosition(0);
+    parcel.readStringList(rehydrated);
+    assertEquals(2, rehydrated.size());
+    assertEquals("str1", rehydrated.get(0));
+    assertEquals("str2", rehydrated.get(1));
+  }
+
+  @Test
+  public void testReadWriteMap() throws Exception {
+    HashMap<String, String> original = new HashMap<>();
+    original.put("key", "value");
+    parcel.writeMap(original);
+    parcel.setDataPosition(0);
+    HashMap<String, String> rehydrated = parcel.readHashMap(null);
+
+    assertEquals("value", rehydrated.get("key"));
+  }
+
+  @Test
+  public void testCreateStringArray() {
+    String[] strs = { "a1", "b2" };
+    parcel.writeStringArray(strs);
+    parcel.setDataPosition(0);
+    String[] newStrs = parcel.createStringArray();
+    assertTrue(Arrays.equals(strs, newStrs));
+  }
+
+  @Test
+  public void testDataPositionAfterSomeWrites() {
+    parcel.writeInt(1);
+    assertThat(parcel.dataPosition()).isEqualTo(4);
+
+    parcel.writeFloat(5);
+    assertThat(parcel.dataPosition()).isEqualTo(8);
+
+    parcel.writeDouble(37);
+    assertThat(parcel.dataPosition()).isEqualTo(16);
+
+    parcel.writeStrongBinder(new Binder()); // 20 bytes
+    assertThat(parcel.dataPosition()).isEqualTo(36);
+  }
+
+  @Test
+  public void testDataPositionAfterSomeReads() {
+    parcel.writeInt(1);
+    parcel.writeFloat(5);
+    parcel.writeDouble(37);
+    parcel.setDataPosition(0);
+
+    parcel.readInt();
+    assertThat(parcel.dataPosition()).isEqualTo(4);
+
+    parcel.readFloat();
+    assertThat(parcel.dataPosition()).isEqualTo(8);
+
+    parcel.readDouble();
+    assertThat(parcel.dataPosition()).isEqualTo(16);
+  }
+
+  @Test
+  public void testDataSizeAfterSomeWrites() {
+    parcel.writeInt(1);
+    assertThat(parcel.dataSize()).isEqualTo(4);
+
+    parcel.writeFloat(5);
+    assertThat(parcel.dataSize()).isEqualTo(8);
+
+    parcel.writeDouble(37);
+    assertThat(parcel.dataSize()).isEqualTo(16);
+  }
+
+  @Test
+  public void testDataAvail() {
+    parcel.writeInt(1);
+    parcel.writeFloat(5);
+    parcel.writeDouble(6);
+    parcel.setDataPosition(4);
+
+    assertThat(parcel.dataAvail()).isEqualTo(12);
+  }
+
+  @Test
+  public void testSetDataPositionIntoMiddleOfParcel() {
+    parcel.writeInt(1);
+    parcel.writeFloat(5);
+    parcel.writeDouble(6);
+    parcel.setDataPosition(4);
+
+    assertThat(parcel.readFloat()).isEqualTo(5.0f);
+  }
+
+  @Test
+  public void testSetDataPositionToEmptyString() {
+    parcel.writeString("");
+    parcel.setDataPosition(parcel.dataPosition());
+    parcel.writeString("something else");
+
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isEmpty();
+  }
+
+  @Test
+  public void testAppendFrom() {
+    // Write a mixture of things, and overwrite something.
+    parcel.writeInt(1);
+    parcel.writeInt(2);
+    parcel.writeInt(3);
+    parcel.writeInt(4);
+
+    // Create a parcel2 that sandwiches parcel1 with happy birthday.
+    Parcel parcel2 = Parcel.obtain();
+    parcel2.writeString("happy");
+
+    parcel2.appendFrom(parcel, 4, 8);
+    assertInvariants(parcel);
+    assertInvariants(parcel2);
+
+    parcel2.writeString("birthday");
+    assertInvariants(parcel);
+
+    parcel2.setDataPosition(0);
+    assertThat(parcel2.readString()).isEqualTo("happy");
+    assertThat(parcel2.readInt()).isEqualTo(2);
+    assertThat(parcel2.readInt()).isEqualTo(3);
+    assertThat(parcel2.readString()).isEqualTo("birthday");
+    assertThat(parcel2.dataAvail()).isEqualTo(0);
+  }
+
+  @Test
+  public void testMarshallAndUnmarshall() {
+    parcel.writeInt(1);
+    parcel.writeString("hello");
+    parcel.writeDouble(25.0);
+    parcel.writeFloat(1.25f);
+    parcel.writeByte((byte) 0xAF);
+    int oldSize = parcel.dataSize();
+
+    parcel.setDataPosition(7);
+    byte[] rawBytes = parcel.marshall();
+    assertWithMessage("data position preserved").that(parcel.dataPosition()).isEqualTo(7);
+    Parcel parcel2 = Parcel.obtain();
+    assertInvariants(parcel2);
+    parcel2.unmarshall(rawBytes, 0, rawBytes.length);
+    assertThat(parcel2.dataPosition()).isEqualTo(parcel2.dataSize());
+    parcel2.setDataPosition(0);
+
+    assertThat(parcel2.dataSize()).isEqualTo(oldSize);
+    assertThat(parcel2.readInt()).isEqualTo(1);
+    assertThat(parcel2.readString()).isEqualTo("hello");
+    assertThat(parcel2.readDouble()).isEqualTo(25.0);
+    assertThat(parcel2.readFloat()).isEqualTo(1.25f);
+    assertThat(parcel2.readByte()).isEqualTo((byte) 0xAF);
+  }
+
+  @Test
+  public void testMarshallFailsFastReadingInterruptedObject() {
+    parcel.writeString("hello all");
+    parcel.setDataPosition(4);
+    parcel.writeInt(1);
+    try {
+      parcel.marshall();
+      fail();
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for Object at position 0, found String [hello all] taking 24 bytes, but "
+                  + "[1] interrupts it at position 4");
+    }
+  }
+
+  @Test
+  public void testMarshallFailsFastReadingTruncatedObject() {
+    parcel.writeString("hello all");
+    parcel.setDataSize(8);
+    try {
+      parcel.marshall();
+      fail();
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Looking for Object at position 0, found String [hello all] taking 24 bytes, but "
+                  + "[uninitialized data or the end of the buffer] interrupts it at position 8");
+    }
+  }
+
+  @Test
+  public void testMarshallFailsFastReadingUninitializedData() {
+    parcel.writeString("hello everyone");
+    parcel.setDataSize(parcel.dataSize() + 4);
+    parcel.setDataPosition(parcel.dataSize());
+    parcel.writeInt(1);
+    try {
+      parcel.marshall();
+      fail();
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e).hasMessageThat().isEqualTo("Reading uninitialized data at position 36");
+    }
+  }
+
+  @Test
+  public void testMarshallIntent() {
+    // Some security fuzzer tests rely on marshalled Intents, and they internally exercise some
+    // fairly weird behavior around cutting and splicing individual parcels.  This makes a good
+    // stress test for fairly common app behavior.
+    Intent intent = new Intent("action.foo");
+    intent.putExtra("key1", "str1");
+    intent.putExtra("key2", 2);
+    intent.putExtra("key3", 3L);
+
+    parcel.writeString("hello world");
+    parcel.writeParcelable(intent, 0);
+    parcel.writeString("bye world");
+
+    // First make sure that it wasn't overtly corrupted pre-marshalling.
+    parcel.setDataPosition(0);
+    parcel.readString();
+    assertThat(
+            ((Intent) parcel.readParcelable(Intent.class.getClassLoader())).getStringExtra("key1"))
+        .isEqualTo("str1");
+
+    byte[] data = parcel.marshall();
+    Parcel parcel2 = Parcel.obtain();
+    parcel2.unmarshall(data, 0, data.length);
+    assertThat(parcel2.dataPosition()).isEqualTo(parcel2.dataSize());
+    parcel2.setDataPosition(0);
+    try {
+      assertThat(parcel2.readString()).isEqualTo("hello world");
+
+      Intent unmarshalledIntent = (Intent) parcel2.readParcelable(Intent.class.getClassLoader());
+      assertThat(unmarshalledIntent.getAction()).isEqualTo("action.foo");
+      assertThat(unmarshalledIntent.getStringExtra("key1")).isEqualTo("str1");
+      assertThat(unmarshalledIntent.getIntExtra("key2", -1)).isEqualTo(2);
+      assertThat(unmarshalledIntent.getLongExtra("key3", -1)).isEqualTo(3L);
+
+      assertThat(parcel2.readString()).isEqualTo("bye world");
+    } finally {
+      parcel2.recycle();
+    }
+  }
+
+  @Test
+  public void testUnmarshallLegacyBlob() throws IOException {
+    ByteArrayOutputStream bos = new ByteArrayOutputStream();
+    ObjectOutputStream oos = new ObjectOutputStream(bos);
+    // This ensures legacy marshalled values still parse, even if they are not aligned.
+    oos.writeInt(9);
+    oos.writeInt(5);
+    oos.writeObject("abcde");
+    // Old/legacy empty-strings are encoded as only 4 bytes.  Make sure that the all-zeroes logic
+    // doesn't kick in and instead consume 8 bytes.
+    oos.writeInt(4);
+    oos.writeObject("");
+    oos.writeInt(4);
+    oos.writeObject(0);
+    oos.writeInt(1); // This "int" only takes 1 byte.
+    oos.writeObject(81);
+    oos.writeInt(4);
+    oos.writeObject(Integer.MAX_VALUE);
+    // A byte array in the previous encoding: length plus individual bytes.
+    oos.writeInt(4);
+    oos.writeObject(3); // Array length
+    oos.writeInt(1);
+    oos.writeObject((byte) 85);
+    oos.writeInt(1);
+    oos.writeObject((byte) 86);
+    oos.writeInt(1);
+    oos.writeObject((byte) 87);
+    oos.flush();
+
+    byte[] data = bos.toByteArray();
+    parcel.unmarshall(data, 0, data.length);
+    assertThat(parcel.dataPosition()).isEqualTo(parcel.dataSize());
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isEqualTo("abcde");
+    assertWithMessage("end offset of legacy string").that(parcel.dataPosition()).isEqualTo(5);
+    assertThat(parcel.readString()).isEqualTo("");
+    assertWithMessage("end offset of legacy empty string").that(parcel.dataPosition()).isEqualTo(9);
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertWithMessage("end offset of zero int").that(parcel.dataPosition()).isEqualTo(13);
+    assertThat(parcel.readByte()).isEqualTo(81);
+    assertWithMessage("end offset of legacy byte").that(parcel.dataPosition()).isEqualTo(14);
+    assertThat(parcel.readInt()).isEqualTo(Integer.MAX_VALUE);
+    assertWithMessage("end offset of legacy int").that(parcel.dataPosition()).isEqualTo(18);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[] {85, 86, 87});
+    assertWithMessage("end offset of legacy int").that(parcel.dataPosition()).isEqualTo(25);
+    assertWithMessage("total size of legacy parcel").that(parcel.dataSize()).isEqualTo(25);
+  }
+
+  @Test
+  public void testUnmarshallZeroes() throws IOException {
+    // This tests special-case handling of zeroes in marshalling.  A few tests rely on the rather
+    // well-defined behavior that Parcel will interpret a byte array of all zeroes as zero
+    // primitives and empty arrays.  When unmarshalling, this can be easily disambiguated from an
+    // ObjectInputStream, which requires at least a non-zero magic, and is therefore not all
+    // zeroes.
+    byte[] mostlyZeroes = new byte[333];
+    // Make a couple of non-zero values outside of the range being copied, just to ensure the range
+    // is considered in the all-zeroes detection.
+    mostlyZeroes[1] = (byte) 55;
+    mostlyZeroes[302] = (byte) -32;
+    // Parse the array of all zeroes.
+    parcel.unmarshall(new byte[300], 2, 300);
+    assertThat(parcel.dataSize()).isEqualTo(300);
+    assertThat(parcel.dataPosition()).isEqualTo(300);
+    assertWithMessage("unmarshall does not grow size incrementally but allocates the exact amount")
+        .that(parcel.dataCapacity())
+        .isEqualTo(300);
+    parcel.setDataPosition(0);
+    assertThat(parcel.readString()).isEqualTo("");
+    assertWithMessage("end offset of empty string").that(parcel.dataPosition()).isEqualTo(8);
+    assertThat(parcel.createByteArray()).isEqualTo(new byte[0]);
+    assertWithMessage("end offset of empty byte array").that(parcel.dataPosition()).isEqualTo(12);
+    assertThat(parcel.readInt()).isEqualTo(0);
+    assertWithMessage("end offset of readInt zeroes").that(parcel.dataPosition()).isEqualTo(16);
+    assertThat(parcel.readFloat()).isEqualTo(0.0f);
+    assertWithMessage("end offset of readFloat zeroes").that(parcel.dataPosition()).isEqualTo(20);
+    assertThat(parcel.readDouble()).isEqualTo(0.0d);
+    assertWithMessage("end offset of readDouble zeroes").that(parcel.dataPosition()).isEqualTo(28);
+    assertThat(parcel.readLong()).isEqualTo(0L);
+    assertWithMessage("end offset of readLong zeroes").that(parcel.dataPosition()).isEqualTo(36);
+    try {
+      parcel.readParcelable(Account.class.getClassLoader());
+      fail("Should not be able to unparcel something without the required header");
+    } catch (BadParcelableException e) {
+      // Good -- a stream of all zeroes should end up throwing BadParcelableException instead of
+      // one of the Robolectric-specific exceptions.  One of the primary reasons for handling
+      // zeroes to begin with is to allow tests to simulate BadParcelableException with a stream of
+      // zeroes.
+    }
+  }
+
+  @Test
+  public void testUnmarshallEmpty() throws IOException {
+    // Unmarshall an zero-length byte string, although, pass a non-empty array to make sure the
+    // length/offset are respected.
+    parcel.unmarshall(new byte[] {1, 2, 3}, 1, 0);
+    assertThat(parcel.dataSize()).isEqualTo(0);
+    assertThat(parcel.dataPosition()).isEqualTo(0);
+    // Should not throw "Did you forget to setDataPosition(0)?" because it's still empty.
+    assertThat(parcel.readInt()).isEqualTo(0);
+  }
+
+  @Test
+  public void testSetDataSize() {
+    parcel.writeInt(1);
+    parcel.writeInt(2);
+    parcel.writeInt(3);
+    parcel.writeInt(4);
+    parcel.writeInt(5);
+    assertThat(parcel.dataSize()).isEqualTo(20);
+    assertInvariants();
+    int oldCapacity = parcel.dataCapacity();
+
+    parcel.setDataSize(12);
+    assertWithMessage("should equal requested size").that(parcel.dataSize()).isEqualTo(12);
+    assertWithMessage("position gets truncated").that(parcel.dataPosition()).isEqualTo(12);
+    assertWithMessage("capacity doesn't shrink").that(parcel.dataCapacity()).isEqualTo(oldCapacity);
+
+    parcel.setDataSize(100);
+    assertWithMessage("should equal requested size").that(parcel.dataSize()).isEqualTo(100);
+    assertWithMessage("position untouched").that(parcel.dataPosition()).isEqualTo(12);
+    assertInvariants();
+  }
+
+  @Test
+  public void testDataSizeShrinkingAndGrowing() {
+    assertWithMessage("still empty").that(parcel.dataSize()).isEqualTo(0);
+    assertWithMessage("did not advance").that(parcel.dataPosition()).isEqualTo(0);
+    for (int i = 0; i < 100; i++) {
+      parcel.writeInt(1000 + i);
+    }
+    assertInvariants();
+    assertWithMessage("now has 100 ints").that(parcel.dataSize()).isEqualTo(400);
+    assertWithMessage("advanced 100 ints").that(parcel.dataPosition()).isEqualTo(400);
+
+    parcel.setDataPosition(88);
+    assertInvariants();
+    parcel.setDataSize(100);
+    assertInvariants();
+
+    assertWithMessage("requested size honored").that(parcel.dataSize()).isEqualTo(100);
+    assertWithMessage("requested position honored").that(parcel.dataPosition()).isEqualTo(88);
+    assertWithMessage("data preserved (index 22, byte 88)").that(parcel.readInt()).isEqualTo(1022);
+
+    parcel.setDataSize(8);
+    assertInvariants();
+    parcel.setDataCapacity(500); // Make sure it doesn't affect size.
+    assertInvariants();
+    assertWithMessage("truncated size").that(parcel.dataSize()).isEqualTo(8);
+    assertWithMessage("truncated position").that(parcel.dataPosition()).isEqualTo(8);
+
+    parcel.setDataSize(400);
+    assertInvariants();
+    parcel.setDataPosition(88);
+    assertInvariants();
+    try {
+      parcel.readInt();
+      fail();
+    } catch (UnreliableBehaviorError e) {
+      assertThat(e).hasMessageThat().isEqualTo("Reading uninitialized data at position 88");
+    }
+    parcel.setDataPosition(4);
+    assertWithMessage("early data should be preserved").that(parcel.readInt()).isEqualTo(1001);
+  }
+
+  @Test
+  public void testSetDataCapacity() {
+    parcel.writeInt(-1);
+    assertWithMessage("size is 1 int").that(parcel.dataSize()).isEqualTo(4);
+    assertInvariants();
+    parcel.setDataPosition(parcel.dataPosition());
+    parcel.readInt();
+    assertWithMessage("reading within capacity but over size does not increase size")
+        .that(parcel.dataSize())
+        .isEqualTo(4);
+
+    parcel.setDataCapacity(100);
+    assertInvariants();
+    assertWithMessage("capacity equals requested").that(parcel.dataCapacity()).isEqualTo(100);
+    assertWithMessage("size does not increase with capacity").that(parcel.dataSize()).isEqualTo(4);
+
+    parcel.setDataCapacity(404);
+    for (int i = 0; i < 100; i++) {
+      parcel.writeInt(i);
+    }
+    assertInvariants();
+    assertWithMessage("capacity exactly holds 404 ints").that(parcel.dataCapacity()).isEqualTo(404);
+    assertWithMessage("101 ints in size").that(parcel.dataSize()).isEqualTo(404);
+    assertWithMessage("advanced 101 ints").that(parcel.dataPosition()).isEqualTo(404);
+
+    parcel.setDataCapacity(12);
+    assertWithMessage("capacity never shrinks").that(parcel.dataCapacity()).isEqualTo(404);
+    parcel.setDataSize(12);
+    assertWithMessage("size does shrink").that(parcel.dataSize()).isEqualTo(12);
+    parcel.setDataCapacity(12);
+    assertWithMessage("capacity never shrinks").that(parcel.dataCapacity()).isEqualTo(404);
+  }
+  
+  @Test
+  public void testWriteAndEnforceCompatibleInterface() {
+    parcel.writeInterfaceToken("com.example.IMyInterface");
+    parcel.setDataPosition(0);
+    parcel.enforceInterface("com.example.IMyInterface");
+    // Nothing explodes
+  }
+  
+  @Test
+  public void testWriteAndEnforceIncompatibleInterface() {
+    parcel.writeInterfaceToken("com.example.Derp");
+    parcel.setDataPosition(0);
+    try {
+      parcel.enforceInterface("com.example.IMyInterface");
+      fail("Expected SecurityException");
+    } catch (SecurityException e) {
+      // Expected
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testReadWriteFileDescriptor() throws Exception {
+    File file = new File(ApplicationProvider.getApplicationContext().getFilesDir(), "test");
+    RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
+    FileDescriptor expectedFileDescriptor = randomAccessFile.getFD();
+
+    parcel.writeFileDescriptor(expectedFileDescriptor);
+    parcel.setDataPosition(0);
+
+    FileDescriptor actualFileDescriptor = parcel.readRawFileDescriptor();
+
+    // Since the test runs in a single process, for simplicity, we can assume the FD isn't changed
+    int expectedFd = ReflectionHelpers.getField(expectedFileDescriptor, "fd");
+    int actualFd = ReflectionHelpers.getField(actualFileDescriptor, "fd");
+    assertThat(actualFd).isEqualTo(expectedFd);
+  }
+
+  private void assertInvariants() {
+    assertInvariants(parcel);
+  }
+
+  private void assertInvariants(Parcel p) {
+    assertWithMessage("capacity >= size").that(p.dataCapacity()).isAtLeast(p.dataSize());
+    assertWithMessage("position <= size").that(p.dataPosition()).isAtMost(p.dataSize());
+    assertWithMessage("available = size - position")
+        .that(p.dataAvail())
+        .isEqualTo(p.dataSize() - p.dataPosition());
+    assertWithMessage("size % 4 == 0").that(p.dataSize() % 4).isEqualTo(0);
+    assertWithMessage("capacity % 4 == 0").that(p.dataSize() % 4).isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPasswordTransformationMethodTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPasswordTransformationMethodTest.java
new file mode 100644
index 0000000..452b7cc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPasswordTransformationMethodTest.java
@@ -0,0 +1,49 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.text.method.PasswordTransformationMethod;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPasswordTransformationMethodTest {
+
+  private PasswordTransformationMethod transformationMethod;
+
+  @Before
+  public void setUp(){
+    transformationMethod = new PasswordTransformationMethod();
+  }
+
+  @Test
+  public void shouldMaskInputCharacters(){
+    CharSequence output = transformationMethod.getTransformation("foobar", null);
+    assertThat(output.toString()).isEqualTo("\u2022\u2022\u2022\u2022\u2022\u2022"); //using the escaped characters for cross platform compatibility.
+  }
+
+  @Test
+  public void shouldTransformSpacesWithText(){
+    CharSequence output = transformationMethod.getTransformation(" baz ", null);
+    assertThat(output.toString()).isEqualTo("\u2022\u2022\u2022\u2022\u2022");
+  }
+
+  @Test
+  public void shouldTransformSpacesWithoutText(){
+    CharSequence output = transformationMethod.getTransformation("    ", null);
+    assertThat(output.toString()).isEqualTo("\u2022\u2022\u2022\u2022");
+  }
+
+  @Test
+  public void shouldNotTransformBlank(){
+    CharSequence output = transformationMethod.getTransformation("", null);
+    assertThat(output.toString()).isEqualTo("");
+  }
+
+  @Test
+  public void shouldRetrieveAnInstance() {
+    assertThat(PasswordTransformationMethod.getInstance()).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPathTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPathTest.java
new file mode 100644
index 0000000..3948971
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPathTest.java
@@ -0,0 +1,112 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowPath.Point.Type.LINE_TO;
+import static org.robolectric.shadows.ShadowPath.Point.Type.MOVE_TO;
+
+import android.graphics.Path;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPathTest {
+
+  private static final float ERROR_TOLERANCE = 0.5f;
+
+  @Test
+  public void testMoveTo() {
+    Path path = new Path();
+    path.moveTo(2, 3);
+    path.moveTo(3, 4);
+
+    List<ShadowPath.Point> moveToPoints = shadowOf(path).getPoints();
+    assertEquals(2, moveToPoints.size());
+    assertEquals(new ShadowPath.Point(2, 3, MOVE_TO), moveToPoints.get(0));
+    assertEquals(new ShadowPath.Point(3, 4, MOVE_TO), moveToPoints.get(1));
+  }
+
+  @Test
+  public void testLineTo() {
+    Path path = new Path();
+    path.lineTo(2, 3);
+    path.lineTo(3, 4);
+
+    List<ShadowPath.Point> lineToPoints = shadowOf(path).getPoints();
+    assertEquals(2, lineToPoints.size());
+    assertEquals(new ShadowPath.Point(2, 3, LINE_TO), lineToPoints.get(0));
+    assertEquals(new ShadowPath.Point(3, 4, LINE_TO), lineToPoints.get(1));
+  }
+
+  @Test
+  public void testReset() {
+    Path path = new Path();
+    path.moveTo(0, 3);
+    path.lineTo(2, 3);
+    path.quadTo(2, 3, 4, 5);
+    path.reset();
+
+    ShadowPath shadowPath = shadowOf(path);
+    List<ShadowPath.Point> points = shadowPath.getPoints();
+    assertEquals(0, points.size());
+  }
+
+  @Test
+  public void copyConstructor_copiesShadowPoints() {
+    Path path = new Path();
+    path.moveTo(0, 3);
+    path.lineTo(2, 3);
+    path.quadTo(2, 3, 4, 5);
+
+    Path copiedPath = new Path(path);
+
+    assertEquals(shadowOf(path).getPoints(), shadowOf(copiedPath).getPoints());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void copyConstructor_copiesPathSegments() {
+    Path path = new Path();
+    path.moveTo(9, 3);
+    path.lineTo(2, 3);
+    path.quadTo(2, 3, 4, 5);
+    float[] segments = path.approximate(ERROR_TOLERANCE);
+
+    Path copiedPath = new Path(path);
+
+    assertArrayEquals(segments, copiedPath.approximate(ERROR_TOLERANCE), ERROR_TOLERANCE);
+  }
+
+  @Test
+  public void copyConstructor_copiesFillType() {
+    Path.FillType fillType = Path.FillType.INVERSE_EVEN_ODD;
+    Path path = new Path();
+    path.setFillType(fillType);
+
+    Path copiedPath = new Path(path);
+
+    assertEquals(fillType, copiedPath.getFillType());
+  }
+
+  @Test
+  public void copyConstructor_emptyPath_isEmpty() {
+    Path emptyPath = new Path();
+
+    Path copiedEmptyPath = new Path(emptyPath);
+
+    assertTrue(copiedEmptyPath.isEmpty());
+  }
+
+  @Test
+  public void emptyConstructor_isEmpty() {
+    Path emptyPath = new Path();
+
+    assertTrue(emptyPath.isEmpty());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedAsyncTaskLoaderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedAsyncTaskLoaderTest.java
new file mode 100644
index 0000000..f8d1de1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedAsyncTaskLoaderTest.java
@@ -0,0 +1,94 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.AsyncTaskLoader;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.android.util.concurrent.PausedExecutorService;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowPausedAsyncTaskLoader}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = KITKAT)
+public class ShadowPausedAsyncTaskLoaderTest {
+  private final List<String> taskRecord = new ArrayList<>();
+  private TestLoader testLoader;
+  private PausedExecutorService pausedBackgroundExecutor;
+
+  @Before
+  public void setUp() {
+    pausedBackgroundExecutor = new PausedExecutorService();
+    testLoader = new TestLoader(42);
+    ShadowPausedAsyncTaskLoader<Integer> shadowLoader = Shadow.extract(testLoader);
+    shadowLoader.setExecutor(pausedBackgroundExecutor);
+  }
+
+  @Test
+  public void forceLoad_enqueuesWork() {
+    testLoader.forceLoad();
+    assertThat(taskRecord).isEmpty();
+
+    pausedBackgroundExecutor.runAll();
+    assertThat(taskRecord).containsExactly("loadInBackground");
+    taskRecord.clear();
+
+    ShadowLooper.idleMainLooper();
+    assertThat(taskRecord).containsExactly("deliverResult 42");
+  }
+
+  @Test
+  public void forceLoad_multipleLoads() {
+    testLoader.forceLoad();
+    assertThat(taskRecord).isEmpty();
+
+    pausedBackgroundExecutor.runAll();
+    assertThat(taskRecord).containsExactly("loadInBackground");
+    taskRecord.clear();
+
+    ShadowLooper.idleMainLooper();
+    assertThat(taskRecord).containsExactly("deliverResult 42");
+
+    testLoader.setData(43);
+    taskRecord.clear();
+    testLoader.forceLoad();
+
+    pausedBackgroundExecutor.runAll();
+    assertThat(taskRecord).containsExactly("loadInBackground");
+    taskRecord.clear();
+
+    ShadowLooper.idleMainLooper();
+    assertThat(taskRecord).containsExactly("deliverResult 43");
+  }
+
+  class TestLoader extends AsyncTaskLoader<Integer> {
+    private Integer data;
+
+    public TestLoader(Integer data) {
+      super(ApplicationProvider.getApplicationContext());
+      this.data = data;
+    }
+
+    @Override
+    public Integer loadInBackground() {
+      taskRecord.add("loadInBackground");
+      return data;
+    }
+
+    @Override
+    public void deliverResult(Integer data) {
+      taskRecord.add("deliverResult " + data);
+    }
+
+    public void setData(int newData) {
+      this.data = newData;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedAsyncTaskTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedAsyncTaskTest.java
new file mode 100644
index 0000000..413662b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedAsyncTaskTest.java
@@ -0,0 +1,268 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.os.AsyncTask;
+import android.os.AsyncTask.Status;
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.android.util.concurrent.PausedExecutorService;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.util.Join;
+
+/**
+ * Unit tests for {@link ShadowPausedAsyncTask}.
+ */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(PAUSED)
+public class ShadowPausedAsyncTaskTest {
+  private List<String> transcript;
+
+  @Before
+  public void setUp() throws Exception {
+    transcript = new ArrayList<>();
+  }
+
+  /** Test uses AsyncTask without overridding executor. */
+  @Test
+  public void testNormalFlow() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new RecordingAsyncTask();
+
+    String result = asyncTask.execute("a", "b").get();
+    assertThat(transcript).containsExactly("onPreExecute", "doInBackground a, b");
+    assertThat(result).isEqualTo("c");
+    transcript.clear();
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("onPostExecute c");
+  }
+
+  @Test
+  public void testCancelBeforeBackground() {
+    AsyncTask<String, String, String> asyncTask = new RecordingAsyncTask();
+
+    // rely on AsyncTask being processed serially on a single background thread, and block
+    // processing
+    BlockingAsyncTask blockingAsyncTask = new BlockingAsyncTask();
+    blockingAsyncTask.execute();
+
+    asyncTask.execute("a", "b");
+    assertThat(transcript).containsExactly("onPreExecute");
+    transcript.clear();
+
+    assertTrue(asyncTask.cancel(true));
+    assertTrue(asyncTask.isCancelled());
+
+    blockingAsyncTask.release();
+
+    assertThat(transcript).isEmpty();
+
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("onCancelled null", "onCancelled");
+  }
+
+  @Test
+  public void testCancelBeforePostExecute() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new RecordingAsyncTask();
+
+    asyncTask.execute("a", "b").get();
+
+    assertThat(transcript).containsExactly("onPreExecute", "doInBackground a, b");
+
+    transcript.clear();
+    assertEquals(
+        "Result should get stored in the AsyncTask",
+        "c",
+        asyncTask.get(100, TimeUnit.MILLISECONDS));
+
+    assertFalse(asyncTask.cancel(true));
+    assertTrue(asyncTask.isCancelled());
+
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("onCancelled c", "onCancelled");
+  }
+
+  @Test
+  public void progressUpdatesAreQueuedUntilBackgroundThreadFinishes() throws Exception {
+    AsyncTask<String, String, String> asyncTask =
+        new RecordingAsyncTask() {
+          @Override
+          protected String doInBackground(String... strings) {
+            publishProgress("33%");
+            publishProgress("66%");
+            publishProgress("99%");
+            return "done";
+          }
+        };
+
+    asyncTask.execute("a", "b").get();
+
+    transcript.clear();
+    assertThat(transcript).isEmpty();
+    assertEquals(
+        "Result should get stored in the AsyncTask",
+        "done",
+        asyncTask.get(100, TimeUnit.MILLISECONDS));
+
+    shadowMainLooper().idle();
+    assertThat(transcript)
+        .containsExactly(
+            "onProgressUpdate 33%",
+            "onProgressUpdate 66%", "onProgressUpdate 99%", "onPostExecute done");
+  }
+
+  @Test
+  public void shouldGetStatusForAsyncTask() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new RecordingAsyncTask();
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.PENDING);
+    asyncTask.execute("a").get();
+
+    assertThat(asyncTask.getStatus()).isEqualTo(Status.RUNNING);
+    shadowMainLooper().idle();
+    assertThat(asyncTask.getStatus()).isEqualTo(Status.FINISHED);
+  }
+
+  @Test
+  public void onPostExecute_doesNotSwallowExceptions() throws Exception {
+    AsyncTask<Void, Void, Void> asyncTask =
+        new AsyncTask<Void, Void, Void>() {
+          @Override
+          protected Void doInBackground(Void... params) {
+            return null;
+          }
+
+          @Override
+          protected void onPostExecute(Void aVoid) {
+            throw new RuntimeException("Don't swallow me!");
+          }
+        };
+
+    try {
+      asyncTask.execute().get();
+
+      shadowMainLooper().idle();
+      fail("Task swallowed onPostExecute() exception!");
+    } catch (RuntimeException e) {
+      assertThat(e.getMessage()).isEqualTo("Don't swallow me!");
+    }
+  }
+
+  @Test
+  public void executeOnExecutor_usesPassedExecutor() throws Exception {
+    AsyncTask<String, String, String> asyncTask = new RecordingAsyncTask();
+
+    assertThat(asyncTask.getStatus()).isEqualTo(AsyncTask.Status.PENDING);
+
+    asyncTask.executeOnExecutor(MoreExecutors.directExecutor(), "a", "b");
+
+    assertThat(asyncTask.getStatus()).isEqualTo(Status.RUNNING);
+    assertThat(transcript).containsExactly("onPreExecute", "doInBackground a, b");
+    transcript.clear();
+    assertEquals("Result should get stored in the AsyncTask", "c", asyncTask.get());
+
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("onPostExecute c");
+    assertThat(asyncTask.getStatus()).isEqualTo(Status.FINISHED);
+  }
+
+  @Test
+  public void asyncTasksExecuteInBackground() throws ExecutionException, InterruptedException {
+    AsyncTask<Void, Void, Void> asyncTask =
+        new AsyncTask<Void, Void, Void>() {
+          @Override
+          protected Void doInBackground(Void... params) {
+            boolean isMainLooper = Looper.getMainLooper().getThread() == Thread.currentThread();
+            transcript.add("doInBackground on main looper " + Boolean.toString(isMainLooper));
+            return null;
+          }
+        };
+    asyncTask.execute().get();
+    assertThat(transcript).containsExactly("doInBackground on main looper false");
+  }
+
+  @Test
+  public void overrideExecutor() throws ExecutionException, InterruptedException {
+    PausedExecutorService pausedExecutor = new PausedExecutorService();
+    ShadowPausedAsyncTask.overrideExecutor(pausedExecutor);
+
+    AsyncTask<String, String, String> asyncTask = new RecordingAsyncTask();
+
+    asyncTask.execute("a", "b");
+    assertThat(transcript).containsExactly("onPreExecute");
+    transcript.clear();
+    pausedExecutor.runAll();
+    assertThat(transcript).containsExactly("doInBackground a, b");
+    assertThat(asyncTask.get()).isEqualTo("c");
+    transcript.clear();
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("onPostExecute c");
+  }
+
+  private class RecordingAsyncTask extends AsyncTask<String, String, String> {
+    @Override
+    protected void onPreExecute() {
+      transcript.add("onPreExecute");
+    }
+
+    @Override
+    protected String doInBackground(String... strings) {
+      transcript.add("doInBackground " + Join.join(", ", (Object[]) strings));
+      return "c";
+    }
+
+    @Override
+    protected void onProgressUpdate(String... values) {
+      transcript.add("onProgressUpdate " + Join.join(", ", (Object[]) values));
+    }
+
+    @Override
+    protected void onPostExecute(String s) {
+      transcript.add("onPostExecute " + s);
+    }
+
+    @Override
+    protected void onCancelled(String result) {
+      transcript.add("onCancelled " + result);
+      // super should call onCancelled() without arguments
+      super.onCancelled(result);
+    }
+
+    @Override
+    protected void onCancelled() {
+      transcript.add("onCancelled");
+    }
+  }
+
+  private static class BlockingAsyncTask extends AsyncTask<Void, Void, Void> {
+
+    private CountDownLatch latch = new CountDownLatch(1);
+
+    @Override
+    protected Void doInBackground(Void... voids) {
+      try {
+        latch.await();
+      } catch (InterruptedException e) {
+        // ignore
+      }
+      return null;
+    }
+
+    void release() {
+      latch.countDown();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java
new file mode 100644
index 0000000..cadc3e9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java
@@ -0,0 +1,586 @@
+package org.robolectric.shadows;
+
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.MessageQueue.IdleHandler;
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.res.android.Ref;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(LooperMode.Mode.PAUSED)
+public class ShadowPausedLooperTest {
+
+  // testName is used when creating background threads. Makes it
+  // easier to debug exceptions on background threads when you
+  // know what test they are associated with.
+  @Rule public TestName testName = new TestName();
+  private HandlerThread handlerThread;
+
+  @Before
+  public void createHandlerThread() {
+    handlerThread = new HandlerThread(testName.getMethodName());
+    handlerThread.start();
+  }
+
+  @After
+  public void quitHandlerThread() throws Exception {
+    handlerThread.quit();
+    handlerThread.join();
+  }
+
+  @Test
+  public void mainLooper_getAllLoopers_shouldContainMainAndHandlerThread() {
+    Looper looper = handlerThread.getLooper();
+
+    assertThat(ShadowLooper.getAllLoopers()).contains(getMainLooper());
+    assertThat(ShadowLooper.getAllLoopers()).contains(looper);
+  }
+
+  @Test
+  public void mainLooper_andMyLooper_shouldBeSame_onMainThread() {
+    assertThat(Looper.myLooper()).isSameInstanceAs(getMainLooper());
+  }
+
+  @Test
+  public void differentThreads_getDifferentLoopers() {
+    assertThat(handlerThread.getLooper()).isNotSameInstanceAs(getMainLooper());
+    handlerThread.quit();
+  }
+
+  @Test
+  public void mainLooperThread_shouldBeTestThread() {
+    assertThat(getMainLooper().getThread()).isSameInstanceAs(Thread.currentThread());
+  }
+
+  @Test(timeout = 200)
+  public void junitTimeoutTestRunsOnMainThread() {
+    assertThat(getMainLooper().getThread()).isSameInstanceAs(Thread.currentThread());
+  }
+
+  @Test
+  public void postedMainLooperTasksAreNotExecuted() {
+    Runnable mockRunnable = mock(Runnable.class);
+    Handler handler = new Handler();
+    handler.post(mockRunnable);
+    verify(mockRunnable, timeout(20).times(0)).run();
+  }
+
+  @Test
+  public void postedBackgroundLooperTasksAreExecuted() throws InterruptedException {
+    Runnable mockRunnable = mock(Runnable.class);
+    Handler handler = new Handler(handlerThread.getLooper());
+    handler.post(mockRunnable);
+    ShadowPausedLooper shadowLooper = Shadow.extract(handlerThread.getLooper());
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void postedBackgroundLooperTasksWhenPaused() throws InterruptedException {
+    Runnable mockRunnable = mock(Runnable.class);
+    shadowOf(handlerThread.getLooper()).pause();
+    new Handler(handlerThread.getLooper()).post(mockRunnable);
+    verify(mockRunnable, timeout(20).times(0)).run();
+    assertThat(shadowOf(handlerThread.getLooper()).isIdle()).isFalse();
+    shadowOf(handlerThread.getLooper()).idle();
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void pause_backgroundLooper() {
+    assertThat(shadowOf(handlerThread.getLooper()).isPaused()).isFalse();
+    shadowOf(handlerThread.getLooper()).pause();
+    assertThat(shadowOf(handlerThread.getLooper()).isPaused()).isTrue();
+
+    shadowOf(handlerThread.getLooper()).unPause();
+    assertThat(shadowOf(handlerThread.getLooper()).isPaused()).isFalse();
+  }
+
+  @Test
+  public void idle_backgroundLooperExecuteInBackgroundThread() {
+    Ref<Thread> threadRef = new Ref<>(null);
+    new Handler(handlerThread.getLooper()).post(() -> threadRef.set(Thread.currentThread()));
+    shadowOf(handlerThread.getLooper()).idle();
+    assertThat(handlerThread.getLooper().getThread()).isEqualTo(threadRef.get());
+    assertThat(getMainLooper().getThread()).isNotEqualTo(threadRef.get());
+  }
+
+  @Test
+  public void runOneTask_backgroundLooperExecuteInBackgroundThread() {
+    shadowOf(handlerThread.getLooper()).pause();
+    Ref<Thread> threadRef = new Ref<>(null);
+    new Handler(handlerThread.getLooper()).post(() -> threadRef.set(Thread.currentThread()));
+    shadowOf(handlerThread.getLooper()).runOneTask();
+    assertThat(handlerThread.getLooper().getThread()).isEqualTo(threadRef.get());
+    assertThat(getMainLooper().getThread()).isNotEqualTo(threadRef.get());
+  }
+
+  @Test
+  public void postedDelayedBackgroundLooperTasksAreExecutedOnlyWhenSystemClockAdvanced()
+      throws InterruptedException {
+    Runnable mockRunnable = mock(Runnable.class);
+    new Handler(handlerThread.getLooper()).postDelayed(mockRunnable, 10);
+    ShadowPausedLooper shadowLooper = Shadow.extract(handlerThread.getLooper());
+    shadowLooper.idle();
+    verify(mockRunnable, times(0)).run();
+    SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100);
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void cannotIdleMainThreadFromBackgroundThread() throws InterruptedException {
+    ExecutorService executorService = newSingleThreadExecutor();
+    Future<Boolean> result =
+        executorService.submit(
+            new Callable<Boolean>() {
+              @Override
+              public Boolean call() throws Exception {
+                shadowMainLooper().idle();
+                return true;
+              }
+            });
+    try {
+      result.get();
+      fail("idling main looper from background thread unexpectedly succeeded.");
+    } catch (InterruptedException e) {
+      throw e;
+    } catch (ExecutionException e) {
+      assertThat(e.getCause()).isInstanceOf(UnsupportedOperationException.class);
+    } finally {
+      executorService.shutdown();
+    }
+  }
+
+  @Test
+  public void idle_mainLooper() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    shadowLooper.idle();
+  }
+
+  @Test
+  public void idle_executesTask_mainLooper() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void idle_executesTask_andIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+  }
+
+  @Test
+  public void idle_executesTask_andIdleHandler_removesIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+
+    mainHandler.post(mockRunnable);
+    shadowLooper.idle();
+    verify(mockRunnable, times(2)).run();
+    verify(mockIdleHandler, times(1)).queueIdle(); // It was not kept, does not run again.
+  }
+
+  @Test
+  public void idle_executesTask_andIdleHandler_keepsIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    when(mockIdleHandler.queueIdle()).thenReturn(true);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+
+    mainHandler.post(mockRunnable);
+    shadowLooper.idle();
+    verify(mockRunnable, times(2)).run();
+    verify(mockIdleHandler, times(2)).queueIdle(); // It was kept and runs again
+  }
+
+  @Test
+  public void runOneTask_executesTask_andIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.runOneTask();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+  }
+
+  @Test
+  public void runOneTask_executesTwoTasks_thenIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.runOneTask();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.runOneTask();
+    verify(mockRunnable, times(2)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+  }
+
+  @Test
+  public void runOneTask_executesTask_andIdleHandler_removesIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.runOneTask();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+
+    mainHandler.post(mockRunnable);
+    shadowLooper.idle();
+    verify(mockRunnable, times(2)).run();
+    verify(mockIdleHandler, times(1)).queueIdle(); // It was not kept, does not run again.
+  }
+
+  @Test
+  public void runOneTask_executesTask_andIdleHandler_keepsIdleHandler() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    IdleHandler mockIdleHandler = mock(IdleHandler.class);
+    when(mockIdleHandler.queueIdle()).thenReturn(true);
+    getMainLooper().getQueue().addIdleHandler(mockIdleHandler);
+    Handler mainHandler = new Handler();
+    mainHandler.post(mockRunnable);
+    verify(mockRunnable, times(0)).run();
+    verify(mockIdleHandler, times(0)).queueIdle();
+
+    shadowLooper.runOneTask();
+    verify(mockRunnable, times(1)).run();
+    verify(mockIdleHandler, times(1)).queueIdle();
+
+    mainHandler.post(mockRunnable);
+    shadowLooper.runOneTask();
+    verify(mockRunnable, times(2)).run();
+    verify(mockIdleHandler, times(2)).queueIdle(); // It was kept and runs again
+  }
+
+  @Test
+  public void idleFor_executesTask_mainLooper() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    Handler mainHandler = new Handler();
+    mainHandler.postDelayed(mockRunnable, 100);
+    verify(mockRunnable, times(0)).run();
+
+    shadowLooper.idle();
+    verify(mockRunnable, times(0)).run();
+
+    shadowLooper.idleFor(Duration.ofMillis(200));
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void idleFor_incrementsTimeTaskByTask() {
+    final Handler mainHandler = new Handler();
+
+    Runnable mockRunnable = mock(Runnable.class);
+    Runnable postingRunnable =
+        () -> {
+          mainHandler.postDelayed(mockRunnable, 100);
+        };
+    mainHandler.postDelayed(postingRunnable, 100);
+
+    verify(mockRunnable, times(0)).run();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void idleExecutesPostedRunnables() {
+    ShadowPausedLooper shadowLooper = Shadow.extract(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    Runnable postingRunnable =
+        () -> {
+          Handler mainHandler = new Handler();
+          mainHandler.post(mockRunnable);
+        };
+    Handler mainHandler = new Handler();
+    mainHandler.post(postingRunnable);
+
+    verify(mockRunnable, times(0)).run();
+    shadowLooper.idle();
+    verify(mockRunnable, times(1)).run();
+  }
+
+  @Test
+  public void getNextScheduledTime() {
+    assertThat(shadowMainLooper().getNextScheduledTaskTime()).isEqualTo(Duration.ZERO);
+    Handler mainHandler = new Handler();
+    mainHandler.postDelayed(() -> {}, 100);
+    assertThat(shadowMainLooper().getNextScheduledTaskTime().toMillis())
+        .isEqualTo(SystemClock.uptimeMillis() + 100);
+  }
+
+  @Test
+  public void getLastScheduledTime() {
+    assertThat(shadowMainLooper().getLastScheduledTaskTime()).isEqualTo(Duration.ZERO);
+    Handler mainHandler = new Handler();
+    mainHandler.postDelayed(() -> {}, 200);
+    mainHandler.postDelayed(() -> {}, 100);
+    assertThat(shadowMainLooper().getLastScheduledTaskTime().toMillis())
+        .isEqualTo(SystemClock.uptimeMillis() + 200);
+  }
+
+  @Before
+  public void assertMainLooperEmpty() {
+    ShadowPausedMessageQueue queue = Shadow.extract(getMainLooper().getQueue());
+    assertThat(queue.isIdle()).isTrue();
+  }
+
+  @Test
+  public void mainLooperQueueIsCleared() {
+    postToMainLooper();
+  }
+
+  @Test
+  public void mainLooperQueueIsClearedB() {
+    postToMainLooper();
+  }
+
+  @Test
+  public void isIdle_mainLooper() {
+    assertThat(shadowMainLooper().isIdle()).isTrue();
+    Handler mainHandler = new Handler();
+    mainHandler.post(() -> {});
+    assertThat(shadowMainLooper().isIdle()).isFalse();
+    shadowMainLooper().idle();
+    assertThat(shadowMainLooper().isIdle()).isTrue();
+  }
+
+  @Test
+  public void isIdle_delayed() {
+    assertThat(shadowMainLooper().isIdle()).isTrue();
+    Handler mainHandler = new Handler();
+    mainHandler.postDelayed(() -> {}, 100);
+    assertThat(shadowMainLooper().isIdle()).isTrue();
+    ShadowSystemClock.advanceBy(Duration.ofMillis(100));
+    assertThat(shadowMainLooper().isIdle()).isFalse();
+  }
+
+  @Test
+  public void isIdle_taskExecuting() throws InterruptedException {
+    BlockingRunnable runnable = new BlockingRunnable();
+    Handler handler = new Handler(handlerThread.getLooper());
+    handler.post(runnable);
+    assertThat(shadowOf(handlerThread.getLooper()).isIdle()).isFalse();
+    runnable.latch.countDown();
+    // poll for isIdle to be true, since it will take some time for queue to clear
+    for (int i = 0; i < 3 && !shadowOf(handlerThread.getLooper()).isIdle(); i++) {
+      Thread.sleep(10);
+    }
+    assertThat(shadowOf(handlerThread.getLooper()).isIdle()).isTrue();
+  }
+
+  @Test
+  public void isIdle_paused() throws InterruptedException {
+    ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper());
+    shadowLooper.pause();
+    assertThat(shadowLooper.isIdle()).isTrue();
+    new Handler(handlerThread.getLooper()).post(mock(Runnable.class));
+    assertThat(shadowLooper.isIdle()).isFalse();
+    shadowOf(handlerThread.getLooper()).idle();
+    assertThat(shadowLooper.isIdle()).isTrue();
+  }
+
+  @Test
+  public void quitFromSameThread_releasesLooperThread() throws Exception {
+    HandlerThread thread = new HandlerThread("WillBeQuit");
+    thread.start();
+    Looper looper = thread.getLooper();
+    new Handler(looper).post(looper::quit);
+    thread.join(5_000);
+    assertThat(thread.getState()).isEqualTo(Thread.State.TERMINATED);
+  }
+
+  @Test
+  public void quitPausedFromSameThread_releasesLooperThread() throws Exception {
+    HandlerThread thread = new HandlerThread("WillBeQuit");
+    thread.start();
+    Looper looper = thread.getLooper();
+    shadowOf(looper).pause();
+    new Handler(looper).post(looper::quit);
+    shadowOf(looper).idle();
+    thread.join(5_000);
+    assertThat(thread.getState()).isEqualTo(Thread.State.TERMINATED);
+  }
+
+  @Test
+  public void quitPausedFromDifferentThread_releasesLooperThread() throws Exception {
+    HandlerThread thread = new HandlerThread("WillBeQuit");
+    thread.start();
+    Looper looper = thread.getLooper();
+    shadowOf(looper).pause();
+    looper.quit();
+    thread.join(5_000);
+    assertThat(thread.getState()).isEqualTo(Thread.State.TERMINATED);
+  }
+
+  @Test
+  public void idle_failsIfThreadQuit() {
+    ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper());
+    handlerThread.quit();
+    try {
+      shadowLooper.idle();
+      fail("IllegalStateException not thrown");
+    } catch (IllegalStateException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void resetter_allowsStaticHandlerThreadsToBeReused() throws Exception {
+    Handler handler = new Handler(handlerThread.getLooper());
+    CountDownLatch countDownLatch1 = new CountDownLatch(1);
+    handler.post(countDownLatch1::countDown);
+    assertThat(countDownLatch1.await(30, SECONDS)).isTrue();
+    ShadowPausedLooper.resetLoopers();
+    CountDownLatch countDownLatch2 = new CountDownLatch(1);
+    handler.post(countDownLatch2::countDown);
+    assertThat(countDownLatch2.await(30, SECONDS)).isTrue();
+  }
+
+  @Test
+  public void testIdleNotStuck_whenThreadCrashes() throws Exception {
+    HandlerThread thread = new HandlerThread("WillCrash");
+    thread.start();
+    Looper looper = thread.getLooper();
+    shadowOf(looper).pause();
+    new Handler(looper)
+        .post(
+            () -> {
+              Looper.myQueue()
+                  .addIdleHandler(
+                      () -> {
+                        throw new RuntimeException();
+                      });
+            });
+    shadowOf(looper).idle();
+    thread.join(5_000);
+    assertThat(thread.getState()).isEqualTo(Thread.State.TERMINATED);
+  }
+
+  @Test
+  public void poll() {
+    ShadowPausedLooper shadowPausedLooper = Shadow.extract(Looper.getMainLooper());
+    AtomicBoolean backgroundThreadPosted = new AtomicBoolean();
+    AtomicBoolean foregroundThreadReceived = new AtomicBoolean();
+    shadowPausedLooper.idle();
+
+    new Handler(handlerThread.getLooper())
+        .post(
+            () -> {
+              backgroundThreadPosted.set(true);
+              new Handler(Looper.getMainLooper()).post(() -> foregroundThreadReceived.set(true));
+            });
+    shadowPausedLooper.poll(0);
+    shadowPausedLooper.idle();
+
+    assertThat(backgroundThreadPosted.get()).isTrue();
+    assertThat(foregroundThreadReceived.get()).isTrue();
+  }
+
+  private static class BlockingRunnable implements Runnable {
+    CountDownLatch latch = new CountDownLatch(1);
+
+    @Override
+    public void run() {
+      try {
+        latch.await();
+      } catch (InterruptedException e) {
+      }
+    }
+  }
+
+  private void postToMainLooper() {
+    // just post a runnable and rely on setUp to check
+    Handler handler = new Handler(getMainLooper());
+    Runnable mockRunnable = mock(Runnable.class);
+    handler.post(mockRunnable);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedMessageQueueTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedMessageQueueTest.java
new file mode 100644
index 0000000..f0fe96f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedMessageQueueTest.java
@@ -0,0 +1,131 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(PAUSED)
+public class ShadowPausedMessageQueueTest {
+  private MessageQueue queue;
+  private ShadowPausedMessageQueue shadowQueue;
+
+  @Before
+  public void setUp() throws Exception {
+    queue =
+        ReflectionHelpers.callConstructor(
+            MessageQueue.class, ClassParameter.from(boolean.class, true));
+    shadowQueue = Shadow.extract(queue);
+  }
+
+  @After
+  public void tearDown() {
+    if (shadowQueue != null) {
+      shadowQueue.quit();
+    }
+  }
+
+  @Test
+  public void isIdle_initial() {
+    assertThat(shadowQueue.isIdle()).isTrue();
+  }
+
+  @Test
+  public void isIdle_withMsg() {
+    Message msg = Message.obtain();
+    msg.setTarget(new Handler());
+    shadowQueue.doEnqueueMessage(msg, 0);
+    assertThat(shadowQueue.isIdle()).isFalse();
+  }
+
+  @Test
+  public void next_withMsg() {
+    Message msg = Message.obtain();
+    msg.setTarget(new Handler());
+    shadowQueue.doEnqueueMessage(msg, 0);
+    Message actual = shadowQueue.getNext();
+    assertThat(actual).isNotNull();
+  }
+
+  @Test
+  public void next_blocks() throws InterruptedException {
+    Message msg = Message.obtain();
+    msg.setTarget(new Handler());
+    NextThread t = NextThread.startSync(shadowQueue);
+    shadowQueue.doEnqueueMessage(msg, 0);
+    t.join();
+  }
+
+  @Test
+  public void next_releasedOnClockIncrement() throws InterruptedException {
+    Message msg = Message.obtain();
+    msg.setTarget(new Handler());
+    shadowQueue.doEnqueueMessage(msg, TimeUnit.MINUTES.toMillis(10));
+    NextThread t = NextThread.startSync(shadowQueue);
+    ShadowSystemClock.advanceBy(Duration.ofMinutes(10));
+    t.join();
+  }
+
+  @Test
+  public void reset_clearsMsg1() {
+    assertMainQueueEmptyAndAdd();
+  }
+
+  @Test
+  public void reset_clearsMsg2() {
+    assertMainQueueEmptyAndAdd();
+  }
+
+  private void assertMainQueueEmptyAndAdd() {
+    MessageQueue mainQueue = Looper.getMainLooper().getQueue();
+    ShadowPausedMessageQueue shadowPausedMessageQueue = Shadow.extract(mainQueue);
+    assertThat(shadowPausedMessageQueue.getMessages()).isNull();
+    Message msg = Message.obtain();
+    msg.setTarget(new Handler());
+    shadowPausedMessageQueue.doEnqueueMessage(msg, 0);
+  }
+
+  private static class NextThread extends Thread {
+
+    private final CountDownLatch latch = new CountDownLatch(1);
+    private final ShadowPausedMessageQueue shadowQueue;
+
+    private NextThread(ShadowPausedMessageQueue shadowQueue) {
+      this.shadowQueue = shadowQueue;
+    }
+
+    @Override
+    public void run() {
+      latch.countDown();
+      shadowQueue.getNext();
+    }
+
+    public static NextThread startSync(ShadowPausedMessageQueue shadowQueue)
+        throws InterruptedException {
+      NextThread t = new NextThread(shadowQueue);
+      t.start();
+      t.latch.await();
+      while (!shadowQueue.isPolling()) {
+        Thread.yield();
+      }
+      assertThat(t.isAlive()).isTrue();
+      return t;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedSystemClockTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedSystemClockTest.java
new file mode 100644
index 0000000..b026152
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedSystemClockTest.java
@@ -0,0 +1,182 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.DateTimeException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.internal.bytecode.RobolectricInternals;
+
+@RunWith(AndroidJUnit4.class)
+@LooperMode(PAUSED)
+public class ShadowPausedSystemClockTest {
+
+  @Test
+  public void sleep() {
+    assertTrue(SystemClock.setCurrentTimeMillis(1000));
+    SystemClock.sleep(34);
+    assertThat(SystemClock.uptimeMillis()).isEqualTo(1034);
+  }
+
+  @Test
+  public void sleep_notifiesListener() {
+    AtomicBoolean listenerCalled = new AtomicBoolean();
+    ShadowPausedSystemClock.addListener(() -> listenerCalled.set(true));
+
+    SystemClock.sleep(100);
+
+    assertThat(listenerCalled.get()).isTrue();
+  }
+
+  @SuppressWarnings("FutureReturnValueIgnored")
+  @Test
+  public void sleep_concurrentAccess() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    try {
+      CountDownLatch latch = new CountDownLatch(2);
+      executor.submit(
+          () -> {
+            SystemClock.sleep(100);
+            latch.countDown();
+          });
+      executor.submit(
+          () -> {
+            SystemClock.sleep(100);
+            latch.countDown();
+          });
+      latch.await();
+
+      assertThat(SystemClock.uptimeMillis()).isEqualTo(300);
+    } finally {
+      executor.shutdown();
+    }
+  }
+
+  @Test
+  public void testSetCurrentTime() {
+    assertTrue(SystemClock.setCurrentTimeMillis(1034));
+    assertThat(SystemClock.uptimeMillis()).isEqualTo(1034);
+    assertThat(SystemClock.currentThreadTimeMillis()).isEqualTo(1034);
+    assertFalse(SystemClock.setCurrentTimeMillis(1000));
+    assertThat(SystemClock.uptimeMillis()).isEqualTo(1034);
+  }
+
+  @Test
+  public void setCurrentTimeMillis_notifiesListener() {
+    AtomicBoolean listenerCalled = new AtomicBoolean();
+    ShadowPausedSystemClock.addListener(() -> listenerCalled.set(true));
+
+    SystemClock.setCurrentTimeMillis(200);
+
+    assertThat(listenerCalled.get()).isTrue();
+  }
+
+  @SuppressWarnings("FutureReturnValueIgnored")
+  @Test
+  public void setCurrentTimeMillis_concurrentAccess() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    try {
+      CountDownLatch latch = new CountDownLatch(2);
+      executor.submit(
+          () -> {
+            SystemClock.setCurrentTimeMillis(300);
+            latch.countDown();
+          });
+      executor.submit(
+          () -> {
+            SystemClock.setCurrentTimeMillis(200);
+            latch.countDown();
+          });
+      latch.await();
+
+      assertThat(SystemClock.uptimeMillis()).isEqualTo(300);
+    } finally {
+      executor.shutdown();
+    }
+  }
+
+  @Test
+  public void testElapsedRealtime() {
+    SystemClock.setCurrentTimeMillis(1000);
+    assertThat(SystemClock.elapsedRealtime()).isEqualTo(1000);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testElapsedRealtimeNanos() {
+    SystemClock.setCurrentTimeMillis(1000);
+    assertThat(SystemClock.elapsedRealtimeNanos()).isEqualTo(1000000000);
+  }
+
+  @Test
+  public void shouldInterceptSystemTimeCalls() throws Throwable {
+    long systemNanoTime =
+        (Long)
+            RobolectricInternals.intercept("java/lang/System/nanoTime()J", null, null, getClass());
+    assertThat(systemNanoTime).isEqualTo(TimeUnit.MILLISECONDS.toNanos(100));
+    SystemClock.setCurrentTimeMillis(1000);
+    systemNanoTime =
+        (Long)
+            RobolectricInternals.intercept("java/lang/System/nanoTime()J", null, null, getClass());
+    assertThat(systemNanoTime).isEqualTo(TimeUnit.MILLISECONDS.toNanos(1000));
+    long systemMilliTime =
+        (Long)
+            RobolectricInternals.intercept(
+                "java/lang/System/currentTimeMillis()J", null, null, getClass());
+    assertThat(systemMilliTime).isEqualTo(1000);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void currentNetworkTimeMillis_networkTimeAvailable_shouldReturnCurrentTime() {
+    assertThat(SystemClock.currentNetworkTimeMillis()).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void currentNetworkTimeMillis_networkTimeNotAvailable_shouldThrowDateTimeException() {
+    ShadowSystemClock.setNetworkTimeAvailable(false);
+    try {
+      SystemClock.currentNetworkTimeMillis();
+      fail("Trying to get currentNetworkTimeMillis without network time should throw");
+    } catch (DateTimeException e) {
+      // pass
+    }
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void currentGnssTimeClock_shouldReturnGnssTime() {
+    ShadowSystemClock.setGnssTimeAvailable(true);
+    SystemClock.setCurrentTimeMillis(123456L);
+    assertThat(SystemClock.currentGnssTimeClock().millis()).isEqualTo(123456);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void currentGnssTimeClock_shouldThrowDateTimeException() {
+    ShadowSystemClock.setGnssTimeAvailable(false);
+    try {
+      SystemClock.currentGnssTimeClock().millis();
+      fail("Trying to get currentGnssTimeClock without gnss time should throw");
+    } catch (DateTimeException e) {
+      // pass
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPeerHandleTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPeerHandleTest.java
new file mode 100644
index 0000000..ea2f3e7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPeerHandleTest.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.wifi.aware.PeerHandle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowPeerHandle}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O)
+public class ShadowPeerHandleTest {
+
+  @Test
+  public void canCreatePeerHandleViaNewInstance() {
+    PeerHandle peerHandle = ShadowPeerHandle.newInstance();
+    assertThat(peerHandle).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPendingIntentTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPendingIntentTest.java
new file mode 100644
index 0000000..7d50e6f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPendingIntentTest.java
@@ -0,0 +1,937 @@
+package org.robolectric.shadows;
+
+import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
+import static android.app.PendingIntent.FLAG_IMMUTABLE;
+import static android.app.PendingIntent.FLAG_NO_CREATE;
+import static android.app.PendingIntent.FLAG_ONE_SHOT;
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Parcel;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPendingIntentTest {
+
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void getBroadcast_shouldCreateIntentForBroadcast() {
+    Intent intent = new Intent();
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 99, intent, 100);
+
+    ShadowPendingIntent shadow = shadowOf(pendingIntent);
+    assertThat(shadow.isActivityIntent()).isFalse();
+    assertThat(shadow.isBroadcastIntent()).isTrue();
+    assertThat(shadow.isServiceIntent()).isFalse();
+    assertThat(shadow.isForegroundServiceIntent()).isFalse();
+    assertThat(intent).isEqualTo(shadow.getSavedIntent());
+    assertThat(context).isEqualTo(shadow.getSavedContext());
+    assertThat(shadow.getRequestCode()).isEqualTo(99);
+    assertThat(shadow.getFlags()).isEqualTo(100);
+  }
+
+  @Test
+  public void getActivity_withBundle_shouldCreateIntentForBroadcast() {
+    Intent intent = new Intent();
+    Bundle bundle = new Bundle();
+    bundle.putInt("weight", 741);
+    bundle.putString("name", "Ada");
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, 99, intent, 100, bundle);
+
+    ShadowPendingIntent shadow = shadowOf(pendingIntent);
+    assertThat(shadow.isActivityIntent()).isTrue();
+    assertThat(shadow.isBroadcastIntent()).isFalse();
+    assertThat(shadow.isServiceIntent()).isFalse();
+    assertThat(shadow.isForegroundServiceIntent()).isFalse();
+    assertThat(intent).isEqualTo(shadow.getSavedIntent());
+    assertThat(context).isEqualTo(shadow.getSavedContext());
+    assertThat(shadow.getRequestCode()).isEqualTo(99);
+    assertThat(shadow.getFlags()).isEqualTo(100);
+    assertThat(shadow.getOptions().getInt("weight")).isEqualTo(741);
+    assertThat(shadow.getOptions().getString("name")).isEqualTo("Ada");
+  }
+
+  @Test
+  public void getActivities_shouldCreateIntentForBroadcast() throws Exception {
+    Intent[] intents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, 100);
+
+    ShadowPendingIntent shadow = shadowOf(pendingIntent);
+    assertThat(shadow.getSavedIntents()).isEqualTo(intents);
+
+    pendingIntent.send();
+    ShadowApplication application =
+        shadowOf((Application) ApplicationProvider.getApplicationContext());
+    assertThat(application.getNextStartedActivity()).isEqualTo(intents[1]);
+    assertThat(application.getNextStartedActivity()).isEqualTo(intents[0]);
+  }
+
+  @Test
+  public void getActivities_withBundle_shouldCreateIntentForBroadcast() throws Exception {
+    Intent[] intents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    Bundle bundle = new Bundle();
+    bundle.putInt("weight", 741);
+    bundle.putString("name", "Ada");
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, 100, bundle);
+
+    ShadowPendingIntent shadow = shadowOf(pendingIntent);
+    assertThat(shadow.getSavedIntents()).isEqualTo(intents);
+
+    pendingIntent.send();
+    ShadowApplication application =
+        shadowOf((Application) ApplicationProvider.getApplicationContext());
+    assertThat(application.getNextStartedActivity()).isEqualTo(intents[1]);
+    assertThat(application.getNextStartedActivity()).isEqualTo(intents[0]);
+    assertThat(shadow.getOptions().getInt("weight")).isEqualTo(741);
+    assertThat(shadow.getOptions().getString("name")).isEqualTo("Ada");
+  }
+
+  @Test
+  public void getService_shouldCreateIntentForBroadcast() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent pendingIntent = PendingIntent.getService(context, 99, intent, 100);
+
+    ShadowPendingIntent shadow = shadowOf(pendingIntent);
+    assertThat(shadow.isActivityIntent()).isFalse();
+    assertThat(shadow.isBroadcastIntent()).isFalse();
+    assertThat(shadow.isForegroundServiceIntent()).isFalse();
+    assertThat(shadow.isServiceIntent()).isTrue();
+    assertThat(intent).isEqualTo(shadow.getSavedIntent());
+    assertThat(context).isEqualTo(shadow.getSavedContext());
+    assertThat(shadow.getRequestCode()).isEqualTo(99);
+    assertThat(shadow.getFlags()).isEqualTo(100);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void getForegroundService_shouldCreateIntentForBroadcast() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent pendingIntent = PendingIntent.getForegroundService(context, 99, intent, 100);
+
+    ShadowPendingIntent shadow = shadowOf(pendingIntent);
+    assertThat(shadow.isActivityIntent()).isFalse();
+    assertThat(shadow.isBroadcastIntent()).isFalse();
+    assertThat(shadow.isForegroundServiceIntent()).isTrue();
+    assertThat(shadow.isServiceIntent()).isFalse();
+    assertThat(intent).isEqualTo(shadow.getSavedIntent());
+    assertThat(context).isEqualTo(shadow.getSavedContext());
+    assertThat(shadow.getRequestCode()).isEqualTo(99);
+    assertThat(shadow.getFlags()).isEqualTo(100);
+  }
+
+  @Test
+  public void getActivities_nullIntent() {
+    try {
+      PendingIntent.getActivities(context, 99, null, 100);
+      fail("Expected NullPointerException when creating PendingIntent with null Intent[]");
+    } catch (NullPointerException ignore) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getActivities_withBundle_nullIntent() {
+    try {
+      PendingIntent.getActivities(context, 99, null, 100, Bundle.EMPTY);
+      fail("Expected NullPointerException when creating PendingIntent with null Intent[]");
+    } catch (NullPointerException ignore) {
+      // expected
+    }
+  }
+
+  @Test
+  public void send_shouldFillInIntentData() throws Exception {
+    Intent intent = new Intent("action");
+    Context context = Robolectric.setupActivity(Activity.class);
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, 99, intent, 100);
+
+    Activity otherContext = Robolectric.setupActivity(Activity.class);
+    Intent fillIntent = new Intent().putExtra("TEST", 23);
+    pendingIntent.send(otherContext, 0, fillIntent);
+
+    Intent i = shadowOf(otherContext).getNextStartedActivity();
+    assertThat(i).isNotNull();
+    assertThat(i.filterEquals(intent)).isTrue(); // Ignore extras.
+    assertThat(i.getIntExtra("TEST", -1)).isEqualTo(23);
+  }
+
+  @Test
+  public void send_shouldNotReusePreviouslyFilledInIntentData() throws Exception {
+    Intent intent = new Intent("action");
+    Context context = Robolectric.setupActivity(Activity.class);
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, 99, intent, 100);
+
+    Activity otherContext = Robolectric.setupActivity(Activity.class);
+    Intent firstFillIntent = new Intent().putExtra("KEY1", 23).putExtra("KEY2", 24);
+    pendingIntent.send(otherContext, 0, firstFillIntent);
+
+    ShadowActivity shadowActivity = shadowOf(otherContext);
+    shadowActivity.clearNextStartedActivities();
+
+    Intent secondFillIntent = new Intent().putExtra("KEY1", 50);
+    pendingIntent.send(otherContext, 0, secondFillIntent);
+
+    Intent i = shadowActivity.getNextStartedActivity();
+    assertThat(i).isNotNull();
+    assertThat(i.filterEquals(intent)).isTrue(); // Ignore extras.
+    assertThat(i.getIntExtra("KEY1", -1)).isEqualTo(50);
+    assertThat(i.hasExtra("KEY2")).isFalse();
+  }
+
+  @Test
+  public void send_shouldFillInLastIntentData() throws Exception {
+    Intent[] intents = {new Intent("first"), new Intent("second")};
+    Context context = Robolectric.setupActivity(Activity.class);
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, 100);
+
+    Activity otherContext = Robolectric.setupActivity(Activity.class);
+    Intent fillIntent = new Intent();
+    fillIntent.putExtra("TEST", 23);
+    pendingIntent.send(otherContext, 0, fillIntent);
+
+    ShadowActivity shadowActivity = shadowOf(otherContext);
+    Intent second = shadowActivity.getNextStartedActivity();
+    assertThat(second).isNotNull();
+    assertThat(second.filterEquals(intents[1])).isTrue(); // Ignore extras.
+    assertThat(second.getIntExtra("TEST", -1)).isEqualTo(23);
+
+    Intent first = shadowActivity.getNextStartedActivity();
+    assertThat(first).isNotNull();
+    assertThat(first).isSameInstanceAs(intents[0]);
+    assertThat(first.hasExtra("TEST")).isFalse();
+  }
+
+  @Test
+  public void send_shouldNotUsePreviouslyFilledInLastIntentData() throws Exception {
+    Intent[] intents = {new Intent("first"), new Intent("second")};
+    Context context = Robolectric.setupActivity(Activity.class);
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, 100);
+
+    Activity otherContext = Robolectric.setupActivity(Activity.class);
+    Intent firstFillIntent = new Intent();
+    firstFillIntent.putExtra("KEY1", 23);
+    firstFillIntent.putExtra("KEY2", 24);
+    pendingIntent.send(otherContext, 0, firstFillIntent);
+
+    ShadowActivity shadowActivity = shadowOf(otherContext);
+    shadowActivity.clearNextStartedActivities();
+
+    Intent secondFillIntent = new Intent();
+    secondFillIntent.putExtra("KEY1", 50);
+    pendingIntent.send(otherContext, 0, secondFillIntent);
+
+    Intent second = shadowActivity.getNextStartedActivity();
+    assertThat(second).isNotNull();
+    assertThat(second.filterEquals(intents[1])).isTrue(); // Ignore extras.
+    assertThat(second.getIntExtra("KEY1", -1)).isEqualTo(50);
+    assertThat(second.hasExtra("KEY2")).isFalse();
+  }
+
+  @Test
+  public void send_shouldNotFillIn_whenPendingIntentIsImmutable() throws Exception {
+    Intent intent = new Intent();
+    Context context = Robolectric.setupActivity(Activity.class);
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE);
+
+    Activity otherContext = Robolectric.setupActivity(Activity.class);
+    Intent fillIntent = new Intent().putExtra("TEST", 23);
+    pendingIntent.send(otherContext, 0, fillIntent);
+
+    Intent i = shadowOf(otherContext).getNextStartedActivity();
+    assertThat(i).isNotNull();
+    assertThat(i).isSameInstanceAs(intent);
+    assertThat(i.hasExtra("TEST")).isFalse();
+  }
+
+  @Test
+  public void updatePendingIntent() {
+    Intent intent = new Intent().putExtra("whatever", 5);
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0);
+    ShadowPendingIntent shadowPendingIntent = shadowOf(pendingIntent);
+
+    // Absent FLAG_UPDATE_CURRENT, this should fail to update the Intent extra.
+    intent = new Intent().putExtra("whatever", 77);
+    PendingIntent.getBroadcast(context, 0, intent, 0);
+    assertThat(shadowPendingIntent.getSavedIntent().getIntExtra("whatever", -1)).isEqualTo(5);
+
+    // With FLAG_UPDATE_CURRENT, this should succeed in updating the Intent extra.
+    PendingIntent.getBroadcast(context, 0, intent, FLAG_UPDATE_CURRENT);
+    assertThat(shadowPendingIntent.getSavedIntent().getIntExtra("whatever", -1)).isEqualTo(77);
+  }
+
+  @Test
+  public void getActivity_withFlagNoCreate_shouldReturnNullIfNoPendingIntentExists() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getActivity(context, 99, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void getActivity_withFlagNoCreate_shouldReturnNullIfRequestCodeIsUnmatched() {
+    Intent intent = new Intent();
+    PendingIntent.getActivity(context, 99, intent, 0);
+    assertThat(PendingIntent.getActivity(context, 98, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void getActivity_withFlagNoCreate_shouldReturnExistingIntent() {
+    Intent intent = new Intent();
+    PendingIntent.getActivity(context, 99, intent, 100);
+
+    Intent identical = new Intent();
+    PendingIntent saved = PendingIntent.getActivity(context, 99, identical, FLAG_NO_CREATE);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  public void getActivity_withNoFlags_shouldReturnExistingIntent() {
+    Intent intent = new Intent();
+    PendingIntent.getActivity(context, 99, intent, 100);
+
+    Intent updated = new Intent();
+    PendingIntent saved = PendingIntent.getActivity(context, 99, updated, 0);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  public void getActivities_withFlagNoCreate_shouldReturnNullIfNoPendingIntentExists() {
+    Intent[] intents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, FLAG_NO_CREATE);
+    assertThat(pendingIntent).isNull();
+  }
+
+  @Test
+  public void getActivities_withFlagNoCreate_shouldReturnNullIfRequestCodeIsUnmatched() {
+    Intent[] intents = {new Intent()};
+    PendingIntent.getActivities(context, 99, intents, 0);
+    assertThat(PendingIntent.getActivities(context, 98, intents, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void getActivities_withFlagNoCreate_shouldReturnExistingIntent() {
+    Intent[] intents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent.getActivities(ApplicationProvider.getApplicationContext(), 99, intents, 100);
+
+    Intent[] identicalIntents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent saved =
+        PendingIntent.getActivities(context, 99, identicalIntents, FLAG_NO_CREATE);
+    assertThat(saved).isNotNull();
+    assertThat(intents).isSameInstanceAs(shadowOf(saved).getSavedIntents());
+  }
+
+  @Test
+  public void getActivities_withNoFlags_shouldReturnExistingIntent() {
+    Intent[] intents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent.getActivities(ApplicationProvider.getApplicationContext(), 99, intents, 100);
+
+    Intent[] identicalIntents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent saved = PendingIntent.getActivities(context, 99, identicalIntents, 0);
+    assertThat(saved).isNotNull();
+    assertThat(intents).isSameInstanceAs(shadowOf(saved).getSavedIntents());
+  }
+
+  @Test
+  public void getBroadcast_withFlagNoCreate_shouldReturnNullIfNoPendingIntentExists() {
+    Intent intent = new Intent();
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 99, intent, FLAG_NO_CREATE);
+    assertThat(pendingIntent).isNull();
+  }
+
+  @Test
+  public void getBroadcast_withFlagNoCreate_shouldReturnNullIfRequestCodeIsUnmatched() {
+    Intent intent = new Intent();
+    PendingIntent.getBroadcast(context, 99, intent, 0);
+    assertThat(PendingIntent.getBroadcast(context, 98, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void getBroadcast_withFlagNoCreate_shouldReturnExistingIntent() {
+    Intent intent = new Intent();
+    PendingIntent.getBroadcast(context, 99, intent, 100);
+
+    Intent identical = new Intent();
+    PendingIntent saved = PendingIntent.getBroadcast(context, 99, identical, FLAG_NO_CREATE);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  public void getBroadcast_withNoFlags_shouldReturnExistingIntent() {
+    Intent intent = new Intent();
+    PendingIntent.getBroadcast(context, 99, intent, 100);
+
+    Intent identical = new Intent();
+    PendingIntent saved = PendingIntent.getBroadcast(context, 99, identical, 0);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  public void getService_withFlagNoCreate_shouldReturnNullIfNoPendingIntentExists() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent pendingIntent = PendingIntent.getService(context, 99, intent, FLAG_NO_CREATE);
+    assertThat(pendingIntent).isNull();
+  }
+
+  @Test
+  public void getService_withFlagNoCreate_shouldReturnNullIfRequestCodeIsUnmatched() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent.getService(context, 99, intent, 0);
+    assertThat(PendingIntent.getService(context, 98, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void getService_withFlagNoCreate_shouldReturnExistingIntent() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent.getService(context, 99, intent, 100);
+
+    Intent identical = new Intent().setPackage("dummy.package");
+    PendingIntent saved = PendingIntent.getService(context, 99, identical, FLAG_NO_CREATE);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  public void getService_withNoFlags_shouldReturnExistingIntent() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent.getService(context, 99, intent, 100);
+
+    Intent identical = new Intent().setPackage("dummy.package");
+    PendingIntent saved = PendingIntent.getService(context, 99, identical, 0);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void getForegroundService_withFlagNoCreate_shouldReturnNullIfNoPendingIntentExists() {
+    Intent intent = new Intent();
+    PendingIntent pendingIntent =
+        PendingIntent.getForegroundService(context, 99, intent, FLAG_NO_CREATE);
+    assertThat(pendingIntent).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void getForegroundService_withFlagNoCreate_shouldReturnNullIfRequestCodeIsUnmatched() {
+    Intent intent = new Intent();
+    PendingIntent.getForegroundService(context, 99, intent, 0);
+    assertThat(PendingIntent.getForegroundService(context, 98, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void getForegroundService_withFlagNoCreate_shouldReturnExistingIntent() {
+    Intent intent = new Intent();
+    PendingIntent.getForegroundService(context, 99, intent, 100);
+
+    Intent identical = new Intent();
+    PendingIntent saved =
+        PendingIntent.getForegroundService(context, 99, identical, FLAG_NO_CREATE);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void getForegroundService_withNoFlags_shouldReturnExistingIntent() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent.getForegroundService(context, 99, intent, 100);
+
+    Intent identical = new Intent().setPackage("dummy.package");
+    PendingIntent saved = PendingIntent.getForegroundService(context, 99, identical, 0);
+    assertThat(saved).isNotNull();
+    assertThat(intent).isSameInstanceAs(shadowOf(saved).getSavedIntent());
+  }
+
+  @Test
+  public void cancel_shouldRemovePendingIntentForBroadcast() {
+    Intent intent = new Intent();
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 99, intent, 100);
+    assertThat(pendingIntent).isNotNull();
+
+    pendingIntent.cancel();
+    assertThat(PendingIntent.getBroadcast(context, 99, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void cancel_shouldRemovePendingIntentForActivity() {
+    Intent intent = new Intent();
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, 99, intent, 100);
+    assertThat(pendingIntent).isNotNull();
+
+    pendingIntent.cancel();
+    assertThat(PendingIntent.getActivity(context, 99, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void cancel_shouldRemovePendingIntentForActivities() {
+    Intent[] intents = {new Intent(Intent.ACTION_VIEW), new Intent(Intent.ACTION_PICK)};
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, 100);
+    assertThat(pendingIntent).isNotNull();
+
+    pendingIntent.cancel();
+    assertThat(PendingIntent.getActivities(context, 99, intents, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  public void cancel_shouldRemovePendingIntentForService() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent pendingIntent = PendingIntent.getService(context, 99, intent, 100);
+    assertThat(pendingIntent).isNotNull();
+
+    pendingIntent.cancel();
+    assertThat(PendingIntent.getService(context, 99, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void cancel_shouldRemovePendingIntentForForegroundService() {
+    Intent intent = new Intent();
+    PendingIntent pendingIntent = PendingIntent.getForegroundService(context, 99, intent, 100);
+    assertThat(pendingIntent).isNotNull();
+
+    pendingIntent.cancel();
+    assertThat(PendingIntent.getForegroundService(context, 99, intent, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isActivity_activityPendingIntent_returnsTrue() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getActivity(context, 99, intent, 100).isActivity()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isActivity_broadcastPendingIntent_returnsFalse() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getBroadcast(context, 99, intent, 100).isActivity()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isBroadcast_broadcastPendingIntent_returnsTrue() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getBroadcast(context, 99, intent, 100).isBroadcast()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isBroadcast_activityPendingIntent_returnsFalse() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getActivity(context, 99, intent, 100).isBroadcast()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isForegroundService_foregroundServicePendingIntent_returnsTrue() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getForegroundService(context, 99, intent, 100).isForegroundService())
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isForegroundService_normalServicePendingIntent_returnsFalse() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getService(context, 99, intent, 100).isForegroundService()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isService_servicePendingIntent_returnsTrue() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getService(context, 99, intent, 100).isService()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isService_foregroundServicePendingIntent_returnsFalse() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getForegroundService(context, 99, intent, 100).isService()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isImmutable_pendingIntentWithImmutableFlag_returnsTrue() {
+    Intent intent = new Intent();
+    assertThat(
+            PendingIntent.getActivity(context, 99, intent, PendingIntent.FLAG_IMMUTABLE)
+                .isImmutable())
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.S)
+  public void isImmutable_pendingIntentWithoutImmutableFlag_returnsFalse() {
+    Intent intent = new Intent();
+    assertThat(PendingIntent.getActivity(context, 99, intent, 0).isActivity()).isTrue();
+  }
+
+  @Test
+  public void send_canceledPendingIntent_throwsCanceledException() throws CanceledException {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent canceled = PendingIntent.getService(context, 99, intent, 100);
+    assertThat(canceled).isNotNull();
+
+    // Cancel the existing PendingIntent and create a new one in its place.
+    PendingIntent current = PendingIntent.getService(context, 99, intent, FLAG_CANCEL_CURRENT);
+    assertThat(current).isNotNull();
+
+    assertThat(shadowOf(canceled).isCanceled()).isTrue();
+    assertThat(shadowOf(current).isCanceled()).isFalse();
+
+    // Sending the new PendingIntent should work as expected.
+    current.send();
+
+    // Sending the canceled PendingIntent should produce a CanceledException.
+    try {
+      canceled.send();
+      fail("CanceledException expected when sending a canceled PendingIntent");
+    } catch (CanceledException ignore) {
+      // expected
+    }
+  }
+
+  @Test
+  public void send_oneShotPendingIntent_shouldCancel() throws CanceledException {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, FLAG_ONE_SHOT);
+    assertThat(shadowOf(pendingIntent).isCanceled()).isFalse();
+
+    pendingIntent.send();
+    assertThat(shadowOf(pendingIntent).isCanceled()).isTrue();
+    assertThat(PendingIntent.getService(context, 0, intent, FLAG_ONE_SHOT | FLAG_NO_CREATE))
+        .isNull();
+  }
+
+  @Test
+  public void send_resultCode() throws CanceledException {
+    AtomicInteger resultCode = new AtomicInteger(0);
+    context.registerReceiver(
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            resultCode.set(getResultCode());
+          }
+        },
+        new IntentFilter("foo"));
+
+    PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, new Intent("foo"), 0);
+    pendingIntent.send(99);
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(resultCode.get()).isEqualTo(99);
+  }
+
+  /** Verify options are sent along with the PendingIntent. */
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void send_broadcastWithOptions() throws CanceledException {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(context, /* requestCode= */ 0, intent, /* flags= */ 0);
+
+    // Add an option for the PendingIntent broadcast.
+    final String keyDontSendToRestrictedApps = "android:broadcast.dontSendToRestrictedApps";
+    Bundle options = new Bundle();
+    options.putBoolean(keyDontSendToRestrictedApps, true);
+
+    // Send the pendingIntent with options.
+    pendingIntent.send(
+        context,
+        /* code= */ 0,
+        intent,
+        /* onFinished= */ null,
+        /* handler= */ null,
+        /* requiredPermission= */ null,
+        options);
+
+    // Verify the options are used when sending the PendingIntent.
+    ShadowApplication shadowApplication = shadowOf((Application) context);
+    Bundle sendOptions = shadowApplication.getBroadcastOptions(intent);
+    assertThat(sendOptions.getBoolean(keyDontSendToRestrictedApps)).isTrue();
+  }
+
+  @Test
+  public void send_withOnFinishedCallback_callbackSavedForLaterInvocation()
+      throws CanceledException {
+    final AtomicBoolean onSendFinishedCalled = new AtomicBoolean(false);
+    PendingIntent.OnFinished onFinished =
+        new PendingIntent.OnFinished() {
+          @Override
+          public void onSendFinished(
+              PendingIntent pendingIntent,
+              Intent intent,
+              int resultCode,
+              String resultData,
+              Bundle resultExtras) {
+            onSendFinishedCalled.set(true);
+          }
+        };
+    Intent intent = new Intent();
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(context, /* requestCode= */ 0, intent, /* flags= */ 0);
+
+    pendingIntent.send(context, /* code= */ 0, intent, onFinished, /* handler= */ null);
+
+    assertThat(
+            shadowOf(pendingIntent)
+                .callLastOnFinished(
+                    intent, /* resultCode= */ 0, /* resultData= */ null, /* resultExtras= */ null))
+        .isTrue();
+    assertThat(onSendFinishedCalled.get()).isTrue();
+  }
+
+  @Test
+  public void send_withOnFinishedCallbackAndHandler_callbackSavedForLaterInvocationOnHandler()
+      throws CanceledException {
+    HandlerThread handlerThread = new HandlerThread("test");
+    handlerThread.start();
+    Handler handler = new Handler(handlerThread.getLooper());
+    final AtomicBoolean onSendFinishedCalled = new AtomicBoolean(false);
+    PendingIntent.OnFinished onFinished =
+        new PendingIntent.OnFinished() {
+          @Override
+          public void onSendFinished(
+              PendingIntent pendingIntent,
+              Intent intent,
+              int resultCode,
+              String resultData,
+              Bundle resultExtras) {
+            onSendFinishedCalled.set(true);
+          }
+        };
+    Intent intent = new Intent();
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(context, /* requestCode= */ 0, intent, /* flags= */ 0);
+
+    pendingIntent.send(context, /* code= */ 0, intent, onFinished, handler);
+
+    assertThat(
+            shadowOf(pendingIntent)
+                .callLastOnFinished(
+                    intent, /* resultCode= */ 0, /* resultData= */ null, /* resultExtras= */ null))
+        .isTrue();
+    shadowOf(handlerThread.getLooper()).idle();
+    assertThat(onSendFinishedCalled.get()).isTrue();
+    handlerThread.quit();
+  }
+
+  @Test
+  public void send_withOutOnFinishedCallback_onFinishedCallbackReset() throws CanceledException {
+    final AtomicBoolean onSendFinishedCalled = new AtomicBoolean(false);
+    PendingIntent.OnFinished onFinished =
+        new PendingIntent.OnFinished() {
+          @Override
+          public void onSendFinished(
+              PendingIntent pendingIntent,
+              Intent intent,
+              int resultCode,
+              String resultData,
+              Bundle resultExtras) {
+            onSendFinishedCalled.set(true);
+          }
+        };
+    Intent intent = new Intent();
+    PendingIntent pendingIntent =
+        PendingIntent.getBroadcast(context, /* requestCode= */ 0, intent, /* flags= */ 0);
+
+    pendingIntent.send(context, /* code= */ 0, intent, onFinished, /* handler= */ null);
+    onSendFinishedCalled.set(false);
+    pendingIntent.send(context, /* code= */ 0, intent);
+
+    assertThat(
+            shadowOf(pendingIntent)
+                .callLastOnFinished(
+                    intent, /* resultCode= */ 0, /* resultData= */ null, /* resultExtras= */ null))
+        .isFalse();
+    assertThat(onSendFinishedCalled.get()).isFalse();
+  }
+
+  @Test
+  public void oneShotFlag_differentiatesPendingIntents() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent oneShot = PendingIntent.getService(context, 0, intent, FLAG_ONE_SHOT);
+    PendingIntent notOneShot = PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT);
+    assertThat(oneShot).isNotSameInstanceAs(notOneShot);
+  }
+
+  @Test
+  public void immutableFlag_differentiatesPendingIntents() {
+    Intent intent = new Intent().setPackage("dummy.package");
+    PendingIntent immutable = PendingIntent.getService(context, 0, intent, FLAG_IMMUTABLE);
+    PendingIntent notImmutable = PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT);
+    assertThat(immutable).isNotSameInstanceAs(notImmutable);
+  }
+
+  @Test
+  public void testEquals() {
+    PendingIntent pendingIntent =
+        PendingIntent.getActivity(context, 99, new Intent("activity"), 100);
+
+    // Same type, requestCode and Intent action implies equality.
+    assertThat(PendingIntent.getActivity(context, 99, new Intent("activity"), FLAG_NO_CREATE))
+        .isSameInstanceAs(pendingIntent);
+
+    // Mismatched Intent action implies inequality.
+    assertThat(PendingIntent.getActivity(context, 99, new Intent("activity2"), FLAG_NO_CREATE))
+        .isNull();
+
+    // Mismatched requestCode implies inequality.
+    assertThat(PendingIntent.getActivity(context, 999, new Intent("activity"), FLAG_NO_CREATE))
+        .isNull();
+
+    // Mismatched types imply inequality.
+    assertThat(PendingIntent.getBroadcast(context, 99, new Intent("activity"), FLAG_NO_CREATE))
+        .isNull();
+    assertThat(PendingIntent.getService(context, 99, new Intent("activity"), FLAG_NO_CREATE))
+        .isNull();
+  }
+
+  @Test
+  public void testEquals_getActivities() {
+    Intent[] intents = {new Intent("activity"), new Intent("activity2")};
+    PendingIntent pendingIntent = PendingIntent.getActivities(context, 99, intents, 100);
+
+    Intent[] forward = {new Intent("activity"), new Intent("activity2")};
+    assertThat(PendingIntent.getActivities(context, 99, forward, FLAG_NO_CREATE))
+        .isSameInstanceAs(pendingIntent);
+
+    Intent[] irrelevant = {new Intent("irrelevant"), new Intent("activity2")};
+    assertThat(PendingIntent.getActivities(context, 99, irrelevant, FLAG_NO_CREATE))
+        .isSameInstanceAs(pendingIntent);
+
+    Intent single = new Intent("activity2");
+    assertThat(PendingIntent.getActivity(context, 99, single, FLAG_NO_CREATE))
+        .isSameInstanceAs(pendingIntent);
+
+    Intent[] backward = {new Intent("activity2"), new Intent("activity")};
+    assertThat(PendingIntent.getActivities(context, 99, backward, FLAG_NO_CREATE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1)
+  public void testGetCreatorPackage_nothingSet() {
+    PendingIntent pendingIntent =
+        PendingIntent.getActivity(context, 99, new Intent("activity"), 100);
+    assertThat(pendingIntent.getCreatorPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingIntent.getTargetPackage()).isEqualTo(context.getPackageName());
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1)
+  public void testGetCreatorPackage_explicitlySetPackage() {
+    String fakePackage = "some.fake.package";
+    PendingIntent pendingIntent =
+        PendingIntent.getActivity(context, 99, new Intent("activity"), 100);
+    shadowOf(pendingIntent).setCreatorPackage(fakePackage);
+    assertThat(pendingIntent.getCreatorPackage()).isEqualTo(fakePackage);
+    assertThat(pendingIntent.getTargetPackage()).isEqualTo(fakePackage);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1)
+  public void testGetCreatorUid() {
+    int fakeUid = 123;
+    PendingIntent pendingIntent =
+        PendingIntent.getActivity(context, 99, new Intent("activity"), 100);
+    shadowOf(pendingIntent).setCreatorUid(fakeUid);
+
+    assertThat(pendingIntent.getCreatorUid()).isEqualTo(fakeUid);
+  }
+
+  @Test
+  public void testHashCode() {
+    Context ctx = ApplicationProvider.getApplicationContext();
+    PendingIntent pendingIntent1 = PendingIntent.getActivity(ctx, 99, new Intent("activity"), 100);
+
+    assertThat(pendingIntent1.hashCode())
+        .isEqualTo(PendingIntent.getActivity(ctx, 99, new Intent("activity"), 100).hashCode());
+
+    assertThat(pendingIntent1.hashCode())
+        .isNotEqualTo(PendingIntent.getActivity(ctx, 99, new Intent("activity2"), 100).hashCode());
+
+    assertThat(pendingIntent1.hashCode())
+        .isNotEqualTo(PendingIntent.getActivity(ctx, 999, new Intent("activity"), 100).hashCode());
+  }
+
+  @Test
+  public void writeReadPendingIntentOrNullToParcel_returnsNull_whenWriteIsNull() {
+    Parcel parcel = Parcel.obtain();
+    PendingIntent.writePendingIntentOrNullToParcel(null, parcel);
+
+    parcel.setDataPosition(0);
+    PendingIntent result = PendingIntent.readPendingIntentOrNullFromParcel(parcel);
+
+    assertThat(result).isNull();
+  }
+
+  @Test
+  public void writeReadPendingIntentOrNullToParcel() {
+    PendingIntent original = PendingIntent.getService(context, 100, new Intent(), 0);
+
+    Parcel parcel = Parcel.obtain();
+    PendingIntent.writePendingIntentOrNullToParcel(original, parcel);
+
+    parcel.setDataPosition(0);
+    PendingIntent result = PendingIntent.readPendingIntentOrNullFromParcel(parcel);
+
+    assertThat(result).isEqualTo(original);
+  }
+
+  @Test
+  public void testWriteToParcel() {
+    Intent embedded = new Intent().setComponent(new ComponentName("pkg", "cls"));
+    PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, embedded, 0);
+    Parcel parcel = Parcel.obtain();
+    parcel.writeParcelable(pendingIntent, 0);
+    parcel.setDataPosition(0);
+    PendingIntent result = parcel.readParcelable(PendingIntent.class.getClassLoader());
+    assertThat(result).isEqualTo(pendingIntent);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN)
+  public void toString_doesNotNPE() {
+    assertThat(
+            PendingIntent.getBroadcast(
+                    ApplicationProvider.getApplicationContext(),
+                    100,
+                    new Intent("action"),
+                    FLAG_ONE_SHOT)
+                .toString())
+        .startsWith("PendingIntent");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPhoneTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPhoneTest.java
new file mode 100644
index 0000000..82e6a8d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPhoneTest.java
@@ -0,0 +1,73 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.telecom.Call;
+import android.telecom.Phone;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Robolectric test for {@link ShadowPhone}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = M)
+public class ShadowPhoneTest {
+
+  @Test
+  public void getZeroCall_noCall() {
+    Call[] calls = new Call[] {};
+    testAddCallGetCall(calls);
+  }
+
+  @Test
+  public void getOneCall_hasOneCall() {
+    Call call = Shadow.newInstanceOf(Call.class);
+    Call[] calls = new Call[] {call};
+    testAddCallGetCall(calls);
+  }
+
+  @Test
+  public void getTwoCall_hasTwoCall() {
+    Call call1 = Shadow.newInstanceOf(Call.class);
+    Call call2 = Shadow.newInstanceOf(Call.class);
+    Call[] calls = new Call[] {call1, call2};
+    testAddCallGetCall(calls);
+  }
+
+  @Test
+  public void addAndRemoveCalls() {
+    Call call1 = Shadow.newInstanceOf(Call.class);
+    Call call2 = Shadow.newInstanceOf(Call.class);
+    Phone phone = Shadow.newInstanceOf(Phone.class);
+    ShadowPhone shadowPhone = Shadow.extract(phone);
+
+    shadowPhone.addCall(call1);
+    shadowPhone.addCall(call2);
+    shadowPhone.removeCall(call1);
+
+    assertThat(phone.getCalls()).containsExactly(call2);
+
+    shadowPhone.removeCall(call2);
+
+    assertThat(phone.getCalls()).isEmpty();
+  }
+
+  public static void testAddCallGetCall(Call[] calls) {
+    Phone phone = Shadow.newInstanceOf(Phone.class);
+    ShadowPhone shadowPhone = Shadow.extract(phone);
+
+    for (Call call : calls) {
+      shadowPhone.addCall(call);
+    }
+
+    List<Call> callList = phone.getCalls();
+
+    for (int i = 0; i < calls.length; i++) {
+      assertThat(callList.get(i)).isEqualTo(calls[i]);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPhoneWindowTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPhoneWindowTest.java
new file mode 100644
index 0000000..019cc75
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPhoneWindowTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.graphics.drawable.Drawable;
+import android.view.Window;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPhoneWindowTest {
+
+  private Window window;
+  private Activity activity;
+
+  @Before
+  public void setUp() throws Exception {
+    activity = Robolectric.setupActivity(Activity.class);
+    window = activity.getWindow();
+  }
+
+  @Test
+  public void getTitle() {
+    window.setTitle("Some title");
+    assertThat(shadowOf(window).getTitle().toString()).isEqualTo("Some title");
+  }
+
+  @Test
+  public void getBackgroundDrawable() {
+    Drawable drawable = activity.getResources().getDrawable(android.R.drawable.bottom_bar);
+    window.setBackgroundDrawable(drawable);
+    assertThat(shadowOf(window).getBackgroundDrawable()).isSameInstanceAs(drawable);
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPictureTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPictureTest.java
new file mode 100644
index 0000000..6aaaa26
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPictureTest.java
@@ -0,0 +1,30 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Picture;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPictureTest {
+
+  @Test
+  public void beginRecordingSetsHeightAndWidth() {
+    Picture picture = new Picture();
+    picture.beginRecording(100, 100);
+    assertThat(picture.getHeight()).isEqualTo(100);
+    assertThat(picture.getWidth()).isEqualTo(100);
+  }
+
+  @Test
+  public void copyConstructor() {
+    Picture originalPicture = new Picture();
+    originalPicture.beginRecording(100, 100);
+
+    Picture copiedPicture = new Picture(originalPicture);
+    assertThat(copiedPicture.getHeight()).isEqualTo(100);
+    assertThat(copiedPicture.getWidth()).isEqualTo(100);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPopupMenuTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPopupMenuTest.java
new file mode 100644
index 0000000..118e101
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPopupMenuTest.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.View;
+import android.widget.PopupMenu;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPopupMenuTest {
+
+  private PopupMenu popupMenu;
+  private ShadowPopupMenu shadowPopupMenu;
+
+  @Before
+  public void setUp() {
+    View anchorView = new View(ApplicationProvider.getApplicationContext());
+    popupMenu = new PopupMenu(ApplicationProvider.getApplicationContext(), anchorView);
+    shadowPopupMenu = shadowOf(popupMenu);
+  }
+
+  @Test
+  public void testIsShowing_returnsFalseUponCreation() {
+    assertThat(shadowPopupMenu.isShowing()).isFalse();
+  }
+
+  @Test
+  public void testIsShowing_returnsTrueIfShown() {
+    popupMenu.show();
+    assertThat(shadowPopupMenu.isShowing()).isTrue();
+  }
+
+  @Test
+  public void testIsShowing_returnsFalseIfShownThenDismissed() {
+    popupMenu.show();
+    popupMenu.dismiss();
+    assertThat(shadowPopupMenu.isShowing()).isFalse();
+  }
+
+  @Test
+  public void getLatestPopupMenu_returnsNullUponCreation() {
+    assertThat(ShadowPopupMenu.getLatestPopupMenu()).isNull();
+  }
+
+  @Test
+  public void getLatestPopupMenu_returnsLastMenuShown() {
+    popupMenu.show();
+    assertThat(ShadowPopupMenu.getLatestPopupMenu()).isEqualTo(popupMenu);
+  }
+
+  @Test
+  public void getOnClickListener_returnsOnClickListener() {
+    assertThat(shadowOf(popupMenu).getOnMenuItemClickListener()).isNull();
+
+    PopupMenu.OnMenuItemClickListener listener = menuItem -> false;
+    popupMenu.setOnMenuItemClickListener(listener);
+
+    assertThat(shadowOf(popupMenu).getOnMenuItemClickListener()).isEqualTo(listener);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPorterDuffColorFilterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPorterDuffColorFilterTest.java
new file mode 100644
index 0000000..d144019
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPorterDuffColorFilterTest.java
@@ -0,0 +1,71 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowPorterDuffColorFilterTest {
+  @Test
+  public void constructor_shouldWork() {
+    final PorterDuffColorFilter filter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+    assertThat(filter.getColor()).isEqualTo(Color.RED);
+    assertThat(filter.getMode()).isEqualTo(PorterDuff.Mode.ADD);
+  }
+
+  @Config(minSdk = O)
+  @Test
+  public void createNativeInstance_shouldWork() {
+    final PorterDuffColorFilter filter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+    assertThat(filter.getNativeInstance()).isEqualTo(0L);
+  }
+
+  @Test
+  public void hashCode_returnsDifferentValuesForDifferentModes() {
+    PorterDuffColorFilter addFilter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+    PorterDuffColorFilter dstFilter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.DST);
+
+    assertThat(addFilter.hashCode()).isNotEqualTo(dstFilter.hashCode());
+  }
+
+  @Test
+  public void hashCode_returnsDifferentValuesForDifferentColors() {
+    PorterDuffColorFilter blueFilter = new PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.ADD);
+    PorterDuffColorFilter redFilter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+
+    assertThat(blueFilter.hashCode()).isNotEqualTo(redFilter.hashCode());
+  }
+
+  @Test
+  public void equals_returnsTrueForEqualObjects() {
+    PorterDuffColorFilter filter1 = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+    PorterDuffColorFilter filter2 = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+
+    assertThat(filter1).isEqualTo(filter2);
+  }
+
+  @Test
+  public void equals_returnsFalseForDifferentModes() {
+    PorterDuffColorFilter addFilter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+    PorterDuffColorFilter dstFilter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.DST);
+
+    assertThat(addFilter).isNotEqualTo(dstFilter);
+  }
+
+  @Test
+  public void equals_returnsFalseForDifferentColors() {
+    PorterDuffColorFilter blueFilter = new PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.ADD);
+    PorterDuffColorFilter redFilter = new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.ADD);
+
+    assertThat(blueFilter).isNotEqualTo(redFilter);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPosixTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPosixTest.java
new file mode 100644
index 0000000..05fe13a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPosixTest.java
@@ -0,0 +1,77 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.system.StructStat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.time.Duration;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Unit tests for ShadowPosix to check values returned from stat() call. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowPosixTest {
+  private File file;
+  private String path;
+
+  @Before
+  public void setUp() throws Exception {
+    file = File.createTempFile("ShadowPosixTest", null);
+    path = file.getAbsolutePath();
+    try (FileOutputStream outputStream = new FileOutputStream(file)) {
+      outputStream.write(1234);
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getStatAtLeastLollipop_returnCorrectMode() throws Exception {
+    StructStat stat = (StructStat) ShadowPosix.stat(path);
+    assertThat(stat.st_mode).isEqualTo(OsConstantsValues.S_IFREG_VALUE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getStatAtLeastLollipop_returnCorrectSize() throws Exception {
+    StructStat stat = (StructStat) ShadowPosix.stat(path);
+    assertThat(stat.st_size).isEqualTo(file.length());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getStatAtLeastLollipop_returnCorrectModifiedTime() throws Exception {
+    StructStat stat = (StructStat) ShadowPosix.stat(path);
+    assertThat(stat.st_mtime).isEqualTo(Duration.ofMillis(file.lastModified()).getSeconds());
+  }
+
+  @Test
+  @Config(maxSdk = KITKAT)
+  public void getStatBelowLollipop_returnCorrectMode() throws Exception {
+    Object stat = ShadowPosix.stat(path);
+    int mode = ReflectionHelpers.getField(stat, "st_mode");
+    assertThat(mode).isEqualTo(OsConstantsValues.S_IFREG_VALUE);
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void getStatBelowLollipop_returnCorrectSize() throws Exception {
+    Object stat = ShadowPosix.stat(path);
+    long size = ReflectionHelpers.getField(stat, "st_size");
+    assertThat(size).isEqualTo(file.length());
+  }
+
+  @Test
+  @Config(minSdk = KITKAT)
+  public void getStatBelowtLollipop_returnCorrectModifiedTime() throws Exception {
+    Object stat = ShadowPosix.stat(path);
+    long modifiedTime = ReflectionHelpers.getField(stat, "st_mtime");
+    assertThat(modifiedTime).isEqualTo(Duration.ofMillis(file.lastModified()).getSeconds());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPowerManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPowerManagerTest.java
new file mode 100644
index 0000000..96ef647
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPowerManagerTest.java
@@ -0,0 +1,609 @@
+package org.robolectric.shadows;
+
+import static android.content.Intent.ACTION_SCREEN_OFF;
+import static android.content.Intent.ACTION_SCREEN_ON;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.Intent;
+import android.os.PowerManager;
+import android.os.WorkSource;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.truth.Correspondence;
+import java.time.Duration;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPowerManagerTest {
+
+  private Application context;
+  private PowerManager powerManager;
+
+  @Before
+  public void before() {
+    context = ApplicationProvider.getApplicationContext();
+    powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+  }
+
+  @Test
+  public void acquire_shouldAcquireAndReleaseReferenceCountedLock() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    assertThat(lock.isHeld()).isFalse();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.acquire();
+
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isWakeLockLevelSupported() {
+    assertThat(powerManager.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)).isFalse();
+
+    shadowOf(powerManager).setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true);
+
+    assertThat(powerManager.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)).isTrue();
+
+    shadowOf(powerManager).setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, false);
+
+    assertThat(powerManager.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)).isFalse();
+  }
+
+  @Test
+  public void acquire_shouldLogLatestWakeLock() {
+    ShadowPowerManager.reset();
+    assertThat(ShadowPowerManager.getLatestWakeLock()).isNull();
+
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    lock.acquire();
+
+    assertThat(ShadowPowerManager.getLatestWakeLock()).isNotNull();
+    assertThat(ShadowPowerManager.getLatestWakeLock()).isSameInstanceAs(lock);
+    assertThat(lock.isHeld()).isTrue();
+
+    lock.release();
+
+    assertThat(ShadowPowerManager.getLatestWakeLock()).isNotNull();
+    assertThat(ShadowPowerManager.getLatestWakeLock()).isSameInstanceAs(lock);
+    assertThat(lock.isHeld()).isFalse();
+
+    ShadowPowerManager.reset();
+    assertThat(ShadowPowerManager.getLatestWakeLock()).isNull();
+  }
+
+  @Test
+  public void newWakeLock_shouldCreateWakeLock() {
+    assertThat(powerManager.newWakeLock(0, "TAG")).isNotNull();
+  }
+
+  @Test
+  public void newWakeLock_shouldSetWakeLockTag() {
+    PowerManager.WakeLock wakeLock = powerManager.newWakeLock(0, "FOO");
+    assertThat(shadowOf(wakeLock).getTag()).isEqualTo("FOO");
+  }
+
+  @Test
+  public void newWakeLock_shouldAcquireAndReleaseNonReferenceCountedLock() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    lock.setReferenceCounted(false);
+
+    assertThat(lock.isHeld()).isFalse();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+
+    lock.release();
+
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void newWakeLock_shouldThrowRuntimeExceptionIfLockIsUnderlocked() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    try {
+      lock.release();
+      fail();
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void isScreenOn_shouldGetAndSet() {
+    assertThat(powerManager.isScreenOn()).isTrue();
+    shadowOf(powerManager).turnScreenOn(false);
+    assertThat(powerManager.isScreenOn()).isFalse();
+    assertThat(shadowOf(context).getBroadcastIntents())
+        .comparingElementsUsing(Correspondence.from(Intent::filterEquals, "is filterEqual to"))
+        .contains(new Intent(ACTION_SCREEN_OFF));
+    shadowOf(context).clearBroadcastIntents();
+  }
+
+  @Test
+  public void isReferenceCounted_shouldGetAndSet() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    assertThat(shadowOf(lock).isReferenceCounted()).isTrue();
+    lock.setReferenceCounted(false);
+    assertThat(shadowOf(lock).isReferenceCounted()).isFalse();
+    lock.setReferenceCounted(true);
+    assertThat(shadowOf(lock).isReferenceCounted()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isInteractive_shouldGetAndSet() {
+    shadowOf(powerManager).turnScreenOn(false);
+    assertThat(powerManager.isInteractive()).isFalse();
+    assertThat(shadowOf(context).getBroadcastIntents())
+        .comparingElementsUsing(Correspondence.from(Intent::filterEquals, "is filterEqual to"))
+        .contains(new Intent(ACTION_SCREEN_OFF));
+    shadowOf(context).clearBroadcastIntents();
+
+    shadowOf(powerManager).turnScreenOn(true);
+    assertThat(powerManager.isInteractive()).isTrue();
+    assertThat(shadowOf(context).getBroadcastIntents())
+        .comparingElementsUsing(Correspondence.from(Intent::filterEquals, "is filterEqual to"))
+        .contains(new Intent(ACTION_SCREEN_ON));
+    shadowOf(context).clearBroadcastIntents();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isPowerSaveMode_shouldGetAndSet() {
+    assertThat(powerManager.isPowerSaveMode()).isFalse();
+    shadowOf(powerManager).setIsPowerSaveMode(true);
+    assertThat(powerManager.isPowerSaveMode()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getLocationPowerSaveMode_shouldGetDefaultWhenPowerSaveModeOff() {
+    shadowOf(powerManager)
+        .setLocationPowerSaveMode(PowerManager.LOCATION_MODE_ALL_DISABLED_WHEN_SCREEN_OFF);
+    assertThat(powerManager.getLocationPowerSaveMode())
+        .isEqualTo(PowerManager.LOCATION_MODE_NO_CHANGE);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getLocationPowerSaveMode_shouldGetSetValueWhenPowerSaveModeOn() {
+    shadowOf(powerManager)
+        .setLocationPowerSaveMode(PowerManager.LOCATION_MODE_GPS_DISABLED_WHEN_SCREEN_OFF);
+    shadowOf(powerManager).setIsPowerSaveMode(true);
+    assertThat(powerManager.getLocationPowerSaveMode())
+        .isEqualTo(PowerManager.LOCATION_MODE_GPS_DISABLED_WHEN_SCREEN_OFF);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getCurrentThermalStatus() {
+    shadowOf(powerManager).setCurrentThermalStatus(PowerManager.THERMAL_STATUS_MODERATE);
+    assertThat(powerManager.getCurrentThermalStatus())
+        .isEqualTo(PowerManager.THERMAL_STATUS_MODERATE);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void addThermalStatusListener() {
+    int[] listenerValue = new int[] {-1};
+    powerManager.addThermalStatusListener(
+        level -> {
+          listenerValue[0] = level;
+        });
+    shadowOf(powerManager).setCurrentThermalStatus(PowerManager.THERMAL_STATUS_MODERATE);
+    assertThat(listenerValue[0]).isEqualTo(PowerManager.THERMAL_STATUS_MODERATE);
+  }
+
+  @Test
+  public void workSource_shouldGetAndSet() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    WorkSource workSource = new WorkSource();
+    assertThat(shadowOf(lock).getWorkSource()).isNull();
+    lock.setWorkSource(workSource);
+    assertThat(shadowOf(lock).getWorkSource()).isEqualTo(workSource);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isIgnoringBatteryOptimizations_shouldGetAndSet() {
+    String packageName = "somepackage";
+    assertThat(powerManager.isIgnoringBatteryOptimizations(packageName)).isFalse();
+    shadowOf(powerManager).setIgnoringBatteryOptimizations(packageName, true);
+    assertThat(powerManager.isIgnoringBatteryOptimizations(packageName)).isTrue();
+    shadowOf(powerManager).setIgnoringBatteryOptimizations(packageName, false);
+    assertThat(powerManager.isIgnoringBatteryOptimizations(packageName)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isDeviceIdleMode_shouldGetAndSet() {
+    assertThat(powerManager.isDeviceIdleMode()).isFalse();
+    shadowOf(powerManager).setIsDeviceIdleMode(true);
+    assertThat(powerManager.isDeviceIdleMode()).isTrue();
+    shadowOf(powerManager).setIsDeviceIdleMode(false);
+    assertThat(powerManager.isDeviceIdleMode()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isLightDeviceIdleMode_shouldGetAndSet() {
+    assertThat(powerManager.isLightDeviceIdleMode()).isFalse();
+    shadowOf(powerManager).setIsLightDeviceIdleMode(true);
+    assertThat(powerManager.isLightDeviceIdleMode()).isTrue();
+    shadowOf(powerManager).setIsLightDeviceIdleMode(false);
+    assertThat(powerManager.isLightDeviceIdleMode()).isFalse();
+  }
+
+  @Test
+  public void reboot_incrementsTimesRebootedAndAppendsRebootReason() {
+    assertThat(shadowOf(powerManager).getTimesRebooted()).isEqualTo(0);
+    assertThat(shadowOf(powerManager).getRebootReasons()).isEmpty();
+
+    String rebootReason = "reason";
+    powerManager.reboot(rebootReason);
+
+    assertThat(shadowOf(powerManager).getTimesRebooted()).isEqualTo(1);
+    assertThat(shadowOf(powerManager).getRebootReasons()).hasSize(1);
+    assertThat(shadowOf(powerManager).getRebootReasons()).contains(rebootReason);
+  }
+
+  @Test
+  public void acquire_shouldIncreaseTimesHeld() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+
+    assertThat(shadowOf(lock).getTimesHeld()).isEqualTo(0);
+
+    lock.acquire();
+    assertThat(shadowOf(lock).getTimesHeld()).isEqualTo(1);
+
+    lock.acquire();
+    assertThat(shadowOf(lock).getTimesHeld()).isEqualTo(2);
+  }
+
+  @Test
+  public void release_shouldNotDecreaseTimesHeld() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    lock.acquire();
+    lock.acquire();
+
+    assertThat(shadowOf(lock).getTimesHeld()).isEqualTo(2);
+
+    lock.release();
+    lock.release();
+    assertThat(shadowOf(lock).getTimesHeld()).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isAmbientDisplayAvailable_shouldReturnTrueByDefault() {
+    assertThat(powerManager.isAmbientDisplayAvailable()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isAmbientDisplayAvailable_setAmbientDisplayAvailableToTrue_shouldReturnTrue() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    shadowPowerManager.setAmbientDisplayAvailable(true);
+
+    assertThat(powerManager.isAmbientDisplayAvailable()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isAmbientDisplayAvailable_setAmbientDisplayAvailableToFalse_shouldReturnFalse() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    shadowPowerManager.setAmbientDisplayAvailable(false);
+
+    assertThat(powerManager.isAmbientDisplayAvailable()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_suppress_shouldSuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test", true);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_suppressTwice_shouldSuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test", true);
+    powerManager.suppressAmbientDisplay("test", true);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_suppressTwiceThenUnsuppress_shouldUnsuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test", true);
+    powerManager.suppressAmbientDisplay("test", true);
+
+    powerManager.suppressAmbientDisplay("test", false);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_suppressMultipleTokens_shouldSuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test1", true);
+    powerManager.suppressAmbientDisplay("test2", true);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void
+      suppressAmbientDisplay_suppressMultipleTokens_unsuppressOnlyOne_shouldKeepAmbientDisplaySuppressed() {
+    powerManager.suppressAmbientDisplay("test1", true);
+    powerManager.suppressAmbientDisplay("test2", true);
+
+    powerManager.suppressAmbientDisplay("test1", false);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_unsuppress_shouldUnsuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test", true);
+
+    powerManager.suppressAmbientDisplay("test", false);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_unsuppressTwice_shouldUnsuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test", true);
+
+    powerManager.suppressAmbientDisplay("test", false);
+    powerManager.suppressAmbientDisplay("test", false);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void suppressAmbientDisplay_unsuppressMultipleTokens_shouldUnsuppressAmbientDisplay() {
+    powerManager.suppressAmbientDisplay("test1", true);
+    powerManager.suppressAmbientDisplay("test2", true);
+
+    powerManager.suppressAmbientDisplay("test1", false);
+    powerManager.suppressAmbientDisplay("test2", false);
+
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isAmbientDisplaySuppressed_default_shouldReturnFalse() {
+    assertThat(powerManager.isAmbientDisplaySuppressed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isRebootingUserspaceSupported_default_shouldReturnFalse() {
+    assertThat(powerManager.isRebootingUserspaceSupported()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isRebootingUserspaceSupported_setToTrue_shouldReturnTrue() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    shadowPowerManager.setIsRebootingUserspaceSupported(true);
+    assertThat(powerManager.isRebootingUserspaceSupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void
+      userspaceReboot_rebootingUserspaceNotSupported_shouldThrowUnsuportedOperationException() {
+    try {
+      powerManager.reboot("userspace");
+      fail("UnsupportedOperationException expected");
+    } catch (UnsupportedOperationException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void userspaceReboot_rebootingUserspaceSupported_shouldReboot() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    shadowPowerManager.setIsRebootingUserspaceSupported(true);
+    powerManager.reboot("userspace");
+    assertThat(shadowPowerManager.getRebootReasons()).contains("userspace");
+  }
+
+  @Test
+  @Config(maxSdk = Q)
+  public void preR_userspaceReboot_shouldReboot() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    powerManager.reboot("userspace");
+    assertThat(shadowPowerManager.getRebootReasons()).contains("userspace");
+  }
+
+  @Test
+  public void releaseWithFlags() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    lock.acquire();
+
+    lock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY);
+
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void release() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TAG");
+    lock.acquire();
+
+    lock.release();
+
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setAdaptivePowerSaveEnabled_default() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    assertThat(shadowPowerManager.getAdaptivePowerSaveEnabled()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setAdaptivePowerSaveEnabled_setTrue() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    assertThat(shadowPowerManager.getAdaptivePowerSaveEnabled()).isFalse();
+    boolean changed = powerManager.setAdaptivePowerSaveEnabled(true);
+    assertThat(changed).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setAdaptivePowerSaveEnabled_setFalse() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    assertThat(shadowPowerManager.getAdaptivePowerSaveEnabled()).isFalse();
+    boolean changed = powerManager.setAdaptivePowerSaveEnabled(false);
+    assertThat(changed).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void setBatteryDischargePrediction() {
+    PowerManager powerManager =
+        ApplicationProvider.getApplicationContext().getSystemService(PowerManager.class);
+    powerManager.setBatteryDischargePrediction(Duration.ofHours(2), true);
+    assertThat(powerManager.getBatteryDischargePrediction()).isEqualTo(Duration.ofHours(2));
+    assertThat(powerManager.isBatteryDischargePredictionPersonalized()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getBatteryDischargePrediction_default() {
+    PowerManager powerManager =
+        ApplicationProvider.getApplicationContext().getSystemService(PowerManager.class);
+    assertThat(powerManager.getBatteryDischargePrediction()).isNull();
+    assertThat(powerManager.isBatteryDischargePredictionPersonalized()).isFalse();
+  }
+
+  @Test
+  public void isHeld_neverAcquired_returnsFalse() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+    lock.setReferenceCounted(false);
+
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void isHeld_wakeLockTimeout_returnsFalse() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+    lock.setReferenceCounted(false);
+
+    lock.acquire(100);
+    RuntimeEnvironment.getMasterScheduler().advanceBy(200, MILLISECONDS);
+
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void isHeld_wakeLockJustTimeout_returnsTrue() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+    lock.setReferenceCounted(false);
+
+    lock.acquire(100);
+    RuntimeEnvironment.getMasterScheduler().advanceBy(100, MILLISECONDS);
+
+    assertThat(lock.isHeld()).isTrue();
+  }
+
+  @Test
+  public void isHeld_wakeLockNotTimeout_returnsTrue() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+    lock.setReferenceCounted(false);
+
+    lock.acquire(100);
+    RuntimeEnvironment.getMasterScheduler().advanceBy(50, MILLISECONDS);
+
+    assertThat(lock.isHeld()).isTrue();
+  }
+
+  @Test
+  public void isHeld_unlimitedWakeLockAcquired_returnsTrue() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+    lock.setReferenceCounted(false);
+
+    lock.acquire();
+    RuntimeEnvironment.getMasterScheduler().advanceBy(1000, MILLISECONDS);
+
+    assertThat(lock.isHeld()).isTrue();
+  }
+
+  @Test
+  public void release_isRefCounted_dequeueTheSmallestTimeoutLock() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+
+    // There are 2 wake lock acquires when calling release(). The wake lock with the smallest
+    // timeout timestamp is release first.
+    lock.acquire(100);
+    lock.acquire(300);
+    lock.release();
+    RuntimeEnvironment.getMasterScheduler().advanceBy(200, MILLISECONDS);
+
+    assertThat(lock.isHeld()).isTrue();
+  }
+
+  @Test
+  public void release_isRefCounted_dequeueTimeoutLockBeforeUnlimited() {
+    PowerManager.WakeLock lock = powerManager.newWakeLock(0, "TIMEOUT");
+
+    // There are 2 wake lock acquires when calling release(). The lock with timeout 100ms will be
+    // released first.
+    lock.acquire(100);
+    lock.acquire();
+    lock.release();
+    RuntimeEnvironment.getMasterScheduler().advanceBy(200, MILLISECONDS);
+
+    assertThat(lock.isHeld()).isTrue();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void isDeviceLightIdleMode_shouldGetAndSet() {
+    ShadowPowerManager shadowPowerManager = Shadow.extract(powerManager);
+    assertThat(powerManager.isDeviceLightIdleMode()).isFalse();
+    shadowPowerManager.setIsDeviceLightIdleMode(true);
+    assertThat(powerManager.isDeviceLightIdleMode()).isTrue();
+    shadowPowerManager.setIsDeviceLightIdleMode(false);
+    assertThat(powerManager.isDeviceLightIdleMode()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceActivityTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceActivityTest.java
new file mode 100644
index 0000000..9eefefc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceActivityTest.java
@@ -0,0 +1,58 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+
+import android.preference.PreferenceActivity;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPreferenceActivityTest {
+
+  private TestPreferenceActivity activity;
+
+  @Before
+  public void setUp() throws Exception {
+    activity = Robolectric.buildActivity(TestPreferenceActivity.class).create().get();
+  }
+
+  @Test
+  public void shouldInitializeListViewInOnCreate() {
+    assertThat(activity.getListView()).isNotNull();
+  }
+
+  @Test
+  public void shouldNotInitializePreferenceScreen() {
+    TestPreferenceActivity activity = Robolectric.buildActivity(TestPreferenceActivity.class).get();
+    assertThat(activity.getPreferenceScreen()).isNull();
+  }
+
+  @Test
+  public void shouldFindPreferences() {
+    activity.addPreferencesFromResource(R.xml.preferences);
+    assertNotNull(activity.findPreference("category"));
+    assertNotNull(activity.findPreference("inside_category"));
+    assertNotNull(activity.findPreference("screen"));
+    assertNotNull(activity.findPreference("inside_screen"));
+    assertNotNull(activity.findPreference("checkbox"));
+    assertNotNull(activity.findPreference("edit_text"));
+    assertNotNull(activity.findPreference("list"));
+    assertNotNull(activity.findPreference("preference"));
+    assertNotNull(activity.findPreference("ringtone"));
+  }
+
+  @Test
+  public void shouldFindPreferencesWithStringResourceKeyValue() {
+    activity.addPreferencesFromResource(R.xml.preferences);
+    assertNotNull(activity.findPreference("preference_resource_key_value"));
+  }
+
+  @SuppressWarnings("FragmentInjection")
+  private static class TestPreferenceActivity extends PreferenceActivity {
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceActivityTestWithFragment.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceActivityTestWithFragment.java
new file mode 100644
index 0000000..203f620
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceActivityTestWithFragment.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.app.FragmentManager;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+/**
+ * Current Android examples show adding a PreferenceFragment as part of the hosting Activity
+ * lifecycle. This resulted in a null pointer exception when trying to access a Context while
+ * inflating the Preference objects defined in xml. This class tests that path.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ShadowPreferenceActivityTestWithFragment {
+  private TestPreferenceActivity activity = Robolectric.setupActivity(TestPreferenceActivity.class);
+  private TestPreferenceFragment fragment;
+  private static final String FRAGMENT_TAG = "fragmentPreferenceTag";
+
+  @Before
+  public void before() {
+    this.fragment = (TestPreferenceFragment) this.activity.getFragmentManager().findFragmentByTag(FRAGMENT_TAG);
+  }
+
+  @Test
+  public void fragmentIsNotNull() {
+    assertThat(this.fragment).isNotNull();
+  }
+
+  @Test
+  public void preferenceAddedWithCorrectDetails() {
+    Preference preference = fragment.findPreference("edit_text");
+    assertThat(preference).isNotNull();
+    assertThat(preference.getTitle().toString()).isEqualTo("EditText Test");
+    assertThat(preference.getSummary().toString()).isEqualTo("");
+  }
+
+  private static class TestPreferenceActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+
+      FragmentManager fragmentManager = this.getFragmentManager();
+      TestPreferenceFragment fragment = new TestPreferenceFragment();
+      fragmentManager.beginTransaction().replace(android.R.id.content, fragment, FRAGMENT_TAG).commit();
+    }
+  }
+
+  public static class TestPreferenceFragment extends PreferenceFragment {
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      this.addPreferencesFromResource(R.xml.preferences);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceGroupTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceGroupTest.java
new file mode 100644
index 0000000..f21c21f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPreferenceGroupTest.java
@@ -0,0 +1,177 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.content.Context;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceManager;
+import android.util.AttributeSet;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowPreferenceGroupTest {
+
+  private TestPreferenceGroup group;
+  private ShadowPreference shadow;
+  private Activity activity;
+  private AttributeSet attrs;
+  private Preference pref1, pref2;
+
+  @Before
+  public void setUp() throws Exception {
+    activity = buildActivity(Activity.class).create().get();
+    attrs =  Robolectric.buildAttributeSet().build();
+
+    group = new TestPreferenceGroup(activity, attrs);
+    shadow = shadowOf(group);
+    shadow.callOnAttachedToHierarchy(new PreferenceManager(activity, 0));
+
+    pref1 = new Preference(activity);
+    pref1.setKey("pref1");
+
+    pref2 = new Preference(activity);
+    pref2.setKey("pref2");
+  }
+
+  @Test
+  public void shouldInheritFromPreference() {
+    assertThat(shadow).isInstanceOf(ShadowPreference.class);
+  }
+
+  @Test
+  public void shouldAddPreferences() {
+    assertThat(group.getPreferenceCount()).isEqualTo(0);
+
+    // First add succeeds
+    assertThat(group.addPreference(pref1)).isTrue();
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+
+    // Dupe add fails silently
+    assertThat(group.addPreference(pref1)).isTrue();
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+
+    // Second add succeeds
+    assertThat(group.addPreference(pref2)).isTrue();
+    assertThat(group.getPreferenceCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldAddItemFromInflater() {
+    assertThat(group.getPreferenceCount()).isEqualTo(0);
+
+    // First add succeeds
+    group.addItemFromInflater(pref1);
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+
+    // Dupe add fails silently
+    group.addItemFromInflater(pref1);
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+
+    // Second add succeeds
+    group.addItemFromInflater(pref2);
+    assertThat(group.getPreferenceCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldGetPreference() {
+    group.addPreference(pref1);
+    group.addPreference(pref2);
+
+    assertThat(group.getPreference(0)).isSameInstanceAs(pref1);
+    assertThat(group.getPreference(1)).isSameInstanceAs(pref2);
+  }
+
+  @Test
+  public void shouldGetPreferenceCount() {
+    assertThat(group.getPreferenceCount()).isEqualTo(0);
+    group.addPreference(pref1);
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+    group.addPreference(pref2);
+    assertThat(group.getPreferenceCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldRemovePreference() {
+    group.addPreference(pref1);
+    group.addPreference(pref2);
+    assertThat(group.getPreferenceCount()).isEqualTo(2);
+
+    // First remove succeeds
+    assertThat(group.removePreference(pref1)).isTrue();
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+
+    // Dupe remove fails
+    assertThat(group.removePreference(pref1)).isFalse();
+    assertThat(group.getPreferenceCount()).isEqualTo(1);
+
+    // Second remove succeeds
+    assertThat(group.removePreference(pref2)).isTrue();
+    assertThat(group.getPreferenceCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldRemoveAll() {
+    group.addPreference(pref1);
+    group.addPreference(pref2);
+    assertThat(group.getPreferenceCount()).isEqualTo(2);
+
+    group.removeAll();
+    assertThat(group.getPreferenceCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldFindPreference() {
+    group.addPreference(pref1);
+    group.addPreference(pref2);
+
+    assertThat(group.findPreference(pref1.getKey())).isSameInstanceAs(pref1);
+    assertThat(group.findPreference(pref2.getKey())).isSameInstanceAs(pref2);
+  }
+
+  @Test
+  public void shouldFindPreferenceRecursively() {
+    TestPreferenceGroup group2 = new TestPreferenceGroup(activity, attrs);
+    shadowOf(group2).callOnAttachedToHierarchy(new PreferenceManager(activity, 0));
+    group2.addPreference(pref2);
+
+    group.addPreference(pref1);
+    group.addPreference(group2);
+
+    assertThat(group.findPreference(pref2.getKey())).isSameInstanceAs(pref2);
+  }
+
+  @Test
+  public void shouldSetEnabledRecursively() {
+    boolean[] values = {false, true};
+
+    TestPreferenceGroup group2 = new TestPreferenceGroup(activity, attrs);
+    shadowOf(group2).callOnAttachedToHierarchy(new PreferenceManager(activity, 0));
+    group2.addPreference(pref2);
+
+    group.addPreference(pref1);
+    group.addPreference(group2);
+
+    for (boolean enabled : values) {
+      group.setEnabled(enabled);
+
+      assertThat(group.isEnabled()).isEqualTo(enabled);
+      assertThat(group2.isEnabled()).isEqualTo(enabled);
+      assertThat(pref1.isEnabled()).isEqualTo(enabled);
+      assertThat(pref2.isEnabled()).isEqualTo(enabled);
+    }
+  }
+
+  private static class TestPreferenceGroup extends PreferenceGroup {
+    public TestPreferenceGroup(Context context, AttributeSet attrs) {
+      super(context, attrs);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowProcessTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowProcessTest.java
new file mode 100644
index 0000000..7bd042b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowProcessTest.java
@@ -0,0 +1,171 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test ShadowProcess */
+@RunWith(AndroidJUnit4.class)
+public class ShadowProcessTest {
+  // The range of thread priority values is specified by
+  // android.os.Process#setThreadPriority(int, int), which is [-20,19].
+  private static final int THREAD_PRIORITY_HIGHEST = -20;
+  private static final int THREAD_PRIORITY_LOWEST = 19;
+
+  @Test
+  public void shouldBeZeroWhenNotSet() {
+    assertThat(android.os.Process.myPid()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldGetMyPidAsSet() {
+    ShadowProcess.setPid(3);
+    assertThat(android.os.Process.myPid()).isEqualTo(3);
+  }
+
+  @Test
+  public void shouldGetMyUidAsSet() {
+    ShadowProcess.setUid(123);
+    assertThat(android.os.Process.myUid()).isEqualTo(123);
+  }
+
+  @Test
+  public void shouldGetKilledProcess() {
+    ShadowProcess.clearKilledProcesses();
+    android.os.Process.killProcess(999);
+    assertThat(ShadowProcess.wasKilled(999)).isTrue();
+  }
+
+  @Test
+  public void shouldClearKilledProcessesOnReset() {
+    android.os.Process.killProcess(999);
+    ShadowProcess.reset();
+    assertThat(ShadowProcess.wasKilled(999)).isFalse();
+  }
+
+  @Test
+  public void shouldClearKilledProcesses() {
+    android.os.Process.killProcess(999);
+    ShadowProcess.clearKilledProcesses();
+    assertThat(ShadowProcess.wasKilled(999)).isFalse();
+  }
+
+  @Test
+  public void shouldGetMultipleKilledProcesses() {
+    ShadowProcess.clearKilledProcesses();
+    android.os.Process.killProcess(999);
+    android.os.Process.killProcess(123);
+    assertThat(ShadowProcess.wasKilled(999)).isTrue();
+    assertThat(ShadowProcess.wasKilled(123)).isTrue();
+  }
+
+  @Test
+  public void myTid_mainThread_returnsCurrentThreadId() {
+    assertThat(android.os.Process.myTid()).isEqualTo(Thread.currentThread().getId());
+  }
+
+  @Test
+  public void myTid_backgroundThread_returnsCurrentThreadId() throws Exception {
+    AtomicBoolean ok = new AtomicBoolean(false);
+
+    Thread thread =
+        new Thread(() -> ok.set(android.os.Process.myTid() == Thread.currentThread().getId()));
+    thread.start();
+    thread.join();
+
+    assertThat(ok.get()).isTrue();
+  }
+
+  @Test
+  public void myTid_returnsDifferentValuesForDifferentThreads() throws Exception {
+    AtomicInteger tid1 = new AtomicInteger(0);
+    AtomicInteger tid2 = new AtomicInteger(0);
+
+    Thread thread1 =
+        new Thread(
+            () -> {
+              tid1.set(android.os.Process.myTid());
+            });
+    Thread thread2 =
+        new Thread(
+            () -> {
+              tid2.set(android.os.Process.myTid());
+            });
+    thread1.start();
+    thread2.start();
+    thread1.join();
+    thread2.join();
+
+    assertThat(tid1).isNotEqualTo(tid2);
+  }
+
+  @Test
+  public void getThreadPriority_notSet_returnsZero() {
+    assertThat(android.os.Process.getThreadPriority(123)).isEqualTo(0);
+  }
+
+  @Test
+  public void getThreadPriority_returnsThreadPriority() {
+    android.os.Process.setThreadPriority(123, android.os.Process.THREAD_PRIORITY_VIDEO);
+
+    assertThat(android.os.Process.getThreadPriority(123))
+        .isEqualTo(android.os.Process.THREAD_PRIORITY_VIDEO);
+  }
+
+  @Test
+  public void getThreadPriority_currentThread_returnsCurrentThreadPriority() {
+    android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
+
+    assertThat(android.os.Process.getThreadPriority(/*tid=*/ 0))
+        .isEqualTo(android.os.Process.THREAD_PRIORITY_AUDIO);
+  }
+
+  @Test
+  public void setThreadPriorityOneArgument_setsCurrentThreadPriority() {
+    android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
+
+    assertThat(android.os.Process.getThreadPriority(android.os.Process.myTid()))
+        .isEqualTo(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
+  }
+
+  @Test
+  public void setThreadPriorityOneArgument_setsCurrentThreadPriority_highestPriority() {
+    android.os.Process.setThreadPriority(THREAD_PRIORITY_HIGHEST);
+
+    assertThat(android.os.Process.getThreadPriority(android.os.Process.myTid()))
+        .isEqualTo(THREAD_PRIORITY_HIGHEST);
+  }
+
+  @Test
+  public void setThreadPriorityOneArgument_setsCurrentThreadPriority_lowestPriority() {
+    android.os.Process.setThreadPriority(THREAD_PRIORITY_LOWEST);
+
+    assertThat(android.os.Process.getThreadPriority(android.os.Process.myTid()))
+        .isEqualTo(THREAD_PRIORITY_LOWEST);
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void shouldGetProcessNameAsSet() {
+    ShadowProcess.setProcessName("com.foo.bar:baz");
+
+    assertThat(android.os.Process.myProcessName()).isEqualTo("com.foo.bar:baz");
+  }
+
+  @Test
+  @Config(minSdk = TIRAMISU)
+  public void shouldGetProcessNameAsEmptyAfterReset() {
+    ShadowProcess.setProcessName("com.foo.bar:baz");
+
+    ShadowProcess.reset();
+
+    assertThat(android.os.Process.myProcessName()).isEmpty();
+  }
+}
+
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowProgressBarTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowProgressBarTest.java
new file mode 100644
index 0000000..b3e8179
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowProgressBarTest.java
@@ -0,0 +1,115 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+
+import android.util.AttributeSet;
+import android.widget.ProgressBar;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowProgressBarTest {
+
+  private int[] testValues = {0, 1, 2, 100};
+  private ProgressBar progressBar;
+
+  @Before
+  public void setUp() {
+    AttributeSet attrs = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.max, "100")
+        .addAttribute(android.R.attr.indeterminate, "false")
+        .addAttribute(android.R.attr.indeterminateOnly, "false")
+        .build();
+
+    progressBar = new ProgressBar(getApplication(), attrs);
+  }
+
+  @Test
+  public void shouldInitMaxTo100() {
+    assertThat(progressBar.getMax()).isEqualTo(100);
+  }
+
+  @Test
+  public void testMax() {
+    for (int max : testValues) {
+      progressBar.setMax(max);
+      assertThat(progressBar.getMax()).isEqualTo(max);
+    }
+  }
+
+  @Test
+  public void testProgress() {
+    for (int progress : testValues) {
+      progressBar.setProgress(progress);
+      assertThat(progressBar.getProgress()).isEqualTo(progress);
+    }
+  }
+
+  @Test
+  public void testSecondaryProgress() {
+    for (int progress : testValues) {
+      progressBar.setSecondaryProgress(progress);
+      assertThat(progressBar.getSecondaryProgress()).isEqualTo(progress);
+    }
+  }
+
+  @Test
+  public void testIsDeterminate() {
+    assertFalse(progressBar.isIndeterminate());
+    progressBar.setIndeterminate(true);
+    assertTrue(progressBar.isIndeterminate());
+  }
+
+  @Test
+  public void shouldReturnZeroAsProgressWhenIndeterminate() {
+    progressBar.setProgress(10);
+    progressBar.setSecondaryProgress(20);
+    progressBar.setIndeterminate(true);
+    assertEquals(0, progressBar.getProgress());
+    assertEquals(0, progressBar.getSecondaryProgress());
+    progressBar.setIndeterminate(false);
+
+    assertEquals(10, progressBar.getProgress());
+    assertEquals(20, progressBar.getSecondaryProgress());
+  }
+
+  @Test
+  public void shouldNotSetProgressWhenIndeterminate() {
+    progressBar.setIndeterminate(true);
+    progressBar.setProgress(10);
+    progressBar.setSecondaryProgress(20);
+    progressBar.setIndeterminate(false);
+
+    assertEquals(0, progressBar.getProgress());
+    assertEquals(0, progressBar.getSecondaryProgress());
+  }
+
+  @Test
+  public void testIncrementProgressBy() {
+    assertEquals(0, progressBar.getProgress());
+    progressBar.incrementProgressBy(1);
+    assertEquals(1, progressBar.getProgress());
+    progressBar.incrementProgressBy(1);
+    assertEquals(2, progressBar.getProgress());
+
+    assertEquals(0, progressBar.getSecondaryProgress());
+    progressBar.incrementSecondaryProgressBy(1);
+    assertEquals(1, progressBar.getSecondaryProgress());
+    progressBar.incrementSecondaryProgressBy(1);
+    assertEquals(2, progressBar.getSecondaryProgress());
+  }
+
+  @Test
+  public void shouldRespectMax() {
+    progressBar.setMax(20);
+    progressBar.setProgress(50);
+    assertEquals(20, progressBar.getProgress());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowProgressDialogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowProgressDialogTest.java
new file mode 100644
index 0000000..ca413cf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowProgressDialogTest.java
@@ -0,0 +1,102 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.ProgressDialog;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowProgressDialogTest {
+
+  private ProgressDialog dialog;
+  private ShadowProgressDialog shadow;
+
+  @Before
+  public void setUp() {
+    dialog = new ProgressDialog(ApplicationProvider.getApplicationContext());
+    shadow = Shadows.shadowOf(dialog);
+  }
+
+  @Test
+  public void shouldExtendAlertDialog() {
+    assertThat(shadow).isInstanceOf(ShadowAlertDialog.class);
+  }
+
+  @Test
+  public void shouldPutTheMessageIntoTheView() {
+    String message = "This is only a test";
+    shadow.callOnCreate(null);
+
+    View dialogView = shadow.getView();
+    assertThat(shadowOf(dialogView).innerText()).doesNotContain(message);
+    dialog.setMessage(message);
+    assertThat(shadowOf(shadow.getView()).innerText()).contains(message);
+  }
+
+  @Test
+  public void shouldSetIndeterminate() {
+    assertThat(dialog.isIndeterminate()).isFalse();
+
+    dialog.setIndeterminate(true);
+    assertThat(dialog.isIndeterminate()).isTrue();
+
+    dialog.setIndeterminate(false);
+    assertThat(dialog.isIndeterminate()).isFalse();
+  }
+
+  @Test
+  public void shouldSetMax() {
+    assertThat(dialog.getMax()).isEqualTo(0);
+
+    dialog.setMax(41);
+    assertThat(dialog.getMax()).isEqualTo(41);
+  }
+
+  @Test
+  public void shouldSetProgress() {
+    assertThat(dialog.getProgress()).isEqualTo(0);
+
+    dialog.setProgress(42);
+    assertThat(dialog.getProgress()).isEqualTo(42);
+  }
+
+  @Test
+  public void shouldGetProgressStyle() {
+    assertThat(shadow.getProgressStyle()).isEqualTo(ProgressDialog.STYLE_SPINNER);
+
+    dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
+    assertThat(shadow.getProgressStyle()).isEqualTo(ProgressDialog.STYLE_HORIZONTAL);
+
+    dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+    assertThat(shadow.getProgressStyle()).isEqualTo(ProgressDialog.STYLE_SPINNER);
+  }
+
+  @Test
+  public void horizontalStyle_shouldGetMessage() {
+    String message = "This is only a test";
+    shadow.callOnCreate(null);
+
+    dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
+    dialog.setMessage(message);
+
+    assertThat(shadow.getMessage().toString()).contains(message);
+  }
+
+  @Test
+  public void spinnerStyle_shouldGetMessage() {
+    String message = "This is only a test";
+    shadow.callOnCreate(null);
+
+    dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+    dialog.setMessage(message);
+
+    assertThat(shadow.getMessage().toString()).contains(message);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRadioButtonTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRadioButtonTest.java
new file mode 100644
index 0000000..6904f5a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRadioButtonTest.java
@@ -0,0 +1,94 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.graphics.drawable.ColorDrawable;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRadioButtonTest {
+
+  private Application context;
+  private RadioButton radioButton;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    radioButton = new RadioButton(context);
+  }
+
+  @Test
+  public void canBeExplicitlyChecked() {
+    assertFalse(radioButton.isChecked());
+
+    radioButton.setChecked(true);
+    assertTrue(radioButton.isChecked());
+
+    radioButton.setChecked(false);
+    assertFalse(radioButton.isChecked());
+  }
+
+  @Test
+  public void canBeToggledBetweenCheckedState() {
+    assertFalse(radioButton.isChecked());
+
+    radioButton.toggle();
+    assertTrue(radioButton.isChecked());
+
+    radioButton.toggle();
+    assertTrue(radioButton.isChecked()); // radio buttons can't be turned off again with a click
+  }
+
+  @Test
+  public void canBeClickedToToggleCheckedState() {
+    assertFalse(radioButton.isChecked());
+
+    radioButton.performClick();
+    assertTrue(radioButton.isChecked());
+
+    radioButton.performClick();
+    assertTrue(radioButton.isChecked()); // radio buttons can't be turned off again with a click
+  }
+
+  @Test
+  public void shouldInformRadioGroupThatItIsChecked() {
+    RadioButton radioButton1 = new RadioButton(context);
+    radioButton1.setId(99);
+    RadioButton radioButton2 = new RadioButton(context);
+    radioButton2.setId(100);
+
+    RadioGroup radioGroup = new RadioGroup(context);
+    radioGroup.addView(radioButton1);
+    radioGroup.addView(radioButton2);
+
+    radioButton1.setChecked(true);
+    assertThat(radioGroup.getCheckedRadioButtonId()).isEqualTo(radioButton1.getId());
+
+    radioButton2.setChecked(true);
+    assertThat(radioGroup.getCheckedRadioButtonId()).isEqualTo(radioButton2.getId());
+  }
+
+  @Test
+  public void getButtonDrawableId() {
+    radioButton.setButtonDrawable(R.drawable.an_image);
+    assertThat(shadowOf(radioButton).getButtonDrawableId()).isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void getButtonDrawable() {
+    ColorDrawable drawable = new ColorDrawable();
+    radioButton.setButtonDrawable(drawable);
+    assertThat(shadowOf(radioButton).getButtonDrawable()).isEqualTo(drawable);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRadioGroupTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRadioGroupTest.java
new file mode 100644
index 0000000..ef5d44a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRadioGroupTest.java
@@ -0,0 +1,68 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import android.app.Application;
+import android.widget.RadioGroup;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRadioGroupTest {
+  private static final int BUTTON_ID = 3245;
+  private Application context;
+  private RadioGroup radioGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    radioGroup = new RadioGroup(context);
+  }
+
+  @Test
+  public void checkedRadioButtonId() {
+    assertThat(radioGroup.getCheckedRadioButtonId()).isEqualTo(-1);
+    radioGroup.check(99);
+    assertThat(radioGroup.getCheckedRadioButtonId()).isEqualTo(99);
+  }
+
+  @Test
+  public void check_shouldCallOnCheckedChangeListener() {
+    TestOnCheckedChangeListener listener = new TestOnCheckedChangeListener();
+    radioGroup.setOnCheckedChangeListener(listener);
+
+    radioGroup.check(BUTTON_ID);
+
+    assertEquals(Collections.singletonList(BUTTON_ID), listener.onCheckedChangedCheckedIds);
+    assertEquals(Collections.singletonList(radioGroup), listener.onCheckedChangedGroups);
+  }
+
+  @Test
+  public void clearCheck_shouldCallOnCheckedChangeListenerTwice() {
+    TestOnCheckedChangeListener listener = new TestOnCheckedChangeListener();
+
+    radioGroup.check(BUTTON_ID);
+    radioGroup.setOnCheckedChangeListener(listener);
+    radioGroup.clearCheck();
+
+    assertEquals(Collections.singletonList(-1), listener.onCheckedChangedCheckedIds);
+    assertEquals(Collections.singletonList(radioGroup), listener.onCheckedChangedGroups);
+  }
+
+  private static class TestOnCheckedChangeListener implements RadioGroup.OnCheckedChangeListener {
+    public ArrayList<RadioGroup> onCheckedChangedGroups = new ArrayList<>();
+    public ArrayList<Integer> onCheckedChangedCheckedIds = new ArrayList<>();
+
+    @Override
+    public void onCheckedChanged(RadioGroup group, int checkedId) {
+      onCheckedChangedGroups.add(group);
+      onCheckedChangedCheckedIds.add(checkedId);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRangingSessionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRangingSessionTest.java
new file mode 100644
index 0000000..06b31c9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRangingSessionTest.java
@@ -0,0 +1,93 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.os.PersistableBundle;
+import android.uwb.RangingSession;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatcher;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit tests for {@link ShadowRangingSession}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = S)
+public class ShadowRangingSessionTest {
+  private /* RangingSession.Callback */ Object callbackObject;
+  private /* RangingSession */ Object rangingSessionObject;
+  private ShadowRangingSession.Adapter adapter;
+
+  @Before
+  public void setUp() {
+    callbackObject = mock(RangingSession.Callback.class);
+    adapter = mock(ShadowRangingSession.Adapter.class);
+    rangingSessionObject =
+        ShadowRangingSession.newInstance(
+            directExecutor(), (RangingSession.Callback) callbackObject, adapter);
+  }
+
+  @After
+  public void tearDown() {
+    verifyNoMoreInteractions(adapter);
+  }
+
+  @Test
+  public void open_notifyAdaptor() {
+    RangingSession session = (RangingSession) rangingSessionObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    Shadow.<ShadowRangingSession>extract(session).open(genParams("open"));
+    verify(adapter).onOpen(eq(session), eq(callback), argThat(checkParams("open")));
+  }
+
+  @Test
+  public void start_notifyAdaptor() {
+    RangingSession session = (RangingSession) rangingSessionObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    session.start(genParams("start"));
+    verify(adapter).onStart(eq(session), eq(callback), argThat(checkParams("start")));
+  }
+
+  @Test
+  public void reconfigure_notifyAdaptor() {
+    RangingSession session = (RangingSession) rangingSessionObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    session.reconfigure(genParams("reconfigure"));
+    verify(adapter).onReconfigure(eq(session), eq(callback), argThat(checkParams("reconfigure")));
+  }
+
+  @Test
+  public void stop_notifyAdaptor() {
+    RangingSession session = (RangingSession) rangingSessionObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    session.stop();
+    verify(adapter).onStop(eq(session), eq(callback));
+  }
+
+  @Test
+  public void close_notifyAdaptor() {
+    RangingSession session = (RangingSession) rangingSessionObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    session.close();
+    verify(adapter).onClose(eq(session), eq(callback));
+  }
+
+  private static PersistableBundle genParams(String name) {
+    PersistableBundle params = new PersistableBundle();
+    params.putString("test", name);
+    return params;
+  }
+
+  private static ArgumentMatcher<PersistableBundle> checkParams(String name) {
+    return params -> params.getString("test").equals(name);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRankingTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRankingTest.java
new file mode 100644
index 0000000..2c4a0c1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRankingTest.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.os.Build.VERSION_CODES;
+import android.service.notification.NotificationListenerService.Ranking;
+import java.util.ArrayList;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowRanking}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.KITKAT_WATCH)
+public class ShadowRankingTest {
+  private Ranking ranking;
+
+  @Before
+  public void setUp() {
+    ranking = new Ranking();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.O)
+  public void setChannel() {
+    NotificationChannel notificationChannel =
+        new NotificationChannel("test_id", "test_name", NotificationManager.IMPORTANCE_DEFAULT);
+
+    shadowOf(ranking).setChannel(notificationChannel);
+
+    assertThat(ranking.getChannel()).isEqualTo(notificationChannel);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.Q)
+  public void setSmartReplies() {
+    ArrayList<CharSequence> smartReplies = new ArrayList<>();
+    smartReplies.add("Hi");
+    smartReplies.add("Yes");
+    smartReplies.add("See you soon!");
+
+    shadowOf(ranking).setSmartReplies(smartReplies);
+
+    assertThat(ranking.getSmartReplies()).containsExactlyElementsIn(smartReplies);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRatingBarTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRatingBarTest.java
new file mode 100644
index 0000000..f50d4a9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRatingBarTest.java
@@ -0,0 +1,49 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.widget.RatingBar;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRatingBarTest {
+
+  private RatingBar ratingBar;
+  private RatingBar.OnRatingBarChangeListener listener;
+  private List<String> transcript;
+
+  @Before
+  public void setup() {
+    ratingBar = new RatingBar(ApplicationProvider.getApplicationContext());
+    listener = new TestRatingBarChangedListener();
+    transcript = new ArrayList<>();
+    ratingBar.setOnRatingBarChangeListener(listener);
+  }
+
+  @Test
+  public void testOnSeekBarChangedListener() {
+    assertThat(ratingBar.getOnRatingBarChangeListener()).isSameInstanceAs(listener);
+    ratingBar.setOnRatingBarChangeListener(null);
+    assertThat(ratingBar.getOnRatingBarChangeListener()).isNull();
+  }
+
+  @Test
+  public void testOnChangeNotification() {
+    ratingBar.setRating(5.0f);
+    assertThat(transcript).containsExactly("onRatingChanged() - 5.0");
+  }
+
+  private class TestRatingBarChangedListener implements RatingBar.OnRatingBarChangeListener {
+
+    @Override
+    public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromUser) {
+      transcript.add("onRatingChanged() - " + rating);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRcsUceAdapterSTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRcsUceAdapterSTest.java
new file mode 100644
index 0000000..5ad1f3c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRcsUceAdapterSTest.java
@@ -0,0 +1,253 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.RcsContactUceCapability;
+import android.telephony.ims.RcsUceAdapter;
+import android.telephony.ims.RcsUceAdapter.CapabilitiesCallback;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowRcsUceAdapter.CapabilityFailureInfo;
+
+/**
+ * Unit tests for {@link ShadowRcsUceAdapter} on S. Split out from ShadowRcsUceAdapterTest since the
+ * callbacks as written are S-only
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = Build.VERSION_CODES.S)
+public class ShadowRcsUceAdapterSTest {
+
+  private static final int SUBSCRIPTION_ID = 0;
+  private static final Uri URI = Uri.parse("tel:+1-900-555-0191");
+  private static final Uri OTHER_URI = Uri.parse("tel:+1-900-555-0192");
+  private static final int ERROR_CODE = 1234;
+  private static final long RETRY_MILLIS = 5678L;
+  private static final String FEATURE_TAG = "I'm a feature tag! (:";
+
+  private RcsUceAdapter rcsUceAdapter;
+  private ExecutorService executorService;
+
+  @Before
+  public void setUp() {
+    rcsUceAdapter =
+        ((ImsManager)
+                ApplicationProvider.getApplicationContext()
+                    .getSystemService(Context.TELEPHONY_IMS_SERVICE))
+            .getImsRcsManager(SUBSCRIPTION_ID)
+            .getUceAdapter();
+    executorService = Executors.newSingleThreadExecutor();
+    ShadowRcsUceAdapter.reset();
+  }
+
+  @Test
+  public void setCapabilitiesForUri_requestCapabilities_overridesCapabilitiesForUri()
+      throws Exception {
+    RcsContactUceCapability capability =
+        new RcsContactUceCapability.OptionsBuilder(URI).addFeatureTag(FEATURE_TAG).build();
+    ShadowRcsUceAdapter.setCapabilitiesForUri(URI, capability);
+    SuccessfulCapabilityVerifierCallback verifierCallback =
+        new SuccessfulCapabilityVerifierCallback(ImmutableList.of(capability));
+
+    rcsUceAdapter.requestCapabilities(ImmutableList.of(URI), executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertExchangeSuccessfullyCompleted();
+  }
+
+  @Test
+  public void setCapabilitiesForUri_requestCapabilities_doesNotOverrideCapabilitiesForOtherUri()
+      throws Exception {
+    RcsContactUceCapability capability =
+        new RcsContactUceCapability.OptionsBuilder(URI).addFeatureTag(FEATURE_TAG).build();
+    RcsContactUceCapability otherEmptyCapability =
+        new RcsContactUceCapability.OptionsBuilder(OTHER_URI).build();
+    ShadowRcsUceAdapter.setCapabilitiesForUri(URI, capability);
+    SuccessfulCapabilityVerifierCallback verifierCallback =
+        new SuccessfulCapabilityVerifierCallback(ImmutableList.of(otherEmptyCapability));
+
+    rcsUceAdapter.requestCapabilities(
+        ImmutableList.of(OTHER_URI), executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertExchangeSuccessfullyCompleted();
+  }
+
+  @Test
+  public void setCapabilitiesFailureForUri_requestCapabilities_failsForUri() throws Exception {
+    CapabilityFailureInfo failureInfo = CapabilityFailureInfo.create(ERROR_CODE, RETRY_MILLIS);
+    ShadowRcsUceAdapter.setCapabilitiesFailureForUri(URI, failureInfo);
+    ErrorVerifierCallback verifierCallback = new ErrorVerifierCallback(failureInfo);
+
+    rcsUceAdapter.requestCapabilities(ImmutableList.of(URI), executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertOnErrorCalled();
+  }
+
+  @Test
+  public void setCapabilitiesFailureForUri_requestCapabilities_doesNotFailForOtherUri()
+      throws Exception {
+    CapabilityFailureInfo failureInfo = CapabilityFailureInfo.create(ERROR_CODE, RETRY_MILLIS);
+    ShadowRcsUceAdapter.setCapabilitiesFailureForUri(URI, failureInfo);
+    RcsContactUceCapability otherEmptyCapability =
+        new RcsContactUceCapability.OptionsBuilder(OTHER_URI).build();
+    SuccessfulCapabilityVerifierCallback verifierCallback =
+        new SuccessfulCapabilityVerifierCallback(ImmutableList.of(otherEmptyCapability));
+
+    rcsUceAdapter.requestCapabilities(
+        ImmutableList.of(OTHER_URI), executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertExchangeSuccessfullyCompleted();
+  }
+
+  @Test
+  public void setCapabilitiesForUri_requestAvailability_overridesCapabilitiesForUri()
+      throws Exception {
+    RcsContactUceCapability capability =
+        new RcsContactUceCapability.OptionsBuilder(URI).addFeatureTag(FEATURE_TAG).build();
+    ShadowRcsUceAdapter.setCapabilitiesForUri(URI, capability);
+    SuccessfulCapabilityVerifierCallback verifierCallback =
+        new SuccessfulCapabilityVerifierCallback(ImmutableList.of(capability));
+
+    rcsUceAdapter.requestAvailability(URI, executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertExchangeSuccessfullyCompleted();
+  }
+
+  @Test
+  public void setCapabilitiesForUri_requestAvailability_doesNotOverrideCapabilitiesForOtherUri()
+      throws Exception {
+    RcsContactUceCapability capability =
+        new RcsContactUceCapability.OptionsBuilder(URI).addFeatureTag(FEATURE_TAG).build();
+    RcsContactUceCapability otherEmptyCapability =
+        new RcsContactUceCapability.OptionsBuilder(OTHER_URI).build();
+    ShadowRcsUceAdapter.setCapabilitiesForUri(URI, capability);
+    SuccessfulCapabilityVerifierCallback verifierCallback =
+        new SuccessfulCapabilityVerifierCallback(ImmutableList.of(otherEmptyCapability));
+
+    rcsUceAdapter.requestAvailability(OTHER_URI, executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertExchangeSuccessfullyCompleted();
+  }
+
+  @Test
+  public void setCapabilitiesFailureForUri_requestAvailability_failsForUri() throws Exception {
+    CapabilityFailureInfo failureInfo = CapabilityFailureInfo.create(ERROR_CODE, RETRY_MILLIS);
+    ShadowRcsUceAdapter.setCapabilitiesFailureForUri(URI, failureInfo);
+    ErrorVerifierCallback verifierCallback = new ErrorVerifierCallback(failureInfo);
+
+    rcsUceAdapter.requestAvailability(URI, executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertOnErrorCalled();
+  }
+
+  @Test
+  public void setCapabilitiesFailureForUri_requestAvailability_doesNotFailForOtherUri()
+      throws Exception {
+    CapabilityFailureInfo failureInfo = CapabilityFailureInfo.create(ERROR_CODE, RETRY_MILLIS);
+    ShadowRcsUceAdapter.setCapabilitiesFailureForUri(URI, failureInfo);
+    RcsContactUceCapability otherEmptyCapability =
+        new RcsContactUceCapability.OptionsBuilder(OTHER_URI).build();
+    SuccessfulCapabilityVerifierCallback verifierCallback =
+        new SuccessfulCapabilityVerifierCallback(ImmutableList.of(otherEmptyCapability));
+
+    rcsUceAdapter.requestAvailability(OTHER_URI, executorService, verifierCallback);
+    executorService.shutdown();
+    executorService.awaitTermination(10, SECONDS);
+
+    verifierCallback.assertExchangeSuccessfullyCompleted();
+  }
+
+  private static class SuccessfulCapabilityVerifierCallback implements CapabilitiesCallback {
+    private final List<RcsContactUceCapability> expectedCapabilities;
+    private int currentIndex = 0;
+    private boolean onCompleteCalled = false;
+
+    private SuccessfulCapabilityVerifierCallback(
+        List<RcsContactUceCapability> expectedCapabilities) {
+      this.expectedCapabilities = expectedCapabilities;
+    }
+
+    @Override
+    public void onCapabilitiesReceived(List<RcsContactUceCapability> contactCapabilities) {
+      if (onCompleteCalled) {
+        Assert.fail();
+      }
+      for (RcsContactUceCapability capability : contactCapabilities) {
+        assertThat(capability.getFeatureTags())
+            .isEqualTo(expectedCapabilities.get(currentIndex).getFeatureTags());
+        currentIndex++;
+      }
+    }
+
+    @Override
+    public void onComplete() {
+      onCompleteCalled = true;
+    }
+
+    @Override
+    public void onError(int i, long l) {
+      Assert.fail();
+    }
+
+    private void assertExchangeSuccessfullyCompleted() {
+      assertThat(currentIndex).isEqualTo(expectedCapabilities.size());
+      assertThat(onCompleteCalled).isTrue();
+    }
+  }
+
+  private static class ErrorVerifierCallback implements CapabilitiesCallback {
+    private final CapabilityFailureInfo failureInfo;
+    private boolean onErrorCalled = false;
+
+    private ErrorVerifierCallback(CapabilityFailureInfo failureInfo) {
+      this.failureInfo = failureInfo;
+    }
+
+    @Override
+    public void onCapabilitiesReceived(List<RcsContactUceCapability> contactCapabilities) {
+      Assert.fail();
+    }
+
+    @Override
+    public void onComplete() {
+      Assert.fail();
+    }
+
+    @Override
+    public void onError(int errorCode, long retryMillis) {
+      assertThat(errorCode).isEqualTo(failureInfo.errorCode());
+      assertThat(retryMillis).isEqualTo(failureInfo.retryMillis());
+      onErrorCalled = true;
+    }
+
+    private void assertOnErrorCalled() {
+      assertThat(onErrorCalled).isTrue();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRcsUceAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRcsUceAdapterTest.java
new file mode 100644
index 0000000..c0c0cbc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRcsUceAdapterTest.java
@@ -0,0 +1,69 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.RcsUceAdapter;
+import androidx.test.core.app.ApplicationProvider;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowRcsUceAdapter}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = R)
+public class ShadowRcsUceAdapterTest {
+  private static final int SUBSCRIPTION_ID = 0;
+  private static final int OTHER_SUBSCRIPTION_ID = 1;
+
+  private ImsManager imsManager;
+
+  @Before
+  public void setUp() {
+    imsManager =
+        (ImsManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.TELEPHONY_IMS_SERVICE);
+  }
+
+  @Test
+  public void
+      setUceSettingEnabledForSubscriptionId_uceSettingEnabledTrue_overridesDesiredSubscriptionId()
+          throws Exception {
+    ShadowRcsUceAdapter.setUceSettingEnabledForSubscriptionId(
+        SUBSCRIPTION_ID, /* uceSettingEnabled= */ true);
+
+    RcsUceAdapter rcsUceAdapter = imsManager.getImsRcsManager(SUBSCRIPTION_ID).getUceAdapter();
+    assertThat(rcsUceAdapter.isUceSettingEnabled()).isTrue();
+  }
+
+  @Test
+  public void setUceSettingEnabledForSubscriptionId_shouldNotOverridesOtherSubIds()
+      throws Exception {
+    ShadowRcsUceAdapter.setUceSettingEnabledForSubscriptionId(
+        SUBSCRIPTION_ID, /* uceSettingEnabled= */ true);
+
+    RcsUceAdapter rcsUceAdapter =
+        imsManager.getImsRcsManager(OTHER_SUBSCRIPTION_ID).getUceAdapter();
+    assertThat(rcsUceAdapter.isUceSettingEnabled()).isFalse();
+  }
+
+  @Test
+  public void setUceSettingEnabledForSubscriptionId_withMultipleSubIds_overridesAllSubIds()
+      throws Exception {
+    ShadowRcsUceAdapter.setUceSettingEnabledForSubscriptionId(
+        SUBSCRIPTION_ID, /* uceSettingEnabled= */ true);
+    ShadowRcsUceAdapter.setUceSettingEnabledForSubscriptionId(
+        OTHER_SUBSCRIPTION_ID, /* uceSettingEnabled= */ true);
+
+    RcsUceAdapter rcsUceAdapter = imsManager.getImsRcsManager(SUBSCRIPTION_ID).getUceAdapter();
+    assertThat(rcsUceAdapter.isUceSettingEnabled()).isTrue();
+
+    rcsUceAdapter = imsManager.getImsRcsManager(OTHER_SUBSCRIPTION_ID).getUceAdapter();
+    assertThat(rcsUceAdapter.isUceSettingEnabled()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRectTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRectTest.java
new file mode 100644
index 0000000..37951fd
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRectTest.java
@@ -0,0 +1,190 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Rect;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRectTest {
+  @Before
+  public void setUp() {
+  }
+
+  @Test
+  public void constructorSetsCoordinates() {
+    Rect r = new Rect(1, 2, 3, 4);
+    assertThat(r.left).isEqualTo(1);
+    assertThat(r.top).isEqualTo(2);
+    assertThat(r.right).isEqualTo(3);
+    assertThat(r.bottom).isEqualTo(4);
+  }
+
+  @Test
+  public void secondConstructorSetsCoordinates() {
+    Rect existingRect = new Rect(1, 2, 3, 4);
+    Rect r = new Rect(existingRect);
+    assertThat(r.left).isEqualTo(1);
+    assertThat(r.top).isEqualTo(2);
+    assertThat(r.right).isEqualTo(3);
+    assertThat(r.bottom).isEqualTo(4);
+  }
+
+  @Test
+  public void width() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.width()).isEqualTo(10);
+  }
+
+  @Test
+  public void height() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.height()).isEqualTo(10);
+  }
+
+  @Test
+  public void doesntEqual() {
+    Rect a = new Rect(1, 2, 3, 4);
+    Rect b = new Rect(2, 3, 4, 5);
+    assertThat(a.equals(b)).isFalse();
+  }
+
+  @Test
+  public void equals() {
+    Rect a = new Rect(1, 2, 3, 4);
+    Rect b = new Rect(1, 2, 3, 4);
+    assertThat(a.equals(b)).isTrue();
+  }
+
+  @Test
+  public void doesntContainPoint() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.contains(11, 11)).isFalse();
+  }
+
+  @Test
+  public void containsPoint() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.contains(5, 5)).isTrue();
+  }
+
+  @Test
+  public void doesntContainPointOnLeftEdge() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.contains(0, 10)).isFalse();
+  }
+
+  @Test
+  public void doesntContainPointOnRightEdge() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.contains(10, 5)).isFalse();
+  }
+
+  @Test
+  public void containsPointOnTopEdge() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.contains(5, 0)).isTrue();
+  }
+
+  @Test
+  public void containsPointOnBottomEdge() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.contains(5, 10)).isFalse();
+  }
+
+  @Test
+  public void doesntContainRect() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(11, 11, 12, 12);
+    assertThat(a.contains(b)).isFalse();
+  }
+
+  @Test
+  public void containsRect() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(8, 8, 9, 9);
+    assertThat(a.contains(b)).isTrue();
+  }
+
+  @Test
+  public void containsEqualRect() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(0, 0, 10, 10);
+    assertThat(a.contains(b)).isTrue();
+  }
+
+  @Test
+  public void intersectsButDoesntContainRect() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(5, 5, 15, 15);
+    assertThat(a.contains(b)).isFalse();
+  }
+
+  @Test
+  public void doesntIntersect() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(11, 11, 21, 21);
+    assertThat(Rect.intersects(a, b)).isFalse();
+  }
+
+  @Test
+  public void intersects() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(5, 0, 15, 10);
+    assertThat(Rect.intersects(a, b)).isTrue();
+  }
+
+  @Test
+  public void almostIntersects() {
+    Rect a = new Rect(3, 0, 4, 2);
+    Rect b = new Rect(1, 0, 3, 1);
+    assertThat(Rect.intersects(a, b)).isFalse();
+  }
+
+  @Test
+  public void intersectRect() {
+    Rect a = new Rect(0, 0, 10, 10);
+    Rect b = new Rect(5, 0, 15, 10);
+    assertThat(a.intersect(b)).isTrue();
+  }
+
+  @Test
+  public void intersectCoordinates() {
+    Rect r = new Rect(0, 0, 10, 10);
+    assertThat(r.intersect(5, 0, 15, 10)).isTrue();
+  }
+
+  @Test
+  public void setWithIntsSetsCoordinates() {
+    Rect r = new Rect();
+    r.set(1, 2, 3, 4);
+    assertThat(r.left).isEqualTo(1);
+    assertThat(r.top).isEqualTo(2);
+    assertThat(r.right).isEqualTo(3);
+    assertThat(r.bottom).isEqualTo(4);
+  }
+
+  @Test
+  public void setWithRectSetsCoordinates() {
+    Rect rSrc = new Rect(1, 2, 3, 4);
+    Rect r = new Rect();
+    r.set(rSrc);
+    assertThat(r.left).isEqualTo(1);
+    assertThat(r.top).isEqualTo(2);
+    assertThat(r.right).isEqualTo(3);
+    assertThat(r.bottom).isEqualTo(4);
+  }
+
+  @Test
+  public void offsetModifiesRect() {
+    Rect r = new Rect(1, 2, 3, 4);
+    r.offset(10, 20);
+    assertThat(r.left).isEqualTo(11);
+    assertThat(r.top).isEqualTo(22);
+    assertThat(r.right).isEqualTo(13);
+    assertThat(r.bottom).isEqualTo(24);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRegionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRegionTest.java
new file mode 100644
index 0000000..7fdf721
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRegionTest.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Rect;
+import android.graphics.Region;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRegionTest {
+  @Test
+  public void testEquals() {
+    Region region = new Region(new Rect(0, 0, 100, 100));
+    assertThat(region.equals(region)).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRelativeLayoutTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRelativeLayoutTest.java
new file mode 100644
index 0000000..f741b63
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRelativeLayoutTest.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRelativeLayoutTest {
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getRules_shouldShowAddRuleData_sinceApiLevel17() {
+    ImageView imageView = new ImageView(ApplicationProvider.getApplicationContext());
+    RelativeLayout layout = new RelativeLayout(ApplicationProvider.getApplicationContext());
+    layout.addView(imageView, new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+    RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) imageView.getLayoutParams();
+    layoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
+    layoutParams.addRule(RelativeLayout.ALIGN_TOP, 1234);
+    int[] rules = layoutParams.getRules();
+    assertThat(rules).isEqualTo(new int[]{0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+  }
+
+  @Test
+  @Config(maxSdk = JELLY_BEAN)
+  public void getRules_shouldShowAddRuleData_uptoApiLevel16() {
+    ImageView imageView = new ImageView(ApplicationProvider.getApplicationContext());
+    RelativeLayout layout = new RelativeLayout(ApplicationProvider.getApplicationContext());
+    layout.addView(imageView, new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+    RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) imageView.getLayoutParams();
+    layoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
+    layoutParams.addRule(RelativeLayout.ALIGN_TOP, 1234);
+    int[] rules = layoutParams.getRules();
+    assertThat(rules).isEqualTo(new int[]{0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, -1, 0, 0, 0, 0});
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRemoteCallbackListTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRemoteCallbackListTest.java
new file mode 100644
index 0000000..6010895
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRemoteCallbackListTest.java
@@ -0,0 +1,50 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.IInterface;
+import android.os.RemoteCallbackList;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowRemoteCallbackListTest {
+
+  private RemoteCallbackList<Foo> fooRemoteCallbackList;
+
+  @Before
+  public void setup() {
+    fooRemoteCallbackList = new RemoteCallbackList<>();
+  }
+
+  @Test
+  public void testBasicWiring() {
+    Foo callback = new Foo();
+    fooRemoteCallbackList.register(callback);
+
+    fooRemoteCallbackList.beginBroadcast();
+
+    assertThat(fooRemoteCallbackList.getBroadcastItem(0)).isSameInstanceAs(callback);
+  }
+
+  @Test
+  @Config(minSdk = 17)
+  public void getRegisteredCallbackCount_callbackRegistered_reflectsInReturnValue() {
+    fooRemoteCallbackList.register(new Foo());
+
+    assertThat(fooRemoteCallbackList.getRegisteredCallbackCount()).isEqualTo(1);
+  }
+
+  private static class Foo implements IInterface {
+
+    @Override
+    public IBinder asBinder() {
+      return new Binder();
+    }
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRenderNodeAnimatorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRenderNodeAnimatorTest.java
new file mode 100644
index 0000000..141c3e4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRenderNodeAnimatorTest.java
@@ -0,0 +1,184 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.app.Activity;
+import android.os.Build;
+import android.view.View;
+import android.view.ViewAnimationUtils;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowRenderNodeAnimatorTest {
+  private Activity activity;
+  private View view;
+  private TestListener listener;
+
+  @Before
+  public void setUp() {
+    activity = Robolectric.buildActivity(Activity.class).setup().get();
+    view = new View(activity);
+    activity.setContentView(view);
+    listener = new TestListener();
+  }
+
+  @Test
+  public void normal() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.addListener(listener);
+    animator.start();
+
+    shadowMainLooper().idle();
+    assertThat(listener.startCount).isEqualTo(1);
+    assertThat(listener.endCount).isEqualTo(1);
+  }
+
+  @Test
+  public void canceled() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.addListener(listener);
+
+    shadowMainLooper().pause();
+    animator.start();
+    animator.cancel();
+
+    assertThat(listener.startCount).isEqualTo(1);
+    assertThat(listener.cancelCount).isEqualTo(1);
+    assertThat(listener.endCount).isEqualTo(1);
+  }
+
+  @Test
+  public void delayed() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.setStartDelay(1000);
+    animator.addListener(listener);
+
+    animator.start();
+
+    shadowMainLooper().idle();
+    assertThat(listener.startCount).isEqualTo(1);
+    assertThat(listener.endCount).isEqualTo(1);
+  }
+
+  @Test
+  public void neverStartedCanceled() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.addListener(listener);
+
+    animator.cancel();
+
+    assertThat(listener.startCount).isEqualTo(0);
+    // This behavior changed between L and L MR1. In older versions, onAnimationCancel and
+    // onAnimationEnd would always be called regardless of whether the animation was started.
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+      assertThat(listener.cancelCount).isEqualTo(0);
+      assertThat(listener.endCount).isEqualTo(0);
+    } else {
+      assertThat(listener.cancelCount).isEqualTo(1);
+      assertThat(listener.endCount).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void neverStartedEnded() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.addListener(listener);
+
+    animator.end();
+
+    shadowMainLooper().idle();
+
+    // This behavior changed between L and L MR1. In older versions, onAnimationEnd would always be
+    // called without any guarantee that onAnimationStart had been called first.
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+      assertThat(listener.startCount).isEqualTo(1);
+      assertThat(listener.endCount).isEqualTo(1);
+    } else {
+      assertThat(listener.startCount).isEqualTo(0);
+      assertThat(listener.endCount).isEqualTo(1);
+    }
+  }
+
+  @Test
+  public void doubleCanceled() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.addListener(listener);
+
+    shadowMainLooper().pause();
+    animator.start();
+    animator.cancel();
+    animator.cancel();
+
+    assertThat(listener.startCount).isEqualTo(1);
+    assertThat(listener.cancelCount).isEqualTo(1);
+    assertThat(listener.endCount).isEqualTo(1);
+  }
+
+  @Test
+  public void doubleEnded() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.addListener(listener);
+
+    shadowMainLooper().pause();
+    animator.start();
+    animator.end();
+    animator.end();
+
+    assertThat(listener.startCount).isEqualTo(1);
+    assertThat(listener.endCount).isEqualTo(1);
+  }
+
+  @Test
+  public void delayedAndCanceled() {
+    Animator animator = ViewAnimationUtils.createCircularReveal(view, 10, 10, 10f, 100f);
+    animator.setStartDelay(1000);
+    animator.addListener(listener);
+
+    shadowMainLooper().pause();
+    animator.start();
+    animator.cancel();
+
+    // This behavior changed between L and L MR1. In older versions, onAnimationStart gets called
+    // *twice* if you cancel a delayed animation before any of its frames run (as both cancel() and
+    // onFinished() implement special behavior for STATE_DELAYED, but the state only gets set to
+    // finished after onFinished()).
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+      assertThat(listener.startCount).isEqualTo(1);
+    } else {
+      assertThat(listener.startCount).isEqualTo(2);
+    }
+    assertThat(listener.cancelCount).isEqualTo(1);
+    assertThat(listener.endCount).isEqualTo(1);
+  }
+
+  private static class TestListener extends AnimatorListenerAdapter {
+    public int startCount;
+    public int cancelCount;
+    public int endCount;
+
+    @Override
+    public void onAnimationStart(Animator animation) {
+      startCount++;
+    }
+
+    @Override
+    public void onAnimationCancel(Animator animation) {
+      cancelCount++;
+    }
+
+    @Override
+    public void onAnimationEnd(Animator animation) {
+      endCount++;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRenderNodeTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRenderNodeTest.java
new file mode 100644
index 0000000..e2b5e49
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRenderNodeTest.java
@@ -0,0 +1,1122 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.util.ReflectionHelpers.callInstanceMethod;
+import static org.robolectric.util.ReflectionHelpers.callStaticMethod;
+import static org.robolectric.util.ReflectionHelpers.loadClass;
+
+import android.annotation.TargetApi;
+import android.graphics.Matrix;
+import android.graphics.RenderNode;
+import android.os.Build;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Android-Q only test for {@code RenderNode}'s shadow for both pre-Q & Q (where the latter's {@code
+ * RenderNode} was moved to a public API to open access to it.
+ */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public final class ShadowRenderNodeTest {
+
+  @Test
+  public void testGetTranslationX_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float translationX = renderNode.getTranslationX();
+
+    assertThat(translationX).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetTranslationX_set_returnsSetTranslation() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationX(1.f);
+
+    float translationX = renderNode.getTranslationX();
+
+    assertThat(translationX).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testGetTranslationY_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float translationY = renderNode.getTranslationY();
+
+    assertThat(translationY).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetTranslationY_set_returnsSetTranslation() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationY(1.f);
+
+    float translationY = renderNode.getTranslationY();
+
+    assertThat(translationY).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testGetRotationX_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float rotationX = renderNode.getRotationX();
+
+    assertThat(rotationX).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetRotationX_set_returnsSetRotationX() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setRotationX(45.f);
+
+    float rotationX = renderNode.getRotationX();
+
+    assertThat(rotationX).isWithin(1e-3f).of(45.f);
+  }
+
+  @Test
+  public void testGetRotationY_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float rotationY = renderNode.getRotationY();
+
+    assertThat(rotationY).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetRotationY_set_returnsSetRotationY() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setRotationY(45.f);
+
+    float rotationY = renderNode.getRotationY();
+
+    assertThat(rotationY).isWithin(1e-3f).of(45.f);
+  }
+
+  @Test
+  public void testGetRotationZ_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float rotationZ = renderNode.getRotationZ();
+
+    assertThat(rotationZ).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetRotationZ_set_returnsSetRotationZ() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setRotationZ(45.f);
+
+    float rotationZ = renderNode.getRotationZ();
+
+    assertThat(rotationZ).isWithin(1e-3f).of(45.f);
+  }
+
+  @Test
+  public void testGetScaleX_unset_returnsOne() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float scaleX = renderNode.getScaleX();
+
+    assertThat(scaleX).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testGetScaleX_set_returnsSetScale() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setScaleX(2.f);
+
+    float scaleX = renderNode.getScaleX();
+
+    assertThat(scaleX).isWithin(1e-3f).of(2.f);
+  }
+
+  @Test
+  public void testGetScaleY_unset_returnsOne() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float scaleY = renderNode.getScaleY();
+
+    assertThat(scaleY).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testGetScaleY_set_returnsSetScale() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setScaleY(2.f);
+
+    float scaleY = renderNode.getScaleY();
+
+    assertThat(scaleY).isWithin(1e-3f).of(2.f);
+  }
+
+  @Test
+  public void testGetPivotX_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float pivotX = renderNode.getPivotX();
+
+    assertThat(pivotX).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetPivotX_set_returnsSetPivot() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setPivotX(1.f);
+
+    float pivotX = renderNode.getPivotX();
+
+    assertThat(pivotX).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testGetPivotY_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float pivotY = renderNode.getPivotY();
+
+    assertThat(pivotY).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetPivotY_set_returnsSetPivot() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setPivotY(1.f);
+
+    float pivotY = renderNode.getPivotY();
+
+    assertThat(pivotY).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testIsPivotExplicitlySet_defaultNode_returnsFalse() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    boolean isExplicitlySet = renderNode.isPivotExplicitlySet();
+
+    assertThat(isExplicitlySet).isFalse();
+  }
+
+  @Test
+  public void testIsPivotExplicitlySet_setPivotX_returnsTrue() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setPivotX(1.f);
+
+    boolean isExplicitlySet = renderNode.isPivotExplicitlySet();
+
+    assertThat(isExplicitlySet).isTrue();
+  }
+
+  @Test
+  public void testIsPivotExplicitlySet_setPivotY_returnsTrue() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setPivotY(1.f);
+
+    boolean isExplicitlySet = renderNode.isPivotExplicitlySet();
+
+    assertThat(isExplicitlySet).isTrue();
+  }
+
+  @Test
+  public void testIsPivotExplicitlySet_setPivotXY_toDefaultValues_returnsFalse() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setPivotX(0.f);
+    renderNode.setPivotY(0.f);
+
+    boolean isExplicitlySet = renderNode.isPivotExplicitlySet();
+
+    // Setting the pivot to the center should result in the pivot not being explicitly set.
+    assertThat(isExplicitlySet).isTrue();
+  }
+
+  @Test
+  public void testHasIdentityMatrix_defaultNode_returnsTrue() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    boolean hasIdentityMatrix = renderNode.hasIdentityMatrix();
+
+    assertThat(hasIdentityMatrix).isTrue();
+  }
+
+  @Test
+  public void testHasIdentityMatrix_updatedTranslationX_returnsFalse() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationX(1.f);
+
+    boolean hasIdentityMatrix = renderNode.hasIdentityMatrix();
+
+    assertThat(hasIdentityMatrix).isFalse();
+  }
+
+  @Test
+  public void testHasIdentityMatrix_updatedRotationX_returnsTrue() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setRotationX(90.f);
+
+    boolean hasIdentityMatrix = renderNode.hasIdentityMatrix();
+
+    // Rotations about the x-axis are not factored into the render node's transformation matrix.
+    assertThat(hasIdentityMatrix).isTrue();
+  }
+
+  @Test
+  public void testGetMatrix_defaultNode_returnsIdentityMatrix() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    assertThat(matrix.isIdentity()).isTrue();
+  }
+
+  @Test
+  public void testGetMatrix_updatedTranslationX_returnsNonIdentityMatrix() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    renderNode.setTranslationX(1.f);
+
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+    assertThat(matrix.isIdentity()).isFalse();
+  }
+
+  @Test
+  public void testGetMatrix_updatedTranslationX_thenY_returnsDifferentMatrix() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    renderNode.setTranslationX(1.f);
+    Matrix matrix1 = new Matrix();
+    renderNode.getMatrix(matrix1);
+
+    renderNode.setTranslationY(1.f);
+    Matrix matrix2 = new Matrix();
+    renderNode.getMatrix(matrix2);
+
+    assertThat(matrix1).isNotEqualTo(matrix2);
+  }
+
+  @Test
+  public void testGetMatrix_updatedTranslation_returnsMatrixWithTranslation() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationX(2.f);
+    renderNode.setTranslationY(3.f);
+
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    float[] values = new float[9];
+    matrix.getValues(values);
+    assertThat(values[Matrix.MTRANS_X]).isWithin(1e-3f).of(2.f);
+    assertThat(values[Matrix.MTRANS_Y]).isWithin(1e-3f).of(3.f);
+  }
+
+  @Test
+  public void testGetMatrix_updatedRotation_noPivot_mappedPoint_pointRotatesCorrectly() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setRotationZ(90.f);
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    float[] point = {2.f, 5.f};
+    matrix.mapPoints(point);
+
+    // A point rotated counterclockwise 90 degrees will now be across the y-axis and flipped.
+    assertThat(point[0]).isWithin(1e-3f).of(-5.f);
+    assertThat(point[1]).isWithin(1e-3f).of(2.f);
+  }
+
+  @Test
+  public void testGetMatrix_updatedRotation_withPivot_mappedPoint_pointRotatesCorrectly() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setPivotX(1.f);
+    renderNode.setPivotY(2.f);
+    renderNode.setRotationZ(90.f);
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    float[] point = {2.f, 5.f};
+    matrix.mapPoints(point);
+
+    /*
+     * A point rotated counterclockwise 90 degrees will now be across the y-axis and flipped.
+     * However, it's further translated due to the rotation above the pivot point (1, 2). See:
+     *   Rotation of v about X by D: Rt(v, X, D) = R(D) * (v - X) + X
+     *     where Rm(D) is the rotation transformation counterclockwise by D degrees. The above
+     *     shifts the pivot point to the origin, rotates about the origin, then shifts back.
+     *   Applied: Rt((2, 5), (1, 2), 90) = R(90) * ((2, 5) - (1, 2)) + (1, 2).
+     *     R(90) * (1, 3) = (-3, 1) -> (-3, 1) + (1, 2) = (-2, 3)
+     */
+    assertThat(point[0]).isWithin(1e-3f).of(-2.f);
+    assertThat(point[1]).isWithin(1e-3f).of(3.f);
+  }
+
+  @Test
+  public void testGetMatrix_updatedScale_noPivot_mappedPoint_pointScalesCorrectly() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setScaleX(1.5f);
+    renderNode.setScaleY(2.f);
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    float[] point = {2.f, 5.f};
+    matrix.mapPoints(point);
+
+    // (2, 5) * (1.5, 2) = (3, 10)
+    assertThat(point[0]).isWithin(1e-3f).of(3.f);
+    assertThat(point[1]).isWithin(1e-3f).of(10.f);
+  }
+
+  @Test
+  public void testGetMatrix_updatedScale_withPivot_mappedPoint_pointScalesCorrectly() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setScaleX(1.5f);
+    renderNode.setScaleY(2.f);
+    renderNode.setPivotX(1);
+    renderNode.setPivotY(2);
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    float[] point = {2.f, 5.f};
+    matrix.mapPoints(point);
+
+    // See the rotation about a pivot above for the explanation for performing a linear
+    // transformation about a point.
+    // 1. (2, 5) - (1, 2) = (1, 3)
+    // 2. (1, 3) * (1.5, 2) = (1.5, 6)
+    // 3. (1.5, 6) + (1, 2) = (2.5, 8)
+    assertThat(point[0]).isWithin(1e-3f).of(2.5f);
+    assertThat(point[1]).isWithin(1e-3f).of(8.f);
+  }
+
+  @Test
+  public void testGetMatrix_updatedScaleTranslationRotation_withPivot_mappedPoint_pointXformed() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationX(-6.f);
+    renderNode.setTranslationY(-3.f);
+    renderNode.setScaleX(1.5f);
+    renderNode.setScaleY(2.f);
+    renderNode.setPivotX(1);
+    renderNode.setPivotY(2);
+    renderNode.setRotationZ(90.f);
+    Matrix matrix = new Matrix();
+    renderNode.getMatrix(matrix);
+
+    float[] point = {2.f, 5.f};
+    matrix.mapPoints(point);
+
+    // See the pivot tests above for scale & rotation to follow this math. Transformations should be
+    // scale, then rotation, then translation. Both the scale and rotation share a pivot.
+    // 1. (2, 5) - (1, 2) = (1, 3)
+    // 2. (1, 3) * (1.5, 2) = (1.5, 6)
+    // 3. rotate(1.5, 6, 90) = (-6, 1.5) <simplify pivot math because it's shared>
+    // 4. (-6, 1.5) + (1, 2) = (-5, 3.5)
+    // 5. (-5, 3.5) + (-6, -3) = (-11, 0.5)
+    assertThat(point[0]).isWithin(1e-3f).of(-11.f);
+    assertThat(point[1]).isWithin(1e-3f).of(0.5f);
+  }
+
+  @Test
+  public void testGetInverseMatrix_defaultNode_returnsIdentityMatrix() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    Matrix matrix = new Matrix();
+    renderNode.getInverseMatrix(matrix);
+
+    assertThat(matrix.isIdentity()).isTrue();
+  }
+
+  @Test
+  public void testGetInverseMatrix_updatedTranslationX_returnsNonIdentityMatrix() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    renderNode.setTranslationX(1.f);
+
+    Matrix matrix = new Matrix();
+    renderNode.getInverseMatrix(matrix);
+    assertThat(matrix.isIdentity()).isFalse();
+  }
+
+  @Test
+  public void testGetInverseMatrix_updatedTranslationX_thenY_returnsDifferentMatrix() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    renderNode.setTranslationX(1.f);
+    Matrix matrix1 = new Matrix();
+    renderNode.getInverseMatrix(matrix1);
+
+    renderNode.setTranslationY(1.f);
+    Matrix matrix2 = new Matrix();
+    renderNode.getInverseMatrix(matrix2);
+
+    assertThat(matrix1).isNotEqualTo(matrix2);
+  }
+
+  @Test
+  public void testGetInverseMatrix_updatedScaleTranslationRotation_withPivot_mappedPoint_inverts() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationX(-6.f);
+    renderNode.setTranslationY(-3.f);
+    renderNode.setScaleX(1.5f);
+    renderNode.setScaleY(2.f);
+    renderNode.setPivotX(1);
+    renderNode.setPivotY(2);
+    renderNode.setRotationZ(90.f);
+    Matrix inverse = new Matrix();
+    renderNode.getInverseMatrix(inverse);
+
+    float[] point = {-11f, 0.5f};
+    inverse.mapPoints(point);
+
+    // See testGetMatrix_updatedScaleTranslationRotation_withPivot_mappedPoint_pointXformed above
+    // for why the point (-11, 0.5) produces (2, 5) when mapped via the inverse matrix.
+    assertThat(point[0]).isWithin(1e-3f).of(2.f);
+    assertThat(point[1]).isWithin(1e-3f).of(5.f);
+  }
+
+  @Test
+  public void testGetMatrix_complexMatrix_multipliedByInverse_producesIdentity() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setTranslationX(-6.f);
+    renderNode.setTranslationY(-3.f);
+    renderNode.setScaleX(1.5f);
+    renderNode.setScaleY(2.f);
+    renderNode.setPivotX(1);
+    renderNode.setPivotY(2);
+    renderNode.setRotationZ(90.f);
+
+    Matrix matrix = new Matrix();
+    Matrix inverse = new Matrix();
+    Matrix product = new Matrix();
+    renderNode.getMatrix(matrix);
+    renderNode.getInverseMatrix(inverse);
+    product.postConcat(matrix);
+    product.postConcat(inverse);
+
+    assertThat(product.isIdentity()).isTrue();
+  }
+
+  @Test
+  public void testGetAlpha_unset_returnsOne() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float alpha = renderNode.getAlpha();
+
+    assertThat(alpha).isWithin(1e-3f).of(1.f);
+  }
+
+  @Test
+  public void testGetAlpha_set_returnsSetAlpha() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setAlpha(0.5f);
+
+    float alpha = renderNode.getAlpha();
+
+    assertThat(alpha).isWithin(1e-3f).of(0.5f);
+  }
+
+  @Test
+  public void testGetCameraDistance_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float cameraDistance = renderNode.getCameraDistance();
+
+    assertThat(cameraDistance).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetCameraDistance_set_returnsSetCameraDistance() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setCameraDistance(2.3f);
+
+    float cameraDistance = renderNode.getCameraDistance();
+
+    assertThat(cameraDistance).isWithin(1e-3f).of(2.3f);
+  }
+
+  @Test
+  public void testGetClipToOutline_unset_returnsFalse() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    boolean clipToOutline = renderNode.getClipToOutline();
+
+    assertThat(clipToOutline).isFalse();
+  }
+
+  @Test
+  public void testGetClipToOutline_set_returnsTrue() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setClipToOutline(true);
+
+    boolean clipToOutline = renderNode.getClipToOutline();
+
+    assertThat(clipToOutline).isTrue();
+  }
+
+  @Test
+  public void testGetElevation_unset_returnsZero() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    float elevation = renderNode.getElevation();
+
+    assertThat(elevation).isWithin(1e-3f).of(0.f);
+  }
+
+  @Test
+  public void testGetElevation_set_returnsSetElevation() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setElevation(2.f);
+
+    float elevation = renderNode.getElevation();
+
+    assertThat(elevation).isWithin(1e-3f).of(2.f);
+  }
+
+  @Test
+  public void testHasOverlappingRendering_unset_returnsFalse() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+
+    boolean hasOverlappingRendering = renderNode.hasOverlappingRendering();
+
+    assertThat(hasOverlappingRendering).isFalse();
+  }
+
+  @Test
+  public void testHasOverlappingRendering_set_returnsTrue() {
+    RenderNodeAccess renderNode = createRenderNode("test");
+    renderNode.setHasOverlappingRendering(true);
+
+    boolean hasOverlappingRendering = renderNode.hasOverlappingRendering();
+
+    assertThat(hasOverlappingRendering).isTrue();
+  }
+
+  private static RenderNodeAccess createRenderNode(String name) {
+    if (Build.VERSION.SDK_INT < Q) {
+      return new RenderNodeAccessPreQ(name);
+    } else {
+      return new RenderNodeAccessPostQ(name);
+    }
+  }
+
+  /**
+   * Provides access to a {@code RenderNode} depending on the version of Android being used. This
+   * class is needed since multiple versions of {@code RenderNode} exist depending on the SDK
+   * version.
+   */
+  private interface RenderNodeAccess {
+    boolean setAlpha(float alpha);
+
+    float getAlpha();
+
+    boolean setCameraDistance(float cameraDistance);
+
+    float getCameraDistance();
+
+    boolean setClipToOutline(boolean clipToOutline);
+
+    boolean getClipToOutline();
+
+    boolean setElevation(float lift);
+
+    float getElevation();
+
+    boolean setHasOverlappingRendering(boolean overlappingRendering);
+
+    boolean hasOverlappingRendering();
+
+    boolean setRotationZ(float rotationZ);
+
+    float getRotationZ();
+
+    boolean setRotationX(float rotationX);
+
+    float getRotationX();
+
+    boolean setRotationY(float rotationY);
+
+    float getRotationY();
+
+    boolean setScaleX(float scaleX);
+
+    float getScaleX();
+
+    boolean setScaleY(float scaleY);
+
+    float getScaleY();
+
+    boolean setTranslationX(float translationX);
+
+    boolean setTranslationY(float translationY);
+
+    boolean setTranslationZ(float translationZ);
+
+    float getTranslationX();
+
+    float getTranslationY();
+
+    float getTranslationZ();
+
+    boolean isPivotExplicitlySet();
+
+    boolean setPivotX(float pivotX);
+
+    float getPivotX();
+
+    boolean setPivotY(float pivotY);
+
+    float getPivotY();
+
+    boolean hasIdentityMatrix();
+
+    void getMatrix(Matrix outMatrix);
+
+    void getInverseMatrix(Matrix outMatrix);
+  }
+
+  /** Provides access to {@link android.view.RenderNode}. */
+  private static final class RenderNodeAccessPreQ implements RenderNodeAccess {
+    private final Class<?> renderNodeClass;
+    private final Object renderNode;
+
+    private RenderNodeAccessPreQ(String name) {
+      renderNodeClass =
+          loadClass(
+              ApplicationProvider.getApplicationContext().getClass().getClassLoader(),
+              "android.view.RenderNode");
+      renderNode =
+          callStaticMethod(
+              renderNodeClass,
+              "create",
+              ReflectionHelpers.ClassParameter.from(String.class, name),
+              ReflectionHelpers.ClassParameter.from(View.class, /* val= */ null));
+    }
+
+    @Override
+    public boolean setAlpha(float alpha) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setAlpha",
+          ReflectionHelpers.ClassParameter.from(float.class, alpha));
+    }
+
+    @Override
+    public float getAlpha() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getAlpha");
+    }
+
+    @Override
+    public boolean setCameraDistance(float cameraDistance) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setCameraDistance",
+          ReflectionHelpers.ClassParameter.from(float.class, cameraDistance));
+    }
+
+    @Override
+    public float getCameraDistance() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getCameraDistance");
+    }
+
+    @Override
+    public boolean setClipToOutline(boolean clipToOutline) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setClipToOutline",
+          ReflectionHelpers.ClassParameter.from(boolean.class, clipToOutline));
+    }
+
+    @Override
+    public boolean getClipToOutline() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getClipToOutline");
+    }
+
+    @Override
+    public boolean setElevation(float lift) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setElevation",
+          ReflectionHelpers.ClassParameter.from(float.class, lift));
+    }
+
+    @Override
+    public float getElevation() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getElevation");
+    }
+
+    @Override
+    public boolean setHasOverlappingRendering(boolean overlappingRendering) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setHasOverlappingRendering",
+          ReflectionHelpers.ClassParameter.from(boolean.class, overlappingRendering));
+    }
+
+    @Override
+    public boolean hasOverlappingRendering() {
+      return callInstanceMethod(renderNodeClass, renderNode, "hasOverlappingRendering");
+    }
+
+    @Override
+    public boolean setRotationZ(float rotationZ) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setRotation",
+          ReflectionHelpers.ClassParameter.from(float.class, rotationZ));
+    }
+
+    @Override
+    public float getRotationZ() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getRotation");
+    }
+
+    @Override
+    public boolean setRotationX(float rotationX) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setRotationX",
+          ReflectionHelpers.ClassParameter.from(float.class, rotationX));
+    }
+
+    @Override
+    public float getRotationX() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getRotationX");
+    }
+
+    @Override
+    public boolean setRotationY(float rotationY) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setRotationY",
+          ReflectionHelpers.ClassParameter.from(float.class, rotationY));
+    }
+
+    @Override
+    public float getRotationY() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getRotationY");
+    }
+
+    @Override
+    public boolean setScaleX(float scaleX) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setScaleX",
+          ReflectionHelpers.ClassParameter.from(float.class, scaleX));
+    }
+
+    @Override
+    public float getScaleX() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getScaleX");
+    }
+
+    @Override
+    public boolean setScaleY(float scaleY) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setScaleY",
+          ReflectionHelpers.ClassParameter.from(float.class, scaleY));
+    }
+
+    @Override
+    public float getScaleY() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getScaleY");
+    }
+
+    @Override
+    public boolean setTranslationX(float translationX) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setTranslationX",
+          ReflectionHelpers.ClassParameter.from(float.class, translationX));
+    }
+
+    @Override
+    public boolean setTranslationY(float translationY) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setTranslationY",
+          ReflectionHelpers.ClassParameter.from(float.class, translationY));
+    }
+
+    @Override
+    public boolean setTranslationZ(float translationZ) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setTranslationZ",
+          ReflectionHelpers.ClassParameter.from(float.class, translationZ));
+    }
+
+    @Override
+    public float getTranslationX() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getTranslationX");
+    }
+
+    @Override
+    public float getTranslationY() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getTranslationY");
+    }
+
+    @Override
+    public float getTranslationZ() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getTranslationZ");
+    }
+
+    @Override
+    public boolean isPivotExplicitlySet() {
+      return callInstanceMethod(renderNodeClass, renderNode, "isPivotExplicitlySet");
+    }
+
+    @Override
+    public boolean setPivotX(float pivotX) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setPivotX",
+          ReflectionHelpers.ClassParameter.from(float.class, pivotX));
+    }
+
+    @Override
+    public float getPivotX() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getPivotX");
+    }
+
+    @Override
+    public boolean setPivotY(float pivotY) {
+      return callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "setPivotY",
+          ReflectionHelpers.ClassParameter.from(float.class, pivotY));
+    }
+
+    @Override
+    public float getPivotY() {
+      return callInstanceMethod(renderNodeClass, renderNode, "getPivotY");
+    }
+
+    @Override
+    public boolean hasIdentityMatrix() {
+      return callInstanceMethod(renderNodeClass, renderNode, "hasIdentityMatrix");
+    }
+
+    @Override
+    public void getMatrix(Matrix outMatrix) {
+      callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "getMatrix",
+          ReflectionHelpers.ClassParameter.from(Matrix.class, outMatrix));
+    }
+
+    @Override
+    public void getInverseMatrix(Matrix outMatrix) {
+      callInstanceMethod(
+          renderNodeClass,
+          renderNode,
+          "getInverseMatrix",
+          ReflectionHelpers.ClassParameter.from(Matrix.class, outMatrix));
+    }
+  }
+
+  /** Provides access to {@link android.graphics.RenderNode}. */
+  @TargetApi(Q)
+  private static final class RenderNodeAccessPostQ implements RenderNodeAccess {
+    private final RenderNode renderNode;
+
+    private RenderNodeAccessPostQ(String name) {
+      renderNode = new RenderNode(name);
+    }
+
+    @Override
+    public boolean setAlpha(float alpha) {
+      return renderNode.setAlpha(alpha);
+    }
+
+    @Override
+    public float getAlpha() {
+      return renderNode.getAlpha();
+    }
+
+    @Override
+    public boolean setCameraDistance(float cameraDistance) {
+      return renderNode.setCameraDistance(cameraDistance);
+    }
+
+    @Override
+    public float getCameraDistance() {
+      return renderNode.getCameraDistance();
+    }
+
+    @Override
+    public boolean setClipToOutline(boolean clipToOutline) {
+      return renderNode.setClipToOutline(clipToOutline);
+    }
+
+    @Override
+    public boolean getClipToOutline() {
+      return renderNode.getClipToOutline();
+    }
+
+    @Override
+    public boolean setElevation(float lift) {
+      return renderNode.setElevation(lift);
+    }
+
+    @Override
+    public float getElevation() {
+      return renderNode.getElevation();
+    }
+
+    @Override
+    public boolean setHasOverlappingRendering(boolean overlappingRendering) {
+      return renderNode.setHasOverlappingRendering(overlappingRendering);
+    }
+
+    @Override
+    public boolean hasOverlappingRendering() {
+      return renderNode.hasOverlappingRendering();
+    }
+
+    @Override
+    public boolean setRotationZ(float rotationZ) {
+      return renderNode.setRotationZ(rotationZ);
+    }
+
+    @Override
+    public float getRotationZ() {
+      return renderNode.getRotationZ();
+    }
+
+    @Override
+    public boolean setRotationX(float rotationX) {
+      return renderNode.setRotationX(rotationX);
+    }
+
+    @Override
+    public float getRotationX() {
+      return renderNode.getRotationX();
+    }
+
+    @Override
+    public boolean setRotationY(float rotationY) {
+      return renderNode.setRotationY(rotationY);
+    }
+
+    @Override
+    public float getRotationY() {
+      return renderNode.getRotationY();
+    }
+
+    @Override
+    public boolean setScaleX(float scaleX) {
+      return renderNode.setScaleX(scaleX);
+    }
+
+    @Override
+    public float getScaleX() {
+      return renderNode.getScaleX();
+    }
+
+    @Override
+    public boolean setScaleY(float scaleY) {
+      return renderNode.setScaleY(scaleY);
+    }
+
+    @Override
+    public float getScaleY() {
+      return renderNode.getScaleY();
+    }
+
+    @Override
+    public boolean setTranslationX(float translationX) {
+      return renderNode.setTranslationX(translationX);
+    }
+
+    @Override
+    public boolean setTranslationY(float translationY) {
+      return renderNode.setTranslationY(translationY);
+    }
+
+    @Override
+    public boolean setTranslationZ(float translationZ) {
+      return renderNode.setTranslationZ(translationZ);
+    }
+
+    @Override
+    public float getTranslationX() {
+      return renderNode.getTranslationX();
+    }
+
+    @Override
+    public float getTranslationY() {
+      return renderNode.getTranslationY();
+    }
+
+    @Override
+    public float getTranslationZ() {
+      return renderNode.getTranslationZ();
+    }
+
+    @Override
+    public boolean isPivotExplicitlySet() {
+      return renderNode.isPivotExplicitlySet();
+    }
+
+    @Override
+    public boolean setPivotX(float pivotX) {
+      return renderNode.setPivotX(pivotX);
+    }
+
+    @Override
+    public float getPivotX() {
+      return renderNode.getPivotX();
+    }
+
+    @Override
+    public boolean setPivotY(float pivotY) {
+      return renderNode.setPivotY(pivotY);
+    }
+
+    @Override
+    public float getPivotY() {
+      return renderNode.getPivotY();
+    }
+
+    @Override
+    public boolean hasIdentityMatrix() {
+      return renderNode.hasIdentityMatrix();
+    }
+
+    @Override
+    public void getMatrix(Matrix outMatrix) {
+      renderNode.getMatrix(outMatrix);
+    }
+
+    @Override
+    public void getInverseMatrix(Matrix outMatrix) {
+      renderNode.getInverseMatrix(outMatrix);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowResolveInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowResolveInfoTest.java
new file mode 100644
index 0000000..f9fb8c8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowResolveInfoTest.java
@@ -0,0 +1,32 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.pm.ResolveInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowResolveInfoTest {
+  @Test
+  public void testNewResolveInfoWithActivity() {
+    ResolveInfo mResolveInfo =
+        ShadowResolveInfo.newResolveInfo("name", "package", "fragmentActivity");
+    assertThat(mResolveInfo.loadLabel(null).toString()).isEqualTo("name");
+    assertThat(mResolveInfo.activityInfo.packageName).isEqualTo("package");
+    assertThat(mResolveInfo.activityInfo.applicationInfo.packageName).isEqualTo("package");
+    assertThat(mResolveInfo.activityInfo.name).isEqualTo("fragmentActivity");
+    assertThat(mResolveInfo.toString()).isNotEmpty();
+  }
+
+  @Test
+  public void testNewResolveInfoWithoutActivity() {
+    ResolveInfo mResolveInfo = ShadowResolveInfo.newResolveInfo("name", "package");
+    assertThat(mResolveInfo.loadLabel(null).toString()).isEqualTo("name");
+    assertThat(mResolveInfo.activityInfo.packageName).isEqualTo("package");
+    assertThat(mResolveInfo.activityInfo.applicationInfo.packageName).isEqualTo("package");
+    assertThat(mResolveInfo.activityInfo.name).isEqualTo("package.TestActivity");
+    assertThat(mResolveInfo.toString()).isNotEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java
new file mode 100644
index 0000000..51e7cdf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java
@@ -0,0 +1,292 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
+
+import android.content.res.AssetFileDescriptor;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Range;
+import java.io.InputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.android.XmlResourceParserImpl;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowResourcesTest {
+  private Resources resources;
+
+  @Before
+  public void setup() throws Exception {
+    resources = ApplicationProvider.getApplicationContext().getResources();
+  }
+
+  @Test
+  @Config(qualifiers = "fr")
+  public void testGetValuesResFromSpecificQualifiers() {
+    assertThat(resources.getString(R.string.hello)).isEqualTo("Bonjour");
+  }
+
+  /**
+   * Public framework symbols are defined here:
+   * https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/public.xml
+   * Private framework symbols are defined here:
+   * https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/symbols.xml
+   *
+   * <p>These generate android.R and com.android.internal.R respectively, when Framework Java code
+   * does not need to reference a framework resource it will not have an R value generated.
+   * Robolectric is then missing an identifier for this resource so we must generate a placeholder
+   * ourselves.
+   */
+  @Test
+  @Config(
+      sdk =
+          Build.VERSION_CODES
+              .LOLLIPOP) // android:color/secondary_text_material_dark was added in API 21
+  public void shouldGenerateIdsForResourcesThatAreMissingRValues() {
+    int identifier_missing_from_r_file =
+        resources.getIdentifier("secondary_text_material_dark", "color", "android");
+
+    // We expect Robolectric to generate a placeholder identifier where one was not generated in the
+    // android R files.
+    assertThat(identifier_missing_from_r_file).isNotEqualTo(0);
+
+    // We expect to be able to successfully android:color/secondary_text_material_dark to a
+    // ColorStateList.
+    assertThat(resources.getColorStateList(identifier_missing_from_r_file)).isNotNull();
+  }
+
+  @Test
+  @Config(qualifiers = "fr")
+  public void openRawResource_shouldLoadDrawableWithQualifiers() {
+    InputStream resourceStream = resources.openRawResource(R.drawable.an_image);
+    Bitmap bitmap = BitmapFactory.decodeStream(resourceStream);
+    assertThat(bitmap.getHeight()).isEqualTo(100);
+    assertThat(bitmap.getWidth()).isEqualTo(100);
+  }
+
+  @Test
+  public void openRawResourceFd_returnsNull_todo_FIX() throws Exception {
+    try (AssetFileDescriptor afd = resources.openRawResourceFd(R.raw.raw_resource)) {
+      if (useLegacy()) {
+        assertThat(afd).isNull();
+      } else {
+        assertThat(afd).isNotNull();
+      }
+    }
+  }
+
+  @Test
+  @Config
+  public void themeResolveAttribute_shouldSupportDereferenceResource() {
+    TypedValue out = new TypedValue();
+
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.MyBlackTheme, false);
+
+    theme.resolveAttribute(android.R.attr.windowBackground, out, true);
+    assertThat(out.type).isNotEqualTo(TypedValue.TYPE_REFERENCE);
+    assertThat(out.type)
+        .isIn(Range.closed(TypedValue.TYPE_FIRST_COLOR_INT, TypedValue.TYPE_LAST_COLOR_INT));
+
+    int value = resources.getColor(android.R.color.black);
+    assertThat(out.data).isEqualTo(value);
+  }
+
+  @Test
+  public void themeResolveAttribute_shouldSupportNotDereferencingResource() {
+    TypedValue out = new TypedValue();
+
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.MyBlackTheme, false);
+
+    theme.resolveAttribute(android.R.attr.windowBackground, out, false);
+    assertThat(out.type).isEqualTo(TypedValue.TYPE_REFERENCE);
+    assertThat(out.data).isEqualTo(android.R.color.black);
+  }
+
+  @Test
+  public void obtainStyledAttributes_shouldCheckXmlFirst_fromAttributeSetBuilder() {
+
+    // This simulates a ResourceProvider built from a 21+ SDK as viewportHeight / viewportWidth were
+    // introduced in API 21 but the public ID values they are assigned clash with private
+    // com.android.internal.R values on older SDKs. This test ensures that even on older SDKs, on
+    // calls to obtainStyledAttributes() Robolectric will first check for matching resource ID
+    // values in the AttributeSet before checking the theme.
+
+    AttributeSet attributes =
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.viewportWidth, "12.0")
+            .addAttribute(android.R.attr.viewportHeight, "24.0")
+            .build();
+
+    TypedArray typedArray =
+        ApplicationProvider.getApplicationContext()
+            .getTheme()
+            .obtainStyledAttributes(
+                attributes,
+                new int[] {android.R.attr.viewportWidth, android.R.attr.viewportHeight},
+                0,
+                0);
+    assertThat(typedArray.getFloat(0, 0)).isEqualTo(12.0f);
+    assertThat(typedArray.getFloat(1, 0)).isEqualTo(24.0f);
+    typedArray.recycle();
+  }
+
+  @Test
+  public void getXml_shouldHavePackageContextForReferenceResolution() {
+    if (!useLegacy()) {
+      return;
+    }
+    XmlResourceParserImpl xmlResourceParser =
+        (XmlResourceParserImpl) resources.getXml(R.xml.preferences);
+    assertThat(xmlResourceParser.qualify("?ref")).isEqualTo("?org.robolectric:attr/ref");
+
+    xmlResourceParser = (XmlResourceParserImpl) resources.getXml(android.R.layout.list_content);
+    assertThat(xmlResourceParser.qualify("?ref")).isEqualTo("?android:attr/ref");
+  }
+
+  @Test
+  @Config(minSdk = N_MR1)
+  public void obtainAttributes() {
+    TypedArray typedArray =
+        resources.obtainAttributes(
+            Robolectric.buildAttributeSet()
+                .addAttribute(R.attr.styleReference, "@xml/shortcuts")
+                .build(),
+            new int[] {R.attr.styleReference});
+    assertThat(typedArray).isNotNull();
+    assertThat(typedArray.peekValue(0).resourceId).isEqualTo(R.xml.shortcuts);
+  }
+
+  @Test
+  public void obtainAttributes_shouldUseReferencedIdFromAttributeSet() {
+    // android:id/mask was introduced in API 21, but it's still possible for apps built against API
+    // 21 to refer to it in older runtimes because referenced resource ids are compiled (by aapt)
+    // into the binary XML format.
+    AttributeSet attributeSet =
+        Robolectric.buildAttributeSet().addAttribute(android.R.attr.id, "@android:id/mask").build();
+    TypedArray typedArray = resources.obtainAttributes(attributeSet, new int[] {android.R.attr.id});
+    assertThat(typedArray.getResourceId(0, -9)).isEqualTo(android.R.id.mask);
+  }
+
+  @Test
+  public void obtainAttributes_shouldReturnValuesFromAttributeSet() {
+    AttributeSet attributes =
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.title, "A title!")
+            .addAttribute(android.R.attr.width, "12px")
+            .addAttribute(android.R.attr.height, "1in")
+            .build();
+    TypedArray typedArray =
+        resources.obtainAttributes(
+            attributes,
+            new int[] {android.R.attr.height, android.R.attr.width, android.R.attr.title});
+
+    assertThat(typedArray.getDimension(0, 0)).isEqualTo(160f);
+    assertThat(typedArray.getDimension(1, 0)).isEqualTo(12f);
+    assertThat(typedArray.getString(2)).isEqualTo("A title!");
+    typedArray.recycle();
+  }
+
+  @Test
+  public void obtainStyledAttributesShouldCheckXmlFirst_andFollowReferences() {
+    // This simulates a ResourceProvider built from a 21+ SDK as viewportHeight / viewportWidth were
+    // introduced in API 21 but the public ID values they are assigned clash with private
+    // com.android.internal.R values on older SDKs. This test ensures that even on older SDKs,
+    // on calls to obtainStyledAttributes() Robolectric will first check for matching
+    // resource ID values in the AttributeSet before checking the theme.
+    AttributeSet attributes =
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.viewportWidth, "@integer/test_integer1")
+            .addAttribute(android.R.attr.viewportHeight, "@integer/test_integer2")
+            .build();
+
+    TypedArray typedArray =
+        ApplicationProvider.getApplicationContext()
+            .getTheme()
+            .obtainStyledAttributes(
+                attributes,
+                new int[] {android.R.attr.viewportWidth, android.R.attr.viewportHeight},
+                0,
+                0);
+    assertThat(typedArray.getFloat(0, 0)).isEqualTo(2000);
+    assertThat(typedArray.getFloat(1, 0)).isEqualTo(9);
+    typedArray.recycle();
+  }
+
+  @Test
+  public void getAttributeSetSourceResId() {
+    XmlResourceParser xmlResourceParser = resources.getXml(R.xml.preferences);
+
+    int sourceRedId = ShadowResources.getAttributeSetSourceResId(xmlResourceParser);
+
+    assertThat(sourceRedId).isEqualTo(R.xml.preferences);
+  }
+
+  @Test
+  public void addConfigurationChangeListener_callsOnConfigurationChange() {
+    AtomicBoolean listenerWasCalled = new AtomicBoolean();
+    shadowOf(resources)
+        .addConfigurationChangeListener(
+            (oldConfig, newConfig, newMetrics) -> {
+              listenerWasCalled.set(true);
+              assertThat(newConfig.fontScale).isEqualTo(oldConfig.fontScale * 2);
+            });
+
+    Configuration newConfig = new Configuration(resources.getConfiguration());
+    newConfig.fontScale *= 2;
+    resources.updateConfiguration(newConfig, resources.getDisplayMetrics());
+
+    assertThat(listenerWasCalled.get()).isTrue();
+  }
+
+  @Test
+  public void removeConfigurationChangeListener_doesNotCallOnConfigurationChange() {
+    AtomicBoolean listenerWasCalled = new AtomicBoolean();
+    ShadowResources.OnConfigurationChangeListener listener =
+        (oldConfig, newConfig, newMetrics) -> listenerWasCalled.set(true);
+    Configuration newConfig = new Configuration(resources.getConfiguration());
+    newConfig.fontScale *= 2;
+
+    shadowOf(resources).addConfigurationChangeListener(listener);
+    shadowOf(resources).removeConfigurationChangeListener(listener);
+    resources.updateConfiguration(newConfig, resources.getDisplayMetrics());
+
+    assertThat(listenerWasCalled.get()).isFalse();
+  }
+
+  @Test
+  public void subclassWithNpeGetConfiguration_constructsCorrectly() {
+    // Simulate the behavior of ResourcesWrapper during construction which will throw an NPE if
+    // getConfiguration is called, on lower SDKs the Configuration constructor calls
+    // updateConfiguration(), the ShadowResources will attempt to call getConfiguration during this
+    // method call and shouldn't fail.
+    Resources resourcesSubclass =
+        new Resources(
+            resources.getAssets(), resources.getDisplayMetrics(), resources.getConfiguration()) {
+          @Override
+          public Configuration getConfiguration() {
+            throw new NullPointerException();
+          }
+        };
+
+    assertThat(resourcesSubclass).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRestrictionsManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRestrictionsManagerTest.java
new file mode 100644
index 0000000..06974fb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRestrictionsManagerTest.java
@@ -0,0 +1,52 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.content.RestrictionEntry;
+import android.content.RestrictionsManager;
+import android.os.Bundle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Iterables;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public final class ShadowRestrictionsManagerTest {
+
+  private RestrictionsManager restrictionsManager;
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    restrictionsManager = (RestrictionsManager) context.getSystemService(Context.RESTRICTIONS_SERVICE);
+  }
+
+  @Test
+  public void getApplicationRestrictions() {
+    assertThat(restrictionsManager.getApplicationRestrictions()).isNull();
+
+    Bundle bundle = new Bundle();
+    bundle.putCharSequence("test_key", "test_value");
+    shadowOf(restrictionsManager).setApplicationRestrictions(bundle);
+
+    assertThat(
+            restrictionsManager.getApplicationRestrictions().getCharSequence("test_key").toString())
+        .isEqualTo("test_value");
+  }
+
+  @Test
+  public void getManifestRestrictions() {
+    RestrictionEntry restrictionEntry = Iterables.getOnlyElement(restrictionsManager
+        .getManifestRestrictions(context.getPackageName()));
+
+    assertThat(restrictionEntry.getKey()).isEqualTo("restrictionKey");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowResultReceiverTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowResultReceiverTest.java
new file mode 100644
index 0000000..3ee8135
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowResultReceiverTest.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.ResultReceiver;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowResultReceiverTest {
+  @Test
+  public void callingSend_shouldCallOverridenOnReceiveResultWithTheSameArguments() {
+    TestResultReceiver testResultReceiver = new TestResultReceiver(null);
+    Bundle bundle = new Bundle();
+
+    testResultReceiver.send(5, bundle);
+    assertEquals(5, testResultReceiver.resultCode);
+    assertEquals(bundle, testResultReceiver.resultData);
+  }
+
+  static class TestResultReceiver extends ResultReceiver {
+    int resultCode;
+    Bundle resultData;
+
+    public TestResultReceiver(Handler handler) {
+      super(handler);
+    }
+
+    @Override
+    protected void onReceiveResult(int resultCode, Bundle resultData) {
+      this.resultCode = resultCode;
+      this.resultData = resultData;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRingtoneManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRingtoneManagerTest.java
new file mode 100644
index 0000000..2df75bf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRingtoneManagerTest.java
@@ -0,0 +1,47 @@
+package org.robolectric.shadows;
+
+import static android.media.RingtoneManager.TYPE_ALARM;
+import static android.media.RingtoneManager.TYPE_RINGTONE;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ShadowRingtoneManager}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowRingtoneManagerTest {
+
+  @Test
+  public void getRingtone_returnSetUri() {
+    Context appContext = ApplicationProvider.getApplicationContext();
+    Uri uri = Uri.parse("content://media/external/330");
+    int type = TYPE_RINGTONE;
+
+    RingtoneManager.setActualDefaultRingtoneUri(appContext, type, uri);
+
+    assertThat(RingtoneManager.getActualDefaultRingtoneUri(appContext, type)).isEqualTo(uri);
+  }
+
+  @Test
+  public void getRingtone_noUriSet_returnNull() {
+    Context appContext = ApplicationProvider.getApplicationContext();
+    int type = TYPE_RINGTONE;
+
+    assertThat(RingtoneManager.getActualDefaultRingtoneUri(appContext, type)).isNull();
+  }
+
+  @Test
+  public void getRingtone_uriSetForDifferentType() {
+    Context appContext = ApplicationProvider.getApplicationContext();
+    int type = TYPE_RINGTONE;
+    Uri uri = Uri.parse("content://media/external/330");
+    RingtoneManager.setActualDefaultRingtoneUri(appContext, type, uri);
+
+    assertThat(RingtoneManager.getActualDefaultRingtoneUri(appContext, TYPE_ALARM)).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRoleManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRoleManagerTest.java
new file mode 100644
index 0000000..86109b0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRoleManagerTest.java
@@ -0,0 +1,78 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.role.RoleManager;
+import android.content.Context;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link org.robolectric.shadows.ShadowRoleManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.Q)
+public final class ShadowRoleManagerTest {
+  private RoleManager roleManager;
+
+  @Before
+  public void setUp() {
+    roleManager = (RoleManager) getApplication().getSystemService(Context.ROLE_SERVICE);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void isRoleHeld_shouldThrowWithNullArgument() {
+    shadowOf(roleManager).isRoleHeld(null);
+  }
+
+  @Test()
+  public void addHeldRole_isPresentInIsRoleHeld() {
+    shadowOf(roleManager).addHeldRole(RoleManager.ROLE_SMS);
+    assertThat(roleManager.isRoleHeld(RoleManager.ROLE_SMS)).isTrue();
+  }
+
+  @Test()
+  public void removeHeldRole_notPresentInIsRoleHeld() {
+    shadowOf(roleManager).addHeldRole(RoleManager.ROLE_SMS);
+    shadowOf(roleManager).removeHeldRole(RoleManager.ROLE_SMS);
+    assertThat(roleManager.isRoleHeld(RoleManager.ROLE_SMS)).isFalse();
+  }
+
+  @Test()
+  public void isRoleHeld_noValueByDefault() {
+    assertThat(roleManager.isRoleHeld(RoleManager.ROLE_SMS)).isFalse();
+  }
+
+  @Test
+  public void isRoleAvailable_shouldThrowWithNullArgument() {
+    assertThrows(IllegalArgumentException.class, () -> shadowOf(roleManager).isRoleAvailable(null));
+  }
+
+  @Test()
+  public void addAvailableRole_isPresentInIsRoleAvailable() {
+    shadowOf(roleManager).addAvailableRole(RoleManager.ROLE_SMS);
+    assertThat(roleManager.isRoleAvailable(RoleManager.ROLE_SMS)).isTrue();
+  }
+
+  @Test()
+  public void addAvailableRole_shouldThrowWithEmptyArgument() {
+    assertThrows(IllegalArgumentException.class, () -> shadowOf(roleManager).addAvailableRole(""));
+  }
+
+  @Test()
+  public void removeAvailableRole_notPresentInIsRoleAvailable() {
+    shadowOf(roleManager).addAvailableRole(RoleManager.ROLE_SMS);
+    shadowOf(roleManager).removeAvailableRole(RoleManager.ROLE_SMS);
+    assertThat(roleManager.isRoleAvailable(RoleManager.ROLE_SMS)).isFalse();
+  }
+
+  @Test()
+  public void isRoleAvailable_noValueByDefault() {
+    assertThat(roleManager.isRoleAvailable(RoleManager.ROLE_SMS)).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRollbackManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRollbackManagerTest.java
new file mode 100644
index 0000000..0e714e0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRollbackManagerTest.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadow.api.Shadow.extract;
+
+import android.content.rollback.RollbackInfo;
+import android.content.rollback.RollbackManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowRollbackManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Q)
+public final class ShadowRollbackManagerTest {
+
+  private ShadowRollbackManager instance;
+
+  @Before
+  public void setUp() {
+    instance =
+        extract(
+            ApplicationProvider.getApplicationContext().getSystemService(RollbackManager.class));
+  }
+
+  @Test
+  public void getAvailableRollbacks_empty() {
+    assertThat(instance.getAvailableRollbacks()).isEmpty();
+  }
+
+  @Test
+  public void getAvailableRollbacks_withRollbackInfo() throws Exception {
+    instance.addAvailableRollbacks((RollbackInfo) createRollbackInfo());
+    assertThat(instance.getAvailableRollbacks()).hasSize(1);
+  }
+
+  @Test
+  public void getRecentlyCommittedRollbacks_empty() {
+    assertThat(instance.getRecentlyCommittedRollbacks()).isEmpty();
+  }
+
+  @Test
+  public void getRecentlyCommittedRollbacks_withRollbackInfo() throws Exception {
+    instance.addRecentlyCommittedRollbacks((RollbackInfo) createRollbackInfo());
+    assertThat(instance.getRecentlyCommittedRollbacks()).hasSize(1);
+  }
+
+  @Test
+  public void getRecentlyCommittedRollbacks_assertListsAreSeparate() throws Exception {
+    instance.addAvailableRollbacks((RollbackInfo) createRollbackInfo());
+    assertThat(instance.getAvailableRollbacks()).hasSize(1);
+    assertThat(instance.getRecentlyCommittedRollbacks()).isEmpty();
+  }
+
+  /**
+   * Returns a RollbackInfo as Object.
+   *
+   * <p>Test methods will need to cast this to RollbackInfo. This is necessary, because the
+   * TestRunner may not have access to @SystemApi @hide classes like RollbackInfo at initialization.
+   */
+  private static Object createRollbackInfo() throws Exception {
+    return RollbackInfo.class
+        .getConstructor(int.class, List.class, boolean.class, List.class, int.class)
+        .newInstance(1, ImmutableList.of(), false, ImmutableList.of(), 2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowRttCallTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowRttCallTest.java
new file mode 100644
index 0000000..ada20b8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowRttCallTest.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.os.Build.VERSION_CODES;
+import android.telecom.Call.RttCall;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowCall.ShadowRttCall;
+
+/** Test of ShadowRttCall. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.O_MR1)
+public class ShadowRttCallTest {
+
+  @Test
+  public void storesOneIncomingMessage() throws Exception {
+    List<String> messages = new ArrayList<>();
+    messages.add("hi");
+    RttCall call = createRttCall(messages);
+    String message = call.readImmediately();
+    assertThat(message).matches("hi");
+  }
+
+  @Test
+  public void storeMultipleIncomingMessages() throws Exception {
+    List<String> messages = new ArrayList<>();
+    messages.add("hi");
+    messages.add("how are you");
+    messages.add("where are you");
+    RttCall call = createRttCall(messages);
+    String message = call.readImmediately();
+    assertThat(message).matches("hihow are youwhere are you");
+  }
+
+  @Test
+  public void emptyBuffer_returnsNull() throws Exception {
+    RttCall call = createRttCall(new ArrayList<>());
+    assertThat(call.readImmediately()).isNull();
+  }
+
+  @Test
+  public void emptiesBufferAfterRead() throws Exception {
+    List<String> messages = new ArrayList<>();
+    messages.add("hi");
+    RttCall call = createRttCall(messages);
+    call.readImmediately();
+    assertThat(call.readImmediately()).isNull();
+  }
+
+  private RttCall createRttCall(List<String> messages) throws IOException {
+    RttCall rttCall = new RttCall(null, null, null, 0, null);
+    ShadowRttCall shadowRttCall = shadowOf(rttCall);
+    for (String message : messages) {
+      shadowRttCall.writeRemoteMessage(message);
+    }
+    return rttCall;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSQLiteConnectionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSQLiteConnectionTest.java
new file mode 100644
index 0000000..323511f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSQLiteConnectionTest.java
@@ -0,0 +1,231 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+import static org.robolectric.annotation.SQLiteMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLegacySQLiteConnection.convertSQLWithLocalizedUnicodeCollator;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDatatypeMismatchException;
+import android.database.sqlite.SQLiteStatement;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.almworks.sqlite4java.SQLiteConnection;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.shadows.util.SQLiteLibraryLoader;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+@SQLiteMode(LEGACY) // This test relies on legacy SQLite behavior in Robolectric.
+public class ShadowSQLiteConnectionTest {
+  private SQLiteDatabase database;
+  private File databasePath;
+  private long ptr;
+  private SQLiteConnection conn;
+  private ShadowLegacySQLiteConnection.Connections connections;
+
+  @Before
+  public void setUp() throws Exception {
+    if (!SQLiteLibraryLoader.isOsSupported()) {
+      return;
+    }
+    database = createDatabase("database.db");
+    SQLiteStatement createStatement =
+        database.compileStatement(
+            "CREATE TABLE `routine` (`id` INTEGER PRIMARY KEY AUTOINCREMENT , `name` VARCHAR ,"
+                + " `lastUsed` INTEGER DEFAULT 0 ,  UNIQUE (`name`)) ;");
+    createStatement.execute();
+    conn = getSQLiteConnection();
+  }
+
+  @After
+  public void tearDown() {
+    if (!SQLiteLibraryLoader.isOsSupported()) {
+      return;
+    }
+    database.close();
+  }
+
+  @Test
+  public void testSqlConversion() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    assertThat(convertSQLWithLocalizedUnicodeCollator("select * from `routine`"))
+        .isEqualTo("select * from `routine`");
+
+    assertThat(
+            convertSQLWithLocalizedUnicodeCollator(
+                "select * from `routine` order by name \n\r \f collate\f\n\tunicode"
+                    + "\n, id \n\n\t collate\n\t \n\flocalized"))
+        .isEqualTo(
+            "select * from `routine` order by name COLLATE NOCASE\n" + ", id COLLATE NOCASE");
+
+    assertThat(
+            convertSQLWithLocalizedUnicodeCollator(
+                "select * from `routine` order by name" + " collate localized"))
+        .isEqualTo("select * from `routine` order by name COLLATE NOCASE");
+
+    assertThat(
+            convertSQLWithLocalizedUnicodeCollator(
+                "select * from `routine` order by name" + " collate unicode"))
+        .isEqualTo("select * from `routine` order by name COLLATE NOCASE");
+  }
+
+  @Test
+  public void testSQLWithLocalizedOrUnicodeCollatorShouldBeSortedAsNoCase() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    database.execSQL("insert into routine(name) values ('الصحافة اليدوية')");
+    database.execSQL("insert into routine(name) values ('Hand press 1')");
+    database.execSQL("insert into routine(name) values ('hand press 2')");
+    database.execSQL("insert into routine(name) values ('Hand press 3')");
+
+    List<String> expected =
+        Arrays.asList(
+            "Hand press" + " 1", "hand press" + " 2", "Hand press" + " 3", "الصحافة" + " اليدوية");
+    String sqlLocalized = "SELECT `name` FROM `routine` ORDER BY `name` collate localized";
+    String sqlUnicode = "SELECT `name` FROM `routine` ORDER BY `name` collate unicode";
+
+    assertThat(simpleQueryForList(database, sqlLocalized)).isEqualTo(expected);
+    assertThat(simpleQueryForList(database, sqlUnicode)).isEqualTo(expected);
+  }
+
+  private List<String> simpleQueryForList(SQLiteDatabase db, String sql) {
+    Cursor cursor = db.rawQuery(sql, new String[0]);
+    List<String> result = new ArrayList<>();
+    while (cursor.moveToNext()) {
+      result.add(cursor.getString(0));
+    }
+    cursor.close();
+    return result;
+  }
+
+  @Test
+  public void nativeOpen_addsConnectionToPool() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    assertThat(conn).isNotNull();
+    assertWithMessage("open").that(conn.isOpen()).isTrue();
+  }
+
+  @Test
+  public void nativeClose_closesConnection() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    ShadowLegacySQLiteConnection.nativeClose(ptr);
+    assertWithMessage("open").that(conn.isOpen()).isFalse();
+  }
+
+  @Test
+  public void reset_closesConnection() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    ShadowLegacySQLiteConnection.reset();
+    assertWithMessage("open").that(conn.isOpen()).isFalse();
+  }
+
+  @Test
+  public void reset_clearsConnectionCache() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    final Map<Long, SQLiteConnection> connectionsMap =
+        ReflectionHelpers.getField(connections, "connectionsMap");
+
+    assertWithMessage("connections before").that(connectionsMap).isNotEmpty();
+    ShadowLegacySQLiteConnection.reset();
+
+    assertWithMessage("connections after").that(connectionsMap).isEmpty();
+  }
+
+  @Test
+  public void reset_clearsStatementCache() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    final Map<Long, SQLiteStatement> statementsMap =
+        ReflectionHelpers.getField(connections, "statementsMap");
+
+    assertWithMessage("statements before").that(statementsMap).isNotEmpty();
+    ShadowLegacySQLiteConnection.reset();
+
+    assertWithMessage("statements after").that(statementsMap).isEmpty();
+  }
+
+  @Test
+  public void error_resultsInSpecificExceptionWithCause() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    try {
+      database.execSQL("insert into routine(name) values ('Hand press 1')");
+      ContentValues values = new ContentValues(1);
+      values.put("rowid", "foo");
+      database.update("routine", values, "name='Hand press 1'", null);
+      fail();
+    } catch (SQLiteDatatypeMismatchException expected) {
+      assertThat(expected)
+          .hasCauseThat()
+          .hasCauseThat()
+          .isInstanceOf(com.almworks.sqlite4java.SQLiteException.class);
+    }
+  }
+
+  @Test
+  public void interruption_doesNotConcurrentlyModifyDatabase() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    Thread.currentThread().interrupt();
+    try {
+      database.execSQL("insert into routine(name) values ('الصحافة اليدوية')");
+    } finally {
+      Thread.interrupted();
+    }
+    ShadowLegacySQLiteConnection.reset();
+  }
+
+  @Test
+  public void test_setUseInMemoryDatabase() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    assertThat(conn.isMemoryDatabase()).isFalse();
+    ShadowSQLiteConnection.setUseInMemoryDatabase(true);
+    SQLiteDatabase inMemoryDb = createDatabase("in_memory.db");
+    SQLiteConnection inMemoryConn = getSQLiteConnection();
+    assertThat(inMemoryConn.isMemoryDatabase()).isTrue();
+    inMemoryDb.close();
+  }
+
+  @Test
+  public void cancel_shouldCancelAllStatements() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    SQLiteStatement statement1 =
+        database.compileStatement("insert into routine(name) values ('Hand press 1')");
+    SQLiteStatement statement2 =
+        database.compileStatement("insert into routine(name) values ('Hand press 2')");
+    ShadowLegacySQLiteConnection.nativeCancel(ptr);
+    // An attempt to execute a statement after a cancellation should be a no-op, unless the
+    // statement hasn't been cancelled, in which case it will throw a SQLiteInterruptedException.
+    statement1.execute();
+    statement2.execute();
+  }
+
+  private SQLiteDatabase createDatabase(String filename) {
+    databasePath = ApplicationProvider.getApplicationContext().getDatabasePath(filename);
+    databasePath.getParentFile().mkdirs();
+    return SQLiteDatabase.openOrCreateDatabase(databasePath.getPath(), null);
+  }
+
+  private SQLiteConnection getSQLiteConnection() {
+    ptr =
+        ShadowLegacySQLiteConnection.nativeOpen(
+                databasePath.getPath(), 0, "test connection", false, false)
+            .longValue();
+    connections =
+        ReflectionHelpers.getStaticField(ShadowLegacySQLiteConnection.class, "CONNECTIONS");
+    return connections.getConnection(ptr);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSafetyCenterManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSafetyCenterManagerTest.java
new file mode 100644
index 0000000..b997726
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSafetyCenterManagerTest.java
@@ -0,0 +1,388 @@
+package org.robolectric.shadows;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.safetycenter.SafetyCenterManager;
+import android.safetycenter.SafetyEvent;
+import android.safetycenter.SafetySourceData;
+import android.safetycenter.SafetySourceErrorDetails;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.TIRAMISU)
+public final class ShadowSafetyCenterManagerTest {
+
+  @Before
+  public void setUp() {
+    ((ShadowSafetyCenterManager)
+            Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class)))
+        .setSafetyCenterEnabled(true);
+  }
+
+  @Test
+  public void isSafetyCenterEnabled_whenSetSafetyCenterEnabledTrueCalledOnShadow_returnsTrue() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    shadowSafetyCenterManager.setSafetyCenterEnabled(true);
+
+    assertThat(safetyCenterManager.isSafetyCenterEnabled()).isTrue();
+  }
+
+  @Test
+  public void isSafetyCenterEnabled_whenSetSafetyCenterEnabledFalseCalledOnShadow_returnsFalse() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    shadowSafetyCenterManager.setSafetyCenterEnabled(false);
+
+    assertThat(safetyCenterManager.isSafetyCenterEnabled()).isFalse();
+  }
+
+  @Test
+  public void getSafetySourceData_whenSetSafetySourceDataNeverCalled_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isNull();
+  }
+
+  @Test
+  public void getSafetySourceData_whenDataOnlySetForAnotherId_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+
+    safetyCenterManager.setSafetySourceData(
+        "anotherId",
+        new SafetySourceData.Builder().build(),
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isNull();
+  }
+
+  @Test
+  public void getSafetySourceData_whenDataSetButSetSafetyCenterDisabled_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    shadowSafetyCenterManager.setSafetyCenterEnabled(false);
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        new SafetySourceData.Builder().build(),
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isNull();
+  }
+
+  @Test
+  public void getSafetySourceData_whenFirstDataSet_returnsThatData() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    SafetySourceData data = new SafetySourceData.Builder().build();
+
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        data,
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isSameInstanceAs(data);
+  }
+
+  @Test
+  public void getSafetySourceData_whenDataSetTwice_returnsTheSecondData() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    SafetySourceData data1 = new SafetySourceData.Builder().build();
+    SafetySourceData data2 = new SafetySourceData.Builder().build();
+
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        data1,
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        data2,
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isSameInstanceAs(data2);
+  }
+
+  @Test
+  public void getSafetySourceData_whenDataSetForTwoSources_returnsEitherDependingOnId() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    SafetySourceData data1 = new SafetySourceData.Builder().build();
+    SafetySourceData data2 = new SafetySourceData.Builder().build();
+
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        data1,
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+    safetyCenterManager.setSafetySourceData(
+        "id2",
+        data2,
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isSameInstanceAs(data1);
+    assertThat(safetyCenterManager.getSafetySourceData("id2")).isSameInstanceAs(data2);
+  }
+
+  @Test
+  public void setSafetySourceData_whenSafetyCenterDisabled_doesNotSetData() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetySourceData data = new SafetySourceData.Builder().build();
+
+    shadowSafetyCenterManager.setSafetyCenterEnabled(false);
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        data,
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    // Re-enable it to make assertion that no data was set
+    shadowSafetyCenterManager.setSafetyCenterEnabled(true);
+    assertThat(safetyCenterManager.getSafetySourceData("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetyEvent_whenSetSafetySourceDataNeverCalled_returnsNull() {
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetyEvent_whenDataOnlySetForAnotherId_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    safetyCenterManager.setSafetySourceData(
+        "anotherId",
+        new SafetySourceData.Builder().build(),
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetyEvent_whenDataSetButSetSafetyCenterDisabled_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    shadowSafetyCenterManager.setSafetyCenterEnabled(false);
+    safetyCenterManager.setSafetySourceData(
+        "id1",
+        new SafetySourceData.Builder().build(),
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build());
+
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetyEvent_whenFirstDataSet_returnsThatData() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetyEvent event =
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build();
+
+    safetyCenterManager.setSafetySourceData("id1", new SafetySourceData.Builder().build(), event);
+
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id1")).isSameInstanceAs(event);
+  }
+
+  @Test
+  public void getLastSafetyEvent_whenDataSetTwice_returnsTheSecondData() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetyEvent event1 =
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build();
+    SafetyEvent event2 =
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build();
+
+    safetyCenterManager.setSafetySourceData("id1", new SafetySourceData.Builder().build(), event1);
+    safetyCenterManager.setSafetySourceData("id1", new SafetySourceData.Builder().build(), event2);
+
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id1")).isSameInstanceAs(event2);
+  }
+
+  @Test
+  public void getLastSafetyEvent_whenDataSetForTwoSources_returnsEitherDependingOnId() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetyEvent event1 =
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build();
+    SafetyEvent event2 =
+        new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+            .setRefreshBroadcastId("id")
+            .build();
+
+    safetyCenterManager.setSafetySourceData("id1", new SafetySourceData.Builder().build(), event1);
+    safetyCenterManager.setSafetySourceData("id2", new SafetySourceData.Builder().build(), event2);
+
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id1")).isSameInstanceAs(event1);
+    assertThat(shadowSafetyCenterManager.getLastSafetyEvent("id2")).isSameInstanceAs(event2);
+  }
+
+  @Test
+  public void getLastSafetySourceError_whenReportSafetySourceErrorNeverCalled_returnsNull() {
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetySourceError_whenErrorReportedForAnotherId_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    safetyCenterManager.reportSafetySourceError(
+        "anotherId",
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build()));
+
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetySourceError_whenErrorReportedButSetSafetyCenterDisabled_returnsNull() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+
+    shadowSafetyCenterManager.setSafetyCenterEnabled(false);
+    safetyCenterManager.reportSafetySourceError(
+        "id1",
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build()));
+
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id1")).isNull();
+  }
+
+  @Test
+  public void getLastSafetySourceError_whenErrorReported_returnsErrorDetails() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetySourceErrorDetails errorDetails =
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build());
+
+    safetyCenterManager.reportSafetySourceError("id1", errorDetails);
+
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id1"))
+        .isSameInstanceAs(errorDetails);
+  }
+
+  @Test
+  public void getLastSafetySourceError_whenErrorReportedTwice_returnsSecondErrorDetails() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetySourceErrorDetails errorDetails1 =
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build());
+    SafetySourceErrorDetails errorDetails2 =
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build());
+
+    safetyCenterManager.reportSafetySourceError("id1", errorDetails1);
+    safetyCenterManager.reportSafetySourceError("id1", errorDetails2);
+
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id1"))
+        .isSameInstanceAs(errorDetails2);
+  }
+
+  @Test
+  public void getLastSafetySourceError_whenErrorReportedForTwoSources_returnsEitherDependingOnId() {
+    SafetyCenterManager safetyCenterManager =
+        getApplicationContext().getSystemService(SafetyCenterManager.class);
+    ShadowSafetyCenterManager shadowSafetyCenterManager =
+        Shadow.extract(getApplicationContext().getSystemService(SafetyCenterManager.class));
+    SafetySourceErrorDetails errorDetails1 =
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build());
+    SafetySourceErrorDetails errorDetails2 =
+        new SafetySourceErrorDetails(
+            new SafetyEvent.Builder(SafetyEvent.SAFETY_EVENT_TYPE_REFRESH_REQUESTED)
+                .setRefreshBroadcastId("id")
+                .build());
+
+    safetyCenterManager.reportSafetySourceError("id1", errorDetails1);
+    safetyCenterManager.reportSafetySourceError("id2", errorDetails2);
+
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id1"))
+        .isSameInstanceAs(errorDetails1);
+    assertThat(shadowSafetyCenterManager.getLastSafetySourceError("id2"))
+        .isSameInstanceAs(errorDetails2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowScaleGestureDetectorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowScaleGestureDetectorTest.java
new file mode 100644
index 0000000..9a82765
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowScaleGestureDetectorTest.java
@@ -0,0 +1,93 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowScaleGestureDetectorTest {
+
+  private ScaleGestureDetector detector;
+  private MotionEvent motionEvent;
+
+  @Before
+  public void setUp() throws Exception {
+    detector = new ScaleGestureDetector(ApplicationProvider.getApplicationContext(),
+        new TestOnGestureListener());
+    motionEvent = MotionEvent.obtain(-1, -1, MotionEvent.ACTION_UP, 100, 30, -1);
+  }
+
+  @Test
+  public void test_getOnTouchEventMotionEvent() {
+    detector.onTouchEvent(motionEvent);
+    assertSame(motionEvent, shadowOf(detector).getOnTouchEventMotionEvent());
+  }
+
+  @Test
+  public void test_getScaleFactor() {
+    shadowOf(detector).setScaleFactor(2.0f);
+    assertThat(detector.getScaleFactor()).isEqualTo(2.0f);
+  }
+
+  @Test
+  public void test_getFocusXY() {
+    shadowOf(detector).setFocusXY(2.0f, 3.0f);
+    assertThat(detector.getFocusX()).isEqualTo(2.0f);
+    assertThat(detector.getFocusY()).isEqualTo(3.0f);
+  }
+
+  @Test
+  public void test_getListener() {
+    TestOnGestureListener listener = new TestOnGestureListener();
+    assertSame(
+        listener,
+        shadowOf(new ScaleGestureDetector(ApplicationProvider.getApplicationContext(), listener))
+            .getListener());
+  }
+
+  @Test
+  public void test_reset() {
+    assertDefaults();
+
+    detector.onTouchEvent(motionEvent);
+    shadowOf(detector).setFocusXY(3f, 3f);
+    shadowOf(detector).setScaleFactor(4f);
+    assertSame(motionEvent, shadowOf(detector).getOnTouchEventMotionEvent());
+
+    shadowOf(detector).reset();
+
+    assertDefaults();
+  }
+
+  private void assertDefaults() {
+    assertNull(shadowOf(detector).getOnTouchEventMotionEvent());
+    assertThat(detector.getScaleFactor()).isEqualTo(1f);
+    assertThat(detector.getFocusX()).isEqualTo(0f);
+    assertThat(detector.getFocusY()).isEqualTo(0f);
+  }
+
+  private static class TestOnGestureListener implements ScaleGestureDetector.OnScaleGestureListener {
+    @Override
+    public boolean onScale(ScaleGestureDetector detector) {
+      return false;
+    }
+
+    @Override
+    public boolean onScaleBegin(ScaleGestureDetector detector) {
+      return false;
+    }
+
+    @Override
+    public void onScaleEnd(ScaleGestureDetector detector) {
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowScanResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowScanResultTest.java
new file mode 100644
index 0000000..e1ddd0a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowScanResultTest.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.wifi.ScanResult;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowScanResultTest {
+
+  @Test
+  public void shouldConstruct() {
+    ScanResult scanResult = ShadowScanResult.newInstance("SSID", "BSSID", "Caps", 11, 42);
+    assertThat(scanResult.SSID).isEqualTo("SSID");
+    assertThat(scanResult.BSSID).isEqualTo("BSSID");
+    assertThat(scanResult.capabilities).isEqualTo("Caps");
+    assertThat(scanResult.level).isEqualTo(11);
+    assertThat(scanResult.frequency).isEqualTo(42);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowScrollViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowScrollViewTest.java
new file mode 100644
index 0000000..1b0970b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowScrollViewTest.java
@@ -0,0 +1,80 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+
+import android.app.Activity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ScrollView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowScrollViewTest {
+  @Test
+  public void shouldSmoothScrollTo() {
+    ScrollView scrollView = new ScrollView(ApplicationProvider.getApplicationContext());
+    scrollView.smoothScrollTo(7, 6);
+
+    assertEquals(7, scrollView.getScrollX());
+    assertEquals(6, scrollView.getScrollY());
+  }
+
+  @Test
+  public void shouldSmoothScrollBy() {
+    ScrollView scrollView = new ScrollView(ApplicationProvider.getApplicationContext());
+    scrollView.smoothScrollTo(7, 6);
+    scrollView.smoothScrollBy(10, 20);
+
+    assertEquals(17, scrollView.getScrollX());
+    assertEquals(26, scrollView.getScrollY());
+  }
+
+  @Test
+  public void realCode_shouldSmoothScrollTo() {
+    try {
+      System.setProperty("robolectric.nativeruntime.enableGraphics", "true");
+      Activity activity = Robolectric.setupActivity(Activity.class);
+      ScrollView scrollView = new ScrollView(activity);
+      View view = new View(activity);
+      view.setLayoutParams(new ViewGroup.LayoutParams(1000, 1000));
+      view.layout(
+          0,
+          0,
+          activity.findViewById(android.R.id.content).getWidth(),
+          activity.findViewById(android.R.id.content).getHeight());
+      scrollView.addView(view);
+      scrollView.smoothScrollTo(7, 6);
+      assertEquals(7, scrollView.getScrollX());
+      assertEquals(6, scrollView.getScrollY());
+    } finally {
+      System.clearProperty("robolectric.nativeruntime.enableGraphics");
+    }
+  }
+
+  @Test
+  public void realCode_shouldSmoothScrollBy() {
+    try {
+      System.setProperty("robolectric.nativeruntime.enableGraphics", "true");
+      Activity activity = Robolectric.setupActivity(Activity.class);
+      ScrollView scrollView = new ScrollView(activity);
+      View view = new View(activity);
+      view.setLayoutParams(new ViewGroup.LayoutParams(1000, 1000));
+      view.layout(
+          0,
+          0,
+          activity.findViewById(android.R.id.content).getWidth(),
+          activity.findViewById(android.R.id.content).getHeight());
+      scrollView.addView(view);
+      scrollView.smoothScrollTo(7, 6);
+      scrollView.smoothScrollBy(10, 20);
+      assertEquals(17, scrollView.getScrollX());
+      assertEquals(26, scrollView.getScrollY());
+    } finally {
+      System.clearProperty("robolectric.nativeruntime.enableGraphics");
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSeekBarTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSeekBarTest.java
new file mode 100644
index 0000000..2ef14a7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSeekBarTest.java
@@ -0,0 +1,60 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.widget.SeekBar;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSeekBarTest {
+
+  private SeekBar seekBar;
+  private ShadowSeekBar shadow;
+  private SeekBar.OnSeekBarChangeListener listener;
+  private List<String> transcript;
+
+  @Before
+  public void setup() {
+    seekBar = new SeekBar(ApplicationProvider.getApplicationContext());
+    shadow = Shadows.shadowOf(seekBar);
+    listener = new TestSeekBarChangedListener();
+    transcript = new ArrayList<>();
+    seekBar.setOnSeekBarChangeListener(listener);
+  }
+
+  @Test
+  public void testOnSeekBarChangedListener() {
+    assertThat(shadow.getOnSeekBarChangeListener()).isSameInstanceAs(listener);
+    seekBar.setOnSeekBarChangeListener(null);
+    assertThat(shadow.getOnSeekBarChangeListener()).isNull();
+  }
+
+  @Test
+  public void testOnChangeNotification() {
+    seekBar.setProgress(5);
+    assertThat(transcript).containsExactly("onProgressChanged() - 5");
+  }
+
+  private class TestSeekBarChangedListener implements SeekBar.OnSeekBarChangeListener {
+
+    @Override
+    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+      transcript.add("onProgressChanged() - " + progress);
+    }
+
+    @Override
+    public void onStartTrackingTouch(SeekBar seekBar) {
+    }
+
+    @Override
+    public void onStopTrackingTouch(SeekBar seekBar) {
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSensorManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSensorManagerTest.java
new file mode 100644
index 0000000..ee02ce2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSensorManagerTest.java
@@ -0,0 +1,244 @@
+package org.robolectric.shadows;
+
+import static android.hardware.Sensor.TYPE_ACCELEROMETER;
+import static android.hardware.Sensor.TYPE_GYROSCOPE;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorDirectChannel;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Build;
+import android.os.MemoryFile;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.base.Optional;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSensorManagerTest {
+
+  private SensorManager sensorManager;
+  private ShadowSensorManager shadow;
+
+  @Before
+  public void setUp() {
+    sensorManager =
+        (SensorManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.SENSOR_SERVICE);
+    shadow = shadowOf(sensorManager);
+  }
+
+  @After
+  public void tearDown() {
+    sensorManager = null;
+    shadow = null;
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void createDirectChannel() throws Exception {
+    SensorDirectChannel channel = sensorManager.createDirectChannel(new MemoryFile("name", 10));
+    assertThat(channel.isOpen()).isTrue();
+
+    channel.close();
+    assertThat(channel.isOpen()).isFalse();
+  }
+
+  @Test
+  public void shouldReturnHasListenerAfterRegisteringListener() {
+    SensorEventListener listener = registerListener();
+
+    assertThat(shadow.hasListener(listener)).isTrue();
+  }
+
+  private SensorEventListener registerListener() {
+    SensorEventListener listener = new TestSensorEventListener();
+    Sensor sensor = sensorManager.getDefaultSensor(SensorManager.SENSOR_ACCELEROMETER);
+    sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL);
+
+    return listener;
+  }
+
+  @Test
+  public void shouldReturnHasNoListenerAfterUnregisterListener() {
+    SensorEventListener listener = registerListener();
+    sensorManager.unregisterListener(
+        listener, sensorManager.getDefaultSensor(SensorManager.SENSOR_ACCELEROMETER));
+
+    assertThat(shadow.hasListener(listener)).isFalse();
+  }
+
+  @Test
+  public void shouldReturnHasNoListenerAfterUnregisterListenerWithoutSpecificSensor() {
+    SensorEventListener listener = registerListener();
+    sensorManager.unregisterListener(listener);
+
+    assertThat(shadow.hasListener(listener)).isFalse();
+  }
+
+  @Test
+  public void shouldReturnHasNoListenerByDefault() {
+    SensorEventListener listener = new TestSensorEventListener();
+
+    assertThat(shadow.hasListener(listener)).isFalse();
+  }
+
+  @Test
+  public void shouldTrackSingleListenerRegistrationForDifferentSensors() {
+    SensorEventListener listener = new TestSensorEventListener();
+    Sensor accelSensor = ShadowSensor.newInstance(TYPE_ACCELEROMETER);
+    Sensor gyroSensor = ShadowSensor.newInstance(TYPE_GYROSCOPE);
+
+    sensorManager.registerListener(listener, accelSensor, SensorManager.SENSOR_DELAY_NORMAL);
+    sensorManager.registerListener(listener, gyroSensor, SensorManager.SENSOR_DELAY_NORMAL);
+
+    assertThat(shadow.hasListener(listener)).isTrue();
+    assertThat(shadow.hasListener(listener, accelSensor)).isTrue();
+    assertThat(shadow.hasListener(listener, gyroSensor)).isTrue();
+
+    sensorManager.unregisterListener(listener, accelSensor);
+    assertThat(shadow.hasListener(listener)).isTrue();
+    assertThat(shadow.hasListener(listener, accelSensor)).isFalse();
+    assertThat(shadow.hasListener(listener, gyroSensor)).isTrue();
+
+    sensorManager.unregisterListener(listener, gyroSensor);
+    assertThat(shadow.hasListener(listener)).isFalse();
+    assertThat(shadow.hasListener(listener, accelSensor)).isFalse();
+    assertThat(shadow.hasListener(listener, gyroSensor)).isFalse();
+  }
+
+  @Test
+  public void shouldSendSensorEventToSingleRegisteredListener() {
+    TestSensorEventListener listener = new TestSensorEventListener();
+    Sensor sensor = sensorManager.getDefaultSensor(SensorManager.SENSOR_ACCELEROMETER);
+    sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL);
+    SensorEvent event = shadow.createSensorEvent();
+    // Confirm that the listener has received no events yet.
+    assertThat(listener.getLatestSensorEvent()).isAbsent();
+
+    shadow.sendSensorEventToListeners(event);
+
+    assertThat(listener.getLatestSensorEvent().get()).isEqualTo(event);
+  }
+
+  @Test
+  public void shouldSendSensorEventToMultipleRegisteredListeners() {
+    TestSensorEventListener listener1 = new TestSensorEventListener();
+    TestSensorEventListener listener2 = new TestSensorEventListener();
+    Sensor sensor = sensorManager.getDefaultSensor(SensorManager.SENSOR_ACCELEROMETER);
+    sensorManager.registerListener(listener1, sensor, SensorManager.SENSOR_DELAY_NORMAL);
+    sensorManager.registerListener(listener2, sensor, SensorManager.SENSOR_DELAY_NORMAL);
+    SensorEvent event = shadow.createSensorEvent();
+
+    shadow.sendSensorEventToListeners(event);
+
+    assertThat(listener1.getLatestSensorEvent().get()).isEqualTo(event);
+    assertThat(listener2.getLatestSensorEvent().get()).isEqualTo(event);
+  }
+
+  @Test
+  public void shouldNotCauseConcurrentModificationExceptionSendSensorEvent() {
+    TestSensorEventListener listener1 =
+        new TestSensorEventListener() {
+          @Override
+          public void onSensorChanged(SensorEvent event) {
+            super.onSensorChanged(event);
+            sensorManager.unregisterListener(this);
+          }
+        };
+    Sensor sensor = sensorManager.getDefaultSensor(SensorManager.SENSOR_ACCELEROMETER);
+    sensorManager.registerListener(listener1, sensor, SensorManager.SENSOR_DELAY_NORMAL);
+    SensorEvent event = shadow.createSensorEvent();
+
+    shadow.sendSensorEventToListeners(event);
+
+    assertThat(listener1.getLatestSensorEvent()).hasValue(event);
+  }
+
+  @Test
+  public void shouldNotSendSensorEventIfNoRegisteredListeners() {
+    // Create a listener but don't register it.
+    TestSensorEventListener listener = new TestSensorEventListener();
+    SensorEvent event = shadow.createSensorEvent();
+
+    shadow.sendSensorEventToListeners(event);
+
+    assertThat(listener.getLatestSensorEvent()).isAbsent();
+  }
+
+  @Test
+  public void shouldCreateSensorEvent() {
+    assertThat(shadow.createSensorEvent()).isNotNull();
+  }
+
+  @Test
+  public void shouldCreateSensorEventWithValueArray() {
+    SensorEvent event = ShadowSensorManager.createSensorEvent(3);
+    assertThat(event.values.length).isEqualTo(3);
+  }
+
+  @Test
+  public void shouldCreateSensorEventWithValueArrayAndSensorType() {
+    SensorEvent event = ShadowSensorManager.createSensorEvent(3, Sensor.TYPE_GRAVITY);
+    assertThat(event.values.length).isEqualTo(3);
+    assertThat(event.sensor).isNotNull();
+    assertThat(event.sensor.getType()).isEqualTo(Sensor.TYPE_GRAVITY);
+  }
+
+  @Test
+  public void createSensorEvent_shouldThrowExceptionWhenValueLessThan1() {
+    try {
+      ShadowSensorManager.createSensorEvent(/* valueArraySize= */ 0);
+      fail("Expected IllegalArgumentException not thrown");
+    } catch (Exception e) {
+      assertThat(e).isInstanceOf(IllegalArgumentException.class);
+    }
+  }
+
+  @Test
+  public void getSensor_shouldBeConfigurable() {
+    Sensor sensor = ShadowSensor.newInstance(Sensor.TYPE_ACCELEROMETER);
+    shadowOf(sensorManager).addSensor(sensor);
+    assertThat(sensor).isSameInstanceAs(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER));
+  }
+
+  @Test
+  public void removeSensor_shouldRemoveAddedSensor() {
+    Sensor sensor = ShadowSensor.newInstance(Sensor.TYPE_ACCELEROMETER);
+    ShadowSensorManager shadowSensorManager = shadowOf(sensorManager);
+    shadowSensorManager.addSensor(sensor);
+    assertThat(sensor).isSameInstanceAs(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER));
+    shadowSensorManager.removeSensor(sensor);
+    assertThat(sensorManager.getDefaultSensor(TYPE_ACCELEROMETER)).isNull();
+  }
+
+  @Test
+  public void shouldReturnASensorList() {
+    assertThat(sensorManager.getSensorList(0)).isNotNull();
+  }
+
+  private static class TestSensorEventListener implements SensorEventListener {
+    private Optional<SensorEvent> latestSensorEvent = Optional.absent();
+
+    @Override
+    public void onAccuracyChanged(Sensor sensor, int accuracy) {}
+
+    @Override
+    public void onSensorChanged(SensorEvent event) {
+      latestSensorEvent = Optional.of(event);
+    }
+
+    public Optional<SensorEvent> getLatestSensorEvent() {
+      return latestSensorEvent;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSensorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSensorTest.java
new file mode 100644
index 0000000..62828e3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSensorTest.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.hardware.Sensor;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowSensor} */
+@RunWith(AndroidJUnit4.class)
+public class ShadowSensorTest {
+
+  @Test
+  public void getType() {
+    Sensor sensor = ShadowSensor.newInstance(Sensor.TYPE_ACCELEROMETER);
+    assertThat(sensor.getType()).isEqualTo(Sensor.TYPE_ACCELEROMETER);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.M)
+  public void getStringType() {
+    Sensor sensor = ShadowSensor.newInstance(Sensor.TYPE_ACCELEROMETER);
+    assertThat(sensor.getStringType()).isEqualTo(Sensor.STRING_TYPE_ACCELEROMETER);
+  }
+
+  @Test
+  public void getMaximumRange() {
+    Sensor sensor = ShadowSensor.newInstance(Sensor.TYPE_PROXIMITY);
+    assertThat(sensor.getMaximumRange()).isEqualTo(0f);
+    Shadows.shadowOf(sensor).setMaximumRange(5f);
+    assertThat(sensor.getMaximumRange()).isEqualTo(5f);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowServiceManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowServiceManagerTest.java
new file mode 100644
index 0000000..cf1bf5b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowServiceManagerTest.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.Context;
+import android.os.IBinder;
+import android.os.ServiceManager;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.android.internal.view.IInputMethodManager;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ShadowServiceManager}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowServiceManagerTest {
+
+  @Test
+  public void getService_available_shouldReturnNonNull() {
+    assertThat(ServiceManager.getService(Context.INPUT_METHOD_SERVICE)).isNotNull();
+  }
+
+  @Test
+  public void getService_unavailableService_shouldReturnNull() {
+    ShadowServiceManager.setServiceAvailability(Context.INPUT_METHOD_SERVICE, false);
+    assertThat(ServiceManager.getService(Context.INPUT_METHOD_SERVICE)).isNull();
+  }
+
+  @Test
+  public void getService_multipleThreads_binderRace() throws Exception {
+    ExecutorService e = Executors.newFixedThreadPool(4);
+    final AtomicReference<Exception> thrownException = new AtomicReference<>();
+    for (int i = 0; i < 10; i++) {
+      e.execute(
+          () -> {
+            try {
+              IBinder b = ServiceManager.getService(Context.INPUT_METHOD_SERVICE);
+              IInputMethodManager.Stub.asInterface(b);
+            } catch (RuntimeException ex) {
+              thrownException.compareAndSet(null, ex);
+            }
+          });
+    }
+    e.shutdown();
+    e.awaitTermination(10, SECONDS);
+    assertThat(thrownException.get()).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowServiceTest.java
new file mode 100644
index 0000000..3caa675
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowServiceTest.java
@@ -0,0 +1,188 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.MediaScannerConnection;
+import android.os.IBinder;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowServiceTest {
+  private MyService service ;
+  private Notification.Builder notBuilder;
+
+  NotificationManager nm2 =
+      (NotificationManager)
+          ApplicationProvider.getApplicationContext()
+              .getSystemService(Context.NOTIFICATION_SERVICE);
+
+  @Before
+  public void setup() {
+    service = Robolectric.setupService(MyService.class);
+    notBuilder =
+        new Notification.Builder(service)
+            .setSmallIcon(1)
+            .setContentTitle("Test")
+            .setContentText("Hi there");
+  }
+
+  @Test
+  public void shouldUnbindServiceAtShadowApplication() {
+    Application application = ApplicationProvider.getApplicationContext();
+    ServiceConnection conn = Shadow.newInstanceOf(MediaScannerConnection.class);
+    service.bindService(new Intent("dummy").setPackage("dummy.package"), conn, 0);
+    assertThat(shadowOf(application).getUnboundServiceConnections()).isEmpty();
+    service.unbindService(conn);
+    assertThat(shadowOf(application).getUnboundServiceConnections()).hasSize(1);
+  }
+
+  @Test
+  public void shouldUnbindServiceSuccessfully() {
+    ServiceConnection conn = Shadow.newInstanceOf(MediaScannerConnection.class);
+    service.unbindService(conn);
+  }
+
+  @Test
+  public void shouldUnbindServiceWithExceptionWhenRequested() {
+    shadowOf(RuntimeEnvironment.getApplication()).setUnbindServiceShouldThrowIllegalArgument(true);
+    ServiceConnection conn = Shadow.newInstanceOf(MediaScannerConnection.class);
+    try {
+      service.unbindService(conn);
+      fail("Should throw");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void startForeground() {
+    Notification n = notBuilder.build();
+    service.startForeground(23, n);
+    assertThat(shadowOf(service).getLastForegroundNotification()).isSameInstanceAs(n);
+    assertThat(shadowOf(service).getLastForegroundNotificationId()).isEqualTo(23);
+    assertThat(shadowOf(nm2).getNotification(23)).isSameInstanceAs(n);
+    assertThat(n.flags & Notification.FLAG_FOREGROUND_SERVICE).isNotEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void startForegroundWithForegroundServiceType() {
+    Notification n = notBuilder.build();
+    service.startForeground(23, n, 64);
+    assertThat(shadowOf(service).getLastForegroundNotification()).isSameInstanceAs(n);
+    assertThat(shadowOf(service).getLastForegroundNotificationId()).isEqualTo(23);
+    assertThat(shadowOf(nm2).getNotification(23)).isSameInstanceAs(n);
+    assertThat(n.flags & Notification.FLAG_FOREGROUND_SERVICE).isNotEqualTo(0);
+    assertThat(service.getForegroundServiceType()).isEqualTo(64);
+  }
+
+  @Test
+  public void stopForeground() {
+    service.stopForeground(true);
+    assertThat(shadowOf(service).isForegroundStopped()).isTrue();
+    assertThat(shadowOf(service).getNotificationShouldRemoved()).isTrue();
+  }
+
+  @Test
+  public void stopForegroundRemovesNotificationIfAsked() {
+    service.startForeground(21, notBuilder.build());
+    service.stopForeground(true);
+    assertThat(shadowOf(nm2).getNotification(21)).isNull();
+  }
+
+  /**
+   * According to spec, if the foreground notification is not removed earlier,
+   * then it will be removed when the service is destroyed.
+   */
+  @Test
+  public void stopForegroundDoesntRemoveNotificationUnlessAsked() {
+    Notification n = notBuilder.build();
+    service.startForeground(21, n);
+    service.stopForeground(false);
+    assertThat(shadowOf(nm2).getNotification(21)).isSameInstanceAs(n);
+  }
+
+  @Test
+  public void stopForegroundDoesntDetachNotificationUnlessAsked() {
+    service.startForeground(21, notBuilder.build());
+    service.stopForeground(false);
+    assertThat(shadowOf(service).isLastForegroundNotificationAttached()).isTrue();
+  }
+
+  /**
+   * According to spec, if the foreground notification is not removed earlier,
+   * then it will be removed when the service is destroyed.
+   */
+  @Test
+  public void onDestroyRemovesNotification() {
+    Notification n = notBuilder.build();
+    service.startForeground(21, n);
+    service.onDestroy();
+    assertThat(shadowOf(nm2).getNotification(21)).isNull();
+  }
+
+  /**
+   * Since Nougat, it's been possible to detach the foreground notification from the service,
+   * allowing it to remain after the service dies.
+   */
+  @Test
+  @Config(minSdk = N)
+  public void onDestroyDoesntRemoveDetachedNotification() {
+    Notification n = notBuilder.build();
+    service.startForeground(21, n);
+    service.stopForeground(Service.STOP_FOREGROUND_DETACH);
+    service.onDestroy();
+    assertThat(shadowOf(nm2).getNotification(21)).isSameInstanceAs(n);
+  }
+
+  @Test
+  public void shouldStopSelf() {
+    service.stopSelf();
+    assertThat(shadowOf(service).isStoppedBySelf()).isTrue();
+  }
+
+  @Test
+  public void shouldStopSelfWithId() {
+    service.stopSelf(1);
+    assertThat(shadowOf(service).isStoppedBySelf()).isTrue();
+    assertThat(shadowOf(service).getStopSelfId()).isEqualTo(1);
+  }
+
+  @Test
+  public void shouldStopSelfResultWithId() {
+    service.stopSelfResult(1);
+    assertThat(shadowOf(service).isStoppedBySelf()).isTrue();
+    assertThat(shadowOf(service).getStopSelfResultId()).isEqualTo(1);
+  }
+
+  public static class MyService extends Service {
+    @Override
+    public void onDestroy() {
+      super.onDestroy();
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+      return null;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSettingsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSettingsTest.java
new file mode 100644
index 0000000..44da610
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSettingsTest.java
@@ -0,0 +1,354 @@
+package org.robolectric.shadows;
+
+import static android.location.LocationManager.GPS_PROVIDER;
+import static android.location.LocationManager.NETWORK_PROVIDER;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static android.provider.Settings.Secure.LOCATION_MODE;
+import static android.provider.Settings.Secure.LOCATION_MODE_BATTERY_SAVING;
+import static android.provider.Settings.Secure.LOCATION_MODE_HIGH_ACCURACY;
+import static android.provider.Settings.Secure.LOCATION_MODE_OFF;
+import static android.provider.Settings.Secure.LOCATION_MODE_SENSORS_ONLY;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.idleMainLooper;
+
+import android.animation.ValueAnimator;
+import android.app.Application;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.os.SystemClock;
+import android.provider.Settings;
+import android.provider.Settings.Global;
+import android.provider.Settings.Secure;
+import android.text.format.DateFormat;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSettingsTest {
+
+  private ContentResolver contentResolver;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    contentResolver = context.getContentResolver();
+  }
+
+  @Test
+  public void testSystemGetInt() {
+    assertThat(Settings.System.getInt(contentResolver, "property", 0)).isEqualTo(0);
+    assertThat(Settings.System.getInt(contentResolver, "property", 2)).isEqualTo(2);
+
+    Settings.System.putInt(contentResolver, "property", 1);
+    assertThat(Settings.System.getInt(contentResolver, "property", 0)).isEqualTo(1);
+  }
+
+  @Test
+  public void testSecureGetInt() {
+    assertThat(Settings.Secure.getInt(contentResolver, "property", 0)).isEqualTo(0);
+    assertThat(Settings.Secure.getInt(contentResolver, "property", 2)).isEqualTo(2);
+
+    Settings.Secure.putInt(contentResolver, "property", 1);
+    assertThat(Settings.Secure.getInt(contentResolver, "property", 0)).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testGlobalGetInt() {
+    assertThat(Settings.Global.getInt(contentResolver, "property", 0)).isEqualTo(0);
+    assertThat(Settings.Global.getInt(contentResolver, "property", 2)).isEqualTo(2);
+
+    Settings.Global.putInt(contentResolver, "property", 1);
+    assertThat(Settings.Global.getInt(contentResolver, "property", 0)).isEqualTo(1);
+  }
+
+  @Test
+  public void testSystemGetString() {
+    assertThat(Settings.System.getString(contentResolver, "property")).isNull();
+
+    Settings.System.putString(contentResolver, "property", "value");
+    assertThat(Settings.System.getString(contentResolver, "property")).isEqualTo("value");
+  }
+
+  @Test
+  public void testSystemGetLong() throws Exception {
+    assertThat(Settings.System.getLong(contentResolver, "property", 10L)).isEqualTo(10L);
+    Settings.System.putLong(contentResolver, "property", 42L);
+    assertThat(Settings.System.getLong(contentResolver, "property")).isEqualTo(42L);
+    assertThat(Settings.System.getLong(contentResolver, "property", 10L)).isEqualTo(42L);
+  }
+
+  @Test
+  public void testSystemGetFloat() {
+    assertThat(Settings.System.getFloat(contentResolver, "property", 23.23f)).isEqualTo(23.23f);
+    Settings.System.putFloat(contentResolver, "property", 42.42f);
+    assertThat(Settings.System.getFloat(contentResolver, "property", (float) 10L))
+        .isEqualTo(42.42f);
+  }
+
+  @Test(expected = Settings.SettingNotFoundException.class)
+  public void testSystemGetLong_exception() throws Exception {
+    Settings.System.getLong(contentResolver, "property");
+  }
+
+  @Test(expected = Settings.SettingNotFoundException.class)
+  public void testSystemGetInt_exception() throws Exception {
+    Settings.System.getInt(contentResolver, "property");
+  }
+
+  @Test(expected = Settings.SettingNotFoundException.class)
+  public void testSystemGetFloat_exception() throws Exception {
+    Settings.System.getFloat(contentResolver, "property");
+  }
+
+  @Test
+  public void testSet24HourMode_24() {
+    ShadowSettings.set24HourTimeFormat(true);
+    assertThat(DateFormat.is24HourFormat(context.getBaseContext())).isTrue();
+  }
+
+  @Test
+  public void testSet24HourMode_12() {
+    ShadowSettings.set24HourTimeFormat(false);
+    assertThat(DateFormat.is24HourFormat(context.getBaseContext())).isFalse();
+  }
+
+  @Test
+  public void testSetAdbEnabled_settingsSecure_true() {
+    ShadowSettings.setAdbEnabled(true);
+    assertThat(Secure.getInt(context.getContentResolver(), Secure.ADB_ENABLED, 0)).isEqualTo(1);
+  }
+
+  @Test
+  public void testSetAdbEnabled_settingsSecure_false() {
+    ShadowSettings.setAdbEnabled(false);
+    assertThat(Secure.getInt(context.getContentResolver(), Secure.ADB_ENABLED, 1)).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testSetAdbEnabled_sinceJBMR1_settingsGlobal_true() {
+    ShadowSettings.setAdbEnabled(true);
+    assertThat(Global.getInt(context.getContentResolver(), Global.ADB_ENABLED, 0)).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testSetAdbEnabled_sinceJBMR1_settingsGlobal_false() {
+    ShadowSettings.setAdbEnabled(false);
+    assertThat(Global.getInt(context.getContentResolver(), Global.ADB_ENABLED, 1)).isEqualTo(0);
+  }
+
+  @Test
+  public void testSetInstallNonMarketApps_settingsSecure_true() {
+    ShadowSettings.setInstallNonMarketApps(true);
+    assertThat(Secure.getInt(context.getContentResolver(), Secure.INSTALL_NON_MARKET_APPS, 0))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void testSetInstallNonMarketApps_settingsSecure_false() {
+    ShadowSettings.setInstallNonMarketApps(false);
+    assertThat(Secure.getInt(context.getContentResolver(), Secure.INSTALL_NON_MARKET_APPS, 1))
+        .isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testSetInstallNonMarketApps_sinceJBMR1_settingsGlobal_true() {
+    ShadowSettings.setInstallNonMarketApps(true);
+    assertThat(Global.getInt(context.getContentResolver(), Global.INSTALL_NON_MARKET_APPS, 0))
+        .isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testSetInstallNonMarketApps_sinceJBMR1_settingsGlobal_false() {
+    ShadowSettings.setInstallNonMarketApps(false);
+    assertThat(Global.getInt(context.getContentResolver(), Global.INSTALL_NON_MARKET_APPS, 1))
+        .isEqualTo(0);
+  }
+
+  @Config(minSdk = LOLLIPOP, maxSdk = O) // TODO(christianw) fix location mode
+  @Test
+  public void locationProviders_affectsLocationMode() {
+    // Verify default values
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isTrue();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isFalse();
+
+    Secure.setLocationProviderEnabled(contentResolver, NETWORK_PROVIDER, true);
+
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isTrue();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isTrue();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1))
+        .isEqualTo(LOCATION_MODE_HIGH_ACCURACY);
+
+    Secure.setLocationProviderEnabled(contentResolver, GPS_PROVIDER, false);
+
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isFalse();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isTrue();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1))
+        .isEqualTo(LOCATION_MODE_BATTERY_SAVING);
+
+    Secure.setLocationProviderEnabled(contentResolver, NETWORK_PROVIDER, false);
+
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isFalse();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isFalse();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1)).isEqualTo(LOCATION_MODE_OFF);
+  }
+
+  @Config(minSdk = LOLLIPOP, maxSdk = O) // TODO(christianw) fix location mode
+  @Test
+  public void locationMode_affectsLocationProviders() {
+    // Verify the default value
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1))
+        .isEqualTo(LOCATION_MODE_SENSORS_ONLY);
+
+    // LOCATION_MODE_OFF should set value and disable both location providers
+    assertThat(Secure.putInt(contentResolver, LOCATION_MODE, LOCATION_MODE_OFF)).isTrue();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1)).isEqualTo(LOCATION_MODE_OFF);
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isFalse();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isFalse();
+
+    // LOCATION_MODE_SENSORS_ONLY should set value and enable GPS_PROVIDER
+    assertThat(Secure.putInt(contentResolver, LOCATION_MODE, LOCATION_MODE_SENSORS_ONLY)).isTrue();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1))
+        .isEqualTo(LOCATION_MODE_SENSORS_ONLY);
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isTrue();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isFalse();
+
+    // LOCATION_MODE_BATTERY_SAVING should set value and enable NETWORK_PROVIDER
+    assertThat(Secure.putInt(contentResolver, LOCATION_MODE, LOCATION_MODE_BATTERY_SAVING))
+        .isTrue();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1))
+        .isEqualTo(LOCATION_MODE_BATTERY_SAVING);
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isFalse();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isTrue();
+
+    // LOCATION_MODE_HIGH_ACCURACY should set value and enable both providers
+    assertThat(Secure.putInt(contentResolver, LOCATION_MODE, LOCATION_MODE_HIGH_ACCURACY)).isTrue();
+    assertThat(Secure.getInt(contentResolver, LOCATION_MODE, -1))
+        .isEqualTo(LOCATION_MODE_HIGH_ACCURACY);
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isTrue();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isTrue();
+  }
+
+  @Config(maxSdk = JELLY_BEAN_MR2)
+  @Test
+  public void setLocationProviderEnabled() {
+    // Verify default values
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isTrue();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isFalse();
+
+    Secure.setLocationProviderEnabled(contentResolver, NETWORK_PROVIDER, true);
+
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isTrue();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isTrue();
+
+    Secure.setLocationProviderEnabled(contentResolver, GPS_PROVIDER, false);
+
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isFalse();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isTrue();
+
+    Secure.setLocationProviderEnabled(contentResolver, NETWORK_PROVIDER, false);
+
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, GPS_PROVIDER)).isFalse();
+    assertThat(Secure.isLocationProviderEnabled(contentResolver, NETWORK_PROVIDER)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void testGlobalGetFloat() {
+    float durationScale =
+        Global.getFloat(
+            context.getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, /* def= */ 1.0f);
+
+    assertThat(durationScale).isEqualTo(1.0f);
+
+    Global.putFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0.01f);
+    assertThat(
+            Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, /* def= */ 0))
+        .isEqualTo(0.01f);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void differentContentResolver() {
+    Context context = ApplicationProvider.getApplicationContext();
+    ContentResolver contentResolver1 =
+        context.createConfigurationContext(new Configuration()).getContentResolver();
+    ContentResolver contentResolver2 =
+        context.createConfigurationContext(new Configuration()).getContentResolver();
+
+    Settings.System.putString(contentResolver1, "setting", "system");
+    Settings.Secure.putString(contentResolver1, "setting", "secure");
+    Settings.Global.putString(contentResolver1, "setting", "global");
+
+    assertThat(contentResolver1).isNotSameInstanceAs(contentResolver2);
+    assertThat(Settings.System.getString(contentResolver2, "setting")).isEqualTo("system");
+    assertThat(Settings.Secure.getString(contentResolver2, "setting")).isEqualTo("secure");
+    assertThat(Settings.Global.getString(contentResolver2, "setting")).isEqualTo("global");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void global_animatorDurationScale() {
+    long startTime = SystemClock.uptimeMillis();
+    ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1);
+
+    Settings.Global.putFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0);
+    valueAnimator.setDuration(100);
+    valueAnimator.start();
+    idleMainLooper();
+
+    assertThat(valueAnimator.isRunning()).isFalse();
+    assertThat(valueAnimator.getAnimatedFraction()).isEqualTo(1);
+    // Expect to complete in one frame when the scale is 0 (animator on M runs a post-start "commit"
+    // frame, so expect no more than 2 frame callbacks).
+    assertThat(SystemClock.uptimeMillis() - startTime)
+        .isAtMost(ShadowChoreographer.getFrameDelay().toMillis() * 2);
+  }
+
+  @Test
+  public void testSetLockScreenShowNotifications_settingsSecure_true() {
+    ShadowSettings.setLockScreenShowNotifications(true);
+    assertThat(
+            Secure.getInt(context.getContentResolver(), Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS, 0))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void testSetLockScreenShowNotifications_settingsSecure_false() {
+    ShadowSettings.setLockScreenShowNotifications(false);
+    assertThat(
+            Secure.getInt(context.getContentResolver(), Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS, 0))
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void testSetLockScreenAllowPrivateNotifications_settingsSecure_true() {
+    ShadowSettings.setLockScreenAllowPrivateNotifications(true);
+    assertThat(
+            Secure.getInt(
+                context.getContentResolver(), Secure.LOCK_SCREEN_ALLOW_PRIVATE_NOTIFICATIONS, 0))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void testSetLockScreenAllowPrivateNotifications_settingsSecure_false() {
+    ShadowSettings.setLockScreenAllowPrivateNotifications(false);
+    assertThat(
+            Secure.getInt(
+                context.getContentResolver(), Secure.LOCK_SCREEN_ALLOW_PRIVATE_NOTIFICATIONS, 0))
+        .isEqualTo(0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowShapeDrawableTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowShapeDrawableTest.java
new file mode 100644
index 0000000..f0c0446
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowShapeDrawableTest.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+
+import android.graphics.Paint;
+import android.graphics.drawable.ShapeDrawable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowShapeDrawableTest {
+  @Test
+  public void getPaint_ShouldReturnTheSamePaint() {
+    ShapeDrawable shapeDrawable = new ShapeDrawable();
+    Paint paint = shapeDrawable.getPaint();
+    assertNotNull(paint);
+    assertThat(shapeDrawable.getPaint()).isSameInstanceAs(paint);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSharedMemoryTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSharedMemoryTest.java
new file mode 100644
index 0000000..59b7800
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSharedMemoryTest.java
@@ -0,0 +1,167 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.os.Build;
+import android.os.Parcel;
+import android.os.SharedMemory;
+import android.system.ErrnoException;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowSharedMemory}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowSharedMemoryTest {
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void getSize_shouldReturnSizeAtCreation() throws Exception {
+    try (SharedMemory sharedMemory = SharedMemory.create("foo", 4)) {
+      assertThat(sharedMemory.getSize()).isEqualTo(4);
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void mapReadWrite_shouldReflectWrites() throws Exception {
+    try (SharedMemory sharedMemory = SharedMemory.create("foo", 4)) {
+      ByteBuffer fooBuf = sharedMemory.mapReadWrite();
+      fooBuf.putInt(1234);
+      fooBuf.flip();
+      assertThat(fooBuf.getInt()).isEqualTo(1234);
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void mapReadWrite_shouldReflectWritesAcrossMappings() throws Exception {
+    try (SharedMemory sharedMemory = SharedMemory.create("foo", 4)) {
+      ByteBuffer fooBuf = sharedMemory.mapReadWrite();
+      ByteBuffer barBuf = sharedMemory.mapReadOnly();
+
+      fooBuf.putInt(1234);
+      assertThat(barBuf.getInt()).isEqualTo(1234);
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void mapReadWrite_shouldPersistWritesAcrossUnmap() throws Exception {
+    try (SharedMemory sharedMemory = SharedMemory.create("foo", 4)) {
+      ByteBuffer fooBuf = sharedMemory.mapReadWrite();
+      fooBuf.putInt(1234);
+      SharedMemory.unmap(fooBuf);
+
+      ByteBuffer barBuf = sharedMemory.mapReadOnly();
+      assertThat(barBuf.getInt()).isEqualTo(1234);
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void mapReadWrite_shouldThrowAfterClose() throws Exception {
+    SharedMemory sharedMemory = SharedMemory.create("foo", 4);
+    sharedMemory.close();
+    // Uncomment when robolectric actually implements android.system.Os#close():
+    // try {
+    //   sharedMemory.mapReadWrite();
+    //   fail();
+    // } catch (IllegalStateException expected) {
+    // }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void create_shouldIgnoreDebugNameForIdentity() throws Exception {
+    try (SharedMemory fooMem = SharedMemory.create("same-name", 4);
+        SharedMemory barMem = SharedMemory.create("same-name", 4)) {
+      ByteBuffer fooBuf = fooMem.mapReadWrite();
+      ByteBuffer barBuf = barMem.mapReadWrite();
+
+      fooBuf.putInt(1234);
+      barBuf.putInt(5678);
+
+      fooBuf.flip();
+      assertThat(fooBuf.getInt()).isEqualTo(1234);
+      barBuf.flip();
+      assertThat(barBuf.getInt()).isEqualTo(5678);
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void create_shouldThrowAsInstructed() throws Exception {
+    ShadowSharedMemory.setCreateShouldThrow(new ErrnoException("function", 123));
+    try {
+      SharedMemory.create("foo", 4);
+      fail();
+    } catch (ErrnoException expected) {
+      assertThat(expected.errno).isEqualTo(123);
+    }
+
+    ShadowSharedMemory.setCreateShouldThrow(null);
+    SharedMemory.create("foo", 4);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void unmap_shouldRejectUnknownByteBuffer() {
+    try {
+      SharedMemory.unmap(ByteBuffer.allocate(4));
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void unmap_shouldTolerateDoubleUnmap() throws Exception {
+    try (SharedMemory sharedMemory = SharedMemory.create("foo", 4)) {
+      ByteBuffer fooBuf = sharedMemory.mapReadWrite();
+      fooBuf.putInt(1234);
+      SharedMemory.unmap(fooBuf);
+      SharedMemory.unmap(fooBuf);
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void writeToParcel_shouldNotCrash() throws Exception {
+    try (SharedMemory sharedMemory = SharedMemory.create("foo", 4)) {
+      ByteBuffer fooBuf = sharedMemory.mapReadWrite();
+      fooBuf.putInt(1234);
+      SharedMemory.unmap(fooBuf);
+
+      Parcel parcel = Parcel.obtain();
+      parcel.writeParcelable(sharedMemory, 0);
+      parcel.recycle();
+    }
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O_MR1)
+  public void readFromParcel_shouldSupport() throws Exception {
+    int foo = 1234;
+    Parcel parcel = Parcel.obtain();
+    try (SharedMemory sharedMemory = SharedMemory.create(/* name= */ "foo", /* size= */ 4)) {
+      ByteBuffer fooBuf = sharedMemory.mapReadWrite();
+      fooBuf.putInt(foo);
+      SharedMemory.unmap(fooBuf);
+
+      parcel.writeParcelable(sharedMemory, /* parcelableFlags= */ 0);
+    }
+
+    parcel.setDataPosition(0);
+    try (SharedMemory sharedMemoryNew =
+        parcel.readParcelable(SharedMemory.class.getClassLoader())) {
+      ByteBuffer barBuf = sharedMemoryNew.mapReadOnly();
+      int bar = barBuf.getInt();
+      SharedMemory.unmap(barBuf);
+      assertThat(bar).isEqualTo(foo);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSharedPreferencesTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSharedPreferencesTest.java
new file mode 100644
index 0000000..3d28537
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSharedPreferencesTest.java
@@ -0,0 +1,245 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.LooperMode;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSharedPreferencesTest {
+  private final static String FILENAME = "filename";
+  private SharedPreferences.Editor editor;
+  private SharedPreferences sharedPreferences;
+
+  private final Set<String> stringSet = new HashSet<>();
+
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+
+    sharedPreferences = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE);
+    // Ensure no shared preferences have leaked from previous tests.
+    assertThat(sharedPreferences.getAll()).hasSize(0);
+
+    editor = sharedPreferences.edit();
+    editor.putBoolean("boolean", true);
+    editor.putFloat("float", 1.1f);
+    editor.putInt("int", 2);
+    editor.putLong("long", 3L);
+    editor.putString("string", "foobar");
+
+    stringSet.add( "string1" );
+    stringSet.add( "string2" );
+    stringSet.add( "string3" );
+    editor.putStringSet("stringSet", stringSet);
+  }
+
+  @Test
+  public void commit_shouldStoreValues() {
+    editor.commit();
+
+    SharedPreferences anotherSharedPreferences = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE);
+    assertTrue(anotherSharedPreferences.getBoolean("boolean", false));
+    assertThat(anotherSharedPreferences.getFloat("float", 666f)).isEqualTo(1.1f);
+    assertThat(anotherSharedPreferences.getInt("int", 666)).isEqualTo(2);
+    assertThat(anotherSharedPreferences.getLong("long", 666L)).isEqualTo(3L);
+    assertThat(anotherSharedPreferences.getString("string", "wacka wa")).isEqualTo("foobar");
+    assertThat(anotherSharedPreferences.getStringSet("stringSet", null)).isEqualTo(stringSet);
+  }
+
+  @Test
+  public void commit_shouldClearEditsThatNeedRemoveAndEditsThatNeedCommit() {
+    editor.commit();
+    editor.remove("string").commit();
+
+    assertThat(sharedPreferences.getString("string", "no value for key"))
+        .isEqualTo("no value for key");
+
+    SharedPreferences anotherSharedPreferences = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE);
+    anotherSharedPreferences.edit().putString("string", "value for key").commit();
+
+    editor.commit();
+    assertThat(sharedPreferences.getString("string", "no value for key"))
+        .isEqualTo("value for key");
+  }
+
+  @Test
+  public void getAll_shouldReturnAllValues() {
+    editor.commit();
+    assertThat(sharedPreferences.getAll()).hasSize(6);
+    assertThat(sharedPreferences.getAll().get("int")).isEqualTo(2);
+  }
+
+  @Test
+  public void commit_shouldRemoveValuesThenSetValues() {
+    editor.putString("deleteMe", "foo").commit();
+
+    editor.remove("deleteMe");
+
+    editor.putString("dontDeleteMe", "baz");
+    editor.remove("dontDeleteMe");
+
+    editor.commit();
+
+    SharedPreferences anotherSharedPreferences = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE);
+    assertThat(anotherSharedPreferences.getBoolean("boolean", false)).isTrue();
+    assertThat(anotherSharedPreferences.getFloat("float", 666f)).isEqualTo(1.1f);
+    assertThat(anotherSharedPreferences.getInt("int", 666)).isEqualTo(2);
+    assertThat(anotherSharedPreferences.getLong("long", 666L)).isEqualTo(3L);
+    assertThat(anotherSharedPreferences.getString("string", "wacka wa")).isEqualTo("foobar");
+
+    assertThat(anotherSharedPreferences.getString("deleteMe", "awol")).isEqualTo("awol");
+    assertThat(anotherSharedPreferences.getString("dontDeleteMe", "oops")).isEqualTo("oops");
+  }
+
+  @Test
+  public void commit_shouldClearThenSetValues() {
+    editor.putString("deleteMe", "foo");
+
+    editor.clear();
+    editor.putString("dontDeleteMe", "baz");
+
+    editor.commit();
+
+    SharedPreferences anotherSharedPreferences = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE);
+    assertTrue(anotherSharedPreferences.getBoolean("boolean", false));
+    assertThat(anotherSharedPreferences.getFloat("float", 666f)).isEqualTo(1.1f);
+    assertThat(anotherSharedPreferences.getInt("int", 666)).isEqualTo(2);
+    assertThat(anotherSharedPreferences.getLong("long", 666L)).isEqualTo(3L);
+    assertThat(anotherSharedPreferences.getString("string", "wacka wa")).isEqualTo("foobar");
+
+    // Android always calls clear before put on any open editor, so here "foo" is preserved rather than cleared.
+    assertThat(anotherSharedPreferences.getString("deleteMe", "awol")).isEqualTo("foo");
+    assertThat(anotherSharedPreferences.getString("dontDeleteMe", "oops")).isEqualTo("baz");
+  }
+
+  @Test
+  public void putString_shouldRemovePairIfValueIsNull() {
+    editor.putString("deleteMe", "foo");
+
+    editor.putString("deleteMe", null);
+    editor.commit();
+
+    assertThat(sharedPreferences.getString("deleteMe", null)).isNull();
+  }
+
+  @Test
+  public void putStringSet_shouldRemovePairIfValueIsNull() {
+    editor.putStringSet("deleteMe", new HashSet<>());
+
+    editor.putStringSet("deleteMe", null);
+    editor.commit();
+
+    assertThat(sharedPreferences.getStringSet("deleteMe", null)).isNull();
+  }
+
+  @Test
+  public void apply_shouldStoreValues() {
+    editor.apply();
+
+    SharedPreferences anotherSharedPreferences = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE);
+    assertThat(anotherSharedPreferences.getString("string", "wacka wa")).isEqualTo("foobar");
+  }
+
+  @Test
+  public void shouldReturnDefaultValues() {
+    SharedPreferences anotherSharedPreferences =
+        context.getSharedPreferences("bazBang", Context.MODE_PRIVATE);
+
+    assertFalse(anotherSharedPreferences.getBoolean("boolean", false));
+    assertThat(anotherSharedPreferences.getFloat("float", 666f)).isEqualTo(666f);
+    assertThat(anotherSharedPreferences.getInt("int", 666)).isEqualTo(666);
+    assertThat(anotherSharedPreferences.getLong("long", 666L)).isEqualTo(666L);
+    assertThat(anotherSharedPreferences.getString("string", "wacka wa")).isEqualTo("wacka wa");
+  }
+
+  @Test
+  public void shouldRemoveRegisteredListenersOnUnresgister() {
+    SharedPreferences anotherSharedPreferences =
+        context.getSharedPreferences("bazBang", Context.MODE_PRIVATE);
+
+    SharedPreferences.OnSharedPreferenceChangeListener mockListener = mock(SharedPreferences.OnSharedPreferenceChangeListener.class);
+    anotherSharedPreferences.registerOnSharedPreferenceChangeListener(mockListener);
+
+    anotherSharedPreferences.unregisterOnSharedPreferenceChangeListener(mockListener);
+
+    anotherSharedPreferences.edit().putString("key", "value");
+    verifyNoMoreInteractions(mockListener);
+  }
+
+  @Test
+  public void shouldTriggerRegisteredListeners() {
+    SharedPreferences anotherSharedPreferences =
+        context.getSharedPreferences("bazBang", Context.MODE_PRIVATE);
+
+    final String testKey = "foo";
+
+    final List<String> transcript = new ArrayList<>();
+
+    SharedPreferences.OnSharedPreferenceChangeListener listener =
+        (sharedPreferences, key) -> transcript.add(key + " called");
+    anotherSharedPreferences.registerOnSharedPreferenceChangeListener(listener);
+    anotherSharedPreferences.edit().putString(testKey, "bar").commit();
+
+    assertThat(transcript).containsExactly(testKey+ " called");
+  }
+
+  @Test
+  public void defaultSharedPreferences() {
+    SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
+    sharedPreferences.edit().putString("foo", "bar").commit();
+
+    SharedPreferences anotherSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
+    String restored = anotherSharedPreferences.getString("foo", null);
+    assertThat(restored).isEqualTo("bar");
+  }
+
+  /** Tests a sequence of operations in SharedPreferences that would previously cause a deadlock. */
+  @Test
+  public void commit_multipleTimes() {
+    SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
+    sharedPreferences.edit().putBoolean("foo", true).apply();
+    sharedPreferences.edit().putBoolean("bar", true).commit();
+    assertTrue(sharedPreferences.getBoolean("foo", false));
+    assertTrue(sharedPreferences.getBoolean("bar", false));
+  }
+
+  /**
+   * Tests a sequence of operations in SharedPreferences that would previously cause a deadlock in
+   * Legacy LooperMode.
+   */
+  @Test
+  @LooperMode(LooperMode.Mode.LEGACY)
+  public void commit_inParallel_doesNotDeadlock() throws InterruptedException {
+    SharedPreferences sharedPreferences =
+        PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext());
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+
+    executor.execute(() -> sharedPreferences.edit().putBoolean("bar", true).commit());
+    sharedPreferences.edit().putBoolean("bar", true).commit();
+
+    assertTrue(sharedPreferences.getBoolean("bar", true));
+    executor.shutdown();
+    executor.awaitTermination(10, SECONDS);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowShortcutManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowShortcutManagerTest.java
new file mode 100644
index 0000000..dfccff5
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowShortcutManagerTest.java
@@ -0,0 +1,392 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.ShortcutManager.FLAG_MATCH_CACHED;
+import static android.content.pm.ShortcutManager.FLAG_MATCH_DYNAMIC;
+import static android.content.pm.ShortcutManager.FLAG_MATCH_MANIFEST;
+import static android.content.pm.ShortcutManager.FLAG_MATCH_PINNED;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit tests for ShadowShortcutManager. */
+@Config(minSdk = Build.VERSION_CODES.N_MR1)
+@RunWith(AndroidJUnit4.class)
+public final class ShadowShortcutManagerTest {
+  private static final int MANIFEST_SHORTCUT_COUNT = 5;
+  private static final int DYNAMIC_SHORTCUT_COUNT = 4;
+  private static final int CACHED_DYNAMIC_SHORTCUT_COUNT = 3;
+  private static final int PINNED_SHORTCUT_COUNT = 2;
+
+  private ShortcutManager shortcutManager;
+
+  @Before
+  public void setUp() {
+    shortcutManager =
+        (ShortcutManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.SHORTCUT_SERVICE);
+  }
+
+  @Test
+  public void setMaxIconWidth_iconWidthSetToNewMax() {
+    int width = 10;
+    shadowOf(shortcutManager).setIconMaxWidth(width);
+
+    assertThat(shortcutManager.getIconMaxWidth()).isEqualTo(width);
+  }
+
+  @Test
+  public void setMaxIconHeight_iconHeightSetToNewMax() {
+    int height = 20;
+    shadowOf(shortcutManager).setIconMaxHeight(height);
+
+    assertThat(shortcutManager.getIconMaxHeight()).isEqualTo(height);
+  }
+
+  @Test
+  public void testDynamicShortcuts_twoAdded() {
+    shortcutManager.addDynamicShortcuts(
+        ImmutableList.of(createShortcut("id1"), createShortcut("id2")));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(2);
+  }
+
+  @Test
+  public void testDynamicShortcuts_duplicateGetsDeduped() {
+    shortcutManager.addDynamicShortcuts(
+        ImmutableList.of(createShortcut("id1"), createShortcut("id1")));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+  }
+
+  @Test
+  public void testDynamicShortcuts_immutableShortcutDoesntGetUpdated() {
+    ShortcutInfo shortcut1 = createImmutableShortcut("id1");
+    when(shortcut1.getLongLabel()).thenReturn("original");
+    ShortcutInfo shortcut2 = createImmutableShortcut("id1");
+    when(shortcut2.getLongLabel()).thenReturn("updated");
+
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut1));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut2));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+    assertThat(shortcutManager.getDynamicShortcuts().get(0).getLongLabel().toString())
+        .isEqualTo("original");
+  }
+
+  @Test
+  public void testShortcutWithIdenticalIdGetsUpdated() {
+
+    ShortcutInfo shortcut1 = createShortcutWithLabel("id1", "original");
+    ShortcutInfo shortcut2 = createShortcutWithLabel("id1", "updated");
+
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut1));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut2));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+    assertThat(shortcutManager.getDynamicShortcuts().get(0).getLongLabel().toString())
+        .isEqualTo("updated");
+  }
+
+  @Test
+  public void testRemoveAllDynamicShortcuts() {
+    shortcutManager.addDynamicShortcuts(
+        ImmutableList.of(createShortcut("id1"), createShortcut("id2")));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(2);
+
+    shortcutManager.removeAllDynamicShortcuts();
+    assertThat(shortcutManager.getDynamicShortcuts()).isEmpty();
+  }
+
+  @Test
+  public void testRemoveDynamicShortcuts() {
+    ShortcutInfo shortcut1 = createShortcut("id1");
+    ShortcutInfo shortcut2 = createShortcut("id2");
+    shortcutManager.addDynamicShortcuts(
+        ImmutableList.of(shortcut1, shortcut2));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(2);
+
+    shortcutManager.removeDynamicShortcuts(ImmutableList.of("id1"));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcut2);
+  }
+
+  @Test
+  public void testSetDynamicShortcutsClearOutOldList() {
+    ShortcutInfo shortcut1 = createShortcut("id1");
+    ShortcutInfo shortcut2 = createShortcut("id2");
+    ShortcutInfo shortcut3 = createShortcut("id3");
+    ShortcutInfo shortcut4 = createShortcut("id4");
+
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut1, shortcut2));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcut1, shortcut2);
+    shortcutManager.setDynamicShortcuts(ImmutableList.of(shortcut3, shortcut4));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcut3, shortcut4);
+  }
+
+  @Test
+  public void testUpdateShortcut_dynamic() {
+    ShortcutInfo shortcut1 = createShortcutWithLabel("id1", "original");
+    ShortcutInfo shortcutUpdated = createShortcutWithLabel("id1", "updated");
+    shortcutManager.addDynamicShortcuts(
+        ImmutableList.of(shortcut1));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcut1);
+
+    shortcutManager.updateShortcuts(ImmutableList.of(shortcutUpdated));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcutUpdated);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void testUpdateShortcut_pinned() {
+    ShortcutInfo shortcut1 = createShortcutWithLabel("id1", "original");
+    ShortcutInfo shortcutUpdated = createShortcutWithLabel("id1", "updated");
+    shortcutManager.requestPinShortcut(
+        shortcut1, null /* resultIntent */);
+    assertThat(shortcutManager.getPinnedShortcuts()).containsExactly(shortcut1);
+
+    shortcutManager.updateShortcuts(ImmutableList.of(shortcutUpdated));
+    assertThat(shortcutManager.getPinnedShortcuts()).containsExactly(shortcutUpdated);
+  }
+
+  @Test
+  public void testUpdateShortcutsOnlyUpdatesExistingShortcuts() {
+    ShortcutInfo shortcut1 = createShortcutWithLabel("id1", "original");
+    ShortcutInfo shortcutUpdated = createShortcutWithLabel("id1", "updated");
+    ShortcutInfo shortcut2 = createShortcut("id2");
+
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut1));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcut1);
+    shortcutManager.updateShortcuts(ImmutableList.of(shortcutUpdated, shortcut2));
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcutUpdated);
+    assertThat(shortcutManager.getDynamicShortcuts().get(0).getLongLabel().toString())
+        .isEqualTo("updated");
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void testPinningExistingDynamicShortcut() {
+    ShortcutInfo shortcut1 = createShortcut("id1");
+    ShortcutInfo shortcut2 = createShortcut("id2");
+    shortcutManager.addDynamicShortcuts(ImmutableList.of(shortcut1, shortcut2));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(2);
+
+    shortcutManager.requestPinShortcut(shortcut1, null /* resultIntent */);
+    assertThat(shortcutManager.getDynamicShortcuts()).containsExactly(shortcut2);
+    assertThat(shortcutManager.getPinnedShortcuts()).containsExactly(shortcut1);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void testPinningNewShortcut() {
+    ShortcutInfo shortcut1 = createShortcut("id1");
+    shortcutManager.requestPinShortcut(shortcut1, null /* resultIntent */);
+    assertThat(shortcutManager.getPinnedShortcuts()).containsExactly(shortcut1);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void testSetMaxShortcutCountPerActivity() {
+    ShadowShortcutManager shadowShortcutManager = Shadow.extract(shortcutManager);
+    shadowShortcutManager.setMaxShortcutCountPerActivity(42);
+    assertThat(shortcutManager.getMaxShortcutCountPerActivity()).isEqualTo(42);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.O)
+  public void testSetManifestShortcuts() {
+    ImmutableList<ShortcutInfo> manifestShortcuts = ImmutableList.of(createShortcut("id1"));
+    ShadowShortcutManager shadowShortcutManager = Shadow.extract(shortcutManager);
+    shadowShortcutManager.setManifestShortcuts(manifestShortcuts);
+    assertThat(shortcutManager.getManifestShortcuts()).isEqualTo(manifestShortcuts);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getShortcuts_matchNone_emptyListReturned() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    List<ShortcutInfo> shortcuts = shortcutManager.getShortcuts(0);
+
+    assertThat(shortcuts).isEmpty();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getShortcuts_matchManifest_manifestShortcutsReturned() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    List<ShortcutInfo> shortcuts = shortcutManager.getShortcuts(FLAG_MATCH_MANIFEST);
+
+    assertThat(shortcuts).hasSize(MANIFEST_SHORTCUT_COUNT);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getShortcuts_matchDynamic_dynamicShortcutsReturned() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    List<ShortcutInfo> shortcuts = shortcutManager.getShortcuts(FLAG_MATCH_DYNAMIC);
+
+    assertThat(shortcuts).hasSize(DYNAMIC_SHORTCUT_COUNT + CACHED_DYNAMIC_SHORTCUT_COUNT);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getShortcuts_matchCached_cachedShortcutsReturned() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    List<ShortcutInfo> shortcuts = shortcutManager.getShortcuts(FLAG_MATCH_CACHED);
+
+    assertThat(shortcuts).hasSize(CACHED_DYNAMIC_SHORTCUT_COUNT);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getShortcuts_matchPinned_pinnedShortcutsReturned() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    List<ShortcutInfo> shortcuts = shortcutManager.getShortcuts(FLAG_MATCH_PINNED);
+
+    assertThat(shortcuts).hasSize(PINNED_SHORTCUT_COUNT);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void getShortcuts_matchMultipleFlags_matchedShortcutsReturned() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    List<ShortcutInfo> shortcuts =
+        shortcutManager.getShortcuts(FLAG_MATCH_CACHED | FLAG_MATCH_PINNED);
+
+    assertThat(shortcuts).hasSize(CACHED_DYNAMIC_SHORTCUT_COUNT + PINNED_SHORTCUT_COUNT);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void addDynamicShortcuts_longLived_cachedShortcutsAdded() {
+    createShortCuts(
+        MANIFEST_SHORTCUT_COUNT,
+        DYNAMIC_SHORTCUT_COUNT,
+        CACHED_DYNAMIC_SHORTCUT_COUNT,
+        PINNED_SHORTCUT_COUNT);
+    shortcutManager.addDynamicShortcuts(
+        ImmutableList.of(
+            createLongLivedShortcut("id1", /* isLonglived= */ true),
+            createLongLivedShortcut("id2", /* isLonglived= */ true)));
+
+    List<ShortcutInfo> shortcuts = shortcutManager.getShortcuts(FLAG_MATCH_CACHED);
+
+    assertThat(shortcuts).hasSize(CACHED_DYNAMIC_SHORTCUT_COUNT + 2);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void pushTwoDynamicShortcuts_shortcutsAdded() {
+    shortcutManager.pushDynamicShortcut(createShortcut("id1"));
+    shortcutManager.pushDynamicShortcut(createShortcut("id2"));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(2);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void pushDynamicShortcutWithSameId_duplicateGetsDeduped() {
+    shortcutManager.pushDynamicShortcut(createShortcut("id1"));
+    shortcutManager.pushDynamicShortcut(createShortcut("id1"));
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void pushDynamicShortcutWithSameId_differentLabel_shortcutIsUpdated() {
+    ShortcutInfo shortcut1 = createShortcutWithLabel("id1", "original");
+    ShortcutInfo shortcut2 = createShortcutWithLabel("id1", "updated");
+
+    shortcutManager.pushDynamicShortcut(shortcut1);
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+    shortcutManager.pushDynamicShortcut(shortcut2);
+    assertThat(shortcutManager.getDynamicShortcuts()).hasSize(1);
+    assertThat(shortcutManager.getDynamicShortcuts().get(0).getLongLabel().toString())
+        .isEqualTo("updated");
+  }
+
+  private void createShortCuts(
+      int manifestShortcutCount,
+      int dynamicShortcutCount,
+      int cachedShortcutCount,
+      int pinnedShortcutCount) {
+    List<ShortcutInfo> manifestShortcuts = new ArrayList<>();
+    for (int i = 0; i < manifestShortcutCount; i++) {
+      manifestShortcuts.add(createShortcut("manifest_" + i));
+    }
+    ShadowShortcutManager shadowShortcutManager = Shadow.extract(shortcutManager);
+    shadowShortcutManager.setManifestShortcuts(manifestShortcuts);
+
+    List<ShortcutInfo> dynamicShortcuts = new ArrayList<>();
+    for (int i = 0; i < dynamicShortcutCount; i++) {
+      dynamicShortcuts.add(createShortcut("dynamic_" + i));
+    }
+    for (int i = 0; i < cachedShortcutCount; i++) {
+      dynamicShortcuts.add(createLongLivedShortcut("cached_" + i, /* isLonglived= */ true));
+    }
+    shortcutManager.addDynamicShortcuts(dynamicShortcuts);
+
+    for (int i = 0; i < pinnedShortcutCount; i++) {
+      shortcutManager.requestPinShortcut(createShortcut("pinned_" + i), /* resultIntent= */ null);
+    }
+  }
+
+  private static ShortcutInfo createShortcut(String id) {
+    return new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), id).build();
+  }
+
+  private static ShortcutInfo createImmutableShortcut(String id) {
+    ShortcutInfo shortcut = mock(ShortcutInfo.class);
+    when(shortcut.getId()).thenReturn(id);
+    when(shortcut.isImmutable()).thenReturn(true);
+    return shortcut;
+  }
+
+  private static ShortcutInfo createLongLivedShortcut(String id, boolean isLonglived) {
+    return new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), id)
+        .setLongLived(isLonglived)
+        .build();
+  }
+
+  private static ShortcutInfo createShortcutWithLabel(String id, CharSequence longLabel) {
+    return new ShortcutInfo.Builder(ApplicationProvider.getApplicationContext(), id)
+        .setLongLabel(longLabel)
+        .build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSigningInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSigningInfoTest.java
new file mode 100644
index 0000000..6f700c5
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSigningInfoTest.java
@@ -0,0 +1,56 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.pm.Signature;
+import android.content.pm.SigningInfo;
+import android.os.Build;
+import android.os.Parcel;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowSigningInfo}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.P)
+public final class ShadowSigningInfoTest {
+  @Test
+  public void testParceling_preservesCurrentSignatures() {
+    Signature[] signatures = { new Signature("0123"), new Signature("4657") };
+    SigningInfo signingInfo = Shadow.newInstanceOf(SigningInfo.class);
+    shadowOf(signingInfo).setSignatures(signatures);
+
+    SigningInfo copy = copyViaParcel(signingInfo);
+
+    assertThat(signingInfo.getApkContentsSigners()).isEqualTo(signatures);
+    assertThat(copy.getSigningCertificateHistory()).isNull();
+    assertThat(copy.hasPastSigningCertificates()).isFalse();
+    assertThat(copy.hasMultipleSigners()).isTrue();
+  }
+
+  @Test
+  public void testParceling_preservesPastSigningCertificates() {
+    Signature[] signatures = { new Signature("0123")};
+    Signature[] pastSignatures = { new Signature("0123"), new Signature("4567") };
+    SigningInfo signingInfo = Shadow.newInstanceOf(SigningInfo.class);
+    shadowOf(signingInfo).setSignatures(signatures);
+    shadowOf(signingInfo).setPastSigningCertificates(pastSignatures);
+
+    SigningInfo copy = copyViaParcel(signingInfo);
+
+    assertThat(signingInfo.getApkContentsSigners()).isEqualTo(signatures);
+    assertThat(copy.getSigningCertificateHistory()).isEqualTo(pastSignatures);
+    assertThat(copy.hasPastSigningCertificates()).isTrue();
+    assertThat(copy.hasMultipleSigners()).isFalse();
+  }
+
+  private static SigningInfo copyViaParcel(SigningInfo orig) {
+    Parcel parcel = Parcel.obtain();
+    orig.writeToParcel(parcel, 0);
+    parcel.setDataPosition(0);
+    return SigningInfo.CREATOR.createFromParcel(parcel);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSimpleCursorAdapterTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSimpleCursorAdapterTest.java
new file mode 100644
index 0000000..c764522
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSimpleCursorAdapterTest.java
@@ -0,0 +1,81 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.widget.SimpleCursorAdapter;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSimpleCursorAdapterTest {
+
+  private Application context;
+  private SQLiteDatabase database;
+  private Cursor cursor;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+
+    database = SQLiteDatabase.create(null);
+    database.execSQL("CREATE TABLE table_name(_id INT PRIMARY KEY, name VARCHAR(255));");
+    String[] inserts = {
+      "INSERT INTO table_name (_id, name) VALUES(1234, 'Chuck');",
+      "INSERT INTO table_name (_id, name) VALUES(1235, 'Julie');",
+      "INSERT INTO table_name (_id, name) VALUES(1236, 'Chris');",
+      "INSERT INTO table_name (_id, name) VALUES(1237, 'Brenda');",
+      "INSERT INTO table_name (_id, name) VALUES(1238, 'Jane');"
+    };
+
+    for (String insert : inserts) {
+      database.execSQL(insert);
+    }
+
+    String sql = "SELECT * FROM table_name;";
+    cursor = database.rawQuery(sql, null);
+  }
+
+  @After
+  public void tearDown() {
+    database.close();
+    cursor.close();
+  }
+
+  @Test
+  public void testChangeCursor() {
+    SimpleCursorAdapter adapter =
+        new SimpleCursorAdapter(context, 1, null, new String[] {"name"}, new int[] {2}, 0);
+
+    adapter.changeCursor(cursor);
+
+    assertThat(adapter.getCursor()).isSameInstanceAs(cursor);
+  }
+
+  @Test
+  public void testSwapCursor() {
+    SimpleCursorAdapter adapter =
+        new SimpleCursorAdapter(context, 1, null, new String[] {"name"}, new int[] {2}, 0);
+
+    adapter.swapCursor(cursor);
+
+    assertThat(adapter.getCursor()).isSameInstanceAs(cursor);
+  }
+
+  @Test
+  public void testSwapCursorToNull() {
+    SimpleCursorAdapter adapter =
+        new SimpleCursorAdapter(context, 1, null, new String[] {"name"}, new int[] {2}, 0);
+
+    adapter.swapCursor(cursor);
+    adapter.swapCursor(null);
+
+    assertThat(adapter.getCursor()).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSliceManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSliceManagerTest.java
new file mode 100644
index 0000000..e057a29
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSliceManagerTest.java
@@ -0,0 +1,96 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.slice.SliceManager;
+import android.app.slice.SliceSpec;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build.VERSION_CODES;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.HashSet;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowSliceManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.P)
+public final class ShadowSliceManagerTest {
+
+  private static final String PACKAGE_NAME_1 = "com.google.testing.slicemanager.foo";
+  private static final int PACKAGE_1_UID = 10;
+  private Uri sliceUri1;
+  private static final String PACKAGE_NAME_2 = "com.google.testing.slicemanager.bar";
+  private static final int PACKAGE_2_UID = 20;
+  private Uri sliceUri2;
+  private SliceManager sliceManager;
+
+  @Before
+  public void setUp() {
+    PackageManager packageManager = RuntimeEnvironment.getApplication().getPackageManager();
+    ShadowApplicationPackageManager shadowPackageManager =
+        (ShadowApplicationPackageManager) shadowOf(packageManager);
+    shadowPackageManager.setPackagesForUid(PACKAGE_1_UID, PACKAGE_NAME_1);
+    shadowPackageManager.setPackagesForUid(PACKAGE_2_UID, PACKAGE_NAME_2);
+    sliceUri1 = Uri.parse("content://a/b");
+    sliceUri2 = Uri.parse("content://c/d");
+    sliceManager = ApplicationProvider.getApplicationContext().getSystemService(SliceManager.class);
+  }
+
+  @Test
+  public void testGrantSlicePermission_grantsPermissionToPackage() {
+    sliceManager.grantSlicePermission(PACKAGE_NAME_1, sliceUri1);
+    assertThat(sliceManager.checkSlicePermission(sliceUri1, /* pid= */ 1, PACKAGE_1_UID))
+        .isEqualTo(PackageManager.PERMISSION_GRANTED);
+  }
+
+  @Test
+  public void testGrantSlicePermission_doesNotGrantPermissionToOtherPackage() {
+    sliceManager.grantSlicePermission(PACKAGE_NAME_1, sliceUri1);
+    assertThat(sliceManager.checkSlicePermission(sliceUri1, /* pid= */ 1, PACKAGE_2_UID))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+
+  @Test
+  public void testGrantSlicePermission_doesNotGrantPermissionToOtherSliceUri() {
+    sliceManager.grantSlicePermission(PACKAGE_NAME_1, sliceUri1);
+    assertThat(sliceManager.checkSlicePermission(sliceUri2, /* pid= */ 1, PACKAGE_1_UID))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+
+  @Test
+  public void testRevokeSlicePermission_revokesPermissionToPackage() {
+    sliceManager.grantSlicePermission(PACKAGE_NAME_1, sliceUri1);
+    sliceManager.revokeSlicePermission(PACKAGE_NAME_1, sliceUri1);
+    assertThat(sliceManager.checkSlicePermission(sliceUri1, /* pid= */ 1, PACKAGE_1_UID))
+        .isEqualTo(PackageManager.PERMISSION_DENIED);
+  }
+
+  @Test
+  public void testPinSlice_getPinnedSlicesReturnCorrectList() {
+    SliceSpec sliceSpec = new SliceSpec("androidx.slice.BASIC", 1);
+    sliceManager.pinSlice(sliceUri1, new HashSet<>(ImmutableList.of(sliceSpec)));
+
+    assertThat(sliceManager.getPinnedSlices()).contains(sliceUri1);
+    Set<SliceSpec> sliceSpecSet = sliceManager.getPinnedSpecs(sliceUri1);
+    assertThat(sliceSpecSet).hasSize(1);
+    assertThat(sliceSpecSet).contains(sliceSpec);
+  }
+
+  @Test
+  public void testUnpinSlice_getPinnedSlicesReturnCorrectList() {
+    SliceSpec sliceSpec = new SliceSpec("androidx.slice.BASIC", 1);
+    sliceManager.pinSlice(sliceUri1, new HashSet<>(ImmutableList.of(sliceSpec)));
+    sliceManager.unpinSlice(sliceUri1);
+
+    assertThat(sliceManager.getPinnedSlices()).isEmpty();
+    assertThat(sliceManager.getPinnedSpecs(sliceUri1)).isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSmsManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSmsManagerTest.java
new file mode 100644
index 0000000..9f9b521
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSmsManagerTest.java
@@ -0,0 +1,329 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.PendingIntent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.telephony.SmsManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.collect.Lists;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowSmsManager.DownloadMultimediaMessageParams;
+import org.robolectric.shadows.ShadowSmsManager.SendMultimediaMessageParams;
+import org.robolectric.shadows.ShadowSmsManager.TextMultipartParams;
+import org.robolectric.shadows.ShadowSmsManager.TextSmsParams;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN_MR2)
+public class ShadowSmsManagerTest {
+  private SmsManager smsManager = SmsManager.getDefault();
+  private final String scAddress = "serviceCenterAddress";
+  private final String destAddress = "destinationAddress";
+  private final Uri mmsContentUri = Uri.parse("content://mms/123");
+  private final String mmsLocationUrl = "https://somewherefancy.com/myMms";
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void getForSubscriptionId() {
+    final int subId = 101;
+
+    smsManager = SmsManager.getSmsManagerForSubscriptionId(subId);
+    assertThat(smsManager.getSubscriptionId()).isEqualTo(subId);
+  }
+
+  @Test
+  public void sendTextMessage_shouldStoreLastSentTextParameters() {
+    smsManager.sendTextMessage(destAddress, scAddress, "Body Text", null, null);
+    ShadowSmsManager.TextSmsParams params = shadowOf(smsManager).getLastSentTextMessageParams();
+
+    assertThat(params.getDestinationAddress()).isEqualTo(destAddress);
+    assertThat(params.getScAddress()).isEqualTo(scAddress);
+    assertThat(params.getText()).isEqualTo("Body Text");
+    assertThat(params.getSentIntent()).isNull();
+    assertThat(params.getDeliveryIntent()).isNull();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void sendTextMessage_shouldThrowExceptionWithEmptyDestination() {
+    smsManager.sendTextMessage("", scAddress, "testSmsBodyText", null, null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void sentTextMessage_shouldThrowExceptionWithEmptyText() {
+    smsManager.sendTextMessage(destAddress, scAddress, "", null, null);
+  }
+
+  @Test
+  public void sendMultipartMessage_shouldStoreLastSendMultimediaParameters() {
+    smsManager.sendMultipartTextMessage(
+        destAddress, scAddress, Lists.newArrayList("Foo", "Bar", "Baz"), null, null);
+    ShadowSmsManager.TextMultipartParams params =
+        shadowOf(smsManager).getLastSentMultipartTextMessageParams();
+
+    assertThat(params.getDestinationAddress()).isEqualTo(destAddress);
+    assertThat(params.getScAddress()).isEqualTo(scAddress);
+    assertThat(params.getParts()).containsExactly("Foo", "Bar", "Baz");
+    assertThat(params.getSentIntents()).isNull();
+    assertThat(params.getDeliveryIntents()).isNull();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void sendMultipartTextMessage_shouldThrowExceptionWithEmptyDestination() {
+    smsManager.sendMultipartTextMessage("", scAddress, Lists.newArrayList("Foo"), null, null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void sentMultipartTextMessage_shouldThrowExceptionWithEmptyText() {
+    smsManager.sendMultipartTextMessage(destAddress, scAddress, null, null, null);
+  }
+
+  @Test
+  public void sendDataMessage_shouldStoreLastParameters() {
+    final short destPort = 24;
+    final byte[] data = new byte[] {0, 1, 2, 3, 4};
+    final PendingIntent sentIntent =
+        PendingIntent.getActivity(ApplicationProvider.getApplicationContext(), 10, null, 0);
+    final PendingIntent deliveryIntent =
+        PendingIntent.getActivity(ApplicationProvider.getApplicationContext(), 10, null, 0);
+
+    smsManager.sendDataMessage(destAddress, scAddress, destPort, data, sentIntent, deliveryIntent);
+
+    final ShadowSmsManager.DataMessageParams params =
+        shadowOf(smsManager).getLastSentDataMessageParams();
+    assertThat(params.getDestinationAddress()).isEqualTo(destAddress);
+    assertThat(params.getScAddress()).isEqualTo(scAddress);
+    assertThat(params.getDestinationPort()).isEqualTo(destPort);
+    assertThat(params.getData()).isEqualTo(data);
+    assertThat(params.getSentIntent()).isSameInstanceAs(sentIntent);
+    assertThat(params.getDeliveryIntent()).isSameInstanceAs(deliveryIntent);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void sendDataMessage_shouldThrowExceptionWithEmptyDestination() {
+    smsManager.sendDataMessage("", null, (short) 0, null, null, null);
+  }
+
+  @Test
+  public void clearLastSentDataMessageParams_shouldClearParameters() {
+    smsManager.sendDataMessage(destAddress, scAddress, (short) 0, null, null, null);
+    assertThat(shadowOf(smsManager).getLastSentDataMessageParams()).isNotNull();
+
+    shadowOf(smsManager).clearLastSentDataMessageParams();
+    assertThat(shadowOf(smsManager).getLastSentDataMessageParams()).isNull();
+  }
+
+  @Test
+  public void clearLastSentTextMessageParams_shouldClearParameters() {
+    smsManager.sendTextMessage(destAddress, scAddress, "testSmsBodyText", null, null);
+    assertThat(shadowOf(smsManager).getLastSentTextMessageParams()).isNotNull();
+
+    shadowOf(smsManager).clearLastSentTextMessageParams();
+    assertThat(shadowOf(smsManager).getLastSentTextMessageParams()).isNull();
+  }
+
+  @Test
+  public void clearLastSentMultipartTextMessageParams_shouldClearParameters() {
+    smsManager.sendMultipartTextMessage(
+        destAddress, scAddress, Lists.newArrayList("Foo", "Bar", "Baz"), null, null);
+    assertThat(shadowOf(smsManager).getLastSentMultipartTextMessageParams()).isNotNull();
+
+    shadowOf(smsManager).clearLastSentMultipartTextMessageParams();
+    assertThat(shadowOf(smsManager).getLastSentMultipartTextMessageParams()).isNull();
+  }
+
+  // Tests for {@link SmsManager#sendMultimediaMessage}
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void sendMultimediaMessage_shouldStoreLastSentMultimediaMessageParameters() {
+    Bundle configOverrides = new Bundle();
+    configOverrides.putBoolean("enableMMSDeliveryReports", true);
+    PendingIntent sentIntent = ReflectionHelpers.callConstructor(PendingIntent.class);
+
+    smsManager.sendMultimediaMessage(
+        null, mmsContentUri, mmsLocationUrl, configOverrides, sentIntent);
+    ShadowSmsManager.SendMultimediaMessageParams params =
+        shadowOf(smsManager).getLastSentMultimediaMessageParams();
+
+    assertThat(params.getContentUri()).isEqualTo(mmsContentUri);
+    assertThat(params.getLocationUrl()).isEqualTo(mmsLocationUrl);
+    assertThat(params.getConfigOverrides()).isSameInstanceAs(configOverrides);
+    assertThat(params.getSentIntent()).isSameInstanceAs(sentIntent);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  @Config(minSdk = LOLLIPOP)
+  public void sendMultimediaMessage_shouldThrowExceptionWithEmptyContentUri() {
+    smsManager.sendMultimediaMessage(
+        null,
+        null,
+        mmsLocationUrl,
+        new Bundle(),
+        ReflectionHelpers.callConstructor(PendingIntent.class));
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void sendMultimediaMessage_shouldStoreMessageId() {
+    Bundle configOverrides = new Bundle();
+    configOverrides.putBoolean("enableMMSDeliveryReports", true);
+    PendingIntent sentIntent = ReflectionHelpers.callConstructor(PendingIntent.class);
+
+    long messageId = 32767L;
+    smsManager.sendMultimediaMessage(
+        null, mmsContentUri, mmsLocationUrl, configOverrides, sentIntent, messageId);
+    SendMultimediaMessageParams params = shadowOf(smsManager).getLastSentMultimediaMessageParams();
+
+    assertThat(params.getMessageId()).isEqualTo(messageId);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void clearLastSentMultimediaMessageParams_shouldClearParameters() {
+    smsManager.sendMultimediaMessage(
+        null,
+        mmsContentUri,
+        mmsLocationUrl,
+        new Bundle(),
+        ReflectionHelpers.callConstructor(PendingIntent.class));
+    assertThat(shadowOf(smsManager).getLastSentMultimediaMessageParams()).isNotNull();
+
+    shadowOf(smsManager).clearLastSentMultimediaMessageParams();
+    assertThat(shadowOf(smsManager).getLastSentMultimediaMessageParams()).isNull();
+  }
+
+  // Tests for {@link SmsManager#downloadMultimediaMessage}
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void downloadMultimediaMessage_shouldStoreLastDownloadedMultimediaMessageParameters() {
+    Bundle configOverrides = new Bundle();
+    configOverrides.putBoolean("enableMMSDeliveryReports", true);
+    PendingIntent downloadedIntent = ReflectionHelpers.callConstructor(PendingIntent.class);
+
+    smsManager.downloadMultimediaMessage(
+        null, mmsLocationUrl, mmsContentUri, configOverrides, downloadedIntent);
+    ShadowSmsManager.DownloadMultimediaMessageParams params =
+        shadowOf(smsManager).getLastDownloadedMultimediaMessageParams();
+
+    assertThat(params.getContentUri()).isEqualTo(mmsContentUri);
+    assertThat(params.getLocationUrl()).isEqualTo(mmsLocationUrl);
+    assertThat(params.getConfigOverrides()).isSameInstanceAs(configOverrides);
+    assertThat(params.getDownloadedIntent()).isSameInstanceAs(downloadedIntent);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void downloadMultimediaMessageS_shouldStoreLastDownloadedMultimediaMessageParameters() {
+    Bundle configOverrides = new Bundle();
+    configOverrides.putBoolean("enableMMSDeliveryReports", true);
+    PendingIntent downloadedIntent = ReflectionHelpers.callConstructor(PendingIntent.class);
+
+    long messageId = -231543663L;
+    smsManager.downloadMultimediaMessage(
+        null, mmsLocationUrl, mmsContentUri, configOverrides, downloadedIntent, messageId);
+    DownloadMultimediaMessageParams params =
+        shadowOf(smsManager).getLastDownloadedMultimediaMessageParams();
+
+    assertThat(params.getMessageId()).isEqualTo(messageId);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  @Config(minSdk = LOLLIPOP)
+  public void downloadMultimediaMessage_shouldThrowExceptionWithEmptyLocationUrl() {
+    smsManager.downloadMultimediaMessage(
+        null,
+        null,
+        mmsContentUri,
+        new Bundle(),
+        ReflectionHelpers.callConstructor(PendingIntent.class));
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  @Config(minSdk = LOLLIPOP)
+  public void downloadMultimediaMessage_shouldThrowExceptionWithEmptyContentUri() {
+    smsManager.downloadMultimediaMessage(
+        null,
+        mmsLocationUrl,
+        null,
+        new Bundle(),
+        ReflectionHelpers.callConstructor(PendingIntent.class));
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void clearLastDownloadedMultimediaMessageParams_shouldClearParameters() {
+    smsManager.downloadMultimediaMessage(
+        null,
+        mmsLocationUrl,
+        mmsContentUri,
+        new Bundle(),
+        ReflectionHelpers.callConstructor(PendingIntent.class));
+    assertThat(shadowOf(smsManager).getLastDownloadedMultimediaMessageParams()).isNotNull();
+
+    shadowOf(smsManager).clearLastDownloadedMultimediaMessageParams();
+    assertThat(shadowOf(smsManager).getLastDownloadedMultimediaMessageParams()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void sendTextMessage_withMessageId_shouldStoreLastSentTextParameters() {
+    smsManager.sendTextMessage(destAddress, scAddress, "Body Text", null, null, 1231L);
+    TextSmsParams params = shadowOf(smsManager).getLastSentTextMessageParams();
+
+    assertThat(params.getDestinationAddress()).isEqualTo(destAddress);
+    assertThat(params.getScAddress()).isEqualTo(scAddress);
+    assertThat(params.getText()).isEqualTo("Body Text");
+    assertThat(params.getSentIntent()).isNull();
+    assertThat(params.getMessageId()).isEqualTo(1231L);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void sendMultipartMessage_withMessageId_shouldStoreLastSendMultimediaParameters() {
+    smsManager.sendMultipartTextMessage(
+        destAddress, scAddress, Lists.newArrayList("Foo", "Bar", "Baz"), null, null, 12312L);
+    TextMultipartParams params = shadowOf(smsManager).getLastSentMultipartTextMessageParams();
+
+    assertThat(params.getDestinationAddress()).isEqualTo(destAddress);
+    assertThat(params.getScAddress()).isEqualTo(scAddress);
+    assertThat(params.getParts()).containsExactly("Foo", "Bar", "Baz");
+    assertThat(params.getSentIntents()).isNull();
+    assertThat(params.getDeliveryIntents()).isNull();
+    assertThat(params.getMessageId()).isEqualTo(12312L);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void shouldGiveSmscAddress() {
+    shadowOf(smsManager).setSmscAddress("123-244-2222");
+    assertThat(smsManager.getSmscAddress()).isEqualTo("123-244-2222");
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getSmscAddress_shouldThrowSecurityExceptionWhenReadPhoneStatePermissionNotGranted()
+      throws Exception {
+    shadowOf(smsManager).setSmscAddressPermission(false);
+    assertThrows(SecurityException.class, () -> smsManager.getSmscAddress());
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void shouldGiveDefaultSmsSubscriptionId() {
+    ShadowSmsManager.setDefaultSmsSubscriptionId(3);
+    assertThat(ShadowSmsManager.getDefaultSmsSubscriptionId()).isEqualTo(3);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSoftKeyboardControllerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSoftKeyboardControllerTest.java
new file mode 100644
index 0000000..7c0a994
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSoftKeyboardControllerTest.java
@@ -0,0 +1,114 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityService.SoftKeyboardController;
+import android.os.Looper;
+import android.view.accessibility.AccessibilityEvent;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/** Test for ShadowSoftKeyboardController. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = N)
+public final class ShadowSoftKeyboardControllerTest {
+
+  private MyService myService;
+  private SoftKeyboardController softKeyboardController;
+
+  @Before
+  public void setUp() {
+    myService = Robolectric.setupService(MyService.class);
+    softKeyboardController = myService.getSoftKeyboardController();
+  }
+
+  @Test
+  public void getShowMode_default_returnsAuto() {
+    int showMode = softKeyboardController.getShowMode();
+
+    assertThat(showMode).isEqualTo(AccessibilityService.SHOW_MODE_AUTO);
+  }
+
+  @Test
+  public void setShowMode_updatesShowMode() {
+    int newMode = AccessibilityService.SHOW_MODE_HIDDEN;
+
+    softKeyboardController.setShowMode(newMode);
+
+    assertThat(softKeyboardController.getShowMode()).isEqualTo(newMode);
+  }
+
+  @Test
+  public void addOnShowModeChangedListener_registersListener() {
+    int newMode = AccessibilityService.SHOW_MODE_HIDDEN;
+    TestOnShowModeChangedListener listener = new TestOnShowModeChangedListener();
+
+    softKeyboardController.addOnShowModeChangedListener(listener);
+
+    softKeyboardController.setShowMode(newMode);
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(listener.invoked).isTrue();
+    assertThat(listener.showMode).isEqualTo(newMode);
+  }
+
+  @Test
+  public void removeOnShowModeChangedListener_unregistersListener() {
+    TestOnShowModeChangedListener listener = new TestOnShowModeChangedListener();
+    softKeyboardController.addOnShowModeChangedListener(listener);
+
+    softKeyboardController.removeOnShowModeChangedListener(listener);
+
+    softKeyboardController.setShowMode(AccessibilityService.SHOW_MODE_HIDDEN);
+    shadowOf(Looper.getMainLooper()).idle();
+    assertThat(listener.invoked).isFalse();
+  }
+
+  @Test
+  public void removeOnShowModeChangedListener_listenerNotRegistered_returnsFalse() {
+    TestOnShowModeChangedListener listener = new TestOnShowModeChangedListener();
+
+    assertThat(softKeyboardController.removeOnShowModeChangedListener(listener)).isFalse();
+  }
+
+  @Test
+  public void removeOnShowModeChangedListener_listenerRegistered_returnsTrue() {
+    TestOnShowModeChangedListener listener = new TestOnShowModeChangedListener();
+    softKeyboardController.addOnShowModeChangedListener(listener);
+
+    assertThat(softKeyboardController.removeOnShowModeChangedListener(listener)).isTrue();
+  }
+
+  /** Test listener that records when it is invoked. */
+  private static class TestOnShowModeChangedListener
+      implements SoftKeyboardController.OnShowModeChangedListener {
+    private boolean invoked = false;
+    private int showMode = -1;
+
+    @Override
+    public void onShowModeChanged(SoftKeyboardController controller, int showMode) {
+      this.invoked = true;
+      this.showMode = showMode;
+    }
+  }
+
+  /** Empty implementation of AccessibilityService, for test purposes. */
+  private static class MyService extends AccessibilityService {
+
+    @Override
+    public void onAccessibilityEvent(AccessibilityEvent arg0) {
+      // Do nothing
+    }
+
+    @Override
+    public void onInterrupt() {
+      // Do nothing
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSoundPoolTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSoundPoolTest.java
new file mode 100644
index 0000000..184fadf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSoundPoolTest.java
@@ -0,0 +1,184 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.media.AudioManager;
+import android.media.SoundPool;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowSoundPool.Playback;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSoundPoolTest {
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldCreateSoundPool_Lollipop() {
+    SoundPool soundPool = new SoundPool.Builder().build();
+    assertThat(soundPool).isNotNull();
+
+    SoundPool.OnLoadCompleteListener listener = mock(SoundPool.OnLoadCompleteListener.class);
+    soundPool.setOnLoadCompleteListener(listener);
+  }
+
+  @Test
+  @Config(maxSdk = JELLY_BEAN_MR2)
+  public void shouldCreateSoundPool_JellyBean() {
+    SoundPool soundPool = new SoundPool(1, AudioManager.STREAM_MUSIC, 0);
+    assertThat(soundPool).isNotNull();
+  }
+
+  @Test
+  public void playedSoundsFromResourcesAreRecorded() {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load(ApplicationProvider.getApplicationContext(), R.raw.sound, 1);
+    soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1);
+
+    assertThat(shadowOf(soundPool).wasResourcePlayed(R.raw.sound)).isTrue();
+  }
+
+  @Test
+  public void playedSoundsFromResourcesAreCollected() {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load(ApplicationProvider.getApplicationContext(), R.raw.sound, 1);
+    soundPool.play(soundId, 1.0f, 0f, 0, 0, 0.5f);
+    soundPool.play(soundId, 0f, 1.0f, 1, 0, 2.0f);
+
+    assertThat(shadowOf(soundPool).getResourcePlaybacks(R.raw.sound))
+        .containsExactly(
+            new Playback(soundId, 1.0f, 0f, 0, 0, 0.5f),
+            new Playback(soundId, 0f, 1.0f, 1, 0, 2.0f))
+        .inOrder();
+  }
+
+  @Test
+  public void playedSoundsFromPathAreRecorded() {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load("/mnt/sdcard/sound.wav", 1);
+    soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1);
+
+    assertThat(shadowOf(soundPool).wasPathPlayed("/mnt/sdcard/sound.wav")).isTrue();
+  }
+
+  @Test
+  public void playedSoundsFromPathAreCollected() {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load("/mnt/sdcard/sound.wav", 1);
+    soundPool.play(soundId, 0f, 1.0f, 1, 0, 2.0f);
+    soundPool.play(soundId, 1.0f, 0f, 0, 0, 0.5f);
+
+    assertThat(shadowOf(soundPool).getPathPlaybacks("/mnt/sdcard/sound.wav"))
+        .containsExactly(
+            new Playback(soundId, 0f, 1.0f, 1, 0, 2.0f),
+            new Playback(soundId, 1.0f, 0f, 0, 0, 0.5f))
+        .inOrder();
+  }
+
+  @Test
+  public void notifyPathLoaded_notifiesListener() {
+    SoundPool soundPool = createSoundPool();
+    SoundPool.OnLoadCompleteListener listener = mock(SoundPool.OnLoadCompleteListener.class);
+    soundPool.setOnLoadCompleteListener(listener);
+
+    int soundId = soundPool.load("/mnt/sdcard/sound.wav", 1);
+    shadowOf(soundPool).notifyPathLoaded("/mnt/sdcard/sound.wav", true);
+
+    verify(listener).onLoadComplete(soundPool, soundId, 0);
+  }
+
+  @Test
+  public void notifyResourceLoaded_notifiesListener() {
+    SoundPool soundPool = createSoundPool();
+    SoundPool.OnLoadCompleteListener listener = mock(SoundPool.OnLoadCompleteListener.class);
+    soundPool.setOnLoadCompleteListener(listener);
+
+    int soundId = soundPool.load(ApplicationProvider.getApplicationContext(), R.raw.sound, 1);
+    shadowOf(soundPool).notifyResourceLoaded(R.raw.sound, true);
+
+    verify(listener).onLoadComplete(soundPool, soundId, 0);
+  }
+
+  @Test
+  public void notifyPathLoaded_notifiesFailure() {
+    SoundPool soundPool = createSoundPool();
+    SoundPool.OnLoadCompleteListener listener = mock(SoundPool.OnLoadCompleteListener.class);
+    soundPool.setOnLoadCompleteListener(listener);
+
+    int soundId = soundPool.load("/mnt/sdcard/sound.wav", 1);
+    shadowOf(soundPool).notifyPathLoaded("/mnt/sdcard/sound.wav", false);
+
+    verify(listener).onLoadComplete(soundPool, soundId, 1);
+  }
+
+  @Test
+  public void notifyResourceLoaded_doNotFailWithoutListener() {
+    SoundPool soundPool = createSoundPool();
+
+    soundPool.load("/mnt/sdcard/sound.wav", 1);
+    shadowOf(soundPool).notifyPathLoaded("/mnt/sdcard/sound.wav", false);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void notifyPathLoaded_failIfLoadWasntCalled() {
+    SoundPool soundPool = createSoundPool();
+
+    shadowOf(soundPool).notifyPathLoaded("no.mp3", true);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void notifyResourceLoaded_failIfLoadWasntCalled() {
+    SoundPool soundPool = createSoundPool();
+
+    shadowOf(soundPool).notifyResourceLoaded(123, true);
+  }
+
+  @Test
+  public void playedSoundsAreCleared() {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load(ApplicationProvider.getApplicationContext(), R.raw.sound, 1);
+    soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1);
+
+    assertThat(shadowOf(soundPool).wasResourcePlayed(R.raw.sound)).isTrue();
+    shadowOf(soundPool).clearPlayed();
+    assertThat(shadowOf(soundPool).wasResourcePlayed(R.raw.sound)).isFalse();
+  }
+
+  @Test
+  public void loadSoundWithResId_positiveId () {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load(ApplicationProvider.getApplicationContext(), R.raw.sound, 1);
+
+    assertThat(soundId).isGreaterThan(0);
+  }
+
+  @Test
+  public void loadSoundWithPath_positiveId () {
+    SoundPool soundPool = createSoundPool();
+
+    int soundId = soundPool.load("/mnt/sdcard/sound.wav", 1);
+
+    assertThat(soundId).isGreaterThan(0);
+  }
+
+  private SoundPool createSoundPool() {
+    return RuntimeEnvironment.getApiLevel() >= LOLLIPOP
+        ? new SoundPool.Builder().build()
+        : new SoundPool(1, AudioManager.STREAM_MUSIC, 0);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpannableStringTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpannableStringTest.java
new file mode 100644
index 0000000..82148f8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpannableStringTest.java
@@ -0,0 +1,100 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.text.SpannableString;
+import android.text.style.URLSpan;
+import android.text.style.UnderlineSpan;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSpannableStringTest {
+  private static final String TEST_STRING = "Visit us at http://www.foobar.com for more selections";
+
+  private SpannableString spanStr;
+
+  @Before
+  public void setUp() throws Exception {
+    spanStr = new SpannableString(TEST_STRING);
+  }
+
+  @Test
+  public void testToString() {
+    assertThat(spanStr.toString()).isSameInstanceAs(TEST_STRING);
+  }
+
+  @Test
+  public void testSetSpan() {
+    URLSpan s1 = new URLSpan("http://www.foobar.com");
+    UnderlineSpan s2 = new UnderlineSpan();
+    spanStr.setSpan(s1, 12, 33, 0);
+    spanStr.setSpan(s2, 1, 10, 0);
+
+    assertBothSpans(s1, s2);
+  }
+
+  @Test
+  public void testRemoveSpan() {
+    URLSpan s1 = new URLSpan("http://www.foobar.com");
+    UnderlineSpan s2 = new UnderlineSpan();
+    spanStr.setSpan(s1, 12, 33, 0);
+    spanStr.setSpan(s2, 1, 10, 0);
+    spanStr.removeSpan(s1);
+
+    Object[] spans = spanStr.getSpans(0, TEST_STRING.length(), Object.class);
+    assertThat(spans).isNotNull();
+    assertThat(spans.length).isEqualTo(1);
+    assertThat(spans[0]).isSameInstanceAs(s2);
+  }
+
+  @Test
+  public void testGetSpans() {
+    URLSpan s1 = new URLSpan("http://www.foobar.com");
+    UnderlineSpan s2 = new UnderlineSpan();
+    spanStr.setSpan(s1, 1, 10, 0);
+    spanStr.setSpan(s2, 20, 30, 0);
+
+    Object[] spans = spanStr.getSpans(0, TEST_STRING.length(), Object.class);
+    assertThat(spans).isNotNull();
+    assertThat(spans.length).isEqualTo(2);
+    assertBothSpans(s1, s2);
+
+    spans = spanStr.getSpans(0, TEST_STRING.length(), URLSpan.class);
+    assertThat(spans).isNotNull();
+    assertThat(spans.length).isEqualTo(1);
+    assertThat(spans[0]).isSameInstanceAs(s1);
+
+    spans = spanStr.getSpans(11, 35, Object.class);
+    assertThat(spans).isNotNull();
+    assertThat(spans.length).isEqualTo(1);
+    assertThat(spans[0]).isSameInstanceAs(s2);
+
+    spans = spanStr.getSpans(21, 35, Object.class);
+    assertThat(spans).isNotNull();
+    assertThat(spans.length).isEqualTo(1);
+    assertThat(spans[0]).isSameInstanceAs(s2);
+
+    spans = spanStr.getSpans(5, 15, Object.class);
+    assertThat(spans).isNotNull();
+    assertThat(spans.length).isEqualTo(1);
+    assertThat(spans[0]).isSameInstanceAs(s1);
+  }
+
+  private void assertBothSpans(URLSpan s1, UnderlineSpan s2) {
+    Object[] spans = spanStr.getSpans(0, TEST_STRING.length(), Object.class);
+    if (spans[0] instanceof URLSpan) {
+      assertThat(spans[0]).isSameInstanceAs(s1);
+    } else {
+      assertThat(spans[0]).isSameInstanceAs(s2);
+    }
+    if (spans[1] instanceof UnderlineSpan) {
+      assertThat(spans[1]).isSameInstanceAs(s2);
+    } else {
+      assertThat(spans[1]).isSameInstanceAs(s1);
+    }
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpannedStringTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpannedStringTest.java
new file mode 100644
index 0000000..37f8fe4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpannedStringTest.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import android.text.SpannedString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSpannedStringTest {
+
+  @Test
+  public void toString_shouldDelegateToUnderlyingCharSequence() {
+    SpannedString spannedString = new SpannedString("foo");
+    assertEquals("foo", spannedString.toString());
+  }
+
+  @Test
+  public void valueOfSpannedString_shouldReturnItself() {
+    SpannedString spannedString = new SpannedString("foo");
+    assertSame(spannedString, SpannedString.valueOf(spannedString));
+  }
+
+  @Test
+  public void valueOfCharSequence_shouldReturnNewSpannedString() {
+    assertEquals("foo", SpannedString.valueOf("foo").toString());
+  }
+
+
+}
+
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java
new file mode 100644
index 0000000..062e73a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java
@@ -0,0 +1,295 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.speech.RecognitionListener;
+import android.speech.RecognitionSupport;
+import android.speech.RecognitionSupportCallback;
+import android.speech.RecognizerIntent;
+import android.speech.SpeechRecognizer;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import java.util.ArrayList;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.util.concurrent.PausedExecutorService;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowLog.LogItem;
+
+/** Unit tests for {@link ShadowSpeechRecognizer}. */
+@RunWith(RobolectricTestRunner.class)
+public class ShadowSpeechRecognizerTest {
+  private SpeechRecognizer speechRecognizer;
+  private TestRecognitionListener listener;
+  private Context applicationContext;
+  private TestRecognitionSupportCallback supportCallback;
+
+  @Before
+  public void setUp() {
+    applicationContext = ApplicationProvider.getApplicationContext();
+    speechRecognizer = SpeechRecognizer.createSpeechRecognizer(applicationContext);
+    listener = new TestRecognitionListener();
+    supportCallback = new TestRecognitionSupportCallback();
+  }
+
+  @Test
+  public void onErrorCalled() {
+    startListening();
+
+    shadowOf(speechRecognizer).triggerOnError(-1);
+
+    assertThat(listener.errorReceived).isEqualTo(-1);
+  }
+
+  @Test
+  public void onReadyForSpeechCalled() {
+    startListening();
+    Bundle expectedBundle = new Bundle();
+    ArrayList<String> results = new ArrayList<>();
+    String result = "onReadyForSpeech";
+    results.add(result);
+    expectedBundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results);
+
+    shadowOf(speechRecognizer).triggerOnReadyForSpeech(expectedBundle);
+
+    assertThat(listener.bundleReceived).isEqualTo(expectedBundle);
+  }
+
+  @Test
+  public void onPartialResultsCalled() {
+    startListening();
+    Bundle expectedBundle = new Bundle();
+    ArrayList<String> results = new ArrayList<>();
+    String result = "onPartialResult";
+    results.add(result);
+    expectedBundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results);
+
+    shadowOf(speechRecognizer).triggerOnPartialResults(expectedBundle);
+
+    assertThat(listener.bundleReceived).isEqualTo(expectedBundle);
+  }
+
+  @Test
+  public void onEndOfSpeechCalled() {
+    startListening();
+
+    shadowOf(speechRecognizer).triggerOnEndOfSpeech();
+
+    assertThat(listener.endofSpeechCalled).isTrue();
+  }
+
+  @Test
+  public void onResultCalled() {
+    startListening();
+    Bundle expectedBundle = new Bundle();
+    ArrayList<String> results = new ArrayList<>();
+    String result = "onResult";
+    results.add(result);
+    expectedBundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results);
+
+    shadowOf(speechRecognizer).triggerOnResults(expectedBundle);
+    shadowOf(getMainLooper()).idle();
+
+    assertThat(listener.bundleReceived).isEqualTo(expectedBundle);
+  }
+
+  @Test
+  public void onRmsChangedCalled() {
+    startListening();
+
+    shadowOf(speechRecognizer).triggerOnRmsChanged(1.0f);
+
+    assertThat(listener.rmsDbReceived).isEqualTo(1.0f);
+  }
+
+  @Test
+  public void startAndStopListening() {
+    startListening();
+    shadowOf(speechRecognizer).triggerOnResults(new Bundle());
+    speechRecognizer.stopListening();
+
+    assertNoErrorLogs();
+  }
+
+  /** Verify the startlistening flow works when using custom component name. */
+  @Test
+  public void startListeningWithCustomComponent() {
+    speechRecognizer =
+        SpeechRecognizer.createSpeechRecognizer(
+            ApplicationProvider.getApplicationContext(),
+            new ComponentName("org.robolectrc", "FakeComponent"));
+    speechRecognizer.setRecognitionListener(listener);
+    speechRecognizer.startListening(new Intent());
+    shadowOf(getMainLooper()).idle();
+    shadowOf(speechRecognizer).triggerOnResults(new Bundle());
+    assertThat(listener.bundleReceived).isNotNull();
+
+    assertNoErrorLogs();
+  }
+
+  @Test
+  public void getLatestSpeechRecognizer() {
+    SpeechRecognizer newSpeechRecognizer =
+        SpeechRecognizer.createSpeechRecognizer(ApplicationProvider.getApplicationContext());
+    newSpeechRecognizer.setRecognitionListener(listener);
+    shadowOf(getMainLooper()).idle();
+    assertThat(ShadowSpeechRecognizer.getLatestSpeechRecognizer())
+        .isSameInstanceAs(newSpeechRecognizer);
+  }
+
+  @Test
+  public void getLastRecognizerIntent() {
+    Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
+    intent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, "com.android.test.package");
+    intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US");
+    SpeechRecognizer newSpeechRecognizer =
+        SpeechRecognizer.createSpeechRecognizer(ApplicationProvider.getApplicationContext());
+    newSpeechRecognizer.setRecognitionListener(listener);
+    newSpeechRecognizer.startListening(intent);
+    shadowOf(getMainLooper()).idle();
+    assertThat(shadowOf(newSpeechRecognizer).getLastRecognizerIntent()).isEqualTo(intent);
+  }
+
+  private void startListening() {
+    speechRecognizer.setRecognitionListener(listener);
+    speechRecognizer.startListening(new Intent());
+    shadowOf(getMainLooper()).idle();
+  }
+
+  private static void assertNoErrorLogs() {
+    for (LogItem item : ShadowLog.getLogsForTag("SpeechRecognizer")) {
+      if (item.type >= Log.ERROR) {
+        fail("Found unexpected error log: " + item.msg);
+      }
+    }
+  }
+
+  static final class TestRecognitionListener implements RecognitionListener {
+
+    int errorReceived;
+    Bundle bundleReceived;
+    float rmsDbReceived;
+    boolean endofSpeechCalled = false;
+
+    @Override
+    public void onBeginningOfSpeech() {}
+
+    @Override
+    public void onBufferReceived(byte[] buffer) {}
+
+    @Override
+    public void onEndOfSpeech() {
+      endofSpeechCalled = true;
+    }
+
+    @Override
+    public void onError(int error) {
+      errorReceived = error;
+    }
+
+    @Override
+    public void onEvent(int eventType, Bundle params) {}
+
+    @Override
+    public void onPartialResults(Bundle bundle) {
+      bundleReceived = bundle;
+    }
+
+    @Override
+    public void onReadyForSpeech(Bundle bundle) {
+      bundleReceived = bundle;
+    }
+
+    @Override
+    public void onResults(Bundle bundle) {
+      bundleReceived = bundle;
+    }
+
+    @Override
+    public void onRmsChanged(float rmsdB) {
+      rmsDbReceived = rmsdB;
+    }
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void onCreateOnDeviceRecognizer_setsLatestSpeechRecognizer() {
+    speechRecognizer = SpeechRecognizer.createOnDeviceSpeechRecognizer(applicationContext);
+
+    assertThat(speechRecognizer)
+        .isSameInstanceAs(ShadowSpeechRecognizer.getLatestSpeechRecognizer());
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setIsOnDeviceRecognitionAvailable_setsAvailability() {
+    ShadowSpeechRecognizer.setIsOnDeviceRecognitionAvailable(false);
+    assertThat(SpeechRecognizer.isOnDeviceRecognitionAvailable(applicationContext)).isFalse();
+
+    ShadowSpeechRecognizer.setIsOnDeviceRecognitionAvailable(true);
+    assertThat(SpeechRecognizer.isOnDeviceRecognitionAvailable(applicationContext)).isTrue();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void onSupportResultCalled() {
+    PausedExecutorService executor = new PausedExecutorService();
+    RecognitionSupport recognitionSupport =
+        new RecognitionSupport.Builder().addInstalledOnDeviceLanguage("en-US").build();
+    speechRecognizer.checkRecognitionSupport(new Intent(), executor, supportCallback);
+
+    ((ShadowSpeechRecognizer) shadowOf(speechRecognizer)).triggerSupportResult(recognitionSupport);
+    executor.runAll();
+
+    assertThat(supportCallback.recognitionSupportReceived).isEqualTo(recognitionSupport);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void onSupportErrorCalled() {
+    PausedExecutorService executor = new PausedExecutorService();
+    TestRecognitionSupportCallback supportCallback = new TestRecognitionSupportCallback();
+    speechRecognizer.checkRecognitionSupport(new Intent(), executor, supportCallback);
+
+    ((ShadowSpeechRecognizer) shadowOf(speechRecognizer)).triggerSupportError(1);
+    executor.runAll();
+
+    assertThat(supportCallback.errorReceived).isEqualTo(1);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void triggerModelDownload_setsLatestModelDownloadIntent() {
+    Intent modelDownloadIntent = new Intent();
+    speechRecognizer.triggerModelDownload(modelDownloadIntent);
+
+    assertThat(((ShadowSpeechRecognizer) shadowOf(speechRecognizer)).getLatestModelDownloadIntent())
+        .isSameInstanceAs(modelDownloadIntent);
+  }
+
+  static final class TestRecognitionSupportCallback implements RecognitionSupportCallback {
+
+    int errorReceived;
+    RecognitionSupport recognitionSupportReceived;
+
+    @Override
+    public void onSupportResult(RecognitionSupport recognitionSupport) {
+      recognitionSupportReceived = recognitionSupport;
+    }
+
+    @Override
+    public void onError(int error) {
+      errorReceived = error;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSslErrorHandlerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSslErrorHandlerTest.java
new file mode 100644
index 0000000..cdf5bd5
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSslErrorHandlerTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.webkit.SslErrorHandler;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSslErrorHandlerTest {
+
+  private SslErrorHandler handler;
+
+  @Before
+  public void setUp() throws Exception {
+    handler = Shadow.newInstanceOf(SslErrorHandler.class);
+  }
+
+  @Test
+  public void shouldRecordCancel() {
+    assertThat(Shadows.shadowOf(handler).wasCancelCalled()).isFalse();
+    handler.cancel();
+    assertThat(Shadows.shadowOf(handler).wasCancelCalled()).isTrue();
+  }
+
+  @Test
+  public void shouldRecordProceed() {
+    assertThat(Shadows.shadowOf(handler).wasProceedCalled()).isFalse();
+    handler.proceed();
+    assertThat(Shadows.shadowOf(handler).wasProceedCalled()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStatFsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStatFsTest.java
new file mode 100644
index 0000000..6ae22e0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStatFsTest.java
@@ -0,0 +1,220 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.StatFs;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowStatFsTest {
+  @Test
+  public void shouldRegisterStats() {
+    ShadowStatFs.registerStats("/tmp", 100, 20, 10);
+    StatFs statsFs = new StatFs("/tmp");
+
+    assertThat(statsFs.getBlockCount()).isEqualTo(100);
+    assertThat(statsFs.getFreeBlocks()).isEqualTo(20);
+    assertThat(statsFs.getAvailableBlocks()).isEqualTo(10);
+    assertThat(statsFs.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  public void shouldRegisterStatsWithFile() {
+    ShadowStatFs.registerStats(new File("/tmp"), 100, 20, 10);
+    StatFs statsFs = new StatFs(new File("/tmp").getAbsolutePath());
+
+    assertThat(statsFs.getBlockCount()).isEqualTo(100);
+    assertThat(statsFs.getFreeBlocks()).isEqualTo(20);
+    assertThat(statsFs.getAvailableBlocks()).isEqualTo(10);
+    assertThat(statsFs.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  public void shouldUseBestMatch() {
+    ShadowStatFs.registerStats("/tmp", 101, 21, 11);
+    ShadowStatFs.registerStats("/tmp/a", 102, 22, 12);
+    StatFs statsFsForTmp = new StatFs("/tmp");
+    StatFs statsFsForA = new StatFs("/tmp/a");
+    StatFs statsFsForB = new StatFs("/tmp/b");
+    StatFs statsFsForAC = new StatFs("/tmp/a/c");
+
+    assertThat(statsFsForTmp.getBlockCount()).isEqualTo(101);
+    assertThat(statsFsForTmp.getFreeBlocks()).isEqualTo(21);
+    assertThat(statsFsForTmp.getAvailableBlocks()).isEqualTo(11);
+    assertThat(statsFsForTmp.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    assertThat(statsFsForA.getBlockCount()).isEqualTo(102);
+    assertThat(statsFsForA.getFreeBlocks()).isEqualTo(22);
+    assertThat(statsFsForA.getAvailableBlocks()).isEqualTo(12);
+    assertThat(statsFsForA.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    assertThat(statsFsForB.getBlockCount()).isEqualTo(101);
+    assertThat(statsFsForB.getFreeBlocks()).isEqualTo(21);
+    assertThat(statsFsForB.getAvailableBlocks()).isEqualTo(11);
+    assertThat(statsFsForB.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    assertThat(statsFsForAC.getBlockCount()).isEqualTo(102);
+    assertThat(statsFsForAC.getFreeBlocks()).isEqualTo(22);
+    assertThat(statsFsForAC.getAvailableBlocks()).isEqualTo(12);
+    assertThat(statsFsForAC.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    StatFs statsFsForSlash = new StatFs("/");
+    assertThat(statsFsForSlash.getFreeBlocks()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldResetStateBetweenTests() {
+    StatFs statsFs = new StatFs("/tmp");
+    assertThat(statsFs.getBlockCount()).isEqualTo(0);
+    assertThat(statsFs.getFreeBlocks()).isEqualTo(0);
+    assertThat(statsFs.getAvailableBlocks()).isEqualTo(0);
+    assertThat(statsFs.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void withApi18_shouldRegisterStats() {
+    ShadowStatFs.registerStats("/tmp", 100, 20, 10);
+    StatFs statsFs = new StatFs("/tmp");
+
+    assertThat(statsFs.getBlockCountLong()).isEqualTo(100L);
+    assertThat(statsFs.getFreeBlocksLong()).isEqualTo(20L);
+    assertThat(statsFs.getFreeBytes()).isEqualTo(20L * ShadowStatFs.BLOCK_SIZE);
+    assertThat(statsFs.getAvailableBlocksLong()).isEqualTo(10L);
+    assertThat(statsFs.getAvailableBytes()).isEqualTo(10L * ShadowStatFs.BLOCK_SIZE);
+    assertThat(statsFs.getBlockSizeLong()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void withApi18_shouldRegisterStatsWithFile() {
+    ShadowStatFs.registerStats(new File("/tmp"), 100, 20, 10);
+    StatFs statsFs = new StatFs(new File("/tmp").getAbsolutePath());
+
+    assertThat(statsFs.getBlockCountLong()).isEqualTo(100L);
+    assertThat(statsFs.getFreeBlocksLong()).isEqualTo(20L);
+    assertThat(statsFs.getFreeBytes()).isEqualTo(20L * ShadowStatFs.BLOCK_SIZE);
+    assertThat(statsFs.getAvailableBlocksLong()).isEqualTo(10L);
+    assertThat(statsFs.getAvailableBytes()).isEqualTo(10L * ShadowStatFs.BLOCK_SIZE);
+    assertThat(statsFs.getBlockSizeLong()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void withApi18_shouldResetStateBetweenTests() {
+    StatFs statsFs = new StatFs("/tmp");
+    assertThat(statsFs.getBlockCountLong()).isEqualTo(0L);
+    assertThat(statsFs.getFreeBlocksLong()).isEqualTo(0L);
+    assertThat(statsFs.getFreeBytes()).isEqualTo(0L);
+    assertThat(statsFs.getAvailableBlocksLong()).isEqualTo(0L);
+    assertThat(statsFs.getAvailableBytes()).isEqualTo(0L);
+    assertThat(statsFs.getBlockSizeLong()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  public void shouldRestat() {
+    ShadowStatFs.registerStats("/tmp", 100, 20, 10);
+    StatFs statsFs = new StatFs("/tmp");
+
+    assertThat(statsFs.getBlockCount()).isEqualTo(100);
+    assertThat(statsFs.getFreeBlocks()).isEqualTo(20);
+    assertThat(statsFs.getAvailableBlocks()).isEqualTo(10);
+
+    ShadowStatFs.registerStats("/tmp", 3, 2, 1);
+
+    statsFs.restat("/tmp");
+    assertThat(statsFs.getBlockCount()).isEqualTo(3);
+    assertThat(statsFs.getFreeBlocks()).isEqualTo(2);
+    assertThat(statsFs.getAvailableBlocks()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void withApi18_shouldRestat() {
+    ShadowStatFs.registerStats("/tmp", 100, 20, 10);
+    StatFs statsFs = new StatFs("/tmp");
+
+    assertThat(statsFs.getBlockCountLong()).isEqualTo(100L);
+    assertThat(statsFs.getFreeBlocksLong()).isEqualTo(20L);
+    assertThat(statsFs.getFreeBytes()).isEqualTo(20L * ShadowStatFs.BLOCK_SIZE);
+    assertThat(statsFs.getAvailableBlocksLong()).isEqualTo(10L);
+    assertThat(statsFs.getAvailableBytes()).isEqualTo(10L * ShadowStatFs.BLOCK_SIZE);
+
+    ShadowStatFs.registerStats("/tmp", 3, 2, 1);
+
+    statsFs.restat("/tmp");
+    assertThat(statsFs.getBlockCountLong()).isEqualTo(3L);
+    assertThat(statsFs.getFreeBlocksLong()).isEqualTo(2L);
+    assertThat(statsFs.getFreeBytes()).isEqualTo(2L * ShadowStatFs.BLOCK_SIZE);
+    assertThat(statsFs.getAvailableBlocksLong()).isEqualTo(1L);
+    assertThat(statsFs.getAvailableBytes()).isEqualTo(1L * ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  public void shouldUnregisterStats() {
+    ShadowStatFs.registerStats("/a", 100, 20, 10);
+    ShadowStatFs.registerStats("/b", 200, 40, 20);
+
+    ShadowStatFs.unregisterStats("/a");
+
+    StatFs statsFsForA = new StatFs("/a");
+    StatFs statsFsForB = new StatFs("/b");
+
+    assertThat(statsFsForA.getBlockCount()).isEqualTo(0);
+    assertThat(statsFsForA.getFreeBlocks()).isEqualTo(0);
+    assertThat(statsFsForA.getAvailableBlocks()).isEqualTo(0);
+    assertThat(statsFsForA.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    assertThat(statsFsForB.getBlockCount()).isEqualTo(200);
+    assertThat(statsFsForB.getFreeBlocks()).isEqualTo(40);
+    assertThat(statsFsForB.getAvailableBlocks()).isEqualTo(20);
+    assertThat(statsFsForB.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  public void shouldUnregisterStatsWithFile() {
+    ShadowStatFs.registerStats(new File("/a"), 100, 20, 10);
+    ShadowStatFs.registerStats(new File("/b"), 200, 40, 20);
+
+    ShadowStatFs.unregisterStats(new File("/a"));
+
+    StatFs statsFsForA = new StatFs("/a");
+    StatFs statsFsForB = new StatFs("/b");
+
+    assertThat(statsFsForA.getBlockCount()).isEqualTo(0);
+    assertThat(statsFsForA.getFreeBlocks()).isEqualTo(0);
+    assertThat(statsFsForA.getAvailableBlocks()).isEqualTo(0);
+    assertThat(statsFsForA.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    assertThat(statsFsForB.getBlockCount()).isEqualTo(200);
+    assertThat(statsFsForB.getFreeBlocks()).isEqualTo(40);
+    assertThat(statsFsForB.getAvailableBlocks()).isEqualTo(20);
+    assertThat(statsFsForB.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+
+  @Test
+  public void shouldReset() {
+    ShadowStatFs.registerStats("/a", 100, 20, 10);
+    ShadowStatFs.registerStats("/b", 200, 40, 20);
+
+    ShadowStatFs.reset();
+
+    StatFs statsFsForA = new StatFs("/a");
+    StatFs statsFsForB = new StatFs("/b");
+
+    assertThat(statsFsForA.getBlockCount()).isEqualTo(0);
+    assertThat(statsFsForA.getFreeBlocks()).isEqualTo(0);
+    assertThat(statsFsForA.getAvailableBlocks()).isEqualTo(0);
+    assertThat(statsFsForA.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+
+    assertThat(statsFsForB.getBlockCount()).isEqualTo(0);
+    assertThat(statsFsForB.getFreeBlocks()).isEqualTo(0);
+    assertThat(statsFsForB.getAvailableBlocks()).isEqualTo(0);
+    assertThat(statsFsForB.getBlockSize()).isEqualTo(ShadowStatFs.BLOCK_SIZE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStateListDrawableTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStateListDrawableTest.java
new file mode 100644
index 0000000..59272b9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStateListDrawableTest.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.R;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.util.StateSet;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowStateListDrawableTest {
+
+  @Test
+  public void testAddStateWithDrawable() {
+    Drawable drawable =
+        RuntimeEnvironment.getApplication()
+            .getResources()
+            .getDrawable(android.R.drawable.ic_delete);
+    StateListDrawable stateListDrawable = new StateListDrawable();
+    int[] states = {R.attr.state_pressed};
+    stateListDrawable.addState(states, drawable);
+
+    ShadowStateListDrawable shadow = shadowOf(stateListDrawable);
+    Drawable drawableForState = shadow.getDrawableForState(states);
+
+    assertNotNull(drawableForState);
+    assertThat(((ShadowBitmapDrawable) shadowOf(drawableForState)).getCreatedFromResId())
+        .isEqualTo(android.R.drawable.ic_delete);
+  }
+
+  @Test
+  public void testAddDrawableWithWildCardState() {
+    Drawable drawable =
+        RuntimeEnvironment.getApplication()
+            .getResources()
+            .getDrawable(android.R.drawable.ic_delete);
+
+    StateListDrawable stateListDrawable = new StateListDrawable();
+    stateListDrawable.addState(StateSet.WILD_CARD, drawable);
+
+    ShadowStateListDrawable shadow = shadowOf(stateListDrawable);
+    Drawable drawableForState = shadow.getDrawableForState(StateSet.WILD_CARD);
+    assertNotNull(drawableForState);
+    assertThat(((ShadowBitmapDrawable) shadowOf(drawableForState)).getCreatedFromResId())
+        .isEqualTo(android.R.drawable.ic_delete);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStaticLayoutTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStaticLayoutTest.java
new file mode 100644
index 0000000..254db50
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStaticLayoutTest.java
@@ -0,0 +1,17 @@
+package org.robolectric.shadows;
+
+import android.text.Layout;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowStaticLayoutTest {
+
+  @Test
+  public void generate_shouldNotThrowException() {
+    new StaticLayout("Hello!", new TextPaint(), 100, Layout.Alignment.ALIGN_LEFT, 1.2f, 1.0f, true);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStatsLogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStatsLogTest.java
new file mode 100644
index 0000000..f58d6db
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStatsLogTest.java
@@ -0,0 +1,243 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertEquals;
+
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.StatsEvent;
+import android.util.StatsLog;
+import com.google.common.collect.Range;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowStatsLog} */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = Build.VERSION_CODES.R)
+public final class ShadowStatsLogTest {
+
+  @Test
+  public void testNoFields() {
+    final StatsEvent statsEvent = StatsEvent.newBuilder().usePooledBuffer().build();
+    long minTimestamp = SystemClock.elapsedRealtimeNanos();
+    StatsLog.write(statsEvent);
+    long maxTimestamp = SystemClock.elapsedRealtimeNanos();
+
+    final int expectedAtomId = 0;
+
+    assertEquals(1, ShadowStatsLog.getStatsLogs().size());
+    assertEquals((int) expectedAtomId, (int) ShadowStatsLog.getStatsLogs().get(0).atomId());
+
+    final ByteBuffer buffer =
+        ByteBuffer.wrap(ShadowStatsLog.getStatsLogs().get(0).bytes())
+            .order(ByteOrder.LITTLE_ENDIAN);
+
+    assertWithMessage("Root element in buffer is not TYPE_OBJECT")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_OBJECT);
+
+    assertWithMessage("Incorrect number of elements in root object")
+        .that(buffer.get())
+        .isEqualTo(3);
+
+    assertWithMessage("First element is not timestamp")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_LONG);
+
+    assertWithMessage("Incorrect timestamp")
+        .that(buffer.getLong())
+        .isIn(Range.closed(minTimestamp, maxTimestamp));
+
+    assertWithMessage("Second element is not atom id")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_INT);
+
+    assertWithMessage("Incorrect atom id").that(buffer.getInt()).isEqualTo(expectedAtomId);
+
+    assertWithMessage("Third element is not errors type")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_ERRORS);
+
+    final int errorMask = buffer.getInt();
+
+    assertWithMessage("ERROR_NO_ATOM_ID should be the only error in the error mask")
+        .that(errorMask)
+        .isEqualTo(StatsEvent.ERROR_NO_ATOM_ID);
+
+    assertThat(statsEvent.getNumBytes()).isEqualTo(buffer.position());
+
+    statsEvent.release();
+  }
+
+  @Test
+  public void testOnlyAtomId() {
+    final int expectedAtomId = 109;
+
+    final StatsEvent statsEvent =
+        StatsEvent.newBuilder().setAtomId(expectedAtomId).usePooledBuffer().build();
+    long minTimestamp = SystemClock.elapsedRealtimeNanos();
+    StatsLog.write(statsEvent);
+    long maxTimestamp = SystemClock.elapsedRealtimeNanos();
+
+    assertEquals(1, ShadowStatsLog.getStatsLogs().size());
+    assertEquals((int) expectedAtomId, (int) ShadowStatsLog.getStatsLogs().get(0).atomId());
+
+    final ByteBuffer buffer =
+        ByteBuffer.wrap(ShadowStatsLog.getStatsLogs().get(0).bytes())
+            .order(ByteOrder.LITTLE_ENDIAN);
+
+    assertWithMessage("Root element in buffer is not TYPE_OBJECT")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_OBJECT);
+
+    assertWithMessage("Incorrect number of elements in root object")
+        .that(buffer.get())
+        .isEqualTo(2);
+
+    assertWithMessage("First element is not timestamp")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_LONG);
+
+    assertWithMessage("Incorrect timestamp")
+        .that(buffer.getLong())
+        .isIn(Range.closed(minTimestamp, maxTimestamp));
+
+    assertWithMessage("Second element is not atom id")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_INT);
+
+    assertWithMessage("Incorrect atom id").that(buffer.getInt()).isEqualTo(expectedAtomId);
+
+    assertThat(statsEvent.getNumBytes()).isEqualTo(buffer.position());
+
+    statsEvent.release();
+  }
+
+  @Test
+  public void testOnlyAtomIdMultipleAtoms() {
+    List<Integer> expectedAtomIds = Arrays.asList(109, 110, 111);
+    List<StatsEvent> statsEvents = new ArrayList<>();
+
+    long minTimestamp = SystemClock.elapsedRealtimeNanos();
+    for (Integer atomId : expectedAtomIds) {
+      final StatsEvent statsEvent =
+          StatsEvent.newBuilder().setAtomId(atomId).usePooledBuffer().build();
+      StatsLog.write(statsEvent);
+      statsEvents.add(statsEvent);
+    }
+    long maxTimestamp = SystemClock.elapsedRealtimeNanos();
+
+    assertThat(ShadowStatsLog.getStatsLogs()).hasSize(statsEvents.size());
+
+    for (int i = 0; i < statsEvents.size(); i++) {
+      assertEquals((int) expectedAtomIds.get(i), ShadowStatsLog.getStatsLogs().get(i).atomId());
+
+      final ByteBuffer buffer =
+          ByteBuffer.wrap(ShadowStatsLog.getStatsLogs().get(i).bytes())
+              .order(ByteOrder.LITTLE_ENDIAN);
+
+      assertWithMessage("Root element in buffer is not TYPE_OBJECT")
+          .that(buffer.get())
+          .isEqualTo(StatsEvent.TYPE_OBJECT);
+
+      assertWithMessage("Incorrect number of elements in root object")
+          .that(buffer.get())
+          .isEqualTo(2);
+
+      assertWithMessage("First element is not timestamp")
+          .that(buffer.get())
+          .isEqualTo(StatsEvent.TYPE_LONG);
+
+      assertWithMessage("Incorrect timestamp")
+          .that(buffer.getLong())
+          .isIn(Range.closed(minTimestamp, maxTimestamp));
+
+      assertWithMessage("Second element is not atom id")
+          .that(buffer.get())
+          .isEqualTo(StatsEvent.TYPE_INT);
+
+      assertWithMessage("Incorrect atom id")
+          .that(buffer.getInt())
+          .isEqualTo(expectedAtomIds.get(i));
+
+      assertThat(statsEvents.get(i).getNumBytes()).isEqualTo(buffer.position());
+    }
+
+    for (StatsEvent statsEvent : statsEvents) {
+      statsEvent.release();
+    }
+  }
+
+  @Test
+  public void testIntIntInt() {
+    final int expectedAtomId = 109;
+    final int field1 = 1;
+    final int field2 = 2;
+    final int field3 = 3;
+
+    final StatsEvent statsEvent =
+        StatsEvent.newBuilder()
+            .setAtomId(expectedAtomId)
+            .writeInt(field1)
+            .writeInt(field2)
+            .writeInt(field3)
+            .usePooledBuffer()
+            .build();
+    long minTimestamp = SystemClock.elapsedRealtimeNanos();
+    StatsLog.write(statsEvent);
+    long maxTimestamp = SystemClock.elapsedRealtimeNanos();
+
+    assertEquals(1, ShadowStatsLog.getStatsLogs().size());
+    assertEquals((int) expectedAtomId, (int) ShadowStatsLog.getStatsLogs().get(0).atomId());
+
+    final ByteBuffer buffer =
+        ByteBuffer.wrap(ShadowStatsLog.getStatsLogs().get(0).bytes())
+            .order(ByteOrder.LITTLE_ENDIAN);
+
+    assertWithMessage("Root element in buffer is not TYPE_OBJECT")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_OBJECT);
+
+    assertWithMessage("Incorrect number of elements in root object")
+        .that(buffer.get())
+        .isEqualTo(5);
+
+    assertWithMessage("First element is not timestamp")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_LONG);
+
+    assertWithMessage("Incorrect timestamp")
+        .that(buffer.getLong())
+        .isIn(Range.closed(minTimestamp, maxTimestamp));
+
+    assertWithMessage("Second element is not atom id")
+        .that(buffer.get())
+        .isEqualTo(StatsEvent.TYPE_INT);
+
+    assertWithMessage("Incorrect atom id").that(buffer.getInt()).isEqualTo(expectedAtomId);
+
+    assertWithMessage("First field is not Int").that(buffer.get()).isEqualTo(StatsEvent.TYPE_INT);
+
+    assertWithMessage("Incorrect field 1").that(buffer.getInt()).isEqualTo(field1);
+
+    assertWithMessage("Third field is not Int").that(buffer.get()).isEqualTo(StatsEvent.TYPE_INT);
+
+    assertWithMessage("Incorrect field 2").that(buffer.getInt()).isEqualTo(field2);
+
+    assertWithMessage("Fourth field is not Int").that(buffer.get()).isEqualTo(StatsEvent.TYPE_INT);
+
+    assertWithMessage("Incorrect field 3").that(buffer.getInt()).isEqualTo(field3);
+
+    assertThat(statsEvent.getNumBytes()).isEqualTo(buffer.position());
+
+    statsEvent.release();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStatusBarManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStatusBarManagerTest.java
new file mode 100644
index 0000000..a602c54
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStatusBarManagerTest.java
@@ -0,0 +1,81 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.StatusBarManager;
+import android.content.Context;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit tests for {@link ShadowStatusBarManager}. */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowStatusBarManagerTest {
+
+  private static final int TEST_NAV_BAR_MODE = 100;
+
+  private final StatusBarManager statusBarManager =
+      (StatusBarManager) getApplicationContext().getSystemService(Context.STATUS_BAR_SERVICE);
+
+  private final ShadowStatusBarManager shadowStatusBarManager = Shadow.extract(statusBarManager);
+
+  @Test
+  public void getDisable() throws ClassNotFoundException {
+    statusBarManager.disable(ShadowStatusBarManager.DEFAULT_DISABLE_MASK);
+    assertThat(shadowStatusBarManager.getDisableFlags())
+        .isEqualTo(ShadowStatusBarManager.DEFAULT_DISABLE_MASK);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getDisable2() throws ClassNotFoundException {
+    statusBarManager.disable2(ShadowStatusBarManager.DEFAULT_DISABLE2_MASK);
+    assertThat(shadowStatusBarManager.getDisable2Flags())
+        .isEqualTo(ShadowStatusBarManager.DEFAULT_DISABLE2_MASK);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setDisabledForSetup() {
+    getApplicationContext().getSystemService(StatusBarManager.class).setDisabledForSetup(true);
+
+    assertThat(shadowStatusBarManager.getDisableFlags())
+        .isEqualTo(ShadowStatusBarManager.getDefaultSetupDisableFlags());
+
+    int disable2Flags = shadowStatusBarManager.getDisable2Flags();
+    assertThat(disable2Flags).isEqualTo(ShadowStatusBarManager.getDefaultSetupDisable2Flags());
+
+    // The default disable2 flags changed in Android T.
+    int expectedDisable2Flags =
+        RuntimeEnvironment.getApiLevel() <= S_V2
+            ? ShadowStatusBarManager.DISABLE2_ROTATE_SUGGESTIONS
+            : ShadowStatusBarManager.DISABLE2_NONE;
+    assertThat(disable2Flags).isEqualTo(expectedDisable2Flags);
+
+    getApplicationContext().getSystemService(StatusBarManager.class).setDisabledForSetup(false);
+    assertThat(shadowStatusBarManager.getDisableFlags()).isEqualTo(StatusBarManager.DISABLE_NONE);
+    assertThat(shadowStatusBarManager.getDisable2Flags()).isEqualTo(StatusBarManager.DISABLE2_NONE);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getNavBarMode_returnsNavBarMode() throws Exception {
+    statusBarManager.setNavBarMode(TEST_NAV_BAR_MODE);
+    assertThat(shadowStatusBarManager.getNavBarMode()).isEqualTo(TEST_NAV_BAR_MODE);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setNavBarMode_storesNavBarMode() throws Exception {
+    shadowStatusBarManager.setNavBarMode(TEST_NAV_BAR_MODE);
+    assertThat(shadowStatusBarManager.getNavBarMode()).isEqualTo(TEST_NAV_BAR_MODE);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStorageManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStorageManagerTest.java
new file mode 100644
index 0000000..863f448
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStorageManagerTest.java
@@ -0,0 +1,103 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.os.storage.StorageManager;
+import android.os.storage.StorageVolume;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowStorageManager}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowStorageManagerTest {
+
+  private final String internalStorage = "/storage/internal";
+  private final String sdcardStorage = "/storage/sdcard";
+
+  private StorageManager storageManager;
+
+  @Before
+  public void setUp() {
+    storageManager = (StorageManager) getApplication().getSystemService(Context.STORAGE_SERVICE);
+  }
+
+  @Test
+  public void getVolumeList() {
+    assertThat(shadowOf(storageManager).getVolumeList()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getStorageVolumes() {
+    File file1 = new File(sdcardStorage);
+    shadowOf(storageManager).addStorageVolume(buildAndGetStorageVolume(file1, "sd card"));
+    assertThat(shadowOf(storageManager).getStorageVolumes()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getStorageVolumesHaveDifferentUUID() {
+    File file1 = new File(sdcardStorage);
+    File file2 = new File(internalStorage);
+
+    shadowOf(storageManager).addStorageVolume(buildAndGetStorageVolume(file1, "sd card"));
+    shadowOf(storageManager).addStorageVolume(buildAndGetStorageVolume(file2, "internal"));
+
+    List<StorageVolume> volumeList = shadowOf(storageManager).getStorageVolumes();
+    assertThat(volumeList).hasSize(2);
+    StorageVolume storage1 = volumeList.get(0);
+    StorageVolume storage2 = volumeList.get(1);
+    assertThat(storage1.getUuid()).isNotEqualTo(storage2.getUuid());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getStorageVolume() {
+    File file1 = new File(internalStorage);
+    File file2 = new File(sdcardStorage);
+    File file3 = new File(internalStorage, "test_folder");
+    File file4 = new File(sdcardStorage, "test_folder");
+    shadowOf(storageManager).addStorageVolume(buildAndGetStorageVolume(file1, "internal"));
+    assertThat(shadowOf(storageManager).getStorageVolume(file1)).isNotNull();
+    assertThat(shadowOf(storageManager).getStorageVolume(file2)).isNull();
+    assertThat(shadowOf(storageManager).getStorageVolume(file3)).isNotNull();
+    assertThat(shadowOf(storageManager).getStorageVolume(file4)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isFileEncryptedNativeOrEmulated() {
+    shadowOf(storageManager).setFileEncryptedNativeOrEmulated(true);
+    assertThat(StorageManager.isFileEncryptedNativeOrEmulated()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isUserKeyUnlocked() {
+    shadowOf(getApplication().getSystemService(UserManager.class)).setUserUnlocked(true);
+    assertThat(StorageManager.isUserKeyUnlocked(0)).isTrue();
+  }
+
+  private StorageVolume buildAndGetStorageVolume(File file, String description) {
+    Parcel parcel = Parcel.obtain();
+    parcel.writeInt(0);
+    parcel.setDataPosition(0);
+    UserHandle userHandle = new UserHandle(parcel);
+    StorageVolumeBuilder storageVolumeBuilder =
+        new StorageVolumeBuilder(
+            "volume" + " " + description, file, description, userHandle, "mounted");
+    return storageVolumeBuilder.build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStorageStatsManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStorageStatsManagerTest.java
new file mode 100644
index 0000000..b3d6139
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStorageStatsManagerTest.java
@@ -0,0 +1,230 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.usage.StorageStats;
+import android.app.usage.StorageStatsManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.storage.StorageManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.UUID;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowStorageStatsManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = Build.VERSION_CODES.O)
+public final class ShadowStorageStatsManagerTest {
+
+  private StorageStatsManager storageStatsManager;
+
+  @Before
+  public void setUp() {
+    storageStatsManager =
+        (StorageStatsManager)
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(Context.STORAGE_STATS_SERVICE);
+  }
+
+  @Test
+  public void getFreeBytes_defaultUuid_shouldReturnDefaultValue() throws Exception {
+    // Act
+    long defaultFreeBytes = shadowOf(storageStatsManager).getFreeBytes(StorageManager.UUID_DEFAULT);
+
+    // Assert
+    assertThat(defaultFreeBytes).isEqualTo(ShadowStorageStatsManager.DEFAULT_STORAGE_FREE_BYTES);
+  }
+
+  @Test
+  public void getFreeBytes_unregisteredUuid_throwsException() {
+    // Arrange
+    UUID newUuid = UUID.randomUUID();
+
+    // Act & Assert
+    assertThrows(IOException.class, () -> shadowOf(storageStatsManager).getFreeBytes(newUuid));
+  }
+
+  @Test
+  public void getFreeBytes_registeredNewUuid_returnSetupValue() throws Exception {
+    // Arrange
+    UUID newUuid = UUID.randomUUID();
+    long expectedFreeBytes = 16 * 1024L;
+    long expectedTotalBytes = 32 * 1024L;
+    shadowOf(storageStatsManager)
+        .setStorageDeviceFreeAndTotalBytes(newUuid, expectedFreeBytes, expectedTotalBytes);
+
+    // Act
+    long defaultFreeBytes = shadowOf(storageStatsManager).getFreeBytes(StorageManager.UUID_DEFAULT);
+    long newUuidFreeBytes = shadowOf(storageStatsManager).getFreeBytes(newUuid);
+
+    // Assert
+    assertThat(defaultFreeBytes).isEqualTo(ShadowStorageStatsManager.DEFAULT_STORAGE_FREE_BYTES);
+    assertThat(newUuidFreeBytes).isEqualTo(expectedFreeBytes);
+  }
+
+  @Test
+  public void getFreeBytes_afterRemoveStorageDevice_throwsException() {
+    // Arange
+    shadowOf(storageStatsManager).removeStorageDevice(StorageManager.UUID_DEFAULT);
+
+    // Act & Assert
+    assertThrows(
+        IOException.class,
+        () -> shadowOf(storageStatsManager).getFreeBytes(StorageManager.UUID_DEFAULT));
+  }
+
+  @Test
+  public void getTotalBytes_defaultUuid_shouldReturnDefaultValue() throws Exception {
+    // Act
+    long defaultTotalBytes =
+        shadowOf(storageStatsManager).getTotalBytes(StorageManager.UUID_DEFAULT);
+
+    // Assert
+    assertThat(defaultTotalBytes).isEqualTo(ShadowStorageStatsManager.DEFAULT_STORAGE_TOTAL_BYTES);
+  }
+
+  @Test
+  public void getTotalBytes_unregisteredUuid_throwsException() {
+    // Arrange
+    UUID newUuid = UUID.randomUUID();
+
+    // Act & Assert
+    assertThrows(IOException.class, () -> shadowOf(storageStatsManager).getTotalBytes(newUuid));
+  }
+
+  @Test
+  public void getTotalBytes_registeredNewUuid_returnSetupValue() throws Exception {
+    // Arrange
+    UUID newUuid = UUID.randomUUID();
+    long expectedFreeBytes = 16 * 1024L;
+    long expectedTotalBytes = 32 * 1024L;
+    shadowOf(storageStatsManager)
+        .setStorageDeviceFreeAndTotalBytes(newUuid, expectedFreeBytes, expectedTotalBytes);
+
+    // Act
+    long defaultTotalBytes =
+        shadowOf(storageStatsManager).getTotalBytes(StorageManager.UUID_DEFAULT);
+    long newUuidTotalBytes = shadowOf(storageStatsManager).getTotalBytes(newUuid);
+
+    // Assert
+    assertThat(defaultTotalBytes).isEqualTo(ShadowStorageStatsManager.DEFAULT_STORAGE_TOTAL_BYTES);
+    assertThat(newUuidTotalBytes).isEqualTo(expectedTotalBytes);
+  }
+
+  @Test
+  public void getTotalBytes_afterRemoveStorageDevice_throwsException() {
+    // Arange
+    shadowOf(storageStatsManager).removeStorageDevice(StorageManager.UUID_DEFAULT);
+
+    // Act & Assert
+    assertThrows(
+        IOException.class,
+        () -> shadowOf(storageStatsManager).getTotalBytes(StorageManager.UUID_DEFAULT));
+  }
+
+  @Test
+  public void queryWithoutSetup_shouldFail() {
+    assertThrows(
+        PackageManager.NameNotFoundException.class,
+        () ->
+            shadowOf(storageStatsManager)
+                .queryStatsForPackage(
+                    UUID.randomUUID(), "somePackageName", Process.myUserHandle()));
+  }
+
+  @Test
+  public void queryWithCorrectArguments_shouldReturnSetupValue() throws Exception {
+    // Arrange
+    StorageStats expected = buildStorageStats();
+    UUID uuid = UUID.randomUUID();
+    String packageName = "somePackageName";
+    UserHandle userHandle = Process.myUserHandle();
+    shadowOf(storageStatsManager).addStorageStats(uuid, packageName, userHandle, expected);
+
+    // Act
+    StorageStats actual =
+        shadowOf(storageStatsManager).queryStatsForPackage(uuid, packageName, userHandle);
+
+    // Assert
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void queryWithWrongArguments_shouldFail() {
+    // Arrange
+    StorageStats expected = buildStorageStats();
+    UUID uuid = UUID.randomUUID();
+    UUID differentUUID = UUID.randomUUID();
+    String packageName = "somePackageName";
+    UserHandle userHandle = UserHandle.getUserHandleForUid(0);
+    // getUserHandleForUid will divide uid by 100000. Pass in some arbitrary number > 100000 to be
+    // different from system uid 0.
+    UserHandle differentUserHandle = UserHandle.getUserHandleForUid(1200000);
+
+    assertThat(uuid).isNotEqualTo(differentUUID);
+    assertThat(userHandle).isNotEqualTo(differentUserHandle);
+
+    // Act
+    shadowOf(storageStatsManager).addStorageStats(uuid, packageName, userHandle, expected);
+
+    // Assert
+    assertThrows(
+        PackageManager.NameNotFoundException.class,
+        () ->
+            shadowOf(storageStatsManager)
+                .queryStatsForPackage(uuid, "differentPackageName", userHandle));
+
+    assertThrows(
+        PackageManager.NameNotFoundException.class,
+        () ->
+            shadowOf(storageStatsManager)
+                .queryStatsForPackage(differentUUID, packageName, userHandle));
+
+    assertThrows(
+        PackageManager.NameNotFoundException.class,
+        () ->
+            shadowOf(storageStatsManager)
+                .queryStatsForPackage(uuid, packageName, differentUserHandle));
+  }
+
+  @Test
+  public void queryAfterClearSetup_shouldFail() {
+    // Arrange
+    StorageStats expected = buildStorageStats();
+    UUID uuid = UUID.randomUUID();
+    String packageName = "somePackageName";
+    UserHandle userHandle = Process.myUserHandle();
+    shadowOf(storageStatsManager).addStorageStats(uuid, packageName, userHandle, expected);
+
+    // Act
+    shadowOf(storageStatsManager).clearStorageStats();
+
+    // Assert
+    assertThrows(
+        PackageManager.NameNotFoundException.class,
+        () -> shadowOf(storageStatsManager).queryStatsForPackage(uuid, packageName, userHandle));
+  }
+
+  private static StorageStats buildStorageStats() {
+    long codeSize = 3000L;
+    long dataSize = 2000L;
+    long cacheSize = 1000L;
+    Parcel parcel = Parcel.obtain();
+    parcel.writeLong(codeSize);
+    parcel.writeLong(dataSize);
+    parcel.writeLong(cacheSize);
+    parcel.setDataPosition(0);
+    return StorageStats.CREATOR.createFromParcel(parcel);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowStrictModeTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowStrictModeTest.java
new file mode 100644
index 0000000..8c047e8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowStrictModeTest.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import android.os.StrictMode;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowStrictModeTest {
+  @Test
+  public void setVmPolicyTest() {
+    StrictMode.VmPolicy policy = new StrictMode.VmPolicy.Builder().build();
+    StrictMode.setVmPolicy(policy); // should not result in an exception
+  }
+}
\ No newline at end of file
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java
new file mode 100644
index 0000000..9ea0e9a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java
@@ -0,0 +1,317 @@
+package org.robolectric.shadows;
+
+import static android.content.Context.TELEPHONY_SUBSCRIPTION_SERVICE;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.P;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowSubscriptionManager.SubscriptionInfoBuilder;
+
+/** Test for {@link ShadowSubscriptionManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = N)
+public class ShadowSubscriptionManagerTest {
+
+  private SubscriptionManager subscriptionManager;
+
+  @Before
+  public void setUp() throws Exception {
+    subscriptionManager =
+        (SubscriptionManager)
+            getApplicationContext().getSystemService(TELEPHONY_SUBSCRIPTION_SERVICE);
+  }
+
+  @Test
+  public void shouldGiveDefaultSubscriptionId() {
+    int testId = 42;
+    ShadowSubscriptionManager.setDefaultSubscriptionId(testId);
+    assertThat(SubscriptionManager.getDefaultSubscriptionId()).isEqualTo(testId);
+  }
+
+  @Test
+  public void shouldGiveDefaultDataSubscriptionId() {
+    int testId = 42;
+    ShadowSubscriptionManager.setDefaultDataSubscriptionId(testId);
+    assertThat(SubscriptionManager.getDefaultDataSubscriptionId()).isEqualTo(testId);
+  }
+
+  @Test
+  public void shouldGiveDefaultSmsSubscriptionId() {
+    int testId = 42;
+    ShadowSubscriptionManager.setDefaultSmsSubscriptionId(testId);
+    assertThat(SubscriptionManager.getDefaultSmsSubscriptionId()).isEqualTo(testId);
+  }
+
+  @Test
+  public void shouldGiveDefaultVoiceSubscriptionId() {
+    int testId = 42;
+    ShadowSubscriptionManager.setDefaultVoiceSubscriptionId(testId);
+    assertThat(SubscriptionManager.getDefaultVoiceSubscriptionId()).isEqualTo(testId);
+  }
+
+  @Test
+  public void addOnSubscriptionsChangedListener_shouldCallbackImmediately() {
+    DummySubscriptionsChangedListener listener = new DummySubscriptionsChangedListener();
+    shadowOf(subscriptionManager).addOnSubscriptionsChangedListener(listener);
+
+    assertThat(listener.subscriptionChangedCount).isEqualTo(1);
+  }
+
+  @Test
+  public void addOnSubscriptionsChangedListener_shouldAddListener() {
+    DummySubscriptionsChangedListener listener = new DummySubscriptionsChangedListener();
+    shadowOf(subscriptionManager).addOnSubscriptionsChangedListener(listener);
+
+    shadowOf(subscriptionManager)
+        .setActiveSubscriptionInfos(
+            SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo());
+
+    assertThat(listener.subscriptionChangedCount).isEqualTo(2);
+  }
+
+  @Test
+  public void removeOnSubscriptionsChangedListener_shouldRemoveListener() {
+    DummySubscriptionsChangedListener listener = new DummySubscriptionsChangedListener();
+    DummySubscriptionsChangedListener listener2 = new DummySubscriptionsChangedListener();
+    shadowOf(subscriptionManager).addOnSubscriptionsChangedListener(listener);
+    shadowOf(subscriptionManager).addOnSubscriptionsChangedListener(listener2);
+
+    shadowOf(subscriptionManager).removeOnSubscriptionsChangedListener(listener);
+    shadowOf(subscriptionManager)
+        .setActiveSubscriptionInfos(
+            SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo());
+
+    assertThat(listener.subscriptionChangedCount).isEqualTo(1);
+    assertThat(listener2.subscriptionChangedCount).isEqualTo(2);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfo_shouldReturnInfoWithSubId() {
+    SubscriptionInfo expectedSubscriptionInfo =
+        SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo();
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos(expectedSubscriptionInfo);
+
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfo(123))
+        .isSameInstanceAs(expectedSubscriptionInfo);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoList_shouldReturnInfoList() {
+    SubscriptionInfo expectedSubscriptionInfo =
+        SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo();
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos(expectedSubscriptionInfo);
+
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfoList())
+        .containsExactly(expectedSubscriptionInfo);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoList_shouldThrowExceptionWhenNoPermissions() {
+    shadowOf(subscriptionManager).setReadPhoneStatePermission(false);
+    assertThrows(
+        SecurityException.class,
+        () -> shadowOf(subscriptionManager).getActiveSubscriptionInfoList());
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoForSimSlotIndex_shouldReturnInfoWithSlotIndex() {
+    SubscriptionInfo expectedSubscriptionInfo =
+        SubscriptionInfoBuilder.newBuilder().setSimSlotIndex(123).buildSubscriptionInfo();
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos(expectedSubscriptionInfo);
+
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfoForSimSlotIndex(123))
+        .isSameInstanceAs(expectedSubscriptionInfo);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoForSimSlotIndex_shouldThrowExceptionWhenNoPermissions() {
+    shadowOf(subscriptionManager).setReadPhoneStatePermission(false);
+    assertThrows(
+        SecurityException.class,
+        () -> shadowOf(subscriptionManager).getActiveSubscriptionInfoForSimSlotIndex(123));
+  }
+
+  @Test
+  public void getActiveSubscriptionInfo_shouldReturnNullForNullList() {
+    shadowOf(subscriptionManager).setActiveSubscriptionInfoList(null);
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfo(123)).isNull();
+  }
+
+  @Test
+  public void getActiveSubscriptionInfo_shouldReturnNullForNullVarargsList() {
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos((SubscriptionInfo[]) null);
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfo(123)).isNull();
+  }
+
+  @Test
+  public void getActiveSubscriptionInfo_shouldReturnNullForEmptyList() {
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos();
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfo(123)).isNull();
+  }
+
+  @Test
+  public void isNetworkRoaming_shouldReturnTrueIfSet() {
+    shadowOf(subscriptionManager).setNetworkRoamingStatus(123, /*isNetworkRoaming=*/ true);
+    assertThat(shadowOf(subscriptionManager).isNetworkRoaming(123)).isTrue();
+  }
+
+  /** Multi act-asserts are discouraged but here we are testing the set+unset. */
+  @Test
+  public void isNetworkRoaming_shouldReturnFalseIfUnset() {
+    shadowOf(subscriptionManager).setNetworkRoamingStatus(123, /*isNetworkRoaming=*/ true);
+    assertThat(shadowOf(subscriptionManager).isNetworkRoaming(123)).isTrue();
+
+    shadowOf(subscriptionManager).setNetworkRoamingStatus(123, /*isNetworkRoaming=*/ false);
+    assertThat(shadowOf(subscriptionManager).isNetworkRoaming(123)).isFalse();
+  }
+
+  /** Multi act-asserts are discouraged but here we are testing the set+clear. */
+  @Test
+  public void isNetworkRoaming_shouldReturnFalseOnClear() {
+    shadowOf(subscriptionManager).setNetworkRoamingStatus(123, /*isNetworkRoaming=*/ true);
+    assertThat(shadowOf(subscriptionManager).isNetworkRoaming(123)).isTrue();
+
+    shadowOf(subscriptionManager).clearNetworkRoamingStatus();
+    assertThat(shadowOf(subscriptionManager).isNetworkRoaming(123)).isFalse();
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoCount_shouldReturnZeroIfActiveSubscriptionInfoListNotSet() {
+    shadowOf(subscriptionManager).setActiveSubscriptionInfoList(null);
+
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfoCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoCount_shouldReturnSizeOfActiveSubscriptionInfosList() {
+    SubscriptionInfo expectedSubscriptionInfo =
+        SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo();
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos(expectedSubscriptionInfo);
+
+    assertThat(shadowOf(subscriptionManager).getActiveSubscriptionInfoCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoCountMax_returnsSubscriptionListCount() {
+    SubscriptionInfo subscriptionInfo =
+        SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo();
+    shadowOf(subscriptionManager).setActiveSubscriptionInfos(subscriptionInfo);
+
+    assertThat(subscriptionManager.getActiveSubscriptionInfoCountMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoCountMax_nullInfoListIsZero() {
+    shadowOf(subscriptionManager).setActiveSubscriptionInfoList(null);
+
+    assertThat(subscriptionManager.getActiveSubscriptionInfoCountMax()).isEqualTo(0);
+  }
+
+  @Test
+  public void getActiveSubscriptionInfoCountMax_shouldThrowExceptionWhenNoPermissions() {
+    shadowOf(subscriptionManager).setReadPhoneStatePermission(false);
+    assertThrows(
+        SecurityException.class, () -> subscriptionManager.getActiveSubscriptionInfoCountMax());
+  }
+
+  @Test
+  public void getAvailableSubscriptionInfoList() {
+    SubscriptionInfo expectedSubscriptionInfo =
+        SubscriptionInfoBuilder.newBuilder().setId(123).buildSubscriptionInfo();
+
+    // default
+    assertThat(shadowOf(subscriptionManager).getAvailableSubscriptionInfoList()).isEmpty();
+
+    // null condition
+    shadowOf(subscriptionManager).setAvailableSubscriptionInfos();
+    assertThat(shadowOf(subscriptionManager).getAvailableSubscriptionInfoList()).isEmpty();
+
+    // set a specific subscription
+    shadowOf(subscriptionManager).setAvailableSubscriptionInfos(expectedSubscriptionInfo);
+    assertThat(shadowOf(subscriptionManager).getAvailableSubscriptionInfoList()).hasSize(1);
+    assertThat(shadowOf(subscriptionManager).getAvailableSubscriptionInfoList().get(0))
+        .isSameInstanceAs(expectedSubscriptionInfo);
+  }
+
+  @Test
+  @Config(maxSdk = P)
+  public void getPhoneId_shouldReturnPhoneIdIfSet() {
+    ShadowSubscriptionManager.putPhoneId(123, 456);
+    assertThat(SubscriptionManager.getPhoneId(123)).isEqualTo(456);
+  }
+
+  @Test
+  @Config(maxSdk = P)
+  public void getPhoneId_shouldReturnInvalidIfNotSet() {
+    ShadowSubscriptionManager.putPhoneId(123, 456);
+    assertThat(SubscriptionManager.getPhoneId(456))
+        .isEqualTo(ShadowSubscriptionManager.INVALID_PHONE_INDEX);
+  }
+
+  @Test
+  @Config(maxSdk = P)
+  public void getPhoneId_shouldReturnInvalidIfRemoved() {
+    ShadowSubscriptionManager.putPhoneId(123, 456);
+    ShadowSubscriptionManager.removePhoneId(123);
+    assertThat(SubscriptionManager.getPhoneId(123))
+        .isEqualTo(ShadowSubscriptionManager.INVALID_PHONE_INDEX);
+  }
+
+  @Test
+  @Config(maxSdk = P)
+  public void getPhoneId_shouldReturnInvalidIfCleared() {
+    ShadowSubscriptionManager.putPhoneId(123, 456);
+    ShadowSubscriptionManager.clearPhoneIds();
+    assertThat(SubscriptionManager.getPhoneId(123))
+        .isEqualTo(ShadowSubscriptionManager.INVALID_PHONE_INDEX);
+  }
+
+  @Test
+  @Config(maxSdk = P)
+  public void getPhoneId_shouldReturnInvalidIfReset() {
+    ShadowSubscriptionManager.putPhoneId(123, 456);
+    ShadowSubscriptionManager.reset();
+    assertThat(SubscriptionManager.getPhoneId(123))
+        .isEqualTo(ShadowSubscriptionManager.INVALID_PHONE_INDEX);
+  }
+
+  @Test
+  public void setMcc() {
+    assertThat(
+            ShadowSubscriptionManager.SubscriptionInfoBuilder.newBuilder()
+                .setMcc("123")
+                .buildSubscriptionInfo()
+                .getMcc())
+        .isEqualTo(123);
+  }
+
+  @Test
+  public void setMnc() {
+    assertThat(
+            ShadowSubscriptionManager.SubscriptionInfoBuilder.newBuilder()
+                .setMnc("123")
+                .buildSubscriptionInfo()
+                .getMnc())
+        .isEqualTo(123);
+  }
+
+  private static class DummySubscriptionsChangedListener
+      extends SubscriptionManager.OnSubscriptionsChangedListener {
+    private int subscriptionChangedCount;
+
+    @Override
+    public void onSubscriptionsChanged() {
+      subscriptionChangedCount++;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceControlTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceControlTest.java
new file mode 100644
index 0000000..6bf42f4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceControlTest.java
@@ -0,0 +1,79 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.SurfaceControl;
+import android.view.SurfaceSession;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import dalvik.system.CloseGuard;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Tests for {@link org.robolectric.shadows.ShadowSurfaceControl} */
+@Config(minSdk = JELLY_BEAN_MR2)
+@RunWith(AndroidJUnit4.class)
+public class ShadowSurfaceControlTest {
+  // The spurious CloseGuard warnings happens in Q, where the CloseGuard is always opened.
+  @Test
+  @Config(sdk = Q)
+  public void newSurfaceControl_doesNotResultInCloseGuardError() {
+    final AtomicBoolean closeGuardWarned = new AtomicBoolean(false);
+    CloseGuard.Reporter originalReporter = CloseGuard.getReporter();
+    try {
+      CloseGuard.setReporter((s, throwable) -> closeGuardWarned.set(true));
+      SurfaceControl sc = new SurfaceControl();
+      ReflectionHelpers.callInstanceMethod(sc, "finalize");
+      assertThat(closeGuardWarned.get()).isFalse();
+    } finally {
+      CloseGuard.setReporter(originalReporter);
+    }
+  }
+
+  @Test
+  @Config(maxSdk = O)
+  public void newSurfaceControl_isNotNull() {
+    SurfaceControl surfaceControl =
+        ReflectionHelpers.callConstructor(
+            SurfaceControl.class,
+            ClassParameter.from(SurfaceSession.class, new SurfaceSession()),
+            ClassParameter.from(String.class, "surface_control_name"),
+            ClassParameter.from(int.class, 100),
+            ClassParameter.from(int.class, 100),
+            ClassParameter.from(int.class, 0),
+            ClassParameter.from(int.class, SurfaceControl.HIDDEN));
+
+    assertThat(surfaceControl).isNotNull();
+  }
+
+  @Test
+  @Config(sdk = P)
+  public void build_isNotNull() {
+    SurfaceControl.Builder surfaceControlBuilder =
+        new SurfaceControl.Builder(new SurfaceSession()).setName("surface_control_name");
+    ReflectionHelpers.callInstanceMethod(
+        surfaceControlBuilder,
+        "setSize",
+        ClassParameter.from(int.class, 100),
+        ClassParameter.from(int.class, 100));
+    SurfaceControl surfaceControl = surfaceControlBuilder.build();
+
+    assertThat(surfaceControl).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void build_isValid() {
+    SurfaceControl surfaceControl =
+        new SurfaceControl.Builder(new SurfaceSession()).setName("surface_control_name").build();
+
+    assertThat(surfaceControl.isValid()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceTest.java
new file mode 100644
index 0000000..cc411a2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceTest.java
@@ -0,0 +1,158 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ClipData;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.SurfaceTexture;
+import android.os.Looper;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.SurfaceSession;
+import android.view.View.DragShadowBuilder;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import dalvik.system.CloseGuard;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowSurface}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowSurfaceTest {
+  private final SurfaceTexture texture = new SurfaceTexture(0);
+  private final Surface surface = new Surface(texture);
+
+  @After
+  public void tearDown() {
+    surface.release();
+  }
+
+  @Test
+  public void getSurfaceTexture_returnsSurfaceTexture() {
+    assertThat(shadowOf(surface).getSurfaceTexture()).isEqualTo(texture);
+  }
+
+  @Test
+  public void release_doesNotThrow() {
+    surface.release();
+  }
+
+  @Test
+  public void toString_returnsNotEmptyString() {
+    assertThat(surface.toString()).isNotEmpty();
+  }
+
+  @Test
+  public void newSurface_doesNotResultInCloseGuardError() throws Throwable {
+    final AtomicBoolean closeGuardWarned = new AtomicBoolean(false);
+    CloseGuard.Reporter originalReporter = CloseGuard.getReporter();
+    try {
+      CloseGuard.setReporter((s, throwable) -> closeGuardWarned.set(true));
+      MySurface surface = new MySurface();
+      surface.finalize();
+      assertThat(closeGuardWarned.get()).isFalse();
+      surface.release();
+    } finally {
+      CloseGuard.setReporter(originalReporter);
+    }
+  }
+
+  @Test
+  public void lockCanvas_returnsCanvas() {
+    assertThat(surface.lockCanvas(new Rect())).isInstanceOf(Canvas.class);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void lockHardwareCanvas_returnsCanvas() {
+    Canvas canvas = surface.lockHardwareCanvas();
+    assertThat(canvas.isHardwareAccelerated()).isTrue();
+  }
+
+  @Test
+  public void lockCanvasTwice_throwsIllegalStateException() {
+    surface.lockCanvas(new Rect());
+
+    assertThrows(IllegalStateException.class, () -> surface.lockCanvas(new Rect()));
+  }
+
+  @Test
+  public void lockCanvasOnReleasedSurface_throwsIllegalStateException() {
+    surface.release();
+
+    assertThrows(IllegalStateException.class, () -> surface.lockCanvas(new Rect()));
+  }
+
+  @Test
+  public void unlockCanvasOnReleasedSurface_throwsIllegalStateException() {
+    Canvas canvas = surface.lockCanvas(new Rect());
+    surface.release();
+
+    assertThrows(IllegalStateException.class, () -> surface.unlockCanvasAndPost(canvas));
+  }
+
+  @Test
+  public void unlockCanvasOnUnLockedSurface_throwsIllegalStateException() {
+    Canvas canvas = surface.lockCanvas(new Rect());
+    surface.unlockCanvasAndPost(canvas);
+
+    assertThrows(IllegalStateException.class, () -> surface.unlockCanvasAndPost(canvas));
+  }
+
+  @Test
+  public void unlockCanvasAndPost_triggersFrameUpdateInSurfaceTexture() {
+    AtomicBoolean listenerCallBackCalled = new AtomicBoolean(false);
+
+    texture.setOnFrameAvailableListener((surfaceTexture) -> listenerCallBackCalled.set(true));
+    Canvas canvas = surface.lockCanvas(new Rect());
+    surface.unlockCanvasAndPost(canvas);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(listenerCallBackCalled.get()).isTrue();
+  }
+
+  @Config(minSdk = M)
+  @Test
+  public void unlockCanvasAndPost_triggersFrameUpdateInSurfaceTexture_hardwareCanvas() {
+    AtomicBoolean listenerCallBackCalled = new AtomicBoolean(false);
+
+    texture.setOnFrameAvailableListener((surfaceTexture) -> listenerCallBackCalled.set(true));
+    Canvas canvas = surface.lockHardwareCanvas();
+    surface.unlockCanvasAndPost(canvas);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(listenerCallBackCalled.get()).isTrue();
+  }
+
+  /**
+   * This test simulates what occurs in {@link android.view.View#startDragAndDrop(ClipData,
+   * DragShadowBuilder, Object, int)}..
+   */
+  @Config(minSdk = Q)
+  @Test
+  public void copyFrom_surfaceControl_lockHardwareCavnvas() {
+    SurfaceSession session = new SurfaceSession();
+    SurfaceControl surfaceControl =
+        new SurfaceControl.Builder(session).setBufferSize(100, 100).setName("").build();
+    Surface surface2 = new Surface();
+    surface2.copyFrom(surfaceControl);
+    Canvas canvas = surface2.lockHardwareCanvas();
+    assertThat(canvas).isNotNull();
+    surface2.release();
+  }
+
+  /** Used to expose the finalize method for testing purposes. */
+  static class MySurface extends Surface {
+    @Override
+    protected void finalize() throws Throwable {
+      super.finalize();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceTextureTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceTextureTest.java
new file mode 100644
index 0000000..0fe2ba8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceTextureTest.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Rect;
+import android.graphics.SurfaceTexture;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Surface;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowSurfaceTexture}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowSurfaceTextureTest {
+  private final SurfaceTexture surfaceTexture = new SurfaceTexture(0);
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void surfaceUnlockAndPost_callsBackListener() throws Exception {
+    final AtomicBoolean frameCallback = new AtomicBoolean(false);
+    CountDownLatch latch = new CountDownLatch(1);
+    HandlerThread cbHandlerThread = new HandlerThread("CallBackHandlerThread");
+    cbHandlerThread.start();
+    Handler handler = new Handler(cbHandlerThread.getLooper());
+
+    surfaceTexture.setOnFrameAvailableListener(
+        (texture) -> {
+          frameCallback.set(true);
+          latch.countDown();
+        },
+        handler);
+    Surface surface = new Surface(surfaceTexture);
+    surface.unlockCanvasAndPost(surface.lockCanvas(new Rect()));
+    latch.await();
+
+    assertThat(frameCallback.get()).isTrue();
+    surface.release();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceViewTest.java
new file mode 100644
index 0000000..2105d78
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSurfaceViewTest.java
@@ -0,0 +1,83 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Robolectric.buildActivity;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.Window;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSurfaceViewTest {
+
+  private SurfaceHolder.Callback callback1 = new TestCallback();
+  private SurfaceHolder.Callback callback2 = new TestCallback();
+
+  private SurfaceView view = new SurfaceView(buildActivity(Activity.class).create().get());
+  private SurfaceHolder surfaceHolder = view.getHolder();
+  private ShadowSurfaceView shadowSurfaceView = (ShadowSurfaceView) Shadows.shadowOf(view);
+  private ShadowSurfaceView.FakeSurfaceHolder fakeSurfaceHolder =
+      shadowSurfaceView.getFakeSurfaceHolder();
+
+  @Test
+  public void addCallback() {
+    assertThat(fakeSurfaceHolder.getCallbacks()).isEmpty();
+
+    surfaceHolder.addCallback(callback1);
+
+    assertThat(fakeSurfaceHolder.getCallbacks()).contains(callback1);
+
+    surfaceHolder.addCallback(callback2);
+
+    assertThat(fakeSurfaceHolder.getCallbacks()).contains(callback1);
+    assertThat(fakeSurfaceHolder.getCallbacks()).contains(callback2);
+  }
+
+  @Test
+  public void removeCallback() {
+    surfaceHolder.addCallback(callback1);
+    surfaceHolder.addCallback(callback2);
+
+    assertThat(fakeSurfaceHolder.getCallbacks().size()).isEqualTo(2);
+
+    surfaceHolder.removeCallback(callback1);
+
+    assertThat(fakeSurfaceHolder.getCallbacks()).doesNotContain(callback1);
+    assertThat(fakeSurfaceHolder.getCallbacks()).contains(callback2);
+  }
+
+  @Test
+  public void canCreateASurfaceView_attachedToAWindowWithActionBar() {
+    TestActivity testActivity = buildActivity(TestActivity.class).create().start().resume().visible().get();
+    assertThat(testActivity).isNotNull();
+  }
+
+  private static class TestCallback implements SurfaceHolder.Callback {
+    @Override
+    public void surfaceCreated(SurfaceHolder holder) {
+    }
+
+    @Override
+    public void surfaceDestroyed(SurfaceHolder holder) {
+    }
+
+    @Override
+    public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
+    }
+  }
+
+  private static class TestActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      requestWindowFeature(Window.FEATURE_ACTION_BAR);
+      setContentView(new SurfaceView(this));
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSuspendDialogInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSuspendDialogInfoTest.java
new file mode 100644
index 0000000..38e40f1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSuspendDialogInfoTest.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.pm.SuspendDialogInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowSuspendDialogInfo} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = R)
+public class ShadowSuspendDialogInfoTest {
+  @Test
+  public void getNeutralActionButton_notSet_shouldReturnMoreDetails() {
+    SuspendDialogInfo dialogInfo = new SuspendDialogInfo.Builder().build();
+    ShadowSuspendDialogInfo shadowDialogInfo = Shadow.extract(dialogInfo);
+    assertThat(shadowDialogInfo.getNeutralButtonAction())
+        .isEqualTo(SuspendDialogInfo.BUTTON_ACTION_MORE_DETAILS);
+  }
+
+  @Test
+  public void getNeutralActionButton_setToUnsuspend_shouldReturnUnsuspend() {
+    SuspendDialogInfo dialogInfo =
+        new SuspendDialogInfo.Builder()
+            .setNeutralButtonAction(SuspendDialogInfo.BUTTON_ACTION_UNSUSPEND)
+            .build();
+    ShadowSuspendDialogInfo shadowDialogInfo = Shadow.extract(dialogInfo);
+    assertThat(shadowDialogInfo.getNeutralButtonAction())
+        .isEqualTo(SuspendDialogInfo.BUTTON_ACTION_UNSUSPEND);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSyncResultTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSyncResultTest.java
new file mode 100644
index 0000000..0ef338f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSyncResultTest.java
@@ -0,0 +1,56 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.SyncResult;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSyncResultTest {
+
+  @Test
+  public void testConstructor() {
+    SyncResult result = new SyncResult();
+    assertThat(result.stats).isNotNull();
+  }
+
+  @Test
+  public void hasSoftError() {
+    SyncResult result = new SyncResult();
+    assertFalse(result.hasSoftError());
+    result.stats.numIoExceptions++;
+    assertTrue(result.hasSoftError());
+    assertTrue(result.hasError());
+  }
+
+  @Test
+  public void hasHardError() {
+    SyncResult result = new SyncResult();
+    assertFalse(result.hasHardError());
+    result.stats.numAuthExceptions++;
+    assertTrue(result.hasHardError());
+    assertTrue(result.hasError());
+  }
+
+  @Test
+  public void testMadeSomeProgress() {
+    SyncResult result = new SyncResult();
+    assertFalse(result.madeSomeProgress());
+    result.stats.numInserts++;
+    assertTrue(result.madeSomeProgress());
+  }
+
+  @Test
+  public void testClear() {
+    SyncResult result = new SyncResult();
+    result.moreRecordsToGet = true;
+    result.stats.numInserts++;
+    result.clear();
+    assertFalse(result.moreRecordsToGet);
+    assertThat(result.stats.numInserts).isEqualTo(0L);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSystemPropertiesTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSystemPropertiesTest.java
new file mode 100644
index 0000000..cc73566
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSystemPropertiesTest.java
@@ -0,0 +1,138 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.SystemProperties;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowSystemPropertiesTest {
+
+  @Test
+  public void get() {
+    assertThat(SystemProperties.get("ro.product.device")).isEqualTo("robolectric");
+  }
+
+  @Test
+  public void getWithDefault() {
+    assertThat(SystemProperties.get("foo", "bar")).isEqualTo("bar");
+  }
+
+  @Test
+  public void getBoolean() {
+    ShadowSystemProperties.override("false_1", "0");
+    ShadowSystemProperties.override("false_2", "n");
+    ShadowSystemProperties.override("false_3", "no");
+    ShadowSystemProperties.override("false_4", "off");
+    ShadowSystemProperties.override("false_5", "false");
+    ShadowSystemProperties.override("true_1", "1");
+    ShadowSystemProperties.override("true_2", "y");
+    ShadowSystemProperties.override("true_3", "yes");
+    ShadowSystemProperties.override("true_4", "on");
+    ShadowSystemProperties.override("true_5", "true");
+    ShadowSystemProperties.override("error_value", "error");
+
+    assertThat(SystemProperties.getBoolean("false_1", true)).isFalse();
+    assertThat(SystemProperties.getBoolean("false_2", true)).isFalse();
+    assertThat(SystemProperties.getBoolean("false_3", true)).isFalse();
+    assertThat(SystemProperties.getBoolean("false_4", true)).isFalse();
+    assertThat(SystemProperties.getBoolean("false_5", true)).isFalse();
+    assertThat(SystemProperties.getBoolean("true_1", false)).isTrue();
+    assertThat(SystemProperties.getBoolean("true_2", false)).isTrue();
+    assertThat(SystemProperties.getBoolean("true_3", false)).isTrue();
+    assertThat(SystemProperties.getBoolean("true_4", false)).isTrue();
+    assertThat(SystemProperties.getBoolean("true_5", false)).isTrue();
+    assertThat(SystemProperties.getBoolean("error_value", false)).isFalse();
+    assertThat(SystemProperties.getBoolean("error_value", true)).isTrue();
+  }
+
+  // The following readPropFromJarNotClassPathXX tests check build.prop is loaded from appropriate
+  // android-all jar instead of loading build.prop from classpath aka LATEST_SDK.
+
+  @Test
+  @Config(sdk = 16)
+  public void readPropFromJarNotClassPath16() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(16);
+  }
+
+  @Test
+  @Config(sdk = 17)
+  public void readPropFromJarNotClassPath17() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(17);
+  }
+
+  @Test
+  @Config(sdk = 18)
+  public void readPropFromJarNotClassPath18() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(18);
+  }
+
+  @Test
+  @Config(sdk = 19)
+  public void readPropFromJarNotClassPath19() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(19);
+  }
+
+  @Test
+  @Config(sdk = 21)
+  public void readPropFromJarNotClassPath21() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(21);
+  }
+
+  @Test
+  @Config(sdk = 22)
+  public void readPropFromJarNotClassPath22() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(22);
+  }
+
+  @Test
+  @Config(sdk = 23)
+  public void readPropFromJarNotClassPath23() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(23);
+  }
+
+  @Test
+  @Config(sdk = 24)
+  public void readPropFromJarNotClassPath24() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(24);
+  }
+
+  @Test
+  @Config(sdk = 25)
+  public void readPropFromJarNotClassPath25() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(25);
+  }
+
+  @Test
+  @Config(sdk = 26)
+  public void readPropFromJarNotClassPath26() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(26);
+  }
+
+  @Test
+  @Config(sdk = 27)
+  public void readPropFromJarNotClassPath27() {
+    assertThat(SystemProperties.getInt("ro.build.version.sdk", 0)).isEqualTo(27);
+  }
+
+  @Test
+  public void set() {
+    assertThat(SystemProperties.get("newkey")).isEqualTo("");
+    SystemProperties.set("newkey", "val");
+    assertThat(SystemProperties.get("newkey")).isEqualTo("val");
+    SystemProperties.set("newkey", null);
+    assertThat(SystemProperties.get("newkey")).isEqualTo("");
+  }
+
+  @Test
+  public void override() {
+    assertThat(SystemProperties.get("newkey")).isEqualTo("");
+    ShadowSystemProperties.override("newkey", "val");
+    assertThat(SystemProperties.get("newkey")).isEqualTo("val");
+    ShadowSystemProperties.override("newkey", null);
+    assertThat(SystemProperties.get("newkey")).isEqualTo("");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTabActivityTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTabActivityTest.java
new file mode 100644
index 0000000..86bd9cb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTabActivityTest.java
@@ -0,0 +1,32 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.TabActivity;
+import android.widget.TabHost;
+import android.widget.TabWidget;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTabActivityTest {
+
+  @Test
+  public void tabActivityShouldNotMakeNewTabHostEveryGet() {
+    TabActivity activity = Robolectric.buildActivity(TabActivity.class).create().get();
+    TabHost tabHost1 = activity.getTabHost();
+    TabHost tabHost2 = activity.getTabHost();
+
+    assertThat(tabHost1).isEqualTo(tabHost2);
+  }
+
+  @Test
+  public void shouldGetTabWidget() {
+    TabActivity activity = Robolectric.buildActivity(TabActivity.class).create().get();
+    activity.setContentView(R.layout.tab_activity);
+    assertThat(activity.getTabWidget()).isInstanceOf(TabWidget.class);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTabHostTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTabHostTest.java
new file mode 100644
index 0000000..b9501f1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTabHostTest.java
@@ -0,0 +1,214 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNull;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.TabActivity;
+import android.view.View;
+import android.widget.TabHost;
+import android.widget.TabWidget;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTabHostTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void newTabSpec_shouldMakeATabSpec() {
+    TabHost tabHost = new TabHost(context);
+    TabHost.TabSpec tabSpec = tabHost.newTabSpec("Foo");
+    assertThat(tabSpec.getTag()).isEqualTo("Foo");
+  }
+
+  @Test
+  public void shouldAddTabsToLayoutWhenAddedToHost() {
+    TabHost tabHost = new TabHost(context);
+
+    View fooView = new View(context);
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo").setIndicator(fooView);
+
+    View barView = new View(context);
+    TabHost.TabSpec bar = tabHost.newTabSpec("Bar").setIndicator(barView);
+
+    tabHost.addTab(foo);
+    tabHost.addTab(bar);
+
+    assertThat(tabHost.getChildAt(0)).isSameInstanceAs(fooView);
+    assertThat(tabHost.getChildAt(1)).isSameInstanceAs(barView);
+  }
+
+  @Test
+  public void shouldReturnTabSpecsByTag() {
+    TabHost tabHost = new TabHost(context);
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo");
+    TabHost.TabSpec bar = tabHost.newTabSpec("Bar");
+    TabHost.TabSpec baz = tabHost.newTabSpec("Baz");
+
+    tabHost.addTab(foo);
+    tabHost.addTab(bar);
+    tabHost.addTab(baz);
+
+    assertThat(shadowOf(tabHost).getSpecByTag("Bar")).isSameInstanceAs(bar);
+    assertThat(shadowOf(tabHost).getSpecByTag("Baz")).isSameInstanceAs(baz);
+    assertNull(shadowOf(tabHost).getSpecByTag("Whammie"));
+  }
+
+  @Test
+  public void shouldFireTheTabChangeListenerWhenCurrentTabIsSet() {
+    TabHost tabHost = new TabHost(context);
+
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo");
+    TabHost.TabSpec bar = tabHost.newTabSpec("Bar");
+    TabHost.TabSpec baz = tabHost.newTabSpec("Baz");
+
+    tabHost.addTab(foo);
+    tabHost.addTab(bar);
+    tabHost.addTab(baz);
+
+    TestOnTabChangeListener listener = new TestOnTabChangeListener();
+    tabHost.setOnTabChangedListener(listener);
+
+    tabHost.setCurrentTab(2);
+
+    assertThat(listener.tag).isEqualTo("Baz");
+  }
+
+  @Test
+  public void shouldFireTheTabChangeListenerWhenTheCurrentTabIsSetByTag() {
+    TabHost tabHost = new TabHost(context);
+
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo");
+    TabHost.TabSpec bar = tabHost.newTabSpec("Bar");
+    TabHost.TabSpec baz = tabHost.newTabSpec("Baz");
+
+    tabHost.addTab(foo);
+    tabHost.addTab(bar);
+    tabHost.addTab(baz);
+
+    TestOnTabChangeListener listener = new TestOnTabChangeListener();
+    tabHost.setOnTabChangedListener(listener);
+
+    tabHost.setCurrentTabByTag("Bar");
+
+    assertThat(listener.tag).isEqualTo("Bar");
+  }
+
+  @Test
+  public void shouldRetrieveTheCurrentViewFromTabContentFactory() {
+    TabHost tabHost = new TabHost(context);
+
+    TabHost.TabSpec foo =
+        tabHost
+            .newTabSpec("Foo")
+            .setContent(
+                tag -> {
+                  TextView tv = new TextView(context);
+                  tv.setText("The Text of " + tag);
+                  return tv;
+                });
+
+    tabHost.addTab(foo);
+    tabHost.setCurrentTabByTag("Foo");
+    TextView textView = (TextView) tabHost.getCurrentView();
+
+    assertThat(textView.getText().toString()).isEqualTo("The Text of Foo");
+  }
+  @Test
+  public void shouldRetrieveTheCurrentViewFromViewId() {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    activity.setContentView(org.robolectric.R.layout.main);
+    TabHost tabHost = new TabHost(activity);
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo")
+    .setContent(org.robolectric.R.id.title);
+
+     tabHost.addTab(foo);
+     tabHost.setCurrentTabByTag("Foo");
+     TextView textView = (TextView) tabHost.getCurrentView();
+
+    assertThat(textView.getText().toString()).isEqualTo("Main Layout");
+  }
+
+  private static class TestOnTabChangeListener implements TabHost.OnTabChangeListener {
+    private String tag;
+
+    @Override
+    public void onTabChanged(String tag) {
+      this.tag = tag;
+    }
+  }
+
+  @Test
+  public void canGetCurrentTabTag() {
+    TabHost tabHost = new TabHost(context);
+
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo");
+    TabHost.TabSpec bar = tabHost.newTabSpec("Bar");
+    TabHost.TabSpec baz = tabHost.newTabSpec("Baz");
+
+    tabHost.addTab(foo);
+    tabHost.addTab(bar);
+    tabHost.addTab(baz);
+
+    tabHost.setCurrentTabByTag("Bar");
+
+    assertThat(tabHost.getCurrentTabTag()).isEqualTo("Bar");
+  }
+
+  @Test
+  public void canGetCurrentTab() {
+    TabHost tabHost = new TabHost(context);
+
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo");
+    TabHost.TabSpec bar = tabHost.newTabSpec("Bar");
+    TabHost.TabSpec baz = tabHost.newTabSpec("Baz");
+
+    tabHost.addTab(foo);
+    tabHost.addTab(bar);
+    tabHost.addTab(baz);
+    assertThat(shadowOf(tabHost).getCurrentTabSpec()).isEqualTo(foo);
+    assertThat(tabHost.getCurrentTab()).isEqualTo(0);
+    tabHost.setCurrentTabByTag("Bar");
+    assertThat(tabHost.getCurrentTab()).isEqualTo(1);
+    assertThat(shadowOf(tabHost).getCurrentTabSpec()).isEqualTo(bar);
+    tabHost.setCurrentTabByTag("Foo");
+    assertThat(tabHost.getCurrentTab()).isEqualTo(0);
+    assertThat(shadowOf(tabHost).getCurrentTabSpec()).isEqualTo(foo);
+    tabHost.setCurrentTabByTag("Baz");
+    assertThat(tabHost.getCurrentTab()).isEqualTo(2);
+    assertThat(shadowOf(tabHost).getCurrentTabSpec()).isEqualTo(baz);
+  }
+
+  @Test
+  public void setCurrentTabByTagShouldAcceptNullAsParameter() {
+    TabHost tabHost = new TabHost(context);
+    TabHost.TabSpec foo = tabHost.newTabSpec("Foo");
+    tabHost.addTab(foo);
+
+    tabHost.setCurrentTabByTag(null);
+    assertThat(tabHost.getCurrentTabTag()).isEqualTo("Foo");
+  }
+
+  @Test
+  public void shouldGetTabWidget() {
+    TabActivity activity = Robolectric.buildActivity(TabActivity.class).create().get();
+    activity.setContentView(R.layout.tab_activity);
+    TabHost host = new TabHost(activity);
+    assertThat(host.getTabWidget()).isInstanceOf(TabWidget.class);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTabSpecTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTabSpecTest.java
new file mode 100644
index 0000000..0cb4f61
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTabSpecTest.java
@@ -0,0 +1,122 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.Intent;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.widget.TabHost;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTabSpecTest {
+  Drawable icon1;
+  private Application context;
+
+  @Before
+  public void init() {
+    icon1 = new TestIcon();
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void shouldGetAndSetTheIndicator() {
+    TabHost.TabSpec spec = new TabHost(context).newTabSpec("foo");
+    View view = new View(context);
+    TabHost.TabSpec self = spec.setIndicator(view);
+    assertThat(self).isSameInstanceAs(spec);
+    assertThat(shadowOf(spec).getIndicatorAsView()).isSameInstanceAs(view);
+  }
+
+  @Test
+  public void shouldGetAndSetTheIntentContent() {
+    TabHost.TabSpec spec = new TabHost(context).newTabSpec("foo");
+    Intent intent = new Intent();
+    TabHost.TabSpec self = spec.setContent(intent);
+    assertThat(self).isSameInstanceAs(spec);
+    assertThat(shadowOf(spec).getContentAsIntent()).isSameInstanceAs(intent);
+  }
+
+  @Test
+  public void shouldGetAndSetTheIndicatorLabel() {
+    TabHost.TabSpec spec =
+        new TabHost(context).newTabSpec("foo").setContent(R.layout.main).setIndicator("labelText");
+
+    assertThat(shadowOf(spec).getIndicatorLabel()).isEqualTo("labelText");
+    assertThat(shadowOf(spec).getText()).isEqualTo("labelText");
+  }
+
+  @Test
+  public void shouldGetAndSetTheIndicatorLabelAndIcon() {
+    TabHost.TabSpec spec =
+        new TabHost(context)
+            .newTabSpec("foo")
+            .setContent(R.layout.main)
+            .setIndicator("labelText", icon1);
+
+    assertThat(shadowOf(spec).getIndicatorLabel()).isEqualTo("labelText");
+    assertThat(shadowOf(spec).getText()).isEqualTo("labelText");
+    assertThat(shadowOf(spec).getIndicatorIcon()).isSameInstanceAs(icon1);
+  }
+
+  @Test
+  public void shouldSetTheContentView() {
+    TabHost.TabSpec foo =
+        new TabHost(context)
+            .newTabSpec("Foo")
+            .setContent(
+                tag -> {
+                  TextView tv = new TextView(context);
+                  tv.setText("The Text of " + tag);
+                  return tv;
+                });
+
+    ShadowTabHost.ShadowTabSpec shadowFoo = shadowOf(foo);
+    TextView textView = (TextView) shadowFoo.getContentView();
+
+
+    assertThat(textView.getText().toString()).isEqualTo("The Text of Foo");
+  }
+
+  @Test
+  public void shouldSetTheContentViewId() {
+    TabHost.TabSpec foo = new TabHost(context).newTabSpec("Foo").setContent(R.id.title);
+
+    ShadowTabHost.ShadowTabSpec shadowFoo = shadowOf(foo);
+    int viewId = shadowFoo.getContentViewId();
+
+    assertThat(viewId).isEqualTo(R.id.title);
+  }
+
+  private static class TestIcon extends Drawable {
+
+    @Override
+    public void draw(Canvas canvas) {
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+    }
+
+    @Override
+    public int getOpacity() {
+      return 0;
+    }
+
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelecomManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelecomManagerTest.java
new file mode 100644
index 0000000..67e4746
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelecomManagerTest.java
@@ -0,0 +1,544 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.net.Uri;
+import android.os.Bundle;
+import android.telecom.ConnectionRequest;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.telecom.VideoProfile;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowTelecomManager.CallRequestMode;
+import org.robolectric.shadows.testing.TestConnectionService;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowTelecomManagerTest {
+
+  @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();
+
+  @Mock TestConnectionService.Listener connectionServiceListener;
+
+  private TelecomManager telecomService;
+  private Context context;
+
+  @Before
+  public void setUp() {
+    telecomService =
+        (TelecomManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.TELECOM_SERVICE);
+    TestConnectionService.setListener(connectionServiceListener);
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void getSimCallManager() {
+    PhoneAccountHandle handle = createHandle("id");
+
+    shadowOf(telecomService).setSimCallManager(handle);
+
+    assertThat(telecomService.getConnectionManager().getId()).isEqualTo("id");
+  }
+
+  @Test
+  public void registerAndUnRegister() {
+    assertThat(shadowOf(telecomService).getAllPhoneAccountsCount()).isEqualTo(0);
+    assertThat(shadowOf(telecomService).getAllPhoneAccounts()).hasSize(0);
+
+    PhoneAccountHandle handler = createHandle("id");
+    PhoneAccount phoneAccount = PhoneAccount.builder(handler, "main_account").build();
+    telecomService.registerPhoneAccount(phoneAccount);
+
+    assertThat(shadowOf(telecomService).getAllPhoneAccountsCount()).isEqualTo(1);
+    assertThat(shadowOf(telecomService).getAllPhoneAccounts()).hasSize(1);
+    assertThat(telecomService.getAllPhoneAccountHandles()).hasSize(1);
+    assertThat(telecomService.getAllPhoneAccountHandles()).contains(handler);
+    assertThat(telecomService.getPhoneAccount(handler).getLabel().toString())
+        .isEqualTo(phoneAccount.getLabel().toString());
+
+    telecomService.unregisterPhoneAccount(handler);
+
+    assertThat(shadowOf(telecomService).getAllPhoneAccountsCount()).isEqualTo(0);
+    assertThat(shadowOf(telecomService).getAllPhoneAccounts()).hasSize(0);
+    assertThat(telecomService.getAllPhoneAccountHandles()).hasSize(0);
+  }
+
+  @Test
+  public void clearAccounts() {
+    PhoneAccountHandle anotherPackageHandle =
+        createHandle("some.other.package", "OtherConnectionService", "id");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(anotherPackageHandle, "another_package").build());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void clearAccountsForPackage() {
+    PhoneAccountHandle accountHandle1 = createHandle("a.package", "OtherConnectionService", "id1");
+    telecomService.registerPhoneAccount(PhoneAccount.builder(accountHandle1, "another_package")
+        .build());
+
+    PhoneAccountHandle accountHandle2 =
+        createHandle("some.other.package", "OtherConnectionService", "id2");
+    telecomService.registerPhoneAccount(PhoneAccount.builder(accountHandle2, "another_package")
+        .build());
+
+    telecomService.clearAccountsForPackage(accountHandle1.getComponentName().getPackageName());
+
+    assertThat(telecomService.getPhoneAccount(accountHandle1)).isNull();
+    assertThat(telecomService.getPhoneAccount(accountHandle2)).isNotNull();
+  }
+
+  @Test
+  public void getPhoneAccountsSupportingScheme() {
+    PhoneAccountHandle handleMatchingScheme = createHandle("id1");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(handleMatchingScheme, "some_scheme")
+            .addSupportedUriScheme("some_scheme")
+            .build());
+    PhoneAccountHandle handleNotMatchingScheme = createHandle("id2");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(handleNotMatchingScheme, "another_scheme")
+            .addSupportedUriScheme("another_scheme")
+            .build());
+
+    List<PhoneAccountHandle> actual =
+        telecomService.getPhoneAccountsSupportingScheme("some_scheme");
+
+    assertThat(actual).contains(handleMatchingScheme);
+    assertThat(actual).doesNotContain(handleNotMatchingScheme);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getCallCapablePhoneAccounts() {
+    PhoneAccountHandle callCapableHandle = createHandle("id1");
+    telecomService.registerPhoneAccount(PhoneAccount.builder(callCapableHandle, "enabled")
+        .setIsEnabled(true)
+        .build());
+    PhoneAccountHandle notCallCapableHandler = createHandle("id2");
+    telecomService.registerPhoneAccount(PhoneAccount.builder(notCallCapableHandler, "disabled")
+        .setIsEnabled(false)
+        .build());
+
+    List<PhoneAccountHandle> callCapablePhoneAccounts = telecomService.getCallCapablePhoneAccounts();
+    assertThat(callCapablePhoneAccounts).contains(callCapableHandle);
+    assertThat(callCapablePhoneAccounts).doesNotContain(notCallCapableHandler);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getSelfManagedPhoneAccounts() {
+    PhoneAccountHandle selfManagedPhoneAccountHandle = createHandle("id1");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(selfManagedPhoneAccountHandle, "self-managed")
+            .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)
+            .build());
+    PhoneAccountHandle nonSelfManagedPhoneAccountHandle = createHandle("id2");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(nonSelfManagedPhoneAccountHandle, "not-self-managed").build());
+
+    List<PhoneAccountHandle> selfManagedPhoneAccounts =
+        telecomService.getSelfManagedPhoneAccounts();
+    assertThat(selfManagedPhoneAccounts).containsExactly(selfManagedPhoneAccountHandle);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void getPhoneAccountsForPackage() {
+    PhoneAccountHandle handleInThisApplicationsPackage = createHandle("id1");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(handleInThisApplicationsPackage, "this_package").build());
+
+    PhoneAccountHandle anotherPackageHandle =
+        createHandle("some.other.package", "OtherConnectionService", "id2");
+    telecomService.registerPhoneAccount(
+        PhoneAccount.builder(anotherPackageHandle, "another_package").build());
+
+    List<PhoneAccountHandle> phoneAccountsForPackage = telecomService.getPhoneAccountsForPackage();
+
+    assertThat(phoneAccountsForPackage).contains(handleInThisApplicationsPackage);
+    assertThat(phoneAccountsForPackage).doesNotContain(anotherPackageHandle);
+  }
+
+  @Test
+  public void testAddNewIncomingCall() {
+    telecomService.addNewIncomingCall(createHandle("id"), null);
+
+    assertThat(shadowOf(telecomService).getAllIncomingCalls()).hasSize(1);
+    assertThat(shadowOf(telecomService).getLastIncomingCall()).isNotNull();
+    assertThat(shadowOf(telecomService).getOnlyIncomingCall()).isNotNull();
+  }
+
+  @Test
+  public void testAllowNewIncomingCall() {
+    shadowOf(telecomService).setCallRequestMode(CallRequestMode.ALLOW_ALL);
+
+    Uri address = Uri.parse("tel:+1-201-555-0123");
+    PhoneAccountHandle phoneAccount = createHandle("id");
+    Bundle extras = new Bundle();
+    extras.putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, address);
+    extras.putInt(
+        TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_BIDIRECTIONAL);
+    extras.putString("TEST_EXTRA_KEY", "TEST_EXTRA_VALUE");
+    telecomService.addNewIncomingCall(createHandle("id"), extras);
+
+    ArgumentCaptor<ConnectionRequest> requestCaptor =
+        ArgumentCaptor.forClass(ConnectionRequest.class);
+    verify(connectionServiceListener)
+        .onCreateIncomingConnection(eq(phoneAccount), requestCaptor.capture());
+    verifyNoMoreInteractions(connectionServiceListener);
+
+    ConnectionRequest request = requestCaptor.getValue();
+    assertThat(request.getAccountHandle()).isEqualTo(phoneAccount);
+    assertThat(request.getExtras().getString("TEST_EXTRA_KEY")).isEqualTo("TEST_EXTRA_VALUE");
+    assertThat(request.getAddress()).isEqualTo(address);
+    assertThat(request.getVideoState()).isEqualTo(VideoProfile.STATE_BIDIRECTIONAL);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void testDenyNewIncomingCall() {
+    shadowOf(telecomService).setCallRequestMode(CallRequestMode.DENY_ALL);
+
+    Uri address = Uri.parse("tel:+1-201-555-0123");
+    PhoneAccountHandle phoneAccount = createHandle("id");
+    Bundle extras = new Bundle();
+    extras.putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, address);
+    extras.putInt(
+        TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_BIDIRECTIONAL);
+    extras.putString("TEST_EXTRA_KEY", "TEST_EXTRA_VALUE");
+    telecomService.addNewIncomingCall(createHandle("id"), extras);
+
+    ArgumentCaptor<ConnectionRequest> requestCaptor =
+        ArgumentCaptor.forClass(ConnectionRequest.class);
+    verify(connectionServiceListener)
+        .onCreateIncomingConnectionFailed(eq(phoneAccount), requestCaptor.capture());
+    verifyNoMoreInteractions(connectionServiceListener);
+
+    ConnectionRequest request = requestCaptor.getValue();
+    assertThat(request.getAccountHandle()).isEqualTo(phoneAccount);
+    assertThat(request.getExtras().getString("TEST_EXTRA_KEY")).isEqualTo("TEST_EXTRA_VALUE");
+    assertThat(request.getAddress()).isEqualTo(address);
+    assertThat(request.getVideoState()).isEqualTo(VideoProfile.STATE_BIDIRECTIONAL);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testPlaceCall() {
+    Bundle extras = new Bundle();
+    extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, createHandle("id"));
+    telecomService.placeCall(Uri.parse("tel:+1-201-555-0123"), extras);
+
+    assertThat(shadowOf(telecomService).getAllOutgoingCalls()).hasSize(1);
+    assertThat(shadowOf(telecomService).getLastOutgoingCall()).isNotNull();
+    assertThat(shadowOf(telecomService).getOnlyOutgoingCall()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testAllowPlaceCall() {
+    shadowOf(telecomService).setCallRequestMode(CallRequestMode.ALLOW_ALL);
+
+    Uri address = Uri.parse("tel:+1-201-555-0123");
+    PhoneAccountHandle phoneAccount = createHandle("id");
+    Bundle outgoingCallExtras = new Bundle();
+    outgoingCallExtras.putString("TEST_EXTRA_KEY", "TEST_EXTRA_VALUE");
+    Bundle extras = new Bundle();
+    extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccount);
+    extras.putInt(
+        TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_BIDIRECTIONAL);
+    extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingCallExtras);
+    telecomService.placeCall(address, extras);
+
+    ArgumentCaptor<ConnectionRequest> requestCaptor =
+        ArgumentCaptor.forClass(ConnectionRequest.class);
+    verify(connectionServiceListener)
+        .onCreateOutgoingConnection(eq(phoneAccount), requestCaptor.capture());
+    verifyNoMoreInteractions(connectionServiceListener);
+
+    ConnectionRequest request = requestCaptor.getValue();
+    assertThat(request.getAccountHandle()).isEqualTo(phoneAccount);
+    assertThat(request.getExtras().getString("TEST_EXTRA_KEY")).isEqualTo("TEST_EXTRA_VALUE");
+    assertThat(request.getAddress()).isEqualTo(address);
+    assertThat(request.getVideoState()).isEqualTo(VideoProfile.STATE_BIDIRECTIONAL);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void testDenyPlaceCall() {
+    shadowOf(telecomService).setCallRequestMode(CallRequestMode.DENY_ALL);
+
+    Uri address = Uri.parse("tel:+1-201-555-0123");
+    PhoneAccountHandle phoneAccount = createHandle("id");
+    Bundle outgoingCallExtras = new Bundle();
+    outgoingCallExtras.putString("TEST_EXTRA_KEY", "TEST_EXTRA_VALUE");
+    Bundle extras = new Bundle();
+    extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccount);
+    extras.putInt(
+        TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_BIDIRECTIONAL);
+    extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingCallExtras);
+    telecomService.placeCall(address, extras);
+
+    ArgumentCaptor<ConnectionRequest> requestCaptor =
+        ArgumentCaptor.forClass(ConnectionRequest.class);
+    verify(connectionServiceListener)
+        .onCreateOutgoingConnectionFailed(eq(phoneAccount), requestCaptor.capture());
+    verifyNoMoreInteractions(connectionServiceListener);
+
+    ConnectionRequest request = requestCaptor.getValue();
+    assertThat(request.getAccountHandle()).isEqualTo(phoneAccount);
+    assertThat(request.getExtras().getString("TEST_EXTRA_KEY")).isEqualTo("TEST_EXTRA_VALUE");
+    assertThat(request.getAddress()).isEqualTo(address);
+    assertThat(request.getVideoState()).isEqualTo(VideoProfile.STATE_BIDIRECTIONAL);
+  }
+
+  @Test
+  public void testAddUnknownCall() {
+    telecomService.addNewUnknownCall(createHandle("id"), null);
+
+    assertThat(shadowOf(telecomService).getAllUnknownCalls()).hasSize(1);
+    assertThat(shadowOf(telecomService).getLastUnknownCall()).isNotNull();
+    assertThat(shadowOf(telecomService).getOnlyUnknownCall()).isNotNull();
+  }
+
+  @Test
+  public void testIsRinging_noIncomingOrUnknownCallsAdded_shouldBeFalse() {
+    assertThat(shadowOf(telecomService).isRinging()).isFalse();
+  }
+
+  @Test
+  public void testIsRinging_incomingCallAdded_shouldBeTrue() {
+    telecomService.addNewIncomingCall(createHandle("id"), null);
+
+    assertThat(shadowOf(telecomService).isRinging()).isTrue();
+  }
+
+  @Test
+  public void testIsRinging_unknownCallAdded_shouldBeTrue() {
+    shadowOf(telecomService).addNewUnknownCall(createHandle("id"), null);
+
+    assertThat(shadowOf(telecomService).isRinging()).isTrue();
+  }
+
+  @Test
+  public void testIsRinging_incomingCallAdded_thenRingerSilenced_shouldBeFalse() {
+    telecomService.addNewIncomingCall(createHandle("id"), null);
+    telecomService.silenceRinger();
+
+    assertThat(shadowOf(telecomService).isRinging()).isFalse();
+  }
+
+  @Test
+  public void testIsRinging_unknownCallAdded_thenRingerSilenced_shouldBeFalse() {
+    shadowOf(telecomService).addNewUnknownCall(createHandle("id"), null);
+    telecomService.silenceRinger();
+
+    assertThat(shadowOf(telecomService).isRinging()).isFalse();
+  }
+
+  @Test
+  public void testIsRinging_ringerSilenced_thenIncomingCallAdded_shouldBeTrue() {
+    telecomService.silenceRinger();
+    telecomService.addNewIncomingCall(createHandle("id"), null);
+
+    assertThat(shadowOf(telecomService).isRinging()).isTrue();
+  }
+
+  @Test
+  public void testIsRinging_ringerSilenced_thenUnknownCallAdded_shouldBeTrue() {
+    telecomService.silenceRinger();
+    shadowOf(telecomService).addNewUnknownCall(createHandle("id"), null);
+
+    assertThat(shadowOf(telecomService).isRinging()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setDefaultDialer() {
+    assertThat(telecomService.getDefaultDialerPackage()).isNull();
+    shadowOf(telecomService).setDefaultDialer("some.package");
+    assertThat(telecomService.getDefaultDialerPackage()).isEqualTo("some.package");
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setDefaultDialerPackage() {
+    assertThat(telecomService.getDefaultDialerPackage()).isNull();
+    shadowOf(telecomService).setDefaultDialerPackage("some.package");
+    assertThat(telecomService.getDefaultDialerPackage()).isEqualTo("some.package");
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setSystemDefaultDialerPackage() {
+    assertThat(telecomService.getSystemDialerPackage()).isNull();
+    shadowOf(telecomService).setSystemDialerPackage("some.package");
+    assertThat(telecomService.getSystemDialerPackage()).isEqualTo("some.package");
+  }
+
+  @Test
+  public void setTtySupported() {
+    assertThat(telecomService.isTtySupported()).isFalse();
+    shadowOf(telecomService).setTtySupported(true);
+    assertThat(telecomService.isTtySupported()).isTrue();
+  }
+
+  @Test
+  public void canSetAndGetIsInCall() {
+    shadowOf(telecomService).setIsInCall(true);
+    assertThat(telecomService.isInCall()).isTrue();
+  }
+
+  @Test
+  public void isInCall_setIsInCallNotCalled_shouldReturnFalse() {
+    assertThat(telecomService.isInCall()).isFalse();
+  }
+
+  @Test
+  public void getDefaultOutgoingPhoneAccount() {
+    // Check initial state
+    assertThat(telecomService.getDefaultOutgoingPhoneAccount("abc")).isNull();
+
+    // After setting
+    PhoneAccountHandle phoneAccountHandle = createHandle("id1");
+    shadowOf(telecomService).setDefaultOutgoingPhoneAccount("abc", phoneAccountHandle);
+    assertThat(telecomService.getDefaultOutgoingPhoneAccount("abc")).isEqualTo(phoneAccountHandle);
+
+    // After removing
+    shadowOf(telecomService).removeDefaultOutgoingPhoneAccount("abc");
+    assertThat(telecomService.getDefaultOutgoingPhoneAccount("abc")).isNull();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void createLaunchEmergencyDialerIntent_shouldReturnValidIntent() {
+    Intent intent = telecomService.createLaunchEmergencyDialerIntent(/* number= */ null);
+    assertThat(intent.getAction()).isEqualTo(Intent.ACTION_DIAL_EMERGENCY);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void createLaunchEmergencyDialerIntent_whenPackageAvailable_shouldContainPackage()
+      throws NameNotFoundException {
+    ComponentName componentName = new ComponentName("com.android.phone", "EmergencyDialer");
+    shadowOf(context.getPackageManager()).addActivityIfNotPresent(componentName);
+
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addAction(Intent.ACTION_DIAL_EMERGENCY);
+
+    shadowOf(context.getPackageManager()).addIntentFilterForActivity(componentName, intentFilter);
+
+    Intent intent = telecomService.createLaunchEmergencyDialerIntent(/* number= */ null);
+    assertThat(intent.getAction()).isEqualTo(Intent.ACTION_DIAL_EMERGENCY);
+    assertThat(intent.getPackage()).isEqualTo("com.android.phone");
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void
+      createLaunchEmergencyDialerIntent_whenSetPhoneNumber_shouldReturnValidIntentWithPhoneNumber() {
+    Intent intent = telecomService.createLaunchEmergencyDialerIntent("1234");
+    assertThat(intent.getAction()).isEqualTo(Intent.ACTION_DIAL_EMERGENCY);
+    Uri uri = intent.getData();
+    assertThat(uri.toString()).isEqualTo("tel:1234");
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getUserSelectedOutgoingPhoneAccount() {
+    // Check initial state
+    assertThat(telecomService.getUserSelectedOutgoingPhoneAccount()).isNull();
+
+    // Set a phone account and verify
+    PhoneAccountHandle phoneAccountHandle = createHandle("id1");
+    shadowOf(telecomService).setUserSelectedOutgoingPhoneAccount(phoneAccountHandle);
+    assertThat(telecomService.getUserSelectedOutgoingPhoneAccount()).isEqualTo(phoneAccountHandle);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void testSetManageBlockNumbersIntent() {
+    // Check initial state
+    Intent targetIntent = telecomService.createManageBlockedNumbersIntent();
+    assertThat(targetIntent).isNull();
+
+    // Set intent and verify
+    Intent initialIntent = new Intent();
+    shadowOf(telecomService).setManageBlockNumbersIntent(initialIntent);
+
+    targetIntent = telecomService.createManageBlockedNumbersIntent();
+    assertThat(initialIntent).isEqualTo(targetIntent);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isVoicemailNumber() {
+    // Check initial state
+    PhoneAccountHandle phoneAccountHandle = createHandle("id1");
+    assertThat(telecomService.isVoiceMailNumber(phoneAccountHandle, "123")).isFalse();
+
+    // After setting
+    shadowOf(telecomService).setVoicemailNumber(phoneAccountHandle, "123");
+    assertThat(telecomService.isVoiceMailNumber(phoneAccountHandle, "123")).isTrue();
+
+    // After reset
+    shadowOf(telecomService).setVoicemailNumber(phoneAccountHandle, null);
+    assertThat(telecomService.isVoiceMailNumber(phoneAccountHandle, "123")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getVoicemailNumber() {
+    // Check initial state
+    PhoneAccountHandle phoneAccountHandle = createHandle("id1");
+    assertThat(telecomService.getVoiceMailNumber(phoneAccountHandle)).isNull();
+
+    // After setting
+    shadowOf(telecomService).setVoicemailNumber(phoneAccountHandle, "123");
+    assertThat(telecomService.getVoiceMailNumber(phoneAccountHandle)).isEqualTo("123");
+
+    // After reset
+    shadowOf(telecomService).setVoicemailNumber(phoneAccountHandle, null);
+    assertThat(telecomService.getVoiceMailNumber(phoneAccountHandle)).isNull();
+  }
+
+  private static PhoneAccountHandle createHandle(String id) {
+    return new PhoneAccountHandle(
+        new ComponentName(ApplicationProvider.getApplicationContext(), TestConnectionService.class),
+        id);
+  }
+
+  private static PhoneAccountHandle createHandle(String packageName, String className, String id) {
+    return new PhoneAccountHandle(new ComponentName(packageName, className), id);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java
new file mode 100644
index 0000000..5f6d6b8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java
@@ -0,0 +1,1033 @@
+package org.robolectric.shadows;
+
+import static android.content.Context.TELEPHONY_SERVICE;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.telephony.PhoneStateListener.LISTEN_CALL_STATE;
+import static android.telephony.PhoneStateListener.LISTEN_CELL_INFO;
+import static android.telephony.PhoneStateListener.LISTEN_CELL_LOCATION;
+import static android.telephony.PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED;
+import static android.telephony.PhoneStateListener.LISTEN_SERVICE_STATE;
+import static android.telephony.PhoneStateListener.LISTEN_SIGNAL_STRENGTHS;
+import static android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA;
+import static android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE;
+import static android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA;
+import static android.telephony.TelephonyManager.CALL_COMPOSER_STATUS_ON;
+import static android.telephony.TelephonyManager.CALL_STATE_IDLE;
+import static android.telephony.TelephonyManager.CALL_STATE_OFFHOOK;
+import static android.telephony.TelephonyManager.CALL_STATE_RINGING;
+import static android.telephony.TelephonyManager.NETWORK_TYPE_EVDO_0;
+import static android.telephony.TelephonyManager.NETWORK_TYPE_LTE;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.withSettings;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowTelephonyManager.createTelephonyDisplayInfo;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.PersistableBundle;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.CellInfo;
+import android.telephony.CellLocation;
+import android.telephony.PhoneStateListener;
+import android.telephony.ServiceState;
+import android.telephony.SignalStrength;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyCallback;
+import android.telephony.TelephonyCallback.CallStateListener;
+import android.telephony.TelephonyCallback.CellInfoListener;
+import android.telephony.TelephonyCallback.CellLocationListener;
+import android.telephony.TelephonyCallback.DisplayInfoListener;
+import android.telephony.TelephonyCallback.ServiceStateListener;
+import android.telephony.TelephonyCallback.SignalStrengthsListener;
+import android.telephony.TelephonyDisplayInfo;
+import android.telephony.TelephonyManager;
+import android.telephony.TelephonyManager.AuthenticationFailureReason;
+import android.telephony.TelephonyManager.BootstrapAuthenticationCallback;
+import android.telephony.TelephonyManager.CellInfoCallback;
+import android.telephony.UiccSlotInfo;
+import android.telephony.VisualVoicemailSmsFilterSettings;
+import android.telephony.gba.UaSecurityProtocolIdentifier;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN)
+public class ShadowTelephonyManagerTest {
+
+  private TelephonyManager telephonyManager;
+  private ShadowTelephonyManager shadowTelephonyManager;
+
+  @Before
+  public void setUp() throws Exception {
+    telephonyManager = (TelephonyManager) getApplication().getSystemService(TELEPHONY_SERVICE);
+    shadowTelephonyManager = Shadow.extract(telephonyManager);
+  }
+
+  @Test
+  public void testListenInit() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    telephonyManager.listen(listener, LISTEN_CALL_STATE | LISTEN_CELL_INFO | LISTEN_CELL_LOCATION);
+
+    verify(listener).onCallStateChanged(CALL_STATE_IDLE, null);
+    verify(listener).onCellLocationChanged(null);
+    if (VERSION.SDK_INT >= JELLY_BEAN_MR1) {
+      verify(listener).onCellInfoChanged(Collections.emptyList());
+    }
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void registerTelephonyCallback_shouldInitCallback() {
+    TelephonyCallback callback =
+        mock(
+            TelephonyCallback.class,
+            withSettings()
+                .extraInterfaces(
+                    CallStateListener.class, CellInfoListener.class, CellLocationListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    assertThat(shadowTelephonyManager.getLastTelephonyCallback()).isEqualTo(callback);
+    verify((CallStateListener) callback).onCallStateChanged(CALL_STATE_IDLE);
+    verify((CellInfoListener) callback).onCellInfoChanged(ImmutableList.of());
+    verify((CellLocationListener) callback).onCellLocationChanged(null);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void unregisterTelephonyCallback_shouldRemoveCallback() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(CallStateListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+    reset(callback);
+    telephonyManager.unregisterTelephonyCallback(callback);
+
+    shadowOf(telephonyManager).setCallState(CALL_STATE_RINGING, "123");
+
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  public void shouldGiveDeviceId() {
+    String testId = "TESTING123";
+    shadowOf(telephonyManager).setDeviceId(testId);
+    assertEquals(testId, telephonyManager.getDeviceId());
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldGiveDeviceIdForSlot() {
+    shadowOf(telephonyManager).setDeviceId(1, "device in slot 1");
+    shadowOf(telephonyManager).setDeviceId(2, "device in slot 2");
+
+    assertEquals("device in slot 1", telephonyManager.getDeviceId(1));
+    assertEquals("device in slot 2", telephonyManager.getDeviceId(2));
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getImei() {
+    String testImei = "4test imei";
+    shadowOf(telephonyManager).setImei(testImei);
+    assertEquals(testImei, telephonyManager.getImei());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getImeiForSlot() {
+    shadowOf(telephonyManager).setImei("defaultImei");
+    shadowOf(telephonyManager).setImei(0, "imei0");
+    shadowOf(telephonyManager).setImei(1, "imei1");
+    assertEquals("imei0", telephonyManager.getImei(0));
+    assertEquals("imei1", telephonyManager.getImei(1));
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getMeid() {
+    String testMeid = "4test meid";
+    shadowOf(telephonyManager).setMeid(testMeid);
+    assertEquals(testMeid, telephonyManager.getMeid());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void getMeidForSlot() {
+    shadowOf(telephonyManager).setMeid("defaultMeid");
+    shadowOf(telephonyManager).setMeid(0, "meid0");
+    shadowOf(telephonyManager).setMeid(1, "meid1");
+    assertEquals("meid0", telephonyManager.getMeid(0));
+    assertEquals("meid1", telephonyManager.getMeid(1));
+  }
+
+  @Test
+  public void shouldGiveNetworkOperatorName() {
+    shadowOf(telephonyManager).setNetworkOperatorName("SomeOperatorName");
+    assertEquals("SomeOperatorName", telephonyManager.getNetworkOperatorName());
+  }
+
+  @Test
+  public void shouldGiveSimOperatorName() {
+    shadowOf(telephonyManager).setSimOperatorName("SomeSimOperatorName");
+    assertEquals("SomeSimOperatorName", telephonyManager.getSimOperatorName());
+  }
+
+  @Test(expected = SecurityException.class)
+  public void
+      getSimSerialNumber_shouldThrowSecurityExceptionWhenReadPhoneStatePermissionNotGranted()
+          throws Exception {
+    shadowOf(telephonyManager).setReadPhoneStatePermission(false);
+    telephonyManager.getSimSerialNumber();
+  }
+
+  @Test
+  public void shouldGetSimSerialNumber() {
+    shadowOf(telephonyManager).setSimSerialNumber("SomeSerialNumber");
+    assertEquals("SomeSerialNumber", telephonyManager.getSimSerialNumber());
+  }
+
+  @Test
+  public void shouldGiveNetworkType() {
+    shadowOf(telephonyManager).setNetworkType(TelephonyManager.NETWORK_TYPE_CDMA);
+    assertEquals(TelephonyManager.NETWORK_TYPE_CDMA, telephonyManager.getNetworkType());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void shouldGiveDataNetworkType() {
+    shadowOf(telephonyManager).setDataNetworkType(TelephonyManager.NETWORK_TYPE_CDMA);
+    assertEquals(TelephonyManager.NETWORK_TYPE_CDMA, telephonyManager.getDataNetworkType());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void shouldGiveVoiceNetworkType() {
+    shadowOf(telephonyManager).setVoiceNetworkType(TelephonyManager.NETWORK_TYPE_CDMA);
+    assertThat(telephonyManager.getVoiceNetworkType())
+        .isEqualTo(TelephonyManager.NETWORK_TYPE_CDMA);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldGiveAllCellInfo() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    telephonyManager.listen(listener, LISTEN_CELL_INFO);
+
+    List<CellInfo> allCellInfo = Collections.singletonList(mock(CellInfo.class));
+    shadowOf(telephonyManager).setAllCellInfo(allCellInfo);
+    assertEquals(allCellInfo, telephonyManager.getAllCellInfo());
+    verify(listener).onCellInfoChanged(allCellInfo);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void shouldGiveAllCellInfo_toCallback() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(CellInfoListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    List<CellInfo> allCellInfo = Collections.singletonList(mock(CellInfo.class));
+    shadowOf(telephonyManager).setAllCellInfo(allCellInfo);
+    assertEquals(allCellInfo, telephonyManager.getAllCellInfo());
+    verify((CellInfoListener) callback).onCellInfoChanged(allCellInfo);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void shouldGiveCellInfoUpdate() throws Exception {
+    List<CellInfo> callbackCellInfo = Collections.singletonList(mock(CellInfo.class));
+    shadowOf(telephonyManager).setCallbackCellInfos(callbackCellInfo);
+    assertNotEquals(callbackCellInfo, telephonyManager.getAllCellInfo());
+
+    CountDownLatch callbackLatch = new CountDownLatch(1);
+    shadowOf(telephonyManager)
+        .requestCellInfoUpdate(
+            new Executor() {
+              @Override
+              public void execute(Runnable r) {
+                r.run();
+              }
+            },
+            new CellInfoCallback() {
+              @Override
+              public void onCellInfo(List<CellInfo> list) {
+                assertEquals(callbackCellInfo, list);
+                callbackLatch.countDown();
+              }
+            });
+
+    assertTrue(callbackLatch.await(5000, TimeUnit.MILLISECONDS));
+  }
+
+  @Test
+  public void shouldGiveNetworkCountryIsoInLowercase() {
+    shadowOf(telephonyManager).setNetworkCountryIso("SomeIso");
+    assertEquals("someiso", telephonyManager.getNetworkCountryIso());
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void shouldGiveSimLocale() {
+    shadowOf(telephonyManager).setSimLocale(Locale.FRANCE);
+    assertEquals(Locale.FRANCE, telephonyManager.getSimLocale());
+  }
+
+  @Test
+  public void shouldGiveNetworkOperator() {
+    shadowOf(telephonyManager).setNetworkOperator("SomeOperator");
+    assertEquals("SomeOperator", telephonyManager.getNetworkOperator());
+  }
+
+  @Test
+  public void shouldGiveLine1Number() {
+    shadowOf(telephonyManager).setLine1Number("123-244-2222");
+    assertEquals("123-244-2222", telephonyManager.getLine1Number());
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void shouldGiveGroupIdLevel1() {
+    shadowOf(telephonyManager).setGroupIdLevel1("SomeGroupId");
+    assertEquals("SomeGroupId", telephonyManager.getGroupIdLevel1());
+  }
+
+  @Test(expected = SecurityException.class)
+  public void getDeviceId_shouldThrowSecurityExceptionWhenReadPhoneStatePermissionNotGranted()
+      throws Exception {
+    shadowOf(telephonyManager).setReadPhoneStatePermission(false);
+    telephonyManager.getDeviceId();
+  }
+
+  @Test
+  public void shouldGivePhoneType() {
+    shadowOf(telephonyManager).setPhoneType(TelephonyManager.PHONE_TYPE_CDMA);
+    assertEquals(TelephonyManager.PHONE_TYPE_CDMA, telephonyManager.getPhoneType());
+    shadowOf(telephonyManager).setPhoneType(TelephonyManager.PHONE_TYPE_GSM);
+    assertEquals(TelephonyManager.PHONE_TYPE_GSM, telephonyManager.getPhoneType());
+  }
+
+  @Test
+  public void shouldGiveCellLocation() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    telephonyManager.listen(listener, LISTEN_CELL_LOCATION);
+
+    CellLocation mockCellLocation = mock(CellLocation.class);
+    shadowOf(telephonyManager).setCellLocation(mockCellLocation);
+    assertEquals(mockCellLocation, telephonyManager.getCellLocation());
+    verify(listener).onCellLocationChanged(mockCellLocation);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void shouldGiveCellLocation_toCallback() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(CellLocationListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    CellLocation mockCellLocation = mock(CellLocation.class);
+    shadowOf(telephonyManager).setCellLocation(mockCellLocation);
+    assertEquals(mockCellLocation, telephonyManager.getCellLocation());
+    verify((CellLocationListener) callback).onCellLocationChanged(mockCellLocation);
+  }
+
+  @Test
+  public void shouldGiveCallState() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    telephonyManager.listen(listener, LISTEN_CALL_STATE);
+
+    shadowOf(telephonyManager).setCallState(CALL_STATE_RINGING, "911");
+    assertEquals(CALL_STATE_RINGING, telephonyManager.getCallState());
+    verify(listener).onCallStateChanged(CALL_STATE_RINGING, "911");
+
+    shadowOf(telephonyManager).setCallState(CALL_STATE_OFFHOOK, "911");
+    assertEquals(CALL_STATE_OFFHOOK, telephonyManager.getCallState());
+    verify(listener).onCallStateChanged(CALL_STATE_OFFHOOK, null);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void shouldGiveCallState_toCallback() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(CallStateListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    shadowOf(telephonyManager).setCallState(CALL_STATE_RINGING, "911");
+    assertEquals(CALL_STATE_RINGING, telephonyManager.getCallState());
+    verify((CallStateListener) callback).onCallStateChanged(CALL_STATE_RINGING);
+
+    shadowOf(telephonyManager).setCallState(CALL_STATE_OFFHOOK, "911");
+    assertEquals(CALL_STATE_OFFHOOK, telephonyManager.getCallState());
+    verify((CallStateListener) callback).onCallStateChanged(CALL_STATE_OFFHOOK);
+  }
+
+  @Test
+  public void isSmsCapable() {
+    assertThat(telephonyManager.isSmsCapable()).isTrue();
+    shadowOf(telephonyManager).setIsSmsCapable(false);
+    assertThat(telephonyManager.isSmsCapable()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldGiveCarrierConfigIfSet() {
+    PersistableBundle bundle = new PersistableBundle();
+    bundle.putInt("foo", 42);
+    shadowOf(telephonyManager).setCarrierConfig(bundle);
+
+    assertEquals(bundle, telephonyManager.getCarrierConfig());
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldGiveNonNullCarrierConfigIfNotSet() {
+    assertNotNull(telephonyManager.getCarrierConfig());
+  }
+
+  @Test
+  public void shouldGiveVoiceMailNumber() {
+    shadowOf(telephonyManager).setVoiceMailNumber("123");
+
+    assertEquals("123", telephonyManager.getVoiceMailNumber());
+  }
+
+  @Test
+  public void shouldGiveVoiceMailAlphaTag() {
+    shadowOf(telephonyManager).setVoiceMailAlphaTag("tag");
+
+    assertEquals("tag", telephonyManager.getVoiceMailAlphaTag());
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldGivePhoneCount() {
+    shadowOf(telephonyManager).setPhoneCount(42);
+
+    assertEquals(42, telephonyManager.getPhoneCount());
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void shouldGiveDefaultActiveModemCount() {
+    assertThat(telephonyManager.getActiveModemCount()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void shouldGiveActiveModemCount() {
+    shadowOf(telephonyManager).setActiveModemCount(42);
+
+    assertThat(telephonyManager.getActiveModemCount()).isEqualTo(42);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void shouldGiveVoiceCapableTrue() {
+    shadowOf(telephonyManager).setVoiceCapable(true);
+
+    assertTrue(telephonyManager.isVoiceCapable());
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP_MR1)
+  public void shouldGiveVoiceCapableFalse() {
+    shadowOf(telephonyManager).setVoiceCapable(false);
+
+    assertFalse(telephonyManager.isVoiceCapable());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void shouldGiveVoiceVibrationEnabled() {
+    PhoneAccountHandle phoneAccountHandle =
+        new PhoneAccountHandle(
+            new ComponentName(ApplicationProvider.getApplicationContext(), Object.class), "handle");
+
+    shadowOf(telephonyManager).setVoicemailVibrationEnabled(phoneAccountHandle, true);
+
+    assertTrue(telephonyManager.isVoicemailVibrationEnabled(phoneAccountHandle));
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void shouldGiveVoicemailRingtoneUri() {
+    PhoneAccountHandle phoneAccountHandle =
+        new PhoneAccountHandle(
+            new ComponentName(ApplicationProvider.getApplicationContext(), Object.class), "handle");
+    Uri ringtoneUri = Uri.fromParts("file", "ringtone.mp3", /* fragment = */ null);
+
+    shadowOf(telephonyManager).setVoicemailRingtoneUri(phoneAccountHandle, ringtoneUri);
+
+    assertEquals(ringtoneUri, telephonyManager.getVoicemailRingtoneUri(phoneAccountHandle));
+  }
+
+  @Test
+  @Config(minSdk = O) // The setter on the real manager was added in O
+  public void shouldSetVoicemailRingtoneUri() {
+    PhoneAccountHandle phoneAccountHandle =
+        new PhoneAccountHandle(
+            new ComponentName(ApplicationProvider.getApplicationContext(), Object.class), "handle");
+    Uri ringtoneUri = Uri.fromParts("file", "ringtone.mp3", /* fragment = */ null);
+
+    // Note: Using the real manager to set, instead of the shadow.
+    telephonyManager.setVoicemailRingtoneUri(phoneAccountHandle, ringtoneUri);
+
+    assertEquals(ringtoneUri, telephonyManager.getVoicemailRingtoneUri(phoneAccountHandle));
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldCreateForPhoneAccountHandle() {
+    PhoneAccountHandle phoneAccountHandle =
+        new PhoneAccountHandle(
+            new ComponentName(ApplicationProvider.getApplicationContext(), Object.class), "handle");
+    TelephonyManager mockTelephonyManager = mock(TelephonyManager.class);
+
+    shadowOf(telephonyManager)
+        .setTelephonyManagerForHandle(phoneAccountHandle, mockTelephonyManager);
+
+    assertEquals(
+        mockTelephonyManager, telephonyManager.createForPhoneAccountHandle(phoneAccountHandle));
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void shouldCreateForSubscriptionId() {
+    int subscriptionId = 42;
+    TelephonyManager mockTelephonyManager = mock(TelephonyManager.class);
+
+    shadowOf(telephonyManager)
+        .setTelephonyManagerForSubscriptionId(subscriptionId, mockTelephonyManager);
+
+    assertEquals(mockTelephonyManager, telephonyManager.createForSubscriptionId(subscriptionId));
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldSetServiceState() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    telephonyManager.listen(listener, LISTEN_SERVICE_STATE);
+    ServiceState serviceState = new ServiceState();
+    serviceState.setState(ServiceState.STATE_OUT_OF_SERVICE);
+
+    shadowOf(telephonyManager).setServiceState(serviceState);
+
+    assertEquals(serviceState, telephonyManager.getServiceState());
+    verify(listener).onServiceStateChanged(serviceState);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void shouldSetServiceState_toCallback() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(ServiceStateListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+    ServiceState serviceState = new ServiceState();
+    serviceState.setState(ServiceState.STATE_OUT_OF_SERVICE);
+
+    shadowOf(telephonyManager).setServiceState(serviceState);
+
+    assertEquals(serviceState, telephonyManager.getServiceState());
+    verify((ServiceStateListener) callback).onServiceStateChanged(serviceState);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void listen_doesNotNotifyListenerOfCurrentServiceStateIfUninitialized() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+
+    telephonyManager.listen(listener, LISTEN_SERVICE_STATE);
+
+    verifyNoMoreInteractions(listener);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void register_doesNotNotifyCallbackOfCurrentServiceStateIfUninitialized() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(ServiceStateListener.class));
+
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void listen_notifiesListenerOfCurrentServiceStateIfInitialized() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    ServiceState serviceState = new ServiceState();
+    serviceState.setState(ServiceState.STATE_OUT_OF_SERVICE);
+    shadowOf(telephonyManager).setServiceState(serviceState);
+
+    telephonyManager.listen(listener, LISTEN_SERVICE_STATE);
+
+    verify(listener, times(1)).onServiceStateChanged(serviceState);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void register_notifiesCallbackOfCurrentServiceStateIfInitialized() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(ServiceStateListener.class));
+    ServiceState serviceState = new ServiceState();
+    serviceState.setState(ServiceState.STATE_OUT_OF_SERVICE);
+    shadowOf(telephonyManager).setServiceState(serviceState);
+
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    verify((ServiceStateListener) callback, times(1)).onServiceStateChanged(serviceState);
+  }
+
+  @Test
+  public void shouldSetIsNetworkRoaming() {
+    shadowOf(telephonyManager).setIsNetworkRoaming(true);
+
+    assertTrue(telephonyManager.isNetworkRoaming());
+  }
+
+  @Test
+  public void shouldGetSimState() {
+    assertThat(telephonyManager.getSimState()).isEqualTo(TelephonyManager.SIM_STATE_READY);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldGetSimStateUsingSlotNumber() {
+    int expectedSimState = TelephonyManager.SIM_STATE_ABSENT;
+    int slotNumber = 3;
+    shadowOf(telephonyManager).setSimState(slotNumber, expectedSimState);
+
+    assertThat(telephonyManager.getSimState(slotNumber)).isEqualTo(expectedSimState);
+  }
+
+  @Test
+  public void shouldGetSimIso() {
+    assertThat(telephonyManager.getSimCountryIso()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = Q)
+  public void shouldGetSimIsoWhenSetUsingSlotNumber() {
+    String expectedSimIso = "usa";
+    int subId = 2;
+    shadowOf(telephonyManager).setSimCountryIso(subId, expectedSimIso);
+
+    assertThat(
+            (String)
+                ReflectionHelpers.callInstanceMethod(
+                    telephonyManager, "getSimCountryIso", ClassParameter.from(int.class, subId)))
+        .isEqualTo(expectedSimIso);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void shouldGetSimCarrierId() {
+    int expectedCarrierId = 132;
+    shadowOf(telephonyManager).setSimCarrierId(expectedCarrierId);
+
+    assertThat(telephonyManager.getSimCarrierId()).isEqualTo(expectedCarrierId);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void shouldGetCarrierIdFromSimMccMnc() {
+    int expectedCarrierId = 419;
+    shadowOf(telephonyManager).setCarrierIdFromSimMccMnc(expectedCarrierId);
+
+    assertThat(telephonyManager.getCarrierIdFromSimMccMnc()).isEqualTo(expectedCarrierId);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldGetCurrentPhoneTypeGivenSubId() {
+    int subId = 1;
+    int expectedPhoneType = TelephonyManager.PHONE_TYPE_GSM;
+    shadowOf(telephonyManager).setCurrentPhoneType(subId, expectedPhoneType);
+
+    assertThat(telephonyManager.getCurrentPhoneType(subId)).isEqualTo(expectedPhoneType);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldGetCarrierPackageNamesForIntentAndPhone() {
+    List<String> packages = Collections.singletonList("package1");
+    int phoneId = 123;
+    shadowOf(telephonyManager).setCarrierPackageNamesForPhone(phoneId, packages);
+
+    assertThat(telephonyManager.getCarrierPackageNamesForIntentAndPhone(new Intent(), phoneId))
+        .isEqualTo(packages);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldGetCarrierPackageNamesForIntent() {
+    List<String> packages = Collections.singletonList("package1");
+    shadowOf(telephonyManager)
+        .setCarrierPackageNamesForPhone(SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, packages);
+
+    assertThat(telephonyManager.getCarrierPackageNamesForIntent(new Intent())).isEqualTo(packages);
+  }
+
+  @Test
+  public void resetSimStates_shouldRetainDefaultState() {
+    shadowOf(telephonyManager).resetSimStates();
+
+    assertThat(telephonyManager.getSimState()).isEqualTo(TelephonyManager.SIM_STATE_READY);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void resetSimCountryIsos_shouldRetainDefaultState() {
+    shadowOf(telephonyManager).resetSimCountryIsos();
+
+    assertThat(telephonyManager.getSimCountryIso()).isEmpty();
+  }
+
+  @Test
+  public void shouldSetSubscriberId() {
+    String subscriberId = "123451234512345";
+    shadowOf(telephonyManager).setSubscriberId(subscriberId);
+
+    assertThat(telephonyManager.getSubscriberId()).isEqualTo(subscriberId);
+  }
+
+  @Test(expected = SecurityException.class)
+  public void getSubscriberId_shouldThrowSecurityExceptionWhenReadPhoneStatePermissionNotGranted() {
+    shadowOf(telephonyManager).setReadPhoneStatePermission(false);
+    telephonyManager.getSubscriberId();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getUiccSlotsInfo() {
+    UiccSlotInfo slotInfo1 = new UiccSlotInfo(true, true, null, 0, 0, true);
+    UiccSlotInfo slotInfo2 = new UiccSlotInfo(true, true, null, 0, 1, true);
+    UiccSlotInfo[] slotInfos = new UiccSlotInfo[] {slotInfo1, slotInfo2};
+    shadowOf(telephonyManager).setUiccSlotsInfo(slotInfos);
+
+    assertThat(shadowOf(telephonyManager).getUiccSlotsInfo()).isEqualTo(slotInfos);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldSetVisualVoicemailPackage() {
+    shadowOf(telephonyManager).setVisualVoicemailPackageName("org.foo");
+
+    assertThat(telephonyManager.getVisualVoicemailPackageName()).isEqualTo("org.foo");
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void canSetAndGetSignalStrength() {
+    SignalStrength ss = Shadow.newInstanceOf(SignalStrength.class);
+    shadowOf(telephonyManager).setSignalStrength(ss);
+    assertThat(telephonyManager.getSignalStrength()).isEqualTo(ss);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void shouldGiveSignalStrength() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    telephonyManager.listen(listener, LISTEN_SIGNAL_STRENGTHS);
+    SignalStrength ss = Shadow.newInstanceOf(SignalStrength.class);
+
+    shadowOf(telephonyManager).setSignalStrength(ss);
+
+    verify(listener).onSignalStrengthsChanged(ss);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void shouldGiveSignalStrength_toCallback() {
+    TelephonyCallback callback =
+        mock(
+            TelephonyCallback.class, withSettings().extraInterfaces(SignalStrengthsListener.class));
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+    SignalStrength ss = Shadow.newInstanceOf(SignalStrength.class);
+
+    shadowOf(telephonyManager).setSignalStrength(ss);
+
+    verify((SignalStrengthsListener) callback).onSignalStrengthsChanged(ss);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setDataEnabledChangesIsDataEnabled() {
+    shadowOf(telephonyManager).setDataEnabled(false);
+    assertThat(telephonyManager.isDataEnabled()).isFalse();
+    shadowOf(telephonyManager).setDataEnabled(true);
+    assertThat(telephonyManager.isDataEnabled()).isTrue();
+  }
+
+  @Test
+  public void setDataStateChangesDataState() {
+    assertThat(telephonyManager.getDataState()).isEqualTo(TelephonyManager.DATA_DISCONNECTED);
+    shadowOf(telephonyManager).setDataState(TelephonyManager.DATA_CONNECTING);
+    assertThat(telephonyManager.getDataState()).isEqualTo(TelephonyManager.DATA_CONNECTING);
+    shadowOf(telephonyManager).setDataState(TelephonyManager.DATA_CONNECTED);
+    assertThat(telephonyManager.getDataState()).isEqualTo(TelephonyManager.DATA_CONNECTED);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setRttSupportedChangesIsRttSupported() {
+    shadowOf(telephonyManager).setRttSupported(false);
+    assertThat(telephonyManager.isRttSupported()).isFalse();
+    shadowOf(telephonyManager).setRttSupported(true);
+    assertThat(telephonyManager.isRttSupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void sendDialerSpecialCode() {
+    shadowOf(telephonyManager).sendDialerSpecialCode("1234");
+    shadowOf(telephonyManager).sendDialerSpecialCode("123456");
+    shadowOf(telephonyManager).sendDialerSpecialCode("1234");
+
+    assertThat(shadowOf(telephonyManager).getSentDialerSpecialCodes())
+        .containsExactly("1234", "123456", "1234")
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setHearingAidCompatibilitySupportedChangesisHearingAidCompatibilitySupported() {
+    shadowOf(telephonyManager).setHearingAidCompatibilitySupported(false);
+    assertThat(telephonyManager.isHearingAidCompatibilitySupported()).isFalse();
+    shadowOf(telephonyManager).setHearingAidCompatibilitySupported(true);
+    assertThat(telephonyManager.isHearingAidCompatibilitySupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void createTelephonyDisplayInfo_correctlyCreatesDisplayInfo() {
+    TelephonyDisplayInfo displayInfo =
+        (TelephonyDisplayInfo)
+            createTelephonyDisplayInfo(NETWORK_TYPE_LTE, OVERRIDE_NETWORK_TYPE_LTE_CA);
+
+    assertThat(displayInfo.getNetworkType()).isEqualTo(NETWORK_TYPE_LTE);
+    assertThat(displayInfo.getOverrideNetworkType()).isEqualTo(OVERRIDE_NETWORK_TYPE_LTE_CA);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void listen_doesNotNotifyListenerOfCurrentTelephonyDisplayInfoIfUninitialized() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+
+    telephonyManager.listen(listener, LISTEN_DISPLAY_INFO_CHANGED);
+
+    verifyNoMoreInteractions(listener);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void register_doesNotNotifyCallbackOfCurrentTelephonyDisplayInfoIfUninitialized() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(DisplayInfoListener.class));
+
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    verifyNoMoreInteractions(callback);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void listen_notifiesListenerOfCurrentTelephonyDisplayInfoIfInitialized() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    TelephonyDisplayInfo displayInfo =
+        (TelephonyDisplayInfo)
+            createTelephonyDisplayInfo(NETWORK_TYPE_EVDO_0, OVERRIDE_NETWORK_TYPE_NONE);
+    shadowTelephonyManager.setTelephonyDisplayInfo(displayInfo);
+
+    telephonyManager.listen(listener, LISTEN_DISPLAY_INFO_CHANGED);
+
+    verify(listener, times(1)).onDisplayInfoChanged(displayInfo);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void register_notifiesCallbackOfCurrentTelephonyDisplayInfoIfInitialized() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(DisplayInfoListener.class));
+    TelephonyDisplayInfo displayInfo =
+        (TelephonyDisplayInfo)
+            createTelephonyDisplayInfo(NETWORK_TYPE_EVDO_0, OVERRIDE_NETWORK_TYPE_NONE);
+    shadowTelephonyManager.setTelephonyDisplayInfo(displayInfo);
+
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    verify((DisplayInfoListener) callback, times(1)).onDisplayInfoChanged(displayInfo);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setTelephonyDisplayInfo_notifiesListeners() {
+    PhoneStateListener listener = mock(PhoneStateListener.class);
+    TelephonyDisplayInfo displayInfo =
+        (TelephonyDisplayInfo)
+            createTelephonyDisplayInfo(NETWORK_TYPE_LTE, OVERRIDE_NETWORK_TYPE_NR_NSA);
+    telephonyManager.listen(listener, LISTEN_DISPLAY_INFO_CHANGED);
+
+    shadowTelephonyManager.setTelephonyDisplayInfo(displayInfo);
+
+    verify(listener, times(1)).onDisplayInfoChanged(displayInfo);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void setTelephonyDisplayInfo_notifiesCallback() {
+    TelephonyCallback callback =
+        mock(TelephonyCallback.class, withSettings().extraInterfaces(DisplayInfoListener.class));
+    TelephonyDisplayInfo displayInfo =
+        (TelephonyDisplayInfo)
+            createTelephonyDisplayInfo(NETWORK_TYPE_LTE, OVERRIDE_NETWORK_TYPE_NR_NSA);
+    shadowTelephonyManager.setTelephonyDisplayInfo(displayInfo);
+
+    telephonyManager.registerTelephonyCallback(directExecutor(), callback);
+
+    verify((DisplayInfoListener) callback, times(1)).onDisplayInfoChanged(displayInfo);
+  }
+
+  @Test(expected = NullPointerException.class)
+  @Config(minSdk = R)
+  public void setTelephonyDisplayInfo_throwsNullPointerException() {
+    shadowTelephonyManager.setTelephonyDisplayInfo(null);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isDataConnectionAllowed_returnsFalseByDefault() {
+    assertThat(shadowTelephonyManager.isDataConnectionAllowed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isDataConnectionAllowed_returnsFalseWhenSetToFalse() {
+    shadowTelephonyManager.setIsDataConnectionAllowed(false);
+
+    assertThat(shadowTelephonyManager.isDataConnectionAllowed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isDataConnectionAllowed_returnsTrueWhenSetToTrue() {
+    shadowTelephonyManager.setIsDataConnectionAllowed(true);
+
+    assertThat(shadowTelephonyManager.isDataConnectionAllowed()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getCallComposerStatus_default() {
+    assertThat(telephonyManager.getCallComposerStatus()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void setCallComposerStatus() {
+    ShadowTelephonyManager.setCallComposerStatus(CALL_COMPOSER_STATUS_ON);
+
+    assertThat(telephonyManager.getCallComposerStatus()).isEqualTo(CALL_COMPOSER_STATUS_ON);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void getBootstrapAuthenticationCallback() {
+    BootstrapAuthenticationCallback callback =
+        new BootstrapAuthenticationCallback() {
+          @Override
+          public void onKeysAvailable(byte[] gbaKey, String transactionId) {}
+
+          @Override
+          public void onAuthenticationFailure(@AuthenticationFailureReason int reason) {}
+        };
+
+    telephonyManager.bootstrapAuthenticationRequest(
+        TelephonyManager.APPTYPE_ISIM,
+        Uri.parse("tel:test-uri"),
+        new UaSecurityProtocolIdentifier.Builder().build(),
+        true,
+        directExecutor(),
+        callback);
+
+    assertThat(shadowTelephonyManager.getBootstrapAuthenticationCallback()).isEqualTo(callback);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void sendVisualVoicemailSms_shouldStoreLastSendSmsParameters() {
+    telephonyManager.sendVisualVoicemailSms("destAddress", 0, "message", null);
+
+    ShadowTelephonyManager.VisualVoicemailSmsParams params =
+        shadowOf(telephonyManager).getLastSentVisualVoicemailSmsParams();
+
+    assertThat(params.getDestinationAddress()).isEqualTo("destAddress");
+    assertThat(params.getPort()).isEqualTo(0);
+    assertThat(params.getText()).isEqualTo("message");
+    assertThat(params.getSentIntent()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setVisualVoicemailSmsFilterSettings_shouldStoreSettings() {
+    VisualVoicemailSmsFilterSettings settings =
+        new VisualVoicemailSmsFilterSettings.Builder()
+            .setClientPrefix("clientPrefix")
+            .setDestinationPort(100)
+            .build();
+
+    telephonyManager.setVisualVoicemailSmsFilterSettings(settings);
+
+    assertThat(shadowOf(telephonyManager).getVisualVoicemailSmsFilterSettings())
+        .isEqualTo(settings);
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void setVisualVoicemailSmsFilterSettings_setNullSettings_clearsSettings() {
+    VisualVoicemailSmsFilterSettings settings =
+        new VisualVoicemailSmsFilterSettings.Builder().build();
+
+    telephonyManager.setVisualVoicemailSmsFilterSettings(settings);
+    telephonyManager.setVisualVoicemailSmsFilterSettings(null);
+
+    assertThat(shadowOf(telephonyManager).getVisualVoicemailSmsFilterSettings()).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyTest.java
new file mode 100644
index 0000000..7846f03
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyTest.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.provider.Telephony.Sms;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowTelephony.ShadowSms;
+
+/** Unit tests for {@link ShadowTelephony}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.KITKAT)
+public class ShadowTelephonyTest {
+  private static final String TEST_PACKAGE_NAME = "test.package.name";
+
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void shadowSms_getDefaultSmsPackage() {
+    ShadowSms.setDefaultSmsPackage(TEST_PACKAGE_NAME);
+
+    assertThat(Sms.getDefaultSmsPackage(context)).isEqualTo(TEST_PACKAGE_NAME);
+  }
+
+  @Test
+  public void shadowSms_getDefaultSmsPackage_returnsNull_whenNoSmsPackageIsSet() {
+    // Make sure #reset is doing its job
+    ShadowSms.setDefaultSmsPackage(TEST_PACKAGE_NAME);
+    ShadowSms.reset();
+
+    assertThat(Sms.getDefaultSmsPackage(context)).isNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTest.java
new file mode 100644
index 0000000..097d1e0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTest {
+
+  private ClassLoader myClassLoader;
+
+  @Before
+  public void setUp() throws Exception {
+    myClassLoader = getClass().getClassLoader();
+  }
+
+  @Test
+  public void newInstanceOf() {
+    assertThat(Shadow.newInstanceOf(Activity.class.getName()).getClass().getClassLoader())
+        .isSameInstanceAs(myClassLoader);
+  }
+
+  @Test
+  public void extractor() {
+    Activity activity = new Activity();
+    assertThat((ShadowActivity) Shadow.extract(activity)).isSameInstanceAs(shadowOf(activity));
+  }
+
+  @Test
+  public void otherDeprecated_extractor() {
+    Activity activity = new Activity();
+    assertThat(Shadow.<Object>extract(activity)).isSameInstanceAs(shadowOf(activity));
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTextToSpeechTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTextToSpeechTest.java
new file mode 100644
index 0000000..1d98774
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTextToSpeechTest.java
@@ -0,0 +1,474 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.TextToSpeech.Engine;
+import android.speech.tts.UtteranceProgressListener;
+import android.speech.tts.Voice;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableSet;
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTextToSpeechTest {
+  private Activity activity;
+
+  @Before
+  public void setUp() {
+    activity = Robolectric.buildActivity(Activity.class).create().get();
+  }
+
+  @Test
+  public void shouldNotBeNull() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(textToSpeech).isNotNull();
+    assertThat(shadowOf(textToSpeech)).isNotNull();
+  }
+
+  @Test
+  public void onInitListener_success_getsCalledAsynchronously() {
+    AtomicReference<Integer> onInitCalled = new AtomicReference<>();
+    TextToSpeech.OnInitListener listener = onInitCalled::set;
+    TextToSpeech textToSpeech = new TextToSpeech(activity, listener);
+    assertThat(textToSpeech).isNotNull();
+    Shadows.shadowOf(textToSpeech).getOnInitListener().onInit(TextToSpeech.SUCCESS);
+    assertThat(onInitCalled.get()).isEqualTo(TextToSpeech.SUCCESS);
+  }
+
+  @Test
+  public void onInitListener_error() {
+    AtomicReference<Integer> onInitCalled = new AtomicReference<>();
+    TextToSpeech.OnInitListener listener = onInitCalled::set;
+    TextToSpeech textToSpeech = new TextToSpeech(activity, listener);
+    assertThat(textToSpeech).isNotNull();
+    Shadows.shadowOf(textToSpeech).getOnInitListener().onInit(TextToSpeech.ERROR);
+    assertThat(onInitCalled.get()).isEqualTo(TextToSpeech.ERROR);
+  }
+
+  @Test
+  public void getContext_shouldReturnContext() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(shadowOf(textToSpeech).getContext()).isEqualTo(activity);
+  }
+
+  @Test
+  public void getOnInitListener_shouldReturnListener() {
+    TextToSpeech.OnInitListener listener = result -> {};
+    TextToSpeech textToSpeech = new TextToSpeech(activity, listener);
+    assertThat(shadowOf(textToSpeech).getOnInitListener()).isEqualTo(listener);
+  }
+
+  @Test
+  public void getLastSpokenText_shouldReturnSpokenText() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null);
+    assertThat(shadowOf(textToSpeech).getLastSpokenText()).isEqualTo("Hello");
+  }
+
+  @Test
+  public void getLastSpokenText_shouldReturnMostRecentText() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null);
+    textToSpeech.speak("Hi", TextToSpeech.QUEUE_FLUSH, null);
+    assertThat(shadowOf(textToSpeech).getLastSpokenText()).isEqualTo("Hi");
+  }
+
+  @Test
+  public void clearLastSpokenText_shouldSetLastSpokenTextToNull() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null);
+    shadowOf(textToSpeech).clearLastSpokenText();
+    assertThat(shadowOf(textToSpeech).getLastSpokenText()).isNull();
+  }
+
+  @Test
+  public void isShutdown_shouldReturnFalseBeforeShutdown() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(shadowOf(textToSpeech).isShutdown()).isFalse();
+  }
+
+  @Test
+  public void isShutdown_shouldReturnTrueAfterShutdown() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.shutdown();
+    assertThat(shadowOf(textToSpeech).isShutdown()).isTrue();
+  }
+
+  @Test
+  public void isStopped_shouldReturnTrueBeforeSpeak() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(shadowOf(textToSpeech).isStopped()).isTrue();
+  }
+
+  @Test
+  public void isStopped_shouldReturnTrueAfterStop() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.stop();
+    assertThat(shadowOf(textToSpeech).isStopped()).isTrue();
+  }
+
+  @Test
+  public void isStopped_shouldReturnFalseAfterSpeak() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null);
+    assertThat(shadowOf(textToSpeech).isStopped()).isFalse();
+  }
+
+  @Test
+  public void getQueueMode_shouldReturnMostRecentQueueMode() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_ADD, null);
+    assertThat(shadowOf(textToSpeech).getQueueMode()).isEqualTo(TextToSpeech.QUEUE_ADD);
+  }
+
+  @Test
+  public void threeArgumentSpeak_withUtteranceId_shouldGetCallbackUtteranceId() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    HashMap<String, String> paramsMap = new HashMap<>();
+    paramsMap.put(Engine.KEY_PARAM_UTTERANCE_ID, "ThreeArgument");
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, paramsMap);
+
+    shadowMainLooper().idle();
+
+    verify(mockListener).onStart("ThreeArgument");
+    verify(mockListener).onDone("ThreeArgument");
+  }
+
+  @Test
+  public void threeArgumentSpeak_withoutUtteranceId_shouldDoesNotGetCallback() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null);
+
+    shadowMainLooper().idle();
+
+    verify(mockListener, never()).onStart(null);
+    verify(mockListener, never()).onDone(null);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void speak_withUtteranceId_shouldReturnSpokenText() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null, "TTSEnable");
+    assertThat(shadowOf(textToSpeech).getLastSpokenText()).isEqualTo("Hello");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void onUtteranceProgressListener_shouldGetCallbackUtteranceId() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    textToSpeech.speak("Hello", TextToSpeech.QUEUE_FLUSH, null, "TTSEnable");
+
+    shadowMainLooper().idle();
+
+    verify(mockListener).onStart("TTSEnable");
+    verify(mockListener).onDone("TTSEnable");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_lastSynthesizeToFileTextStored() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+    int result = textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    assertThat(result).isEqualTo(TextToSpeech.SUCCESS);
+    assertThat(shadowOf(textToSpeech).getLastSynthesizeToFileText()).isEqualTo("text");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_byDefault_doesNotCallOnStart() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+
+    textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    verify(mockListener, never()).onDone("id");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_byDefault_doesNotCallOnDone() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+
+    textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    verify(mockListener, never()).onDone("id");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_successSimulated_callsOnStart() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+
+    ShadowTextToSpeech shadowTextToSpeech = shadowOf(textToSpeech);
+    shadowTextToSpeech.simulateSynthesizeToFileResult(TextToSpeech.SUCCESS);
+
+    textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    verify(mockListener).onStart("id");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_successSimulated_callsOnDone() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+
+    ShadowTextToSpeech shadowTextToSpeech = shadowOf(textToSpeech);
+    shadowTextToSpeech.simulateSynthesizeToFileResult(TextToSpeech.SUCCESS);
+
+    textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    verify(mockListener).onDone("id");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_setToFail_doesNotCallIsDone() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+
+    ShadowTextToSpeech shadowTextToSpeech = shadowOf(textToSpeech);
+    // The actual error used does not matter for this test.
+    shadowTextToSpeech.simulateSynthesizeToFileResult(TextToSpeech.ERROR_NETWORK_TIMEOUT);
+
+    textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    verify(mockListener, never()).onDone("id");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_setToFail_callsOnErrorWithErrorCode() throws IOException {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    UtteranceProgressListener mockListener = mock(UtteranceProgressListener.class);
+    textToSpeech.setOnUtteranceProgressListener(mockListener);
+    Bundle bundle = new Bundle();
+    File file = createFile("example.txt");
+
+    ShadowTextToSpeech shadowTextToSpeech = shadowOf(textToSpeech);
+    int errorCode = TextToSpeech.ERROR_NETWORK_TIMEOUT;
+    shadowTextToSpeech.simulateSynthesizeToFileResult(errorCode);
+
+    textToSpeech.synthesizeToFile("text", bundle, file, "id");
+
+    verify(mockListener).onError("id", errorCode);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void synthesizeToFile_neverCalled_lastSynthesizeToFileTextNull() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(shadowOf(textToSpeech).getLastSynthesizeToFileText()).isNull();
+  }
+
+  @Test
+  public void getCurrentLanguage_languageSet_returnsLanguage() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    Locale language = Locale.forLanguageTag("pl-pl");
+    textToSpeech.setLanguage(language);
+    assertThat(shadowOf(textToSpeech).getCurrentLanguage()).isEqualTo(language);
+  }
+
+  @Test
+  public void getCurrentLanguage_languageNeverSet_returnsNull() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(shadowOf(textToSpeech).getCurrentLanguage()).isNull();
+  }
+
+  @Test
+  public void isLanguageAvailable_neverAdded_returnsUnsupported() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(
+            textToSpeech.isLanguageAvailable(
+                new Locale.Builder().setLanguage("pl").setRegion("pl").build()))
+        .isEqualTo(TextToSpeech.LANG_NOT_SUPPORTED);
+  }
+
+  @Test
+  public void isLanguageAvailable_twoLanguageAvailabilities_returnsRequestedAvailability() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    ShadowTextToSpeech.addLanguageAvailability(
+        new Locale.Builder().setLanguage("pl").setRegion("pl").build());
+    ShadowTextToSpeech.addLanguageAvailability(
+        new Locale.Builder().setLanguage("ja").setRegion("jp").build());
+
+    assertThat(
+            textToSpeech.isLanguageAvailable(
+                new Locale.Builder().setLanguage("pl").setRegion("pl").build()))
+        .isEqualTo(TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE);
+  }
+
+  @Test
+  public void isLanguageAvailable_matchingVariant_returnsCountryVarAvailable() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    ShadowTextToSpeech.addLanguageAvailability(
+        new Locale.Builder().setLanguage("en").setRegion("us").setVariant("WOLTK").build());
+
+    assertThat(
+            textToSpeech.isLanguageAvailable(
+                new Locale.Builder().setLanguage("en").setRegion("us").setVariant("WOLTK").build()))
+        .isEqualTo(TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE);
+  }
+
+  @Test
+  public void isLanguageAvailable_matchingCountry_returnsLangCountryAvailable() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    ShadowTextToSpeech.addLanguageAvailability(
+        new Locale.Builder().setLanguage("en").setRegion("us").setVariant("ONETW").build());
+
+    assertThat(
+            textToSpeech.isLanguageAvailable(
+                new Locale.Builder().setLanguage("en").setRegion("us").setVariant("THREE").build()))
+        .isEqualTo(TextToSpeech.LANG_COUNTRY_AVAILABLE);
+  }
+
+  @Test
+  public void isLanguageAvailable_matchingLanguage_returnsLangAvailable() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    ShadowTextToSpeech.addLanguageAvailability(
+        new Locale.Builder().setLanguage("en").setRegion("us").build());
+
+    assertThat(
+            textToSpeech.isLanguageAvailable(
+                new Locale.Builder().setLanguage("en").setRegion("gb").build()))
+        .isEqualTo(TextToSpeech.LANG_AVAILABLE);
+  }
+
+  @Test
+  public void isLanguageAvailable_matchingNone_returnsLangNotSupported() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    ShadowTextToSpeech.addLanguageAvailability(
+        new Locale.Builder().setLanguage("en").setRegion("us").build());
+
+    assertThat(
+            textToSpeech.isLanguageAvailable(
+                new Locale.Builder().setLanguage("ja").setRegion("jp").build()))
+        .isEqualTo(TextToSpeech.LANG_NOT_SUPPORTED);
+  }
+
+  @Test
+  public void getLastTextToSpeechInstance_neverConstructed_returnsNull() {
+    assertThat(ShadowTextToSpeech.getLastTextToSpeechInstance()).isNull();
+  }
+
+  @Test
+  public void getLastTextToSpeechInstance_constructed_returnsInstance() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(ShadowTextToSpeech.getLastTextToSpeechInstance()).isEqualTo(textToSpeech);
+  }
+
+  @Test
+  public void getLastTextToSpeechInstance_constructedTwice_returnsMostRecentInstance() {
+    TextToSpeech textToSpeechOne = new TextToSpeech(activity, result -> {});
+    TextToSpeech textToSpeechTwo = new TextToSpeech(activity, result -> {});
+
+    assertThat(ShadowTextToSpeech.getLastTextToSpeechInstance()).isEqualTo(textToSpeechTwo);
+    assertThat(ShadowTextToSpeech.getLastTextToSpeechInstance()).isNotEqualTo(textToSpeechOne);
+  }
+
+  @Test
+  public void getSpokenTextList_neverSpoke_returnsEmpty() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+    assertThat(shadowOf(textToSpeech).getSpokenTextList()).isEmpty();
+  }
+
+  @Test
+  public void getSpokenTextList_spoke_returnsSpokenTexts() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+
+    textToSpeech.speak("one", TextToSpeech.QUEUE_FLUSH, null);
+    textToSpeech.speak("two", TextToSpeech.QUEUE_FLUSH, null);
+    textToSpeech.speak("three", TextToSpeech.QUEUE_FLUSH, null);
+
+    assertThat(shadowOf(textToSpeech).getSpokenTextList()).containsExactly("one", "two", "three");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getCurrentVoice_voiceSet_returnsVoice() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+
+    Voice voice =
+        new Voice(
+            "test voice",
+            Locale.getDefault(),
+            Voice.QUALITY_VERY_HIGH,
+            Voice.LATENCY_LOW,
+            false /* requiresNetworkConnection */,
+            ImmutableSet.of());
+    textToSpeech.setVoice(voice);
+
+    assertThat(shadowOf(textToSpeech).getCurrentVoice()).isEqualTo(voice);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getVoices_returnsAvailableVoices() {
+    TextToSpeech textToSpeech = new TextToSpeech(activity, result -> {});
+
+    Voice voice =
+        new Voice(
+            "test voice",
+            Locale.getDefault(),
+            Voice.QUALITY_VERY_HIGH,
+            Voice.LATENCY_LOW,
+            false /* requiresNetworkConnection */,
+            ImmutableSet.of());
+    ShadowTextToSpeech.addVoice(voice);
+
+    assertThat(shadowOf(textToSpeech).getVoices()).containsExactly(voice);
+  }
+
+  private static File createFile(String filename) throws IOException {
+    TemporaryFolder temporaryFolder = new TemporaryFolder();
+    temporaryFolder.create();
+    return temporaryFolder.newFile(filename);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTextUtilsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTextUtilsTest.java
new file mode 100644
index 0000000..cc2cd2b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTextUtilsTest.java
@@ -0,0 +1,94 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertArrayEquals;
+
+import android.text.TextPaint;
+import android.text.TextUtils;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTextUtilsTest {
+  @Test
+  public void testExpandTemplate() {
+    assertThat(TextUtils.expandTemplate("a^1b^2c^3d", "A", "B", "C", "D").toString())
+        .isEqualTo("aAbBcCd");
+  }
+
+  @Test
+  public void testIsEmpty() {
+    assertThat(TextUtils.isEmpty(null)).isTrue();
+    assertThat(TextUtils.isEmpty("")).isTrue();
+    assertThat(TextUtils.isEmpty(" ")).isFalse();
+    assertThat(TextUtils.isEmpty("123")).isFalse();
+  }
+
+  @Test public void testJoin() {
+    assertThat(TextUtils.join(",", new String[]{"1"})).isEqualTo("1");
+    assertThat(TextUtils.join(",", new String[]{"1", "2", "3"})).isEqualTo("1,2,3");
+    assertThat(TextUtils.join(",", Arrays.asList("1", "2", "3"))).isEqualTo("1,2,3");
+  }
+
+  @Test
+  public void testIsDigitsOnly() {
+    assertThat(TextUtils.isDigitsOnly("123456")).isTrue();
+    assertThat(TextUtils.isDigitsOnly("124a56")).isFalse();
+  }
+
+  @Test
+  public void testSplit() {
+    //empty
+    assertThat(TextUtils.split("", ",").length).isEqualTo(0);
+
+    //one value
+    assertArrayEquals(TextUtils.split("abc", ","), new String[]{"abc"});
+
+    //two values
+    assertArrayEquals(TextUtils.split("abc,def", ","), new String[]{"abc", "def"});
+
+    //two values with space
+    assertArrayEquals(TextUtils.split("abc, def", ","), new String[]{"abc", " def"});
+  }
+
+  @Test
+  public void testEquals() {
+    assertThat(TextUtils.equals(null, null)).isTrue();
+    assertThat(TextUtils.equals("", "")).isTrue();
+    assertThat(TextUtils.equals("a", "a")).isTrue();
+    assertThat(TextUtils.equals("ab", "ab")).isTrue();
+
+    assertThat(TextUtils.equals(null, "")).isFalse();
+    assertThat(TextUtils.equals("", null)).isFalse();
+
+    assertThat(TextUtils.equals(null, "a")).isFalse();
+    assertThat(TextUtils.equals("a", null)).isFalse();
+
+    assertThat(TextUtils.equals(null, "ab")).isFalse();
+    assertThat(TextUtils.equals("ab", null)).isFalse();
+
+    assertThat(TextUtils.equals("", "a")).isFalse();
+    assertThat(TextUtils.equals("a", "")).isFalse();
+
+    assertThat(TextUtils.equals("", "ab")).isFalse();
+    assertThat(TextUtils.equals("ab", "")).isFalse();
+
+    assertThat(TextUtils.equals("a", "ab")).isFalse();
+    assertThat(TextUtils.equals("ab", "a")).isFalse();
+  }
+
+  @Test public void testEllipsize() {
+    TextPaint p = new TextPaint();
+    assertThat(TextUtils.ellipsize("apples", p, 0, TextUtils.TruncateAt.END).toString())
+        .isEqualTo("");
+    assertThat(TextUtils.ellipsize("apples", p, -1, TextUtils.TruncateAt.END).toString())
+        .isEqualTo("");
+    assertThat(TextUtils.ellipsize("apples", p, 3, TextUtils.TruncateAt.END).toString())
+        .isEqualTo("app");
+    assertThat(TextUtils.ellipsize("apples", p, 100, TextUtils.TruncateAt.END).toString())
+        .isEqualTo("apples");
+    assertThat(TextUtils.ellipsize("", p, 100, TextUtils.TruncateAt.END).toString()).isEqualTo("");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTextViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTextViewTest.java
new file mode 100644
index 0000000..5d8234b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTextViewTest.java
@@ -0,0 +1,597 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.graphics.Typeface;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.TextWatcher;
+import android.text.method.ArrowKeyMovementMethod;
+import android.text.method.MovementMethod;
+import android.text.method.PasswordTransformationMethod;
+import android.text.style.URLSpan;
+import android.text.util.Linkify;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.inputmethod.EditorInfo;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Random;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.R;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTextViewTest {
+
+  private static final String INITIAL_TEXT = "initial text";
+  private static final String NEW_TEXT = "new text";
+  private TextView textView;
+  private ActivityController<Activity> activityController;
+
+  @Before
+  public void setUp() throws Exception {
+    activityController = buildActivity(Activity.class);
+    Activity activity = activityController.create().get();
+    textView = new TextView(activity);
+    activity.setContentView(textView);
+    activityController.start().resume().visible();
+  }
+
+  @Test
+  public void shouldTriggerTheImeListener() {
+    TestOnEditorActionListener actionListener = new TestOnEditorActionListener();
+    textView.setOnEditorActionListener(actionListener);
+
+    textView.onEditorAction(EditorInfo.IME_ACTION_GO);
+
+    assertThat(actionListener.textView).isSameInstanceAs(textView);
+    assertThat(actionListener.sentImeId).isEqualTo(EditorInfo.IME_ACTION_GO);
+  }
+
+  @Test
+  public void shouldCreateGetterForEditorActionListener() {
+    TestOnEditorActionListener actionListener = new TestOnEditorActionListener();
+
+    textView.setOnEditorActionListener(actionListener);
+
+    assertThat(shadowOf(textView).getOnEditorActionListener()).isSameInstanceAs(actionListener);
+  }
+
+  @Test
+  public void testGetUrls() {
+    Locale.setDefault(Locale.ENGLISH);
+    textView.setAutoLinkMask(Linkify.ALL);
+    textView.setText("here's some text http://google.com/\nblah\thttp://another.com/123?456 blah");
+
+    assertThat(urlStringsFrom(textView.getUrls())).isEqualTo(asList(
+            "http://google.com",
+            "http://another.com/123?456"
+    ));
+  }
+
+  @Test
+  public void testGetGravity() {
+    assertThat(textView.getGravity()).isNotEqualTo(Gravity.CENTER);
+    textView.setGravity(Gravity.CENTER);
+    assertThat(textView.getGravity()).isEqualTo(Gravity.CENTER);
+  }
+
+  @Test
+  public void testMovementMethod() {
+    MovementMethod movement = new ArrowKeyMovementMethod();
+
+    assertNull(textView.getMovementMethod());
+    textView.setMovementMethod(movement);
+    assertThat(textView.getMovementMethod()).isSameInstanceAs(movement);
+  }
+
+  @Test
+  public void testLinksClickable() {
+    assertThat(textView.getLinksClickable()).isTrue();
+
+    textView.setLinksClickable(false);
+    assertThat(textView.getLinksClickable()).isFalse();
+
+    textView.setLinksClickable(true);
+    assertThat(textView.getLinksClickable()).isTrue();
+  }
+
+  @Test
+  public void testGetTextAppearanceId() {
+    textView.setTextAppearance(
+        ApplicationProvider.getApplicationContext(), android.R.style.TextAppearance_Small);
+
+    assertThat(shadowOf(textView).getTextAppearanceId()).isEqualTo(android.R.style.TextAppearance_Small);
+  }
+
+  @Test
+  public void shouldSetTextAndTextColorWhileInflatingXmlLayout() {
+    Activity activity = activityController.get();
+    activity.setContentView(R.layout.text_views);
+
+    TextView black = activity.findViewById(R.id.black_text_view);
+    assertThat(black.getText().toString()).isEqualTo("Black Text");
+    assertThat(black.getCurrentTextColor()).isEqualTo(0xff000000);
+
+    TextView white = activity.findViewById(R.id.white_text_view);
+    assertThat(white.getText().toString()).isEqualTo("White Text");
+    assertThat(white.getCurrentTextColor()).isEqualTo(activity.getResources().getColor(android.R.color.white));
+
+    TextView grey = activity.findViewById(R.id.grey_text_view);
+    assertThat(grey.getText().toString()).isEqualTo("Grey Text");
+    assertThat(grey.getCurrentTextColor()).isEqualTo(activity.getResources().getColor(R.color.grey42));
+  }
+
+  @Test
+  public void shouldSetHintAndHintColorWhileInflatingXmlLayout() {
+    Activity activity = activityController.get();
+    activity.setContentView(R.layout.text_views_hints);
+
+    TextView black = activity.findViewById(R.id.black_text_view_hint);
+    assertThat(black.getHint().toString()).isEqualTo("Black Hint");
+    assertThat(black.getCurrentHintTextColor()).isEqualTo(0xff000000);
+
+    TextView white = activity.findViewById(R.id.white_text_view_hint);
+    assertThat(white.getHint().toString()).isEqualTo("White Hint");
+    assertThat(white.getCurrentHintTextColor()).isEqualTo(activity.getResources().getColor(android.R.color.white));
+
+    TextView grey = activity.findViewById(R.id.grey_text_view_hint);
+    assertThat(grey.getHint().toString()).isEqualTo("Grey Hint");
+    assertThat(grey.getCurrentHintTextColor()).isEqualTo(activity.getResources().getColor(R.color.grey42));
+  }
+
+  @Test
+  public void shouldNotHaveTransformationMethodByDefault() {
+    assertThat(textView.getTransformationMethod()).isNull();
+  }
+
+  @Test
+  public void shouldAllowSettingATransformationMethod() {
+    textView.setTransformationMethod(PasswordTransformationMethod.getInstance());
+    assertThat(textView.getTransformationMethod()).isInstanceOf(PasswordTransformationMethod.class);
+  }
+
+  @Test
+  public void testGetInputType() {
+    assertThat(textView.getInputType()).isNotEqualTo(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+    textView.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+    assertThat(textView.getInputType()).isEqualTo(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+  }
+
+  @Test
+  public void givenATextViewWithATextWatcherAdded_WhenSettingTextWithTextResourceId_ShouldNotifyTextWatcher() {
+    MockTextWatcher mockTextWatcher = new MockTextWatcher();
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText(R.string.hello);
+
+    assertEachTextWatcherEventWasInvoked(mockTextWatcher);
+  }
+
+  @Test
+  public void givenATextViewWithATextWatcherAdded_WhenSettingTextWithCharSequence_ShouldNotifyTextWatcher() {
+    MockTextWatcher mockTextWatcher = new MockTextWatcher();
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText("text");
+
+    assertEachTextWatcherEventWasInvoked(mockTextWatcher);
+  }
+
+  @Test
+  public void givenATextViewWithATextWatcherAdded_WhenSettingNullText_ShouldNotifyTextWatcher() {
+    MockTextWatcher mockTextWatcher = new MockTextWatcher();
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText(null);
+
+    assertEachTextWatcherEventWasInvoked(mockTextWatcher);
+  }
+
+  @Test
+  public void givenATextViewWithMultipleTextWatchersAdded_WhenSettingText_ShouldNotifyEachTextWatcher() {
+    List<MockTextWatcher> mockTextWatchers = anyNumberOfTextWatchers();
+    for (MockTextWatcher textWatcher : mockTextWatchers) {
+      textView.addTextChangedListener(textWatcher);
+    }
+
+    textView.setText("text");
+
+    for (MockTextWatcher textWatcher : mockTextWatchers) {
+      assertEachTextWatcherEventWasInvoked(textWatcher);
+    }
+  }
+
+  @Test
+  public void whenSettingText_ShouldFireBeforeTextChangedWithCorrectArguments() {
+    textView.setText(INITIAL_TEXT);
+    TextWatcher mockTextWatcher = mock(TextWatcher.class);
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText(NEW_TEXT);
+
+    verify(mockTextWatcher).beforeTextChanged(INITIAL_TEXT, 0, INITIAL_TEXT.length(), NEW_TEXT.length());
+  }
+
+  @Test
+  public void whenSettingText_ShouldFireOnTextChangedWithCorrectArguments() {
+    textView.setText(INITIAL_TEXT);
+    TextWatcher mockTextWatcher = mock(TextWatcher.class);
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText(NEW_TEXT);
+
+    ArgumentCaptor<SpannableStringBuilder> builderCaptor = ArgumentCaptor.forClass(SpannableStringBuilder.class);
+    verify(mockTextWatcher).onTextChanged(builderCaptor.capture(), eq(0), eq(INITIAL_TEXT.length()), eq(NEW_TEXT.length()));
+    assertThat(builderCaptor.getValue().toString()).isEqualTo(NEW_TEXT);
+  }
+
+  @Test
+  public void whenSettingText_ShouldFireAfterTextChangedWithCorrectArgument() {
+    MockTextWatcher mockTextWatcher = new MockTextWatcher();
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText(NEW_TEXT);
+
+    assertThat(mockTextWatcher.afterTextChangeArgument.toString()).isEqualTo(NEW_TEXT);
+  }
+
+  @Test
+  public void whenAppendingText_ShouldAppendNewTextAfterOldOne() {
+    textView.setText(INITIAL_TEXT);
+    textView.append(NEW_TEXT);
+
+    assertThat(textView.getText().toString()).isEqualTo(INITIAL_TEXT + NEW_TEXT);
+  }
+
+  @Test
+  public void whenAppendingText_ShouldFireBeforeTextChangedWithCorrectArguments() {
+    textView.setText(INITIAL_TEXT);
+    TextWatcher mockTextWatcher = mock(TextWatcher.class);
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.append(NEW_TEXT);
+
+    verify(mockTextWatcher).beforeTextChanged(eq(INITIAL_TEXT), eq(0), eq(INITIAL_TEXT.length()), eq(INITIAL_TEXT.length()));
+  }
+
+  @Test
+  public void whenAppendingText_ShouldFireOnTextChangedWithCorrectArguments() {
+    textView.setText(INITIAL_TEXT);
+    TextWatcher mockTextWatcher = mock(TextWatcher.class);
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.append(NEW_TEXT);
+
+    ArgumentCaptor<SpannableStringBuilder> builderCaptor = ArgumentCaptor.forClass(SpannableStringBuilder.class);
+    verify(mockTextWatcher).onTextChanged(builderCaptor.capture(), eq(0), eq(INITIAL_TEXT.length()), eq(INITIAL_TEXT.length()));
+    assertThat(builderCaptor.getValue().toString()).isEqualTo(INITIAL_TEXT + NEW_TEXT);
+  }
+
+  @Test
+  public void whenAppendingText_ShouldFireAfterTextChangedWithCorrectArgument() {
+    textView.setText(INITIAL_TEXT);
+    MockTextWatcher mockTextWatcher = new MockTextWatcher();
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.append(NEW_TEXT);
+
+    assertThat(mockTextWatcher.afterTextChangeArgument.toString()).isEqualTo(INITIAL_TEXT + NEW_TEXT);
+  }
+
+  @Test
+  public void removeTextChangedListener_shouldRemoveTheListener() {
+    MockTextWatcher watcher = new MockTextWatcher();
+    textView.addTextChangedListener(watcher);
+    assertTrue(shadowOf(textView).getWatchers().contains(watcher));
+
+    textView.removeTextChangedListener(watcher);
+    assertFalse(shadowOf(textView).getWatchers().contains(watcher));
+  }
+
+  @Test
+  public void getPaint_returnsMeasureTextEnabledObject() {
+    assertThat(textView.getPaint().measureText("12345")).isEqualTo(5f);
+  }
+
+  @Test
+  public void append_whenSelectionIsAtTheEnd_shouldKeepSelectionAtTheEnd() {
+    textView.setText("1", TextView.BufferType.EDITABLE);
+    Selection.setSelection(textView.getEditableText(), 0, 0);
+    textView.append("2");
+    assertEquals(0, textView.getSelectionEnd());
+    assertEquals(0, textView.getSelectionStart());
+
+    Selection.setSelection(textView.getEditableText(), 2, 2);
+    textView.append("3");
+    assertEquals(3, textView.getSelectionEnd());
+    assertEquals(3, textView.getSelectionStart());
+  }
+
+  @Test
+  public void append_whenSelectionReachesToEnd_shouldExtendSelectionToTheEnd() {
+    textView.setText("12", TextView.BufferType.EDITABLE);
+    Selection.setSelection(textView.getEditableText(), 0, 2);
+    textView.append("3");
+    assertEquals(3, textView.getSelectionEnd());
+    assertEquals(0, textView.getSelectionStart());
+  }
+
+  @Test
+  public void
+      testSetCompountDrawablesWithIntrinsicBounds_int_shouldCreateDrawablesWithResourceIds() {
+    textView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.an_image, R.drawable.an_other_image, R.drawable.third_image, R.drawable.fourth_image);
+
+    assertEquals(R.drawable.an_image, shadowOf(textView.getCompoundDrawables()[0]).getCreatedFromResId());
+    assertEquals(R.drawable.an_other_image, shadowOf(textView.getCompoundDrawables()[1]).getCreatedFromResId());
+    assertEquals(R.drawable.third_image, shadowOf(textView.getCompoundDrawables()[2]).getCreatedFromResId());
+    assertEquals(R.drawable.fourth_image, shadowOf(textView.getCompoundDrawables()[3]).getCreatedFromResId());
+  }
+
+  @Test
+  public void testSetCompountDrawablesWithIntrinsicBounds_int_shouldNotCreateDrawablesForZero() {
+    textView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+
+    assertNull(textView.getCompoundDrawables()[0]);
+    assertNull(textView.getCompoundDrawables()[1]);
+    assertNull(textView.getCompoundDrawables()[2]);
+    assertNull(textView.getCompoundDrawables()[3]);
+  }
+
+  @Test
+  public void canSetAndGetTypeface() {
+    Typeface typeface = Shadow.newInstanceOf(Typeface.class);
+    textView.setTypeface(typeface);
+    assertSame(typeface, textView.getTypeface());
+  }
+
+  @Test
+  public void onTouchEvent_shouldCallMovementMethodOnTouchEventWithSetMotionEvent() {
+    TestMovementMethod testMovementMethod = new TestMovementMethod();
+    textView.setMovementMethod(testMovementMethod);
+    textView.setLayoutParams(new FrameLayout.LayoutParams(100, 100));
+    textView.measure(100, 100);
+
+    MotionEvent event = MotionEvent.obtain(0, 0, 0, 0, 0, 0);
+    textView.dispatchTouchEvent(event);
+
+    assertEquals(testMovementMethod.event, event);
+  }
+
+  @Test
+  public void testGetError() {
+    assertNull(textView.getError());
+    CharSequence error = "myError";
+    textView.setError(error);
+    assertEquals(error.toString(), textView.getError().toString());
+  }
+
+  @Test
+  public void canSetAndGetInputFilters() {
+    final InputFilter[] expectedFilters = new InputFilter[]{new InputFilter.LengthFilter(1)};
+    textView.setFilters(expectedFilters);
+    assertThat(textView.getFilters()).isSameInstanceAs(expectedFilters);
+  }
+
+  @Test
+  public void testHasSelectionReturnsTrue() {
+    textView.setText("1", TextView.BufferType.SPANNABLE);
+    textView.onTextContextMenuItem(android.R.id.selectAll);
+    assertTrue(textView.hasSelection());
+  }
+
+  @Test
+  public void testHasSelectionReturnsFalse() {
+    textView.setText("1", TextView.BufferType.SPANNABLE);
+    assertFalse(textView.hasSelection());
+  }
+
+  @Test
+  public void whenSettingTextToNull_WatchersSeeEmptyString() {
+    TextWatcher mockTextWatcher = mock(TextWatcher.class);
+    textView.addTextChangedListener(mockTextWatcher);
+
+    textView.setText(null);
+
+    ArgumentCaptor<SpannableStringBuilder> builderCaptor = ArgumentCaptor.forClass(SpannableStringBuilder.class);
+    verify(mockTextWatcher).onTextChanged(builderCaptor.capture(), eq(0), eq(0), eq(0));
+    assertThat(builderCaptor.getValue().toString()).isEmpty();
+  }
+
+  @Test
+  public void getPaint_returnsNonNull() {
+    assertNotNull(textView.getPaint());
+  }
+
+  @Test
+  public void testNoArgAppend() {
+    textView.setText("a");
+    textView.append("b");
+    assertThat(textView.getText().toString()).isEqualTo("ab");
+  }
+
+  @Test
+  public void setTextSize_shouldHandleDips() {
+    textView.getContext().getResources().getDisplayMetrics().density = 1.5f;
+    textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 10);
+    assertThat(textView.getTextSize()).isEqualTo(15f);
+    textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 20);
+    assertThat(textView.getTextSize()).isEqualTo(30f);
+  }
+
+  @Test
+  public void setTextSize_shouldHandleSp() {
+    textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10);
+    assertThat(textView.getTextSize()).isEqualTo(10f);
+
+    textView.getContext().getResources().getDisplayMetrics().scaledDensity = 1.5f;
+
+    textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10);
+    assertThat(textView.getTextSize()).isEqualTo(15f);
+  }
+
+  @Test
+  public void setTextSize_shouldHandlePixels() {
+    textView.getContext().getResources().getDisplayMetrics().density = 1.5f;
+    textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, 10);
+    assertThat(textView.getTextSize()).isEqualTo(10f);
+    textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, 20);
+    assertThat(textView.getTextSize()).isEqualTo(20f);
+  }
+
+  @Test
+  public void getPaintFlagsAndSetPaintFlags_shouldWork() {
+    textView.setPaintFlags(100);
+    assertThat(textView.getPaintFlags()).isEqualTo(100);
+  }
+
+  @Test
+  public void setCompoundDrawablesWithIntrinsicBounds_setsValues() {
+    textView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.l0_red, R.drawable.l1_orange, R.drawable.l2_yellow, R.drawable.l3_green);
+    assertThat(shadowOf(textView).getCompoundDrawablesWithIntrinsicBoundsLeft()).isEqualTo(R.drawable.l0_red);
+    assertThat(shadowOf(textView).getCompoundDrawablesWithIntrinsicBoundsTop()).isEqualTo(R.drawable.l1_orange);
+    assertThat(shadowOf(textView).getCompoundDrawablesWithIntrinsicBoundsRight()).isEqualTo(R.drawable.l2_yellow);
+    assertThat(shadowOf(textView).getCompoundDrawablesWithIntrinsicBoundsBottom()).isEqualTo(R.drawable.l3_green);
+  }
+
+  private List<MockTextWatcher> anyNumberOfTextWatchers() {
+    List<MockTextWatcher> mockTextWatchers = new ArrayList<>();
+    int numberBetweenOneAndTen = new Random().nextInt(10) + 1;
+    for (int i = 0; i < numberBetweenOneAndTen; i++) {
+      mockTextWatchers.add(new MockTextWatcher());
+    }
+    return mockTextWatchers;
+  }
+
+  private void assertEachTextWatcherEventWasInvoked(MockTextWatcher mockTextWatcher) {
+    assertTrue("Expected each TextWatcher event to"
+                   + " have"
+                   + " been"
+                   + " invoked"
+                   + " once", mockTextWatcher.methodsCalled.size() == 3);
+
+    assertThat(mockTextWatcher.methodsCalled.get(0)).isEqualTo("beforeTextChanged");
+    assertThat(mockTextWatcher.methodsCalled.get(1)).isEqualTo("onTextChanged");
+    assertThat(mockTextWatcher.methodsCalled.get(2)).isEqualTo("afterTextChanged");
+  }
+
+  private List<String> urlStringsFrom(URLSpan[] urlSpans) {
+    List<String> urls = new ArrayList<>();
+    for (URLSpan urlSpan : urlSpans) {
+      urls.add(urlSpan.getURL());
+    }
+    return urls;
+  }
+
+  private static class TestOnEditorActionListener implements TextView.OnEditorActionListener {
+    private TextView textView;
+    private int sentImeId;
+
+    @Override
+    public boolean onEditorAction(TextView textView, int sentImeId, KeyEvent keyEvent) {
+      this.textView = textView;
+      this.sentImeId = sentImeId;
+      return false;
+    }
+  }
+
+  private static class MockTextWatcher implements TextWatcher {
+
+    List<String> methodsCalled = new ArrayList<>();
+    Editable afterTextChangeArgument;
+
+    @Override
+    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+      methodsCalled.add("beforeTextChanged");
+    }
+
+    @Override
+    public void onTextChanged(CharSequence s, int start, int before, int count) {
+      methodsCalled.add("onTextChanged");
+    }
+
+    @Override
+    public void afterTextChanged(Editable s) {
+      methodsCalled.add("afterTextChanged");
+      afterTextChangeArgument = s;
+    }
+
+  }
+
+  private static class TestMovementMethod implements MovementMethod {
+    public MotionEvent event;
+
+    @Override
+    public void initialize(TextView widget, Spannable text) {}
+
+    @Override
+    public boolean onKeyDown(TextView widget, Spannable text, int keyCode, KeyEvent event) {
+      return false;
+    }
+
+    @Override
+    public boolean onKeyUp(TextView widget, Spannable text, int keyCode, KeyEvent event) {
+      return false;
+    }
+
+    @Override
+    public boolean onKeyOther(TextView view, Spannable text, KeyEvent event) {
+      return false;
+    }
+
+    @Override
+    public void onTakeFocus(TextView widget, Spannable text, int direction) {
+    }
+
+    @Override
+    public boolean onTrackballEvent(TextView widget, Spannable text, MotionEvent event) {
+      return false;
+    }
+
+    @Override
+    public boolean onTouchEvent(TextView widget, Spannable text, MotionEvent event) {
+      this.event = event;
+      return false;
+    }
+
+    @Override
+    public boolean canSelectArbitrarily() {
+      return false;
+    }
+
+    @Override
+    public boolean onGenericMotionEvent(TextView widget, Spannable text,
+                                        MotionEvent event) {
+      return false;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowThemeTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowThemeTest.java
new file mode 100644
index 0000000..38d0702
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowThemeTest.java
@@ -0,0 +1,336 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Robolectric.buildActivity;
+
+import android.app.Activity;
+import android.content.res.Resources;
+import android.content.res.Resources.Theme;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.graphics.drawable.ColorDrawable;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.util.Xml;
+import android.view.View;
+import android.widget.Button;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.lang.ref.WeakReference;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+import org.robolectric.res.android.Registries;
+import org.robolectric.shadows.testing.TestActivity;
+import org.robolectric.util.ReflectionHelpers;
+import org.xmlpull.v1.XmlPullParser;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowThemeTest {
+  private Resources resources;
+
+  @Before
+  public void setUp() throws Exception {
+    resources = ApplicationProvider.getApplicationContext().getResources();
+  }
+
+  @After
+  public void tearDown() {
+    ShadowLegacyAssetManager.strictErrors = false;
+  }
+
+  @Test
+  public void
+      whenExplicitlySetOnActivity_afterSetContentView_activityGetsThemeFromActivityInManifest() {
+    TestActivity activity = buildActivity(TestActivityWithAnotherTheme.class).create().get();
+    activity.setTheme(R.style.Theme_Robolectric);
+    Button theButton = activity.findViewById(R.id.button);
+    ColorDrawable background = (ColorDrawable) theButton.getBackground();
+    assertThat(background.getColor()).isEqualTo(0xffff0000);
+  }
+
+  @Test
+  public void whenExplicitlySetOnActivity_beforeSetContentView_activityUsesNewTheme() {
+    ActivityController<TestActivityWithAnotherTheme> activityController = buildActivity(TestActivityWithAnotherTheme.class);
+    TestActivity activity = activityController.get();
+    activity.setTheme(R.style.Theme_Robolectric);
+    activityController.create();
+    Button theButton = activity.findViewById(R.id.button);
+    ColorDrawable background = (ColorDrawable) theButton.getBackground();
+    assertThat(background.getColor()).isEqualTo(0xff00ff00);
+  }
+
+  @Test
+  public void whenSetOnActivityInManifest_activityGetsThemeFromActivityInManifest() {
+    TestActivity activity = buildActivity(TestActivityWithAnotherTheme.class).create().get();
+    Button theButton = activity.findViewById(R.id.button);
+    ColorDrawable background = (ColorDrawable) theButton.getBackground();
+    assertThat(background.getColor()).isEqualTo(0xffff0000);
+  }
+
+  @Test
+  public void whenNotSetOnActivityInManifest_activityGetsThemeFromApplicationInManifest() {
+    TestActivity activity = buildActivity(TestActivity.class).create().get();
+    Button theButton = activity.findViewById(R.id.button);
+    ColorDrawable background = (ColorDrawable) theButton.getBackground();
+    assertThat(background.getColor()).isEqualTo(0xff00ff00);
+  }
+
+  @Test
+  public void shouldResolveReferencesThatStartWithAQuestionMark() {
+    TestActivity activity = buildActivity(TestActivityWithAnotherTheme.class).create().get();
+    Button theButton = activity.findViewById(R.id.button);
+    assertThat(theButton.getMinWidth()).isEqualTo(8);
+    assertThat(theButton.getMinHeight()).isEqualTo(8);
+  }
+
+  @Test
+  public void forStylesWithImplicitParents_shouldInheritValuesNotDefinedInChild() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric_EmptyParent, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.string1}).hasValue(0)).isFalse();
+  }
+
+  @Test
+  public void shouldApplyParentStylesFromAttrs() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.SimpleParent, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.parent_string}).getString(0))
+        .isEqualTo("parent string");
+  }
+
+  @Test
+  public void applyStyle_shouldOverrideParentAttrs() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.SimpleChildWithOverride, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.parent_string}).getString(0))
+        .isEqualTo("parent string overridden by child");
+  }
+
+  @Test
+  public void applyStyle_shouldOverrideImplicitParentAttrs() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.SimpleParent_ImplicitChild, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.parent_string}).getString(0))
+        .isEqualTo("parent string overridden by child");
+  }
+
+  @Test
+  public void applyStyle_shouldInheritParentAttrs() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.SimpleChildWithAdditionalAttributes, true);
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.child_string}).getString(0))
+        .isEqualTo("child string");
+    assertThat(theme.obtainStyledAttributes(new int[] {R.attr.parent_string}).getString(0))
+        .isEqualTo("parent string");
+  }
+
+  @Test
+  public void setTo_shouldCopyAllAttributesToEmptyTheme() {
+    Resources.Theme theme1 = resources.newTheme();
+    theme1.applyStyle(R.style.Theme_Robolectric, false);
+    assertThat(theme1.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+
+    Resources.Theme theme2 = resources.newTheme();
+    theme2.setTo(theme1);
+
+    assertThat(theme2.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+  }
+
+  @Test
+  public void setTo_whenDestThemeIsModified_sourceThemeShouldNotMutate() {
+    Resources.Theme sourceTheme = resources.newTheme();
+    sourceTheme.applyStyle(R.style.StyleA, false);
+
+    Resources.Theme destTheme = resources.newTheme();
+    destTheme.setTo(sourceTheme);
+    destTheme.applyStyle(R.style.StyleB, true);
+
+    assertThat(destTheme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style B");
+    assertThat(sourceTheme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style A");
+  }
+
+  @Test
+  public void setTo_whenSourceThemeIsModified_destThemeShouldNotMutate() {
+    Resources.Theme sourceTheme = resources.newTheme();
+    sourceTheme.applyStyle(R.style.StyleA, false);
+
+    Resources.Theme destTheme = resources.newTheme();
+    destTheme.setTo(sourceTheme);
+    sourceTheme.applyStyle(R.style.StyleB, true);
+
+    assertThat(destTheme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style A");
+    assertThat(sourceTheme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style B");
+  }
+
+  @Test
+  public void applyStyle_withForceFalse_shouldApplyButNotOverwriteExistingAttributeValues() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.StyleA, false);
+    assertThat(theme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style A");
+
+    theme.applyStyle(R.style.StyleB, false);
+    assertThat(theme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style A");
+  }
+
+  @Test
+  public void applyStyle_withForceTrue_shouldApplyAndOverwriteExistingAttributeValues() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.StyleA, false);
+    assertThat(theme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style A");
+
+    theme.applyStyle(R.style.StyleB, true);
+    assertThat(theme.obtainStyledAttributes(new int[]{R.attr.string1}).getString(0))
+        .isEqualTo("string 1 from style B");
+  }
+
+  @Test
+  public void whenStyleSpecifiesAttr_obtainStyledAttribute_findsCorrectValue() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric, false);
+    theme.applyStyle(R.style.Theme_ThemeContainingStyleReferences, true);
+
+    assertThat(theme.obtainStyledAttributes(
+        Robolectric.buildAttributeSet().setStyleAttribute("?attr/styleReference").build(),
+        new int[]{R.attr.string2}, 0, 0).getString(0))
+        .isEqualTo("string 2 from YetAnotherStyle");
+
+    assertThat(theme.obtainStyledAttributes(
+        Robolectric.buildAttributeSet().setStyleAttribute("?styleReference").build(),
+        new int[]{R.attr.string2}, 0, 0).getString(0))
+        .isEqualTo("string 2 from YetAnotherStyle");
+  }
+
+  @Test
+  public void xml_whenStyleSpecifiesAttr_obtainStyledAttribute_findsCorrectValue() throws Exception {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric, false);
+    theme.applyStyle(R.style.Theme_ThemeContainingStyleReferences, true);
+
+    assertThat(theme.obtainStyledAttributes(getFirstElementAttrSet(R.xml.temp),
+        new int[]{R.attr.string2}, 0, 0).getString(0))
+        .isEqualTo("string 2 from YetAnotherStyle");
+
+    assertThat(theme.obtainStyledAttributes(getFirstElementAttrSet(R.xml.temp_parent),
+        new int[]{R.attr.string2}, 0, 0).getString(0))
+        .isEqualTo("string 2 from YetAnotherStyle");
+  }
+
+  @Test
+  public void whenAttrSetAttrSpecifiesAttr_obtainStyledAttribute_returnsItsValue() {
+    Resources.Theme theme = resources.newTheme();
+    theme.applyStyle(R.style.Theme_Robolectric, false);
+    theme.applyStyle(R.style.Theme_ThemeContainingStyleReferences, true);
+
+    assertThat(theme.obtainStyledAttributes(
+        Robolectric.buildAttributeSet().addAttribute(R.attr.string2, "?attr/string1").build(),
+        new int[]{R.attr.string2}, 0, 0).getString(0))
+        .isEqualTo("string 1 from Theme.Robolectric");
+  }
+
+  @Test
+  public void dimenRef() {
+    AttributeSet attributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.layout_height, "@dimen/test_px_dimen")
+        .build();
+    TypedArray typedArray = resources.newTheme().obtainStyledAttributes(
+        attributeSet, new int[]{android.R.attr.layout_height}, 0, 0);
+    assertThat(typedArray.getDimensionPixelSize(0, -1)).isEqualTo(15);
+  }
+
+  @Test
+  public void dimenRefRef() {
+    AttributeSet attributeSet = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.layout_height, "@dimen/ref_to_px_dimen")
+        .build();
+    TypedArray typedArray = resources.newTheme().obtainStyledAttributes(
+        attributeSet, new int[]{android.R.attr.layout_height}, 0, 0);
+    assertThat(typedArray.getDimensionPixelSize(0, -1)).isEqualTo(15);
+  }
+
+  @Test
+  public void obtainStyledAttributes_shouldFindAttributeInDefaultStyle() {
+    Theme theme = resources.newTheme();
+    TypedArray typedArray = theme.obtainStyledAttributes(R.style.StyleA, new int[]{R.attr.string1});
+    assertThat(typedArray.getString(0)).isEqualTo("string 1 from style A");
+  }
+
+  @Test
+  public void shouldApplyFromStyleAttribute() {
+    TestWithStyleAttrActivity activity = buildActivity(TestWithStyleAttrActivity.class).create().get();
+    View button = activity.findViewById(R.id.button);
+    assertThat(button.getLayoutParams().width).isEqualTo(42); // comes via style attr
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.N)
+  public void shouldFreeNativeObjectInRegistry() {
+    final AtomicLong themeId = new AtomicLong(0);
+    Supplier<Theme> themeSupplier =
+        () -> {
+          Theme theme = resources.newTheme();
+          long nativeId =
+              ReflectionHelpers.getField(ReflectionHelpers.getField(theme, "mThemeImpl"), "mTheme");
+          themeId.set(nativeId);
+          return theme;
+        };
+
+    WeakReference<Theme> weakRef = new WeakReference<>(themeSupplier.get());
+    awaitFinalized(weakRef);
+    assertThat(Registries.NATIVE_THEME9_REGISTRY.peekNativeObject(themeId.get())).isNull();
+  }
+
+  private static <T> void awaitFinalized(WeakReference<T> weakRef) {
+    final CountDownLatch latch = new CountDownLatch(1);
+    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+    while (System.nanoTime() < deadline) {
+      if (weakRef.get() == null) {
+        return;
+      }
+      try {
+        System.gc();
+        latch.await(100, TimeUnit.MILLISECONDS);
+      } catch (InterruptedException e) {
+        throw new AssertionError(e);
+      }
+    }
+  }
+
+  ////////////////////////////
+
+  private XmlResourceParser getFirstElementAttrSet(int resId) throws Exception {
+    XmlResourceParser xml = resources.getXml(resId);
+    assertThat(xml.next()).isEqualTo(XmlPullParser.START_DOCUMENT);
+    assertThat(xml.nextTag()).isEqualTo(XmlPullParser.START_TAG);
+    return (XmlResourceParser) Xml.asAttributeSet(xml);
+  }
+
+  public static class TestActivityWithAnotherTheme extends TestActivity {
+  }
+
+  public static class TestWithStyleAttrActivity extends Activity {
+    @Override protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      setContentView(R.layout.styles_button_with_style_layout);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTileServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTileServiceTest.java
new file mode 100644
index 0000000..5b1415b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTileServiceTest.java
@@ -0,0 +1,83 @@
+package org.robolectric.shadows;
+
+import static androidx.test.ext.truth.content.IntentSubject.assertThat;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Build;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link org.robolectric.shadows.ShadowTileService}. */
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = Build.VERSION_CODES.N)
+public final class ShadowTileServiceTest {
+
+  private MyTileService tileService;
+
+  @Before
+  public void setUp() {
+    tileService = Robolectric.setupService(MyTileService.class);
+  }
+
+  @Test
+  public void getTile() {
+    Tile tile = tileService.getQsTile();
+    assertThat(tile).isNotNull();
+  }
+
+  @Test
+  public void isLocked() {
+    assertThat(tileService.isLocked()).isFalse();
+    shadowOf(tileService).setLocked(true);
+    assertThat(tileService.isLocked()).isTrue();
+  }
+
+  @Test
+  public void unlockAndRun() {
+    shadowOf(tileService).setLocked(true);
+    assertThat(tileService.isLocked()).isTrue();
+    tileService.unlockAndRun(null);
+    assertThat(tileService.isLocked()).isFalse();
+
+    shadowOf(tileService).setLocked(true);
+    boolean[] result = new boolean[1];
+    Runnable runnable = () -> result[0] = true;
+    tileService.unlockAndRun(runnable);
+    assertThat(result[0]).isTrue();
+  }
+
+  @Test
+  public void startActivityAndCollapse() {
+    tileService.startActivityAndCollapse(
+        new Intent().setComponent(new ComponentName("foo.bar", "Activity")));
+
+    assertThat(
+            shadowOf((Application) ApplicationProvider.getApplicationContext())
+                .getNextStartedActivity())
+        .hasComponent(new ComponentName("foo.bar", "Activity"));
+  }
+
+  @Test
+  public void requestListeningState_doesNotCrash() {
+    TileService.requestListeningState(
+        RuntimeEnvironment.getApplication(), ComponentName.createRelative("pkg", "cls"));
+  }
+
+  /**
+   * A subclass of {@link TileService} for testing, To mimic the way {@link TileService} is used in
+   * production.
+   */
+  static class MyTileService extends TileService {}
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTileTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTileTest.java
new file mode 100644
index 0000000..cf968b9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTileTest.java
@@ -0,0 +1,34 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.Shadows.shadowOf;
+
+import android.os.Build;
+import android.service.quicksettings.Tile;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** test for {@link org.robolectric.shadows.ShadowTile}. */
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = Build.VERSION_CODES.N)
+public final class ShadowTileTest {
+
+  private Tile tile;
+  private ShadowTile shadowTile;
+
+  @Before
+  public void setUp() {
+    tile = Shadow.newInstanceOf(Tile.class);
+    shadowTile = shadowOf(tile);
+  }
+
+  @Test
+  public void updateTile() {
+    // this test passes if updateTile() throws no Exception.
+    tile.updateTile();
+    shadowTile.updateTile();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeManagerTest.java
new file mode 100644
index 0000000..9da4c41
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeManagerTest.java
@@ -0,0 +1,59 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.time.TimeManager;
+import android.app.time.TimeZoneCapabilitiesAndConfig;
+import android.app.time.TimeZoneConfiguration;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowTimeManager} */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Build.VERSION_CODES.S)
+public final class ShadowTimeManagerTest {
+
+  @Test
+  public void registeredAsSystemService() {
+    TimeManager timeManager =
+        ApplicationProvider.getApplicationContext().getSystemService(TimeManager.class);
+    assertThat(timeManager).isNotNull();
+  }
+
+  @Test
+  public void updateConfigurationOverridesPreviousValues() {
+    TimeManager timeManager =
+        ApplicationProvider.getApplicationContext().getSystemService(TimeManager.class);
+    Assume.assumeNotNull(timeManager);
+
+    TimeZoneConfiguration configuration =
+        new TimeZoneConfiguration.Builder()
+            .setAutoDetectionEnabled(false)
+            .setGeoDetectionEnabled(false)
+            .build();
+
+    timeManager.updateTimeZoneConfiguration(configuration);
+
+    TimeZoneCapabilitiesAndConfig capabilitiesAndConfig =
+        timeManager.getTimeZoneCapabilitiesAndConfig();
+
+    assertThat(capabilitiesAndConfig.getConfiguration()).isEqualTo(configuration);
+
+    TimeZoneConfiguration updatedConfiguration =
+        new TimeZoneConfiguration.Builder()
+            .setAutoDetectionEnabled(true)
+            .setGeoDetectionEnabled(true)
+            .build();
+
+    timeManager.updateTimeZoneConfiguration(updatedConfiguration);
+
+    capabilitiesAndConfig = timeManager.getTimeZoneCapabilitiesAndConfig();
+
+    assertThat(capabilitiesAndConfig.getConfiguration()).isEqualTo(updatedConfiguration);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTimePickerDialogTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimePickerDialogTest.java
new file mode 100644
index 0000000..bee55cb
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimePickerDialogTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.TimePickerDialog;
+import android.os.Bundle;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTimePickerDialogTest {
+
+  @Test
+  public void testGettersReturnInitialConstructorValues() {
+    TimePickerDialog timePickerDialog =
+        new TimePickerDialog(ApplicationProvider.getApplicationContext(), 0, null, 6, 55, true);
+    ShadowTimePickerDialog shadow = shadowOf(timePickerDialog);
+    assertThat(shadow.getHourOfDay()).isEqualTo(6);
+    assertThat(shadow.getMinute()).isEqualTo(55);
+    assertThat(shadow.getIs24HourView()).isEqualTo(true);
+  }
+
+  @Test
+  public void updateTime_shouldUpdateHourAndMinute() {
+    TimePickerDialog timePickerDialog =
+        new TimePickerDialog(ApplicationProvider.getApplicationContext(), 0, null, 6, 55, true);
+    timePickerDialog.updateTime(1, 2);
+
+    Bundle bundle = timePickerDialog.onSaveInstanceState();
+    assertThat(bundle.getInt("hour")).isEqualTo(1);
+    assertThat(bundle.getInt("minute")).isEqualTo(2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeTest.java
new file mode 100644
index 0000000..809437a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeTest.java
@@ -0,0 +1,92 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import android.os.SystemClock;
+import android.text.format.Time;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN_MR2)
+public class ShadowTimeTest {
+
+  @Test
+  public void shouldFormatAllFormats() {
+    Time t = new Time("Asia/Tokyo");
+    t.set(1407496560000L);
+
+    // Don't check for %c (the docs state not to use it, and it doesn't work correctly).
+    assertEquals("Fri", t.format("%a"));
+    assertEquals("Friday", t.format("%A"));
+    assertEquals("Aug", t.format("%b"));
+    assertEquals("August", t.format("%B"));
+    assertEquals("20", t.format("%C"));
+    assertEquals("08", t.format("%d"));
+    assertEquals("08/08/14", t.format("%D"));
+    assertEquals(" 8", t.format("%e"));
+    assertEquals("2014-08-08", t.format("%F"));
+    assertEquals("14", t.format("%g"));
+    assertEquals("2014", t.format("%G"));
+    assertEquals("Aug", t.format("%h"));
+    assertEquals("20", t.format("%H"));
+    assertEquals("08", t.format("%I"));
+    assertEquals("220", t.format("%j"));
+    assertEquals("20", t.format("%k"));
+    assertEquals(" 8", t.format("%l"));
+    assertEquals("08", t.format("%m"));
+    assertEquals("16", t.format("%M"));
+    assertEquals("\n", t.format("%n"));
+    assertEquals("PM", t.format("%p"));
+    assertEquals("pm", t.format("%P"));
+    assertEquals("08:16:00 PM", t.format("%r"));
+    assertEquals("20:16", t.format("%R"));
+    assertEquals("1407496560", t.format("%s"));
+    assertEquals("00", t.format("%S"));
+    assertEquals("\t", t.format("%t"));
+    assertEquals("20:16:00", t.format("%T"));
+    assertEquals("5", t.format("%u"));
+    assertEquals("32", t.format("%V"));
+    assertEquals("5", t.format("%w"));
+    assertEquals("14", t.format("%y"));
+    assertEquals("2014", t.format("%Y"));
+    assertEquals("+0900", t.format("%z"));
+    assertEquals("JST", t.format("%Z"));
+
+    // Padding.
+    assertEquals("8", t.format("%-l"));
+    assertEquals(" 8", t.format("%_l"));
+    assertEquals("08", t.format("%0l"));
+
+    // Escape.
+    assertEquals("%", t.format("%%"));
+  }
+
+  @Test
+  @Config(maxSdk = KITKAT_WATCH)
+  // these fail on LOLLIPOP+; is the shadow impl of format correct for pre-LOLLIPOP?
+  public void shouldFormatAllFormats_withQuestionableResults() {
+    Time t = new Time("Asia/Tokyo");
+    t.set(1407496560000L);
+
+    assertEquals("08/08/2014", t.format("%x"));
+    assertEquals("08:16:00 PM", t.format("%X"));
+
+    // Case.
+    assertEquals("PM", t.format("%^P"));
+    assertEquals("PM", t.format("%#P"));
+  }
+
+  @Test
+  public void shouldSetToNow() {
+    Time t = new Time();
+    SystemClock.setCurrentTimeMillis(1000);
+    t.setToNow();
+    assertThat(t.toMillis(false)).isEqualTo(1000);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeZoneFinderTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeZoneFinderTest.java
new file mode 100644
index 0000000..89b9f0b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTimeZoneFinderTest.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.icu.util.TimeZone;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Unit tests for {@link ShadowTimeZoneFinder}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowTimeZoneFinderTest {
+
+  @Test
+  @Config(minSdk = O, maxSdk = P)
+  public void lookupTimeZonesByCountry_shouldReturnExpectedTimeZones() throws Exception {
+    Class<?> cls = Class.forName("libcore.util.TimeZoneFinder");
+    lookupTimeZonesByCountryAndAssert(cls);
+  }
+
+  @Test
+  @Config(minSdk = Q, maxSdk = Q)
+  public void lookupTimeZonesByCountry_shouldReturnExpectedTimeZones_Q() throws Exception {
+    Class<?> cls = Class.forName("libcore.timezone.TimeZoneFinder");
+    lookupTimeZonesByCountryAndAssert(cls);
+  }
+
+  private void lookupTimeZonesByCountryAndAssert(Class<?> cls) {
+    Object timeZoneFinder = ReflectionHelpers.callStaticMethod(cls, "getInstance");
+    List<TimeZone> timezones =
+        ReflectionHelpers.callInstanceMethod(
+            cls,
+            timeZoneFinder,
+            "lookupTimeZonesByCountry",
+            ClassParameter.from(String.class, "us"));
+
+    assertThat(timezones.stream().map(TimeZone::getID).collect(Collectors.toList()))
+        .containsAtLeast("America/Los_Angeles", "America/New_York", "Pacific/Honolulu");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowToastTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowToastTest.java
new file mode 100644
index 0000000..bb3d4d7
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowToastTest.java
@@ -0,0 +1,121 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowToastTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void shouldHaveShortDuration() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    assertThat(toast).isNotNull();
+    assertThat(toast.getDuration()).isEqualTo(Toast.LENGTH_SHORT);
+  }
+
+  @Test
+  public void shouldHaveLongDuration() {
+    Toast toast = Toast.makeText(context, "long toast", Toast.LENGTH_LONG);
+    assertThat(toast).isNotNull();
+    assertThat(toast.getDuration()).isEqualTo(Toast.LENGTH_LONG);
+  }
+
+  @Test
+  public void shouldMakeTextCorrectly() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    assertThat(toast).isNotNull();
+    assertThat(toast.getDuration()).isEqualTo(Toast.LENGTH_SHORT);
+    toast.show();
+    assertThat(ShadowToast.getLatestToast()).isSameInstanceAs(toast);
+    assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo("short toast");
+    assertThat(ShadowToast.showedToast("short toast")).isTrue();
+  }
+
+  @Test
+  public void shouldSetTextCorrectly() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    toast.setText("other toast");
+    toast.show();
+    assertThat(ShadowToast.getLatestToast()).isSameInstanceAs(toast);
+    assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo("other toast");
+    assertThat(ShadowToast.showedToast("other toast")).isTrue();
+  }
+
+  @Test
+  public void shouldSetTextWithIdCorrectly() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    toast.setText(R.string.hello);
+    toast.show();
+    assertThat(ShadowToast.getLatestToast()).isSameInstanceAs(toast);
+    assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo("Hello");
+    assertThat(ShadowToast.showedToast("Hello")).isTrue();
+  }
+
+  @Test
+  public void shouldSetViewCorrectly() {
+    Toast toast = new Toast(context);
+    toast.setDuration(Toast.LENGTH_SHORT);
+    final View view = new TextView(context);
+    toast.setView(view);
+    assertThat(toast.getView()).isSameInstanceAs(view);
+  }
+
+  @Test
+  public void shouldSetGravityCorrectly() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    assertThat(toast).isNotNull();
+    toast.setGravity(Gravity.CENTER, 0, 0);
+    assertThat(toast.getGravity()).isEqualTo(Gravity.CENTER);
+  }
+
+  @Test
+  public void shouldSetOffsetsCorrectly() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    toast.setGravity(0, 12, 34);
+    assertThat(toast.getXOffset()).isEqualTo(12);
+    assertThat(toast.getYOffset()).isEqualTo(34);
+  }
+
+  @Test
+  public void shouldCountToastsCorrectly() {
+    assertThat(ShadowToast.shownToastCount()).isEqualTo(0);
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    assertThat(toast).isNotNull();
+    toast.show();
+    toast.show();
+    toast.show();
+    assertThat(ShadowToast.shownToastCount()).isEqualTo(3);
+    ShadowToast.reset();
+    assertThat(ShadowToast.shownToastCount()).isEqualTo(0);
+    toast.show();
+    toast.show();
+    assertThat(ShadowToast.shownToastCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldBeCancelled() {
+    Toast toast = Toast.makeText(context, "short toast", Toast.LENGTH_SHORT);
+    toast.cancel();
+    ShadowToast shadowToast = shadowOf(toast);
+    assertThat(shadowToast.isCancelled()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowToneGeneratorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowToneGeneratorTest.java
new file mode 100644
index 0000000..1247898
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowToneGeneratorTest.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowToneGenerator.MAXIMUM_STORED_TONES;
+
+import android.media.AudioManager;
+import android.media.ToneGenerator;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.time.Duration;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadows.ShadowToneGenerator.Tone;
+
+/** Test class for ShadowToneGenerator */
+@RunWith(AndroidJUnit4.class)
+public class ShadowToneGeneratorTest {
+  private static final int TONE_RELATIVE_VOLUME = 80;
+  private ToneGenerator toneGenerator;
+
+  @Before
+  public void setUp() {
+    toneGenerator = new ToneGenerator(AudioManager.STREAM_DTMF, TONE_RELATIVE_VOLUME);
+  }
+
+  @Test
+  public void testProvideToneAndDuration() {
+    assertThat(toneGenerator.startTone(ToneGenerator.TONE_CDMA_ALERT_NETWORK_LITE)).isTrue();
+    Tone initialTone =
+        Tone.create(ToneGenerator.TONE_CDMA_ALERT_NETWORK_LITE, Duration.ofMillis(-1));
+
+    assertThat(ShadowToneGenerator.getPlayedTones()).containsExactly(initialTone);
+
+    for (int i = 0; i < MAXIMUM_STORED_TONES; i++) {
+      assertThat(toneGenerator.startTone(ToneGenerator.TONE_CDMA_ABBR_ALERT, 1000)).isTrue();
+    }
+
+    assertThat(ShadowToneGenerator.getPlayedTones()).hasSize(MAXIMUM_STORED_TONES);
+
+    assertThat(ShadowToneGenerator.getPlayedTones()).doesNotContain(initialTone);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTouchDelegateTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTouchDelegateTest.java
new file mode 100644
index 0000000..5d92a3f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTouchDelegateTest.java
@@ -0,0 +1,68 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.Rect;
+import android.view.MotionEvent;
+import android.view.TouchDelegate;
+import android.view.View;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTouchDelegateTest {
+
+  private ShadowTouchDelegate td;
+  private Rect rect;
+  private View view;
+
+  @Before
+  public void setUp() throws Exception {
+    rect = new Rect(1, 2, 3, 4);
+    view = new View(ApplicationProvider.getApplicationContext());
+    TouchDelegate realTD = new TouchDelegate(rect, view);
+    td = Shadows.shadowOf(realTD);
+  }
+
+  @Test
+  public void testBounds() {
+    Rect bounds = td.getBounds();
+    assertThat(bounds).isEqualTo(rect);
+  }
+
+  @Test
+  public void testDelegateView() {
+    View view = td.getDelegateView();
+    assertThat(view).isEqualTo(this.view);
+  }
+
+  @Test
+  public void testRealObjectIsFunctional() {
+    // Instantiate a TouchDelegate using the Shadow construction APIs and make sure that the
+    // underlying real object's constructor gets instantiated by verifying that the returned object
+    // behaves as expected.
+    Rect rect = new Rect(100, 5000, 200, 6000);
+    TouchDelegate td =
+        Shadow.newInstance(
+            TouchDelegate.class,
+            new Class[] { Rect.class, View.class },
+            new Object[] {rect, view});
+    // Make the underlying view clickable. This ensures that if a touch event does get delegated, it
+    // gets reported as having been handled.
+    view.setClickable(true);
+
+    // Verify that a touch event in the center of the rectangle is handled.
+    assertThat(
+        td.onTouchEvent(
+            MotionEvent.obtain(1, 1, MotionEvent.ACTION_DOWN, rect.centerX(), rect.centerY(), 0)))
+        .isTrue();
+    // Verify that a touch event outside of the rectangle is not handled.
+    assertThat(td.onTouchEvent(MotionEvent.obtain(1, 1, MotionEvent.ACTION_DOWN, 5f, 10f, 0)))
+        .isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTraceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTraceTest.java
new file mode 100644
index 0000000..07b57f9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTraceTest.java
@@ -0,0 +1,362 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import android.os.Trace;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.base.VerifyException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowTrace.AsyncTraceSection;
+import org.robolectric.shadows.ShadowTrace.Counter;
+
+/** Test for {@link ShadowTrace}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN_MR2)
+public class ShadowTraceTest {
+  private static final String VERY_LONG_TAG_NAME = String.format(String.format("%%%ds", 128), "A");
+
+  // Arbitrary value
+  private static final Integer COOKIE = 353882576;
+
+  @Test
+  public void beginSection_calledOnce_addsSection() {
+    Trace.beginSection("section1");
+
+    assertThat(ShadowTrace.getCurrentSections()).containsExactly("section1");
+    assertThat(ShadowTrace.getPreviousSections()).isEmpty();
+  }
+
+  @Test
+  public void beginSection_calledTwice_addsBothSections() {
+    Trace.beginSection("section1");
+    Trace.beginSection("section2");
+
+    assertThat(ShadowTrace.getCurrentSections()).containsExactly("section1", "section2");
+    assertThat(ShadowTrace.getPreviousSections()).isEmpty();
+  }
+
+  @Test
+  public void beginSection_tagIsNull_throwsNullPointerException() {
+    try {
+      Trace.beginSection(null);
+      fail("Must throw");
+    } catch (NullPointerException e) {
+      // Must throw.
+    }
+  }
+
+  @Test
+  public void beginSection_tagIsNullAndCrashDisabled_doesNotThrow() {
+    ShadowTrace.doNotUseSetCrashOnIncorrectUsage(false);
+    Trace.beginSection(null);
+    // Should not crash.
+  }
+
+  @Test
+  public void beginSection_tagIsTooLong_throwsIllegalArgumentException() {
+    try {
+      Trace.beginSection(VERY_LONG_TAG_NAME);
+      fail("Must throw");
+    } catch (IllegalArgumentException e) {
+      // Must throw.
+    }
+  }
+
+  @Test
+  public void beginSection_tagIsTooLongAndCrashDisabled_doesNotThrow() {
+    ShadowTrace.doNotUseSetCrashOnIncorrectUsage(false);
+    Trace.beginSection(VERY_LONG_TAG_NAME);
+    // Should not crash.
+  }
+
+  @Test
+  public void endSection_oneSection_closesSection() {
+    Trace.beginSection("section1");
+
+    Trace.endSection();
+
+    assertThat(ShadowTrace.getCurrentSections()).isEmpty();
+    assertThat(ShadowTrace.getPreviousSections()).containsExactly("section1");
+  }
+
+  @Test
+  public void endSection_twoSections_closesLastSection() {
+    Trace.beginSection("section1");
+    Trace.beginSection("section2");
+
+    Trace.endSection();
+
+    assertThat(ShadowTrace.getCurrentSections()).containsExactly("section1");
+    assertThat(ShadowTrace.getPreviousSections()).containsExactly("section2");
+  }
+
+  @Test
+  public void endSection_twoRecursiveSectionsAndCalledTwice_closesAllSections() {
+    Trace.beginSection("section1");
+    Trace.beginSection("section2");
+
+    Trace.endSection();
+    Trace.endSection();
+
+    assertThat(ShadowTrace.getCurrentSections()).isEmpty();
+    assertThat(ShadowTrace.getPreviousSections()).containsExactly("section2", "section1");
+  }
+
+  @Test
+  public void endSection_twoSequentialSections_closesAllSections() {
+    Trace.beginSection("section1");
+    Trace.endSection();
+    Trace.beginSection("section2");
+    Trace.endSection();
+
+    assertThat(ShadowTrace.getCurrentSections()).isEmpty();
+    assertThat(ShadowTrace.getPreviousSections()).containsExactly("section1", "section2");
+  }
+
+  @Test
+  public void endSection_calledBeforeBeginning_doesNotThrow() {
+    Trace.endSection();
+    // Should not crash.
+  }
+
+  @Test
+  public void endSection_oneSectionButCalledTwice_doesNotThrow() {
+    Trace.beginSection("section1");
+
+    Trace.endSection();
+    Trace.endSection();
+    // Should not crash.
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_calledOnce_addsSection() {
+    Trace.beginAsyncSection("section1", COOKIE);
+
+    assertThat(ShadowTrace.getCurrentAsyncSections())
+        .containsExactly(
+            AsyncTraceSection.newBuilder().setSectionName("section1").setCookie(COOKIE).build());
+    assertThat(ShadowTrace.getPreviousAsyncSections()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_calledTwice_addsBothSections() {
+    Trace.beginAsyncSection("section1", COOKIE);
+    Trace.beginAsyncSection("section2", COOKIE);
+
+    assertThat(ShadowTrace.getCurrentAsyncSections())
+        .containsExactly(
+            AsyncTraceSection.newBuilder().setSectionName("section1").setCookie(COOKIE).build(),
+            AsyncTraceSection.newBuilder().setSectionName("section2").setCookie(COOKIE).build());
+    assertThat(ShadowTrace.getPreviousAsyncSections()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_tagIsNull_throwsNullPointerException() {
+    try {
+      Trace.beginAsyncSection(null, COOKIE);
+      fail("Must throw");
+    } catch (NullPointerException e) {
+      // Must throw.
+    }
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_tagIsNullAndCrashDisabled_doesNotThrow() {
+    ShadowTrace.doNotUseSetCrashOnIncorrectUsage(false);
+    Trace.beginAsyncSection(null, COOKIE);
+    // Should not crash.
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_tagIsTooLong_throwsIllegalArgumentException() {
+    try {
+      Trace.beginAsyncSection(VERY_LONG_TAG_NAME, COOKIE);
+      fail("Must throw");
+    } catch (IllegalArgumentException e) {
+      // Must throw.
+    }
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_tagIsTooLongAndCrashDisabled_doesNotThrow() {
+    ShadowTrace.doNotUseSetCrashOnIncorrectUsage(false);
+    Trace.beginAsyncSection(VERY_LONG_TAG_NAME, COOKIE);
+    // Should not crash.
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void endAsyncSection_oneSection_closesSection() {
+    Trace.beginAsyncSection("section1", COOKIE);
+
+    Trace.endAsyncSection("section1", COOKIE);
+
+    assertThat(ShadowTrace.getCurrentAsyncSections()).isEmpty();
+    assertThat(ShadowTrace.getPreviousAsyncSections())
+        .containsExactly(
+            AsyncTraceSection.newBuilder().setSectionName("section1").setCookie(COOKIE).build());
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void async_sameSectionTwoCookies_separateTraces() {
+    Trace.beginAsyncSection("section1", COOKIE);
+    Trace.beginAsyncSection("section1", COOKIE + 1);
+
+    AsyncTraceSection sectionWithCookie =
+        AsyncTraceSection.newBuilder().setSectionName("section1").setCookie(COOKIE).build();
+    AsyncTraceSection sectionWithCookiePlusOne =
+        AsyncTraceSection.newBuilder().setSectionName("section1").setCookie(COOKIE + 1).build();
+
+    assertThat(ShadowTrace.getCurrentAsyncSections())
+        .containsExactly(sectionWithCookie, sectionWithCookiePlusOne);
+    assertThat(ShadowTrace.getPreviousAsyncSections()).isEmpty();
+
+    Trace.endAsyncSection("section1", COOKIE);
+
+    assertThat(ShadowTrace.getCurrentAsyncSections()).containsExactly(sectionWithCookiePlusOne);
+    assertThat(ShadowTrace.getPreviousAsyncSections()).containsExactly(sectionWithCookie);
+
+    Trace.endAsyncSection("section1", COOKIE + 1);
+    assertThat(ShadowTrace.getCurrentAsyncSections()).isEmpty();
+    assertThat(ShadowTrace.getPreviousAsyncSections())
+        .containsExactly(sectionWithCookie, sectionWithCookiePlusOne);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setCounter_setsValuesInOrder() {
+    Trace.setCounter(/* counterName= */ "Test", /* counterValue= */ 1);
+    Trace.setCounter(/* counterName= */ "Test", /* counterValue= */ 2);
+    Trace.setCounter(/* counterName= */ "Test", /* counterValue= */ 3);
+
+    assertThat(ShadowTrace.getCounters())
+        .containsExactlyElementsIn(
+            new Counter[] {
+              Counter.newBuilder().setName("Test").setValue(1).build(),
+              Counter.newBuilder().setName("Test").setValue(2).build(),
+              Counter.newBuilder().setName("Test").setValue(3).build()
+            })
+        .inOrder();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setCounter_nameIsNull_throwsException() {
+    assertThrows(
+        VerifyException.class,
+        () -> Trace.setCounter(/* counterName= */ null, /* counterValue= */ 1));
+  }
+
+  @Test
+  public void reset_resetsInternalState() throws Exception {
+    Trace.beginSection(/* sectionName= */ "section1");
+    Trace.endSection();
+    Trace.beginSection(/* sectionName= */ "section2");
+
+    ShadowTrace.reset();
+
+    assertThat(ShadowTrace.getCurrentSections()).isEmpty();
+    assertThat(ShadowTrace.getPreviousSections()).isEmpty();
+  }
+
+  @Test
+  public void toggleEnabledTest() {
+    Trace.beginSection("section1");
+    assertThat(ShadowTrace.isEnabled()).isTrue();
+    ShadowTrace.setEnabled(false);
+    assertThat(ShadowTrace.isEnabled()).isFalse();
+    ShadowTrace.setEnabled(true);
+    assertThat(ShadowTrace.isEnabled()).isTrue();
+    Trace.endSection();
+  }
+
+  @Test
+  public void traceFromIndependentThreads() throws ExecutionException, InterruptedException {
+    ShadowTrace.doNotUseSetCrashOnIncorrectUsage(true);
+    ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
+
+    try {
+      Trace.beginSection("main_looper_trace");
+      Future<?> f = backgroundExecutor.submit(() -> Trace.beginSection("bg_trace"));
+      f.get();
+      Trace.endSection();
+
+      assertThat(ShadowTrace.getPreviousSections()).containsExactly("main_looper_trace");
+      assertThat(ShadowTrace.getCurrentSections()).isEmpty();
+
+      f =
+          backgroundExecutor.submit(
+              () -> {
+                assertThat(ShadowTrace.getCurrentSections()).containsExactly("bg_trace");
+                assertThat(ShadowTrace.getPreviousSections()).isEmpty();
+
+                Trace.endSection();
+
+                assertThat(ShadowTrace.getPreviousSections()).containsExactly("bg_trace");
+                assertThat(ShadowTrace.getCurrentSections()).isEmpty();
+              });
+      f.get();
+    } finally {
+      backgroundExecutor.shutdown();
+    }
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void beginAsyncSection_multipleThreads_stateIsShared()
+      throws ExecutionException, InterruptedException {
+    ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
+
+    AsyncTraceSection mainLooperSection =
+        AsyncTraceSection.newBuilder()
+            .setSectionName("main_looper_trace")
+            .setCookie(COOKIE)
+            .build();
+    AsyncTraceSection bgLooperSection =
+        AsyncTraceSection.newBuilder().setSectionName("bg_trace").setCookie(COOKIE).build();
+
+    try {
+      Trace.beginAsyncSection("main_looper_trace", COOKIE);
+      Future<?> f = backgroundExecutor.submit(() -> Trace.beginAsyncSection("bg_trace", COOKIE));
+      f.get();
+
+      assertThat(ShadowTrace.getCurrentAsyncSections())
+          .containsExactly(mainLooperSection, bgLooperSection);
+      assertThat(ShadowTrace.getPreviousAsyncSections()).isEmpty();
+
+      Trace.endAsyncSection("main_looper_trace", COOKIE);
+      assertThat(ShadowTrace.getPreviousAsyncSections()).containsExactly(mainLooperSection);
+      assertThat(ShadowTrace.getCurrentAsyncSections()).containsExactly(bgLooperSection);
+
+      f =
+          backgroundExecutor.submit(
+              () -> {
+                Trace.endAsyncSection("bg_trace", COOKIE);
+                assertThat(ShadowTrace.getPreviousAsyncSections())
+                    .containsExactly(mainLooperSection, bgLooperSection);
+                assertThat(ShadowTrace.getCurrentAsyncSections()).isEmpty();
+              });
+      f.get();
+
+    } finally {
+      backgroundExecutor.shutdown();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTrafficStatsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTrafficStatsTest.java
new file mode 100644
index 0000000..671e742
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTrafficStatsTest.java
@@ -0,0 +1,107 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.TrafficStats;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTrafficStatsTest {
+
+  @Test
+  public void allUidSpecificAccessorsAreStubbed() {
+    int anything = -2;
+
+    assertThat(TrafficStats.getUidTxBytes(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidRxBytes(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidTxPackets(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidRxPackets(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidTcpTxBytes(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidTcpRxBytes(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidUdpTxBytes(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidUdpRxBytes(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidTcpTxSegments(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidTcpRxSegments(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidUdpTxPackets(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+    assertThat(TrafficStats.getUidUdpRxPackets(anything)).isEqualTo(TrafficStats.UNSUPPORTED);
+  }
+
+  @Test
+  public void setThreadStatsTagUpdateTag() {
+    int tag = 42;
+    TrafficStats.setThreadStatsTag(tag);
+    assertThat(TrafficStats.getThreadStatsTag()).isEqualTo(tag);
+  }
+
+  @Test
+  public void clearThreadStatsTagClearsTag() {
+    TrafficStats.clearThreadStatsTag();
+    assertThat(TrafficStats.getThreadStatsTag()).isEqualTo(TrafficStats.UNSUPPORTED);
+  }
+
+  @Test
+  public void setMobileTxPacketsUpdatesMobileTxPackets() {
+    assertThat(TrafficStats.getMobileTxPackets()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setMobileTxPackets(1);
+    assertThat(TrafficStats.getMobileTxPackets()).isEqualTo(1);
+  }
+
+  @Test
+  public void setMobileRxPacketsUpdatesMobileRxPackets() {
+    assertThat(TrafficStats.getMobileRxPackets()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setMobileRxPackets(2);
+    assertThat(TrafficStats.getMobileRxPackets()).isEqualTo(2);
+  }
+
+  @Test
+  public void setMobileTxBytesUpdatesMobileTxBytes() {
+    assertThat(TrafficStats.getMobileTxBytes()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setMobileTxBytes(3);
+    assertThat(TrafficStats.getMobileTxBytes()).isEqualTo(3);
+  }
+
+  @Test
+  public void setMobileRxBytesUpdatesMobileRxBytes() {
+    assertThat(TrafficStats.getMobileRxBytes()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setMobileRxBytes(4);
+    assertThat(TrafficStats.getMobileRxBytes()).isEqualTo(4);
+  }
+
+  @Test
+  public void setTotalTxPacketsUpdatesTotalTxPackets() {
+    assertThat(TrafficStats.getTotalTxPackets()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setTotalTxPackets(5);
+    assertThat(TrafficStats.getTotalTxPackets()).isEqualTo(5);
+  }
+
+  @Test
+  public void setTotalRxPacketsUpdatesTotalRxPackets() {
+    assertThat(TrafficStats.getTotalRxPackets()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setTotalRxPackets(6);
+    assertThat(TrafficStats.getTotalRxPackets()).isEqualTo(6);
+  }
+
+  @Test
+  public void setTotalTxBytesUpdatesTotalTxBytes() {
+    assertThat(TrafficStats.getTotalTxBytes()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setTotalTxBytes(7);
+    assertThat(TrafficStats.getTotalTxBytes()).isEqualTo(7);
+  }
+
+  @Test
+  public void setTotalRxBytesUpdatesTotalRxBytes() {
+    assertThat(TrafficStats.getTotalRxBytes()).isEqualTo(TrafficStats.UNSUPPORTED);
+
+    ShadowTrafficStats.setTotalRxBytes(8);
+    assertThat(TrafficStats.getTotalRxBytes()).isEqualTo(8);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTranslationManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTranslationManagerTest.java
new file mode 100644
index 0000000..0dfbb5c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTranslationManagerTest.java
@@ -0,0 +1,63 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadow.api.Shadow.extract;
+
+import android.icu.util.ULocale;
+import android.os.Build.VERSION_CODES;
+import android.view.translation.TranslationCapability;
+import android.view.translation.TranslationManager;
+import android.view.translation.TranslationSpec;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableSet;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.S)
+public class ShadowTranslationManagerTest {
+  private ShadowTranslationManager instance;
+
+  @Before
+  public void setUp() {
+    instance =
+        extract(
+            ApplicationProvider.getApplicationContext().getSystemService(TranslationManager.class));
+  }
+
+  @Test
+  public void getOnDeviceTranslationCapabilities_noCapabilitiesSet_returnsEmpty() {
+    assertThat(
+            instance.getOnDeviceTranslationCapabilities(
+                TranslationSpec.DATA_FORMAT_TEXT, TranslationSpec.DATA_FORMAT_TEXT))
+        .isEmpty();
+  }
+
+  @Test
+  public void getOnDeviceTranslationCapabilities_returnsSetCapabilities() {
+    ImmutableSet<TranslationCapability> capabilities =
+        ImmutableSet.of(
+            new TranslationCapability(
+                TranslationCapability.STATE_NOT_AVAILABLE,
+                new TranslationSpec(ULocale.JAPANESE, TranslationSpec.DATA_FORMAT_TEXT),
+                new TranslationSpec(ULocale.ENGLISH, TranslationSpec.DATA_FORMAT_TEXT),
+                /* uiTranslationEnabled= */ false,
+                /* supportedTranslationFlags= */ 0),
+            new TranslationCapability(
+                TranslationCapability.STATE_ON_DEVICE,
+                new TranslationSpec(ULocale.KOREAN, TranslationSpec.DATA_FORMAT_TEXT),
+                new TranslationSpec(ULocale.FRENCH, TranslationSpec.DATA_FORMAT_TEXT),
+                /* uiTranslationEnabled= */ true,
+                /* supportedTranslationFlags= */ 0));
+    instance.setOnDeviceTranslationCapabilities(
+        TranslationSpec.DATA_FORMAT_TEXT, TranslationSpec.DATA_FORMAT_TEXT, capabilities);
+
+    assertThat(
+            instance.getOnDeviceTranslationCapabilities(
+                TranslationSpec.DATA_FORMAT_TEXT, TranslationSpec.DATA_FORMAT_TEXT))
+        .isEqualTo(capabilities);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTypedArrayTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTypedArrayTest.java
new file mode 100644
index 0000000..3b0d8d0
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTypedArrayTest.java
@@ -0,0 +1,173 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.res.AttributeResource;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTypedArrayTest {
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void getResources() {
+    assertNotNull(context.obtainStyledAttributes(new int[]{}).getResources());
+  }
+
+  @Test
+  public void getInt_shouldReturnDefaultValue() {
+    assertThat(context.obtainStyledAttributes(new int[]{android.R.attr.alpha}).getInt(0, -1)).isEqualTo(-1);
+  }
+
+  @Test
+  public void getInteger_shouldReturnDefaultValue() {
+    assertThat(context.obtainStyledAttributes(new int[]{android.R.attr.alpha}).getInteger(0, -1)).isEqualTo(-1);
+  }
+
+  @Test
+  public void getInt_withFlags_shouldReturnValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.gravity, "top|left")
+            .build(),
+        new int[]{android.R.attr.gravity});
+    assertThat(typedArray.getInt(0, -1)).isEqualTo(0x33);
+  }
+
+  @Test
+  public void getResourceId_shouldReturnDefaultValue() {
+    assertThat(context.obtainStyledAttributes(new int[]{android.R.attr.alpha}).getResourceId(0, -1)).isEqualTo(-1);
+  }
+
+  @Test
+  public void getResourceId_shouldReturnActualValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.id, "@+id/snippet_text")
+            .build(),
+        new int[]{android.R.attr.id});
+    assertThat(typedArray.getResourceId(0, -1)).isEqualTo(R.id.snippet_text);
+  }
+
+  @Test
+  public void getFraction_shouldReturnDefaultValue() {
+    assertThat(context.obtainStyledAttributes(new int[]{android.R.attr.width}).getDimension(0, -1f))
+        .isEqualTo(-1f);
+  }
+
+  @Test
+  public void getFraction_shouldReturnGivenValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.width, "50%")
+            .build(),
+        new int[]{android.R.attr.width});
+    assertThat(typedArray.getFraction(0, 100, 1, -1))
+        .isEqualTo(50f);
+  }
+
+  @Test
+  public void getDimension_shouldReturnDefaultValue() {
+    assertThat(context.obtainStyledAttributes(new int[]{android.R.attr.width}).getDimension(0, -1f)).isEqualTo(-1f);
+  }
+
+  @Test
+  public void getDimension_shouldReturnGivenValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.width, "50dp")
+            .build(),
+        new int[]{android.R.attr.width});
+    assertThat(typedArray.getDimension(0, -1)).isEqualTo(50f);
+  }
+
+  @Test
+  public void getDrawable_withExplicitColorValue_shouldReturnColorDrawable() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.background, "#ff777777")
+            .build(),
+        new int[]{android.R.attr.background});
+    ColorDrawable drawable = (ColorDrawable) typedArray.getDrawable(0);
+    assertThat(drawable.getColor()).isEqualTo(0xff777777);
+  }
+
+  @Test
+  public void getTextArray_whenNoSuchAttribute_shouldReturnNull() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.keycode, "@array/greetings")
+            .build(),
+        new int[]{android.R.attr.absListViewStyle});
+    CharSequence[] textArray = typedArray.getTextArray(0);
+    assertThat(textArray).isInstanceOf(CharSequence[].class);
+    for (CharSequence text : textArray) {
+      assertThat(text).isNull();
+    }
+  }
+
+  @Test
+  public void getTextArray_shouldReturnValues() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(R.attr.responses, "@array/greetings")
+            .build(),
+        new int[]{R.attr.responses});
+    assertThat(typedArray.getTextArray(0)).asList().containsExactly("hola", "Hello");
+  }
+
+  @Test
+  public void hasValue_withValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(R.attr.responses, "@array/greetings")
+            .build(),
+        new int[]{R.attr.responses});
+    assertThat(typedArray.hasValue(0)).isTrue();
+  }
+
+  @Test
+  public void hasValue_withoutValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        null,
+        new int[]{R.attr.responses});
+    assertThat(typedArray.hasValue(0)).isFalse();
+  }
+
+  @Test
+  public void hasValue_withNullValue() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(R.attr.responses, AttributeResource.NULL_VALUE)
+            .build(),
+        new int[]{R.attr.responses});
+    assertThat(typedArray.hasValue(0)).isFalse();
+  }
+
+  @Test
+  public void shouldEnumeratePresentValues() {
+    TypedArray typedArray = context.obtainStyledAttributes(
+        Robolectric.buildAttributeSet()
+            .addAttribute(R.attr.responses, "@array/greetings")
+            .addAttribute(R.attr.aspectRatio, "1")
+            .build(),
+        new int[]{R.attr.scrollBars, R.attr.responses, R.attr.isSugary});
+    assertThat(typedArray.getIndexCount()).isEqualTo(1);
+    assertThat(typedArray.getIndex(0)).isEqualTo(1);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTypefaceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTypefaceTest.java
new file mode 100644
index 0000000..47789de
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTypefaceTest.java
@@ -0,0 +1,170 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.graphics.Typeface;
+import android.graphics.fonts.Font;
+import android.graphics.fonts.FontFamily;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowLog.LogItem;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.TestUtil;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowTypefaceTest {
+
+  private File fontFile;
+
+  @Before
+  public void setup() {
+    fontFile = TestUtil.resourcesBaseDir().resolve("assets/myFont.ttf").toFile();
+  }
+
+  @Test
+  public void create_withFamilyName_shouldCreateTypeface() {
+    Typeface typeface = Typeface.create("roboto", Typeface.BOLD);
+    assertThat(typeface.getStyle()).isEqualTo(Typeface.BOLD);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo("roboto");
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(Typeface.BOLD);
+  }
+
+  @Test
+  public void create_withFamily_shouldCreateTypeface() {
+    Typeface typeface = Typeface.create(Typeface.create("roboto", Typeface.BOLD), Typeface.ITALIC);
+
+    assertThat(typeface.getStyle()).isEqualTo(Typeface.ITALIC);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo("roboto");
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(Typeface.ITALIC);
+  }
+
+  @Test
+  public void create_withoutFamily_shouldCreateTypeface() {
+    Typeface typeface = Typeface.create((Typeface) null, Typeface.ITALIC);
+    assertThat(typeface.getStyle()).isEqualTo(Typeface.ITALIC);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo(null);
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(Typeface.ITALIC);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void create_withFamily_customWeight_shouldCreateTypeface() {
+    Typeface typeface =
+        Typeface.create(
+            Typeface.create("roboto", Typeface.NORMAL), /* weight= */ 400, /* italic= */ false);
+    assertThat(typeface.getStyle()).isEqualTo(400);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo("roboto");
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(400);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void create_withoutFamily_customWeight_shouldCreateTypeface() {
+    Typeface typeface = Typeface.create(null, /* weight= */ 500, /* italic= */ false);
+    assertThat(typeface.getStyle()).isEqualTo(500);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo(null);
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(500);
+  }
+
+  @Test
+  public void createFromFile_withFile_shouldCreateTypeface() {
+    Typeface typeface = Typeface.createFromFile(fontFile);
+
+    assertThat(typeface.getStyle()).isEqualTo(Typeface.NORMAL);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo("myFont.ttf");
+  }
+
+  @Test
+  public void createFromFile_withPath_shouldCreateTypeface() {
+    Typeface typeface = Typeface.createFromFile(fontFile.getPath());
+
+    assertThat(typeface.getStyle()).isEqualTo(Typeface.NORMAL);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo("myFont.ttf");
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(Typeface.NORMAL);
+  }
+
+  @Test
+  public void createFromAsset_shouldCreateTypeface() {
+    Typeface typeface =
+        Typeface.createFromAsset(
+            ApplicationProvider.getApplicationContext().getAssets(), "myFont.ttf");
+
+    assertThat(typeface.getStyle()).isEqualTo(Typeface.NORMAL);
+    assertThat(shadowOf(typeface).getFontDescription().getFamilyName()).isEqualTo("myFont.ttf");
+    assertThat(shadowOf(typeface).getFontDescription().getStyle()).isEqualTo(Typeface.NORMAL);
+  }
+
+  @Test
+  public void createFromAsset_throwsExceptionWhenFontNotFound() {
+    try {
+      Typeface.createFromAsset(
+          ApplicationProvider.getApplicationContext().getAssets(), "nonexistent.ttf");
+      fail("Expected exception");
+    } catch (RuntimeException expected) {
+      // Expected
+    }
+  }
+
+  @Test
+  public void equals_bothRoboto_shouldBeTrue() {
+    Typeface roboto = Typeface.create("roboto", Typeface.BOLD);
+    assertThat(roboto).isEqualTo(Typeface.create("roboto", Typeface.BOLD));
+  }
+
+  @Test
+  public void equals_robotoAndDroid_shouldBeFalse() {
+    Typeface roboto = Typeface.create("roboto", Typeface.BOLD);
+    Typeface droid = Typeface.create("droid", Typeface.BOLD);
+    assertThat(roboto).isNotEqualTo(droid);
+  }
+
+  @Test
+  public void hashCode_bothRoboto_shouldBeEqual() {
+    Typeface roboto = Typeface.create("roboto", Typeface.BOLD);
+    assertThat(roboto.hashCode()).isEqualTo(Typeface.create("roboto", Typeface.BOLD).hashCode());
+  }
+
+  @Test
+  public void hashCode_robotoAndDroid_shouldNotBeEqual() {
+    Typeface roboto = Typeface.create("roboto", Typeface.BOLD);
+    Typeface droid = Typeface.create("droid", Typeface.BOLD);
+    assertThat(roboto.hashCode()).isNotEqualTo(droid.hashCode());
+  }
+
+  /** Check that there is no spurious error message about /system/etc/fonts.xml */
+  @Test
+  @Config(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  public void init_shouldNotComplainAboutSystemFonts() {
+    ShadowLog.clear();
+    ReflectionHelpers.callStaticMethod(Typeface.class, "init");
+    List<LogItem> logs = ShadowLog.getLogsForTag("Typeface");
+    assertThat(logs).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void typeface_customFallbackBuilder_afterReset() throws IOException {
+    Font font = new Font.Builder(fontFile).build();
+    FontFamily family = new FontFamily.Builder(font).build();
+    // This invokes the Typeface static initializer, which creates some default typefaces.
+    Typeface.create("roboto", Typeface.BOLD);
+    // Call the resetter to clear the FONTS map in Typeface
+    ShadowTypeface.reset();
+    Typeface typeface =
+        new Typeface.CustomFallbackBuilder(family).setStyle(font.getStyle()).build();
+    assertThat(typeface).isNotNull();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUIModeManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUIModeManagerTest.java
new file mode 100644
index 0000000..2dff134
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUIModeManagerTest.java
@@ -0,0 +1,278 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.res.Configuration;
+import android.os.Build.VERSION_CODES;
+import android.provider.Settings;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowUIModeManagerTest {
+  private Context context;
+  private UiModeManager uiModeManager;
+  private ShadowUIModeManager shadowUiModeManager;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
+    shadowUiModeManager = (ShadowUIModeManager) Shadow.extract(uiModeManager);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void testModeSwitch() {
+    assertThat(uiModeManager.getCurrentModeType()).isEqualTo(Configuration.UI_MODE_TYPE_UNDEFINED);
+    assertThat(shadowOf(uiModeManager).lastFlags).isEqualTo(0);
+
+    uiModeManager.enableCarMode(1);
+    assertThat(uiModeManager.getCurrentModeType()).isEqualTo(Configuration.UI_MODE_TYPE_CAR);
+    assertThat(shadowOf(uiModeManager).lastFlags).isEqualTo(1);
+
+    uiModeManager.disableCarMode(2);
+    assertThat(uiModeManager.getCurrentModeType()).isEqualTo(Configuration.UI_MODE_TYPE_NORMAL);
+    assertThat(shadowOf(uiModeManager).lastFlags).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void testCarModePriority() {
+    int priority = 9;
+    int flags = 1;
+    uiModeManager.enableCarMode(priority, flags);
+    assertThat(uiModeManager.getCurrentModeType()).isEqualTo(Configuration.UI_MODE_TYPE_CAR);
+    assertThat(shadowOf(uiModeManager).lastCarModePriority).isEqualTo(priority);
+    assertThat(shadowOf(uiModeManager).lastFlags).isEqualTo(flags);
+  }
+
+  private static final int INVALID_NIGHT_MODE = -4242;
+
+  @Test
+  public void testNightMode() {
+    assertThat(uiModeManager.getNightMode()).isEqualTo(UiModeManager.MODE_NIGHT_AUTO);
+
+    uiModeManager.setNightMode(UiModeManager.MODE_NIGHT_YES);
+    assertThat(uiModeManager.getNightMode()).isEqualTo(UiModeManager.MODE_NIGHT_YES);
+
+    uiModeManager.setNightMode(UiModeManager.MODE_NIGHT_NO);
+    assertThat(uiModeManager.getNightMode()).isEqualTo(UiModeManager.MODE_NIGHT_NO);
+
+    uiModeManager.setNightMode(INVALID_NIGHT_MODE);
+    assertThat(uiModeManager.getNightMode()).isEqualTo(UiModeManager.MODE_NIGHT_AUTO);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void testGetApplicationNightMode() {
+    uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES);
+
+    assertThat(shadowUiModeManager.getApplicationNightMode())
+        .isEqualTo(UiModeManager.MODE_NIGHT_YES);
+  }
+
+  @Test
+  @Config(minSdk = S)
+  public void testRequestReleaseProjection() {
+    shadowUiModeManager.setFailOnProjectionToggle(true);
+
+    assertThrows(
+        SecurityException.class,
+        () -> uiModeManager.requestProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE));
+    assertThat(shadowUiModeManager.getActiveProjectionTypes()).isEmpty();
+
+    assertThrows(
+        SecurityException.class,
+        () -> uiModeManager.releaseProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE));
+    assertThat(shadowUiModeManager.getActiveProjectionTypes()).isEmpty();
+
+    setPermissions(android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION);
+
+    assertThat(uiModeManager.requestProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE)).isFalse();
+    assertThat(shadowUiModeManager.getActiveProjectionTypes()).isEmpty();
+
+    shadowUiModeManager.setFailOnProjectionToggle(false);
+
+    assertThat(uiModeManager.requestProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE)).isTrue();
+    assertThat(shadowUiModeManager.getActiveProjectionTypes())
+        .containsExactly(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE);
+    assertThat(uiModeManager.requestProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE)).isTrue();
+    assertThat(shadowUiModeManager.getActiveProjectionTypes())
+        .containsExactly(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE);
+
+    shadowUiModeManager.setFailOnProjectionToggle(true);
+    assertThat(uiModeManager.releaseProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE)).isFalse();
+    assertThat(shadowUiModeManager.getActiveProjectionTypes())
+        .containsExactly(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE);
+
+    shadowUiModeManager.setFailOnProjectionToggle(false);
+    assertThat(uiModeManager.releaseProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE)).isTrue();
+    assertThat(shadowUiModeManager.getActiveProjectionTypes()).isEmpty();
+    assertThat(uiModeManager.releaseProjection(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE)).isFalse();
+    assertThat(shadowUiModeManager.getActiveProjectionTypes()).isEmpty();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getDefaultUiNightModeCustomType_shouldBeUnknownCustomType() {
+    assertThat(uiModeManager.getNightModeCustomType())
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+  }
+
+  @Test
+  public void getDefaultIsNightModeOn_shouldBeFalse() {
+    assertThat(((ShadowUIModeManager) Shadow.extract(uiModeManager)).isNightModeOn()).isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setUiNightModeCustomType_validType_shouldGetSameCustomType() {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    assertThat(uiModeManager.getNightMode()).isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM);
+    assertThat(uiModeManager.getNightModeCustomType())
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setUiNightModeCustomType_invalidType_shouldGetUnknownCustomType() {
+    uiModeManager.setNightModeCustomType(123);
+
+    assertThat(uiModeManager.getNightModeCustomType())
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void
+      setNightModeActivatedForCustomMode_requestActivated_matchedCustomMode_shouldActivate() {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    assertThat(
+            uiModeManager.setNightModeActivatedForCustomMode(
+                UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME, true))
+        .isTrue();
+    assertThat(((ShadowUIModeManager) Shadow.extract(uiModeManager)).isNightModeOn()).isTrue();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void
+      setNightModeActivatedForCustomMode_requestDeactivated_matchedCustomMode_shouldDeactivate() {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    assertThat(
+            uiModeManager.setNightModeActivatedForCustomMode(
+                UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME, false))
+        .isTrue();
+    assertThat(((ShadowUIModeManager) Shadow.extract(uiModeManager)).isNightModeOn()).isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setNightModeActivatedForCustomMode_invalidTypeActivated_shouldNotActivate() {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    assertThat(uiModeManager.setNightModeActivatedForCustomMode(123, true)).isFalse();
+    assertThat(((ShadowUIModeManager) Shadow.extract(uiModeManager)).isNightModeOn()).isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setNightModeActivatedForCustomMode_differentCustomTypeActivated_shouldNotActivate() {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    assertThat(
+            uiModeManager.setNightModeActivatedForCustomMode(
+                UiModeManager.MODE_NIGHT_CUSTOM_TYPE_SCHEDULE, true))
+        .isFalse();
+    assertThat(((ShadowUIModeManager) Shadow.extract(uiModeManager)).isNightModeOn()).isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setNightModeCustomTypeBedtime_setNightModeOff_shouldGetUnknownCustomType() {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    uiModeManager.setNightMode(UiModeManager.MODE_NIGHT_NO);
+
+    assertThat(uiModeManager.getNightModeCustomType())
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void setNightMode_setToYes_shouldUpdateSecureSettings() throws Exception {
+    uiModeManager.setNightMode(UiModeManager.MODE_NIGHT_YES);
+
+    assertThat(Settings.Secure.getInt(context.getContentResolver(), Settings.Secure.UI_NIGHT_MODE))
+        .isEqualTo(UiModeManager.MODE_NIGHT_YES);
+    assertThat(
+            Settings.Secure.getInt(
+                context.getContentResolver(), Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE))
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void setNightMode_setToNo_shouldUpdateSecureSettings() throws Exception {
+    uiModeManager.setNightMode(UiModeManager.MODE_NIGHT_NO);
+
+    assertThat(Settings.Secure.getInt(context.getContentResolver(), Settings.Secure.UI_NIGHT_MODE))
+        .isEqualTo(UiModeManager.MODE_NIGHT_NO);
+    assertThat(
+            Settings.Secure.getInt(
+                context.getContentResolver(), Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE))
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setNightModeActivatedForCustomMode_setToBedtimeMode_shouldUpdateSecureSettings()
+      throws Exception {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+    assertThat(
+            Settings.Secure.getInt(
+                context.getContentResolver(), Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE))
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setNightModeActivatedForCustomMode_setToSchedule_shouldUpdateSecureSettings()
+      throws Exception {
+    uiModeManager.setNightModeCustomType(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_SCHEDULE);
+
+    assertThat(
+            Settings.Secure.getInt(
+                context.getContentResolver(), Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE))
+        .isEqualTo(UiModeManager.MODE_NIGHT_CUSTOM_TYPE_SCHEDULE);
+  }
+
+  private void setPermissions(String... permissions) {
+    PackageInfo pi = new PackageInfo();
+    pi.packageName = context.getPackageName();
+    pi.versionCode = 1;
+    pi.requestedPermissions = permissions;
+    ((ShadowPackageManager) Shadow.extract(context.getPackageManager())).installPackage(pi);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUiAutomationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUiAutomationTest.java
new file mode 100644
index 0000000..5f19aaf
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUiAutomationTest.java
@@ -0,0 +1,105 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.UiAutomation;
+import android.content.ContentResolver;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.provider.Settings;
+import android.view.Surface;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowUiAutomation}. */
+@Config(minSdk = JELLY_BEAN_MR2)
+@RunWith(AndroidJUnit4.class)
+public class ShadowUiAutomationTest {
+  @Config(sdk = JELLY_BEAN_MR1)
+  @Test
+  public void setAnimationScale_zero() throws Exception {
+    ShadowUiAutomation.setAnimationScaleCompat(0);
+
+    ContentResolver cr = ApplicationProvider.getApplicationContext().getContentResolver();
+    assertThat(Settings.Global.getFloat(cr, Settings.Global.ANIMATOR_DURATION_SCALE)).isEqualTo(0);
+    assertThat(Settings.Global.getFloat(cr, Settings.Global.TRANSITION_ANIMATION_SCALE))
+        .isEqualTo(0);
+    assertThat(Settings.Global.getFloat(cr, Settings.Global.WINDOW_ANIMATION_SCALE)).isEqualTo(0);
+  }
+
+  @Config(sdk = JELLY_BEAN_MR1)
+  @Test
+  public void setAnimationScale_one() throws Exception {
+    ShadowUiAutomation.setAnimationScaleCompat(1);
+
+    ContentResolver cr = ApplicationProvider.getApplicationContext().getContentResolver();
+    assertThat(Settings.Global.getFloat(cr, Settings.Global.ANIMATOR_DURATION_SCALE)).isEqualTo(1);
+    assertThat(Settings.Global.getFloat(cr, Settings.Global.TRANSITION_ANIMATION_SCALE))
+        .isEqualTo(1);
+    assertThat(Settings.Global.getFloat(cr, Settings.Global.WINDOW_ANIMATION_SCALE)).isEqualTo(1);
+  }
+
+  @Config(sdk = JELLY_BEAN)
+  @Test
+  public void setAnimationScale_zero_jellyBean() throws Exception {
+    ShadowUiAutomation.setAnimationScaleCompat(0);
+
+    ContentResolver cr = ApplicationProvider.getApplicationContext().getContentResolver();
+    assertThat(Settings.System.getFloat(cr, Settings.System.ANIMATOR_DURATION_SCALE)).isEqualTo(0);
+    assertThat(Settings.System.getFloat(cr, Settings.System.TRANSITION_ANIMATION_SCALE))
+        .isEqualTo(0);
+    assertThat(Settings.System.getFloat(cr, Settings.System.WINDOW_ANIMATION_SCALE)).isEqualTo(0);
+  }
+
+  @Config(sdk = JELLY_BEAN)
+  @Test
+  public void setAnimationScale_one_jellyBean() throws Exception {
+    ShadowUiAutomation.setAnimationScaleCompat(1);
+
+    ContentResolver cr = ApplicationProvider.getApplicationContext().getContentResolver();
+    assertThat(Settings.System.getFloat(cr, Settings.System.ANIMATOR_DURATION_SCALE)).isEqualTo(1);
+    assertThat(Settings.System.getFloat(cr, Settings.System.TRANSITION_ANIMATION_SCALE))
+        .isEqualTo(1);
+    assertThat(Settings.System.getFloat(cr, Settings.System.WINDOW_ANIMATION_SCALE)).isEqualTo(1);
+  }
+
+  @Test
+  public void setRotation_freeze90_rotatesToLandscape() {
+    UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+
+    uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_90);
+
+    assertThat(ShadowDisplay.getDefaultDisplay().getRotation()).isEqualTo(Surface.ROTATION_90);
+    assertThat(Resources.getSystem().getConfiguration().orientation)
+        .isEqualTo(Configuration.ORIENTATION_LANDSCAPE);
+  }
+
+  @Test
+  public void setRotation_freeze180_rotatesToPortrait() {
+    UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+
+    uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_180);
+
+    assertThat(ShadowDisplay.getDefaultDisplay().getRotation()).isEqualTo(Surface.ROTATION_180);
+    assertThat(Resources.getSystem().getConfiguration().orientation)
+        .isEqualTo(Configuration.ORIENTATION_PORTRAIT);
+  }
+
+  @Test
+  public void setRotation_freezeCurrent_doesNothing() {
+    UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+
+    uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_CURRENT);
+
+    assertThat(ShadowDisplay.getDefaultDisplay().getRotation()).isEqualTo(Surface.ROTATION_0);
+    assertThat(Resources.getSystem().getConfiguration().orientation)
+        .isEqualTo(Configuration.ORIENTATION_PORTRAIT);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUriTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUriTest.java
new file mode 100644
index 0000000..cb7265e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUriTest.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowUriTest {
+  @Test
+  public void shouldParseUris() {
+    Uri testUri =
+        Uri.parse("http://someplace.com:8080/a/path?param=value&another_param=another_value#top");
+
+    assertThat(testUri.getQuery()).isEqualTo("param=value&another_param=another_value");
+    assertThat(testUri.getPort()).isEqualTo(8080);
+    assertThat(testUri.getAuthority()).isEqualTo("someplace.com:8080");
+    assertThat(testUri.getHost()).isEqualTo("someplace.com");
+    assertThat(testUri.getFragment()).isEqualTo("top");
+    assertThat(testUri.getPath()).isEqualTo("/a/path");
+    assertThat(testUri.getScheme()).isEqualTo("http");
+  }
+
+  @Test
+  public void getQueryParameter_shouldWork() {
+    Uri testUri =
+        Uri.parse("http://someplace.com:8080/a/path?param=value&another_param=another_value#top");
+    assertThat(testUri.getQueryParameter("param")).isEqualTo("value");
+  }
+
+  // Captures a known issue in Android Q+ issue where Uri.EMPTY may not be initialized properly if
+  // Uri.Builder is used before Uri.<clinit> runs.
+  @Test
+  public void testEmpty_initializerOrder() {
+    new Uri.Builder().scheme("http").path("path").build();
+    assertThat(Uri.EMPTY.toString()).isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUsageStatsManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsageStatsManagerTest.java
new file mode 100644
index 0000000..acd99dc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsageStatsManagerTest.java
@@ -0,0 +1,1036 @@
+package org.robolectric.shadows;
+
+import static android.app.usage.UsageStatsManager.INTERVAL_DAILY;
+import static android.app.usage.UsageStatsManager.INTERVAL_WEEKLY;
+import static android.content.Context.USAGE_STATS_SERVICE;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.PendingIntent;
+import android.app.usage.BroadcastResponseStats;
+import android.app.usage.UsageEvents;
+import android.app.usage.UsageEvents.Event;
+import android.app.usage.UsageStats;
+import android.app.usage.UsageStatsManager;
+import android.content.Intent;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowUsageStatsManager.AppUsageLimitObserver;
+import org.robolectric.shadows.ShadowUsageStatsManager.AppUsageObserver;
+import org.robolectric.shadows.ShadowUsageStatsManager.UsageSessionObserver;
+import org.robolectric.shadows.ShadowUsageStatsManager.UsageStatsBuilder;
+
+/** Test for {@link ShadowUsageStatsManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = LOLLIPOP)
+public class ShadowUsageStatsManagerTest {
+
+  private static final String TEST_PACKAGE_NAME1 = "com.company1.pkg1";
+  private static final String TEST_PACKAGE_NAME2 = "com.company2.pkg2";
+  private static final String TEST_ACTIVITY_NAME = "com.company2.pkg2.Activity";
+  private static final String APP_1 = "test.app";
+  private static final String APP_2 = "some.other.app";
+  private static final int BUCKET_ID_1 = 10;
+  private static final int BUCKET_ID_2 = 42;
+
+  private UsageStatsManager usageStatsManager;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    usageStatsManager =
+        (UsageStatsManager)
+            ApplicationProvider.getApplicationContext().getSystemService(USAGE_STATS_SERVICE);
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void testQueryEvents_emptyEvents() throws Exception {
+    UsageEvents events = usageStatsManager.queryEvents(1000L, 2000L);
+    Event event = new Event();
+
+    assertThat(events.hasNextEvent()).isFalse();
+    assertThat(events.getNextEvent(event)).isFalse();
+  }
+
+  @Test
+  public void testQueryEvents_overlappingEvents() throws Exception {
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME1, 1000L, Event.MOVE_TO_BACKGROUND);
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(1000L)
+                .setEventType(Event.SYSTEM_INTERACTION)
+                .build());
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME1, 1000L, Event.MOVE_TO_FOREGROUND);
+
+    UsageEvents events = usageStatsManager.queryEvents(1000L, 2000L);
+    Event event = new Event();
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(TEST_PACKAGE_NAME1);
+    assertThat(event.getTimeStamp()).isEqualTo(1000L);
+    assertThat(event.getEventType()).isEqualTo(Event.MOVE_TO_BACKGROUND);
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isNull();
+    assertThat(event.getTimeStamp()).isEqualTo(1000L);
+    assertThat(event.getEventType()).isEqualTo(Event.SYSTEM_INTERACTION);
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(TEST_PACKAGE_NAME1);
+    assertThat(event.getTimeStamp()).isEqualTo(1000L);
+    assertThat(event.getEventType()).isEqualTo(Event.MOVE_TO_FOREGROUND);
+  }
+
+  @Test
+  public void testQueryEvents_appendEventData_shouldCombineWithPreviousData() throws Exception {
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME1, 500L, Event.MOVE_TO_FOREGROUND);
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME1, 1000L, Event.MOVE_TO_BACKGROUND);
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(1500L)
+                .setPackage(TEST_PACKAGE_NAME2)
+                .setClass(TEST_ACTIVITY_NAME)
+                .setEventType(Event.MOVE_TO_FOREGROUND)
+                .build());
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME2, 2000L, Event.MOVE_TO_BACKGROUND);
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(2500L)
+                .setPackage(TEST_PACKAGE_NAME1)
+                .setEventType(Event.MOVE_TO_FOREGROUND)
+                .setClass(TEST_ACTIVITY_NAME)
+                .build());
+
+    UsageEvents events = usageStatsManager.queryEvents(1000L, 2000L);
+    Event event = new Event();
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(TEST_PACKAGE_NAME1);
+    assertThat(event.getTimeStamp()).isEqualTo(1000L);
+    assertThat(event.getEventType()).isEqualTo(Event.MOVE_TO_BACKGROUND);
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(TEST_PACKAGE_NAME2);
+    assertThat(event.getTimeStamp()).isEqualTo(1500L);
+    assertThat(event.getEventType()).isEqualTo(Event.MOVE_TO_FOREGROUND);
+    assertThat(event.getClassName()).isEqualTo(TEST_ACTIVITY_NAME);
+
+    assertThat(events.hasNextEvent()).isFalse();
+    assertThat(events.getNextEvent(event)).isFalse();
+  }
+
+  @Test
+  public void testQueryEvents_appendEventData_simulateTimeChange_shouldAddOffsetToPreviousData()
+      throws Exception {
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME1, 500L, Event.MOVE_TO_FOREGROUND);
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME1, 1000L, Event.MOVE_TO_BACKGROUND);
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(1500L)
+                .setPackage(TEST_PACKAGE_NAME2)
+                .setClass(TEST_ACTIVITY_NAME)
+                .setEventType(Event.MOVE_TO_FOREGROUND)
+                .build());
+    shadowOf(usageStatsManager).addEvent(TEST_PACKAGE_NAME2, 2000L, Event.MOVE_TO_BACKGROUND);
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(2500L)
+                .setPackage(TEST_PACKAGE_NAME1)
+                .setEventType(Event.MOVE_TO_FOREGROUND)
+                .setClass(TEST_ACTIVITY_NAME)
+                .build());
+    shadowOf(usageStatsManager).simulateTimeChange(10000L);
+
+    UsageEvents events = usageStatsManager.queryEvents(11000L, 12000L);
+    Event event = new Event();
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(TEST_PACKAGE_NAME1);
+    assertThat(event.getTimeStamp()).isEqualTo(11000L);
+    assertThat(event.getEventType()).isEqualTo(Event.MOVE_TO_BACKGROUND);
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(TEST_PACKAGE_NAME2);
+    assertThat(event.getTimeStamp()).isEqualTo(11500L);
+    assertThat(event.getEventType()).isEqualTo(Event.MOVE_TO_FOREGROUND);
+    assertThat(event.getClassName()).isEqualTo(TEST_ACTIVITY_NAME);
+
+    assertThat(events.hasNextEvent()).isFalse();
+    assertThat(events.getNextEvent(event)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testGetAppStandbyBucket_withPackageName() throws Exception {
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBuckets()).isEmpty();
+
+    shadowOf(usageStatsManager).setAppStandbyBucket("app1", UsageStatsManager.STANDBY_BUCKET_RARE);
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBucket("app1"))
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_RARE);
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBuckets().keySet()).containsExactly("app1");
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBuckets().get("app1"))
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_RARE);
+
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBucket("app_unset"))
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_ACTIVE);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testSetAppStandbyBuckets() throws Exception {
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBuckets()).isEmpty();
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBucket("app1"))
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_ACTIVE);
+
+    Map<String, Integer> appBuckets =
+        Collections.singletonMap("app1", UsageStatsManager.STANDBY_BUCKET_RARE);
+    shadowOf(usageStatsManager).setAppStandbyBuckets(appBuckets);
+
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBuckets()).isEqualTo(appBuckets);
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBucket("app1"))
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_RARE);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testGetAppStandbyBucket_currentApp() throws Exception {
+    shadowOf(usageStatsManager).setCurrentAppStandbyBucket(UsageStatsManager.STANDBY_BUCKET_RARE);
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBucket())
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_RARE);
+    ShadowUsageStatsManager.reset();
+    assertThat(shadowOf(usageStatsManager).getAppStandbyBucket())
+        .isEqualTo(UsageStatsManager.STANDBY_BUCKET_ACTIVE);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testRegisterAppUsageObserver_uniqueObserverIds_shouldAddBothObservers() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        12, new String[] {"com.package1", "com.package2"}, 123L, TimeUnit.MINUTES, pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        24, new String[] {"com.package3"}, 456L, TimeUnit.SECONDS, pendingIntent2);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageObservers())
+        .containsExactly(
+            AppUsageObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                123L,
+                TimeUnit.MINUTES,
+                pendingIntent1),
+            AppUsageObserver.build(
+                24, ImmutableList.of("com.package3"), 456L, TimeUnit.SECONDS, pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testRegisterAppUsageObserver_duplicateObserverIds_shouldOverrideExistingObserver() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        12, new String[] {"com.package1", "com.package2"}, 123L, TimeUnit.MINUTES, pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        12, new String[] {"com.package3"}, 456L, TimeUnit.SECONDS, pendingIntent2);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageObservers())
+        .containsExactly(
+            AppUsageObserver.build(
+                12, ImmutableList.of("com.package3"), 456L, TimeUnit.SECONDS, pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testUnregisterAppUsageObserver_existingObserverId_shouldRemoveObserver() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        12, new String[] {"com.package1", "com.package2"}, 123L, TimeUnit.MINUTES, pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        24, new String[] {"com.package3"}, 456L, TimeUnit.SECONDS, pendingIntent2);
+
+    usageStatsManager.unregisterAppUsageObserver(12);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageObservers())
+        .containsExactly(
+            AppUsageObserver.build(
+                24, ImmutableList.of("com.package3"), 456L, TimeUnit.SECONDS, pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testUnregisterAppUsageObserver_nonExistentObserverId_shouldBeNoOp() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        12, new String[] {"com.package1", "com.package2"}, 123L, TimeUnit.MINUTES, pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        24, new String[] {"com.package3"}, 456L, TimeUnit.SECONDS, pendingIntent2);
+
+    usageStatsManager.unregisterAppUsageObserver(36);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageObservers())
+        .containsExactly(
+            AppUsageObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                123L,
+                TimeUnit.MINUTES,
+                pendingIntent1),
+            AppUsageObserver.build(
+                24, ImmutableList.of("com.package3"), 456L, TimeUnit.SECONDS, pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testTriggerRegisteredAppUsageObserver_shouldSendIntentAndRemoveObserver() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        12, new String[] {"com.package1", "com.package2"}, 123L, TimeUnit.MINUTES, pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageObserver(
+        24, new String[] {"com.package3"}, 456L, TimeUnit.SECONDS, pendingIntent2);
+
+    shadowOf(usageStatsManager).triggerRegisteredAppUsageObserver(24, 500000L);
+
+    List<Intent> broadcastIntents = shadowOf(context).getBroadcastIntents();
+    assertThat(broadcastIntents).hasSize(1);
+    Intent broadcastIntent = broadcastIntents.get(0);
+    assertThat(broadcastIntent.getAction()).isEqualTo("ACTION2");
+    assertThat(broadcastIntent.getIntExtra(UsageStatsManager.EXTRA_OBSERVER_ID, 0)).isEqualTo(24);
+    assertThat(broadcastIntent.getLongExtra(UsageStatsManager.EXTRA_TIME_LIMIT, 0))
+        .isEqualTo(456000L);
+    assertThat(broadcastIntent.getLongExtra(UsageStatsManager.EXTRA_TIME_USED, 0))
+        .isEqualTo(500000L);
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageObservers())
+        .containsExactly(
+            AppUsageObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                123L,
+                TimeUnit.MINUTES,
+                pendingIntent1));
+  }
+
+
+  @Test
+  public void queryUsageStats_noStatsAdded() {
+    List<UsageStats> results = usageStatsManager.queryUsageStats(INTERVAL_WEEKLY, 0, 3000);
+    assertThat(results).isEmpty();
+  }
+
+  @Test
+  public void queryUsageStats() {
+    UsageStats usageStats1 = newUsageStats(TEST_PACKAGE_NAME1, 0, 1000);
+    UsageStats usageStats2 = newUsageStats(TEST_PACKAGE_NAME1, 1001, 2000);
+    UsageStats usageStats3 = newUsageStats(TEST_PACKAGE_NAME1, 2001, 3000);
+    UsageStats usageStats4 = newUsageStats(TEST_PACKAGE_NAME1, 3001, 4000);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_WEEKLY, usageStats1);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_WEEKLY, usageStats2);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_WEEKLY, usageStats3);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_WEEKLY, usageStats4);
+    // Query fully covers usageStats 2 and 3, and partially overlaps with 4.
+    List<UsageStats> results = usageStatsManager.queryUsageStats(INTERVAL_WEEKLY, 1001, 3500);
+    assertThat(results).containsExactly(usageStats2, usageStats3, usageStats4);
+  }
+
+  @Test
+  public void queryUsageStats_multipleIntervalTypes() {
+    // Weekly data.
+    UsageStats usageStats1 = newUsageStats(TEST_PACKAGE_NAME1, 1000, 2000);
+    UsageStats usageStats2 = newUsageStats(TEST_PACKAGE_NAME1, 2001, 3000);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_WEEKLY, usageStats1);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_WEEKLY, usageStats2);
+
+    // Daily data.
+    UsageStats usageStats3 = newUsageStats(TEST_PACKAGE_NAME1, 2001, 3000);
+    shadowOf(usageStatsManager).addUsageStats(INTERVAL_DAILY, usageStats3);
+
+    List<UsageStats> results = usageStatsManager.queryUsageStats(INTERVAL_WEEKLY, 0, 3000);
+    assertThat(results).containsExactly(usageStats1, usageStats2);
+    results = usageStatsManager.queryUsageStats(INTERVAL_DAILY, 0, 3000);
+    assertThat(results).containsExactly(usageStats3);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getUsageSource_setUsageSourceNotCalled_shouldReturnTaskRootActivityByDefault() {
+    assertThat(usageStatsManager.getUsageSource())
+        .isEqualTo(UsageStatsManager.USAGE_SOURCE_TASK_ROOT_ACTIVITY);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getUsageSource_setUsageSourceCalled_shouldReturnSetValue() {
+    shadowOf(usageStatsManager).setUsageSource(UsageStatsManager.USAGE_SOURCE_CURRENT_ACTIVITY);
+    assertThat(usageStatsManager.getUsageSource())
+        .isEqualTo(UsageStatsManager.USAGE_SOURCE_CURRENT_ACTIVITY);
+  }
+
+  private UsageStats newUsageStats(String packageName, long firstTimeStamp, long lastTimeStamp) {
+    return UsageStatsBuilder.newBuilder()
+        .setPackageName(packageName)
+        .setFirstTimeStamp(firstTimeStamp)
+        .setLastTimeStamp(lastTimeStamp)
+        .build();
+  }
+
+  @Test
+  public void usageStatsBuilder_noFieldsSet() {
+    UsageStats usage =
+        UsageStatsBuilder.newBuilder()
+            // Don't set any fields; the object should still build.
+            .build();
+    assertThat(usage.getPackageName()).isNull();
+    assertThat(usage.getFirstTimeStamp()).isEqualTo(0);
+    assertThat(usage.getLastTimeStamp()).isEqualTo(0);
+    assertThat(usage.getLastTimeUsed()).isEqualTo(0);
+    assertThat(usage.getTotalTimeInForeground()).isEqualTo(0);
+  }
+
+  @Test
+  public void usageStatsBuilder() {
+    long firstTimestamp = 1_500_000_000_000L;
+    long lastTimestamp = firstTimestamp + 10000;
+    long lastTimeUsed = firstTimestamp + 100;
+    long totalTimeInForeground = HOURS.toMillis(10);
+
+    UsageStats usage =
+        UsageStatsBuilder.newBuilder()
+            // Set all fields
+            .setPackageName(TEST_PACKAGE_NAME1)
+            .setFirstTimeStamp(firstTimestamp)
+            .setLastTimeStamp(lastTimestamp)
+            .setLastTimeUsed(lastTimeUsed)
+            .setTotalTimeInForeground(totalTimeInForeground)
+            .build();
+    assertThat(usage.getPackageName()).isEqualTo(TEST_PACKAGE_NAME1);
+    assertThat(usage.getFirstTimeStamp()).isEqualTo(firstTimestamp);
+    assertThat(usage.getLastTimeStamp()).isEqualTo(lastTimestamp);
+    assertThat(usage.getLastTimeUsed()).isEqualTo(lastTimeUsed);
+    assertThat(usage.getTotalTimeInForeground()).isEqualTo(totalTimeInForeground);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void eventBuilder() {
+    Event event =
+        ShadowUsageStatsManager.EventBuilder.buildEvent()
+            .setEventType(Event.ACTIVITY_RESUMED)
+            .setTimeStamp(123456)
+            .setPackage("com.sample.pkg")
+            .setClass("SampleClass")
+            .setInstanceId(999)
+            .setTaskRootPackage("org.example.root")
+            .setTaskRootClass("RootKlass")
+            .setAppStandbyBucket(50)
+            .build();
+    assertThat(event.getEventType()).isEqualTo(Event.ACTIVITY_RESUMED);
+    assertThat(event.getTimeStamp()).isEqualTo(123456);
+    assertThat(event.getPackageName()).isEqualTo("com.sample.pkg");
+    assertThat(event.getClassName()).isEqualTo("SampleClass");
+    assertThat(event.getInstanceId()).isEqualTo(999);
+    assertThat(event.getTaskRootPackageName()).isEqualTo("org.example.root");
+    assertThat(event.getTaskRootClassName()).isEqualTo("RootKlass");
+    assertThat(event.getAppStandbyBucket()).isEqualTo(50);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testRegisterUsageSessionObserver_uniqueObserverIds_shouldAddBothObservers() {
+    PendingIntent sessionStepIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION1"), 0);
+    PendingIntent sessionEndedIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION1"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        Duration.ofMinutes(123L),
+        Duration.ofSeconds(1L),
+        sessionStepIntent1,
+        sessionEndedIntent1);
+    PendingIntent sessionStepIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION2"), 0);
+    PendingIntent sessionEndedIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION2"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        24,
+        new String[] {"com.package3"},
+        Duration.ofSeconds(456L),
+        Duration.ofMinutes(1L),
+        sessionStepIntent2,
+        sessionEndedIntent2);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredUsageSessionObservers())
+        .containsExactly(
+            UsageSessionObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                Duration.ofMinutes(123L),
+                Duration.ofSeconds(1L),
+                sessionStepIntent1,
+                sessionEndedIntent1),
+            UsageSessionObserver.build(
+                24,
+                ImmutableList.of("com.package3"),
+                Duration.ofSeconds(456L),
+                Duration.ofMinutes(1L),
+                sessionStepIntent2,
+                sessionEndedIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void
+  testRegisterUsageSessionObserver_duplicateObserverIds_shouldOverrideExistingObserver() {
+    PendingIntent sessionStepIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION1"), 0);
+    PendingIntent sessionEndedIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION1"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        Duration.ofMinutes(123L),
+        Duration.ofSeconds(1L),
+        sessionStepIntent1,
+        sessionEndedIntent1);
+    PendingIntent sessionStepIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION2"), 0);
+    PendingIntent sessionEndedIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION2"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package3"},
+        Duration.ofSeconds(456L),
+        Duration.ofMinutes(1L),
+        sessionStepIntent2,
+        sessionEndedIntent2);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredUsageSessionObservers())
+        .containsExactly(
+            UsageSessionObserver.build(
+                12,
+                ImmutableList.of("com.package3"),
+                Duration.ofSeconds(456L),
+                Duration.ofMinutes(1L),
+                sessionStepIntent2,
+                sessionEndedIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testUnregisterUsageSessionObserver_existingObserverId_shouldRemoveObserver() {
+    PendingIntent sessionStepIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION1"), 0);
+    PendingIntent sessionEndedIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION1"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        Duration.ofMinutes(123L),
+        Duration.ofSeconds(1L),
+        sessionStepIntent1,
+        sessionEndedIntent1);
+    PendingIntent sessionStepIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION2"), 0);
+    PendingIntent sessionEndedIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION2"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        24,
+        new String[] {"com.package3"},
+        Duration.ofSeconds(456L),
+        Duration.ofMinutes(1L),
+        sessionStepIntent2,
+        sessionEndedIntent2);
+
+    usageStatsManager.unregisterUsageSessionObserver(12);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredUsageSessionObservers())
+        .containsExactly(
+            UsageSessionObserver.build(
+                24,
+                ImmutableList.of("com.package3"),
+                Duration.ofSeconds(456L),
+                Duration.ofMinutes(1L),
+                sessionStepIntent2,
+                sessionEndedIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testUnregisterUsageSessionObserver_nonExistentObserverId_shouldBeNoOp() {
+    PendingIntent sessionStepIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION1"), 0);
+    PendingIntent sessionEndedIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION1"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        Duration.ofMinutes(123L),
+        Duration.ofSeconds(1L),
+        sessionStepIntent1,
+        sessionEndedIntent1);
+    PendingIntent sessionStepIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION2"), 0);
+    PendingIntent sessionEndedIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION2"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        24,
+        new String[] {"com.package3"},
+        Duration.ofSeconds(456L),
+        Duration.ofMinutes(1L),
+        sessionStepIntent2,
+        sessionEndedIntent2);
+
+    usageStatsManager.unregisterUsageSessionObserver(36);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredUsageSessionObservers())
+        .containsExactly(
+            UsageSessionObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                Duration.ofMinutes(123L),
+                Duration.ofSeconds(1L),
+                sessionStepIntent1,
+                sessionEndedIntent1),
+            UsageSessionObserver.build(
+                24,
+                ImmutableList.of("com.package3"),
+                Duration.ofSeconds(456L),
+                Duration.ofMinutes(1L),
+                sessionStepIntent2,
+                sessionEndedIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testTriggerRegisteredSessionStepObserver_shouldSendIntentAndKeepObserver() {
+    PendingIntent sessionStepIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION1"), 0);
+    PendingIntent sessionEndedIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION1"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        Duration.ofMinutes(123L),
+        Duration.ofSeconds(1L),
+        sessionStepIntent1,
+        sessionEndedIntent1);
+    PendingIntent sessionStepIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION2"), 0);
+    PendingIntent sessionEndedIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION2"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        24,
+        new String[] {"com.package3"},
+        Duration.ofSeconds(456L),
+        Duration.ofMinutes(1L),
+        sessionStepIntent2,
+        sessionEndedIntent2);
+
+    shadowOf(usageStatsManager).triggerRegisteredSessionStepObserver(24, 500000L);
+
+    List<Intent> broadcastIntents = shadowOf(context).getBroadcastIntents();
+    assertThat(broadcastIntents).hasSize(1);
+    Intent broadcastIntent = broadcastIntents.get(0);
+    assertThat(broadcastIntent.getAction()).isEqualTo("SESSION_STEP_ACTION2");
+    assertThat(broadcastIntent.getIntExtra(UsageStatsManager.EXTRA_OBSERVER_ID, 0)).isEqualTo(24);
+    assertThat(broadcastIntent.getLongExtra(UsageStatsManager.EXTRA_TIME_LIMIT, 0))
+        .isEqualTo(456000L);
+    assertThat(broadcastIntent.getLongExtra(UsageStatsManager.EXTRA_TIME_USED, 0))
+        .isEqualTo(500000L);
+    assertThat(shadowOf(usageStatsManager).getRegisteredUsageSessionObservers())
+        .containsExactly(
+            UsageSessionObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                Duration.ofMinutes(123L),
+                Duration.ofSeconds(1L),
+                sessionStepIntent1,
+                sessionEndedIntent1),
+            UsageSessionObserver.build(
+                24,
+                ImmutableList.of("com.package3"),
+                Duration.ofSeconds(456L),
+                Duration.ofMinutes(1L),
+                sessionStepIntent2,
+                sessionEndedIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testTriggerRegisteredSessionEndedObserver_shouldSendIntentAndKeepObserver() {
+    PendingIntent sessionStepIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION1"), 0);
+    PendingIntent sessionEndedIntent1 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION1"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        Duration.ofMinutes(123L),
+        Duration.ofSeconds(1L),
+        sessionStepIntent1,
+        sessionEndedIntent1);
+    PendingIntent sessionStepIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_STEP_ACTION2"), 0);
+    PendingIntent sessionEndedIntent2 =
+        PendingIntent.getBroadcast(context, 0, new Intent("SESSION_ENDED_ACTION2"), 0);
+    usageStatsManager.registerUsageSessionObserver(
+        24,
+        new String[] {"com.package3"},
+        Duration.ofSeconds(456L),
+        Duration.ofMinutes(1L),
+        sessionStepIntent2,
+        sessionEndedIntent2);
+
+    shadowOf(usageStatsManager).triggerRegisteredSessionEndedObserver(24);
+
+    List<Intent> broadcastIntents = shadowOf(context).getBroadcastIntents();
+    assertThat(broadcastIntents).hasSize(1);
+    Intent broadcastIntent = broadcastIntents.get(0);
+    assertThat(broadcastIntent.getAction()).isEqualTo("SESSION_ENDED_ACTION2");
+    assertThat(broadcastIntent.getIntExtra(UsageStatsManager.EXTRA_OBSERVER_ID, 0)).isEqualTo(24);
+    assertThat(shadowOf(usageStatsManager).getRegisteredUsageSessionObservers())
+        .containsExactly(
+            UsageSessionObserver.build(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                Duration.ofMinutes(123L),
+                Duration.ofSeconds(1L),
+                sessionStepIntent1,
+                sessionEndedIntent1),
+            UsageSessionObserver.build(
+                24,
+                ImmutableList.of("com.package3"),
+                Duration.ofSeconds(456L),
+                Duration.ofMinutes(1L),
+                sessionStepIntent2,
+                sessionEndedIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testRegisterAppUsageLimitObserver_uniqueObserverIds_shouldAddBothObservers() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        /* timeLimit= */ Duration.ofMinutes(30),
+        /* timeUsed= */ Duration.ofMinutes(10),
+        pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        24,
+        new String[] {"com.package3"},
+        /* timeLimit= */ Duration.ofMinutes(5),
+        /* timeUsed= */ Duration.ofMinutes(1),
+        pendingIntent2);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageLimitObservers())
+        .containsExactly(
+            new AppUsageLimitObserver(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                /* timeLimit= */ Duration.ofMinutes(30),
+                /* timeUsed= */ Duration.ofMinutes(10),
+                pendingIntent1),
+            new AppUsageLimitObserver(
+                24,
+                ImmutableList.of("com.package3"),
+                /* timeLimit= */ Duration.ofMinutes(5),
+                /* timeUsed= */ Duration.ofMinutes(1),
+                pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void
+      testRegisterAppUsageLimitObserver_duplicateObserverIds_shouldOverrideExistingObserver() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        /* timeLimit= */ Duration.ofMinutes(30),
+        /* timeUsed= */ Duration.ofMinutes(10),
+        pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        12,
+        new String[] {"com.package3"},
+        /* timeLimit= */ Duration.ofMinutes(5),
+        /* timeUsed= */ Duration.ofMinutes(1),
+        pendingIntent2);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageLimitObservers())
+        .containsExactly(
+            new AppUsageLimitObserver(
+                12,
+                ImmutableList.of("com.package3"),
+                /* timeLimit= */ Duration.ofMinutes(5),
+                /* timeUsed= */ Duration.ofMinutes(1),
+                pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testUnregisterAppUsageLimitObserver_existingObserverId_shouldRemoveObserver() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        /* timeLimit= */ Duration.ofMinutes(30),
+        /* timeUsed= */ Duration.ofMinutes(10),
+        pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        24,
+        new String[] {"com.package3"},
+        /* timeLimit= */ Duration.ofMinutes(5),
+        /* timeUsed= */ Duration.ofMinutes(1),
+        pendingIntent2);
+
+    usageStatsManager.unregisterAppUsageLimitObserver(12);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageLimitObservers())
+        .containsExactly(
+            new AppUsageLimitObserver(
+                24,
+                ImmutableList.of("com.package3"),
+                /* timeLimit= */ Duration.ofMinutes(5),
+                /* timeUsed= */ Duration.ofMinutes(1),
+                pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testUnregisterAppUsageLimitObserver_nonExistentObserverId_shouldBeNoOp() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        /* timeLimit= */ Duration.ofMinutes(30),
+        /* timeUsed= */ Duration.ofMinutes(10),
+        pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        24,
+        new String[] {"com.package3"},
+        /* timeLimit= */ Duration.ofMinutes(5),
+        /* timeUsed= */ Duration.ofMinutes(1),
+        pendingIntent2);
+
+    usageStatsManager.unregisterAppUsageLimitObserver(36);
+
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageLimitObservers())
+        .containsExactly(
+            new AppUsageLimitObserver(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                /* timeLimit= */ Duration.ofMinutes(30),
+                /* timeUsed= */ Duration.ofMinutes(10),
+                pendingIntent1),
+            new AppUsageLimitObserver(
+                24,
+                ImmutableList.of("com.package3"),
+                /* timeLimit= */ Duration.ofMinutes(5),
+                /* timeUsed= */ Duration.ofMinutes(1),
+                pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void testTriggerRegisteredAppUsageLimitObserver_shouldSendIntentAndKeepObserver() {
+    PendingIntent pendingIntent1 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION1"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        12,
+        new String[] {"com.package1", "com.package2"},
+        /* timeLimit= */ Duration.ofMinutes(30),
+        /* timeUsed= */ Duration.ofMinutes(10),
+        pendingIntent1);
+    PendingIntent pendingIntent2 = PendingIntent.getBroadcast(context, 0, new Intent("ACTION2"), 0);
+    usageStatsManager.registerAppUsageLimitObserver(
+        24,
+        new String[] {"com.package3"},
+        /* timeLimit= */ Duration.ofMinutes(5),
+        /* timeUsed= */ Duration.ofMinutes(1),
+        pendingIntent2);
+
+    shadowOf(usageStatsManager).triggerRegisteredAppUsageLimitObserver(24, Duration.ofMinutes(3));
+
+    List<Intent> broadcastIntents = shadowOf(context).getBroadcastIntents();
+    assertThat(broadcastIntents).hasSize(1);
+    Intent broadcastIntent = broadcastIntents.get(0);
+    assertThat(broadcastIntent.getAction()).isEqualTo("ACTION2");
+    assertThat(broadcastIntent.getIntExtra(UsageStatsManager.EXTRA_OBSERVER_ID, 0)).isEqualTo(24);
+    assertThat(broadcastIntent.getLongExtra(UsageStatsManager.EXTRA_TIME_LIMIT, 0))
+        .isEqualTo(300_000L);
+    assertThat(broadcastIntent.getLongExtra(UsageStatsManager.EXTRA_TIME_USED, 0))
+        .isEqualTo(180_000L);
+    assertThat(shadowOf(usageStatsManager).getRegisteredAppUsageLimitObservers())
+        .containsExactly(
+            new AppUsageLimitObserver(
+                12,
+                ImmutableList.of("com.package1", "com.package2"),
+                /* timeLimit= */ Duration.ofMinutes(30),
+                /* timeUsed= */ Duration.ofMinutes(10),
+                pendingIntent1),
+            new AppUsageLimitObserver(
+                24,
+                ImmutableList.of("com.package3"),
+                /* timeLimit= */ Duration.ofMinutes(5),
+                /* timeUsed= */ Duration.ofMinutes(1),
+                pendingIntent2));
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.P)
+  public void testQueryEventsForSelf_shouldReturnsEventsForCurrentPackageOnly() {
+    String packageName = context.getOpPackageName();
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(1500L)
+                .setPackage(TEST_PACKAGE_NAME2)
+                .setClass(TEST_ACTIVITY_NAME)
+                .setEventType(Event.ACTIVITY_PAUSED)
+                .build());
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(2200L)
+                .setPackage(packageName)
+                .setEventType(Event.ACTIVITY_RESUMED)
+                .setClass(TEST_ACTIVITY_NAME)
+                .build());
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(2500L)
+                .setPackage(TEST_PACKAGE_NAME1)
+                .setEventType(Event.ACTIVITY_RESUMED)
+                .setClass(TEST_ACTIVITY_NAME)
+                .build());
+    shadowOf(usageStatsManager)
+        .addEvent(
+            ShadowUsageStatsManager.EventBuilder.buildEvent()
+                .setTimeStamp(2800L)
+                .setPackage(packageName)
+                .setEventType(Event.ACTIVITY_STOPPED)
+                .setClass(TEST_ACTIVITY_NAME)
+                .build());
+
+    UsageEvents events = usageStatsManager.queryEventsForSelf(0L, 3000L);
+    Event event = new Event();
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(packageName);
+    assertThat(event.getTimeStamp()).isEqualTo(2200L);
+    assertThat(event.getEventType()).isEqualTo(Event.ACTIVITY_RESUMED);
+
+    assertThat(events.hasNextEvent()).isTrue();
+    assertThat(events.getNextEvent(event)).isTrue();
+    assertThat(event.getPackageName()).isEqualTo(packageName);
+    assertThat(event.getTimeStamp()).isEqualTo(2800L);
+    assertThat(event.getEventType()).isEqualTo(Event.ACTIVITY_STOPPED);
+
+    assertThat(events.hasNextEvent()).isFalse();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void addBroadcastResponseStats_returnsSeededData() {
+    BroadcastResponseStats stats = new BroadcastResponseStats(APP_1, BUCKET_ID_1);
+
+    shadowOf(usageStatsManager).addBroadcastResponseStats(stats);
+
+    assertThat(usageStatsManager.queryBroadcastResponseStats(APP_1, BUCKET_ID_1))
+        .containsExactly(stats);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void addBroadcastResponseStats_multipleApps_returnsCorrectStats() {
+    BroadcastResponseStats app1Stats = new BroadcastResponseStats(APP_1, BUCKET_ID_1);
+    BroadcastResponseStats app2Stats = new BroadcastResponseStats(APP_2, BUCKET_ID_1);
+
+    shadowOf(usageStatsManager).addBroadcastResponseStats(app1Stats);
+    shadowOf(usageStatsManager).addBroadcastResponseStats(app2Stats);
+
+    assertThat(usageStatsManager.queryBroadcastResponseStats(APP_1, BUCKET_ID_1))
+        .containsExactly(app1Stats);
+    assertThat(usageStatsManager.queryBroadcastResponseStats(APP_2, BUCKET_ID_1))
+        .containsExactly(app2Stats);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void addBroadcastResponseStats_multipleBuckets_returnsCorrectStats() {
+    BroadcastResponseStats bucket1Stats = new BroadcastResponseStats(APP_1, BUCKET_ID_1);
+    BroadcastResponseStats bucket2Stats = new BroadcastResponseStats(APP_1, BUCKET_ID_2);
+
+    shadowOf(usageStatsManager).addBroadcastResponseStats(bucket1Stats);
+    shadowOf(usageStatsManager).addBroadcastResponseStats(bucket2Stats);
+
+    assertThat(usageStatsManager.queryBroadcastResponseStats(APP_1, BUCKET_ID_1))
+        .containsExactly(bucket1Stats);
+    assertThat(usageStatsManager.queryBroadcastResponseStats(APP_1, BUCKET_ID_2))
+        .containsExactly(bucket2Stats);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void queryBroadcastResponseStats_zeroBucketId_returnsAllBuckets() {
+    BroadcastResponseStats bucket1Stats = new BroadcastResponseStats(APP_1, BUCKET_ID_1);
+    BroadcastResponseStats bucket2Stats = new BroadcastResponseStats(APP_1, BUCKET_ID_2);
+
+    shadowOf(usageStatsManager).addBroadcastResponseStats(bucket1Stats);
+    shadowOf(usageStatsManager).addBroadcastResponseStats(bucket2Stats);
+
+    assertThat(usageStatsManager.queryBroadcastResponseStats(APP_1, 0))
+        .containsExactly(bucket1Stats, bucket2Stats);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void addBroadcastResponseStats_nullApp_returnsAllApps() {
+    BroadcastResponseStats app1Stats = new BroadcastResponseStats(APP_1, BUCKET_ID_1);
+    BroadcastResponseStats app2Stats = new BroadcastResponseStats(APP_2, BUCKET_ID_1);
+
+    shadowOf(usageStatsManager).addBroadcastResponseStats(app1Stats);
+    shadowOf(usageStatsManager).addBroadcastResponseStats(app2Stats);
+
+    assertThat(usageStatsManager.queryBroadcastResponseStats(null, BUCKET_ID_1))
+        .containsExactly(app1Stats, app2Stats);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbDeviceConnectionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbDeviceConnectionTest.java
new file mode 100644
index 0000000..09f568c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbDeviceConnectionTest.java
@@ -0,0 +1,142 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.usb.UsbConfiguration;
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.hardware.usb.UsbManager;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowUsbDeviceConnection}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowUsbDeviceConnectionTest {
+  private static final String DEVICE_NAME = "usb";
+
+  private UsbManager usbManager;
+
+  @Mock private UsbDevice usbDevice;
+  @Mock private UsbConfiguration usbConfiguration;
+  @Mock private UsbInterface usbInterface;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    usbManager =
+        (UsbManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.USB_SERVICE);
+
+    when(usbDevice.getDeviceName()).thenReturn(DEVICE_NAME);
+    when(usbDevice.getConfigurationCount()).thenReturn(1);
+    when(usbDevice.getConfiguration(0)).thenReturn(usbConfiguration);
+    when(usbConfiguration.getInterfaceCount()).thenReturn(1);
+    when(usbConfiguration.getInterface(0)).thenReturn(usbInterface);
+    when(usbConfiguration.getInterface(0)).thenReturn(usbInterface);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void claimInterface() {
+    UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+    UsbInterface usbInterface = selectInterface(usbDevice);
+
+    assertThat(usbDeviceConnection.claimInterface(usbInterface, /*force=*/ false)).isTrue();
+    assertThat(usbDeviceConnection.releaseInterface(usbInterface)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void controlTransfer() {
+    UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+    UsbInterface usbInterface = selectInterface(usbDevice);
+    usbDeviceConnection.claimInterface(usbInterface, /*force=*/ false);
+
+    int len = 10;
+    assertThat(
+            usbDeviceConnection.controlTransfer(
+                /*requestType=*/ 0,
+                /*request=*/ 0,
+                /*value=*/ 0,
+                /*index=*/ 0,
+                /*buffer=*/ new byte[len],
+                /*length=*/ len,
+                /*timeout=*/ 0))
+        .isEqualTo(len);
+    assertThat(
+            usbDeviceConnection.controlTransfer(
+                /*requestType=*/ 0,
+                /*request=*/ 0,
+                /*value=*/ 0,
+                /*index=*/ 0,
+                /*buffer=*/ new byte[len],
+                /*offset=*/ 0,
+                /*length=*/ len,
+                /*timeout=*/ 0))
+        .isEqualTo(len);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void bulkTransfer() throws Exception {
+    UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+    UsbInterface usbInterface = selectInterface(usbDevice);
+    UsbEndpoint usbEndpointOut = getEndpoint(usbInterface, UsbConstants.USB_DIR_OUT);
+    usbDeviceConnection.claimInterface(usbInterface, /*force=*/ false);
+
+    byte[] msg = "Hello World".getBytes(UTF_8);
+    assertThat(usbDeviceConnection.bulkTransfer(usbEndpointOut, msg, msg.length, /*timeout=*/ 0))
+        .isEqualTo(msg.length);
+
+    byte[] buffer = new byte[msg.length];
+    shadowOf(usbDeviceConnection).readOutgoingData(buffer);
+    assertThat(buffer).isEqualTo(msg);
+
+    msg = "Goodbye World".getBytes(UTF_8);
+    assertThat(
+            usbDeviceConnection.bulkTransfer(
+                usbEndpointOut, msg, /*offset=*/ 0, msg.length, /*timeout=*/ 0))
+        .isEqualTo(msg.length);
+
+    buffer = new byte[msg.length];
+    shadowOf(usbDeviceConnection).readOutgoingData(buffer);
+    assertThat(buffer).isEqualTo(msg);
+  }
+
+  @Nullable
+  private static UsbInterface selectInterface(UsbDevice device) {
+    for (int i = 0; i < device.getConfigurationCount(); i++) {
+      UsbConfiguration configuration = device.getConfiguration(i);
+      for (int j = 0; j < configuration.getInterfaceCount(); j++) {
+        return configuration.getInterface(i);
+      }
+    }
+    return null;
+  }
+
+  @Nullable
+  private static UsbEndpoint getEndpoint(UsbInterface usbInterface, int direction) {
+    for (int i = 0; i < usbInterface.getEndpointCount(); i++) {
+      UsbEndpoint endpoint = usbInterface.getEndpoint(i);
+      if (endpoint.getDirection() == direction) {
+        return endpoint;
+      }
+    }
+    return null;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbManagerTest.java
new file mode 100644
index 0000000..3ea2fde
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbManagerTest.java
@@ -0,0 +1,245 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.util.ReflectionHelpers.getStaticField;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.hardware.usb.UsbAccessory;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbManager;
+import android.hardware.usb.UsbPort;
+import android.hardware.usb.UsbPortStatus;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowUsbManager._UsbManagerQ_;
+import org.robolectric.shadows.ShadowUsbManager._UsbManager_;
+
+/** Unit tests for {@link ShadowUsbManager}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowUsbManagerTest {
+  private static final String DEVICE_NAME_1 = "usb1";
+  private static final String DEVICE_NAME_2 = "usb2";
+
+  private UsbManager usbManager;
+
+  @Mock UsbDevice usbDevice1;
+  @Mock UsbDevice usbDevice2;
+  @Mock UsbAccessory usbAccessory;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    usbManager =
+        (UsbManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.USB_SERVICE);
+
+    when(usbDevice1.getDeviceName()).thenReturn(DEVICE_NAME_1);
+    when(usbDevice2.getDeviceName()).thenReturn(DEVICE_NAME_2);
+  }
+
+  @Test
+  public void getDeviceList() {
+    assertThat(usbManager.getDeviceList()).isEmpty();
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice1, true);
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice2, true);
+    assertThat(usbManager.getDeviceList().values()).containsExactly(usbDevice1, usbDevice2);
+  }
+
+  @Test
+  public void hasPermission_device() {
+    assertThat(usbManager.hasPermission(usbDevice1)).isFalse();
+
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice1, false);
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice2, false);
+
+    assertThat(usbManager.hasPermission(usbDevice1)).isFalse();
+    assertThat(usbManager.hasPermission(usbDevice2)).isFalse();
+
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice1, true);
+
+    assertThat(usbManager.hasPermission(usbDevice1)).isTrue();
+    assertThat(usbManager.hasPermission(usbDevice2)).isFalse();
+  }
+
+  @Test
+  public void hasPermission_accessory() {
+    assertThat(usbManager.hasPermission(usbAccessory)).isFalse();
+    shadowOf(usbManager).setAttachedUsbAccessory(usbAccessory);
+    assertThat(usbManager.hasPermission(usbAccessory)).isFalse();
+    shadowOf(usbManager).grantPermission(usbAccessory);
+    assertThat(usbManager.hasPermission(usbAccessory)).isTrue();
+    shadowOf(usbManager)
+        .revokePermission(usbAccessory, RuntimeEnvironment.getApplication().getPackageName());
+    assertThat(usbManager.hasPermission(usbAccessory)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void grantPermission_selfPackage_shouldHavePermission() {
+    usbManager.grantPermission(usbDevice1);
+
+    assertThat(usbManager.hasPermission(usbDevice1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N_MR1)
+  public void grantPermission_differentPackage_shouldHavePermission() {
+    usbManager.grantPermission(usbDevice1, "foo.bar");
+
+    assertThat(shadowOf(usbManager).hasPermissionForPackage(usbDevice1, "foo.bar")).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N_MR1)
+  public void revokePermission_shouldNotHavePermission() {
+    usbManager.grantPermission(usbDevice1, "foo.bar");
+    assertThat(shadowOf(usbManager).hasPermissionForPackage(usbDevice1, "foo.bar")).isTrue();
+
+    shadowOf(usbManager).revokePermission(usbDevice1, "foo.bar");
+
+    assertThat(shadowOf(usbManager).hasPermissionForPackage(usbDevice1, "foo.bar")).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M, maxSdk = P)
+  public void getPorts_shouldReturnAddedPorts() {
+    shadowOf(usbManager).addPort("port1");
+    shadowOf(usbManager).addPort("port2");
+    shadowOf(usbManager).addPort("port3");
+
+    List<UsbPort> usbPorts = getUsbPorts();
+    assertThat(usbPorts).hasSize(3);
+    assertThat(usbPorts.stream().map(UsbPort::getId).collect(Collectors.toList()))
+        .containsExactly("port1", "port2", "port3");
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getPortStatus_shouldReturnStatusForCorrespondingPort() {
+    shadowOf(usbManager).addPort("port1");
+    shadowOf(usbManager).addPort("port2");
+    shadowOf(usbManager)
+        .addPort(
+            "port3",
+            UsbPortStatus.MODE_DUAL,
+            UsbPortStatus.POWER_ROLE_SINK,
+            UsbPortStatus.DATA_ROLE_DEVICE,
+            /*statusSupportedRoleCombinations=*/ 0);
+
+    UsbPortStatus portStatus = (UsbPortStatus) shadowOf(usbManager).getPortStatus("port3");
+    assertThat(portStatus.getCurrentMode()).isEqualTo(UsbPortStatus.MODE_DUAL);
+    assertThat(portStatus.getCurrentPowerRole()).isEqualTo(UsbPortStatus.POWER_ROLE_SINK);
+    assertThat(portStatus.getCurrentDataRole()).isEqualTo(UsbPortStatus.DATA_ROLE_DEVICE);
+    assertThat(portStatus.getSupportedRoleCombinations()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void clearPorts_shouldRemoveAllPorts() {
+    shadowOf(usbManager).addPort("port1");
+    shadowOf(usbManager).clearPorts();
+
+    List<UsbPort> usbPorts = getUsbPorts();
+    assertThat(usbPorts).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = M, maxSdk = P)
+  public void setPortRoles_sinkHost_shouldSetPortStatus() {
+    final int powerRoleSink = getStaticField(UsbPort.class, "POWER_ROLE_SINK");
+    final int dataRoleHost = getStaticField(UsbPort.class, "DATA_ROLE_HOST");
+
+    shadowOf(usbManager).addPort("port1");
+
+    List<UsbPort> usbPorts = getUsbPorts();
+
+    _usbManager_().setPortRoles(usbPorts.get(0), powerRoleSink, dataRoleHost);
+
+    UsbPortStatus usbPortStatus = _usbManager_().getPortStatus(usbPorts.get(0));
+    assertThat(usbPortStatus.getCurrentPowerRole()).isEqualTo(powerRoleSink);
+    assertThat(usbPortStatus.getCurrentDataRole()).isEqualTo(dataRoleHost);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setPortRoles_sinkHost_shouldSetPortStatus_Q() {
+    shadowOf(usbManager).addPort("port1");
+
+    List<UsbPort> usbPorts = getUsbPorts();
+    _usbManager_()
+        .setPortRoles(usbPorts.get(0), UsbPortStatus.POWER_ROLE_SINK, UsbPortStatus.DATA_ROLE_HOST);
+
+    UsbPortStatus usbPortStatus = _usbManager_().getPortStatus(usbPorts.get(0));
+    assertThat(usbPortStatus.getCurrentPowerRole()).isEqualTo(UsbPortStatus.POWER_ROLE_SINK);
+    assertThat(usbPortStatus.getCurrentDataRole()).isEqualTo(UsbPortStatus.DATA_ROLE_HOST);
+  }
+
+  @Test
+  public void removeDevice() {
+    assertThat(usbManager.getDeviceList()).isEmpty();
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice1, false);
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice2, false);
+
+    assertThat(usbManager.getDeviceList().values()).containsExactly(usbDevice1, usbDevice2);
+
+    shadowOf(usbManager).removeUsbDevice(usbDevice1);
+    assertThat(usbManager.getDeviceList().values()).containsExactly(usbDevice2);
+  }
+
+  @Test
+  public void openDevice() throws Exception {
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice1, true);
+    UsbDeviceConnection connection = usbManager.openDevice(usbDevice1);
+    assertThat(connection).isNotNull();
+  }
+
+  @Test
+  public void openAccessory() throws Exception {
+    try (ParcelFileDescriptor pfd = usbManager.openAccessory(usbAccessory)) {
+      assertThat(pfd).isNotNull();
+    }
+  }
+
+  @Test
+  public void setAccessory() {
+    assertThat(usbManager.getAccessoryList()).isNull();
+    shadowOf(usbManager).setAttachedUsbAccessory(usbAccessory);
+    assertThat(usbManager.getAccessoryList()).hasLength(1);
+    assertThat(usbManager.getAccessoryList()[0]).isEqualTo(usbAccessory);
+  }
+
+  /////////////////////////
+
+  private List<UsbPort> getUsbPorts() {
+    // return type changed from UsbPort[] to List<UsbPort> in Q...
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      return reflector(_UsbManagerQ_.class, usbManager).getPorts();
+    }
+    return Arrays.asList(_usbManager_().getPorts());
+  }
+
+  private _UsbManager_ _usbManager_() {
+    return reflector(_UsbManager_.class, usbManager);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbRequestTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbRequestTest.java
new file mode 100644
index 0000000..98fc28c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUsbRequestTest.java
@@ -0,0 +1,139 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.hardware.usb.UsbConfiguration;
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.hardware.usb.UsbManager;
+import android.hardware.usb.UsbRequest;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowUsbRequest}. */
+@RunWith(AndroidJUnit4.class)
+public class ShadowUsbRequestTest {
+  private static final String DEVICE_NAME = "usb";
+
+  private UsbManager usbManager;
+  private UsbRequest dataRequest;
+
+  @Mock private UsbDevice usbDevice;
+  @Mock private UsbConfiguration usbConfiguration;
+  @Mock private UsbInterface usbInterface;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    usbManager =
+        (UsbManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.USB_SERVICE);
+    dataRequest = new UsbRequest();
+
+    when(usbDevice.getDeviceName()).thenReturn(DEVICE_NAME);
+    when(usbDevice.getConfigurationCount()).thenReturn(1);
+    when(usbDevice.getConfiguration(0)).thenReturn(usbConfiguration);
+    when(usbConfiguration.getInterfaceCount()).thenReturn(1);
+    when(usbConfiguration.getInterface(0)).thenReturn(usbInterface);
+    when(usbConfiguration.getInterface(0)).thenReturn(usbInterface);
+
+    shadowOf(usbManager).addOrUpdateUsbDevice(usbDevice, /*hasPermission=*/ true);
+  }
+
+  @After
+  public void tearDown() {
+    dataRequest.close();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void initialize() {
+    UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+    UsbInterface usbInterface = selectInterface(usbDevice);
+    UsbEndpoint usbEndpointIn = getEndpoint(usbInterface, UsbConstants.USB_DIR_IN);
+
+    assertThat(dataRequest.initialize(usbDeviceConnection, usbEndpointIn)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void queue() {
+    UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+    UsbInterface usbInterface = selectInterface(usbDevice);
+    UsbEndpoint usbEndpointIn = getEndpoint(usbInterface, UsbConstants.USB_DIR_IN);
+    dataRequest.initialize(usbDeviceConnection, usbEndpointIn);
+
+    byte[] msg = "Hello World".getBytes(UTF_8);
+    shadowOf(usbDeviceConnection).writeIncomingData(msg);
+
+    byte[] buffer = new byte[msg.length];
+    ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
+
+    dataRequest.queue(byteBuffer, buffer.length);
+    assertThat(usbDeviceConnection.requestWait()).isEqualTo(dataRequest);
+    assertThat(buffer).isEqualTo(msg);
+  }
+
+  @Test
+  @Config(sdk = LOLLIPOP)
+  // Before P, there's a limitation on the size of data that can be exchanged. Data over this limit
+  // is cropped.
+  public void queue_outOfSize() {
+    UsbDeviceConnection usbDeviceConnection = usbManager.openDevice(usbDevice);
+    UsbInterface usbInterface = selectInterface(usbDevice);
+    UsbEndpoint usbEndpointIn = getEndpoint(usbInterface, UsbConstants.USB_DIR_IN);
+    dataRequest.initialize(usbDeviceConnection, usbEndpointIn);
+
+    byte[] helloWorld = "Hello World".getBytes(UTF_8);
+    ByteBuffer msg = ByteBuffer.allocate(16384 + 1);
+    msg.position(msg.capacity() - helloWorld.length);
+    msg.put(helloWorld);
+    new Thread(() -> shadowOf(usbDeviceConnection).writeIncomingData(msg.array())).start();
+
+    byte[] buffer = new byte[msg.capacity()];
+    ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
+
+    dataRequest.queue(byteBuffer, buffer.length);
+    assertThat(usbDeviceConnection.requestWait()).isEqualTo(dataRequest);
+    assertThat(buffer).isNotEqualTo(msg.array());
+  }
+
+  @Nullable
+  private static UsbInterface selectInterface(UsbDevice device) {
+    for (int i = 0; i < device.getConfigurationCount(); i++) {
+      UsbConfiguration configuration = device.getConfiguration(i);
+      for (int j = 0; j < configuration.getInterfaceCount(); j++) {
+        return configuration.getInterface(i);
+      }
+    }
+    return null;
+  }
+
+  @Nullable
+  private static UsbEndpoint getEndpoint(UsbInterface usbInterface, int direction) {
+    for (int i = 0; i < usbInterface.getEndpointCount(); i++) {
+      UsbEndpoint endpoint = usbInterface.getEndpoint(i);
+      if (endpoint.getDirection() == direction) {
+        return endpoint;
+      }
+    }
+    return null;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java
new file mode 100644
index 0000000..d165ec1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java
@@ -0,0 +1,1066 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.Manifest.permission;
+import android.app.Application;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageInfo;
+import android.content.pm.UserInfo;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Looper;
+import android.os.Parcel;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowUserManager.UserState;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowUserManagerTest {
+
+  private UserManager userManager;
+  private Context context;
+
+  private static final int TEST_USER_HANDLE = 0;
+  private static final int PROFILE_USER_HANDLE = 2;
+  private static final String PROFILE_USER_NAME = "profile";
+  private static final String SEED_ACCOUNT_NAME = "seed_account_name";
+  private static final String SEED_ACCOUNT_TYPE = "seed_account_type";
+  private static final int PROFILE_USER_FLAGS = 0;
+  private static final Bitmap TEST_USER_ICON = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldGetUserProfiles() {
+    assertThat(userManager.getUserProfiles()).contains(Process.myUserHandle());
+
+    UserHandle anotherProfile = newUserHandle(2);
+    shadowOf(userManager).addUserProfile(anotherProfile);
+
+    assertThat(userManager.getUserProfiles()).containsExactly(Process.myUserHandle(), anotherProfile);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getUserProfiles_calledFromProfile_shouldReturnList() {
+    ShadowProcess.setUid(2 * 100000);
+    assertThat(userManager.getUserProfiles()).contains(new UserHandle(2));
+
+    shadowOf(userManager).addProfile(0, 2, "profile", /* profileFlags= */ 0);
+
+    assertThat(userManager.getUserProfiles()).containsExactly(new UserHandle(0), new UserHandle(2));
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getUserProfiles_noProfiles_shouldReturnListOfSelf() {
+    assertThat(userManager.getUserProfiles()).containsExactly(new UserHandle(0));
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void testGetApplicationRestrictions() {
+    String packageName = context.getPackageName();
+    assertThat(userManager.getApplicationRestrictions(packageName).size()).isEqualTo(0);
+
+    Bundle restrictions = new Bundle();
+    restrictions.putCharSequence("test_key", "test_value");
+    shadowOf(userManager).setApplicationRestrictions(packageName, restrictions);
+
+    assertThat(
+            userManager
+                .getApplicationRestrictions(packageName)
+                .getCharSequence("test_key")
+                .toString())
+        .isEqualTo("test_value");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isUserUnlocked() {
+    assertThat(userManager.isUserUnlocked()).isTrue();
+    shadowOf(userManager).setUserUnlocked(false);
+    assertThat(userManager.isUserUnlocked()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void hasUserRestriction() {
+    assertThat(userManager.hasUserRestriction(UserManager.ENSURE_VERIFY_APPS)).isFalse();
+
+    UserHandle userHandle = Process.myUserHandle();
+    shadowOf(userManager).setUserRestriction(userHandle, UserManager.ENSURE_VERIFY_APPS, true);
+
+    assertThat(userManager.hasUserRestriction(UserManager.ENSURE_VERIFY_APPS)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void setUserRestriction_forGivenUserHandle_setsTheRestriction() {
+    assertThat(userManager.hasUserRestriction(UserManager.ENSURE_VERIFY_APPS)).isFalse();
+
+    UserHandle userHandle = Process.myUserHandle();
+    shadowOf(userManager).setUserRestriction(UserManager.ENSURE_VERIFY_APPS, true, userHandle);
+
+    assertThat(userManager.hasUserRestriction(UserManager.ENSURE_VERIFY_APPS)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void setUserRestriction_forCurrentUser_setsTheRestriction() {
+    assertThat(userManager.hasUserRestriction(UserManager.ENSURE_VERIFY_APPS)).isFalse();
+
+    userManager.setUserRestriction(UserManager.ENSURE_VERIFY_APPS, true);
+
+    assertThat(userManager.hasUserRestriction(UserManager.ENSURE_VERIFY_APPS)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getUserRestrictions() {
+    assertThat(userManager.getUserRestrictions().size()).isEqualTo(0);
+
+    UserHandle userHandle = Process.myUserHandle();
+    shadowOf(userManager).setUserRestriction(userHandle, UserManager.ENSURE_VERIFY_APPS, true);
+
+    Bundle restrictions = userManager.getUserRestrictions();
+    assertThat(restrictions.size()).isEqualTo(1);
+    assertThat(restrictions.getBoolean(UserManager.ENSURE_VERIFY_APPS)).isTrue();
+
+    // make sure that the bundle is not an internal state
+    restrictions.putBoolean("something", true);
+    restrictions = userManager.getUserRestrictions();
+    assertThat(restrictions.size()).isEqualTo(1);
+
+    shadowOf(userManager).setUserRestriction(newUserHandle(10), UserManager.DISALLOW_CAMERA, true);
+
+    assertThat(userManager.hasUserRestriction(UserManager.DISALLOW_CAMERA)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void clearUserRestrictions() {
+    assertThat(userManager.getUserRestrictions().size()).isEqualTo(0);
+    shadowOf(userManager)
+        .setUserRestriction(Process.myUserHandle(), UserManager.ENSURE_VERIFY_APPS, true);
+    assertThat(userManager.getUserRestrictions().size()).isEqualTo(1);
+
+    shadowOf(userManager).clearUserRestrictions(Process.myUserHandle());
+    assertThat(userManager.getUserRestrictions().size()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void isManagedProfile() {
+    assertThat(userManager.isManagedProfile()).isFalse();
+    shadowOf(userManager).setManagedProfile(true);
+    assertThat(userManager.isManagedProfile()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isManagedProfile_usesContextUser() {
+    shadowOf(userManager)
+        .addProfile(
+            0, PROFILE_USER_HANDLE, PROFILE_USER_NAME, ShadowUserManager.FLAG_MANAGED_PROFILE);
+
+    assertThat(userManager.isManagedProfile()).isFalse();
+
+    Application application = ApplicationProvider.getApplicationContext();
+    ShadowContextImpl shadowContext = Shadow.extract(application.getBaseContext());
+    shadowContext.setUserId(PROFILE_USER_HANDLE);
+
+    assertThat(userManager.isManagedProfile()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isManagedProfileWithHandle() {
+    shadowOf(userManager).addUser(12, "secondary user", 0);
+    shadowOf(userManager)
+        .addProfile(12, 13, "another managed profile", ShadowUserManager.FLAG_MANAGED_PROFILE);
+    assertThat(userManager.isManagedProfile(13)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isProfile_fullUser_returnsFalse() {
+    assertThat(userManager.isProfile()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isProfile_profileUser_returnsTrue() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).setMaxSupportedUsers(2);
+    UserHandle profileHandle =
+        userManager.createProfile(PROFILE_USER_NAME, UserManager.USER_TYPE_PROFILE_MANAGED, null);
+    assertThat(userManager.isProfile()).isFalse();
+
+    Application application = ApplicationProvider.getApplicationContext();
+    ShadowContextImpl shadowContext = Shadow.extract(application.getBaseContext());
+    shadowContext.setUserId(profileHandle.getIdentifier());
+
+    assertThat(userManager.isProfile()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void enforcePermissionChecks() {
+    shadowOf(userManager).enforcePermissionChecks(true);
+
+    try {
+      userManager.isManagedProfile();
+      fail("Expected exception");
+    } catch (SecurityException expected) {}
+
+    setPermissions(permission.MANAGE_USERS);
+
+    shadowOf(userManager).setManagedProfile(true);
+
+    assertThat(userManager.isManagedProfile()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldGetSerialNumberForUser() {
+    long serialNumberInvalid = -1L;
+
+    UserHandle userHandle = newUserHandle(10);
+    assertThat(userManager.getSerialNumberForUser(userHandle)).isEqualTo(serialNumberInvalid);
+    assertThat(userManager.getUserSerialNumber(userHandle.getIdentifier()))
+        .isEqualTo(serialNumberInvalid);
+
+    shadowOf(userManager).addUserProfile(userHandle);
+
+    assertThat(userManager.getSerialNumberForUser(userHandle)).isNotEqualTo(serialNumberInvalid);
+    assertThat(userManager.getUserSerialNumber(userHandle.getIdentifier()))
+        .isNotEqualTo(serialNumberInvalid);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getUserForNonExistSerialNumber() {
+    long nonExistSerialNumber = 121;
+    assertThat(userManager.getUserForSerialNumber(nonExistSerialNumber)).isNull();
+    assertThat(userManager.getUserHandle((int) nonExistSerialNumber)).isEqualTo(-1);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldGetSerialNumberForProfile() {
+    long serialNumberInvalid = -1L;
+
+    assertThat(userManager.getUserSerialNumber(11)).isEqualTo(serialNumberInvalid);
+    shadowOf(userManager).addProfile(10, 11, "profile", 0);
+    assertThat(userManager.getUserSerialNumber(11)).isNotEqualTo(serialNumberInvalid);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void shouldGetUserHandleFromSerialNumberForProfile() {
+    long serialNumberInvalid = -1L;
+
+    shadowOf(userManager).addProfile(10, 11, "profile", 0);
+    long serialNumber = userManager.getUserSerialNumber(11);
+    assertThat(serialNumber).isNotEqualTo(serialNumberInvalid);
+    assertThat(userManager.getUserHandle((int) serialNumber)).isEqualTo(11);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getSerialNumberForUser_returnsSetSerialNumberForUser() {
+    UserHandle userHandle = newUserHandle(0);
+    shadowOf(userManager).setSerialNumberForUser(userHandle, 123L);
+    assertThat(userManager.getSerialNumberForUser(userHandle)).isEqualTo(123L);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getUserHandle() {
+    UserHandle expectedUserHandle = shadowOf(userManager).addUser(10, "secondary_user", 0);
+
+    long serialNumber = userManager.getUserSerialNumber(10);
+    int actualUserHandle = shadowOf(userManager).getUserHandle((int) serialNumber);
+    assertThat(actualUserHandle).isEqualTo(expectedUserHandle.getIdentifier());
+  }
+
+  @Test
+  @Config(minSdk = N_MR1, maxSdk = Q)
+  public void isDemoUser() {
+    // All methods are based on the current user, so no need to pass a UserHandle.
+    assertThat(userManager.isDemoUser()).isFalse();
+
+    shadowOf(userManager).setIsDemoUser(true);
+    assertThat(userManager.isDemoUser()).isTrue();
+
+    shadowOf(userManager).setIsDemoUser(false);
+    assertThat(userManager.isDemoUser()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isSystemUser() {
+    assertThat(userManager.isSystemUser()).isTrue();
+
+    shadowOf(userManager).setIsSystemUser(false);
+    assertThat(userManager.isSystemUser()).isFalse();
+
+    shadowOf(userManager).setIsSystemUser(true);
+    assertThat(userManager.isSystemUser()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isPrimaryUser() {
+    assertThat(userManager.isPrimaryUser()).isTrue();
+
+    shadowOf(userManager).setIsPrimaryUser(false);
+    assertThat(userManager.isPrimaryUser()).isFalse();
+
+    shadowOf(userManager).setIsPrimaryUser(true);
+    assertThat(userManager.isPrimaryUser()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void isLinkedUser() {
+    assertThat(userManager.isLinkedUser()).isFalse();
+
+    shadowOf(userManager).setIsLinkedUser(true);
+    assertThat(userManager.isLinkedUser()).isTrue();
+
+    shadowOf(userManager).setIsLinkedUser(false);
+    assertThat(userManager.isLinkedUser()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.Q)
+  public void isRestrictedProfile() {
+    assertThat(userManager.isRestrictedProfile()).isFalse();
+
+    shadowOf(userManager).setIsRestrictedProfile(true);
+    assertThat(userManager.isRestrictedProfile()).isTrue();
+
+    shadowOf(userManager).setIsRestrictedProfile(false);
+    assertThat(userManager.isRestrictedProfile()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void setSeedAccountName() {
+    assertThat(userManager.getSeedAccountName()).isNull();
+
+    shadowOf(userManager).setSeedAccountName(SEED_ACCOUNT_NAME);
+    assertThat(userManager.getSeedAccountName()).isEqualTo(SEED_ACCOUNT_NAME);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void setSeedAccountType() {
+    assertThat(userManager.getSeedAccountType()).isNull();
+
+    shadowOf(userManager).setSeedAccountType(SEED_ACCOUNT_TYPE);
+    assertThat(userManager.getSeedAccountType()).isEqualTo(SEED_ACCOUNT_TYPE);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void setSeedAccountOptions() {
+    assertThat(userManager.getSeedAccountOptions()).isNull();
+
+    PersistableBundle options = new PersistableBundle();
+    shadowOf(userManager).setSeedAccountOptions(options);
+    assertThat(userManager.getSeedAccountOptions()).isEqualTo(options);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.N)
+  public void clearSeedAccountData() {
+    shadowOf(userManager).setSeedAccountName(SEED_ACCOUNT_NAME);
+    shadowOf(userManager).setSeedAccountType(SEED_ACCOUNT_TYPE);
+    shadowOf(userManager).setSeedAccountOptions(new PersistableBundle());
+
+    assertThat(userManager.getSeedAccountName()).isNotNull();
+    assertThat(userManager.getSeedAccountType()).isNotNull();
+    assertThat(userManager.getSeedAccountOptions()).isNotNull();
+
+    userManager.clearSeedAccountData();
+
+    assertThat(userManager.getSeedAccountName()).isNull();
+    assertThat(userManager.getSeedAccountType()).isNull();
+    assertThat(userManager.getSeedAccountOptions()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT_WATCH)
+  public void isGuestUser() {
+    assertThat(userManager.isGuestUser()).isFalse();
+
+    shadowOf(userManager).setIsGuestUser(true);
+    assertThat(userManager.isGuestUser()).isTrue();
+
+    shadowOf(userManager).setIsGuestUser(false);
+    assertThat(userManager.isGuestUser()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void isUserRunning() {
+    UserHandle userHandle = newUserHandle(0);
+
+    assertThat(userManager.isUserRunning(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKED);
+    assertThat(userManager.isUserRunning(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_LOCKED);
+    assertThat(userManager.isUserRunning(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKING);
+    assertThat(userManager.isUserRunning(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_STOPPING);
+    assertThat(userManager.isUserRunning(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_BOOTING);
+    assertThat(userManager.isUserRunning(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_SHUTDOWN);
+    assertThat(userManager.isUserRunning(userHandle)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void isUserRunningOrStopping() {
+    UserHandle userHandle = newUserHandle(0);
+
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKED);
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_LOCKED);
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKING);
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_STOPPING);
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_BOOTING);
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_SHUTDOWN);
+    assertThat(userManager.isUserRunningOrStopping(userHandle)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isUserUnlockingOrUnlocked() {
+    UserHandle userHandle = newUserHandle(0);
+
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKED);
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_LOCKED);
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKING);
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_STOPPING);
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_BOOTING);
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_SHUTDOWN);
+    assertThat(userManager.isUserUnlockingOrUnlocked(userHandle)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = 24)
+  public void isUserUnlockedByUserHandle() {
+    UserHandle userHandle = newUserHandle(0);
+
+    assertThat(userManager.isUserUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKED);
+    assertThat(userManager.isUserUnlocked(userHandle)).isTrue();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_LOCKED);
+    assertThat(userManager.isUserUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_RUNNING_UNLOCKING);
+    assertThat(userManager.isUserUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_STOPPING);
+    assertThat(userManager.isUserUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_BOOTING);
+    assertThat(userManager.isUserUnlocked(userHandle)).isFalse();
+
+    shadowOf(userManager).setUserState(userHandle, UserState.STATE_SHUTDOWN);
+    assertThat(userManager.isUserUnlocked(userHandle)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void addSecondaryUser() {
+    assertThat(userManager.getUserCount()).isEqualTo(1);
+    UserHandle userHandle = shadowOf(userManager).addUser(10, "secondary_user", 0);
+    assertThat(userHandle.getIdentifier()).isEqualTo(10);
+    assertThat(userManager.getUserCount()).isEqualTo(2);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void removeSecondaryUser() {
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    assertThat(shadowOf(userManager).removeUser(10)).isTrue();
+    assertThat(userManager.getUserCount()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void removeSecondaryUser_withUserHandle() {
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    assertThat(shadowOf(userManager).removeUser(UserHandle.of(10))).isTrue();
+    assertThat(userManager.getUserCount()).isEqualTo(1);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void switchToSecondaryUser() {
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    shadowOf(userManager).switchUser(10);
+    assertThat(UserHandle.myUserId()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void switchToSecondaryUser_system() {
+    assertThat(userManager.isSystemUser()).isTrue();
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    shadowOf(userManager).switchUser(10);
+
+    assertThat(userManager.isSystemUser()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void canSwitchUsers() {
+    shadowOf(userManager).setCanSwitchUser(false);
+    assertThat(shadowOf(userManager).canSwitchUsers()).isFalse();
+    shadowOf(userManager).setCanSwitchUser(true);
+    assertThat(shadowOf(userManager).canSwitchUsers()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void getSerialNumbersOfUsers() {
+    assertThat(userManager.getSerialNumbersOfUsers(true)).hasLength(userManager.getUserCount());
+
+    UserHandle userHandle = shadowOf(userManager).addUser(10, "secondary_user", 0);
+    int userSerialNumber = userManager.getUserSerialNumber(userHandle.getIdentifier());
+    long[] serialNumbers = userManager.getSerialNumbersOfUsers(true);
+
+    assertThat(userHandle.getIdentifier()).isEqualTo(10);
+    assertThat(serialNumbers).hasLength(userManager.getUserCount());
+    assertThat(serialNumbers[0] == userSerialNumber || serialNumbers[1] == userSerialNumber)
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getUsers() {
+    assertThat(userManager.getUsers()).hasSize(1);
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    assertThat(userManager.getUsers()).hasSize(2);
+    shadowOf(userManager).addProfile(10, 11, "profile", 0);
+    assertThat(userManager.getUsers()).hasSize(3);
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getUserInfo() {
+    shadowOf(userManager).addUser(10, "secondary_user", 0);
+    assertThat(userManager.getUserInfo(10)).isNotNull();
+    assertThat(userManager.getUserInfo(10).name).isEqualTo("secondary_user");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getUserInfoOfProfile() {
+    shadowOf(userManager).addProfile(10, 11, "profile_user", 0);
+    shadowOf(userManager).addProfile(10, 12, "profile_user_2", 0);
+    shadowOf(userManager).addProfile(13, 14, "profile_user_3", 0);
+    assertThat(userManager.getUserInfo(11)).isNotNull();
+    assertThat(userManager.getUserInfo(11).name).isEqualTo("profile_user");
+    assertThat(userManager.getUserInfo(12)).isNotNull();
+    assertThat(userManager.getUserInfo(12).name).isEqualTo("profile_user_2");
+    assertThat(userManager.getUserInfo(14)).isNotNull();
+    assertThat(userManager.getUserInfo(14).name).isEqualTo("profile_user_3");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void switchToUserNotAddedShouldThrowException() {
+    try {
+      shadowOf(userManager).switchUser(10);
+      fail("Switching to the user that was never added should throw UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertThat(e).hasMessageThat().isEqualTo("Must add user before switching to it");
+    }
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void getProfiles_addedProfile_containsProfile() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).addProfile(
+        TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+
+    // getProfiles(userId) include user itself and asssociated profiles.
+    assertThat(userManager.getProfiles(TEST_USER_HANDLE).get(0).id).isEqualTo(TEST_USER_HANDLE);
+    assertThat(userManager.getProfiles(TEST_USER_HANDLE).get(1).id).isEqualTo(PROFILE_USER_HANDLE);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getEnabledProfiles() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).addProfile(TEST_USER_HANDLE, 10, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    shadowOf(userManager).addProfile(TEST_USER_HANDLE, 11, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    shadowOf(userManager).setIsUserEnabled(11, false);
+
+    assertThat(userManager.getEnabledProfiles()).hasSize(2);
+    assertThat(userManager.getEnabledProfiles().get(0).getIdentifier()).isEqualTo(TEST_USER_HANDLE);
+    assertThat(userManager.getEnabledProfiles().get(1).getIdentifier()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void getAllProfiles() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).addProfile(TEST_USER_HANDLE, 10, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    shadowOf(userManager).addProfile(TEST_USER_HANDLE, 11, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    shadowOf(userManager).setIsUserEnabled(11, false);
+
+    assertThat(userManager.getAllProfiles()).hasSize(3);
+    assertThat(userManager.getAllProfiles().get(0).getIdentifier()).isEqualTo(TEST_USER_HANDLE);
+    assertThat(userManager.getAllProfiles().get(1).getIdentifier()).isEqualTo(10);
+    assertThat(userManager.getAllProfiles().get(2).getIdentifier()).isEqualTo(11);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void createProfile_maxUsersReached_returnsNull() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).setMaxSupportedUsers(1);
+    assertThat(
+            userManager.createProfile(
+                PROFILE_USER_NAME, UserManager.USER_TYPE_PROFILE_MANAGED, null))
+        .isNull();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void createProfile_setsGivenUserName() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).setMaxSupportedUsers(2);
+    userManager.createProfile(PROFILE_USER_NAME, UserManager.USER_TYPE_PROFILE_MANAGED, null);
+
+    Application application = ApplicationProvider.getApplicationContext();
+    ShadowContextImpl shadowContext = Shadow.extract(application.getBaseContext());
+    shadowContext.setUserId(ShadowUserManager.DEFAULT_SECONDARY_USER_ID);
+    assertThat(userManager.getUserName()).isEqualTo(PROFILE_USER_NAME);
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void createProfile_userIdIncreasesFromDefault() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).setMaxSupportedUsers(3);
+    UserHandle newUser1 =
+        userManager.createProfile("profile A", UserManager.USER_TYPE_PROFILE_MANAGED, null);
+    UserHandle newUser2 =
+        userManager.createProfile("profile B", UserManager.USER_TYPE_PROFILE_MANAGED, null);
+
+    assertThat(newUser1.getIdentifier()).isEqualTo(ShadowUserManager.DEFAULT_SECONDARY_USER_ID);
+    assertThat(newUser2.getIdentifier()).isEqualTo(ShadowUserManager.DEFAULT_SECONDARY_USER_ID + 1);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getProfileParent_returnsNullForUser() {
+    assertThat(userManager.getProfileParent(UserHandle.of(0))).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getProfileParent_returnsNullForParent() {
+    shadowOf(userManager)
+        .addProfile(TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    assertThat(userManager.getProfileParent(UserHandle.of(TEST_USER_HANDLE))).isNull();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getProfileParent_returnsParentForProfile() {
+    shadowOf(userManager)
+        .addProfile(TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    assertThat(userManager.getProfileParent(UserHandle.of(PROFILE_USER_HANDLE)))
+        .isEqualTo(UserHandle.of(TEST_USER_HANDLE));
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isSameProfileGroup_sameNonParentUser_returnsFalse() {
+    assertThat(
+            userManager.isSameProfileGroup(
+                UserHandle.of(TEST_USER_HANDLE), UserHandle.of(TEST_USER_HANDLE)))
+        .isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isSameProfileGroup_sameParentUser_returnsTrue() {
+    shadowOf(userManager)
+        .addProfile(TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    assertThat(
+            userManager.isSameProfileGroup(
+                UserHandle.of(TEST_USER_HANDLE), UserHandle.of(TEST_USER_HANDLE)))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isSameProfileGroup_parentAndProfile_returnsTrue() {
+    shadowOf(userManager)
+        .addProfile(TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    assertThat(
+            userManager.isSameProfileGroup(
+                UserHandle.of(PROFILE_USER_HANDLE), UserHandle.of(TEST_USER_HANDLE)))
+        .isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isSameProfileGroup_twoProfilesOfSameUser_returnsTrue() {
+    shadowOf(userManager).addProfile(TEST_USER_HANDLE, 10, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    shadowOf(userManager).addProfile(TEST_USER_HANDLE, 11, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+
+    assertThat(userManager.isSameProfileGroup(UserHandle.of(10), UserHandle.of(11))).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isSameProfileGroup_profilesOfDifferentUsers_returnsFalse() {
+    shadowOf(userManager).addProfile(0, 10, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+    shadowOf(userManager).addProfile(1, 11, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+
+    assertThat(userManager.isSameProfileGroup(UserHandle.of(10), UserHandle.of(11))).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void setUserName() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager)
+        .addProfile(TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS);
+
+    userManager.setUserName("new user name");
+
+    Application application = ApplicationProvider.getApplicationContext();
+    ShadowContextImpl shadowContext = Shadow.extract(application.getBaseContext());
+    shadowContext.setUserId(PROFILE_USER_HANDLE);
+    userManager.setUserName("new profile name");
+    assertThat(userManager.getUserName()).isEqualTo("new profile name");
+
+    shadowContext.setUserId(TEST_USER_HANDLE);
+    assertThat(userManager.getUserName()).isEqualTo("new user name");
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isUserOfType() {
+    shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0);
+    shadowOf(userManager).setMaxSupportedUsers(2);
+    UserHandle newUser =
+        userManager.createProfile(PROFILE_USER_NAME, UserManager.USER_TYPE_PROFILE_MANAGED, null);
+    assertThat(userManager.isUserOfType(UserManager.USER_TYPE_PROFILE_MANAGED)).isFalse();
+
+    Application application = ApplicationProvider.getApplicationContext();
+    ShadowContextImpl shadowContext = Shadow.extract(application.getBaseContext());
+    shadowContext.setUserId(newUser.getIdentifier());
+
+    assertThat(userManager.isUserOfType(UserManager.USER_TYPE_PROFILE_MANAGED)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getMaxSupportedUsers() {
+    assertThat(UserManager.getMaxSupportedUsers()).isEqualTo(1);
+    shadowOf(userManager).setMaxSupportedUsers(5);
+    assertThat(UserManager.getMaxSupportedUsers()).isEqualTo(5);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void supportsMultipleUsers() {
+    assertThat(UserManager.supportsMultipleUsers()).isFalse();
+
+    shadowOf(userManager).setSupportsMultipleUsers(true);
+    assertThat(UserManager.supportsMultipleUsers()).isTrue();
+  }
+
+
+  @Test
+  @Config(minSdk = Q)
+  public void getUserSwitchability_shouldReturnLastSetSwitchability() {
+    assertThat(userManager.getUserSwitchability()).isEqualTo(UserManager.SWITCHABILITY_STATUS_OK);
+    shadowOf(userManager)
+        .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED);
+    assertThat(userManager.getUserSwitchability())
+        .isEqualTo(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED);
+    shadowOf(userManager)
+        .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK);
+    assertThat(userManager.getUserSwitchability()).isEqualTo(UserManager.SWITCHABILITY_STATUS_OK);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void setCanSwitchUser_shouldChangeSwitchabilityState() {
+    shadowOf(userManager).setCanSwitchUser(false);
+    assertThat(userManager.getUserSwitchability())
+        .isEqualTo(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED);
+    shadowOf(userManager).setCanSwitchUser(true);
+    assertThat(userManager.getUserSwitchability()).isEqualTo(UserManager.SWITCHABILITY_STATUS_OK);
+  }
+
+  @Test
+  @Config(minSdk = N, maxSdk = Q)
+  public void canSwitchUser_shouldReflectSwitchabilityState() {
+    shadowOf(userManager)
+        .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED);
+    assertThat(userManager.canSwitchUsers()).isFalse();
+    shadowOf(userManager)
+        .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK);
+    assertThat(userManager.canSwitchUsers()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getUserName_shouldReturnSetUserName() {
+    shadowOf(userManager).setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK);
+    shadowOf(userManager).addUser(10, PROFILE_USER_NAME, /* flags = */ 0);
+    shadowOf(userManager).switchUser(10);
+    assertThat(userManager.getUserName()).isEqualTo(PROFILE_USER_NAME);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getUserIcon_shouldReturnSetUserIcon() {
+    userManager.setUserIcon(TEST_USER_ICON);
+    assertThat(userManager.getUserIcon()).isEqualTo(TEST_USER_ICON);
+
+    shadowOf(userManager).addUser(10, PROFILE_USER_NAME, /* flags = */ 0);
+    shadowOf(userManager).switchUser(10);
+    assertThat(userManager.getUserIcon()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void isQuietModeEnabled_shouldReturnFalse() {
+    assertThat(userManager.isQuietModeEnabled(Process.myUserHandle())).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void isQuietModeEnabled_withProfile_shouldReturnFalse() {
+    shadowOf(userManager).addProfile(0, 10, "Work profile", UserInfo.FLAG_MANAGED_PROFILE);
+
+    assertThat(userManager.isQuietModeEnabled(new UserHandle(10))).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void requestQuietModeEnabled_withoutPermission_shouldThrowException() {
+    shadowOf(userManager).enforcePermissionChecks(true);
+
+    shadowOf(userManager).addProfile(0, 10, "Work profile", UserInfo.FLAG_MANAGED_PROFILE);
+
+    UserHandle workHandle = new UserHandle(10);
+    try {
+      userManager.requestQuietModeEnabled(true, workHandle);
+      fail("Expected SecurityException.");
+    } catch (SecurityException expected) {
+    }
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void requestQuietModeEnabled_withManagedProfile_shouldStopProfileAndEmitBroadcast() {
+    shadowOf(userManager).enforcePermissionChecks(true);
+    setPermissions(permission.MODIFY_QUIET_MODE);
+
+    UserHandle workHandle =
+        shadowOf(userManager).addUser(10, "Work profile", UserInfo.FLAG_MANAGED_PROFILE);
+    shadowOf(userManager).setUserState(workHandle, UserState.STATE_RUNNING_UNLOCKED);
+
+    final AtomicReference<String> receivedAction = new AtomicReference<>();
+    final AtomicReference<UserHandle> receivedHandle = new AtomicReference<>();
+
+    BroadcastReceiver receiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            receivedAction.set(intent.getAction());
+            receivedHandle.set(intent.getParcelableExtra(Intent.EXTRA_USER));
+          }
+        };
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
+    intentFilter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
+    context.registerReceiver(receiver, intentFilter);
+
+    assertThat(userManager.requestQuietModeEnabled(true, workHandle)).isTrue();
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(userManager.isQuietModeEnabled(workHandle)).isTrue();
+    assertThat(userManager.isUserRunning(workHandle)).isFalse();
+    assertThat(userManager.getUserInfo(10).flags & UserInfo.FLAG_QUIET_MODE)
+        .isEqualTo(UserInfo.FLAG_QUIET_MODE);
+    assertThat(receivedAction.get()).isEqualTo(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
+    assertThat(receivedHandle.get()).isEqualTo(workHandle);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void requestQuietModeDisabled_withManagedProfile_shouldStartProfileAndEmitBroadcast() {
+    shadowOf(userManager).enforcePermissionChecks(true);
+    setPermissions(permission.MODIFY_QUIET_MODE);
+
+    UserHandle workHandle =
+        shadowOf(userManager)
+            .addUser(10, "Work profile", UserInfo.FLAG_MANAGED_PROFILE | UserInfo.FLAG_QUIET_MODE);
+    shadowOf(userManager).setUserState(workHandle, UserState.STATE_SHUTDOWN);
+
+    final AtomicReference<String> receivedAction = new AtomicReference<>();
+    final AtomicReference<UserHandle> receivedHandle = new AtomicReference<>();
+
+    BroadcastReceiver receiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            receivedAction.set(intent.getAction());
+            receivedHandle.set(intent.getParcelableExtra(Intent.EXTRA_USER));
+          }
+        };
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
+    intentFilter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
+    context.registerReceiver(receiver, intentFilter);
+
+    assertThat(userManager.requestQuietModeEnabled(false, workHandle)).isTrue();
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(userManager.isQuietModeEnabled(workHandle)).isFalse();
+    assertThat(userManager.isUserRunning(workHandle)).isTrue();
+    assertThat(userManager.getUserInfo(10).flags & UserInfo.FLAG_QUIET_MODE).isEqualTo(0);
+    assertThat(receivedAction.get()).isEqualTo(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
+    assertThat(receivedHandle.get()).isEqualTo(workHandle);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void requestQuietModeDisabled_withLockedManagedProfile_shouldNotDoAnything() {
+    shadowOf(userManager).enforcePermissionChecks(true);
+    setPermissions(permission.MODIFY_QUIET_MODE);
+
+    UserHandle workHandle =
+        shadowOf(userManager)
+            .addUser(10, "Work profile", UserInfo.FLAG_MANAGED_PROFILE | UserInfo.FLAG_QUIET_MODE);
+
+    final AtomicReference<String> receivedAction = new AtomicReference<>();
+    final AtomicReference<UserHandle> receivedHandle = new AtomicReference<>();
+
+    BroadcastReceiver receiver =
+        new BroadcastReceiver() {
+          @Override
+          public void onReceive(Context context, Intent intent) {
+            receivedAction.set(intent.getAction());
+            receivedHandle.set(intent.getParcelableExtra(Intent.EXTRA_USER));
+          }
+        };
+    IntentFilter intentFilter = new IntentFilter();
+    intentFilter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE);
+    intentFilter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE);
+    context.registerReceiver(receiver, intentFilter);
+
+    shadowOf(userManager).setProfileIsLocked(workHandle, true);
+
+    assertThat(userManager.requestQuietModeEnabled(false, workHandle)).isFalse();
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(userManager.isQuietModeEnabled(workHandle)).isTrue();
+    assertThat(userManager.isUserRunning(workHandle)).isFalse();
+    assertThat(userManager.getUserInfo(10).flags & UserInfo.FLAG_QUIET_MODE)
+        .isEqualTo(UserInfo.FLAG_QUIET_MODE);
+    assertThat(receivedAction.get()).isNull();
+    assertThat(receivedHandle.get()).isNull();
+  }
+
+  // Create user handle from parcel since UserHandle.of() was only added in later APIs.
+  private static UserHandle newUserHandle(int uid) {
+    Parcel userParcel = Parcel.obtain();
+    userParcel.writeInt(uid);
+    userParcel.setDataPosition(0);
+    return new UserHandle(userParcel);
+  }
+
+  private static void setPermissions(String... permissions) {
+    Application context = ApplicationProvider.getApplicationContext();
+    PackageInfo packageInfo =
+        shadowOf(context.getPackageManager())
+            .getInternalMutablePackageInfo(context.getPackageName());
+    packageInfo.requestedPermissions = permissions;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUwbAdapterStateListenerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUwbAdapterStateListenerTest.java
new file mode 100644
index 0000000..e0c0cd9
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUwbAdapterStateListenerTest.java
@@ -0,0 +1,104 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.os.Build.VERSION_CODES;
+import android.uwb.AdapterStateListener;
+import android.uwb.IUwbAdapter;
+import android.uwb.UwbManager.AdapterStateCallback;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.util.concurrent.PausedExecutorService;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for {@link ShadowUwbAdapterStateListener}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.S)
+public class ShadowUwbAdapterStateListenerTest {
+  private Object /* AdapterStateListener */ adapterStateListenerObject;
+
+  @Before
+  public void setUp() {
+    IUwbAdapter mockUwbAdapter = mock(IUwbAdapter.class);
+    adapterStateListenerObject = new AdapterStateListener(mockUwbAdapter);
+  }
+
+  @Test
+  public void testSetEnabledTrue_isEnabled() {
+    AdapterStateListener adapterStateListener = (AdapterStateListener) adapterStateListenerObject;
+
+    adapterStateListener.setEnabled(true);
+    assertThat(adapterStateListener.getAdapterState())
+        .isEqualTo(AdapterStateCallback.STATE_ENABLED_INACTIVE);
+  }
+
+  @Test
+  public void testSetEnabledFalse_isDisabled() {
+    AdapterStateListener adapterStateListener = (AdapterStateListener) adapterStateListenerObject;
+
+    adapterStateListener.setEnabled(false);
+    assertThat(adapterStateListener.getAdapterState())
+        .isEqualTo(AdapterStateCallback.STATE_DISABLED);
+  }
+
+  @Test
+  public void testOnAdapterStateChanged_stateIsUpdated() {
+    AdapterStateListener adapterStateListener = (AdapterStateListener) adapterStateListenerObject;
+
+    adapterStateListener.onAdapterStateChanged(
+        AdapterStateCallback.STATE_ENABLED_ACTIVE,
+        AdapterStateCallback.STATE_CHANGED_REASON_SESSION_STARTED);
+    assertThat(adapterStateListener.getAdapterState())
+        .isEqualTo(AdapterStateCallback.STATE_ENABLED_ACTIVE);
+
+    adapterStateListener.onAdapterStateChanged(
+        AdapterStateCallback.STATE_ENABLED_INACTIVE,
+        AdapterStateCallback.STATE_CHANGED_REASON_ALL_SESSIONS_CLOSED);
+    assertThat(adapterStateListener.getAdapterState())
+        .isEqualTo(AdapterStateCallback.STATE_ENABLED_INACTIVE);
+
+    adapterStateListener.onAdapterStateChanged(
+        AdapterStateCallback.STATE_DISABLED,
+        AdapterStateCallback.STATE_CHANGED_REASON_ERROR_UNKNOWN);
+    assertThat(adapterStateListener.getAdapterState())
+        .isEqualTo(AdapterStateCallback.STATE_DISABLED);
+  }
+
+  @Test
+  public void testRegisterCallback() {
+    AdapterStateListener adapterStateListener = (AdapterStateListener) adapterStateListenerObject;
+    AdapterStateCallback mockAdapterStateCallback = mock(AdapterStateCallback.class);
+    PausedExecutorService executorService = new PausedExecutorService();
+
+    adapterStateListener.register(executorService, mockAdapterStateCallback);
+    executorService.runAll();
+
+    verify(mockAdapterStateCallback)
+        .onStateChanged(
+            adapterStateListener.getAdapterState(),
+            AdapterStateCallback.STATE_CHANGED_REASON_ERROR_UNKNOWN);
+
+    adapterStateListener.onAdapterStateChanged(
+        AdapterStateCallback.STATE_ENABLED_ACTIVE,
+        AdapterStateCallback.STATE_CHANGED_REASON_SESSION_STARTED);
+    executorService.runAll();
+
+    verify(mockAdapterStateCallback)
+        .onStateChanged(
+            AdapterStateCallback.STATE_ENABLED_ACTIVE,
+            AdapterStateCallback.STATE_CHANGED_REASON_SESSION_STARTED);
+
+    adapterStateListener.unregister(mockAdapterStateCallback);
+    adapterStateListener.onAdapterStateChanged(
+        AdapterStateCallback.STATE_ENABLED_INACTIVE,
+        AdapterStateCallback.STATE_CHANGED_REASON_ALL_SESSIONS_CLOSED);
+    executorService.runAll();
+
+    verifyNoMoreInteractions(mockAdapterStateCallback);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUwbManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUwbManagerTest.java
new file mode 100644
index 0000000..ecc5237
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUwbManagerTest.java
@@ -0,0 +1,98 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.os.PersistableBundle;
+import android.uwb.RangingSession;
+import android.uwb.UwbManager;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatcher;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Unit tests for {@link ShadowUwbManager}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = S)
+public class ShadowUwbManagerTest {
+  private /* RangingSession.Callback */ Object callbackObject;
+  private /* UwbManager */ Object uwbManagerObject;
+  private ShadowRangingSession.Adapter adapter;
+
+  @Before
+  public void setUp() {
+    callbackObject = mock(RangingSession.Callback.class);
+    adapter = mock(ShadowRangingSession.Adapter.class);
+    uwbManagerObject = getApplicationContext().getSystemService(UwbManager.class);
+  }
+
+  @Test
+  public void openRangingSession_openAdapter() {
+    UwbManager manager = (UwbManager) uwbManagerObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    Shadow.<ShadowUwbManager>extract(manager).setUwbAdapter(adapter);
+    manager.openRangingSession(genParams("openRangingSession"), directExecutor(), callback);
+    verify(adapter)
+        .onOpen(
+            any(RangingSession.class), eq(callback), argThat(checkParams("openRangingSession")));
+  }
+
+  @Test
+  public void getSpecificationInfo_expectedValue() {
+    UwbManager manager = (UwbManager) uwbManagerObject;
+    Shadow.<ShadowUwbManager>extract(manager)
+        .setSpecificationInfo(genParams("getSpecificationInfo"));
+    assertThat(getName(manager.getSpecificationInfo())).isEqualTo("getSpecificationInfo");
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void openRangingSessionWithChipId_openAdapter() {
+    UwbManager manager = (UwbManager) uwbManagerObject;
+    RangingSession.Callback callback = (RangingSession.Callback) callbackObject;
+    Shadow.<ShadowUwbManager>extract(manager).setUwbAdapter(adapter);
+    manager.openRangingSession(
+        genParams("openRangingSession"), directExecutor(), callback, "chipId");
+    verify(adapter)
+        .onOpen(
+            any(RangingSession.class), eq(callback), argThat(checkParams("openRangingSession")));
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getChipInfos_expectedValue() {
+    UwbManager manager = (UwbManager) uwbManagerObject;
+    Shadow.<ShadowUwbManager>extract(manager).setChipInfos(ImmutableList.of(genParams("chipInfo")));
+
+    List<PersistableBundle> chipInfos = manager.getChipInfos();
+    assertThat(chipInfos).hasSize(1);
+    assertThat(getName(chipInfos.get(0))).isEqualTo("chipInfo");
+  }
+
+  private static PersistableBundle genParams(String name) {
+    PersistableBundle params = new PersistableBundle();
+    params.putString("test", name);
+    return params;
+  }
+
+  private static String getName(PersistableBundle params) {
+    return params.getString("test");
+  }
+
+  private static ArgumentMatcher<PersistableBundle> checkParams(String name) {
+    return params -> getName(params).equals(name);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowValueAnimatorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowValueAnimatorTest.java
new file mode 100644
index 0000000..b6f7fe6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowValueAnimatorTest.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.animation.ValueAnimator;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.Ordering;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = JELLY_BEAN)
+public class ShadowValueAnimatorTest {
+
+  @Test
+  public void start_shouldRunAnimation() {
+    final List<Integer> values = new ArrayList<>();
+
+    final ValueAnimator animator = ValueAnimator.ofInt(0, 10);
+    animator.setDuration(1000);
+    animator.addUpdateListener(animation -> values.add((int) animation.getAnimatedValue()));
+    animator.start();
+
+    assertThat(values).isInOrder(Ordering.natural());
+  }
+
+  @Test
+  public void test_WithInfiniteRepeatCount_CountIsSetToOne() {
+    final ValueAnimator animator = ValueAnimator.ofInt(0, 10);
+    animator.setRepeatCount(ValueAnimator.INFINITE);
+
+    assertThat(Shadows.shadowOf(animator).getActualRepeatCount()).isEqualTo(ValueAnimator.INFINITE);
+    assertThat(animator.getRepeatCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void test_WhenInfiniteAnimationIsPlayed_AnimationIsOnlyPlayedOnce() {
+    final ValueAnimator animator = ValueAnimator.ofInt(0, 10);
+    animator.setDuration(200);
+    animator.setRepeatCount(ValueAnimator.INFINITE);
+
+    shadowMainLooper().pause();
+    animator.start();
+    assertThat(animator.isRunning()).isTrue();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(200));
+    assertThat(animator.isRunning()).isFalse();
+  }
+
+  @Test
+  public void animation_setPostFrameCallbackDelay() {
+    ShadowChoreographer.setPostFrameCallbackDelay(16);
+    ValueAnimator animator = ValueAnimator.ofInt(0, 10);
+    animator.setDuration(1000);
+    animator.setRepeatCount(0);
+    animator.start();
+    // without setPostFrameCallbackDelay this would finish the animation. Verify it doesn't, so
+    // tests can verify in progress animation state
+    shadowMainLooper().idleFor(Duration.ofMillis(16));
+    assertThat(animator.isRunning()).isTrue();
+    // advance 1000 frames - the duration of the animation
+    for (int i = 0; i < 999; i++) {
+      shadowMainLooper().idleFor(Duration.ofMillis(16));
+    }
+    assertThat(animator.isRunning()).isFalse();
+  }
+
+  @Test
+  public void setDurationScale_disablesDurations() {
+    ShadowValueAnimator.setDurationScale(0);
+    ValueAnimator animator = ValueAnimator.ofInt(0, 10);
+    animator.setDuration(Duration.ofDays(100).toMillis());
+    animator.setRepeatCount(0);
+    animator.start();
+    // this would time out without the duration scale being set to zero
+    shadowMainLooper().runToEndOfTasks();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVcnManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVcnManagerTest.java
new file mode 100644
index 0000000..bf59fd3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVcnManagerTest.java
@@ -0,0 +1,92 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.shadow.api.Shadow.extract;
+
+import android.net.vcn.VcnConfig;
+import android.net.vcn.VcnManager;
+import android.net.vcn.VcnManager.VcnStatusCallback;
+import android.os.ParcelUuid;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Executor;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowVcnManager}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = S)
+public final class ShadowVcnManagerTest {
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+  private ShadowVcnManager instance;
+  private final ParcelUuid subGroup = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000");
+  private final Executor executor = MoreExecutors.directExecutor();
+  @Mock private VcnStatusCallback callback;
+  private VcnConfig vcnConfig;
+
+  @Before
+  public void setUp() {
+    instance =
+        extract(ApplicationProvider.getApplicationContext().getSystemService(VcnManager.class));
+  }
+
+  @Test
+  public void registerVcnStatusCallback_callbackRegistered() {
+    instance.registerVcnStatusCallback(subGroup, executor, callback);
+
+    assertThat(instance.getRegisteredVcnStatusCallbacks()).contains(callback);
+    assertThat(instance.getRegisteredSubscriptionGroup(callback)).isEqualTo(subGroup);
+  }
+
+  @Test
+  public void setStatus_callbackOnStatusChanged() {
+    instance.registerVcnStatusCallback(subGroup, executor, callback);
+    instance.setStatus(VcnManager.VCN_STATUS_CODE_ACTIVE);
+
+    verify(callback).onStatusChanged(VcnManager.VCN_STATUS_CODE_ACTIVE);
+  }
+
+  @Test
+  public void unregisterVcnStatusCallback_callbackNotInSet() {
+    instance.registerVcnStatusCallback(subGroup, executor, callback);
+    instance.unregisterVcnStatusCallback(callback);
+
+    assertThat(instance.getRegisteredVcnStatusCallbacks()).doesNotContain(callback);
+  }
+
+  @Test
+  public void setGatewayConnectionError_firesCallback() {
+    String gatewayConnectionName = "gateway_connection";
+    int errorCode = VcnManager.VCN_ERROR_CODE_INTERNAL_ERROR;
+
+    instance.registerVcnStatusCallback(subGroup, executor, callback);
+    instance.setGatewayConnectionError(gatewayConnectionName, errorCode, null);
+
+    verify(callback).onGatewayConnectionError(gatewayConnectionName, errorCode, null);
+  }
+
+  @Test
+  public void setVcnConfig_configInSet() {
+    instance.setVcnConfig(subGroup, vcnConfig);
+
+    assertThat(instance.getConfiguredSubscriptionGroups()).containsExactly(subGroup);
+  }
+
+  @Test
+  public void clearVcnConfig_configNotInSet() {
+    instance.setVcnConfig(subGroup, vcnConfig);
+    instance.clearVcnConfig(subGroup);
+
+    assertThat(instance.getConfiguredSubscriptionGroups()).isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVibratorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVibratorTest.java
new file mode 100644
index 0000000..b8b5785
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVibratorTest.java
@@ -0,0 +1,257 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.VibrationEffect.EFFECT_CLICK;
+import static android.os.VibrationEffect.EFFECT_DOUBLE_CLICK;
+import static android.os.VibrationEffect.EFFECT_HEAVY_CLICK;
+import static android.os.VibrationEffect.EFFECT_TICK;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.os.vibrator.PrimitiveSegment;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.time.Duration;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowVibrator.PrimitiveEffect;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowVibratorTest {
+  private Vibrator vibrator;
+
+  @Before
+  public void before() {
+    vibrator =
+        (Vibrator)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE);
+  }
+
+  @Test
+  public void hasVibrator() {
+    assertThat(vibrator.hasVibrator()).isTrue();
+
+    shadowOf(vibrator).setHasVibrator(false);
+
+    assertThat(vibrator.hasVibrator()).isFalse();
+  }
+
+  @Config(minSdk = O)
+  @Test
+  public void hasAmplitudeControl() {
+    assertThat(vibrator.hasAmplitudeControl()).isFalse();
+
+    shadowOf(vibrator).setHasAmplitudeControl(true);
+
+    assertThat(vibrator.hasAmplitudeControl()).isTrue();
+  }
+
+  @Test
+  public void vibrateMilliseconds() {
+    vibrator.vibrate(5000);
+
+    assertThat(shadowOf(vibrator).isVibrating()).isTrue();
+    assertThat(shadowOf(vibrator).getMilliseconds()).isEqualTo(5000L);
+
+    shadowMainLooper().idleFor(Duration.ofSeconds(5));
+    assertThat(shadowOf(vibrator).isVibrating()).isFalse();
+  }
+
+  @Test
+  public void vibratePattern() {
+    long[] pattern = new long[] { 0, 200 };
+    vibrator.vibrate(pattern, 1);
+
+    assertThat(shadowOf(vibrator).isVibrating()).isTrue();
+    assertThat(shadowOf(vibrator).getPattern()).isEqualTo(pattern);
+    assertThat(shadowOf(vibrator).getRepeat()).isEqualTo(1);
+    assertThat(shadowOf(vibrator).getPrimitiveEffects()).isEmpty();
+  }
+
+  @Config(minSdk = Q)
+  @Test
+  public void vibratePredefined() {
+    vibrator.vibrate(VibrationEffect.createPredefined(EFFECT_CLICK));
+
+    assertThat(shadowOf(vibrator).getEffectId()).isEqualTo(EFFECT_CLICK);
+    assertThat(shadowOf(vibrator).getPrimitiveEffects()).isEmpty();
+  }
+
+  @Config(sdk = R)
+  @Test
+  public void getPrimitiveEffects_composeOnce_shouldReturnSamePrimitiveEffects() {
+    vibrator.vibrate(
+        VibrationEffect.startComposition()
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.5f, /* delay= */ 20)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.7f, /* delay= */ 50)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)
+            .compose());
+
+    assertThat(shadowOf(vibrator).getPrimitiveEffects())
+        .isEqualTo(
+            ImmutableList.of(
+                new PrimitiveEffect(EFFECT_CLICK, /* scale= */ 0.5f, /* delay= */ 20),
+                new PrimitiveEffect(EFFECT_CLICK, /* scale= */ 0.7f, /* delay= */ 50),
+                new PrimitiveEffect(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)));
+  }
+
+  @Config(sdk = R)
+  @Test
+  public void getPrimitiveEffects_composeTwice_shouldReturnTheLastComposition() {
+    vibrator.vibrate(
+        VibrationEffect.startComposition()
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.5f, /* delay= */ 20)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.7f, /* delay= */ 50)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)
+            .compose());
+    vibrator.vibrate(
+        VibrationEffect.startComposition()
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.4f, /* delay= */ 120)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 1f, /* delay= */ 2150)
+            .compose());
+
+    assertThat(shadowOf(vibrator).getPrimitiveEffects())
+        .isEqualTo(
+            ImmutableList.of(
+                new PrimitiveEffect(EFFECT_CLICK, /* scale= */ 0.4f, /* delay= */ 120),
+                new PrimitiveEffect(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150),
+                new PrimitiveEffect(EFFECT_CLICK, /* scale= */ 1f, /* delay= */ 2150)));
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void getVibrationEffectSegments_composeOnce_shouldReturnSameFragment() {
+    vibrator.vibrate(
+        VibrationEffect.startComposition()
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.5f, /* delay= */ 20)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.7f, /* delay= */ 50)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)
+            .compose());
+
+    assertThat(shadowOf(vibrator).getVibrationEffectSegments())
+        .isEqualTo(
+            ImmutableList.of(
+                new PrimitiveSegment(EFFECT_CLICK, /* scale= */ 0.5f, /* delay= */ 20),
+                new PrimitiveSegment(EFFECT_CLICK, /* scale= */ 0.7f, /* delay= */ 50),
+                new PrimitiveSegment(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)));
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void getVibrationEffectSegments_composeTwice_shouldReturnTheLastComposition() {
+    vibrator.vibrate(
+        VibrationEffect.startComposition()
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.5f, /* delay= */ 20)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.7f, /* delay= */ 50)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)
+            .compose());
+    vibrator.vibrate(
+        VibrationEffect.startComposition()
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.4f, /* delay= */ 120)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150)
+            .addPrimitive(EFFECT_CLICK, /* scale= */ 1f, /* delay= */ 2150)
+            .compose());
+
+    assertThat(shadowOf(vibrator).getVibrationEffectSegments())
+        .isEqualTo(
+            ImmutableList.of(
+                new PrimitiveSegment(EFFECT_CLICK, /* scale= */ 0.4f, /* delay= */ 120),
+                new PrimitiveSegment(EFFECT_CLICK, /* scale= */ 0.9f, /* delay= */ 150),
+                new PrimitiveSegment(EFFECT_CLICK, /* scale= */ 1f, /* delay= */ 2150)));
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void areAllPrimitivesSupported_oneSupportedPrimitive_shouldReturnTrue() {
+    shadowOf(vibrator)
+        .setSupportedPrimitives(ImmutableList.of(EFFECT_CLICK, EFFECT_TICK, EFFECT_HEAVY_CLICK));
+
+    assertThat(vibrator.areAllPrimitivesSupported(EFFECT_CLICK)).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void areAllPrimitivesSupported_twoSupportedPrimitives_shouldReturnTrue() {
+    shadowOf(vibrator)
+        .setSupportedPrimitives(ImmutableList.of(EFFECT_CLICK, EFFECT_TICK, EFFECT_HEAVY_CLICK));
+
+    assertThat(vibrator.areAllPrimitivesSupported(EFFECT_TICK, EFFECT_CLICK)).isTrue();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void areAllPrimitivesSupported_twoSupportedPrimitivesOneUnsupported_shouldReturnFalse() {
+    shadowOf(vibrator)
+        .setSupportedPrimitives(ImmutableList.of(EFFECT_CLICK, EFFECT_TICK, EFFECT_HEAVY_CLICK));
+
+    assertThat(vibrator.areAllPrimitivesSupported(EFFECT_TICK, EFFECT_CLICK, EFFECT_DOUBLE_CLICK))
+        .isFalse();
+  }
+
+  @Config(minSdk = R)
+  @Test
+  public void areAllPrimitivesSupported_oneUnsupportedPrimitivie_shouldReturnFalse() {
+    shadowOf(vibrator)
+        .setSupportedPrimitives(ImmutableList.of(EFFECT_CLICK, EFFECT_TICK, EFFECT_HEAVY_CLICK));
+
+    assertThat(vibrator.areAllPrimitivesSupported(EFFECT_DOUBLE_CLICK)).isFalse();
+  }
+
+  @Test
+  public void cancelled() {
+    vibrator.vibrate(5000);
+    assertThat(shadowOf(vibrator).isVibrating()).isTrue();
+    assertThat(shadowOf(vibrator).isCancelled()).isFalse();
+    vibrator.cancel();
+
+    assertThat(shadowOf(vibrator).isVibrating()).isFalse();
+    assertThat(shadowOf(vibrator).isCancelled()).isTrue();
+  }
+
+  @Config(minSdk = S)
+  @Test
+  public void vibratePattern_withVibrationAttributes() {
+    AudioAttributes audioAttributes =
+        new AudioAttributes.Builder()
+            .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST)
+            .setFlags(AudioAttributes.FLAG_BYPASS_INTERRUPTION_POLICY)
+            .build();
+
+    vibrator.vibrate(VibrationEffect.createPredefined(EFFECT_CLICK), audioAttributes);
+
+    assertThat(shadowOf(vibrator).getVibrationAttributesFromLastVibration())
+        .isEqualTo(
+            VibrationAttributesBuilder.newBuilder()
+                .setAudioAttributes(audioAttributes)
+                .setVibrationEffect(VibrationEffect.createPredefined(EFFECT_CLICK))
+                .build());
+  }
+
+  @Config(minSdk = O, maxSdk = R)
+  @Test
+  public void getAudioAttribues_vibrateWithAudioAttributes_shouldReturnAudioAttributes() {
+    AudioAttributes audioAttributes =
+        new AudioAttributes.Builder()
+            .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST)
+            .setFlags(AudioAttributes.FLAG_BYPASS_INTERRUPTION_POLICY)
+            .build();
+
+    vibrator.vibrate(/* delay= */ 200, audioAttributes);
+
+    AudioAttributes actualAudioAttriubes = shadowOf(vibrator).getAudioAttributesFromLastVibration();
+    assertThat(actualAudioAttriubes.getAllFlags()).isEqualTo(audioAttributes.getAllFlags());
+    assertThat(actualAudioAttriubes.getUsage()).isEqualTo(audioAttributes.getUsage());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVideoViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVideoViewTest.java
new file mode 100644
index 0000000..8e16fbc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVideoViewTest.java
@@ -0,0 +1,163 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.widget.VideoView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowVideoViewTest {
+
+  private VideoView view;
+
+  @Before public void setUp() throws Exception {
+    view = new VideoView(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void shouldSetOnPreparedListener() {
+    TestPreparedListener l = new TestPreparedListener();
+    view.setOnPreparedListener(l);
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getOnPreparedListener()).isSameInstanceAs(l);
+  }
+
+  @Test
+  public void shouldSetOnErrorListener() {
+    TestErrorListener l = new TestErrorListener();
+    view.setOnErrorListener(l);
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getOnErrorListener()).isSameInstanceAs(l);
+  }
+
+  @Test
+  public void shouldSetOnCompletionListener() {
+    TestCompletionListener l = new TestCompletionListener();
+    view.setOnCompletionListener(l);
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getOnCompletionListener()).isSameInstanceAs(l);
+  }
+
+  @Test
+  public void shouldSetVideoPath() {
+    view.setVideoPath("video.mp4");
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getVideoPath()).isEqualTo("video.mp4");
+    view.setVideoPath(null);
+    assertThat(shadowVideoView.getVideoPath()).isNull();
+  }
+
+  @Test
+  public void shouldSetVideoURI() {
+    view.setVideoURI(Uri.parse("video.mp4"));
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getVideoURIString()).isEqualTo("video.mp4");
+    view.setVideoURI(null);
+    assertThat(shadowVideoView.getVideoURIString()).isNull();
+  }
+
+  @Test
+  public void shouldSetVideoDuration() {
+    assertThat(view.getDuration()).isEqualTo(0);
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    shadowVideoView.setDuration(10);
+    assertThat(view.getDuration()).isEqualTo(10);
+  }
+
+  @Test
+  public void shouldDetermineIsPlaying() {
+    assertThat(view.isPlaying()).isFalse();
+    view.start();
+    assertThat(view.isPlaying()).isTrue();
+    view.stopPlayback();
+    assertThat(view.isPlaying()).isFalse();
+  }
+
+  @Test
+  public void shouldStartPlaying() {
+    view.start();
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getCurrentVideoState()).isEqualTo(ShadowVideoView.START);
+  }
+
+  @Test
+  public void shouldStopPlayback() {
+    view.stopPlayback();
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getCurrentVideoState()).isEqualTo(ShadowVideoView.STOP);
+  }
+
+  @Test
+  public void shouldSuspendPlaying() {
+    view.start();
+    view.suspend();
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getPrevVideoState()).isEqualTo(ShadowVideoView.START);
+    assertThat(shadowVideoView.getCurrentVideoState()).isEqualTo(ShadowVideoView.SUSPEND);
+  }
+
+  @Test
+  public void shouldResumePlaying() {
+    view.start();
+    view.suspend();
+    view.resume();
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getPrevVideoState()).isEqualTo(ShadowVideoView.SUSPEND);
+    assertThat(shadowVideoView.getCurrentVideoState()).isEqualTo(ShadowVideoView.RESUME);
+  }
+
+  @Test
+  public void shouldPausePlaying() {
+    view.start();
+    view.pause();
+    ShadowVideoView shadowVideoView = shadowOf(view);
+    assertThat(shadowVideoView.getPrevVideoState()).isEqualTo(ShadowVideoView.START);
+    assertThat(shadowVideoView.getCurrentVideoState()).isEqualTo(ShadowVideoView.PAUSE);
+  }
+
+  @Test
+  public void shouldDetermineIfPausable() {
+    view.start();
+    assertThat(view.canPause()).isTrue();
+
+    view.pause();
+    assertThat(view.canPause()).isFalse();
+
+    view.resume();
+    assertThat(view.canPause()).isTrue();
+
+    view.suspend();
+    assertThat(view.canPause()).isFalse();
+  }
+
+  @Test
+  public void shouldSeekToSpecifiedPosition() {
+    assertThat(view.getCurrentPosition()).isEqualTo(0);
+    view.seekTo(10000);
+    assertThat(view.getCurrentPosition()).isEqualTo(10000);
+  }
+
+  private static class TestPreparedListener implements MediaPlayer.OnPreparedListener {
+    @Override
+    public void onPrepared(MediaPlayer mp) {}
+  }
+
+  private static class TestErrorListener implements MediaPlayer.OnErrorListener  {
+    @Override
+    public boolean onError(MediaPlayer mp, int what, int extra) {
+      return false;
+    }
+  }
+
+  private static class TestCompletionListener implements MediaPlayer.OnCompletionListener {
+    @Override
+    public void onCompletion(MediaPlayer mp) {}
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowViewAnimatorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewAnimatorTest.java
new file mode 100644
index 0000000..5667a53
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewAnimatorTest.java
@@ -0,0 +1,77 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import android.app.Application;
+import android.view.View;
+import android.widget.ViewAnimator;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowViewAnimatorTest {
+
+  ViewAnimator viewAnimator;
+  final Application application = ApplicationProvider.getApplicationContext();
+
+  @Before
+  public void setUp() {
+    viewAnimator = new ViewAnimator(application);
+  }
+
+  @Test
+  public void getDisplayedChildWhenEmpty_shouldDefaultToZero() {
+    assertEquals(0, viewAnimator.getDisplayedChild());
+  }
+
+  @Test
+  public void getDisplayedChild_shouldDefaultToZero() {
+    viewAnimator.addView(new View(application));
+    assertEquals(0, viewAnimator.getDisplayedChild());
+  }
+
+  @Test
+  public void setDisplayedChild_shouldUpdateDisplayedChildIndex() {
+    viewAnimator.addView(new View(application));
+    viewAnimator.addView(new View(application));
+    viewAnimator.setDisplayedChild(2);
+    assertEquals(2, viewAnimator.getDisplayedChild());
+  }
+
+  @Test
+  public void getCurrentView_shouldWork() {
+    View view0 = new View(application);
+    View view1 = new View(application);
+    viewAnimator.addView(view0);
+    viewAnimator.addView(view1);
+    assertSame(view0, viewAnimator.getCurrentView());
+    viewAnimator.setDisplayedChild(1);
+    assertSame(view1, viewAnimator.getCurrentView());
+  }
+
+  @Test
+  public void showNext_shouldDisplayNextChild() {
+    viewAnimator.addView(new View(application));
+    viewAnimator.addView(new View(application));
+    assertEquals(0, viewAnimator.getDisplayedChild());
+    viewAnimator.showNext();
+    assertEquals(1, viewAnimator.getDisplayedChild());
+    viewAnimator.showNext();
+    assertEquals(0, viewAnimator.getDisplayedChild());
+  }
+
+  @Test
+  public void showPrevious_shouldDisplayPreviousChild() {
+    viewAnimator.addView(new View(application));
+    viewAnimator.addView(new View(application));
+    assertEquals(0, viewAnimator.getDisplayedChild());
+    viewAnimator.showPrevious();
+    assertEquals(1, viewAnimator.getDisplayedChild());
+    viewAnimator.showPrevious();
+    assertEquals(0, viewAnimator.getDisplayedChild());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowViewConfigurationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewConfigurationTest.java
new file mode 100644
index 0000000..41661af
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewConfigurationTest.java
@@ -0,0 +1,86 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.view.ViewConfiguration;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowViewConfigurationTest {
+
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void methodsShouldReturnAndroidConstants() {
+    ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+
+    assertEquals(10, ViewConfiguration.getScrollBarSize());
+    assertEquals(250, ViewConfiguration.getScrollBarFadeDuration());
+    assertEquals(300, ViewConfiguration.getScrollDefaultDelay());
+    assertEquals(12, ViewConfiguration.getFadingEdgeLength());
+    assertEquals(125, ViewConfiguration.getPressedStateDuration());
+    assertEquals(500, ViewConfiguration.getLongPressTimeout());
+    assertEquals(115, ViewConfiguration.getTapTimeout());
+    assertEquals(500, ViewConfiguration.getJumpTapTimeout());
+    assertEquals(300, ViewConfiguration.getDoubleTapTimeout());
+    assertEquals(12, ViewConfiguration.getEdgeSlop());
+    assertEquals(16, ViewConfiguration.getTouchSlop());
+    assertEquals(16, ViewConfiguration.getWindowTouchSlop());
+    assertEquals(50, ViewConfiguration.getMinimumFlingVelocity());
+    assertEquals(4000, ViewConfiguration.getMaximumFlingVelocity());
+    assertEquals(320 * 480 * 4, ViewConfiguration.getMaximumDrawingCacheSize());
+    assertEquals(3000, ViewConfiguration.getZoomControlsTimeout());
+    assertEquals(500, ViewConfiguration.getGlobalActionKeyTimeout());
+    assertThat(ViewConfiguration.getScrollFriction()).isEqualTo(0.015f);
+
+    assertThat(context.getResources().getDisplayMetrics().density).isEqualTo(1f);
+
+    assertEquals(10, viewConfiguration.getScaledScrollBarSize());
+    assertEquals(12, viewConfiguration.getScaledFadingEdgeLength());
+    assertEquals(12, viewConfiguration.getScaledEdgeSlop());
+    assertEquals(16, viewConfiguration.getScaledTouchSlop());
+    assertEquals(32, viewConfiguration.getScaledPagingTouchSlop());
+    assertEquals(100, viewConfiguration.getScaledDoubleTapSlop());
+    assertEquals(16, viewConfiguration.getScaledWindowTouchSlop());
+    assertEquals(50, viewConfiguration.getScaledMinimumFlingVelocity());
+    assertEquals(4000, viewConfiguration.getScaledMaximumFlingVelocity());
+  }
+
+  @Test
+  public void methodsShouldReturnScaledAndroidConstantsDependingOnPixelDensity() {
+    context.getResources().getDisplayMetrics().density = 1.5f;
+    ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+
+    assertEquals(15, viewConfiguration.getScaledScrollBarSize());
+    assertEquals(18, viewConfiguration.getScaledFadingEdgeLength());
+    assertEquals(18, viewConfiguration.getScaledEdgeSlop());
+    assertEquals(24, viewConfiguration.getScaledTouchSlop());
+    assertEquals(48, viewConfiguration.getScaledPagingTouchSlop());
+    assertEquals(150, viewConfiguration.getScaledDoubleTapSlop());
+    assertEquals(24, viewConfiguration.getScaledWindowTouchSlop());
+    assertEquals(75, viewConfiguration.getScaledMinimumFlingVelocity());
+    assertEquals(6000, viewConfiguration.getScaledMaximumFlingVelocity());
+  }
+
+  @Test
+  public void testHasPermanentMenuKey() {
+    ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
+    assertThat(viewConfiguration.hasPermanentMenuKey()).isTrue();
+
+    ShadowViewConfiguration shadowViewConfiguration = shadowOf(viewConfiguration);
+    shadowViewConfiguration.setHasPermanentMenuKey(false);
+    assertThat(viewConfiguration.hasPermanentMenuKey()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowViewFlipperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewFlipperTest.java
new file mode 100644
index 0000000..b68efc6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewFlipperTest.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.widget.ViewFlipper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowViewFlipperTest {
+  protected ViewFlipper flipper;
+
+  @Before
+  public void setUp() {
+    flipper = new ViewFlipper(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void testStartFlipping() {
+    flipper.startFlipping();
+    assertTrue("flipping", flipper.isFlipping());
+  }
+
+  @Test
+  public void testStopFlipping() {
+    flipper.stopFlipping();
+    assertFalse("flipping", flipper.isFlipping());
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowViewGroupTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewGroupTest.java
new file mode 100644
index 0000000..488b55e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewGroupTest.java
@@ -0,0 +1,501 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.idleMainLooper;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.LayoutAnimationController;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowViewGroupTest {
+  private String defaultLineSeparator;
+  private ViewGroup root;
+  private View child1;
+  private View child2;
+  private ViewGroup child3;
+  private View child3a;
+  private View child3b;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+
+    root = new FrameLayout(context);
+
+    child1 = new View(context);
+    child2 = new View(context);
+    child3 = new FrameLayout(context);
+    child3a = new View(context);
+    child3b = new View(context);
+
+    root.addView(child1);
+    root.addView(child2);
+    root.addView(child3);
+
+    child3.addView(child3a);
+    child3.addView(child3b);
+
+    defaultLineSeparator = System.getProperty("line.separator");
+    System.setProperty("line.separator", "\n");
+  }
+
+  @After
+  public void tearDown() {
+    System.setProperty("line.separator", defaultLineSeparator);
+  }
+
+  @Test
+  public void removeNullView_doesNothing() {
+    root.removeView(null);
+  }
+
+  @Test
+  public void testLayoutAnimationListener() {
+    assertThat(root.getLayoutAnimationListener()).isNull();
+
+    AnimationListener animationListener = new AnimationListener() {
+      @Override
+      public void onAnimationEnd(Animation a) {
+      }
+
+      @Override
+      public void onAnimationRepeat(Animation a) {
+      }
+
+      @Override
+      public void onAnimationStart(Animation a) {
+      }
+    };
+    root.setLayoutAnimationListener(animationListener);
+
+    assertThat(root.getLayoutAnimationListener()).isSameInstanceAs(animationListener);
+  }
+
+  @Test
+  public void testLayoutAnimation() {
+    assertThat(root.getLayoutAnimation()).isNull();
+    LayoutAnimationController layoutAnim = new LayoutAnimationController(context, null);
+    root.setLayoutAnimation(layoutAnim);
+    assertThat(root.getLayoutAnimation()).isSameInstanceAs(layoutAnim);
+  }
+
+  @Test
+  public void testRemoveChildAt() {
+    root.removeViewAt(1);
+
+    assertThat(root.getChildCount()).isEqualTo(2);
+    assertThat(root.getChildAt(0)).isSameInstanceAs(child1);
+    assertThat(root.getChildAt(1)).isSameInstanceAs(child3);
+
+    assertThat(child2.getParent()).isNull();
+  }
+
+  @Test
+  public void testAddViewAt() {
+    root.removeAllViews();
+    root.addView(child1);
+    root.addView(child2);
+    root.addView(child3, 1);
+    assertThat(root.getChildAt(0)).isSameInstanceAs(child1);
+    assertThat(root.getChildAt(1)).isSameInstanceAs(child3);
+    assertThat(root.getChildAt(2)).isSameInstanceAs(child2);
+  }
+
+  @Test
+  public void shouldFindViewWithTag() {
+    root.removeAllViews();
+    child1.setTag("tag1");
+    child2.setTag("tag2");
+    child3.setTag("tag3");
+    root.addView(child1);
+    root.addView(child2);
+    root.addView(child3, 1);
+    assertThat((View) root.findViewWithTag("tag1")).isSameInstanceAs(child1);
+    assertThat((View) root.findViewWithTag("tag2")).isSameInstanceAs(child2);
+    assertThat((ViewGroup) root.findViewWithTag("tag3")).isSameInstanceAs(child3);
+  }
+
+  @Test
+  public void shouldNotFindViewWithTagReturnNull() {
+    root.removeAllViews();
+    child1.setTag("tag1");
+    child2.setTag("tag2");
+    child3.setTag("tag3");
+    root.addView(child1);
+    root.addView(child2);
+    root.addView(child3, 1);
+    assertThat((View) root.findViewWithTag("tag21")).isNull();
+    assertThat((ViewGroup) root.findViewWithTag("tag23")).isNull();
+  }
+
+  @Test
+  public void shouldfindViewWithTagFromCorrectViewGroup() {
+    root.removeAllViews();
+    child1.setTag("tag1");
+    child2.setTag("tag2");
+    child3.setTag("tag3");
+    root.addView(child1);
+    root.addView(child2);
+    root.addView(child3);
+
+    child3a.setTag("tag1");
+    child3b.setTag("tag2");
+
+    // can find views by tag from root
+    assertThat((View) root.findViewWithTag("tag1")).isSameInstanceAs(child1);
+    assertThat((View) root.findViewWithTag("tag2")).isSameInstanceAs(child2);
+    assertThat((ViewGroup) root.findViewWithTag("tag3")).isSameInstanceAs(child3);
+
+    // can find views by tag from child3
+    assertThat((View) child3.findViewWithTag("tag1")).isSameInstanceAs(child3a);
+    assertThat((View) child3.findViewWithTag("tag2")).isSameInstanceAs(child3b);
+  }
+
+  @Test
+  public void hasFocus_shouldReturnTrueIfAnyChildHasFocus() {
+    makeFocusable(root, child1, child2, child3, child3a, child3b);
+    assertFalse(root.hasFocus());
+
+    child1.requestFocus();
+    assertTrue(root.hasFocus());
+
+    child1.clearFocus();
+    assertFalse(child1.hasFocus());
+    assertTrue(root.hasFocus());
+
+    child3b.requestFocus();
+    assertTrue(root.hasFocus());
+
+    child3b.clearFocus();
+    assertFalse(child3b.hasFocus());
+    assertFalse(child3.hasFocus());
+    assertTrue(root.hasFocus());
+
+    child2.requestFocus();
+    assertFalse(child3.hasFocus());
+    assertTrue(child2.hasFocus());
+    assertTrue(root.hasFocus());
+
+    root.requestFocus();
+    assertTrue(root.hasFocus());
+  }
+
+  @Test
+  public void clearFocus_shouldRecursivelyClearTheFocusOfAllChildren() {
+    child3a.requestFocus();
+
+    root.clearFocus();
+
+    assertFalse(child3a.hasFocus());
+    assertFalse(child3.hasFocus());
+    assertFalse(root.hasFocus());
+
+    root.requestFocus();
+    root.clearFocus();
+    assertFalse(root.hasFocus());
+  }
+
+  @Test
+  public void dump_shouldDumpStructure() {
+    child3.setId(R.id.snippet_text);
+    child3b.setVisibility(View.GONE);
+    TextView textView = new TextView(context);
+    textView.setText("Here's some text!");
+    textView.setVisibility(View.INVISIBLE);
+    child3.addView(textView);
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    shadowOf(root).dump(new PrintStream(out), 0);
+    String expected = "<FrameLayout>\n" +
+        "  <View/>\n" +
+        "  <View/>\n" +
+        "  <FrameLayout id=\"org.robolectric:id/snippet_text\">\n" +
+        "    <View/>\n" +
+        "    <View visibility=\"GONE\"/>\n" +
+        "    <TextView visibility=\"INVISIBLE\" text=\"Here&#39;s some text!\"/>\n" +
+        "  </FrameLayout>\n" +
+        "</FrameLayout>\n";
+    assertEquals(expected.replaceAll("\n", System.lineSeparator()), out.toString());
+  }
+
+  @Test
+  public void addViewWithLayoutParams_shouldStoreLayoutParams() {
+    FrameLayout.LayoutParams layoutParams1 = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+    FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+    View child1 = new View(ApplicationProvider.getApplicationContext());
+    View child2 = new View(ApplicationProvider.getApplicationContext());
+    root.addView(child1, layoutParams1);
+    root.addView(child2, 1, layoutParams2);
+    assertSame(layoutParams1, child1.getLayoutParams());
+    assertSame(layoutParams2, child2.getLayoutParams());
+  }
+
+//  todo: re-enable this
+//  @Test @Config(minSdk = FROYO)
+//  public void getChildAt_shouldThrowIndexOutOfBoundsForInvalidIndices() { // 'cause that's what Android does
+//    assertThat(root.getChildCount()).isEqualTo(3);
+//    assertThrowsExceptionForBadIndex(13);
+//    assertThrowsExceptionForBadIndex(3);
+//    assertThrowsExceptionForBadIndex(-1);
+//  }
+//
+//  private void assertThrowsExceptionForBadIndex(int index) {
+//    try {
+//      assertThat(root.getChildAt(index)).isNull();
+//      fail("no exception");
+//    } catch (IndexOutOfBoundsException ex) {
+//      //noinspection UnnecessaryReturnStatement
+//      return;
+//    } catch (Exception ex) {
+//      fail("wrong exception type");
+//    }
+//  }
+
+  @Test
+  public void layoutParams_shouldBeViewGroupLayoutParams() {
+    assertThat(child1.getLayoutParams()).isInstanceOf(FrameLayout.LayoutParams.class);
+    assertThat(child1.getLayoutParams()).isInstanceOf(ViewGroup.LayoutParams.class);
+  }
+
+  @Test
+  public void removeView_removesView() {
+    assertThat(root.getChildCount()).isEqualTo(3);
+    root.removeView(child1);
+    assertThat(root.getChildCount()).isEqualTo(2);
+    assertThat(root.getChildAt(0)).isSameInstanceAs(child2);
+    assertThat(root.getChildAt(1)).isSameInstanceAs(child3);
+    assertThat(child1.getParent()).isNull();
+  }
+
+  @Test
+  public void removeView_resetsParentOnlyIfViewIsInViewGroup() {
+    assertThat(root.getChildCount()).isEqualTo(3);
+    assertNotSame(child3a.getParent(), root);
+    root.removeView(child3a);
+    assertThat(root.getChildCount()).isEqualTo(3);
+    assertThat(child3a.getParent()).isSameInstanceAs(child3);
+  }
+
+  @Test
+  public void addView_whenChildAlreadyHasAParent_shouldThrow() {
+    ViewGroup newRoot = new FrameLayout(context);
+    try {
+      newRoot.addView(child1);
+      fail("Expected IllegalStateException");
+    } catch (IllegalStateException e) {
+      // pass
+    }
+  }
+
+  @Test
+  public void shouldKnowWhenOnInterceptTouchEventWasCalled() {
+    ViewGroup viewGroup = new FrameLayout(context);
+
+    MotionEvent touchEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
+    viewGroup.onInterceptTouchEvent(touchEvent);
+
+    assertThat(shadowOf(viewGroup).getInterceptedTouchEvent()).isEqualTo(touchEvent);
+  }
+
+  @Test
+  public void removeView_shouldRequestLayout() {
+    View view = new View(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view);
+    shadowOf(viewGroup).setDidRequestLayout(false);
+
+    viewGroup.removeView(view);
+    assertThat(shadowOf(viewGroup).didRequestLayout()).isTrue();
+  }
+
+  @Test
+  public void removeViewAt_shouldRequestLayout() {
+    View view = new View(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view);
+    shadowOf(viewGroup).setDidRequestLayout(false);
+
+    viewGroup.removeViewAt(0);
+    assertThat(shadowOf(viewGroup).didRequestLayout()).isTrue();
+  }
+
+  @Test
+  public void removeAllViews_shouldRequestLayout() {
+    View view = new View(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view);
+    shadowOf(viewGroup).setDidRequestLayout(false);
+
+    viewGroup.removeAllViews();
+    assertThat(shadowOf(viewGroup).didRequestLayout()).isTrue();
+  }
+
+  @Test
+  public void addView_shouldRequestLayout() {
+    View view = new View(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view);
+
+    assertThat(shadowOf(viewGroup).didRequestLayout()).isTrue();
+  }
+
+  @Test
+  public void addView_withIndex_shouldRequestLayout() {
+    View view = new View(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view, 0);
+
+    assertThat(shadowOf(viewGroup).didRequestLayout()).isTrue();
+  }
+
+  @Test
+  public void removeAllViews_shouldCallOnChildViewRemovedWithEachChild() {
+    View view = new View(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view);
+
+    TestOnHierarchyChangeListener testListener = new TestOnHierarchyChangeListener();
+
+    viewGroup.setOnHierarchyChangeListener(testListener);
+    viewGroup.removeAllViews();
+    assertTrue(testListener.wasCalled());
+  }
+
+  @Test
+  public void requestDisallowInterceptTouchEvent_storedOnShadow() {
+    child3.requestDisallowInterceptTouchEvent(true);
+
+    assertTrue(shadowOf(child3).getDisallowInterceptTouchEvent());
+  }
+
+  @Test
+  public void requestDisallowInterceptTouchEvent_bubblesUp() {
+    child3.requestDisallowInterceptTouchEvent(true);
+
+    assertTrue(shadowOf(child3).getDisallowInterceptTouchEvent());
+    assertTrue(shadowOf(root).getDisallowInterceptTouchEvent());
+  }
+
+  @Test
+  public void requestDisallowInterceptTouchEvent_isReflected() {
+    // Set up an Activity to accurately dispatch touch events.
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    activity.setContentView(root);
+    idleMainLooper();
+    // Set a no-op click listener so we collect all the touch events.
+    child3a.setOnClickListener(view -> {});
+    // Request our parent not intercept our touch events.
+    // This must be _during the initial down MotionEvent_ and not before.
+    // The down event will reset this state (and so we do not need to reset it).
+    // The value in getDisallowInterceptTouchEvent() is not in-sync with the flag and
+    // only records the last call to requestDisallowInterceptTouchEvent().
+    child3a.setOnTouchListener(
+        (view, event) -> {
+          if (event.getAction() == MotionEvent.ACTION_DOWN) {
+            view.getParent().requestDisallowInterceptTouchEvent(true);
+          }
+          return false;
+        });
+
+    MotionEvent downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
+    MotionEvent moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0, 0, 0);
+    MotionEvent upEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0, 0, 0);
+
+    root.dispatchTouchEvent(downEvent);
+    // Down event is _always_ intercepted by the root.
+    assertTrue(shadowOf(root).getDisallowInterceptTouchEvent());
+    assertSame(shadowOf(root).getInterceptedTouchEvent(), downEvent);
+    assertSame(shadowOf(child3a).getLastTouchEvent(), downEvent);
+
+    root.dispatchTouchEvent(moveEvent);
+    // Subsequent event types are _not_ intercepted:
+    assertTrue(shadowOf(root).getDisallowInterceptTouchEvent());
+    assertSame(shadowOf(root).getInterceptedTouchEvent(), downEvent);
+    assertSame(shadowOf(child3a).getLastTouchEvent(), moveEvent);
+
+    root.dispatchTouchEvent(upEvent);
+    // Subsequent event types are _not_ intercepted:
+    assertTrue(shadowOf(root).getDisallowInterceptTouchEvent());
+    assertSame(shadowOf(root).getInterceptedTouchEvent(), downEvent);
+    assertSame(shadowOf(child3a).getLastTouchEvent(), upEvent);
+  }
+
+  @Test
+  public void draw_drawsChildren() {
+    DrawRecordView view = new DrawRecordView(context);
+    ViewGroup viewGroup = new FrameLayout(context);
+    viewGroup.addView(view);
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Config.ARGB_8888);
+    Canvas canvas = new Canvas(bitmap);
+    viewGroup.draw(canvas);
+    assertThat(view.wasDrawn).isTrue();
+  }
+
+  private void makeFocusable(View... views) {
+    for (View view : views) {
+      view.setFocusable(true);
+    }
+  }
+
+  static class TestOnHierarchyChangeListener implements ViewGroup.OnHierarchyChangeListener {
+    boolean wasCalled = false;
+
+    @Override
+    public void onChildViewAdded(View parent, View child) {
+    }
+
+    @Override
+    public void onChildViewRemoved(View parent, View child) {
+      wasCalled = true;
+    }
+
+    public boolean wasCalled() {
+      return wasCalled;
+    }
+  }
+
+  static class DrawRecordView extends View {
+
+    boolean wasDrawn;
+
+    public DrawRecordView(Context context) {
+      super(context);
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+      super.draw(canvas);
+      wasDrawn = true;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewTest.java
new file mode 100644
index 0000000..432eec6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowViewTest.java
@@ -0,0 +1,1176 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Robolectric.setupActivity;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.Context;
+import android.graphics.BitmapFactory;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.view.ContextMenu;
+import android.view.HapticFeedbackConstants;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.View.OnClickListener;
+import android.view.View.OnLongClickListener;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.WindowId;
+import android.view.WindowManager;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.android.DeviceConfig;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.TestRunnable;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowViewTest {
+  private View view;
+  private List<String> transcript;
+  private Application context;
+
+  @Before
+  public void setUp() throws Exception {
+    InstrumentationRegistry.getInstrumentation().setInTouchMode(false);
+    transcript = new ArrayList<>();
+    context = ApplicationProvider.getApplicationContext();
+    view = Robolectric.setupActivity(ContainerActivity.class).getView();
+  }
+
+  public static class ContainerActivity extends Activity {
+
+    private TextView view;
+
+    @Override
+    protected void onResume() {
+      super.onResume();
+      LinearLayout parent = new LinearLayout(this);
+
+      // decoy to receive default focus
+      TextView otherTextView = new TextView(this);
+      otherTextView.setFocusable(true);
+      parent.addView(otherTextView);
+
+      view = new TextView(this);
+      view.setId(R.id.action_search);
+      parent.addView(view);
+      setContentView(parent);
+    }
+
+    public View getView() {
+      return view;
+    }
+  }
+
+  @Test
+  public void layout_shouldAffectWidthAndHeight() throws Exception {
+    view.layout(100, 200, 303, 404);
+    assertThat(view.getWidth()).isEqualTo(303 - 100);
+    assertThat(view.getHeight()).isEqualTo(404 - 200);
+  }
+
+  @Test
+  public void measuredDimensions() throws Exception {
+    View view1 =
+        new View(context) {
+          {
+            setMeasuredDimension(123, 456);
+          }
+        };
+    assertThat(view1.getMeasuredWidth()).isEqualTo(123);
+    assertThat(view1.getMeasuredHeight()).isEqualTo(456);
+  }
+
+  @Test
+  public void layout_shouldCallOnLayoutOnlyIfChanged() throws Exception {
+    View view1 =
+        new View(context) {
+          @Override
+          protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+            transcript.add(
+                "onLayout " + changed + " " + left + " " + top + " " + right + " " + bottom);
+          }
+        };
+    view1.layout(0, 0, 0, 0);
+    assertThat(transcript).isEmpty();
+    view1.layout(1, 2, 3, 4);
+    assertThat(transcript).containsExactly("onLayout true 1 2 3 4");
+    transcript.clear();
+    view1.layout(1, 2, 3, 4);
+    assertThat(transcript).isEmpty();
+  }
+
+  @Test
+  public void shouldFocus() throws Exception {
+    final List<String> transcript = new ArrayList<>();
+
+    view.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+      @Override
+      public void onFocusChange(View v, boolean hasFocus) {
+        transcript.add(hasFocus ? "Gained focus" : "Lost focus");
+      }
+    });
+
+    assertFalse(view.isFocused());
+    assertFalse(view.hasFocus());
+    assertThat(transcript).isEmpty();
+
+    view.requestFocus();
+    assertFalse(view.isFocused());
+    assertFalse(view.hasFocus());
+    assertThat(transcript).isEmpty();
+
+    view.setFocusable(true);
+    shadowMainLooper().idle();
+    view.requestFocus();
+    assertTrue(view.isFocused());
+    assertTrue(view.hasFocus());
+    assertThat(transcript).containsExactly("Gained focus");
+    transcript.clear();
+
+    // take it
+    view.clearFocus();
+    shadowMainLooper().idle();
+    assertFalse(view.isFocused());
+    assertFalse(view.hasFocus());
+    assertThat(transcript).containsExactly("Lost focus");
+  }
+
+  @Test
+  public void shouldNotBeFocusableByDefault() throws Exception {
+    assertFalse(view.isFocusable());
+
+    view.setFocusable(true);
+    assertTrue(view.isFocusable());
+  }
+
+  @Test
+  public void shouldKnowIfThisOrAncestorsAreVisible() throws Exception {
+    assertThat(view.isShown()).isTrue();
+    shadowOf(view).setMyParent(null);
+
+    ViewGroup parent = new LinearLayout(context);
+    parent.addView(view);
+
+    ViewGroup grandParent = new LinearLayout(context);
+    grandParent.addView(parent);
+
+    grandParent.setVisibility(View.GONE);
+
+    assertFalse(view.isShown());
+  }
+
+  @Test
+  public void shouldInflateMergeRootedLayoutAndNotCreateReferentialLoops() throws Exception {
+    LinearLayout root = new LinearLayout(context);
+    LinearLayout.inflate(context, R.layout.inner_merge, root);
+    for (int i = 0; i < root.getChildCount(); i++) {
+      View child = root.getChildAt(i);
+      assertNotSame(root, child);
+    }
+  }
+
+  @Test
+  public void performLongClick_shouldClickOnView() throws Exception {
+    OnLongClickListener clickListener = mock(OnLongClickListener.class);
+    view.setOnLongClickListener(clickListener);
+    view.performLongClick();
+
+    verify(clickListener).onLongClick(view);
+  }
+
+  @Test
+  public void checkedClick_shouldClickOnView() throws Exception {
+    OnClickListener clickListener = mock(OnClickListener.class);
+    view.setOnClickListener(clickListener);
+    shadowOf(view).checkedPerformClick();
+
+    verify(clickListener).onClick(view);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void checkedClick_shouldThrowIfViewIsNotVisible() throws Exception {
+    ViewGroup grandParent = new LinearLayout(context);
+    ViewGroup parent = new LinearLayout(context);
+    grandParent.addView(parent);
+    parent.addView(view);
+    grandParent.setVisibility(View.GONE);
+
+    shadowOf(view).checkedPerformClick();
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void checkedClick_shouldThrowIfViewIsDisabled() throws Exception {
+    view.setEnabled(false);
+    shadowOf(view).checkedPerformClick();
+  }
+
+  @Test
+  public void getBackground_shouldReturnNullIfNoBackgroundHasBeenSet() throws Exception {
+    assertThat(view.getBackground()).isNull();
+  }
+
+  @Test
+  public void shouldSetBackgroundColor() {
+    int red = 0xffff0000;
+    view.setBackgroundColor(red);
+    ColorDrawable background = (ColorDrawable) view.getBackground();
+    assertThat(background.getColor()).isEqualTo(red);
+  }
+
+  @Test
+  public void shouldSetBackgroundResource() throws Exception {
+    view.setBackgroundResource(R.drawable.an_image);
+    assertThat(shadowOf((BitmapDrawable) view.getBackground()).getCreatedFromResId())
+        .isEqualTo(R.drawable.an_image);
+  }
+
+  @Test
+  public void shouldClearBackgroundResource() throws Exception {
+    view.setBackgroundResource(R.drawable.an_image);
+    view.setBackgroundResource(0);
+    assertThat(view.getBackground()).isEqualTo(null);
+  }
+
+  @Test
+  public void shouldRecordBackgroundColor() {
+    int[] colors = {R.color.black, R.color.clear, R.color.white};
+
+    for (int color : colors) {
+      view.setBackgroundColor(color);
+      ColorDrawable drawable = (ColorDrawable) view.getBackground();
+      assertThat(drawable.getColor()).isEqualTo(color);
+    }
+  }
+
+  @Test
+  public void shouldRecordBackgroundDrawable() {
+    Drawable drawable = new BitmapDrawable(BitmapFactory.decodeFile("some/fake/file"));
+    view.setBackgroundDrawable(drawable);
+    assertThat(view.getBackground()).isSameInstanceAs(drawable);
+    assertThat(ShadowView.visualize(view)).isEqualTo("background:\nBitmap for file:some/fake/file");
+  }
+
+  @Test
+  public void shouldPostActionsToTheMessageQueue() throws Exception {
+    shadowMainLooper().pause();
+
+    TestRunnable runnable = new TestRunnable();
+    assertThat(view.post(runnable)).isTrue();
+    assertFalse(runnable.wasRun);
+
+    shadowMainLooper().idle();
+    assertTrue(runnable.wasRun);
+  }
+
+  @Test
+  public void shouldPostInvalidateDelayed() throws Exception {
+    shadowMainLooper().pause();
+    ShadowView shadowView = shadowOf(view);
+    shadowView.clearWasInvalidated();
+    assertFalse(shadowView.wasInvalidated());
+
+    view.postInvalidateDelayed(1);
+    assertFalse(shadowView.wasInvalidated());
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    assertTrue(shadowView.wasInvalidated());
+  }
+
+  @Test
+  public void shouldPostActionsToTheMessageQueueWithDelay() throws Exception {
+    shadowMainLooper().pause();
+
+    TestRunnable runnable = new TestRunnable();
+    view.postDelayed(runnable, 1);
+    assertFalse(runnable.wasRun);
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    assertTrue(runnable.wasRun);
+  }
+
+  @Test
+  public void shouldRemovePostedCallbacksFromMessageQueue() throws Exception {
+    TestRunnable runnable = new TestRunnable();
+    assertThat(view.postDelayed(runnable, 1)).isTrue();
+
+    assertThat(view.removeCallbacks(runnable)).isTrue();
+
+    shadowMainLooper().idleFor(Duration.ofMillis(1));
+    assertThat(runnable.wasRun).isFalse();
+  }
+
+  @Test
+  public void shouldSupportAllConstructors() throws Exception {
+    new View(context);
+    new View(context, null);
+    new View(context, null, 0);
+  }
+
+  @Test
+  public void shouldRememberIsPressed() {
+    view.setPressed(true);
+    assertTrue(view.isPressed());
+    view.setPressed(false);
+    assertFalse(view.isPressed());
+  }
+
+  @Test
+  public void shouldAddOnClickListenerFromAttribute() throws Exception {
+    AttributeSet attrs = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.onClick, "clickMe")
+        .build()
+        ;
+
+    view = new View(context, attrs);
+    assertNotNull(shadowOf(view).getOnClickListener());
+  }
+
+  @Test
+  public void shouldCallOnClickWithAttribute() throws Exception {
+    MyActivity myActivity = buildActivity(MyActivity.class).create().get();
+
+    AttributeSet attrs = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.onClick, "clickMe")
+        .build();
+
+    view = new View(myActivity, attrs);
+    view.performClick();
+    assertTrue("Should have been called", myActivity.called);
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void shouldThrowExceptionWithBadMethodName() throws Exception {
+    MyActivity myActivity = buildActivity(MyActivity.class).create().get();
+
+    AttributeSet attrs = Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.onClick, "clickYou")
+        .build();
+
+    view = new View(myActivity, attrs);
+    view.performClick();
+  }
+
+  @Test
+  public void shouldSetAnimation() throws Exception {
+    Animation anim = new TestAnimation();
+    view.setAnimation(anim);
+    assertThat(view.getAnimation()).isSameInstanceAs(anim);
+  }
+
+  @Test
+  public void clearAnimation_cancelsAnimation() throws Exception {
+    AtomicInteger numTicks = new AtomicInteger();
+    final Animation anim =
+        new Animation() {
+          @Override
+          protected void applyTransformation(float interpolatedTime, Transformation t) {
+            super.applyTransformation(interpolatedTime, t);
+            numTicks.incrementAndGet();
+          }
+        };
+    anim.setDuration(Duration.ofSeconds(1).toMillis());
+    view.setAnimation(anim);
+    view.clearAnimation();
+    shadowOf(Looper.getMainLooper()).runToEndOfTasks();
+    assertThat(numTicks.get()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldFindViewWithTag() {
+    view.setTag("tagged");
+    assertThat((View) view.findViewWithTag("tagged")).isSameInstanceAs(view);
+  }
+
+  @Test
+  public void scrollTo_shouldStoreTheScrolledCoordinates() throws Exception {
+    view.scrollTo(1, 2);
+    assertThat(shadowOf(view).scrollToCoordinates).isEqualTo(new Point(1, 2));
+  }
+
+  @Test
+  public void shouldScrollTo() throws Exception {
+    view.scrollTo(7, 6);
+
+    assertEquals(7, view.getScrollX());
+    assertEquals(6, view.getScrollY());
+  }
+
+  @Test
+  public void scrollBy_shouldStoreTheScrolledCoordinates() throws Exception {
+    view.scrollTo(4, 5);
+    view.scrollBy(10, 20);
+    assertThat(shadowOf(view).scrollToCoordinates).isEqualTo(new Point(14, 25));
+
+    assertThat(view.getScrollX()).isEqualTo(14);
+    assertThat(view.getScrollY()).isEqualTo(25);
+  }
+
+  @Test
+  public void shouldGetScrollXAndY() {
+    assertEquals(0, view.getScrollX());
+    assertEquals(0, view.getScrollY());
+  }
+
+  @Test
+  public void getViewTreeObserver_shouldReturnTheSameObserverFromMultipleCalls() throws Exception {
+    ViewTreeObserver observer = view.getViewTreeObserver();
+    assertThat(observer).isInstanceOf(ViewTreeObserver.class);
+    assertThat(view.getViewTreeObserver()).isSameInstanceAs(observer);
+  }
+
+  @Test
+  public void dispatchTouchEvent_sendsMotionEventToOnTouchEvent() throws Exception {
+    TouchableView touchableView = new TouchableView(context);
+    MotionEvent event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 12f, 34f, 0);
+    touchableView.dispatchTouchEvent(event);
+    assertThat(touchableView.event).isSameInstanceAs(event);
+    view.dispatchTouchEvent(event);
+    assertThat(shadowOf(view).getLastTouchEvent()).isSameInstanceAs(event);
+  }
+
+  @Test
+  public void dispatchTouchEvent_listensToFalseFromListener() throws Exception {
+    final AtomicBoolean called = new AtomicBoolean(false);
+    view.setOnTouchListener(new View.OnTouchListener() {
+      @Override
+      public boolean onTouch(View view, MotionEvent motionEvent) {
+        called.set(true); return false;
+      }
+    });
+    MotionEvent event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 12f, 34f, 0);
+    view.dispatchTouchEvent(event);
+    assertThat(shadowOf(view).getLastTouchEvent()).isSameInstanceAs(event);
+    assertThat(called.get()).isTrue();
+  }
+
+  @Test
+  public void test_nextFocusDownId() throws Exception {
+    assertEquals(View.NO_ID, view.getNextFocusDownId());
+
+    view.setNextFocusDownId(R.id.icon);
+    assertEquals(R.id.icon, view.getNextFocusDownId());
+  }
+
+  @Test
+  public void startAnimation() {
+    AlphaAnimation animation = new AlphaAnimation(0, 1);
+    Animation.AnimationListener listener = mock(Animation.AnimationListener.class);
+    animation.setAnimationListener(listener);
+
+    view.startAnimation(animation);
+    shadowMainLooper().idle();
+
+    verify(listener).onAnimationStart(animation);
+    verify(listener).onAnimationEnd(animation);
+    assertThat(animation.isInitialized()).isTrue();
+    assertThat(view.getAnimation()).isNull();
+    assertThat(shadowOf(view).getAnimations()).contains(animation);
+  }
+
+  @Test
+  public void setAnimation() {
+    AlphaAnimation animation = new AlphaAnimation(0, 1);
+
+    Animation.AnimationListener listener = mock(Animation.AnimationListener.class);
+    animation.setAnimationListener(listener);
+    animation.setStartTime(1000);
+    view.setAnimation(animation);
+
+    verifyNoMoreInteractions(listener);
+
+    SystemClock.setCurrentTimeMillis(1000);
+    shadowMainLooper().idle();
+
+    verify(listener).onAnimationStart(animation);
+    verify(listener).onAnimationEnd(animation);
+  }
+
+  @Test
+  public void setNullAnimation() {
+    TestView view = new TestView(buildActivity(Activity.class).create().get());
+    view.setAnimation(null);
+    assertThat(view.getAnimation()).isNull();
+  }
+
+  @Test
+  public void test_measuredDimension() {
+    // View does not provide its own onMeasure implementation
+    TestView view1 = new TestView(buildActivity(Activity.class).create().get());
+
+    assertThat(view1.getHeight()).isEqualTo(0);
+    assertThat(view1.getWidth()).isEqualTo(0);
+    assertThat(view1.getMeasuredHeight()).isEqualTo(0);
+    assertThat(view1.getMeasuredWidth()).isEqualTo(0);
+
+    view1.measure(MeasureSpec.makeMeasureSpec(150, MeasureSpec.AT_MOST),
+        MeasureSpec.makeMeasureSpec(300, MeasureSpec.AT_MOST));
+
+    assertThat(view1.getHeight()).isEqualTo(0);
+    assertThat(view1.getWidth()).isEqualTo(0);
+    assertThat(view1.getMeasuredHeight()).isEqualTo(300);
+    assertThat(view1.getMeasuredWidth()).isEqualTo(150);
+  }
+
+  @Test
+  public void test_measuredDimensionCustomView() {
+    // View provides its own onMeasure implementation
+    TestView2 view2 = new TestView2(buildActivity(Activity.class).create().get(), 300, 100);
+
+    assertThat(view2.getWidth()).isEqualTo(0);
+    assertThat(view2.getHeight()).isEqualTo(0);
+    assertThat(view2.getMeasuredWidth()).isEqualTo(0);
+    assertThat(view2.getMeasuredHeight()).isEqualTo(0);
+
+    view2.measure(MeasureSpec.makeMeasureSpec(200, MeasureSpec.AT_MOST),
+    MeasureSpec.makeMeasureSpec(50, MeasureSpec.AT_MOST));
+
+    assertThat(view2.getWidth()).isEqualTo(0);
+    assertThat(view2.getHeight()).isEqualTo(0);
+    assertThat(view2.getMeasuredWidth()).isEqualTo(300);
+    assertThat(view2.getMeasuredHeight()).isEqualTo(100);
+  }
+
+  @Test
+  public void shouldGetAndSetTranslations() throws Exception {
+    view = new TestView(buildActivity(Activity.class).create().get());
+    view.setTranslationX(8.9f);
+    view.setTranslationY(4.6f);
+
+    assertThat(view.getTranslationX()).isEqualTo(8.9f);
+    assertThat(view.getTranslationY()).isEqualTo(4.6f);
+  }
+
+  @Test
+  public void shouldGetAndSetAlpha() throws Exception {
+    view = new TestView(buildActivity(Activity.class).create().get());
+    view.setAlpha(9.1f);
+
+    assertThat(view.getAlpha()).isEqualTo(9.1f);
+  }
+
+  @Test
+  public void itKnowsIfTheViewIsShown() {
+    view.setVisibility(View.VISIBLE);
+    assertThat(view.isShown()).isTrue();
+  }
+
+  @Test
+  public void itKnowsIfTheViewIsNotShown() {
+    view.setVisibility(View.GONE);
+    assertThat(view.isShown()).isFalse();
+
+    view.setVisibility(View.INVISIBLE);
+    assertThat(view.isShown()).isFalse();
+  }
+
+  @Test
+  public void shouldTrackRequestLayoutCalls() throws Exception {
+    shadowOf(view).setDidRequestLayout(false);
+    assertThat(shadowOf(view).didRequestLayout()).isFalse();
+    view.requestLayout();
+    assertThat(shadowOf(view).didRequestLayout()).isTrue();
+    shadowOf(view).setDidRequestLayout(false);
+    assertThat(shadowOf(view).didRequestLayout()).isFalse();
+  }
+
+  @Test
+  public void shouldClickAndNotClick() throws Exception {
+    assertThat(view.isClickable()).isFalse();
+    view.setClickable(true);
+    assertThat(view.isClickable()).isTrue();
+    view.setClickable(false);
+    assertThat(view.isClickable()).isFalse();
+    view.setOnClickListener(new OnClickListener() {
+      @Override
+      public void onClick(View v) {
+        ;
+      }
+    });
+    assertThat(view.isClickable()).isTrue();
+  }
+
+  @Test
+  public void shouldLongClickAndNotLongClick() throws Exception {
+    assertThat(view.isLongClickable()).isFalse();
+    view.setLongClickable(true);
+    assertThat(view.isLongClickable()).isTrue();
+    view.setLongClickable(false);
+    assertThat(view.isLongClickable()).isFalse();
+    view.setOnLongClickListener(new OnLongClickListener() {
+      @Override
+      public boolean onLongClick(View v) {
+        return false;
+      }
+    });
+    assertThat(view.isLongClickable()).isTrue();
+  }
+
+  @Test
+  public void rotationX() {
+    view.setRotationX(10f);
+    assertThat(view.getRotationX()).isEqualTo(10f);
+  }
+
+  @Test
+  public void rotationY() {
+    view.setRotationY(20f);
+    assertThat(view.getRotationY()).isEqualTo(20f);
+  }
+
+  @Test
+  public void rotation() {
+    view.setRotation(30f);
+    assertThat(view.getRotation()).isEqualTo(30f);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void cameraDistance() {
+    view.setCameraDistance(100f);
+    assertThat(view.getCameraDistance()).isEqualTo(100f);
+  }
+
+  @Test
+  public void scaleX() {
+    assertThat(view.getScaleX()).isEqualTo(1f);
+    view.setScaleX(0.5f);
+    assertThat(view.getScaleX()).isEqualTo(0.5f);
+  }
+
+  @Test
+  public void scaleY() {
+    assertThat(view.getScaleY()).isEqualTo(1f);
+    view.setScaleY(0.5f);
+    assertThat(view.getScaleY()).isEqualTo(0.5f);
+  }
+
+  @Test
+  public void pivotX() {
+    view.setPivotX(10f);
+    assertThat(view.getPivotX()).isEqualTo(10f);
+  }
+
+  @Test
+  public void pivotY() {
+    view.setPivotY(10f);
+    assertThat(view.getPivotY()).isEqualTo(10f);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void elevation() {
+    view.setElevation(10f);
+    assertThat(view.getElevation()).isEqualTo(10f);
+  }
+
+  @Test
+  public void translationX() {
+    view.setTranslationX(10f);
+    assertThat(view.getTranslationX()).isEqualTo(10f);
+  }
+
+  @Test
+  public void translationY() {
+    view.setTranslationY(10f);
+    assertThat(view.getTranslationY()).isEqualTo(10f);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void translationZ() {
+    view.setTranslationZ(10f);
+    assertThat(view.getTranslationZ()).isEqualTo(10f);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void clipToOutline() {
+    view.setClipToOutline(true);
+    assertThat(view.getClipToOutline()).isTrue();
+  }
+
+  @Test
+  public void performHapticFeedback_shouldSetLastPerformedHapticFeedback() throws Exception {
+    assertThat(shadowOf(view).lastHapticFeedbackPerformed()).isEqualTo(-1);
+    view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+    assertThat(shadowOf(view).lastHapticFeedbackPerformed()).isEqualTo(HapticFeedbackConstants.LONG_PRESS);
+  }
+
+  @Test
+  public void canAssertThatSuperDotOnLayoutWasCalledFromViewSubclasses() throws Exception {
+    TestView2 view = new TestView2(setupActivity(Activity.class), 1111, 1112);
+    assertThat(shadowOf(view).onLayoutWasCalled()).isFalse();
+    view.onLayout(true, 1, 2, 3, 4);
+    assertThat(shadowOf(view).onLayoutWasCalled()).isTrue();
+  }
+
+  @Test
+  public void setScrolls_canBeAskedFor() throws Exception {
+    view.setScrollX(234);
+    view.setScrollY(544);
+    assertThat(view.getScrollX()).isEqualTo(234);
+    assertThat(view.getScrollY()).isEqualTo(544);
+  }
+
+  @Test
+  public void setScrolls_firesOnScrollChanged() throws Exception {
+    TestView testView = new TestView(buildActivity(Activity.class).create().get());
+    testView.setScrollX(122);
+    testView.setScrollY(150);
+    testView.setScrollX(453);
+    assertThat(testView.oldl).isEqualTo(122);
+    testView.setScrollY(54);
+    assertThat(testView.l).isEqualTo(453);
+    assertThat(testView.t).isEqualTo(54);
+    assertThat(testView.oldt).isEqualTo(150);
+  }
+
+  @Test
+  public void layerType() throws Exception {
+    assertThat(view.getLayerType()).isEqualTo(View.LAYER_TYPE_NONE);
+    view.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+    assertThat(view.getLayerType()).isEqualTo(View.LAYER_TYPE_SOFTWARE);
+  }
+
+  private static class TestAnimation extends Animation {
+  }
+
+  private static class TouchableView extends View {
+    MotionEvent event;
+
+    public TouchableView(Context context) {
+      super(context);
+    }
+
+    @Override
+    public boolean onTouchEvent(MotionEvent event) {
+      this.event = event;
+      return false;
+    }
+  }
+
+  public static class TestView extends View {
+    boolean onAnimationEndWasCalled;
+    private int l;
+    private int t;
+    private int oldl;
+    private int oldt;
+
+    public TestView(Context context) {
+      super(context);
+    }
+
+    @Override
+    protected void onAnimationEnd() {
+      super.onAnimationEnd();
+      onAnimationEndWasCalled = true;
+    }
+
+    @Override
+    public void onScrollChanged(int l, int t, int oldl, int oldt) {
+      this.l = l;
+      this.t = t;
+      this.oldl = oldl;
+      this.oldt = oldt;
+    }
+  }
+
+  private static class TestView2 extends View {
+
+    private int minWidth;
+    private int minHeight;
+
+    public TestView2(Context context, int minWidth, int minHeight) {
+      super(context);
+      this.minWidth = minWidth;
+      this.minHeight = minHeight;
+    }
+
+    @Override
+    public void onLayout(boolean changed, int l, int t, int r, int b) {
+      super.onLayout(changed, l, t, r, b);
+    }
+
+    @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+      setMeasuredDimension(minWidth, minHeight);
+    }
+  }
+
+  @Test
+  public void shouldCallOnAttachedToAndDetachedFromWindow() throws Exception {
+    MyView parent = new MyView("parent", transcript);
+    parent.addView(new MyView("child", transcript));
+    assertThat(transcript).isEmpty();
+
+    Activity activity = Robolectric.buildActivity(ContentViewActivity.class).setup().get();
+    activity.getWindowManager().addView(parent, new WindowManager.LayoutParams(100, 100));
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("parent attached", "child attached");
+    transcript.clear();
+
+    parent.addView(new MyView("another child", transcript));
+    assertThat(transcript).containsExactly("another child attached");
+    transcript.clear();
+
+    MyView temporaryChild = new MyView("temporary child", transcript);
+    parent.addView(temporaryChild);
+    assertThat(transcript).containsExactly("temporary child attached");
+    transcript.clear();
+    assertTrue(shadowOf(temporaryChild).isAttachedToWindow());
+
+    parent.removeView(temporaryChild);
+    assertThat(transcript).containsExactly("temporary child detached");
+    assertFalse(shadowOf(temporaryChild).isAttachedToWindow());
+  }
+
+  @Test @Config(minSdk = JELLY_BEAN_MR2)
+  public void getWindowId_shouldReturnValidObjectWhenAttached() throws Exception {
+    MyView parent = new MyView("parent", transcript);
+    MyView child = new MyView("child", transcript);
+    parent.addView(child);
+
+    assertThat(parent.getWindowId()).isNull();
+    assertThat(child.getWindowId()).isNull();
+
+    Activity activity = Robolectric.buildActivity(ContentViewActivity.class).create().get();
+    activity.getWindowManager().addView(parent, new WindowManager.LayoutParams(100, 100));
+    shadowMainLooper().idle();
+
+    WindowId windowId = parent.getWindowId();
+    assertThat(windowId).isNotNull();
+    assertThat(child.getWindowId()).isSameInstanceAs(windowId);
+    assertThat(child.getWindowId()).isEqualTo(windowId); // equals must work!
+
+    MyView anotherChild = new MyView("another child", transcript);
+    parent.addView(anotherChild);
+    assertThat(anotherChild.getWindowId()).isEqualTo(windowId);
+
+    parent.removeView(anotherChild);
+    assertThat(anotherChild.getWindowId()).isNull();
+  }
+
+  // todo looks like this is flaky...
+  @Test
+  public void removeAllViews_shouldCallOnAttachedToAndDetachedFromWindow() throws Exception {
+    MyView parent = new MyView("parent", transcript);
+    Activity activity = Robolectric.buildActivity(ContentViewActivity.class).create().get();
+    activity.getWindowManager().addView(parent, new WindowManager.LayoutParams(100, 100));
+
+    parent.addView(new MyView("child", transcript));
+    parent.addView(new MyView("another child", transcript));
+    shadowMainLooper().idle();
+    transcript.clear();
+    parent.removeAllViews();
+    shadowMainLooper().idle();
+    assertThat(transcript).containsExactly("another child detached", "child detached");
+  }
+
+  @Test
+  public void capturesOnSystemUiVisibilityChangeListener() throws Exception {
+    TestView testView = new TestView(buildActivity(Activity.class).create().get());
+    View.OnSystemUiVisibilityChangeListener changeListener = new View.OnSystemUiVisibilityChangeListener() {
+      @Override
+      public void onSystemUiVisibilityChange(int i) { }
+    };
+    testView.setOnSystemUiVisibilityChangeListener(changeListener);
+
+    assertThat(changeListener).isEqualTo(shadowOf(testView).getOnSystemUiVisibilityChangeListener());
+  }
+
+  @Test
+  public void capturesOnCreateContextMenuListener() throws Exception {
+    TestView testView = new TestView(buildActivity(Activity.class).create().get());
+    assertThat(shadowOf(testView).getOnCreateContextMenuListener()).isNull();
+
+    View.OnCreateContextMenuListener createListener = new View.OnCreateContextMenuListener() {
+      @Override
+      public void onCreateContextMenu(ContextMenu contextMenu, View view, ContextMenu.ContextMenuInfo contextMenuInfo) {}
+    };
+
+    testView.setOnCreateContextMenuListener(createListener);
+    assertThat(shadowOf(testView).getOnCreateContextMenuListener()).isEqualTo(createListener);
+
+    testView.setOnCreateContextMenuListener(null);
+    assertThat(shadowOf(testView).getOnCreateContextMenuListener()).isNull();
+  }
+
+  @Test
+  public void capturesOnAttachStateChangeListeners() throws Exception {
+    TestView testView = new TestView(buildActivity(Activity.class).create().get());
+    assertThat(shadowOf(testView).getOnAttachStateChangeListeners()).isEmpty();
+
+    View.OnAttachStateChangeListener attachListener1 =
+        new View.OnAttachStateChangeListener() {
+          @Override
+          public void onViewAttachedToWindow(View v) {}
+
+          @Override
+          public void onViewDetachedFromWindow(View v) {}
+        };
+
+    View.OnAttachStateChangeListener attachListener2 =
+        new View.OnAttachStateChangeListener() {
+          @Override
+          public void onViewAttachedToWindow(View v) {}
+
+          @Override
+          public void onViewDetachedFromWindow(View v) {}
+        };
+
+    testView.addOnAttachStateChangeListener(attachListener1);
+    assertThat(shadowOf(testView).getOnAttachStateChangeListeners())
+        .containsExactly(attachListener1);
+
+    testView.addOnAttachStateChangeListener(attachListener2);
+    assertThat(shadowOf(testView).getOnAttachStateChangeListeners())
+        .containsExactly(attachListener1, attachListener2);
+
+    testView.removeOnAttachStateChangeListener(attachListener2);
+    assertThat(shadowOf(testView).getOnAttachStateChangeListeners())
+        .containsExactly(attachListener1);
+
+    testView.removeOnAttachStateChangeListener(attachListener1);
+    assertThat(shadowOf(testView).getOnAttachStateChangeListeners()).isEmpty();
+  }
+
+  @Test
+  public void setsGlobalVisibleRect() {
+    Rect globalVisibleRect = new Rect();
+    shadowOf(view).setGlobalVisibleRect(new Rect());
+    assertThat(view.getGlobalVisibleRect(globalVisibleRect))
+        .isFalse();
+    assertThat(globalVisibleRect.isEmpty())
+        .isTrue();
+    assertThat(view.getGlobalVisibleRect(globalVisibleRect, new Point(1, 1)))
+        .isFalse();
+    assertThat(globalVisibleRect.isEmpty())
+        .isTrue();
+
+    shadowOf(view).setGlobalVisibleRect(new Rect(1, 2, 3, 4));
+    assertThat(view.getGlobalVisibleRect(globalVisibleRect))
+        .isTrue();
+    assertThat(globalVisibleRect)
+        .isEqualTo(new Rect(1, 2, 3, 4));
+    assertThat(view.getGlobalVisibleRect(globalVisibleRect, new Point(1, 1)))
+        .isTrue();
+    assertThat(globalVisibleRect)
+        .isEqualTo(new Rect(0, 1, 2, 3));
+  }
+
+  @Test
+  public void usesDefaultGlobalVisibleRect() {
+
+    final ActivityController<Activity> activityController = Robolectric.buildActivity(Activity.class);
+    final Activity activity = activityController.get();
+    TextView fooView = new TextView(activity);
+    activity.setContentView(
+        fooView,
+        new ViewGroup.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+    activityController.setup();
+
+    Rect globalVisibleRect = new Rect();
+    assertThat(fooView.getGlobalVisibleRect(globalVisibleRect)).isTrue();
+    assertThat(globalVisibleRect)
+        .isEqualTo(new Rect(0, 25,
+            DeviceConfig.DEFAULT_SCREEN_SIZE.width, DeviceConfig.DEFAULT_SCREEN_SIZE.height));
+  }
+
+  @Test
+  public void performClick_addListener_sendsGlobalViewAction() {
+    class GlobalListener implements View.OnClickListener {
+      boolean receivedEvent = false;
+
+      @Override
+      public void onClick(View view) {
+        receivedEvent = true;
+      }
+    }
+    GlobalListener listener = new GlobalListener();
+
+    ShadowView.addGlobalPerformClickListener(listener);
+    view.performClick();
+
+    assertThat(listener.receivedEvent).isTrue();
+  }
+
+  @Test
+  public void performClick_removeListener_sendsGlobalViewAction() {
+    class GlobalListener implements View.OnClickListener {
+      boolean receivedEvent = false;
+
+      @Override
+      public void onClick(View view) {
+        receivedEvent = true;
+      }
+    }
+    GlobalListener listener = new GlobalListener();
+
+    ShadowView.addGlobalPerformClickListener(listener);
+    ShadowView.removeGlobalPerformClickListener(listener);
+    view.performClick();
+
+    assertThat(listener.receivedEvent).isFalse();
+  }
+
+  @Test
+  public void performLongClick_addListener_sendsGlobalViewAction() {
+    class GlobalListener implements View.OnLongClickListener {
+      boolean receivedEvent = false;
+
+      @Override
+      public boolean onLongClick(View view) {
+        receivedEvent = true;
+        return true;
+      }
+    }
+    GlobalListener listener = new GlobalListener();
+
+    ShadowView.addGlobalPerformLongClickListener(listener);
+    view.performLongClick();
+
+    assertThat(listener.receivedEvent).isTrue();
+  }
+
+  @Test
+  public void performLongClick_removeListener_sendsGlobalViewAction() {
+    class GlobalListener implements View.OnLongClickListener {
+      boolean receivedEvent = false;
+
+      @Override
+      public boolean onLongClick(View view) {
+        receivedEvent = true;
+        return true;
+      }
+    }
+    GlobalListener listener = new GlobalListener();
+
+    ShadowView.addGlobalPerformLongClickListener(listener);
+    ShadowView.removeGlobalPerformLongClickListener(listener);
+    view.performLongClick();
+
+    assertThat(listener.receivedEvent).isFalse();
+  }
+
+  @Test
+  public void reset_removesAllGlobalViewActionListeners() {
+    class GlobalClickListener implements View.OnClickListener {
+      boolean receivedEvent = false;
+
+      @Override
+      public void onClick(View view) {
+        receivedEvent = true;
+      }
+    }
+    class GlobalLongClickListener implements View.OnLongClickListener {
+      boolean receivedEvent = false;
+
+      @Override
+      public boolean onLongClick(View view) {
+        receivedEvent = true;
+        return true;
+      }
+    }
+    GlobalClickListener globalClickListener = new GlobalClickListener();
+    GlobalLongClickListener globalLongClickListener = new GlobalLongClickListener();
+
+    ShadowView.addGlobalPerformClickListener(globalClickListener);
+    ShadowView.addGlobalPerformLongClickListener(globalLongClickListener);
+
+    ShadowView.reset();
+
+    // Should call both listeners since this calls sendAccessibilityEvent.
+    view.performClick();
+
+    assertThat(globalClickListener.receivedEvent).isFalse();
+    assertThat(globalLongClickListener.receivedEvent).isFalse();
+  }
+
+  @Test
+  public void getSourceLayoutResId() {
+    View view = LayoutInflater.from(context).inflate(R.layout.edit_text, null, false);
+
+    assertThat(shadowOf(view).getSourceLayoutResId()).isEqualTo(R.layout.edit_text);
+  }
+
+  public static class MyActivity extends Activity {
+    public boolean called;
+
+    @SuppressWarnings("UnusedDeclaration")
+    public void clickMe(View view) {
+      called = true;
+    }
+  }
+
+  public static class MyView extends LinearLayout {
+    private String name;
+    private List<String> transcript;
+
+    public MyView(String name, List<String> transcript) {
+      super(ApplicationProvider.getApplicationContext());
+      this.name = name;
+      this.transcript = transcript;
+    }
+
+    @Override protected void onAttachedToWindow() {
+      transcript.add(name + " attached");
+      super.onAttachedToWindow();
+    }
+
+    @Override protected void onDetachedFromWindow() {
+      transcript.add(name + " detached");
+      super.onDetachedFromWindow();
+    }
+  }
+
+  private static class ContentViewActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      setContentView(new FrameLayout(this));
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualVoicemailSmsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualVoicemailSmsTest.java
new file mode 100644
index 0000000..4b5832c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualVoicemailSmsTest.java
@@ -0,0 +1,88 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.VisualVoicemailSms;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowVisualVoicemailSms} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.O)
+public class ShadowVisualVoicemailSmsTest {
+
+  private final Context appContext = ApplicationProvider.getApplicationContext();
+  private final PhoneAccountHandle phoneAccountHandle =
+      new PhoneAccountHandle(new ComponentName(appContext, Object.class), "foo");
+
+  private VisualVoicemailSms sms;
+  private ShadowVisualVoicemailSms shadowSms;
+
+  @Before
+  public void setup() {
+    sms = Shadow.newInstanceOf(VisualVoicemailSms.class);
+    shadowSms = Shadow.extract(sms);
+  }
+
+  @Test
+  public void setPhoneAccountHandle_setsPhoneAccountHandle() {
+    shadowSms.setPhoneAccountHandle(phoneAccountHandle);
+
+    assertThat(sms.getPhoneAccountHandle()).isEqualTo(phoneAccountHandle);
+  }
+
+  @Test
+  public void setPrefix_setsPrefix() {
+    shadowSms.setPrefix("prefix");
+
+    assertThat(sms.getPrefix()).isEqualTo("prefix");
+  }
+
+  @Test
+  public void setFields_setsFields() {
+    Bundle bundle = new Bundle();
+    bundle.putString("key", "value");
+    shadowSms.setFields(bundle);
+
+    assertThat(sms.getFields()).isEqualTo(bundle);
+  }
+
+  @Test
+  public void setMessageBody_setsMessageBody() {
+    shadowSms.setMessageBody("messageBody");
+
+    assertThat(sms.getMessageBody()).isEqualTo("messageBody");
+  }
+
+  @Test
+  public void parcelable_unparcelable() {
+    Bundle bundle = new Bundle();
+    bundle.putString("key", "value");
+    shadowSms
+        .setPhoneAccountHandle(phoneAccountHandle)
+        .setPrefix("prefix")
+        .setFields(bundle)
+        .setMessageBody("messageBody");
+
+    Parcel parcel = Parcel.obtain();
+    sms.writeToParcel(parcel, 0);
+    parcel.setDataPosition(0);
+    VisualVoicemailSms newSms = VisualVoicemailSms.CREATOR.createFromParcel(parcel);
+
+    assertThat(newSms.getPhoneAccountHandle()).isEqualTo(phoneAccountHandle);
+    assertThat(newSms.getPrefix()).isEqualTo("prefix");
+    assertThat(newSms.getFields().getString("key")).isEqualTo("value");
+    assertThat(newSms.getMessageBody()).isEqualTo("messageBody");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualVoicemailTaskTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualVoicemailTaskTest.java
new file mode 100644
index 0000000..0156e28
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualVoicemailTaskTest.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.VisualVoicemailService.VisualVoicemailTask;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowVisualVoicemailTask} */
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.O)
+public class ShadowVisualVoicemailTaskTest {
+
+  private VisualVoicemailTask task;
+  private ShadowVisualVoicemailTask shadowTask;
+
+  @Before
+  public void setup() {
+    task = Shadow.newInstanceOf(VisualVoicemailTask.class);
+    shadowTask = Shadow.extract(task);
+  }
+
+  @Test
+  public void isFinished_defaultFalse() {
+    assertThat(shadowTask.isFinished()).isFalse();
+  }
+
+  @Test
+  public void finish_setsIsFinishedTrue() {
+    task.finish();
+
+    assertThat(shadowTask.isFinished()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualizerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualizerTest.java
new file mode 100644
index 0000000..f62941f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVisualizerTest.java
@@ -0,0 +1,252 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.GINGERBREAD;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.media.audiofx.AudioEffect;
+import android.media.audiofx.Visualizer;
+import android.media.audiofx.Visualizer.MeasurementPeakRms;
+import android.media.audiofx.Visualizer.OnDataCaptureListener;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowVisualizer.VisualizerSource;
+
+/** Tests for {@link ShadowVisualizer}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = GINGERBREAD)
+public class ShadowVisualizerTest {
+
+  private Visualizer visualizer;
+
+  @Before
+  public void setUp() {
+    visualizer = new Visualizer(/* audioSession= */ 0);
+    visualizer.setEnabled(true);
+  }
+
+  @Test
+  public void getSamplingRate_returnsRateFromSource() {
+    assertThat(visualizer.getSamplingRate()).isEqualTo(0);
+
+    shadowOf(visualizer)
+        .setSource(
+            new VisualizerSource() {
+              @Override
+              public int getSamplingRate() {
+                return 100;
+              }
+            });
+
+    assertThat(visualizer.getSamplingRate()).isEqualTo(100);
+  }
+
+  @Test
+  public void getWaveform_returnsWaveformFromSource() {
+    byte[] waveformInput = new byte[10];
+    Arrays.fill(waveformInput, (byte) 5);
+
+    // Default behaviour
+    assertThat(visualizer.getWaveForm(waveformInput)).isEqualTo(Visualizer.SUCCESS);
+
+    shadowOf(visualizer).setSource(createVisualizerSourceReturningValue(42));
+
+    assertThat(visualizer.getWaveForm(waveformInput)).isEqualTo(42);
+  }
+
+  @Test
+  public void getFft_returnsFftFromSource() {
+    byte[] fftInput = new byte[10];
+    Arrays.fill(fftInput, (byte) 5);
+
+    // Default behaviour
+    assertThat(visualizer.getFft(fftInput)).isEqualTo(Visualizer.SUCCESS);
+
+    shadowOf(visualizer).setSource(createVisualizerSourceReturningValue(42));
+
+    assertThat(visualizer.getFft(fftInput)).isEqualTo(42);
+  }
+
+  @Test
+  public void getEnabled_isFalseByDefault() {
+    assertThat(new Visualizer(/* audioSession= */ 0).getEnabled()).isFalse();
+  }
+
+  @Test
+  public void setEnabled_changedEnabledState() {
+    int status = visualizer.setEnabled(true);
+
+    assertThat(status).isEqualTo(Visualizer.SUCCESS);
+    assertThat(visualizer.getEnabled()).isTrue();
+  }
+
+  @Test
+  public void setCaptureSize_changedCaptureSize() {
+    // The capture size can only be set while the Visualizer is disabled.
+    visualizer.setEnabled(false);
+
+    int status = visualizer.setCaptureSize(2000);
+
+    assertThat(status).isEqualTo(Visualizer.SUCCESS);
+    assertThat(visualizer.getCaptureSize()).isEqualTo(2000);
+  }
+
+  @Config(minSdk = KITKAT)
+  @Test
+  public void getMeasurementPeakRms_returnsRmsFromSource() {
+    int peak = -500;
+    int rms = -1000;
+    shadowOf(visualizer)
+        .setSource(
+            new VisualizerSource() {
+              @Override
+              public int getPeakRms(MeasurementPeakRms measurement) {
+                measurement.mPeak = peak;
+                measurement.mRms = rms;
+                return AudioEffect.ERROR;
+              }
+            });
+    MeasurementPeakRms measurement = new MeasurementPeakRms();
+
+    int result = visualizer.getMeasurementPeakRms(measurement);
+
+    assertThat(result).isEqualTo(AudioEffect.ERROR);
+    assertThat(measurement.mPeak).isEqualTo(peak);
+    assertThat(measurement.mRms).isEqualTo(rms);
+  }
+
+  @Test
+  public void release_sourceThrowsException_throwsException() {
+    shadowOf(visualizer)
+        .setSource(
+            new VisualizerSource() {
+              @Override
+              public void release() {
+                throw new RuntimeException();
+              }
+            });
+
+    assertThrows(RuntimeException.class, () -> visualizer.release());
+  }
+
+  @Test
+  public void getEnabled_visualizerUninitialized_throwsException() {
+    shadowOf(visualizer).setState(Visualizer.STATE_UNINITIALIZED);
+
+    assertThrows(IllegalStateException.class, () -> visualizer.getEnabled());
+  }
+
+  @Test
+  public void setEnabled_visualizerUninitialized_throwsException() {
+    shadowOf(visualizer).setState(Visualizer.STATE_UNINITIALIZED);
+
+    assertThrows(IllegalStateException.class, () -> visualizer.setEnabled(false));
+  }
+
+  @Test
+  public void setDataCaptureListener_errorCodeSet_returnsErrorCode() {
+    shadowOf(visualizer).setErrorCode(Visualizer.ERROR);
+
+    assertThat(visualizer.setDataCaptureListener(null, 1024, false, false))
+        .isEqualTo(Visualizer.ERROR);
+  }
+
+  @Test
+  public void setEnabled_errorCodeSet_returnsErrorCode() {
+    visualizer.setEnabled(false);
+    shadowOf(visualizer).setErrorCode(Visualizer.ERROR);
+
+    assertThat(visualizer.setEnabled(true)).isEqualTo(Visualizer.ERROR);
+  }
+
+  @Test
+  public void setCaptureSize_errorCodeSet_returnsErrorCode() {
+    // The capture size can only be set while the Visualizer is disabled.
+    visualizer.setEnabled(false);
+    shadowOf(visualizer).setErrorCode(Visualizer.ERROR);
+
+    assertThat(visualizer.setCaptureSize(1024)).isEqualTo(Visualizer.ERROR);
+  }
+
+  @Test
+  public void triggerDataCapture_waveformAndFftTriggered() {
+    AtomicBoolean waveformCalled = new AtomicBoolean(false);
+    AtomicBoolean fftCalled = new AtomicBoolean(false);
+    visualizer.setDataCaptureListener(
+        createSimpleDataListener(waveformCalled, fftCalled),
+        /* rate= */ 1,
+        /* waveform= */ true,
+        /* fft= */ true);
+
+    shadowOf(visualizer).triggerDataCapture();
+
+    assertThat(waveformCalled.get()).isTrue();
+    assertThat(fftCalled.get()).isTrue();
+  }
+
+  @Test
+  public void triggerDataCapture_waveformDisabled_waveFormNotTriggered() {
+    AtomicBoolean waveformCalled = new AtomicBoolean(false);
+    AtomicBoolean fftCalled = new AtomicBoolean(false);
+    visualizer.setDataCaptureListener(
+        createSimpleDataListener(waveformCalled, fftCalled),
+        /* rate= */ 1,
+        /* waveform= */ false,
+        /* fft= */ true);
+
+    shadowOf(visualizer).triggerDataCapture();
+
+    assertThat(waveformCalled.get()).isFalse();
+  }
+
+  @Test
+  public void triggerDataCapture_fftDisabled_fftNotTriggered() {
+    AtomicBoolean waveformCalled = new AtomicBoolean(false);
+    AtomicBoolean fftCalled = new AtomicBoolean(false);
+    visualizer.setDataCaptureListener(
+        createSimpleDataListener(waveformCalled, fftCalled),
+        /* rate= */ 1,
+        /* waveform= */ true,
+        /* fft= */ false);
+
+    shadowOf(visualizer).triggerDataCapture();
+
+    assertThat(fftCalled.get()).isFalse();
+  }
+
+  private static VisualizerSource createVisualizerSourceReturningValue(int returnValue) {
+    return new VisualizerSource() {
+      @Override
+      public int getWaveForm(byte[] waveform) {
+        return returnValue;
+      }
+
+      @Override
+      public int getFft(byte[] fft) {
+        return returnValue;
+      }
+    };
+  }
+
+  private static OnDataCaptureListener createSimpleDataListener(
+      AtomicBoolean waveformCalled, AtomicBoolean fftCalled) {
+    return new OnDataCaptureListener() {
+      @Override
+      public void onWaveFormDataCapture(Visualizer visualizer, byte[] waveform, int samplingRate) {
+        waveformCalled.set(true);
+      }
+
+      @Override
+      public void onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate) {
+        fftCalled.set(true);
+      }
+    };
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractionServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractionServiceTest.java
new file mode 100644
index 0000000..a3233d3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractionServiceTest.java
@@ -0,0 +1,120 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.ComponentName;
+import android.os.Bundle;
+import android.service.voice.VoiceInteractionService;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.annotation.Config;
+
+/** Test for ShadowVoiceInteractionService. */
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = Q)
+public class ShadowVoiceInteractionServiceTest {
+
+  /** VoiceInteractionService needs to be extended to function. */
+  public static class TestVoiceInteractionService extends VoiceInteractionService {
+    public TestVoiceInteractionService() {}
+  }
+
+  private VoiceInteractionService service;
+  private ShadowVoiceInteractionService shadowService;
+
+  @Before
+  public void setUp() {
+    service = Robolectric.buildService(TestVoiceInteractionService.class).get();
+    shadowService = shadowOf(service);
+    ShadowVoiceInteractionService.reset();
+  }
+
+  @Test
+  public void testSetUiHintsInvoked_returnsValues() {
+    Bundle bundle1 = new Bundle();
+    bundle1.putString("testKey", "value");
+    Bundle bundle2 = new Bundle();
+    bundle2.putString("secondKey", "value");
+
+    service.onReady();
+    service.setUiHints(bundle1);
+    service.setUiHints(bundle2);
+
+    assertThat(shadowService.getLastUiHintBundle()).isEqualTo(bundle2);
+    assertThat(shadowService.getPreviousUiHintBundles()).containsExactly(bundle1, bundle2);
+  }
+
+  @Test
+  public void testSetUiHintsNotInvoked_returnsValues() {
+    service.onReady();
+    assertThat(shadowService.getLastUiHintBundle()).isNull();
+    assertThat(shadowService.getPreviousUiHintBundles()).isEmpty();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testSetUiHintsInvokedBeforeServiceReady_throwsException() {
+    service.setUiHints(new Bundle());
+  }
+
+  @Test
+  public void setActiveService_returnsDefaultFalse() {
+    assertThat(
+            VoiceInteractionService.isActiveService(
+                ApplicationProvider.getApplicationContext(), new ComponentName("test", "test")))
+        .isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void showSessionInvokedBeforeServiceReady_throwsException() {
+    assertThrows(
+        NullPointerException.class,
+        () -> {
+          service.showSession(new Bundle(), 0);
+        });
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void showSessionNotInvoked_returnsNull() {
+    service.onReady();
+    assertThat(shadowService.getLastSessionBundle()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void showSessionInvoked_returnsValues() {
+    service.onReady();
+    service.showSession(new Bundle(), /* flags= */ 0);
+    assertThat(shadowService.getLastSessionBundle()).isNotNull();
+  }
+
+  @Test
+  public void setActiveService_returnsChangedValue() {
+    ShadowVoiceInteractionService.setActiveService(new ComponentName("test", "test"));
+    assertThat(
+            VoiceInteractionService.isActiveService(
+                ApplicationProvider.getApplicationContext(), new ComponentName("test", "test")))
+        .isTrue();
+  }
+
+  @Test
+  public void resetter_resetsActiveServiceValue() {
+    ShadowVoiceInteractionService.setActiveService(new ComponentName("test", "test"));
+
+    ShadowVoiceInteractionService.reset();
+
+    assertThat(
+            VoiceInteractionService.isActiveService(
+                ApplicationProvider.getApplicationContext(), new ComponentName("test", "test")))
+        .isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractionSessionTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractionSessionTest.java
new file mode 100644
index 0000000..18b3e92
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractionSessionTest.java
@@ -0,0 +1,162 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.service.voice.VoiceInteractionSession;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+/** Tests for {@link ShadowVoiceInteractionSession}. */
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = Q)
+public class ShadowVoiceInteractionSessionTest {
+
+  private VoiceInteractionSession session;
+  private ShadowVoiceInteractionSession shadowSession;
+
+  @Before
+  public void setUp() {
+    session = new VoiceInteractionSession(getApplicationContext());
+    shadowSession = Shadow.extract(session);
+  }
+
+  @Test
+  public void isWindowShowing_returnsFalseByDefault() {
+    shadowSession.create();
+
+    assertThat(shadowSession.isWindowShowing()).isFalse();
+  }
+
+  @Test
+  public void isWindowShowing_afterShow_returnsTrue() {
+    shadowSession.create();
+
+    session.show(new Bundle(), /* flags= */ 0);
+
+    assertThat(shadowSession.isWindowShowing()).isTrue();
+  }
+
+  @Test
+  public void isWindowShowing_afterShowThenHide_returnsFalse() {
+    shadowSession.create();
+
+    session.show(new Bundle(), /* flags= */ 0);
+    session.hide();
+
+    assertThat(shadowSession.isWindowShowing()).isFalse();
+  }
+
+  @Test
+  public void startAssistantActivity_invokedTwice_lastIntentRegistered() {
+    shadowSession.create();
+    Intent intent1 = new Intent("foo action");
+    Intent intent2 = new Intent("bar action");
+
+    session.startAssistantActivity(intent1);
+    session.startAssistantActivity(intent2);
+
+    assertThat(shadowSession.getLastAssistantActivityIntent()).isEqualTo(intent2);
+  }
+
+  @Test
+  public void startAssistantActivity_invokedTwice_allIntentsRegisteredInOrder() {
+    shadowSession.create();
+    Intent intent1 = new Intent("foo action");
+    Intent intent2 = new Intent("bar action");
+
+    session.startAssistantActivity(intent1);
+    session.startAssistantActivity(intent2);
+
+    assertThat(shadowSession.getAssistantActivityIntents())
+        .containsExactly(intent1, intent2)
+        .inOrder();
+  }
+
+  @Test
+  public void startAssistantActivity_notInvoked_noRegisteredIntents() {
+    assertThat(shadowSession.getAssistantActivityIntents()).isEmpty();
+  }
+
+  @Test
+  public void startAssistantActivity_notInvoked_lastRegisteredIntentIsNull() {
+    assertThat(shadowSession.getLastAssistantActivityIntent()).isNull();
+  }
+
+  @Test(expected = SecurityException.class)
+  public void startVoiceActivity_exceptionSet_throws() {
+    shadowSession.create();
+
+    shadowSession.setStartVoiceActivityException(new SecurityException());
+
+    session.startVoiceActivity(new Intent());
+  }
+
+  @Test
+  public void startVoiceActivity_invokedTwice_lastIntentRegistered() {
+    shadowSession.create();
+    Intent intent1 = new Intent("foo action");
+    Intent intent2 = new Intent("bar action");
+
+    session.startVoiceActivity(intent1);
+    session.startVoiceActivity(intent2);
+
+    assertThat(shadowSession.getLastVoiceActivityIntent()).isEqualTo(intent2);
+  }
+
+  @Test
+  public void startVoiceActivity_invokedTwice_allIntentsRegisteredInOrder() {
+    shadowSession.create();
+    Intent intent1 = new Intent("foo action");
+    Intent intent2 = new Intent("bar action");
+
+    session.startVoiceActivity(intent1);
+    session.startVoiceActivity(intent2);
+
+    assertThat(shadowSession.getVoiceActivityIntents()).containsExactly(intent1, intent2).inOrder();
+  }
+
+  @Test
+  public void startVoiceActivity_notInvoked_noRegisteredIntents() {
+    assertThat(shadowSession.getVoiceActivityIntents()).isEmpty();
+  }
+
+  @Test
+  public void startVoiceActivity_notInvoked_lastRegisteredIntentIsNull() {
+    assertThat(shadowSession.getVoiceActivityIntents()).isEmpty();
+  }
+
+  @Test
+  public void isUiEnabled_returnsTrueByDefault() {
+    assertThat(shadowSession.isUiEnabled()).isTrue();
+  }
+
+  @Test
+  public void isUiEnabled_afterSettingDisabled_returnsFalse() {
+    session.setUiEnabled(false);
+
+    assertThat(shadowSession.isUiEnabled()).isFalse();
+  }
+
+  @Test
+  public void isUiEnabled_afterSettingDisabledThenEnabled_returnsTrue() {
+    session.setUiEnabled(false);
+    session.setUiEnabled(true);
+
+    assertThat(shadowSession.isUiEnabled()).isTrue();
+  }
+
+  @Test(expected = RuntimeException.class)
+  @Config(sdk = N)
+  public void isUiEnabled_belowAndroidO_throws() {
+    shadowSession.isUiEnabled();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractorTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractorTest.java
new file mode 100644
index 0000000..7b4daae
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVoiceInteractorTest.java
@@ -0,0 +1,144 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.ExpectFailure.expectFailure;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Robolectric.buildActivity;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.VoiceInteractor.AbortVoiceRequest;
+import android.app.VoiceInteractor.CommandRequest;
+import android.app.VoiceInteractor.CompleteVoiceRequest;
+import android.app.VoiceInteractor.ConfirmationRequest;
+import android.app.VoiceInteractor.PickOptionRequest;
+import android.app.VoiceInteractor.Prompt;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowVoiceInteractor}. */
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = TIRAMISU)
+public final class ShadowVoiceInteractorTest {
+
+  private static final String PROMPT_MESSAGE_1 = "Message_1";
+  private static final String PROMPT_MESSAGE_2 = "Message_2";
+
+  private Activity testActivity;
+  private ShadowVoiceInteractor shadowVoiceInteractor;
+
+  @Before
+  public void setUp() {
+    testActivity = buildActivity(Activity.class).create().get();
+    shadowOf(testActivity).initializeVoiceInteractor();
+    shadowVoiceInteractor = shadowOf(testActivity.getVoiceInteractor());
+  }
+
+  @Test
+  public void testGetDirectActionsInvalidationCount() {
+    testActivity.getVoiceInteractor().notifyDirectActionsChanged();
+    assertThat(shadowVoiceInteractor.getDirectActionsInvalidationCount()).isEqualTo(1);
+    AssertionError unused =
+        expectFailure(
+            whenTesting ->
+                whenTesting
+                    .that(shadowVoiceInteractor.getDirectActionsInvalidationCount())
+                    .isEqualTo(2));
+    testActivity.getVoiceInteractor().notifyDirectActionsChanged();
+    assertThat(shadowVoiceInteractor.getDirectActionsInvalidationCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void voiceInteractions_completeVoiceRequest() {
+    CompleteVoiceRequest completeVoiceRequest =
+        new CompleteVoiceRequest(new Prompt(PROMPT_MESSAGE_1), /* extras= */ null);
+
+    testActivity.getVoiceInteractor().submitRequest(completeVoiceRequest);
+
+    assertValues(Collections.singletonList(PROMPT_MESSAGE_1));
+  }
+
+  @Test
+  public void voiceInteractions_confirmRequest() {
+    ConfirmationRequest confirmationRequest =
+        new ConfirmationRequest(new Prompt(PROMPT_MESSAGE_1), /* extras= */ null);
+
+    testActivity.getVoiceInteractor().submitRequest(confirmationRequest);
+
+    assertValues(Collections.singletonList(PROMPT_MESSAGE_1));
+  }
+
+  @Test
+  public void voiceInteractions_abortVoiceRequest() {
+    AbortVoiceRequest abortVoiceRequest =
+        new AbortVoiceRequest(new Prompt(PROMPT_MESSAGE_1), /* extras= */ null);
+
+    testActivity.getVoiceInteractor().submitRequest(abortVoiceRequest);
+
+    assertValues(Collections.singletonList(PROMPT_MESSAGE_1));
+  }
+
+  @Test
+  public void voiceInteractions_commandRequest() {
+    CommandRequest commandRequest = new CommandRequest(PROMPT_MESSAGE_1, /* extras= */ null);
+
+    testActivity.getVoiceInteractor().submitRequest(commandRequest);
+
+    assertValues(Collections.singletonList(PROMPT_MESSAGE_1));
+  }
+
+  @Test
+  public void voiceInteractions_pickOptionsRequest() {
+    PickOptionRequest pickOptionRequest =
+        new PickOptionRequest(
+            new Prompt(PROMPT_MESSAGE_1), /* options= */ null, /* extras= */ null);
+
+    testActivity.getVoiceInteractor().submitRequest(pickOptionRequest);
+
+    assertValues(Collections.singletonList(PROMPT_MESSAGE_1));
+  }
+
+  @Test
+  public void voiceInteractions_withMultipleRequests() {
+    CompleteVoiceRequest completeVoiceRequest =
+        new CompleteVoiceRequest(new Prompt(PROMPT_MESSAGE_1), /* extras= */ null);
+    testActivity.getVoiceInteractor().submitRequest(completeVoiceRequest);
+    ConfirmationRequest confirmationRequest =
+        new ConfirmationRequest(new Prompt(PROMPT_MESSAGE_2), /* extras= */ null);
+    testActivity.getVoiceInteractor().submitRequest(confirmationRequest);
+
+    assertValues(ImmutableList.of(PROMPT_MESSAGE_1, PROMPT_MESSAGE_2));
+  }
+
+  @Test
+  public void voiceInteractions_returnsTrue() {
+    ConfirmationRequest confirmationRequest =
+        new ConfirmationRequest(new Prompt(PROMPT_MESSAGE_1), /* extras= */ null);
+
+    assertThat(testActivity.getVoiceInteractor().submitRequest(confirmationRequest)).isTrue();
+  }
+
+  @Test
+  public void getPackageName_returnsDefaultPackageName() {
+    assertThat(testActivity.getVoiceInteractor().getPackageName())
+        .isEqualTo(shadowVoiceInteractor.getPackageName());
+  }
+
+  @Test
+  public void getPackageName_returnsModifiedPackageName() {
+    shadowVoiceInteractor.setPackageName("random_voice_interactor");
+    assertThat(testActivity.getVoiceInteractor().getPackageName())
+        .isEqualTo("random_voice_interactor");
+  }
+
+  private void assertValues(List<String> promptMessage) {
+    assertThat(shadowVoiceInteractor.getVoiceInteractions().size()).isEqualTo(promptMessage.size());
+    assertThat(shadowVoiceInteractor.getVoiceInteractions()).isEqualTo(promptMessage);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVpnServiceTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVpnServiceTest.java
new file mode 100644
index 0000000..d59abbc
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVpnServiceTest.java
@@ -0,0 +1,30 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.VpnService;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowVpnServiceTest {
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void prepare() {
+    Intent intent = new Intent("foo");
+    ShadowVpnService.setPrepareResult(intent);
+
+    assertThat(VpnService.prepare(context)).isEqualTo(intent);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWallpaperManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWallpaperManagerTest.java
new file mode 100644
index 0000000..89535f6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWallpaperManagerTest.java
@@ -0,0 +1,579 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.truth.Truth.assertThat;
+import static junit.framework.Assert.fail;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.app.WallpaperManager;
+import android.content.ComponentName;
+import android.graphics.Bitmap;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowWallpaperManager.WallpaperCommandRecord;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWallpaperManagerTest {
+
+  private static final Bitmap TEST_IMAGE_1 = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+
+  private static final Bitmap TEST_IMAGE_2 = Bitmap.createBitmap(3, 2, Bitmap.Config.ARGB_8888);
+
+  private static final Bitmap TEST_IMAGE_3 = Bitmap.createBitmap(1, 5, Bitmap.Config.ARGB_8888);
+
+  private static final int UNSUPPORTED_FLAG = WallpaperManager.FLAG_LOCK + 123;
+
+  private static final String SET_WALLPAPER_COMPONENT =
+      "android.permission.SET_WALLPAPER_COMPONENT";
+
+  private static final ComponentName TEST_WALLPAPER_SERVICE =
+      new ComponentName("org.robolectric", "org.robolectric.TestWallpaperService");
+
+  private Application application;
+  private WallpaperManager manager;
+
+  @Before
+  public void setUp() {
+    application = ApplicationProvider.getApplicationContext();
+    manager = WallpaperManager.getInstance(application);
+
+    shadowOf(application).grantPermissions(SET_WALLPAPER_COMPONENT);
+  }
+
+  @Test
+  public void getInstance_shouldCreateInstance() {
+    assertThat(manager).isNotNull();
+  }
+
+  @Test
+  public void sendWallpaperCommand_shouldTrackRecord() {
+    manager.sendWallpaperCommand(null, null, 0, 0, 0, null);
+
+    IBinder binder = new Binder();
+    Bundle bundle = new Bundle();
+    bundle.putString("key", "value");
+    manager.sendWallpaperCommand(binder, "action", 1, 2, 3, bundle);
+
+    List<WallpaperCommandRecord> records = shadowOf(manager).getWallpaperCommandRecords();
+
+    assertThat(records).hasSize(2);
+
+    WallpaperCommandRecord record0 = records.get(0);
+    assertThat(record0.windowToken).isNull();
+    assertThat(record0.action).isNull();
+    assertThat(record0.x).isEqualTo(0);
+    assertThat(record0.y).isEqualTo(0);
+    assertThat(record0.z).isEqualTo(0);
+    assertThat(record0.extras).isNull();
+
+    WallpaperCommandRecord record1 = records.get(1);
+    assertThat(record1.windowToken).isEqualTo(binder);
+    assertThat(record1.action).isEqualTo("action");
+    assertThat(record1.x).isEqualTo(1);
+    assertThat(record1.y).isEqualTo(2);
+    assertThat(record1.z).isEqualTo(3);
+    assertThat(record1.extras.getString("key")).isEqualTo("value");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void hasResourceWallpaper_wallpaperResourceNotSet_returnsFalse() {
+    assertThat(manager.hasResourceWallpaper(1)).isFalse();
+    assertThat(manager.hasResourceWallpaper(5)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void hasResourceWallpaper_wallpaperResourceSet_returnsTrue() throws IOException {
+    int resid = 5;
+    manager.setResource(resid);
+
+    assertThat(manager.hasResourceWallpaper(1)).isFalse();
+    assertThat(manager.hasResourceWallpaper(resid)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void setResource_multipleTimes_hasResourceWallpaperReturnsTrueForLastValue()
+      throws IOException {
+    manager.setResource(1);
+    manager.setResource(2);
+    manager.setResource(3);
+
+    assertThat(manager.hasResourceWallpaper(1)).isFalse();
+    assertThat(manager.hasResourceWallpaper(2)).isFalse();
+    assertThat(manager.hasResourceWallpaper(3)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setResource_invalidFlag_returnsZero() throws IOException {
+    assertThat(manager.setResource(1, 0)).isEqualTo(0);
+    assertThat(manager.hasResourceWallpaper(1)).isFalse();
+    assertThat(manager.hasResourceWallpaper(2)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setResource_lockScreenOnly_returnsNewId() throws IOException {
+    assertThat(manager.setResource(1, WallpaperManager.FLAG_LOCK)).isEqualTo(1);
+    assertThat(manager.hasResourceWallpaper(1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setResource_homeScreenOnly_returnsNewId() throws IOException {
+    assertThat(manager.setResource(1, WallpaperManager.FLAG_SYSTEM)).isEqualTo(1);
+    assertThat(manager.hasResourceWallpaper(1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_flagSystem_shouldCacheInMemory() throws Exception {
+    int returnCode =
+        manager.setBitmap(
+            TEST_IMAGE_1,
+            /* visibleCropHint= */ null,
+            /* allowBackup= */ false,
+            WallpaperManager.FLAG_SYSTEM);
+
+    assertThat(returnCode).isEqualTo(1);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isEqualTo(TEST_IMAGE_1);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_liveWallpaperWasDefault_flagSystem_shouldRemoveLiveWallpaper()
+      throws Exception {
+    manager.setWallpaperComponent(TEST_WALLPAPER_SERVICE);
+
+    manager.setBitmap(
+        TEST_IMAGE_1,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_SYSTEM);
+
+    assertThat(manager.getWallpaperInfo()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_multipleCallsWithFlagSystem_shouldCacheLastBitmapInMemory()
+      throws Exception {
+    manager.setBitmap(
+        TEST_IMAGE_1,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_SYSTEM);
+    manager.setBitmap(
+        TEST_IMAGE_2,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_SYSTEM);
+    manager.setBitmap(
+        TEST_IMAGE_3,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_SYSTEM);
+
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isEqualTo(TEST_IMAGE_3);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_flagLock_shouldCacheInMemory() throws Exception {
+    int returnCode =
+        manager.setBitmap(
+            TEST_IMAGE_2,
+            /* visibleCropHint= */ null,
+            /* allowBackup= */ false,
+            WallpaperManager.FLAG_LOCK);
+
+    assertThat(returnCode).isEqualTo(1);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isEqualTo(TEST_IMAGE_2);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_liveWallpaperWasDefault_flagLock_shouldRemoveLiveWallpaper()
+      throws Exception {
+    manager.setWallpaperComponent(TEST_WALLPAPER_SERVICE);
+
+    manager.setBitmap(
+        TEST_IMAGE_1,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_LOCK);
+
+    assertThat(manager.getWallpaperInfo()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_multipleCallsWithFlagLock_shouldCacheLastBitmapInMemory() throws Exception {
+    manager.setBitmap(
+        TEST_IMAGE_1,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_LOCK);
+    manager.setBitmap(
+        TEST_IMAGE_2,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_LOCK);
+    manager.setBitmap(
+        TEST_IMAGE_3,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_LOCK);
+
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isEqualTo(TEST_IMAGE_3);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_unsupportedFlag_shouldNotCacheInMemory() throws Exception {
+    int code =
+        manager.setBitmap(
+            TEST_IMAGE_1, /* visibleCropHint= */ null, /* allowBackup= */ false, UNSUPPORTED_FLAG);
+
+    assertThat(code).isEqualTo(0);
+    assertThat(shadowOf(manager).getBitmap(UNSUPPORTED_FLAG)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void setBitmap_liveWallpaperWasDefault_unsupportedFlag_shouldNotRemoveLiveWallpaper()
+      throws Exception {
+    manager.setWallpaperComponent(TEST_WALLPAPER_SERVICE);
+
+    manager.setBitmap(
+        TEST_IMAGE_1, /* visibleCropHint= */ null, /* allowBackup= */ false, UNSUPPORTED_FLAG);
+
+    assertThat(manager.getWallpaperInfo().getComponent()).isEqualTo(TEST_WALLPAPER_SERVICE);
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getWallpaperFile_flagSystem_nothingCached_shouldReturnNull() throws Exception {
+    assertThat(manager.getWallpaperFile(WallpaperManager.FLAG_SYSTEM)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getWallpaperFile_flagSystem_previouslyCached_shouldReturnParcelFileDescriptor()
+      throws Exception {
+    manager.setBitmap(
+        TEST_IMAGE_1,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_SYSTEM);
+
+    try (ParcelFileDescriptor parcelFileDescriptor =
+        manager.getWallpaperFile(WallpaperManager.FLAG_SYSTEM)) {
+    assertThat(getBytesFromFileDescriptor(parcelFileDescriptor.getFileDescriptor()))
+        .isEqualTo(getBytesFromBitmap(TEST_IMAGE_1));
+    }
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getWallpaperFile_flagLock_nothingCached_shouldReturnNull() throws Exception {
+    assertThat(manager.getWallpaperFile(WallpaperManager.FLAG_LOCK)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getWallpaperFile_flagLock_previouslyCached_shouldReturnParcelFileDescriptor()
+      throws Exception {
+    manager.setBitmap(
+        TEST_IMAGE_3,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_LOCK);
+
+    try (ParcelFileDescriptor parcelFileDescriptor =
+        manager.getWallpaperFile(WallpaperManager.FLAG_LOCK)) {
+    assertThat(getBytesFromFileDescriptor(parcelFileDescriptor.getFileDescriptor()))
+        .isEqualTo(getBytesFromBitmap(TEST_IMAGE_3));
+    }
+  }
+
+  @Test
+  @Config(minSdk = P)
+  public void getWallpaperFile_unsupportedFlag_shouldReturnNull() throws Exception {
+    assertThat(manager.getWallpaperFile(UNSUPPORTED_FLAG)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isSetWallpaperAllowed_allowed_shouldReturnTrue() {
+    shadowOf(manager).setIsSetWallpaperAllowed(true);
+
+    assertThat(manager.isSetWallpaperAllowed()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void isSetWallpaperAllowed_disallowed_shouldReturnFalse() {
+    shadowOf(manager).setIsSetWallpaperAllowed(false);
+
+    assertThat(manager.isSetWallpaperAllowed()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isWallpaperSupported_supported_shouldReturnTrue() {
+    shadowOf(manager).setIsWallpaperSupported(true);
+
+    assertThat(manager.isWallpaperSupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void isWallpaperSupported_unsupported_shouldReturnFalse() {
+    shadowOf(manager).setIsWallpaperSupported(false);
+
+    assertThat(manager.isWallpaperSupported()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setStream_flagSystem_shouldCacheInMemory() throws Exception {
+    InputStream inputStream = null;
+    byte[] testImageBytes = getBytesFromBitmap(TEST_IMAGE_1);
+    try {
+      inputStream = new ByteArrayInputStream(testImageBytes);
+      manager.setStream(
+          inputStream,
+          /* visibleCropHint= */ null,
+          /* allowBackup= */ true,
+          WallpaperManager.FLAG_SYSTEM);
+
+      assertThat(getBytesFromBitmap(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)))
+          .isEqualTo(testImageBytes);
+      assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isNull();
+    } finally {
+      close(inputStream);
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setStream_flagLock_shouldCacheInMemory() throws Exception {
+    InputStream inputStream = null;
+    byte[] testImageBytes = getBytesFromBitmap(TEST_IMAGE_2);
+    try {
+      inputStream = new ByteArrayInputStream(testImageBytes);
+      manager.setStream(
+          inputStream,
+          /* visibleCropHint= */ null,
+          /* allowBackup= */ true,
+          WallpaperManager.FLAG_LOCK);
+
+      assertThat(getBytesFromBitmap(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)))
+          .isEqualTo(testImageBytes);
+      assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isNull();
+    } finally {
+      close(inputStream);
+    }
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void setStream_unsupportedFlag_shouldNotCacheInMemory() throws Exception {
+    InputStream inputStream = null;
+    byte[] testImageBytes = getBytesFromBitmap(TEST_IMAGE_2);
+    try {
+      inputStream = new ByteArrayInputStream(testImageBytes);
+      manager.setStream(
+          inputStream, /* visibleCropHint= */ null, /* allowBackup= */ true, UNSUPPORTED_FLAG);
+
+      assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isNull();
+      assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isNull();
+      assertThat(shadowOf(manager).getBitmap(UNSUPPORTED_FLAG)).isNull();
+    } finally {
+      close(inputStream);
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setWallpaperComponent_setWallpaperComponentPermissionNotGranted_shouldThrow() {
+    shadowOf(application).denyPermissions(SET_WALLPAPER_COMPONENT);
+
+    try {
+      manager.setWallpaperComponent(TEST_WALLPAPER_SERVICE);
+      fail();
+    } catch (SecurityException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void setWallpaperComponent_wallpaperServiceNotExist_shouldThrow() {
+    try {
+      manager.setWallpaperComponent(new ComponentName("Foo", "Bar"));
+      fail();
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void
+      setWallpaperComponent_liveWallpaperSet_shouldReturnLiveWallpaperComponentAndUnsetStaticWallpapers()
+          throws Exception {
+    manager.setWallpaperComponent(TEST_WALLPAPER_SERVICE);
+
+    assertThat(manager.getWallpaperInfo().getComponent()).isEqualTo(TEST_WALLPAPER_SERVICE);
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_LOCK)).isNull();
+    assertThat(shadowOf(manager).getBitmap(WallpaperManager.FLAG_SYSTEM)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getWallpaperInfo_noLiveWallpaperSet_shouldReturnNull() throws Exception {
+    assertThat(manager.getWallpaperInfo()).isNull();
+  }
+
+  @Config(minSdk = P)
+  public void
+      getWallpaperInfo_staticWallpaperWasDefault_liveWallpaperSet_shouldRemoveCachedStaticWallpaper()
+          throws Exception {
+    manager.setBitmap(
+        TEST_IMAGE_1,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_SYSTEM);
+    manager.setBitmap(
+        TEST_IMAGE_2,
+        /* visibleCropHint= */ null,
+        /* allowBackup= */ false,
+        WallpaperManager.FLAG_LOCK);
+
+    manager.setWallpaperComponent(TEST_WALLPAPER_SERVICE);
+
+    assertThat(manager.getWallpaperFile(WallpaperManager.FLAG_SYSTEM)).isNull();
+    assertThat(manager.getWallpaperFile(WallpaperManager.FLAG_LOCK)).isNull();
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void getDefaultWallpaperDimAmount_shouldBeZero() {
+    assertThat(
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(WallpaperManager.class)
+                .getWallpaperDimAmount())
+        .isEqualTo(0.0f);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setWallpaperDimAmount_shouldGetSameDimAmount() {
+    float testDimAmount = 0.1f;
+    ApplicationProvider.getApplicationContext()
+        .getSystemService(WallpaperManager.class)
+        .setWallpaperDimAmount(testDimAmount);
+
+    assertThat(
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(WallpaperManager.class)
+                .getWallpaperDimAmount())
+        .isEqualTo(testDimAmount);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setWallpaperDimAmount_belowRange_shouldBeBounded() {
+    float testDimAmount = -0.5f;
+    ApplicationProvider.getApplicationContext()
+        .getSystemService(WallpaperManager.class)
+        .setWallpaperDimAmount(testDimAmount);
+
+    assertThat(
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(WallpaperManager.class)
+                .getWallpaperDimAmount())
+        .isEqualTo(0f);
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void setWallpaperDimAmount_aboveRange_shouldBeBounded() {
+    float testDimAmount = 2.5f;
+    ApplicationProvider.getApplicationContext()
+        .getSystemService(WallpaperManager.class)
+        .setWallpaperDimAmount(testDimAmount);
+
+    assertThat(
+            ApplicationProvider.getApplicationContext()
+                .getSystemService(WallpaperManager.class)
+                .getWallpaperDimAmount())
+        .isEqualTo(1f);
+  }
+
+  private static byte[] getBytesFromFileDescriptor(FileDescriptor fileDescriptor)
+      throws IOException {
+    FileInputStream inputStream = null;
+    ByteArrayOutputStream outputStream = null;
+    try {
+      inputStream = new FileInputStream(fileDescriptor);
+      outputStream = new ByteArrayOutputStream();
+      byte[] buffer = new byte[1024];
+      int numOfBytes = 0;
+      while ((numOfBytes = inputStream.read(buffer, 0, buffer.length)) != -1) {
+        outputStream.write(buffer, 0, numOfBytes);
+      }
+      return outputStream.toByteArray();
+    } finally {
+      close(inputStream);
+      close(outputStream);
+    }
+  }
+
+  private static byte[] getBytesFromBitmap(Bitmap bitmap) throws IOException {
+    ByteArrayOutputStream stream = null;
+    try {
+      stream = new ByteArrayOutputStream();
+      bitmap.compress(Bitmap.CompressFormat.PNG, /* quality= */ 0, stream);
+      return stream.toByteArray();
+    } finally {
+      close(stream);
+    }
+  }
+
+  private static void close(@Nullable Closeable closeable) throws IOException {
+    if (closeable != null) {
+      closeable.close();
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWebSettingsTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebSettingsTest.java
new file mode 100644
index 0000000..3328041
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebSettingsTest.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.webkit.WebSettings;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link ShadowWebSettings} */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowWebSettingsTest {
+
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void setDefaultUserAgent() {
+    ShadowWebSettings.setDefaultUserAgent("Chrome/71.0.143.1");
+
+    assertThat(WebSettings.getDefaultUserAgent(context)).isEqualTo("Chrome/71.0.143.1");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWebStorageTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebStorageTest.java
new file mode 100644
index 0000000..3fa82c2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebStorageTest.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import android.webkit.WebStorage;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ShadowWebStorage} */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowWebStorageTest {
+
+  @Test
+  public void webStorageDoesNotCrash() {
+    WebStorage.getInstance().deleteAllData();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWebViewDatabaseTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebViewDatabaseTest.java
new file mode 100644
index 0000000..1d9b944
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebViewDatabaseTest.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.webkit.WebViewDatabase;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ShadowWebViewDatabase} */
+@RunWith(AndroidJUnit4.class)
+public final class ShadowWebViewDatabaseTest {
+  private WebViewDatabase subject;
+
+  @Before
+  public void setUp() {
+    subject = WebViewDatabase.getInstance(ApplicationProvider.getApplicationContext());
+  }
+
+  @After
+  public void tearDown() {
+    shadowOf(subject).resetDatabase();
+  }
+
+  @Test
+  public void getInstance_returnsSameInstance() {
+    assertThat(subject)
+        .isSameInstanceAs(WebViewDatabase.getInstance(ApplicationProvider.getApplicationContext()));
+  }
+
+  @Test
+  public void wasClearFormDataCalled_falseIfClearFormDataIsNotInvoked() {
+    assertThat(shadowOf(subject).wasClearFormDataCalled()).isFalse();
+  }
+
+  @Test
+  public void wasClearFormDataCalled_trueAfterClearFormFataInvocation() {
+    subject.clearFormData();
+
+    assertThat(shadowOf(subject).wasClearFormDataCalled()).isTrue();
+  }
+
+  @Test
+  public void resetClearFormData_resetsWasClearFormDataCalledState() {
+    subject.clearFormData();
+
+    shadowOf(subject).resetClearFormData();
+
+    assertThat(shadowOf(subject).wasClearFormDataCalled()).isFalse();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWebViewTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebViewTest.java
new file mode 100644
index 0000000..a11de5f
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWebViewTest.java
@@ -0,0 +1,732 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.pm.PackageInfo;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.ViewGroup.LayoutParams;
+import android.webkit.DownloadListener;
+import android.webkit.ValueCallback;
+import android.webkit.WebBackForwardList;
+import android.webkit.WebChromeClient;
+import android.webkit.WebMessagePort;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebView.HitTestResult;
+import android.webkit.WebViewClient;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWebViewTest {
+
+  private WebView webView;
+
+  @Before
+  public void setUp() throws Exception {
+    webView = new WebView(ApplicationProvider.getApplicationContext());
+  }
+
+  @Test
+  public void shouldRecordLastLoadedUrl() {
+    webView.loadUrl("http://example.com");
+    assertThat(shadowOf(webView).getLastLoadedUrl()).isEqualTo("http://example.com");
+  }
+
+  @Test
+  @Config(minSdk = O)
+  public void shouldPerformPageLoadCallbacksOnLoadUrl() {
+    WebChromeClient mockWebChromeClient = mock(WebChromeClient.class);
+    WebViewClient mockWebViewClient = mock(WebViewClient.class);
+    webView.setWebChromeClient(mockWebChromeClient);
+    webView.setWebViewClient(mockWebViewClient);
+    String url = "http://example.com";
+
+    webView.loadUrl(url);
+    shadowOf(getMainLooper()).idle();
+
+    verifyNoMoreInteractions(mockWebChromeClient);
+    verifyNoMoreInteractions(mockWebViewClient);
+
+    shadowOf(webView).performSuccessfulPageLoadClientCallbacks();
+    webView.loadUrl(url);
+    shadowOf(getMainLooper()).idle();
+
+    InOrder inOrder = inOrder(mockWebViewClient, mockWebChromeClient);
+    inOrder.verify(mockWebViewClient).onPageStarted(webView, url, null);
+    inOrder.verify(mockWebViewClient).onPageCommitVisible(webView, url);
+    inOrder.verify(mockWebChromeClient).onReceivedTitle(webView, url);
+    inOrder.verify(mockWebChromeClient).onProgressChanged(webView, 100);
+    inOrder.verify(mockWebViewClient).onPageFinished(webView, url);
+  }
+
+  @Test
+  public void shouldRecordLastLoadedUrlForRequestWithAdditionalHeaders() {
+    webView.loadUrl("http://example.com", null);
+    assertThat(shadowOf(webView).getLastLoadedUrl()).isEqualTo("http://example.com");
+    assertThat(shadowOf(webView).getLastAdditionalHttpHeaders()).isNull();
+
+    Map<String, String> additionalHttpHeaders = new HashMap<>(1);
+    additionalHttpHeaders.put("key1", "value1");
+    webView.loadUrl("http://example.com", additionalHttpHeaders);
+    assertThat(shadowOf(webView).getLastLoadedUrl()).isEqualTo("http://example.com");
+    assertThat(shadowOf(webView).getLastAdditionalHttpHeaders()).isNotNull();
+    assertThat(shadowOf(webView).getLastAdditionalHttpHeaders()).containsKey("key1");
+    assertThat(shadowOf(webView).getLastAdditionalHttpHeaders().get("key1")).isEqualTo("value1");
+  }
+
+  @Test
+  public void shouldRecordLastLoadedData() {
+    webView.loadData("<html><body><h1>Hi</h1></body></html>", "text/html", "utf-8");
+    ShadowWebView.LoadData lastLoadData = shadowOf(webView).getLastLoadData();
+    assertThat(lastLoadData.data).isEqualTo("<html><body><h1>Hi</h1></body></html>");
+    assertThat(lastLoadData.mimeType).isEqualTo("text/html");
+    assertThat(lastLoadData.encoding).isEqualTo("utf-8");
+  }
+
+  @Test
+  public void shouldRecordLastLoadDataWithBaseURL() {
+    webView.loadDataWithBaseURL(
+        "base/url", "<html><body><h1>Hi</h1></body></html>", "text/html", "utf-8", "history/url");
+    ShadowWebView.LoadDataWithBaseURL lastLoadData = shadowOf(webView).getLastLoadDataWithBaseURL();
+    assertThat(lastLoadData.baseUrl).isEqualTo("base/url");
+    assertThat(lastLoadData.data).isEqualTo("<html><body><h1>Hi</h1></body></html>");
+    assertThat(lastLoadData.mimeType).isEqualTo("text/html");
+    assertThat(lastLoadData.encoding).isEqualTo("utf-8");
+    assertThat(lastLoadData.historyUrl).isEqualTo("history/url");
+  }
+
+  @Test
+  public void shouldReturnSettings() {
+    WebSettings webSettings = webView.getSettings();
+
+    assertThat(webSettings).isNotNull();
+  }
+
+  @Test
+  public void shouldRecordWebViewClient() {
+    WebViewClient webViewClient = new WebViewClient();
+
+    assertThat(shadowOf(webView).getWebViewClient()).isNull();
+    webView.setWebViewClient(webViewClient);
+    assertThat(shadowOf(webView).getWebViewClient()).isSameInstanceAs(webViewClient);
+  }
+
+  @Test
+  public void shouldRecordWebChromeClient() {
+    WebChromeClient webChromeClient = new WebChromeClient();
+    assertThat(shadowOf(webView).getWebChromeClient()).isNull();
+    webView.setWebChromeClient(webChromeClient);
+    assertThat(shadowOf(webView).getWebChromeClient()).isSameInstanceAs(webChromeClient);
+  }
+
+  @Test
+  public void shouldRecordJavascriptInterfaces() {
+    String[] names = {"name1", "name2"};
+    for (String name : names) {
+      Object obj = new Object();
+      assertThat(shadowOf(webView).getJavascriptInterface(name)).isNull();
+      webView.addJavascriptInterface(obj, name);
+      assertThat(shadowOf(webView).getJavascriptInterface(name)).isSameInstanceAs(obj);
+    }
+  }
+
+  @Test
+  public void shouldRemoveJavascriptInterfaces() {
+    String name = "myJavascriptInterface";
+    webView.addJavascriptInterface(new Object(), name);
+    assertThat(shadowOf(webView).getJavascriptInterface(name)).isNotNull();
+    webView.removeJavascriptInterface(name);
+    assertThat(shadowOf(webView).getJavascriptInterface(name)).isNull();
+  }
+
+  @Test
+  public void canGoBack() {
+    webView.clearHistory();
+    assertThat(webView.canGoBack()).isFalse();
+    shadowOf(webView).pushEntryToHistory("fake.url");
+    shadowOf(webView).pushEntryToHistory("fake.url");
+    assertThat(webView.canGoBack()).isTrue();
+    webView.goBack();
+    assertThat(webView.canGoBack()).isFalse();
+  }
+
+  @Test
+  public void canGoForward() {
+    webView.clearHistory();
+    assertThat(webView.canGoForward()).isFalse();
+    shadowOf(webView).pushEntryToHistory("fake.url");
+    shadowOf(webView).pushEntryToHistory("fake.url");
+    assertThat(webView.canGoForward()).isFalse();
+    webView.goBack();
+    assertThat(webView.canGoForward()).isTrue();
+    webView.goForward();
+    assertThat(webView.canGoForward()).isFalse();
+  }
+
+  @Test
+  public void shouldStoreTheNumberOfTimesGoBackWasCalled() {
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(0);
+    webView.goBack();
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    // If there is no history (only one page), we shouldn't invoke go back.
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(0);
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    webView.goBack();
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(1);
+    webView.goBack();
+    webView.goBack();
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(3);
+    webView.goBack();
+    webView.goBack();
+    webView.goBack();
+    // We've gone back one too many times for the history, so we should only have 5 invocations.
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(5);
+  }
+
+  @Test
+  public void shouldStoreTheNumberOfTimesGoBackWasCalled_goBackOrForward() {
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(0);
+    webView.goBackOrForward(-1);
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    // If there is no history (only one page), we shouldn't invoke go back.
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(0);
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    webView.goBackOrForward(-1);
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(1);
+    webView.goBackOrForward(-2);
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(3);
+    webView.goBackOrForward(-3);
+    // We've gone back one too many times for the history, so we should only have 5 invocations.
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(5);
+  }
+
+  @Test
+  public void shouldStoreTheNumberOfTimesGoBackWasCalled_SetCanGoBack() {
+    shadowOf(webView).setCanGoBack(true);
+    webView.goBack();
+    webView.goBack();
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(2);
+    shadowOf(webView).setCanGoBack(false);
+    webView.goBack();
+    webView.goBack();
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldStoreTheNumberOfTimesGoBackWasCalled_SetCanGoBack_goBackOrForward() {
+    shadowOf(webView).setCanGoBack(true);
+    webView.goBackOrForward(-2);
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(2);
+    shadowOf(webView).setCanGoBack(false);
+    webView.goBackOrForward(-2);
+    assertThat(shadowOf(webView).getGoBackInvocations()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldStoreTheNumberOfTimesGoForwardWasCalled() {
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+
+    webView.goBackOrForward(-2);
+    webView.goForward();
+    webView.goForward();
+    assertThat(shadowOf(webView).getGoForwardInvocations()).isEqualTo(2);
+
+    webView.goForward();
+    assertThat(shadowOf(webView).getGoForwardInvocations()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldStoreTheNumberOfTimesGoForwardWasCalled_goBackOrForward() {
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+    shadowOf(webView).pushEntryToHistory("foo.bar");
+
+    webView.goBackOrForward(-2);
+    webView.goBackOrForward(2);
+    assertThat(shadowOf(webView).getGoForwardInvocations()).isEqualTo(2);
+
+    webView.goBackOrForward(2);
+    assertThat(shadowOf(webView).getGoForwardInvocations()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldUpdateUrlWhenGoBackIsCalled() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    webView.goBack();
+
+    assertThat(webView.getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldUpdateUrlWhenGoForwardIsCalled() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+    webView.goBack();
+
+    webView.goForward();
+
+    assertThat(webView.getUrl()).isEqualTo("foo2.bar");
+  }
+
+  @Test
+  public void shouldClearForwardHistoryWhenPushEntryToHistoryIsCalled() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    webView.goBack();
+    shadowOf(webView).pushEntryToHistory("foo3.bar");
+
+    assertThat(webView.getUrl()).isEqualTo("foo3.bar");
+    assertThat(webView.canGoForward()).isFalse();
+    assertThat(webView.canGoBack()).isTrue();
+    webView.goBack();
+    assertThat(webView.getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldNotPushEntryFromLoadUrlToHistoryUntilRequested() {
+    webView.loadUrl("foo1.bar");
+
+    assertThat(webView.getUrl()).isEqualTo("foo1.bar");
+    WebBackForwardList history = webView.copyBackForwardList();
+    assertThat(history.getSize()).isEqualTo(0);
+
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+
+    history = webView.copyBackForwardList();
+    assertThat(history.getSize()).isEqualTo(1);
+    assertThat(history.getItemAtIndex(0).getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldNotPushEntryFromLoadDataWithBaseUrlToHistoryUntilRequested() {
+    webView.loadDataWithBaseURL("foo1.bar", "data", "mime", "encoding", "foo1.bar");
+
+    assertThat(webView.getUrl()).isEqualTo("foo1.bar");
+    WebBackForwardList history = webView.copyBackForwardList();
+    assertThat(history.getSize()).isEqualTo(0);
+
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+
+    history = webView.copyBackForwardList();
+    assertThat(history.getSize()).isEqualTo(1);
+    assertThat(history.getItemAtIndex(0).getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldUpdateUrlWhenEntryPushedToHistory() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+
+    assertThat(webView.getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldClearForwardHistoryWhenPushEntryIntoHistoryIsCalled() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    webView.goBack();
+    shadowOf(webView).pushEntryToHistory("foo3.bar");
+
+    assertThat(webView.getUrl()).isEqualTo("foo3.bar");
+    assertThat(webView.canGoForward()).isFalse();
+    assertThat(webView.canGoBack()).isTrue();
+    webView.goBack();
+    assertThat(webView.getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldRecordClearCacheWithoutDiskFiles() {
+    assertThat(shadowOf(webView).wasClearCacheCalled()).isFalse();
+
+    webView.clearCache(false);
+    assertThat(shadowOf(webView).wasClearCacheCalled()).isTrue();
+    assertThat(shadowOf(webView).didClearCacheIncludeDiskFiles()).isFalse();
+  }
+
+  @Test
+  public void shouldRecordClearCacheWithDiskFiles() {
+    assertThat(shadowOf(webView).wasClearCacheCalled()).isFalse();
+
+    webView.clearCache(true);
+    assertThat(shadowOf(webView).wasClearCacheCalled()).isTrue();
+    assertThat(shadowOf(webView).didClearCacheIncludeDiskFiles()).isTrue();
+  }
+
+  @Test
+  public void shouldRecordClearFormData() {
+    assertThat(shadowOf(webView).wasClearFormDataCalled()).isFalse();
+    webView.clearFormData();
+    assertThat(shadowOf(webView).wasClearFormDataCalled()).isTrue();
+  }
+
+  @Test
+  public void shouldRecordClearHistory() {
+    assertThat(shadowOf(webView).wasClearHistoryCalled()).isFalse();
+    webView.clearHistory();
+    assertThat(shadowOf(webView).wasClearHistoryCalled()).isTrue();
+  }
+
+  @Test
+  public void shouldRecordClearView() {
+    assertThat(shadowOf(webView).wasClearViewCalled()).isFalse();
+    webView.clearView();
+    assertThat(shadowOf(webView).wasClearViewCalled()).isTrue();
+  }
+
+  @Test
+  public void getFavicon() {
+    assertThat(webView.getFavicon()).isNull();
+  }
+
+  @Test
+  public void getFavicon_withMockFaviconSet_returnsMockFavicon() {
+    Bitmap emptyFavicon = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+
+    shadowOf(webView).setFavicon(emptyFavicon);
+    assertThat(webView.getFavicon()).isEqualTo(emptyFavicon);
+  }
+
+  @Test
+  public void getFavicon_withMockFaviconSetMultipleTimes_returnsCorrectMockFavicon() {
+    Bitmap emptyFavicon = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+    Bitmap emptyFavicon2 = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+
+    shadowOf(webView).setFavicon(emptyFavicon);
+    assertThat(webView.getFavicon()).isEqualTo(emptyFavicon);
+    shadowOf(webView).setFavicon(emptyFavicon2);
+    assertThat(webView.getFavicon()).isEqualTo(emptyFavicon2);
+  }
+
+  @Test
+  public void getOriginalUrl() {
+    webView.clearHistory();
+    assertThat(webView.getOriginalUrl()).isNull();
+    webView.loadUrl("fake.url", null);
+    assertThat(webView.getOriginalUrl()).isEqualTo("fake.url");
+  }
+
+  @Test
+  public void getTitle() {
+    webView.clearHistory();
+    assertThat(webView.getTitle()).isNull();
+    webView.loadUrl("fake.url", null);
+    assertThat(webView.getTitle()).isEqualTo("fake.url");
+  }
+
+  @Test
+  public void getUrl() {
+    webView.clearHistory();
+    assertThat(webView.getUrl()).isNull();
+    webView.loadUrl("fake.url", null);
+    assertThat(webView.getUrl()).isEqualTo("fake.url");
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  @Config(minSdk = 19)
+  public void evaluateJavascript() {
+    ValueCallback<String> callback = mock(ValueCallback.class);
+    assertThat(shadowOf(webView).getLastEvaluatedJavascript()).isNull();
+    assertThat(shadowOf(webView).getLastEvaluatedJavascriptCallback()).isNull();
+    webView.evaluateJavascript("myScript", callback);
+    assertThat(shadowOf(webView).getLastEvaluatedJavascript()).isEqualTo("myScript");
+    assertThat(shadowOf(webView).getLastEvaluatedJavascriptCallback()).isSameInstanceAs(callback);
+  }
+
+  @Test
+  public void shouldRecordReloadInvocations() {
+    assertThat(shadowOf(webView).getReloadInvocations()).isEqualTo(0);
+    webView.reload();
+    assertThat(shadowOf(webView).getReloadInvocations()).isEqualTo(1);
+    webView.reload();
+    assertThat(shadowOf(webView).getReloadInvocations()).isEqualTo(2);
+  }
+
+  @Test
+  public void shouldRecordDestroy() {
+    assertThat(shadowOf(webView).wasDestroyCalled()).isFalse();
+    webView.destroy();
+    assertThat(shadowOf(webView).wasDestroyCalled()).isTrue();
+  }
+
+  @Test
+  public void shouldRecordOnPause() {
+    assertThat(shadowOf(webView).wasOnPauseCalled()).isFalse();
+    webView.onPause();
+    assertThat(shadowOf(webView).wasOnPauseCalled()).isTrue();
+  }
+
+  @Test
+  public void shouldRecordOnResume() {
+    assertThat(shadowOf(webView).wasOnResumeCalled()).isFalse();
+    webView.onResume();
+    assertThat(shadowOf(webView).wasOnResumeCalled()).isTrue();
+  }
+
+  @Test
+  public void shouldReturnPreviouslySetLayoutParams() {
+    assertThat(webView.getLayoutParams()).isNull();
+    LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    webView.setLayoutParams(params);
+    assertThat(webView.getLayoutParams()).isSameInstanceAs(params);
+  }
+
+  @Test
+  public void shouldSaveAndRestoreHistoryList() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    Bundle outState = new Bundle();
+    webView.saveState(outState);
+
+    WebView newWebView = new WebView(ApplicationProvider.getApplicationContext());
+    WebBackForwardList historyList = newWebView.restoreState(outState);
+
+    assertThat(newWebView.canGoBack()).isTrue();
+    assertThat(newWebView.getUrl()).isEqualTo("foo2.bar");
+
+    assertThat(historyList.getSize()).isEqualTo(2);
+    assertThat(historyList.getCurrentItem().getUrl()).isEqualTo("foo2.bar");
+  }
+
+  @Test
+  public void shouldSaveAndRestoreHistoryList_goBack() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+    webView.goBack();
+
+    Bundle outState = new Bundle();
+    webView.saveState(outState);
+
+    WebView newWebView = new WebView(ApplicationProvider.getApplicationContext());
+    WebBackForwardList historyList = newWebView.restoreState(outState);
+
+    assertThat(newWebView.canGoBack()).isFalse();
+    assertThat(newWebView.canGoForward()).isTrue();
+    assertThat(newWebView.getUrl()).isEqualTo("foo1.bar");
+
+    assertThat(historyList.getSize()).isEqualTo(2);
+    assertThat(historyList.getCurrentItem().getUrl()).isEqualTo("foo1.bar");
+  }
+
+  @Test
+  public void shouldSaveAndRestoreHistoryList_noPushedEntries() {
+    webView.loadUrl("foo1.bar");
+
+    Bundle outState = new Bundle();
+    webView.saveState(outState);
+
+    WebView newWebView = new WebView(ApplicationProvider.getApplicationContext());
+    WebBackForwardList historyList = newWebView.restoreState(outState);
+
+    assertThat(newWebView.canGoBack()).isFalse();
+    assertThat(newWebView.canGoForward()).isFalse();
+    assertThat(newWebView.getUrl()).isNull();
+
+    assertThat(historyList).isNull();
+  }
+
+  @Test
+  public void shouldReturnHistoryFromSaveState() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    Bundle outState = new Bundle();
+    WebBackForwardList historyList = webView.saveState(outState);
+
+    assertThat(historyList.getSize()).isEqualTo(2);
+    assertThat(historyList.getCurrentItem().getUrl()).isEqualTo("foo2.bar");
+  }
+
+  @Test
+  public void shouldReturnNullFromRestoreStateIfNoHistoryAvailable() {
+    Bundle inState = new Bundle();
+    WebBackForwardList historyList = webView.restoreState(inState);
+
+    assertThat(historyList).isNull();
+  }
+
+  @Test
+  public void shouldCopyBackForwardListWhenEmpty() {
+    WebBackForwardList historyList = webView.copyBackForwardList();
+
+    assertThat(historyList.getSize()).isEqualTo(0);
+    assertThat(historyList.getCurrentIndex()).isEqualTo(-1);
+    assertThat(historyList.getCurrentItem()).isNull();
+  }
+
+  @Test
+  public void shouldCopyBackForwardListWhenPopulated() {
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    WebBackForwardList historyList = webView.copyBackForwardList();
+
+    assertThat(historyList.getSize()).isEqualTo(2);
+    assertThat(historyList.getCurrentItem().getUrl()).isEqualTo("foo2.bar");
+  }
+
+  @Test
+  public void shouldReturnCopyFromCopyBackForwardList() {
+    WebBackForwardList historyList = webView.copyBackForwardList();
+
+    // Adding history after copying should not affect the copy.
+    shadowOf(webView).pushEntryToHistory("foo1.bar");
+    shadowOf(webView).pushEntryToHistory("foo2.bar");
+
+    assertThat(historyList.getSize()).isEqualTo(0);
+    assertThat(historyList.getCurrentIndex()).isEqualTo(-1);
+    assertThat(historyList.getCurrentItem()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = 26)
+  public void shouldReturnNullForGetCurrentWebViewPackageIfNotSet() {
+    assertThat(WebView.getCurrentWebViewPackage()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = 26)
+  public void shouldReturnStoredPackageInfoForGetCurrentWebViewPackageIfSet() {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = "org.robolectric.shadows.shadowebviewtest";
+    ShadowWebView.setCurrentWebViewPackage(packageInfo);
+    assertThat(WebView.getCurrentWebViewPackage()).isEqualTo(packageInfo);
+  }
+
+  @Test
+  public void getHitTestResult() {
+    shadowOf(webView)
+        .setHitTestResult(ShadowWebView.createHitTestResult(HitTestResult.ANCHOR_TYPE, "extra"));
+
+    HitTestResult result = webView.getHitTestResult();
+
+    assertThat(result.getType()).isEqualTo(HitTestResult.ANCHOR_TYPE);
+    assertThat(result.getExtra()).isEqualTo("extra");
+  }
+
+  @Test
+  @Config(minSdk = 21)
+  public void canEnableSlowWholeDocumentDraw() {
+    WebView.enableSlowWholeDocumentDraw();
+  }
+
+  @Test
+  @Config(minSdk = 21)
+  public void canClearClientCertPreferences() {
+    WebView.clearClientCertPreferences(null);
+  }
+
+  @Test
+  @Config(minSdk = 27)
+  public void canStartSafeBrowsing() {
+    WebView.startSafeBrowsing(null, null);
+  }
+
+  @Test
+  @Config(minSdk = 27)
+  public void shouldReturnStoredUrlForGetSafeBrowsingPrivacyPolicyUrl() {
+    assertThat(WebView.getSafeBrowsingPrivacyPolicyUrl()).isNull();
+  }
+
+  @Test
+  @Config(minSdk = 19)
+  public void canSetWebContentsDebuggingEnabled() {
+    WebView.setWebContentsDebuggingEnabled(false);
+    WebView.setWebContentsDebuggingEnabled(true);
+  }
+
+  @Test
+  @Config(minSdk = 28)
+  public void shouldReturnClassLoaderForGetWebViewClassLoader() {
+    assertThat(WebView.getWebViewClassLoader()).isNull();
+  }
+
+  @Test
+  public void getBackgroundColor_backgroundColorNotSet_returnsZero() {
+    assertThat(shadowOf(webView).getBackgroundColor()).isEqualTo(0);
+  }
+
+  @Test
+  public void getBackgroundColor_backgroundColorHasBeenSet_returnsCorrectBackgroundColor() {
+    webView.setBackgroundColor(Color.RED);
+
+    assertThat(shadowOf(webView).getBackgroundColor()).isEqualTo(Color.RED);
+  }
+
+  @Test
+  public void getBackgroundColor_backgroundColorSetMultipleTimes_returnsLastBackgroundColor() {
+    webView.setBackgroundColor(Color.RED);
+    webView.setBackgroundColor(Color.BLUE);
+    webView.setBackgroundColor(Color.GREEN);
+
+    assertThat(shadowOf(webView).getBackgroundColor()).isEqualTo(Color.GREEN);
+  }
+
+  @Test
+  public void getDownloadListener_noListenerSet_returnsNull() {
+    assertThat(shadowOf(webView).getDownloadListener()).isEqualTo(null);
+  }
+
+  @Test
+  public void getDownloadListener_listenerSet_returnsLastSetListener() {
+    webView.setDownloadListener(mock(DownloadListener.class));
+
+    DownloadListener lastListener = mock(DownloadListener.class);
+    webView.setDownloadListener(lastListener);
+
+    assertThat(shadowOf(webView).getDownloadListener()).isEqualTo(lastListener);
+  }
+
+  @Test
+  public void restoreAndSaveState() {
+    webView.restoreState(new Bundle());
+    webView.saveState(new Bundle());
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void shouldCreateWebMessageChannel() {
+    WebMessagePort[] channel = shadowOf(webView).createWebMessageChannel();
+
+    assertThat(channel[0]).isNotNull();
+    assertThat(channel[1]).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = M)
+  public void getCreatedPorts_returnsCreatedPortsList() {
+    shadowOf(webView).createWebMessageChannel();
+    assertThat(shadowOf(webView).getCreatedPorts()).isNotEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiAwareManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiAwareManagerTest.java
new file mode 100644
index 0000000..1c7ce68
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiAwareManagerTest.java
@@ -0,0 +1,197 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.content.Context;
+import android.net.wifi.aware.AttachCallback;
+import android.net.wifi.aware.DiscoverySessionCallback;
+import android.net.wifi.aware.PublishConfig;
+import android.net.wifi.aware.PublishDiscoverySession;
+import android.net.wifi.aware.SubscribeConfig;
+import android.net.wifi.aware.SubscribeDiscoverySession;
+import android.net.wifi.aware.WifiAwareManager;
+import android.net.wifi.aware.WifiAwareSession;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.Looper;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test for {@link ShadowWifiAwareManager} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = P)
+public final class ShadowWifiAwareManagerTest {
+  private WifiAwareManager wifiAwareManager;
+  private Binder binder;
+  private Handler handler;
+  private Looper looper;
+  private WifiAwareSession session;
+  private static final int CLIENT_ID = 1;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    wifiAwareManager = (WifiAwareManager) context.getSystemService(Context.WIFI_AWARE_SERVICE);
+    binder = new Binder();
+    handler = new Handler();
+    looper = handler.getLooper();
+    session = ShadowWifiAwareManager.newWifiAwareSession(wifiAwareManager, binder, CLIENT_ID);
+    shadowOf(wifiAwareManager).setWifiAwareSession(session);
+  }
+
+  @After
+  public void tearDown() {
+    session.close();
+  }
+
+  @Test
+  public void setAvailable_shouldUpdateWithAvailableStatus() {
+    boolean available = false;
+    shadowOf(wifiAwareManager).setAvailable(available);
+    assertThat(wifiAwareManager.isAvailable()).isEqualTo(available);
+  }
+
+  @Test
+  public void attach_shouldAttachIfSessionDetachedAndWifiAwareManagerAvailable() {
+    shadowOf(wifiAwareManager).setAvailable(true);
+    shadowOf(wifiAwareManager).setSessionDetached(true);
+    TestAttachCallback testAttachCallback = new TestAttachCallback();
+    wifiAwareManager.attach(testAttachCallback, handler);
+    shadowMainLooper().idle();
+    assertThat(testAttachCallback.success).isTrue();
+  }
+
+  @Test
+  public void attach_shouldNotAttachSessionIfSessionDetachedAndWifiAwareUnavailable()
+      throws Exception {
+    shadowOf(wifiAwareManager).setAvailable(false);
+    shadowOf(wifiAwareManager).setSessionDetached(true);
+    TestAttachCallback testAttachCallback = new TestAttachCallback();
+    wifiAwareManager.attach(testAttachCallback, handler);
+    shadowMainLooper().idle();
+    assertThat(testAttachCallback.success).isFalse();
+  }
+
+  @Test
+  public void publish_shouldPublishServiceIfWifiAwareAvailable() {
+    int sessionId = 1;
+    PublishConfig config = new PublishConfig.Builder().setServiceName("service").build();
+    PublishDiscoverySession publishDiscoverySession =
+        ShadowWifiAwareManager.newPublishDiscoverySession(wifiAwareManager, CLIENT_ID, sessionId);
+    shadowOf(wifiAwareManager).setAvailable(true);
+    shadowOf(wifiAwareManager).setDiscoverySessionToPublish(publishDiscoverySession);
+    TestDiscoverySessionCallback testDiscoverySessionCallback = new TestDiscoverySessionCallback();
+    shadowOf(wifiAwareManager).publish(CLIENT_ID, looper, config, testDiscoverySessionCallback);
+    shadowMainLooper().idle();
+    assertThat(testDiscoverySessionCallback.publishSuccess).isTrue();
+    publishDiscoverySession.close();
+  }
+
+  @Test
+  public void publish_shouldPublishServiceIfWifiAwareUnavailable() {
+    int sessionId = 2;
+    PublishConfig config = new PublishConfig.Builder().setServiceName("service").build();
+    PublishDiscoverySession publishDiscoverySession =
+        ShadowWifiAwareManager.newPublishDiscoverySession(wifiAwareManager, CLIENT_ID, sessionId);
+    shadowOf(wifiAwareManager).setAvailable(false);
+    shadowOf(wifiAwareManager).setDiscoverySessionToPublish(publishDiscoverySession);
+    TestDiscoverySessionCallback testDiscoverySessionCallback = new TestDiscoverySessionCallback();
+    wifiAwareManager.publish(CLIENT_ID, looper, config, testDiscoverySessionCallback);
+    shadowMainLooper().idle();
+    assertThat(testDiscoverySessionCallback.publishSuccess).isFalse();
+    publishDiscoverySession.close();
+  }
+
+  @Test
+  public void subscribe_shouldSubscribeIfWifiAwareAvailable() {
+    int sessionId = 3;
+    SubscribeConfig config = new SubscribeConfig.Builder().setServiceName("service").build();
+    SubscribeDiscoverySession subscribeDiscoverySession =
+        ShadowWifiAwareManager.newSubscribeDiscoverySession(wifiAwareManager, CLIENT_ID, sessionId);
+    shadowOf(wifiAwareManager).setAvailable(true);
+    shadowOf(wifiAwareManager).setDiscoverySessionToSubscribe(subscribeDiscoverySession);
+    TestDiscoverySessionCallback testDiscoverySessionCallback = new TestDiscoverySessionCallback();
+    wifiAwareManager.subscribe(CLIENT_ID, looper, config, testDiscoverySessionCallback);
+    shadowMainLooper().idle();
+    assertThat(testDiscoverySessionCallback.subscribeSuccess).isTrue();
+    subscribeDiscoverySession.close();
+  }
+
+  @Test
+  public void subscribe_shouldNotSubscribeIfWifiAwareUnavailable() {
+    int sessionId = 4;
+    SubscribeConfig config = new SubscribeConfig.Builder().setServiceName("service").build();
+    SubscribeDiscoverySession subscribeDiscoverySession =
+        ShadowWifiAwareManager.newSubscribeDiscoverySession(wifiAwareManager, CLIENT_ID, sessionId);
+    shadowOf(wifiAwareManager).setAvailable(false);
+    shadowOf(wifiAwareManager).setDiscoverySessionToSubscribe(subscribeDiscoverySession);
+    TestDiscoverySessionCallback testDiscoverySessionCallback = new TestDiscoverySessionCallback();
+    wifiAwareManager.subscribe(CLIENT_ID, looper, config, testDiscoverySessionCallback);
+    shadowMainLooper().idle();
+    assertThat(testDiscoverySessionCallback.subscribeSuccess).isFalse();
+    subscribeDiscoverySession.close();
+  }
+
+  @Test
+  public void canCreatePublishDiscoverySessionViaNewInstance() {
+    int sessionId = 1;
+    try (PublishDiscoverySession publishDiscoverySession =
+        ShadowWifiAwareManager.newPublishDiscoverySession(wifiAwareManager, CLIENT_ID, sessionId)) {
+      assertThat(publishDiscoverySession).isNotNull();
+    }
+  }
+
+  @Test
+  public void canCreateSubscribeDiscoverySessionViaNewInstance() {
+    int sessionId = 1;
+    SubscribeDiscoverySession subscribeDiscoverySession =
+        ShadowWifiAwareManager.newSubscribeDiscoverySession(wifiAwareManager, CLIENT_ID, sessionId);
+    assertThat(subscribeDiscoverySession).isNotNull();
+    subscribeDiscoverySession.close();
+  }
+
+  @Test
+  public void canCreateWifiAwareSessionViaNewInstance() {
+    WifiAwareSession wifiAwareSession =
+        ShadowWifiAwareManager.newWifiAwareSession(wifiAwareManager, binder, CLIENT_ID);
+    assertThat(wifiAwareSession).isNotNull();
+    wifiAwareSession.close();
+  }
+
+  private static class TestAttachCallback extends AttachCallback {
+    private boolean success;
+
+    @Override
+    public void onAttached(WifiAwareSession session) {
+      success = true;
+    }
+
+    @Override
+    public void onAttachFailed() {
+      success = false;
+    }
+  }
+
+  private static class TestDiscoverySessionCallback extends DiscoverySessionCallback {
+    private boolean publishSuccess;
+    private boolean subscribeSuccess;
+
+    @Override
+    public void onPublishStarted(PublishDiscoverySession publishDiscoverySession) {
+      publishSuccess = true;
+    }
+
+    @Override
+    public void onSubscribeStarted(SubscribeDiscoverySession subscribeDiscoverySession) {
+      subscribeSuccess = true;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiConfigurationTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiConfigurationTest.java
new file mode 100644
index 0000000..0b30d7e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiConfigurationTest.java
@@ -0,0 +1,140 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotNull;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiEnterpriseConfig;
+import android.os.Build;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWifiConfigurationTest {
+  @Test
+  public void shouldSetTheBitSetsAndWepKeyArrays() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    assertNotNull(wifiConfiguration.allowedAuthAlgorithms);
+  }
+
+  @Test
+  public void shouldCopy() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+
+    wifiConfiguration.networkId = 1;
+    wifiConfiguration.SSID = "SSID";
+    wifiConfiguration.BSSID = "BSSID";
+    wifiConfiguration.preSharedKey = "preSharedKey";
+    wifiConfiguration.status = 666;
+    wifiConfiguration.wepTxKeyIndex = 777;
+    wifiConfiguration.priority = 2;
+    wifiConfiguration.hiddenSSID = true;
+    wifiConfiguration.allowedKeyManagement.set(1);
+    wifiConfiguration.allowedProtocols.set(2);
+    wifiConfiguration.allowedAuthAlgorithms.set(3);
+    wifiConfiguration.allowedPairwiseCiphers.set(4);
+    wifiConfiguration.allowedGroupCiphers.set(5);
+    wifiConfiguration.wepKeys[0] = "0";
+    wifiConfiguration.wepKeys[1] = "1";
+    wifiConfiguration.wepKeys[2] = "2";
+    wifiConfiguration.wepKeys[3] = "3";
+
+    WifiConfiguration copy = shadowOf(wifiConfiguration).copy();
+
+    assertThat(copy.networkId).isEqualTo(1);
+    assertThat(copy.SSID).isEqualTo("SSID");
+    assertThat(copy.BSSID).isEqualTo("BSSID");
+    assertThat(copy.preSharedKey).isEqualTo("preSharedKey");
+    assertThat(copy.status).isEqualTo(666);
+    assertThat(copy.wepTxKeyIndex).isEqualTo(777);
+    assertThat(copy.priority).isEqualTo(2);
+    assertThat(copy.hiddenSSID).isTrue();
+    assertThat(copy.allowedKeyManagement.get(1)).isTrue();
+    assertThat(copy.allowedProtocols.get(2)).isTrue();
+    assertThat(copy.allowedAuthAlgorithms.get(3)).isTrue();
+    assertThat(copy.allowedPairwiseCiphers.get(4)).isTrue();
+    assertThat(copy.allowedGroupCiphers.get(5)).isTrue();
+    assertThat(copy.wepKeys[0]).isEqualTo("0");
+    assertThat(copy.wepKeys[1]).isEqualTo("1");
+    assertThat(copy.wepKeys[2]).isEqualTo("2");
+    assertThat(copy.wepKeys[3]).isEqualTo("3");
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR2)
+  @Test
+  public void shouldCopy_sdk18() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+
+    wifiConfiguration.networkId = 1;
+    wifiConfiguration.SSID = "SSID";
+
+    WifiEnterpriseConfig enterpriseConfig = new WifiEnterpriseConfig();
+    enterpriseConfig.setIdentity("fake identity");
+    enterpriseConfig.setPassword("fake password");
+    wifiConfiguration.enterpriseConfig = enterpriseConfig;
+
+    WifiConfiguration copy = shadowOf(wifiConfiguration).copy();
+    assertThat(copy.networkId).isEqualTo(1);
+    assertThat(copy.SSID).isEqualTo("SSID");
+    assertThat(copy.enterpriseConfig).isNotNull();
+    assertThat(copy.enterpriseConfig.getIdentity()).isEqualTo("fake identity");
+    assertThat(copy.enterpriseConfig.getPassword()).isEqualTo("fake password");
+  }
+
+  @Config(sdk = Build.VERSION_CODES.LOLLIPOP)
+  @Test
+  public void shouldCopy_sdk21() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+
+    wifiConfiguration.networkId = 1;
+    wifiConfiguration.SSID = "SSID";
+    wifiConfiguration.creatorUid = 888;
+
+    WifiConfiguration copy = shadowOf(wifiConfiguration).copy();
+
+    assertThat(copy.networkId).isEqualTo(1);
+    assertThat(copy.SSID).isEqualTo("SSID");
+    assertThat(copy.creatorUid).isEqualTo(888);
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.M)
+  @Test
+  public void shouldCopy_sdk23() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+
+    wifiConfiguration.networkId = 1;
+    wifiConfiguration.SSID = "SSID";
+    wifiConfiguration.creatorName = "creatorName";
+    wifiConfiguration.creatorUid = 888;
+
+    WifiConfiguration copy = shadowOf(wifiConfiguration).copy();
+
+    assertThat(copy.networkId).isEqualTo(1);
+    assertThat(copy.SSID).isEqualTo("SSID");
+    assertThat(copy.creatorName).isEqualTo("creatorName");
+    assertThat(copy.creatorUid).isEqualTo(888);
+  }
+
+  @SuppressWarnings("CheckReturnValue")
+  @Test
+  public void toStringDoesntCrash() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.toString();
+
+    wifiConfiguration.SSID = "SSID";
+    wifiConfiguration.toString();
+  }
+
+  @Config(minSdk = Build.VERSION_CODES.R)
+  @Test
+  public void setSecurityParams_shouldWorkCorrectly() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.setSecurityParams(WifiConfiguration.SECURITY_TYPE_OPEN);
+
+    assertThat(shadowOf(wifiConfiguration).getSecurityTypes())
+        .containsExactly(WifiConfiguration.SECURITY_TYPE_OPEN);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiInfoTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiInfoTest.java
new file mode 100644
index 0000000..6351aed
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiInfoTest.java
@@ -0,0 +1,134 @@
+package org.robolectric.shadows;
+
+import static android.content.Context.WIFI_SERVICE;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.RuntimeEnvironment.getApplication;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.net.wifi.SupplicantState;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.net.InetAddress;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWifiInfoTest {
+
+  private WifiManager wifiManager;
+
+  @Before
+  public void setUp() {
+    wifiManager = (WifiManager) getApplication().getSystemService(WIFI_SERVICE);
+  }
+
+  @Test
+  public void newInstance_shouldNotCrash() {
+    assertThat(ShadowWifiInfo.newInstance()).isNotNull();
+  }
+
+  @Test
+  public void shouldReturnIpAddress() throws Exception {
+    String ipAddress = "192.168.0.1";
+    int expectedIpAddress = 16820416;
+
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+    shadowOf(wifiInfo).setInetAddress(InetAddress.getByName(ipAddress));
+    assertThat(wifiInfo.getIpAddress()).isEqualTo(expectedIpAddress);
+  }
+
+  @Test
+  public void shouldReturnMacAddress() {
+
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+
+    shadowOf(wifiInfo).setMacAddress("mac address");
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getMacAddress()).isEqualTo("mac address");
+  }
+
+  @Test
+  public void shouldReturnSSID() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+
+    shadowOf(wifiInfo).setSSID("SSID");
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getSSID()).contains("SSID");
+  }
+
+  @Test
+  public void shouldReturnBSSID() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getBSSID()).isEqualTo(null);
+
+    shadowOf(wifiInfo).setBSSID("BSSID");
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getBSSID()).isEqualTo("BSSID");
+  }
+
+  @Test
+  public void shouldReturnRssi() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+
+    shadowOf(wifiInfo).setRssi(10);
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getRssi()).isEqualTo(10);
+  }
+
+  @Test
+  public void shouldReturnLinkSpeed() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getLinkSpeed()).isEqualTo(-1);
+
+    shadowOf(wifiInfo).setLinkSpeed(10);
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getLinkSpeed()).isEqualTo(10);
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void shouldReturnFrequency() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getFrequency()).isEqualTo(-1);
+
+    shadowOf(wifiInfo).setFrequency(10);
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getFrequency()).isEqualTo(10);
+  }
+
+  @Test
+  public void shouldReturnNetworkId() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getNetworkId()).isEqualTo(-1);
+
+    shadowOf(wifiInfo).setNetworkId(10);
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getNetworkId()).isEqualTo(10);
+  }
+
+  @Test
+  public void shouldReturnSupplicantState() {
+    WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+
+    shadowOf(wifiInfo).setSupplicantState(SupplicantState.COMPLETED);
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getSupplicantState()).isEqualTo(SupplicantState.COMPLETED);
+
+    shadowOf(wifiInfo).setSupplicantState(SupplicantState.DISCONNECTED);
+
+    wifiInfo = wifiManager.getConnectionInfo();
+    assertThat(wifiInfo.getSupplicantState()).isEqualTo(SupplicantState.DISCONNECTED);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java
new file mode 100644
index 0000000..593327a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java
@@ -0,0 +1,752 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.DhcpInfo;
+import android.net.NetworkInfo;
+import android.net.wifi.ScanResult;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.MulticastLock;
+import android.net.wifi.WifiUsabilityStatsEntry;
+import android.os.Build;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWifiManagerTest {
+  private WifiManager wifiManager;
+
+  @Before
+  public void setUp() throws Exception {
+    wifiManager =
+        (WifiManager)
+            ApplicationProvider.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+  }
+
+  @Test
+  public void shouldReturnWifiInfo() {
+    assertThat(wifiManager.getConnectionInfo().getClass()).isEqualTo(WifiInfo.class);
+  }
+
+  @Test
+  public void setWifiInfo_shouldUpdateWifiInfo() {
+    WifiInfo wifiInfo = new WifiInfo();
+    shadowOf(wifiManager).setConnectionInfo(wifiInfo);
+    assertThat(wifiManager.getConnectionInfo()).isSameInstanceAs(wifiInfo);
+  }
+
+  @Test
+  public void setWifiEnabled_shouldThrowSecurityExceptionWhenAccessWifiStatePermissionNotGranted() {
+    shadowOf(wifiManager).setAccessWifiStatePermission(false);
+    try {
+      wifiManager.setWifiEnabled(true);
+      fail("SecurityException not thrown");
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void isWifiEnabled_shouldThrowSecurityExceptionWhenAccessWifiStatePermissionNotGranted() {
+    shadowOf(wifiManager).setAccessWifiStatePermission(false);
+    try {
+      wifiManager.isWifiEnabled();
+      fail("SecurityException not thrown");
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getWifiState_shouldThrowSecurityExceptionWhenAccessWifiStatePermissionNotGranted() {
+    shadowOf(wifiManager).setAccessWifiStatePermission(false);
+    try {
+      wifiManager.getWifiState();
+      fail("SecurityException not thrown");
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void
+      getConnectionInfo_shouldThrowSecurityExceptionWhenAccessWifiStatePermissionNotGranted() {
+    shadowOf(wifiManager).setAccessWifiStatePermission(false);
+    try {
+      wifiManager.getConnectionInfo();
+      fail("SecurityException not thrown");
+    } catch (SecurityException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void getWifiState() {
+    wifiManager.setWifiEnabled(true);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_ENABLED);
+
+    wifiManager.setWifiEnabled(false);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_DISABLED);
+  }
+
+  @Test
+  public void startScan() {
+    // By default startScan() succeeds.
+    assertThat(wifiManager.startScan()).isTrue();
+
+    shadowOf(wifiManager).setStartScanSucceeds(true);
+    assertThat(wifiManager.startScan()).isTrue();
+
+    shadowOf(wifiManager).setStartScanSucceeds(false);
+    assertThat(wifiManager.startScan()).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR2)
+  public void getIsScanAlwaysAvailable() {
+    shadowOf(wifiManager).setIsScanAlwaysAvailable(true);
+    assertThat(wifiManager.isScanAlwaysAvailable()).isEqualTo(true);
+
+    shadowOf(wifiManager).setIsScanAlwaysAvailable(false);
+    assertThat(wifiManager.isScanAlwaysAvailable()).isEqualTo(false);
+  }
+
+  @Test
+  public void shouldEnableNetworks() {
+    wifiManager.enableNetwork(666, true);
+    Pair<Integer, Boolean> lastEnabled = shadowOf(wifiManager).getLastEnabledNetwork();
+    assertThat(lastEnabled).isEqualTo(new Pair<>(666, true));
+
+    wifiManager.enableNetwork(777, false);
+    lastEnabled = shadowOf(wifiManager).getLastEnabledNetwork();
+    assertThat(lastEnabled).isEqualTo(new Pair<>(777, false));
+
+    boolean enabledNetwork = shadowOf(wifiManager).isNetworkEnabled(666);
+    assertThat(enabledNetwork).isTrue();
+
+    enabledNetwork = shadowOf(wifiManager).isNetworkEnabled(777);
+    assertThat(enabledNetwork).isTrue();
+  }
+
+  @Test
+  public void shouldDisableNetwork() {
+    wifiManager.enableNetwork(666, true);
+    boolean enabledNetwork = shadowOf(wifiManager).isNetworkEnabled(666);
+    assertThat(enabledNetwork).isTrue();
+
+    wifiManager.disableNetwork(666);
+    enabledNetwork = shadowOf(wifiManager).isNetworkEnabled(666);
+    assertThat(enabledNetwork).isFalse();
+  }
+
+  @Test
+  public void shouldReturnSetScanResults() {
+    List<ScanResult> scanResults = new ArrayList<>();
+    shadowOf(wifiManager).setScanResults(scanResults);
+    assertThat(wifiManager.getScanResults()).isSameInstanceAs(scanResults);
+  }
+
+  @Test
+  public void shouldReturnDhcpInfo() {
+    DhcpInfo dhcpInfo = new DhcpInfo();
+    shadowOf(wifiManager).setDhcpInfo(dhcpInfo);
+    assertThat(wifiManager.getDhcpInfo()).isSameInstanceAs(dhcpInfo);
+  }
+
+  @Test
+  public void shouldRecordTheLastAddedNetwork() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.networkId = -1;
+    int networkId = wifiManager.addNetwork(wifiConfiguration);
+    assertThat(networkId).isEqualTo(0);
+    assertThat(wifiManager.getConfiguredNetworks().get(0)).isNotSameInstanceAs(wifiConfiguration);
+    assertThat(wifiConfiguration.networkId).isEqualTo(-1);
+    assertThat(wifiManager.getConfiguredNetworks().get(0).networkId).isEqualTo(0);
+    assertThat(wifiManager.addNetwork(/* wifiConfiguration= */ null)).isEqualTo(-1);
+
+    WifiConfiguration anotherConfig = new WifiConfiguration();
+    assertThat(wifiManager.addNetwork(anotherConfig)).isEqualTo(1);
+    assertThat(anotherConfig.networkId).isEqualTo(-1);
+    assertThat(wifiManager.getConfiguredNetworks().get(1).networkId).isEqualTo(1);
+  }
+
+  @Test
+  public void updateNetwork_shouldReplaceNetworks() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.networkId = -1;
+    wifiManager.addNetwork(wifiConfiguration);
+
+    WifiConfiguration anotherConfig = new WifiConfiguration();
+    int networkId = wifiManager.addNetwork(anotherConfig);
+
+    assertThat(networkId).isEqualTo(1);
+    WifiConfiguration configuration = new WifiConfiguration();
+    configuration.networkId = networkId;
+    configuration.priority = 44;
+
+    assertThat(wifiManager.updateNetwork(configuration)).isEqualTo(networkId);
+    List<WifiConfiguration> configuredNetworks = wifiManager.getConfiguredNetworks();
+    assertThat(configuredNetworks.size()).isEqualTo(2);
+    assertThat(configuration.priority).isEqualTo(44);
+    assertThat(configuredNetworks.get(1).priority).isEqualTo(44);
+  }
+
+  @Test
+  public void updateNetworkTests_permissions() {
+    int networkId = 1;
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.networkId = networkId;
+
+    // By default we should have permission to update networks.
+    assertThat(wifiManager.updateNetwork(wifiConfiguration)).isEqualTo(networkId);
+
+    // If we don't have permission to update, updateNetwork will return -1.
+    shadowOf(wifiManager).setUpdateNetworkPermission(networkId, /* hasPermission = */ false);
+    assertThat(wifiManager.updateNetwork(wifiConfiguration)).isEqualTo(-1);
+
+    // Ensure updates can occur if permission is restored.
+    shadowOf(wifiManager).setUpdateNetworkPermission(networkId, /* hasPermission = */ true);
+    assertThat(wifiManager.updateNetwork(wifiConfiguration)).isEqualTo(networkId);
+  }
+
+  @Test
+  public void removeNetwork() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.networkId = 123;
+    wifiManager.addNetwork(wifiConfiguration);
+
+    List<WifiConfiguration> list = wifiManager.getConfiguredNetworks();
+    assertThat(list.size()).isEqualTo(1);
+
+    wifiManager.removeNetwork(0);
+
+    list = wifiManager.getConfiguredNetworks();
+    assertThat(list.size()).isEqualTo(0);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+  public void getPrivilegedConfiguredNetworks_shouldReturnConfiguredNetworks() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.networkId = 123;
+    wifiManager.addNetwork(wifiConfiguration);
+
+    List<WifiConfiguration> list = wifiManager.getPrivilegedConfiguredNetworks();
+    assertThat(list.size()).isEqualTo(1);
+
+    wifiManager.removeNetwork(0);
+
+    list = wifiManager.getPrivilegedConfiguredNetworks();
+    assertThat(list.size()).isEqualTo(0);
+  }
+
+  @Test
+  public void updateNetwork_shouldRejectNullandNewConfigs() {
+    WifiConfiguration config = new WifiConfiguration();
+    config.networkId = -1;
+    assertThat(wifiManager.updateNetwork(config)).isEqualTo(-1);
+    assertThat(wifiManager.updateNetwork(null)).isEqualTo(-1);
+    assertThat(wifiManager.getConfiguredNetworks()).isEmpty();
+  }
+
+  @Test
+  public void shouldSaveConfigurations() {
+    assertThat(wifiManager.saveConfiguration()).isTrue();
+    assertThat(shadowOf(wifiManager).wasConfigurationSaved()).isTrue();
+  }
+
+  @Test
+  public void shouldCreateWifiLock() {
+    assertThat(wifiManager.createWifiLock("TAG")).isNotNull();
+    assertThat(wifiManager.createWifiLock(1, "TAG")).isNotNull();
+  }
+
+  @Test
+  public void wifiLockAcquireIncreasesActiveLockCount() {
+    WifiManager.WifiLock lock = wifiManager.createWifiLock("TAG");
+    assertThat(shadowOf(wifiManager).getActiveLockCount()).isEqualTo(0);
+    lock.acquire();
+    assertThat(shadowOf(wifiManager).getActiveLockCount()).isEqualTo(1);
+    lock.release();
+    assertThat(shadowOf(wifiManager).getActiveLockCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void multicastLockAcquireIncreasesActiveLockCount() {
+    MulticastLock lock = wifiManager.createMulticastLock("TAG");
+    assertThat(shadowOf(wifiManager).getActiveLockCount()).isEqualTo(0);
+    lock.acquire();
+    assertThat(shadowOf(wifiManager).getActiveLockCount()).isEqualTo(1);
+    lock.release();
+    assertThat(shadowOf(wifiManager).getActiveLockCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldAcquireAndReleaseWifilockRefCounted() {
+    WifiManager.WifiLock lock = wifiManager.createWifiLock("TAG");
+    lock.acquire();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void shouldAcquireAndReleaseWifilockNonRefCounted() {
+    WifiManager.WifiLock lock = wifiManager.createWifiLock("TAG");
+    lock.setReferenceCounted(false);
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void shouldThrowRuntimeExceptionIfWifiLockisUnderlocked() {
+    WifiManager.WifiLock lock = wifiManager.createWifiLock("TAG");
+    try {
+      lock.release();
+      fail("RuntimeException not thrown");
+    } catch (RuntimeException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void shouldThrowUnsupportedOperationIfWifiLockisOverlocked() {
+    WifiManager.WifiLock lock = wifiManager.createWifiLock("TAG");
+    try {
+      for (int i = 0; i < ShadowWifiManager.ShadowWifiLock.MAX_ACTIVE_LOCKS; i++) {
+        lock.acquire();
+      }
+      fail("UnsupportedOperationException not thrown");
+    } catch (UnsupportedOperationException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void shouldCreateMulticastLock() {
+    assertThat(wifiManager.createMulticastLock("TAG")).isNotNull();
+  }
+
+  @Test
+  public void shouldAcquireAndReleaseMulticastLockRefCounted() {
+    MulticastLock lock = wifiManager.createMulticastLock("TAG");
+    lock.acquire();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void shouldAcquireAndReleaseMulticastLockNonRefCounted() {
+    MulticastLock lock = wifiManager.createMulticastLock("TAG");
+    lock.setReferenceCounted(false);
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.acquire();
+    assertThat(lock.isHeld()).isTrue();
+    lock.release();
+    assertThat(lock.isHeld()).isFalse();
+  }
+
+  @Test
+  public void shouldThrowRuntimeExceptionIfMulticastLockisUnderlocked() {
+    MulticastLock lock = wifiManager.createMulticastLock("TAG");
+    try {
+      lock.release();
+      fail("Expected exception");
+    } catch (RuntimeException expected) {
+    }
+    ;
+  }
+
+  @Test
+  public void shouldThrowUnsupportedOperationIfMulticastLockisOverlocked() {
+    MulticastLock lock = wifiManager.createMulticastLock("TAG");
+    try {
+      for (int i = 0; i < ShadowWifiManager.ShadowMulticastLock.MAX_ACTIVE_LOCKS; i++) {
+        lock.acquire();
+      }
+      fail("Expected exception");
+    } catch (UnsupportedOperationException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void shouldCalculateSignalLevelSetBefore() {
+    ShadowWifiManager.setSignalLevelInPercent(0.5f);
+    assertThat(WifiManager.calculateSignalLevel(0, 5)).isEqualTo(2);
+    assertThat(WifiManager.calculateSignalLevel(2, 5)).isEqualTo(2);
+
+    ShadowWifiManager.setSignalLevelInPercent(0.9f);
+    assertThat(WifiManager.calculateSignalLevel(0, 5)).isEqualTo(3);
+    assertThat(WifiManager.calculateSignalLevel(2, 5)).isEqualTo(3);
+
+    ShadowWifiManager.setSignalLevelInPercent(1f);
+    assertThat(WifiManager.calculateSignalLevel(0, 4)).isEqualTo(3);
+    assertThat(WifiManager.calculateSignalLevel(2, 4)).isEqualTo(3);
+
+    ShadowWifiManager.setSignalLevelInPercent(0);
+    assertThat(WifiManager.calculateSignalLevel(0, 5)).isEqualTo(0);
+    assertThat(WifiManager.calculateSignalLevel(2, 5)).isEqualTo(0);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldThrowIllegalArgumentExceptionWhenSignalLevelToLow() {
+    ShadowWifiManager.setSignalLevelInPercent(-0.01f);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldThrowIllegalArgumentExceptionWhenSignalLevelToHigh() {
+    ShadowWifiManager.setSignalLevelInPercent(1.01f);
+  }
+
+  @Test
+  public void startScan_shouldNotThrowException() {
+    assertThat(wifiManager.startScan()).isTrue();
+  }
+
+  @Test
+  public void reconnect_shouldNotThrowException() {
+    assertThat(wifiManager.reconnect()).isFalse();
+  }
+
+  @Test
+  public void reconnect_setsConnectionInfo() {
+    // GIVEN
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.SSID = "SSID";
+    int netId = wifiManager.addNetwork(wifiConfiguration);
+    wifiManager.enableNetwork(netId, false);
+
+    // WHEN
+    wifiManager.reconnect();
+
+    // THEN
+    assertThat(wifiManager.getConnectionInfo().getSSID()).contains("SSID");
+  }
+
+  @Test
+  public void reconnect_shouldEnableDhcp() {
+    // GIVEN
+    WifiConfiguration config = new WifiConfiguration();
+    config.SSID = "SSID";
+    int netId = wifiManager.addNetwork(config);
+    wifiManager.enableNetwork(netId, false);
+
+    // WHEN
+    wifiManager.reconnect();
+
+    // THEN
+    assertThat(wifiManager.getDhcpInfo()).isNotNull();
+  }
+
+  @Test
+  public void reconnect_updatesConnectivityManager() {
+    // GIVEN
+    WifiConfiguration config = new WifiConfiguration();
+    config.SSID = "SSID";
+    int netId = wifiManager.addNetwork(config);
+    wifiManager.enableNetwork(netId, false);
+
+    // WHEN
+    wifiManager.reconnect();
+
+    // THEN
+    NetworkInfo networkInfo =
+        ((ConnectivityManager)
+                ApplicationProvider.getApplicationContext()
+                    .getSystemService(Context.CONNECTIVITY_SERVICE))
+            .getActiveNetworkInfo();
+    assertThat(networkInfo.getType()).isEqualTo(ConnectivityManager.TYPE_WIFI);
+    assertThat(networkInfo.isConnected()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  public void connect_setsNetworkId_shouldHasNetworkId() {
+    // WHEN
+    wifiManager.connect(123, null);
+
+    // THEN
+    assertThat(wifiManager.getConnectionInfo().getNetworkId()).isEqualTo(123);
+  }
+
+  @Test
+  @Config(minSdk = Build.VERSION_CODES.KITKAT)
+  public void connect_setsConnectionInfo() {
+    // GIVEN
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.SSID = "foo";
+
+    // WHEN
+    wifiManager.connect(wifiConfiguration, null);
+
+    // THEN
+    assertThat(wifiManager.getConnectionInfo().getSSID()).contains("foo");
+  }
+
+  @Test
+  @Config(minSdk = LOLLIPOP)
+  public void is5GhzBandSupportedAndConfigurable() {
+    assertThat(wifiManager.is5GHzBandSupported()).isFalse();
+    shadowOf(wifiManager).setIs5GHzBandSupported(true);
+    assertThat(wifiManager.is5GHzBandSupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = R)
+  public void isStaApConcurrencySupportedAndConfigurable() {
+    assertThat(wifiManager.isStaApConcurrencySupported()).isFalse();
+    shadowOf(wifiManager).setStaApConcurrencySupported(true);
+    assertThat(wifiManager.isStaApConcurrencySupported()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testAddOnWifiUsabilityStatsListener() {
+    // GIVEN
+    WifiManager.OnWifiUsabilityStatsListener mockListener =
+        mock(WifiManager.OnWifiUsabilityStatsListener.class);
+    wifiManager.addOnWifiUsabilityStatsListener(directExecutor(), mockListener);
+
+    // WHEN
+    WifiUsabilityStatsEntryBuilder builder = new WifiUsabilityStatsEntryBuilder();
+    builder
+        .setTimeStampMillis(1234567L)
+        .setRssi(23)
+        .setLinkSpeedMbps(998)
+        .setTotalTxSuccess(1)
+        .setTotalTxRetries(2)
+        .setTotalTxBad(3)
+        .setTotalRxSuccess(4)
+        .setTotalRadioOnTimeMillis(5)
+        .setTotalRadioTxTimeMillis(6)
+        .setTotalRadioRxTimeMillis(7)
+        .setTotalScanTimeMillis(8)
+        .setTotalNanScanTimeMillis(9)
+        .setTotalBackgroundScanTimeMillis(10)
+        .setTotalRoamScanTimeMillis(11)
+        .setTotalPnoScanTimeMillis(12)
+        .setTotalHotspot2ScanTimeMillis(13)
+        .setTotalCcaBusyFreqTimeMillis(14)
+        .setTotalRadioOnFreqTimeMillis(15)
+        .setTotalBeaconRx(16)
+        .setProbeStatusSinceLastUpdate(2)
+        .setProbeElapsedTimeSinceLastUpdateMillis(18)
+        .setProbeMcsRateSinceLastUpdate(19)
+        .setRxLinkSpeedMbps(20)
+        .setCellularDataNetworkType(1)
+        .setCellularSignalStrengthDbm(2)
+        .setCellularSignalStrengthDb(3)
+        .setSameRegisteredCell(false);
+
+    if (RuntimeEnvironment.getApiLevel() >= S) {
+      builder
+          .setTimeSliceDutyCycleInPercent(10)
+          .setIsCellularDataAvailable(false)
+          .setIsThroughputSufficient(false)
+          .setIsWifiScoringEnabled(false);
+    }
+
+    shadowOf(wifiManager)
+        .postUsabilityStats(/* seqNum= */ 10, /* isSameBssidAndFreq= */ false, builder);
+    // THEN
+
+    ArgumentCaptor<WifiUsabilityStatsEntry> usabilityStats =
+        ArgumentCaptor.forClass(WifiUsabilityStatsEntry.class);
+    verify(mockListener).onWifiUsabilityStats(eq(10), eq(false), usabilityStats.capture());
+    assertThat(usabilityStats.getValue().getTimeStampMillis()).isEqualTo(1234567L);
+    assertThat(usabilityStats.getValue().getRssi()).isEqualTo(23);
+    assertThat(usabilityStats.getValue().getLinkSpeedMbps()).isEqualTo(998);
+    assertThat(usabilityStats.getValue().getTotalTxSuccess()).isEqualTo(1);
+    assertThat(usabilityStats.getValue().getTotalTxRetries()).isEqualTo(2);
+    assertThat(usabilityStats.getValue().getTotalTxBad()).isEqualTo(3);
+    assertThat(usabilityStats.getValue().getTotalRxSuccess()).isEqualTo(4);
+    assertThat(usabilityStats.getValue().getTotalRadioOnTimeMillis()).isEqualTo(5);
+    assertThat(usabilityStats.getValue().getTotalRadioTxTimeMillis()).isEqualTo(6);
+    assertThat(usabilityStats.getValue().getTotalRadioRxTimeMillis()).isEqualTo(7);
+    assertThat(usabilityStats.getValue().getTotalScanTimeMillis()).isEqualTo(8);
+    assertThat(usabilityStats.getValue().getTotalNanScanTimeMillis()).isEqualTo(9);
+    assertThat(usabilityStats.getValue().getTotalBackgroundScanTimeMillis()).isEqualTo(10);
+    assertThat(usabilityStats.getValue().getTotalRoamScanTimeMillis()).isEqualTo(11);
+    assertThat(usabilityStats.getValue().getTotalPnoScanTimeMillis()).isEqualTo(12);
+    assertThat(usabilityStats.getValue().getTotalHotspot2ScanTimeMillis()).isEqualTo(13);
+    assertThat(usabilityStats.getValue().getTotalCcaBusyFreqTimeMillis()).isEqualTo(14);
+    assertThat(usabilityStats.getValue().getTotalRadioOnFreqTimeMillis()).isEqualTo(15);
+    assertThat(usabilityStats.getValue().getTotalBeaconRx()).isEqualTo(16);
+    assertThat(usabilityStats.getValue().getProbeStatusSinceLastUpdate()).isEqualTo(2);
+    assertThat(usabilityStats.getValue().getProbeElapsedTimeSinceLastUpdateMillis()).isEqualTo(18);
+    assertThat(usabilityStats.getValue().getProbeMcsRateSinceLastUpdate()).isEqualTo(19);
+    assertThat(usabilityStats.getValue().getRxLinkSpeedMbps()).isEqualTo(20);
+    assertThat(usabilityStats.getValue().getCellularDataNetworkType()).isEqualTo(1);
+    assertThat(usabilityStats.getValue().getCellularSignalStrengthDbm()).isEqualTo(2);
+    assertThat(usabilityStats.getValue().getCellularSignalStrengthDb()).isEqualTo(3);
+    assertThat(usabilityStats.getValue().isSameRegisteredCell()).isFalse();
+    if (RuntimeEnvironment.getApiLevel() >= S) {
+      assertThat(usabilityStats.getValue().getTimeSliceDutyCycleInPercent()).isEqualTo(10);
+      assertThat(usabilityStats.getValue().isCellularDataAvailable()).isFalse();
+      assertThat(usabilityStats.getValue().isThroughputSufficient()).isFalse();
+      assertThat(usabilityStats.getValue().isWifiScoringEnabled()).isFalse();
+    }
+    verifyNoMoreInteractions(mockListener);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testRemoveOnWifiUsabilityStatsListener() {
+    // GIVEN
+    WifiManager.OnWifiUsabilityStatsListener mockListener =
+        mock(WifiManager.OnWifiUsabilityStatsListener.class);
+    wifiManager.addOnWifiUsabilityStatsListener(directExecutor(), mockListener);
+
+    WifiUsabilityStatsEntryBuilder builder = new WifiUsabilityStatsEntryBuilder();
+    builder
+        .setTimeStampMillis(1234567L)
+        .setRssi(23)
+        .setLinkSpeedMbps(998)
+        .setTotalTxSuccess(0)
+        .setTotalTxRetries(0)
+        .setTotalTxBad(0)
+        .setTotalRxSuccess(0)
+        .setTotalRadioOnTimeMillis(0)
+        .setTotalRadioTxTimeMillis(0)
+        .setTotalRadioRxTimeMillis(0)
+        .setTotalScanTimeMillis(0)
+        .setTotalNanScanTimeMillis(0)
+        .setTotalBackgroundScanTimeMillis(0)
+        .setTotalRoamScanTimeMillis(0)
+        .setTotalPnoScanTimeMillis(0)
+        .setTotalHotspot2ScanTimeMillis(0)
+        .setTotalCcaBusyFreqTimeMillis(0)
+        .setTotalRadioOnFreqTimeMillis(0)
+        .setTotalBeaconRx(0)
+        .setProbeStatusSinceLastUpdate(0)
+        .setProbeElapsedTimeSinceLastUpdateMillis(0)
+        .setProbeMcsRateSinceLastUpdate(0)
+        .setRxLinkSpeedMbps(0)
+        .setCellularDataNetworkType(0)
+        .setCellularSignalStrengthDbm(0)
+        .setCellularSignalStrengthDb(0)
+        .setSameRegisteredCell(false);
+
+    // WHEN
+    wifiManager.removeOnWifiUsabilityStatsListener(mockListener);
+    shadowOf(wifiManager)
+        .postUsabilityStats(/* seqNum= */ 10, /* isSameBssidAndFreq= */ true, builder);
+
+    // THEN
+    verifyNoMoreInteractions(mockListener);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testGetUsabilityScores() {
+    // GIVEN
+    wifiManager.updateWifiUsabilityScore(
+        /* seqNum= */ 23, /* score= */ 50, /* predictionHorizonSec= */ 16);
+    wifiManager.updateWifiUsabilityScore(
+        /* seqNum= */ 24, /* score= */ 40, /* predictionHorizonSec= */ 16);
+
+    // WHEN
+    List<ShadowWifiManager.WifiUsabilityScore> scores = shadowOf(wifiManager).getUsabilityScores();
+
+    // THEN
+    assertThat(scores).hasSize(2);
+    assertThat(scores.get(0).seqNum).isEqualTo(23);
+    assertThat(scores.get(0).score).isEqualTo(50);
+    assertThat(scores.get(0).predictionHorizonSec).isEqualTo(16);
+    assertThat(scores.get(1).seqNum).isEqualTo(24);
+    assertThat(scores.get(1).score).isEqualTo(40);
+    assertThat(scores.get(1).predictionHorizonSec).isEqualTo(16);
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void testClearUsabilityScores() {
+    // GIVEN
+    wifiManager.updateWifiUsabilityScore(
+        /* seqNum= */ 23, /* score= */ 50, /* predictionHorizonSec= */ 16);
+    wifiManager.updateWifiUsabilityScore(
+        /* seqNum= */ 24, /* score= */ 40, /* predictionHorizonSec= */ 16);
+
+    // WHEN
+    shadowOf(wifiManager).clearUsabilityScores();
+    List<ShadowWifiManager.WifiUsabilityScore> scores = shadowOf(wifiManager).getUsabilityScores();
+
+    // THEN
+    assertThat(scores).isEmpty();
+  }
+
+  @Test
+  public void testSetWifiState() {
+    shadowOf(wifiManager).setWifiState(WifiManager.WIFI_STATE_ENABLED);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_ENABLED);
+    assertThat(wifiManager.isWifiEnabled()).isTrue();
+
+    wifiManager.setWifiEnabled(false);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_DISABLED);
+    assertThat(wifiManager.isWifiEnabled()).isFalse();
+
+    shadowOf(wifiManager).setWifiState(WifiManager.WIFI_STATE_ENABLING);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_ENABLING);
+    assertThat(wifiManager.isWifiEnabled()).isFalse();
+
+    shadowOf(wifiManager).setWifiState(WifiManager.WIFI_STATE_DISABLING);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_DISABLING);
+    assertThat(wifiManager.isWifiEnabled()).isFalse();
+
+    shadowOf(wifiManager).setWifiState(WifiManager.WIFI_STATE_UNKNOWN);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_UNKNOWN);
+    assertThat(wifiManager.isWifiEnabled()).isFalse();
+
+    shadowOf(wifiManager).setWifiState(WifiManager.WIFI_STATE_DISABLED);
+    assertThat(wifiManager.getWifiState()).isEqualTo(WifiManager.WIFI_STATE_DISABLED);
+    assertThat(wifiManager.isWifiEnabled()).isFalse();
+  }
+
+  @Test
+  public void shouldRecordTheLastApConfiguration() {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.SSID = "foo";
+    boolean status = shadowOf(wifiManager).setWifiApConfiguration(wifiConfiguration);
+    assertThat(status).isTrue();
+
+    assertThat(shadowOf(wifiManager).getWifiApConfiguration().SSID).isEqualTo("foo");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiP2pManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiP2pManagerTest.java
new file mode 100644
index 0000000..c1a9d32
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiP2pManagerTest.java
@@ -0,0 +1,161 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.Application;
+import android.content.Context;
+import android.net.wifi.p2p.WifiP2pGroup;
+import android.net.wifi.p2p.WifiP2pManager;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWifiP2pManagerTest {
+
+  private WifiP2pManager manager;
+  private ShadowWifiP2pManager shadowManager;
+  @Mock private WifiP2pManager.ChannelListener mockListener;
+  private WifiP2pManager.Channel channel;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    Application context = ApplicationProvider.getApplicationContext();
+    manager = (WifiP2pManager) context.getSystemService(Context.WIFI_P2P_SERVICE);
+    shadowManager = shadowOf(manager);
+    channel = manager.initialize(context, context.getMainLooper(), mockListener);
+    assertThat(channel).isNotNull();
+  }
+
+  @Test
+  public void createGroup_success() {
+    TestActionListener testListener = new TestActionListener();
+    manager.createGroup(channel, testListener);
+    shadowMainLooper().idle();
+    assertThat(testListener.success).isTrue();
+  }
+
+  @Test
+  public void createGroup_nullListener() {
+    manager.createGroup(channel, null);
+
+    // Should not fail with a null listener
+  }
+
+  @Test
+  public void createGroup_fail() {
+    TestActionListener testListener = new TestActionListener();
+
+    shadowMainLooper().pause();
+
+    shadowManager.setNextActionFailure(WifiP2pManager.BUSY);
+    manager.createGroup(channel, testListener);
+
+    shadowMainLooper().idle();
+
+    assertThat(testListener.success).isFalse();
+    assertThat(testListener.reason).isEqualTo(WifiP2pManager.BUSY);
+  }
+
+  @Test
+  public void clearActionFailure() {
+    shadowManager.setNextActionFailure(WifiP2pManager.ERROR);
+
+    TestActionListener testListener = new TestActionListener();
+    manager.createGroup(channel, testListener);
+    shadowMainLooper().idle();
+    assertThat(testListener.success).isFalse();
+
+    manager.createGroup(channel, testListener);
+    shadowMainLooper().idle();
+    assertThat(testListener.success).isTrue();
+  }
+
+  @Test
+  public void removeGroup_success() {
+    TestActionListener testListener = new TestActionListener();
+    manager.removeGroup(channel, testListener);
+    shadowMainLooper().idle();
+    assertThat(testListener.success).isTrue();
+  }
+
+  @Test
+  public void removeGroup_nullListener() {
+    manager.removeGroup(channel, null);
+
+    // Should not fail with a null listener
+  }
+
+  @Test
+  public void removeGroup_failure() {
+    TestActionListener testListener = new TestActionListener();
+
+    shadowManager.setNextActionFailure(WifiP2pManager.BUSY);
+    manager.removeGroup(channel, testListener);
+    shadowMainLooper().idle();
+
+    assertThat(testListener.success).isFalse();
+    assertThat(testListener.reason).isEqualTo(WifiP2pManager.BUSY);
+  }
+
+  @Test
+  public void requestGroupInfo() {
+    TestGroupInfoListener listener = new TestGroupInfoListener();
+
+    WifiP2pGroup wifiP2pGroup = new WifiP2pGroup();
+    shadowOf(wifiP2pGroup).setInterface("ssid");
+    shadowOf(wifiP2pGroup).setPassphrase("passphrase");
+    shadowOf(wifiP2pGroup).setNetworkName("networkname");
+
+    shadowManager.setGroupInfo(channel, wifiP2pGroup);
+
+    manager.requestGroupInfo(channel, listener);
+    shadowMainLooper().idle();
+
+    assertThat(listener.group.getNetworkName()).isEqualTo(wifiP2pGroup.getNetworkName());
+    assertThat(listener.group.getInterface()).isEqualTo(wifiP2pGroup.getInterface());
+    assertThat(listener.group.getPassphrase()).isEqualTo(wifiP2pGroup.getPassphrase());
+  }
+
+  @Test
+  public void requestGroupInfo_nullListener() {
+    WifiP2pGroup wifiP2pGroup = new WifiP2pGroup();
+    shadowManager.setGroupInfo(channel, wifiP2pGroup);
+
+    manager.requestGroupInfo(channel, null);
+
+    // Should not fail with a null listener
+  }
+
+  private static class TestActionListener implements WifiP2pManager.ActionListener {
+    private int reason;
+    private boolean success;
+
+    @Override
+    public void onSuccess() {
+      success = true;
+    }
+
+    @Override
+    public void onFailure(int reason) {
+      success = false;
+      this.reason = reason;
+    }
+  }
+
+  private static class TestGroupInfoListener implements WifiP2pManager.GroupInfoListener {
+    private WifiP2pGroup group;
+
+    @Override
+    public void onGroupInfoAvailable(WifiP2pGroup group) {
+      this.group = group;
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerGlobalTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerGlobalTest.java
new file mode 100644
index 0000000..2ffab72
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerGlobalTest.java
@@ -0,0 +1,86 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Looper;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.DragShadowBuilder;
+import androidx.annotation.Nullable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWindowManagerGlobalTest {
+
+  @Before
+  public void setup() {
+    System.setProperty("robolectric.areWindowsMarkedVisible", "true");
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getWindowSession_shouldReturnSession() {
+    assertThat(ShadowWindowManagerGlobal.getWindowSession()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void getWindowSession_withLooper_shouldReturnSession() {
+    // method not available in JELLY BEAN, sorry :(
+    assertThat(ShadowWindowManagerGlobal.getWindowSession(Looper.getMainLooper())).isNotNull();
+  }
+
+  @Test
+  public void getLastDragClipData() {
+    MotionEvent downEvent = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 12f, 34f, 0);
+    Robolectric.buildActivity(DragActivity.class)
+        .setup()
+        .get()
+        .findViewById(android.R.id.content)
+        .dispatchTouchEvent(downEvent);
+
+    assertThat(ShadowWindowManagerGlobal.getLastDragClipData()).isNotNull();
+  }
+
+  @Test
+  @Config(minSdk = JELLY_BEAN_MR1)
+  public void windowIsVisible() {
+    View decorView =
+        Robolectric.buildActivity(DragActivity.class).setup().get().getWindow().getDecorView();
+
+    assertThat(decorView.getWindowVisibility()).isEqualTo(View.VISIBLE);
+  }
+
+  static final class DragActivity extends Activity {
+    @Override
+    protected void onCreate(@Nullable Bundle bundle) {
+      super.onCreate(bundle);
+      View contentView = new View(this);
+      contentView.setOnTouchListener(
+          (view, motionEvent) -> {
+            if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+              ClipData clipData = ClipData.newPlainText("label", "text");
+              DragShadowBuilder dragShadowBuilder = new DragShadowBuilder(view);
+              if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.N) {
+                view.startDragAndDrop(clipData, dragShadowBuilder, null, 0);
+              } else {
+                view.startDrag(clipData, dragShadowBuilder, null, 0);
+              }
+            }
+            return true;
+          });
+      setContentView(contentView);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerGlobalUnitTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerGlobalUnitTest.java
new file mode 100644
index 0000000..39a6bcd
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerGlobalUnitTest.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.view.WindowManagerGlobal;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.Robolectric;
+import org.robolectric.android.FailureListener;
+import org.robolectric.annotation.Config;
+
+@RunWith(JUnit4.class)
+public class ShadowWindowManagerGlobalUnitTest {
+  @Test
+  public void shouldReset() throws Exception {
+    assertThat(FailureListener.runTests(DummyTest.class)).isEmpty();
+  }
+
+  @Config(sdk = 23)
+  public static class DummyTest {
+    @Test
+    public void first() {
+      assertThat(WindowManagerGlobal.getInstance().getViewRootNames()).isEmpty();
+      Robolectric.setupActivity(Activity.class);
+    }
+
+    @Test
+    public void second() {
+      assertThat(WindowManagerGlobal.getInstance().getViewRootNames()).isEmpty();
+      Robolectric.setupActivity(Activity.class);
+    }
+  }
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerImplTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerImplTest.java
new file mode 100644
index 0000000..946ee67
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowManagerImplTest.java
@@ -0,0 +1,80 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.graphics.PixelFormat;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.WindowManager.LayoutParams;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit test for {@link ShadowWindowManagerImpl}. */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = O)
+public class ShadowWindowManagerImplTest {
+
+  private View view;
+  private LayoutParams layoutParams;
+  private WindowManager windowManager;
+
+  @Before
+  public void setUp() {
+    Context context = ApplicationProvider.getApplicationContext();
+    view = new View(context);
+    windowManager = context.getSystemService(WindowManager.class);
+    layoutParams =
+        new LayoutParams(
+            /*w=*/ ViewGroup.LayoutParams.MATCH_PARENT,
+            /*h=*/ ViewGroup.LayoutParams.MATCH_PARENT,
+            LayoutParams.TYPE_APPLICATION_OVERLAY,
+            LayoutParams.FLAG_LAYOUT_IN_SCREEN,
+            PixelFormat.TRANSLUCENT);
+  }
+
+  @Test
+  public void getViews_isInitiallyEmpty() {
+    List<View> views = ((ShadowWindowManagerImpl) shadowOf(windowManager)).getViews();
+
+    assertThat(views).isEmpty();
+  }
+
+  @Test
+  public void getViews_returnsAnAddedView() {
+    windowManager.addView(view, layoutParams);
+
+    List<View> views = ((ShadowWindowManagerImpl) shadowOf(windowManager)).getViews();
+
+    assertThat(views).hasSize(1);
+    assertThat(views.get(0)).isSameInstanceAs(view);
+  }
+
+  @Test
+  public void getViews_doesNotReturnAViewThatWasRemoved() {
+    windowManager.addView(view, layoutParams);
+    windowManager.removeView(view);
+
+    List<View> views = ((ShadowWindowManagerImpl) shadowOf(windowManager)).getViews();
+
+    assertThat(views).isEmpty();
+  }
+
+  @Test
+  public void getViews_doesNotReturnAViewThatWasRemovedImmediately() {
+    windowManager.addView(view, layoutParams);
+    windowManager.removeViewImmediate(view);
+
+    List<View> views = ((ShadowWindowManagerImpl) shadowOf(windowManager)).getViews();
+
+    assertThat(views).isEmpty();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowTest.java
new file mode 100644
index 0000000..2a6ab49
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWindowTest.java
@@ -0,0 +1,241 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.R;
+import android.app.Activity;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.FrameMetrics;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.LinearLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.Robolectric;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+public class ShadowWindowTest {
+  @Test
+  public void getFlag_shouldReturnWindowFlags() {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+
+    assertThat(shadowOf(window).getFlag(WindowManager.LayoutParams.FLAG_FULLSCREEN)).isFalse();
+    window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
+    assertThat(shadowOf(window).getFlag(WindowManager.LayoutParams.FLAG_FULLSCREEN)).isTrue();
+    window.setFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON, WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON);
+    assertThat(shadowOf(window).getFlag(WindowManager.LayoutParams.FLAG_FULLSCREEN)).isTrue();
+  }
+
+  @Test
+  public void getSystemFlag_isFalseByDefault() {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    int fakeSystemFlag1 = 0b1;
+
+    assertThat(shadowOf(window).getPrivateFlag(fakeSystemFlag1)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getSystemFlag_shouldReturnFlagsSetViaAddSystemFlags() throws Exception {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    int fakeSystemFlag1 = 0b1;
+
+    window.addSystemFlags(fakeSystemFlag1);
+
+    assertThat(shadowOf(window).getPrivateFlag(fakeSystemFlag1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = Q)
+  public void getSystemFlag_callingAddSystemFlagsShouldNotOverrideExistingFlags() throws Exception {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    int fakeSystemFlag1 = 0b1;
+    int fakeSystemFlag2 = 0b10;
+    window.addSystemFlags(fakeSystemFlag1);
+
+    window.addSystemFlags(fakeSystemFlag2);
+
+    assertThat(shadowOf(window).getPrivateFlag(fakeSystemFlag1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT, maxSdk = VERSION_CODES.R)
+  public void getSystemFlag_shouldReturnFlagsSetViaAddPrivateFlags() throws Exception {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    int fakeSystemFlag1 = 0b1;
+
+    window.addPrivateFlags(fakeSystemFlag1);
+
+    assertThat(shadowOf(window).getPrivateFlag(fakeSystemFlag1)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = KITKAT, maxSdk = VERSION_CODES.R)
+  public void getSystemFlag_callingAddPrivateFlagsShouldNotOverrideExistingFlags()
+      throws Exception {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    int fakeSystemFlag1 = 0b1;
+    int fakeSystemFlag2 = 0b10;
+    window.addPrivateFlags(fakeSystemFlag1);
+
+    window.addPrivateFlags(fakeSystemFlag2);
+
+    assertThat(shadowOf(window).getPrivateFlag(fakeSystemFlag1)).isTrue();
+  }
+
+  @Test
+  public void getTitle_shouldReturnWindowTitle() {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    window.setTitle("My Window Title");
+    assertThat(shadowOf(window).getTitle().toString()).isEqualTo("My Window Title");
+  }
+
+  @Test
+  public void getBackgroundDrawable_returnsSetDrawable() {
+    Activity activity = Robolectric.buildActivity(Activity.class).create().get();
+    Window window = activity.getWindow();
+    ShadowWindow shadowWindow = shadowOf(window);
+
+    assertThat(shadowWindow.getBackgroundDrawable()).isNull();
+
+    window.setBackgroundDrawableResource(R.drawable.btn_star);
+    assertThat(shadowOf(shadowWindow.getBackgroundDrawable()).createdFromResId).isEqualTo(R.drawable.btn_star);
+  }
+
+  @Test
+  public void getSoftInputMode_returnsSoftInputMode() {
+    TestActivity activity = Robolectric.buildActivity(TestActivity.class).create().get();
+    Window window = activity.getWindow();
+    ShadowWindow shadowWindow = shadowOf(window);
+
+    window.setSoftInputMode(7);
+
+    assertThat(shadowWindow.getSoftInputMode()).isEqualTo(7);
+  }
+
+  @Test @Config(maxSdk = LOLLIPOP_MR1)
+  public void forPreM_create_shouldCreateImplPhoneWindow() throws Exception {
+    assertThat(
+            ShadowWindow.create(ApplicationProvider.getApplicationContext()).getClass().getName())
+        .isEqualTo("com.android.internal.policy.impl.PhoneWindow");
+  }
+
+  @Test @Config(minSdk = M)
+  public void forM_create_shouldCreatePhoneWindow() throws Exception {
+    assertThat(
+            ShadowWindow.create(ApplicationProvider.getApplicationContext()).getClass().getName())
+        .isEqualTo("com.android.internal.policy.PhoneWindow");
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void reportOnFrameMetricsAvailable_notifiesListeners() throws Exception {
+    ActivityController<Activity> activityController = Robolectric.buildActivity(Activity.class);
+
+    // Attaches the ViewRootImpl to the window.
+    // When the ViewRootImpl is attached, android will check to see if hardware acceleration is
+    // enabled, and only attach listeners if it is. Attaching, rather than just using a created
+    // window, allows for verification that triggering works even then.
+    activityController.setup();
+
+    Window window = activityController.get().getWindow();
+    Window.OnFrameMetricsAvailableListener listener =
+        Mockito.mock(Window.OnFrameMetricsAvailableListener.class);
+    FrameMetrics frameMetrics = new FrameMetricsBuilder().build();
+
+    window.addOnFrameMetricsAvailableListener(listener, new Handler(Looper.getMainLooper()));
+    shadowOf(window).reportOnFrameMetricsAvailable(frameMetrics);
+
+    verify(listener)
+        .onFrameMetricsAvailable(window, frameMetrics, /* dropCountSinceLastInvocation= */ 0);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void reportOnFrameMetricsAvailable_nonZeroDropCount_notifiesListeners() throws Exception {
+    ActivityController<Activity> activityController = Robolectric.buildActivity(Activity.class);
+    activityController.setup();
+
+    Window window = activityController.get().getWindow();
+    Window.OnFrameMetricsAvailableListener listener =
+        Mockito.mock(Window.OnFrameMetricsAvailableListener.class);
+    FrameMetrics frameMetrics = new FrameMetricsBuilder().build();
+
+    window.addOnFrameMetricsAvailableListener(listener, new Handler(Looper.getMainLooper()));
+    shadowOf(window)
+        .reportOnFrameMetricsAvailable(frameMetrics, /* dropCountSinceLastInvocation= */ 3);
+
+    verify(listener)
+        .onFrameMetricsAvailable(window, frameMetrics, /* dropCountSinceLastInvocation= */ 3);
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void reportOnFrameMetricsAvailable_listenerRemoved_doesntNotifyListener()
+      throws Exception {
+    ActivityController<Activity> activityController = Robolectric.buildActivity(Activity.class);
+    activityController.setup();
+
+    Window window = activityController.get().getWindow();
+    Window.OnFrameMetricsAvailableListener listener =
+        Mockito.mock(Window.OnFrameMetricsAvailableListener.class);
+    FrameMetrics frameMetrics = new FrameMetricsBuilder().build();
+
+    window.addOnFrameMetricsAvailableListener(listener, new Handler(Looper.getMainLooper()));
+    window.removeOnFrameMetricsAvailableListener(listener);
+    shadowOf(window).reportOnFrameMetricsAvailable(frameMetrics);
+
+    verify(listener, never())
+        .onFrameMetricsAvailable(
+            any(Window.class),
+            any(FrameMetrics.class),
+            /* dropCountSinceLastInvocation= */ anyInt());
+  }
+
+  @Test
+  @Config(minSdk = N)
+  public void reportOnFrameMetricsAvailable_noListener_doesntCrash() throws Exception {
+    Window window = ShadowWindow.create(ApplicationProvider.getApplicationContext());
+
+    // Shouldn't crash.
+    shadowOf(window).reportOnFrameMetricsAvailable(new FrameMetricsBuilder().build());
+  }
+
+  public static class TestActivity extends Activity {
+    public int requestFeature = Window.FEATURE_PROGRESS;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      setTheme(R.style.Theme_Holo_Light);
+      getWindow().requestFeature(requestFeature);
+      setContentView(new LinearLayout(this));
+      getActionBar().setIcon(R.drawable.ic_lock_power_off);
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/StorageVolumeBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/StorageVolumeBuilderTest.java
new file mode 100644
index 0000000..dad2d47
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/StorageVolumeBuilderTest.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link StorageVolumeBuilder} */
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.N)
+public class StorageVolumeBuilderTest {
+
+  @Test
+  public void testBuilder() {
+    UserHandle userHandle = UserHandle.getUserHandleForUid(0);
+    new StorageVolumeBuilder("id", new File("path"), "description", userHandle, "").build();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/StreamConfigurationMapBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/StreamConfigurationMapBuilderTest.java
new file mode 100644
index 0000000..2d3a04b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/StreamConfigurationMapBuilderTest.java
@@ -0,0 +1,119 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.graphics.ImageFormat;
+import android.graphics.PixelFormat;
+import android.hardware.camera2.params.StreamConfigurationMap;
+import android.media.MediaRecorder;
+import android.os.Build.VERSION_CODES;
+import android.util.Size;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link StreamConfigurationMapBuilder}. */
+@Config(minSdk = VERSION_CODES.LOLLIPOP)
+@RunWith(AndroidJUnit4.class)
+public class StreamConfigurationMapBuilderTest {
+  @Test
+  public void testGetOutputSizes() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addOutputSize(size1)
+            .addOutputSize(size2)
+            .build();
+    assertThat(Arrays.asList(map.getOutputSizes(MediaRecorder.class)))
+        .containsExactly(size1, size2);
+  }
+
+  @Test
+  public void testGetOutputSizesForTwoFormats() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addOutputSize(size1)
+            .addOutputSize(ImageFormat.YUV_420_888, size2)
+            .build();
+    assertThat(Arrays.asList(map.getOutputSizes(MediaRecorder.class))).containsExactly(size1);
+    assertThat(Arrays.asList(map.getOutputSizes(ImageFormat.YUV_420_888))).containsExactly(size2);
+  }
+
+  @Test
+  public void testGetOutputSizesForImageFormatNV21() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addOutputSize(ImageFormat.NV21, size1)
+            .addOutputSize(ImageFormat.NV21, size2)
+            .build();
+    assertThat(Arrays.asList(map.getOutputSizes(ImageFormat.NV21))).containsExactly(size1, size2);
+  }
+
+  @Test
+  public void testGetOutputSizesPixelFormatRgba8888() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addOutputSize(PixelFormat.RGBA_8888, size1)
+            .addOutputSize(PixelFormat.RGBA_8888, size2)
+            .build();
+    assertThat(Arrays.asList(map.getOutputSizes(PixelFormat.RGBA_8888)))
+        .containsExactly(size1, size2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetInputSizesIfNotAddInputSizes() {
+    StreamConfigurationMap map = StreamConfigurationMapBuilder.newBuilder().build();
+    assertThat(map.getInputSizes(ImageFormat.PRIVATE)).isNull();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetInputSizesForTwoFormats() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addInputSize(ImageFormat.PRIVATE, size1)
+            .addInputSize(ImageFormat.YUV_420_888, size2)
+            .build();
+    assertThat(Arrays.asList(map.getInputSizes(ImageFormat.PRIVATE))).containsExactly(size1);
+    assertThat(Arrays.asList(map.getInputSizes(ImageFormat.YUV_420_888))).containsExactly(size2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetInputSizesForImageFormatNV21() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addInputSize(ImageFormat.NV21, size1)
+            .addInputSize(ImageFormat.NV21, size2)
+            .build();
+    assertThat(Arrays.asList(map.getInputSizes(ImageFormat.NV21))).containsExactly(size1, size2);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.M)
+  public void testGetInputSizesPixelFormatRgba8888() {
+    Size size1 = new Size(1920, 1080);
+    Size size2 = new Size(1280, 720);
+    StreamConfigurationMap map =
+        StreamConfigurationMapBuilder.newBuilder()
+            .addInputSize(PixelFormat.RGBA_8888, size1)
+            .addInputSize(PixelFormat.RGBA_8888, size2)
+            .build();
+    assertThat(Arrays.asList(map.getInputSizes(PixelFormat.RGBA_8888)))
+        .containsExactly(size1, size2);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/TestParcelable.java b/robolectric/src/test/java/org/robolectric/shadows/TestParcelable.java
new file mode 100644
index 0000000..643ec42
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/TestParcelable.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * This class is intentionally package private to verify that Robolectric is able to parcel
+ * non-public classes.
+ *
+ * <p>DO NOT CHANGE TO PUBLIC.
+ */
+class TestParcelable implements Parcelable {
+  int contents;
+
+  public TestParcelable(int contents) {
+    this.contents = contents;
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+
+  public static final Creator<TestParcelable> CREATOR =
+      new Creator<TestParcelable>() {
+        @Override
+        public TestParcelable createFromParcel(Parcel source) {
+          return new TestParcelable(source.readInt());
+        }
+
+        @Override
+        public TestParcelable[] newArray(int size) {
+          return new TestParcelable[0];
+        }
+      };
+  @Override
+  public void writeToParcel(Parcel dest, int flags) {
+    dest.writeInt(contents);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/TestParcelableBase.java b/robolectric/src/test/java/org/robolectric/shadows/TestParcelableBase.java
new file mode 100644
index 0000000..a505f97
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/TestParcelableBase.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import android.os.Parcelable;
+
+abstract class TestParcelableBase implements Parcelable {
+  int contents;
+
+  protected TestParcelableBase(int contents) {
+    this.contents = contents;
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/TestParcelableImpl.java b/robolectric/src/test/java/org/robolectric/shadows/TestParcelableImpl.java
new file mode 100644
index 0000000..9c4a835
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/TestParcelableImpl.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * This class is intentionally package private to verify that Robolectric is able to parcel
+ * non-public classes.
+ *
+ * <p>DO NOT CHANGE TO PUBLIC.
+ */
+class TestParcelableImpl extends TestParcelableBase implements Parcelable {
+
+  public TestParcelableImpl(int contents) {
+    super(contents);
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+
+  public static final Creator<TestParcelableImpl> CREATOR =
+      new Creator<TestParcelableImpl>() {
+        @Override
+        public TestParcelableImpl createFromParcel(Parcel source) {
+          return new TestParcelableImpl(source.readInt());
+        }
+
+        @Override
+        public TestParcelableImpl[] newArray(int size) {
+          return new TestParcelableImpl[0];
+        }
+      };
+  @Override
+  public void writeToParcel(Parcel dest, int flags) {
+    dest.writeInt(contents);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/TestParcelablePackage.java b/robolectric/src/test/java/org/robolectric/shadows/TestParcelablePackage.java
new file mode 100644
index 0000000..9836ca8
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/TestParcelablePackage.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * This class is intentionally package private to verify that Robolectric is able to parcel
+ * non-public classes.
+ *
+ * <p>DO NOT CHANGE TO PUBLIC.
+ */
+class TestParcelablePackage implements Parcelable {
+  int contents;
+
+  public TestParcelablePackage(int contents) {
+    this.contents = contents;
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+
+  public static final CreatorImpl CREATOR = new CreatorImpl();
+
+  @Override
+  public void writeToParcel(Parcel dest, int flags) {
+    dest.writeInt(contents);
+  }
+
+  public static class CreatorImpl implements Creator<TestParcelablePackage> {
+    @Override
+    public TestParcelablePackage createFromParcel(Parcel source) {
+      return new TestParcelablePackage(source.readInt());
+    }
+
+    @Override
+    public TestParcelablePackage[] newArray(int size) {
+      return new TestParcelablePackage[size];
+    }
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/TestService.java b/robolectric/src/test/java/org/robolectric/shadows/TestService.java
new file mode 100644
index 0000000..878eeb6
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/TestService.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.IBinder;
+
+class TestService extends Service implements ServiceConnection {
+  ComponentName name;
+  IBinder service;
+  ComponentName nameDisconnected;
+
+  @Override
+  public IBinder onBind(Intent intent) {
+    return null;
+  }
+
+  @Override
+  public void onServiceConnected(ComponentName name, IBinder service) {
+    this.name = name;
+    this.service = service;
+  }
+
+  @Override
+  public void onServiceDisconnected(ComponentName name) {
+    nameDisconnected = name;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/UiccCardInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/UiccCardInfoBuilderTest.java
new file mode 100644
index 0000000..b84324c
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/UiccCardInfoBuilderTest.java
@@ -0,0 +1,80 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.UiccCardInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.Q)
+public final class UiccCardInfoBuilderTest {
+
+  @Test
+  @Config(maxSdk = VERSION_CODES.S_V2)
+  public void buildUiccCardInfo_sdkQtoT() {
+    UiccCardInfo cardInfo =
+        UiccCardInfoBuilder.newBuilder()
+            .setIsEuicc(true)
+            .setCardId(5)
+            .setEid("sample_eid")
+            .setIccId("sample_iccid")
+            .setSlotIndex(1)
+            .setIsRemovable(true)
+            .build();
+
+    assertThat(cardInfo).isNotNull();
+    assertThat(cardInfo.isEuicc()).isTrue();
+    assertThat(cardInfo.getCardId()).isEqualTo(5);
+    assertThat(cardInfo.getEid()).isEqualTo("sample_eid");
+    assertThat(cardInfo.getIccId()).isEqualTo("sample_iccid");
+    assertThat(cardInfo.getSlotIndex()).isEqualTo(1);
+    assertThat(cardInfo.isRemovable()).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.TIRAMISU)
+  public void buildUiccCardInfo_fromSdkT() {
+    UiccCardInfo cardInfo =
+        UiccCardInfoBuilder.newBuilder()
+            .setCardId(5)
+            .setIsEuicc(true)
+            .setEid("sample_eid")
+            .setPhysicalSlotIndex(1)
+            .setIsRemovable(true)
+            .setIsMultipleEnabledProfilesSupported(true)
+            .setPorts(new ArrayList<>())
+            .build();
+
+    assertThat(cardInfo).isNotNull();
+    assertThat(cardInfo.isEuicc()).isTrue();
+    assertThat(cardInfo.getCardId()).isEqualTo(5);
+    assertThat(cardInfo.getEid()).isEqualTo("sample_eid");
+    assertThat(cardInfo.getPhysicalSlotIndex()).isEqualTo(1);
+    assertThat(cardInfo.isRemovable()).isTrue();
+    assertThat(cardInfo.isMultipleEnabledProfilesSupported()).isTrue();
+    assertThat(cardInfo.getPorts()).isEmpty();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.TIRAMISU)
+  public void buildUiccCardInfo_nullPorts_fromSdkT() {
+    UiccCardInfo cardInfo =
+        UiccCardInfoBuilder.newBuilder()
+            .setCardId(5)
+            .setIsEuicc(true)
+            .setEid("sample_eid")
+            .setPhysicalSlotIndex(1)
+            .setIsRemovable(true)
+            .setIsMultipleEnabledProfilesSupported(true)
+            .setPorts(null)
+            .build();
+
+    assertThrows(NullPointerException.class, cardInfo::getPorts);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/UiccPortInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/UiccPortInfoBuilderTest.java
new file mode 100644
index 0000000..627bc35
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/UiccPortInfoBuilderTest.java
@@ -0,0 +1,32 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.UiccPortInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = VERSION_CODES.TIRAMISU)
+public final class UiccPortInfoBuilderTest {
+
+  @Test
+  public void buildUiccPortInfo() {
+    UiccPortInfo portInfo =
+        UiccPortInfoBuilder.newBuilder()
+            .setIccId("sample_iccid")
+            .setPortIndex(1)
+            .setLogicalSlotIndex(1)
+            .setIsActive(true)
+            .build();
+
+    assertThat(portInfo).isNotNull();
+    assertThat(portInfo.getIccId()).isEqualTo("sample_iccid");
+    assertThat(portInfo.getPortIndex()).isEqualTo(1);
+    assertThat(portInfo.getLogicalSlotIndex()).isEqualTo(1);
+    assertThat(portInfo.isActive()).isTrue();
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/UiccSlotInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/UiccSlotInfoBuilderTest.java
new file mode 100644
index 0000000..16aca80
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/UiccSlotInfoBuilderTest.java
@@ -0,0 +1,66 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static android.telephony.UiccSlotInfo.CARD_STATE_INFO_PRESENT;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.telephony.UiccPortInfo;
+import android.telephony.UiccSlotInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(minSdk = P)
+public class UiccSlotInfoBuilderTest {
+  @Test
+  public void buildUiccSlotInfo() {
+    UiccSlotInfo slotInfo =
+        UiccSlotInfoBuilder.newBuilder()
+            .setCardStateInfo(CARD_STATE_INFO_PRESENT)
+            .setCardId("cardId")
+            .setIsEuicc(true)
+            .setIsRemovable(true)
+            .setIsExtendedApduSupported(true)
+            .addPort("iccId", 1, 1, true)
+            .build();
+
+    assertThat(slotInfo).isNotNull();
+    assertThat(slotInfo.getCardId()).isEqualTo("cardId");
+    assertThat(slotInfo.getIsEuicc()).isTrue();
+    assertThat(slotInfo.getIsExtendedApduSupported()).isEqualTo(true);
+    assertThat(slotInfo.getCardStateInfo()).isEqualTo(CARD_STATE_INFO_PRESENT);
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      assertThat(slotInfo.isRemovable()).isEqualTo(true);
+    }
+  }
+
+  @Config(minSdk = TIRAMISU)
+  @Test
+  public void buildUiccSlotInfo_ports() {
+    UiccSlotInfo slotInfo =
+        UiccSlotInfoBuilder.newBuilder()
+            .setCardStateInfo(CARD_STATE_INFO_PRESENT)
+            .setCardId("cardId")
+            .setIsEuicc(true)
+            .setIsRemovable(true)
+            .setIsExtendedApduSupported(true)
+            .addPort("iccId", 1, 1, true)
+            .build();
+    assertThat(slotInfo).isNotNull();
+    assertThat(slotInfo.getCardId()).isEqualTo("cardId");
+    assertThat(slotInfo.getIsEuicc()).isTrue();
+    assertThat(slotInfo.getIsExtendedApduSupported()).isEqualTo(true);
+    assertThat(slotInfo.getCardStateInfo()).isEqualTo(CARD_STATE_INFO_PRESENT);
+    assertThat(slotInfo.getPorts()).hasSize(1);
+    UiccPortInfo portInfo = slotInfo.getPorts().stream().findFirst().get();
+    assertThat(portInfo.getIccId()).isEqualTo("iccId");
+    assertThat(portInfo.getPortIndex()).isEqualTo(1);
+    assertThat(portInfo.getLogicalSlotIndex()).isEqualTo(1);
+    assertThat(portInfo.isActive()).isEqualTo(true);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/VelocityTrackerTest.java b/robolectric/src/test/java/org/robolectric/shadows/VelocityTrackerTest.java
new file mode 100644
index 0000000..747bb3b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/VelocityTrackerTest.java
@@ -0,0 +1,145 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(AndroidJUnit4.class)
+public class VelocityTrackerTest {
+  VelocityTracker velocityTracker;
+
+  @Before
+  public void setUp() {
+    velocityTracker = VelocityTracker.obtain();
+  }
+
+  @Test
+  public void handlesXMovement() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, 20, 0));
+    velocityTracker.computeCurrentVelocity(1);
+
+    // active pointer
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(1.0f);
+    assertThat(velocityTracker.getXVelocity(0)).isEqualTo(1.0f);
+    // inactive pointer
+    assertThat(velocityTracker.getXVelocity(10)).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void handlesYMovement() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, 0, 20));
+    velocityTracker.computeCurrentVelocity(1);
+
+    // active pointer
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(1.0f);
+    assertThat(velocityTracker.getYVelocity(0)).isEqualTo(1.0f);
+    // inactive pointer
+    assertThat(velocityTracker.getYVelocity(10)).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void handlesXAndYMovement() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, 20, 40));
+    velocityTracker.computeCurrentVelocity(1);
+
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(1.0f);
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(2.0f);
+  }
+
+  @Test
+  public void handlesWindowing_positive() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, 10000, 10000));
+    velocityTracker.computeCurrentVelocity(1, 10);
+
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(10.0f);
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(10.0f);
+  }
+
+  @Test
+  public void handlesWindowing_negative() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, -10000, -10000));
+    velocityTracker.computeCurrentVelocity(1, 10);
+
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(-10.0f);
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(-10.0f);
+  }
+
+  @Test
+  public void handlesMultiplePointers() {
+    // pointer 0 active
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    // pointer 1 active
+    velocityTracker.addMovement(doMotion(20, 40, 40, 0, 0));
+    velocityTracker.addMovement(doMotion(40, 80, 80, 20, 20));
+    velocityTracker.computeCurrentVelocity(1);
+
+    // active pointer
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(1.0f);
+    assertThat(velocityTracker.getXVelocity(1)).isEqualTo(1.0f);
+    // inactive pointer
+    assertThat(velocityTracker.getXVelocity(0)).isEqualTo(2.0f);
+  }
+
+  @Test
+  public void handlesClearing() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, 20, 20));
+    velocityTracker.computeCurrentVelocity(1);
+    velocityTracker.clear();
+
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(0.0f);
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(0.0f);
+    velocityTracker.computeCurrentVelocity(1);
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(0.0f);
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(0.0f);
+  }
+
+  @Test
+  public void clearsOnDown() {
+    velocityTracker.addMovement(doMotion(0, 0, 0));
+    velocityTracker.addMovement(doMotion(20, 20, 20));
+    velocityTracker.computeCurrentVelocity(1);
+    velocityTracker.addMovement(doPointerDown(40, 40, 40));
+    velocityTracker.computeCurrentVelocity(1);
+
+    assertThat(velocityTracker.getXVelocity()).isEqualTo(0.0f);
+    assertThat(velocityTracker.getYVelocity()).isEqualTo(0.0f);
+  }
+
+  private static MotionEvent doMotion(long time, float x, float y) {
+    return MotionEvent.obtain(0, time, MotionEvent.ACTION_MOVE, x, y, 0);
+  }
+
+  private static MotionEvent doPointerDown(long time, float x, float y) {
+    return MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, x, y, 0);
+  }
+
+  /**
+   * Construct a new MotionEvent involving two pointers at {@code time}. Pointer 2 will be
+   * considered active.
+   */
+  private static MotionEvent doMotion(
+      long time, float pointer1X, float pointer1Y, float pointer2X, float pointer2Y) {
+    MotionEvent event =
+        MotionEvent.obtain(0, time, MotionEvent.ACTION_MOVE, pointer2X, pointer2Y, 0);
+    ShadowMotionEvent shadowEvent = Shadows.shadowOf(event);
+    shadowEvent.setPointer2(pointer1X, pointer1Y);
+    shadowEvent.setPointerIndex(0);
+    // we put our active pointer (the second one down) first, so flip the IDs so that they match up
+    // properly
+    shadowEvent.setPointerIds(1, 0);
+
+    return event;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ViewInnerTextTest.java b/robolectric/src/test/java/org/robolectric/shadows/ViewInnerTextTest.java
new file mode 100644
index 0000000..a927418
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ViewInnerTextTest.java
@@ -0,0 +1,76 @@
+package org.robolectric.shadows;
+
+import static org.junit.Assert.assertEquals;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ViewInnerTextTest {
+  private Context context;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void testInnerText() {
+    LinearLayout top = new LinearLayout(context);
+    top.addView(textView("blah"));
+    top.addView(new View(context));
+    top.addView(textView("a b c"));
+
+    LinearLayout innerLayout = new LinearLayout(context);
+    top.addView(innerLayout);
+
+    innerLayout.addView(textView("d e f"));
+    innerLayout.addView(textView("g h i"));
+    innerLayout.addView(textView(""));
+    innerLayout.addView(textView(null));
+    innerLayout.addView(textView("jkl!"));
+
+    top.addView(textView("mnop"));
+
+    assertEquals("blah a b c d e f g h i jkl! mnop", shadowOf(top).innerText());
+  }
+
+  @Test
+  public void shouldOnlyIncludeViewTextViewsText() {
+    LinearLayout top = new LinearLayout(context);
+    top.addView(textView("blah", View.VISIBLE));
+    top.addView(textView("blarg", View.GONE));
+    top.addView(textView("arrg", View.INVISIBLE));
+
+    assertEquals("blah", shadowOf(top).innerText());
+  }
+
+  @Test
+  public void shouldNotPrefixBogusSpaces() {
+    LinearLayout top = new LinearLayout(context);
+    top.addView(textView("blarg", View.GONE));
+    top.addView(textView("arrg", View.INVISIBLE));
+    top.addView(textView("blah", View.VISIBLE));
+
+    assertEquals("blah", shadowOf(top).innerText());
+  }
+
+  private TextView textView(String text) {
+    return textView(text, View.VISIBLE);
+  }
+
+  private TextView textView(String text, int visibility) {
+    TextView textView = new TextView(context);
+    textView.setText(text);
+    textView.setVisibility(visibility);
+    return textView;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ViewStubTest.java b/robolectric/src/test/java/org/robolectric/shadows/ViewStubTest.java
new file mode 100644
index 0000000..be1c37d
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/ViewStubTest.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+
+import android.content.Context;
+import android.view.View;
+import android.view.ViewStub;
+import android.widget.LinearLayout;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+
+@RunWith(AndroidJUnit4.class)
+public class ViewStubTest {
+  private Context ctxt;
+
+  @Before public void setUp() throws Exception {
+    ctxt = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void inflate_shouldReplaceOriginalWithLayout() {
+    ViewStub viewStub = new ViewStub(ctxt);
+    int stubId = 12345;
+    int inflatedId = 12346;
+
+    viewStub.setId(stubId);
+    viewStub.setInflatedId(inflatedId);
+    viewStub.setLayoutResource(R.layout.media);
+
+    LinearLayout root = new LinearLayout(ctxt);
+    root.addView(new View(ctxt));
+    root.addView(viewStub);
+    root.addView(new View(ctxt));
+
+    View inflatedView = viewStub.inflate();
+    assertNotNull(inflatedView);
+    assertSame(inflatedView, root.findViewById(inflatedId));
+
+    assertNull(root.findViewById(stubId));
+
+    assertEquals(1, root.indexOfChild(inflatedView));
+    assertEquals(3, root.getChildCount());
+  }
+
+  @Test
+  public void shouldApplyAttributes() {
+    ViewStub viewStub = new ViewStub(ctxt,
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.inflatedId, "@+id/include_id")
+            .addAttribute(android.R.attr.layout, "@layout/media")
+            .build());
+
+    assertThat(viewStub.getInflatedId()).isEqualTo(R.id.include_id);
+    assertThat(viewStub.getLayoutResource()).isEqualTo(R.layout.media);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/XmlPullParserTest.java b/robolectric/src/test/java/org/robolectric/shadows/XmlPullParserTest.java
new file mode 100644
index 0000000..b500828
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/XmlPullParserTest.java
@@ -0,0 +1,181 @@
+package org.robolectric.shadows;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.res.android.ResourceTypes.ANDROID_NS;
+import static org.robolectric.res.android.ResourceTypes.AUTO_NS;
+
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.R;
+import org.robolectric.Robolectric;
+import org.robolectric.RuntimeEnvironment;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+@RunWith(AndroidJUnit4.class)
+public class XmlPullParserTest {
+
+  // emulator output:
+    /*
+http://schemas.android.com/apk/res/android:id(resId=16842960) type=CDATA: value=@16908308 (resId=16908308)
+http://schemas.android.com/apk/res/android:height(resId=16843093) type=CDATA: value=1234.0px (resId=-1)
+http://schemas.android.com/apk/res/android:width(resId=16843097) type=CDATA: value=1234.0px (resId=-1)
+http://schemas.android.com/apk/res/android:title(resId=16843233) type=CDATA: value=Android Title (resId=-1)
+http://schemas.android.com/apk/res/android:scrollbarFadeDuration(resId=16843432) type=CDATA: value=1111 (resId=-1)
+http://schemas.android.com/apk/res-auto:title(resId=2130771971) type=CDATA: value=App Title (resId=-1)
+:style(resId=0) type=CDATA: value=@android:style/TextAppearance.Small (resId=16973894)
+*/
+
+  @Test
+  public void xmlParser() throws IOException, XmlPullParserException {
+    Resources resources = ApplicationProvider.getApplicationContext().getResources();
+    XmlResourceParser parser = resources.getXml(R.xml.xml_attrs);
+    assertThat(parser).isNotNull();
+
+    assertThat(parser.getAttributeCount()).isEqualTo(-1);
+
+    assertThat(parser.next()).isEqualTo(XmlPullParser.START_DOCUMENT);
+    assertThat(parser.next()).isEqualTo(XmlPullParser.START_TAG);
+
+    assertThat(parser.getName()).isEqualTo("whatever");
+    int attributeCount = parser.getAttributeCount();
+
+    List<String> attrNames = new ArrayList<>();
+    for (int i = 0; i < attributeCount; i++) {
+      String namespace = parser.getAttributeNamespace(i);
+      if (!"http://www.w3.org/2000/xmlns/".equals(namespace)) {
+        attrNames.add(namespace + ":" + parser.getAttributeName(i));
+      }
+    }
+
+    assertThat(attrNames).containsExactly(
+        ANDROID_NS + ":id",
+        ANDROID_NS + ":height",
+        ANDROID_NS + ":width",
+        ANDROID_NS + ":title",
+        ANDROID_NS + ":scrollbarFadeDuration",
+        AUTO_NS + ":title",
+        ":style",
+        ":class",
+        ":id"
+    );
+
+    if (!RuntimeEnvironment.useLegacyResources()) {
+      // doesn't work in legacy mode, sorry
+      assertAttribute(parser,
+          ANDROID_NS, "id", android.R.attr.id, "@" + android.R.id.text1, android.R.id.text1);
+      assertAttribute(parser,
+          ANDROID_NS, "height", android.R.attr.height, "@" + android.R.dimen.app_icon_size,
+          android.R.dimen.app_icon_size);
+      assertAttribute(parser,
+          ANDROID_NS, "width", android.R.attr.width, "1234.0px", -1);
+      assertThat(parser.getAttributeResourceValue(null, "style", /*defaultValue=*/ -1))
+          .isEqualTo(android.R.style.TextAppearance_Small);
+    }
+    assertAttribute(parser,
+        ANDROID_NS, "title", android.R.attr.title, "Android Title", -1);
+    assertAttribute(parser,
+        ANDROID_NS, "scrollbarFadeDuration", android.R.attr.scrollbarFadeDuration, "1111", -1);
+    assertAttribute(parser,
+        AUTO_NS, "title", R.attr.title, "App Title", -1);
+
+    if (!RuntimeEnvironment.useLegacyResources()) {
+      // doesn't work in legacy mode, sorry
+      assertThat(parser.getStyleAttribute()).isEqualTo(android.R.style.TextAppearance_Small);
+    }
+    assertThat(parser.getIdAttributeResourceValue(/*defaultValue=*/ -1))
+        .isEqualTo(android.R.id.text2);
+    assertThat(parser.getClassAttribute()).isEqualTo("none");
+  }
+
+  @Test
+  public void buildAttrSet() {
+    XmlResourceParser parser = (XmlResourceParser) Robolectric.buildAttributeSet()
+        .addAttribute(android.R.attr.width, "1234px")
+        .addAttribute(android.R.attr.height, "@android:dimen/app_icon_size")
+        .addAttribute(android.R.attr.scrollbarFadeDuration, "1111")
+        .addAttribute(android.R.attr.title, "Android Title")
+        .addAttribute(R.attr.title, "App Title")
+        .addAttribute(android.R.attr.id, "@android:id/text1")
+        .setStyleAttribute("@android:style/TextAppearance.Small")
+        .setClassAttribute("none")
+        .setIdAttribute("@android:id/text2")
+        .build();
+
+    assertThat(parser.getName()).isEqualTo("dummy");
+    int attributeCount = parser.getAttributeCount();
+
+    List<String> attrNames = new ArrayList<>();
+    for (int i = 0; i < attributeCount; i++) {
+      attrNames.add(parser.getAttributeNamespace(i) + ":" + parser.getAttributeName(i));
+    }
+    assertThat(attrNames).containsExactly(
+        ANDROID_NS + ":id",
+        ANDROID_NS + ":height",
+        ANDROID_NS + ":width",
+        ANDROID_NS + ":title",
+        ANDROID_NS + ":scrollbarFadeDuration",
+        AUTO_NS + ":title",
+        ":style",
+        ":class",
+        ":id"
+    );
+
+    assertAttribute(parser,
+        ANDROID_NS, "id", android.R.attr.id, "@" + android.R.id.text1, android.R.id.text1);
+    assertAttribute(parser,
+        ANDROID_NS, "height", android.R.attr.height, "@" + android.R.dimen.app_icon_size,
+        android.R.dimen.app_icon_size);
+    assertAttribute(parser,
+        ANDROID_NS, "width", android.R.attr.width, "1234.0px", -1);
+    assertAttribute(parser,
+        "", "style", 0, "@android:style/TextAppearance.Small",
+        android.R.style.TextAppearance_Small);
+    assertAttribute(parser,
+        ANDROID_NS, "title", android.R.attr.title, "Android Title", -1);
+    assertAttribute(parser,
+        ANDROID_NS, "scrollbarFadeDuration", android.R.attr.scrollbarFadeDuration, "1111", -1);
+    assertAttribute(parser,
+        AUTO_NS, "title", R.attr.title, "App Title", -1);
+
+    assertThat(parser.getStyleAttribute()).isEqualTo(android.R.style.TextAppearance_Small);
+    assertThat(parser.getIdAttribute()).isEqualTo("@android:id/text2");
+    assertThat(parser.getClassAttribute()).isEqualTo("none");
+  }
+
+  void assertAttribute(XmlResourceParser parser,
+      String attrNs, String attrName, int resId, String value, int valueResId) {
+    assertThat(format(parser, attrNs, attrName))
+        .isEqualTo(format(attrNs, attrName, resId, "CDATA", value, valueResId));
+  }
+
+  private String format(XmlResourceParser parser, String namespace, String name) {
+    int attributeCount = parser.getAttributeCount();
+    for (int i = 0; i < attributeCount; i++) {
+      if (namespace.equals(parser.getAttributeNamespace(i))
+          && name.equals(parser.getAttributeName(i))) {
+        return format(parser.getAttributeNamespace(i), parser.getAttributeName(i),
+            parser.getAttributeNameResource(i), parser.getAttributeType(i),
+            parser.getAttributeValue(i), parser.getAttributeResourceValue(i, -1));
+      }
+    }
+    throw new RuntimeException("not found: " + namespace + ":" + name);
+  }
+
+  private String format(String attrNs, String attrName,
+      int attrResId, String type, String value, int valueResId) {
+    return attrNs + ":" + attrName
+        + "(resId=" + attrResId
+        + "): type=" + type
+        + ": value=" + value
+        + "(resId=" + valueResId
+        + ")";
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/OnMethodTestActivity.java b/robolectric/src/test/java/org/robolectric/shadows/testing/OnMethodTestActivity.java
new file mode 100644
index 0000000..56704d1
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/OnMethodTestActivity.java
@@ -0,0 +1,79 @@
+package org.robolectric.shadows.testing;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import java.util.List;
+
+public class OnMethodTestActivity extends Activity {
+  private final List<String> transcript;
+
+  public OnMethodTestActivity(List<String> transcript) {
+    this.transcript = transcript;
+  }
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    transcript.add("onCreate was called with " + savedInstanceState.get("key"));
+  }
+
+  @Override
+  protected void onStart() {
+    transcript.add("onStart was called");
+  }
+
+  @Override
+  protected void onRestoreInstanceState(Bundle savedInstanceState) {
+    transcript.add("onRestoreInstanceState was called");
+  }
+
+  @Override
+  protected void onPostCreate(Bundle savedInstanceState) {
+    transcript.add("onPostCreate was called");
+  }
+
+  @Override
+  protected void onRestart() {
+    transcript.add("onRestart was called");
+  }
+
+  @Override
+  protected void onResume() {
+    transcript.add("onResume was called");
+  }
+
+  @Override
+  protected void onPostResume() {
+    transcript.add("onPostResume was called");
+  }
+
+  @Override
+  protected void onNewIntent(Intent intent) {
+    transcript.add("onNewIntent was called with " + intent);
+  }
+
+  @Override
+  protected void onSaveInstanceState(Bundle outState) {
+    transcript.add("onSaveInstanceState was called");
+  }
+
+  @Override
+  protected void onPause() {
+    transcript.add("onPause was called");
+  }
+
+  @Override
+  protected void onUserLeaveHint() {
+    transcript.add("onUserLeaveHint was called");
+  }
+
+  @Override
+  protected void onStop() {
+    transcript.add("onStop was called");
+  }
+
+  @Override
+  protected void onDestroy() {
+    transcript.add("onDestroy was called");
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestActivity.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestActivity.java
new file mode 100644
index 0000000..2602419
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestActivity.java
@@ -0,0 +1,13 @@
+package org.robolectric.shadows.testing;
+
+import android.app.Activity;
+import android.os.Bundle;
+import org.robolectric.R;
+
+/** Activity for tests. */
+public class TestActivity extends Activity {
+  @Override protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.styles_button_layout);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestApplication.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestApplication.java
new file mode 100644
index 0000000..eb023e4
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestApplication.java
@@ -0,0 +1,6 @@
+package org.robolectric.shadows.testing;
+
+import android.app.Application;
+
+public class TestApplication extends Application {
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestConnectionService.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestConnectionService.java
new file mode 100644
index 0000000..23a033b
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestConnectionService.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows.testing;
+
+import android.telecom.Connection;
+import android.telecom.ConnectionRequest;
+import android.telecom.ConnectionService;
+import android.telecom.PhoneAccountHandle;
+import javax.annotation.Nullable;
+
+/** A fake {@link ConnectionService} implementation for testing. */
+public class TestConnectionService extends ConnectionService {
+
+  /** Listens for calls to {@link TestConnectionService} methods. */
+  public interface Listener {
+    @Nullable
+    default Connection onCreateIncomingConnection(
+        PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
+      return null;
+    }
+
+    default void onCreateIncomingConnectionFailed(
+        PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {}
+
+    @Nullable
+    default Connection onCreateOutgoingConnection(
+        PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
+      return null;
+    }
+
+    default void onCreateOutgoingConnectionFailed(
+        PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {}
+  }
+
+  private static Listener listener = new Listener() {};
+
+  public static void setListener(Listener listener) {
+    TestConnectionService.listener = listener == null ? new Listener() {} : listener;
+  }
+
+  @Override
+  @Nullable
+  public Connection onCreateIncomingConnection(
+      PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
+    return listener.onCreateIncomingConnection(connectionManagerPhoneAccount, request);
+  }
+
+  @Override
+  public void onCreateIncomingConnectionFailed(
+      PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
+    listener.onCreateIncomingConnectionFailed(connectionManagerPhoneAccount, request);
+  }
+
+  @Override
+  @Nullable
+  public Connection onCreateOutgoingConnection(
+      PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
+    return listener.onCreateOutgoingConnection(connectionManagerPhoneAccount, request);
+  }
+
+  @Override
+  public void onCreateOutgoingConnectionFailed(
+      PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
+    listener.onCreateOutgoingConnectionFailed(connectionManagerPhoneAccount, request);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider1.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider1.java
new file mode 100644
index 0000000..b0efcad
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider1.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows.testing;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import java.util.ArrayList;
+import java.util.List;
+
+public class TestContentProvider1 extends ContentProvider {
+
+  public final List<String> transcript = new ArrayList<>();
+
+  @Override
+  public boolean onCreate() {
+    transcript.add("onCreate");
+    return false;
+  }
+
+  @Override
+  public void shutdown() {
+    super.shutdown();
+    transcript.add("shutdown");
+  }
+
+  @Override
+  public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+    transcript.add("query for " + uri);
+    return null;
+  }
+
+  @Override
+  public String getType(Uri uri) {
+    return null;
+  }
+
+  @Override
+  public Uri insert(Uri uri, ContentValues values) {
+    return null;
+  }
+
+  @Override
+  public int delete(Uri uri, String selection, String[] selectionArgs) {
+    return 0;
+  }
+
+  @Override
+  public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+    return 0;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider2.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider2.java
new file mode 100644
index 0000000..fb4ae20
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider2.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows.testing;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+public class TestContentProvider2 extends ContentProvider {
+
+  @Override
+  public int delete(Uri arg0, String arg1, String[] arg2) {
+    return 0;
+  }
+
+  @Override
+  public String getType(Uri arg0) {
+    return null;
+  }
+
+  @Override
+  public Uri insert(Uri arg0, ContentValues arg1) {
+    return null;
+  }
+
+  @Override
+  public boolean onCreate() {
+    return false;
+  }
+
+  @Override
+  public Cursor query(Uri arg0, String[] arg1, String arg2, String[] arg3, String arg4) {
+    return null;
+  }
+
+  @Override
+  public int update(Uri arg0, ContentValues arg1, String arg2, String[] arg3) {
+    return 0;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider3And4.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider3And4.java
new file mode 100644
index 0000000..f9c6666
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestContentProvider3And4.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows.testing;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Test class for a ContentProvider with multiple authorities */
+public class TestContentProvider3And4 extends ContentProvider {
+
+  public final List<String> transcript = new ArrayList<>();
+
+  @Override
+  public boolean onCreate() {
+    transcript.add("onCreate");
+    return false;
+  }
+
+  @Override
+  public void shutdown() {
+    super.shutdown();
+    transcript.add("shutdown");
+  }
+
+  @Override
+  public Cursor query(
+      Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+    transcript.add("query for " + uri);
+    return null;
+  }
+
+  @Override
+  public String getType(Uri uri) {
+    return null;
+  }
+
+  @Override
+  public Uri insert(Uri uri, ContentValues values) {
+    return null;
+  }
+
+  @Override
+  public int delete(Uri uri, String selection, String[] selectionArgs) {
+    return 0;
+  }
+
+  @Override
+  public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+    return 0;
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/shadows/testing/TestDialogPreference.java b/robolectric/src/test/java/org/robolectric/shadows/testing/TestDialogPreference.java
new file mode 100644
index 0000000..3adac1a
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/shadows/testing/TestDialogPreference.java
@@ -0,0 +1,11 @@
+package org.robolectric.shadows.testing;
+
+import android.content.Context;
+import android.preference.DialogPreference;
+import android.util.AttributeSet;
+
+public class TestDialogPreference extends DialogPreference {
+  public TestDialogPreference(Context context, AttributeSet attrs) {
+    super(context, attrs);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/test/DummyClass.java b/robolectric/src/test/java/org/robolectric/test/DummyClass.java
new file mode 100644
index 0000000..e2e8cb2
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/test/DummyClass.java
@@ -0,0 +1,13 @@
+package org.robolectric.test;
+
+import org.robolectric.internal.bytecode.SandboxClassLoader;
+
+/**
+ * Dummy class placed in package that is not loaded by parent classloader of {@link SandboxClassLoader}.
+ * @see org.robolectric.RobolectricTestRunnerClassLoaderConfigTest#testGetPackage()
+ */
+public class DummyClass {
+
+  // nothing here
+
+}
diff --git a/robolectric/src/test/java/org/robolectric/tester/ConfigTestReceiverPermissionsAndActions.java b/robolectric/src/test/java/org/robolectric/tester/ConfigTestReceiverPermissionsAndActions.java
new file mode 100644
index 0000000..98c1172
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/tester/ConfigTestReceiverPermissionsAndActions.java
@@ -0,0 +1,11 @@
+package org.robolectric.tester;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class ConfigTestReceiverPermissionsAndActions extends BroadcastReceiver {
+  @Override
+  public void onReceive(Context context, Intent intent) {
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/util/SQLiteLibraryLoaderTest.java b/robolectric/src/test/java/org/robolectric/util/SQLiteLibraryLoaderTest.java
new file mode 100644
index 0000000..3b06a4e
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/util/SQLiteLibraryLoaderTest.java
@@ -0,0 +1,241 @@
+package org.robolectric.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadows.util.SQLiteLibraryLoader;
+
+@RunWith(AndroidJUnit4.class)
+public class SQLiteLibraryLoaderTest {
+  private static final SQLiteLibraryLoader.LibraryNameMapper LINUX =
+      new LibraryMapperTest("lib", "so");
+  private static final SQLiteLibraryLoader.LibraryNameMapper WINDOWS =
+      new LibraryMapperTest("", "dll");
+  private static final SQLiteLibraryLoader.LibraryNameMapper MAC =
+      new LibraryMapperTest("lib", "dylib");
+  private static final String OS_NAME_WINDOWS_XP = "Windows XP";
+  private static final String OS_NAME_WINDOWS_7 = "Windows 7";
+  private static final String OS_NAME_WINDOWS_10 = "Windows 10";
+  private static final String OS_NAME_LINUX = "Some linux version";
+  private static final String OS_NAME_MAC = "Mac OS X";
+  private static final String OS_ARCH_ARM64 = "aarch64";
+  private static final String OS_ARCH_X86 = "x86";
+  private static final String OS_ARCH_X64 = "x86_64";
+  private static final String OS_ARCH_AMD64 = "amd64";
+  private static final String OS_ARCH_I386 = "i386";
+  private static final String SYSTEM_PROPERTY_OS_NAME = "os.name";
+  private static final String SYSTEM_PROPERTY_OS_ARCH = "os.arch";
+
+  /** Saved system properties. */
+  private String savedOs, savedArch;
+
+  private SQLiteLibraryLoader loader;
+
+  @Before
+  public void setUp() {
+    loader = new SQLiteLibraryLoader();
+  }
+
+  @Before
+  public void saveSystemProperties() {
+    savedOs = System.getProperty(SYSTEM_PROPERTY_OS_NAME);
+    savedArch = System.getProperty(SYSTEM_PROPERTY_OS_ARCH);
+  }
+
+  @After
+  public void restoreSystemProperties() {
+    System.setProperty(SYSTEM_PROPERTY_OS_NAME, savedOs);
+    System.setProperty(SYSTEM_PROPERTY_OS_ARCH, savedArch);
+  }
+
+  @Test
+  public void shouldExtractNativeLibrary() {
+    assumeTrue(SQLiteLibraryLoader.isOsSupported());
+    assertThat(loader.isLoaded()).isFalse();
+    loader.doLoad();
+    assertThat(loader.isLoaded()).isTrue();
+  }
+
+  @Test
+  public void shouldFindLibraryForWindowsXPX86() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(WINDOWS), OS_NAME_WINDOWS_XP, OS_ARCH_X86))
+        .isEqualTo("sqlite4java/win32-x86/sqlite4java.dll");
+  }
+
+  @Test
+  public void shouldFindLibraryForWindows7X86() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(WINDOWS), OS_NAME_WINDOWS_7, OS_ARCH_X86))
+        .isEqualTo("sqlite4java/win32-x86/sqlite4java.dll");
+  }
+
+  @Test
+  public void shouldFindLibraryForWindowsXPAmd64() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(WINDOWS), OS_NAME_WINDOWS_XP, OS_ARCH_AMD64))
+        .isEqualTo("sqlite4java/win32-x64/sqlite4java.dll");
+  }
+
+  @Test
+  public void shouldFindLibraryForWindows7Amd64() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(WINDOWS), OS_NAME_WINDOWS_7, OS_ARCH_AMD64))
+        .isEqualTo("sqlite4java/win32-x64/sqlite4java.dll");
+  }
+
+  @Test
+  public void shouldFindLibraryForWindows10Amd64() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(WINDOWS), OS_NAME_WINDOWS_10, OS_ARCH_AMD64))
+        .isEqualTo("sqlite4java/win32-x64/sqlite4java.dll");
+  }
+
+  @Test
+  public void shouldFindLibraryForLinuxI386() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(LINUX), OS_NAME_LINUX, OS_ARCH_I386))
+        .isEqualTo("sqlite4java/linux-i386/libsqlite4java.so");
+  }
+
+  @Test
+  public void shouldFindLibraryForLinuxX86() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(LINUX), OS_NAME_LINUX, OS_ARCH_X86))
+        .isEqualTo("sqlite4java/linux-i386/libsqlite4java.so");
+  }
+
+  @Test
+  public void shouldFindLibraryForLinuxAmd64() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(LINUX), OS_NAME_LINUX, OS_ARCH_AMD64))
+        .isEqualTo("sqlite4java/linux-amd64/libsqlite4java.so");
+  }
+
+  @Test
+  public void shouldFindLibraryForMacWithI386() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(MAC), OS_NAME_MAC, OS_ARCH_I386))
+        .isEqualTo("sqlite4java/osx/libsqlite4java.dylib");
+  }
+
+  @Test
+  public void shouldFindLibraryForMacWithX86() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(MAC), OS_NAME_MAC, OS_ARCH_X86))
+        .isEqualTo("sqlite4java/osx/libsqlite4java.dylib");
+  }
+
+  @Test
+  public void shouldFindLibraryForMacWithX64() {
+    assertThat(loadLibrary(new SQLiteLibraryLoader(MAC), OS_NAME_MAC, OS_ARCH_X64))
+        .isEqualTo("sqlite4java/osx/libsqlite4java.dylib");
+  }
+
+  @Test
+  public void shouldNotFindLibraryForMacWithARM64() {
+    assertThrows(
+        UnsupportedOperationException.class,
+        () -> loadLibrary(new SQLiteLibraryLoader(MAC), OS_NAME_MAC, OS_ARCH_ARM64));
+  }
+
+  @Test
+  public void shouldThrowExceptionIfUnknownNameAndArch() {
+    assertThrows(
+        UnsupportedOperationException.class,
+        () -> loadLibrary(new SQLiteLibraryLoader(LINUX), "ACME Electronic", "FooBar2000"));
+  }
+
+  @Test
+  public void shouldNotSupportMacOSWithArchArm64() {
+    setNameAndArch(OS_NAME_MAC, OS_ARCH_ARM64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isFalse();
+  }
+
+  @Test
+  public void shouldSupportMacOSWithArchX86() {
+    setNameAndArch(OS_NAME_MAC, OS_ARCH_X86);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportMacOSWithArchX64() {
+    setNameAndArch(OS_NAME_MAC, OS_ARCH_X64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportWindowsXPWithArchX86() {
+    setNameAndArch(OS_NAME_WINDOWS_XP, OS_ARCH_X86);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportWindowsXPWithArcAMD64() {
+    setNameAndArch(OS_NAME_WINDOWS_XP, OS_ARCH_AMD64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportWindows7WithArchX86() {
+    setNameAndArch(OS_NAME_WINDOWS_7, OS_ARCH_X86);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportWindows7WithAMD64() {
+    setNameAndArch(OS_NAME_WINDOWS_7, OS_ARCH_AMD64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportWindows10WithAMD64() {
+    setNameAndArch(OS_NAME_WINDOWS_10, OS_ARCH_AMD64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportLinuxWithI386() {
+    setNameAndArch(OS_NAME_LINUX, OS_ARCH_I386);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportLinuxWithX86() {
+    setNameAndArch(OS_NAME_LINUX, OS_ARCH_X86);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportLinuxWithX64() {
+    setNameAndArch(OS_NAME_LINUX, OS_ARCH_X64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  @Test
+  public void shouldSupportLinuxWithAMD64() {
+    setNameAndArch(OS_NAME_LINUX, OS_ARCH_AMD64);
+    assertThat(SQLiteLibraryLoader.isOsSupported()).isTrue();
+  }
+
+  private String loadLibrary(SQLiteLibraryLoader loader, String name, String arch) {
+    setNameAndArch(name, arch);
+    return loader.getLibClasspathResourceName();
+  }
+
+  private static class LibraryMapperTest implements SQLiteLibraryLoader.LibraryNameMapper {
+    private final String prefix;
+    private final String ext;
+
+    private LibraryMapperTest(String prefix, String ext) {
+      this.prefix = prefix;
+      this.ext = ext;
+    }
+
+    @Override
+    public String mapLibraryName(String name) {
+      return prefix + name + "." + ext;
+    }
+  }
+
+  private static void setNameAndArch(String name, String arch) {
+    System.setProperty(SYSTEM_PROPERTY_OS_NAME, name);
+    System.setProperty(SYSTEM_PROPERTY_OS_ARCH, arch);
+  }
+}
diff --git a/robolectric/src/test/java/org/robolectric/util/TestUtil.java b/robolectric/src/test/java/org/robolectric/util/TestUtil.java
new file mode 100644
index 0000000..e1c6ba3
--- /dev/null
+++ b/robolectric/src/test/java/org/robolectric/util/TestUtil.java
@@ -0,0 +1,88 @@
+package org.robolectric.util;
+
+import com.google.common.io.CharStreams;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.util.Properties;
+import org.robolectric.R;
+import org.robolectric.pluginapi.Sdk;
+import org.robolectric.plugins.SdkCollection;
+import org.robolectric.res.Fs;
+import org.robolectric.res.ResourcePath;
+import org.robolectric.util.inject.Injector;
+
+public abstract class TestUtil {
+  private static ResourcePath SYSTEM_RESOURCE_PATH;
+  private static ResourcePath TEST_RESOURCE_PATH;
+  private static File testDirLocation;
+  private static SdkCollection sdkCollection;
+  private static final Injector injector = new Injector.Builder()
+      .bind(Properties.class, System.getProperties()).build();
+
+  public static Path resourcesBaseDir() {
+    return resourcesBaseDirFile().toPath();
+  }
+
+  private static File resourcesBaseDirFile() {
+    if (testDirLocation == null) {
+      String baseDir = System.getProperty("robolectric-tests.base-dir");
+      return testDirLocation = new File(baseDir, "src/test/resources");
+    } else {
+      return testDirLocation;
+    }
+  }
+
+  public static Path resourceFile(String... pathParts) {
+    return Fs.join(resourcesBaseDir(), pathParts);
+  }
+
+  public static ResourcePath testResources() {
+    if (TEST_RESOURCE_PATH == null) {
+      TEST_RESOURCE_PATH = new ResourcePath(R.class, resourceFile("res"), resourceFile("assets"));
+    }
+    return TEST_RESOURCE_PATH;
+  }
+
+  public static ResourcePath systemResources() {
+    if (SYSTEM_RESOURCE_PATH == null) {
+      Sdk sdk = getSdkCollection().getMaxSupportedSdk();
+      Path path = sdk.getJarPath();
+      SYSTEM_RESOURCE_PATH =
+          new ResourcePath(
+              android.R.class, path.resolve("raw-res/res"), path.resolve("raw-res/assets"));
+    }
+    return SYSTEM_RESOURCE_PATH;
+  }
+
+  public static ResourcePath sdkResources(int apiLevel) {
+    Path path = getSdkCollection().getSdk(apiLevel).getJarPath();
+    return new ResourcePath(null, path.resolve("raw-res/res"), null, null);
+  }
+
+  public static String readString(InputStream is) throws IOException {
+    return CharStreams.toString(new InputStreamReader(is, "UTF-8"));
+  }
+
+  public static synchronized SdkCollection getSdkCollection() {
+    if (sdkCollection == null) {
+      sdkCollection = getInjectedInstance(SdkCollection.class);
+    }
+    return sdkCollection;
+  }
+
+  public static void resetSystemProperty(String name, String value) {
+    if (value == null) {
+      System.clearProperty(name);
+    } else {
+      System.setProperty(name, value);
+    }
+  }
+
+  private static <T> T getInjectedInstance(Class<T> clazz) {
+    return injector.getInstance(clazz);
+  }
+
+}
diff --git a/robolectric/src/test/resources/AndroidManifest.xml b/robolectric/src/test/resources/AndroidManifest.xml
new file mode 100644
index 0000000..f5e755d
--- /dev/null
+++ b/robolectric/src/test/resources/AndroidManifest.xml
@@ -0,0 +1,240 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+      package="org.robolectric" android:sharedUserId="sharedUserId.robolectric"
+    android:versionCode="123"
+    android:versionName="aVersionName">
+  <uses-sdk android:targetSdkVersion="23"/>
+
+  <permission android:name="some_permission"
+      android:description="@string/test_permission_description"
+      android:icon="@drawable/an_image"
+      android:label="@string/test_permission_label"
+      android:permissionGroup="my_permission_group"
+      android:protectionLevel="dangerous">
+    <meta-data android:name="meta_data_name" android:value="meta_data_value"/>
+  </permission>
+
+  <permission android:name="permission_with_literal_label"
+      android:description="@string/test_permission_description"
+      android:icon="@drawable/an_image"
+      android:label="Literal label"/>
+
+  <permission android:name="permission_with_minimal_fields"/>
+  
+  <permission-group android:name="package_permission_group"
+    android:icon="@drawable/an_image"
+    android:label="Permission Group Label"
+    />
+
+    <!-- For SettingsTest -->
+  <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+  <uses-permission android:name="android.permission.GET_TASKS"/>
+
+  <application android:name="org.robolectric.shadows.testing.TestApplication"
+         android:theme="@style/Theme.Robolectric"
+         android:label="@string/app_name"
+         android:allowBackup="true"
+         android:allowClearUserData="true"
+         android:allowTaskReparenting="true"
+         android:debuggable="true"
+         android:hasCode="true"
+         android:killAfterRestore="true"
+         android:persistent="true"
+         android:resizeable="true"
+         android:restoreAnyVersion="true"
+         android:largeScreens="true"
+         android:normalScreens="true"
+         android:smallScreens="true"
+         android:anyDensity="true"
+         android:vmSafeMode="true">
+
+    <meta-data android:name="android.content.APP_RESTRICTIONS" android:resource="@xml/app_restrictions" />
+    <meta-data android:name="org.robolectric.metaName1" android:value="metaValue1" />
+    <meta-data android:name="org.robolectric.metaName2" android:value="metaValue2" />
+    <meta-data android:name="org.robolectric.metaTrueLiteral" android:value="true" />
+    <meta-data android:name="org.robolectric.metaFalseLiteral" android:value="false" />
+    <meta-data android:name="org.robolectric.metaInt" android:value="123" />
+    <meta-data android:name="org.robolectric.metaFloat" android:value="1.23" />
+    <meta-data android:name="org.robolectric.metaColor" android:value="#FFFFFF" />
+    <meta-data android:name="org.robolectric.metaBooleanFromRes" android:value="@bool/false_bool_value" />
+    <meta-data android:name="org.robolectric.metaIntFromRes" android:value="@integer/test_integer1" />
+    <meta-data android:name="org.robolectric.metaColorFromRes" android:value="@color/clear" />
+    <meta-data android:name="org.robolectric.metaStringFromRes" android:value="@string/app_name" />
+    <meta-data android:name="org.robolectric.metaStringOfIntFromRes" android:value="@string/str_int" />
+    <meta-data android:name="org.robolectric.metaStringRes" android:resource="@string/app_name" />
+
+    <activity android:name="org.robolectric.shadows.testing.TestActivity"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowThemeTest$TestActivityWithAnotherTheme"
+              android:theme="@style/Theme.AnotherTheme"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$ParentActivity"/>
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$ChildActivity"
+              android:parentActivityName="org.robolectric.shadows.ShadowActivityTest$ParentActivity"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowPackageManagerTest$ActivityWithMetadata">
+      <meta-data android:name="someName" android:value="someValue"/>
+    </activity>
+
+    <activity android:name="org.robolectric.shadows.ShadowPackageManagerTest$ActivityWithConfigChanges"
+              android:configChanges="screenLayout|orientation"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$LabelTestActivity1" />
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$LabelTestActivity2"
+              android:label="@string/activity_name"/>
+    <activity android:name=".shadows.ShadowActivityTest$LabelTestActivity3"
+              android:label="@string/activity_name"/>
+
+    <activity android:name=".android.controller.ActivityControllerTest$ConfigAwareActivity"
+              android:configChanges="fontScale|smallestScreenSize" />
+
+    <activity android:name="org.robolectric.shadows.TestActivity">
+      <intent-filter>
+        <action android:name="android.intent.action.VIEW"/>
+        <category android:name="android.intent.category.DEFAULT"/>
+        <data android:scheme="content"
+              android:host="testhost1.com"
+              android:port="1"
+              android:path="/testPath/test.jpeg"
+              android:mimeType="video/mpeg" />
+        <data android:scheme="http"
+              android:host="testhost2.com"
+              android:port="2"
+              android:pathPrefix="/testPrefix"
+              android:mimeType="image/jpeg" />
+        <data android:scheme="https"
+              android:host="testhost3.com"
+              android:port="3"
+              android:pathPattern="/.*testPattern"
+              android:mimeType="image/*" />
+      </intent-filter>
+    </activity>
+
+    <activity android:name="org.robolectric.shadows.ShadowPackageManagerTest$ActivityWithFilters"
+        android:permission="com.foo.MY_PERMISSION">
+      <intent-filter>
+        <action android:name="android.intent.action.VIEW"/>
+        <category android:name="android.intent.category.DEFAULT"/>
+        <data android:scheme="content"
+            android:host="testhost1.com"
+            android:port="1"
+            android:path="/testPath/test.jpeg"
+            android:mimeType="video/mpeg" />
+      </intent-filter>
+    </activity>
+
+    <activity android:name="org.robolectric.shadows.DisabledActivity" android:enabled="false"/>
+
+    <activity-alias
+            android:name="org.robolectric.shadows.TestActivityAlias"
+            android:targetActivity=".shadows.TestActivity">
+      <intent-filter>
+        <action android:name="android.intent.action.MAIN"/>
+        <category android:name="android.intent.category.LAUNCHER"/>
+      </intent-filter>
+    </activity-alias>
+
+    <service android:name="com.foo.Service" android:permission="com.foo.MY_PERMISSION">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DIFFERENT_PACKAGE"/>
+        <data android:mimeType="image/jpeg"/>
+        <category android:name="android.intent.category.LAUNCHER"/>
+      </intent-filter>
+      <meta-data android:name="metadatasample" android:value="sample"/>
+    </service>
+
+    <service android:name="com.bar.ServiceWithoutIntentFilter"/>
+
+    <service android:name="org.robolectric.shadows.DisabledService" android:enabled="false"/>
+
+    <service android:name="org.robolectric.TestWallpaperService"
+        android:label="Test Wallpaper Service"
+        android:exported="false"
+        android:permission="android.permission.BIND_WALLPAPER">
+      <intent-filter>
+        <action android:name="android.service.wallpaper.WallpaperService"/>
+      </intent-filter>
+      <meta-data android:name="android.service.wallpaper"
+          android:resource="@xml/test_wallpaper"/>
+    </service>
+
+    <!-- Fully qualified name reference -->
+    <provider
+        android:name="org.robolectric.shadows.testing.TestContentProvider1"
+        android:authorities="org.robolectric.authority1"
+        android:permission="PERMISSION"
+        android:readPermission="READ_PERMISSION"
+        android:writePermission="WRITE_PERMISSION">
+      <path-permission
+          android:pathPattern="/path/*"
+          android:readPermission="PATH_READ_PERMISSION"
+          android:writePermission="PATH_WRITE_PERMISSION"/>
+      <meta-data android:name="greeting" android:value="@string/hello"/>
+    </provider>
+
+
+    <!-- Partially qualified name reference -->
+    <provider
+            android:name=".shadows.testing.TestContentProvider2"
+            android:authorities="org.robolectric.authority2"/>
+
+    <!-- Multiple authorities -->
+    <provider
+            android:name=".shadows.testing.TestContentProvider3And4"
+            android:authorities="org.robolectric.authority3;org.robolectric.authority4"/>
+
+    <receiver android:name="org.robolectric.ConfigTestReceiver.InnerReceiver"
+              android:permission="com.ignored.PERM">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION1"/>
+        <category android:name="com.ignored"/>
+      </intent-filter>
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION2"/>
+        <category android:name="com.ignored"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name="org.robolectric.fakes.ConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_SUPERSET_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name="org.robolectric.ConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_SUBSET_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name=".DotConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DOT_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name=".test.ConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DOT_SUBPACKAGE"/>
+      </intent-filter>
+      <meta-data android:name="numberOfSheep" android:value="42" />
+    </receiver>
+
+    <receiver android:name="com.foo.Receiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DIFFERENT_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name="com.bar.ReceiverWithoutIntentFilter"/>
+    <receiver android:name="org.robolectric.ConfigTestReceiverPermissionsAndActions"
+              android:permission="org.robolectric.CUSTOM_PERM">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_RECEIVER_PERMISSION_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+  </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifest.xml b/robolectric/src/test/resources/TestAndroidManifest.xml
new file mode 100644
index 0000000..5aa6110
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifest.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+      package="org.robolectric">
+  <uses-sdk android:targetSdkVersion="23"/>
+
+  <application android:name="org.robolectric.shadows.testing.TestApplication"
+         android:theme="@style/Theme.Robolectric"
+         android:label="@string/app_name">
+
+    <activity android:name="org.robolectric.shadows.testing.TestActivity"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest.TestActivityWithAnotherTheme"
+          android:theme="@style/Theme.AnotherTheme"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$ParentActivity"/>
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$ChildActivity"
+        android:parentActivityName="org.robolectric.shadows.ShadowActivityTest$ParentActivity"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowPackageManagerTest$ActivityWithMetadata">
+      <meta-data android:name="someName" android:value="someValue"/>
+    </activity>
+
+    <activity android:name="org.robolectric.shadows.ShadowPackageManagerTest$ActivityWithConfigChanges"
+              android:configChanges="mcc|screenLayout|orientation"/>
+
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$LabelTestActivity1" />
+    <activity android:name="org.robolectric.shadows.ShadowActivityTest$LabelTestActivity2"
+              android:label="@string/activity_name"/>
+    <activity android:name=".shadows.ShadowActivityTest$LabelTestActivity3"
+              android:label="@string/activity_name"/>
+    
+    <activity android:name=".android.controller.ActivityControllerTest$ConfigAwareActivity"
+              android:configChanges="fontScale" />
+  </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestForActivities.xml b/robolectric/src/test/resources/TestAndroidManifestForActivities.xml
new file mode 100644
index 0000000..b5e5227
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestForActivities.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="16"/>
+
+    <application>
+        <activity android:name="org.robolectric.shadows.TestActivity"/>
+
+        <activity android:name=".shadows.TestActivity2"/>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithIntentFilter.xml b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithIntentFilter.xml
new file mode 100644
index 0000000..a44e70a
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithIntentFilter.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="16"/>
+
+    <application>
+        <activity android:name="org.robolectric.shadows.TestActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </activity>
+        <service android:name="org.robolectric.shadows.TestService">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </service>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithIntentFilterWithData.xml b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithIntentFilterWithData.xml
new file mode 100644
index 0000000..b20f7fe
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithIntentFilterWithData.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric"
+          name="ImplicitIntentTestApp">
+    <uses-sdk android:targetSdkVersion="16"/>
+
+    <application>
+        <activity android:name="org.robolectric.shadows.TestActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW"/>
+                <category android:name="android.intent.category.DEFAULT"/>
+                <data android:scheme="content"
+                      android:host="testhost1.com"
+                      android:port="1"
+                      android:path="/testPath/test.jpeg"
+                      android:mimeType="video/mpeg" />
+                <data android:scheme="http"
+                      android:host="testhost2.com"
+                      android:port="2"
+                      android:pathPrefix="/testPrefix"
+                      android:mimeType="image/jpeg" />
+                <data android:scheme="https"
+                      android:host="testhost3.com"
+                      android:port="3"
+                      android:pathPattern="/.*testPattern"
+                      android:mimeType="image/*" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithMultipleIntentFilters.xml b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithMultipleIntentFilters.xml
new file mode 100644
index 0000000..5d4025d
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithMultipleIntentFilters.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="16"/>
+
+    <application>
+        <activity android:name="org.robolectric.shadows.TestActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW"/>
+                <action android:name="android.intent.action.EDIT"/>
+                <action android:name="android.intent.action.PICK"/>
+                <category android:name="android.intent.category.DEFAULT"/>
+                <category android:name="android.intent.category.ALTERNATIVE"/>
+                <category android:name="android.intent.category.SELECTED_ALTERNATIVE"/>
+                <data android:mimeType="vnd.android.cursor.dir/vnd.google.note"/>
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithTaskAffinity.xml b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithTaskAffinity.xml
new file mode 100644
index 0000000..7329d1a
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestForActivitiesWithTaskAffinity.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="16"/>
+
+    <application>
+        <activity android:name="org.robolectric.shadows.TestTaskAffinityActivity"
+                  android:taskAffinity="org.robolectric.shadows.TestTaskAffinity">
+        </activity>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestForActivityAliases.xml b/robolectric/src/test/resources/TestAndroidManifestForActivityAliases.xml
new file mode 100644
index 0000000..35613df
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestForActivityAliases.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="16"/>
+
+    <application>
+        <activity android:name="org.robolectric.shadows.TestActivity">
+        </activity>
+
+        <activity-alias
+                android:name="org.robolectric.shadows.TestActivityAlias"
+                android:targetActivity=".shadows.TestActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </activity-alias>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestNoApplicationElement.xml b/robolectric/src/test/resources/TestAndroidManifestNoApplicationElement.xml
new file mode 100644
index 0000000..cc6436b
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestNoApplicationElement.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+      package="org.robolectric">
+  <uses-sdk android:targetSdkVersion="18"/>
+
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithAppMetaData.xml b/robolectric/src/test/resources/TestAndroidManifestWithAppMetaData.xml
new file mode 100644
index 0000000..8b463b0
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithAppMetaData.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric">
+
+  <uses-sdk android:targetSdkVersion="18"/>
+  <application android:name="org.robolectric.shadows.testing.TestApplication"
+               android:label="App Label">
+    <meta-data android:name="org.robolectric.metaName1" android:value="metaValue1" />
+    <meta-data android:name="org.robolectric.metaName2" android:value="metaValue2" />
+    <meta-data android:name="org.robolectric.metaTrue" android:value="true" />
+    <meta-data android:name="org.robolectric.metaFalse" android:value="false" />
+    <meta-data android:name="org.robolectric.metaInt" android:value="123" />
+    <meta-data android:name="org.robolectric.metaFloat" android:value="1.23" />
+    <meta-data android:name="org.robolectric.metaColor" android:value="#FFFFFF" />
+    <meta-data android:name="org.robolectric.metaBooleanFromRes" android:value="@bool/false_bool_value" />
+    <meta-data android:name="org.robolectric.metaIntFromRes" android:value="@integer/test_integer1" />
+    <meta-data android:name="org.robolectric.metaColorFromRes" android:value="@color/clear" />
+    <meta-data android:name="org.robolectric.metaStringFromRes" android:value="@string/app_name" />
+    <meta-data android:name="org.robolectric.metaStringOfIntFromRes" android:value="@string/str_int" />
+    <meta-data android:name="org.robolectric.metaStringRes" android:resource="@string/app_name" />
+  </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithContentProviders.xml b/robolectric/src/test/resources/TestAndroidManifestWithContentProviders.xml
new file mode 100644
index 0000000..78c51eb
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithContentProviders.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.robolectric">
+  <uses-sdk android:targetSdkVersion="18"/>
+  <application>
+    <provider
+      android:name="org.robolectric.tester.FullyQualifiedClassName"
+      android:authorities="org.robolectric.authority1"/>
+
+    <provider
+      android:name=".tester.PartiallyQualifiedClassName"
+      android:authorities="org.robolectric.authority2"
+      android:enabled="false"/>
+
+    <provider
+      android:name="org.robolectric.android.controller.ContentProviderControllerTest$MyContentProvider"
+      android:authorities="org.robolectric.authority2"
+      android:permission="PERMISSION"
+      android:readPermission="READ_PERMISSION"
+      android:writePermission="WRITE_PERMISSION"
+      android:grantUriPermissions="true"
+    >
+      <path-permission
+              android:pathPattern="/path/*"
+              android:readPermission="PATH_READ_PERMISSION"
+              android:writePermission="PATH_WRITE_PERMISSION"
+      />
+      <meta-data android:name="greeting" android:value="@string/hello"/>
+    </provider>
+  </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithFlags.xml b/robolectric/src/test/resources/TestAndroidManifestWithFlags.xml
new file mode 100644
index 0000000..540d337
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithFlags.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric">
+  <application android:name="org.robolectric.shadows.testing.TestApplication"
+      android:allowBackup="true"
+      android:allowClearUserData="true"
+      android:allowTaskReparenting="true"
+      android:debuggable="true"
+      android:hasCode="true"
+      android:killAfterRestore="true"
+      android:persistent="true"
+      android:resizeable="true"
+      android:restoreAnyVersion="true"
+      android:largeScreens="true"
+      android:normalScreens="true"
+      android:smallScreens="true"
+      android:anyDensity="true"
+      android:testOnly="true"
+      android:vmSafeMode="true"/>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithNoContentProviders.xml b/robolectric/src/test/resources/TestAndroidManifestWithNoContentProviders.xml
new file mode 100644
index 0000000..a05ae9f
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithNoContentProviders.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="18"/>
+    <application>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithNoProcess.xml b/robolectric/src/test/resources/TestAndroidManifestWithNoProcess.xml
new file mode 100644
index 0000000..f72efd1
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithNoProcess.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+       package="org.robolectric">
+  <application android:name="org.robolectric.shadows.testing.TestApplication"/>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithNoRFile.xml b/robolectric/src/test/resources/TestAndroidManifestWithNoRFile.xml
new file mode 100644
index 0000000..d7710fb
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithNoRFile.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.no.resources.for.me">
+  <application android:name=".TestApplication"
+      android:process="robolectricprocess"/>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithPermissions.xml b/robolectric/src/test/resources/TestAndroidManifestWithPermissions.xml
new file mode 100644
index 0000000..cf3eb78
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithPermissions.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="18"/>
+
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+    <uses-permission android:name="android.permission.GET_TASKS"/>
+
+    <permission android:name="some_permission"
+                android:description="@string/test_permission_description"
+                android:icon="drawable resource"
+                android:label="@string/test_permission_label"
+                android:permissionGroup="my_permission_group"
+                android:protectionLevel="dangerous">
+        <meta-data android:name="meta_data_name" android:value="meta_data_value"/>
+    </permission>
+
+    <permission android:name="permission_with_literal_label"
+                android:description="@string/test_permission_description"
+                android:icon="drawable resource"
+                android:label="Literal label"/>
+
+    <permission android:name="permission_with_minimal_fields"/>
+
+    <permission-group android:name="permission_group"
+                android:description="@string/test_permission_description"
+                android:icon="drawable resource"
+                android:label="Literal label"/>
+
+    <application>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithProcess.xml b/robolectric/src/test/resources/TestAndroidManifestWithProcess.xml
new file mode 100644
index 0000000..2a1c50e
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithProcess.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric">
+  <application android:name="org.robolectric.shadows.testing.TestApplication"
+      android:process="robolectricprocess"/>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithProtectionLevels.xml b/robolectric/src/test/resources/TestAndroidManifestWithProtectionLevels.xml
new file mode 100644
index 0000000..c853365
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithProtectionLevels.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="18"/>
+
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+    <uses-permission android:name="android.permission.GET_TASKS"/>
+
+    <permission android:name="dangerous_permission"
+                android:protectionLevel="dangerous" />
+
+    <permission android:name="signature_or_privileged_permission"
+                android:protectionLevel="signature|privileged" />
+
+    <permission android:name="vendor_privileged_or_oem_permission"
+                android:protectionLevel="vendorPrivileged|oem" />
+
+    <permission android:name="permission_with_minimal_fields"/>
+
+    <application>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithReceivers.xml b/robolectric/src/test/resources/TestAndroidManifestWithReceivers.xml
new file mode 100644
index 0000000..95120e2
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithReceivers.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.robolectric">
+  <uses-sdk android:targetSdkVersion="18"/>
+
+  <application>
+    <receiver android:name="org.robolectric.ConfigTestReceiver.InnerReceiver"
+          android:permission="com.ignored.PERM">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION1"/>
+        <category android:name="com.ignored"/>
+      </intent-filter>
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION2"/>
+        <category android:name="com.ignored"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name="org.robolectric.fakes.ConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_SUPERSET_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name="org.robolectric.ConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_SUBSET_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name=".DotConfigTestReceiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DOT_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name=".test.ConfigTestReceiver" android:enabled="false">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DOT_SUBPACKAGE"/>
+      </intent-filter>
+      <meta-data android:name="org.robolectric.metaName1" android:value="metaValue1" />
+      <meta-data android:name="org.robolectric.metaName2" android:value="metaValue2" />
+      <meta-data android:name="org.robolectric.metaTrue" android:value="true" />
+      <meta-data android:name="org.robolectric.metaFalse" android:value="false" />
+      <meta-data android:name="org.robolectric.metaInt" android:value="123" />
+      <meta-data android:name="org.robolectric.metaFloat" android:value="1.23" />
+      <meta-data android:name="org.robolectric.metaColor" android:value="#FFFFFF" />
+      <meta-data android:name="org.robolectric.metaBooleanFromRes" android:value="@bool/false_bool_value" />
+      <meta-data android:name="org.robolectric.metaIntFromRes" android:value="@integer/test_integer1" />
+      <meta-data android:name="org.robolectric.metaColorFromRes" android:value="@color/clear" />
+      <meta-data android:name="org.robolectric.metaStringFromRes" android:value="@string/app_name" />
+      <meta-data android:name="org.robolectric.metaStringOfIntFromRes" android:value="@string/str_int" />
+      <meta-data android:name="org.robolectric.metaStringRes" android:resource="@string/app_name" />
+    </receiver>
+
+    <receiver android:name="com.foo.Receiver">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DIFFERENT_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+
+    <receiver android:name="com.bar.ReceiverWithoutIntentFilter"/>
+    <receiver android:name="org.robolectric.ConfigTestReceiverPermissionsAndActions"
+        android:permission="org.robolectric.CUSTOM_PERM">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_RECEIVER_PERMISSION_PACKAGE"/>
+      </intent-filter>
+    </receiver>
+  </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithServices.xml b/robolectric/src/test/resources/TestAndroidManifestWithServices.xml
new file mode 100644
index 0000000..3322d9d
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithServices.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.robolectric">
+  <uses-sdk android:targetSdkVersion="18"/>
+  <application>
+    <service android:name="com.foo.Service" android:permission="com.foo.Permission">
+      <intent-filter>
+        <action android:name="org.robolectric.ACTION_DIFFERENT_PACKAGE"/>
+        <data android:mimeType="image/jpeg"/>
+        <category android:name="android.intent.category.LAUNCHER"/>
+      </intent-filter>
+    </service>
+
+    <service android:name="com.bar.ServiceWithoutIntentFilter" android:enabled="false"/>
+  </application>
+</manifest>
diff --git a/robolectric/src/test/resources/TestAndroidManifestWithoutPermissions.xml b/robolectric/src/test/resources/TestAndroidManifestWithoutPermissions.xml
new file mode 100644
index 0000000..2a1a4f9
--- /dev/null
+++ b/robolectric/src/test/resources/TestAndroidManifestWithoutPermissions.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+    <uses-sdk android:targetSdkVersion="18"/>
+    <application>
+    </application>
+</manifest>
diff --git a/robolectric/src/test/resources/assets/assetsHome.txt b/robolectric/src/test/resources/assets/assetsHome.txt
new file mode 100644
index 0000000..0b27d62
--- /dev/null
+++ b/robolectric/src/test/resources/assets/assetsHome.txt
@@ -0,0 +1 @@
+assetsHome!
\ No newline at end of file
diff --git a/robolectric/src/test/resources/assets/deflatedAsset.xml b/robolectric/src/test/resources/assets/deflatedAsset.xml
new file mode 100644
index 0000000..163c1ad
--- /dev/null
+++ b/robolectric/src/test/resources/assets/deflatedAsset.xml
@@ -0,0 +1 @@
+<blah></blah>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/assets/docs/extra/testing/hello.txt b/robolectric/src/test/resources/assets/docs/extra/testing/hello.txt
new file mode 100644
index 0000000..3462721
--- /dev/null
+++ b/robolectric/src/test/resources/assets/docs/extra/testing/hello.txt
@@ -0,0 +1 @@
+hello!
\ No newline at end of file
diff --git a/robolectric/src/test/resources/assets/exampleapp.apk b/robolectric/src/test/resources/assets/exampleapp.apk
new file mode 100644
index 0000000..7cf990c
--- /dev/null
+++ b/robolectric/src/test/resources/assets/exampleapp.apk
Binary files differ
diff --git a/robolectric/src/test/resources/assets/myFont.ttf b/robolectric/src/test/resources/assets/myFont.ttf
new file mode 100644
index 0000000..05c4d7e
--- /dev/null
+++ b/robolectric/src/test/resources/assets/myFont.ttf
@@ -0,0 +1 @@
+myFontData
\ No newline at end of file
diff --git a/robolectric/src/test/resources/assets/robolectric.png b/robolectric/src/test/resources/assets/robolectric.png
new file mode 100644
index 0000000..ea52306
--- /dev/null
+++ b/robolectric/src/test/resources/assets/robolectric.png
Binary files differ
diff --git a/robolectric/src/test/resources/com/android/tools/test_config.properties b/robolectric/src/test/resources/com/android/tools/test_config.properties
new file mode 100644
index 0000000..72270b9
--- /dev/null
+++ b/robolectric/src/test/resources/com/android/tools/test_config.properties
@@ -0,0 +1,5 @@
+android_merged_assets=src/test/resources/assets
+android_merged_resources=src/test/resources/res
+android_merged_manifest=src/test/resources/AndroidManifest.xml
+android_custom_package=org.robolectric
+android_resource_apk=src/test/resources/resources.ap_
\ No newline at end of file
diff --git a/robolectric/src/test/resources/org/robolectric/robolectric.properties b/robolectric/src/test/resources/org/robolectric/robolectric.properties
new file mode 100644
index 0000000..93c0919
--- /dev/null
+++ b/robolectric/src/test/resources/org/robolectric/robolectric.properties
@@ -0,0 +1,2 @@
+sdk=ALL_SDKS
+packageName=org.robolectric
diff --git a/robolectric/src/test/resources/res/anim/animation_list.xml b/robolectric/src/test/resources/res/anim/animation_list.xml
new file mode 100644
index 0000000..ef11209
--- /dev/null
+++ b/robolectric/src/test/resources/res/anim/animation_list.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
+  <item android:drawable="@drawable/an_image" android:duration="400"/>
+  <item android:drawable="@drawable/an_other_image" android:duration="400"/>
+  <item android:drawable="@drawable/third_image" android:duration="300"/>
+</animation-list>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/anim/test_anim_1.xml b/robolectric/src/test/resources/res/anim/test_anim_1.xml
new file mode 100644
index 0000000..7d7b305
--- /dev/null
+++ b/robolectric/src/test/resources/res/anim/test_anim_1.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+   android:interpolator="@android:anim/accelerate_interpolator"
+   android:shareInterpolator="true">
+  <alpha
+      android:fromAlpha="0.1"
+      android:toAlpha="0.2"/>
+  <scale
+      android:fromXScale="0.1"
+      android:toXScale="0.2"
+      android:fromYScale="0.3"
+      android:toYScale="0.4"
+      android:pivotX="0.5"
+      android:pivotY="0.6"/>
+  <translate
+      android:fromXDelta="0.1"
+      android:toXDelta="0.2"
+      android:fromYDelta="0.3"
+      android:toYDelta="0.4"/>
+  <rotate
+      android:fromDegrees="0.1"
+      android:toDegrees="0.2"
+      android:pivotX="0.3"
+      android:pivotY="0.4"/>
+
+  <animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="true">
+    <item android:drawable="@drawable/l0_red" android:duration="200" />
+    <item android:drawable="@drawable/l1_orange" android:duration="200" />
+    <item android:drawable="@drawable/l2_yellow" android:duration="200" />
+  </animation-list>
+</set>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/animator/fade.xml b/robolectric/src/test/resources/res/animator/fade.xml
new file mode 100644
index 0000000..8ea39b9
--- /dev/null
+++ b/robolectric/src/test/resources/res/animator/fade.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android" android:propertyName="alpha" android:valueFrom="0.0" android:valueTo="1.0" android:duration="10"/>
diff --git a/robolectric/src/test/resources/res/animator/spinning.xml b/robolectric/src/test/resources/res/animator/spinning.xml
new file mode 100644
index 0000000..db0a704
--- /dev/null
+++ b/robolectric/src/test/resources/res/animator/spinning.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<animator xmlns:android="http://schemas.android.com/apk/res/android" android:valueTo="100" android:valueFrom="0" />
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/color/color_state_list.xml b/robolectric/src/test/resources/res/color/color_state_list.xml
new file mode 100644
index 0000000..669b5e2
--- /dev/null
+++ b/robolectric/src/test/resources/res/color/color_state_list.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:state_pressed="true" android:color="#ffff0000"/>
+  <item android:state_focused="true" android:color="#ff0000ff"/>
+  <item android:color="#ff000000"/>
+</selector>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/color/custom_state_view_text_color.xml b/robolectric/src/test/resources/res/color/custom_state_view_text_color.xml
new file mode 100644
index 0000000..5f4f159
--- /dev/null
+++ b/robolectric/src/test/resources/res/color/custom_state_view_text_color.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android"
+          xmlns:aCustomNamespace="http://schemas.android.com/apk/res-auto">
+  <item android:color="#ffff0000" aCustomNamespace:stateFoo="true"/>
+  <item android:color="#ff000000"/>
+</selector>
diff --git a/robolectric/src/test/resources/res/drawable-anydpi/an_image_or_vector.xml b/robolectric/src/test/resources/res/drawable-anydpi/an_image_or_vector.xml
new file mode 100644
index 0000000..d90482e
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable-anydpi/an_image_or_vector.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <group
+            android:name="icon_null"
+            android:translateX="24"
+            android:translateY="24"
+            android:scaleX="0.2"
+            android:scaleY="0.2">
+        <group
+                android:name="check"
+                android:scaleX="7.5"
+                android:scaleY="7.5">
+            <path
+                    android:name="check_path_merged"
+                    android:pathData="M 7.0,-9.0 c 0.0,0.0 -14.0,0.0 -14.0,0.0 c -1.1044921875,0.0 -2.0,0.8955078125 -2.0,2.0 c 0.0,0.0 0.0,14.0 0.0,14.0 c 0.0,1.1044921875 0.8955078125,2.0 2.0,2.0 c 0.0,0.0 14.0,0.0 14.0,0.0 c 1.1044921875,0.0 2.0,-0.8955078125 2.0,-2.0 c 0.0,0.0 0.0,-14.0 0.0,-14.0 c 0.0,-1.1044921875 -0.8955078125,-2.0 -2.0,-2.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z M -2.0,5.00001525879 c 0.0,0.0 -5.0,-5.00001525879 -5.0,-5.00001525879 c 0.0,0.0 1.41409301758,-1.41409301758 1.41409301758,-1.41409301758 c 0.0,0.0 3.58590698242,3.58601379395 3.58590698242,3.58601379395 c 0.0,0.0 7.58590698242,-7.58601379395 7.58590698242,-7.58601379395 c 0.0,0.0 1.41409301758,1.41409301758 1.41409301758,1.41409301758 c 0.0,0.0 -9.0,9.00001525879 -9.0,9.00001525879 Z"
+                    android:fillColor="#FF000000" />
+        </group>
+        <group
+                android:name="box_dilate"
+                android:scaleX="7.5"
+                android:scaleY="7.5">
+            <path
+                    android:fillColor="#FFFF0000"
+                    android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
+        </group>
+    </group>
+</vector>
diff --git a/robolectric/src/test/resources/res/drawable-fr/an_image.png b/robolectric/src/test/resources/res/drawable-fr/an_image.png
new file mode 100644
index 0000000..2a26476
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable-fr/an_image.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable-hdpi/an_image.png b/robolectric/src/test/resources/res/drawable-hdpi/an_image.png
new file mode 100644
index 0000000..a40f229
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable-hdpi/an_image.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable-hdpi/robolectric.png b/robolectric/src/test/resources/res/drawable-hdpi/robolectric.png
new file mode 100644
index 0000000..ea52306
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable-hdpi/robolectric.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable-land/rainbow.xml b/robolectric/src/test/resources/res/drawable-land/rainbow.xml
new file mode 100644
index 0000000..8e12d46
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable-land/rainbow.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:drawable="@drawable/l0_red" />
+  <item android:drawable="@drawable/l1_orange" />
+  <item android:drawable="@drawable/l2_yellow" />
+  <item android:drawable="@drawable/l3_green" />
+  <item android:drawable="@drawable/l4_blue" />
+  <item android:drawable="@drawable/l6_violet" />
+</layer-list>
diff --git a/robolectric/src/test/resources/res/drawable-mdpi/robolectric.png b/robolectric/src/test/resources/res/drawable-mdpi/robolectric.png
new file mode 100644
index 0000000..7d9902d
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable-mdpi/robolectric.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/an_image.png b/robolectric/src/test/resources/res/drawable/an_image.png
new file mode 100644
index 0000000..a40f229
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/an_image.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/an_image_or_vector.png b/robolectric/src/test/resources/res/drawable/an_image_or_vector.png
new file mode 100644
index 0000000..65d000a
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/an_image_or_vector.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/an_other_image.gif b/robolectric/src/test/resources/res/drawable/an_other_image.gif
new file mode 100644
index 0000000..d64678d
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/an_other_image.gif
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/drawable_with_nine_patch.xml b/robolectric/src/test/resources/res/drawable/drawable_with_nine_patch.xml
new file mode 100644
index 0000000..9bd5451
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/drawable_with_nine_patch.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <!-- default -->
+  <item>
+    <nine-patch android:src="@drawable/nine_patch_drawable">
+    </nine-patch>
+  </item>
+</layer-list>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/drawable/fourth_image.jpg b/robolectric/src/test/resources/res/drawable/fourth_image.jpg
new file mode 100644
index 0000000..d173fea
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/fourth_image.jpg
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/image_background.png b/robolectric/src/test/resources/res/drawable/image_background.png
new file mode 100644
index 0000000..b93206c
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/image_background.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l0_red.png b/robolectric/src/test/resources/res/drawable/l0_red.png
new file mode 100644
index 0000000..2a26476
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l0_red.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l1_orange.png b/robolectric/src/test/resources/res/drawable/l1_orange.png
new file mode 100644
index 0000000..016a52b
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l1_orange.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l2_yellow.png b/robolectric/src/test/resources/res/drawable/l2_yellow.png
new file mode 100644
index 0000000..30f971d
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l2_yellow.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l3_green.png b/robolectric/src/test/resources/res/drawable/l3_green.png
new file mode 100644
index 0000000..67ffb0a
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l3_green.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l4_blue.png b/robolectric/src/test/resources/res/drawable/l4_blue.png
new file mode 100644
index 0000000..5528619
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l4_blue.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l5_indigo.png b/robolectric/src/test/resources/res/drawable/l5_indigo.png
new file mode 100644
index 0000000..fbc8e42
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l5_indigo.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l6_violet.png b/robolectric/src/test/resources/res/drawable/l6_violet.png
new file mode 100644
index 0000000..b9f4f8d
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l6_violet.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/l7_white.png b/robolectric/src/test/resources/res/drawable/l7_white.png
new file mode 100644
index 0000000..288d33c
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/l7_white.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/nine_patch_drawable.9.png b/robolectric/src/test/resources/res/drawable/nine_patch_drawable.9.png
new file mode 100644
index 0000000..6e25b0b
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/nine_patch_drawable.9.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/pure_black.png b/robolectric/src/test/resources/res/drawable/pure_black.png
new file mode 100644
index 0000000..411911e
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/pure_black.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/pure_blue.png b/robolectric/src/test/resources/res/drawable/pure_blue.png
new file mode 100644
index 0000000..c92b0bf
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/pure_blue.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/pure_green.png b/robolectric/src/test/resources/res/drawable/pure_green.png
new file mode 100644
index 0000000..d2a4df1
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/pure_green.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/pure_red.png b/robolectric/src/test/resources/res/drawable/pure_red.png
new file mode 100644
index 0000000..7c702c8
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/pure_red.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/pure_white.png b/robolectric/src/test/resources/res/drawable/pure_white.png
new file mode 100644
index 0000000..1129ae8
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/pure_white.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/rainbow.xml b/robolectric/src/test/resources/res/drawable/rainbow.xml
new file mode 100644
index 0000000..03352cb
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/rainbow.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:drawable="@drawable/l7_white" />
+  <item android:drawable="@drawable/l0_red" />
+  <item android:drawable="@drawable/l1_orange" />
+  <item android:drawable="@drawable/l2_yellow" />
+  <item android:drawable="@drawable/l3_green" />
+  <item android:drawable="@drawable/l4_blue" />
+  <item android:drawable="@drawable/l5_indigo" />
+  <item android:drawable="@drawable/l6_violet" />
+</layer-list>
diff --git a/robolectric/src/test/resources/res/drawable/state_drawable.xml b/robolectric/src/test/resources/res/drawable/state_drawable.xml
new file mode 100644
index 0000000..8489487
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/state_drawable.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<selector
+  xmlns:android="http://schemas.android.com/apk/res/android">
+
+   <item android:state_selected="true" android:drawable="@drawable/l0_red" />
+   <item android:state_pressed="true" android:drawable="@drawable/l1_orange" />
+   <item android:state_focused="true" android:drawable="@drawable/l2_yellow" />
+   <item android:state_checkable="true" android:drawable="@drawable/l3_green" />
+   <item android:state_checked="true" android:drawable="@drawable/l4_blue" />
+   <item android:state_enabled="true" android:drawable="@drawable/l5_indigo" />
+   <item android:state_window_focused="true" android:drawable="@drawable/l6_violet" />
+   
+   <item android:drawable="@drawable/l7_white" /> 
+
+</selector>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/drawable/test_jpeg.jpg b/robolectric/src/test/resources/res/drawable/test_jpeg.jpg
new file mode 100644
index 0000000..d1758d6
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/test_jpeg.jpg
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/test_webp.webp b/robolectric/src/test/resources/res/drawable/test_webp.webp
new file mode 100644
index 0000000..122741b
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/test_webp.webp
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/test_webp_lossless.webp b/robolectric/src/test/resources/res/drawable/test_webp_lossless.webp
new file mode 100644
index 0000000..8559e8b
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/test_webp_lossless.webp
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/test_webp_lossy.webp b/robolectric/src/test/resources/res/drawable/test_webp_lossy.webp
new file mode 100644
index 0000000..eb61c71
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/test_webp_lossy.webp
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/third_image.png b/robolectric/src/test/resources/res/drawable/third_image.png
new file mode 100644
index 0000000..4734497
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/third_image.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/drawable/vector.xml b/robolectric/src/test/resources/res/drawable/vector.xml
new file mode 100644
index 0000000..f0c4032
--- /dev/null
+++ b/robolectric/src/test/resources/res/drawable/vector.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="21dp"
+        android:height="22dp"
+        android:viewportWidth="23.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FFFF0000"
+        android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
+</vector>
diff --git a/robolectric/src/test/resources/res/layout-land/different_screen_sizes.xml b/robolectric/src/test/resources/res/layout-land/different_screen_sizes.xml
new file mode 100644
index 0000000..d264edb
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout-land/different_screen_sizes.xml
@@ -0,0 +1,12 @@
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content"
+  >
+  <TextView
+    android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="land"
+    />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout-land/multi_orientation.xml b/robolectric/src/test/resources/res/layout-land/multi_orientation.xml
new file mode 100644
index 0000000..a251957
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout-land/multi_orientation.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/landscape"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/my_landscape_text"
+      android:text="Landscape!"
+      android:layout_width="match_parent"
+      android:layout_height="match_parent"
+      />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout-sw320dp/layout_320_smallest_width.xml b/robolectric/src/test/resources/res/layout-sw320dp/layout_320_smallest_width.xml
new file mode 100644
index 0000000..43103af
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout-sw320dp/layout_320_smallest_width.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:orientation="vertical"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+/>
diff --git a/robolectric/src/test/resources/res/layout-sw320dp/layout_smallest_width.xml b/robolectric/src/test/resources/res/layout-sw320dp/layout_smallest_width.xml
new file mode 100644
index 0000000..f23364e
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout-sw320dp/layout_smallest_width.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:orientation="vertical"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content">
+        <TextView android:id="@+id/text1"
+                  android:layout_width="wrap_content"
+                  android:layout_height="wrap_content"
+                  android:text="320"/>
+></LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout-sw720dp/layout_smallest_width.xml b/robolectric/src/test/resources/res/layout-sw720dp/layout_smallest_width.xml
new file mode 100644
index 0000000..1fb3098
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout-sw720dp/layout_smallest_width.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:orientation="vertical"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content">
+        <TextView android:id="@+id/text1"
+                  android:layout_width="wrap_content"
+                  android:layout_height="wrap_content"
+                  android:text="720"/>
+></LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout-xlarge/different_screen_sizes.xml b/robolectric/src/test/resources/res/layout-xlarge/different_screen_sizes.xml
new file mode 100644
index 0000000..86d5b8e
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout-xlarge/different_screen_sizes.xml
@@ -0,0 +1,12 @@
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content"
+  >
+  <TextView
+    android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="xlarge"
+    />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/activity_list_item.xml b/robolectric/src/test/resources/res/layout/activity_list_item.xml
new file mode 100644
index 0000000..df33117
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/activity_list_item.xml
@@ -0,0 +1,18 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="match_parent"
+  android:layout_height="wrap_content"
+  android:paddingTop="1dip"
+  android:paddingBottom="1dip"
+  android:paddingLeft="6dip"
+  android:paddingRight="6dip">
+
+  <ImageView android:id="@+id/icon"
+    android:layout_width="24dip"
+    android:layout_height="24dip"/>
+
+  <TextView android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_gravity="center_horizontal"
+    android:paddingLeft="6dip" />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/activity_main.xml b/robolectric/src/test/resources/res/layout/activity_main.xml
new file mode 100644
index 0000000..5daef49
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/activity_main.xml
@@ -0,0 +1,11 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" >
+
+    <TextView
+        android:id="@+id/hello"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="hello" />
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/activity_main_1.xml b/robolectric/src/test/resources/res/layout/activity_main_1.xml
new file mode 100644
index 0000000..9649e98
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/activity_main_1.xml
@@ -0,0 +1,24 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" >
+
+    <LinearLayout
+        android:id="@+id/id_declared_in_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical" >
+
+        <TextView
+            android:id="@+id/hello"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="hello" />
+
+        <TextView
+            android:id="@+id/world"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="world" />
+    </LinearLayout>
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/custom_layout.xml b/robolectric/src/test/resources/res/layout/custom_layout.xml
new file mode 100644
index 0000000..581f3e8
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_layout.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:robolectric="http://schemas.android.com/apk/res/org.robolectric"
+    android:gravity="center"
+    robolectric:message="@string/hello"
+    robolectric:itemType="marsupial"
+    />
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/custom_layout2.xml b/robolectric/src/test/resources/res/layout/custom_layout2.xml
new file mode 100644
index 0000000..de67e3e
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_layout2.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomView2
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    message="@string/hello">
+  <org.robolectric.android.CustomView2
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      >
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+  </org.robolectric.android.CustomView2>
+</org.robolectric.android.CustomView2>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/custom_layout3.xml b/robolectric/src/test/resources/res/layout/custom_layout3.xml
new file mode 100644
index 0000000..85c858b
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_layout3.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<view class="org.robolectric.shadows.ShadowLayoutInflaterTest$CustomView3" android:text="Hello bonjour"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    />
diff --git a/robolectric/src/test/resources/res/layout/custom_layout4.xml b/robolectric/src/test/resources/res/layout/custom_layout4.xml
new file mode 100644
index 0000000..f541c6d
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_layout4.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:robolectric="http://schemas.android.com/apk/res-auto"
+    xmlns:fakens="http://example.com/fakens"
+    >
+  <org.robolectric.android.CustomView
+      android:id="@+id/custom_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:gravity="center"
+      robolectric:message="@string/hello"
+      robolectric:itemType="marsupial"
+      fakens:message="@layout/text_views"
+      >
+    <View
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+  </org.robolectric.android.CustomView>
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/custom_layout5.xml b/robolectric/src/test/resources/res/layout/custom_layout5.xml
new file mode 100644
index 0000000..97b4367
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_layout5.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:robolectric="http://schemas.android.com/apk/res/org.robolectric"
+    android:gravity="center"
+    robolectric:message="@string/hello"
+    robolectric:itemType="marsupial"
+    />
diff --git a/robolectric/src/test/resources/res/layout/custom_layout6.xml b/robolectric/src/test/resources/res/layout/custom_layout6.xml
new file mode 100644
index 0000000..eb099fb
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_layout6.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomStateView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:text="Some text"
+    android:textColor="@color/custom_state_view_text_color"
+    />
diff --git a/robolectric/src/test/resources/res/layout/custom_title.xml b/robolectric/src/test/resources/res/layout/custom_title.xml
new file mode 100644
index 0000000..37f0af4
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/custom_title.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="30dp" >
+
+    <TextView
+        android:id="@+id/custom_title_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/hello" />
+
+</RelativeLayout>
diff --git a/robolectric/src/test/resources/res/layout/different_screen_sizes.xml b/robolectric/src/test/resources/res/layout/different_screen_sizes.xml
new file mode 100644
index 0000000..313abfb
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/different_screen_sizes.xml
@@ -0,0 +1,12 @@
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content"
+  >
+  <TextView
+    android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="default"
+    />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/edit_text.xml b/robolectric/src/test/resources/res/layout/edit_text.xml
new file mode 100644
index 0000000..f0c0fa6
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/edit_text.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<EditText
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:hint="Hello, Hint"
+  />
diff --git a/robolectric/src/test/resources/res/layout/fragment.xml b/robolectric/src/test/resources/res/layout/fragment.xml
new file mode 100644
index 0000000..cd32b7e
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/fragment.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<fragment
+      xmlns:android="http://schemas.android.com/apk/res/android"
+      android:name="org.robolectric.util.CustomFragment"
+      android:id="@+id/my_fragment"
+      android:layout_width="match_parent"
+      android:layout_height="match_parent"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/fragment_activity.xml b/robolectric/src/test/resources/res/layout/fragment_activity.xml
new file mode 100644
index 0000000..bea8241
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/fragment_activity.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/fragment_container"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <fragment
+      android:id="@+id/fragment"
+      android:tag="fragment_tag"
+      android:name="org.robolectric.shadows.TestFragment"
+      />
+
+  <LinearLayout
+      android:id="@+id/dynamic_fragment_container"
+      />
+
+
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/fragment_contents.xml b/robolectric/src/test/resources/res/layout/fragment_contents.xml
new file mode 100644
index 0000000..c3d9cfa
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/fragment_contents.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    android:orientation="vertical"
+    >
+  <TextView
+      android:id="@+id/tacos"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="TACOS"/>
+  <TextView
+      android:id="@+id/burritos"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="BURRITOS"/>
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/included_layout_parent.xml b/robolectric/src/test/resources/res/layout/included_layout_parent.xml
new file mode 100644
index 0000000..9c2bd86
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/included_layout_parent.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include layout="@layout/included_linear_layout"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/included_linear_layout.xml b/robolectric/src/test/resources/res/layout/included_linear_layout.xml
new file mode 100644
index 0000000..e02b8c1
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/included_linear_layout.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    />
diff --git a/robolectric/src/test/resources/res/layout/inner_merge.xml b/robolectric/src/test/resources/res/layout/inner_merge.xml
new file mode 100644
index 0000000..538b0b8
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/inner_merge.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+  <TextView
+      android:id="@+id/inner_text"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+</merge>
diff --git a/robolectric/src/test/resources/res/layout/main.xml b/robolectric/src/test/resources/res/layout/main.xml
new file mode 100644
index 0000000..6fc3b8c
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/main.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include layout="@layout/snippet"/>
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/time"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:enabled="false"
+      android:contentDescription="@string/howdy"
+      android:alpha="0.3"
+      />
+
+  <TextView
+      android:id="@+id/title"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:maxHeight="46dip"
+      android:singleLine="false"
+      android:gravity="center_horizontal"
+      android:text="Main Layout"
+      android:textSize="18dip"
+      android:textStyle="bold"
+      android:textColor="#fff"
+      android:drawableTop="@drawable/an_image"
+      android:drawableRight="@drawable/an_other_image"
+      android:drawableBottom="@drawable/third_image"
+      android:drawableLeft="@drawable/fourth_image"
+      />
+
+  <TextView
+      android:id="@+id/subtitle"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:gravity="center_horizontal"
+      android:text="@string/hello"
+      android:maxHeight="36dip"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      />
+
+  <CheckBox
+      android:id="@+id/true_checkbox"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:checked="true"
+      />
+  <CheckBox
+      android:id="@+id/false_checkbox"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:checked="false"
+      />
+  <CheckBox
+      android:id="@+id/default_checkbox"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+
+  <ImageView
+      android:id="@+id/image"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:src="@drawable/an_image"
+      android:background="@drawable/image_background"
+      />
+
+    <ImageView
+            android:id="@+id/mipmapImage"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:src="@mipmap/robolectric"
+            />
+
+  <Button
+      android:id="@+id/button"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:onClick="onButtonClick"
+      />
+
+  <!-- see https://github.com/robolectric/robolectric/issues/521 -->
+  <HorizontalScrollView
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/mapview.xml b/robolectric/src/test/resources/res/layout/mapview.xml
new file mode 100644
index 0000000..ef8e34c
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/mapview.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    >
+
+  <com.google.android.maps.MapView
+      android:id="@+id/map_view"
+      android:layout_width="fill_parent"
+      android:layout_height="fill_parent"
+      android:clickable="true"
+      android:apiKey="Your Maps API Key goes here"
+      />
+</RelativeLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/media.xml b/robolectric/src/test/resources/res/layout/media.xml
new file mode 100644
index 0000000..d25af5c
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/media.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include android:id="@+id/include_id" layout="@layout/snippet"/>
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/time"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:text="Media Layout"
+      />
+
+  <TextView
+      android:id="@+id/title"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:maxHeight="46dip"
+      android:singleLine="false"
+      android:gravity="center_horizontal"
+      android:textSize="18dip"
+      android:textStyle="bold"
+      android:textColor="#fff"
+      />
+
+  <TextView
+      android:id="@+id/subtitle"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:gravity="center_horizontal"
+      android:maxHeight="36dip"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:visibility="gone"
+      />
+
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/multi_orientation.xml b/robolectric/src/test/resources/res/layout/multi_orientation.xml
new file mode 100644
index 0000000..5b5e267
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/multi_orientation.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/portrait"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/title"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:text="I'm a Portrait Layout!"
+      />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/ordinal_scrollbar.xml b/robolectric/src/test/resources/res/layout/ordinal_scrollbar.xml
new file mode 100644
index 0000000..571f1a1
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/ordinal_scrollbar.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ListView
+        android:id="@+id/list_view_with_enum_scrollbar"
+        android:layout_width="match_parent"
+        android:layout_height="0px"
+        android:layout_weight="1"
+        android:scrollbarStyle="@integer/scrollbar_style_ordinal_outside_overlay"/>
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/outer.xml b/robolectric/src/test/resources/res/layout/outer.xml
new file mode 100644
index 0000000..d1309fc
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/outer.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/outer_merge"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include android:id="@+id/include_id" layout="@layout/inner_merge"/>
+
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/override_include.xml b/robolectric/src/test/resources/res/layout/override_include.xml
new file mode 100644
index 0000000..24ceea0
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/override_include.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <include
+      layout="@layout/snippet"
+      android:visibility="invisible"
+      />
+
+  <include layout="@layout/inner_merge"/>
+
+
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/progress_bar.xml b/robolectric/src/test/resources/res/layout/progress_bar.xml
new file mode 100644
index 0000000..dc64a6e
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/progress_bar.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <ProgressBar
+      android:id="@+id/progress_bar"
+      android:layout_width="24dp"
+      android:layout_height="10dp"
+      />
+
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/remote_views.xml b/robolectric/src/test/resources/res/layout/remote_views.xml
new file mode 100644
index 0000000..52a6860
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/remote_views.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/remote_views_root"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content">
+
+    <TextView
+      android:id="@+id/subtitle"
+      android:text="@string/hello"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"/>
+
+    <TextView
+        android:id="@+id/remote_view_1"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+    <ImageView
+        android:id="@+id/remote_view_2"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+    <Button
+        android:id="@+id/remote_view_3"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/remote_views_alt.xml b/robolectric/src/test/resources/res/layout/remote_views_alt.xml
new file mode 100644
index 0000000..995fd8f
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/remote_views_alt.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:id="@+id/remote_views_alt_root"
+  android:orientation="horizontal"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content">
+
+  <TextView
+    android:id="@+id/remote_view_1"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"/>
+
+  <ImageView
+    android:id="@+id/remote_view_2"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"/>
+
+  <Button
+    android:id="@+id/remote_view_3"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/remote_views_bad.xml b/robolectric/src/test/resources/res/layout/remote_views_bad.xml
new file mode 100644
index 0000000..da21d31
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/remote_views_bad.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/remote_views_bad_root"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content">
+
+    <CalendarView
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/request_focus.xml b/robolectric/src/test/resources/res/layout/request_focus.xml
new file mode 100644
index 0000000..03131ab
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/request_focus.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+  <EditText
+      android:id="@+id/edit_text"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+  <FrameLayout
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      >
+    <requestFocus/>
+    <!-- focus should be given to the FrameLayout, *not* the EditText -->
+  </FrameLayout>
+  <View
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/request_focus_with_two_edit_texts.xml b/robolectric/src/test/resources/res/layout/request_focus_with_two_edit_texts.xml
new file mode 100644
index 0000000..fc4db8a
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/request_focus_with_two_edit_texts.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+  <EditText
+      android:id="@+id/edit_text"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+  <EditText
+      android:id="@+id/edit_text2"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+  <View
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+</LinearLayout>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/snippet.xml b/robolectric/src/test/resources/res/layout/snippet.xml
new file mode 100644
index 0000000..1e517eb
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/snippet.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<TextView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/snippet_text"
+    android:layout_width="fill_parent"
+    android:layout_height="10dip"
+    android:visibility="gone"
+    />
diff --git a/robolectric/src/test/resources/res/layout/styles_button_layout.xml b/robolectric/src/test/resources/res/layout/styles_button_layout.xml
new file mode 100644
index 0000000..4dc1f9b
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/styles_button_layout.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Button xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@id/button"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    />
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/styles_button_with_style_layout.xml b/robolectric/src/test/resources/res/layout/styles_button_with_style_layout.xml
new file mode 100644
index 0000000..612d6cf
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/styles_button_with_style_layout.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Button xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@id/button"
+    style="@style/Sized"
+    />
diff --git a/robolectric/src/test/resources/res/layout/tab_activity.xml b/robolectric/src/test/resources/res/layout/tab_activity.xml
new file mode 100644
index 0000000..a2eb52a
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/tab_activity.xml
@@ -0,0 +1,26 @@
+<merge  xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/main"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent">
+
+  <TabHost
+      android:id="@android:id/tabhost"
+      android:layout_width="fill_parent"
+      android:layout_height="fill_parent">
+    <LinearLayout
+        android:orientation="vertical"
+        android:layout_width="fill_parent"
+        android:layout_height="fill_parent">
+      <TabWidget
+          android:id="@android:id/tabs"
+          android:layout_width="fill_parent"
+          android:layout_height="wrap_content"
+          android:background="@color/list_separator"/>
+      <FrameLayout
+          android:id="@android:id/tabcontent"
+          android:layout_width="fill_parent"
+          android:layout_height="fill_parent">
+      </FrameLayout>
+    </LinearLayout>
+  </TabHost>
+</merge>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/text_views.xml b/robolectric/src/test/resources/res/layout/text_views.xml
new file mode 100644
index 0000000..e7f20e4
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/text_views.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <TextView
+      android:id="@+id/black_text_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Black Text"
+      android:textColor="#000000"
+      />
+
+  <TextView
+      android:id="@+id/white_text_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="White Text"
+      android:textColor="@android:color/white"
+      />
+
+  <TextView
+      android:id="@+id/grey_text_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Grey Text"
+      android:textColor="@color/grey42"
+      />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/text_views_hints.xml b/robolectric/src/test/resources/res/layout/text_views_hints.xml
new file mode 100644
index 0000000..95a183c
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/text_views_hints.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+  <TextView
+      android:id="@+id/black_text_view_hint"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:hint="Black Hint"
+      android:textColorHint="#000000"
+      />
+
+  <TextView
+      android:id="@+id/white_text_view_hint"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:hint="White Hint"
+      android:textColorHint="@android:color/white"
+      />
+
+  <TextView
+      android:id="@+id/grey_text_view_hint"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:hint="Grey Hint"
+      android:textColorHint="@color/grey42"
+      />
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/toplevel_merge.xml b/robolectric/src/test/resources/res/layout/toplevel_merge.xml
new file mode 100644
index 0000000..5d62c22
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/toplevel_merge.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<merge
+    android:id="@+id/main"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+ </merge>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/layout/webview_holder.xml b/robolectric/src/test/resources/res/layout/webview_holder.xml
new file mode 100644
index 0000000..85c41dc
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/webview_holder.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <WebView
+      android:id="@+id/web_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+
+</LinearLayout>
diff --git a/robolectric/src/test/resources/res/layout/with_invalid_onclick.xml b/robolectric/src/test/resources/res/layout/with_invalid_onclick.xml
new file mode 100644
index 0000000..2e16215
--- /dev/null
+++ b/robolectric/src/test/resources/res/layout/with_invalid_onclick.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Button
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/invalid_onclick_button"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:onClick="someInvalidMethod"
+    />
diff --git a/robolectric/src/test/resources/res/menu/action_menu.xml b/robolectric/src/test/resources/res/menu/action_menu.xml
new file mode 100644
index 0000000..b071173
--- /dev/null
+++ b/robolectric/src/test/resources/res/menu/action_menu.xml
@@ -0,0 +1,5 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:id="@+id/action_search"
+          android:actionViewClass="android.widget.SearchView"
+          android:showAsAction="always"/>
+</menu>
diff --git a/robolectric/src/test/resources/res/menu/test.xml b/robolectric/src/test/resources/res/menu/test.xml
new file mode 100644
index 0000000..4aed5f2
--- /dev/null
+++ b/robolectric/src/test/resources/res/menu/test.xml
@@ -0,0 +1,6 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:id="@+id/test_menu_1"
+    android:title="Test menu item 1" />
+  <item android:id="@+id/test_menu_2"
+    android:title="@string/test_menu_2" />
+</menu>
diff --git a/robolectric/src/test/resources/res/menu/test_withchilds.xml b/robolectric/src/test/resources/res/menu/test_withchilds.xml
new file mode 100644
index 0000000..e7b920c
--- /dev/null
+++ b/robolectric/src/test/resources/res/menu/test_withchilds.xml
@@ -0,0 +1,14 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:id="@+id/test_menu_1" android:title="Test menu item 1">
+  </item>
+  <group android:id="@+id/group_id_1">
+    <item android:id="@+id/test_menu_2" android:title="Test menu item 2" />
+    <item android:id="@+id/test_menu_3" android:title="Test menu item 3" />
+  </group>
+  <item android:id="@+id/test_submenu_1">
+    <menu>
+      <item android:id="@+id/test_menu_2" android:title="Test menu item 2" />
+      <item android:id="@+id/test_menu_3" android:title="Test menu item 3" />
+    </menu>
+  </item>
+</menu>
diff --git a/robolectric/src/test/resources/res/menu/test_withorder.xml b/robolectric/src/test/resources/res/menu/test_withorder.xml
new file mode 100644
index 0000000..d73a158
--- /dev/null
+++ b/robolectric/src/test/resources/res/menu/test_withorder.xml
@@ -0,0 +1,8 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:id="@+id/test_menu_2"
+        android:orderInCategory="2"
+        android:title="Test menu item 2"/>
+  <item android:id="@+id/test_menu_1"
+        android:orderInCategory="1"
+        android:title="Test menu item 1"/>
+</menu>
diff --git a/robolectric/src/test/resources/res/mipmap-v26/robolectric_xml.xml b/robolectric/src/test/resources/res/mipmap-v26/robolectric_xml.xml
new file mode 100644
index 0000000..7d0b17e
--- /dev/null
+++ b/robolectric/src/test/resources/res/mipmap-v26/robolectric_xml.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+  <background>
+    <bitmap android:src="@mipmap/robolectric"/>
+  </background>
+  <foreground>
+    <bitmap android:src="@mipmap/robolectric"/>
+  </foreground>
+</adaptive-icon>
diff --git a/robolectric/src/test/resources/res/mipmap/robolectric.png b/robolectric/src/test/resources/res/mipmap/robolectric.png
new file mode 100644
index 0000000..ea52306
--- /dev/null
+++ b/robolectric/src/test/resources/res/mipmap/robolectric.png
Binary files differ
diff --git a/robolectric/src/test/resources/res/raw/raw_no_ext b/robolectric/src/test/resources/res/raw/raw_no_ext
new file mode 100644
index 0000000..bfc4c5f
--- /dev/null
+++ b/robolectric/src/test/resources/res/raw/raw_no_ext
@@ -0,0 +1 @@
+no ext file contents
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/raw/raw_resource.txt b/robolectric/src/test/resources/res/raw/raw_resource.txt
new file mode 100644
index 0000000..6f46228
--- /dev/null
+++ b/robolectric/src/test/resources/res/raw/raw_resource.txt
@@ -0,0 +1 @@
+raw txt file contents
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/raw/sound b/robolectric/src/test/resources/res/raw/sound
new file mode 100644
index 0000000..8ce8d6a
--- /dev/null
+++ b/robolectric/src/test/resources/res/raw/sound
Binary files differ
diff --git a/robolectric/src/test/resources/res/values-b+sr+Latn/values.xml b/robolectric/src/test/resources/res/values-b+sr+Latn/values.xml
new file mode 100644
index 0000000..f8b057e
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-b+sr+Latn/values.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+  <string name="howdy">Kako si</string>
+  <string name="hello">Zdravo</string>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values-de/strings.xml b/robolectric/src/test/resources/res/values-de/strings.xml
new file mode 100644
index 0000000..1e38290
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-de/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="minute_singular">Minute</string>
+  <string name="minute_plural">Minuten</string>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values-fr/strings.xml b/robolectric/src/test/resources/res/values-fr/strings.xml
new file mode 100644
index 0000000..279987b
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-fr/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="hello">Bonjour</string>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values-sw600dp-v14/booleans.xml b/robolectric/src/test/resources/res/values-sw600dp-v14/booleans.xml
new file mode 100644
index 0000000..97e9722
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-sw600dp-v14/booleans.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+	<bool name="different_resource_boolean">true</bool>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values-w320dp/bools.xml b/robolectric/src/test/resources/res/values-w320dp/bools.xml
new file mode 100644
index 0000000..c16ae30
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-w320dp/bools.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="value_only_present_in_w320dp">true</bool>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values-w820dp/bools.xml b/robolectric/src/test/resources/res/values-w820dp/bools.xml
new file mode 100644
index 0000000..cf947fe
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-w820dp/bools.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="different_resource_boolean">true</bool>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values-w820dp/layout.xml b/robolectric/src/test/resources/res/values-w820dp/layout.xml
new file mode 100644
index 0000000..eb73406
--- /dev/null
+++ b/robolectric/src/test/resources/res/values-w820dp/layout.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="main_layout" type="layout">@layout/activity_main_1</item>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/attrs.xml b/robolectric/src/test/resources/res/values/attrs.xml
new file mode 100644
index 0000000..b96d706
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/attrs.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <declare-styleable name="CustomView">
+    <attr name="bar" format="string"/>
+
+    <attr name="multiformat" format="integer|string|boolean"/>
+
+    <attr name="itemType" format="enum">
+      <enum name="marsupial" value="0"/>
+      <enum name="ungulate" value="1"/>
+    </attr>
+    <attr name="message" format="string"/>
+
+    <attr name="scrollBars">
+      <flag name="horizontal" value="0x00000100" />
+      <flag name="vertical" value="0x00000200" />
+      <flag name="sideways" value="0x00000400" />
+    </attr>
+
+    <attr name="quitKeyCombo" format="string"/>
+
+    <attr name="numColumns" format="integer" min="0">
+      <!-- Display as many columns as possible to fill the available space. -->
+      <enum name="auto_fit" value="-1" />
+    </attr>
+
+    <attr name="sugarinessPercent" format="integer" min="0"/>
+
+    <attr name="gravity"/>
+
+    <attr name="keycode"/>
+
+    <attr name="aspectRatio" format="float" />
+    <attr name="aspectRatioEnabled" format="boolean" />
+    <attr name="animalStyle" format="reference" />
+
+    <!-- Test the same attr name as android namespace with different format -->
+    <attr name="typeface" format="string" />
+
+    <attr name="someLayoutOne" format="reference" />
+    <attr name="someLayoutTwo" format="reference" />
+  </declare-styleable>
+
+  <attr name="gravity">
+    <flag name="center" value="0x11" />
+    <flag name="fill_vertical" value="0x70" />
+  </attr>
+
+  <attr name="keycode">
+    <enum name="KEYCODE_SOFT_RIGHT" value="2" />
+    <enum name="KEYCODE_HOME" value="3" />
+  </attr>
+
+  <attr name="responses" format="reference"/>
+  <attr name="string1" format="string"/>
+  <attr name="string2" format="string"/>
+  <attr name="string3" format="string"/>
+  <attr name="parent_string" format="string"/>
+  <attr name="child_string" format="string"/>
+  <attr name="child_string2" format="string"/>
+  <attr name="parentStyleReference" format="reference"/>
+  <attr name="styleNotSpecifiedInAnyTheme" format="reference"/>
+  <attr name="stringReference" format="reference"/>
+  <attr name="anAttribute" format="reference"/>
+  <attr name="attributeReferencingAnAttribute" format="reference"/>
+  <attr name="circularReference" format="reference"/>
+  <attr name="title" format="string"/>
+
+  <declare-styleable name="CustomStateView">
+    <attr name="stateFoo" format="boolean" />
+  </declare-styleable>
+
+  <declare-styleable name="Theme.AnotherTheme.Attributes">
+    <attr name="averageSheepWidth" format="reference|dimension"/>
+    <attr name="isSugary" format="reference|boolean"/>
+    <attr name="logoHeight" format="reference|dimension"/>
+    <attr name="logoWidth" format="dimension"/>
+    <attr name="styleReference" format="reference"/>
+    <attr name="snail" format="reference"/>
+    <attr name="altTitle" format="reference|string"/>
+  </declare-styleable>
+</resources>
diff --git a/robolectric/src/test/resources/res/values/bools.xml b/robolectric/src/test/resources/res/values/bools.xml
new file mode 100644
index 0000000..7198dfb
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/bools.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="false_bool_value">false</bool>
+  <bool name="true_bool_value">true</bool>
+  <bool name="reference_to_true">@bool/true_bool_value</bool>
+  <item name="true_as_item" type="bool">true</item>
+  <bool name="different_resource_boolean">false</bool>
+  <bool name="typed_array_true">true</bool>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/colors.xml b/robolectric/src/test/resources/res/values/colors.xml
new file mode 100644
index 0000000..bb4aea0
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/colors.xml
@@ -0,0 +1,25 @@
+<resources>
+  <color name="foreground">@color/grey42</color>
+
+  <color name="clear">#00000001</color>
+  <color name="white">#FFFFFF</color>
+  <color name="black">#000000</color>
+  <color name="blue">#0000ff</color>
+  <color name="grey42">#f5f5f5</color>
+  <color name="color_with_alpha">#802C76AD</color>
+
+  <color name="background">@color/grey42</color>
+
+  <color name="android_namespaced_black">@android:color/black</color>
+
+  <color name="android_namespaced_transparent">@android:color/transparent</color>
+
+  <color name="list_separator">#111111</color>
+
+  <color name="typed_array_orange">#FF5C00</color>
+
+  <color name="test_ARGB4">#0001</color>
+  <color name="test_ARGB8">#00000002</color>
+  <color name="test_RGB4">#00f</color>
+  <color name="test_RGB8">#000004</color>
+</resources>
diff --git a/robolectric/src/test/resources/res/values/dimens.xml b/robolectric/src/test/resources/res/values/dimens.xml
new file mode 100644
index 0000000..b6e2f95
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/dimens.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <dimen name="test_dp_dimen">8dp</dimen>
+  <dimen name="test_dip_dimen">20dip</dimen>
+  <dimen name="test_pt_dimen">12pt</dimen>
+  <dimen name="test_px_dimen">15px</dimen>
+  <dimen name="test_sp_dimen">5sp</dimen>
+  <dimen name="test_mm_dimen">42mm</dimen>
+  <dimen name="test_in_dimen">99in</dimen>
+
+  <dimen name="ref_to_px_dimen">@dimen/test_px_dimen</dimen>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/fractions.xml b/robolectric/src/test/resources/res/values/fractions.xml
new file mode 100644
index 0000000..65e3ea4
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/fractions.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <fraction name="half">50%</fraction>
+    <fraction name="half_of_parent">50%p</fraction>
+    <item name="quarter_as_item" type="fraction">25%</item>
+    <item name="quarter_of_parent_as_item" type="fraction">25%p</item>
+    <fraction name="fifth">20%</fraction>
+    <fraction name="fifth_as_reference">@fraction/fifth</fraction>
+    <fraction name="fifth_of_parent">20%p</fraction>
+    <fraction name="fifth_of_parent_as_reference">@fraction/fifth_of_parent</fraction>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/ids.xml b/robolectric/src/test/resources/res/values/ids.xml
new file mode 100644
index 0000000..a70cfe3
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/ids.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <item name="id_declared_in_item_tag" type="id"/>
+  <item name="id_with_string_value" type="id"/>
+  <item name="declared_id" type="id"/>
+</resources>
diff --git a/robolectric/src/test/resources/res/values/int_arrays.xml b/robolectric/src/test/resources/res/values/int_arrays.xml
new file mode 100644
index 0000000..f4d5ba0
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/int_arrays.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+   <integer-array name="zero_to_four_int_array">
+      <item>0</item>
+      <item>1</item>
+      <item>2</item>
+      <item>3</item>
+      <item>4</item>
+   </integer-array>
+   <integer-array name="empty_int_array" />
+   <integer-array name="with_references_int_array">
+      <item>0</item>
+      <item>@integer/test_integer1</item>
+      <item>1</item>
+   </integer-array>
+   <integer-array name="referenced_colors_int_array">
+      <item>@color/clear</item>
+      <item>@color/white</item>
+      <item>@color/black</item>
+      <item>@color/grey42</item>
+      <item>@color/color_with_alpha</item>
+   </integer-array>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/integer.xml b/robolectric/src/test/resources/res/values/integer.xml
new file mode 100644
index 0000000..5318c1c
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/integer.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<resources>
+  <string name="test_non_integer">This is not an integer</string>
+  <integer name="test_integer1">2000</integer>
+  <integer name="test_integer2">9</integer>
+  <integer name="test_large_hex">0xFFFF0000</integer>
+  <integer name="test_value_with_zero">07210</integer>
+  <integer name="scrollbar_style_ordinal_outside_overlay">0x02000000</integer>
+  <integer name="typed_array_5">5</integer>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/integers.xml b/robolectric/src/test/resources/res/values/integers.xml
new file mode 100644
index 0000000..26ef9d5
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/integers.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <integer name="meaning_of_life">42</integer>
+  <integer name="loneliest_number">1</integer>
+  <integer name="there_can_be_only">@integer/loneliest_number</integer>
+  <integer name="hex_int">0xFFFF0000</integer>
+  <integer name="reference_to_meaning_of_life">@integer/meaning_of_life_as_item</integer>
+  <item name="meaning_of_life_as_item" type="integer">42</item>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/layout.xml b/robolectric/src/test/resources/res/values/layout.xml
new file mode 100644
index 0000000..35cfbb7
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/layout.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="main_layout" type="layout">@layout/activity_main</item>
+    <item name="multiline_layout" type="layout">
+        @layout/activity_main
+    </item>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/mipmaps.xml b/robolectric/src/test/resources/res/values/mipmaps.xml
new file mode 100644
index 0000000..f1289d1
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/mipmaps.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <item name="mipmap_reference" type="mipmap">@mipmap/robolectric</item>
+  <item name="mipmap_reference_xml" type="mipmap">@mipmap/robolectric_xml</item>
+</resources>
diff --git a/robolectric/src/test/resources/res/values/plurals.xml b/robolectric/src/test/resources/res/values/plurals.xml
new file mode 100644
index 0000000..823968a
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/plurals.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <plurals name="beer">
+    <!--
+    In us-en locale, only one and other plurals are used because there are only two possible
+    variants: singular vs plural tense.
+    -->
+    <item quantity="one">a beer</item>
+    <item quantity="other">some beers</item>
+  </plurals>
+  <plurals name="minute">
+    <item quantity="one">@string/minute_singular</item>
+    <item quantity="other">@string/minute_plural</item>
+  </plurals>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/refs.xml b/robolectric/src/test/resources/res/values/refs.xml
new file mode 100644
index 0000000..a9a5f2a
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/refs.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="example_item_drawable" type="drawable">@drawable/an_image</item>
+</resources>
diff --git a/robolectric/src/test/resources/res/values/string_arrays.xml b/robolectric/src/test/resources/res/values/string_arrays.xml
new file mode 100644
index 0000000..2d77f1c
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/string_arrays.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string-array name="more_items">
+    <item>baz</item>
+    <item>bang</item>
+  </string-array>
+
+  <string-array name="greetings">
+    <item>hola</item>
+    <item>@string/hello</item>
+  </string-array>
+
+   <string-array name="alertDialogTestItems">
+    <item>Aloha</item>
+    <item>Hawai</item>
+  </string-array>
+
+  <string-array name="emailAddressTypes">
+    <item>Doggy</item>
+    <item>Catty</item>
+  </string-array>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/values/strings.xml b/robolectric/src/test/resources/res/values/strings.xml
new file mode 100644
index 0000000..82fb9a9
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/strings.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+  <string name="greeting">@string/howdy</string>
+  <string name="howdy">Howdy</string>
+  <string name="hello">Hello</string>
+  <string name="some_html"><b>Hello, <i>world</i></b></string>
+  <string-array name="items">
+    <item>foo</item>
+    <item>bar</item>
+  </string-array>
+  <string name="copy">Local Copy</string>
+  <string name="not_in_the_r_file">Proguarded Out Probably</string>
+  <string name="only_in_main">from main</string>
+  <string name="in_all_libs">from main</string>
+  <string name="in_main_and_lib1">from main</string>
+  <string name="ok">@string/ok2</string>
+  <string name="ok2">ok yup!</string>
+  <string name="interpolate">Here is a %s!</string>
+  <string name="surrounding_quotes">"This'll work"</string>
+  <string name="escaped_apostrophe">This\'ll also work</string>
+  <string name="escaped_quotes">Click \"OK\"</string>
+  <string name="leading_and_trailing_new_lines">
+    Some text
+  </string>
+  <string name="new_lines_and_tabs">
+    <xliff:g id="minutes" example="3 min">%1$s</xliff:g>\tmph\nfaster
+  </string>
+  <string name="non_breaking_space">Closing soon:\u00A05pm</string>
+  <string name="space">Closing soon:\u00205pm</string>
+  <string name="app_name">Testing App</string>
+  <string name="activity_name">Testing App Activity</string>
+  <string name="minute_singular">minute</string>
+  <string name="minute_plural">minutes</string>
+  <string name="str_int">123456</string>
+  <string name="preference_resource_key">preference_resource_key_value</string>
+  <string name="preference_resource_title">preference_resource_title_value</string>
+  <string name="preference_resource_summary">preference_resource_summary_value</string>
+  <string name="preference_resource_default_value">preference_resource_default_value</string>
+  <string name="test_menu_2">Test menu item 2</string>
+  <item name="say_it_with_item" type="string">flowers</item>
+  <string name="typed_array_a">apple</string>
+  <string name="typed_array_b">banana</string>
+  <string name="test_permission_description">permission string</string>
+  <string name="test_permission_label">permission label</string>
+  <string name="string_with_spaces">
+    Up to <xliff:g id="upper_limit" example="12">%1$s</xliff:g> <xliff:g id="space"> </xliff:g> <xliff:g id="currency" example="USD">%2$s</xliff:g>
+  </string>
+
+  <string name="internal_whitespace_blocks">Whitespace     in     the          middle</string>
+  <string name="internal_newlines">Some
+
+
+  Newlines
+  </string>
+
+  <!--
+    Resources to validate examples from https://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling
+  -->
+  <string name="bad_example">This is a "bad string".</string>
+  <!-- Quotes are stripped; displays as: This is a bad string. -->
+</resources>
diff --git a/robolectric/src/test/resources/res/values/themes.xml b/robolectric/src/test/resources/res/values/themes.xml
new file mode 100644
index 0000000..3e256bd
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/themes.xml
@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <style name="Theme.Robolectric" parent="@android:style/Theme">
+    <item name="android:buttonStyle">@style/Widget.Robolectric.Button</item>
+    <item name="string1">string 1 from Theme.Robolectric</item>
+    <item name="string2">string 2 from Theme.Robolectric</item>
+    <item name="string3">string 3 from Theme.Robolectric</item>
+  </style>
+
+  <style name="Theme.Robolectric.ImplicitChild">
+    <item name="string2">string 2 from Theme.Robolectric.ImplicitChild</item>
+    <item name="string3">string 3 from Theme.Robolectric.ImplicitChild</item>
+  </style>
+
+  <style name="Theme.Robolectric.EmptyParent" parent=""/>
+
+  <style name="Theme.AnotherTheme" parent="@style/Theme.Robolectric">
+    <item name="android:buttonStyle">@style/Widget.AnotherTheme.Button</item>
+    <item name="logoWidth">?attr/averageSheepWidth</item>
+    <item name="logoHeight">@dimen/test_dp_dimen</item>
+    <item name="averageSheepWidth">@dimen/test_dp_dimen</item>
+    <item name="animalStyle">@style/Gastropod</item>
+    <item name="isSugary">?attr/isSugary</item>
+    <item name="styleReference">?android:attr/buttonStyle</item>
+    <item name="typeface">custom_font</item>
+    <item name="string1">string 1 from Theme.AnotherTheme</item>
+    <item name="string2">string 2 from Theme.AnotherTheme</item>
+  </style>
+
+  <style name="Theme.ThirdTheme" parent="@style/Theme.Robolectric">
+    <item name="snail">@style/Gastropod</item>
+    <item name="animalStyle">?attr/snail</item>
+    <item name="someLayoutOne">@layout/activity_main</item>
+    <item name="someLayoutTwo">?someLayoutOne</item>
+  </style>
+
+  <style name="Theme">
+  </style>
+
+  <style name="Theme.ThemeReferredToByParentAttrReference">
+    <item name="parentStyleReference">@style/YetAnotherStyle</item>
+  </style>
+
+  <style name="Theme.ThemeContainingStyleReferences" parent="@style/Theme.ThemeReferredToByParentAttrReference">
+    <item name="styleReference">@style/YetAnotherStyle</item>
+  </style>
+
+  <style name="YetAnotherStyle">
+    <item name="string2">string 2 from YetAnotherStyle</item>
+  </style>
+
+  <style name="Widget.Robolectric.Button" parent="@android:style/Widget.Button">
+    <item name="android:background">#ff00ff00</item>
+  </style>
+
+  <style name="Widget.AnotherTheme.Button" parent="@android:style/Widget.Button">
+    <item name="android:background">#ffff0000</item>
+    <item name="android:minWidth">?attr/logoWidth</item>
+    <item name="android:minHeight">?attr/logoHeight</item>
+  </style>
+
+  <style name="Widget.AnotherTheme.Button.Blarf"/>
+
+  <style name="MyCustomView">
+    <item name="aspectRatioEnabled">true</item>
+  </style>
+
+  <style name="SomeStyleable">
+    <item name="snail">@style/Gastropod</item>
+    <item name="animalStyle">@style/Gastropod</item>
+  </style>
+
+  <style name="Sized">
+    <item name="android:layout_width">42px</item>
+    <item name="android:layout_height">42px</item>
+  </style>
+
+  <style name="Gastropod">
+      <item name="aspectRatio">1.69</item>
+  </style>
+
+  <style name="MyBlackTheme">
+    <item name="android:windowBackground">@android:color/black</item>
+    <item name="android:textColorHint">@android:color/darker_gray</item>
+  </style>
+
+  <style name="MyBlueTheme">
+    <item name="android:windowBackground">@color/blue</item>
+    <item name="android:textColor">@color/white</item>
+  </style>
+
+  <style name="ThemeWithSelfReferencingTextAttr">
+    <!-- android's Widget style (among others) does this, wtf? -->
+    <item name="android:textAppearance">?android:attr/textAppearance</item>
+  </style>
+
+  <style name="IndirectButtonStyle" parent="@android:style/Widget.Button">
+    <item name="android:minHeight">12dp</item>
+  </style>
+
+  <!-- Styles for testing inheritance -->
+  <style name="SimpleParent">
+    <item name="parent_string">parent string</item>
+  </style>
+  <style name="SimpleChildWithOverride" parent="@style/SimpleParent">
+    <item name="parent_string">parent string overridden by child</item>
+  </style>
+  <style name="SimpleParent.ImplicitChild">
+    <item name="parent_string">parent string overridden by child</item>
+  </style>
+
+  <style name="SimpleChildWithAdditionalAttributes" parent="@style/SimpleParent">
+    <item name="child_string">child string</item>
+    <item name="child_string2">child string2</item>
+  </style>
+
+  <style name="StyleA">
+    <item name="string1">string 1 from style A</item>
+  </style>
+  <style name="StyleB">
+    <item name="string1">string 1 from style B</item>
+  </style>
+
+  <style name="StyleWithReference">
+    <item name="stringReference">@string/hello</item>
+  </style>
+
+  <style name="StyleWithAttributeReference">
+    <item name="anAttribute">@string/hello</item>
+    <item name="attributeReferencingAnAttribute">?anAttribute</item>
+  </style>
+
+  <style name="StyleWithCircularReference">
+    <item name="circularReference">?circularReference</item>
+  </style>
+  <style name="StyleWithMultipleAttributes">
+    <item name="string1">string 1 from StyleWithMultipleAttributes</item>
+    <item name="string2">string 2 from StyleWithMultipleAttributes</item>
+  </style>
+</resources>
diff --git a/robolectric/src/test/resources/res/values/typed_arrays.xml b/robolectric/src/test/resources/res/values/typed_arrays.xml
new file mode 100644
index 0000000..7060a9f
--- /dev/null
+++ b/robolectric/src/test/resources/res/values/typed_arrays.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <array name="typed_array_values">
+        <item>abcdefg</item>
+        <item>3875</item>
+        <item>2.0</item>
+        <item>#ffff00ff</item>
+        <item>#00ffff</item>
+        <item>8px</item>
+        <item>12dp</item>
+        <item>6dip</item>
+        <item>3mm</item>
+        <item>4in</item>
+        <item>36sp</item>
+        <item>18pt</item>
+    </array>
+
+    <array name="typed_array_references">
+        <item>@string/typed_array_a</item>
+        <item>@string/typed_array_b</item>
+        <item>@integer/typed_array_5</item>
+        <item>@bool/typed_array_true</item>
+        <item>@null</item>
+        <item>@drawable/an_image</item>
+        <item>@color/typed_array_orange</item>
+        <item>?attr/animalStyle</item>
+        <item>@array/string_array_values</item>
+        <item>@style/Theme.Robolectric</item>
+    </array>
+
+    <array name="typed_array_with_resource_id">
+        <item>@id/id_declared_in_item_tag</item>
+        <item>@id/id_declared_in_layout</item>
+    </array>
+
+    <string-array name="string_array_values">
+        <item>abcdefg</item>
+        <item>3875</item>
+        <item>2.0</item>
+        <item>#ffff00ff</item>
+        <item>#00ffff</item>
+        <item>8px</item>
+        <item>12dp</item>
+        <item>6dip</item>
+        <item>3mm</item>
+        <item>4in</item>
+        <item>36sp</item>
+        <item>18pt</item>
+    </string-array>
+</resources>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml-v25/shortcuts.xml b/robolectric/src/test/resources/res/xml-v25/shortcuts.xml
new file mode 100644
index 0000000..e869d58
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml-v25/shortcuts.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shortcuts xmlns:android="http://schemas.android.com/apk/res/android" >
+
+</shortcuts>
diff --git a/robolectric/src/test/resources/res/xml/app_restrictions.xml b/robolectric/src/test/resources/res/xml/app_restrictions.xml
new file mode 100644
index 0000000..1f131f2
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/app_restrictions.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
+  <restriction
+      android:key="restrictionKey"
+      android:title="@string/ok"
+      android:restrictionType="bool"
+      android:defaultValue="false" />
+</restrictions>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/dialog_preferences.xml b/robolectric/src/test/resources/res/xml/dialog_preferences.xml
new file mode 100644
index 0000000..87e3e02
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/dialog_preferences.xml
@@ -0,0 +1,12 @@
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+  <org.robolectric.shadows.testing.TestDialogPreference
+      android:key="dialog"
+      android:title="Dialog Preference"
+      android:summary="This is the dialog summary"
+      android:dialogMessage="This is the dialog message"
+      android:positiveButtonText="YES"
+      android:negativeButtonText="NO"/>
+
+</PreferenceScreen>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/has_attribute_resource_value.xml b/robolectric/src/test/resources/res/xml/has_attribute_resource_value.xml
new file mode 100644
index 0000000..01b4142
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/has_attribute_resource_value.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo xmlns:app="http://schemas.android.com/apk/res-auto" app:bar="@layout/main"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/has_id.xml b/robolectric/src/test/resources/res/xml/has_id.xml
new file mode 100644
index 0000000..0cd5a26
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/has_id.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo id="@+id/tacos"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/has_parent_style_reference.xml b/robolectric/src/test/resources/res/xml/has_parent_style_reference.xml
new file mode 100644
index 0000000..0f9172c
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/has_parent_style_reference.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo style="?parentStyleReference"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/has_style_attribute_reference.xml b/robolectric/src/test/resources/res/xml/has_style_attribute_reference.xml
new file mode 100644
index 0000000..b4bd862
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/has_style_attribute_reference.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo style="?attr/parentStyleReference"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/preferences.xml b/robolectric/src/test/resources/res/xml/preferences.xml
new file mode 100644
index 0000000..1192279
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/preferences.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+  <PreferenceCategory
+      android:key="category"
+      android:title="Category Test">
+
+    <Preference
+        android:key="inside_category"
+        android:title="Inside Category Test"
+        android:summary=""/>
+
+  </PreferenceCategory>
+
+  <PreferenceScreen
+      android:key="screen"
+      android:title="Screen Test"
+      android:summary="Screen summary">
+
+    <Preference
+        android:key="inside_screen"
+        android:title="Inside Screen Test"
+        android:summary=""/>
+
+    <Preference
+        android:key="inside_screen_dependent"
+        android:title="Inside Screen Dependent Test"
+        android:dependency="inside_screen"
+        android:summary=""/>
+
+  </PreferenceScreen>
+
+  <CheckBoxPreference
+      android:key="checkbox"
+      android:title="Checkbox Test"
+      android:summary=""
+      android:defaultValue="true"/>
+
+  <EditTextPreference
+      android:key="edit_text"
+      android:title="EditText Test"
+      android:summary=""/>
+
+  <ListPreference
+      android:key="list"
+      android:title="List Test"
+      android:summary=""/>
+
+  <Preference
+      android:key="preference"
+      android:title="Preference Title"
+      android:summary=""/>
+
+  <RingtonePreference
+      android:key="ringtone"
+      android:title="Ringtone Test"
+      android:summary=""/>
+
+  <Preference
+      android:key="@string/preference_resource_key"
+      android:title="@string/preference_resource_title"
+      android:summary="@string/preference_resource_summary"/>
+
+  <Preference
+      android:key="dependant"
+      android:title="This preference is dependant on something else"
+      android:summary="Still depending on the preference above"
+      android:dependency="preference"/>
+
+  <Preference
+      android:key="intent"
+      android:title="Intent test"
+      android:summary="">
+
+    <intent
+        android:targetPackage="org.robolectric"
+        android:targetClass="org.robolectric.test.Intent"
+        android:mimeType="application/text"
+        android:action="action"
+        android:data="tel://1235"/>
+  </Preference>
+
+</PreferenceScreen>
diff --git a/robolectric/src/test/resources/res/xml/temp.xml b/robolectric/src/test/resources/res/xml/temp.xml
new file mode 100644
index 0000000..ae0c2c1
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/temp.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<whatever style="?attr/styleReference"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/temp_parent.xml b/robolectric/src/test/resources/res/xml/temp_parent.xml
new file mode 100644
index 0000000..032a396
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/temp_parent.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<whatever style="?attr/parentStyleReference"/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/test_wallpaper.xml b/robolectric/src/test/resources/res/xml/test_wallpaper.xml
new file mode 100644
index 0000000..7889128
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/test_wallpaper.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="utf-8"?>
+<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
+    android:label="Test Wallpaper" />
\ No newline at end of file
diff --git a/robolectric/src/test/resources/res/xml/xml_attrs.xml b/robolectric/src/test/resources/res/xml/xml_attrs.xml
new file mode 100644
index 0000000..cb94d7c
--- /dev/null
+++ b/robolectric/src/test/resources/res/xml/xml_attrs.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<whatever xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:width="1234px"
+    android:height="@android:dimen/app_icon_size"
+    android:scrollbarFadeDuration="1111"
+    android:title="Android Title"
+    app:title="App Title"
+    android:id="@android:id/text1"
+    style="@android:style/TextAppearance.Small"
+    class="none"
+    id="@android:id/text2"
+/>
\ No newline at end of file
diff --git a/robolectric/src/test/resources/resources.ap_ b/robolectric/src/test/resources/resources.ap_
new file mode 100644
index 0000000..52d9aea
--- /dev/null
+++ b/robolectric/src/test/resources/resources.ap_
Binary files differ
diff --git a/sandbox/build.gradle b/sandbox/build.gradle
new file mode 100644
index 0000000..64accd7
--- /dev/null
+++ b/sandbox/build.gradle
@@ -0,0 +1,28 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    annotationProcessor "com.google.auto.service:auto-service:$autoServiceVersion"
+    annotationProcessor "com.google.errorprone:error_prone_core:$errorproneVersion"
+
+    api project(":annotations")
+    api project(":utils")
+    api project(":shadowapi")
+    api project(":utils:reflector")
+    compileOnly "com.google.auto.service:auto-service-annotations:$autoServiceVersion"
+    api "javax.annotation:javax.annotation-api:1.3.2"
+    api "javax.inject:javax.inject:1"
+
+    api "org.ow2.asm:asm:${asmVersion}"
+    api "org.ow2.asm:asm-commons:${asmVersion}"
+    api "com.google.guava:guava:$guavaJREVersion"
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+    testImplementation project(":junit")
+}
diff --git a/sandbox/src/main/java/org/robolectric/config/AndroidConfigurer.java b/sandbox/src/main/java/org/robolectric/config/AndroidConfigurer.java
new file mode 100644
index 0000000..3a549a7
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/config/AndroidConfigurer.java
@@ -0,0 +1,124 @@
+package org.robolectric.config;
+
+import java.nio.charset.StandardCharsets;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implements;
+import org.robolectric.internal.bytecode.InstrumentationConfiguration;
+import org.robolectric.internal.bytecode.Interceptors;
+import org.robolectric.internal.bytecode.MethodRef;
+import org.robolectric.internal.bytecode.ShadowProviders;
+import org.robolectric.util.Util;
+
+/** Instruments the Android jars */
+public class AndroidConfigurer {
+
+  private final ShadowProviders shadowProviders;
+
+  public AndroidConfigurer(ShadowProviders shadowProviders) {
+    this.shadowProviders = shadowProviders;
+  }
+
+  public void withConfig(InstrumentationConfiguration.Builder builder, Config config) {
+    for (Class<?> clazz : config.shadows()) {
+      Implements annotation = clazz.getAnnotation(Implements.class);
+      if (annotation == null) {
+        throw new IllegalArgumentException(clazz + " is not annotated with @Implements");
+      }
+
+      String className = annotation.className();
+      if (className.isEmpty()) {
+        className = annotation.value().getName();
+      }
+
+      if (!className.isEmpty()) {
+        builder.addInstrumentedClass(className);
+      }
+    }
+    for (String packageName : config.instrumentedPackages()) {
+      builder.addInstrumentedPackage(packageName);
+    }
+  }
+
+  public void configure(InstrumentationConfiguration.Builder builder, Interceptors interceptors) {
+    for (MethodRef methodRef : interceptors.getAllMethodRefs()) {
+      builder.addInterceptedMethod(methodRef);
+    }
+
+    builder
+        .doNotAcquireClass("org.robolectric.TestLifecycle")
+        .doNotAcquireClass("org.robolectric.manifest.AndroidManifest")
+        .doNotAcquireClass("org.robolectric.RobolectricTestRunner")
+        .doNotAcquireClass("org.robolectric.RobolectricTestRunner.HelperTestRunner")
+        .doNotAcquireClass("org.robolectric.shadow.api.ShadowPicker")
+        .doNotAcquireClass("org.robolectric.res.ResourcePath")
+        .doNotAcquireClass("org.robolectric.res.ResourceTable")
+        .doNotAcquireClass("org.robolectric.ApkLoader")
+        .doNotAcquireClass("org.robolectric.res.builder.XmlBlock");
+
+    builder
+        .doNotAcquirePackage("javax.")
+        .doNotAcquirePackage("jdk.internal.")
+        .doNotAcquirePackage("org.junit")
+        .doNotAcquirePackage("org.hamcrest")
+        .doNotAcquirePackage("org.robolectric.annotation.")
+        .doNotAcquirePackage("org.robolectric.internal.")
+        .doNotAcquirePackage("org.robolectric.pluginapi.")
+        .doNotAcquirePackage("org.robolectric.manifest.")
+        .doNotAcquirePackage("org.robolectric.res.")
+        .doNotAcquirePackage("org.robolectric.util.")
+        .doNotAcquirePackage("org.robolectric.RobolectricTestRunner$")
+        .doNotAcquirePackage("sun.")
+        .doNotAcquirePackage("com.sun.")
+        .doNotAcquirePackage("org.w3c.")
+        .doNotAcquirePackage("org.xml.")
+        .doNotAcquirePackage(
+            "org.specs2") // allows for android projects with mixed scala\java tests to be
+        .doNotAcquirePackage(
+            "scala.") //  run with Maven Surefire (see the RoboSpecs project on github)
+        .doNotAcquirePackage("kotlin.")
+        .doNotAcquirePackage("io.mockk.proxy.")
+        .doNotAcquirePackage("org.bouncycastle.")
+        .doNotAcquirePackage("org.conscrypt.")
+        // Fix #958: SQLite native library must be loaded once.
+        .doNotAcquirePackage("com.almworks.sqlite4java")
+        .doNotAcquirePackage("org.jacoco.");
+
+    builder
+        .addClassNameTranslation(
+            "java.net.ExtendedResponseCache", "org.robolectric.fakes.RoboExtendedResponseCache")
+        .addClassNameTranslation(
+            "java.net.ResponseSource", "org.robolectric.fakes.RoboResponseSource")
+        // Needed for android.net.Uri in older SDK versions
+        .addClassNameTranslation("java.nio.charset.Charsets", StandardCharsets.class.getName())
+        .addClassNameTranslation("java.lang.UnsafeByteSequence", Object.class.getName())
+        .addClassNameTranslation("java.util.jar.StrictJarFile", Object.class.getName());
+
+    if (Util.getJavaVersion() >= 9) {
+      builder.addClassNameTranslation("sun.misc.Cleaner", "java.lang.ref.Cleaner$Cleanable");
+    }
+
+    // Instrumenting these classes causes a weird failure.
+    builder.doNotInstrumentClass("android.R")
+        .doNotInstrumentClass("android.R$styleable");
+
+    builder
+        .addInstrumentedPackage("dalvik.")
+        .addInstrumentedPackage("libcore.")
+        .addInstrumentedPackage("android.")
+        .addInstrumentedPackage("com.android.internal.")
+        .addInstrumentedPackage("org.apache.http.")
+        .addInstrumentedPackage("org.ccil.cowan.tagsoup")
+        .addInstrumentedPackage("org.kxml2.");
+
+    builder.doNotInstrumentPackage("android.arch");
+    builder.doNotInstrumentPackage("android.support.test");
+
+    // Mockito's MockMethodDispatcher must only exist in the Bootstrap class loader.
+    builder.doNotAcquireClass(
+        "org.mockito.internal.creation.bytebuddy.inject.MockMethodDispatcher");
+
+    for (String packagePrefix : shadowProviders.getInstrumentedPackages()) {
+      builder.addInstrumentedPackage(packagePrefix);
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/fakes/CleanerCompat.java b/sandbox/src/main/java/org/robolectric/fakes/CleanerCompat.java
new file mode 100644
index 0000000..f30bfbc
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/fakes/CleanerCompat.java
@@ -0,0 +1,47 @@
+package org.robolectric.fakes;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import org.robolectric.interceptors.AndroidInterceptors;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * Wrapper for {@link java.lang.ref.Cleaner}, used by {@link AndroidInterceptors.CleanerInterceptor}
+ * when running on Java 9+.
+ */
+public class CleanerCompat {
+
+  private static final String CLEANER_CLASS_NAME = "java.lang.ref.Cleaner";
+  private static final String CLEANABLE_CLASS_NAME = "java.lang.ref.Cleaner$Cleanable";
+  private static final _Cleaner_ CLEANER;
+
+  static {
+    Object cleaner = reflector(_Cleaner_.class).create();
+    CLEANER = reflector(_Cleaner_.class, cleaner);
+  }
+
+  public static Object register(Object obj, Runnable action) {
+    return CLEANER.register(obj, action);
+  }
+
+  public static void clean(Object cleanable) {
+    reflector(_Cleanable_.class, cleanable).clean();
+  }
+
+  /** Accessor interface for Cleaner's internals. */
+  @ForType(className = CLEANER_CLASS_NAME)
+  interface _Cleaner_ {
+    @Static
+    Object create();
+
+    Object register(Object obj, Runnable action);
+  }
+
+  /** Accessor interface for Cleaner's internals. */
+  @ForType(className = CLEANABLE_CLASS_NAME)
+  interface _Cleanable_ {
+
+    void clean();
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/fakes/RoboExtendedResponseCache.java b/sandbox/src/main/java/org/robolectric/fakes/RoboExtendedResponseCache.java
new file mode 100644
index 0000000..2e995d2
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/fakes/RoboExtendedResponseCache.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.fakes;
+
+import java.net.CacheResponse;
+import java.net.HttpURLConnection;
+
+/**
+ * A response cache that supports statistics tracking and updating stored
+ * responses. Implementations of {@link java.net.ResponseCache} should implement this
+ * interface to receive additional support from the HTTP engine.
+ */
+public interface RoboExtendedResponseCache {
+
+  /**
+   * Track an HTTP response being satisfied by {@code source}.
+   *
+   * @param source Response source.
+   */
+  void trackResponse(RoboResponseSource source);
+
+  /**
+   * Track an conditional GET that was satisfied by this cache.
+   */
+  void trackConditionalCacheHit();
+
+  /**
+   * Updates stored HTTP headers using a hit on a conditional GET.
+   *
+   * @param conditionalCacheHit Conditional cache hit.
+   * @param httpConnection Http connection.
+   */
+  void update(CacheResponse conditionalCacheHit, HttpURLConnection httpConnection);
+}
diff --git a/sandbox/src/main/java/org/robolectric/fakes/RoboResponseSource.java b/sandbox/src/main/java/org/robolectric/fakes/RoboResponseSource.java
new file mode 100644
index 0000000..3e366a2
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/fakes/RoboResponseSource.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.fakes;
+
+/**
+ * Where the HTTP client should look for a response.
+ */
+public enum RoboResponseSource {
+
+  /**
+   * Return the response from the cache immediately.
+   */
+  CACHE,
+
+  /**
+   * Make a conditional request to the host, returning the cache response if
+   * the cache is valid and the network response otherwise.
+   */
+  CONDITIONAL_CACHE,
+
+  /**
+   * Return the response from the network.
+   */
+  NETWORK;
+
+  public boolean requiresConnection() {
+    return this == CONDITIONAL_CACHE || this == NETWORK;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/interceptors/AndroidInterceptors.java b/sandbox/src/main/java/org/robolectric/interceptors/AndroidInterceptors.java
new file mode 100644
index 0000000..219b8fc
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/interceptors/AndroidInterceptors.java
@@ -0,0 +1,495 @@
+package org.robolectric.interceptors;
+
+import static java.lang.Math.min;
+import static java.lang.invoke.MethodHandles.constant;
+import static java.lang.invoke.MethodHandles.dropArguments;
+import static java.lang.invoke.MethodType.methodType;
+import static java.util.Arrays.asList;
+import static org.robolectric.util.ReflectionHelpers.callStaticMethod;
+
+import com.google.common.base.Throwables;
+import java.io.FileDescriptor;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.ref.PhantomReference;
+import java.lang.ref.Reference;
+import java.lang.ref.SoftReference;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Field;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import javax.annotation.Nullable;
+import org.robolectric.fakes.CleanerCompat;
+import org.robolectric.internal.bytecode.Interceptor;
+import org.robolectric.internal.bytecode.MethodRef;
+import org.robolectric.internal.bytecode.MethodSignature;
+import org.robolectric.util.Function;
+import org.robolectric.util.Util;
+
+public class AndroidInterceptors {
+  private static final MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+  public static Collection<Interceptor> all() {
+    List<Interceptor> interceptors =
+        new ArrayList<>(
+            asList(
+                new LinkedHashMapEldestInterceptor(),
+                new SystemTimeInterceptor(),
+                new SystemArrayCopyInterceptor(),
+                new LocaleAdjustLanguageCodeInterceptor(),
+                new SystemLogInterceptor(),
+                new FileDescriptorInterceptor(),
+                new NoOpInterceptor(),
+                new SocketInterceptor(),
+                new ReferenceRefersToInterceptor()));
+
+    if (Util.getJavaVersion() >= 9) {
+      interceptors.add(new CleanerInterceptor());
+    }
+
+    return interceptors;
+  }
+
+  /**
+   * Intercepts calls to libcore-extensions to {@link FileDescriptor}.
+   *
+   * <p>libcore implements extensions to {@link FileDescriptor} that support ownership tracking of
+   * unix FDs, which are not part of the Java API. This intercepts calls to these and maps them to
+   * the OpenJDK API.
+   */
+  public static class FileDescriptorInterceptor extends Interceptor {
+    public FileDescriptorInterceptor() {
+      super(
+          new MethodRef(FileDescriptor.class, "release$"),
+          new MethodRef(FileDescriptor.class, "getInt$"),
+          new MethodRef(FileDescriptor.class, "setInt$"));
+    }
+
+    private static void moveField(
+        FileDescriptor out, FileDescriptor in, String fieldName, Object movedOutValue)
+        throws ReflectiveOperationException {
+      Field fieldAccessor = FileDescriptor.class.getDeclaredField(fieldName);
+      fieldAccessor.setAccessible(true);
+      fieldAccessor.set(out, fieldAccessor.get(in));
+      fieldAccessor.set(in, movedOutValue);
+    }
+
+    static Object setInt(FileDescriptor input, int value) {
+      try {
+        input.getClass().getDeclaredField("fd").setInt(input, value);
+      } catch (Exception e) {
+        // Ignore
+      }
+      return null;
+    }
+
+    static int getInt(FileDescriptor input) {
+      try {
+        Field fieldAccessor = input.getClass().getDeclaredField("fd");
+        fieldAccessor.setAccessible(true);
+        return fieldAccessor.getInt(input);
+      } catch (IllegalAccessException e) {
+        // Should not happen since we set this to be accessible
+        return -1;
+      } catch (NoSuchFieldException e) {
+        return -2;
+      }
+    }
+
+    static FileDescriptor release(FileDescriptor input) {
+      synchronized (input) {
+        try {
+          FileDescriptor ret = new FileDescriptor();
+
+          moveField(ret, input, "fd", /*movedOutValue=*/ -1);
+          // "closed" is irrelevant if the fd is already -1.
+          moveField(ret, input, "closed", /*movedOutValue=*/ false);
+          // N.B.: FileDescriptor.attach() is not implemented in libcore (yet), so these won't be
+          // used.
+          moveField(ret, input, "parent", /*movedOutValue=*/ null);
+          moveField(ret, input, "otherParents", /*movedOutValue=*/ null);
+
+          // These only exist on Windows.
+          try {
+            moveField(ret, input, "handle", /*movedOutValue=*/ -1);
+            moveField(ret, input, "append", /*movedOutValue=*/ false);
+          } catch (ReflectiveOperationException ex) {
+            // Ignore.
+          }
+
+          return ret;
+        } catch (ReflectiveOperationException ex) {
+          throw new RuntimeException(ex);
+        }
+      }
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          if ("release$".equals(methodSignature.methodName)) {
+            return release((FileDescriptor) value);
+          } else if ("getInt$".equals(methodSignature.methodName)) {
+            return getInt((FileDescriptor) value);
+          } else {
+            return setInt((FileDescriptor) value, (int) params[0]);
+          }
+        }
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      if ("release$".equals(methodName)) {
+        return lookup.findStatic(
+            getClass(), "release", methodType(FileDescriptor.class, FileDescriptor.class));
+      } else if ("getInt$".equals(methodName)) {
+        return lookup.findStatic(getClass(), "getInt", methodType(int.class, FileDescriptor.class));
+      } else {
+        return lookup.findStatic(
+            getClass(), "setInt", methodType(Object.class, FileDescriptor.class, int.class));
+      }
+    }
+  }
+
+  // @Intercept(value = LinkedHashMap.class, method = "eldest")
+  public static class LinkedHashMapEldestInterceptor extends Interceptor {
+    public LinkedHashMapEldestInterceptor() {
+      super(new MethodRef(LinkedHashMap.class, "eldest"));
+    }
+
+    @Nullable
+    static Object eldest(LinkedHashMap map) {
+      return map.isEmpty() ? null : map.entrySet().iterator().next();
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          return eldest((LinkedHashMap) value);
+        }
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      return lookup.findStatic(getClass(), "eldest", methodType(Object.class, LinkedHashMap.class));
+    }
+  }
+
+  public static class SystemTimeInterceptor extends Interceptor {
+    public SystemTimeInterceptor() {
+      super(
+          new MethodRef(System.class, "nanoTime"),
+          new MethodRef(System.class, "currentTimeMillis"));
+    }
+
+    @Override
+    public Function<Object, Object> handle(final MethodSignature methodSignature) {
+      return new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          ClassLoader cl = theClass.getClassLoader();
+          try {
+            Class<?> shadowSystemClass = cl.loadClass("org.robolectric.shadows.ShadowSystem");
+            return callStaticMethod(shadowSystemClass, methodSignature.methodName);
+          } catch (ClassNotFoundException e) {
+            throw new RuntimeException(e);
+          }
+        }
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      Class<?> shadowSystemClass;
+      try {
+        shadowSystemClass =
+            getClass().getClassLoader().loadClass("org.robolectric.shadows.ShadowSystem");
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+      switch (methodName) {
+        case "nanoTime":
+          return lookup.findStatic(shadowSystemClass, "nanoTime", methodType(long.class));
+        case "currentTimeMillis":
+          return lookup.findStatic(shadowSystemClass, "currentTimeMillis", methodType(long.class));
+      }
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  public static class SystemArrayCopyInterceptor extends Interceptor {
+    public SystemArrayCopyInterceptor() {
+      super(new MethodRef(System.class, "arraycopy"));
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          //noinspection SuspiciousSystemArraycopy
+          System.arraycopy(
+              params[0], (Integer) params[1], params[2], (Integer) params[3], (Integer) params[4]);
+          return null;
+        }
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      return lookup.findStatic(
+          System.class,
+          "arraycopy",
+          methodType(void.class, Object.class, int.class, Object.class, int.class, int.class));
+    }
+  }
+
+  public static class LocaleAdjustLanguageCodeInterceptor extends Interceptor {
+    public LocaleAdjustLanguageCodeInterceptor() {
+      super(new MethodRef(Locale.class, "adjustLanguageCode"));
+    }
+
+    static String adjustLanguageCode(String languageCode) {
+      String adjusted = languageCode.toLowerCase(Locale.US);
+      // Map new language codes to the obsolete language
+      // codes so the correct resource bundles will be used.
+      if (languageCode.equals("he")) {
+        adjusted = "iw";
+      } else if (languageCode.equals("id")) {
+        adjusted = "in";
+      } else if (languageCode.equals("yi")) {
+        adjusted = "ji";
+      }
+
+      return adjusted;
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          return adjustLanguageCode((String) params[0]);
+        }
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      return lookup.findStatic(
+          getClass(), "adjustLanguageCode", methodType(String.class, String.class));
+    }
+  }
+
+  /** AndroidInterceptor for System.logE and System.logW. */
+  public static class SystemLogInterceptor extends Interceptor {
+    public SystemLogInterceptor() {
+      super(
+          new MethodRef(System.class.getName(), "logE"),
+          new MethodRef(System.class.getName(), "logW"));
+    }
+
+    static void logE(Object... params) {
+      log("System.logE: ", params);
+    }
+
+    static void logW(Object... params) {
+      log("System.logW: ", params);
+    }
+
+    /**
+     * Write logs to {@code System#err}.
+     *
+     * @param prefix A non-null prefix to write.
+     * @param params Optional parameters. Expected parameters are [String message] or [String
+     *     message, Throwable tr] in which case the throwable's stack trace is written to the
+     *     output.
+     */
+    static void log(String prefix, Object... params) {
+      StringBuilder message = new StringBuilder(prefix);
+
+      for (int i = 0; i < min(2, params.length); i++) {
+        Object param = params[i];
+        if (i > 0 && param instanceof Throwable) {
+          message.append('\n').append(Throwables.getStackTraceAsString((Throwable) param));
+        } else {
+          message.append(param);
+        }
+      }
+
+      System.err.println(message);
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return (theClass, value, params) -> {
+        if ("logE".equals(methodSignature.methodName)) {
+          logE(params);
+        } else if ("logW".equals(methodSignature.methodName)) {
+          logW(params);
+        }
+        return null;
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      return lookup.findStatic(getClass(), methodName, methodType(void.class, Object[].class));
+    }
+  }
+
+  /**
+   * Maps calls to Cleaner, which moved between Java 8 and 9:
+   *
+   * <ul>
+   *   <li>{@code sun.misc.Cleaner.create()} -> {@code new java.lang.ref.Cleaner().register()}
+   *   <li>{@code sun.misc.Cleaner.clean()} -> {@code java.lang.ref.Cleaner.Cleanable().clean()}
+   * </ul>
+   */
+  public static class CleanerInterceptor extends Interceptor {
+
+    public CleanerInterceptor() {
+      super(
+          new MethodRef("sun.misc.Cleaner", "create"), new MethodRef("sun.misc.Cleaner", "clean"));
+    }
+
+    static Object create(Object obj, Runnable action) {
+      return CleanerCompat.register(obj, action);
+    }
+
+    static void clean(Object cleanable) {
+      CleanerCompat.clean(cleanable);
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      switch (methodSignature.methodName) {
+        case "create":
+          return (theClass, value, params) -> create(params[0], (Runnable) params[1]);
+        case "clean":
+          return (theClass, value, params) -> {
+            clean(value);
+            return null;
+          };
+        default:
+          throw new IllegalStateException();
+      }
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      switch (methodName) {
+        case "create":
+          return lookup.findStatic(
+              getClass(), "create", methodType(Object.class, Object.class, Runnable.class));
+        case "clean":
+          return lookup.findStatic(getClass(), "clean", methodType(void.class, Object.class));
+        default:
+          throw new IllegalStateException();
+      }
+    }
+  }
+
+  public static class NoOpInterceptor extends Interceptor {
+    public NoOpInterceptor() {
+      super(
+          new MethodRef("java.lang.System", "loadLibrary"),
+          new MethodRef("android.os.StrictMode", "trackActivity"),
+          new MethodRef("android.os.StrictMode", "incrementExpectedActivityCount"),
+          new MethodRef("android.util.LocaleUtil", "getLayoutDirectionFromLocale"),
+          new MethodRef("android.view.FallbackEventHandler", "*"));
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return returnDefaultValue(methodSignature);
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      MethodHandle nothing = constant(Void.class, null).asType(methodType(void.class));
+
+      if (type.parameterCount() != 0) {
+        return dropArguments(nothing, 0, type.parameterArray());
+      } else {
+        return nothing;
+      }
+    }
+  }
+
+  /** Intercepts calls to methods in {@link Socket} not present in the OpenJDK. */
+  public static class SocketInterceptor extends Interceptor {
+    public SocketInterceptor() {
+      super(new MethodRef(Socket.class, "getFileDescriptor$"));
+    }
+
+    @Nullable
+    static FileDescriptor getFileDescriptor(Socket socket) {
+      return null;
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          return getFileDescriptor((Socket) value);
+        }
+      };
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      return lookup.findStatic(
+          getClass(), "getFileDescriptor", methodType(FileDescriptor.class, Socket.class));
+    }
+  }
+
+  /** AndroidInterceptor for Reference.refersTo which is not available until JDK 16. */
+  public static class ReferenceRefersToInterceptor extends Interceptor {
+    private static final String METHOD = "refersTo";
+
+    public ReferenceRefersToInterceptor() {
+      super(
+          new MethodRef(WeakReference.class.getName(), METHOD),
+          new MethodRef(SoftReference.class.getName(), METHOD),
+          new MethodRef(PhantomReference.class.getName(), METHOD));
+    }
+
+    static boolean refersTo(Reference ref, Object obj) {
+      return ref.get() == obj;
+    }
+
+    @Override
+    public Function<Object, Object> handle(MethodSignature methodSignature) {
+      return (theClass, value, params) -> refersTo((Reference) value, params[0]);
+    }
+
+    @Override
+    public MethodHandle getMethodHandle(String methodName, MethodType type)
+        throws NoSuchMethodException, IllegalAccessException {
+      return lookup.findStatic(
+          getClass(), METHOD, methodType(boolean.class, Reference.class, Object.class));
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassDetails.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassDetails.java
new file mode 100644
index 0000000..5004d19
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassDetails.java
@@ -0,0 +1,86 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.annotation.Annotation;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.Opcodes;
+
+/**
+ * A more lightweight variant of {@link MutableClass}. This lets you check for basic metadata like
+ * class name, interfaces, and annotation info by wrapping a {@link ClassReader}, which is
+ * significantly faster than a {@link org.objectweb.asm.tree.ClassNode} object.
+ */
+public class ClassDetails {
+  private static final String SHADOWED_OBJECT_INTERNAL_NAME =
+      ShadowedObject.class.getName().replace('.', '/');
+  private static final String INSTRUMENTED_INTERFACE_INTERNAL_NAME =
+      InstrumentedInterface.class.getName().replace('.', '/');
+
+  private final ClassReader classReader;
+  private final String className;
+  private final byte[] classBytes;
+  private Set<String> annotations;
+  private Set<String> interfaces;
+
+  public ClassDetails(byte[] classBytes) {
+    this.classBytes = classBytes;
+    this.classReader = new ClassReader(classBytes);
+    this.className = classReader.getClassName().replace('/', '.');
+  }
+
+  public boolean isInterface() {
+    return (classReader.getAccess() & Opcodes.ACC_INTERFACE) != 0;
+  }
+
+  public boolean isAnnotation() {
+    return (classReader.getAccess() & Opcodes.ACC_ANNOTATION) != 0;
+  }
+
+  public String getName() {
+    return className;
+  }
+
+  public boolean hasAnnotation(Class<? extends Annotation> annotationClass) {
+    if (annotations == null) {
+      this.annotations = new HashSet<>();
+      classReader.accept(
+          new AnnotationCollector(annotations),
+          ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+    }
+    String internalName = "L" + annotationClass.getName().replace('.', '/') + ";";
+    return this.annotations.contains(internalName);
+  }
+
+  public boolean isInstrumented() {
+    if (this.interfaces == null) {
+      this.interfaces = new HashSet<>(Arrays.asList(classReader.getInterfaces()));
+    }
+    return (isInterface() && this.interfaces.contains(INSTRUMENTED_INTERFACE_INTERNAL_NAME))
+        || this.interfaces.contains(SHADOWED_OBJECT_INTERNAL_NAME);
+  }
+
+  public byte[] getClassBytes() {
+    return classBytes;
+  }
+
+  private static class AnnotationCollector extends ClassVisitor {
+    private final Set<String> annotations;
+
+    public AnnotationCollector(Set<String> annotations) {
+      super(Opcodes.ASM9);
+      this.annotations = annotations;
+    }
+
+    @Override
+    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+      if (visible) {
+        annotations.add(descriptor);
+      }
+      return null;
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassHandler.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassHandler.java
new file mode 100644
index 0000000..73fc9e8
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassHandler.java
@@ -0,0 +1,101 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodType;
+
+/**
+ * This interface is used by Robolectric when instrumented classes are created and interacted with.
+ */
+public interface ClassHandler {
+
+  /**
+   * Called by Robolectric when an instrumented class is first loaded into a sandbox and is ready to
+   * be statically initialized.
+   *
+   * <p>This happens *in place of* any static initialization that may be performed by the class
+   * being loaded. The class will have a method named {@code __staticInitializer__} which may be
+   * invoked to perform its normal initialization from {@code <clinit>}.
+   *
+   * @param clazz the class being loaded
+   */
+  void classInitializing(Class clazz);
+
+  /**
+   * Called by Robolectric when a new instance of an instrumented class has been created and is
+   * ready to be initialized (but only on JVMs which don't support the {@code invokedynamic}
+   * instruction).
+   *
+   * <p>This happens before constructor code executes on the new instance.
+   *
+   * @param instance the newly-created instance
+   * @return a data value to be associated with the new instance
+   * @see #getShadowCreator(Class) for newer JVMs
+   */
+  Object initializing(Object instance);
+
+  /**
+   * Called by Robolectric to determine how to create and initialize a shadow object when a new
+   * instance of an instrumented class has been instantiated. (but only on JVMs which support the
+   * {@code invokedynamic} instruction).
+   *
+   * <p>The returned {@link MethodHandle} will be invoked after the new object has been allocated
+   * but before its constructor code is executed.
+   *
+   * <p>Note that this is not directly analogous to {@link #initializing(Object)}; the return value
+   * from this method will be cached and used again for other instantiations of instances of the
+   * same class.
+   *
+   * @param theClass the instrumented class
+   * @return a data value to be associated with the new instance
+   * @see #getShadowCreator(Class) for older JVMs
+   * @see ShadowInvalidator for invalidating the returned {@link MethodHandle}
+   */
+  MethodHandle getShadowCreator(Class<?> theClass);
+
+  /**
+   * Called by Robolectric when an instrumented method is invoked.
+   *
+   * <p>Implementations should return an {@link MethodHandle}, which will be invoked with details
+   * about the current instance and parameters.
+   *
+   * <p>Implementations may also return null, in which case the method's original code will be
+   * executed.
+   *
+   * @param theClass the class on which the method is declared
+   * @param name the name of the method
+   * @param methodType the method type
+   * @param isStatic true if the method is static
+   * @return a method handle to invoke, or null if the original method's code should be executed
+   * @see ShadowInvalidator for invalidating the returned {@link MethodHandle}
+   */
+  MethodHandle findShadowMethodHandle(
+      Class<?> theClass, String name, MethodType methodType, boolean isStatic)
+      throws IllegalAccessException;
+
+  /**
+   * Called by Robolectric when an intercepted method is invoked.
+   *
+   * <p>Unlike instrumented methods, calls to intercepted methods are modified in place by
+   * Robolectric in the calling code. This is useful when the method about to be invoked doesn't
+   * exist in the current JVM (e.g. because of Android differences).
+   *
+   * @param signature the JVM internal-format signature of the method being invoked (e.g. {@code
+   *     android/view/View/measure(II)V})
+   * @param instance the instance on which the method would have been invoked
+   * @param params the parameters to the method
+   * @param theClass the class on which the method is declared
+   * @return the value to be returned
+   * @throws Throwable if anything bad happens
+   */
+  Object intercept(String signature, Object instance, Object[] params, Class<?> theClass)
+      throws Throwable;
+
+  /**
+   * Removes Robolectric noise from stack traces.
+   *
+   * @param throwable the exception to be stripped
+   * @param <T> the type of exception
+   * @return the stripped stack trace
+   */
+  <T extends Throwable> T stripStackTrace(T throwable);
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassHandlerBuilder.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassHandlerBuilder.java
new file mode 100644
index 0000000..c1f9d10
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassHandlerBuilder.java
@@ -0,0 +1,17 @@
+package org.robolectric.internal.bytecode;
+
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.util.inject.AutoFactory;
+
+/**
+ * Factory interface for {@link ClassHandler}.
+ *
+ * To inject your own ClassHandler, annotate a subclass with {@link com.google.auto.service.AutoService}(ClassHandler).
+ *
+ * Robolectric's default ClassHandler is {@link ShadowWrangler}.
+ */
+@AutoFactory
+public interface ClassHandlerBuilder {
+  /** Builds a {@link ClassHandler instance}. */
+  ClassHandler build(ShadowMap shadowMap, ShadowMatcher shadowMatcher, Interceptors interceptors);
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java
new file mode 100644
index 0000000..53872c1
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java
@@ -0,0 +1,747 @@
+package org.robolectric.internal.bytecode;
+
+import static java.lang.invoke.MethodType.methodType;
+
+import com.google.common.collect.Iterables;
+import java.lang.invoke.CallSite;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Modifier;
+import java.util.List;
+import java.util.ListIterator;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.ConstantDynamic;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.Label;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.commons.ClassRemapper;
+import org.objectweb.asm.commons.GeneratorAdapter;
+import org.objectweb.asm.commons.JSRInlinerAdapter;
+import org.objectweb.asm.commons.Method;
+import org.objectweb.asm.commons.Remapper;
+import org.objectweb.asm.tree.AbstractInsnNode;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.FieldInsnNode;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.InsnList;
+import org.objectweb.asm.tree.InsnNode;
+import org.objectweb.asm.tree.InvokeDynamicInsnNode;
+import org.objectweb.asm.tree.JumpInsnNode;
+import org.objectweb.asm.tree.LabelNode;
+import org.objectweb.asm.tree.LdcInsnNode;
+import org.objectweb.asm.tree.MethodInsnNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.TypeInsnNode;
+import org.objectweb.asm.tree.VarInsnNode;
+import org.robolectric.util.PerfStatsCollector;
+
+/**
+ * Instruments (i.e. modifies the bytecode) of classes to place the scaffolding necessary to use
+ * Robolectric's shadows.
+ */
+public class ClassInstrumentor {
+  private static final Handle BOOTSTRAP_INIT;
+  private static final Handle BOOTSTRAP;
+  private static final Handle BOOTSTRAP_STATIC;
+  private static final Handle BOOTSTRAP_INTRINSIC;
+  private static final String ROBO_INIT_METHOD_NAME = "$$robo$init";
+  static final Type OBJECT_TYPE = Type.getType(Object.class);
+  private static final ShadowImpl SHADOW_IMPL = new ShadowImpl();
+  final Decorator decorator;
+
+  static {
+    String className = Type.getInternalName(InvokeDynamicSupport.class);
+
+    MethodType bootstrap =
+        methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class);
+    String bootstrapMethod =
+        bootstrap.appendParameterTypes(MethodHandle.class).toMethodDescriptorString();
+    String bootstrapIntrinsic =
+        bootstrap.appendParameterTypes(String.class).toMethodDescriptorString();
+
+    BOOTSTRAP_INIT =
+        new Handle(
+            Opcodes.H_INVOKESTATIC,
+            className,
+            "bootstrapInit",
+            bootstrap.toMethodDescriptorString(),
+            false);
+    BOOTSTRAP = new Handle(Opcodes.H_INVOKESTATIC, className, "bootstrap", bootstrapMethod, false);
+    BOOTSTRAP_STATIC =
+        new Handle(Opcodes.H_INVOKESTATIC, className, "bootstrapStatic", bootstrapMethod, false);
+    BOOTSTRAP_INTRINSIC =
+        new Handle(
+            Opcodes.H_INVOKESTATIC, className, "bootstrapIntrinsic", bootstrapIntrinsic, false);
+  }
+
+  public ClassInstrumentor() {
+    this(new ShadowDecorator());
+  }
+
+  protected ClassInstrumentor(Decorator decorator) {
+    this.decorator = decorator;
+  }
+
+  private MutableClass analyzeClass(
+      byte[] origClassBytes,
+      final InstrumentationConfiguration config,
+      ClassNodeProvider classNodeProvider) {
+    ClassNode classNode =
+        new ClassNode(Opcodes.ASM4) {
+          @Override
+          public MethodVisitor visitMethod(
+              int access, String name, String desc, String signature, String[] exceptions) {
+            MethodVisitor methodVisitor =
+                super.visitMethod(access, name, config.remapParams(desc), signature, exceptions);
+            return new JSRInlinerAdapter(methodVisitor, access, name, desc, signature, exceptions);
+          }
+        };
+
+    final ClassReader classReader = new ClassReader(origClassBytes);
+    classReader.accept(classNode, 0);
+    return new MutableClass(classNode, config, classNodeProvider);
+  }
+
+  byte[] instrumentToBytes(MutableClass mutableClass) {
+    instrument(mutableClass);
+
+    ClassNode classNode = mutableClass.classNode;
+    ClassWriter writer = new InstrumentingClassWriter(mutableClass.classNodeProvider, classNode);
+    Remapper remapper =
+        new Remapper() {
+          @Override
+          public String map(final String internalName) {
+            return mutableClass.config.mappedTypeName(internalName);
+          }
+        };
+    ClassRemapper visitor = new ClassRemapper(writer, remapper);
+    classNode.accept(visitor);
+
+    return writer.toByteArray();
+  }
+
+  public byte[] instrument(
+      ClassDetails classDetails,
+      InstrumentationConfiguration config,
+      ClassNodeProvider classNodeProvider) {
+    PerfStatsCollector perfStats = PerfStatsCollector.getInstance();
+    MutableClass mutableClass =
+        perfStats.measure(
+            "analyze class",
+            () -> analyzeClass(classDetails.getClassBytes(), config, classNodeProvider));
+    byte[] instrumentedBytes =
+        perfStats.measure("instrument class", () -> instrumentToBytes(mutableClass));
+    recordPackageStats(perfStats, mutableClass);
+    return instrumentedBytes;
+  }
+
+  private void recordPackageStats(PerfStatsCollector perfStats, MutableClass mutableClass) {
+    String className = mutableClass.getName();
+    for (int i = className.indexOf('.'); i != -1; i = className.indexOf('.', i + 1)) {
+      perfStats.incrementCount("instrument package " + className.substring(0, i));
+    }
+  }
+
+  public void instrument(MutableClass mutableClass) {
+    try {
+      // Need Java version >=7 to allow invokedynamic
+      mutableClass.classNode.version = Math.max(mutableClass.classNode.version, Opcodes.V1_7);
+
+      if (mutableClass.getName().equals("android.util.SparseArray")) {
+        addSetToSparseArray(mutableClass);
+      }
+
+      instrumentMethods(mutableClass);
+
+      if (mutableClass.isInterface()) {
+        mutableClass.addInterface(Type.getInternalName(InstrumentedInterface.class));
+      } else {
+        makeClassPublic(mutableClass.classNode);
+        if ((mutableClass.classNode.access & Opcodes.ACC_FINAL) == Opcodes.ACC_FINAL) {
+          mutableClass
+              .classNode
+              .visitAnnotation("Lcom/google/errorprone/annotations/DoNotMock;", true)
+              .visit(
+                  "value",
+                  "This class is final. Consider using the real thing, or "
+                      + "adding/enhancing a Robolectric shadow for it.");
+        }
+        mutableClass.classNode.access = mutableClass.classNode.access & ~Opcodes.ACC_FINAL;
+
+        // If there is no constructor, adds one
+        addNoArgsConstructor(mutableClass);
+
+        addDirectCallConstructor(mutableClass);
+
+        addRoboInitMethod(mutableClass);
+
+        removeFinalFromFields(mutableClass);
+
+        decorator.decorate(mutableClass);
+      }
+    } catch (Exception e) {
+      throw new RuntimeException("failed to instrument " + mutableClass.getName(), e);
+    }
+  }
+
+  // See https://github.com/robolectric/robolectric/issues/6840
+  // Adds Set(int, object) to android.util.SparseArray.
+  private void addSetToSparseArray(MutableClass mutableClass) {
+    for (MethodNode method : mutableClass.getMethods()) {
+      if ("set".equals(method.name)) {
+        return;
+      }
+    }
+
+    MethodNode setFunction =
+        new MethodNode(
+            Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC,
+            "set",
+            "(ILjava/lang/Object;)V",
+            "(ITE;)V",
+            null);
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(setFunction);
+    generator.loadThis();
+    generator.loadArg(0);
+    generator.loadArg(1);
+    generator.invokeVirtual(mutableClass.classType, new Method("put", "(ILjava/lang/Object;)V"));
+    generator.returnValue();
+    mutableClass.addMethod(setFunction);
+  }
+
+  /**
+   * Checks if the first instruction is a Jacoco load instructions. Robolectric is not capable at
+   * the moment of re-instrumenting Jacoco-instrumented constructors.
+   *
+   * @param ctor constructor method node
+   * @return whether or not the constructor can be instrumented
+   */
+  private boolean isJacocoInstrumented(MethodNode ctor) {
+    AbstractInsnNode[] insns = ctor.instructions.toArray();
+    if (insns.length > 0) {
+      if (insns[0] instanceof LdcInsnNode
+          && ((LdcInsnNode) insns[0]).cst instanceof ConstantDynamic) {
+        ConstantDynamic cst = (ConstantDynamic) ((LdcInsnNode) insns[0]).cst;
+        return cst.getName().equals("$jacocoData");
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Adds a call $$robo$init, which instantiates a shadow object if required. This is to support
+   * custom shadows for Jacoco-instrumented classes (except cnstructor shadows).
+   */
+  private void addCallToRoboInit(MutableClass mutableClass, MethodNode ctor) {
+    AbstractInsnNode returnNode =
+        Iterables.find(
+            ctor.instructions,
+            node -> node instanceof InsnNode && node.getOpcode() == Opcodes.RETURN,
+            null);
+    ctor.instructions.insertBefore(returnNode, new VarInsnNode(Opcodes.ALOAD, 0));
+    ctor.instructions.insertBefore(
+        returnNode,
+        new MethodInsnNode(
+            Opcodes.INVOKEVIRTUAL,
+            mutableClass.classType.getInternalName(),
+            ROBO_INIT_METHOD_NAME,
+            "()V"));
+  }
+
+  private void instrumentMethods(MutableClass mutableClass) {
+    if (mutableClass.isInterface()) {
+      for (MethodNode method : mutableClass.getMethods()) {
+        rewriteMethodBody(mutableClass, method);
+      }
+    } else {
+      for (MethodNode method : mutableClass.getMethods()) {
+        rewriteMethodBody(mutableClass, method);
+
+        if (method.name.equals("<clinit>")) {
+          method.name = ShadowConstants.STATIC_INITIALIZER_METHOD_NAME;
+          mutableClass.addMethod(generateStaticInitializerNotifierMethod(mutableClass));
+        } else if (method.name.equals("<init>")) {
+          if (isJacocoInstrumented(method)) {
+            addCallToRoboInit(mutableClass, method);
+          } else {
+            instrumentConstructor(mutableClass, method);
+          }
+        } else if (!isSyntheticAccessorMethod(method) && !Modifier.isAbstract(method.access)) {
+          instrumentNormalMethod(mutableClass, method);
+        }
+      }
+    }
+  }
+
+  private static void addNoArgsConstructor(MutableClass mutableClass) {
+    if (!mutableClass.foundMethods.contains("<init>()V")) {
+      MethodNode defaultConstructor =
+          new MethodNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC, "<init>", "()V", "()V", null);
+      RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(defaultConstructor);
+      generator.loadThis();
+      generator.visitMethodInsn(
+          Opcodes.INVOKESPECIAL, mutableClass.classNode.superName, "<init>", "()V", false);
+      generator.loadThis();
+      generator.invokeVirtual(mutableClass.classType, new Method(ROBO_INIT_METHOD_NAME, "()V"));
+      generator.returnValue();
+      mutableClass.addMethod(defaultConstructor);
+    }
+  }
+
+  protected void addDirectCallConstructor(MutableClass mutableClass) {}
+
+  /**
+   * Generates code like this:
+   *
+   * <pre>
+   * protected void $$robo$init() {
+   *   if (__robo_data__ == null) {
+   *     __robo_data__ = RobolectricInternals.initializing(this);
+   *   }
+   * }
+   * </pre>
+   */
+  private void addRoboInitMethod(MutableClass mutableClass) {
+    MethodNode initMethodNode =
+        new MethodNode(
+            Opcodes.ACC_PROTECTED | Opcodes.ACC_SYNTHETIC,
+            ROBO_INIT_METHOD_NAME,
+            "()V",
+            null,
+            null);
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(initMethodNode);
+    Label alreadyInitialized = new Label();
+    generator.loadThis(); // this
+    generator.getField(
+        mutableClass.classType,
+        ShadowConstants.CLASS_HANDLER_DATA_FIELD_NAME,
+        OBJECT_TYPE); // contents of __robo_data__
+    generator.ifNonNull(alreadyInitialized);
+    generator.loadThis(); // this
+    generator.loadThis(); // this, this
+    writeCallToInitializing(mutableClass, generator);
+    // this, __robo_data__
+    generator.putField(
+        mutableClass.classType, ShadowConstants.CLASS_HANDLER_DATA_FIELD_NAME, OBJECT_TYPE);
+    generator.mark(alreadyInitialized);
+    generator.returnValue();
+    mutableClass.addMethod(initMethodNode);
+  }
+
+  protected void writeCallToInitializing(
+      MutableClass mutableClass, RobolectricGeneratorAdapter generator) {
+    generator.invokeDynamic(
+        "initializing",
+        Type.getMethodDescriptor(OBJECT_TYPE, mutableClass.classType),
+        BOOTSTRAP_INIT);
+  }
+
+  private static void removeFinalFromFields(MutableClass mutableClass) {
+    for (FieldNode fieldNode : mutableClass.getFields()) {
+      fieldNode.access &= ~Modifier.FINAL;
+    }
+  }
+
+  private static boolean isSyntheticAccessorMethod(MethodNode method) {
+    return (method.access & Opcodes.ACC_SYNTHETIC) != 0;
+  }
+
+  /**
+   * Constructors are instrumented as follows: TODO(slliu): Fill in constructor instrumentation
+   * directions
+   *
+   * @param method the constructor to instrument
+   */
+  private void instrumentConstructor(MutableClass mutableClass, MethodNode method) {
+    makeMethodPrivate(method);
+
+    InsnList callSuper = extractCallToSuperConstructor(mutableClass, method);
+    method.name = directMethodName(mutableClass, ShadowConstants.CONSTRUCTOR_METHOD_NAME);
+    mutableClass.addMethod(
+        redirectorMethod(mutableClass, method, ShadowConstants.CONSTRUCTOR_METHOD_NAME));
+
+    String[] exceptions = exceptionArray(method);
+    MethodNode initMethodNode =
+        new MethodNode(method.access, "<init>", method.desc, method.signature, exceptions);
+    makeMethodPublic(initMethodNode);
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(initMethodNode);
+    initMethodNode.instructions.add(callSuper);
+    generator.loadThis();
+    generator.invokeVirtual(mutableClass.classType, new Method(ROBO_INIT_METHOD_NAME, "()V"));
+    generateClassHandlerCall(
+        mutableClass, method, ShadowConstants.CONSTRUCTOR_METHOD_NAME, generator);
+
+    generator.endMethod();
+
+    InsnList postamble = extractInstructionsAfterReturn(method, initMethodNode);
+    if (postamble.size() > 0) {
+      initMethodNode.instructions.add(postamble);
+    }
+    mutableClass.addMethod(initMethodNode);
+  }
+
+  /**
+   * Checks to see if there are instructions after RETURN. If there are, it will check to see if
+   * they belong in the call-to-super, or the shadowable part of the constructor.
+   */
+  private InsnList extractInstructionsAfterReturn(MethodNode method, MethodNode initMethodNode) {
+    InsnList removedInstructions = new InsnList();
+    AbstractInsnNode returnNode =
+        Iterables.find(
+            method.instructions,
+            node -> node instanceof InsnNode && node.getOpcode() == Opcodes.RETURN,
+            null);
+    if (returnNode == null) {
+      return removedInstructions;
+    }
+    if (returnNode.getNext() instanceof LabelNode) {
+      // There are instructions after the return, check where they belong. Note this is a very rare
+      // edge case and only seems to happen with desugared+proguarded classes such as
+      // play-services-basement's ApiException.
+      LabelNode labelAfterReturn = (LabelNode) returnNode.getNext();
+      boolean inInitMethodNode =
+          Iterables.any(
+              initMethodNode.instructions,
+              input ->
+                  input instanceof JumpInsnNode
+                      && ((JumpInsnNode) input).label == labelAfterReturn);
+
+      if (inInitMethodNode) {
+        while (returnNode.getNext() != null) {
+          AbstractInsnNode node = returnNode.getNext();
+          method.instructions.remove(node);
+          removedInstructions.add(node);
+        }
+      }
+    }
+    return removedInstructions;
+  }
+
+  private static InsnList extractCallToSuperConstructor(
+      MutableClass mutableClass, MethodNode ctor) {
+    InsnList removedInstructions = new InsnList();
+    // Start removing instructions at the beginning of the method. The first instructions of
+    // constructors may vary.
+    int startIndex = 0;
+
+    AbstractInsnNode[] insns = ctor.instructions.toArray();
+    for (int i = 0; i < insns.length; i++) {
+      AbstractInsnNode node = insns[i];
+
+      switch (node.getOpcode()) {
+        case Opcodes.INVOKESPECIAL:
+          MethodInsnNode mnode = (MethodInsnNode) node;
+          if (mnode.owner.equals(mutableClass.internalClassName)
+              || mnode.owner.equals(mutableClass.classNode.superName)) {
+            if (!"<init>".equals(mnode.name)) {
+              throw new AssertionError("Invalid MethodInsnNode name");
+            }
+
+            // remove all instructions in the range 0 (the start) to invokespecial
+            // <init>
+            while (startIndex <= i) {
+              ctor.instructions.remove(insns[startIndex]);
+              removedInstructions.add(insns[startIndex]);
+              startIndex++;
+            }
+            return removedInstructions;
+          }
+          break;
+
+        case Opcodes.ATHROW:
+          ctor.visitCode();
+          ctor.visitInsn(Opcodes.RETURN);
+          ctor.visitEnd();
+          return removedInstructions;
+
+        default:
+          // nothing to do
+      }
+    }
+
+    throw new RuntimeException("huh? " + ctor.name + ctor.desc);
+  }
+
+  /**
+   * Instruments a normal method
+   *
+   * <ul>
+   *   <li>Rename the method from {@code methodName} to {@code $$robo$$methodName}.
+   *   <li>Make it private so we can invoke it directly without subclass overrides taking
+   *       precedence.
+   *   <li>Remove {@code final} modifiers, if present.
+   *   <li>Create a delegator method named {@code methodName} which delegates to the {@link
+   *       ClassHandler}.
+   * </ul>
+   */
+  protected void instrumentNormalMethod(MutableClass mutableClass, MethodNode method) {
+    // if not abstract, set a final modifier
+    if ((method.access & Opcodes.ACC_ABSTRACT) == 0) {
+      method.access = method.access | Opcodes.ACC_FINAL;
+    }
+    boolean isNativeMethod = (method.access & Opcodes.ACC_NATIVE) != 0;
+    if (isNativeMethod) {
+      instrumentNativeMethod(mutableClass, method);
+    }
+
+    // todo figure out
+    String originalName = method.name;
+    method.name = directMethodName(mutableClass, originalName);
+
+    MethodNode delegatorMethodNode =
+        new MethodNode(
+            method.access, originalName, method.desc, method.signature, exceptionArray(method));
+    delegatorMethodNode.visibleAnnotations = method.visibleAnnotations;
+    delegatorMethodNode.access &= ~(Opcodes.ACC_NATIVE | Opcodes.ACC_ABSTRACT | Opcodes.ACC_FINAL);
+
+    makeMethodPrivate(method);
+
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(delegatorMethodNode);
+    generateClassHandlerCall(mutableClass, method, originalName, generator);
+    generator.endMethod();
+    mutableClass.addMethod(delegatorMethodNode);
+  }
+
+  /**
+   * Creates native stub which returns the default return value.
+   *
+   * @param mutableClass Class to be instrumented
+   * @param method Method to be instrumented, must be native
+   */
+  protected void instrumentNativeMethod(MutableClass mutableClass, MethodNode method) {
+    method.access = method.access & ~Opcodes.ACC_NATIVE;
+
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(method);
+    Type returnType = generator.getReturnType();
+    generator.pushDefaultReturnValueToStack(returnType);
+    generator.returnValue();
+  }
+
+  protected static String directMethodName(MutableClass mutableClass, String originalName) {
+    return SHADOW_IMPL.directMethodName(mutableClass.getName(), originalName);
+  }
+
+  // todo rename
+  private MethodNode redirectorMethod(
+      MutableClass mutableClass, MethodNode method, String newName) {
+    MethodNode redirector =
+        new MethodNode(
+            Opcodes.ASM4, newName, method.desc, method.signature, exceptionArray(method));
+    redirector.access =
+        method.access & ~(Opcodes.ACC_NATIVE | Opcodes.ACC_ABSTRACT | Opcodes.ACC_FINAL);
+    makeMethodPrivate(redirector);
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(redirector);
+    generator.invokeMethod(mutableClass.internalClassName, method);
+    generator.returnValue();
+    return redirector;
+  }
+
+  protected String[] exceptionArray(MethodNode method) {
+    List<String> exceptions = method.exceptions;
+    return exceptions.toArray(new String[exceptions.size()]);
+  }
+
+  /** Filters methods that might need special treatment because of various reasons */
+  private void rewriteMethodBody(MutableClass mutableClass, MethodNode callingMethod) {
+    ListIterator<AbstractInsnNode> instructions = callingMethod.instructions.iterator();
+    while (instructions.hasNext()) {
+      AbstractInsnNode node = instructions.next();
+
+      switch (node.getOpcode()) {
+        case Opcodes.NEW:
+          TypeInsnNode newInsnNode = (TypeInsnNode) node;
+          newInsnNode.desc = mutableClass.config.mappedTypeName(newInsnNode.desc);
+          break;
+
+        case Opcodes.GETFIELD:
+          /* falls through */
+        case Opcodes.PUTFIELD:
+          /* falls through */
+        case Opcodes.GETSTATIC:
+          /* falls through */
+        case Opcodes.PUTSTATIC:
+          FieldInsnNode fieldInsnNode = (FieldInsnNode) node;
+          fieldInsnNode.desc = mutableClass.config.mappedTypeName(fieldInsnNode.desc); // todo test
+          break;
+
+        case Opcodes.INVOKESTATIC:
+          /* falls through */
+        case Opcodes.INVOKEINTERFACE:
+          /* falls through */
+        case Opcodes.INVOKESPECIAL:
+          /* falls through */
+        case Opcodes.INVOKEVIRTUAL:
+          MethodInsnNode targetMethod = (MethodInsnNode) node;
+          targetMethod.desc = mutableClass.config.remapParams(targetMethod.desc);
+          if (isGregorianCalendarBooleanConstructor(targetMethod)) {
+            replaceGregorianCalendarBooleanConstructor(instructions, targetMethod);
+          } else if (mutableClass.config.shouldIntercept(targetMethod)) {
+            interceptInvokeVirtualMethod(mutableClass, instructions, targetMethod);
+          }
+          break;
+
+        case Opcodes.INVOKEDYNAMIC:
+          /* no unusual behavior */
+          break;
+
+        default:
+          break;
+      }
+    }
+  }
+
+  /**
+   * Verifies if the @targetMethod is a {@code <init>(boolean)} constructor for {@link
+   * java.util.GregorianCalendar}.
+   */
+  private static boolean isGregorianCalendarBooleanConstructor(MethodInsnNode targetMethod) {
+    return targetMethod.owner.equals("java/util/GregorianCalendar")
+        && targetMethod.name.equals("<init>")
+        && targetMethod.desc.equals("(Z)V");
+  }
+
+  /**
+   * Replaces the void {@code <init>(boolean)} constructor for a call to the {@code void <init>(int,
+   * int, int)} one.
+   */
+  private static void replaceGregorianCalendarBooleanConstructor(
+      ListIterator<AbstractInsnNode> instructions, MethodInsnNode targetMethod) {
+    // Remove the call to GregorianCalendar(boolean)
+    instructions.remove();
+
+    // Discard the already-pushed parameter for GregorianCalendar(boolean)
+    instructions.add(new InsnNode(Opcodes.POP));
+
+    // Add parameters values for calling GregorianCalendar(int, int, int)
+    instructions.add(new InsnNode(Opcodes.ICONST_0));
+    instructions.add(new InsnNode(Opcodes.ICONST_0));
+    instructions.add(new InsnNode(Opcodes.ICONST_0));
+
+    // Call GregorianCalendar(int, int, int)
+    instructions.add(
+        new MethodInsnNode(
+            Opcodes.INVOKESPECIAL,
+            targetMethod.owner,
+            targetMethod.name,
+            "(III)V",
+            targetMethod.itf));
+  }
+
+  /**
+   * Decides to call through the appropriate method to intercept the method with an INVOKEVIRTUAL
+   * Opcode, depending if the invokedynamic bytecode instruction is available (Java 7+).
+   */
+  protected void interceptInvokeVirtualMethod(
+      MutableClass mutableClass,
+      ListIterator<AbstractInsnNode> instructions,
+      MethodInsnNode targetMethod) {
+    instructions.remove(); // remove the method invocation
+
+    Type type = Type.getObjectType(targetMethod.owner);
+    String description = targetMethod.desc;
+    String owner = type.getClassName();
+
+    if (targetMethod.getOpcode() != Opcodes.INVOKESTATIC) {
+      String thisType = type.getDescriptor();
+      description = "(" + thisType + description.substring(1);
+    }
+
+    instructions.add(
+        new InvokeDynamicInsnNode(targetMethod.name, description, BOOTSTRAP_INTRINSIC, owner));
+  }
+
+  /** Replaces protected and private class modifiers with public. */
+  private static void makeClassPublic(ClassNode clazz) {
+    clazz.access =
+        (clazz.access | Opcodes.ACC_PUBLIC) & ~(Opcodes.ACC_PROTECTED | Opcodes.ACC_PRIVATE);
+  }
+
+  /** Replaces protected and private method modifiers with public. */
+  protected void makeMethodPublic(MethodNode method) {
+    method.access =
+        (method.access | Opcodes.ACC_PUBLIC) & ~(Opcodes.ACC_PROTECTED | Opcodes.ACC_PRIVATE);
+  }
+
+  /** Replaces protected and public class modifiers with private. */
+  protected void makeMethodPrivate(MethodNode method) {
+    method.access =
+        (method.access | Opcodes.ACC_PRIVATE) & ~(Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED);
+  }
+
+  private static MethodNode generateStaticInitializerNotifierMethod(MutableClass mutableClass) {
+    MethodNode methodNode = new MethodNode(Opcodes.ACC_STATIC, "<clinit>", "()V", "()V", null);
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(methodNode);
+    generator.push(mutableClass.classType);
+    generator.invokeStatic(
+        Type.getType(RobolectricInternals.class),
+        new Method("classInitializing", "(Ljava/lang/Class;)V"));
+    generator.returnValue();
+    generator.endMethod();
+    return methodNode;
+  }
+
+  // todo javadocs
+  protected void generateClassHandlerCall(
+      MutableClass mutableClass,
+      MethodNode originalMethod,
+      String originalMethodName,
+      RobolectricGeneratorAdapter generator) {
+    Handle original =
+        new Handle(
+            getTag(originalMethod),
+            mutableClass.classType.getInternalName(),
+            originalMethod.name,
+            originalMethod.desc,
+            getTag(originalMethod) == Opcodes.H_INVOKEINTERFACE);
+
+    if (generator.isStatic()) {
+      generator.loadArgs();
+      generator.invokeDynamic(originalMethodName, originalMethod.desc, BOOTSTRAP_STATIC, original);
+    } else {
+      String desc = "(" + mutableClass.classType.getDescriptor() + originalMethod.desc.substring(1);
+      generator.loadThis();
+      generator.loadArgs();
+      generator.invokeDynamic(originalMethodName, desc, BOOTSTRAP, original);
+    }
+
+    generator.returnValue();
+  }
+
+  int getTag(MethodNode m) {
+    return Modifier.isStatic(m.access) ? Opcodes.H_INVOKESTATIC : Opcodes.H_INVOKESPECIAL;
+  }
+
+  public interface Decorator {
+    void decorate(MutableClass mutableClass);
+  }
+
+  /**
+   * Provides try/catch code generation with a {@link org.objectweb.asm.commons.GeneratorAdapter}.
+   */
+  static class TryCatch {
+    private final Label start;
+    private final Label end;
+    private final Label handler;
+    private final GeneratorAdapter generatorAdapter;
+
+    TryCatch(GeneratorAdapter generatorAdapter, Type type) {
+      this.generatorAdapter = generatorAdapter;
+      this.start = generatorAdapter.mark();
+      this.end = new Label();
+      this.handler = new Label();
+      generatorAdapter.visitTryCatchBlock(start, end, handler, type.getInternalName());
+    }
+
+    void end() {
+      generatorAdapter.mark(end);
+    }
+
+    void handler() {
+      generatorAdapter.mark(handler);
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassNodeProvider.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassNodeProvider.java
new file mode 100644
index 0000000..71d39ef
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassNodeProvider.java
@@ -0,0 +1,31 @@
+package org.robolectric.internal.bytecode;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.tree.ClassNode;
+
+public abstract class ClassNodeProvider {
+  private final Map<String, ClassNode> classNodes = new ConcurrentHashMap<>();
+
+  protected abstract byte[] getClassBytes(String className) throws ClassNotFoundException;
+
+  ClassNode getClassNode(String internalClassName) throws ClassNotFoundException {
+    ClassNode classNode = classNodes.get(internalClassName);
+    if (classNode == null) {
+      classNode = createClassNode(internalClassName);
+      classNodes.put(internalClassName, classNode);
+    }
+    return classNode;
+  }
+
+  private ClassNode createClassNode(String internalClassName) throws ClassNotFoundException {
+    byte[] byteCode = getClassBytes(internalClassName);
+    ClassReader classReader = new ClassReader(byteCode);
+    ClassNode classNode = new ClassNode();
+    classReader.accept(classNode,
+        ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+    return classNode;
+  }
+
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassValueMap.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassValueMap.java
new file mode 100644
index 0000000..3a7dff7
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassValueMap.java
@@ -0,0 +1,27 @@
+package org.robolectric.internal.bytecode;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.Collections;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+/**
+ * {@link java.lang.ClassValue} doesn't exist in Android, so provide a trivial impl.
+ *
+ * Note that if T contains references to Class, this won't really be weak. That's okay.
+ */
+abstract class ClassValueMap<T> {
+  private final Map<Class<?>, T> map = Collections.synchronizedMap(new WeakHashMap<>());
+
+  protected abstract T computeValue(Class<?> type);
+
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public T get(Class<?> type) {
+    return map.computeIfAbsent(type, this::computeValue);
+  }
+
+  @VisibleForTesting
+  void clear() {
+    map.clear();
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/DirectObjectMarker.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/DirectObjectMarker.java
new file mode 100644
index 0000000..f9219b2
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/DirectObjectMarker.java
@@ -0,0 +1,9 @@
+package org.robolectric.internal.bytecode;
+
+public class DirectObjectMarker {
+  public static final DirectObjectMarker INSTANCE = new DirectObjectMarker() {
+  };
+
+  private DirectObjectMarker() {
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentationConfiguration.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentationConfiguration.java
new file mode 100644
index 0000000..387751c
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentationConfiguration.java
@@ -0,0 +1,334 @@
+package org.robolectric.internal.bytecode;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.objectweb.asm.tree.MethodInsnNode;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Configuration rules for {@link SandboxClassLoader}.
+ */
+public class InstrumentationConfiguration {
+
+  public static Builder newBuilder() {
+    return new Builder();
+  }
+
+  static final Set<String> CLASSES_TO_ALWAYS_ACQUIRE = Sets.newHashSet(
+      RobolectricInternals.class.getName(),
+      InvokeDynamicSupport.class.getName(),
+      Shadow.class.getName(),
+
+      // these classes are deprecated and will be removed soon:
+      "org.robolectric.util.FragmentTestUtil",
+      "org.robolectric.util.FragmentTestUtil$FragmentUtilActivity"
+  );
+
+  // Must always acquire these as they change from API level to API level
+  static final ImmutableSet<String> RESOURCES_TO_ALWAYS_ACQUIRE =
+      ImmutableSet.of("build.prop", "usr/share/zoneinfo/tzdata");
+
+  private final List<String> instrumentedPackages;
+  private final Set<String> instrumentedClasses;
+  private final Set<String> classesToNotInstrument;
+  private final String classesToNotInstrumentRegex;
+  private final Map<String, String> classNameTranslations;
+  private final Set<MethodRef> interceptedMethods;
+  private final Set<String> classesToNotAcquire;
+  private final Set<String> packagesToNotAcquire;
+  private final Set<String> packagesToNotInstrument;
+  private int cachedHashCode;
+
+  private final TypeMapper typeMapper;
+  private final Set<MethodRef> methodsToIntercept;
+
+  protected InstrumentationConfiguration(
+      Map<String, String> classNameTranslations,
+      Collection<MethodRef> interceptedMethods,
+      Collection<String> instrumentedPackages,
+      Collection<String> instrumentedClasses,
+      Collection<String> classesToNotAcquire,
+      Collection<String> packagesToNotAquire,
+      Collection<String> classesToNotInstrument,
+      Collection<String> packagesToNotInstrument,
+      String classesToNotInstrumentRegex) {
+    this.classNameTranslations = ImmutableMap.copyOf(classNameTranslations);
+    this.interceptedMethods = ImmutableSet.copyOf(interceptedMethods);
+    this.instrumentedPackages = ImmutableList.copyOf(instrumentedPackages);
+    this.instrumentedClasses = ImmutableSet.copyOf(instrumentedClasses);
+    this.classesToNotAcquire = ImmutableSet.copyOf(classesToNotAcquire);
+    this.packagesToNotAcquire = ImmutableSet.copyOf(packagesToNotAquire);
+    this.classesToNotInstrument = ImmutableSet.copyOf(classesToNotInstrument);
+    this.packagesToNotInstrument = ImmutableSet.copyOf(packagesToNotInstrument);
+    this.classesToNotInstrumentRegex = classesToNotInstrumentRegex;
+    this.cachedHashCode = 0;
+
+    this.typeMapper = new TypeMapper(classNameTranslations());
+    this.methodsToIntercept = ImmutableSet.copyOf(convertToSlashes(methodsToIntercept()));
+  }
+
+  /**
+   * Determine if {@link SandboxClassLoader} should instrument a given class.
+   *
+   * @param classDetails The class to check.
+   * @return True if the class should be instrumented.
+   */
+  public boolean shouldInstrument(ClassDetails classDetails) {
+    return !classDetails.isAnnotation()
+        && !classesToNotInstrument.contains(classDetails.getName())
+        && !isInPackagesToNotInstrument(classDetails.getName())
+        && !classMatchesExclusionRegex(classDetails.getName())
+        && !classDetails.isInstrumented()
+        && !classDetails.hasAnnotation(DoNotInstrument.class)
+        && (isInInstrumentedPackage(classDetails.getName())
+            || instrumentedClasses.contains(classDetails.getName())
+            || classDetails.hasAnnotation(Instrument.class));
+  }
+
+  private boolean classMatchesExclusionRegex(String className) {
+    return classesToNotInstrumentRegex != null && className.matches(classesToNotInstrumentRegex);
+  }
+
+  /**
+   * Determine if {@link SandboxClassLoader} should load a given class.
+   *
+   * @param   name The fully-qualified class name.
+   * @return  True if the class should be loaded.
+   */
+  public boolean shouldAcquire(String name) {
+    if (CLASSES_TO_ALWAYS_ACQUIRE.contains(name)) {
+      return true;
+    }
+
+    if (name.equals("java.util.jar.StrictJarFile")) {
+      return true;
+    }
+
+    // android.R and com.android.internal.R classes must be loaded from the framework jar
+    if (name.matches("(android|com\\.android\\.internal)\\.R(\\$.+)?")) {
+      return true;
+    }
+
+    // Hack. Fixes https://github.com/robolectric/robolectric/issues/1864
+    if (name.equals("javax.net.ssl.DistinguishedNameParser")
+        || name.equals("javax.microedition.khronos.opengles.GL")) {
+      return true;
+    }
+
+    for (String packageName : packagesToNotAcquire) {
+      if (name.startsWith(packageName)) return false;
+    }
+
+    // R classes must be loaded from system CP
+    boolean isRClass = name.matches(".*\\.R(|\\$[a-z]+)$");
+    return !isRClass && !classesToNotAcquire.contains(name);
+  }
+
+  /**
+   * Determine if {@link SandboxClassLoader} should load a given resource.
+   *
+   * @param name The fully-qualified resource name.
+   * @return True if the resource should be loaded.
+   */
+  public boolean shouldAcquireResource(String name) {
+    return RESOURCES_TO_ALWAYS_ACQUIRE.contains(name);
+  }
+
+  public Set<MethodRef> methodsToIntercept() {
+    return Collections.unmodifiableSet(interceptedMethods);
+  }
+
+  /**
+   * Map from a requested class to an alternate stand-in, or not.
+   *
+   * @return Mapping of class name translations.
+   */
+  public Map<String, String> classNameTranslations() {
+    return Collections.unmodifiableMap(classNameTranslations);
+  }
+
+  private boolean isInInstrumentedPackage(String className) {
+    for (String instrumentedPackage : instrumentedPackages) {
+      if (className.startsWith(instrumentedPackage)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean isInPackagesToNotInstrument(String className) {
+    for (String notInstrumentedPackage : packagesToNotInstrument) {
+      if (className.startsWith(notInstrumentedPackage)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof InstrumentationConfiguration)) return false;
+
+    InstrumentationConfiguration that = (InstrumentationConfiguration) o;
+
+    if (!classNameTranslations.equals(that.classNameTranslations)) return false;
+    if (!classesToNotAcquire.equals(that.classesToNotAcquire)) return false;
+    if (!instrumentedPackages.equals(that.instrumentedPackages)) return false;
+    if (!instrumentedClasses.equals(that.instrumentedClasses)) return false;
+    if (!interceptedMethods.equals(that.interceptedMethods)) return false;
+
+
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    if (cachedHashCode != 0) {
+      return cachedHashCode;
+    }
+
+    int result = instrumentedPackages.hashCode();
+    result = 31 * result + instrumentedClasses.hashCode();
+    result = 31 * result + classNameTranslations.hashCode();
+    result = 31 * result + interceptedMethods.hashCode();
+    result = 31 * result + classesToNotAcquire.hashCode();
+    cachedHashCode = result;
+    return result;
+  }
+
+  public String remapParamType(String desc) {
+    return typeMapper.remapParamType(desc);
+  }
+
+  public String remapParams(String desc) {
+    return typeMapper.remapParams(desc);
+  }
+
+  public String mappedTypeName(String internalName) {
+    return typeMapper.mappedTypeName(internalName);
+  }
+
+  boolean shouldIntercept(MethodInsnNode targetMethod) {
+    if (targetMethod.name.equals("<init>")) {
+      return false; // sorry, can't strip out calls to super() in constructor
+    }
+    return methodsToIntercept.contains(new MethodRef(targetMethod.owner, targetMethod.name))
+        || methodsToIntercept.contains(new MethodRef(targetMethod.owner, "*"));
+  }
+
+  private static Set<MethodRef> convertToSlashes(Set<MethodRef> methodRefs) {
+    HashSet<MethodRef> transformed = new HashSet<>();
+    for (MethodRef methodRef : methodRefs) {
+      transformed.add(new MethodRef(internalize(methodRef.className), methodRef.methodName));
+    }
+    return transformed;
+  }
+
+  private static String internalize(String className) {
+    return className.replace('.', '/');
+  }
+
+  public static final class Builder {
+    public final Collection<String> instrumentedPackages = new HashSet<>();
+    public final Collection<MethodRef> interceptedMethods = new HashSet<>();
+    public final Map<String, String> classNameTranslations = new HashMap<>();
+    public final Collection<String> classesToNotAcquire = new HashSet<>();
+    public final Collection<String> packagesToNotAcquire = new HashSet<>();
+    public final Collection<String> instrumentedClasses = new HashSet<>();
+    public final Collection<String> classesToNotInstrument = new HashSet<>();
+    public final Collection<String> packagesToNotInstrument = new HashSet<>();
+    public String classesToNotInstrumentRegex;
+
+
+    public Builder() {
+    }
+
+    public Builder(InstrumentationConfiguration classLoaderConfig) {
+      instrumentedPackages.addAll(classLoaderConfig.instrumentedPackages);
+      interceptedMethods.addAll(classLoaderConfig.interceptedMethods);
+      classNameTranslations.putAll(classLoaderConfig.classNameTranslations);
+      classesToNotAcquire.addAll(classLoaderConfig.classesToNotAcquire);
+      packagesToNotAcquire.addAll(classLoaderConfig.packagesToNotAcquire);
+      instrumentedClasses.addAll(classLoaderConfig.instrumentedClasses);
+      classesToNotInstrument.addAll(classLoaderConfig.classesToNotInstrument);
+      packagesToNotInstrument.addAll(classLoaderConfig.packagesToNotInstrument);
+      classesToNotInstrumentRegex = classLoaderConfig.classesToNotInstrumentRegex;
+    }
+
+    public Builder doNotAcquireClass(Class<?> clazz) {
+      doNotAcquireClass(clazz.getName());
+      return this;
+    }
+
+    public Builder doNotAcquireClass(String className) {
+      this.classesToNotAcquire.add(className);
+      return this;
+    }
+
+    public Builder doNotAcquirePackage(String packageName) {
+      this.packagesToNotAcquire.add(packageName);
+      return this;
+    }
+
+    public Builder addClassNameTranslation(String fromName, String toName) {
+      classNameTranslations.put(fromName, toName);
+      return this;
+    }
+
+    public Builder addInterceptedMethod(MethodRef methodReference) {
+      interceptedMethods.add(methodReference);
+      return this;
+    }
+
+    public Builder addInstrumentedClass(String name) {
+      instrumentedClasses.add(name);
+      return this;
+    }
+
+    public Builder addInstrumentedPackage(String packageName) {
+      instrumentedPackages.add(packageName);
+      return this;
+    }
+
+    public Builder doNotInstrumentClass(String className) {
+      this.classesToNotInstrument.add(className);
+      return this;
+    }
+
+    public Builder doNotInstrumentPackage(String packageName) {
+      this.packagesToNotInstrument.add(packageName);
+      return this;
+    }
+
+    public Builder setDoNotInstrumentClassRegex(String classNameRegex) {
+      this.classesToNotInstrumentRegex = classNameRegex;
+      return this;
+    }
+
+
+      public InstrumentationConfiguration build() {
+      return new InstrumentationConfiguration(
+          classNameTranslations,
+          interceptedMethods,
+          instrumentedPackages,
+          instrumentedClasses,
+          classesToNotAcquire,
+          packagesToNotAcquire,
+          classesToNotInstrument,
+          packagesToNotInstrument,
+          classesToNotInstrumentRegex);
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentedInterface.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentedInterface.java
new file mode 100644
index 0000000..d006a39
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentedInterface.java
@@ -0,0 +1,4 @@
+package org.robolectric.internal.bytecode;
+
+/** Marker interface used by Robolectric to indicate that an interface has been instrumented */
+public interface InstrumentedInterface {}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentingClassWriter.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentingClassWriter.java
new file mode 100644
index 0000000..28e64a0
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/InstrumentingClassWriter.java
@@ -0,0 +1,117 @@
+package org.robolectric.internal.bytecode;
+
+import java.util.List;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.tree.ClassNode;
+
+/**
+ * ClassWriter implementation that verifies classes by comparing type information obtained
+ * from loading the classes as resources. This was taken from the ASM ClassWriter unit tests.
+ */
+public class InstrumentingClassWriter extends ClassWriter {
+
+  private final ClassNodeProvider classNodeProvider;
+
+  /**
+   * Preserve stack map frames for V51 and newer bytecode. This fixes class verification errors for
+   * JDK7 and JDK8. The option to disable bytecode verification was removed in JDK8.
+   *
+   * <p>Don't bother for V50 and earlier bytecode, because it doesn't contain stack map frames, and
+   * also because ASM's stack map frame handling doesn't support the JSR and RET instructions
+   * present in legacy bytecode.
+   */
+  public InstrumentingClassWriter(ClassNodeProvider classNodeProvider, ClassNode classNode) {
+    super(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+    this.classNodeProvider = classNodeProvider;
+  }
+
+  /**
+   * Returns the common super type of the two given types without actually loading
+   * the classes in the ClassLoader.
+   */
+  @Override
+  protected String getCommonSuperClass(final String type1, final String type2) {
+    try {
+      ClassNode info1 = typeInfo(type1);
+      ClassNode info2 = typeInfo(type2);
+      if ((info1.access & Opcodes.ACC_INTERFACE) != 0) {
+        if (typeImplements(type2, info2, type1)) {
+          return type1;
+        }
+        if ((info2.access & Opcodes.ACC_INTERFACE) != 0) {
+          if (typeImplements(type1, info1, type2)) {
+            return type2;
+          }
+        }
+        return "java/lang/Object";
+      }
+      if ((info2.access & Opcodes.ACC_INTERFACE) != 0) {
+        if (typeImplements(type1, info1, type2)) {
+          return type2;
+        } else {
+          return "java/lang/Object";
+        }
+      }
+      String b1 = typeAncestors(type1, info1);
+      String b2 = typeAncestors(type2, info2);
+      String result = "java/lang/Object";
+      int end1 = b1.length();
+      int end2 = b2.length();
+      while (true) {
+        int start1 = b1.lastIndexOf(';', end1 - 1);
+        int start2 = b2.lastIndexOf(';', end2 - 1);
+        if (start1 != -1 && start2 != -1
+            && end1 - start1 == end2 - start2) {
+          String p1 = b1.substring(start1 + 1, end1);
+          String p2 = b2.substring(start2 + 1, end2);
+          if (p1.equals(p2)) {
+            result = p1;
+            end1 = start1;
+            end2 = start2;
+          } else {
+            return result;
+          }
+        } else {
+          return result;
+        }
+      }
+    } catch (ClassNotFoundException e) {
+      return "java/lang/Object"; // Handle classes that may be obfuscated
+    }
+  }
+
+  private String typeAncestors(String type, ClassNode info) throws ClassNotFoundException {
+    StringBuilder b = new StringBuilder();
+    while (!"java/lang/Object".equals(type)) {
+      b.append(';').append(type);
+      type = info.superName;
+      info = typeInfo(type);
+    }
+    return b.toString();
+  }
+
+  private boolean typeImplements(String type, ClassNode info, String itf)
+      throws ClassNotFoundException {
+    while (!"java/lang/Object".equals(type)) {
+      List<String> itfs = info.interfaces;
+      for (String itf2 : itfs) {
+        if (itf2.equals(itf)) {
+          return true;
+        }
+      }
+      for (String itf1 : itfs) {
+        if (typeImplements(itf1, typeInfo(itf1), itf)) {
+          return true;
+        }
+      }
+      type = info.superName;
+      info = typeInfo(type);
+    }
+    return false;
+  }
+
+  private ClassNode typeInfo(final String type) throws ClassNotFoundException {
+    return classNodeProvider.getClassNode(type);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/Interceptor.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/Interceptor.java
new file mode 100644
index 0000000..8edee10
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/Interceptor.java
@@ -0,0 +1,33 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodType;
+import javax.annotation.Nonnull;
+import org.robolectric.util.Function;
+import org.robolectric.util.ReflectionHelpers;
+
+public abstract class Interceptor {
+  private MethodRef[] methodRefs;
+
+  public Interceptor(MethodRef... methodRefs) {
+    this.methodRefs = methodRefs;
+  }
+
+  public MethodRef[] getMethodRefs() {
+    return methodRefs;
+  }
+
+  abstract public Function<Object, Object> handle(MethodSignature methodSignature);
+
+  abstract public MethodHandle getMethodHandle(String methodName, MethodType type) throws NoSuchMethodException, IllegalAccessException;
+
+  @Nonnull
+  protected static Function<Object, Object> returnDefaultValue(final MethodSignature methodSignature) {
+    return new Function<Object, Object>() {
+      @Override
+      public Object call(Class<?> theClass, Object value, Object[] params) {
+        return ReflectionHelpers.defaultValueForType(methodSignature.returnType);
+      }
+    };
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/Interceptors.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/Interceptors.java
new file mode 100644
index 0000000..2377c6c
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/Interceptors.java
@@ -0,0 +1,46 @@
+package org.robolectric.internal.bytecode;
+
+import static java.util.Arrays.asList;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.util.Function;
+
+public class Interceptors {
+  private final Map<MethodRef, Interceptor> interceptors = new HashMap<>();
+
+  public Interceptors(Interceptor... interceptors) {
+    this(asList(interceptors));
+  }
+
+  public Interceptors(Collection<Interceptor> interceptorList) {
+    for (Interceptor interceptor : interceptorList) {
+      for (MethodRef methodRef : interceptor.getMethodRefs()) {
+        this.interceptors.put(methodRef, interceptor);
+      }
+    }
+  }
+
+  public Collection<MethodRef> getAllMethodRefs() {
+    return interceptors.keySet();
+  }
+
+  public Function<Object, Object> getInterceptionHandler(final MethodSignature methodSignature) {
+    Interceptor interceptor = findInterceptor(methodSignature.className, methodSignature.methodName);
+    if (interceptor != null) {
+      return interceptor.handle(methodSignature);
+    }
+
+    // nothing matched, return default
+    return Interceptor.returnDefaultValue(methodSignature);
+  }
+
+  public Interceptor findInterceptor(String className, String methodName) {
+    Interceptor mh = interceptors.get(new MethodRef(className, methodName));
+    if (mh == null) {
+      mh = interceptors.get(new MethodRef(className, "*"));
+    }
+    return mh;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/InvocationProfile.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/InvocationProfile.java
new file mode 100644
index 0000000..6766f86
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/InvocationProfile.java
@@ -0,0 +1,80 @@
+package org.robolectric.internal.bytecode;
+
+import java.util.Arrays;
+
+public class InvocationProfile {
+  public final Class clazz;
+  public final String methodName;
+  public final boolean isStatic;
+  public final String[] paramTypes;
+  private final boolean isDeclaredOnObject;
+
+  public InvocationProfile(String methodSignatureString, boolean isStatic, ClassLoader classLoader) {
+    MethodSignature methodSignature = MethodSignature.parse(methodSignatureString);
+    this.clazz = loadClass(classLoader, methodSignature.className);
+    this.methodName = methodSignature.methodName;
+    this.paramTypes = methodSignature.paramTypes;
+    this.isStatic = isStatic;
+
+    this.isDeclaredOnObject = methodSignatureString.endsWith("/equals(Ljava/lang/Object;)Z")
+        || methodSignatureString.endsWith("/hashCode()I")
+        || methodSignatureString.endsWith("/toString()Ljava/lang/String;");
+  }
+
+  public Class<?>[] getParamClasses(ClassLoader classLoader) throws ClassNotFoundException {
+    Class[] classes = new Class[paramTypes.length];
+    for (int i = 0; i < paramTypes.length; i++) {
+      String paramType = paramTypes[i];
+      classes[i] = ShadowWrangler.loadClass(paramType, classLoader);
+    }
+    return classes;
+  }
+
+  private Class<?> loadClass(ClassLoader classLoader, String className) {
+    try {
+      return classLoader.loadClass(className);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public boolean isDeclaredOnObject() {
+    return isDeclaredOnObject;
+  }
+
+  boolean isAndroidSupport() {
+    return clazz.getName().startsWith("android.support") || clazz.getName().startsWith("androidx.");
+  }
+
+  boolean strict() {
+    return isAndroidSupport() || isDeclaredOnObject();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof InvocationProfile)) {
+      return false;
+    }
+
+    InvocationProfile that = (InvocationProfile) o;
+
+    if (isDeclaredOnObject != that.isDeclaredOnObject) return false;
+    if (isStatic != that.isStatic) return false;
+    if (clazz != null ? !clazz.equals(that.clazz) : that.clazz != null) return false;
+    if (methodName != null ? !methodName.equals(that.methodName) : that.methodName != null) return false;
+    if (!Arrays.equals(paramTypes, that.paramTypes)) return false;
+
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = clazz != null ? clazz.hashCode() : 0;
+    result = 31 * result + (methodName != null ? methodName.hashCode() : 0);
+    result = 31 * result + (isStatic ? 1 : 0);
+    result = 31 * result + (paramTypes != null ? Arrays.hashCode(paramTypes) : 0);
+    result = 31 * result + (isDeclaredOnObject ? 1 : 0);
+    return result;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/InvokeDynamicClassInstrumentor.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/InvokeDynamicClassInstrumentor.java
new file mode 100644
index 0000000..1eb5e4a
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/InvokeDynamicClassInstrumentor.java
@@ -0,0 +1,12 @@
+package org.robolectric.internal.bytecode;
+
+/**
+ * @deprecated The invoke-dynamic case has been moved to ClassInstrumentor. Classes previously
+ *     extending this class should extend {@link ClassInstrumentor} directly.
+ */
+@Deprecated
+public class InvokeDynamicClassInstrumentor extends ClassInstrumentor {
+  public InvokeDynamicClassInstrumentor(Decorator decorator) {
+    super(decorator);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/InvokeDynamicSupport.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/InvokeDynamicSupport.java
new file mode 100644
index 0000000..d7b8b79
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/InvokeDynamicSupport.java
@@ -0,0 +1,217 @@
+package org.robolectric.internal.bytecode;
+
+import static java.lang.invoke.MethodHandles.catchException;
+import static java.lang.invoke.MethodHandles.constant;
+import static java.lang.invoke.MethodHandles.dropArguments;
+import static java.lang.invoke.MethodHandles.exactInvoker;
+import static java.lang.invoke.MethodHandles.filterArguments;
+import static java.lang.invoke.MethodHandles.foldArguments;
+import static java.lang.invoke.MethodHandles.throwException;
+import static java.lang.invoke.MethodType.methodType;
+import static org.robolectric.internal.bytecode.MethodCallSite.Kind.REGULAR;
+import static org.robolectric.internal.bytecode.MethodCallSite.Kind.STATIC;
+
+import java.lang.invoke.CallSite;
+import java.lang.invoke.ConstantCallSite;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.SwitchPoint;
+import java.lang.invoke.WrongMethodTypeException;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings("RethrowReflectiveOperationExceptionAsLinkageError")
+public class InvokeDynamicSupport {
+  @SuppressWarnings("unused")
+  private static Interceptors INTERCEPTORS;
+
+  private static final MethodHandle BIND_CALL_SITE;
+  private static final MethodHandle BIND_INIT_CALL_SITE;
+  private static final MethodHandle EXCEPTION_HANDLER;
+  private static final MethodHandle GET_SHADOW;
+
+  static {
+    try {
+      MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+      BIND_CALL_SITE =
+          lookup.findStatic(
+              InvokeDynamicSupport.class,
+              "bindCallSite",
+              methodType(MethodHandle.class, MethodCallSite.class));
+      BIND_INIT_CALL_SITE =
+          lookup.findStatic(
+              InvokeDynamicSupport.class,
+              "bindInitCallSite",
+              methodType(MethodHandle.class, RoboCallSite.class));
+      MethodHandle cleanStackTrace =
+          lookup.findStatic(
+              RobolectricInternals.class,
+              "cleanStackTrace",
+              methodType(Throwable.class, Throwable.class));
+      EXCEPTION_HANDLER =
+          filterArguments(throwException(void.class, Throwable.class), 0, cleanStackTrace);
+      GET_SHADOW =
+          lookup.findVirtual(ShadowedObject.class, "$$robo$getData", methodType(Object.class));
+    } catch (NoSuchMethodException | IllegalAccessException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static CallSite bootstrapInit(MethodHandles.Lookup caller, String name, MethodType type) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "invokedynamic bootstrap init",
+            () -> {
+              RoboCallSite site = new RoboCallSite(type, caller.lookupClass());
+
+              bindInitCallSite(site);
+
+              return site;
+            });
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static CallSite bootstrap(
+      MethodHandles.Lookup caller, String name, MethodType type, MethodHandle original)
+      throws IllegalAccessException {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "invokedynamic bootstrap",
+            () -> {
+              MethodCallSite site =
+                  new MethodCallSite(caller.lookupClass(), type, name, original, REGULAR);
+
+              bindCallSite(site);
+
+              return site;
+            });
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static CallSite bootstrapStatic(
+      MethodHandles.Lookup caller, String name, MethodType type, MethodHandle original)
+      throws IllegalAccessException {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "invokedynamic bootstrap static",
+            () -> {
+              MethodCallSite site =
+                  new MethodCallSite(caller.lookupClass(), type, name, original, STATIC);
+
+              bindCallSite(site);
+
+              return site;
+            });
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static CallSite bootstrapIntrinsic(
+      MethodHandles.Lookup caller, String name, MethodType type, String callee)
+      throws IllegalAccessException {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "invokedynamic bootstrap intrinsic",
+            () -> {
+              MethodHandle mh = getMethodHandle(callee, name, type);
+              if (mh == null) {
+                throw new IllegalArgumentException(
+                    "Could not find intrinsic for " + callee + ":" + name);
+              }
+              return new ConstantCallSite(mh.asType(type));
+            });
+  }
+
+  private static final MethodHandle NOTHING =
+      constant(Void.class, null).asType(methodType(void.class));
+
+  private static MethodHandle getMethodHandle(
+      String className, String methodName, MethodType type) {
+    Interceptor interceptor = INTERCEPTORS.findInterceptor(className, methodName);
+    if (interceptor != null) {
+      try {
+        // reload interceptor in sandbox...
+        Class<Interceptor> theClass =
+            (Class<Interceptor>)
+                ReflectionHelpers.loadClass(
+                        RobolectricInternals.getClassLoader(), interceptor.getClass().getName())
+                    .asSubclass(Interceptor.class);
+        return ReflectionHelpers.newInstance(theClass).getMethodHandle(methodName, type);
+      } catch (NoSuchMethodException | IllegalAccessException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    if (type.parameterCount() != 0) {
+      return dropArguments(NOTHING, 0, type.parameterArray());
+    } else {
+      return NOTHING;
+    }
+  }
+
+  private static MethodHandle bindInitCallSite(RoboCallSite site) {
+    MethodHandle mh = RobolectricInternals.getShadowCreator(site.getTheClass());
+    return bindWithFallback(site, mh, BIND_INIT_CALL_SITE);
+  }
+
+  private static MethodHandle bindCallSite(MethodCallSite site) throws IllegalAccessException {
+    MethodHandle mh =
+        RobolectricInternals.findShadowMethodHandle(
+            site.getTheClass(), site.getName(), site.type(), site.isStatic());
+
+    if (mh == null) {
+      // call original code
+      mh = site.getOriginal();
+    } else if (mh == ShadowWrangler.DO_NOTHING) {
+      // no-op
+      mh = dropArguments(mh, 0, site.type().parameterList());
+    } else if (!site.isStatic()) {
+      // drop arg 0 (this) for static methods
+      Class<?> shadowType = mh.type().parameterType(0);
+      mh = filterArguments(mh, 0, GET_SHADOW.asType(methodType(shadowType, site.thisType())));
+    }
+
+    try {
+      return bindWithFallback(site, cleanStackTraces(mh), BIND_CALL_SITE);
+    } catch (Throwable t) {
+      // The error that bubbles up is currently not very helpful so we print any error messages
+      // here
+      t.printStackTrace();
+      System.err.println(site.getTheClass());
+      throw t;
+    }
+  }
+
+  private static MethodHandle bindWithFallback(
+      RoboCallSite site, MethodHandle mh, MethodHandle fallback) {
+    SwitchPoint switchPoint = getInvalidator(site.getTheClass());
+    MethodType type = site.type();
+
+    MethodHandle boundFallback = foldArguments(exactInvoker(type), fallback.bindTo(site));
+    try {
+      mh = switchPoint.guardWithTest(mh.asType(type), boundFallback);
+    } catch (WrongMethodTypeException e) {
+      if (site instanceof MethodCallSite) {
+        MethodCallSite methodCallSite = (MethodCallSite) site;
+        throw new RuntimeException(
+            "failed to bind " + methodCallSite.thisType() + "." + methodCallSite.getName(), e);
+      } else {
+        throw e;
+      }
+    }
+
+    site.setTarget(mh);
+    return mh;
+  }
+
+  private static SwitchPoint getInvalidator(Class<?> cl) {
+    return RobolectricInternals.getShadowInvalidator().getSwitchPoint(cl);
+  }
+
+  private static MethodHandle cleanStackTraces(MethodHandle mh) {
+    MethodType type = EXCEPTION_HANDLER.type().changeReturnType(mh.type().returnType());
+    return catchException(mh, Throwable.class, EXCEPTION_HANDLER.asType(type));
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodCallSite.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodCallSite.java
new file mode 100644
index 0000000..bcb2ae0
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodCallSite.java
@@ -0,0 +1,49 @@
+package org.robolectric.internal.bytecode;
+
+import static org.robolectric.internal.bytecode.MethodCallSite.Kind.STATIC;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodType;
+
+public class MethodCallSite extends RoboCallSite {
+  private final String name;
+  private final MethodHandle original;
+  private final Kind kind;
+
+  public MethodCallSite(Class<?> theClass, MethodType type, String name, MethodHandle original,
+      Kind kind) {
+    super(type, theClass);
+    this.name = name;
+    this.original = original;
+    this.kind = kind;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public MethodHandle getOriginal() {
+    return original;
+  }
+
+  public Class<?> thisType() {
+    return isStatic() ? null : type().parameterType(0);
+  }
+
+  public boolean isStatic() {
+    return kind == STATIC;
+  }
+
+  @Override public String toString() {
+    return "RoboCallSite{" +
+        "theClass=" + getTheClass() +
+        ", original=" + original +
+        ", kind=" + kind +
+        '}';
+  }
+
+  public enum Kind {
+    REGULAR,
+    STATIC
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodRef.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodRef.java
new file mode 100644
index 0000000..e2796ec
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodRef.java
@@ -0,0 +1,42 @@
+package org.robolectric.internal.bytecode;
+
+/**
+ * Reference to a specific method on a class.
+ */
+public class MethodRef {
+  public final String className;
+  public final String methodName;
+
+  public MethodRef(Class<?> clazz, String methodName) {
+    this(clazz.getName(), methodName);
+  }
+
+  public MethodRef(String className, String methodName) {
+    this.className = className;
+    this.methodName = methodName;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof MethodRef)) return false;
+
+    MethodRef methodRef = (MethodRef) o;
+
+    return className.equals(methodRef.className) && methodName.equals(methodRef.methodName);
+  }
+
+  @Override public int hashCode() {
+    int result = className.hashCode();
+    result = 31 * result + methodName.hashCode();
+    return result;
+  }
+
+  @Override
+  public String toString() {
+    return "MethodRef{" +
+        "className='" + className + '\'' +
+        ", methodName='" + methodName + '\'' +
+        '}';
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodSignature.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodSignature.java
new file mode 100644
index 0000000..afc4e2c
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/MethodSignature.java
@@ -0,0 +1,42 @@
+package org.robolectric.internal.bytecode;
+
+import org.objectweb.asm.Type;
+import org.robolectric.util.Join;
+
+public class MethodSignature {
+  public final String className;
+  public final String methodName;
+  public final String[] paramTypes;
+  public final String returnType;
+
+  private MethodSignature(String className, String methodName, String[] paramTypes, String returnType) {
+    this.className = className;
+    this.methodName = methodName;
+    this.paramTypes = paramTypes;
+    this.returnType = returnType;
+  }
+
+  public static MethodSignature parse(String internalString) {
+    int parenStart = internalString.indexOf('(');
+    int methodStart = internalString.lastIndexOf('/', parenStart);
+    String className = internalString.substring(0, methodStart).replace('/', '.');
+    String methodName = internalString.substring(methodStart + 1, parenStart);
+    String methodDescriptor = internalString.substring(parenStart);
+    Type[] argumentTypes = Type.getArgumentTypes(methodDescriptor);
+    String[] paramTypes = new String[argumentTypes.length];
+    for (int i = 0; i < argumentTypes.length; i++) {
+      paramTypes[i] = argumentTypes[i].getClassName();
+    }
+    final String returnType = Type.getReturnType(methodDescriptor).getClassName();
+    return new MethodSignature(className, methodName, paramTypes, returnType);
+  }
+
+  @Override
+  public String toString() {
+    return className + "." + methodName + "(" + Join.join(", ", (Object[]) paramTypes) + ")";
+  }
+
+  boolean matches(String className, String methodName) {
+    return this.className.equals(className) && this.methodName.equals(methodName);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/MutableClass.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/MutableClass.java
new file mode 100644
index 0000000..305431f
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/MutableClass.java
@@ -0,0 +1,78 @@
+package org.robolectric.internal.bytecode;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import java.util.ArrayList;
+import java.util.List;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.MethodNode;
+
+public class MutableClass {
+  public final ClassNode classNode;
+  final InstrumentationConfiguration config;
+  final ClassNodeProvider classNodeProvider;
+
+  final String internalClassName;
+  private final String className;
+  final Type classType;
+  final ImmutableSet<String> foundMethods;
+
+  public MutableClass(
+      ClassNode classNode,
+      InstrumentationConfiguration config,
+      ClassNodeProvider classNodeProvider) {
+    this.classNode = classNode;
+    this.config = config;
+    this.classNodeProvider = classNodeProvider;
+    this.internalClassName = classNode.name;
+    this.className = classNode.name.replace('/', '.');
+    this.classType = Type.getObjectType(internalClassName);
+
+    List<String> foundMethods = new ArrayList<>(classNode.methods.size());
+    for (MethodNode methodNode : getMethods()) {
+      foundMethods.add(methodNode.name + methodNode.desc);
+    }
+    this.foundMethods = ImmutableSet.copyOf(foundMethods);
+  }
+
+  public boolean isInterface() {
+    return (classNode.access & Opcodes.ACC_INTERFACE) != 0;
+  }
+
+  public boolean isAnnotation() {
+    return (classNode.access & Opcodes.ACC_ANNOTATION) != 0;
+  }
+
+  public String getName() {
+    return className;
+  }
+
+  public Iterable<? extends MethodNode> getMethods() {
+    return new ArrayList<>(classNode.methods);
+  }
+
+  public void addMethod(MethodNode methodNode) {
+    classNode.methods.add(methodNode);
+  }
+
+  public void removeMethod(String name, String desc) {
+    Iterables.removeIf(
+        classNode.methods,
+        methodNode -> name.equals(methodNode.name) && desc.equals(methodNode.desc));
+  }
+
+  public List<FieldNode> getFields() {
+    return classNode.fields;
+  }
+
+  public void addField(int index, FieldNode fieldNode) {
+    classNode.fields.add(index, fieldNode);
+  }
+
+  public void addInterface(String internalName) {
+    classNode.interfaces.add(internalName);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ProxyMaker.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ProxyMaker.java
new file mode 100644
index 0000000..9741ee1
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ProxyMaker.java
@@ -0,0 +1,219 @@
+package org.robolectric.internal.bytecode;
+
+import static org.objectweb.asm.Opcodes.ACC_FINAL;
+import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
+import static org.objectweb.asm.Opcodes.ACC_SUPER;
+import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
+import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL;
+import static org.objectweb.asm.Opcodes.V1_7;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.commons.GeneratorAdapter;
+import org.objectweb.asm.commons.Method;
+import org.robolectric.util.PerfStatsCollector;
+import sun.misc.Unsafe;
+
+/**
+ * Defines proxy classes that can invoke methods names transformed with a {@link MethodMapper}. It
+ * is primarily used to invoke the original $$robo$$-prefixed methods, but it can technically
+ * support arbitrary naming schemes.
+ *
+ * @deprecated This is incompatible with JDK17+. Use a {@link
+ *     org.robolectric.util.reflector.Reflector} interface with {@link
+ *     org.robolectric.util.reflector.Direct}.
+ */
+@Deprecated
+public class ProxyMaker {
+  private static final String TARGET_FIELD = "__proxy__";
+  private static final Class UNSAFE_CLASS = Unsafe.class;
+  private static final Class LOOKUP_CLASS = MethodHandles.Lookup.class;
+  private static final Unsafe UNSAFE;
+  private static final java.lang.reflect.Method DEFINE_ANONYMOUS_CLASS;
+
+  private static final MethodHandles.Lookup LOOKUP;
+  private static final java.lang.reflect.Method HIDDEN_DEFINE_METHOD;
+  private static final Object HIDDEN_CLASS_OPTIONS;
+
+  private static final boolean DEBUG = false;
+
+  static {
+    try {
+      Field unsafeField = UNSAFE_CLASS.getDeclaredField("theUnsafe");
+      unsafeField.setAccessible(true);
+      UNSAFE = (Unsafe) unsafeField.get(null);
+
+      // Unsafe.defineAnonymousClass() has been deprecated in Java 15 and removed in Java 17. Its
+      // usage should be replace by MethodHandles.Lookup.defineHiddenClass() which was introduced in
+      // Java 15. For now, try and support both older and newer Java versions.
+      DEFINE_ANONYMOUS_CLASS = getDefineAnonymousClass();
+      if (DEFINE_ANONYMOUS_CLASS == null) {
+        LOOKUP = getTrustedLookup();
+
+        Class classOptionClass = Class.forName(LOOKUP_CLASS.getName() + "$ClassOption");
+        HIDDEN_CLASS_OPTIONS = Array.newInstance(classOptionClass, 1);
+        Array.set(HIDDEN_CLASS_OPTIONS, 0, Enum.valueOf(classOptionClass, "NESTMATE"));
+        HIDDEN_DEFINE_METHOD =
+            LOOKUP_CLASS.getMethod(
+                "defineHiddenClass", byte[].class, boolean.class, HIDDEN_CLASS_OPTIONS.getClass());
+      } else {
+        LOOKUP = null;
+        HIDDEN_DEFINE_METHOD = null;
+        HIDDEN_CLASS_OPTIONS = null;
+      }
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError(e.getMessage(), e);
+    }
+  }
+
+  private static java.lang.reflect.Method getDefineAnonymousClass() {
+    try {
+      return UNSAFE_CLASS.getMethod(
+          "defineAnonymousClass", Class.class, byte[].class, Object[].class);
+    } catch (NoSuchMethodException e) {
+      return null;
+    }
+  }
+
+  private static MethodHandles.Lookup getTrustedLookup() throws ReflectiveOperationException {
+    Field trustedLookupField = LOOKUP_CLASS.getDeclaredField("IMPL_LOOKUP");
+    java.lang.reflect.Method baseMethod = UNSAFE_CLASS.getMethod("staticFieldBase", Field.class);
+    Object lookupBase = baseMethod.invoke(UNSAFE, trustedLookupField);
+    java.lang.reflect.Method offsetMethod =
+        UNSAFE_CLASS.getMethod("staticFieldOffset", Field.class);
+    Object lookupOffset = offsetMethod.invoke(UNSAFE, trustedLookupField);
+
+    java.lang.reflect.Method getObjectMethod =
+        UNSAFE_CLASS.getMethod("getObject", Object.class, long.class);
+    return (MethodHandles.Lookup) getObjectMethod.invoke(UNSAFE, lookupBase, lookupOffset);
+  }
+
+  private final MethodMapper methodMapper;
+  private final ClassValueMap<Factory> factories;
+
+  public ProxyMaker(MethodMapper methodMapper) {
+    this.methodMapper = methodMapper;
+    factories =
+        new ClassValueMap<Factory>() {
+          @Override
+          protected Factory computeValue(Class<?> type) {
+            return PerfStatsCollector.getInstance()
+                .measure("createProxyFactory", () -> createProxyFactory(type));
+          }
+        };
+  }
+
+  public <T> T createProxy(Class<T> targetClass, T target) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "createProxyInstance",
+            () -> factories.get(targetClass).createProxy(targetClass, target));
+  }
+
+  <T> Factory createProxyFactory(Class<T> targetClass) {
+    Type targetType = Type.getType(targetClass);
+    String targetName = targetType.getInternalName();
+    String proxyName = targetName + "$GeneratedProxy";
+    Type proxyType = Type.getType("L" + proxyName.replace('.', '/') + ";");
+    ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES| ClassWriter.COMPUTE_MAXS);
+    writer.visit(
+        V1_7,
+        ACC_PUBLIC | ACC_SYNTHETIC | ACC_SUPER | ACC_FINAL,
+        proxyName,
+        null,
+        targetName,
+        null);
+
+    writer.visitField(
+        ACC_PUBLIC | ACC_SYNTHETIC, TARGET_FIELD, targetType.getDescriptor(), null, null);
+
+    for (java.lang.reflect.Method method : targetClass.getMethods()) {
+      if (!shouldProxy(method)) continue;
+
+      Method proxyMethod = Method.getMethod(method);
+      GeneratorAdapter m =
+          new GeneratorAdapter(
+              ACC_PUBLIC | ACC_SYNTHETIC, Method.getMethod(method), null, null, writer);
+      m.loadThis();
+      m.getField(proxyType, TARGET_FIELD, targetType);
+      m.loadArgs();
+      String targetMethod = methodMapper.getName(targetClass.getName(), method.getName());
+      // In Java 8 we could use invokespecial here but not in 7, from jvm spec:
+      // If an invokespecial instruction names a method which is not an instance
+      // initialization method, then the type of the target reference on the operand
+      // stack must be assignment compatible with the current class (JLS §5.2).
+      m.visitMethodInsn(INVOKEVIRTUAL, targetName, targetMethod, proxyMethod.getDescriptor(), false);
+      m.returnValue();
+      m.endMethod();
+    }
+
+    writer.visitEnd();
+
+    byte[] bytecode = writer.toByteArray();
+
+    if (DEBUG) {
+      File file = new File("/tmp", targetClass.getCanonicalName() + "-DirectProxy.class");
+      System.out.println("Generated Direct Proxy: " + file.getAbsolutePath());
+      try (OutputStream out = new FileOutputStream(file)) {
+        out.write(bytecode);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    try {
+      final Class<?> proxyClass = defineHiddenClass(targetClass, bytecode);
+      final Field field = proxyClass.getDeclaredField(TARGET_FIELD);
+      return new Factory() {
+        @Override public <E> E createProxy(Class<E> targetClass, E target) {
+          try {
+            Object proxy = UNSAFE.allocateInstance(proxyClass);
+
+            field.set(proxy, target);
+
+            return targetClass.cast(proxy);
+          } catch (Throwable t) {
+            throw new AssertionError(t);
+          }
+        }
+      };
+    } catch (ReflectiveOperationException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private static Class<?> defineHiddenClass(Class<?> targetClass, byte[] bytes)
+      throws ReflectiveOperationException {
+    if (DEFINE_ANONYMOUS_CLASS != null) {
+      return (Class<?>) DEFINE_ANONYMOUS_CLASS.invoke(UNSAFE, targetClass, bytes, (Object[]) null);
+    } else {
+      MethodHandles.Lookup lookup = (MethodHandles.Lookup) LOOKUP.in(targetClass);
+      MethodHandles.Lookup definedLookup =
+          (MethodHandles.Lookup)
+              HIDDEN_DEFINE_METHOD.invoke(lookup, bytes, false, HIDDEN_CLASS_OPTIONS);
+      return definedLookup.lookupClass();
+    }
+  }
+
+  private static boolean shouldProxy(java.lang.reflect.Method method) {
+    int modifiers = method.getModifiers();
+    return !Modifier.isAbstract(modifiers) && !Modifier.isFinal(modifiers) && !Modifier.isPrivate(
+        modifiers) && !Modifier.isNative(modifiers);
+  }
+
+  interface MethodMapper {
+    String getName(String className, String methodName);
+  }
+
+  interface Factory {
+    <T> T createProxy(Class<T> targetClass, T target);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ResourceProvider.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ResourceProvider.java
new file mode 100644
index 0000000..e181991
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ResourceProvider.java
@@ -0,0 +1,12 @@
+package org.robolectric.internal.bytecode;
+
+import java.io.InputStream;
+import java.net.URL;
+
+/** A provider of resources (à la ClassLoader). */
+public interface ResourceProvider {
+
+  URL getResource(String resName);
+
+  InputStream getResourceAsStream(String resName);
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/RoboCallSite.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/RoboCallSite.java
new file mode 100644
index 0000000..87d49dd
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/RoboCallSite.java
@@ -0,0 +1,17 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.invoke.MethodType;
+import java.lang.invoke.MutableCallSite;
+
+public class RoboCallSite extends MutableCallSite {
+  private final Class<?> theClass;
+
+  public RoboCallSite(MethodType type, Class<?> theClass) {
+    super(type);
+    this.theClass = theClass;
+  }
+
+  public Class<?> getTheClass() {
+    return theClass;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/RoboType.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/RoboType.java
new file mode 100644
index 0000000..d966b25
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/RoboType.java
@@ -0,0 +1,29 @@
+package org.robolectric.internal.bytecode;
+
+enum RoboType {
+  VOID(Void.TYPE),
+  BOOLEAN(Boolean.TYPE),
+  BYTE(Byte.TYPE),
+  CHAR(Character.TYPE),
+  SHORT(Short.TYPE),
+  INT(Integer.TYPE),
+  LONG(Long.TYPE),
+  FLOAT(Float.TYPE),
+  DOUBLE(Double.TYPE),
+  OBJECT(null);
+
+  RoboType(Class type) {
+    this.type = type;
+  }
+
+  private final Class type;
+
+  public static Class findPrimitiveClass(String name) {
+    for (RoboType type : RoboType.values()) {
+      if (type.type != null && type.type.getName().equals(name)) {
+        return type.type;
+      }
+    }
+    return null;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/RobolectricGeneratorAdapter.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/RobolectricGeneratorAdapter.java
new file mode 100644
index 0000000..e6c63b2
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/RobolectricGeneratorAdapter.java
@@ -0,0 +1,86 @@
+package org.robolectric.internal.bytecode;
+
+import static org.objectweb.asm.Type.ARRAY;
+import static org.objectweb.asm.Type.OBJECT;
+
+import java.lang.reflect.Modifier;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.commons.GeneratorAdapter;
+import org.objectweb.asm.tree.MethodNode;
+import org.robolectric.internal.bytecode.ClassInstrumentor.TryCatch;
+
+/**
+ * GeneratorAdapter implementation specific to generate code for Robolectric purposes
+ */
+public class RobolectricGeneratorAdapter extends GeneratorAdapter {
+  final boolean isStatic;
+  private final String desc;
+
+  public RobolectricGeneratorAdapter(MethodNode methodNode) {
+    super(Opcodes.ASM4, methodNode, methodNode.access, methodNode.name, methodNode.desc);
+    this.isStatic = Modifier.isStatic(methodNode.access);
+    this.desc = methodNode.desc;
+  }
+
+  public void loadThisOrNull() {
+    if (isStatic) {
+      loadNull();
+    } else {
+      loadThis();
+    }
+  }
+
+  public boolean isStatic() {
+    return isStatic;
+  }
+
+  public void loadNull() {
+    visitInsn(Opcodes.ACONST_NULL);
+  }
+
+  @Override
+  public Type getReturnType() {
+    return Type.getReturnType(desc);
+  }
+
+  /**
+   * Forces a return of a default value, depending on the method's return type
+   *
+   * @param type The method's return type
+   */
+  public void pushDefaultReturnValueToStack(Type type) {
+    if (type.equals(Type.BOOLEAN_TYPE)) {
+      push(false);
+    } else if (type.equals(Type.INT_TYPE) || type.equals(Type.SHORT_TYPE) || type.equals(Type.BYTE_TYPE) || type.equals(Type.CHAR_TYPE)) {
+      push(0);
+    } else if (type.equals(Type.LONG_TYPE)) {
+      push(0L);
+    } else if (type.equals(Type.FLOAT_TYPE)) {
+      push(0f);
+    } else if (type.equals(Type.DOUBLE_TYPE)) {
+      push(0d);
+    } else if (type.getSort() == ARRAY || type.getSort() == OBJECT) {
+      loadNull();
+    }
+  }
+
+  public void invokeMethod(String internalClassName, MethodNode method) {
+    invokeMethod(internalClassName, method.name, method.desc);
+  }
+
+  void invokeMethod(String internalClassName, String methodName, String methodDesc) {
+    if (isStatic()) {
+      loadArgs();                                             // this, [args]
+      visitMethodInsn(Opcodes.INVOKESTATIC, internalClassName, methodName, methodDesc, false);
+    } else {
+      loadThisOrNull();                                       // this
+      loadArgs();                                             // this, [args]
+      visitMethodInsn(Opcodes.INVOKESPECIAL, internalClassName, methodName, methodDesc, false);
+    }
+  }
+
+  public TryCatch tryStart(Type exceptionType) {
+    return new TryCatch(this, exceptionType);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/RobolectricInternals.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/RobolectricInternals.java
new file mode 100644
index 0000000..673f5c1
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/RobolectricInternals.java
@@ -0,0 +1,71 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public class RobolectricInternals {
+
+  @SuppressWarnings("UnusedDeclaration")
+  private static ClassHandler classHandler; // initialized via magic by AndroidSandbox
+
+  @SuppressWarnings("UnusedDeclaration")
+  private static ShadowInvalidator shadowInvalidator;
+
+  @SuppressWarnings("UnusedDeclaration")
+  private static ClassLoader classLoader;
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static void classInitializing(Class clazz) throws Exception {
+    classHandler.classInitializing(clazz);
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static Object initializing(Object instance) throws Exception {
+    return classHandler.initializing(instance);
+  }
+
+  public static MethodHandle getShadowCreator(Class<?> caller) {
+    return classHandler.getShadowCreator(caller);
+  }
+
+  public static MethodHandle findShadowMethodHandle(
+      Class<?> theClass, String name, MethodType methodType, boolean isStatic)
+      throws IllegalAccessException {
+    return classHandler.findShadowMethodHandle(theClass, name, methodType, isStatic);
+  }
+
+  @SuppressWarnings("UnusedDeclaration")
+  public static Throwable cleanStackTrace(Throwable exception) {
+    return classHandler.stripStackTrace(exception);
+  }
+
+  public static Object intercept(String signature, Object instance, Object[] params, Class theClass)
+      throws Throwable {
+    try {
+      return classHandler.intercept(signature, instance, params, theClass);
+    } catch (java.lang.LinkageError e) {
+      throw new Exception(e);
+    }
+  }
+
+  public static void performStaticInitialization(Class<?> clazz)
+      throws InvocationTargetException, IllegalAccessException {
+    try {
+      Method clinitMethod = clazz.getDeclaredMethod(ShadowConstants.STATIC_INITIALIZER_METHOD_NAME);
+      clinitMethod.setAccessible(true);
+      clinitMethod.invoke(null);
+    } catch (NoSuchMethodException e) {
+      throw new IllegalArgumentException(clazz + " not instrumented?", e);
+    }
+  }
+
+  public static ShadowInvalidator getShadowInvalidator() {
+    return shadowInvalidator;
+  }
+
+  public static ClassLoader getClassLoader() {
+    return classLoader;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/Sandbox.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/Sandbox.java
new file mode 100644
index 0000000..a237ff4
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/Sandbox.java
@@ -0,0 +1,115 @@
+package org.robolectric.internal.bytecode;
+
+import static org.robolectric.util.ReflectionHelpers.newInstance;
+import static org.robolectric.util.ReflectionHelpers.setStaticField;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import javax.inject.Inject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.Util;
+
+public class Sandbox {
+  private final SandboxClassLoader sandboxClassLoader;
+  private final ExecutorService executorService;
+  private ShadowInvalidator shadowInvalidator;
+  public ClassHandler classHandler; // todo not public
+  private ShadowMap shadowMap = ShadowMap.EMPTY;
+
+  public Sandbox(
+      InstrumentationConfiguration config,
+      ResourceProvider resourceProvider,
+      ClassInstrumentor classInstrumentor) {
+    this(new SandboxClassLoader(config, resourceProvider, classInstrumentor));
+  }
+
+  @Inject
+  public Sandbox(SandboxClassLoader sandboxClassLoader) {
+    this.sandboxClassLoader = sandboxClassLoader;
+    executorService = Executors.newSingleThreadExecutor(mainThreadFactory());
+  }
+
+  protected ThreadFactory mainThreadFactory() {
+    return Thread::new;
+  }
+
+  public <T> Class<T> bootstrappedClass(Class<?> clazz) {
+    try {
+      return (Class<T>) sandboxClassLoader.loadClass(clazz.getName());
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public ClassLoader getRobolectricClassLoader() {
+    return sandboxClassLoader;
+  }
+
+  private ShadowInvalidator getShadowInvalidator() {
+    if (shadowInvalidator == null) {
+      this.shadowInvalidator = new ShadowInvalidator();
+    }
+    return shadowInvalidator;
+  }
+
+  public void replaceShadowMap(ShadowMap shadowMap) {
+    ShadowMap oldShadowMap = this.shadowMap;
+    this.shadowMap = shadowMap;
+    Set<String> invalidatedClasses = new HashSet<>();
+    invalidatedClasses.addAll(shadowMap.getInvalidatedClasses(oldShadowMap));
+    invalidatedClasses.addAll(getModeInvalidatedClasses());
+    getShadowInvalidator().invalidateClasses(invalidatedClasses);
+    clearModeInvalidatedClasses();
+  }
+
+  protected Set<String> getModeInvalidatedClasses() {
+    return Collections.emptySet();
+  }
+
+  protected void clearModeInvalidatedClasses() {}
+
+  public void configure(ClassHandler classHandler, Interceptors interceptors) {
+    this.classHandler = classHandler;
+
+    ClassLoader robolectricClassLoader = getRobolectricClassLoader();
+    Class<?> robolectricInternalsClass = bootstrappedClass(RobolectricInternals.class);
+    ShadowInvalidator invalidator = getShadowInvalidator();
+    setStaticField(robolectricInternalsClass, "shadowInvalidator", invalidator);
+
+    setStaticField(robolectricInternalsClass, "classHandler", classHandler);
+    setStaticField(robolectricInternalsClass, "classLoader", robolectricClassLoader);
+
+    Class<?> invokeDynamicSupportClass = bootstrappedClass(InvokeDynamicSupport.class);
+    setStaticField(invokeDynamicSupportClass, "INTERCEPTORS", interceptors);
+
+    Class<?> shadowClass = bootstrappedClass(Shadow.class);
+    setStaticField(shadowClass, "SHADOW_IMPL", newInstance(bootstrappedClass(ShadowImpl.class)));
+  }
+
+  public void runOnMainThread(Runnable runnable) {
+    runOnMainThread(
+        () -> {
+          runnable.run();
+          return null;
+        });
+  }
+
+  public <T> T runOnMainThread(Callable<T> callable) {
+    Future<T> future = executorService.submit(callable);
+    try {
+      return future.get();
+    } catch (InterruptedException e) {
+      future.cancel(true);
+      throw new RuntimeException(e);
+    } catch (ExecutionException e) {
+      throw Util.sneakyThrow(e.getCause());
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/SandboxClassLoader.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/SandboxClassLoader.java
new file mode 100644
index 0000000..c71a735
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/SandboxClassLoader.java
@@ -0,0 +1,208 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.base.StandardSystemProperty.JAVA_CLASS_PATH;
+import static com.google.common.base.StandardSystemProperty.PATH_SEPARATOR;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.inject.Inject;
+import org.robolectric.util.Logger;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.Util;
+
+/**
+ * Class loader that modifies the bytecode of Android classes to insert calls to Robolectric's
+ * shadow classes.
+ */
+public class SandboxClassLoader extends URLClassLoader {
+  // The directory where instrumented class files will be dumped
+  private static final String DUMP_CLASSES_PROPERTY = "robolectric.dumpClassesDirectory";
+  private static final AtomicInteger DUMP_CLASSES_COUNTER = new AtomicInteger();
+
+  private final InstrumentationConfiguration config;
+  private final ResourceProvider resourceProvider;
+  private final ClassInstrumentor classInstrumentor;
+  private final ClassNodeProvider classNodeProvider;
+  private final String dumpClassesDirectory;
+
+  /** Constructor for use by tests. */
+  SandboxClassLoader(InstrumentationConfiguration config) {
+    this(config, new UrlResourceProvider(), new ClassInstrumentor(new ShadowDecorator()));
+  }
+
+  @Inject
+  public SandboxClassLoader(
+      InstrumentationConfiguration config,
+      ResourceProvider resourceProvider,
+      ClassInstrumentor classInstrumentor) {
+    this(
+        Thread.currentThread().getContextClassLoader(),
+        config,
+        resourceProvider,
+        classInstrumentor);
+  }
+
+  public SandboxClassLoader(
+      ClassLoader erstwhileClassLoader,
+      InstrumentationConfiguration config,
+      ResourceProvider resourceProvider,
+      ClassInstrumentor classInstrumentor) {
+    super(getClassPathUrls(erstwhileClassLoader), erstwhileClassLoader);
+
+    this.config = config;
+    this.resourceProvider = resourceProvider;
+
+    this.classInstrumentor = classInstrumentor;
+
+    classNodeProvider =
+        new ClassNodeProvider() {
+          @Override
+          protected byte[] getClassBytes(String internalClassName) throws ClassNotFoundException {
+            return getByteCode(internalClassName);
+          }
+        };
+    this.dumpClassesDirectory = System.getProperty(DUMP_CLASSES_PROPERTY, "");
+  }
+
+  private static URL[] getClassPathUrls(ClassLoader classloader) {
+    if (classloader instanceof URLClassLoader) {
+      return ((URLClassLoader) classloader).getURLs();
+    }
+    return parseJavaClassPath();
+  }
+
+  // TODO(https://github.com/google/guava/issues/2956): Use a public API once one is available.
+  private static URL[] parseJavaClassPath() {
+    ImmutableList.Builder<URL> urls = ImmutableList.builder();
+    for (String entry : Splitter.on(PATH_SEPARATOR.value()).split(JAVA_CLASS_PATH.value())) {
+      try {
+        try {
+          urls.add(new File(entry).toURI().toURL());
+        } catch (SecurityException e) { // File.toURI checks to see if the file is a directory
+          urls.add(new URL("file", null, new File(entry).getAbsolutePath()));
+        }
+      } catch (MalformedURLException e) {
+        Logger.strict("malformed classpath entry: " + entry, e);
+      }
+    }
+    return urls.build().toArray(new URL[0]);
+  }
+
+  @Override
+  public URL getResource(String name) {
+    if (config.shouldAcquireResource(name)) {
+      return resourceProvider.getResource(name);
+    }
+    URL fromParent = super.getResource(name);
+    if (fromParent != null) {
+      return fromParent;
+    }
+    return resourceProvider.getResource(name);
+  }
+
+  private InputStream getClassBytesAsStreamPreferringLocalUrls(String resName) {
+    InputStream fromUrlsClassLoader = resourceProvider.getResourceAsStream(resName);
+    if (fromUrlsClassLoader != null) {
+      return fromUrlsClassLoader;
+    }
+    return super.getResourceAsStream(resName);
+  }
+
+  @Override
+  public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
+    synchronized (getClassLoadingLock(name)) {
+      Class<?> loadedClass = findLoadedClass(name);
+      if (loadedClass != null) {
+        return loadedClass;
+      }
+
+      if (config.shouldAcquire(name)) {
+        loadedClass =
+            PerfStatsCollector.getInstance()
+                .measure("load sandboxed class", () -> maybeInstrumentClass(name));
+      } else {
+        loadedClass = getParent().loadClass(name);
+      }
+
+      if (resolve) {
+        resolveClass(loadedClass);
+      }
+
+      return loadedClass;
+    }
+  }
+
+  protected Class<?> maybeInstrumentClass(String className) throws ClassNotFoundException {
+    final byte[] origClassBytes = getByteCode(className);
+
+    try {
+      final byte[] bytes;
+      ClassDetails classDetails = new ClassDetails(origClassBytes);
+      if (config.shouldInstrument(classDetails)) {
+        bytes = classInstrumentor.instrument(classDetails, config, classNodeProvider);
+        maybeDumpClassBytes(classDetails, bytes);
+      } else {
+        bytes = postProcessUninstrumentedClass(classDetails);
+      }
+      ensurePackage(className);
+      return defineClass(className, bytes, 0, bytes.length);
+    } catch (Exception e) {
+      throw new ClassNotFoundException("couldn't load " + className, e);
+    } catch (OutOfMemoryError e) {
+      System.err.println("[ERROR] couldn't load " + className + " in " + this);
+      throw e;
+    }
+  }
+
+  private void maybeDumpClassBytes(ClassDetails classDetails, byte[] classBytes) {
+    if (!Strings.isNullOrEmpty(dumpClassesDirectory)) {
+      String outputClassName =
+          classDetails.getName() + "-robo-instrumented-" + DUMP_CLASSES_COUNTER.getAndIncrement();
+      Path path = Paths.get(dumpClassesDirectory, outputClassName + ".class");
+      try {
+        Files.write(path, classBytes);
+      } catch (IOException e) {
+        throw new AssertionError(e);
+      }
+    }
+  }
+
+  protected byte[] postProcessUninstrumentedClass(ClassDetails classDetails) {
+    return classDetails.getClassBytes();
+  }
+
+  protected byte[] getByteCode(String className) throws ClassNotFoundException {
+    String classFilename = className.replace('.', '/') + ".class";
+    try (InputStream classBytesStream = getClassBytesAsStreamPreferringLocalUrls(classFilename)) {
+      if (classBytesStream == null) {
+        throw new ClassNotFoundException(className);
+      }
+
+      return Util.readBytes(classBytesStream);
+    } catch (IOException e) {
+      throw new ClassNotFoundException("couldn't load " + className, e);
+    }
+  }
+
+  private void ensurePackage(final String className) {
+    int lastDotIndex = className.lastIndexOf('.');
+    if (lastDotIndex != -1) {
+      String pckgName = className.substring(0, lastDotIndex);
+      Package pckg = getPackage(pckgName);
+      if (pckg == null) {
+        definePackage(pckgName, null, null, null, null, null, null, null);
+      }
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/SandboxConfig.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/SandboxConfig.java
new file mode 100644
index 0000000..60251bd
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/SandboxConfig.java
@@ -0,0 +1,31 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Configuration settings that can be used on a per-class or per-test basis.
+ */
+@Documented
+@Inherited
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface SandboxConfig {
+  /**
+   * A list of shadow classes to enable, in addition to those that are already present.
+   *
+   * @return A list of additional shadow classes to enable.
+   */
+  Class<?>[] shadows() default {};  // DEFAULT_SHADOWS
+
+  /**
+   * A list of instrumented packages, in addition to those that are already instrumented.
+   *
+   * @return A list of additional instrumented packages.
+   */
+  String[] instrumentedPackages() default {};  // DEFAULT_INSTRUMENTED_PACKAGES
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowConstants.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowConstants.java
new file mode 100644
index 0000000..98dd9b0
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowConstants.java
@@ -0,0 +1,9 @@
+package org.robolectric.internal.bytecode;
+
+public class ShadowConstants {
+  public static final String ROBO_PREFIX = "$$robo$$";
+  public static final String CLASS_HANDLER_DATA_FIELD_NAME = "__robo_data__"; // todo: rename
+  public static final String STATIC_INITIALIZER_METHOD_NAME = "__staticInitializer__";
+  public static final String CONSTRUCTOR_METHOD_NAME = "__constructor__";
+  public static final String GET_ROBO_DATA_METHOD_NAME = "$$robo$getData";
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowDecorator.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowDecorator.java
new file mode 100644
index 0000000..a83009d
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowDecorator.java
@@ -0,0 +1,52 @@
+package org.robolectric.internal.bytecode;
+
+import com.google.auto.service.AutoService;
+import javax.annotation.Priority;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.MethodNode;
+
+/** Decorator which adds Robolectric's shadowing behavior to a class. */
+@AutoService(ClassInstrumentor.Decorator.class)
+@Priority(Integer.MIN_VALUE)
+public class ShadowDecorator implements ClassInstrumentor.Decorator {
+  private static final String OBJECT_DESC = Type.getDescriptor(Object.class);
+  private static final Type OBJECT_TYPE = Type.getType(Object.class);
+  private static final String GET_ROBO_DATA_SIGNATURE = "()Ljava/lang/Object;";
+
+  @Override
+  public void decorate(MutableClass mutableClass) {
+    mutableClass.addInterface(Type.getInternalName(ShadowedObject.class));
+
+    mutableClass.addField(
+        0,
+        new FieldNode(
+            Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC,
+            ShadowConstants.CLASS_HANDLER_DATA_FIELD_NAME,
+            OBJECT_DESC,
+            OBJECT_DESC,
+            null));
+
+    addRoboGetDataMethod(mutableClass);
+  }
+
+  private void addRoboGetDataMethod(MutableClass mutableClass) {
+    MethodNode initMethodNode =
+        new MethodNode(
+            Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC,
+            ShadowConstants.GET_ROBO_DATA_METHOD_NAME,
+            GET_ROBO_DATA_SIGNATURE,
+            null,
+            null);
+    RobolectricGeneratorAdapter generator = new RobolectricGeneratorAdapter(initMethodNode);
+    generator.loadThis(); // this
+    generator.getField(
+        mutableClass.classType,
+        ShadowConstants.CLASS_HANDLER_DATA_FIELD_NAME,
+        OBJECT_TYPE); // contents of __robo_data__
+    generator.returnValue();
+    generator.endMethod();
+    mutableClass.addMethod(initMethodNode);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowImpl.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowImpl.java
new file mode 100644
index 0000000..a55bf5f
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowImpl.java
@@ -0,0 +1,92 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.reflect.InvocationTargetException;
+import org.robolectric.internal.IShadow;
+import org.robolectric.util.ReflectionHelpers;
+
+public class ShadowImpl implements IShadow {
+
+  private final ProxyMaker proxyMaker = new ProxyMaker(this::directMethodName);
+
+  @Override
+  @SuppressWarnings("TypeParameterUnusedInFormals")
+  public <T> T extract(Object instance) {
+    return (T) ((ShadowedObject) instance).$$robo$getData();
+  }
+
+  @Override public <T> T newInstanceOf(Class<T> clazz) {
+    return ReflectionHelpers.callConstructor(clazz);
+  }
+
+  @Override public <T> T newInstance(Class<T> clazz, Class[] parameterTypes, Object[] params) {
+    return ReflectionHelpers.callConstructor(clazz, ReflectionHelpers.ClassParameter.fromComponentLists(parameterTypes, params));
+  }
+
+  /**
+   * Returns a proxy object that invokes the original $$robo$$-prefixed methods for {@code
+   * shadowedObject}.
+   *
+   * @deprecated This is incompatible with JDK17+. Use a {@link
+   *     org.robolectric.util.reflector.Reflector} interface with {@link
+   *     org.robolectric.util.reflector.Direct}.
+   */
+  @Deprecated
+  @Override
+  public <T> T directlyOn(T shadowedObject, Class<T> clazz) {
+    return createProxy(shadowedObject, clazz);
+  }
+
+  private <T> T createProxy(T shadowedObject, Class<T> clazz) {
+    try {
+      return proxyMaker.createProxy(clazz, shadowedObject);
+    } catch (Exception e) {
+      throw new RuntimeException("error creating direct call proxy for " + clazz, e);
+    }
+  }
+
+  @Override @SuppressWarnings(value = {"unchecked", "TypeParameterUnusedInFormals"})
+  public <R> R directlyOn(Object shadowedObject, String clazzName, String methodName, ReflectionHelpers.ClassParameter... paramValues) {
+    try {
+      Class<Object> aClass = (Class<Object>) shadowedObject.getClass().getClassLoader().loadClass(clazzName);
+      return directlyOn(shadowedObject, aClass, methodName, paramValues);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override @SuppressWarnings(value = {"unchecked", "TypeParameterUnusedInFormals"})
+  public <R, T> R directlyOn(T shadowedObject, Class<T> clazz, String methodName, ReflectionHelpers.ClassParameter... paramValues) {
+    String directMethodName = directMethodName(clazz.getName(), methodName);
+    return (R) ReflectionHelpers.callInstanceMethod(clazz, shadowedObject, directMethodName, paramValues);
+  }
+
+  @Override @SuppressWarnings(value = {"unchecked", "TypeParameterUnusedInFormals"})
+  public <R, T> R directlyOn(Class<T> clazz, String methodName, ReflectionHelpers.ClassParameter... paramValues) {
+    String directMethodName = directMethodName(clazz.getName(), methodName);
+    return (R) ReflectionHelpers.callStaticMethod(clazz, directMethodName, paramValues);
+  }
+
+  @Override @SuppressWarnings(value = {"unchecked", "TypeParameterUnusedInFormals"})
+  public <R> R invokeConstructor(Class<? extends R> clazz, R instance, ReflectionHelpers.ClassParameter... paramValues) {
+    String directMethodName =
+        directMethodName(clazz.getName(), ShadowConstants.CONSTRUCTOR_METHOD_NAME);
+    return (R) ReflectionHelpers.callInstanceMethod(clazz, instance, directMethodName, paramValues);
+  }
+
+  @Override
+  public String directMethodName(String className, String methodName) {
+     return ShadowConstants.ROBO_PREFIX
+      + className.replace('.', '_').replace('$', '_')
+      + "$" + methodName;
+  }
+
+  @Override
+  public void directInitialize(Class<?> clazz) {
+    try {
+      RobolectricInternals.performStaticInitialization(clazz);
+    } catch (InvocationTargetException | IllegalAccessException e) {
+      throw new RuntimeException("failed to initialize " + clazz, e);
+    }
+  }
+
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowInfo.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowInfo.java
new file mode 100644
index 0000000..1276c37
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowInfo.java
@@ -0,0 +1,94 @@
+package org.robolectric.internal.bytecode;
+
+import java.util.Objects;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Implements.DefaultShadowPicker;
+import org.robolectric.shadow.api.ShadowPicker;
+
+@SuppressWarnings("NewApi")
+public class ShadowInfo {
+
+  public final String shadowedClassName;
+  public final String shadowClassName;
+  public final boolean callThroughByDefault;
+  public final boolean looseSignatures;
+  private final int minSdk;
+  private final int maxSdk;
+  private final Class<? extends ShadowPicker<?>> shadowPickerClass;
+
+  ShadowInfo(
+      String shadowedClassName,
+      String shadowClassName,
+      boolean callThroughByDefault,
+      boolean looseSignatures,
+      int minSdk,
+      int maxSdk,
+      Class<? extends ShadowPicker<?>> shadowPickerClass) {
+    this.shadowedClassName = shadowedClassName;
+    this.shadowClassName = shadowClassName;
+    this.callThroughByDefault = callThroughByDefault;
+    this.looseSignatures = looseSignatures;
+    this.minSdk = minSdk;
+    this.maxSdk = maxSdk;
+    this.shadowPickerClass =
+        DefaultShadowPicker.class.equals(shadowPickerClass)
+            ? null
+            : shadowPickerClass;
+  }
+
+  ShadowInfo(String shadowedClassName, String shadowClassName, Implements annotation) {
+    this(shadowedClassName,
+        shadowClassName,
+        annotation.callThroughByDefault(),
+        annotation.looseSignatures(),
+        annotation.minSdk(),
+        annotation.maxSdk(),
+        annotation.shadowPicker());
+  }
+
+  public boolean supportsSdk(int sdkInt) {
+    return minSdk <= sdkInt && (maxSdk == -1 || maxSdk >= sdkInt);
+  }
+
+  public boolean isShadowOf(Class<?> clazz) {
+    return shadowedClassName.equals(clazz.getName());
+  }
+
+  public boolean hasShadowPicker() {
+    return shadowPickerClass != null && !DefaultShadowPicker.class.equals(shadowPickerClass);
+  }
+
+  public Class<? extends ShadowPicker<?>> getShadowPickerClass() {
+    return shadowPickerClass;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof ShadowInfo)) {
+      return false;
+    }
+    ShadowInfo that = (ShadowInfo) o;
+    return callThroughByDefault == that.callThroughByDefault
+        && looseSignatures == that.looseSignatures
+        && minSdk == that.minSdk
+        && maxSdk == that.maxSdk
+        && Objects.equals(shadowedClassName, that.shadowedClassName)
+        && Objects.equals(shadowClassName, that.shadowClassName)
+        && Objects.equals(shadowPickerClass, that.shadowPickerClass);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        shadowedClassName,
+        shadowClassName,
+        callThroughByDefault,
+        looseSignatures,
+        minSdk,
+        maxSdk,
+        shadowPickerClass);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowInvalidator.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowInvalidator.java
new file mode 100644
index 0000000..6baa7e5
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowInvalidator.java
@@ -0,0 +1,43 @@
+package org.robolectric.internal.bytecode;
+
+import java.lang.invoke.SwitchPoint;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+public class ShadowInvalidator {
+  private static final SwitchPoint DUMMY = new SwitchPoint();
+
+  static {
+    SwitchPoint.invalidateAll(new SwitchPoint[] { DUMMY });
+  }
+
+  private Map<String, SwitchPoint> switchPoints;
+
+  public ShadowInvalidator() {
+    this.switchPoints = new HashMap<>();
+  }
+
+  public SwitchPoint getSwitchPoint(Class<?> caller) {
+    return getSwitchPoint(caller.getName());
+  }
+
+  public synchronized SwitchPoint getSwitchPoint(String className) {
+    SwitchPoint switchPoint = switchPoints.get(className);
+    if (switchPoint == null) switchPoints.put(className, switchPoint = new SwitchPoint());
+    return switchPoint;
+  }
+
+  public synchronized void invalidateClasses(Collection<String> classNames) {
+    if (classNames.isEmpty()) return;
+    SwitchPoint[] points = new SwitchPoint[classNames.size()];
+    int i = 0;
+    for (String className : classNames) {
+      SwitchPoint switchPoint = switchPoints.put(className, null);
+      if (switchPoint == null) switchPoint = DUMMY;
+      points[i++] = switchPoint;
+    }
+
+    SwitchPoint.invalidateAll(points);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowMap.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowMap.java
new file mode 100644
index 0000000..39cfd01
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowMap.java
@@ -0,0 +1,274 @@
+package org.robolectric.internal.bytecode;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.annotation.Implements;
+import org.robolectric.internal.ShadowProvider;
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.shadow.api.ShadowPicker;
+
+/**
+ * Maps from instrumented class to shadow class.
+ *
+ * We deal with class names rather than actual classes here, since a ShadowMap is built outside of
+ * any sandboxes, but instrumented and shadowed classes must be loaded through a
+ * {@link SandboxClassLoader}. We don't want to try to resolve those classes outside of a sandbox.
+ *
+ * Once constructed, instances are immutable.
+ */
+@SuppressWarnings("NewApi")
+public class ShadowMap {
+
+  static final ShadowMap EMPTY = new ShadowMap(ImmutableListMultimap.of(), ImmutableMap.of());
+
+  private final ImmutableListMultimap<String, String> defaultShadows;
+  private final ImmutableMap<String, ShadowInfo> overriddenShadows;
+  private final ImmutableMap<String, String> shadowPickers;
+
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public static ShadowMap createFromShadowProviders(List<ShadowProvider> sortedProviders) {
+    final ArrayListMultimap<String, String> shadowMap = ArrayListMultimap.create();
+    final Map<String, String> shadowPickerMap = new HashMap<>();
+
+    // These are sorted in descending order (higher priority providers are first).
+    for (ShadowProvider provider : sortedProviders) {
+      for (Map.Entry<String, String> entry : provider.getShadows()) {
+        shadowMap.put(entry.getKey(), entry.getValue());
+      }
+      provider.getShadowPickerMap().forEach(shadowPickerMap::putIfAbsent);
+    }
+    return new ShadowMap(
+        ImmutableListMultimap.copyOf(shadowMap),
+        Collections.emptyMap(),
+        ImmutableMap.copyOf(shadowPickerMap));
+  }
+
+  ShadowMap(
+      ImmutableListMultimap<String, String> defaultShadows,
+      Map<String, ShadowInfo> overriddenShadows) {
+    this(defaultShadows, overriddenShadows, Collections.emptyMap());
+  }
+
+  private ShadowMap(
+      ImmutableListMultimap<String, String> defaultShadows,
+      Map<String, ShadowInfo> overriddenShadows,
+      Map<String, String> shadowPickers) {
+    this.defaultShadows = ImmutableListMultimap.copyOf(defaultShadows);
+    this.overriddenShadows = ImmutableMap.copyOf(overriddenShadows);
+    this.shadowPickers = ImmutableMap.copyOf(shadowPickers);
+  }
+
+  public ShadowInfo getShadowInfo(Class<?> clazz, ShadowMatcher shadowMatcher) {
+    String instrumentedClassName = clazz.getName();
+
+    ShadowInfo shadowInfo = overriddenShadows.get(instrumentedClassName);
+    if (shadowInfo == null) {
+      shadowInfo = checkShadowPickers(instrumentedClassName, clazz);
+    } else if (shadowInfo.hasShadowPicker()) {
+      shadowInfo = pickShadow(instrumentedClassName, clazz, shadowInfo);
+    }
+
+    if (shadowInfo == null && clazz.getClassLoader() != null) {
+      try {
+        final ImmutableList<String> shadowNames = defaultShadows.get(clazz.getCanonicalName());
+        for (String shadowName : shadowNames) {
+          if (shadowName != null) {
+            Class<?> shadowClass = clazz.getClassLoader().loadClass(shadowName);
+            shadowInfo = obtainShadowInfo(shadowClass);
+            if (!shadowInfo.shadowedClassName.equals(instrumentedClassName)) {
+              // somehow we got the wrong shadow class?
+              shadowInfo = null;
+            }
+            if (shadowInfo != null && shadowMatcher.matches(shadowInfo)) {
+              return shadowInfo;
+            }
+          }
+        }
+      } catch (ClassNotFoundException | IncompatibleClassChangeError e) {
+        return null;
+      }
+    }
+
+    if (shadowInfo != null && !shadowMatcher.matches(shadowInfo)) {
+      return null;
+    }
+
+    return shadowInfo;
+  }
+
+  // todo: some caching would probably be nice here...
+  private ShadowInfo checkShadowPickers(String instrumentedClassName, Class<?> clazz) {
+    String shadowPickerClassName = shadowPickers.get(instrumentedClassName);
+    if (shadowPickerClassName == null) {
+      return null;
+    }
+
+    return pickShadow(instrumentedClassName, clazz, shadowPickerClassName);
+  }
+
+  private ShadowInfo pickShadow(String instrumentedClassName, Class<?> clazz,
+      String shadowPickerClassName) {
+    ClassLoader sandboxClassLoader = clazz.getClassLoader();
+    try {
+      Class<? extends ShadowPicker<?>> shadowPickerClass =
+          (Class<? extends ShadowPicker<?>>) sandboxClassLoader.loadClass(shadowPickerClassName);
+      ShadowPicker<?> shadowPicker = shadowPickerClass.getDeclaredConstructor().newInstance();
+      Class<?> selectedShadowClass = shadowPicker.pickShadowClass();
+      if (selectedShadowClass == null) {
+        return obtainShadowInfo(Object.class, true);
+      }
+      ShadowInfo shadowInfo = obtainShadowInfo(selectedShadowClass);
+
+      if (!shadowInfo.shadowedClassName.equals(instrumentedClassName)) {
+        throw new IllegalArgumentException("Implemented class for "
+            + selectedShadowClass.getName() + " (" + shadowInfo.shadowedClassName + ") != "
+            + instrumentedClassName);
+      }
+
+      return shadowInfo;
+    } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException
+        | IllegalAccessException | InstantiationException e) {
+      throw new RuntimeException("Failed to resolve shadow picker for " + instrumentedClassName,
+          e);
+    }
+  }
+
+  private ShadowInfo pickShadow(
+      String instrumentedClassName, Class<?> clazz, ShadowInfo shadowInfo) {
+    return pickShadow(instrumentedClassName, clazz, shadowInfo.getShadowPickerClass().getName());
+  }
+
+  public static ShadowInfo obtainShadowInfo(Class<?> clazz) {
+    return obtainShadowInfo(clazz, false);
+  }
+
+  static ShadowInfo obtainShadowInfo(Class<?> clazz, boolean mayBeNonShadow) {
+    Implements annotation = clazz.getAnnotation(Implements.class);
+    if (annotation == null) {
+      if (mayBeNonShadow) {
+        return null;
+      } else {
+        throw new IllegalArgumentException(clazz + " is not annotated with @Implements");
+      }
+    }
+
+    String className = annotation.className();
+    if (className.isEmpty()) {
+      className = annotation.value().getName();
+    }
+    return new ShadowInfo(className, clazz.getName(), annotation);
+  }
+
+  @SuppressWarnings("ReferenceEquality")
+  public Set<String> getInvalidatedClasses(ShadowMap previous) {
+    if (this == previous && shadowPickers.isEmpty()) return Collections.emptySet();
+
+    Map<String, ShadowInfo> invalidated = new HashMap<>(overriddenShadows);
+
+    for (Map.Entry<String, ShadowInfo> entry : previous.overriddenShadows.entrySet()) {
+      String className = entry.getKey();
+      ShadowInfo previousConfig = entry.getValue();
+      ShadowInfo currentConfig = invalidated.get(className);
+      if (currentConfig == null) {
+        invalidated.put(className, previousConfig);
+      } else if (previousConfig.equals(currentConfig)) {
+        invalidated.remove(className);
+      }
+    }
+
+    return invalidated.keySet();
+  }
+
+  /**
+   * @deprecated do not use
+   */
+  @Deprecated
+  public static String convertToShadowName(String className) {
+    String shadowClassName =
+        "org.robolectric.shadows.Shadow" + className.substring(className.lastIndexOf(".") + 1);
+    shadowClassName = shadowClassName.replaceAll("\\$", "\\$Shadow");
+    return shadowClassName;
+  }
+
+  public Builder newBuilder() {
+    return new Builder(this);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ShadowMap)) return false;
+
+    ShadowMap shadowMap = (ShadowMap) o;
+
+    if (!overriddenShadows.equals(shadowMap.overriddenShadows)) return false;
+
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    return overriddenShadows.hashCode();
+  }
+
+  public static class Builder {
+    private final ImmutableListMultimap<String, String> defaultShadows;
+    private final Map<String, ShadowInfo> overriddenShadows;
+    private final Map<String, String> shadowPickers;
+
+    public Builder () {
+      defaultShadows = ImmutableListMultimap.of();
+      overriddenShadows = new HashMap<>();
+      shadowPickers = new HashMap<>();
+    }
+
+    public Builder(ShadowMap shadowMap) {
+      this.defaultShadows = shadowMap.defaultShadows;
+      this.overriddenShadows = new HashMap<>(shadowMap.overriddenShadows);
+      this.shadowPickers = new HashMap<>(shadowMap.shadowPickers);
+    }
+
+    public Builder addShadowClasses(Class<?>... shadowClasses) {
+      for (Class<?> shadowClass : shadowClasses) {
+        addShadowClass(shadowClass);
+      }
+      return this;
+    }
+
+    Builder addShadowClass(Class<?> shadowClass) {
+      addShadowInfo(obtainShadowInfo(shadowClass));
+      return this;
+    }
+
+    Builder addShadowClass(
+        String realClassName,
+        String shadowClassName,
+        boolean callThroughByDefault,
+        boolean looseSignatures) {
+      addShadowInfo(
+          new ShadowInfo(
+              realClassName, shadowClassName, callThroughByDefault, looseSignatures, -1, -1, null));
+      return this;
+    }
+
+    private void addShadowInfo(ShadowInfo shadowInfo) {
+      overriddenShadows.put(shadowInfo.shadowedClassName, shadowInfo);
+      if (shadowInfo.hasShadowPicker()) {
+        shadowPickers
+            .put(shadowInfo.shadowedClassName, shadowInfo.getShadowPickerClass().getName());
+      }
+    }
+
+    public ShadowMap build() {
+      return new ShadowMap(defaultShadows, overriddenShadows, shadowPickers);
+    }
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowProviders.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowProviders.java
new file mode 100644
index 0000000..2abcdab
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowProviders.java
@@ -0,0 +1,70 @@
+package org.robolectric.internal.bytecode;
+
+import static java.util.Comparator.comparing;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import javax.annotation.Priority;
+import org.robolectric.internal.ShadowProvider;
+
+/** The set of {@link ShadowProvider} implementations found on the classpath. */
+@SuppressWarnings("AndroidJdkLibsChecker")
+public class ShadowProviders {
+
+  private final ImmutableList<ShadowProvider> shadowProviders;
+  private final ShadowMap baseShadowMap;
+
+  public ShadowProviders(List<ShadowProvider> shadowProviders) {
+    // Return providers sorted by descending priority.
+    this.shadowProviders =
+        ImmutableList.sortedCopyOf(
+            comparing(ShadowProviders::priority).reversed().thenComparing(ShadowProviders::name),
+            shadowProviders);
+
+    this.baseShadowMap = ShadowMap.createFromShadowProviders(this.shadowProviders);
+  }
+
+  public ShadowMap getBaseShadowMap() {
+    return baseShadowMap;
+  }
+
+  private static int priority(ShadowProvider shadowProvider) {
+    Priority priority = shadowProvider.getClass().getAnnotation(Priority.class);
+    return priority == null ? 0 : priority.value();
+  }
+
+  private static String name(ShadowProvider shadowProvider) {
+    return shadowProvider.getClass().getName();
+  }
+
+  public List<String> getInstrumentedPackages() {
+    Set<String> packages = new HashSet<>();
+    for (ShadowProvider shadowProvider : shadowProviders) {
+      Collections.addAll(packages, shadowProvider.getProvidedPackageNames());
+    }
+    return new ArrayList<>(packages);
+  }
+
+  public ShadowProvider[] inClassLoader(ClassLoader classLoader) {
+    ShadowProvider[] inCL = new ShadowProvider[shadowProviders.size()];
+    for (int i = 0; i < shadowProviders.size(); i++) {
+      ShadowProvider shadowProvider = shadowProviders.get(i);
+      String name = shadowProvider.getClass().getName();
+      try {
+        inCL[i] =
+            classLoader
+                .loadClass(name)
+                .asSubclass(ShadowProvider.class)
+                .getConstructor()
+                .newInstance();
+      } catch (ReflectiveOperationException e) {
+        throw new IllegalStateException("couldn't reload " + name + " in " + classLoader, e);
+      }
+    }
+    return inCL;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowWrangler.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowWrangler.java
new file mode 100644
index 0000000..51953fd
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowWrangler.java
@@ -0,0 +1,552 @@
+package org.robolectric.internal.bytecode;
+
+import static java.lang.invoke.MethodHandles.constant;
+import static java.lang.invoke.MethodHandles.dropArguments;
+import static java.lang.invoke.MethodHandles.foldArguments;
+import static java.lang.invoke.MethodHandles.identity;
+import static java.lang.invoke.MethodType.methodType;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import com.google.auto.service.AutoService;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nonnull;
+import javax.annotation.Priority;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.sandbox.ShadowMatcher;
+import org.robolectric.util.Function;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.Util;
+
+/**
+ * ShadowWrangler matches shadowed classes up with corresponding shadows based on a {@link
+ * ShadowMap}.
+ *
+ * <p>ShadowWrangler has no specific knowledge of Android SDK levels or other peculiarities of the
+ * affected classes and shadows.
+ *
+ * <p>To apply additional rules about which shadow classes and methods are considered matches, pass
+ * in a {@link ShadowMatcher}.
+ *
+ * <p>ShadowWrangler is Robolectric's default {@link ClassHandler} implementation. To inject your
+ * own, create a subclass and annotate it with {@link AutoService}(ClassHandler).
+ */
+@SuppressWarnings("NewApi")
+@AutoService(ClassHandler.class)
+@Priority(Integer.MIN_VALUE)
+public class ShadowWrangler implements ClassHandler {
+  public static final Function<Object, Object> DO_NOTHING_HANDLER =
+      new Function<Object, Object>() {
+        @Override
+        public Object call(Class<?> theClass, Object value, Object[] params) {
+          return null;
+        }
+      };
+  public static final Method CALL_REAL_CODE = null;
+  public static final MethodHandle DO_NOTHING =
+      constant(Void.class, null).asType(methodType(void.class));
+  public static final Method DO_NOTHING_METHOD;
+
+  static {
+    try {
+      DO_NOTHING_METHOD = ShadowWrangler.class.getDeclaredMethod("doNothing");
+      DO_NOTHING_METHOD.setAccessible(true);
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
+
+  // Required to support the equivalent of MethodHandles.privateLookupIn in Java 8. It allows
+  // calling protected constructors using incokespecial.
+  private static final boolean HAS_PRIVATE_LOOKUP_IN = Util.getJavaVersion() >= 9;
+  private static final Constructor<MethodHandles.Lookup> JAVA_8_LOOKUP_CTOR;
+
+  static {
+    if (!HAS_PRIVATE_LOOKUP_IN) {
+      try {
+        JAVA_8_LOOKUP_CTOR = MethodHandles.Lookup.class.getDeclaredConstructor(Class.class);
+        JAVA_8_LOOKUP_CTOR.setAccessible(true);
+      } catch (NoSuchMethodException e) {
+        throw new AssertionError(e);
+      }
+    } else {
+      JAVA_8_LOOKUP_CTOR = null;
+    }
+  }
+
+  private static final Class<?>[] NO_ARGS = new Class<?>[0];
+  static final Object NO_SHADOW = new Object();
+  private static final MethodHandle NO_SHADOW_HANDLE = constant(Object.class, NO_SHADOW);
+  private final ShadowMap shadowMap;
+  private final Interceptors interceptors;
+  private final ShadowMatcher shadowMatcher;
+  private final MethodHandle reflectorHandle;
+
+  /** key is instrumented class */
+  private final ClassValueMap<ShadowInfo> cachedShadowInfos =
+      new ClassValueMap<ShadowInfo>() {
+        @Override
+        protected ShadowInfo computeValue(Class<?> type) {
+          return shadowMap.getShadowInfo(type, shadowMatcher);
+        }
+      };
+
+  /** key is shadow class */
+  private final ClassValueMap<ShadowMetadata> cachedShadowMetadata =
+      new ClassValueMap<ShadowMetadata>() {
+        @Nonnull
+        @Override
+        protected ShadowMetadata computeValue(Class<?> type) {
+          return new ShadowMetadata(type);
+        }
+      };
+
+  public ShadowWrangler(
+      ShadowMap shadowMap, ShadowMatcher shadowMatcher, Interceptors interceptors) {
+    this.shadowMap = shadowMap;
+    this.shadowMatcher = shadowMatcher;
+    this.interceptors = interceptors;
+    try {
+      this.reflectorHandle =
+          LOOKUP
+              .findVirtual(
+                  ShadowWrangler.class,
+                  "injectReflectorObjectOn",
+                  methodType(void.class, Object.class, Object.class))
+              .bindTo(this);
+    } catch (IllegalAccessException | NoSuchMethodException e) {
+      throw new RuntimeException(
+          "Could not instantiate MethodHandle for ReflectorObject injection.", e);
+    }
+  }
+
+  public static Class<?> loadClass(String paramType, ClassLoader classLoader) {
+    Class<?> primitiveClass = RoboType.findPrimitiveClass(paramType);
+    if (primitiveClass != null) return primitiveClass;
+
+    int arrayLevel = 0;
+    while (paramType.endsWith("[]")) {
+      arrayLevel++;
+      paramType = paramType.substring(0, paramType.length() - 2);
+    }
+
+    Class<?> clazz = RoboType.findPrimitiveClass(paramType);
+    if (clazz == null) {
+      try {
+        clazz = classLoader.loadClass(paramType);
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    while (arrayLevel-- > 0) {
+      clazz = Array.newInstance(clazz, 0).getClass();
+    }
+
+    return clazz;
+  }
+
+  @SuppressWarnings("ReferenceEquality")
+  @Override
+  public void classInitializing(Class clazz) {
+    try {
+      Method method =
+          pickShadowMethod(clazz, ShadowConstants.STATIC_INITIALIZER_METHOD_NAME, NO_ARGS);
+
+      // if we got back DO_NOTHING_METHOD that means the shadow is {@code callThroughByDefault =
+      // false};
+      // for backwards compatibility we'll still perform static initialization though for now.
+      if (method == DO_NOTHING_METHOD) {
+        method = null;
+      }
+
+      if (method != null) {
+        if (!Modifier.isStatic(method.getModifiers())) {
+          throw new RuntimeException(
+              method.getDeclaringClass().getName() + "." + method.getName() + " is not static");
+        }
+
+        method.invoke(null);
+      } else {
+        RobolectricInternals.performStaticInitialization(clazz);
+      }
+    } catch (InvocationTargetException | IllegalAccessException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public Object initializing(Object instance) {
+    return createShadowFor(instance);
+  }
+
+  @SuppressWarnings({"ReferenceEquality"})
+  @Override
+  public MethodHandle findShadowMethodHandle(
+      Class<?> definingClass, String name, MethodType methodType, boolean isStatic)
+      throws IllegalAccessException {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "find shadow method handle",
+            () -> {
+              MethodType actualType = isStatic ? methodType : methodType.dropParameterTypes(0, 1);
+              Class<?>[] paramTypes = actualType.parameterArray();
+
+              Method shadowMethod = pickShadowMethod(definingClass, name, paramTypes);
+
+              if (shadowMethod == CALL_REAL_CODE) {
+                return null;
+              } else if (shadowMethod == DO_NOTHING_METHOD) {
+                return DO_NOTHING;
+              }
+
+              shadowMethod.setAccessible(true);
+
+              MethodHandle mh;
+              if (name.equals(ShadowConstants.CONSTRUCTOR_METHOD_NAME)) {
+                if (Modifier.isStatic(shadowMethod.getModifiers())) {
+                  throw new UnsupportedOperationException(
+                      "static __constructor__ shadow methods are not supported");
+                }
+                // Use invokespecial to call constructor shadow methods. If invokevirtual is used,
+                // the wrong constructor may be called in situations where constructors with
+                // identical signatures are shadowed in object hierarchies.
+                mh =
+                    privateLookupFor(shadowMethod.getDeclaringClass())
+                        .unreflectSpecial(shadowMethod, shadowMethod.getDeclaringClass());
+              } else {
+                mh = LOOKUP.unreflect(shadowMethod);
+              }
+
+              // Robolectric doesn't actually look for static, this for example happens
+              // in MessageQueue.nativeInit() which used to be void non-static in 4.2.
+              if (!isStatic && Modifier.isStatic(shadowMethod.getModifiers())) {
+                return dropArguments(mh, 0, Object.class);
+              } else {
+                return mh;
+              }
+            });
+  }
+
+  @SuppressWarnings({"AndroidJdkLibsChecker"})
+  private MethodHandles.Lookup privateLookupFor(Class<?> lookupClass)
+      throws IllegalAccessException {
+    if (HAS_PRIVATE_LOOKUP_IN) {
+      return MethodHandles.privateLookupIn(lookupClass, LOOKUP);
+    }
+    try {
+      return JAVA_8_LOOKUP_CTOR.newInstance(lookupClass);
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError(e.getMessage(), e);
+    }
+  }
+
+  protected Method pickShadowMethod(Class<?> definingClass, String name, Class<?>[] paramTypes) {
+    ShadowInfo shadowInfo = getExactShadowInfo(definingClass);
+    if (shadowInfo == null) {
+      return CALL_REAL_CODE;
+    } else {
+      ClassLoader classLoader = definingClass.getClassLoader();
+      Class<?> shadowClass;
+      try {
+        shadowClass = Class.forName(shadowInfo.shadowClassName, false, classLoader);
+      } catch (ClassNotFoundException e) {
+        throw new IllegalStateException(e);
+      }
+
+      Method method = findShadowMethod(definingClass, name, paramTypes, shadowInfo, shadowClass);
+      if (method == null) {
+        return shadowInfo.callThroughByDefault ? CALL_REAL_CODE : DO_NOTHING_METHOD;
+      } else {
+        return method;
+      }
+    }
+  }
+
+  /**
+   * Searches for an {@code @Implementation} method on a given shadow class.
+   *
+   * <p>If the shadow class allows loose signatures, search for them.
+   *
+   * <p>If the shadow class doesn't have such a method, but does have a superclass which implements
+   * the same class as it, call ourself recursively with the shadow superclass.
+   */
+  private Method findShadowMethod(
+      Class<?> definingClass,
+      String name,
+      Class<?>[] types,
+      ShadowInfo shadowInfo,
+      Class<?> shadowClass) {
+    Method method = findShadowMethodDeclaredOnClass(shadowClass, name, types);
+
+    if (method == null && shadowInfo.looseSignatures) {
+      Class<?>[] genericTypes = MethodType.genericMethodType(types.length).parameterArray();
+      method = findShadowMethodDeclaredOnClass(shadowClass, name, genericTypes);
+    }
+
+    if (method != null) {
+      return method;
+    } else {
+      // if the shadow's superclass shadows the same class as this shadow, then recurse.
+      // Buffalo buffalo buffalo buffalo buffalo buffalo buffalo.
+      Class<?> shadowSuperclass = shadowClass.getSuperclass();
+      if (shadowSuperclass != null && !shadowSuperclass.equals(Object.class)) {
+        ShadowInfo shadowSuperclassInfo = ShadowMap.obtainShadowInfo(shadowSuperclass, true);
+        if (shadowSuperclassInfo != null
+            && shadowSuperclassInfo.isShadowOf(definingClass)
+            && shadowMatcher.matches(shadowSuperclassInfo)) {
+
+          method =
+              findShadowMethod(definingClass, name, types, shadowSuperclassInfo, shadowSuperclass);
+        }
+      }
+    }
+
+    return method;
+  }
+
+  private Method findShadowMethodDeclaredOnClass(
+      Class<?> shadowClass, String methodName, Class<?>[] paramClasses) {
+    try {
+      Method method = shadowClass.getDeclaredMethod(methodName, paramClasses);
+
+      // todo: allow per-version overloading
+      // if (method == null) {
+      //   String methodPrefix = name + "$$";
+      //   for (Method candidateMethod : shadowClass.getDeclaredMethods()) {
+      //     if (candidateMethod.getName().startsWith(methodPrefix)) {
+      //
+      //     }
+      //   }
+      // }
+
+      if (isValidShadowMethod(method)) {
+        method.setAccessible(true);
+        return method;
+      } else {
+        return null;
+      }
+
+    } catch (NoSuchMethodException e) {
+      return null;
+    }
+  }
+
+  private boolean isValidShadowMethod(Method method) {
+    int modifiers = method.getModifiers();
+    if (!Modifier.isPublic(modifiers) && !Modifier.isProtected(modifiers)) {
+      return false;
+    }
+
+    return shadowMatcher.matches(method);
+  }
+
+  @Override
+  public Object intercept(String signature, Object instance, Object[] params, Class theClass)
+      throws Throwable {
+    final MethodSignature methodSignature = MethodSignature.parse(signature);
+    return interceptors.getInterceptionHandler(methodSignature).call(theClass, instance, params);
+  }
+
+  @Override
+  public <T extends Throwable> T stripStackTrace(T throwable) {
+    StackTraceElement[] elements = throwable.getStackTrace();
+    if (elements != null) {
+      List<StackTraceElement> stackTrace = new ArrayList<>();
+
+      String previousClassName = null;
+      String previousMethodName = null;
+      String previousFileName = null;
+
+      for (StackTraceElement stackTraceElement : elements) {
+        String methodName = stackTraceElement.getMethodName();
+        String className = stackTraceElement.getClassName();
+        String fileName = stackTraceElement.getFileName();
+
+        if (methodName.equals(previousMethodName)
+            && className.equals(previousClassName)
+            && fileName != null
+            && fileName.equals(previousFileName)
+            && stackTraceElement.getLineNumber() < 0) {
+          continue;
+        }
+
+        if (methodName.startsWith(ShadowConstants.ROBO_PREFIX)) {
+          methodName =
+              methodName.substring(
+                  methodName.indexOf('$', ShadowConstants.ROBO_PREFIX.length() + 1) + 1);
+          stackTraceElement =
+              new StackTraceElement(
+                  className,
+                  methodName,
+                  stackTraceElement.getFileName(),
+                  stackTraceElement.getLineNumber());
+        }
+
+        if (className.startsWith("sun.reflect.") || className.startsWith("java.lang.reflect.")) {
+          continue;
+        }
+
+        stackTrace.add(stackTraceElement);
+
+        previousClassName = className;
+        previousMethodName = methodName;
+        previousFileName = fileName;
+      }
+      throwable.setStackTrace(stackTrace.toArray(new StackTraceElement[stackTrace.size()]));
+    }
+    return throwable;
+  }
+
+  Object createShadowFor(Object instance) {
+    Class<?> theClass = instance.getClass();
+    Object shadow = createShadowFor(theClass);
+    injectRealObjectOn(shadow, instance);
+    injectReflectorObjectOn(shadow, instance);
+    return shadow;
+  }
+
+  private Object createShadowFor(Class<?> theClass) {
+    ShadowInfo shadowInfo = getShadowInfo(theClass);
+    if (shadowInfo == null) {
+      return NO_SHADOW;
+    } else {
+      try {
+        Class<?> shadowClass = loadClass(shadowInfo.shadowClassName, theClass.getClassLoader());
+        ShadowMetadata shadowMetadata = getShadowMetadata(shadowClass);
+        return shadowMetadata.constructor.newInstance();
+      } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) {
+        throw new RuntimeException(
+            "Could not instantiate shadow " + shadowInfo.shadowClassName + " for " + theClass, e);
+      }
+    }
+  }
+
+  private ShadowMetadata getShadowMetadata(Class<?> shadowClass) {
+    return cachedShadowMetadata.get(shadowClass);
+  }
+
+  @Override
+  public MethodHandle getShadowCreator(Class<?> theClass) {
+    ShadowInfo shadowInfo = getShadowInfo(theClass);
+    if (shadowInfo == null) return dropArguments(NO_SHADOW_HANDLE, 0, theClass);
+    String shadowClassName = shadowInfo.shadowClassName;
+
+    try {
+      Class<?> shadowClass = Class.forName(shadowClassName, false, theClass.getClassLoader());
+      ShadowMetadata shadowMetadata = getShadowMetadata(shadowClass);
+
+      MethodHandle mh = identity(shadowClass); // (instance)
+      mh = dropArguments(mh, 1, theClass); // (instance)
+      for (Field field : shadowMetadata.realObjectFields) {
+        MethodHandle setter = LOOKUP.unreflectSetter(field);
+        MethodType setterType = mh.type().changeReturnType(void.class);
+        mh = foldArguments(mh, setter.asType(setterType));
+      }
+
+      MethodHandle classHandle =
+          reflectorHandle.asType(
+              reflectorHandle
+                  .type()
+                  .changeParameterType(0, shadowClass)
+                  .changeParameterType(1, theClass));
+      mh = foldArguments(mh, classHandle);
+
+      mh =
+          foldArguments(
+              mh, LOOKUP.unreflectConstructor(shadowMetadata.constructor)); // (shadow, instance)
+
+      return mh; // (instance)
+    } catch (IllegalAccessException | ClassNotFoundException e) {
+      throw new RuntimeException(
+          "Could not instantiate shadow " + shadowClassName + " for " + theClass, e);
+    }
+  }
+
+  private void injectRealObjectOn(Object shadow, Object instance) {
+    ShadowMetadata shadowMetadata = getShadowMetadata(shadow.getClass());
+    for (Field realObjectField : shadowMetadata.realObjectFields) {
+      setField(shadow, instance, realObjectField);
+    }
+  }
+
+  private void injectReflectorObjectOn(Object shadow, Object instance) {
+    ShadowMetadata shadowMetadata = getShadowMetadata(shadow.getClass());
+    for (Field reflectorObjectField : shadowMetadata.reflectorObjectFields) {
+      setField(shadow, reflector(reflectorObjectField.getType(), instance), reflectorObjectField);
+    }
+  }
+
+  private ShadowInfo getShadowInfo(Class<?> clazz) {
+    ShadowInfo shadowInfo = null;
+    for (; shadowInfo == null && clazz != null; clazz = clazz.getSuperclass()) {
+      shadowInfo = getExactShadowInfo(clazz);
+    }
+    return shadowInfo;
+  }
+
+  private ShadowInfo getExactShadowInfo(Class<?> clazz) {
+    return cachedShadowInfos.get(clazz);
+  }
+
+  private static void setField(Object target, Object value, Field realObjectField) {
+    try {
+      realObjectField.set(target, value);
+    } catch (IllegalAccessException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static class ShadowMetadata {
+    final Constructor<?> constructor;
+    final List<Field> realObjectFields = new ArrayList<>();
+    final List<Field> reflectorObjectFields = new ArrayList<>();
+
+    public ShadowMetadata(Class<?> shadowClass) {
+      try {
+        this.constructor = shadowClass.getConstructor();
+      } catch (NoSuchMethodException e) {
+        throw new RuntimeException("Missing public empty constructor on " + shadowClass, e);
+      }
+
+      while (shadowClass != null) {
+        for (Field field : shadowClass.getDeclaredFields()) {
+          if (field.isAnnotationPresent(RealObject.class)) {
+            if (Modifier.isStatic(field.getModifiers())) {
+              String message = "@RealObject must be on a non-static field, " + shadowClass;
+              System.err.println(message);
+              throw new IllegalArgumentException(message);
+            }
+            field.setAccessible(true);
+            realObjectFields.add(field);
+          }
+          if (field.isAnnotationPresent(ReflectorObject.class)) {
+            if (Modifier.isStatic(field.getModifiers())) {
+              String message = "@ReflectorObject must be on a non-static field, " + shadowClass;
+              System.err.println(message);
+              throw new IllegalArgumentException(message);
+            }
+            field.setAccessible(true);
+            reflectorObjectFields.add(field);
+          }
+        }
+        shadowClass = shadowClass.getSuperclass();
+      }
+    }
+  }
+
+  @SuppressWarnings("unused")
+  private static void doNothing() {}
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowedObject.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowedObject.java
new file mode 100644
index 0000000..8d33076
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ShadowedObject.java
@@ -0,0 +1,5 @@
+package org.robolectric.internal.bytecode;
+
+public interface ShadowedObject {
+  Object $$robo$getData();
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/TypeMapper.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/TypeMapper.java
new file mode 100644
index 0000000..e42ca79
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/TypeMapper.java
@@ -0,0 +1,93 @@
+package org.robolectric.internal.bytecode;
+
+import static org.objectweb.asm.Type.ARRAY;
+import static org.objectweb.asm.Type.OBJECT;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.objectweb.asm.Type;
+
+class TypeMapper {
+  private final Map<String, String> classesToRemap;
+
+  public TypeMapper(Map<String, String> classNameToClassNameMap) {
+    classesToRemap = convertToSlashes(classNameToClassNameMap);
+  }
+
+  private static Map<String, String> convertToSlashes(Map<String, String> map) {
+    HashMap<String, String> newMap = new HashMap<>();
+    for (Map.Entry<String, String> entry : map.entrySet()) {
+      String key = internalize(entry.getKey());
+      String value = internalize(entry.getValue());
+      newMap.put(key, value);
+      newMap.put("L" + key + ";", "L" + value + ";"); // also the param reference form
+    }
+    return newMap;
+  }
+
+  private static String internalize(String className) {
+    return className.replace('.', '/');
+  }
+
+  // remap android/Foo to android/Bar
+  String mappedTypeName(String internalName) {
+    String remappedInternalName = classesToRemap.get(internalName);
+    if (remappedInternalName != null) {
+      return remappedInternalName;
+    } else {
+      return internalName;
+    }
+  }
+
+  Type mappedType(Type type) {
+    String internalName = type.getInternalName();
+    String remappedInternalName = classesToRemap.get(internalName);
+    if (remappedInternalName != null) {
+      return Type.getObjectType(remappedInternalName);
+    } else {
+      return type;
+    }
+  }
+
+  String remapParams(String desc) {
+    StringBuilder buf = new StringBuilder();
+    buf.append("(");
+    for (Type type : Type.getArgumentTypes(desc)) {
+      buf.append(remapParamType(type));
+    }
+    buf.append(")");
+    buf.append(remapParamType(Type.getReturnType(desc)));
+    return buf.toString();
+  }
+
+  // remap Landroid/Foo; to Landroid/Bar;
+  String remapParamType(String desc) {
+    return remapParamType(Type.getType(desc));
+  }
+
+  private String remapParamType(Type type) {
+    String remappedName;
+    String internalName;
+
+    switch (type.getSort()) {
+      case ARRAY:
+        internalName = type.getInternalName();
+        int count = 0;
+        while (internalName.charAt(count) == '[') count++;
+
+        remappedName = remapParamType(internalName.substring(count));
+        if (remappedName != null) {
+          return Type.getObjectType(internalName.substring(0, count) + remappedName).getDescriptor();
+        }
+        break;
+
+      case OBJECT:
+        type = mappedType(type);
+        break;
+
+      default:
+        break;
+    }
+    return type.getDescriptor();
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/UrlResourceProvider.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/UrlResourceProvider.java
new file mode 100644
index 0000000..8dac6cc
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/UrlResourceProvider.java
@@ -0,0 +1,12 @@
+package org.robolectric.internal.bytecode;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+
+/** ResourceProvider using URLs. */
+public class UrlResourceProvider extends URLClassLoader implements ResourceProvider {
+
+  public UrlResourceProvider(URL... urls) {
+    super(urls, null);
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/sandbox/AlwaysTrueShadowMatcher.java b/sandbox/src/main/java/org/robolectric/sandbox/AlwaysTrueShadowMatcher.java
new file mode 100644
index 0000000..3649999
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/sandbox/AlwaysTrueShadowMatcher.java
@@ -0,0 +1,17 @@
+package org.robolectric.sandbox;
+
+import org.robolectric.internal.bytecode.ShadowInfo;
+
+import java.lang.reflect.Method;
+
+class AlwaysTrueShadowMatcher implements ShadowMatcher {
+  @Override
+  public boolean matches(ShadowInfo shadowInfo) {
+    return true;
+  }
+
+  @Override
+  public boolean matches(Method method) {
+    return true;
+  }
+}
diff --git a/sandbox/src/main/java/org/robolectric/sandbox/ShadowMatcher.java b/sandbox/src/main/java/org/robolectric/sandbox/ShadowMatcher.java
new file mode 100644
index 0000000..ea55225
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/sandbox/ShadowMatcher.java
@@ -0,0 +1,17 @@
+package org.robolectric.sandbox;
+
+import org.robolectric.internal.bytecode.ShadowInfo;
+
+import java.lang.reflect.Method;
+
+/**
+ * ShadowMatcher is used by {@link org.robolectric.internal.bytecode.ShadowWrangler} to provide library-specific
+ * rules about whether shadow classes and methods should be considered matches.
+ */
+public interface ShadowMatcher {
+  ShadowMatcher MATCH_ALL = new AlwaysTrueShadowMatcher();
+
+  boolean matches(ShadowInfo shadowInfo);
+
+  boolean matches(Method method);
+}
diff --git a/sandbox/src/main/java/org/robolectric/util/Function.java b/sandbox/src/main/java/org/robolectric/util/Function.java
new file mode 100644
index 0000000..49ccad1
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/util/Function.java
@@ -0,0 +1,8 @@
+package org.robolectric.util;
+
+/**
+ * Interface defining a function object.
+ */
+public interface Function<R, T> {
+  R call(Class<?> theClass, T value, Object[] params);
+}
diff --git a/sandbox/src/main/java/org/robolectric/util/JavaVersion.java b/sandbox/src/main/java/org/robolectric/util/JavaVersion.java
new file mode 100644
index 0000000..7f47b0a
--- /dev/null
+++ b/sandbox/src/main/java/org/robolectric/util/JavaVersion.java
@@ -0,0 +1,31 @@
+package org.robolectric.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+
+public class JavaVersion implements Comparable<JavaVersion> {
+  private final List<Integer> versions;
+
+  public JavaVersion(String version) {
+    versions = new ArrayList<>();
+    Scanner s = new Scanner(version).useDelimiter("[^\\d]+");
+    while (s.hasNext()) {
+      versions.add(s.nextInt());
+    }
+  }
+
+  @Override public int compareTo(JavaVersion o) {
+    List<Integer> versions2 = o.versions;
+    int max = Math.min(versions.size(), versions2.size());
+    for (int i = 0; i < max; i++) {
+      int compare = versions.get(i).compareTo(versions2.get(i));
+      if (compare != 0) {
+        return compare;
+      }
+    }
+
+    // Assume longer is newer
+    return Integer.compare(versions.size(), versions2.size());
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/ClassicSuperHandlingTest.java b/sandbox/src/test/java/org/robolectric/ClassicSuperHandlingTest.java
new file mode 100644
index 0000000..0441b9b
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/ClassicSuperHandlingTest.java
@@ -0,0 +1,79 @@
+package org.robolectric;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+
+@RunWith(SandboxTestRunner.class)
+public class ClassicSuperHandlingTest {
+  @Test
+  @SandboxConfig(shadows = {ChildShadow.class, ParentShadow.class, GrandparentShadow.class})
+  public void uninstrumentedSubclassesShouldBeAbleToCallSuperWithoutLooping() throws Exception {
+    assertEquals("4-3s-2s-1s-boof", new BabiesHavingBabies().method("boof"));
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ChildShadow.class, ParentShadow.class, GrandparentShadow.class})
+  public void shadowInvocationWhenAllAreShadowed() throws Exception {
+    assertEquals("3s-2s-1s-boof", new Child().method("boof"));
+    assertEquals("2s-1s-boof", new Parent().method("boof"));
+    assertEquals("1s-boof", new Grandparent().method("boof"));
+  }
+
+  @Implements(Child.class)
+  public static class ChildShadow extends ParentShadow {
+
+    @Override public String method(String value) {
+      return "3s-" + super.method(value);
+    }
+  }
+
+  @Implements(Parent.class)
+  public static class ParentShadow extends GrandparentShadow {
+
+    @Override public String method(String value) {
+      return "2s-" + super.method(value);
+    }
+  }
+
+  @Implements(Grandparent.class)
+  public static class GrandparentShadow {
+
+    public String method(String value) {
+      return "1s-" + value;
+    }
+  }
+
+  private static class BabiesHavingBabies extends Child {
+    @Override
+    public String method(String value) {
+      return "4-" + super.method(value);
+    }
+  }
+
+  @Instrument
+  public static class Child extends Parent {
+    @Override public String method(String value) {
+      throw new RuntimeException("Stub!");
+    }
+  }
+
+  @Instrument
+  public static class Parent extends Grandparent {
+    @Override public String method(String value) {
+      throw new RuntimeException("Stub!");
+    }
+  }
+
+  @Instrument
+  private static class Grandparent {
+    public String method(String value) {
+      throw new RuntimeException("Stub!");
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/JavaVersionTest.java b/sandbox/src/test/java/org/robolectric/JavaVersionTest.java
new file mode 100644
index 0000000..c312d43
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/JavaVersionTest.java
@@ -0,0 +1,51 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.util.JavaVersion;
+
+@RunWith(JUnit4.class)
+public class JavaVersionTest {
+  @Test
+  public void jdk8() {
+    check("1.8.1u40", "1.8.5u60");
+    check("1.8.0u40", "1.8.0u60");
+    check("1.8.0u40", "1.8.0u100");
+  }
+
+  @Test
+  public void jdk9() {
+    check("9.0.1+12", "9.0.2+12");
+    check("9.0.2+60", "9.0.2+100");
+  }
+
+  @Test
+  public void differentJdk() {
+    check("1.7.0", "1.8.0u60");
+    check("1.8.1u40", "9.0.2+12");
+  }
+
+  @Test
+  public void longer() {
+    check("1.8.0", "1.8.0.1");
+  }
+
+  @Test
+  public void longerEquality() {
+    checkEqual("1.8.0", "1.8.0");
+    checkEqual("1.8.0u33", "1.8.0u33");
+    checkEqual("5", "5");
+  }
+
+  private static void check(String v1, String v2) {
+    assertThat(new JavaVersion(v1).compareTo(new JavaVersion(v2))).isLessThan(0);
+  }
+
+  private static void checkEqual(String v1, String v2) {
+    assertThat(new JavaVersion(v1).compareTo(new JavaVersion(v2))).isEqualTo(0);
+  }
+
+}
\ No newline at end of file
diff --git a/sandbox/src/test/java/org/robolectric/RealApisTest.java b/sandbox/src/test/java/org/robolectric/RealApisTest.java
new file mode 100644
index 0000000..88c874a
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/RealApisTest.java
@@ -0,0 +1,44 @@
+package org.robolectric;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.testing.Pony;
+
+@RunWith(SandboxTestRunner.class)
+public class RealApisTest {
+  @Test
+  @SandboxConfig(shadows = {ShimmeryShadowPony.class})
+  public void whenShadowHandlerIsInRealityBasedMode_shouldNotCallRealForUnshadowedMethod() throws Exception {
+    assertEquals("Off I saunter to the salon!", new Pony().saunter("the salon"));
+  }
+
+  @Implements(Pony.class)
+  public static class ShimmeryShadowPony extends Pony.ShadowPony {
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowOfClassWithSomeConstructors.class})
+  public void shouldCallOriginalConstructorBodySomehow() throws Exception {
+    ClassWithSomeConstructors o = new ClassWithSomeConstructors("my name");
+    assertEquals("my name", o.name);
+  }
+
+  @Instrument
+  public static class ClassWithSomeConstructors {
+    public String name;
+
+    public ClassWithSomeConstructors(String name) {
+      this.name = name;
+    }
+  }
+
+  @Implements(ClassWithSomeConstructors.class)
+  public static class ShadowOfClassWithSomeConstructors {
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/RobolectricInternalsTest.java b/sandbox/src/test/java/org/robolectric/RobolectricInternalsTest.java
new file mode 100644
index 0000000..0c74c8d
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/RobolectricInternalsTest.java
@@ -0,0 +1,201 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricInternalsTest.Outer.Inner;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Tests for constructor re-writing in ClassInstrumentor. */
+@SandboxConfig(shadows = {RobolectricInternalsTest.ShadowConstructors.class})
+@RunWith(SandboxTestRunner.class)
+public class RobolectricInternalsTest {
+
+  private static final String PARAM1 = "param1";
+  private static final Byte PARAM2 = (byte) 24;
+  private static final Long PARAM3 = (long) 10122345;
+
+  @Test
+  public void getConstructor_withNoParams() {
+    Constructors a = new Constructors();
+    ShadowConstructors sa = shadowOf(a);
+
+    assertThat(a.constructorCalled).isFalse();
+    assertThat(sa.shadowConstructorCalled).isTrue();
+
+    Shadow.invokeConstructor(Constructors.class, a);
+    assertThat(a.constructorCalled).isTrue();
+  }
+
+  @Test
+  public void getConstructor_withOneClassParam() {
+    Constructors a = new Constructors(PARAM1);
+    ShadowConstructors sa = shadowOf(a);
+
+    assertThat(a.param11).isNull();
+    assertThat(sa.shadowParam11).isEqualTo(PARAM1);
+
+    Shadow.invokeConstructor(Constructors.class, a, ClassParameter.from(String.class, PARAM1));
+    assertThat(a.param11).isEqualTo(PARAM1);
+  }
+
+  @Test
+  public void getConstructor_withTwoClassParams() {
+    Constructors a = new Constructors(PARAM1, PARAM2);
+    ShadowConstructors sa = shadowOf(a);
+
+    assertThat(a.param21).isNull();
+    assertThat(a.param22).isNull();
+    assertThat(sa.shadowParam21).isEqualTo(PARAM1);
+    assertThat(sa.shadowParam22).isEqualTo(PARAM2);
+
+    Shadow.invokeConstructor(
+        Constructors.class,
+        a,
+        ClassParameter.from(String.class, PARAM1),
+        ClassParameter.from(Byte.class, PARAM2));
+    assertThat(a.param21).isEqualTo(PARAM1);
+    assertThat(a.param22).isEqualTo(PARAM2);
+  }
+
+  @Test
+  public void getConstructor_withThreeClassParams() {
+    Constructors a = new Constructors(PARAM1, PARAM2, PARAM3);
+    ShadowConstructors sa = shadowOf(a);
+
+    assertThat(a.param31).isNull();
+    assertThat(a.param32).isNull();
+    assertThat(a.param33).isNull();
+    assertThat(sa.shadowParam31).isEqualTo(PARAM1);
+    assertThat(sa.shadowParam32).isEqualTo(PARAM2);
+    assertThat(sa.shadowParam33).isEqualTo(PARAM3);
+
+    Shadow.invokeConstructor(
+        Constructors.class,
+        a,
+        ClassParameter.from(String.class, PARAM1),
+        ClassParameter.from(Byte.class, PARAM2),
+        ClassParameter.from(Long.class, PARAM3));
+    assertThat(a.param31).isEqualTo(PARAM1);
+    assertThat(a.param32).isEqualTo(PARAM2);
+    assertThat(a.param33).isEqualTo(PARAM3);
+  }
+
+  @Test
+  public void innerClass_referencesOuterClass() {
+    Outer outer = new Outer(PARAM1);
+    Inner inner = outer.new Inner();
+
+    assertThat(inner.getOuterParam()).isEqualTo(PARAM1);
+  }
+
+  private static ShadowConstructors shadowOf(Constructors realObject) {
+    Object shadow = Shadow.extract(realObject);
+    assertThat(shadow).isInstanceOf(ShadowConstructors.class);
+    return (ShadowConstructors) shadow;
+  }
+
+  @Instrument
+  public static class Constructors {
+    public boolean constructorCalled = false;
+    public String param11 = null;
+
+    public String param21 = null;
+    public Byte param22 = null;
+
+    public String param31 = null;
+    public Byte param32 = null;
+    public Long param33 = null;
+
+    public Constructors() {
+      constructorCalled = true;
+    }
+
+    public Constructors(String param) {
+      param11 = param;
+    }
+
+    public Constructors(String param1, Byte param2) {
+      param21 = param1;
+      param22 = param2;
+    }
+
+    public Constructors(String param1, Byte param2, Long param3) {
+      param31 = param1;
+      param32 = param2;
+      param33 = param3;
+    }
+  }
+
+  @Implements(Constructors.class)
+  public static class ShadowConstructors {
+    public boolean shadowConstructorCalled = false;
+    public String shadowParam11 = null;
+
+    public String shadowParam21 = null;
+    public Byte shadowParam22 = null;
+
+    public String shadowParam31 = null;
+    public Byte shadowParam32 = null;
+    public Long shadowParam33 = null;
+
+    @Implementation
+    protected void __constructor__() {
+      shadowConstructorCalled = true;
+    }
+
+    @Implementation
+    protected void __constructor__(String param) {
+      shadowParam11 = param;
+    }
+
+    @Implementation
+    protected void __constructor__(String param1, Byte param2) {
+      shadowParam21 = param1;
+      shadowParam22 = param2;
+    }
+
+    @Implementation
+    protected void __constructor__(String param1, Byte param2, Long param3) {
+      shadowParam31 = param1;
+      shadowParam32 = param2;
+      shadowParam33 = param3;
+    }
+  }
+
+  @Instrument
+  static class Outer {
+    public String outerParam = null;
+
+    public Outer(String param) {
+      this.outerParam = param;
+    }
+
+    @Instrument
+    abstract static class AbstractInner {
+      public String innerParam;
+
+      AbstractInner() {
+        this.innerParam = getOuterParam();
+      }
+
+      abstract String getOuterParam();
+    }
+
+    @Instrument
+    class Inner extends AbstractInner {
+
+      @Override
+      String getOuterParam() {
+        return outerParam;
+      }
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/ShadowConstructorTest.java b/sandbox/src/test/java/org/robolectric/ShadowConstructorTest.java
new file mode 100644
index 0000000..94c1ae8
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/ShadowConstructorTest.java
@@ -0,0 +1,104 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ShadowConstructorTest.ShadowBar;
+import org.robolectric.ShadowConstructorTest.ShadowFoo;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Tests for some nuances of constructor shadowing, particularly when shadowing constructor
+ * hierarchies.
+ */
+@RunWith(SandboxTestRunner.class)
+@SandboxConfig(shadows = {ShadowFoo.class, ShadowBar.class})
+public class ShadowConstructorTest {
+  @Test
+  public void constructorShadows_invokesAllConstructors() {
+    Bar b = new Bar(10);
+    assertThat(b.a).isEqualTo(1);
+    assertThat(b.b).isEqualTo(11);
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowFooWithStaticConstructor.class})
+  public void staticConstructor_isNotAllowed() {
+    BootstrapMethodError e = assertThrows(BootstrapMethodError.class, () -> new Foo(1));
+    assertThat(e)
+        .hasCauseThat()
+        .hasMessageThat()
+        .isEqualTo("static __constructor__ shadow methods are not supported");
+  }
+
+  @Instrument
+  static class Foo {
+    int a;
+
+    Foo() {}
+
+    Foo(int a) {
+      this.a = a;
+    }
+  }
+
+  @Instrument
+  static class Bar extends Foo {
+    int b;
+
+    Bar(int b) {
+      super(1);
+      this.b = b + this.a;
+    }
+  }
+
+  /**
+   * Shadow for {@link org.robolectric.ShadowConstructorTest.Foo}
+   *
+   * <p>This just shadows {@link Foo#Foo(int)} to invoke the original constructor.
+   */
+  @Implements(Foo.class)
+  public static class ShadowFoo {
+    @RealObject protected Foo realFoo;
+
+    @Implementation
+    protected void __constructor__(int a) {
+      invokeConstructor(Foo.class, realFoo, ClassParameter.from(int.class, a));
+    }
+  }
+
+  /**
+   * Shadow for {@link org.robolectric.ShadowConstructorTest.Bar}
+   *
+   * <p>Similar to {@link ShadowFoo}, this just shadows {@link Bar#Bar(int)} and invokes the
+   * original.
+   */
+  @Implements(Bar.class)
+  public static class ShadowBar extends ShadowFoo {
+    @RealObject protected Bar realBar;
+
+    @Implementation
+    @Override
+    protected void __constructor__(int b) {
+      invokeConstructor(Bar.class, realBar, ClassParameter.from(int.class, b));
+    }
+  }
+
+  /** Shadow for {@link org.robolectric.ShadowConstructorTest.Foo} */
+  @Implements(Foo.class)
+  public static class ShadowFooWithStaticConstructor {
+    @RealObject protected Foo realFoo;
+
+    @Implementation
+    protected static void __constructor__(int a) {}
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/ShadowWranglerIntegrationTest.java b/sandbox/src/test/java/org/robolectric/ShadowWranglerIntegrationTest.java
new file mode 100644
index 0000000..b14137f
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/ShadowWranglerIntegrationTest.java
@@ -0,0 +1,416 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.internal.bytecode.ShadowWrangler;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.testing.Foo;
+import org.robolectric.testing.ShadowFoo;
+
+@RunWith(SandboxTestRunner.class)
+public class ShadowWranglerIntegrationTest {
+
+  private static final boolean YES = true;
+
+  private String name;
+
+  @Before
+  public void setUp() throws Exception {
+    name = "context";
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowForAClassWithDefaultConstructor_HavingNoConstructorDelegate.class})
+  public void testConstructorInvocation_WithDefaultConstructorAndNoConstructorDelegateOnShadowClass() throws Exception {
+    AClassWithDefaultConstructor instance = new AClassWithDefaultConstructor();
+    assertThat(Shadow.<Object>extract(instance)).isInstanceOf(ShadowForAClassWithDefaultConstructor_HavingNoConstructorDelegate.class);
+    assertThat(instance.initialized).isTrue();
+  }
+
+  @Test
+  @SandboxConfig(shadows = { ShadowFoo.class })
+  public void testConstructorInvocation() throws Exception {
+    Foo foo = new Foo(name);
+    assertSame(name, shadowOf(foo).name);
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowFoo.class})
+  public void testRealObjectAnnotatedFieldsAreSetBeforeConstructorIsCalled() throws Exception {
+    Foo foo = new Foo(name);
+    assertSame(name, shadowOf(foo).name);
+    assertSame(foo, shadowOf(foo).realFooField);
+
+    assertSame(foo, shadowOf(foo).realFooInConstructor);
+    assertSame(foo, shadowOf(foo).realFooInParentConstructor);
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowFoo.class})
+  public void testMethodDelegation() throws Exception {
+    Foo foo = new Foo(name);
+    assertSame(name, foo.getName());
+  }
+
+  @Test
+  @SandboxConfig(shadows = {WithEquals.class})
+  public void testEqualsMethodDelegation() throws Exception {
+    Foo foo1 = new Foo(name);
+    Foo foo2 = new Foo(name);
+    assertEquals(foo1, foo2);
+  }
+
+  @Test
+  @SandboxConfig(shadows = {WithEquals.class})
+  public void testHashCodeMethodDelegation() throws Exception {
+    Foo foo = new Foo(name);
+    assertEquals(42, foo.hashCode());
+  }
+
+  @Test
+  @SandboxConfig(shadows = {WithToString.class})
+  public void testToStringMethodDelegation() throws Exception {
+    Foo foo = new Foo(name);
+    assertEquals("the expected string", foo.toString());
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowFoo.class})
+  public void testShadowSelectionSearchesSuperclasses() throws Exception {
+    TextFoo textFoo = new TextFoo(name);
+    assertEquals(ShadowFoo.class, Shadow.extract(textFoo).getClass());
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowFoo.class, ShadowTextFoo.class})
+  public void shouldUseMostSpecificShadow() throws Exception {
+    TextFoo textFoo = new TextFoo(name);
+    assertThat(shadowOf(textFoo)).isInstanceOf(ShadowTextFoo.class);
+  }
+
+  @Test
+  public void testPrimitiveArrays() throws Exception {
+    Class<?> objArrayClass = ShadowWrangler.loadClass("java.lang.Object[]", getClass().getClassLoader());
+    assertTrue(objArrayClass.isArray());
+    assertEquals(Object.class, objArrayClass.getComponentType());
+
+    Class<?> intArrayClass = ShadowWrangler.loadClass("int[]", getClass().getClassLoader());
+    assertTrue(intArrayClass.isArray());
+    assertEquals(Integer.TYPE, intArrayClass.getComponentType());
+  }
+
+  @Test
+  @SandboxConfig(shadows = ShadowThrowInShadowMethod.class)
+  public void shouldRemoveNoiseFromShadowedStackTraces() throws Exception {
+    ThrowInShadowMethod instance = new ThrowInShadowMethod();
+
+    Exception e = null;
+    try {
+      instance.method();
+    } catch (Exception e1) {
+      e = e1;
+    }
+
+    assertNotNull(e);
+    assertEquals(IOException.class, e.getClass());
+    assertEquals("fake exception", e.getMessage());
+    StackTraceElement[] stackTrace = e.getStackTrace();
+
+    assertThat(stackTrace[0].getClassName()).isEqualTo(ShadowThrowInShadowMethod.class.getName());
+    assertThat(stackTrace[0].getMethodName()).isEqualTo("method");
+    assertThat(stackTrace[0].getLineNumber()).isGreaterThan(0);
+
+    assertThat(stackTrace[1].getClassName()).isEqualTo(ThrowInShadowMethod.class.getName());
+    assertThat(stackTrace[1].getMethodName()).isEqualTo("method");
+    assertThat(stackTrace[1].getLineNumber()).isLessThan(0);
+
+    assertThat(stackTrace[2].getClassName()).isEqualTo(ShadowWranglerIntegrationTest.class.getName());
+    assertThat(stackTrace[2].getMethodName()).isEqualTo("shouldRemoveNoiseFromShadowedStackTraces");
+    assertThat(stackTrace[2].getLineNumber()).isGreaterThan(0);
+  }
+
+  @Instrument
+  public static class ThrowInShadowMethod {
+    public void method() throws IOException {
+    }
+  }
+
+  @Implements(ThrowInShadowMethod.class)
+  public static class ShadowThrowInShadowMethod {
+    public void method() throws IOException {
+      throw new IOException("fake exception");
+    }
+  }
+
+
+  @Test
+  @SandboxConfig(shadows = ShadowThrowInRealMethod.class)
+  public void shouldRemoveNoiseFromUnshadowedStackTraces() throws Exception {
+    ThrowInRealMethod instance = new ThrowInRealMethod();
+
+    Exception e = null;
+    try {
+      instance.method();
+    } catch (Exception e1) {
+      e = e1;
+    }
+
+    assertNotNull(e);
+    assertEquals(IOException.class, e.getClass());
+    assertEquals("fake exception", e.getMessage());
+    StackTraceElement[] stackTrace = e.getStackTrace();
+
+    assertThat(stackTrace[0].getClassName()).isEqualTo(ThrowInRealMethod.class.getName());
+    assertThat(stackTrace[0].getMethodName()).isEqualTo("method");
+    assertThat(stackTrace[0].getLineNumber()).isGreaterThan(0);
+
+    assertThat(stackTrace[1].getClassName()).isEqualTo(ShadowWranglerIntegrationTest.class.getName());
+    assertThat(stackTrace[1].getMethodName()).isEqualTo("shouldRemoveNoiseFromUnshadowedStackTraces");
+    assertThat(stackTrace[1].getLineNumber()).isGreaterThan(0);
+  }
+
+  @Instrument
+  public static class ThrowInRealMethod {
+    public void method() throws IOException {
+      throw new IOException("fake exception");
+    }
+  }
+
+  @Implements(ThrowInRealMethod.class)
+  public static class ShadowThrowInRealMethod {
+  }
+
+  @Test @SandboxConfig(shadows = {Shadow2OfChild.class, ShadowOfParent.class})
+  public void whenShadowMethodIsOverriddenInShadowWithSameShadowedClass_shouldUseOverriddenMethod() throws Exception {
+    assertThat(new Child().get()).isEqualTo("get from Shadow2OfChild");
+  }
+
+  @Test @SandboxConfig(shadows = {Shadow22OfChild.class, ShadowOfParent.class})
+  public void whenShadowMethodIsNotOverriddenInShadowWithSameShadowedClass_shouldUseOverriddenMethod() throws Exception {
+    assertThat(new Child().get()).isEqualTo("get from Shadow2OfChild");
+  }
+
+  @Test @SandboxConfig(shadows = {Shadow3OfChild.class, ShadowOfParent.class})
+  public void whenShadowMethodIsOverriddenInShadowOfAnotherClass_shouldNotUseShadowSuperclassMethods() throws Exception {
+    assertThat(new Child().get()).isEqualTo("from child (from shadow of parent)");
+  }
+
+  @Test @SandboxConfig(shadows = {ShadowOfParentWithPackageImpl.class})
+  public void whenShadowMethodIsntCorrectlyVisible_shouldNotUseShadowMethods() throws Exception {
+    assertThat(new Parent().get()).isEqualTo("from parent");
+  }
+
+  @Instrument
+  public static class Parent {
+    public String get() {
+      return "from parent";
+    }
+  }
+
+  @Instrument
+  public static class Child extends Parent {
+    @Override
+    public String get() {
+      return "from child (" + super.get() + ")";
+    }
+  }
+
+  @Implements(Parent.class)
+  public static class ShadowOfParent {
+    @Implementation
+    protected String get() {
+      return "from shadow of parent";
+    }
+  }
+
+  @Implements(Parent.class)
+  public static class ShadowOfParentWithPackageImpl {
+    @Implementation
+    String get() {
+      return "from ShadowOfParentWithPackageImpl";
+    }
+  }
+
+  @Implements(value = Child.class)
+  public static class ShadowOfChild extends ShadowOfParent {
+    @Implementation
+    @Override
+    protected String get() {
+      return "get from ShadowOfChild";
+    }
+  }
+
+  @Implements(value = Child.class)
+  public static class Shadow2OfChild extends ShadowOfChild {
+    @Implementation
+    @Override
+    protected String get() {
+      return "get from Shadow2OfChild";
+    }
+  }
+
+  @Implements(value = Child.class)
+  public static class Shadow22OfChild extends Shadow2OfChild {
+  }
+
+  public static class SomethingOtherThanChild extends Child {
+  }
+
+  @Implements(value = SomethingOtherThanChild.class)
+  public static class Shadow3OfChild extends ShadowOfChild {
+    @Implementation
+    @Override
+    protected String get() {
+      return "get from Shadow3OfChild";
+    }
+  }
+
+  private ShadowFoo shadowOf(Foo foo) {
+    return (ShadowFoo) Shadow.extract(foo);
+  }
+
+  private ShadowTextFoo shadowOf(TextFoo foo) {
+    return (ShadowTextFoo) Shadow.extract(foo);
+  }
+
+  @Implements(Foo.class)
+  public static class WithEquals {
+    @Implementation
+    protected void __constructor__(String s) {
+    }
+
+    @Override
+    @Implementation
+    public boolean equals(Object o) {
+      return true;
+    }
+
+    @Override
+    @Implementation
+    public int hashCode() {
+      return 42;
+    }
+
+  }
+
+  @Implements(Foo.class)
+  public static class WithToString {
+    @Implementation
+    protected void __constructor__(String s) {
+    }
+
+    @Override
+    @Implementation
+    public String toString() {
+      return "the expected string";
+    }
+  }
+
+  @Implements(TextFoo.class)
+  public static class ShadowTextFoo extends ShadowFoo {
+  }
+
+  @Instrument
+  public static class TextFoo extends Foo {
+    public TextFoo(String s) {
+      super(s);
+    }
+  }
+
+  @Instrument
+  public static class AClassWithDefaultConstructor {
+    public boolean initialized;
+
+    public AClassWithDefaultConstructor() {
+      initialized = true;
+    }
+  }
+
+  @Implements(AClassWithDefaultConstructor.class)
+  public static class ShadowForAClassWithDefaultConstructor_HavingNoConstructorDelegate {
+  }
+
+  @SandboxConfig(shadows = ShadowAClassWithDifficultArgs.class)
+  @Test public void shouldAllowLooseSignatureMatches() throws Exception {
+    assertThat(new AClassWithDifficultArgs().aMethod("bc")).isEqualTo("abc");
+  }
+
+  @Implements(value = AClassWithDifficultArgs.class, looseSignatures = true)
+  public static class ShadowAClassWithDifficultArgs {
+    @Implementation
+    protected Object aMethod(Object s) {
+      return "a" + s;
+    }
+  }
+
+  @Instrument
+  public static class AClassWithDifficultArgs {
+    public CharSequence aMethod(CharSequence s) {
+      return s;
+    }
+  }
+
+  @Test @SandboxConfig(shadows = ShadowOfAClassWithStaticInitializer.class)
+  public void classesWithInstrumentedShadowsDontDoubleInitialize() throws Exception {
+    // if we didn't reject private shadow methods, __staticInitializer__ on the shadow
+    // would be executed twice.
+    new AClassWithStaticInitializer();
+    assertThat(ShadowOfAClassWithStaticInitializer.initCount).isEqualTo(1);
+    assertThat(AClassWithStaticInitializer.initCount).isEqualTo(1);
+  }
+
+  @Instrument
+  public static class AClassWithStaticInitializer {
+    static int initCount;
+    static {
+      initCount++;
+    }
+  }
+
+  @Instrument // because it's fairly common that people accidentally instrument their own shadows
+  @Implements(AClassWithStaticInitializer.class)
+  public static class ShadowOfAClassWithStaticInitializer {
+    static int initCount;
+    static {
+      initCount++;
+    }
+  }
+
+  @Test @SandboxConfig(shadows = Shadow22OfAClassWithBrokenStaticInitializer.class)
+  public void staticInitializerShadowMethodsObeySameRules() throws Exception {
+    new AClassWithBrokenStaticInitializer();
+  }
+
+  @Instrument
+  public static class AClassWithBrokenStaticInitializer {
+    static {
+      if (YES) throw new RuntimeException("broken!");
+    }
+  }
+
+  @Implements(AClassWithBrokenStaticInitializer.class)
+  public static class Shadow2OfAClassWithBrokenStaticInitializer {
+    @Implementation
+    protected static void __staticInitializer__() {
+      // don't call real static initializer
+    }
+  }
+
+  @Implements(AClassWithBrokenStaticInitializer.class)
+  public static class Shadow22OfAClassWithBrokenStaticInitializer
+      extends Shadow2OfAClassWithBrokenStaticInitializer {
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/ShadowingTest.java b/sandbox/src/test/java/org/robolectric/ShadowingTest.java
new file mode 100644
index 0000000..6d22100
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/ShadowingTest.java
@@ -0,0 +1,250 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.internal.bytecode.ShadowConstants;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.testing.AnUninstrumentedClass;
+import org.robolectric.testing.Pony;
+
+@RunWith(SandboxTestRunner.class)
+public class ShadowingTest {
+
+  @Test
+  @SandboxConfig(shadows = {ShadowAccountManagerForTests.class})
+  public void testStaticMethodsAreDelegated() throws Exception {
+    Object arg = mock(Object.class);
+    AccountManager.get(arg);
+    assertThat(ShadowAccountManagerForTests.wasCalled).isTrue();
+    assertThat(ShadowAccountManagerForTests.arg).isSameInstanceAs(arg);
+  }
+
+  @Implements(AccountManager.class)
+  public static class ShadowAccountManagerForTests {
+    public static boolean wasCalled = false;
+    public static Object arg;
+
+    public static AccountManager get(Object arg) {
+      wasCalled = true;
+      ShadowAccountManagerForTests.arg = arg;
+      return mock(AccountManager.class);
+    }
+  }
+
+  static class Context {
+  }
+
+  static class AccountManager {
+    public static AccountManager get(Object arg) {
+      return null;
+    }
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowClassWithProtectedMethod.class})
+  public void testProtectedMethodsAreDelegated() throws Exception {
+    ClassWithProtectedMethod overlay = new ClassWithProtectedMethod();
+    assertEquals("shadow name", overlay.getName());
+  }
+
+  @Implements(ClassWithProtectedMethod.class)
+  public static class ShadowClassWithProtectedMethod {
+    @Implementation
+    protected String getName() {
+      return "shadow name";
+    }
+  }
+
+  @Instrument
+  public static class ClassWithProtectedMethod {
+    protected String getName() {
+      return "protected name";
+    }
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowPaintForTests.class})
+  public void testNativeMethodsAreDelegated() throws Exception {
+    Paint paint = new Paint();
+    paint.setColor(1234);
+
+    assertThat(paint.getColor()).isEqualTo(1234);
+  }
+
+  @Instrument
+  static class Paint {
+    public native void setColor(int color);
+    public native int getColor();
+  }
+
+  @Implements(Paint.class)
+  public static class ShadowPaintForTests {
+    private int color;
+
+    @Implementation
+    protected void setColor(int color) {
+      this.color = color;
+    }
+
+    @Implementation
+    protected int getColor() {
+      return color;
+    }
+  }
+
+  @Implements(ClassWithNoDefaultConstructor.class)
+  public static class ShadowForClassWithNoDefaultConstructor {
+    public static boolean shadowDefaultConstructorCalled = false;
+    public static boolean shadowDefaultConstructorImplementorCalled = false;
+
+    public ShadowForClassWithNoDefaultConstructor() {
+      shadowDefaultConstructorCalled = true;
+    }
+
+    @Implementation
+    protected void __constructor__() {
+      shadowDefaultConstructorImplementorCalled = true;
+    }
+  }
+
+  @Instrument @SuppressWarnings({"UnusedDeclaration"})
+  public static class ClassWithNoDefaultConstructor {
+    ClassWithNoDefaultConstructor(String string) {
+    }
+  }
+
+  @Test
+  @SandboxConfig(shadows = {Pony.ShadowPony.class})
+  public void directlyOn_shouldCallThroughToOriginalMethodBody() throws Exception {
+    Pony pony = new Pony();
+
+    assertEquals("Fake whinny! You're on my neck!", pony.ride("neck"));
+    assertEquals("Whinny! You're on my neck!", Shadow.directlyOn(pony, Pony.class).ride("neck"));
+
+    assertEquals("Fake whinny! You're on my haunches!", pony.ride("haunches"));
+  }
+
+  @Test
+  @SandboxConfig(shadows = {Pony.ShadowPony.class})
+  public void shouldCallRealForUnshadowedMethod() throws Exception {
+    assertEquals("Off I saunter to the salon!", new Pony().saunter("the salon"));
+  }
+
+  static class TextView {
+  }
+
+  static class ColorStateList {
+    public ColorStateList(int[][] ints, int[] ints1) {
+    }
+  }
+
+  static class TypedArray {
+  }
+
+  @Implements(TextView.class)
+  public static class TextViewWithDummyGetTextColorsMethod {
+    public static ColorStateList getTextColors(Context context, TypedArray attrs) {
+      return new ColorStateList(new int[0][0], new int[0]);
+    }
+  }
+
+  @Test
+  @SandboxConfig(shadows = ShadowOfClassWithSomeConstructors.class)
+  public void shouldGenerateSeparatedConstructorBodies() throws Exception {
+    ClassWithSomeConstructors o = new ClassWithSomeConstructors("my name");
+    assertNull(o.name);
+
+    Method realConstructor = o.getClass().getDeclaredMethod(ShadowConstants.CONSTRUCTOR_METHOD_NAME, String.class);
+    realConstructor.setAccessible(true);
+    realConstructor.invoke(o, "my name");
+    assertEquals("my name", o.name);
+  }
+
+  @Instrument
+  public static class ClassWithSomeConstructors {
+    public String name;
+
+    public ClassWithSomeConstructors(String name) {
+      this.name = name;
+    }
+  }
+
+  @Implements(ClassWithSomeConstructors.class)
+  public static class ShadowOfClassWithSomeConstructors {
+    @Implementation
+    protected void __constructor__(String s) {
+    }
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowApiImplementedClass.class})
+  public void withNonApiSubclassesWhichExtendApi_shouldStillBeInvoked() throws Exception {
+    assertEquals("did foo", new NonApiSubclass().doSomething("foo"));
+  }
+
+  public static class NonApiSubclass extends ApiImplementedClass {
+    public String doSomething(String value) {
+      return "did " + value;
+    }
+  }
+
+  @Instrument
+  public static class ApiImplementedClass {
+  }
+
+  @Implements(ApiImplementedClass.class)
+  public static class ShadowApiImplementedClass {
+  }
+
+  @Test
+  public void shouldNotInstrumentClassIfNotAddedToConfig() {
+    assertEquals(1, new NonInstrumentedClass().plus(0));
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowNonInstrumentedClass.class})
+  public void shouldInstrumentClassIfAddedToConfig() {
+    assertEquals(2, new NonInstrumentedClass().plus(0));
+  }
+
+  public static class NonInstrumentedClass {
+    public int plus(int x) {
+      return x + 1;
+    }
+  }
+
+  @Implements(NonInstrumentedClass.class)
+  public static class ShadowNonInstrumentedClass {
+    @Implementation
+    protected int plus(int x) {
+      return x + 2;
+    }
+  }
+
+  @Test
+  public void shouldNotInstrumentPackageIfNotAddedToConfig() throws Exception {
+    Class<?> clazz = Class.forName(AnUninstrumentedClass.class.getName());
+    assertTrue(Modifier.isFinal(clazz.getModifiers()));
+  }
+
+  @Test
+  @SandboxConfig(instrumentedPackages = {"org.robolectric.testing"})
+  public void shouldInstrumentPackageIfAddedToConfig() throws Exception {
+    Class<?> clazz = Class.forName(AnUninstrumentedClass.class.getName());
+    assertFalse(Modifier.isFinal(clazz.getModifiers()));
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/StaticInitializerTest.java b/sandbox/src/test/java/org/robolectric/StaticInitializerTest.java
new file mode 100644
index 0000000..d1ea48c
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/StaticInitializerTest.java
@@ -0,0 +1,69 @@
+package org.robolectric;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.RobolectricInternals;
+import org.robolectric.internal.bytecode.SandboxConfig;
+
+@RunWith(SandboxTestRunner.class)
+public class StaticInitializerTest {
+  @Test
+  public void whenClassIsUnshadowed_shouldPerformStaticInitialization() throws Exception {
+    assertEquals("Floyd", ClassWithStaticInitializerA.name);
+  }
+
+  @Instrument
+  public static class ClassWithStaticInitializerA {
+    static String name = "Floyd";
+  }
+
+
+  @Test
+  @SandboxConfig(shadows = {ShadowClassWithoutStaticInitializerOverride.class})
+  public void whenClassHasShadowWithoutOverrideMethod_shouldPerformStaticInitialization() throws Exception {
+    assertEquals("Floyd", ClassWithStaticInitializerB.name);
+
+    RobolectricInternals.performStaticInitialization(ClassWithStaticInitializerB.class);
+    assertEquals("Floyd", ClassWithStaticInitializerB.name);
+  }
+
+  @Instrument public static class ClassWithStaticInitializerB {
+    public static String name = "Floyd";
+  }
+
+  @Implements(ClassWithStaticInitializerB.class) public static class ShadowClassWithoutStaticInitializerOverride {
+  }
+
+  @Test
+  @SandboxConfig(shadows = {ShadowClassWithStaticInitializerOverride.class})
+  public void whenClassHasShadowWithOverrideMethod_shouldDeferStaticInitialization() throws Exception {
+    assertFalse(ShadowClassWithStaticInitializerOverride.initialized);
+    assertEquals(null, ClassWithStaticInitializerC.name);
+    assertTrue(ShadowClassWithStaticInitializerOverride.initialized);
+
+    RobolectricInternals.performStaticInitialization(ClassWithStaticInitializerC.class);
+    assertEquals("Floyd", ClassWithStaticInitializerC.name);
+  }
+
+  @Instrument public static class ClassWithStaticInitializerC {
+    public static String name = "Floyd";
+  }
+
+  @Implements(ClassWithStaticInitializerC.class)
+  public static class ShadowClassWithStaticInitializerOverride {
+    public static boolean initialized = false;
+
+    @Implementation
+    protected static void __staticInitializer__() {
+      initialized = true;
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/ThreadSafetyTest.java b/sandbox/src/test/java/org/robolectric/ThreadSafetyTest.java
new file mode 100644
index 0000000..d09b308
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/ThreadSafetyTest.java
@@ -0,0 +1,53 @@
+package org.robolectric;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.lang.reflect.Field;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.internal.Instrument;
+import org.robolectric.internal.SandboxTestRunner;
+import org.robolectric.internal.bytecode.SandboxConfig;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(SandboxTestRunner.class)
+public class ThreadSafetyTest {
+  @Test
+  @SandboxConfig(shadows = {InstrumentedThreadShadow.class})
+  public void shadowCreationShouldBeThreadsafe() throws Exception {
+    Field field = InstrumentedThread.class.getDeclaredField("shadowFromOtherThread");
+    field.setAccessible(true);
+
+    for (int i = 0; i < 100; i++) { // :-(
+      InstrumentedThread instrumentedThread = new InstrumentedThread();
+      instrumentedThread.start();
+      Object shadowFromThisThread = Shadow.extract(instrumentedThread);
+
+      instrumentedThread.join();
+      Object shadowFromOtherThread = field.get(instrumentedThread);
+      assertThat(shadowFromThisThread).isSameInstanceAs(shadowFromOtherThread);
+    }
+  }
+
+  @Instrument
+  public static class InstrumentedThread extends Thread {
+    InstrumentedThreadShadow shadowFromOtherThread;
+
+    @Override
+    public void run() {
+      shadowFromOtherThread = Shadow.extract(this);
+    }
+  }
+
+  @Implements(InstrumentedThread.class)
+  public static class InstrumentedThreadShadow {
+    @RealObject InstrumentedThread realObject;
+    @Implementation
+    protected void run() {
+      Shadow.directlyOn(realObject, InstrumentedThread.class, "run");
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/internal/bytecode/ClassValueMapTest.java b/sandbox/src/test/java/org/robolectric/internal/bytecode/ClassValueMapTest.java
new file mode 100644
index 0000000..64eccc3
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/internal/bytecode/ClassValueMapTest.java
@@ -0,0 +1,52 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link ClassValueMap} */
+@RunWith(JUnit4.class)
+public class ClassValueMapTest {
+
+  private final ClassValueMap<String> map =
+      new ClassValueMap<String>() {
+        @Override
+        protected String computeValue(Class<?> type) {
+          return type.toString();
+        }
+      };
+
+  @Test
+  @SuppressWarnings("FutureReturnValueIgnored")
+  public void testConcurrency() throws Exception {
+    Random r = new Random();
+    ExecutorService executor = Executors.newFixedThreadPool(4);
+    int n = 10000;
+    CountDownLatch latch = new CountDownLatch(n);
+    AtomicInteger failures = new AtomicInteger(0);
+    for (int i = 0; i < n; i++) {
+      executor.submit(
+          () -> {
+            latch.countDown();
+            if (r.nextInt(2) == 0) {
+              if (map.get(Object.class) == null) {
+                failures.incrementAndGet();
+              }
+            } else {
+              // Simulate GC of weak references
+              map.clear();
+            }
+          });
+    }
+    latch.await();
+    executor.shutdown();
+    assertThat(failures.get()).isEqualTo(0);
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/internal/bytecode/ProxyMakerTest.java b/sandbox/src/test/java/org/robolectric/internal/bytecode/ProxyMakerTest.java
new file mode 100644
index 0000000..ee74506
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/internal/bytecode/ProxyMakerTest.java
@@ -0,0 +1,68 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ProxyMakerTest {
+  private static final ProxyMaker.MethodMapper IDENTITY_NAME = new ProxyMaker.MethodMapper() {
+    @Override public String getName(String className, String methodName) {
+      return methodName;
+    }
+  };
+
+  @Test
+  public void proxyCall() {
+    ProxyMaker maker = new ProxyMaker(IDENTITY_NAME);
+
+    Thing mock = mock(Thing.class);
+    Thing proxy = maker.createProxyFactory(Thing.class).createProxy(Thing.class, mock);
+    assertThat(proxy.getClass()).isNotSameInstanceAs(Thing.class);
+
+    proxy.returnNothing();
+    verify(mock).returnNothing();
+
+    when(mock.returnInt()).thenReturn(42);
+    assertThat(proxy.returnInt()).isEqualTo(42);
+    verify(mock).returnInt();
+
+    proxy.argument("hello");
+    verify(mock).argument("hello");
+  }
+
+  @Test
+  public void cachesProxyClass() {
+    ProxyMaker maker = new ProxyMaker(IDENTITY_NAME);
+    Thing thing1 = mock(Thing.class);
+    Thing thing2 = mock(Thing.class);
+
+    Thing proxy1 = maker.createProxy(Thing.class, thing1);
+    Thing proxy2 = maker.createProxy(Thing.class, thing2);
+
+    assertThat(proxy1.getClass()).isSameInstanceAs(proxy2.getClass());
+  }
+
+  public static class Thing {
+    public Thing() {
+      throw new UnsupportedOperationException();
+    }
+
+    public void returnNothing() {
+      throw new UnsupportedOperationException();
+    }
+
+    public int returnInt() {
+      throw new UnsupportedOperationException();
+    }
+
+    public void argument(String arg) {
+      throw new UnsupportedOperationException();
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/internal/bytecode/SandboxClassLoaderTest.java b/sandbox/src/test/java/org/robolectric/internal/bytecode/SandboxClassLoaderTest.java
new file mode 100644
index 0000000..016e883
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/internal/bytecode/SandboxClassLoaderTest.java
@@ -0,0 +1,729 @@
+package org.robolectric.internal.bytecode;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.lang.invoke.MethodHandles.constant;
+import static java.lang.invoke.MethodHandles.dropArguments;
+import static java.lang.invoke.MethodHandles.insertArguments;
+import static java.lang.invoke.MethodType.methodType;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.robolectric.util.ReflectionHelpers.newInstance;
+import static org.robolectric.util.ReflectionHelpers.setStaticField;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.SwitchPoint;
+import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.testing.AChild;
+import org.robolectric.testing.AClassThatCallsAMethodReturningAForgettableClass;
+import org.robolectric.testing.AClassThatExtendsAClassWithFinalEqualsHashCode;
+import org.robolectric.testing.AClassThatRefersToAForgettableClass;
+import org.robolectric.testing.AClassThatRefersToAForgettableClassInItsConstructor;
+import org.robolectric.testing.AClassThatRefersToAForgettableClassInMethodCalls;
+import org.robolectric.testing.AClassToForget;
+import org.robolectric.testing.AClassToRemember;
+import org.robolectric.testing.AClassWithEqualsHashCodeToString;
+import org.robolectric.testing.AClassWithFunnyConstructors;
+import org.robolectric.testing.AClassWithMethodReturningArray;
+import org.robolectric.testing.AClassWithMethodReturningBoolean;
+import org.robolectric.testing.AClassWithMethodReturningDouble;
+import org.robolectric.testing.AClassWithMethodReturningInteger;
+import org.robolectric.testing.AClassWithNativeMethod;
+import org.robolectric.testing.AClassWithNativeMethodReturningPrimitive;
+import org.robolectric.testing.AClassWithNoDefaultConstructor;
+import org.robolectric.testing.AClassWithStaticMethod;
+import org.robolectric.testing.AFinalClass;
+import org.robolectric.testing.AnEnum;
+import org.robolectric.testing.AnExampleClass;
+import org.robolectric.testing.AnInstrumentedChild;
+import org.robolectric.testing.AnUninstrumentedClass;
+import org.robolectric.testing.AnUninstrumentedParent;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Util;
+
+@RunWith(JUnit4.class)
+public class SandboxClassLoaderTest {
+
+  private ClassLoader classLoader;
+  private List<String> transcript = new ArrayList<>();
+  private MyClassHandler classHandler = new MyClassHandler(transcript);
+  private ShadowImpl shadow;
+
+  @Before
+  public void setUp() throws Exception {
+    shadow = new ShadowImpl();
+  }
+
+  @Test
+  public void shouldMakeClassesNonFinal() throws Exception {
+    Class<?> clazz = loadClass(AFinalClass.class);
+    assertEquals(0, clazz.getModifiers() & Modifier.FINAL);
+  }
+
+  @Test
+  public void forClassesWithNoDefaultConstructor_shouldCreateOneButItShouldNotCallShadow()
+      throws Exception {
+    Constructor<?> defaultCtor = loadClass(AClassWithNoDefaultConstructor.class).getConstructor();
+    assertTrue(Modifier.isPublic(defaultCtor.getModifiers()));
+    defaultCtor.setAccessible(true);
+    Object instance = defaultCtor.newInstance();
+    assertThat((Object) shadow.extract(instance)).isNotNull();
+    assertThat(transcript).isEmpty();
+  }
+
+  @Test
+  public void shouldDelegateToHandlerForConstructors() throws Exception {
+    Class<?> clazz = loadClass(AClassWithNoDefaultConstructor.class);
+    Constructor<?> ctor = clazz.getDeclaredConstructor(String.class);
+    assertTrue(Modifier.isPublic(ctor.getModifiers()));
+    ctor.setAccessible(true);
+    Object instance = ctor.newInstance("new one");
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithNoDefaultConstructor.__constructor__(java.lang.String new"
+                + " one)");
+
+    Field nameField = clazz.getDeclaredField("name");
+    nameField.setAccessible(true);
+    assertNull(nameField.get(instance));
+  }
+
+  @Test
+  public void shouldDelegateClassLoadForUnacquiredClasses() throws Exception {
+    InstrumentationConfiguration config = mock(InstrumentationConfiguration.class);
+    when(config.shouldAcquire(anyString())).thenReturn(false);
+    when(config.shouldInstrument(any(ClassDetails.class))).thenReturn(false);
+    ClassLoader classLoader = new SandboxClassLoader(config);
+    Class<?> exampleClass = classLoader.loadClass(AnExampleClass.class.getName());
+    assertSame(getClass().getClassLoader(), exampleClass.getClassLoader());
+  }
+
+  @Test
+  public void shouldPerformClassLoadForAcquiredClasses() throws Exception {
+    ClassLoader classLoader = new SandboxClassLoader(configureBuilder().build());
+    Class<?> exampleClass = classLoader.loadClass(AnUninstrumentedClass.class.getName());
+    assertSame(classLoader, exampleClass.getClassLoader());
+    try {
+      exampleClass.getField(ShadowConstants.CLASS_HANDLER_DATA_FIELD_NAME);
+      fail("class shouldn't be instrumented!");
+    } catch (Exception e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void shouldPerformClassLoadAndInstrumentLoadForInstrumentedClasses() throws Exception {
+    ClassLoader classLoader = new SandboxClassLoader(configureBuilder().build());
+    Class<?> exampleClass = classLoader.loadClass(AnExampleClass.class.getName());
+    assertSame(classLoader, exampleClass.getClassLoader());
+    Field roboDataField = exampleClass.getField(ShadowConstants.CLASS_HANDLER_DATA_FIELD_NAME);
+    assertNotNull(roboDataField);
+    assertThat(Modifier.isPublic(roboDataField.getModifiers())).isTrue();
+
+    // Java 9 doesn't allow updates to final fields from outside <init> or <clinit>:
+    // https://bugs.openjdk.java.net/browse/JDK-8157181
+    // Therefore, these fields need to be nonfinal / be made nonfinal.
+    assertThat(Modifier.isFinal(roboDataField.getModifiers())).isFalse();
+    assertThat(Modifier.isFinal(exampleClass.getField("STATIC_FINAL_FIELD").getModifiers()))
+        .isFalse();
+    assertThat(Modifier.isFinal(exampleClass.getField("nonstaticFinalField").getModifiers()))
+        .isFalse();
+  }
+
+  @Test
+  public void callingNormalMethodShouldInvokeClassHandler() throws Exception {
+    Class<?> exampleClass = loadClass(AnExampleClass.class);
+    Method normalMethod = exampleClass.getMethod("normalMethod", String.class, int.class);
+
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertEquals(
+        "response from methodInvoked: AnExampleClass.normalMethod(java.lang.String"
+            + " value1, int 123)",
+        normalMethod.invoke(exampleInstance, "value1", 123));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AnExampleClass.__constructor__()",
+            "methodInvoked: AnExampleClass.normalMethod(java.lang.String value1, int 123)");
+  }
+
+  @Test
+  public void shouldGenerateClassSpecificDirectAccessMethod() throws Exception {
+    Class<?> exampleClass = loadClass(AnExampleClass.class);
+    String methodName = shadow.directMethodName(exampleClass.getName(), "normalMethod");
+    Method directMethod = exampleClass.getDeclaredMethod(methodName, String.class, int.class);
+    directMethod.setAccessible(true);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertEquals("normalMethod(value1, 123)", directMethod.invoke(exampleInstance, "value1", 123));
+    assertThat(transcript).containsExactly("methodInvoked: AnExampleClass.__constructor__()");
+  }
+
+  @Test
+  public void
+      soMockitoDoesntExplodeDueToTooManyMethods_shouldGenerateClassSpecificDirectAccessMethodWhichIsPrivateAndFinal()
+          throws Exception {
+    Class<?> exampleClass = loadClass(AnExampleClass.class);
+    String methodName = shadow.directMethodName(exampleClass.getName(), "normalMethod");
+    Method directMethod = exampleClass.getDeclaredMethod(methodName, String.class, int.class);
+    assertTrue(Modifier.isPrivate(directMethod.getModifiers()));
+    assertTrue(Modifier.isFinal(directMethod.getModifiers()));
+  }
+
+  @Test
+  public void callingStaticMethodShouldInvokeClassHandler() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithStaticMethod.class);
+    Method normalMethod = exampleClass.getMethod("staticMethod", String.class);
+
+    assertEquals(
+        "response from methodInvoked: AClassWithStaticMethod.staticMethod(java.lang.String value1)",
+        normalMethod.invoke(null, "value1"));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithStaticMethod.staticMethod(java.lang.String value1)");
+  }
+
+  @Test
+  public void callingStaticDirectAccessMethodShouldWork() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithStaticMethod.class);
+    String methodName = shadow.directMethodName(exampleClass.getName(), "staticMethod");
+    Method directMethod = exampleClass.getDeclaredMethod(methodName, String.class);
+    directMethod.setAccessible(true);
+    assertEquals("staticMethod(value1)", directMethod.invoke(null, "value1"));
+  }
+
+  @Test
+  public void callingNormalMethodReturningIntegerShouldInvokeClassHandler() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithMethodReturningInteger.class);
+    classHandler.valueToReturn = 456;
+
+    Method normalMethod = exampleClass.getMethod("normalMethodReturningInteger", int.class);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertEquals(456, normalMethod.invoke(exampleInstance, 123));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithMethodReturningInteger.__constructor__()",
+            "methodInvoked: AClassWithMethodReturningInteger.normalMethodReturningInteger(int"
+                + " 123)");
+  }
+
+  @Test
+  public void callingMethodReturningDoubleShouldInvokeClassHandler() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithMethodReturningDouble.class);
+    classHandler.valueToReturn = 456;
+
+    Method normalMethod = exampleClass.getMethod("normalMethodReturningDouble", double.class);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertEquals(456.0, normalMethod.invoke(exampleInstance, 123d));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithMethodReturningDouble.__constructor__()",
+            "methodInvoked: AClassWithMethodReturningDouble.normalMethodReturningDouble(double"
+                + " 123.0)");
+  }
+
+  @Test
+  public void callingNativeMethodShouldInvokeClassHandler() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithNativeMethod.class);
+    Method normalMethod = exampleClass.getDeclaredMethod("nativeMethod", String.class, int.class);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertEquals(
+        "response from methodInvoked:"
+            + " AClassWithNativeMethod.nativeMethod(java.lang.String value1, int 123)",
+        normalMethod.invoke(exampleInstance, "value1", 123));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithNativeMethod.__constructor__()",
+            "methodInvoked: AClassWithNativeMethod.nativeMethod(java.lang.String value1, int 123)");
+  }
+
+  @Test
+  public void directlyCallingNativeMethodShouldBeNoOp() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithNativeMethod.class);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    Method directMethod = findDirectMethod(exampleClass, "nativeMethod", String.class, int.class);
+    assertThat(Modifier.isNative(directMethod.getModifiers())).isFalse();
+
+    assertThat(directMethod.invoke(exampleInstance, "", 1)).isNull();
+  }
+
+  @Test
+  public void directlyCallingNativeMethodReturningPrimitiveShouldBeNoOp() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithNativeMethodReturningPrimitive.class);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    Method directMethod = findDirectMethod(exampleClass, "nativeMethod");
+    assertThat(Modifier.isNative(directMethod.getModifiers())).isFalse();
+
+    assertThat(directMethod.invoke(exampleInstance)).isEqualTo(0);
+  }
+
+  @Test
+  public void shouldHandleMethodsReturningBoolean() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithMethodReturningBoolean.class);
+    classHandler.valueToReturn = true;
+
+    Method directMethod =
+        exampleClass.getMethod("normalMethodReturningBoolean", boolean.class, boolean[].class);
+    directMethod.setAccessible(true);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertEquals(true, directMethod.invoke(exampleInstance, true, new boolean[0]));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithMethodReturningBoolean.__constructor__()",
+            "methodInvoked: AClassWithMethodReturningBoolean.normalMethodReturningBoolean(boolean"
+                + " true, boolean[] {})");
+  }
+
+  @Test
+  public void shouldHandleMethodsReturningArray() throws Exception {
+    Class<?> exampleClass = loadClass(AClassWithMethodReturningArray.class);
+    classHandler.valueToReturn = new String[] {"miao, mieuw"};
+
+    Method directMethod = exampleClass.getMethod("normalMethodReturningArray");
+    directMethod.setAccessible(true);
+    Object exampleInstance = exampleClass.getDeclaredConstructor().newInstance();
+    assertThat(transcript)
+        .containsExactly("methodInvoked: AClassWithMethodReturningArray.__constructor__()");
+    transcript.clear();
+    assertArrayEquals(
+        new String[] {"miao, mieuw"}, (String[]) directMethod.invoke(exampleInstance));
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AClassWithMethodReturningArray.normalMethodReturningArray()");
+  }
+
+  @Test
+  public void shouldInvokeShadowForEachConstructorInInheritanceTree() throws Exception {
+    loadClass(AChild.class).getDeclaredConstructor().newInstance();
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked: AGrandparent.__constructor__()",
+            "methodInvoked: AParent.__constructor__()",
+            "methodInvoked: AChild.__constructor__()");
+  }
+
+  @Test
+  public void shouldRetainSuperCallInConstructor() throws Exception {
+    Class<?> aClass = loadClass(AnInstrumentedChild.class);
+    Object o = aClass.getDeclaredConstructor(String.class).newInstance("hortense");
+    assertEquals("HORTENSE's child", aClass.getSuperclass().getDeclaredField("parentName").get(o));
+    assertNull(aClass.getDeclaredField("childName").get(o));
+  }
+
+  @Test
+  public void shouldCorrectlySplitStaticPrepFromConstructorChaining() throws Exception {
+    Class<?> aClass = loadClass(AClassWithFunnyConstructors.class);
+    Object o = aClass.getDeclaredConstructor(String.class).newInstance("hortense");
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked:"
+                + " AClassWithFunnyConstructors.__constructor__("
+                + AnUninstrumentedParent.class.getName()
+                + " UninstrumentedParent{parentName='hortense'},"
+                + " java.lang.String"
+                + " foo)",
+            "methodInvoked: AClassWithFunnyConstructors.__constructor__(java.lang.String"
+                + " hortense)");
+
+    // should not run constructor bodies...
+    assertEquals(null, getDeclaredFieldValue(aClass, o, "name"));
+    assertEquals(null, getDeclaredFieldValue(aClass, o, "uninstrumentedParent"));
+  }
+
+  @Test
+  public void shouldGenerateClassSpecificDirectAccessMethodForConstructorWhichDoesNotCallSuper()
+      throws Exception {
+    Class<?> aClass = loadClass(AClassWithFunnyConstructors.class);
+    Object instance = aClass.getConstructor(String.class).newInstance("horace");
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked:"
+                + " AClassWithFunnyConstructors.__constructor__("
+                + AnUninstrumentedParent.class.getName()
+                + " UninstrumentedParent{parentName='horace'},"
+                + " java.lang.String"
+                + " foo)",
+            "methodInvoked: AClassWithFunnyConstructors.__constructor__(java.lang.String horace)");
+    transcript.clear();
+
+    // each directly-accessible constructor body will need to be called explicitly, with the correct
+    // args...
+
+    Class<?> uninstrumentedParentClass = loadClass(AnUninstrumentedParent.class);
+    Method directMethod =
+        findDirectMethod(aClass, "__constructor__", uninstrumentedParentClass, String.class);
+    Object uninstrumentedParentIn =
+        uninstrumentedParentClass.getDeclaredConstructor(String.class).newInstance("hortense");
+    assertEquals(null, directMethod.invoke(instance, uninstrumentedParentIn, "foo"));
+    assertThat(transcript).isEmpty();
+
+    assertEquals(null, getDeclaredFieldValue(aClass, instance, "name"));
+    Object uninstrumentedParentOut =
+        getDeclaredFieldValue(aClass, instance, "uninstrumentedParent");
+    assertEquals(
+        "hortense",
+        getDeclaredFieldValue(uninstrumentedParentClass, uninstrumentedParentOut, "parentName"));
+
+    Method directMethod2 = findDirectMethod(aClass, "__constructor__", String.class);
+    assertEquals(null, directMethod2.invoke(instance, "hortense"));
+    assertThat(transcript).isEmpty();
+
+    assertEquals("hortense", getDeclaredFieldValue(aClass, instance, "name"));
+  }
+
+  private Method findDirectMethod(
+      Class<?> declaringClass, String methodName, Class<?>... argClasses)
+      throws NoSuchMethodException {
+    String directMethodName = shadow.directMethodName(declaringClass.getName(), methodName);
+    Method directMethod = declaringClass.getDeclaredMethod(directMethodName, argClasses);
+    directMethod.setAccessible(true);
+    return directMethod;
+  }
+
+  @Test
+  public void shouldNotInstrumentFinalEqualsHashcode() throws ClassNotFoundException {
+    loadClass(AClassThatExtendsAClassWithFinalEqualsHashCode.class);
+  }
+
+  @Test
+  @SuppressWarnings("CheckReturnValue")
+  public void shouldAlsoInstrumentEqualsAndHashCodeAndToStringWhenDeclared() throws Exception {
+    Class<?> theClass = loadClass(AClassWithEqualsHashCodeToString.class);
+    Object instance = theClass.getDeclaredConstructor().newInstance();
+    assertThat(transcript)
+        .containsExactly("methodInvoked:" + " AClassWithEqualsHashCodeToString.__constructor__()");
+    transcript.clear();
+
+    instance.toString();
+    assertThat(transcript)
+        .containsExactly("methodInvoked:" + " AClassWithEqualsHashCodeToString.toString()");
+    transcript.clear();
+
+    classHandler.valueToReturn = true;
+    //noinspection ResultOfMethodCallIgnored,ObjectEqualsNull
+    instance.equals(null);
+    assertThat(transcript)
+        .containsExactly(
+            "methodInvoked:"
+                + " AClassWithEqualsHashCodeToString.equals(java.lang.Object"
+                + " null)");
+    transcript.clear();
+
+    classHandler.valueToReturn = 42;
+    //noinspection ResultOfMethodCallIgnored
+    instance.hashCode();
+    assertThat(transcript)
+        .containsExactly("methodInvoked:" + " AClassWithEqualsHashCodeToString.hashCode()");
+  }
+
+  @Test
+  public void shouldRemapClasses() throws Exception {
+    setClassLoader(new SandboxClassLoader(createRemappingConfig()));
+    Class<?> theClass = loadClass(AClassThatRefersToAForgettableClass.class);
+    assertEquals(loadClass(AClassToRemember.class), theClass.getField("someField").getType());
+    assertEquals(
+        Array.newInstance(loadClass(AClassToRemember.class), 0).getClass(),
+        theClass.getField("someFields").getType());
+  }
+
+  private InstrumentationConfiguration createRemappingConfig() {
+    return configureBuilder()
+        .addClassNameTranslation(AClassToForget.class.getName(), AClassToRemember.class.getName())
+        .build();
+  }
+
+  @Test
+  public void shouldFixTypesInFieldAccess() throws Exception {
+    setClassLoader(new SandboxClassLoader(createRemappingConfig()));
+    Class<?> theClass = loadClass(AClassThatRefersToAForgettableClassInItsConstructor.class);
+    Object instance = theClass.getDeclaredConstructor().newInstance();
+    Method method =
+        theClass.getDeclaredMethod(
+            shadow.directMethodName(theClass.getName(), ShadowConstants.CONSTRUCTOR_METHOD_NAME));
+    method.setAccessible(true);
+    method.invoke(instance);
+  }
+
+  @Test
+  public void shouldFixTypesInMethodArgsAndReturn() throws Exception {
+    setClassLoader(new SandboxClassLoader(createRemappingConfig()));
+    Class<?> theClass = loadClass(AClassThatRefersToAForgettableClassInMethodCalls.class);
+    assertNotNull(
+        theClass.getDeclaredMethod(
+            "aMethod", int.class, loadClass(AClassToRemember.class), String.class));
+  }
+
+  @Test
+  public void shouldInterceptFilteredMethodInvocations() throws Exception {
+    setClassLoader(
+        new SandboxClassLoader(
+            configureBuilder()
+                .addInterceptedMethod(new MethodRef(AClassToForget.class, "forgettableMethod"))
+                .build()));
+
+    Class<?> theClass = loadClass(AClassThatRefersToAForgettableClass.class);
+    Object instance = theClass.getDeclaredConstructor().newInstance();
+    Object output =
+        theClass
+            .getMethod("interactWithForgettableClass")
+            .invoke(shadow.directlyOn(instance, (Class<Object>) theClass));
+    assertEquals("null, get this!", output);
+  }
+
+  @Test
+  public void shouldInterceptFilteredStaticMethodInvocations() throws Exception {
+    setClassLoader(
+        new SandboxClassLoader(
+            configureBuilder()
+                .addInterceptedMethod(
+                    new MethodRef(AClassToForget.class, "forgettableStaticMethod"))
+                .build()));
+
+    Class<?> theClass = loadClass(AClassThatRefersToAForgettableClass.class);
+    Object instance = theClass.getDeclaredConstructor().newInstance();
+    Object output =
+        theClass
+            .getMethod("interactWithForgettableStaticMethod")
+            .invoke(shadow.directlyOn(instance, (Class<Object>) theClass));
+    assertEquals("yess? forget this: null", output);
+  }
+
+  @Nonnull
+  private InstrumentationConfiguration.Builder configureBuilder() {
+    InstrumentationConfiguration.Builder builder = InstrumentationConfiguration.newBuilder();
+    builder
+        .doNotAcquirePackage("java.")
+        .doNotAcquirePackage("jdk.internal.")
+        .doNotAcquirePackage("sun.")
+        .doNotAcquirePackage("com.sun.")
+        .doNotAcquirePackage("org.robolectric.internal.")
+        .doNotAcquirePackage("org.robolectric.pluginapi.");
+    return builder;
+  }
+
+  @Test
+  public void shouldRemapClassesWhileInterceptingMethods() throws Exception {
+    InstrumentationConfiguration config =
+        configureBuilder()
+            .addClassNameTranslation(
+                AClassToForget.class.getName(), AClassToRemember.class.getName())
+            .addInterceptedMethod(
+                new MethodRef(
+                    AClassThatCallsAMethodReturningAForgettableClass.class, "getAForgettableClass"))
+            .build();
+
+    setClassLoader(new SandboxClassLoader(config));
+    Class<?> theClass = loadClass(AClassThatCallsAMethodReturningAForgettableClass.class);
+    theClass
+        .getMethod("callSomeMethod")
+        .invoke(
+            shadow.directlyOn(
+                theClass.getDeclaredConstructor().newInstance(), (Class<Object>) theClass));
+  }
+
+  @Test
+  public void shouldWorkWithEnums() throws Exception {
+    loadClass(AnEnum.class);
+  }
+
+  @Test
+  public void shouldReverseAnArray() throws Exception {
+    assertArrayEquals(new Integer[] {5, 4, 3, 2, 1}, Util.reverse(new Integer[] {1, 2, 3, 4, 5}));
+    assertArrayEquals(new Integer[] {4, 3, 2, 1}, Util.reverse(new Integer[] {1, 2, 3, 4}));
+    assertArrayEquals(new Integer[] {1}, Util.reverse(new Integer[] {1}));
+    assertArrayEquals(new Integer[] {}, Util.reverse(new Integer[] {}));
+  }
+
+  /////////////////////////////
+
+  private Object getDeclaredFieldValue(Class<?> aClass, Object o, String fieldName)
+      throws Exception {
+    Field field = aClass.getDeclaredField(fieldName);
+    field.setAccessible(true);
+    return field.get(o);
+  }
+
+  public static class MyClassHandler implements ClassHandler {
+    private static final Object GENERATE_YOUR_OWN_VALUE = new Object();
+    private List<String> transcript;
+    private Object valueToReturn = GENERATE_YOUR_OWN_VALUE;
+    private Object valueToReturnFromIntercept = null;
+
+    public MyClassHandler(List<String> transcript) {
+      this.transcript = transcript;
+    }
+
+    @Override
+    public void classInitializing(Class clazz) {}
+
+    @Override
+    public Object initializing(Object instance) {
+      return "a shadow!";
+    }
+
+    public Object methodInvoked(
+        Class clazz, String methodName, Object instance, String[] paramTypes, Object[] params) {
+      StringBuilder buf = new StringBuilder();
+      buf.append("methodInvoked:" + " ")
+          .append(clazz.getSimpleName())
+          .append(".")
+          .append(methodName)
+          .append("(");
+      for (int i = 0; i < paramTypes.length; i++) {
+        if (i > 0) buf.append(", ");
+        Object param = params[i];
+        Object display = param == null ? "null" : param.getClass().isArray() ? "{}" : param;
+        buf.append(paramTypes[i]).append(" ").append(display);
+      }
+      buf.append(")");
+      transcript.add(buf.toString());
+
+      if (valueToReturn != GENERATE_YOUR_OWN_VALUE) return valueToReturn;
+      return "response from " + buf.toString();
+    }
+
+    @Override
+    public MethodHandle getShadowCreator(Class<?> theClass) {
+      return dropArguments(constant(String.class, "a shadow!"), 0, theClass);
+    }
+
+    @SuppressWarnings(value = {"UnusedDeclaration", "unused"})
+    private Object invoke(InvocationProfile invocationProfile, Object instance, Object[] params) {
+      return methodInvoked(
+          invocationProfile.clazz,
+          invocationProfile.methodName,
+          instance,
+          invocationProfile.paramTypes,
+          params);
+    }
+
+    @Override
+    public MethodHandle findShadowMethodHandle(
+        Class<?> theClass, String name, MethodType type, boolean isStatic)
+        throws IllegalAccessException {
+      String signature = getSignature(theClass, name, type, isStatic);
+      InvocationProfile invocationProfile =
+          new InvocationProfile(signature, isStatic, getClass().getClassLoader());
+
+      try {
+        MethodHandle mh =
+            MethodHandles.lookup()
+                .findVirtual(
+                    getClass(),
+                    "invoke",
+                    methodType(
+                        Object.class, InvocationProfile.class, Object.class, Object[].class));
+        mh = insertArguments(mh, 0, this, invocationProfile);
+
+        if (isStatic) {
+          return mh.bindTo(null).asCollector(Object[].class, type.parameterCount());
+        } else {
+          return mh.asCollector(Object[].class, type.parameterCount() - 1);
+        }
+      } catch (NoSuchMethodException e) {
+        throw new AssertionError(e);
+      }
+    }
+
+    public String getSignature(Class<?> caller, String name, MethodType type, boolean isStatic) {
+      String className = caller.getName().replace('.', '/');
+      // Remove implicit first argument
+      if (!isStatic) type = type.dropParameterTypes(0, 1);
+      return className + "/" + name + type.toMethodDescriptorString();
+    }
+
+    @Override
+    public Object intercept(String signature, Object instance, Object[] params, Class theClass)
+        throws Throwable {
+      StringBuilder buf = new StringBuilder();
+      buf.append("intercept: ").append(signature).append(" with params (");
+      for (int i = 0; i < params.length; i++) {
+        if (i > 0) buf.append(", ");
+        Object param = params[i];
+        Object display = param == null ? "null" : param.getClass().isArray() ? "{}" : param;
+        buf.append(params[i]).append(" ").append(display);
+      }
+      buf.append(")");
+      transcript.add(buf.toString());
+      return valueToReturnFromIntercept;
+    }
+
+    @Override
+    public <T extends Throwable> T stripStackTrace(T throwable) {
+      return throwable;
+    }
+  }
+
+  private void setClassLoader(ClassLoader classLoader) {
+    this.classLoader = classLoader;
+  }
+
+  private Class<?> loadClass(Class<?> clazz) throws ClassNotFoundException {
+    if (classLoader == null) {
+      classLoader = new SandboxClassLoader(configureBuilder().build());
+    }
+
+    setStaticField(
+        classLoader.loadClass(InvokeDynamicSupport.class.getName()),
+        "INTERCEPTORS",
+        new Interceptors(Collections.<Interceptor>emptyList()));
+    setStaticField(
+        classLoader.loadClass(Shadow.class.getName()),
+        "SHADOW_IMPL",
+        newInstance(classLoader.loadClass(ShadowImpl.class.getName())));
+
+    ShadowInvalidator invalidator = Mockito.mock(ShadowInvalidator.class);
+    when(invalidator.getSwitchPoint(any(Class.class))).thenReturn(new SwitchPoint());
+
+    String className = RobolectricInternals.class.getName();
+    Class<?> robolectricInternalsClass = ReflectionHelpers.loadClass(classLoader, className);
+    ReflectionHelpers.setStaticField(robolectricInternalsClass, "classHandler", classHandler);
+    ReflectionHelpers.setStaticField(robolectricInternalsClass, "shadowInvalidator", invalidator);
+
+    return classLoader.loadClass(clazz.getName());
+  }
+
+  @Test
+  public void shouldDumpClassesWhenConfigured() throws Exception {
+    Path tempDir = Files.createTempDirectory("SandboxClassLoaderTest");
+    System.setProperty("robolectric.dumpClassesDirectory", tempDir.toAbsolutePath().toString());
+    ClassLoader classLoader = new SandboxClassLoader(configureBuilder().build());
+    classLoader.loadClass(AnExampleClass.class.getName());
+    try (Stream<Path> stream = Files.list(tempDir)) {
+      List<Path> files = stream.collect(Collectors.toList());
+      assertThat(files).hasSize(1);
+      assertThat(files.get(0).toAbsolutePath().toString())
+          .containsMatch("org.robolectric.testing.AnExampleClass-robo-instrumented-\\d+.class");
+      Files.delete(files.get(0));
+    } finally {
+      Files.delete(tempDir);
+      System.clearProperty("robolectric.dumpClassesDirectory");
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AChild.java b/sandbox/src/test/java/org/robolectric/testing/AChild.java
new file mode 100644
index 0000000..d4c76bf
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AChild.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AChild extends AParent {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassThatCallsAMethodReturningAForgettableClass.java b/sandbox/src/test/java/org/robolectric/testing/AClassThatCallsAMethodReturningAForgettableClass.java
new file mode 100644
index 0000000..53fc6b7
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassThatCallsAMethodReturningAForgettableClass.java
@@ -0,0 +1,16 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassThatCallsAMethodReturningAForgettableClass {
+  public void callSomeMethod() {
+    @SuppressWarnings("unused")
+    AClassToForget forgettableClass = getAForgettableClass();
+  }
+
+  public AClassToForget getAForgettableClass() {
+    throw new RuntimeException("should never be called!");
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassThatExtendsAClassWithFinalEqualsHashCode.java b/sandbox/src/test/java/org/robolectric/testing/AClassThatExtendsAClassWithFinalEqualsHashCode.java
new file mode 100644
index 0000000..c34b0b6
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassThatExtendsAClassWithFinalEqualsHashCode.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AClassThatExtendsAClassWithFinalEqualsHashCode extends AClassWithFinalEqualsHashCode {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClass.java b/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClass.java
new file mode 100644
index 0000000..6331060
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClass.java
@@ -0,0 +1,20 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassThatRefersToAForgettableClass {
+  public AClassToForget someField;
+  public AClassToForget[] someFields;
+
+  public String interactWithForgettableClass() {
+    AClassToForget aClassToForget = new AClassToForget();
+    return aClassToForget.forgettableMethod() + ", " + aClassToForget.memorableMethod();
+  }
+
+  public String interactWithForgettableStaticMethod() {
+    return AClassToForget.memorableStaticMethod() + " forget this: " + AClassToForget.forgettableStaticMethod();
+  }
+
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClassInItsConstructor.java b/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClassInItsConstructor.java
new file mode 100644
index 0000000..f64cb09
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClassInItsConstructor.java
@@ -0,0 +1,13 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassThatRefersToAForgettableClassInItsConstructor {
+  public final AClassToForget aClassToForget;
+
+  public AClassThatRefersToAForgettableClassInItsConstructor() {
+    aClassToForget = null;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClassInMethodCalls.java b/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClassInMethodCalls.java
new file mode 100644
index 0000000..6d92074
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassThatRefersToAForgettableClassInMethodCalls.java
@@ -0,0 +1,15 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassThatRefersToAForgettableClassInMethodCalls {
+  AClassToForget aMethod(int a, AClassToForget aClassToForget, String b) {
+    return null;
+  }
+
+  AClassToForget[] anotherMethod(int a, AClassToForget[] aClassToForget, String b) {
+    return null;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassToForget.java b/sandbox/src/test/java/org/robolectric/testing/AClassToForget.java
new file mode 100644
index 0000000..f2b689c
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassToForget.java
@@ -0,0 +1,70 @@
+package org.robolectric.testing;
+
+public class AClassToForget {
+  public String memorableMethod() {
+    return "get this!";
+  }
+
+  public String forgettableMethod() {
+    return "shouldn't get this!";
+  }
+
+  public static String memorableStaticMethod() {
+    return "yess?";
+  }
+
+  public static String forgettableStaticMethod() {
+    return "noooo!";
+  }
+
+  public static int intReturningMethod() {
+    return 1;
+  }
+
+  public static int[] intArrayReturningMethod() {
+    return new int[0];
+  }
+
+  public static long longReturningMethod(String str, int i, long l) {
+    return 1;
+  }
+
+  public static long[] longArrayReturningMethod() {
+    return new long[0];
+  }
+
+  public static byte byteReturningMethod() {
+    return 0;
+  }
+
+  public static byte[] byteArrayReturningMethod() {
+    return new byte[0];
+  }
+
+  public static float floatReturningMethod() {
+    return 0f;
+  }
+
+  public static float[] floatArrayReturningMethod() {
+    return new float[0];
+  }
+
+  public static double doubleReturningMethod() {
+    return 0;
+  }
+
+  public static double[] doubleArrayReturningMethod() {
+    return new double[0];
+  }
+
+  public static short shortReturningMethod() {
+    return 0;
+  }
+
+  public static short[] shortArrayReturningMethod() {
+    return new short[0];
+  }
+
+  public static void voidReturningMethod() {
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassToRemember.java b/sandbox/src/test/java/org/robolectric/testing/AClassToRemember.java
new file mode 100644
index 0000000..7761b7c
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassToRemember.java
@@ -0,0 +1,4 @@
+package org.robolectric.testing;
+
+public class AClassToRemember {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithEqualsHashCodeToString.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithEqualsHashCodeToString.java
new file mode 100644
index 0000000..8e9167d
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithEqualsHashCodeToString.java
@@ -0,0 +1,22 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AClassWithEqualsHashCodeToString {
+  @SuppressWarnings("EqualsWhichDoesntCheckParameterClass")
+  @Override
+  public boolean equals(Object obj) {
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    return 42;
+  }
+
+  @Override
+  public String toString() {
+    return "baaaaaah";
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithFinalEqualsHashCode.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithFinalEqualsHashCode.java
new file mode 100644
index 0000000..e99fcce
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithFinalEqualsHashCode.java
@@ -0,0 +1,13 @@
+package org.robolectric.testing;
+
+public class AClassWithFinalEqualsHashCode {
+  @Override
+  public final int hashCode() {
+    return super.hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object obj) {
+    return super.equals(obj);
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithFunnyConstructors.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithFunnyConstructors.java
new file mode 100644
index 0000000..7f4dfeb
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithFunnyConstructors.java
@@ -0,0 +1,19 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithFunnyConstructors {
+  private final AnUninstrumentedParent uninstrumentedParent;
+  private String name;
+
+  public AClassWithFunnyConstructors(String name) {
+    this(new AnUninstrumentedParent(name), "foo");
+    this.name = name;
+  }
+
+  public AClassWithFunnyConstructors(AnUninstrumentedParent uninstrumentedParent, String fooString) {
+    this.uninstrumentedParent = uninstrumentedParent;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningArray.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningArray.java
new file mode 100644
index 0000000..40c6bec
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningArray.java
@@ -0,0 +1,11 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithMethodReturningArray {
+  public String[] normalMethodReturningArray() {
+    return new String[] { "hello, working!" };
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningBoolean.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningBoolean.java
new file mode 100644
index 0000000..c2fe756
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningBoolean.java
@@ -0,0 +1,11 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithMethodReturningBoolean {
+  public boolean normalMethodReturningBoolean(boolean boolArg, boolean[] boolArrayArg) {
+    return true;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningDouble.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningDouble.java
new file mode 100644
index 0000000..734d6bb
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningDouble.java
@@ -0,0 +1,11 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithMethodReturningDouble {
+  public double normalMethodReturningDouble(double doubleArg) {
+    return doubleArg + 1;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningInteger.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningInteger.java
new file mode 100644
index 0000000..71b9eac
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithMethodReturningInteger.java
@@ -0,0 +1,11 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithMethodReturningInteger {
+  public int normalMethodReturningInteger(int intArg) {
+    return intArg + 1;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithNativeMethod.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithNativeMethod.java
new file mode 100644
index 0000000..be217a6
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithNativeMethod.java
@@ -0,0 +1,9 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithNativeMethod {
+  public native String nativeMethod(String stringArg, int intArg);
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithNativeMethodReturningPrimitive.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithNativeMethodReturningPrimitive.java
new file mode 100644
index 0000000..41b75b5
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithNativeMethodReturningPrimitive.java
@@ -0,0 +1,9 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithNativeMethodReturningPrimitive {
+  public native int nativeMethod();
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithNoDefaultConstructor.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithNoDefaultConstructor.java
new file mode 100644
index 0000000..d191452
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithNoDefaultConstructor.java
@@ -0,0 +1,12 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument public class AClassWithNoDefaultConstructor {
+  private String name;
+
+  AClassWithNoDefaultConstructor(String name) {
+    this.name = name;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithStaticMethod.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithStaticMethod.java
new file mode 100644
index 0000000..0414418
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithStaticMethod.java
@@ -0,0 +1,11 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AClassWithStaticMethod {
+  public static String staticMethod(String stringArg) {
+    return "staticMethod(" + stringArg + ")";
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AClassWithoutEqualsHashCodeToString.java b/sandbox/src/test/java/org/robolectric/testing/AClassWithoutEqualsHashCodeToString.java
new file mode 100644
index 0000000..ac194e8
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AClassWithoutEqualsHashCodeToString.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AClassWithoutEqualsHashCodeToString {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AFinalClass.java b/sandbox/src/test/java/org/robolectric/testing/AFinalClass.java
new file mode 100644
index 0000000..c4ce30b
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AFinalClass.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public final class AFinalClass {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AGrandparent.java b/sandbox/src/test/java/org/robolectric/testing/AGrandparent.java
new file mode 100644
index 0000000..aef9ae1
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AGrandparent.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AGrandparent {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AParent.java b/sandbox/src/test/java/org/robolectric/testing/AParent.java
new file mode 100644
index 0000000..b640e4f
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AParent.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AParent extends AGrandparent {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnEnum.java b/sandbox/src/test/java/org/robolectric/testing/AnEnum.java
new file mode 100644
index 0000000..f063061
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnEnum.java
@@ -0,0 +1,8 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public enum AnEnum {
+  ONE, TWO, MANY
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnExampleClass.java b/sandbox/src/test/java/org/robolectric/testing/AnExampleClass.java
new file mode 100644
index 0000000..60ce159
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnExampleClass.java
@@ -0,0 +1,18 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@SuppressWarnings("UnusedDeclaration")
+@Instrument
+public class AnExampleClass {
+  static int foo = 123;
+
+  public static final String STATIC_FINAL_FIELD = "STATIC_FINAL_FIELD";
+  public final String nonstaticFinalField = "nonstaticFinalField";
+
+  public String normalMethod(String stringArg, int intArg) {
+    return "normalMethod(" + stringArg + ", " + intArg + ")";
+  }
+
+  //        abstract void abstractMethod(); todo
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnInstrumentedChild.java b/sandbox/src/test/java/org/robolectric/testing/AnInstrumentedChild.java
new file mode 100644
index 0000000..7357a3e
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnInstrumentedChild.java
@@ -0,0 +1,13 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AnInstrumentedChild extends AnUninstrumentedParent {
+  public final String childName;
+
+  public AnInstrumentedChild(String name) {
+    super(name.toUpperCase() + "'s child");
+    this.childName = name;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnInstrumentedClassWithoutToStringWithSuperToString.java b/sandbox/src/test/java/org/robolectric/testing/AnInstrumentedClassWithoutToStringWithSuperToString.java
new file mode 100644
index 0000000..4ad06f6
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnInstrumentedClassWithoutToStringWithSuperToString.java
@@ -0,0 +1,7 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class AnInstrumentedClassWithoutToStringWithSuperToString extends AnUninstrumentedClassWithToString {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedClass.java b/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedClass.java
new file mode 100644
index 0000000..f4f2bdf
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedClass.java
@@ -0,0 +1,4 @@
+package org.robolectric.testing;
+
+public final class AnUninstrumentedClass {
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedClassWithToString.java b/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedClassWithToString.java
new file mode 100644
index 0000000..f5bcefe
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedClassWithToString.java
@@ -0,0 +1,8 @@
+package org.robolectric.testing;
+
+public class AnUninstrumentedClassWithToString {
+  @Override
+  public String toString() {
+    return "baaaaaah";
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedParent.java b/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedParent.java
new file mode 100644
index 0000000..6737266
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/AnUninstrumentedParent.java
@@ -0,0 +1,17 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+@DoNotInstrument
+public class AnUninstrumentedParent {
+  public final String parentName;
+
+  public AnUninstrumentedParent(String name) {
+    this.parentName = name;
+  }
+
+  @Override
+  public String toString() {
+    return "UninstrumentedParent{parentName='" + parentName + '\'' + '}';
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/Foo.java b/sandbox/src/test/java/org/robolectric/testing/Foo.java
new file mode 100644
index 0000000..20317e6
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/Foo.java
@@ -0,0 +1,33 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class Foo {
+  public Foo(String s) {
+    throw new RuntimeException("stub!");
+  }
+
+  public String getName() {
+    throw new RuntimeException("stub!");
+  }
+
+  public void findFooById(int i) {
+    throw new RuntimeException("stub!");
+  }
+
+  @Override
+  public int hashCode() {
+    throw new RuntimeException("stub!");
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    throw new RuntimeException("stub!");
+  }
+
+  @Override
+  public String toString() {
+    throw new RuntimeException("stub!");
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/Pony.java b/sandbox/src/test/java/org/robolectric/testing/Pony.java
new file mode 100644
index 0000000..4d1b058
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/Pony.java
@@ -0,0 +1,36 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.internal.Instrument;
+
+@Instrument
+public class Pony {
+  public Pony() {
+  }
+
+  public String ride(String where) {
+    return "Whinny! You're on my " + where + "!";
+  }
+
+  public static String prance(String where) {
+    return "I'm prancing to " + where + "!";
+  }
+
+  public String saunter(String where) {
+    return "Off I saunter to " + where + "!";
+  }
+
+  @Implements(Pony.class)
+  public static class ShadowPony {
+    @Implementation
+    public String ride(String where) {
+      return "Fake whinny! You're on my " + where + "!";
+    }
+
+    @Implementation
+    protected static String prance(String where) {
+      return "I'm shadily prancing to " + where + "!";
+    }
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/ShadowFoo.java b/sandbox/src/test/java/org/robolectric/testing/ShadowFoo.java
new file mode 100644
index 0000000..531a7f9
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/ShadowFoo.java
@@ -0,0 +1,26 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** A test class that shadows a constructor. */
+@Implements(Foo.class)
+public class ShadowFoo extends ShadowFooParent {
+  @RealObject public Foo realFooField;
+  public Foo realFooInConstructor;
+  public String name;
+
+  @Override
+  @Implementation
+  protected void __constructor__(String name) {
+    super.__constructor__(name);
+    this.name = name;
+    realFooInConstructor = realFooField;
+  }
+
+  @SuppressWarnings({"UnusedDeclaration"})
+  public String getName() {
+    return name;
+  }
+}
diff --git a/sandbox/src/test/java/org/robolectric/testing/ShadowFooParent.java b/sandbox/src/test/java/org/robolectric/testing/ShadowFooParent.java
new file mode 100644
index 0000000..a2d7fba
--- /dev/null
+++ b/sandbox/src/test/java/org/robolectric/testing/ShadowFooParent.java
@@ -0,0 +1,17 @@
+package org.robolectric.testing;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** A test class that is a parent for {@link Foo}. */
+@Implements(Foo.class)
+public class ShadowFooParent {
+  @RealObject private Foo realFoo;
+  public Foo realFooInParentConstructor;
+
+  @Implementation
+  protected void __constructor__(String name) {
+    realFooInParentConstructor = realFoo;
+  }
+}
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..884a36f
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,89 @@
+# Robolectric build-android.sh tutorial
+
+**Note:** Based on [Android documentation](https://source.android.com/source/downloading.html).
+
+This tutorial will allow you to run the `build-android.sh` script in the Robolectric repository, resulting in the corresponding Android version's android-all jar.
+
+## 1. Installing Repo
+Repo is a tool that makes it easier to work with Git in the context of Android. For more information about Repo, see the Developing section.
+
+To install Repo make sure you have a bin/ directory in your home directory and that it is included in your path:
+```
+$ mkdir ~/bin
+$ PATH=~/bin:$PATH
+```
+
+Download the Repo tool and ensure that it is executable:
+```
+$ curl https://storage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
+$ chmod a+x ~/bin/repo
+```
+
+## 2. Initializing a Repo client
+After installing Repo, set up your client to access the Android source repository:
+
+Create an empty directory to hold your working files. If you're using MacOS, this has to be on a case-sensitive filesystem. Give it any name you like:
+```
+$ mkdir <WORKING_DIRECTORY>
+$ cd <WORKING_DIRECTORY>
+```
+
+Configure git with your real name and email address. To use the Gerrit code-review tool, you will need an email address that is connected with a [registered Google account](https://myaccount.google.com/?pli=1). Make sure this is a live address at which you can receive messages. The name that you provide here will show up in attributions for your code submissions.
+```
+$ git config --global user.name "Your Name"
+$ git config --global user.email "you@example.com"
+```
+
+## 3. Grab dependencies
+On Ubuntu run
+```
+$ sudo apt-get install git-core gnupg gnupg-agent flex bison gperf build-essential \
+zip curl zlib1g-dev gcc-multilib g++-multilib libc6-dev-i386 \
+lib32ncurses5-dev x11proto-core-dev libx11-dev lib32z-dev ccache \
+libgl1-mesa-dev libxml2-utils xsltproc unzip libswitch-perl
+```
+
+## 4. Run sync-android.sh to sync and build source
+Now use repo to sync the android source, and then build.
+
+```
+./sync-android.sh <root source location> <android version> <# parallel jobs>
+```
+
+The currently supported Android versions are:
+
+*  `4.1.2_r1`    - Jelly Bean API 16
+*  `4.2.2_r1.2`  - Jelly Bean MR1 API 17
+*  `4.3_r2`      - Jelly Bean MR2 API 18
+*  `4.4_r1`      - Kit Kat API 19
+*  `5.0.2_r3`    - Lollipop API 21
+*  `5.1.1_r9`    - Lollipop MR1 API 22
+*  `6.0.1_r3`    - Marshmallow API 23
+*  `7.0.0_r1`    - Nougat API 24
+*  `7.1.0_r7`    - Nougat MR1 API 25
+*  `8.0.0_r4`    - Oreo API 26
+
+Beware it can take upwards of 100 GB of space to sync and build. 
+
+For more infomation see [Downloading and Building](https://source.android.com/source/requirements)
+
+Choose a <# parallel jobs> value roughly equal to # of free cores on your machine. YMMV.
+
+
+## 7. Run build-android.sh
+
+Signing Artifacts:
+The end of the script will prompt you to sign the new artifacts using GPG. See [Performing a Release](https://github.com/robolectric/robolectric-gradle-plugin/wiki/Performing-a-Release) for instructions on obtaining a GPG key pair.
+
+(Optional) You will be prompted a total of 4 times (once for each artifact). To make this easier, run this command beforehand:
+```
+$ gpg-agent --daemon
+```
+
+Finally, in your Robolectric directory run:
+```
+$ export SIGNING_PASSWORD=<Passphrase for GPG signing key>
+$ build-android.sh <Path to AOSP source directory> <android version> <robolectric sub-version>
+```
+
+For Robolectric version `6.0.1_r3-robolectric-0`, android version would be `6.0.1_r3` and  robolectric sub-version `0`.
diff --git a/scripts/build-android.sh b/scripts/build-android.sh
new file mode 100755
index 0000000..1350427
--- /dev/null
+++ b/scripts/build-android.sh
@@ -0,0 +1,310 @@
+#!/bin/bash
+#
+# This script builds the AOSP Android jars, and installs them in your local
+# Maven repository. See: http://source.android.com/source/building.html for
+# more information on building AOSP.
+#
+# Usage:
+#   build-android.sh <android repo path> <android version> <robolectric version>
+#
+# For a tutorial check scripts/README.md
+
+set -ex
+
+function usage() {
+    echo "Usage: ${0} <android repo path> <android-version> <robolectric-sub-version> <output directory>"
+}
+
+if [[ $# -ne 4 ]]; then
+    usage
+    exit 1
+fi
+
+buildRoot=$1
+
+if [[ ! -d $buildRoot ]]; then
+    echo $buildRoot is not a directory
+    usage
+    exit 1
+fi
+
+if [[ -z "${SIGNING_PASSWORD}" ]]; then
+    echo "Please set the GPG passphrase as SIGNING_PASSWORD"
+    exit 1
+fi
+
+buildRoot=$(cd $buildRoot; pwd)
+
+ANDROID_VERSION=$2
+ROBOLECTRIC_SUB_VERSION=$3
+
+SCRIPT_DIR=$(cd $(dirname "$0"); pwd)
+
+ANDROID_SOURCES_BASE=${buildRoot}
+FRAMEWORKS_BASE_DIR=${ANDROID_SOURCES_BASE}/frameworks/base
+FRAMEWORKS_RAW_RES_DIR=${FRAMEWORKS_BASE_DIR}/core/res/
+ROBOLECTRIC_VERSION=${ANDROID_VERSION}-robolectric-${ROBOLECTRIC_SUB_VERSION}
+
+# Intermediate artifacts
+ANDROID_RES=android-res-${ANDROID_VERSION}.apk
+ANDROID_EXT=android-ext-${ANDROID_VERSION}.jar
+ANDROID_CLASSES=android-classes-${ANDROID_VERSION}.jar
+
+# API specific paths
+LIB_PHONE_NUMBERS_PKG="com/android/i18n/phonenumbers"
+LIB_PHONE_NUMBERS_PATH="external/libphonenumber/java/src"
+
+# Final artifact names
+ANDROID_ALL=android-all-${ROBOLECTRIC_VERSION}.jar
+ANDROID_ALL_POM=android-all-${ROBOLECTRIC_VERSION}.pom
+ANDROID_ALL_SRC=android-all-${ROBOLECTRIC_VERSION}-sources.jar
+ANDROID_ALL_DOC=android-all-${ROBOLECTRIC_VERSION}-javadoc.jar
+ANDROID_BUNDLE=android-all-${ROBOLECTRIC_VERSION}-bundle.jar
+
+TZDATA_ARCH="generic_x86"
+
+build_platform() {
+    NATIVE_ARTIFACTS=()
+
+    if [[ "${ANDROID_VERSION}" == "4.1.2_r1" ]]; then
+        ARTIFACTS=("core" "services" "framework" "android.policy" "ext")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java policy/src sax/java services/java telephony/java wifi/java)
+    elif [[ "${ANDROID_VERSION}" == "4.2.2_r1.2" ]]; then
+        ARTIFACTS=("core" "services" "telephony-common" "framework" "android.policy" "ext")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java policy/src sax/java services/java telephony/java wifi/java)
+    elif [[ "${ANDROID_VERSION}" == "4.3_r2" ]]; then
+        ARTIFACTS=("core" "services" "telephony-common" "framework" "android.policy" "ext")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java policy/src sax/java services/java telephony/java wifi/java)
+    elif [[ "${ANDROID_VERSION}" == "4.4_r1" ]]; then
+        ARTIFACTS=("core" "services" "telephony-common" "framework" "framework2" "framework-base" "android.policy" "ext" "webviewchromium" "okhttp" "conscrypt")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java policy/src sax/java services/java telephony/java wifi/java)
+    elif [[ "${ANDROID_VERSION}" == "5.0.2_r3" ]]; then
+        ARTIFACTS=("core-libart" "services" "telephony-common" "framework" "android.policy" "ext" "okhttp" "conscrypt")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java policy/src sax/java services/java telephony/java wifi/java)
+        TZDATA_ARCH="generic"
+    elif [[ "${ANDROID_VERSION}" == "5.1.1_r9" ]]; then
+        ARTIFACTS=("core-libart" "services" "telephony-common" "framework" "android.policy" "ext" "okhttp" "conscrypt")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java policy/src sax/java services/java telephony/java wifi/java)
+        TZDATA_ARCH="generic"
+    elif [[ "${ANDROID_VERSION}" == "6.0.1_r3" ]]; then
+        ARTIFACTS=("core-libart" "services" "services.accessibility" "telephony-common" "framework" "ext" "icu4j-icudata-jarjar" "okhttp" "conscrypt")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java sax/java services/java telephony/java wifi/java)
+        LIB_PHONE_NUMBERS_PKG="com/google/i18n/phonenumbers"
+        LIB_PHONE_NUMBERS_PATH="external/libphonenumber/libphonenumber/src"
+        TZDATA_ARCH="generic"
+    elif [[ "${ANDROID_VERSION}" == "7.0.0_r1" ]]; then
+        ARTIFACTS=("core-libart" "services" "services.accessibility" "telephony-common" "framework" "ext" "okhttp" "conscrypt")
+        NATIVE_ARTIFACTS=("icu4j-icudata-host-jarjar" "icu4j-icutzdata-host-jarjar")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java sax/java services/java telephony/java wifi/java)
+        LIB_PHONE_NUMBERS_PKG="com/google/i18n/phonenumbers"
+        LIB_PHONE_NUMBERS_PATH="external/libphonenumber/libphonenumber/src"
+    elif [[ "${ANDROID_VERSION}" == "7.1.0_r7" ]]; then
+        ARTIFACTS=("core-libart" "services" "services.accessibility" "telephony-common" "framework" "ext" "okhttp" "conscrypt")
+        NATIVE_ARTIFACTS=("icu4j-icudata-host-jarjar" "icu4j-icutzdata-host-jarjar")
+        SOURCES=(core/java graphics/java media/java location/java opengl/java sax/java services/java telephony/java wifi/java)
+        LIB_PHONE_NUMBERS_PKG="com/google/i18n/phonenumbers"
+        LIB_PHONE_NUMBERS_PATH="external/libphonenumber/libphonenumber/src"
+    elif [[ "${ANDROID_VERSION}" == "8.0.0_r4" ]]; then
+        ARTIFACTS=("robolectric_android-all")
+        NATIVE_ARTIFACTS=()
+        SOURCES=(core/java graphics/java media/java location/java opengl/java sax/java services/java telephony/java wifi/java)
+    else
+        echo "Robolectric: No match for version: ${ANDROID_VERSION}"
+        exit 1
+    fi
+
+    cd ${ANDROID_SOURCES_BASE}
+    if [ ! -d out/target/common/obj/JAVA_LIBRARIES ]; then
+      echo "Robolectric: You need to run 'sync-android.sh' first"
+      exit 1
+    fi
+}
+
+build_android_res() {
+    echo "Robolectric: Building android-res..."
+    cd ${FRAMEWORKS_BASE_DIR}/core/res; jar cf ${OUT}/${ANDROID_RES} .
+    src=${ANDROID_SOURCES_BASE}/out/target/common/obj/APPS/framework-res_intermediates/package-export.apk
+    cp $src ${OUT}/${ANDROID_RES}
+}
+
+build_android_ext() {
+    echo "Robolectric: Building android-ext..."
+    mkdir -p ${OUT}/ext-classes-modified/${LIB_PHONE_NUMBERS_PKG}
+    cd ${OUT}/ext-classes-modified; jar xf ${ANDROID_SOURCES_BASE}/out/target/common/obj/JAVA_LIBRARIES/ext_intermediates/classes.jar
+    cp -R ${ANDROID_SOURCES_BASE}/${LIB_PHONE_NUMBERS_PATH}/${LIB_PHONE_NUMBERS_PKG}/data ${OUT}/ext-classes-modified/${LIB_PHONE_NUMBERS_PKG}
+    cd ${OUT}/ext-classes-modified; jar cf ${OUT}/${ANDROID_EXT} .
+    rm -rf ${OUT}/ext-classes-modified
+}
+
+build_android_classes() {
+    echo "Robolectric: Building android-classes..."
+    mkdir ${OUT}/android-all-classes
+    for artifact in "${ARTIFACTS[@]}"; do
+        src=${ANDROID_SOURCES_BASE}/out/target/common/obj/JAVA_LIBRARIES/${artifact}_intermediates
+        cd ${OUT}/android-all-classes
+        if [[ -f ${src}/classes.jar ]]; then
+            jar xf ${src}/classes.jar
+        else
+            echo "Couldn't find ${artifact} at ${src}/classes.jar"
+            exit 1
+        fi
+    done
+
+    for artifact in "${NATIVE_ARTIFACTS[@]}"; do
+        jarPath=${ANDROID_SOURCES_BASE}/out/host/linux-x86/framework/${artifact}.jar
+        cd ${OUT}/android-all-classes
+        if [[ -f $jarPath ]]; then
+            jar xf $jarPath
+        else
+            echo "Couldn't find ${artifact} at $jarPath"
+            exit 1
+        fi
+    done
+    build_tzdata
+    build_prop
+    cd ${OUT}/android-all-classes; jar cf ${OUT}/${ANDROID_CLASSES} .
+    rm -rf ${OUT}/android-all-classes
+}
+
+build_tzdata() {
+  echo "Robolectric: Building tzdata..."
+  mkdir -p ${OUT}/android-all-classes/usr/share/zoneinfo
+  cp ${ANDROID_SOURCES_BASE}/out/target/product/${TZDATA_ARCH}/system/usr/share/zoneinfo/tzdata ${OUT}/android-all-classes/usr/share/zoneinfo
+}
+
+build_prop() {
+  cp ${ANDROID_SOURCES_BASE}/out/target/product/generic_x86/system/build.prop ${OUT}/android-all-classes
+}
+
+build_android_all_jar() {
+    echo "Robolectric: Building android-all..."
+    mkdir ${OUT}/android-all
+    cd ${OUT}/android-all; unzip ${OUT}/${ANDROID_RES}
+    # temporarily add raw resources too
+    cd ${OUT}/android-all; rsync -a ${FRAMEWORKS_RAW_RES_DIR} raw-res
+    cd ${OUT}/android-all; jar xf ${OUT}/${ANDROID_EXT}
+    cd ${OUT}/android-all; jar xf ${OUT}/${ANDROID_CLASSES}
+
+    # Remove unused files
+    rm -rf ${OUT}/android-all/Android.mk
+    rm -rf ${OUT}/android-all/raw-res/Android.mk
+    rm -rf ${OUT}/android-all/AndroidManifest.xml
+    rm -rf ${OUT}/android-all/raw-resAndroidManifest.xml
+    rm -rf ${OUT}/android-all/META-INF
+    rm -rf ${OUT}/android-all/MODULE_LICENSE_APACHE2
+    rm -rf ${OUT}/android-all/raw-res/MODULE_LICENSE_APACHE2
+    rm -rf ${OUT}/android-all/MakeJavaSymbols.sed
+    rm -rf ${OUT}/android-all/raw-res/MakeJavaSymbols.sed
+    rm -rf ${OUT}/android-all/NOTICE
+    rm -rf ${OUT}/android-all/raw-res/NOTICE
+    rm -rf ${OUT}/android-all/lint.xml
+    rm -rf ${OUT}/android-all/raw-res/lint.xml
+    rm -rf ${OUT}/android-all/java/lang
+
+    # Build the new JAR file
+    cd ${OUT}/android-all; jar cf ${OUT}/${ANDROID_ALL} .
+    rm ${OUT}/${ANDROID_RES} ${OUT}/${ANDROID_EXT} ${OUT}/${ANDROID_CLASSES}
+}
+
+cp_android_all_jar() {
+  # function to use for android versions that support building the android all
+  # jar directly
+  # This will just copy the android all jar to the final name
+  src=${ANDROID_SOURCES_BASE}/out/target/common/obj/JAVA_LIBRARIES/robolectric_android-all-stub_intermediates/classes-with-res.jar
+  cp $src ${OUT}/${ANDROID_ALL}
+}
+
+build_android_src_jar() {
+    echo "Robolectric: Building android-all-source..."
+    local src=${ANDROID_SOURCES_BASE}/frameworks/base
+    local tmp=${OUT}/sources
+    mkdir ${tmp}
+
+    for sourceSubDir in "${SOURCES[@]}"; do
+        rsync -a ${src}/${sourceSubDir}/ ${tmp}/
+    done
+    rsync -a ${ANDROID_SOURCES_BASE}/libcore/luni/src/main/java/ ${tmp}/ # this is new
+    cd ${tmp}; jar cf ${OUT}/${ANDROID_ALL_SRC} .
+    rm -rf ${tmp}
+}
+
+build_android_doc_jar() {
+    # TODO: Actually build the docs
+    echo "Robolectric: Building android-all-javadoc..."
+    mkdir ${OUT}/javadoc
+    cd ${OUT}/javadoc; jar cf ${OUT}/${ANDROID_ALL_DOC} .
+    rm -rf ${OUT}/javadoc
+}
+
+build_signed_packages() {
+    echo "Robolectric: Building android-all.pom..."
+    sed s/VERSION/${ROBOLECTRIC_VERSION}/ ${SCRIPT_DIR}/pom_template.xml | sed s/ARTIFACT_ID/android-all/ > ${OUT}/${ANDROID_ALL_POM}
+
+    echo "Robolectric: Signing files with gpg..."
+    for ext in ".jar" "-javadoc.jar" "-sources.jar" ".pom"; do
+        ( cd ${OUT} && gpg -ab --passphrase ${SIGNING_PASSWORD} android-all-${ROBOLECTRIC_VERSION}$ext )
+    done
+
+    echo "Robolectric: Creating bundle for Sonatype upload..."
+    cd ${OUT}; jar cf ${ANDROID_BUNDLE} *.jar *.pom *.asc
+}
+
+cp_android_all_jar() {
+  # function to use for android versions that support building the android all
+  # jar directly
+  # This will just copy the android all jar to the final name
+  src=${ANDROID_SOURCES_BASE}/out/target/common/obj/JAVA_LIBRARIES/robolectric_android-all-stub_intermediates/classes-with-res.jar
+  cp $src ${OUT}/${ANDROID_ALL}
+}
+
+mavenize() {
+    local FILE_NAME_BASE=android-all-${ROBOLECTRIC_VERSION}
+    mvn install:install-file \
+      -Dfile=${OUT}/${FILE_NAME_BASE}.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar
+
+    mvn install:install-file \
+      -Dfile=${OUT}/${FILE_NAME_BASE}-sources.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar \
+      -Dclassifier=sources
+
+    mvn install:install-file \
+      -Dfile=${OUT}/${FILE_NAME_BASE}-javadoc.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar \
+      -Dclassifier=javadoc
+}
+
+if [[ ! -d "${4}" ]]; then
+  echo "$4 is not a directory"
+  exit 1
+fi
+
+OUT=${4}/${ANDROID_VERSION}
+mkdir -p ${OUT}
+
+build_platform
+if [[ "${ANDROID_VERSION}" == "8.0.0_r4" ]]; then
+  cp_android_all_jar
+else
+  build_android_res
+  build_android_ext
+  build_android_classes
+  build_android_all_jar
+fi
+
+build_android_src_jar
+build_android_doc_jar
+build_signed_packages
+mavenize
+
+echo "DONE!!"
+echo "Your artifacts are located here: ${OUT}"
diff --git a/scripts/build-resources.sh b/scripts/build-resources.sh
new file mode 100755
index 0000000..8ba2620
--- /dev/null
+++ b/scripts/build-resources.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -x
+
+rootDir=$(dirname $(dirname $0))
+projects=("robolectric")
+
+for project in "${projects[@]}"
+do
+  androidProjDir="$rootDir/$project"
+  echo $androidProjDir
+
+  aapts=( $ANDROID_HOME/build-tools/28.0.*/aapt )
+  aapt=${aapts[-1]}
+  inDir=$androidProjDir/src/test/resources
+  outDir=$androidProjDir/src/test/resources
+  javaSrc=$androidProjDir/src/test/java
+
+  mkdir -p $outDir
+  mkdir -p $javaSrc
+
+  $aapt p -v -f -m --auto-add-overlay -I $ANDROID_HOME/platforms/android-28/android.jar \
+    -S $inDir/res -M $inDir/AndroidManifest.xml \
+    -A $inDir/assets \
+    -F $outDir/resources.ap_ \
+    -J $javaSrc \
+    --no-version-vectors
+done
diff --git a/scripts/deploy-android.sh b/scripts/deploy-android.sh
new file mode 100755
index 0000000..4432bf8
--- /dev/null
+++ b/scripts/deploy-android.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+#
+# This script deploys/publishes a built AOSP Android jars to remote maven
+#
+# Usage:
+#   deploy-android.sh <jar path> <android version> <robolectric version>
+#
+# For a tutorial check scripts/README.md
+
+set -ex
+
+function usage() {
+    echo "Usage: ${0} <artifact path> <android-version> <robolectric-sub-version>"
+}
+
+if [[ $# -ne 3 ]]; then
+    usage
+    exit 1
+fi
+
+ARTIFACT_PATH=$1
+ANDROID_VERSION=$2
+ROBOLECTRIC_SUB_VERSION=$3
+
+SCRIPT_DIR=$(cd $(dirname "$0"); pwd)
+
+ROBOLECTRIC_VERSION=${ANDROID_VERSION}-robolectric-${ROBOLECTRIC_SUB_VERSION}
+
+# Final artifact names
+ANDROID_ALL=android-all-${ROBOLECTRIC_VERSION}.jar
+ANDROID_ALL_POM=android-all-${ROBOLECTRIC_VERSION}.pom
+ANDROID_ALL_SRC=android-all-${ROBOLECTRIC_VERSION}-sources.jar
+ANDROID_ALL_DOC=android-all-${ROBOLECTRIC_VERSION}-javadoc.jar
+ANDROID_BUNDLE=android-all-${ROBOLECTRIC_VERSION}-bundle.jar
+
+
+mavenize() {
+    local FILE_NAME_BASE=android-all-${ROBOLECTRIC_VERSION}
+    mvn deploy:deploy-file \
+      -Dfile=${ARTIFACT_PATH}/${FILE_NAME_BASE}.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar
+
+    mvn deploy:deploy-file \
+      -Dfile=${ARTIFACT_PATH}/${FILE_NAME_BASE}-sources.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar \
+      -Dclassifier=sources
+
+    mvn deploy:deploy-file \
+      -Dfile=${ARTIFACT_PATH}/${FILE_NAME_BASE}-javadoc.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar \
+      -Dclassifier=javadoc
+}
+
+mavenize
+
+echo "DONE!!"
diff --git a/scripts/deploy-snapshot.sh b/scripts/deploy-snapshot.sh
new file mode 100755
index 0000000..6dcc35b
--- /dev/null
+++ b/scripts/deploy-snapshot.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+#
+# Deploy a snapshot build to Sonatype.
+#
+
+set -e
+
+echo "Pull request: '${TRAVIS_PULL_REQUEST}' on branch '${TRAVIS_BRANCH}' with JDK '${TRAVIS_JDK_VERSION}'"
+if [ "${TRAVIS_PULL_REQUEST}" = "false" ] && [ "${TRAVIS_BRANCH}" = "master" ] && [ "${TRAVIS_JDK_VERSION}" = "oraclejdk8" ]; then
+  echo "Deploying snapshot..."
+  SKIP_JAVADOC=true ./gradlew upload --info --stacktrace
+fi
diff --git a/scripts/install-android-prebuilt.sh b/scripts/install-android-prebuilt.sh
new file mode 100755
index 0000000..4436881
--- /dev/null
+++ b/scripts/install-android-prebuilt.sh
@@ -0,0 +1,93 @@
+@@ -0,0 1,85 @@
+#!/bin/bash
+#
+# This script signs already built AOSP Android jars, and installs them in your local
+# Maven repository. See: http://source.android.com/source/building.html for
+# more information on building AOSP.
+#
+# Usage:
+#   build-android-prebuilt.sh <jar directory path> <android version> <robolectric version>
+#
+
+set -ex
+
+function usage() {
+    echo "Usage: ${0} <jar dir path> <android-version> <robolectric-sub-version>"
+}
+
+if [[ $# -ne 3 ]]; then
+    usage
+    exit 1
+fi
+
+read -p "Please set the GPG passphrase: " -s signingPassphrase
+if [[ -z "${signingPassphrase}" ]]; then
+    exit 1
+fi
+
+JAR_DIR=$(readlink -e "$1")
+ANDROID_VERSION="$2"
+ROBOLECTRIC_SUB_VERSION="$3"
+
+SCRIPT_DIR=$(cd $(dirname "$0"); pwd)
+
+ROBOLECTRIC_VERSION=${ANDROID_VERSION}-robolectric-${ROBOLECTRIC_SUB_VERSION}
+
+# Final artifact names
+ANDROID_ALL=android-all-${ROBOLECTRIC_VERSION}.jar
+ANDROID_ALL_POM=android-all-${ROBOLECTRIC_VERSION}.pom
+ANDROID_ALL_SRC=android-all-${ROBOLECTRIC_VERSION}-sources.jar
+ANDROID_ALL_DOC=android-all-${ROBOLECTRIC_VERSION}-javadoc.jar
+ANDROID_BUNDLE=android-all-${ROBOLECTRIC_VERSION}-bundle.jar
+
+generate_empty_javadoc() {
+    TMP=`mktemp --directory`
+    cd ${TMP}
+    jar cf ${JAR_DIR}/${ANDROID_ALL_DOC} .
+    cd ${JAR_DIR}; rm -rf ${TMP}
+}
+
+build_signed_packages() {
+    echo "Robolectric: Building android-all.pom..."
+    sed s/VERSION/${ROBOLECTRIC_VERSION}/ ${SCRIPT_DIR}/pom_template.xml | sed s/ARTIFACT_ID/android-all/ > ${JAR_DIR}/${ANDROID_ALL_POM}
+
+    echo "Robolectric: Signing files with gpg..."
+    for ext in ".jar" "-javadoc.jar" "-sources.jar" ".pom"; do
+        ( cd ${JAR_DIR} && gpg -ab --passphrase ${signingPassphrase} android-all-${ROBOLECTRIC_VERSION}$ext )
+    done
+
+    echo "Robolectric: Creating bundle for Sonatype upload..."
+    cd ${JAR_DIR}; jar cf ${ANDROID_BUNDLE} *.jar *.pom *.asc
+}
+
+mavenize() {
+    local FILE_NAME_BASE=android-all-${ROBOLECTRIC_VERSION}
+    mvn install:install-file \
+      -Dfile=${JAR_DIR}/${FILE_NAME_BASE}.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar
+
+    mvn install:install-file \
+      -Dfile=${JAR_DIR}/${FILE_NAME_BASE}-sources.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar \
+      -Dclassifier=sources
+
+    mvn install:install-file \
+      -Dfile=${JAR_DIR}/${FILE_NAME_BASE}-javadoc.jar \
+      -DgroupId=org.robolectric \
+      -DartifactId=android-all \
+      -Dversion=${ROBOLECTRIC_VERSION} \
+      -Dpackaging=jar \
+      -Dclassifier=javadoc
+}
+
+generate_empty_javadoc
+build_signed_packages
+mavenize
+
+echo "DONE!!"
\ No newline at end of file
diff --git a/scripts/pom_template.xml b/scripts/pom_template.xml
new file mode 100644
index 0000000..357618e
--- /dev/null
+++ b/scripts/pom_template.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.robolectric</groupId>
+    <artifactId>ARTIFACT_ID</artifactId>
+    <version>VERSION</version>
+    <packaging>jar</packaging>
+    <parent>
+        <groupId>org.sonatype.oss</groupId>
+        <artifactId>oss-parent</artifactId>
+        <version>9</version>
+    </parent>
+    <name>Google Android ARTIFACT_ID Library</name>
+    <description>
+        A library jar that provides APIs for Applications written for the Google Android Platform.
+    </description>
+    <url>http://source.android.com/</url>
+    <inceptionYear>2008</inceptionYear>
+    <licenses>
+        <license>
+            <name>Apache 2.0</name>
+            <url>http://www.apache.org/licenses/LICENSE-2.0</url>
+            <comments>
+                While the EULA for the Android SDK restricts distribution of those binaries, the source code is licensed under Apache 2.0 which allows compiling binaries from source and then distributing those versions.
+            </comments>
+            <distribution>repo</distribution>
+        </license>
+    </licenses>
+    <scm>
+        <url>https://android.git.kernel.org/</url>
+        <connection>git://android.git.kernel.org/platform/manifest.git</connection>
+    </scm>
+    <developers>
+        <developer>
+            <name>The Android Open Source Projects</name>
+        </developer>
+    </developers>
+</project>
\ No newline at end of file
diff --git a/scripts/release-notes-template.md b/scripts/release-notes-template.md
new file mode 100644
index 0000000..0d4fdca
--- /dev/null
+++ b/scripts/release-notes-template.md
@@ -0,0 +1,35 @@
+Robolectric 4.3 Alpha 1
+
+Robolectric 4.3 Alpha 1 is an alpha release. APIs are very likely to change and new features will be added. We'd love feedback from testing this version, but we recommend you don't rely on it in your test suite.
+
+This release introduces a new extension mechanism for Robolectric, fixes a performance regression in 4.x, and includes numerous SDK support improvements and bug fixes.
+
+## Features
+* Description. Thanks @user! [issue #x].<sup>[b1](#beta1)</sup>
+
+## Android SDK support and Test API changes
+
+## Configuration
+
+## Bug Fixes
+
+## Deprecations and Removals
+
+## Internal Changes
+
+## Known Issues
+
+## Compatibility
+* Android Studio/Android Gradle Plugin 3.3 or 3.4 Beta
+* Android SDK 28 (includes support for testing against SDKs from 16 on)
+
+## Use Robolectric:
+
+``` groovy
+testCompile "org.robolectric:robolectric:4.2-beta-1"
+```
+
+Find more [details here](http://robolectric.org/getting-started/). Report issues [here](https://github.com/robolectric/robolectric/issues). Enjoy!
+___
+<a name="alpha2"><sup>a2</sup></a> — added in Alpha 2.
+<a name="beta1"><sup>b1</sup></a> — added in Beta 1.
\ No newline at end of file
diff --git a/scripts/sync-android.sh b/scripts/sync-android.sh
new file mode 100755
index 0000000..d9a717a
--- /dev/null
+++ b/scripts/sync-android.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+#
+# This script sync and builds the AOSP source for a specified platform version.
+#
+# Usage:
+#   sync-android.sh <src root> <android version>
+#
+# This will create a <src root>/aosp-<android version>, sync the source for that version, and
+# attempt to build.
+#
+# You may need to customize the JAVA_6 or JAVA_7 install locations environment variables, or ensure
+# the right version of java is in your PATH when versions earlier than nougat. See
+# https://source.android.com/source/requirements#jdk for more details.
+#
+# See README.md for additional instructions
+
+JAVA_6=/usr/lib/jvm/jdk1.6.0_45/bin
+JAVA_7=/usr/lib/jvm/java-7-openjdk-amd64/bin
+
+function usage() {
+    echo "Usage: ${0} <android root path> <android-version> <parallel jobs>"
+}
+
+if [[ $# -ne 3 ]]; then
+    usage
+    exit 1
+fi
+
+set -ex
+
+ANDROID_VERSION=$2
+SRC_ROOT=$1/aosp-$ANDROID_VERSION
+J=$3
+
+sync_source() {
+    repo init -q --depth=1 -uhttps://android.googlesource.com/platform/manifest -b android-$ANDROID_VERSION
+    repo sync -cq -j$J
+}
+
+build_source() {
+    source build/envsetup.sh
+
+    if [[ "${ANDROID_VERSION}" == "4.1.2_r1" ]]; then
+        lunch generic_x86-eng
+        export PATH=$JAVA_6:$PATH
+        make -j$J
+    elif [[ "${ANDROID_VERSION}" == "4.2.2_r1.2" ]]; then
+        lunch generic_x86-eng
+        export PATH=$JAVA_6:$PATH
+        make -j$J
+    elif [[ "${ANDROID_VERSION}" == "4.3_r2" ]]; then
+        lunch aosp_x86-eng
+        export PATH=$JAVA_6:$PATH
+        make -j$J
+    elif [[ "${ANDROID_VERSION}" == "4.4_r1" ]]; then
+        lunch aosp_x86-eng
+        export PATH=$JAVA_6:$PATH
+        make -j$J
+    elif [[ "${ANDROID_VERSION}" == "5.0.2_r3" ]]; then
+        lunch aosp_x86-eng
+        tapas core-libart services services.accessibility telephony-common framework ext framework-res
+        export PATH=$JAVA_7:$PATH
+        ANDROID_COMPILE_WITH_JACK=false make -j$J
+    elif [[ "${ANDROID_VERSION}" == "5.1.1_r9" ]]; then
+        tapas core-libart services services.accessibility telephony-common framework ext framework-res
+        export PATH=$JAVA_7:$PATH
+        ANDROID_COMPILE_WITH_JACK=false make -j$J
+    elif [[ "${ANDROID_VERSION}" == "6.0.1_r3" ]]; then
+        tapas core-libart services services.accessibility telephony-common framework ext icu4j-icudata-jarjar framework-res
+        export PATH=$JAVA_7:$PATH
+        ANDROID_COMPILE_WITH_JACK=false make -j$J
+    elif [[ "${ANDROID_VERSION}" == "7.0.0_r1" ]]; then
+        cd ../..
+        lunch aosp_x86-eng
+        make -j$J
+        make -j$J out/target/common/obj/JAVA_LIBRARIES/services_intermediates/classes.jar out/host/linux-x86/framework/icu4j-icudata-host-jarjar.jar out/host/linux-x86/framework/icu4j-icutzdata-host-jarjar.jar
+    elif [[ "${ANDROID_VERSION}" == "7.1.0_r7" ]]; then
+        cd frameworks/base && git fetch https://android.googlesource.com/platform/frameworks/base refs/changes/75/310575/1 && git cherry-pick FETCH_HEAD && git commit -a -m "patch shortcut service"
+        cd ../..
+        lunch aosp_x86-eng
+        make -j$J
+        make -j$J out/target/common/obj/JAVA_LIBRARIES/services_intermediates/classes.jar out/host/linux-x86/framework/icu4j-icudata-host-jarjar.jar out/host/linux-x86/framework/icu4j-icutzdata-host-jarjar.jar
+    elif [[ "${ANDROID_VERSION}" == "8.0.0_r4" ]]; then
+        cd external/robolectric && git fetch https://android.googlesource.com/platform/external/robolectric refs/changes/24/516524/1 && git cherry-pick FETCH_HEAD
+        cd ../..
+        lunch aosp_x86-eng
+        make -j$J robolectric_android-all
+    else
+        echo "Robolectric: No match for version: ${ANDROID_VERSION}"
+        exit 1
+    fi
+}
+
+mkdir -p $SRC_ROOT
+cd $SRC_ROOT
+
+sync_source
+build_source
+
+echo "Done building $SRC_ROOT!!"
+
diff --git a/scripts/update-cpp.sh b/scripts/update-cpp.sh
new file mode 100755
index 0000000..216815b
--- /dev/null
+++ b/scripts/update-cpp.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+
+set -e
+
+#currentVersion=android-8.0.0_r36
+#currentVersion=android-8.1.0_r22
+currentVersion=android-9.0.0_r12
+
+baseDir=`dirname $0`/..
+frameworksBaseRepoDir="$HOME/Dev/AOSP/frameworks/base"
+
+function showDiffs2() {
+  file="$1"
+  line="$2"
+
+  x=$(echo "$line" | sed -e 's/.*https:\/\/android.googlesource.com\/\([^ ]*\)\/[+]\/\([^/]*\)\/\([^ ]*\).*/\1 \2 \3/')
+  IFS=" " read -a parts <<< "$x"
+  repo="${parts[0]}"
+  version="${parts[1]}"
+  repoFile="${parts[2]}"
+
+  curSha=$(cd "$frameworksBaseRepoDir" && git rev-parse --verify "$currentVersion") || true
+  if [[ -z "$curSha" ]]; then
+    echo "Unknown $currentVersion!"
+    exit 1
+  fi
+
+  thisSha=$(cd "$frameworksBaseRepoDir" && git rev-parse --verify "$version") || true
+  if [[ -z "$thisSha" ]]; then
+    echo "Unknown $version!"
+    return
+  fi
+
+  if [ "x$curSha" != "x$thisSha" ]; then
+    (cd "$frameworksBaseRepoDir" && git diff --quiet "${version}..${currentVersion}" "--" "$repoFile")
+    if [ $? -eq 0 ]; then
+      echo "No changes in: $file"
+      echo "  From $repoFile $version -> $currentVersion"
+    else
+      tmpFile="/tmp/diff.tmp"
+      rm -f "$tmpFile"
+      echo "Apply changes to: $file" > "$tmpFile"
+      echo "  From $repoFile $version -> $currentVersion" >> "$tmpFile"
+      (cd "$frameworksBaseRepoDir" && git diff --color=always "${version}..${currentVersion}" "--" "$repoFile" >> "$tmpFile")
+      less -r "$tmpFile"
+    fi
+  fi
+}
+
+function showDiffs() {
+  file="$1"
+
+  grep -E 'https?:\/\/(android\.googlesource\.com|.*\.git\.corp\.google\.com)\/' "$file" | \
+      while read -r line ; do
+    showDiffs2 "$file" "$line"
+  done
+}
+
+files=$*
+
+if [ -z "$files" ]; then
+  find . -name "*.java" -print0 | while read -d $'\0' file; do
+    showDiffs "$file"
+  done
+else
+  for file in "$files"; do
+    showDiffs "$file"
+  done
+fi
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..9a187d8
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,39 @@
+rootProject.name = 'robolectric'
+
+include ":robolectric"
+include ":sandbox"
+include ":junit"
+include ":utils"
+include ":utils:reflector"
+include ":pluginapi"
+include ":plugins:accessibility-deprecated"
+include ":plugins:maven-dependency-resolver"
+include ":preinstrumented"
+include ":processor"
+include ":resources"
+include ":annotations"
+include ":shadows:framework"
+include ":shadows:httpclient"
+include ":shadows:multidex"
+include ":shadows:playservices"
+include ":shadowapi"
+include ":errorprone"
+include ":nativeruntime"
+include ":integration_tests:agp"
+include ":integration_tests:agp:testsupport"
+include ":integration_tests:dependency-on-stubs"
+include ":integration_tests:libphonenumber"
+include ":integration_tests:memoryleaks"
+include ":integration_tests:mockito"
+include ":integration_tests:mockito-kotlin"
+include ":integration_tests:mockito-experimental"
+include ":integration_tests:powermock"
+include ':integration_tests:androidx'
+include ':integration_tests:androidx_test'
+include ':integration_tests:ctesque'
+include ':integration_tests:security-providers'
+include ":integration_tests:mockk"
+include ':integration_tests:compat-target28'
+include ":integration_tests:multidex"
+include ":integration_tests:sparsearray"
+include ':testapp'
diff --git a/shadowapi/build.gradle b/shadowapi/build.gradle
new file mode 100644
index 0000000..f63d048
--- /dev/null
+++ b/shadowapi/build.gradle
@@ -0,0 +1,15 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+
+    api project(":annotations")
+    api project(":utils")
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+}
\ No newline at end of file
diff --git a/shadowapi/src/main/java/org/robolectric/annotation/internal/DoNotInstrument.java b/shadowapi/src/main/java/org/robolectric/annotation/internal/DoNotInstrument.java
new file mode 100644
index 0000000..4a77d97
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/annotation/internal/DoNotInstrument.java
@@ -0,0 +1,16 @@
+package org.robolectric.annotation.internal;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that a class should not be stripped/instrumented under any circumstances.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE})
+public @interface DoNotInstrument {
+}
diff --git a/shadowapi/src/main/java/org/robolectric/annotation/internal/Instrument.java b/shadowapi/src/main/java/org/robolectric/annotation/internal/Instrument.java
new file mode 100644
index 0000000..c53e72c
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/annotation/internal/Instrument.java
@@ -0,0 +1,16 @@
+package org.robolectric.annotation.internal;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that a class should always be instrumented regardless of its package.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE})
+public @interface Instrument {
+}
diff --git a/shadowapi/src/main/java/org/robolectric/config/ConfigurationRegistry.java b/shadowapi/src/main/java/org/robolectric/config/ConfigurationRegistry.java
new file mode 100644
index 0000000..dd8170d
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/config/ConfigurationRegistry.java
@@ -0,0 +1,86 @@
+package org.robolectric.config;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Holds configuration objects for the current test, computed using {@link Configurer}.
+ *
+ * <p>Configuration is computed before tests run, outside of their sandboxes. If the configuration
+ * is needed from within a sandbox (when a test is executing), we need to transfer it to a class
+ * that the SandboxClassLoader recognizes. We do this by serializing and deserializing in {@link
+ * #maybeReloadInSandboxClassLoader(Object)}.
+ */
+public class ConfigurationRegistry {
+
+  public static ConfigurationRegistry instance;
+
+  /**
+   * Returns the configuration object of the specified class, computed using
+   * {@link Configurer}.
+   */
+  public static <T> T get(Class<T> configClass) {
+    return instance.getInSandboxClassLoader(configClass);
+  }
+
+  private final Map<String, Object> configurations = new HashMap<>();
+
+  public ConfigurationRegistry(Map<Class<?>, Object> configClassMap) {
+    for (Class<?> classInParentLoader : configClassMap.keySet()) {
+      Object configInParentLoader = configClassMap.get(classInParentLoader);
+      configurations.put(classInParentLoader.getName(), configInParentLoader);
+    }
+  }
+
+  private <T> T getInSandboxClassLoader(Class<T> someConfigClass) {
+    Object configInParentLoader = configurations.get(someConfigClass.getName());
+    if (configInParentLoader == null) {
+      return null;
+    }
+    Object configInSandboxLoader = maybeReloadInSandboxClassLoader(configInParentLoader);
+    return someConfigClass.cast(configInSandboxLoader);
+  }
+
+  /**
+   * Reloads the value of the config in the current class loader. This has to be done in case of
+   * custom {@link org.robolectric.pluginapi.config.Configurer} classes. If there is a custom
+   * Configurer class, the config value will be initialized in the Application ClassLoader, before
+   * any tests are run. However, because custom config classes will typically not be included in
+   * {@link AndroidConfigurer}, they will be acquired by the Robolectric ClassLoader and redefined
+   * whenever they are referenced during the lifecycle of a Robolectric test. This causes a problem
+   * because an object of ConfigClass[ApplicationClassLoader] cannot be cast to a
+   * ConfigClass[RobolectricClassLoader].
+   *
+   * <p>Note this logic is not required for built-in Config classes because there are rules in
+   * {@link AndroidConfigurer} to exclude org.robolectric.annotation.* classes from being acquired
+   * by Robolectric ClassLoaders.
+   */
+  @SuppressWarnings("BanSerializableRead")
+  private static Object maybeReloadInSandboxClassLoader(Object configInParentLoader) {
+    // Avoid reloading for built-in config from the org.robolectric.annotation package. This package
+    // is excluded from instrumentation, so it will always exist in the Application ClassLoader.
+    if (configInParentLoader.getClass().getName().startsWith("org.robolectric.annotation.")) {
+      return configInParentLoader;
+    }
+    ByteArrayOutputStream buf = new ByteArrayOutputStream();
+    try (ObjectOutputStream out = new ObjectOutputStream(buf)) {
+      out.writeObject(configInParentLoader);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    byte[] bytes = buf.toByteArray();
+
+    // ObjectInputStream loads classes in the current classloader by magic
+    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+      return in.readObject();
+    } catch (IOException | ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/shadowapi/src/main/java/org/robolectric/internal/IShadow.java b/shadowapi/src/main/java/org/robolectric/internal/IShadow.java
new file mode 100644
index 0000000..d46cbe7
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/internal/IShadow.java
@@ -0,0 +1,36 @@
+package org.robolectric.internal;
+
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings("TypeParameterUnusedInFormals")
+public interface IShadow {
+  <T> T extract(Object instance);
+
+  <T> T newInstanceOf(Class<T> clazz);
+
+  <T> T newInstance(Class<T> clazz, Class[] parameterTypes, Object[] params);
+
+  /**
+   * Returns a proxy object that invokes the original $$robo$$-prefixed methods for {@code
+   * shadowedObject}.
+   *
+   * @deprecated This is incompatible with JDK17+. Use a {@link
+   *     org.robolectric.util.reflector.Reflector} interface with {@link
+   *     org.robolectric.util.reflector.Direct}.
+   */
+  @Deprecated
+  <T> T directlyOn(T shadowedObject, Class<T> clazz);
+
+  @SuppressWarnings("unchecked")
+  <R> R directlyOn(Object shadowedObject, String clazzName, String methodName, ReflectionHelpers.ClassParameter... paramValues);
+
+  <R, T> R directlyOn(T shadowedObject, Class<T> clazz, String methodName, ReflectionHelpers.ClassParameter... paramValues);
+
+  <R, T> R directlyOn(Class<T> clazz, String methodName, ReflectionHelpers.ClassParameter... paramValues);
+
+  <R> R invokeConstructor(Class<? extends R> clazz, R instance, ReflectionHelpers.ClassParameter... paramValues);
+
+  String directMethodName(String className, String methodName);
+
+  void directInitialize(Class<?> clazz);
+}
diff --git a/shadowapi/src/main/java/org/robolectric/internal/ShadowProvider.java b/shadowapi/src/main/java/org/robolectric/internal/ShadowProvider.java
new file mode 100644
index 0000000..b3fc6f8
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/internal/ShadowProvider.java
@@ -0,0 +1,49 @@
+package org.robolectric.internal;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/**
+ * Interface implemented by packages that provide shadows to Robolectric.
+ */
+@SuppressWarnings("NewApi")
+public interface ShadowProvider {
+
+  /**
+   * Reset the static state of all shadows provided by this package.
+   */
+  void reset();
+
+  /**
+   * Array of Java package names that are shadowed by this package.
+   *
+   * @return  Array of Java package names.
+   */
+  String[] getProvidedPackageNames();
+
+  /**
+   * Return a collection of Map.Entry objects representing the mapping of class name to shadow name.
+   *
+   * <p>This is a multimap instead of a regular map in order to support, for instance, multiple
+   * shadows per class that only differ by SDK level.
+   *
+   * <p>It also uses a {@code Collection<Entry<String, String>>} as the return value to avoid having
+   * a dependency on something like Guava Multimap.
+   *
+   * @return Shadow mapping.
+   */
+  Collection<Entry<String, String>> getShadows();
+
+  /**
+   * Map of framework classes which may be represented by more than one shadow, to be picked
+   * at runtime.
+   *
+   * @return A map from the name of the framework class to the name of its
+   *     {#link org.robolectric.shadow.apiShadowPicker}.
+   */
+  default Map<String, String> getShadowPickerMap() {
+    return Collections.emptyMap();
+  }
+}
diff --git a/shadowapi/src/main/java/org/robolectric/shadow/api/Shadow.java b/shadowapi/src/main/java/org/robolectric/shadow/api/Shadow.java
new file mode 100644
index 0000000..7c01b89
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/shadow/api/Shadow.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadow.api;
+
+import org.robolectric.internal.IShadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+public class Shadow {
+  @SuppressWarnings("unused")
+  private static IShadow SHADOW_IMPL;
+
+  static {
+    try {
+      SHADOW_IMPL = Class.forName("org.robolectric.internal.bytecode.ShadowImpl")
+          .asSubclass(IShadow.class).getDeclaredConstructor().newInstance();
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Retrieve corresponding Shadow of the object.
+   * @since 3.3
+   */
+  @SuppressWarnings("TypeParameterUnusedInFormals")
+  public static <T> T extract(Object instance) {
+    return SHADOW_IMPL.extract(instance);
+  }
+
+  public static <T> T newInstanceOf(Class<T> clazz) {
+    return SHADOW_IMPL.newInstanceOf(clazz);
+  }
+
+  public static Object newInstanceOf(String className) {
+    try {
+      Class<?> aClass = Shadow.class.getClassLoader().loadClass(className);
+      return SHADOW_IMPL.newInstanceOf(aClass);
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
+
+  public static <T> T newInstance(Class<T> clazz, Class[] parameterTypes, Object[] params) {
+    return SHADOW_IMPL.newInstance(clazz, parameterTypes, params);
+  }
+
+  /**
+   * Returns a proxy object that invokes the original $$robo$$-prefixed methods whenever a method on
+   * the proxy is invoked. This is primarily used to invoke original methods in shadow
+   * implementations.
+   *
+   * @deprecated This is incompatible with JDK17+. Use a {@link
+   *     org.robolectric.util.reflector.Reflector} interface with {@link
+   *     org.robolectric.util.reflector.Direct}.
+   */
+  @Deprecated
+  public static <T> T directlyOn(T shadowedObject, Class<T> clazz) {
+    return SHADOW_IMPL.directlyOn(shadowedObject, clazz);
+  }
+
+  @SuppressWarnings(value = {"unchecked", "TypeParameterUnusedInFormals"})
+  public static <R> R directlyOn(Object shadowedObject, String clazzName, String methodName, ClassParameter... paramValues) {
+    return SHADOW_IMPL.directlyOn(shadowedObject, clazzName, methodName, paramValues);
+  }
+
+  @SuppressWarnings("TypeParameterUnusedInFormals")
+  public static <R, T> R directlyOn(T shadowedObject, Class<T> clazz, String methodName, ClassParameter... paramValues) {
+    return SHADOW_IMPL.directlyOn(shadowedObject, clazz, methodName, paramValues);
+  }
+
+  @SuppressWarnings("TypeParameterUnusedInFormals")
+  public static <R, T> R directlyOn(Class<T> clazz, String methodName, ClassParameter... paramValues) {
+    return SHADOW_IMPL.directlyOn(clazz, methodName, paramValues);
+  }
+
+  public static <R> R invokeConstructor(Class<? extends R> clazz, R instance, ClassParameter... paramValues) {
+    return SHADOW_IMPL.invokeConstructor(clazz, instance, paramValues);
+  }
+
+  public static String directMethodName(String className, String methodName) {
+    return SHADOW_IMPL.directMethodName(className, methodName);
+  }
+
+  public static void directInitialize(Class<?> clazz) {
+    SHADOW_IMPL.directInitialize(clazz);
+  }
+}
diff --git a/shadowapi/src/main/java/org/robolectric/util/ReflectionHelpers.java b/shadowapi/src/main/java/org/robolectric/util/ReflectionHelpers.java
new file mode 100644
index 0000000..eaaee1a
--- /dev/null
+++ b/shadowapi/src/main/java/org/robolectric/util/ReflectionHelpers.java
@@ -0,0 +1,524 @@
+package org.robolectric.util;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Proxy;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/** Collection of helper methods for calling methods and accessing fields reflectively. */
+@SuppressWarnings(value = {"unchecked", "TypeParameterUnusedInFormals", "NewApi"})
+public class ReflectionHelpers {
+
+  private static final Map<String, Object> PRIMITIVE_RETURN_VALUES;
+  private static final PerfStatsCollector perfStatsCollector = PerfStatsCollector.getInstance();
+
+  static {
+    HashMap<String, Object> map = new HashMap<>();
+    map.put("boolean", Boolean.FALSE);
+    map.put("int", 0);
+    map.put("long", (long) 0);
+    map.put("float", (float) 0);
+    map.put("double", (double) 0);
+    map.put("short", (short) 0);
+    map.put("byte", (byte) 0);
+    PRIMITIVE_RETURN_VALUES = Collections.unmodifiableMap(map);
+  }
+
+  public static <T> T createNullProxy(Class<T> clazz) {
+    return (T)
+        Proxy.newProxyInstance(
+            clazz.getClassLoader(),
+            new Class<?>[] {clazz},
+            (proxy, method, args) -> PRIMITIVE_RETURN_VALUES.get(method.getReturnType().getName()));
+  }
+
+  /**
+   * Create a proxy for the given class which returns other deep proxies from all it's methods.
+   *
+   * <p>The returned object will be an instance of the given class, but all methods will return
+   * either the "default" value for primitives, or another deep proxy for non-primitive types.
+   *
+   * <p>This should be used rarely, for cases where we need to create deep proxies in order not
+   * to crash. The inner proxies are impossible to configure, so there is no way to create
+   * meaningful behavior from a deep proxy. It serves mainly to prevent Null Pointer Exceptions.
+   * @param clazz the class to provide a proxy instance of.
+   * @return a new "Deep Proxy" instance of the given class.
+   */
+  public static <T> T createDeepProxy(Class<T> clazz) {
+    return (T)
+        Proxy.newProxyInstance(
+            clazz.getClassLoader(),
+            new Class[] {clazz},
+            (proxy, method, args) -> {
+              if (PRIMITIVE_RETURN_VALUES.containsKey(method.getReturnType().getName())) {
+                return PRIMITIVE_RETURN_VALUES.get(method.getReturnType().getName());
+              } else if (method.getReturnType().isInterface()) {
+                return createDeepProxy(method.getReturnType());
+              } else {
+                return null;
+              }
+            });
+  }
+
+  public static <T> T createDelegatingProxy(Class<T> clazz, final Object delegate) {
+    final Class delegateClass = delegate.getClass();
+    return (T)
+        Proxy.newProxyInstance(
+            clazz.getClassLoader(),
+            new Class[] {clazz},
+            (proxy, method, args) -> {
+              try {
+                Method delegateMethod =
+                    delegateClass.getMethod(method.getName(), method.getParameterTypes());
+                delegateMethod.setAccessible(true);
+                return delegateMethod.invoke(delegate, args);
+              } catch (NoSuchMethodException e) {
+                return PRIMITIVE_RETURN_VALUES.get(method.getReturnType().getName());
+              } catch (InvocationTargetException e) {
+                // Required to propagate the correct throwable.
+                throw e.getTargetException();
+              }
+            });
+  }
+
+  public static <A extends Annotation> A defaultsFor(Class<A> annotation) {
+    return annotation.cast(
+        Proxy.newProxyInstance(
+            annotation.getClassLoader(),
+            new Class[] {annotation},
+            (proxy, method, args) -> method.getDefaultValue()));
+  }
+
+  /**
+   * Reflectively get the value of a field.
+   *
+   * @param object Target object.
+   * @param fieldName The field name.
+   * @param <R> The return type.
+   * @return Value of the field on the object.
+   */
+  @SuppressWarnings("unchecked")
+  public static <R> R getField(final Object object, final String fieldName) {
+    try {
+      return traverseClassHierarchy(
+          object.getClass(),
+          NoSuchFieldException.class,
+          traversalClass -> {
+            Field field = traversalClass.getDeclaredField(fieldName);
+            field.setAccessible(true);
+            return (R) field.get(object);
+          });
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively set the value of a field.
+   *
+   * @param object Target object.
+   * @param fieldName The field name.
+   * @param fieldNewValue New value.
+   */
+  public static void setField(final Object object, final String fieldName, final Object fieldNewValue) {
+    try {
+      traverseClassHierarchy(
+          object.getClass(),
+          NoSuchFieldException.class,
+          (InsideTraversal<Void>)
+              traversalClass -> {
+                Field field = traversalClass.getDeclaredField(fieldName);
+                field.setAccessible(true);
+                field.set(object, fieldNewValue);
+                return null;
+              });
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively set the value of a field.
+   *
+   * @param type Target type.
+   * @param object Target object.
+   * @param fieldName The field name.
+   * @param fieldNewValue New value.
+   */
+  public static void setField(Class<?> type, final Object object, final String fieldName, final Object fieldNewValue) {
+    try {
+      Field field = type.getDeclaredField(fieldName);
+      field.setAccessible(true);
+      field.set(object, fieldNewValue);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively get the value of a static field.
+   *
+   * @param field Field object.
+   * @param <R> The return type.
+   * @return Value of the field.
+   */
+  @SuppressWarnings("unchecked")
+  public static <R> R getStaticField(Field field) {
+    try {
+      field.setAccessible(true);
+      return (R) field.get(null);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively get the value of a static field.
+   *
+   * @param clazz Target class.
+   * @param fieldName The field name.
+   * @param <R> The return type.
+   * @return Value of the field.
+   */
+  public static <R> R getStaticField(Class<?> clazz, String fieldName) {
+    try {
+      return getStaticField(clazz.getDeclaredField(fieldName));
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively set the value of a static field.
+   *
+   * @param field Field object.
+   * @param fieldNewValue The new value.
+   */
+  public static void setStaticField(Field field, Object fieldNewValue) {
+    try {
+      if ((field.getModifiers() & Modifier.FINAL) == Modifier.FINAL) {
+        throw new IllegalArgumentException("Cannot set the value of final field " + field);
+      }
+      field.setAccessible(true);
+      field.set(null, fieldNewValue);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively set the value of a static field.
+   *
+   * @param clazz Target class.
+   * @param fieldName The field name.
+   * @param fieldNewValue The new value.
+   */
+  public static void setStaticField(Class<?> clazz, String fieldName, Object fieldNewValue) {
+    try {
+      setStaticField(clazz.getDeclaredField(fieldName), fieldNewValue);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively call an instance method on an object.
+   *
+   * @param instance Target object.
+   * @param methodName The method name to call.
+   * @param classParameters Array of parameter types and values.
+   * @param <R> The return type.
+   * @return The return value of the method.
+   */
+  public static <R> R callInstanceMethod(
+      final Object instance, final String methodName, ClassParameter<?>... classParameters) {
+    perfStatsCollector.incrementCount(
+        String.format(
+            "ReflectionHelpers.callInstanceMethod-%s_%s",
+            instance.getClass().getName(), methodName));
+    try {
+      final Class<?>[] classes = ClassParameter.getClasses(classParameters);
+      final Object[] values = ClassParameter.getValues(classParameters);
+
+      return traverseClassHierarchy(
+          instance.getClass(),
+          NoSuchMethodException.class,
+          traversalClass -> {
+            Method declaredMethod = traversalClass.getDeclaredMethod(methodName, classes);
+            declaredMethod.setAccessible(true);
+            return (R) declaredMethod.invoke(instance, values);
+          });
+    } catch (InvocationTargetException e) {
+      if (e.getTargetException() instanceof RuntimeException) {
+        throw (RuntimeException) e.getTargetException();
+      }
+      if (e.getTargetException() instanceof Error) {
+        throw (Error) e.getTargetException();
+      }
+      throw new RuntimeException(e.getTargetException());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively call an instance method on an object on a specific class.
+   *
+   * @param cl The class.
+   * @param instance Target object.
+   * @param methodName The method name to call.
+   * @param classParameters Array of parameter types and values.
+   * @param <R> The return type.
+   * @return The return value of the method.
+   */
+  public static <R> R callInstanceMethod(
+      Class<?> cl,
+      final Object instance,
+      final String methodName,
+      ClassParameter<?>... classParameters) {
+    perfStatsCollector.incrementCount(
+        String.format("ReflectionHelpers.callInstanceMethod-%s_%s", cl.getName(), methodName));
+    try {
+      final Class<?>[] classes = ClassParameter.getClasses(classParameters);
+      final Object[] values = ClassParameter.getValues(classParameters);
+
+      Method method = cl.getDeclaredMethod(methodName, classes);
+      method.setAccessible(true);
+      if (Modifier.isStatic(method.getModifiers())) {
+        throw new IllegalArgumentException(method + " is static");
+      }
+      return (R) method.invoke(instance, values);
+    } catch (InvocationTargetException e) {
+      if (e.getTargetException() instanceof RuntimeException) {
+        throw (RuntimeException) e.getTargetException();
+      }
+      if (e.getTargetException() instanceof Error) {
+        throw (Error) e.getTargetException();
+      }
+      throw new RuntimeException(e.getTargetException());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Helper method for calling a static method using a class from a custom class loader
+   *
+   * @param classLoader The ClassLoader used to load class
+   * @param fullyQualifiedClassName The full qualified class name with package name of the
+   *     ClassLoader will load
+   * @param methodName The method name will be called
+   * @param classParameters The input parameters will be used for method calling
+   * @param <R> Return type of the method
+   * @return Return the value of the method
+   */
+  public static <R> R callStaticMethod(
+      ClassLoader classLoader,
+      String fullyQualifiedClassName,
+      String methodName,
+      ClassParameter<?>... classParameters) {
+    Class<?> clazz = loadClass(classLoader, fullyQualifiedClassName);
+    return callStaticMethod(clazz, methodName, classParameters);
+  }
+
+  /**
+   * Reflectively call a static method on a class.
+   *
+   * @param clazz Target class.
+   * @param methodName The method name to call.
+   * @param classParameters Array of parameter types and values.
+   * @param <R> The return type.
+   * @return The return value of the method.
+   */
+  @SuppressWarnings("unchecked")
+  public static <R> R callStaticMethod(
+      Class<?> clazz, String methodName, ClassParameter<?>... classParameters) {
+    perfStatsCollector.incrementCount(
+        String.format("ReflectionHelpers.callStaticMethod-%s_%s", clazz.getName(), methodName));
+    try {
+      Class<?>[] classes = ClassParameter.getClasses(classParameters);
+      Object[] values = ClassParameter.getValues(classParameters);
+
+      Method method = clazz.getDeclaredMethod(methodName, classes);
+      method.setAccessible(true);
+      if (!Modifier.isStatic(method.getModifiers())) {
+        throw new IllegalArgumentException(method + " is not static");
+      }
+      return (R) method.invoke(null, values);
+    } catch (InvocationTargetException e) {
+      if (e.getTargetException() instanceof RuntimeException) {
+        throw (RuntimeException) e.getTargetException();
+      }
+      if (e.getTargetException() instanceof Error) {
+        throw (Error) e.getTargetException();
+      }
+      throw new RuntimeException(e.getTargetException());
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException("no such method " + clazz + "." + methodName, e);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Load a class.
+   *
+   * @param classLoader The class loader.
+   * @param fullyQualifiedClassName The fully qualified class name.
+   * @return The class object.
+   */
+  public static Class<?> loadClass(ClassLoader classLoader, String fullyQualifiedClassName) {
+    try {
+      return classLoader.loadClass(fullyQualifiedClassName);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Create a new instance of a class
+   *
+   * @param cl The class object.
+   * @param <T> The class type.
+   * @return New class instance.
+   */
+  public static <T> T newInstance(Class<T> cl) {
+    try {
+      return cl.getDeclaredConstructor().newInstance();
+    } catch (InstantiationException | IllegalAccessException | NoSuchMethodException
+        | InvocationTargetException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reflectively call the constructor of an object.
+   *
+   * @param clazz Target class.
+   * @param classParameters Array of parameter types and values.
+   * @param <R> The return type.
+   * @return The return value of the method.
+   */
+  public static <R> R callConstructor(
+      Class<? extends R> clazz, ClassParameter<?>... classParameters) {
+    perfStatsCollector.incrementCount("ReflectionHelpers.callConstructor-" + clazz.getName());
+    try {
+      final Class<?>[] classes = ClassParameter.getClasses(classParameters);
+      final Object[] values = ClassParameter.getValues(classParameters);
+
+      Constructor<? extends R> constructor = clazz.getDeclaredConstructor(classes);
+      constructor.setAccessible(true);
+      return constructor.newInstance(values);
+    } catch (InstantiationException e) {
+      throw new RuntimeException("error instantiating " + clazz.getName(), e);
+    } catch (InvocationTargetException e) {
+      if (e.getTargetException() instanceof RuntimeException) {
+        throw (RuntimeException) e.getTargetException();
+      }
+      if (e.getTargetException() instanceof Error) {
+        throw (Error) e.getTargetException();
+      }
+      throw new RuntimeException(e.getTargetException());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static <R, E extends Exception> R traverseClassHierarchy(
+      Class<?> targetClass, Class<? extends E> exceptionClass, InsideTraversal<R> insideTraversal)
+      throws Exception {
+    Class<?> hierarchyTraversalClass = targetClass;
+    while (true) {
+      try {
+        return insideTraversal.run(hierarchyTraversalClass);
+      } catch (Exception e) {
+        if (!exceptionClass.isInstance(e)) {
+          throw e;
+        }
+        hierarchyTraversalClass = hierarchyTraversalClass.getSuperclass();
+        if (hierarchyTraversalClass == null) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+  }
+
+  public static Object defaultValueForType(String returnType) {
+    return PRIMITIVE_RETURN_VALUES.get(returnType);
+  }
+
+  private interface InsideTraversal<R> {
+    R run(Class<?> traversalClass) throws Exception;
+  }
+
+  /**
+   * Typed parameter used with reflective method calls.
+   *
+   * @param <V> The value of the method parameter.
+   */
+  public static class ClassParameter<V> {
+    public final Class<? extends V> clazz;
+    public final V val;
+
+    public ClassParameter(Class<? extends V> clazz, V val) {
+      this.clazz = clazz;
+      this.val = val;
+    }
+
+    public static <V> ClassParameter<V> from(Class<? extends V> clazz, V val) {
+      return new ClassParameter<>(clazz, val);
+    }
+
+    public static ClassParameter<?>[] fromComponentLists(Class<?>[] classes, Object[] values) {
+      ClassParameter<?>[] classParameters = new ClassParameter[classes.length];
+      for (int i = 0; i < classes.length; i++) {
+        classParameters[i] = ClassParameter.from(classes[i], values[i]);
+      }
+      return classParameters;
+    }
+
+    public static Class<?>[] getClasses(ClassParameter<?>... classParameters) {
+      Class<?>[] classes = new Class[classParameters.length];
+      for (int i = 0; i < classParameters.length; i++) {
+        Class<?> paramClass = classParameters[i].clazz;
+        classes[i] = paramClass;
+      }
+      return classes;
+    }
+
+    public static Object[] getValues(ClassParameter<?>... classParameters) {
+      Object[] values = new Object[classParameters.length];
+      for (int i = 0; i < classParameters.length; i++) {
+        Object paramValue = classParameters[i].val;
+        values[i] = paramValue;
+      }
+      return values;
+    }
+  }
+
+  /**
+   * String parameter used with reflective method calls.
+   *
+   * @param <V> The value of the method parameter.
+   */
+  public static class StringParameter<V> {
+    public final String className;
+    public final V val;
+
+    public StringParameter(String className, V val) {
+      this.className = className;
+      this.val = val;
+    }
+
+    public static <V> StringParameter<V> from(String className, V val) {
+      return new StringParameter<>(className, val);
+    }
+  }
+}
diff --git a/shadowapi/src/test/java/org/robolectric/util/ReflectionHelpersTest.java b/shadowapi/src/test/java/org/robolectric/util/ReflectionHelpersTest.java
new file mode 100644
index 0000000..814efd6
--- /dev/null
+++ b/shadowapi/src/test/java/org/robolectric/util/ReflectionHelpersTest.java
@@ -0,0 +1,411 @@
+package org.robolectric.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import java.lang.reflect.Field;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@RunWith(JUnit4.class)
+public class ReflectionHelpersTest {
+
+  @Test
+  public void getFieldReflectively_getsPrivateFields() {
+    ExampleDescendant example = new ExampleDescendant();
+    example.overridden = 5;
+    assertThat((int) ReflectionHelpers.getField(example, "overridden")).isEqualTo(5);
+  }
+
+  @Test
+  public void getFieldReflectively_getsInheritedFields() {
+    ExampleDescendant example = new ExampleDescendant();
+    example.setNotOverridden(6);
+    assertThat((int) ReflectionHelpers.getField(example, "notOverridden")).isEqualTo(6);
+  }
+
+  @Test
+  public void getFieldReflectively_givesHelpfulExceptions() {
+    ExampleDescendant example = new ExampleDescendant();
+    try {
+      ReflectionHelpers.getField(example, "nonExistent");
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      if (!e.getMessage().contains("nonExistent")) {
+        throw new RuntimeException("Incorrect exception thrown", e);
+      }
+    }
+  }
+
+  @Test
+  public void setFieldReflectively_setsPrivateFields() {
+    ExampleDescendant example = new ExampleDescendant();
+    example.overridden = 5;
+    ReflectionHelpers.setField(example, "overridden", 10);
+    assertThat(example.overridden).isEqualTo(10);
+  }
+
+  @Test
+  public void setFieldReflectively_setsInheritedFields() {
+    ExampleDescendant example = new ExampleDescendant();
+    example.setNotOverridden(5);
+    ReflectionHelpers.setField(example, "notOverridden", 10);
+    assertThat(example.getNotOverridden()).isEqualTo(10);
+  }
+
+  @Test
+  public void setFieldReflectively_givesHelpfulExceptions() {
+    ExampleDescendant example = new ExampleDescendant();
+    try {
+      ReflectionHelpers.setField(example, "nonExistent", 6);
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      if (!e.getMessage().contains("nonExistent")) {
+        throw new RuntimeException("Incorrect exception thrown", e);
+      }
+    }
+  }
+
+  @Test
+  public void getStaticFieldReflectively_withField_getsStaticField() throws Exception {
+    Field field = ExampleDescendant.class.getDeclaredField("DESCENDANT");
+
+    int result = ReflectionHelpers.getStaticField(field);
+    assertThat(result).isEqualTo(6);
+  }
+
+  @Test
+  public void getStaticFieldReflectively_withFieldName_getsStaticField() {
+    assertThat((int) ReflectionHelpers.getStaticField(ExampleDescendant.class, "DESCENDANT"))
+        .isEqualTo(6);
+  }
+
+  @Test
+  public void getFinalStaticFieldReflectively_withField_getsStaticField() throws Exception {
+    Field field = ExampleBase.class.getDeclaredField("BASE");
+
+    int result = ReflectionHelpers.getStaticField(field);
+    assertThat(result).isEqualTo(8);
+  }
+
+  @Test
+  public void getFinalStaticFieldReflectively_withFieldName_getsStaticField() throws Exception {
+    assertThat((int) ReflectionHelpers.getStaticField(ExampleBase.class, "BASE")).isEqualTo(8);
+  }
+
+  @Test
+  public void setStaticFieldReflectively_withField_setsStaticFields() throws Exception {
+    Field field = ExampleDescendant.class.getDeclaredField("DESCENDANT");
+    int startingValue = ReflectionHelpers.getStaticField(field);
+
+    ReflectionHelpers.setStaticField(field, 7);
+    assertWithMessage("startingValue").that(startingValue).isEqualTo(6);
+    assertWithMessage("DESCENDENT").that(ExampleDescendant.DESCENDANT).isEqualTo(7);
+
+    /// Reset the value to avoid test pollution
+    ReflectionHelpers.setStaticField(field, startingValue);
+  }
+
+  @Test
+  public void setStaticFieldReflectively_withFieldName_setsStaticFields() {
+    int startingValue = ReflectionHelpers.getStaticField(ExampleDescendant.class, "DESCENDANT");
+
+    ReflectionHelpers.setStaticField(ExampleDescendant.class, "DESCENDANT", 7);
+    assertWithMessage("startingValue").that(startingValue).isEqualTo(6);
+    assertWithMessage("DESCENDENT").that(ExampleDescendant.DESCENDANT).isEqualTo(7);
+
+    // Reset the value to avoid test pollution
+    ReflectionHelpers.setStaticField(ExampleDescendant.class, "DESCENDANT", startingValue);
+  }
+
+  @Test
+  public void setFinalStaticFieldReflectively_withFieldName_setsStaticFields() {
+    int startingValue = ReflectionHelpers.getStaticField(ExampleWithFinalStatic.class, "FIELD");
+
+    RuntimeException thrown =
+        assertThrows(
+            RuntimeException.class,
+            () -> ReflectionHelpers.setStaticField(ExampleWithFinalStatic.class, "FIELD", 101));
+    assertThat(thrown)
+        .hasCauseThat()
+        .hasMessageThat()
+        .contains("Cannot set the value of final field");
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_callsPrivateMethods() {
+    ExampleDescendant example = new ExampleDescendant();
+    assertThat((int) ReflectionHelpers.callInstanceMethod(example, "returnNumber")).isEqualTo(1337);
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_whenMultipleSignaturesExistForAMethodName_callsMethodWithCorrectSignature() {
+    ExampleDescendant example = new ExampleDescendant();
+    int returnNumber =
+        ReflectionHelpers.callInstanceMethod(
+            example, "returnNumber", ClassParameter.from(int.class, 5));
+    assertThat(returnNumber).isEqualTo(5);
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_callsInheritedMethods() {
+    ExampleDescendant example = new ExampleDescendant();
+    assertThat((int) ReflectionHelpers.callInstanceMethod(example, "returnNegativeNumber"))
+        .isEqualTo(-46);
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_givesHelpfulExceptions() {
+    ExampleDescendant example = new ExampleDescendant();
+    try {
+      ReflectionHelpers.callInstanceMethod(example, "nonExistent");
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      if (!e.getMessage().contains("nonExistent")) {
+        throw new RuntimeException("Incorrect exception thrown", e);
+      }
+    }
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_rethrowsUncheckedException() {
+    ExampleDescendant example = new ExampleDescendant();
+    try {
+      ReflectionHelpers.callInstanceMethod(example, "throwUncheckedException");
+      fail("Expected exception not thrown");
+    } catch (TestRuntimeException e) {
+    } catch (RuntimeException e) {
+      throw new RuntimeException("Incorrect exception thrown", e);
+    }
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_rethrowsError() {
+    ExampleDescendant example = new ExampleDescendant();
+    try {
+      ReflectionHelpers.callInstanceMethod(example, "throwError");
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      throw new RuntimeException("Incorrect exception thrown", e);
+    } catch (TestError e) {
+    }
+  }
+
+  @Test
+  public void callInstanceMethodReflectively_wrapsCheckedException() {
+    ExampleDescendant example = new ExampleDescendant();
+    try {
+      ReflectionHelpers.callInstanceMethod(example, "throwCheckedException");
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      assertThat(e.getCause()).isInstanceOf(TestException.class);
+    }
+  }
+
+  @Test
+  public void callStaticMethodReflectively_callsPrivateStaticMethodsReflectively() {
+    int constantNumber =
+        ReflectionHelpers.callStaticMethod(ExampleDescendant.class, "getConstantNumber");
+    assertThat(constantNumber).isEqualTo(1);
+  }
+
+  @Test
+  public void callStaticMethodReflectively_rethrowsUncheckedException() {
+    try {
+      ReflectionHelpers.callStaticMethod(ExampleDescendant.class, "staticThrowUncheckedException");
+      fail("Expected exception not thrown");
+    } catch (TestRuntimeException e) {
+    } catch (RuntimeException e) {
+      throw new RuntimeException("Incorrect exception thrown", e);
+    }
+  }
+
+  @Test
+  public void callStaticMethodReflectively_rethrowsError() {
+    try {
+      ReflectionHelpers.callStaticMethod(ExampleDescendant.class, "staticThrowError");
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      throw new RuntimeException("Incorrect exception thrown", e);
+    } catch (TestError e) {
+    }
+  }
+
+  @Test
+  public void callStaticMethodReflectively_wrapsCheckedException() {
+    try {
+      ReflectionHelpers.callStaticMethod(ExampleDescendant.class, "staticThrowCheckedException");
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      assertThat(e.getCause()).isInstanceOf(TestException.class);
+    }
+  }
+
+  @Test
+  public void callConstructorReflectively_callsPrivateConstructors() {
+    ExampleClass e = ReflectionHelpers.callConstructor(ExampleClass.class);
+    assertThat(e).isNotNull();
+  }
+
+  @Test
+  public void callConstructorReflectively_rethrowsUncheckedException() {
+    try {
+      ReflectionHelpers.callConstructor(ThrowsUncheckedException.class);
+      fail("Expected exception not thrown");
+    } catch (TestRuntimeException e) {
+    } catch (RuntimeException e) {
+      throw new RuntimeException("Incorrect exception thrown", e);
+    }
+  }
+
+  @Test
+  public void callConstructorReflectively_rethrowsError() {
+    try {
+      ReflectionHelpers.callConstructor(ThrowsError.class);
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      throw new RuntimeException("Incorrect exception thrown", e);
+    } catch (TestError e) {
+    }
+  }
+
+  @Test
+  public void callConstructorReflectively_wrapsCheckedException() {
+    try {
+      ReflectionHelpers.callConstructor(ThrowsCheckedException.class);
+      fail("Expected exception not thrown");
+    } catch (RuntimeException e) {
+      assertThat(e.getCause()).isInstanceOf(TestException.class);
+    }
+  }
+
+  @Test
+  public void callConstructorReflectively_whenMultipleSignaturesExistForTheConstructor_callsConstructorWithCorrectSignature() {
+    ExampleClass ec = ReflectionHelpers.callConstructor(ExampleClass.class, ClassParameter.from(int.class, 16));
+    assertWithMessage("index").that(ec.index).isEqualTo(16);
+    assertWithMessage("name").that(ec.name).isNull();
+  }
+
+  @SuppressWarnings("serial")
+  private static class TestError extends Error {
+  }
+
+  @SuppressWarnings("serial")
+  private static class TestException extends Exception {
+  }
+
+  @SuppressWarnings("serial")
+  private static class TestRuntimeException extends RuntimeException {
+  }
+
+  @SuppressWarnings("unused")
+  private static class ExampleBase {
+    private int notOverridden;
+    protected int overridden;
+
+    private static final int BASE = 8;
+
+    public int getNotOverridden() {
+      return notOverridden;
+    }
+
+    public void setNotOverridden(int notOverridden) {
+      this.notOverridden = notOverridden;
+    }
+
+    private int returnNegativeNumber() {
+      return -46;
+    }
+  }
+
+  @SuppressWarnings("unused")
+  private static class ExampleDescendant extends ExampleBase {
+
+    public static int DESCENDANT = 6;
+
+    @SuppressWarnings("HidingField")
+    protected int overridden;
+
+    private int returnNumber() {
+      return 1337;
+    }
+
+    private int returnNumber(int n) {
+      return n;
+    }
+
+    private static int getConstantNumber() {
+      return 1;
+    }
+
+    private void throwUncheckedException() {
+      throw new TestRuntimeException();
+    }
+
+    private void throwCheckedException() throws Exception {
+      throw new TestException();
+    }
+
+    private void throwError() {
+      throw new TestError();
+    }
+
+    private static void staticThrowUncheckedException() {
+      throw new TestRuntimeException();
+    }
+
+    private static void staticThrowCheckedException() throws Exception {
+      throw new TestException();
+    }
+
+    private static void staticThrowError() {
+      throw new TestError();
+    }
+  }
+
+  @SuppressWarnings("unused")
+  private static class ExampleWithFinalStatic {
+    private static final int FIELD = 100;
+  }
+
+  private static class ThrowsError {
+    @SuppressWarnings("unused")
+    public ThrowsError() {
+      throw new TestError();
+    }
+  }
+
+  private static class ThrowsCheckedException {
+    @SuppressWarnings("unused")
+    public ThrowsCheckedException() throws Exception {
+      throw new TestException();
+    }
+  }
+
+  private static class ThrowsUncheckedException {
+    @SuppressWarnings("unused")
+    public ThrowsUncheckedException() {
+      throw new TestRuntimeException();
+    }
+  }
+
+  private static class ExampleClass {
+    public String name;
+    public int index;
+
+    private ExampleClass() {
+    }
+
+    private ExampleClass(String name) {
+      this.name = name;
+    }
+
+    private ExampleClass(int index) {
+      this.index = index;
+    }
+  }
+}
diff --git a/shadows/framework/build.gradle b/shadows/framework/build.gradle
new file mode 100644
index 0000000..21160b6
--- /dev/null
+++ b/shadows/framework/build.gradle
@@ -0,0 +1,66 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+apply plugin: ShadowsPlugin
+
+shadows {
+    packageName "org.robolectric"
+    sdkCheckMode "ERROR"
+}
+
+configurations {
+    sqlite4java
+}
+
+task copySqliteNatives(type: Copy) {
+    from project.configurations.sqlite4java {
+        include '**/*.dll'
+        include '**/*.so'
+        include '**/*.dylib'
+        rename { String filename ->
+            def filenameMatch = filename =~ /^([^\-]+)-(.+)-${sqlite4javaVersion}\.(.+)/
+            if (filenameMatch) {
+                def platformFilename = filenameMatch[0][1]
+                def platformFolder = filenameMatch[0][2]
+                def platformExtension = filenameMatch[0][3]
+
+                "${platformFolder}/${platformFilename}.${platformExtension}"
+            }
+        }
+    }
+    into project.file("$buildDir/resources/main/sqlite4java")
+}
+
+jar {
+    dependsOn copySqliteNatives
+}
+
+dependencies {
+    api project(":annotations")
+    api project(":nativeruntime")
+    api project(":resources")
+    api project(":pluginapi")
+    api project(":sandbox")
+    api project(":shadowapi")
+    api project(":utils")
+    api project(":utils:reflector")
+    api "androidx.test:monitor:$axtMonitorVersion@aar"
+
+    implementation "com.google.errorprone:error_prone_annotations:$errorproneVersion"
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+    api "com.almworks.sqlite4java:sqlite4java:$sqlite4javaVersion"
+    compileOnly(AndroidSdk.MAX_SDK.coordinates) { force = true }
+    api "com.ibm.icu:icu4j:70.1"
+    api "androidx.annotation:annotation:1.1.0"
+    api "com.google.auto.value:auto-value-annotations:1.9"
+    annotationProcessor "com.google.auto.value:auto-value:1.9"
+
+    sqlite4java "com.almworks.sqlite4java:libsqlite4java-osx:$sqlite4javaVersion"
+    sqlite4java "com.almworks.sqlite4java:libsqlite4java-linux-amd64:$sqlite4javaVersion"
+    sqlite4java "com.almworks.sqlite4java:sqlite4java-win32-x64:$sqlite4javaVersion"
+    sqlite4java "com.almworks.sqlite4java:libsqlite4java-linux-i386:$sqlite4javaVersion"
+    sqlite4java "com.almworks.sqlite4java:sqlite4java-win32-x86:$sqlite4javaVersion"
+}
diff --git a/shadows/framework/src/main/java/android/media/Session2Token.java b/shadows/framework/src/main/java/android/media/Session2Token.java
new file mode 100644
index 0000000..4a321e7
--- /dev/null
+++ b/shadows/framework/src/main/java/android/media/Session2Token.java
@@ -0,0 +1,10 @@
+package android.media;
+
+/**
+ * Temporary replacement for class missing in Android Q Preview 1.
+ *
+ * TODO: Remove for Q Preview 2.
+ */
+public class Session2Token {
+
+}
diff --git a/shadows/framework/src/main/java/android/webkit/RoboCookieManager.java b/shadows/framework/src/main/java/android/webkit/RoboCookieManager.java
new file mode 100644
index 0000000..2fc58d0
--- /dev/null
+++ b/shadows/framework/src/main/java/android/webkit/RoboCookieManager.java
@@ -0,0 +1,306 @@
+package android.webkit;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Robolectric implementation of {@link android.webkit.CookieManager}.
+ *
+ * <p>Basic implementation which does not fully implement RFC2109.
+ */
+public class RoboCookieManager extends CookieManager {
+  private static final String HTTP = "http://";
+  private static final String HTTPS = "https://";
+  private static final String EXPIRATION_FIELD_NAME = "Expires";
+  private static final String SECURE_ATTR_NAME = "SECURE";
+  private final List<Cookie> store = new ArrayList<>();
+  private boolean accept;
+
+  @Override
+  public void setCookie(String url, String value) {
+    Cookie cookie = parseCookie(url, value);
+    if (cookie != null) {
+      store.add(cookie);
+    }
+  }
+
+  @Override
+  public void setCookie(String url, String value, @Nullable ValueCallback<Boolean> valueCallback) {
+    setCookie(url, value);
+    if (valueCallback != null) {
+      valueCallback.onReceiveValue(true);
+    }
+  }
+
+  @Override
+  public void setAcceptThirdPartyCookies(WebView webView, boolean b) {}
+
+  @Override
+  public boolean acceptThirdPartyCookies(WebView webView) {
+    return false;
+  }
+
+  @Override
+  public void removeAllCookies(@Nullable ValueCallback<Boolean> valueCallback) {
+    store.clear();
+    if (valueCallback != null) {
+      valueCallback.onReceiveValue(Boolean.TRUE);
+    }
+  }
+
+  @Override
+  public void flush() {}
+
+  @Override
+  public void removeSessionCookies(@Nullable ValueCallback<Boolean> valueCallback) {
+    boolean value;
+    synchronized (store) {
+      value = clearAndAddPersistentCookies();
+    }
+    if (valueCallback != null) {
+      valueCallback.onReceiveValue(value);
+    }
+  }
+
+  @Override
+  public String getCookie(String url) {
+    // Return null value for empty url
+    if (url == null || url.equals("")) {
+      return null;
+    }
+
+    try {
+      url = URLDecoder.decode(url, "UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      throw new RuntimeException(e);
+    }
+
+    final List<Cookie> matchedCookies;
+    if (url.startsWith(".")) {
+      matchedCookies = filter(url.substring(1));
+    } else if (url.contains("//.")) {
+      matchedCookies = filter(url.substring(url.indexOf("//.") + 3));
+    } else {
+      matchedCookies = filter(getCookieHost(url), url.startsWith(HTTPS));
+    }
+    if (matchedCookies.isEmpty()) {
+      return null;
+    }
+
+    StringBuilder cookieHeaderValue = new StringBuilder();
+    for (int i = 0, n = matchedCookies.size(); i < n; i++) {
+      Cookie cookie = matchedCookies.get(i);
+
+      if (i > 0) {
+        cookieHeaderValue.append("; ");
+      }
+      cookieHeaderValue.append(cookie.getName());
+      String value = cookie.getValue();
+      if (value != null) {
+        cookieHeaderValue.append("=");
+        cookieHeaderValue.append(value);
+      }
+    }
+
+    return cookieHeaderValue.toString();
+  }
+
+  @Override
+  public String getCookie(String s, boolean b) {
+    return null;
+  }
+
+  private List<Cookie> filter(String domain) {
+    return filter(domain, false);
+  }
+
+  private List<Cookie> filter(String domain, boolean isSecure) {
+    List<Cookie> matchedCookies = new ArrayList<>();
+    for (Cookie cookie : store) {
+      if (cookie.isSameHost(domain) && (isSecure == cookie.isSecure() || isSecure)) {
+        matchedCookies.add(cookie);
+      }
+    }
+    return matchedCookies;
+  }
+
+  @Override
+  public void setAcceptCookie(boolean accept) {
+    this.accept = accept;
+  }
+
+  @Override
+  public boolean acceptCookie() {
+    return this.accept;
+  }
+
+  public void removeAllCookie() {
+    store.clear();
+  }
+
+  public void removeExpiredCookie() {
+    List<Cookie> expired = new ArrayList<>();
+    Date now = new Date();
+
+    for (Cookie cookie : store) {
+      if (cookie.isExpiredAt(now)) {
+        expired.add(cookie);
+      }
+    }
+
+    store.removeAll(expired);
+  }
+
+  @Override
+  public boolean hasCookies() {
+    return !store.isEmpty();
+  }
+
+  @Override
+  public boolean hasCookies(boolean b) {
+    return false;
+  }
+
+  public void removeSessionCookie() {
+    synchronized (store) {
+      clearAndAddPersistentCookies();
+    }
+  }
+
+  @Override
+  protected boolean allowFileSchemeCookiesImpl() {
+    return false;
+  }
+
+  @Override
+  protected void setAcceptFileSchemeCookiesImpl(boolean b) {}
+
+  private boolean clearAndAddPersistentCookies() {
+    List<Cookie> existing = new ArrayList<>(store);
+    int length = store.size();
+    store.clear();
+    for (Cookie cookie : existing) {
+      if (cookie.isPersistent()) {
+        store.add(cookie);
+      }
+    }
+    return store.size() < length;
+  }
+
+  @Nullable
+  private static Cookie parseCookie(String url, String cookieHeader) {
+    Date expiration = null;
+    boolean isSecure = false;
+
+    String[] fields = cookieHeader.split(";", 0);
+    String cookieValue = fields[0].trim();
+
+    for (int i = 1; i < fields.length; i++) {
+      String field = fields[i].trim();
+      if (field.startsWith(EXPIRATION_FIELD_NAME)) {
+        expiration = getExpiration(field);
+      } else if (field.toUpperCase().equals(SECURE_ATTR_NAME)) {
+        isSecure = true;
+      }
+    }
+
+    String hostname = getCookieHost(url);
+    if (expiration == null || expiration.compareTo(new Date()) >= 0) {
+      return new Cookie(hostname, isSecure, cookieValue, expiration);
+    }
+
+    return null;
+  }
+
+  private static String getCookieHost(String url) {
+    if (!(url.startsWith(HTTP) || url.startsWith(HTTPS))) {
+      url = HTTP + url;
+    }
+
+    try {
+      return new URI(url).getHost();
+    } catch (URISyntaxException e) {
+      throw new IllegalArgumentException("wrong URL : " + url, e);
+    }
+  }
+
+  private static Date getExpiration(String field) {
+    int equalsIndex = field.indexOf("=");
+
+    if (equalsIndex < 0) {
+      return null;
+    }
+
+    String date = field.substring(equalsIndex + 1);
+
+    try {
+      DateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
+      return dateFormat.parse(date);
+    } catch (ParseException e) {
+      // No-op. Try to inferFromValue additional date formats.
+    }
+
+    try {
+      DateFormat dateFormat = new SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss zzz");
+      return dateFormat.parse(date);
+    } catch (ParseException e) {
+      return null; // Was not parsed by any date formatter.
+    }
+  }
+
+  private static class Cookie {
+    private final String mName;
+    private final String mValue;
+    private final Date mExpiration;
+    private final String mHostname;
+    private final boolean mIsSecure;
+
+    public Cookie(String hostname, boolean isSecure, String cookie, Date expiration) {
+      mHostname = hostname;
+      mIsSecure = isSecure;
+      mExpiration = expiration;
+
+      int equalsIndex = cookie.indexOf("=");
+      if (equalsIndex >= 0) {
+        mName = cookie.substring(0, equalsIndex);
+        mValue = cookie.substring(equalsIndex + 1);
+      } else {
+        mName = cookie;
+        mValue = null;
+      }
+    }
+
+    public String getName() {
+      return mName;
+    }
+
+    public String getValue() {
+      return mValue;
+    }
+
+    public boolean isExpiredAt(Date date) {
+      return mExpiration != null && mExpiration.compareTo(date) < 0;
+    }
+
+    public boolean isPersistent() {
+      return mExpiration != null;
+    }
+
+    public boolean isSameHost(String host) {
+      return mHostname.endsWith(host);
+    }
+
+    public boolean isSecure() {
+      return mIsSecure;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java b/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java
new file mode 100755
index 0000000..33276d9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java
@@ -0,0 +1,327 @@
+package org.robolectric;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.assertLooperMode;
+
+import android.app.Application;
+import android.app.ResourcesManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import com.google.common.base.Supplier;
+import java.nio.file.Path;
+import org.robolectric.android.Bootstrap;
+import org.robolectric.android.ConfigurationV25;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.TempDirectory;
+
+public class RuntimeEnvironment {
+  /**
+   * @deprecated Use {@link #getApplication} instead. Note that unlike the alternative, this field
+   *     is inherently incompatible with {@link
+   *     org.robolectric.annotation.experimental.LazyApplication}. This field may be removed in a
+   *     later release
+   */
+  @Deprecated public static Context systemContext;
+
+  /**
+   * @deprecated Please use {#getApplication} instead. Accessing this field directly is inherently
+   *     incompatible with {@link org.robolectric.annotation.experimental.LazyApplication} and
+   *     Robolectric makes no guarantees if a test *modifies* this field during execution.
+   */
+  @Deprecated public static Application application;
+
+  private static volatile Thread mainThread;
+  private static Object activityThread;
+  private static int apiLevel;
+  private static Scheduler masterScheduler;
+  private static ResourceTable systemResourceTable;
+  private static ResourceTable appResourceTable;
+  private static ResourceTable compileTimeResourceTable;
+  private static TempDirectory tempDirectory = new TempDirectory("no-test-yet");
+  private static Path androidFrameworkJar;
+  public static Path compileTimeSystemResourcesFile;
+
+  private static boolean useLegacyResources;
+  private static Supplier<Application> applicationSupplier;
+  private static final Object supplierLock = new Object();
+
+  /**
+   * Get a reference to the {@link Application} under test.
+   *
+   * <p>The Application may be created a test setup time or created lazily at call time, based on
+   * the test's {@link org.robolectric.annotation.experimental.LazyApplication} setting. If lazy
+   * loading is enabled, this method must be called on the main/test thread.
+   *
+   * <p>An alternate API outside of Robolectric is {@link
+   * androidx.test.core.app.ApplicationProvider#getApplicationContext()}, which is preferable if you
+   * desire cross platform tests that work on the JVM and real Android devices.
+   */
+  public static Application getApplication() {
+    // IMPORTANT NOTE: Given the order in which these are nulled out when cleaning up in
+    // AndroidTestEnvironment, the application null check must happen before the supplier null
+    // check. Otherwise the get() call can try to load an application that has already been
+    // loaded and cleaned up (as well as race with other threads trying to load the "correct"
+    // application)
+    if (application == null) {
+      synchronized (supplierLock) {
+        if (applicationSupplier != null) {
+          application = applicationSupplier.get();
+        }
+      }
+    }
+    return application;
+  }
+
+  /** internal use only */
+  public static void setApplicationSupplier(Supplier<Application> applicationSupplier) {
+    synchronized (supplierLock) {
+      RuntimeEnvironment.applicationSupplier = applicationSupplier;
+    }
+  }
+
+  private static Class<? extends Application> applicationClass;
+
+  public static Class<? extends Application> getConfiguredApplicationClass() {
+    return applicationClass;
+  }
+
+  public static void setConfiguredApplicationClass(Class<? extends Application> clazz) {
+    applicationClass = clazz;
+  }
+
+  /**
+   * Tests if the given thread is currently set as the main thread.
+   *
+   * @param thread the thread to test.
+   * @return true if the specified thread is the main thread, false otherwise.
+   * @see #isMainThread()
+   */
+  public static boolean isMainThread(Thread thread) {
+    assertLooperMode(LEGACY);
+    return thread == mainThread;
+  }
+
+  /**
+   * Tests if the current thread is currently set as the main thread.
+   *
+   * <p>Not supported in realistic looper mode.
+   *
+   * @return true if the current thread is the main thread, false otherwise.
+   */
+  public static boolean isMainThread() {
+    assertLooperMode(LEGACY);
+    return isMainThread(Thread.currentThread());
+  }
+
+  /**
+   * Retrieves the main thread. The main thread is the thread to which the main looper is attached.
+   * Defaults to the thread that initialises the {@link RuntimeEnvironment} class.
+   *
+   * <p>Not supported in realistic looper mode.
+   *
+   * @return The main thread.
+   * @see #setMainThread(Thread)
+   * @see #isMainThread()
+   */
+  public static Thread getMainThread() {
+    assertLooperMode(LEGACY);
+    return mainThread;
+  }
+
+  /**
+   * Sets the main thread. The main thread is the thread to which the main looper is attached.
+   * Defaults to the thread that initialises the {@link RuntimeEnvironment} class.
+   *
+   * <p>Not supported in realistic looper mode.
+   *
+   * @param newMainThread the new main thread.
+   * @see #setMainThread(Thread)
+   * @see #isMainThread()
+   */
+  public static void setMainThread(Thread newMainThread) {
+    assertLooperMode(LEGACY);
+    mainThread = newMainThread;
+  }
+
+  public static Object getActivityThread() {
+    return activityThread;
+  }
+
+  public static void setActivityThread(Object newActivityThread) {
+    activityThread = newActivityThread;
+  }
+
+  /**
+   * Returns a qualifier string describing the current {@link Configuration} of the system
+   * resources.
+   *
+   * @return a qualifier string as described
+   *     (https://developer.android.com/guide/topics/resources/providing-resources.html#QualifierRules)[here].
+   */
+  public static String getQualifiers() {
+    Resources systemResources = Resources.getSystem();
+    return getQualifiers(systemResources.getConfiguration(), systemResources.getDisplayMetrics());
+  }
+
+  /**
+   * Returns a qualifier string describing the given configuration and display metrics.
+   *
+   * @param configuration the configuration.
+   * @param displayMetrics the display metrics.
+   * @return a qualifier string as described
+   *     (https://developer.android.com/guide/topics/resources/providing-resources.html#QualifierRules)[here].
+   */
+  public static String getQualifiers(Configuration configuration, DisplayMetrics displayMetrics) {
+    return ConfigurationV25.resourceQualifierString(configuration, displayMetrics);
+  }
+
+  /**
+   * Overrides the current device configuration.
+   *
+   * <p>If {@param newQualifiers} starts with a plus ('+'), the prior configuration is used as the
+   * base configuration, with the given changes applied additively. Otherwise, default values are
+   * used for unspecified properties, as described <a
+   * href="http://robolectric.org/device-configuration/">here</a>.
+   *
+   * @param newQualifiers the qualifiers to apply
+   */
+  public static void setQualifiers(String newQualifiers) {
+    Configuration configuration;
+    DisplayMetrics displayMetrics = new DisplayMetrics();
+
+    if (newQualifiers.startsWith("+")) {
+      configuration = new Configuration(Resources.getSystem().getConfiguration());
+      displayMetrics.setTo(Resources.getSystem().getDisplayMetrics());
+    } else {
+      configuration = new Configuration();
+    }
+    Bootstrap.applyQualifiers(newQualifiers, getApiLevel(), configuration, displayMetrics);
+    if (Boolean.getBoolean("robolectric.nativeruntime.enableGraphics")) {
+      Bitmap.setDefaultDensity(displayMetrics.densityDpi);
+    }
+
+    // Update the resources last so that listeners will have a consistent environment.
+    // TODO(paulsowden): Can we call ResourcesManager.getInstance().applyConfigurationToResources()?
+    if (Build.VERSION.SDK_INT >= KITKAT
+        && ResourcesManager.getInstance().getConfiguration() != null) {
+      ResourcesManager.getInstance().getConfiguration().updateFrom(configuration);
+    }
+    Resources.getSystem().updateConfiguration(configuration, displayMetrics);
+    if (RuntimeEnvironment.application != null) {
+      getApplication().getResources().updateConfiguration(configuration, displayMetrics);
+    } else {
+      // if application is not yet loaded, update the configuration in Bootstrap so that the
+      // changes will be propagated once the application is finally loaded
+      Bootstrap.updateDisplayResources(configuration, displayMetrics);
+    }
+  }
+
+
+  public static int getApiLevel() {
+    return apiLevel;
+  }
+
+  public static Number castNativePtr(long ptr) {
+    // Weird, using a ternary here doesn't work, there's some auto promotion of boxed types
+    // happening.
+    if (getApiLevel() >= LOLLIPOP) {
+      return ptr;
+    } else {
+      return (int) ptr;
+    }
+  }
+
+  /**
+   * Retrieves the current master scheduler. This scheduler is always used by the main {@link
+   * android.os.Looper Looper}, and if the global scheduler option is set it is also used for the
+   * background scheduler and for all other {@link android.os.Looper Looper}s
+   *
+   * @return The current master scheduler.
+   * @see #setMasterScheduler(Scheduler) see
+   *     org.robolectric.Robolectric#getForegroundThreadScheduler() see
+   *     org.robolectric.Robolectric#getBackgroundThreadScheduler()
+   */
+  public static Scheduler getMasterScheduler() {
+    return masterScheduler;
+  }
+
+  /**
+   * Sets the current master scheduler. See {@link #getMasterScheduler()} for details. Note that
+   * this method is primarily intended to be called by the Robolectric core setup code. Changing the
+   * master scheduler during a test will have unpredictable results.
+   *
+   * @param masterScheduler the new master scheduler.
+   * @see #getMasterScheduler() see org.robolectric.Robolectric#getForegroundThreadScheduler() see
+   *     org.robolectric.Robolectric#getBackgroundThreadScheduler()
+   */
+  public static void setMasterScheduler(Scheduler masterScheduler) {
+    RuntimeEnvironment.masterScheduler = masterScheduler;
+  }
+
+  public static void setSystemResourceTable(ResourceTable systemResourceTable) {
+    RuntimeEnvironment.systemResourceTable = systemResourceTable;
+  }
+
+  public static void setAppResourceTable(ResourceTable appResourceTable) {
+    RuntimeEnvironment.appResourceTable = appResourceTable;
+  }
+
+  public static ResourceTable getSystemResourceTable() {
+    return systemResourceTable;
+  }
+
+  public static ResourceTable getAppResourceTable() {
+    return appResourceTable;
+  }
+
+  public static void setCompileTimeResourceTable(ResourceTable compileTimeResourceTable) {
+    RuntimeEnvironment.compileTimeResourceTable = compileTimeResourceTable;
+  }
+
+  public static ResourceTable getCompileTimeResourceTable() {
+    return compileTimeResourceTable;
+  }
+
+  public static void setTempDirectory(TempDirectory tempDirectory) {
+    RuntimeEnvironment.tempDirectory = tempDirectory;
+  }
+
+  public static TempDirectory getTempDirectory() {
+    return tempDirectory;
+  }
+
+  public static void setAndroidFrameworkJarPath(Path localArtifactPath) {
+    RuntimeEnvironment.androidFrameworkJar = localArtifactPath;
+  }
+
+  public static Path getAndroidFrameworkJarPath() {
+    return RuntimeEnvironment.androidFrameworkJar;
+  }
+
+  /**
+   * Internal only.
+   *
+   * @deprecated Do not use.
+   */
+  @Deprecated
+  public static boolean useLegacyResources() {
+    return useLegacyResources;
+  }
+
+  /**
+   * Internal only.
+   *
+   * @deprecated Do not use.
+   */
+  @Deprecated
+  public static void setUseLegacyResources(boolean useLegacyResources) {
+    RuntimeEnvironment.useLegacyResources = useLegacyResources;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/Bootstrap.java b/shadows/framework/src/main/java/org/robolectric/android/Bootstrap.java
new file mode 100644
index 0000000..b349ed5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/Bootstrap.java
@@ -0,0 +1,116 @@
+package org.robolectric.android;
+
+import android.content.res.AssetManager;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.util.DisplayMetrics;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.res.Qualifiers;
+import org.robolectric.shadows.ShadowDisplayManager;
+import org.robolectric.shadows.ShadowWindowManagerImpl;
+
+public class Bootstrap {
+
+  private static Configuration configuration = new Configuration();
+  private static DisplayMetrics displayMetrics = new DisplayMetrics();
+  private static Resources displayResources;
+  /** internal only */
+  public static boolean displaySet = false;
+
+  /** internal only */
+  public static void setDisplayConfiguration(
+      Configuration configuration, DisplayMetrics displayMetrics) {
+    Bootstrap.configuration = configuration;
+    Bootstrap.displayMetrics = displayMetrics;
+  }
+
+  /** internal only */
+  public static void resetDisplayConfiguration() {
+    configuration = new Configuration();
+    displayMetrics = new DisplayMetrics();
+    displayResources = null;
+    displaySet = false;
+  }
+
+  /** internal only */
+  public static void updateDisplayResources(
+      Configuration configuration, DisplayMetrics displayMetrics) {
+    if (displayResources == null) {
+      displayResources =
+          new Resources(
+              AssetManager.getSystem(), Bootstrap.displayMetrics, Bootstrap.configuration);
+    }
+    displayResources.updateConfiguration(configuration, displayMetrics);
+  }
+
+  /** internal only */
+  public static void updateConfiguration(Resources resources) {
+    if (displayResources == null) {
+      resources.updateConfiguration(Bootstrap.configuration, Bootstrap.displayMetrics);
+    } else {
+      resources.updateConfiguration(
+          displayResources.getConfiguration(), displayResources.getDisplayMetrics());
+    }
+  }
+
+  /** internal only */
+  public static void setUpDisplay() {
+    if (!displaySet) {
+      displaySet = true;
+      if (Build.VERSION.SDK_INT == VERSION_CODES.JELLY_BEAN) {
+        ShadowWindowManagerImpl.configureDefaultDisplayForJBOnly(configuration, displayMetrics);
+      } else {
+        ShadowDisplayManager.configureDefaultDisplay(configuration, displayMetrics);
+      }
+    }
+  }
+
+  public static void applyQualifiers(
+      String qualifiersStrs,
+      int apiLevel,
+      Configuration configuration,
+      DisplayMetrics displayMetrics) {
+
+    String[] qualifiersParts = qualifiersStrs.split(" ", 0);
+    int i = qualifiersParts.length - 1;
+    // find the index of the left-most qualifier string that doesn't start with '+'
+    for (; i >= 0; i--) {
+      String qualifiersStr = qualifiersParts[i];
+      if (qualifiersStr.startsWith("+")) {
+        qualifiersParts[i] = qualifiersStr.substring(1);
+      } else {
+        break;
+      }
+    }
+
+    for (i = (i < 0) ? 0 : i; i < qualifiersParts.length; i++) {
+      String qualifiersStr = qualifiersParts[i];
+      int platformVersion = Qualifiers.getPlatformVersion(qualifiersStr);
+      if (platformVersion != -1 && platformVersion != apiLevel) {
+        throw new IllegalArgumentException(
+            "Cannot specify conflicting platform version in qualifiers: \"" + qualifiersStr + "\"");
+      }
+
+      Qualifiers qualifiers = Qualifiers.parse(qualifiersStr);
+
+      DeviceConfig.applyToConfiguration(qualifiers, apiLevel, configuration, displayMetrics);
+    }
+
+    DeviceConfig.applyRules(configuration, displayMetrics, apiLevel);
+
+    fixJellyBean(configuration, displayMetrics);
+  }
+
+  private static void fixJellyBean(Configuration configuration, DisplayMetrics displayMetrics) {
+    if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.KITKAT) {
+      int widthPx = (int) (configuration.screenWidthDp * displayMetrics.density);
+      int heightPx = (int) (configuration.screenHeightDp * displayMetrics.density);
+      displayMetrics.widthPixels = displayMetrics.noncompatWidthPixels = widthPx;
+      displayMetrics.heightPixels = displayMetrics.noncompatHeightPixels = heightPx;
+      displayMetrics.xdpi = displayMetrics.noncompatXdpi = displayMetrics.densityDpi;
+      displayMetrics.ydpi = displayMetrics.noncompatYdpi = displayMetrics.densityDpi;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/ConfigurationV25.java b/shadows/framework/src/main/java/org/robolectric/android/ConfigurationV25.java
new file mode 100644
index 0000000..d92298a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/ConfigurationV25.java
@@ -0,0 +1,352 @@
+package org.robolectric.android;
+
+import static android.content.res.Configuration.DENSITY_DPI_ANY;
+import static android.content.res.Configuration.DENSITY_DPI_NONE;
+import static android.content.res.Configuration.DENSITY_DPI_UNDEFINED;
+
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.LocaleList;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.robolectric.RuntimeEnvironment;
+
+// adapted from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/java/android/content/res/Configuration.java
+public class ConfigurationV25 {
+
+  private static String localesToResourceQualifier(List<Locale> locs) {
+    final StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < locs.size(); i++) {
+      final Locale loc = locs.get(i);
+      final int l = loc.getLanguage().length();
+      if (l == 0) {
+        continue;
+      }
+      final int s = loc.getScript().length();
+      final int c = loc.getCountry().length();
+      final int v = loc.getVariant().length();
+      // We ignore locale extensions, since they are not supported by AAPT
+
+      if (sb.length() != 0) {
+        sb.append(",");
+      }
+      if (l == 2 && s == 0 && (c == 0 || c == 2) && v == 0) {
+        // Traditional locale format: xx or xx-rYY
+        sb.append(loc.getLanguage());
+        if (c == 2) {
+          sb.append("-r").append(loc.getCountry());
+        }
+      } else {
+        sb.append("b+");
+        sb.append(loc.getLanguage());
+        if (s != 0) {
+          sb.append("+");
+          sb.append(loc.getScript());
+        }
+        if (c != 0) {
+          sb.append("+");
+          sb.append(loc.getCountry());
+        }
+        if (v != 0) {
+          sb.append("+");
+          sb.append(loc.getVariant());
+        }
+      }
+    }
+    return sb.toString();
+  }
+
+
+  /**
+   * Returns a string representation of the configuration that can be parsed
+   * by build tools (like AAPT).
+   *
+   * @hide
+   */
+  public static String resourceQualifierString(Configuration config, DisplayMetrics displayMetrics) {
+    return resourceQualifierString(config, displayMetrics, true);
+  }
+
+  public static String resourceQualifierString(Configuration config, DisplayMetrics displayMetrics, boolean includeSdk) {
+    ArrayList<String> parts = new ArrayList<String>();
+
+    if (config.mcc != 0) {
+      parts.add("mcc" + config.mcc);
+      if (config.mnc != 0) {
+        parts.add("mnc" + config.mnc);
+      }
+    }
+
+    List<Locale> locales = getLocales(config);
+    if (!locales.isEmpty()) {
+      final String resourceQualifier = localesToResourceQualifier(locales);
+      if (!resourceQualifier.isEmpty()) {
+        parts.add(resourceQualifier);
+      }
+    }
+
+    switch (config.screenLayout & Configuration.SCREENLAYOUT_LAYOUTDIR_MASK) {
+      case Configuration.SCREENLAYOUT_LAYOUTDIR_LTR:
+        parts.add("ldltr");
+        break;
+      case Configuration.SCREENLAYOUT_LAYOUTDIR_RTL:
+        parts.add("ldrtl");
+        break;
+      default:
+        break;
+    }
+
+    if (config.smallestScreenWidthDp != 0) {
+      parts.add("sw" + config.smallestScreenWidthDp + "dp");
+    }
+
+    if (config.screenWidthDp != 0) {
+      parts.add("w" + config.screenWidthDp + "dp");
+    }
+
+    if (config.screenHeightDp != 0) {
+      parts.add("h" + config.screenHeightDp + "dp");
+    }
+
+    switch (config.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) {
+      case Configuration.SCREENLAYOUT_SIZE_SMALL:
+        parts.add("small");
+        break;
+      case Configuration.SCREENLAYOUT_SIZE_NORMAL:
+        parts.add("normal");
+        break;
+      case Configuration.SCREENLAYOUT_SIZE_LARGE:
+        parts.add("large");
+        break;
+      case Configuration.SCREENLAYOUT_SIZE_XLARGE:
+        parts.add("xlarge");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.screenLayout & Configuration.SCREENLAYOUT_LONG_MASK) {
+      case Configuration.SCREENLAYOUT_LONG_YES:
+        parts.add("long");
+        break;
+      case Configuration.SCREENLAYOUT_LONG_NO:
+        parts.add("notlong");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.screenLayout & Configuration.SCREENLAYOUT_ROUND_MASK) {
+      case Configuration.SCREENLAYOUT_ROUND_YES:
+        parts.add("round");
+        break;
+      case Configuration.SCREENLAYOUT_ROUND_NO:
+        parts.add("notround");
+        break;
+      default:
+        break;
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.O) {
+      switch (config.colorMode & Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_MASK) {
+        case Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_YES:
+          parts.add("widecg");
+          break;
+        case Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_NO:
+          parts.add("nowidecg");
+          break;
+        default:
+          break;
+      }
+
+      switch (config.colorMode & Configuration.COLOR_MODE_HDR_MASK) {
+        case Configuration.COLOR_MODE_HDR_YES:
+          parts.add("highdr");
+          break;
+        case Configuration.COLOR_MODE_HDR_NO:
+          parts.add("lowdr");
+          break;
+        default:
+          break;
+      }
+    }
+
+    switch (config.orientation) {
+      case Configuration.ORIENTATION_LANDSCAPE:
+        parts.add("land");
+        break;
+      case Configuration.ORIENTATION_PORTRAIT:
+        parts.add("port");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.uiMode & Configuration.UI_MODE_TYPE_MASK) {
+      case Configuration.UI_MODE_TYPE_APPLIANCE:
+        parts.add("appliance");
+        break;
+      case Configuration.UI_MODE_TYPE_DESK:
+        parts.add("desk");
+        break;
+      case Configuration.UI_MODE_TYPE_TELEVISION:
+        parts.add("television");
+        break;
+      case Configuration.UI_MODE_TYPE_CAR:
+        parts.add("car");
+        break;
+      case Configuration.UI_MODE_TYPE_WATCH:
+        parts.add("watch");
+        break;
+      case Configuration.UI_MODE_TYPE_VR_HEADSET:
+        parts.add("vrheadset");
+        break;
+      case Configuration.UI_MODE_TYPE_NORMAL:
+      default:
+        break;
+    }
+
+    switch (config.uiMode & Configuration.UI_MODE_NIGHT_MASK) {
+      case Configuration.UI_MODE_NIGHT_YES:
+        parts.add("night");
+        break;
+      case Configuration.UI_MODE_NIGHT_NO:
+        parts.add("notnight");
+        break;
+      default:
+        break;
+    }
+
+    int densityDpi;
+    if (RuntimeEnvironment.getApiLevel() > VERSION_CODES.JELLY_BEAN) {
+      densityDpi = config.densityDpi;
+    } else {
+      densityDpi = displayMetrics.densityDpi;
+    }
+
+    switch (densityDpi) {
+      case DENSITY_DPI_UNDEFINED:
+        break;
+      case 120:
+        parts.add("ldpi");
+        break;
+      case 160:
+        parts.add("mdpi");
+        break;
+      case 213:
+        parts.add("tvdpi");
+        break;
+      case 240:
+        parts.add("hdpi");
+        break;
+      case 320:
+        parts.add("xhdpi");
+        break;
+      case 480:
+        parts.add("xxhdpi");
+        break;
+      case 640:
+        parts.add("xxxhdpi");
+        break;
+      case DENSITY_DPI_ANY:
+        parts.add("anydpi");
+        break;
+      case DENSITY_DPI_NONE:
+        parts.add("nodpi");
+        break;
+      default:
+        parts.add(densityDpi + "dpi");
+        break;
+    }
+
+    switch (config.touchscreen) {
+      case Configuration.TOUCHSCREEN_NOTOUCH:
+        parts.add("notouch");
+        break;
+      case Configuration.TOUCHSCREEN_FINGER:
+        parts.add("finger");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.keyboardHidden) {
+      case Configuration.KEYBOARDHIDDEN_NO:
+        parts.add("keysexposed");
+        break;
+      case Configuration.KEYBOARDHIDDEN_YES:
+        parts.add("keyshidden");
+        break;
+      case Configuration.KEYBOARDHIDDEN_SOFT:
+        parts.add("keyssoft");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.keyboard) {
+      case Configuration.KEYBOARD_NOKEYS:
+        parts.add("nokeys");
+        break;
+      case Configuration.KEYBOARD_QWERTY:
+        parts.add("qwerty");
+        break;
+      case Configuration.KEYBOARD_12KEY:
+        parts.add("12key");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.navigationHidden) {
+      case Configuration.NAVIGATIONHIDDEN_NO:
+        parts.add("navexposed");
+        break;
+      case Configuration.NAVIGATIONHIDDEN_YES:
+        parts.add("navhidden");
+        break;
+      default:
+        break;
+    }
+
+    switch (config.navigation) {
+      case Configuration.NAVIGATION_NONAV:
+        parts.add("nonav");
+        break;
+      case Configuration.NAVIGATION_DPAD:
+        parts.add("dpad");
+        break;
+      case Configuration.NAVIGATION_TRACKBALL:
+        parts.add("trackball");
+        break;
+      case Configuration.NAVIGATION_WHEEL:
+        parts.add("wheel");
+        break;
+      default:
+        break;
+    }
+
+    if (includeSdk) {
+      parts.add("v" + Build.VERSION.RESOURCES_SDK_INT);
+    }
+
+    return TextUtils.join("-", parts);
+  }
+
+  private static List<Locale> getLocales(Configuration config) {
+    List<Locale> locales = new ArrayList<>();
+    if (RuntimeEnvironment.getApiLevel() > Build.VERSION_CODES.M) {
+      LocaleList localeList = config.getLocales();
+      for (int i = 0; i < localeList.size(); i++) {
+        locales.add(localeList.get(i));
+      }
+    } else if (config.locale != null) {
+      locales.add(config.locale);
+    }
+    return locales;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/DeviceConfig.java b/shadows/framework/src/main/java/org/robolectric/android/DeviceConfig.java
new file mode 100644
index 0000000..6ca8a43
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/DeviceConfig.java
@@ -0,0 +1,501 @@
+package org.robolectric.android;
+
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import android.app.WindowConfiguration;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.util.DisplayMetrics;
+import java.util.Locale;
+import org.robolectric.res.Qualifiers;
+import org.robolectric.res.android.ConfigDescription;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Supports device configuration for Robolectric tests.
+ *
+ * @see <a href="http://robolectric.org/device-configuration/">Device Configuration</a>
+ */
+@SuppressWarnings("NewApi")
+public class DeviceConfig {
+  public static final int DEFAULT_DENSITY = ResTable_config.DENSITY_DPI_MDPI;
+  public static final ScreenSize DEFAULT_SCREEN_SIZE = ScreenSize.normal;
+
+  /**
+   * Standard sizes for the screen size qualifier.
+   *
+   * @see <a
+   *     href="https://developer.android.com/guide/topics/resources/providing-resources.html#ScreenSizeQualifier">Screen
+   *     Size Qualifier</a>.
+   */
+  public enum ScreenSize {
+    small(320, 426, Configuration.SCREENLAYOUT_SIZE_SMALL),
+    normal(320, 470, Configuration.SCREENLAYOUT_SIZE_NORMAL),
+    large(480, 640, Configuration.SCREENLAYOUT_SIZE_LARGE),
+    xlarge(720, 960, Configuration.SCREENLAYOUT_SIZE_XLARGE);
+
+    public final int width;
+    public final int height;
+    public final int landscapeWidth;
+    public final int landscapeHeight;
+    private final int configValue;
+
+    ScreenSize(int width, int height, int configValue) {
+      this.width = width;
+      this.height = height;
+
+      //noinspection SuspiciousNameCombination
+      this.landscapeWidth = height;
+      //noinspection SuspiciousNameCombination
+      this.landscapeHeight = width;
+
+      this.configValue = configValue;
+    }
+
+    private boolean isSmallerThanOrEqualTo(int x, int y) {
+      if (y < x) {
+        int oldY = y;
+        //noinspection SuspiciousNameCombination
+        y = x;
+        //noinspection SuspiciousNameCombination
+        x = oldY;
+      }
+
+      return width <= x && height <= y;
+    }
+
+    static ScreenSize find(int configValue) {
+      switch (configValue) {
+        case Configuration.SCREENLAYOUT_SIZE_SMALL:
+          return small;
+        case Configuration.SCREENLAYOUT_SIZE_NORMAL:
+          return normal;
+        case Configuration.SCREENLAYOUT_SIZE_LARGE:
+          return large;
+        case Configuration.SCREENLAYOUT_SIZE_XLARGE:
+          return xlarge;
+        case Configuration.SCREENLAYOUT_SIZE_UNDEFINED:
+          return null;
+        default:
+          throw new IllegalArgumentException();
+      }
+    }
+
+    static ScreenSize match(int x, int y) {
+      ScreenSize bestMatch = small;
+
+      for (ScreenSize screenSize : values()) {
+        if (screenSize.isSmallerThanOrEqualTo(x, y)) {
+          bestMatch = screenSize;
+        }
+      }
+
+      return bestMatch;
+    }
+  }
+
+  private DeviceConfig() {
+  }
+
+  static void applyToConfiguration(Qualifiers qualifiers, int apiLevel,
+      Configuration configuration, DisplayMetrics displayMetrics) {
+    ResTable_config resTab = qualifiers.getConfig();
+
+    if (resTab.mcc != 0) {
+      configuration.mcc = resTab.mcc;
+    }
+
+    if (resTab.mnc != 0) {
+      configuration.mnc = resTab.mnc;
+    }
+
+    // screenLayout includes size, long, layoutdir, and round.
+    // layoutdir may be overridden by setLocale(), so do this first:
+    int screenLayoutSize = getScreenLayoutSize(configuration);
+    int resTabSize = resTab.screenLayoutSize();
+    if (resTabSize != ResTable_config.SCREENSIZE_ANY) {
+      screenLayoutSize = resTabSize;
+
+      if (resTab.screenWidthDp == 0) {
+        configuration.screenWidthDp = 0;
+      }
+
+      if (resTab.screenHeightDp == 0) {
+        configuration.screenHeightDp = 0;
+      }
+    }
+
+    int screenLayoutLong = getScreenLayoutLong(configuration);
+    int resTabLong = resTab.screenLayoutLong();
+    if (resTabLong != ResTable_config.SCREENLONG_ANY) {
+      screenLayoutLong = resTabLong;
+    }
+
+    int screenLayoutLayoutDir = getScreenLayoutLayoutDir(configuration);
+    int resTabLayoutDir = resTab.screenLayoutDirection();
+    if (resTabLayoutDir != ResTable_config.LAYOUTDIR_ANY) {
+      screenLayoutLayoutDir = resTabLayoutDir;
+    }
+
+    int screenLayoutRound = getScreenLayoutRound(configuration);
+    int resTabRound = resTab.screenLayoutRound();
+    if (resTabRound != ResTable_config.SCREENROUND_ANY) {
+      screenLayoutRound = resTabRound << 8;
+    }
+
+    configuration.screenLayout =
+        screenLayoutSize | screenLayoutLong | screenLayoutLayoutDir | screenLayoutRound;
+
+    // locale...
+    String lang = resTab.languageString();
+    String region = resTab.regionString();
+    String script = resTab.scriptString();
+
+    Locale locale;
+    if (isNullOrEmpty(lang) && isNullOrEmpty(region) && isNullOrEmpty(script)) {
+      locale = null;
+    } else {
+      locale = new Locale.Builder()
+          .setLanguage(lang)
+          .setRegion(region)
+          .setScript(script == null ? "" : script)
+          .build();
+    }
+    if (locale != null) {
+      setLocale(apiLevel, configuration, locale);
+    }
+
+    if (resTab.smallestScreenWidthDp != 0) {
+      configuration.smallestScreenWidthDp = resTab.smallestScreenWidthDp;
+    }
+
+    if (resTab.screenWidthDp != 0) {
+      configuration.screenWidthDp = resTab.screenWidthDp;
+    }
+
+    if (resTab.screenHeightDp != 0) {
+      configuration.screenHeightDp = resTab.screenHeightDp;
+    }
+
+    if (resTab.orientation != ResTable_config.ORIENTATION_ANY) {
+      configuration.orientation = resTab.orientation;
+    }
+
+    // uiMode includes type and night...
+    int uiModeType = getUiModeType(configuration);
+    int resTabType = resTab.uiModeType();
+    if (resTabType != ResTable_config.UI_MODE_TYPE_ANY) {
+      uiModeType = resTabType;
+    }
+
+    int uiModeNight = getUiModeNight(configuration);
+    int resTabNight = resTab.uiModeNight();
+    if (resTabNight != ResTable_config.UI_MODE_NIGHT_ANY) {
+      uiModeNight = resTabNight;
+    }
+    configuration.uiMode = uiModeType | uiModeNight;
+
+    if (resTab.density != ResTable_config.DENSITY_DEFAULT) {
+      setDensity(resTab.density, apiLevel, configuration, displayMetrics);
+    }
+    setDimensions(apiLevel, configuration, displayMetrics);
+
+    if (resTab.touchscreen != ResTable_config.TOUCHSCREEN_ANY) {
+      configuration.touchscreen = resTab.touchscreen;
+    }
+
+    if (resTab.keyboard != ResTable_config.KEYBOARD_ANY) {
+      configuration.keyboard = resTab.keyboard;
+    }
+
+    if (resTab.keyboardHidden() != ResTable_config.KEYSHIDDEN_ANY) {
+      configuration.keyboardHidden = resTab.keyboardHidden();
+    }
+
+    if (resTab.navigation != ResTable_config.NAVIGATION_ANY) {
+      configuration.navigation = resTab.navigation;
+    }
+
+    if (resTab.navigationHidden() != ResTable_config.NAVHIDDEN_ANY) {
+      configuration.navigationHidden = resTab.navigationHidden();
+    }
+
+    if (apiLevel >= VERSION_CODES.O) {
+      if (resTab.colorModeWideColorGamut() != ResTable_config.WIDE_COLOR_GAMUT_ANY) {
+        setColorModeGamut(configuration, resTab.colorMode & ResTable_config.MASK_WIDE_COLOR_GAMUT);
+      }
+
+      if (resTab.colorModeHdr() != ResTable_config.HDR_ANY) {
+        setColorModeHdr(configuration, resTab.colorMode & ResTable_config.MASK_HDR);
+      }
+    }
+  }
+
+  private static void setDensity(int densityDpi, int apiLevel, Configuration configuration,
+      DisplayMetrics displayMetrics) {
+    if (apiLevel >= VERSION_CODES.JELLY_BEAN_MR1) {
+      configuration.densityDpi = densityDpi;
+    }
+    displayMetrics.densityDpi = densityDpi;
+    displayMetrics.density = displayMetrics.densityDpi * DisplayMetrics.DENSITY_DEFAULT_SCALE;
+
+    displayMetrics.xdpi = displayMetrics.noncompatXdpi = displayMetrics.densityDpi;
+    displayMetrics.ydpi = displayMetrics.noncompatYdpi = displayMetrics.densityDpi;
+  }
+
+  private static void setDimensions(
+      int apiLevel, Configuration configuration, DisplayMetrics displayMetrics) {
+    int widthPx = (int) (configuration.screenWidthDp * displayMetrics.density);
+    int heightPx = (int) (configuration.screenHeightDp * displayMetrics.density);
+    displayMetrics.widthPixels = displayMetrics.noncompatWidthPixels = widthPx;
+    displayMetrics.heightPixels = displayMetrics.noncompatHeightPixels = heightPx;
+    if (apiLevel >= VERSION_CODES.P) {
+      Rect bounds = new Rect(0, 0, widthPx, heightPx);
+      WindowConfiguration windowConfiguration =
+          ReflectionHelpers.getField(configuration, "windowConfiguration");
+      windowConfiguration.setBounds(bounds);
+      windowConfiguration.setAppBounds(bounds);
+    }
+  }
+
+  /**
+   * Makes a given configuration, which may have undefined values, conform to the rules declared <a
+   * href="http://robolectric.org/device-configuration/">here</a>.
+   */
+  static void applyRules(Configuration configuration, DisplayMetrics displayMetrics, int apiLevel) {
+    Locale locale = getLocale(configuration, apiLevel);
+
+    String language = locale == null ? "" : locale.getLanguage();
+    if (language.isEmpty()) {
+      language = "en";
+
+      String country = locale == null ? "" : locale.getCountry();
+      if (country.isEmpty()) {
+        country = "us";
+      }
+
+      locale = new Locale(language, country);
+      setLocale(apiLevel, configuration, locale);
+    }
+
+    if (apiLevel <= ConfigDescription.SDK_JELLY_BEAN &&
+        getScreenLayoutLayoutDir(configuration) == Configuration.SCREENLAYOUT_LAYOUTDIR_UNDEFINED) {
+      setScreenLayoutLayoutDir(configuration, Configuration.SCREENLAYOUT_LAYOUTDIR_LTR);
+    }
+
+    ScreenSize requestedScreenSize = getScreenSize(configuration);
+    if (requestedScreenSize == null) {
+      requestedScreenSize = DEFAULT_SCREEN_SIZE;
+    }
+
+    if (configuration.orientation == Configuration.ORIENTATION_UNDEFINED
+        && configuration.screenWidthDp != 0 && configuration.screenHeightDp != 0) {
+      configuration.orientation = (configuration.screenWidthDp > configuration.screenHeightDp)
+          ? Configuration.ORIENTATION_LANDSCAPE
+          : Configuration.ORIENTATION_PORTRAIT;
+    }
+
+    if (configuration.screenWidthDp == 0) {
+      configuration.screenWidthDp = requestedScreenSize.width;
+    }
+
+    if (configuration.screenHeightDp == 0) {
+      configuration.screenHeightDp = requestedScreenSize.height;
+
+      if ((configuration.screenLayout & Configuration.SCREENLAYOUT_LONG_MASK)
+          == Configuration.SCREENLAYOUT_LONG_YES) {
+        configuration.screenHeightDp = (int) (configuration.screenHeightDp * 1.25f);
+      }
+    }
+
+    int lesserDimenPx = Math.min(configuration.screenWidthDp, configuration.screenHeightDp);
+    int greaterDimenPx = Math.max(configuration.screenWidthDp, configuration.screenHeightDp);
+
+    if (configuration.smallestScreenWidthDp == 0) {
+      configuration.smallestScreenWidthDp = lesserDimenPx;
+    }
+
+    if (getScreenLayoutSize(configuration) == Configuration.SCREENLAYOUT_SIZE_UNDEFINED) {
+      ScreenSize screenSize =
+          ScreenSize.match(configuration.screenWidthDp, configuration.screenHeightDp);
+      setScreenLayoutSize(configuration, screenSize.configValue);
+    }
+
+    if (getScreenLayoutLong(configuration) == Configuration.SCREENLAYOUT_LONG_UNDEFINED) {
+      setScreenLayoutLong(configuration,
+          ((float) greaterDimenPx) / lesserDimenPx >= 1.75
+              ? Configuration.SCREENLAYOUT_LONG_YES
+              : Configuration.SCREENLAYOUT_LONG_NO);
+    }
+
+    if (getScreenLayoutRound(configuration) == Configuration.SCREENLAYOUT_ROUND_UNDEFINED) {
+      setScreenLayoutRound(configuration, Configuration.SCREENLAYOUT_ROUND_NO);
+    }
+
+    if (configuration.orientation == Configuration.ORIENTATION_UNDEFINED) {
+      configuration.orientation = configuration.screenWidthDp > configuration.screenHeightDp
+          ? Configuration.ORIENTATION_LANDSCAPE
+          : Configuration.ORIENTATION_PORTRAIT;
+    } else if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT
+        && configuration.screenWidthDp > configuration.screenHeightDp) {
+      swapXY(configuration);
+    } else if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+        && configuration.screenWidthDp < configuration.screenHeightDp) {
+      swapXY(configuration);
+    }
+
+    if (getUiModeType(configuration) == Configuration.UI_MODE_TYPE_UNDEFINED) {
+      setUiModeType(configuration, Configuration.UI_MODE_TYPE_NORMAL);
+    }
+
+    if (getUiModeNight(configuration) == Configuration.UI_MODE_NIGHT_UNDEFINED) {
+      setUiModeNight(configuration, Configuration.UI_MODE_NIGHT_NO);
+    }
+
+    switch (displayMetrics.densityDpi) {
+      case ResTable_config.DENSITY_DPI_ANY:
+        throw new IllegalArgumentException("'anydpi' isn't actually a dpi");
+      case ResTable_config.DENSITY_DPI_NONE:
+        throw new IllegalArgumentException("'nodpi' isn't actually a dpi");
+      case ResTable_config.DENSITY_DPI_UNDEFINED:
+        // DisplayMetrics.DENSITY_DEFAULT is mdpi
+        setDensity(DEFAULT_DENSITY, apiLevel, configuration, displayMetrics);
+    }
+    setDimensions(apiLevel, configuration, displayMetrics);
+
+    if (configuration.touchscreen == Configuration.TOUCHSCREEN_UNDEFINED) {
+      configuration.touchscreen = Configuration.TOUCHSCREEN_FINGER;
+    }
+
+    if (configuration.keyboardHidden == Configuration.KEYBOARDHIDDEN_UNDEFINED) {
+      configuration.keyboardHidden = Configuration.KEYBOARDHIDDEN_SOFT;
+    }
+
+    if (configuration.keyboard == Configuration.KEYBOARD_UNDEFINED) {
+      configuration.keyboard = Configuration.KEYBOARD_NOKEYS;
+    }
+
+    if (configuration.navigationHidden == Configuration.NAVIGATIONHIDDEN_UNDEFINED) {
+      configuration.navigationHidden = Configuration.NAVIGATIONHIDDEN_YES;
+    }
+
+    if (configuration.navigation == Configuration.NAVIGATION_UNDEFINED) {
+      configuration.navigation = Configuration.NAVIGATION_NONAV;
+    }
+
+    if (apiLevel >= VERSION_CODES.O) {
+      if (getColorModeGamut(configuration) == Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_UNDEFINED) {
+        setColorModeGamut(configuration, Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_NO);
+      }
+
+      if (getColorModeHdr(configuration) == Configuration.COLOR_MODE_HDR_UNDEFINED) {
+        setColorModeHdr(configuration, Configuration.COLOR_MODE_HDR_NO);
+      }
+    }
+  }
+
+  public static ScreenSize getScreenSize(Configuration configuration) {
+    return ScreenSize.find(getScreenLayoutSize(configuration));
+  }
+
+  private static void swapXY(Configuration configuration) {
+    int oldWidth = configuration.screenWidthDp;
+    //noinspection SuspiciousNameCombination
+    configuration.screenWidthDp = configuration.screenHeightDp;
+    //noinspection SuspiciousNameCombination
+    configuration.screenHeightDp = oldWidth;
+  }
+
+  private static void setLocale(int apiLevel, Configuration configuration, Locale locale) {
+    if (apiLevel >= VERSION_CODES.JELLY_BEAN_MR1) {
+      configuration.setLocale(locale);
+    } else {
+      configuration.locale = locale;
+    }
+  }
+
+  private static Locale getLocale(Configuration configuration, int apiLevel) {
+    Locale locale;
+    if (apiLevel > Build.VERSION_CODES.M) {
+      locale = configuration.getLocales().get(0);
+    } else {
+      locale = configuration.locale;
+    }
+    return locale;
+  }
+
+  private static int getScreenLayoutSize(Configuration configuration) {
+    return configuration.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK;
+  }
+
+  private static void setScreenLayoutSize(Configuration configuration, int value) {
+    configuration.screenLayout =
+        (configuration.screenLayout & ~Configuration.SCREENLAYOUT_SIZE_MASK)
+            | value;
+  }
+
+  private static int getScreenLayoutLong(Configuration configuration) {
+    return configuration.screenLayout & Configuration.SCREENLAYOUT_LONG_MASK;
+  }
+
+  private static void setScreenLayoutLong(Configuration configuration, int value) {
+    configuration.screenLayout =
+        (configuration.screenLayout & ~Configuration.SCREENLAYOUT_LONG_MASK)
+            | value;
+  }
+
+  private static int getScreenLayoutLayoutDir(Configuration configuration) {
+    return configuration.screenLayout & Configuration.SCREENLAYOUT_LAYOUTDIR_MASK;
+  }
+
+  private static void setScreenLayoutLayoutDir(Configuration configuration, int value) {
+    configuration.screenLayout =
+        (configuration.screenLayout & ~Configuration.SCREENLAYOUT_LAYOUTDIR_MASK)
+            | value;
+  }
+
+  private static int getScreenLayoutRound(Configuration configuration) {
+    return configuration.screenLayout & Configuration.SCREENLAYOUT_ROUND_MASK;
+  }
+
+  private static void setScreenLayoutRound(Configuration configuration, int value) {
+    configuration.screenLayout =
+        (configuration.screenLayout & ~Configuration.SCREENLAYOUT_ROUND_MASK)
+            | value;
+  }
+
+  private static int getUiModeType(Configuration configuration) {
+    return configuration.uiMode & Configuration.UI_MODE_TYPE_MASK;
+  }
+
+  private static void setUiModeType(Configuration configuration, int value) {
+    configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_TYPE_MASK) | value;
+  }
+
+  private static int getUiModeNight(Configuration configuration) {
+    return configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK;
+  }
+
+  private static void setUiModeNight(Configuration configuration, int value) {
+    configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | value;
+  }
+
+  private static int getColorModeGamut(Configuration configuration) {
+    return configuration.colorMode & Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_MASK;
+  }
+
+  private static void setColorModeGamut(Configuration configuration, int value) {
+    configuration.colorMode = (configuration.colorMode & ~Configuration.COLOR_MODE_WIDE_COLOR_GAMUT_MASK) | value;
+  }
+
+  private static int getColorModeHdr(Configuration configuration) {
+    return configuration.colorMode & Configuration.COLOR_MODE_HDR_MASK;
+  }
+
+  private static void setColorModeHdr(Configuration configuration, int value) {
+    configuration.colorMode = (configuration.colorMode & ~Configuration.COLOR_MODE_HDR_MASK) | value;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/XmlResourceParserImpl.java b/shadows/framework/src/main/java/org/robolectric/android/XmlResourceParserImpl.java
new file mode 100644
index 0000000..646df28
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/XmlResourceParserImpl.java
@@ -0,0 +1,850 @@
+package org.robolectric.android;
+
+import static org.robolectric.res.AttributeResource.ANDROID_RES_NS_PREFIX;
+import static org.robolectric.res.AttributeResource.RES_AUTO_NS_URI;
+
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import com.android.internal.util.XmlUtils;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import org.robolectric.res.AttributeResource;
+import org.robolectric.res.Fs;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.StringResources;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.xmlpull.v1.XmlPullParserException;
+
+/**
+ * Concrete implementation of the {@link XmlResourceParser}.
+ *
+ * Clients expects a pull parser while the resource loader
+ * initialise this object with a {@link Document}.
+ * This implementation navigates the dom and emulates a pull
+ * parser by raising all the opportune events.
+ *
+ * Note that the original android implementation is based on
+ * a set of native methods calls. Here those methods are
+ * re-implemented in java when possible.
+ */
+public class XmlResourceParserImpl implements XmlResourceParser {
+
+  /**
+   * All the parser features currently supported by Android.
+   */
+  public static final String[] AVAILABLE_FEATURES = {
+      XmlResourceParser.FEATURE_PROCESS_NAMESPACES,
+      XmlResourceParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES
+  };
+  /**
+   * All the parser features currently NOT supported by Android.
+   */
+  public static final String[] UNAVAILABLE_FEATURES = {
+      XmlResourceParser.FEATURE_PROCESS_DOCDECL,
+      XmlResourceParser.FEATURE_VALIDATION
+  };
+
+  private final Document document;
+  private final Path fileName;
+  private final String packageName;
+  private final ResourceTable resourceTable;
+  private final String applicationNamespace;
+
+  private Node currentNode;
+
+  private boolean mStarted = false;
+  private boolean mDecNextDepth = false;
+  private int mDepth = 0;
+  private int mEventType = START_DOCUMENT;
+
+  /**
+   * @deprecated use {@link XmlResourceParserImpl#XmlResourceParserImpl(Document, Path, String,
+   *     String, ResourceTable)} instead.
+   */
+  @Deprecated
+  public XmlResourceParserImpl(
+      Document document,
+      String fileName,
+      String packageName,
+      String applicationPackageName,
+      ResourceTable resourceTable) {
+    this(document, Fs.fromUrl(fileName), packageName, applicationPackageName, resourceTable);
+  }
+
+  public XmlResourceParserImpl(
+      Document document,
+      Path fileName,
+      String packageName,
+      String applicationPackageName,
+      ResourceTable resourceTable) {
+    this.document = document;
+    this.fileName = fileName;
+    this.packageName = packageName;
+    this.resourceTable = resourceTable;
+    this.applicationNamespace = ANDROID_RES_NS_PREFIX + applicationPackageName;
+  }
+
+  @Override
+  public void setFeature(String name, boolean state)
+      throws XmlPullParserException {
+    if (isAndroidSupportedFeature(name) && state) {
+      return;
+    }
+    throw new XmlPullParserException("Unsupported feature: " + name);
+  }
+
+  @Override
+  public boolean getFeature(String name) {
+    return isAndroidSupportedFeature(name);
+  }
+
+  @Override
+  public void setProperty(String name, Object value)
+      throws XmlPullParserException {
+    throw new XmlPullParserException("setProperty() not supported");
+  }
+
+  @Override
+  public Object getProperty(String name) {
+    // Properties are not supported. Android returns null
+    // instead of throwing an XmlPullParserException.
+    return null;
+  }
+
+  @Override
+  public void setInput(Reader in) throws XmlPullParserException {
+    throw new XmlPullParserException("setInput() not supported");
+  }
+
+  @Override
+  public void setInput(InputStream inputStream, String inputEncoding)
+      throws XmlPullParserException {
+    throw new XmlPullParserException("setInput() not supported");
+  }
+
+  @Override
+  public void defineEntityReplacementText(
+      String entityName, String replacementText)
+      throws XmlPullParserException {
+    throw new XmlPullParserException(
+        "defineEntityReplacementText() not supported");
+  }
+
+  @Override
+  public String getNamespacePrefix(int pos)
+      throws XmlPullParserException {
+    throw new XmlPullParserException(
+        "getNamespacePrefix() not supported");
+  }
+
+  @Override
+  public String getInputEncoding() {
+    return null;
+  }
+
+  @Override
+  public String getNamespace(String prefix) {
+    throw new RuntimeException(
+        "getNamespaceCount() not supported");
+  }
+
+  @Override
+  public int getNamespaceCount(int depth)
+      throws XmlPullParserException {
+    throw new XmlPullParserException(
+        "getNamespaceCount() not supported");
+  }
+
+  @Override
+  public String getPositionDescription() {
+    return "XML file " + fileName + " line #" + getLineNumber() + " (sorry, not yet implemented)";
+  }
+
+  @Override
+  public String getNamespaceUri(int pos)
+      throws XmlPullParserException {
+    throw new XmlPullParserException(
+        "getNamespaceUri() not supported");
+  }
+
+  @Override
+  public int getColumnNumber() {
+    // Android always returns -1
+    return -1;
+  }
+
+  @Override
+  public int getDepth() {
+    return mDepth;
+  }
+
+  @Override
+  public String getText() {
+    if (currentNode == null) {
+      return "";
+    }
+    return StringResources.processStringResources(currentNode.getTextContent());
+  }
+
+  @Override
+  public int getLineNumber() {
+    // TODO(msama): The current implementation is
+    //   unable to return line numbers.
+    return -1;
+  }
+
+  @Override
+  public int getEventType()
+      throws XmlPullParserException {
+    return mEventType;
+  }
+
+  /*package*/
+  public boolean isWhitespace(String text)
+      throws XmlPullParserException {
+    if (text == null) {
+      return false;
+    }
+    return text.split("\\s").length == 0;
+  }
+
+  @Override
+  public boolean isWhitespace()
+      throws XmlPullParserException {
+    // Note: in android whitespaces are automatically stripped.
+    // Here we have to skip them manually
+    return isWhitespace(getText());
+  }
+
+  @Override
+  public String getPrefix() {
+    throw new RuntimeException("getPrefix not supported");
+  }
+
+  @Override
+  public char[] getTextCharacters(int[] holderForStartAndLength) {
+    String txt = getText();
+    char[] chars = null;
+    if (txt != null) {
+      holderForStartAndLength[0] = 0;
+      holderForStartAndLength[1] = txt.length();
+      chars = new char[txt.length()];
+      txt.getChars(0, txt.length(), chars, 0);
+    }
+    return chars;
+  }
+
+  @Override
+  public String getNamespace() {
+    String namespace = currentNode != null ? currentNode.getNamespaceURI() : null;
+    if (namespace == null) {
+      return "";
+    }
+
+    return maybeReplaceNamespace(namespace);
+  }
+
+  @Override
+  public String getName() {
+    if (currentNode == null) {
+      return null;
+    }
+    return currentNode.getNodeName();
+  }
+
+  Node getAttributeAt(int index) {
+    if (currentNode == null) {
+      throw new IndexOutOfBoundsException(String.valueOf(index));
+    }
+    NamedNodeMap map = currentNode.getAttributes();
+    if (index >= map.getLength()) {
+      throw new IndexOutOfBoundsException(String.valueOf(index));
+    }
+    return map.item(index);
+  }
+
+  public String getAttribute(String namespace, String name) {
+    if (currentNode == null) {
+      return null;
+    }
+
+    Element element = (Element) currentNode;
+    if (element.hasAttributeNS(namespace, name)) {
+      return element.getAttributeNS(namespace, name).trim();
+    } else if (applicationNamespace.equals(namespace)
+        && element.hasAttributeNS(AttributeResource.RES_AUTO_NS_URI, name)) {
+      return element.getAttributeNS(AttributeResource.RES_AUTO_NS_URI, name).trim();
+    }
+
+    return null;
+  }
+
+  @Override
+  public String getAttributeNamespace(int index) {
+    Node attr = getAttributeAt(index);
+    if (attr == null) {
+      return "";
+    }
+    return maybeReplaceNamespace(attr.getNamespaceURI());
+  }
+
+  private String maybeReplaceNamespace(String namespace) {
+    if (namespace == null) {
+      return "";
+    } else if (namespace.equals(applicationNamespace)) {
+      return AttributeResource.RES_AUTO_NS_URI;
+    } else {
+      return namespace;
+    }
+  }
+
+  @Override
+  public String getAttributeName(int index) {
+    Node attr = getAttributeAt(index);
+    String name = attr.getLocalName();
+    return name == null ? attr.getNodeName() : name;
+  }
+
+  @Override
+  public String getAttributePrefix(int index) {
+    throw new RuntimeException("getAttributePrefix not supported");
+  }
+
+  @Override
+  public boolean isEmptyElementTag() throws XmlPullParserException {
+    // In Android this method is left unimplemented.
+    // This implementation is mirroring that.
+    return false;
+  }
+
+  @Override
+  public int getAttributeCount() {
+    if (currentNode == null) {
+      return -1;
+    }
+    return currentNode.getAttributes().getLength();
+  }
+
+  @Override
+  public String getAttributeValue(int index) {
+    return qualify(getAttributeAt(index).getNodeValue());
+  }
+
+  // for testing only...
+  public String qualify(String value) {
+    if (value == null) return null;
+    if (AttributeResource.isResourceReference(value)) {
+      return "@" + ResName.qualifyResourceName(value.trim().substring(1).replace("+", ""), packageName, "attr");
+    } else if (AttributeResource.isStyleReference(value)) {
+      return "?" + ResName.qualifyResourceName(value.trim().substring(1), packageName, "attr");
+    } else {
+      return StringResources.processStringResources(value);
+    }
+  }
+
+  @Override
+  public String getAttributeType(int index) {
+    // Android always returns CDATA even if the
+    // node has no attribute.
+    return "CDATA";
+  }
+
+  @Override
+  public boolean isAttributeDefault(int index) {
+    // The android implementation always returns false
+    return false;
+  }
+
+  @Override
+  public int nextToken() throws XmlPullParserException, IOException {
+    return next();
+  }
+
+  @Override
+  public String getAttributeValue(String namespace, String name) {
+    return qualify(getAttribute(namespace, name));
+  }
+
+  @Override
+  public int next() throws XmlPullParserException, IOException {
+    if (!mStarted) {
+      mStarted = true;
+      return START_DOCUMENT;
+    }
+    if (mEventType == END_DOCUMENT) {
+      return END_DOCUMENT;
+    }
+    int ev = nativeNext();
+    if (mDecNextDepth) {
+      mDepth--;
+      mDecNextDepth = false;
+    }
+    switch (ev) {
+      case START_TAG:
+        mDepth++;
+        break;
+      case END_TAG:
+        mDecNextDepth = true;
+        break;
+    }
+    mEventType = ev;
+    if (ev == END_DOCUMENT) {
+      // Automatically close the parse when we reach the end of
+      // a document, since the standard XmlPullParser interface
+      // doesn't have such an API so most clients will leave us
+      // dangling.
+      close();
+    }
+    return ev;
+  }
+
+  /**
+   * A twin implementation of the native android nativeNext(status)
+   *
+   * @throws XmlPullParserException
+   */
+  private int nativeNext() throws XmlPullParserException {
+    switch (mEventType) {
+      case (CDSECT): {
+        throw new IllegalArgumentException(
+            "CDSECT is not handled by Android");
+      }
+      case (COMMENT): {
+        throw new IllegalArgumentException(
+            "COMMENT is not handled by Android");
+      }
+      case (DOCDECL): {
+        throw new IllegalArgumentException(
+            "DOCDECL is not handled by Android");
+      }
+      case (ENTITY_REF): {
+        throw new IllegalArgumentException(
+            "ENTITY_REF is not handled by Android");
+      }
+      case (END_DOCUMENT): {
+        // The end document event should have been filtered
+        // from the invoker. This should never happen.
+        throw new IllegalArgumentException(
+            "END_DOCUMENT should not be found here.");
+      }
+      case (END_TAG): {
+        return navigateToNextNode(currentNode);
+      }
+      case (IGNORABLE_WHITESPACE): {
+        throw new IllegalArgumentException(
+            "IGNORABLE_WHITESPACE");
+      }
+      case (PROCESSING_INSTRUCTION): {
+        throw new IllegalArgumentException(
+            "PROCESSING_INSTRUCTION");
+      }
+      case (START_DOCUMENT): {
+        currentNode = document.getDocumentElement();
+        return START_TAG;
+      }
+      case (START_TAG): {
+        if (currentNode.hasChildNodes()) {
+          // The node has children, navigate down
+          return processNextNodeType(
+              currentNode.getFirstChild());
+        } else {
+          // The node has no children
+          return END_TAG;
+        }
+      }
+      case (TEXT): {
+        return navigateToNextNode(currentNode);
+      }
+      default: {
+        // This can only happen if mEventType is
+        // assigned with an unmapped integer.
+        throw new RuntimeException(
+            "Robolectric-> Uknown XML event type: " + mEventType);
+      }
+    }
+
+  }
+
+  /*protected*/ int processNextNodeType(Node node)
+      throws XmlPullParserException {
+    switch (node.getNodeType()) {
+      case (Node.ATTRIBUTE_NODE): {
+        throw new IllegalArgumentException("ATTRIBUTE_NODE");
+      }
+      case (Node.CDATA_SECTION_NODE): {
+        return navigateToNextNode(node);
+      }
+      case (Node.COMMENT_NODE): {
+        return navigateToNextNode(node);
+      }
+      case (Node.DOCUMENT_FRAGMENT_NODE): {
+        throw new IllegalArgumentException("DOCUMENT_FRAGMENT_NODE");
+      }
+      case (Node.DOCUMENT_NODE): {
+        throw new IllegalArgumentException("DOCUMENT_NODE");
+      }
+      case (Node.DOCUMENT_TYPE_NODE): {
+        throw new IllegalArgumentException("DOCUMENT_TYPE_NODE");
+      }
+      case (Node.ELEMENT_NODE): {
+        currentNode = node;
+        return START_TAG;
+      }
+      case (Node.ENTITY_NODE): {
+        throw new IllegalArgumentException("ENTITY_NODE");
+      }
+      case (Node.ENTITY_REFERENCE_NODE): {
+        throw new IllegalArgumentException("ENTITY_REFERENCE_NODE");
+      }
+      case (Node.NOTATION_NODE): {
+        throw new IllegalArgumentException("DOCUMENT_TYPE_NODE");
+      }
+      case (Node.PROCESSING_INSTRUCTION_NODE): {
+        throw new IllegalArgumentException("DOCUMENT_TYPE_NODE");
+      }
+      case (Node.TEXT_NODE): {
+        if (isWhitespace(node.getNodeValue())) {
+          // Skip whitespaces
+          return navigateToNextNode(node);
+        } else {
+          currentNode = node;
+          return TEXT;
+        }
+      }
+      default: {
+        throw new RuntimeException(
+            "Robolectric -> Unknown node type: " +
+                node.getNodeType() + ".");
+      }
+    }
+  }
+
+  /**
+   * Navigate to the next node after a node and all of his
+   * children have been explored.
+   *
+   * If the node has unexplored siblings navigate to the
+   * next sibling. Otherwise return to its parent.
+   *
+   * @param node the node which was just explored.
+   * @return {@link XmlPullParserException#START_TAG} if the given
+   *         node has siblings, {@link XmlPullParserException#END_TAG}
+   *         if the node has no unexplored siblings or
+   *         {@link XmlPullParserException#END_DOCUMENT} if the explored
+   *         was the root document.
+   * @throws XmlPullParserException if the parser fails to
+   *                                parse the next node.
+   */
+  int navigateToNextNode(Node node)
+      throws XmlPullParserException {
+    Node nextNode = node.getNextSibling();
+    if (nextNode != null) {
+      // Move to the next siblings
+      return processNextNodeType(nextNode);
+    } else {
+      // Goes back to the parent
+      if (document.getDocumentElement().equals(node)) {
+        currentNode = null;
+        return END_DOCUMENT;
+      }
+      currentNode = node.getParentNode();
+      return END_TAG;
+    }
+  }
+
+  @Override
+  public void require(int type, String namespace, String name)
+      throws XmlPullParserException, IOException {
+    if (type != getEventType()
+        || (namespace != null && !namespace.equals(getNamespace()))
+        || (name != null && !name.equals(getName()))) {
+      throw new XmlPullParserException(
+          "expected " + TYPES[type] + getPositionDescription());
+    }
+  }
+
+  @Override
+  public String nextText() throws XmlPullParserException, IOException {
+    if (getEventType() != START_TAG) {
+      throw new XmlPullParserException(
+          getPositionDescription()
+              + ": parser must be on START_TAG to read next text", this, null);
+    }
+    int eventType = next();
+    if (eventType == TEXT) {
+      String result = getText();
+      eventType = next();
+      if (eventType != END_TAG) {
+        throw new XmlPullParserException(
+            getPositionDescription()
+                + ": event TEXT it must be immediately followed by END_TAG", this, null);
+      }
+      return result;
+    } else if (eventType == END_TAG) {
+      return "";
+    } else {
+      throw new XmlPullParserException(
+          getPositionDescription()
+              + ": parser must be on START_TAG or TEXT to read text", this, null);
+    }
+  }
+
+  @Override
+  public int nextTag() throws XmlPullParserException, IOException {
+    int eventType = next();
+    if (eventType == TEXT && isWhitespace()) { // skip whitespace
+      eventType = next();
+    }
+    if (eventType != START_TAG && eventType != END_TAG) {
+      throw new XmlPullParserException(
+          "Expected start or end tag. Found: " + eventType, this, null);
+    }
+    return eventType;
+  }
+
+  @Override
+  public int getAttributeNameResource(int index) {
+    String attributeNamespace = getAttributeNamespace(index);
+    if (attributeNamespace.equals(RES_AUTO_NS_URI)) {
+      attributeNamespace = packageName;
+    } else if (attributeNamespace.startsWith(ANDROID_RES_NS_PREFIX)) {
+      attributeNamespace = attributeNamespace.substring(ANDROID_RES_NS_PREFIX.length());
+    }
+    return getResourceId(getAttributeName(index), attributeNamespace, "attr");
+  }
+
+  @Override
+  public int getAttributeListValue(String namespace, String attribute,
+      String[] options, int defaultValue) {
+    String attr = getAttribute(namespace, attribute);
+    if (attr == null) {
+      return 0;
+    }
+    List<String> optList = Arrays.asList(options);
+    int index = optList.indexOf(attr);
+    if (index == -1) {
+      return defaultValue;
+    }
+    return index;
+  }
+
+  @Override
+  public boolean getAttributeBooleanValue(String namespace, String attribute,
+      boolean defaultValue) {
+    String attr = getAttribute(namespace, attribute);
+    if (attr == null) {
+      return defaultValue;
+    }
+    return Boolean.parseBoolean(attr);
+  }
+
+  @Override
+  public int getAttributeResourceValue(String namespace, String attribute, int defaultValue) {
+    String attr = getAttribute(namespace, attribute);
+    if (attr != null && attr.startsWith("@") && !AttributeResource.isNull(attr)) {
+      return getResourceId(attr, packageName, null);
+    }
+    return defaultValue;
+  }
+
+  @Override
+  public int getAttributeIntValue(String namespace, String attribute, int defaultValue) {
+    return XmlUtils.convertValueToInt(this.getAttributeValue(namespace, attribute), defaultValue);
+  }
+
+  @Override
+  public int getAttributeUnsignedIntValue(String namespace, String attribute, int defaultValue) {
+    int value = getAttributeIntValue(namespace, attribute, defaultValue);
+    if (value < 0) {
+      return defaultValue;
+    }
+    return value;
+  }
+
+  @Override
+  public float getAttributeFloatValue(String namespace, String attribute,
+      float defaultValue) {
+    String attr = getAttribute(namespace, attribute);
+    if (attr == null) {
+      return defaultValue;
+    }
+    try {
+      return Float.parseFloat(attr);
+    } catch (NumberFormatException ex) {
+      return defaultValue;
+    }
+  }
+
+  @Override
+  public int getAttributeListValue(
+      int idx, String[] options, int defaultValue) {
+    try {
+      String value = getAttributeValue(idx);
+      List<String> optList = Arrays.asList(options);
+      int index = optList.indexOf(value);
+      if (index == -1) {
+        return defaultValue;
+      }
+      return index;
+    } catch (IndexOutOfBoundsException ex) {
+      return defaultValue;
+    }
+  }
+
+  @Override
+  public boolean getAttributeBooleanValue(
+      int idx, boolean defaultValue) {
+    try {
+      return Boolean.parseBoolean(getAttributeValue(idx));
+    } catch (IndexOutOfBoundsException ex) {
+      return defaultValue;
+    }
+  }
+
+  @Override
+  public int getAttributeResourceValue(int idx, int defaultValue) {
+    String attributeValue = getAttributeValue(idx);
+    if (attributeValue != null && attributeValue.startsWith("@")) {
+      int resourceId = getResourceId(attributeValue.substring(1), packageName, null);
+      if (resourceId != 0) {
+        return resourceId;
+      }
+    }
+    return defaultValue;
+  }
+
+  @Override
+  public int getAttributeIntValue(int idx, int defaultValue) {
+    try {
+      return Integer.parseInt(getAttributeValue(idx));
+    } catch (NumberFormatException ex) {
+      return defaultValue;
+    } catch (IndexOutOfBoundsException ex) {
+      return defaultValue;
+    }
+  }
+
+  @Override
+  public int getAttributeUnsignedIntValue(int idx, int defaultValue) {
+    int value = getAttributeIntValue(idx, defaultValue);
+    if (value < 0) {
+      return defaultValue;
+    }
+    return value;
+  }
+
+  @Override
+  public float getAttributeFloatValue(int idx, float defaultValue) {
+    try {
+      return Float.parseFloat(getAttributeValue(idx));
+    } catch (NumberFormatException ex) {
+      return defaultValue;
+    } catch (IndexOutOfBoundsException ex) {
+      return defaultValue;
+    }
+  }
+
+  @Override
+  public String getIdAttribute() {
+    return getAttribute(null, "id");
+  }
+
+  @Override
+  public String getClassAttribute() {
+    return getAttribute(null, "class");
+  }
+
+  @Override
+  public int getIdAttributeResourceValue(int defaultValue) {
+    return getAttributeResourceValue(null, "id", defaultValue);
+  }
+
+  @Override
+  public int getStyleAttribute() {
+    String attr = getAttribute(null, "style");
+    if (attr == null ||
+        (!AttributeResource.isResourceReference(attr) && !AttributeResource.isStyleReference(attr))) {
+      return 0;
+    }
+
+    int style = getResourceId(attr, packageName, "style");
+    if (style == 0) {
+      // try again with underscores...
+      style = getResourceId(attr.replace('.', '_'), packageName, "style");
+    }
+    return style;
+  }
+
+  @Override
+  public void close() {
+    // Nothing to do
+  }
+
+  @Override
+  protected void finalize() throws Throwable {
+    close();
+  }
+
+  private int getResourceId(String possiblyQualifiedResourceName, String defaultPackageName, String defaultType) {
+
+    if (AttributeResource.isNull(possiblyQualifiedResourceName)) return 0;
+
+    if (AttributeResource.isStyleReference(possiblyQualifiedResourceName)) {
+      ResName styleReference = AttributeResource.getStyleReference(possiblyQualifiedResourceName, defaultPackageName, "attr");
+      Integer resourceId = resourceTable.getResourceId(styleReference);
+      if (resourceId == null) {
+        throw new Resources.NotFoundException(styleReference.getFullyQualifiedName());
+      }
+      return resourceId;
+    }
+
+    if (AttributeResource.isResourceReference(possiblyQualifiedResourceName)) {
+      ResName resourceReference = AttributeResource.getResourceReference(possiblyQualifiedResourceName, defaultPackageName, defaultType);
+      Integer resourceId = resourceTable.getResourceId(resourceReference);
+      if (resourceId == null) {
+        throw new Resources.NotFoundException(resourceReference.getFullyQualifiedName());
+      }
+      return resourceId;
+    }
+    possiblyQualifiedResourceName = removeLeadingSpecialCharsIfAny(possiblyQualifiedResourceName);
+    ResName resName = ResName.qualifyResName(possiblyQualifiedResourceName, defaultPackageName, defaultType);
+    Integer resourceId = resourceTable.getResourceId(resName);
+    return resourceId == null ? 0 : resourceId;
+  }
+
+  private static String removeLeadingSpecialCharsIfAny(String name){
+    if (name.startsWith("@+")) {
+      return name.substring(2);
+    }
+    if (name.startsWith("@")) {
+      return name.substring(1);
+    }
+    return name;
+  }
+
+  /**
+   * Tell is a given feature is supported by android.
+   *
+   * @param name Feature name.
+   * @return True if the feature is supported.
+   */
+  private static boolean isAndroidSupportedFeature(String name) {
+    if (name == null) {
+      return false;
+    }
+    for (String feature : AVAILABLE_FEATURES) {
+      if (feature.equals(name)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java
new file mode 100644
index 0000000..803930c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java
@@ -0,0 +1,688 @@
+package org.robolectric.android.controller;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.robolectric.shadow.api.Shadow.extract;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.Instrumentation;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ActivityInfo.Config;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.ViewRootImpl;
+import android.view.WindowManager;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowActivity;
+import org.robolectric.shadows.ShadowContextThemeWrapper;
+import org.robolectric.shadows.ShadowPackageManager;
+import org.robolectric.shadows.ShadowViewRootImpl;
+import org.robolectric.shadows._Activity_;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.WithType;
+
+/**
+ * ActivityController provides low-level APIs to control activity's lifecycle.
+ *
+ * <p>Using ActivityController directly from your tests is strongly discouraged. You have to call
+ * all the lifecycle callback methods (create, postCreate, start, ...) in the same manner as the
+ * Android framework by yourself otherwise you'll see fidelity issues. Consider using {@link
+ * androidx.test.core.app.ActivityScenario} instead, which provides higher-level, streamlined APIs
+ * to control the lifecycle and it works with instrumentation tests too.
+ *
+ * @param <T> a class of the activity which is under control by this class.
+ */
+@SuppressWarnings("NewApi")
+public class ActivityController<T extends Activity>
+    extends ComponentController<ActivityController<T>, T> implements AutoCloseable {
+
+  enum LifecycleState {
+    INITIAL,
+    CREATED,
+    RESTARTED,
+    STARTED,
+    RESUMED,
+    PAUSED,
+    STOPPED,
+    DESTROYED
+  }
+
+  private _Activity_ _component_;
+  private LifecycleState currentState = LifecycleState.INITIAL;
+
+  public static <T extends Activity> ActivityController<T> of(
+      T activity, Intent intent, @Nullable Bundle activityOptions) {
+    return new ActivityController<>(activity, intent).attach(activityOptions);
+  }
+
+  public static <T extends Activity> ActivityController<T> of(T activity, Intent intent) {
+    return new ActivityController<>(activity, intent).attach(/* activityOptions= */ null);
+  }
+
+  public static <T extends Activity> ActivityController<T> of(T activity) {
+    return new ActivityController<>(activity, null).attach(/* activityOptions= */ null);
+  }
+
+  private ActivityController(T activity, Intent intent) {
+    super(activity, intent);
+
+    _component_ = reflector(_Activity_.class, component);
+  }
+
+  private ActivityController<T> attach(@Nullable Bundle activityOptions) {
+    return attach(
+        activityOptions, /* lastNonConfigurationInstances= */ null, /* overrideConfig= */ null);
+  }
+
+  private ActivityController<T> attach(
+      @Nullable Bundle activityOptions,
+      @Nullable @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      @Nullable Configuration overrideConfig) {
+    if (attached) {
+      return this;
+    }
+    // make sure the component is enabled
+    Context context = RuntimeEnvironment.getApplication().getBaseContext();
+    PackageManager packageManager = context.getPackageManager();
+    ComponentName componentName =
+        new ComponentName(context.getPackageName(), this.component.getClass().getName());
+    ((ShadowPackageManager) extract(packageManager)).addActivityIfNotPresent(componentName);
+    packageManager.setComponentEnabledSetting(
+        componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
+    ShadowActivity shadowActivity = Shadow.extract(component);
+    shadowActivity.callAttach(
+        getIntent(), activityOptions, lastNonConfigurationInstances, overrideConfig);
+    shadowActivity.attachController(this);
+    attached = true;
+    return this;
+  }
+
+  private ActivityInfo getActivityInfo(Application application) {
+    PackageManager packageManager = application.getPackageManager();
+    ComponentName componentName =
+        new ComponentName(application.getPackageName(), this.component.getClass().getName());
+    try {
+      return packageManager.getActivityInfo(componentName, PackageManager.GET_META_DATA);
+    } catch (PackageManager.NameNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  public ActivityController<T> create(@Nullable final Bundle bundle) {
+    shadowMainLooper.runPaused(
+        () -> {
+          getInstrumentation().callActivityOnCreate(component, bundle);
+          currentState = LifecycleState.CREATED;
+        });
+    return this;
+  }
+
+  @Override
+  public ActivityController<T> create() {
+    return create(null);
+  }
+
+  public ActivityController<T> restart() {
+    invokeWhilePaused(
+        () -> {
+          if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
+            _component_.performRestart();
+          } else {
+            _component_.performRestart(true, "restart()");
+          }
+          currentState = LifecycleState.RESTARTED;
+        });
+    return this;
+  }
+
+  public ActivityController<T> start() {
+    // Start and stop are tricky cases. Unlike other lifecycle methods such as
+    // Instrumentation#callActivityOnPause calls Activity#performPause, Activity#performStop calls
+    // Instrumentation#callActivityOnStop internally so the dependency direction is the opposite.
+    invokeWhilePaused(
+        () -> {
+          if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
+            _component_.performStart();
+          } else {
+            _component_.performStart("start()");
+          }
+          currentState = LifecycleState.STARTED;
+        });
+    return this;
+  }
+
+  public ActivityController<T> restoreInstanceState(Bundle bundle) {
+    shadowMainLooper.runPaused(
+        () -> getInstrumentation().callActivityOnRestoreInstanceState(component, bundle));
+    return this;
+  }
+
+  public ActivityController<T> postCreate(@Nullable Bundle bundle) {
+    invokeWhilePaused(() -> _component_.onPostCreate(bundle));
+    return this;
+  }
+
+  public ActivityController<T> resume() {
+    invokeWhilePaused(
+        () -> {
+          if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
+            _component_.performResume();
+          } else {
+            _component_.performResume(true, "resume()");
+          }
+          currentState = LifecycleState.RESUMED;
+        });
+    return this;
+  }
+
+  public ActivityController<T> postResume() {
+    invokeWhilePaused(() -> _component_.onPostResume());
+    return this;
+  }
+  /**
+   * Calls the same lifecycle methods on the Activity called by Android when an Activity is the top
+   * most resumed activity on Q+.
+   */
+  @CanIgnoreReturnValue
+  public ActivityController<T> topActivityResumed(boolean isTop) {
+    if (RuntimeEnvironment.getApiLevel() < Q) {
+      return this;
+    }
+    invokeWhilePaused(
+        () -> _component_.performTopResumedActivityChanged(isTop, "topStateChangedWhenResumed"));
+    return this;
+  }
+
+  public ActivityController<T> visible() {
+    shadowMainLooper.runPaused(
+        () -> {
+          // emulate logic of ActivityThread#handleResumeActivity
+          component.getWindow().getAttributes().type =
+              WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
+          _component_.setDecor(component.getWindow().getDecorView());
+          _component_.makeVisible();
+        });
+
+    shadowMainLooper.idleIfPaused();
+    ViewRootImpl root = getViewRoot();
+    // root can be null if activity does not have content attached, or if looper is paused.
+    // this is unusual but leave the check here for legacy compatibility
+    if (root != null) {
+      callDispatchResized(root);
+      shadowMainLooper.idleIfPaused();
+    }
+    return this;
+  }
+
+  private ViewRootImpl getViewRoot() {
+    return component.getWindow().getDecorView().getViewRootImpl();
+  }
+
+  private void callDispatchResized(ViewRootImpl root) {
+    ((ShadowViewRootImpl) extract(root)).callDispatchResized();
+  }
+
+  public ActivityController<T> windowFocusChanged(boolean hasFocus) {
+    ViewRootImpl root = getViewRoot();
+    if (root == null) {
+      // root can be null if looper was paused during visible. Flush the looper and try again
+      shadowMainLooper.idle();
+
+      root = checkNotNull(getViewRoot());
+      callDispatchResized(root);
+    }
+
+    ((ShadowViewRootImpl) extract(root)).callWindowFocusChanged(hasFocus);
+
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public ActivityController<T> userLeaving() {
+    shadowMainLooper.runPaused(() -> getInstrumentation().callActivityOnUserLeaving(component));
+    return this;
+  }
+
+  public ActivityController<T> pause() {
+    shadowMainLooper.runPaused(
+        () -> {
+          getInstrumentation().callActivityOnPause(component);
+          currentState = LifecycleState.PAUSED;
+        });
+    return this;
+  }
+
+  public ActivityController<T> saveInstanceState(Bundle outState) {
+    shadowMainLooper.runPaused(
+        () -> getInstrumentation().callActivityOnSaveInstanceState(component, outState));
+    return this;
+  }
+
+  public ActivityController<T> stop() {
+    // Stop and start are tricky cases. Unlike other lifecycle methods such as
+    // Instrumentation#callActivityOnPause calls Activity#performPause, Activity#performStop calls
+    // Instrumentation#callActivityOnStop internally so the dependency direction is the opposite.
+    invokeWhilePaused(
+        () -> {
+          if (RuntimeEnvironment.getApiLevel() <= M) {
+            _component_.performStop();
+          } else if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
+            _component_.performStop(true);
+          } else {
+            _component_.performStop(true, "stop()");
+          }
+          currentState = LifecycleState.STOPPED;
+        });
+    return this;
+  }
+
+  @Override
+  public ActivityController<T> destroy() {
+    shadowMainLooper.runPaused(
+        () -> {
+          getInstrumentation().callActivityOnDestroy(component);
+          makeActivityEligibleForGc();
+          currentState = LifecycleState.DESTROYED;
+        });
+    return this;
+  }
+
+  private void makeActivityEligibleForGc() {
+    // Clear WindowManager state for this activity. On real Android this is done by
+    // ActivityThread.handleDestroyActivity, which is initiated by the window manager
+    // service.
+    boolean windowAdded = _component_.getWindowAdded();
+    if (windowAdded) {
+      WindowManager windowManager = component.getWindowManager();
+      windowManager.removeViewImmediate(component.getWindow().getDecorView());
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O_MR1) {
+      // Starting Android O_MR1, there is a leak in Android where `ContextImpl` holds on to the
+      // activity after being destroyed. This "fixes" the leak in Robolectric only, and will be
+      // properly fixed in Android S.
+      component.setAutofillClient(null);
+    }
+  }
+
+  /**
+   * Calls the same lifecycle methods on the Activity called by Android the first time the Activity
+   * is created.
+   *
+   * @return Activity controller instance.
+   */
+  public ActivityController<T> setup() {
+    return create().start().postCreate(null).resume().visible().topActivityResumed(true);
+  }
+
+  /**
+   * Calls the same lifecycle methods on the Activity called by Android when an Activity is restored
+   * from previously saved state.
+   *
+   * @param savedInstanceState Saved instance state.
+   * @return Activity controller instance.
+   */
+  public ActivityController<T> setup(Bundle savedInstanceState) {
+    return create(savedInstanceState)
+        .start()
+        .restoreInstanceState(savedInstanceState)
+        .postCreate(savedInstanceState)
+        .resume()
+        .visible()
+        .topActivityResumed(true);
+  }
+
+  public ActivityController<T> newIntent(Intent intent) {
+    invokeWhilePaused(() -> _component_.onNewIntent(intent));
+    return this;
+  }
+
+  /**
+   * Performs a configuration change on the Activity. See {@link #configurationChange(Configuration,
+   * DisplayMetrics, int)}. The configuration is taken from the application's configuration.
+   *
+   * <p>Generally this method should be avoided due to the way Robolectric shares the application
+   * context with activitys by default, this will result in the configuration diff producing no
+   * indicated change and Robolectric will not recreate the activity. Instead prefer to use {@link
+   * #configurationChange(Configuration, DisplayMetrics, int)} and provide an explicit configuration
+   * and diff.
+   */
+  public ActivityController<T> configurationChange() {
+    return configurationChange(component.getApplicationContext().getResources().getConfiguration());
+  }
+
+  /**
+   * Performs a configuration change on the Activity. See {@link #configurationChange(Configuration,
+   * DisplayMetrics, int)}. The changed configuration is calculated based on the activity's existing
+   * configuration.
+   *
+   * <p>When using {@link RuntimeEnvironment#setQualifiers(String)} prefer to use the {@link
+   * #configurationChange(Configuration, DisplayMetrics, int)} method and calculate the
+   * configuration diff manually, due to the way Robolectric uses the application context for
+   * activitys by default the configuration diff will otherwise be incorrectly calculated and the
+   * activity will not get recreqted if it doesn't handle configuration change.
+   */
+  public ActivityController<T> configurationChange(final Configuration newConfiguration) {
+    Resources resources = component.getResources();
+    return configurationChange(
+        newConfiguration,
+        resources.getDisplayMetrics(),
+        resources.getConfiguration().diff(newConfiguration));
+  }
+
+  /**
+   * Performs a configuration change on the Activity.
+   *
+   * <p>If the activity is configured to handle changes without being recreated, {@link
+   * Activity#onConfigurationChanged(Configuration)} will be called. Otherwise, the activity is
+   * recreated as described <a
+   * href="https://developer.android.com/guide/topics/resources/runtime-changes.html">here</a>.
+   *
+   * <p>Typically configuration should be applied using {@link RuntimeEnvironment#setQualifiers} and
+   * then propagated to the activity controller, e.g.
+   *
+   * <pre>{@code
+   * Resources resources = RuntimeEnvironment.getApplication().getResources();
+   * Configuration oldConfig = new Configuration(resources.getConfiguration());
+   * RuntimeEnvironment.setQualifiers("+ar-rXB");
+   * Configuration newConfig = resources.getConfiguration();
+   * activityController.configurationChange(
+   *     newConfig, resources.getDisplayMetrics(), oldConfig.diff(newConfig));
+   * }</pre>
+   *
+   * @param newConfiguration The new configuration to be set.
+   * @param changedConfig The changed configuration properties bitmask (e.g. the result of calling
+   *     {@link Configuration#diff(Configuration)}). This will be used to determine whether the
+   *     activity handles the configuration change or not, and whether it must be recreated.
+   * @return ActivityController instance
+   */
+  // TODO: Passing in the changed config explicitly should be unnecessary (i.e. the controller
+  //  should be able to diff against the current activity configuration), but due to the way
+  //  Robolectric uses the application context as the default activity context the application
+  //  context may be updated before entering this method (e.g. if RuntimeEnvironment#setQualifiers
+  //  was called before calling this method). When this issue is fixed this method should be
+  //  deprecated and removed.
+  public ActivityController<T> configurationChange(
+      Configuration newConfiguration, DisplayMetrics newMetrics, @Config int changedConfig) {
+    component.getResources().updateConfiguration(newConfiguration, newMetrics);
+
+    // TODO: throw on changedConfig == 0 since it non-intuitively calls onConfigurationChanged
+
+    // Can the activity handle itself ALL configuration changes?
+    if ((getActivityInfo(component.getApplication()).configChanges & changedConfig)
+        == changedConfig) {
+      shadowMainLooper.runPaused(
+          () -> {
+            component.onConfigurationChanged(newConfiguration);
+            ViewRootImpl root = getViewRoot();
+            if (root != null) {
+              if (RuntimeEnvironment.getApiLevel() <= N_MR1) {
+                ReflectionHelpers.callInstanceMethod(
+                    root,
+                    "updateConfiguration",
+                    ClassParameter.from(Configuration.class, newConfiguration),
+                    ClassParameter.from(boolean.class, false));
+              } else {
+                root.updateConfiguration(Display.INVALID_DISPLAY);
+              }
+            }
+          });
+
+      return this;
+    } else {
+      @SuppressWarnings("unchecked")
+      final T recreatedActivity = (T) ReflectionHelpers.callConstructor(component.getClass());
+      final _Activity_ _recreatedActivity_ = reflector(_Activity_.class, recreatedActivity);
+
+      shadowMainLooper.runPaused(
+          () -> {
+            // Set flags
+            _component_.setChangingConfigurations(true);
+            _component_.setConfigChangeFlags(changedConfig);
+
+            // Perform activity destruction
+            final Bundle outState = new Bundle();
+
+            // The order of onPause/onStop/onSaveInstanceState is undefined, but is usually:
+            // onPause -> onSaveInstanceState -> onStop before API P, and onPause -> onStop ->
+            // onSaveInstanceState from API P.
+            // See
+            // https://developer.android.com/reference/android/app/Activity#onSaveInstanceState(android.os.Bundle) for documentation explained.
+            // And see ActivityThread#callActivityOnStop for related code.
+            getInstrumentation().callActivityOnPause(component);
+            if (RuntimeEnvironment.getApiLevel() < P) {
+              _component_.performSaveInstanceState(outState);
+              if (RuntimeEnvironment.getApiLevel() <= M) {
+                _component_.performStop();
+              } else {
+                // API from N to O_MR1(both including)
+                _component_.performStop(true);
+              }
+            } else {
+              _component_.performStop(true, "configurationChange");
+              _component_.performSaveInstanceState(outState);
+            }
+
+            // This is the true and complete retained state, including loaders and retained
+            // fragments.
+            final Object nonConfigInstance = _component_.retainNonConfigurationInstances();
+            // This is the activity's "user" state
+            final Object activityConfigInstance =
+                nonConfigInstance == null
+                    ? null // No framework or user state.
+                    : reflector(_NonConfigurationInstances_.class, nonConfigInstance).getActivity();
+
+            getInstrumentation().callActivityOnDestroy(component);
+            makeActivityEligibleForGc();
+
+            // Restore theme in case it was set in the test manually.
+            // This is not technically what happens but is purely to make this easier to use in
+            // Robolectric.
+            ShadowContextThemeWrapper shadowContextThemeWrapper = Shadow.extract(component);
+            int theme = shadowContextThemeWrapper.callGetThemeResId();
+
+            // Setup controller for the new activity
+            attached = false;
+            component = recreatedActivity;
+            _component_ = _recreatedActivity_;
+
+            // TODO: Because robolectric is currently not creating unique context objects per
+            //  activity and that the app copmat framework uses weak maps to cache resources per
+            //  context the caches end up with stale objects between activity creations (which would
+            //  typically be flushed by an onConfigurationChanged when running in real android). To
+            //  workaround this we can invoke a gc after running the configuration change and
+            //  destroying the old activity which will flush the object references from the weak
+            //  maps (the side effect otherwise is flaky tests that behave differently based on when
+            //  garbage collection last happened to run).
+            //  This should be removed when robolectric.createActivityContexts is enabled.
+            System.gc();
+
+            // TODO: Pass nonConfigurationInstance here instead of setting
+            // mLastNonConfigurationInstances directly below. This field must be set before
+            // attach. Since current implementation sets it after attach(), initialization is not
+            // done correctly. For instance, fragment marked as retained is not retained.
+            attach(
+                /* activityOptions= */ null,
+                /* lastNonConfigurationInstances= */ null,
+                newConfiguration);
+
+            if (theme != 0) {
+              recreatedActivity.setTheme(theme);
+            }
+
+            // Set saved non config instance
+            _recreatedActivity_.setLastNonConfigurationInstances(nonConfigInstance);
+            ShadowActivity shadowActivity = Shadow.extract(recreatedActivity);
+            shadowActivity.setLastNonConfigurationInstance(activityConfigInstance);
+
+            // Create lifecycle
+            getInstrumentation().callActivityOnCreate(recreatedActivity, outState);
+
+            if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
+
+              _recreatedActivity_.performStart();
+
+            } else {
+              _recreatedActivity_.performStart("configurationChange");
+            }
+
+            getInstrumentation().callActivityOnRestoreInstanceState(recreatedActivity, outState);
+            _recreatedActivity_.onPostCreate(outState);
+            if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
+              _recreatedActivity_.performResume();
+            } else {
+              _recreatedActivity_.performResume(true, "configurationChange");
+            }
+            _recreatedActivity_.onPostResume();
+            // TODO: Call visible() too.
+            if (RuntimeEnvironment.getApiLevel() >= Q) {
+              _recreatedActivity_.performTopResumedActivityChanged(true, "configurationChange");
+            }
+          });
+    }
+
+    return this;
+  }
+
+  /**
+   * Recreates activity instance which is controlled by this ActivityController.
+   * NonConfigurationInstances and savedInstanceStateBundle are properly passed into a new instance.
+   * After the recreation, it brings back its lifecycle state to the original state. The activity
+   * should not be destroyed yet.
+   */
+  @SuppressWarnings("unchecked")
+  public ActivityController<T> recreate() {
+
+    LifecycleState originalState = currentState;
+
+    switch (originalState) {
+      case INITIAL:
+        create();
+        // fall through
+      case CREATED:
+      case RESTARTED:
+        start();
+        postCreate(null);
+        // fall through
+      case STARTED:
+        resume();
+        // fall through
+      case RESUMED:
+        pause();
+        // fall through
+      case PAUSED:
+        stop();
+        // fall through
+      case STOPPED:
+        break;
+      default:
+        throw new IllegalStateException("Cannot recreate activity since it's destroyed already");
+    }
+
+    // Activity#mChangingConfigurations flag should be set prior to Activity recreation process
+    // starts. ActivityThread does set it on real device but here we simulate the Activity
+    // recreation process on behalf of ActivityThread so set the flag here. Note we don't need to
+    // reset the flag to false because this Activity instance is going to be destroyed and disposed.
+    // https://android.googlesource.com/platform/frameworks/base/+/55418eada51d4f5e6532ae9517af66c50
+    // ea495c4/core/java/android/app/ActivityThread.java#4806
+    _component_.setChangingConfigurations(true);
+
+    Bundle outState = new Bundle();
+    saveInstanceState(outState);
+    Object lastNonConfigurationInstances = _component_.retainNonConfigurationInstances();
+    Configuration overrideConfig = component.getResources().getConfiguration();
+    destroy();
+
+    component = (T) ReflectionHelpers.callConstructor(component.getClass());
+    _component_ = reflector(_Activity_.class, component);
+    attached = false;
+    attach(/* activityOptions= */ null, lastNonConfigurationInstances, overrideConfig);
+    create(outState);
+    start();
+    restoreInstanceState(outState);
+    postCreate(outState);
+    resume();
+    postResume();
+    visible();
+    windowFocusChanged(true);
+    topActivityResumed(true);
+
+    // Move back to the original stage. If the original stage was transient stage, it will bring it
+    // to resumed state to match the on device behavior.
+    switch (originalState) {
+      case PAUSED:
+        pause();
+        return this;
+      case STOPPED:
+        pause();
+        stop();
+        return this;
+      default:
+        return this;
+    }
+  }
+
+  // Get the Instrumentation object scoped to the Activity.
+  private Instrumentation getInstrumentation() {
+    return _component_.getInstrumentation();
+  }
+
+  /**
+   * Transitions the underlying Activity to the 'destroyed' state by progressing through the
+   * appropriate lifecycle events. It frees up any resources and makes the Activity eligible for GC.
+   */
+  @Override
+  public void close() {
+
+    LifecycleState originalState = currentState;
+
+    switch (originalState) {
+      case INITIAL:
+      case DESTROYED:
+        return;
+      case RESUMED:
+        pause();
+        // fall through
+      case PAUSED:
+        // fall through
+      case RESTARTED:
+        // fall through
+      case STARTED:
+        stop();
+        // fall through
+      case STOPPED:
+        // fall through
+      case CREATED:
+        break;
+    }
+
+    destroy();
+  }
+
+  /** Accessor interface for android.app.Activity.NonConfigurationInstances's internals. */
+  @ForType(className = "android.app.Activity$NonConfigurationInstances")
+  interface _NonConfigurationInstances_ {
+
+    @Accessor("activity")
+    Object getActivity();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/BackupAgentController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/BackupAgentController.java
new file mode 100644
index 0000000..68aba8e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/BackupAgentController.java
@@ -0,0 +1,39 @@
+package org.robolectric.android.controller;
+
+import android.app.backup.BackupAgent;
+import android.content.Context;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+public class BackupAgentController<T extends BackupAgent> extends
+    ComponentController<BackupAgentController<T>, T> {
+  private BackupAgentController(T backupAgent) {
+    super(backupAgent);
+  }
+
+  public static <T extends BackupAgent> BackupAgentController<T> of(T backupAgent) {
+    return new BackupAgentController<>(backupAgent).attach();
+  }
+
+  private BackupAgentController<T> attach() {
+    if (attached) {
+      return this;
+    }
+    Context baseContext = RuntimeEnvironment.getApplication().getBaseContext();
+    ReflectionHelpers.callInstanceMethod(BackupAgent.class, component, "attach",
+        ReflectionHelpers.ClassParameter.from(Context.class, baseContext));
+    return this;
+  }
+
+  @Override
+  public BackupAgentController<T> create() {
+    invokeWhilePaused("onCreate");
+    return this;
+  }
+
+  @Override
+  public BackupAgentController<T> destroy() {
+    invokeWhilePaused("onDestroy");
+    return this;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/ComponentController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/ComponentController.java
new file mode 100644
index 0000000..09f7379
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/ComponentController.java
@@ -0,0 +1,67 @@
+package org.robolectric.android.controller;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import android.content.Intent;
+import android.os.Looper;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+public abstract class ComponentController<C extends ComponentController<C, T>, T> {
+  protected final C myself;
+  protected T component;
+  protected final ShadowLooper shadowMainLooper;
+
+  protected Intent intent;
+
+  protected boolean attached;
+
+  @SuppressWarnings("unchecked")
+  public ComponentController(T component, Intent intent) {
+    this(component);
+    this.intent = intent;
+  }
+
+  @SuppressWarnings("unchecked")
+  public ComponentController(T component) {
+    myself = (C) this;
+    this.component = component;
+    shadowMainLooper = Shadow.extract(Looper.getMainLooper());
+  }
+
+  public T get() {
+    return component;
+  }
+
+  public abstract C create();
+
+  public abstract C destroy();
+
+  public Intent getIntent() {
+    Intent intent =
+        this.intent == null
+            ? new Intent(RuntimeEnvironment.getApplication(), component.getClass())
+            : this.intent;
+    if (intent.getComponent() == null) {
+      intent.setClass(RuntimeEnvironment.getApplication(), component.getClass());
+    }
+    return intent;
+  }
+
+  protected C invokeWhilePaused(final String methodName, final ClassParameter<?>... classParameters) {
+    return invokeWhilePaused(
+        () -> ReflectionHelpers.callInstanceMethod(component, methodName, classParameters));
+  }
+
+  protected C invokeWhilePaused(Runnable runnable) {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!");
+    }
+    shadowMainLooper.runPaused(runnable);
+    return myself;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/ContentProviderController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/ContentProviderController.java
new file mode 100644
index 0000000..3643b16
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/ContentProviderController.java
@@ -0,0 +1,110 @@
+package org.robolectric.android.controller;
+
+import android.content.ComponentName;
+import android.content.ContentProvider;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.ProviderInfo;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.ShadowContentResolver;
+import org.robolectric.util.Logger;
+
+public class ContentProviderController<T extends ContentProvider>  {
+  private T contentProvider;
+
+  private ContentProviderController(T contentProvider) {
+    this.contentProvider = contentProvider;
+  }
+
+  public static <T extends ContentProvider> ContentProviderController<T> of(T contentProvider) {
+    return new ContentProviderController<>(contentProvider);
+  }
+
+  /**
+   * Create and register {@link ContentProvider} using {@link ProviderInfo} found from manifest.
+   */
+  public ContentProviderController<T> create() {
+    Context baseContext = RuntimeEnvironment.getApplication().getBaseContext();
+
+    ComponentName componentName = createRelative(baseContext.getPackageName(), contentProvider.getClass().getName());
+
+    ProviderInfo providerInfo = null;
+    try {
+      providerInfo =
+          baseContext
+              .getPackageManager()
+              .getProviderInfo(componentName, PackageManager.MATCH_DISABLED_COMPONENTS);
+    } catch (PackageManager.NameNotFoundException e) {
+      Logger.strict("Unable to find provider info for " + componentName, e);
+    }
+
+    return create(providerInfo);
+  }
+
+  /**
+   * Create and register {@link ContentProvider} using {@link ProviderInfo} found from manifest.
+   *
+   * @param authority the authority to use
+   * @return this {@link ContentProviderController}
+   */
+  public ContentProviderController<T> create(String authority) {
+    ProviderInfo providerInfo = new ProviderInfo();
+    providerInfo.authority = authority;
+    return create(providerInfo);
+  }
+
+  /**
+   * Create and register {@link ContentProvider} using the given {@link ProviderInfo}.
+   *
+   * @param providerInfo the {@link ProviderInfo} to use
+   * @return this {@link ContentProviderController}
+   */
+  public ContentProviderController<T> create(ProviderInfo providerInfo) {
+    if (providerInfo != null) {
+      Preconditions.checkArgument(
+          !Strings.isNullOrEmpty(providerInfo.authority),
+          "ProviderInfo.authority must not be null or empty");
+    }
+    Context baseContext = RuntimeEnvironment.getApplication().getBaseContext();
+    // make sure the component is enabled
+    ComponentName componentName =
+        createRelative(baseContext.getPackageName(), contentProvider.getClass().getName());
+    baseContext
+        .getPackageManager()
+        .setComponentEnabledSetting(
+            componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
+    contentProvider.attachInfo(baseContext, providerInfo);
+
+    if (providerInfo != null && providerInfo.authority != null) {
+      for (String authority : Splitter.on(';').split(providerInfo.authority)) {
+        ShadowContentResolver.registerProviderInternal(authority, contentProvider);
+      }
+    }
+
+    return this;
+  }
+
+  public T get() {
+    return contentProvider;
+  }
+
+  public ContentProviderController<T> shutdown() {
+    contentProvider.shutdown();
+    return this;
+  }
+
+  private static ComponentName createRelative(String pkg, String cls) {
+    final String fullName;
+    if (cls.charAt(0) == '.') {
+      // Relative to the package. Prepend the package name.
+      fullName = pkg + cls;
+    } else {
+      // Fully qualified package name.
+      fullName = cls;
+    }
+    return new ComponentName(pkg, fullName);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/FragmentController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/FragmentController.java
new file mode 100644
index 0000000..2ffe249
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/FragmentController.java
@@ -0,0 +1,218 @@
+package org.robolectric.android.controller;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.LinearLayout;
+import androidx.test.runner.lifecycle.ActivityLifecycleCallback;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.Stage;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * FragmentController provides low-level APIs to control fragment's lifecycle.
+ *
+ * @param <F> a class of the fragment which is under control by this class.
+ * @deprecated Native Fragments have been deprecated in Android P. Android encourages developers to
+ *     use androidx fragments, to test these use FragmentScenario.
+ */
+@Deprecated
+public class FragmentController<F extends Fragment>
+    extends ComponentController<FragmentController<F>, F> {
+  private F fragment;
+  private final ActivityController<? extends Activity> activityController;
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment) {
+    return of(fragment, FragmentControllerActivity.class, null, null);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Class<? extends Activity> activityClass) {
+    return of(fragment, activityClass, null, null);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Intent intent) {
+    return new FragmentController<>(fragment, FragmentControllerActivity.class, intent);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Bundle arguments) {
+    return new FragmentController<>(fragment, FragmentControllerActivity.class, arguments);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Intent intent, Bundle arguments) {
+    return new FragmentController<>(fragment, FragmentControllerActivity.class, intent,
+            arguments);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Class<? extends Activity> activityClass, Intent intent) {
+    return new FragmentController<>(fragment, activityClass, intent);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Class<? extends Activity> activityClass, Bundle arguments) {
+    return new FragmentController<>(fragment, activityClass, arguments);
+  }
+
+  public static <F extends Fragment> FragmentController<F> of(F fragment, Class<? extends Activity> activityClass,
+                                                              Intent intent, Bundle arguments) {
+    return new FragmentController<>(fragment, activityClass, intent, arguments);
+  }
+
+  private FragmentController(F fragment, Class<? extends Activity> activityClass, Intent intent) {
+    this(fragment, activityClass, intent, null);
+  }
+
+  private FragmentController(F fragment, Class<? extends Activity> activityClass, Bundle arguments) {
+    this(fragment, activityClass, null, arguments);
+  }
+
+  private FragmentController(F fragment, Class<? extends Activity> activityClass,
+                             Intent intent, Bundle arguments) {
+    super(fragment, intent);
+    this.fragment = fragment;
+    if (arguments != null) {
+      this.fragment.setArguments(arguments);
+    }
+    this.activityController = ActivityController.of(ReflectionHelpers.callConstructor(activityClass), intent);
+  }
+
+  /**
+   * Creates the activity with {@link Bundle} and adds the fragment to the view with ID {@code contentViewId}.
+   */
+  public FragmentController<F> create(final int contentViewId, final Bundle bundle) {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.create(bundle).get().getFragmentManager().beginTransaction().add(contentViewId, fragment).commit();
+      }
+    });
+    return this;
+  }
+
+  /**
+   * Creates the activity with {@link Bundle} and adds the fragment to it. Note that the fragment will be added to the view with ID 1.
+   */
+  public FragmentController<F> create(Bundle bundle) {
+    return create(1, bundle);
+  }
+
+  @Override
+  public FragmentController<F> create() {
+    return create(null);
+  }
+
+  @Override
+  public FragmentController<F> destroy() {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.destroy();
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> start() {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.start();
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> resume() {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.resume();
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> pause() {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.pause();
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> visible() {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.visible();
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> stop() {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.stop();
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> saveInstanceState(final Bundle outState) {
+    shadowMainLooper.runPaused(new Runnable() {
+      @Override
+      public void run() {
+        activityController.saveInstanceState(outState);
+      }
+    });
+    return this;
+  }
+
+  public FragmentController<F> recreate() {
+    return recreate((F) ReflectionHelpers.callConstructor(fragment.getClass()), 1);
+  }
+
+  public FragmentController<F> recreate(final F recreatedFragment, final int contentViewId) {
+    ActivityLifecycleCallback fragmentCreateCallback =
+        new ActivityLifecycleCallback() {
+          @Override
+          public void onActivityLifecycleChanged(Activity activity, Stage stage) {
+            if (Stage.CREATED.equals(stage)) {
+              activity
+                  .getFragmentManager()
+                  .beginTransaction()
+                  .add(contentViewId, recreatedFragment)
+                  .commit();
+              FragmentController.this.fragment = recreatedFragment;
+              FragmentController.this.component = recreatedFragment;
+            }
+          }
+        };
+    ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(fragmentCreateCallback);
+
+    shadowMainLooper.runPaused(
+        new Runnable() {
+          @Override
+          public void run() {
+            activityController.recreate();
+          }
+        });
+
+    ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(fragmentCreateCallback);
+    return this;
+  }
+
+  private static class FragmentControllerActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+      super.onCreate(savedInstanceState);
+      LinearLayout view = new LinearLayout(this);
+      view.setId(1);
+
+      setContentView(view);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/IntentServiceController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/IntentServiceController.java
new file mode 100644
index 0000000..4c5911e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/IntentServiceController.java
@@ -0,0 +1,97 @@
+package org.robolectric.android.controller;
+
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.app.ActivityThread;
+import android.app.Application;
+import android.app.IntentService;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.IBinder;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+public class IntentServiceController<T extends IntentService> extends ComponentController<IntentServiceController<T>, T> {
+
+  public static <T extends IntentService> IntentServiceController<T> of(final T service, final Intent intent) {
+    final IntentServiceController<T> controller = new IntentServiceController<>(service, intent);
+    controller.attach();
+    return controller;
+  }
+
+  private IntentServiceController(final T service, final Intent intent) {
+    super(service, intent);
+  }
+
+  private IntentServiceController<T> attach() {
+    if (attached) {
+      return this;
+    }
+    // make sure the component is enabled
+    Context context = RuntimeEnvironment.getApplication().getBaseContext();
+    ComponentName name =
+        new ComponentName(context.getPackageName(), component.getClass().getName());
+    context
+        .getPackageManager()
+        .setComponentEnabledSetting(name, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
+    ReflectionHelpers.callInstanceMethod(
+        Service.class,
+        component,
+        "attach",
+        from(Context.class, RuntimeEnvironment.getApplication().getBaseContext()),
+        from(ActivityThread.class, null),
+        from(String.class, component.getClass().getSimpleName()),
+        from(IBinder.class, null),
+        from(Application.class, RuntimeEnvironment.getApplication()),
+        from(Object.class, null));
+
+    attached = true;
+    return this;
+  }
+
+  public IntentServiceController<T> bind() {
+    invokeWhilePaused("onBind", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  @Override public IntentServiceController<T> create() {
+    invokeWhilePaused("onCreate");
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  @Override public IntentServiceController<T> destroy() {
+    invokeWhilePaused("onDestroy");
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public IntentServiceController<T> rebind() {
+    invokeWhilePaused("onRebind", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public IntentServiceController<T> startCommand(final int flags, final int startId) {
+    final IntentServiceController<T> intentServiceController = handleIntent();
+    get().stopSelf(startId);
+    shadowMainLooper.idleIfPaused();
+    return intentServiceController;
+  }
+
+  public IntentServiceController<T> unbind() {
+    invokeWhilePaused("onUnbind", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public IntentServiceController<T> handleIntent() {
+    invokeWhilePaused("onHandleIntent", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/ServiceController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/ServiceController.java
new file mode 100644
index 0000000..1c51823
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/ServiceController.java
@@ -0,0 +1,105 @@
+package org.robolectric.android.controller;
+
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.app.ActivityThread;
+import android.app.Application;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.IBinder;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+public class ServiceController<T extends Service> extends ComponentController<ServiceController<T>, T> {
+
+  public static <T extends Service> ServiceController<T> of(T service, Intent intent) {
+    ServiceController<T> controller = new ServiceController<>(service, intent);
+    controller.attach();
+    return controller;
+  }
+
+  private ServiceController(T service, Intent intent) {
+    super(service, intent);
+  }
+
+  private ServiceController<T> attach() {
+    if (attached) {
+      return this;
+    }
+    // make sure the component is enabled
+    Context context = RuntimeEnvironment.getApplication().getBaseContext();
+    ComponentName name =
+        new ComponentName(context.getPackageName(), component.getClass().getName());
+    context
+        .getPackageManager()
+        .setComponentEnabledSetting(name, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
+
+    ReflectionHelpers.callInstanceMethod(
+        Service.class,
+        component,
+        "attach",
+        from(Context.class, RuntimeEnvironment.getApplication().getBaseContext()),
+        from(ActivityThread.class, null),
+        from(String.class, component.getClass().getSimpleName()),
+        from(IBinder.class, null),
+        from(Application.class, RuntimeEnvironment.getApplication()),
+        from(Object.class, null));
+
+    attached = true;
+    return this;
+  }
+
+  public ServiceController<T> bind() {
+    invokeWhilePaused("onBind", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  @Override public ServiceController<T> create() {
+    invokeWhilePaused("onCreate");
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  @Override public ServiceController<T> destroy() {
+    invokeWhilePaused("onDestroy");
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public ServiceController<T> rebind() {
+    invokeWhilePaused("onRebind", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public ServiceController<T> startCommand(int flags, int startId) {
+    invokeWhilePaused(
+        "onStartCommand",
+        from(Intent.class, getIntent()),
+        from(int.class, flags),
+        from(int.class, startId));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  public ServiceController<T> unbind() {
+    invokeWhilePaused("onUnbind", from(Intent.class, getIntent()));
+    shadowMainLooper.idleIfPaused();
+    return this;
+  }
+
+  /**
+   * @deprecated Use the appropriate builder in {@link org.robolectric.Robolectric} instead.
+   *
+   * This method will be removed in Robolectric 3.6.
+   */
+  @Deprecated
+  public ServiceController<T> withIntent(Intent intent) {
+    this.intent = intent;
+    return this;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/internal/DisplayConfig.java b/shadows/framework/src/main/java/org/robolectric/android/internal/DisplayConfig.java
new file mode 100644
index 0000000..d2e75e1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/internal/DisplayConfig.java
@@ -0,0 +1,463 @@
+package org.robolectric.android.internal;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.view.Display;
+import android.view.DisplayCutout;
+import android.view.DisplayInfo;
+import android.view.Surface;
+import java.util.Arrays;
+import java.util.Objects;
+import org.robolectric.RuntimeEnvironment;
+
+/**
+ * Describes the characteristics of a particular logical display.
+ *
+ * <p>Robolectric internal (for now), do not use.
+ */
+public final class DisplayConfig {
+  /** The surface flinger layer stack associated with this logical display. */
+  public int layerStack;
+
+  /** Display flags. */
+  public int flags;
+
+  /** Display type. */
+  public int type;
+
+  /** Display address, or null if none. Interpretation varies by display type. */
+  // public String address;
+
+  /** The human-readable name of the display. */
+  public String name;
+
+  /** Unique identifier for the display. Shouldn't be displayed to the user. */
+  public String uniqueId;
+
+  /**
+   * The width of the portion of the display that is available to applications, in pixels.
+   * Represents the size of the display minus any system decorations.
+   */
+  public int appWidth;
+
+  /**
+   * The height of the portion of the display that is available to applications, in pixels.
+   * Represents the size of the display minus any system decorations.
+   */
+  public int appHeight;
+
+  /**
+   * The smallest value of {@link #appWidth} that an application is likely to encounter, in pixels,
+   * excepting cases where the width may be even smaller due to the presence of a soft keyboard, for
+   * example.
+   */
+  public int smallestNominalAppWidth;
+
+  /**
+   * The smallest value of {@link #appHeight} that an application is likely to encounter, in pixels,
+   * excepting cases where the height may be even smaller due to the presence of a soft keyboard,
+   * for example.
+   */
+  public int smallestNominalAppHeight;
+
+  /**
+   * The largest value of {@link #appWidth} that an application is likely to encounter, in pixels,
+   * excepting cases where the width may be even larger due to system decorations such as the status
+   * bar being hidden, for example.
+   */
+  public int largestNominalAppWidth;
+
+  /**
+   * The largest value of {@link #appHeight} that an application is likely to encounter, in pixels,
+   * excepting cases where the height may be even larger due to system decorations such as the
+   * status bar being hidden, for example.
+   */
+  public int largestNominalAppHeight;
+
+  /**
+   * The logical width of the display, in pixels. Represents the usable size of the display which
+   * may be smaller than the physical size when the system is emulating a smaller display.
+   */
+  public int logicalWidth;
+
+  /**
+   * The logical height of the display, in pixels. Represents the usable size of the display which
+   * may be smaller than the physical size when the system is emulating a smaller display.
+   */
+  public int logicalHeight;
+
+  /**
+   * The rotation of the display relative to its natural orientation. May be one of {@link
+   * Surface#ROTATION_0}, {@link Surface#ROTATION_90}, {@link Surface#ROTATION_180}, {@link
+   * Surface#ROTATION_270}.
+   *
+   * <p>The value of this field is indeterminate if the logical display is presented on more than
+   * one physical display.
+   */
+  @Surface.Rotation public int rotation;
+
+  /** The active display mode. */
+  public int modeId;
+
+  /** The default display mode. */
+  public int defaultModeId;
+
+  /** The supported modes of this display. */
+  public Display.Mode[] supportedModes = new Display.Mode[0];
+
+  /** The active color mode. */
+  public int colorMode;
+
+  /** The list of supported color modes */
+  public int[] supportedColorModes = {Display.COLOR_MODE_DEFAULT};
+
+  /** The display's HDR capabilities */
+  public Display.HdrCapabilities hdrCapabilities;
+
+  /** The logical display density which is the basis for density-independent pixels. */
+  public int logicalDensityDpi;
+
+  /**
+   * The exact physical pixels per inch of the screen in the X dimension.
+   *
+   * <p>The value of this field is indeterminate if the logical display is presented on more than
+   * one physical display.
+   */
+  public float physicalXDpi;
+
+  /**
+   * The exact physical pixels per inch of the screen in the Y dimension.
+   *
+   * <p>The value of this field is indeterminate if the logical display is presented on more than
+   * one physical display.
+   */
+  public float physicalYDpi;
+
+  /**
+   * This is a positive value indicating the phase offset of the VSYNC events provided by
+   * Choreographer relative to the display refresh. For example, if Choreographer reports that the
+   * refresh occurred at time N, it actually occurred at (N - appVsyncOffsetNanos).
+   */
+  public long appVsyncOffsetNanos;
+
+  /**
+   * This is how far in advance a buffer must be queued for presentation at a given time. If you
+   * want a buffer to appear on the screen at time N, you must submit the buffer before (N -
+   * bufferDeadlineNanos).
+   */
+  public long presentationDeadlineNanos;
+
+  /** The state of the display, such as {@link Display#STATE_ON}. */
+  public int state;
+
+  /**
+   * The UID of the application that owns this display, or zero if it is owned by the system.
+   *
+   * <p>If the display is private, then only the owner can use it.
+   */
+  public int ownerUid;
+
+  /**
+   * The package name of the application that owns this display, or null if it is owned by the
+   * system.
+   *
+   * <p>If the display is private, then only the owner can use it.
+   */
+  public String ownerPackageName;
+
+  /**
+   * @hide Get current remove mode of the display - what actions should be performed with the
+   *     display's content when it is removed.
+   * @see Display#getRemoveMode()
+   */
+  public int removeMode = Display.REMOVE_MODE_MOVE_CONTENT_TO_PRIMARY;
+
+  /** The area of the display that is not functional for displaying content */
+  public Object displayCutout;
+
+  public DisplayConfig() {}
+
+  public DisplayConfig(DisplayConfig other) {
+    copyFrom(other);
+  }
+
+  public DisplayConfig(DisplayInfo other) {
+    layerStack = other.layerStack;
+    flags = other.flags;
+    type = other.type;
+    // address = other.address;
+    name = other.name;
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP_MR1) {
+      uniqueId = other.uniqueId;
+    }
+    appWidth = other.appWidth;
+    appHeight = other.appHeight;
+    smallestNominalAppWidth = other.smallestNominalAppWidth;
+    smallestNominalAppHeight = other.smallestNominalAppHeight;
+    largestNominalAppWidth = other.largestNominalAppWidth;
+    largestNominalAppHeight = other.largestNominalAppHeight;
+    logicalWidth = other.logicalWidth;
+    logicalHeight = other.logicalHeight;
+    rotation = other.rotation;
+    if (RuntimeEnvironment.getApiLevel() >= M) {
+      modeId = other.modeId;
+      defaultModeId = other.defaultModeId;
+      supportedModes = Arrays.copyOf(other.supportedModes, other.supportedModes.length);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N_MR1) {
+      colorMode = other.colorMode;
+      supportedColorModes =
+          Arrays.copyOf(other.supportedColorModes, other.supportedColorModes.length);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      hdrCapabilities = other.hdrCapabilities;
+    }
+    logicalDensityDpi = other.logicalDensityDpi;
+    physicalXDpi = other.physicalXDpi;
+    physicalYDpi = other.physicalYDpi;
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      appVsyncOffsetNanos = other.appVsyncOffsetNanos;
+      presentationDeadlineNanos = other.presentationDeadlineNanos;
+      state = other.state;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= KITKAT) {
+      ownerUid = other.ownerUid;
+      ownerPackageName = other.ownerPackageName;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      removeMode = other.removeMode;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      displayCutout = other.displayCutout;
+    }
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return o instanceof DisplayConfig && equals((DisplayConfig) o);
+  }
+
+  @SuppressWarnings("NonOverridingEquals")
+  public boolean equals(DisplayConfig other) {
+    return other != null
+        && layerStack == other.layerStack
+        && flags == other.flags
+        && type == other.type
+        // && Objects.equals(address, other.address)
+        && Objects.equals(uniqueId, other.uniqueId)
+        && appWidth == other.appWidth
+        && appHeight == other.appHeight
+        && smallestNominalAppWidth == other.smallestNominalAppWidth
+        && smallestNominalAppHeight == other.smallestNominalAppHeight
+        && largestNominalAppWidth == other.largestNominalAppWidth
+        && largestNominalAppHeight == other.largestNominalAppHeight
+        && logicalWidth == other.logicalWidth
+        && logicalHeight == other.logicalHeight
+        && rotation == other.rotation
+        && modeId == other.modeId
+        && defaultModeId == other.defaultModeId
+        && colorMode == other.colorMode
+        && Arrays.equals(supportedColorModes, other.supportedColorModes)
+        && Objects.equals(hdrCapabilities, other.hdrCapabilities)
+        && logicalDensityDpi == other.logicalDensityDpi
+        && physicalXDpi == other.physicalXDpi
+        && physicalYDpi == other.physicalYDpi
+        && appVsyncOffsetNanos == other.appVsyncOffsetNanos
+        && presentationDeadlineNanos == other.presentationDeadlineNanos
+        && state == other.state
+        && ownerUid == other.ownerUid
+        && Objects.equals(ownerPackageName, other.ownerPackageName)
+        && removeMode == other.removeMode
+        && Objects.equals(displayCutout, other.displayCutout);
+  }
+
+  @Override
+  public int hashCode() {
+    return 0; // don't care
+  }
+
+  public void copyFrom(DisplayConfig other) {
+    layerStack = other.layerStack;
+    flags = other.flags;
+    type = other.type;
+    // address = other.address;
+    name = other.name;
+    uniqueId = other.uniqueId;
+    appWidth = other.appWidth;
+    appHeight = other.appHeight;
+    smallestNominalAppWidth = other.smallestNominalAppWidth;
+    smallestNominalAppHeight = other.smallestNominalAppHeight;
+    largestNominalAppWidth = other.largestNominalAppWidth;
+    largestNominalAppHeight = other.largestNominalAppHeight;
+    logicalWidth = other.logicalWidth;
+    logicalHeight = other.logicalHeight;
+    rotation = other.rotation;
+    modeId = other.modeId;
+    defaultModeId = other.defaultModeId;
+    supportedModes = Arrays.copyOf(other.supportedModes, other.supportedModes.length);
+    colorMode = other.colorMode;
+    supportedColorModes =
+        Arrays.copyOf(other.supportedColorModes, other.supportedColorModes.length);
+    hdrCapabilities = other.hdrCapabilities;
+    logicalDensityDpi = other.logicalDensityDpi;
+    physicalXDpi = other.physicalXDpi;
+    physicalYDpi = other.physicalYDpi;
+    appVsyncOffsetNanos = other.appVsyncOffsetNanos;
+    presentationDeadlineNanos = other.presentationDeadlineNanos;
+    state = other.state;
+    ownerUid = other.ownerUid;
+    ownerPackageName = other.ownerPackageName;
+    removeMode = other.removeMode;
+    displayCutout = other.displayCutout;
+  }
+
+  public void copyTo(DisplayInfo other) {
+    other.layerStack = layerStack;
+    other.flags = flags;
+    other.type = type;
+    // other.address = address;
+    other.name = name;
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP_MR1) {
+      other.uniqueId = uniqueId;
+    }
+    other.appWidth = appWidth;
+    other.appHeight = appHeight;
+    other.smallestNominalAppWidth = smallestNominalAppWidth;
+    other.smallestNominalAppHeight = smallestNominalAppHeight;
+    other.largestNominalAppWidth = largestNominalAppWidth;
+    other.largestNominalAppHeight = largestNominalAppHeight;
+    other.logicalWidth = logicalWidth;
+    other.logicalHeight = logicalHeight;
+    other.rotation = rotation;
+    if (RuntimeEnvironment.getApiLevel() >= M) {
+      other.modeId = modeId;
+      other.defaultModeId = defaultModeId;
+      other.supportedModes = Arrays.copyOf(supportedModes, supportedModes.length);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N_MR1) {
+      other.colorMode = colorMode;
+      other.supportedColorModes = Arrays.copyOf(supportedColorModes, supportedColorModes.length);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      other.hdrCapabilities = hdrCapabilities;
+    }
+    other.logicalDensityDpi = logicalDensityDpi;
+    other.physicalXDpi = physicalXDpi;
+    other.physicalYDpi = physicalYDpi;
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      other.appVsyncOffsetNanos = appVsyncOffsetNanos;
+      other.presentationDeadlineNanos = presentationDeadlineNanos;
+      other.state = state;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= KITKAT) {
+      other.ownerUid = ownerUid;
+      other.ownerPackageName = ownerPackageName;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      other.removeMode = removeMode;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      other.displayCutout = (DisplayCutout) displayCutout;
+    }
+  }
+
+  // For debugging purposes
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("DisplayConfig{\"");
+    sb.append(name);
+    sb.append("\", uniqueId \"");
+    sb.append(uniqueId);
+    sb.append("\", app ");
+    sb.append(appWidth);
+    sb.append(" x ");
+    sb.append(appHeight);
+    sb.append(", real ");
+    sb.append(logicalWidth);
+    sb.append(" x ");
+    sb.append(logicalHeight);
+    sb.append(", largest app ");
+    sb.append(largestNominalAppWidth);
+    sb.append(" x ");
+    sb.append(largestNominalAppHeight);
+    sb.append(", smallest app ");
+    sb.append(smallestNominalAppWidth);
+    sb.append(" x ");
+    sb.append(smallestNominalAppHeight);
+    sb.append(", mode ");
+    sb.append(modeId);
+    sb.append(", defaultMode ");
+    sb.append(defaultModeId);
+    sb.append(", modes ");
+    sb.append(Arrays.toString(supportedModes));
+    sb.append(", colorMode ");
+    sb.append(colorMode);
+    sb.append(", supportedColorModes ");
+    sb.append(Arrays.toString(supportedColorModes));
+    sb.append(", hdrCapabilities ");
+    sb.append(hdrCapabilities);
+    sb.append(", rotation ");
+    sb.append(rotation);
+    sb.append(", density ");
+    sb.append(logicalDensityDpi);
+    sb.append(" (");
+    sb.append(physicalXDpi);
+    sb.append(" x ");
+    sb.append(physicalYDpi);
+    sb.append(") dpi, layerStack ");
+    sb.append(layerStack);
+    sb.append(", appVsyncOff ");
+    sb.append(appVsyncOffsetNanos);
+    sb.append(", presDeadline ");
+    sb.append(presentationDeadlineNanos);
+    sb.append(", type ");
+    sb.append(Display.typeToString(type));
+    // if (address != null) {
+    //   sb.append(", address ").append(address);
+    // }
+    sb.append(", state ");
+    sb.append(Display.stateToString(state));
+    if (ownerUid != 0 || ownerPackageName != null) {
+      sb.append(", owner ").append(ownerPackageName);
+      sb.append(" (uid ").append(ownerUid).append(")");
+    }
+    sb.append(flagsToString(flags));
+    sb.append(", removeMode ");
+    sb.append(removeMode);
+    sb.append(", displayCutout ");
+    sb.append(displayCutout);
+    sb.append("}");
+    return sb.toString();
+  }
+
+  private static String flagsToString(int flags) {
+    StringBuilder result = new StringBuilder();
+    if ((flags & Display.FLAG_SECURE) != 0) {
+      result.append(", FLAG_SECURE");
+    }
+    if ((flags & Display.FLAG_SUPPORTS_PROTECTED_BUFFERS) != 0) {
+      result.append(", FLAG_SUPPORTS_PROTECTED_BUFFERS");
+    }
+    if ((flags & Display.FLAG_PRIVATE) != 0) {
+      result.append(", FLAG_PRIVATE");
+    }
+    if ((flags & Display.FLAG_PRESENTATION) != 0) {
+      result.append(", FLAG_PRESENTATION");
+    }
+    if ((flags & Display.FLAG_SCALING_DISABLED) != 0) {
+      result.append(", FLAG_SCALING_DISABLED");
+    }
+    if ((flags & Display.FLAG_ROUND) != 0) {
+      result.append(", FLAG_ROUND");
+    }
+    return result.toString();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/BackgroundExecutor.java b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/BackgroundExecutor.java
new file mode 100644
index 0000000..a9aaa1a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/BackgroundExecutor.java
@@ -0,0 +1,41 @@
+package org.robolectric.android.util.concurrent;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+/**
+ * Utility class for running code off the main looper thread aka Robolectric test thread.
+ */
+public class BackgroundExecutor {
+
+  private BackgroundExecutor() {}
+
+  // use an inner class reference to lazy load the singleton in a thread-safe manner
+  private static class SingletonHolder {
+    private static final BackgroundExecutor instance = new BackgroundExecutor();
+  }
+
+  private final InlineExecutorService backgroundExecutorService =
+      new InlineExecutorService();
+
+  /**
+   * A helper method intended for testing production code that needs to run off the main Looper.
+   *
+   * Will execute given runnable in a background thread and will do a best-effort attempt at
+   * propagating any exception back up to caller in their original form.
+   */
+  public static void runInBackground(Runnable runnable) {
+    SingletonHolder.instance.backgroundExecutorService.execute(runnable);
+  }
+
+  /**
+   * A helper method intended for testing production code that needs to run off the main Looper.
+   *
+   * <p>Will execute given callable in a background thread and will do a best-effort attempt at
+   * propagating any exception back up to caller in their original form.
+   */
+  public static <T> T runInBackground(Callable<T> callable) {
+    Future<T> future = SingletonHolder.instance.backgroundExecutorService.submit(callable);
+    return PausedExecutorService.getFutureResultWithExceptionPreserved(future);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/InlineExecutorService.java b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/InlineExecutorService.java
new file mode 100644
index 0000000..2551303
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/InlineExecutorService.java
@@ -0,0 +1,122 @@
+package org.robolectric.android.util.concurrent;
+
+import androidx.annotation.NonNull;
+import com.google.common.annotations.Beta;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.robolectric.annotation.LooperMode;
+
+/**
+ * Executor service that executes posted tasks as soon as they are posted.
+ *
+ * Intended to be a replacement for {@link RoboExecutorService} when using
+ * {@link LooperMode.Mode#PAUSED}.
+ * Unlike {@link RoboExecutorService}, will execute tasks on a background thread. This is useful
+ * to test Android code that enforces it runs off the main thread.
+ *
+ * Also consider using {@link MoreExecutors#directExecutor()}, if your code under test can handle
+ * being called from main thread.
+ *
+ * Also see {@link PausedExecutorService} if you need control over when posted tasks are executed.
+ *
+ * NOTE: Beta API, subject to change.
+ */
+@Beta
+public class InlineExecutorService implements ExecutorService {
+  private final PausedExecutorService delegateService;
+
+  public InlineExecutorService() {
+    this.delegateService = new PausedExecutorService();
+  }
+
+  @Override
+  public void shutdown() {
+    delegateService.shutdown();
+  }
+
+  @Override
+  public List<Runnable> shutdownNow() {
+    return delegateService.shutdownNow();
+  }
+
+  @Override
+  public boolean isShutdown() {
+    return delegateService.isShutdown();
+  }
+
+  @Override
+  public boolean isTerminated() {
+    return delegateService.isTerminated();
+  }
+
+  @Override
+  public boolean awaitTermination(long l, TimeUnit timeUnit) throws InterruptedException {
+    // If not shut down first, timeout would occur with normal behavior.
+    return delegateService.awaitTermination(l, timeUnit);
+  }
+
+  @NonNull
+  @Override
+  public <T> Future<T> submit(@NonNull Callable<T> task) {
+    Future<T> future = delegateService.submit(task);
+    delegateService.runAll();
+    return future;
+  }
+
+  @NonNull
+  @Override
+  public <T> Future<T> submit(@NonNull Runnable task, T result) {
+    Future<T> future =  delegateService.submit(task, result);
+    delegateService.runAll();
+    return future;
+  }
+
+  @NonNull
+  @Override
+  public Future<?> submit(@NonNull Runnable task) {
+    Future<?> future = delegateService.submit(task);
+    delegateService.runAll();
+    return future;
+  }
+
+  @Override
+  @SuppressWarnings("FutureReturnValueIgnored")
+  public void execute(@NonNull Runnable command) {
+    delegateService.execute(command);
+    delegateService.runAll();
+  }
+
+  @NonNull
+  @Override
+  public <T> List<Future<T>> invokeAll(@NonNull Collection<? extends Callable<T>> tasks)
+      throws InterruptedException {
+    return delegateService.invokeAll(tasks);
+  }
+
+  @NonNull
+  @Override
+  public <T> List<Future<T>> invokeAll(@NonNull Collection<? extends Callable<T>> tasks,
+      long timeout, @NonNull TimeUnit unit) throws InterruptedException {
+    return delegateService.invokeAll(tasks, timeout, unit);
+  }
+
+  @NonNull
+  @Override
+  public <T> T invokeAny(@NonNull Collection<? extends Callable<T>> tasks)
+      throws ExecutionException, InterruptedException {
+    return delegateService.invokeAny(tasks);
+  }
+
+  @Override
+  public <T> T invokeAny(@NonNull Collection<? extends Callable<T>> tasks, long timeout,
+      @NonNull TimeUnit unit) throws ExecutionException, InterruptedException, TimeoutException {
+    return delegateService.invokeAny(tasks, timeout, unit);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/PausedExecutorService.java b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/PausedExecutorService.java
new file mode 100644
index 0000000..cd16c11
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/PausedExecutorService.java
@@ -0,0 +1,180 @@
+package org.robolectric.android.util.concurrent;
+
+import androidx.annotation.NonNull;
+import com.google.common.annotations.Beta;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.AbstractFuture;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.RunnableFuture;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.util.Logger;
+
+/**
+ * Executor service that queues any posted tasks.
+ *
+ * Users must explicitly call {@link runAll()} to execute all pending tasks.
+ *
+ * Intended to be a replacement for {@link RoboExecutorService} when using
+ * {@link LooperMode.Mode#PAUSED}.
+ * Unlike {@link RoboExecutorService}, will execute tasks on a background thread. This is useful
+ * to test Android code that enforces it runs off the main thread.
+ *
+ * NOTE: Beta API, subject to change.
+ */
+@Beta
+public class PausedExecutorService extends AbstractExecutorService {
+
+  /**
+   * Run given callable on the given executor and try to preserve original exception if possible.
+   */
+  static <T> T getFutureResultWithExceptionPreserved(Future<T> future) {
+    try {
+      return future.get();
+    } catch (ExecutionException e) {
+      // try to preserve original exception if possible
+      Throwable cause = e.getCause();
+      if (cause == null) {
+        throw new RuntimeException(e);
+      } else if (cause instanceof RuntimeException) {
+        throw (RuntimeException) cause;
+      } else {
+        throw new RuntimeException(cause);
+      }
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private final ExecutorService realService;
+  private final Queue<Runnable> deferredTasks = new ConcurrentLinkedQueue<>();
+  private Thread executorThread;
+
+  private static class DeferredTask<V> extends AbstractFuture<V> implements RunnableFuture<V> {
+
+    private final Callable<V> callable;
+    private final ExecutorService executor;
+
+    DeferredTask(Callable<V> callable, ExecutorService executor) {
+      this.callable = callable;
+      this.executor = executor;
+    }
+
+    @Override
+    public void run() {
+      Future<V> future = executor.submit(callable);
+      set(getFutureResultWithExceptionPreserved(future));
+    }
+  }
+
+  public PausedExecutorService() {
+    this.realService =
+        Executors.newSingleThreadExecutor(
+            r -> {
+              executorThread = new Thread(r);
+              return executorThread;
+            });
+  }
+
+  /**
+   * Execute all posted tasks and block until they are complete.
+   *
+   * @return the number of tasks executed
+   */
+  public int runAll() {
+    int numTasksRun = 0;
+    if (Thread.currentThread().equals(executorThread)) {
+      Logger.info("ignoring request to execute task - called from executor's own thread");
+      return numTasksRun;
+    }
+    while (hasQueuedTasks()) {
+      runNext();
+      numTasksRun++;
+    }
+    return numTasksRun;
+  }
+
+  /**
+   * Executes the next queued task.
+   *
+   * Will be ignored if called from the executor service thread to prevent deadlocks.
+   *
+   * @return true if task was run, false if queue was empty
+   */
+  public boolean runNext() {
+    if (!hasQueuedTasks()) {
+      return false;
+    }
+    if (Thread.currentThread().equals(executorThread)) {
+      Logger.info("ignoring request to execute task - called from executor's own thread");
+      return false;
+    }
+    Runnable task = deferredTasks.poll();
+    task.run();
+    return true;
+  }
+
+  /**
+   * @return true if there are queued pending tasks
+   */
+  public boolean hasQueuedTasks() {
+    return !deferredTasks.isEmpty();
+  }
+
+  @Override
+  public void shutdown() {
+    realService.shutdown();
+    deferredTasks.clear();
+  }
+
+  @Override
+  public List<Runnable> shutdownNow() {
+    realService.shutdownNow();
+    List<Runnable> copy = ImmutableList.copyOf(deferredTasks);
+    deferredTasks.clear();
+    return copy;
+  }
+
+  @Override
+  public boolean isShutdown() {
+    return realService.isShutdown();
+  }
+
+  @Override
+  public boolean isTerminated() {
+    return realService.isTerminated();
+  }
+
+  @Override
+  public boolean awaitTermination(long l, TimeUnit timeUnit) throws InterruptedException {
+    // If not shut down first, timeout would occur with normal behavior.
+    return realService.awaitTermination(l, timeUnit);
+  }
+
+  @Override
+  public void execute(@NonNull Runnable command) {
+    if (command instanceof DeferredTask) {
+      deferredTasks.add(command);
+    } else {
+      deferredTasks.add(new DeferredTask<>(Executors.callable(command), realService));
+    }
+  }
+
+  @Override
+  protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
+    return newTaskFor(Executors.callable(runnable, value));
+  }
+
+  @Override
+  protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
+    return new DeferredTask<T>(callable, realService);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/RoboExecutorService.java b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/RoboExecutorService.java
new file mode 100644
index 0000000..8bd64da
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/android/util/concurrent/RoboExecutorService.java
@@ -0,0 +1,144 @@
+package org.robolectric.android.util.concurrent;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.util.Scheduler;
+
+/**
+ * Executor service that runs all operations on the background scheduler.
+ *
+ * @deprecated only works when used in conjunction with the deprecated {@link LooperMode.LEGACY}
+ *     mode. Consider using guava's {@link MoreExecutors#directExecutor()} or {@link
+ *     org.robolectric.android.util.concurrent.PausedExecutorService} or {@link
+ *     org.robolectric.android.util.concurrent.InlineExecutorService}.
+ */
+@Deprecated
+public class RoboExecutorService implements ExecutorService {
+  private final Scheduler scheduler;
+  private boolean isShutdown;
+  private final HashSet<Runnable> runnables = new HashSet<>();
+
+  static class AdvancingFutureTask<V> extends FutureTask<V> {
+    private final Scheduler scheduler;
+
+    public AdvancingFutureTask(Scheduler scheduler, Callable<V> callable) {
+      super(callable);
+      this.scheduler = scheduler;
+    }
+
+    public AdvancingFutureTask(Scheduler scheduler, Runnable runnable, V result) {
+      super(runnable, result);
+      this.scheduler = scheduler;
+    }
+
+    @Override
+    public V get() throws InterruptedException, ExecutionException {
+      while (!isDone()) {
+        scheduler.advanceToNextPostedRunnable();
+      }
+      return super.get();
+    }
+  }
+
+  public RoboExecutorService() {
+    this.scheduler = ShadowApplication.getInstance().getBackgroundThreadScheduler();
+  }
+
+  @Override
+  public void shutdown() {
+    shutdownNow();
+  }
+
+  @Override
+  public List<Runnable> shutdownNow() {
+    isShutdown = true;
+    List<Runnable> notExecutedRunnables = new ArrayList<>();
+    for (Runnable runnable : runnables) {
+      scheduler.remove(runnable);
+      notExecutedRunnables.add(runnable);
+    }
+    runnables.clear();
+    return notExecutedRunnables;
+  }
+
+  @Override
+  public boolean isShutdown() {
+    return isShutdown;
+  }
+
+  @Override
+  public boolean isTerminated() {
+    return isShutdown;
+  }
+
+  @Override
+  public boolean awaitTermination(long l, TimeUnit timeUnit) throws InterruptedException {
+    // If not shut down first, timeout would occur with normal behavior.
+    return isShutdown();
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> tCallable) {
+    return schedule(new AdvancingFutureTask<T>(scheduler, tCallable));
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable runnable, T t) {
+    return schedule(new AdvancingFutureTask<T>(scheduler, runnable, t));
+  }
+
+  @Override
+  public Future<?> submit(Runnable runnable) {
+    return submit(runnable, null);
+  }
+
+  private <T> Future<T> schedule(final FutureTask<T> futureTask) {
+    Runnable runnable = new Runnable() {
+      @Override
+      public void run() {
+        futureTask.run();
+        runnables.remove(this);
+      }
+    };
+    runnables.add(runnable);
+    scheduler.post(runnable);
+
+    return futureTask;
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> callables) throws InterruptedException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> callables, long l, TimeUnit timeUnit) throws InterruptedException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> callables) throws InterruptedException, ExecutionException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> callables, long l, TimeUnit timeUnit) throws InterruptedException, ExecutionException, TimeoutException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void execute(Runnable runnable) {
+    @SuppressWarnings({"unused", "nullness"})
+    Future<?> possiblyIgnoredError = submit(runnable);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/BaseCursor.java b/shadows/framework/src/main/java/org/robolectric/fakes/BaseCursor.java
new file mode 100644
index 0000000..e6bb4cf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/BaseCursor.java
@@ -0,0 +1,229 @@
+package org.robolectric.fakes;
+
+import android.content.ContentResolver;
+import android.database.CharArrayBuffer;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.DataSetObserver;
+import android.net.Uri;
+import android.os.Bundle;
+
+/**
+ * Robolectric implementation of {@link android.database.Cursor}.
+ *
+ * @deprecated Use {@link android.database.MatrixCursor} instead.
+ */
+@Deprecated
+public class BaseCursor implements Cursor {
+  @Override
+  public int getCount() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getPosition() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean move(int offset) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean moveToPosition(int position) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean moveToFirst() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean moveToLast() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean moveToNext() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean moveToPrevious() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isFirst() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isLast() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isBeforeFirst() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isAfterLast() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getColumnIndex(String columnName) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getColumnName(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String[] getColumnNames() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getColumnCount() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public byte[] getBlob(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getString(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public short getShort(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getInt(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public long getLong(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public float getFloat(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public double getDouble(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isNull(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void deactivate() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean requery() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void close() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isClosed() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void registerContentObserver(ContentObserver observer) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void unregisterContentObserver(ContentObserver observer) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void registerDataSetObserver(DataSetObserver observer) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void unregisterDataSetObserver(DataSetObserver observer) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setNotificationUri(ContentResolver cr, Uri uri) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Uri getNotificationUri() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean getWantsAllOnMoveCalls() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setExtras(Bundle extras) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Bundle getExtras() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Bundle respond(Bundle extras) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getType(int columnIndex) {
+    throw new UnsupportedOperationException();
+  }
+
+  /*
+   * Mimics ContentResolver.query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
+   */
+  public void setQuery(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+    // Override this in your subclass if you care to implement any of the other methods based on the query that was performed.
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboCursor.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboCursor.java
new file mode 100644
index 0000000..f1a64ec
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboCursor.java
@@ -0,0 +1,213 @@
+package org.robolectric.fakes;
+
+import android.database.DatabaseUtils;
+import android.net.Uri;
+import android.os.Bundle;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Robolectric implementation of {@link android.database.Cursor}.
+
+ * @deprecated Use {@link android.database.MatrixCursor} instead.
+ */
+@Deprecated
+public class RoboCursor extends BaseCursor {
+  public Uri uri;
+  public String[] projection;
+  public String selection;
+  public String[] selectionArgs;
+  public String sortOrder;
+  protected Object[][] results = new Object[0][0];
+  protected List<String> columnNames = new ArrayList<>();
+  private int resultsIndex = -1;
+  private boolean closeWasCalled;
+  private Bundle extras;
+
+  @Override
+  public void setQuery(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+    this.uri = uri;
+    this.projection = projection;
+    this.selection = selection;
+    this.selectionArgs = selectionArgs;
+    this.sortOrder = sortOrder;
+  }
+
+  @Override
+  public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
+    int col = getColumnIndex(columnName);
+    if (col == -1) {
+      throw new IllegalArgumentException("No column with name: " + columnName);
+    }
+    return col;
+  }
+
+  @Override
+  public int getColumnIndex(String columnName) {
+    return columnNames.indexOf(columnName);
+  }
+
+  @Override
+  public String getString(int columnIndex) {
+    Object value = results[resultsIndex][columnIndex];
+    return value == null ? null : value.toString();
+  }
+
+  @Override
+  public short getShort(int columnIndex) {
+    Object value = results[resultsIndex][columnIndex];
+    return value == null ? 0 : (value instanceof Number ? ((Number) value).shortValue() : Short.parseShort(value.toString()));
+  }
+
+  @Override
+  public int getInt(int columnIndex) {
+    Object value = results[resultsIndex][columnIndex];
+    return value == null ? 0 : (value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString()));
+  }
+
+  @Override
+  public long getLong(int columnIndex) {
+    Object value = results[resultsIndex][columnIndex];
+    return value == null
+        ? 0
+        : (value instanceof Number
+            ? ((Number) value).longValue()
+            : Long.parseLong(value.toString()));
+  }
+
+  @Override
+  public float getFloat(int columnIndex) {
+    Object value = results[resultsIndex][columnIndex];
+    return value == null ? 0 : (value instanceof Number ? ((Number) value).floatValue() : Float.parseFloat(value.toString()));
+  }
+
+  @Override
+  public double getDouble(int columnIndex) {
+    Object value = results[resultsIndex][columnIndex];
+    return value == null ? 0 : (value instanceof Number ? ((Number) value).doubleValue() : Double.parseDouble(value.toString()));
+  }
+
+  @Override
+  public byte[] getBlob(int columnIndex) {
+    return (byte[]) results[resultsIndex][columnIndex];
+  }
+
+  @Override
+  public int getType(int columnIndex) {
+    return DatabaseUtils.getTypeOfObject(results[0][columnIndex]);
+  }
+
+  @Override
+  public boolean isNull(int columnIndex) {
+    return results[resultsIndex][columnIndex] == null;
+  }
+
+  @Override
+  public int getCount() {
+    return results.length;
+  }
+
+  @Override
+  public boolean moveToNext() {
+    return doMoveToPosition(resultsIndex + 1);
+  }
+
+  @Override
+  public boolean moveToFirst() {
+    return doMoveToPosition(0);
+  }
+
+  @Override
+  public boolean moveToPosition(int position) {
+    return doMoveToPosition(position);
+  }
+
+  private boolean doMoveToPosition(int position) {
+    resultsIndex = position;
+    return resultsIndex >= 0 && resultsIndex < results.length;
+  }
+
+  @Override
+  public void close() {
+    closeWasCalled = true;
+  }
+
+  @Override
+  public int getColumnCount() {
+    if (columnNames.isEmpty()) {
+      return results[0].length;
+    } else {
+      return columnNames.size();
+    }
+  }
+
+  @Override
+  public String getColumnName(int index) {
+    return columnNames.get(index);
+  }
+
+  @Override
+  public boolean isBeforeFirst() {
+    return resultsIndex < 0;
+  }
+
+  @Override
+  public boolean isAfterLast() {
+    return resultsIndex > results.length - 1;
+  }
+
+  @Override
+  public boolean isFirst() {
+    return resultsIndex == 0;
+  }
+
+  @Override
+  public boolean isLast() {
+    return resultsIndex == results.length - 1;
+  }
+
+  @Override public int getPosition() {
+    return resultsIndex;
+  }
+
+  @Override public boolean move(int offset) {
+    return doMoveToPosition(resultsIndex + offset);
+  }
+
+  @Override public boolean moveToLast() {
+    return doMoveToPosition(results.length - 1);
+  }
+
+  @Override public boolean moveToPrevious() {
+    return doMoveToPosition(resultsIndex - 1);
+  }
+
+  @Override public String[] getColumnNames() {
+    return columnNames.toArray(new String[columnNames.size()]);
+  }
+
+  @Override public boolean isClosed() {
+    return closeWasCalled;
+  }
+
+  @Override public Bundle getExtras() {
+    return extras;
+  }
+
+  @Override
+  public void setExtras(Bundle extras) {
+    this.extras = extras;
+  }
+
+  public void setColumnNames(List<String> columnNames) {
+    this.columnNames = columnNames;
+  }
+
+  public void setResults(Object[][] results) {
+    this.results = results;
+  }
+
+  public boolean getCloseWasCalled() {
+    return closeWasCalled;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboIntentSender.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboIntentSender.java
new file mode 100644
index 0000000..ce71837
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboIntentSender.java
@@ -0,0 +1,49 @@
+package org.robolectric.fakes;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.IIntentSender;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.os.Handler;
+import java.util.Objects;
+
+/**
+ * Robolectric implementation of {@link android.content.IntentSender}.
+ */
+public class RoboIntentSender extends IntentSender {
+  public Intent intent;
+  private PendingIntent pendingIntent;
+
+  public RoboIntentSender(PendingIntent pendingIntent) {
+    super((IIntentSender) null);
+    this.pendingIntent = pendingIntent;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof RoboIntentSender)) {
+      return false;
+    }
+    return Objects.equals(pendingIntent, ((RoboIntentSender) other).pendingIntent);
+  }
+
+  @Override
+  public int hashCode() {
+    return pendingIntent.hashCode();
+  }
+
+  @Override public void sendIntent(Context context, int code, Intent intent,
+                         final OnFinished onFinished, Handler handler, String requiredPermission)
+      throws SendIntentException {
+    try {
+      pendingIntent.send(context, code, intent);
+    } catch (PendingIntent.CanceledException e) {
+      throw new SendIntentException(e);
+    }
+  }
+
+  public PendingIntent getPendingIntent() {
+    return pendingIntent;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboMenu.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboMenu.java
new file mode 100644
index 0000000..9a755f0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboMenu.java
@@ -0,0 +1,217 @@
+package org.robolectric.fakes;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+
+/**
+ * Robolectric implementation of {@link android.view.Menu}.
+ */
+public class RoboMenu implements Menu {
+  private List<MenuItem> menuItems = new ArrayList<>();
+  private Context context;
+
+  public RoboMenu() {
+    this(RuntimeEnvironment.getApplication());
+  }
+
+  public RoboMenu(Context context) {
+    this.context = context;
+  }
+
+  @Override
+  public MenuItem add(CharSequence title) {
+    return add(0, 0, 0, title);
+  }
+
+  @Override
+  public MenuItem add(int titleRes) {
+    return add(0, 0, 0, titleRes);
+  }
+
+  @Override
+  public MenuItem add(int groupId, int itemId, int order, CharSequence title) {
+    RoboMenuItem menuItem = new RoboMenuItem(context);
+    menuItem.setOrder(order);
+    menuItems.add(menuItem);
+    menuItem.setGroupId(groupId);
+    Collections.sort(menuItems, new CustomMenuItemComparator());
+    menuItem.setItemId(itemId);
+    menuItem.setTitle(title);
+    return menuItem;
+  }
+
+  @Override
+  public MenuItem add(int groupId, int itemId, int order, int titleRes) {
+    return add(groupId, itemId, order, context.getResources().getString(titleRes));
+  }
+
+  @Override
+  public SubMenu addSubMenu(CharSequence title) {
+    RoboSubMenu tsm = new RoboSubMenu(context);
+    RoboMenuItem menuItem = new RoboMenuItem(context);
+    menuItems.add(menuItem);
+    menuItem.setTitle(title);
+    menuItem.setSubMenu(tsm);
+    return tsm;
+  }
+
+  @Override
+  public SubMenu addSubMenu(int titleRes) {
+    RoboSubMenu tsm = new RoboSubMenu(context);
+    RoboMenuItem menuItem = new RoboMenuItem(context);
+    menuItems.add(menuItem);
+    menuItem.setTitle(titleRes);
+    menuItem.setSubMenu(tsm);
+    return tsm;
+  }
+
+  @Override
+  public SubMenu addSubMenu(int groupId, int itemId, int order, CharSequence title) {
+    RoboSubMenu tsm = new RoboSubMenu(context);
+    RoboMenuItem menuItem = new RoboMenuItem(context);
+    menuItems.add(menuItem);
+    menuItem.setGroupId(groupId);
+    menuItem.setItemId(itemId);
+    menuItem.setTitle(title);
+    menuItem.setSubMenu(tsm);
+    return tsm;
+  }
+
+  @Override
+  public SubMenu addSubMenu(int groupId, int itemId, int order, int titleRes) {
+    RoboSubMenu tsm = new RoboSubMenu(context);
+    RoboMenuItem menuItem = new RoboMenuItem(context);
+    menuItems.add(menuItem);
+    menuItem.setGroupId(groupId);
+    menuItem.setItemId(itemId);
+    menuItem.setTitle(titleRes);
+    menuItem.setSubMenu(tsm);
+    return tsm;
+  }
+
+  @Override
+  public int addIntentOptions(int groupId, int itemId, int order, ComponentName caller, Intent[] specifics,
+                              Intent intent, int flags, MenuItem[] outSpecificItems) {
+    return 0;
+  }
+
+  @Override
+  public void removeItem(int id) {
+    MenuItem menuItem = findItem(id);
+    menuItems.remove(menuItem);
+  }
+
+  @Override
+  public void removeGroup(int groupId) {
+  }
+
+  @Override
+  public void clear() {
+    menuItems.clear();
+  }
+
+  @Override
+  public void setGroupCheckable(int group, boolean checkable, boolean exclusive) {
+  }
+
+  @Override
+  public void setGroupVisible(int group, boolean visible) {
+  }
+
+  @Override
+  public void setGroupEnabled(int group, boolean enabled) {
+  }
+
+  @Override
+  public boolean hasVisibleItems() {
+    return false;
+  }
+
+  @Override
+  public MenuItem findItem(int id) {
+    for (MenuItem item : menuItems) {
+      if (item.getItemId() == id) {
+        return item;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public int size() {
+    return menuItems.size();
+  }
+
+  @Override
+  public MenuItem getItem(int index) {
+    return menuItems.get(index);
+  }
+
+  @Override
+  public void close() {
+  }
+
+  @Override
+  public boolean performShortcut(int keyCode, KeyEvent event, int flags) {
+    return false;
+  }
+
+  @Override
+  public boolean isShortcutKey(int keyCode, KeyEvent event) {
+    return false;
+  }
+
+  @Override
+  public boolean performIdentifierAction(int id, int flags) {
+    return false;
+  }
+
+  @Override
+  public void setQwertyMode(boolean isQwerty) {
+  }
+
+  public RoboMenuItem findMenuItem(CharSequence title) {
+    for (int i = 0; i < size(); i++) {
+      RoboMenuItem menuItem = (RoboMenuItem) getItem(i);
+      if (menuItem.getTitle().equals(title)) {
+        return menuItem;
+      }
+    }
+    return null;
+  }
+
+  public RoboMenuItem findMenuItemContaining(CharSequence desiredText) {
+    for (int i = 0; i < size(); i++) {
+      RoboMenuItem menuItem = (RoboMenuItem) getItem(i);
+      if (menuItem.getTitle().toString().contains(desiredText)) {
+        return menuItem;
+      }
+    }
+    return null;
+  }
+
+  private static class CustomMenuItemComparator implements Comparator<MenuItem> {
+
+    @Override
+    public int compare(MenuItem a, MenuItem b) {
+      if (a.getOrder() == b.getOrder()) {
+        return 0;
+      } else if (a.getOrder() > b.getOrder()) {
+        return 1;
+      } else {
+        return -1;
+      }
+    }
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboMenuItem.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboMenuItem.java
new file mode 100644
index 0000000..f94d45c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboMenuItem.java
@@ -0,0 +1,305 @@
+package org.robolectric.fakes;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.view.ActionProvider;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+import org.robolectric.RuntimeEnvironment;
+
+/**
+ * Robolectric implementation of {@link android.view.MenuItem}.
+ */
+public class RoboMenuItem implements MenuItem {
+  private int itemId;
+  private int groupId;
+  private CharSequence title;
+  private boolean enabled = true;
+  private boolean checked = false;
+  private boolean checkable = false;
+  private boolean visible = true;
+  private boolean expanded = false;
+  private OnMenuItemClickListener menuItemClickListener;
+  public Drawable icon;
+  private Intent intent;
+  private SubMenu subMenu;
+  private View actionView;
+  private OnActionExpandListener actionExpandListener;
+  private int order;
+  private Context context;
+
+  public RoboMenuItem() {
+    this(RuntimeEnvironment.getApplication());
+  }
+
+  public RoboMenuItem(Context context) {
+    this.context = context;
+  }
+
+  public RoboMenuItem(int itemId) {
+    this.itemId = itemId;
+  }
+
+  public void setItemId(int itemId) {
+    this.itemId = itemId;
+  }
+
+  public void setGroupId(int groupId) {
+    this.groupId = groupId;
+  }
+
+  @Override
+  public int getItemId() {
+    return itemId;
+  }
+
+  @Override
+  public int getGroupId() {
+    return groupId;
+  }
+
+  @Override
+  public int getOrder() {
+    return order;
+  }
+
+  public void setOrder(int order) {
+    this.order = order;
+  }
+
+  @Override
+  public MenuItem setTitle(CharSequence title) {
+    this.title = title;
+    return this;
+  }
+
+  @Override
+  public MenuItem setTitle(int title) {
+    return this;
+  }
+
+  @Override
+  public CharSequence getTitle() {
+    return title;
+  }
+
+  @Override
+  public MenuItem setTitleCondensed(CharSequence title) {
+    return this;
+  }
+
+  @Override
+  public CharSequence getTitleCondensed() {
+    return null;
+  }
+
+  @Override
+  public MenuItem setIcon(Drawable icon) {
+    this.icon = icon;
+    return this;
+  }
+
+  @Override
+  public MenuItem setIcon(int iconRes) {
+    this.icon = iconRes == 0 ? null : context.getResources().getDrawable(iconRes);
+    return this;
+  }
+
+  @Override
+  public Drawable getIcon() {
+    return this.icon;
+  }
+
+  @Override
+  public MenuItem setIntent(Intent intent) {
+    this.intent = intent;
+    return this;
+  }
+
+  @Override
+  public Intent getIntent() {
+    return this.intent;
+  }
+
+  @Override
+  public MenuItem setShortcut(char numericChar, char alphaChar) {
+    return this;
+  }
+
+  @Override
+  public MenuItem setNumericShortcut(char numericChar) {
+    return this;
+  }
+
+  @Override
+  public char getNumericShortcut() {
+    return 0;
+  }
+
+  @Override
+  public MenuItem setAlphabeticShortcut(char alphaChar) {
+    return this;
+  }
+
+  @Override
+  public char getAlphabeticShortcut() {
+    return 0;
+  }
+
+  @Override
+  public MenuItem setCheckable(boolean checkable) {
+    this.checkable = checkable;
+    return this;
+  }
+
+  @Override
+  public boolean isCheckable() {
+    return checkable;
+  }
+
+  @Override
+  public MenuItem setChecked(boolean checked) {
+    this.checked = checked;
+    return this;
+  }
+
+  @Override
+  public boolean isChecked() {
+    return checked;
+  }
+
+  @Override
+  public MenuItem setVisible(boolean visible) {
+    this.visible = visible;
+    return this;
+  }
+
+  @Override
+  public boolean isVisible() {
+    return visible;
+  }
+
+  @Override
+  public MenuItem setEnabled(boolean enabled) {
+    this.enabled = enabled;
+    return this;
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  @Override
+  public boolean hasSubMenu() {
+    return subMenu != null;
+  }
+
+  @Override
+  public SubMenu getSubMenu() {
+    return subMenu;
+  }
+
+  public void setSubMenu(SubMenu subMenu) {
+    this.subMenu = subMenu;
+  }
+
+  @Override
+  public MenuItem setOnMenuItemClickListener(OnMenuItemClickListener menuItemClickListener) {
+    this.menuItemClickListener = menuItemClickListener;
+    return this;
+  }
+
+  @Override
+  public ContextMenu.ContextMenuInfo getMenuInfo() {
+    return null;
+  }
+
+  public void click() {
+    if (enabled && menuItemClickListener != null) {
+      menuItemClickListener.onMenuItemClick(this);
+    } else if (enabled && intent != null) {
+      context.startActivity(intent);
+    }
+  }
+
+  @Override
+  public void setShowAsAction(int actionEnum) {
+  }
+
+  @Override
+  public MenuItem setShowAsActionFlags(int actionEnum) {
+    return this;
+  }
+
+  @Override
+  public MenuItem setActionView(View view) {
+    actionView = view;
+    return this;
+  }
+
+  @Override
+  public MenuItem setActionView(int resId) {
+    actionView = LayoutInflater.from(context).inflate(resId, null);
+    return this;
+  }
+
+  @Override
+  public View getActionView() {
+    return actionView;
+  }
+
+  @Override
+  public MenuItem setActionProvider(ActionProvider actionProvider) {
+    return this;
+  }
+
+  @Override
+  public ActionProvider getActionProvider() {
+    return null;
+  }
+
+  @Override
+  public boolean expandActionView() {
+    if (actionView != null) {
+      if (actionExpandListener != null) {
+        actionExpandListener.onMenuItemActionExpand(this);
+      }
+
+      expanded = true;
+      return true;
+    }
+
+    return false;
+  }
+
+  @Override
+  public boolean collapseActionView() {
+    if (actionView != null) {
+      if (actionExpandListener != null) {
+        actionExpandListener.onMenuItemActionCollapse(this);
+      }
+
+      expanded = false;
+      return true;
+    }
+
+    return false;
+  }
+
+  @Override
+  public boolean isActionViewExpanded() {
+    return expanded;
+  }
+
+  @Override
+  public MenuItem setOnActionExpandListener(OnActionExpandListener listener) {
+    actionExpandListener = listener;
+    return this;
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboSplashScreen.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboSplashScreen.java
new file mode 100644
index 0000000..915834d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboSplashScreen.java
@@ -0,0 +1,29 @@
+package org.robolectric.fakes;
+
+import android.annotation.StyleRes;
+import android.os.Build;
+import android.window.SplashScreen;
+import androidx.annotation.RequiresApi;
+
+/** Robolectric implementation of {@link android.window.SplashScreen}. */
+@RequiresApi(api = Build.VERSION_CODES.S)
+public class RoboSplashScreen implements SplashScreen {
+
+  @StyleRes private int themeId;
+
+  @Override
+  public void setOnExitAnimationListener(SplashScreen.OnExitAnimationListener listener) {}
+
+  @Override
+  public void clearOnExitAnimationListener() {}
+
+  @Override
+  public void setSplashScreenTheme(@StyleRes int themeId) {
+    this.themeId = themeId;
+  }
+
+  @StyleRes
+  public int getSplashScreenTheme() {
+    return themeId;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboSubMenu.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboSubMenu.java
new file mode 100644
index 0000000..462d2b0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboSubMenu.java
@@ -0,0 +1,67 @@
+package org.robolectric.fakes;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+import org.robolectric.RuntimeEnvironment;
+
+/**
+ * Robolectric implementation of {@link android.view.SubMenu}.
+ */
+public class RoboSubMenu extends RoboMenu implements SubMenu {
+
+  public RoboSubMenu() {
+    this(RuntimeEnvironment.getApplication());
+  }
+
+  public RoboSubMenu(Context context) {
+    super(context);
+  }
+
+  @Override
+  public SubMenu setHeaderTitle(int titleRes) {
+    return this;
+  }
+
+  @Override
+  public SubMenu setHeaderTitle(CharSequence title) {
+    return this;
+  }
+
+  @Override
+  public SubMenu setHeaderIcon(int iconRes) {
+    return this;
+  }
+
+  @Override
+  public SubMenu setHeaderIcon(Drawable icon) {
+    return this;
+  }
+
+  @Override
+  public SubMenu setHeaderView(View view) {
+    return this;
+  }
+
+  @Override
+  public void clearHeader() {
+  }
+
+  @Override
+  public SubMenu setIcon(int iconRes) {
+    return this;
+  }
+
+  @Override
+  public SubMenu setIcon(Drawable icon) {
+    return this;
+  }
+
+  @Override
+  public MenuItem getItem() {
+    return null;
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboWebMessagePort.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboWebMessagePort.java
new file mode 100644
index 0000000..2b477f3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboWebMessagePort.java
@@ -0,0 +1,94 @@
+package org.robolectric.fakes;
+
+import android.os.Handler;
+import android.webkit.WebMessage;
+import android.webkit.WebMessagePort;
+import android.webkit.WebMessagePort.WebMessageCallback;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/** Robolectric implementation of {@link WebMessagePort}. */
+public class RoboWebMessagePort extends WebMessagePort {
+  private final List<String> receivedMessages = Collections.synchronizedList(new ArrayList<>());
+  // The connected port receives all messages this port sends. This port receives messages sent by
+  // the connected port.
+  private RoboWebMessagePort connectedPort;
+  private WebMessageCallback callback;
+  private boolean closed = false;
+
+  public static RoboWebMessagePort[] createPair() {
+    RoboWebMessagePort portA = new RoboWebMessagePort();
+    RoboWebMessagePort portB = new RoboWebMessagePort();
+
+    portA.setConnectedPort(portB);
+    portB.setConnectedPort(portA);
+
+    return new RoboWebMessagePort[] {portA, portB};
+  }
+
+  @Override
+  public void postMessage(WebMessage message) {
+    if (closed || connectedPort == null) {
+      return;
+    }
+
+    String data = message.getData();
+    if (data == null) {
+      return;
+    }
+
+    connectedPort.receivedMessages.add(data);
+    if (connectedPort.callback != null) {
+      connectedPort.callback.onMessage(connectedPort, message);
+    }
+  }
+
+  @Override
+  public void setWebMessageCallback(WebMessagePort.WebMessageCallback callback) {
+    setWebMessageCallback(callback, null);
+  }
+
+  @Override
+  public void setWebMessageCallback(
+      WebMessagePort.WebMessageCallback callback, @Nullable Handler handler) {
+    this.callback = callback;
+  }
+
+  /**
+   * Links another port to this port. After set, messages which sent from this port will arrive at
+   * the connected one.
+   */
+  public void setConnectedPort(@Nullable RoboWebMessagePort port) {
+    this.connectedPort = port;
+  }
+
+  public RoboWebMessagePort getConnectedPort() {
+    return this.connectedPort;
+  }
+
+  public WebMessageCallback getWebMessageCallback() {
+    return this.callback;
+  }
+
+  /** Returns the list of all messages sent to its connected ports. */
+  public ImmutableList<String> getOutgoingMessages() {
+    return ImmutableList.copyOf(getConnectedPort().receivedMessages);
+  }
+
+  /** Returns the list of all messages received from its connected ports. */
+  public ImmutableList<String> getReceivedMessages() {
+    return ImmutableList.copyOf(receivedMessages);
+  }
+
+  @Override
+  public void close() {
+    this.closed = true;
+  }
+
+  public boolean isClosed() {
+    return this.closed;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/RoboWebSettings.java b/shadows/framework/src/main/java/org/robolectric/fakes/RoboWebSettings.java
new file mode 100644
index 0000000..79340c5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/RoboWebSettings.java
@@ -0,0 +1,662 @@
+package org.robolectric.fakes;
+
+import android.webkit.WebSettings;
+
+/**
+ * Robolectric implementation of {@link android.webkit.WebSettings}.
+ */
+public class RoboWebSettings extends WebSettings {
+  private boolean blockNetworkImage = false;
+  private boolean javaScriptEnabled = false;
+  private boolean javaScriptCanOpenWindowAutomatically = false;
+  private boolean lightTouchEnabled = false;
+  private boolean needInitialFocus = false;
+  private RenderPriority renderPriority = RenderPriority.NORMAL;
+  private boolean pluginsEnabled = false;
+  private boolean saveFormData = false;
+  private boolean supportMultipleWindows = false;
+  private boolean supportZoom = true;
+  private boolean useWideViewPort = false;
+  private int cacheMode;
+  private WebSettings.LayoutAlgorithm layoutAlgorithm = WebSettings.LayoutAlgorithm.NARROW_COLUMNS;
+  private String defaultTextEncoding = "UTF-8";
+  private int defaultFontSize = 16;
+  private boolean loadsImagesAutomatically;
+  private int defaultFixedFontSize;
+  private int minimumLogicalFontSize;
+  private int minimumFontSize;
+  private String fantasyFontFamily;
+  private String cursiveFontFamily;
+  private String serifFontFamily;
+  private String sansSerifFontFamily;
+  private String fixedFontFamily;
+  private String standardFontFamily;
+  private boolean savePassword;
+  private int userAgent;
+  private boolean navDump;
+  private int forceDark;
+
+  @Override
+  public synchronized boolean getBlockNetworkImage() {
+    return blockNetworkImage;
+  }
+
+  @Override
+  public synchronized void setBlockNetworkImage(boolean flag) {
+    blockNetworkImage = flag;
+  }
+
+  @Override
+  public synchronized boolean getJavaScriptEnabled() {
+    return javaScriptEnabled;
+  }
+
+  @Override
+  public synchronized void setJavaScriptEnabled(boolean flag) {
+    javaScriptEnabled = flag;
+  }
+
+  @Override
+  public boolean getLightTouchEnabled() {
+    return lightTouchEnabled;
+  }
+
+  @Override
+  public void setLightTouchEnabled(boolean flag) {
+    lightTouchEnabled = flag;
+  }
+
+  public boolean getNeedInitialFocus() {
+    return needInitialFocus;
+  }
+
+  @Override
+  public void setNeedInitialFocus(boolean flag) {
+    needInitialFocus = flag;
+  }
+
+  @Override
+  public synchronized void setRenderPriority(RenderPriority priority) {
+    renderPriority = priority;
+  }
+
+  public RenderPriority getRenderPriority() {
+    return renderPriority;
+  }
+
+  @Override
+  public synchronized boolean getPluginsEnabled() {
+    return pluginsEnabled;
+  }
+
+  @Override
+  public synchronized void setPluginsEnabled(boolean flag) {
+    pluginsEnabled = flag;
+  }
+
+  public boolean getSupportMultipleWindows() {
+    return supportMultipleWindows;
+  }
+
+  @Override
+  public synchronized void setSupportMultipleWindows(boolean support) {
+    supportMultipleWindows = support;
+  }
+
+  public boolean getSupportZoom() {
+    return supportZoom;
+  }
+
+  @Override
+  public void setSupportZoom(boolean support) {
+    supportZoom = support;
+  }
+
+  @Override
+  public void setCacheMode(int mode) {
+    this.cacheMode = mode;
+  }
+
+  @Override
+  public int getCacheMode() {
+    return cacheMode;
+  }
+
+  @Override
+  public boolean getUseWideViewPort() {
+    return useWideViewPort;
+  }
+
+  @Override
+  public void setUseWideViewPort(boolean useWideViewPort) {
+    this.useWideViewPort = useWideViewPort;
+  }
+
+  @Override
+  public boolean getSaveFormData() {
+    return saveFormData;
+  }
+
+  @Override
+  public void setSaveFormData(boolean saveFormData) {
+    this.saveFormData = saveFormData;
+  }
+
+  @Override
+  public void setJavaScriptCanOpenWindowsAutomatically(boolean javaScriptCanOpenWindowAutomatically) {
+    this.javaScriptCanOpenWindowAutomatically = javaScriptCanOpenWindowAutomatically;
+  }
+
+  @Override
+  public boolean getJavaScriptCanOpenWindowsAutomatically() {
+    return this.javaScriptCanOpenWindowAutomatically;
+  }
+
+  @Override
+  public synchronized void setLayoutAlgorithm(WebSettings.LayoutAlgorithm algorithm) {
+    this.layoutAlgorithm = algorithm;
+  }
+
+  @Override
+  public String getDefaultTextEncodingName() {
+    return this.defaultTextEncoding;
+  }
+
+  @Override
+  public void setDefaultTextEncodingName(String defaultTextEncoding) {
+    this.defaultTextEncoding = defaultTextEncoding;
+  }
+
+  @Override
+  public int getDefaultFontSize() {
+    return defaultFontSize;
+  }
+
+  @Override
+  public void setDefaultFontSize(int defaultFontSize) {
+    this.defaultFontSize = defaultFontSize;
+  }
+
+  @Override
+  public boolean getLoadsImagesAutomatically() {
+    return loadsImagesAutomatically;
+  }
+
+  @Override public void setLoadsImagesAutomatically(boolean loadsImagesAutomatically) {
+    this.loadsImagesAutomatically = loadsImagesAutomatically;
+  }
+
+  @Override
+  public int getDefaultFixedFontSize() {
+    return defaultFixedFontSize;
+  }
+
+  @Override public void setDefaultFixedFontSize(int defaultFixedFontSize) {
+    this.defaultFixedFontSize = defaultFixedFontSize;
+  }
+
+  @Override
+  public int getMinimumLogicalFontSize() {
+    return minimumLogicalFontSize;
+  }
+
+  @Override public void setMinimumLogicalFontSize(int minimumLogicalFontSize) {
+    this.minimumLogicalFontSize = minimumLogicalFontSize;
+  }
+
+  @Override
+  public int getMinimumFontSize() {
+    return minimumFontSize;
+  }
+
+  @Override public void setMinimumFontSize(int minimumFontSize) {
+    this.minimumFontSize = minimumFontSize;
+  }
+
+  @Override
+  public String getFantasyFontFamily() {
+    return fantasyFontFamily;
+  }
+
+  @Override public void setFantasyFontFamily(String fantasyFontFamily) {
+    this.fantasyFontFamily = fantasyFontFamily;
+  }
+
+  @Override
+  public String getCursiveFontFamily() {
+    return cursiveFontFamily;
+  }
+
+  @Override public void setCursiveFontFamily(String cursiveFontFamily) {
+    this.cursiveFontFamily = cursiveFontFamily;
+  }
+
+  @Override
+  public String getSerifFontFamily() {
+    return serifFontFamily;
+  }
+
+  @Override public void setSerifFontFamily(String serifFontFamily) {
+    this.serifFontFamily = serifFontFamily;
+  }
+
+  @Override
+  public String getSansSerifFontFamily() {
+    return sansSerifFontFamily;
+  }
+
+  @Override public void setSansSerifFontFamily(String sansSerifFontFamily) {
+    this.sansSerifFontFamily = sansSerifFontFamily;
+  }
+
+  @Override
+  public String getFixedFontFamily() {
+    return fixedFontFamily;
+  }
+
+  @Override public void setFixedFontFamily(String fixedFontFamily) {
+    this.fixedFontFamily = fixedFontFamily;
+  }
+
+  @Override
+  public String getStandardFontFamily() {
+    return standardFontFamily;
+  }
+
+  @Override public void setStandardFontFamily(String standardFontFamily) {
+    this.standardFontFamily = standardFontFamily;
+  }
+
+  @Override
+  public LayoutAlgorithm getLayoutAlgorithm() {
+    return layoutAlgorithm;
+  }
+
+  @Override
+  public boolean supportMultipleWindows() {
+    return supportMultipleWindows;
+  }
+
+  @Override
+  public boolean getSavePassword() {
+    return savePassword;
+  }
+
+  @Override
+  public void setSavePassword(boolean savePassword) {
+    this.savePassword = savePassword;
+  }
+
+  @Override
+  public boolean supportZoom() {
+    return supportZoom;
+  }
+
+  @Override
+  public int getUserAgent() {
+    return userAgent;
+  }
+
+  @Override
+  public void setUserAgent(int userAgent) {
+    this.userAgent =  userAgent;
+  }
+
+  @Override
+  public boolean getNavDump() {
+    return navDump;
+  }
+
+  @Override
+  public void setNavDump(boolean navDump) {
+    this.navDump = navDump;
+  }
+
+  private boolean allowFileAccess = true;
+  private boolean builtInZoomControls = true;
+  private String userAgentString =
+      "Mozilla/5.0 (Linux; U; Android 4.0.3; ko-kr; LG-L160L Build/IML74K) AppleWebkit/534.30"
+          + " (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30";
+
+  @Override
+  public boolean getAllowFileAccess() {
+    return allowFileAccess;
+  }
+
+  @Override
+  public void setAllowFileAccess(boolean allow) {
+    allowFileAccess = allow;
+  }
+
+  @Override
+  public boolean getBuiltInZoomControls() {
+    return builtInZoomControls;
+  }
+
+  @Override
+  public void setBuiltInZoomControls(boolean enabled) {
+    builtInZoomControls = enabled;
+  }
+
+  @Override
+  public synchronized void setUserAgentString(String ua) {
+    userAgentString = ua;
+  }
+
+  @Override
+  public synchronized String getUserAgentString() {
+    return userAgentString;
+  }
+  // End API 3
+
+  private boolean databaseEnabled = false;
+  private String databasePath = "database";
+  private String geolocationDatabasePath = "geolocation";
+  private boolean geolocationEnabled = false;
+
+  @Override
+  public synchronized boolean getDatabaseEnabled() {
+    return databaseEnabled;
+  }
+
+  @Override
+  public synchronized void setDatabaseEnabled(boolean flag) {
+    databaseEnabled = flag;
+  }
+
+  @Override
+  public synchronized void setDatabasePath(String path) {
+    databasePath = path;
+  }
+
+  @Override
+  public synchronized String getDatabasePath() {
+    return databasePath;
+  }
+
+  public String getGeolocationDatabasePath() {
+    return geolocationDatabasePath;
+  }
+
+  @Override
+  public void setGeolocationDatabasePath(String geolocationDatabasePath) {
+    this.geolocationDatabasePath = geolocationDatabasePath;
+  }
+
+  public boolean getGeolocationEnabled() {
+    return geolocationEnabled;
+  }
+
+  @Override
+  public void setGeolocationEnabled(boolean geolocationEnabled) {
+    this.geolocationEnabled = geolocationEnabled;
+  }
+  // End API 5
+
+  private ZoomDensity defaultZoom;
+  private boolean domStorageEnabled = false;
+  private boolean loadWithOverviewMode = false;
+  private boolean appCacheEnabled = false;
+  private long appCacheMaxSize;
+  private String appCachePath = "appcache";
+
+  @Override
+  public void setDefaultZoom(ZoomDensity zoom) {
+    this.defaultZoom = zoom;
+  }
+
+  @Override
+  public ZoomDensity getDefaultZoom() {
+    return defaultZoom;
+  }
+
+  @Override
+  public synchronized boolean getDomStorageEnabled() {
+    return domStorageEnabled;
+  }
+
+  @Override
+  public synchronized void setDomStorageEnabled(boolean flag) {
+    domStorageEnabled = flag;
+  }
+
+  @Override
+  public boolean getLoadWithOverviewMode() {
+    return loadWithOverviewMode;
+  }
+
+  @Override
+  public void setLoadWithOverviewMode(boolean flag) {
+    loadWithOverviewMode = flag;
+  }
+
+  public boolean getAppCacheEnabled() {
+    return appCacheEnabled;
+  }
+
+  @Override
+  public void setAppCacheEnabled(boolean appCacheEnabled) {
+    this.appCacheEnabled = appCacheEnabled;
+  }
+
+  @Override
+  public void setAppCacheMaxSize(long appCacheMaxSize) {
+    this.appCacheMaxSize = appCacheMaxSize;
+  }
+
+  public long getAppCacheMaxSize() {
+    return appCacheMaxSize;
+  }
+
+  public String getAppCachePath() {
+    return appCachePath;
+  }
+
+  @Override
+  public void setAppCachePath(String appCachePath) {
+    this.appCachePath = appCachePath;
+  }
+  // End API 7
+
+  private boolean blockNetworkLoads = false;
+  private WebSettings.PluginState pluginState = WebSettings.PluginState.OFF;
+
+  @Override
+  public synchronized boolean getBlockNetworkLoads() {
+    return blockNetworkLoads;
+  }
+
+  @Override
+  public synchronized void setBlockNetworkLoads(boolean flag) {
+    blockNetworkLoads = flag;
+  }
+
+  @Override
+  public synchronized WebSettings.PluginState getPluginState() {
+    return pluginState;
+  }
+
+  @Override
+  public synchronized void setPluginState(WebSettings.PluginState state) {
+    pluginState = state;
+  }
+  // End API 8
+
+  private boolean useWebViewBackgroundForOverscrollBackground;
+
+  @Override
+  public boolean getUseWebViewBackgroundForOverscrollBackground() {
+    return useWebViewBackgroundForOverscrollBackground;
+  }
+
+  @Override
+  public void setUseWebViewBackgroundForOverscrollBackground(boolean useWebViewBackgroundForOverscrollBackground) {
+    this.useWebViewBackgroundForOverscrollBackground = useWebViewBackgroundForOverscrollBackground;
+  }
+  // End API 9
+
+  private boolean enableSmoothTransition;
+  private boolean allowContentAccess = true;
+  private boolean displayZoomControls;
+
+  @Override
+  public boolean enableSmoothTransition() {
+    return enableSmoothTransition;
+  }
+
+  @Override
+  public void setEnableSmoothTransition(boolean enableSmoothTransition) {
+    this.enableSmoothTransition = enableSmoothTransition;
+  }
+
+  @Override
+  public void setAllowContentAccess(boolean allow) {
+    allowContentAccess = allow;
+  }
+
+  @Override
+  public boolean getAllowContentAccess() {
+    return allowContentAccess;
+  }
+
+  @Override
+  public void setDisplayZoomControls(boolean enabled) {
+    displayZoomControls = enabled;
+  }
+
+  @Override
+  public boolean getDisplayZoomControls() {
+    return displayZoomControls;
+  }
+  // End API 11
+
+  private int textZoom = 100;
+
+  @Override
+  public int getTextZoom() {
+    return textZoom;
+  }
+
+  @Override
+  public void setTextZoom(int textZoom) {
+    this.textZoom = textZoom;
+  }
+  // End API 14
+
+  private boolean allowFileAccessFromFile = true;
+  private boolean allowUniversalAccessFromFile = true;
+
+  @Override
+  public boolean getAllowFileAccessFromFileURLs() {
+    return allowFileAccessFromFile;
+  }
+
+  @Override
+  public void setAllowFileAccessFromFileURLs(boolean allow) {
+    allowFileAccessFromFile = allow;
+  }
+
+  @Override
+  public boolean getAllowUniversalAccessFromFileURLs() {
+    return allowUniversalAccessFromFile;
+  }
+
+  @Override
+  public void setAllowUniversalAccessFromFileURLs(boolean allow) {
+    allowUniversalAccessFromFile = allow;
+  }
+  //End API 16
+
+  private boolean mediaPlaybackRequiresUserGesture = true;
+
+  @Override
+  public boolean getMediaPlaybackRequiresUserGesture() {
+    return mediaPlaybackRequiresUserGesture;
+  }
+
+  @Override
+  public void setMediaPlaybackRequiresUserGesture(boolean require) {
+    mediaPlaybackRequiresUserGesture = require;
+  }
+  //End API 17
+
+  private int mixedContentMode;
+  private boolean acceptThirdPartyCookies;
+  private boolean videoOverlayForEmbeddedEncryptedVideoEnabled;
+
+  @Override
+  public void setMixedContentMode(int mixedContentMode) {
+    this.mixedContentMode = mixedContentMode;
+  }
+
+  @Override
+  public int getMixedContentMode() {
+    return mixedContentMode;
+  }
+
+  @Override
+  public void setVideoOverlayForEmbeddedEncryptedVideoEnabled(boolean b) {
+    videoOverlayForEmbeddedEncryptedVideoEnabled = b;
+  }
+
+  @Override
+  public boolean getVideoOverlayForEmbeddedEncryptedVideoEnabled() {
+    return videoOverlayForEmbeddedEncryptedVideoEnabled;
+  }
+
+  @Override
+  public boolean getAcceptThirdPartyCookies() {
+    return acceptThirdPartyCookies;
+  }
+
+  @Override
+  public void setAcceptThirdPartyCookies(boolean acceptThirdPartyCookies) {
+    this.acceptThirdPartyCookies = acceptThirdPartyCookies;
+  }
+  // End API 21
+
+  @Override
+  public void setOffscreenPreRaster(boolean enabled) {
+
+  }
+
+  @Override public boolean getOffscreenPreRaster() {
+    return false;
+  }
+
+  // End API 23
+
+  @Override
+  public int getDisabledActionModeMenuItems() {
+    return 0;
+  }
+
+  @Override
+  public void setDisabledActionModeMenuItems(int menuItems) {
+
+  }
+
+  // End API 24.
+
+  @Override public boolean getSafeBrowsingEnabled() {
+    return false;
+  }
+
+  @Override public void setSafeBrowsingEnabled(boolean enabled) {
+
+  }
+
+  // End API 26
+
+  @Override
+  public int getForceDark() {
+    return forceDark;
+  }
+
+  @Override
+  public void setForceDark(int forceDark) {
+    this.forceDark = forceDark;
+  }
+
+  // End API 29
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/fakes/package-info.java b/shadows/framework/src/main/java/org/robolectric/fakes/package-info.java
new file mode 100644
index 0000000..38e02ca
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/fakes/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Package containing fake implementations of Android classes.
+ */
+package org.robolectric.fakes;
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/AppWidgetProviderInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/AppWidgetProviderInfoBuilder.java
new file mode 100644
index 0000000..1353007
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/AppWidgetProviderInfoBuilder.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.pm.ActivityInfo;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Class to build {@link AppWidgetProviderInfo} */
+public class AppWidgetProviderInfoBuilder {
+  private ActivityInfo providerInfo;
+
+  private AppWidgetProviderInfoBuilder() {}
+
+  public AppWidgetProviderInfoBuilder setProviderInfo(ActivityInfo providerInfo) {
+    this.providerInfo = providerInfo;
+    return this;
+  }
+
+  public AppWidgetProviderInfo build() {
+    AppWidgetProviderInfo appWidgetProviderInfo = new AppWidgetProviderInfo();
+    if (this.providerInfo != null) {
+      reflector(AppWidgetProviderInfoReflector.class, appWidgetProviderInfo)
+          .setProviderInfo(this.providerInfo);
+    }
+    return appWidgetProviderInfo;
+  }
+
+  /**
+   * Create a new {@link AppWidgetProviderInfoBuilder}.
+   *
+   * @return The created {@link AppWidgetProviderInfoBuilder}.
+   */
+  public static AppWidgetProviderInfoBuilder newBuilder() {
+    return new AppWidgetProviderInfoBuilder();
+  }
+
+  /** Accessor interface for {@link AppWidgetProviderInfo}'s internals. */
+  @ForType(AppWidgetProviderInfo.class)
+  interface AppWidgetProviderInfoReflector {
+    @Accessor("providerInfo")
+    void setProviderInfo(ActivityInfo providerInfo);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/AttestedKeyPairFactory.java b/shadows/framework/src/main/java/org/robolectric/shadows/AttestedKeyPairFactory.java
new file mode 100644
index 0000000..eb0b2d5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/AttestedKeyPairFactory.java
@@ -0,0 +1,14 @@
+package org.robolectric.shadows;
+
+import android.security.AttestedKeyPair;
+import java.security.KeyPair;
+import java.security.cert.Certificate;
+
+/** Factory to create AttestedKeyPair. */
+public class AttestedKeyPairFactory {
+
+  /** Create AttestedKeyPair. */
+  public static AttestedKeyPair create(KeyPair keyPair, Certificate[] attestationRecord) {
+    return new AttestedKeyPair(keyPair, attestationRecord);
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/BarringInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/BarringInfoBuilder.java
new file mode 100644
index 0000000..74ee140
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/BarringInfoBuilder.java
@@ -0,0 +1,134 @@
+package org.robolectric.shadows;
+
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_CS_FALLBACK;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_CS_SERVICE;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_CS_VOICE;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_EMERGENCY;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_MMTEL_VIDEO;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_MMTEL_VOICE;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_MO_DATA;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_MO_SIGNALLING;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_PS_SERVICE;
+import static android.telephony.BarringInfo.BARRING_SERVICE_TYPE_SMS;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_CONDITIONAL;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_NONE;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_UNCONDITIONAL;
+import static android.telephony.BarringInfo.BarringServiceInfo.BARRING_TYPE_UNKNOWN;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.BarringInfo;
+import android.telephony.BarringInfo.BarringServiceInfo;
+import android.telephony.CellIdentity;
+import android.util.SparseArray;
+import androidx.annotation.RequiresApi;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link BarringInfo}. */
+@RequiresApi(VERSION_CODES.R)
+public class BarringInfoBuilder {
+
+  private CellIdentity barringCellIdentity;
+  private final SparseArray<BarringServiceInfo> barringServiceInfos = new SparseArray<>();
+
+  private BarringInfoBuilder() {}
+
+  public static BarringInfoBuilder newBuilder() {
+    return new BarringInfoBuilder();
+  }
+
+  @CanIgnoreReturnValue
+  public BarringInfoBuilder setCellIdentity(CellIdentity cellIdentity) {
+    this.barringCellIdentity = cellIdentity;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public BarringInfoBuilder addBarringServiceInfo(
+      int barringServiceType, BarringServiceInfo barringServiceInfo) {
+    validateBarringServiceType(barringServiceType);
+    barringServiceInfos.put(barringServiceType, barringServiceInfo);
+    return this;
+  }
+
+  public BarringInfo build() {
+    return ReflectionHelpers.callConstructor(
+        BarringInfo.class,
+        ClassParameter.from(CellIdentity.class, barringCellIdentity),
+        ClassParameter.from(SparseArray.class, barringServiceInfos));
+  }
+
+  /** Builder for {@link BarringServiceInfo}. */
+  public static class BarringServiceInfoBuilder {
+    private int barringType = BARRING_TYPE_NONE;
+    private boolean isConditionallyBarred;
+    private int conditionalBarringFactor;
+    private int conditionalBarringTimeSeconds;
+
+    private BarringServiceInfoBuilder() {}
+
+    public static BarringServiceInfoBuilder newBuilder() {
+      return new BarringServiceInfoBuilder();
+    }
+
+    @CanIgnoreReturnValue
+    public BarringServiceInfoBuilder setBarringType(int barringType) {
+      validateBarringType(barringType);
+      this.barringType = barringType;
+      return this;
+    }
+
+    @CanIgnoreReturnValue
+    public BarringServiceInfoBuilder setIsConditionallyBarred(boolean isConditionallyBarred) {
+      this.isConditionallyBarred = isConditionallyBarred;
+      return this;
+    }
+
+    @CanIgnoreReturnValue
+    public BarringServiceInfoBuilder setConditionalBarringFactor(int conditionalBarringFactor) {
+      this.conditionalBarringFactor = conditionalBarringFactor;
+      return this;
+    }
+
+    @CanIgnoreReturnValue
+    public BarringServiceInfoBuilder setConditionalBarringTimeSeconds(
+        int conditionalBarringTimeSeconds) {
+      this.conditionalBarringTimeSeconds = conditionalBarringTimeSeconds;
+      return this;
+    }
+
+    public BarringServiceInfo build() {
+      return ReflectionHelpers.callConstructor(
+          BarringServiceInfo.class,
+          ClassParameter.from(int.class, barringType),
+          ClassParameter.from(boolean.class, isConditionallyBarred),
+          ClassParameter.from(int.class, conditionalBarringFactor),
+          ClassParameter.from(int.class, conditionalBarringTimeSeconds));
+    }
+
+    private void validateBarringType(int barringType) {
+      if (barringType != BARRING_TYPE_NONE
+          && barringType != BARRING_TYPE_UNCONDITIONAL
+          && barringType != BARRING_TYPE_CONDITIONAL
+          && barringType != BARRING_TYPE_UNKNOWN) {
+        throw new IllegalArgumentException("Unknown barringType: " + barringType);
+      }
+    }
+  }
+
+  private void validateBarringServiceType(int barringServiceType) {
+    if (barringServiceType != BARRING_SERVICE_TYPE_CS_SERVICE
+        && barringServiceType != BARRING_SERVICE_TYPE_PS_SERVICE
+        && barringServiceType != BARRING_SERVICE_TYPE_CS_VOICE
+        && barringServiceType != BARRING_SERVICE_TYPE_MO_SIGNALLING
+        && barringServiceType != BARRING_SERVICE_TYPE_MO_DATA
+        && barringServiceType != BARRING_SERVICE_TYPE_CS_FALLBACK
+        && barringServiceType != BARRING_SERVICE_TYPE_MMTEL_VOICE
+        && barringServiceType != BARRING_SERVICE_TYPE_MMTEL_VIDEO
+        && barringServiceType != BARRING_SERVICE_TYPE_EMERGENCY
+        && barringServiceType != BARRING_SERVICE_TYPE_SMS) {
+      throw new IllegalArgumentException("Unknown barringServiceType: " + barringServiceType);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/BrightnessChangeEventBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/BrightnessChangeEventBuilder.java
new file mode 100644
index 0000000..bc99b0f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/BrightnessChangeEventBuilder.java
@@ -0,0 +1,90 @@
+package org.robolectric.shadows;
+
+import android.hardware.display.BrightnessChangeEvent;
+
+/** Builder for {@link BrightnessChangeEvent}. */
+public class BrightnessChangeEventBuilder {
+
+  private final BrightnessChangeEvent.Builder builderInternal = new BrightnessChangeEvent.Builder();
+
+  public BrightnessChangeEventBuilder setBrightness(float brightness) {
+    builderInternal.setBrightness(brightness);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setTimeStamp(long timeStamp) {
+    builderInternal.setTimeStamp(timeStamp);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setPackageName(String packageName) {
+    builderInternal.setPackageName(packageName);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setUserId(int userId) {
+    builderInternal.setUserId(userId);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setLuxValues(float[] luxValues) {
+    builderInternal.setLuxValues(luxValues);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setLuxTimestamps(long[] luxTimestamps) {
+    builderInternal.setLuxTimestamps(luxTimestamps);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setBatteryLevel(float batteryLevel) {
+    builderInternal.setBatteryLevel(batteryLevel);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setPowerBrightnessFactor(float powerBrightnessFactor) {
+    builderInternal.setPowerBrightnessFactor(powerBrightnessFactor);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setNightMode(boolean nightMode) {
+    builderInternal.setNightMode(nightMode);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setColorTemperature(int colorTemperature) {
+    builderInternal.setColorTemperature(colorTemperature);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setLastBrightness(float lastBrightness) {
+    builderInternal.setLastBrightness(lastBrightness);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setIsDefaultBrightnessConfig(
+      boolean isDefaultBrightnessConfig) {
+    builderInternal.setIsDefaultBrightnessConfig(isDefaultBrightnessConfig);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setUserBrightnessPoint(boolean isUserSetBrightness) {
+    builderInternal.setUserBrightnessPoint(isUserSetBrightness);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setColorValues(
+      long[] colorValueBuckets, long colorSampleDuration) {
+    builderInternal.setColorValues(colorValueBuckets, colorSampleDuration);
+    return this;
+  }
+
+  public BrightnessChangeEventBuilder setUniqueDisplayId(String displayId) {
+    builderInternal.setUniqueDisplayId(displayId);
+    return this;
+  }
+
+  public BrightnessChangeEvent build() {
+    return builderInternal.build();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/CachedPathIteratorFactory.java b/shadows/framework/src/main/java/org/robolectric/shadows/CachedPathIteratorFactory.java
new file mode 100644
index 0000000..7ef10bb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/CachedPathIteratorFactory.java
@@ -0,0 +1,470 @@
+package org.robolectric.shadows;
+
+import java.awt.geom.CubicCurve2D;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Point2D;
+import java.awt.geom.QuadCurve2D;
+import java.util.ArrayList;
+
+/**
+ * Class that returns iterators for a given path. These iterators are lightweight and can be reused
+ * multiple times to iterate over the path.
+ *
+ * <p>copied from
+ * https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/tools/layoutlib/bridge/src/com/android/layoutlib/bridge/util/CachedPathIteratorFactory.java
+ */
+public class CachedPathIteratorFactory {
+  /*
+   * A few conventions used in the code:
+   * Coordinates or coords arrays store segment coordinates. They use the same format as
+   * PathIterator#currentSegment coordinates array.
+   * float arrays store always points where the first element is X and the second is Y.
+   */
+
+  // This governs how accurate the approximation of the Path is.
+  private static final float PRECISION = 0.002f;
+
+  private final int mWindingRule;
+  private final int[] mTypes;
+  private final float[][] mCoordinates;
+  private final float[] mSegmentsLength;
+  private final float mTotalLength;
+
+  public CachedPathIteratorFactory(PathIterator iterator) {
+    mWindingRule = iterator.getWindingRule();
+
+    ArrayList<Integer> typesArray = new ArrayList<>();
+    ArrayList<float[]> pointsArray = new ArrayList<>();
+    float[] points = new float[6];
+    while (!iterator.isDone()) {
+      int type = iterator.currentSegment(points);
+      int nPoints = getNumberOfPoints(type) * 2; // 2 coordinates per point
+
+      typesArray.add(type);
+      float[] itemPoints = new float[nPoints];
+      System.arraycopy(points, 0, itemPoints, 0, nPoints);
+      pointsArray.add(itemPoints);
+      iterator.next();
+    }
+
+    mTypes = new int[typesArray.size()];
+    mCoordinates = new float[mTypes.length][];
+    for (int i = 0; i < typesArray.size(); i++) {
+      mTypes[i] = typesArray.get(i);
+      mCoordinates[i] = pointsArray.get(i);
+    }
+
+    // Do measurement
+    mSegmentsLength = new float[mTypes.length];
+
+    // Curves that we can reuse to estimate segments length
+    CubicCurve2D.Float cubicCurve = new CubicCurve2D.Float();
+    QuadCurve2D.Float quadCurve = new QuadCurve2D.Float();
+    float lastX = 0;
+    float lastY = 0;
+    float totalLength = 0;
+    for (int i = 0; i < mTypes.length; i++) {
+      switch (mTypes[i]) {
+        case PathIterator.SEG_CUBICTO:
+          cubicCurve.setCurve(
+              lastX,
+              lastY,
+              mCoordinates[i][0],
+              mCoordinates[i][1],
+              mCoordinates[i][2],
+              mCoordinates[i][3],
+              lastX = mCoordinates[i][4],
+              lastY = mCoordinates[i][5]);
+          mSegmentsLength[i] = getFlatPathLength(cubicCurve.getPathIterator(null, PRECISION));
+          break;
+        case PathIterator.SEG_QUADTO:
+          quadCurve.setCurve(
+              lastX,
+              lastY,
+              mCoordinates[i][0],
+              mCoordinates[i][1],
+              lastX = mCoordinates[i][2],
+              lastY = mCoordinates[i][3]);
+          mSegmentsLength[i] = getFlatPathLength(quadCurve.getPathIterator(null, PRECISION));
+          break;
+        case PathIterator.SEG_CLOSE:
+          mSegmentsLength[i] =
+              (float)
+                  Point2D.distance(
+                      lastX, lastY, lastX = mCoordinates[0][0], lastY = mCoordinates[0][1]);
+          mCoordinates[i] = new float[2];
+          // We convert a SEG_CLOSE segment to a SEG_LINETO so we do not have to worry
+          // about this special case in the rest of the code.
+          mTypes[i] = PathIterator.SEG_LINETO;
+          mCoordinates[i][0] = mCoordinates[0][0];
+          mCoordinates[i][1] = mCoordinates[0][1];
+          break;
+        case PathIterator.SEG_MOVETO:
+          mSegmentsLength[i] = 0;
+          lastX = mCoordinates[i][0];
+          lastY = mCoordinates[i][1];
+          break;
+        case PathIterator.SEG_LINETO:
+          mSegmentsLength[i] =
+              (float) Point2D.distance(lastX, lastY, mCoordinates[i][0], mCoordinates[i][1]);
+          lastX = mCoordinates[i][0];
+          lastY = mCoordinates[i][1];
+          break;
+        default:
+      }
+      totalLength += mSegmentsLength[i];
+    }
+
+    mTotalLength = totalLength;
+  }
+
+  private static void quadCurveSegment(float[] coords, float t0, float t1) {
+    // Calculate X and Y at 0.5 (We'll use this to reconstruct the control point later)
+    float mt = t0 + (t1 - t0) / 2;
+    float mu = 1 - mt;
+    float mx = mu * mu * coords[0] + 2 * mu * mt * coords[2] + mt * mt * coords[4];
+    float my = mu * mu * coords[1] + 2 * mu * mt * coords[3] + mt * mt * coords[5];
+
+    float u0 = 1 - t0;
+    float u1 = 1 - t1;
+
+    // coords at t0
+    coords[0] = coords[0] * u0 * u0 + coords[2] * 2 * t0 * u0 + coords[4] * t0 * t0;
+    coords[1] = coords[1] * u0 * u0 + coords[3] * 2 * t0 * u0 + coords[5] * t0 * t0;
+
+    // coords at t1
+    coords[4] = coords[0] * u1 * u1 + coords[2] * 2 * t1 * u1 + coords[4] * t1 * t1;
+    coords[5] = coords[1] * u1 * u1 + coords[3] * 2 * t1 * u1 + coords[5] * t1 * t1;
+
+    // estimated control point at t'=0.5
+    coords[2] = 2 * mx - coords[0] / 2 - coords[4] / 2;
+    coords[3] = 2 * my - coords[1] / 2 - coords[5] / 2;
+  }
+
+  private static void cubicCurveSegment(float[] coords, float t0, float t1) {
+    // http://stackoverflow.com/questions/11703283/cubic-bezier-curve-segment
+    float u0 = 1 - t0;
+    float u1 = 1 - t1;
+
+    // Calculate the points at t0 and t1 for the quadratic curves formed by (P0, P1, P2) and
+    // (P1, P2, P3)
+    float qxa = coords[0] * u0 * u0 + coords[2] * 2 * t0 * u0 + coords[4] * t0 * t0;
+    float qxb = coords[0] * u1 * u1 + coords[2] * 2 * t1 * u1 + coords[4] * t1 * t1;
+    float qxc = coords[2] * u0 * u0 + coords[4] * 2 * t0 * u0 + coords[6] * t0 * t0;
+    float qxd = coords[2] * u1 * u1 + coords[4] * 2 * t1 * u1 + coords[6] * t1 * t1;
+
+    float qya = coords[1] * u0 * u0 + coords[3] * 2 * t0 * u0 + coords[5] * t0 * t0;
+    float qyb = coords[1] * u1 * u1 + coords[3] * 2 * t1 * u1 + coords[5] * t1 * t1;
+    float qyc = coords[3] * u0 * u0 + coords[5] * 2 * t0 * u0 + coords[7] * t0 * t0;
+    float qyd = coords[3] * u1 * u1 + coords[5] * 2 * t1 * u1 + coords[7] * t1 * t1;
+
+    // Linear interpolation
+    coords[0] = qxa * u0 + qxc * t0;
+    coords[1] = qya * u0 + qyc * t0;
+
+    coords[2] = qxa * u1 + qxc * t1;
+    coords[3] = qya * u1 + qyc * t1;
+
+    coords[4] = qxb * u0 + qxd * t0;
+    coords[5] = qyb * u0 + qyd * t0;
+
+    coords[6] = qxb * u1 + qxd * t1;
+    coords[7] = qyb * u1 + qyd * t1;
+  }
+
+  /**
+   * Returns the end point of a given segment
+   *
+   * @param type the segment type
+   * @param coords the segment coordinates array
+   * @param point the return array where the point will be stored
+   */
+  private static void getShapeEndPoint(int type, float[] coords, float[] point) {
+    // start index of the end point for the segment type
+    int pointIndex = (getNumberOfPoints(type) - 1) * 2;
+    point[0] = coords[pointIndex];
+    point[1] = coords[pointIndex + 1];
+  }
+
+  /** Returns the number of points stored in a coordinates array for the given segment type. */
+  private static int getNumberOfPoints(int segmentType) {
+    switch (segmentType) {
+      case PathIterator.SEG_QUADTO:
+        return 2;
+      case PathIterator.SEG_CUBICTO:
+        return 3;
+      case PathIterator.SEG_CLOSE:
+        return 0;
+      default:
+        return 1;
+    }
+  }
+
+  /**
+   * Returns the estimated length of a flat path. If the passed path is not flat (i.e. contains a
+   * segment that is not {@link PathIterator#SEG_CLOSE}, {@link PathIterator#SEG_MOVETO} or {@link
+   * PathIterator#SEG_LINETO} this method will fail.
+   */
+  private static float getFlatPathLength(PathIterator iterator) {
+    float segment[] = new float[6];
+    float totalLength = 0;
+    float[] previousPoint = new float[2];
+    boolean isFirstPoint = true;
+
+    while (!iterator.isDone()) {
+      int type = iterator.currentSegment(segment);
+      assert type == PathIterator.SEG_LINETO
+          || type == PathIterator.SEG_CLOSE
+          || type == PathIterator.SEG_MOVETO;
+
+      // MoveTo shouldn't affect the length
+      if (!isFirstPoint && type != PathIterator.SEG_MOVETO) {
+        totalLength +=
+            (float) Point2D.distance(previousPoint[0], previousPoint[1], segment[0], segment[1]);
+      } else {
+        isFirstPoint = false;
+      }
+      previousPoint[0] = segment[0];
+      previousPoint[1] = segment[1];
+      iterator.next();
+    }
+
+    return totalLength;
+  }
+
+  /** Returns the estimated position along a path of the given length. */
+  private void getPointAtLength(
+      int type, float[] coords, float lastX, float lastY, float t, float[] point) {
+    if (type == PathIterator.SEG_LINETO) {
+      point[0] = lastX + (coords[0] - lastX) * t;
+      point[1] = lastY + (coords[1] - lastY) * t;
+      // Return here, since we do not need a shape to estimate
+      return;
+    }
+
+    float[] curve = new float[8];
+    int lastPointIndex = (getNumberOfPoints(type) - 1) * 2;
+
+    System.arraycopy(coords, 0, curve, 2, coords.length);
+    curve[0] = lastX;
+    curve[1] = lastY;
+    if (type == PathIterator.SEG_CUBICTO) {
+      cubicCurveSegment(curve, 0f, t);
+    } else {
+      quadCurveSegment(curve, 0f, t);
+    }
+
+    point[0] = curve[2 + lastPointIndex];
+    point[1] = curve[2 + lastPointIndex + 1];
+  }
+
+  public CachedPathIterator iterator() {
+    return new CachedPathIterator();
+  }
+
+  /** Class that allows us to iterate over a path multiple times */
+  public class CachedPathIterator implements PathIterator {
+    private int mNextIndex;
+
+    /**
+     * Current segment type.
+     *
+     * @see PathIterator
+     */
+    private int mCurrentType;
+
+    /**
+     * Stores the coordinates array of the current segment. The number of points stored depends on
+     * the segment type.
+     *
+     * @see PathIterator
+     */
+    private float[] mCurrentCoords = new float[6];
+
+    private float mCurrentSegmentLength;
+
+    /**
+     * Current segment length offset. When asking for the length of the current segment, the length
+     * will be reduced by this amount. This is useful when we are only using portions of the
+     * segment.
+     *
+     * @see #jumpToSegment(float)
+     */
+    private float mOffsetLength;
+
+    /** Point where the current segment started */
+    private float[] mLastPoint = new float[2];
+
+    private boolean isIteratorDone;
+
+    private CachedPathIterator() {
+      next();
+    }
+
+    public float getCurrentSegmentLength() {
+      return mCurrentSegmentLength;
+    }
+
+    @Override
+    public int getWindingRule() {
+      return mWindingRule;
+    }
+
+    @Override
+    public boolean isDone() {
+      return isIteratorDone;
+    }
+
+    @Override
+    public void next() {
+      if (mNextIndex >= mTypes.length) {
+        isIteratorDone = true;
+        return;
+      }
+
+      if (mNextIndex >= 1) {
+        // We've already called next() once so there is a previous segment in this path.
+        // We want to get the coordinates where the path ends.
+        getShapeEndPoint(mCurrentType, mCurrentCoords, mLastPoint);
+      } else {
+        // This is the first segment, no previous point so initialize to 0, 0
+        mLastPoint[0] = mLastPoint[1] = 0f;
+      }
+      mCurrentType = mTypes[mNextIndex];
+      mCurrentSegmentLength = mSegmentsLength[mNextIndex] - mOffsetLength;
+
+      if (mOffsetLength > 0f && (mCurrentType == SEG_CUBICTO || mCurrentType == SEG_QUADTO)) {
+        // We need to skip part of the start of the current segment (because
+        // mOffsetLength > 0)
+        float[] points = new float[8];
+
+        if (mNextIndex < 1) {
+          points[0] = points[1] = 0f;
+        } else {
+          getShapeEndPoint(mTypes[mNextIndex - 1], mCoordinates[mNextIndex - 1], points);
+        }
+
+        System.arraycopy(mCoordinates[mNextIndex], 0, points, 2, mCoordinates[mNextIndex].length);
+        float t0 =
+            (mSegmentsLength[mNextIndex] - mCurrentSegmentLength) / mSegmentsLength[mNextIndex];
+        if (mCurrentType == SEG_CUBICTO) {
+          cubicCurveSegment(points, t0, 1f);
+        } else {
+          quadCurveSegment(points, t0, 1f);
+        }
+        System.arraycopy(points, 2, mCurrentCoords, 0, mCoordinates[mNextIndex].length);
+      } else {
+        System.arraycopy(
+            mCoordinates[mNextIndex], 0, mCurrentCoords, 0, mCoordinates[mNextIndex].length);
+      }
+
+      mOffsetLength = 0f;
+      mNextIndex++;
+    }
+
+    @Override
+    public int currentSegment(float[] coords) {
+      System.arraycopy(mCurrentCoords, 0, coords, 0, getNumberOfPoints(mCurrentType) * 2);
+      return mCurrentType;
+    }
+
+    @Override
+    public int currentSegment(double[] coords) {
+      throw new UnsupportedOperationException();
+    }
+
+    /** Returns the point where the current segment ends */
+    public void getCurrentSegmentEnd(float[] point) {
+      point[0] = mLastPoint[0];
+      point[1] = mLastPoint[1];
+    }
+
+    /** Restarts the iterator and jumps all the segments of this path up to the length value. */
+    public void jumpToSegment(float length) {
+      isIteratorDone = false;
+      if (length <= 0f) {
+        mNextIndex = 0;
+        return;
+      }
+
+      float accLength = 0;
+      float lastPoint[] = new float[2];
+      for (mNextIndex = 0; mNextIndex < mTypes.length; mNextIndex++) {
+        float segmentLength = mSegmentsLength[mNextIndex];
+        if (accLength + segmentLength >= length && mTypes[mNextIndex] != SEG_MOVETO) {
+          float[] estimatedPoint = new float[2];
+          getPointAtLength(
+              mTypes[mNextIndex],
+              mCoordinates[mNextIndex],
+              lastPoint[0],
+              lastPoint[1],
+              (length - accLength) / segmentLength,
+              estimatedPoint);
+
+          // This segment makes us go further than length so we go back one step,
+          // set a moveto and offset the length of the next segment by the length
+          // of this segment that we've already used.
+          mCurrentType = PathIterator.SEG_MOVETO;
+          mCurrentCoords[0] = estimatedPoint[0];
+          mCurrentCoords[1] = estimatedPoint[1];
+          mCurrentSegmentLength = 0;
+
+          // We need to offset next path length to account for the segment we've just
+          // skipped.
+          mOffsetLength = length - accLength;
+          return;
+        }
+        accLength += segmentLength;
+        getShapeEndPoint(mTypes[mNextIndex], mCoordinates[mNextIndex], lastPoint);
+      }
+    }
+
+    /**
+     * Returns the current segment up to certain length. If the current segment is shorter than
+     * length, then the whole segment is returned. The segment coordinates are copied into the
+     * coords array.
+     *
+     * @return the segment type
+     */
+    public int currentSegment(float[] coords, float length) {
+      int type = currentSegment(coords);
+      // If the length is greater than the current segment length, no need to find
+      // the cut point. Same if this is a SEG_MOVETO.
+      if (mCurrentSegmentLength <= length || type == SEG_MOVETO) {
+        return type;
+      }
+
+      float t = length / getCurrentSegmentLength();
+
+      // We find at which offset the end point is located within the coords array and set
+      // a new end point to cut the segment short
+      switch (type) {
+        case SEG_CUBICTO:
+        case SEG_QUADTO:
+          float[] curve = new float[8];
+          curve[0] = mLastPoint[0];
+          curve[1] = mLastPoint[1];
+          System.arraycopy(coords, 0, curve, 2, coords.length);
+          if (type == SEG_CUBICTO) {
+            cubicCurveSegment(curve, 0f, t);
+          } else {
+            quadCurveSegment(curve, 0f, t);
+          }
+          System.arraycopy(curve, 2, coords, 0, coords.length);
+          break;
+        default:
+          float[] point = new float[2];
+          getPointAtLength(type, coords, mLastPoint[0], mLastPoint[1], t, point);
+          coords[0] = point[0];
+          coords[1] = point[1];
+      }
+
+      return type;
+    }
+
+    /** Returns the total length of the path */
+    public float getTotalLength() {
+      return mTotalLength;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ClassNameResolver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ClassNameResolver.java
new file mode 100644
index 0000000..32e2875
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ClassNameResolver.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+public class ClassNameResolver<T> {
+
+  public static <T> Class<T> resolve(String packageName, String className) throws ClassNotFoundException {
+    Class<T> aClass;
+    if (looksFullyQualified(className)) {
+      aClass = safeClassForName(className);
+    } else {
+      if (className.startsWith(".")) {
+        aClass = safeClassForName(packageName + className);
+      } else {
+        aClass = safeClassForName(packageName + "." + className);
+      }
+    }
+
+    if (aClass == null) {
+      throw new ClassNotFoundException("Could not find a class for package: "
+          + packageName + " and class name: " + className);
+    }
+    return aClass;
+  }
+
+  private static boolean looksFullyQualified(String className) {
+    return className.contains(".") && !className.startsWith(".");
+  }
+
+  private static <T> Class<T> safeClassForName(String classNamePath) {
+    try {
+      return (Class<T>) Class.forName(classNamePath);
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/Converter.java b/shadows/framework/src/main/java/org/robolectric/shadows/Converter.java
new file mode 100644
index 0000000..30cf0d7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/Converter.java
@@ -0,0 +1,337 @@
+package org.robolectric.shadows;
+
+import android.content.res.Resources;
+import android.util.TypedValue;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.res.AttrData;
+import org.robolectric.res.ResType;
+import org.robolectric.res.TypedResource;
+import org.robolectric.util.Util;
+
+@SuppressWarnings("NewApi")
+public class Converter<T> {
+  private static int nextStringCookie = 0xbaaa5;
+
+  synchronized static int getNextStringCookie() {
+    return nextStringCookie++;
+  }
+
+  static Converter getConverterFor(AttrData attrData, String type) {
+    switch (type) {
+      case "enum":
+        return new EnumConverter(attrData);
+      case "flag":
+      case "flags": // because {@link ResourceTable#gFormatFlags} uses "flags"
+        return new FlagConverter(attrData);
+      case "boolean":
+        return new FromBoolean();
+      case "color":
+        return new FromColor();
+      case "dimension":
+        return new FromDimen();
+      case "float":
+        return new FromFloat();
+      case "integer":
+        return new FromInt();
+      case "string":
+        return new FromCharSequence();
+      case "fraction":
+        return new FromFraction();
+      default:
+        throw new UnsupportedOperationException("Type not supported: " + type);
+    }
+  }
+
+  // TODO: Handle 'anim' resources
+  public static Converter getConverter(ResType resType) {
+    switch (resType) {
+      case ATTR_DATA:
+        return new FromAttrData();
+      case BOOLEAN:
+        return new FromBoolean();
+      case CHAR_SEQUENCE:
+        return new FromCharSequence();
+      case COLOR:
+      case DRAWABLE:
+        return new FromColor();
+      case COLOR_STATE_LIST:
+      case LAYOUT:
+        return new FromFilePath();
+      case DIMEN:
+        return new FromDimen();
+      case FILE:
+        return new FromFile();
+      case FLOAT:
+        return new FromFloat();
+      case INTEGER:
+        return new FromInt();
+      case FRACTION:
+        return new FromFraction();
+      case CHAR_SEQUENCE_ARRAY:
+      case INTEGER_ARRAY:
+      case TYPED_ARRAY:
+        return new FromArray();
+      case STYLE:
+        return new Converter();
+      default:
+        throw new UnsupportedOperationException("can't convert from " + resType.name());
+    }
+  }
+
+  public CharSequence asCharSequence(TypedResource typedResource) {
+    return typedResource.asString();
+  }
+
+  public int asInt(TypedResource typedResource) {
+    throw cantDo("asInt");
+  }
+
+  public List<TypedResource> getItems(TypedResource typedResource) {
+    return new ArrayList<>();
+  }
+
+  public boolean fillTypedValue(T data, TypedValue typedValue) {
+    return false;
+  }
+
+  private UnsupportedOperationException cantDo(String operation) {
+    return new UnsupportedOperationException(getClass().getName() + " doesn't support " + operation);
+  }
+
+  public static class FromAttrData extends Converter<AttrData> {
+    @Override
+    public CharSequence asCharSequence(TypedResource typedResource) {
+      return typedResource.asString();
+    }
+
+    @Override
+    public boolean fillTypedValue(AttrData data, TypedValue typedValue) {
+      typedValue.type = TypedValue.TYPE_STRING;
+      return false;
+    }
+  }
+
+  public static class FromCharSequence extends Converter<String> {
+    @Override
+    public CharSequence asCharSequence(TypedResource typedResource) {
+      return typedResource.asString().trim();
+    }
+
+    @Override
+    public int asInt(TypedResource typedResource) {
+      return convertInt(typedResource.asString().trim());
+    }
+
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      typedValue.type = TypedValue.TYPE_STRING;
+      typedValue.data = 0;
+      typedValue.assetCookie = getNextStringCookie();
+      typedValue.string = data;
+      return true;
+    }
+  }
+
+  public static class FromColor extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      try {
+        typedValue.type =  ResourceHelper.getColorType(data);
+        typedValue.data = ResourceHelper.getColor(data);
+        typedValue.assetCookie = 0;
+        typedValue.string = null;
+        return true;
+      } catch (NumberFormatException nfe) {
+        return false;
+      }
+    }
+
+    @Override
+    public int asInt(TypedResource typedResource) {
+      return ResourceHelper.getColor(typedResource.asString().trim());
+    }
+  }
+
+  public static class FromFilePath extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      typedValue.type = TypedValue.TYPE_STRING;
+      typedValue.data = 0;
+      typedValue.string = data;
+      typedValue.assetCookie = getNextStringCookie();
+      return true;
+    }
+  }
+
+  public static class FromArray extends Converter {
+    @Override
+    public List<TypedResource> getItems(TypedResource typedResource) {
+      return (List<TypedResource>) typedResource.getData();
+    }
+  }
+
+  private static class FromInt extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      try {
+        if (data.startsWith("0x")) {
+          typedValue.type = data.startsWith("0x") ? TypedValue.TYPE_INT_HEX : TypedValue.TYPE_INT_DEC;
+        } else {
+          typedValue.type = TypedValue.TYPE_INT_DEC;
+        }
+        typedValue.data = convertInt(data);
+        typedValue.assetCookie = 0;
+        typedValue.string = null;
+        return true;
+      } catch (NumberFormatException nfe) {
+        return false;
+      }
+    }
+
+    @Override
+    public int asInt(TypedResource typedResource) {
+      return convertInt(typedResource.asString().trim());
+    }
+  }
+
+  private static class FromFraction extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      return ResourceHelper.parseFloatAttribute(null, data, typedValue, false);
+    }
+  }
+
+  private static class FromFile extends Converter<Object> {
+    @Override
+    public boolean fillTypedValue(Object data, TypedValue typedValue) {
+      typedValue.type = TypedValue.TYPE_STRING;
+      typedValue.data = 0;
+      typedValue.string = data instanceof Path ? data.toString() : (CharSequence) data;
+      typedValue.assetCookie = getNextStringCookie();
+      return true;
+    }
+  }
+
+  private static class FromFloat extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      return ResourceHelper.parseFloatAttribute(null, data, typedValue, false);
+    }
+  }
+
+  private static class FromBoolean extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      typedValue.type = TypedValue.TYPE_INT_BOOLEAN;
+      typedValue.assetCookie = 0;
+      typedValue.string = null;
+
+      if ("true".equalsIgnoreCase(data)) {
+        typedValue.data = 1;
+        return true;
+      } else if ("false".equalsIgnoreCase(data)) {
+        typedValue.data = 0;
+        return true;
+      }
+      return false;
+    }
+  }
+
+  private static class FromDimen extends Converter<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      return ResourceHelper.parseFloatAttribute(null, data, typedValue, false);
+    }
+  }
+
+  private static int convertInt(String rawValue) {
+    try {
+      // Decode into long, because there are some large hex values in the android resource files
+      // (e.g. config_notificationsBatteryLowARGB = 0xFFFF0000 in sdk 14).
+      // Integer.decode() does not support large, i.e. negative values in hex numbers.
+      // try parsing decimal number
+      return (int) Long.parseLong(rawValue);
+    } catch (NumberFormatException nfe) {
+      // try parsing hex number
+      return Long.decode(rawValue).intValue();
+    }
+  }
+
+  private static class EnumConverter extends EnumOrFlagConverter {
+    public EnumConverter(AttrData attrData) {
+      super(attrData);
+    }
+
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      try {
+        typedValue.type = TypedValue.TYPE_INT_HEX;
+        try {
+          typedValue.data = findValueFor(data);
+        } catch (Resources.NotFoundException e) {
+          typedValue.data = convertInt(data);
+        }
+        typedValue.assetCookie = 0;
+        typedValue.string = null;
+        return true;
+      } catch (Exception e) {
+        return false;
+      }
+    }
+  }
+
+  private static class FlagConverter extends EnumOrFlagConverter {
+    public FlagConverter(AttrData attrData) {
+      super(attrData);
+    }
+
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue) {
+      int flags = 0;
+
+      try {
+        for (String key : data.split("\\|", 0)) {
+          flags |= findValueFor(key);
+        }
+      } catch (Resources.NotFoundException e) {
+        try {
+          flags = Integer.decode(data);
+        } catch (NumberFormatException e1) {
+          return false;
+        }
+      } catch (Exception e) {
+        return false;
+      }
+
+      typedValue.type = TypedValue.TYPE_INT_HEX;
+      typedValue.data = flags;
+      typedValue.assetCookie = 0;
+      typedValue.string = null;
+      return true;
+    }
+  }
+
+  private static class EnumOrFlagConverter extends Converter<String> {
+    private final AttrData attrData;
+
+    public EnumOrFlagConverter(AttrData attrData) {
+      this.attrData = attrData;
+    }
+
+    protected int findValueFor(String key) {
+      key = (key == null) ? null : key.trim();
+      String valueFor = attrData.getValueFor(key);
+      if (valueFor == null) {
+        // Maybe they have passed in the value directly, rather than the name.
+        if (attrData.isValue(key)) {
+          valueFor = key;
+        } else {
+          throw new Resources.NotFoundException("no value found for " + key);
+        }
+      }
+      return Util.parseInt(valueFor);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/Converter2.java b/shadows/framework/src/main/java/org/robolectric/shadows/Converter2.java
new file mode 100644
index 0000000..1ae3016
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/Converter2.java
@@ -0,0 +1,216 @@
+package org.robolectric.shadows;
+
+import android.util.TypedValue;
+import org.robolectric.res.AttrData;
+import org.robolectric.util.Util;
+
+public class Converter2<T> {
+  private static int nextStringCookie = 0xbaaa5;
+
+  synchronized static int getNextStringCookie() {
+    return nextStringCookie++;
+  }
+
+  public static Converter2 getConverterFor(AttrData attrData, String type) {
+    switch (type) {
+      case "enum":
+        return new EnumConverter(attrData);
+      case "flag":
+      case "flags": // because {@link ResourceTable#gFormatFlags} uses "flags"
+        return new FlagConverter(attrData);
+      case "boolean":
+        return new FromBoolean();
+      case "color":
+        return new FromColor();
+      case "dimension":
+        return new FromDimen();
+      case "float":
+        return new FromFloat();
+      case "integer":
+        return new FromInt();
+      case "string":
+        return new FromCharSequence();
+      case "fraction":
+        return new FromFraction();
+      default:
+        throw new UnsupportedOperationException("Type not supported: " + type);
+    }
+  }
+
+  public boolean fillTypedValue(T data, TypedValue typedValue, boolean throwOnFailure) {
+    return false;
+  }
+
+  public static class FromCharSequence extends Converter2<String> {
+
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      typedValue.type = TypedValue.TYPE_STRING;
+      typedValue.data = 0;
+      typedValue.assetCookie = getNextStringCookie();
+      typedValue.string = data;
+      return true;
+    }
+  }
+
+  public static class FromColor extends Converter2<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      try {
+        typedValue.type = ResourceHelper.getColorType(data);
+        typedValue.data = ResourceHelper.getColor(data);
+        typedValue.assetCookie = 0;
+        typedValue.string = null;
+        return true;
+      } catch (NumberFormatException nfe) {
+        return false;
+      }
+    }
+
+  }
+
+
+  private static class FromInt extends Converter2<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      try {
+        if (data.startsWith("0x")) {
+          typedValue.type = TypedValue.TYPE_INT_HEX;
+        } else {
+          typedValue.type = TypedValue.TYPE_INT_DEC;
+        }
+        typedValue.data = convertInt(data);
+        typedValue.assetCookie = 0;
+        typedValue.string = null;
+        return true;
+      } catch (NumberFormatException nfe) {
+        return false;
+      }
+    }
+
+  }
+
+  private static class FromFraction extends Converter2<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      return ResourceHelper2.parseFloatAttribute(null, data, typedValue, false);
+    }
+  }
+
+  private static class FromFloat extends Converter2<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      return ResourceHelper2.parseFloatAttribute(null, data, typedValue, false);
+    }
+  }
+
+  private static class FromBoolean extends Converter2<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      typedValue.type = TypedValue.TYPE_INT_BOOLEAN;
+      typedValue.assetCookie = 0;
+      typedValue.string = null;
+
+      if ("true".equalsIgnoreCase(data)) {
+        typedValue.data = 1;
+        return true;
+      } else if ("false".equalsIgnoreCase(data)) {
+        typedValue.data = 0;
+        return true;
+      }
+      return false;
+    }
+  }
+
+  private static class FromDimen extends Converter2<String> {
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      return ResourceHelper2.parseFloatAttribute(null, data, typedValue, true);
+    }
+  }
+
+  private static int convertInt(String rawValue) {
+    try {
+      // Decode into long, because there are some large hex values in the android resource files
+      // (e.g. config_notificationsBatteryLowARGB = 0xFFFF0000 in sdk 14).
+      // Integer.decode() does not support large, i.e. negative values in hex numbers.
+      // try parsing decimal number
+      return (int) Long.parseLong(rawValue);
+    } catch (NumberFormatException nfe) {
+      // try parsing hex number
+      return Long.decode(rawValue).intValue();
+    }
+  }
+
+  private static class EnumConverter extends EnumOrFlagConverter {
+    EnumConverter(AttrData attrData) {
+      super(attrData);
+    }
+
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      typedValue.type = TypedValue.TYPE_INT_HEX;
+      if (throwOnFailure) {
+        typedValue.data = findValueFor(data);
+      } else {
+        try {
+          typedValue.data = findValueFor(data);
+        } catch (Exception e) {
+          return false;
+        }
+      }
+      typedValue.assetCookie = 0;
+      typedValue.string = null;
+      return true;
+    }
+  }
+
+  private static class FlagConverter extends EnumOrFlagConverter {
+    FlagConverter(AttrData attrData) {
+      super(attrData);
+    }
+
+    @Override
+    public boolean fillTypedValue(String data, TypedValue typedValue, boolean throwOnFailure) {
+      int flags = 0;
+      for (String key : data.split("\\|", 0)) {
+        if (throwOnFailure) {
+          flags |= findValueFor(key);
+        } else {
+          try {
+            flags |= findValueFor(key);
+          } catch (Exception e) {
+            return false;
+          }
+        }
+      }
+
+      typedValue.type = TypedValue.TYPE_INT_HEX;
+      typedValue.data = flags;
+      typedValue.assetCookie = 0;
+      typedValue.string = null;
+      return true;
+    }
+  }
+
+  private static class EnumOrFlagConverter extends Converter2<String> {
+    private final AttrData attrData;
+
+    EnumOrFlagConverter(AttrData attrData) {
+      this.attrData = attrData;
+    }
+
+    int findValueFor(String key) {
+      String valueFor = attrData.getValueFor(key);
+      if (valueFor == null) {
+        // Maybe they have passed in the value directly, rather than the name.
+        if (attrData.isValue(key)) {
+          valueFor = key;
+        } else {
+          throw new RuntimeException("no value found for " + key);
+        }
+      }
+      return Util.parseInt(valueFor);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/DeviceStateSensorOrientationBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/DeviceStateSensorOrientationBuilder.java
new file mode 100644
index 0000000..ae97119
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/DeviceStateSensorOrientationBuilder.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import android.hardware.camera2.params.DeviceStateSensorOrientationMap;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.RequiresApi;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+/** Builder for {@link DeviceStateSensorOrientationMap} which was introduced in Android T. */
+@RequiresApi(VERSION_CODES.TIRAMISU)
+public class DeviceStateSensorOrientationBuilder {
+  private long[] sensorOrientationMap;
+
+  private DeviceStateSensorOrientationBuilder() {}
+
+  public static DeviceStateSensorOrientationBuilder newBuilder() {
+    return new DeviceStateSensorOrientationBuilder();
+  }
+
+  @CanIgnoreReturnValue
+  public DeviceStateSensorOrientationBuilder addSensorOrientationMap(long[] sensorOrientationMap) {
+    this.sensorOrientationMap = sensorOrientationMap;
+    return this;
+  }
+
+  public DeviceStateSensorOrientationMap build() {
+    return new DeviceStateSensorOrientationMap(sensorOrientationMap);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/DragEventBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/DragEventBuilder.java
new file mode 100644
index 0000000..6b10fac
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/DragEventBuilder.java
@@ -0,0 +1,109 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.view.DragEvent;
+import android.view.SurfaceControl;
+import androidx.annotation.Nullable;
+import com.android.internal.view.IDragAndDropPermissions;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link DragEvent}. */
+public class DragEventBuilder {
+  private int action;
+  private float x;
+  private float y;
+  @Nullable private Object localState;
+  @Nullable private ClipDescription clipDescription;
+  @Nullable private ClipData clipData;
+  private boolean result;
+
+  private DragEventBuilder() {}
+
+  public static DragEventBuilder newBuilder() {
+    return new DragEventBuilder();
+  }
+
+  public DragEventBuilder setAction(int action) {
+    this.action = action;
+    return this;
+  }
+
+  public DragEventBuilder setX(float x) {
+    this.x = x;
+    return this;
+  }
+
+  public DragEventBuilder setY(float y) {
+    this.y = y;
+    return this;
+  }
+
+  public DragEventBuilder setLocalState(@Nullable Object localState) {
+    this.localState = localState;
+    return this;
+  }
+
+  public DragEventBuilder setClipDescription(@Nullable ClipDescription clipDescription) {
+    this.clipDescription = clipDescription;
+    return this;
+  }
+
+  public DragEventBuilder setClipData(@Nullable ClipData clipData) {
+    this.clipData = clipData;
+    return this;
+  }
+
+  public DragEventBuilder setResult(boolean result) {
+    this.result = result;
+    return this;
+  }
+
+  public DragEvent build() {
+    int api = RuntimeEnvironment.getApiLevel();
+    if (api <= M) {
+      return ReflectionHelpers.callStaticMethod(
+          DragEvent.class,
+          "obtain",
+          ClassParameter.from(int.class, action),
+          ClassParameter.from(float.class, x),
+          ClassParameter.from(float.class, y),
+          ClassParameter.from(Object.class, localState),
+          ClassParameter.from(ClipDescription.class, clipDescription),
+          ClassParameter.from(ClipData.class, clipData),
+          ClassParameter.from(boolean.class, result));
+    } else if (api <= R) {
+      return ReflectionHelpers.callStaticMethod(
+          DragEvent.class,
+          "obtain",
+          ClassParameter.from(int.class, action),
+          ClassParameter.from(float.class, x),
+          ClassParameter.from(float.class, y),
+          ClassParameter.from(Object.class, localState),
+          ClassParameter.from(ClipDescription.class, clipDescription),
+          ClassParameter.from(ClipData.class, clipData),
+          ClassParameter.from(IDragAndDropPermissions.class, null),
+          ClassParameter.from(boolean.class, result));
+    } else {
+      return ReflectionHelpers.callStaticMethod(
+          DragEvent.class,
+          "obtain",
+          ClassParameter.from(int.class, action),
+          ClassParameter.from(float.class, x),
+          ClassParameter.from(float.class, y),
+          ClassParameter.from(float.class, 0),
+          ClassParameter.from(float.class, 0),
+          ClassParameter.from(Object.class, localState),
+          ClassParameter.from(ClipDescription.class, clipDescription),
+          ClassParameter.from(ClipData.class, clipData),
+          ClassParameter.from(SurfaceControl.class, null),
+          ClassParameter.from(IDragAndDropPermissions.class, null),
+          ClassParameter.from(boolean.class, result));
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/EpsBearerQosSessionAttributesBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/EpsBearerQosSessionAttributesBuilder.java
new file mode 100644
index 0000000..d3288c1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/EpsBearerQosSessionAttributesBuilder.java
@@ -0,0 +1,68 @@
+package org.robolectric.shadows;
+
+import android.annotation.TargetApi;
+import android.os.Build.VERSION_CODES;
+import android.telephony.data.EpsBearerQosSessionAttributes;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Class to build {@link EpsBearerQosSessionAttributes}. */
+@TargetApi(VERSION_CODES.S)
+public final class EpsBearerQosSessionAttributesBuilder {
+
+  private int qci;
+  private long maxDownlinkBitRate;
+  private long maxUplinkBitRate;
+  private long guaranteedDownlinkBitRate;
+  private long guaranteedUplinkBitRate;
+  private final List<InetSocketAddress> remoteAddresses = new ArrayList<>();
+
+  private EpsBearerQosSessionAttributesBuilder() {}
+
+  public static EpsBearerQosSessionAttributesBuilder newBuilder() {
+    return new EpsBearerQosSessionAttributesBuilder();
+  }
+
+  public EpsBearerQosSessionAttributesBuilder setQci(int qci) {
+    this.qci = qci;
+    return this;
+  }
+
+  public EpsBearerQosSessionAttributesBuilder setMaxDownlinkBitRate(long maxDownlinkBitRate) {
+    this.maxDownlinkBitRate = maxDownlinkBitRate;
+    return this;
+  }
+
+  public EpsBearerQosSessionAttributesBuilder setMaxUplinkBitRate(long maxUplinkBitRate) {
+    this.maxUplinkBitRate = maxUplinkBitRate;
+    return this;
+  }
+
+  public EpsBearerQosSessionAttributesBuilder setGuaranteedDownlinkBitRate(
+      long guaranteedDownlinkBitRate) {
+    this.guaranteedDownlinkBitRate = guaranteedDownlinkBitRate;
+    return this;
+  }
+
+  public EpsBearerQosSessionAttributesBuilder setGuaranteedUplinkBitRate(
+      long guaranteedUplinkBitRate) {
+    this.guaranteedUplinkBitRate = guaranteedUplinkBitRate;
+    return this;
+  }
+
+  public EpsBearerQosSessionAttributesBuilder addRemoteAddress(InetSocketAddress remoteAddress) {
+    this.remoteAddresses.add(remoteAddress);
+    return this;
+  }
+
+  public EpsBearerQosSessionAttributes build() {
+    return new EpsBearerQosSessionAttributes(
+        qci,
+        maxUplinkBitRate,
+        maxDownlinkBitRate,
+        guaranteedUplinkBitRate,
+        guaranteedDownlinkBitRate,
+        remoteAddresses);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/FrameMetricsBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/FrameMetricsBuilder.java
new file mode 100644
index 0000000..6cbff6d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/FrameMetricsBuilder.java
@@ -0,0 +1,134 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.FrameMetrics;
+import android.view.FrameMetrics.Metric;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Class to build {@link FrameMetrics} */
+public final class FrameMetricsBuilder {
+
+  // android.view.FrameMetrics$Index defines all of these values, but has RetentionPolicy.SOURCE,
+  // preventing use of reflection to read them.
+  private static final int FLAGS_INDEX = 0;
+  private static final int INTENDED_VSYNC_INDEX = RuntimeEnvironment.getApiLevel() <= R ? 1 : 2;
+  private static final int VSYNC_INDEX = RuntimeEnvironment.getApiLevel() <= R ? 2 : 3;
+
+  private final Map<Integer, Long> metricsMap = new HashMap<>();
+  private long syncDelayTimeNanos = 0;
+
+  public FrameMetricsBuilder() {}
+
+  /**
+   * Sets the given metric to the given value.
+   *
+   * <p>If this is not called for a certain metric, that metric will be assumed to have the value 0.
+   * The value of {@code frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)} will be equal to the
+   * sum of all non-boolean metrics and the value given to {@link this#setSyncDelayTimeNanos(long)}.
+   */
+  public FrameMetricsBuilder setMetric(@Metric int metric, long value) {
+    if (metric == FrameMetrics.FIRST_DRAW_FRAME) {
+      if (value > 1 || value < 0) {
+        throw new IllegalArgumentException(
+            "For boolean metric FIRST_DRAW_FRAME, use 0 or 1 to represent false and true");
+      }
+    }
+    metricsMap.put(metric, value);
+    return this;
+  }
+
+  /**
+   * Sets the delay time between when drawing finishes and syncing begins. If unset, defaults to 0.
+   */
+  public FrameMetricsBuilder setSyncDelayTimeNanos(long syncDelayTimeNanos) {
+    this.syncDelayTimeNanos = syncDelayTimeNanos;
+    return this;
+  }
+
+  public FrameMetrics build() throws Exception {
+    FrameMetrics metrics = ReflectionHelpers.callConstructor(FrameMetrics.class);
+    long[] timingData = reflector(FrameMetricsReflector.class, metrics).getTimingData();
+
+    // This value is left shifted 0 in the real code.
+    timingData[FLAGS_INDEX] = getMetric(FrameMetrics.FIRST_DRAW_FRAME);
+
+    timingData[INTENDED_VSYNC_INDEX] = getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP);
+    timingData[VSYNC_INDEX] = getMetric(FrameMetrics.VSYNC_TIMESTAMP);
+
+    // First we calculate everything up to and including DRAW_DURATION.
+    for (@Metric int metric = FrameMetrics.UNKNOWN_DELAY_DURATION;
+        metric <= FrameMetrics.DRAW_DURATION;
+        metric++) {
+      timingData[getEndIndexForMetric(metric)] =
+          timingData[getStartIndexForMetric(metric)] + getMetric(metric);
+    }
+
+    // Then, we delay the starting of syncing by the given syncDelayTimeNanos.
+    timingData[getStartIndexForMetric(FrameMetrics.SYNC_DURATION)] =
+        timingData[getEndIndexForMetric(FrameMetrics.DRAW_DURATION)] + syncDelayTimeNanos;
+
+    // Finally we calculate the remainder of the durations after enqueing the sync.
+    // Note that we don't directly compute the value for TOTAL_DURATION, as it's generated from the
+    // start of UNKNOWN_DELAY_DURATION to the end of SWAP_BUFFERS_DURATION.
+    for (@Metric int metric = FrameMetrics.SYNC_DURATION; metric < getMetricsCount(); metric++) {
+      if (metric == FrameMetrics.TOTAL_DURATION) {
+        continue;
+      }
+
+      int endIndex = getEndIndexForMetric(metric);
+      int startIndex = getStartIndexForMetric(metric);
+      if (startIndex == 0 && endIndex == 0) {
+        // skip reserved fields
+        continue;
+      }
+      timingData[getEndIndexForMetric(metric)] =
+          timingData[getStartIndexForMetric(metric)] + getMetric(metric);
+    }
+
+    // SWAP_BUFFERS_DURATION is the current endpoint in the chain of supported FrameMetrics.
+    timingData[getEndIndexForMetric(FrameMetrics.TOTAL_DURATION)] =
+        timingData[getEndIndexForMetric(FrameMetrics.SWAP_BUFFERS_DURATION)];
+    return metrics;
+  }
+
+  private static int getMetricsCount() {
+    return reflector(FrameMetricsReflector.class).getDurations().length / 2;
+  }
+
+  private int getStartIndexForMetric(@Metric int metric) {
+    return reflector(FrameMetricsReflector.class).getDurations()[2 * metric];
+  }
+
+  private int getEndIndexForMetric(@Metric int metric) {
+    return reflector(FrameMetricsReflector.class).getDurations()[2 * metric + 1];
+  }
+
+  private long getMetric(@Metric int metric) {
+    if (metricsMap.containsKey(metric)) {
+      return metricsMap.get(metric);
+    }
+    // Default to 0.
+    return 0;
+  }
+
+  @ForType(FrameMetrics.class)
+  private interface FrameMetricsReflector {
+    @Accessor("mTimingData")
+    long[] getTimingData();
+
+    @Accessor("DURATIONS")
+    @Static
+    int[] getDurations();
+
+    @Accessor("FRAME_INFO_FLAG_FIRST_DRAW")
+    int getFrameInfoFlagFirstDraw();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/GnssStatusBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/GnssStatusBuilder.java
new file mode 100644
index 0000000..df5a5c3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/GnssStatusBuilder.java
@@ -0,0 +1,225 @@
+package org.robolectric.shadows;
+
+import android.location.GnssStatus;
+import android.os.Build;
+import androidx.annotation.Nullable;
+import com.google.auto.value.AutoValue;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Builder for {@link GnssStatus} objects, since they have a hidden constructor.
+ *
+ * @deprecated Use {@link GnssStatus.Builder} instead where possible.
+ */
+@Deprecated
+public final class GnssStatusBuilder {
+  /** Information about a single satellite in a {@link GnssStatus}. */
+  @AutoValue
+  public abstract static class GnssSatelliteInfo {
+    /**
+     * Gets the {@link GnssStatus#getConstellationType(int) GNSS constellation} of the satellite.
+     */
+    public abstract int getConstellation();
+
+    /** Gets the {@link GnssStatus#getSvid(int) identification number} of the satellite. */
+    public abstract int getSvid();
+
+    /** Gets the {@link GnssStatus#getCn0DbHz(int) carrier-to-noise density} of the satellite. */
+    public abstract float getCn0DbHz();
+
+    /**
+     * Gets the {@link GnssStatus#getElevationDegrees(int) elevation} of the satellite, in degrees.
+     */
+    public abstract float getElevation();
+
+    /** Gets the {@link GnssStatus#getAzimuthDegrees(int) azimuth} of the satellite, in degrees. */
+    public abstract float getAzimuth();
+
+    /** Gets whether the satellite {@link GnssStatus#hasEphemerisData(int) has ephemeris data}. */
+    public abstract boolean getHasEphemeris();
+
+    /** Gets whether the satellite {@link GnssStatus#hasAlmanacData(int) has almanac data}. */
+    public abstract boolean getHasAlmanac();
+
+    /**
+     * Gets whether the satellite {@link GnssStatus#usedInFix(int) was used in the most recent
+     * position fix}.
+     */
+    public abstract boolean isUsedInFix();
+
+    /**
+     * Gets the {@link GnssStatus#getCarrierFrequencyHz(int) carrier frequency} of the satellite, in
+     * Hz, if present; if {@code null}, indicates that the carrier frequency {@link
+     * GnssStatus#hasCarrierFrequencyHz(int) is not available}.
+     */
+    @Nullable
+    public abstract Float getCarrierFrequencyHz();
+
+    public static Builder builder() {
+      return new AutoValue_GnssStatusBuilder_GnssSatelliteInfo.Builder();
+    }
+
+    /** Builder for {@link GnssSatelliteInfo}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      /**
+       * Sets the {@link GnssStatus#getConstellationType(int) GNSS constellation} of the satellite.
+       */
+      public abstract Builder setConstellation(int constellation);
+
+      /** Sets the {@link GnssStatus#getSvid(int) identification number} of the satellite. */
+      public abstract Builder setSvid(int svid);
+
+      /** Gets the {@link GnssStatus#getCn0DbHz(int) carrier-to-noise density} of the satellite. */
+      public abstract Builder setCn0DbHz(float cn0DbHz);
+
+      /**
+       * Sets the {@link GnssStatus#getElevationDegrees(int) elevation} of the satellite, in
+       * degrees.
+       */
+      public abstract Builder setElevation(float elevation);
+
+      /**
+       * Sets the {@link GnssStatus#getAzimuthDegrees(int) azimuth} of the satellite, in degrees.
+       */
+      public abstract Builder setAzimuth(float azimuth);
+
+      /** Sets whether the satellite {@link GnssStatus#hasEphemerisData(int) has ephemeris data}. */
+      public abstract Builder setHasEphemeris(boolean hasEphemeris);
+
+      /** Sets whether the satellite {@link GnssStatus#hasAlmanacData(int) has almanac data}. */
+      public abstract Builder setHasAlmanac(boolean hasAlmanac);
+
+      /**
+       * Sets whether the satellite {@link GnssStatus#usedInFix(int) was used in the most recent
+       * position fix}.
+       */
+      public abstract Builder setUsedInFix(boolean usedInFix);
+
+      /**
+       * Sets the {@link GnssStatus#getCarrierFrequencyHz(int) carrier frequency} of the satellite,
+       * in Hz, if present; if {@code null}, indicates that the carrier frequency {@link
+       * GnssStatus#hasCarrierFrequencyHz(int) is not available}.
+       */
+      public abstract Builder setCarrierFrequencyHz(@Nullable Float carrierFrequencyHz);
+
+      /** Builds the {@link GnssSatelliteInfo}. */
+      public abstract GnssSatelliteInfo build();
+    }
+  }
+
+  private GnssStatusBuilder() {}
+
+  /** Creates a new {@link GnssStatusBuilder}. */
+  public static GnssStatusBuilder create() {
+    return new GnssStatusBuilder();
+  }
+
+  private final List<GnssSatelliteInfo> satelliteInfos = new ArrayList<>();
+
+  /** Adds a satellite to the {@link GnssStatus} being built. */
+  public GnssStatusBuilder addSatellite(GnssSatelliteInfo satelliteInfo) {
+    satelliteInfos.add(satelliteInfo);
+    return this;
+  }
+
+  /** Adds a collection of satellites to the {@link GnssStatus} being built. */
+  public GnssStatusBuilder addAllSatellites(Collection<GnssSatelliteInfo> satelliteInfos) {
+    this.satelliteInfos.addAll(satelliteInfos);
+    return this;
+  }
+
+  /** Builds the {@link GnssStatus} from the satellites previously added. */
+  public GnssStatus build() {
+    return createFrom(satelliteInfos);
+  }
+
+  /** Convenience method to create a {@link GnssStatus} directly from known satellite info. */
+  public static GnssStatus buildFrom(GnssSatelliteInfo... satelliteInfos) {
+    return createFrom(Arrays.asList(satelliteInfos));
+  }
+
+  private static final int GNSS_SV_FLAGS_HAS_EPHEMERIS_DATA =
+      (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
+          ? ReflectionHelpers.getStaticField(GnssStatus.class, "GNSS_SV_FLAGS_HAS_EPHEMERIS_DATA")
+          : 0;
+  private static final int GNSS_SV_FLAGS_HAS_ALMANAC_DATA =
+      (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
+          ? ReflectionHelpers.getStaticField(GnssStatus.class, "GNSS_SV_FLAGS_HAS_ALMANAC_DATA")
+          : 0;
+  private static final int GNSS_SV_FLAGS_USED_IN_FIX =
+      (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
+          ? ReflectionHelpers.getStaticField(GnssStatus.class, "GNSS_SV_FLAGS_USED_IN_FIX")
+          : 0;
+  private static final int GNSS_SV_FLAGS_HAS_CARRIER_FREQUENCY =
+      (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+              && Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
+          ? ReflectionHelpers.getStaticField(
+              GnssStatus.class, "GNSS_SV_FLAGS_HAS_CARRIER_FREQUENCY")
+          : 0;
+  private static final boolean SUPPORTS_CARRIER_FREQUENCY =
+      (GNSS_SV_FLAGS_HAS_CARRIER_FREQUENCY != 0);
+
+  private static final int SVID_SHIFT_WIDTH =
+      ReflectionHelpers.getStaticField(GnssStatus.class, "SVID_SHIFT_WIDTH");
+  private static final int CONSTELLATION_TYPE_SHIFT_WIDTH =
+      ReflectionHelpers.getStaticField(GnssStatus.class, "CONSTELLATION_TYPE_SHIFT_WIDTH");
+  private static final int CONSTELLATION_TYPE_MASK =
+      ReflectionHelpers.getStaticField(GnssStatus.class, "CONSTELLATION_TYPE_MASK");
+
+  private static GnssStatus createFrom(List<GnssSatelliteInfo> satelliteInfos) {
+    int svCount = satelliteInfos.size();
+    int[] svidWithFlags = new int[svCount];
+    float[] cn0DbHz = new float[svCount];
+    float[] elevations = new float[svCount];
+    float[] azimuths = new float[svCount];
+    float[] carrierFrequencies = new float[svCount];
+
+    for (int i = 0; i < svCount; i++) {
+      GnssSatelliteInfo info = satelliteInfos.get(i);
+
+      int packedSvid =
+          (info.getSvid() << SVID_SHIFT_WIDTH)
+              | (info.getConstellation() & CONSTELLATION_TYPE_MASK)
+                  << CONSTELLATION_TYPE_SHIFT_WIDTH;
+
+      if (info.getHasEphemeris()) {
+        packedSvid |= GNSS_SV_FLAGS_HAS_EPHEMERIS_DATA;
+      }
+      if (info.getHasAlmanac()) {
+        packedSvid |= GNSS_SV_FLAGS_HAS_ALMANAC_DATA;
+      }
+      if (info.isUsedInFix()) {
+        packedSvid |= GNSS_SV_FLAGS_USED_IN_FIX;
+      }
+      if (SUPPORTS_CARRIER_FREQUENCY && info.getCarrierFrequencyHz() != null) {
+        packedSvid |= GNSS_SV_FLAGS_HAS_CARRIER_FREQUENCY;
+        carrierFrequencies[i] = info.getCarrierFrequencyHz();
+      }
+      svidWithFlags[i] = packedSvid;
+
+      cn0DbHz[i] = info.getCn0DbHz();
+      elevations[i] = info.getElevation();
+      azimuths[i] = info.getAzimuth();
+    }
+
+    List<ClassParameter<?>> classParameters = new ArrayList<>();
+    classParameters.add(ClassParameter.from(int.class, svCount));
+    classParameters.add(ClassParameter.from(int[].class, svidWithFlags));
+    classParameters.add(ClassParameter.from(float[].class, cn0DbHz));
+    classParameters.add(ClassParameter.from(float[].class, elevations));
+    classParameters.add(ClassParameter.from(float[].class, azimuths));
+
+    if (SUPPORTS_CARRIER_FREQUENCY) {
+      classParameters.add(ClassParameter.from(float[].class, carrierFrequencies));
+    }
+
+    return ReflectionHelpers.callConstructor(
+        GnssStatus.class, classParameters.toArray(new ClassParameter<?>[0]));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ImageUtil.java b/shadows/framework/src/main/java/org/robolectric/shadows/ImageUtil.java
new file mode 100644
index 0000000..75b4957
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ImageUtil.java
@@ -0,0 +1,234 @@
+package org.robolectric.shadows;
+
+import static java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR;
+import static java.awt.RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
+import static java.awt.image.BufferedImage.TYPE_INT_ARGB;
+import static java.awt.image.BufferedImage.TYPE_INT_ARGB_PRE;
+import static java.awt.image.BufferedImage.TYPE_INT_RGB;
+import static javax.imageio.ImageIO.createImageInputStream;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.Point;
+import com.google.auto.value.AutoValue;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Iterator;
+import javax.imageio.IIOException;
+import javax.imageio.IIOImage;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.ImageWriteParam;
+import javax.imageio.ImageWriter;
+import javax.imageio.stream.ImageInputStream;
+import javax.imageio.stream.ImageOutputStream;
+import org.robolectric.Shadows;
+import org.robolectric.shadow.api.Shadow;
+
+public class ImageUtil {
+  private static final String FORMAT_NAME_JPEG = "jpg";
+  private static final String FORMAT_NAME_PNG = "png";
+  private static boolean initialized;
+
+  static Point getImageSizeFromStream(InputStream is) {
+    if (!initialized) {
+      // Stops ImageIO from creating temp files when reading images
+      // from input stream.
+      ImageIO.setUseCache(false);
+      initialized = true;
+    }
+
+    try {
+      ImageInputStream imageStream = createImageInputStream(is);
+      Iterator<ImageReader> readers = ImageIO.getImageReaders(imageStream);
+      if (!readers.hasNext()) return null;
+
+      ImageReader reader = readers.next();
+      try {
+        reader.setInput(imageStream);
+        return new Point(reader.getWidth(0), reader.getHeight(0));
+      } finally {
+        reader.dispose();
+      }
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  static RobolectricBufferedImage getImageFromStream(InputStream is) {
+    return getImageFromStream(null, is);
+  }
+
+  static RobolectricBufferedImage getImageFromStream(String fileName, InputStream is) {
+    if (!initialized) {
+      // Stops ImageIO from creating temp files when reading images
+      // from input stream.
+      ImageIO.setUseCache(false);
+      initialized = true;
+    }
+
+    String format = null;
+    try {
+      ImageInputStream imageStream = createImageInputStream(is);
+      Iterator<ImageReader> readers = ImageIO.getImageReaders(imageStream);
+      if (!readers.hasNext()) {
+        return null;
+      }
+
+      ImageReader reader = readers.next();
+      try {
+        reader.setInput(imageStream);
+        format = reader.getFormatName();
+        int minIndex = reader.getMinIndex();
+        BufferedImage image = reader.read(minIndex);
+        return RobolectricBufferedImage.create(image, ("image/" + format).toLowerCase());
+      } finally {
+        reader.dispose();
+      }
+    } catch (IOException e) {
+      Throwable cause = e.getCause();
+      if (FORMAT_NAME_PNG.equalsIgnoreCase(format)
+          && cause instanceof IIOException
+          && cause.getMessage() != null
+          && cause.getMessage().contains("Invalid chunk length")) {
+        String pngFileName = "(" + (fileName == null ? "not given PNG file name" : fileName) + ")";
+        System.err.println(
+            "The PNG file"
+                + pngFileName
+                + " cannot be decoded. This may be due to an OpenJDK issue with certain PNG files."
+                + " See https://github.com/robolectric/robolectric/issues/6812 for more details.");
+      }
+      throw new RuntimeException(e);
+    }
+  }
+
+  static boolean scaledBitmap(Bitmap src, Bitmap dst, boolean filter) {
+    if (src == null || dst == null) {
+      return false;
+    }
+    int srcWidth = src.getWidth();
+    int srcHeight = src.getHeight();
+    int dstWidth = dst.getWidth();
+    int dstHeight = dst.getHeight();
+    if (srcWidth <= 0 || srcHeight <= 0 || dstWidth <= 0 || dstHeight <= 0) {
+      return false;
+    }
+    BufferedImage before = ((ShadowBitmap) Shadow.extract(src)).getBufferedImage();
+    if (before == null || before.getColorModel() == null) {
+      return false;
+    }
+    int imageType = getBufferedImageType(src.getConfig(), before.getColorModel().hasAlpha());
+    BufferedImage after = new BufferedImage(dstWidth, dstHeight, imageType);
+    Graphics2D graphics2D = after.createGraphics();
+    graphics2D.setRenderingHint(
+        RenderingHints.KEY_INTERPOLATION,
+        filter ? VALUE_INTERPOLATION_BILINEAR : VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+    graphics2D.drawImage(before, 0, 0, dstWidth, dstHeight, 0, 0, srcWidth, srcHeight, null);
+    graphics2D.dispose();
+    ((ShadowBitmap) Shadow.extract(dst)).setBufferedImage(after);
+    return true;
+  }
+
+  public static boolean writeToStream(
+      Bitmap realBitmap, CompressFormat format, int quality, OutputStream stream) {
+    if ((quality < 0) || (quality > 100)) {
+      throw new IllegalArgumentException("Quality out of bounds!");
+    }
+
+    try {
+      ImageWriter writer = null;
+      Iterator<ImageWriter> iter = ImageIO.getImageWritersByFormatName(getFormatName(format));
+      if (iter.hasNext()) {
+        writer = iter.next();
+      }
+      if (writer == null) {
+        return false;
+      }
+      try (ImageOutputStream ios = ImageIO.createImageOutputStream(stream)) {
+        writer.setOutput(ios);
+        ImageWriteParam iwparam = writer.getDefaultWriteParam();
+        iwparam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
+        iwparam.setCompressionQuality((quality / 100f));
+        int width = realBitmap.getWidth();
+        int height = realBitmap.getHeight();
+        boolean needAlphaChannel = needAlphaChannel(format);
+        BufferedImage bufferedImage = Shadows.shadowOf(realBitmap).getBufferedImage();
+        if (bufferedImage == null) {
+          bufferedImage =
+              new BufferedImage(
+                  realBitmap.getWidth(), realBitmap.getHeight(), BufferedImage.TYPE_INT_ARGB);
+        }
+        int outputImageType = getBufferedImageType(realBitmap.getConfig(), needAlphaChannel);
+        if (outputImageType != BufferedImage.TYPE_INT_ARGB) {
+          // re-encode image data with a type that is compatible with the output format.
+          BufferedImage outputBufferedImage = new BufferedImage(width, height, outputImageType);
+          Graphics2D g = outputBufferedImage.createGraphics();
+          g.drawImage(bufferedImage, 0, 0, null);
+          g.dispose();
+          bufferedImage = outputBufferedImage;
+        }
+        writer.write(null, new IIOImage(bufferedImage, null, null), iwparam);
+        ios.flush();
+        writer.dispose();
+      }
+    } catch (IOException ignore) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private static String getFormatName(CompressFormat compressFormat) {
+    switch (compressFormat) {
+      case JPEG:
+        return FORMAT_NAME_JPEG;
+      case WEBP:
+      case WEBP_LOSSY:
+      case WEBP_LOSSLESS:
+      case PNG:
+        return FORMAT_NAME_PNG;
+    }
+    throw new UnsupportedOperationException("Cannot convert format: " + compressFormat);
+  }
+
+  private static boolean needAlphaChannel(CompressFormat compressFormat) {
+    return !FORMAT_NAME_JPEG.equals(getFormatName(compressFormat));
+  }
+
+  private static int getBufferedImageType(Bitmap.Config config, boolean needAlphaChannel) {
+    if (config == null) {
+      return needAlphaChannel ? TYPE_INT_ARGB : TYPE_INT_RGB;
+    }
+    switch (config) {
+      case RGB_565:
+        return BufferedImage.TYPE_USHORT_565_RGB;
+      case RGBA_F16:
+        return needAlphaChannel ? TYPE_INT_ARGB_PRE : TYPE_INT_RGB;
+      case ALPHA_8:
+      case ARGB_4444:
+      case ARGB_8888:
+      case HARDWARE:
+      default:
+        return needAlphaChannel ? TYPE_INT_ARGB : TYPE_INT_RGB;
+    }
+  }
+
+  @AutoValue
+  abstract static class RobolectricBufferedImage {
+    abstract BufferedImage getBufferedImage();
+
+    abstract String getMimeType();
+
+    public Point getWidthAndHeight() {
+      return new Point(getBufferedImage().getWidth(), getBufferedImage().getHeight());
+    }
+
+    static RobolectricBufferedImage create(BufferedImage bufferedImage, String mimeType) {
+      return new AutoValue_ImageUtil_RobolectricBufferedImage(bufferedImage, mimeType);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/LegacyManifestParser.java b/shadows/framework/src/main/java/org/robolectric/shadows/LegacyManifestParser.java
new file mode 100644
index 0000000..760c7b7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/LegacyManifestParser.java
@@ -0,0 +1,602 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP;
+import static android.content.pm.ApplicationInfo.FLAG_ALLOW_CLEAR_USER_DATA;
+import static android.content.pm.ApplicationInfo.FLAG_ALLOW_TASK_REPARENTING;
+import static android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE;
+import static android.content.pm.ApplicationInfo.FLAG_HAS_CODE;
+import static android.content.pm.ApplicationInfo.FLAG_KILL_AFTER_RESTORE;
+import static android.content.pm.ApplicationInfo.FLAG_PERSISTENT;
+import static android.content.pm.ApplicationInfo.FLAG_RESIZEABLE_FOR_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_RESTORE_ANY_VERSION;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_LARGE_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_NORMAL_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_SCREEN_DENSITIES;
+import static android.content.pm.ApplicationInfo.FLAG_SUPPORTS_SMALL_SCREENS;
+import static android.content.pm.ApplicationInfo.FLAG_TEST_ONLY;
+import static android.content.pm.ApplicationInfo.FLAG_VM_SAFE_MODE;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.PatternMatcher.PATTERN_LITERAL;
+import static android.os.PatternMatcher.PATTERN_PREFIX;
+import static android.os.PatternMatcher.PATTERN_SIMPLE_GLOB;
+import static java.util.Arrays.asList;
+
+import android.content.IntentFilter.MalformedMimeTypeException;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ComponentInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageItemInfo;
+import android.content.pm.PackageParser;
+import android.content.pm.PackageParser.Activity;
+import android.content.pm.PackageParser.ActivityIntentInfo;
+import android.content.pm.PackageParser.IntentInfo;
+import android.content.pm.PackageParser.Package;
+import android.content.pm.PackageParser.Permission;
+import android.content.pm.PackageParser.PermissionGroup;
+import android.content.pm.PackageParser.Service;
+import android.content.pm.PackageParser.ServiceIntentInfo;
+import android.content.pm.PathPermission;
+import android.content.pm.PermissionGroupInfo;
+import android.content.pm.PermissionInfo;
+import android.content.pm.ProviderInfo;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Process;
+import android.util.Pair;
+import com.google.common.base.Strings;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.manifest.ActivityData;
+import org.robolectric.manifest.AndroidManifest;
+import org.robolectric.manifest.BroadcastReceiverData;
+import org.robolectric.manifest.ContentProviderData;
+import org.robolectric.manifest.IntentFilterData;
+import org.robolectric.manifest.IntentFilterData.DataAuthority;
+import org.robolectric.manifest.PackageItemData;
+import org.robolectric.manifest.PathPermissionData;
+import org.robolectric.manifest.PermissionGroupItemData;
+import org.robolectric.manifest.PermissionItemData;
+import org.robolectric.manifest.ServiceData;
+import org.robolectric.res.AttributeResource;
+import org.robolectric.res.ResName;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Creates a {@link PackageInfo} from a {@link AndroidManifest} */
+@SuppressWarnings("NewApi")
+public class LegacyManifestParser {
+
+  private static final List<Pair<String, Integer>> APPLICATION_FLAGS =
+      asList(
+          Pair.create("android:allowBackup", FLAG_ALLOW_BACKUP),
+          Pair.create("android:allowClearUserData", FLAG_ALLOW_CLEAR_USER_DATA),
+          Pair.create("android:allowTaskReparenting", FLAG_ALLOW_TASK_REPARENTING),
+          Pair.create("android:debuggable", FLAG_DEBUGGABLE),
+          Pair.create("android:hasCode", FLAG_HAS_CODE),
+          Pair.create("android:killAfterRestore", FLAG_KILL_AFTER_RESTORE),
+          Pair.create("android:persistent", FLAG_PERSISTENT),
+          Pair.create("android:resizeable", FLAG_RESIZEABLE_FOR_SCREENS),
+          Pair.create("android:restoreAnyVersion", FLAG_RESTORE_ANY_VERSION),
+          Pair.create("android:largeScreens", FLAG_SUPPORTS_LARGE_SCREENS),
+          Pair.create("android:normalScreens", FLAG_SUPPORTS_NORMAL_SCREENS),
+          Pair.create("android:anyDensity", FLAG_SUPPORTS_SCREEN_DENSITIES),
+          Pair.create("android:smallScreens", FLAG_SUPPORTS_SMALL_SCREENS),
+          Pair.create("android:testOnly", FLAG_TEST_ONLY),
+          Pair.create("android:vmSafeMode", FLAG_VM_SAFE_MODE));
+  private static final List<Pair<String, Integer>> CONFIG_OPTIONS =
+      asList(
+          Pair.create("mcc", ActivityInfo.CONFIG_MCC),
+          Pair.create("mnc", ActivityInfo.CONFIG_MNC),
+          Pair.create("locale", ActivityInfo.CONFIG_LOCALE),
+          Pair.create("touchscreen", ActivityInfo.CONFIG_TOUCHSCREEN),
+          Pair.create("keyboard", ActivityInfo.CONFIG_KEYBOARD),
+          Pair.create("keyboardHidden", ActivityInfo.CONFIG_KEYBOARD_HIDDEN),
+          Pair.create("navigation", ActivityInfo.CONFIG_NAVIGATION),
+          Pair.create("screenLayout", ActivityInfo.CONFIG_SCREEN_LAYOUT),
+          Pair.create("fontScale", ActivityInfo.CONFIG_FONT_SCALE),
+          Pair.create("uiMode", ActivityInfo.CONFIG_UI_MODE),
+          Pair.create("orientation", ActivityInfo.CONFIG_ORIENTATION),
+          Pair.create("screenSize", ActivityInfo.CONFIG_SCREEN_SIZE),
+          Pair.create("smallestScreenSize", ActivityInfo.CONFIG_SMALLEST_SCREEN_SIZE));
+
+  public static Package createPackage(AndroidManifest androidManifest) {
+
+    Package pkg = new Package(androidManifest.getPackageName());
+
+    pkg.mVersionName = androidManifest.getVersionName();
+    pkg.mVersionCode = androidManifest.getVersionCode();
+
+    Map<String, PermissionItemData> permissionItemData = androidManifest.getPermissions();
+    for (PermissionItemData itemData : permissionItemData.values()) {
+      Permission permission = new Permission(pkg, createPermissionInfo(pkg, itemData));
+      permission.metaData = permission.info.metaData;
+      pkg.permissions.add(permission);
+    }
+
+    Map<String, PermissionGroupItemData> permissionGroupItemData =
+        androidManifest.getPermissionGroups();
+    for (PermissionGroupItemData itemData : permissionGroupItemData.values()) {
+      PermissionGroup permissionGroup =
+          new PermissionGroup(pkg, createPermissionGroupInfo(pkg, itemData));
+      permissionGroup.metaData = permissionGroup.info.metaData;
+      pkg.permissionGroups.add(permissionGroup);
+    }
+
+    pkg.requestedPermissions.addAll(androidManifest.getUsedPermissions());
+    if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.M) {
+      List<Boolean> permissionsRequired =
+          ReflectionHelpers.getField(pkg, "requestedPermissionsRequired");
+      permissionsRequired.addAll(buildBooleanList(pkg.requestedPermissions.size(), true));
+    }
+
+    pkg.applicationInfo.flags = decodeFlags(androidManifest.getApplicationAttributes());
+    pkg.applicationInfo.targetSdkVersion = androidManifest.getTargetSdkVersion();
+    pkg.applicationInfo.packageName = androidManifest.getPackageName();
+    pkg.applicationInfo.processName = androidManifest.getProcessName();
+    if (!Strings.isNullOrEmpty(androidManifest.getApplicationName())) {
+      pkg.applicationInfo.className =
+          buildClassName(pkg.applicationInfo.packageName, androidManifest.getApplicationName());
+      if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.N_MR1) {
+        pkg.applicationInfo.name = pkg.applicationInfo.className;
+      }
+    }
+    pkg.applicationInfo.metaData = metaDataToBundle(androidManifest.getApplicationMetaData());
+    pkg.applicationInfo.uid = Process.myUid();
+    if (androidManifest.getThemeRef() != null) {
+      pkg.applicationInfo.theme =
+          RuntimeEnvironment.getAppResourceTable()
+              .getResourceId(
+                  ResName.qualifyResName(
+                      androidManifest.getThemeRef().replace("@", ""), pkg.packageName, "style"));
+    }
+
+    int labelRes = 0;
+    if (androidManifest.getLabelRef() != null) {
+      String fullyQualifiedName =
+          ResName.qualifyResName(androidManifest.getLabelRef(), androidManifest.getPackageName());
+      Integer id =
+          fullyQualifiedName == null
+              ? null
+              : RuntimeEnvironment.getAppResourceTable()
+                  .getResourceId(new ResName(fullyQualifiedName));
+      labelRes = id != null ? id : 0;
+    }
+
+    pkg.applicationInfo.labelRes = labelRes;
+    String labelRef = androidManifest.getLabelRef();
+    if (labelRef != null && !labelRef.startsWith("@")) {
+      pkg.applicationInfo.nonLocalizedLabel = labelRef;
+    }
+
+    Map<String, ActivityData> activityDatas = androidManifest.getActivityDatas();
+    for (ActivityData data : activityDatas.values()) {
+      ActivityInfo activityInfo = new ActivityInfo();
+      activityInfo.name = buildClassName(pkg.packageName, data.getName());
+      activityInfo.packageName = pkg.packageName;
+      activityInfo.configChanges = getConfigChanges(data);
+      activityInfo.parentActivityName = data.getParentActivityName();
+      activityInfo.metaData = metaDataToBundle(data.getMetaData().getValueMap());
+      activityInfo.applicationInfo = pkg.applicationInfo;
+      activityInfo.targetActivity = data.getTargetActivityName();
+      activityInfo.exported = data.isExported();
+      activityInfo.permission = data.getPermission();
+      activityInfo.enabled = data.isEnabled();
+      String themeRef;
+
+      // Based on ShadowActivity
+      if (data.getThemeRef() != null) {
+        themeRef = data.getThemeRef();
+      } else {
+        themeRef = androidManifest.getThemeRef();
+      }
+      if (themeRef != null) {
+        activityInfo.theme =
+            RuntimeEnvironment.getAppResourceTable()
+                .getResourceId(
+                    ResName.qualifyResName(themeRef.replace("@", ""), pkg.packageName, "style"));
+      }
+
+      if (data.getLabel() != null) {
+        activityInfo.labelRes =
+            RuntimeEnvironment.getAppResourceTable()
+                .getResourceId(
+                    ResName.qualifyResName(
+                        data.getLabel().replace("@", ""), pkg.packageName, "string"));
+        if (activityInfo.labelRes == 0) {
+          activityInfo.nonLocalizedLabel = data.getLabel();
+        }
+      }
+
+      Activity activity = createActivity(pkg, activityInfo);
+      for (IntentFilterData intentFilterData : data.getIntentFilters()) {
+        ActivityIntentInfo outInfo = new ActivityIntentInfo(activity);
+        populateIntentInfo(intentFilterData, outInfo);
+        activity.intents.add(outInfo);
+      }
+      pkg.activities.add(activity);
+    }
+
+    for (ContentProviderData data : androidManifest.getContentProviders()) {
+      ProviderInfo info = new ProviderInfo();
+      populateComponentInfo(info, pkg, data);
+      info.authority = data.getAuthorities();
+
+      List<PathPermission> permissions = new ArrayList<>();
+      for (PathPermissionData permissionData : data.getPathPermissionDatas()) {
+        permissions.add(createPathPermission(permissionData));
+      }
+      info.pathPermissions = permissions.toArray(new PathPermission[permissions.size()]);
+      info.readPermission = data.getReadPermission();
+      info.writePermission = data.getWritePermission();
+      info.grantUriPermissions = data.getGrantUriPermissions();
+      info.enabled = data.isEnabled();
+      pkg.providers.add(createProvider(pkg, info));
+    }
+
+    for (BroadcastReceiverData data : androidManifest.getBroadcastReceivers()) {
+      ActivityInfo info = new ActivityInfo();
+      populateComponentInfo(info, pkg, data);
+      info.permission = data.getPermission();
+      info.exported = data.isExported();
+      info.enabled = data.isEnabled();
+      Activity receiver = createActivity(pkg, info);
+      for (IntentFilterData intentFilterData : data.getIntentFilters()) {
+        ActivityIntentInfo outInfo = new ActivityIntentInfo(receiver);
+        populateIntentInfo(intentFilterData, outInfo);
+        receiver.intents.add(outInfo);
+      }
+      pkg.receivers.add(receiver);
+    }
+
+    for (ServiceData data : androidManifest.getServices()) {
+      ServiceInfo info = new ServiceInfo();
+      populateComponentInfo(info, pkg, data);
+      info.permission = data.getPermission();
+      info.exported = data.isExported();
+      info.enabled = data.isEnabled();
+
+      Service service = createService(pkg, info);
+      for (IntentFilterData intentFilterData : data.getIntentFilters()) {
+        ServiceIntentInfo outInfo = new ServiceIntentInfo(service);
+        populateIntentInfo(intentFilterData, outInfo);
+        service.intents.add(outInfo);
+      }
+      pkg.services.add(service);
+    }
+
+    String codePath = RuntimeEnvironment.getTempDirectory()
+        .createIfNotExists(pkg.packageName + "-codePath")
+        .toAbsolutePath()
+        .toString();
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      pkg.codePath = codePath;
+    } else {
+      ReflectionHelpers.setField(Package.class, pkg, "mPath", codePath);
+    }
+
+    return pkg;
+  }
+
+  private static PathPermission createPathPermission(PathPermissionData data) {
+    if (!Strings.isNullOrEmpty(data.pathPattern)) {
+      return new PathPermission(
+          data.pathPattern, PATTERN_SIMPLE_GLOB, data.readPermission, data.writePermission);
+    } else if (!Strings.isNullOrEmpty(data.path)) {
+      return new PathPermission(
+          data.path, PATTERN_LITERAL, data.readPermission, data.writePermission);
+    } else if (!Strings.isNullOrEmpty(data.pathPrefix)) {
+      return new PathPermission(
+          data.pathPrefix, PATTERN_PREFIX, data.readPermission, data.writePermission);
+    } else {
+      throw new IllegalStateException("Permission without type");
+    }
+  }
+
+  private static void populateComponentInfo(
+      ComponentInfo outInfo, Package owner, PackageItemData itemData) {
+    populatePackageItemInfo(outInfo, owner, itemData);
+    outInfo.applicationInfo = owner.applicationInfo;
+  }
+
+  private static void populatePackageItemInfo(
+      PackageItemInfo outInfo, Package owner, PackageItemData itemData) {
+    outInfo.name = buildClassName(owner.packageName, itemData.getName());
+    outInfo.packageName = owner.packageName;
+    outInfo.metaData = metaDataToBundle(itemData.getMetaData().getValueMap());
+  }
+
+  private static List<Boolean> buildBooleanList(int size, boolean defaultVal) {
+    Boolean[] barray = new Boolean[size];
+    Arrays.fill(barray, defaultVal);
+    return Arrays.asList(barray);
+  }
+
+  private static PackageParser.Provider createProvider(Package pkg, ProviderInfo info) {
+    PackageParser.Provider provider =
+        ReflectionHelpers.callConstructor(PackageParser.Provider.class);
+    populateComponent(pkg, info, provider);
+    return provider;
+  }
+
+  private static Activity createActivity(Package pkg, ActivityInfo activityInfo) {
+    Activity activity = ReflectionHelpers.callConstructor(Activity.class);
+    populateComponent(pkg, activityInfo, activity);
+    return activity;
+  }
+
+  private static Service createService(Package pkg, ServiceInfo info) {
+    PackageParser.Service service = ReflectionHelpers.callConstructor(PackageParser.Service.class);
+    populateComponent(pkg, info, service);
+    return service;
+  }
+
+  private static void populateComponent(
+      Package pkg, ComponentInfo info, PackageParser.Component component) {
+    ReflectionHelpers.setField(component, "info", info);
+    ReflectionHelpers.setField(component, "intents", new ArrayList<>());
+    ReflectionHelpers.setField(component, "owner", pkg);
+    ReflectionHelpers.setField(component, "className", info.name);
+  }
+
+  private static void populateIntentInfo(IntentFilterData intentFilterData, IntentInfo outInfo) {
+    for (String action : intentFilterData.getActions()) {
+      outInfo.addAction(action);
+    }
+    for (String category : intentFilterData.getCategories()) {
+      outInfo.addCategory(category);
+    }
+    for (DataAuthority dataAuthority : intentFilterData.getAuthorities()) {
+      outInfo.addDataAuthority(dataAuthority.getHost(), dataAuthority.getPort());
+    }
+    for (String mimeType : intentFilterData.getMimeTypes()) {
+      try {
+        outInfo.addDataType(mimeType);
+      } catch (MalformedMimeTypeException e) {
+        throw new RuntimeException(e);
+      }
+    }
+    for (String scheme : intentFilterData.getSchemes()) {
+      outInfo.addDataScheme(scheme);
+    }
+    for (String pathPattern : intentFilterData.getPathPatterns()) {
+      outInfo.addDataPath(pathPattern, PATTERN_SIMPLE_GLOB);
+    }
+    for (String pathPattern : intentFilterData.getPathPrefixes()) {
+      outInfo.addDataPath(pathPattern, PATTERN_PREFIX);
+    }
+    for (String pathPattern : intentFilterData.getPaths()) {
+      outInfo.addDataPath(pathPattern, PATTERN_LITERAL);
+    }
+  }
+
+  private static int getConfigChanges(ActivityData activityData) {
+    String s = activityData.getConfigChanges();
+
+    int res = 0;
+
+    // quick sanity check.
+    if (s == null || "".equals(s)) {
+      return res;
+    }
+
+    String[] pieces = s.split("\\|", 0);
+
+    for (String s1 : pieces) {
+      s1 = s1.trim();
+
+      for (Pair<String, Integer> pair : CONFIG_OPTIONS) {
+        if (s1.equals(pair.first)) {
+          res |= pair.second;
+          break;
+        }
+      }
+    }
+
+    // Matches platform behavior
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.O) {
+      res |= ActivityInfo.CONFIG_MNC;
+      res |= ActivityInfo.CONFIG_MCC;
+    }
+
+    return res;
+  }
+
+  private static int decodeFlags(Map<String, String> applicationAttributes) {
+    int applicationFlags = 0;
+    for (Pair<String, Integer> pair : APPLICATION_FLAGS) {
+      if ("true".equals(applicationAttributes.get(pair.first))) {
+        applicationFlags |= pair.second;
+      }
+    }
+    return applicationFlags;
+  }
+
+  private static PermissionInfo createPermissionInfo(Package owner, PermissionItemData itemData) {
+    PermissionInfo permissionInfo = new PermissionInfo();
+    populatePackageItemInfo(permissionInfo, owner, itemData);
+
+    permissionInfo.group = itemData.getPermissionGroup();
+    permissionInfo.protectionLevel = decodeProtectionLevel(itemData.getProtectionLevel());
+    permissionInfo.metaData = metaDataToBundle(itemData.getMetaData().getValueMap());
+
+    String descriptionRef = itemData.getDescription();
+    if (descriptionRef != null) {
+      ResName descResName =
+          AttributeResource.getResourceReference(descriptionRef, owner.packageName, "string");
+      permissionInfo.descriptionRes =
+          RuntimeEnvironment.getAppResourceTable().getResourceId(descResName);
+    }
+
+    String labelRefOrString = itemData.getLabel();
+    if (labelRefOrString != null) {
+      if (AttributeResource.isResourceReference(labelRefOrString)) {
+        ResName labelResName =
+            AttributeResource.getResourceReference(labelRefOrString, owner.packageName, "string");
+        permissionInfo.labelRes =
+            RuntimeEnvironment.getAppResourceTable().getResourceId(labelResName);
+      } else {
+        permissionInfo.nonLocalizedLabel = labelRefOrString;
+      }
+    }
+
+    return permissionInfo;
+  }
+
+  private static PermissionGroupInfo createPermissionGroupInfo(Package owner,
+      PermissionGroupItemData itemData) {
+    PermissionGroupInfo permissionGroupInfo = new PermissionGroupInfo();
+    populatePackageItemInfo(permissionGroupInfo, owner, itemData);
+
+    permissionGroupInfo.metaData = metaDataToBundle(itemData.getMetaData().getValueMap());
+
+    String descriptionRef = itemData.getDescription();
+    if (descriptionRef != null) {
+      ResName descResName =
+          AttributeResource.getResourceReference(descriptionRef, owner.packageName, "string");
+      permissionGroupInfo.descriptionRes =
+          RuntimeEnvironment.getAppResourceTable().getResourceId(descResName);
+    }
+
+    String labelRefOrString = itemData.getLabel();
+    if (labelRefOrString != null) {
+      if (AttributeResource.isResourceReference(labelRefOrString)) {
+        ResName labelResName =
+            AttributeResource.getResourceReference(labelRefOrString, owner.packageName, "string");
+        permissionGroupInfo.labelRes =
+            RuntimeEnvironment.getAppResourceTable().getResourceId(labelResName);
+      } else {
+        permissionGroupInfo.nonLocalizedLabel = labelRefOrString;
+      }
+    }
+
+    return permissionGroupInfo;
+  }
+
+  private static int decodeProtectionLevel(String protectionLevel) {
+    if (protectionLevel == null) {
+      return PermissionInfo.PROTECTION_NORMAL;
+    }
+
+    int permissions = PermissionInfo.PROTECTION_NORMAL;
+    String[] levels = protectionLevel.split("\\|", 0);
+
+    for (String level : levels) {
+      switch (level) {
+        case "normal":
+          permissions |= PermissionInfo.PROTECTION_NORMAL;
+          break;
+        case "dangerous":
+          permissions |= PermissionInfo.PROTECTION_DANGEROUS;
+          break;
+        case "signature":
+          permissions |= PermissionInfo.PROTECTION_SIGNATURE;
+          break;
+        case "signatureOrSystem":
+          permissions |= PermissionInfo.PROTECTION_SIGNATURE_OR_SYSTEM;
+          break;
+        case "privileged":
+          permissions |= PermissionInfo.PROTECTION_FLAG_PRIVILEGED;
+          break;
+        case "system":
+          permissions |= PermissionInfo.PROTECTION_FLAG_SYSTEM;
+          break;
+        case "development":
+          permissions |= PermissionInfo.PROTECTION_FLAG_DEVELOPMENT;
+          break;
+        case "appop":
+          permissions |= PermissionInfo.PROTECTION_FLAG_APPOP;
+          break;
+        case "pre23":
+          permissions |= PermissionInfo.PROTECTION_FLAG_PRE23;
+          break;
+        case "installer":
+          permissions |= PermissionInfo.PROTECTION_FLAG_INSTALLER;
+          break;
+        case "verifier":
+          permissions |= PermissionInfo.PROTECTION_FLAG_VERIFIER;
+          break;
+        case "preinstalled":
+          permissions |= PermissionInfo.PROTECTION_FLAG_PREINSTALLED;
+          break;
+        case "setup":
+          permissions |= PermissionInfo.PROTECTION_FLAG_SETUP;
+          break;
+        case "instant":
+          permissions |= PermissionInfo.PROTECTION_FLAG_INSTANT;
+          break;
+        case "runtime":
+          permissions |= PermissionInfo.PROTECTION_FLAG_RUNTIME_ONLY;
+          break;
+        case "oem":
+          permissions |= PermissionInfo.PROTECTION_FLAG_OEM;
+          break;
+        case "vendorPrivileged":
+          permissions |= PermissionInfo.PROTECTION_FLAG_VENDOR_PRIVILEGED;
+          break;
+        case "textClassifier":
+          permissions |= PermissionInfo.PROTECTION_FLAG_SYSTEM_TEXT_CLASSIFIER;
+          break;
+        default:
+          throw new IllegalArgumentException("unknown protection level " + protectionLevel);
+      }
+    }
+    return permissions;
+  }
+
+  /**
+   * Goes through the meta data and puts each value in to a bundle as the correct type.
+   *
+   * <p>Note that this will convert resource identifiers specified via the value attribute as well.
+   *
+   * @param meta Meta data to put in to a bundle
+   * @return bundle containing the meta data
+   */
+  private static Bundle metaDataToBundle(Map<String, Object> meta) {
+    if (meta.size() == 0) {
+      return null;
+    }
+
+    Bundle bundle = new Bundle();
+
+    for (Map.Entry<String, Object> entry : meta.entrySet()) {
+      String key = entry.getKey();
+      Object value = entry.getValue();
+      if (Boolean.class.isInstance(value)) {
+        bundle.putBoolean(key, (Boolean) value);
+      } else if (Float.class.isInstance(value)) {
+        bundle.putFloat(key, (Float) value);
+      } else if (Integer.class.isInstance(value)) {
+        bundle.putInt(key, (Integer) value);
+      } else {
+        bundle.putString(key, value == null ? null : value.toString());
+      }
+    }
+    return bundle;
+  }
+
+  private static String buildClassName(String pkg, String cls) {
+    if (Strings.isNullOrEmpty(cls)) {
+      throw new IllegalArgumentException("Empty class name in package " + pkg);
+    }
+    char c = cls.charAt(0);
+    if (c == '.') {
+      return (pkg + cls).intern();
+    }
+    if (cls.indexOf('.') < 0) {
+      StringBuilder b = new StringBuilder(pkg);
+      b.append('.');
+      b.append(cls);
+      return b.toString();
+    }
+    return cls;
+    // TODO: consider reenabling this for stricter platform-complaint checking
+    // if (c >= 'a' && c <= 'z') {
+    // return cls;
+    // }
+    // throw new IllegalArgumentException("Bad class name " + cls + " in package " + pkg);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/LooperShadowPicker.java b/shadows/framework/src/main/java/org/robolectric/shadows/LooperShadowPicker.java
new file mode 100644
index 0000000..5c12213
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/LooperShadowPicker.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.shadow.api.ShadowPicker;
+
+public class LooperShadowPicker<T> implements ShadowPicker<T> {
+
+  private Class<? extends T> legacyShadowClass;
+  private Class<? extends T> pausedShadowClass;
+
+  public LooperShadowPicker(
+      Class<? extends T> legacyShadowClass, Class<? extends T> pausedShadowClass) {
+    this.legacyShadowClass = legacyShadowClass;
+    this.pausedShadowClass = pausedShadowClass;
+  }
+
+  @Override
+  public Class<? extends T> pickShadowClass() {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      return pausedShadowClass;
+    } else {
+      return legacyShadowClass;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java
new file mode 100644
index 0000000..841f7d5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java
@@ -0,0 +1,368 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.AudioCapabilities;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaCodecInfo.EncoderCapabilities;
+import android.media.MediaCodecInfo.VideoCapabilities;
+import android.media.MediaFormat;
+import com.google.common.base.Preconditions;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Reflector;
+import org.robolectric.util.reflector.Static;
+
+/** Builder for {@link MediaCodecInfo}. */
+public class MediaCodecInfoBuilder {
+
+  private String name;
+  private boolean isEncoder;
+  private boolean isVendor;
+  private boolean isSoftwareOnly;
+  private boolean isHardwareAccelerated;
+  private CodecCapabilities[] capabilities = new CodecCapabilities[0];
+
+  private MediaCodecInfoBuilder() {}
+
+  /** Create a new {@link MediaCodecInfoBuilder}. */
+  public static MediaCodecInfoBuilder newBuilder() {
+    return new MediaCodecInfoBuilder();
+  }
+
+  /**
+   * Sets the codec name.
+   *
+   * @param name codec name.
+   * @throws NullPointerException if name is null.
+   */
+  public MediaCodecInfoBuilder setName(String name) {
+    this.name = Preconditions.checkNotNull(name);
+    return this;
+  }
+
+  /**
+   * Sets the codec role.
+   *
+   * @param isEncoder a boolean to indicate whether the codec is an encoder {@code true} or a
+   *     decoder {@code false}. Default value is {@code false}.
+   */
+  public MediaCodecInfoBuilder setIsEncoder(boolean isEncoder) {
+    this.isEncoder = isEncoder;
+    return this;
+  }
+
+  /**
+   * Sets the codec provider.
+   *
+   * @param isVendor a boolean to indicate whether the codec is provided by the device manufacturer
+   *     {@code true} or by the Android platform {@code false}. Default value is {@code false}.
+   */
+  public MediaCodecInfoBuilder setIsVendor(boolean isVendor) {
+    this.isVendor = isVendor;
+    return this;
+  }
+
+  /**
+   * Sets whether the codec is softwrare only or not.
+   *
+   * @param isSoftwareOnly a boolean to indicate whether the codec is software only {@code true} or
+   *     not {@code false}. Default value is {@code false}.
+   */
+  public MediaCodecInfoBuilder setIsSoftwareOnly(boolean isSoftwareOnly) {
+    this.isSoftwareOnly = isSoftwareOnly;
+    return this;
+  }
+
+  /**
+   * Sets whether the codec is hardware accelerated or not.
+   *
+   * @param isHardwareAccelerated a boolean to indicate whether the codec is hardware accelerated
+   *     {@code true} or not {@code false}. Default value is {@code false}.
+   */
+  public MediaCodecInfoBuilder setIsHardwareAccelerated(boolean isHardwareAccelerated) {
+    this.isHardwareAccelerated = isHardwareAccelerated;
+    return this;
+  }
+
+  /**
+   * Sets codec capabilities.
+   *
+   * <p>Use {@link CodecCapabilitiesBuilder} can be to create an instance of {@link
+   * CodecCapabilities}.
+   *
+   * @param capabilities one or multiple {@link CodecCapabilities}.
+   * @throws NullPointerException if capabilities is null.
+   */
+  public MediaCodecInfoBuilder setCapabilities(CodecCapabilities... capabilities) {
+    this.capabilities = capabilities;
+    return this;
+  }
+
+  public MediaCodecInfo build() {
+    Preconditions.checkNotNull(name, "Codec name is not set.");
+
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      int flags = getCodecFlags();
+      return ReflectionHelpers.callConstructor(
+          MediaCodecInfo.class,
+          ClassParameter.from(String.class, name),
+          ClassParameter.from(String.class, name), // canonicalName
+          ClassParameter.from(int.class, flags),
+          ClassParameter.from(CodecCapabilities[].class, capabilities));
+    } else if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      return ReflectionHelpers.callConstructor(
+          MediaCodecInfo.class,
+          ClassParameter.from(String.class, name),
+          ClassParameter.from(boolean.class, isEncoder),
+          ClassParameter.from(CodecCapabilities[].class, capabilities));
+    } else {
+      throw new UnsupportedOperationException("Unable to create MediaCodecInfo");
+    }
+  }
+
+  /** Accessor interface for {@link MediaCodecInfo}'s internals. */
+  @ForType(MediaCodecInfo.class)
+  interface MediaCodecInfoReflector {
+
+    @Static
+    @Accessor("FLAG_IS_ENCODER")
+    int getIsEncoderFlagValue();
+
+    @Static
+    @Accessor("FLAG_IS_VENDOR")
+    int getIsVendorFlagValue();
+
+    @Static
+    @Accessor("FLAG_IS_SOFTWARE_ONLY")
+    int getIsSoftwareOnlyFlagValue();
+
+    @Static
+    @Accessor("FLAG_IS_HARDWARE_ACCELERATED")
+    int getIsHardwareAcceleratedFlagValue();
+  }
+
+  /** Convert the boolean flags describing codec to values recognized by {@link MediaCodecInfo}. */
+  private int getCodecFlags() {
+    MediaCodecInfoReflector mediaCodecInfoReflector =
+        Reflector.reflector(MediaCodecInfoReflector.class);
+
+    int flags = 0;
+
+    if (isEncoder) {
+      flags |= mediaCodecInfoReflector.getIsEncoderFlagValue();
+    }
+    if (isVendor) {
+      flags |= mediaCodecInfoReflector.getIsVendorFlagValue();
+    }
+    if (isSoftwareOnly) {
+      flags |= mediaCodecInfoReflector.getIsSoftwareOnlyFlagValue();
+    }
+    if (isHardwareAccelerated) {
+      flags |= mediaCodecInfoReflector.getIsHardwareAcceleratedFlagValue();
+    }
+
+    return flags;
+  }
+
+  /** Builder for {@link CodecCapabilities}. */
+  public static class CodecCapabilitiesBuilder {
+    private MediaFormat mediaFormat;
+    private boolean isEncoder;
+    private CodecProfileLevel[] profileLevels = new CodecProfileLevel[0];
+    private int[] colorFormats;
+
+    private CodecCapabilitiesBuilder() {}
+
+    /** Creates a new {@link CodecCapabilitiesBuilder}. */
+    public static CodecCapabilitiesBuilder newBuilder() {
+      return new CodecCapabilitiesBuilder();
+    }
+
+    /**
+     * Sets media format.
+     *
+     * @param mediaFormat a {@link MediaFormat} supported by the codec. It is a requirement for
+     *     mediaFormat to have {@link MediaFormat.KEY_MIME} set. Other keys are optional.
+     * @throws {@link NullPointerException} if mediaFormat is null.
+     * @throws {@link IllegalArgumentException} if mediaFormat does not have {@link
+     *     MediaFormat.KEY_MIME}.
+     */
+    public CodecCapabilitiesBuilder setMediaFormat(MediaFormat mediaFormat) {
+      Preconditions.checkNotNull(mediaFormat);
+      Preconditions.checkArgument(
+          mediaFormat.getString(MediaFormat.KEY_MIME) != null,
+          "MIME type of the format is not set.");
+      this.mediaFormat = mediaFormat;
+      return this;
+    }
+
+    /**
+     * Sets codec role.
+     *
+     * @param isEncoder a boolean to indicate whether the codec is an encoder or a decoder. Default
+     *     value is false.
+     */
+    public CodecCapabilitiesBuilder setIsEncoder(boolean isEncoder) {
+      this.isEncoder = isEncoder;
+      return this;
+    }
+
+    /**
+     * Sets profiles and levels.
+     *
+     * @param profileLevels an array of {@link MediaCodecInfo.CodecProfileLevel} supported by the
+     *     codec.
+     * @throws {@link NullPointerException} if profileLevels is null.
+     */
+    public CodecCapabilitiesBuilder setProfileLevels(CodecProfileLevel[] profileLevels) {
+      this.profileLevels = Preconditions.checkNotNull(profileLevels);
+      return this;
+    }
+
+    /**
+     * Sets color formats.
+     *
+     * @param colorFormats an array of color formats supported by the video codec. Refer to {@link
+     *     CodecCapabilities} for possible values.
+     */
+    public CodecCapabilitiesBuilder setColorFormats(int[] colorFormats) {
+      this.colorFormats = colorFormats;
+      return this;
+    }
+
+    /** Accessor interface for {@link CodecCapabilities}'s internals. */
+    @ForType(CodecCapabilities.class)
+    interface CodecCapabilitiesReflector {
+
+      @Accessor("mMime")
+      void setMime(String mime);
+
+      @Accessor("mMaxSupportedInstances")
+      void setMaxSupportedInstances(int maxSupportedInstances);
+
+      @Accessor("mDefaultFormat")
+      void setDefaultFormat(MediaFormat mediaFormat);
+
+      @Accessor("mCapabilitiesInfo")
+      void setCapabilitiesInfo(MediaFormat mediaFormat);
+
+      @Accessor("mVideoCaps")
+      void setVideoCaps(VideoCapabilities videoCaps);
+
+      @Accessor("mAudioCaps")
+      void setAudioCaps(AudioCapabilities audioCaps);
+
+      @Accessor("mEncoderCaps")
+      void setEncoderCaps(EncoderCapabilities encoderCaps);
+
+      @Accessor("mFlagsSupported")
+      void setFlagsSupported(int flagsSupported);
+    }
+
+    public CodecCapabilities build() {
+      Preconditions.checkNotNull(mediaFormat, "mediaFormat is not set.");
+      Preconditions.checkNotNull(profileLevels, "profileLevels is not set.");
+
+      final String mime = mediaFormat.getString(MediaFormat.KEY_MIME);
+      final boolean isVideoCodec = mime.startsWith("video/");
+
+      CodecCapabilities caps = new CodecCapabilities();
+      CodecCapabilitiesReflector capsReflector =
+          Reflector.reflector(CodecCapabilitiesReflector.class, caps);
+
+      caps.profileLevels = profileLevels;
+      if (isVideoCodec) {
+        Preconditions.checkNotNull(colorFormats, "colorFormats should not be null for video codec");
+        caps.colorFormats = colorFormats;
+      } else {
+        Preconditions.checkArgument(
+            colorFormats == null || colorFormats.length == 0,
+            "colorFormats should not be set for audio codec");
+        caps.colorFormats = new int[0]; // To prevet crash in CodecCapabilities.dup().
+      }
+
+      capsReflector.setMime(mime);
+      if (RuntimeEnvironment.getApiLevel() >= Q) {
+        capsReflector.setMaxSupportedInstances(32);
+      }
+
+      capsReflector.setDefaultFormat(mediaFormat);
+      capsReflector.setCapabilitiesInfo(mediaFormat);
+
+      if (isVideoCodec) {
+        VideoCapabilities videoCaps = createDefaultVideoCapabilities(caps, mediaFormat);
+        capsReflector.setVideoCaps(videoCaps);
+      } else {
+        AudioCapabilities audioCaps = createDefaultAudioCapabilities(caps, mediaFormat);
+        capsReflector.setAudioCaps(audioCaps);
+      }
+
+      if (isEncoder) {
+        EncoderCapabilities encoderCaps = createDefaultEncoderCapabilities(caps, mediaFormat);
+        capsReflector.setEncoderCaps(encoderCaps);
+      }
+
+      if (RuntimeEnvironment.getApiLevel() >= Q) {
+        int flagsSupported = getSupportedFeatures(caps, mediaFormat);
+        capsReflector.setFlagsSupported(flagsSupported);
+      }
+
+      return caps;
+    }
+
+    /** Create a default {@link AudioCapabilities} for a given {@link MediaFormat}. */
+    private static AudioCapabilities createDefaultAudioCapabilities(
+        CodecCapabilities parent, MediaFormat mediaFormat) {
+      return ReflectionHelpers.callStaticMethod(
+          AudioCapabilities.class,
+          "create",
+          ClassParameter.from(MediaFormat.class, mediaFormat),
+          ClassParameter.from(CodecCapabilities.class, parent));
+    }
+
+    /** Create a default {@link VideoCapabilities} for a given {@link MediaFormat}. */
+    private static VideoCapabilities createDefaultVideoCapabilities(
+        CodecCapabilities parent, MediaFormat mediaFormat) {
+      return ReflectionHelpers.callStaticMethod(
+          VideoCapabilities.class,
+          "create",
+          ClassParameter.from(MediaFormat.class, mediaFormat),
+          ClassParameter.from(CodecCapabilities.class, parent));
+    }
+
+    /** Create a default {@link EncoderCapabilities} for a given {@link MediaFormat}. */
+    private static EncoderCapabilities createDefaultEncoderCapabilities(
+        CodecCapabilities parent, MediaFormat mediaFormat) {
+      return ReflectionHelpers.callStaticMethod(
+          EncoderCapabilities.class,
+          "create",
+          ClassParameter.from(MediaFormat.class, mediaFormat),
+          ClassParameter.from(CodecCapabilities.class, parent));
+    }
+
+    /**
+     * Read codec features from a given {@link MediaFormat} and convert them to values recognized by
+     * {@link CodecCapabilities}.
+     */
+    private static int getSupportedFeatures(CodecCapabilities parent, MediaFormat mediaFormat) {
+      int flagsSupported = 0;
+      Object[] validFeatures = ReflectionHelpers.callInstanceMethod(parent, "getValidFeatures");
+      for (Object validFeature : validFeatures) {
+        String featureName = (String) ReflectionHelpers.getField(validFeature, "mName");
+        int featureValue = (int) ReflectionHelpers.getField(validFeature, "mValue");
+        if (mediaFormat.containsFeature(featureName)
+            && mediaFormat.getFeatureEnabled(featureName)) {
+          flagsSupported |= featureValue;
+        }
+      }
+      return flagsSupported;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ModuleInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ModuleInfoBuilder.java
new file mode 100644
index 0000000..39063ca
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ModuleInfoBuilder.java
@@ -0,0 +1,63 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.content.pm.ModuleInfo;
+import androidx.annotation.Nullable;
+
+/**
+ * Builder for {@link ModuleInfo} as ModuleInfo has hidden constructors, this builder class has been
+ * added as a way to make custom ModuleInfo objects when needed.
+ */
+public final class ModuleInfoBuilder {
+
+  @Nullable private CharSequence name;
+  @Nullable private String packageName;
+  private boolean hidden = false;
+
+  private ModuleInfoBuilder() {}
+
+  /**
+   * Start building a new ModuleInfo
+   *
+   * @return a new instance of {@link ModuleInfoBuilder}.
+   */
+  public static ModuleInfoBuilder newBuilder() {
+    return new ModuleInfoBuilder();
+  }
+
+  /** Sets the public name of the module */
+  public ModuleInfoBuilder setName(CharSequence name) {
+    this.name = name;
+    return this;
+  }
+
+  /** Sets the package name of the module */
+  public ModuleInfoBuilder setPackageName(String packageName) {
+    this.packageName = packageName;
+    return this;
+  }
+
+  /** Sets whether or not the module is hidden */
+  public ModuleInfoBuilder setHidden(boolean hidden) {
+    this.hidden = hidden;
+    return this;
+  }
+
+  /**
+   * Returns a {@link ModuleInfo} with the data that was given. Both name and packageName are
+   * mandatory to build, but hidden is optional, if no value was given will default to false
+   */
+  public ModuleInfo build() {
+    // Check mandatory fields.
+    checkNotNull(name, "Mandatory field 'name' missing.");
+    checkNotNull(packageName, "Mandatory field 'packageName' missing.");
+
+    ModuleInfo moduleInfo = new ModuleInfo();
+    moduleInfo.setName(name);
+    moduleInfo.setPackageName(packageName);
+    moduleInfo.setHidden(hidden);
+
+    return moduleInfo;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/NativeAndroidInput.java b/shadows/framework/src/main/java/org/robolectric/shadows/NativeAndroidInput.java
new file mode 100644
index 0000000..c83728d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/NativeAndroidInput.java
@@ -0,0 +1,1101 @@
+package org.robolectric.shadows;
+
+/**
+ * Java representation of framework native system headers Transliterated from oreo-mr1 (SDK 27)
+ * frameworks/native/include/android/Input.h
+ *
+ * @see <a href="https://android.googlesource.com/platform/frameworks/native/+/oreo-mr1-release/include/android/input.h">include/android/input.h</a>
+ */
+public class NativeAndroidInput {
+
+  /**
+   * Key states (may be returned by queries about the current state of a particular key code, scan
+   * code or switch).
+   */
+  /** The key state is unknown or the requested key itself is not supported. */
+  static final int AKEY_STATE_UNKNOWN = -1;
+  /** The key is up. */
+  static final int AKEY_STATE_UP = 0;
+  /** The key is down. */
+  static final int AKEY_STATE_DOWN = 1;
+  /** The key is down but is a virtual key press that is being emulated by the system. */
+  static final int AKEY_STATE_VIRTUAL = 2;
+
+  /** Meta key / modifer state. */
+  /** No meta keys are pressed. */
+  static final int AMETA_NONE = 0;
+  /** This mask is used to check whether one of the ALT meta keys is pressed. */
+  static final int AMETA_ALT_ON = 0x02;
+  /** This mask is used to check whether the left ALT meta key is pressed. */
+  static final int AMETA_ALT_LEFT_ON = 0x10;
+  /** This mask is used to check whether the right ALT meta key is pressed. */
+  static final int AMETA_ALT_RIGHT_ON = 0x20;
+  /** This mask is used to check whether one of the SHIFT meta keys is pressed. */
+  static final int AMETA_SHIFT_ON = 0x01;
+  /** This mask is used to check whether the left SHIFT meta key is pressed. */
+  static final int AMETA_SHIFT_LEFT_ON = 0x40;
+  /** This mask is used to check whether the right SHIFT meta key is pressed. */
+  static final int AMETA_SHIFT_RIGHT_ON = 0x80;
+  /** This mask is used to check whether the SYM meta key is pressed. */
+  static final int AMETA_SYM_ON = 0x04;
+  /** This mask is used to check whether the FUNCTION meta key is pressed. */
+  static final int AMETA_FUNCTION_ON = 0x08;
+  /** This mask is used to check whether one of the CTRL meta keys is pressed. */
+  static final int AMETA_CTRL_ON = 0x1000;
+  /** This mask is used to check whether the left CTRL meta key is pressed. */
+  static final int AMETA_CTRL_LEFT_ON = 0x2000;
+  /** This mask is used to check whether the right CTRL meta key is pressed. */
+  static final int AMETA_CTRL_RIGHT_ON = 0x4000;
+  /** This mask is used to check whether one of the META meta keys is pressed. */
+  static final int AMETA_META_ON = 0x10000;
+  /** This mask is used to check whether the left META meta key is pressed. */
+  static final int AMETA_META_LEFT_ON = 0x20000;
+  /** This mask is used to check whether the right META meta key is pressed. */
+  static final int AMETA_META_RIGHT_ON = 0x40000;
+  /** This mask is used to check whether the CAPS LOCK meta key is on. */
+  static final int AMETA_CAPS_LOCK_ON = 0x100000;
+  /** This mask is used to check whether the NUM LOCK meta key is on. */
+  static final int AMETA_NUM_LOCK_ON = 0x200000;
+  /** This mask is used to check whether the SCROLL LOCK meta key is on. */
+  static final int AMETA_SCROLL_LOCK_ON = 0x400000;
+
+  /** Input event types. */
+  /** Indicates that the input event is a key event. */
+  static final int AINPUT_EVENT_TYPE_KEY = 1;
+  /** Indicates that the input event is a motion event. */
+  static final int AINPUT_EVENT_TYPE_MOTION = 2;
+
+  /** Key event actions. */
+  /** The key has been pressed down. */
+  static final int AKEY_EVENT_ACTION_DOWN = 0;
+  /** The key has been released. */
+  static final int AKEY_EVENT_ACTION_UP = 1;
+  /**
+   * Multiple duplicate key events have occurred in a row, or a complex string is being delivered.
+   * The repeat_count property of the key event contains the number of times the given key code
+   * should be executed.
+   */
+  static final int AKEY_EVENT_ACTION_MULTIPLE = 2;
+
+  /** Key event flags. */
+
+  /** This mask is set if the device woke because of this key event. */
+  static final int AKEY_EVENT_FLAG_WOKE_HERE = 0x1;
+  /** This mask is set if the key event was generated by a software keyboard. */
+  static final int AKEY_EVENT_FLAG_SOFT_KEYBOARD = 0x2;
+  /** This mask is set if we don't want the key event to cause us to leave touch mode. */
+  static final int AKEY_EVENT_FLAG_KEEP_TOUCH_MODE = 0x4;
+  /**
+   * This mask is set if an event was known to come from a trusted part of the system. That is; the
+   * event is known to come from the user; and could not have been spoofed by a third party
+   * component.
+   */
+  static final int AKEY_EVENT_FLAG_FROM_SYSTEM = 0x8;
+  /**
+   * This mask is used for compatibility; to identify enter keys that are coming from an IME whose
+   * enter key has been auto-labelled "next" or "done". This allows TextView to dispatch these as
+   * normal enter keys for old applications; but still do the appropriate action when receiving
+   * them.
+   */
+  static final int AKEY_EVENT_FLAG_EDITOR_ACTION = 0x10;
+  /**
+   * When associated with up key events; this indicates that the key press has been canceled.
+   * Typically this is used with virtual touch screen keys; where the user can slide from the
+   * virtual key area on to the display: in that case; the application will receive a canceled up
+   * event and should not perform the action normally associated with the key. Note that for this to
+   * work; the application can not perform an action for a key until it receives an up or the long
+   * press timeout has expired.
+   */
+  static final int AKEY_EVENT_FLAG_CANCELED = 0x20;
+  /**
+   * This key event was generated by a virtual (on-screen) hard key area. Typically this is an area
+   * of the touchscreen; outside of the regular display; dedicated to "hardware" buttons.
+   */
+  static final int AKEY_EVENT_FLAG_VIRTUAL_HARD_KEY = 0x40;
+  /** This flag is set for the first key repeat that occurs after the long press timeout. */
+  static final int AKEY_EVENT_FLAG_LONG_PRESS = 0x80;
+  /**
+   * Set when a key event has AKEY_EVENT_FLAG_CANCELED set because a long press action was executed
+   * while it was down.
+   */
+  static final int AKEY_EVENT_FLAG_CANCELED_LONG_PRESS = 0x100;
+  /**
+   * Set for AKEY_EVENT_ACTION_UP when this event's key code is still being tracked from its initial
+   * down. That is; somebody requested that tracking started on the key down and a long press has
+   * not caused the tracking to be canceled.
+   */
+  static final int AKEY_EVENT_FLAG_TRACKING = 0x200;
+  /**
+   * Set when a key event has been synthesized to implement default behavior for an event that the
+   * application did not handle. Fallback key events are generated by unhandled trackball motions
+   * (to emulate a directional keypad) and by certain unhandled key presses that are declared in the
+   * key map (such as special function numeric keypad keys when numlock is off).
+   */
+  static final int AKEY_EVENT_FLAG_FALLBACK = 0x400;
+
+  /**
+   * Bit shift for the action bits holding the pointer index as defined by
+   * AMOTION_EVENT_ACTION_POINTER_INDEX_MASK.
+   */
+  static final int AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT = 8;
+
+  /** Motion event actions */
+
+  /** Bit mask of the parts of the action code that are the action itself. */
+  static final int AMOTION_EVENT_ACTION_MASK = 0xff;
+  /**
+   * Bits in the action code that represent a pointer index; used with
+   * AMOTION_EVENT_ACTION_POINTER_DOWN and AMOTION_EVENT_ACTION_POINTER_UP. Shifting down by
+   * AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT provides the actual pointer index where the data for
+   * the pointer going up or down can be found.
+   */
+  static final int AMOTION_EVENT_ACTION_POINTER_INDEX_MASK = 0xff00;
+  /** A pressed gesture has started; the motion contains the initial starting location. */
+  static final int AMOTION_EVENT_ACTION_DOWN = 0;
+  /**
+   * A pressed gesture has finished; the motion contains the final release location as well as any
+   * intermediate points since the last down or move event.
+   */
+  static final int AMOTION_EVENT_ACTION_UP = 1;
+  /**
+   * A change has happened during a press gesture (between AMOTION_EVENT_ACTION_DOWN and
+   * AMOTION_EVENT_ACTION_UP). The motion contains the most recent point; as well as any
+   * intermediate points since the last down or move event.
+   */
+  static final int AMOTION_EVENT_ACTION_MOVE = 2;
+  /**
+   * The current gesture has been aborted. You will not receive any more points in it. You should
+   * treat this as an up event; but not perform any action that you normally would.
+   */
+  static final int AMOTION_EVENT_ACTION_CANCEL = 3;
+  /**
+   * A movement has happened outside of the normal bounds of the UI element. This does not provide a
+   * full gesture; but only the initial location of the movement/touch.
+   */
+  static final int AMOTION_EVENT_ACTION_OUTSIDE = 4;
+  /**
+   * A non-primary pointer has gone down. The bits in AMOTION_EVENT_ACTION_POINTER_INDEX_MASK
+   * indicate which pointer changed.
+   */
+  static final int AMOTION_EVENT_ACTION_POINTER_DOWN = 5;
+  /**
+   * A non-primary pointer has gone up. The bits in AMOTION_EVENT_ACTION_POINTER_INDEX_MASK indicate
+   * which pointer changed.
+   */
+  static final int AMOTION_EVENT_ACTION_POINTER_UP = 6;
+  /**
+   * A change happened but the pointer is not down (unlike AMOTION_EVENT_ACTION_MOVE). The motion
+   * contains the most recent point; as well as any intermediate points since the last hover move
+   * event.
+   */
+  static final int AMOTION_EVENT_ACTION_HOVER_MOVE = 7;
+  /**
+   * The motion event contains relative vertical and/or horizontal scroll offsets. Use getAxisValue
+   * to retrieve the information from AMOTION_EVENT_AXIS_VSCROLL and AMOTION_EVENT_AXIS_HSCROLL. The
+   * pointer may or may not be down when this event is dispatched. This action is always delivered
+   * to the winder under the pointer; which may not be the window currently touched.
+   */
+  static final int AMOTION_EVENT_ACTION_SCROLL = 8;
+  /** The pointer is not down but has entered the boundaries of a window or view. */
+  static final int AMOTION_EVENT_ACTION_HOVER_ENTER = 9;
+  /** The pointer is not down but has exited the boundaries of a window or view. */
+  static final int AMOTION_EVENT_ACTION_HOVER_EXIT = 10;
+  /* One or more buttons have been pressed. */
+  static final int AMOTION_EVENT_ACTION_BUTTON_PRESS = 11;
+  /* One or more buttons have been released. */
+  static final int AMOTION_EVENT_ACTION_BUTTON_RELEASE = 12;
+
+  /** Motion event flags. */
+  /**
+   * This flag indicates that the window that received this motion event is partly or wholly
+   * obscured by another visible window above it. This flag is set to true even if the event did not
+   * directly pass through the obscured area. A security sensitive application can check this flag
+   * to identify situations in which a malicious application may have covered up part of its content
+   * for the purpose of misleading the user or hijacking touches. An appropriate response might be
+   * to drop the suspect touches or to take additional precautions to confirm the user's actual
+   * intent.
+   */
+  static final int AMOTION_EVENT_FLAG_WINDOW_IS_OBSCURED = 0x1;
+
+  /** Motion event edge touch flags. */
+  /** No edges intersected. */
+  static final int AMOTION_EVENT_EDGE_FLAG_NONE = 0;
+  /** Flag indicating the motion event intersected the top edge of the screen. */
+  static final int AMOTION_EVENT_EDGE_FLAG_TOP = 0x01;
+  /** Flag indicating the motion event intersected the bottom edge of the screen. */
+  static final int AMOTION_EVENT_EDGE_FLAG_BOTTOM = 0x02;
+  /** Flag indicating the motion event intersected the left edge of the screen. */
+  static final int AMOTION_EVENT_EDGE_FLAG_LEFT = 0x04;
+  /** Flag indicating the motion event intersected the right edge of the screen. */
+  static final int AMOTION_EVENT_EDGE_FLAG_RIGHT = 0x08;
+
+  /**
+   * Constants that identify each individual axis of a motion event.
+   *
+   * @anchor AMOTION_EVENT_AXIS
+   */
+  /**
+   * Axis constant: X axis of a motion event.
+   *
+   * <p>- For a touch screen, reports the absolute X screen position of the center of the touch
+   * contact area. The units are display pixels. - For a touch pad, reports the absolute X surface
+   * position of the center of the touch contact area. The units are device-dependent. - For a
+   * mouse, reports the absolute X screen position of the mouse pointer. The units are display
+   * pixels. - For a trackball, reports the relative horizontal displacement of the trackball. The
+   * value is normalized to a range from -1.0 (left) to 1.0 (right). - For a joystick, reports the
+   * absolute X position of the joystick. The value is normalized to a range from -1.0 (left) to 1.0
+   * (right).
+   */
+  static final int AMOTION_EVENT_AXIS_X = 0;
+  /**
+   * Axis constant: Y axis of a motion event.
+   *
+   * <p>- For a touch screen; reports the absolute Y screen position of the center of the touch
+   * contact area. The units are display pixels. - For a touch pad; reports the absolute Y surface
+   * position of the center of the touch contact area. The units are device-dependent. - For a
+   * mouse; reports the absolute Y screen position of the mouse pointer. The units are display
+   * pixels. - For a trackball; reports the relative vertical displacement of the trackball. The
+   * value is normalized to a range from -1.0 (up) to 1.0 (down). - For a joystick; reports the
+   * absolute Y position of the joystick. The value is normalized to a range from -1.0 (up or far)
+   * to 1.0 (down or near).
+   */
+  static final int AMOTION_EVENT_AXIS_Y = 1;
+  /**
+   * Axis constant: Pressure axis of a motion event.
+   *
+   * <p>- For a touch screen or touch pad; reports the approximate pressure applied to the surface
+   * by a finger or other tool. The value is normalized to a range from 0 (no pressure at all) to 1
+   * (normal pressure); although values higher than 1 may be generated depending on the calibration
+   * of the input device. - For a trackball; the value is set to 1 if the trackball button is
+   * pressed or 0 otherwise. - For a mouse; the value is set to 1 if the primary mouse button is
+   * pressed or 0 otherwise.
+   */
+  static final int AMOTION_EVENT_AXIS_PRESSURE = 2;
+  /**
+   * Axis constant: Size axis of a motion event.
+   *
+   * <p>- For a touch screen or touch pad; reports the approximate size of the contact area in
+   * relation to the maximum detectable size for the device. The value is normalized to a range from
+   * 0 (smallest detectable size) to 1 (largest detectable size); although it is not a linear scale.
+   * This value is of limited use. To obtain calibrated size information; see {@link
+   * AMOTION_EVENT_AXIS_TOUCH_MAJOR} or {@link AMOTION_EVENT_AXIS_TOOL_MAJOR}.
+   */
+  static final int AMOTION_EVENT_AXIS_SIZE = 3;
+  /**
+   * Axis constant: TouchMajor axis of a motion event.
+   *
+   * <p>- For a touch screen; reports the length of the major axis of an ellipse that represents the
+   * touch area at the point of contact. The units are display pixels. - For a touch pad; reports
+   * the length of the major axis of an ellipse that represents the touch area at the point of
+   * contact. The units are device-dependent.
+   */
+  static final int AMOTION_EVENT_AXIS_TOUCH_MAJOR = 4;
+  /**
+   * Axis constant: TouchMinor axis of a motion event.
+   *
+   * <p>- For a touch screen; reports the length of the minor axis of an ellipse that represents the
+   * touch area at the point of contact. The units are display pixels. - For a touch pad; reports
+   * the length of the minor axis of an ellipse that represents the touch area at the point of
+   * contact. The units are device-dependent.
+   *
+   * <p>When the touch is circular; the major and minor axis lengths will be equal to one another.
+   */
+  static final int AMOTION_EVENT_AXIS_TOUCH_MINOR = 5;
+  /**
+   * Axis constant: ToolMajor axis of a motion event.
+   *
+   * <p>- For a touch screen; reports the length of the major axis of an ellipse that represents the
+   * size of the approaching finger or tool used to make contact. - For a touch pad; reports the
+   * length of the major axis of an ellipse that represents the size of the approaching finger or
+   * tool used to make contact. The units are device-dependent.
+   *
+   * <p>When the touch is circular; the major and minor axis lengths will be equal to one another.
+   *
+   * <p>The tool size may be larger than the touch size since the tool may not be fully in contact
+   * with the touch sensor.
+   */
+  static final int AMOTION_EVENT_AXIS_TOOL_MAJOR = 6;
+  /**
+   * Axis constant: ToolMinor axis of a motion event.
+   *
+   * <p>- For a touch screen; reports the length of the minor axis of an ellipse that represents the
+   * size of the approaching finger or tool used to make contact. - For a touch pad; reports the
+   * length of the minor axis of an ellipse that represents the size of the approaching finger or
+   * tool used to make contact. The units are device-dependent.
+   *
+   * <p>When the touch is circular; the major and minor axis lengths will be equal to one another.
+   *
+   * <p>The tool size may be larger than the touch size since the tool may not be fully in contact
+   * with the touch sensor.
+   */
+  static final int AMOTION_EVENT_AXIS_TOOL_MINOR = 7;
+  /**
+   * Axis constant: Orientation axis of a motion event.
+   *
+   * <p>- For a touch screen or touch pad; reports the orientation of the finger or tool in radians
+   * relative to the vertical plane of the device. An angle of 0 radians indicates that the major
+   * axis of contact is oriented upwards; is perfectly circular or is of unknown orientation. A
+   * positive angle indicates that the major axis of contact is oriented to the right. A negative
+   * angle indicates that the major axis of contact is oriented to the left. The full range is from
+   * -PI/2 radians (finger pointing fully left) to PI/2 radians (finger pointing fully right). - For
+   * a stylus; the orientation indicates the direction in which the stylus is pointing in relation
+   * to the vertical axis of the current orientation of the screen. The range is from -PI radians to
+   * PI radians; where 0 is pointing up; -PI/2 radians is pointing left; -PI or PI radians is
+   * pointing down; and PI/2 radians is pointing right. See also {@link AMOTION_EVENT_AXIS_TILT}.
+   */
+  static final int AMOTION_EVENT_AXIS_ORIENTATION = 8;
+  /**
+   * Axis constant: Vertical Scroll axis of a motion event.
+   *
+   * <p>- For a mouse; reports the relative movement of the vertical scroll wheel. The value is
+   * normalized to a range from -1.0 (down) to 1.0 (up).
+   *
+   * <p>This axis should be used to scroll views vertically.
+   */
+  static final int AMOTION_EVENT_AXIS_VSCROLL = 9;
+  /**
+   * Axis constant: Horizontal Scroll axis of a motion event.
+   *
+   * <p>- For a mouse; reports the relative movement of the horizontal scroll wheel. The value is
+   * normalized to a range from -1.0 (left) to 1.0 (right).
+   *
+   * <p>This axis should be used to scroll views horizontally.
+   */
+  static final int AMOTION_EVENT_AXIS_HSCROLL = 10;
+  /**
+   * Axis constant: Z axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute Z position of the joystick. The value is normalized
+   * to a range from -1.0 (high) to 1.0 (low). <em>On game pads with two analog joysticks; this axis
+   * is often reinterpreted to report the absolute X position of the second joystick instead.</em>
+   */
+  static final int AMOTION_EVENT_AXIS_Z = 11;
+  /**
+   * Axis constant: X Rotation axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute rotation angle about the X axis. The value is
+   * normalized to a range from -1.0 (counter-clockwise) to 1.0 (clockwise).
+   */
+  static final int AMOTION_EVENT_AXIS_RX = 12;
+  /**
+   * Axis constant: Y Rotation axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute rotation angle about the Y axis. The value is
+   * normalized to a range from -1.0 (counter-clockwise) to 1.0 (clockwise).
+   */
+  static final int AMOTION_EVENT_AXIS_RY = 13;
+  /**
+   * Axis constant: Z Rotation axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute rotation angle about the Z axis. The value is
+   * normalized to a range from -1.0 (counter-clockwise) to 1.0 (clockwise). On game pads with two
+   * analog joysticks; this axis is often reinterpreted to report the absolute Y position of the
+   * second joystick instead.
+   */
+  static final int AMOTION_EVENT_AXIS_RZ = 14;
+  /**
+   * Axis constant: Hat X axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute X position of the directional hat control. The value
+   * is normalized to a range from -1.0 (left) to 1.0 (right).
+   */
+  static final int AMOTION_EVENT_AXIS_HAT_X = 15;
+  /**
+   * Axis constant: Hat Y axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute Y position of the directional hat control. The value
+   * is normalized to a range from -1.0 (up) to 1.0 (down).
+   */
+  static final int AMOTION_EVENT_AXIS_HAT_Y = 16;
+  /**
+   * Axis constant: Left Trigger axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the left trigger control. The value is
+   * normalized to a range from 0.0 (released) to 1.0 (fully pressed).
+   */
+  static final int AMOTION_EVENT_AXIS_LTRIGGER = 17;
+  /**
+   * Axis constant: Right Trigger axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the right trigger control. The value is
+   * normalized to a range from 0.0 (released) to 1.0 (fully pressed).
+   */
+  static final int AMOTION_EVENT_AXIS_RTRIGGER = 18;
+  /**
+   * Axis constant: Throttle axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the throttle control. The value is
+   * normalized to a range from 0.0 (fully open) to 1.0 (fully closed).
+   */
+  static final int AMOTION_EVENT_AXIS_THROTTLE = 19;
+  /**
+   * Axis constant: Rudder axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the rudder control. The value is
+   * normalized to a range from -1.0 (turn left) to 1.0 (turn right).
+   */
+  static final int AMOTION_EVENT_AXIS_RUDDER = 20;
+  /**
+   * Axis constant: Wheel axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the steering wheel control. The value is
+   * normalized to a range from -1.0 (turn left) to 1.0 (turn right).
+   */
+  static final int AMOTION_EVENT_AXIS_WHEEL = 21;
+  /**
+   * Axis constant: Gas axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the gas (accelerator) control. The value
+   * is normalized to a range from 0.0 (no acceleration) to 1.0 (maximum acceleration).
+   */
+  static final int AMOTION_EVENT_AXIS_GAS = 22;
+  /**
+   * Axis constant: Brake axis of a motion event.
+   *
+   * <p>- For a joystick; reports the absolute position of the brake control. The value is
+   * normalized to a range from 0.0 (no braking) to 1.0 (maximum braking).
+   */
+  static final int AMOTION_EVENT_AXIS_BRAKE = 23;
+  /**
+   * Axis constant: Distance axis of a motion event.
+   *
+   * <p>- For a stylus; reports the distance of the stylus from the screen. A value of 0.0 indicates
+   * direct contact and larger values indicate increasing distance from the surface.
+   */
+  static final int AMOTION_EVENT_AXIS_DISTANCE = 24;
+  /**
+   * Axis constant: Tilt axis of a motion event.
+   *
+   * <p>- For a stylus; reports the tilt angle of the stylus in radians where 0 radians indicates
+   * that the stylus is being held perpendicular to the surface; and PI/2 radians indicates that the
+   * stylus is being held flat against the surface.
+   */
+  static final int AMOTION_EVENT_AXIS_TILT = 25;
+  /**
+   * Axis constant: Generic scroll axis of a motion event.
+   *
+   * <p>- This is used for scroll axis motion events that can't be classified as strictly vertical
+   * or horizontal. The movement of a rotating scroller is an example of this.
+   */
+  static final int AMOTION_EVENT_AXIS_SCROLL = 26;
+  /**
+   * Axis constant: The movement of x position of a motion event.
+   *
+   * <p>- For a mouse; reports a difference of x position between the previous position. This is
+   * useful when pointer is captured; in that case the mouse pointer doesn't change the location but
+   * this axis reports the difference which allows the app to see how the mouse is moved.
+   */
+  static final int AMOTION_EVENT_AXIS_RELATIVE_X = 27;
+  /**
+   * Axis constant: The movement of y position of a motion event.
+   *
+   * <p>Same as {@link RELATIVE_X}; but for y position.
+   */
+  static final int AMOTION_EVENT_AXIS_RELATIVE_Y = 28;
+  /**
+   * Axis constant: Generic 1 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_1 = 32;
+  /**
+   * Axis constant: Generic 2 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_2 = 33;
+  /**
+   * Axis constant: Generic 3 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_3 = 34;
+  /**
+   * Axis constant: Generic 4 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_4 = 35;
+  /**
+   * Axis constant: Generic 5 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_5 = 36;
+  /**
+   * Axis constant: Generic 6 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_6 = 37;
+  /**
+   * Axis constant: Generic 7 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_7 = 38;
+  /**
+   * Axis constant: Generic 8 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_8 = 39;
+  /**
+   * Axis constant: Generic 9 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_9 = 40;
+  /**
+   * Axis constant: Generic 10 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_10 = 41;
+  /**
+   * Axis constant: Generic 11 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_11 = 42;
+  /**
+   * Axis constant: Generic 12 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_12 = 43;
+  /**
+   * Axis constant: Generic 13 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_13 = 44;
+  /**
+   * Axis constant: Generic 14 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_14 = 45;
+  /**
+   * Axis constant: Generic 15 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_15 = 46;
+  /**
+   * Axis constant: Generic 16 axis of a motion event. The interpretation of a generic axis is
+   * device-specific.
+   */
+  static final int AMOTION_EVENT_AXIS_GENERIC_16 = 47;
+
+  // NOTE: If you add a new axis here you must also add it to several other files.
+  //       Refer to frameworks/base/core/java/android/view/MotionEvent.java for the full list.
+
+  /**
+   * Constants that identify buttons that are associated with motion events. Refer to the
+   * documentation on the MotionEvent class for descriptions of each button.
+   */
+  /** primary */
+  static final int AMOTION_EVENT_BUTTON_PRIMARY = 1 << 0;
+  /** secondary */
+  static final int AMOTION_EVENT_BUTTON_SECONDARY = 1 << 1;
+  /** tertiary */
+  static final int AMOTION_EVENT_BUTTON_TERTIARY = 1 << 2;
+  /** back */
+  static final int AMOTION_EVENT_BUTTON_BACK = 1 << 3;
+  /** forward */
+  static final int AMOTION_EVENT_BUTTON_FORWARD = 1 << 4;
+
+  static final int AMOTION_EVENT_BUTTON_STYLUS_PRIMARY = 1 << 5;
+  static final int AMOTION_EVENT_BUTTON_STYLUS_SECONDARY = 1 << 6;
+
+  /**
+   * Constants that identify tool types. Refer to the documentation on the MotionEvent class for
+   * descriptions of each tool type.
+   */
+  /** unknown */
+  static final int AMOTION_EVENT_TOOL_TYPE_UNKNOWN = 0;
+  /** finger */
+  static final int AMOTION_EVENT_TOOL_TYPE_FINGER = 1;
+  /** stylus */
+  static final int AMOTION_EVENT_TOOL_TYPE_STYLUS = 2;
+  /** mouse */
+  static final int AMOTION_EVENT_TOOL_TYPE_MOUSE = 3;
+  /** eraser */
+  static final int AMOTION_EVENT_TOOL_TYPE_ERASER = 4;
+
+  /**
+   * Input source masks.
+   *
+   * <p>Refer to the documentation on android.view.InputDevice for more details about input sources
+   * and their correct interpretation.
+   */
+  /** mask */
+  static final int AINPUT_SOURCE_CLASS_MASK = 0x000000ff;
+  /** none */
+  static final int AINPUT_SOURCE_CLASS_NONE = 0x00000000;
+  /** button */
+  static final int AINPUT_SOURCE_CLASS_BUTTON = 0x00000001;
+  /** pointer */
+  static final int AINPUT_SOURCE_CLASS_POINTER = 0x00000002;
+  /** navigation */
+  static final int AINPUT_SOURCE_CLASS_NAVIGATION = 0x00000004;
+  /** position */
+  static final int AINPUT_SOURCE_CLASS_POSITION = 0x00000008;
+  /** joystick */
+  static final int AINPUT_SOURCE_CLASS_JOYSTICK = 0x00000010;
+
+  /** Input sources. */
+  /** unknown */
+  static final int AINPUT_SOURCE_UNKNOWN = 0x00000000;
+  /** keyboard */
+  static final int AINPUT_SOURCE_KEYBOARD = 0x00000100 | AINPUT_SOURCE_CLASS_BUTTON;
+  /** dpad */
+  static final int AINPUT_SOURCE_DPAD = 0x00000200 | AINPUT_SOURCE_CLASS_BUTTON;
+  /** gamepad */
+  static final int AINPUT_SOURCE_GAMEPAD = 0x00000400 | AINPUT_SOURCE_CLASS_BUTTON;
+  /** touchscreen */
+  static final int AINPUT_SOURCE_TOUCHSCREEN = 0x00001000 | AINPUT_SOURCE_CLASS_POINTER;
+  /** mouse */
+  static final int AINPUT_SOURCE_MOUSE = 0x00002000 | AINPUT_SOURCE_CLASS_POINTER;
+  /** stylus */
+  static final int AINPUT_SOURCE_STYLUS = 0x00004000 | AINPUT_SOURCE_CLASS_POINTER;
+  /** bluetooth stylus */
+  static final int AINPUT_SOURCE_BLUETOOTH_STYLUS = 0x00008000 | AINPUT_SOURCE_STYLUS;
+  /** trackball */
+  static final int AINPUT_SOURCE_TRACKBALL = 0x00010000 | AINPUT_SOURCE_CLASS_NAVIGATION;
+  /** mouse relative */
+  static final int AINPUT_SOURCE_MOUSE_RELATIVE = 0x00020000 | AINPUT_SOURCE_CLASS_NAVIGATION;
+  /** touchpad */
+  static final int AINPUT_SOURCE_TOUCHPAD = 0x00100000 | AINPUT_SOURCE_CLASS_POSITION;
+  /** navigation */
+  static final int AINPUT_SOURCE_TOUCH_NAVIGATION = 0x00200000 | AINPUT_SOURCE_CLASS_NONE;
+  /** joystick */
+  static final int AINPUT_SOURCE_JOYSTICK = 0x01000000 | AINPUT_SOURCE_CLASS_JOYSTICK;
+  /** rotary encoder */
+  static final int AINPUT_SOURCE_ROTARY_ENCODER = 0x00400000 | AINPUT_SOURCE_CLASS_NONE;
+  /** any */
+  static final int AINPUT_SOURCE_ANY = 0xffffff00;
+
+  /**
+   * Keyboard types.
+   *
+   * <p>Refer to the documentation on android.view.InputDevice for more details.
+   */
+  /** none */
+  static final int AINPUT_KEYBOARD_TYPE_NONE = 0;
+  /** non alphabetic */
+  static final int AINPUT_KEYBOARD_TYPE_NON_ALPHABETIC = 1;
+  /** alphabetic */
+  static final int AINPUT_KEYBOARD_TYPE_ALPHABETIC = 2;
+
+  // /**
+  //  * Constants used to retrieve information about the range of motion for a particular
+  //  * coordinate of a motion event.
+  //  *
+  //  * Refer to the documentation on android.view.InputDevice for more details about input sources
+  //  * and their correct interpretation.
+  //  *
+  //  * @deprecated These constants are deprecated. Use {@link AMOTION_EVENT_AXIS
+  // AMOTION_EVENT_AXIS_*} constants instead.
+  //  */
+  //     /** x */
+  //     AINPUT_MOTION_RANGE_X = AMOTION_EVENT_AXIS_X;
+  //         /** y */
+  //         AINPUT_MOTION_RANGE_Y = AMOTION_EVENT_AXIS_Y;
+  //         /** pressure */
+  //         AINPUT_MOTION_RANGE_PRESSURE = AMOTION_EVENT_AXIS_PRESSURE;
+  //         /** size */
+  //         AINPUT_MOTION_RANGE_SIZE = AMOTION_EVENT_AXIS_SIZE;
+  //         /** touch major */
+  //         AINPUT_MOTION_RANGE_TOUCH_MAJOR = AMOTION_EVENT_AXIS_TOUCH_MAJOR;
+  //         /** touch minor */
+  //         AINPUT_MOTION_RANGE_TOUCH_MINOR = AMOTION_EVENT_AXIS_TOUCH_MINOR;
+  //         /** tool major */
+  //         AINPUT_MOTION_RANGE_TOOL_MAJOR = AMOTION_EVENT_AXIS_TOOL_MAJOR;
+  //         /** tool minor */
+  //         AINPUT_MOTION_RANGE_TOOL_MINOR = AMOTION_EVENT_AXIS_TOOL_MINOR;
+  //         /** orientation */
+  //         AINPUT_MOTION_RANGE_ORIENTATION = AMOTION_EVENT_AXIS_ORIENTATION;
+  //   };
+  // /**
+  //  * Input event accessors.
+  //  *
+  //  * Note that most functions can only be used on input events that are of a given type.
+  //  * Calling these functions on input events of other types will yield undefined behavior.
+  //  */
+  // /*** Accessors for all input events. ***/
+  //   /** Get the input event type. */
+  //   int32_t AInputEvent_getType(const AInputEvent* event);
+  //   /** Get the id for the device that an input event came from.
+  //    *
+  //    * Input events can be generated by multiple different input devices.
+  //    * Use the input device id to obtain information about the input
+  //    * device that was responsible for generating a particular event.
+  //    *
+  //    * An input device id of 0 indicates that the event didn't come from a physical device;
+  //    * other numbers are arbitrary and you shouldn't depend on the values.
+  //    * Use the provided input device query API to obtain information about input devices.
+  //    */
+  //   int32_t AInputEvent_getDeviceId(const AInputEvent* event);
+  //   /** Get the input event source. */
+  //   int32_t AInputEvent_getSource(const AInputEvent* event);
+  // /*** Accessors for key events only. ***/
+  //   /** Get the key event action. */
+  //   int32_t AKeyEvent_getAction(const AInputEvent* key_event);
+  //   /** Get the key event flags. */
+  //   int32_t AKeyEvent_getFlags(const AInputEvent* key_event);
+  //   /**
+  //    * Get the key code of the key event.
+  //    * This is the physical key that was pressed, not the Unicode character.
+  //    */
+  //   int32_t AKeyEvent_getKeyCode(const AInputEvent* key_event);
+  //   /**
+  //    * Get the hardware key id of this key event.
+  //    * These values are not reliable and vary from device to device.
+  //    */
+  //   int32_t AKeyEvent_getScanCode(const AInputEvent* key_event);
+  //   /** Get the meta key state. */
+  //   int32_t AKeyEvent_getMetaState(const AInputEvent* key_event);
+  //   /**
+  //    * Get the repeat count of the event.
+  //    * For both key up an key down events, this is the number of times the key has
+  //    * repeated with the first down starting at 0 and counting up from there.  For
+  //    * multiple key events, this is the number of down/up pairs that have occurred.
+  //    */
+  //   int32_t AKeyEvent_getRepeatCount(const AInputEvent* key_event);
+  //   /**
+  //    * Get the time of the most recent key down event, in the
+  //    * java.lang.System.nanoTime() time base.  If this is a down event,
+  //    * this will be the same as eventTime.
+  //    * Note that when chording keys, this value is the down time of the most recently
+  //    * pressed key, which may not be the same physical key of this event.
+  //    */
+  //   int64_t AKeyEvent_getDownTime(const AInputEvent* key_event);
+  //   /**
+  //    * Get the time this event occurred, in the
+  //    * java.lang.System.nanoTime() time base.
+  //    */
+  //   int64_t AKeyEvent_getEventTime(const AInputEvent* key_event);
+  // /*** Accessors for motion events only. ***/
+  //   /** Get the combined motion event action code and pointer index. */
+  //   int32_t AMotionEvent_getAction(const AInputEvent* motion_event);
+  //   /** Get the motion event flags. */
+  //   int32_t AMotionEvent_getFlags(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the state of any meta / modifier keys that were in effect when the
+  //    * event was generated.
+  //    */
+  //   int32_t AMotionEvent_getMetaState(const AInputEvent* motion_event);
+  // #if __ANDROID_API__ >= 14
+  //   /** Get the button state of all buttons that are pressed. */
+  //   int32_t AMotionEvent_getButtonState(const AInputEvent* motion_event);
+  // #endif
+  //   /**
+  //    * Get a bitfield indicating which edges, if any, were touched by this motion event.
+  //    * For touch events, clients can use this to determine if the user's finger was
+  //    * touching the edge of the display.
+  //    */
+  //   int32_t AMotionEvent_getEdgeFlags(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the time when the user originally pressed down to start a stream of
+  //    * position events, in the java.lang.System.nanoTime() time base.
+  //    */
+  //   int64_t AMotionEvent_getDownTime(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the time when this specific event was generated,
+  //    * in the java.lang.System.nanoTime() time base.
+  //    */
+  //   int64_t AMotionEvent_getEventTime(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the X coordinate offset.
+  //    * For touch events on the screen, this is the delta that was added to the raw
+  //    * screen coordinates to adjust for the absolute position of the containing windows
+  //    * and views.
+  //    */
+  //   float AMotionEvent_getXOffset(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the Y coordinate offset.
+  //    * For touch events on the screen, this is the delta that was added to the raw
+  //    * screen coordinates to adjust for the absolute position of the containing windows
+  //    * and views.
+  //    */
+  //   float AMotionEvent_getYOffset(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the precision of the X coordinates being reported.
+  //    * You can multiply this number with an X coordinate sample to find the
+  //    * actual hardware value of the X coordinate.
+  //    */
+  //   float AMotionEvent_getXPrecision(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the precision of the Y coordinates being reported.
+  //    * You can multiply this number with a Y coordinate sample to find the
+  //    * actual hardware value of the Y coordinate.
+  //    */
+  //   float AMotionEvent_getYPrecision(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the number of pointers of data contained in this event.
+  //    * Always >= 1.
+  //    */
+  //   size_t AMotionEvent_getPointerCount(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the pointer identifier associated with a particular pointer
+  //    * data index in this event.  The identifier tells you the actual pointer
+  //    * number associated with the data, accounting for individual pointers
+  //    * going up and down since the start of the current gesture.
+  //    */
+  //   int32_t AMotionEvent_getPointerId(const AInputEvent* motion_event, size_t pointer_index);
+  // #if __ANDROID_API__ >= 14
+  //   /**
+  //    * Get the tool type of a pointer for the given pointer index.
+  //    * The tool type indicates the type of tool used to make contact such as a
+  //    * finger or stylus, if known.
+  //    */
+  //   int32_t AMotionEvent_getToolType(const AInputEvent* motion_event, size_t pointer_index);
+  // #endif
+  //   /**
+  //    * Get the original raw X coordinate of this event.
+  //    * For touch events on the screen, this is the original location of the event
+  //    * on the screen, before it had been adjusted for the containing window
+  //    * and views.
+  //    */
+  //   float AMotionEvent_getRawX(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the original raw X coordinate of this event.
+  //    * For touch events on the screen, this is the original location of the event
+  //    * on the screen, before it had been adjusted for the containing window
+  //    * and views.
+  //    */
+  //   float AMotionEvent_getRawY(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current X coordinate of this event for the given pointer index.
+  //    * Whole numbers are pixels; the value may have a fraction for input devices
+  //    * that are sub-pixel precise.
+  //    */
+  //   float AMotionEvent_getX(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current Y coordinate of this event for the given pointer index.
+  //    * Whole numbers are pixels; the value may have a fraction for input devices
+  //    * that are sub-pixel precise.
+  //    */
+  //   float AMotionEvent_getY(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current pressure of this event for the given pointer index.
+  //    * The pressure generally ranges from 0 (no pressure at all) to 1 (normal pressure),
+  //    * although values higher than 1 may be generated depending on the calibration of
+  //    * the input device.
+  //    */
+  //   float AMotionEvent_getPressure(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current scaled value of the approximate size for the given pointer index.
+  //    * This represents some approximation of the area of the screen being
+  //    * pressed; the actual value in pixels corresponding to the
+  //    * touch is normalized with the device specific range of values
+  //    * and scaled to a value between 0 and 1.  The value of size can be used to
+  //    * determine fat touch events.
+  //    */
+  //   float AMotionEvent_getSize(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current length of the major axis of an ellipse that describes the touch area
+  //    * at the point of contact for the given pointer index.
+  //    */
+  //   float AMotionEvent_getTouchMajor(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current length of the minor axis of an ellipse that describes the touch area
+  //    * at the point of contact for the given pointer index.
+  //    */
+  //   float AMotionEvent_getTouchMinor(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current length of the major axis of an ellipse that describes the size
+  //    * of the approaching tool for the given pointer index.
+  //    * The tool area represents the estimated size of the finger or pen that is
+  //    * touching the device independent of its actual touch area at the point of contact.
+  //    */
+  //   float AMotionEvent_getToolMajor(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current length of the minor axis of an ellipse that describes the size
+  //    * of the approaching tool for the given pointer index.
+  //    * The tool area represents the estimated size of the finger or pen that is
+  //    * touching the device independent of its actual touch area at the point of contact.
+  //    */
+  //   float AMotionEvent_getToolMinor(const AInputEvent* motion_event, size_t pointer_index);
+  //   /**
+  //    * Get the current orientation of the touch area and tool area in radians clockwise from
+  //    * vertical for the given pointer index.
+  //    * An angle of 0 degrees indicates that the major axis of contact is oriented
+  //    * upwards, is perfectly circular or is of unknown orientation.  A positive angle
+  //    * indicates that the major axis of contact is oriented to the right.  A negative angle
+  //    * indicates that the major axis of contact is oriented to the left.
+  //    * The full range is from -PI/2 radians (finger pointing fully left) to PI/2 radians
+  //    * (finger pointing fully right).
+  //    */
+  //   float AMotionEvent_getOrientation(const AInputEvent* motion_event, size_t pointer_index);
+  // #if __ANDROID_API__ >= 13
+  //   /** Get the value of the request axis for the given pointer index. */
+  //   float AMotionEvent_getAxisValue(const AInputEvent* motion_event,
+  //       int32_t axis, size_t pointer_index);
+  // #endif
+  //   /**
+  //    * Get the number of historical points in this event.  These are movements that
+  //    * have occurred between this event and the previous event.  This only applies
+  //    * to AMOTION_EVENT_ACTION_MOVE events -- all other actions will have a size of 0.
+  //    * Historical samples are indexed from oldest to newest.
+  //    */
+  //   size_t AMotionEvent_getHistorySize(const AInputEvent* motion_event);
+  //   /**
+  //    * Get the time that a historical movement occurred between this event and
+  //    * the previous event, in the java.lang.System.nanoTime() time base.
+  //    */
+  //   int64_t AMotionEvent_getHistoricalEventTime(const AInputEvent* motion_event,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical raw X coordinate of this event for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * For touch events on the screen, this is the original location of the event
+  //    * on the screen, before it had been adjusted for the containing window
+  //    * and views.
+  //    * Whole numbers are pixels; the value may have a fraction for input devices
+  //    * that are sub-pixel precise.
+  //    */
+  //   float AMotionEvent_getHistoricalRawX(const AInputEvent* motion_event, size_t pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical raw Y coordinate of this event for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * For touch events on the screen, this is the original location of the event
+  //    * on the screen, before it had been adjusted for the containing window
+  //    * and views.
+  //    * Whole numbers are pixels; the value may have a fraction for input devices
+  //    * that are sub-pixel precise.
+  //    */
+  //   float AMotionEvent_getHistoricalRawY(const AInputEvent* motion_event, size_t pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical X coordinate of this event for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * Whole numbers are pixels; the value may have a fraction for input devices
+  //    * that are sub-pixel precise.
+  //    */
+  //   float AMotionEvent_getHistoricalX(const AInputEvent* motion_event, size_t pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical Y coordinate of this event for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * Whole numbers are pixels; the value may have a fraction for input devices
+  //    * that are sub-pixel precise.
+  //    */
+  //   float AMotionEvent_getHistoricalY(const AInputEvent* motion_event, size_t pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical pressure of this event for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * The pressure generally ranges from 0 (no pressure at all) to 1 (normal pressure),
+  //    * although values higher than 1 may be generated depending on the calibration of
+  //    * the input device.
+  //    */
+  //   float AMotionEvent_getHistoricalPressure(const AInputEvent* motion_event, size_t
+  // pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the current scaled value of the approximate size for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * This represents some approximation of the area of the screen being
+  //    * pressed; the actual value in pixels corresponding to the
+  //    * touch is normalized with the device specific range of values
+  //    * and scaled to a value between 0 and 1.  The value of size can be used to
+  //    * determine fat touch events.
+  //    */
+  //   float AMotionEvent_getHistoricalSize(const AInputEvent* motion_event, size_t pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical length of the major axis of an ellipse that describes the touch area
+  //    * at the point of contact for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    */
+  //   float AMotionEvent_getHistoricalTouchMajor(const AInputEvent* motion_event, size_t
+  // pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical length of the minor axis of an ellipse that describes the touch area
+  //    * at the point of contact for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    */
+  //   float AMotionEvent_getHistoricalTouchMinor(const AInputEvent* motion_event, size_t
+  // pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical length of the major axis of an ellipse that describes the size
+  //    * of the approaching tool for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * The tool area represents the estimated size of the finger or pen that is
+  //    * touching the device independent of its actual touch area at the point of contact.
+  //    */
+  //   float AMotionEvent_getHistoricalToolMajor(const AInputEvent* motion_event, size_t
+  // pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical length of the minor axis of an ellipse that describes the size
+  //    * of the approaching tool for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * The tool area represents the estimated size of the finger or pen that is
+  //    * touching the device independent of its actual touch area at the point of contact.
+  //    */
+  //   float AMotionEvent_getHistoricalToolMinor(const AInputEvent* motion_event, size_t
+  // pointer_index,
+  //       size_t history_index);
+  //   /**
+  //    * Get the historical orientation of the touch area and tool area in radians clockwise from
+  //    * vertical for the given pointer index that
+  //    * occurred between this event and the previous motion event.
+  //    * An angle of 0 degrees indicates that the major axis of contact is oriented
+  //    * upwards, is perfectly circular or is of unknown orientation.  A positive angle
+  //    * indicates that the major axis of contact is oriented to the right.  A negative angle
+  //    * indicates that the major axis of contact is oriented to the left.
+  //    * The full range is from -PI/2 radians (finger pointing fully left) to PI/2 radians
+  //    * (finger pointing fully right).
+  //    */
+  //   float AMotionEvent_getHistoricalOrientation(const AInputEvent* motion_event, size_t
+  // pointer_index,
+  //       size_t history_index);
+  // #if __ANDROID_API__ >= 13
+  //   /**
+  //    * Get the historical value of the request axis for the given pointer index
+  //    * that occurred between this event and the previous motion event.
+  //    */
+  //   float AMotionEvent_getHistoricalAxisValue(const AInputEvent* motion_event,
+  //       int32_t axis, size_t pointer_index, size_t history_index);
+  // #endif
+  //   struct AInputQueue;
+  //   /**
+  //    * Input queue
+  //    *
+  //    * An input queue is the facility through which you retrieve input
+  //    * events.
+  //    */
+  //   typedef struct AInputQueue AInputQueue;
+  //   /**
+  //    * Add this input queue to a looper for processing.  See
+  //    * ALooper_addFd() for information on the ident, callback, and data params.
+  //    */
+  //   void AInputQueue_attachLooper(AInputQueue* queue, ALooper* looper,
+  //       int ident, ALooper_callbackFunc callback, void* data);
+  //   /**
+  //    * Remove the input queue from the looper it is currently attached to.
+  //    */
+  //   void AInputQueue_detachLooper(AInputQueue* queue);
+  //   /**
+  //    * Returns true if there are one or more events available in the
+  //    * input queue.  Returns 1 if the queue has events; 0 if
+  //    * it does not have events; and a negative value if there is an error.
+  //    */
+  //   int32_t AInputQueue_hasEvents(AInputQueue* queue);
+  //   /**
+  //    * Returns the next available event from the queue.  Returns a negative
+  //    * value if no events are available or an error has occurred.
+  //    */
+  //   int32_t AInputQueue_getEvent(AInputQueue* queue, AInputEvent** outEvent);
+  //   /**
+  //    * Sends the key for standard pre-dispatching -- that is, possibly deliver
+  //    * it to the current IME to be consumed before the app.  Returns 0 if it
+  //    * was not pre-dispatched, meaning you can process it right now.  If non-zero
+  //    * is returned, you must abandon the current event processing and allow the
+  //    * event to appear again in the event queue (if it does not get consumed during
+  //    * pre-dispatching).
+  //    */
+  //   int32_t AInputQueue_preDispatchEvent(AInputQueue* queue, AInputEvent* event);
+  //   /**
+  //    * Report that dispatching has finished with the given event.
+  //    * This must be called after receiving an event with AInputQueue_get_event().
+  //    */
+  //   void AInputQueue_finishEvent(AInputQueue* queue, AInputEvent* event, int handled);
+  // #ifdef __cplusplus
+  // }
+  // #endif
+  //     #endif // _ANDROID_INPUT_H
+  // /** @} */
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/NativeBitSet64.java b/shadows/framework/src/main/java/org/robolectric/shadows/NativeBitSet64.java
new file mode 100644
index 0000000..ae2d4a7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/NativeBitSet64.java
@@ -0,0 +1,110 @@
+package org.robolectric.shadows;
+
+/**
+ * Transliteration of native BitSet64.
+ *
+ * <p>Unlike the native code stores value inline as opposed to a manipulating data via series of
+ * static methods passed values by reference.
+ *
+ * @see "system/core/libutils/include/utils/BitSet.h"
+ */
+public class NativeBitSet64 {
+
+  private long value;
+
+  NativeBitSet64(long value) {
+    this.value = value;
+  }
+
+  NativeBitSet64(NativeBitSet64 other) {
+    this.value = other.value;
+  }
+
+  NativeBitSet64() {
+    this(0);
+  }
+
+  /** Gets the value associated with a particular bit index. */
+  static long valueForBit(int n) {
+    return 0x8000000000000000L >>> n;
+  }
+
+  /** Clears the bit set. */
+  void clear() {
+    value = 0;
+  }
+
+  /** Returns the number of marked bits in the set. */
+  int count() {
+    int count = 0;
+    for (int n = 0; n < 64; n++) {
+      if (hasBit(n)) {
+        count++;
+      }
+    }
+    return count;
+  }
+
+  /** Returns true if the bit set does not contain any marked bits. */
+  boolean isEmpty() {
+    return value == 0;
+  }
+
+  /** Returns true if the specified bit is marked. */
+  boolean hasBit(int n) {
+    return (value & valueForBit(n)) != 0;
+  }
+
+  /** Marks the specified bit. */
+  void markBit(int n) {
+    value |= valueForBit(n);
+  }
+
+  /** Clears the specified bit. */
+  void clearBit(int n) {
+    value &= ~valueForBit(n);
+  }
+
+  /** Finds the first marked bit in the set. Result is undefined if all bits are unmarked. */
+  int firstMarkedBit() {
+    for (int n = 0; n < 64; n++) {
+      if (hasBit(n)) {
+        return n;
+      }
+    }
+    return 0;
+  }
+
+  /**
+   * Finds the first marked bit in the set and clears it. Returns the bit index. Result is undefined
+   * if all bits are unmarked.
+   */
+  int clearFirstMarkedBit() {
+    int n = firstMarkedBit();
+    clearBit(n);
+    return n;
+  }
+
+  /**
+   * Gets the index of the specified bit in the set, which is the number of marked bits that appear
+   * before the specified bit.
+   */
+  int getIndexOfBit(int n) {
+    // return __builtin_popcountll(value & ~(0xffffffffffffffffULL >> n));
+    int numMarkedBits = 0;
+    for (int i = 0; i < n; i++) {
+      if (hasBit(i)) {
+        numMarkedBits++;
+      }
+    }
+    return numMarkedBits;
+  }
+
+  public void setValue(long l) {
+    value = l;
+  }
+
+  public long getValue() {
+    return value;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/NativeInput.java b/shadows/framework/src/main/java/org/robolectric/shadows/NativeInput.java
new file mode 100644
index 0000000..d04421c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/NativeInput.java
@@ -0,0 +1,835 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.Preconditions.checkState;
+import static org.robolectric.shadows.NativeAndroidInput.AINPUT_EVENT_TYPE_MOTION;
+import static org.robolectric.shadows.NativeAndroidInput.AINPUT_SOURCE_CLASS_POINTER;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_CANCEL;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_DOWN;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_MASK;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_MOVE;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_OUTSIDE;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_POINTER_DOWN;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_POINTER_INDEX_MASK;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_POINTER_UP;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_ACTION_UP;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_ORIENTATION;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_PRESSURE;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_SIZE;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOOL_MAJOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOOL_MINOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOUCH_MAJOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOUCH_MINOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_X;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_Y;
+
+import android.os.Parcel;
+import android.view.MotionEvent.PointerProperties;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.robolectric.res.android.Ref;
+
+/**
+ * Java representation of framework native input Transliterated from oreo-mr1 (SDK 27)
+ * frameworks/native/include/input/Input.h and libs/input/Input.cpp
+ *
+ * @see <a href="https://android.googlesource.com/platform/frameworks/native/+/oreo-mr1-release/include/input/Input.h">include/input/Input.h</a>
+ * @see <a href="https://android.googlesource.com/platform/frameworks/native/+/oreo-mr1-release/libs/input/Input.cpp>libs/input/Input.cpp</a>
+ */
+public class NativeInput {
+
+  /*
+   * Maximum number of pointers supported per motion event.
+   * Smallest number of pointers is 1.
+   * (We want at least 10 but some touch controllers obstensibly configured for 10 pointers
+   * will occasionally emit 11.  There is not much harm making this ant bigger.)
+   */
+  private static final int MAX_POINTERS = 16;
+  /*
+   * Maximum number of samples supported per motion event.
+   */
+  private static final int MAX_SAMPLES = 0xffff; /* UINT16_MAX */
+  /*
+   * Maximum pointer id value supported in a motion event.
+   * Smallest pointer id is 0.
+   * (This is limited by our use of BitSet32 to track pointer assignments.)
+   */
+  private static final int MAX_POINTER_ID = 31;
+
+  /*
+   * Declare a concrete type for the NDK's input event forward declaration.
+   */
+  static class AInputEvent {}
+
+  /*
+   * Pointer coordinate data.
+   *
+   * Deviates from original platform implementation to store axises in simple SparseArray as opposed
+   * to complicated bitset + array arrangement.
+   */
+  static class PointerCoords {
+
+    private static final int MAX_AXES = 30;
+
+    // Bitfield of axes that are present in this structure.
+    private NativeBitSet64 bits = new NativeBitSet64();
+
+    NativeBitSet64 getBits() {
+      return bits;
+    }
+
+    // Values of axes that are stored in this structure
+    private float[] values = new float[MAX_AXES];
+
+    public void clear() {
+      bits.clear();
+    }
+
+    public boolean isEmpty() {
+      return bits.isEmpty();
+    }
+
+    public float getAxisValue(int axis) {
+      if (axis < 0 || axis > 63 || !bits.hasBit(axis)) {
+        return 0;
+      }
+      return values[bits.getIndexOfBit(axis)];
+    }
+
+    public boolean setAxisValue(int axis, float value) {
+      checkState(axis >= 0 && axis <= 63, "axis out of range");
+      int index = bits.getIndexOfBit(axis);
+      if (!bits.hasBit(axis)) {
+        if (value == 0) {
+          return true; // axes with value 0 do not need to be stored
+        }
+
+        int count = bits.count();
+        if (count >= MAX_AXES) {
+          tooManyAxes(axis);
+          return false;
+        }
+        bits.markBit(axis);
+        for (int i = count; i > index; i--) {
+          values[i] = values[i - 1];
+        }
+      }
+      values[index] = value;
+      return true;
+    }
+
+    static void scaleAxisValue(PointerCoords c, int axis, float scaleFactor) {
+      float value = c.getAxisValue(axis);
+      if (value != 0) {
+        c.setAxisValue(axis, value * scaleFactor);
+      }
+    }
+
+    public void scale(float scaleFactor) {
+      // No need to scale pressure or size since they are normalized.
+      // No need to scale orientation since it is meaningless to do so.
+      scaleAxisValue(this, AMOTION_EVENT_AXIS_X, scaleFactor);
+      scaleAxisValue(this, AMOTION_EVENT_AXIS_Y, scaleFactor);
+      scaleAxisValue(this, AMOTION_EVENT_AXIS_TOUCH_MAJOR, scaleFactor);
+      scaleAxisValue(this, AMOTION_EVENT_AXIS_TOUCH_MINOR, scaleFactor);
+      scaleAxisValue(this, AMOTION_EVENT_AXIS_TOOL_MAJOR, scaleFactor);
+      scaleAxisValue(this, AMOTION_EVENT_AXIS_TOOL_MINOR, scaleFactor);
+    }
+
+    public void applyOffset(float xOffset, float yOffset) {
+      setAxisValue(AMOTION_EVENT_AXIS_X, getX() + xOffset);
+      setAxisValue(AMOTION_EVENT_AXIS_Y, getY() + yOffset);
+    }
+
+    public float getX() {
+      return getAxisValue(AMOTION_EVENT_AXIS_X);
+    }
+
+    public float getY() {
+      return getAxisValue(AMOTION_EVENT_AXIS_Y);
+    }
+
+    public boolean readFromParcel(Parcel parcel) {
+      bits.setValue(parcel.readLong());
+      int count = bits.count();
+      if (count > MAX_AXES) {
+        return false;
+      }
+      for (int i = 0; i < count; i++) {
+        values[i] = parcel.readFloat();
+      }
+      return true;
+    }
+
+    public boolean writeToParcel(Parcel parcel) {
+      parcel.writeLong(bits.getValue());
+      int count = bits.count();
+      for (int i = 0; i < count; i++) {
+        parcel.writeFloat(values[i]);
+      }
+      return true;
+    }
+
+    //     bool operator==( PointerCoords& other) ;
+    //      bool operator!=( PointerCoords& other)  {
+    //       return !(*this == other);
+    //     }
+
+    public void copyFrom(PointerCoords other) {
+      bits = new NativeBitSet64(other.bits);
+      int count = bits.count();
+      for (int i = 0; i < count; i++) {
+        values[i] = other.values[i];
+      }
+    }
+
+    private static void tooManyAxes(int axis) {
+      // native code just logs this as warning. Be a bit more defensive for now and throw
+      throw new IllegalStateException(
+          String.format(
+              "Could not set value for axis %d because the PointerCoords structure is full and "
+                  + "cannot contain more than %d axis values.",
+              axis, MAX_AXES));
+    }
+  }
+
+  /*
+   * Input events.
+   */
+  static class InputEvent extends AInputEvent {
+
+    protected int mDeviceId;
+    protected int mSource;
+
+    public int getType() {
+      return 0;
+    }
+
+    public int getDeviceId() {
+      return mDeviceId;
+    }
+
+    public int getSource() {
+      return mSource;
+    }
+
+    public void setSource(int source) {
+      mSource = source;
+    }
+
+    protected void initialize(int deviceId, int source) {
+      this.mDeviceId = deviceId;
+      this.mSource = source;
+    }
+
+    protected void initialize(NativeInput.InputEvent from) {
+      initialize(from.getDeviceId(), from.getSource());
+    }
+  }
+
+  /*
+   * Key events.
+   */
+  static class KeyEvent extends InputEvent {
+    //       public:
+    //       virtual ~KeyEvent() { }
+    //       virtual int getType()  { return AINPUT_EVENT_TYPE_KEY; }
+    //        int getAction()  { return mAction; }
+    //        int getFlags()  { return mFlags; }
+    //        void setFlags(int flags) { mFlags = flags; }
+    //        int getKeyCode()  { return mKeyCode; }
+    //        int getScanCode()  { return mScanCode; }
+    //        int getMetaState()  { return mMetaState; }
+    //        int getRepeatCount()  { return mRepeatCount; }
+    //        nsecs_t getDownTime()  { return mDownTime; }
+    //        nsecs_t getEventTime()  { return mEventTime; }
+    //       static  char* getLabel(int keyCode);
+    //     static int getKeyCodeFromLabel( char* label);
+    //
+    //     void initialize(
+    //         int deviceId,
+    //         int source,
+    //         int action,
+    //         int flags,
+    //         int keyCode,
+    //         int scanCode,
+    //         int metaState,
+    //         int repeatCount,
+    //         nsecs_t downTime,
+    //         nsecs_t eventTime);
+    //     void initialize( KeyEvent& from);
+    //     protected:
+    //     int mAction;
+    //     int mFlags;
+    //     int mKeyCode;
+    //     int mScanCode;
+    //     int mMetaState;
+    //     int mRepeatCount;
+    //     nsecs_t mDownTime;
+    //     nsecs_t mEventTime;
+  }
+
+  /*
+   * Motion events.
+   */
+  static class MotionEvent extends InputEvent {
+
+    // constants copied from android bionic/libc/include/math.h
+    @SuppressWarnings("FloatingPointLiteralPrecision")
+    private static final double M_PI = 3.14159265358979323846f; /* pi */
+
+    @SuppressWarnings("FloatingPointLiteralPrecision")
+    private static final double M_PI_2 = 1.57079632679489661923f; /* pi/2 */
+
+    private int mAction;
+    private int mActionButton;
+    private int mFlags;
+    private int mEdgeFlags;
+    private int mMetaState;
+    private int mButtonState;
+    private float mXOffset;
+    private float mYOffset;
+    private float mXPrecision;
+    private float mYPrecision;
+    private long mDownTime;
+    private List<PointerProperties> mPointerProperties = new ArrayList<>();
+    private List<Long> mSampleEventTimes = new ArrayList<>();
+    private List<NativeInput.PointerCoords> mSamplePointerCoords = new ArrayList<>();
+
+    @Override
+    public int getType() {
+      return AINPUT_EVENT_TYPE_MOTION;
+    }
+
+    public int getAction() {
+      return mAction;
+    }
+
+    public int getActionMasked() {
+      return mAction & AMOTION_EVENT_ACTION_MASK;
+    }
+
+    public int getActionIndex() {
+      return (mAction & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK)
+          >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT;
+    }
+
+    public void setAction(int action) {
+      mAction = action;
+    }
+
+    public int getFlags() {
+      return mFlags;
+    }
+
+    public void setFlags(int flags) {
+      mFlags = flags;
+    }
+
+    public int getEdgeFlags() {
+      return mEdgeFlags;
+    }
+
+    public void setEdgeFlags(int edgeFlags) {
+      mEdgeFlags = edgeFlags;
+    }
+
+    public int getMetaState() {
+      return mMetaState;
+    }
+
+    public void setMetaState(int metaState) {
+      mMetaState = metaState;
+    }
+
+    public int getButtonState() {
+      return mButtonState;
+    }
+
+    public void setButtonState(int buttonState) {
+      mButtonState = buttonState;
+    }
+
+    public int getActionButton() {
+      return mActionButton;
+    }
+
+    public void setActionButton(int button) {
+      mActionButton = button;
+    }
+
+    public float getXOffset() {
+      return mXOffset;
+    }
+
+    public float getYOffset() {
+      return mYOffset;
+    }
+
+    public float getXPrecision() {
+      return mXPrecision;
+    }
+
+    public float getYPrecision() {
+      return mYPrecision;
+    }
+
+    public long getDownTime() {
+      return mDownTime;
+    }
+
+    public void setDownTime(long downTime) {
+      mDownTime = downTime;
+    }
+
+    public int getPointerCount() {
+      return mPointerProperties.size();
+    }
+
+    public PointerProperties getPointerProperties(int pointerIndex) {
+      return mPointerProperties.get(pointerIndex);
+    }
+
+    public int getPointerId(int pointerIndex) {
+      return mPointerProperties.get(pointerIndex).id;
+    }
+
+    public int getToolType(int pointerIndex) {
+      return mPointerProperties.get(pointerIndex).toolType;
+    }
+
+    public long getEventTime() {
+      return mSampleEventTimes.get(getHistorySize());
+    }
+
+    public PointerCoords getRawPointerCoords(int pointerIndex) {
+
+      return mSamplePointerCoords.get(getHistorySize() * getPointerCount() + pointerIndex);
+    }
+
+    public float getRawAxisValue(int axis, int pointerIndex) {
+      return getRawPointerCoords(pointerIndex).getAxisValue(axis);
+    }
+
+    public float getRawX(int pointerIndex) {
+      return getRawAxisValue(AMOTION_EVENT_AXIS_X, pointerIndex);
+    }
+
+    public float getRawY(int pointerIndex) {
+      return getRawAxisValue(AMOTION_EVENT_AXIS_Y, pointerIndex);
+    }
+
+    public float getAxisValue(int axis, int pointerIndex) {
+      float value = getRawPointerCoords(pointerIndex).getAxisValue(axis);
+      switch (axis) {
+        case AMOTION_EVENT_AXIS_X:
+          return value + mXOffset;
+        case AMOTION_EVENT_AXIS_Y:
+          return value + mYOffset;
+      }
+      return value;
+    }
+
+    public float getX(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_X, pointerIndex);
+    }
+
+    public float getY(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_Y, pointerIndex);
+    }
+
+    public float getPressure(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_PRESSURE, pointerIndex);
+    }
+
+    public float getSize(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_SIZE, pointerIndex);
+    }
+
+    public float getTouchMajor(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_TOUCH_MAJOR, pointerIndex);
+    }
+
+    public float getTouchMinor(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_TOUCH_MINOR, pointerIndex);
+    }
+
+    public float getToolMajor(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_TOOL_MAJOR, pointerIndex);
+    }
+
+    public float getToolMinor(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_TOOL_MINOR, pointerIndex);
+    }
+
+    public float getOrientation(int pointerIndex) {
+      return getAxisValue(AMOTION_EVENT_AXIS_ORIENTATION, pointerIndex);
+    }
+
+    public int getHistorySize() {
+      return mSampleEventTimes.size() - 1;
+    }
+
+    public long getHistoricalEventTime(int historicalIndex) {
+      return mSampleEventTimes.get(historicalIndex);
+    }
+
+    public PointerCoords getHistoricalRawPointerCoords(int pointerIndex, int historicalIndex) {
+      return mSamplePointerCoords.get(historicalIndex * getPointerCount() + pointerIndex);
+    }
+
+    public float getHistoricalRawAxisValue(int axis, int pointerIndex, int historicalIndex) {
+      return getHistoricalRawPointerCoords(pointerIndex, historicalIndex).getAxisValue(axis);
+    }
+
+    public float getHistoricalRawX(int pointerIndex, int historicalIndex) {
+      return getHistoricalRawAxisValue(AMOTION_EVENT_AXIS_X, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalRawY(int pointerIndex, int historicalIndex) {
+      return getHistoricalRawAxisValue(AMOTION_EVENT_AXIS_Y, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalAxisValue(int axis, int pointerIndex, int historicalIndex) {
+      float value = getHistoricalRawPointerCoords(pointerIndex, historicalIndex).getAxisValue(axis);
+      switch (axis) {
+        case AMOTION_EVENT_AXIS_X:
+          return value + mXOffset;
+        case AMOTION_EVENT_AXIS_Y:
+          return value + mYOffset;
+      }
+      return value;
+    }
+
+    public float getHistoricalX(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_X, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalY(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_Y, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalPressure(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_PRESSURE, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalSize(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_SIZE, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalTouchMajor(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_TOUCH_MAJOR, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalTouchMinor(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_TOUCH_MINOR, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalToolMajor(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_TOOL_MAJOR, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalToolMinor(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_TOOL_MINOR, pointerIndex, historicalIndex);
+    }
+
+    public float getHistoricalOrientation(int pointerIndex, int historicalIndex) {
+      return getHistoricalAxisValue(AMOTION_EVENT_AXIS_ORIENTATION, pointerIndex, historicalIndex);
+    }
+
+    public int findPointerIndex(int pointerId) {
+      int pointerCount = mPointerProperties.size();
+      for (int i = 0; i < pointerCount; i++) {
+        if (mPointerProperties.get(i).id == pointerId) {
+          return i;
+        }
+      }
+      return -1;
+    }
+
+    public void initialize(
+        int deviceId,
+        int source,
+        int action,
+        int actionButton,
+        int flags,
+        int edgeFlags,
+        int metaState,
+        int buttonState,
+        float xOffset,
+        float yOffset,
+        float xPrecision,
+        float yPrecision,
+        long downTime,
+        long eventTime,
+        int pointerCount,
+        PointerProperties[] pointerProperties,
+        NativeInput.PointerCoords[] pointerCoords) {
+      super.initialize(deviceId, source);
+      mAction = action;
+      mActionButton = actionButton;
+      mFlags = flags;
+      mEdgeFlags = edgeFlags;
+      mMetaState = metaState;
+      mButtonState = buttonState;
+      mXOffset = xOffset;
+      mYOffset = yOffset;
+      mXPrecision = xPrecision;
+      mYPrecision = yPrecision;
+      mDownTime = downTime;
+      mPointerProperties.clear();
+      for (int i = 0; i < pointerCount; i++) {
+        PointerProperties copy = new PointerProperties(pointerProperties[i]);
+        mPointerProperties.add(copy);
+      }
+      mSampleEventTimes.clear();
+      mSamplePointerCoords.clear();
+      addSample(eventTime, Arrays.asList(pointerCoords).subList(0, pointerCount));
+    }
+
+    public void copyFrom(MotionEvent other, boolean keepHistory) {
+      super.initialize(other.getDeviceId(), other.getSource());
+      mAction = other.mAction;
+      mActionButton = other.mActionButton;
+      mFlags = other.mFlags;
+      mEdgeFlags = other.mEdgeFlags;
+      mMetaState = other.mMetaState;
+      mButtonState = other.mButtonState;
+      mXOffset = other.mXOffset;
+      mYOffset = other.mYOffset;
+      mXPrecision = other.mXPrecision;
+      mYPrecision = other.mYPrecision;
+      mDownTime = other.mDownTime;
+      mPointerProperties.clear();
+      for (PointerProperties pointerProperties : other.mPointerProperties) {
+        mPointerProperties.add(new PointerProperties(pointerProperties));
+      }
+      mSampleEventTimes.clear();
+      mSamplePointerCoords.clear();
+      if (keepHistory) {
+        mSampleEventTimes.addAll(other.mSampleEventTimes);
+        mSamplePointerCoords.addAll(other.mSamplePointerCoords);
+      } else {
+        mSampleEventTimes.add(other.getEventTime());
+        int pointerCount = other.getPointerCount();
+        int historySize = other.getHistorySize();
+        // mSamplePointerCoords.appendArray(other->mSamplePointerCoords.array()
+        //    + (historySize * pointerCount), pointerCount);
+        int currentStartIndex = historySize * pointerCount;
+        mSamplePointerCoords.addAll(
+            other.mSamplePointerCoords.subList(
+                currentStartIndex, currentStartIndex + pointerCount));
+      }
+    }
+
+    public void addSample(long eventTime, PointerCoords[] pointerCoords) {
+      addSample(eventTime, Arrays.asList(pointerCoords));
+    }
+
+    public void addSample(long eventTime, List<PointerCoords> pointerCoords) {
+      mSampleEventTimes.add(eventTime);
+      mSamplePointerCoords.addAll(pointerCoords);
+    }
+
+    public void offsetLocation(float xOffset, float yOffset) {
+      mXOffset += xOffset;
+      mYOffset += yOffset;
+    }
+
+    public void scale(float scaleFactor) {
+      mXOffset *= scaleFactor;
+      mYOffset *= scaleFactor;
+      mXPrecision *= scaleFactor;
+      mYPrecision *= scaleFactor;
+      int numSamples = mSamplePointerCoords.size();
+      for (int i = 0; i < numSamples; i++) {
+        mSamplePointerCoords.get(i).scale(scaleFactor);
+      }
+    }
+
+    // Apply 3x3 perspective matrix transformation.
+    // Matrix is in row-major form and compatible with SkMatrix.
+    public void transform(float[] matrix) {
+      checkState(matrix.length == 9);
+      // The tricky part of this implementation is to preserve the value of
+      // rawX and rawY.  So we apply the transformation to the first point
+      // then derive an appropriate new X/Y offset that will preserve rawX
+      // and rawY for that point.
+      float oldXOffset = mXOffset;
+      float oldYOffset = mYOffset;
+      final Ref<Float> newX = new Ref<>(0f);
+      final Ref<Float> newY = new Ref<>(0f);
+      float rawX = getRawX(0);
+      float rawY = getRawY(0);
+      transformPoint(matrix, rawX + oldXOffset, rawY + oldYOffset, newX, newY);
+      mXOffset = newX.get() - rawX;
+      mYOffset = newY.get() - rawY;
+      // Determine how the origin is transformed by the matrix so that we
+      // can transform orientation vectors.
+      final Ref<Float> originX = new Ref<>(0f);
+      final Ref<Float> originY = new Ref<>(0f);
+      transformPoint(matrix, 0, 0, originX, originY);
+      // Apply the transformation to all samples.
+      int numSamples = mSamplePointerCoords.size();
+      for (int i = 0; i < numSamples; i++) {
+        PointerCoords c = mSamplePointerCoords.get(i);
+        final Ref<Float> x = new Ref<>(c.getAxisValue(AMOTION_EVENT_AXIS_X) + oldXOffset);
+        final Ref<Float> y = new Ref<>(c.getAxisValue(AMOTION_EVENT_AXIS_Y) + oldYOffset);
+        transformPoint(matrix, x.get(), y.get(), x, y);
+        c.setAxisValue(AMOTION_EVENT_AXIS_X, x.get() - mXOffset);
+        c.setAxisValue(AMOTION_EVENT_AXIS_Y, y.get() - mYOffset);
+        float orientation = c.getAxisValue(AMOTION_EVENT_AXIS_ORIENTATION);
+        c.setAxisValue(
+            AMOTION_EVENT_AXIS_ORIENTATION,
+            transformAngle(matrix, orientation, originX.get(), originY.get()));
+      }
+    }
+
+    private static void transformPoint(
+        float[] matrix, float x, float y, Ref<Float> outX, Ref<Float> outY) {
+      checkState(matrix.length == 9);
+      // Apply perspective transform like Skia.
+      float newX = matrix[0] * x + matrix[1] * y + matrix[2];
+      float newY = matrix[3] * x + matrix[4] * y + matrix[5];
+      float newZ = matrix[6] * x + matrix[7] * y + matrix[8];
+      if (newZ != 0) {
+        newZ = 1.0f / newZ;
+      }
+      outX.set(newX * newZ);
+      outY.set(newY * newZ);
+    }
+
+    static float transformAngle(float[] matrix, float angleRadians, float originX, float originY) {
+      checkState(matrix.length == 9);
+      // ruct and transform a vector oriented at the specified clockwise angle from vertical.
+      // Coordinate system: down is increasing Y, right is increasing X.
+      final Ref<Float> x = new Ref<>((float) Math.sin(angleRadians));
+      final Ref<Float> y = new Ref<>(-(float) Math.cos(angleRadians));
+      transformPoint(matrix, x.get(), y.get(), x, y);
+      x.set(x.get() - originX);
+      y.set(y.get() - originY);
+      // Derive the transformed vector's clockwise angle from vertical.
+      double result = Math.atan2(x.get(), -y.get());
+      if (result < -M_PI_2) {
+        result += M_PI;
+      } else if (result > M_PI_2) {
+        result -= M_PI;
+      }
+      return (float) result;
+    }
+
+    public boolean readFromParcel(Parcel parcel) {
+      int pointerCount = parcel.readInt();
+      int sampleCount = parcel.readInt();
+      if (pointerCount == 0
+          || pointerCount > MAX_POINTERS
+          || sampleCount == 0
+          || sampleCount > MAX_SAMPLES) {
+        return false;
+      }
+      mDeviceId = parcel.readInt();
+      mSource = parcel.readInt();
+      mAction = parcel.readInt();
+      mActionButton = parcel.readInt();
+      mFlags = parcel.readInt();
+      mEdgeFlags = parcel.readInt();
+      mMetaState = parcel.readInt();
+      mButtonState = parcel.readInt();
+      mXOffset = parcel.readFloat();
+      mYOffset = parcel.readFloat();
+      mXPrecision = parcel.readFloat();
+      mYPrecision = parcel.readFloat();
+      mDownTime = parcel.readLong();
+      mPointerProperties = new ArrayList<>(pointerCount);
+      mSampleEventTimes = new ArrayList<>(sampleCount);
+      mSamplePointerCoords = new ArrayList<>(sampleCount * pointerCount);
+      for (int i = 0; i < pointerCount; i++) {
+        PointerProperties properties = new PointerProperties();
+        mPointerProperties.add(properties);
+        properties.id = parcel.readInt();
+        properties.toolType = parcel.readInt();
+      }
+      while (sampleCount > 0) {
+        sampleCount--;
+        mSampleEventTimes.add(parcel.readLong());
+        for (int i = 0; i < pointerCount; i++) {
+          NativeInput.PointerCoords pointerCoords = new NativeInput.PointerCoords();
+          mSamplePointerCoords.add(pointerCoords);
+          if (!pointerCoords.readFromParcel(parcel)) {
+            return false;
+          }
+        }
+      }
+      return true;
+    }
+
+    public boolean writeToParcel(Parcel parcel) {
+      int pointerCount = mPointerProperties.size();
+      int sampleCount = mSampleEventTimes.size();
+      parcel.writeInt(pointerCount);
+      parcel.writeInt(sampleCount);
+      parcel.writeInt(mDeviceId);
+      parcel.writeInt(mSource);
+      parcel.writeInt(mAction);
+      parcel.writeInt(mActionButton);
+      parcel.writeInt(mFlags);
+      parcel.writeInt(mEdgeFlags);
+      parcel.writeInt(mMetaState);
+      parcel.writeInt(mButtonState);
+      parcel.writeFloat(mXOffset);
+      parcel.writeFloat(mYOffset);
+      parcel.writeFloat(mXPrecision);
+      parcel.writeFloat(mYPrecision);
+      parcel.writeLong(mDownTime);
+      for (int i = 0; i < pointerCount; i++) {
+        PointerProperties properties = mPointerProperties.get(i);
+        parcel.writeInt(properties.id);
+        parcel.writeInt(properties.toolType);
+      }
+      for (int h = 0; h < sampleCount; h++) {
+        parcel.writeLong(mSampleEventTimes.get(h));
+        for (int i = 0; i < pointerCount; i++) {
+          if (!mSamplePointerCoords.get(i).writeToParcel(parcel)) {
+            return false;
+          }
+        }
+      }
+      return true;
+    }
+
+    public static boolean isTouchEvent(int source, int action) {
+      if ((source & AINPUT_SOURCE_CLASS_POINTER) != 0) {
+        // Specifically excludes HOVER_MOVE and SCROLL.
+        switch (action & AMOTION_EVENT_ACTION_MASK) {
+          case AMOTION_EVENT_ACTION_DOWN:
+          case AMOTION_EVENT_ACTION_MOVE:
+          case AMOTION_EVENT_ACTION_UP:
+          case AMOTION_EVENT_ACTION_POINTER_DOWN:
+          case AMOTION_EVENT_ACTION_POINTER_UP:
+          case AMOTION_EVENT_ACTION_CANCEL:
+          case AMOTION_EVENT_ACTION_OUTSIDE:
+            return true;
+        }
+      }
+      return false;
+    }
+
+    public boolean isTouchEvent() {
+      return isTouchEvent(getSource(), mAction);
+    }
+
+    // Low-level accessors.
+    public List<PointerProperties> getPointerProperties() {
+      return mPointerProperties;
+    }
+
+    List<Long> getSampleEventTimes() {
+      return mSampleEventTimes;
+    }
+
+    List<NativeInput.PointerCoords> getSamplePointerCoords() {
+      return mSamplePointerCoords;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/NrQosSessionAttributesBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/NrQosSessionAttributesBuilder.java
new file mode 100644
index 0000000..38d1990
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/NrQosSessionAttributesBuilder.java
@@ -0,0 +1,79 @@
+package org.robolectric.shadows;
+
+import android.annotation.TargetApi;
+import android.os.Build.VERSION_CODES;
+import android.telephony.data.NrQosSessionAttributes;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Class to build {@link NrQosSessionAttributes}. */
+@TargetApi(VERSION_CODES.S)
+public final class NrQosSessionAttributesBuilder {
+
+  private int fiveQi;
+  private int qfi;
+  private long maxDownlinkBitRate;
+  private long maxUplinkBitRate;
+  private long guaranteedDownlinkBitRate;
+  private long guaranteedUplinkBitRate;
+  private long averagingWindow;
+  private final List<InetSocketAddress> remoteAddresses = new ArrayList<>();
+
+  public static NrQosSessionAttributesBuilder newBuilder() {
+    return new NrQosSessionAttributesBuilder();
+  }
+
+  public NrQosSessionAttributesBuilder setFiveQi(int fiveQi) {
+    this.fiveQi = fiveQi;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder setQfi(int qfi) {
+    this.qfi = qfi;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder setMaxDownlinkBitRate(long maxDownlinkBitRate) {
+    this.maxDownlinkBitRate = maxDownlinkBitRate;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder setMaxUplinkBitRate(long maxUplinkBitRate) {
+    this.maxUplinkBitRate = maxUplinkBitRate;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder setGuaranteedDownlinkBitRate(
+      long guaranteedDownlinkBitRate) {
+    this.guaranteedDownlinkBitRate = guaranteedDownlinkBitRate;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder setGuaranteedUplinkBitRate(long guaranteedUplinkBitRate) {
+    this.guaranteedUplinkBitRate = guaranteedUplinkBitRate;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder setAveragingWindow(long averagingWindow) {
+    this.averagingWindow = averagingWindow;
+    return this;
+  }
+
+  public NrQosSessionAttributesBuilder addRemoteAddress(InetSocketAddress remoteAddress) {
+    this.remoteAddresses.add(remoteAddress);
+    return this;
+  }
+
+  public NrQosSessionAttributes build() {
+    return new NrQosSessionAttributes(
+        fiveQi,
+        qfi,
+        maxDownlinkBitRate,
+        maxUplinkBitRate,
+        guaranteedDownlinkBitRate,
+        guaranteedUplinkBitRate,
+        averagingWindow,
+        remoteAddresses);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/OsConstantsValues.java b/shadows/framework/src/main/java/org/robolectric/shadows/OsConstantsValues.java
new file mode 100644
index 0000000..0532c4f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/OsConstantsValues.java
@@ -0,0 +1,78 @@
+package org.robolectric.shadows;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.File;
+
+/**
+ * Provides a utility class for OsConstants See
+ * https://unix.superglobalmegacorp.com/Net2/newsrc/sys/stat.h.html.
+ */
+final class OsConstantsValues {
+
+  private OsConstantsValues() {}
+
+  // Type of file.
+  public static final String S_IFMT = "S_IFMT";
+
+  // Directory.
+  public static final String S_IFDIR = "S_IFDIR";
+
+  // Regular file.
+  public static final String S_IFREG = "S_IFREG";
+
+  // Symbolic link.
+  public static final String S_IFLNK = "S_IFLNK";
+
+  // Type of file value.
+  public static final int S_IFMT_VALUE = 0x0170000;
+
+  // Directory value.
+  public static final int S_IFDIR_VALUE = 0x0040000;
+
+  // Regular file value.
+  public static final int S_IFREG_VALUE = 0x0100000;
+
+  // Link value.
+  public static final int S_IFLNK_VALUE = 0x0120000;
+
+  // File open mode values from
+  // https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/fcntl.h
+  static final ImmutableMap<String, Integer> OPEN_MODE_VALUES =
+      new ImmutableMap.Builder<String, Integer>()
+          .put("O_RDONLY", 0x0000)
+          .put("O_WRONLY", 0x0001)
+          .put("O_RDWR", 0x0002)
+          .put("O_ACCMODE", 0x0003)
+          .put("O_CREAT", 0x0100)
+          .put("O_EXCL", 0x0200)
+          .put("O_TRUNC", 0x1000)
+          .put("O_APPEND", 0x2000)
+          .build();
+
+  /** Returns the st_mode for the path. */
+  public static int getMode(String path) {
+    if (path == null) {
+      return 0;
+    }
+
+    File file = new File(path);
+    if (file.isDirectory()) {
+      return S_IFDIR_VALUE;
+    }
+    if (file.isFile()) {
+      return S_IFREG_VALUE;
+    }
+    if (!canonicalize(path).equals(path)) {
+      return S_IFLNK_VALUE;
+    }
+    return 0;
+  }
+
+  private static String canonicalize(String path) {
+    try {
+      return new File(path).getCanonicalPath();
+    } catch (Throwable t) {
+      throw new RuntimeException(t);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/PackageRollbackInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/PackageRollbackInfoBuilder.java
new file mode 100644
index 0000000..ac71cb5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/PackageRollbackInfoBuilder.java
@@ -0,0 +1,159 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import android.content.pm.VersionedPackage;
+import android.content.rollback.PackageRollbackInfo;
+import android.content.rollback.PackageRollbackInfo.RestoreInfo;
+import android.os.Build.VERSION_CODES;
+import android.util.IntArray;
+import android.util.SparseLongArray;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Builder for {@link PackageRollbackInfo} as PackageRollbackInfo has hidden constructors, this
+ * builder class has been added as a way to make custom PackageRollbackInfo objects when needed.
+ */
+public final class PackageRollbackInfoBuilder {
+
+  @Nullable private VersionedPackage packageRolledBackFrom;
+  @Nullable private VersionedPackage packageRolledBackTo;
+  private final IntArray pendingBackups = new IntArray();
+  private final ArrayList<RestoreInfo> pendingRestores = new ArrayList<>();
+  private boolean isApex;
+  private boolean isApkInApex;
+  private final IntArray installedUsers = new IntArray();
+  private final IntArray snapshottedUsers = new IntArray();
+  private SparseLongArray ceSnapshotInodes = new SparseLongArray();
+
+  private PackageRollbackInfoBuilder() {}
+
+  /**
+   * Start building a new PackageRollbackInfo
+   *
+   * @return a new instance of {@link PackageRollbackInfoBuilder}.
+   */
+  public static PackageRollbackInfoBuilder newBuilder() {
+    return new PackageRollbackInfoBuilder();
+  }
+
+  /** Sets the version packaged rolled back from. */
+  public PackageRollbackInfoBuilder setPackageRolledBackFrom(
+      VersionedPackage packageRolledBackFrom) {
+    this.packageRolledBackFrom = packageRolledBackFrom;
+    return this;
+  }
+
+  /** Sets the version packaged rolled back to. */
+  public PackageRollbackInfoBuilder setPackageRolledBackTo(VersionedPackage packageRolledBackTo) {
+    this.packageRolledBackTo = packageRolledBackTo;
+    return this;
+  }
+
+  /** Adds pending backup. We choose this API because IntArray is not publicly available. */
+  public PackageRollbackInfoBuilder addPendingBackup(int pendingBackup) {
+    this.pendingBackups.add(pendingBackup);
+    return this;
+  }
+
+  /** Adds pending restores. We choose this API because RestoreInfo is not publicly available. */
+  public PackageRollbackInfoBuilder addPendingRestore(int userId, int appId, String seInfo) {
+    this.pendingRestores.add(new PackageRollbackInfo.RestoreInfo(userId, appId, seInfo));
+    return this;
+  }
+
+  /** Sets is apex. */
+  public PackageRollbackInfoBuilder setIsApex(boolean isApex) {
+    this.isApex = isApex;
+    return this;
+  }
+
+  /** Sets is apk in apex. */
+  public PackageRollbackInfoBuilder setIsApkInApex(boolean isApkInApex) {
+    this.isApkInApex = isApkInApex;
+    return this;
+  }
+
+  /** Adds installed user. We choose this API because IntArray is not publicly available. */
+  public PackageRollbackInfoBuilder addInstalledUser(int installedUser) {
+    this.installedUsers.add(installedUser);
+    return this;
+  }
+
+  /** Adds snapshotted user. We choose this API because IntArray is not publicly available. */
+  public PackageRollbackInfoBuilder addSnapshottedUser(int snapshottedUser) {
+    this.snapshottedUsers.add(snapshottedUser);
+    return this;
+  }
+
+  /** Sets ce snapshot inodes. */
+  public PackageRollbackInfoBuilder setCeSnapshotInodes(SparseLongArray ceSnapshotInodes) {
+    checkNotNull(ceSnapshotInodes, "Field 'packageRolledBackFrom' not allowed to be null.");
+    this.ceSnapshotInodes = ceSnapshotInodes;
+    return this;
+  }
+
+  private List<Integer> getPendingBackupsList() {
+    List<Integer> pendingBackupsList = new ArrayList<>();
+    for (int pendingBackup : pendingBackups.toArray()) {
+      pendingBackupsList.add(pendingBackup);
+    }
+    return pendingBackupsList;
+  }
+
+  private List<Integer> getSnapshottedUsersList() {
+    List<Integer> snapshottedUsersList = new ArrayList<>();
+    for (int snapshottedUser : snapshottedUsers.toArray()) {
+      snapshottedUsersList.add(snapshottedUser);
+    }
+    return snapshottedUsersList;
+  }
+
+  /** Returns a {@link PackageRollbackInfo} with the data that was given. */
+  public PackageRollbackInfo build() {
+    // Check mandatory fields.
+    checkNotNull(packageRolledBackFrom, "Mandatory field 'packageRolledBackFrom' missing.");
+    checkNotNull(packageRolledBackTo, "Mandatory field 'packageRolledBackTo' missing.");
+    checkState(RuntimeEnvironment.getApiLevel() >= VERSION_CODES.Q);
+
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel == VERSION_CODES.Q) {
+      return ReflectionHelpers.callConstructor(
+          PackageRollbackInfo.class,
+          ReflectionHelpers.ClassParameter.from(VersionedPackage.class, packageRolledBackFrom),
+          ReflectionHelpers.ClassParameter.from(VersionedPackage.class, packageRolledBackTo),
+          ReflectionHelpers.ClassParameter.from(IntArray.class, pendingBackups),
+          ReflectionHelpers.ClassParameter.from(ArrayList.class, pendingRestores),
+          ReflectionHelpers.ClassParameter.from(Boolean.TYPE, isApex),
+          ReflectionHelpers.ClassParameter.from(IntArray.class, installedUsers),
+          ReflectionHelpers.ClassParameter.from(SparseLongArray.class, ceSnapshotInodes));
+    } else if (apiLevel == VERSION_CODES.R) {
+      return ReflectionHelpers.callConstructor(
+          PackageRollbackInfo.class,
+          ReflectionHelpers.ClassParameter.from(VersionedPackage.class, packageRolledBackFrom),
+          ReflectionHelpers.ClassParameter.from(VersionedPackage.class, packageRolledBackTo),
+          ReflectionHelpers.ClassParameter.from(IntArray.class, pendingBackups),
+          ReflectionHelpers.ClassParameter.from(ArrayList.class, pendingRestores),
+          ReflectionHelpers.ClassParameter.from(Boolean.TYPE, isApex),
+          ReflectionHelpers.ClassParameter.from(Boolean.TYPE, isApkInApex),
+          ReflectionHelpers.ClassParameter.from(IntArray.class, snapshottedUsers),
+          ReflectionHelpers.ClassParameter.from(SparseLongArray.class, ceSnapshotInodes));
+    } else if (apiLevel > VERSION_CODES.R) {
+      return new PackageRollbackInfo(
+          packageRolledBackFrom,
+          packageRolledBackTo,
+          getPendingBackupsList(),
+          pendingRestores,
+          isApex,
+          isApkInApex,
+          getSnapshottedUsersList());
+    } else {
+      throw new UnsupportedOperationException("PackageRollbackInfoBuilder requires SDK >= Q");
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/PhoneAccountBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/PhoneAccountBuilder.java
new file mode 100644
index 0000000..cd03fd9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/PhoneAccountBuilder.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+
+/**
+ * A more advanced builder for {@link PhoneAccount} that gives access to some hidden methods on
+ * {@link PhoneAccount.Builder}.
+ */
+public class PhoneAccountBuilder extends PhoneAccount.Builder {
+
+  public PhoneAccountBuilder(PhoneAccountHandle accountHandle, CharSequence label) {
+    super(accountHandle, label);
+  }
+
+  public PhoneAccountBuilder(PhoneAccount phoneAccount) {
+    super(phoneAccount);
+  }
+
+  @Override
+  public PhoneAccountBuilder setIsEnabled(boolean isEnabled) {
+    super.setIsEnabled(isEnabled);
+    return this;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/PlaybackInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/PlaybackInfoBuilder.java
new file mode 100644
index 0000000..f22d0d4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/PlaybackInfoBuilder.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import android.media.AudioAttributes;
+import android.media.session.MediaController.PlaybackInfo;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link PlaybackInfo} */
+public class PlaybackInfoBuilder {
+  private int volumeType;
+  private int volumeControl;
+  private int maxVolume;
+  private int currentVolume;
+  private AudioAttributes audioAttrs;
+
+  private PlaybackInfoBuilder() {}
+
+  public static PlaybackInfoBuilder newBuilder() {
+    return new PlaybackInfoBuilder();
+  }
+
+  public PlaybackInfoBuilder setVolumeType(int volumeType) {
+    this.volumeType = volumeType;
+    return this;
+  }
+
+  public PlaybackInfoBuilder setVolumeControl(int volumeControl) {
+    this.volumeControl = volumeControl;
+    return this;
+  }
+
+  public PlaybackInfoBuilder setMaxVolume(int maxVolume) {
+    this.maxVolume = maxVolume;
+    return this;
+  }
+
+  public PlaybackInfoBuilder setCurrentVolume(int currentVolume) {
+    this.currentVolume = currentVolume;
+    return this;
+  }
+
+  public PlaybackInfoBuilder setAudioAttributes(AudioAttributes audioAttrs) {
+    this.audioAttrs = audioAttrs;
+    return this;
+  }
+
+  public PlaybackInfo build() {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel < VERSION_CODES.Q) {
+      return ReflectionHelpers.callConstructor(
+          PlaybackInfo.class,
+          ClassParameter.from(int.class, volumeType),
+          ClassParameter.from(AudioAttributes.class, audioAttrs),
+          ClassParameter.from(int.class, volumeControl),
+          ClassParameter.from(int.class, maxVolume),
+          ClassParameter.from(int.class, currentVolume));
+    } else if (apiLevel == VERSION_CODES.Q) {
+      return ReflectionHelpers.callConstructor(
+          PlaybackInfo.class,
+          ClassParameter.from(int.class, volumeType),
+          ClassParameter.from(int.class, volumeControl),
+          ClassParameter.from(int.class, maxVolume),
+          ClassParameter.from(int.class, currentVolume),
+          ClassParameter.from(AudioAttributes.class, audioAttrs));
+    } else {
+      return new PlaybackInfo(
+          volumeType, volumeControl, maxVolume, currentVolume, audioAttrs, null);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/PreLPointers.java b/shadows/framework/src/main/java/org/robolectric/shadows/PreLPointers.java
new file mode 100644
index 0000000..46830f9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/PreLPointers.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A pointer registration system used to associate real (long) pointers with fake 32-bit pointers
+ * used in pre-lollipop.
+ */
+class PreLPointers {
+  static final Map<Integer, Long> preLPointers = new ConcurrentHashMap<>();
+  private static final AtomicInteger nextPreLPointer = new AtomicInteger(1);
+
+  private PreLPointers() {}
+
+  static int register(long realPtr) {
+    int nextPtr = nextPreLPointer.incrementAndGet();
+    preLPointers.put(nextPtr, realPtr);
+    return nextPtr;
+  }
+
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  static long get(int fakePtr) {
+    return preLPointers.computeIfAbsent(
+        fakePtr,
+        integer -> {
+          throw new AssertionError("Missing pre-L pointer " + fakePtr);
+        });
+  }
+
+  static void remove(int fakePtr) {
+    if (!preLPointers.containsKey(fakePtr)) {
+      throw new AssertionError("Missing pre-L pointer " + fakePtr);
+    }
+    preLPointers.remove(fakePtr);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/PreciseDataConnectionStateBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/PreciseDataConnectionStateBuilder.java
new file mode 100644
index 0000000..1fa638b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/PreciseDataConnectionStateBuilder.java
@@ -0,0 +1,89 @@
+package org.robolectric.shadows;
+
+import android.net.LinkProperties;
+import android.os.Build.VERSION_CODES;
+import android.telephony.PreciseDataConnectionState;
+import android.telephony.data.ApnSetting;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link PreciseDataConnectionState} */
+public class PreciseDataConnectionStateBuilder {
+
+  private int dataState;
+  private int networkType;
+  private int transportType;
+  private int id;
+  private LinkProperties linkProperties;
+  private ApnSetting apnSetting;
+  private int dataFailCause;
+
+  private PreciseDataConnectionStateBuilder() {}
+
+  public static PreciseDataConnectionStateBuilder newBuilder() {
+    return new PreciseDataConnectionStateBuilder();
+  }
+
+  public PreciseDataConnectionStateBuilder setDataState(int dataState) {
+    this.dataState = dataState;
+    return this;
+  }
+
+  public PreciseDataConnectionStateBuilder setNetworkType(int networkType) {
+    this.networkType = networkType;
+    return this;
+  }
+
+  public PreciseDataConnectionStateBuilder setTransportType(int transportType) {
+    this.transportType = networkType;
+    return this;
+  }
+
+  public PreciseDataConnectionStateBuilder setLinkProperties(LinkProperties linkProperties) {
+    this.linkProperties = linkProperties;
+    return this;
+  }
+
+  public PreciseDataConnectionStateBuilder setId(int id) {
+    this.id = id;
+    return this;
+  }
+
+  public PreciseDataConnectionStateBuilder setApnSetting(ApnSetting apnSetting) {
+    this.apnSetting = apnSetting;
+    return this;
+  }
+
+  public PreciseDataConnectionStateBuilder setDataFailCause(int dataFailCause) {
+    this.dataFailCause = dataFailCause;
+    return this;
+  }
+
+  public PreciseDataConnectionState build() {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel <= VERSION_CODES.R) {
+      return ReflectionHelpers.callConstructor(
+          PreciseDataConnectionState.class,
+          ClassParameter.from(int.class, dataState),
+          ClassParameter.from(int.class, networkType),
+          ClassParameter.from(
+              int.class,
+              apnSetting == null ? ApnSetting.TYPE_DEFAULT : apnSetting.getApnTypeBitmask()),
+          ClassParameter.from(String.class, apnSetting == null ? "" : apnSetting.getApnName()),
+          ClassParameter.from(LinkProperties.class, linkProperties),
+          ClassParameter.from(int.class, dataFailCause),
+          ClassParameter.from(ApnSetting.class, apnSetting));
+    } else {
+      return new PreciseDataConnectionState.Builder()
+          .setTransportType(transportType)
+          .setId(id)
+          .setState(dataState)
+          .setNetworkType(networkType)
+          .setLinkProperties(linkProperties)
+          .setFailCause(dataFailCause)
+          .setApnSetting(apnSetting)
+          .build();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/Provider.java b/shadows/framework/src/main/java/org/robolectric/shadows/Provider.java
new file mode 100644
index 0000000..41a3646
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/Provider.java
@@ -0,0 +1,5 @@
+package org.robolectric.shadows;
+
+public interface Provider<T> {
+  T get();
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/RangingSessionBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/RangingSessionBuilder.java
new file mode 100644
index 0000000..37805f6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/RangingSessionBuilder.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import android.uwb.IUwbAdapter;
+import android.uwb.RangingSession;
+import android.uwb.SessionHandle;
+import java.util.concurrent.Executor;
+
+/** Class to build {@link RangingSession} */
+public class RangingSessionBuilder {
+
+  private Executor executor;
+  private RangingSession.Callback callback;
+  private IUwbAdapter adapter;
+  private SessionHandle handle;
+
+  private RangingSessionBuilder() {}
+
+  public static RangingSessionBuilder newBuilder() {
+    return new RangingSessionBuilder();
+  }
+
+  public RangingSessionBuilder setExecutor(Executor executor) {
+    this.executor = executor;
+    return this;
+  }
+
+  public RangingSessionBuilder setCallback(RangingSession.Callback callback) {
+    this.callback = callback;
+    return this;
+  }
+
+  public RangingSessionBuilder setIUwbAdapter(IUwbAdapter adapter) {
+    this.adapter = adapter;
+    return this;
+  }
+
+  public RangingSessionBuilder setSessionHandle(SessionHandle handle) {
+    this.handle = handle;
+    return this;
+  }
+
+  public RangingSession build() {
+    return new RangingSession(executor, callback, adapter, handle);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ResourceHelper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceHelper.java
new file mode 100644
index 0000000..7fe54d9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceHelper.java
@@ -0,0 +1,317 @@
+/*
+ * Copyright (C) 2008 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.shadows;
+
+import android.util.TypedValue;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Helper class to provide various conversion method used in handling android resources.
+ */
+public final class ResourceHelper {
+
+  private final static Pattern sFloatPattern = Pattern.compile("(-?[0-9]*(?:\\.[0-9]+)?)(.*)");
+  private final static float[] sFloatOut = new float[1];
+
+  private final static TypedValue mValue = new TypedValue();
+
+  private final static Class<?> androidInternalR;
+
+  static {
+    try {
+      androidInternalR = Class.forName("com.android.internal.R$id");
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Returns the color value represented by the given string value
+   *
+   * @param value the color value
+   * @return the color as an int
+   * @throws NumberFormatException if the conversion failed.
+   */
+  public static int getColor(String value) {
+    if (value != null) {
+      if (value.startsWith("#") == false) {
+        throw new NumberFormatException(
+            String.format("Color value '%s' must start with #", value));
+      }
+
+      value = value.substring(1);
+
+      // make sure it's not longer than 32bit
+      if (value.length() > 8) {
+        throw new NumberFormatException(String.format(
+            "Color value '%s' is too long. Format is either" +
+            "#AARRGGBB, #RRGGBB, #RGB, or #ARGB",
+            value));
+      }
+
+      if (value.length() == 3) { // RGB format
+        char[] color = new char[8];
+        color[0] = color[1] = 'F';
+        color[2] = color[3] = value.charAt(0);
+        color[4] = color[5] = value.charAt(1);
+        color[6] = color[7] = value.charAt(2);
+        value = new String(color);
+      } else if (value.length() == 4) { // ARGB format
+        char[] color = new char[8];
+        color[0] = color[1] = value.charAt(0);
+        color[2] = color[3] = value.charAt(1);
+        color[4] = color[5] = value.charAt(2);
+        color[6] = color[7] = value.charAt(3);
+        value = new String(color);
+      } else if (value.length() == 6) {
+        value = "FF" + value;
+      }
+
+      // this is a RRGGBB or AARRGGBB value
+
+      // Integer.parseInt will fail to inferFromValue strings like "ff191919", so we use
+      // a Long, but cast the result back into an int, since we know that we're only
+      // dealing with 32 bit values.
+      return (int)Long.parseLong(value, 16);
+    }
+
+    throw new NumberFormatException();
+  }
+
+  /**
+   * Returns the TypedValue color type represented by the given string value
+   *
+   * @param value the color value
+   * @return the color as an int. For backwards compatibility, will return a default of ARGB8 if
+   *   value format is unrecognized.
+   */
+  public static int getColorType(String value) {
+    if (value != null && value.startsWith("#")) {
+      switch (value.length()) {
+        case 4:
+          return TypedValue.TYPE_INT_COLOR_RGB4;
+        case 5:
+          return TypedValue.TYPE_INT_COLOR_ARGB4;
+        case 7:
+          return TypedValue.TYPE_INT_COLOR_RGB8;
+        case 9:
+          return TypedValue.TYPE_INT_COLOR_ARGB8;
+      }
+    }
+    return TypedValue.TYPE_INT_COLOR_ARGB8;
+  }
+
+  public static int getInternalResourceId(String idName) {
+    try {
+      return (int) androidInternalR.getField(idName).get(null);
+    } catch (NoSuchFieldException | IllegalAccessException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  // ------- TypedValue stuff
+  // This is taken from //device/libs/utils/ResourceTypes.cpp
+
+  private static final class UnitEntry {
+    String name;
+    int type;
+    int unit;
+    float scale;
+
+    UnitEntry(String name, int type, int unit, float scale) {
+      this.name = name;
+      this.type = type;
+      this.unit = unit;
+      this.scale = scale;
+    }
+  }
+
+  private final static UnitEntry[] sUnitNames = new UnitEntry[] {
+    new UnitEntry("px", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_PX, 1.0f),
+    new UnitEntry("dip", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_DIP, 1.0f),
+    new UnitEntry("dp", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_DIP, 1.0f),
+    new UnitEntry("sp", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_SP, 1.0f),
+    new UnitEntry("pt", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_PT, 1.0f),
+    new UnitEntry("in", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_IN, 1.0f),
+    new UnitEntry("mm", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_MM, 1.0f),
+    new UnitEntry("%", TypedValue.TYPE_FRACTION, TypedValue.COMPLEX_UNIT_FRACTION, 1.0f/100),
+    new UnitEntry("%p", TypedValue.TYPE_FRACTION, TypedValue.COMPLEX_UNIT_FRACTION_PARENT, 1.0f/100),
+  };
+
+  /**
+   * Returns the raw value from the given attribute float-type value string.
+   * This object is only valid until the next call on to {@link ResourceHelper}.
+   *
+   * @param attribute Attribute name.
+   * @param value Attribute value.
+   * @param requireUnit whether the value is expected to contain a unit.
+   * @return The typed value.
+   */
+  public static TypedValue getValue(String attribute, String value, boolean requireUnit) {
+    if (parseFloatAttribute(attribute, value, mValue, requireUnit)) {
+      return mValue;
+    }
+
+    return null;
+  }
+
+  /**
+   * Parse a float attribute and return the parsed value into a given TypedValue.
+   * @param attribute the name of the attribute. Can be null if <var>requireUnit</var> is false.
+   * @param value the string value of the attribute
+   * @param outValue the TypedValue to receive the parsed value
+   * @param requireUnit whether the value is expected to contain a unit.
+   * @return true if success.
+   */
+  public static boolean parseFloatAttribute(String attribute, String value,
+      TypedValue outValue, boolean requireUnit) {
+    assert requireUnit == false || attribute != null;
+
+    // remove the space before and after
+    value = value.trim();
+    int len = value.length();
+
+    if (len <= 0) {
+      return false;
+    }
+
+    // check that there's no non ascii characters.
+    char[] buf = value.toCharArray();
+    for (int i = 0 ; i < len ; i++) {
+      if (buf[i] > 255) {
+        return false;
+      }
+    }
+
+    // check the first character
+    if (buf[0] < '0' && buf[0] > '9' && buf[0] != '.' && buf[0] != '-') {
+      return false;
+    }
+
+    // now look for the string that is after the float...
+    Matcher m = sFloatPattern.matcher(value);
+    if (m.matches()) {
+      String f_str = m.group(1);
+      String end = m.group(2);
+
+      float f;
+      try {
+        f = Float.parseFloat(f_str);
+      } catch (NumberFormatException e) {
+        // this shouldn't happen with the regexp above.
+        return false;
+      }
+
+      if (end.length() > 0 && end.charAt(0) != ' ') {
+        // Might be a unit...
+        if (parseUnit(end, outValue, sFloatOut)) {
+          computeTypedValue(outValue, f, sFloatOut[0]);
+          return true;
+        }
+        return false;
+      }
+
+      // make sure it's only spaces at the end.
+      end = end.trim();
+
+      if (end.length() == 0) {
+        if (outValue != null) {
+          outValue.assetCookie = 0;
+          outValue.string = null;
+
+          if (requireUnit == false) {
+            outValue.type = TypedValue.TYPE_FLOAT;
+            outValue.data = Float.floatToIntBits(f);
+          } else {
+            // no unit when required? Use dp and out an error.
+            applyUnit(sUnitNames[1], outValue, sFloatOut);
+            computeTypedValue(outValue, f, sFloatOut[0]);
+
+            System.out.println(String.format(
+                "Dimension \"%1$s\" in attribute \"%2$s\" is missing unit!",
+                    value, attribute));
+          }
+          return true;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  private static void computeTypedValue(TypedValue outValue, float value, float scale) {
+    value *= scale;
+    boolean neg = value < 0;
+    if (neg) {
+      value = -value;
+    }
+    long bits = (long)(value*(1<<23)+.5f);
+    int radix;
+    int shift;
+    if ((bits&0x7fffff) == 0) {
+      // Always use 23p0 if there is no fraction, just to make
+      // things easier to read.
+      radix = TypedValue.COMPLEX_RADIX_23p0;
+      shift = 23;
+    } else if ((bits&0xffffffffff800000L) == 0) {
+      // Magnitude is zero -- can fit in 0 bits of precision.
+      radix = TypedValue.COMPLEX_RADIX_0p23;
+      shift = 0;
+    } else if ((bits&0xffffffff80000000L) == 0) {
+      // Magnitude can fit in 8 bits of precision.
+      radix = TypedValue.COMPLEX_RADIX_8p15;
+      shift = 8;
+    } else if ((bits&0xffffff8000000000L) == 0) {
+      // Magnitude can fit in 16 bits of precision.
+      radix = TypedValue.COMPLEX_RADIX_16p7;
+      shift = 16;
+    } else {
+      // Magnitude needs entire range, so no fractional part.
+      radix = TypedValue.COMPLEX_RADIX_23p0;
+      shift = 23;
+    }
+    int mantissa = (int)(
+      (bits>>shift) & TypedValue.COMPLEX_MANTISSA_MASK);
+    if (neg) {
+      mantissa = (-mantissa) & TypedValue.COMPLEX_MANTISSA_MASK;
+    }
+    outValue.data |=
+      (radix<<TypedValue.COMPLEX_RADIX_SHIFT)
+      | (mantissa<<TypedValue.COMPLEX_MANTISSA_SHIFT);
+  }
+
+  private static boolean parseUnit(String str, TypedValue outValue, float[] outScale) {
+    str = str.trim();
+
+    for (UnitEntry unit : sUnitNames) {
+      if (unit.name.equals(str)) {
+        applyUnit(unit, outValue, outScale);
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  private static void applyUnit(UnitEntry unit, TypedValue outValue, float[] outScale) {
+    outValue.type = unit.type;
+    outValue.data = unit.unit << TypedValue.COMPLEX_UNIT_SHIFT;
+    outScale[0] = unit.scale;
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ResourceHelper2.java b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceHelper2.java
new file mode 100644
index 0000000..db42973
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceHelper2.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2008 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.shadows;
+
+import android.util.TypedValue;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.res.ResName;
+
+/**
+ * Helper class to provide various conversion method used in handling android resources.
+ */
+public final class ResourceHelper2 {
+
+  private final static Pattern sFloatPattern = Pattern.compile("(-?[0-9]+(?:\\.[0-9]+)?)(.*)");
+  private final static float[] sFloatOut = new float[1];
+
+  private final static TypedValue mValue = new TypedValue();
+
+  // ------- TypedValue stuff
+  // This is taken from //device/libs/utils/ResourceTypes.cpp
+
+  private static final class UnitEntry {
+    String name;
+    int type;
+    int unit;
+    float scale;
+
+    UnitEntry(String name, int type, int unit, float scale) {
+      this.name = name;
+      this.type = type;
+      this.unit = unit;
+      this.scale = scale;
+    }
+  }
+
+  private final static UnitEntry[] sUnitNames = new UnitEntry[] {
+    new UnitEntry("px", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_PX, 1.0f),
+    new UnitEntry("dip", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_DIP, 1.0f),
+    new UnitEntry("dp", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_DIP, 1.0f),
+    new UnitEntry("sp", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_SP, 1.0f),
+    new UnitEntry("pt", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_PT, 1.0f),
+    new UnitEntry("in", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_IN, 1.0f),
+    new UnitEntry("mm", TypedValue.TYPE_DIMENSION, TypedValue.COMPLEX_UNIT_MM, 1.0f),
+    new UnitEntry("%", TypedValue.TYPE_FRACTION, TypedValue.COMPLEX_UNIT_FRACTION, 1.0f/100),
+    new UnitEntry("%p", TypedValue.TYPE_FRACTION, TypedValue.COMPLEX_UNIT_FRACTION_PARENT, 1.0f/100),
+  };
+
+  /**
+   * Returns the raw value from the given attribute float-type value string.
+   * This object is only valid until the next call on to {@link ResourceHelper2}.
+   *
+   * @param attribute Attribute name.
+   * @param value Attribute value.
+   * @param requireUnit whether the value is expected to contain a unit.
+   * @return The typed value.
+   */
+  public static TypedValue getValue(String attribute, String value, boolean requireUnit) {
+    if (parseFloatAttribute(attribute, value, mValue, requireUnit)) {
+      return mValue;
+    }
+
+    return null;
+  }
+
+  /**
+   * Parse a float attribute and return the parsed value into a given TypedValue.
+   * @param attribute the name of the attribute. Can be null if <var>requireUnit</var> is false.
+   * @param value the string value of the attribute
+   * @param outValue the TypedValue to receive the parsed value
+   * @param requireUnit whether the value is expected to contain a unit.
+   * @return true if success.
+   */
+  public static boolean parseFloatAttribute(String attribute, String value,
+      TypedValue outValue, boolean requireUnit) {
+//    assert requireUnit == false || attribute != null;
+
+    // remove the space before and after
+    value = value.trim();
+    int len = value.length();
+
+    if (len <= 0) {
+      return false;
+    }
+
+    // check that there's no non ascii characters.
+    char[] buf = value.toCharArray();
+    for (int i = 0 ; i < len ; i++) {
+      if (buf[i] > 255) {
+        return false;
+      }
+    }
+
+    // check the first character
+    if (buf[0] < '0' && buf[0] > '9' && buf[0] != '.' && buf[0] != '-') {
+      return false;
+    }
+
+    // now look for the string that is after the float...
+    Matcher m = sFloatPattern.matcher(value);
+    if (m.matches()) {
+      String f_str = m.group(1);
+      String end = m.group(2);
+
+      float f;
+      try {
+        f = Float.parseFloat(f_str);
+      } catch (NumberFormatException e) {
+        // this shouldn't happen with the regexp above.
+        return false;
+      }
+
+      if (end.length() > 0 && end.charAt(0) != ' ') {
+        // Might be a unit...
+        if (parseUnit(end, outValue, sFloatOut)) {
+          computeTypedValue(outValue, f, sFloatOut[0], end);
+          return true;
+        }
+        return false;
+      }
+
+      // make sure it's only spaces at the end.
+      end = end.trim();
+
+      if (end.length() == 0) {
+        if (outValue != null) {
+          outValue.assetCookie = 0;
+          outValue.string = null;
+
+          if (requireUnit == false) {
+            outValue.type = TypedValue.TYPE_FLOAT;
+            outValue.data = Float.floatToIntBits(f);
+          } else {
+            // no unit when required? Use dp and out an error.
+            applyUnit(sUnitNames[1], outValue, sFloatOut);
+            computeTypedValue(outValue, f, sFloatOut[0], "dp");
+
+            System.out.println(String.format(
+                "Dimension \"%1$s\" in attribute \"%2$s\" is missing unit!",
+                    value, attribute == null ? "(unknown)" : attribute));
+          }
+          return true;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  private static void computeTypedValue(TypedValue outValue, float value, float scale, String unit) {
+    value *= scale;
+    boolean neg = value < 0;
+    if (neg) {
+      value = -value;
+    }
+    long bits = (long)(value*(1<<23)+.5f);
+    int radix;
+    int shift;
+    if ((bits&0x7fffff) == 0) {
+      // Always use 23p0 if there is no fraction, just to make
+      // things easier to read.
+      radix = TypedValue.COMPLEX_RADIX_23p0;
+      shift = 23;
+    } else if ((bits&0xffffffffff800000L) == 0) {
+      // Magnitude is zero -- can fit in 0 bits of precision.
+      radix = TypedValue.COMPLEX_RADIX_0p23;
+      shift = 0;
+    } else if ((bits&0xffffffff80000000L) == 0) {
+      // Magnitude can fit in 8 bits of precision.
+      radix = TypedValue.COMPLEX_RADIX_8p15;
+      shift = 8;
+    } else if ((bits&0xffffff8000000000L) == 0) {
+      // Magnitude can fit in 16 bits of precision.
+      radix = TypedValue.COMPLEX_RADIX_16p7;
+      shift = 16;
+    } else {
+      // Magnitude needs entire range, so no fractional part.
+      radix = TypedValue.COMPLEX_RADIX_23p0;
+      shift = 23;
+    }
+    int mantissa = (int)(
+      (bits>>shift) & TypedValue.COMPLEX_MANTISSA_MASK);
+    if (neg) {
+      mantissa = (-mantissa) & TypedValue.COMPLEX_MANTISSA_MASK;
+    }
+    outValue.data |=
+      (radix<<TypedValue.COMPLEX_RADIX_SHIFT)
+      | (mantissa<<TypedValue.COMPLEX_MANTISSA_SHIFT);
+
+    if ("%".equals(unit)) {
+      value = value * 100;
+    }
+
+    outValue.string = value + unit;
+  }
+
+  private static boolean parseUnit(String str, TypedValue outValue, float[] outScale) {
+    str = str.trim();
+
+    for (UnitEntry unit : sUnitNames) {
+      if (unit.name.equals(str)) {
+        applyUnit(unit, outValue, outScale);
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  private static void applyUnit(UnitEntry unit, TypedValue outValue, float[] outScale) {
+    outValue.type = unit.type;
+    outValue.data = unit.unit << TypedValue.COMPLEX_UNIT_SHIFT;
+    outScale[0] = unit.scale;
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java
new file mode 100644
index 0000000..5da1409
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadow.api.ShadowPicker;
+
+public class ResourceModeShadowPicker<T> implements ShadowPicker<T> {
+
+  private Class<? extends T> legacyShadowClass;
+  private Class<? extends T> binaryShadowClass;
+  private Class<? extends T> binary9ShadowClass;
+  private Class<? extends T> binary10ShadowClass;
+
+  public ResourceModeShadowPicker(Class<? extends T> legacyShadowClass,
+      Class<? extends T> binaryShadowClass,
+      Class<? extends T> binary9ShadowClass) {
+    this.legacyShadowClass = legacyShadowClass;
+    this.binaryShadowClass = binaryShadowClass;
+    this.binary9ShadowClass = binary9ShadowClass;
+    this.binary10ShadowClass = binary9ShadowClass;
+  }
+
+  public ResourceModeShadowPicker(Class<? extends T> legacyShadowClass,
+          Class<? extends T> binaryShadowClass,
+          Class<? extends T> binary9ShadowClass,
+          Class<? extends T> binary10ShadowClass) {
+    this.legacyShadowClass = legacyShadowClass;
+    this.binaryShadowClass = binaryShadowClass;
+    this.binary9ShadowClass = binary9ShadowClass;
+    this.binary10ShadowClass = binary10ShadowClass;
+  }
+
+  @Override
+  public Class<? extends T> pickShadowClass() {
+    if (ShadowAssetManager.useLegacy()) {
+      return legacyShadowClass;
+    } else {
+      if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+        return binary10ShadowClass;
+      } else
+      if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.P) {
+        return binary9ShadowClass;
+      } else {
+        return binaryShadowClass;
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/RollbackInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/RollbackInfoBuilder.java
new file mode 100644
index 0000000..9ac0493
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/RollbackInfoBuilder.java
@@ -0,0 +1,75 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import android.content.pm.VersionedPackage;
+import android.content.rollback.PackageRollbackInfo;
+import android.content.rollback.RollbackInfo;
+import android.os.Build.VERSION_CODES;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+
+/**
+ * Builder for {@link RollbackInfo} as RollbackInfo has hidden constructors, this builder class has
+ * been added as a way to make custom RollbackInfo objects when needed.
+ */
+public final class RollbackInfoBuilder {
+
+  private int rollbackId;
+  private List<PackageRollbackInfo> packages = ImmutableList.of();
+  private boolean isStaged;
+  private List<VersionedPackage> causePackages = ImmutableList.of();
+  private int committedSessionId;
+
+  private RollbackInfoBuilder() {}
+
+  /**
+   * Start building a new RollbackInfo
+   *
+   * @return a new instance of {@link RollbackInfoBuilder}.
+   */
+  public static RollbackInfoBuilder newBuilder() {
+    return new RollbackInfoBuilder();
+  }
+
+  /** Sets the id of the rollback. */
+  public RollbackInfoBuilder setRollbackId(int rollbackId) {
+    this.rollbackId = rollbackId;
+    return this;
+  }
+
+  /** Sets the packages of the rollback. */
+  public RollbackInfoBuilder setPackages(List<PackageRollbackInfo> packages) {
+    checkNotNull(packages, "Field 'packages' not allowed to be null.");
+    this.packages = packages;
+    return this;
+  }
+
+  /** Sets the staged status of the rollback. */
+  public RollbackInfoBuilder setIsStaged(boolean isStaged) {
+    this.isStaged = isStaged;
+    return this;
+  }
+
+  /** Sets the cause packages of the rollback. */
+  public RollbackInfoBuilder setCausePackages(List<VersionedPackage> causePackages) {
+    checkNotNull(causePackages, "Field 'causePackages' not allowed to be null.");
+    this.causePackages = causePackages;
+    return this;
+  }
+
+  /** Sets the committed session id of the rollback. */
+  public RollbackInfoBuilder setCommittedSessionId(int committedSessionId) {
+    this.committedSessionId = committedSessionId;
+    return this;
+  }
+
+  /** Returns a {@link RollbackInfo} with the data that was given. */
+  public RollbackInfo build() {
+    checkState(RuntimeEnvironment.getApiLevel() >= VERSION_CODES.Q);
+
+    return new RollbackInfo(rollbackId, packages, isStaged, causePackages, committedSessionId);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/RoundRectangle.java b/shadows/framework/src/main/java/org/robolectric/shadows/RoundRectangle.java
new file mode 100644
index 0000000..c92729d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/RoundRectangle.java
@@ -0,0 +1,354 @@
+package org.robolectric.shadows;
+
+import java.awt.geom.AffineTransform;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Rectangle2D;
+import java.awt.geom.RectangularShape;
+import java.awt.geom.RoundRectangle2D;
+import java.util.EnumSet;
+import java.util.NoSuchElementException;
+
+/**
+ * Defines a rectangle with rounded corners, where the sizes of the corners are potentially
+ * different.
+ *
+ * <p>Copied from
+ * https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/tools/layoutlib/bridge/src/android/graphics/RoundRectangle.java
+ */
+public class RoundRectangle extends RectangularShape {
+  public double x;
+  public double y;
+  public double width;
+  public double height;
+  public double ulWidth;
+  public double ulHeight;
+  public double urWidth;
+  public double urHeight;
+  public double lrWidth;
+  public double lrHeight;
+  public double llWidth;
+  public double llHeight;
+
+  private enum Zone {
+    CLOSE_OUTSIDE,
+    CLOSE_INSIDE,
+    MIDDLE,
+    FAR_INSIDE,
+    FAR_OUTSIDE
+  }
+
+  private final EnumSet<Zone> close = EnumSet.of(Zone.CLOSE_OUTSIDE, Zone.CLOSE_INSIDE);
+  private final EnumSet<Zone> far = EnumSet.of(Zone.FAR_OUTSIDE, Zone.FAR_INSIDE);
+
+  /**
+   * @param cornerDimensions array of 8 floating-point number corresponding to the width and the
+   *     height of each corner in the following order: upper-left, upper-right, lower-right,
+   *     lower-left. It assumes for the size the same convention as {@link RoundRectangle2D}, that
+   *     is that the width and height of a corner correspond to the total width and height of the
+   *     ellipse that corner is a quarter of.
+   */
+  public RoundRectangle(float x, float y, float width, float height, float[] cornerDimensions) {
+    assert cornerDimensions.length == 8
+        : "The array of corner dimensions must have eight " + "elements";
+
+    this.x = x;
+    this.y = y;
+    this.width = width;
+    this.height = height;
+
+    float[] dimensions = cornerDimensions.clone();
+    // If a value is negative, the corresponding corner is squared
+    for (int i = 0; i < dimensions.length; i += 2) {
+      if (dimensions[i] < 0 || dimensions[i + 1] < 0) {
+        dimensions[i] = 0;
+        dimensions[i + 1] = 0;
+      }
+    }
+
+    double topCornerWidth = (dimensions[0] + dimensions[2]) / 2d;
+    double bottomCornerWidth = (dimensions[4] + dimensions[6]) / 2d;
+    double leftCornerHeight = (dimensions[1] + dimensions[7]) / 2d;
+    double rightCornerHeight = (dimensions[3] + dimensions[5]) / 2d;
+
+    // Rescale the corner dimensions if they are bigger than the rectangle
+    double scale = Math.min(1.0, width / topCornerWidth);
+    scale = Math.min(scale, width / bottomCornerWidth);
+    scale = Math.min(scale, height / leftCornerHeight);
+    scale = Math.min(scale, height / rightCornerHeight);
+
+    this.ulWidth = dimensions[0] * scale;
+    this.ulHeight = dimensions[1] * scale;
+    this.urWidth = dimensions[2] * scale;
+    this.urHeight = dimensions[3] * scale;
+    this.lrWidth = dimensions[4] * scale;
+    this.lrHeight = dimensions[5] * scale;
+    this.llWidth = dimensions[6] * scale;
+    this.llHeight = dimensions[7] * scale;
+  }
+
+  @Override
+  public double getX() {
+    return x;
+  }
+
+  @Override
+  public double getY() {
+    return y;
+  }
+
+  @Override
+  public double getWidth() {
+    return width;
+  }
+
+  @Override
+  public double getHeight() {
+    return height;
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return (width <= 0d) || (height <= 0d);
+  }
+
+  @Override
+  public void setFrame(double x, double y, double w, double h) {
+    this.x = x;
+    this.y = y;
+    this.width = w;
+    this.height = h;
+  }
+
+  @Override
+  public Rectangle2D getBounds2D() {
+    return new Rectangle2D.Double(x, y, width, height);
+  }
+
+  @Override
+  public boolean contains(double x, double y) {
+    if (isEmpty()) {
+      return false;
+    }
+
+    double x0 = getX();
+    double y0 = getY();
+    double x1 = x0 + getWidth();
+    double y1 = y0 + getHeight();
+    // Check for trivial rejection - point is outside bounding rectangle
+    if (x < x0 || y < y0 || x >= x1 || y >= y1) {
+      return false;
+    }
+
+    double insideTopX0 = x0 + ulWidth / 2d;
+    double insideLeftY0 = y0 + ulHeight / 2d;
+    if (x < insideTopX0 && y < insideLeftY0) {
+      // In the upper-left corner
+      return isInsideCorner(x - insideTopX0, y - insideLeftY0, ulWidth / 2d, ulHeight / 2d);
+    }
+
+    double insideTopX1 = x1 - urWidth / 2d;
+    double insideRightY0 = y0 + urHeight / 2d;
+    if (x > insideTopX1 && y < insideRightY0) {
+      // In the upper-right corner
+      return isInsideCorner(x - insideTopX1, y - insideRightY0, urWidth / 2d, urHeight / 2d);
+    }
+
+    double insideBottomX1 = x1 - lrWidth / 2d;
+    double insideRightY1 = y1 - lrHeight / 2d;
+    if (x > insideBottomX1 && y > insideRightY1) {
+      // In the lower-right corner
+      return isInsideCorner(x - insideBottomX1, y - insideRightY1, lrWidth / 2d, lrHeight / 2d);
+    }
+
+    double insideBottomX0 = x0 + llWidth / 2d;
+    double insideLeftY1 = y1 - llHeight / 2d;
+    if (x < insideBottomX0 && y > insideLeftY1) {
+      // In the lower-left corner
+      return isInsideCorner(x - insideBottomX0, y - insideLeftY1, llWidth / 2d, llHeight / 2d);
+    }
+
+    // In the central part of the rectangle
+    return true;
+  }
+
+  private boolean isInsideCorner(double x, double y, double width, double height) {
+    double squareDist = height * height * x * x + width * width * y * y;
+    return squareDist <= width * width * height * height;
+  }
+
+  private Zone classify(
+      double coord, double side1, double arcSize1, double side2, double arcSize2) {
+    if (coord < side1) {
+      return Zone.CLOSE_OUTSIDE;
+    } else if (coord < side1 + arcSize1) {
+      return Zone.CLOSE_INSIDE;
+    } else if (coord < side2 - arcSize2) {
+      return Zone.MIDDLE;
+    } else if (coord < side2) {
+      return Zone.FAR_INSIDE;
+    } else {
+      return Zone.FAR_OUTSIDE;
+    }
+  }
+
+  @Override
+  public boolean intersects(double x, double y, double w, double h) {
+    if (isEmpty() || w <= 0 || h <= 0) {
+      return false;
+    }
+    double x0 = getX();
+    double y0 = getY();
+    double x1 = x0 + getWidth();
+    double y1 = y0 + getHeight();
+    // Check for trivial rejection - bounding rectangles do not intersect
+    if (x + w <= x0 || x >= x1 || y + h <= y0 || y >= y1) {
+      return false;
+    }
+
+    double maxLeftCornerWidth = Math.max(ulWidth, llWidth) / 2d;
+    double maxRightCornerWidth = Math.max(urWidth, lrWidth) / 2d;
+    double maxUpperCornerHeight = Math.max(ulHeight, urHeight) / 2d;
+    double maxLowerCornerHeight = Math.max(llHeight, lrHeight) / 2d;
+    Zone x0class = classify(x, x0, maxLeftCornerWidth, x1, maxRightCornerWidth);
+    Zone x1class = classify(x + w, x0, maxLeftCornerWidth, x1, maxRightCornerWidth);
+    Zone y0class = classify(y, y0, maxUpperCornerHeight, y1, maxLowerCornerHeight);
+    Zone y1class = classify(y + h, y0, maxUpperCornerHeight, y1, maxLowerCornerHeight);
+
+    // Trivially accept if any point is inside inner rectangle
+    if (x0class == Zone.MIDDLE
+        || x1class == Zone.MIDDLE
+        || y0class == Zone.MIDDLE
+        || y1class == Zone.MIDDLE) {
+      return true;
+    }
+    // Trivially accept if either edge spans inner rectangle
+    if ((close.contains(x0class) && far.contains(x1class))
+        || (close.contains(y0class) && far.contains(y1class))) {
+      return true;
+    }
+
+    // Since neither edge spans the center, then one of the corners
+    // must be in one of the rounded edges.  We detect this case if
+    // a [xy]0class is 3 or a [xy]1class is 1.  One of those two cases
+    // must be true for each direction.
+    // We now find a "nearest point" to test for being inside a rounded
+    // corner.
+    if (x1class == Zone.CLOSE_INSIDE && y1class == Zone.CLOSE_INSIDE) {
+      // Potentially in upper-left corner
+      x = x + w - x0 - ulWidth / 2d;
+      y = y + h - y0 - ulHeight / 2d;
+      return x > 0 || y > 0 || isInsideCorner(x, y, ulWidth / 2d, ulHeight / 2d);
+    }
+    if (x1class == Zone.CLOSE_INSIDE) {
+      // Potentially in lower-left corner
+      x = x + w - x0 - llWidth / 2d;
+      y = y - y1 + llHeight / 2d;
+      return x > 0 || y < 0 || isInsideCorner(x, y, llWidth / 2d, llHeight / 2d);
+    }
+    if (y1class == Zone.CLOSE_INSIDE) {
+      // Potentially in the upper-right corner
+      x = x - x1 + urWidth / 2d;
+      y = y + h - y0 - urHeight / 2d;
+      return x < 0 || y > 0 || isInsideCorner(x, y, urWidth / 2d, urHeight / 2d);
+    }
+    // Potentially in the lower-right corner
+    x = x - x1 + lrWidth / 2d;
+    y = y - y1 + lrHeight / 2d;
+    return x < 0 || y < 0 || isInsideCorner(x, y, lrWidth / 2d, lrHeight / 2d);
+  }
+
+  @Override
+  public boolean contains(double x, double y, double w, double h) {
+    if (isEmpty() || w <= 0 || h <= 0) {
+      return false;
+    }
+    return (contains(x, y) && contains(x + w, y) && contains(x, y + h) && contains(x + w, y + h));
+  }
+
+  @Override
+  public PathIterator getPathIterator(final AffineTransform at) {
+    return new PathIterator() {
+      int index;
+
+      // ArcIterator.btan(Math.PI/2)
+      public static final double CtrlVal = 0.5522847498307933;
+      private final double ncv = 1.0 - CtrlVal;
+
+      // Coordinates of control points for Bezier curves approximating the straight lines
+      // and corners of the rounded rectangle.
+      private final double[][] ctrlpts = {
+        {0.0, 0.0, 0.0, ulHeight},
+        {0.0, 0.0, 1.0, -llHeight},
+        {0.0, 0.0, 1.0, -llHeight * ncv, 0.0, ncv * llWidth, 1.0, 0.0, 0.0, llWidth, 1.0, 0.0},
+        {1.0, -lrWidth, 1.0, 0.0},
+        {1.0, -lrWidth * ncv, 1.0, 0.0, 1.0, 0.0, 1.0, -lrHeight * ncv, 1.0, 0.0, 1.0, -lrHeight},
+        {1.0, 0.0, 0.0, urHeight},
+        {1.0, 0.0, 0.0, ncv * urHeight, 1.0, -urWidth * ncv, 0.0, 0.0, 1.0, -urWidth, 0.0, 0.0},
+        {0.0, ulWidth, 0.0, 0.0},
+        {0.0, ncv * ulWidth, 0.0, 0.0, 0.0, 0.0, 0.0, ncv * ulHeight, 0.0, 0.0, 0.0, ulHeight},
+        {}
+      };
+      private final int[] types = {
+        SEG_MOVETO,
+        SEG_LINETO,
+        SEG_CUBICTO,
+        SEG_LINETO,
+        SEG_CUBICTO,
+        SEG_LINETO,
+        SEG_CUBICTO,
+        SEG_LINETO,
+        SEG_CUBICTO,
+        SEG_CLOSE,
+      };
+
+      @Override
+      public int getWindingRule() {
+        return WIND_NON_ZERO;
+      }
+
+      @Override
+      public boolean isDone() {
+        return index >= ctrlpts.length;
+      }
+
+      @Override
+      public void next() {
+        index++;
+      }
+
+      @Override
+      public int currentSegment(float[] coords) {
+        if (isDone()) {
+          throw new NoSuchElementException("roundrect iterator out of bounds");
+        }
+        int nc = 0;
+        double ctrls[] = ctrlpts[index];
+        for (int i = 0; i < ctrls.length; i += 4) {
+          coords[nc++] = (float) (x + ctrls[i] * width + ctrls[i + 1] / 2d);
+          coords[nc++] = (float) (y + ctrls[i + 2] * height + ctrls[i + 3] / 2d);
+        }
+        if (at != null) {
+          at.transform(coords, 0, coords, 0, nc / 2);
+        }
+        return types[index];
+      }
+
+      @Override
+      public int currentSegment(double[] coords) {
+        if (isDone()) {
+          throw new NoSuchElementException("roundrect iterator out of bounds");
+        }
+        int nc = 0;
+        double ctrls[] = ctrlpts[index];
+        for (int i = 0; i < ctrls.length; i += 4) {
+          coords[nc++] = x + ctrls[i] * width + ctrls[i + 1] / 2d;
+          coords[nc++] = y + ctrls[i + 2] * height + ctrls[i + 3] / 2d;
+        }
+        if (at != null) {
+          at.transform(coords, 0, coords, 0, nc / 2);
+        }
+        return types[index];
+      }
+    };
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/RunningTaskInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/RunningTaskInfoBuilder.java
new file mode 100644
index 0000000..0712d34
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/RunningTaskInfoBuilder.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.app.ActivityManager.RunningTaskInfo;
+import android.content.ComponentName;
+import org.robolectric.RuntimeEnvironment;
+
+/** Builder for {@link RunningTaskInfo}. */
+public class RunningTaskInfoBuilder {
+
+  private boolean isVisible;
+  private int taskId;
+
+  private ComponentName baseActivity;
+  private ComponentName topActivity;
+
+  private RunningTaskInfoBuilder() {}
+
+  public static RunningTaskInfoBuilder newBuilder() {
+    return new RunningTaskInfoBuilder();
+  }
+
+  public RunningTaskInfoBuilder setTaskId(int taskId) {
+    this.taskId = taskId;
+    return this;
+  }
+
+  public RunningTaskInfoBuilder setIsVisible(boolean visible) {
+    this.isVisible = visible;
+    return this;
+  }
+
+  public RunningTaskInfoBuilder setBaseActivity(ComponentName baseActivity) {
+    this.baseActivity = baseActivity;
+    return this;
+  }
+
+  public RunningTaskInfoBuilder setTopActivity(ComponentName topActivity) {
+    this.topActivity = topActivity;
+    return this;
+  }
+
+  public RunningTaskInfo build() {
+    RunningTaskInfo taskInfo = new RunningTaskInfo();
+    taskInfo.baseActivity = baseActivity;
+    taskInfo.topActivity = topActivity;
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      taskInfo.taskId = taskId;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= S) {
+      taskInfo.isVisible = isVisible;
+    }
+    return taskInfo;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/SQLiteShadowPicker.java b/shadows/framework/src/main/java/org/robolectric/shadows/SQLiteShadowPicker.java
new file mode 100644
index 0000000..9af30ec
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/SQLiteShadowPicker.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import com.google.common.collect.ImmutableSet;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.annotation.SQLiteMode.Mode;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.ShadowPicker;
+
+/** A {@link ShadowPicker} that selects between shadows given the SQLite mode */
+public class SQLiteShadowPicker<T> implements ShadowPicker<T> {
+
+  private final Class<? extends T> legacyShadowClass;
+  private final Class<? extends T> nativeShadowClass;
+
+  private static final ImmutableSet<String> AFFECTED_CLASSES =
+      ImmutableSet.of("android.database.CursorWindow", "android.database.sqlite.SQLiteConnection");
+
+  public SQLiteShadowPicker(
+      Class<? extends T> legacyShadowClass, Class<? extends T> nativeShadowClass) {
+    this.legacyShadowClass = legacyShadowClass;
+    this.nativeShadowClass = nativeShadowClass;
+  }
+
+  @Override
+  public Class<? extends T> pickShadowClass() {
+    if (ConfigurationRegistry.get(SQLiteMode.Mode.class) == Mode.NATIVE) {
+      return nativeShadowClass;
+    } else {
+      return legacyShadowClass;
+    }
+  }
+
+  /**
+   * Returns a list of shadow classes that need to be invalidated when the SQLite Mode is switched.
+   */
+  public static ImmutableSet<String> getAffectedClasses() {
+    return AFFECTED_CLASSES;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbsListView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbsListView.java
new file mode 100644
index 0000000..279448e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbsListView.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import android.widget.AbsListView;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(AbsListView.class)
+public class ShadowAbsListView extends ShadowAdapterView {
+  private AbsListView.OnScrollListener onScrollListener;
+  private int smoothScrolledPosition;
+  private int lastSmoothScrollByDistance;
+  private int lastSmoothScrollByDuration;
+
+  @Implementation
+  protected void setOnScrollListener(AbsListView.OnScrollListener l) {
+    onScrollListener = l;
+  }
+
+  @Implementation
+  protected void smoothScrollToPosition(int position) {
+    smoothScrolledPosition = position;
+  }
+
+  @Implementation
+  protected void smoothScrollBy(int distance, int duration) {
+    this.lastSmoothScrollByDistance = distance;
+    this.lastSmoothScrollByDuration = duration;
+  }
+
+  /**
+   * Robolectric accessor for the onScrollListener
+   *
+   * @return AbsListView.OnScrollListener
+   */
+  public AbsListView.OnScrollListener getOnScrollListener() {
+    return onScrollListener;
+  }
+
+  /**
+   * Robolectric accessor for the last smoothScrolledPosition
+   *
+   * @return int position
+   */
+  public int getSmoothScrolledPosition() {
+    return smoothScrolledPosition;
+  }
+
+  /**
+   * Robolectric accessor for the last smoothScrollBy distance
+   *
+   * @return int distance
+   */
+  public int getLastSmoothScrollByDistance() {
+    return lastSmoothScrollByDistance;
+  }
+
+  /**
+   * Robolectric accessor for the last smoothScrollBy duration
+   *
+   * @return int duration
+   */
+  public int getLastSmoothScrollByDuration() {
+    return lastSmoothScrollByDuration;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbsSpinner.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbsSpinner.java
new file mode 100644
index 0000000..f0c5b6f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbsSpinner.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.AbsSpinner;
+import android.widget.SpinnerAdapter;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(AbsSpinner.class)
+public class ShadowAbsSpinner extends ShadowAdapterView {
+  @RealObject AbsSpinner realAbsSpinner;
+  private boolean animatedTransition;
+
+  @Implementation
+  protected void setSelection(int position, boolean animate) {
+    reflector(AbsSpinnerReflector.class, realAbsSpinner).setSelection(position, animate);
+    animatedTransition = animate;
+  }
+
+  @Implementation
+  protected void setSelection(int position) {
+    reflector(AbsSpinnerReflector.class, realAbsSpinner).setSelection(position);
+    SpinnerAdapter adapter = realAbsSpinner.getAdapter();
+    if (getItemSelectedListener() != null && adapter != null) {
+      getItemSelectedListener().onItemSelected(realAbsSpinner, null, position, adapter.getItemId(position));
+    }
+  }
+
+  // Non-implementation helper method
+  public boolean isAnimatedTransition() {
+    return animatedTransition;
+  }
+
+  @ForType(AbsSpinner.class)
+  interface AbsSpinnerReflector {
+
+    @Direct
+    void setSelection(int position, boolean animate);
+
+    @Direct
+    void setSelection(int position);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbstractCursor.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbstractCursor.java
new file mode 100644
index 0000000..24e3845
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAbstractCursor.java
@@ -0,0 +1,23 @@
+package org.robolectric.shadows;
+
+import android.database.AbstractCursor;
+import android.net.Uri;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(AbstractCursor.class)
+public class ShadowAbstractCursor {
+
+  @RealObject
+  private AbstractCursor realAbstractCursor;
+
+  /**
+   * Returns the Uri set by {@code setNotificationUri()}.
+   *
+   * @return Notification URI.
+   */
+  public Uri getNotificationUri_Compatibility() {
+    return ReflectionHelpers.getField(realAbstractCursor, "mNotifyUri");
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityButtonController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityButtonController.java
new file mode 100644
index 0000000..03375fa
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityButtonController.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.accessibilityservice.AccessibilityButtonController;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow for {@link AccessibilityButtonController}. */
+@Implements(className = "android.accessibilityservice.AccessibilityButtonController", minSdk = P)
+public class ShadowAccessibilityButtonController {
+
+  @RealObject AccessibilityButtonController realObject;
+
+  /** Performs click action for accessibility button. */
+  public void performAccessibilityButtonClick() {
+    ReflectionHelpers.callInstanceMethod(realObject, "dispatchAccessibilityButtonClicked");
+    shadowMainLooper().idleIfPaused();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityManager.java
new file mode 100644
index 0000000..35c350a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityManager.java
@@ -0,0 +1,280 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.content.Context;
+import android.content.pm.ServiceInfo;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
+import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener;
+import android.view.accessibility.IAccessibilityManager;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(AccessibilityManager.class)
+public class ShadowAccessibilityManager {
+  private static AccessibilityManager sInstance;
+  private static final Object sInstanceSync = new Object();
+
+  @RealObject AccessibilityManager realAccessibilityManager;
+  private final List<AccessibilityEvent> sentAccessibilityEvents = new ArrayList<>();
+  private boolean enabled;
+  private List<AccessibilityServiceInfo> installedAccessibilityServiceList = new ArrayList<>();
+  private List<AccessibilityServiceInfo> enabledAccessibilityServiceList = new ArrayList<>();
+  private List<ServiceInfo> accessibilityServiceList = new ArrayList<>();
+  private final HashMap<AccessibilityStateChangeListener, Handler>
+      onAccessibilityStateChangeListeners = new HashMap<>();
+  private boolean touchExplorationEnabled;
+
+  private static boolean isAccessibilityButtonSupported = true;
+
+  @Resetter
+  public static void reset() {
+    synchronized (sInstanceSync) {
+      sInstance = null;
+    }
+    isAccessibilityButtonSupported = true;
+  }
+
+  @HiddenApi
+  @Implementation
+  public static AccessibilityManager getInstance(Context context) throws Exception {
+    synchronized (sInstanceSync) {
+      if (sInstance == null) {
+        sInstance = createInstance(context);
+      }
+    }
+    return sInstance;
+  }
+
+  private static AccessibilityManager createInstance(Context context) {
+    if (getApiLevel() >= KITKAT) {
+      AccessibilityManager accessibilityManager =
+          Shadow.newInstance(
+              AccessibilityManager.class,
+              new Class[] {Context.class, IAccessibilityManager.class, int.class},
+              new Object[] {
+                context, ReflectionHelpers.createNullProxy(IAccessibilityManager.class), 0
+              });
+      ReflectionHelpers.setField(
+          accessibilityManager,
+          "mHandler",
+          new MyHandler(context.getMainLooper(), accessibilityManager));
+      return accessibilityManager;
+    } else {
+      AccessibilityManager accessibilityManager =
+          Shadow.newInstance(AccessibilityManager.class, new Class[0], new Object[0]);
+      ReflectionHelpers.setField(
+          accessibilityManager,
+          "mHandler",
+          new MyHandler(context.getMainLooper(), accessibilityManager));
+      ReflectionHelpers.setField(
+          accessibilityManager,
+          "mService",
+          ReflectionHelpers.createNullProxy(IAccessibilityManager.class));
+      return accessibilityManager;
+    }
+  }
+
+  @Implementation
+  protected boolean addAccessibilityStateChangeListener(AccessibilityStateChangeListener listener) {
+    addAccessibilityStateChangeListener(listener, null);
+    return true;
+  }
+
+  @Implementation(minSdk = O)
+  protected void addAccessibilityStateChangeListener(
+      AccessibilityStateChangeListener listener, Handler handler) {
+    onAccessibilityStateChangeListeners.put(listener, handler);
+  }
+
+  @Implementation
+  protected boolean removeAccessibilityStateChangeListener(
+      AccessibilityStateChangeListener listener) {
+    final boolean wasRegistered = onAccessibilityStateChangeListeners.containsKey(listener);
+    onAccessibilityStateChangeListeners.remove(listener);
+    return wasRegistered;
+  }
+
+  @Implementation
+  protected List<ServiceInfo> getAccessibilityServiceList() {
+    return Collections.unmodifiableList(accessibilityServiceList);
+  }
+
+  public void setAccessibilityServiceList(List<ServiceInfo> accessibilityServiceList) {
+    this.accessibilityServiceList = new ArrayList<>(accessibilityServiceList);
+  }
+
+  @Nullable
+  @Implementation
+  protected List<AccessibilityServiceInfo> getEnabledAccessibilityServiceList(
+      int feedbackTypeFlags) {
+    // TODO(hoisie): prohibit null values for enabledAccessibilityServiceList
+    if (enabledAccessibilityServiceList == null) {
+      return null;
+    }
+    return Collections.unmodifiableList(enabledAccessibilityServiceList);
+  }
+
+  public void setEnabledAccessibilityServiceList(
+      List<AccessibilityServiceInfo> enabledAccessibilityServiceList) {
+    this.enabledAccessibilityServiceList =
+        enabledAccessibilityServiceList == null
+            ? null
+            : new ArrayList<>(enabledAccessibilityServiceList);
+  }
+
+  @Implementation
+  protected List<AccessibilityServiceInfo> getInstalledAccessibilityServiceList() {
+    return Collections.unmodifiableList(installedAccessibilityServiceList);
+  }
+
+  public void setInstalledAccessibilityServiceList(
+      List<AccessibilityServiceInfo> installedAccessibilityServiceList) {
+    this.installedAccessibilityServiceList = new ArrayList<>(installedAccessibilityServiceList);
+  }
+
+  @Implementation
+  protected void sendAccessibilityEvent(AccessibilityEvent event) {
+    sentAccessibilityEvents.add(event);
+    reflector(AccessibilityManagerReflector.class, realAccessibilityManager)
+        .sendAccessibilityEvent(event);
+  }
+
+  /**
+   * Returns a list of all {@linkplain AccessibilityEvent accessibility events} that have been sent
+   * via {@link #sendAccessibilityEvent}.
+   */
+  public ImmutableList<AccessibilityEvent> getSentAccessibilityEvents() {
+    return ImmutableList.copyOf(sentAccessibilityEvents);
+  }
+
+  @Implementation
+  protected boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+    ReflectionHelpers.setField(realAccessibilityManager, "mIsEnabled", enabled);
+    for (AccessibilityStateChangeListener l : onAccessibilityStateChangeListeners.keySet()) {
+      if (l != null) {
+        l.onAccessibilityStateChanged(enabled);
+      }
+    }
+  }
+
+  @Implementation
+  protected boolean isTouchExplorationEnabled() {
+    return touchExplorationEnabled;
+  }
+
+  public void setTouchExplorationEnabled(boolean touchExplorationEnabled) {
+    this.touchExplorationEnabled = touchExplorationEnabled;
+    List<TouchExplorationStateChangeListener> listeners = new ArrayList<>();
+    if (getApiLevel() >= O) {
+      listeners =
+          new ArrayList<>(
+              reflector(AccessibilityManagerReflector.class, realAccessibilityManager)
+                  .getTouchExplorationStateChangeListeners()
+                  .keySet());
+    } else if (getApiLevel() >= KITKAT) {
+      listeners =
+          new ArrayList<>(
+              reflector(AccessibilityManagerReflectorN.class, realAccessibilityManager)
+                  .getTouchExplorationStateChangeListeners());
+    }
+    listeners.forEach(listener -> listener.onTouchExplorationStateChanged(touchExplorationEnabled));
+  }
+
+  /**
+   * Returns {@code true} by default, or the value specified via {@link
+   * #setAccessibilityButtonSupported(boolean)}.
+   */
+  @Implementation(minSdk = O_MR1)
+  protected static boolean isAccessibilityButtonSupported() {
+    return isAccessibilityButtonSupported;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = O)
+  protected void performAccessibilityShortcut() {
+    setEnabled(true);
+    setTouchExplorationEnabled(true);
+  }
+
+  /**
+   * Sets that the system navigation area is supported accessibility button; controls the return
+   * value of {@link AccessibilityManager#isAccessibilityButtonSupported()}.
+   */
+  public static void setAccessibilityButtonSupported(boolean supported) {
+    isAccessibilityButtonSupported = supported;
+  }
+
+  static class MyHandler extends Handler {
+    private static final int DO_SET_STATE = 10;
+    private final AccessibilityManager accessibilityManager;
+
+    MyHandler(Looper mainLooper, AccessibilityManager accessibilityManager) {
+      super(mainLooper);
+      this.accessibilityManager = accessibilityManager;
+    }
+
+    @Override
+    public void handleMessage(Message message) {
+      switch (message.what) {
+        case DO_SET_STATE:
+          ReflectionHelpers.callInstanceMethod(
+              accessibilityManager, "setState", ClassParameter.from(int.class, message.arg1));
+          return;
+        default:
+          Log.w("AccessibilityManager", "Unknown message type: " + message.what);
+      }
+    }
+  }
+
+  @ForType(AccessibilityManager.class)
+  interface AccessibilityManagerReflector {
+
+    @Direct
+    void sendAccessibilityEvent(AccessibilityEvent event);
+
+    @Accessor("mTouchExplorationStateChangeListeners")
+    ArrayMap<TouchExplorationStateChangeListener, Handler>
+        getTouchExplorationStateChangeListeners();
+  }
+
+  @ForType(AccessibilityManager.class)
+  interface AccessibilityManagerReflectorN {
+    @Accessor("mTouchExplorationStateChangeListeners")
+    CopyOnWriteArrayList<TouchExplorationStateChangeListener>
+        getTouchExplorationStateChangeListeners();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityNodeInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityNodeInfo.java
new file mode 100644
index 0000000..bd9f509
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityNodeInfo.java
@@ -0,0 +1,743 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.Pair;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
+import android.view.accessibility.AccessibilityWindowInfo;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * Properties of {@link android.view.accessibility.AccessibilityNodeInfo} that are normally locked
+ * may be changed using test APIs.
+ *
+ * Calls to {@code obtain()} and {@code recycle()} are tracked to help spot bugs.
+ */
+@Implements(AccessibilityNodeInfo.class)
+public class ShadowAccessibilityNodeInfo {
+  // Map of obtained instances of the class along with stack traces of how they were obtained
+  private static final Map<StrictEqualityNodeWrapper, StackTraceElement[]> obtainedInstances =
+      new HashMap<>();
+
+  private static final SparseArray<StrictEqualityNodeWrapper> orderedInstances =
+      new SparseArray<>();
+
+  public static final Parcelable.Creator<AccessibilityNodeInfo> CREATOR =
+      new Parcelable.Creator<AccessibilityNodeInfo>() {
+
+    @Override
+    public AccessibilityNodeInfo createFromParcel(Parcel source) {
+      return obtain(orderedInstances.get(source.readInt()).mInfo);
+    }
+
+    @Override
+    public AccessibilityNodeInfo[] newArray(int size) {
+      return new AccessibilityNodeInfo[size];
+    }};
+
+  private static int sAllocationCount = 0;
+
+  private static final int PASTEABLE_MASK = 0x00000040;
+
+
+  private static final int TEXT_SELECTION_SETABLE_MASK = 0x00000100;
+
+  /**
+   * Uniquely identifies the origin of the AccessibilityNodeInfo for equality
+   * testing. Two instances that come from the same node info should have the
+   * same ID.
+   */
+  private long mOriginNodeId;
+
+  private List<AccessibilityNodeInfo> children;
+
+  private List<Pair<Integer, Bundle>> performedActionAndArgsList;
+
+  private AccessibilityNodeInfo parent;
+
+  private AccessibilityNodeInfo labelFor;
+
+  private AccessibilityNodeInfo labeledBy;
+
+  private View view;
+
+  private CharSequence text;
+
+  private boolean refreshReturnValue = true;
+
+  private AccessibilityWindowInfo accessibilityWindowInfo;
+
+  private AccessibilityNodeInfo traversalAfter; //22
+
+  private AccessibilityNodeInfo traversalBefore; //22
+
+  private OnPerformActionListener actionListener;
+
+  @RealObject
+  private AccessibilityNodeInfo realAccessibilityNodeInfo;
+
+  @ReflectorObject AccessibilityNodeInfoReflector accessibilityNodeInfoReflector;
+
+  @Implementation
+  protected void __constructor__() {
+    reflector(AccessibilityNodeInfoReflector.class).setCreator(ShadowAccessibilityNodeInfo.CREATOR);
+    Shadow.invokeConstructor(AccessibilityNodeInfo.class, realAccessibilityNodeInfo);
+  }
+
+  @Implementation
+  protected static AccessibilityNodeInfo obtain(AccessibilityNodeInfo info) {
+    final ShadowAccessibilityNodeInfo shadowInfo = Shadow.extract(info);
+    final AccessibilityNodeInfo obtainedInstance = shadowInfo.getClone();
+
+    sAllocationCount++;
+    if (shadowInfo.mOriginNodeId == 0) {
+      shadowInfo.mOriginNodeId = sAllocationCount;
+    }
+    StrictEqualityNodeWrapper wrapper = new StrictEqualityNodeWrapper(obtainedInstance);
+    obtainedInstances.put(wrapper, Thread.currentThread().getStackTrace());
+    orderedInstances.put(sAllocationCount, wrapper);
+    return obtainedInstance;
+  }
+
+  @Implementation
+  protected static AccessibilityNodeInfo obtain(View view) {
+    // We explicitly avoid allocating the AccessibilityNodeInfo from the actual pool by using the
+    // private constructor. Not doing so affects test suites which use both shadow and
+    // non-shadow objects.
+    final AccessibilityNodeInfo obtainedInstance =
+        ReflectionHelpers.callConstructor(AccessibilityNodeInfo.class);
+    final ShadowAccessibilityNodeInfo shadowObtained = Shadow.extract(obtainedInstance);
+
+    /*
+     * We keep a separate list of actions for each object newly obtained
+     * from a view, and perform a shallow copy during getClone. That way the
+     * list of actions performed contains all actions performed on the view
+     * by the tree of nodes initialized from it. Note that initializing two
+     * nodes with the same view will not merge the two lists, as so the list
+     * of performed actions will not contain all actions performed on the
+     * underlying view.
+     */
+    shadowObtained.performedActionAndArgsList = new ArrayList<>();
+
+    shadowObtained.view = view;
+    sAllocationCount++;
+    if (shadowObtained.mOriginNodeId == 0) {
+      shadowObtained.mOriginNodeId = sAllocationCount;
+    }
+    StrictEqualityNodeWrapper wrapper = new StrictEqualityNodeWrapper(obtainedInstance);
+    obtainedInstances.put(wrapper, Thread.currentThread().getStackTrace());
+    orderedInstances.put(sAllocationCount, wrapper);
+    return obtainedInstance;
+  }
+
+  @Implementation
+  protected static AccessibilityNodeInfo obtain() {
+    return obtain(new View(RuntimeEnvironment.getApplication().getApplicationContext()));
+  }
+
+  @Implementation
+  protected static AccessibilityNodeInfo obtain(View root, int virtualDescendantId) {
+    AccessibilityNodeInfo node = obtain(root);
+    return node;
+  }
+
+  /**
+   * Check for leaked objects that were {@code obtain}ed but never
+   * {@code recycle}d.
+   *
+   * @param printUnrecycledNodesToSystemErr - if true, stack traces of calls
+   *        to {@code obtain} that lack matching calls to {@code recycle} are
+   *        dumped to System.err.
+   * @return {@code true} if there are unrecycled nodes
+   */
+  public static boolean areThereUnrecycledNodes(boolean printUnrecycledNodesToSystemErr) {
+    if (printUnrecycledNodesToSystemErr) {
+      for (final StrictEqualityNodeWrapper wrapper : obtainedInstances.keySet()) {
+        final ShadowAccessibilityNodeInfo shadow = Shadow.extract(wrapper.mInfo);
+
+        System.err.printf(
+            "Leaked contentDescription = %s. Stack trace:%n",
+            shadow.realAccessibilityNodeInfo.getContentDescription());
+        for (final StackTraceElement stackTraceElement : obtainedInstances.get(wrapper)) {
+          System.err.println(stackTraceElement.toString());
+        }
+      }
+    }
+
+    return (obtainedInstances.size() != 0);
+  }
+
+  /**
+   * Clear list of obtained instance objects. {@code areThereUnrecycledNodes}
+   * will always return false if called immediately afterwards.
+   */
+  @Resetter
+  public static void resetObtainedInstances() {
+    obtainedInstances.clear();
+    orderedInstances.clear();
+  }
+
+  @Implementation
+  protected void recycle() {
+    final StrictEqualityNodeWrapper wrapper =
+        new StrictEqualityNodeWrapper(realAccessibilityNodeInfo);
+    if (!obtainedInstances.containsKey(wrapper)) {
+      throw new IllegalStateException();
+    }
+
+    if (labelFor != null) {
+      labelFor.recycle();
+    }
+
+    if (labeledBy != null) {
+      labeledBy.recycle();
+    }
+    if (getApiLevel() >= LOLLIPOP_MR1) {
+      if (traversalAfter != null) {
+        traversalAfter.recycle();
+      }
+
+      if (traversalBefore != null) {
+        traversalBefore.recycle();
+      }
+    }
+
+    obtainedInstances.remove(wrapper);
+    int keyOfWrapper = -1;
+    for (int i = 0; i < orderedInstances.size(); i++) {
+      int key = orderedInstances.keyAt(i);
+      if (orderedInstances.get(key).equals(wrapper)) {
+        keyOfWrapper = key;
+        break;
+      }
+    }
+    orderedInstances.remove(keyOfWrapper);
+  }
+
+  @Implementation
+  protected int getChildCount() {
+    if (children == null) {
+      return 0;
+    }
+
+    return children.size();
+  }
+
+  @Implementation
+  protected AccessibilityNodeInfo getChild(int index) {
+    if (children == null) {
+      return null;
+    }
+
+    final AccessibilityNodeInfo child = children.get(index);
+    if (child == null) {
+      return null;
+    }
+
+    return obtain(child);
+  }
+
+  @Implementation
+  protected AccessibilityNodeInfo getParent() {
+    if (parent == null) {
+      return null;
+    }
+
+    return obtain(parent);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean refresh() {
+      return refreshReturnValue;
+  }
+
+  public void setRefreshReturnValue(boolean refreshReturnValue) {
+    this.refreshReturnValue = refreshReturnValue;
+  }
+
+  public boolean isPasteable() {
+    return (accessibilityNodeInfoReflector.getBooleanProperties() & PASTEABLE_MASK) != 0;
+  }
+
+  public boolean isTextSelectionSetable() {
+    return (accessibilityNodeInfoReflector.getBooleanProperties() & TEXT_SELECTION_SETABLE_MASK)
+        != 0;
+  }
+
+  public void setTextSelectionSetable(boolean isTextSelectionSetable) {
+    accessibilityNodeInfoReflector.setBooleanProperty(
+        TEXT_SELECTION_SETABLE_MASK, isTextSelectionSetable);
+  }
+
+  public void setPasteable(boolean isPasteable) {
+    accessibilityNodeInfoReflector.setBooleanProperty(PASTEABLE_MASK, isPasteable);
+  }
+
+  @Implementation
+  protected void setText(CharSequence t) {
+    text = t;
+  }
+
+  @Implementation
+  protected CharSequence getText() {
+    return text;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected AccessibilityNodeInfo getLabelFor() {
+    if (labelFor == null) {
+      return null;
+    }
+
+    return obtain(labelFor);
+  }
+
+  public void setLabelFor(AccessibilityNodeInfo info) {
+    if (labelFor != null) {
+      labelFor.recycle();
+    }
+
+    labelFor = obtain(info);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected AccessibilityNodeInfo getLabeledBy() {
+    if (labeledBy == null) {
+      return null;
+    }
+
+    return obtain(labeledBy);
+  }
+
+  public void setLabeledBy(AccessibilityNodeInfo info) {
+    if (labeledBy != null) {
+      labeledBy.recycle();
+    }
+
+    labeledBy = obtain(info);
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected AccessibilityNodeInfo getTraversalAfter() {
+    if (traversalAfter == null) {
+      return null;
+    }
+
+    return obtain(traversalAfter);
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected void setTraversalAfter(View view, int virtualDescendantId) {
+    if (this.traversalAfter != null) {
+      this.traversalAfter.recycle();
+    }
+    
+    this.traversalAfter = obtain(view);
+  }
+
+  /**
+   * Sets the view whose node is visited after this one in accessibility traversal.
+   *
+   * This may be useful for configuring traversal order in tests before the corresponding
+   * views have been inflated.
+   *
+   * @param info The previous node.
+   * @see #getTraversalAfter()
+   */
+  public void setTraversalAfter(AccessibilityNodeInfo info) {
+    if (this.traversalAfter != null) {
+      this.traversalAfter.recycle();
+    }
+
+    this.traversalAfter = obtain(info);
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected AccessibilityNodeInfo getTraversalBefore() {
+    if (traversalBefore == null) {
+      return null;
+    }
+
+    return obtain(traversalBefore);
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected void setTraversalBefore(View info, int virtualDescendantId) {
+    if (this.traversalBefore != null) {
+      this.traversalBefore.recycle();
+    }
+
+    this.traversalBefore = obtain(info);
+  }
+
+  /**
+   * Sets the view before whose node this one should be visited during traversal.
+   *
+   * This may be useful for configuring traversal order in tests before the corresponding
+   * views have been inflated.
+   *
+   * @param info The view providing the preceding node.
+   * @see #getTraversalBefore()
+   */
+  public void setTraversalBefore(AccessibilityNodeInfo info) {
+    if (this.traversalBefore != null) {
+      this.traversalBefore.recycle();
+    }
+    
+    this.traversalBefore = obtain(info);
+  }
+
+  @Implementation
+  protected void setSource(View source) {
+    this.view = source;
+  }
+
+  @Implementation
+  protected void setSource(View root, int virtualDescendantId) {
+    this.view = root;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected AccessibilityWindowInfo getWindow() {
+    return accessibilityWindowInfo;
+  }
+
+  /** Returns the id of the window from which the info comes. */
+  @Implementation
+  protected int getWindowId() {
+    return (accessibilityWindowInfo == null) ? -1 : accessibilityWindowInfo.getId();
+  }
+
+  public void setAccessibilityWindowInfo(AccessibilityWindowInfo info) {
+    accessibilityWindowInfo = info;
+  }
+
+  @Implementation
+  protected boolean performAction(int action) {
+    return performAction(action, null);
+  }
+
+  @Implementation
+  protected boolean performAction(int action, Bundle arguments) {
+    if (performedActionAndArgsList == null) {
+      performedActionAndArgsList = new ArrayList<>();
+    }
+
+    performedActionAndArgsList.add(new Pair<>(action, arguments));
+    return actionListener == null || actionListener.onPerformAccessibilityAction(action, arguments);
+  }
+
+  /**
+   * Equality check based on reference equality of the Views from which these instances were
+   * created, or the equality of their assigned IDs.
+   */
+  @Implementation
+  @Override
+  public boolean equals(Object object) {
+    if (!(object instanceof AccessibilityNodeInfo)) {
+      return false;
+    }
+
+    final AccessibilityNodeInfo info = (AccessibilityNodeInfo) object;
+    final ShadowAccessibilityNodeInfo otherShadow = Shadow.extract(info);
+
+    if (this.view != null) {
+      return this.view == otherShadow.view;
+    }
+    if (this.mOriginNodeId != 0) {
+      return this.mOriginNodeId == otherShadow.mOriginNodeId;
+    }
+    throw new IllegalStateException("Node has neither an ID nor View");
+  }
+
+  @Implementation
+  @Override
+  public int hashCode() {
+    // This is 0 for a reason. If you change it, you will break the obtained
+    // instances map in a manner that is remarkably difficult to debug.
+    // Having a dynamic hash code keeps this object from being located
+    // in the map if it was mutated after being obtained.
+    return 0;
+  }
+
+  /**
+   * Add a child node to this one. Also initializes the parent field of the
+   * child.
+   *
+   * @param child The node to be added as a child.
+   */
+  public void addChild(AccessibilityNodeInfo child) {
+    if (children == null) {
+      children = new ArrayList<>();
+    }
+
+    children.add(child);
+    ShadowAccessibilityNodeInfo shadowAccessibilityNodeInfo = Shadow.extract(child);
+    shadowAccessibilityNodeInfo.parent = realAccessibilityNodeInfo;
+  }
+
+  @Implementation
+  protected void addChild(View child) {
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(child);
+    addChild(node);
+  }
+
+  @Implementation
+  protected void addChild(View root, int virtualDescendantId) {
+    AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(root, virtualDescendantId);
+    addChild(node);
+  }
+
+  /**
+   * @return The list of arguments for the various calls to performAction. Unmodifiable.
+   */
+  public List<Integer> getPerformedActions() {
+    if (performedActionAndArgsList == null) {
+      performedActionAndArgsList = new ArrayList<>();
+    }
+
+    // Here we take the actions out of the pairs and stick them into a separate LinkedList to return
+    List<Integer> actionsOnly = new ArrayList<>();
+    Iterator<Pair<Integer, Bundle>> iter = performedActionAndArgsList.iterator();
+    while (iter.hasNext()) {
+      actionsOnly.add(iter.next().first);
+    }
+
+    return Collections.unmodifiableList(actionsOnly);
+  }
+
+  /**
+   * @return The list of arguments for the various calls to performAction. Unmodifiable.
+   */
+  public List<Pair<Integer, Bundle>> getPerformedActionsWithArgs() {
+    if (performedActionAndArgsList == null) {
+      performedActionAndArgsList = new ArrayList<>();
+    }
+    return Collections.unmodifiableList(performedActionAndArgsList);
+  }
+
+  /**
+   * @return A shallow copy.
+   */
+  private AccessibilityNodeInfo getClone() {
+    // We explicitly avoid allocating the AccessibilityNodeInfo from the actual pool by using
+    // the private constructor. Not doing so affects test suites which use both shadow and
+    // non-shadow objects.
+    final AccessibilityNodeInfo newInfo =
+        ReflectionHelpers.callConstructor(AccessibilityNodeInfo.class);
+    final ShadowAccessibilityNodeInfo newShadow = Shadow.extract(newInfo);
+
+    newShadow.mOriginNodeId = mOriginNodeId;
+    Rect boundsInScreen = new Rect();
+    realAccessibilityNodeInfo.getBoundsInScreen(boundsInScreen);
+    newInfo.setBoundsInScreen(boundsInScreen);
+    newShadow.accessibilityNodeInfoReflector.setBooleanProperties(
+        accessibilityNodeInfoReflector.getBooleanProperties());
+    newInfo.setContentDescription(realAccessibilityNodeInfo.getContentDescription());
+    newShadow.text = text;
+    newShadow.performedActionAndArgsList = performedActionAndArgsList;
+    newShadow.parent = parent;
+    newInfo.setClassName(realAccessibilityNodeInfo.getClassName());
+    newShadow.labelFor = (labelFor == null) ? null : obtain(labelFor);
+    newShadow.labeledBy = (labeledBy == null) ? null : obtain(labeledBy);
+    newShadow.view = view;
+    newShadow.actionListener = actionListener;
+    if (getApiLevel() >= LOLLIPOP) {
+      newShadow.accessibilityNodeInfoReflector.setActionsList(
+          new ArrayList<>(realAccessibilityNodeInfo.getActionList()));
+    } else {
+      newShadow.accessibilityNodeInfoReflector.setActionsMask(
+          realAccessibilityNodeInfo.getActions());
+    }
+
+    if (children != null) {
+      newShadow.children = new ArrayList<>();
+      newShadow.children.addAll(children);
+    } else {
+      newShadow.children = null;
+    }
+
+    newShadow.refreshReturnValue = refreshReturnValue;
+    newInfo.setMovementGranularities(realAccessibilityNodeInfo.getMovementGranularities());
+    newInfo.setPackageName(realAccessibilityNodeInfo.getPackageName());
+    if (getApiLevel() >= JELLY_BEAN_MR2) {
+      newInfo.setViewIdResourceName(realAccessibilityNodeInfo.getViewIdResourceName());
+      newInfo.setTextSelection(
+          realAccessibilityNodeInfo.getTextSelectionStart(),
+          realAccessibilityNodeInfo.getTextSelectionEnd());
+    }
+    if (getApiLevel() >= KITKAT) {
+      newInfo.setCollectionInfo(realAccessibilityNodeInfo.getCollectionInfo());
+      newInfo.setCollectionItemInfo(realAccessibilityNodeInfo.getCollectionItemInfo());
+      newInfo.setInputType(realAccessibilityNodeInfo.getInputType());
+      newInfo.setLiveRegion(realAccessibilityNodeInfo.getLiveRegion());
+      newInfo.setRangeInfo(realAccessibilityNodeInfo.getRangeInfo());
+      newShadow.realAccessibilityNodeInfo.getExtras().putAll(realAccessibilityNodeInfo.getExtras());
+    }
+    if (getApiLevel() >= LOLLIPOP) {
+      newInfo.setMaxTextLength(realAccessibilityNodeInfo.getMaxTextLength());
+      newInfo.setError(realAccessibilityNodeInfo.getError());
+    }
+    if (getApiLevel() >= LOLLIPOP_MR1) {
+      newShadow.traversalAfter = (traversalAfter == null) ? null : obtain(traversalAfter);
+      newShadow.traversalBefore = (traversalBefore == null) ? null : obtain(traversalBefore);
+    }
+    if ((getApiLevel() >= LOLLIPOP) && (accessibilityWindowInfo != null)) {
+      newShadow.accessibilityWindowInfo =
+          ShadowAccessibilityWindowInfo.obtain(accessibilityWindowInfo);
+    }
+    if (getApiLevel() >= N) {
+      newInfo.setDrawingOrder(realAccessibilityNodeInfo.getDrawingOrder());
+    }
+    if (getApiLevel() >= O) {
+      newInfo.setHintText(realAccessibilityNodeInfo.getHintText());
+    }
+    if (getApiLevel() >= P) {
+      newInfo.setTooltipText(realAccessibilityNodeInfo.getTooltipText());
+    }
+
+    return newInfo;
+  }
+
+  /**
+   * Private class to keep different nodes referring to the same view straight
+   * in the mObtainedInstances map.
+   */
+  private static class StrictEqualityNodeWrapper {
+    public final AccessibilityNodeInfo mInfo;
+
+    public StrictEqualityNodeWrapper(AccessibilityNodeInfo info) {
+      mInfo = info;
+    }
+
+    @Override
+    @SuppressWarnings("ReferenceEquality")
+    public boolean equals(Object object) {
+      if (object == null) {
+        return false;
+      }
+      if (!(object instanceof StrictEqualityNodeWrapper)) {
+        return false;
+      }
+      final StrictEqualityNodeWrapper wrapper = (StrictEqualityNodeWrapper) object;
+      return mInfo == wrapper.mInfo;
+    }
+
+    @Override
+    public int hashCode() {
+      return mInfo.hashCode();
+    }
+  }
+
+  @Implementation
+  protected int describeContents() {
+    return 0;
+  }
+
+  @Implementation
+  protected void writeToParcel(Parcel dest, int flags) {
+    StrictEqualityNodeWrapper wrapper = new StrictEqualityNodeWrapper(realAccessibilityNodeInfo);
+    int keyOfWrapper = -1;
+    for (int i = 0; i < orderedInstances.size(); i++) {
+      if (orderedInstances.valueAt(i).equals(wrapper)) {
+        keyOfWrapper = orderedInstances.keyAt(i);
+        break;
+      }
+    }
+    dest.writeInt(keyOfWrapper);
+  }
+
+  /**
+   * Configure the return result of an action if it is performed
+   *
+   * @param listener The listener.
+   */
+  public void setOnPerformActionListener(OnPerformActionListener listener) {
+    actionListener = listener;
+  }
+
+  public interface OnPerformActionListener {
+    boolean onPerformAccessibilityAction(int action, Bundle arguments);
+  }
+
+  @Override
+  @Implementation
+  public String toString() {
+    return "ShadowAccessibilityNodeInfo@"
+        + System.identityHashCode(this)
+        + ":{text:"
+        + text
+        + ", className:"
+        + realAccessibilityNodeInfo.getClassName()
+        + "}";
+  }
+
+  @ForType(AccessibilityNodeInfo.class)
+  interface AccessibilityNodeInfoReflector {
+    @Static
+    @Accessor("CREATOR")
+    void setCreator(Parcelable.Creator<AccessibilityNodeInfo> creator);
+
+    @Static
+    AccessibilityAction getActionSingleton(int id);
+
+    @Accessor("mBooleanProperties")
+    int getBooleanProperties();
+
+    @Accessor("mBooleanProperties")
+    void setBooleanProperties(int properties);
+
+    void setBooleanProperty(int property, boolean value);
+
+    @Accessor("mActions")
+    void setActionsList(ArrayList<AccessibilityAction> actions);
+
+    @Accessor("mActions")
+    void setActionsMask(int actions); // pre-L
+
+    @Direct
+    void getBoundsInScreen(Rect outBounds);
+
+    @Direct
+    void getBoundsInParent(Rect outBounds);
+
+    @Direct
+    void setBoundsInScreen(Rect b);
+
+    @Direct
+    void setBoundsInParent(Rect b);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityRecord.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityRecord.java
new file mode 100644
index 0000000..7470e4d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityRecord.java
@@ -0,0 +1,114 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityRecord;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * Shadow of {@link android.view.accessibility.AccessibilityRecord}.
+ */
+@Implements(AccessibilityRecord.class)
+public class ShadowAccessibilityRecord {
+
+  @RealObject private AccessibilityRecord realRecord;
+
+  public static final int NO_VIRTUAL_ID = -1;
+
+  private View sourceRoot;
+  private int virtualDescendantId;
+  private AccessibilityNodeInfo sourceNode;
+  private int windowId = -1;
+
+  @Implementation
+  protected void init(AccessibilityRecord model) {
+    // Copy shadow fields.
+    ShadowAccessibilityRecord modelShadow = Shadow.extract(model);
+    sourceRoot = modelShadow.sourceRoot;
+    virtualDescendantId = modelShadow.virtualDescendantId;
+    sourceNode = modelShadow.sourceNode;
+    windowId = modelShadow.windowId;
+
+    // Copy realRecord fields.
+    reflector(AccessibilityRecordReflector.class, realRecord).init(model);
+  }
+
+  @Implementation
+  protected void setSource(View root, int virtualDescendantId) {
+    this.sourceRoot = root;
+    this.virtualDescendantId = virtualDescendantId;
+    reflector(AccessibilityRecordReflector.class, realRecord).setSource(root, virtualDescendantId);
+  }
+
+  @Implementation
+  protected void setSource(View root) {
+    this.sourceRoot = root;
+    this.virtualDescendantId = NO_VIRTUAL_ID;
+    reflector(AccessibilityRecordReflector.class, realRecord).setSource(root);
+  }
+
+  /**
+   * Sets the {@link AccessibilityNodeInfo} of the event source.
+   *
+   * @param node The node to set
+   */
+  public void setSourceNode(AccessibilityNodeInfo node) {
+    sourceNode = node;
+  }
+
+  /**
+   * Returns the {@link AccessibilityNodeInfo} of the event source or {@code null} if there is none.
+   */
+  @Implementation
+  protected AccessibilityNodeInfo getSource() {
+    if (sourceNode == null) {
+      return null;
+    }
+    return AccessibilityNodeInfo.obtain(sourceNode);
+  }
+
+  /**
+   * Sets the id of the window from which the event comes.
+   *
+   * @param id The id to set
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  public void setWindowId(int id) {
+    windowId = id;
+  }
+
+  /** Returns the id of the window from which the event comes. */
+  @Implementation
+  protected int getWindowId() {
+    return windowId;
+  }
+
+  public View getSourceRoot() {
+    return sourceRoot;
+  }
+
+  public int getVirtualDescendantId() {
+    return virtualDescendantId;
+  }
+
+  @ForType(AccessibilityRecord.class)
+  interface AccessibilityRecordReflector {
+
+    @Direct
+    void setSource(View root, int virtualDescendantId);
+
+    @Direct
+    void setSource(View root);
+
+    @Direct
+    void init(AccessibilityRecord model);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityService.java
new file mode 100644
index 0000000..e5620df
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityService.java
@@ -0,0 +1,189 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityService.GestureResultCallback;
+import android.accessibilityservice.AccessibilityService.ScreenshotErrorCode;
+import android.accessibilityservice.AccessibilityService.ScreenshotResult;
+import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback;
+import android.accessibilityservice.GestureDescription;
+import android.graphics.ColorSpace;
+import android.graphics.ColorSpace.Named;
+import android.hardware.HardwareBuffer;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Shadow of AccessibilityService that tracks global actions and provides a mechanism to simulate
+ * the window list.
+ */
+@Implements(AccessibilityService.class)
+public class ShadowAccessibilityService extends ShadowService {
+
+  private final List<Integer> globalActionsPerformed = new ArrayList<>();
+  private List<AccessibilityNodeInfo.AccessibilityAction> systemActions;
+  private final List<AccessibilityWindowInfo> windows = new ArrayList<>();
+  private final List<GestureDispatch> gesturesDispatched = new ArrayList<>();
+
+  private boolean canDispatchGestures = true;
+
+  @ScreenshotErrorCode
+  private int takeScreenshotErrorCode = AccessibilityService.ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR;
+
+  private boolean isScreenshotError = false;
+
+  @Implementation
+  protected final boolean performGlobalAction(int action) {
+    globalActionsPerformed.add(action);
+    return true;
+  }
+
+  public List<Integer> getGlobalActionsPerformed() {
+    return globalActionsPerformed;
+  }
+
+  @Implementation(minSdk = S)
+  protected final List<AccessibilityNodeInfo.AccessibilityAction> getSystemActions() {
+    return systemActions;
+  }
+
+  public final void setSystemActions(
+      List<AccessibilityNodeInfo.AccessibilityAction> systemActions) {
+    this.systemActions = systemActions;
+  }
+
+  /**
+   * Returns a representation of interactive windows shown on the device screen. Mirrors the values
+   * provided to {@link #setWindows(List<AccessibilityWindowInfo>)}. Returns an empty List if not
+   * set.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected List<AccessibilityWindowInfo> getWindows() {
+    return new ArrayList<>(windows);
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean dispatchGesture(
+      GestureDescription gesture, GestureResultCallback callback, Handler handler) {
+    if (canDispatchGestures) {
+      gesturesDispatched.add(new GestureDispatch(gesture, callback));
+    }
+    return canDispatchGestures;
+  }
+
+  @Implementation(minSdk = R)
+  protected void takeScreenshot(
+      int displayId, Executor executor, AccessibilityService.TakeScreenshotCallback callback) {
+    executor.execute(
+        () -> {
+          if (isScreenshotError) {
+            callback.onFailure(takeScreenshotErrorCode);
+            return;
+          }
+
+          HardwareBuffer hardwareBuffer =
+              HardwareBuffer.create(1, 1, HardwareBuffer.RGBA_8888, 1, 0L);
+          ColorSpace colorSpace = ColorSpace.get(Named.SRGB);
+          long timestamp = SystemClock.elapsedRealtimeNanos();
+          ScreenshotResult screenshotResult =
+              ReflectionHelpers.callConstructor(
+                  ScreenshotResult.class,
+                  new ClassParameter<>(HardwareBuffer.class, hardwareBuffer),
+                  new ClassParameter<>(ColorSpace.class, colorSpace),
+                  new ClassParameter<>(long.class, timestamp));
+          callback.onSuccess(screenshotResult);
+        });
+  }
+
+  /**
+   * Sets {@link AccessibilityService#takeScreenshot(int, Executor, TakeScreenshotCallback)} to
+   * start returning the given {@code errorCode}.
+   *
+   * @see #unsetTakeScreenshotErrorCode() to unset the error condition.
+   */
+  public void setTakeScreenshotErrorCode(@ScreenshotErrorCode int errorCode) {
+    this.isScreenshotError = true;
+    this.takeScreenshotErrorCode = errorCode;
+  }
+
+  /**
+   * Sets {@link AccessibilityService#takeScreenshot(int, Executor, TakeScreenshotCallback)} to
+   * start returning successful results again.
+   *
+   * @see #setTakeScreenshotErrorCode(int) to set an error condition instead.
+   */
+  public void unsetTakeScreenshotErrorCode() {
+    this.isScreenshotError = false;
+  }
+
+  /**
+   * Sets the list of interactive windows shown on the device screen as reported by {@link
+   * #getWindows()}
+   */
+  public void setWindows(List<AccessibilityWindowInfo> windowList) {
+    windows.clear();
+    if (windowList != null) {
+      windows.addAll(windowList);
+    }
+  }
+
+  /**
+   * Returns a list of gestures that have been dispatched.
+   *
+   * Gestures are dispatched by calling {@link AccessibilityService#dispatchGesture}.
+   */
+  public List<GestureDispatch> getGesturesDispatched() {
+    return gesturesDispatched;
+  }
+
+  /**
+   * Sets whether the service is currently able to dispatch gestures.
+   *
+   * If {@code false}, {@link AccessibilityService#dispatchGesture} will return {@code false}.
+   */
+  public void setCanDispatchGestures(boolean canDispatchGestures) {
+    this.canDispatchGestures = canDispatchGestures;
+  }
+
+  /**
+   * Represents a gesture that has been dispatched through the accessibility service.
+   *
+   * Gestures are dispatched by calling {@link AccessibilityService#dispatchGesture}.
+   */
+  public static final class GestureDispatch {
+    private final GestureDescription description;
+    private final GestureResultCallback callback;
+
+    public GestureDispatch(GestureDescription description, GestureResultCallback callback) {
+      this.description = description;
+      this.callback = callback;
+    }
+
+    /** The description of the gesture to be dispatched. Includes timestamps and the path. */
+    public GestureDescription description() {
+      return description;
+    }
+
+    /**
+     * The callback that is to be invoked once the gesture has finished dispatching.
+     *
+     * The shadow itself does not invoke this callback. You must manually invoke it to run it.
+     */
+    public GestureResultCallback callback() {
+      return callback;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityWindowInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityWindowInfo.java
new file mode 100644
index 0000000..77f1a30
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccessibilityWindowInfo.java
@@ -0,0 +1,379 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Rect;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * Shadow of {@link android.view.accessibility.AccessibilityWindowInfo} that allows a test to set
+ * properties that are locked in the original class.
+ */
+@Implements(value = AccessibilityWindowInfo.class, minSdk = LOLLIPOP)
+public class ShadowAccessibilityWindowInfo {
+
+  private static final Map<StrictEqualityWindowWrapper, StackTraceElement[]> obtainedInstances =
+      new HashMap<>();
+
+  private List<AccessibilityWindowInfo> children = null;
+
+  private AccessibilityWindowInfo parent = null;
+
+  private AccessibilityNodeInfo rootNode = null;
+
+  private AccessibilityNodeInfo anchorNode = null;
+
+  private Rect boundsInScreen = new Rect();
+
+  private int type = AccessibilityWindowInfo.TYPE_APPLICATION;
+
+  private int layer = 0;
+
+  private CharSequence title = null;
+
+  private boolean isAccessibilityFocused = false;
+
+  private boolean isActive = false;
+
+  private boolean isFocused = false;
+
+  @RealObject private AccessibilityWindowInfo mRealAccessibilityWindowInfo;
+
+  @Implementation
+  protected void __constructor__() {}
+
+  @Implementation
+  protected static AccessibilityWindowInfo obtain() {
+    final AccessibilityWindowInfo obtainedInstance =
+        ReflectionHelpers.callConstructor(AccessibilityWindowInfo.class);
+    StrictEqualityWindowWrapper wrapper = new StrictEqualityWindowWrapper(obtainedInstance);
+    obtainedInstances.put(wrapper, Thread.currentThread().getStackTrace());
+    return obtainedInstance;
+  }
+
+  @Implementation
+  protected static AccessibilityWindowInfo obtain(AccessibilityWindowInfo window) {
+    final ShadowAccessibilityWindowInfo shadowInfo = Shadow.extract(window);
+    final AccessibilityWindowInfo obtainedInstance = shadowInfo.getClone();
+    StrictEqualityWindowWrapper wrapper = new StrictEqualityWindowWrapper(obtainedInstance);
+    obtainedInstances.put(wrapper, Thread.currentThread().getStackTrace());
+    return obtainedInstance;
+  }
+
+  private AccessibilityWindowInfo getClone() {
+    final AccessibilityWindowInfo newInfo =
+        ReflectionHelpers.callConstructor(AccessibilityWindowInfo.class);
+    final ShadowAccessibilityWindowInfo newShadow = Shadow.extract(newInfo);
+
+    newShadow.boundsInScreen = new Rect(boundsInScreen);
+    newShadow.parent = parent;
+    newShadow.rootNode = rootNode;
+    newShadow.anchorNode = anchorNode;
+    newShadow.type = type;
+    newShadow.layer = layer;
+    newShadow.setId(getId());
+    newShadow.title = title;
+    newShadow.isAccessibilityFocused = isAccessibilityFocused;
+    newShadow.isActive = isActive;
+    newShadow.isFocused = isFocused;
+    if (children != null) {
+      newShadow.children = new ArrayList<>(children);
+    } else {
+      newShadow.children = null;
+    }
+
+    return newInfo;
+  }
+
+  /**
+   * Clear list of obtained instance objects. {@code areThereUnrecycledWindows} will always
+   * return false if called immediately afterwards.
+   */
+  public static void resetObtainedInstances() {
+    obtainedInstances.clear();
+  }
+
+  /**
+   * Check for leaked objects that were {@code obtain}ed but never
+   * {@code recycle}d.
+   *
+   * @param printUnrecycledWindowsToSystemErr - if true, stack traces of calls
+   *        to {@code obtain} that lack matching calls to {@code recycle} are
+   *        dumped to System.err.
+   * @return {@code true} if there are unrecycled windows
+   */
+  public static boolean areThereUnrecycledWindows(boolean printUnrecycledWindowsToSystemErr) {
+    if (printUnrecycledWindowsToSystemErr) {
+      for (final StrictEqualityWindowWrapper wrapper : obtainedInstances.keySet()) {
+        final ShadowAccessibilityWindowInfo shadow = Shadow.extract(wrapper.mInfo);
+
+        System.err.println(
+            String.format(
+                "Leaked type = %d, id = %d. Stack trace:",
+                shadow.getType(), wrapper.mInfo.getId()));
+        for (final StackTraceElement stackTraceElement : obtainedInstances.get(wrapper)) {
+          System.err.println(stackTraceElement.toString());
+        }
+      }
+    }
+
+    return (obtainedInstances.size() != 0);
+  }
+
+  @SuppressWarnings("ReferenceEquality")
+  public boolean deepEquals(Object object) {
+    if (!(object instanceof AccessibilityWindowInfo)) {
+      return false;
+    }
+
+    final AccessibilityWindowInfo window = (AccessibilityWindowInfo) object;
+    final ShadowAccessibilityWindowInfo otherShadow = Shadow.extract(window);
+
+    boolean areEqual = (type == otherShadow.getType());
+    areEqual &=
+        (parent == null)
+            ? (otherShadow.getParent() == null)
+            : parent.equals(otherShadow.getParent());
+    areEqual &=
+        (rootNode == null)
+            ? (otherShadow.getRoot() == null)
+            : rootNode.equals(otherShadow.getRoot());
+    areEqual &=
+        (anchorNode == null)
+            ? (otherShadow.getAnchor() == null)
+            : anchorNode.equals(otherShadow.getAnchor());
+    areEqual &= (layer == otherShadow.getLayer());
+    areEqual &= (getId() == otherShadow.getId());
+    areEqual &= (title == otherShadow.getTitle());
+    areEqual &= (isAccessibilityFocused == otherShadow.isAccessibilityFocused());
+    areEqual &= (isActive == otherShadow.isActive());
+    areEqual &= (isFocused == otherShadow.isFocused());
+    Rect anotherBounds = new Rect();
+    otherShadow.getBoundsInScreen(anotherBounds);
+    areEqual &= (boundsInScreen.equals(anotherBounds));
+    return areEqual;
+  }
+
+  @Override
+  @Implementation
+  public int hashCode() {
+    // This is 0 for a reason. If you change it, you will break the obtained instances map in
+    // a manner that is remarkably difficult to debug. Having a dynamic hash code keeps this
+    // object from being located in the map if it was mutated after being obtained.
+    return 0;
+  }
+
+  @Implementation
+  protected int getType() {
+    return type;
+  }
+
+  @Implementation
+  protected int getChildCount() {
+    if (children == null) {
+      return 0;
+    }
+
+    return children.size();
+  }
+
+  @Implementation
+  protected AccessibilityWindowInfo getChild(int index) {
+    if (children == null) {
+      return null;
+    }
+
+    return children.get(index);
+  }
+
+  @Implementation
+  protected AccessibilityWindowInfo getParent() {
+    return parent;
+  }
+
+  @Implementation
+  protected AccessibilityNodeInfo getRoot() {
+    return (rootNode == null) ? null : AccessibilityNodeInfo.obtain(rootNode);
+  }
+
+  @Implementation(minSdk = N)
+  protected AccessibilityNodeInfo getAnchor() {
+    return (anchorNode == null) ? null : AccessibilityNodeInfo.obtain(anchorNode);
+  }
+
+  @Implementation
+  protected boolean isActive() {
+    return isActive;
+  }
+
+  @Implementation
+  protected int getId() {
+    return reflector(AccessibilityWindowInfoReflector.class, mRealAccessibilityWindowInfo).getId();
+  }
+
+  @Implementation
+  protected void getBoundsInScreen(Rect outBounds) {
+    if (boundsInScreen == null) {
+      outBounds.setEmpty();
+    } else {
+      outBounds.set(boundsInScreen);
+    }
+  }
+
+  @Implementation
+  protected int getLayer() {
+    return layer;
+  }
+
+  /** Returns the title of this window, or {@code null} if none is available. */
+  @Implementation(minSdk = N)
+  protected CharSequence getTitle() {
+    return title;
+  }
+
+  @Implementation
+  protected boolean isFocused() {
+    return isFocused;
+  }
+
+  @Implementation
+  protected boolean isAccessibilityFocused() {
+    return isAccessibilityFocused;
+  }
+
+  @Implementation
+  protected void recycle() {
+    // This shadow does not track recycling of windows.
+  }
+
+  public void setRoot(AccessibilityNodeInfo root) {
+    rootNode = root;
+  }
+
+  public void setAnchor(AccessibilityNodeInfo anchor) {
+    anchorNode = anchor;
+  }
+
+  @Implementation
+  public void setType(int value) {
+    type = value;
+  }
+
+  @Implementation(maxSdk = Q)
+  public void setBoundsInScreen(Rect bounds) {
+    boundsInScreen.set(bounds);
+  }
+
+  @Implementation
+  public void setAccessibilityFocused(boolean value) {
+    isAccessibilityFocused = value;
+  }
+
+  @Implementation
+  public void setActive(boolean value) {
+    isActive = value;
+  }
+
+  @Implementation
+  public void setId(int value) {
+    reflector(AccessibilityWindowInfoReflector.class, mRealAccessibilityWindowInfo).setId(value);
+  }
+
+  @Implementation
+  public void setLayer(int value) {
+    layer = value;
+  }
+
+  /**
+   * Sets the title of this window.
+   *
+   * @param value The {@link CharSequence} to set as the title of this window
+   */
+  @Implementation(minSdk = N)
+  public void setTitle(CharSequence value) {
+    title = value;
+  }
+
+  @Implementation
+  public void setFocused(boolean focused) {
+    isFocused = focused;
+  }
+
+  public void addChild(AccessibilityWindowInfo child) {
+    if (children == null) {
+      children = new ArrayList<>();
+    }
+
+    children.add(child);
+    ((ShadowAccessibilityWindowInfo) Shadow.extract(child)).parent =
+        mRealAccessibilityWindowInfo;
+  }
+
+  /**
+   * Private class to keep different windows referring to the same window straight
+   * in the mObtainedInstances map.
+   */
+  private static class StrictEqualityWindowWrapper {
+    public final AccessibilityWindowInfo mInfo;
+
+    public StrictEqualityWindowWrapper(AccessibilityWindowInfo info) {
+      mInfo = info;
+    }
+
+    @Override
+    @SuppressWarnings("ReferenceEquality")
+    public boolean equals(Object object) {
+      if (object == null) {
+        return false;
+      }
+
+      if (!(object instanceof StrictEqualityWindowWrapper)) {
+        return false;
+      }
+      final StrictEqualityWindowWrapper wrapper = (StrictEqualityWindowWrapper) object;
+      return mInfo == wrapper.mInfo;
+    }
+
+    @Override
+    public int hashCode() {
+      return mInfo.hashCode();
+    }
+  }
+
+  @Override
+  @Implementation
+  public String toString() {
+    return "ShadowAccessibilityWindowInfo@"
+        + System.identityHashCode(this)
+        + ":{id:"
+        + getId()
+        + ", title:"
+        + title
+        + "}";
+  }
+
+  @ForType(AccessibilityWindowInfo.class)
+  interface AccessibilityWindowInfoReflector {
+
+    @Direct
+    int getId();
+
+    @Direct
+    void setId(int value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccountManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccountManager.java
new file mode 100644
index 0000000..81b70d1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAccountManager.java
@@ -0,0 +1,834 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.O;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerCallback;
+import android.accounts.AccountManagerFuture;
+import android.accounts.AuthenticatorDescription;
+import android.accounts.AuthenticatorException;
+import android.accounts.IAccountManager;
+import android.accounts.OnAccountsUpdateListener;
+import android.accounts.OperationCanceledException;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import androidx.annotation.Nullable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.Scheduler.IdleState;
+
+@Implements(AccountManager.class)
+public class ShadowAccountManager {
+
+  private List<Account> accounts = new ArrayList<>();
+  private Map<Account, Map<String, String>> authTokens = new HashMap<>();
+  private Map<String, AuthenticatorDescription> authenticators = new LinkedHashMap<>();
+  /**
+   * Maps listeners to a set of account types. If null, the listener should be notified for changes
+   * to accounts of any type. Otherwise, the listener is only notified of changes to accounts of the
+   * given type.
+   */
+  private Map<OnAccountsUpdateListener, Set<String>> listeners = new LinkedHashMap<>();
+
+  private Map<Account, Map<String, String>> userData = new HashMap<>();
+  private Map<Account, String> passwords = new HashMap<>();
+  private Map<Account, Set<String>> accountFeatures = new HashMap<>();
+  private Map<Account, Set<String>> packageVisibleAccounts = new HashMap<>();
+
+  private List<Bundle> addAccountOptionsList = new ArrayList<>();
+  private Handler mainHandler;
+  private RoboAccountManagerFuture pendingAddFuture;
+  private boolean authenticationErrorOnNextResponse = false;
+  private Intent removeAccountIntent;
+
+  @Implementation
+  protected void __constructor__(Context context, IAccountManager service) {
+    mainHandler = new Handler(context.getMainLooper());
+  }
+
+  @Implementation
+  protected static AccountManager get(Context context) {
+    return (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE);
+  }
+
+  @Implementation
+  protected Account[] getAccounts() {
+    return accounts.toArray(new Account[accounts.size()]);
+  }
+
+  @Implementation
+  protected Account[] getAccountsByType(String type) {
+    if (type == null) {
+      return getAccounts();
+    }
+    List<Account> accountsByType = new ArrayList<>();
+
+    for (Account a : accounts) {
+      if (type.equals(a.type)) {
+        accountsByType.add(a);
+      }
+    }
+
+    return accountsByType.toArray(new Account[accountsByType.size()]);
+  }
+
+  @Implementation
+  protected synchronized void setAuthToken(Account account, String tokenType, String authToken) {
+    if (accounts.contains(account)) {
+      Map<String, String> tokenMap = authTokens.get(account);
+      if (tokenMap == null) {
+        tokenMap = new HashMap<>();
+        authTokens.put(account, tokenMap);
+      }
+      tokenMap.put(tokenType, authToken);
+    }
+  }
+
+  @Implementation
+  protected String peekAuthToken(Account account, String tokenType) {
+    Map<String, String> tokenMap = authTokens.get(account);
+    if (tokenMap != null) {
+      return tokenMap.get(tokenType);
+    }
+    return null;
+  }
+
+  @SuppressWarnings("InconsistentCapitalization")
+  @Implementation
+  protected boolean addAccountExplicitly(Account account, String password, Bundle userdata) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+    for (Account a : getAccountsByType(account.type)) {
+      if (a.name.equals(account.name)) {
+        return false;
+      }
+    }
+
+    if (!accounts.add(account)) {
+      return false;
+    }
+
+    setPassword(account, password);
+
+    if (userdata != null) {
+      for (String key : userdata.keySet()) {
+        setUserData(account, key, userdata.get(key).toString());
+      }
+    }
+
+    notifyListeners(account);
+
+    return true;
+  }
+
+  @Implementation
+  protected String blockingGetAuthToken(
+      Account account, String authTokenType, boolean notifyAuthFailure) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+    if (authTokenType == null) {
+      throw new IllegalArgumentException("authTokenType is null");
+    }
+
+    Map<String, String> tokensForAccount = authTokens.get(account);
+    if (tokensForAccount == null) {
+      return null;
+    }
+    return tokensForAccount.get(authTokenType);
+  }
+
+  /**
+   * The remove operation is posted to the given {@code handler}, and will be executed according to
+   * the {@link IdleState} of the corresponding {@link org.robolectric.util.Scheduler}.
+   */
+  @Implementation
+  protected AccountManagerFuture<Boolean> removeAccount(
+      final Account account, AccountManagerCallback<Boolean> callback, Handler handler) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+
+    return start(
+        new BaseRoboAccountManagerFuture<Boolean>(callback, handler) {
+          @Override
+          public Boolean doWork() {
+            return removeAccountExplicitly(account);
+          }
+        });
+  }
+
+  /**
+   * Removes the account unless {@link #setRemoveAccountIntent} has been set. If set, the future
+   * Bundle will include the Intent and {@link AccountManager#KEY_BOOLEAN_RESULT} will be false.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected AccountManagerFuture<Bundle> removeAccount(
+      Account account,
+      Activity activity,
+      AccountManagerCallback<Bundle> callback,
+      Handler handler) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+    return start(
+        new BaseRoboAccountManagerFuture<Bundle>(callback, handler) {
+          @Override
+          public Bundle doWork() {
+            Bundle result = new Bundle();
+            if (removeAccountIntent == null) {
+              result.putBoolean(
+                  AccountManager.KEY_BOOLEAN_RESULT, removeAccountExplicitly(account));
+            } else {
+              result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
+              result.putParcelable(AccountManager.KEY_INTENT, removeAccountIntent);
+            }
+            return result;
+          }
+        });
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected boolean removeAccountExplicitly(Account account) {
+    passwords.remove(account);
+    userData.remove(account);
+    if (accounts.remove(account)) {
+      notifyListeners(account);
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Removes all accounts that have been added.
+   */
+  public void removeAllAccounts() {
+    passwords.clear();
+    userData.clear();
+    accounts.clear();
+  }
+
+  @Implementation
+  protected AuthenticatorDescription[] getAuthenticatorTypes() {
+    return authenticators.values().toArray(new AuthenticatorDescription[authenticators.size()]);
+  }
+
+  @Implementation
+  protected void addOnAccountsUpdatedListener(
+      final OnAccountsUpdateListener listener, Handler handler, boolean updateImmediately) {
+    addOnAccountsUpdatedListener(listener, handler, updateImmediately, /* accountTypes= */ null);
+  }
+
+  /**
+   * Based on {@link AccountManager#addOnAccountsUpdatedListener(OnAccountsUpdateListener, Handler,
+   * boolean, String[])}. {@link Handler} is ignored.
+   */
+  @Implementation(minSdk = O)
+  protected void addOnAccountsUpdatedListener(
+      @Nullable final OnAccountsUpdateListener listener,
+      @Nullable Handler handler,
+      boolean updateImmediately,
+      @Nullable String[] accountTypes) {
+    // TODO: Match real method behavior by throwing IllegalStateException.
+    if (listeners.containsKey(listener)) {
+      return;
+    }
+
+    Set<String> types = null;
+    if (accountTypes != null) {
+      types = new HashSet<>(Arrays.asList(accountTypes));
+    }
+    listeners.put(listener, types);
+
+    if (updateImmediately) {
+      notifyListener(listener, types, getAccounts());
+    }
+  }
+
+  @Implementation
+  protected void removeOnAccountsUpdatedListener(OnAccountsUpdateListener listener) {
+    listeners.remove(listener);
+  }
+
+  @Implementation
+  protected String getUserData(Account account, String key) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+
+    if (!userData.containsKey(account)) {
+      return null;
+    }
+
+    Map<String, String> userDataMap = userData.get(account);
+    if (userDataMap.containsKey(key)) {
+      return userDataMap.get(key);
+    }
+
+    return null;
+  }
+
+  @Implementation
+  protected void setUserData(Account account, String key, String value) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+
+    if (!userData.containsKey(account)) {
+      userData.put(account, new HashMap<String, String>());
+    }
+
+    Map<String, String> userDataMap = userData.get(account);
+
+    if (value == null) {
+      userDataMap.remove(key);
+    } else {
+      userDataMap.put(key, value);
+    }
+  }
+
+  @Implementation
+  protected void setPassword(Account account, String password) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+
+    if (password == null) {
+      passwords.remove(account);
+    } else {
+      passwords.put(account, password);
+    }
+  }
+
+  @Implementation
+  protected String getPassword(Account account) {
+    if (account == null) {
+      throw new IllegalArgumentException("account is null");
+    }
+
+    if (passwords.containsKey(account)) {
+      return passwords.get(account);
+    } else {
+      return null;
+    }
+  }
+
+  @Implementation
+  protected void invalidateAuthToken(final String accountType, final String authToken) {
+    Account[] accountsByType = getAccountsByType(accountType);
+    for (Account account : accountsByType) {
+      Map<String, String> tokenMap = authTokens.get(account);
+      if (tokenMap != null) {
+        Iterator<Entry<String, String>> it = tokenMap.entrySet().iterator();
+        while (it.hasNext()) {
+          Map.Entry<String, String> map = it.next();
+          if (map.getValue().equals(authToken)) {
+            it.remove();
+          }
+        }
+        authTokens.put(account, tokenMap);
+      }
+    }
+  }
+
+  /**
+   * Returns a bundle that contains the account session bundle under {@link
+   * AccountManager#KEY_ACCOUNT_SESSION_BUNDLE} to later be passed on to {@link
+   * AccountManager#finishSession(Bundle,Activity,AccountManagerCallback<Bundle>,Handler)}. The
+   * session bundle simply propagates the given {@code accountType} so as not to be empty and is not
+   * encrypted as it would be in the real implementation. If an activity isn't provided, resulting
+   * bundle will only have a dummy {@link Intent} under {@link AccountManager#KEY_INTENT}.
+   *
+   * @param accountType An authenticator must exist for the accountType, or else {@link
+   *     AuthenticatorException} is thrown.
+   * @param authTokenType is ignored.
+   * @param requiredFeatures is ignored.
+   * @param options is ignored.
+   * @param activity if null, only {@link AccountManager#KEY_INTENT} will be present in result.
+   * @param callback if not null, will be called with result bundle.
+   * @param handler is ignored.
+   * @return future for bundle containing {@link AccountManager#KEY_ACCOUNT_SESSION_BUNDLE} if
+   *     activity is provided, or {@link AccountManager#KEY_INTENT} otherwise.
+   */
+  @Implementation(minSdk = O)
+  protected AccountManagerFuture<Bundle> startAddAccountSession(
+      String accountType,
+      String authTokenType,
+      String[] requiredFeatures,
+      Bundle options,
+      Activity activity,
+      AccountManagerCallback<Bundle> callback,
+      Handler handler) {
+
+    return start(
+        new BaseRoboAccountManagerFuture<Bundle>(callback, handler) {
+          @Override
+          public Bundle doWork() throws AuthenticatorException {
+            if (!authenticators.containsKey(accountType)) {
+              throw new AuthenticatorException("No authenticator specified for " + accountType);
+            }
+
+            Bundle resultBundle = new Bundle();
+
+            if (activity == null) {
+              Intent resultIntent = new Intent();
+              resultBundle.putParcelable(AccountManager.KEY_INTENT, resultIntent);
+            } else {
+              // This would actually be an encrypted bundle. Account type is copied as is simply to
+              // make it non-empty.
+              Bundle accountSessionBundle = new Bundle();
+              accountSessionBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, accountType);
+              resultBundle.putBundle(AccountManager.KEY_ACCOUNT_SESSION_BUNDLE, Bundle.EMPTY);
+            }
+
+            return resultBundle;
+          }
+        });
+  }
+
+  /**
+   * Returns sessionBundle as the result of finishSession.
+   *
+   * @param sessionBundle is returned as the result bundle.
+   * @param activity is ignored.
+   * @param callback if not null, will be called with result bundle.
+   * @param handler is ignored.
+   */
+  @Implementation(minSdk = O)
+  protected AccountManagerFuture<Bundle> finishSession(
+      Bundle sessionBundle,
+      Activity activity,
+      AccountManagerCallback<Bundle> callback,
+      Handler handler) {
+
+    return start(
+        new BaseRoboAccountManagerFuture<Bundle>(callback, handler) {
+          @Override
+          public Bundle doWork() {
+            // Just return sessionBundle as the result since it's not really used, allowing it to
+            // be easily controlled in tests.
+            return sessionBundle;
+          }
+        });
+  }
+
+  /**
+   * Based off of private method postToHandler(Handler, OnAccountsUpdateListener, Account[]) in
+   * {@link AccountManager}
+   */
+  private void notifyListener(
+      OnAccountsUpdateListener listener,
+      @Nullable Set<String> accountTypesToReportOn,
+      Account[] allAccounts) {
+    if (accountTypesToReportOn != null) {
+      ArrayList<Account> filtered = new ArrayList<>();
+      for (Account account : allAccounts) {
+        if (accountTypesToReportOn.contains(account.type)) {
+          filtered.add(account);
+        }
+      }
+      listener.onAccountsUpdated(filtered.toArray(new Account[0]));
+    } else {
+      listener.onAccountsUpdated(allAccounts);
+    }
+  }
+
+  private void notifyListeners(Account changedAccount) {
+    Account[] accounts = getAccounts();
+    for (Map.Entry<OnAccountsUpdateListener, Set<String>> entry : listeners.entrySet()) {
+      OnAccountsUpdateListener listener = entry.getKey();
+      Set<String> types = entry.getValue();
+      if (types == null || types.contains(changedAccount.type)) {
+        notifyListener(listener, types, accounts);
+      }
+    }
+  }
+
+  /**
+   * @param account User account.
+   */
+  public void addAccount(Account account) {
+    accounts.add(account);
+    if (pendingAddFuture != null) {
+      pendingAddFuture.resultBundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+      start(pendingAddFuture);
+      pendingAddFuture = null;
+    }
+    notifyListeners(account);
+  }
+
+  /**
+   * Adds an account to the AccountManager but when {@link
+   * AccountManager#getAccountsByTypeForPackage(String, String)} is called will be included if is in
+   * one of the #visibleToPackages
+   *
+   * @param account User account.
+   */
+  public void addAccount(Account account, String... visibleToPackages) {
+    addAccount(account);
+    HashSet<String> value = new HashSet<>();
+    Collections.addAll(value, visibleToPackages);
+    packageVisibleAccounts.put(account, value);
+  }
+
+  /**
+   * Consumes and returns the next {@code addAccountOptions} passed to {@link #addAccount}.
+   *
+   * @return the next {@code addAccountOptions}
+   */
+  public Bundle getNextAddAccountOptions() {
+    if (addAccountOptionsList.isEmpty()) {
+      return null;
+    } else {
+      return addAccountOptionsList.remove(0);
+    }
+  }
+
+  /**
+   * Returns the next {@code addAccountOptions} passed to {@link #addAccount} without consuming it.
+   *
+   * @return the next {@code addAccountOptions}
+   */
+  public Bundle peekNextAddAccountOptions() {
+    if (addAccountOptionsList.isEmpty()) {
+      return null;
+    } else {
+      return addAccountOptionsList.get(0);
+    }
+  }
+
+  private class RoboAccountManagerFuture extends BaseRoboAccountManagerFuture<Bundle> {
+    private final String accountType;
+    private final Activity activity;
+    private final Bundle resultBundle;
+
+    RoboAccountManagerFuture(AccountManagerCallback<Bundle> callback, Handler handler, String accountType, Activity activity) {
+      super(callback, handler);
+
+      this.accountType = accountType;
+      this.activity = activity;
+      this.resultBundle = new Bundle();
+    }
+
+    @Override
+    public Bundle doWork() throws AuthenticatorException {
+      if (!authenticators.containsKey(accountType)) {
+        throw new AuthenticatorException("No authenticator specified for " + accountType);
+      }
+
+      resultBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, accountType);
+
+      if (activity == null) {
+        Intent resultIntent = new Intent();
+        resultBundle.putParcelable(AccountManager.KEY_INTENT, resultIntent);
+      } else if (callback == null) {
+        resultBundle.putString(AccountManager.KEY_ACCOUNT_NAME, "some_user@gmail.com");
+      }
+
+      return resultBundle;
+    }
+  }
+
+  @Implementation
+  protected AccountManagerFuture<Bundle> addAccount(
+      final String accountType,
+      String authTokenType,
+      String[] requiredFeatures,
+      Bundle addAccountOptions,
+      Activity activity,
+      AccountManagerCallback<Bundle> callback,
+      Handler handler) {
+    addAccountOptionsList.add(addAccountOptions);
+    if (activity == null) {
+      // Caller only wants to get the intent, so start the future immediately.
+      RoboAccountManagerFuture future =
+          new RoboAccountManagerFuture(callback, handler, accountType, null);
+      start(future);
+      return future;
+    } else {
+      // Caller wants to start the sign in flow and return the intent with the new account added.
+      // Account can be added via ShadowAccountManager#addAccount.
+      pendingAddFuture = new RoboAccountManagerFuture(callback, handler, accountType, activity);
+      return pendingAddFuture;
+    }
+  }
+
+  public void setFeatures(Account account, String[] accountFeatures) {
+    HashSet<String> featureSet = new HashSet<>();
+    featureSet.addAll(Arrays.asList(accountFeatures));
+    this.accountFeatures.put(account, featureSet);
+  }
+
+  /**
+   * @param authenticator System authenticator.
+   */
+  public void addAuthenticator(AuthenticatorDescription authenticator) {
+    authenticators.put(authenticator.type, authenticator);
+  }
+
+  public void addAuthenticator(String type) {
+    addAuthenticator(AuthenticatorDescription.newKey(type));
+  }
+
+  private Map<Account, String> previousNames = new HashMap<Account, String>();
+
+  /**
+   * Sets the previous name for an account, which will be returned by {@link AccountManager#getPreviousName(Account)}.
+   *
+   * @param account User account.
+   * @param previousName Previous account name.
+   */
+  public void setPreviousAccountName(Account account, String previousName) {
+    previousNames.put(account, previousName);
+  }
+
+  /** @see #setPreviousAccountName(Account, String) */
+  @Implementation(minSdk = LOLLIPOP)
+  protected String getPreviousName(Account account) {
+    return previousNames.get(account);
+  }
+
+  @Implementation
+  protected AccountManagerFuture<Bundle> getAuthToken(
+      final Account account,
+      final String authTokenType,
+      final Bundle options,
+      final Activity activity,
+      final AccountManagerCallback<Bundle> callback,
+      Handler handler) {
+
+    return start(
+        new BaseRoboAccountManagerFuture<Bundle>(callback, handler) {
+          @Override
+          public Bundle doWork() throws AuthenticatorException {
+            return getAuthToken(account, authTokenType);
+          }
+        });
+  }
+
+  @Implementation
+  protected AccountManagerFuture<Bundle> getAuthToken(
+      final Account account,
+      final String authTokenType,
+      final Bundle options,
+      final boolean notifyAuthFailure,
+      final AccountManagerCallback<Bundle> callback,
+      Handler handler) {
+
+    return start(
+        new BaseRoboAccountManagerFuture<Bundle>(callback, handler) {
+          @Override
+          public Bundle doWork() throws AuthenticatorException {
+            return getAuthToken(account, authTokenType);
+          }
+        });
+  }
+
+  private Bundle getAuthToken(Account account, String authTokenType) throws AuthenticatorException {
+    Bundle result = new Bundle();
+
+    String authToken = blockingGetAuthToken(account, authTokenType, false);
+    result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+    result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
+    result.putString(AccountManager.KEY_AUTHTOKEN, authToken);
+
+    if (authToken != null) {
+      return result;
+    }
+
+    if (!authenticators.containsKey(account.type)) {
+      throw new AuthenticatorException("No authenticator specified for " + account.type);
+    }
+
+    Intent resultIntent = new Intent();
+    result.putParcelable(AccountManager.KEY_INTENT, resultIntent);
+
+    return result;
+  }
+
+  @Implementation
+  protected AccountManagerFuture<Boolean> hasFeatures(
+      final Account account,
+      final String[] features,
+      AccountManagerCallback<Boolean> callback,
+      Handler handler) {
+    return start(
+        new BaseRoboAccountManagerFuture<Boolean>(callback, handler) {
+          @Override
+          public Boolean doWork() {
+            Set<String> availableFeatures = accountFeatures.get(account);
+            for (String feature : features) {
+              if (!availableFeatures.contains(feature)) {
+                return false;
+              }
+            }
+            return true;
+          }
+        });
+  }
+
+  @Implementation
+  protected AccountManagerFuture<Account[]> getAccountsByTypeAndFeatures(
+      final String type,
+      final String[] features,
+      AccountManagerCallback<Account[]> callback,
+      Handler handler) {
+    return start(
+        new BaseRoboAccountManagerFuture<Account[]>(callback, handler) {
+          @Override
+          public Account[] doWork() throws AuthenticatorException {
+
+            if (authenticationErrorOnNextResponse) {
+              setAuthenticationErrorOnNextResponse(false);
+              throw new AuthenticatorException();
+            }
+
+            List<Account> result = new ArrayList<>();
+
+            Account[] accountsByType = getAccountsByType(type);
+            for (Account account : accountsByType) {
+              Set<String> featureSet = accountFeatures.get(account);
+              if (features == null
+                  || (featureSet != null && featureSet.containsAll(Arrays.asList(features)))) {
+                result.add(account);
+              }
+            }
+            return result.toArray(new Account[result.size()]);
+          }
+        });
+  }
+
+  private <T extends BaseRoboAccountManagerFuture> T start(T future) {
+    future.start();
+    return future;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected Account[] getAccountsByTypeForPackage(String type, String packageName) {
+    List<Account> result = new ArrayList<>();
+
+    Account[] accountsByType = getAccountsByType(type);
+    for (Account account : accountsByType) {
+      if (packageVisibleAccounts.containsKey(account)
+          && packageVisibleAccounts.get(account).contains(packageName)) {
+        result.add(account);
+      }
+    }
+
+    return result.toArray(new Account[result.size()]);
+  }
+
+  /**
+   * Sets authenticator exception, which will be thrown by {@link #getAccountsByTypeAndFeatures}.
+   *
+   * @param authenticationErrorOnNextResponse to set flag that exception will be thrown on next
+   *     response.
+   */
+  public void setAuthenticationErrorOnNextResponse(boolean authenticationErrorOnNextResponse) {
+    this.authenticationErrorOnNextResponse = authenticationErrorOnNextResponse;
+  }
+
+  /**
+   * Sets the intent to include in Bundle result from {@link #removeAccount} if Activity is given.
+   *
+   * @param removeAccountIntent the intent to surface as {@link AccountManager#KEY_INTENT}.
+   */
+  public void setRemoveAccountIntent(Intent removeAccountIntent) {
+    this.removeAccountIntent = removeAccountIntent;
+  }
+
+  public Map<OnAccountsUpdateListener, Set<String>> getListeners() {
+    return listeners;
+  }
+
+  private abstract class BaseRoboAccountManagerFuture<T> implements AccountManagerFuture<T> {
+    protected final AccountManagerCallback<T> callback;
+    private final Handler handler;
+    protected T result;
+    private Exception exception;
+    private boolean started = false;
+
+    BaseRoboAccountManagerFuture(AccountManagerCallback<T> callback, Handler handler) {
+      this.callback = callback;
+      this.handler = handler == null ? mainHandler : handler;
+    }
+
+    void start() {
+      if (started) return;
+      started = true;
+
+      try {
+        result = doWork();
+      } catch (OperationCanceledException | IOException | AuthenticatorException e) {
+        exception = e;
+      }
+
+      if (callback != null) {
+        handler.post(
+            new Runnable() {
+              @Override
+              public void run() {
+                callback.run(BaseRoboAccountManagerFuture.this);
+              }
+            });
+      }
+    }
+
+    @Override
+    public boolean cancel(boolean mayInterruptIfRunning) {
+      return false;
+    }
+
+    @Override
+    public boolean isCancelled() {
+      return false;
+    }
+
+    @Override
+    public boolean isDone() {
+      return result != null || exception != null || isCancelled();
+    }
+
+    @Override
+    public T getResult() throws OperationCanceledException, IOException, AuthenticatorException {
+      start();
+
+      if (exception instanceof OperationCanceledException) {
+        throw new OperationCanceledException(exception);
+      } else if (exception instanceof IOException) {
+        throw new IOException(exception);
+      } else if (exception instanceof AuthenticatorException) {
+        throw new AuthenticatorException(exception);
+      }
+      return result;
+    }
+
+    @Override
+    public T getResult(long timeout, TimeUnit unit) throws OperationCanceledException, IOException, AuthenticatorException {
+      return getResult();
+    }
+
+    public abstract T doWork() throws OperationCanceledException, IOException, AuthenticatorException;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java
new file mode 100644
index 0000000..47f7306
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java
@@ -0,0 +1,998 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.app.ActivityOptions;
+import android.app.ActivityThread;
+import android.app.Application;
+import android.app.Dialog;
+import android.app.DirectAction;
+import android.app.Instrumentation;
+import android.app.LoadedApk;
+import android.app.PendingIntent;
+import android.app.PictureInPictureParams;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.os.Binder;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Parcel;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import androidx.annotation.RequiresApi;
+import com.android.internal.app.IVoiceInteractor;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.fakes.RoboIntentSender;
+import org.robolectric.fakes.RoboMenuItem;
+import org.robolectric.fakes.RoboSplashScreen;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowContextImpl._ContextImpl_;
+import org.robolectric.shadows.ShadowInstrumentation.TargetAndRequestCode;
+import org.robolectric.shadows.ShadowLoadedApk._LoadedApk_;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.WithType;
+
+@SuppressWarnings("NewApi")
+@Implements(value = Activity.class, looseSignatures = true)
+public class ShadowActivity extends ShadowContextThemeWrapper {
+
+  @RealObject protected Activity realActivity;
+
+  private int resultCode;
+  private Intent resultIntent;
+  private Activity parent;
+  private int requestedOrientation = -1;
+  private View currentFocus;
+  private Integer lastShownDialogId = null;
+  private int pendingTransitionEnterAnimResId = -1;
+  private int pendingTransitionExitAnimResId = -1;
+  private Object lastNonConfigurationInstance;
+  private Map<Integer, Dialog> dialogForId = new HashMap<>();
+  private ArrayList<Cursor> managedCursors = new ArrayList<>();
+  private int mDefaultKeyMode = Activity.DEFAULT_KEYS_DISABLE;
+  private SpannableStringBuilder mDefaultKeySsb = null;
+  private int streamType = -1;
+  private boolean mIsTaskRoot = true;
+  private Menu optionsMenu;
+  private ComponentName callingActivity;
+  private PermissionsRequest lastRequestedPermission;
+  private ActivityController controller;
+  private boolean inMultiWindowMode = false;
+  private IntentSenderRequest lastIntentSenderRequest;
+  private boolean throwIntentSenderException;
+  private boolean hasReportedFullyDrawn = false;
+  private boolean isInPictureInPictureMode = false;
+  private Object splashScreen = null;
+  private boolean showWhenLocked = false;
+  private boolean turnScreenOn = false;
+
+  public void setApplication(Application application) {
+    reflector(_Activity_.class, realActivity).setApplication(application);
+  }
+
+  public void callAttach(Intent intent) {
+    callAttach(intent, /*activityOptions=*/ null, /*lastNonConfigurationInstances=*/ null);
+  }
+
+  public void callAttach(Intent intent, @Nullable Bundle activityOptions) {
+    callAttach(
+        intent, /*activityOptions=*/ activityOptions, /*lastNonConfigurationInstances=*/ null);
+  }
+
+  public void callAttach(
+      Intent intent,
+      @Nullable Bundle activityOptions,
+      @Nullable @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances) {
+    callAttach(
+        intent,
+        /* activityOptions= */ activityOptions,
+        /* lastNonConfigurationInstances= */ null,
+        /* overrideConfig= */ null);
+  }
+
+  public void callAttach(
+      Intent intent,
+      @Nullable Bundle activityOptions,
+      @Nullable @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      @Nullable Configuration overrideConfig) {
+    Application application = RuntimeEnvironment.getApplication();
+    Context baseContext = application.getBaseContext();
+
+    ComponentName componentName =
+        new ComponentName(application.getPackageName(), realActivity.getClass().getName());
+    ActivityInfo activityInfo;
+    PackageManager packageManager = application.getPackageManager();
+    shadowOf(packageManager).addActivityIfNotPresent(componentName);
+    try {
+      activityInfo = packageManager.getActivityInfo(componentName, PackageManager.GET_META_DATA);
+    } catch (NameNotFoundException e) {
+      throw new RuntimeException("Activity is not resolved even if we made sure it exists", e);
+    }
+    Binder token = new Binder();
+
+    CharSequence activityTitle = activityInfo.loadLabel(baseContext.getPackageManager());
+
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    Instrumentation instrumentation = activityThread.getInstrumentation();
+
+    Context activityContext;
+    int displayId =
+        activityOptions != null
+            ? ActivityOptions.fromBundle(activityOptions).getLaunchDisplayId()
+            : Display.DEFAULT_DISPLAY;
+    // There's no particular reason to only do this above O, however the createActivityContext
+    // method signature changed between versions so just for convenience only the latest version is
+    // plumbed through, older versions will use the previous robolectric behavior of sharing
+    // activity and application ContextImpl objects.
+    // TODO(paulsowden): This should be enabled always but many service shadows are storing instance
+    //  state that should be represented globally, we'll have to update these one by one to use
+    //  static (i.e. global) state instead of instance state. For now enable only when the display
+    //  is requested to a non-default display which requires a separate context to function
+    //  properly.
+    if ((Boolean.getBoolean("robolectric.createActivityContexts")
+            || (displayId != Display.DEFAULT_DISPLAY && displayId != Display.INVALID_DISPLAY))
+        && RuntimeEnvironment.getApiLevel() >= O) {
+      LoadedApk loadedApk =
+          activityThread.getPackageInfo(
+              ShadowActivityThread.getApplicationInfo(), null, Context.CONTEXT_INCLUDE_CODE);
+      _LoadedApk_ loadedApkReflector = reflector(_LoadedApk_.class, loadedApk);
+      loadedApkReflector.setResources(application.getResources());
+      loadedApkReflector.setApplication(application);
+      activityContext =
+          reflector(_ContextImpl_.class)
+              .createActivityContext(
+                  activityThread, loadedApk, activityInfo, token, displayId, overrideConfig);
+      reflector(_ContextImpl_.class, activityContext).setOuterContext(realActivity);
+      // This is not what the SDK does but for backwards compatibility with previous versions of
+      // robolectric, which did not use a separate activity context, move the theme from the
+      // application context (previously tests would configure the theme on the application context
+      // with the expectation that it modify the activity).
+      if (baseContext.getThemeResId() != 0) {
+        activityContext.setTheme(baseContext.getThemeResId());
+      }
+    } else {
+      activityContext = baseContext;
+    }
+
+    reflector(_Activity_.class, realActivity)
+        .callAttach(
+            realActivity,
+            activityContext,
+            activityThread,
+            instrumentation,
+            application,
+            intent,
+            activityInfo,
+            token,
+            activityTitle,
+            lastNonConfigurationInstances);
+
+    int theme = activityInfo.getThemeResource();
+    if (theme != 0) {
+      realActivity.setTheme(theme);
+    }
+  }
+
+  /**
+   * Sets the calling activity that will be reflected in {@link Activity#getCallingActivity} and
+   * {@link Activity#getCallingPackage}.
+   */
+  public void setCallingActivity(@Nullable ComponentName activityName) {
+    callingActivity = activityName;
+  }
+
+  @Implementation
+  protected ComponentName getCallingActivity() {
+    return callingActivity;
+  }
+
+  /**
+   * Sets the calling package that will be reflected in {@link Activity#getCallingActivity} and
+   * {@link Activity#getCallingPackage}.
+   *
+   * <p>Activity name defaults to some default value.
+   */
+  public void setCallingPackage(@Nullable String packageName) {
+    if (callingActivity != null && callingActivity.getPackageName().equals(packageName)) {
+      // preserve the calling activity as it was, so non-conflicting setCallingActivity followed by
+      // setCallingPackage will not erase previously set information.
+      return;
+    }
+    callingActivity =
+        packageName != null ? new ComponentName(packageName, "unknown.Activity") : null;
+  }
+
+  @Implementation
+  protected String getCallingPackage() {
+    return callingActivity != null ? callingActivity.getPackageName() : null;
+  }
+
+  @Implementation
+  protected void setDefaultKeyMode(int keyMode) {
+    mDefaultKeyMode = keyMode;
+
+    // Some modes use a SpannableStringBuilder to track & dispatch input events
+    // This list must remain in sync with the switch in onKeyDown()
+    switch (mDefaultKeyMode) {
+      case Activity.DEFAULT_KEYS_DISABLE:
+      case Activity.DEFAULT_KEYS_SHORTCUT:
+        mDefaultKeySsb = null; // not used in these modes
+        break;
+      case Activity.DEFAULT_KEYS_DIALER:
+      case Activity.DEFAULT_KEYS_SEARCH_LOCAL:
+      case Activity.DEFAULT_KEYS_SEARCH_GLOBAL:
+        mDefaultKeySsb = new SpannableStringBuilder();
+        Selection.setSelection(mDefaultKeySsb, 0);
+        break;
+      default:
+        throw new IllegalArgumentException();
+    }
+  }
+
+  public int getDefaultKeymode() {
+    return mDefaultKeyMode;
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected void setShowWhenLocked(boolean showWhenLocked) {
+    this.showWhenLocked = showWhenLocked;
+  }
+
+  @RequiresApi(api = O_MR1)
+  public boolean getShowWhenLocked() {
+    return showWhenLocked;
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected void setTurnScreenOn(boolean turnScreenOn) {
+    this.turnScreenOn = turnScreenOn;
+  }
+
+  @RequiresApi(api = O_MR1)
+  public boolean getTurnScreenOn() {
+    return turnScreenOn;
+  }
+
+  @Implementation
+  protected final void setResult(int resultCode) {
+    this.resultCode = resultCode;
+  }
+
+  @Implementation
+  protected final void setResult(int resultCode, Intent data) {
+    this.resultCode = resultCode;
+    this.resultIntent = data;
+  }
+
+  @Implementation
+  protected LayoutInflater getLayoutInflater() {
+    return LayoutInflater.from(realActivity);
+  }
+
+  @Implementation
+  protected MenuInflater getMenuInflater() {
+    return new MenuInflater(realActivity);
+  }
+
+  /**
+   * Checks to ensure that the{@code contentView} has been set
+   *
+   * @param id ID of the view to find
+   * @return the view
+   * @throws RuntimeException if the {@code contentView} has not been called first
+   */
+  @Implementation
+  protected View findViewById(int id) {
+    return getWindow().findViewById(id);
+  }
+
+  @Implementation
+  protected final Activity getParent() {
+    return parent;
+  }
+
+  /**
+   * Allow setting of Parent fragmentActivity (for unit testing purposes only)
+   *
+   * @param parent Parent fragmentActivity to set on this fragmentActivity
+   */
+  @HiddenApi
+  @Implementation
+  public void setParent(Activity parent) {
+    this.parent = parent;
+  }
+
+  @Implementation
+  protected void onBackPressed() {
+    finish();
+  }
+
+  @Implementation
+  protected void finish() {
+    // Sets the mFinished field in the real activity so NoDisplay activities can be tested.
+    reflector(_Activity_.class, realActivity).setFinished(true);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void finishAndRemoveTask() {
+    // Sets the mFinished field in the real activity so NoDisplay activities can be tested.
+    reflector(_Activity_.class, realActivity).setFinished(true);
+  }
+
+  @Implementation
+  protected void finishAffinity() {
+    // Sets the mFinished field in the real activity so NoDisplay activities can be tested.
+    reflector(_Activity_.class, realActivity).setFinished(true);
+  }
+
+  public void resetIsFinishing() {
+    reflector(_Activity_.class, realActivity).setFinished(false);
+  }
+
+  /**
+   * Returns whether {@link #finish()} was called.
+   *
+   * <p>Note: this method seems redundant, but removing it will cause problems for Mockito spies of
+   * Activities that call {@link Activity#finish()} followed by {@link Activity#isFinishing()}. This
+   * is because `finish` modifies the members of {@link ShadowActivity#realActivity}, so
+   * `isFinishing` should refer to those same members.
+   */
+  @Implementation(minSdk = JELLY_BEAN)
+  protected boolean isFinishing() {
+    return reflector(DirectActivityReflector.class, realActivity).isFinishing();
+  }
+
+  /**
+   * Constructs a new Window (a {@link com.android.internal.policy.impl.PhoneWindow}) if no window
+   * has previously been set.
+   *
+   * @return the window associated with this Activity
+   */
+  @Implementation
+  protected Window getWindow() {
+    Window window = reflector(DirectActivityReflector.class, realActivity).getWindow();
+
+    if (window == null) {
+      try {
+        window = ShadowWindow.create(realActivity);
+        setWindow(window);
+      } catch (Exception e) {
+        throw new RuntimeException("Window creation failed!", e);
+      }
+    }
+
+    return window;
+  }
+
+  /**
+   * @return fake SplashScreen
+   */
+  @Implementation(minSdk = S)
+  protected synchronized Object getSplashScreen() {
+    if (splashScreen == null) {
+      splashScreen = new RoboSplashScreen();
+    }
+    return splashScreen;
+  }
+
+  public void setWindow(Window window) {
+    reflector(_Activity_.class, realActivity).setWindow(window);
+  }
+
+  @Implementation
+  protected void runOnUiThread(Runnable action) {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      reflector(DirectActivityReflector.class, realActivity).runOnUiThread(action);
+    } else {
+      ShadowApplication.getInstance().getForegroundThreadScheduler().post(action);
+    }
+  }
+
+  @Implementation
+  protected void setRequestedOrientation(int requestedOrientation) {
+    if (getParent() != null) {
+      getParent().setRequestedOrientation(requestedOrientation);
+    } else {
+      this.requestedOrientation = requestedOrientation;
+    }
+  }
+
+  @Implementation
+  protected int getRequestedOrientation() {
+    if (getParent() != null) {
+      return getParent().getRequestedOrientation();
+    } else {
+      return this.requestedOrientation;
+    }
+  }
+
+  @Implementation
+  protected int getTaskId() {
+    return 0;
+  }
+
+  @Implementation
+  public void startIntentSenderForResult(
+      IntentSender intentSender,
+      int requestCode,
+      @Nullable Intent fillInIntent,
+      int flagsMask,
+      int flagsValues,
+      int extraFlags,
+      Bundle options)
+      throws IntentSender.SendIntentException {
+    if (throwIntentSenderException) {
+      throw new IntentSender.SendIntentException("PendingIntent was canceled");
+    }
+    lastIntentSenderRequest =
+        new IntentSenderRequest(
+            intentSender, requestCode, fillInIntent, flagsMask, flagsValues, extraFlags, options);
+    lastIntentSenderRequest.send();
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void reportFullyDrawn() {
+    hasReportedFullyDrawn = true;
+  }
+
+  /**
+   * @return whether {@code ReportFullyDrawn()} methods has been called.
+   */
+  public boolean getReportFullyDrawn() {
+    return hasReportedFullyDrawn;
+  }
+
+  /**
+   * @return the {@code contentView} set by one of the {@code setContentView()} methods
+   */
+  public View getContentView() {
+    return ((ViewGroup) getWindow().findViewById(android.R.id.content)).getChildAt(0);
+  }
+
+  /**
+   * @return the {@code resultCode} set by one of the {@code setResult()} methods
+   */
+  public int getResultCode() {
+    return resultCode;
+  }
+
+  /**
+   * @return the {@code Intent} set by {@link #setResult(int, android.content.Intent)}
+   */
+  public Intent getResultIntent() {
+    return resultIntent;
+  }
+
+  /**
+   * Consumes and returns the next {@code Intent} on the started activities for results stack.
+   *
+   * @return the next started {@code Intent} for an activity, wrapped in an {@link
+   *     ShadowActivity.IntentForResult} object
+   */
+  public IntentForResult getNextStartedActivityForResult() {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    ShadowInstrumentation shadowInstrumentation =
+        Shadow.extract(activityThread.getInstrumentation());
+    return shadowInstrumentation.getNextStartedActivityForResult();
+  }
+
+  /**
+   * Returns the most recent {@code Intent} started by {@link
+   * Activity#startActivityForResult(Intent, int)} without consuming it.
+   *
+   * @return the most recently started {@code Intent}, wrapped in an {@link
+   *     ShadowActivity.IntentForResult} object
+   */
+  public IntentForResult peekNextStartedActivityForResult() {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    ShadowInstrumentation shadowInstrumentation =
+        Shadow.extract(activityThread.getInstrumentation());
+    return shadowInstrumentation.peekNextStartedActivityForResult();
+  }
+
+  @Implementation
+  protected Object getLastNonConfigurationInstance() {
+    if (lastNonConfigurationInstance != null) {
+      return lastNonConfigurationInstance;
+    }
+    return reflector(DirectActivityReflector.class, realActivity).getLastNonConfigurationInstance();
+  }
+
+  /**
+   * @deprecated use {@link ActivityController#recreate()}.
+   */
+  @Deprecated
+  public void setLastNonConfigurationInstance(Object lastNonConfigurationInstance) {
+    this.lastNonConfigurationInstance = lastNonConfigurationInstance;
+  }
+
+  /**
+   * @param view View to focus.
+   */
+  public void setCurrentFocus(View view) {
+    currentFocus = view;
+  }
+
+  @Implementation
+  protected View getCurrentFocus() {
+    return currentFocus;
+  }
+
+  public int getPendingTransitionEnterAnimationResourceId() {
+    return pendingTransitionEnterAnimResId;
+  }
+
+  public int getPendingTransitionExitAnimationResourceId() {
+    return pendingTransitionExitAnimResId;
+  }
+
+  @Implementation
+  protected boolean onCreateOptionsMenu(Menu menu) {
+    optionsMenu = menu;
+    return reflector(DirectActivityReflector.class, realActivity).onCreateOptionsMenu(menu);
+  }
+
+  /**
+   * Return the options menu.
+   *
+   * @return Options menu.
+   */
+  public Menu getOptionsMenu() {
+    return optionsMenu;
+  }
+
+  /**
+   * Perform a click on a menu item.
+   *
+   * @param menuItemResId Menu item resource ID.
+   * @return True if the click was handled, false otherwise.
+   */
+  public boolean clickMenuItem(int menuItemResId) {
+    final RoboMenuItem item = new RoboMenuItem(menuItemResId);
+    return realActivity.onMenuItemSelected(Window.FEATURE_OPTIONS_PANEL, item);
+  }
+
+  @Deprecated
+  public void callOnActivityResult(int requestCode, int resultCode, Intent resultData) {
+    reflector(_Activity_.class, realActivity).onActivityResult(requestCode, resultCode, resultData);
+  }
+
+  /** For internal use only. Not for public use. */
+  public void internalCallDispatchActivityResult(
+      String who, int requestCode, int resultCode, Intent data) {
+    if (VERSION.SDK_INT >= VERSION_CODES.P) {
+      reflector(_Activity_.class, realActivity)
+          .dispatchActivityResult(who, requestCode, resultCode, data, "ACTIVITY_RESULT");
+    } else {
+      reflector(_Activity_.class, realActivity)
+          .dispatchActivityResult(who, requestCode, resultCode, data);
+    }
+  }
+
+  /** For internal use only. Not for public use. */
+  public <T extends Activity> void attachController(ActivityController controller) {
+    this.controller = controller;
+  }
+
+  /** Sets if startIntentSenderForRequestCode will throw an IntentSender.SendIntentException. */
+  public void setThrowIntentSenderException(boolean throwIntentSenderException) {
+    this.throwIntentSenderException = throwIntentSenderException;
+  }
+
+  /**
+   * Container object to hold an Intent, together with the requestCode used in a call to {@code
+   * Activity.startActivityForResult(Intent, int)}
+   */
+  public static class IntentForResult {
+    public Intent intent;
+    public int requestCode;
+    public Bundle options;
+
+    public IntentForResult(Intent intent, int requestCode) {
+      this.intent = intent;
+      this.requestCode = requestCode;
+      this.options = null;
+    }
+
+    public IntentForResult(Intent intent, int requestCode, Bundle options) {
+      this.intent = intent;
+      this.requestCode = requestCode;
+      this.options = options;
+    }
+
+    @Override
+    public String toString() {
+      return super.toString()
+          + "{intent="
+          + intent
+          + ", requestCode="
+          + requestCode
+          + ", options="
+          + options
+          + '}';
+    }
+  }
+
+  public void receiveResult(Intent requestIntent, int resultCode, Intent resultIntent) {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    ShadowInstrumentation shadowInstrumentation =
+        Shadow.extract(activityThread.getInstrumentation());
+    TargetAndRequestCode targetAndRequestCode =
+        shadowInstrumentation.getTargetAndRequestCodeForIntent(requestIntent);
+
+    internalCallDispatchActivityResult(
+        targetAndRequestCode.target, targetAndRequestCode.requestCode, resultCode, resultIntent);
+  }
+
+  @Implementation
+  protected final void showDialog(int id) {
+    showDialog(id, null);
+  }
+
+  @Implementation
+  protected final void dismissDialog(int id) {
+    final Dialog dialog = dialogForId.get(id);
+    if (dialog == null) {
+      throw new IllegalArgumentException();
+    }
+
+    dialog.dismiss();
+  }
+
+  @Implementation
+  protected final void removeDialog(int id) {
+    dialogForId.remove(id);
+  }
+
+  @Implementation
+  protected final boolean showDialog(int id, Bundle bundle) {
+    this.lastShownDialogId = id;
+    Dialog dialog = dialogForId.get(id);
+
+    if (dialog == null) {
+      dialog = reflector(_Activity_.class, realActivity).onCreateDialog(id);
+      if (dialog == null) {
+        return false;
+      }
+      if (bundle == null) {
+        reflector(_Activity_.class, realActivity).onPrepareDialog(id, dialog);
+      } else {
+        reflector(_Activity_.class, realActivity).onPrepareDialog(id, dialog, bundle);
+      }
+
+      dialogForId.put(id, dialog);
+    }
+
+    dialog.show();
+    return true;
+  }
+
+  public void setIsTaskRoot(boolean isRoot) {
+    mIsTaskRoot = isRoot;
+  }
+
+  @Implementation
+  protected final boolean isTaskRoot() {
+    return mIsTaskRoot;
+  }
+
+  /**
+   * @return the dialog resource id passed into {@code Activity.showDialog(int, Bundle)} or {@code
+   *     Activity.showDialog(int)}
+   */
+  public Integer getLastShownDialogId() {
+    return lastShownDialogId;
+  }
+
+  public boolean hasCancelledPendingTransitions() {
+    return pendingTransitionEnterAnimResId == 0 && pendingTransitionExitAnimResId == 0;
+  }
+
+  @Implementation
+  protected void overridePendingTransition(int enterAnim, int exitAnim) {
+    pendingTransitionEnterAnimResId = enterAnim;
+    pendingTransitionExitAnimResId = exitAnim;
+  }
+
+  public Dialog getDialogById(int dialogId) {
+    return dialogForId.get(dialogId);
+  }
+
+  // TODO(hoisie): consider moving this to ActivityController#makeActivityEligibleForGc
+  @Implementation
+  protected void onDestroy() {
+    reflector(DirectActivityReflector.class, realActivity).onDestroy();
+    ShadowActivityThread activityThread = Shadow.extract(RuntimeEnvironment.getActivityThread());
+    IBinder token = reflector(_Activity_.class, realActivity).getToken();
+    activityThread.removeActivity(token);
+  }
+
+  @Implementation
+  protected void recreate() {
+    if (controller != null) {
+      // Post the call to recreate to simulate ActivityThread behavior.
+      new Handler(Looper.getMainLooper()).post(controller::recreate);
+    } else {
+      throw new IllegalStateException(
+          "Cannot use an Activity that is not managed by an ActivityController");
+    }
+  }
+
+  @Implementation
+  protected void startManagingCursor(Cursor c) {
+    managedCursors.add(c);
+  }
+
+  @Implementation
+  protected void stopManagingCursor(Cursor c) {
+    managedCursors.remove(c);
+  }
+
+  public List<Cursor> getManagedCursors() {
+    return managedCursors;
+  }
+
+  @Implementation
+  protected final void setVolumeControlStream(int streamType) {
+    this.streamType = streamType;
+  }
+
+  @Implementation
+  protected final int getVolumeControlStream() {
+    return streamType;
+  }
+
+  @Implementation(minSdk = M)
+  protected final void requestPermissions(String[] permissions, int requestCode) {
+    lastRequestedPermission = new PermissionsRequest(permissions, requestCode);
+    reflector(DirectActivityReflector.class, realActivity)
+        .requestPermissions(permissions, requestCode);
+  }
+
+  /**
+   * Starts a lock task.
+   *
+   * <p>The status of the lock task can be verified using {@link #isLockTask} method. Otherwise this
+   * implementation has no effect.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void startLockTask() {
+    Shadow.<ShadowActivityManager>extract(getActivityManager())
+        .setLockTaskModeState(ActivityManager.LOCK_TASK_MODE_LOCKED);
+  }
+
+  /**
+   * Stops a lock task.
+   *
+   * <p>The status of the lock task can be verified using {@link #isLockTask} method. Otherwise this
+   * implementation has no effect.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void stopLockTask() {
+    Shadow.<ShadowActivityManager>extract(getActivityManager())
+        .setLockTaskModeState(ActivityManager.LOCK_TASK_MODE_NONE);
+  }
+
+  /**
+   * Returns if the activity is in the lock task mode.
+   *
+   * @deprecated Use {@link ActivityManager#getLockTaskModeState} instead.
+   */
+  @Deprecated
+  public boolean isLockTask() {
+    return getActivityManager().isInLockTaskMode();
+  }
+
+  private ActivityManager getActivityManager() {
+    return (ActivityManager) realActivity.getSystemService(Context.ACTIVITY_SERVICE);
+  }
+
+  /** Changes state of {@link #isInMultiWindowMode} method. */
+  public void setInMultiWindowMode(boolean value) {
+    inMultiWindowMode = value;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean isInMultiWindowMode() {
+    return inMultiWindowMode;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean isInPictureInPictureMode() {
+    return isInPictureInPictureMode;
+  }
+
+  @Implementation(minSdk = N)
+  protected void enterPictureInPictureMode() {
+    isInPictureInPictureMode = true;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean enterPictureInPictureMode(PictureInPictureParams params) {
+    isInPictureInPictureMode = true;
+    return true;
+  }
+
+  @Implementation
+  protected boolean moveTaskToBack(boolean nonRoot) {
+    isInPictureInPictureMode = false;
+    return true;
+  }
+
+  /**
+   * Gets the last startIntentSenderForResult request made to this activity.
+   *
+   * @return The IntentSender request details.
+   */
+  public IntentSenderRequest getLastIntentSenderRequest() {
+    return lastIntentSenderRequest;
+  }
+
+  /**
+   * Gets the last permission request submitted to this activity.
+   *
+   * @return The permission request details.
+   */
+  public PermissionsRequest getLastRequestedPermission() {
+    return lastRequestedPermission;
+  }
+
+  /**
+   * Initializes the associated Activity with an {@link android.app.VoiceInteractor} instance.
+   * Subsequent {@link android.app.Activity#getVoiceInteractor()} calls on the associated activity
+   * will return a {@link android.app.VoiceInteractor} instance
+   */
+  public void initializeVoiceInteractor() {
+    if (RuntimeEnvironment.getApiLevel() < N) {
+      throw new IllegalStateException("initializeVoiceInteractor requires API " + N);
+    }
+    reflector(_Activity_.class, realActivity)
+        .setVoiceInteractor(ReflectionHelpers.createDeepProxy(IVoiceInteractor.class));
+  }
+
+  /**
+   * Calls Activity#onGetDirectActions with the given parameters. This method also simulates the
+   * Parcel serialization/deserialization which occurs when assistant requests DirectAction.
+   */
+  public void callOnGetDirectActions(
+      CancellationSignal cancellationSignal, Consumer<List<DirectAction>> callback) {
+    if (RuntimeEnvironment.getApiLevel() < Q) {
+      throw new IllegalStateException("callOnGetDirectActions requires API " + Q);
+    }
+    realActivity.onGetDirectActions(
+        cancellationSignal,
+        directActions -> {
+          Parcel parcel = Parcel.obtain();
+          parcel.writeParcelableList(directActions, 0);
+          parcel.setDataPosition(0);
+          List<DirectAction> output = new ArrayList<>();
+          parcel.readParcelableList(output, DirectAction.class.getClassLoader());
+          callback.accept(output);
+        });
+  }
+
+  /** Class to hold a permissions request, including its request code. */
+  public static class PermissionsRequest {
+    public final int requestCode;
+    public final String[] requestedPermissions;
+
+    public PermissionsRequest(String[] requestedPermissions, int requestCode) {
+      this.requestedPermissions = requestedPermissions;
+      this.requestCode = requestCode;
+    }
+  }
+
+  /** Class to holds details of a startIntentSenderForResult request. */
+  public static class IntentSenderRequest {
+    public final IntentSender intentSender;
+    public final int requestCode;
+    @Nullable public final Intent fillInIntent;
+    public final int flagsMask;
+    public final int flagsValues;
+    public final int extraFlags;
+    public final Bundle options;
+
+    public IntentSenderRequest(
+        IntentSender intentSender,
+        int requestCode,
+        @Nullable Intent fillInIntent,
+        int flagsMask,
+        int flagsValues,
+        int extraFlags,
+        Bundle options) {
+      this.intentSender = intentSender;
+      this.requestCode = requestCode;
+      this.fillInIntent = fillInIntent;
+      this.flagsMask = flagsMask;
+      this.flagsValues = flagsValues;
+      this.extraFlags = extraFlags;
+      this.options = options;
+    }
+
+    public void send() {
+      if (intentSender instanceof RoboIntentSender) {
+        try {
+          Shadow.<ShadowPendingIntent>extract(((RoboIntentSender) intentSender).getPendingIntent())
+              .send(
+                  RuntimeEnvironment.getApplication(),
+                  0,
+                  null,
+                  null,
+                  null,
+                  null,
+                  null,
+                  requestCode);
+        } catch (PendingIntent.CanceledException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+  }
+
+  private ShadowPackageManager shadowOf(PackageManager packageManager) {
+    return Shadow.extract(packageManager);
+  }
+
+  @ForType(value = Activity.class, direct = true)
+  interface DirectActivityReflector {
+
+    void runOnUiThread(Runnable action);
+
+    void onDestroy();
+
+    boolean isFinishing();
+
+    Window getWindow();
+
+    Object getLastNonConfigurationInstance();
+
+    boolean onCreateOptionsMenu(Menu menu);
+
+    void requestPermissions(String[] permissions, int requestCode);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityGroup.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityGroup.java
new file mode 100644
index 0000000..49ef26d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityGroup.java
@@ -0,0 +1,23 @@
+package org.robolectric.shadows;
+
+import android.app.Activity;
+import android.app.ActivityGroup;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(ActivityGroup.class)
+public class ShadowActivityGroup extends ShadowActivity {
+  private Activity currentActivity;
+
+  @Implementation
+  protected android.app.Activity getCurrentActivity() {
+    return currentActivity;
+  }
+
+  /**
+   * @param activity Current activity.
+   */
+  public void setCurrentActivity(Activity activity) {
+    currentActivity = activity;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityManager.java
new file mode 100644
index 0000000..c28bdc4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityManager.java
@@ -0,0 +1,529 @@
+package org.robolectric.shadows;
+
+import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.R;
+import static java.util.stream.Collectors.toCollection;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.RequiresPermission;
+import android.app.ActivityManager;
+import android.app.ApplicationExitInfo;
+import android.app.IActivityManager;
+import android.content.Context;
+import android.content.pm.ConfigurationInfo;
+import android.content.pm.IPackageDataObserver;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Process;
+import android.os.UserHandle;
+import android.util.ArrayMap;
+import android.util.SparseIntArray;
+import androidx.annotation.RequiresApi;
+import com.google.common.base.Preconditions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.app.ActivityManager} */
+@Implements(value = ActivityManager.class, looseSignatures = true)
+public class ShadowActivityManager {
+  private int memoryClass = 16;
+  private String backgroundPackage;
+  private ActivityManager.MemoryInfo memoryInfo;
+  private final List<ActivityManager.AppTask> appTasks = new CopyOnWriteArrayList<>();
+  private final List<ActivityManager.RunningTaskInfo> tasks = new CopyOnWriteArrayList<>();
+  private final List<ActivityManager.RunningServiceInfo> services = new CopyOnWriteArrayList<>();
+  private static final List<ActivityManager.RunningAppProcessInfo> processes =
+      new CopyOnWriteArrayList<>();
+  private final List<ImportanceListener> importanceListeners = new CopyOnWriteArrayList<>();
+  private final SparseIntArray uidImportances = new SparseIntArray();
+  @RealObject private ActivityManager realObject;
+  private Boolean isLowRamDeviceOverride = null;
+  private int lockTaskModeState = ActivityManager.LOCK_TASK_MODE_NONE;
+  private boolean isBackgroundRestricted;
+  private final Deque<Object> appExitInfoList = new ArrayDeque<>();
+  private ConfigurationInfo configurationInfo;
+  private Context context;
+
+  @Implementation
+  protected void __constructor__(Context context, Handler handler) {
+    Shadow.invokeConstructor(
+        ActivityManager.class,
+        realObject,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(Handler.class, handler));
+    this.context = context;
+    ActivityManager.RunningAppProcessInfo processInfo = new ActivityManager.RunningAppProcessInfo();
+    fillInProcessInfo(processInfo);
+    processInfo.processName = context.getPackageName();
+    processInfo.pkgList = new String[] {context.getPackageName()};
+    processes.add(processInfo);
+  }
+
+  @Implementation
+  protected int getMemoryClass() {
+    return memoryClass;
+  }
+
+  @Implementation
+  protected static boolean isUserAMonkey() {
+    return false;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  @HiddenApi
+  @RequiresPermission(
+      anyOf = {
+        "android.permission.INTERACT_ACROSS_USERS",
+        "android.permission.INTERACT_ACROSS_USERS_FULL"
+      })
+  protected static int getCurrentUser() {
+    return UserHandle.myUserId();
+  }
+
+  @Implementation
+  protected List<ActivityManager.RunningTaskInfo> getRunningTasks(int maxNum) {
+    return tasks;
+  }
+
+  /**
+   * For tests, returns the list of {@link android.app.ActivityManager.AppTask} set using {@link
+   * #setAppTasks(List)}. Returns empty list if nothing is set.
+   *
+   * @see #setAppTasks(List)
+   * @return List of current AppTask.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected List<ActivityManager.AppTask> getAppTasks() {
+    return appTasks;
+  }
+
+  @Implementation
+  protected List<ActivityManager.RunningServiceInfo> getRunningServices(int maxNum) {
+    return services;
+  }
+
+  @Implementation
+  protected List<ActivityManager.RunningAppProcessInfo> getRunningAppProcesses() {
+    // This method is explicitly documented not to return an empty list
+    if (processes.isEmpty()) {
+      return null;
+    }
+    return processes;
+  }
+
+  /** Returns information seeded by {@link #setProcesses}. */
+  @Implementation
+  protected static void getMyMemoryState(ActivityManager.RunningAppProcessInfo inState) {
+    fillInProcessInfo(inState);
+    for (ActivityManager.RunningAppProcessInfo info : processes) {
+      if (info.pid == Process.myPid()) {
+        inState.importance = info.importance;
+        inState.lru = info.lru;
+        inState.importanceReasonCode = info.importanceReasonCode;
+        inState.importanceReasonPid = info.importanceReasonPid;
+        inState.lastTrimLevel = info.lastTrimLevel;
+        inState.pkgList = info.pkgList;
+        inState.processName = info.processName;
+      }
+    }
+  }
+
+  private static void fillInProcessInfo(ActivityManager.RunningAppProcessInfo processInfo) {
+    processInfo.pid = Process.myPid();
+    processInfo.uid = Process.myUid();
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected boolean switchUser(int userid) {
+    ShadowUserManager shadowUserManager =
+        Shadow.extract(context.getSystemService(Context.USER_SERVICE));
+    shadowUserManager.switchUser(userid);
+    return true;
+  }
+
+  @Implementation(minSdk = android.os.Build.VERSION_CODES.Q)
+  protected boolean switchUser(UserHandle userHandle) {
+    return switchUser(userHandle.getIdentifier());
+  }
+
+  @Implementation
+  protected void killBackgroundProcesses(String packageName) {
+    backgroundPackage = packageName;
+  }
+
+  @Implementation
+  protected void getMemoryInfo(ActivityManager.MemoryInfo outInfo) {
+    if (memoryInfo != null) {
+      outInfo.availMem = memoryInfo.availMem;
+      outInfo.lowMemory = memoryInfo.lowMemory;
+      outInfo.threshold = memoryInfo.threshold;
+      outInfo.totalMem = memoryInfo.totalMem;
+    }
+  }
+
+  @Implementation
+  protected android.content.pm.ConfigurationInfo getDeviceConfigurationInfo() {
+    return configurationInfo == null ? new ConfigurationInfo() : configurationInfo;
+  }
+
+  /**
+   * Sets the {@link android.content.pm.ConfigurationInfo} returned by {@link
+   * ActivityManager#getDeviceConfigurationInfo()}, but has no effect otherwise.
+   */
+  public void setDeviceConfigurationInfo(ConfigurationInfo configurationInfo) {
+    this.configurationInfo = configurationInfo;
+  }
+
+  /** @param tasks List of running tasks. */
+  public void setTasks(List<ActivityManager.RunningTaskInfo> tasks) {
+    this.tasks.clear();
+    this.tasks.addAll(tasks);
+  }
+
+  /**
+   * Sets the values to be returned by {@link #getAppTasks()}.
+   *
+   * @see #getAppTasks()
+   * @param tasks List of app tasks.
+   */
+  public void setAppTasks(List<ActivityManager.AppTask> appTasks) {
+    this.appTasks.clear();
+    this.appTasks.addAll(appTasks);
+  }
+
+  /** @param services List of running services. */
+  public void setServices(List<ActivityManager.RunningServiceInfo> services) {
+    this.services.clear();
+    this.services.addAll(services);
+  }
+
+  /** @param processes List of running processes. */
+  public void setProcesses(List<ActivityManager.RunningAppProcessInfo> processes) {
+    ShadowActivityManager.processes.clear();
+    ShadowActivityManager.processes.addAll(processes);
+  }
+
+  /** @return Get the package name of the last background processes killed. */
+  public String getBackgroundPackage() {
+    return backgroundPackage;
+  }
+
+  /** @param memoryClass Set the application's memory class. */
+  public void setMemoryClass(int memoryClass) {
+    this.memoryClass = memoryClass;
+  }
+
+  /** @param memoryInfo Set the application's memory info. */
+  public void setMemoryInfo(ActivityManager.MemoryInfo memoryInfo) {
+    this.memoryInfo = memoryInfo;
+  }
+
+  @Implementation(minSdk = O)
+  protected static IActivityManager getService() {
+    return ReflectionHelpers.createNullProxy(IActivityManager.class);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected boolean isLowRamDevice() {
+    if (isLowRamDeviceOverride != null) {
+      return isLowRamDeviceOverride;
+    }
+    return reflector(ActivityManagerReflector.class, realObject).isLowRamDevice();
+  }
+
+  /** Override the return value of isLowRamDevice(). */
+  public void setIsLowRamDevice(boolean isLowRamDevice) {
+    isLowRamDeviceOverride = isLowRamDevice;
+  }
+
+  @Implementation(minSdk = O)
+  protected void addOnUidImportanceListener(Object listener, Object importanceCutpoint) {
+    importanceListeners.add(new ImportanceListener(listener, (Integer) importanceCutpoint));
+  }
+
+  @Implementation(minSdk = O)
+  protected void removeOnUidImportanceListener(Object listener) {
+    importanceListeners.remove(new ImportanceListener(listener));
+  }
+
+  @Implementation(minSdk = M)
+  protected int getPackageImportance(String packageName) {
+    try {
+      return uidImportances.get(
+          context.getPackageManager().getPackageUid(packageName, 0), IMPORTANCE_GONE);
+    } catch (NameNotFoundException e) {
+      return IMPORTANCE_GONE;
+    }
+  }
+
+  @Implementation(minSdk = O)
+  protected int getUidImportance(int uid) {
+    return uidImportances.get(uid, IMPORTANCE_GONE);
+  }
+
+  public void setUidImportance(int uid, int importance) {
+    uidImportances.put(uid, importance);
+    for (ImportanceListener listener : importanceListeners) {
+      listener.onUidImportanceChanged(uid, importance);
+    }
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected int getLockTaskModeState() {
+    return lockTaskModeState;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected boolean isInLockTaskMode() {
+    return getLockTaskModeState() != ActivityManager.LOCK_TASK_MODE_NONE;
+  }
+
+  /**
+   * Sets lock task mode state to be reported by {@link ActivityManager#getLockTaskModeState}, but
+   * has no effect otherwise.
+   */
+  public void setLockTaskModeState(int lockTaskModeState) {
+    this.lockTaskModeState = lockTaskModeState;
+  }
+
+  @Resetter
+  public static void reset() {
+    processes.clear();
+  }
+
+  /** Returns the background restriction state set by {@link #setBackgroundRestricted}. */
+  @Implementation(minSdk = P)
+  protected boolean isBackgroundRestricted() {
+    return isBackgroundRestricted;
+  }
+
+  /**
+   * Sets the background restriction state reported by {@link
+   * ActivityManager#isBackgroundRestricted}, but has no effect otherwise.
+   */
+  public void setBackgroundRestricted(boolean isBackgroundRestricted) {
+    this.isBackgroundRestricted = isBackgroundRestricted;
+  }
+
+  /**
+   * Returns the matched {@link ApplicationExitInfo} added by {@link #addApplicationExitInfo}.
+   * {@code packageName} is ignored.
+   */
+  @Implementation(minSdk = R)
+  protected Object getHistoricalProcessExitReasons(Object packageName, Object pid, Object maxNum) {
+    return appExitInfoList.stream()
+        .filter(
+            appExitInfo ->
+                (int) pid == 0 || ((ApplicationExitInfo) appExitInfo).getPid() == (int) pid)
+        .limit((int) maxNum == 0 ? appExitInfoList.size() : (int) maxNum)
+        .collect(toCollection(ArrayList::new));
+  }
+
+  /**
+   * Adds an {@link ApplicationExitInfo} with the given information
+   *
+   * @deprecated Prefer using overload with {@link ApplicationExitInfoBuilder}
+   */
+  @Deprecated
+  @RequiresApi(api = R)
+  public void addApplicationExitInfo(String processName, int pid, int reason, int status) {
+    ApplicationExitInfo info =
+        ApplicationExitInfoBuilder.newBuilder()
+            .setProcessName(processName)
+            .setPid(pid)
+            .setReason(reason)
+            .setStatus(status)
+            .build();
+    addApplicationExitInfo(info);
+  }
+
+  /** Adds given {@link ApplicationExitInfo}, see {@link ApplicationExitInfoBuilder} */
+  @RequiresApi(api = R)
+  public void addApplicationExitInfo(Object info) {
+    Preconditions.checkArgument(info instanceof ApplicationExitInfo);
+    appExitInfoList.addFirst(info);
+  }
+
+  @Implementation
+  protected boolean clearApplicationUserData(String packageName, IPackageDataObserver observer) {
+    // The real ActivityManager calls clearApplicationUserData on the ActivityManagerService that
+    // calls PackageManager#clearApplicationUserData.
+    context.getPackageManager().clearApplicationUserData(packageName, observer);
+    return true;
+  }
+
+  /**
+   * Returns true after clearing application user data was requested by calling {@link
+   * ActivityManager#clearApplicationUserData()}.
+   */
+  public boolean isApplicationUserDataCleared() {
+    PackageManager packageManager = RuntimeEnvironment.getApplication().getPackageManager();
+    return Shadow.<ShadowApplicationPackageManager>extract(packageManager)
+        .getClearedApplicationUserDataPackages()
+        .contains(RuntimeEnvironment.getApplication().getPackageName());
+  }
+
+  /** Builder class for {@link ApplicationExitInfo} */
+  @RequiresApi(api = R)
+  public static class ApplicationExitInfoBuilder {
+
+    private final ApplicationExitInfo instance;
+
+    public static ApplicationExitInfoBuilder newBuilder() {
+      return new ApplicationExitInfoBuilder();
+    }
+
+    public ApplicationExitInfoBuilder setDefiningUid(int uid) {
+      instance.setDefiningUid(uid);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setDescription(String description) {
+      instance.setDescription(description);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setImportance(int importance) {
+      instance.setImportance(importance);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setPackageUid(int packageUid) {
+      instance.setPackageUid(packageUid);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setPid(int pid) {
+      instance.setPid(pid);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setProcessName(String processName) {
+      instance.setProcessName(processName);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setProcessStateSummary(byte[] processStateSummary) {
+      instance.setProcessStateSummary(processStateSummary);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setPss(long pss) {
+      instance.setPss(pss);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setRealUid(int realUid) {
+      instance.setRealUid(realUid);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setReason(int reason) {
+      instance.setReason(reason);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setRss(long rss) {
+      instance.setRss(rss);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setStatus(int status) {
+      instance.setStatus(status);
+      return this;
+    }
+
+    public ApplicationExitInfoBuilder setTimestamp(long timestamp) {
+      instance.setTimestamp(timestamp);
+      return this;
+    }
+
+    public ApplicationExitInfo build() {
+      return instance;
+    }
+
+    private ApplicationExitInfoBuilder() {
+      this.instance = new ApplicationExitInfo();
+    }
+  }
+
+  @ForType(ActivityManager.class)
+  interface ActivityManagerReflector {
+
+    @Direct
+    boolean isLowRamDevice();
+  }
+
+  /**
+   * Helper class mimicing the package-private UidObserver class inside {@link ActivityManager}.
+   *
+   * <p>This class is responsible for maintaining the cutpoint of the corresponding {@link
+   * ActivityManager.OnUidImportanceListener} and invoking the listener only when the importance of
+   * a given UID crosses the cutpoint.
+   */
+  private static class ImportanceListener {
+
+    private final ActivityManager.OnUidImportanceListener listener;
+    private final int importanceCutpoint;
+
+    private final ArrayMap<Integer, Boolean> lastAboveCuts = new ArrayMap<>();
+
+    ImportanceListener(Object listener) {
+      this(listener, 0);
+    }
+
+    ImportanceListener(Object listener, int importanceCutpoint) {
+      this.listener = (ActivityManager.OnUidImportanceListener) listener;
+      this.importanceCutpoint = importanceCutpoint;
+    }
+
+    void onUidImportanceChanged(int uid, int importance) {
+      Boolean isAboveCut = importance > importanceCutpoint;
+      if (!isAboveCut.equals(lastAboveCuts.get(uid))) {
+        lastAboveCuts.put(uid, isAboveCut);
+        listener.onUidImportance(uid, importance);
+      }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof ImportanceListener)) {
+        return false;
+      }
+
+      ImportanceListener that = (ImportanceListener) o;
+      return listener.equals(that.listener);
+    }
+
+    @Override
+    public int hashCode() {
+      return listener.hashCode();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityManagerNative.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityManagerNative.java
new file mode 100644
index 0000000..045a771
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityManagerNative.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import android.app.ActivityManagerNative;
+import android.app.IActivityManager;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = ActivityManagerNative.class, isInAndroidSdk = false)
+public class ShadowActivityManagerNative {
+  private static final IActivityManager activityManager =
+      ReflectionHelpers.createNullProxy(IActivityManager.class);
+
+  @Implementation
+  static public IActivityManager getDefault() {
+    return activityManager;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityTaskManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityTaskManager.java
new file mode 100644
index 0000000..4e1e17c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityTaskManager.java
@@ -0,0 +1,17 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.app.ActivityTaskManager;
+import android.app.IActivityTaskManager;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = ActivityTaskManager.class, minSdk = Q, isInAndroidSdk = false)
+public class ShadowActivityTaskManager {
+  @Implementation
+  protected static IActivityTaskManager getService() {
+    return ReflectionHelpers.createDeepProxy(IActivityTaskManager.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java
new file mode 100644
index 0000000..9d4f6ab
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java
@@ -0,0 +1,280 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Activity;
+import android.app.ActivityThread;
+import android.app.ActivityThread.ActivityClientRecord;
+import android.app.Application;
+import android.app.Instrumentation;
+import android.app.ResultInfo;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.os.IBinder;
+import com.android.internal.content.ReferrerIntent;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nonnull;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Reflector;
+
+@Implements(value = ActivityThread.class, isInAndroidSdk = false, looseSignatures = true)
+public class ShadowActivityThread {
+  private static ApplicationInfo applicationInfo;
+  @RealObject protected ActivityThread realActivityThread;
+  @ReflectorObject protected _ActivityThread_ activityThreadReflector;
+
+  @Implementation
+  public static Object getPackageManager() {
+    ClassLoader classLoader = ShadowActivityThread.class.getClassLoader();
+    Class<?> iPackageManagerClass;
+    try {
+      iPackageManagerClass = classLoader.loadClass("android.content.pm.IPackageManager");
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+    return Proxy.newProxyInstance(
+        classLoader,
+        new Class[] {iPackageManagerClass},
+        new InvocationHandler() {
+          @Override
+          public Object invoke(Object proxy, @Nonnull Method method, Object[] args)
+              throws Exception {
+            if (method.getName().equals("getApplicationInfo")) {
+              String packageName = (String) args[0];
+              int flags = ((Number) args[1]).intValue();
+              if (packageName.equals(ShadowActivityThread.applicationInfo.packageName)) {
+                return ShadowActivityThread.applicationInfo;
+              }
+
+              try {
+                return RuntimeEnvironment.getApplication()
+                    .getPackageManager()
+                    .getApplicationInfo(packageName, flags);
+              } catch (PackageManager.NameNotFoundException e) {
+                return null;
+              }
+            } else if (method.getName().equals("notifyPackageUse")) {
+              return null;
+            } else if (method.getName().equals("getPackageInstaller")) {
+              return null;
+            } else if (method.getName().equals("hasSystemFeature")) {
+              String featureName = (String) args[0];
+              return RuntimeEnvironment.getApplication()
+                  .getPackageManager()
+                  .hasSystemFeature(featureName);
+            }
+            throw new UnsupportedOperationException("sorry, not supporting " + method + " yet!");
+          }
+        });
+  }
+
+  @Implementation
+  public static Object currentActivityThread() {
+    return RuntimeEnvironment.getActivityThread();
+  }
+
+  @Implementation
+  protected static Application currentApplication() {
+    return ((ActivityThread) currentActivityThread()).getApplication();
+  }
+
+  @Implementation
+  protected Application getApplication() {
+    // Prefer the stored application from the real Activity Thread.
+    Application currentApplication =
+        Reflector.reflector(_ActivityThread_.class, realActivityThread).getInitialApplication();
+    if (currentApplication == null) {
+      return RuntimeEnvironment.getApplication();
+    } else {
+      return currentApplication;
+    }
+  }
+
+  @Implementation(minSdk = R)
+  public static Object getPermissionManager() {
+    ClassLoader classLoader = ShadowActivityThread.class.getClassLoader();
+    Class<?> iPermissionManagerClass;
+    try {
+      iPermissionManagerClass = classLoader.loadClass("android.permission.IPermissionManager");
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+    return Proxy.newProxyInstance(
+        classLoader,
+        new Class<?>[] {iPermissionManagerClass},
+        new InvocationHandler() {
+          @Override
+          public Object invoke(Object proxy, @Nonnull Method method, Object[] args)
+              throws Exception {
+            if (method.getName().equals("getSplitPermissions")) {
+              return Collections.emptyList();
+            }
+            return method.getDefaultValue();
+          }
+        });
+  }
+
+  // Override this method as it's used directly by reflection by androidx ActivityRecreator.
+  @Implementation(minSdk = N, maxSdk = O_MR1)
+  protected void requestRelaunchActivity(
+      IBinder token,
+      List<ResultInfo> pendingResults,
+      List<ReferrerIntent> pendingNewIntents,
+      int configChanges,
+      boolean notResumed,
+      Configuration config,
+      Configuration overrideConfig,
+      boolean fromServer,
+      boolean preserveWindow) {
+    ActivityClientRecord record = activityThreadReflector.getActivities().get(token);
+    if (record != null) {
+      reflector(ActivityClientRecordReflector.class, record).getActivity().recreate();
+    }
+  }
+
+  /** Update's ActivityThread's list of active Activities */
+  void registerActivityLaunch(
+      Intent intent, ActivityInfo activityInfo, Activity activity, IBinder token) {
+    ActivityClientRecord record = new ActivityClientRecord();
+    ActivityClientRecordReflector recordReflector =
+        reflector(ActivityClientRecordReflector.class, record);
+    recordReflector.setToken(token);
+    recordReflector.setIntent(intent);
+    recordReflector.setActivityInfo(activityInfo);
+    recordReflector.setActivity(activity);
+    reflector(_ActivityThread_.class, realActivityThread).getActivities().put(token, record);
+  }
+
+  void removeActivity(IBinder token) {
+    reflector(_ActivityThread_.class, realActivityThread).getActivities().remove(token);
+  }
+
+  /**
+   * Internal use only.
+   *
+   * @deprecated do not use
+   */
+  @Deprecated
+  public static void setApplicationInfo(ApplicationInfo applicationInfo) {
+    ShadowActivityThread.applicationInfo = applicationInfo;
+  }
+
+  static ApplicationInfo getApplicationInfo() {
+    return applicationInfo;
+  }
+
+  /**
+   * internal, do not use
+   *
+   * @param androidConfiguration
+   */
+  public void setCompatConfiguration(Configuration androidConfiguration) {
+    if (RuntimeEnvironment.getApiLevel() >= S) {
+      // Setting compat configuration was refactored in android S
+      // use reflection to create package private classes
+      Class<?> activityThreadInternalClass =
+          ReflectionHelpers.loadClass(
+              getClass().getClassLoader(), "android.app.ActivityThreadInternal");
+      Class<?> configurationControllerClass =
+          ReflectionHelpers.loadClass(
+              getClass().getClassLoader(), "android.app.ConfigurationController");
+      Object configController =
+          ReflectionHelpers.callConstructor(
+              configurationControllerClass, from(activityThreadInternalClass, realActivityThread));
+      ReflectionHelpers.callInstanceMethod(
+          configController,
+          "setCompatConfiguration",
+          from(Configuration.class, androidConfiguration));
+      androidConfiguration =
+          ReflectionHelpers.callInstanceMethod(configController, "getCompatConfiguration");
+      ReflectionHelpers.setField(realActivityThread, "mConfigurationController", configController);
+    } else {
+      reflector(_ActivityThread_.class, realActivityThread)
+          .setCompatConfiguration(androidConfiguration);
+    }
+  }
+
+  /** Accessor interface for {@link ActivityThread}'s internals. */
+  @ForType(ActivityThread.class)
+  public interface _ActivityThread_ {
+
+    @Accessor("mBoundApplication")
+    void setBoundApplication(Object data);
+
+    @Accessor("mBoundApplication")
+    Object getBoundApplication();
+
+    @Accessor("mCompatConfiguration")
+    void setCompatConfiguration(Configuration configuration);
+
+    @Accessor("mInitialApplication")
+    void setInitialApplication(Application application);
+
+    /** internal use only. Tests should use {@link ActivityThread.getApplication} */
+    @Accessor("mInitialApplication")
+    Application getInitialApplication();
+
+    @Accessor("mInstrumentation")
+    void setInstrumentation(Instrumentation instrumentation);
+
+    @Accessor("mActivities")
+    Map<IBinder, ActivityClientRecord> getActivities();
+  }
+
+  /** Accessor interface for {@link ActivityThread.AppBindData}'s internals. */
+  @ForType(className = "android.app.ActivityThread$AppBindData")
+  public interface _AppBindData_ {
+
+    @Accessor("appInfo")
+    void setAppInfo(ApplicationInfo applicationInfo);
+
+    @Accessor("processName")
+    void setProcessName(String name);
+  }
+
+  @ForType(ActivityClientRecord.class)
+  private interface ActivityClientRecordReflector {
+    @Accessor("activity")
+    void setActivity(Activity activity);
+
+    @Accessor("activity")
+    Activity getActivity();
+
+    @Accessor("token")
+    void setToken(IBinder token);
+
+    @Accessor("intent")
+    void setIntent(Intent intent);
+
+    @Accessor("activityInfo")
+    void setActivityInfo(ActivityInfo activityInfo);
+  }
+
+  @Resetter
+  public static void reset() {
+    reflector(_ActivityThread_.class, RuntimeEnvironment.getActivityThread())
+        .getActivities()
+        .clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAdapterView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAdapterView.java
new file mode 100644
index 0000000..c0226c7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAdapterView.java
@@ -0,0 +1,88 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.View;
+import android.widget.Adapter;
+import android.widget.AdapterView;
+import android.widget.FrameLayout;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(AdapterView.class)
+public class ShadowAdapterView<T extends Adapter> extends ShadowViewGroup {
+  private static int ignoreRowsAtEndOfList = 0;
+
+  @RealObject private AdapterView<T> realAdapterView;
+
+  private AdapterView.OnItemSelectedListener itemSelectedListener;
+
+  @Implementation
+  protected void setOnItemSelectedListener(
+      AdapterView.OnItemSelectedListener itemSelectedListener) {
+    this.itemSelectedListener = itemSelectedListener;
+    reflector(AdapterViewReflector.class, realAdapterView)
+        .setOnItemSelectedListener(itemSelectedListener);
+  }
+
+  public AdapterView.OnItemSelectedListener getItemSelectedListener() {
+    return itemSelectedListener;
+  }
+
+  public boolean performItemClick(int position) {
+    return realAdapterView.performItemClick(
+        realAdapterView.getChildAt(position),
+        position,
+        realAdapterView.getItemIdAtPosition(position));
+  }
+
+  public int findIndexOfItemContainingText(String targetText) {
+    for (int i = 0; i < realAdapterView.getCount(); i++) {
+      View childView = realAdapterView.getAdapter().getView(i, null, new FrameLayout(realAdapterView.getContext()));
+      ShadowView shadowView = Shadow.extract(childView);
+      String innerText = shadowView.innerText();
+      if (innerText.contains(targetText)) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  public View findItemContainingText(String targetText) {
+    int itemIndex = findIndexOfItemContainingText(targetText);
+    if (itemIndex == -1) {
+      return null;
+    }
+    return realAdapterView.getChildAt(itemIndex);
+  }
+
+  public void clickFirstItemContainingText(String targetText) {
+    int itemIndex = findIndexOfItemContainingText(targetText);
+    if (itemIndex == -1) {
+      throw new IllegalArgumentException("No item found containing text \"" + targetText + "\"");
+    }
+    performItemClick(itemIndex);
+  }
+
+  public void populateItems() {
+    realView.measure(0, 0);
+    realView.layout(0, 0, 100, 10000);
+  }
+
+  public void selectItemWithText(String s) {
+    int itemIndex = findIndexOfItemContainingText(s);
+    realAdapterView.setSelection(itemIndex);
+  }
+
+  @ForType(AdapterView.class)
+  interface AdapterViewReflector {
+
+    @Direct
+    void setOnItemSelectedListener(AdapterView.OnItemSelectedListener itemSelectedListener);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlarmManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlarmManager.java
new file mode 100644
index 0000000..b43631c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlarmManager.java
@@ -0,0 +1,320 @@
+package org.robolectric.shadows;
+
+import static android.app.AlarmManager.RTC_WAKEUP;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.TargetApi;
+import android.app.AlarmManager;
+import android.app.AlarmManager.AlarmClockInfo;
+import android.app.AlarmManager.OnAlarmListener;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.os.Handler;
+import java.util.Collections;
+import java.util.List;
+import java.util.TimeZone;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(AlarmManager.class)
+public class ShadowAlarmManager {
+
+  private static final TimeZone DEFAULT_TIMEZONE = TimeZone.getDefault();
+
+  private static boolean canScheduleExactAlarms;
+  private final List<ScheduledAlarm> scheduledAlarms = new CopyOnWriteArrayList<>();
+
+  @RealObject private AlarmManager realObject;
+
+  @Resetter
+  public static void reset() {
+    TimeZone.setDefault(DEFAULT_TIMEZONE);
+    canScheduleExactAlarms = false;
+  }
+
+  @Implementation
+  protected void setTimeZone(String timeZone) {
+    // Do the real check first
+    reflector(AlarmManagerReflector.class, realObject).setTimeZone(timeZone);
+    // Then do the right side effect
+    TimeZone.setDefault(TimeZone.getTimeZone(timeZone));
+  }
+
+  @Implementation
+  protected void set(int type, long triggerAtTime, PendingIntent operation) {
+    internalSet(type, triggerAtTime, 0L, operation, null);
+  }
+
+  @Implementation(minSdk = N)
+  protected void set(
+      int type, long triggerAtTime, String tag, OnAlarmListener listener, Handler targetHandler) {
+    internalSet(type, triggerAtTime, listener, targetHandler);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setExact(int type, long triggerAtTime, PendingIntent operation) {
+    internalSet(type, triggerAtTime, 0L, operation, null);
+  }
+
+  @Implementation(minSdk = N)
+  protected void setExact(
+      int type, long triggerAtTime, String tag, OnAlarmListener listener, Handler targetHandler) {
+    internalSet(type, triggerAtTime, listener, targetHandler);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setWindow(
+      int type, long windowStartMillis, long windowLengthMillis, PendingIntent operation) {
+    internalSet(type, windowStartMillis, 0L, operation, null);
+  }
+
+  @Implementation(minSdk = N)
+  protected void setWindow(
+      int type,
+      long windowStartMillis,
+      long windowLengthMillis,
+      String tag,
+      OnAlarmListener listener,
+      Handler targetHandler) {
+    internalSet(type, windowStartMillis, listener, targetHandler);
+  }
+
+  @Implementation(minSdk = M)
+  protected void setAndAllowWhileIdle(int type, long triggerAtTime, PendingIntent operation) {
+    internalSet(type, triggerAtTime, 0L, operation, null, true);
+  }
+
+  @Implementation(minSdk = M)
+  protected void setExactAndAllowWhileIdle(int type, long triggerAtTime, PendingIntent operation) {
+    internalSet(type, triggerAtTime, 0L, operation, null, true);
+  }
+
+  @Implementation
+  protected void setRepeating(
+      int type, long triggerAtTime, long interval, PendingIntent operation) {
+    internalSet(type, triggerAtTime, interval, operation, null);
+  }
+
+  @Implementation
+  protected void setInexactRepeating(
+      int type, long triggerAtMillis, long intervalMillis, PendingIntent operation) {
+    internalSet(type, triggerAtMillis, intervalMillis, operation, null);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void setAlarmClock(AlarmClockInfo info, PendingIntent operation) {
+    internalSet(RTC_WAKEUP, info.getTriggerTime(), 0L, operation, info.getShowIntent());
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected AlarmClockInfo getNextAlarmClock() {
+    for (ScheduledAlarm scheduledAlarm : scheduledAlarms) {
+      AlarmClockInfo alarmClockInfo = scheduledAlarm.getAlarmClockInfo();
+      if (alarmClockInfo != null) {
+        return alarmClockInfo;
+      }
+    }
+    return null;
+  }
+
+  private void internalSet(
+      int type,
+      long triggerAtTime,
+      long interval,
+      PendingIntent operation,
+      PendingIntent showIntent) {
+    cancel(operation);
+    synchronized (scheduledAlarms) {
+      scheduledAlarms.add(new ScheduledAlarm(type, triggerAtTime, interval, operation, showIntent));
+      Collections.sort(scheduledAlarms);
+    }
+  }
+
+  private void internalSet(
+      int type,
+      long triggerAtTime,
+      long interval,
+      PendingIntent operation,
+      PendingIntent showIntent,
+      boolean allowWhileIdle) {
+    cancel(operation);
+    synchronized (scheduledAlarms) {
+      scheduledAlarms.add(
+          new ScheduledAlarm(type, triggerAtTime, interval, operation, showIntent, allowWhileIdle));
+      Collections.sort(scheduledAlarms);
+    }
+  }
+
+  private void internalSet(
+      int type, long triggerAtTime, OnAlarmListener listener, Handler handler) {
+    cancel(listener);
+    synchronized (scheduledAlarms) {
+      scheduledAlarms.add(new ScheduledAlarm(type, triggerAtTime, 0L, listener, handler));
+      Collections.sort(scheduledAlarms);
+    }
+  }
+
+  /** @return the next scheduled alarm after consuming it */
+  public ScheduledAlarm getNextScheduledAlarm() {
+    if (scheduledAlarms.isEmpty()) {
+      return null;
+    } else {
+      return scheduledAlarms.remove(0);
+    }
+  }
+
+  /** @return the most recently scheduled alarm without consuming it */
+  public ScheduledAlarm peekNextScheduledAlarm() {
+    if (scheduledAlarms.isEmpty()) {
+      return null;
+    } else {
+      return scheduledAlarms.get(0);
+    }
+  }
+
+  /** @return all scheduled alarms */
+  public List<ScheduledAlarm> getScheduledAlarms() {
+    return scheduledAlarms;
+  }
+
+  @Implementation
+  protected void cancel(PendingIntent operation) {
+    ShadowPendingIntent shadowPendingIntent = Shadow.extract(operation);
+    final Intent toRemove = shadowPendingIntent.getSavedIntent();
+    final int requestCode = shadowPendingIntent.getRequestCode();
+    for (ScheduledAlarm scheduledAlarm : scheduledAlarms) {
+      if (scheduledAlarm.operation != null) {
+        ShadowPendingIntent scheduledShadowPendingIntent = Shadow.extract(scheduledAlarm.operation);
+        final Intent scheduledIntent = scheduledShadowPendingIntent.getSavedIntent();
+        final int scheduledRequestCode = scheduledShadowPendingIntent.getRequestCode();
+        if (scheduledIntent.filterEquals(toRemove) && scheduledRequestCode == requestCode) {
+          scheduledAlarms.remove(scheduledAlarm);
+          break;
+        }
+      }
+    }
+  }
+
+  @Implementation(minSdk = N)
+  protected void cancel(OnAlarmListener listener) {
+    for (ScheduledAlarm scheduledAlarm : scheduledAlarms) {
+      if (scheduledAlarm.onAlarmListener != null) {
+        if (scheduledAlarm.onAlarmListener.equals(listener)) {
+          scheduledAlarms.remove(scheduledAlarm);
+          break;
+        }
+      }
+    }
+  }
+
+  /** Returns the schedule exact alarm state set by {@link #setCanScheduleExactAlarms}. */
+  @Implementation(minSdk = S)
+  protected boolean canScheduleExactAlarms() {
+    return canScheduleExactAlarms;
+  }
+
+  /**
+   * Sets the schedule exact alarm state reported by {@link AlarmManager#canScheduleExactAlarms},
+   * but has no effect otherwise.
+   */
+  public static void setCanScheduleExactAlarms(boolean scheduleExactAlarms) {
+    canScheduleExactAlarms = scheduleExactAlarms;
+  }
+
+  /** Container object to hold a PendingIntent and parameters describing when to send it. */
+  public static class ScheduledAlarm implements Comparable<ScheduledAlarm> {
+
+    public final int type;
+    public final long triggerAtTime;
+    public final long interval;
+    public final PendingIntent operation;
+    public final boolean allowWhileIdle;
+
+    // A non-null showIntent implies this alarm has a user interface. (i.e. in an alarm clock app)
+    public final PendingIntent showIntent;
+
+    public final OnAlarmListener onAlarmListener;
+    public final Handler handler;
+
+    public ScheduledAlarm(
+        int type, long triggerAtTime, PendingIntent operation, PendingIntent showIntent) {
+      this(type, triggerAtTime, 0, operation, showIntent);
+    }
+
+    public ScheduledAlarm(
+        int type,
+        long triggerAtTime,
+        long interval,
+        PendingIntent operation,
+        PendingIntent showIntent) {
+      this(type, triggerAtTime, interval, operation, showIntent, null, null, false);
+    }
+
+    public ScheduledAlarm(
+        int type,
+        long triggerAtTime,
+        long interval,
+        PendingIntent operation,
+        PendingIntent showIntent,
+        boolean allowWhileIdle) {
+      this(type, triggerAtTime, interval, operation, showIntent, null, null, allowWhileIdle);
+    }
+
+    private ScheduledAlarm(
+        int type,
+        long triggerAtTime,
+        long interval,
+        OnAlarmListener onAlarmListener,
+        Handler handler) {
+      this(type, triggerAtTime, interval, null, null, onAlarmListener, handler, false);
+    }
+
+    private ScheduledAlarm(
+        int type,
+        long triggerAtTime,
+        long interval,
+        PendingIntent operation,
+        PendingIntent showIntent,
+        OnAlarmListener onAlarmListener,
+        Handler handler,
+        boolean allowWhileIdle) {
+      this.type = type;
+      this.triggerAtTime = triggerAtTime;
+      this.operation = operation;
+      this.interval = interval;
+      this.showIntent = showIntent;
+      this.onAlarmListener = onAlarmListener;
+      this.handler = handler;
+      this.allowWhileIdle = allowWhileIdle;
+    }
+
+    @TargetApi(LOLLIPOP)
+    public AlarmClockInfo getAlarmClockInfo() {
+      return showIntent == null ? null : new AlarmClockInfo(triggerAtTime, showIntent);
+    }
+
+    @Override
+    public int compareTo(ScheduledAlarm scheduledAlarm) {
+      return Long.compare(triggerAtTime, scheduledAlarm.triggerAtTime);
+    }
+  }
+
+  @ForType(AlarmManager.class)
+  interface AlarmManagerReflector {
+
+    @Direct
+    void setTimeZone(String timeZone);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlertController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlertController.java
new file mode 100644
index 0000000..ead437d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlertController.java
@@ -0,0 +1,109 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Adapter;
+import android.widget.ListView;
+import com.android.internal.app.AlertController;
+import java.lang.reflect.InvocationTargetException;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = AlertController.class, isInAndroidSdk = false)
+public class ShadowAlertController {
+
+  @RealObject AlertController realAlertController;
+
+  private CharSequence title;
+  private CharSequence message;
+  private View view;
+  private View customTitleView;
+  private int iconId;
+
+  @Implementation
+  public void setTitle(CharSequence title) throws InvocationTargetException, IllegalAccessException {
+    this.title = title;
+    reflector(AlertControllerReflector.class, realAlertController).setTitle(title);
+  }
+
+  public CharSequence getTitle() {
+    return title == null ? "" : title;
+  }
+
+  @Implementation
+  public void setCustomTitle(View customTitleView) {
+    this.customTitleView = customTitleView;
+    reflector(AlertControllerReflector.class, realAlertController).setCustomTitle(customTitleView);
+  }
+
+  public View getCustomTitleView() {
+    return customTitleView;
+  }
+
+  @Implementation
+  public void setMessage(CharSequence message) {
+    this.message = message;
+    reflector(AlertControllerReflector.class, realAlertController).setMessage(message);
+  }
+
+  public CharSequence getMessage() {
+    return message == null ? "" : message;
+  }
+
+  @Implementation
+  public void setView(View view) {
+    this.view = view;
+    reflector(AlertControllerReflector.class, realAlertController).setView(view);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  public void setView(int resourceId) {
+    setView(LayoutInflater.from(RuntimeEnvironment.getApplication()).inflate(resourceId, null));
+  }
+
+  @Implementation
+  public void setIcon(int iconId) {
+    this.iconId = iconId;
+    reflector(AlertControllerReflector.class, realAlertController).setIcon(iconId);
+  }
+
+  public int getIconId() {
+    return iconId;
+  }
+
+  public View getView() {
+    return view;
+  }
+
+  public Adapter getAdapter() {
+    return ReflectionHelpers.<ListView>callInstanceMethod(realAlertController, "getListView")
+        .getAdapter();
+  }
+
+  @ForType(AlertController.class)
+  interface AlertControllerReflector {
+
+    @Direct
+    void setTitle(CharSequence title);
+
+    @Direct
+    void setCustomTitle(View customTitleView);
+
+    @Direct
+    void setMessage(CharSequence message);
+
+    @Direct
+    void setView(View view);
+
+    @Direct
+    void setIcon(int iconId);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlertDialog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlertDialog.java
new file mode 100644
index 0000000..43cecb2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAlertDialog.java
@@ -0,0 +1,124 @@
+package org.robolectric.shadows;
+
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.view.View;
+import android.widget.Adapter;
+import android.widget.FrameLayout;
+import com.android.internal.app.AlertController;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(AlertDialog.class)
+public class ShadowAlertDialog extends ShadowDialog {
+  @RealObject
+  private AlertDialog realAlertDialog;
+
+  private CharSequence[] items;
+  private DialogInterface.OnClickListener clickListener;
+  private boolean isMultiItem;
+  private boolean isSingleItem;
+  private DialogInterface.OnMultiChoiceClickListener multiChoiceClickListener;
+  private FrameLayout custom;
+
+  private static ShadowAlertDialog latestAlertDialog;
+
+  /**
+   * @return the most recently created {@code AlertDialog}, or null if none has been created during this test run
+   */
+  public static AlertDialog getLatestAlertDialog() {
+    return latestAlertDialog == null ? null : latestAlertDialog.realAlertDialog;
+  }
+
+  public FrameLayout getCustomView() {
+    if (custom == null) {
+      custom = new FrameLayout(context);
+    }
+    return custom;
+  }
+
+  /** Resets the tracking of the most recently created {@code AlertDialog} */
+  @Resetter
+  public static void reset() {
+    latestAlertDialog = null;
+  }
+
+  /**
+   * Simulates a click on the {@code Dialog} item indicated by {@code index}. Handles both multi- and single-choice dialogs, tracks which items are currently
+   * checked and calls listeners appropriately.
+   *
+   * @param index the index of the item to click on
+   */
+  public void clickOnItem(int index) {
+    ShadowListView shadowListView = Shadow.extract(realAlertDialog.getListView());
+    shadowListView.performItemClick(index);
+  }
+
+  @Override public CharSequence getTitle() {
+    return getShadowAlertController().getTitle();
+  }
+
+  /**
+   * @return the items that are available to be clicked on
+   */
+  public CharSequence[] getItems() {
+    Adapter adapter = getShadowAlertController().getAdapter();
+    int count = adapter.getCount();
+    CharSequence[] items = new CharSequence[count];
+    for (int i = 0; i < items.length; i++) {
+      items[i] = (CharSequence) adapter.getItem(i);
+    }
+    return items;
+  }
+
+  public Adapter getAdapter() {
+    return getShadowAlertController().getAdapter();
+  }
+
+  /**
+   * @return the message displayed in the dialog
+   */
+  public CharSequence getMessage() {
+    return getShadowAlertController().getMessage();
+  }
+
+  @Override
+  public void show() {
+    super.show();
+    latestAlertDialog = this;
+  }
+
+  /**
+   * @return the view set with {@link AlertDialog.Builder#setView(View)}
+   */
+  public View getView() {
+    return getShadowAlertController().getView();
+  }
+
+  /**
+   * @return the icon set with {@link AlertDialog.Builder#setIcon(int)}
+   */
+  public int getIconId() {
+    return getShadowAlertController().getIconId();
+  }
+
+  /**
+   * @return return the view set with {@link AlertDialog.Builder#setCustomTitle(View)}
+   */
+  public View getCustomTitleView() {
+    return getShadowAlertController().getCustomTitleView();
+  }
+
+  private ShadowAlertController getShadowAlertController() {
+    AlertController alertController = ReflectionHelpers.getField(realAlertDialog, "mAlert");
+    return (ShadowAlertController) Shadow.extract(alertController);
+  }
+
+  @Implements(AlertDialog.Builder.class)
+  public static class ShadowBuilder {
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAmbientContextManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAmbientContextManager.java
new file mode 100644
index 0000000..9e9dda5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAmbientContextManager.java
@@ -0,0 +1,122 @@
+package org.robolectric.shadows;
+
+import android.app.PendingIntent;
+import android.app.ambientcontext.AmbientContextEventRequest;
+import android.app.ambientcontext.AmbientContextManager;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.Nullable;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link AmbientContextManager} */
+@Implements(
+    value = AmbientContextManager.class,
+    minSdk = VERSION_CODES.TIRAMISU,
+    isInAndroidSdk = false)
+public class ShadowAmbientContextManager {
+
+  private final Object lock = new Object();
+
+  /**
+   * Caches the last {@link AmbientContextEventRequest} passed into {@link
+   * #registerObserver(AmbientContextEventRequest, PendingIntent, Executor, Consumer)}
+   *
+   * <p>If {@link #unregisterObserver()} was called after {@link
+   * #registerObserver(AmbientContextEventRequest, PendingIntent, Executor, Consumer)}, this is set
+   * to null.
+   */
+  @GuardedBy("lock")
+  @Nullable
+  private AmbientContextEventRequest lastRegisterObserverRequest;
+
+  /**
+   * The ambient context service status code that will be consumed by the {@code consumer} which is
+   * passed in {@link #queryAmbientContextServiceStatus(Set, Executor, Consumer)} or {@link
+   * #registerObserver(AmbientContextEventRequest, PendingIntent, Executor, Consumer)}.
+   */
+  @GuardedBy("lock")
+  private Integer ambientContextServiceStatus = AmbientContextManager.STATUS_NOT_SUPPORTED;
+
+  /** Caches the last requested event codes passed into {@link #startConsentActivity(Set)}. */
+  @GuardedBy("lock")
+  @Nullable
+  private Set<Integer> lastRequestedEventCodesForConsentActivity;
+
+  @Implementation
+  protected void registerObserver(
+      AmbientContextEventRequest request,
+      PendingIntent resultPendingIntent,
+      Executor executor,
+      Consumer<Integer> statusConsumer) {
+    synchronized (lock) {
+      lastRegisterObserverRequest = request;
+      statusConsumer.accept(ambientContextServiceStatus);
+    }
+  }
+
+  @Implementation
+  protected void unregisterObserver() {
+    synchronized (lock) {
+      lastRegisterObserverRequest = null;
+    }
+  }
+
+  /**
+   * Returns the last {@link AmbientContextEventRequest} passed into {@link
+   * AmbientContextManager#registerObserver(AmbientContextEventRequest, PendingIntent, Executor,
+   * Consumer)}.
+   *
+   * <p>Returns null if {@link AmbientContextManager#unregisterObserver()} is invoked or there is no
+   * invocation of {@link AmbientContextManager#registerObserver(AmbientContextEventRequest,
+   * PendingIntent, Executor, Consumer)}.
+   */
+  @Nullable
+  public AmbientContextEventRequest getLastRegisterObserverRequest() {
+    synchronized (lock) {
+      return lastRegisterObserverRequest;
+    }
+  }
+
+  @Implementation
+  protected void queryAmbientContextServiceStatus(
+      Set<Integer> eventTypes, Executor executor, Consumer<Integer> consumer) {
+    synchronized (lock) {
+      consumer.accept(ambientContextServiceStatus);
+    }
+  }
+
+  /**
+   * Sets a {@code status} that will be consumed by the {@code consumer} which is passed in {@link
+   * #queryAmbientContextServiceStatus(Set, Executor, Consumer)} or {@link
+   * #registerObserver(AmbientContextEventRequest, PendingIntent, Executor, Consumer)}.
+   */
+  public void setAmbientContextServiceStatus(Integer status) {
+    synchronized (lock) {
+      ambientContextServiceStatus = status;
+    }
+  }
+
+  @Implementation
+  protected void startConsentActivity(Set<Integer> eventTypes) {
+    synchronized (lock) {
+      lastRequestedEventCodesForConsentActivity = eventTypes;
+    }
+  }
+
+  /**
+   * Returns the last requested event codes that were passed into {@link
+   * #startConsentActivity(Set)}.
+   *
+   * <p>If {@link #startConsentActivity(Set)} is never invoked, returns {@code null}.
+   */
+  @Nullable
+  public Set<Integer> getLastRequestedEventCodesForConsentActivity() {
+    synchronized (lock) {
+      return lastRequestedEventCodesForConsentActivity;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAndroidBidi.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAndroidBidi.java
new file mode 100644
index 0000000..5971979
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAndroidBidi.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O_MR1;
+
+import android.text.Layout;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(className = "android.text.AndroidBidi", isInAndroidSdk = false)
+public class ShadowAndroidBidi {
+  @Implementation(maxSdk = O_MR1)
+  public static int bidi(int dir, char[] chs, byte[] chInfo, int n, boolean haveInfo) {
+    // sorry, arabic, hebrew, syriac, n'ko, imperial aramaic, and old turks!
+    return Layout.DIR_LEFT_TO_RIGHT;
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnimationBridge.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnimationBridge.java
new file mode 100644
index 0000000..7884df1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnimationBridge.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Bridge between shadows and {@link android.view.animation.Animation}.
+ */
+@DoNotInstrument
+public class ShadowAnimationBridge {
+  private Animation realAnimation;
+
+  public ShadowAnimationBridge(Animation realAnimation) {
+    this.realAnimation = realAnimation;
+  }
+
+  public void applyTransformation(float interpolatedTime, Transformation transformation) {
+    ReflectionHelpers.callInstanceMethod(realAnimation, "applyTransformation",
+        ClassParameter.from(float.class, interpolatedTime),
+        ClassParameter.from(Transformation.class, transformation));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnimationUtils.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnimationUtils.java
new file mode 100644
index 0000000..8666c6b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnimationUtils.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.view.animation.Interpolator;
+import android.view.animation.LayoutAnimationController;
+import android.view.animation.LinearInterpolator;
+import android.view.animation.TranslateAnimation;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(AnimationUtils.class)
+public class ShadowAnimationUtils {
+
+  @Implementation
+  protected static Interpolator loadInterpolator(Context context, int id) {
+    return new LinearInterpolator();
+  }
+
+  @Implementation
+  protected static LayoutAnimationController loadLayoutAnimation(Context context, int id) {
+    Animation anim = new TranslateAnimation(0, 0, 30, 0);
+    LayoutAnimationController layoutAnim = new LayoutAnimationController(anim);
+    ShadowLayoutAnimationController shadowLayoutAnimationController = Shadow.extract(layoutAnim);
+    shadowLayoutAnimationController.setLoadedFromResourceId(id);
+    return layoutAnim;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnnotationValidations.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnnotationValidations.java
new file mode 100644
index 0000000..7a21a82
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAnnotationValidations.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.ColorInt;
+import android.os.Build.VERSION_CODES;
+import com.android.internal.util.AnnotationValidations;
+import java.lang.annotation.Annotation;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(
+    value = com.android.internal.util.AnnotationValidations.class,
+    isInAndroidSdk = false,
+    minSdk = VERSION_CODES.R)
+public class ShadowAnnotationValidations {
+
+  /** Re-implement to avoid use of android-only Class.getPackageName$ */
+  @Implementation
+  protected static void validate(
+      Class<? extends Annotation> annotation, Annotation ignored, int value) {
+    if (("android.annotation".equals(annotation.getPackage().getName())
+            && annotation.getSimpleName().endsWith("Res"))
+        || ColorInt.class.equals(annotation)) {
+      if (value < 0) {
+        reflector(ReflectorAnnotationValidations.class).invalid(annotation, value);
+      }
+    }
+  }
+
+  /** Accessor interface for {@link AnnotationValidations}'s internals. */
+  @ForType(AnnotationValidations.class)
+  private interface ReflectorAnnotationValidations {
+    void invalid(Class<? extends Annotation> annotation, Object value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApkAssets.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApkAssets.java
new file mode 100644
index 0000000..c717751
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApkAssets.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/jni/android_content_res_ApkAssets.cpp
+
+abstract public class ShadowApkAssets {
+
+  public static class Picker extends ResourceModeShadowPicker<ShadowApkAssets> {
+
+    public Picker() {
+      super(ShadowLegacyApkAssets.class, null, ShadowArscApkAssets9.class);
+    }
+  }
+
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppIntegrityManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppIntegrityManager.java
new file mode 100644
index 0000000..1065554
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppIntegrityManager.java
@@ -0,0 +1,54 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.content.IntentSender;
+import android.content.integrity.AppIntegrityManager;
+import android.content.integrity.RuleSet;
+import java.util.Optional;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link AppIntegrityManager} */
+@Implements(
+    value = AppIntegrityManager.class,
+    minSdk = R,
+    looseSignatures = true,
+    isInAndroidSdk = false)
+public class ShadowAppIntegrityManager {
+
+  private Optional<RuleSet> recordedRuleSet;
+
+  /** Default shadow constructor that resets the {@code recordedRuleSet}. */
+  public ShadowAppIntegrityManager() {
+    recordedRuleSet = Optional.empty();
+  }
+
+  /**
+   * Overrides the implementation of the {@code updateRuleSet} method so that a copy of the pushed
+   * rule set is kept within the shadow class.
+   */
+  @Implementation
+  protected void updateRuleSet(RuleSet updateRequest, IntentSender statusReceiver) {
+    recordedRuleSet = Optional.of(updateRequest);
+  }
+
+  /**
+   * Overrides the implementation of the {@code getCurrentRuleSetVersion} method to return the
+   * version stored in the recorded rule set. The method returns "None" if there is no such rule set
+   * available.
+   */
+  @Implementation
+  protected String getCurrentRuleSetVersion() {
+    return recordedRuleSet.isPresent() ? recordedRuleSet.get().getVersion() : "None";
+  }
+
+  /**
+   * Overrides the implementation of the {@code getCurrentRuleSetProvider} method to return the
+   * gmscore package name for all the requests when a rule set exists.
+   */
+  @Implementation
+  protected String getCurrentRuleSetProvider() {
+    return recordedRuleSet.isPresent() ? "com.google.android.gms" : "None";
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppOpsManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppOpsManager.java
new file mode 100644
index 0000000..f420031
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppOpsManager.java
@@ -0,0 +1,636 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static java.util.stream.Collectors.toSet;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.app.AppOpsManager;
+import android.app.AppOpsManager.AttributedOpEntry;
+import android.app.AppOpsManager.NoteOpEvent;
+import android.app.AppOpsManager.OnOpChangedListener;
+import android.app.AppOpsManager.OpEntry;
+import android.app.AppOpsManager.OpEventProxyInfo;
+import android.app.AppOpsManager.PackageOps;
+import android.app.SyncNotedAppOp;
+import android.content.AttributionSource;
+import android.content.Context;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.media.AudioAttributes.AttributeUsage;
+import android.os.Binder;
+import android.os.Build;
+import android.util.ArrayMap;
+import android.util.LongSparseArray;
+import android.util.LongSparseLongArray;
+import androidx.annotation.RequiresApi;
+import com.android.internal.app.IAppOpsService;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.IntStream;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow for {@link AppOpsManager}. */
+@Implements(value = AppOpsManager.class, minSdk = KITKAT, looseSignatures = true)
+public class ShadowAppOpsManager {
+
+  // OpEntry fields that the shadow doesn't currently allow the test to configure.
+  protected static final long OP_TIME = 1400000000L;
+  protected static final long REJECT_TIME = 0L;
+  protected static final int DURATION = 10;
+  protected static final int PROXY_UID = 0;
+  protected static final String PROXY_PACKAGE = "";
+
+  @RealObject private AppOpsManager realObject;
+
+  private static boolean staticallyInitialized = false;
+
+  // Recorded operations, keyed by (uid, packageName)
+  private final Multimap<Key, Integer> storedOps = HashMultimap.create();
+  // (uid, packageName, opCode) => opMode
+  private final Map<Key, Integer> appModeMap = new HashMap<>();
+
+  // (uid, packageName, opCode)
+  private final Set<Key> longRunningOp = new HashSet<>();
+
+  private final Map<OnOpChangedListener, Set<Key>> appOpListeners = new ArrayMap<>();
+
+  // op | (usage << 8) => ModeAndExcpetion
+  private final Map<Integer, ModeAndException> audioRestrictions = new HashMap<>();
+
+  private Context context;
+
+  @Implementation
+  protected void __constructor__(Context context, IAppOpsService service) {
+    this.context = context;
+    invokeConstructor(
+        AppOpsManager.class,
+        realObject,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(IAppOpsService.class, service));
+  }
+
+  @Implementation
+  protected static void __staticInitializer__() {
+    staticallyInitialized = true;
+    Shadow.directInitialize(AppOpsManager.class);
+  }
+
+  /**
+   * Change the operating mode for the given op in the given app package. You must pass in both the
+   * uid and name of the application whose mode is being modified; if these do not match, the
+   * modification will not be applied.
+   *
+   * <p>This method is public for testing {@link #checkOpNoThrow}. If {@link #checkOpNoThrow} is
+   * called afterwards with the {@code op}, {@code ui}, and {@code packageName} provided, it will
+   * return the {@code mode} set here.
+   *
+   * @param op The operation to modify. One of the OPSTR_* constants.
+   * @param uid The user id of the application whose mode will be changed.
+   * @param packageName The name of the application package name whose mode will be changed.
+   */
+  @Implementation(minSdk = P)
+  @HiddenApi
+  @SystemApi
+  @RequiresPermission(android.Manifest.permission.MANAGE_APP_OPS_MODES)
+  public void setMode(String op, int uid, String packageName, int mode) {
+    setMode(AppOpsManager.strOpToOp(op), uid, packageName, mode);
+  }
+
+  /**
+   * Int version of {@link #setMode(String, int, String, int)}.
+   *
+   * <p>This method is public for testing {@link #checkOpNoThrow}. If {@link #checkOpNoThrow} is *
+   * called afterwards with the {@code op}, {@code ui}, and {@code packageName} provided, it will *
+   * return the {@code mode} set here.
+   */
+  @Implementation
+  @HiddenApi
+  public void setMode(int op, int uid, String packageName, int mode) {
+    Integer oldMode = appModeMap.put(Key.create(uid, packageName, op), mode);
+    if (Objects.equals(oldMode, mode)) {
+      return;
+    }
+
+    for (Map.Entry<OnOpChangedListener, Set<Key>> entry : appOpListeners.entrySet()) {
+      for (Key key : entry.getValue()) {
+        if (op == key.getOpCode()
+            && (key.getPackageName() == null || key.getPackageName().equals(packageName))) {
+          String[] sOpToString =
+              ReflectionHelpers.getStaticField(AppOpsManager.class, "sOpToString");
+          entry.getKey().onOpChanged(sOpToString[op], packageName);
+        }
+      }
+    }
+  }
+
+  /**
+   * Returns app op details for all packages for which one of {@link #setMode} methods was used to
+   * set the value of one of the given app ops (it does return those set to 'default' mode, while
+   * the true implementation usually doesn't). Also, we don't enforce any permission checks which
+   * might be needed in the true implementation.
+   *
+   * @param ops The set of operations you are interested in, or null if you want all of them.
+   * @return app ops information about each package, containing only ops that were specified as an
+   *     argument
+   */
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  @SystemApi
+  @NonNull
+  protected List<PackageOps> getPackagesForOps(@Nullable String[] ops) {
+    List<PackageOps> result = null;
+
+    if (ops == null) {
+      int[] intOps = null;
+      result = getPackagesForOps(intOps);
+    } else {
+      List<Integer> intOpsList = new ArrayList<>();
+      for (String op : ops) {
+        intOpsList.add(AppOpsManager.strOpToOp(op));
+      }
+      result = getPackagesForOps(intOpsList.stream().mapToInt(i -> i).toArray());
+    }
+
+    return result != null ? result : new ArrayList<>();
+  }
+
+  /**
+   * Returns app op details for all packages for which one of {@link #setMode} methods was used to
+   * set the value of one of the given app ops (it does return those set to 'default' mode, while
+   * the true implementation usually doesn't). Also, we don't enforce any permission checks which
+   * might be needed in the true implementation.
+   *
+   * @param ops The set of operations you are interested in, or null if you want all of them.
+   * @return app ops information about each package, containing only ops that were specified as an
+   *     argument
+   */
+  @Implementation
+  @HiddenApi
+  protected List<PackageOps> getPackagesForOps(int[] ops) {
+    Set<Integer> relevantOps;
+    if (ops != null) {
+      relevantOps = IntStream.of(ops).boxed().collect(toSet());
+    } else {
+      relevantOps = new HashSet<>();
+    }
+
+    // Aggregating op data per each package.
+    // (uid, packageName) => [(op, mode)]
+    Multimap<Key, OpEntry> perPackageMap = MultimapBuilder.hashKeys().hashSetValues().build();
+    for (Map.Entry<Key, Integer> appOpInfo : appModeMap.entrySet()) {
+      Key key = appOpInfo.getKey();
+      if (ops == null || relevantOps.contains(key.getOpCode())) {
+        Key packageKey = Key.create(key.getUid(), key.getPackageName(), null);
+        OpEntry opEntry = toOpEntry(key.getOpCode(), appOpInfo.getValue());
+        perPackageMap.put(packageKey, opEntry);
+      }
+    }
+
+    List<PackageOps> result = new ArrayList<>();
+    // Creating resulting PackageOps objects using all op info collected per package.
+    for (Map.Entry<Key, Collection<OpEntry>> packageInfo : perPackageMap.asMap().entrySet()) {
+      Key key = packageInfo.getKey();
+      result.add(
+          new PackageOps(
+              key.getPackageName(), key.getUid(), new ArrayList<>(packageInfo.getValue())));
+    }
+
+    return result.isEmpty() ? null : result;
+  }
+
+  @Implementation(minSdk = Q)
+  public int unsafeCheckOpNoThrow(String op, int uid, String packageName) {
+    return checkOpNoThrow(AppOpsManager.strOpToOp(op), uid, packageName);
+  }
+
+  @Implementation(minSdk = R)
+  protected int unsafeCheckOpRawNoThrow(int op, int uid, String packageName) {
+    Integer mode = appModeMap.get(Key.create(uid, packageName, op));
+    if (mode == null) {
+      return AppOpsManager.MODE_ALLOWED;
+    }
+    return mode;
+  }
+
+  /**
+   * Like {@link #unsafeCheckOpNoThrow(String, int, String)} but returns the <em>raw</em> mode
+   * associated with the op. Does not throw a security exception, does not translate {@link
+   * AppOpsManager#MODE_FOREGROUND}.
+   */
+  @Implementation(minSdk = Q)
+  public int unsafeCheckOpRawNoThrow(String op, int uid, String packageName) {
+    return unsafeCheckOpRawNoThrow(AppOpsManager.strOpToOp(op), uid, packageName);
+  }
+
+  /** Stores a fake long-running operation. It does not throw if a wrong uid is passed. */
+  @Implementation(minSdk = R)
+  protected int startOp(
+      String op, int uid, String packageName, String attributionTag, String message) {
+    int mode = unsafeCheckOpRawNoThrow(op, uid, packageName);
+    if (mode == AppOpsManager.MODE_ALLOWED) {
+      longRunningOp.add(Key.create(uid, packageName, AppOpsManager.strOpToOp(op)));
+    }
+    return mode;
+  }
+
+  /** Stores a fake long-running operation. It does not throw if a wrong uid is passed. */
+  @Implementation(minSdk = KITKAT, maxSdk = Q)
+  protected int startOpNoThrow(int op, int uid, String packageName) {
+    int mode = unsafeCheckOpRawNoThrow(op, uid, packageName);
+    if (mode == AppOpsManager.MODE_ALLOWED) {
+      longRunningOp.add(Key.create(uid, packageName, op));
+    }
+    return mode;
+  }
+
+  /** Stores a fake long-running operation. It does not throw if a wrong uid is passed. */
+  @Implementation(minSdk = R)
+  protected int startOpNoThrow(
+      String op, int uid, String packageName, String attributionTag, String message) {
+    int mode = unsafeCheckOpRawNoThrow(op, uid, packageName);
+    if (mode == AppOpsManager.MODE_ALLOWED) {
+      longRunningOp.add(Key.create(uid, packageName, AppOpsManager.strOpToOp(op)));
+    }
+    return mode;
+  }
+
+  /** Removes a fake long-running operation from the set. */
+  @Implementation(maxSdk = Q)
+  protected void finishOp(int op, int uid, String packageName) {
+    longRunningOp.remove(Key.create(uid, packageName, op));
+  }
+
+  /** Removes a fake long-running operation from the set. */
+  @Implementation(minSdk = R)
+  protected void finishOp(String op, int uid, String packageName, String attributionTag) {
+    longRunningOp.remove(Key.create(uid, packageName, AppOpsManager.strOpToOp(op)));
+  }
+
+  /** Checks whether op was previously set using {@link #setMode} */
+  @Implementation(minSdk = R)
+  protected int checkOp(String op, int uid, String packageName) {
+    return checkOpNoThrow(op, uid, packageName);
+  }
+
+  /**
+   * Checks whether the given op is active, i.e. did someone call {@link #startOp(String, int,
+   * String, String, String)} without {@link #finishOp(String, int, String, String)} yet.
+   */
+  @Implementation(minSdk = R)
+  public boolean isOpActive(String op, int uid, String packageName) {
+    return longRunningOp.contains(Key.create(uid, packageName, AppOpsManager.strOpToOp(op)));
+  }
+
+  @Implementation(minSdk = P)
+  @Deprecated // renamed to unsafeCheckOpNoThrow
+  protected int checkOpNoThrow(String op, int uid, String packageName) {
+    return checkOpNoThrow(AppOpsManager.strOpToOp(op), uid, packageName);
+  }
+
+  /**
+   * Like {@link AppOpsManager#checkOp} but instead of throwing a {@link SecurityException} it
+   * returns {@link AppOpsManager#MODE_ERRORED}.
+   *
+   * <p>Made public for testing {@link #setMode} as the method is {@code @hide}.
+   */
+  @Implementation
+  @HiddenApi
+  public int checkOpNoThrow(int op, int uid, String packageName) {
+    int mode = unsafeCheckOpRawNoThrow(op, uid, packageName);
+    return mode == AppOpsManager.MODE_FOREGROUND ? AppOpsManager.MODE_ALLOWED : mode;
+  }
+
+  @Implementation
+  public int noteOp(int op, int uid, String packageName) {
+    return noteOpInternal(op, uid, packageName, "", "");
+  }
+
+  private int noteOpInternal(
+      int op, int uid, String packageName, String attributionTag, String message) {
+    storedOps.put(Key.create(uid, packageName, null), op);
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      Object lock = ReflectionHelpers.getStaticField(AppOpsManager.class, "sLock");
+      synchronized (lock) {
+        AppOpsManager.OnOpNotedCallback callback =
+            ReflectionHelpers.getStaticField(AppOpsManager.class, "sOnOpNotedCallback");
+        if (callback != null) {
+          callback.onSelfNoted(new SyncNotedAppOp(op, attributionTag));
+        }
+      }
+    }
+
+    // Permission check not currently implemented in this shadow.
+    return AppOpsManager.MODE_ALLOWED;
+  }
+
+  @Implementation(minSdk = R)
+  protected int noteOp(int op, int uid, String packageName, String attributionTag, String message) {
+    return noteOpInternal(op, uid, packageName, attributionTag, message);
+  }
+
+  @Implementation
+  protected int noteOpNoThrow(int op, int uid, String packageName) {
+    storedOps.put(Key.create(uid, packageName, null), op);
+    return checkOpNoThrow(op, uid, packageName);
+  }
+
+  @Implementation(minSdk = R)
+  protected int noteOpNoThrow(
+      int op,
+      int uid,
+      @Nullable String packageName,
+      @Nullable String attributionTag,
+      @Nullable String message) {
+    return noteOpNoThrow(op, uid, packageName);
+  }
+
+  @Implementation(minSdk = M, maxSdk = Q)
+  @HiddenApi
+  protected int noteProxyOpNoThrow(int op, String proxiedPackageName) {
+    storedOps.put(Key.create(Binder.getCallingUid(), proxiedPackageName, null), op);
+    return checkOpNoThrow(op, Binder.getCallingUid(), proxiedPackageName);
+  }
+
+  @Implementation(minSdk = Q, maxSdk = Q)
+  @HiddenApi
+  protected int noteProxyOpNoThrow(int op, String proxiedPackageName, int proxiedUid) {
+    storedOps.put(Key.create(proxiedUid, proxiedPackageName, null), op);
+    return checkOpNoThrow(op, proxiedUid, proxiedPackageName);
+  }
+
+  @Implementation(minSdk = R, maxSdk = R)
+  @HiddenApi
+  protected int noteProxyOpNoThrow(
+      int op,
+      String proxiedPackageName,
+      int proxiedUid,
+      String proxiedAttributionTag,
+      String message) {
+    storedOps.put(Key.create(proxiedUid, proxiedPackageName, null), op);
+    return checkOpNoThrow(op, proxiedUid, proxiedPackageName);
+  }
+
+  @RequiresApi(api = S)
+  @Implementation(minSdk = S)
+  protected int noteProxyOpNoThrow(
+      Object op, Object attributionSource, Object message, Object ignoredSkipProxyOperation) {
+    Preconditions.checkArgument(op instanceof Integer);
+    Preconditions.checkArgument(attributionSource instanceof AttributionSource);
+    Preconditions.checkArgument(message == null || message instanceof String);
+    Preconditions.checkArgument(ignoredSkipProxyOperation instanceof Boolean);
+    AttributionSource castedAttributionSource = (AttributionSource) attributionSource;
+    return noteProxyOpNoThrow(
+        (int) op,
+        castedAttributionSource.getNextPackageName(),
+        castedAttributionSource.getNextUid(),
+        castedAttributionSource.getNextAttributionTag(),
+        (String) message);
+  }
+
+  @Implementation
+  @HiddenApi
+  public List<PackageOps> getOpsForPackage(int uid, String packageName, int[] ops) {
+    Set<Integer> opFilter = new HashSet<>();
+    if (ops != null) {
+      for (int op : ops) {
+        opFilter.add(op);
+      }
+    }
+
+    List<OpEntry> opEntries = new ArrayList<>();
+    for (Integer op : storedOps.get(Key.create(uid, packageName, null))) {
+      if (opFilter.isEmpty() || opFilter.contains(op)) {
+        opEntries.add(toOpEntry(op, AppOpsManager.MODE_ALLOWED));
+      }
+    }
+
+    return ImmutableList.of(new PackageOps(packageName, uid, opEntries));
+  }
+
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  @SystemApi
+  @RequiresPermission(android.Manifest.permission.GET_APP_OPS_STATS)
+  protected List<PackageOps> getOpsForPackage(int uid, String packageName, String[] ops) {
+    if (ops == null) {
+      int[] intOps = null;
+      return getOpsForPackage(uid, packageName, intOps);
+    }
+    Map<String, Integer> strOpToIntOp =
+        ReflectionHelpers.getStaticField(AppOpsManager.class, "sOpStrToOp");
+    List<Integer> intOpsList = new ArrayList<>();
+    for (String op : ops) {
+      Integer intOp = strOpToIntOp.get(op);
+      if (intOp != null) {
+        intOpsList.add(intOp);
+      }
+    }
+
+    return getOpsForPackage(uid, packageName, intOpsList.stream().mapToInt(i -> i).toArray());
+  }
+
+  @Implementation
+  protected void checkPackage(int uid, String packageName) {
+    try {
+      // getPackageUid was introduced in API 24, so we call it on the shadow class
+      ShadowApplicationPackageManager shadowApplicationPackageManager =
+          Shadow.extract(context.getPackageManager());
+      int packageUid = shadowApplicationPackageManager.getPackageUid(packageName, 0);
+      if (packageUid == uid) {
+        return;
+      }
+      throw new SecurityException("Package " + packageName + " belongs to " + packageUid);
+    } catch (NameNotFoundException e) {
+      throw new SecurityException("Package " + packageName + " doesn't belong to " + uid, e);
+    }
+  }
+
+  /**
+   * Sets audio restrictions.
+   *
+   * <p>This method is public for testing, as the original method is {@code @hide}.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  public void setRestriction(
+      int code, @AttributeUsage int usage, int mode, String[] exceptionPackages) {
+    audioRestrictions.put(
+        getAudioRestrictionKey(code, usage), new ModeAndException(mode, exceptionPackages));
+  }
+
+  @Nullable
+  public ModeAndException getRestriction(int code, @AttributeUsage int usage) {
+    // this gives us room for 256 op_codes. There are 78 as of P.
+    return audioRestrictions.get(getAudioRestrictionKey(code, usage));
+  }
+
+  @Implementation
+  protected void startWatchingMode(int op, String packageName, OnOpChangedListener callback) {
+    startWatchingModeImpl(op, packageName, 0, callback);
+  }
+
+  @Implementation(minSdk = Q)
+  protected void startWatchingMode(
+      int op, String packageName, int flags, OnOpChangedListener callback) {
+    startWatchingModeImpl(op, packageName, flags, callback);
+  }
+
+  private void startWatchingModeImpl(
+      int op, String packageName, int flags, OnOpChangedListener callback) {
+    Set<Key> keys = appOpListeners.get(callback);
+    if (keys == null) {
+      keys = new HashSet<>();
+      appOpListeners.put(callback, keys);
+    }
+    keys.add(Key.create(null, packageName, op));
+  }
+
+  @Implementation
+  protected void stopWatchingMode(OnOpChangedListener callback) {
+    appOpListeners.remove(callback);
+  }
+
+  protected OpEntry toOpEntry(Integer op, int mode) {
+    if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.M) {
+      return ReflectionHelpers.callConstructor(
+          OpEntry.class,
+          ClassParameter.from(int.class, op),
+          ClassParameter.from(int.class, mode),
+          ClassParameter.from(long.class, OP_TIME),
+          ClassParameter.from(long.class, REJECT_TIME),
+          ClassParameter.from(int.class, DURATION));
+    } else if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.Q) {
+      return ReflectionHelpers.callConstructor(
+          OpEntry.class,
+          ClassParameter.from(int.class, op),
+          ClassParameter.from(int.class, mode),
+          ClassParameter.from(long.class, OP_TIME),
+          ClassParameter.from(long.class, REJECT_TIME),
+          ClassParameter.from(int.class, DURATION),
+          ClassParameter.from(int.class, PROXY_UID),
+          ClassParameter.from(String.class, PROXY_PACKAGE));
+    } else if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.R) {
+      final long key =
+          AppOpsManager.makeKey(AppOpsManager.UID_STATE_TOP, AppOpsManager.OP_FLAG_SELF);
+
+      final LongSparseLongArray accessTimes = new LongSparseLongArray();
+      accessTimes.put(key, OP_TIME);
+
+      final LongSparseLongArray rejectTimes = new LongSparseLongArray();
+      rejectTimes.put(key, REJECT_TIME);
+
+      final LongSparseLongArray durations = new LongSparseLongArray();
+      durations.put(key, DURATION);
+
+      final LongSparseLongArray proxyUids = new LongSparseLongArray();
+      proxyUids.put(key, PROXY_UID);
+
+      final LongSparseArray<String> proxyPackages = new LongSparseArray<>();
+      proxyPackages.put(key, PROXY_PACKAGE);
+
+      return ReflectionHelpers.callConstructor(
+          OpEntry.class,
+          ClassParameter.from(int.class, op),
+          ClassParameter.from(boolean.class, false),
+          ClassParameter.from(int.class, mode),
+          ClassParameter.from(LongSparseLongArray.class, accessTimes),
+          ClassParameter.from(LongSparseLongArray.class, rejectTimes),
+          ClassParameter.from(LongSparseLongArray.class, durations),
+          ClassParameter.from(LongSparseLongArray.class, proxyUids),
+          ClassParameter.from(LongSparseArray.class, proxyPackages));
+    } else {
+      final long key =
+          AppOpsManager.makeKey(AppOpsManager.UID_STATE_TOP, AppOpsManager.OP_FLAG_SELF);
+
+      LongSparseArray<NoteOpEvent> accessEvents = new LongSparseArray<>();
+      LongSparseArray<NoteOpEvent> rejectEvents = new LongSparseArray<>();
+
+      accessEvents.put(
+          key,
+          new NoteOpEvent(OP_TIME, DURATION, new OpEventProxyInfo(PROXY_UID, PROXY_PACKAGE, null)));
+      rejectEvents.put(key, new NoteOpEvent(REJECT_TIME, -1, null));
+
+      return new OpEntry(
+          op,
+          mode,
+          Collections.singletonMap(
+              null, new AttributedOpEntry(op, false, accessEvents, rejectEvents)));
+    }
+  }
+
+  private static int getAudioRestrictionKey(int code, @AttributeUsage int usage) {
+    return code | (usage << 8);
+  }
+
+  @AutoValue
+  abstract static class Key {
+    @Nullable
+    abstract Integer getUid();
+
+    @Nullable
+    abstract String getPackageName();
+
+    @Nullable
+    abstract Integer getOpCode();
+
+    static Key create(Integer uid, String packageName, Integer opCode) {
+      return new AutoValue_ShadowAppOpsManager_Key(uid, packageName, opCode);
+    }
+  }
+
+  /** Class holding usage mode and excpetion packages. */
+  public static class ModeAndException {
+    public final int mode;
+    public final List<String> exceptionPackages;
+
+    public ModeAndException(int mode, String[] exceptionPackages) {
+      this.mode = mode;
+      this.exceptionPackages =
+          exceptionPackages == null
+              ? Collections.emptyList()
+              : Collections.unmodifiableList(Arrays.asList(exceptionPackages));
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    // The callback passed in AppOpsManager#setOnOpNotedCallback is stored statically.
+    // The check for staticallyInitialized is to make it so that we don't load AppOpsManager if it
+    // hadn't already been loaded (both to save time and to also avoid any errors that might
+    // happen if we tried to lazy load the class during reset)
+    if (RuntimeEnvironment.getApiLevel() >= R && staticallyInitialized) {
+      ReflectionHelpers.setStaticField(AppOpsManager.class, "sOnOpNotedCallback", null);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppTask.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppTask.java
new file mode 100644
index 0000000..505c75f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppTask.java
@@ -0,0 +1,113 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.app.ActivityManager.AppTask;
+import android.app.ActivityManager.RecentTaskInfo;
+import android.app.IAppTask;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = AppTask.class, minSdk = LOLLIPOP)
+public class ShadowAppTask {
+  private boolean isFinished;
+  private RecentTaskInfo recentTaskInfo;
+  private boolean hasMovedToFront;
+  private boolean isExcludedFromRecents;
+
+  public static AppTask newInstance() {
+    return ReflectionHelpers.callConstructor(
+        AppTask.class, ReflectionHelpers.ClassParameter.from(IAppTask.class, null));
+  }
+
+  /**
+   * For tests, marks the task as finished. Task is not finished when created initially.
+   *
+   * @see #isFinishedAndRemoved()
+   */
+  @Implementation
+  protected void finishAndRemoveTask() {
+    this.isFinished = true;
+  }
+
+  /**
+   * For tests, returns the {@link RecentTaskInfo} set using {@link #setTaskInfo(RecentTaskInfo)}.
+   * If nothing is set, it returns null.
+   *
+   * @see #setTaskInfo(RecentTaskInfo)
+   */
+  @Implementation
+  protected RecentTaskInfo getTaskInfo() {
+    return recentTaskInfo;
+  }
+
+  /**
+   * For tests, marks the task as moved to the front. Task is created and marked as not moved to the
+   * front.
+   *
+   * @see #hasMovedToFront()
+   */
+  @Implementation
+  protected void moveToFront() {
+    this.hasMovedToFront = true;
+  }
+
+  /**
+   * Starts the activity using given context. Started activity can be checked using {@link
+   * ShadowContextWrapper#getNextStartedActivity()}
+   *
+   * @param context Context with which the activity will be start.
+   * @param intent Intent of the activity to be started.
+   * @param options Extras passed to the activity.
+   */
+  @Implementation
+  protected void startActivity(Context context, Intent intent, Bundle options) {
+    context.startActivity(intent, options);
+  }
+
+  /**
+   * For tests, marks the task as excluded from recents. Current, status can be checked using {@link
+   * #isExcludedFromRecents()}.
+   *
+   * @param exclude Whether to exclude from recents.
+   */
+  @Implementation
+  protected void setExcludeFromRecents(boolean exclude) {
+    this.isExcludedFromRecents = exclude;
+  }
+
+  /** Returns true if {@link #finishAndRemoveTask()} has been called before. */
+  public boolean isFinishedAndRemoved() {
+    return isFinished;
+  }
+
+  /**
+   * Sets the recentTaskInfo for the task. {@link #getTaskInfo()} returns the task info set using
+   * this method.
+   */
+  public void setTaskInfo(RecentTaskInfo recentTaskInfo) {
+    this.recentTaskInfo = recentTaskInfo;
+  }
+
+  /**
+   * Returns true if task has been moved to the front.
+   *
+   * @see #moveToFront()
+   */
+  public boolean hasMovedToFront() {
+    return hasMovedToFront;
+  }
+
+  /**
+   * Returns true if task has been excluded from recents.
+   *
+   * @see #setExcludeFromRecents(boolean)
+   */
+  public boolean isExcludedFromRecents() {
+    return isExcludedFromRecents;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetHost.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetHost.java
new file mode 100644
index 0000000..72fa46a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetHost.java
@@ -0,0 +1,76 @@
+package org.robolectric.shadows;
+
+import android.appwidget.AppWidgetHost;
+import android.appwidget.AppWidgetHostView;
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.Context;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(AppWidgetHost.class)
+public class ShadowAppWidgetHost {
+  @RealObject private AppWidgetHost realAppWidgetHost;
+
+  private Context context;
+  private int hostId;
+  private int appWidgetIdToAllocate;
+  private boolean listening = false;
+
+  @Implementation
+  protected void __constructor__(Context context, int hostId) {
+    this.context = context;
+    this.hostId = hostId;
+  }
+
+  public Context getContext() {
+    return context;
+  }
+
+  public int getHostId() {
+    return hostId;
+  }
+
+  public void setAppWidgetIdToAllocate(int idToAllocate) {
+    appWidgetIdToAllocate = idToAllocate;
+  }
+
+  /** Returns true if this host is listening for updates. */
+  public boolean isListening() {
+    return listening;
+  }
+
+  @Implementation
+  protected int allocateAppWidgetId() {
+    return appWidgetIdToAllocate;
+  }
+
+  @Implementation
+  protected AppWidgetHostView createView(
+      Context context, int appWidgetId, AppWidgetProviderInfo appWidget) {
+    AppWidgetHostView hostView =
+        ReflectionHelpers.callInstanceMethod(
+            AppWidgetHost.class,
+            realAppWidgetHost,
+            "onCreateView",
+            ReflectionHelpers.ClassParameter.from(Context.class, context),
+            ReflectionHelpers.ClassParameter.from(int.class, appWidgetId),
+            ReflectionHelpers.ClassParameter.from(AppWidgetProviderInfo.class, appWidget));
+    hostView.setAppWidget(appWidgetId, appWidget);
+    ShadowAppWidgetHostView shadowAppWidgetHostView = Shadow.extract(hostView);
+    shadowAppWidgetHostView.setHost(realAppWidgetHost);
+    return hostView;
+  }
+
+  @Implementation
+  protected void startListening() {
+    listening = true;
+  }
+
+  @Implementation
+  protected void stopListening() {
+    listening = false;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetHostView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetHostView.java
new file mode 100644
index 0000000..1203ae6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetHostView.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import android.appwidget.AppWidgetHost;
+import android.appwidget.AppWidgetHostView;
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.RemoteViews;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@Implements(AppWidgetHostView.class)
+public class ShadowAppWidgetHostView extends ShadowViewGroup {
+
+  @RealObject private AppWidgetHostView appWidgetHostView;
+  private int appWidgetId;
+  private AppWidgetProviderInfo appWidgetInfo;
+  private AppWidgetHost host;
+  private View view;
+
+  @Implementation
+  protected void setAppWidget(int appWidgetId, AppWidgetProviderInfo info) {
+    this.appWidgetId = appWidgetId;
+    this.appWidgetInfo = info;
+  }
+
+  @Implementation
+  protected int getAppWidgetId() {
+    return appWidgetId;
+  }
+
+  @Implementation
+  protected AppWidgetProviderInfo getAppWidgetInfo() {
+    return appWidgetInfo;
+  }
+
+  @Implementation
+  protected void updateAppWidget(RemoteViews remoteViews) {
+    if (view != null) {
+      realViewGroup.removeView(view);
+    }
+    Context context = appWidgetHostView.getContext();
+    view = LayoutInflater.from(context).inflate(remoteViews.getLayoutId(), null);
+    remoteViews.reapply(context, view);
+    realViewGroup.addView(view);
+  }
+
+  public AppWidgetHost getHost() {
+    return host;
+  }
+
+  public void setHost(AppWidgetHost host) {
+    this.host = host;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetManager.java
new file mode 100644
index 0000000..cf93b9e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAppWidgetManager.java
@@ -0,0 +1,427 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.L;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.Nullable;
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.appwidget.AppWidgetHostView;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build.VERSION;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.view.View;
+import android.widget.RemoteViews;
+import com.android.internal.appwidget.IAppWidgetService;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(AppWidgetManager.class)
+public class ShadowAppWidgetManager {
+
+  @RealObject private AppWidgetManager realAppWidgetManager;
+
+  private Context context;
+  private final Map<Integer, WidgetInfo> widgetInfos = new HashMap<>();
+  private int nextWidgetId = 1;
+  private boolean alwaysRecreateViewsDuringUpdate = false;
+  private boolean allowedToBindWidgets;
+  private boolean requestPinAppWidgetSupported = false;
+  private boolean validWidgetProviderComponentName = true;
+  private final ArrayList<AppWidgetProviderInfo> installedProviders = new ArrayList<>();
+  private Multimap<UserHandle, AppWidgetProviderInfo> installedProvidersForProfile =
+      HashMultimap.create();
+
+  @Implementation(maxSdk = KITKAT)
+  protected void __constructor__(Context context) {
+    this.context = context;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void __constructor__(Context context, IAppWidgetService service) {
+    this.context = context;
+  }
+
+  @Implementation
+  protected void updateAppWidget(int[] appWidgetIds, RemoteViews views) {
+    for (int appWidgetId : appWidgetIds) {
+      updateAppWidget(appWidgetId, views);
+    }
+  }
+
+  /**
+   * Simulates updating an {@code AppWidget} with a new set of views
+   *
+   * @param appWidgetId id of widget
+   * @param views views to update
+   */
+  @Implementation
+  protected void updateAppWidget(int appWidgetId, RemoteViews views) {
+    WidgetInfo widgetInfo = widgetInfos.get(appWidgetId);
+    if (canReapplyRemoteViews(widgetInfo, views)) {
+      views.reapply(context, widgetInfo.view);
+    } else {
+      widgetInfo.view = views.apply(context, new AppWidgetHostView(context));
+      widgetInfo.layoutId = getRemoteViewsToApply(views).getLayoutId();
+    }
+    widgetInfo.lastRemoteViews = views;
+  }
+
+  private boolean canReapplyRemoteViews(WidgetInfo widgetInfo, RemoteViews views) {
+    if (alwaysRecreateViewsDuringUpdate) {
+      return false;
+    }
+    if (VERSION.SDK_INT < 25 && !hasLandscapeAndPortraitLayouts(views)) {
+      return widgetInfo.layoutId == views.getLayoutId();
+    }
+    RemoteViews remoteViewsToApply = getRemoteViewsToApply(views);
+    if (VERSION.SDK_INT >= 25) {
+      return widgetInfo.layoutId == remoteViewsToApply.getLayoutId();
+    } else {
+      return widgetInfo.view != null && widgetInfo.view.getId() == remoteViewsToApply.getLayoutId();
+    }
+  }
+
+  private RemoteViews getRemoteViewsToApply(RemoteViews views) {
+    return reflector(RemoteViewsReflector.class, views).getRemoteViewsToApply(context);
+  }
+
+  private static boolean hasLandscapeAndPortraitLayouts(RemoteViews views) {
+    return reflector(RemoteViewsReflector.class, views).hasLandscapeAndPortraitLayouts();
+  }
+
+  @Implementation
+  protected int[] getAppWidgetIds(ComponentName provider) {
+    List<Integer> idList = new ArrayList<>();
+    for (int id : widgetInfos.keySet()) {
+      WidgetInfo widgetInfo = widgetInfos.get(id);
+      if (provider.equals(widgetInfo.providerComponent)) {
+        idList.add(id);
+      }
+    }
+    int ids[] = new int[idList.size()];
+    for (int i = 0; i < idList.size(); i++) {
+      ids[i] = idList.get(i);
+    }
+    return ids;
+  }
+
+  @Implementation
+  protected List<AppWidgetProviderInfo> getInstalledProviders() {
+    return new ArrayList<>(installedProviders);
+  }
+
+  @Implementation(minSdk = L)
+  protected List<AppWidgetProviderInfo> getInstalledProvidersForProfile(UserHandle profile) {
+    return ImmutableList.copyOf(installedProvidersForProfile.get(profile));
+  }
+
+  @Implementation(minSdk = O)
+  protected List<AppWidgetProviderInfo> getInstalledProvidersForPackage(
+      String packageName, UserHandle profile) {
+    return ImmutableList.copyOf(
+        installedProvidersForProfile.get(profile).stream()
+            .filter(
+                (AppWidgetProviderInfo providerInfo) ->
+                    providerInfo.provider.getPackageName().equals(packageName))
+            .collect(Collectors.toList()));
+  }
+
+  public void addInstalledProvider(AppWidgetProviderInfo appWidgetProviderInfo) {
+    installedProviders.add(appWidgetProviderInfo);
+  }
+
+  public boolean removeInstalledProvider(AppWidgetProviderInfo appWidgetProviderInfo) {
+    return installedProviders.remove(appWidgetProviderInfo);
+  }
+
+  public void addInstalledProvidersForProfile(
+      UserHandle userHandle, AppWidgetProviderInfo appWidgetProviderInfo) {
+    installedProvidersForProfile.put(userHandle, appWidgetProviderInfo);
+  }
+
+  public void addBoundWidget(int appWidgetId, AppWidgetProviderInfo providerInfo) {
+    addInstalledProvider(providerInfo);
+    bindAppWidgetId(appWidgetId, providerInfo.provider);
+    widgetInfos.get(appWidgetId).info = providerInfo;
+  }
+
+  @Deprecated
+  public void putWidgetInfo(int appWidgetId, AppWidgetProviderInfo expectedWidgetInfo) {
+    addBoundWidget(appWidgetId, expectedWidgetInfo);
+  }
+
+  @Implementation
+  protected AppWidgetProviderInfo getAppWidgetInfo(int appWidgetId) {
+    WidgetInfo widgetInfo = widgetInfos.get(appWidgetId);
+    if (widgetInfo == null) return null;
+    return widgetInfo.info;
+  }
+
+  /** Gets the appWidgetOptions Bundle stored in a local cache. */
+  @Implementation
+  protected Bundle getAppWidgetOptions(int appWidgetId) {
+    WidgetInfo widgetInfo = widgetInfos.get(appWidgetId);
+    if (widgetInfo == null) {
+      return Bundle.EMPTY;
+    }
+    return (Bundle) widgetInfo.options.clone();
+  }
+
+  /**
+   * Update the locally cached appWidgetOptions Bundle. Instead of triggering associated
+   * AppWidgetProvider.onAppWidgetOptionsChanged through Intent, this implementation calls the
+   * method directly.
+   */
+  @Implementation
+  protected void updateAppWidgetOptions(int appWidgetId, Bundle options) {
+    WidgetInfo widgetInfo = widgetInfos.get(appWidgetId);
+    if (widgetInfo != null && options != null) {
+      widgetInfo.options.putAll(options);
+      if (widgetInfo.appWidgetProvider != null) {
+        widgetInfo.appWidgetProvider.onAppWidgetOptionsChanged(
+            context, realAppWidgetManager, appWidgetId, (Bundle) options.clone());
+      }
+    }
+  }
+
+  @HiddenApi
+  @Implementation
+  public void bindAppWidgetId(int appWidgetId, ComponentName provider) {
+    bindAppWidgetId(appWidgetId, provider, null);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void bindAppWidgetId(int appWidgetId, ComponentName provider, Bundle options) {
+    WidgetInfo widgetInfo = new WidgetInfo(provider);
+    widgetInfos.put(appWidgetId, widgetInfo);
+    if (options != null) {
+      widgetInfo.options = (Bundle) options.clone();
+    }
+    for (AppWidgetProviderInfo appWidgetProviderInfo : installedProviders) {
+      if (provider != null && provider.equals(appWidgetProviderInfo.provider)) {
+        widgetInfo.info = appWidgetProviderInfo;
+      }
+    }
+  }
+
+  /**
+   * Create an internal presentation of the widget and cache it locally. This implementation doesn't
+   * trigger {@code AppWidgetProvider.onUpdate}
+   */
+  @Implementation
+  protected boolean bindAppWidgetIdIfAllowed(int appWidgetId, ComponentName provider) {
+    return bindAppWidgetIdIfAllowed(appWidgetId, provider, null);
+  }
+
+  /**
+   * Create an internal presentation of the widget locally and store the options {@link Bundle} with
+   * it. This implementation doesn't trigger {@code AppWidgetProvider.onUpdate}
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected boolean bindAppWidgetIdIfAllowed(
+      int appWidgetId, ComponentName provider, Bundle options) {
+    if (validWidgetProviderComponentName) {
+      bindAppWidgetId(appWidgetId, provider, options);
+      return allowedToBindWidgets;
+    } else {
+      throw new IllegalArgumentException("not an appwidget provider");
+    }
+  }
+
+  /** Returns true if {@link setSupportedToRequestPinAppWidget} is called with {@code true} */
+  @Implementation(minSdk = O)
+  protected boolean isRequestPinAppWidgetSupported() {
+    return requestPinAppWidgetSupported;
+  }
+
+  /**
+   * This implementation currently uses {@code requestPinAppWidgetSupported} to determine if it
+   * should bind the app widget provided and execute the {@code successCallback}.
+   *
+   * <p>Note: This implementation doesn't trigger {@code AppWidgetProvider.onUpdate}.
+   *
+   * @param provider The provider for the app widget to bind.
+   * @param extras Returned in the callback along with the ID of the newly bound app widget, sent as
+   *     {@link AppWidgetManager#EXTRA_APPWIDGET_ID}.
+   * @param successCallback Called after binding the app widget, if possible.
+   * @return true if the widget was installed, false otherwise.
+   */
+  @Implementation(minSdk = O)
+  protected boolean requestPinAppWidget(
+      ComponentName provider, @Nullable Bundle extras, @Nullable PendingIntent successCallback) {
+    if (requestPinAppWidgetSupported) {
+      int myWidgetId = nextWidgetId++;
+      // Bind the widget.
+      bindAppWidgetId(myWidgetId, provider);
+
+      // Call the success callback if it exists.
+      if (successCallback != null) {
+        try {
+          successCallback.send(
+              context.getApplicationContext(),
+              0,
+              new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, myWidgetId));
+        } catch (CanceledException e) {
+          throw new RuntimeException(e);
+        }
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Triggers a reapplication of the most recent set of actions against the widget, which is what
+   * happens when the phone is rotated. Does not attempt to simulate a change in screen geometry.
+   *
+   * @param appWidgetId the ID of the widget to be affected
+   */
+  public void reconstructWidgetViewAsIfPhoneWasRotated(int appWidgetId) {
+    WidgetInfo widgetInfo = widgetInfos.get(appWidgetId);
+    widgetInfo.view = widgetInfo.lastRemoteViews.apply(context, new AppWidgetHostView(context));
+  }
+
+  /**
+   * Creates a widget by inflating its layout.
+   *
+   * @param appWidgetProviderClass the app widget provider class
+   * @param widgetLayoutId id of the layout to inflate
+   * @return the ID of the new widget
+   */
+  public int createWidget(
+      Class<? extends AppWidgetProvider> appWidgetProviderClass, int widgetLayoutId) {
+    return createWidgets(appWidgetProviderClass, widgetLayoutId, 1)[0];
+  }
+
+  /**
+   * Creates a bunch of widgets by inflating the same layout multiple times.
+   *
+   * @param appWidgetProviderClass the app widget provider class
+   * @param widgetLayoutId id of the layout to inflate
+   * @param howManyToCreate number of new widgets to create
+   * @return the IDs of the new widgets
+   */
+  public int[] createWidgets(
+      Class<? extends AppWidgetProvider> appWidgetProviderClass,
+      int widgetLayoutId,
+      int howManyToCreate) {
+    AppWidgetProvider appWidgetProvider = ReflectionHelpers.callConstructor(appWidgetProviderClass);
+
+    int[] newWidgetIds = new int[howManyToCreate];
+    for (int i = 0; i < howManyToCreate; i++) {
+      int myWidgetId = nextWidgetId++;
+      RemoteViews remoteViews = new RemoteViews(context.getPackageName(), widgetLayoutId);
+      View widgetView = remoteViews.apply(context, new AppWidgetHostView(context));
+      WidgetInfo widgetInfo =
+          new WidgetInfo(widgetView, widgetLayoutId, context, appWidgetProvider);
+      widgetInfo.lastRemoteViews = remoteViews;
+      widgetInfos.put(myWidgetId, widgetInfo);
+      newWidgetIds[i] = myWidgetId;
+    }
+
+    appWidgetProvider.onUpdate(context, realAppWidgetManager, newWidgetIds);
+    return newWidgetIds;
+  }
+
+  /**
+   * @param widgetId id of the desired widget
+   * @return the widget associated with {@code widgetId}
+   */
+  public View getViewFor(int widgetId) {
+    return widgetInfos.get(widgetId).view;
+  }
+
+  /**
+   * @param widgetId id of the widget whose provider is to be returned
+   * @return the {@code AppWidgetProvider} associated with {@code widgetId}
+   */
+  public AppWidgetProvider getAppWidgetProviderFor(int widgetId) {
+    return widgetInfos.get(widgetId).appWidgetProvider;
+  }
+
+  /**
+   * Enables testing of widget behavior when all of the views are recreated on every update. This is
+   * useful for ensuring that your widget will behave correctly even if it is restarted by the OS
+   * between events.
+   *
+   * @param alwaysRecreate whether or not to always recreate the views
+   */
+  public void setAlwaysRecreateViewsDuringUpdate(boolean alwaysRecreate) {
+    alwaysRecreateViewsDuringUpdate = alwaysRecreate;
+  }
+
+  /**
+   * @return the state of the{@code alwaysRecreateViewsDuringUpdate} flag
+   */
+  public boolean getAlwaysRecreateViewsDuringUpdate() {
+    return alwaysRecreateViewsDuringUpdate;
+  }
+
+  public void setAllowedToBindAppWidgets(boolean allowed) {
+    allowedToBindWidgets = allowed;
+  }
+
+  public void setRequestPinAppWidgetSupported(boolean supported) {
+    requestPinAppWidgetSupported = supported;
+  }
+
+  public void setValidWidgetProviderComponentName(boolean validWidgetProviderComponentName) {
+    this.validWidgetProviderComponentName = validWidgetProviderComponentName;
+  }
+
+  private static class WidgetInfo {
+    View view;
+    int layoutId;
+    final AppWidgetProvider appWidgetProvider;
+    RemoteViews lastRemoteViews;
+    final ComponentName providerComponent;
+    AppWidgetProviderInfo info;
+    Bundle options = new Bundle();
+
+    public WidgetInfo(
+        View view, int layoutId, Context context, AppWidgetProvider appWidgetProvider) {
+      this.view = view;
+      this.layoutId = layoutId;
+      this.appWidgetProvider = appWidgetProvider;
+      providerComponent = new ComponentName(context, appWidgetProvider.getClass());
+    }
+
+    public WidgetInfo(ComponentName providerComponent) {
+      this.providerComponent = providerComponent;
+      this.appWidgetProvider = null;
+    }
+  }
+
+  @ForType(RemoteViews.class)
+  interface RemoteViewsReflector {
+    RemoteViews getRemoteViewsToApply(Context context);
+
+    boolean hasLandscapeAndPortraitLayouts();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApplication.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApplication.java
new file mode 100644
index 0000000..dc736cc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApplication.java
@@ -0,0 +1,394 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+import static org.robolectric.shadows.ShadowLooper.assertLooperMode;
+
+import android.app.ActivityThread;
+import android.app.AlertDialog;
+import android.app.Application;
+import android.app.Dialog;
+import android.appwidget.AppWidgetManager;
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.PowerManager;
+import android.widget.ListPopupWindow;
+import android.widget.PopupWindow;
+import android.widget.Toast;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowActivityThread._ActivityThread_;
+import org.robolectric.shadows.ShadowActivityThread._AppBindData_;
+import org.robolectric.shadows.ShadowUserManager.UserManagerState;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.reflector.Reflector;
+
+@Implements(Application.class)
+public class ShadowApplication extends ShadowContextWrapper {
+  @RealObject private Application realApplication;
+
+  private List<android.widget.Toast> shownToasts = new ArrayList<>();
+  private ShadowPopupMenu latestPopupMenu;
+  private PopupWindow latestPopupWindow;
+  private ListPopupWindow latestListPopupWindow;
+  private UserManagerState userManagerState;
+
+  /**
+   * @deprecated Use {@code shadowOf({@link ApplicationProvider#getApplicationContext()})} instead.
+   */
+  @Deprecated
+  public static ShadowApplication getInstance() {
+    return Shadow.extract(RuntimeEnvironment.getApplication());
+  }
+
+  /**
+   * Runs any background tasks previously queued by {@link android.os.AsyncTask#execute(Object[])}.
+   *
+   * <p>Note: calling this method does not pause or un-pause the scheduler.
+   */
+  public static void runBackgroundTasks() {
+    getInstance().getBackgroundThreadScheduler().advanceBy(0);
+  }
+
+  /** Configures the value to be returned by {@link Application#getProcessName()}. */
+  public static void setProcessName(String processName) {
+    // No need for a @Resetter because the whole ActivityThread is reset before each test.
+    _ActivityThread_ activityThread =
+        Reflector.reflector(_ActivityThread_.class, ShadowActivityThread.currentActivityThread());
+    Reflector.reflector(_AppBindData_.class, activityThread.getBoundApplication())
+        .setProcessName(processName);
+  }
+
+  /**
+   * Attaches an application to a base context.
+   *
+   * @param context The context with which to initialize the application, whose base context will be
+   *     attached to the application
+   */
+  public void callAttach(Context context) {
+    ReflectionHelpers.callInstanceMethod(
+        Application.class,
+        realApplication,
+        "attach",
+        ReflectionHelpers.ClassParameter.from(Context.class, context));
+  }
+
+  public List<Toast> getShownToasts() {
+    return shownToasts;
+  }
+
+  /**
+   * Return the foreground scheduler.
+   *
+   * @return Foreground scheduler.
+   * @deprecated use {@link org.robolectric.Robolectric#getForegroundThreadScheduler()}
+   */
+  @Deprecated
+  public Scheduler getForegroundThreadScheduler() {
+    return RuntimeEnvironment.getMasterScheduler();
+  }
+
+  /**
+   * Return the background scheduler.
+   *
+   * @return Background scheduler.
+   * @deprecated use {@link org.robolectric.Robolectric#getBackgroundThreadScheduler()}
+   */
+  @Deprecated
+  public Scheduler getBackgroundThreadScheduler() {
+    assertLooperMode(LEGACY);
+    return ShadowLegacyLooper.getBackgroundThreadScheduler();
+  }
+
+  /**
+   * Sets whether or not calls to unbindService should call onServiceDisconnected().
+   *
+   * <p>The default for this is currently {@code true} because that is the historical behavior.
+   * However, this does not correctly mirror Android's actual behavior. This value will eventually
+   * default to {@code false} once users have had a chance to migrate, and eventually the option
+   * will be removed altogether.
+   */
+  public void setUnbindServiceCallsOnServiceDisconnected(boolean flag) {
+    getShadowInstrumentation().setUnbindServiceCallsOnServiceDisconnected(flag);
+  }
+
+  public void setComponentNameAndServiceForBindService(ComponentName name, IBinder service) {
+    getShadowInstrumentation().setComponentNameAndServiceForBindService(name, service);
+  }
+
+  public void setComponentNameAndServiceForBindServiceForIntent(
+      Intent intent, ComponentName name, IBinder service) {
+    getShadowInstrumentation()
+        .setComponentNameAndServiceForBindServiceForIntent(intent, name, service);
+  }
+
+  public void assertNoBroadcastListenersOfActionRegistered(ContextWrapper context, String action) {
+    getShadowInstrumentation().assertNoBroadcastListenersOfActionRegistered(context, action);
+  }
+
+  public List<ServiceConnection> getBoundServiceConnections() {
+    return getShadowInstrumentation().getBoundServiceConnections();
+  }
+
+  public void setUnbindServiceShouldThrowIllegalArgument(boolean flag) {
+    getShadowInstrumentation().setUnbindServiceShouldThrowIllegalArgument(flag);
+  }
+
+  /**
+   * Configures the ShadowApplication so that calls to bindService will throw the given
+   * SecurityException.
+   */
+  public void setThrowInBindService(SecurityException e) {
+    getShadowInstrumentation().setThrowInBindService(e);
+  }
+
+  /**
+   * Configures the ShadowApplication so that calls to bindService will call
+   * ServiceConnection#onServiceConnected before returning.
+   */
+  public void setBindServiceCallsOnServiceConnectedDirectly(boolean callDirectly) {
+    getShadowInstrumentation().setBindServiceCallsOnServiceConnectedDirectly(callDirectly);
+  }
+
+  public List<ServiceConnection> getUnboundServiceConnections() {
+    return getShadowInstrumentation().getUnboundServiceConnections();
+  }
+
+  /**
+   * @deprecated use PackageManager.queryBroadcastReceivers instead
+   */
+  @Deprecated
+  public boolean hasReceiverForIntent(Intent intent) {
+    return getShadowInstrumentation().hasReceiverForIntent(intent);
+  }
+
+  /**
+   * @deprecated use PackageManager.queryBroadcastReceivers instead
+   */
+  @Deprecated
+  public List<BroadcastReceiver> getReceiversForIntent(Intent intent) {
+    return getShadowInstrumentation().getReceiversForIntent(intent);
+  }
+
+  /**
+   * @return list of {@link Wrapper}s for registered receivers
+   */
+  public ImmutableList<Wrapper> getRegisteredReceivers() {
+    return getShadowInstrumentation().getRegisteredReceivers();
+  }
+
+  /** Clears the list of {@link Wrapper}s for registered receivers */
+  public void clearRegisteredReceivers() {
+    getShadowInstrumentation().clearRegisteredReceivers();
+  }
+
+  /**
+   * @deprecated Please use {@link Context#getSystemService(Context.APPWIDGET_SERVICE)} intstead.
+   */
+  @Deprecated
+  public AppWidgetManager getAppWidgetManager() {
+    return (AppWidgetManager) realApplication.getSystemService(Context.APPWIDGET_SERVICE);
+  }
+
+  /**
+   * @deprecated Use {@link ShadowAlertDialog#getLatestAlertDialog()} instead.
+   */
+  @Deprecated
+  public ShadowAlertDialog getLatestAlertDialog() {
+    AlertDialog dialog = ShadowAlertDialog.getLatestAlertDialog();
+    return dialog == null ? null : Shadow.extract(dialog);
+  }
+
+  /**
+   * @deprecated Use {@link ShadowDialog#getLatestDialog()} instead.
+   */
+  @Deprecated
+  public ShadowDialog getLatestDialog() {
+    Dialog dialog = ShadowDialog.getLatestDialog();
+    return dialog == null ? null : Shadow.extract(dialog);
+  }
+
+  /**
+   * @deprecated Use {@link BluetoothAdapter#getDefaultAdapter()} ()} instead.
+   */
+  @Deprecated
+  public final BluetoothAdapter getBluetoothAdapter() {
+    return BluetoothAdapter.getDefaultAdapter();
+  }
+
+  public void declareActionUnbindable(String action) {
+    getShadowInstrumentation().declareActionUnbindable(action);
+  }
+
+  /**
+   * Configures the ShadowApplication so that bindService calls for the given ComponentName return
+   * false and do not call onServiceConnected.
+   */
+  public void declareComponentUnbindable(ComponentName component) {
+    getShadowInstrumentation().declareComponentUnbindable(component);
+  }
+
+  /**
+   * @deprecated use ShadowPowerManager.getLatestWakeLock
+   */
+  @Deprecated
+  public PowerManager.WakeLock getLatestWakeLock() {
+    return ShadowPowerManager.getLatestWakeLock();
+  }
+
+  /**
+   * @deprecated use PowerManager APIs instead
+   */
+  @Deprecated
+  public void addWakeLock(PowerManager.WakeLock wl) {
+    ShadowPowerManager.addWakeLock(wl);
+  }
+
+  /**
+   * @deprecated use ShadowPowerManager.clearWakeLocks
+   */
+  @Deprecated
+  public void clearWakeLocks() {
+    ShadowPowerManager.clearWakeLocks();
+  }
+
+  private final Map<String, Object> singletons = new HashMap<>();
+
+  public <T> T getSingleton(Class<T> clazz, Provider<T> provider) {
+    synchronized (singletons) {
+      //noinspection unchecked
+      T item = (T) singletons.get(clazz.getName());
+      if (item == null) {
+        singletons.put(clazz.getName(), item = provider.get());
+      }
+      return item;
+    }
+  }
+
+  /**
+   * Set to true if you'd like Robolectric to strictly simulate the real Android behavior when
+   * calling {@link Context#startActivity(android.content.Intent)}. Real Android throws a {@link
+   * android.content.ActivityNotFoundException} if given an {@link Intent} that is not known to the
+   * {@link android.content.pm.PackageManager}
+   *
+   * <p>By default, this behavior is off (false).
+   *
+   * @param checkActivities True to validate activities.
+   */
+  public void checkActivities(boolean checkActivities) {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    ShadowInstrumentation shadowInstrumentation =
+        Shadow.extract(activityThread.getInstrumentation());
+    shadowInstrumentation.checkActivities(checkActivities);
+  }
+
+  /**
+   * @deprecated Use {@link ShadowPopupMenu#getLatestPopupMenu()} instead.
+   */
+  @Deprecated
+  public ShadowPopupMenu getLatestPopupMenu() {
+    return latestPopupMenu;
+  }
+
+  protected void setLatestPopupMenu(ShadowPopupMenu latestPopupMenu) {
+    this.latestPopupMenu = latestPopupMenu;
+  }
+
+  public PopupWindow getLatestPopupWindow() {
+    return latestPopupWindow;
+  }
+
+  protected void setLatestPopupWindow(PopupWindow latestPopupWindow) {
+    this.latestPopupWindow = latestPopupWindow;
+  }
+
+  public ListPopupWindow getLatestListPopupWindow() {
+    return latestListPopupWindow;
+  }
+
+  protected void setLatestListPopupWindow(ListPopupWindow latestListPopupWindow) {
+    this.latestListPopupWindow = latestListPopupWindow;
+  }
+
+  UserManagerState getUserManagerState() {
+    if (userManagerState == null) {
+      userManagerState = new UserManagerState();
+    }
+
+    return userManagerState;
+  }
+
+  public static final class Wrapper {
+    public BroadcastReceiver broadcastReceiver;
+    public IntentFilter intentFilter;
+    public Context context;
+    public Throwable exception;
+    public String broadcastPermission;
+    public Handler scheduler;
+    public int flags;
+
+    public Wrapper(
+        BroadcastReceiver broadcastReceiver,
+        IntentFilter intentFilter,
+        Context context,
+        String broadcastPermission,
+        Handler scheduler,
+        int flags) {
+      this.broadcastReceiver = broadcastReceiver;
+      this.intentFilter = intentFilter;
+      this.context = context;
+      this.broadcastPermission = broadcastPermission;
+      this.scheduler = scheduler;
+      this.flags = flags;
+      exception = new Throwable();
+    }
+
+    public BroadcastReceiver getBroadcastReceiver() {
+      return broadcastReceiver;
+    }
+
+    public IntentFilter getIntentFilter() {
+      return intentFilter;
+    }
+
+    public Context getContext() {
+      return context;
+    }
+
+    @Override
+    public String toString() {
+      return "Wrapper[receiver=["
+          + broadcastReceiver
+          + "], context=["
+          + context
+          + "], intentFilter=["
+          + intentFilter
+          + "]]";
+    }
+  }
+
+  /**
+   * @deprecated Do not depend on this method to override services as it will be removed in a future
+   *     update. The preferered method is use the shadow of the corresponding service.
+   */
+  @Deprecated
+  public void setSystemService(String key, Object service) {
+    ShadowContextImpl shadowContext = Shadow.extract(realApplication.getBaseContext());
+    shadowContext.setSystemService(key, service);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApplicationPackageManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApplicationPackageManager.java
new file mode 100644
index 0000000..544d199
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowApplicationPackageManager.java
@@ -0,0 +1,2231 @@
+package org.robolectric.shadows;
+
+import static android.content.IntentFilter.MATCH_CATEGORY_MASK;
+import static android.content.pm.ApplicationInfo.FLAG_INSTALLED;
+import static android.content.pm.ApplicationInfo.FLAG_SYSTEM;
+import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED;
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT;
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
+import static android.content.pm.PackageManager.GET_ACTIVITIES;
+import static android.content.pm.PackageManager.GET_META_DATA;
+import static android.content.pm.PackageManager.GET_PROVIDERS;
+import static android.content.pm.PackageManager.GET_RECEIVERS;
+import static android.content.pm.PackageManager.GET_RESOLVED_FILTER;
+import static android.content.pm.PackageManager.GET_SERVICES;
+import static android.content.pm.PackageManager.GET_SIGNATURES;
+import static android.content.pm.PackageManager.MATCH_ALL;
+import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY;
+import static android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS;
+import static android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES;
+import static android.content.pm.PackageManager.SIGNATURE_UNKNOWN_PACKAGE;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.robolectric.annotation.GetInstallerPackageNameMode.Mode.REALISTIC;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.Manifest.permission;
+import android.annotation.DrawableRes;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.StringRes;
+import android.annotation.UserIdInt;
+import android.app.ApplicationPackageManager;
+import android.app.admin.DevicePolicyManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.IntentSender;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.ChangedPackages;
+import android.content.pm.ComponentInfo;
+import android.content.pm.FeatureInfo;
+import android.content.pm.IPackageDataObserver;
+import android.content.pm.IPackageDeleteObserver;
+import android.content.pm.IPackageStatsObserver;
+import android.content.pm.InstallSourceInfo;
+import android.content.pm.InstrumentationInfo;
+import android.content.pm.IntentFilterVerificationInfo;
+import android.content.pm.ModuleInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageItemInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.PackageManager.OnPermissionsChangedListener;
+import android.content.pm.PackageManager.PackageInfoFlags;
+import android.content.pm.PackageManager.ResolveInfoFlags;
+import android.content.pm.PackageStats;
+import android.content.pm.PermissionGroupInfo;
+import android.content.pm.PermissionInfo;
+import android.content.pm.ProviderInfo;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.content.pm.VerifierDeviceIdentity;
+import android.content.res.AssetManager;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcel;
+import android.os.PersistableBundle;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.os.storage.VolumeInfo;
+import android.telecom.TelecomManager;
+import android.util.Log;
+import android.util.Pair;
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Sets;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.concurrent.Executor;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.GetInstallerPackageNameMode;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = ApplicationPackageManager.class, isInAndroidSdk = false, looseSignatures = true)
+public class ShadowApplicationPackageManager extends ShadowPackageManager {
+  /** Package name of the Android platform. */
+  private static final String PLATFORM_PACKAGE_NAME = "android";
+
+  /** MIME type of Android Packages (APKs). */
+  private static final String PACKAGE_MIME_TYPE = "application/vnd.android.package-archive";
+
+  /** {@link Uri} scheme of installed apps. */
+  private static final String PACKAGE_SCHEME = "package";
+
+  @RealObject private ApplicationPackageManager realObject;
+  private final List<String> clearedApplicationUserDataPackages = new ArrayList<>();
+
+  @Implementation
+  public List<PackageInfo> getInstalledPackages(int flags) {
+    List<PackageInfo> result = new ArrayList<>();
+    synchronized (lock) {
+      Set<String> packageNames = null;
+      if ((flags & MATCH_UNINSTALLED_PACKAGES) == 0) {
+        packageNames = packageInfos.keySet();
+      } else {
+        packageNames = Sets.union(packageInfos.keySet(), deletedPackages);
+      }
+      for (String packageName : packageNames) {
+        try {
+          PackageInfo packageInfo = getPackageInfo(packageName, flags);
+          result.add(packageInfo);
+        } catch (NameNotFoundException e) {
+          Log.i(TAG, String.format("Package %s filtered out: %s", packageName, e.getMessage()));
+        }
+      }
+    }
+    return result;
+  }
+
+  @Implementation(minSdk = Q)
+  protected List<ModuleInfo> getInstalledModules(int flags) {
+    synchronized (lock) {
+      List<ModuleInfo> result = new ArrayList<>();
+      for (String moduleName : moduleInfos.keySet()) {
+        try {
+          ModuleInfo moduleInfo = (ModuleInfo) getModuleInfo(moduleName, flags);
+          result.add(moduleInfo);
+        } catch (NameNotFoundException e) {
+          Log.i(TAG, String.format("Module %s filtered out: %s", moduleName, e.getMessage()));
+        }
+      }
+      return result;
+    }
+  }
+
+  @Implementation(minSdk = Q)
+  protected Object getModuleInfo(String packageName, int flags) throws NameNotFoundException {
+    synchronized (lock) {
+      // Double checks that the respective package matches and is not disabled
+      getPackageInfo(packageName, flags);
+      Object info = moduleInfos.get(packageName);
+      if (info == null) {
+        throw new NameNotFoundException("Module: " + packageName + " is not installed.");
+      }
+
+      return info;
+    }
+  }
+
+  @Implementation
+  protected ActivityInfo getActivityInfo(ComponentName component, int flags)
+      throws NameNotFoundException {
+    return getComponentInfo(
+        component,
+        flags,
+        packageInfo -> packageInfo.activities,
+        resolveInfo -> resolveInfo.activityInfo,
+        ActivityInfo::new);
+  }
+
+  private <T extends ComponentInfo> T getComponentInfo(
+      ComponentName component,
+      int flags,
+      Function<PackageInfo, T[]> componentsInPackage,
+      Function<ResolveInfo, T> componentInResolveInfo,
+      Function<T, T> copyConstructor)
+      throws NameNotFoundException {
+    String activityName = component.getClassName();
+    String packageName = component.getPackageName();
+    PackageInfo packageInfo = getInternalMutablePackageInfo(packageName);
+    T result = null;
+    ApplicationInfo appInfo = null;
+    // search in the manifest
+    if (packageInfo != null) {
+      if (packageInfo.applicationInfo != null) {
+        appInfo = packageInfo.applicationInfo;
+      }
+      T[] components = componentsInPackage.apply(packageInfo);
+      if (components != null) {
+        for (T activity : components) {
+          if (activityName.equals(activity.name)) {
+            result = copyConstructor.apply(activity);
+            break;
+          }
+        }
+      }
+    }
+    if (result == null) {
+      // look in the registered intents
+      outer:
+      for (List<ResolveInfo> listOfResolveInfo : resolveInfoForIntent.values()) {
+        for (ResolveInfo resolveInfo : listOfResolveInfo) {
+          T info = componentInResolveInfo.apply(resolveInfo);
+          if (isValidComponentInfo(info)
+              && component.equals(new ComponentName(info.applicationInfo.packageName, info.name))) {
+            result = copyConstructor.apply(info);
+            if (appInfo == null) {
+              // we found valid app info in the resolve info. Use it.
+              appInfo = result.applicationInfo;
+            }
+            break outer;
+          }
+        }
+      }
+    }
+    if (result == null) {
+      throw new NameNotFoundException("Component not found: " + component);
+    }
+    if (appInfo == null) {
+      appInfo = new ApplicationInfo();
+      appInfo.packageName = packageName;
+      appInfo.flags = ApplicationInfo.FLAG_INSTALLED;
+    } else {
+      appInfo = new ApplicationInfo(appInfo);
+    }
+    result.applicationInfo = appInfo;
+    applyFlagsToComponentInfo(result, flags);
+    return result;
+  }
+
+  @Implementation
+  protected boolean hasSystemFeature(String name) {
+    return systemFeatureList.containsKey(name) ? systemFeatureList.get(name) : false;
+  }
+
+  @Implementation
+  protected int getComponentEnabledSetting(ComponentName componentName) {
+    ComponentState state = componentList.get(componentName);
+    return state != null ? state.newState : PackageManager.COMPONENT_ENABLED_STATE_DEFAULT;
+  }
+
+  @Implementation
+  protected @Nullable String getNameForUid(int uid) {
+    return namesForUid.get(uid);
+  }
+
+  @Implementation
+  @Override
+  protected @Nullable String[] getPackagesForUid(int uid) {
+    String[] packageNames = packagesForUid.get(uid);
+    if (packageNames != null) {
+      return packageNames;
+    }
+
+    Set<String> results = new HashSet<>();
+    synchronized (lock) {
+      for (PackageInfo packageInfo : packageInfos.values()) {
+        if (packageInfo.applicationInfo != null && packageInfo.applicationInfo.uid == uid) {
+          results.add(packageInfo.packageName);
+        }
+      }
+    }
+
+    return results.isEmpty() ? null : results.toArray(new String[results.size()]);
+  }
+
+  @Implementation
+  protected int getApplicationEnabledSetting(String packageName) {
+    synchronized (lock) {
+      if (!packageInfos.containsKey(packageName)) {
+        throw new IllegalArgumentException("Package doesn't exist: " + packageName);
+      }
+    }
+
+    return applicationEnabledSettingMap.get(packageName);
+  }
+
+  @Implementation
+  protected ProviderInfo getProviderInfo(ComponentName component, int flags)
+      throws NameNotFoundException {
+    return getComponentInfo(
+        component,
+        flags,
+        packageInfo -> packageInfo.providers,
+        resolveInfo -> resolveInfo.providerInfo,
+        ProviderInfo::new);
+  }
+
+  @Implementation
+  protected void setComponentEnabledSetting(ComponentName componentName, int newState, int flags) {
+    componentList.put(componentName, new ComponentState(newState, flags));
+  }
+
+  @Implementation
+  protected void setApplicationEnabledSetting(String packageName, int newState, int flags) {
+    applicationEnabledSettingMap.put(packageName, newState);
+  }
+
+  @Implementation
+  protected ResolveInfo resolveActivity(Intent intent, int flags) {
+    List<ResolveInfo> candidates = queryIntentActivities(intent, flags);
+    if (candidates.isEmpty()) {
+      return null;
+    }
+    if (candidates.size() == 1) {
+      return candidates.get(0);
+    }
+    ResolveInfo persistentPreferredResolveInfo =
+        resolvePreferredActivity(intent, candidates, persistentPreferredActivities);
+    if (persistentPreferredResolveInfo != null) {
+      return persistentPreferredResolveInfo;
+    }
+    ResolveInfo preferredResolveInfo =
+        resolvePreferredActivity(intent, candidates, preferredActivities);
+    if (preferredResolveInfo != null) {
+      return preferredResolveInfo;
+    }
+    if (!shouldShowActivityChooser) {
+      return candidates.get(0);
+    }
+    ResolveInfo c1 = candidates.get(0);
+    ResolveInfo c2 = candidates.get(1);
+    if (c1.preferredOrder == c2.preferredOrder
+        && isValidComponentInfo(c1.activityInfo)
+        && isValidComponentInfo(c2.activityInfo)) {
+      // When the top pick is as good as the second and is not preferred explicitly show the
+      // chooser
+      ResolveInfo result = new ResolveInfo();
+      result.activityInfo = new ActivityInfo();
+      result.activityInfo.name = "ActivityResolver";
+      result.activityInfo.packageName = "android";
+      result.activityInfo.applicationInfo = new ApplicationInfo();
+      result.activityInfo.applicationInfo.flags = FLAG_INSTALLED | FLAG_SYSTEM;
+      result.activityInfo.applicationInfo.packageName = "android";
+      return result;
+    } else {
+      return c1;
+    }
+  }
+
+  private ResolveInfo resolvePreferredActivity(
+      Intent intent,
+      List<ResolveInfo> candidates,
+      SortedMap<ComponentName, List<IntentFilter>> preferredActivities) {
+    preferredActivities = mapForPackage(preferredActivities, intent.getPackage());
+    for (ResolveInfo candidate : candidates) {
+      ActivityInfo activityInfo = candidate.activityInfo;
+      if (!isValidComponentInfo(activityInfo)) {
+        continue;
+      }
+      ComponentName candidateName =
+          new ComponentName(activityInfo.applicationInfo.packageName, activityInfo.name);
+      List<IntentFilter> intentFilters = preferredActivities.get(candidateName);
+      if (intentFilters == null) {
+        continue;
+      }
+      for (IntentFilter filter : intentFilters) {
+        if ((filter.match(getContext().getContentResolver(), intent, false, "robo")
+                & MATCH_CATEGORY_MASK)
+            != 0) {
+          return candidate;
+        }
+      }
+    }
+    return null;
+  }
+
+  @Implementation
+  protected ProviderInfo resolveContentProvider(String name, int flags) {
+    if (name == null) {
+      return null;
+    }
+    synchronized (lock) {
+      for (PackageInfo packageInfo : packageInfos.values()) {
+        if (packageInfo.providers == null) {
+          continue;
+        }
+
+        for (ProviderInfo providerInfo : packageInfo.providers) {
+          for (String authority : Splitter.on(';').split(providerInfo.authority)) {
+            if (name.equals(authority)) {
+              return new ProviderInfo(providerInfo);
+            }
+          }
+        }
+      }
+    }
+    return null;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected ProviderInfo resolveContentProviderAsUser(
+      String name, int flags, @UserIdInt int userId) {
+    return null;
+  }
+
+  @Implementation
+  protected PackageInfo getPackageInfo(String packageName, int flags) throws NameNotFoundException {
+    synchronized (lock) {
+      PackageInfo info = packageInfos.get(packageName);
+      if (info == null
+          && (flags & MATCH_UNINSTALLED_PACKAGES) != 0
+          && deletedPackages.contains(packageName)) {
+        info = new PackageInfo();
+        info.packageName = packageName;
+        info.applicationInfo = new ApplicationInfo();
+        info.applicationInfo.packageName = packageName;
+      }
+      if (info == null) {
+        throw new NameNotFoundException(packageName);
+      }
+      info = newPackageInfo(info);
+      if (info.applicationInfo == null) {
+        return info;
+      }
+      if (hiddenPackages.contains(packageName) && !isFlagSet(flags, MATCH_UNINSTALLED_PACKAGES)) {
+        throw new NameNotFoundException("Package is hidden, can't find");
+      }
+      applyFlagsToApplicationInfo(info.applicationInfo, flags);
+      info.activities =
+          applyFlagsToComponentInfoList(info.activities, flags, GET_ACTIVITIES, ActivityInfo::new);
+      info.services =
+          applyFlagsToComponentInfoList(info.services, flags, GET_SERVICES, ServiceInfo::new);
+      info.receivers =
+          applyFlagsToComponentInfoList(info.receivers, flags, GET_RECEIVERS, ActivityInfo::new);
+      info.providers =
+          applyFlagsToComponentInfoList(info.providers, flags, GET_PROVIDERS, ProviderInfo::new);
+      return info;
+    }
+  }
+
+  /**
+   * Starting in Android S, this method was moved from {@link android.content.pm.PackageManager} to
+   * {@link ApplicationPackageManager}. However, it was moved back to {@link
+   * android.content.pm.PackageManager} in T.
+   */
+  @Override
+  @Implementation(minSdk = S, maxSdk = S_V2)
+  protected PackageInfo getPackageArchiveInfo(String archiveFilePath, int flags) {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel == S || apiLevel == S_V2) {
+
+      PackageInfo shadowPackageInfo = getShadowPackageArchiveInfo(archiveFilePath, flags);
+      if (shadowPackageInfo != null) {
+        return shadowPackageInfo;
+      } else {
+        return reflector(ReflectorApplicationPackageManager.class, realObject)
+            .getPackageArchiveInfo(archiveFilePath, flags);
+      }
+    } else {
+      return super.getPackageArchiveInfo(archiveFilePath, flags);
+    }
+  }
+
+  // There is no copy constructor for PackageInfo
+  private static PackageInfo newPackageInfo(PackageInfo orig) {
+    Parcel parcel = Parcel.obtain();
+    orig.writeToParcel(parcel, 0);
+    parcel.setDataPosition(0);
+    return PackageInfo.CREATOR.createFromParcel(parcel);
+  }
+
+  private <T extends ComponentInfo> T[] applyFlagsToComponentInfoList(
+      T[] components, int flags, int activationFlag, Function<T, T> copyConstructor) {
+    if (components == null || (flags & activationFlag) == 0) {
+      return null;
+    }
+    List<T> returned = new ArrayList<>(components.length);
+    for (T component : components) {
+      component = copyConstructor.apply(component);
+      try {
+        applyFlagsToComponentInfo(component, flags);
+        returned.add(component);
+      } catch (NameNotFoundException e) {
+        // skip this component
+      }
+    }
+    if (returned.isEmpty()) {
+      return null;
+    }
+    @SuppressWarnings("unchecked") // component arrays are of their respective types.
+    Class<T[]> componentArrayType = (Class<T[]>) components.getClass();
+    T[] result = Arrays.copyOf(components, returned.size(), componentArrayType);
+    return returned.toArray(result);
+  }
+
+  @Implementation
+  protected List<ResolveInfo> queryIntentServices(Intent intent, int flags) {
+    return queryIntentComponents(
+        intent,
+        flags,
+        (pkg) -> pkg.services,
+        serviceFilters,
+        (resolveInfo, serviceInfo) -> resolveInfo.serviceInfo = serviceInfo,
+        (resolveInfo) -> resolveInfo.serviceInfo,
+        ServiceInfo::new);
+  }
+
+  private boolean hasSomeComponentInfo(ResolveInfo resolveInfo) {
+
+    return resolveInfo.activityInfo != null
+        || resolveInfo.serviceInfo != null
+        || (VERSION.SDK_INT >= VERSION_CODES.KITKAT && resolveInfo.providerInfo != null);
+  }
+
+  private static boolean isFlagSet(int flags, int matchFlag) {
+    return (flags & matchFlag) == matchFlag;
+  }
+
+  private static boolean isValidComponentInfo(ComponentInfo componentInfo) {
+    return componentInfo != null
+        && componentInfo.applicationInfo != null
+        && componentInfo.applicationInfo.packageName != null
+        && componentInfo.name != null;
+  }
+
+  /** Behaves as {@link #queryIntentServices(Intent, int)} and currently ignores userId. */
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected List<ResolveInfo> queryIntentServicesAsUser(Intent intent, int flags, int userId) {
+    return queryIntentServices(intent, flags);
+  }
+
+  @Implementation
+  protected List<ResolveInfo> queryIntentActivities(Intent intent, int flags) {
+    return this.queryIntentComponents(
+        intent,
+        flags,
+        (pkg) -> pkg.activities,
+        activityFilters,
+        (resolveInfo, activityInfo) -> resolveInfo.activityInfo = activityInfo,
+        (resolveInfo) -> resolveInfo.activityInfo,
+        ActivityInfo::new);
+  }
+
+  private <I extends ComponentInfo> List<ResolveInfo> queryIntentComponents(
+      Intent intent,
+      int flags,
+      Function<PackageInfo, I[]> componentsInPackage,
+      SortedMap<ComponentName, List<IntentFilter>> filters,
+      BiConsumer<ResolveInfo, I> componentSetter,
+      Function<ResolveInfo, I> componentInResolveInfo,
+      Function<I, I> copyConstructor) {
+    synchronized (lock) {
+      if (intent.getComponent() != null) {
+        flags &= ~MATCH_DEFAULT_ONLY;
+      }
+      List<ResolveInfo> result = new ArrayList<>();
+      List<ResolveInfo> resolveInfoList = queryOverriddenIntents(intent, flags);
+      if (!resolveInfoList.isEmpty()) {
+        result.addAll(resolveInfoList);
+      }
+
+      result.addAll(
+          queryComponentsInManifest(intent, componentsInPackage, filters, componentSetter));
+
+      for (Iterator<ResolveInfo> iterator = result.iterator(); iterator.hasNext(); ) {
+        ResolveInfo resolveInfo = iterator.next();
+        I componentInfo = componentInResolveInfo.apply(resolveInfo);
+        if (hasSomeComponentInfo(resolveInfo) && componentInfo == null) {
+          Log.d(TAG, "ResolveInfo for different component type");
+          // different component type
+          iterator.remove();
+          continue;
+        }
+        if (componentInfo == null) {
+          // null component? Don't filter this sh...
+          continue;
+        }
+        if (!applyFlagsToResolveInfo(resolveInfo, flags)) {
+          Log.d(TAG, "ResolveInfo doesn't match flags");
+          iterator.remove();
+          continue;
+        }
+        ApplicationInfo applicationInfo = componentInfo.applicationInfo;
+        if (applicationInfo == null) {
+          String packageName = null;
+          if (getComponentForIntent(intent) != null) {
+            packageName = getComponentForIntent(intent).getPackageName();
+          } else if (intent.getPackage() != null) {
+            packageName = intent.getPackage();
+          } else if (componentInfo.packageName != null) {
+            packageName = componentInfo.packageName;
+          }
+          if (packageName != null) {
+            PackageInfo packageInfo = packageInfos.get(packageName);
+            if (packageInfo != null && packageInfo.applicationInfo != null) {
+              applicationInfo = new ApplicationInfo(packageInfo.applicationInfo);
+            } else {
+              applicationInfo = new ApplicationInfo();
+              applicationInfo.packageName = packageName;
+              applicationInfo.flags = FLAG_INSTALLED;
+            }
+          }
+        } else {
+          applicationInfo = new ApplicationInfo(applicationInfo);
+        }
+        componentInfo = copyConstructor.apply(componentInfo);
+        componentSetter.accept(resolveInfo, componentInfo);
+        componentInfo.applicationInfo = applicationInfo;
+
+        try {
+          applyFlagsToComponentInfo(componentInfo, flags);
+        } catch (NameNotFoundException e) {
+          Log.d(TAG, "ComponentInfo doesn't match flags:" + e.getMessage());
+          iterator.remove();
+          continue;
+        }
+      }
+      Collections.sort(result, new ResolveInfoComparator());
+      return result;
+    }
+  }
+
+  private boolean applyFlagsToResolveInfo(ResolveInfo resolveInfo, int flags) {
+    if ((flags & GET_RESOLVED_FILTER) == 0) {
+      resolveInfo.filter = null;
+    }
+    return (flags & MATCH_DEFAULT_ONLY) == 0 || resolveInfo.isDefault;
+  }
+
+  private <I extends ComponentInfo> List<ResolveInfo> queryComponentsInManifest(
+      Intent intent,
+      Function<PackageInfo, I[]> componentsInPackage,
+      SortedMap<ComponentName, List<IntentFilter>> filters,
+      BiConsumer<ResolveInfo, I> componentSetter) {
+    synchronized (lock) {
+      if (isExplicitIntent(intent)) {
+        ComponentName component = getComponentForIntent(intent);
+        PackageInfo appPackage = packageInfos.get(component.getPackageName());
+        if (appPackage == null) {
+          return Collections.emptyList();
+        }
+        I componentInfo = findMatchingComponent(component, componentsInPackage.apply(appPackage));
+        if (componentInfo != null) {
+          List<IntentFilter> componentFilters = filters.get(component);
+          PackageInfo targetPackage = packageInfos.get(component.getPackageName());
+          if (RuntimeEnvironment.getApiLevel() >= TIRAMISU
+              && (intent.getAction() != null
+                  || intent.getCategories() != null
+                  || intent.getData() != null)
+              && componentFilters != null
+              && !component.getPackageName().equals(getContext().getPackageName())
+              && targetPackage.applicationInfo.targetSdkVersion >= TIRAMISU) {
+            // Check if the explicit intent matches filters on the target component for T+
+            boolean matchFound = false;
+            for (IntentFilter filter : componentFilters) {
+              if (matchIntentFilter(intent, filter) > 0) {
+                matchFound = true;
+                break;
+              }
+            }
+            if (!matchFound) {
+              Log.w(
+                  TAG,
+                  "Component "
+                      + componentInfo
+                      + " doesn't have required intent filters for "
+                      + intent);
+              return Collections.emptyList();
+            }
+          }
+          ResolveInfo resolveInfo = buildResolveInfo(componentInfo);
+          componentSetter.accept(resolveInfo, componentInfo);
+          return new ArrayList<>(Collections.singletonList(resolveInfo));
+        }
+
+        return Collections.emptyList();
+      } else {
+        List<ResolveInfo> resolveInfoList = new ArrayList<>();
+        Map<ComponentName, List<IntentFilter>> filtersForPackage =
+            mapForPackage(filters, intent.getPackage());
+        components:
+        for (Map.Entry<ComponentName, List<IntentFilter>> componentEntry :
+            filtersForPackage.entrySet()) {
+          ComponentName componentName = componentEntry.getKey();
+          for (IntentFilter filter : componentEntry.getValue()) {
+            int match = matchIntentFilter(intent, filter);
+            if (match > 0) {
+              PackageInfo packageInfo = packageInfos.get(componentName.getPackageName());
+              I[] componentInfoArray = componentsInPackage.apply(packageInfo);
+              if (componentInfoArray != null) {
+                for (I componentInfo : componentInfoArray) {
+                  if (!componentInfo.name.equals(componentName.getClassName())) {
+                    continue;
+                  }
+                  ResolveInfo resolveInfo = buildResolveInfo(componentInfo, filter);
+                  resolveInfo.match = match;
+                  componentSetter.accept(resolveInfo, componentInfo);
+                  resolveInfoList.add(resolveInfo);
+                  continue components;
+                }
+              }
+            }
+          }
+        }
+        return resolveInfoList;
+      }
+    }
+  }
+
+  /** Behaves as {@link #queryIntentActivities(Intent, int)} and currently ignores userId. */
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected List<ResolveInfo> queryIntentActivitiesAsUser(Intent intent, int flags, int userId) {
+    return queryIntentActivities(intent, flags);
+  }
+
+  /** Returns true if intent has specified a specific component. */
+  private static boolean isExplicitIntent(Intent intent) {
+    return getComponentForIntent(intent) != null;
+  }
+
+  private static <T extends ComponentInfo> T findMatchingComponent(
+      ComponentName componentName, T[] components) {
+    if (components == null) {
+      return null;
+    }
+    for (T component : components) {
+      if (componentName.equals(new ComponentName(component.packageName, component.name))) {
+        return component;
+      }
+    }
+    return null;
+  }
+
+  private static ComponentName getComponentForIntent(Intent intent) {
+    ComponentName component = intent.getComponent();
+    if (component == null) {
+      if (intent.getSelector() != null) {
+        intent = intent.getSelector();
+        component = intent.getComponent();
+      }
+    }
+    return component;
+  }
+
+  private static ResolveInfo buildResolveInfo(ComponentInfo componentInfo) {
+    ResolveInfo resolveInfo = new ResolveInfo();
+    resolveInfo.resolvePackageName = componentInfo.applicationInfo.packageName;
+    resolveInfo.labelRes = componentInfo.labelRes;
+    resolveInfo.icon = componentInfo.icon;
+    resolveInfo.nonLocalizedLabel = componentInfo.nonLocalizedLabel;
+    return resolveInfo;
+  }
+
+  static ResolveInfo buildResolveInfo(ComponentInfo componentInfo, IntentFilter intentFilter) {
+    ResolveInfo info = buildResolveInfo(componentInfo);
+    info.isDefault = intentFilter.hasCategory("android.intent.category.DEFAULT");
+    info.filter = new IntentFilter(intentFilter);
+    info.priority = intentFilter.getPriority();
+    return info;
+  }
+
+  @Implementation
+  protected int checkPermission(String permName, String pkgName) {
+    PackageInfo permissionsInfo = getInternalMutablePackageInfo(pkgName);
+    if (permissionsInfo == null || permissionsInfo.requestedPermissions == null) {
+      return PackageManager.PERMISSION_DENIED;
+    }
+
+    String permission;
+    for (int i = 0; i < permissionsInfo.requestedPermissions.length; i++) {
+      permission = permissionsInfo.requestedPermissions[i];
+      if (permission != null && permission.equals(permName)) {
+        // The package requests this permission. Now check if it's been granted to the package.
+        if (isGrantedForBackwardsCompatibility(pkgName, permissionsInfo)) {
+          return PackageManager.PERMISSION_GRANTED;
+        }
+
+        if ((permissionsInfo.requestedPermissionsFlags[i] & REQUESTED_PERMISSION_GRANTED)
+            == REQUESTED_PERMISSION_GRANTED) {
+          return PackageManager.PERMISSION_GRANTED;
+        }
+      }
+    }
+
+    return PackageManager.PERMISSION_DENIED;
+  }
+
+  /**
+   * Returns whether a permission should be treated as granted to the package for backward
+   * compatibility reasons.
+   *
+   * <p>Before Robolectric 4.0 the ShadowPackageManager treated every requested permission as
+   * automatically granted. 4.0 changes this behavior, and only treats a permission as granted if
+   * PackageInfo.requestedPermissionFlags[permissionIndex] & REQUESTED_PERMISSION_GRANTED ==
+   * REQUESTED_PERMISSION_GRANTED which matches the real PackageManager's behavior.
+   *
+   * <p>Since many existing tests didn't set the requestedPermissionFlags on their {@code
+   * PackageInfo} objects, but assumed that all permissions are granted, we auto-grant all
+   * permissions if the requestedPermissionFlags is not set. If the requestedPermissionFlags is set,
+   * we assume that the test is configuring the permission grant state, and we don't override this
+   * setting.
+   */
+  private boolean isGrantedForBackwardsCompatibility(String pkgName, PackageInfo permissionsInfo) {
+    // Note: it might be cleaner to auto-grant these permissions when the package is added to the
+    // PackageManager. But many existing tests modify the requested permissions _after_ adding the
+    // package to the PackageManager, without updating the requestedPermissionsFlags.
+    return permissionsInfo.requestedPermissionsFlags == null
+        // Robolectric uses the PackageParser to create the current test package's PackageInfo from
+        // the manifest XML. The parser populates the requestedPermissionsFlags, but doesn't grant
+        // the permissions. Several tests rely on the test package being granted all permissions, so
+        // we treat this as a special case.
+        || pkgName.equals(RuntimeEnvironment.getApplication().getPackageName());
+  }
+
+  @Implementation
+  protected ActivityInfo getReceiverInfo(ComponentName component, int flags)
+      throws NameNotFoundException {
+    return getComponentInfo(
+        component,
+        flags,
+        packageInfo -> packageInfo.receivers,
+        resolveInfo -> resolveInfo.activityInfo,
+        ActivityInfo::new);
+  }
+
+  @Implementation
+  protected List<ResolveInfo> queryBroadcastReceivers(Intent intent, int flags) {
+    return this.queryIntentComponents(
+        intent,
+        flags,
+        (pkg) -> pkg.receivers,
+        receiverFilters,
+        (resolveInfo, activityInfo) -> resolveInfo.activityInfo = activityInfo,
+        (resolveInfo) -> resolveInfo.activityInfo,
+        ActivityInfo::new);
+  }
+
+  private static int matchIntentFilter(Intent intent, IntentFilter intentFilter) {
+    return intentFilter.match(
+        intent.getAction(),
+        intent.getType(),
+        intent.getScheme(),
+        intent.getData(),
+        intent.getCategories(),
+        TAG);
+  }
+
+  @Implementation
+  protected ResolveInfo resolveService(Intent intent, int flags) {
+    List<ResolveInfo> candidates = queryIntentServices(intent, flags);
+    return candidates.isEmpty() ? null : candidates.get(0);
+  }
+
+  @Implementation
+  protected ServiceInfo getServiceInfo(ComponentName component, int flags)
+      throws NameNotFoundException {
+    return getComponentInfo(
+        component,
+        flags,
+        packageInfo -> packageInfo.services,
+        resolveInfo -> resolveInfo.serviceInfo,
+        ServiceInfo::new);
+  }
+
+  /**
+   * Modifies the component in place using.
+   *
+   * @throws NameNotFoundException when component is filtered out by a flag
+   */
+  private void applyFlagsToComponentInfo(ComponentInfo componentInfo, int flags)
+      throws NameNotFoundException {
+    componentInfo.name = (componentInfo.name == null) ? "" : componentInfo.name;
+    ApplicationInfo applicationInfo = componentInfo.applicationInfo;
+    boolean isApplicationEnabled = true;
+    if (applicationInfo != null) {
+      if (applicationInfo.packageName == null) {
+        applicationInfo.packageName = componentInfo.packageName;
+      }
+      applyFlagsToApplicationInfo(componentInfo.applicationInfo, flags);
+      componentInfo.packageName = applicationInfo.packageName;
+      isApplicationEnabled = applicationInfo.enabled;
+    }
+    if ((flags & GET_META_DATA) == 0) {
+      componentInfo.metaData = null;
+    }
+    boolean isComponentEnabled = isComponentEnabled(componentInfo);
+    if ((flags & MATCH_ALL) != 0 && Build.VERSION.SDK_INT >= 23) {
+      return;
+    }
+    // Android don't override the enabled field of component with the actual value.
+    boolean isEnabledForFiltering =
+        isComponentEnabled && (Build.VERSION.SDK_INT >= 24 ? isApplicationEnabled : true);
+    if ((flags & MATCH_DISABLED_COMPONENTS) == 0 && !isEnabledForFiltering) {
+      throw new NameNotFoundException("Disabled component: " + componentInfo);
+    }
+    if (isFlagSet(flags, PackageManager.MATCH_SYSTEM_ONLY)) {
+      if (applicationInfo == null) {
+        // TODO: for backwards compatibility just skip filtering. In future should just remove
+        // invalid resolve infos from list
+      } else {
+        final int applicationFlags = applicationInfo.flags;
+        if ((applicationFlags & ApplicationInfo.FLAG_SYSTEM) != ApplicationInfo.FLAG_SYSTEM) {
+          throw new NameNotFoundException("Not system component: " + componentInfo);
+        }
+      }
+    }
+    if (!isFlagSet(flags, MATCH_UNINSTALLED_PACKAGES)
+        && isValidComponentInfo(componentInfo)
+        && hiddenPackages.contains(componentInfo.applicationInfo.packageName)) {
+      throw new NameNotFoundException("Uninstalled package: " + componentInfo);
+    }
+  }
+
+  @Implementation
+  protected Resources getResourcesForApplication(@NonNull ApplicationInfo applicationInfo)
+      throws PackageManager.NameNotFoundException {
+    synchronized (lock) {
+      if (getContext().getPackageName().equals(applicationInfo.packageName)) {
+        return getContext().getResources();
+      } else if (packageInfos.containsKey(applicationInfo.packageName)) {
+        Resources appResources = resources.get(applicationInfo.packageName);
+        if (appResources == null) {
+          appResources = new Resources(new AssetManager(), null, null);
+          resources.put(applicationInfo.packageName, appResources);
+        }
+        return appResources;
+      }
+      Resources resources = null;
+
+      if (RuntimeEnvironment.useLegacyResources()
+          && (applicationInfo.publicSourceDir == null
+              || !new File(applicationInfo.publicSourceDir).exists())) {
+        // In legacy mode, the underlying getResourcesForApplication implementation just returns an
+        // empty Resources instance in this case.
+        throw new NameNotFoundException(applicationInfo.packageName);
+      }
+
+      try {
+        resources =
+            reflector(ReflectorApplicationPackageManager.class, realObject)
+                .getResourcesForApplication(applicationInfo);
+      } catch (Exception ex) {
+        // handled below
+      }
+      if (resources == null) {
+        throw new NameNotFoundException(applicationInfo.packageName);
+      }
+      return resources;
+    }
+  }
+
+  @Implementation
+  protected List<ApplicationInfo> getInstalledApplications(int flags) {
+    List<PackageInfo> packageInfos = getInstalledPackages(flags);
+    List<ApplicationInfo> result = new ArrayList<>(packageInfos.size());
+
+    for (PackageInfo packageInfo : packageInfos) {
+      if (packageInfo.applicationInfo != null) {
+        result.add(packageInfo.applicationInfo);
+      }
+    }
+    return result;
+  }
+
+  @Implementation
+  protected String getInstallerPackageName(String packageName) {
+    synchronized (lock) {
+      // In REALISTIC mode, throw exception if the package is not installed or installed but
+      // later uninstalled
+      if (ConfigurationRegistry.get(GetInstallerPackageNameMode.Mode.class) == REALISTIC
+          && (!packageInstallerMap.containsKey(packageName)
+              || !packageInfos.containsKey(packageName))) {
+        throw new IllegalArgumentException("Package is not installed: " + packageName);
+      } else if (!packageInstallerMap.containsKey(packageName)) {
+        Log.w(
+            TAG,
+            String.format(
+                "Call to getInstallerPackageName returns null for package: '%s'. Please run"
+                    + " setInstallerPackageName to set installer package name before making the"
+                    + " call.",
+                packageName));
+      }
+      return packageInstallerMap.get(packageName);
+    }
+  }
+
+  @Implementation(minSdk = R)
+  protected Object getInstallSourceInfo(String packageName) {
+    return (InstallSourceInfo) packageInstallSourceInfoMap.get(packageName);
+  }
+
+  @Implementation
+  protected PermissionInfo getPermissionInfo(String name, int flags) throws NameNotFoundException {
+    PermissionInfo permissionInfo = extraPermissions.get(name);
+    if (permissionInfo != null) {
+      return permissionInfo;
+    }
+
+    synchronized (lock) {
+      for (PackageInfo packageInfo : packageInfos.values()) {
+        if (packageInfo.permissions != null) {
+          for (PermissionInfo permission : packageInfo.permissions) {
+            if (name.equals(permission.name)) {
+              return createCopyPermissionInfo(permission, flags);
+            }
+          }
+        }
+      }
+    }
+
+    throw new NameNotFoundException(name);
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean shouldShowRequestPermissionRationale(String permission) {
+    return permissionRationaleMap.containsKey(permission)
+        ? permissionRationaleMap.get(permission)
+        : false;
+  }
+
+  @Implementation
+  protected FeatureInfo[] getSystemAvailableFeatures() {
+    return systemAvailableFeatures.isEmpty()
+        ? null
+        : systemAvailableFeatures.toArray(new FeatureInfo[systemAvailableFeatures.size()]);
+  }
+
+  @Implementation
+  protected void verifyPendingInstall(int id, int verificationCode) {
+    if (verificationResults.containsKey(id)) {
+      throw new IllegalStateException("Multiple verifications for id=" + id);
+    }
+    verificationResults.put(id, verificationCode);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void extendVerificationTimeout(
+      int id, int verificationCodeAtTimeout, long millisecondsToDelay) {
+    verificationTimeoutExtension.put(id, millisecondsToDelay);
+  }
+
+  @Override
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected void freeStorageAndNotify(long freeStorageSize, IPackageDataObserver observer) {}
+
+  @Implementation(minSdk = M)
+  protected void freeStorageAndNotify(
+      String volumeUuid, long freeStorageSize, IPackageDataObserver observer) {}
+
+  @Implementation
+  protected void setInstallerPackageName(String targetPackage, String installerPackageName) {
+    packageInstallerMap.put(targetPackage, installerPackageName);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected List<ResolveInfo> queryIntentContentProviders(Intent intent, int flags) {
+    return this.queryIntentComponents(
+        intent,
+        flags,
+        (pkg) -> pkg.providers,
+        providerFilters,
+        (resolveInfo, providerInfo) -> resolveInfo.providerInfo = providerInfo,
+        (resolveInfo) -> resolveInfo.providerInfo,
+        ProviderInfo::new);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected List<ResolveInfo> queryIntentContentProvidersAsUser(
+      Intent intent, int flags, int userId) {
+    return Collections.emptyList();
+  }
+
+  @Implementation(minSdk = M)
+  protected String getPermissionControllerPackageName() {
+    return null;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN)
+  protected void getPackageSizeInfo(Object pkgName, Object observer) {
+    final PackageStats packageStats = packageStatsMap.get((String) pkgName);
+    new Handler(Looper.getMainLooper())
+        .post(
+            () -> {
+              try {
+                ((IPackageStatsObserver) observer)
+                    .onGetStatsCompleted(packageStats, packageStats != null);
+              } catch (RemoteException remoteException) {
+                remoteException.rethrowFromSystemServer();
+              }
+            });
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1, maxSdk = M)
+  protected void getPackageSizeInfo(Object pkgName, Object uid, final Object observer) {
+    final PackageStats packageStats = packageStatsMap.get((String) pkgName);
+    new Handler(Looper.getMainLooper())
+        .post(
+            () -> {
+              try {
+                ((IPackageStatsObserver) observer)
+                    .onGetStatsCompleted(packageStats, packageStats != null);
+              } catch (RemoteException remoteException) {
+                remoteException.rethrowFromSystemServer();
+              }
+            });
+  }
+
+  @Implementation(minSdk = N)
+  protected void getPackageSizeInfoAsUser(Object pkgName, Object uid, final Object observer) {
+    final PackageStats packageStats = packageStatsMap.get((String) pkgName);
+    new Handler(Looper.getMainLooper())
+        .post(
+            () -> {
+              try {
+                ((IPackageStatsObserver) observer)
+                    .onGetStatsCompleted(packageStats, packageStats != null);
+              } catch (RemoteException remoteException) {
+                remoteException.rethrowFromSystemServer();
+              }
+            });
+  }
+
+  @Override
+  @Implementation
+  protected void deletePackage(String packageName, IPackageDeleteObserver observer, int flags) {
+    super.deletePackage(packageName, observer, flags);
+  }
+
+  @Implementation
+  protected String[] currentToCanonicalPackageNames(String[] names) {
+    String[] out = new String[names.length];
+    for (int i = 0; i < names.length; i++) {
+      out[i] = currentToCanonicalNames.getOrDefault(names[i], names[i]);
+    }
+    return out;
+  }
+
+  @Implementation
+  protected String[] canonicalToCurrentPackageNames(String[] names) {
+    String[] out = new String[names.length];
+    for (int i = 0; i < names.length; i++) {
+      out[i] = canonicalToCurrentNames.getOrDefault(names[i], names[i]);
+    }
+    return out;
+  }
+
+  @Implementation
+  protected boolean isSafeMode() {
+    return safeMode;
+  }
+
+  @Implementation
+  protected Drawable getApplicationIcon(String packageName) throws NameNotFoundException {
+    return applicationIcons.get(packageName);
+  }
+
+  @Implementation
+  protected Drawable getApplicationIcon(ApplicationInfo info) throws NameNotFoundException {
+    return getApplicationIcon(info.packageName);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected Drawable getUserBadgeForDensity(UserHandle userHandle, int i) {
+    return null;
+  }
+
+  @Implementation
+  protected int checkSignatures(String pkg1, String pkg2) {
+    try {
+      PackageInfo packageInfo1 = getPackageInfo(pkg1, GET_SIGNATURES);
+      PackageInfo packageInfo2 = getPackageInfo(pkg2, GET_SIGNATURES);
+      return compareSignature(packageInfo1.signatures, packageInfo2.signatures);
+    } catch (NameNotFoundException e) {
+      return SIGNATURE_UNKNOWN_PACKAGE;
+    }
+  }
+
+  @Implementation
+  protected int checkSignatures(int uid1, int uid2) {
+    return 0;
+  }
+
+  @Implementation
+  protected List<PermissionInfo> queryPermissionsByGroup(String group, int flags)
+      throws NameNotFoundException {
+    List<PermissionInfo> result = new ArrayList<>();
+    for (PermissionInfo permissionInfo : extraPermissions.values()) {
+      if (Objects.equals(permissionInfo.group, group)) {
+        result.add(permissionInfo);
+      }
+    }
+    synchronized (lock) {
+      for (PackageInfo packageInfo : packageInfos.values()) {
+        if (packageInfo.permissions != null) {
+          for (PermissionInfo permission : packageInfo.permissions) {
+            if (Objects.equals(group, permission.group)) {
+              result.add(createCopyPermissionInfo(permission, flags));
+            }
+          }
+        }
+      }
+    }
+
+    if (result.isEmpty()) {
+      throw new NameNotFoundException(group);
+    }
+
+    return result;
+  }
+
+  private static PermissionInfo createCopyPermissionInfo(PermissionInfo src, int flags) {
+    PermissionInfo matchedPermission = new PermissionInfo(src);
+    if ((flags & GET_META_DATA) != GET_META_DATA) {
+      matchedPermission.metaData = null;
+    }
+    return matchedPermission;
+  }
+
+  private Intent getLaunchIntentForPackage(String packageName, String launcherCategory) {
+    Intent intentToResolve = new Intent(Intent.ACTION_MAIN);
+    intentToResolve.addCategory(Intent.CATEGORY_INFO);
+    intentToResolve.setPackage(packageName);
+    List<ResolveInfo> ris = queryIntentActivities(intentToResolve, 0);
+
+    if (ris == null || ris.isEmpty()) {
+      intentToResolve.removeCategory(Intent.CATEGORY_INFO);
+      intentToResolve.addCategory(launcherCategory);
+      intentToResolve.setPackage(packageName);
+      ris = queryIntentActivities(intentToResolve, 0);
+    }
+    if (ris == null || ris.isEmpty()) {
+      return null;
+    }
+    Intent intent = new Intent(intentToResolve);
+    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+    intent.setClassName(packageName, ris.get(0).activityInfo.name);
+    return intent;
+  }
+
+  @Implementation
+  protected Intent getLaunchIntentForPackage(String packageName) {
+    return getLaunchIntentForPackage(packageName, Intent.CATEGORY_LAUNCHER);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected Intent getLeanbackLaunchIntentForPackage(String packageName) {
+    return getLaunchIntentForPackage(packageName, Intent.CATEGORY_LEANBACK_LAUNCHER);
+  }
+
+  /**
+   * In Android T, the type of {@code flags} changed from {@code int} to {@link PackageInfoFlags}
+   */
+  @Implementation(minSdk = N)
+  protected Object getPackageInfoAsUser(Object packageName, Object flagsObject, Object userId)
+      throws NameNotFoundException {
+    int flags;
+    if (RuntimeEnvironment.getApiLevel() >= TIRAMISU) {
+      flags = (int) ((PackageInfoFlags) flagsObject).getValue();
+    } else {
+      flags = (int) flagsObject;
+    }
+    return getPackageInfo((String) packageName, flags);
+  }
+
+  @Implementation
+  protected int[] getPackageGids(String packageName) throws NameNotFoundException {
+    return new int[0];
+  }
+
+  @Implementation(minSdk = N)
+  protected int[] getPackageGids(String packageName, int flags) throws NameNotFoundException {
+    return null;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected int getPackageUid(String packageName, int flags) throws NameNotFoundException {
+    Integer uid = uidForPackage.get(packageName);
+    if (uid == null) {
+      throw new NameNotFoundException(packageName);
+    }
+    return uid;
+  }
+
+  @Implementation(minSdk = N)
+  protected int getPackageUidAsUser(String packageName, int userId) throws NameNotFoundException {
+    return 0;
+  }
+
+  @Implementation(minSdk = N)
+  protected int getPackageUidAsUser(String packageName, int flags, int userId)
+      throws NameNotFoundException {
+    return 0;
+  }
+
+  /**
+   * @see ShadowPackageManager#addPermissionGroupInfo(android.content.pm.PermissionGroupInfo)
+   */
+  @Implementation
+  protected PermissionGroupInfo getPermissionGroupInfo(String name, int flags)
+      throws NameNotFoundException {
+    if (permissionGroups.containsKey(name)) {
+      return new PermissionGroupInfo(permissionGroups.get(name));
+    }
+
+    throw new NameNotFoundException(name);
+  }
+
+  /**
+   * @see ShadowPackageManager#addPermissionGroupInfo(android.content.pm.PermissionGroupInfo)
+   */
+  @Implementation
+  protected List<PermissionGroupInfo> getAllPermissionGroups(int flags) {
+    ArrayList<PermissionGroupInfo> allPermissionGroups = new ArrayList<PermissionGroupInfo>();
+
+    for (PermissionGroupInfo permissionGroupInfo : permissionGroups.values()) {
+      allPermissionGroups.add(new PermissionGroupInfo(permissionGroupInfo));
+    }
+
+    return allPermissionGroups;
+  }
+
+  @Implementation
+  protected ApplicationInfo getApplicationInfo(String packageName, int flags)
+      throws NameNotFoundException {
+    PackageInfo packageInfo = getPackageInfo(packageName, flags);
+    if (packageInfo.applicationInfo == null) {
+      throw new NameNotFoundException("Package found but without application info");
+    }
+    // Maybe query app infos from overridden resolveInfo as well?
+    return packageInfo.applicationInfo;
+  }
+
+  private void applyFlagsToApplicationInfo(@Nullable ApplicationInfo appInfo, int flags)
+      throws NameNotFoundException {
+    if (appInfo == null) {
+      return;
+    }
+    String packageName = appInfo.packageName;
+
+    Integer stateOverride = applicationEnabledSettingMap.get(packageName);
+    if (stateOverride == null) {
+      stateOverride = COMPONENT_ENABLED_STATE_DEFAULT;
+    }
+    appInfo.enabled =
+        (appInfo.enabled && stateOverride == COMPONENT_ENABLED_STATE_DEFAULT)
+            || stateOverride == COMPONENT_ENABLED_STATE_ENABLED;
+
+    synchronized (lock) {
+      if (deletedPackages.contains(packageName)) {
+        appInfo.flags &= ~FLAG_INSTALLED;
+      }
+    }
+
+    if ((flags & MATCH_ALL) != 0 && Build.VERSION.SDK_INT >= 23) {
+      return;
+    }
+    if ((flags & MATCH_UNINSTALLED_PACKAGES) == 0 && (appInfo.flags & FLAG_INSTALLED) == 0) {
+      throw new NameNotFoundException("Package not installed: " + packageName);
+    }
+    if ((flags & MATCH_UNINSTALLED_PACKAGES) == 0 && hiddenPackages.contains(packageName)) {
+      throw new NameNotFoundException("Package hidden: " + packageName);
+    }
+  }
+
+  /**
+   * Returns all the values added via {@link
+   * ShadowPackageManager#addSystemSharedLibraryName(String)}.
+   */
+  @Implementation
+  protected String[] getSystemSharedLibraryNames() {
+    return systemSharedLibraryNames.toArray(new String[systemSharedLibraryNames.size()]);
+  }
+
+  @Implementation(minSdk = N)
+  protected @NonNull String getServicesSystemSharedLibraryPackageName() {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected @NonNull String getSharedSystemSharedLibraryPackageName() {
+    return "";
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean hasSystemFeature(String name, int version) {
+    return false;
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean isPermissionRevokedByPolicy(String permName, String pkgName) {
+    return false;
+  }
+
+  @Implementation
+  protected boolean addPermission(PermissionInfo info) {
+    return false;
+  }
+
+  @Implementation
+  protected boolean addPermissionAsync(PermissionInfo info) {
+    return false;
+  }
+
+  @Implementation
+  protected void removePermission(String name) {}
+
+  @Implementation(minSdk = M)
+  protected void grantRuntimePermission(
+      String packageName, String permissionName, UserHandle user) {
+    Integer uid;
+    synchronized (lock) {
+      if (!packageInfos.containsKey(packageName)) {
+        throw new SecurityException("Package not found: " + packageName);
+      }
+      PackageInfo packageInfo = packageInfos.get(packageName);
+      checkPermissionGrantStateInitialized(packageInfo);
+
+      int permissionIndex = getPermissionIndex(packageInfo, permissionName);
+      if (permissionIndex < 0) {
+        throw new SecurityException(
+            "Permission " + permissionName + " not requested by package " + packageName);
+      }
+
+      packageInfo.requestedPermissionsFlags[permissionIndex] |= REQUESTED_PERMISSION_GRANTED;
+
+      uid = uidForPackage.get(packageName);
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.M && uid != null) {
+      for (Object listener : permissionListeners) {
+        ((OnPermissionsChangedListener) listener).onPermissionsChanged(uid);
+      }
+    }
+  }
+
+  @Implementation(minSdk = M)
+  protected void revokeRuntimePermission(
+      String packageName, String permissionName, UserHandle user) {
+    Integer uid;
+    synchronized (lock) {
+      if (!packageInfos.containsKey(packageName)) {
+        throw new SecurityException("Package not found: " + packageName);
+      }
+      PackageInfo packageInfo = packageInfos.get(packageName);
+      checkPermissionGrantStateInitialized(packageInfo);
+
+      int permissionIndex = getPermissionIndex(packageInfo, permissionName);
+      if (permissionIndex < 0) {
+        throw new SecurityException(
+            "Permission " + permissionName + " not requested by package " + packageName);
+      }
+
+      packageInfo.requestedPermissionsFlags[permissionIndex] &= ~REQUESTED_PERMISSION_GRANTED;
+
+      uid = uidForPackage.get(packageName);
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.M && uid != null) {
+      for (Object listener : permissionListeners) {
+        ((OnPermissionsChangedListener) listener).onPermissionsChanged(uid);
+      }
+    }
+  }
+
+  private void checkPermissionGrantStateInitialized(PackageInfo packageInfo) {
+    if (packageInfo.requestedPermissionsFlags == null) {
+      // In the real OS this would never be null, but tests don't necessarily initialize this
+      // structure.
+      throw new SecurityException(
+          "Permission grant state (PackageInfo.requestedPermissionFlags) "
+              + "is null. This operation requires this variable to be initialized.");
+    }
+  }
+
+  /**
+   * Returns the index of the given permission in the PackageInfo.requestedPermissions array, or -1
+   * if it's not found.
+   */
+  private int getPermissionIndex(PackageInfo packageInfo, String permissionName) {
+    if (packageInfo.requestedPermissions != null) {
+      for (int i = 0; i < packageInfo.requestedPermissions.length; i++) {
+        if (permissionName.equals(packageInfo.requestedPermissions[i])) {
+          return i;
+        }
+      }
+    }
+
+    return -1;
+  }
+
+  /**
+   * This method differs from the real implementation in that we only return the permission flags
+   * that were added via updatePermissionFlags, and do not perform any verification of permissions,
+   * packages or users.
+   */
+  @Implementation(minSdk = M)
+  protected int getPermissionFlags(String permissionName, String packageName, UserHandle user) {
+    if (permissionFlags.containsKey(packageName)) {
+      return permissionFlags.get(packageName).getOrDefault(permissionName, /* defaultValue= */ 0);
+    }
+
+    return 0;
+  }
+
+  /**
+   * This method differs from the real implementation in that no permission checking or package
+   * existent checks are performed here.
+   */
+  @Implementation(minSdk = M)
+  protected void updatePermissionFlags(
+      String permissionName,
+      String packageName,
+      @PackageManager.PermissionFlags int flagMask,
+      @PackageManager.PermissionFlags int flagValues,
+      UserHandle user) {
+    if (!permissionFlags.containsKey(packageName)) {
+      permissionFlags.put(packageName, new HashMap<String, Integer>());
+    }
+
+    int existingFlags =
+        permissionFlags.get(packageName).getOrDefault(permissionName, /* defaultValue= */ 0);
+    int flagsToKeep = ~flagMask & existingFlags;
+    int flagsToChange = flagMask & flagValues;
+    int newFlags = flagsToKeep | flagsToChange;
+
+    permissionFlags.get(packageName).put(permissionName, newFlags);
+  }
+
+  @Implementation
+  protected int getUidForSharedUser(String sharedUserName) throws NameNotFoundException {
+    return 0;
+  }
+
+  @Implementation(minSdk = N)
+  protected List<PackageInfo> getInstalledPackagesAsUser(int flags, int userId) {
+    return null;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected List<PackageInfo> getPackagesHoldingPermissions(String[] permissions, int flags) {
+    synchronized (lock) {
+      List<PackageInfo> packageInfosWithPermissions = new ArrayList<>();
+      for (PackageInfo packageInfo : packageInfos.values()) {
+        for (String permission : permissions) {
+          int permissionIndex = getPermissionIndex(packageInfo, permission);
+          if (permissionIndex >= 0) {
+            packageInfosWithPermissions.add(packageInfo);
+            break;
+          }
+        }
+      }
+      return packageInfosWithPermissions;
+    }
+  }
+
+  @Implementation(minSdk = S)
+  public void getGroupOfPlatformPermission(
+      String permissionName, Executor executor, Consumer<String> callback) {
+    String permissionGroup = null;
+    try {
+      PermissionInfo permissionInfo =
+          getPermissionInfo(permissionName, PackageManager.GET_META_DATA);
+      permissionGroup = permissionInfo.group;
+    } catch (NameNotFoundException ignored) {
+      // fall through
+    }
+    final String finalPermissionGroup = permissionGroup;
+    executor.execute(() -> callback.accept(finalPermissionGroup));
+  }
+
+  /** Behaves as {@link #resolveActivity(Intent, int)} and currently ignores userId. */
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected ResolveInfo resolveActivityAsUser(Intent intent, int flags, int userId) {
+    return resolveActivity(intent, flags);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected ResolveInfo resolveActivityAsUser(Object intent, Object flags, Object userId) {
+    return resolveActivity((Intent) intent, (int) ((ResolveInfoFlags) flags).getValue());
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected ResolveInfo resolveServiceAsUser(Object intent, Object flags, Object userId) {
+    return resolveService((Intent) intent, (int) ((ResolveInfoFlags) flags).getValue());
+  }
+
+  @Implementation
+  protected List<ResolveInfo> queryIntentActivityOptions(
+      ComponentName caller, Intent[] specifics, Intent intent, int flags) {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected List<ResolveInfo> queryBroadcastReceiversAsUser(Intent intent, int flags, int userId) {
+    return null;
+  }
+
+  @Implementation
+  protected List<ProviderInfo> queryContentProviders(String processName, int uid, int flags) {
+    return null;
+  }
+
+  @Implementation
+  protected InstrumentationInfo getInstrumentationInfo(ComponentName className, int flags)
+      throws NameNotFoundException {
+    return null;
+  }
+
+  @Implementation
+  protected List<InstrumentationInfo> queryInstrumentation(String targetPackage, int flags) {
+    return null;
+  }
+
+  @Nullable
+  @Implementation
+  protected Drawable getDrawable(
+      String packageName, @DrawableRes int resId, @Nullable ApplicationInfo appInfo) {
+    Drawable result = drawables.get(new Pair<>(packageName, resId));
+    if (result != null) {
+      return result;
+    }
+    return reflector(ReflectorApplicationPackageManager.class, realObject)
+        .getDrawable(packageName, resId, appInfo);
+  }
+
+  /**
+   * Returns a user stored String resource with {@code resId} corresponding to {@code packageName}.
+   * User can store this String via {@link #addStringResource(String, int, String)}.
+   *
+   * <p>Real method is called if the user has not stored a String corresponding to {@code resId} and
+   * {@code packageName}.
+   */
+  @Nullable
+  @Implementation
+  protected CharSequence getText(String packageName, int resId, ApplicationInfo appInfo) {
+    if (stringResources.containsKey(packageName)
+        && stringResources.get(packageName).containsKey(resId)) {
+      return stringResources.get(packageName).get(resId);
+    }
+    return reflector(ReflectorApplicationPackageManager.class, realObject)
+        .getText(packageName, resId, appInfo);
+  }
+
+  @Implementation
+  protected Drawable getActivityIcon(ComponentName activityName) throws NameNotFoundException {
+    Drawable result = drawableList.get(activityName);
+    if (result != null) {
+      return result;
+    }
+    return reflector(ReflectorApplicationPackageManager.class, realObject)
+        .getActivityIcon(activityName);
+  }
+
+  @Implementation
+  protected Drawable getDefaultActivityIcon() {
+    return Resources.getSystem().getDrawable(com.android.internal.R.drawable.sym_def_app_icon);
+  }
+
+  @Implementation
+  protected Resources getResourcesForActivity(ComponentName activityName)
+      throws NameNotFoundException {
+    return getResourcesForApplication(activityName.getPackageName());
+  }
+
+  @Implementation
+  protected Resources getResourcesForApplication(String appPackageName)
+      throws NameNotFoundException {
+    synchronized (lock) {
+      if (getContext().getPackageName().equals(appPackageName)) {
+        return getContext().getResources();
+      } else if (packageInfos.containsKey(appPackageName)) {
+        Resources appResources = resources.get(appPackageName);
+        if (appResources == null) {
+          appResources = new Resources(new AssetManager(), null, null);
+          resources.put(appPackageName, appResources);
+        }
+        return appResources;
+      }
+      throw new NameNotFoundException(appPackageName);
+    }
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected Resources getResourcesForApplicationAsUser(String appPackageName, int userId)
+      throws NameNotFoundException {
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected void addOnPermissionsChangeListener(Object listener) {
+    permissionListeners.add(listener);
+  }
+
+  @Implementation(minSdk = M)
+  protected void removeOnPermissionsChangeListener(Object listener) {
+    permissionListeners.remove(listener);
+  }
+
+  @Implementation(maxSdk = O_MR1)
+  protected void installPackage(
+      Object packageURI, Object observer, Object flags, Object installerPackageName) {}
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected int installExistingPackage(String packageName) throws NameNotFoundException {
+    return 0;
+  }
+
+  @Implementation(minSdk = N)
+  protected int installExistingPackageAsUser(String packageName, int userId)
+      throws NameNotFoundException {
+    return 0;
+  }
+
+  @Implementation(minSdk = M)
+  protected void verifyIntentFilter(int id, int verificationCode, List<String> failedDomains) {}
+
+  @Implementation(minSdk = N)
+  protected int getIntentVerificationStatusAsUser(String packageName, int userId) {
+    return 0;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean updateIntentVerificationStatusAsUser(
+      String packageName, int status, int userId) {
+    return false;
+  }
+
+  @Implementation(minSdk = M)
+  protected List<IntentFilterVerificationInfo> getIntentFilterVerifications(String packageName) {
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected List<IntentFilter> getAllIntentFilters(String packageName) {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected String getDefaultBrowserPackageNameAsUser(int userId) {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean setDefaultBrowserPackageNameAsUser(String packageName, int userId) {
+    return false;
+  }
+
+  @Implementation(minSdk = M)
+  protected int getMoveStatus(int moveId) {
+    return 0;
+  }
+
+  @Implementation(minSdk = M)
+  protected void registerMoveCallback(Object callback, Object handler) {}
+
+  @Implementation(minSdk = M)
+  protected void unregisterMoveCallback(Object callback) {}
+
+  @Implementation(minSdk = M)
+  protected Object movePackage(Object packageName, Object vol) {
+    return 0;
+  }
+
+  @Implementation(minSdk = M)
+  protected Object getPackageCurrentVolume(Object app) {
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected List<VolumeInfo> getPackageCandidateVolumes(ApplicationInfo app) {
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected Object movePrimaryStorage(Object vol) {
+    return 0;
+  }
+
+  @Implementation(minSdk = M)
+  protected @Nullable Object getPrimaryStorageCurrentVolume() {
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected @NonNull List<VolumeInfo> getPrimaryStorageCandidateVolumes() {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected void deletePackageAsUser(
+      String packageName, IPackageDeleteObserver observer, int flags, int userId) {}
+
+  @Implementation
+  protected void clearApplicationUserData(String packageName, IPackageDataObserver observer) {
+    clearedApplicationUserDataPackages.add(packageName);
+  }
+
+  @Implementation
+  protected void deleteApplicationCacheFiles(String packageName, IPackageDataObserver observer) {}
+
+  @Implementation(minSdk = N)
+  protected void deleteApplicationCacheFilesAsUser(
+      String packageName, int userId, IPackageDataObserver observer) {}
+
+  @Implementation(minSdk = M)
+  protected void freeStorage(String volumeUuid, long freeStorageSize, IntentSender pi) {}
+
+  @Implementation(minSdk = N, maxSdk = O_MR1)
+  protected String[] setPackagesSuspendedAsUser(
+      String[] packageNames, boolean suspended, int userId) {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean isPackageSuspendedForUser(String packageName, int userId) {
+    return false;
+  }
+
+  @Implementation
+  protected void addPackageToPreferred(String packageName) {}
+
+  @Implementation
+  protected void removePackageFromPreferred(String packageName) {}
+
+  @Implementation
+  protected List<PackageInfo> getPreferredPackages(int flags) {
+    return null;
+  }
+
+  @Implementation
+  public void addPreferredActivity(
+      IntentFilter filter, int match, ComponentName[] set, ComponentName activity) {
+    addPreferredActivityInternal(filter, activity, preferredActivities);
+  }
+
+  @Implementation
+  protected void replacePreferredActivity(
+      IntentFilter filter, int match, ComponentName[] set, ComponentName activity) {
+    addPreferredActivity(filter, match, set, activity);
+  }
+
+  @Implementation
+  public int getPreferredActivities(
+      List<IntentFilter> outFilters, List<ComponentName> outActivities, String packageName) {
+    return getPreferredActivitiesInternal(
+        outFilters, outActivities, packageName, preferredActivities);
+  }
+
+  @Implementation
+  protected void clearPackagePreferredActivities(String packageName) {
+    clearPackagePreferredActivitiesInternal(packageName, preferredActivities);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected ComponentName getHomeActivities(List<ResolveInfo> outActivities) {
+    return null;
+  }
+
+  @Implementation(minSdk = N)
+  protected void flushPackageRestrictionsAsUser(int userId) {}
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean setApplicationHiddenSettingAsUser(
+      String packageName, boolean hidden, UserHandle user) {
+    synchronized (lock) {
+      // Note that this ignores the UserHandle parameter
+      if (!packageInfos.containsKey(packageName)) {
+        // Package doesn't exist
+        return false;
+      }
+      if (hidden) {
+        hiddenPackages.add(packageName);
+      } else {
+        hiddenPackages.remove(packageName);
+      }
+
+      return true;
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean getApplicationHiddenSettingAsUser(String packageName, UserHandle user) {
+    // Note that this ignores the UserHandle parameter
+    synchronized (lock) {
+      if (!packageInfos.containsKey(packageName)) {
+        // Match Android behaviour of returning true if package isn't found
+        return true;
+      }
+      return hiddenPackages.contains(packageName);
+    }
+  }
+
+  @Implementation
+  protected VerifierDeviceIdentity getVerifierDeviceIdentity() {
+    return null;
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected boolean isUpgrade() {
+    return false;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isPackageAvailable(String packageName) {
+    return false;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addCrossProfileIntentFilter(
+      IntentFilter filter, int sourceUserId, int targetUserId, int flags) {}
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void clearCrossProfileIntentFilters(int sourceUserId) {}
+
+  /**
+   * Gets the unbadged icon based on the values set by {@link
+   * ShadowPackageManager#setUnbadgedApplicationIcon} or returns null if nothing has been set.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected Drawable loadUnbadgedItemIcon(PackageItemInfo itemInfo, ApplicationInfo appInfo) {
+    Drawable result = unbadgedApplicationIcons.get(itemInfo.packageName);
+    if (result != null) {
+      return result;
+    }
+    return reflector(ReflectorApplicationPackageManager.class, realObject)
+        .loadUnbadgedItemIcon(itemInfo, appInfo);
+  }
+
+  /**
+   * Adds a profile badge to the icon.
+   *
+   * <p>This implementation just returns the unbadged icon, as some default implementations add an
+   * internal resource to the icon that is unavailable to Robolectric.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected Drawable getUserBadgedIcon(Drawable icon, UserHandle user) {
+    return icon;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean canRequestPackageInstalls() {
+    return canRequestPackageInstalls;
+  }
+
+  @Implementation(minSdk = O)
+  protected Object getChangedPackages(int sequenceNumber) {
+    if (sequenceNumber < 0 || sequenceNumberChangedPackagesMap.get(sequenceNumber).isEmpty()) {
+      return null;
+    }
+    return new ChangedPackages(
+        sequenceNumber + 1, new ArrayList<>(sequenceNumberChangedPackagesMap.get(sequenceNumber)));
+  }
+
+  @Implementation(minSdk = P)
+  public String getSystemTextClassifierPackageName() {
+    return "";
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected String[] setPackagesSuspended(
+      String[] packageNames,
+      boolean suspended,
+      PersistableBundle appExtras,
+      PersistableBundle launcherExtras,
+      String dialogMessage) {
+    return setPackagesSuspended(
+        packageNames, suspended, appExtras, launcherExtras, dialogMessage, /* dialogInfo= */ null);
+  }
+
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  protected /* String[] */ Object setPackagesSuspended(
+      /* String[] */ Object packageNames,
+      /* boolean */ Object suspended,
+      /* PersistableBundle */ Object appExtras,
+      /* PersistableBundle */ Object launcherExtras,
+      /* SuspendDialogInfo */ Object dialogInfo) {
+    return setPackagesSuspended(
+        (String[]) packageNames,
+        (boolean) suspended,
+        (PersistableBundle) appExtras,
+        (PersistableBundle) launcherExtras,
+        /* dialogMessage= */ null,
+        dialogInfo);
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean isAutoRevokeWhitelisted() {
+    return whitelisted;
+  }
+
+  /**
+   * Sets {@code packageNames} suspension status to {@code suspended} in the package manager.
+   *
+   * <p>At least one of {@code dialogMessage} and {@code dialogInfo} should be null.
+   */
+  private String[] setPackagesSuspended(
+      String[] packageNames,
+      boolean suspended,
+      PersistableBundle appExtras,
+      PersistableBundle launcherExtras,
+      String dialogMessage,
+      Object dialogInfo) {
+    if (hasProfileOwnerOrDeviceOwnerOnCurrentUser()
+        && (VERSION.SDK_INT < VERSION_CODES.Q
+            || !isCurrentApplicationProfileOwnerOrDeviceOwner())) {
+      throw new UnsupportedOperationException();
+    }
+    ArrayList<String> unupdatedPackages = new ArrayList<>();
+    for (String packageName : packageNames) {
+      if (!canSuspendPackage(packageName)) {
+        unupdatedPackages.add(packageName);
+        continue;
+      }
+      PackageSetting setting = packageSettings.get(packageName);
+      if (setting == null) {
+        unupdatedPackages.add(packageName);
+        continue;
+      }
+      setting.setSuspended(suspended, dialogMessage, dialogInfo, appExtras, launcherExtras);
+    }
+    return unupdatedPackages.toArray(new String[0]);
+  }
+
+  /** Returns whether the current user profile has a profile owner or a device owner. */
+  private boolean isCurrentApplicationProfileOwnerOrDeviceOwner() {
+    String currentApplication = getContext().getPackageName();
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) getContext().getSystemService(Context.DEVICE_POLICY_SERVICE);
+    return devicePolicyManager.isProfileOwnerApp(currentApplication)
+        || devicePolicyManager.isDeviceOwnerApp(currentApplication);
+  }
+
+  /** Returns whether the current user profile has a profile owner or a device owner. */
+  private boolean hasProfileOwnerOrDeviceOwnerOnCurrentUser() {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) getContext().getSystemService(Context.DEVICE_POLICY_SERVICE);
+    return devicePolicyManager.getProfileOwner() != null
+        || (UserHandle.of(UserHandle.myUserId()).isSystem()
+            && devicePolicyManager.getDeviceOwner() != null);
+  }
+
+  private boolean canSuspendPackage(String packageName) {
+    // This code approximately mirrors PackageManagerService#canSuspendPackageForUserLocked.
+    return !packageName.equals(getContext().getPackageName())
+        && !isPackageDeviceAdmin(packageName)
+        && !isPackageActiveLauncher(packageName)
+        && !isPackageRequiredInstaller(packageName)
+        && !isPackageRequiredUninstaller(packageName)
+        && !isPackageRequiredVerifier(packageName)
+        && !isPackageDefaultDialer(packageName)
+        && !packageName.equals(PLATFORM_PACKAGE_NAME);
+  }
+
+  private boolean isPackageDeviceAdmin(String packageName) {
+    DevicePolicyManager devicePolicyManager =
+        (DevicePolicyManager) getContext().getSystemService(Context.DEVICE_POLICY_SERVICE);
+    // Strictly speaking, this should be devicePolicyManager.getDeviceOwnerComponentOnAnyUser(),
+    // but that method is currently not shadowed.
+    return packageName.equals(devicePolicyManager.getDeviceOwner());
+  }
+
+  private boolean isPackageActiveLauncher(String packageName) {
+    Intent intent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME);
+    ResolveInfo info = resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
+    return info != null && packageName.equals(info.activityInfo.packageName);
+  }
+
+  private boolean isPackageRequiredInstaller(String packageName) {
+    Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE);
+    intent.addCategory(Intent.CATEGORY_DEFAULT);
+    intent.setDataAndType(Uri.fromFile(new File("foo.apk")), PACKAGE_MIME_TYPE);
+    ResolveInfo info =
+        resolveActivity(
+            intent,
+            PackageManager.MATCH_SYSTEM_ONLY
+                | PackageManager.MATCH_DIRECT_BOOT_AWARE
+                | PackageManager.MATCH_DIRECT_BOOT_UNAWARE);
+    return info != null && packageName.equals(info.activityInfo.packageName);
+  }
+
+  private boolean isPackageRequiredUninstaller(String packageName) {
+    final Intent intent = new Intent(Intent.ACTION_UNINSTALL_PACKAGE);
+    intent.addCategory(Intent.CATEGORY_DEFAULT);
+    intent.setData(Uri.fromParts(PACKAGE_SCHEME, "foo.bar", null));
+    ResolveInfo info =
+        resolveActivity(
+            intent,
+            PackageManager.MATCH_SYSTEM_ONLY
+                | PackageManager.MATCH_DIRECT_BOOT_AWARE
+                | PackageManager.MATCH_DIRECT_BOOT_UNAWARE);
+    return info != null && packageName.equals(info.activityInfo.packageName);
+  }
+
+  private boolean isPackageRequiredVerifier(String packageName) {
+    final Intent intent = new Intent(Intent.ACTION_PACKAGE_NEEDS_VERIFICATION);
+    List<ResolveInfo> infos =
+        queryBroadcastReceivers(
+            intent,
+            PackageManager.MATCH_SYSTEM_ONLY
+                | PackageManager.MATCH_DIRECT_BOOT_AWARE
+                | PackageManager.MATCH_DIRECT_BOOT_UNAWARE);
+    if (infos != null) {
+      for (ResolveInfo info : infos) {
+        if (packageName.equals(info.activityInfo.packageName)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private boolean isPackageDefaultDialer(String packageName) {
+    TelecomManager telecomManager =
+        (TelecomManager) getContext().getSystemService(Context.TELECOM_SERVICE);
+    return packageName.equals(telecomManager.getDefaultDialerPackage());
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = Q)
+  @RequiresPermission(permission.SUSPEND_APPS)
+  protected String[] getUnsuspendablePackages(String[] packageNames) {
+    checkNotNull(packageNames, "packageNames cannot be null");
+    if (getContext().checkSelfPermission(permission.SUSPEND_APPS)
+        != PackageManager.PERMISSION_GRANTED) {
+      throw new SecurityException("Current process does not have " + permission.SUSPEND_APPS);
+    }
+    ArrayList<String> unsuspendablePackages = new ArrayList<>();
+    for (String packageName : packageNames) {
+      if (!canSuspendPackage(packageName)) {
+        unsuspendablePackages.add(packageName);
+      }
+    }
+    return unsuspendablePackages.toArray(new String[0]);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = P)
+  protected boolean isPackageSuspended(String packageName) throws NameNotFoundException {
+    PackageSetting setting = packageSettings.get(packageName);
+    if (setting == null) {
+      throw new NameNotFoundException(packageName);
+    }
+    return setting.isSuspended();
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean isInstantApp(String packageName) {
+    synchronized (lock) {
+      PackageInfo pi = packageInfos.get(packageName);
+      if (pi == null) {
+        return false;
+      }
+      ApplicationInfo ai = pi.applicationInfo;
+      if (ai == null) {
+        return false;
+      }
+      return ai.isInstantApp();
+    }
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = Q)
+  protected String[] setDistractingPackageRestrictions(String[] packages, int restrictionFlags) {
+    for (String pkg : packages) {
+      distractingPackageRestrictions.put(pkg, restrictionFlags);
+    }
+    return new String[0];
+  }
+
+  private Context getContext() {
+    return reflector(ReflectorApplicationPackageManager.class, realObject).getContext();
+  }
+
+  /** Reflector interface for {@link ApplicationPackageManager}'s internals. */
+  @ForType(ApplicationPackageManager.class)
+  private interface ReflectorApplicationPackageManager {
+
+    @Direct
+    Resources getResourcesForApplication(@NonNull ApplicationInfo applicationInfo);
+
+    @Direct
+    Drawable getDrawable(
+        String packageName, @DrawableRes int resId, @Nullable ApplicationInfo appInfo);
+
+    @Direct
+    CharSequence getText(String packageName, @StringRes int resId, ApplicationInfo appInfo);
+
+    @Direct
+    Drawable getActivityIcon(ComponentName activityName);
+
+    @Direct
+    Drawable loadUnbadgedItemIcon(PackageItemInfo itemInfo, ApplicationInfo appInfo);
+
+    @Direct
+    PackageInfo getPackageArchiveInfo(String archiveFilePath, int flags);
+
+    @Accessor("mContext")
+    Context getContext();
+  }
+
+  /** Returns the list of package names that were requested to be cleared. */
+  public List<String> getClearedApplicationUserDataPackages() {
+    return Collections.unmodifiableList(clearedApplicationUserDataPackages);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArrayAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArrayAdapter.java
new file mode 100644
index 0000000..cf2ea0e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArrayAdapter.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import android.widget.ArrayAdapter;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings("UnusedDeclaration")
+@Implements(ArrayAdapter.class)
+public class ShadowArrayAdapter<T> extends ShadowBaseAdapter {
+  @RealObject private ArrayAdapter<T> realArrayAdapter;
+
+  public int getTextViewResourceId() {
+    return ReflectionHelpers.getField(realArrayAdapter, "mFieldId");
+  }
+
+  public int getResourceId() {
+    return ReflectionHelpers.getField(realArrayAdapter, "mResource");
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscApkAssets9.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscApkAssets9.java
new file mode 100755
index 0000000..eb2276c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscApkAssets9.java
@@ -0,0 +1,405 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Util.ATRACE_NAME;
+import static org.robolectric.res.android.Util.JNI_TRUE;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.NonNull;
+import android.content.res.ApkAssets;
+import android.content.res.AssetManager;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.util.HashMap;
+import java.util.Objects;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.CppApkAssets;
+import org.robolectric.res.android.Registries;
+import org.robolectric.res.android.ResXMLTree;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowApkAssets.Picker;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/jni/android_content_res_ApkAssets.cpp
+
+/** Shadow for {@link ApkAssets} for Android P+ */
+@Implements(
+    value = ApkAssets.class,
+    minSdk = P,
+    shadowPicker = Picker.class,
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowArscApkAssets9 extends ShadowApkAssets {
+  // #define ATRACE_TAG ATRACE_TAG_RESOURCES
+  //
+  // #include "android-base/macros.h"
+  // #include "android-base/stringprintf.h"
+  // #include "android-base/unique_fd.h"
+  // #include "androidfw/ApkAssets.h"
+  // #include "utils/misc.h"
+  // #include "utils/Trace.h"
+  //
+  // #include "core_jni_helpers.h"
+  // #include "jni.h"
+  // #include "nativehelper/ScopedUtfChars.h"
+  //
+  // using ::android::base::unique_fd;
+  //
+  // namespace android {
+
+  // TODO: just use the ApkAssets constants. For some unknown reason these cannot be found
+  private static final int PROPERTY_SYSTEM = 1 << 0;
+  private static final int PROPERTY_DYNAMIC = 1 << 1;
+  private static final int PROPERTY_OVERLAY = 1 << 3;
+
+  protected static final String FRAMEWORK_APK_PATH =
+      ReflectionHelpers.getStaticField(AssetManager.class, "FRAMEWORK_APK_PATH");
+
+  private static final HashMap<Key, WeakReference<ApkAssets>> cachedApkAssets =
+      new HashMap<>();
+  private static final HashMap<Key, Long> cachedNativePtrs = new HashMap<>();
+
+  @RealObject private ApkAssets realApkAssets;
+
+  long getNativePtr() {
+    return reflector(_ApkAssets_.class, realApkAssets).getNativePtr();
+  }
+
+  /** Reflector interface for {@link ApkAssets}'s internals. */
+  @ForType(ApkAssets.class)
+  interface _ApkAssets_ {
+
+    @Static
+    @Direct
+    ApkAssets loadFromPath(String path);
+
+    @Static
+    @Direct
+    ApkAssets loadFromPath(String finalPath, boolean system);
+
+    @Static
+    @Direct
+    ApkAssets loadFromPath(String path, boolean system, boolean forceSharedLibrary);
+
+    @Static
+    @Direct
+    ApkAssets loadFromPath(String finalPath, int flags);
+
+    @Static
+    @Direct
+    ApkAssets loadFromPath(
+        FileDescriptor fd, String friendlyName, boolean system, boolean forceSharedLibrary);
+
+    @Accessor("mNativePtr")
+    long getNativePtr();
+  }
+
+
+  /**
+   * Caching key for {@link ApkAssets}.
+   */
+  protected static class Key {
+    private final FileDescriptor fd;
+    private final String path;
+    private final boolean system;
+    private final boolean load_as_shared_library;
+    private final boolean overlay;
+
+    public Key(
+        FileDescriptor fd,
+        String path,
+        boolean system,
+        boolean load_as_shared_library,
+        boolean overlay) {
+      this.fd = fd;
+      this.path = path;
+      this.system = system;
+      this.load_as_shared_library = load_as_shared_library;
+      this.overlay = overlay;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      Key key = (Key) o;
+      return system == key.system &&
+          load_as_shared_library == key.load_as_shared_library &&
+          overlay == key.overlay &&
+          Objects.equals(fd, key.fd) &&
+          Objects.equals(path, key.path);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(fd, path, system, load_as_shared_library, overlay);
+    }
+  }
+
+  @FunctionalInterface
+  protected interface ApkAssetMaker {
+    ApkAssets call();
+  }
+
+  protected static ApkAssets getFromCacheOrLoad(Key key, ApkAssetMaker callable) {
+    synchronized (cachedApkAssets) {
+      WeakReference<ApkAssets> cachedRef = cachedApkAssets.get(key);
+      ApkAssets apkAssets;
+      if (cachedRef != null) {
+        apkAssets = cachedRef.get();
+        if (apkAssets != null) {
+          return apkAssets;
+        } else {
+          cachedApkAssets.remove(key);
+          long nativePtr = cachedNativePtrs.remove(key);
+          Registries.NATIVE_APK_ASSETS_REGISTRY.unregister(nativePtr);
+        }
+      }
+
+      apkAssets = callable.call();
+      cachedApkAssets.put(key, new WeakReference<>(apkAssets));
+      long nativePtr = ((ShadowArscApkAssets9) Shadow.extract(apkAssets)).getNativePtr();
+      cachedNativePtrs.put(key, nativePtr);
+      return apkAssets;
+    }
+  }
+
+  @Implementation
+  protected static ApkAssets loadFromPath(@NonNull String path) throws IOException {
+    return getFromCacheOrLoad(
+        new Key(null, path, false, false, false),
+        () -> reflector(_ApkAssets_.class).loadFromPath(path));
+  }
+
+  /**
+   * Necessary to shadow this method because the framework path is hard-coded. Called from
+   * AssetManager.createSystemAssetsInZygoteLocked() in P+.
+   */
+  @Implementation(maxSdk = Q)
+  protected static ApkAssets loadFromPath(String path, boolean system) throws IOException {
+    System.out.println(
+        "Called loadFromPath("
+            + path
+            + ", "
+            + system
+            + "); mode="
+            + (RuntimeEnvironment.useLegacyResources() ? "legacy" : "binary")
+            + " sdk="
+            + RuntimeEnvironment.getApiLevel());
+
+    if (FRAMEWORK_APK_PATH.equals(path)) {
+      path = RuntimeEnvironment.getAndroidFrameworkJarPath().toString();
+    }
+
+    String finalPath = path;
+    return getFromCacheOrLoad(
+        new Key(null, path, system, false, false),
+        () -> reflector(_ApkAssets_.class).loadFromPath(finalPath, system));
+  }
+
+  @Implementation(maxSdk = Q)
+  @NonNull
+  protected static ApkAssets loadFromPath(
+      @NonNull String path, boolean system, boolean forceSharedLibrary) throws IOException {
+    return getFromCacheOrLoad(
+        new Key(null, path, system, forceSharedLibrary, false),
+        () -> reflector(_ApkAssets_.class).loadFromPath(path, system, forceSharedLibrary));
+  }
+
+  @Implementation(minSdk = R)
+  protected static ApkAssets loadFromPath(String path, int flags) throws IOException {
+    boolean system = (flags & PROPERTY_SYSTEM) == PROPERTY_SYSTEM;
+
+    if (FRAMEWORK_APK_PATH.equals(path)) {
+      path = RuntimeEnvironment.getAndroidFrameworkJarPath().toString();
+    }
+
+    String finalPath = path;
+    return getFromCacheOrLoad(
+        new Key(null, path, system, false, false),
+        () -> reflector(_ApkAssets_.class).loadFromPath(finalPath, flags));
+  }
+
+  @Implementation(maxSdk = Q)
+  protected static ApkAssets loadFromFd(
+      FileDescriptor fd, String friendlyName, boolean system, boolean forceSharedLibrary)
+      throws IOException {
+    return getFromCacheOrLoad(
+        new Key(fd, friendlyName, system, forceSharedLibrary, false),
+        () ->
+            reflector(_ApkAssets_.class)
+                .loadFromPath(fd, friendlyName, system, forceSharedLibrary));
+  }
+
+  // static jlong NativeLoad(JNIEnv* env, jclass /*clazz*/, jstring java_path, jboolean system,
+  //                         jboolean force_shared_lib, jboolean overlay) {
+
+  @Implementation(maxSdk = Q)
+  protected static long nativeLoad(
+      String path, boolean system, boolean forceSharedLib, boolean overlay) throws IOException {
+    if (path == null) {
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("LoadApkAssets(%s)", path));
+
+    CppApkAssets apk_assets;
+    try {
+      if (overlay) {
+        apk_assets = CppApkAssets.LoadOverlay(path, system);
+      } else if (forceSharedLib) {
+        apk_assets = CppApkAssets.LoadAsSharedLibrary(path, system);
+      } else {
+        apk_assets = CppApkAssets.Load(path, system);
+      }
+    } catch (OutOfMemoryError e) {
+      OutOfMemoryError outOfMemoryError = new OutOfMemoryError("Failed to load " + path);
+      outOfMemoryError.initCause(e);
+      throw outOfMemoryError;
+    }
+
+    if (apk_assets == null) {
+      String error_msg = String.format("Failed to load asset path %s", path);
+      throw new IOException(error_msg);
+    }
+    return Registries.NATIVE_APK_ASSETS_REGISTRY.register(apk_assets);
+  }
+
+  @Implementation(minSdk = R)
+  protected static Object nativeLoad(
+      Object format, Object javaPath, Object flags, Object assetsProvider) throws IOException {
+    boolean system = ((int) flags & PROPERTY_SYSTEM) == PROPERTY_SYSTEM;
+    boolean overlay = ((int) flags & PROPERTY_OVERLAY) == PROPERTY_OVERLAY;
+    boolean forceSharedLib = ((int) flags & PROPERTY_DYNAMIC) == PROPERTY_DYNAMIC;
+    return nativeLoad((String) javaPath, system, forceSharedLib, overlay);
+  }
+
+  // static jlong NativeLoadFromFd(JNIEnv* env, jclass /*clazz*/, jobject file_descriptor,
+  //                               jstring friendly_name, jboolean system, jboolean
+  // force_shared_lib) {
+  @Implementation(maxSdk = Q)
+  protected static long nativeLoadFromFd(
+      FileDescriptor file_descriptor,
+      String friendly_name,
+      boolean system,
+      boolean force_shared_lib) {
+    String friendly_name_utf8 = friendly_name;
+    if (friendly_name_utf8 == null) {
+      return 0;
+    }
+
+    throw new UnsupportedOperationException();
+    // ATRACE_NAME(String.format("LoadApkAssetsFd(%s)", friendly_name_utf8));
+    //
+    // int fd = jniGetFDFromFileDescriptor(env, file_descriptor);
+    // if (fd < 0) {
+    //   throw new IllegalArgumentException("Bad FileDescriptor");
+    // }
+    //
+    // unique_fd dup_fd(.dup(fd));
+    // if (dup_fd < 0) {
+    //   throw new IOException(errno);
+    //   return 0;
+    // }
+    //
+    // ApkAssets apk_assets = ApkAssets.LoadFromFd(std.move(dup_fd),
+    //                                                                     friendly_name_utf8,
+    //                                                                     system, force_shared_lib);
+    // if (apk_assets == null) {
+    //   String error_msg = String.format("Failed to load asset path %s from fd %d",
+    //                                              friendly_name_utf8, dup_fd.get());
+    //   throw new IOException(error_msg);
+    //   return 0;
+    // }
+    // return ShadowArscAssetManager9.NATIVE_APK_ASSETS_REGISTRY.getNativeObjectId(apk_assets);
+  }
+
+  // static jstring NativeGetAssetPath(JNIEnv* env, jclass /*clazz*/, jlong ptr) {
+  @Implementation
+  protected static String nativeGetAssetPath(long ptr) {
+    CppApkAssets apk_assets = Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(ptr);
+    return apk_assets.GetPath();
+  }
+
+  // static jlong NativeGetStringBlock(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr) {
+  @Implementation
+  protected static long nativeGetStringBlock(long ptr) {
+    CppApkAssets apk_assets = Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(ptr);
+    return apk_assets.GetLoadedArsc().GetStringPool().getNativePtr();
+  }
+
+  // static jboolean NativeIsUpToDate(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr) {
+  @Implementation
+  protected static boolean nativeIsUpToDate(long ptr) {
+    // (void)apk_assets;
+    return JNI_TRUE;
+  }
+
+  // static jlong NativeOpenXml(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring file_name) {
+  @Implementation
+  protected static long nativeOpenXml(long ptr, String file_name) throws FileNotFoundException {
+    String path_utf8 = file_name;
+    if (path_utf8 == null) {
+      return 0;
+    }
+
+    CppApkAssets apk_assets =
+        Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(ptr);
+    Asset asset = apk_assets.Open(path_utf8, Asset.AccessMode.ACCESS_RANDOM);
+    if (asset == null) {
+      throw new FileNotFoundException(path_utf8);
+    }
+
+    // DynamicRefTable is only needed when looking up resource references. Opening an XML file
+    // directly from an ApkAssets has no notion of proper resource references.
+    ResXMLTree xml_tree = new ResXMLTree(null); // util.make_unique<ResXMLTree>(nullptr /*dynamicRefTable*/);
+    int err = xml_tree.setTo(asset.getBuffer(true), (int) asset.getLength(), true);
+    // asset.reset();
+
+    if (err != NO_ERROR) {
+      throw new FileNotFoundException("Corrupt XML binary file");
+    }
+    return Registries.NATIVE_RES_XML_TREES.register(xml_tree); // reinterpret_cast<jlong>(xml_tree.release());
+  }
+
+  // // JNI registration.
+  // static const JNINativeMethod gApkAssetsMethods[] = {
+  //     {"nativeLoad", "(Ljava/lang/String;ZZZ)J", (void*)NativeLoad},
+  //     {"nativeLoadFromFd", "(Ljava/io/FileDescriptor;Ljava/lang/String;ZZ)J",
+  //         (void*)NativeLoadFromFd},
+  //     {"nativeDestroy", "(J)V", (void*)NativeDestroy},
+  //     {"nativeGetAssetPath", "(J)Ljava/lang/String;", (void*)NativeGetAssetPath},
+  //     {"nativeGetStringBlock", "(J)J", (void*)NativeGetStringBlock},
+  //     {"nativeIsUpToDate", "(J)Z", (void*)NativeIsUpToDate},
+  //     {"nativeOpenXml", "(JLjava/lang/String;)J", (void*)NativeOpenXml},
+  // };
+  //
+  // int register_android_content_res_ApkAssets(JNIEnv* env) {
+  //   return RegisterMethodsOrDie(env, "android/content/res/ApkAssets", gApkAssetsMethods,
+  //                               arraysize(gApkAssetsMethods));
+  // }
+  //
+  // }  // namespace android
+
+  @Implementation(minSdk = S)
+  protected void close() {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetInputStream.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetInputStream.java
new file mode 100644
index 0000000..9b91a22
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetInputStream.java
@@ -0,0 +1,52 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetManager.AssetInputStream;
+import java.io.InputStream;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.Registries;
+import org.robolectric.shadows.ShadowAssetInputStream.Picker;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings("UnusedDeclaration")
+@Implements(value = AssetInputStream.class, shadowPicker = Picker.class)
+public class ShadowArscAssetInputStream extends ShadowAssetInputStream {
+
+  @RealObject
+  private AssetInputStream realObject;
+
+  @Override
+  InputStream getDelegate() {
+    return realObject;
+  }
+
+  private Asset getAsset() {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    long assetPtr;
+    if (apiLevel >= LOLLIPOP) {
+      assetPtr = reflector(_AssetInputStream_.class, realObject).getNativeAsset();
+    } else {
+      assetPtr = reflector(_AssetInputStream_.class, realObject).getAssetInt();
+    }
+    return Registries.NATIVE_ASSET_REGISTRY.getNativeObject(assetPtr);
+  }
+
+  @Override
+  boolean isNinePatch() {
+    Asset asset = getAsset();
+    return asset != null && asset.isNinePatch();
+  }
+
+  /** Accessor interface for {@link AssetInputStream}'s internals. */
+  @ForType(AssetInputStream.class)
+  private interface _AssetInputStream_ {
+    int getAssetInt();
+
+    long getNativeAsset();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java
new file mode 100755
index 0000000..c0c9c7a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java
@@ -0,0 +1,1383 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.res.android.Asset.SEEK_CUR;
+import static org.robolectric.res.android.Asset.SEEK_SET;
+import static org.robolectric.res.android.AttributeResolution.kThrowOnBadId;
+import static org.robolectric.res.android.Errors.BAD_INDEX;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Util.ALOGV;
+import static org.robolectric.res.android.Util.isTruthy;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetManager;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelFileDescriptor;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import dalvik.system.VMRuntime;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.Fs;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.Asset.AccessMode;
+import org.robolectric.res.android.AssetDir;
+import org.robolectric.res.android.AssetPath;
+import org.robolectric.res.android.AttributeResolution;
+import org.robolectric.res.android.CppAssetManager;
+import org.robolectric.res.android.DataType;
+import org.robolectric.res.android.DynamicRefTable;
+import org.robolectric.res.android.Ref;
+import org.robolectric.res.android.Registries;
+import org.robolectric.res.android.ResStringPool;
+import org.robolectric.res.android.ResTable;
+import org.robolectric.res.android.ResTable.ResourceName;
+import org.robolectric.res.android.ResTable.bag_entry;
+import org.robolectric.res.android.ResTableTheme;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.android.ResXMLParser;
+import org.robolectric.res.android.ResXMLTree;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+import org.robolectric.res.android.String8;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowAssetManager.Picker;
+
+// native method impls transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/jni/android_util_AssetManager.cpp
+@Implements(value = AssetManager.class, maxSdk = VERSION_CODES.O_MR1, shadowPicker = Picker.class)
+@SuppressWarnings("NewApi")
+public class ShadowArscAssetManager extends ShadowAssetManager.ArscBase {
+
+  private static final int STYLE_NUM_ENTRIES = 6;
+  private static final int STYLE_TYPE = 0;
+  private static final int STYLE_DATA = 1;
+  private static final int STYLE_ASSET_COOKIE = 2;
+  private static final int STYLE_RESOURCE_ID = 3;
+  private static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+  private static final int STYLE_DENSITY = 5;
+
+  @RealObject
+  protected AssetManager realObject;
+
+  private CppAssetManager cppAssetManager;
+
+  @Resetter
+  public static void reset() {
+    // todo: ShadowPicker doesn't discriminate properly between concrete shadow classes for resetters...
+    if (!useLegacy() && RuntimeEnvironment.getApiLevel() < P) {
+      reflector(_AssetManager_.class).setSystem(null);
+      // NATIVE_THEME_REGISTRY.clear();
+      // nativeXMLParserRegistry.clear(); // todo: shouldn't these be freed explicitly? [yes! xw]
+      // NATIVE_ASSET_REGISTRY.clear();
+    }
+  }
+
+  @Implementation
+  protected final String[] list(String path) throws IOException {
+    CppAssetManager am = assetManagerForJavaObject();
+
+    String fileName8 = path;
+    if (fileName8 == null) {
+      return null;
+    }
+
+    AssetDir dir = am.openDir(fileName8);
+
+    if (dir == null) {
+      throw new FileNotFoundException(fileName8);
+    }
+
+
+    int N = dir.getFileCount();
+
+    String[] array = new String[dir.getFileCount()];
+
+    for (int i=0; i<N; i++) {
+      String8 name = dir.getFileName(i);
+      array[i] = name.string();
+    }
+
+    return array;
+  }
+
+  // @HiddenApi @Implementation(minSdk = VERSION_CODES.P)
+  // public void setApkAssets(Object apkAssetsObjects, Object invalidateCaches) {
+  //   throw new UnsupportedOperationException("implement me");
+  // }
+
+  @HiddenApi @Implementation(maxSdk = N_MR1)
+  final public void setConfiguration(int mcc, int mnc, String locale,
+      int orientation, int touchscreen, int density, int keyboard,
+      int keyboardHidden, int navigation, int screenWidth, int screenHeight,
+      int smallestScreenWidthDp, int screenWidthDp, int screenHeightDp,
+      int screenLayout, int uiMode, int sdkVersion) {
+    setConfiguration(mcc, mnc, locale,
+        orientation, touchscreen, density, keyboard,
+        keyboardHidden, navigation, screenWidth, screenHeight,
+        smallestScreenWidthDp, screenWidthDp, screenHeightDp,
+        screenLayout, uiMode, 0, sdkVersion);
+  }
+
+  @HiddenApi @Implementation(minSdk = O)
+  public void setConfiguration(int mcc, int mnc, String locale,
+      int orientation, int touchscreen, int density, int keyboard,
+      int keyboardHidden, int navigation, int screenWidth, int screenHeight,
+      int smallestScreenWidthDp, int screenWidthDp, int screenHeightDp,
+      int screenLayout, int uiMode, int colorMode, int sdkVersion) {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return;
+    }
+
+    ResTable_config config = new ResTable_config();
+//    memset(&config, 0, sizeof(config));
+
+//    const char* locale8 = locale != NULL ? env->GetStringUTFChars(locale, NULL) : NULL;
+
+    // Constants duplicated from Java class android.content.res.Configuration.
+    int kScreenLayoutRoundMask = 0x300;
+    int kScreenLayoutRoundShift = 8;
+
+    config.mcc = mcc;
+    config.mnc = mnc;
+    config.orientation = orientation;
+    config.touchscreen = touchscreen;
+    config.density = density;
+    config.keyboard = keyboard;
+    config.inputFlags = keyboardHidden;
+    config.navigation = navigation;
+    config.screenWidth = screenWidth;
+    config.screenHeight = screenHeight;
+    config.smallestScreenWidthDp = smallestScreenWidthDp;
+    config.screenWidthDp = screenWidthDp;
+    config.screenHeightDp = screenHeightDp;
+    config.screenLayout = screenLayout;
+    config.uiMode = uiMode;
+    config.colorMode = (byte) colorMode;
+    config.sdkVersion = sdkVersion;
+    config.minorVersion = 0;
+
+    // In Java, we use a 32bit integer for screenLayout, while we only use an 8bit integer
+    // in C++. We must extract the round qualifier out of the Java screenLayout and put it
+    // into screenLayout2.
+    config.screenLayout2 =
+        (byte) ((screenLayout & kScreenLayoutRoundMask) >> kScreenLayoutRoundShift);
+
+    am.setConfiguration(config, locale);
+
+//    if (locale != null) env->ReleaseStringUTFChars(locale, locale8);
+  }
+
+  @HiddenApi @Implementation
+  protected static void dumpTheme(long theme, int priority, String tag, String prefix) {
+    throw new UnsupportedOperationException("not yet implemented");
+  }
+
+  @Implementation
+  protected String getResourceName(int resid) {
+    CppAssetManager am = assetManagerForJavaObject();
+
+    ResourceName name = new ResourceName();
+    if (!am.getResources().getResourceName(resid, true, name)) {
+      return null;
+    }
+
+    StringBuilder str = new StringBuilder();
+    if (name.packageName != null) {
+      str.append(name.packageName.trim());
+    }
+    if (name.type != null) {
+      if (str.length() > 0) {
+        char div = ':';
+        str.append(div);
+      }
+      str.append(name.type);
+    }
+    if (name.name != null) {
+      if (str.length() > 0) {
+        char div = '/';
+        str.append(div);
+      }
+      str.append(name.name);
+    }
+    return str.toString();
+  }
+
+  @Implementation
+  protected String getResourcePackageName(int resid) {
+    CppAssetManager cppAssetManager = assetManagerForJavaObject();
+
+    ResourceName name = new ResourceName();
+    if (!cppAssetManager.getResources().getResourceName(resid, true, name)) {
+      return null;
+    }
+
+    return name.packageName.trim();
+  }
+
+  @Implementation
+  protected String getResourceTypeName(int resid) {
+    CppAssetManager cppAssetManager = assetManagerForJavaObject();
+
+    ResourceName name = new ResourceName();
+    if (!cppAssetManager.getResources().getResourceName(resid, true, name)) {
+      return null;
+    }
+
+    return name.type;
+  }
+
+  @Implementation
+  protected String getResourceEntryName(int resid) {
+    CppAssetManager cppAssetManager = assetManagerForJavaObject();
+
+    ResourceName name = new ResourceName();
+    if (!cppAssetManager.getResources().getResourceName(resid, true, name)) {
+      return null;
+    }
+
+    return name.name;
+  }
+
+  //////////// native method implementations
+
+//  public native final String[] list(String path)
+//      throws IOException;
+
+//  @HiddenApi @Implementation(minSdk = VERSION_CODES.P)
+//  public void setApkAssets(Object apkAssetsObjects, Object invalidateCaches) {
+//    throw new UnsupportedOperationException("implement me");
+//  }
+//
+
+  @HiddenApi @Implementation(maxSdk = VERSION_CODES.JELLY_BEAN_MR1)
+  public int addAssetPath(String path) {
+    return addAssetPathNative(path);
+  }
+
+  @HiddenApi @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = M)
+  final protected int addAssetPathNative(String path) {
+    return addAssetPathNative(path, false);
+  }
+
+  @HiddenApi @Implementation(minSdk = VERSION_CODES.N)
+  protected int addAssetPathNative(String path, boolean appAsLib) {
+    if (Strings.isNullOrEmpty(path)) {
+      return 0;
+    }
+
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0;
+    }
+    final Ref<Integer> cookie = new Ref<>(null);
+    boolean res = am.addAssetPath(new String8(path), cookie, appAsLib);
+    return (res) ? cookie.get() : 0;
+  }
+
+  @HiddenApi @Implementation
+  public int getResourceIdentifier(String name, String defType, String defPackage) {
+    if (Strings.isNullOrEmpty(name)) {
+      return 0;
+    }
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0;
+    }
+
+    int ident = am.getResources().identifierForName(name, defType, defPackage);
+
+    return ident;
+  }
+
+  @HiddenApi @Implementation
+  protected final Number openAsset(String fileName, int mode) throws FileNotFoundException {
+    CppAssetManager am = assetManagerForJavaObject();
+
+    ALOGV("openAsset in %s", am);
+
+    String fileName8 = fileName;
+    if (fileName8 == null) {
+      throw new IllegalArgumentException("Empty file name");
+    }
+
+    if (mode != AccessMode.ACCESS_UNKNOWN.mode() && mode != AccessMode.ACCESS_RANDOM.mode()
+        && mode != AccessMode.ACCESS_STREAMING.mode() && mode != AccessMode.ACCESS_BUFFER.mode()) {
+      throw new IllegalArgumentException("Bad access mode");
+    }
+
+    Asset a = am.open(fileName8, AccessMode.fromInt(mode));
+
+    if (a == null) {
+      throw new FileNotFoundException(fileName8);
+    }
+
+    //printf("Created Asset Stream: %p\n", a);
+
+    return RuntimeEnvironment.castNativePtr(Registries.NATIVE_ASSET_REGISTRY.register(a));
+  }
+
+  @HiddenApi @Implementation
+  protected ParcelFileDescriptor openAssetFd(String fileName, long[] outOffsets) throws IOException {
+    CppAssetManager am = assetManagerForJavaObject();
+
+    ALOGV("openAssetFd in %s", am);
+
+    String fileName8 = fileName;
+    if (fileName8 == null) {
+      return null;
+    }
+
+    Asset a = am.open(fileName8, Asset.AccessMode.ACCESS_RANDOM);
+
+    if (a == null) {
+      throw new FileNotFoundException(fileName8);
+    }
+
+    return returnParcelFileDescriptor(a, outOffsets);
+  }
+
+  @HiddenApi @Implementation
+  protected final Number openNonAssetNative(int cookie, String fileName,
+      int accessMode) throws FileNotFoundException {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return RuntimeEnvironment.castNativePtr(0);
+    }
+    ALOGV("openNonAssetNative in %s (Java object %s)\n", am, AssetManager.class);
+    String fileName8 = fileName;
+    if (fileName8 == null) {
+      return RuntimeEnvironment.castNativePtr(-1);
+    }
+    AccessMode mode = AccessMode.fromInt(accessMode);
+    if (mode != Asset.AccessMode.ACCESS_UNKNOWN && mode != Asset.AccessMode.ACCESS_RANDOM
+        && mode != Asset.AccessMode.ACCESS_STREAMING && mode != Asset.AccessMode.ACCESS_BUFFER) {
+      throw new IllegalArgumentException("Bad access mode");
+    }
+    Asset a = isTruthy(cookie)
+        ? am.openNonAsset(cookie, fileName8, mode)
+        : am.openNonAsset(fileName8, mode, null);
+    if (a == null) {
+      throw new FileNotFoundException(fileName8);
+    }
+    long assetId = Registries.NATIVE_ASSET_REGISTRY.register(a);
+    // todo: something better than this [xw]
+    a.onClose = () -> destroyAsset(assetId);
+    //printf("Created Asset Stream: %p\n", a);
+    return RuntimeEnvironment.castNativePtr(assetId);
+  }
+
+  @HiddenApi @Implementation
+  protected ParcelFileDescriptor openNonAssetFdNative(int cookie,
+      String fileName, long[] outOffsets) throws IOException {
+    CppAssetManager am = assetManagerForJavaObject();
+
+    ALOGV("openNonAssetFd in %s (Java object %s)", am, this);
+
+    if (fileName == null) {
+      return null;
+    }
+
+    Asset a = isTruthy(cookie)
+        ? am.openNonAsset(cookie, fileName, Asset.AccessMode.ACCESS_RANDOM)
+        : am.openNonAsset(fileName, Asset.AccessMode.ACCESS_RANDOM, null);
+
+    if (a == null) {
+      throw new FileNotFoundException(fileName);
+    }
+
+    //printf("Created Asset Stream: %p\n", a);
+
+    return returnParcelFileDescriptor(a, outOffsets);
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final void destroyAsset(int asset) {
+    destroyAsset((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final void destroyAsset(long asset) {
+    Registries.NATIVE_ASSET_REGISTRY.unregister(asset);
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final int readAssetChar(int asset) {
+    return readAssetChar((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final int readAssetChar(long asset) {
+    Asset a = getAsset(asset);
+    byte[] b = new byte[1];
+    int res = a.read(b, 1);
+    return res == 1 ? b[0] & 0xff : -1;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final int readAsset(int asset, byte[] b, int off, int len) throws IOException {
+    return readAsset((long) asset, b, off, len);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final int readAsset(long asset, byte[] bArray, int off, int len) throws IOException {
+    Asset a = getAsset(asset);
+
+    if (a == null || bArray == null) {
+      throw new NullPointerException("asset");
+    }
+
+    if (len == 0) {
+      return 0;
+    }
+
+    int bLen = bArray.length;
+    if (off < 0 || off >= bLen || len < 0 || len > bLen || (off+len) > bLen) {
+      throw new IndexOutOfBoundsException();
+    }
+
+    byte[] b = bArray;
+    int res = a.read(b, off, len);
+
+    if (res > 0) return res;
+
+    if (res < 0) {
+      throw new IOException();
+    }
+    return -1;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final long seekAsset(int asset, long offset, int whence) {
+    return seekAsset((long) asset, offset, whence);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final long seekAsset(long asset, long offset, int whence) {
+    Asset a = getAsset(asset);
+    return a.seek(offset, whence < 0 ? SEEK_SET : SEEK_CUR);
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final long getAssetLength(int asset) {
+    return getAssetLength((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final long getAssetLength(long asset) {
+    Asset a = getAsset(asset);
+    return a.getLength();
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final long getAssetRemainingLength(int asset) {
+    return getAssetRemainingLength((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final long getAssetRemainingLength(long assetHandle) {
+    Asset a = getAsset(assetHandle);
+
+    if (a == null) {
+      throw new NullPointerException("asset");
+    }
+
+    return a.getRemainingLength();
+  }
+
+  private Asset getAsset(long asset) {
+    return Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset);
+  }
+
+  @HiddenApi @Implementation
+  protected int loadResourceValue(int ident, short density, TypedValue outValue, boolean resolve) {
+    if (outValue == null) {
+      throw new NullPointerException("outValue");
+      //return 0;
+    }
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0;
+    }
+    final ResTable res = am.getResources();
+
+    final Ref<Res_value> value = new Ref<>(null);
+    final Ref<ResTable_config> config = new Ref<>(null);
+    final Ref<Integer> typeSpecFlags = new Ref<>(null);
+    int block = res.getResource(ident, value, false, density, typeSpecFlags, config);
+    if (kThrowOnBadId) {
+        if (block == BAD_INDEX) {
+            throw new IllegalStateException("Bad resource!");
+            //return 0;
+        }
+    }
+    final Ref<Integer> ref = new Ref<>(ident);
+    if (resolve) {
+        block = res.resolveReference(value, block, ref, typeSpecFlags, config);
+        if (kThrowOnBadId) {
+            if (block == BAD_INDEX) {
+              throw new IllegalStateException("Bad resource!");
+                //return 0;
+            }
+        }
+    }
+    if (block >= 0) {
+        //return copyValue(env, outValue, &res, value, ref, block, typeSpecFlags, &config);
+      return copyValue(outValue, res, value.get(), ref.get(), block, typeSpecFlags.get(),
+          config.get());
+
+    }
+    return block;
+}
+
+  private static int copyValue(TypedValue outValue, ResTable table,  Res_value value, int ref, int block,
+      int typeSpecFlags) {
+    return copyValue(outValue, table, value, ref, block, typeSpecFlags, null);
+  }
+
+  private static int copyValue(TypedValue outValue, ResTable table,  Res_value value, int ref, int block,
+      int typeSpecFlags, ResTable_config config) {
+    outValue.type = value.dataType;
+    outValue.assetCookie = table.getTableCookie(block);
+    outValue.data = value.data;
+    outValue.string = null;
+    outValue.resourceId = ref;
+    outValue.changingConfigurations = typeSpecFlags;
+
+    if (config != null) {
+      outValue.density = config.density;
+    }
+    return block;
+  }
+
+  public static Map<String, Integer> getResourceBagValues(int ident, ResTable res) {
+    // Now lock down the resource object and start pulling stuff from it.
+    res.lock();
+
+    HashMap<String, Integer> map;
+    try {
+      final Ref<bag_entry[]> entryRef = new Ref<>(null);
+      final Ref<Integer> typeSpecFlags = new Ref<>(0);
+      int entryCount = res.getBagLocked(ident, entryRef, typeSpecFlags);
+
+      map = new HashMap<>();
+      bag_entry[] bag_entries = entryRef.get();
+      for (int i=0; i < entryCount; i++) {
+        bag_entry entry = bag_entries[i];
+        ResourceName resourceName = new ResourceName();
+        if (res.getResourceName(entry.map.name.ident, true, resourceName)) {
+          map.put(resourceName.name, entry.map.value.data);
+        }
+      }
+    } finally {
+      res.unlock();
+    }
+
+    return map;
+  }
+
+  /**
+   * Returns true if the resource was found, filling in mRetStringBlock and
+   * mRetData.
+   */
+  @Implementation @HiddenApi
+  protected final int loadResourceBagValue(int ident, int bagEntryId, TypedValue outValue,
+      boolean resolve) {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0;
+    }
+    final ResTable res = am.getResources();
+    return loadResourceBagValueInternal(ident, bagEntryId, outValue, resolve, res);
+  }
+
+  public static String getResourceBagValue(int ident, int bagEntryId, ResTable resTable) {
+    TypedValue outValue = new TypedValue();
+    int blockId = ShadowArscAssetManager
+        .loadResourceBagValueInternal(ident, bagEntryId, outValue, true, resTable);
+    if (outValue.type == TypedValue.TYPE_STRING) {
+      return resTable.getTableStringBlock(blockId).stringAt(outValue.data);
+    } else {
+      return outValue.coerceToString().toString();
+    }
+  }
+
+  private static int loadResourceBagValueInternal(int ident, int bagEntryId, TypedValue outValue,
+      boolean resolve, ResTable res) {
+    // Now lock down the resource object and start pulling stuff from it.
+    res.lock();
+
+    int block = -1;
+    final Ref<Res_value> valueRef = new Ref<>(null);
+    final Ref<bag_entry[]> entryRef = new Ref<>(null);
+    final Ref<Integer> typeSpecFlags = new Ref<>(0);
+    int entryCount = res.getBagLocked(ident, entryRef, typeSpecFlags);
+
+    bag_entry[] bag_entries = entryRef.get();
+    for (int i=0; i < entryCount; i++) {
+      bag_entry entry = bag_entries[i];
+      if (bagEntryId == entry.map.name.ident) {
+        block = entry.stringBlock;
+        valueRef.set(entry.map.value);
+      }
+    }
+
+    res.unlock();
+
+    if (block < 0) {
+      return block;
+    }
+
+    final Ref<Integer> ref = new Ref<>(ident);
+    if (resolve) {
+      block = res.resolveReference(valueRef, block, ref, typeSpecFlags);
+      if (kThrowOnBadId) {
+        if (block == BAD_INDEX) {
+          throw new IllegalStateException("Bad resource!");
+        }
+      }
+    }
+    if (block >= 0) {
+      return copyValue(outValue, res, valueRef.get(), ref.get(), block, typeSpecFlags.get());
+    }
+
+    return block;
+  }
+
+  // /*package*/ static final int STYLE_NUM_ENTRIES = 6;
+  // /*package*/ static final int STYLE_TYPE = 0;
+  // /*package*/ static final int STYLE_DATA = 1;
+  // /*package*/ static final int STYLE_ASSET_COOKIE = 2;
+  // /*package*/ static final int STYLE_RESOURCE_ID = 3;
+  //
+  // /* Offset within typed data array for native changingConfigurations. */
+  // static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+
+  // /*package*/ static final int STYLE_DENSITY = 5;
+
+/* lowercase hexadecimal notation.  */
+//# define PRIx8		"x"
+//      # define PRIx16		"x"
+//      # define PRIx32		"x"
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void applyStyle(int themeToken, int defStyleAttr, int defStyleRes,
+      int xmlParserToken, int[] attrs, int[] outValues, int[] outIndices) {
+    applyStyle((long)themeToken, defStyleAttr, defStyleRes, (long)xmlParserToken, attrs,
+        outValues, outIndices);
+  }
+
+  @HiddenApi @Implementation(minSdk = O, maxSdk = O_MR1)
+  protected static void applyStyle(long themeToken, int defStyleAttr, int defStyleRes,
+      long xmlParserToken, int[] inAttrs, int length, long outValuesAddress,
+      long outIndicesAddress) {
+    ShadowVMRuntime shadowVMRuntime = Shadow.extract(VMRuntime.getRuntime());
+    int[] outValues = (int[])shadowVMRuntime.getObjectForAddress(outValuesAddress);
+    int[] outIndices = (int[])shadowVMRuntime.getObjectForAddress(outIndicesAddress);
+    applyStyle(themeToken, defStyleAttr, defStyleRes, xmlParserToken, inAttrs,
+        outValues, outIndices);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected static void applyStyle(long themeToken, int defStyleAttr, int defStyleRes,
+      long xmlParserToken, int[] attrs, int[] outValues, int[] outIndices) {
+    ResTableTheme theme = Registries.NATIVE_THEME_REGISTRY.getNativeObject(themeToken);
+    ResXMLParser xmlParser = xmlParserToken == 0
+        ? null
+        : Registries.NATIVE_RES_XML_PARSERS.getNativeObject(xmlParserToken);
+    AttributeResolution.ApplyStyle(theme, xmlParser, defStyleAttr, defStyleRes,
+        attrs, attrs.length, outValues, outIndices);
+  }
+
+  @Implementation @HiddenApi
+  protected static boolean resolveAttrs(long themeToken,
+      int defStyleAttr, int defStyleRes, int[] inValues,
+      int[] attrs, int[] outValues, int[] outIndices) {
+    if (themeToken == 0) {
+      throw new NullPointerException("theme token");
+    }
+    if (attrs == null) {
+      throw new NullPointerException("attrs");
+    }
+    if (outValues == null) {
+      throw new NullPointerException("out values");
+    }
+
+    final int NI = attrs.length;
+    final int NV = outValues.length;
+    if (NV < (NI*STYLE_NUM_ENTRIES)) {
+      throw new IndexOutOfBoundsException("out values too small");
+    }
+
+    int[] src = attrs;
+//    if (src == null) {
+//      return JNI_FALSE;
+//    }
+
+    int[] srcValues = inValues;
+    final int NSV = srcValues == null ? 0 : inValues.length;
+
+    int[] baseDest = outValues;
+    int destOffset = 0;
+    if (baseDest == null) {
+      return false;
+    }
+
+    int[] indices = null;
+    if (outIndices != null) {
+      if (outIndices.length > NI) {
+        indices = outIndices;
+      }
+    }
+
+    ResTableTheme theme = Registries.NATIVE_THEME_REGISTRY.getNativeObject(themeToken);
+
+    boolean result = AttributeResolution.ResolveAttrs(theme, defStyleAttr, defStyleRes,
+        srcValues, NSV,
+        src, NI,
+        baseDest,
+        indices);
+
+    if (indices != null) {
+//      env.ReleasePrimitiveArrayCritical(outIndices, indices, 0);
+    }
+//    env.ReleasePrimitiveArrayCritical(outValues, baseDest, 0);
+//    env.ReleasePrimitiveArrayCritical(inValues, srcValues, 0);
+//    env.ReleasePrimitiveArrayCritical(attrs, src, 0);
+
+    return result;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final boolean retrieveAttributes(
+      int xmlParserToken, int[] attrs, int[] outValues, int[] outIndices) {
+    return retrieveAttributes((long)xmlParserToken, attrs, outValues, outIndices);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final boolean retrieveAttributes(
+      long xmlParserToken, int[] attrs, int[] outValues, int[] outIndices) {
+    if (xmlParserToken == 0) {
+      throw new NullPointerException("xmlParserToken");
+//      return JNI_FALSE;
+    }
+    if (attrs == null) {
+      throw new NullPointerException("attrs");
+//      return JNI_FALSE;
+    }
+    if (outValues == null) {
+      throw new NullPointerException("out values");
+//      return JNI_FALSE;
+    }
+
+    CppAssetManager am = assetManagerForJavaObject();
+//    if (am == null) {
+//      return JNI_FALSE;
+//    }
+    ResTable res = am.getResources();
+//    ResXMLParser xmlParser = (ResXMLParser*)xmlParserToken;
+    ResXMLParser xmlParser = Registries.NATIVE_RES_XML_PARSERS.getNativeObject(xmlParserToken);
+
+//    const int NI = env.GetArrayLength(attrs);
+//    const int NV = env.GetArrayLength(outValues);
+    final int NI = attrs.length;
+    final int NV = outValues.length;
+    if (NV < (NI*STYLE_NUM_ENTRIES)) {
+      throw new IndexOutOfBoundsException("out values too small");
+//      return JNI_FALSE;
+    }
+
+//    int[] src = (int[])env.GetPrimitiveArrayCritical(attrs, 0);
+//    if (src == null) {
+//      return JNI_FALSE;
+//    }
+    int[] src = attrs;
+
+//    int[] baseDest = (int[])env.GetPrimitiveArrayCritical(outValues, 0);
+    int[] baseDest = outValues;
+    if (baseDest == null) {
+//      env.ReleasePrimitiveArrayCritical(attrs, src, 0);
+//      return JNI_FALSE;
+      return false;
+    }
+
+    int[] indices = null;
+    if (outIndices != null) {
+      if (outIndices.length > NI) {
+//        indices = (int[])env.GetPrimitiveArrayCritical(outIndices, 0);
+        indices = outIndices;
+      }
+    }
+    boolean result = AttributeResolution.RetrieveAttributes(res, xmlParser, src, NI, baseDest, indices);
+
+    if (indices != null) {
+//      indices[0] = indicesIdx;
+//      env.ReleasePrimitiveArrayCritical(outIndices, indices, 0);
+    }
+
+//    env.ReleasePrimitiveArrayCritical(outValues, baseDest, 0);
+//    env.ReleasePrimitiveArrayCritical(attrs, src, 0);
+
+    return result;
+  }
+
+  @HiddenApi @Implementation
+  protected int getArraySize(int id) {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0;
+    }
+    final ResTable res = am.getResources();
+
+    res.lock();
+    final Ref<bag_entry[]> defStyleEnt = new Ref<>(null);
+    int bagOff = res.getBagLocked(id, defStyleEnt, null);
+    res.unlock();
+
+    return bagOff;
+
+  }
+
+  @Implementation @HiddenApi
+  protected int retrieveArray(int id, int[] outValues) {
+    if (outValues == null) {
+      throw new NullPointerException("out values");
+    }
+
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0 /*JNI_FALSE */;
+    }
+    ResTable res = am.getResources();
+    final Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+    Res_value value;
+    int block;
+
+    int NV = outValues.length;
+
+//    int[] baseDest = (int[])env->GetPrimitiveArrayCritical(outValues, 0);
+    int[] baseDest = outValues;
+    int[] dest = baseDest;
+//    if (dest == null) {
+//      throw new NullPointerException(env, "java/lang/OutOfMemoryError", "");
+//      return JNI_FALSE;
+//    }
+
+    // Now lock down the resource object and start pulling stuff from it.
+    res.lock();
+
+    final Ref<bag_entry[]> arrayEnt = new Ref<>(null);
+    final Ref<Integer> arrayTypeSetFlags = new Ref<>(0);
+    int bagOff = res.getBagLocked(id, arrayEnt, arrayTypeSetFlags);
+//    final ResTable::bag_entry* endArrayEnt = arrayEnt +
+//        (bagOff >= 0 ? bagOff : 0);
+
+    int destOffset = 0;
+    final Ref<Integer> typeSetFlags = new Ref<>(0);
+    while (destOffset < NV && destOffset < bagOff * STYLE_NUM_ENTRIES /*&& arrayEnt < endArrayEnt*/) {
+      bag_entry curArrayEnt = arrayEnt.get()[destOffset / STYLE_NUM_ENTRIES];
+
+      block = curArrayEnt.stringBlock;
+      typeSetFlags.set(arrayTypeSetFlags.get());
+      config.get().density = 0;
+      value = curArrayEnt.map.value;
+
+      final Ref<Integer> resid = new Ref<>(0);
+      if (value.dataType != DataType.NULL.code()) {
+        // Take care of resolving the found resource to its final value.
+        //printf("Resolving attribute reference\n");
+        final Ref<Res_value> resValueRef = new Ref<>(value);
+        int newBlock = res.resolveReference(resValueRef, block, resid,
+                    typeSetFlags, config);
+        value = resValueRef.get();
+        if (kThrowOnBadId) {
+          if (newBlock == BAD_INDEX) {
+            throw new IllegalStateException("Bad resource!");
+          }
+        }
+        if (newBlock >= 0) block = newBlock;
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.dataType == DataType.REFERENCE.code() && value.data == 0) {
+        value = Res_value.NULL_VALUE;
+      }
+
+      //printf("Attribute 0x%08x: final type=0x%x, data=0x%08x\n", curIdent, value.dataType, value.data);
+
+      // Write the final value back to Java.
+      dest[destOffset + STYLE_TYPE] = value.dataType;
+      dest[destOffset + STYLE_DATA] = value.data;
+      dest[destOffset + STYLE_ASSET_COOKIE] = res.getTableCookie(block);
+      dest[destOffset + STYLE_RESOURCE_ID] = resid.get();
+      dest[destOffset + STYLE_CHANGING_CONFIGURATIONS] = typeSetFlags.get();
+      dest[destOffset + STYLE_DENSITY] = config.get().density;
+//      dest += STYLE_NUM_ENTRIES;
+      destOffset+= STYLE_NUM_ENTRIES;
+//      arrayEnt++;
+    }
+
+    destOffset /= STYLE_NUM_ENTRIES;
+
+    res.unlock();
+
+//    env->ReleasePrimitiveArrayCritical(outValues, baseDest, 0);
+
+    return destOffset;
+
+  }
+
+  @HiddenApi @Implementation
+  protected Number getNativeStringBlock(int block) {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return RuntimeEnvironment.castNativePtr(0);
+    }
+
+    return RuntimeEnvironment.castNativePtr(
+        am.getResources().getTableStringBlock(block).getNativePtr());
+  }
+
+  @Implementation
+  protected final SparseArray<String> getAssignedPackageIdentifiers() {
+    CppAssetManager am = assetManagerForJavaObject();
+    final ResTable res = am.getResources();
+
+    SparseArray<String> sparseArray = new SparseArray<>();
+    final int N = res.getBasePackageCount();
+    for (int i = 0; i < N; i++) {
+      final String name = res.getBasePackageName(i);
+      sparseArray.put(res.getBasePackageId(i), name);
+    }
+    return sparseArray;
+  }
+
+  @HiddenApi @Implementation
+  protected final Number newTheme() {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return RuntimeEnvironment.castNativePtr(0);
+    }
+    ResTableTheme theme = new ResTableTheme(am.getResources());
+    return RuntimeEnvironment.castNativePtr(Registries.NATIVE_THEME_REGISTRY.register(theme));
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final void deleteTheme(int theme) {
+    deleteTheme((long) theme);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected final void deleteTheme(long theme) {
+    Registries.NATIVE_THEME_REGISTRY.unregister(theme);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void applyThemeStyle(int themePtr, int styleRes, boolean force) {
+    applyThemeStyle((long)themePtr, styleRes, force);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  public static void applyThemeStyle(long themePtr, int styleRes, boolean force) {
+    Registries.NATIVE_THEME_REGISTRY.getNativeObject(themePtr).applyStyle(styleRes, force);
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  public static void copyTheme(int destPtr, int sourcePtr) {
+    copyTheme((long) destPtr, (long) sourcePtr);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  public static void copyTheme(long destPtr, long sourcePtr) {
+    ResTableTheme dest = Registries.NATIVE_THEME_REGISTRY.getNativeObject(destPtr);
+    ResTableTheme src = Registries.NATIVE_THEME_REGISTRY.getNativeObject(sourcePtr);
+    dest.setTo(src);
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int loadThemeAttributeValue(int themeHandle, int ident,
+      TypedValue outValue, boolean resolve) {
+    return loadThemeAttributeValue((long) themeHandle, ident, outValue, resolve);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  protected static int loadThemeAttributeValue(long themeHandle, int ident,
+      TypedValue outValue, boolean resolve) {
+    ResTableTheme theme = Preconditions.checkNotNull(Registries.NATIVE_THEME_REGISTRY.getNativeObject(themeHandle));
+    ResTable res = theme.getResTable();
+
+    final Ref<Res_value> value = new Ref<>(null);
+    // XXX value could be different in different configs!
+    final Ref<Integer> typeSpecFlags = new Ref<>(0);
+    int block = theme.GetAttribute(ident, value, typeSpecFlags);
+    final Ref<Integer> ref = new Ref<>(0);
+    if (resolve) {
+      block = res.resolveReference(value, block, ref, typeSpecFlags);
+      if (kThrowOnBadId) {
+        if (block == BAD_INDEX) {
+          throw new IllegalStateException("Bad resource!");
+        }
+      }
+    }
+    return block >= 0 ? copyValue(outValue, res, value.get(), ref.get(), block, typeSpecFlags.get(), null) : block;
+  }
+
+//  /*package*/@HiddenApi @Implementation public static final @NativeConfig
+//  int getThemeChangingConfigurations(long theme);
+
+  @HiddenApi @Implementation
+  protected final Number openXmlAssetNative(int cookie, String fileName) throws FileNotFoundException {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return RuntimeEnvironment.castNativePtr(0);
+    }
+
+    ALOGV("openXmlAsset in %s (Java object %s)\n", am, ShadowArscAssetManager.class);
+
+    String fileName8 = fileName;
+    if (fileName8 == null) {
+      return RuntimeEnvironment.castNativePtr(0);
+    }
+
+    int assetCookie = cookie;
+    Asset a;
+    if (isTruthy(assetCookie)) {
+      a = am.openNonAsset(assetCookie, fileName8, AccessMode.ACCESS_BUFFER);
+    } else {
+      final Ref<Integer> assetCookieRef = new Ref<>(assetCookie);
+      a = am.openNonAsset(fileName8, AccessMode.ACCESS_BUFFER, assetCookieRef);
+      assetCookie = assetCookieRef.get();
+    }
+
+    if (a == null) {
+      throw new FileNotFoundException(fileName8);
+    }
+
+    final DynamicRefTable dynamicRefTable =
+        am.getResources().getDynamicRefTableForCookie(assetCookie);
+    ResXMLTree block = new ResXMLTree(dynamicRefTable);
+    int err = block.setTo(a.getBuffer(true), (int) a.getLength(), true);
+    a.close();
+//    delete a;
+
+    if (err != NO_ERROR) {
+      throw new FileNotFoundException("Corrupt XML binary file");
+    }
+
+    return RuntimeEnvironment.castNativePtr(
+        Registries.NATIVE_RES_XML_TREES.register(block));
+  }
+
+  @HiddenApi @Implementation
+  protected final String[] getArrayStringResource(int arrayResId) {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return null;
+    }
+    final ResTable res = am.getResources();
+
+    final Ref<bag_entry[]> startOfBag = new Ref<>(null);
+    final int N = res.lockBag(arrayResId, startOfBag);
+    if (N < 0) {
+      return null;
+    }
+
+    String[] array = new String[N];
+
+    final Ref<Res_value> valueRef = new Ref<>(null);
+    final bag_entry[] bag = startOfBag.get();
+    int strLen = 0;
+    for (int i=0; ((int)i)<N; i++) {
+      valueRef.set(bag[i].map.value);
+      String str = null;
+
+      // Take care of resolving the found resource to its final value.
+      int block = res.resolveReference(valueRef, bag[i].stringBlock, null);
+      if (kThrowOnBadId) {
+        if (block == BAD_INDEX) {
+          throw new IllegalStateException("Bad resource!");
+        }
+      }
+      if (valueRef.get().dataType == DataType.STRING.code()) {
+            final ResStringPool pool = res.getTableStringBlock(block);
+            str = pool.stringAt(valueRef.get().data);
+
+            // assume we can skip utf8 vs utf 16 handling
+
+//            final char* str8 = pool.string8At(value.data, &strLen);
+//        if (str8 != NULL) {
+//          str = env.NewStringUTF(str8);
+//        } else {
+//                final char16_t* str16 = pool.stringAt(value.data, &strLen);
+//          str = env.NewString(reinterpret_cast<final jchar*>(str16),
+//              strLen);
+//        }
+//
+//        // If one of our NewString{UTF} calls failed due to memory, an
+//        // exception will be pending.
+//        if (env.ExceptionCheck()) {
+//          res.unlockBag(startOfBag);
+//          return NULL;
+//        }
+        if (str == null) {
+          res.unlockBag(startOfBag);
+          return null;
+        }
+
+        array[i] = str;
+
+        // str is not NULL at that point, otherwise ExceptionCheck would have been true.
+        // If we have a large amount of strings in our array, we might
+        // overflow the local reference table of the VM.
+        // env.DeleteLocalRef(str);
+      }
+    }
+    res.unlockBag(startOfBag);
+    return array;
+  }
+
+  @HiddenApi @Implementation
+  protected final int[] getArrayStringInfo(int arrayResId) {
+    CppAssetManager am = assetManagerForJavaObject();
+    ResTable res = am.getResources();
+
+    final Ref<bag_entry[]> startOfBag = new Ref<>(null);
+    final int N = res.lockBag(arrayResId, startOfBag);
+    if (N < 0) {
+      return null;
+    }
+
+    int[] array = new int[N * 2];
+
+    final Ref<Res_value> value = new Ref<>(null);
+    bag_entry[] bag = startOfBag.get();
+    for (int i = 0, j = 0; i<N; i++) {
+      int stringIndex = -1;
+      int stringBlock = 0;
+      value.set(bag[i].map.value);
+
+      // Take care of resolving the found resource to its final value.
+      stringBlock = res.resolveReference(value, bag[i].stringBlock, null);
+      if (value.get().dataType == DataType.STRING.code()) {
+        stringIndex = value.get().data;
+      }
+
+      if (kThrowOnBadId) {
+        if (stringBlock == BAD_INDEX) {
+          throw new IllegalStateException("Bad resource!");
+        }
+      }
+
+      //todo: It might be faster to allocate a C array to contain
+      //      the blocknums and indices, put them in there and then
+      //      do just one SetIntArrayRegion()
+      //env->SetIntArrayRegion(array, j, 1, &stringBlock);
+      array[j] = stringBlock;
+      //env->SetIntArrayRegion(array, j + 1, 1, &stringIndex);
+      array[j+1] = stringIndex;
+      j += 2;
+    }
+    res.unlockBag(startOfBag);
+    return array;
+  }
+
+  @HiddenApi @Implementation
+  public int[] getArrayIntResource(int arrayResId) {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return null;
+    }
+    final ResTable res = am.getResources();
+
+//    final ResTable::bag_entry* startOfBag;
+    final Ref<bag_entry[]> startOfBag = new Ref<>(null);
+    final int N = res.lockBag(arrayResId, startOfBag);
+    if (N < 0) {
+      return null;
+    }
+
+    int[] array = new int[N];
+    if (array == null) {
+      res.unlockBag(startOfBag);
+      return null;
+    }
+
+    final Ref<Res_value> valueRef = new Ref<>(null);
+    bag_entry[] bag = startOfBag.get();
+    for (int i=0; i<N; i++) {
+      valueRef.set(bag[i].map.value);
+
+      // Take care of resolving the found resource to its final value.
+      int block = res.resolveReference(valueRef, bag[i].stringBlock, null, null, null);
+      if (kThrowOnBadId) {
+        if (block == BAD_INDEX) {
+          res.unlockBag(startOfBag); // seems like this is missing from android_util_AssetManager.cpp?
+          throw new IllegalStateException("Bad resource!");
+//          return array;
+        }
+      }
+      Res_value value = valueRef.get();
+      if (value.dataType >= DataType.TYPE_FIRST_INT
+          && value.dataType <= DataType.TYPE_LAST_INT) {
+        int intVal = value.data;
+//        env->SetIntArrayRegion(array, i, 1, &intVal);
+        array[i] = intVal;
+      }
+    }
+    res.unlockBag(startOfBag);
+    return array;
+  }
+
+  @HiddenApi @Implementation(maxSdk = VERSION_CODES.KITKAT)
+  protected void init() {
+  //  if (isSystem) {
+  //    verifySystemIdmaps();
+  //  }
+    init(false);
+  }
+
+  private static CppAssetManager systemCppAssetManager;
+
+  @HiddenApi @Implementation(minSdk = VERSION_CODES.KITKAT_WATCH)
+  protected void init(boolean isSystem) {
+    //  if (isSystem) {
+    //    verifySystemIdmaps();
+    //  }
+
+    Path androidFrameworkJarPath = RuntimeEnvironment.getAndroidFrameworkJarPath();
+    Preconditions.checkNotNull(androidFrameworkJarPath);
+
+    if (isSystem) {
+      synchronized (ShadowArscAssetManager.class) {
+        if (systemCppAssetManager == null) {
+          systemCppAssetManager = new CppAssetManager();
+          systemCppAssetManager.addDefaultAssets(androidFrameworkJarPath);
+        }
+      }
+      this.cppAssetManager = systemCppAssetManager;
+    } else {
+      this.cppAssetManager = new CppAssetManager();
+      cppAssetManager.addDefaultAssets(androidFrameworkJarPath);
+    }
+
+    ALOGV("Created AssetManager %s for Java object %s\n", cppAssetManager,
+        ShadowArscAssetManager.class);
+  }
+
+  @VisibleForTesting
+  ResTable_config getConfiguration() {
+    final Ref<ResTable_config> config = new Ref<>(new ResTable_config());
+    assetManagerForJavaObject().getConfiguration(config);
+    return config.get();
+  }
+
+//  private native final void destroy();
+
+//  @HiddenApi
+//  @Implementation
+//  public int addOverlayPathNative(String idmapPath) {
+//    if (Strings.isNullOrEmpty(idmapPath)) {
+//      return 0;
+//    }
+//
+//    CppAssetManager am = assetManagerForJavaObject();
+//    if (am == null) {
+//      return 0;
+//    }
+//    final Ref<Integer> cookie = new Ref<>(null);
+//    boolean res = am.addOverlayPath(new String8(idmapPath), cookie);
+//    return (res) ? cookie.get() : 0;
+//  }
+
+  @HiddenApi @Implementation
+  protected int getStringBlockCount() {
+    CppAssetManager am = assetManagerForJavaObject();
+    if (am == null) {
+      return 0;
+    }
+    return am.getResources().getTableCount();
+  }
+
+  
+  synchronized private CppAssetManager assetManagerForJavaObject() {
+    if (cppAssetManager == null) {
+      throw new NullPointerException();
+    }
+    return cppAssetManager;
+  }
+
+  static ParcelFileDescriptor returnParcelFileDescriptor(Asset a, long[] outOffsets)
+      throws FileNotFoundException {
+    final Ref<Long> startOffset = new Ref<Long>(-1L);
+    final Ref<Long> length = new Ref<Long>(-1L);;
+    FileDescriptor fd = a.openFileDescriptor(startOffset, length);
+
+    if (fd == null) {
+      throw new FileNotFoundException(
+          "This file can not be opened as a file descriptor; it is probably compressed");
+    }
+
+    long[] offsets = outOffsets;
+    if (offsets == null) {
+      // fd.close();
+      return null;
+    }
+
+    offsets[0] = startOffset.get();
+    offsets[1] = length.get();
+
+    // FileDescriptor fileDesc = jniCreateFileDescriptor(fd);
+    // if (fileDesc == null) {
+    // close(fd);
+    // return null;
+    // }
+
+    // TODO: consider doing this
+    // return new ParcelFileDescriptor(fileDesc);
+    return ParcelFileDescriptor.open(a.getFile(), ParcelFileDescriptor.MODE_READ_ONLY);
+  }
+
+  @Override
+  Collection<Path> getAllAssetDirs() {
+    ArrayList<Path> paths = new ArrayList<>();
+    for (AssetPath assetPath : cppAssetManager.getAssetPaths()) {
+      if (Files.isRegularFile(assetPath.file)) {
+        paths.add(Fs.forJar(assetPath.file).getPath("assets"));
+      } else {
+        paths.add(assetPath.file);
+      }
+    }
+    return paths;
+  }
+
+  @Override
+  List<AssetPath> getAssetPaths() {
+    return assetManagerForJavaObject().getAssetPaths();
+  }
+
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager10.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager10.java
new file mode 100644
index 0000000..d52b514
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager10.java
@@ -0,0 +1,1951 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.res.android.ApkAssetsCookie.K_INVALID_COOKIE;
+import static org.robolectric.res.android.ApkAssetsCookie.kInvalidCookie;
+import static org.robolectric.res.android.Asset.SEEK_CUR;
+import static org.robolectric.res.android.Asset.SEEK_END;
+import static org.robolectric.res.android.Asset.SEEK_SET;
+import static org.robolectric.res.android.AttributeResolution10.ApplyStyle;
+import static org.robolectric.res.android.AttributeResolution10.ResolveAttrs;
+import static org.robolectric.res.android.AttributeResolution10.RetrieveAttributes;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Registries.NATIVE_RES_XML_PARSERS;
+import static org.robolectric.res.android.Registries.NATIVE_RES_XML_TREES;
+import static org.robolectric.res.android.Util.ATRACE_NAME;
+import static org.robolectric.res.android.Util.CHECK;
+import static org.robolectric.res.android.Util.JNI_FALSE;
+import static org.robolectric.res.android.Util.JNI_TRUE;
+import static org.robolectric.res.android.Util.isTruthy;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.AnyRes;
+import android.annotation.ArrayRes;
+import android.annotation.AttrRes;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.StyleRes;
+import android.content.res.ApkAssets;
+import android.content.res.AssetManager;
+import android.content.res.Configuration;
+import android.content.res.Configuration.NativeConfig;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.util.ArraySet;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import dalvik.system.VMRuntime;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.Fs;
+import org.robolectric.res.android.ApkAssetsCookie;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.AssetDir;
+import org.robolectric.res.android.AssetPath;
+import org.robolectric.res.android.CppApkAssets;
+import org.robolectric.res.android.CppAssetManager;
+import org.robolectric.res.android.CppAssetManager2;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag;
+import org.robolectric.res.android.CppAssetManager2.ResourceName;
+import org.robolectric.res.android.CppAssetManager2.Theme;
+import org.robolectric.res.android.DynamicRefTable;
+import org.robolectric.res.android.Ref;
+import org.robolectric.res.android.Registries;
+import org.robolectric.res.android.ResStringPool;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.android.ResXMLParser;
+import org.robolectric.res.android.ResXMLTree;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+// TODO: update path to released version.
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-10.0.0_rXX/core/jni/android_util_AssetManager.cpp
+
+@Implements(
+    value = AssetManager.class,
+    minSdk = Build.VERSION_CODES.Q,
+    shadowPicker = ShadowAssetManager.Picker.class)
+@SuppressWarnings("NewApi")
+public class ShadowArscAssetManager10 extends ShadowAssetManager.ArscBase {
+
+  // Offsets into the outValues array populated by the methods below. outValues is a uint32_t
+  // array, but each logical element takes up 7 uint32_t-sized physical elements.
+  // Keep these in sync with android.content.res.TypedArray java class
+  private static final int STYLE_NUM_ENTRIES = 7;
+  private static final int STYLE_TYPE = 0;
+  private static final int STYLE_DATA = 1;
+  private static final int STYLE_ASSET_COOKIE = 2;
+  private static final int STYLE_RESOURCE_ID = 3;
+  private static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+  private static final int STYLE_DENSITY = 5;
+  private static final int STYLE_SOURCE_STYLE_RESOURCE_ID = 6;
+
+  private static CppAssetManager2 systemCppAssetManager2;
+  private static long systemCppAssetManager2Ref;
+  private static boolean inNonSystemConstructor;
+  private static ApkAssets[] cachedSystemApkAssets;
+  private static ArraySet<ApkAssets> cachedSystemApkAssetsSet;
+
+  @RealObject AssetManager realAssetManager;
+
+  //  @RealObject  //  protected AssetManager realObject;
+
+  // #define ATRACE_TAG ATRACE_TAG_RESOURCES
+  // #define LOG_TAG "asset"
+  //
+  // #include <inttypes.h>
+  // #include <linux/capability.h>
+  // #include <stdio.h>
+  // #include <sys/stat.h>
+  // #include <sys/system_properties.h>
+  // #include <sys/types.h>
+  // #include <sys/wait.h>
+  //
+  // #include <private/android_filesystem_config.h> // for AID_SYSTEM
+  //
+  // #include "android-base/logging.h"
+  // #include "android-base/properties.h"
+  // #include "android-base/stringprintf.h"
+  // #include "android_runtime/android_util_AssetManager.h"
+  // #include "android_runtime/AndroidRuntime.h"
+  // #include "android_util_Binder.h"
+  // #include "androidfw/Asset.h"
+  // #include "androidfw/AssetManager.h"
+  // #include "androidfw/AssetManager2.h"
+  // #include "androidfw/AttributeResolution.h"
+  // #include "androidfw/MutexGuard.h"
+  // #include "androidfw/ResourceTypes.h"
+  // #include "core_jni_helpers.h"
+  // #include "jni.h"
+  // #include "nativehelper/JNIHelp.h"
+  // #include "nativehelper/ScopedPrimitiveArray.h"
+  // #include "nativehelper/ScopedStringChars.h"
+  // #include "nativehelper/String.h"
+  // #include "utils/Log.h"
+  // #include "utils/misc.h"
+  // #include "utils/String.h"
+  // #include "utils/Trace.h"
+  //
+  // extern "C" int capget(cap_user_header_t hdrp, cap_user_data_t datap);
+  // extern "C" int capset(cap_user_header_t hdrp, const cap_user_data_t datap);
+  //
+  // using ::android::base::StringPrintf;
+  //
+  // namespace android {
+  //
+  // // ----------------------------------------------------------------------------
+  //
+
+  // static class typedvalue_offsets_t {
+  //   jfieldID mType;
+  //   jfieldID mData;
+  //   jfieldID mString;
+  //   jfieldID mAssetCookie;
+  //   jfieldID mResourceId;
+  //   jfieldID mChangingConfigurations;
+  //   jfieldID mDensity;
+  // }
+  // static final typedvalue_offsets_t gTypedValueOffsets = new typedvalue_offsets_t();
+  //
+  // static class assetfiledescriptor_offsets_t {
+  //   jfieldID mFd;
+  //   jfieldID mStartOffset;
+  //   jfieldID mLength;
+  // }
+  // static final assetfiledescriptor_offsets_t gAssetFileDescriptorOffsets = new
+  // assetfiledescriptor_offsets_t();
+  //
+  // static class assetmanager_offsets_t
+  // {
+  //   jfieldID mObject;
+  // };
+  // // This is also used by asset_manager.cpp.
+  // static final assetmanager_offsets_t gAssetManagerOffsets = new assetmanager_offsets_t();
+  //
+  // static class apkassetsfields {
+  //   jfieldID native_ptr;
+  // }
+  // static final apkassetsfields gApkAssetsFields = new apkassetsfields();
+  //
+  // static class sparsearray_offsets_t {
+  //   jclass classObject;
+  //   jmethodID constructor;
+  //   jmethodID put;
+  // }
+  // static final sparsearray_offsets_t gSparseArrayOffsets = new sparsearray_offsets_t();
+  //
+  // static class configuration_offsets_t {
+  //   jclass classObject;
+  //   jmethodID constructor;
+  //   jfieldID mSmallestScreenWidthDpOffset;
+  //   jfieldID mScreenWidthDpOffset;
+  //   jfieldID mScreenHeightDpOffset;
+  // }
+  // static final configuration_offsets_t gConfigurationOffsets = new configuration_offsets_t();
+  //
+  // jclass g_stringClass = nullptr;
+  //
+  // // ----------------------------------------------------------------------------
+
+  @Implementation(maxSdk = Q)
+  protected static void createSystemAssetsInZygoteLocked() {
+    _AssetManager28_ _assetManagerStatic_ = reflector(_AssetManager28_.class);
+    AssetManager sSystem = _assetManagerStatic_.getSystem();
+    if (sSystem != null) {
+      return;
+    }
+
+    if (systemCppAssetManager2 == null) {
+      // first time! let the framework create a CppAssetManager2 and an AssetManager, which we'll
+      // hang on to.
+      reflector(AssetManagerReflector.class).createSystemAssetsInZygoteLocked();
+      cachedSystemApkAssets = _assetManagerStatic_.getSystemApkAssets();
+      cachedSystemApkAssetsSet = _assetManagerStatic_.getSystemApkAssetsSet();
+    } else {
+      // reuse the shared system CppAssetManager2; create a new AssetManager around it.
+      _assetManagerStatic_.setSystemApkAssets(cachedSystemApkAssets);
+      _assetManagerStatic_.setSystemApkAssetsSet(cachedSystemApkAssetsSet);
+
+      sSystem =
+          ReflectionHelpers.callConstructor(
+              AssetManager.class, ClassParameter.from(boolean.class, true /*sentinel*/));
+      sSystem.setApkAssets(cachedSystemApkAssets, false /*invalidateCaches*/);
+      ReflectionHelpers.setStaticField(AssetManager.class, "sSystem", sSystem);
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    // todo: ShadowPicker doesn't discriminate properly between concrete shadow classes for
+    // resetters...
+    if (!useLegacy() && RuntimeEnvironment.getApiLevel() >= P) {
+      _AssetManager28_ _assetManagerStatic_ = reflector(_AssetManager28_.class);
+      _assetManagerStatic_.setSystemApkAssetsSet(null);
+      _assetManagerStatic_.setSystemApkAssets(null);
+      _assetManagerStatic_.setSystem(null);
+    }
+  }
+
+  // Java asset cookies have 0 as an invalid cookie, but TypedArray expects < 0.
+  static int ApkAssetsCookieToJavaCookie(ApkAssetsCookie cookie) {
+    return cookie.intValue() != kInvalidCookie ? (cookie.intValue() + 1) : -1;
+  }
+
+  static ApkAssetsCookie JavaCookieToApkAssetsCookie(int cookie) {
+    return ApkAssetsCookie.forInt(cookie > 0 ? (cookie - 1) : kInvalidCookie);
+  }
+
+  // This is called by zygote (running as user root) as part of preloadResources.
+  // static void NativeVerifySystemIdmaps(JNIEnv* /*env*/, jclass /*clazz*/) {
+  @Implementation(minSdk = P, maxSdk = Q)
+  protected static void nativeVerifySystemIdmaps() {
+    return;
+
+    // todo: maybe implement?
+    // switch (pid_t pid = fork()) {
+    //   case -1:
+    //     PLOG(ERROR) << "failed to fork for idmap";
+    //     break;
+    //
+    //   // child
+    //   case 0: {
+    //     struct __user_cap_header_struct capheader;
+    //     struct __user_cap_data_struct capdata;
+    //
+    //     memset(&capheader, 0, sizeof(capheader));
+    //     memset(&capdata, 0, sizeof(capdata));
+    //
+    //     capheader.version = _LINUX_CAPABILITY_VERSION;
+    //     capheader.pid = 0;
+    //
+    //     if (capget(&capheader, &capdata) != 0) {
+    //       PLOG(ERROR) << "capget";
+    //       exit(1);
+    //     }
+    //
+    //     capdata.effective = capdata.permitted;
+    //     if (capset(&capheader, &capdata) != 0) {
+    //       PLOG(ERROR) << "capset";
+    //       exit(1);
+    //     }
+    //
+    //     if (setgid(AID_SYSTEM) != 0) {
+    //       PLOG(ERROR) << "setgid";
+    //       exit(1);
+    //     }
+    //
+    //     if (setuid(AID_SYSTEM) != 0) {
+    //       PLOG(ERROR) << "setuid";
+    //       exit(1);
+    //     }
+    //
+    //     // Generic idmap parameters
+    //     char* argv[8];
+    //     int argc = 0;
+    //     struct stat st;
+    //
+    //     memset(argv, 0, sizeof(argv));
+    //     argv[argc++] = AssetManager.IDMAP_BIN;
+    //     argv[argc++] = "--scan";
+    //     argv[argc++] = AssetManager.TARGET_PACKAGE_NAME;
+    //     argv[argc++] = AssetManager.TARGET_APK_PATH;
+    //     argv[argc++] = AssetManager.IDMAP_DIR;
+    //
+    //     // Directories to scan for overlays: if OVERLAY_THEME_DIR_PROPERTY is defined,
+    //     // use OVERLAY_DIR/<value of OVERLAY_THEME_DIR_PROPERTY> in addition to OVERLAY_DIR.
+    //     String overlay_theme_path = base.GetProperty(AssetManager.OVERLAY_THEME_DIR_PROPERTY,
+    //         "");
+    //     if (!overlay_theme_path.empty()) {
+    //       overlay_theme_path = String(AssetManager.OVERLAY_DIR) + "/" + overlay_theme_path;
+    //       if (stat(overlay_theme_path, &st) == 0) {
+    //         argv[argc++] = overlay_theme_path;
+    //       }
+    //     }
+    //
+    //     if (stat(AssetManager.OVERLAY_DIR, &st) == 0) {
+    //       argv[argc++] = AssetManager.OVERLAY_DIR;
+    //     }
+    //
+    //     if (stat(AssetManager.PRODUCT_OVERLAY_DIR, &st) == 0) {
+    //       argv[argc++] = AssetManager.PRODUCT_OVERLAY_DIR;
+    //     }
+    //
+    //     // Finally, invoke idmap (if any overlay directory exists)
+    //     if (argc > 5) {
+    //       execv(AssetManager.IDMAP_BIN, (char* const*)argv);
+    //       PLOG(ERROR) << "failed to execv for idmap";
+    //       exit(1); // should never get here
+    //     } else {
+    //       exit(0);
+    //     }
+    //   } break;
+    //
+    //   // parent
+    //   default:
+    //     waitpid(pid, null, 0);
+    //     break;
+    // }
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q, maxSdk = Build.VERSION_CODES.R)
+  protected static String[] nativeCreateIdmapsForStaticOverlaysTargetingAndroid() {
+    return new String[0];
+  }
+
+  static int CopyValue(
+      /*JNIEnv* env,*/ ApkAssetsCookie cookie,
+      Res_value value,
+      int ref,
+      int type_spec_flags,
+      ResTable_config config,
+      TypedValue out_typed_value) {
+    out_typed_value.type = value.dataType;
+    out_typed_value.assetCookie = ApkAssetsCookieToJavaCookie(cookie);
+    out_typed_value.data = value.data;
+    out_typed_value.string = null;
+    out_typed_value.resourceId = ref;
+    out_typed_value.changingConfigurations = type_spec_flags;
+    if (config != null) {
+      out_typed_value.density = config.density;
+    }
+    return (int) (ApkAssetsCookieToJavaCookie(cookie));
+  }
+
+  //  @Override
+  //  protected int addAssetPathNative(String path) {
+  //    throw new UnsupportedOperationException(); // todo
+  //  }
+
+  @Override
+  Collection<Path> getAllAssetDirs() {
+    ApkAssets[] apkAssetsArray = reflector(_AssetManager28_.class, realAssetManager).getApkAssets();
+
+    ArrayList<Path> assetDirs = new ArrayList<>();
+    for (ApkAssets apkAssets : apkAssetsArray) {
+      long apk_assets_native_ptr =
+          ((ShadowArscApkAssets9) Shadow.extract(apkAssets)).getNativePtr();
+      CppApkAssets cppApkAssets =
+          Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(apk_assets_native_ptr);
+
+      if (new File(cppApkAssets.GetPath()).isFile()) {
+        assetDirs.add(Fs.forJar(Paths.get(cppApkAssets.GetPath())).getPath("assets"));
+      } else {
+        assetDirs.add(Paths.get(cppApkAssets.GetPath()));
+      }
+    }
+    return assetDirs;
+  }
+
+  @Override
+  List<AssetPath> getAssetPaths() {
+    return AssetManagerForJavaObject(realAssetManager).getAssetPaths();
+  }
+
+  // ----------------------------------------------------------------------------
+
+  // interface AAssetManager {}
+  //
+  // // Let the opaque type AAssetManager refer to a guarded AssetManager2 instance.
+  // static class GuardedAssetManager implements AAssetManager {
+  //   CppAssetManager2 guarded_assetmanager = new CppAssetManager2();
+  // }
+
+  static CppAssetManager2 NdkAssetManagerForJavaObject(
+      /* JNIEnv* env,*/ AssetManager jassetmanager) {
+    // long assetmanager_handle = env.GetLongField(jassetmanager, gAssetManagerOffsets.mObject);
+    long assetmanager_handle = ReflectionHelpers.getField(jassetmanager, "mObject");
+    CppAssetManager2 am =
+        Registries.NATIVE_ASSET_MANAGER_REGISTRY.getNativeObject(assetmanager_handle);
+    if (am == null) {
+      throw new IllegalStateException("AssetManager has been finalized!");
+    }
+    return am;
+  }
+
+  static CppAssetManager2 AssetManagerForJavaObject(/* JNIEnv* env,*/ AssetManager jassetmanager) {
+    return NdkAssetManagerForJavaObject(jassetmanager);
+  }
+
+  static CppAssetManager2 AssetManagerFromLong(long ptr) {
+    // return *AssetManagerForNdkAssetManager(reinterpret_cast<AAssetManager>(ptr));
+    return Registries.NATIVE_ASSET_MANAGER_REGISTRY.getNativeObject(ptr);
+  }
+
+  static ParcelFileDescriptor ReturnParcelFileDescriptor(
+      /* JNIEnv* env,*/ Asset asset, long[] out_offsets) throws FileNotFoundException {
+    final Ref<Long> start_offset = new Ref<>(0L);
+    final Ref<Long> length = new Ref<>(0L);
+    FileDescriptor fd = asset.openFileDescriptor(start_offset, length);
+    // asset.reset();
+
+    if (fd == null) {
+      throw new FileNotFoundException(
+          "This file can not be opened as a file descriptor; it is probably compressed");
+    }
+
+    long[] offsets =
+        out_offsets; // reinterpret_cast<long*>(env.GetPrimitiveArrayCritical(out_offsets, 0));
+    if (offsets == null) {
+      // close(fd);
+      return null;
+    }
+
+    offsets[0] = start_offset.get();
+    offsets[1] = length.get();
+
+    // env.ReleasePrimitiveArrayCritical(out_offsets, offsets, 0);
+
+    // jniCreateFileDescriptor(env, fd);
+    // if (file_desc == null) {
+    //   close(fd);
+    //   return null;
+    // }
+
+    // TODO: consider doing this
+    // return new ParcelFileDescriptor(file_desc);
+    return ParcelFileDescriptor.open(asset.getFile(), ParcelFileDescriptor.MODE_READ_ONLY);
+  }
+
+  /** Used for the creation of system assets. */
+  @Implementation(minSdk = P)
+  protected void __constructor__(boolean sentinel) {
+    inNonSystemConstructor = true;
+    try {
+      // call real constructor so field initialization happens.
+      invokeConstructor(
+          AssetManager.class, realAssetManager, ClassParameter.from(boolean.class, sentinel));
+    } finally {
+      inNonSystemConstructor = false;
+    }
+  }
+
+  // static jint NativeGetGlobalAssetCount(JNIEnv* /*env*/, jobject /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static int getGlobalAssetCount() {
+    return Asset.getGlobalCount();
+  }
+
+  // static jobject NativeGetAssetAllocations(JNIEnv* env, jobject /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static String getAssetAllocations() {
+    String alloc = Asset.getAssetAllocations();
+    if (alloc.length() <= 0) {
+      return null;
+    }
+    return alloc;
+  }
+
+  // static jint NativeGetGlobalAssetManagerCount(JNIEnv* /*env*/, jobject /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static int getGlobalAssetManagerCount() {
+    // TODO(adamlesinski): Switch to AssetManager2.
+    return CppAssetManager.getGlobalCount();
+  }
+
+  // static jlong NativeCreate(JNIEnv* /*env*/, jclass /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static long nativeCreate() {
+    // AssetManager2 needs to be protected by a lock. To avoid cache misses, we allocate the lock
+    // and
+    // AssetManager2 in a contiguous block (GuardedAssetManager).
+    // return reinterpret_cast<long>(new GuardedAssetManager());
+
+    long cppAssetManagerRef;
+
+    // we want to share a single instance of the system CppAssetManager2
+    if (inNonSystemConstructor) {
+      CppAssetManager2 appAssetManager = new CppAssetManager2();
+      cppAssetManagerRef = Registries.NATIVE_ASSET_MANAGER_REGISTRY.register(appAssetManager);
+    } else {
+      if (systemCppAssetManager2 == null) {
+        systemCppAssetManager2 = new CppAssetManager2();
+        systemCppAssetManager2Ref =
+            Registries.NATIVE_ASSET_MANAGER_REGISTRY.register(systemCppAssetManager2);
+      }
+
+      cppAssetManagerRef = systemCppAssetManager2Ref;
+    }
+
+    return cppAssetManagerRef;
+  }
+
+  // static void NativeDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P)
+  protected static void nativeDestroy(long ptr) {
+    if (ptr == systemCppAssetManager2Ref) {
+      // don't destroy the shared system CppAssetManager2!
+      return;
+    }
+
+    // delete reinterpret_cast<GuardedAssetManager*>(ptr);
+    Registries.NATIVE_ASSET_MANAGER_REGISTRY.unregister(ptr);
+  }
+
+  // static void NativeSetApkAssets(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                jobjectArray apk_assets_array, jboolean invalidate_caches) {
+  @Implementation(minSdk = P)
+  protected static void nativeSetApkAssets(
+      long ptr,
+      @NonNull android.content.res.ApkAssets[] apk_assets_array,
+      boolean invalidate_caches) {
+    ATRACE_NAME("AssetManager::SetApkAssets");
+
+    int apk_assets_len = apk_assets_array.length;
+    List<CppApkAssets> apk_assets = new ArrayList<>();
+    // apk_assets.reserve(apk_assets_len);
+    for (int i = 0; i < apk_assets_len; i++) {
+      android.content.res.ApkAssets apkAssets =
+          apk_assets_array[i]; // env.GetObjectArrayElement(apk_assets_array, i);
+      if (apkAssets == null) {
+        throw new NullPointerException(String.format("ApkAssets at index %d is null", i));
+      }
+
+      long apk_assets_native_ptr =
+          ((ShadowArscApkAssets9) Shadow.extract(apkAssets)).getNativePtr();
+      // if (env.ExceptionCheck()) {
+      //   return;
+      // }
+      apk_assets.add(Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(apk_assets_native_ptr));
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    assetmanager.SetApkAssets(apk_assets, invalidate_caches);
+  }
+
+  // static void NativeSetConfiguration(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint mcc, jint
+  // mnc,
+  //                                    jstring locale, jint orientation, jint touchscreen, jint
+  // density,
+  //                                    jint keyboard, jint keyboard_hidden, jint navigation,
+  //                                    jint screen_width, jint screen_height,
+  //                                    jint smallest_screen_width_dp, jint screen_width_dp,
+  //                                    jint screen_height_dp, jint screen_layout, jint ui_mode,
+  //                                    jint color_mode, jint major_version) {
+  @Implementation(minSdk = P)
+  protected static void nativeSetConfiguration(
+      long ptr,
+      int mcc,
+      int mnc,
+      @Nullable String locale,
+      int orientation,
+      int touchscreen,
+      int density,
+      int keyboard,
+      int keyboard_hidden,
+      int navigation,
+      int screen_width,
+      int screen_height,
+      int smallest_screen_width_dp,
+      int screen_width_dp,
+      int screen_height_dp,
+      int screen_layout,
+      int ui_mode,
+      int color_mode,
+      int major_version) {
+    ATRACE_NAME("AssetManager::SetConfiguration");
+
+    ResTable_config configuration = new ResTable_config();
+    // memset(&configuration, 0, sizeof(configuration));
+    configuration.mcc = (short) (mcc);
+    configuration.mnc = (short) (mnc);
+    configuration.orientation = (byte) (orientation);
+    configuration.touchscreen = (byte) (touchscreen);
+    configuration.density = (short) (density);
+    configuration.keyboard = (byte) (keyboard);
+    configuration.inputFlags = (byte) (keyboard_hidden);
+    configuration.navigation = (byte) (navigation);
+    configuration.screenWidth = (short) (screen_width);
+    configuration.screenHeight = (short) (screen_height);
+    configuration.smallestScreenWidthDp = (short) (smallest_screen_width_dp);
+    configuration.screenWidthDp = (short) (screen_width_dp);
+    configuration.screenHeightDp = (short) (screen_height_dp);
+    configuration.screenLayout = (byte) (screen_layout);
+    configuration.uiMode = (byte) (ui_mode);
+    configuration.colorMode = (byte) (color_mode);
+    configuration.sdkVersion = (short) (major_version);
+
+    if (locale != null) {
+      String locale_utf8 = locale;
+      CHECK(locale_utf8 != null);
+      configuration.setBcp47Locale(locale_utf8);
+    }
+
+    // Constants duplicated from Java class android.content.res.Configuration.
+    int kScreenLayoutRoundMask = 0x300;
+    int kScreenLayoutRoundShift = 8;
+
+    // In Java, we use a 32bit integer for screenLayout, while we only use an 8bit integer
+    // in C++. We must extract the round qualifier out of the Java screenLayout and put it
+    // into screenLayout2.
+    configuration.screenLayout2 =
+        (byte) ((screen_layout & kScreenLayoutRoundMask) >> kScreenLayoutRoundShift);
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    assetmanager.SetConfiguration(configuration);
+  }
+
+  // static jobject NativeGetAssignedPackageIdentifiers(JNIEnv* env, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P, maxSdk = Q)
+  protected static @NonNull SparseArray<String> nativeGetAssignedPackageIdentifiers(long ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+
+    SparseArray<String> sparse_array = new SparseArray<>();
+
+    if (sparse_array == null) {
+      // An exception is pending.
+      return null;
+    }
+
+    assetmanager.ForEachPackage(
+        (String package_name, byte package_id) -> {
+          String jpackage_name = package_name; // env.NewStringUTF(package_name);
+          if (jpackage_name == null) {
+            // An exception is pending.
+            return;
+          }
+
+          // env.CallVoidMethod(sparse_array, gSparseArrayOffsets.put, (int) (package_id),
+          //     jpackage_name);
+          sparse_array.put(package_id, jpackage_name);
+        });
+    return sparse_array;
+  }
+
+  @Implementation(minSdk = R)
+  protected static SparseArray<String> nativeGetAssignedPackageIdentifiers(
+      long ptr, boolean includeOverlays, boolean includeLoaders) {
+    return nativeGetAssignedPackageIdentifiers(ptr);
+  }
+
+  // static jobjectArray NativeList(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring path) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String[] nativeList(long ptr, @NonNull String path)
+      throws IOException {
+    String path_utf8 = path;
+    if (path_utf8 == null) {
+      // This will throw NPE.
+      return null;
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    AssetDir asset_dir = assetmanager.OpenDir(path_utf8);
+    if (asset_dir == null) {
+      throw new FileNotFoundException(path_utf8);
+    }
+
+    int file_count = asset_dir.getFileCount();
+
+    String[] array = new String[file_count]; // env.NewObjectArray(file_count, g_stringClass, null);
+    // if (array == null) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < file_count; i++) {
+      String java_string = asset_dir.getFileName(i).string();
+
+      // Check for errors creating the strings (if malformed or no memory).
+      // if (env.ExceptionCheck()) {
+      //   return null;
+      // }
+
+      // env.SetObjectArrayElement(array, i, java_string);
+      array[i] = java_string;
+
+      // If we have a large amount of string in our array, we might overflow the
+      // local reference table of the VM.
+      // env.DeleteLocalRef(java_string);
+    }
+    return array;
+  }
+
+  // static jlong NativeOpenAsset(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring asset_path,
+  //                              jint access_mode) {
+  @Implementation(minSdk = P)
+  protected static long nativeOpenAsset(long ptr, @NonNull String asset_path, int access_mode)
+      throws FileNotFoundException {
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenAsset(%s)", asset_path_utf8));
+
+    if (access_mode != Asset.AccessMode.ACCESS_UNKNOWN.mode()
+        && access_mode != Asset.AccessMode.ACCESS_RANDOM.mode()
+        && access_mode != Asset.AccessMode.ACCESS_STREAMING.mode()
+        && access_mode != Asset.AccessMode.ACCESS_BUFFER.mode()) {
+      throw new IllegalArgumentException("Bad access mode");
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset = assetmanager.Open(asset_path_utf8, Asset.AccessMode.fromInt(access_mode));
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return Registries.NATIVE_ASSET_REGISTRY.register(asset);
+  }
+
+  // static jobject NativeOpenAssetFd(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring asset_path,
+  //                                  jlongArray out_offsets) {
+  @Implementation(minSdk = P)
+  protected static ParcelFileDescriptor nativeOpenAssetFd(
+      long ptr, @NonNull String asset_path, long[] out_offsets) throws IOException {
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return null;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenAssetFd(%s)", asset_path_utf8));
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset = assetmanager.Open(asset_path_utf8, Asset.AccessMode.ACCESS_RANDOM);
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return ReturnParcelFileDescriptor(asset, out_offsets);
+  }
+
+  // static jlong NativeOpenNonAsset(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint jcookie,
+  //                                 jstring asset_path, jint access_mode) {
+  @Implementation(minSdk = P)
+  protected static long nativeOpenNonAsset(
+      long ptr, int jcookie, @NonNull String asset_path, int access_mode)
+      throws FileNotFoundException {
+    ApkAssetsCookie cookie = JavaCookieToApkAssetsCookie(jcookie);
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenNonAsset(%s)", asset_path_utf8));
+
+    if (access_mode != Asset.AccessMode.ACCESS_UNKNOWN.mode()
+        && access_mode != Asset.AccessMode.ACCESS_RANDOM.mode()
+        && access_mode != Asset.AccessMode.ACCESS_STREAMING.mode()
+        && access_mode != Asset.AccessMode.ACCESS_BUFFER.mode()) {
+      throw new IllegalArgumentException("Bad access mode");
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset;
+    if (cookie.intValue() != kInvalidCookie) {
+      asset =
+          assetmanager.OpenNonAsset(asset_path_utf8, cookie, Asset.AccessMode.fromInt(access_mode));
+    } else {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, Asset.AccessMode.fromInt(access_mode));
+    }
+
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return Registries.NATIVE_ASSET_REGISTRY.register(asset);
+  }
+
+  // static jobject NativeOpenNonAssetFd(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint jcookie,
+  //                                     jstring asset_path, jlongArray out_offsets) {
+  @Implementation(minSdk = P)
+  protected static @Nullable ParcelFileDescriptor nativeOpenNonAssetFd(
+      long ptr, int jcookie, @NonNull String asset_path, @NonNull long[] out_offsets)
+      throws IOException {
+    ApkAssetsCookie cookie = JavaCookieToApkAssetsCookie(jcookie);
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return null;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenNonAssetFd(%s)", asset_path_utf8));
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset;
+    if (cookie.intValue() != kInvalidCookie) {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, cookie, Asset.AccessMode.ACCESS_RANDOM);
+    } else {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, Asset.AccessMode.ACCESS_RANDOM);
+    }
+
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return ReturnParcelFileDescriptor(asset, out_offsets);
+  }
+
+  // static jlong NativeOpenXmlAsset(JNIEnv* env, jobject /*clazz*/, jlong ptr, jint jcookie,
+  //                                 jstring asset_path) {
+  @Implementation(minSdk = P)
+  protected static long nativeOpenXmlAsset(long ptr, int jcookie, @NonNull String asset_path)
+      throws FileNotFoundException {
+    ApkAssetsCookie cookie = JavaCookieToApkAssetsCookie(jcookie);
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenXmlAsset(%s)", asset_path_utf8));
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset;
+    if (cookie.intValue() != kInvalidCookie) {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, cookie, Asset.AccessMode.ACCESS_RANDOM);
+    } else {
+      Ref<ApkAssetsCookie> cookieRef = new Ref<>(cookie);
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, Asset.AccessMode.ACCESS_RANDOM, cookieRef);
+      cookie = cookieRef.get();
+    }
+
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+
+    // May be nullptr.
+    DynamicRefTable dynamic_ref_table = assetmanager.GetDynamicRefTableForCookie(cookie);
+
+    ResXMLTree xml_tree = new ResXMLTree(dynamic_ref_table);
+    int err = xml_tree.setTo(asset.getBuffer(true), (int) asset.getLength(), true);
+    // asset.reset();
+
+    if (err != NO_ERROR) {
+      throw new FileNotFoundException("Corrupt XML binary file");
+    }
+    return NATIVE_RES_XML_TREES.register(xml_tree);
+  }
+
+  // static jint NativeGetResourceValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
+  //                                    jshort density, jobject typed_value,
+  //                                    jboolean resolve_references) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceValue(
+      long ptr,
+      @AnyRes int resid,
+      short density,
+      @NonNull TypedValue typed_value,
+      boolean resolve_references) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final Ref<Res_value> value = new Ref<>(null);
+    final Ref<ResTable_config> selected_config = new Ref<>(null);
+    final Ref<Integer> flags = new Ref<>(0);
+    ApkAssetsCookie cookie =
+        assetmanager.GetResource(
+            resid, false /*may_be_bag*/, (short) (density), value, selected_config, flags);
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Integer> ref = new Ref<>(resid);
+    if (resolve_references) {
+      cookie = assetmanager.ResolveReference(cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+      }
+    }
+    return CopyValue(
+        cookie, value.get(), ref.get(), flags.get(), selected_config.get(), typed_value);
+  }
+
+  // static jint NativeGetResourceBagValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
+  //                                       jint bag_entry_id, jobject typed_value) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceBagValue(
+      long ptr, @AnyRes int resid, int bag_entry_id, @NonNull TypedValue typed_value) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Integer> type_spec_flags = new Ref<>(bag.type_spec_flags);
+    ApkAssetsCookie cookie = K_INVALID_COOKIE;
+    Res_value bag_value = null;
+    for (ResolvedBag.Entry entry : bag.entries) {
+      if (entry.key == (int) (bag_entry_id)) {
+        cookie = entry.cookie;
+        bag_value = entry.value;
+
+        // Keep searching (the old implementation did that).
+      }
+    }
+
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Res_value> value = new Ref<>(bag_value);
+    final Ref<Integer> ref = new Ref<>(resid);
+    final Ref<ResTable_config> selected_config = new Ref<>(null);
+    cookie = assetmanager.ResolveReference(cookie, value, selected_config, type_spec_flags, ref);
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+    return CopyValue(cookie, value.get(), ref.get(), type_spec_flags.get(), null, typed_value);
+  }
+
+  // static jintArray NativeGetStyleAttributes(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid)
+  // {
+  @Implementation(minSdk = P)
+  protected static @Nullable @AttrRes int[] nativeGetStyleAttributes(
+      long ptr, @StyleRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    int[] array = new int[bag.entry_count];
+    // if (env.ExceptionCheck()) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      int attr_resid = bag.entries[i].key;
+      // env.SetIntArrayRegion(array, i, 1, &attr_resid);
+      array[i] = attr_resid;
+    }
+    return array;
+  }
+
+  // static jobjectArray NativeGetResourceStringArray(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                                  jint resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String[] nativeGetResourceStringArray(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    String[] array = new String[bag.entry_count];
+    if (array == null) {
+      return null;
+    }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+
+      // Resolve any references to their final value.
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      final Ref<Integer> flags = new Ref<>(0);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        return null;
+      }
+
+      if (value.get().dataType == Res_value.TYPE_STRING) {
+        CppApkAssets apk_assets = assetmanager.GetApkAssets().get(cookie.intValue());
+        ResStringPool pool = apk_assets.GetLoadedArsc().GetStringPool();
+
+        String java_string = null;
+        String str_utf8 = pool.stringAt(value.get().data);
+        if (str_utf8 != null) {
+          java_string = str_utf8;
+        } else {
+          String str_utf16 = pool.stringAt(value.get().data);
+          java_string = str_utf16;
+        }
+
+        // // Check for errors creating the strings (if malformed or no memory).
+        // if (env.ExceptionCheck()) {
+        //   return null;
+        // }
+
+        // env.SetObjectArrayElement(array, i, java_string);
+        array[i] = java_string;
+
+        // If we have a large amount of string in our array, we might overflow the
+        // local reference table of the VM.
+        // env.DeleteLocalRef(java_string);
+      }
+    }
+    return array;
+  }
+
+  // static jintArray NativeGetResourceStringArrayInfo(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                                   jint resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable int[] nativeGetResourceStringArrayInfo(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    int[] array = new int[bag.entry_count * 2];
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int[] buffer = array; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(array, null));
+    // if (buffer == null) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      final Ref<Integer> flags = new Ref<>(0);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        // env.ReleasePrimitiveArrayCritical(array, buffer, JNI_ABORT);
+        return null;
+      }
+
+      int string_index = -1;
+      if (value.get().dataType == Res_value.TYPE_STRING) {
+        string_index = (int) (value.get().data);
+      }
+
+      buffer[i * 2] = ApkAssetsCookieToJavaCookie(cookie);
+      buffer[(i * 2) + 1] = string_index;
+    }
+    // env.ReleasePrimitiveArrayCritical(array, buffer, 0);
+    return array;
+  }
+
+  // static jintArray NativeGetResourceIntArray(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint
+  // resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable int[] nativeGetResourceIntArray(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    int[] array = new int[bag.entry_count];
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int[] buffer = array; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(array, null));
+    // if (buffer == null) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      final Ref<Integer> flags = new Ref<>(0);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        // env.ReleasePrimitiveArrayCritical(array, buffer, JNI_ABORT);
+        return null;
+      }
+
+      if (value.get().dataType >= Res_value.TYPE_FIRST_INT
+          && value.get().dataType <= Res_value.TYPE_LAST_INT) {
+        buffer[i] = (int) (value.get().data);
+      }
+    }
+    // env.ReleasePrimitiveArrayCritical(array, buffer, 0);
+    return array;
+  }
+
+  // static jint NativeGetResourceArraySize(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr, jint
+  // resid) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceArraySize(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return -1;
+    }
+    return (int) (bag.entry_count);
+  }
+
+  // static jint NativeGetResourceArray(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
+  //                                    jintArray out_data) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceArray(
+      long ptr, @ArrayRes int resid, @NonNull int[] out_data) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return -1;
+    }
+
+    int out_data_length = out_data.length;
+    // if (env.ExceptionCheck()) {
+    //   return -1;
+    // }
+
+    if ((int) (bag.entry_count) > out_data_length * STYLE_NUM_ENTRIES) {
+      throw new IllegalArgumentException("Input array is not large enough");
+    }
+
+    int[] buffer =
+        out_data; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_data, null));
+    if (buffer == null) {
+      return -1;
+    }
+
+    int[] cursor = buffer;
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(new ResTable_config());
+      selected_config.get().density = 0;
+      final Ref<Integer> flags = new Ref<>(bag.type_spec_flags);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        // env.ReleasePrimitiveArrayCritical(out_data, buffer, JNI_ABORT);
+        return -1;
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.get().dataType == Res_value.TYPE_REFERENCE && value.get().data == 0) {
+        value.set(Res_value.NULL_VALUE);
+      }
+
+      int offset = i * STYLE_NUM_ENTRIES;
+      cursor[offset + STYLE_TYPE] = (int) (value.get().dataType);
+      cursor[offset + STYLE_DATA] = (int) (value.get().data);
+      cursor[offset + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+      cursor[offset + STYLE_RESOURCE_ID] = (int) (ref.get());
+      cursor[offset + STYLE_CHANGING_CONFIGURATIONS] = (int) (flags.get());
+      cursor[offset + STYLE_DENSITY] = (int) (selected_config.get().density);
+      // cursor += STYLE_NUM_ENTRIES;
+    }
+    // env.ReleasePrimitiveArrayCritical(out_data, buffer, 0);
+    return (int) (bag.entry_count);
+  }
+
+  // static jint NativeGetResourceIdentifier(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring name,
+  //                                         jstring def_type, jstring def_package) {
+  @Implementation(minSdk = P)
+  protected static @AnyRes int nativeGetResourceIdentifier(
+      long ptr, @NonNull String name, @Nullable String def_type, @Nullable String def_package) {
+    String name_utf8 = name;
+    if (name_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    String type = null;
+    if (def_type != null) {
+      String type_utf8 = def_type;
+      CHECK(type_utf8 != null);
+      type = type_utf8;
+    }
+
+    String package_ = null;
+    if (def_package != null) {
+      String package_utf8 = def_package;
+      CHECK(package_utf8 != null);
+      package_ = package_utf8;
+    }
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    return (int) (assetmanager.GetResourceId(name_utf8, type, package_));
+  }
+
+  // static jstring NativeGetResourceName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourceName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    CppAssetManager2.ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    StringBuilder result = new StringBuilder();
+    if (name.package_ != null) {
+      result.append(name.package_ /*, name.package_len*/);
+    }
+
+    if (name.type != null /*|| name.type16 != null*/) {
+      if (!(result.length() == 0)) {
+        result.append(":");
+      }
+
+      // if (name.type != null) {
+      result.append(name.type /*, name.type_len*/);
+      // } else {
+      //   result.append( /*util.Utf16ToUtf8(StringPiece16(*/ name.type16 /*, name.type_len))*/);
+      // }
+    }
+
+    if (name.entry != null /*|| name.entry16 != null*/) {
+      if (!(result.length() == 0)) {
+        result.append("/");
+      }
+
+      // if (name.entry != null) {
+      result.append(name.entry /*, name.entry_len*/);
+      // } else {
+      //   result.append( /*util.Utf16ToUtf8(StringPiece16(*/ name.entry16 /*, name.entry_len)*/);
+      // }
+    }
+    return result.toString();
+  }
+
+  // static jstring NativeGetResourcePackageName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint
+  // resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourcePackageName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    if (name.package_ != null) {
+      return name.package_;
+    }
+    return null;
+  }
+
+  // static jstring NativeGetResourceTypeName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid)
+  // {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourceTypeName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    if (name.type != null) {
+      return name.type;
+      // } else if (name.get().type16 != null) {
+      //   return name.get().type16; // env.NewString(reinterpret_cast<jchar*>(name.type16),
+      // name.type_len);
+    }
+    return null;
+  }
+
+  // static jstring NativeGetResourceEntryName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid)
+  // {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourceEntryName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    if (name.entry != null) {
+      return name.entry;
+      // } else if (name.entry16 != null) {
+      //   return name.entry16; // env.NewString(reinterpret_cast<jchar*>(name.entry16),
+      // name.entry_len);
+    }
+    return null;
+  }
+
+  // static jobjectArray NativeGetLocales(JNIEnv* env, jclass /*class*/, jlong ptr,
+  //                                      jboolean exclude_system) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String[] nativeGetLocales(long ptr, boolean exclude_system) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Set<String> locales =
+        assetmanager.GetResourceLocales(exclude_system, true /*merge_equivalent_languages*/);
+
+    String[] array =
+        new String[locales.size()]; // env.NewObjectArray(locales.size(), g_stringClass, null);
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int idx = 0;
+    for (String locale : locales) {
+      String java_string = locale;
+      if (java_string == null) {
+        return null;
+      }
+      // env.SetObjectArrayElement(array, idx++, java_string);
+      array[idx++] = java_string;
+      // env.DeleteLocalRef(java_string);
+    }
+    return array;
+  }
+
+  static Configuration ConstructConfigurationObject(/* JNIEnv* env,*/ ResTable_config config) {
+    // jobject result =
+    //     env.NewObject(gConfigurationOffsets.classObject, gConfigurationOffsets.constructor);
+    Configuration result = new Configuration();
+    // if (result == null) {
+    //   return null;
+    // }
+
+    result.smallestScreenWidthDp = config.smallestScreenWidthDp;
+    result.screenWidthDp = config.screenWidthDp;
+    result.screenHeightDp = config.screenHeightDp;
+    return result;
+  }
+
+  // static jobjectArray NativeGetSizeConfigurations(JNIEnv* env, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P)
+  protected static @Nullable Configuration[] nativeGetSizeConfigurations(long ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Set<ResTable_config> configurations =
+        assetmanager.GetResourceConfigurations(true /*exclude_system*/, false /*exclude_mipmap*/);
+
+    Configuration[] array = new Configuration[configurations.size()];
+    // env.NewObjectArray(configurations.size(), gConfigurationOffsets.classObject, null);
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int idx = 0;
+    for (ResTable_config configuration : configurations) {
+      Configuration java_configuration = ConstructConfigurationObject(configuration);
+      // if (java_configuration == null) {
+      //   return null;
+      // }
+
+      // env.SetObjectArrayElement(array, idx++, java_configuration);
+      array[idx++] = java_configuration;
+      // env.DeleteLocalRef(java_configuration);
+    }
+    return array;
+  }
+
+  // static void NativeApplyStyle(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                              jint def_style_attr, jint def_style_resid, jlong xml_parser_ptr,
+  //                              jintArray java_attrs, jlong out_values_ptr, jlong out_indices_ptr)
+  // {
+  @Implementation(minSdk = P)
+  protected static void nativeApplyStyle(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int def_style_attr,
+      @StyleRes int def_style_resid,
+      long xml_parser_ptr,
+      @NonNull int[] java_attrs,
+      long out_values_ptr,
+      long out_indices_ptr) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "applyStyle",
+            () ->
+                nativeApplyStyle_measured(
+                    ptr,
+                    theme_ptr,
+                    def_style_attr,
+                    def_style_resid,
+                    xml_parser_ptr,
+                    java_attrs,
+                    out_values_ptr,
+                    out_indices_ptr));
+  }
+
+  private static void nativeApplyStyle_measured(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int def_style_attr,
+      @StyleRes int def_style_resid,
+      long xml_parser_ptr,
+      @NonNull int[] java_attrs,
+      long out_values_ptr,
+      long out_indices_ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+
+    ResXMLParser xml_parser =
+        xml_parser_ptr == 0 ? null : NATIVE_RES_XML_PARSERS.getNativeObject(xml_parser_ptr);
+    // int[] out_values = reinterpret_cast<int*>(out_values_ptr);
+    // int[] out_indices = reinterpret_cast<int*>(out_indices_ptr);
+    ShadowVMRuntime shadowVMRuntime = Shadow.extract(VMRuntime.getRuntime());
+    int[] out_values = (int[]) shadowVMRuntime.getObjectForAddress(out_values_ptr);
+    int[] out_indices = (int[]) shadowVMRuntime.getObjectForAddress(out_indices_ptr);
+
+    int attrs_len = java_attrs.length;
+    int[] attrs =
+        java_attrs; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_attrs, null));
+    // if (attrs == null) {
+    //   return;
+    // }
+
+    ApplyStyle(
+        theme,
+        xml_parser,
+        (int) (def_style_attr),
+        (int) (def_style_resid),
+        attrs,
+        attrs_len,
+        out_values,
+        out_indices);
+    // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+  }
+
+  // static jboolean NativeResolveAttrs(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                                    jint def_style_attr, jint def_style_resid, jintArray
+  // java_values,
+  //                                    jintArray java_attrs, jintArray out_java_values,
+  //                                    jintArray out_java_indices) {
+  @Implementation(minSdk = P)
+  protected static boolean nativeResolveAttrs(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int def_style_attr,
+      @StyleRes int def_style_resid,
+      @Nullable int[] java_values,
+      @NonNull int[] java_attrs,
+      @NonNull int[] out_java_values,
+      @NonNull int[] out_java_indices) {
+    int attrs_len = java_attrs.length;
+    int out_values_len = out_java_values.length;
+    if (out_values_len < (attrs_len * STYLE_NUM_ENTRIES)) {
+      throw new IndexOutOfBoundsException("outValues too small");
+    }
+
+    int[] attrs =
+        java_attrs; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_attrs, null));
+    if (attrs == null) {
+      return JNI_FALSE;
+    }
+
+    int[] values = null;
+    int values_len = 0;
+    if (java_values != null) {
+      values_len = java_values.length;
+      values =
+          java_values; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_values, null));
+      if (values == null) {
+        // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+        return JNI_FALSE;
+      }
+    }
+
+    int[] out_values = out_java_values;
+    // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_values, null));
+    if (out_values == null) {
+      // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+      // if (values != null) {
+      //   env.ReleasePrimitiveArrayCritical(java_values, values, JNI_ABORT);
+      // }
+      return JNI_FALSE;
+    }
+
+    int[] out_indices = null;
+    if (out_java_indices != null) {
+      int out_indices_len = out_java_indices.length;
+      if (out_indices_len > attrs_len) {
+        out_indices = out_java_indices;
+        // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_indices, null));
+        if (out_indices == null) {
+          // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+          // if (values != null) {
+          //   env.ReleasePrimitiveArrayCritical(java_values, values, JNI_ABORT);
+          // }
+          // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, JNI_ABORT);
+          return JNI_FALSE;
+        }
+      }
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+
+    boolean result =
+        ResolveAttrs(
+            theme,
+            (int) (def_style_attr),
+            (int) (def_style_resid),
+            values,
+            values_len,
+            attrs,
+            attrs_len,
+            out_values,
+            out_indices);
+    // if (out_indices != null) {
+    //   env.ReleasePrimitiveArrayCritical(out_java_indices, out_indices, 0);
+    // }
+
+    // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, 0);
+    // if (values != null) {
+    //   env.ReleasePrimitiveArrayCritical(java_values, values, JNI_ABORT);
+    // }
+    // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+    return result ? JNI_TRUE : JNI_FALSE;
+  }
+
+  // static jboolean NativeRetrieveAttributes(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                          jlong xml_parser_ptr, jintArray java_attrs,
+  //                                          jintArray out_java_values, jintArray out_java_indices)
+  // {
+  @Implementation(minSdk = P)
+  protected static boolean nativeRetrieveAttributes(
+      long ptr,
+      long xml_parser_ptr,
+      @NonNull int[] java_attrs,
+      @NonNull int[] out_java_values,
+      @NonNull int[] out_java_indices) {
+    int attrs_len = java_attrs.length;
+    int out_values_len = out_java_values.length;
+    if (out_values_len < (attrs_len * STYLE_NUM_ENTRIES)) {
+      throw new IndexOutOfBoundsException("outValues too small");
+    }
+
+    int[] attrs =
+        java_attrs; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_attrs, null));
+    if (attrs == null) {
+      return JNI_FALSE;
+    }
+
+    int[] out_values = out_java_values;
+    // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_values, null));
+    if (out_values == null) {
+      // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+      return JNI_FALSE;
+    }
+
+    int[] out_indices = null;
+    if (out_java_indices != null) {
+      int out_indices_len = out_java_indices.length;
+      if (out_indices_len > attrs_len) {
+        out_indices = out_java_indices;
+        // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_indices, null));
+        if (out_indices == null) {
+          // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+          // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, JNI_ABORT);
+          return JNI_FALSE;
+        }
+      }
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResXMLParser xml_parser = NATIVE_RES_XML_PARSERS.getNativeObject(xml_parser_ptr);
+
+    boolean result =
+        RetrieveAttributes(assetmanager, xml_parser, attrs, attrs_len, out_values, out_indices);
+
+    // if (out_indices != null) {
+    //   env.ReleasePrimitiveArrayCritical(out_java_indices, out_indices, 0);
+    // }
+    // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, 0);
+    // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+    return result;
+  }
+
+  // static jlong NativeThemeCreate(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P)
+  protected static long nativeThemeCreate(long ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    return Registries.NATIVE_THEME9_REGISTRY.register(assetmanager.NewTheme());
+  }
+
+  // static void NativeThemeDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong theme_ptr) {
+  @Implementation(minSdk = P, maxSdk = R)
+  protected static void nativeThemeDestroy(long theme_ptr) {
+    Registries.NATIVE_THEME9_REGISTRY.unregister(theme_ptr);
+  }
+
+  @Implementation(minSdk = S)
+  protected void releaseTheme(long ptr) {
+    Registries.NATIVE_THEME9_REGISTRY.unregister(ptr);
+    reflector(AssetManagerReflector.class, realAssetManager).releaseTheme(ptr);
+  }
+
+  // static void NativeThemeApplyStyle(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                                   jint resid, jboolean force) {
+  @Implementation(minSdk = P)
+  protected static void nativeThemeApplyStyle(
+      long ptr, long theme_ptr, @StyleRes int resid, boolean force) {
+    // AssetManager is accessed via the theme, so grab an explicit lock here.
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+    theme.ApplyStyle(resid, force);
+
+    // TODO(adamlesinski): Consider surfacing exception when result is failure.
+    // CTS currently expects no exceptions from this method.
+    // std::string error_msg = StringPrintf("Failed to apply style 0x%08x to theme", resid);
+    // throw new IllegalArgumentException(error_msg.c_str());
+  }
+
+  // static void NativeThemeCopy(JNIEnv* env, jclass /*clazz*/, jlong dst_theme_ptr,
+  //                             jlong src_theme_ptr) {
+  @Implementation(minSdk = P, maxSdk = P)
+  protected static void nativeThemeCopy(long dst_theme_ptr, long src_theme_ptr) {
+    Theme dst_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(dst_theme_ptr);
+    Theme src_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(src_theme_ptr);
+    if (!dst_theme.SetTo(src_theme)) {
+      throw new IllegalArgumentException("Themes are from different AssetManagers");
+    }
+  }
+
+  // static void NativeThemeCopy(JNIEnv* env, jclass /*clazz*/, jlong dst_asset_manager_ptr,
+  //     jlong dst_theme_ptr, jlong src_asset_manager_ptr, jlong src_theme_ptr) {
+  @Implementation(minSdk = Q)
+  protected static void nativeThemeCopy(
+      long dst_asset_manager_ptr,
+      long dst_theme_ptr,
+      long src_asset_manager_ptr,
+      long src_theme_ptr) {
+    Theme dst_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(dst_theme_ptr);
+    Theme src_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(src_theme_ptr);
+    if (dst_asset_manager_ptr != src_asset_manager_ptr) {
+      CppAssetManager2 dst_assetmanager = AssetManagerFromLong(dst_asset_manager_ptr);
+      CHECK(dst_theme.GetAssetManager() == dst_assetmanager);
+      // (void) dst_assetmanager;
+
+      CppAssetManager2 src_assetmanager = AssetManagerFromLong(src_asset_manager_ptr);
+      CHECK(src_theme.GetAssetManager() == src_assetmanager);
+      // (void) src_assetmanager;
+
+      dst_theme.SetTo(src_theme);
+    } else {
+      dst_theme.SetTo(src_theme);
+    }
+  }
+
+  // static void NativeThemeClear(JNIEnv* /*env*/, jclass /*clazz*/, jlong theme_ptr) {
+  @Implementation(minSdk = P, maxSdk = R)
+  protected static void nativeThemeClear(long themePtr) {
+    Registries.NATIVE_THEME9_REGISTRY.getNativeObject(themePtr).Clear();
+  }
+
+  // static jint NativeThemeGetAttributeValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong
+  // theme_ptr,
+  //                                          jint resid, jobject typed_value,
+  //                                          jboolean resolve_references) {
+  @Implementation(minSdk = P)
+  protected static int nativeThemeGetAttributeValue(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int resid,
+      @NonNull TypedValue typed_value,
+      boolean resolve_references) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager; // huh?
+
+    final Ref<Res_value> value = new Ref<>(null);
+    final Ref<Integer> flags = new Ref<>(null);
+    ApkAssetsCookie cookie = theme.GetAttribute(resid, value, flags);
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Integer> ref = new Ref<>(0);
+    if (resolve_references) {
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      cookie = theme.GetAssetManager().ResolveReference(cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+      }
+    }
+    return CopyValue(cookie, value.get(), ref.get(), flags.get(), null, typed_value);
+  }
+
+  // static void NativeThemeDump(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                             jint priority, jstring tag, jstring prefix) {
+  @Implementation(minSdk = P)
+  protected static void nativeThemeDump(
+      long ptr, long theme_ptr, int priority, String tag, String prefix) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+    // (void) theme;
+    // (void) priority;
+    // (void) tag;
+    // (void) prefix;
+  }
+
+  // static jint NativeThemeGetChangingConfigurations(JNIEnv* /*env*/, jclass /*clazz*/,
+  //                                                  jlong theme_ptr) {
+  @Implementation(minSdk = P)
+  protected static @NativeConfig int nativeThemeGetChangingConfigurations(long theme_ptr) {
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    return (int) (theme.GetChangingConfigurations());
+  }
+
+  // static void NativeAssetDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static void nativeAssetDestroy(long asset_ptr) {
+    Registries.NATIVE_ASSET_REGISTRY.unregister(asset_ptr);
+  }
+
+  // static jint NativeAssetReadChar(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static int nativeAssetReadChar(long asset_ptr) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    byte[] b = new byte[1];
+    int res = asset.read(b, 1);
+    return res == 1 ? (int) (b[0]) & 0xff : -1;
+  }
+
+  // static jint NativeAssetRead(JNIEnv* env, jclass /*clazz*/, jlong asset_ptr, jbyteArray
+  // java_buffer,
+  //                             jint offset, jint len) {
+  @Implementation(minSdk = P)
+  protected static int nativeAssetRead(long asset_ptr, byte[] java_buffer, int offset, int len)
+      throws IOException {
+    if (len == 0) {
+      return 0;
+    }
+
+    int buffer_len = java_buffer.length;
+    if (offset < 0
+        || offset >= buffer_len
+        || len < 0
+        || len > buffer_len
+        || offset > buffer_len - len) {
+      throw new IndexOutOfBoundsException();
+    }
+
+    // ScopedByteArrayRW byte_array(env, java_buffer);
+    // if (byte_array.get() == null) {
+    //   return -1;
+    // }
+
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    // sint res = asset.read(byte_array.get() + offset, len);
+    int res = asset.read(java_buffer, offset, len);
+    if (res < 0) {
+      throw new IOException();
+    }
+    return res > 0 ? (int) (res) : -1;
+  }
+
+  // static jlong NativeAssetSeek(JNIEnv* env, jclass /*clazz*/, jlong asset_ptr, jlong offset,
+  //                              jint whence) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetSeek(long asset_ptr, long offset, int whence) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    return asset.seek((offset), (whence > 0 ? SEEK_END : (whence < 0 ? SEEK_SET : SEEK_CUR)));
+  }
+
+  // static jlong NativeAssetGetLength(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetGetLength(long asset_ptr) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    return asset.getLength();
+  }
+
+  // static jlong NativeAssetGetRemainingLength(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr)
+  // {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetGetRemainingLength(long asset_ptr) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    return asset.getRemainingLength();
+  }
+
+  // ----------------------------------------------------------------------------
+
+  // JNI registration.
+  // static JNINativeMethod gAssetManagerMethods[] = {
+  //     // AssetManager setup methods.
+  //     {"nativeCreate", "()J", (void*)NativeCreate},
+  //   {"nativeDestroy", "(J)V", (void*)NativeDestroy},
+  //   {"nativeSetApkAssets", "(J[Landroid/content/res/ApkAssets;Z)V", (void*)NativeSetApkAssets},
+  //   {"nativeSetConfiguration", "(JIILjava/lang/String;IIIIIIIIIIIIIII)V",
+  //   (void*)NativeSetConfiguration},
+  //   {"nativeGetAssignedPackageIdentifiers", "(J)Landroid/util/SparseArray;",
+  //   (void*)NativeGetAssignedPackageIdentifiers},
+  //
+  //   // AssetManager file methods.
+  //   {"nativeList", "(JLjava/lang/String;)[Ljava/lang/String;", (void*)NativeList},
+  //   {"nativeOpenAsset", "(JLjava/lang/String;I)J", (void*)NativeOpenAsset},
+  //   {"nativeOpenAssetFd", "(JLjava/lang/String;[J)Landroid/os/ParcelFileDescriptor;",
+  //   (void*)NativeOpenAssetFd},
+  //   {"nativeOpenNonAsset", "(JILjava/lang/String;I)J", (void*)NativeOpenNonAsset},
+  //   {"nativeOpenNonAssetFd", "(JILjava/lang/String;[J)Landroid/os/ParcelFileDescriptor;",
+  //   (void*)NativeOpenNonAssetFd},
+  //   {"nativeOpenXmlAsset", "(JILjava/lang/String;)J", (void*)NativeOpenXmlAsset},
+  //
+  //   // AssetManager resource methods.
+  //   {"nativeGetResourceValue", "(JISLandroid/util/TypedValue;Z)I",
+  // (void*)NativeGetResourceValue},
+  //   {"nativeGetResourceBagValue", "(JIILandroid/util/TypedValue;)I",
+  //   (void*)NativeGetResourceBagValue},
+  //   {"nativeGetStyleAttributes", "(JI)[I", (void*)NativeGetStyleAttributes},
+  //   {"nativeGetResourceStringArray", "(JI)[Ljava/lang/String;",
+  //   (void*)NativeGetResourceStringArray},
+  //   {"nativeGetResourceStringArrayInfo", "(JI)[I", (void*)NativeGetResourceStringArrayInfo},
+  //   {"nativeGetResourceIntArray", "(JI)[I", (void*)NativeGetResourceIntArray},
+  //   {"nativeGetResourceArraySize", "(JI)I", (void*)NativeGetResourceArraySize},
+  //   {"nativeGetResourceArray", "(JI[I)I", (void*)NativeGetResourceArray},
+  //
+  //   // AssetManager resource name/ID methods.
+  //   {"nativeGetResourceIdentifier", "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
+  //   (void*)NativeGetResourceIdentifier},
+  //   {"nativeGetResourceName", "(JI)Ljava/lang/String;", (void*)NativeGetResourceName},
+  //   {"nativeGetResourcePackageName", "(JI)Ljava/lang/String;",
+  // (void*)NativeGetResourcePackageName},
+  //   {"nativeGetResourceTypeName", "(JI)Ljava/lang/String;", (void*)NativeGetResourceTypeName},
+  //   {"nativeGetResourceEntryName", "(JI)Ljava/lang/String;", (void*)NativeGetResourceEntryName},
+  //   {"nativeGetLocales", "(JZ)[Ljava/lang/String;", (void*)NativeGetLocales},
+  //   {"nativeGetSizeConfigurations", "(J)[Landroid/content/res/Configuration;",
+  //   (void*)NativeGetSizeConfigurations},
+  //
+  //   // Style attribute related methods.
+  //   {"nativeApplyStyle", "(JJIIJ[IJJ)V", (void*)NativeApplyStyle},
+  //   {"nativeResolveAttrs", "(JJII[I[I[I[I)Z", (void*)NativeResolveAttrs},
+  //   {"nativeRetrieveAttributes", "(JJ[I[I[I)Z", (void*)NativeRetrieveAttributes},
+  //
+  //   // Theme related methods.
+  //   {"nativeThemeCreate", "(J)J", (void*)NativeThemeCreate},
+  //   {"nativeThemeDestroy", "(J)V", (void*)NativeThemeDestroy},
+  //   {"nativeThemeApplyStyle", "(JJIZ)V", (void*)NativeThemeApplyStyle},
+  //   {"nativeThemeCopy", "(JJ)V", (void*)NativeThemeCopy},
+  //   {"nativeThemeClear", "(J)V", (void*)NativeThemeClear},
+  //   {"nativeThemeGetAttributeValue", "(JJILandroid/util/TypedValue;Z)I",
+  //   (void*)NativeThemeGetAttributeValue},
+  //   {"nativeThemeDump", "(JJILjava/lang/String;Ljava/lang/String;)V", (void*)NativeThemeDump},
+  //   {"nativeThemeGetChangingConfigurations", "(J)I",
+  // (void*)NativeThemeGetChangingConfigurations},
+  //
+  //   // AssetInputStream methods.
+  //   {"nativeAssetDestroy", "(J)V", (void*)NativeAssetDestroy},
+  //   {"nativeAssetReadChar", "(J)I", (void*)NativeAssetReadChar},
+  //   {"nativeAssetRead", "(J[BII)I", (void*)NativeAssetRead},
+  //   {"nativeAssetSeek", "(JJI)J", (void*)NativeAssetSeek},
+  //   {"nativeAssetGetLength", "(J)J", (void*)NativeAssetGetLength},
+  //   {"nativeAssetGetRemainingLength", "(J)J", (void*)NativeAssetGetRemainingLength},
+  //
+  //   // System/idmap related methods.
+  //   {"nativeVerifySystemIdmaps", "()V", (void*)NativeVerifySystemIdmaps},
+  //
+  //   // Global management/debug methods.
+  //   {"getGlobalAssetCount", "()I", (void*)NativeGetGlobalAssetCount},
+  //   {"getAssetAllocations", "()Ljava/lang/String;", (void*)NativeGetAssetAllocations},
+  //   {"getGlobalAssetManagerCount", "()I", (void*)NativeGetGlobalAssetManagerCount},
+  //   };
+  //
+  //   int register_android_content_AssetManager(JNIEnv* env) {
+  //   jclass apk_assets_class = FindClassOrDie(env, "android/content/res/ApkAssets");
+  //   gApkAssetsFields.native_ptr = GetFieldIDOrDie(env, apk_assets_class, "mNativePtr", "J");
+  //
+  //   jclass typedValue = FindClassOrDie(env, "android/util/TypedValue");
+  //   gTypedValueOffsets.mType = GetFieldIDOrDie(env, typedValue, "type", "I");
+  //   gTypedValueOffsets.mData = GetFieldIDOrDie(env, typedValue, "data", "I");
+  //   gTypedValueOffsets.mString =
+  //   GetFieldIDOrDie(env, typedValue, "string", "Ljava/lang/CharSequence;");
+  //   gTypedValueOffsets.mAssetCookie = GetFieldIDOrDie(env, typedValue, "assetCookie", "I");
+  //   gTypedValueOffsets.mResourceId = GetFieldIDOrDie(env, typedValue, "resourceId", "I");
+  //   gTypedValueOffsets.mChangingConfigurations =
+  //   GetFieldIDOrDie(env, typedValue, "changingConfigurations", "I");
+  //   gTypedValueOffsets.mDensity = GetFieldIDOrDie(env, typedValue, "density", "I");
+  //
+  //   jclass assetFd = FindClassOrDie(env, "android/content/res/AssetFileDescriptor");
+  //   gAssetFileDescriptorOffsets.mFd =
+  //   GetFieldIDOrDie(env, assetFd, "mFd", "Landroid/os/ParcelFileDescriptor;");
+  //   gAssetFileDescriptorOffsets.mStartOffset = GetFieldIDOrDie(env, assetFd, "mStartOffset",
+  // "J");
+  //   gAssetFileDescriptorOffsets.mLength = GetFieldIDOrDie(env, assetFd, "mLength", "J");
+  //
+  //   jclass assetManager = FindClassOrDie(env, "android/content/res/AssetManager");
+  //   gAssetManagerOffsets.mObject = GetFieldIDOrDie(env, assetManager, "mObject", "J");
+  //
+  //   jclass stringClass = FindClassOrDie(env, "java/lang/String");
+  //   g_stringClass = MakeGlobalRefOrDie(env, stringClass);
+  //
+  //   jclass sparseArrayClass = FindClassOrDie(env, "android/util/SparseArray");
+  //   gSparseArrayOffsets.classObject = MakeGlobalRefOrDie(env, sparseArrayClass);
+  //   gSparseArrayOffsets.constructor =
+  //   GetMethodIDOrDie(env, gSparseArrayOffsets.classObject, "<init>", "()V");
+  //   gSparseArrayOffsets.put =
+  //   GetMethodIDOrDie(env, gSparseArrayOffsets.classObject, "put", "(ILjava/lang/Object;)V");
+  //
+  //   jclass configurationClass = FindClassOrDie(env, "android/content/res/Configuration");
+  //   gConfigurationOffsets.classObject = MakeGlobalRefOrDie(env, configurationClass);
+  //   gConfigurationOffsets.constructor = GetMethodIDOrDie(env, configurationClass, "<init>",
+  // "()V");
+  //   gConfigurationOffsets.mSmallestScreenWidthDpOffset =
+  //   GetFieldIDOrDie(env, configurationClass, "smallestScreenWidthDp", "I");
+  //   gConfigurationOffsets.mScreenWidthDpOffset =
+  //   GetFieldIDOrDie(env, configurationClass, "screenWidthDp", "I");
+  //   gConfigurationOffsets.mScreenHeightDpOffset =
+  //   GetFieldIDOrDie(env, configurationClass, "screenHeightDp", "I");
+  //
+  //   return RegisterMethodsOrDie(env, "android/content/res/AssetManager", gAssetManagerMethods,
+  //   NELEM(gAssetManagerMethods));
+  //   }
+
+  @ForType(AssetManager.class)
+  interface AssetManagerReflector {
+
+    @Static
+    @Direct
+    void createSystemAssetsInZygoteLocked();
+
+    @Direct
+    void releaseTheme(long ptr);
+  }
+}
+// namespace android
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager9.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager9.java
new file mode 100644
index 0000000..1db172c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager9.java
@@ -0,0 +1,1930 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.res.android.ApkAssetsCookie.K_INVALID_COOKIE;
+import static org.robolectric.res.android.ApkAssetsCookie.kInvalidCookie;
+import static org.robolectric.res.android.Asset.SEEK_CUR;
+import static org.robolectric.res.android.Asset.SEEK_END;
+import static org.robolectric.res.android.Asset.SEEK_SET;
+import static org.robolectric.res.android.AttributeResolution9.ApplyStyle;
+import static org.robolectric.res.android.AttributeResolution9.ResolveAttrs;
+import static org.robolectric.res.android.AttributeResolution9.RetrieveAttributes;
+import static org.robolectric.res.android.Errors.NO_ERROR;
+import static org.robolectric.res.android.Registries.NATIVE_RES_XML_PARSERS;
+import static org.robolectric.res.android.Registries.NATIVE_RES_XML_TREES;
+import static org.robolectric.res.android.Util.ATRACE_NAME;
+import static org.robolectric.res.android.Util.CHECK;
+import static org.robolectric.res.android.Util.JNI_FALSE;
+import static org.robolectric.res.android.Util.JNI_TRUE;
+import static org.robolectric.res.android.Util.isTruthy;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.AnyRes;
+import android.annotation.ArrayRes;
+import android.annotation.AttrRes;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.StyleRes;
+import android.content.res.ApkAssets;
+import android.content.res.AssetManager;
+import android.content.res.Configuration;
+import android.content.res.Configuration.NativeConfig;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.util.ArraySet;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import dalvik.system.VMRuntime;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.Fs;
+import org.robolectric.res.android.ApkAssetsCookie;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.AssetDir;
+import org.robolectric.res.android.AssetPath;
+import org.robolectric.res.android.CppApkAssets;
+import org.robolectric.res.android.CppAssetManager;
+import org.robolectric.res.android.CppAssetManager2;
+import org.robolectric.res.android.CppAssetManager2.ResolvedBag;
+import org.robolectric.res.android.CppAssetManager2.ResourceName;
+import org.robolectric.res.android.CppAssetManager2.Theme;
+import org.robolectric.res.android.DynamicRefTable;
+import org.robolectric.res.android.Ref;
+import org.robolectric.res.android.Registries;
+import org.robolectric.res.android.ResStringPool;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.android.ResXMLParser;
+import org.robolectric.res.android.ResXMLTree;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/jni/android_util_AssetManager.cpp
+
+@Implements(
+    value = AssetManager.class,
+    minSdk = Build.VERSION_CODES.P,
+    shadowPicker = ShadowAssetManager.Picker.class)
+@SuppressWarnings("NewApi")
+public class ShadowArscAssetManager9 extends ShadowAssetManager.ArscBase {
+
+  private static final int STYLE_NUM_ENTRIES = 6;
+  private static final int STYLE_TYPE = 0;
+  private static final int STYLE_DATA = 1;
+  private static final int STYLE_ASSET_COOKIE = 2;
+  private static final int STYLE_RESOURCE_ID = 3;
+  private static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+  private static final int STYLE_DENSITY = 5;
+
+  private static CppAssetManager2 systemCppAssetManager2;
+  private static long systemCppAssetManager2Ref;
+  private static boolean inNonSystemConstructor;
+  private static ApkAssets[] cachedSystemApkAssets;
+  private static ArraySet<ApkAssets> cachedSystemApkAssetsSet;
+
+  @RealObject AssetManager realAssetManager;
+
+  //  @RealObject  //  protected AssetManager realObject;
+
+  // #define ATRACE_TAG ATRACE_TAG_RESOURCES
+  // #define LOG_TAG "asset"
+  //
+  // #include <inttypes.h>
+  // #include <linux/capability.h>
+  // #include <stdio.h>
+  // #include <sys/stat.h>
+  // #include <sys/system_properties.h>
+  // #include <sys/types.h>
+  // #include <sys/wait.h>
+  //
+  // #include <private/android_filesystem_config.h> // for AID_SYSTEM
+  //
+  // #include "android-base/logging.h"
+  // #include "android-base/properties.h"
+  // #include "android-base/stringprintf.h"
+  // #include "android_runtime/android_util_AssetManager.h"
+  // #include "android_runtime/AndroidRuntime.h"
+  // #include "android_util_Binder.h"
+  // #include "androidfw/Asset.h"
+  // #include "androidfw/AssetManager.h"
+  // #include "androidfw/AssetManager2.h"
+  // #include "androidfw/AttributeResolution.h"
+  // #include "androidfw/MutexGuard.h"
+  // #include "androidfw/ResourceTypes.h"
+  // #include "core_jni_helpers.h"
+  // #include "jni.h"
+  // #include "nativehelper/JNIHelp.h"
+  // #include "nativehelper/ScopedPrimitiveArray.h"
+  // #include "nativehelper/ScopedStringChars.h"
+  // #include "nativehelper/String.h"
+  // #include "utils/Log.h"
+  // #include "utils/misc.h"
+  // #include "utils/String.h"
+  // #include "utils/Trace.h"
+  //
+  // extern "C" int capget(cap_user_header_t hdrp, cap_user_data_t datap);
+  // extern "C" int capset(cap_user_header_t hdrp, const cap_user_data_t datap);
+  //
+  // using ::android::base::StringPrintf;
+  //
+  // namespace android {
+  //
+  // // ----------------------------------------------------------------------------
+  //
+
+  // static class typedvalue_offsets_t {
+  //   jfieldID mType;
+  //   jfieldID mData;
+  //   jfieldID mString;
+  //   jfieldID mAssetCookie;
+  //   jfieldID mResourceId;
+  //   jfieldID mChangingConfigurations;
+  //   jfieldID mDensity;
+  // }
+  // static final typedvalue_offsets_t gTypedValueOffsets = new typedvalue_offsets_t();
+  //
+  // static class assetfiledescriptor_offsets_t {
+  //   jfieldID mFd;
+  //   jfieldID mStartOffset;
+  //   jfieldID mLength;
+  // }
+  // static final assetfiledescriptor_offsets_t gAssetFileDescriptorOffsets = new
+  // assetfiledescriptor_offsets_t();
+  //
+  // static class assetmanager_offsets_t
+  // {
+  //   jfieldID mObject;
+  // };
+  // // This is also used by asset_manager.cpp.
+  // static final assetmanager_offsets_t gAssetManagerOffsets = new assetmanager_offsets_t();
+  //
+  // static class apkassetsfields {
+  //   jfieldID native_ptr;
+  // }
+  // static final apkassetsfields gApkAssetsFields = new apkassetsfields();
+  //
+  // static class sparsearray_offsets_t {
+  //   jclass classObject;
+  //   jmethodID constructor;
+  //   jmethodID put;
+  // }
+  // static final sparsearray_offsets_t gSparseArrayOffsets = new sparsearray_offsets_t();
+  //
+  // static class configuration_offsets_t {
+  //   jclass classObject;
+  //   jmethodID constructor;
+  //   jfieldID mSmallestScreenWidthDpOffset;
+  //   jfieldID mScreenWidthDpOffset;
+  //   jfieldID mScreenHeightDpOffset;
+  // }
+  // static final configuration_offsets_t gConfigurationOffsets = new configuration_offsets_t();
+  //
+  // jclass g_stringClass = nullptr;
+  //
+  // // ----------------------------------------------------------------------------
+
+  @Implementation(maxSdk = Q)
+  protected static void createSystemAssetsInZygoteLocked() {
+    _AssetManager28_ _assetManagerStatic_ = reflector(_AssetManager28_.class);
+    AssetManager sSystem = _assetManagerStatic_.getSystem();
+    if (sSystem != null) {
+      return;
+    }
+
+    if (systemCppAssetManager2 == null) {
+      // first time! let the framework create a CppAssetManager2 and an AssetManager, which we'll
+      // hang on to.
+      reflector(AssetManagerReflector.class).createSystemAssetsInZygoteLocked();
+      cachedSystemApkAssets = _assetManagerStatic_.getSystemApkAssets();
+      cachedSystemApkAssetsSet = _assetManagerStatic_.getSystemApkAssetsSet();
+    } else {
+      // reuse the shared system CppAssetManager2; create a new AssetManager around it.
+      _assetManagerStatic_.setSystemApkAssets(cachedSystemApkAssets);
+      _assetManagerStatic_.setSystemApkAssetsSet(cachedSystemApkAssetsSet);
+
+      sSystem =
+          ReflectionHelpers.callConstructor(
+              AssetManager.class, ClassParameter.from(boolean.class, true /*sentinel*/));
+      sSystem.setApkAssets(cachedSystemApkAssets, false /*invalidateCaches*/);
+      ReflectionHelpers.setStaticField(AssetManager.class, "sSystem", sSystem);
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    // todo: ShadowPicker doesn't discriminate properly between concrete shadow classes for
+    // resetters...
+    if (!useLegacy() && RuntimeEnvironment.getApiLevel() >= P) {
+      _AssetManager28_ _assetManagerStatic_ = reflector(_AssetManager28_.class);
+      _assetManagerStatic_.setSystemApkAssetsSet(null);
+      _assetManagerStatic_.setSystemApkAssets(null);
+      _assetManagerStatic_.setSystem(null);
+    }
+  }
+
+  // Java asset cookies have 0 as an invalid cookie, but TypedArray expects < 0.
+  static int ApkAssetsCookieToJavaCookie(ApkAssetsCookie cookie) {
+    return cookie.intValue() != kInvalidCookie ? (cookie.intValue() + 1) : -1;
+  }
+
+  static ApkAssetsCookie JavaCookieToApkAssetsCookie(int cookie) {
+    return ApkAssetsCookie.forInt(cookie > 0 ? (cookie - 1) : kInvalidCookie);
+  }
+
+  // This is called by zygote (running as user root) as part of preloadResources.
+  // static void NativeVerifySystemIdmaps(JNIEnv* /*env*/, jclass /*clazz*/) {
+  @Implementation(minSdk = P, maxSdk = Q)
+  protected static void nativeVerifySystemIdmaps() {
+    return;
+
+    // todo: maybe implement?
+    // switch (pid_t pid = fork()) {
+    //   case -1:
+    //     PLOG(ERROR) << "failed to fork for idmap";
+    //     break;
+    //
+    //   // child
+    //   case 0: {
+    //     struct __user_cap_header_struct capheader;
+    //     struct __user_cap_data_struct capdata;
+    //
+    //     memset(&capheader, 0, sizeof(capheader));
+    //     memset(&capdata, 0, sizeof(capdata));
+    //
+    //     capheader.version = _LINUX_CAPABILITY_VERSION;
+    //     capheader.pid = 0;
+    //
+    //     if (capget(&capheader, &capdata) != 0) {
+    //       PLOG(ERROR) << "capget";
+    //       exit(1);
+    //     }
+    //
+    //     capdata.effective = capdata.permitted;
+    //     if (capset(&capheader, &capdata) != 0) {
+    //       PLOG(ERROR) << "capset";
+    //       exit(1);
+    //     }
+    //
+    //     if (setgid(AID_SYSTEM) != 0) {
+    //       PLOG(ERROR) << "setgid";
+    //       exit(1);
+    //     }
+    //
+    //     if (setuid(AID_SYSTEM) != 0) {
+    //       PLOG(ERROR) << "setuid";
+    //       exit(1);
+    //     }
+    //
+    //     // Generic idmap parameters
+    //     char* argv[8];
+    //     int argc = 0;
+    //     struct stat st;
+    //
+    //     memset(argv, 0, sizeof(argv));
+    //     argv[argc++] = AssetManager.IDMAP_BIN;
+    //     argv[argc++] = "--scan";
+    //     argv[argc++] = AssetManager.TARGET_PACKAGE_NAME;
+    //     argv[argc++] = AssetManager.TARGET_APK_PATH;
+    //     argv[argc++] = AssetManager.IDMAP_DIR;
+    //
+    //     // Directories to scan for overlays: if OVERLAY_THEME_DIR_PROPERTY is defined,
+    //     // use OVERLAY_DIR/<value of OVERLAY_THEME_DIR_PROPERTY> in addition to OVERLAY_DIR.
+    //     String overlay_theme_path = base.GetProperty(AssetManager.OVERLAY_THEME_DIR_PROPERTY,
+    //         "");
+    //     if (!overlay_theme_path.empty()) {
+    //       overlay_theme_path = String(AssetManager.OVERLAY_DIR) + "/" + overlay_theme_path;
+    //       if (stat(overlay_theme_path, &st) == 0) {
+    //         argv[argc++] = overlay_theme_path;
+    //       }
+    //     }
+    //
+    //     if (stat(AssetManager.OVERLAY_DIR, &st) == 0) {
+    //       argv[argc++] = AssetManager.OVERLAY_DIR;
+    //     }
+    //
+    //     if (stat(AssetManager.PRODUCT_OVERLAY_DIR, &st) == 0) {
+    //       argv[argc++] = AssetManager.PRODUCT_OVERLAY_DIR;
+    //     }
+    //
+    //     // Finally, invoke idmap (if any overlay directory exists)
+    //     if (argc > 5) {
+    //       execv(AssetManager.IDMAP_BIN, (char* const*)argv);
+    //       PLOG(ERROR) << "failed to execv for idmap";
+    //       exit(1); // should never get here
+    //     } else {
+    //       exit(0);
+    //     }
+    //   } break;
+    //
+    //   // parent
+    //   default:
+    //     waitpid(pid, null, 0);
+    //     break;
+    // }
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q, maxSdk = Build.VERSION_CODES.R)
+  protected static String[] nativeCreateIdmapsForStaticOverlaysTargetingAndroid() {
+    return new String[0];
+  }
+
+  static int CopyValue(
+      /*JNIEnv* env,*/ ApkAssetsCookie cookie,
+      Res_value value,
+      int ref,
+      int type_spec_flags,
+      ResTable_config config,
+      TypedValue out_typed_value) {
+    out_typed_value.type = value.dataType;
+    out_typed_value.assetCookie = ApkAssetsCookieToJavaCookie(cookie);
+    out_typed_value.data = value.data;
+    out_typed_value.string = null;
+    out_typed_value.resourceId = ref;
+    out_typed_value.changingConfigurations = type_spec_flags;
+    if (config != null) {
+      out_typed_value.density = config.density;
+    }
+    return (int) (ApkAssetsCookieToJavaCookie(cookie));
+  }
+
+  //  @Override
+  //  protected int addAssetPathNative(String path) {
+  //    throw new UnsupportedOperationException(); // todo
+  //  }
+
+  @Override
+  Collection<Path> getAllAssetDirs() {
+    ApkAssets[] apkAssetsArray = reflector(_AssetManager28_.class, realAssetManager).getApkAssets();
+
+    ArrayList<Path> assetDirs = new ArrayList<>();
+    for (ApkAssets apkAssets : apkAssetsArray) {
+      long apk_assets_native_ptr =
+          ((ShadowArscApkAssets9) Shadow.extract(apkAssets)).getNativePtr();
+      CppApkAssets cppApkAssets =
+          Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(apk_assets_native_ptr);
+
+      if (new File(cppApkAssets.GetPath()).isFile()) {
+        assetDirs.add(Fs.forJar(Paths.get(cppApkAssets.GetPath())).getPath("assets"));
+      } else {
+        assetDirs.add(Paths.get(cppApkAssets.GetPath()));
+      }
+    }
+    return assetDirs;
+  }
+
+  @Override
+  List<AssetPath> getAssetPaths() {
+    return AssetManagerForJavaObject(realAssetManager).getAssetPaths();
+  }
+
+  // ----------------------------------------------------------------------------
+
+  // interface AAssetManager {}
+  //
+  // // Let the opaque type AAssetManager refer to a guarded AssetManager2 instance.
+  // static class GuardedAssetManager implements AAssetManager {
+  //   CppAssetManager2 guarded_assetmanager = new CppAssetManager2();
+  // }
+
+  static CppAssetManager2 NdkAssetManagerForJavaObject(
+      /* JNIEnv* env,*/ AssetManager jassetmanager) {
+    // long assetmanager_handle = env.GetLongField(jassetmanager, gAssetManagerOffsets.mObject);
+    long assetmanager_handle = ReflectionHelpers.getField(jassetmanager, "mObject");
+    CppAssetManager2 am =
+        Registries.NATIVE_ASSET_MANAGER_REGISTRY.getNativeObject(assetmanager_handle);
+    if (am == null) {
+      throw new IllegalStateException("AssetManager has been finalized!");
+    }
+    return am;
+  }
+
+  static CppAssetManager2 AssetManagerForJavaObject(/* JNIEnv* env,*/ AssetManager jassetmanager) {
+    return NdkAssetManagerForJavaObject(jassetmanager);
+  }
+
+  static CppAssetManager2 AssetManagerFromLong(long ptr) {
+    // return *AssetManagerForNdkAssetManager(reinterpret_cast<AAssetManager>(ptr));
+    return Registries.NATIVE_ASSET_MANAGER_REGISTRY.getNativeObject(ptr);
+  }
+
+  static ParcelFileDescriptor ReturnParcelFileDescriptor(
+      /* JNIEnv* env,*/ Asset asset, long[] out_offsets) throws FileNotFoundException {
+    final Ref<Long> start_offset = new Ref<>(0L);
+    final Ref<Long> length = new Ref<>(0L);
+    FileDescriptor fd = asset.openFileDescriptor(start_offset, length);
+    // asset.reset();
+
+    if (fd == null) {
+      throw new FileNotFoundException(
+          "This file can not be opened as a file descriptor; it is probably compressed");
+    }
+
+    long[] offsets =
+        out_offsets; // reinterpret_cast<long*>(env.GetPrimitiveArrayCritical(out_offsets, 0));
+    if (offsets == null) {
+      // close(fd);
+      return null;
+    }
+
+    offsets[0] = start_offset.get();
+    offsets[1] = length.get();
+
+    // env.ReleasePrimitiveArrayCritical(out_offsets, offsets, 0);
+
+    FileDescriptor file_desc = fd; // jniCreateFileDescriptor(env, fd);
+    // if (file_desc == null) {
+    //   close(fd);
+    //   return null;
+    // }
+
+    // TODO: consider doing this
+    // return new ParcelFileDescriptor(file_desc);
+    return ParcelFileDescriptor.open(asset.getFile(), ParcelFileDescriptor.MODE_READ_ONLY);
+  }
+
+  /** Used for the creation of system assets. */
+  @Implementation(minSdk = P)
+  protected void __constructor__(boolean sentinel) {
+    inNonSystemConstructor = true;
+    try {
+      // call real constructor so field initialization happens.
+      invokeConstructor(
+          AssetManager.class, realAssetManager, ClassParameter.from(boolean.class, sentinel));
+    } finally {
+      inNonSystemConstructor = false;
+    }
+  }
+
+  // static jint NativeGetGlobalAssetCount(JNIEnv* /*env*/, jobject /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static int getGlobalAssetCount() {
+    return Asset.getGlobalCount();
+  }
+
+  // static jobject NativeGetAssetAllocations(JNIEnv* env, jobject /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static String getAssetAllocations() {
+    String alloc = Asset.getAssetAllocations();
+    if (alloc.length() <= 0) {
+      return null;
+    }
+    return alloc;
+  }
+
+  // static jint NativeGetGlobalAssetManagerCount(JNIEnv* /*env*/, jobject /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static int getGlobalAssetManagerCount() {
+    // TODO(adamlesinski): Switch to AssetManager2.
+    return CppAssetManager.getGlobalCount();
+  }
+
+  // static jlong NativeCreate(JNIEnv* /*env*/, jclass /*clazz*/) {
+  @Implementation(minSdk = P)
+  protected static long nativeCreate() {
+    // AssetManager2 needs to be protected by a lock. To avoid cache misses, we allocate the lock
+    // and
+    // AssetManager2 in a contiguous block (GuardedAssetManager).
+    // return reinterpret_cast<long>(new GuardedAssetManager());
+
+    long cppAssetManagerRef;
+
+    // we want to share a single instance of the system CppAssetManager2
+    if (inNonSystemConstructor) {
+      CppAssetManager2 appAssetManager = new CppAssetManager2();
+      cppAssetManagerRef = Registries.NATIVE_ASSET_MANAGER_REGISTRY.register(appAssetManager);
+    } else {
+      if (systemCppAssetManager2 == null) {
+        systemCppAssetManager2 = new CppAssetManager2();
+        systemCppAssetManager2Ref =
+            Registries.NATIVE_ASSET_MANAGER_REGISTRY.register(systemCppAssetManager2);
+      }
+
+      cppAssetManagerRef = systemCppAssetManager2Ref;
+    }
+
+    return cppAssetManagerRef;
+  }
+
+  // static void NativeDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P)
+  protected static void nativeDestroy(long ptr) {
+    if (ptr == systemCppAssetManager2Ref) {
+      // don't destroy the shared system CppAssetManager2!
+      return;
+    }
+
+    // delete reinterpret_cast<GuardedAssetManager*>(ptr);
+    Registries.NATIVE_ASSET_MANAGER_REGISTRY.unregister(ptr);
+  }
+
+  // static void NativeSetApkAssets(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                jobjectArray apk_assets_array, jboolean invalidate_caches) {
+  @Implementation(minSdk = P)
+  protected static void nativeSetApkAssets(
+      long ptr,
+      @NonNull android.content.res.ApkAssets[] apk_assets_array,
+      boolean invalidate_caches) {
+    ATRACE_NAME("AssetManager::SetApkAssets");
+
+    int apk_assets_len = apk_assets_array.length;
+    List<CppApkAssets> apk_assets = new ArrayList<>();
+    // apk_assets.reserve(apk_assets_len);
+    for (int i = 0; i < apk_assets_len; i++) {
+      android.content.res.ApkAssets apkAssets =
+          apk_assets_array[i]; // env.GetObjectArrayElement(apk_assets_array, i);
+      if (apkAssets == null) {
+        throw new NullPointerException(String.format("ApkAssets at index %d is null", i));
+      }
+
+      long apk_assets_native_ptr =
+          ((ShadowArscApkAssets9) Shadow.extract(apkAssets)).getNativePtr();
+      // if (env.ExceptionCheck()) {
+      //   return;
+      // }
+      apk_assets.add(Registries.NATIVE_APK_ASSETS_REGISTRY.getNativeObject(apk_assets_native_ptr));
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    assetmanager.SetApkAssets(apk_assets, invalidate_caches);
+  }
+
+  // static void NativeSetConfiguration(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint mcc, jint
+  // mnc,
+  //                                    jstring locale, jint orientation, jint touchscreen, jint
+  // density,
+  //                                    jint keyboard, jint keyboard_hidden, jint navigation,
+  //                                    jint screen_width, jint screen_height,
+  //                                    jint smallest_screen_width_dp, jint screen_width_dp,
+  //                                    jint screen_height_dp, jint screen_layout, jint ui_mode,
+  //                                    jint color_mode, jint major_version) {
+  @Implementation(minSdk = P)
+  protected static void nativeSetConfiguration(
+      long ptr,
+      int mcc,
+      int mnc,
+      @Nullable String locale,
+      int orientation,
+      int touchscreen,
+      int density,
+      int keyboard,
+      int keyboard_hidden,
+      int navigation,
+      int screen_width,
+      int screen_height,
+      int smallest_screen_width_dp,
+      int screen_width_dp,
+      int screen_height_dp,
+      int screen_layout,
+      int ui_mode,
+      int color_mode,
+      int major_version) {
+    ATRACE_NAME("AssetManager::SetConfiguration");
+
+    ResTable_config configuration = new ResTable_config();
+    // memset(&configuration, 0, sizeof(configuration));
+    configuration.mcc = (short) (mcc);
+    configuration.mnc = (short) (mnc);
+    configuration.orientation = (byte) (orientation);
+    configuration.touchscreen = (byte) (touchscreen);
+    configuration.density = (short) (density);
+    configuration.keyboard = (byte) (keyboard);
+    configuration.inputFlags = (byte) (keyboard_hidden);
+    configuration.navigation = (byte) (navigation);
+    configuration.screenWidth = (short) (screen_width);
+    configuration.screenHeight = (short) (screen_height);
+    configuration.smallestScreenWidthDp = (short) (smallest_screen_width_dp);
+    configuration.screenWidthDp = (short) (screen_width_dp);
+    configuration.screenHeightDp = (short) (screen_height_dp);
+    configuration.screenLayout = (byte) (screen_layout);
+    configuration.uiMode = (byte) (ui_mode);
+    configuration.colorMode = (byte) (color_mode);
+    configuration.sdkVersion = (short) (major_version);
+
+    if (locale != null) {
+      String locale_utf8 = locale;
+      CHECK(locale_utf8 != null);
+      configuration.setBcp47Locale(locale_utf8);
+    }
+
+    // Constants duplicated from Java class android.content.res.Configuration.
+    int kScreenLayoutRoundMask = 0x300;
+    int kScreenLayoutRoundShift = 8;
+
+    // In Java, we use a 32bit integer for screenLayout, while we only use an 8bit integer
+    // in C++. We must extract the round qualifier out of the Java screenLayout and put it
+    // into screenLayout2.
+    configuration.screenLayout2 =
+        (byte) ((screen_layout & kScreenLayoutRoundMask) >> kScreenLayoutRoundShift);
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    assetmanager.SetConfiguration(configuration);
+  }
+
+  // static jobject NativeGetAssignedPackageIdentifiers(JNIEnv* env, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P, maxSdk = Q)
+  protected static @NonNull SparseArray<String> nativeGetAssignedPackageIdentifiers(long ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+
+    SparseArray<String> sparse_array = new SparseArray<>();
+
+    if (sparse_array == null) {
+      // An exception is pending.
+      return null;
+    }
+
+    assetmanager.ForEachPackage(
+        (String package_name, byte package_id) -> {
+          String jpackage_name = package_name; // env.NewStringUTF(package_name);
+          if (jpackage_name == null) {
+            // An exception is pending.
+            return;
+          }
+
+          // env.CallVoidMethod(sparse_array, gSparseArrayOffsets.put, (int) (package_id),
+          //     jpackage_name);
+          sparse_array.put(package_id, jpackage_name);
+        });
+    return sparse_array;
+  }
+
+  // static jobjectArray NativeList(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring path) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String[] nativeList(long ptr, @NonNull String path)
+      throws IOException {
+    String path_utf8 = path;
+    if (path_utf8 == null) {
+      // This will throw NPE.
+      return null;
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    AssetDir asset_dir = assetmanager.OpenDir(path_utf8);
+    if (asset_dir == null) {
+      throw new FileNotFoundException(path_utf8);
+    }
+
+    int file_count = asset_dir.getFileCount();
+
+    String[] array = new String[file_count]; // env.NewObjectArray(file_count, g_stringClass, null);
+    // if (array == null) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < file_count; i++) {
+      String java_string = asset_dir.getFileName(i).string();
+
+      // Check for errors creating the strings (if malformed or no memory).
+      // if (env.ExceptionCheck()) {
+      //   return null;
+      // }
+
+      // env.SetObjectArrayElement(array, i, java_string);
+      array[i] = java_string;
+
+      // If we have a large amount of string in our array, we might overflow the
+      // local reference table of the VM.
+      // env.DeleteLocalRef(java_string);
+    }
+    return array;
+  }
+
+  // static jlong NativeOpenAsset(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring asset_path,
+  //                              jint access_mode) {
+  @Implementation(minSdk = P)
+  protected static long nativeOpenAsset(long ptr, @NonNull String asset_path, int access_mode)
+      throws FileNotFoundException {
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenAsset(%s)", asset_path_utf8));
+
+    if (access_mode != Asset.AccessMode.ACCESS_UNKNOWN.mode()
+        && access_mode != Asset.AccessMode.ACCESS_RANDOM.mode()
+        && access_mode != Asset.AccessMode.ACCESS_STREAMING.mode()
+        && access_mode != Asset.AccessMode.ACCESS_BUFFER.mode()) {
+      throw new IllegalArgumentException("Bad access mode");
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset = assetmanager.Open(asset_path_utf8, Asset.AccessMode.fromInt(access_mode));
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return Registries.NATIVE_ASSET_REGISTRY.register(asset);
+  }
+
+  // static jobject NativeOpenAssetFd(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring asset_path,
+  //                                  jlongArray out_offsets) {
+  @Implementation(minSdk = P)
+  protected static ParcelFileDescriptor nativeOpenAssetFd(
+      long ptr, @NonNull String asset_path, long[] out_offsets) throws IOException {
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return null;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenAssetFd(%s)", asset_path_utf8));
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset = assetmanager.Open(asset_path_utf8, Asset.AccessMode.ACCESS_RANDOM);
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return ReturnParcelFileDescriptor(asset, out_offsets);
+  }
+
+  // static jlong NativeOpenNonAsset(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint jcookie,
+  //                                 jstring asset_path, jint access_mode) {
+  @Implementation(minSdk = P)
+  protected static long nativeOpenNonAsset(
+      long ptr, int jcookie, @NonNull String asset_path, int access_mode)
+      throws FileNotFoundException {
+    ApkAssetsCookie cookie = JavaCookieToApkAssetsCookie(jcookie);
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenNonAsset(%s)", asset_path_utf8));
+
+    if (access_mode != Asset.AccessMode.ACCESS_UNKNOWN.mode()
+        && access_mode != Asset.AccessMode.ACCESS_RANDOM.mode()
+        && access_mode != Asset.AccessMode.ACCESS_STREAMING.mode()
+        && access_mode != Asset.AccessMode.ACCESS_BUFFER.mode()) {
+      throw new IllegalArgumentException("Bad access mode");
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset;
+    if (cookie.intValue() != kInvalidCookie) {
+      asset =
+          assetmanager.OpenNonAsset(asset_path_utf8, cookie, Asset.AccessMode.fromInt(access_mode));
+    } else {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, Asset.AccessMode.fromInt(access_mode));
+    }
+
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return Registries.NATIVE_ASSET_REGISTRY.register(asset);
+  }
+
+  // static jobject NativeOpenNonAssetFd(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint jcookie,
+  //                                     jstring asset_path, jlongArray out_offsets) {
+  @Implementation(minSdk = P)
+  protected static @Nullable ParcelFileDescriptor nativeOpenNonAssetFd(
+      long ptr, int jcookie, @NonNull String asset_path, @NonNull long[] out_offsets)
+      throws IOException {
+    ApkAssetsCookie cookie = JavaCookieToApkAssetsCookie(jcookie);
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return null;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenNonAssetFd(%s)", asset_path_utf8));
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset;
+    if (cookie.intValue() != kInvalidCookie) {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, cookie, Asset.AccessMode.ACCESS_RANDOM);
+    } else {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, Asset.AccessMode.ACCESS_RANDOM);
+    }
+
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+    return ReturnParcelFileDescriptor(asset, out_offsets);
+  }
+
+  // static jlong NativeOpenXmlAsset(JNIEnv* env, jobject /*clazz*/, jlong ptr, jint jcookie,
+  //                                 jstring asset_path) {
+  @Implementation(minSdk = P)
+  protected static long nativeOpenXmlAsset(long ptr, int jcookie, @NonNull String asset_path)
+      throws FileNotFoundException {
+    ApkAssetsCookie cookie = JavaCookieToApkAssetsCookie(jcookie);
+    String asset_path_utf8 = asset_path;
+    if (asset_path_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    ATRACE_NAME(String.format("AssetManager::OpenXmlAsset(%s)", asset_path_utf8));
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Asset asset;
+    if (cookie.intValue() != kInvalidCookie) {
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, cookie, Asset.AccessMode.ACCESS_RANDOM);
+    } else {
+      Ref<ApkAssetsCookie> cookieRef = new Ref<>(cookie);
+      asset = assetmanager.OpenNonAsset(asset_path_utf8, Asset.AccessMode.ACCESS_RANDOM, cookieRef);
+      cookie = cookieRef.get();
+    }
+
+    if (!isTruthy(asset)) {
+      throw new FileNotFoundException(asset_path_utf8);
+    }
+
+    // May be nullptr.
+    DynamicRefTable dynamic_ref_table = assetmanager.GetDynamicRefTableForCookie(cookie);
+
+    ResXMLTree xml_tree = new ResXMLTree(dynamic_ref_table);
+    int err = xml_tree.setTo(asset.getBuffer(true), (int) asset.getLength(), true);
+    // asset.reset();
+
+    if (err != NO_ERROR) {
+      throw new FileNotFoundException("Corrupt XML binary file");
+    }
+    return NATIVE_RES_XML_TREES.register(xml_tree);
+  }
+
+  // static jint NativeGetResourceValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
+  //                                    jshort density, jobject typed_value,
+  //                                    jboolean resolve_references) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceValue(
+      long ptr,
+      @AnyRes int resid,
+      short density,
+      @NonNull TypedValue typed_value,
+      boolean resolve_references) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final Ref<Res_value> value = new Ref<>(null);
+    final Ref<ResTable_config> selected_config = new Ref<>(null);
+    final Ref<Integer> flags = new Ref<>(0);
+    ApkAssetsCookie cookie =
+        assetmanager.GetResource(
+            resid, false /*may_be_bag*/, (short) (density), value, selected_config, flags);
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Integer> ref = new Ref<>(resid);
+    if (resolve_references) {
+      cookie = assetmanager.ResolveReference(cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+      }
+    }
+    return CopyValue(
+        cookie, value.get(), ref.get(), flags.get(), selected_config.get(), typed_value);
+  }
+
+  // static jint NativeGetResourceBagValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
+  //                                       jint bag_entry_id, jobject typed_value) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceBagValue(
+      long ptr, @AnyRes int resid, int bag_entry_id, @NonNull TypedValue typed_value) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Integer> type_spec_flags = new Ref<>(bag.type_spec_flags);
+    ApkAssetsCookie cookie = K_INVALID_COOKIE;
+    Res_value bag_value = null;
+    for (ResolvedBag.Entry entry : bag.entries) {
+      if (entry.key == (int) (bag_entry_id)) {
+        cookie = entry.cookie;
+        bag_value = entry.value;
+
+        // Keep searching (the old implementation did that).
+      }
+    }
+
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Res_value> value = new Ref<>(bag_value);
+    final Ref<Integer> ref = new Ref<>(resid);
+    final Ref<ResTable_config> selected_config = new Ref<>(null);
+    cookie = assetmanager.ResolveReference(cookie, value, selected_config, type_spec_flags, ref);
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+    return CopyValue(cookie, value.get(), ref.get(), type_spec_flags.get(), null, typed_value);
+  }
+
+  // static jintArray NativeGetStyleAttributes(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid)
+  // {
+  @Implementation(minSdk = P)
+  protected static @Nullable @AttrRes int[] nativeGetStyleAttributes(
+      long ptr, @StyleRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    int[] array = new int[bag.entry_count];
+    // if (env.ExceptionCheck()) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      int attr_resid = bag.entries[i].key;
+      // env.SetIntArrayRegion(array, i, 1, &attr_resid);
+      array[i] = attr_resid;
+    }
+    return array;
+  }
+
+  // static jobjectArray NativeGetResourceStringArray(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                                  jint resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String[] nativeGetResourceStringArray(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    String[] array = new String[bag.entry_count];
+    if (array == null) {
+      return null;
+    }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+
+      // Resolve any references to their final value.
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      final Ref<Integer> flags = new Ref<>(0);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        return null;
+      }
+
+      if (value.get().dataType == Res_value.TYPE_STRING) {
+        CppApkAssets apk_assets = assetmanager.GetApkAssets().get(cookie.intValue());
+        ResStringPool pool = apk_assets.GetLoadedArsc().GetStringPool();
+
+        String java_string = null;
+        int str_len;
+        String str_utf8 = pool.stringAt(value.get().data);
+        if (str_utf8 != null) {
+          java_string = str_utf8;
+        } else {
+          String str_utf16 = pool.stringAt(value.get().data);
+          java_string = str_utf16;
+        }
+
+        // // Check for errors creating the strings (if malformed or no memory).
+        // if (env.ExceptionCheck()) {
+        //   return null;
+        // }
+
+        // env.SetObjectArrayElement(array, i, java_string);
+        array[i] = java_string;
+
+        // If we have a large amount of string in our array, we might overflow the
+        // local reference table of the VM.
+        // env.DeleteLocalRef(java_string);
+      }
+    }
+    return array;
+  }
+
+  // static jintArray NativeGetResourceStringArrayInfo(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                                   jint resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable int[] nativeGetResourceStringArrayInfo(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    int[] array = new int[bag.entry_count * 2];
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int[] buffer = array; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(array, null));
+    // if (buffer == null) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      final Ref<Integer> flags = new Ref<>(0);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        // env.ReleasePrimitiveArrayCritical(array, buffer, JNI_ABORT);
+        return null;
+      }
+
+      int string_index = -1;
+      if (value.get().dataType == Res_value.TYPE_STRING) {
+        string_index = (int) (value.get().data);
+      }
+
+      buffer[i * 2] = ApkAssetsCookieToJavaCookie(cookie);
+      buffer[(i * 2) + 1] = string_index;
+    }
+    // env.ReleasePrimitiveArrayCritical(array, buffer, 0);
+    return array;
+  }
+
+  // static jintArray NativeGetResourceIntArray(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint
+  // resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable int[] nativeGetResourceIntArray(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return null;
+    }
+
+    int[] array = new int[bag.entry_count];
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int[] buffer = array; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(array, null));
+    // if (buffer == null) {
+    //   return null;
+    // }
+
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      final Ref<Integer> flags = new Ref<>(0);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        // env.ReleasePrimitiveArrayCritical(array, buffer, JNI_ABORT);
+        return null;
+      }
+
+      if (value.get().dataType >= Res_value.TYPE_FIRST_INT
+          && value.get().dataType <= Res_value.TYPE_LAST_INT) {
+        buffer[i] = (int) (value.get().data);
+      }
+    }
+    // env.ReleasePrimitiveArrayCritical(array, buffer, 0);
+    return array;
+  }
+
+  // static jint NativeGetResourceArraySize(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr, jint
+  // resid) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceArraySize(long ptr, @ArrayRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return -1;
+    }
+    return (int) (bag.entry_count);
+  }
+
+  // static jint NativeGetResourceArray(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid,
+  //                                    jintArray out_data) {
+  @Implementation(minSdk = P)
+  protected static int nativeGetResourceArray(
+      long ptr, @ArrayRes int resid, @NonNull int[] out_data) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResolvedBag bag = assetmanager.GetBag(resid);
+    if (bag == null) {
+      return -1;
+    }
+
+    int out_data_length = out_data.length;
+    // if (env.ExceptionCheck()) {
+    //   return -1;
+    // }
+
+    if ((int) (bag.entry_count) > out_data_length * STYLE_NUM_ENTRIES) {
+      throw new IllegalArgumentException("Input array is not large enough");
+    }
+
+    int[] buffer =
+        out_data; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_data, null));
+    if (buffer == null) {
+      return -1;
+    }
+
+    int[] cursor = buffer;
+    for (int i = 0; i < bag.entry_count; i++) {
+      ResolvedBag.Entry entry = bag.entries[i];
+      final Ref<Res_value> value = new Ref<>(entry.value);
+      final Ref<ResTable_config> selected_config = new Ref<>(new ResTable_config());
+      selected_config.get().density = 0;
+      final Ref<Integer> flags = new Ref<>(bag.type_spec_flags);
+      final Ref<Integer> ref = new Ref<>(0);
+      ApkAssetsCookie cookie =
+          assetmanager.ResolveReference(entry.cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        // env.ReleasePrimitiveArrayCritical(out_data, buffer, JNI_ABORT);
+        return -1;
+      }
+
+      // Deal with the special @null value -- it turns back to TYPE_NULL.
+      if (value.get().dataType == Res_value.TYPE_REFERENCE && value.get().data == 0) {
+        value.set(Res_value.NULL_VALUE);
+      }
+
+      int offset = i * STYLE_NUM_ENTRIES;
+      cursor[offset + STYLE_TYPE] = (int) (value.get().dataType);
+      cursor[offset + STYLE_DATA] = (int) (value.get().data);
+      cursor[offset + STYLE_ASSET_COOKIE] = ApkAssetsCookieToJavaCookie(cookie);
+      cursor[offset + STYLE_RESOURCE_ID] = (int) (ref.get());
+      cursor[offset + STYLE_CHANGING_CONFIGURATIONS] = (int) (flags.get());
+      cursor[offset + STYLE_DENSITY] = (int) (selected_config.get().density);
+      // cursor += STYLE_NUM_ENTRIES;
+    }
+    // env.ReleasePrimitiveArrayCritical(out_data, buffer, 0);
+    return (int) (bag.entry_count);
+  }
+
+  // static jint NativeGetResourceIdentifier(JNIEnv* env, jclass /*clazz*/, jlong ptr, jstring name,
+  //                                         jstring def_type, jstring def_package) {
+  @Implementation(minSdk = P)
+  protected static @AnyRes int nativeGetResourceIdentifier(
+      long ptr, @NonNull String name, @Nullable String def_type, @Nullable String def_package) {
+    String name_utf8 = name;
+    if (name_utf8 == null) {
+      // This will throw NPE.
+      return 0;
+    }
+
+    String type = null;
+    if (def_type != null) {
+      String type_utf8 = def_type;
+      CHECK(type_utf8 != null);
+      type = type_utf8;
+    }
+
+    String package_ = null;
+    if (def_package != null) {
+      String package_utf8 = def_package;
+      CHECK(package_utf8 != null);
+      package_ = package_utf8;
+    }
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    return (int) (assetmanager.GetResourceId(name_utf8, type, package_));
+  }
+
+  // static jstring NativeGetResourceName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourceName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    CppAssetManager2.ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    StringBuilder result = new StringBuilder();
+    if (name.package_ != null) {
+      result.append(name.package_ /*, name.package_len*/);
+    }
+
+    if (name.type != null /*|| name.type16 != null*/) {
+      if (!(result.length() == 0)) {
+        result.append(":");
+      }
+
+      // if (name.type != null) {
+      result.append(name.type /*, name.type_len*/);
+      // } else {
+      //   result.append( /*util.Utf16ToUtf8(StringPiece16(*/ name.type16 /*, name.type_len))*/);
+      // }
+    }
+
+    if (name.entry != null /*|| name.entry16 != null*/) {
+      if (!(result.length() == 0)) {
+        result.append("/");
+      }
+
+      // if (name.entry != null) {
+      result.append(name.entry /*, name.entry_len*/);
+      // } else {
+      //   result.append( /*util.Utf16ToUtf8(StringPiece16(*/ name.entry16 /*, name.entry_len)*/);
+      // }
+    }
+    return result.toString();
+  }
+
+  // static jstring NativeGetResourcePackageName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint
+  // resid) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourcePackageName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    if (name.package_ != null) {
+      return name.package_;
+    }
+    return null;
+  }
+
+  // static jstring NativeGetResourceTypeName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid)
+  // {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourceTypeName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    if (name.type != null) {
+      return name.type;
+      // } else if (name.get().type16 != null) {
+      //   return name.get().type16; // env.NewString(reinterpret_cast<jchar*>(name.type16),
+      // name.type_len);
+    }
+    return null;
+  }
+
+  // static jstring NativeGetResourceEntryName(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint resid)
+  // {
+  @Implementation(minSdk = P)
+  protected static @Nullable String nativeGetResourceEntryName(long ptr, @AnyRes int resid) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    final ResourceName name = new ResourceName();
+    if (!assetmanager.GetResourceName(resid, name)) {
+      return null;
+    }
+
+    if (name.entry != null) {
+      return name.entry;
+      // } else if (name.entry16 != null) {
+      //   return name.entry16; // env.NewString(reinterpret_cast<jchar*>(name.entry16),
+      // name.entry_len);
+    }
+    return null;
+  }
+
+  // static jobjectArray NativeGetLocales(JNIEnv* env, jclass /*class*/, jlong ptr,
+  //                                      jboolean exclude_system) {
+  @Implementation(minSdk = P)
+  protected static @Nullable String[] nativeGetLocales(long ptr, boolean exclude_system) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Set<String> locales =
+        assetmanager.GetResourceLocales(exclude_system, true /*merge_equivalent_languages*/);
+
+    String[] array =
+        new String[locales.size()]; // env.NewObjectArray(locales.size(), g_stringClass, null);
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int idx = 0;
+    for (String locale : locales) {
+      String java_string = locale;
+      if (java_string == null) {
+        return null;
+      }
+      // env.SetObjectArrayElement(array, idx++, java_string);
+      array[idx++] = java_string;
+      // env.DeleteLocalRef(java_string);
+    }
+    return array;
+  }
+
+  static Configuration ConstructConfigurationObject(/* JNIEnv* env,*/ ResTable_config config) {
+    // jobject result =
+    //     env.NewObject(gConfigurationOffsets.classObject, gConfigurationOffsets.constructor);
+    Configuration result = new Configuration();
+    // if (result == null) {
+    //   return null;
+    // }
+
+    result.smallestScreenWidthDp = config.smallestScreenWidthDp;
+    result.screenWidthDp = config.screenWidthDp;
+    result.screenHeightDp = config.screenHeightDp;
+    return result;
+  }
+
+  // static jobjectArray NativeGetSizeConfigurations(JNIEnv* env, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P)
+  protected static @Nullable Configuration[] nativeGetSizeConfigurations(long ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Set<ResTable_config> configurations =
+        assetmanager.GetResourceConfigurations(true /*exclude_system*/, false /*exclude_mipmap*/);
+
+    Configuration[] array = new Configuration[configurations.size()];
+    // env.NewObjectArray(configurations.size(), gConfigurationOffsets.classObject, null);
+    // if (array == null) {
+    //   return null;
+    // }
+
+    int idx = 0;
+    for (ResTable_config configuration : configurations) {
+      Configuration java_configuration = ConstructConfigurationObject(configuration);
+      // if (java_configuration == null) {
+      //   return null;
+      // }
+
+      // env.SetObjectArrayElement(array, idx++, java_configuration);
+      array[idx++] = java_configuration;
+      // env.DeleteLocalRef(java_configuration);
+    }
+    return array;
+  }
+
+  // static void NativeApplyStyle(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                              jint def_style_attr, jint def_style_resid, jlong xml_parser_ptr,
+  //                              jintArray java_attrs, jlong out_values_ptr, jlong out_indices_ptr)
+  // {
+  @Implementation(minSdk = P)
+  protected static void nativeApplyStyle(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int def_style_attr,
+      @StyleRes int def_style_resid,
+      long xml_parser_ptr,
+      @NonNull int[] java_attrs,
+      long out_values_ptr,
+      long out_indices_ptr) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "applyStyle",
+            () ->
+                nativeApplyStyle_measured(
+                    ptr,
+                    theme_ptr,
+                    def_style_attr,
+                    def_style_resid,
+                    xml_parser_ptr,
+                    java_attrs,
+                    out_values_ptr,
+                    out_indices_ptr));
+  }
+
+  private static void nativeApplyStyle_measured(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int def_style_attr,
+      @StyleRes int def_style_resid,
+      long xml_parser_ptr,
+      @NonNull int[] java_attrs,
+      long out_values_ptr,
+      long out_indices_ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+
+    ResXMLParser xml_parser =
+        xml_parser_ptr == 0 ? null : NATIVE_RES_XML_PARSERS.getNativeObject(xml_parser_ptr);
+    // int[] out_values = reinterpret_cast<int*>(out_values_ptr);
+    // int[] out_indices = reinterpret_cast<int*>(out_indices_ptr);
+    ShadowVMRuntime shadowVMRuntime = Shadow.extract(VMRuntime.getRuntime());
+    int[] out_values = (int[]) shadowVMRuntime.getObjectForAddress(out_values_ptr);
+    int[] out_indices = (int[]) shadowVMRuntime.getObjectForAddress(out_indices_ptr);
+
+    int attrs_len = java_attrs.length;
+    int[] attrs =
+        java_attrs; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_attrs, null));
+    // if (attrs == null) {
+    //   return;
+    // }
+
+    ApplyStyle(
+        theme,
+        xml_parser,
+        (int) (def_style_attr),
+        (int) (def_style_resid),
+        attrs,
+        attrs_len,
+        out_values,
+        out_indices);
+    // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+  }
+
+  // static jboolean NativeResolveAttrs(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                                    jint def_style_attr, jint def_style_resid, jintArray
+  // java_values,
+  //                                    jintArray java_attrs, jintArray out_java_values,
+  //                                    jintArray out_java_indices) {
+  @Implementation(minSdk = P)
+  protected static boolean nativeResolveAttrs(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int def_style_attr,
+      @StyleRes int def_style_resid,
+      @Nullable int[] java_values,
+      @NonNull int[] java_attrs,
+      @NonNull int[] out_java_values,
+      @NonNull int[] out_java_indices) {
+    int attrs_len = java_attrs.length;
+    int out_values_len = out_java_values.length;
+    if (out_values_len < (attrs_len * STYLE_NUM_ENTRIES)) {
+      throw new IndexOutOfBoundsException("outValues too small");
+    }
+
+    int[] attrs =
+        java_attrs; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_attrs, null));
+    if (attrs == null) {
+      return JNI_FALSE;
+    }
+
+    int[] values = null;
+    int values_len = 0;
+    if (java_values != null) {
+      values_len = java_values.length;
+      values =
+          java_values; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_values, null));
+      if (values == null) {
+        // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+        return JNI_FALSE;
+      }
+    }
+
+    int[] out_values = out_java_values;
+    // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_values, null));
+    if (out_values == null) {
+      // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+      // if (values != null) {
+      //   env.ReleasePrimitiveArrayCritical(java_values, values, JNI_ABORT);
+      // }
+      return JNI_FALSE;
+    }
+
+    int[] out_indices = null;
+    if (out_java_indices != null) {
+      int out_indices_len = out_java_indices.length;
+      if (out_indices_len > attrs_len) {
+        out_indices = out_java_indices;
+        // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_indices, null));
+        if (out_indices == null) {
+          // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+          // if (values != null) {
+          //   env.ReleasePrimitiveArrayCritical(java_values, values, JNI_ABORT);
+          // }
+          // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, JNI_ABORT);
+          return JNI_FALSE;
+        }
+      }
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+
+    boolean result =
+        ResolveAttrs(
+            theme,
+            (int) (def_style_attr),
+            (int) (def_style_resid),
+            values,
+            values_len,
+            attrs,
+            attrs_len,
+            out_values,
+            out_indices);
+    // if (out_indices != null) {
+    //   env.ReleasePrimitiveArrayCritical(out_java_indices, out_indices, 0);
+    // }
+
+    // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, 0);
+    // if (values != null) {
+    //   env.ReleasePrimitiveArrayCritical(java_values, values, JNI_ABORT);
+    // }
+    // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+    return result ? JNI_TRUE : JNI_FALSE;
+  }
+
+  // static jboolean NativeRetrieveAttributes(JNIEnv* env, jclass /*clazz*/, jlong ptr,
+  //                                          jlong xml_parser_ptr, jintArray java_attrs,
+  //                                          jintArray out_java_values, jintArray out_java_indices)
+  // {
+  @Implementation(minSdk = P)
+  protected static boolean nativeRetrieveAttributes(
+      long ptr,
+      long xml_parser_ptr,
+      @NonNull int[] java_attrs,
+      @NonNull int[] out_java_values,
+      @NonNull int[] out_java_indices) {
+    int attrs_len = java_attrs.length;
+    int out_values_len = out_java_values.length;
+    if (out_values_len < (attrs_len * STYLE_NUM_ENTRIES)) {
+      throw new IndexOutOfBoundsException("outValues too small");
+    }
+
+    int[] attrs =
+        java_attrs; // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(java_attrs, null));
+    if (attrs == null) {
+      return JNI_FALSE;
+    }
+
+    int[] out_values = out_java_values;
+    // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_values, null));
+    if (out_values == null) {
+      // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+      return JNI_FALSE;
+    }
+
+    int[] out_indices = null;
+    if (out_java_indices != null) {
+      int out_indices_len = out_java_indices.length;
+      if (out_indices_len > attrs_len) {
+        out_indices = out_java_indices;
+        // reinterpret_cast<int*>(env.GetPrimitiveArrayCritical(out_java_indices, null));
+        if (out_indices == null) {
+          // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+          // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, JNI_ABORT);
+          return JNI_FALSE;
+        }
+      }
+    }
+
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    ResXMLParser xml_parser = NATIVE_RES_XML_PARSERS.getNativeObject(xml_parser_ptr);
+
+    boolean result =
+        RetrieveAttributes(assetmanager, xml_parser, attrs, attrs_len, out_values, out_indices);
+
+    // if (out_indices != null) {
+    //   env.ReleasePrimitiveArrayCritical(out_java_indices, out_indices, 0);
+    // }
+    // env.ReleasePrimitiveArrayCritical(out_java_values, out_values, 0);
+    // env.ReleasePrimitiveArrayCritical(java_attrs, attrs, JNI_ABORT);
+    return result;
+  }
+
+  // static jlong NativeThemeCreate(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr) {
+  @Implementation(minSdk = P)
+  protected static long nativeThemeCreate(long ptr) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    return Registries.NATIVE_THEME9_REGISTRY.register(assetmanager.NewTheme());
+  }
+
+  // static void NativeThemeDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong theme_ptr) {
+  @Implementation(minSdk = P, maxSdk = R)
+  protected static void nativeThemeDestroy(long theme_ptr) {
+    Registries.NATIVE_THEME9_REGISTRY.unregister(theme_ptr);
+  }
+
+  // static void NativeThemeApplyStyle(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                                   jint resid, jboolean force) {
+  @Implementation(minSdk = P)
+  protected static void nativeThemeApplyStyle(
+      long ptr, long theme_ptr, @StyleRes int resid, boolean force) {
+    // AssetManager is accessed via the theme, so grab an explicit lock here.
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+    theme.ApplyStyle(resid, force);
+
+    // TODO(adamlesinski): Consider surfacing exception when result is failure.
+    // CTS currently expects no exceptions from this method.
+    // std::string error_msg = StringPrintf("Failed to apply style 0x%08x to theme", resid);
+    // throw new IllegalArgumentException(error_msg.c_str());
+  }
+
+  // static void NativeThemeCopy(JNIEnv* env, jclass /*clazz*/, jlong dst_theme_ptr,
+  //                             jlong src_theme_ptr) {
+  @Implementation(minSdk = P, maxSdk = P)
+  protected static void nativeThemeCopy(long dst_theme_ptr, long src_theme_ptr) {
+    Theme dst_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(dst_theme_ptr);
+    Theme src_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(src_theme_ptr);
+    if (!dst_theme.SetTo(src_theme)) {
+      throw new IllegalArgumentException("Themes are from different AssetManagers");
+    }
+  }
+
+  // static void NativeThemeCopy(JNIEnv* env, jclass /*clazz*/, jlong dst_asset_manager_ptr,
+  //     jlong dst_theme_ptr, jlong src_asset_manager_ptr, jlong src_theme_ptr) {
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected static void nativeThemeCopy(
+      long dst_asset_manager_ptr,
+      long dst_theme_ptr,
+      long src_asset_manager_ptr,
+      long src_theme_ptr) {
+    Theme dst_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(dst_theme_ptr);
+    Theme src_theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(src_theme_ptr);
+
+    if (dst_asset_manager_ptr != src_asset_manager_ptr) {
+      CppAssetManager2 dst_assetmanager = AssetManagerFromLong(dst_asset_manager_ptr);
+      CHECK(dst_theme.GetAssetManager() == dst_assetmanager);
+
+      CppAssetManager2 src_assetmanager = AssetManagerFromLong(src_asset_manager_ptr);
+      CHECK(src_theme.GetAssetManager() == src_assetmanager);
+
+      dst_theme.SetTo(src_theme);
+    } else {
+      dst_theme.SetTo(src_theme);
+    }
+  }
+
+  // static void NativeThemeClear(JNIEnv* /*env*/, jclass /*clazz*/, jlong theme_ptr) {
+  @Implementation(minSdk = P, maxSdk = R)
+  protected static void nativeThemeClear(long themePtr) {
+    Registries.NATIVE_THEME9_REGISTRY.getNativeObject(themePtr).Clear();
+  }
+
+  // static jint NativeThemeGetAttributeValue(JNIEnv* env, jclass /*clazz*/, jlong ptr, jlong
+  // theme_ptr,
+  //                                          jint resid, jobject typed_value,
+  //                                          jboolean resolve_references) {
+  @Implementation(minSdk = P)
+  protected static int nativeThemeGetAttributeValue(
+      long ptr,
+      long theme_ptr,
+      @AttrRes int resid,
+      @NonNull TypedValue typed_value,
+      boolean resolve_references) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager; // huh?
+
+    final Ref<Res_value> value = new Ref<>(null);
+    final Ref<Integer> flags = new Ref<>(null);
+    ApkAssetsCookie cookie = theme.GetAttribute(resid, value, flags);
+    if (cookie.intValue() == kInvalidCookie) {
+      return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+    }
+
+    final Ref<Integer> ref = new Ref<>(0);
+    if (resolve_references) {
+      final Ref<ResTable_config> selected_config = new Ref<>(null);
+      cookie = theme.GetAssetManager().ResolveReference(cookie, value, selected_config, flags, ref);
+      if (cookie.intValue() == kInvalidCookie) {
+        return ApkAssetsCookieToJavaCookie(K_INVALID_COOKIE);
+      }
+    }
+    return CopyValue(cookie, value.get(), ref.get(), flags.get(), null, typed_value);
+  }
+
+  // static void NativeThemeDump(JNIEnv* /*env*/, jclass /*clazz*/, jlong ptr, jlong theme_ptr,
+  //                             jint priority, jstring tag, jstring prefix) {
+  @Implementation(minSdk = P)
+  protected static void nativeThemeDump(
+      long ptr, long theme_ptr, int priority, String tag, String prefix) {
+    CppAssetManager2 assetmanager = AssetManagerFromLong(ptr);
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    CHECK(theme.GetAssetManager() == assetmanager);
+    // (void) assetmanager;
+    // (void) theme;
+    // (void) priority;
+    // (void) tag;
+    // (void) prefix;
+  }
+
+  // static jint NativeThemeGetChangingConfigurations(JNIEnv* /*env*/, jclass /*clazz*/,
+  //                                                  jlong theme_ptr) {
+  @Implementation(minSdk = P)
+  protected static @NativeConfig int nativeThemeGetChangingConfigurations(long theme_ptr) {
+    Theme theme = Registries.NATIVE_THEME9_REGISTRY.getNativeObject(theme_ptr);
+    return (int) (theme.GetChangingConfigurations());
+  }
+
+  // static void NativeAssetDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static void nativeAssetDestroy(long asset_ptr) {
+    Registries.NATIVE_ASSET_REGISTRY.unregister(asset_ptr);
+  }
+
+  // static jint NativeAssetReadChar(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static int nativeAssetReadChar(long asset_ptr) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    byte[] b = new byte[1];
+    int res = asset.read(b, 1);
+    return res == 1 ? (int) (b[0]) & 0xff : -1;
+  }
+
+  // static jint NativeAssetRead(JNIEnv* env, jclass /*clazz*/, jlong asset_ptr, jbyteArray
+  // java_buffer,
+  //                             jint offset, jint len) {
+  @Implementation(minSdk = P)
+  protected static int nativeAssetRead(long asset_ptr, byte[] java_buffer, int offset, int len)
+      throws IOException {
+    if (len == 0) {
+      return 0;
+    }
+
+    int buffer_len = java_buffer.length;
+    if (offset < 0
+        || offset >= buffer_len
+        || len < 0
+        || len > buffer_len
+        || offset > buffer_len - len) {
+      throw new IndexOutOfBoundsException();
+    }
+
+    // ScopedByteArrayRW byte_array(env, java_buffer);
+    // if (byte_array.get() == null) {
+    //   return -1;
+    // }
+
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    // sint res = asset.read(byte_array.get() + offset, len);
+    int res = asset.read(java_buffer, offset, len);
+    if (res < 0) {
+      throw new IOException();
+    }
+    return res > 0 ? (int) (res) : -1;
+  }
+
+  // static jlong NativeAssetSeek(JNIEnv* env, jclass /*clazz*/, jlong asset_ptr, jlong offset,
+  //                              jint whence) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetSeek(long asset_ptr, long offset, int whence) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    return asset.seek((offset), (whence > 0 ? SEEK_END : (whence < 0 ? SEEK_SET : SEEK_CUR)));
+  }
+
+  // static jlong NativeAssetGetLength(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetGetLength(long asset_ptr) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    return asset.getLength();
+  }
+
+  // static jlong NativeAssetGetRemainingLength(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr)
+  // {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetGetRemainingLength(long asset_ptr) {
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(asset_ptr);
+    return asset.getRemainingLength();
+  }
+
+  // ----------------------------------------------------------------------------
+
+  // JNI registration.
+  // static JNINativeMethod gAssetManagerMethods[] = {
+  //     // AssetManager setup methods.
+  //     {"nativeCreate", "()J", (void*)NativeCreate},
+  //   {"nativeDestroy", "(J)V", (void*)NativeDestroy},
+  //   {"nativeSetApkAssets", "(J[Landroid/content/res/ApkAssets;Z)V", (void*)NativeSetApkAssets},
+  //   {"nativeSetConfiguration", "(JIILjava/lang/String;IIIIIIIIIIIIIII)V",
+  //   (void*)NativeSetConfiguration},
+  //   {"nativeGetAssignedPackageIdentifiers", "(J)Landroid/util/SparseArray;",
+  //   (void*)NativeGetAssignedPackageIdentifiers},
+  //
+  //   // AssetManager file methods.
+  //   {"nativeList", "(JLjava/lang/String;)[Ljava/lang/String;", (void*)NativeList},
+  //   {"nativeOpenAsset", "(JLjava/lang/String;I)J", (void*)NativeOpenAsset},
+  //   {"nativeOpenAssetFd", "(JLjava/lang/String;[J)Landroid/os/ParcelFileDescriptor;",
+  //   (void*)NativeOpenAssetFd},
+  //   {"nativeOpenNonAsset", "(JILjava/lang/String;I)J", (void*)NativeOpenNonAsset},
+  //   {"nativeOpenNonAssetFd", "(JILjava/lang/String;[J)Landroid/os/ParcelFileDescriptor;",
+  //   (void*)NativeOpenNonAssetFd},
+  //   {"nativeOpenXmlAsset", "(JILjava/lang/String;)J", (void*)NativeOpenXmlAsset},
+  //
+  //   // AssetManager resource methods.
+  //   {"nativeGetResourceValue", "(JISLandroid/util/TypedValue;Z)I",
+  // (void*)NativeGetResourceValue},
+  //   {"nativeGetResourceBagValue", "(JIILandroid/util/TypedValue;)I",
+  //   (void*)NativeGetResourceBagValue},
+  //   {"nativeGetStyleAttributes", "(JI)[I", (void*)NativeGetStyleAttributes},
+  //   {"nativeGetResourceStringArray", "(JI)[Ljava/lang/String;",
+  //   (void*)NativeGetResourceStringArray},
+  //   {"nativeGetResourceStringArrayInfo", "(JI)[I", (void*)NativeGetResourceStringArrayInfo},
+  //   {"nativeGetResourceIntArray", "(JI)[I", (void*)NativeGetResourceIntArray},
+  //   {"nativeGetResourceArraySize", "(JI)I", (void*)NativeGetResourceArraySize},
+  //   {"nativeGetResourceArray", "(JI[I)I", (void*)NativeGetResourceArray},
+  //
+  //   // AssetManager resource name/ID methods.
+  //   {"nativeGetResourceIdentifier", "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
+  //   (void*)NativeGetResourceIdentifier},
+  //   {"nativeGetResourceName", "(JI)Ljava/lang/String;", (void*)NativeGetResourceName},
+  //   {"nativeGetResourcePackageName", "(JI)Ljava/lang/String;",
+  // (void*)NativeGetResourcePackageName},
+  //   {"nativeGetResourceTypeName", "(JI)Ljava/lang/String;", (void*)NativeGetResourceTypeName},
+  //   {"nativeGetResourceEntryName", "(JI)Ljava/lang/String;", (void*)NativeGetResourceEntryName},
+  //   {"nativeGetLocales", "(JZ)[Ljava/lang/String;", (void*)NativeGetLocales},
+  //   {"nativeGetSizeConfigurations", "(J)[Landroid/content/res/Configuration;",
+  //   (void*)NativeGetSizeConfigurations},
+  //
+  //   // Style attribute related methods.
+  //   {"nativeApplyStyle", "(JJIIJ[IJJ)V", (void*)NativeApplyStyle},
+  //   {"nativeResolveAttrs", "(JJII[I[I[I[I)Z", (void*)NativeResolveAttrs},
+  //   {"nativeRetrieveAttributes", "(JJ[I[I[I)Z", (void*)NativeRetrieveAttributes},
+  //
+  //   // Theme related methods.
+  //   {"nativeThemeCreate", "(J)J", (void*)NativeThemeCreate},
+  //   {"nativeThemeDestroy", "(J)V", (void*)NativeThemeDestroy},
+  //   {"nativeThemeApplyStyle", "(JJIZ)V", (void*)NativeThemeApplyStyle},
+  //   {"nativeThemeCopy", "(JJ)V", (void*)NativeThemeCopy},
+  //   {"nativeThemeClear", "(J)V", (void*)NativeThemeClear},
+  //   {"nativeThemeGetAttributeValue", "(JJILandroid/util/TypedValue;Z)I",
+  //   (void*)NativeThemeGetAttributeValue},
+  //   {"nativeThemeDump", "(JJILjava/lang/String;Ljava/lang/String;)V", (void*)NativeThemeDump},
+  //   {"nativeThemeGetChangingConfigurations", "(J)I",
+  // (void*)NativeThemeGetChangingConfigurations},
+  //
+  //   // AssetInputStream methods.
+  //   {"nativeAssetDestroy", "(J)V", (void*)NativeAssetDestroy},
+  //   {"nativeAssetReadChar", "(J)I", (void*)NativeAssetReadChar},
+  //   {"nativeAssetRead", "(J[BII)I", (void*)NativeAssetRead},
+  //   {"nativeAssetSeek", "(JJI)J", (void*)NativeAssetSeek},
+  //   {"nativeAssetGetLength", "(J)J", (void*)NativeAssetGetLength},
+  //   {"nativeAssetGetRemainingLength", "(J)J", (void*)NativeAssetGetRemainingLength},
+  //
+  //   // System/idmap related methods.
+  //   {"nativeVerifySystemIdmaps", "()V", (void*)NativeVerifySystemIdmaps},
+  //
+  //   // Global management/debug methods.
+  //   {"getGlobalAssetCount", "()I", (void*)NativeGetGlobalAssetCount},
+  //   {"getAssetAllocations", "()Ljava/lang/String;", (void*)NativeGetAssetAllocations},
+  //   {"getGlobalAssetManagerCount", "()I", (void*)NativeGetGlobalAssetManagerCount},
+  //   };
+  //
+  //   int register_android_content_AssetManager(JNIEnv* env) {
+  //   jclass apk_assets_class = FindClassOrDie(env, "android/content/res/ApkAssets");
+  //   gApkAssetsFields.native_ptr = GetFieldIDOrDie(env, apk_assets_class, "mNativePtr", "J");
+  //
+  //   jclass typedValue = FindClassOrDie(env, "android/util/TypedValue");
+  //   gTypedValueOffsets.mType = GetFieldIDOrDie(env, typedValue, "type", "I");
+  //   gTypedValueOffsets.mData = GetFieldIDOrDie(env, typedValue, "data", "I");
+  //   gTypedValueOffsets.mString =
+  //   GetFieldIDOrDie(env, typedValue, "string", "Ljava/lang/CharSequence;");
+  //   gTypedValueOffsets.mAssetCookie = GetFieldIDOrDie(env, typedValue, "assetCookie", "I");
+  //   gTypedValueOffsets.mResourceId = GetFieldIDOrDie(env, typedValue, "resourceId", "I");
+  //   gTypedValueOffsets.mChangingConfigurations =
+  //   GetFieldIDOrDie(env, typedValue, "changingConfigurations", "I");
+  //   gTypedValueOffsets.mDensity = GetFieldIDOrDie(env, typedValue, "density", "I");
+  //
+  //   jclass assetFd = FindClassOrDie(env, "android/content/res/AssetFileDescriptor");
+  //   gAssetFileDescriptorOffsets.mFd =
+  //   GetFieldIDOrDie(env, assetFd, "mFd", "Landroid/os/ParcelFileDescriptor;");
+  //   gAssetFileDescriptorOffsets.mStartOffset = GetFieldIDOrDie(env, assetFd, "mStartOffset",
+  // "J");
+  //   gAssetFileDescriptorOffsets.mLength = GetFieldIDOrDie(env, assetFd, "mLength", "J");
+  //
+  //   jclass assetManager = FindClassOrDie(env, "android/content/res/AssetManager");
+  //   gAssetManagerOffsets.mObject = GetFieldIDOrDie(env, assetManager, "mObject", "J");
+  //
+  //   jclass stringClass = FindClassOrDie(env, "java/lang/String");
+  //   g_stringClass = MakeGlobalRefOrDie(env, stringClass);
+  //
+  //   jclass sparseArrayClass = FindClassOrDie(env, "android/util/SparseArray");
+  //   gSparseArrayOffsets.classObject = MakeGlobalRefOrDie(env, sparseArrayClass);
+  //   gSparseArrayOffsets.constructor =
+  //   GetMethodIDOrDie(env, gSparseArrayOffsets.classObject, "<init>", "()V");
+  //   gSparseArrayOffsets.put =
+  //   GetMethodIDOrDie(env, gSparseArrayOffsets.classObject, "put", "(ILjava/lang/Object;)V");
+  //
+  //   jclass configurationClass = FindClassOrDie(env, "android/content/res/Configuration");
+  //   gConfigurationOffsets.classObject = MakeGlobalRefOrDie(env, configurationClass);
+  //   gConfigurationOffsets.constructor = GetMethodIDOrDie(env, configurationClass, "<init>",
+  // "()V");
+  //   gConfigurationOffsets.mSmallestScreenWidthDpOffset =
+  //   GetFieldIDOrDie(env, configurationClass, "smallestScreenWidthDp", "I");
+  //   gConfigurationOffsets.mScreenWidthDpOffset =
+  //   GetFieldIDOrDie(env, configurationClass, "screenWidthDp", "I");
+  //   gConfigurationOffsets.mScreenHeightDpOffset =
+  //   GetFieldIDOrDie(env, configurationClass, "screenHeightDp", "I");
+  //
+  //   return RegisterMethodsOrDie(env, "android/content/res/AssetManager", gAssetManagerMethods,
+  //   NELEM(gAssetManagerMethods));
+  //   }
+
+  @ForType(AssetManager.class)
+  interface AssetManagerReflector {
+
+    @Static
+    @Direct
+    void createSystemAssetsInZygoteLocked();
+  }
+}
+// namespace android
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscResourcesImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscResourcesImpl.java
new file mode 100644
index 0000000..6ffc578
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscResourcesImpl.java
@@ -0,0 +1,189 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static org.robolectric.shadows.ShadowAssetManager.legacyShadowOf;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetFileDescriptor;
+import android.content.res.Resources;
+import android.content.res.ResourcesImpl;
+import android.graphics.drawable.Drawable;
+import android.os.ParcelFileDescriptor;
+import android.util.LongSparseArray;
+import android.util.TypedValue;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.Plural;
+import org.robolectric.res.PluralRules;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResType;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.TypedResource;
+import org.robolectric.shadows.ShadowResourcesImpl.Picker;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings("NewApi")
+@Implements(
+    value = ResourcesImpl.class,
+    isInAndroidSdk = false,
+    minSdk = N,
+    shadowPicker = Picker.class)
+public class ShadowArscResourcesImpl extends ShadowResourcesImpl {
+  private static List<LongSparseArray<?>> resettableArrays;
+
+  @RealObject ResourcesImpl realResourcesImpl;
+
+  @Resetter
+  public static void reset() {
+    if (RuntimeEnvironment.useLegacyResources()) {
+      ShadowResourcesImpl.reset();
+    }
+  }
+
+  private static List<LongSparseArray<?>> obtainResettableArrays() {
+    List<LongSparseArray<?>> resettableArrays = new ArrayList<>();
+    Field[] allFields = Resources.class.getDeclaredFields();
+    for (Field field : allFields) {
+      if (Modifier.isStatic(field.getModifiers()) && field.getType().equals(LongSparseArray.class)) {
+        field.setAccessible(true);
+        try {
+          LongSparseArray<?> longSparseArray = (LongSparseArray<?>) field.get(null);
+          if (longSparseArray != null) {
+            resettableArrays.add(longSparseArray);
+          }
+        } catch (IllegalAccessException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+    return resettableArrays;
+  }
+
+  @Implementation(maxSdk = M)
+  public String getQuantityString(int id, int quantity, Object... formatArgs) throws Resources.NotFoundException {
+    String raw = getQuantityString(id, quantity);
+    return String.format(Locale.ENGLISH, raw, formatArgs);
+  }
+
+  @Implementation(maxSdk = M)
+  public String getQuantityString(int resId, int quantity) throws Resources.NotFoundException {
+    ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResourcesImpl.getAssets());
+
+    TypedResource typedResource = shadowAssetManager.getResourceTable().getValue(resId, shadowAssetManager.config);
+    if (typedResource != null && typedResource instanceof PluralRules) {
+      PluralRules pluralRules = (PluralRules) typedResource;
+      Plural plural = pluralRules.find(quantity);
+
+      if (plural == null) {
+        return null;
+      }
+
+      TypedResource<?> resolvedTypedResource =
+          shadowAssetManager.resolve(
+              new TypedResource<>(
+                  plural.getString(), ResType.CHAR_SEQUENCE, pluralRules.getXmlContext()),
+              shadowAssetManager.config,
+              resId);
+      return resolvedTypedResource == null ? null : resolvedTypedResource.asString();
+    } else {
+      return null;
+    }
+  }
+
+  @Implementation(maxSdk = M)
+  public InputStream openRawResource(int id) throws Resources.NotFoundException {
+    if (false) {
+      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResourcesImpl.getAssets());
+      ResourceTable resourceTable = shadowAssetManager.getResourceTable();
+      InputStream inputStream = resourceTable.getRawValue(id, shadowAssetManager.config);
+      if (inputStream == null) {
+        throw newNotFoundException(id);
+      } else {
+        return inputStream;
+      }
+    } else {
+      return reflector(ResourcesImplReflector.class, realResourcesImpl).openRawResource(id);
+    }
+  }
+
+  /**
+   * Since {@link AssetFileDescriptor}s are not yet supported by Robolectric, {@code null} will
+   * be returned if the resource is found. If the resource cannot be found, {@link Resources.NotFoundException} will
+   * be thrown.
+   */
+  @Implementation(maxSdk = M)
+  public AssetFileDescriptor openRawResourceFd(int id) throws Resources.NotFoundException {
+    InputStream inputStream = openRawResource(id);
+    if (!(inputStream instanceof FileInputStream)) {
+      // todo fixme
+      return null;
+    }
+
+    FileInputStream fis = (FileInputStream) inputStream;
+    try {
+      return new AssetFileDescriptor(ParcelFileDescriptor.dup(fis.getFD()), 0, fis.getChannel().size());
+    } catch (IOException e) {
+      throw newNotFoundException(id);
+    }
+  }
+
+  private Resources.NotFoundException newNotFoundException(int id) {
+    ResourceTable resourceTable = legacyShadowOf(realResourcesImpl.getAssets()).getResourceTable();
+    ResName resName = resourceTable.getResName(id);
+    if (resName == null) {
+      return new Resources.NotFoundException("resource ID #0x" + Integer.toHexString(id));
+    } else {
+      return new Resources.NotFoundException(resName.getFullyQualifiedName());
+    }
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  public Drawable loadDrawable(Resources wrapper, TypedValue value, int id, Resources.Theme theme, boolean useCache) throws Resources.NotFoundException {
+    Drawable drawable =
+        reflector(ResourcesImplReflector.class, realResourcesImpl)
+            .loadDrawable(wrapper, value, id, theme, useCache);
+
+    ShadowResources.setCreatedFromResId(wrapper, id, drawable);
+    return drawable;
+  }
+
+  @Implementation(minSdk = O)
+  public Drawable loadDrawable(Resources wrapper,  TypedValue value, int id, int density, Resources.Theme theme) {
+    Drawable drawable =
+        reflector(ResourcesImplReflector.class, realResourcesImpl)
+            .loadDrawable(wrapper, value, id, density, theme);
+
+    ShadowResources.setCreatedFromResId(wrapper, id, drawable);
+    return drawable;
+  }
+
+  @ForType(ResourcesImpl.class)
+  interface ResourcesImplReflector {
+
+    @Direct
+    InputStream openRawResource(int id);
+
+    @Direct
+    Drawable loadDrawable(
+        Resources wrapper, TypedValue value, int id, Resources.Theme theme, boolean useCache);
+
+    @Direct
+    Drawable loadDrawable(
+        Resources wrapper, TypedValue value, int id, int density, Resources.Theme theme);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetInputStream.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetInputStream.java
new file mode 100644
index 0000000..10de51e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetInputStream.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.res.android.Registries.NATIVE_ASSET_REGISTRY;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.content.res.AssetManager;
+import android.content.res.AssetManager.AssetInputStream;
+import java.io.InputStream;
+import org.robolectric.res.android.Asset;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings("UnusedDeclaration")
+public abstract class ShadowAssetInputStream {
+
+  static AssetInputStream createAssetInputStream(InputStream delegateInputStream, long assetPtr,
+      AssetManager assetManager) {
+    Asset asset = NATIVE_ASSET_REGISTRY.getNativeObject(assetPtr);
+
+    AssetInputStream ais = ReflectionHelpers.callConstructor(AssetInputStream.class,
+        from(AssetManager.class, assetManager),
+        from(long.class, assetPtr));
+
+    ShadowAssetInputStream sais = Shadow.extract(ais);
+    if (sais instanceof ShadowLegacyAssetInputStream) {
+      ShadowLegacyAssetInputStream slais = (ShadowLegacyAssetInputStream) sais;
+      slais.setDelegate(delegateInputStream);
+      slais.setNinePatch(asset.isNinePatch());
+    }
+    return ais;
+  }
+
+  public static class Picker extends ResourceModeShadowPicker<ShadowAssetInputStream> {
+
+    public Picker() {
+      super(ShadowLegacyAssetInputStream.class, ShadowArscAssetInputStream.class,
+          ShadowArscAssetInputStream.class);
+    }
+  }
+
+  abstract InputStream getDelegate();
+
+  abstract boolean isNinePatch();
+
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java
new file mode 100644
index 0000000..1f6e40d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java
@@ -0,0 +1,106 @@
+package org.robolectric.shadows;
+
+import android.content.res.ApkAssets;
+import android.content.res.AssetManager;
+import android.util.ArraySet;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.res.android.AssetPath;
+import org.robolectric.res.android.CppAssetManager;
+import org.robolectric.res.android.ResTable;
+import org.robolectric.res.android.String8;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+abstract public class ShadowAssetManager {
+
+  public static class Picker extends ResourceModeShadowPicker<ShadowAssetManager> {
+
+    public Picker() {
+      super(
+          ShadowLegacyAssetManager.class,
+          ShadowArscAssetManager.class,
+          ShadowArscAssetManager9.class,
+          ShadowArscAssetManager10.class);
+    }
+  }
+
+  /**
+   * @deprecated Avoid use.
+   */
+  @Deprecated
+  public static boolean useLegacy() {
+    return RuntimeEnvironment.useLegacyResources();
+  }
+
+  /**
+   * @deprecated Avoid use.
+   */
+  @Deprecated
+  static ShadowLegacyAssetManager legacyShadowOf(AssetManager assetManager) {
+    return Shadow.extract(assetManager);
+  }
+
+  abstract Collection<Path> getAllAssetDirs();
+
+  public abstract static class ArscBase extends ShadowAssetManager {
+    private ResTable compileTimeResTable;
+
+    /**
+     * @deprecated Avoid use.
+     */
+    @Deprecated
+    synchronized public ResTable getCompileTimeResTable() {
+      if (compileTimeResTable == null) {
+        CppAssetManager compileTimeCppAssetManager = new CppAssetManager();
+        for (AssetPath assetPath : getAssetPaths()) {
+          if (assetPath.isSystem) {
+            compileTimeCppAssetManager.addDefaultAssets(
+                RuntimeEnvironment.compileTimeSystemResourcesFile);
+          } else {
+            compileTimeCppAssetManager.addAssetPath(new String8(assetPath.file), null, false);
+          }
+        }
+        compileTimeResTable = compileTimeCppAssetManager.getResources();
+      }
+
+      return compileTimeResTable;
+    }
+
+    abstract List<AssetPath> getAssetPaths();
+  }
+
+  /** Accessor interface for {@link AssetManager}'s internals. */
+  @ForType(AssetManager.class)
+  interface _AssetManager_ {
+
+    @Static @Accessor("sSystem")
+    AssetManager getSystem();
+
+    @Static @Accessor("sSystem")
+    void setSystem(AssetManager o);
+  }
+
+  /** Accessor interface for {@link AssetManager}'s internals added in API level 28. */
+  @ForType(AssetManager.class)
+  interface _AssetManager28_ extends _AssetManager_ {
+
+    @Static @Accessor("sSystemApkAssets")
+    ApkAssets[] getSystemApkAssets();
+
+    @Static @Accessor("sSystemApkAssets")
+    void setSystemApkAssets(ApkAssets[] apkAssets);
+
+    @Static @Accessor("sSystemApkAssetsSet")
+    ArraySet<ApkAssets> getSystemApkAssetsSet();
+
+    @Static @Accessor("sSystemApkAssetsSet")
+    void setSystemApkAssetsSet(ArraySet<ApkAssets> assetsSet);
+
+    ApkAssets[] getApkAssets();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncQueryHandler.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncQueryHandler.java
new file mode 100644
index 0000000..2560884
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncQueryHandler.java
@@ -0,0 +1,87 @@
+package org.robolectric.shadows;
+
+import android.content.AsyncQueryHandler;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow of {@link android.content.AsyncQueryHandler}, which calls methods synchronously. */
+@Implements(AsyncQueryHandler.class)
+public class ShadowAsyncQueryHandler {
+
+  @RealObject private AsyncQueryHandler asyncQueryHandler;
+
+  private ContentResolver contentResolver;
+
+  @Implementation
+  protected void __constructor__(ContentResolver contentResolver) {
+    this.contentResolver = contentResolver;
+  }
+
+  @Implementation
+  protected void startDelete(
+      int token, Object cookie, Uri uri, String selection, String[] selectionArgs) {
+    int rows = contentResolver.delete(uri, selection, selectionArgs);
+    ReflectionHelpers.callInstanceMethod(
+        asyncQueryHandler,
+        "onDeleteComplete",
+        new ClassParameter<>(int.class, token),
+        new ClassParameter<>(Object.class, cookie),
+        new ClassParameter<>(int.class, rows));
+  }
+
+  @Implementation
+  protected void startInsert(int token, Object cookie, Uri uri, ContentValues initialValues) {
+    Uri resultUri = contentResolver.insert(uri, initialValues);
+    ReflectionHelpers.callInstanceMethod(
+        asyncQueryHandler,
+        "onInsertComplete",
+        new ClassParameter<>(int.class, token),
+        new ClassParameter<>(Object.class, cookie),
+        new ClassParameter<>(Uri.class, resultUri));
+  }
+
+  @Implementation
+  protected void startQuery(
+      int token,
+      Object cookie,
+      Uri uri,
+      String[] projection,
+      String selection,
+      String[] selectionArgs,
+      String orderBy) {
+    Cursor cursor = contentResolver.query(uri, projection, selection, selectionArgs, orderBy);
+    ReflectionHelpers.callInstanceMethod(
+        asyncQueryHandler,
+        "onQueryComplete",
+        new ClassParameter<>(int.class, token),
+        new ClassParameter<>(Object.class, cookie),
+        new ClassParameter<>(Cursor.class, cursor));
+  }
+
+  @Implementation
+  protected void startUpdate(
+      int token,
+      Object cookie,
+      Uri uri,
+      ContentValues values,
+      String selection,
+      String[] selectionArgs) {
+    int rows = contentResolver.update(uri, values, selection, selectionArgs);
+    ReflectionHelpers.callInstanceMethod(
+        asyncQueryHandler,
+        "onUpdateComplete",
+        new ClassParameter<>(int.class, token),
+        new ClassParameter<>(Object.class, cookie),
+        new ClassParameter<>(int.class, rows));
+  }
+
+  @Implementation
+  protected final void cancelOperation(int token) {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncTask.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncTask.java
new file mode 100644
index 0000000..d60580e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncTask.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import android.os.AsyncTask;
+import org.robolectric.annotation.Implements;
+
+/** The shadow API for {@link android.os.AsyncTask}. */
+@Implements(value = AsyncTask.class, shadowPicker = ShadowAsyncTask.Picker.class)
+public abstract class ShadowAsyncTask<Params, Progress, Result> {
+
+  public static class Picker extends LooperShadowPicker<ShadowAsyncTask> {
+
+    public Picker() {
+      super(ShadowLegacyAsyncTask.class, ShadowPausedAsyncTask.class);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncTaskLoader.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncTaskLoader.java
new file mode 100644
index 0000000..7ce0540
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAsyncTaskLoader.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import android.content.AsyncTaskLoader;
+import org.robolectric.annotation.Implements;
+
+/**
+ * The shadow API for {@link AsyncTaskLoader}.
+ *
+ * Different shadow implementations will be used based on the current {@link LooperMode.Mode}.
+ * @see ShadowLegacyAsyncTaskLoader, ShadowPausedAsyncTaskLoader
+ */
+@Implements(value = AsyncTaskLoader.class, shadowPicker = ShadowAsyncTaskLoader.Picker.class)
+public abstract class ShadowAsyncTaskLoader<D> {
+
+  public static class Picker extends LooperShadowPicker<ShadowAsyncTaskLoader> {
+
+    public Picker() {
+      super(ShadowLegacyAsyncTaskLoader.class, ShadowPausedAsyncTaskLoader.class);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioEffect.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioEffect.java
new file mode 100644
index 0000000..a0997fd
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioEffect.java
@@ -0,0 +1,232 @@
+package org.robolectric.shadows;
+
+import static android.media.audiofx.AudioEffect.STATE_INITIALIZED;
+import static android.media.audiofx.AudioEffect.STATE_UNINITIALIZED;
+import static android.media.audiofx.AudioEffect.SUCCESS;
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.media.audiofx.AudioEffect;
+import android.os.Build.VERSION_CODES;
+import com.google.common.collect.ImmutableList;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Implements {@link AudioEffect} by shadowing its native methods. */
+@Implements(value = AudioEffect.class)
+public class ShadowAudioEffect {
+  private static final List<AudioEffect.Descriptor> descriptors = new ArrayList<>();
+  private static final List<AudioEffect> audioEffects = new ArrayList<>();
+
+  private final Map<ByteBuffer, ByteBuffer> parameters = new HashMap<>();
+
+  @RealObject AudioEffect audioEffect;
+
+  private int priority;
+  private int audioSession;
+  private boolean isEnabled = false;
+  private int errorCode = SUCCESS;
+
+  @Implementation(minSdk = VERSION_CODES.JELLY_BEAN, maxSdk = VERSION_CODES.LOLLIPOP_MR1)
+  protected int native_setup(
+      Object audioEffectThis,
+      String type,
+      String uuid,
+      int priority,
+      int audioSession,
+      int[] id,
+      Object[] desc) {
+    return native_setup(
+        audioEffectThis, type, uuid, priority, audioSession, id, desc, /* opPackageName= */ null);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M, maxSdk = VERSION_CODES.Q)
+  protected int native_setup(
+      Object audioEffectThis,
+      String type,
+      String uuid,
+      int priority,
+      int audioSession,
+      int[] id,
+      Object[] desc,
+      String opPackageName) {
+    audioEffects.add(audioEffect);
+    this.priority = priority;
+    this.audioSession = audioSession;
+    return SUCCESS;
+  }
+
+  /** Marks the {@link AudioEffect} as enabled, and always returns {@code SUCCESS}. */
+  @Implementation
+  protected int native_setEnabled(boolean enabled) {
+    if (errorCode != SUCCESS) {
+      return errorCode;
+    }
+    isEnabled = enabled;
+    return SUCCESS;
+  }
+
+  /** Returns whether the {@link AudioEffect} is enabled (as per {@link #native_setEnabled}). */
+  @Implementation
+  protected boolean native_getEnabled() {
+    return isEnabled;
+  }
+
+  /**
+   * Sets the parameter with the given key {@code param} to the given value {@code value}.
+   *
+   * @return always {@code SUCCESS}
+   */
+  @Implementation
+  protected int native_setParameter(int psize, byte[] param, int vsize, byte[] value) {
+    if (errorCode != SUCCESS) {
+      return errorCode;
+    }
+    ByteBuffer parameterKey = createReadOnlyByteBuffer(param, psize);
+    ByteBuffer parameterValue = createReadOnlyByteBuffer(value, vsize);
+    parameters.put(parameterKey, parameterValue);
+    return SUCCESS;
+  }
+
+  /**
+   * Gets the value of the parameter with key {@code param}, by putting its value in {@code value}.
+   *
+   * <p>Note: Sub-classes of {@link ShadowAudioEffect} can declare default values for any
+   * parameters. Note: If the given parameter has not been set, and there is no default value for
+   * that parameter, then we "return" (set {@code value} to) a single integer 0.
+   *
+   * @return the size of the returned value, in bytes, or an error code in case of failure.
+   */
+  @Implementation
+  protected int native_getParameter(int psize, byte[] param, int vsize, byte[] value) {
+    if (errorCode != SUCCESS) {
+      return errorCode;
+    }
+
+    ByteBuffer parameterKey = ByteBuffer.wrap(Arrays.copyOf(param, psize));
+    if (parameters.containsKey(parameterKey)) {
+      ByteBuffer parameterValue = parameters.get(parameterKey);
+      return copyByteBufferToArrayAndReturnSize(parameterValue, value);
+    }
+
+    Optional<ByteBuffer> defaultValue = getDefaultParameter(parameterKey);
+    if (defaultValue.isPresent()) {
+      return copyByteBufferToArrayAndReturnSize(defaultValue.get(), value);
+    }
+
+    byte[] val = AudioEffect.intToByteArray(0);
+    System.arraycopy(val, 0, value, 0, 4);
+    return 4; // The number of meaningful bytes in the value array
+  }
+
+  private static int copyByteBufferToArrayAndReturnSize(ByteBuffer byteBuffer, byte[] array) {
+    checkArgument(byteBuffer.position() == 0);
+    for (int i = 0; i < byteBuffer.limit(); i++) {
+      array[i] = byteBuffer.get(i);
+    }
+    return byteBuffer.limit();
+  }
+
+  /**
+   * Allows sub-classes to provide default parameters.
+   *
+   * <p>Override this method to provide default parameters.
+   */
+  protected Optional<ByteBuffer> getDefaultParameter(ByteBuffer parameter) {
+    return Optional.empty();
+  }
+
+  /** Returns the priority set in the {@link AudioEffect} ctor. */
+  public int getPriority() {
+    return priority;
+  }
+
+  /** Returns the audio session set in the {@link AudioEffect} ctor. */
+  public int getAudioSession() {
+    return audioSession;
+  }
+
+  /**
+   * Updates the state of the {@link AudioEffect} itself.
+   *
+   * <p>This can be used e.g. to put the AudioEffect in an unexpected state and cause an exception
+   * the next time the Visualizer is used.
+   */
+  public void setInitialized(boolean initialized) {
+    reflector(ReflectorAudioEffect.class, audioEffect)
+        .setState(initialized ? STATE_INITIALIZED : STATE_UNINITIALIZED);
+  }
+
+  /**
+   * Sets the error code to override setter methods in this class.
+   *
+   * <p>When the error code is set to anything other than {@link SUCCESS} setters in the AudioEffect
+   * will early-out and return that error code.
+   */
+  public void setErrorCode(int errorCode) {
+    this.errorCode = errorCode;
+  }
+
+  /**
+   * Adds an effect represented by an {@link AudioEffect.Descriptor}, only to be queried from {@link
+   * #queryEffects()}.
+   */
+  public static void addEffect(AudioEffect.Descriptor descriptor) {
+    descriptors.add(descriptor);
+  }
+
+  /**
+   * Returns the set of audio effects added through {@link #addEffect}.
+   *
+   * <p>Note: in the original {@link AudioEffect} implementation this method returns all the
+   * existing unique AudioEffects created through an {@link AudioEffect} ctor. In this
+   * implementation only the effects added through {@link #addEffect} are returned here.
+   */
+  @Implementation
+  protected static AudioEffect.Descriptor[] queryEffects() {
+    return descriptors.toArray(new AudioEffect.Descriptor[descriptors.size()]);
+  }
+
+  /** Returns all effects created with an {@code AudioEffect} constructor. */
+  public static ImmutableList<AudioEffect> getAudioEffects() {
+    return ImmutableList.copyOf(audioEffects);
+  }
+
+  /** Removes this audio effect from the set of active audio effects. */
+  @Implementation
+  protected void native_release() {
+    audioEffects.remove(audioEffect);
+  }
+
+  static ByteBuffer createReadOnlyByteBuffer(byte[] array) {
+    return createReadOnlyByteBuffer(array, array.length);
+  }
+
+  static ByteBuffer createReadOnlyByteBuffer(byte[] array, int length) {
+    return ByteBuffer.wrap(Arrays.copyOf(array, length)).asReadOnlyBuffer();
+  }
+
+  @Resetter
+  public static void reset() {
+    descriptors.clear();
+    audioEffects.clear();
+  }
+
+  /** Accessor interface for {@link AudioEffect}'s internals. */
+  @ForType(AudioEffect.class)
+  private interface ReflectorAudioEffect {
+    @Accessor("mState")
+    void setState(int state);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioManager.java
new file mode 100644
index 0000000..183e9f3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioManager.java
@@ -0,0 +1,762 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.annotation.NonNull;
+import android.annotation.RequiresPermission;
+import android.annotation.TargetApi;
+import android.media.AudioAttributes;
+import android.media.AudioDeviceInfo;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioPlaybackConfiguration;
+import android.media.AudioRecordingConfiguration;
+import android.media.IPlayer;
+import android.media.PlayerBase;
+import android.media.audiopolicy.AudioPolicy;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Parcel;
+import com.android.internal.util.Preconditions;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = AudioManager.class, looseSignatures = true)
+public class ShadowAudioManager {
+
+  public static final int MAX_VOLUME_MUSIC_DTMF = 15;
+  public static final int DEFAULT_MAX_VOLUME = 7;
+  public static final int MIN_VOLUME = 0;
+  public static final int DEFAULT_VOLUME = 7;
+  public static final int INVALID_VOLUME = 0;
+  public static final int FLAG_NO_ACTION = 0;
+  public static final ImmutableList<Integer> ALL_STREAMS =
+      ImmutableList.of(
+          AudioManager.STREAM_MUSIC,
+          AudioManager.STREAM_ALARM,
+          AudioManager.STREAM_NOTIFICATION,
+          AudioManager.STREAM_RING,
+          AudioManager.STREAM_SYSTEM,
+          AudioManager.STREAM_VOICE_CALL,
+          AudioManager.STREAM_DTMF);
+
+  private static final int INVALID_PATCH_HANDLE = -1;
+  private static final float MAX_VOLUME_DB = 0;
+  private static final float MIN_VOLUME_DB = -100;
+
+  private AudioFocusRequest lastAudioFocusRequest;
+  private int nextResponseValue = AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
+  private AudioManager.OnAudioFocusChangeListener lastAbandonedAudioFocusListener;
+  private android.media.AudioFocusRequest lastAbandonedAudioFocusRequest;
+  private HashMap<Integer, AudioStream> streamStatus = new HashMap<>();
+  private List<AudioPlaybackConfiguration> activePlaybackConfigurations = Collections.emptyList();
+  private List<AudioRecordingConfiguration> activeRecordingConfigurations = ImmutableList.of();
+  private final HashSet<AudioManager.AudioRecordingCallback> audioRecordingCallbacks =
+      new HashSet<>();
+  private final HashSet<AudioManager.AudioPlaybackCallback> audioPlaybackCallbacks =
+      new HashSet<>();
+  private int ringerMode = AudioManager.RINGER_MODE_NORMAL;
+  private int mode = AudioManager.MODE_NORMAL;
+  private boolean bluetoothA2dpOn;
+  private boolean isBluetoothScoOn;
+  private boolean isSpeakerphoneOn;
+  private boolean isMicrophoneMuted = false;
+  private boolean isMusicActive;
+  private boolean wiredHeadsetOn;
+  private boolean isBluetoothScoAvailableOffCall = false;
+  private final Map<String, String> parameters = new HashMap<>();
+  private final Map<Integer, Boolean> streamsMuteState = new HashMap<>();
+  private final Map<String, AudioPolicy> registeredAudioPolicies = new HashMap<>();
+  private int audioSessionIdCounter = 1;
+  private final Map<AudioAttributes, ImmutableList<Object>> devicesForAttributes = new HashMap<>();
+  private ImmutableList<Object> defaultDevicesForAttributes = ImmutableList.of();
+  private List<AudioDeviceInfo> inputDevices = new ArrayList<>();
+  private List<AudioDeviceInfo> outputDevices = new ArrayList<>();
+  private AudioDeviceInfo communicationDevice = null;
+
+  public ShadowAudioManager() {
+    for (int stream : ALL_STREAMS) {
+      streamStatus.put(stream, new AudioStream(DEFAULT_VOLUME, DEFAULT_MAX_VOLUME, FLAG_NO_ACTION));
+    }
+    streamStatus.get(AudioManager.STREAM_MUSIC).setMaxVolume(MAX_VOLUME_MUSIC_DTMF);
+    streamStatus.get(AudioManager.STREAM_DTMF).setMaxVolume(MAX_VOLUME_MUSIC_DTMF);
+  }
+
+  @Implementation
+  protected int getStreamMaxVolume(int streamType) {
+    AudioStream stream = streamStatus.get(streamType);
+    return (stream != null) ? stream.getMaxVolume() : INVALID_VOLUME;
+  }
+
+  @Implementation
+  protected int getStreamVolume(int streamType) {
+    AudioStream stream = streamStatus.get(streamType);
+    return (stream != null) ? stream.getCurrentVolume() : INVALID_VOLUME;
+  }
+
+  @Implementation(minSdk = P)
+  protected float getStreamVolumeDb(int streamType, int index, int deviceType) {
+    AudioStream stream = streamStatus.get(streamType);
+    if (stream == null) {
+      return INVALID_VOLUME;
+    }
+    if (index < MIN_VOLUME || index > stream.getMaxVolume()) {
+      throw new IllegalArgumentException("Invalid stream volume index " + index);
+    }
+    if (index == MIN_VOLUME) {
+      return Float.NEGATIVE_INFINITY;
+    }
+    float interpolation = (index - MIN_VOLUME) / (float) (stream.getMaxVolume() - MIN_VOLUME);
+    return MIN_VOLUME_DB + interpolation * (MAX_VOLUME_DB - MIN_VOLUME_DB);
+  }
+
+  @Implementation
+  protected void setStreamVolume(int streamType, int index, int flags) {
+    AudioStream stream = streamStatus.get(streamType);
+    if (stream != null) {
+      stream.setCurrentVolume(index);
+      stream.setFlag(flags);
+    }
+  }
+
+  @Implementation
+  protected boolean isBluetoothScoAvailableOffCall() {
+    return isBluetoothScoAvailableOffCall;
+  }
+
+  @Implementation
+  protected int requestAudioFocus(
+      android.media.AudioManager.OnAudioFocusChangeListener l, int streamType, int durationHint) {
+    lastAudioFocusRequest = new AudioFocusRequest(l, streamType, durationHint);
+    return nextResponseValue;
+  }
+
+  /**
+   * Provides a mock like interface for the requestAudioFocus method by storing the request object
+   * for later inspection and returning the value specified in setNextFocusRequestResponse.
+   */
+  @Implementation(minSdk = O)
+  protected int requestAudioFocus(android.media.AudioFocusRequest audioFocusRequest) {
+    lastAudioFocusRequest = new AudioFocusRequest(audioFocusRequest);
+    return nextResponseValue;
+  }
+
+  @Implementation
+  protected int abandonAudioFocus(AudioManager.OnAudioFocusChangeListener l) {
+    lastAbandonedAudioFocusListener = l;
+    return nextResponseValue;
+  }
+
+  /**
+   * Provides a mock like interface for the abandonAudioFocusRequest method by storing the request
+   * object for later inspection and returning the value specified in setNextFocusRequestResponse.
+   */
+  @Implementation(minSdk = O)
+  protected int abandonAudioFocusRequest(android.media.AudioFocusRequest audioFocusRequest) {
+    lastAbandonedAudioFocusListener = audioFocusRequest.getOnAudioFocusChangeListener();
+    lastAbandonedAudioFocusRequest = audioFocusRequest;
+    return nextResponseValue;
+  }
+
+  @Implementation
+  protected int getRingerMode() {
+    return ringerMode;
+  }
+
+  @Implementation
+  protected void setRingerMode(int ringerMode) {
+    if (!AudioManager.isValidRingerMode(ringerMode)) {
+      return;
+    }
+    this.ringerMode = ringerMode;
+  }
+
+  @Implementation
+  public static boolean isValidRingerMode(int ringerMode) {
+    return ringerMode >= 0
+        && ringerMode
+            <= (int) ReflectionHelpers.getStaticField(AudioManager.class, "RINGER_MODE_MAX");
+  }
+
+  @Implementation
+  protected void setMode(int mode) {
+    this.mode = mode;
+  }
+
+  @Implementation
+  protected int getMode() {
+    return this.mode;
+  }
+
+  public void setStreamMaxVolume(int streamMaxVolume) {
+    streamStatus.forEach((key, value) -> value.setMaxVolume(streamMaxVolume));
+  }
+
+  public void setStreamVolume(int streamVolume) {
+    streamStatus.forEach((key, value) -> value.setCurrentVolume(streamVolume));
+  }
+
+  @Implementation
+  protected void setWiredHeadsetOn(boolean on) {
+    wiredHeadsetOn = on;
+  }
+
+  @Implementation
+  protected boolean isWiredHeadsetOn() {
+    return wiredHeadsetOn;
+  }
+
+  @Implementation
+  protected void setBluetoothA2dpOn(boolean on) {
+    bluetoothA2dpOn = on;
+  }
+
+  @Implementation
+  protected boolean isBluetoothA2dpOn() {
+    return bluetoothA2dpOn;
+  }
+
+  @Implementation
+  protected void setSpeakerphoneOn(boolean on) {
+    isSpeakerphoneOn = on;
+  }
+
+  @Implementation
+  protected boolean isSpeakerphoneOn() {
+    return isSpeakerphoneOn;
+  }
+
+  @Implementation
+  protected void setMicrophoneMute(boolean on) {
+    isMicrophoneMuted = on;
+  }
+
+  @Implementation
+  protected boolean isMicrophoneMute() {
+    return isMicrophoneMuted;
+  }
+
+  @Implementation
+  protected boolean isBluetoothScoOn() {
+    return isBluetoothScoOn;
+  }
+
+  @Implementation
+  protected void setBluetoothScoOn(boolean isBluetoothScoOn) {
+    this.isBluetoothScoOn = isBluetoothScoOn;
+  }
+
+  @Implementation
+  protected boolean isMusicActive() {
+    return isMusicActive;
+  }
+
+  @Implementation(minSdk = O)
+  protected List<AudioPlaybackConfiguration> getActivePlaybackConfigurations() {
+    return new ArrayList<>(activePlaybackConfigurations);
+  }
+
+  @Implementation
+  protected void setParameters(String keyValuePairs) {
+    if (keyValuePairs.isEmpty()) {
+      throw new IllegalArgumentException("keyValuePairs should not be empty");
+    }
+
+    if (keyValuePairs.charAt(keyValuePairs.length() - 1) != ';') {
+      throw new IllegalArgumentException("keyValuePairs should end with a ';'");
+    }
+
+    String[] pairs = keyValuePairs.split(";", 0);
+
+    for (String pair : pairs) {
+      if (pair.isEmpty()) {
+        continue;
+      }
+
+      String[] splittedPair = pair.split("=", 0);
+      if (splittedPair.length != 2) {
+        throw new IllegalArgumentException(
+            "keyValuePairs: each pair should be in the format of key=value;");
+      }
+      parameters.put(splittedPair[0], splittedPair[1]);
+    }
+  }
+
+  /**
+   * The expected composition for keys is not well defined.
+   *
+   * <p>For testing purposes this method call always returns null.
+   */
+  @Implementation
+  protected String getParameters(String keys) {
+    return null;
+  }
+
+  /** Returns a single parameter that was set via {@link #setParameters(String)}. */
+  public String getParameter(String key) {
+    return parameters.get(key);
+  }
+
+  /**
+   * Implements {@link AudioManager#adjustStreamVolume(int, int, int)}.
+   *
+   * <p>Currently supports only the directions {@link AudioManager#ADJUST_MUTE}, {@link
+   * AudioManager#ADJUST_UNMUTE}, {@link AudioManager#ADJUST_LOWER} and {@link
+   * AudioManager#ADJUST_RAISE}.
+   */
+  @Implementation
+  protected void adjustStreamVolume(int streamType, int direction, int flags) {
+    int streamVolume = getStreamVolume(streamType);
+    switch (direction) {
+      case AudioManager.ADJUST_MUTE:
+        streamsMuteState.put(streamType, true);
+        break;
+      case AudioManager.ADJUST_UNMUTE:
+        streamsMuteState.put(streamType, false);
+        break;
+      case AudioManager.ADJUST_RAISE:
+        int streamMaxVolume = getStreamMaxVolume(streamType);
+        if (streamVolume == INVALID_VOLUME || streamMaxVolume == INVALID_VOLUME) {
+          return;
+        }
+        int raisedVolume = streamVolume + 1;
+        if (raisedVolume <= streamMaxVolume) {
+          setStreamVolume(raisedVolume);
+        }
+        break;
+      case AudioManager.ADJUST_LOWER:
+        if (streamVolume == INVALID_VOLUME) {
+          return;
+        }
+        int lowerVolume = streamVolume - 1;
+        if (lowerVolume >= 1) {
+          setStreamVolume(lowerVolume);
+        }
+        break;
+      default:
+        break;
+    }
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean isStreamMute(int streamType) {
+    if (!streamsMuteState.containsKey(streamType)) {
+      return false;
+    }
+    return streamsMuteState.get(streamType);
+  }
+
+  public void setIsBluetoothScoAvailableOffCall(boolean isBluetoothScoAvailableOffCall) {
+    this.isBluetoothScoAvailableOffCall = isBluetoothScoAvailableOffCall;
+  }
+
+  public void setIsStreamMute(int streamType, boolean isMuted) {
+    streamsMuteState.put(streamType, isMuted);
+  }
+
+  /**
+   * Registers callback that will receive changes made to the list of active playback configurations
+   * by {@link setActivePlaybackConfigurationsFor}.
+   */
+  @Implementation(minSdk = O)
+  protected void registerAudioPlaybackCallback(
+      AudioManager.AudioPlaybackCallback cb, Handler handler) {
+    audioPlaybackCallbacks.add(cb);
+  }
+
+  /** Unregisters callback listening to changes made to list of active playback configurations. */
+  @Implementation(minSdk = O)
+  protected void unregisterAudioPlaybackCallback(AudioManager.AudioPlaybackCallback cb) {
+    audioPlaybackCallbacks.remove(cb);
+  }
+
+  /**
+   * Returns the devices associated with the given audio stream.
+   *
+   * <p>In this shadow-implementation the devices returned are either
+   *
+   * <ol>
+   *   <li>devices set through {@link #setDevicesForAttributes}, or
+   *   <li>devices set through {@link #setDefaultDevicesForAttributes}, or
+   *   <li>an empty list.
+   * </ol>
+   */
+  @Implementation(minSdk = R)
+  @NonNull
+  protected List<Object> getDevicesForAttributes(@NonNull AudioAttributes attributes) {
+    ImmutableList<Object> devices = devicesForAttributes.get(attributes);
+    return devices == null ? defaultDevicesForAttributes : devices;
+  }
+
+  /** Sets the devices associated with the given audio stream. */
+  public void setDevicesForAttributes(
+      @NonNull AudioAttributes attributes, @NonNull ImmutableList<Object> devices) {
+    devicesForAttributes.put(attributes, devices);
+  }
+
+  /** Sets the devices to use as default for all audio streams. */
+  public void setDefaultDevicesForAttributes(@NonNull ImmutableList<Object> devices) {
+    defaultDevicesForAttributes = devices;
+  }
+
+  public void setInputDevices(List<AudioDeviceInfo> inputDevices) {
+    this.inputDevices = inputDevices;
+  }
+
+  public void setOutputDevices(List<AudioDeviceInfo> outputDevices) {
+    this.outputDevices = outputDevices;
+  }
+
+  private List<AudioDeviceInfo> getInputDevices() {
+    return inputDevices;
+  }
+
+  private List<AudioDeviceInfo> getOutputDevices() {
+    return outputDevices;
+  }
+
+  @Implementation(minSdk = S)
+  protected boolean setCommunicationDevice(AudioDeviceInfo communicationDevice) {
+    this.communicationDevice = communicationDevice;
+    return true;
+  }
+
+  @Implementation(minSdk = S)
+  protected AudioDeviceInfo getCommunicationDevice() {
+    return communicationDevice;
+  }
+
+  @Implementation(minSdk = S)
+  protected void clearCommunicationDevice() {
+    this.communicationDevice = null;
+  }
+
+  @Implementation(minSdk = M)
+  public AudioDeviceInfo[] getDevices(int flags) {
+    List<AudioDeviceInfo> result = new ArrayList<>();
+    if ((flags & AudioManager.GET_DEVICES_INPUTS) == AudioManager.GET_DEVICES_INPUTS) {
+      result.addAll(getInputDevices());
+    }
+    if ((flags & AudioManager.GET_DEVICES_OUTPUTS) == AudioManager.GET_DEVICES_OUTPUTS) {
+      result.addAll(getOutputDevices());
+    }
+    return result.toArray(new AudioDeviceInfo[0]);
+  }
+
+  /**
+   * Sets active playback configurations that will be served by {@link
+   * AudioManager#getActivePlaybackConfigurations}.
+   *
+   * <p>Note that there is no public {@link AudioPlaybackConfiguration} constructor, so the
+   * configurations returned are specified by their audio attributes only.
+   */
+  @TargetApi(VERSION_CODES.O)
+  public void setActivePlaybackConfigurationsFor(List<AudioAttributes> audioAttributes) {
+    setActivePlaybackConfigurationsFor(audioAttributes, /* notifyCallbackListeners= */ false);
+  }
+
+  /**
+   * Same as {@link #setActivePlaybackConfigurationsFor(List)}, but also notifies callbacks if
+   * notifyCallbackListeners is true.
+   */
+  @TargetApi(VERSION_CODES.O)
+  public void setActivePlaybackConfigurationsFor(
+      List<AudioAttributes> audioAttributes, boolean notifyCallbackListeners) {
+    if (RuntimeEnvironment.getApiLevel() < O) {
+      throw new UnsupportedOperationException(
+          "setActivePlaybackConfigurationsFor is not supported on API "
+              + RuntimeEnvironment.getApiLevel());
+    }
+    activePlaybackConfigurations = new ArrayList<>(audioAttributes.size());
+    for (AudioAttributes audioAttribute : audioAttributes) {
+      AudioPlaybackConfiguration configuration = createAudioPlaybackConfiguration(audioAttribute);
+      activePlaybackConfigurations.add(configuration);
+    }
+    if (notifyCallbackListeners) {
+      for (AudioManager.AudioPlaybackCallback callback : audioPlaybackCallbacks) {
+        callback.onPlaybackConfigChanged(activePlaybackConfigurations);
+      }
+    }
+  }
+
+  protected AudioPlaybackConfiguration createAudioPlaybackConfiguration(
+      AudioAttributes audioAttributes) {
+    // use reflection to call package private APIs
+    if (RuntimeEnvironment.getApiLevel() >= S) {
+      PlayerBase.PlayerIdCard playerIdCard =
+          ReflectionHelpers.callConstructor(
+              PlayerBase.PlayerIdCard.class,
+              ReflectionHelpers.ClassParameter.from(int.class, 0), /* type */
+              ReflectionHelpers.ClassParameter.from(AudioAttributes.class, audioAttributes),
+              ReflectionHelpers.ClassParameter.from(IPlayer.class, null),
+              ReflectionHelpers.ClassParameter.from(int.class, 0) /* sessionId */);
+      AudioPlaybackConfiguration config =
+          ReflectionHelpers.callConstructor(
+              AudioPlaybackConfiguration.class,
+              ReflectionHelpers.ClassParameter.from(PlayerBase.PlayerIdCard.class, playerIdCard),
+              ReflectionHelpers.ClassParameter.from(int.class, 0), /* piid */
+              ReflectionHelpers.ClassParameter.from(int.class, 0), /* uid */
+              ReflectionHelpers.ClassParameter.from(int.class, 0) /* pid */);
+      ReflectionHelpers.setField(
+          config, "mPlayerState", AudioPlaybackConfiguration.PLAYER_STATE_STARTED);
+      return config;
+    } else {
+      PlayerBase.PlayerIdCard playerIdCard =
+          ReflectionHelpers.callConstructor(
+              PlayerBase.PlayerIdCard.class,
+              from(int.class, 0), /* type */
+              from(AudioAttributes.class, audioAttributes),
+              from(IPlayer.class, null));
+      AudioPlaybackConfiguration config =
+          ReflectionHelpers.callConstructor(
+              AudioPlaybackConfiguration.class,
+              from(PlayerBase.PlayerIdCard.class, playerIdCard),
+              from(int.class, 0), /* piid */
+              from(int.class, 0), /* uid */
+              from(int.class, 0) /* pid */);
+      ReflectionHelpers.setField(
+          config, "mPlayerState", AudioPlaybackConfiguration.PLAYER_STATE_STARTED);
+      return config;
+    }
+  }
+
+  public void setIsMusicActive(boolean isMusicActive) {
+    this.isMusicActive = isMusicActive;
+  }
+
+  public AudioFocusRequest getLastAudioFocusRequest() {
+    return lastAudioFocusRequest;
+  }
+
+  public void setNextFocusRequestResponse(int nextResponseValue) {
+    this.nextResponseValue = nextResponseValue;
+  }
+
+  public AudioManager.OnAudioFocusChangeListener getLastAbandonedAudioFocusListener() {
+    return lastAbandonedAudioFocusListener;
+  }
+
+  public android.media.AudioFocusRequest getLastAbandonedAudioFocusRequest() {
+    return lastAbandonedAudioFocusRequest;
+  }
+
+  /**
+   * Returns list of active recording configurations that was set by {@link
+   * #setActiveRecordingConfigurations} or empty list otherwise.
+   */
+  @Implementation(minSdk = N)
+  protected List<AudioRecordingConfiguration> getActiveRecordingConfigurations() {
+    return activeRecordingConfigurations;
+  }
+
+  /**
+   * Registers callback that will receive changes made to the list of active recording
+   * configurations by {@link setActiveRecordingConfigurations}.
+   */
+  @Implementation(minSdk = N)
+  protected void registerAudioRecordingCallback(
+      AudioManager.AudioRecordingCallback cb, Handler handler) {
+    audioRecordingCallbacks.add(cb);
+  }
+
+  /** Unregisters callback listening to changes made to list of active recording configurations. */
+  @Implementation(minSdk = N)
+  protected void unregisterAudioRecordingCallback(AudioManager.AudioRecordingCallback cb) {
+    audioRecordingCallbacks.remove(cb);
+  }
+
+  /**
+   * Sets active recording configurations that will be served by {@link
+   * AudioManager#getActiveRecordingConfigurations} and notifies callback listeners about that
+   * change.
+   */
+  public void setActiveRecordingConfigurations(
+      List<AudioRecordingConfiguration> activeRecordingConfigurations,
+      boolean notifyCallbackListeners) {
+    this.activeRecordingConfigurations = new ArrayList<>(activeRecordingConfigurations);
+
+    if (notifyCallbackListeners) {
+      for (AudioManager.AudioRecordingCallback callback : audioRecordingCallbacks) {
+        callback.onRecordingConfigChanged(this.activeRecordingConfigurations);
+      }
+    }
+  }
+
+  /**
+   * Creates simple active recording configuration. The resulting configuration will return {@code
+   * null} for {@link android.media.AudioRecordingConfiguration#getAudioDevice}.
+   */
+  public AudioRecordingConfiguration createActiveRecordingConfiguration(
+      int sessionId, int audioSource, String clientPackageName) {
+    Parcel p = Parcel.obtain();
+    p.writeInt(sessionId); // mSessionId
+    p.writeInt(audioSource); // mClientSource
+    writeMono16BitAudioFormatToParcel(p); // mClientFormat
+    writeMono16BitAudioFormatToParcel(p); // mDeviceFormat
+    p.writeInt(INVALID_PATCH_HANDLE); // mPatchHandle
+    p.writeString(clientPackageName); // mClientPackageName
+    p.writeInt(0); // mClientUid
+
+    p.setDataPosition(0);
+
+    AudioRecordingConfiguration configuration =
+        AudioRecordingConfiguration.CREATOR.createFromParcel(p);
+    p.recycle();
+
+    return configuration;
+  }
+
+  /**
+   * Registers an {@link AudioPolicy} to allow that policy to control audio routing and audio focus.
+   *
+   * <p>Note: this implementation does NOT ensure that we have the permissions necessary to register
+   * the given {@link AudioPolicy}.
+   *
+   * @return {@link AudioManager.ERROR} if the given policy has already been registered, and {@link
+   *     AudioManager.SUCCESS} otherwise.
+   */
+  @HiddenApi
+  @Implementation(minSdk = P)
+  @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
+  protected int registerAudioPolicy(@NonNull Object audioPolicy) {
+    Preconditions.checkNotNull(audioPolicy, "Illegal null AudioPolicy argument");
+    AudioPolicy policy = (AudioPolicy) audioPolicy;
+    String id = getIdForAudioPolicy(audioPolicy);
+    if (registeredAudioPolicies.containsKey(id)) {
+      return AudioManager.ERROR;
+    }
+    registeredAudioPolicies.put(id, policy);
+    policy.setRegistration(id);
+    return AudioManager.SUCCESS;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = Q)
+  protected void unregisterAudioPolicy(@NonNull Object audioPolicy) {
+    Preconditions.checkNotNull(audioPolicy, "Illegal null AudioPolicy argument");
+    AudioPolicy policy = (AudioPolicy) audioPolicy;
+    registeredAudioPolicies.remove(getIdForAudioPolicy(policy));
+    policy.setRegistration(null);
+  }
+
+  /**
+   * Returns true if at least one audio policy is registered with this manager, and false otherwise.
+   */
+  public boolean isAnyAudioPolicyRegistered() {
+    return !registeredAudioPolicies.isEmpty();
+  }
+
+  /**
+   * Provides a mock like interface for the {@link AudioManager#generateAudioSessionId} method by
+   * returning positive distinct values, or {@link AudioManager#ERROR} if all possible values have
+   * already been returned.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected int generateAudioSessionId() {
+    if (audioSessionIdCounter < 0) {
+      return AudioManager.ERROR;
+    }
+
+    return audioSessionIdCounter++;
+  }
+
+  private static String getIdForAudioPolicy(@NonNull Object audioPolicy) {
+    return Integer.toString(System.identityHashCode(audioPolicy));
+  }
+
+  private static void writeMono16BitAudioFormatToParcel(Parcel p) {
+    p.writeInt(
+        AudioFormat.AUDIO_FORMAT_HAS_PROPERTY_ENCODING
+            + AudioFormat.AUDIO_FORMAT_HAS_PROPERTY_SAMPLE_RATE
+            + AudioFormat.AUDIO_FORMAT_HAS_PROPERTY_CHANNEL_MASK); // mPropertySetMask
+    p.writeInt(AudioFormat.ENCODING_PCM_16BIT); // mEncoding
+    p.writeInt(16000); // mSampleRate
+    p.writeInt(AudioFormat.CHANNEL_OUT_MONO); // mChannelMask
+    p.writeInt(0); // mChannelIndexMask
+  }
+
+  public static class AudioFocusRequest {
+    public final AudioManager.OnAudioFocusChangeListener listener;
+    public final int streamType;
+    public final int durationHint;
+    public final android.media.AudioFocusRequest audioFocusRequest;
+
+    private AudioFocusRequest(
+        AudioManager.OnAudioFocusChangeListener listener, int streamType, int durationHint) {
+      this.listener = listener;
+      this.streamType = streamType;
+      this.durationHint = durationHint;
+      this.audioFocusRequest = null;
+    }
+
+    private AudioFocusRequest(android.media.AudioFocusRequest audioFocusRequest) {
+      this.listener = audioFocusRequest.getOnAudioFocusChangeListener();
+      this.durationHint = audioFocusRequest.getFocusGain();
+      this.streamType = audioFocusRequest.getAudioAttributes().getVolumeControlStream();
+      this.audioFocusRequest = audioFocusRequest;
+    }
+  }
+
+  private static class AudioStream {
+    private int currentVolume;
+    private int maxVolume;
+    private int flag;
+
+    public AudioStream(int currVol, int maxVol, int flag) {
+      if (MIN_VOLUME > maxVol) {
+        throw new IllegalArgumentException("Min volume is higher than max volume.");
+      }
+      setCurrentVolume(currVol);
+      setMaxVolume(maxVol);
+      setFlag(flag);
+    }
+
+    public int getCurrentVolume() {
+      return currentVolume;
+    }
+
+    public int getMaxVolume() {
+      return maxVolume;
+    }
+
+    public int getFlag() {
+      return flag;
+    }
+
+    public void setCurrentVolume(int vol) {
+      if (vol > maxVolume) {
+        vol = maxVolume;
+      } else if (vol < MIN_VOLUME) {
+        vol = MIN_VOLUME;
+      }
+      currentVolume = vol;
+    }
+
+    public void setMaxVolume(int vol) {
+      maxVolume = vol;
+    }
+
+    public void setFlag(int flag) {
+      this.flag = flag;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioRecord.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioRecord.java
new file mode 100644
index 0000000..6272556
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioRecord.java
@@ -0,0 +1,189 @@
+package org.robolectric.shadows;
+
+import static android.media.AudioRecord.ERROR_BAD_VALUE;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+
+import android.media.AudioFormat;
+import android.media.AudioRecord;
+import android.media.AudioSystem;
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.util.concurrent.atomic.AtomicReference;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Shadow {@link AudioRecord} which by default will fulfil any requests for audio data by completely
+ * filling any requested buffers.
+ *
+ * <p>It is also possible to provide the underlying data by implementing {@link AudioRecordSource}
+ * and setting this via {@link #setSourceProvider(AudioRecordSourceProvider)}.
+ */
+@Implements(value = AudioRecord.class, minSdk = LOLLIPOP)
+public final class ShadowAudioRecord {
+
+  @RealObject AudioRecord audioRecord;
+
+  private static final AudioRecordSource DEFAULT_SOURCE = new AudioRecordSource() {};
+  private static final AtomicReference<AudioRecordSourceProvider> audioRecordSourceProvider =
+      new AtomicReference<>(audioRecord -> DEFAULT_SOURCE);
+
+  /**
+   * Sets {@link AudioRecordSource} to be used for providing data to {@link AudioRecord}.
+   *
+   * @deprecated use {@link #setSourceProvider(AudioRecordSourceProvider)}.
+   */
+  @Deprecated
+  public static void setSource(AudioRecordSource source) {
+    audioRecordSourceProvider.set(audioRecord -> source);
+  }
+
+  /**
+   * Sets {@link AudioRecordSourceProvider} to be used for providing data of {@link AudioRecord}.
+   */
+  public static void setSourceProvider(AudioRecordSourceProvider audioRecordSourceProvider) {
+    ShadowAudioRecord.audioRecordSourceProvider.set(audioRecordSourceProvider);
+  }
+
+  /**
+   * Resets {@link AudioRecordSource} to be used for providing data to {@link AudioRecord}, so that
+   * all requests are fulfilled for audio data by completely filling any requested buffers.
+   */
+  @Resetter
+  public static void clearSource() {
+    setSource(DEFAULT_SOURCE);
+  }
+
+  @Implementation
+  protected static int native_get_min_buff_size(
+      int sampleRateInHz, int channelCount, int audioFormat) {
+    int frameSize;
+    switch (audioFormat) {
+      case AudioFormat.ENCODING_PCM_16BIT:
+        frameSize = 2;
+        break;
+      case AudioFormat.ENCODING_PCM_FLOAT:
+        frameSize = 2 * channelCount;
+        break;
+      default:
+        return ERROR_BAD_VALUE;
+    }
+    return frameSize * (sampleRateInHz / 4); // Approx quarter of a second sample per buffer
+  }
+
+  @Implementation
+  protected int native_start(int syncEvent, int sessionId) {
+    return AudioSystem.SUCCESS;
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected int native_read_in_byte_array(byte[] audioData, int offsetInBytes, int sizeInBytes) {
+    return native_read_in_byte_array(audioData, offsetInBytes, sizeInBytes, true);
+  }
+
+  @Implementation(minSdk = M)
+  protected int native_read_in_byte_array(
+      byte[] audioData, int offsetInBytes, int sizeInBytes, boolean isBlocking) {
+    return getAudioRecordSource()
+        .readInByteArray(audioData, offsetInBytes, sizeInBytes, isBlocking);
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected int native_read_in_short_array(
+      short[] audioData, int offsetInShorts, int sizeInShorts) {
+    return native_read_in_short_array(audioData, offsetInShorts, sizeInShorts, true);
+  }
+
+  @Implementation(minSdk = M)
+  protected int native_read_in_short_array(
+      short[] audioData, int offsetInShorts, int sizeInShorts, boolean isBlocking) {
+    return getAudioRecordSource()
+        .readInShortArray(audioData, offsetInShorts, sizeInShorts, isBlocking);
+  }
+
+  @Implementation(minSdk = M)
+  protected int native_read_in_float_array(
+      float[] audioData, int offsetInFloats, int sizeInFloats, boolean isBlocking) {
+    return getAudioRecordSource()
+        .readInFloatArray(audioData, offsetInFloats, sizeInFloats, isBlocking);
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected int native_read_in_direct_buffer(Object jBuffer, int sizeInBytes) {
+    return native_read_in_direct_buffer(jBuffer, sizeInBytes, true);
+  }
+
+  @Implementation(minSdk = M)
+  protected int native_read_in_direct_buffer(Object jBuffer, int sizeInBytes, boolean isBlocking) {
+    // Note, in the real implementation the buffers position is not adjusted during the
+    // read, so use duplicate to ensure the real implementation is matched.
+    return getAudioRecordSource()
+        .readInDirectBuffer(((ByteBuffer) jBuffer).duplicate(), sizeInBytes, isBlocking);
+  }
+
+  private AudioRecordSource getAudioRecordSource() {
+    return audioRecordSourceProvider.get().get(audioRecord);
+  }
+
+  /** Provides {@link AudioRecordSource} for the given {@link AudioRecord}. */
+  public interface AudioRecordSourceProvider {
+    AudioRecordSource get(AudioRecord audioRecord);
+  }
+
+  /** Provides underlying data for the {@link ShadowAudioRecord}. */
+  public interface AudioRecordSource {
+
+    /**
+     * Provides backing data for {@link AudioRecord#read(byte[], int, int)} and {@link
+     * AudioRecord#read(byte[], int, int, int)}.
+     *
+     * @return Either a non-negative value representing number of bytes that have been written from
+     *     the offset or a negative error code.
+     */
+    default int readInByteArray(
+        byte[] audioData, int offsetInBytes, int sizeInBytes, boolean isBlocking) {
+      return sizeInBytes;
+    }
+
+    /**
+     * Provides backing data for {@link AudioRecord#read(short[], int, int)} and {@link
+     * AudioRecord#read(short[], int, int, int)}.
+     *
+     * @return Either a non-negative value representing number of bytes that have been written from
+     *     the offset or a negative error code.
+     */
+    default int readInShortArray(
+        short[] audioData, int offsetInShorts, int sizeInShorts, boolean isBlocking) {
+      return sizeInShorts;
+    }
+
+    /**
+     * Provides backing data for {@link AudioRecord#read(float[], int, int, int)}.
+     *
+     * @return Either a non-negative value representing number of bytes that have been written from
+     *     the offset or a negative error code.
+     */
+    default int readInFloatArray(
+        float[] audioData, int offsetInFloats, int sizeInFloats, boolean isBlocking) {
+      return sizeInFloats;
+    }
+
+    /**
+     * Provides backing data for {@link AudioRecord#read(byte[], int, int)} and {@link
+     * AudioRecord#read(byte[], int, int, int)}.
+     *
+     * @return Either a non-negative value representing number of bytes that have been written from
+     *     the offset or a negative error code. Note any position/limit changes to the buffer will
+     *     not be visible to the caller of the AudioRecord methods.
+     */
+    default int readInDirectBuffer(ByteBuffer buffer, int sizeInBytes, boolean isBlocking) {
+      int maxBytes = Math.min(buffer.remaining(), sizeInBytes);
+      ((Buffer) buffer).position(buffer.position() + maxBytes);
+      return maxBytes;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java
new file mode 100644
index 0000000..5b05140
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.media.AudioSystem;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link AudioSystem}. */
+@Implements(value = AudioSystem.class, isInAndroidSdk = false)
+public class ShadowAudioSystem {
+
+  // from frameworks/base/core/jni/android_media_AudioSystem.cpp
+  private static final int MAX_CHANNEL_COUNT = 8;
+  private static final int MAX_SAMPLE_RATE = 192000;
+  private static final int MIN_SAMPLE_RATE = 4000;
+
+  @Implementation(minSdk = S)
+  protected static int native_getMaxChannelCount() {
+    return MAX_CHANNEL_COUNT;
+  }
+
+  @Implementation(minSdk = S)
+  protected static int native_getMaxSampleRate() {
+    return MAX_SAMPLE_RATE;
+  }
+
+  @Implementation(minSdk = S)
+  protected static int native_getMinSampleRate() {
+    return MIN_SAMPLE_RATE;
+  }
+
+  @Implementation(minSdk = Q, maxSdk = R)
+  protected static int native_get_FCC_8() {
+    // Return the value hard-coded in native code:
+    // https://cs.android.com/android/platform/superproject/+/master:system/media/audio/include/system/audio-base.h;l=197;drc=c84ca89fa5d660046364897482b202c797c8595e
+    return 8;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java
new file mode 100644
index 0000000..45a5557
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java
@@ -0,0 +1,154 @@
+package org.robolectric.shadows;
+
+import static android.media.AudioTrack.ERROR_BAD_VALUE;
+import static android.media.AudioTrack.WRITE_BLOCKING;
+import static android.media.AudioTrack.WRITE_NON_BLOCKING;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.annotation.NonNull;
+import android.media.AudioFormat;
+import android.media.AudioTrack;
+import android.media.AudioTrack.WriteMode;
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Implementation of a couple methods in {@link AudioTrack}. Only a couple methods are supported,
+ * other methods are expected run through the real class. The two {@link WriteMode} are treated the
+ * same.
+ */
+@Implements(value = AudioTrack.class, looseSignatures = true)
+public class ShadowAudioTrack {
+
+  /**
+   * Listeners to be notified when data is written to an {@link AudioTrack} via {@link
+   * AudioTrack#write(ByteBuffer, int, int)}
+   *
+   * <p>Currently, only the data written through AudioTrack.write(ByteBuffer audioData, int
+   * sizeInBytes, int writeMode) will be reported.
+   */
+  public interface OnAudioDataWrittenListener {
+
+    /**
+     * Called when data is written to {@link ShadowAudioTrack}.
+     *
+     * @param audioTrack The {@link ShadowAudioTrack} to which the data is written.
+     * @param audioData The data that is written to the {@link ShadowAudioTrack}.
+     * @param format The output format of the {@link ShadowAudioTrack}.
+     */
+    void onAudioDataWritten(ShadowAudioTrack audioTrack, byte[] audioData, AudioFormat format);
+  }
+
+  protected static final int DEFAULT_MIN_BUFFER_SIZE = 1024;
+
+  private static final String TAG = "ShadowAudioTrack";
+  private static int minBufferSize = DEFAULT_MIN_BUFFER_SIZE;
+  private static final List<OnAudioDataWrittenListener> audioDataWrittenListeners =
+      new CopyOnWriteArrayList<>();
+  private int numBytesReceived;
+  @RealObject AudioTrack audioTrack;
+
+  /**
+   * In the real class, the minimum buffer size is estimated from audio sample rate and other
+   * factors. We do not provide such estimation in {@link #native_get_min_buff_size(int, int, int)},
+   * instead letting users set the minimum for the expected audio sample. Usually higher sample rate
+   * requires bigger buffer size.
+   */
+  public static void setMinBufferSize(int bufferSize) {
+    minBufferSize = bufferSize;
+  }
+
+  @Implementation(minSdk = N, maxSdk = P)
+  protected static int native_get_FCC_8() {
+    // Return the value hard-coded in native code:
+    // https://cs.android.com/android/platform/superproject/+/android-7.1.1_r41:system/media/audio/include/system/audio.h;l=42;drc=57a4158dc4c4ce62bc6a2b8a0072ba43305548d4
+    return 8;
+  }
+
+  /** Returns a predefined or default minimum buffer size. Audio format and config are neglected. */
+  @Implementation
+  protected static int native_get_min_buff_size(
+      int sampleRateInHz, int channelConfig, int audioFormat) {
+    return minBufferSize;
+  }
+
+  /**
+   * Always return the number of bytes to write. This method returns immedidately even with {@link
+   * AudioTrack#WRITE_BLOCKING}
+   */
+  @Implementation(minSdk = M)
+  protected final int native_write_byte(
+      byte[] audioData, int offsetInBytes, int sizeInBytes, int format, boolean isBlocking) {
+    return sizeInBytes;
+  }
+
+  /**
+   * Always return the number of bytes to write except with invalid parameters. Assumes AudioTrack
+   * is already initialized (object properly created). Do not block even if AudioTrack in offload
+   * mode is in STOPPING play state. This method returns immediately even with {@link
+   * AudioTrack#WRITE_BLOCKING}
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected int write(@NonNull ByteBuffer audioData, int sizeInBytes, @WriteMode int writeMode) {
+    if (writeMode != WRITE_BLOCKING && writeMode != WRITE_NON_BLOCKING) {
+      Log.e(TAG, "ShadowAudioTrack.write() called with invalid blocking mode");
+      return ERROR_BAD_VALUE;
+    }
+    if (sizeInBytes < 0 || sizeInBytes > audioData.remaining()) {
+      Log.e(TAG, "ShadowAudioTrack.write() called with invalid size (" + sizeInBytes + ") value");
+      return ERROR_BAD_VALUE;
+    }
+
+    byte[] receivedBytes = new byte[sizeInBytes];
+    audioData.get(receivedBytes);
+    numBytesReceived += sizeInBytes;
+
+    for (OnAudioDataWrittenListener listener : audioDataWrittenListeners) {
+      listener.onAudioDataWritten(this, receivedBytes, audioTrack.getFormat());
+    }
+
+    return sizeInBytes;
+  }
+
+  @Implementation
+  protected int getPlaybackHeadPosition() {
+    return numBytesReceived / audioTrack.getFormat().getFrameSizeInBytes();
+  }
+
+  @Implementation
+  protected void flush() {
+    numBytesReceived = 0;
+  }
+
+  /**
+   * Registers an {@link OnAudioDataWrittenListener} to the {@link ShadowAudioTrack}.
+   *
+   * @param listener The {@link OnAudioDataWrittenListener} to be registered.
+   */
+  public static void addAudioDataListener(OnAudioDataWrittenListener listener) {
+    ShadowAudioTrack.audioDataWrittenListeners.add(listener);
+  }
+
+  /**
+   * Removes an {@link OnAudioDataWrittenListener} from the {@link ShadowAudioTrack}.
+   *
+   * @param listener The {@link OnAudioDataWrittenListener} to be removed.
+   */
+  public static void removeAudioDataListener(OnAudioDataWrittenListener listener) {
+    ShadowAudioTrack.audioDataWrittenListeners.remove(listener);
+  }
+
+  @Resetter
+  public static void resetTest() {
+    audioDataWrittenListeners.clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAutofillManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAutofillManager.java
new file mode 100644
index 0000000..fea9f9e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAutofillManager.java
@@ -0,0 +1,71 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.content.ComponentName;
+import android.service.autofill.FillEventHistory;
+import android.view.autofill.AutofillManager;
+import androidx.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Robolectric implementation of {@link android.os.AutofillManager}.
+ */
+@Implements(value = AutofillManager.class, minSdk = O)
+public class ShadowAutofillManager {
+  @Nullable private ComponentName autofillServiceComponentName = null;
+  private boolean autofillSupported = false;
+  private boolean enabled = false;
+
+  @Implementation
+  protected FillEventHistory getFillEventHistory() {
+    return null;
+  }
+
+  /**
+   * Returns the overridden value set by {@link #setAutofillServiceComponentName(ComponentName)}.
+   */
+  @Nullable
+  @Implementation(minSdk = P)
+  protected ComponentName getAutofillServiceComponentName() {
+    return autofillServiceComponentName;
+  }
+
+  /** Returns the overridden value set by {@link #setAutofillSupported(boolean)}. */
+  @Implementation
+  protected boolean isAutofillSupported() {
+    return autofillSupported;
+  }
+
+  /** Returns the overridden value set by {@link #setEnabled(boolean)}. */
+  @Implementation
+  protected boolean isEnabled() {
+    return enabled;
+  }
+
+  /**
+   * Overrides the component name of the autofill service enabled for the current user. See {@link
+   * AutofillManager#getAutofillServiceComponentName()}.
+   */
+  public void setAutofillServiceComponentName(@Nullable ComponentName componentName) {
+    this.autofillServiceComponentName = componentName;
+  }
+
+  /**
+   * Overrides the autofill supported state for the current device and current user. See {@link
+   * AutofillManager#isAutofillSupported()}.
+   */
+  public void setAutofillSupported(boolean supported) {
+    this.autofillSupported = supported;
+  }
+
+  /**
+   * Overrides the autofill enabled state for the current user. See {@link
+   * AutofillManager#isEnabled()}.
+   */
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackdropFrameRenderer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackdropFrameRenderer.java
new file mode 100644
index 0000000..2beaab0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackdropFrameRenderer.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Looper;
+import android.view.Choreographer;
+import android.view.ThreadedRenderer;
+import com.android.internal.policy.BackdropFrameRenderer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link BackdropFrameRenderer} */
+@Implements(value = BackdropFrameRenderer.class, minSdk = S, isInAndroidSdk = false)
+public class ShadowBackdropFrameRenderer {
+
+  // Updated to the real value in the generated Shadow constructor
+  @RealObject private final BackdropFrameRenderer realBackdropFrameRenderer = null;
+
+  @Implementation
+  protected void run() {
+    try {
+      Looper.prepare();
+      synchronized (realBackdropFrameRenderer) {
+        ThreadedRenderer renderer =
+            reflector(BackdropFrameRendererReflector.class, realBackdropFrameRenderer)
+                .getRenderer();
+        if (renderer == null) {
+          // This can happen if 'releaseRenderer' is called immediately after 'start'.
+          return;
+        }
+        reflector(BackdropFrameRendererReflector.class, realBackdropFrameRenderer)
+            .setChoreographer(Choreographer.getInstance());
+      }
+      Looper.loop();
+    } finally {
+      reflector(BackdropFrameRendererReflector.class, realBackdropFrameRenderer).releaseRenderer();
+    }
+    synchronized (realBackdropFrameRenderer) {
+      reflector(BackdropFrameRendererReflector.class, realBackdropFrameRenderer)
+          .setChoreographer(null);
+      Choreographer.releaseInstance();
+    }
+  }
+
+  @ForType(BackdropFrameRenderer.class)
+  interface BackdropFrameRendererReflector {
+    @Direct
+    void releaseRenderer();
+
+    @Accessor("mRenderer")
+    ThreadedRenderer getRenderer();
+
+    @Accessor("mChoreographer")
+    void setChoreographer(Choreographer c);
+
+    @Accessor("mChoreographer")
+    Choreographer getChoreographer();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackgroundThread.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackgroundThread.java
new file mode 100644
index 0000000..90e81ac
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackgroundThread.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Handler;
+import com.android.internal.os.BackgroundThread;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = BackgroundThread.class, isInAndroidSdk = false, minSdk = KITKAT)
+public class ShadowBackgroundThread {
+
+  @Resetter
+  public static void reset() {
+    _BackgroundThread_ _backgroundThreadStatic_ = reflector(_BackgroundThread_.class);
+
+    BackgroundThread instance = _backgroundThreadStatic_.getInstance();
+    if (instance != null) {
+      instance.quit();
+      _backgroundThreadStatic_.setInstance(null);
+      _backgroundThreadStatic_.setHandler(null);
+    }
+  }
+
+  /** Accessor interface for {@link BackgroundThread}'s internals. */
+  @ForType(BackgroundThread.class)
+  interface _BackgroundThread_ {
+
+    @Static @Accessor("sHandler")
+    void setHandler(Handler o);
+
+    @Static @Accessor("sInstance")
+    void setInstance(BackgroundThread o);
+
+    @Static @Accessor("sInstance")
+    BackgroundThread getInstance();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackupManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackupManager.java
new file mode 100644
index 0000000..42195f5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBackupManager.java
@@ -0,0 +1,245 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+
+import android.app.backup.BackupManager;
+import android.app.backup.IBackupManagerMonitor;
+import android.app.backup.IRestoreObserver;
+import android.app.backup.IRestoreSession;
+import android.app.backup.RestoreSession;
+import android.app.backup.RestoreSet;
+import android.content.Context;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * A stub implementation of {@link BackupManager} that instead of connecting to a real backup
+ * transport and performing restores, stores which packages are restored from which backup set, and
+ * can be verified using methods on the shadow like {@link #getPackageRestoreToken(String)}.
+ */
+@Implements(BackupManager.class)
+public class ShadowBackupManager {
+
+  private static BackupManagerServiceState serviceState = new BackupManagerServiceState();
+
+  @RealObject private BackupManager realBackupManager;
+  private Context context;
+
+  @Resetter
+  public static void reset() {
+    serviceState = new BackupManagerServiceState();
+  }
+
+  @Implementation
+  protected void __constructor__(Context context) {
+    this.context = context;
+    Shadow.invokeConstructor(
+        BackupManager.class, realBackupManager, ClassParameter.from(Context.class, context));
+  }
+
+  @Implementation
+  protected void dataChanged() {
+    serviceState.dataChangedCount.merge(context.getPackageName(), 1, Integer::sum);
+  }
+
+  /** Returns whether {@link #dataChanged()} was called. */
+  public boolean isDataChanged() {
+    return serviceState.dataChangedCount.containsKey(context.getPackageName());
+  }
+
+  /** Returns number of times {@link #dataChanged()} was called. */
+  public int getDataChangedCount() {
+    return serviceState.dataChangedCount.getOrDefault(context.getPackageName(), 0);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi // SystemApi
+  protected void setBackupEnabled(boolean isEnabled) {
+    enforceBackupPermission("setBackupEnabled");
+    serviceState.backupEnabled = isEnabled;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi // SystemApi
+  protected boolean isBackupEnabled() {
+    enforceBackupPermission("isBackupEnabled");
+    return serviceState.backupEnabled;
+  }
+
+  @Implementation
+  @HiddenApi // SystemApi
+  protected RestoreSession beginRestoreSession() {
+    enforceBackupPermission("beginRestoreSession");
+    return ReflectionHelpers.callConstructor(
+        RestoreSession.class,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(IRestoreSession.class, new FakeRestoreSession()));
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi // SystemApi
+  protected long getAvailableRestoreToken(String packageName) {
+    enforceBackupPermission("getAvailableRestoreToken");
+    return getPackageRestoreToken(packageName);
+  }
+
+  /**
+   * Returns the restore token for the given package, or {@code 0} if the package was not restored.
+   */
+  public long getPackageRestoreToken(String packageName) {
+    Long token = serviceState.restoredPackages.get(packageName);
+    return token != null ? token : 0L;
+  }
+
+  /** Adds a restore set available to be restored. */
+  public void addAvailableRestoreSets(long restoreToken, List<String> packages) {
+    serviceState.restoreData.put(restoreToken, packages);
+  }
+
+  private void enforceBackupPermission(String message) {
+    RuntimeEnvironment.getApplication()
+        .enforceCallingOrSelfPermission(android.Manifest.permission.BACKUP, message);
+  }
+
+  private static class FakeRestoreSession implements IRestoreSession {
+
+    // Override method for SDK < 26
+    public int getAvailableRestoreSets(IRestoreObserver observer) throws RemoteException {
+      return getAvailableRestoreSets(observer, null);
+    }
+
+    @Override
+    public int getAvailableRestoreSets(IRestoreObserver observer, IBackupManagerMonitor monitor)
+        throws RemoteException {
+      post(
+          () -> {
+            Set<Long> restoreTokens = serviceState.restoreData.keySet();
+            Set<RestoreSet> restoreSets = new HashSet<>();
+            for (long token : restoreTokens) {
+              restoreSets.add(new RestoreSet("RestoreSet-" + token, "device", token));
+            }
+            observer.restoreSetsAvailable(restoreSets.toArray(new RestoreSet[restoreSets.size()]));
+          });
+      return BackupManager.SUCCESS;
+    }
+
+    // Override method for SDK < 26
+    public int restoreAll(long token, IRestoreObserver observer) throws RemoteException {
+      return restoreAll(token, observer, null);
+    }
+
+    @Override
+    public int restoreAll(long token, IRestoreObserver observer, IBackupManagerMonitor monitor)
+        throws RemoteException {
+      return restorePackages(token, observer, null, monitor);
+    }
+
+    // Override method for SDK <= 25
+    public int restoreSome(long token, IRestoreObserver observer, String[] packages)
+        throws RemoteException {
+      return restorePackages(token, observer, packages, null);
+    }
+
+    // Override method for SDK <= 28
+    public int restoreSome(
+        long token, IRestoreObserver observer, IBackupManagerMonitor monitor, String[] packages)
+        throws RemoteException {
+      return restorePackages(token, observer, packages, monitor);
+    }
+
+    public int restorePackages(
+        long token, IRestoreObserver observer, String[] packages, IBackupManagerMonitor monitor)
+        throws RemoteException {
+      List<String> restorePackages = new ArrayList<>(serviceState.restoreData.get(token));
+      if (packages != null) {
+        restorePackages.retainAll(Arrays.asList(packages));
+      }
+      post(() -> observer.restoreStarting(restorePackages.size()));
+      for (int i = 0; i < restorePackages.size(); i++) {
+        final int index = i; // final copy of i
+        post(() -> observer.onUpdate(index, restorePackages.get(index)));
+        serviceState.restoredPackages.put(restorePackages.get(index), token);
+      }
+      post(() -> observer.restoreFinished(BackupManager.SUCCESS));
+      serviceState.lastRestoreToken = token;
+      return BackupManager.SUCCESS;
+    }
+
+    // Override method for SDK < 26
+    public int restorePackage(String packageName, IRestoreObserver observer)
+        throws RemoteException {
+      return restorePackage(packageName, observer, null);
+    }
+
+    @Override
+    public int restorePackage(
+        String packageName, IRestoreObserver observer, IBackupManagerMonitor monitor)
+        throws RemoteException {
+      if (serviceState.lastRestoreToken == 0L) {
+        return -1;
+      }
+      List<String> restorePackages = serviceState.restoreData.get(serviceState.lastRestoreToken);
+      if (!restorePackages.contains(packageName)) {
+        return BackupManager.ERROR_PACKAGE_NOT_FOUND;
+      }
+      post(() -> observer.restoreStarting(1));
+      post(() -> observer.onUpdate(0, packageName));
+      serviceState.restoredPackages.put(packageName, serviceState.lastRestoreToken);
+      post(() -> observer.restoreFinished(BackupManager.SUCCESS));
+      return BackupManager.SUCCESS;
+    }
+
+    @Override
+    public void endRestoreSession() throws RemoteException {
+      // do nothing
+    }
+
+    @Override
+    public IBinder asBinder() {
+      return null;
+    }
+
+    private void post(RemoteRunnable runnable) {
+      new Handler(Looper.getMainLooper())
+          .post(
+              () -> {
+                try {
+                  runnable.run();
+                } catch (RemoteException e) {
+                  throw new RuntimeException(e);
+                }
+              });
+    }
+  }
+
+  private static class BackupManagerServiceState {
+    boolean backupEnabled = true;
+    long lastRestoreToken = 0L;
+    final Map<String, Integer> dataChangedCount = new HashMap<>();
+    final Map<Long, List<String>> restoreData = new HashMap<>();
+    final Map<String, Long> restoredPackages = new HashMap<>();
+  }
+
+  private interface RemoteRunnable {
+    void run() throws RemoteException;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBaseAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBaseAdapter.java
new file mode 100644
index 0000000..2ccf9f9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBaseAdapter.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.BaseAdapter;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(BaseAdapter.class)
+public class ShadowBaseAdapter {
+  @RealObject private BaseAdapter realBaseAdapter;
+  private boolean wasNotifyDataSetChangedCalled;
+
+  @Implementation
+  protected void notifyDataSetChanged() {
+    wasNotifyDataSetChangedCalled = true;
+    reflector(BaseAdapterReflector.class, realBaseAdapter).notifyDataSetChanged();
+  }
+
+  public void clearWasDataSetChangedCalledFlag() {
+    wasNotifyDataSetChangedCalled = false;
+  }
+
+  public boolean wasNotifyDataSetChangedCalled() {
+    return wasNotifyDataSetChangedCalled;
+  }
+
+  @ForType(BaseAdapter.class)
+  interface BaseAdapterReflector {
+
+    @Direct
+    void notifyDataSetChanged();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBasicTagTechnology.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBasicTagTechnology.java
new file mode 100644
index 0000000..ac583f4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBasicTagTechnology.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import java.io.IOException;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Extends BasicTagTechnology to allow for testing. */
+@Implements(className = "android.nfc.tech.BasicTagTechnology")
+public class ShadowBasicTagTechnology {
+  private boolean isConnected = false;
+
+  @Implementation
+  protected boolean isConnected() {
+    return isConnected;
+  }
+
+  @Implementation
+  protected void connect() throws IOException {
+    isConnected = true;
+  }
+
+  @Implementation
+  protected void close() {
+    isConnected = false;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBatteryManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBatteryManager.java
new file mode 100644
index 0000000..6dad750
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBatteryManager.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+
+import android.os.BatteryManager;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(BatteryManager.class)
+public class ShadowBatteryManager {
+  private boolean isCharging = false;
+  private final Map<Integer, Long> longProperties = new HashMap<>();
+  private final Map<Integer, Integer> intProperties = new HashMap<>();
+
+  @Implementation(minSdk = M)
+  protected boolean isCharging() {
+    return isCharging;
+  }
+
+  public void setIsCharging(boolean charging) {
+    isCharging = charging;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected int getIntProperty(int id) {
+    return intProperties.containsKey(id) ? intProperties.get(id) : Integer.MIN_VALUE;
+  }
+
+  public void setIntProperty(int id, int value) {
+    intProperties.put(id, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected long getLongProperty(int id) {
+    return longProperties.containsKey(id) ? longProperties.get(id) : Long.MIN_VALUE;
+  }
+
+  public void setLongProperty(int id, long value) {
+    longProperties.put(id, value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBinder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBinder.java
new file mode 100644
index 0000000..a5f99b1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBinder.java
@@ -0,0 +1,113 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.os.Binder;
+import android.os.Parcel;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+
+@Implements(Binder.class)
+public class ShadowBinder {
+  @RealObject
+  Binder realObject;
+
+  private static Integer callingUid;
+  private static Integer callingPid;
+  private static UserHandle callingUserHandle;
+
+  @Implementation
+  protected boolean transact(int code, Parcel data, Parcel reply, int flags)
+      throws RemoteException {
+   if (data != null) {
+     data.setDataPosition(0);
+   }
+
+   boolean result;
+   try {
+     result = new ShadowBinderBridge(realObject).onTransact(code, data, reply, flags);
+   } catch (RemoteException e) {
+     throw e;
+   } catch (Exception e) {
+     result = true;
+     if (reply != null) {
+       reply.writeException(e);
+     }
+   }
+
+   if (reply != null) {
+     reply.setDataPosition(0);
+   }
+   return result;
+  }
+
+  @Implementation
+  protected static final int getCallingPid() {
+    if (callingPid != null) {
+      return callingPid;
+    }
+    return android.os.Process.myPid();
+  }
+
+  @Implementation
+  protected static final int getCallingUid() {
+    if (callingUid != null) {
+      return callingUid;
+    }
+    return android.os.Process.myUid();
+  }
+
+  /**
+   * See {@link Binder#getCallingUidOrThrow()}. Whether or not this returns a value is controlled by
+   * {@link #setCallingUid(int)} (to set the value to be returned) or by {@link #reset()} (to
+   * trigger the exception).
+   *
+   * @return the value set by {@link #setCallingUid(int)}
+   * @throws IllegalStateException if no UID has been set
+   */
+  @Implementation(minSdk = Q)
+  protected static final int getCallingUidOrThrow() {
+    if (callingUid != null) {
+      return callingUid;
+    }
+
+    // Typo in "transaction" intentional to match platform
+    throw new IllegalStateException("Thread is not in a binder transcation");
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static final UserHandle getCallingUserHandle() {
+    if (callingUserHandle != null) {
+      return callingUserHandle;
+    }
+    return android.os.Process.myUserHandle();
+  }
+
+  public static void setCallingPid(int pid) {
+    ShadowBinder.callingPid = pid;
+  }
+
+  public static void setCallingUid(int uid) {
+    ShadowBinder.callingUid = uid;
+  }
+
+  /**
+   * Configures {@link android.os.Binder#getCallingUserHandle} to return the specified {@link
+   * UserHandle} to subsequent callers on *any* thread, for testing purposes.
+   */
+  public static void setCallingUserHandle(UserHandle userHandle) {
+    ShadowBinder.callingUserHandle = userHandle;
+  }
+
+  @Resetter
+  public static void reset() {
+    ShadowBinder.callingPid = null;
+    ShadowBinder.callingUid = null;
+    ShadowBinder.callingUserHandle = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBinderBridge.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBinderBridge.java
new file mode 100644
index 0000000..bbf1b8f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBinderBridge.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import android.os.Binder;
+import android.os.Parcel;
+import android.os.RemoteException;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Bridge between shadow and {@link android.os.Binder}.
+ */
+@DoNotInstrument
+public class ShadowBinderBridge {
+  private Binder realBinder;
+
+  public ShadowBinderBridge(Binder realBinder) {
+    this.realBinder = realBinder;
+  }
+
+  public boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {
+    try {
+      return ReflectionHelpers.callInstanceMethod(
+          realBinder,
+          "onTransact",
+          ClassParameter.from(int.class, code),
+          ClassParameter.from(Parcel.class, data),
+          ClassParameter.from(Parcel.class, reply),
+          ClassParameter.from(int.class, flags));
+    } catch (RuntimeException e) {
+      if (e.getCause() instanceof RemoteException) {
+        throw (RemoteException) e.getCause();
+      } else {
+        throw e;
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBiometricManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBiometricManager.java
new file mode 100644
index 0000000..6dabdf5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBiometricManager.java
@@ -0,0 +1,125 @@
+package org.robolectric.shadows;
+
+import static android.Manifest.permission.USE_BIOMETRIC;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.RequiresPermission;
+import android.content.Context;
+import android.hardware.biometrics.BiometricManager;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Provides testing APIs for {@link BiometricManager} */
+@Implements(
+    className = "android.hardware.biometrics.BiometricManager",
+    minSdk = Q,
+    isInAndroidSdk = false)
+public class ShadowBiometricManager {
+
+  protected boolean biometricServiceConnected = true;
+  private int authenticatorType = BiometricManager.Authenticators.EMPTY_SET;
+
+  @RealObject private BiometricManager realBiometricManager;
+
+  @SuppressWarnings("deprecation")
+  @RequiresPermission(USE_BIOMETRIC)
+  @Implementation
+  protected int canAuthenticate() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      return reflector(BiometricManagerReflector.class, realBiometricManager).canAuthenticate();
+    } else {
+      int biometricResult =
+          canAuthenticateInternal(0, BiometricManager.Authenticators.BIOMETRIC_WEAK);
+      if (biometricServiceConnected) {
+        return BiometricManager.BIOMETRIC_SUCCESS;
+      } else if (biometricResult != BiometricManager.BIOMETRIC_SUCCESS) {
+        return biometricResult;
+      } else {
+        boolean hasBiometrics =
+            ReflectionHelpers.callStaticMethod(
+                BiometricManager.class,
+                "hasBiometrics",
+                ReflectionHelpers.ClassParameter.from(
+                    Context.class, RuntimeEnvironment.getApplication().getApplicationContext()));
+        if (!hasBiometrics) {
+          return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE;
+        } else {
+          return BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE;
+        }
+      }
+    }
+  }
+
+  @RequiresPermission(USE_BIOMETRIC)
+  @Implementation(minSdk = R)
+  protected int canAuthenticate(int authenticators) {
+    return canAuthenticateInternal(0, authenticators);
+  }
+
+  @RequiresPermission(USE_BIOMETRIC)
+  @Implementation(minSdk = R)
+  protected int canAuthenticate(int userId, int authenticators) {
+    return canAuthenticateInternal(userId, authenticators);
+  }
+
+  private int canAuthenticateInternal(int userId, int authenticators) {
+    if (authenticatorType == BiometricManager.Authenticators.BIOMETRIC_STRONG
+        && biometricServiceConnected) {
+      return BiometricManager.BIOMETRIC_SUCCESS;
+    }
+    if ((authenticatorType & BiometricManager.Authenticators.DEVICE_CREDENTIAL)
+        == BiometricManager.Authenticators.DEVICE_CREDENTIAL) {
+      return BiometricManager.BIOMETRIC_SUCCESS;
+    }
+    if (authenticatorType != BiometricManager.Authenticators.EMPTY_SET) {
+      return authenticatorType;
+    }
+    if (!biometricServiceConnected) {
+      return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE;
+    } else {
+      return BiometricManager.BIOMETRIC_SUCCESS;
+    }
+  }
+
+  /**
+   * Sets the value {@code true} to allow {@link #canAuthenticate()} return {@link
+   * BIOMETRIC_SUCCESS} If sets the value to {@code false}, result will depend on {@link
+   * BiometricManager#hasBiometrics(Context context)}
+   *
+   * @param flag to set can authenticate or not
+   */
+  public void setCanAuthenticate(boolean flag) {
+    biometricServiceConnected = flag;
+  }
+
+  /**
+   * Allow different result {@link #canAuthenticate(int)}, result will depend on the combination as
+   * described <a
+   * href="https://developer.android.com/reference/android/hardware/biometrics/BiometricManager#canAuthenticate(int)">here</a>
+   * For example, you can set the value {@code BiometricManager.Authenticators.BIOMETRIC_STRONG} to
+   * allow {@link #canAuthenticate(int)} return {@link BiometricManager#BIOMETRIC_SUCCESS} when you
+   * passed {@code BiometricManager.Authenticators.BIOMETRIC_WEAK} as parameter in {@link
+   * #canAuthenticate(int)}
+   *
+   * @param type to set the authenticatorType
+   * @see <a
+   *     href="https://developer.android.com/reference/android/hardware/biometrics/BiometricManager#canAuthenticate(int)"
+   */
+  public void setAuthenticatorType(int type) {
+    authenticatorType = type;
+  }
+
+  @ForType(BiometricManager.class)
+  interface BiometricManagerReflector {
+
+    @Direct
+    int canAuthenticate();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmap.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmap.java
new file mode 100644
index 0000000..1b35839
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmap.java
@@ -0,0 +1,918 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.lang.Integer.max;
+import static java.lang.Integer.min;
+
+import android.graphics.Bitmap;
+import android.graphics.ColorSpace;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Parcel;
+import android.util.DisplayMetrics;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.ColorModel;
+import java.awt.image.DataBufferInt;
+import java.awt.image.WritableRaster;
+import java.io.FileDescriptor;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.nio.IntBuffer;
+import java.util.Arrays;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Bitmap.class)
+public class ShadowBitmap {
+  /** Number of bytes used internally to represent each pixel */
+  private static final int INTERNAL_BYTES_PER_PIXEL = 4;
+
+  int createdFromResId = -1;
+  String createdFromPath;
+  InputStream createdFromStream;
+  FileDescriptor createdFromFileDescriptor;
+  byte[] createdFromBytes;
+  @RealObject private Bitmap realBitmap;
+  private Bitmap createdFromBitmap;
+  private Bitmap scaledFromBitmap;
+  private int createdFromX = -1;
+  private int createdFromY = -1;
+  private int createdFromWidth = -1;
+  private int createdFromHeight = -1;
+  private int[] createdFromColors;
+  private Matrix createdFromMatrix;
+  private boolean createdFromFilter;
+
+  private int width;
+  private int height;
+  private BufferedImage bufferedImage;
+  private Bitmap.Config config;
+  private boolean mutable = true;
+  private String description = "";
+  private boolean recycled = false;
+  private boolean hasMipMap;
+  private boolean requestPremultiplied = true;
+  private boolean hasAlpha;
+  private ColorSpace colorSpace;
+
+  /**
+   * Returns a textual representation of the appearance of the object.
+   *
+   * @param bitmap the bitmap to visualize
+   * @return Textual representation of the appearance of the object.
+   */
+  public static String visualize(Bitmap bitmap) {
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    return shadowBitmap.getDescription();
+  }
+
+  @Implementation
+  protected static Bitmap createBitmap(int width, int height, Bitmap.Config config) {
+    return createBitmap((DisplayMetrics) null, width, height, config);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static Bitmap createBitmap(
+      DisplayMetrics displayMetrics, int width, int height, Bitmap.Config config) {
+    return createBitmap(displayMetrics, width, height, config, true);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static Bitmap createBitmap(
+      DisplayMetrics displayMetrics,
+      int width,
+      int height,
+      Bitmap.Config config,
+      boolean hasAlpha) {
+    if (width <= 0 || height <= 0) {
+      throw new IllegalArgumentException("width and height must be > 0");
+    }
+    checkNotNull(config);
+    Bitmap scaledBitmap = ReflectionHelpers.callConstructor(Bitmap.class);
+    ShadowBitmap shadowBitmap = Shadow.extract(scaledBitmap);
+    shadowBitmap.setDescription("Bitmap (" + width + " x " + height + ")");
+
+    shadowBitmap.width = width;
+    shadowBitmap.height = height;
+    shadowBitmap.config = config;
+    shadowBitmap.hasAlpha = hasAlpha;
+    shadowBitmap.setMutable(true);
+    if (displayMetrics != null) {
+      scaledBitmap.setDensity(displayMetrics.densityDpi);
+    }
+    shadowBitmap.bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      shadowBitmap.colorSpace = ColorSpace.get(ColorSpace.Named.SRGB);
+    }
+    return scaledBitmap;
+  }
+
+  @Implementation(minSdk = O)
+  protected static Bitmap createBitmap(
+      int width, int height, Bitmap.Config config, boolean hasAlpha, ColorSpace colorSpace) {
+    checkArgument(colorSpace != null || config == Bitmap.Config.ALPHA_8);
+    Bitmap bitmap = createBitmap(null, width, height, config, hasAlpha);
+    ShadowBitmap shadowBitmap = Shadows.shadowOf(bitmap);
+    shadowBitmap.colorSpace = colorSpace;
+    return bitmap;
+  }
+
+  @Implementation
+  protected static Bitmap createBitmap(
+      Bitmap src, int x, int y, int width, int height, Matrix matrix, boolean filter) {
+    if (x == 0
+        && y == 0
+        && width == src.getWidth()
+        && height == src.getHeight()
+        && (matrix == null || matrix.isIdentity())) {
+      return src; // Return the original.
+    }
+
+    if (x + width > src.getWidth()) {
+      throw new IllegalArgumentException("x + width must be <= bitmap.width()");
+    }
+    if (y + height > src.getHeight()) {
+      throw new IllegalArgumentException("y + height must be <= bitmap.height()");
+    }
+
+    Bitmap newBitmap = ReflectionHelpers.callConstructor(Bitmap.class);
+    ShadowBitmap shadowNewBitmap = Shadow.extract(newBitmap);
+
+    ShadowBitmap shadowSrcBitmap = Shadow.extract(src);
+    shadowNewBitmap.appendDescription(shadowSrcBitmap.getDescription());
+    shadowNewBitmap.appendDescription(" at (" + x + "," + y + ")");
+    shadowNewBitmap.appendDescription(" with width " + width + " and height " + height);
+
+    shadowNewBitmap.createdFromBitmap = src;
+    shadowNewBitmap.createdFromX = x;
+    shadowNewBitmap.createdFromY = y;
+    shadowNewBitmap.createdFromWidth = width;
+    shadowNewBitmap.createdFromHeight = height;
+    shadowNewBitmap.createdFromMatrix = matrix;
+    shadowNewBitmap.createdFromFilter = filter;
+    shadowNewBitmap.config = src.getConfig();
+    if (matrix != null) {
+      ShadowMatrix shadowMatrix = Shadow.extract(matrix);
+      shadowNewBitmap.appendDescription(" using matrix " + shadowMatrix.getDescription());
+
+      // Adjust width and height by using the matrix.
+      RectF mappedRect = new RectF();
+      matrix.mapRect(mappedRect, new RectF(0, 0, width, height));
+      width = Math.round(mappedRect.width());
+      height = Math.round(mappedRect.height());
+    }
+    if (filter) {
+      shadowNewBitmap.appendDescription(" with filter");
+    }
+
+    // updated if matrix is non-null
+    shadowNewBitmap.width = width;
+    shadowNewBitmap.height = height;
+    shadowNewBitmap.setMutable(true);
+    newBitmap.setDensity(src.getDensity());
+    if ((matrix == null || matrix.isIdentity()) && shadowSrcBitmap.bufferedImage != null) {
+      // Only simple cases are supported for setting image data to the new Bitmap.
+      shadowNewBitmap.bufferedImage =
+          shadowSrcBitmap.bufferedImage.getSubimage(x, y, width, height);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      shadowNewBitmap.colorSpace = shadowSrcBitmap.colorSpace;
+    }
+    return newBitmap;
+  }
+
+  @Implementation
+  protected static Bitmap createBitmap(
+      int[] colors, int offset, int stride, int width, int height, Bitmap.Config config) {
+    return createBitmap(null, colors, offset, stride, width, height, config);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static Bitmap createBitmap(
+      DisplayMetrics displayMetrics,
+      int[] colors,
+      int offset,
+      int stride,
+      int width,
+      int height,
+      Bitmap.Config config) {
+    if (width <= 0) {
+      throw new IllegalArgumentException("width must be > 0");
+    }
+    if (height <= 0) {
+      throw new IllegalArgumentException("height must be > 0");
+    }
+    if (Math.abs(stride) < width) {
+      throw new IllegalArgumentException("abs(stride) must be >= width");
+    }
+    checkNotNull(config);
+    int lastScanline = offset + (height - 1) * stride;
+    int length = colors.length;
+    if (offset < 0
+        || (offset + width > length)
+        || lastScanline < 0
+        || (lastScanline + width > length)) {
+      throw new ArrayIndexOutOfBoundsException();
+    }
+
+    BufferedImage bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+    bufferedImage.setRGB(0, 0, width, height, colors, offset, stride);
+    Bitmap bitmap = createBitmap(bufferedImage, width, height, config);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.setMutable(false);
+    shadowBitmap.createdFromColors = colors;
+    if (displayMetrics != null) {
+      bitmap.setDensity(displayMetrics.densityDpi);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      shadowBitmap.colorSpace = ColorSpace.get(ColorSpace.Named.SRGB);
+    }
+    return bitmap;
+  }
+
+  private static Bitmap createBitmap(
+      BufferedImage bufferedImage, int width, int height, Bitmap.Config config) {
+    Bitmap newBitmap = Bitmap.createBitmap(width, height, config);
+    ShadowBitmap shadowBitmap = Shadow.extract(newBitmap);
+    shadowBitmap.bufferedImage = bufferedImage;
+    return newBitmap;
+  }
+
+  @Implementation
+  protected static Bitmap createScaledBitmap(
+      Bitmap src, int dstWidth, int dstHeight, boolean filter) {
+    if (dstWidth == src.getWidth() && dstHeight == src.getHeight() && !filter) {
+      return src; // Return the original.
+    }
+    if (dstWidth <= 0 || dstHeight <= 0) {
+      throw new IllegalArgumentException("width and height must be > 0");
+    }
+    Bitmap scaledBitmap = ReflectionHelpers.callConstructor(Bitmap.class);
+    ShadowBitmap shadowBitmap = Shadow.extract(scaledBitmap);
+
+    ShadowBitmap shadowSrcBitmap = Shadow.extract(src);
+    shadowBitmap.appendDescription(shadowSrcBitmap.getDescription());
+    shadowBitmap.appendDescription(" scaled to " + dstWidth + " x " + dstHeight);
+    if (filter) {
+      shadowBitmap.appendDescription(" with filter " + filter);
+    }
+
+    shadowBitmap.createdFromBitmap = src;
+    shadowBitmap.scaledFromBitmap = src;
+    shadowBitmap.createdFromFilter = filter;
+    shadowBitmap.width = dstWidth;
+    shadowBitmap.height = dstHeight;
+    shadowBitmap.config = src.getConfig();
+    shadowBitmap.mutable = true;
+    if (!ImageUtil.scaledBitmap(src, scaledBitmap, filter)) {
+      shadowBitmap.bufferedImage =
+          new BufferedImage(dstWidth, dstHeight, BufferedImage.TYPE_INT_ARGB);
+      shadowBitmap.setPixelsInternal(
+          new int[shadowBitmap.getHeight() * shadowBitmap.getWidth()],
+          0,
+          0,
+          0,
+          0,
+          shadowBitmap.getWidth(),
+          shadowBitmap.getHeight());
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      shadowBitmap.colorSpace = shadowSrcBitmap.colorSpace;
+    }
+    return scaledBitmap;
+  }
+
+  @Implementation
+  protected static Bitmap nativeCreateFromParcel(Parcel p) {
+    int parceledWidth = p.readInt();
+    int parceledHeight = p.readInt();
+    Bitmap.Config parceledConfig = (Bitmap.Config) p.readSerializable();
+
+    int[] parceledColors = new int[parceledHeight * parceledWidth];
+    p.readIntArray(parceledColors);
+
+    return createBitmap(
+        parceledColors, 0, parceledWidth, parceledWidth, parceledHeight, parceledConfig);
+  }
+
+  static int getBytesPerPixel(Bitmap.Config config) {
+    if (config == null) {
+      throw new NullPointerException("Bitmap config was null.");
+    }
+    switch (config) {
+      case RGBA_F16:
+        return 8;
+      case ARGB_8888:
+      case HARDWARE:
+        return 4;
+      case RGB_565:
+      case ARGB_4444:
+        return 2;
+      case ALPHA_8:
+        return 1;
+      default:
+        throw new IllegalArgumentException("Unknown bitmap config: " + config);
+    }
+  }
+
+  /**
+   * Reference to original Bitmap from which this Bitmap was created. {@code null} if this Bitmap
+   * was not copied from another instance.
+   *
+   * @return Original Bitmap from which this Bitmap was created.
+   */
+  public Bitmap getCreatedFromBitmap() {
+    return createdFromBitmap;
+  }
+
+  /**
+   * Resource ID from which this Bitmap was created. {@code 0} if this Bitmap was not created from a
+   * resource.
+   *
+   * @return Resource ID from which this Bitmap was created.
+   */
+  public int getCreatedFromResId() {
+    return createdFromResId;
+  }
+
+  /**
+   * Path from which this Bitmap was created. {@code null} if this Bitmap was not create from a
+   * path.
+   *
+   * @return Path from which this Bitmap was created.
+   */
+  public String getCreatedFromPath() {
+    return createdFromPath;
+  }
+
+  /**
+   * {@link InputStream} from which this Bitmap was created. {@code null} if this Bitmap was not
+   * created from a stream.
+   *
+   * @return InputStream from which this Bitmap was created.
+   */
+  public InputStream getCreatedFromStream() {
+    return createdFromStream;
+  }
+
+  /**
+   * Bytes from which this Bitmap was created. {@code null} if this Bitmap was not created from
+   * bytes.
+   *
+   * @return Bytes from which this Bitmap was created.
+   */
+  public byte[] getCreatedFromBytes() {
+    return createdFromBytes;
+  }
+
+  /**
+   * Horizontal offset within {@link #getCreatedFromBitmap()} of this Bitmap's content, or -1.
+   *
+   * @return Horizontal offset within {@link #getCreatedFromBitmap()}.
+   */
+  public int getCreatedFromX() {
+    return createdFromX;
+  }
+
+  /**
+   * Vertical offset within {@link #getCreatedFromBitmap()} of this Bitmap's content, or -1.
+   *
+   * @return Vertical offset within {@link #getCreatedFromBitmap()} of this Bitmap's content, or -1.
+   */
+  public int getCreatedFromY() {
+    return createdFromY;
+  }
+
+  /**
+   * Width from {@link #getCreatedFromX()} within {@link #getCreatedFromBitmap()} of this Bitmap's
+   * content, or -1.
+   *
+   * @return Width from {@link #getCreatedFromX()} within {@link #getCreatedFromBitmap()} of this
+   *     Bitmap's content, or -1.
+   */
+  public int getCreatedFromWidth() {
+    return createdFromWidth;
+  }
+
+  /**
+   * Height from {@link #getCreatedFromX()} within {@link #getCreatedFromBitmap()} of this Bitmap's
+   * content, or -1.
+   *
+   * @return Height from {@link #getCreatedFromX()} within {@link #getCreatedFromBitmap()} of this
+   *     Bitmap's content, or -1.
+   */
+  public int getCreatedFromHeight() {
+    return createdFromHeight;
+  }
+
+  /**
+   * Color array from which this Bitmap was created. {@code null} if this Bitmap was not created
+   * from a color array.
+   *
+   * @return Color array from which this Bitmap was created.
+   */
+  public int[] getCreatedFromColors() {
+    return createdFromColors;
+  }
+
+  /**
+   * Matrix from which this Bitmap's content was transformed, or {@code null}.
+   *
+   * @return Matrix from which this Bitmap's content was transformed, or {@code null}.
+   */
+  public Matrix getCreatedFromMatrix() {
+    return createdFromMatrix;
+  }
+
+  /**
+   * {@code true} if this Bitmap was created with filtering.
+   *
+   * @return {@code true} if this Bitmap was created with filtering.
+   */
+  public boolean getCreatedFromFilter() {
+    return createdFromFilter;
+  }
+
+  @Implementation(minSdk = S)
+  public Bitmap asShared() {
+    setMutable(false);
+    return realBitmap;
+  }
+
+  @Implementation
+  protected boolean compress(Bitmap.CompressFormat format, int quality, OutputStream stream) {
+    appendDescription(" compressed as " + format + " with quality " + quality);
+    return ImageUtil.writeToStream(realBitmap, format, quality, stream);
+  }
+
+  @Implementation
+  protected void setPixels(
+      int[] pixels, int offset, int stride, int x, int y, int width, int height) {
+    checkBitmapMutable();
+    setPixelsInternal(pixels, offset, stride, x, y, width, height);
+  }
+
+  void setPixelsInternal(
+      int[] pixels, int offset, int stride, int x, int y, int width, int height) {
+    if (bufferedImage == null) {
+      bufferedImage = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB);
+    }
+    bufferedImage.setRGB(x, y, width, height, pixels, offset, stride);
+  }
+
+  @Implementation
+  protected int getPixel(int x, int y) {
+    internalCheckPixelAccess(x, y);
+    if (bufferedImage != null) {
+      // Note that getPixel() returns a non-premultiplied ARGB value; if
+      // config is RGB_565, our return value will likely be more precise than
+      // on a physical device, since it needs to map each color component from
+      // 5 or 6 bits to 8 bits.
+      return bufferedImage.getRGB(x, y);
+    } else {
+      return 0;
+    }
+  }
+
+  @Implementation
+  protected void setPixel(int x, int y, int color) {
+    checkBitmapMutable();
+    internalCheckPixelAccess(x, y);
+    if (bufferedImage == null) {
+      bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+    }
+    bufferedImage.setRGB(x, y, color);
+  }
+
+  /**
+   * Note that this method will return a RuntimeException unless: - {@code pixels} has the same
+   * length as the number of pixels of the bitmap. - {@code x = 0} - {@code y = 0} - {@code width}
+   * and {@code height} height match the current bitmap's dimensions.
+   */
+  @Implementation
+  protected void getPixels(
+      int[] pixels, int offset, int stride, int x, int y, int width, int height) {
+    bufferedImage.getRGB(x, y, width, height, pixels, offset, stride);
+  }
+
+  @Implementation
+  protected int getRowBytes() {
+    return getBytesPerPixel(config) * getWidth();
+  }
+
+  @Implementation
+  protected int getByteCount() {
+    return getRowBytes() * getHeight();
+  }
+
+  @Implementation
+  protected void recycle() {
+    recycled = true;
+  }
+
+  @Implementation
+  protected final boolean isRecycled() {
+    return recycled;
+  }
+
+  @Implementation
+  protected Bitmap copy(Bitmap.Config config, boolean isMutable) {
+    Bitmap newBitmap = ReflectionHelpers.callConstructor(Bitmap.class);
+    ShadowBitmap shadowBitmap = Shadow.extract(newBitmap);
+    shadowBitmap.createdFromBitmap = realBitmap;
+    shadowBitmap.config = config;
+    shadowBitmap.mutable = isMutable;
+    shadowBitmap.height = getHeight();
+    shadowBitmap.width = getWidth();
+    if (bufferedImage != null) {
+      ColorModel cm = bufferedImage.getColorModel();
+      WritableRaster raster =
+          bufferedImage.copyData(bufferedImage.getRaster().createCompatibleWritableRaster());
+      shadowBitmap.bufferedImage = new BufferedImage(cm, raster, false, null);
+    }
+    return newBitmap;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected final int getAllocationByteCount() {
+    return getRowBytes() * getHeight();
+  }
+
+  @Implementation
+  protected final Bitmap.Config getConfig() {
+    return config;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setConfig(Bitmap.Config config) {
+    this.config = config;
+  }
+
+  @Implementation
+  protected final boolean isMutable() {
+    return mutable;
+  }
+
+  public void setMutable(boolean mutable) {
+    this.mutable = mutable;
+  }
+
+  public void appendDescription(String s) {
+    description += s;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  public void setDescription(String s) {
+    description = s;
+  }
+
+  @Implementation
+  protected final boolean hasAlpha() {
+    return hasAlpha && config != Bitmap.Config.RGB_565;
+  }
+
+  @Implementation
+  protected void setHasAlpha(boolean hasAlpha) {
+    this.hasAlpha = hasAlpha;
+  }
+
+  @Implementation
+  protected Bitmap extractAlpha() {
+    WritableRaster raster = bufferedImage.getAlphaRaster();
+    BufferedImage alphaImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+    alphaImage.getAlphaRaster().setRect(raster);
+    return createBitmap(alphaImage, getWidth(), getHeight(), Bitmap.Config.ALPHA_8);
+  }
+
+  /**
+   * This shadow implementation ignores the given paint and offsetXY and simply calls {@link
+   * #extractAlpha()}.
+   */
+  @Implementation
+  protected Bitmap extractAlpha(Paint paint, int[] offsetXY) {
+    return extractAlpha();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected final boolean hasMipMap() {
+    return hasMipMap;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected final void setHasMipMap(boolean hasMipMap) {
+    this.hasMipMap = hasMipMap;
+  }
+
+  @Implementation
+  protected int getWidth() {
+    return width;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setWidth(int width) {
+    this.width = width;
+  }
+
+  @Implementation
+  protected int getHeight() {
+    return height;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setHeight(int height) {
+    this.height = height;
+  }
+
+  @Implementation
+  protected int getGenerationId() {
+    return 0;
+  }
+
+  @Implementation(minSdk = M)
+  protected Bitmap createAshmemBitmap() {
+    return realBitmap;
+  }
+
+  @Implementation
+  protected void eraseColor(int color) {
+    if (bufferedImage != null) {
+      int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+      Arrays.fill(pixels, color);
+    }
+    setDescription(String.format("Bitmap (%d, %d)", width, height));
+    if (color != 0) {
+      appendDescription(String.format(" erased with 0x%08x", color));
+    }
+  }
+
+  @Implementation
+  protected void writeToParcel(Parcel p, int flags) {
+    p.writeInt(width);
+    p.writeInt(height);
+    p.writeSerializable(config);
+    int[] pixels = new int[width * height];
+    getPixels(pixels, 0, width, 0, 0, width, height);
+    p.writeIntArray(pixels);
+  }
+
+  @Implementation
+  protected void copyPixelsFromBuffer(Buffer dst) {
+    if (isRecycled()) {
+      throw new IllegalStateException("Can't call copyPixelsFromBuffer() on a recycled bitmap");
+    }
+
+    // See the related comment in #copyPixelsToBuffer(Buffer).
+    if (getBytesPerPixel(config) != INTERNAL_BYTES_PER_PIXEL) {
+      throw new RuntimeException(
+          "Not implemented: only Bitmaps with "
+              + INTERNAL_BYTES_PER_PIXEL
+              + " bytes per pixel are supported");
+    }
+    if (!(dst instanceof ByteBuffer) && !(dst instanceof IntBuffer)) {
+      throw new RuntimeException("Not implemented: unsupported Buffer subclass");
+    }
+
+    ByteBuffer byteBuffer = null;
+    IntBuffer intBuffer;
+    if (dst instanceof IntBuffer) {
+      intBuffer = (IntBuffer) dst;
+    } else {
+      byteBuffer = (ByteBuffer) dst;
+      intBuffer = byteBuffer.asIntBuffer();
+    }
+
+    if (intBuffer.remaining() < (width * height)) {
+      throw new RuntimeException("Buffer not large enough for pixels");
+    }
+
+    int[] colors = new int[width * height];
+    intBuffer.get(colors);
+    if (byteBuffer != null) {
+      byteBuffer.position(byteBuffer.position() + intBuffer.position() * INTERNAL_BYTES_PER_PIXEL);
+    }
+    int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+    System.arraycopy(colors, 0, pixels, 0, pixels.length);
+  }
+
+  @Implementation
+  protected void copyPixelsToBuffer(Buffer dst) {
+    // Ensure that the Bitmap uses 4 bytes per pixel, since we always use 4 bytes per pixels
+    // internally. Clients of this API probably expect that the buffer size must be >=
+    // getByteCount(), but if we don't enforce this restriction then for RGB_4444 and other
+    // configs that value would be smaller then the buffer size we actually need.
+    if (getBytesPerPixel(config) != INTERNAL_BYTES_PER_PIXEL) {
+      throw new RuntimeException(
+          "Not implemented: only Bitmaps with "
+              + INTERNAL_BYTES_PER_PIXEL
+              + " bytes per pixel are supported");
+    }
+
+    if (!(dst instanceof ByteBuffer) && !(dst instanceof IntBuffer)) {
+      throw new RuntimeException("Not implemented: unsupported Buffer subclass");
+    }
+    int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+    if (dst instanceof ByteBuffer) {
+      IntBuffer intBuffer = ((ByteBuffer) dst).asIntBuffer();
+      intBuffer.put(pixels);
+      dst.position(intBuffer.position() * 4);
+    } else if (dst instanceof IntBuffer) {
+      ((IntBuffer) dst).put(pixels);
+    }
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void reconfigure(int width, int height, Bitmap.Config config) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this.config == Bitmap.Config.HARDWARE) {
+      throw new IllegalStateException("native-backed bitmaps may not be reconfigured");
+    }
+
+    // This should throw if the resulting allocation size is greater than the initial allocation
+    // size of our Bitmap, but we don't keep track of that information reliably, so we're forced to
+    // assume that our original dimensions and config are large enough to fit the new dimensions and
+    // config
+    this.width = width;
+    this.height = height;
+    this.config = config;
+    bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected boolean isPremultiplied() {
+    return requestPremultiplied && hasAlpha();
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setPremultiplied(boolean isPremultiplied) {
+    this.requestPremultiplied = isPremultiplied;
+  }
+
+  @Implementation(minSdk = O)
+  protected ColorSpace getColorSpace() {
+    return colorSpace;
+  }
+
+  @Implementation(minSdk = Q)
+  protected void setColorSpace(ColorSpace colorSpace) {
+    this.colorSpace = checkNotNull(colorSpace);
+  }
+
+  @Implementation
+  protected boolean sameAs(Bitmap other) {
+    if (other == null) {
+      return false;
+    }
+    ShadowBitmap shadowOtherBitmap = Shadow.extract(other);
+    if (this.width != shadowOtherBitmap.width || this.height != shadowOtherBitmap.height) {
+      return false;
+    }
+    if (this.config != shadowOtherBitmap.config) {
+      return false;
+    }
+
+    if (bufferedImage == null && shadowOtherBitmap.bufferedImage != null) {
+      return false;
+    } else if (bufferedImage != null && shadowOtherBitmap.bufferedImage == null) {
+      return false;
+    } else if (bufferedImage != null && shadowOtherBitmap.bufferedImage != null) {
+      int[] pixels = ((DataBufferInt) bufferedImage.getData().getDataBuffer()).getData();
+      int[] otherPixels =
+          ((DataBufferInt) shadowOtherBitmap.bufferedImage.getData().getDataBuffer()).getData();
+      if (!Arrays.equals(pixels, otherPixels)) {
+        return false;
+      }
+    }
+    // When Bitmap.createScaledBitmap is called, the colors array is cleared, so we need a basic
+    // way to detect if two scaled bitmaps are the same.
+    if (scaledFromBitmap != null && shadowOtherBitmap.scaledFromBitmap != null) {
+      return scaledFromBitmap.sameAs(shadowOtherBitmap.scaledFromBitmap);
+    }
+    return true;
+  }
+
+  public void setCreatedFromResId(int resId, String description) {
+    this.createdFromResId = resId;
+    appendDescription(" for resource:" + description);
+  }
+
+  private void checkBitmapMutable() {
+    if (isRecycled()) {
+      throw new IllegalStateException("Can't call setPixel() on a recycled bitmap");
+    } else if (!isMutable()) {
+      throw new IllegalStateException("Bitmap is immutable");
+    }
+  }
+
+  private void internalCheckPixelAccess(int x, int y) {
+    if (x < 0) {
+      throw new IllegalArgumentException("x must be >= 0");
+    }
+    if (y < 0) {
+      throw new IllegalArgumentException("y must be >= 0");
+    }
+    if (x >= getWidth()) {
+      throw new IllegalArgumentException("x must be < bitmap.width()");
+    }
+    if (y >= getHeight()) {
+      throw new IllegalArgumentException("y must be < bitmap.height()");
+    }
+  }
+
+  void drawRect(Rect r, Paint paint) {
+    if (bufferedImage == null) {
+      return;
+    }
+    int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+
+    Rect toDraw =
+        new Rect(
+            max(0, r.left), max(0, r.top), min(getWidth(), r.right), min(getHeight(), r.bottom));
+    if (toDraw.left == 0 && toDraw.top == 0 && toDraw.right == getWidth()) {
+      Arrays.fill(pixels, 0, getWidth() * toDraw.bottom, paint.getColor());
+      return;
+    }
+    for (int y = toDraw.top; y < toDraw.bottom; y++) {
+      Arrays.fill(
+          pixels, y * getWidth() + toDraw.left, y * getWidth() + toDraw.right, paint.getColor());
+    }
+  }
+
+  void drawRect(RectF r, Paint paint) {
+    if (bufferedImage == null) {
+      return;
+    }
+
+    Graphics2D graphics2D = bufferedImage.createGraphics();
+    Rectangle2D r2d = new Rectangle2D.Float(r.left, r.top, r.right - r.left, r.bottom - r.top);
+    graphics2D.setColor(new Color(paint.getColor()));
+    graphics2D.draw(r2d);
+    graphics2D.dispose();
+  }
+
+  void drawBitmap(Bitmap source, int left, int top) {
+    ShadowBitmap shadowSource = Shadows.shadowOf(source);
+    if (bufferedImage == null || shadowSource.bufferedImage == null) {
+      // pixel data not available, so there's nothing we can do
+      return;
+    }
+
+    int[] pixels = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
+    int[] sourcePixels =
+        ((DataBufferInt) shadowSource.bufferedImage.getRaster().getDataBuffer()).getData();
+
+    // fast path
+    if (left == 0 && top == 0 && getWidth() == source.getWidth()) {
+      int size = min(getWidth() * getHeight(), source.getWidth() * source.getHeight());
+      System.arraycopy(sourcePixels, 0, pixels, 0, size);
+      return;
+    }
+    // slower (row-by-row) path
+    int startSourceY = max(0, -top);
+    int startSourceX = max(0, -left);
+    int startY = max(0, top);
+    int startX = max(0, left);
+    int endY = min(getHeight(), top + source.getHeight());
+    int endX = min(getWidth(), left + source.getWidth());
+    int lenY = endY - startY;
+    int lenX = endX - startX;
+    for (int y = 0; y < lenY; y++) {
+      System.arraycopy(
+          sourcePixels,
+          (startSourceY + y) * source.getWidth() + startSourceX,
+          pixels,
+          (startY + y) * getWidth() + startX,
+          lenX);
+    }
+  }
+
+  BufferedImage getBufferedImage() {
+    return bufferedImage;
+  }
+
+  void setBufferedImage(BufferedImage bufferedImage) {
+    this.bufferedImage = bufferedImage;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapDrawable.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapDrawable.java
new file mode 100644
index 0000000..de499e9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapDrawable.java
@@ -0,0 +1,80 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(BitmapDrawable.class)
+public class ShadowBitmapDrawable extends ShadowDrawable {
+  String drawableCreateFromStreamSource;
+  String drawableCreateFromPath;
+
+  @RealObject private BitmapDrawable realBitmapDrawable;
+
+  /**
+   * Draws the contained bitmap onto the canvas at 0,0 with a default {@code Paint}
+   *
+   * @param canvas the canvas to draw on
+   */
+  @Implementation
+  protected void draw(Canvas canvas) {
+    if (ShadowView.useRealGraphics()) {
+      reflector(BitmapDrawableReflector.class, realBitmapDrawable).draw(canvas);
+    } else {
+      Bitmap bitmap = realBitmapDrawable.getBitmap();
+      if (bitmap == null) {
+        return;
+      }
+      canvas.drawBitmap(bitmap, 0, 0, realBitmapDrawable.getPaint());
+    }
+  }
+
+  @Override
+  protected void setCreatedFromResId(int createdFromResId, String resourceName) {
+    super.setCreatedFromResId(createdFromResId, resourceName);
+    Bitmap bitmap = realBitmapDrawable.getBitmap();
+    if (bitmap != null && Shadow.extract(bitmap) instanceof ShadowBitmap) {
+      ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+      if (shadowBitmap.createdFromResId == -1) {
+        shadowBitmap.setCreatedFromResId(createdFromResId, resourceName);
+      }
+    }
+  }
+
+  /**
+   * Returns the resource id that this {@code BitmapDrawable} was loaded from. This lets
+   * your tests assert that the bitmap is correct without having to actually load the bitmap.
+   *
+   * @return resource id from which this {@code BitmapDrawable} was loaded
+   * @deprecated use ShadowBitmap#getCreatedFromResId() instead.
+   */
+  @Deprecated
+  @Override
+  public int getCreatedFromResId() {
+    ShadowBitmap shadowBitmap = Shadow.extract(realBitmapDrawable.getBitmap());
+    return shadowBitmap.getCreatedFromResId();
+  }
+
+  public String getSource() {
+    return drawableCreateFromStreamSource;
+  }
+
+  public String getPath() {
+    return drawableCreateFromPath;
+  }
+
+  @ForType(BitmapDrawable.class)
+  interface BitmapDrawableReflector {
+    @Direct
+    void draw(Canvas canvas);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java
new file mode 100644
index 0000000..33bc8fd
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java
@@ -0,0 +1,417 @@
+package org.robolectric.shadows;
+
+import static java.lang.Math.round;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.shadows.ImageUtil.getImageFromStream;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetManager.AssetInputStream;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Build;
+import android.util.TypedValue;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ImageUtil.RobolectricBufferedImage;
+import org.robolectric.util.Join;
+import org.robolectric.util.Logger;
+import org.robolectric.util.NamedStream;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(BitmapFactory.class)
+public class ShadowBitmapFactory {
+  private static Map<String, Point> widthAndHeightMap = new HashMap<>();
+
+  // Determines whether BitmapFactory.decode methods should allow invalid bitmap data and always
+  // return a Bitmap object. Currently defaults to true to preserve legacy behavior. A
+  // forthcoming release will switch the default to false, which is consistent with real Android.
+  private static boolean allowInvalidImageData = true;
+
+  @Implementation
+  protected static Bitmap decodeResourceStream(
+      Resources res, TypedValue value, InputStream is, Rect pad, BitmapFactory.Options opts) {
+    Bitmap bitmap =
+        reflector(BitmapFactoryReflector.class).decodeResourceStream(res, value, is, pad, opts);
+
+    if (value != null && value.string != null && value.string.toString().contains(".9.")) {
+      // todo: better support for nine-patches
+      ReflectionHelpers.callInstanceMethod(
+          bitmap, "setNinePatchChunk", ClassParameter.from(byte[].class, new byte[0]));
+    }
+    return bitmap;
+  }
+
+  @Implementation
+  protected static Bitmap decodeResource(Resources res, int id, BitmapFactory.Options options) {
+    if (id == 0) {
+      return null;
+    }
+
+    final TypedValue value = new TypedValue();
+    InputStream is = res.openRawResource(id, value);
+
+    String resourceName = res.getResourceName(id);
+    RobolectricBufferedImage image = getImageFromStream(resourceName, is);
+    if (!allowInvalidImageData && image == null) {
+      if (options != null) {
+        options.outWidth = -1;
+        options.outHeight = -1;
+      }
+      return null;
+    }
+    Bitmap bitmap = create("resource:" + resourceName, options, image);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.createdFromResId = id;
+    return bitmap;
+  }
+
+  @Implementation
+  protected static Bitmap decodeFile(String pathName) {
+    return decodeFile(pathName, null);
+  }
+
+  @SuppressWarnings("Var")
+  @Implementation
+  protected static Bitmap decodeFile(String pathName, BitmapFactory.Options options) {
+    // If a real file is used, attempt to get the image size from that file.
+    RobolectricBufferedImage image = null;
+    if (pathName != null && new File(pathName).exists()) {
+      try (FileInputStream fileInputStream = new FileInputStream(pathName);
+          BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream)) {
+        image = getImageFromStream(pathName, bufferedInputStream);
+      } catch (IOException e) {
+        Logger.warn("Error getting size of bitmap file", e);
+      }
+    }
+    if (!allowInvalidImageData && image == null) {
+      if (options != null) {
+        options.outWidth = -1;
+        options.outHeight = -1;
+      }
+      return null;
+    }
+    Bitmap bitmap = create("file:" + pathName, options, image);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.createdFromPath = pathName;
+    return bitmap;
+  }
+
+  @SuppressWarnings({"ObjectToString", "Var"})
+  @Implementation
+  protected static Bitmap decodeFileDescriptor(
+      FileDescriptor fd, Rect outPadding, BitmapFactory.Options opts) {
+    RobolectricBufferedImage image = null;
+    // If a real FileDescriptor is used, attempt to get the image size.
+    if (fd != null && fd.valid()) {
+      try (FileInputStream fileInputStream = new FileInputStream(fd);
+          BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream); ) {
+        image = getImageFromStream(bufferedInputStream);
+      } catch (IOException e) {
+        Logger.warn("Error getting size of bitmap file", e);
+      }
+    }
+    if (!allowInvalidImageData && image == null) {
+      if (opts != null) {
+        opts.outWidth = -1;
+        opts.outHeight = -1;
+      }
+      return null;
+    }
+    Bitmap bitmap = create("fd:" + fd, null, outPadding, opts, null, image);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.createdFromFileDescriptor = fd;
+    return bitmap;
+  }
+
+  @Implementation
+  protected static Bitmap decodeStream(InputStream is) {
+    return decodeStream(is, null, null);
+  }
+
+  @Implementation
+  protected static Bitmap decodeStream(
+      InputStream is, Rect outPadding, BitmapFactory.Options opts) {
+    byte[] ninePatchChunk = null;
+
+    if (is instanceof AssetInputStream) {
+      ShadowAssetInputStream sais = Shadow.extract(is);
+      if (sais.isNinePatch()) {
+        ninePatchChunk = new byte[0];
+      }
+      if (sais.getDelegate() != null) {
+        is = sais.getDelegate();
+      }
+    }
+
+    try {
+      if (is != null) {
+        is.reset();
+      }
+    } catch (IOException e) {
+      // ignore
+    }
+
+    boolean isNamedStream = is instanceof NamedStream;
+    String name = isNamedStream ? is.toString().replace("stream for ", "") : null;
+    RobolectricBufferedImage image = isNamedStream ? null : getImageFromStream(is);
+    if (!allowInvalidImageData && image == null) {
+      if (opts != null) {
+        opts.outWidth = -1;
+        opts.outHeight = -1;
+      }
+      return null;
+    }
+    Bitmap bitmap = create(name, null, outPadding, opts, null, image);
+    ReflectionHelpers.callInstanceMethod(
+        bitmap, "setNinePatchChunk", ClassParameter.from(byte[].class, ninePatchChunk));
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.createdFromStream = is;
+
+    if (image != null && opts != null) {
+      opts.outMimeType = image.getMimeType();
+    }
+    return bitmap;
+  }
+
+  @Implementation
+  protected static Bitmap decodeByteArray(byte[] data, int offset, int length) {
+    return decodeByteArray(data, offset, length, new BitmapFactory.Options());
+  }
+
+  @Implementation
+  protected static Bitmap decodeByteArray(
+      byte[] data, int offset, int length, BitmapFactory.Options opts) {
+    String desc = data.length + " bytes";
+
+    if (offset != 0 || length != data.length) {
+      desc += " " + offset + ".." + length;
+    }
+
+    ByteArrayInputStream is = new ByteArrayInputStream(data, offset, length);
+    RobolectricBufferedImage image = getImageFromStream(is);
+    if (!allowInvalidImageData && image == null) {
+      if (opts != null) {
+        opts.outWidth = -1;
+        opts.outHeight = -1;
+      }
+      return null;
+    }
+    Bitmap bitmap = create(desc, data, null, opts, null, image);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.createdFromBytes = data;
+    return bitmap;
+  }
+
+  static Bitmap create(
+      final String name,
+      final BitmapFactory.Options options,
+      final RobolectricBufferedImage image) {
+    return create(name, null, null, options, null, image);
+  }
+
+  private static Bitmap create(
+      final String name,
+      byte[] bytes,
+      final Rect outPadding,
+      final BitmapFactory.Options options,
+      final Point widthAndHeightOverride,
+      final RobolectricBufferedImage image) {
+    Bitmap bitmap = Shadow.newInstanceOf(Bitmap.class);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.appendDescription(name == null ? "Bitmap" : "Bitmap for " + name);
+
+    Bitmap.Config config;
+    if (options != null && options.inPreferredConfig != null) {
+      config = options.inPreferredConfig;
+    } else {
+      config = Bitmap.Config.ARGB_8888;
+    }
+    shadowBitmap.setConfig(config);
+
+    String optionsString = stringify(options);
+    if (!optionsString.isEmpty()) {
+      shadowBitmap.appendDescription(" with options ");
+      shadowBitmap.appendDescription(optionsString);
+    }
+
+    Point p = new Point(selectWidthAndHeight(name, bytes, widthAndHeightOverride, image));
+    if (options != null && options.inSampleSize > 1) {
+      p.x = p.x / options.inSampleSize;
+      p.y = p.y / options.inSampleSize;
+
+      p.x = p.x == 0 ? 1 : p.x;
+      p.y = p.y == 0 ? 1 : p.y;
+    }
+
+    // Prior to KitKat the density scale will be applied by finishDecode below.
+    float scale =
+        RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.KITKAT
+                && options != null
+                && options.inScaled
+                && options.inDensity != 0
+                && options.inTargetDensity != 0
+                && options.inDensity != options.inScreenDensity
+            ? (float) options.inTargetDensity / options.inDensity
+            : 1;
+    int scaledWidth = round(p.x * scale);
+    int scaledHeight = round(p.y * scale);
+
+    shadowBitmap.setWidth(scaledWidth);
+    shadowBitmap.setHeight(scaledHeight);
+    if (image != null) {
+      BufferedImage bufferedImage =
+          new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_ARGB);
+      // Copy the image as TYPE_INT_ARGB for fast comparison (sameAs).
+      Graphics2D g = bufferedImage.createGraphics();
+      g.drawImage(image.getBufferedImage(), 0, 0, null);
+      g.dispose();
+      shadowBitmap.setBufferedImage(bufferedImage);
+    } else {
+      shadowBitmap.setPixelsInternal(
+          new int[scaledWidth * scaledHeight], 0, 0, 0, 0, scaledWidth, scaledHeight);
+    }
+    if (options != null) {
+      options.outWidth = p.x;
+      options.outHeight = p.y;
+      shadowBitmap.setMutable(options.inMutable);
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.KITKAT) {
+      ReflectionHelpers.callStaticMethod(
+          BitmapFactory.class,
+          "setDensityFromOptions",
+          ClassParameter.from(Bitmap.class, bitmap),
+          ClassParameter.from(BitmapFactory.Options.class, options));
+    } else {
+      bitmap =
+          ReflectionHelpers.callStaticMethod(
+              BitmapFactory.class,
+              "finishDecode",
+              ClassParameter.from(Bitmap.class, bitmap),
+              ClassParameter.from(Rect.class, outPadding),
+              ClassParameter.from(BitmapFactory.Options.class, options));
+    }
+    return bitmap;
+  }
+
+  /**
+   * @deprecated Use any of the BitmapFactory.decode methods with real image data.
+   */
+  @Deprecated
+  public static void provideWidthAndHeightHints(Uri uri, int width, int height) {
+    widthAndHeightMap.put(uri.toString(), new Point(width, height));
+  }
+
+  /**
+   * @deprecated Use any of the BitmapFactory.decode methods with real image data.
+   */
+  @Deprecated
+  public static void provideWidthAndHeightHints(int resourceId, int width, int height) {
+    widthAndHeightMap.put(
+        "resource:"
+            + RuntimeEnvironment.getApplication().getResources().getResourceName(resourceId),
+        new Point(width, height));
+  }
+
+  /**
+   * @deprecated Use any of the BitmapFactory.decode methods with real image data.
+   */
+  @Deprecated
+  public static void provideWidthAndHeightHints(String file, int width, int height) {
+    widthAndHeightMap.put("file:" + file, new Point(width, height));
+  }
+
+  /**
+   * @deprecated Use any of the BitmapFactory.decode methods with real image data.
+   */
+  @Deprecated
+  @SuppressWarnings("ObjectToString")
+  public static void provideWidthAndHeightHints(FileDescriptor fd, int width, int height) {
+    widthAndHeightMap.put("fd:" + fd, new Point(width, height));
+  }
+
+  private static String stringify(BitmapFactory.Options options) {
+    if (options == null) return "";
+    List<String> opts = new ArrayList<>();
+
+    if (options.inJustDecodeBounds) opts.add("inJustDecodeBounds");
+    if (options.inSampleSize > 1) opts.add("inSampleSize=" + options.inSampleSize);
+
+    return Join.join(", ", opts);
+  }
+
+  @Resetter
+  public static void reset() {
+    widthAndHeightMap.clear();
+    allowInvalidImageData = true;
+  }
+
+  private static Point selectWidthAndHeight(
+      final String name,
+      byte[] bytes,
+      final Point widthAndHeightOverride,
+      final RobolectricBufferedImage robolectricBufferedImage) {
+    if (!widthAndHeightMap.isEmpty()) {
+      String sizeKey = bytes == null ? name : new String(bytes, UTF_8);
+      final Point widthAndHeightFromMap = widthAndHeightMap.get(sizeKey);
+      if (widthAndHeightFromMap != null) {
+        return widthAndHeightFromMap;
+      }
+    }
+    if (robolectricBufferedImage != null) {
+      return robolectricBufferedImage.getWidthAndHeight();
+    }
+    if (widthAndHeightOverride != null) {
+      return widthAndHeightOverride;
+    }
+    return new Point(100, 100);
+  }
+
+  /**
+   * Whether the BitmapFactory.decode methods, such as {@link
+   * BitmapFactory#decodeStream(InputStream, Rect, Options)} should allow invalid image data and
+   * always return Bitmap objects. If set to false, BitmapFactory.decode methods will be consistent
+   * with real Android, and return null Bitmap values and set {@link BitmapFactory.Options#outWidth}
+   * and {@link BitmapFactory.Options#outHeight} to -1.
+   *
+   * @param allowInvalidImageData whether invalid bitmap data is allowed and BitmapFactory should
+   *     always return Bitmap objects.
+   */
+  public static void setAllowInvalidImageData(boolean allowInvalidImageData) {
+    ShadowBitmapFactory.allowInvalidImageData = allowInvalidImageData;
+  }
+
+  @ForType(BitmapFactory.class)
+  interface BitmapFactoryReflector {
+
+    @Static
+    @Direct
+    Bitmap decodeResourceStream(
+        Resources res, TypedValue value, InputStream is, Rect pad, BitmapFactory.Options opts);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapRegionDecoder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapRegionDecoder.java
new file mode 100644
index 0000000..82f6c79
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapRegionDecoder.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Point;
+import android.graphics.Rect;
+import java.io.ByteArrayInputStream;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(BitmapRegionDecoder.class)
+public class ShadowBitmapRegionDecoder {
+  private int width;
+  private int height;
+
+  @Implementation
+  protected static BitmapRegionDecoder newInstance(
+      byte[] data, int offset, int length, boolean isShareable) throws IOException {
+    return fillWidthAndHeight(newInstance(), new ByteArrayInputStream(data));
+  }
+
+  @Implementation
+  protected static BitmapRegionDecoder newInstance(FileDescriptor fd, boolean isShareable)
+      throws IOException {
+    return fillWidthAndHeight(newInstance(), new FileInputStream(fd));
+  }
+
+  @Implementation
+  protected static BitmapRegionDecoder newInstance(InputStream is, boolean isShareable)
+      throws IOException {
+    return fillWidthAndHeight(newInstance(), is);
+  }
+
+  @Implementation
+  protected static BitmapRegionDecoder newInstance(String pathName, boolean isShareable)
+      throws IOException {
+    return fillWidthAndHeight(newInstance(), new FileInputStream(pathName));
+  }
+
+  private static BitmapRegionDecoder fillWidthAndHeight(BitmapRegionDecoder bitmapRegionDecoder, InputStream is) {
+    ShadowBitmapRegionDecoder shadowDecoder = Shadow.extract(bitmapRegionDecoder);
+    Point imageSize = ImageUtil.getImageSizeFromStream(is);
+    if (imageSize != null) {
+      shadowDecoder.width = imageSize.x;
+      shadowDecoder.height = imageSize.y;
+    }
+    return bitmapRegionDecoder;
+  }
+
+  @Implementation
+  protected int getWidth() {
+    return width;
+  }
+
+  @Implementation
+  protected int getHeight() {
+    return height;
+  }
+
+  @Implementation
+  protected Bitmap decodeRegion(Rect rect, BitmapFactory.Options options) {
+    return Bitmap.createBitmap(rect.width(), rect.height(),
+        options.inPreferredConfig != null ? options.inPreferredConfig : Bitmap.Config.ARGB_8888);
+  }
+
+  private static BitmapRegionDecoder newInstance() {
+    if (getApiLevel() >= LOLLIPOP) {
+      return ReflectionHelpers.callConstructor(BitmapRegionDecoder.class,
+          new ReflectionHelpers.ClassParameter<>(long.class, 0L));
+    } else {
+      return ReflectionHelpers.callConstructor(BitmapRegionDecoder.class,
+          new ReflectionHelpers.ClassParameter<>(int.class, 0));
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBlockGuardOs.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBlockGuardOs.java
new file mode 100644
index 0000000..8cec641
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBlockGuardOs.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import android.system.ErrnoException;
+import java.io.FileDescriptor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(className = "libcore.io.BlockGuardOs", isInAndroidSdk = false)
+public class ShadowBlockGuardOs {
+  // override to avoid call to non-existent FileDescriptor.isSocket
+  @Implementation
+  protected void close(FileDescriptor fd) throws ErrnoException {
+    // ignore
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothA2dp.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothA2dp.java
new file mode 100644
index 0000000..101fee7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothA2dp.java
@@ -0,0 +1,132 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothCodecConfig;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Intent;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link BluetoothA2dp}. */
+@Implements(BluetoothA2dp.class)
+public class ShadowBluetoothA2dp {
+  private final Map<BluetoothDevice, Integer> bluetoothDevices = new HashMap<>();
+  private int dynamicBufferSupportType = BluetoothA2dp.DYNAMIC_BUFFER_SUPPORT_NONE;
+  private final int[] bufferLengthMillisArray = new int[6];
+  private BluetoothDevice activeBluetoothDevice;
+
+  /* Adds the given bluetoothDevice with connectionState to the list of devices
+   * returned by {@link ShadowBluetoothA2dp#getConnectedDevices} and
+   * {@link ShadowBluetoothA2dp#getDevicesMatchingConnectionStates}
+   */
+  public void addDevice(BluetoothDevice bluetoothDevice, int connectionState) {
+    bluetoothDevices.put(bluetoothDevice, connectionState);
+  }
+
+  /* Removes the given bluetoothDevice from the list of devices
+   * returned by {@link ShadowBluetoothA2dp#getConnectedDevices} and
+   * {@link ShadowBluetoothA2dp#getDevicesMatchingConnectionStates}
+   */
+  public void removeDevice(BluetoothDevice bluetoothDevice) {
+    bluetoothDevices.remove(bluetoothDevice);
+  }
+
+  /*
+   * Overrides behavior of {@link getConnectedDevices}. Returns an immutable list of bluetooth
+   * devices that is set up by call(s) to {@link ShadowBluetoothA2dp#addDevice} and
+   * {@link ShadowBluetoothA2dp#removeDevice} with connectionState equals
+   * {@code BluetoothProfile.STATE_CONNECTED}. Returns an empty list by default.
+   */
+  @Implementation
+  protected List<BluetoothDevice> getConnectedDevices() {
+    return getDevicesMatchingConnectionStates(new int[] {BluetoothProfile.STATE_CONNECTED});
+  }
+
+  /*
+   * Overrides behavior of {@link getDevicesMatchingConnectionStates}. Returns an immutable list
+   * of bluetooth devices that is set up by call(s) to {@link ShadowBluetoothA2dp#addDevice} and
+   * {@link ShadowBluetoothA2dp#removeDevice} with connectionState matching any of the
+   * {@code states}. Returns an empty list by default.
+   */
+  @Implementation
+  protected List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states) {
+    List<BluetoothDevice> deviceList = new ArrayList<>();
+    for (Map.Entry<BluetoothDevice, Integer> entry : bluetoothDevices.entrySet()) {
+      for (int state : states) {
+        if (entry.getValue() == state) {
+          deviceList.add(entry.getKey());
+        }
+      }
+    }
+    return ImmutableList.copyOf(deviceList);
+  }
+
+  /*
+   * Overrides behavior of {@link getConnectionState}. Returns the connection state
+   * of {@code device} if present in the list of devices controlled by
+   * {@link ShadowBluetoothA2dp#addDevice} and {@link ShadowBluetoothA2dp#removeDevice}.
+   * Returns {@code BluetoothProfile.STATE_DISCONNECTED} if device not found.
+   */
+  @Implementation
+  protected int getConnectionState(BluetoothDevice device) {
+    return bluetoothDevices.containsKey(device)
+        ? bluetoothDevices.get(device)
+        : BluetoothProfile.STATE_DISCONNECTED;
+  }
+
+  /*
+   * Sets {@link @BluetoothA2dp.Type} which will return by {@link getDynamicBufferSupport).
+   */
+  public void setDynamicBufferSupport(@BluetoothA2dp.Type int type) {
+    this.dynamicBufferSupportType = type;
+  }
+
+  @Implementation(minSdk = S)
+  @BluetoothA2dp.Type
+  protected int getDynamicBufferSupport() {
+    return dynamicBufferSupportType;
+  }
+
+  @Implementation(minSdk = S)
+  protected boolean setBufferLengthMillis(
+      @BluetoothCodecConfig.SourceCodecType int codec, int value) {
+    if (codec >= bufferLengthMillisArray.length || codec < 0 || value < 0) {
+      return false;
+    }
+    bufferLengthMillisArray[codec] = value;
+    return true;
+  }
+
+  /*
+   * Gets the buffer length with given codec type which set by #setBufferLengthMillis.
+   */
+  public int getBufferLengthMillis(@BluetoothCodecConfig.SourceCodecType int codec) {
+    return bufferLengthMillisArray[codec];
+  }
+
+  @Nullable
+  @Implementation(minSdk = P)
+  protected BluetoothDevice getActiveDevice() {
+    return activeBluetoothDevice;
+  }
+
+  @Implementation(minSdk = P)
+  protected boolean setActiveDevice(@Nullable BluetoothDevice bluetoothDevice) {
+    activeBluetoothDevice = bluetoothDevice;
+    Intent intent = new Intent(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED);
+    intent.putExtra(BluetoothDevice.EXTRA_DEVICE, activeBluetoothDevice);
+    RuntimeEnvironment.getApplication().sendBroadcast(intent);
+    return true;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothAdapter.java
new file mode 100644
index 0000000..8634585
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothAdapter.java
@@ -0,0 +1,722 @@
+package org.robolectric.shadows;
+
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothAdapter.LeScanCallback;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.bluetooth.BluetoothStatusCodes;
+import android.bluetooth.IBluetoothManager;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.content.AttributionSource;
+import android.content.Context;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelUuid;
+import android.provider.Settings;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = BluetoothAdapter.class, looseSignatures = true)
+public class ShadowBluetoothAdapter {
+  @RealObject private BluetoothAdapter realAdapter;
+
+  private static final int ADDRESS_LENGTH = 17;
+  private static final int LE_MAXIMUM_ADVERTISING_DATA_LENGTH = 31;
+  private static final int LE_MAXIMUM_ADVERTISING_DATA_LENGTH_EXTENDED = 1650;
+
+  /**
+   * Equivalent value to internal SystemApi {@link
+   * BluetoothStatusCodes#RFCOMM_LISTENER_START_FAILED_UUID_IN_USE}.
+   */
+  public static final int RFCOMM_LISTENER_START_FAILED_UUID_IN_USE = 2000;
+
+  /**
+   * Equivalent value to internal SystemApi {@link
+   * BluetoothStatusCodes#RFCOMM_LISTENER_OPERATION_FAILED_NO_MATCHING_SERVICE_RECORD}.
+   */
+  public static final int RFCOMM_LISTENER_OPERATION_FAILED_NO_MATCHING_SERVICE_RECORD = 2001;
+
+  /**
+   * Equivalent value to internal SystemApi {@link
+   * BluetoothStatusCodes#RFCOMM_LISTENER_FAILED_TO_CLOSE_SERVER_SOCKET}.
+   */
+  public static final int RFCOMM_LISTENER_FAILED_TO_CLOSE_SERVER_SOCKET = 2004;
+
+  private static boolean isBluetoothSupported = true;
+
+  private static final Map<String, BluetoothDevice> deviceCache = new HashMap<>();
+  private Set<BluetoothDevice> bondedDevices = new HashSet<BluetoothDevice>();
+  private Set<LeScanCallback> leScanCallbacks = new HashSet<LeScanCallback>();
+  private boolean isDiscovering;
+  private String address;
+  private int state;
+  private String name = "DefaultBluetoothDeviceName";
+  private int scanMode = BluetoothAdapter.SCAN_MODE_NONE;
+  private Duration discoverableTimeout;
+  private boolean isBleScanAlwaysAvailable = true;
+  private boolean isMultipleAdvertisementSupported = true;
+  private boolean isLeExtendedAdvertisingSupported = true;
+  private boolean isOverridingProxyBehavior;
+  private final Map<Integer, Integer> profileConnectionStateData = new HashMap<>();
+  private final Map<Integer, BluetoothProfile> profileProxies = new HashMap<>();
+  private final ConcurrentMap<UUID, BackgroundRfcommServerEntry> backgroundRfcommServers =
+      new ConcurrentHashMap<>();
+
+  @Resetter
+  public static void reset() {
+    setIsBluetoothSupported(true);
+    BluetoothAdapterReflector bluetoothReflector = reflector(BluetoothAdapterReflector.class);
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel >= VERSION_CODES.LOLLIPOP && apiLevel <= VERSION_CODES.R) {
+      bluetoothReflector.setSBluetoothLeAdvertiser(null);
+      bluetoothReflector.setSBluetoothLeScanner(null);
+    }
+    bluetoothReflector.setAdapter(null);
+    deviceCache.clear();
+  }
+
+  @Implementation
+  protected static BluetoothAdapter getDefaultAdapter() {
+    if (!isBluetoothSupported) {
+      return null;
+    }
+    return reflector(BluetoothAdapterReflector.class).getDefaultAdapter();
+  }
+
+  /** Requires LooseSignatures because of {@link AttributionSource} parameter */
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected static Object createAdapter(Object attributionSource) {
+    IBluetoothManager service = ReflectionHelpers.createNullProxy(IBluetoothManager.class);
+    return ReflectionHelpers.callConstructor(
+        BluetoothAdapter.class,
+        ClassParameter.from(IBluetoothManager.class, service),
+        ClassParameter.from(AttributionSource.class, attributionSource));
+  }
+
+  /** Determines if getDefaultAdapter() returns the default local adapter (true) or null (false). */
+  public static void setIsBluetoothSupported(boolean supported) {
+    isBluetoothSupported = supported;
+  }
+
+  /** @deprecated use real BluetoothLeAdvertiser instead */
+  @Deprecated
+  public void setBluetoothLeAdvertiser(BluetoothLeAdvertiser advertiser) {
+    if (RuntimeEnvironment.getApiLevel() <= VERSION_CODES.LOLLIPOP_MR1) {
+      reflector(BluetoothAdapterReflector.class, realAdapter).setSBluetoothLeAdvertiser(advertiser);
+    } else {
+      reflector(BluetoothAdapterReflector.class, realAdapter).setBluetoothLeAdvertiser(advertiser);
+    }
+  }
+
+  @Implementation
+  protected synchronized BluetoothDevice getRemoteDevice(String address) {
+    if (!deviceCache.containsKey(address)) {
+      deviceCache.put(
+          address,
+          reflector(BluetoothAdapterReflector.class, realAdapter).getRemoteDevice(address));
+    }
+    return deviceCache.get(address);
+  }
+
+  @Implementation
+  protected Set<BluetoothDevice> getBondedDevices() {
+    return Collections.unmodifiableSet(bondedDevices);
+  }
+
+  public void setBondedDevices(Set<BluetoothDevice> bluetoothDevices) {
+    bondedDevices = bluetoothDevices;
+  }
+
+  @Implementation
+  protected BluetoothServerSocket listenUsingInsecureRfcommWithServiceRecord(
+      String serviceName, UUID uuid) {
+    return ShadowBluetoothServerSocket.newInstance(
+        BluetoothSocket.TYPE_RFCOMM, /*auth=*/ false, /*encrypt=*/ false, new ParcelUuid(uuid));
+  }
+
+  @Implementation
+  protected BluetoothServerSocket listenUsingRfcommWithServiceRecord(String serviceName, UUID uuid)
+      throws IOException {
+    return ShadowBluetoothServerSocket.newInstance(
+        BluetoothSocket.TYPE_RFCOMM, /*auth=*/ false, /*encrypt=*/ true, new ParcelUuid(uuid));
+  }
+
+  @Implementation(minSdk = Q)
+  protected BluetoothServerSocket listenUsingInsecureL2capChannel() throws IOException {
+    return ShadowBluetoothServerSocket.newInstance(
+        BluetoothSocket.TYPE_L2CAP, /*auth=*/ false, /*encrypt=*/ true, /*uuid=*/ null);
+  }
+
+  @Implementation(minSdk = Q)
+  protected BluetoothServerSocket listenUsingL2capChannel() throws IOException {
+    return ShadowBluetoothServerSocket.newInstance(
+        BluetoothSocket.TYPE_L2CAP, /*auth=*/ false, /*encrypt=*/ true, /*uuid=*/ null);
+  }
+
+  @Implementation
+  protected boolean startDiscovery() {
+    isDiscovering = true;
+    return true;
+  }
+
+  @Implementation
+  protected boolean cancelDiscovery() {
+    isDiscovering = false;
+    return true;
+  }
+
+  /** When true, overrides the value of {@link #getLeState}. By default, this is false. */
+  @Implementation(minSdk = M)
+  protected boolean isBleScanAlwaysAvailable() {
+    return isBleScanAlwaysAvailable;
+  }
+
+  /**
+   * Decides the correct LE state. When off, BLE calls will fail or return null.
+   *
+   * <p>LE is enabled if either Bluetooth or BLE scans are enabled. LE is always off if Airplane
+   * Mode is enabled.
+   */
+  @Implementation(minSdk = M)
+  public int getLeState() {
+    if (isAirplaneMode()) {
+      return BluetoothAdapter.STATE_OFF;
+    }
+
+    if (isEnabled()) {
+      return BluetoothAdapter.STATE_ON;
+    }
+
+    if (isBleScanAlwaysAvailable()) {
+      return BluetoothAdapter.STATE_BLE_ON;
+    }
+
+    return BluetoothAdapter.STATE_OFF;
+  }
+
+  /**
+   * True if either Bluetooth is enabled or BLE scanning is available. Always false if Airplane Mode
+   * is enabled. When false, BLE scans will fail. @Implementation(minSdk = M) protected boolean
+   * isLeEnabled() { if (isAirplaneMode()) { return false; } return isEnabled() ||
+   * isBleScanAlwaysAvailable(); }
+   */
+  private static boolean isAirplaneMode() {
+    Context context = RuntimeEnvironment.getApplication();
+    return Settings.Global.getInt(context.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0)
+        != 0;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean startLeScan(LeScanCallback callback) {
+    return startLeScan(null, callback);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean startLeScan(UUID[] serviceUuids, LeScanCallback callback) {
+    if (Build.VERSION.SDK_INT >= M && !realAdapter.isLeEnabled()) {
+      return false;
+    }
+
+    // Ignoring the serviceUuids param for now.
+    leScanCallbacks.add(callback);
+    return true;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected void stopLeScan(LeScanCallback callback) {
+    leScanCallbacks.remove(callback);
+  }
+
+  public Set<LeScanCallback> getLeScanCallbacks() {
+    return Collections.unmodifiableSet(leScanCallbacks);
+  }
+
+  public LeScanCallback getSingleLeScanCallback() {
+    if (leScanCallbacks.size() != 1) {
+      throw new IllegalStateException("There are " + leScanCallbacks.size() + " callbacks");
+    }
+    return leScanCallbacks.iterator().next();
+  }
+
+  @Implementation
+  protected boolean isDiscovering() {
+    return isDiscovering;
+  }
+
+  @Implementation
+  protected boolean isEnabled() {
+    return state == BluetoothAdapter.STATE_ON;
+  }
+
+  @Implementation
+  protected boolean enable() {
+    setState(BluetoothAdapter.STATE_ON);
+    return true;
+  }
+
+  @Implementation
+  protected boolean disable() {
+    setState(BluetoothAdapter.STATE_OFF);
+    return true;
+  }
+
+  @Implementation
+  protected String getAddress() {
+    return this.address;
+  }
+
+  @Implementation
+  protected int getState() {
+    return state;
+  }
+
+  @Implementation
+  protected String getName() {
+    return name;
+  }
+
+  @Implementation
+  protected boolean setName(String name) {
+    this.name = name;
+    return true;
+  }
+
+  /**
+   * Needs looseSignatures because in Android T the return value of this method was changed from
+   * bool to int.
+   */
+  @Implementation
+  protected Object setScanMode(int scanMode) {
+    boolean result = true;
+    if (scanMode != BluetoothAdapter.SCAN_MODE_CONNECTABLE
+        && scanMode != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE
+        && scanMode != BluetoothAdapter.SCAN_MODE_NONE) {
+      result = false;
+    }
+
+    this.scanMode = scanMode;
+    if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.TIRAMISU) {
+      return result ? BluetoothStatusCodes.SUCCESS : BluetoothStatusCodes.ERROR_UNKNOWN;
+    } else {
+      return result;
+    }
+  }
+
+  @Implementation(maxSdk = Q)
+  protected boolean setScanMode(int scanMode, int discoverableTimeout) {
+    setDiscoverableTimeout(discoverableTimeout);
+    return (boolean) setScanMode(scanMode);
+  }
+
+  @Implementation(minSdk = R, maxSdk = S_V2)
+  protected boolean setScanMode(int scanMode, long durationMillis) {
+    int durationSeconds = Math.toIntExact(durationMillis / 1000);
+    setDiscoverableTimeout(durationSeconds);
+    return (boolean) setScanMode(scanMode);
+  }
+
+  @Implementation
+  protected int getScanMode() {
+    return scanMode;
+  }
+
+  /**
+   * Needs looseSignatures because the return value changed from {@code int} to {@link Duration}
+   * starting in T.
+   */
+  @Implementation
+  protected Object getDiscoverableTimeout() {
+    if (RuntimeEnvironment.getApiLevel() <= S_V2) {
+      return (int) discoverableTimeout.toSeconds();
+    } else {
+      return discoverableTimeout;
+    }
+  }
+
+  @Implementation(maxSdk = S_V2)
+  protected void setDiscoverableTimeout(int timeout) {
+    discoverableTimeout = Duration.ofSeconds(timeout);
+  }
+
+  @Implementation(minSdk = 33)
+  protected int setDiscoverableTimeout(Duration timeout) {
+    if (getState() != STATE_ON) {
+      return BluetoothStatusCodes.ERROR_BLUETOOTH_NOT_ENABLED;
+    }
+    if (timeout.toSeconds() > Integer.MAX_VALUE) {
+      throw new IllegalArgumentException(
+          "Timeout in seconds must be less or equal to " + Integer.MAX_VALUE);
+    }
+    this.discoverableTimeout = timeout;
+    return BluetoothStatusCodes.SUCCESS;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isMultipleAdvertisementSupported() {
+    return isMultipleAdvertisementSupported;
+  }
+
+  /**
+   * Validate a Bluetooth address, such as "00:43:A8:23:10:F0" Alphabetic characters must be
+   * uppercase to be valid.
+   *
+   * @param address Bluetooth address as string
+   * @return true if the address is valid, false otherwise
+   */
+  @Implementation
+  protected static boolean checkBluetoothAddress(String address) {
+    if (address == null || address.length() != ADDRESS_LENGTH) {
+      return false;
+    }
+    for (int i = 0; i < ADDRESS_LENGTH; i++) {
+      char c = address.charAt(i);
+      switch (i % 3) {
+        case 0:
+        case 1:
+          if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) {
+            // hex character, OK
+            break;
+          }
+          return false;
+        case 2:
+          if (c == ':') {
+            break; // OK
+          }
+          return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Returns the connection state for the given Bluetooth {@code profile}, defaulting to {@link
+   * BluetoothProfile.STATE_DISCONNECTED} if the profile's connection state was never set.
+   *
+   * <p>Set a Bluetooth profile's connection state via {@link #setProfileConnectionState(int, int)}.
+   */
+  @Implementation
+  protected int getProfileConnectionState(int profile) {
+    Integer state = profileConnectionStateData.get(profile);
+    if (state == null) {
+      return BluetoothProfile.STATE_DISCONNECTED;
+    }
+    return state;
+  }
+
+  public void setAddress(String address) {
+    this.address = address;
+  }
+
+  public void setState(int state) {
+    this.state = state;
+  }
+
+  /** @deprecated Use {@link BluetoothAdapter#enable()} or {@link BluetoothAdapter#disable()}. */
+  @Deprecated
+  public void setEnabled(boolean enabled) {
+    if (enabled) {
+      enable();
+    } else {
+      disable();
+    }
+  }
+
+  /**
+   * Sets the value for {@link isBleScanAlwaysAvailable}. If true, {@link getLeState} will always
+   * return true.
+   */
+  public void setBleScanAlwaysAvailable(boolean alwaysAvailable) {
+    isBleScanAlwaysAvailable = alwaysAvailable;
+  }
+
+  /** Sets the value for {@link isMultipleAdvertisementSupported}. */
+  public void setIsMultipleAdvertisementSupported(boolean supported) {
+    isMultipleAdvertisementSupported = supported;
+  }
+
+  /** Sets the connection state {@code state} for the given BluetoothProfile {@code profile} */
+  public void setProfileConnectionState(int profile, int state) {
+    profileConnectionStateData.put(profile, state);
+  }
+
+  /**
+   * Sets the active BluetoothProfile {@code proxy} for the given {@code profile}. Will always
+   * affect behavior of {@link BluetoothAdapter#getProfileProxy} and {@link
+   * BluetoothAdapter#closeProfileProxy}. Call to {@link BluetoothAdapter#closeProfileProxy} can
+   * remove the set active proxy.
+   *
+   * @param proxy can be 'null' to simulate the situation where {@link
+   *     BluetoothAdapter#getProfileProxy} would return 'false'. This can happen on older Android
+   *     versions for Bluetooth profiles introduced in later Android versions.
+   */
+  public void setProfileProxy(int profile, @Nullable BluetoothProfile proxy) {
+    isOverridingProxyBehavior = true;
+    if (proxy != null) {
+      profileProxies.put(profile, proxy);
+    }
+  }
+
+  /**
+   * @return 'true' if active (non-null) proxy has been set by {@link
+   *     ShadowBluetoothAdapter#setProfileProxy} for the given {@code profile} AND it has not been
+   *     "deactivated" by a call to {@link BluetoothAdapter#closeProfileProxy}. Only meaningful if
+   *     {@link ShadowBluetoothAdapter#setProfileProxy} has been previously called.
+   */
+  public boolean hasActiveProfileProxy(int profile) {
+    return profileProxies.get(profile) != null;
+  }
+
+  /**
+   * Overrides behavior of {@link getProfileProxy} if {@link ShadowBluetoothAdapter#setProfileProxy}
+   * has been previously called.
+   *
+   * <p>If active (non-null) proxy has been set by {@link setProfileProxy} for the given {@code
+   * profile}, {@link getProfileProxy} will immediately call {@code onServiceConnected} of the given
+   * BluetoothProfile.ServiceListener {@code listener}.
+   *
+   * @return 'true' if a proxy object has been set by {@link setProfileProxy} for the given
+   *     BluetoothProfile {@code profile}
+   */
+  @Implementation
+  protected boolean getProfileProxy(
+      Context context, BluetoothProfile.ServiceListener listener, int profile) {
+    if (!isOverridingProxyBehavior) {
+      return reflector(BluetoothAdapterReflector.class, realAdapter)
+          .getProfileProxy(context, listener, profile);
+    }
+
+    BluetoothProfile proxy = profileProxies.get(profile);
+    if (proxy == null) {
+      return false;
+    } else {
+      listener.onServiceConnected(profile, proxy);
+      return true;
+    }
+  }
+
+  /**
+   * Overrides behavior of {@link closeProfileProxy} if {@link
+   * ShadowBluetoothAdapter#setProfileProxy} has been previously called.
+   *
+   * If the given non-null BluetoothProfile {@code proxy} was previously set for the given {@code
+   * profile} by {@link ShadowBluetoothAdapter#setProfileProxy}, this proxy will be "deactivated".
+   */
+  @Implementation
+  protected void closeProfileProxy(int profile, BluetoothProfile proxy) {
+    if (!isOverridingProxyBehavior) {
+      reflector(BluetoothAdapterReflector.class, realAdapter).closeProfileProxy(profile, proxy);
+      return;
+    }
+
+    if (proxy != null && proxy.equals(profileProxies.get(profile))) {
+      profileProxies.remove(profile);
+    }
+  }
+
+  /** Returns the last value of {@link #setIsLeExtendedAdvertisingSupported}, defaulting to true. */
+  @Implementation(minSdk = O)
+  protected boolean isLeExtendedAdvertisingSupported() {
+    return isLeExtendedAdvertisingSupported;
+  }
+
+  /**
+   * Sets the isLeExtendedAdvertisingSupported to enable/disable LE extended advertisements feature
+   */
+  public void setIsLeExtendedAdvertisingSupported(boolean supported) {
+    isLeExtendedAdvertisingSupported = supported;
+  }
+
+  @Implementation(minSdk = O)
+  protected int getLeMaximumAdvertisingDataLength() {
+    return isLeExtendedAdvertisingSupported
+        ? LE_MAXIMUM_ADVERTISING_DATA_LENGTH_EXTENDED
+        : LE_MAXIMUM_ADVERTISING_DATA_LENGTH;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected int startRfcommServer(String name, UUID uuid, PendingIntent pendingIntent) {
+    // PendingIntent#isImmutable throws an NPE if the component does not exist, so verify directly
+    // against the flags for now.
+    if ((shadowOf(pendingIntent).getFlags() & PendingIntent.FLAG_IMMUTABLE) == 0) {
+      throw new IllegalArgumentException("RFCOMM server PendingIntent must be marked immutable");
+    }
+
+    boolean[] isNewServerSocket = {false};
+    backgroundRfcommServers.computeIfAbsent(
+        uuid,
+        unused -> {
+          isNewServerSocket[0] = true;
+          return new BackgroundRfcommServerEntry(uuid, pendingIntent);
+        });
+    return isNewServerSocket[0]
+        ? BluetoothStatusCodes.SUCCESS
+        : RFCOMM_LISTENER_START_FAILED_UUID_IN_USE;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected int stopRfcommServer(UUID uuid) {
+    BackgroundRfcommServerEntry entry = backgroundRfcommServers.remove(uuid);
+
+    if (entry == null) {
+      return RFCOMM_LISTENER_OPERATION_FAILED_NO_MATCHING_SERVICE_RECORD;
+    }
+
+    try {
+      entry.serverSocket.close();
+      return BluetoothStatusCodes.SUCCESS;
+    } catch (IOException e) {
+      return RFCOMM_LISTENER_FAILED_TO_CLOSE_SERVER_SOCKET;
+    }
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  @Nullable
+  protected BluetoothSocket retrieveConnectedRfcommSocket(UUID uuid) {
+    BackgroundRfcommServerEntry serverEntry = backgroundRfcommServers.get(uuid);
+
+    try {
+      return serverEntry == null ? null : serverEntry.serverSocket.accept(/* timeout= */ 0);
+    } catch (IOException e) {
+      // This means there is no pending socket, so the contract indicates we should return null.
+      return null;
+    }
+  }
+
+  /**
+   * Creates an incoming socket connection from the given {@link BluetoothDevice} to a background
+   * Bluetooth server created with {@link BluetoothAdapter#startRfcommServer(String, UUID,
+   * PendingIntent)} on the given uuid.
+   *
+   * <p>Creating this socket connection will invoke the {@link PendingIntent} provided in {@link
+   * BluetoothAdapter#startRfcommServer(String, UUID, PendingIntent)} when the server socket was
+   * created for the given UUID. The component provided in the intent can then call {@link
+   * BluetoothAdapter#retrieveConnectedRfcommSocket(UUID)} to obtain the server side socket.
+   *
+   * <p>A {@link ShadowBluetoothSocket} obtained from the returned {@link BluetoothSocket} can be
+   * used to send data to and receive data from the server side socket. This returned {@link
+   * BluetoothSocket} is the same socket as returned by {@link
+   * BluetoothAdapter#retrieveConnectedRfcommSocket(UUID)} and should generally not be used directly
+   * outside of obtaining the shadow, as this socket is normally not exposed outside of the
+   * component started by the pending intent. {@link ShadowBluetoothSocket#getInputStreamFeeder()}
+   * and {@link ShadowBluetoothSocket#getOutputStreamSink()} can be used to send data to and from
+   * the socket as if it was a remote connection.
+   *
+   * <p><b>Warning:</b> The socket returned by this method and the corresponding server side socket
+   * retrieved from {@link BluetoothAdapter#retrieveConnectedRfcommSocket(UUID)} do not support
+   * reads and writes from different threads. Once reading or writing is started for a given socket
+   * on a given thread, that type of operation on that socket must only be done on that thread.
+   *
+   * @return a server side BluetoothSocket or {@code null} if the {@link UUID} is not registered.
+   *     This value should generally not be used directly, and is mainly used to obtain a shadow
+   *     with which a RFCOMM client can be simulated.
+   * @throws IllegalArgumentException if a server is not started for the given {@link UUID}.
+   * @throws CanceledException if the pending intent for the server socket was cancelled.
+   */
+  public BluetoothSocket addIncomingRfcommConnection(BluetoothDevice remoteDevice, UUID uuid)
+      throws CanceledException {
+    BackgroundRfcommServerEntry entry = backgroundRfcommServers.get(uuid);
+    if (entry == null) {
+      throw new IllegalArgumentException("No RFCOMM server open for UUID: " + uuid);
+    }
+
+    BluetoothSocket socket = shadowOf(entry.serverSocket).deviceConnected(remoteDevice);
+    entry.pendingIntent.send();
+
+    return socket;
+  }
+
+  /**
+   * Returns an immutable set of {@link UUID}s representing the currently registered RFCOMM servers.
+   */
+  @SuppressWarnings("JdkImmutableCollections")
+  public Set<UUID> getRegisteredRfcommServerUuids() {
+    return Set.of(backgroundRfcommServers.keySet().toArray(new UUID[0]));
+  }
+
+  private static final class BackgroundRfcommServerEntry {
+    final BluetoothServerSocket serverSocket;
+    final PendingIntent pendingIntent;
+
+    BackgroundRfcommServerEntry(UUID uuid, PendingIntent pendingIntent) {
+      this.serverSocket =
+          ShadowBluetoothServerSocket.newInstance(
+              /* type= */ BluetoothSocket.TYPE_RFCOMM,
+              /* auth= */ true,
+              /* encrypt= */ true,
+              new ParcelUuid(uuid));
+      this.pendingIntent = pendingIntent;
+    }
+  }
+
+  @ForType(BluetoothAdapter.class)
+  interface BluetoothAdapterReflector {
+
+    @Static
+    @Direct
+    BluetoothAdapter getDefaultAdapter();
+
+    @Direct
+    boolean getProfileProxy(
+        Context context, BluetoothProfile.ServiceListener listener, int profile);
+
+    @Direct
+    void closeProfileProxy(int profile, BluetoothProfile proxy);
+
+    @Direct
+    BluetoothDevice getRemoteDevice(String address);
+
+    @Accessor("sAdapter")
+    @Static
+    void setAdapter(BluetoothAdapter adapter);
+
+    @Accessor("mBluetoothLeAdvertiser")
+    @Deprecated
+    void setBluetoothLeAdvertiser(BluetoothLeAdvertiser advertiser);
+
+    @Accessor("sBluetoothLeAdvertiser")
+    @Static
+    void setSBluetoothLeAdvertiser(BluetoothLeAdvertiser advertiser);
+
+    @Accessor("sBluetoothLeScanner")
+    @Static
+    void setSBluetoothLeScanner(BluetoothLeScanner scanner);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothDevice.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothDevice.java
new file mode 100644
index 0000000..95c7547
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothDevice.java
@@ -0,0 +1,452 @@
+package org.robolectric.shadows;
+
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.IntRange;
+import android.app.ActivityThread;
+import android.bluetooth.BluetoothClass;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothSocket;
+import android.bluetooth.IBluetooth;
+import android.content.Context;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.ParcelUuid;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(BluetoothDevice.class)
+public class ShadowBluetoothDevice {
+  @Deprecated // Prefer {@link android.bluetooth.BluetoothAdapter#getRemoteDevice}
+  public static BluetoothDevice newInstance(String address) {
+    return ReflectionHelpers.callConstructor(
+        BluetoothDevice.class, ReflectionHelpers.ClassParameter.from(String.class, address));
+  }
+
+  @Resetter
+  public static void reset() {
+    bluetoothSocket = null;
+  }
+
+  private static BluetoothSocket bluetoothSocket = null;
+
+  @RealObject private BluetoothDevice realBluetoothDevice;
+  private String name;
+  private ParcelUuid[] uuids;
+  private int bondState = BOND_NONE;
+  private boolean createdBond = false;
+  private boolean fetchUuidsWithSdpResult = false;
+  private int fetchUuidsWithSdpCount = 0;
+  private int type = BluetoothDevice.DEVICE_TYPE_UNKNOWN;
+  private final List<BluetoothGatt> bluetoothGatts = new ArrayList<>();
+  private Boolean pairingConfirmation = null;
+  private byte[] pin = null;
+  private String alias;
+  private boolean shouldThrowOnGetAliasName = false;
+  private BluetoothClass bluetoothClass = null;
+  private boolean shouldThrowSecurityExceptions = false;
+  private final Map<Integer, byte[]> metadataMap = new HashMap<>();
+  private int batteryLevel = BluetoothDevice.BATTERY_LEVEL_BLUETOOTH_OFF;
+  private boolean isInSilenceMode = false;
+
+  /**
+   * Implements getService() in the same way the original method does, but ignores any Exceptions
+   * from invoking {@link android.bluetooth.BluetoothAdapter#getBluetoothService}.
+   */
+  @Implementation
+  protected static IBluetooth getService() {
+    // Attempt to call the underlying getService method, but ignore any Exceptions. This allows us
+    // to easily create BluetoothDevices for testing purposes without having any actual Bluetooth
+    // capability.
+    try {
+      return reflector(BluetoothDeviceReflector.class).getService();
+    } catch (Exception e) {
+      // No-op.
+    }
+    return null;
+  }
+
+  public void setName(String name) {
+    this.name = name;
+  }
+
+  /**
+   * Sets the alias name of the device.
+   *
+   * <p>Alias is the locally modified name of a remote device.
+   *
+   * <p>Alias Name is not part of the supported SDK, and accessed via reflection.
+   *
+   * @param alias alias name.
+   */
+  public void setAlias(String alias) {
+    this.alias = alias;
+  }
+
+  /**
+   * Sets if a runtime exception is thrown when the alias name of the device is accessed.
+   *
+   * <p>Intended to replicate what may happen if the unsupported SDK is changed.
+   *
+   * <p>Alias is the locally modified name of a remote device.
+   *
+   * <p>Alias Name is not part of the supported SDK, and accessed via reflection.
+   *
+   * @param shouldThrow if getAliasName() should throw when called.
+   */
+  public void setThrowOnGetAliasName(boolean shouldThrow) {
+    shouldThrowOnGetAliasName = shouldThrow;
+  }
+
+  /**
+   * Sets if a runtime exception is thrown when bluetooth methods with BLUETOOTH_CONNECT permission
+   * pre-requisites are accessed.
+   *
+   * <p>Intended to replicate what may happen if user has not enabled nearby device permissions.
+   *
+   * @param shouldThrow if methods should throw SecurityExceptions without enabled permissions when
+   *     called.
+   */
+  public void setShouldThrowSecurityExceptions(boolean shouldThrow) {
+    shouldThrowSecurityExceptions = shouldThrow;
+  }
+
+  @Implementation
+  protected String getName() {
+    checkForBluetoothConnectPermission();
+    return name;
+  }
+
+  @Implementation
+  protected String getAlias() {
+    checkForBluetoothConnectPermission();
+    return alias;
+  }
+
+  @Implementation(maxSdk = Q)
+  protected String getAliasName() throws ReflectiveOperationException {
+    // Mimicking if the officially supported function is changed.
+    if (shouldThrowOnGetAliasName) {
+      throw new ReflectiveOperationException("Exception on getAliasName");
+    }
+
+    // Matches actual implementation.
+    String name = getAlias();
+    return name != null ? name : getName();
+  }
+
+  /** Sets the return value for {@link BluetoothDevice#getType}. */
+  public void setType(int type) {
+    this.type = type;
+  }
+
+  /**
+   * Overrides behavior of {@link BluetoothDevice#getType} to return pre-set result.
+   *
+   * @return Value set by calling {@link ShadowBluetoothDevice#setType}. If setType has not
+   *     previously been called, will return BluetoothDevice.DEVICE_TYPE_UNKNOWN.
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected int getType() {
+    checkForBluetoothConnectPermission();
+    return type;
+  }
+
+  /** Sets the return value for {@link BluetoothDevice#getUuids}. */
+  public void setUuids(ParcelUuid[] uuids) {
+    this.uuids = uuids;
+  }
+
+  /**
+   * Overrides behavior of {@link BluetoothDevice#getUuids} to return pre-set result.
+   *
+   * @return Value set by calling {@link ShadowBluetoothDevice#setUuids}. If setUuids has not
+   *     previously been called, will return null.
+   */
+  @Implementation
+  protected ParcelUuid[] getUuids() {
+    checkForBluetoothConnectPermission();
+    return uuids;
+  }
+
+  /** Sets value of bond state for {@link BluetoothDevice#getBondState}. */
+  public void setBondState(int bondState) {
+    this.bondState = bondState;
+  }
+
+  /**
+   * Overrides behavior of {@link BluetoothDevice#getBondState} to return pre-set result.
+   *
+   * @returns Value set by calling {@link ShadowBluetoothDevice#setBondState}. If setBondState has
+   *     not previously been called, will return {@link BluetoothDevice#BOND_NONE} to indicate the
+   *     device is not bonded.
+   */
+  @Implementation
+  protected int getBondState() {
+    checkForBluetoothConnectPermission();
+    return bondState;
+  }
+
+  /** Sets whether this device has been bonded with. */
+  public void setCreatedBond(boolean createdBond) {
+    this.createdBond = createdBond;
+  }
+
+  /** Returns whether this device has been bonded with. */
+  @Implementation
+  protected boolean createBond() {
+    checkForBluetoothConnectPermission();
+    return createdBond;
+  }
+
+  @Implementation(minSdk = Q)
+  protected BluetoothSocket createInsecureL2capChannel(int psm) throws IOException {
+    checkForBluetoothConnectPermission();
+    return reflector(BluetoothDeviceReflector.class, realBluetoothDevice)
+        .createInsecureL2capChannel(psm);
+  }
+
+  @Implementation(minSdk = Q)
+  protected BluetoothSocket createL2capChannel(int psm) throws IOException {
+    checkForBluetoothConnectPermission();
+    return reflector(BluetoothDeviceReflector.class, realBluetoothDevice).createL2capChannel(psm);
+  }
+
+  @Implementation
+  protected boolean removeBond() {
+    checkForBluetoothConnectPermission();
+    boolean result = createdBond;
+    createdBond = false;
+    return result;
+  }
+
+  @Implementation
+  protected boolean setPin(byte[] pin) {
+    checkForBluetoothConnectPermission();
+    this.pin = pin;
+    return true;
+  }
+
+  /**
+   * Get the PIN previously set with a call to {@link BluetoothDevice#setPin(byte[])}, or null if no
+   * PIN has been set.
+   */
+  public byte[] getPin() {
+    return pin;
+  }
+
+  @Implementation
+  public boolean setPairingConfirmation(boolean confirm) {
+    checkForBluetoothConnectPermission();
+    this.pairingConfirmation = confirm;
+    return true;
+  }
+
+  /**
+   * Get the confirmation value previously set with a call to {@link
+   * BluetoothDevice#setPairingConfirmation(boolean)}, or null if no value is set.
+   */
+  public Boolean getPairingConfirmation() {
+    return pairingConfirmation;
+  }
+
+  @Implementation
+  protected BluetoothSocket createRfcommSocketToServiceRecord(UUID uuid) throws IOException {
+    checkForBluetoothConnectPermission();
+    synchronized (ShadowBluetoothDevice.class) {
+      if (bluetoothSocket == null) {
+        bluetoothSocket = Shadow.newInstanceOf(BluetoothSocket.class);
+      }
+    }
+    return bluetoothSocket;
+  }
+
+  /** Sets value of the return result for {@link BluetoothDevice#fetchUuidsWithSdp}. */
+  public void setFetchUuidsWithSdpResult(boolean fetchUuidsWithSdpResult) {
+    this.fetchUuidsWithSdpResult = fetchUuidsWithSdpResult;
+  }
+
+  /**
+   * Overrides behavior of {@link BluetoothDevice#fetchUuidsWithSdp}. This method updates the
+   * counter which counts the number of invocations of this method.
+   *
+   * @returns Value set by calling {@link ShadowBluetoothDevice#setFetchUuidsWithSdpResult}. If not
+   *     previously set, will return false by default.
+   */
+  @Implementation
+  protected boolean fetchUuidsWithSdp() {
+    checkForBluetoothConnectPermission();
+    fetchUuidsWithSdpCount++;
+    return fetchUuidsWithSdpResult;
+  }
+
+  /** Returns the number of times fetchUuidsWithSdp has been called. */
+  public int getFetchUuidsWithSdpCount() {
+    return fetchUuidsWithSdpCount;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected BluetoothGatt connectGatt(
+      Context context, boolean autoConnect, BluetoothGattCallback callback) {
+    checkForBluetoothConnectPermission();
+    return connectGatt(callback);
+  }
+
+  @Implementation(minSdk = M)
+  protected BluetoothGatt connectGatt(
+      Context context, boolean autoConnect, BluetoothGattCallback callback, int transport) {
+    checkForBluetoothConnectPermission();
+    return connectGatt(callback);
+  }
+
+  @Implementation(minSdk = O)
+  protected BluetoothGatt connectGatt(
+      Context context,
+      boolean autoConnect,
+      BluetoothGattCallback callback,
+      int transport,
+      int phy,
+      Handler handler) {
+    checkForBluetoothConnectPermission();
+    return connectGatt(callback);
+  }
+
+  private BluetoothGatt connectGatt(BluetoothGattCallback callback) {
+    BluetoothGatt bluetoothGatt = ShadowBluetoothGatt.newInstance(realBluetoothDevice);
+    bluetoothGatts.add(bluetoothGatt);
+    ShadowBluetoothGatt shadowBluetoothGatt = Shadow.extract(bluetoothGatt);
+    shadowBluetoothGatt.setGattCallback(callback);
+    return bluetoothGatt;
+  }
+
+  /**
+   * Returns all {@link BluetoothGatt} objects created by calling {@link
+   * ShadowBluetoothDevice#connectGatt}.
+   */
+  public List<BluetoothGatt> getBluetoothGatts() {
+    return bluetoothGatts;
+  }
+
+  /**
+   * Causes {@link BluetoothGattCallback#onConnectionStateChange to be called for every GATT client.
+   * @param status Status of the GATT operation
+   * @param newState The new state of the GATT profile
+   */
+  public void simulateGattConnectionChange(int status, int newState) {
+    for (BluetoothGatt bluetoothGatt : bluetoothGatts) {
+      ShadowBluetoothGatt shadowBluetoothGatt = Shadow.extract(bluetoothGatt);
+      BluetoothGattCallback gattCallback = shadowBluetoothGatt.getGattCallback();
+      gattCallback.onConnectionStateChange(bluetoothGatt, status, newState);
+    }
+  }
+
+  /**
+   * Overrides behavior of {@link BluetoothDevice#getBluetoothClass} to return pre-set result.
+   *
+   * @return Value set by calling {@link ShadowBluetoothDevice#setBluetoothClass}. If setType has
+   *     not previously been called, will return null.
+   */
+  @Implementation
+  public BluetoothClass getBluetoothClass() {
+    checkForBluetoothConnectPermission();
+    return bluetoothClass;
+  }
+
+  /** Sets the return value for {@link BluetoothDevice#getBluetoothClass}. */
+  public void setBluetoothClass(BluetoothClass bluetoothClass) {
+    this.bluetoothClass = bluetoothClass;
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean setMetadata(int key, byte[] value) {
+    checkForBluetoothConnectPermission();
+    metadataMap.put(key, value);
+    return true;
+  }
+
+  @Implementation(minSdk = Q)
+  protected byte[] getMetadata(int key) {
+    checkForBluetoothConnectPermission();
+    return metadataMap.get(key);
+  }
+
+  public void setBatteryLevel(@IntRange(from = -100, to = 100) int batteryLevel) {
+    this.batteryLevel = batteryLevel;
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected int getBatteryLevel() {
+    checkForBluetoothConnectPermission();
+    return batteryLevel;
+  }
+
+  @Implementation(minSdk = Q)
+  public boolean setSilenceMode(boolean isInSilenceMode) {
+    checkForBluetoothConnectPermission();
+    this.isInSilenceMode = isInSilenceMode;
+    return true;
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean isInSilenceMode() {
+    checkForBluetoothConnectPermission();
+    return isInSilenceMode;
+  }
+
+  @ForType(BluetoothDevice.class)
+  interface BluetoothDeviceReflector {
+
+    @Static
+    @Direct
+    IBluetooth getService();
+
+    @Direct
+    BluetoothSocket createInsecureL2capChannel(int psm);
+
+    @Direct
+    BluetoothSocket createL2capChannel(int psm);
+  }
+
+  static ShadowInstrumentation getShadowInstrumentation() {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    return Shadow.extract(activityThread.getInstrumentation());
+  }
+
+  private void checkForBluetoothConnectPermission() {
+    if (shouldThrowSecurityExceptions
+        && VERSION.SDK_INT >= VERSION_CODES.S
+        && !checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT)) {
+      throw new SecurityException("Bluetooth connect permission required.");
+    }
+  }
+
+  static boolean checkPermission(String permission) {
+    return getShadowInstrumentation()
+            .checkPermission(permission, android.os.Process.myPid(), android.os.Process.myUid())
+        == PERMISSION_GRANTED;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java
new file mode 100644
index 0000000..5f5fd88
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java
@@ -0,0 +1,103 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.content.Context;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(value = BluetoothGatt.class, minSdk = JELLY_BEAN_MR2)
+public class ShadowBluetoothGatt {
+  private BluetoothGattCallback bluetoothGattCallback;
+
+  @SuppressLint("PrivateApi")
+  @SuppressWarnings("unchecked")
+  public static BluetoothGatt newInstance(BluetoothDevice device) {
+    try {
+      Class<?> iBluetoothGattClass =
+          Shadow.class.getClassLoader().loadClass("android.bluetooth.IBluetoothGatt");
+
+      BluetoothGatt bluetoothGatt;
+      int apiLevel = RuntimeEnvironment.getApiLevel();
+      if (apiLevel > R) {
+        bluetoothGatt =
+            Shadow.newInstance(
+                BluetoothGatt.class,
+                new Class<?>[] {
+                  iBluetoothGattClass,
+                  BluetoothDevice.class,
+                  Integer.TYPE,
+                  Boolean.TYPE,
+                  Integer.TYPE,
+                  Class.forName("android.content.AttributionSource"),
+                },
+                new Object[] {null, device, 0, false, 0, null});
+      } else if (apiLevel >= O_MR1) {
+        bluetoothGatt =
+            Shadow.newInstance(
+                BluetoothGatt.class,
+                new Class<?>[] {
+                  iBluetoothGattClass,
+                  BluetoothDevice.class,
+                  Integer.TYPE,
+                  Boolean.TYPE,
+                  Integer.TYPE
+                },
+                new Object[] {null, device, 0, false, 0});
+      } else if (apiLevel >= O) {
+        bluetoothGatt =
+            Shadow.newInstance(
+                BluetoothGatt.class,
+                new Class<?>[] {
+                  iBluetoothGattClass, BluetoothDevice.class, Integer.TYPE, Integer.TYPE
+                },
+                new Object[] {null, device, 0, 0});
+      } else if (apiLevel >= LOLLIPOP) {
+        bluetoothGatt =
+            Shadow.newInstance(
+                BluetoothGatt.class,
+                new Class<?>[] {
+                  Context.class, iBluetoothGattClass, BluetoothDevice.class, Integer.TYPE
+                },
+                new Object[] {RuntimeEnvironment.getApplication(), null, device, 0});
+      } else {
+        bluetoothGatt =
+            Shadow.newInstance(
+                BluetoothGatt.class,
+                new Class<?>[] {Context.class, iBluetoothGattClass, BluetoothDevice.class},
+                new Object[] {RuntimeEnvironment.getApplication(), null, device});
+      }
+      return bluetoothGatt;
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /* package */ BluetoothGattCallback getGattCallback() {
+    return bluetoothGattCallback;
+  }
+
+  /* package */ void setGattCallback(BluetoothGattCallback bluetoothGattCallback) {
+    this.bluetoothGattCallback = bluetoothGattCallback;
+  }
+
+  /**
+   * Overrides behavior of {@link BluetoothGatt#connect()} to always return true.
+   *
+   * @return true, unconditionally
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean connect() {
+    return true;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java
new file mode 100644
index 0000000..51dde02
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java
@@ -0,0 +1,155 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothHeadset;
+import android.bluetooth.BluetoothProfile;
+import android.content.Intent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.NotThreadSafe;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link BluetoothHeadset} */
+@NotThreadSafe
+@Implements(value = BluetoothHeadset.class)
+public class ShadowBluetoothHeadset {
+  private final List<BluetoothDevice> connectedDevices = new ArrayList<>();
+  private boolean allowsSendVendorSpecificResultCode = true;
+  private BluetoothDevice activeBluetoothDevice;
+
+  /**
+   * Overrides behavior of {@link getConnectedDevices}. Returns list of devices that is set up by
+   * call(s) to {@link ShadowBluetoothHeadset#addConnectedDevice}. Returns an empty list by default.
+   */
+  @Implementation
+  protected List<BluetoothDevice> getConnectedDevices() {
+    return connectedDevices;
+  }
+
+  /** Adds the given BluetoothDevice to the shadow's list of "connected devices" */
+  public void addConnectedDevice(BluetoothDevice device) {
+    connectedDevices.add(device);
+  }
+
+  /**
+   * Overrides behavior of {@link getConnectionState}.
+   *
+   * @return {@code BluetoothProfile.STATE_CONNECTED} if the given device has been previously added
+   *     by a call to {@link ShadowBluetoothHeadset#addConnectedDevice}, and {@code
+   *     BluetoothProfile.STATE_DISCONNECTED} otherwise.
+   */
+  @Implementation
+  protected int getConnectionState(BluetoothDevice device) {
+    return connectedDevices.contains(device)
+        ? BluetoothProfile.STATE_CONNECTED
+        : BluetoothProfile.STATE_DISCONNECTED;
+  }
+
+  /**
+   * Overrides behavior of {@link startVoiceRecognition}. Returns false if 'bluetoothDevice' is null
+   * or voice recognition is already started. Users can listen to {@link
+   * ACTION_AUDIO_STATE_CHANGED}. If this function returns true, this intent will be broadcasted
+   * once with {@link BluetoothProfile.EXTRA_STATE} set to {@link STATE_AUDIO_CONNECTING} and once
+   * set to {@link STATE_AUDIO_CONNECTED}.
+   */
+  @Implementation
+  protected boolean startVoiceRecognition(BluetoothDevice bluetoothDevice) {
+    if (bluetoothDevice == null || !connectedDevices.contains(bluetoothDevice)) {
+      return false;
+    }
+    if (activeBluetoothDevice != null) {
+      stopVoiceRecognition(activeBluetoothDevice);
+      return false;
+    }
+    sendAudioStateChangedBroadcast(BluetoothHeadset.STATE_AUDIO_CONNECTING, bluetoothDevice);
+    sendAudioStateChangedBroadcast(BluetoothHeadset.STATE_AUDIO_CONNECTED, bluetoothDevice);
+
+    activeBluetoothDevice = bluetoothDevice;
+    return true;
+  }
+
+  /**
+   * Overrides the behavior of {@link stopVoiceRecognition}. Returns false if voice recognition was
+   * not started or voice recogntion has already ended on this headset. If this function returns
+   * true, {@link ACTION_AUDIO_STATE_CHANGED} intent is broadcasted with {@link
+   * BluetoothProfile.EXTRA_STATE} set to {@link STATE_DISCONNECTED}.
+   */
+  @Implementation
+  protected boolean stopVoiceRecognition(BluetoothDevice bluetoothDevice) {
+    boolean isDeviceActive = isDeviceActive(bluetoothDevice);
+    activeBluetoothDevice = null;
+    if (isDeviceActive) {
+      sendAudioStateChangedBroadcast(BluetoothHeadset.STATE_AUDIO_DISCONNECTED, bluetoothDevice);
+    }
+    return isDeviceActive;
+  }
+
+  @Implementation
+  protected boolean isAudioConnected(BluetoothDevice bluetoothDevice) {
+    return isDeviceActive(bluetoothDevice);
+  }
+
+  /**
+   * Overrides behavior of {@link sendVendorSpecificResultCode}.
+   *
+   * @return 'true' only if the given device has been previously added by a call to {@link
+   *     ShadowBluetoothHeadset#addConnectedDevice} and {@link
+   *     ShadowBluetoothHeadset#setAllowsSendVendorSpecificResultCode} has not been called with
+   *     'false' argument.
+   * @throws IllegalArgumentException if 'command' argument is null, per Android API
+   */
+  @Implementation(minSdk = KITKAT)
+  protected boolean sendVendorSpecificResultCode(
+      BluetoothDevice device, String command, String arg) {
+    if (command == null) {
+      throw new IllegalArgumentException("Command cannot be null");
+    }
+    return allowsSendVendorSpecificResultCode && connectedDevices.contains(device);
+  }
+
+  @Nullable
+  @Implementation(minSdk = P)
+  protected BluetoothDevice getActiveDevice() {
+    return activeBluetoothDevice;
+  }
+
+  @Implementation(minSdk = P)
+  protected boolean setActiveDevice(@Nullable BluetoothDevice bluetoothDevice) {
+    activeBluetoothDevice = bluetoothDevice;
+    Intent intent = new Intent(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED);
+    intent.putExtra(BluetoothDevice.EXTRA_DEVICE, activeBluetoothDevice);
+    RuntimeEnvironment.getApplication().sendBroadcast(intent);
+    return true;
+  }
+
+  /**
+   * Affects the behavior of {@link BluetoothHeadset#sendVendorSpecificResultCode}
+   *
+   * @param allowsSendVendorSpecificResultCode can be set to 'false' to simulate the situation where
+   *     the system is unable to send vendor-specific result codes to a device
+   */
+  public void setAllowsSendVendorSpecificResultCode(boolean allowsSendVendorSpecificResultCode) {
+    this.allowsSendVendorSpecificResultCode = allowsSendVendorSpecificResultCode;
+  }
+
+  private boolean isDeviceActive(BluetoothDevice bluetoothDevice) {
+    return Objects.equals(activeBluetoothDevice, bluetoothDevice);
+  }
+
+  private static void sendAudioStateChangedBroadcast(
+      int bluetoothProfileExtraState, BluetoothDevice bluetoothDevice) {
+    Intent connectedIntent =
+        new Intent(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
+            .putExtra(BluetoothProfile.EXTRA_STATE, bluetoothProfileExtraState)
+            .putExtra(BluetoothDevice.EXTRA_DEVICE, bluetoothDevice);
+
+    RuntimeEnvironment.getApplication().sendBroadcast(connectedIntent);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java
new file mode 100644
index 0000000..191eb9e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java
@@ -0,0 +1,189 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothUuid;
+import android.bluetooth.IBluetoothManager;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.os.ParcelUuid;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow implementation of {@link BluetoothLeAdvertiser}. */
+@Implements(value = BluetoothLeAdvertiser.class, minSdk = O)
+public class ShadowBluetoothLeAdvertiser {
+
+  private static final String CALLBACK_NULL_MESSAGE = "callback cannot be null.";
+  private static final int MAX_LEGACY_ADVERTISING_DATA_BYTES = 31;
+  private static final int OVERHEAD_BYTES_PER_FIELD = 2;
+  private static final int FLAGS_FIELD_BYTES = 3;
+  private static final int MANUFACTURER_SPECIFIC_DATA_LENGTH = 2;
+  private static final int SERVICE_DATA_UUID_LENGTH = 2;
+
+  private BluetoothAdapter bluetoothAdapter;
+  private final Set<AdvertiseCallback> advertisements = new HashSet<>();
+  @ReflectorObject protected BluetoothLeAdvertiserReflector bluetoothLeAdvertiserReflector;
+
+  @Implementation(maxSdk = R)
+  protected void __constructor__(IBluetoothManager bluetoothManager) {
+    bluetoothLeAdvertiserReflector.__constructor__(bluetoothManager);
+    PerfStatsCollector.getInstance().incrementCount("constructShadowBluetoothLeAdvertiser");
+    this.bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+  }
+
+  @Implementation(minSdk = S)
+  protected void __constructor__(BluetoothAdapter bluetoothAdapter) {
+    bluetoothLeAdvertiserReflector.__constructor__(bluetoothAdapter);
+    PerfStatsCollector.getInstance().incrementCount("constructShadowBluetoothLeAdvertiser");
+    this.bluetoothAdapter = bluetoothAdapter;
+  }
+
+  /**
+   * Start Bluetooth LE Advertising. This method returns immediately, the operation status is
+   * delivered through {@code callback}.
+   *
+   * @param settings Settings for Bluetooth LE advertising.
+   * @param advertiseData Advertisement data to be broadcasted.
+   * @param callback Callback for advertising status.
+   */
+  @Implementation
+  protected void startAdvertising(
+      AdvertiseSettings settings, AdvertiseData advertiseData, AdvertiseCallback callback) {
+    startAdvertising(settings, advertiseData, null, callback);
+  }
+
+  /**
+   * Start Bluetooth LE Advertising. This method returns immediately, the operation status is
+   * delivered through {@code callback}.
+   *
+   * @param settings Settings for Bluetooth LE advertising.
+   * @param advertiseData Advertisement data to be broadcasted.
+   * @param scanResponse Scan response associated with the advertisement data.
+   * @param callback Callback for advertising status.
+   * @throws IllegalArgumentException When {@code callback} is not present.
+   */
+  @Implementation
+  protected void startAdvertising(
+      AdvertiseSettings settings,
+      AdvertiseData advertiseData,
+      AdvertiseData scanResponse,
+      AdvertiseCallback callback) {
+
+    if (callback == null) {
+      throw new IllegalArgumentException(CALLBACK_NULL_MESSAGE);
+    }
+
+    boolean isConnectable = settings.isConnectable();
+
+    if (this.getTotalBytes(advertiseData, isConnectable) > MAX_LEGACY_ADVERTISING_DATA_BYTES
+        || this.getTotalBytes(scanResponse, false) > MAX_LEGACY_ADVERTISING_DATA_BYTES) {
+      callback.onStartFailure(AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE);
+      return;
+    }
+
+    if (advertisements.contains(callback)) {
+      callback.onStartFailure(AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED);
+      return;
+    }
+
+    this.advertisements.add(callback);
+    callback.onStartSuccess(settings);
+  }
+
+  /**
+   * Stop Bluetooth LE advertising. The {@code callback} must be the same one use in {@link
+   * ShadowBluetoothLeAdvertiser#startAdvertising}.
+   *
+   * @param callback {@link AdvertiseCallback} identifies the advertising instance to stop.
+   * @throws IllegalArgumentException When the {@code callback} is not a key present in {@code
+   *     advertisements}.
+   */
+  @Implementation
+  protected void stopAdvertising(AdvertiseCallback callback) {
+    if (callback == null) {
+      throw new IllegalArgumentException(CALLBACK_NULL_MESSAGE);
+    }
+    this.advertisements.remove(callback);
+  }
+
+  /** Returns the count of current ongoing Bluetooth LE advertising requests. */
+  public int getAdvertisementRequestCount() {
+    return this.advertisements.size();
+  }
+
+  private int getTotalBytes(AdvertiseData data, boolean isConnectable) {
+    if (data == null) {
+      return 0;
+    }
+    // Flags field is omitted if the advertising is not connectable.
+    int size = isConnectable ? FLAGS_FIELD_BYTES : 0;
+    if (data.getServiceUuids() != null) {
+      int num16BitUuids = 0;
+      int num32BitUuids = 0;
+      int num128BitUuids = 0;
+      for (ParcelUuid uuid : data.getServiceUuids()) {
+        if (BluetoothUuid.is16BitUuid(uuid)) {
+          ++num16BitUuids;
+        } else if (BluetoothUuid.is32BitUuid(uuid)) {
+          ++num32BitUuids;
+        } else {
+          ++num128BitUuids;
+        }
+      }
+      // 16 bit service uuids are grouped into one field when doing advertising.
+      if (num16BitUuids != 0) {
+        size += OVERHEAD_BYTES_PER_FIELD + num16BitUuids * BluetoothUuid.UUID_BYTES_16_BIT;
+      }
+      // 32 bit service uuids are grouped into one field when doing advertising.
+      if (num32BitUuids != 0) {
+        size += OVERHEAD_BYTES_PER_FIELD + num32BitUuids * BluetoothUuid.UUID_BYTES_32_BIT;
+      }
+      // 128 bit service uuids are grouped into one field when doing advertising.
+      if (num128BitUuids != 0) {
+        size += OVERHEAD_BYTES_PER_FIELD + num128BitUuids * BluetoothUuid.UUID_BYTES_128_BIT;
+      }
+    }
+
+    for (byte[] value : data.getServiceData().values()) {
+      size += OVERHEAD_BYTES_PER_FIELD + SERVICE_DATA_UUID_LENGTH + getByteLength(value);
+    }
+    for (int i = 0; i < data.getManufacturerSpecificData().size(); ++i) {
+      size +=
+          OVERHEAD_BYTES_PER_FIELD
+              + MANUFACTURER_SPECIFIC_DATA_LENGTH
+              + getByteLength(data.getManufacturerSpecificData().valueAt(i));
+    }
+    if (data.getIncludeTxPowerLevel()) {
+      size += OVERHEAD_BYTES_PER_FIELD + 1; // tx power level value is one byte.
+    }
+    if (data.getIncludeDeviceName() && bluetoothAdapter.getName() != null) {
+      size += OVERHEAD_BYTES_PER_FIELD + bluetoothAdapter.getName().length();
+    }
+    return size;
+  }
+
+  private static int getByteLength(byte[] array) {
+    return array == null ? 0 : array.length;
+  }
+
+  @ForType(BluetoothLeAdvertiser.class)
+  private interface BluetoothLeAdvertiserReflector {
+    @Direct
+    void __constructor__(IBluetoothManager bluetoothManager);
+
+    @Direct
+    void __constructor__(BluetoothAdapter bluetoothAdapter);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeScanner.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeScanner.java
new file mode 100644
index 0000000..d65dfb3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeScanner.java
@@ -0,0 +1,125 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static java.util.Collections.unmodifiableList;
+
+import android.app.PendingIntent;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Adds Robolectric support for BLE scanning. */
+@Implements(value = BluetoothLeScanner.class, minSdk = LOLLIPOP)
+public class ShadowBluetoothLeScanner {
+
+  private List<ScanParams> activeScanParams = new ArrayList<>();
+
+  /**
+   * Encapsulates scan params passed to {@link android.bluetooth.BluetoothAdapter} startScan
+   * methods.
+   */
+  @AutoValue
+  public abstract static class ScanParams {
+    public abstract ImmutableList<ScanFilter> scanFilters();
+
+    @Nullable
+    public abstract ScanSettings scanSettings();
+
+    @Nullable
+    public abstract PendingIntent pendingIntent();
+
+    @Nullable
+    public abstract ScanCallback scanCallback();
+
+    static ScanParams create(
+        List<ScanFilter> filters, ScanSettings settings, ScanCallback scanCallback) {
+      ImmutableList<ScanFilter> filtersCopy =
+          (filters == null) ? ImmutableList.of() : ImmutableList.copyOf(filters);
+      return new AutoValue_ShadowBluetoothLeScanner_ScanParams(
+          filtersCopy, settings, null, scanCallback);
+    }
+
+    static ScanParams create(
+        List<ScanFilter> filters, ScanSettings settings, PendingIntent pendingIntent) {
+      ImmutableList<ScanFilter> filtersCopy =
+          (filters == null) ? ImmutableList.of() : ImmutableList.copyOf(filters);
+      return new AutoValue_ShadowBluetoothLeScanner_ScanParams(
+          filtersCopy, settings, pendingIntent, null);
+    }
+  }
+
+  /**
+   * Tracks ongoing scans. Use {@link #getScanCallbacks} to get a list of any currently registered
+   * {@link ScanCallback}s.
+   */
+  @Implementation
+  protected void startScan(List<ScanFilter> filters, ScanSettings settings, ScanCallback callback) {
+    if (filters != null) {
+      filters = unmodifiableList(filters);
+    }
+
+    activeScanParams.add(ScanParams.create(filters, settings, callback));
+  }
+
+  /**
+   * Tracks ongoing scans. Use {@link #getScanCallbacks} to get a list of any currently registered
+   * {@link ScanCallback}s.
+   */
+  @Implementation(minSdk = O)
+  protected int startScan(
+      List<ScanFilter> filters, ScanSettings settings, PendingIntent pendingIntent) {
+    if (filters != null) {
+      filters = unmodifiableList(filters);
+    }
+    activeScanParams.add(ScanParams.create(filters, settings, pendingIntent));
+    return 0;
+  }
+
+  @Implementation
+  protected void stopScan(ScanCallback callback) {
+    activeScanParams =
+        Lists.newArrayList(
+            Iterables.filter(
+                activeScanParams, input -> !Objects.equals(callback, input.scanCallback())));
+  }
+
+  @Implementation(minSdk = O)
+  protected void stopScan(PendingIntent pendingIntent) {
+    activeScanParams =
+        Lists.newArrayList(
+            Iterables.filter(
+                activeScanParams, input -> !Objects.equals(pendingIntent, input.pendingIntent())));
+  }
+
+  /** Returns all currently active {@link ScanCallback}s. */
+  public Set<ScanCallback> getScanCallbacks() {
+    ArrayList<ScanCallback> scanCallbacks = new ArrayList<>();
+
+    for (ScanParams scanParams : activeScanParams) {
+      if (scanParams.scanCallback() != null) {
+        scanCallbacks.add(scanParams.scanCallback());
+      }
+    }
+    return Collections.unmodifiableSet(new HashSet<>(scanCallbacks));
+  }
+
+  /** Returns all {@link ScanParams}s representing active scans. */
+  public List<ScanParams> getActiveScans() {
+    return Collections.unmodifiableList(activeScanParams);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothManager.java
new file mode 100644
index 0000000..a5dd7bf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothManager.java
@@ -0,0 +1,104 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.ImmutableIntArray;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link BluetoothManager} that makes the testing possible. */
+@Implements(value = BluetoothManager.class, minSdk = JELLY_BEAN_MR2)
+public class ShadowBluetoothManager {
+  private static final ImmutableIntArray VALID_STATES =
+      ImmutableIntArray.of(
+          BluetoothProfile.STATE_CONNECTED,
+          BluetoothProfile.STATE_CONNECTING,
+          BluetoothProfile.STATE_DISCONNECTED,
+          BluetoothProfile.STATE_DISCONNECTING);
+
+  private final ArrayList<BleDevice> bleDevices = new ArrayList<>();
+
+  /** Used for storing registered {@link BluetoothDevice} with the specified profile and state. */
+  @AutoValue
+  abstract static class BleDevice {
+    /** {@link BluetoothProfile#GATT} or {@link BluetoothProfile#GATT_SERVER}. */
+    abstract int profile();
+    /**
+     * State of the profile connection. One of {@link BluetoothProfile#STATE_CONNECTED}, {@link
+     * BluetoothProfile#STATE_CONNECTING}, {@link BluetoothProfile#STATE_DISCONNECTED} and {@link
+     * BluetoothProfile#STATE_DISCONNECTING}.
+     */
+    abstract int state();
+    /** The remote bluetooth device. */
+    abstract BluetoothDevice device();
+
+    static Builder builder() {
+      return new AutoValue_ShadowBluetoothManager_BleDevice.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setProfile(int profile);
+
+      abstract Builder setState(int state);
+
+      abstract Builder setDevice(BluetoothDevice device);
+
+      abstract BleDevice build();
+    }
+  }
+
+  @Implementation
+  protected BluetoothAdapter getAdapter() {
+    return BluetoothAdapter.getDefaultAdapter();
+  }
+
+  @Implementation
+  protected List<BluetoothDevice> getDevicesMatchingConnectionStates(int profile, int[] states) {
+    checkArgument(isProfileValid(profile), "Profile not supported: %s", profile);
+
+    if (states == null) {
+      return ImmutableList.of();
+    }
+
+    ImmutableList.Builder<BluetoothDevice> result = ImmutableList.builder();
+    ImmutableIntArray stateArray = ImmutableIntArray.copyOf(states);
+    for (BleDevice ble : bleDevices) {
+      if (ble.profile() == profile && stateArray.contains(ble.state())) {
+        result.add(ble.device());
+      }
+    }
+    return result.build();
+  }
+
+  /**
+   * Add a remote bluetooth device that will be served by {@link
+   * BluetoothManager#getDevicesMatchingConnectionStates} for the specified profile and states of
+   * the profile connection.
+   *
+   * @param profile {@link BluetoothProfile#GATT} or {@link BluetoothProfile#GATT_SERVER}.
+   * @param state State of the profile connection. One of {@link BluetoothProfile#STATE_CONNECTED},
+   *     {@link BluetoothProfile#STATE_CONNECTING}, {@link BluetoothProfile#STATE_DISCONNECTED} and
+   *     {@link BluetoothProfile#STATE_DISCONNECTING}.
+   * @param device The remote bluetooth device.
+   */
+  public void addDevice(int profile, int state, BluetoothDevice device) {
+    if (isProfileValid(profile) && VALID_STATES.contains(state)) {
+      bleDevices.add(
+          BleDevice.builder().setProfile(profile).setState(state).setDevice(device).build());
+    }
+  }
+
+  private boolean isProfileValid(int profile) {
+    return profile == BluetoothProfile.GATT || profile == BluetoothProfile.GATT_SERVER;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothServerSocket.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothServerSocket.java
new file mode 100644
index 0000000..7d8c283
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothServerSocket.java
@@ -0,0 +1,89 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.os.Build;
+import android.os.ParcelUuid;
+import java.io.IOException;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = BluetoothServerSocket.class)
+public class ShadowBluetoothServerSocket {
+  private final BlockingQueue<BluetoothSocket> sockets = new LinkedBlockingQueue<>();
+  private boolean closed;
+
+  @SuppressLint("PrivateApi")
+  @SuppressWarnings("unchecked")
+  public static BluetoothServerSocket newInstance(
+      int type, boolean auth, boolean encrypt, ParcelUuid uuid) {
+    if (Build.VERSION.SDK_INT >= JELLY_BEAN_MR1) {
+      return Shadow.newInstance(
+          BluetoothServerSocket.class,
+          new Class<?>[] {Integer.TYPE, Boolean.TYPE, Boolean.TYPE, ParcelUuid.class},
+          new Object[] {type, auth, encrypt, uuid});
+    } else {
+      return Shadow.newInstance(
+          BluetoothServerSocket.class,
+          new Class<?>[] {Integer.TYPE, Boolean.TYPE, Boolean.TYPE, Integer.TYPE},
+          new Object[] {type, auth, encrypt, getPort(uuid)});
+    }
+  }
+
+  // Port ranges are valid from 1 to MAX_RFCOMM_CHANNEL.
+  private static int getPort(ParcelUuid uuid) {
+    return Math.abs(uuid.hashCode() % BluetoothSocket.MAX_RFCOMM_CHANNEL) + 1;
+  }
+
+  /**
+   * May block the current thread and wait until {@link BluetoothDevice} is offered via
+   * {@link #deviceConnected(BluetoothDevice)} method or timeout occurred.
+   *
+   * @return socket of the connected bluetooth device
+   * @throws IOException if socket has been closed, thread interrupted while waiting or timeout has
+   *         occurred.
+   */
+  @Implementation
+  protected BluetoothSocket accept(int timeout) throws IOException {
+    if (closed) {
+      throw new IOException("Socket closed");
+    }
+
+    BluetoothSocket socket;
+    try {
+      socket = timeout == -1
+              ? sockets.take() : sockets.poll(timeout, TimeUnit.MILLISECONDS);
+    } catch (InterruptedException e) {
+      throw new IOException(e);
+    }
+
+    if (socket == null) {
+      throw new IOException("Timeout occurred");
+    }
+    socket.connect();
+    return socket;
+  }
+
+  @Implementation
+  protected void close() throws IOException {
+    closed = true;
+  }
+
+  /** Creates {@link BluetoothSocket} for the given device and makes this socket available
+   * immediately in the {@link #accept(int)} method. */
+  public BluetoothSocket deviceConnected(BluetoothDevice device) {
+    BluetoothSocket socket = Shadow.newInstanceOf(BluetoothSocket.class);
+    ReflectionHelpers.setField(socket, "mDevice", device);
+    sockets.offer(socket);
+    return socket;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothSocket.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothSocket.java
new file mode 100644
index 0000000..90cdd23
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothSocket.java
@@ -0,0 +1,89 @@
+package org.robolectric.shadows;
+
+import android.bluetooth.BluetoothSocket;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(BluetoothSocket.class)
+public class ShadowBluetoothSocket {
+  private final PipedOutputStream inputStreamFeeder = new PipedOutputStream();
+  private final PipedInputStream outputStreamSink = new PipedInputStream();
+  private OutputStream outputStream;
+  private final InputStream inputStream;
+
+  private enum SocketState {
+    INIT,
+    CONNECTED,
+    CLOSED,
+  }
+
+  private SocketState state = SocketState.INIT;
+
+  public ShadowBluetoothSocket() {
+    try {
+      outputStream = new PipedOutputStream(outputStreamSink);
+      inputStream = new PipedInputStream(inputStreamFeeder);
+    } catch (IOException e) {
+      // Shouldn't happen. Rethrow as an unchecked exception.
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Set the output stream. {@code write()} operations on this stream can be observed to verify
+   * expected behavior.
+   */
+  public void setOutputStream(PipedOutputStream outputStream) {
+    this.outputStream = outputStream;
+  }
+
+  /**
+   * Returns {@link PipedOutputStream} that controls <b>input</b> stream of the {@link
+   * BluetoothSocket}.
+   */
+  public PipedOutputStream getInputStreamFeeder() {
+    return inputStreamFeeder;
+  }
+
+  /**
+   * Returns {@link PipedInputStream} that controls <b>output</b> stream of the {@link
+   * BluetoothSocket}.
+   */
+  public PipedInputStream getOutputStreamSink() {
+    return outputStreamSink;
+  }
+
+  @Implementation
+  protected InputStream getInputStream() {
+    return inputStream;
+  }
+
+  @Implementation
+  protected OutputStream getOutputStream() {
+    return outputStream;
+  }
+
+  @Implementation
+  protected boolean isConnected() {
+    return state == SocketState.CONNECTED;
+  }
+
+  /** This method doesn't perform an actual connection and returns immediately */
+  @Implementation
+  protected void connect() throws IOException {
+    if (state == SocketState.CLOSED) {
+      throw new IOException("socket closed");
+    }
+    state = SocketState.CONNECTED;
+  }
+
+  @Implementation
+  protected void close() throws IOException {
+    state = SocketState.CLOSED;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastPendingResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastPendingResult.java
new file mode 100644
index 0000000..2cd2058
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastPendingResult.java
@@ -0,0 +1,133 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+
+import android.content.BroadcastReceiver;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.IBinder;
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@Implements(BroadcastReceiver.PendingResult.class)
+public final class ShadowBroadcastPendingResult {
+  @RealObject BroadcastReceiver.PendingResult pendingResult;
+
+  static BroadcastReceiver.PendingResult create(int resultCode, String resultData, Bundle resultExtras, boolean ordered) {
+    try {
+      if (getApiLevel() <= JELLY_BEAN) {
+        return BroadcastReceiver.PendingResult.class
+            .getConstructor(int.class, String.class, Bundle.class, int.class, boolean.class, boolean.class, IBinder.class)
+            .newInstance(
+                resultCode,
+                resultData,
+                resultExtras,
+                0 /* type */,
+                ordered,
+                false /*sticky*/,
+                null /* ibinder token */);
+      } else if (getApiLevel() <= LOLLIPOP_MR1) {
+        return BroadcastReceiver.PendingResult.class
+            .getConstructor(int.class, String.class, Bundle.class, int.class, boolean.class, boolean.class, IBinder.class, int.class)
+            .newInstance(
+                resultCode,
+                resultData,
+                resultExtras,
+                0 /* type */,
+                ordered,
+                false /*sticky*/,
+                null /* ibinder token */,
+                0 /* userid */);
+
+      } else {
+        return new BroadcastReceiver.PendingResult(
+            resultCode,
+            resultData,
+            resultExtras,
+            0 /* type */,
+            ordered,
+            false /*sticky*/,
+            null /* ibinder token */,
+            0 /* userid */,
+            0 /* flags */);
+      }
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  static BroadcastReceiver.PendingResult createSticky(Intent intent) {
+    try {
+      if (getApiLevel() <= JELLY_BEAN) {
+        return BroadcastReceiver.PendingResult.class
+            .getConstructor(
+                int.class,
+                String.class,
+                Bundle.class,
+                int.class,
+                boolean.class,
+                boolean.class,
+                IBinder.class)
+            .newInstance(
+                0 /*resultCode*/,
+                intent.getDataString(),
+                intent.getExtras(),
+                0 /* type */,
+                false /*ordered*/,
+                true /*sticky*/,
+                null /* ibinder token */);
+      } else if (getApiLevel() <= LOLLIPOP_MR1) {
+        return BroadcastReceiver.PendingResult.class
+            .getConstructor(
+                int.class,
+                String.class,
+                Bundle.class,
+                int.class,
+                boolean.class,
+                boolean.class,
+                IBinder.class,
+                int.class)
+            .newInstance(
+                0 /*resultCode*/,
+                intent.getDataString(),
+                intent.getExtras(),
+                0 /* type */,
+                false /*ordered*/,
+                true /*sticky*/,
+                null /* ibinder token */,
+                0 /* userid */);
+
+      } else {
+        return new BroadcastReceiver.PendingResult(
+            0 /*resultCode*/,
+            intent.getDataString(),
+            intent.getExtras(),
+            0 /* type */,
+            false /*ordered*/,
+            true /*sticky*/,
+            null /* ibinder token */,
+            0 /* userid */,
+            0 /* flags */);
+      }
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private final SettableFuture<BroadcastReceiver.PendingResult> finished = SettableFuture.create();
+
+  @Implementation
+  protected final void finish() {
+    Preconditions.checkState(finished.set(pendingResult), "Broadcast already finished");
+  }
+
+  public ListenableFuture<BroadcastReceiver.PendingResult> getFuture() {
+    return finished;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastReceiver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastReceiver.java
new file mode 100644
index 0000000..fa62de0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastReceiver.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.BroadcastReceiver;
+import android.content.BroadcastReceiver.PendingResult;
+import android.content.Context;
+import android.content.Intent;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(BroadcastReceiver.class)
+public class ShadowBroadcastReceiver {
+  @RealObject BroadcastReceiver receiver;
+
+  // The abort state of the currently processed broadcast
+  private AtomicBoolean abort = new AtomicBoolean(false);
+  private boolean wentAsync = false;
+  private PendingResult originalPendingResult;
+
+  @Implementation
+  protected void abortBroadcast() {
+    // TODO probably needs a check to prevent calling this method from ordinary Broadcasts
+    abort.set(true);
+  }
+
+  @Implementation
+  protected void onReceive(Context context, Intent intent) {
+    if (abort == null || !abort.get()) {
+      receiver.onReceive(context, intent);
+    }
+  }
+
+  public void onReceive(Context context, Intent intent, AtomicBoolean abort) {
+    this.abort = abort;
+    onReceive(context, intent);
+    // If the underlying receiver has called goAsync(), we should not finish the pending result yet - they'll do that
+    // for us.
+    if (receiver.getPendingResult() != null) {
+      receiver.getPendingResult().finish();
+    }
+  }
+
+  @Implementation
+  protected PendingResult goAsync() {
+    // Save the PendingResult before goAsync() clears it.
+    originalPendingResult = receiver.getPendingResult();
+    wentAsync = true;
+    return reflector(BroadcastReceiverReflector.class, receiver).goAsync();
+  }
+
+  public boolean wentAsync() {
+    return wentAsync;
+  }
+
+  public PendingResult getOriginalPendingResult() {
+    if (wentAsync) {
+      return originalPendingResult;
+    } else {
+      return receiver.getPendingResult();
+    }
+  }
+
+  @ForType(BroadcastReceiver.class)
+  interface BroadcastReceiverReflector {
+
+    @Direct
+    PendingResult goAsync();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastResponseStats.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastResponseStats.java
new file mode 100644
index 0000000..4581de0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBroadcastResponseStats.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.usage.BroadcastResponseStats;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow of {@link BroadcastResponseStats} for accessing hidden APIs. */
+@Implements(value = BroadcastResponseStats.class, minSdk = TIRAMISU, isInAndroidSdk = false)
+public class ShadowBroadcastResponseStats {
+
+  @RealObject private BroadcastResponseStats broadcastResponseStats;
+
+  @Implementation(minSdk = TIRAMISU)
+  public void incrementBroadcastsDispatchedCount(int count) {
+    reflector(BroadcastResponseStatsReflector.class, broadcastResponseStats)
+        .incrementBroadcastsDispatchedCount(count);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  public void incrementNotificationsPostedCount(int count) {
+    reflector(BroadcastResponseStatsReflector.class, broadcastResponseStats)
+        .incrementNotificationsPostedCount(count);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  public void incrementNotificationsUpdatedCount(int count) {
+    reflector(BroadcastResponseStatsReflector.class, broadcastResponseStats)
+        .incrementNotificationsUpdatedCount(count);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  public void incrementNotificationsCancelledCount(int count) {
+    reflector(BroadcastResponseStatsReflector.class, broadcastResponseStats)
+        .incrementNotificationsCancelledCount(count);
+  }
+
+  @ForType(BroadcastResponseStats.class)
+  interface BroadcastResponseStatsReflector {
+    @Direct
+    void incrementBroadcastsDispatchedCount(int count);
+
+    @Direct
+    void incrementNotificationsPostedCount(int count);
+
+    @Direct
+    void incrementNotificationsUpdatedCount(int count);
+
+    @Direct
+    void incrementNotificationsCancelledCount(int count);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBugreportManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBugreportManager.java
new file mode 100644
index 0000000..22df7cf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBugreportManager.java
@@ -0,0 +1,160 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.os.BugreportManager;
+import android.os.BugreportManager.BugreportCallback;
+import android.os.BugreportParams;
+import android.os.ParcelFileDescriptor;
+import androidx.annotation.Nullable;
+import java.io.IOException;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Implementation of {@link android.os.BugreportManager}.
+ *
+ * <p>This class is not available in the public Android SDK, but it is available for system apps.
+ */
+@Implements(value = BugreportManager.class, minSdk = Q, isInAndroidSdk = false)
+public class ShadowBugreportManager {
+
+  private boolean hasPermission = true;
+
+  @Nullable private ParcelFileDescriptor bugreportFd;
+  @Nullable private ParcelFileDescriptor screenshotFd;
+  @Nullable private Executor executor;
+  @Nullable private BugreportCallback callback;
+  private boolean bugreportRequested;
+  @Nullable private CharSequence shareTitle;
+  @Nullable private CharSequence shareDescription;
+
+  /**
+   * Starts a bugreport with which can execute callback methods on the provided executor.
+   *
+   * <p>If bugreport already in progress, {@link BugreportCallback#onError} will be executed.
+   */
+  @Implementation
+  protected void startBugreport(
+      ParcelFileDescriptor bugreportFd,
+      ParcelFileDescriptor screenshotFd,
+      BugreportParams params,
+      Executor executor,
+      BugreportCallback callback) {
+    enforcePermission("startBugreport");
+    if (isBugreportInProgress()) {
+      executor.execute(
+          () -> callback.onError(BugreportCallback.BUGREPORT_ERROR_ANOTHER_REPORT_IN_PROGRESS));
+    } else {
+      this.bugreportFd = bugreportFd;
+      this.screenshotFd = screenshotFd;
+      this.executor = executor;
+      this.callback = callback;
+    }
+  }
+
+  /**
+   * Normally requests the platform/system to take a bugreport and make the final bugreport
+   * available to the user.
+   *
+   * <p>This implementation just sets a boolean recording that the method was invoked, and the share
+   * title and description.
+   */
+  @Implementation(minSdk = R)
+  protected void requestBugreport(
+      BugreportParams params, CharSequence shareTitle, CharSequence shareDescription) {
+    this.bugreportRequested = true;
+    this.shareTitle = shareTitle;
+    this.shareDescription = shareDescription;
+  }
+
+  /** Cancels bugreport in progress and executes {@link BugreportCallback#onError}. */
+  @Implementation
+  protected void cancelBugreport() {
+    enforcePermission("cancelBugreport");
+    executeOnError(BugreportCallback.BUGREPORT_ERROR_RUNTIME);
+  }
+
+  /** Executes {@link BugreportCallback#onProgress} on the provided Executor. */
+  public void executeOnProgress(float progress) {
+    if (isBugreportInProgress()) {
+      BugreportCallback callback = this.callback;
+      executor.execute(() -> callback.onProgress(progress));
+    }
+  }
+
+  /** Executes {@link BugreportCallback#onError} on the provided Executor. */
+  public void executeOnError(int errorCode) {
+    if (isBugreportInProgress()) {
+      BugreportCallback callback = this.callback;
+      executor.execute(() -> callback.onError(errorCode));
+    }
+    resetParams();
+  }
+
+  /** Executes {@link BugreportCallback#onFinished} on the provided Executor. */
+  public void executeOnFinished() {
+    if (isBugreportInProgress()) {
+      BugreportCallback callback = this.callback;
+      executor.execute(callback::onFinished);
+    }
+    resetParams();
+  }
+
+  public boolean isBugreportInProgress() {
+    return executor != null && callback != null;
+  }
+
+  /** Returns true if {@link #requestBugreport} was called. */
+  public boolean wasBugreportRequested() {
+    return bugreportRequested;
+  }
+
+  /**
+   * Simulates if the calling process has the required permissions to call BugreportManager methods.
+   *
+   * <p>If {@code hasPermission} is false, {@link #startBugreport} and {@link #cancelBugreport} will
+   * throw {@link SecurityException}.
+   */
+  public void setHasPermission(boolean hasPermission) {
+    this.hasPermission = hasPermission;
+  }
+
+  private void enforcePermission(String message) {
+    if (!hasPermission) {
+      throw new SecurityException(message);
+    }
+  }
+
+  /** Returns the title of the bugreport if set with {@code requestBugreport}, else null. */
+  @Nullable
+  public CharSequence getShareTitle() {
+    return shareTitle;
+  }
+
+  /** Returns the description of the bugreport if set with {@code requestBugreport}, else null. */
+  @Nullable
+  public CharSequence getShareDescription() {
+    return shareDescription;
+  }
+
+  private void resetParams() {
+    try {
+      bugreportFd.close();
+      if (screenshotFd != null) {
+        screenshotFd.close();
+      }
+    } catch (IOException e) {
+      // ignore.
+    }
+    bugreportFd = null;
+    screenshotFd = null;
+    executor = null;
+    callback = null;
+    bugreportRequested = false;
+    shareTitle = null;
+    shareDescription = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java
new file mode 100644
index 0000000..b0cc137
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java
@@ -0,0 +1,228 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = Build.class)
+public class ShadowBuild {
+
+  private static String radioVersionOverride = null;
+  private static String serialOverride = Build.UNKNOWN;
+
+  /**
+   * Sets the value of the {@link Build#DEVICE} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setDevice(String device) {
+    ReflectionHelpers.setStaticField(Build.class, "DEVICE", device);
+  }
+
+  /**
+   * Sets the value of the {@link Build#FINGERPRINT} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setFingerprint(String fingerprint) {
+    ReflectionHelpers.setStaticField(Build.class, "FINGERPRINT", fingerprint);
+  }
+
+  /**
+   * Sets the value of the {@link Build#ID} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setId(String id) {
+    ReflectionHelpers.setStaticField(Build.class, "ID", id);
+  }
+
+  /**
+   * Sets the value of the {@link Build#PRODUCT} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setProduct(String product) {
+    ReflectionHelpers.setStaticField(Build.class, "PRODUCT", product);
+  }
+
+  /**
+   * Sets the value of the {@link Build#MODEL} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setModel(String model) {
+    ReflectionHelpers.setStaticField(Build.class, "MODEL", model);
+  }
+
+  /**
+   * Sets the value of the {@link Build#MANUFACTURER} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setManufacturer(String manufacturer) {
+    ReflectionHelpers.setStaticField(Build.class, "MANUFACTURER", manufacturer);
+  }
+
+  /**
+   * Sets the value of the {@link Build#BRAND} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setBrand(String brand) {
+    ReflectionHelpers.setStaticField(Build.class, "BRAND", brand);
+  }
+
+  /**
+   * Sets the value of the {@link Build#HARDWARE} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setHardware(String hardware) {
+    ReflectionHelpers.setStaticField(Build.class, "HARDWARE", hardware);
+  }
+
+  /** Override return value from {@link Build#getSerial()}. */
+  public static void setSerial(String serial) {
+    serialOverride = serial;
+  }
+
+  /**
+   * Sets the value of the {@link Build.VERSION#CODENAME} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setVersionCodename(String versionCodename) {
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "CODENAME", versionCodename);
+  }
+
+  /**
+   * Sets the value of the {@link Build.VERSION#INCREMENTAL} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setVersionIncremental(String versionIncremental) {
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "INCREMENTAL", versionIncremental);
+  }
+
+  /**
+   * Sets the value of the {@link Build.VERSION#MEDIA_PERFORMANCE_CLASS} field. Available in Android
+   * S+.
+   *
+   * <p>It will be reset for the next test.
+   */
+  @TargetApi(S)
+  public static void setVersionMediaPerformanceClass(int performanceClass) {
+    ReflectionHelpers.setStaticField(
+        Build.VERSION.class, "MEDIA_PERFORMANCE_CLASS", performanceClass);
+  }
+
+  /**
+   * Sets the value of the {@link Build.VERSION#RELEASE} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setVersionRelease(String release) {
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "RELEASE", release);
+  }
+
+  /**
+   * Sets the value of the {@link Build.VERSION#SECURITY_PATCH} field. Available in Android M+.
+   *
+   * <p>It will be reset for the next test.
+   */
+  @TargetApi(M)
+  public static void setVersionSecurityPatch(String securityPatch) {
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "SECURITY_PATCH", securityPatch);
+  }
+
+  /**
+   * Sets the value of the {@link Build#TAGS} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setTags(String tags) {
+    ReflectionHelpers.setStaticField(Build.class, "TAGS", tags);
+  }
+
+  /**
+   * Sets the value of the {@link Build#TYPE} field.
+   *
+   * <p>It will be reset for the next test.
+   */
+  public static void setType(String type) {
+    ReflectionHelpers.setStaticField(Build.class, "TYPE", type);
+  }
+
+  /**
+   * Sets the value of the {@link Build#SUPPORTED_64_BIT_ABIS} field. Available in Android L+.
+   *
+   * <p>It will be reset for the next test.
+   */
+  @TargetApi(LOLLIPOP)
+  public static void setSupported64BitAbis(String[] supported64BitAbis) {
+    ReflectionHelpers.setStaticField(Build.class, "SUPPORTED_64_BIT_ABIS", supported64BitAbis);
+  }
+
+  /**
+   * Override return value from {@link Build#getRadioVersion()}
+   *
+   * @param radioVersion
+   */
+  public static void setRadioVersion(String radioVersion) {
+    radioVersionOverride = radioVersion;
+  }
+
+  @Implementation
+  protected static String getRadioVersion() {
+    if (radioVersionOverride != null) {
+      return radioVersionOverride;
+    }
+    return reflector(_Build_.class).getRadioVersion();
+  }
+
+  @Implementation(minSdk = O)
+  protected static String getSerial() {
+    return serialOverride;
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    radioVersionOverride = null;
+    serialOverride = Build.UNKNOWN;
+    reflector(_Build_.class).__staticInitializer__();
+    reflector(_VERSION_.class).__staticInitializer__();
+  }
+
+  /** Reflector interface for {@link Build}. */
+  @ForType(Build.class)
+  private interface _Build_ {
+
+    @Static
+    void __staticInitializer__();
+
+    @Static
+    @Direct
+    String getRadioVersion();
+  }
+
+  /** Reflector interface for {@link Build.VERSION}. */
+  @ForType(Build.VERSION.class)
+  private interface _VERSION_ {
+
+    @Static
+    void __staticInitializer__();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCall.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCall.java
new file mode 100644
index 0000000..e68223e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCall.java
@@ -0,0 +1,140 @@
+package org.robolectric.shadows;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build.VERSION_CODES;
+import android.telecom.Call;
+import android.telecom.Call.RttCall;
+import android.telecom.InCallAdapter;
+import android.util.Log;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Robolectric test for {@link android.telecom.Call}. */
+@Implements(value = Call.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowCall {
+  @RealObject Call realObject;
+  private boolean hasSentRttRequest;
+  private boolean hasRespondedToRttRequest;
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected void sendRttRequest() {
+    hasSentRttRequest = true;
+    if (getInCallAdapter() == null) {
+      return;
+    }
+    reflector(ReflectorCall.class, realObject).sendRttRequest();
+  }
+
+  /**
+   * Determines whether sendRttRequest() was called.
+   *
+   * @return true if sendRttRequest() was called, false otherwise.
+   */
+  public boolean hasSentRttRequest() {
+    return hasSentRttRequest;
+  }
+
+  /** "Forgets" that sendRttRequest() was called. */
+  public void clearHasSentRttRequest() {
+    hasSentRttRequest = false;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected void respondToRttRequest(int id, boolean accept) {
+    hasRespondedToRttRequest = true;
+    if (getInCallAdapter() == null) {
+      return;
+    }
+    reflector(ReflectorCall.class, realObject).respondToRttRequest(id, accept);
+  }
+
+  /**
+   * Determines whether respondToRttRequest() was called.
+   *
+   * @return True if respondToRttRequest() was called, false otherwise.
+   */
+  public boolean hasRespondedToRttRequest() {
+    return hasRespondedToRttRequest;
+  }
+
+  /** Robolectric test for {@link android.telecom.Call.RttCall}. */
+  @Implements(value = Call.RttCall.class, minSdk = VERSION_CODES.O_MR1)
+  public static class ShadowRttCall {
+    private static final String TAG = "ShadowRttCall";
+    @RealObject RttCall realRttCallObject;
+    PipedOutputStream pipedOutputStream = new PipedOutputStream();
+
+    @Implementation
+    protected void __constructor__(
+        String telecomCallId,
+        InputStreamReader receiveStream,
+        OutputStreamWriter transmitStream,
+        int mode,
+        InCallAdapter inCallAdapter) {
+      PipedInputStream pipedInputStream = new PipedInputStream();
+      try {
+        pipedInputStream.connect(pipedOutputStream);
+      } catch (IOException e) {
+        Log.w(TAG, "Could not connect streams.");
+      }
+      invokeConstructor(
+          RttCall.class,
+          realRttCallObject,
+          ClassParameter.from(String.class, telecomCallId),
+          ClassParameter.from(
+              InputStreamReader.class, new InputStreamReader(pipedInputStream, UTF_8)),
+          ClassParameter.from(OutputStreamWriter.class, transmitStream),
+          ClassParameter.from(int.class, mode),
+          ClassParameter.from(InCallAdapter.class, inCallAdapter));
+    }
+
+    /**
+     * Writes a message to the RttCall buffer. This simulates receiving a message from a sender
+     * during an RTT call.
+     *
+     * @param message from sender.
+     * @throws IOException if write to buffer fails.
+     */
+    public void writeRemoteMessage(String message) throws IOException {
+      byte[] messageBytes = message.getBytes();
+      pipedOutputStream.write(messageBytes, 0, messageBytes.length);
+    }
+  }
+
+  public String getId() {
+    return reflector(ReflectorCall.class, realObject).getId();
+  }
+
+  private InCallAdapter getInCallAdapter() {
+    return reflector(ReflectorCall.class, realObject).getInCallAdapter();
+  }
+
+  @ForType(Call.class)
+  interface ReflectorCall {
+
+    @Direct
+    void sendRttRequest();
+
+    @Direct
+    void respondToRttRequest(int id, boolean accept);
+
+    @Accessor("mTelecomCallId")
+    String getId();
+
+    @Accessor("mInCallAdapter")
+    InCallAdapter getInCallAdapter();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCallLogCalls.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCallLogCalls.java
new file mode 100644
index 0000000..30244f4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCallLogCalls.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.provider.CallLog;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Shadow for the system's CallLog.Call class that allows tests to configure the most recent call.
+ */
+@Implements(CallLog.Calls.class)
+public class ShadowCallLogCalls {
+  private static String lastOutgoingCall;
+
+  /**
+   * Gets the last outgoing call String set by {@link #setLastOutgoingCall(String)}.
+   *
+   * @param context A Context object not used
+   * @return The last outgoing call set by {@link #setLastOutgoingCall(String)}
+   */
+  @Implementation
+  protected static String getLastOutgoingCall(Context context) {
+    return lastOutgoingCall;
+  }
+
+  /**
+   * Sets a last outgoing call that can later be retrieved by {@link #getLastOutgoingCall(Context)}.
+   *
+   * @param lastCall The last outgoing call String.
+   */
+  public static void setLastOutgoingCall(String lastCall) {
+    lastOutgoingCall = lastCall;
+  }
+
+  @Resetter
+  public static void reset() {
+    lastOutgoingCall = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCallScreeningService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCallScreeningService.java
new file mode 100644
index 0000000..d4c9467
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCallScreeningService.java
@@ -0,0 +1,106 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.telecom.Call;
+import android.telecom.CallScreeningService;
+import android.telecom.CallScreeningService.CallResponse;
+import com.android.internal.telecom.ICallScreeningAdapter;
+import java.util.Optional;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.telecom.CallScreeningService}. */
+@Implements(CallScreeningService.class)
+public final class ShadowCallScreeningService {
+  /** Contains the parameters used to call {@link CallScreeningService#respondToCall}. */
+  public static final class RespondToCallInput {
+    private Call.Details callDetails;
+    private CallResponse callResponse;
+
+    public RespondToCallInput(Call.Details callDetails, CallResponse callResponse) {
+      this.callDetails = callDetails;
+      this.callResponse = callResponse;
+    }
+
+    public Call.Details getCallDetails() {
+      return callDetails;
+    }
+
+    public CallResponse getCallResponse() {
+      return callResponse;
+    }
+  }
+
+  @RealObject private CallScreeningService realObject;
+
+  private Optional<RespondToCallInput> lastRespondToCallInput = Optional.empty();
+
+  /** Shadows {@link CallScreeningService#respondToCall}. */
+  @Implementation(minSdk = N)
+  protected final void respondToCall(Call.Details callDetails, CallResponse response) {
+    lastRespondToCallInput = Optional.of(new RespondToCallInput(callDetails, response));
+
+    if (shouldForwardResponseToRealObject()) {
+      reflector(CallScreeningServiceReflector.class, realObject)
+          .respondToCall(callDetails, response);
+    }
+  }
+
+  /**
+   * The real {@link CallScreeningService} class forwards the response to an {@code
+   * ICallScreeningAdapter}, which sends it across via IPC to the Telecom system service. In
+   * Robolectric, when interacting with a {@link CallScreeningService} via {@link
+   * org.robolectric.android.controller.ServiceController} as in
+   *
+   * <pre>{@code
+   * ServiceController<? extends CallScreeningService> serviceController =
+   *     Robolectric.buildService(MyCallScreeningServiceImpl.class, intent).create().bind();
+   * serviceController.onScreenCall(callDetails);
+   * }</pre>
+   *
+   * then no {@code ICallScreeningAdapter} is present and the response must not be forwarded to the
+   * real object to avoid a NullPointerException.
+   *
+   * <p>Test code interacting with {@link CallScreeningService} may set up an {@code
+   * ICallScreeningAdapter} by doing the following:
+   *
+   * <pre>{@code
+   * ServiceController<? extends CallScreeningService> serviceController =
+   *     Robolectric.buildService(MyCallScreeningServiceImpl.class, intent).create();
+   * ICallScreeningService.Stub binder = serviceController.get().onBind(intent);
+   * binder.screenCall(callScreeningAdapter, parcelableCall);
+   * }</pre>
+   *
+   * When this second approach is used, ShadowCallScreeningService will find that the {@code
+   * ICallScreeningAdapter} instance is present and forward the response to it.
+   */
+  private boolean shouldForwardResponseToRealObject() {
+    return reflector(CallScreeningServiceReflector.class, realObject).getCallScreeningAdapter()
+        != null;
+  }
+
+  /**
+   * If {@link CallScreeningService} has called {@link #respondToCall}, returns the values of its
+   * parameters. Returns an empty optional otherwise.
+   */
+  public Optional<RespondToCallInput> getLastRespondToCallInput() {
+    return lastRespondToCallInput;
+  }
+
+  /** Reflector interface for {@link CallScreeningService}'s internals. */
+  @ForType(CallScreeningService.class)
+  interface CallScreeningServiceReflector {
+
+    @Accessor("mCallScreeningAdapter")
+    ICallScreeningAdapter getCallScreeningAdapter();
+
+    @Direct
+    void respondToCall(Call.Details callDetails, CallResponse response);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCamera.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCamera.java
new file mode 100644
index 0000000..da43162
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCamera.java
@@ -0,0 +1,589 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static org.robolectric.shadow.api.Shadow.newInstanceOf;
+
+import android.graphics.ImageFormat;
+import android.hardware.Camera;
+import android.os.Build;
+import android.view.SurfaceHolder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(Camera.class)
+public class ShadowCamera {
+
+  private static int lastOpenedCameraId;
+
+  private int id;
+  private boolean locked;
+  private boolean previewing;
+  private boolean released;
+  private Camera.Parameters parameters;
+  private Camera.PreviewCallback previewCallback;
+  private List<byte[]> callbackBuffers = new ArrayList<>();
+  private SurfaceHolder surfaceHolder;
+  private int displayOrientation;
+  private Camera.AutoFocusCallback autoFocusCallback;
+  private boolean autoFocusing;
+  private boolean shutterSoundEnabled;
+
+  private static Map<Integer, Camera.CameraInfo> cameras = new HashMap<>();
+
+  @RealObject private Camera realCamera;
+
+  @Implementation
+  protected void __constructor__() {
+    locked = true;
+    previewing = false;
+    released = false;
+    shutterSoundEnabled = true;
+  }
+
+  @Implementation
+  protected static Camera open() {
+    lastOpenedCameraId = 0;
+    Camera camera = newInstanceOf(Camera.class);
+    ShadowCamera shadowCamera = Shadow.extract(camera);
+    shadowCamera.id = 0;
+    return camera;
+  }
+
+  @Implementation
+  protected static Camera open(int cameraId) {
+    lastOpenedCameraId = cameraId;
+    Camera camera = newInstanceOf(Camera.class);
+    ShadowCamera shadowCamera = Shadow.extract(camera);
+    shadowCamera.id = cameraId;
+    return camera;
+  }
+
+  public static int getLastOpenedCameraId() {
+    return lastOpenedCameraId;
+  }
+
+  @Implementation
+  protected void unlock() {
+    locked = false;
+  }
+
+  @Implementation
+  protected void reconnect() {
+    locked = true;
+  }
+
+  @Implementation
+  protected Camera.Parameters getParameters() {
+    if (null == parameters) {
+      parameters = newInstanceOf(Camera.Parameters.class);
+    }
+    return parameters;
+  }
+
+  @Implementation
+  protected void setParameters(Camera.Parameters params) {
+    parameters = params;
+  }
+
+  @Implementation
+  protected void setPreviewDisplay(SurfaceHolder holder) {
+    surfaceHolder = holder;
+  }
+
+  @Implementation
+  protected void startPreview() {
+    previewing = true;
+  }
+
+  @Implementation
+  protected void stopPreview() {
+    previewing = false;
+  }
+
+  @Implementation
+  protected void release() {
+    released = true;
+  }
+
+  @Implementation
+  protected void setPreviewCallback(Camera.PreviewCallback cb) {
+    previewCallback = cb;
+  }
+
+  @Implementation
+  protected void setOneShotPreviewCallback(Camera.PreviewCallback cb) {
+    previewCallback = cb;
+  }
+
+  @Implementation
+  protected void setPreviewCallbackWithBuffer(Camera.PreviewCallback cb) {
+    previewCallback = cb;
+  }
+
+  /**
+   * Allows test cases to invoke the preview callback, to simulate a frame of camera data.
+   *
+   * @param data byte buffer of simulated camera data
+   */
+  public void invokePreviewCallback(byte[] data) {
+    if (previewCallback != null) {
+      previewCallback.onPreviewFrame(data, realCamera);
+    }
+  }
+
+  @Implementation
+  protected void addCallbackBuffer(byte[] callbackBuffer) {
+    callbackBuffers.add(callbackBuffer);
+  }
+
+  public List<byte[]> getAddedCallbackBuffers() {
+    return Collections.unmodifiableList(callbackBuffers);
+  }
+
+  @Implementation
+  protected void setDisplayOrientation(int degrees) {
+    displayOrientation = degrees;
+    if (cameras.containsKey(id)) {
+      cameras.get(id).orientation = degrees;
+    }
+  }
+
+  public int getDisplayOrientation() {
+    return displayOrientation;
+  }
+
+  @Implementation
+  protected void autoFocus(Camera.AutoFocusCallback callback) {
+    autoFocusCallback = callback;
+    autoFocusing = true;
+  }
+
+  @Implementation
+  protected void cancelAutoFocus() {
+    autoFocusCallback = null;
+    autoFocusing = false;
+  }
+
+  public boolean hasRequestedAutoFocus() {
+    return autoFocusing;
+  }
+
+  public void invokeAutoFocusCallback(boolean success, Camera camera) {
+    if (autoFocusCallback == null) {
+      throw new IllegalStateException(
+          "cannot invoke AutoFocusCallback before autoFocus() has been called "
+              + "or after cancelAutoFocus() has been called "
+              + "or after the callback has been invoked.");
+    }
+    autoFocusCallback.onAutoFocus(success, camera);
+    autoFocusCallback = null;
+    autoFocusing = false;
+  }
+
+  @Implementation
+  protected static void getCameraInfo(int cameraId, Camera.CameraInfo cameraInfo) {
+    Camera.CameraInfo foundCam = cameras.get(cameraId);
+    cameraInfo.facing = foundCam.facing;
+    cameraInfo.orientation = foundCam.orientation;
+    // canDisableShutterSound was added in API 17.
+    if (Build.VERSION.SDK_INT >= JELLY_BEAN_MR1) {
+      cameraInfo.canDisableShutterSound = foundCam.canDisableShutterSound;
+    }
+  }
+
+  @Implementation
+  protected static int getNumberOfCameras() {
+    return cameras.size();
+  }
+
+  @Implementation
+  protected void takePicture(
+      Camera.ShutterCallback shutter, Camera.PictureCallback raw, Camera.PictureCallback jpeg) {
+    if (shutter != null) {
+      shutter.onShutter();
+    }
+
+    if (raw != null) {
+      raw.onPictureTaken(new byte[0], realCamera);
+    }
+
+    if (jpeg != null) {
+      jpeg.onPictureTaken(new byte[0], realCamera);
+    }
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected boolean enableShutterSound(boolean enabled) {
+    if (!enabled && cameras.containsKey(id) && !cameras.get(id).canDisableShutterSound) {
+      return false;
+    }
+    shutterSoundEnabled = enabled;
+    return true;
+  }
+
+  /** Returns {@code true} if the default shutter sound is played when taking a picture. */
+  public boolean isShutterSoundEnabled() {
+    return shutterSoundEnabled;
+  }
+
+  public boolean isLocked() {
+    return locked;
+  }
+
+  public boolean isPreviewing() {
+    return previewing;
+  }
+
+  public boolean isReleased() {
+    return released;
+  }
+
+  public SurfaceHolder getPreviewDisplay() {
+    return surfaceHolder;
+  }
+
+  /**
+   * Add a mock {@code Camera.CameraInfo} object to simulate the existence of one or more cameras.
+   * By default, no cameras are defined.
+   *
+   * @param id The camera id
+   * @param camInfo The CameraInfo
+   */
+  public static void addCameraInfo(int id, Camera.CameraInfo camInfo) {
+    cameras.put(id, camInfo);
+  }
+
+  public static void clearCameraInfo() {
+    cameras.clear();
+  }
+
+  /** Shadows the Android {@code Camera.Parameters} class. */
+  @Implements(Camera.Parameters.class)
+  public static class ShadowParameters {
+
+    private int pictureWidth = 1280;
+    private int pictureHeight = 960;
+    private int previewWidth = 640;
+    private int previewHeight = 480;
+    private int previewFormat = ImageFormat.NV21;
+    private int previewFpsMin = 10;
+    private int previewFpsMax = 30;
+    private int previewFps = 30;
+    private int exposureCompensation = 0;
+    private String flashMode;
+    private String focusMode;
+    private List<String> supportedFlashModes = new ArrayList<>();
+    private List<String> supportedFocusModes = new ArrayList<>();
+    private int maxNumFocusAreas;
+    private List<Camera.Area> focusAreas = new ArrayList<>();
+    private int maxNumMeteringAreas;
+    private List<Camera.Area> meteringAreas = new ArrayList<>();
+    private final Map<String, String> paramsMap = new HashMap<>();
+    private static List<Camera.Size> supportedPreviewSizes;
+
+    /**
+     * Explicitly initialize custom preview sizes array, to switch from default values to
+     * individually added.
+     */
+    public void initSupportedPreviewSizes() {
+      supportedPreviewSizes = new ArrayList<>();
+    }
+
+    /** Add custom preview sizes to supportedPreviewSizes. */
+    public void addSupportedPreviewSize(int width, int height) {
+      Camera.Size newSize = ReflectionHelpers.newInstance(Camera.class).new Size(width, height);
+      supportedPreviewSizes.add(newSize);
+    }
+
+    @Implementation
+    protected Camera.Size getPictureSize() {
+      Camera.Size pictureSize = newInstanceOf(Camera.class).new Size(0, 0);
+      pictureSize.width = pictureWidth;
+      pictureSize.height = pictureHeight;
+      return pictureSize;
+    }
+
+    @Implementation
+    protected int getPreviewFormat() {
+      return previewFormat;
+    }
+
+    @Implementation
+    protected void getPreviewFpsRange(int[] range) {
+      range[0] = previewFpsMin;
+      range[1] = previewFpsMax;
+    }
+
+    @Implementation
+    protected int getPreviewFrameRate() {
+      return previewFps;
+    }
+
+    @Implementation
+    protected Camera.Size getPreviewSize() {
+      Camera.Size previewSize = newInstanceOf(Camera.class).new Size(0, 0);
+      previewSize.width = previewWidth;
+      previewSize.height = previewHeight;
+      return previewSize;
+    }
+
+    @Implementation
+    protected List<Camera.Size> getSupportedPictureSizes() {
+      List<Camera.Size> supportedSizes = new ArrayList<>();
+      addSize(supportedSizes, 320, 240);
+      addSize(supportedSizes, 640, 480);
+      addSize(supportedSizes, 800, 600);
+      return supportedSizes;
+    }
+
+    @Implementation
+    protected List<Integer> getSupportedPictureFormats() {
+      List<Integer> formats = new ArrayList<>();
+      formats.add(ImageFormat.NV21);
+      formats.add(ImageFormat.JPEG);
+      return formats;
+    }
+
+    @Implementation
+    protected List<Integer> getSupportedPreviewFormats() {
+      List<Integer> formats = new ArrayList<>();
+      formats.add(ImageFormat.NV21);
+      formats.add(ImageFormat.JPEG);
+      return formats;
+    }
+
+    @Implementation
+    protected List<int[]> getSupportedPreviewFpsRange() {
+      List<int[]> supportedRanges = new ArrayList<>();
+      addRange(supportedRanges, 15000, 15000);
+      addRange(supportedRanges, 10000, 30000);
+      return supportedRanges;
+    }
+
+    @Implementation
+    protected List<Integer> getSupportedPreviewFrameRates() {
+      List<Integer> supportedRates = new ArrayList<>();
+      supportedRates.add(10);
+      supportedRates.add(15);
+      supportedRates.add(30);
+      return supportedRates;
+    }
+
+    @Implementation
+    protected List<Camera.Size> getSupportedPreviewSizes() {
+      if (supportedPreviewSizes == null) {
+        initSupportedPreviewSizes();
+        addSupportedPreviewSize(320, 240);
+        addSupportedPreviewSize(640, 480);
+      }
+      return supportedPreviewSizes;
+    }
+
+    public void setSupportedFocusModes(String... focusModes) {
+      supportedFocusModes = Arrays.asList(focusModes);
+    }
+
+    @Implementation
+    protected List<String> getSupportedFocusModes() {
+      return supportedFocusModes;
+    }
+
+    @Implementation
+    protected String getFocusMode() {
+      return focusMode;
+    }
+
+    @Implementation
+    protected void setFocusMode(String focusMode) {
+      this.focusMode = focusMode;
+    }
+
+    /**
+     * Allows test cases to set the maximum number of focus areas. See {@link
+     * Camera.Parameters#getMaxNumFocusAreas}.
+     */
+    public void setMaxNumFocusAreas(int maxNumFocusAreas) {
+      this.maxNumFocusAreas = maxNumFocusAreas;
+    }
+
+    @Implementation
+    protected int getMaxNumFocusAreas() {
+      return maxNumFocusAreas;
+    }
+
+    @Implementation
+    protected void setFocusAreas(List<Camera.Area> focusAreas) {
+      this.focusAreas = focusAreas;
+    }
+
+    @Implementation
+    protected List<Camera.Area> getFocusAreas() {
+      return focusAreas;
+    }
+
+    /**
+     * Allows test cases to set the maximum number of metering areas. See {@link
+     * Camera.Parameters#getMaxNumMeteringAreas}.
+     */
+    public void setMaxNumMeteringAreas(int maxNumMeteringAreas) {
+      this.maxNumMeteringAreas = maxNumMeteringAreas;
+    }
+
+    @Implementation
+    protected int getMaxNumMeteringAreas() {
+      return maxNumMeteringAreas;
+    }
+
+    @Implementation
+    protected void setMeteringAreas(List<Camera.Area> meteringAreas) {
+      this.meteringAreas = meteringAreas;
+    }
+
+    @Implementation
+    protected List<Camera.Area> getMeteringAreas() {
+      return meteringAreas;
+    }
+
+    @Implementation
+    protected void setPictureSize(int width, int height) {
+      pictureWidth = width;
+      pictureHeight = height;
+    }
+
+    @Implementation
+    protected void setPreviewFormat(int pixel_format) {
+      previewFormat = pixel_format;
+    }
+
+    @Implementation
+    protected void setPreviewFpsRange(int min, int max) {
+      previewFpsMin = min;
+      previewFpsMax = max;
+    }
+
+    @Implementation
+    protected void setPreviewFrameRate(int fps) {
+      previewFps = fps;
+    }
+
+    @Implementation
+    protected void setPreviewSize(int width, int height) {
+      previewWidth = width;
+      previewHeight = height;
+    }
+
+    @Implementation
+    protected void setRecordingHint(boolean recordingHint) {
+      // Do nothing - this prevents an NPE in the SDK code
+    }
+
+    @Implementation
+    protected void setRotation(int rotation) {
+      // Do nothing - this prevents an NPE in the SDK code
+    }
+
+    @Implementation
+    protected int getMinExposureCompensation() {
+      return -6;
+    }
+
+    @Implementation
+    protected int getMaxExposureCompensation() {
+      return 6;
+    }
+
+    @Implementation
+    protected float getExposureCompensationStep() {
+      return 0.5f;
+    }
+
+    @Implementation
+    protected int getExposureCompensation() {
+      return exposureCompensation;
+    }
+
+    @Implementation
+    protected void setExposureCompensation(int compensation) {
+      exposureCompensation = compensation;
+    }
+
+    public void setSupportedFlashModes(String... flashModes) {
+      supportedFlashModes = Arrays.asList(flashModes);
+    }
+
+    @Implementation
+    protected List<String> getSupportedFlashModes() {
+      return supportedFlashModes;
+    }
+
+    @Implementation
+    protected String getFlashMode() {
+      return flashMode;
+    }
+
+    @Implementation
+    protected void setFlashMode(String flashMode) {
+      this.flashMode = flashMode;
+    }
+
+    @Implementation
+    protected void set(String key, String value) {
+      paramsMap.put(key, value);
+    }
+
+    @Implementation
+    protected String get(String key) {
+      return paramsMap.get(key);
+    }
+
+    public int getPreviewWidth() {
+      return previewWidth;
+    }
+
+    public int getPreviewHeight() {
+      return previewHeight;
+    }
+
+    public int getPictureWidth() {
+      return pictureWidth;
+    }
+
+    public int getPictureHeight() {
+      return pictureHeight;
+    }
+
+    private void addSize(List<Camera.Size> sizes, int width, int height) {
+      Camera.Size newSize = newInstanceOf(Camera.class).new Size(0, 0);
+      newSize.width = width;
+      newSize.height = height;
+      sizes.add(newSize);
+    }
+
+    private void addRange(List<int[]> ranges, int min, int max) {
+      int[] range = new int[2];
+      range[0] = min;
+      range[1] = max;
+      ranges.add(range);
+    }
+  }
+
+  @Implements(Camera.Size.class)
+  public static class ShadowSize {
+    @RealObject private Camera.Size realCameraSize;
+
+    @Implementation
+    protected void __constructor__(Camera camera, int width, int height) {
+      realCameraSize.width = width;
+      realCameraSize.height = height;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraCaptureSessionImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraCaptureSessionImpl.java
new file mode 100644
index 0000000..cadab22
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraCaptureSessionImpl.java
@@ -0,0 +1,60 @@
+package org.robolectric.shadows;
+
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraCaptureSession.CaptureCallback;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.impl.CameraCaptureSessionImpl;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow class for {@link CameraCaptureSessionImpl} */
+@Implements(
+    value = CameraCaptureSessionImpl.class,
+    minSdk = VERSION_CODES.LOLLIPOP,
+    isInAndroidSdk = false)
+public class ShadowCameraCaptureSessionImpl {
+  @RealObject private CameraCaptureSessionImpl realObject;
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected int setRepeatingRequest(
+      CaptureRequest request, CaptureCallback callback, Handler handler)
+      throws CameraAccessException {
+    return 1;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected int setSingleRepeatingRequest(
+      CaptureRequest request, Executor executor, CaptureCallback callback)
+      throws CameraAccessException {
+    return 1;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected int capture(CaptureRequest request, CaptureCallback callback, Handler handler)
+      throws CameraAccessException {
+    return 1;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected int captureSingleRequest(
+      CaptureRequest request, Executor executor, CaptureCallback callback)
+      throws CameraAccessException {
+    return 1;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected void close() {
+    CameraCaptureSession.StateCallback callback =
+        ReflectionHelpers.getField(realObject, "mStateCallback");
+    if (callback == null) {
+      throw new IllegalArgumentException("blah");
+    }
+    callback.onClosed(realObject);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraCharacteristics.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraCharacteristics.java
new file mode 100644
index 0000000..e023a81
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraCharacteristics.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import android.annotation.Nullable;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraCharacteristics.Key;
+import android.os.Build.VERSION_CODES;
+import com.google.common.base.Preconditions;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = CameraCharacteristics.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowCameraCharacteristics {
+
+  private final Map<Key<?>, Object> charactersKeyToValue = new HashMap<>();
+
+  /** Convenience method which returns a new instance of {@link CameraCharacteristics}. */
+  public static CameraCharacteristics newCameraCharacteristics() {
+    return ReflectionHelpers.callConstructor(CameraCharacteristics.class);
+  }
+
+  @Implementation
+  @Nullable
+  protected <T> T get(Key<T> key) {
+    return (T) charactersKeyToValue.get(key);
+  }
+
+  /**
+   * Sets the value for a given key.
+   *
+   * @throws IllegalArgumentException if there's an existing value for the key.
+   */
+  public <T> void set(Key<T> key, Object value) {
+    Preconditions.checkArgument(!charactersKeyToValue.containsKey(key));
+    charactersKeyToValue.put(key, value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraDeviceImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraDeviceImpl.java
new file mode 100644
index 0000000..dbcd461
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraDeviceImpl.java
@@ -0,0 +1,105 @@
+package org.robolectric.shadows;
+
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.impl.CameraCaptureSessionImpl;
+import android.hardware.camera2.impl.CameraDeviceImpl;
+import android.hardware.camera2.impl.CameraMetadataNative;
+import android.hardware.camera2.params.SessionConfiguration;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.view.Surface;
+import java.util.List;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow class for {@link CameraDeviceImpl} */
+@Implements(value = CameraDeviceImpl.class, minSdk = VERSION_CODES.LOLLIPOP, isInAndroidSdk = false)
+public class ShadowCameraDeviceImpl {
+  @RealObject private CameraDeviceImpl realObject;
+  private boolean closed = false;
+
+  @Implementation
+  protected CaptureRequest.Builder createCaptureRequest(int templateType) {
+    checkIfCameraClosedOrInError();
+    CameraMetadataNative templatedRequest = new CameraMetadataNative();
+    String cameraId = ReflectionHelpers.getField(realObject, "mCameraId");
+    final CaptureRequest.Builder builder;
+    if (VERSION.SDK_INT >= VERSION_CODES.P) {
+      builder =
+          new CaptureRequest.Builder(
+              templatedRequest, /*reprocess*/
+              false,
+              CameraCaptureSession.SESSION_ID_NONE,
+              cameraId, /*physicalCameraIdSet*/
+              null);
+    } else if (VERSION.SDK_INT >= VERSION_CODES.M) {
+      builder =
+          ReflectionHelpers.callConstructor(
+              CaptureRequest.Builder.class,
+              ReflectionHelpers.ClassParameter.from(CameraMetadataNative.class, templatedRequest),
+              ReflectionHelpers.ClassParameter.from(Boolean.TYPE, false),
+              ReflectionHelpers.ClassParameter.from(
+                  Integer.TYPE, CameraCaptureSession.SESSION_ID_NONE));
+    } else {
+      builder =
+          ReflectionHelpers.callConstructor(
+              CaptureRequest.Builder.class,
+              ReflectionHelpers.ClassParameter.from(CameraMetadataNative.class, templatedRequest));
+    }
+    return builder;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected void createCaptureSession(
+      List<Surface> outputs, CameraCaptureSession.StateCallback callback, Handler handler)
+      throws CameraAccessException {
+    checkIfCameraClosedOrInError();
+    CameraCaptureSession session = createCameraCaptureSession(callback);
+    handler.post(() -> callback.onConfigured(session));
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected void createCaptureSession(SessionConfiguration config) throws CameraAccessException {
+    checkIfCameraClosedOrInError();
+    CameraCaptureSession session = createCameraCaptureSession(config.getStateCallback());
+    config.getExecutor().execute(() -> config.getStateCallback().onConfigured(session));
+  }
+
+  @Implementation
+  protected void close() {
+    if (!closed) {
+      Runnable callOnClosed = ReflectionHelpers.getField(realObject, "mCallOnClosed");
+      if (VERSION.SDK_INT >= VERSION_CODES.P) {
+        Executor deviceExecutor = ReflectionHelpers.getField(realObject, "mDeviceExecutor");
+        deviceExecutor.execute(callOnClosed);
+      } else {
+        Handler deviceHandler = ReflectionHelpers.getField(realObject, "mDeviceHandler");
+        deviceHandler.post(callOnClosed);
+      }
+    }
+
+    closed = true;
+  }
+
+  @Implementation
+  protected void checkIfCameraClosedOrInError() {
+    if (closed) {
+      throw new IllegalStateException("CameraDevice was already closed");
+    }
+  }
+
+  private CameraCaptureSession createCameraCaptureSession(
+      CameraCaptureSession.StateCallback callback) {
+    CameraCaptureSession sess = Shadow.newInstanceOf(CameraCaptureSessionImpl.class);
+    ReflectionHelpers.setField(CameraCaptureSessionImpl.class, sess, "mStateCallback", callback);
+    ReflectionHelpers.setField(CameraCaptureSessionImpl.class, sess, "mDeviceImpl", realObject);
+    return sess;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java
new file mode 100644
index 0000000..55b9b68
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java
@@ -0,0 +1,314 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.NonNull;
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraDevice.StateCallback;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.impl.CameraDeviceImpl;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import com.google.common.base.Preconditions;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow class for {@link CameraManager} */
+@Implements(value = CameraManager.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowCameraManager {
+  @RealObject private CameraManager realObject;
+
+  // LinkedHashMap used to ensure getCameraIdList returns ids in the order in which they were added
+  private final Map<String, CameraCharacteristics> cameraIdToCharacteristics =
+      new LinkedHashMap<>();
+  private final Map<String, Boolean> cameraTorches = new HashMap<>();
+  private final Set<CameraManager.AvailabilityCallback> registeredCallbacks = new HashSet<>();
+  // Most recent camera device opened with openCamera
+  private CameraDevice lastDevice;
+  // Most recent callback passed to openCamera
+  private CameraDevice.StateCallback lastCallback;
+  @Nullable private Executor lastCallbackExecutor;
+  @Nullable private Handler lastCallbackHandler;
+
+  // Keep references to cameras so they can be closed after each test
+  protected static final Set<CameraDeviceImpl> createdCameras =
+      Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
+
+  @Implementation
+  @NonNull
+  protected String[] getCameraIdList() throws CameraAccessException {
+    Set<String> cameraIds = cameraIdToCharacteristics.keySet();
+    return cameraIds.toArray(new String[0]);
+  }
+
+  @Implementation
+  @NonNull
+  protected CameraCharacteristics getCameraCharacteristics(@NonNull String cameraId) {
+    Preconditions.checkNotNull(cameraId);
+    CameraCharacteristics characteristics = cameraIdToCharacteristics.get(cameraId);
+    Preconditions.checkArgument(characteristics != null);
+    return characteristics;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected void setTorchMode(@NonNull String cameraId, boolean enabled) {
+    Preconditions.checkNotNull(cameraId);
+    Preconditions.checkArgument(cameraIdToCharacteristics.keySet().contains(cameraId));
+    cameraTorches.put(cameraId, enabled);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.S)
+  protected CameraDevice openCameraDeviceUserAsync(
+      String cameraId,
+      CameraDevice.StateCallback callback,
+      Executor executor,
+      int unusedClientUid,
+      int unusedOomScoreOffset) {
+    CameraCharacteristics characteristics = getCameraCharacteristics(cameraId);
+    Context context = RuntimeEnvironment.getApplication();
+    CameraDeviceImpl deviceImpl =
+        ReflectionHelpers.callConstructor(
+            CameraDeviceImpl.class,
+            ClassParameter.from(String.class, cameraId),
+            ClassParameter.from(CameraDevice.StateCallback.class, callback),
+            ClassParameter.from(Executor.class, executor),
+            ClassParameter.from(CameraCharacteristics.class, characteristics),
+            ClassParameter.from(Map.class, Collections.emptyMap()),
+            ClassParameter.from(int.class, context.getApplicationInfo().targetSdkVersion),
+            ClassParameter.from(Context.class, context));
+    createdCameras.add(deviceImpl);
+    updateCameraCallback(deviceImpl, callback, null, executor);
+    executor.execute(() -> callback.onOpened(deviceImpl));
+    return deviceImpl;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P, maxSdk = VERSION_CODES.R)
+  protected CameraDevice openCameraDeviceUserAsync(
+      String cameraId, CameraDevice.StateCallback callback, Executor executor, final int uid)
+      throws CameraAccessException {
+    CameraCharacteristics characteristics = getCameraCharacteristics(cameraId);
+    Context context = reflector(ReflectorCameraManager.class, realObject).getContext();
+
+    CameraDeviceImpl deviceImpl =
+        ReflectionHelpers.callConstructor(
+            CameraDeviceImpl.class,
+            ClassParameter.from(String.class, cameraId),
+            ClassParameter.from(CameraDevice.StateCallback.class, callback),
+            ClassParameter.from(Executor.class, executor),
+            ClassParameter.from(CameraCharacteristics.class, characteristics),
+            ClassParameter.from(int.class, context.getApplicationInfo().targetSdkVersion));
+
+    createdCameras.add(deviceImpl);
+    updateCameraCallback(deviceImpl, callback, null, executor);
+    executor.execute(() -> callback.onOpened(deviceImpl));
+    return deviceImpl;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N_MR1, maxSdk = VERSION_CODES.O_MR1)
+  protected CameraDevice openCameraDeviceUserAsync(
+      String cameraId, CameraDevice.StateCallback callback, Handler handler, final int uid)
+      throws CameraAccessException {
+    CameraCharacteristics characteristics = getCameraCharacteristics(cameraId);
+    Context context = reflector(ReflectorCameraManager.class, realObject).getContext();
+
+    CameraDeviceImpl deviceImpl;
+    if (Build.VERSION.SDK_INT == VERSION_CODES.N_MR1) {
+      deviceImpl =
+          ReflectionHelpers.callConstructor(
+              CameraDeviceImpl.class,
+              ClassParameter.from(String.class, cameraId),
+              ClassParameter.from(CameraDevice.StateCallback.class, callback),
+              ClassParameter.from(Handler.class, handler),
+              ClassParameter.from(CameraCharacteristics.class, characteristics));
+    } else {
+      deviceImpl =
+          ReflectionHelpers.callConstructor(
+              CameraDeviceImpl.class,
+              ClassParameter.from(String.class, cameraId),
+              ClassParameter.from(CameraDevice.StateCallback.class, callback),
+              ClassParameter.from(Handler.class, handler),
+              ClassParameter.from(CameraCharacteristics.class, characteristics),
+              ClassParameter.from(int.class, context.getApplicationInfo().targetSdkVersion));
+    }
+    createdCameras.add(deviceImpl);
+    updateCameraCallback(deviceImpl, callback, handler, null);
+    handler.post(() -> callback.onOpened(deviceImpl));
+    return deviceImpl;
+  }
+
+  /**
+   * Enables {@link CameraManager#openCamera(String, StateCallback, Handler)} to open a
+   * {@link CameraDevice}.
+   *
+   * <p>If the provided cameraId exists, this will always post
+   * {@link CameraDevice.StateCallback#onOpened(CameraDevice) to the provided {@link Handler}.
+   * Unlike on real Android, this will not check if the camera has been disabled by device policy
+   * and does not attempt to connect to the camera service, so
+   * {@link CameraDevice.StateCallback#onError(CameraDevice, int)} and
+   * {@link CameraDevice.StateCallback#onDisconnected(CameraDevice)} will not be triggered by
+   * {@link CameraManager#openCamera(String, StateCallback, Handler)}.
+   */
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP, maxSdk = VERSION_CODES.N)
+  protected CameraDevice openCameraDeviceUserAsync(
+      String cameraId, CameraDevice.StateCallback callback, Handler handler)
+      throws CameraAccessException {
+    CameraCharacteristics characteristics = getCameraCharacteristics(cameraId);
+
+    CameraDeviceImpl deviceImpl =
+        ReflectionHelpers.callConstructor(
+            CameraDeviceImpl.class,
+            ClassParameter.from(String.class, cameraId),
+            ClassParameter.from(CameraDevice.StateCallback.class, callback),
+            ClassParameter.from(Handler.class, handler),
+            ClassParameter.from(CameraCharacteristics.class, characteristics));
+
+    createdCameras.add(deviceImpl);
+    updateCameraCallback(deviceImpl, callback, handler, null);
+    handler.post(() -> callback.onOpened(deviceImpl));
+    return deviceImpl;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected void registerAvailabilityCallback(
+      CameraManager.AvailabilityCallback callback, Handler handler) {
+    Preconditions.checkNotNull(callback);
+    registeredCallbacks.add(callback);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected void unregisterAvailabilityCallback(CameraManager.AvailabilityCallback callback) {
+    Preconditions.checkNotNull(callback);
+    registeredCallbacks.remove(callback);
+  }
+
+  /**
+   * Calls all registered callbacks's onCameraAvailable method. This is a no-op if no callbacks are
+   * registered.
+   */
+  private void triggerOnCameraAvailable(@NonNull String cameraId) {
+    Preconditions.checkNotNull(cameraId);
+    for (CameraManager.AvailabilityCallback callback : registeredCallbacks) {
+      callback.onCameraAvailable(cameraId);
+    }
+  }
+
+  /**
+   * Calls all registered callbacks's onCameraUnavailable method. This is a no-op if no callbacks
+   * are registered.
+   */
+  private void triggerOnCameraUnavailable(@NonNull String cameraId) {
+    Preconditions.checkNotNull(cameraId);
+    for (CameraManager.AvailabilityCallback callback : registeredCallbacks) {
+      callback.onCameraUnavailable(cameraId);
+    }
+  }
+
+  /**
+   * Adds the given cameraId and characteristics to this shadow.
+   *
+   * <p>The result from {@link #getCameraIdList()} will be in the order in which cameras were added.
+   *
+   * @throws IllegalArgumentException if there's already an existing camera with the given id.
+   */
+  public void addCamera(@NonNull String cameraId, @NonNull CameraCharacteristics characteristics) {
+    Preconditions.checkNotNull(cameraId);
+    Preconditions.checkNotNull(characteristics);
+    Preconditions.checkArgument(!cameraIdToCharacteristics.containsKey(cameraId));
+
+    cameraIdToCharacteristics.put(cameraId, characteristics);
+    triggerOnCameraAvailable(cameraId);
+  }
+
+  /**
+   * Removes the given cameraId and associated characteristics from this shadow.
+   *
+   * @throws IllegalArgumentException if there is not an existing camera with the given id.
+   */
+  public void removeCamera(@NonNull String cameraId) {
+    Preconditions.checkNotNull(cameraId);
+    Preconditions.checkArgument(cameraIdToCharacteristics.containsKey(cameraId));
+
+    cameraIdToCharacteristics.remove(cameraId);
+    triggerOnCameraUnavailable(cameraId);
+  }
+
+  /** Returns what the supplied camera's torch is set to. */
+  public boolean getTorchMode(@NonNull String cameraId) {
+    Preconditions.checkNotNull(cameraId);
+    Preconditions.checkArgument(cameraIdToCharacteristics.keySet().contains(cameraId));
+    Boolean torchState = cameraTorches.get(cameraId);
+    return torchState;
+  }
+
+  /**
+   * Triggers a disconnect event, where any open camera will be disconnected (simulating the case
+   * where another app takes control of the camera).
+   */
+  public void triggerDisconnect() {
+    if (lastCallbackHandler != null) {
+      lastCallbackHandler.post(() -> lastCallback.onDisconnected(lastDevice));
+    } else if (lastCallbackExecutor != null) {
+      lastCallbackExecutor.execute(() -> lastCallback.onDisconnected(lastDevice));
+    }
+  }
+
+  protected void updateCameraCallback(
+      CameraDevice device,
+      CameraDevice.StateCallback callback,
+      @Nullable Handler handler,
+      @Nullable Executor executor) {
+    lastDevice = device;
+    lastCallback = callback;
+    lastCallbackHandler = handler;
+    lastCallbackExecutor = executor;
+  }
+
+  @Resetter
+  public static void reset() {
+    for (CameraDeviceImpl cameraDevice : createdCameras) {
+      cameraDevice.close();
+    }
+    createdCameras.clear();
+  }
+
+  /** Accessor interface for {@link CameraManager}'s internals. */
+  @ForType(CameraManager.class)
+  private interface ReflectorCameraManager {
+
+    @Accessor("mContext")
+    Context getContext();
+  }
+
+  /** Shadow class for internal class CameraManager$CameraManagerGlobal */
+  @Implements(
+      className = "android.hardware.camera2.CameraManager$CameraManagerGlobal",
+      minSdk = VERSION_CODES.LOLLIPOP)
+  public static class ShadowCameraManagerGlobal {
+
+    /**
+     * Cannot create a CameraService connection within Robolectric. Avoid endless reconnect loop.
+     */
+    @Implementation(minSdk = VERSION_CODES.N)
+    protected void scheduleCameraServiceReconnectionLocked() {}
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraMetadataNative.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraMetadataNative.java
new file mode 100644
index 0000000..840b959
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraMetadataNative.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.hardware.camera2.impl.CameraMetadataNative;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow class for {@link CameraMetadataNative} */
+@Implements(
+    value = CameraMetadataNative.class,
+    minSdk = LOLLIPOP,
+    maxSdk = Q,
+    isInAndroidSdk = false)
+public class ShadowCameraMetadataNative {
+  @Implementation(minSdk = LOLLIPOP, maxSdk = Q)
+  protected long nativeAllocate() {
+    return 1L;
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = Q)
+  protected long nativeAllocateCopy(CameraMetadataNative other) {
+    return 1L;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraMetadataNativeR.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraMetadataNativeR.java
new file mode 100644
index 0000000..0dbdfbc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraMetadataNativeR.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.hardware.camera2.impl.CameraMetadataNative;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow class for {@link CameraMetadataNative} */
+@Implements(value = CameraMetadataNative.class, minSdk = R, isInAndroidSdk = false)
+public class ShadowCameraMetadataNativeR {
+  // This method was changed to static in R, but otherwise has the same signature.
+  @Implementation(minSdk = R)
+  protected static long nativeAllocate() {
+    return 1L;
+  }
+
+  @Implementation(minSdk = R)
+  protected static long nativeAllocateCopy(long ptr) {
+    return 1L;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCanvas.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCanvas.java
new file mode 100644
index 0000000..310b610
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCanvas.java
@@ -0,0 +1,753 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.android.NativeObjRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * Broken. This implementation is very specific to the application for which it was developed. Todo:
+ * Reimplement. Consider using the same strategy of collecting a history of draw events and
+ * providing methods for writing queries based on type, number, and order of events.
+ */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Canvas.class)
+public class ShadowCanvas {
+  private static final NativeObjRegistry<NativeCanvas> nativeObjectRegistry =
+      new NativeObjRegistry<>(NativeCanvas.class);
+
+  @RealObject protected Canvas realCanvas;
+  @ReflectorObject protected CanvasReflector canvasReflector;
+
+  private final List<RoundRectPaintHistoryEvent> roundRectPaintEvents = new ArrayList<>();
+  private List<PathPaintHistoryEvent> pathPaintEvents = new ArrayList<>();
+  private List<CirclePaintHistoryEvent> circlePaintEvents = new ArrayList<>();
+  private List<ArcPaintHistoryEvent> arcPaintEvents = new ArrayList<>();
+  private List<RectPaintHistoryEvent> rectPaintEvents = new ArrayList<>();
+  private List<LinePaintHistoryEvent> linePaintEvents = new ArrayList<>();
+  private List<OvalPaintHistoryEvent> ovalPaintEvents = new ArrayList<>();
+  private List<TextHistoryEvent> drawnTextEventHistory = new ArrayList<>();
+  private Paint drawnPaint;
+  private Bitmap targetBitmap = ReflectionHelpers.callConstructor(Bitmap.class);
+  private float translateX;
+  private float translateY;
+  private float scaleX = 1;
+  private float scaleY = 1;
+  private int height;
+  private int width;
+
+  /**
+   * Returns a textual representation of the appearance of the object.
+   *
+   * @param canvas the canvas to visualize
+   * @return The textual representation of the appearance of the object.
+   */
+  public static String visualize(Canvas canvas) {
+    ShadowCanvas shadowCanvas = Shadow.extract(canvas);
+    return shadowCanvas.getDescription();
+  }
+
+  @Implementation
+  protected void __constructor__(Bitmap bitmap) {
+    canvasReflector.__constructor__(bitmap);
+    this.targetBitmap = bitmap;
+  }
+
+  private long getNativeId() {
+    return RuntimeEnvironment.getApiLevel() <= KITKAT_WATCH
+        ? (int) ReflectionHelpers.getField(realCanvas, "mNativeCanvas")
+        : realCanvas.getNativeCanvasWrapper();
+  }
+
+  private NativeCanvas getNativeCanvas() {
+    return nativeObjectRegistry.getNativeObject(getNativeId());
+  }
+
+  public void appendDescription(String s) {
+    ShadowBitmap shadowBitmap = Shadow.extract(targetBitmap);
+    shadowBitmap.appendDescription(s);
+  }
+
+  public String getDescription() {
+    ShadowBitmap shadowBitmap = Shadow.extract(targetBitmap);
+    return shadowBitmap.getDescription();
+  }
+
+  @Implementation
+  protected void setBitmap(Bitmap bitmap) {
+    targetBitmap = bitmap;
+  }
+
+  @Implementation
+  protected void drawText(String text, float x, float y, Paint paint) {
+    drawnTextEventHistory.add(new TextHistoryEvent(x, y, paint, text));
+  }
+
+  @Implementation
+  protected void drawText(CharSequence text, int start, int end, float x, float y, Paint paint) {
+    drawnTextEventHistory.add(
+        new TextHistoryEvent(x, y, paint, text.subSequence(start, end).toString()));
+  }
+
+  @Implementation
+  protected void drawText(char[] text, int index, int count, float x, float y, Paint paint) {
+    drawnTextEventHistory.add(new TextHistoryEvent(x, y, paint, new String(text, index, count)));
+  }
+
+  @Implementation
+  protected void drawText(String text, int start, int end, float x, float y, Paint paint) {
+    drawnTextEventHistory.add(new TextHistoryEvent(x, y, paint, text.substring(start, end)));
+  }
+
+  @Implementation
+  protected void translate(float x, float y) {
+    this.translateX = x;
+    this.translateY = y;
+  }
+
+  @Implementation
+  protected void scale(float sx, float sy) {
+    this.scaleX = sx;
+    this.scaleY = sy;
+  }
+
+  @Implementation
+  protected void scale(float sx, float sy, float px, float py) {
+    this.scaleX = sx;
+    this.scaleY = sy;
+  }
+
+  @Implementation
+  protected void drawPaint(Paint paint) {
+    drawnPaint = paint;
+  }
+
+  @Implementation
+  protected void drawColor(int color) {
+    appendDescription("draw color " + color);
+  }
+
+  @Implementation
+  protected void drawBitmap(Bitmap bitmap, float left, float top, Paint paint) {
+    describeBitmap(bitmap, paint);
+
+    int x = (int) (left + translateX);
+    int y = (int) (top + translateY);
+    if (x != 0 || y != 0) {
+      appendDescription(" at (" + x + "," + y + ")");
+    }
+
+    if (scaleX != 1 && scaleY != 1) {
+      appendDescription(" scaled by (" + scaleX + "," + scaleY + ")");
+    }
+
+    if (bitmap != null && targetBitmap != null) {
+      ShadowBitmap shadowTargetBitmap = Shadows.shadowOf(targetBitmap);
+      shadowTargetBitmap.drawBitmap(bitmap, (int) left, (int) top);
+    }
+  }
+
+  @Implementation
+  protected void drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint) {
+    describeBitmap(bitmap, paint);
+
+    StringBuilder descriptionBuilder = new StringBuilder();
+    if (dst != null) {
+      descriptionBuilder
+          .append(" at (")
+          .append(dst.left)
+          .append(",")
+          .append(dst.top)
+          .append(") with height=")
+          .append(dst.height())
+          .append(" and width=")
+          .append(dst.width());
+    }
+
+    if (src != null) {
+      descriptionBuilder.append(" taken from ").append(src.toString());
+    }
+    appendDescription(descriptionBuilder.toString());
+  }
+
+  @Implementation
+  protected void drawBitmap(Bitmap bitmap, Rect src, RectF dst, Paint paint) {
+    describeBitmap(bitmap, paint);
+
+    StringBuilder descriptionBuilder = new StringBuilder();
+    if (dst != null) {
+      descriptionBuilder
+          .append(" at (")
+          .append(dst.left)
+          .append(",")
+          .append(dst.top)
+          .append(") with height=")
+          .append(dst.height())
+          .append(" and width=")
+          .append(dst.width());
+    }
+
+    if (src != null) {
+      descriptionBuilder.append(" taken from ").append(src.toString());
+    }
+    appendDescription(descriptionBuilder.toString());
+  }
+
+  @Implementation
+  protected void drawBitmap(Bitmap bitmap, Matrix matrix, Paint paint) {
+    describeBitmap(bitmap, paint);
+
+    ShadowMatrix shadowMatrix = Shadow.extract(matrix);
+    appendDescription(" transformed by " + shadowMatrix.getDescription());
+  }
+
+  @Implementation
+  protected void drawPath(Path path, Paint paint) {
+    pathPaintEvents.add(new PathPaintHistoryEvent(new Path(path), new Paint(paint)));
+
+    separateLines();
+    ShadowPath shadowPath = Shadow.extract(path);
+    appendDescription("Path " + shadowPath.getPoints().toString());
+  }
+
+  @Implementation
+  protected void drawCircle(float cx, float cy, float radius, Paint paint) {
+    circlePaintEvents.add(new CirclePaintHistoryEvent(cx, cy, radius, paint));
+  }
+
+  @Implementation
+  protected void drawArc(
+      RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) {
+    arcPaintEvents.add(new ArcPaintHistoryEvent(oval, startAngle, sweepAngle, useCenter, paint));
+  }
+
+  @Implementation
+  protected void drawRect(float left, float top, float right, float bottom, Paint paint) {
+    rectPaintEvents.add(new RectPaintHistoryEvent(left, top, right, bottom, paint));
+
+    if (targetBitmap != null) {
+      ShadowBitmap shadowTargetBitmap = Shadows.shadowOf(targetBitmap);
+      shadowTargetBitmap.drawRect(new RectF(left, top, right, bottom), paint);
+    }
+  }
+
+  @Implementation
+  protected void drawRect(Rect r, Paint paint) {
+    rectPaintEvents.add(new RectPaintHistoryEvent(r.left, r.top, r.right, r.bottom, paint));
+
+    if (targetBitmap != null) {
+      ShadowBitmap shadowTargetBitmap = Shadows.shadowOf(targetBitmap);
+      shadowTargetBitmap.drawRect(r, paint);
+    }
+  }
+
+  @Implementation
+  protected void drawRoundRect(RectF rect, float rx, float ry, Paint paint) {
+    roundRectPaintEvents.add(
+        new RoundRectPaintHistoryEvent(
+            rect.left, rect.top, rect.right, rect.bottom, rx, ry, paint));
+  }
+
+  @Implementation
+  protected void drawLine(float startX, float startY, float stopX, float stopY, Paint paint) {
+    linePaintEvents.add(new LinePaintHistoryEvent(startX, startY, stopX, stopY, paint));
+  }
+
+  @Implementation
+  protected void drawOval(RectF oval, Paint paint) {
+    ovalPaintEvents.add(new OvalPaintHistoryEvent(oval, paint));
+  }
+
+  private void describeBitmap(Bitmap bitmap, Paint paint) {
+    separateLines();
+
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    appendDescription(shadowBitmap.getDescription());
+
+    if (paint != null) {
+      ColorFilter colorFilter = paint.getColorFilter();
+      if (colorFilter != null) {
+        appendDescription(" with " + colorFilter.getClass().getSimpleName());
+      }
+    }
+  }
+
+  private void separateLines() {
+    if (getDescription().length() != 0) {
+      appendDescription("\n");
+    }
+  }
+
+  public int getPathPaintHistoryCount() {
+    return pathPaintEvents.size();
+  }
+
+  public int getCirclePaintHistoryCount() {
+    return circlePaintEvents.size();
+  }
+
+  public int getArcPaintHistoryCount() {
+    return arcPaintEvents.size();
+  }
+
+  public boolean hasDrawnPath() {
+    return getPathPaintHistoryCount() > 0;
+  }
+
+  public boolean hasDrawnCircle() {
+    return circlePaintEvents.size() > 0;
+  }
+
+  public Paint getDrawnPathPaint(int i) {
+    return pathPaintEvents.get(i).pathPaint;
+  }
+
+  public Path getDrawnPath(int i) {
+    return pathPaintEvents.get(i).drawnPath;
+  }
+
+  public CirclePaintHistoryEvent getDrawnCircle(int i) {
+    return circlePaintEvents.get(i);
+  }
+
+  public ArcPaintHistoryEvent getDrawnArc(int i) {
+    return arcPaintEvents.get(i);
+  }
+
+  public void resetCanvasHistory() {
+    drawnTextEventHistory.clear();
+    pathPaintEvents.clear();
+    circlePaintEvents.clear();
+    rectPaintEvents.clear();
+    roundRectPaintEvents.clear();
+    linePaintEvents.clear();
+    ovalPaintEvents.clear();
+    ShadowBitmap shadowBitmap = Shadow.extract(targetBitmap);
+    shadowBitmap.setDescription("");
+  }
+
+  public Paint getDrawnPaint() {
+    return drawnPaint;
+  }
+
+  public void setHeight(int height) {
+    this.height = height;
+  }
+
+  public void setWidth(int width) {
+    this.width = width;
+  }
+
+  @Implementation
+  protected int getWidth() {
+    if (width == 0) {
+      return targetBitmap.getWidth();
+    }
+    return width;
+  }
+
+  @Implementation
+  protected int getHeight() {
+    if (height == 0) {
+      return targetBitmap.getHeight();
+    }
+    return height;
+  }
+
+  @Implementation
+  protected boolean getClipBounds(Rect bounds) {
+    Preconditions.checkNotNull(bounds);
+    if (targetBitmap == null) {
+      return false;
+    }
+    bounds.set(0, 0, targetBitmap.getWidth(), targetBitmap.getHeight());
+    return !bounds.isEmpty();
+  }
+
+  public TextHistoryEvent getDrawnTextEvent(int i) {
+    return drawnTextEventHistory.get(i);
+  }
+
+  public int getTextHistoryCount() {
+    return drawnTextEventHistory.size();
+  }
+
+  public RectPaintHistoryEvent getDrawnRect(int i) {
+    return rectPaintEvents.get(i);
+  }
+
+  public RectPaintHistoryEvent getLastDrawnRect() {
+    return rectPaintEvents.get(rectPaintEvents.size() - 1);
+  }
+
+  public int getRectPaintHistoryCount() {
+    return rectPaintEvents.size();
+  }
+
+  public RoundRectPaintHistoryEvent getDrawnRoundRect(int i) {
+    return roundRectPaintEvents.get(i);
+  }
+
+  public RoundRectPaintHistoryEvent getLastDrawnRoundRect() {
+    return roundRectPaintEvents.get(roundRectPaintEvents.size() - 1);
+  }
+
+  public int getRoundRectPaintHistoryCount() {
+    return roundRectPaintEvents.size();
+  }
+
+  public LinePaintHistoryEvent getDrawnLine(int i) {
+    return linePaintEvents.get(i);
+  }
+
+  public int getLinePaintHistoryCount() {
+    return linePaintEvents.size();
+  }
+
+  public int getOvalPaintHistoryCount() {
+    return ovalPaintEvents.size();
+  }
+
+  public OvalPaintHistoryEvent getDrawnOval(int i) {
+    return ovalPaintEvents.get(i);
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected int save() {
+    return getNativeCanvas().save();
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected void restore() {
+    getNativeCanvas().restore();
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected int getSaveCount() {
+    return getNativeCanvas().getSaveCount();
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected void restoreToCount(int saveCount) {
+    getNativeCanvas().restoreToCount(saveCount);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void release() {
+    nativeObjectRegistry.unregister(getNativeId());
+    canvasReflector.release();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int initRaster(int bitmapHandle) {
+    return (int) nativeObjectRegistry.register(new NativeCanvas());
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = LOLLIPOP_MR1)
+  protected static long initRaster(long bitmapHandle) {
+    return nativeObjectRegistry.register(new NativeCanvas());
+  }
+
+  @Implementation(minSdk = M, maxSdk = N_MR1)
+  protected static long initRaster(Bitmap bitmap) {
+    return nativeObjectRegistry.register(new NativeCanvas());
+  }
+
+  @Implementation(minSdk = O, maxSdk = P)
+  protected static long nInitRaster(Bitmap bitmap) {
+    return nativeObjectRegistry.register(new NativeCanvas());
+  }
+
+  @Implementation(minSdk = Q)
+  protected static long nInitRaster(long bitmapHandle) {
+    return nativeObjectRegistry.register(new NativeCanvas());
+  }
+
+  @Implementation(minSdk = O)
+  protected static int nGetSaveCount(long canvasHandle) {
+    return nativeObjectRegistry.getNativeObject(canvasHandle).getSaveCount();
+  }
+
+  @Implementation(minSdk = O)
+  protected static int nSave(long canvasHandle, int saveFlags) {
+    return nativeObjectRegistry.getNativeObject(canvasHandle).save();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int native_saveLayer(int nativeCanvas, RectF bounds, int paint, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int native_saveLayer(
+      int nativeCanvas, float l, float t, float r, float b, int paint, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected static int native_saveLayer(
+      long nativeCanvas, float l, float t, float r, float b, long nativePaint, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = O, maxSdk = R)
+  protected static int nSaveLayer(
+      long nativeCanvas, float l, float t, float r, float b, long nativePaint, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = S)
+  protected static int nSaveLayer(
+      long nativeCanvas, float l, float t, float r, float b, long nativePaint) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int native_saveLayerAlpha(
+      int nativeCanvas, RectF bounds, int alpha, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int native_saveLayerAlpha(
+      int nativeCanvas, float l, float t, float r, float b, int alpha, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected static int native_saveLayerAlpha(
+      long nativeCanvas, float l, float t, float r, float b, int alpha, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = O, maxSdk = R)
+  protected static int nSaveLayerAlpha(
+      long nativeCanvas, float l, float t, float r, float b, int alpha, int layerFlags) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = S)
+  protected static int nSaveLayerAlpha(
+      long nativeCanvas, float l, float t, float r, float b, int alpha) {
+    return nativeObjectRegistry.getNativeObject(nativeCanvas).save();
+  }
+
+  @Implementation(minSdk = O)
+  protected static boolean nRestore(long canvasHandle) {
+    return nativeObjectRegistry.getNativeObject(canvasHandle).restore();
+  }
+
+  @Implementation(minSdk = O)
+  protected static void nRestoreToCount(long canvasHandle, int saveCount) {
+    nativeObjectRegistry.getNativeObject(canvasHandle).restoreToCount(saveCount);
+  }
+
+  @Resetter
+  public static void reset() {
+    nativeObjectRegistry.clear();
+  }
+
+  public static class LinePaintHistoryEvent {
+    public Paint paint;
+    public float startX;
+    public float startY;
+    public float stopX;
+    public float stopY;
+
+    private LinePaintHistoryEvent(
+        float startX, float startY, float stopX, float stopY, Paint paint) {
+      this.paint = new Paint(paint);
+      this.paint.setColor(paint.getColor());
+      this.paint.setStrokeWidth(paint.getStrokeWidth());
+      this.startX = startX;
+      this.startY = startY;
+      this.stopX = stopX;
+      this.stopY = stopY;
+    }
+  }
+
+  public static class OvalPaintHistoryEvent {
+    public final RectF oval;
+    public final Paint paint;
+
+    private OvalPaintHistoryEvent(RectF oval, Paint paint) {
+      this.oval = new RectF(oval);
+      this.paint = new Paint(paint);
+      this.paint.setColor(paint.getColor());
+      this.paint.setStrokeWidth(paint.getStrokeWidth());
+    }
+  }
+
+  public static class RectPaintHistoryEvent {
+    public final Paint paint;
+    public final RectF rect;
+    public final float left;
+    public final float top;
+    public final float right;
+    public final float bottom;
+
+    private RectPaintHistoryEvent(float left, float top, float right, float bottom, Paint paint) {
+      this.rect = new RectF(left, top, right, bottom);
+      this.paint = new Paint(paint);
+      this.paint.setColor(paint.getColor());
+      this.paint.setStrokeWidth(paint.getStrokeWidth());
+      this.paint.setTextSize(paint.getTextSize());
+      this.paint.setStyle(paint.getStyle());
+      this.left = left;
+      this.top = top;
+      this.right = right;
+      this.bottom = bottom;
+    }
+  }
+
+  /** Captures round rectangle drawing events */
+  public static class RoundRectPaintHistoryEvent {
+    public final Paint paint;
+    public final RectF rect;
+    public final float left;
+    public final float top;
+    public final float right;
+    public final float bottom;
+    public final float rx;
+    public final float ry;
+
+    private RoundRectPaintHistoryEvent(
+        float left, float top, float right, float bottom, float rx, float ry, Paint paint) {
+      this.rect = new RectF(left, top, right, bottom);
+      this.paint = new Paint(paint);
+      this.paint.setColor(paint.getColor());
+      this.paint.setStrokeWidth(paint.getStrokeWidth());
+      this.paint.setTextSize(paint.getTextSize());
+      this.paint.setStyle(paint.getStyle());
+      this.left = left;
+      this.top = top;
+      this.right = right;
+      this.bottom = bottom;
+      this.rx = rx;
+      this.ry = ry;
+    }
+  }
+
+  private static class PathPaintHistoryEvent {
+    private final Path drawnPath;
+    private final Paint pathPaint;
+
+    PathPaintHistoryEvent(Path drawnPath, Paint pathPaint) {
+      this.drawnPath = drawnPath;
+      this.pathPaint = pathPaint;
+    }
+  }
+
+  public static class CirclePaintHistoryEvent {
+    public final float centerX;
+    public final float centerY;
+    public final float radius;
+    public final Paint paint;
+
+    private CirclePaintHistoryEvent(float centerX, float centerY, float radius, Paint paint) {
+      this.centerX = centerX;
+      this.centerY = centerY;
+      this.radius = radius;
+      this.paint = paint;
+    }
+  }
+
+  public static class ArcPaintHistoryEvent {
+    public final RectF oval;
+    public final float startAngle;
+    public final float sweepAngle;
+    public final boolean useCenter;
+    public final Paint paint;
+
+    public ArcPaintHistoryEvent(
+        RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) {
+      this.oval = oval;
+      this.startAngle = startAngle;
+      this.sweepAngle = sweepAngle;
+      this.useCenter = useCenter;
+      this.paint = paint;
+    }
+  }
+
+  public static class TextHistoryEvent {
+    public final float x;
+    public final float y;
+    public final Paint paint;
+    public final String text;
+
+    private TextHistoryEvent(float x, float y, Paint paint, String text) {
+      this.x = x;
+      this.y = y;
+      this.paint = paint;
+      this.text = text;
+    }
+  }
+
+  @SuppressWarnings("MemberName")
+  @ForType(Canvas.class)
+  private interface CanvasReflector {
+    @Direct
+    void __constructor__(Bitmap bitmap);
+
+    @Direct
+    void release();
+  }
+
+  private static class NativeCanvas {
+    private int saveCount = 1;
+
+    int save() {
+      return saveCount++;
+    }
+
+    boolean restore() {
+      if (saveCount > 1) {
+        saveCount--;
+        return true;
+      } else {
+        return false;
+      }
+    }
+
+    int getSaveCount() {
+      return saveCount;
+    }
+
+    void restoreToCount(int saveCount) {
+      if (saveCount > 0) {
+        this.saveCount = saveCount;
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java
new file mode 100644
index 0000000..19762d1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import android.annotation.NonNull;
+import android.util.ArraySet;
+import android.view.accessibility.CaptioningManager;
+import android.view.accessibility.CaptioningManager.CaptioningChangeListener;
+import java.util.Locale;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link android.view.accessibility.CaptioningManager}. */
+@Implements(CaptioningManager.class)
+public class ShadowCaptioningManager {
+  private float fontScale = 1;
+  private boolean isEnabled = false;
+  @Nullable private Locale locale;
+
+  private final ArraySet<CaptioningChangeListener> listeners = new ArraySet<>();
+
+  /** Returns 1.0 as default or the most recent value passed to {@link #setFontScale()} */
+  @Implementation(minSdk = 19)
+  protected float getFontScale() {
+    return fontScale;
+  }
+
+  /** Sets the value to be returned by {@link CaptioningManager#getFontScale()} */
+  public void setFontScale(float fontScale) {
+    this.fontScale = fontScale;
+
+    for (CaptioningChangeListener captioningChangeListener : listeners) {
+      captioningChangeListener.onFontScaleChanged(fontScale);
+    }
+  }
+
+  /** Returns false or the most recent value passed to {@link #setEnabled(boolean)} */
+  @Implementation(minSdk = 19)
+  protected boolean isEnabled() {
+    return isEnabled;
+  }
+
+  /** Sets the value to be returned by {@link CaptioningManager#isEnabled()} */
+  public void setEnabled(boolean isEnabled) {
+    this.isEnabled = isEnabled;
+  }
+
+  @Implementation(minSdk = 19)
+  protected void addCaptioningChangeListener(@NonNull CaptioningChangeListener listener) {
+    listeners.add(listener);
+  }
+
+  @Implementation(minSdk = 19)
+  protected void removeCaptioningChangeListener(@NonNull CaptioningChangeListener listener) {
+    listeners.remove(listener);
+  }
+
+  /** Returns null or the most recent value passed to {@link #setLocale(Locale)} */
+  @Implementation(minSdk = 19)
+  @Nullable
+  protected Locale getLocale() {
+    return locale;
+  }
+
+  /** Sets the value to be returned by {@link CaptioningManager#getLocale()} */
+  public void setLocale(@Nullable Locale locale) {
+    this.locale = locale;
+
+    for (CaptioningChangeListener captioningChangeListener : listeners) {
+      captioningChangeListener.onLocaleChanged(locale);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptureRequestBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptureRequestBuilder.java
new file mode 100644
index 0000000..2d6e162
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptureRequestBuilder.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.CaptureRequest.Key;
+import android.os.Build.VERSION_CODES;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow class for {@link CaptureRequest.Builder}. */
+@Implements(value = CaptureRequest.Builder.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowCaptureRequestBuilder {
+  private final Map<Key<?>, Object> characteristics = Collections.synchronizedMap(new HashMap<>());
+
+  /**
+   * Original implementation would store its state in a local CameraMetadataNative object. Trying
+   * to set these values causes issues while testing as that starts to involve native code. We write
+   * to a managed map stored in the shadow instead.
+   */
+  @Implementation
+  protected <T> void set(CaptureRequest.Key<T> key, T value) {
+    characteristics.put(key, value);
+  }
+
+  /**
+   * Original implementation would store its state in a local CameraMetadataNative object. Instead,
+   * we are extracting the data from a managed map stored in the shadow.
+   */
+  @SuppressWarnings("unchecked")
+  @Implementation
+  protected <T> T get(CaptureRequest.Key<T> key) {
+    return (T) characteristics.get(key);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptureResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptureResult.java
new file mode 100644
index 0000000..239a719
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptureResult.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import android.annotation.Nullable;
+import android.hardware.camera2.CaptureResult;
+import android.hardware.camera2.CaptureResult.Key;
+import android.os.Build.VERSION_CODES;
+import com.google.common.base.Preconditions;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow of {@link CaptureResult}. */
+@Implements(value = CaptureResult.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowCaptureResult {
+
+  private final Map<Key<?>, Object> resultsKeyToValue = new HashMap<>();
+
+  /** Convenience method which returns a new instance of {@link CaptureResult}. */
+  public static CaptureResult newCaptureResult() {
+    return ReflectionHelpers.callConstructor(CaptureResult.class);
+  }
+
+  /**
+   * Obtain a property of the CaptureResult.
+   */
+  @Implementation
+  @Nullable
+  @SuppressWarnings("unchecked")
+  protected <T> T get(Key<T> key) {
+    return (T) resultsKeyToValue.get(key);
+  }
+
+  /**
+   * Sets the value for a given key.
+   *
+   * @throws IllegalArgumentException if there's an existing value for the key.
+   */
+  public <T> void set(Key<T> key, T value) {
+    Preconditions.checkArgument(!resultsKeyToValue.containsKey(key));
+    resultsKeyToValue.put(key, value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCardEmulation.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCardEmulation.java
new file mode 100644
index 0000000..2bd28da
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCardEmulation.java
@@ -0,0 +1,107 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.Nullable;
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Context;
+import android.nfc.INfcCardEmulation;
+import android.nfc.cardemulation.CardEmulation;
+import android.os.Build;
+import android.provider.Settings;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Shadow implementation of {@link CardEmulation}. */
+@Implements(CardEmulation.class)
+public class ShadowCardEmulation {
+
+  private static Map<String, ComponentName> defaultServiceForCategoryMap = new HashMap<>();
+  private static ComponentName preferredService = null;
+
+  @RealObject CardEmulation cardEmulation;
+
+  @Implementation(minSdk = Build.VERSION_CODES.KITKAT)
+  public boolean isDefaultServiceForCategory(ComponentName service, String category) {
+    return service.equals(defaultServiceForCategoryMap.get(category));
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.LOLLIPOP)
+  public boolean setPreferredService(Activity activity, ComponentName service) {
+    preferredService = service;
+    return true;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.LOLLIPOP)
+  public boolean unsetPreferredService(Activity activity) {
+    preferredService = null;
+    return true;
+  }
+
+  /**
+   * Modifies the behavior of {@link #isDefaultServiceForCategory(ComponentName, String)} to return
+   * {@code true} for the given inputs.
+   */
+  public static void setDefaultServiceForCategory(ComponentName service, String category) {
+    defaultServiceForCategoryMap.put(category, service);
+  }
+
+  /**
+   * Utility function that returns the latest {@code ComponentName} captured when calling {@link
+   * #setPreferredService(Activity, ComponentName)}.
+   */
+  @Nullable
+  public static ComponentName getPreferredService() {
+    return preferredService;
+  }
+
+  /**
+   * Modifies the behavior of {@code categoryAllowsForegroundPreference(String)} to return the given
+   * {@code value} for the {@code CardEmulation.CATEGORY_PAYMENT}.
+   */
+  public static void setCategoryPaymentAllowsForegroundPreference(boolean value) {
+    Settings.Secure.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Secure.NFC_PAYMENT_FOREGROUND,
+        value ? 1 : 0);
+  }
+
+  @Resetter
+  public static void reset() {
+    defaultServiceForCategoryMap = new HashMap<>();
+    preferredService = null;
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.KITKAT) {
+      CardEmulationReflector reflector = reflector(CardEmulationReflector.class);
+      reflector.setIsInitialized(false);
+      reflector.setService(null);
+      Map<Context, CardEmulation> cardEmus = reflector.getCardEmus();
+      if (cardEmus != null) {
+        cardEmus.clear();
+      }
+    }
+  }
+
+  @ForType(CardEmulation.class)
+  interface CardEmulationReflector {
+    @Static
+    @Accessor("sIsInitialized")
+    void setIsInitialized(boolean isInitialized);
+
+    @Static
+    @Accessor("sService")
+    void setService(INfcCardEmulation service);
+
+    @Static
+    @Accessor("sCardEmus")
+    Map<Context, CardEmulation> getCardEmus();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCarrierConfigManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCarrierConfigManager.java
new file mode 100644
index 0000000..8a17212
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCarrierConfigManager.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.annotation.Nullable;
+import android.os.PersistableBundle;
+import android.telephony.CarrierConfigManager;
+import java.util.HashMap;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = CarrierConfigManager.class, minSdk = M)
+public class ShadowCarrierConfigManager {
+
+  private final HashMap<Integer, PersistableBundle> bundles = new HashMap<>();
+  private final HashMap<Integer, PersistableBundle> overrideBundles = new HashMap<>();
+
+  /**
+   * Returns {@link android.os.PersistableBundle} previously set by {@link #overrideConfig} or
+   * {@link #setConfigForSubId(int, PersistableBundle)}, or default values for an invalid {@code
+   * subId}.
+   */
+  @Implementation
+  public PersistableBundle getConfigForSubId(int subId) {
+    if (overrideBundles.containsKey(subId) && overrideBundles.get(subId) != null) {
+      return overrideBundles.get(subId);
+    }
+    if (bundles.containsKey(subId)) {
+      return bundles.get(subId);
+    }
+    return new PersistableBundle();
+  }
+
+  /**
+   * Sets that the {@code config} PersistableBundle for a particular {@code subId}; controls the
+   * return value of {@link CarrierConfigManager#getConfigForSubId()}.
+   */
+  public void setConfigForSubId(int subId, PersistableBundle config) {
+    bundles.put(subId, config);
+  }
+
+  /**
+   * Overrides the carrier config of the provided subscription ID with the provided values.
+   *
+   * <p>This method will NOT check if {@code overrideValues} contains valid values for specified
+   * config keys.
+   */
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  protected void overrideConfig(int subId, @Nullable PersistableBundle config) {
+    overrideBundles.put(subId, config);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java
new file mode 100644
index 0000000..cf1aac2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java
@@ -0,0 +1,175 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.base.Preconditions.checkState;
+import static org.robolectric.shadows.ShadowLooper.looperMode;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.Choreographer;
+import android.view.Choreographer.FrameCallback;
+import java.time.Duration;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.PerfStatsCollector;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * The shadow API for {@link android.view.Choreographer}.
+ *
+ * <p>Different shadow implementations will be used depending on the current {@link LooperMode}. See
+ * {@link ShadowLegacyChoreographer} and {@link ShadowPausedChoreographer} for details.
+ */
+@Implements(value = Choreographer.class, shadowPicker = ShadowChoreographer.Picker.class)
+public abstract class ShadowChoreographer {
+
+  @RealObject Choreographer realObject;
+  private ChoreographerReflector reflector;
+
+  private static volatile boolean isPaused = false;
+  private static volatile Duration frameDelay = Duration.ofMillis(1);
+
+  public static class Picker extends LooperShadowPicker<ShadowChoreographer> {
+
+    public Picker() {
+      super(ShadowLegacyChoreographer.class, ShadowPausedChoreographer.class);
+    }
+  }
+
+  /**
+   * Sets the delay between each frame. Note that the frames use the {@link ShadowSystemClock} and
+   * so have the same fidelity, when using the paused looper mode (which is the only mode supported
+   * by {@code ShadowDisplayEventReceiver}) the clock has millisecond fidelity.
+   *
+   * <p>Reasonable delays may be 15ms (approximating 60fps ~16.6ms), 10ms (approximating 90fps
+   * ~11.1ms), and 30ms (approximating 30fps ~33.3ms). Choosing too small of a frame delay may
+   * increase runtime as animation frames will have more steps.
+   *
+   * <p>Only works in {@link LooperMode.Mode#PAUSED} looper mode.
+   */
+  public static void setFrameDelay(Duration delay) {
+    checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED);
+    frameDelay = delay;
+  }
+
+  /** See {@link #setFrameDelay(Duration)}. */
+  public static Duration getFrameDelay() {
+    checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED);
+    return frameDelay;
+  }
+
+  /**
+   * Sets whether posting a frame should auto advance the clock or not. When paused the clock is not
+   * auto advanced, when unpaused the clock is advanced by the frame delay every time a frame
+   * callback is added. The default is not paused.
+   *
+   * <p>Only works in {@link LooperMode.Mode#PAUSED} looper mode.
+   */
+  public static void setPaused(boolean paused) {
+    checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED);
+    isPaused = paused;
+  }
+
+  /** See {@link #setPaused(boolean)}. */
+  public static boolean isPaused() {
+    checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED);
+    return isPaused;
+  }
+
+  /**
+   * Allows application to specify a fixed amount of delay when {@link #postCallback(int, Runnable,
+   * Object)} is invoked. The default delay value is 0. This can be used to avoid infinite animation
+   * tasks to be spawned when the Robolectric {@link org.robolectric.util.Scheduler} is in {@link
+   * org.robolectric.util.Scheduler.IdleState#PAUSED} mode.
+   *
+   * <p>Only supported in {@link LooperMode.Mode#LEGACY}
+   *
+   * @deprecated Use the {@link Mode#PAUSED} looper instead.
+   */
+  @Deprecated
+  public static void setPostCallbackDelay(int delayMillis) {
+    checkState(ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper must be %s", Mode.LEGACY);
+    ShadowLegacyChoreographer.setPostCallbackDelay(delayMillis);
+  }
+
+  /**
+   * Allows application to specify a fixed amount of delay when {@link
+   * #postFrameCallback(FrameCallback)} is invoked. The default delay value is 0. This can be used
+   * to avoid infinite animation tasks to be spawned when in LooperMode PAUSED or {@link
+   * org.robolectric.util.Scheduler.IdleState#PAUSED} and displaying an animation.
+   *
+   * @deprecated Use the {@link Mode#PAUSED} looper and {@link #setPaused(boolean)} and {@link
+   *     #setFrameDelay(Duration)} to configure the vsync event behavior.
+   */
+  @Deprecated
+  public static void setPostFrameCallbackDelay(int delayMillis) {
+    if (looperMode() == Mode.PAUSED) {
+      setPaused(delayMillis != 0);
+      setFrameDelay(Duration.ofMillis(delayMillis == 0 ? 1 : delayMillis));
+    } else {
+      ShadowLegacyChoreographer.setPostFrameCallbackDelay(delayMillis);
+    }
+  }
+
+  /**
+   * Return the current inter-frame interval.
+   *
+   * <p>Can only be used in {@link LooperMode.Mode#LEGACY}
+   *
+   * @return Inter-frame interval.
+   * @deprecated Use the {@link Mode#PAUSED} looper and {@link #getFrameDelay()} to configure the
+   *     frame delay.
+   */
+  @Deprecated
+  public static long getFrameInterval() {
+    checkState(ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper must be %s", Mode.LEGACY);
+    return ShadowLegacyChoreographer.getFrameInterval();
+  }
+
+  /**
+   * Set the inter-frame interval used to advance the clock. By default, this is set to 1ms.
+   *
+   * <p>Only supported in {@link LooperMode.Mode#LEGACY}
+   *
+   * @param frameInterval Inter-frame interval.
+   * @deprecated Use the {@link Mode#PAUSED} looper and {@link #setFrameDelay(Duration)} to
+   *     configure the frame delay.
+   */
+  @Deprecated
+  public static void setFrameInterval(long frameInterval) {
+    checkState(ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper must be %s", Mode.LEGACY);
+    ShadowLegacyChoreographer.setFrameInterval(frameInterval);
+  }
+
+  @Implementation(maxSdk = R)
+  protected void doFrame(long frameTimeNanos, int frame) {
+    if (reflector == null) {
+      reflector = reflector(ChoreographerReflector.class, realObject);
+    }
+    PerfStatsCollector.getInstance()
+        .measure("doFrame", () -> reflector.doFrame(frameTimeNanos, frame));
+  }
+
+  @Resetter
+  public static void reset() {
+    isPaused = false;
+    frameDelay = Duration.ofMillis(1);
+  }
+
+  /** Accessor interface for {@link Choreographer}'s internals */
+  @ForType(Choreographer.class)
+  protected interface ChoreographerReflector {
+    @Accessor("sThreadInstance")
+    @Static
+    ThreadLocal<Choreographer> getThreadInstance();
+
+    @Direct
+    void doFrame(long frameTimeNanos, int frame);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowClipboardManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowClipboardManager.java
new file mode 100644
index 0000000..d2215ca
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowClipboardManager.java
@@ -0,0 +1,87 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.N;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ClipboardManager;
+import android.content.ClipboardManager.OnPrimaryClipChangedListener;
+import java.util.Collection;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings("UnusedDeclaration")
+@Implements(ClipboardManager.class)
+public class ShadowClipboardManager {
+  @RealObject private ClipboardManager realClipboardManager;
+  private final Collection<OnPrimaryClipChangedListener> listeners =
+      new CopyOnWriteArrayList<OnPrimaryClipChangedListener>();
+  private ClipData clip;
+
+  @Implementation
+  protected void setPrimaryClip(ClipData clip) {
+    if (getApiLevel() >= N) {
+      if (clip != null) {
+        clip.prepareToLeaveProcess(true);
+      }
+    } else if (getApiLevel() >= JELLY_BEAN_MR2) {
+      if (clip != null) {
+        ReflectionHelpers.callInstanceMethod(ClipData.class, clip, "prepareToLeaveProcess");
+      }
+    }
+
+    this.clip = clip;
+
+    for (OnPrimaryClipChangedListener listener : listeners) {
+      listener.onPrimaryClipChanged();
+    }
+  }
+
+  @Implementation
+  protected ClipData getPrimaryClip() {
+    return clip;
+  }
+
+  @Implementation
+  protected ClipDescription getPrimaryClipDescription() {
+    return clip == null ? null : clip.getDescription();
+  }
+
+  @Implementation
+  protected boolean hasPrimaryClip() {
+    return clip != null;
+  }
+
+  @Implementation
+  protected void addPrimaryClipChangedListener(OnPrimaryClipChangedListener listener) {
+    listeners.add(listener);
+  }
+
+  @Implementation
+  protected void removePrimaryClipChangedListener(OnPrimaryClipChangedListener listener) {
+    listeners.remove(listener);
+  }
+
+  @Implementation
+  protected void setText(CharSequence text) {
+    setPrimaryClip(ClipData.newPlainText(null, text));
+  }
+
+  @Implementation
+  protected boolean hasText() {
+    CharSequence text = reflector(ClipboardManagerReflector.class, realClipboardManager).getText();
+    return text != null && text.length() > 0;
+  }
+
+  @ForType(ClipboardManager.class)
+  interface ClipboardManagerReflector {
+    CharSequence getText();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCloseGuard.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCloseGuard.java
new file mode 100644
index 0000000..41085c8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCloseGuard.java
@@ -0,0 +1,110 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import dalvik.system.CloseGuard;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * Shadow for {@link CloseGuard}. {@code CloseGuardRule} can be used to easily verify all
+ * CloseGuards have been closed.
+ */
+@Implements(value = CloseGuard.class, isInAndroidSdk = false)
+public class ShadowCloseGuard {
+
+  private static final Set<CloseGuard> openCloseGuards =
+      Collections.synchronizedSet(new HashSet<>());
+  private static final Set<Throwable> warnedThrowables =
+      Collections.synchronizedSet(new HashSet<>());
+
+  @RealObject private CloseGuard realCloseGuard;
+  @ReflectorObject private CloseGuardReflector closeGuardReflector;
+
+  @Implementation
+  protected void open(String closer) {
+    closeGuardReflector.open(closer);
+    openCloseGuards.add(realCloseGuard);
+  }
+
+  @Implementation
+  protected void close() {
+    closeGuardReflector.close();
+    openCloseGuards.remove(realCloseGuard);
+  }
+
+  @Implementation
+  protected void warnIfOpen() {
+    closeGuardReflector.warnIfOpen();
+    if (openCloseGuards.contains(realCloseGuard)) {
+      warnedThrowables.add(createThrowableFromCloseGuard(realCloseGuard));
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    openCloseGuards.clear();
+    warnedThrowables.clear();
+  }
+
+  public static ArrayList<Throwable> getErrors() {
+    ArrayList<Throwable> errors = new ArrayList<>(openCloseGuards.size() + warnedThrowables.size());
+    for (CloseGuard closeGuard : openCloseGuards) {
+      errors.add(createThrowableFromCloseGuard(closeGuard));
+    }
+    errors.addAll(warnedThrowables);
+    return errors;
+  }
+
+  private static Throwable createThrowableFromCloseGuard(CloseGuard closeGuard) {
+    if (VERSION.SDK_INT >= VERSION_CODES.P) {
+      Object closerNameOrAllocationInfo =
+          reflector(CloseGuardReflector.class, closeGuard).getCloserNameOrAllocationInfo();
+      if (closerNameOrAllocationInfo instanceof Throwable) {
+        return (Throwable) closerNameOrAllocationInfo;
+      } else if (closerNameOrAllocationInfo instanceof String) {
+        return new Throwable((String) closerNameOrAllocationInfo);
+      }
+    } else {
+      Throwable allocationSite =
+          reflector(CloseGuardReflector.class, closeGuard).getAllocationSite();
+      if (allocationSite != null) {
+        return allocationSite;
+      }
+    }
+    return new Throwable("CloseGuard with no allocation info");
+  }
+
+  @ForType(CloseGuard.class)
+  interface CloseGuardReflector {
+
+    @Direct
+    void open(String closer);
+
+    @Direct
+    void close();
+
+    @Direct
+    void warnIfOpen();
+
+    // For API 29+
+    @Accessor("closerNameOrAllocationInfo")
+    Object getCloserNameOrAllocationInfo();
+
+    // For API <= 28
+    @Accessor("allocationSite")
+    Throwable getAllocationSite();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColor.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColor.java
new file mode 100644
index 0000000..89609cc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColor.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+import android.graphics.Color;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(Color.class)
+public class ShadowColor {
+  /**
+   * This is implemented in native code in the Android SDK.
+   *
+   * <p>Since HSV == HSB then the implementation from {@link java.awt.Color} can be used, with a
+   * small adjustment to the representation of the hue.
+   *
+   * <p>{@link java.awt.Color} represents hue as 0..1 (where 1 == 100% == 360 degrees), while {@link
+   * android.graphics.Color} represents hue as 0..360 degrees. The correct hue can be calculated by
+   * multiplying with 360.
+   *
+   * @param red Red component
+   * @param green Green component
+   * @param blue Blue component
+   * @param hsv Array to store HSV components
+   */
+  @Implementation
+  protected static void RGBToHSV(int red, int green, int blue, float hsv[]) {
+    java.awt.Color.RGBtoHSB(red, green, blue, hsv);
+    hsv[0] = hsv[0] * 360;
+  }
+
+  @Implementation
+  protected static int HSVToColor(int alpha, float hsv[]) {
+    int rgb = java.awt.Color.HSBtoRGB(hsv[0] / 360, hsv[1], hsv[2]);
+    return Color.argb(alpha, Color.red(rgb), Color.green(rgb), Color.blue(rgb));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColorDisplayManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColorDisplayManager.java
new file mode 100644
index 0000000..866db19
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColorDisplayManager.java
@@ -0,0 +1,119 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.Manifest;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.hardware.display.ColorDisplayManager.AutoMode;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(
+    className = "android.hardware.display.ColorDisplayManager",
+    isInAndroidSdk = false,
+    minSdk = Q)
+public class ShadowColorDisplayManager {
+
+  private boolean isNightDisplayActivated;
+  private int nightDisplayTemperature;
+  private int nightDisplayAutoMode;
+  private final Map<String, Integer> packagesToSaturation = new HashMap<>();
+
+  // Full saturation by default
+  private int saturationLevel = 100;
+  // No capabilities by default
+  private int transformCapabilities = 0x0;
+
+  @Implementation
+  protected void __constructor__() {
+    // Don't initialize ColorDisplayManagerInternal.
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected int getTransformCapabilities() {
+    return transformCapabilities;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected boolean setNightDisplayActivated(boolean activated) {
+    isNightDisplayActivated = activated;
+    return true;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected boolean isNightDisplayActivated() {
+    return isNightDisplayActivated;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected boolean setNightDisplayColorTemperature(int temperature) {
+    nightDisplayTemperature = temperature;
+    return true;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected int getNightDisplayColorTemperature() {
+    return nightDisplayTemperature;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected boolean setNightDisplayAutoMode(@AutoMode int autoMode) {
+    nightDisplayAutoMode = autoMode;
+    return true;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  @AutoMode
+  protected int getNightDisplayAutoMode() {
+    return nightDisplayAutoMode;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected boolean setSaturationLevel(int saturationLevel) {
+    this.saturationLevel = saturationLevel;
+    return true;
+  }
+
+  @Implementation
+  @SystemApi
+  @RequiresPermission(Manifest.permission.CONTROL_DISPLAY_COLOR_TRANSFORMS)
+  protected boolean setAppSaturationLevel(String packageName, int saturationLevel) {
+    packagesToSaturation.put(packageName, saturationLevel);
+    return true;
+  }
+
+  /** Sets the current transform capabilities. */
+  public boolean setTransformCapabilities(int transformCapabilities) {
+    this.transformCapabilities = transformCapabilities;
+    return true;
+  }
+
+  /** Returns the current display saturation level for the {@code packageName}. */
+  public int getAppSaturationLevel(String packageName) {
+    return packagesToSaturation.getOrDefault(packageName, 100);
+  }
+
+  /** Returns the current display saturation level. */
+  public int getSaturationLevel() {
+    return saturationLevel;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColorSpaceRgb.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColorSpaceRgb.java
new file mode 100644
index 0000000..ebf347c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowColorSpaceRgb.java
@@ -0,0 +1,17 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.graphics.ColorSpace;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link ColorSpace.Rgb}. */
+@Implements(value = ColorSpace.Rgb.class, minSdk = O)
+public class ShadowColorSpaceRgb {
+  @Implementation(minSdk = Q)
+  protected long getNativeInstance() {
+    return 1;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompanionDeviceManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompanionDeviceManager.java
new file mode 100644
index 0000000..7937487
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompanionDeviceManager.java
@@ -0,0 +1,250 @@
+package org.robolectric.shadows;
+
+import static java.util.stream.Collectors.toCollection;
+import static java.util.stream.Collectors.toList;
+
+import android.companion.AssociationInfo;
+import android.companion.AssociationRequest;
+import android.companion.CompanionDeviceManager;
+import android.content.ComponentName;
+import android.net.MacAddress;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import androidx.annotation.Nullable;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Ascii;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for CompanionDeviceManager. */
+@Implements(value = CompanionDeviceManager.class, minSdk = VERSION_CODES.O)
+public class ShadowCompanionDeviceManager {
+
+  protected final Set<RoboAssociationInfo> associations = new HashSet<>();
+  protected final Set<ComponentName> hasNotificationAccess = new HashSet<>();
+  protected ComponentName lastRequestedNotificationAccess;
+  protected AssociationRequest lastAssociationRequest;
+  protected CompanionDeviceManager.Callback lastAssociationCallback;
+
+  @Implementation
+  @SuppressWarnings("JdkCollectors") // toImmutableList is only supported in Java 8+.
+  protected List<String> getAssociations() {
+    return ImmutableList.copyOf(
+        associations.stream().map(RoboAssociationInfo::deviceMacAddress).collect(toList()));
+  }
+
+  public void addAssociation(String newAssociation) {
+    associations.add(RoboAssociationInfo.builder().setDeviceMacAddress(newAssociation).build());
+  }
+
+  public void addAssociation(AssociationInfo info) {
+    associations.add(createShadowAssociationInfo(info));
+  }
+
+  @Implementation
+  protected void disassociate(String deviceMacAddress) {
+    RoboAssociationInfo associationInfo =
+        associations.stream()
+            .filter(
+                association ->
+                    Ascii.equalsIgnoreCase(deviceMacAddress, association.deviceMacAddress()))
+            .findFirst()
+            .orElseThrow(() -> new IllegalArgumentException("Association does not exist"));
+    associations.remove(associationInfo);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected void disassociate(int associationId) {
+    RoboAssociationInfo associationInfo =
+        associations.stream()
+            .filter(association -> associationId == association.id())
+            .findFirst()
+            .orElseThrow(() -> new IllegalArgumentException("Association does not exist"));
+    associations.remove(associationInfo);
+  }
+
+  @Implementation
+  protected boolean hasNotificationAccess(ComponentName component) {
+    checkHasAssociation();
+    return hasNotificationAccess.contains(component);
+  }
+
+  public void setNotificationAccess(ComponentName component, boolean hasAccess) {
+    if (hasAccess) {
+      hasNotificationAccess.add(component);
+    } else {
+      hasNotificationAccess.remove(component);
+    }
+  }
+
+  @Implementation
+  protected void requestNotificationAccess(ComponentName component) {
+    checkHasAssociation();
+    lastRequestedNotificationAccess = component;
+  }
+
+  @Implementation
+  protected void associate(
+      AssociationRequest request, CompanionDeviceManager.Callback callback, Handler handler) {
+    lastAssociationRequest = request;
+    lastAssociationCallback = callback;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected void associate(
+      AssociationRequest request, Executor executor, CompanionDeviceManager.Callback callback) {
+    associate(request, callback, /* handler= */ null);
+  }
+
+  public AssociationRequest getLastAssociationRequest() {
+    return lastAssociationRequest;
+  }
+
+  public CompanionDeviceManager.Callback getLastAssociationCallback() {
+    return lastAssociationCallback;
+  }
+
+  public ComponentName getLastRequestedNotificationAccess() {
+    return lastRequestedNotificationAccess;
+  }
+
+  private void checkHasAssociation() {
+    if (associations.isEmpty()) {
+      throw new IllegalStateException("App must have an association before calling this API");
+    }
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected List<AssociationInfo> getMyAssociations() {
+    return this.associations.stream()
+        .map(this::createAssociationInfo)
+        .collect(toCollection(ArrayList::new));
+  }
+
+  /** Convert {@link RoboAssociationInfo} to actual {@link AssociationInfo}. */
+  private AssociationInfo createAssociationInfo(RoboAssociationInfo info) {
+    return new AssociationInfo(
+        info.id(),
+        info.userId(),
+        info.packageName(),
+        MacAddress.fromString(info.deviceMacAddress()),
+        info.displayName(),
+        info.deviceProfile(),
+        info.selfManaged(),
+        info.notifyOnDeviceNearby(),
+        info.timeApprovedMs(),
+        info.lastTimeConnectedMs());
+  }
+
+  private RoboAssociationInfo createShadowAssociationInfo(AssociationInfo info) {
+    return RoboAssociationInfo.create(
+        info.getId(),
+        info.getUserId(),
+        info.getPackageName(),
+        info.getDeviceMacAddress().toString(),
+        info.getDisplayName(),
+        info.getDeviceProfile(),
+        info.isSelfManaged(),
+        info.isNotifyOnDeviceNearby(),
+        info.getTimeApprovedMs(),
+        info.getLastTimeConnectedMs());
+  }
+
+  /**
+   * This is a copy of frameworks/base/core/java/android/companion/AssociationInfo.java to store
+   * full AssociationInfo data without breaking existing Android test dependencies.
+   */
+  @AutoValue
+  abstract static class RoboAssociationInfo {
+    public abstract int id();
+
+    public abstract int userId();
+
+    @Nullable
+    public abstract String packageName();
+
+    @Nullable
+    public abstract String deviceMacAddress();
+
+    @Nullable
+    public abstract CharSequence displayName();
+
+    @Nullable
+    public abstract String deviceProfile();
+
+    public abstract boolean selfManaged();
+
+    public abstract boolean notifyOnDeviceNearby();
+
+    public abstract long timeApprovedMs();
+
+    public abstract long lastTimeConnectedMs();
+
+    public static Builder builder() {
+      return new AutoValue_ShadowCompanionDeviceManager_RoboAssociationInfo.Builder()
+          .setId(1)
+          .setUserId(1)
+          .setSelfManaged(false)
+          .setNotifyOnDeviceNearby(false)
+          .setTimeApprovedMs(0)
+          .setLastTimeConnectedMs(0);
+    }
+
+    public static RoboAssociationInfo create(
+        int id,
+        int userId,
+        String packageName,
+        String deviceMacAddress,
+        CharSequence displayName,
+        String deviceProfile,
+        boolean selfManaged,
+        boolean notifyOnDeviceNearby,
+        long timeApprovedMs,
+        long lastTimeConnectedMs) {
+      return RoboAssociationInfo.builder()
+          .setId(id)
+          .setUserId(userId)
+          .setPackageName(packageName)
+          .setDeviceMacAddress(deviceMacAddress)
+          .setDisplayName(displayName)
+          .setDeviceProfile(deviceProfile)
+          .setSelfManaged(selfManaged)
+          .setNotifyOnDeviceNearby(notifyOnDeviceNearby)
+          .setTimeApprovedMs(timeApprovedMs)
+          .setLastTimeConnectedMs(lastTimeConnectedMs)
+          .build();
+    }
+
+    /** Builder for {@link AssociationInfo}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder setId(int id);
+
+      public abstract Builder setUserId(int userId);
+
+      public abstract Builder setPackageName(String packageName);
+
+      public abstract Builder setDeviceMacAddress(String deviceMacAddress);
+
+      public abstract Builder setDisplayName(CharSequence displayName);
+
+      public abstract Builder setDeviceProfile(String deviceProfile);
+
+      public abstract Builder setSelfManaged(boolean selfManaged);
+
+      public abstract Builder setNotifyOnDeviceNearby(boolean notifyOnDeviceNearby);
+
+      public abstract Builder setTimeApprovedMs(long timeApprovedMs);
+
+      public abstract Builder setLastTimeConnectedMs(long lastTimeConnectedMs);
+
+      public abstract RoboAssociationInfo build();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompatibility.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompatibility.java
new file mode 100644
index 0000000..1dd6308
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompatibility.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.compat.Compatibility;
+import android.compat.annotation.ChangeId;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * Robolectric shadow to disable CALL_ACTIVITY_RESULT_BEFORE_RESUME using Compatibility's
+ * isChangeEnabled.
+ */
+@Implements(value = Compatibility.class, isInAndroidSdk = false)
+public class ShadowCompatibility {
+
+  private static final long CALL_ACTIVITY_RESULT_BEFORE_RESUME = 78294732L;
+
+  @RealObject protected static Compatibility realCompatibility;
+
+  @Implementation(minSdk = VERSION_CODES.S_V2)
+  protected static boolean isChangeEnabled(@ChangeId long changeId) {
+    if (changeId == CALL_ACTIVITY_RESULT_BEFORE_RESUME) {
+      return false;
+    }
+    return reflector(CompatibilityReflector.class).isChangeEnabled(changeId);
+  }
+
+  /** Reflector interface for {@link Compatibility}'s isChangeEnabled function. */
+  @ForType(Compatibility.class)
+  private interface CompatibilityReflector {
+
+    @Direct
+    @Static
+    boolean isChangeEnabled(@ChangeId long changeId);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompoundButton.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompoundButton.java
new file mode 100644
index 0000000..18b606a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCompoundButton.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION_CODES;
+import android.widget.CompoundButton;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(CompoundButton.class)
+public class ShadowCompoundButton extends ShadowTextView {
+  @RealObject CompoundButton realObject;
+  private int buttonDrawableId;
+  private Drawable buttonDrawable;
+
+  @Implementation
+  protected void setButtonDrawable(int buttonDrawableId) {
+    this.buttonDrawableId = buttonDrawableId;
+    reflector(CompoundButtonReflector.class, realObject).setButtonDrawable(buttonDrawableId);
+  }
+
+  @Implementation
+  protected void setButtonDrawable(Drawable buttonDrawable) {
+    this.buttonDrawable = buttonDrawable;
+    reflector(CompoundButtonReflector.class, realObject).setButtonDrawable(buttonDrawable);
+  }
+
+  public int getButtonDrawableId() {
+    return buttonDrawableId;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M)
+  public Drawable getButtonDrawable() {
+    return buttonDrawable;
+  }
+
+  @ForType(CompoundButton.class)
+  interface CompoundButtonReflector {
+
+    @Direct
+    void setButtonDrawable(int buttonDrawableId);
+
+    @Direct
+    void setButtonDrawable(Drawable buttonDrawable);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowConnection.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowConnection.java
new file mode 100644
index 0000000..d69882e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowConnection.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N_MR1;
+
+import android.os.Bundle;
+import android.telecom.Connection;
+import java.util.Optional;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow for {@link Connection} that represents a phone call or connection to a remote endpoint
+ * that carries voice and/or video traffic.
+ */
+@Implements(value = Connection.class, minSdk = N_MR1)
+public class ShadowConnection {
+  private String mostRecentEvent;
+
+  /** Records the event sent through sendConnectionEvent to be accessed later by tests. */
+  @Implementation
+  protected void sendConnectionEvent(String event, Bundle extras) {
+    this.mostRecentEvent = event;
+  }
+
+  public Optional<String> getLastConnectionEvent() {
+    return Optional.ofNullable(mostRecentEvent);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowConnectivityManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowConnectivityManager.java
new file mode 100644
index 0000000..a994fa2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowConnectivityManager.java
@@ -0,0 +1,467 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.OnNetworkActiveListener;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.net.NetworkRequest;
+import android.net.ProxyInfo;
+import android.os.Handler;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(ConnectivityManager.class)
+public class ShadowConnectivityManager {
+
+  // Package-private for tests.
+  static final int NET_ID_WIFI = ConnectivityManager.TYPE_WIFI;
+  static final int NET_ID_MOBILE = ConnectivityManager.TYPE_MOBILE;
+
+  private NetworkInfo activeNetworkInfo;
+  private boolean backgroundDataSetting;
+  private int restrictBackgroundStatus = ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
+  private int networkPreference = ConnectivityManager.DEFAULT_NETWORK_PREFERENCE;
+  private final Map<Integer, NetworkInfo> networkTypeToNetworkInfo = new HashMap<>();
+
+  private HashSet<ConnectivityManager.NetworkCallback> networkCallbacks = new HashSet<>();
+  private final Map<Integer, Network> netIdToNetwork = new HashMap<>();
+  private final Map<Integer, NetworkInfo> netIdToNetworkInfo = new HashMap<>();
+  private Network processBoundNetwork;
+  private boolean defaultNetworkActive;
+  private HashSet<ConnectivityManager.OnNetworkActiveListener> onNetworkActiveListeners =
+      new HashSet<>();
+  private Map<Network, Boolean> reportedNetworkConnectivity = new HashMap<>();
+  private Map<Network, NetworkCapabilities> networkCapabilitiesMap = new HashMap<>();
+  private String captivePortalServerUrl = "http://10.0.0.2";
+  private final Map<Network, LinkProperties> linkPropertiesMap = new HashMap<>();
+  private final Map<Network, ProxyInfo> proxyInfoMap = new HashMap<>();
+
+  public ShadowConnectivityManager() {
+    NetworkInfo wifi = ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.DISCONNECTED,
+        ConnectivityManager.TYPE_WIFI, 0, true, false);
+    networkTypeToNetworkInfo.put(ConnectivityManager.TYPE_WIFI, wifi);
+
+    NetworkInfo mobile = ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED,
+        ConnectivityManager.TYPE_MOBILE, ConnectivityManager.TYPE_MOBILE_MMS, true, true);
+    networkTypeToNetworkInfo.put(ConnectivityManager.TYPE_MOBILE, mobile);
+
+    this.activeNetworkInfo = mobile;
+
+    if (getApiLevel() >= LOLLIPOP) {
+      netIdToNetwork.put(NET_ID_WIFI, ShadowNetwork.newInstance(NET_ID_WIFI));
+      netIdToNetwork.put(NET_ID_MOBILE, ShadowNetwork.newInstance(NET_ID_MOBILE));
+      netIdToNetworkInfo.put(NET_ID_WIFI, wifi);
+      netIdToNetworkInfo.put(NET_ID_MOBILE, mobile);
+
+      NetworkCapabilities wifiNetworkCapabilities = ShadowNetworkCapabilities.newInstance();
+      shadowOf(wifiNetworkCapabilities).addTransportType(NetworkCapabilities.TRANSPORT_WIFI);
+      NetworkCapabilities mobileNetworkCapabilities = ShadowNetworkCapabilities.newInstance();
+      shadowOf(mobileNetworkCapabilities).addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR);
+
+      networkCapabilitiesMap.put(netIdToNetwork.get(NET_ID_WIFI), wifiNetworkCapabilities);
+      networkCapabilitiesMap.put(netIdToNetwork.get(NET_ID_MOBILE), mobileNetworkCapabilities);
+    }
+    defaultNetworkActive = true;
+  }
+
+  public Set<ConnectivityManager.NetworkCallback> getNetworkCallbacks() {
+    return networkCallbacks;
+  }
+
+  /**
+   * @return networks and their connectivity status which was reported with {@link
+   *     #reportNetworkConnectivity}.
+   */
+  public Map<Network, Boolean> getReportedNetworkConnectivity() {
+    return new HashMap<>(reportedNetworkConnectivity);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void registerNetworkCallback(
+      NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback) {
+    registerNetworkCallback(request, networkCallback, null);
+  }
+
+  @Implementation(minSdk = O)
+  protected void registerNetworkCallback(
+      NetworkRequest request,
+      ConnectivityManager.NetworkCallback networkCallback,
+      Handler handler) {
+    networkCallbacks.add(networkCallback);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void requestNetwork(
+      NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback) {
+    registerNetworkCallback(request, networkCallback);
+  }
+
+  @Implementation(minSdk = O)
+  protected void requestNetwork(
+      NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback, int timeoutMs) {
+    registerNetworkCallback(request, networkCallback);
+  }
+
+  @Implementation(minSdk = O)
+  protected void requestNetwork(
+      NetworkRequest request,
+      ConnectivityManager.NetworkCallback networkCallback,
+      Handler handler) {
+    registerNetworkCallback(request, networkCallback);
+  }
+
+  @Implementation(minSdk = O)
+  protected void requestNetwork(
+      NetworkRequest request,
+      ConnectivityManager.NetworkCallback networkCallback,
+      Handler handler,
+      int timeoutMs) {
+    registerNetworkCallback(request, networkCallback);
+  }
+
+  @Implementation(minSdk = N)
+  protected void registerDefaultNetworkCallback(
+      ConnectivityManager.NetworkCallback networkCallback) {
+    networkCallbacks.add(networkCallback);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void unregisterNetworkCallback(ConnectivityManager.NetworkCallback networkCallback) {
+    if (networkCallback == null) {
+      throw new IllegalArgumentException("Invalid NetworkCallback");
+    }
+    if (networkCallbacks.contains(networkCallback)) {
+      networkCallbacks.remove(networkCallback);
+    }
+  }
+
+  @Implementation
+  protected NetworkInfo getActiveNetworkInfo() {
+    return activeNetworkInfo;
+  }
+
+  /**
+   * @see #setActiveNetworkInfo(NetworkInfo)
+   * @see #setNetworkInfo(int, NetworkInfo)
+   */
+  @Implementation(minSdk = M)
+  protected Network getActiveNetwork() {
+    if (defaultNetworkActive) {
+      return netIdToNetwork.get(getActiveNetworkInfo().getType());
+    }
+    return null;
+  }
+
+  /**
+   * @see #setActiveNetworkInfo(NetworkInfo)
+   * @see #setNetworkInfo(int, NetworkInfo)
+   */
+  @Implementation
+  protected NetworkInfo[] getAllNetworkInfo() {
+    // todo(xian): is `defaultNetworkActive` really relevant here?
+    if (defaultNetworkActive) {
+      return networkTypeToNetworkInfo
+          .values()
+          .toArray(new NetworkInfo[networkTypeToNetworkInfo.size()]);
+    }
+    return null;
+  }
+
+  @Implementation
+  protected NetworkInfo getNetworkInfo(int networkType) {
+    return networkTypeToNetworkInfo.get(networkType);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected NetworkInfo getNetworkInfo(Network network) {
+    if (network == null) {
+      return null;
+    }
+    ShadowNetwork shadowNetwork = Shadow.extract(network);
+    return netIdToNetworkInfo.get(shadowNetwork.getNetId());
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected Network[] getAllNetworks() {
+    return netIdToNetwork.values().toArray(new Network[netIdToNetwork.size()]);
+  }
+
+  @Implementation
+  protected boolean getBackgroundDataSetting() {
+    return backgroundDataSetting;
+  }
+
+  @Implementation
+  protected void setNetworkPreference(int preference) {
+    networkPreference = preference;
+  }
+
+  @Implementation
+  protected int getNetworkPreference() {
+    return networkPreference;
+  }
+
+  /**
+   * Counts {@link ConnectivityManager#TYPE_MOBILE} networks as metered. Other types will be
+   * considered unmetered.
+   *
+   * @return true if the active network is metered, otherwise false.
+   * @see #setActiveNetworkInfo(NetworkInfo)
+   * @see #setDefaultNetworkActive(boolean)
+   */
+  @Implementation
+  protected boolean isActiveNetworkMetered() {
+    if (defaultNetworkActive && activeNetworkInfo != null) {
+      return activeNetworkInfo.getType() == ConnectivityManager.TYPE_MOBILE;
+    } else {
+      return false;
+    }
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean bindProcessToNetwork(Network network) {
+    processBoundNetwork = network;
+    return true;
+  }
+
+  @Implementation(minSdk = M)
+  protected Network getBoundNetworkForProcess() {
+    return processBoundNetwork;
+  }
+
+  public void setNetworkInfo(int networkType, NetworkInfo networkInfo) {
+    networkTypeToNetworkInfo.put(networkType, networkInfo);
+  }
+
+  /**
+   * Returns the captive portal URL previously set with {@link #setCaptivePortalServerUrl}.
+   */
+  @Implementation(minSdk = N)
+  protected String getCaptivePortalServerUrl() {
+    return captivePortalServerUrl;
+  }
+
+  /**
+   * Sets the captive portal URL, which will be returned in {@link #getCaptivePortalServerUrl}.
+   *
+   * @param captivePortalServerUrl the url of captive portal.
+   */
+  public void setCaptivePortalServerUrl(String captivePortalServerUrl) {
+    this.captivePortalServerUrl = captivePortalServerUrl;
+  }
+
+  @HiddenApi @Implementation
+  public void setBackgroundDataSetting(boolean b) {
+    backgroundDataSetting = b;
+  }
+
+  public void setActiveNetworkInfo(NetworkInfo info) {
+    if (getApiLevel() >= LOLLIPOP) {
+      activeNetworkInfo = info;
+      if (info != null) {
+        networkTypeToNetworkInfo.put(info.getType(), info);
+        netIdToNetwork.put(info.getType(), ShadowNetwork.newInstance(info.getType()));
+        netIdToNetworkInfo.put(info.getType(), info);
+      } else {
+        networkTypeToNetworkInfo.clear();
+        netIdToNetwork.clear();
+      }
+    } else {
+      activeNetworkInfo = info;
+      if (info != null) {
+        networkTypeToNetworkInfo.put(info.getType(), info);
+      } else {
+        networkTypeToNetworkInfo.clear();
+      }
+    }
+  }
+
+  /**
+   * Adds new {@code network} to the list of all {@link android.net.Network}s.
+   *
+   * @param network The network.
+   * @param networkInfo The network info paired with the {@link android.net.Network}.
+   */
+  public void addNetwork(Network network, NetworkInfo networkInfo) {
+    ShadowNetwork shadowNetwork = Shadow.extract(network);
+    int netId = shadowNetwork.getNetId();
+    netIdToNetwork.put(netId, network);
+    netIdToNetworkInfo.put(netId, networkInfo);
+  }
+
+  /**
+   * Removes the {@code network} from the list of all {@link android.net.Network}s.
+   * @param network The network.
+   */
+  public void removeNetwork(Network network) {
+    ShadowNetwork shadowNetwork = Shadow.extract(network);
+    int netId = shadowNetwork.getNetId();
+    netIdToNetwork.remove(netId);
+    netIdToNetworkInfo.remove(netId);
+  }
+
+  /**
+   * Clears the list of all {@link android.net.Network}s.
+   */
+  public void clearAllNetworks() {
+    netIdToNetwork.clear();
+    netIdToNetworkInfo.clear();
+  }
+
+  /**
+   * Sets the active state of the default network.
+   *
+   * By default this is true and affects the result of {@link
+   * ConnectivityManager#isActiveNetworkMetered()}, {@link
+   * ConnectivityManager#isDefaultNetworkActive()}, {@link ConnectivityManager#getActiveNetwork()}
+   * and {@link ConnectivityManager#getAllNetworkInfo()}.
+   *
+   * Calling this method with {@code true} after any listeners have been registered with {@link
+   * ConnectivityManager#addDefaultNetworkActiveListener(OnNetworkActiveListener)} will result in
+   * those listeners being fired.
+   *
+   * @param isActive The active state of the default network.
+   */
+  public void setDefaultNetworkActive(boolean isActive) {
+    defaultNetworkActive = isActive;
+    if (defaultNetworkActive) {
+      for (ConnectivityManager.OnNetworkActiveListener l : onNetworkActiveListeners) {
+        if (l != null) {
+          l.onNetworkActive();
+        }
+      }
+    }
+  }
+
+  /**
+   * @return true by default, or the value specifed via {@link #setDefaultNetworkActive(boolean)}
+   * @see #setDefaultNetworkActive(boolean)
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isDefaultNetworkActive() {
+    return defaultNetworkActive;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addDefaultNetworkActiveListener(final ConnectivityManager.OnNetworkActiveListener l) {
+    onNetworkActiveListeners.add(l);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void removeDefaultNetworkActiveListener(ConnectivityManager.OnNetworkActiveListener l) {
+    if (l == null) {
+      throw new IllegalArgumentException("Invalid OnNetworkActiveListener");
+    }
+    if (onNetworkActiveListeners.contains(l)) {
+      onNetworkActiveListeners.remove(l);
+    }
+  }
+
+  @Implementation(minSdk = M)
+  protected void reportNetworkConnectivity(Network network, boolean hasConnectivity) {
+    reportedNetworkConnectivity.put(network, hasConnectivity);
+  }
+
+  /**
+   * Gets the network capabilities of a given {@link Network}.
+   *
+   * @param network The {@link Network} object identifying the network in question.
+   * @return The {@link android.net.NetworkCapabilities} for the network.
+   * @see #setNetworkCapabilities(Network, NetworkCapabilities)
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected NetworkCapabilities getNetworkCapabilities(Network network) {
+    return networkCapabilitiesMap.get(network);
+  }
+
+  /**
+   * Sets network capability and affects the result of {@link
+   * ConnectivityManager#getNetworkCapabilities(Network)}
+   *
+   * @param network The {@link Network} object identifying the network in question.
+   * @param networkCapabilities The {@link android.net.NetworkCapabilities} for the network.
+   */
+  public void setNetworkCapabilities(Network network, NetworkCapabilities networkCapabilities) {
+    networkCapabilitiesMap.put(network, networkCapabilities);
+  }
+
+  /**
+   * Sets the value for enabling/disabling airplane mode
+   *
+   * @param enable new status for airplane mode
+   */
+  @Implementation(minSdk = KITKAT)
+  protected void setAirplaneMode(boolean enable) {
+    ShadowSettings.setAirplaneMode(enable);
+  }
+
+  /** @see #setLinkProperties(Network, LinkProperties) */
+  @Implementation(minSdk = LOLLIPOP)
+  protected LinkProperties getLinkProperties(Network network) {
+    return linkPropertiesMap.get(network);
+  }
+
+  /**
+   * Sets the LinkProperties for the given Network.
+   *
+   * <p>A LinkProperties can be constructed by {@code
+   * org.robolectric.util.ReflectionHelpers.callConstructor} in tests.
+   */
+  public void setLinkProperties(Network network, LinkProperties linkProperties) {
+    linkPropertiesMap.put(network, linkProperties);
+  }
+
+  /**
+   * Gets the RESTRICT_BACKGROUND_STATUS value. Default value is 1
+   * (RESTRICT_BACKGROUND_STATUS_DISABLED).
+   */
+  @Implementation(minSdk = N)
+  protected int getRestrictBackgroundStatus() {
+    return restrictBackgroundStatus;
+  }
+
+  /** Sets the next return value for {@link ConnectivityManager#getRestrictBackgroundStatus()}. */
+  public void setRestrictBackgroundStatus(int status) {
+    if (status <= 0 || status >= 4) {
+      throw new IllegalArgumentException("Invalid RESTRICT_BACKGROUND_STATUS value.");
+    }
+    restrictBackgroundStatus = status;
+  }
+
+  /**
+   * Sets a proxy for a given {@link Network}.
+   *
+   * @param network The network.
+   * @param proxyInfo The proxy info.
+   */
+  public void setProxyForNetwork(Network network, ProxyInfo proxyInfo) {
+    proxyInfoMap.put(network, proxyInfo);
+  }
+
+  /**
+   * Returns a proxy for a given {@link Network}.
+   *
+   * <p>In order {@link ConnectivityManager#getDefaultProxy()} to work the default network should be
+   * set using {@link ConnectivityManager#bindProcessToNetwork(Network)}.
+   */
+  @Implementation(minSdk = M)
+  protected ProxyInfo getProxyForNetwork(Network network) {
+    return proxyInfoMap.get(network);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentCaptureManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentCaptureManager.java
new file mode 100644
index 0000000..34abfc0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentCaptureManager.java
@@ -0,0 +1,119 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.os.ParcelFileDescriptor;
+import android.view.contentcapture.ContentCaptureCondition;
+import android.view.contentcapture.ContentCaptureManager;
+import android.view.contentcapture.ContentCaptureManager.DataShareError;
+import android.view.contentcapture.DataRemovalRequest;
+import android.view.contentcapture.DataShareRequest;
+import android.view.contentcapture.DataShareWriteAdapter;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** A Shadow for android.view.contentcapture.ContentCaptureManager added in Android R. */
+@Implements(value = ContentCaptureManager.class, minSdk = Q, isInAndroidSdk = false)
+public class ShadowContentCaptureManager {
+
+  @Nullable private Set<ContentCaptureCondition> contentCaptureConditions;
+  @Nullable private ComponentName serviceComponentName;
+  private boolean isContentCaptureEnabled = false;
+  @Nullable private ParcelFileDescriptor parcelFileDescriptor;
+  @DataShareError private int dataShareErrorCode = -1;
+  private boolean shouldRejectRequest = false;
+
+  /**
+   * Configures the set of {@link ContentCaptureCondition} that will be returned when calling {@link
+   * #getContentCaptureConditions()}.
+   */
+  public void setContentCaptureConditions(Set<ContentCaptureCondition> contentCaptureConditions) {
+    this.contentCaptureConditions = contentCaptureConditions;
+  }
+
+  /**
+   * Configures the {@link ComponentName} that will be returned when calling {@link
+   * #getServiceComponentName()}.
+   */
+  public void setServiceComponentName(ComponentName serviceComponentName) {
+    this.serviceComponentName = serviceComponentName;
+  }
+
+  /** Configures whether {@link #isContentCaptureEnabled()} returns true or false. */
+  public void setIsContentCaptureEnabled(boolean isContentCaptureEnabled) {
+    this.isContentCaptureEnabled = isContentCaptureEnabled;
+  }
+
+  /**
+   * Configures {@link DataShareError} to be raised on calls to {@link #shareData(DataShareRequest,
+   * Executor, DataShareWriteAdapter)}.
+   */
+  @TargetApi(R)
+  public void setDataShareErrorCode(@DataShareError int dataShareErrorCode) {
+    this.dataShareErrorCode = dataShareErrorCode;
+  }
+
+  /**
+   * Configures whether or not to raise request rejection on calls to {@link
+   * #shareData(DataShareRequest, Executor, DataShareWriteAdapter)}.
+   */
+  @TargetApi(R)
+  public void setShouldRejectRequest(boolean shouldRejectRequest) {
+    this.shouldRejectRequest = shouldRejectRequest;
+  }
+
+  /**
+   * Configures the {@link ParcelFileDescriptor} that {@link
+   * DataShareWriteAdapter#onWrite(ParcelFileDescriptor)} will receive on calls to {@link
+   * #shareData(DataShareRequest, Executor, DataShareWriteAdapter)}.
+   */
+  @TargetApi(R)
+  public void setShareDataParcelFileDescriptor(ParcelFileDescriptor parcelFileDescriptor) {
+    this.parcelFileDescriptor = parcelFileDescriptor;
+  }
+
+  @Implementation
+  protected Set<ContentCaptureCondition> getContentCaptureConditions() {
+    return contentCaptureConditions;
+  }
+
+  @Implementation
+  protected ComponentName getServiceComponentName() {
+    return serviceComponentName;
+  }
+
+  @Implementation
+  protected boolean isContentCaptureEnabled() {
+    return isContentCaptureEnabled;
+  }
+
+  @Implementation
+  protected void setContentCaptureEnabled(boolean enabled) {
+    isContentCaptureEnabled = enabled;
+  }
+
+  @Implementation
+  protected void removeData(DataRemovalRequest request) {}
+
+  @Implementation(minSdk = R)
+  protected void shareData(
+      DataShareRequest request, Executor executor, DataShareWriteAdapter dataShareWriteAdapter) {
+    if (shouldRejectRequest) {
+      dataShareWriteAdapter.onRejected();
+      return;
+    }
+
+    if (dataShareErrorCode >= 0) {
+      dataShareWriteAdapter.onError(dataShareErrorCode);
+      return;
+    }
+
+    dataShareWriteAdapter.onWrite(parcelFileDescriptor);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProvider.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProvider.java
new file mode 100644
index 0000000..59e275e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProvider.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ContentProvider;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(ContentProvider.class)
+public class ShadowContentProvider {
+  @RealObject private ContentProvider realContentProvider;
+
+  private String callingPackage;
+
+  public void setCallingPackage(String callingPackage) {
+    this.callingPackage = callingPackage;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected String getCallingPackage() {
+    if (callingPackage != null) {
+      return callingPackage;
+    } else {
+      return reflector(ContentProviderReflector.class, realContentProvider).getCallingPackage();
+    }
+  }
+
+  @ForType(ContentProvider.class)
+  interface ContentProviderReflector {
+
+    @Direct
+    String getCallingPackage();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderClient.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderClient.java
new file mode 100644
index 0000000..4652869
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderClient.java
@@ -0,0 +1,153 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ContentProvider;
+import android.content.ContentProviderClient;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentValues;
+import android.content.OperationApplicationException;
+import android.content.res.AssetFileDescriptor;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(ContentProviderClient.class)
+public class ShadowContentProviderClient {
+  @RealObject private ContentProviderClient realContentProviderClient;
+
+  private ContentProvider provider;
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected Bundle call(String method, String arg, Bundle extras) throws RemoteException {
+    return provider.call(method, arg, extras);
+  }
+
+  @Implementation
+  protected String getType(Uri uri) throws RemoteException {
+    return provider.getType(uri);
+  }
+
+  @Implementation
+  protected String[] getStreamTypes(Uri uri, String mimeTypeFilter) {
+    return provider.getStreamTypes(uri, mimeTypeFilter);
+  }
+
+  @Implementation
+  protected Cursor query(
+      Uri url, String[] projection, String selection, String[] selectionArgs, String sortOrder)
+      throws RemoteException {
+    return provider.query(url, projection, selection, selectionArgs, sortOrder);
+  }
+
+  @Implementation
+  protected Cursor query(
+      Uri url,
+      String[] projection,
+      String selection,
+      String[] selectionArgs,
+      String sortOrder,
+      CancellationSignal cancellationSignal)
+      throws RemoteException {
+    return provider.query(url, projection, selection, selectionArgs, sortOrder, cancellationSignal);
+  }
+
+  @Implementation
+  protected Uri insert(Uri url, ContentValues initialValues) throws RemoteException {
+    return provider.insert(url, initialValues);
+  }
+
+  @Implementation
+  protected int bulkInsert(Uri url, ContentValues[] initialValues) throws RemoteException {
+    return provider.bulkInsert(url, initialValues);
+  }
+
+  @Implementation
+  protected int delete(Uri url, String selection, String[] selectionArgs) throws RemoteException {
+    return provider.delete(url, selection, selectionArgs);
+  }
+
+  @Implementation
+  protected int update(Uri url, ContentValues values, String selection, String[] selectionArgs)
+      throws RemoteException {
+    return provider.update(url, values, selection, selectionArgs);
+  }
+
+  @Implementation
+  protected ParcelFileDescriptor openFile(Uri url, String mode)
+      throws RemoteException, FileNotFoundException {
+    return provider.openFile(url, mode);
+  }
+
+  @Implementation
+  protected AssetFileDescriptor openAssetFile(Uri url, String mode)
+      throws RemoteException, FileNotFoundException {
+    return provider.openAssetFile(url, mode);
+  }
+
+  @Implementation
+  protected final AssetFileDescriptor openTypedAssetFileDescriptor(
+      Uri uri, String mimeType, Bundle opts) throws RemoteException, FileNotFoundException {
+    return provider.openTypedAssetFile(uri, mimeType, opts);
+  }
+
+  @Implementation
+  protected ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
+      throws RemoteException, OperationApplicationException {
+    return provider.applyBatch(operations);
+  }
+
+  @Implementation
+  protected ContentProvider getLocalContentProvider() {
+    return ContentProvider.coerceToLocalContentProvider(provider.getIContentProvider());
+  }
+
+  public boolean isStable() {
+    return reflector(ContentProviderClientReflector.class, realContentProviderClient).getStable();
+  }
+
+  public boolean isReleased() {
+    ContentProviderClientReflector contentProviderClientReflector =
+        reflector(ContentProviderClientReflector.class, realContentProviderClient);
+    if (RuntimeEnvironment.getApiLevel() <= Build.VERSION_CODES.M) {
+      return contentProviderClientReflector.getReleased();
+    } else {
+      return contentProviderClientReflector.getClosed().get();
+    }
+  }
+
+  void setContentProvider(ContentProvider provider) {
+    this.provider = provider;
+  }
+
+  @ForType(ContentProviderClient.class)
+  interface ContentProviderClientReflector {
+    @Direct
+    boolean release();
+
+    @Accessor("mStable")
+    boolean getStable();
+
+    @Accessor("mReleased")
+    boolean getReleased();
+
+    @Accessor("mClosed")
+    AtomicBoolean getClosed();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderOperation.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderOperation.java
new file mode 100644
index 0000000..aa88575
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderOperation.java
@@ -0,0 +1,94 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(ContentProviderOperation.class)
+public class ShadowContentProviderOperation {
+  public final static int TYPE_INSERT = 1;
+  public final static int TYPE_UPDATE = 2;
+  public final static int TYPE_DELETE = 3;
+  public final static int TYPE_ASSERT = 4;
+
+  @RealObject
+  private ContentProviderOperation realOperation;
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @HiddenApi
+  @Implementation
+  @Deprecated
+  public int getType() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mType", Integer.class);
+  }
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @Deprecated
+  public String getSelection() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mSelection", String.class);
+  }
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @Deprecated
+  public String[] getSelectionArgs() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mSelectionArgs", String[].class);
+  }
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @Deprecated
+  public ContentValues getContentValues() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mValues", ContentValues.class);
+  }
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @Deprecated
+  public Integer getExpectedCount() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mExpectedCount", Integer.class);
+  }
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @Deprecated
+  public ContentValues getValuesBackReferences() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mValuesBackReferences", ContentValues.class);
+  }
+
+  /** @deprecated implementation detail - use public Android APIs instead */
+  @SuppressWarnings("unchecked")
+  @Deprecated
+  public Map<Integer, Integer> getSelectionArgsBackReferences() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      throw new UnsupportedOperationException("unsupported on Android R");
+    }
+    return getFieldReflectively("mSelectionArgsBackReferences", Map.class);
+  }
+
+  private <T> T getFieldReflectively(String fieldName, Class<T> clazz) {
+    return clazz.cast(ReflectionHelpers.getField(realOperation, fieldName));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderResult.java
new file mode 100644
index 0000000..baf44dd
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentProviderResult.java
@@ -0,0 +1,31 @@
+package org.robolectric.shadows;
+
+import android.content.ContentProviderResult;
+import android.net.Uri;
+import java.lang.reflect.Field;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@Implements(ContentProviderResult.class)
+public class ShadowContentProviderResult {
+  @RealObject ContentProviderResult realResult;
+
+  @Implementation
+  protected void __constructor__(Uri uri)
+      throws SecurityException, NoSuchFieldException, IllegalArgumentException,
+          IllegalAccessException {
+    Field field = realResult.getClass().getField("uri");
+    field.setAccessible(true);
+    field.set(realResult, uri);
+  }
+
+  @Implementation
+  protected void __constructor__(int count)
+      throws SecurityException, NoSuchFieldException, IllegalArgumentException,
+          IllegalAccessException {
+    Field field = realResult.getClass().getField("count");
+    field.setAccessible(true);
+    field.set(realResult, count);
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentResolver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentResolver.java
new file mode 100644
index 0000000..98adb47
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentResolver.java
@@ -0,0 +1,1153 @@
+package org.robolectric.shadows;
+
+import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS;
+import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER;
+import static android.content.ContentResolver.SCHEME_ANDROID_RESOURCE;
+import static android.content.ContentResolver.SCHEME_CONTENT;
+import static android.content.ContentResolver.SCHEME_FILE;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.accounts.Account;
+import android.annotation.NonNull;
+import android.annotation.SuppressLint;
+import android.content.ContentProvider;
+import android.content.ContentProviderClient;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.IContentProvider;
+import android.content.Intent;
+import android.content.OperationApplicationException;
+import android.content.PeriodicSync;
+import android.content.SyncAdapterType;
+import android.content.SyncInfo;
+import android.content.UriPermission;
+import android.content.pm.ProviderInfo;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import com.google.common.base.Splitter;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Supplier;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.fakes.BaseCursor;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.NamedStream;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(ContentResolver.class)
+@SuppressLint("NewApi")
+public class ShadowContentResolver {
+  private int nextDatabaseIdForInserts;
+
+  @RealObject ContentResolver realContentResolver;
+
+  private BaseCursor cursor;
+  private final List<Statement> statements = new CopyOnWriteArrayList<>();
+  private final List<InsertStatement> insertStatements = new CopyOnWriteArrayList<>();
+  private final List<UpdateStatement> updateStatements = new CopyOnWriteArrayList<>();
+  private final List<DeleteStatement> deleteStatements = new CopyOnWriteArrayList<>();
+  private List<NotifiedUri> notifiedUris = new ArrayList<>();
+  private Map<Uri, BaseCursor> uriCursorMap = new HashMap<>();
+  private Map<Uri, Supplier<InputStream>> inputStreamMap = new HashMap<>();
+  private Map<Uri, Supplier<OutputStream>> outputStreamMap = new HashMap<>();
+  private final Map<String, List<ContentProviderOperation>> contentProviderOperations =
+      new HashMap<>();
+  private ContentProviderResult[] contentProviderResults;
+  private final List<UriPermission> uriPermissions = new ArrayList<>();
+
+  private final CopyOnWriteArrayList<ContentObserverEntry> contentObservers =
+      new CopyOnWriteArrayList<>();
+
+  private static final Map<String, Map<Account, Status>> syncableAccounts = new HashMap<>();
+  private static final Map<String, ContentProvider> providers =
+      Collections.synchronizedMap(new HashMap<>());
+  private static boolean masterSyncAutomatically;
+
+  private static SyncAdapterType[] syncAdapterTypes;
+
+  @Resetter
+  public static void reset() {
+    syncableAccounts.clear();
+    providers.clear();
+    masterSyncAutomatically = false;
+  }
+
+  private static class ContentObserverEntry {
+    public final Uri uri;
+    public final boolean notifyForDescendents;
+    public final ContentObserver observer;
+
+    private ContentObserverEntry(Uri uri, boolean notifyForDescendents, ContentObserver observer) {
+      this.uri = uri;
+      this.notifyForDescendents = notifyForDescendents;
+      this.observer = observer;
+
+      if (uri == null || observer == null) {
+        throw new NullPointerException();
+      }
+    }
+
+    public boolean matches(Uri test) {
+      if (!Objects.equals(uri.getScheme(), test.getScheme())) {
+        return false;
+      }
+      if (!Objects.equals(uri.getAuthority(), test.getAuthority())) {
+        return false;
+      }
+
+      String uriPath = uri.getPath();
+      String testPath = test.getPath();
+
+      return Objects.equals(uriPath, testPath)
+          || (notifyForDescendents && testPath != null && testPath.startsWith(uriPath));
+    }
+  }
+
+  public static class NotifiedUri {
+    public final Uri uri;
+    public final boolean syncToNetwork;
+    public final ContentObserver observer;
+
+    public NotifiedUri(Uri uri, ContentObserver observer, boolean syncToNetwork) {
+      this.uri = uri;
+      this.syncToNetwork = syncToNetwork;
+      this.observer = observer;
+    }
+  }
+
+  public static class Status {
+    public int syncRequests;
+    public int state = -1;
+    public boolean syncAutomatically;
+    public Bundle syncExtras;
+    public List<PeriodicSync> syncs = new ArrayList<>();
+  }
+
+  public void registerInputStream(Uri uri, InputStream inputStream) {
+    inputStreamMap.put(uri, () -> inputStream);
+  }
+
+  public void registerInputStreamSupplier(Uri uri, Supplier<InputStream> supplier) {
+    inputStreamMap.put(uri, supplier);
+  }
+
+  public void registerOutputStream(Uri uri, OutputStream outputStream) {
+    outputStreamMap.put(uri, () -> outputStream);
+  }
+
+  public void registerOutputStreamSupplier(Uri uri, Supplier<OutputStream> supplier) {
+    outputStreamMap.put(uri, supplier);
+  }
+
+  @Implementation
+  protected final InputStream openInputStream(final Uri uri) throws FileNotFoundException {
+    Supplier<InputStream> supplier = inputStreamMap.get(uri);
+    if (supplier != null) {
+      InputStream inputStream = supplier.get();
+      if (inputStream != null) {
+        return inputStream;
+      }
+    }
+    String scheme = uri.getScheme();
+    if (SCHEME_ANDROID_RESOURCE.equals(scheme)
+        || SCHEME_FILE.equals(scheme)
+        || (SCHEME_CONTENT.equals(scheme) && getProvider(uri, getContext()) != null)) {
+      return reflector(ContentResolverReflector.class, realContentResolver).openInputStream(uri);
+    }
+    return new UnregisteredInputStream(uri);
+  }
+
+  @Implementation
+  protected final OutputStream openOutputStream(final Uri uri) {
+    Supplier<OutputStream> supplier = outputStreamMap.get(uri);
+    if (supplier != null) {
+      OutputStream outputStream = supplier.get();
+      if (outputStream != null) {
+        return outputStream;
+      }
+    }
+    return new OutputStream() {
+      @Override
+      public void write(int arg0) throws IOException {}
+
+      @Override
+      public String toString() {
+        return "outputstream for " + uri;
+      }
+    };
+  }
+
+  /**
+   * If a {@link ContentProvider} is registered for the given {@link Uri}, its {@link
+   * ContentProvider#insert(Uri, ContentValues)} method will be invoked.
+   *
+   * <p>Tests can verify that this method was called using {@link #getStatements()} or {@link
+   * #getInsertStatements()}.
+   *
+   * <p>If no appropriate {@link ContentProvider} is found, no action will be taken and a {@link
+   * Uri} including the incremented value set with {@link #setNextDatabaseIdForInserts(int)} will
+   * returned.
+   */
+  @Implementation
+  protected final Uri insert(Uri url, ContentValues values) {
+    ContentProvider provider = getProvider(url, getContext());
+    ContentValues valuesCopy = (values == null) ? null : new ContentValues(values);
+    InsertStatement insertStatement = new InsertStatement(url, provider, valuesCopy);
+    statements.add(insertStatement);
+    insertStatements.add(insertStatement);
+
+    if (provider != null) {
+      return provider.insert(url, values);
+    } else {
+      return Uri.parse(url.toString() + "/" + ++nextDatabaseIdForInserts);
+    }
+  }
+
+  private Context getContext() {
+    return reflector(ContentResolverReflector.class, realContentResolver).getContext();
+  }
+
+  /**
+   * If a {@link ContentProvider} is registered for the given {@link Uri}, its {@link
+   * ContentProvider#update(Uri, ContentValues, String, String[])} method will be invoked.
+   *
+   * <p>Tests can verify that this method was called using {@link #getStatements()} or {@link
+   * #getUpdateStatements()}.
+   *
+   * @return If no appropriate {@link ContentProvider} is found, no action will be taken and 1 will
+   *     be returned.
+   */
+  @Implementation
+  protected int update(Uri uri, ContentValues values, String where, String[] selectionArgs) {
+    ContentProvider provider = getProvider(uri, getContext());
+    ContentValues valuesCopy = (values == null) ? null : new ContentValues(values);
+    UpdateStatement updateStatement =
+        new UpdateStatement(uri, provider, valuesCopy, where, selectionArgs);
+    statements.add(updateStatement);
+    updateStatements.add(updateStatement);
+
+    if (provider != null) {
+      return provider.update(uri, values, where, selectionArgs);
+    } else {
+      return 1;
+    }
+  }
+
+  @Implementation(minSdk = O)
+  protected final Cursor query(
+      Uri uri, String[] projection, Bundle queryArgs, CancellationSignal cancellationSignal) {
+    ContentProvider provider = getProvider(uri, getContext());
+    if (provider != null) {
+      return provider.query(uri, projection, queryArgs, cancellationSignal);
+    } else {
+      BaseCursor returnCursor = getCursor(uri);
+      if (returnCursor == null) {
+        return null;
+      }
+      String selection = queryArgs.getString(QUERY_ARG_SQL_SELECTION);
+      String[] selectionArgs = queryArgs.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
+      String sortOrder = queryArgs.getString(QUERY_ARG_SQL_SORT_ORDER);
+
+      returnCursor.setQuery(uri, projection, selection, selectionArgs, sortOrder);
+      return returnCursor;
+    }
+  }
+
+  @Implementation
+  protected final Cursor query(
+      Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+    ContentProvider provider = getProvider(uri, getContext());
+    if (provider != null) {
+      return provider.query(uri, projection, selection, selectionArgs, sortOrder);
+    } else {
+      BaseCursor returnCursor = getCursor(uri);
+      if (returnCursor == null) {
+        return null;
+      }
+
+      returnCursor.setQuery(uri, projection, selection, selectionArgs, sortOrder);
+      return returnCursor;
+    }
+  }
+
+  @Implementation
+  protected Cursor query(
+      Uri uri,
+      String[] projection,
+      String selection,
+      String[] selectionArgs,
+      String sortOrder,
+      CancellationSignal cancellationSignal) {
+    ContentProvider provider = getProvider(uri, getContext());
+    if (provider != null) {
+      return provider.query(
+          uri, projection, selection, selectionArgs, sortOrder, cancellationSignal);
+    } else {
+      BaseCursor returnCursor = getCursor(uri);
+      if (returnCursor == null) {
+        return null;
+      }
+
+      returnCursor.setQuery(uri, projection, selection, selectionArgs, sortOrder);
+      return returnCursor;
+    }
+  }
+
+  @Implementation
+  protected String getType(Uri uri) {
+    ContentProvider provider = getProvider(uri, getContext());
+    if (provider != null) {
+      return provider.getType(uri);
+    } else {
+      return null;
+    }
+  }
+
+  @Implementation
+  protected Bundle call(Uri uri, String method, String arg, Bundle extras) {
+    ContentProvider cp = getProvider(uri, getContext());
+    if (cp != null) {
+      return cp.call(method, arg, extras);
+    } else {
+      return null;
+    }
+  }
+
+  @Implementation
+  protected final ContentProviderClient acquireContentProviderClient(String name) {
+    ContentProvider provider = getProvider(name, getContext());
+    if (provider == null) {
+      return null;
+    }
+    return getContentProviderClient(provider, true);
+  }
+
+  @Implementation
+  protected final ContentProviderClient acquireContentProviderClient(Uri uri) {
+    ContentProvider provider = getProvider(uri, getContext());
+    if (provider == null) {
+      return null;
+    }
+    return getContentProviderClient(provider, true);
+  }
+
+  @Implementation
+  protected final ContentProviderClient acquireUnstableContentProviderClient(String name) {
+    ContentProvider provider = getProvider(name, getContext());
+    if (provider == null) {
+      return null;
+    }
+    return getContentProviderClient(provider, false);
+  }
+
+  @Implementation
+  protected final ContentProviderClient acquireUnstableContentProviderClient(Uri uri) {
+    ContentProvider provider = getProvider(uri, getContext());
+    if (provider == null) {
+      return null;
+    }
+    return getContentProviderClient(provider, false);
+  }
+
+  private ContentProviderClient getContentProviderClient(ContentProvider provider, boolean stable) {
+    ContentProviderClient client =
+        ReflectionHelpers.callConstructor(
+            ContentProviderClient.class,
+            ClassParameter.from(ContentResolver.class, realContentResolver),
+            ClassParameter.from(IContentProvider.class, provider.getIContentProvider()),
+            ClassParameter.from(boolean.class, stable));
+    ShadowContentProviderClient shadowContentProviderClient = Shadow.extract(client);
+    shadowContentProviderClient.setContentProvider(provider);
+    return client;
+  }
+
+  @Implementation
+  protected final IContentProvider acquireProvider(String name) {
+    return acquireUnstableProvider(name);
+  }
+
+  @Implementation
+  protected final IContentProvider acquireProvider(Uri uri) {
+    return acquireUnstableProvider(uri);
+  }
+
+  @Implementation
+  protected final IContentProvider acquireUnstableProvider(String name) {
+    ContentProvider cp = getProvider(name, getContext());
+    if (cp != null) {
+      return cp.getIContentProvider();
+    }
+    return null;
+  }
+
+  @Implementation
+  protected final IContentProvider acquireUnstableProvider(Uri uri) {
+    ContentProvider cp = getProvider(uri, getContext());
+    if (cp != null) {
+      return cp.getIContentProvider();
+    }
+    return null;
+  }
+
+  /**
+   * If a {@link ContentProvider} is registered for the given {@link Uri}, its {@link
+   * ContentProvider#delete(Uri, String, String[])} method will be invoked.
+   *
+   * <p>Tests can verify that this method was called using {@link #getDeleteStatements()} or {@link
+   * #getDeletedUris()}.
+   *
+   * <p>If no appropriate {@link ContentProvider} is found, no action will be taken and {@code 1}
+   * will be returned.
+   */
+  @Implementation
+  protected final int delete(Uri url, String where, String[] selectionArgs) {
+    ContentProvider provider = getProvider(url, getContext());
+
+    DeleteStatement deleteStatement = new DeleteStatement(url, provider, where, selectionArgs);
+    statements.add(deleteStatement);
+    deleteStatements.add(deleteStatement);
+
+    if (provider != null) {
+      return provider.delete(url, where, selectionArgs);
+    } else {
+      return 1;
+    }
+  }
+
+  /**
+   * If a {@link ContentProvider} is registered for the given {@link Uri}, its {@link
+   * ContentProvider#bulkInsert(Uri, ContentValues[])} method will be invoked.
+   *
+   * <p>Tests can verify that this method was called using {@link #getStatements()} or {@link
+   * #getInsertStatements()}.
+   *
+   * <p>If no appropriate {@link ContentProvider} is found, no action will be taken and the number
+   * of rows in {@code values} will be returned.
+   */
+  @Implementation
+  protected final int bulkInsert(Uri url, ContentValues[] values) {
+    ContentProvider provider = getProvider(url, getContext());
+
+    InsertStatement insertStatement = new InsertStatement(url, provider, values);
+    statements.add(insertStatement);
+    insertStatements.add(insertStatement);
+
+    if (provider != null) {
+      return provider.bulkInsert(url, values);
+    } else {
+      return values.length;
+    }
+  }
+
+  @Implementation
+  protected void notifyChange(Uri uri, ContentObserver observer, boolean syncToNetwork) {
+    notifiedUris.add(new NotifiedUri(uri, observer, syncToNetwork));
+
+    for (ContentObserverEntry entry : contentObservers) {
+      if (entry.matches(uri) && entry.observer != observer) {
+        entry.observer.dispatchChange(false, uri);
+      }
+    }
+    if (observer != null && observer.deliverSelfNotifications()) {
+      observer.dispatchChange(true, uri);
+    }
+  }
+
+  @Implementation
+  protected void notifyChange(Uri uri, ContentObserver observer) {
+    notifyChange(uri, observer, false);
+  }
+
+  @Implementation
+  protected ContentProviderResult[] applyBatch(
+      String authority, ArrayList<ContentProviderOperation> operations)
+      throws OperationApplicationException {
+    ContentProvider provider = getProvider(authority, getContext());
+    if (provider != null) {
+      return provider.applyBatch(operations);
+    } else {
+      contentProviderOperations.put(authority, operations);
+      return contentProviderResults;
+    }
+  }
+
+  @Implementation
+  protected static void requestSync(Account account, String authority, Bundle extras) {
+    validateSyncExtrasBundle(extras);
+    Status status = getStatus(account, authority, true);
+    status.syncRequests++;
+    status.syncExtras = extras;
+  }
+
+  @Implementation
+  protected static void cancelSync(Account account, String authority) {
+    Status status = getStatus(account, authority);
+    if (status != null) {
+      status.syncRequests = 0;
+      if (status.syncExtras != null) {
+        status.syncExtras.clear();
+      }
+      // This may be too much, as the above should be sufficient.
+      if (status.syncs != null) {
+        status.syncs.clear();
+      }
+    }
+  }
+
+  @Implementation
+  protected static boolean isSyncActive(Account account, String authority) {
+    ShadowContentResolver.Status status = getStatus(account, authority);
+    // TODO: this means a sync is *perpetually* active after one request
+    return status != null && status.syncRequests > 0;
+  }
+
+  @Implementation
+  protected static List<SyncInfo> getCurrentSyncs() {
+    List<SyncInfo> list = new ArrayList<>();
+    for (Map.Entry<String, Map<Account, Status>> map : syncableAccounts.entrySet()) {
+      if (map.getValue() == null) {
+        continue;
+      }
+      for (Map.Entry<Account, Status> mp : map.getValue().entrySet()) {
+        if (isSyncActive(mp.getKey(), map.getKey())) {
+          SyncInfo si = new SyncInfo(0, mp.getKey(), map.getKey(), 0);
+          list.add(si);
+        }
+      }
+    }
+    return list;
+  }
+
+  @Implementation
+  protected static void setIsSyncable(Account account, String authority, int syncable) {
+    getStatus(account, authority, true).state = syncable;
+  }
+
+  @Implementation
+  protected static int getIsSyncable(Account account, String authority) {
+    return getStatus(account, authority, true).state;
+  }
+
+  @Implementation
+  protected static boolean getSyncAutomatically(Account account, String authority) {
+    return getStatus(account, authority, true).syncAutomatically;
+  }
+
+  @Implementation
+  protected static void setSyncAutomatically(Account account, String authority, boolean sync) {
+    getStatus(account, authority, true).syncAutomatically = sync;
+  }
+
+  @Implementation
+  protected static void addPeriodicSync(
+      Account account, String authority, Bundle extras, long pollFrequency) {
+    validateSyncExtrasBundle(extras);
+    removePeriodicSync(account, authority, extras);
+    getStatus(account, authority, true)
+        .syncs
+        .add(new PeriodicSync(account, authority, extras, pollFrequency));
+  }
+
+  @Implementation
+  protected static void removePeriodicSync(Account account, String authority, Bundle extras) {
+    validateSyncExtrasBundle(extras);
+    Status status = getStatus(account, authority);
+    if (status != null) {
+      for (int i = 0; i < status.syncs.size(); ++i) {
+        if (isBundleEqual(extras, status.syncs.get(i).extras)) {
+          status.syncs.remove(i);
+          break;
+        }
+      }
+    }
+  }
+
+  @Implementation
+  protected static List<PeriodicSync> getPeriodicSyncs(Account account, String authority) {
+    return getStatus(account, authority, true).syncs;
+  }
+
+  @Implementation
+  protected static void validateSyncExtrasBundle(Bundle extras) {
+    for (String key : extras.keySet()) {
+      Object value = extras.get(key);
+      if (value == null
+          || value instanceof Long
+          || value instanceof Integer
+          || value instanceof Boolean
+          || value instanceof Float
+          || value instanceof Double
+          || value instanceof String
+          || value instanceof Account) {
+        continue;
+      }
+
+      throw new IllegalArgumentException("unexpected value type: " + value.getClass().getName());
+    }
+  }
+
+  @Implementation
+  protected static void setMasterSyncAutomatically(boolean sync) {
+    masterSyncAutomatically = sync;
+  }
+
+  @Implementation
+  protected static boolean getMasterSyncAutomatically() {
+    return masterSyncAutomatically;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void takePersistableUriPermission(@NonNull Uri uri, int modeFlags) {
+    Objects.requireNonNull(uri, "uri may not be null");
+    modeFlags &= (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+
+    // If neither read nor write permission is specified there is nothing to do.
+    if (modeFlags == 0) {
+      return;
+    }
+
+    // Attempt to locate an existing record for the uri.
+    for (Iterator<UriPermission> i = uriPermissions.iterator(); i.hasNext(); ) {
+      UriPermission perm = i.next();
+      if (uri.equals(perm.getUri())) {
+        if (perm.isReadPermission()) {
+          modeFlags |= Intent.FLAG_GRANT_READ_URI_PERMISSION;
+        }
+        if (perm.isWritePermission()) {
+          modeFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
+        }
+        i.remove();
+        break;
+      }
+    }
+
+    addUriPermission(uri, modeFlags);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void releasePersistableUriPermission(@NonNull Uri uri, int modeFlags) {
+    Objects.requireNonNull(uri, "uri may not be null");
+    modeFlags &= (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+
+    // If neither read nor write permission is specified there is nothing to do.
+    if (modeFlags == 0) {
+      return;
+    }
+
+    // Attempt to locate an existing record for the uri.
+    for (Iterator<UriPermission> i = uriPermissions.iterator(); i.hasNext(); ) {
+      UriPermission perm = i.next();
+      if (uri.equals(perm.getUri())) {
+        // Reconstruct the current mode flags.
+        int oldModeFlags =
+            (perm.isReadPermission() ? Intent.FLAG_GRANT_READ_URI_PERMISSION : 0)
+                | (perm.isWritePermission() ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0);
+
+        // Apply the requested permission change.
+        int newModeFlags = oldModeFlags & ~modeFlags;
+
+        // Update the permission record if a change occurred.
+        if (newModeFlags != oldModeFlags) {
+          i.remove();
+          if (newModeFlags != 0) {
+            addUriPermission(uri, newModeFlags);
+          }
+        }
+        break;
+      }
+    }
+  }
+
+  @Implementation(minSdk = KITKAT)
+  @NonNull
+  protected List<UriPermission> getPersistedUriPermissions() {
+    return uriPermissions;
+  }
+
+  private void addUriPermission(@NonNull Uri uri, int modeFlags) {
+    UriPermission perm =
+        ReflectionHelpers.callConstructor(
+            UriPermission.class,
+            ClassParameter.from(Uri.class, uri),
+            ClassParameter.from(int.class, modeFlags),
+            ClassParameter.from(long.class, System.currentTimeMillis()));
+    uriPermissions.add(perm);
+  }
+
+  public static ContentProvider getProvider(Uri uri) {
+    return getProvider(uri, RuntimeEnvironment.getApplication());
+  }
+
+  private static ContentProvider getProvider(Uri uri, Context context) {
+    if (uri == null || !SCHEME_CONTENT.equals(uri.getScheme())) {
+      return null;
+    }
+    return getProvider(uri.getAuthority(), context);
+  }
+
+  private static ContentProvider getProvider(String authority, Context context) {
+    synchronized (providers) {
+      if (!providers.containsKey(authority)) {
+        ProviderInfo providerInfo =
+            context.getPackageManager().resolveContentProvider(authority, 0);
+        if (providerInfo != null) {
+          ContentProvider contentProvider = createAndInitialize(providerInfo);
+          for (String auth : Splitter.on(';').split(providerInfo.authority)) {
+            providers.put(auth, contentProvider);
+          }
+        }
+      }
+      return providers.get(authority);
+    }
+  }
+
+  /**
+   * Internal-only method, do not use!
+   *
+   * <p>Instead, use
+   *
+   * <pre>
+   * ProviderInfo info = new ProviderInfo();
+   * info.authority = authority;
+   * Robolectric.buildContentProvider(ContentProvider.class).create(info);
+   * </pre>
+   */
+  public static void registerProviderInternal(String authority, ContentProvider provider) {
+    providers.put(authority, provider);
+  }
+
+  public static Status getStatus(Account account, String authority) {
+    return getStatus(account, authority, false);
+  }
+
+  /**
+   * Retrieve information on the status of the given account.
+   *
+   * @param account the account
+   * @param authority the authority
+   * @param create whether to create if no such account is found
+   * @return the account's status
+   */
+  public static Status getStatus(Account account, String authority, boolean create) {
+    Map<Account, Status> map = syncableAccounts.get(authority);
+    if (map == null) {
+      map = new HashMap<>();
+      syncableAccounts.put(authority, map);
+    }
+    Status status = map.get(account);
+    if (status == null && create) {
+      status = new Status();
+      map.put(account, status);
+    }
+    return status;
+  }
+
+  /**
+   * @deprecated This method affects all calls, and does not work with {@link
+   *     android.content.ContentResolver#acquireContentProviderClient}
+   */
+  @Deprecated
+  public void setCursor(BaseCursor cursor) {
+    this.cursor = cursor;
+  }
+
+  /**
+   * @deprecated This method does not work with {@link
+   *     android.content.ContentResolver#acquireContentProviderClient}
+   */
+  @Deprecated
+  public void setCursor(Uri uri, BaseCursor cursorForUri) {
+    this.uriCursorMap.put(uri, cursorForUri);
+  }
+
+  /**
+   * @deprecated This method affects all calls, and does not work with {@link
+   *     android.content.ContentResolver#acquireContentProviderClient}
+   */
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public void setNextDatabaseIdForInserts(int nextId) {
+    nextDatabaseIdForInserts = nextId;
+  }
+
+  /**
+   * Returns the list of {@link InsertStatement}s, {@link UpdateStatement}s, and {@link
+   * DeleteStatement}s invoked on this {@link ContentResolver}.
+   *
+   * @return a list of statements
+   * @deprecated This method does not work with {@link
+   *     android.content.ContentResolver#acquireContentProviderClient}
+   */
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public List<Statement> getStatements() {
+    return statements;
+  }
+
+  /**
+   * Returns the list of {@link InsertStatement}s for corresponding calls to {@link
+   * ContentResolver#insert(Uri, ContentValues)} or {@link ContentResolver#bulkInsert(Uri,
+   * ContentValues[])}.
+   *
+   * @return a list of insert statements
+   * @deprecated This method does not work with {@link
+   *     android.content.ContentResolver#acquireContentProviderClient}
+   */
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public List<InsertStatement> getInsertStatements() {
+    return insertStatements;
+  }
+
+  /**
+   * Returns the list of {@link UpdateStatement}s for corresponding calls to {@link
+   * ContentResolver#update(Uri, ContentValues, String, String[])}.
+   *
+   * @return a list of update statements
+   * @deprecated This method does not work with {@link
+   *     android.content.ContentResolver#acquireContentProviderClient}
+   */
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public List<UpdateStatement> getUpdateStatements() {
+    return updateStatements;
+  }
+
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public List<Uri> getDeletedUris() {
+    List<Uri> uris = new ArrayList<>();
+    for (DeleteStatement deleteStatement : deleteStatements) {
+      uris.add(deleteStatement.getUri());
+    }
+    return uris;
+  }
+
+  /**
+   * Returns the list of {@link DeleteStatement}s for corresponding calls to {@link
+   * ContentResolver#delete(Uri, String, String[])}.
+   *
+   * @return a list of delete statements
+   */
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public List<DeleteStatement> getDeleteStatements() {
+    return deleteStatements;
+  }
+
+  @Deprecated
+  @SuppressWarnings({"unused", "WeakerAccess"})
+  public List<NotifiedUri> getNotifiedUris() {
+    return notifiedUris;
+  }
+
+  @Deprecated
+  public List<ContentProviderOperation> getContentProviderOperations(String authority) {
+    List<ContentProviderOperation> operations = contentProviderOperations.get(authority);
+    if (operations == null) {
+      return new ArrayList<>();
+    }
+    return operations;
+  }
+
+  @Deprecated
+  public void setContentProviderResult(ContentProviderResult[] contentProviderResults) {
+    this.contentProviderResults = contentProviderResults;
+  }
+
+  private final Map<Uri, RuntimeException> registerContentProviderExceptions = new HashMap<>();
+
+  /** Makes {@link #registerContentObserver} throw the specified exception for the specified URI. */
+  public void setRegisterContentProviderException(Uri uri, RuntimeException exception) {
+    registerContentProviderExceptions.put(uri, exception);
+  }
+
+  /**
+   * Clears an exception previously set with {@link #setRegisterContentProviderException(Uri,
+   * RuntimeException)}.
+   */
+  public void clearRegisterContentProviderException(Uri uri) {
+    registerContentProviderExceptions.remove(uri);
+  }
+
+  @Implementation
+  protected void registerContentObserver(
+      Uri uri, boolean notifyForDescendents, ContentObserver observer) {
+    if (uri == null || observer == null) {
+      throw new NullPointerException();
+    }
+    if (registerContentProviderExceptions.containsKey(uri)) {
+      throw registerContentProviderExceptions.get(uri);
+    }
+    contentObservers.add(new ContentObserverEntry(uri, notifyForDescendents, observer));
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void registerContentObserver(
+      Uri uri, boolean notifyForDescendents, ContentObserver observer, int userHandle) {
+    registerContentObserver(uri, notifyForDescendents, observer);
+  }
+
+  @Implementation
+  protected void unregisterContentObserver(ContentObserver observer) {
+    synchronized (contentObservers) {
+      for (ContentObserverEntry entry : contentObservers) {
+        if (entry.observer == observer) {
+          contentObservers.remove(entry);
+        }
+      }
+    }
+  }
+
+  @Implementation
+  protected static SyncAdapterType[] getSyncAdapterTypes() {
+    return syncAdapterTypes;
+  }
+
+  /** Sets the SyncAdapterType array which will be returned by {@link #getSyncAdapterTypes()}. */
+  public static void setSyncAdapterTypes(SyncAdapterType[] syncAdapterTypes) {
+    ShadowContentResolver.syncAdapterTypes = syncAdapterTypes;
+  }
+
+  /**
+   * Returns the content observers registered for updates under the given URI.
+   *
+   * <p>Will be empty if no observer is registered.
+   *
+   * @param uri Given URI
+   * @return The content observers, or null
+   */
+  public Collection<ContentObserver> getContentObservers(Uri uri) {
+    ArrayList<ContentObserver> observers = new ArrayList<>(1);
+    for (ContentObserverEntry entry : contentObservers) {
+      if (entry.matches(uri)) {
+        observers.add(entry.observer);
+      }
+    }
+    return observers;
+  }
+
+  @Implementation(minSdk = Q)
+  protected static void onDbCorruption(String tag, String message, Throwable stacktrace) {
+    // No-op.
+  }
+
+  private static ContentProvider createAndInitialize(ProviderInfo providerInfo) {
+    try {
+      ContentProvider provider =
+          (ContentProvider) Class.forName(providerInfo.name).getDeclaredConstructor().newInstance();
+      provider.attachInfo(RuntimeEnvironment.application, providerInfo);
+      return provider;
+    } catch (InstantiationException
+        | ClassNotFoundException
+        | IllegalAccessException
+        | NoSuchMethodException
+        | InvocationTargetException e) {
+      throw new RuntimeException("Error instantiating class " + providerInfo.name, e);
+    }
+  }
+
+  private BaseCursor getCursor(Uri uri) {
+    if (uriCursorMap.get(uri) != null) {
+      return uriCursorMap.get(uri);
+    } else if (cursor != null) {
+      return cursor;
+    } else {
+      return null;
+    }
+  }
+
+  private static boolean isBundleEqual(Bundle bundle1, Bundle bundle2) {
+    if (bundle1 == null || bundle2 == null) {
+      return false;
+    }
+    if (bundle1.size() != bundle2.size()) {
+      return false;
+    }
+    for (String key : bundle1.keySet()) {
+      if (!bundle1.get(key).equals(bundle2.get(key))) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** A statement used to modify content in a {@link ContentProvider}. */
+  public static class Statement {
+    private final Uri uri;
+    private final ContentProvider contentProvider;
+
+    Statement(Uri uri, ContentProvider contentProvider) {
+      this.uri = uri;
+      this.contentProvider = contentProvider;
+    }
+
+    public Uri getUri() {
+      return uri;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public ContentProvider getContentProvider() {
+      return contentProvider;
+    }
+  }
+
+  /** A statement used to insert content into a {@link ContentProvider}. */
+  public static class InsertStatement extends Statement {
+    private final ContentValues[] bulkContentValues;
+
+    InsertStatement(Uri uri, ContentProvider contentProvider, ContentValues contentValues) {
+      super(uri, contentProvider);
+      this.bulkContentValues = new ContentValues[] {contentValues};
+    }
+
+    InsertStatement(Uri uri, ContentProvider contentProvider, ContentValues[] bulkContentValues) {
+      super(uri, contentProvider);
+      this.bulkContentValues = bulkContentValues;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public ContentValues getContentValues() {
+      if (bulkContentValues.length != 1) {
+        throw new ArrayIndexOutOfBoundsException("bulk insert, use getBulkContentValues() instead");
+      }
+      return bulkContentValues[0];
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public ContentValues[] getBulkContentValues() {
+      return bulkContentValues;
+    }
+  }
+
+  /** A statement used to update content in a {@link ContentProvider}. */
+  public static class UpdateStatement extends Statement {
+    private final ContentValues values;
+    private final String where;
+    private final String[] selectionArgs;
+
+    UpdateStatement(
+        Uri uri,
+        ContentProvider contentProvider,
+        ContentValues values,
+        String where,
+        String[] selectionArgs) {
+      super(uri, contentProvider);
+      this.values = values;
+      this.where = where;
+      this.selectionArgs = selectionArgs;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public ContentValues getContentValues() {
+      return values;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public String getWhere() {
+      return where;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public String[] getSelectionArgs() {
+      return selectionArgs;
+    }
+  }
+
+  /** A statement used to delete content in a {@link ContentProvider}. */
+  public static class DeleteStatement extends Statement {
+    private final String where;
+    private final String[] selectionArgs;
+
+    DeleteStatement(
+        Uri uri, ContentProvider contentProvider, String where, String[] selectionArgs) {
+      super(uri, contentProvider);
+      this.where = where;
+      this.selectionArgs = selectionArgs;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public String getWhere() {
+      return where;
+    }
+
+    @SuppressWarnings({"unused", "WeakerAccess"})
+    public String[] getSelectionArgs() {
+      return selectionArgs;
+    }
+  }
+
+  private static class UnregisteredInputStream extends InputStream implements NamedStream {
+    private final Uri uri;
+
+    UnregisteredInputStream(Uri uri) {
+      this.uri = uri;
+    }
+
+    @Override
+    public int read() throws IOException {
+      throw new UnsupportedOperationException(
+          "You must use ShadowContentResolver.registerInputStream() in order to call read()");
+    }
+
+    @Override
+    public int read(byte[] b) throws IOException {
+      throw new UnsupportedOperationException(
+          "You must use ShadowContentResolver.registerInputStream() in order to call read()");
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+      throw new UnsupportedOperationException(
+          "You must use ShadowContentResolver.registerInputStream() in order to call read()");
+    }
+
+    @Override
+    public String toString() {
+      return "stream for " + uri;
+    }
+  }
+
+  @ForType(ContentResolver.class)
+  interface ContentResolverReflector {
+    @Accessor("mContext")
+    Context getContext();
+
+    @Direct
+    InputStream openInputStream(Uri uri) throws FileNotFoundException;
+
+    @Direct
+    OutputStream openOutputStream(Uri uri) throws FileNotFoundException;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentUris.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentUris.java
new file mode 100644
index 0000000..d030882
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContentUris.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import android.content.ContentUris;
+import android.net.Uri;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(ContentUris.class)
+public class ShadowContentUris {
+
+  @Implementation
+  protected static Uri withAppendedId(Uri contentUri, long id) {
+    return Uri.withAppendedPath(contentUri, String.valueOf(id));
+  }
+
+  @Implementation
+  protected static long parseId(Uri contentUri) {
+    if (!contentUri.isHierarchical()) {
+      throw new UnsupportedOperationException();
+    }
+    String path = contentUri.getLastPathSegment();
+    if (path == null) return -1;
+    return Long.parseLong(path);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextHubClient.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextHubClient.java
new file mode 100644
index 0000000..4a99909
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextHubClient.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import android.hardware.location.ContextHubClient;
+import android.hardware.location.ContextHubTransaction;
+import android.hardware.location.NanoAppMessage;
+import android.os.Build.VERSION_CODES;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link ContextHubClient}. */
+@Implements(
+    value = ContextHubClient.class,
+    minSdk = VERSION_CODES.P,
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowContextHubClient {
+  private final List<NanoAppMessage> messages = new ArrayList<>();
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  @HiddenApi
+  protected int sendMessageToNanoApp(NanoAppMessage message) {
+    messages.add(message);
+    return ContextHubTransaction.RESULT_SUCCESS;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  @HiddenApi
+  protected void close() {}
+
+  public List<NanoAppMessage> getMessages() {
+    return ImmutableList.copyOf(messages);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextHubManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextHubManager.java
new file mode 100644
index 0000000..7b98fbf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextHubManager.java
@@ -0,0 +1,227 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.hardware.location.ContextHubClient;
+import android.hardware.location.ContextHubInfo;
+import android.hardware.location.ContextHubManager;
+import android.hardware.location.ContextHubTransaction;
+import android.hardware.location.NanoAppInstanceInfo;
+import android.hardware.location.NanoAppState;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.Nullable;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link ContextHubManager}. */
+@Implements(
+    value = ContextHubManager.class,
+    minSdk = VERSION_CODES.N,
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowContextHubManager {
+  private static final List<ContextHubInfo> contextHubInfoList = new ArrayList<>();
+  private static final List<ContextHubClient> contextHubClientWithPendingIntentList =
+      new ArrayList<>();
+  private final Map<Integer, NanoAppInstanceInfo> nanoAppUidToInfo = new ConcurrentHashMap<>();
+  private final Multimap<ContextHubInfo, Integer> contextHubToNanoappUid =
+      Multimaps.synchronizedMultimap(HashMultimap.<ContextHubInfo, Integer>create());
+  private final HashMultimap<String, ContextHubClient> attributionTagToClientMap =
+      HashMultimap.create();
+
+  static {
+    contextHubInfoList.add(new ContextHubInfo());
+  }
+
+  /** Adds a nanoApp to the list of nanoApps that are supported by the provided contexthubinfo. */
+  public void addNanoApp(ContextHubInfo info, int nanoAppUid, long nanoAppId, int nanoAppVersion) {
+    contextHubToNanoappUid.put(info, nanoAppUid);
+    NanoAppInstanceInfo instanceInfo =
+        createInstanceInfo(info, nanoAppUid, nanoAppId, nanoAppVersion);
+    nanoAppUidToInfo.put(nanoAppUid, instanceInfo);
+  }
+
+  /** Creates and returns a {@link NanoAppInstanceInfo}. */
+  public NanoAppInstanceInfo createInstanceInfo(
+      ContextHubInfo info, int nanoAppUid, long nanoAppId, int nanoAppVersion) {
+    if (VERSION.SDK_INT >= VERSION_CODES.P) {
+      return new NanoAppInstanceInfo(nanoAppUid, nanoAppId, nanoAppVersion, info.getId());
+    } else {
+      NanoAppInstanceInfo instanceInfo = new NanoAppInstanceInfo();
+      ReflectorNanoAppInstanceInfo reflectedInfo =
+          reflector(ReflectorNanoAppInstanceInfo.class, instanceInfo);
+      reflectedInfo.setAppId(nanoAppId);
+      reflectedInfo.setAppVersion(nanoAppVersion);
+      return instanceInfo;
+    }
+  }
+
+  /**
+   * Provides a list with fake {@link ContextHubInfo}s.
+   *
+   * <p>{@link ContextHubInfo} describes an optional physical chip on the device. This does not
+   * exist in test; this implementation allows to avoid possible NPEs.
+   */
+  @Implementation(minSdk = VERSION_CODES.P)
+  @HiddenApi
+  protected List<ContextHubInfo> getContextHubs() {
+    return contextHubInfoList;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  @HiddenApi
+  protected Object /* ContextHubClient */ createClient(
+      Object /* ContextHubInfo */ contextHubInfo,
+      Object /* ContextHubClientCallback */ contextHubClientCallback) {
+    return ReflectionHelpers.newInstance(ContextHubClient.class);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  @HiddenApi
+  protected Object /* ContextHubClient */ createClient(
+      Object /* ContextHubInfo */ contextHubInfo,
+      Object /* ContextHubClientCallback */ contextHubClientCallback,
+      Object /* Executor */ executor) {
+    return ReflectionHelpers.newInstance(ContextHubClient.class);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  @HiddenApi
+  protected Object /* ContextHubClient */ createClient(
+      Object /* Context */ context,
+      Object /* ContextHubInfo */ contextHubInfo,
+      Object /* Executor */ executor,
+      Object /* ContextHubClientCallback */ contextHubClientCallback) {
+    ContextHubClient client = ReflectionHelpers.newInstance(ContextHubClient.class);
+    if (context != null && ((Context) context).getAttributionTag() != null) {
+      attributionTagToClientMap.put(((Context) context).getAttributionTag(), client);
+    }
+    return client;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  @HiddenApi
+  protected Object /* ContextHubClient */ createClient(
+      Context context, ContextHubInfo hubInfo, PendingIntent pendingIntent, long nanoAppId) {
+    ContextHubClient client =
+        Shadow.newInstance(
+            ContextHubClient.class,
+            new Class<?>[] {ContextHubInfo.class, Boolean.TYPE},
+            new Object[] {hubInfo, false});
+    contextHubClientWithPendingIntentList.add(client);
+    return client;
+  }
+
+  @Nullable
+  public List<ContextHubClient> getClientsWithAttributionTag(String attributionTag) {
+    return ImmutableList.copyOf(attributionTagToClientMap.get(attributionTag));
+  }
+
+  @Nullable
+  public List<ContextHubClient> getContextHubClientWithPendingIntentList() {
+    return ImmutableList.copyOf(contextHubClientWithPendingIntentList);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  @HiddenApi
+  protected Object queryNanoApps(ContextHubInfo hubInfo) {
+    @SuppressWarnings("unchecked")
+    ContextHubTransaction<List<NanoAppState>> transaction =
+        ReflectionHelpers.callConstructor(
+            ContextHubTransaction.class,
+            ClassParameter.from(int.class, ContextHubTransaction.TYPE_QUERY_NANOAPPS));
+    Collection<Integer> uids = contextHubToNanoappUid.get(hubInfo);
+    List<NanoAppState> nanoAppStates = new ArrayList<>();
+
+    for (Integer uid : uids) {
+      NanoAppInstanceInfo info = nanoAppUidToInfo.get(uid);
+      if (info != null) {
+        nanoAppStates.add(
+            new NanoAppState(info.getAppId(), info.getAppVersion(), true /* enabled */));
+      }
+    }
+    @SuppressWarnings("unchecked")
+    ContextHubTransaction.Response<List<NanoAppState>> response =
+        ReflectionHelpers.newInstance(ContextHubTransaction.Response.class);
+    ReflectorContextHubTransactionResponse reflectedResponse =
+        reflector(ReflectorContextHubTransactionResponse.class, response);
+    reflectedResponse.setResult(ContextHubTransaction.RESULT_SUCCESS);
+    reflectedResponse.setContents(nanoAppStates);
+    reflector(ReflectorContextHubTransaction.class, transaction).setResponse(response);
+    return transaction;
+  }
+
+  /**
+   * Provides an array of fake handles.
+   *
+   * <p>These describe an optional physical chip on the device which does not exist during testing.
+   * This implementation enables testing of classes that utilize these APIs.
+   */
+  @Implementation
+  @HiddenApi
+  protected int[] getContextHubHandles() {
+    int[] handles = new int[contextHubInfoList.size()];
+    for (int i = 0; i < handles.length; i++) {
+      handles[i] = i;
+    }
+    return handles;
+  }
+
+  @Implementation
+  @HiddenApi
+  protected ContextHubInfo getContextHubInfo(int hubHandle) {
+    if (hubHandle < 0 || hubHandle >= contextHubInfoList.size()) {
+      return null;
+    }
+
+    return contextHubInfoList.get(hubHandle);
+  }
+
+  @Implementation
+  @HiddenApi
+  protected NanoAppInstanceInfo getNanoAppInstanceInfo(int nanoAppHandle) {
+    return nanoAppUidToInfo.get(nanoAppHandle);
+  }
+
+  /** Accessor interface for {@link NanoAppInstanceInfo}'s internals. */
+  @ForType(NanoAppInstanceInfo.class)
+  private interface ReflectorNanoAppInstanceInfo {
+    void setAppId(long nanoAppId);
+
+    void setAppVersion(int nanoAppVersion);
+  }
+
+  /** Accessor interface for {@link ContextHubTransaction}'s internals. */
+  @ForType(ContextHubTransaction.class)
+  private interface ReflectorContextHubTransaction {
+    void setResponse(ContextHubTransaction.Response<List<NanoAppState>> response);
+  }
+
+  /** Accessor interface for {@link ContextHubTransaction.Response}'s internals. */
+  @ForType(ContextHubTransaction.Response.class)
+  private interface ReflectorContextHubTransactionResponse {
+    @Accessor("mResult")
+    void setResult(int result);
+
+    @Accessor("mContents")
+    void setContents(List<NanoAppState> contents);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextImpl.java
new file mode 100644
index 0000000..b74295b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextImpl.java
@@ -0,0 +1,517 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.shadow.api.Shadow.directlyOn;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.app.ActivityThread;
+import android.app.LoadedApk;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.IContentProvider;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.IntentSender;
+import android.content.ServiceConnection;
+import android.content.SharedPreferences;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.FileUtils;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.UserHandle;
+import com.google.common.base.Strings;
+import com.google.errorprone.annotations.concurrent.GuardedBy;
+import java.io.File;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(className = ShadowContextImpl.CLASS_NAME)
+@SuppressWarnings("NewApi")
+public class ShadowContextImpl {
+
+  public static final String CLASS_NAME = "android.app.ContextImpl";
+
+  @RealObject private Context realContextImpl;
+
+  private Map<String, Object> systemServices = new HashMap<String, Object>();
+  private final Set<String> removedSystemServices = new HashSet<>();
+  private final Object contentResolverLock = new Object();
+
+  @GuardedBy("contentResolverLock")
+  private ContentResolver contentResolver;
+
+  private Integer userId;
+
+  /**
+   * Returns the handle to a system-level service by name. If the service is not available in
+   * Roboletric, or it is set to unavailable in {@link ShadowServiceManager#setServiceAvailability},
+   * {@code null} will be returned.
+   */
+  @Implementation
+  @Nullable
+  protected Object getSystemService(String name) {
+    if (removedSystemServices.contains(name)) {
+      return null;
+    }
+    if (!systemServices.containsKey(name)) {
+      return reflector(_ContextImpl_.class, realContextImpl).getSystemService(name);
+    }
+    return systemServices.get(name);
+  }
+
+  public void setSystemService(String key, Object service) {
+    systemServices.put(key, service);
+  }
+
+  /**
+   * Makes {@link #getSystemService(String)} return {@code null} for the given system service name,
+   * mimicking a device that doesn't have that system service.
+   */
+  public void removeSystemService(String name) {
+    removedSystemServices.add(name);
+  }
+
+  @Implementation
+  protected void startIntentSender(
+      IntentSender intent,
+      Intent fillInIntent,
+      int flagsMask,
+      int flagsValues,
+      int extraFlags,
+      Bundle options)
+      throws IntentSender.SendIntentException {
+    intent.sendIntent(realContextImpl, 0, fillInIntent, null, null, null);
+  }
+
+  @Implementation
+  protected ClassLoader getClassLoader() {
+    return this.getClass().getClassLoader();
+  }
+
+  @Implementation
+  protected int checkCallingPermission(String permission) {
+    return checkPermission(permission, android.os.Process.myPid(), android.os.Process.myUid());
+  }
+
+  @Implementation
+  protected int checkCallingOrSelfPermission(String permission) {
+    return checkCallingPermission(permission);
+  }
+
+  @Implementation
+  protected ContentResolver getContentResolver() {
+    synchronized (contentResolverLock) {
+      if (contentResolver == null) {
+        contentResolver =
+            new ContentResolver(realContextImpl) {
+              @Override
+              protected IContentProvider acquireProvider(Context c, String name) {
+                return null;
+              }
+
+              @Override
+              public boolean releaseProvider(IContentProvider icp) {
+                return false;
+              }
+
+              @Override
+              protected IContentProvider acquireUnstableProvider(Context c, String name) {
+                return null;
+              }
+
+              @Override
+              public boolean releaseUnstableProvider(IContentProvider icp) {
+                return false;
+              }
+
+              @Override
+              public void unstableProviderDied(IContentProvider icp) {}
+            };
+      }
+      return contentResolver;
+    }
+  }
+
+  @Implementation
+  protected void sendBroadcast(Intent intent) {
+    getShadowInstrumentation()
+        .sendBroadcastWithPermission(
+            intent, /*userHandle=*/ null, /*receiverPermission=*/ null, realContextImpl);
+  }
+
+  @Implementation
+  protected void sendBroadcast(Intent intent, String receiverPermission) {
+    getShadowInstrumentation()
+        .sendBroadcastWithPermission(
+            intent, /*userHandle=*/ null, receiverPermission, realContextImpl);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  @RequiresPermission(android.Manifest.permission.INTERACT_ACROSS_USERS)
+  protected void sendBroadcastAsUser(@RequiresPermission Intent intent, UserHandle user) {
+    getShadowInstrumentation()
+        .sendBroadcastWithPermission(intent, user, /*receiverPermission=*/ null, realContextImpl);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  @RequiresPermission(android.Manifest.permission.INTERACT_ACROSS_USERS)
+  protected void sendBroadcastAsUser(
+      @RequiresPermission Intent intent, UserHandle user, @Nullable String receiverPermission) {
+    getShadowInstrumentation()
+        .sendBroadcastWithPermission(intent, user, receiverPermission, realContextImpl);
+  }
+
+  @Implementation
+  protected void sendOrderedBroadcast(Intent intent, String receiverPermission) {
+    getShadowInstrumentation()
+        .sendOrderedBroadcastWithPermission(intent, receiverPermission, realContextImpl);
+  }
+
+  @Implementation
+  protected void sendOrderedBroadcast(
+      Intent intent,
+      String receiverPermission,
+      BroadcastReceiver resultReceiver,
+      Handler scheduler,
+      int initialCode,
+      String initialData,
+      Bundle initialExtras) {
+    getShadowInstrumentation()
+        .sendOrderedBroadcastAsUser(
+            intent,
+            /*userHandle=*/ null,
+            receiverPermission,
+            resultReceiver,
+            scheduler,
+            initialCode,
+            initialData,
+            initialExtras,
+            realContextImpl);
+  }
+
+  /**
+   * Allows the test to query for the broadcasts for specific users, for everything else behaves as
+   * {@link #sendOrderedBroadcastAsUser}.
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void sendOrderedBroadcastAsUser(
+      Intent intent,
+      UserHandle userHandle,
+      String receiverPermission,
+      BroadcastReceiver resultReceiver,
+      Handler scheduler,
+      int initialCode,
+      String initialData,
+      Bundle initialExtras) {
+    getShadowInstrumentation()
+        .sendOrderedBroadcastAsUser(
+            intent,
+            userHandle,
+            receiverPermission,
+            resultReceiver,
+            scheduler,
+            initialCode,
+            initialData,
+            initialExtras,
+            realContextImpl);
+  }
+
+  /** Behaves as {@link #sendOrderedBroadcastAsUser}. Currently ignores appOp and options. */
+  @Implementation(minSdk = M)
+  protected void sendOrderedBroadcastAsUser(
+      Intent intent,
+      UserHandle userHandle,
+      String receiverPermission,
+      int appOp,
+      Bundle options,
+      BroadcastReceiver resultReceiver,
+      Handler scheduler,
+      int initialCode,
+      String initialData,
+      Bundle initialExtras) {
+    sendOrderedBroadcastAsUser(
+        intent,
+        userHandle,
+        receiverPermission,
+        resultReceiver,
+        scheduler,
+        initialCode,
+        initialData,
+        initialExtras);
+  }
+
+  @Implementation
+  protected void sendStickyBroadcast(Intent intent) {
+    getShadowInstrumentation().sendStickyBroadcast(intent, realContextImpl);
+  }
+
+  @Implementation
+  protected int checkPermission(String permission, int pid, int uid) {
+    return getShadowInstrumentation().checkPermission(permission, pid, uid);
+  }
+
+  @Implementation
+  protected Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+    return getShadowInstrumentation().registerReceiver(receiver, filter, 0, realContextImpl);
+  }
+
+  @Implementation(minSdk = O)
+  protected Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter, int flags) {
+    return getShadowInstrumentation().registerReceiver(receiver, filter, flags, realContextImpl);
+  }
+
+  @Implementation
+  protected Intent registerReceiver(
+      BroadcastReceiver receiver,
+      IntentFilter filter,
+      String broadcastPermission,
+      Handler scheduler) {
+    return getShadowInstrumentation()
+        .registerReceiver(receiver, filter, broadcastPermission, scheduler, 0, realContextImpl);
+  }
+
+  @Implementation(minSdk = O)
+  protected Intent registerReceiver(
+      BroadcastReceiver receiver,
+      IntentFilter filter,
+      String broadcastPermission,
+      Handler scheduler,
+      int flags) {
+    return getShadowInstrumentation()
+        .registerReceiver(receiver, filter, broadcastPermission, scheduler, flags, realContextImpl);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected Intent registerReceiverAsUser(
+      BroadcastReceiver receiver,
+      UserHandle user,
+      IntentFilter filter,
+      String broadcastPermission,
+      Handler scheduler) {
+    return getShadowInstrumentation()
+        .registerReceiverWithContext(
+            receiver, filter, broadcastPermission, scheduler, 0, realContextImpl);
+  }
+
+  @Implementation
+  protected void unregisterReceiver(BroadcastReceiver broadcastReceiver) {
+    getShadowInstrumentation().unregisterReceiver(broadcastReceiver);
+  }
+
+  @Implementation
+  protected ComponentName startService(Intent service) {
+    validateServiceIntent(service);
+    return getShadowInstrumentation().startService(service);
+  }
+
+  @Implementation(minSdk = O)
+  protected ComponentName startForegroundService(Intent service) {
+    return startService(service);
+  }
+
+  @Implementation
+  protected boolean stopService(Intent name) {
+    validateServiceIntent(name);
+    return getShadowInstrumentation().stopService(name);
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean bindService(
+      Intent service, int flags, Executor executor, ServiceConnection conn) {
+    return getShadowInstrumentation().bindService(service, flags, executor, conn);
+  }
+
+  @Implementation
+  protected boolean bindService(Intent intent, final ServiceConnection serviceConnection, int i) {
+    validateServiceIntent(intent);
+    return getShadowInstrumentation().bindService(intent, serviceConnection, i);
+  }
+
+  /** Binds to a service but ignores the given UserHandle. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean bindServiceAsUser(
+      Intent intent, final ServiceConnection serviceConnection, int i, UserHandle userHandle) {
+    return bindService(intent, serviceConnection, i);
+  }
+
+  @Implementation
+  protected void unbindService(final ServiceConnection serviceConnection) {
+    getShadowInstrumentation().unbindService(serviceConnection);
+  }
+
+  // This is a private method in ContextImpl so we copy the relevant portions of it here.
+  @Implementation(minSdk = KITKAT)
+  protected void validateServiceIntent(Intent service) {
+    if (service.getComponent() == null
+        && service.getPackage() == null
+        && realContextImpl.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP) {
+      throw new IllegalArgumentException("Service Intent must be explicit: " + service);
+    }
+  }
+
+  /**
+   * Behaves as {@link android.app.ContextImpl#startActivity(Intent, Bundle)}. The user parameter is
+   * ignored.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void startActivityAsUser(Intent intent, Bundle options, UserHandle user) {
+    // TODO: Remove this once {@link com.android.server.wmActivityTaskManagerService} is
+    // properly shadowed.
+    reflector(_ContextImpl_.class, realContextImpl).startActivity(intent, options);
+  }
+
+  /* Set the user id returned by {@link #getUserId()}. */
+  public void setUserId(int userId) {
+    this.userId = userId;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected int getUserId() {
+    if (userId != null) {
+      return userId;
+    } else {
+      return directlyOn(realContextImpl, ShadowContextImpl.CLASS_NAME, "getUserId");
+    }
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR2)
+  protected File getExternalFilesDir(String type) {
+    return Environment.getExternalStoragePublicDirectory(type);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected File[] getExternalFilesDirs(String type) {
+    return new File[] {Environment.getExternalStoragePublicDirectory(type)};
+  }
+
+  @Resetter
+  public static void reset() {
+    String prefsCacheFieldName =
+        RuntimeEnvironment.getApiLevel() >= N ? "sSharedPrefsCache" : "sSharedPrefs";
+    Object prefsDefaultValue = RuntimeEnvironment.getApiLevel() >= KITKAT ? null : new HashMap<>();
+    Class<?> contextImplClass =
+        ReflectionHelpers.loadClass(
+            ShadowContextImpl.class.getClassLoader(), "android.app.ContextImpl");
+    ReflectionHelpers.setStaticField(contextImplClass, prefsCacheFieldName, prefsDefaultValue);
+
+    if (RuntimeEnvironment.getApiLevel() <= VERSION_CODES.LOLLIPOP_MR1) {
+      HashMap<String, Object> fetchers =
+          ReflectionHelpers.getStaticField(contextImplClass, "SYSTEM_SERVICE_MAP");
+      Class staticServiceFetcherClass =
+          ReflectionHelpers.loadClass(
+              ShadowContextImpl.class.getClassLoader(),
+              "android.app.ContextImpl$StaticServiceFetcher");
+
+      for (Object o : fetchers.values()) {
+        if (staticServiceFetcherClass.isInstance(o)) {
+          ReflectionHelpers.setField(staticServiceFetcherClass, o, "mCachedInstance", null);
+        }
+      }
+
+      if (RuntimeEnvironment.getApiLevel() >= KITKAT) {
+
+        Object windowServiceFetcher = fetchers.get(Context.WINDOW_SERVICE);
+        ReflectionHelpers.setField(
+            windowServiceFetcher.getClass(), windowServiceFetcher, "mDefaultDisplay", null);
+      }
+    }
+  }
+
+  private ShadowInstrumentation getShadowInstrumentation() {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    return Shadow.extract(activityThread.getInstrumentation());
+  }
+
+  @Implementation
+  public File getDatabasePath(String name) {
+    // Windows is an abomination.
+    if (File.separatorChar == '\\' && Paths.get(name).isAbsolute()) {
+      String dirPath = name.substring(0, name.lastIndexOf(File.separatorChar));
+      File dir = new File(dirPath);
+      name = name.substring(name.lastIndexOf(File.separatorChar));
+      File f = new File(dir, name);
+      if (!dir.isDirectory() && dir.mkdir()) {
+        FileUtils.setPermissions(dir.getPath(), 505, -1, -1);
+      }
+      return f;
+    } else {
+      return reflector(_ContextImpl_.class, realContextImpl).getDatabasePath(name);
+    }
+  }
+
+  @Implementation
+  protected SharedPreferences getSharedPreferences(String name, int mode) {
+    // Windows does not allow colons in file names, which may be used in shared preference
+    // names. URL-encode any colons in Windows.
+    if (!Strings.isNullOrEmpty(name) && File.separatorChar == '\\') {
+      name = name.replace(":", "%3A");
+    }
+    return reflector(_ContextImpl_.class, realContextImpl).getSharedPreferences(name, mode);
+  }
+
+  /** Reflector interface for {@link android.app.ContextImpl}'s internals. */
+  @ForType(className = CLASS_NAME)
+  public interface _ContextImpl_ {
+    @Static
+    Context createSystemContext(ActivityThread activityThread);
+
+    @Static
+    Context createAppContext(ActivityThread activityThread, LoadedApk loadedApk);
+
+    @Static
+    Context createActivityContext(
+        ActivityThread mainThread,
+        LoadedApk packageInfo,
+        ActivityInfo activityInfo,
+        IBinder activityToken,
+        int displayId,
+        Configuration overrideConfiguration);
+
+    void setOuterContext(Context context);
+
+    @Direct
+    Object getSystemService(String name);
+
+    void startActivity(Intent intent, Bundle options);
+
+    @Direct
+    File getDatabasePath(String name);
+
+    @Direct
+    SharedPreferences getSharedPreferences(String name, int mode);
+
+    @Accessor("mClassLoader")
+    void setClassLoader(ClassLoader classLoader);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextThemeWrapper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextThemeWrapper.java
new file mode 100644
index 0000000..5f569b9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextThemeWrapper.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import android.view.ContextThemeWrapper;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ContextThemeWrapper.class)
+public class ShadowContextThemeWrapper extends ShadowContextWrapper {
+  @RealObject private ContextThemeWrapper realContextThemeWrapper;
+
+  public Integer callGetThemeResId() {
+    return ReflectionHelpers.callInstanceMethod(realContextThemeWrapper, "getThemeResId");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextWrapper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextWrapper.java
new file mode 100644
index 0000000..3f821b7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowContextWrapper.java
@@ -0,0 +1,173 @@
+package org.robolectric.shadows;
+
+import android.app.ActivityThread;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.UserHandle;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowActivity.IntentForResult;
+
+@Implements(ContextWrapper.class)
+public class ShadowContextWrapper {
+
+  @RealObject private ContextWrapper realContextWrapper;
+
+  /** Returns the broadcast intents sent during the tests (for all users). */
+  public List<Intent> getBroadcastIntents() {
+    return getShadowInstrumentation().getBroadcastIntents();
+  }
+
+  /** Returns the broadcast options when the intent was last sent. */
+  public Bundle getBroadcastOptions(Intent intent) {
+    return getShadowInstrumentation().getBroadcastOptions(intent);
+  }
+
+  /** Returns the broadcast intents sent to the given user. */
+  public List<Intent> getBroadcastIntentsForUser(UserHandle userHandle) {
+    return getShadowInstrumentation().getBroadcastIntentsForUser(userHandle);
+  }
+
+  /** Clears the broadcast intents sent during the tests (for all users). */
+  public void clearBroadcastIntents() {
+    getShadowInstrumentation().clearBroadcastIntents();
+  }
+
+  /**
+   * Consumes the most recent {@code Intent} started by {@link
+   * ContextWrapper#startActivity(android.content.Intent)} and returns it.
+   *
+   * @return the most recently started {@code Intent}
+   */
+  public Intent getNextStartedActivity() {
+    return getShadowInstrumentation().getNextStartedActivity();
+  }
+
+  /**
+   * Returns the most recent {@code Intent} started by {@link
+   * ContextWrapper#startActivity(android.content.Intent)} without consuming it.
+   *
+   * @return the most recently started {@code Intent}
+   */
+  public Intent peekNextStartedActivity() {
+    return getShadowInstrumentation().peekNextStartedActivity();
+  }
+
+  /**
+   * Clears all {@code Intent}s started by {@link
+   * ContextWrapper#startActivity(android.content.Intent)}.
+   */
+  public void clearNextStartedActivities() {
+    getShadowInstrumentation().clearNextStartedActivities();
+  }
+
+  /**
+   * Consumes the most recent {@code IntentForResult} started by {@link *
+   * ContextWrapper#startActivity(android.content.Intent, android.os.Bundle)} and returns it.
+   *
+   * @return the most recently started {@code IntentForResult}
+   */
+  public IntentForResult getNextStartedActivityForResult() {
+    return getShadowInstrumentation().getNextStartedActivityForResult();
+  }
+
+  /**
+   * Returns the most recent {@code IntentForResult} started by {@link
+   * ContextWrapper#startActivity(android.content.Intent, android.os.Bundle)} without consuming it.
+   *
+   * @return the most recently started {@code IntentForResult}
+   */
+  public IntentForResult peekNextStartedActivityForResult() {
+    return getShadowInstrumentation().peekNextStartedActivityForResult();
+  }
+
+  /**
+   * Consumes the most recent {@code Intent} started by {@link
+   * android.content.Context#startService(android.content.Intent)} and returns it.
+   *
+   * @return the most recently started {@code Intent}
+   */
+  public Intent getNextStartedService() {
+    return getShadowInstrumentation().getNextStartedService();
+  }
+
+  /**
+   * Returns the most recent {@code Intent} started by {@link
+   * android.content.Context#startService(android.content.Intent)} without consuming it.
+   *
+   * @return the most recently started {@code Intent}
+   */
+  public Intent peekNextStartedService() {
+    return getShadowInstrumentation().peekNextStartedService();
+  }
+
+  /**
+   * Returns all {@code Intent} started by {@link #startService(android.content.Intent)} without
+   * consuming them.
+   *
+   * @return the list of {@code Intent}
+   */
+  public List<Intent> getAllStartedServices() {
+    return getShadowInstrumentation().getAllStartedServices();
+  }
+
+  /**
+   * Clears all {@code Intent} started by {@link
+   * android.content.Context#startService(android.content.Intent)}.
+   */
+  public void clearStartedServices() {
+    getShadowInstrumentation().clearStartedServices();
+  }
+
+  /**
+   * Consumes the {@code Intent} requested to stop a service by {@link
+   * android.content.Context#stopService(android.content.Intent)} from the bottom of the stack of
+   * stop requests.
+   */
+  public Intent getNextStoppedService() {
+    return getShadowInstrumentation().getNextStoppedService();
+  }
+
+  /** Grant the given permissions for the current process and user. */
+  public void grantPermissions(String... permissionNames) {
+    getShadowInstrumentation().grantPermissions(permissionNames);
+  }
+
+  /** Grant the given permissions for the given process and user. */
+  public void grantPermissions(int pid, int uid, String... permissions) {
+    getShadowInstrumentation().grantPermissions(pid, uid, permissions);
+  }
+
+  /**
+   * Revoke the given permissions for the current process and user.
+   *
+   * <p>Has no effect if permissions were not previously granted.
+   */
+  public void denyPermissions(String... permissionNames) {
+    getShadowInstrumentation().denyPermissions(permissionNames);
+  }
+
+  /** Revoke the given permissions for the given process and user. */
+  public void denyPermissions(int pid, int uid, String... permissions) {
+    getShadowInstrumentation().denyPermissions(pid, uid, permissions);
+  }
+
+  static ShadowInstrumentation getShadowInstrumentation() {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    return Shadow.extract(activityThread.getInstrumentation());
+  }
+
+  /**
+   * Makes {@link Context#getSystemService(String)} return {@code null} for the given system service
+   * name, mimicking a device that doesn't have that system service.
+   */
+  public void removeSystemService(String name) {
+    ((ShadowContextImpl) Shadow.extract(realContextWrapper.getBaseContext()))
+        .removeSystemService(name);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCookieManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCookieManager.java
new file mode 100644
index 0000000..a44ecf5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCookieManager.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import android.webkit.CookieManager;
+import android.webkit.RoboCookieManager;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(CookieManager.class)
+public class ShadowCookieManager {
+  private static RoboCookieManager cookieManager;
+  private boolean flushed;
+
+  @Resetter
+  public static void resetCookies() {
+    cookieManager = null;
+  }
+
+  @Implementation
+  protected static CookieManager getInstance() {
+    if (cookieManager == null) {
+      cookieManager = new RoboCookieManager();
+    }
+    return cookieManager;
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCookieSyncManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCookieSyncManager.java
new file mode 100644
index 0000000..7bfe48d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCookieSyncManager.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.webkit.CookieSyncManager;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(CookieSyncManager.class)
+public class ShadowCookieSyncManager extends ShadowWebSyncManager {
+
+  private static CookieSyncManager sRef;
+
+  @Implementation
+  protected static synchronized CookieSyncManager createInstance(Context ctx) {
+    if (sRef == null) {
+      sRef = Shadow.newInstanceOf(CookieSyncManager.class);
+    }
+    return sRef;
+  }
+
+  @Implementation
+  protected static CookieSyncManager getInstance() {
+    if (sRef == null) {
+      throw new IllegalStateException("createInstance must be called first");
+    }
+    return sRef;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCountDownTimer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCountDownTimer.java
new file mode 100644
index 0000000..78d58f6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCountDownTimer.java
@@ -0,0 +1,60 @@
+package org.robolectric.shadows;
+
+import android.os.CountDownTimer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(CountDownTimer.class)
+public class ShadowCountDownTimer {
+  private boolean started;
+  private long countDownInterval;
+  private long millisInFuture;
+
+  @RealObject CountDownTimer countDownTimer;
+
+  @Implementation
+  protected void __constructor__(long millisInFuture, long countDownInterval) {
+    this.countDownInterval = countDownInterval;
+    this.millisInFuture = millisInFuture;
+    this.started = false;
+    Shadow.invokeConstructor(
+        CountDownTimer.class,
+        countDownTimer,
+        ClassParameter.from(long.class, millisInFuture),
+        ClassParameter.from(long.class, countDownInterval));
+  }
+
+  @Implementation
+  protected final synchronized CountDownTimer start() {
+    started = true;
+    return countDownTimer;
+  }
+
+  @Implementation
+  protected final void cancel() {
+    started = false;
+  }
+
+  public void invokeTick(long millisUntilFinished) {
+    countDownTimer.onTick(millisUntilFinished);
+  }
+
+  public void invokeFinish() {
+    countDownTimer.onFinish();
+  }
+
+  public boolean hasStarted() {
+    return started;
+  }
+
+  public long getCountDownInterval() {
+    return countDownInterval;
+  }
+
+  public long getMillisInFuture() {
+    return millisInFuture;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCrossProfileApps.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCrossProfileApps.java
new file mode 100644
index 0000000..a895fcb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCrossProfileApps.java
@@ -0,0 +1,550 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.Manifest.permission;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.app.Activity;
+import android.app.AppOpsManager;
+import android.app.AppOpsManager.Mode;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.CrossProfileApps;
+import android.content.pm.ICrossProfileApps;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Process;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.text.TextUtils;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Robolectric implementation of {@link CrossProfileApps}. */
+@Implements(value = CrossProfileApps.class, minSdk = P)
+public class ShadowCrossProfileApps {
+
+  private final Set<UserHandle> targetUserProfiles = new LinkedHashSet<>();
+  private final List<StartedMainActivity> startedMainActivities = new ArrayList<>();
+  private final List<StartedActivity> startedActivities =
+      Collections.synchronizedList(new ArrayList<>());
+
+  private Context context;
+  private PackageManager packageManager;
+  // Whether the current application has the interact across profile AppOps.
+  private volatile int canInteractAcrossProfileAppOps = AppOpsManager.MODE_ERRORED;
+
+  // Whether the current application has requested the interact across profile permission.
+  private volatile boolean hasRequestedInteractAcrossProfiles = false;
+
+  @Implementation
+  protected void __constructor__(Context context, ICrossProfileApps service) {
+    this.context = context;
+    this.packageManager = context.getPackageManager();
+  }
+
+  /**
+   * Returns a list of {@link UserHandle}s currently accessible. This list is populated from calls
+   * to {@link #addTargetUserProfile(UserHandle)}.
+   */
+  @Implementation
+  protected List<UserHandle> getTargetUserProfiles() {
+    return ImmutableList.copyOf(targetUserProfiles);
+  }
+
+  /**
+   * Returns a {@link Drawable} that can be shown for profile switching, which is guaranteed to
+   * always be the same for a particular user and to be distinct between users.
+   */
+  @Implementation
+  protected Drawable getProfileSwitchingIconDrawable(UserHandle userHandle) {
+    verifyCanAccessUser(userHandle);
+    return new ColorDrawable(userHandle.getIdentifier());
+  }
+
+  /**
+   * Returns a {@link CharSequence} that can be shown as a label for profile switching, which is
+   * guaranteed to always be the same for a particular user and to be distinct between users.
+   */
+  @Implementation
+  protected CharSequence getProfileSwitchingLabel(UserHandle userHandle) {
+    verifyCanAccessUser(userHandle);
+    return "Switch to " + userHandle;
+  }
+
+  /**
+   * Simulates starting the main activity specified in the specified profile, performing the same
+   * security checks done by the real {@link CrossProfileApps}.
+   *
+   * <p>The most recent main activity started can be queried by {@link #peekNextStartedActivity()}.
+   */
+  @Implementation
+  protected void startMainActivity(ComponentName componentName, UserHandle targetUser) {
+    verifyCanAccessUser(targetUser);
+    verifyActivityInManifest(componentName, /* requireMainActivity= */ true);
+    startedMainActivities.add(new StartedMainActivity(componentName, targetUser));
+    startedActivities.add(new StartedActivity(componentName, targetUser));
+  }
+
+  /**
+   * Simulates starting the activity specified in the specified profile, performing the same
+   * security checks done by the real {@link CrossProfileApps}.
+   *
+   * <p>The most recent main activity started can be queried by {@link #peekNextStartedActivity()}.
+   */
+  @Implementation(minSdk = Q)
+  @SystemApi
+  @RequiresPermission(permission.INTERACT_ACROSS_PROFILES)
+  protected void startActivity(ComponentName componentName, UserHandle targetUser) {
+    verifyCanAccessUser(targetUser);
+    verifyActivityInManifest(componentName, /* requireMainActivity= */ false);
+    verifyHasInteractAcrossProfilesPermission();
+    startedActivities.add(new StartedActivity(componentName, targetUser));
+  }
+
+  /**
+   * Simulates starting the activity specified in the specified profile, performing the same
+   * security checks done by the real {@link CrossProfileApps}.
+   *
+   * <p>The most recent main activity started can be queried by {@link #peekNextStartedActivity()}.
+   */
+  @Implementation(minSdk = R)
+  @SystemApi
+  @RequiresPermission(permission.INTERACT_ACROSS_PROFILES)
+  protected void startActivity(Intent intent, UserHandle targetUser, @Nullable Activity activity) {
+    startActivity(intent, targetUser, activity, /* options= */ null);
+  }
+
+  /**
+   * Simulates starting the activity specified in the specified profile, performing the same
+   * security checks done by the real {@link CrossProfileApps}.
+   *
+   * <p>The most recent main activity started can be queried by {@link #peekNextStartedActivity()}.
+   */
+  @Implementation(minSdk = R)
+  @SystemApi
+  @RequiresPermission(permission.INTERACT_ACROSS_PROFILES)
+  protected void startActivity(
+      Intent intent, UserHandle targetUser, @Nullable Activity activity, @Nullable Bundle options) {
+    ComponentName componentName = intent.getComponent();
+    if (componentName == null) {
+      throw new IllegalArgumentException("Must set ComponentName on Intent");
+    }
+    verifyCanAccessUser(targetUser);
+    verifyHasInteractAcrossProfilesPermission();
+    startedActivities.add(
+        new StartedActivity(componentName, targetUser, intent, activity, options));
+  }
+
+  /** Adds {@code userHandle} to the list of accessible handles. */
+  public void addTargetUserProfile(UserHandle userHandle) {
+    if (userHandle.equals(Process.myUserHandle())) {
+      throw new IllegalArgumentException("Cannot target current user");
+    }
+    targetUserProfiles.add(userHandle);
+  }
+
+  /** Removes {@code userHandle} from the list of accessible handles, if present. */
+  public void removeTargetUserProfile(UserHandle userHandle) {
+    if (userHandle.equals(Process.myUserHandle())) {
+      throw new IllegalArgumentException("Cannot target current user");
+    }
+    targetUserProfiles.remove(userHandle);
+  }
+
+  /** Clears the list of accessible handles. */
+  public void clearTargetUserProfiles() {
+    targetUserProfiles.clear();
+  }
+
+  /**
+   * Returns the most recent {@link ComponentName}, {@link UserHandle} pair started by {@link
+   * CrossProfileApps#startMainActivity(ComponentName, UserHandle)}, wrapped in {@link
+   * StartedMainActivity}.
+   *
+   * @deprecated Use {@link #peekNextStartedActivity()} instead.
+   */
+  @Nullable
+  @Deprecated
+  public StartedMainActivity peekNextStartedMainActivity() {
+    if (startedMainActivities.isEmpty()) {
+      return null;
+    } else {
+      return Iterables.getLast(startedMainActivities);
+    }
+  }
+
+  /**
+   * Returns the most recent {@link ComponentName}, {@link UserHandle} pair started by {@link
+   * CrossProfileApps#startMainActivity(ComponentName, UserHandle)} or {@link
+   * CrossProfileApps#startActivity(ComponentName, UserHandle)}, {@link #startActivity(Intent,
+   * UserHandle, Activity)}, {@link #startActivity(Intent, UserHandle, Activity, Bundle)}, wrapped
+   * in {@link StartedActivity}.
+   */
+  @Nullable
+  public StartedActivity peekNextStartedActivity() {
+    if (startedActivities.isEmpty()) {
+      return null;
+    } else {
+      return Iterables.getLast(startedActivities);
+    }
+  }
+
+  /**
+   * Consumes the most recent {@link ComponentName}, {@link UserHandle} pair started by {@link
+   * CrossProfileApps#startMainActivity(ComponentName, UserHandle)} or {@link
+   * CrossProfileApps#startActivity(ComponentName, UserHandle)}, {@link #startActivity(Intent,
+   * UserHandle, Activity)}, {@link #startActivity(Intent, UserHandle, Activity, Bundle)}, and
+   * returns it wrapped in {@link StartedActivity}.
+   */
+  @Nullable
+  public StartedActivity getNextStartedActivity() {
+    if (startedActivities.isEmpty()) {
+      return null;
+    } else {
+      return startedActivities.remove(startedActivities.size() - 1);
+    }
+  }
+
+  /**
+   * Clears all records of {@link StartedActivity}s from calls to {@link
+   * CrossProfileApps#startActivity(ComponentName, UserHandle)} or {@link
+   * CrossProfileApps#startMainActivity(ComponentName, UserHandle)}, {@link #startActivity(Intent,
+   * UserHandle, Activity)}, {@link #startActivity(Intent, UserHandle, Activity, Bundle)}.
+   */
+  public void clearNextStartedActivities() {
+    startedActivities.clear();
+  }
+
+  @Implementation(minSdk = P)
+  protected void verifyCanAccessUser(UserHandle userHandle) {
+    if (!targetUserProfiles.contains(userHandle)) {
+      throw new SecurityException(
+          "Not allowed to access "
+              + userHandle
+              + " (did you forget to call addTargetUserProfile?)");
+    }
+  }
+
+  /**
+   * Ensure the current package has the permission to interact across profiles.
+   */
+  protected void verifyHasInteractAcrossProfilesPermission() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      if (!canInteractAcrossProfiles()) {
+        throw new SecurityException("Attempt to launch activity without required the permissions.");
+      }
+      return;
+    }
+    if (context.checkSelfPermission(permission.INTERACT_ACROSS_PROFILES)
+        != PackageManager.PERMISSION_GRANTED) {
+      throw new SecurityException(
+          "Attempt to launch activity without required "
+              + permission.INTERACT_ACROSS_PROFILES
+              + " permission");
+    }
+  }
+
+  /**
+   * Ensures that {@code component} is present in the manifest as an exported and enabled activity.
+   * This check and the error thrown are the same as the check done by the real {@link
+   * CrossProfileApps}.
+   *
+   * <p>If {@code requireMainActivity} is true, then this also asserts that the activity is a
+   * launcher activity.
+   */
+  private void verifyActivityInManifest(ComponentName component, boolean requireMainActivity) {
+    Intent launchIntent = new Intent();
+    if (requireMainActivity) {
+      launchIntent
+          .setAction(Intent.ACTION_MAIN)
+          .addCategory(Intent.CATEGORY_LAUNCHER)
+          .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
+          .setPackage(component.getPackageName());
+    } else {
+      launchIntent.setComponent(component);
+    }
+
+    boolean existsMatchingActivity =
+        Iterables.any(
+            packageManager.queryIntentActivities(
+                launchIntent, MATCH_DIRECT_BOOT_AWARE | MATCH_DIRECT_BOOT_UNAWARE),
+            resolveInfo -> {
+              ActivityInfo activityInfo = resolveInfo.activityInfo;
+              return TextUtils.equals(activityInfo.packageName, component.getPackageName())
+                  && TextUtils.equals(activityInfo.name, component.getClassName())
+                  && activityInfo.exported;
+            });
+    if (!existsMatchingActivity) {
+      throw new SecurityException(
+          "Attempt to launch activity without "
+              + " category Intent.CATEGORY_LAUNCHER or activity is not exported"
+              + component);
+    }
+  }
+
+  /**
+   * Checks if the current application can interact across profile.
+   *
+   * <p>This checks for the existence of a target user profile, and if the app has
+   * INTERACT_ACROSS_USERS, INTERACT_ACROSS_USERS_FULL or INTERACT_ACROSS_PROFILES permission.
+   * Importantly, the {@code interact_across_profiles} AppOps is only checked through the value set
+   * by {@link #setInteractAcrossProfilesAppOp(int)} or by {@link
+   * #setInteractAcrossProfilesAppOp(String, int)}, if the application has the needed permissions.
+   */
+  @Implementation(minSdk = R)
+  protected boolean canInteractAcrossProfiles() {
+    if (getTargetUserProfiles().isEmpty()) {
+      return false;
+    }
+    return hasPermission(permission.INTERACT_ACROSS_USERS_FULL)
+        || hasPermission(permission.INTERACT_ACROSS_PROFILES)
+        || hasPermission(permission.INTERACT_ACROSS_USERS)
+        || canInteractAcrossProfileAppOps == AppOpsManager.MODE_ALLOWED;
+  }
+
+  /**
+   * Returns whether the calling package can request to navigate the user to the relevant settings
+   * page to request user consent to interact across profiles.
+   *
+   * <p>This checks for the existence of a target user profile, and if the app has requested the
+   * INTERACT_ACROSS_PROFILES permission in its manifest. As Robolectric doesn't interpret the
+   * permissions in the manifest, whether or not the app has requested this is defined by {@link
+   * #setHasRequestedInteractAcrossProfiles(boolean)}.
+   *
+   * <p>If the test uses {@link #setInteractAcrossProfilesAppOp(int)}, it implies the app has
+   * requested the AppOps.
+   *
+   * <p>In short, compared to {@link #canInteractAcrossProfiles()}, it doesn't check if the user has
+   * the AppOps or not.
+   */
+  @Implementation(minSdk = R)
+  protected boolean canRequestInteractAcrossProfiles() {
+    if (getTargetUserProfiles().isEmpty()) {
+      return false;
+    }
+    return hasRequestedInteractAcrossProfiles;
+  }
+
+  /**
+   * Sets whether or not the current application has requested the interact across profile
+   * permission in its manifest.
+   */
+  public void setHasRequestedInteractAcrossProfiles(boolean value) {
+    hasRequestedInteractAcrossProfiles = value;
+  }
+
+  /**
+   * Returns an intent with the same action as the one returned by system when requesting the same.
+   *
+   * <p>Note: Currently, the system will also set the package name as a URI, but as this is not
+   * specified in the main doc, we shouldn't rely on it. The purpose is only to make an intent can
+   * that be recognised in a test.
+   *
+   * @throws SecurityException if this is called while {@link
+   *     CrossProfileApps#canRequestInteractAcrossProfiles()} returns false.
+   */
+  @Implementation(minSdk = R)
+  protected Intent createRequestInteractAcrossProfilesIntent() {
+    if (!canRequestInteractAcrossProfiles()) {
+      throw new SecurityException(
+          "The calling package can not request to interact across profiles.");
+    }
+    return new Intent(Settings.ACTION_MANAGE_CROSS_PROFILE_ACCESS);
+  }
+
+  /**
+   * Checks whether the given intent will redirect toward the screen allowing the user to change the
+   * interact across profiles AppOps.
+   */
+  public boolean isRequestInteractAcrossProfilesIntent(Intent intent) {
+    return Settings.ACTION_MANAGE_CROSS_PROFILE_ACCESS.equals(intent.getAction());
+  }
+
+  private boolean hasPermission(String permission) {
+    return context.getPackageManager().checkPermission(permission, context.getPackageName())
+        == PackageManager.PERMISSION_GRANTED;
+  }
+
+  /**
+   * Forces the {code interact_across_profile} AppOps for the current package.
+   *
+   * <p>If the value changes, this also sends the {@link
+   * CrossProfileApps#ACTION_CAN_INTERACT_ACROSS_PROFILES_CHANGED} broadcast.
+   */
+  public void setInteractAcrossProfilesAppOp(@Mode int newMode) {
+    hasRequestedInteractAcrossProfiles = true;
+    if (canInteractAcrossProfileAppOps != newMode) {
+      canInteractAcrossProfileAppOps = newMode;
+      context.sendBroadcast(
+          new Intent(CrossProfileApps.ACTION_CAN_INTERACT_ACROSS_PROFILES_CHANGED));
+    }
+  }
+
+  /**
+   * Checks permission and changes the AppOps value stored in {@link ShadowCrossProfileApps}.
+   *
+   * <p>In the real implementation, if there is no target profile, the AppOps is not changed, as it
+   * will be set during the profile's initialization. The real implementation also really changes
+   * the AppOps for all profiles the package is installed in.
+   */
+  @Implementation(minSdk = R)
+  protected void setInteractAcrossProfilesAppOp(String packageName, @Mode int newMode) {
+    if (!hasPermission(permission.INTERACT_ACROSS_USERS)
+        || !hasPermission(permission.CONFIGURE_INTERACT_ACROSS_PROFILES)) {
+      throw new SecurityException(
+          "Requires INTERACT_ACROSS_USERS and CONFIGURE_INTERACT_ACROSS_PROFILES permission");
+    }
+    setInteractAcrossProfilesAppOp(newMode);
+  }
+
+  /**
+   * Unlike the real system, we will assume a package can always configure its own cross profile
+   * interaction.
+   */
+  @Implementation(minSdk = R)
+  protected boolean canConfigureInteractAcrossProfiles(String packageName) {
+    return context.getPackageName().equals(packageName);
+  }
+
+  /**
+   * Container object to hold parameters passed to {@link #startMainActivity(ComponentName,
+   * UserHandle)}.
+   *
+   * @deprecated Use {@link #peekNextStartedActivity()} and {@link StartedActivity} instead.
+   */
+  @Deprecated
+  public static class StartedMainActivity {
+
+    private final ComponentName componentName;
+    private final UserHandle userHandle;
+
+    public StartedMainActivity(ComponentName componentName, UserHandle userHandle) {
+      this.componentName = checkNotNull(componentName);
+      this.userHandle = checkNotNull(userHandle);
+    }
+
+    public ComponentName getComponentName() {
+      return componentName;
+    }
+
+    public UserHandle getUserHandle() {
+      return userHandle;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      StartedMainActivity that = (StartedMainActivity) o;
+      return Objects.equals(componentName, that.componentName)
+          && Objects.equals(userHandle, that.userHandle);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(componentName, userHandle);
+    }
+  }
+
+  /**
+   * Container object to hold parameters passed to {@link #startMainActivity(ComponentName,
+   * UserHandle)} or {@link #startActivity(ComponentName, UserHandle)}, {@link
+   * #startActivity(Intent, UserHandle, Activity)}, {@link #startActivity(Intent, UserHandle,
+   * Activity, Bundle)}.
+   *
+   * <p>Note: {@link #equals} and {@link #hashCode} are only defined for the {@link ComponentName}
+   * and {@link UserHandle}.
+   */
+  public static final class StartedActivity {
+
+    private final ComponentName componentName;
+    private final UserHandle userHandle;
+    @Nullable private final Intent intent;
+    @Nullable private final Activity activity;
+    @Nullable private final Bundle options;
+
+    public StartedActivity(ComponentName componentName, UserHandle userHandle) {
+      this(
+          componentName, userHandle, /* intent= */ null, /* activity= */ null, /* options= */ null);
+    }
+
+    public StartedActivity(
+        ComponentName componentName,
+        UserHandle userHandle,
+        @Nullable Intent intent,
+        @Nullable Activity activity,
+        @Nullable Bundle options) {
+      this.componentName = checkNotNull(componentName);
+      this.userHandle = checkNotNull(userHandle);
+      this.intent = intent;
+      this.activity = activity;
+      this.options = options;
+    }
+
+    public ComponentName getComponentName() {
+      return componentName;
+    }
+
+    public UserHandle getUserHandle() {
+      return userHandle;
+    }
+
+    @Nullable
+    public Intent getIntent() {
+      return intent;
+    }
+
+    @Nullable
+    public Bundle getOptions() {
+      return options;
+    }
+
+    @Nullable
+    public Activity getActivity() {
+      return activity;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      StartedActivity that = (StartedActivity) o;
+      return Objects.equals(componentName, that.componentName)
+          && Objects.equals(userHandle, that.userHandle);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(componentName, userHandle);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCursorWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCursorWindow.java
new file mode 100644
index 0000000..d9a45d5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCursorWindow.java
@@ -0,0 +1,46 @@
+package org.robolectric.shadows;
+
+import android.database.CursorWindow;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * The base shadow class for {@link CursorWindow}.
+ *
+ * <p>The actual shadow class for {@link CursorWindow} will be selected during runtime by the
+ * Picker.
+ */
+@Implements(value = CursorWindow.class, shadowPicker = ShadowCursorWindow.Picker.class)
+public class ShadowCursorWindow {
+
+  @ReflectorObject CursorWindowReflector cursorWindowReflector;
+
+  private final AtomicBoolean disposed = new AtomicBoolean();
+
+  @Implementation
+  protected void dispose() {
+    // On the JVM there may be two concurrent finalizer threads running if 'System.runFinalization'
+    // is called. Because CursorWindow.dispose is not thread safe, we can work around it
+    // by manually making it thread safe.
+    if (disposed.compareAndSet(false, true)) {
+      cursorWindowReflector.dispose();
+    }
+  }
+
+  /** Shadow {@link Picker} for {@link ShadowCursorWindow} */
+  public static class Picker extends SQLiteShadowPicker<ShadowCursorWindow> {
+    public Picker() {
+      super(ShadowLegacyCursorWindow.class, ShadowNativeCursorWindow.class);
+    }
+  }
+
+  @ForType(CursorWindow.class)
+  interface CursorWindowReflector {
+    @Direct
+    void dispose();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCursorWrapper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCursorWrapper.java
new file mode 100644
index 0000000..04623f1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCursorWrapper.java
@@ -0,0 +1,235 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.M;
+
+import android.content.ContentResolver;
+import android.database.CharArrayBuffer;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.database.DataSetObserver;
+import android.net.Uri;
+import android.os.Bundle;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(CursorWrapper.class)
+public class ShadowCursorWrapper implements Cursor {
+  private Cursor wrappedCursor;
+
+  @Implementation
+  protected void __constructor__(Cursor c) {
+    wrappedCursor = c;
+  }
+
+  @Override @Implementation
+  public int getCount() {
+    return wrappedCursor.getCount();
+  }
+
+  @Override @Implementation
+  public int getPosition() {
+    return wrappedCursor.getPosition();
+  }
+
+  @Override @Implementation
+  public boolean move(int i) {
+    return wrappedCursor.move(i);
+  }
+
+  @Override @Implementation
+  public boolean moveToPosition(int i) {
+    return wrappedCursor.moveToPosition(i);
+  }
+
+  @Override @Implementation
+  public boolean moveToFirst() {
+    return wrappedCursor.moveToFirst();
+  }
+
+  @Override @Implementation
+  public boolean moveToLast() {
+    return wrappedCursor.moveToLast();
+  }
+
+  @Override @Implementation
+  public boolean moveToNext() {
+    return wrappedCursor.moveToNext();
+  }
+
+  @Override @Implementation
+  public boolean moveToPrevious() {
+    return wrappedCursor.moveToPrevious();
+  }
+
+  @Override @Implementation
+  public boolean isFirst() {
+    return wrappedCursor.isFirst();
+  }
+
+  @Override @Implementation
+  public boolean isLast() {
+    return wrappedCursor.isLast();
+  }
+
+  @Override @Implementation
+  public boolean isBeforeFirst() {
+    return wrappedCursor.isBeforeFirst();
+  }
+
+  @Override @Implementation
+  public boolean isAfterLast() {
+    return wrappedCursor.isAfterLast();
+  }
+
+  @Override @Implementation
+  public int getColumnIndex(String s) {
+    return wrappedCursor.getColumnIndex(s);
+  }
+
+  @Override @Implementation
+  public int getColumnIndexOrThrow(String s) throws IllegalArgumentException {
+    return wrappedCursor.getColumnIndexOrThrow(s);
+  }
+
+  @Override @Implementation
+  public String getColumnName(int i) {
+    return wrappedCursor.getColumnName(i);
+  }
+
+  @Override @Implementation
+  public String[] getColumnNames() {
+    return wrappedCursor.getColumnNames();
+  }
+
+  @Override @Implementation
+  public int getColumnCount() {
+    return wrappedCursor.getColumnCount();
+  }
+
+  @Override @Implementation
+  public byte[] getBlob(int i) {
+    return wrappedCursor.getBlob(i);
+  }
+
+  @Override @Implementation
+  public String getString(int i) {
+    return wrappedCursor.getString(i);
+  }
+
+  @Override @Implementation
+  public void copyStringToBuffer(int i, CharArrayBuffer charArrayBuffer) {
+    wrappedCursor.copyStringToBuffer(i, charArrayBuffer);
+  }
+
+  @Override @Implementation
+  public short getShort(int i) {
+    return wrappedCursor.getShort(i);
+  }
+
+  @Override @Implementation
+  public int getInt(int i) {
+    return wrappedCursor.getInt(i);
+  }
+
+  @Override @Implementation
+  public long getLong(int i) {
+    return wrappedCursor.getLong(i);
+  }
+
+  @Override @Implementation
+  public float getFloat(int i) {
+    return wrappedCursor.getFloat(i);
+  }
+
+  @Override @Implementation
+  public double getDouble(int i) {
+    return wrappedCursor.getDouble(i);
+  }
+
+  @Override @Implementation
+  public boolean isNull(int i) {
+    return wrappedCursor.isNull(i);
+  }
+
+  @Override @Implementation
+  public void deactivate() {
+    wrappedCursor.deactivate();
+  }
+
+  @Override @Implementation
+  public boolean requery() {
+    return wrappedCursor.requery();
+  }
+
+  @Override @Implementation
+  public void close() {
+    wrappedCursor.close();
+  }
+
+  @Override @Implementation
+  public boolean isClosed() {
+    return wrappedCursor.isClosed();
+  }
+
+  @Override @Implementation
+  public void registerContentObserver(ContentObserver contentObserver) {
+    wrappedCursor.registerContentObserver(contentObserver);
+  }
+
+  @Override @Implementation
+  public void unregisterContentObserver(ContentObserver contentObserver) {
+    wrappedCursor.unregisterContentObserver(contentObserver);
+  }
+
+  @Override @Implementation
+  public void registerDataSetObserver(DataSetObserver dataSetObserver) {
+    wrappedCursor.registerDataSetObserver(dataSetObserver);
+  }
+
+  @Override @Implementation
+  public void unregisterDataSetObserver(DataSetObserver dataSetObserver) {
+    wrappedCursor.unregisterDataSetObserver(dataSetObserver);
+  }
+
+  @Override @Implementation
+  public void setNotificationUri(ContentResolver contentResolver, Uri uri) {
+    wrappedCursor.setNotificationUri(contentResolver, uri);
+  }
+
+  @Override @Implementation(minSdk = KITKAT)
+  public Uri getNotificationUri() {
+    return wrappedCursor.getNotificationUri();
+  }
+
+  @Override @Implementation
+  public boolean getWantsAllOnMoveCalls() {
+    return wrappedCursor.getWantsAllOnMoveCalls();
+  }
+
+  @Override @Implementation(minSdk = M)
+  public void setExtras(Bundle extras) {
+    wrappedCursor.setExtras(extras);
+  }
+
+  @Override @Implementation
+  public Bundle getExtras() {
+    return wrappedCursor.getExtras();
+  }
+
+  @Override @Implementation
+  public Bundle respond(Bundle bundle) {
+    return wrappedCursor.respond(bundle);
+  }
+
+  @Override @Implementation
+  public int getType(int columnIndex) {
+    return wrappedCursor.getType(columnIndex);
+  }
+
+  @Implementation
+  protected Cursor getWrappedCursor() {
+    return wrappedCursor;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDateIntervalFormat.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDateIntervalFormat.java
new file mode 100644
index 0000000..abbed64
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDateIntervalFormat.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+
+import java.text.FieldPosition;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import libcore.icu.DateIntervalFormat;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = DateIntervalFormat.class, isInAndroidSdk = false, minSdk = KITKAT)
+public class ShadowDateIntervalFormat {
+
+  private static long address;
+  private static Map<Long, com.ibm.icu.text.DateIntervalFormat> INTERVAL_CACHE = new HashMap<>();
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  public static long createDateIntervalFormat(String skeleton, String localeName, String tzName) {
+    address++;
+    INTERVAL_CACHE.put(address, com.ibm.icu.text.DateIntervalFormat.getInstance(skeleton, new Locale(localeName)));
+    return address;
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  public static void destroyDateIntervalFormat(long address) {
+    INTERVAL_CACHE.remove(address);
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  @SuppressWarnings("JdkObsolete")
+  public static String formatDateInterval(long address, long fromDate, long toDate) {
+    StringBuffer buffer = new StringBuffer();
+
+    FieldPosition pos = new FieldPosition(0);
+    INTERVAL_CACHE.get(address).format(new com.ibm.icu.util.DateInterval(fromDate, toDate), buffer, pos);
+
+    return buffer.toString();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDatePickerDialog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDatePickerDialog.java
new file mode 100644
index 0000000..84fc398
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDatePickerDialog.java
@@ -0,0 +1,87 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.N;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.TargetApi;
+import android.app.DatePickerDialog;
+import android.app.DatePickerDialog.OnDateSetListener;
+import android.content.Context;
+import androidx.annotation.RequiresApi;
+import java.util.Calendar;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(DatePickerDialog.class)
+public class ShadowDatePickerDialog extends ShadowAlertDialog {
+
+  @RealObject protected DatePickerDialog realDatePickerDialog;
+  private Calendar calendar;
+
+  @Implementation(minSdk = N)
+  protected void __constructor__(
+      Context context,
+      int theme,
+      DatePickerDialog.OnDateSetListener callBack,
+      Calendar calendar,
+      int year,
+      int monthOfYear,
+      int dayOfMonth) {
+    this.calendar = calendar;
+
+    invokeConstructor(DatePickerDialog.class, realDatePickerDialog,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(int.class, theme),
+        ClassParameter.from(DatePickerDialog.OnDateSetListener.class, callBack),
+        ClassParameter.from(Calendar.class, calendar),
+        ClassParameter.from(int.class, year),
+        ClassParameter.from(int.class, monthOfYear),
+        ClassParameter.from(int.class, dayOfMonth));
+  }
+
+  public Calendar getCalendar() {
+    return calendar;
+  }
+
+  public int getYear() {
+    return realDatePickerDialog.getDatePicker().getYear();
+  }
+
+  public int getMonthOfYear() {
+    return realDatePickerDialog.getDatePicker().getMonth();
+  }
+
+  public int getDayOfMonth() {
+    return realDatePickerDialog.getDatePicker().getDayOfMonth();
+  }
+
+  public DatePickerDialog.OnDateSetListener getOnDateSetListenerCallback() {
+    if (RuntimeEnvironment.getApiLevel() <= KITKAT) {
+      return reflector(DatePickerDialogReflector.class, realDatePickerDialog).getCallback();
+    } else {
+      return reflector(DatePickerDialogReflector.class, realDatePickerDialog).getDateSetListener();
+    }
+  }
+
+  @ForType(DatePickerDialog.class)
+  interface DatePickerDialogReflector {
+
+    /** For sdk version at least {@link KITKAT_WATCH} */
+    @RequiresApi(KITKAT_WATCH)
+    @Accessor("mDateSetListener")
+    OnDateSetListener getDateSetListener();
+
+    /** For sdk version is equals to {@link KITKAT} */
+    @TargetApi(KITKAT)
+    @Accessor("mCallBack")
+    OnDateSetListener getCallback();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDebug.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDebug.java
new file mode 100644
index 0000000..b2f1a55
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDebug.java
@@ -0,0 +1,112 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+
+import android.os.Debug;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.Files;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(Debug.class)
+public class ShadowDebug {
+
+  private static boolean tracingStarted = false;
+  private static String tracingFilename;
+
+  @Implementation
+  protected static void __staticInitializer__() {
+    // Avoid calling Environment.getLegacyExternalStorageDirectory()
+  }
+
+  @Implementation
+  protected static long getNativeHeapAllocatedSize() {
+    return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
+  }
+
+  @Implementation(minSdk = M)
+  protected static Map<String, String> getRuntimeStats() {
+    return ImmutableMap.<String, String>builder().build();
+  }
+
+  @Implementation
+  protected static void startMethodTracing() {
+    internalStartTracing(fixTracePath(null));
+  }
+
+  @Implementation
+  protected static void startMethodTracing(String tracePath, int bufferSize, int flags) {
+    internalStartTracing(fixTracePath(tracePath));
+  }
+
+  @Implementation
+  protected static void startMethodTracing(String tracePath) {
+    internalStartTracing(fixTracePath(tracePath));
+  }
+
+  @Implementation
+  protected static void startMethodTracing(String tracePath, int bufferSize) {
+    internalStartTracing(fixTracePath(tracePath));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void startMethodTracingSampling(String tracePath, int bufferSize, int intervalUs) {
+    internalStartTracing(fixTracePath(tracePath));
+  }
+
+  @Implementation
+  protected static void stopMethodTracing() {
+    if (!tracingStarted) {
+      throw new RuntimeException("Tracing is not started.");
+    }
+
+    try {
+      Files.asCharSink(new File(tracingFilename), Charset.forName("UTF-8")).write("trace data");
+    } catch (IOException e) {
+      throw new RuntimeException("Writing trace file failed", e);
+    }
+    tracingStarted = false;
+    tracingFilename = null;
+  }
+
+  private static void internalStartTracing(String tracePath) {
+    if (tracingStarted) {
+      throw new RuntimeException("Tracing is already started.");
+    }
+    tracingStarted = true;
+    tracingFilename = tracePath;
+  }
+
+  @Resetter
+  public static void reset() {
+    tracingStarted = false;
+    tracingFilename = null;
+  }
+
+  @Implementation(minSdk = N)
+  protected static String fixTracePath(String tracePath) {
+    String defaultTraceBody = "dmtrace";
+    String defaultTraceExtension = ".trace";
+
+    if (tracePath == null || tracePath.charAt(0) != '/') {
+      final File dir = RuntimeEnvironment.getApplication().getExternalFilesDir(null);
+      if (tracePath == null) {
+        tracePath = new File(dir, defaultTraceBody).getAbsolutePath();
+      } else {
+        tracePath = new File(dir, tracePath).getAbsolutePath();
+      }
+    }
+    if (!tracePath.endsWith(defaultTraceExtension)) {
+      tracePath += defaultTraceExtension;
+    }
+    return tracePath;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDeviceConfig.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDeviceConfig.java
new file mode 100644
index 0000000..4bca7e9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDeviceConfig.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import android.provider.DeviceConfig;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = DeviceConfig.class, isInAndroidSdk = false, minSdk = Build.VERSION_CODES.Q)
+public class ShadowDeviceConfig {
+
+  @Implementation
+  protected static String getProperty(String namespace, String name) {
+    // avoid call to Settings.Config
+    return null;
+  }
+
+  @Resetter
+  public static void reset() {
+    Object lock = ReflectionHelpers.getStaticField(DeviceConfig.class, "sLock");
+    //noinspection SynchronizationOnLocalVariableOrMethodParameter
+    synchronized (lock) {
+      if (RuntimeEnvironment.getApiLevel() == Build.VERSION_CODES.Q) {
+        Map singleListeners =
+            ReflectionHelpers.getStaticField(DeviceConfig.class, "sSingleListeners");
+        singleListeners.clear();
+      }
+
+      Map listeners = ReflectionHelpers.getStaticField(DeviceConfig.class, "sListeners");
+      listeners.clear();
+
+      Map namespaces = ReflectionHelpers.getStaticField(DeviceConfig.class, "sNamespaces");
+      namespaces.clear();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDevicePolicyManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDevicePolicyManager.java
new file mode 100644
index 0000000..c768882
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDevicePolicyManager.java
@@ -0,0 +1,1485 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.accounts.Account;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SuppressLint;
+import android.annotation.SystemApi;
+import android.app.ApplicationPackageManager;
+import android.app.KeyguardManager;
+import android.app.admin.DeviceAdminReceiver;
+import android.app.admin.DevicePolicyManager;
+import android.app.admin.DevicePolicyManager.NearbyStreamingPolicy;
+import android.app.admin.DevicePolicyManager.PasswordComplexity;
+import android.app.admin.DevicePolicyManager.UserProvisioningState;
+import android.app.admin.IDevicePolicyManager;
+import android.app.admin.SystemUpdatePolicy;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import com.android.internal.util.Preconditions;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(DevicePolicyManager.class)
+@SuppressLint("NewApi")
+public class ShadowDevicePolicyManager {
+  /**
+   * @see
+   *     https://developer.android.com/reference/android/app/admin/DevicePolicyManager.html#setOrganizationColor(android.content.ComponentName,
+   *     int)
+   */
+  private static final int DEFAULT_ORGANIZATION_COLOR = 0xFF008080; // teal
+
+  private ComponentName deviceOwner;
+  private ComponentName profileOwner;
+  private List<ComponentName> deviceAdmins = new ArrayList<>();
+  private Map<Integer, String> profileOwnerNamesMap = new HashMap<>();
+  private List<String> permittedAccessibilityServices = new ArrayList<>();
+  private List<String> permittedInputMethods = new ArrayList<>();
+  private Map<String, Bundle> applicationRestrictionsMap = new HashMap<>();
+  private CharSequence organizationName;
+  private int organizationColor;
+  private boolean isAutoTimeRequired;
+  private boolean isAutoTimeZoneEnabled;
+  private String timeZone;
+  private int keyguardDisabledFeatures;
+  private String lastSetPassword;
+  private int requiredPasswordQuality = DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED;
+
+  private int passwordMinimumLength;
+  private int passwordMinimumLetters = 1;
+  private int passwordMinimumLowerCase;
+  private int passwordMinimumUpperCase;
+  private int passwordMinimumNonLetter;
+  private int passwordMinimumNumeric = 1;
+  private int passwordMinimumSymbols = 1;
+  private int passwordHistoryLength = 0;
+  private long passwordExpiration = 0;
+  private long passwordExpirationTimeout = 0;
+  private int maximumFailedPasswordsForWipe = 0;
+  private long maximumTimeToLock = 0;
+  private boolean cameraDisabled;
+  private boolean isActivePasswordSufficient;
+  private boolean isUniqueDeviceAttestationSupported;
+  @PasswordComplexity private int passwordComplexity;
+
+  private int wipeCalled;
+  private int storageEncryptionStatus;
+  private int permissionPolicy;
+  private boolean storageEncryptionRequested;
+  private final Set<String> wasHiddenPackages = new HashSet<>();
+  private final Set<String> accountTypesWithManagementDisabled = new HashSet<>();
+  private final Set<String> systemAppsEnabled = new HashSet<>();
+  private final Set<String> uninstallBlockedPackages = new HashSet<>();
+  private final Set<String> suspendedPackages = new HashSet<>();
+  private final Set<String> affiliationIds = new HashSet<>();
+  private final Map<PackageAndPermission, Boolean> appPermissionGrantedMap = new HashMap<>();
+  private final Map<PackageAndPermission, Integer> appPermissionGrantStateMap = new HashMap<>();
+  private final Map<ComponentName, byte[]> passwordResetTokens = new HashMap<>();
+  private final Map<ComponentName, Set<Integer>> adminPolicyGrantedMap = new HashMap<>();
+  private final Map<ComponentName, CharSequence> shortSupportMessageMap = new HashMap<>();
+  private final Map<ComponentName, CharSequence> longSupportMessageMap = new HashMap<>();
+  private final Set<ComponentName> componentsWithActivatedTokens = new HashSet<>();
+  private Collection<String> packagesToFailForSetApplicationHidden = Collections.emptySet();
+  private final List<String> lockTaskPackages = new ArrayList<>();
+  private Context context;
+  private ApplicationPackageManager applicationPackageManager;
+  private SystemUpdatePolicy policy;
+  private List<UserHandle> bindDeviceAdminTargetUsers = ImmutableList.of();
+  private boolean isDeviceProvisioned;
+  private boolean isDeviceProvisioningConfigApplied;
+  private volatile boolean organizationOwnedDeviceWithManagedProfile = false;
+  private int nearbyNotificationStreamingPolicy =
+      DevicePolicyManager.NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY;
+  private int nearbyAppStreamingPolicy =
+      DevicePolicyManager.NEARBY_STREAMING_NOT_CONTROLLED_BY_POLICY;
+  private boolean isUsbDataSignalingEnabled = true;
+  @Nullable private String devicePolicyManagementRoleHolderPackage;
+  private final Map<UserHandle, Account> finalizedWorkProfileProvisioningMap = new HashMap<>();
+  private List<UserHandle> policyManagedProfiles = new ArrayList<>();
+  private final Map<Integer, Integer> userProvisioningStatesMap = new HashMap<>();
+  @Nullable private PersistableBundle lastTransferOwnershipBundle;
+
+  private @RealObject DevicePolicyManager realObject;
+
+  private static class PackageAndPermission {
+
+    public PackageAndPermission(String packageName, String permission) {
+      this.packageName = packageName;
+      this.permission = permission;
+    }
+
+    private String packageName;
+    private String permission;
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof PackageAndPermission)) {
+        return false;
+      }
+      PackageAndPermission other = (PackageAndPermission) o;
+      return packageName.equals(other.packageName) && permission.equals(other.permission);
+    }
+
+    @Override
+    public int hashCode() {
+      int result = packageName.hashCode();
+      result = 31 * result + permission.hashCode();
+      return result;
+    }
+  }
+
+  @Implementation(maxSdk = M)
+  protected void __constructor__(Context context, Handler handler) {
+    init(context);
+    invokeConstructor(
+        DevicePolicyManager.class,
+        realObject,
+        from(Context.class, context),
+        from(Handler.class, handler));
+  }
+
+  @Implementation(minSdk = N, maxSdk = N_MR1)
+  protected void __constructor__(Context context, boolean parentInstance) {
+    init(context);
+  }
+
+  @Implementation(minSdk = O)
+  protected void __constructor__(Context context, IDevicePolicyManager service) {
+    init(context);
+  }
+
+  private void init(Context context) {
+    this.context = context;
+    this.applicationPackageManager =
+        (ApplicationPackageManager) context.getApplicationContext().getPackageManager();
+    organizationColor = DEFAULT_ORGANIZATION_COLOR;
+    storageEncryptionStatus = DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean isDeviceOwnerApp(String packageName) {
+    return deviceOwner != null && deviceOwner.getPackageName().equals(packageName);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isProfileOwnerApp(String packageName) {
+    return profileOwner != null && profileOwner.getPackageName().equals(packageName);
+  }
+
+  @Implementation
+  protected boolean isAdminActive(ComponentName who) {
+    return who != null && deviceAdmins.contains(who);
+  }
+
+  @Implementation
+  protected List<ComponentName> getActiveAdmins() {
+    return deviceAdmins;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addUserRestriction(ComponentName admin, String key) {
+    enforceActiveAdmin(admin);
+    getShadowUserManager().setUserRestriction(Process.myUserHandle(), key, true);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void clearUserRestriction(ComponentName admin, String key) {
+    enforceActiveAdmin(admin);
+    getShadowUserManager().setUserRestriction(Process.myUserHandle(), key, false);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean setApplicationHidden(ComponentName admin, String packageName, boolean hidden) {
+    enforceActiveAdmin(admin);
+    if (packagesToFailForSetApplicationHidden.contains(packageName)) {
+      return false;
+    }
+    if (hidden) {
+      wasHiddenPackages.add(packageName);
+    }
+    return applicationPackageManager.setApplicationHiddenSettingAsUser(
+        packageName, hidden, Process.myUserHandle());
+  }
+
+  /**
+   * Set package names for witch {@link DevicePolicyManager#setApplicationHidden} should fail.
+   *
+   * @param packagesToFail collection of package names or {@code null} to clear the packages.
+   */
+  public void failSetApplicationHiddenFor(Collection<String> packagesToFail) {
+    if (packagesToFail == null) {
+      packagesToFail = Collections.emptySet();
+    }
+    packagesToFailForSetApplicationHidden = packagesToFail;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isApplicationHidden(ComponentName admin, String packageName) {
+    enforceActiveAdmin(admin);
+    return applicationPackageManager.getApplicationHiddenSettingAsUser(
+        packageName, Process.myUserHandle());
+  }
+
+  /** Returns {@code true} if the given {@code packageName} was ever hidden. */
+  public boolean wasPackageEverHidden(String packageName) {
+    return wasHiddenPackages.contains(packageName);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void enableSystemApp(ComponentName admin, String packageName) {
+    enforceActiveAdmin(admin);
+    systemAppsEnabled.add(packageName);
+  }
+
+  /** Returns {@code true} if the given {@code packageName} was a system app and was enabled. */
+  public boolean wasSystemAppEnabled(String packageName) {
+    return systemAppsEnabled.contains(packageName);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void setUninstallBlocked(
+      ComponentName admin, String packageName, boolean uninstallBlocked) {
+    enforceActiveAdmin(admin);
+    if (uninstallBlocked) {
+      uninstallBlockedPackages.add(packageName);
+    } else {
+      uninstallBlockedPackages.remove(packageName);
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isUninstallBlocked(@Nullable ComponentName admin, String packageName) {
+    if (admin == null) {
+      // Starting from LOLLIPOP_MR1, the behavior of this API is changed such that passing null as
+      // the admin parameter will return if any admin has blocked the uninstallation. Before L MR1,
+      // passing null will cause a NullPointerException to be raised.
+      if (Build.VERSION.SDK_INT < LOLLIPOP_MR1) {
+        throw new NullPointerException("ComponentName is null");
+      }
+    } else {
+      enforceActiveAdmin(admin);
+    }
+    return uninstallBlockedPackages.contains(packageName);
+  }
+
+  public void setIsUniqueDeviceAttestationSupported(boolean supported) {
+    isUniqueDeviceAttestationSupported = supported;
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean isUniqueDeviceAttestationSupported() {
+    return isUniqueDeviceAttestationSupported;
+  }
+
+  /** Sets USB signaling device restriction. */
+  public void setIsUsbDataSignalingEnabled(boolean isEnabled) {
+    isUsbDataSignalingEnabled = isEnabled;
+  }
+
+  @Implementation(minSdk = S)
+  protected boolean isUsbDataSignalingEnabled() {
+    return isUsbDataSignalingEnabled;
+  }
+
+  /**
+   * @see #setDeviceOwner(ComponentName)
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected String getDeviceOwner() {
+    return deviceOwner != null ? deviceOwner.getPackageName() : null;
+  }
+
+  /**
+   * @see #setDeviceOwner(ComponentName)
+   */
+  @Implementation(minSdk = N)
+  public boolean isDeviceManaged() {
+    return getDeviceOwner() != null;
+  }
+
+  /**
+   * @see #setProfileOwner(ComponentName)
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected ComponentName getProfileOwner() {
+    return profileOwner;
+  }
+
+  /**
+   * Returns the human-readable name of the profile owner for a user if set using {@link
+   * #setProfileOwnerName}, otherwise null.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected String getProfileOwnerNameAsUser(int userId) {
+    return profileOwnerNamesMap.get(userId);
+  }
+
+  @Implementation(minSdk = P)
+  protected void transferOwnership(
+      ComponentName admin, ComponentName target, @Nullable PersistableBundle bundle) {
+    Objects.requireNonNull(admin, "ComponentName is null");
+    Objects.requireNonNull(target, "Target cannot be null.");
+    Preconditions.checkArgument(
+        !admin.equals(target), "Provided administrator and target are the same object.");
+    Preconditions.checkArgument(
+        !admin.getPackageName().equals(target.getPackageName()),
+        "Provided administrator and target have the same package name.");
+    try {
+      context.getPackageManager().getReceiverInfo(target, 0);
+    } catch (PackageManager.NameNotFoundException e) {
+      throw new IllegalArgumentException("Unknown admin: " + target);
+    }
+    if (admin.equals(deviceOwner)) {
+      deviceOwner = target;
+    } else if (admin.equals(profileOwner)) {
+      profileOwner = target;
+    } else {
+      throw new SecurityException("Calling identity is not authorized");
+    }
+    lastTransferOwnershipBundle = bundle;
+  }
+
+  @Implementation(minSdk = P)
+  @Nullable
+  protected PersistableBundle getTransferOwnershipBundle() {
+    return lastTransferOwnershipBundle;
+  }
+
+  private ShadowUserManager getShadowUserManager() {
+    return Shadow.extract(context.getSystemService(Context.USER_SERVICE));
+  }
+
+  /**
+   * Sets the admin as active admin and device owner.
+   *
+   * @see DevicePolicyManager#getDeviceOwner()
+   */
+  @Implementation(minSdk = N, maxSdk = S_V2)
+  public boolean setDeviceOwner(ComponentName admin) {
+    setActiveAdmin(admin);
+    deviceOwner = admin;
+    return true;
+  }
+
+  /**
+   * Sets the admin as active admin and profile owner.
+   *
+   * @see DevicePolicyManager#getProfileOwner()
+   */
+  public void setProfileOwner(ComponentName admin) {
+    setActiveAdmin(admin);
+    profileOwner = admin;
+  }
+
+  public void setProfileOwnerName(int userId, String name) {
+    profileOwnerNamesMap.put(userId, name);
+  }
+
+  /** Sets the given {@code componentName} as one of the active admins. */
+  public void setActiveAdmin(ComponentName componentName) {
+    deviceAdmins.add(componentName);
+  }
+
+  @Implementation
+  protected void removeActiveAdmin(ComponentName admin) {
+    deviceAdmins.remove(admin);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void clearProfileOwner(ComponentName admin) {
+    profileOwner = null;
+    lastTransferOwnershipBundle = null;
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+      removeActiveAdmin(admin);
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected Bundle getApplicationRestrictions(ComponentName admin, String packageName) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return getApplicationRestrictions(packageName);
+  }
+
+  /** Returns all application restrictions of the {@code packageName} in a {@link Bundle}. */
+  public Bundle getApplicationRestrictions(String packageName) {
+    Bundle bundle = applicationRestrictionsMap.get(packageName);
+    // If no restrictions were saved, DPM method should return an empty Bundle as per JavaDoc.
+    return bundle != null ? new Bundle(bundle) : new Bundle();
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void setApplicationRestrictions(
+      ComponentName admin, String packageName, Bundle applicationRestrictions) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    setApplicationRestrictions(packageName, applicationRestrictions);
+  }
+
+  /**
+   * Sets the application restrictions of the {@code packageName}.
+   *
+   * <p>The new {@code applicationRestrictions} always completely overwrites any existing ones.
+   */
+  public void setApplicationRestrictions(String packageName, Bundle applicationRestrictions) {
+    applicationRestrictionsMap.put(packageName, new Bundle(applicationRestrictions));
+  }
+
+  private void enforceProfileOwner(ComponentName admin) {
+    if (!admin.equals(profileOwner)) {
+      throw new SecurityException("[" + admin + "] is not a profile owner");
+    }
+  }
+
+  private void enforceDeviceOwnerOrProfileOwner(ComponentName admin) {
+    if (!admin.equals(deviceOwner) && !admin.equals(profileOwner)) {
+      throw new SecurityException("[" + admin + "] is neither a device owner nor a profile owner.");
+    }
+  }
+
+  private void enforceActiveAdmin(ComponentName admin) {
+    if (!deviceAdmins.contains(admin)) {
+      throw new SecurityException("[" + admin + "] is not an active device admin");
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void setAccountManagementDisabled(
+      ComponentName admin, String accountType, boolean disabled) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    if (disabled) {
+      accountTypesWithManagementDisabled.add(accountType);
+    } else {
+      accountTypesWithManagementDisabled.remove(accountType);
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected String[] getAccountTypesWithManagementDisabled() {
+    return accountTypesWithManagementDisabled.toArray(new String[0]);
+  }
+
+  /**
+   * Sets organization name.
+   *
+   * <p>The API can only be called by profile owner since Android N and can be called by both of
+   * profile owner and device owner since Android O.
+   */
+  @Implementation(minSdk = N)
+  protected void setOrganizationName(ComponentName admin, @Nullable CharSequence name) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      enforceDeviceOwnerOrProfileOwner(admin);
+    } else {
+      enforceProfileOwner(admin);
+    }
+
+    if (TextUtils.isEmpty(name)) {
+      organizationName = null;
+    } else {
+      organizationName = name;
+    }
+  }
+
+  @Implementation(minSdk = N)
+  protected String[] setPackagesSuspended(
+      ComponentName admin, String[] packageNames, boolean suspended) {
+    if (admin != null) {
+      enforceDeviceOwnerOrProfileOwner(admin);
+    }
+    if (packageNames == null) {
+      throw new NullPointerException("package names cannot be null");
+    }
+    PackageManager pm = context.getPackageManager();
+    ArrayList<String> packagesFailedToSuspend = new ArrayList<>();
+    for (String packageName : packageNames) {
+      try {
+        // check if it is installed
+        pm.getPackageInfo(packageName, 0);
+        if (suspended) {
+          suspendedPackages.add(packageName);
+        } else {
+          suspendedPackages.remove(packageName);
+        }
+      } catch (NameNotFoundException e) {
+        packagesFailedToSuspend.add(packageName);
+      }
+    }
+    return packagesFailedToSuspend.toArray(new String[0]);
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean isPackageSuspended(ComponentName admin, String packageName)
+      throws NameNotFoundException {
+    if (admin != null) {
+      enforceDeviceOwnerOrProfileOwner(admin);
+    }
+    // Throws NameNotFoundException
+    context.getPackageManager().getPackageInfo(packageName, 0);
+    return suspendedPackages.contains(packageName);
+  }
+
+  @Implementation(minSdk = N)
+  protected void setOrganizationColor(ComponentName admin, int color) {
+    enforceProfileOwner(admin);
+    organizationColor = color;
+  }
+
+  /**
+   * Returns organization name.
+   *
+   * <p>The API can only be called by profile owner since Android N.
+   *
+   * <p>Android framework has a hidden API for getting the organization name for device owner since
+   * Android O. This method, however, is extended to return the organization name for device owners
+   * too to make testing of {@link #setOrganizationName(ComponentName, CharSequence)} easier for
+   * device owner cases.
+   */
+  @Implementation(minSdk = N)
+  @Nullable
+  protected CharSequence getOrganizationName(ComponentName admin) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+      enforceDeviceOwnerOrProfileOwner(admin);
+    } else {
+      enforceProfileOwner(admin);
+    }
+
+    return organizationName;
+  }
+
+  @Implementation(minSdk = N)
+  protected int getOrganizationColor(ComponentName admin) {
+    enforceProfileOwner(admin);
+    return organizationColor;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void setAutoTimeRequired(ComponentName admin, boolean required) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    isAutoTimeRequired = required;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean getAutoTimeRequired() {
+    return isAutoTimeRequired;
+  }
+
+  @Implementation(minSdk = R)
+  protected void setAutoTimeZoneEnabled(ComponentName admin, boolean enabled) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    isAutoTimeZoneEnabled = enabled;
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean getAutoTimeZoneEnabled(ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return isAutoTimeZoneEnabled;
+  }
+
+  @Implementation(minSdk = P)
+  protected boolean setTimeZone(ComponentName admin, String timeZone) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    if (isAutoTimeZoneEnabled) {
+      return false;
+    }
+    this.timeZone = timeZone;
+    return true;
+  }
+
+  /** Returns the time zone set by setTimeZone. */
+  public String getTimeZone() {
+    return timeZone;
+  }
+
+  /**
+   * Sets permitted accessibility services.
+   *
+   * <p>The API can be called by either a profile or device owner.
+   *
+   * <p>This method does not check already enabled non-system accessibility services, so will always
+   * set the restriction and return true.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean setPermittedAccessibilityServices(
+      ComponentName admin, List<String> packageNames) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    permittedAccessibilityServices = packageNames;
+    return true;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @Nullable
+  protected List<String> getPermittedAccessibilityServices(ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return permittedAccessibilityServices;
+  }
+
+  /**
+   * Sets permitted input methods.
+   *
+   * <p>The API can be called by either a profile or device owner.
+   *
+   * <p>This method does not check already enabled non-system input methods, so will always set the
+   * restriction and return true.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean setPermittedInputMethods(ComponentName admin, List<String> packageNames) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    permittedInputMethods = packageNames;
+    return true;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @Nullable
+  protected List<String> getPermittedInputMethods(ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return permittedInputMethods;
+  }
+
+  /**
+   * @return the previously set status; default is {@link
+   *     DevicePolicyManager#ENCRYPTION_STATUS_UNSUPPORTED}
+   * @see #setStorageEncryptionStatus(int)
+   */
+  @Implementation
+  protected int getStorageEncryptionStatus() {
+    return storageEncryptionStatus;
+  }
+
+  /** Setter for {@link DevicePolicyManager#getStorageEncryptionStatus()}. */
+  public void setStorageEncryptionStatus(int status) {
+    switch (status) {
+      case DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE:
+      case DevicePolicyManager.ENCRYPTION_STATUS_INACTIVE:
+      case DevicePolicyManager.ENCRYPTION_STATUS_ACTIVATING:
+      case DevicePolicyManager.ENCRYPTION_STATUS_UNSUPPORTED:
+        break;
+      case DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_DEFAULT_KEY:
+        if (RuntimeEnvironment.getApiLevel() < M) {
+          throw new IllegalArgumentException("status " + status + " requires API " + M);
+        }
+        break;
+      case DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER:
+        if (RuntimeEnvironment.getApiLevel() < N) {
+          throw new IllegalArgumentException("status " + status + " requires API " + N);
+        }
+        break;
+      default:
+        throw new IllegalArgumentException("Unknown status: " + status);
+    }
+
+    storageEncryptionStatus = status;
+  }
+
+  @Implementation
+  protected int setStorageEncryption(ComponentName admin, boolean encrypt) {
+    enforceActiveAdmin(admin);
+    this.storageEncryptionRequested = encrypt;
+    return storageEncryptionStatus;
+  }
+
+  @Implementation
+  protected boolean getStorageEncryption(ComponentName admin) {
+    return storageEncryptionRequested;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected int getPermissionGrantState(
+      ComponentName admin, String packageName, String permission) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    Integer state =
+        appPermissionGrantStateMap.get(new PackageAndPermission(packageName, permission));
+    return state == null ? DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT : state;
+  }
+
+  public boolean isPermissionGranted(String packageName, String permission) {
+    Boolean isGranted =
+        appPermissionGrantedMap.get(new PackageAndPermission(packageName, permission));
+    return isGranted == null ? false : isGranted;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected boolean setPermissionGrantState(
+      ComponentName admin, String packageName, String permission, int grantState) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+
+    String selfPackageName = context.getPackageName();
+
+    if (packageName.equals(selfPackageName)) {
+      PackageInfo packageInfo;
+      try {
+        packageInfo =
+            context
+                .getPackageManager()
+                .getPackageInfo(selfPackageName, PackageManager.GET_PERMISSIONS);
+      } catch (NameNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+      if (Arrays.asList(packageInfo.requestedPermissions).contains(permission)) {
+        if (grantState == DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED) {
+          ShadowApplication.getInstance().grantPermissions(permission);
+        }
+        if (grantState == DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED) {
+          ShadowApplication.getInstance().denyPermissions(permission);
+        }
+      } else {
+        // the app does not require this permission
+        return false;
+      }
+    }
+    PackageAndPermission key = new PackageAndPermission(packageName, permission);
+    switch (grantState) {
+      case DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED:
+        appPermissionGrantedMap.put(key, true);
+        break;
+      case DevicePolicyManager.PERMISSION_GRANT_STATE_DENIED:
+        appPermissionGrantedMap.put(key, false);
+        break;
+      default:
+        // no-op
+    }
+    appPermissionGrantStateMap.put(key, grantState);
+    return true;
+  }
+
+  @Implementation
+  protected void lockNow() {
+    KeyguardManager keyguardManager =
+        (KeyguardManager) this.context.getSystemService(Context.KEYGUARD_SERVICE);
+    ShadowKeyguardManager shadowKeyguardManager = Shadow.extract(keyguardManager);
+    shadowKeyguardManager.setKeyguardLocked(true);
+    shadowKeyguardManager.setIsDeviceLocked(true);
+  }
+
+  @Implementation
+  protected void wipeData(int flags) {
+    wipeCalled++;
+  }
+
+  public long getWipeCalledTimes() {
+    return wipeCalled;
+  }
+
+  @Implementation
+  protected void setPasswordQuality(ComponentName admin, int quality) {
+    enforceActiveAdmin(admin);
+    requiredPasswordQuality = quality;
+  }
+
+  @Implementation
+  protected int getPasswordQuality(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return requiredPasswordQuality;
+  }
+
+  @Implementation
+  protected boolean resetPassword(String password, int flags) {
+    if (!passwordMeetsRequirements(password)) {
+      return false;
+    }
+    lastSetPassword = password;
+    boolean secure = !password.isEmpty();
+    KeyguardManager keyguardManager =
+        (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE);
+    shadowOf(keyguardManager).setIsDeviceSecure(secure);
+    shadowOf(keyguardManager).setIsKeyguardSecure(secure);
+    return true;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean resetPasswordWithToken(
+      ComponentName admin, String password, byte[] token, int flags) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    if (!Arrays.equals(passwordResetTokens.get(admin), token)
+        || !componentsWithActivatedTokens.contains(admin)) {
+      throw new IllegalStateException("wrong or not activated token");
+    }
+    resetPassword(password, flags);
+    return true;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean isResetPasswordTokenActive(ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return componentsWithActivatedTokens.contains(admin);
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean setResetPasswordToken(ComponentName admin, byte[] token) {
+    if (token.length < 32) {
+      throw new IllegalArgumentException("token too short: " + token.length);
+    }
+    enforceDeviceOwnerOrProfileOwner(admin);
+    passwordResetTokens.put(admin, token);
+    componentsWithActivatedTokens.remove(admin);
+    KeyguardManager keyguardManager =
+        (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE);
+    if (!keyguardManager.isDeviceSecure()) {
+      activateResetToken(admin);
+    }
+    return true;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumLength(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumLength = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumLength(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumLength;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumLetters(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumLetters = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumLetters(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumLetters;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumLowerCase(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumLowerCase = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumLowerCase(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumLowerCase;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumUpperCase(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumUpperCase = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumUpperCase(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumUpperCase;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumNonLetter(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumNonLetter = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumNonLetter(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumNonLetter;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumNumeric(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumNumeric = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumNumeric(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumNumeric;
+  }
+
+  @Implementation
+  protected void setPasswordMinimumSymbols(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordMinimumSymbols = length;
+  }
+
+  @Implementation
+  protected int getPasswordMinimumSymbols(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordMinimumSymbols;
+  }
+
+  @Implementation
+  protected void setMaximumFailedPasswordsForWipe(ComponentName admin, int num) {
+    enforceActiveAdmin(admin);
+    maximumFailedPasswordsForWipe = num;
+  }
+
+  @Implementation
+  protected int getMaximumFailedPasswordsForWipe(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return maximumFailedPasswordsForWipe;
+  }
+
+  @Implementation
+  protected void setCameraDisabled(ComponentName admin, boolean disabled) {
+    enforceActiveAdmin(admin);
+    cameraDisabled = disabled;
+  }
+
+  @Implementation
+  protected boolean getCameraDisabled(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return cameraDisabled;
+  }
+
+  @Implementation
+  protected void setPasswordExpirationTimeout(ComponentName admin, long timeout) {
+    enforceActiveAdmin(admin);
+    passwordExpirationTimeout = timeout;
+  }
+
+  @Implementation
+  protected long getPasswordExpirationTimeout(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordExpirationTimeout;
+  }
+
+  /**
+   * Sets the password expiration time for a particular admin.
+   *
+   * @param admin which DeviceAdminReceiver this request is associated with.
+   * @param timeout the password expiration time, in milliseconds since epoch.
+   */
+  public void setPasswordExpiration(ComponentName admin, long timeout) {
+    enforceActiveAdmin(admin);
+    passwordExpiration = timeout;
+  }
+
+  @Implementation
+  protected long getPasswordExpiration(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordExpiration;
+  }
+
+  @Implementation
+  protected void setMaximumTimeToLock(ComponentName admin, long timeMs) {
+    enforceActiveAdmin(admin);
+    maximumTimeToLock = timeMs;
+  }
+
+  @Implementation
+  protected long getMaximumTimeToLock(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return maximumTimeToLock;
+  }
+
+  @Implementation
+  protected void setPasswordHistoryLength(ComponentName admin, int length) {
+    enforceActiveAdmin(admin);
+    passwordHistoryLength = length;
+  }
+
+  @Implementation
+  protected int getPasswordHistoryLength(ComponentName admin) {
+    if (admin != null) {
+      enforceActiveAdmin(admin);
+    }
+    return passwordHistoryLength;
+  }
+
+  /**
+   * Sets if the password meets the current requirements.
+   *
+   * @param sufficient indicates the password meets the current requirements
+   */
+  public void setActivePasswordSufficient(boolean sufficient) {
+    isActivePasswordSufficient = sufficient;
+  }
+
+  @Implementation
+  protected boolean isActivePasswordSufficient() {
+    return isActivePasswordSufficient;
+  }
+
+  /** Sets whether the device is provisioned. */
+  public void setDeviceProvisioned(boolean isProvisioned) {
+    isDeviceProvisioned = isProvisioned;
+  }
+
+  @Implementation(minSdk = O)
+  @SystemApi
+  @RequiresPermission(android.Manifest.permission.MANAGE_USERS)
+  protected boolean isDeviceProvisioned() {
+    return isDeviceProvisioned;
+  }
+
+  @Implementation(minSdk = O)
+  @SystemApi
+  @RequiresPermission(android.Manifest.permission.MANAGE_USERS)
+  protected void setDeviceProvisioningConfigApplied() {
+    isDeviceProvisioningConfigApplied = true;
+  }
+
+  @Implementation(minSdk = O)
+  @SystemApi
+  @RequiresPermission(android.Manifest.permission.MANAGE_USERS)
+  protected boolean isDeviceProvisioningConfigApplied() {
+    return isDeviceProvisioningConfigApplied;
+  }
+
+  /** Sets the password complexity. */
+  public void setPasswordComplexity(@PasswordComplexity int passwordComplexity) {
+    this.passwordComplexity = passwordComplexity;
+  }
+
+  @PasswordComplexity
+  @Implementation(minSdk = Q)
+  protected int getPasswordComplexity() {
+    return passwordComplexity;
+  }
+
+  private boolean passwordMeetsRequirements(String password) {
+    int digit = 0;
+    int alpha = 0;
+    int upper = 0;
+    int lower = 0;
+    int symbol = 0;
+    for (int i = 0; i < password.length(); i++) {
+      char c = password.charAt(i);
+      if (Character.isDigit(c)) {
+        digit++;
+      }
+      if (Character.isLetter(c)) {
+        alpha++;
+      }
+      if (Character.isUpperCase(c)) {
+        upper++;
+      }
+      if (Character.isLowerCase(c)) {
+        lower++;
+      }
+      if (!Character.isLetterOrDigit(c)) {
+        symbol++;
+      }
+    }
+    switch (requiredPasswordQuality) {
+      case DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED:
+      case DevicePolicyManager.PASSWORD_QUALITY_MANAGED:
+      case DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK:
+        return true;
+      case DevicePolicyManager.PASSWORD_QUALITY_SOMETHING:
+        return password.length() > 0;
+      case DevicePolicyManager.PASSWORD_QUALITY_NUMERIC:
+      case DevicePolicyManager.PASSWORD_QUALITY_NUMERIC_COMPLEX: // complexity not enforced
+        return digit > 0 && password.length() >= passwordMinimumLength;
+      case DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC:
+        return digit > 0 && alpha > 0 && password.length() >= passwordMinimumLength;
+      case DevicePolicyManager.PASSWORD_QUALITY_COMPLEX:
+        return password.length() >= passwordMinimumLength
+            && alpha >= passwordMinimumLetters
+            && lower >= passwordMinimumLowerCase
+            && upper >= passwordMinimumUpperCase
+            && digit + symbol >= passwordMinimumNonLetter
+            && digit >= passwordMinimumNumeric
+            && symbol >= passwordMinimumSymbols;
+      default:
+        return true;
+    }
+  }
+
+  /**
+   * Retrieves last password set through {@link DevicePolicyManager#resetPassword} or {@link
+   * DevicePolicyManager#resetPasswordWithToken}.
+   */
+  public String getLastSetPassword() {
+    return lastSetPassword;
+  }
+
+  /**
+   * Activates reset token for given admin.
+   *
+   * @param admin Which {@link DeviceAdminReceiver} this request is associated with.
+   * @return if the activation state changed.
+   * @throws IllegalArgumentException if there is no token set for this admin.
+   */
+  public boolean activateResetToken(ComponentName admin) {
+    if (!passwordResetTokens.containsKey(admin)) {
+      throw new IllegalArgumentException("No token set for comopnent: " + admin);
+    }
+    return componentsWithActivatedTokens.add(admin);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addPersistentPreferredActivity(
+      ComponentName admin, IntentFilter filter, ComponentName activity) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+
+    PackageManager packageManager = context.getPackageManager();
+    Shadow.<ShadowPackageManager>extract(packageManager)
+        .addPersistentPreferredActivity(filter, activity);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void clearPackagePersistentPreferredActivities(
+      ComponentName admin, String packageName) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    PackageManager packageManager = context.getPackageManager();
+    Shadow.<ShadowPackageManager>extract(packageManager)
+        .clearPackagePersistentPreferredActivities(packageName);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void setKeyguardDisabledFeatures(ComponentName admin, int which) {
+    enforceActiveAdmin(admin);
+    keyguardDisabledFeatures = which;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected int getKeyguardDisabledFeatures(ComponentName admin) {
+    return keyguardDisabledFeatures;
+  }
+
+  /**
+   * Sets the user provisioning state.
+   *
+   * @param state to store provisioning state
+   */
+  public void setUserProvisioningState(int state) {
+    setUserProvisioningState(state, Process.myUserHandle());
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void setUserProvisioningState(@UserProvisioningState int state, UserHandle userHandle) {
+    userProvisioningStatesMap.put(userHandle.getIdentifier(), state);
+  }
+
+  /**
+   * Returns the provisioning state set in {@link #setUserProvisioningState(int)}, or {@link
+   * DevicePolicyManager#STATE_USER_UNMANAGED} if none is set.
+   */
+  @Implementation(minSdk = N)
+  protected int getUserProvisioningState() {
+    return getUserProvisioningStateForUser(Process.myUserHandle().getIdentifier());
+  }
+
+  @Implementation
+  protected boolean hasGrantedPolicy(@NonNull ComponentName admin, int usesPolicy) {
+    enforceActiveAdmin(admin);
+    Set<Integer> policyGrantedSet = adminPolicyGrantedMap.get(admin);
+    return policyGrantedSet != null && policyGrantedSet.contains(usesPolicy);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void setLockTaskPackages(@NonNull ComponentName admin, String[] packages) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    lockTaskPackages.clear();
+    Collections.addAll(lockTaskPackages, packages);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected String[] getLockTaskPackages(@NonNull ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return lockTaskPackages.toArray(new String[0]);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isLockTaskPermitted(@NonNull String pkg) {
+    return lockTaskPackages.contains(pkg);
+  }
+
+  @Implementation(minSdk = O)
+  protected void setAffiliationIds(@NonNull ComponentName admin, @NonNull Set<String> ids) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    affiliationIds.clear();
+    affiliationIds.addAll(ids);
+  }
+
+  @Implementation(minSdk = O)
+  protected Set<String> getAffiliationIds(@NonNull ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return affiliationIds;
+  }
+
+  @Implementation(minSdk = M)
+  protected void setPermissionPolicy(@NonNull ComponentName admin, int policy) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    permissionPolicy = policy;
+  }
+
+  @Implementation(minSdk = M)
+  protected int getPermissionPolicy(ComponentName admin) {
+    enforceDeviceOwnerOrProfileOwner(admin);
+    return permissionPolicy;
+  }
+
+  /**
+   * Grants a particular device policy for an active ComponentName.
+   *
+   * @param admin the ComponentName which DeviceAdminReceiver this request is associated with. Must
+   *     be an active administrator, or an exception will be thrown. This value must never be null.
+   * @param usesPolicy the uses-policy to check
+   */
+  public void grantPolicy(@NonNull ComponentName admin, int usesPolicy) {
+    enforceActiveAdmin(admin);
+    Set<Integer> policyGrantedSet = adminPolicyGrantedMap.get(admin);
+    if (policyGrantedSet == null) {
+      policyGrantedSet = new HashSet<>();
+      policyGrantedSet.add(usesPolicy);
+      adminPolicyGrantedMap.put(admin, policyGrantedSet);
+    } else {
+      policyGrantedSet.add(usesPolicy);
+    }
+  }
+
+  @Implementation(minSdk = M)
+  protected SystemUpdatePolicy getSystemUpdatePolicy() {
+    return policy;
+  }
+
+  @Implementation(minSdk = M)
+  protected void setSystemUpdatePolicy(ComponentName admin, SystemUpdatePolicy policy) {
+    this.policy = policy;
+  }
+
+  /**
+   * Sets the system update policy.
+   *
+   * @see #setSystemUpdatePolicy(ComponentName, SystemUpdatePolicy)
+   */
+  public void setSystemUpdatePolicy(SystemUpdatePolicy policy) {
+    setSystemUpdatePolicy(null, policy);
+  }
+
+  /**
+   * Set the list of target users that the calling device or profile owner can use when calling
+   * {@link #bindDeviceAdminServiceAsUser}.
+   *
+   * @see #getBindDeviceAdminTargetUsers(ComponentName)
+   */
+  public void setBindDeviceAdminTargetUsers(List<UserHandle> bindDeviceAdminTargetUsers) {
+    this.bindDeviceAdminTargetUsers = bindDeviceAdminTargetUsers;
+  }
+
+  /**
+   * Returns the list of target users that the calling device or profile owner can use when calling
+   * {@link #bindDeviceAdminServiceAsUser}.
+   *
+   * @see #setBindDeviceAdminTargetUsers(List)
+   */
+  @Implementation(minSdk = O)
+  protected List<UserHandle> getBindDeviceAdminTargetUsers(ComponentName admin) {
+    return bindDeviceAdminTargetUsers;
+  }
+
+  /**
+   * Bind to the same package in another user.
+   *
+   * <p>This validates that the targetUser is one from {@link
+   * #getBindDeviceAdminTargetUsers(ComponentName)} but does not actually bind to a different user,
+   * instead binding to the same user.
+   *
+   * <p>It also does not validate the service being bound to.
+   */
+  @Implementation(minSdk = O)
+  protected boolean bindDeviceAdminServiceAsUser(
+      ComponentName admin,
+      Intent serviceIntent,
+      ServiceConnection conn,
+      int flags,
+      UserHandle targetUser) {
+    if (!getBindDeviceAdminTargetUsers(admin).contains(targetUser)) {
+      throw new SecurityException("Not allowed to bind to target user id");
+    }
+
+    return context.bindServiceAsUser(serviceIntent, conn, flags, targetUser);
+  }
+
+  @Implementation(minSdk = N)
+  protected void setShortSupportMessage(ComponentName admin, @Nullable CharSequence message) {
+    enforceActiveAdmin(admin);
+    shortSupportMessageMap.put(admin, message);
+  }
+
+  @Implementation(minSdk = N)
+  @Nullable
+  protected CharSequence getShortSupportMessage(ComponentName admin) {
+    enforceActiveAdmin(admin);
+    return shortSupportMessageMap.get(admin);
+  }
+
+  @Implementation(minSdk = N)
+  protected void setLongSupportMessage(ComponentName admin, @Nullable CharSequence message) {
+    enforceActiveAdmin(admin);
+    longSupportMessageMap.put(admin, message);
+  }
+
+  @Implementation(minSdk = N)
+  @Nullable
+  protected CharSequence getLongSupportMessage(ComponentName admin) {
+    enforceActiveAdmin(admin);
+    return longSupportMessageMap.get(admin);
+  }
+
+  /**
+   * Sets the return value of the {@link
+   * DevicePolicyManager#isOrganizationOwnedDeviceWithManagedProfile} method (only for Android R+).
+   */
+  public void setOrganizationOwnedDeviceWithManagedProfile(boolean value) {
+    organizationOwnedDeviceWithManagedProfile = value;
+  }
+
+  /**
+   * Returns the value stored using in the shadow, while the real method returns the value store on
+   * the device.
+   *
+   * <p>The value can be set by {@link #setOrganizationOwnedDeviceWithManagedProfile} and is {@code
+   * false} by default.
+   */
+  @Implementation(minSdk = R)
+  protected boolean isOrganizationOwnedDeviceWithManagedProfile() {
+    return organizationOwnedDeviceWithManagedProfile;
+  }
+
+  @Implementation(minSdk = S)
+  @NearbyStreamingPolicy
+  protected int getNearbyNotificationStreamingPolicy() {
+    return nearbyNotificationStreamingPolicy;
+  }
+
+  @Implementation(minSdk = S)
+  protected void setNearbyNotificationStreamingPolicy(@NearbyStreamingPolicy int policy) {
+    nearbyNotificationStreamingPolicy = policy;
+  }
+
+  @Implementation(minSdk = S)
+  @NearbyStreamingPolicy
+  protected int getNearbyAppStreamingPolicy() {
+    return nearbyAppStreamingPolicy;
+  }
+
+  @Implementation(minSdk = S)
+  protected void setNearbyAppStreamingPolicy(@NearbyStreamingPolicy int policy) {
+    nearbyAppStreamingPolicy = policy;
+  }
+
+  @Nullable
+  @Implementation(minSdk = TIRAMISU)
+  protected String getDevicePolicyManagementRoleHolderPackage() {
+    return devicePolicyManagementRoleHolderPackage;
+  }
+
+  /**
+   * Sets the package name of the device policy management role holder.
+   *
+   * @see #getDevicePolicyManagementRoleHolderPackage()
+   */
+  public void setDevicePolicyManagementRoleHolderPackage(@Nullable String packageName) {
+    devicePolicyManagementRoleHolderPackage = packageName;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void finalizeWorkProfileProvisioning(
+      UserHandle managedProfileUser, @Nullable Account migratedAccount) {
+    finalizedWorkProfileProvisioningMap.put(managedProfileUser, migratedAccount);
+  }
+
+  /**
+   * Returns if {@link #finalizeWorkProfileProvisioning(UserHandle, Account)} was called with the
+   * provided parameters.
+   */
+  public boolean isWorkProfileProvisioningFinalized(
+      UserHandle userHandle, @Nullable Account migratedAccount) {
+    return finalizedWorkProfileProvisioningMap.containsKey(userHandle)
+        && Objects.equals(finalizedWorkProfileProvisioningMap.get(userHandle), migratedAccount);
+  }
+
+  /**
+   * Returns the managed profiles set in {@link #setPolicyManagedProfiles(List)}. This value does
+   * not take the user handle parameter into account.
+   */
+  @Implementation(minSdk = TIRAMISU)
+  protected List<UserHandle> getPolicyManagedProfiles(UserHandle userHandle) {
+    return policyManagedProfiles;
+  }
+
+  /** Sets the value returned by {@link #getPolicyManagedProfiles(UserHandle)}. */
+  public void setPolicyManagedProfiles(List<UserHandle> policyManagedProfiles) {
+    this.policyManagedProfiles = policyManagedProfiles;
+  }
+
+  /**
+   * Returns the user provisioning state set by {@link #setUserProvisioningState(int, UserHandle)},
+   * or {@link DevicePolicyManager#STATE_USER_UNMANAGED} if none is set.
+   */
+  @UserProvisioningState
+  public int getUserProvisioningStateForUser(int userId) {
+    return userProvisioningStatesMap.getOrDefault(userId, DevicePolicyManager.STATE_USER_UNMANAGED);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDevicePolicyResourcesManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDevicePolicyResourcesManager.java
new file mode 100644
index 0000000..eaf9027
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDevicePolicyResourcesManager.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.admin.DevicePolicyResourcesManager;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link DevicePolicyResourcesManager}. */
+@Implements(
+    value = DevicePolicyResourcesManager.class,
+    minSdk = VERSION_CODES.TIRAMISU,
+    // turn off shadowOf generation (new API)
+    isInAndroidSdk = false)
+public class ShadowDevicePolicyResourcesManager {
+
+  @RealObject DevicePolicyResourcesManager realDevicePolicyResourcesManager;
+  private final Map<String, String> stringMappings = new HashMap<>();
+
+  /**
+   * Override string returned by the resource identified by {@code stringId}. Reset the override by
+   * providing null as the {@code vaNlue}.
+   */
+  public void setString(@NonNull String stringId, String value) {
+    stringMappings.put(stringId, value);
+  }
+
+  @Implementation
+  @Nullable
+  protected String getString(
+      @NonNull String stringId, @NonNull Supplier<String> defaultStringLoader) {
+    String value = stringMappings.get(stringId);
+    if (value != null) {
+      return value;
+    }
+
+    return reflector(DevicePolicyResourcesManagerReflector.class, realDevicePolicyResourcesManager)
+        .getString(stringId, defaultStringLoader);
+  }
+
+  @ForType(DevicePolicyResourcesManager.class)
+  interface DevicePolicyResourcesManagerReflector {
+    @Direct
+    String getString(@NonNull String stringId, @NonNull Supplier<String> defaultStringLoader);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDexFile.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDexFile.java
new file mode 100644
index 0000000..53e5a76
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDexFile.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import dalvik.system.DexFile;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow implementation of dalvik.system.Dexfile. */
+@Implements(DexFile.class)
+public class ShadowDexFile {
+  private static boolean isDexOptNeeded = false;
+  private static Throwable dexOptNeededError = null;
+
+  @Implementation
+  protected static boolean isDexOptNeeded(String fileName) throws Throwable {
+    if (dexOptNeededError != null) {
+      dexOptNeededError.fillInStackTrace();
+      throw dexOptNeededError;
+    }
+    return isDexOptNeeded;
+  }
+
+  /** Sets the value to be returned when isDexOptNeeded() is called with any argument. */
+  public static void setIsDexOptNeeded(boolean isDexOptNeeded) {
+    ShadowDexFile.isDexOptNeeded = isDexOptNeeded;
+  }
+
+  /**
+   * Sets the throwable that will be thrown when isDexOptNeeded() is called. isDexOptNeeded() won't
+   * throw if the error is null.
+   */
+  public static void setIsDexOptNeededError(Throwable error) {
+    dexOptNeededError = error;
+  }
+
+  @Resetter
+  public static void reset() {
+    isDexOptNeeded = false;
+    dexOptNeededError = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDialog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDialog.java
new file mode 100644
index 0000000..a041bfb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDialog.java
@@ -0,0 +1,170 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Dialog.class)
+public class ShadowDialog {
+
+  @RealObject private Dialog realDialog;
+
+  private boolean isShowing;
+  Context context;
+  private int layoutId;
+  private int themeId;
+  private View inflatedView;
+  private boolean hasBeenDismissed;
+  protected CharSequence title;
+  private DialogInterface.OnCancelListener onCancelListener;
+  private Window window;
+  private Activity ownerActivity;
+  private boolean hasShownBefore;
+  private static final ArrayList<Dialog> shownDialogs = new ArrayList<>();
+  private boolean isCancelableOnTouchOutside;
+
+  private static ShadowDialog latestDialog;
+
+  @Resetter
+  public static void reset() {
+    setLatestDialog(null);
+    shownDialogs.clear();
+  }
+
+  public static Dialog getLatestDialog() {
+    return latestDialog == null ? null : latestDialog.realDialog;
+  }
+
+  public static void setLatestDialog(ShadowDialog dialog) {
+    latestDialog = dialog;
+  }
+
+  @Implementation
+  protected void show() {
+    setLatestDialog(this);
+    shownDialogs.add(realDialog);
+    reflector(DialogReflector.class, realDialog).show();
+  }
+
+  @Implementation
+  protected void dismiss() {
+    reflector(DialogReflector.class, realDialog).dismiss();
+    hasBeenDismissed = true;
+  }
+
+  public void clickOn(int viewId) {
+    realDialog.findViewById(viewId).performClick();
+  }
+
+  @Implementation
+  protected void setCanceledOnTouchOutside(boolean flag) {
+    isCancelableOnTouchOutside = flag;
+    reflector(DialogReflector.class, realDialog).setCanceledOnTouchOutside(flag);
+  }
+
+  public boolean isCancelable() {
+    return ReflectionHelpers.getField(realDialog, "mCancelable");
+  }
+
+  public boolean isCancelableOnTouchOutside() {
+    return isCancelableOnTouchOutside;
+  }
+
+  public DialogInterface.OnCancelListener getOnCancelListener() {
+    return onCancelListener;
+  }
+
+  @Implementation
+  protected void setOnCancelListener(DialogInterface.OnCancelListener listener) {
+    this.onCancelListener = listener;
+    reflector(DialogReflector.class, realDialog).setOnCancelListener(listener);
+  }
+
+  public boolean hasBeenDismissed() {
+    return hasBeenDismissed;
+  }
+
+  public CharSequence getTitle() {
+    ShadowWindow shadowWindow = Shadow.extract(realDialog.getWindow());
+    return shadowWindow.getTitle();
+  }
+
+  public void clickOnText(int textId) {
+    if (inflatedView == null) {
+      inflatedView = LayoutInflater.from(context).inflate(layoutId, null);
+    }
+    String text = realDialog.getContext().getResources().getString(textId);
+    if (!clickOnText(inflatedView, text)) {
+      throw new IllegalArgumentException("Text not found: " + text);
+    }
+  }
+
+  public void clickOnText(String text) {
+    if (!clickOnText(inflatedView, text)) {
+      throw new IllegalArgumentException("Text not found: " + text);
+    }
+  }
+
+  private boolean clickOnText(View view, String text) {
+    ShadowView shadowView = Shadow.extract(view);
+    if (text.equals(shadowView.innerText())) {
+      view.performClick();
+      return true;
+    }
+    if (view instanceof ViewGroup) {
+      ViewGroup viewGroup = (ViewGroup) view;
+      for (int i = 0; i < viewGroup.getChildCount(); i++) {
+        View child = viewGroup.getChildAt(i);
+        if (clickOnText(child, text)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  public static List<Dialog> getShownDialogs() {
+    return shownDialogs;
+  }
+
+  public void callOnCreate(Bundle bundle) {
+    ReflectionHelpers.callInstanceMethod(
+        realDialog, "onCreate", ClassParameter.from(Bundle.class, bundle));
+  }
+
+  @ForType(Dialog.class)
+  interface DialogReflector {
+
+    @Direct
+    void show();
+
+    @Direct
+    void dismiss();
+
+    @Direct
+    void setCanceledOnTouchOutside(boolean flag);
+
+    @Direct
+    void setOnCancelListener(DialogInterface.OnCancelListener listener);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDiscoverySession.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDiscoverySession.java
new file mode 100644
index 0000000..0fc9de3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDiscoverySession.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+
+import android.net.wifi.aware.DiscoverySession;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = DiscoverySession.class, minSdk = O)
+public class ShadowDiscoverySession {
+
+  public static DiscoverySession newInstance() {
+    return ReflectionHelpers.callConstructor(DiscoverySession.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplay.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplay.java
new file mode 100644
index 0000000..c4f5770
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplay.java
@@ -0,0 +1,477 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Point;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.Display.HdrCapabilities;
+import android.view.Surface;
+import android.view.WindowManager;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * It is possible to override some display properties using setters on {@link ShadowDisplay}.
+ *
+ * @see <a href="http://robolectric.org/device-configuration/">device configuration</a> for details.
+ */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = Display.class)
+public class ShadowDisplay {
+
+  /**
+   * Returns the default display.
+   *
+   * @return the default display
+   */
+  public static Display getDefaultDisplay() {
+    WindowManager windowManager =
+        (WindowManager)
+            RuntimeEnvironment.getApplication().getSystemService(Context.WINDOW_SERVICE);
+    return windowManager.getDefaultDisplay();
+  }
+
+  @RealObject Display realObject;
+
+  private Float refreshRate;
+
+  // the following fields are used only for Jelly Bean...
+  private String name;
+  private Integer displayId;
+  private Integer width;
+  private Integer height;
+  private Integer realWidth;
+  private Integer realHeight;
+  private Integer densityDpi;
+  private Float xdpi;
+  private Float ydpi;
+  private Float scaledDensity;
+  private Integer rotation;
+  private Integer pixelFormat;
+
+  /**
+   * If {@link #setScaledDensity(float)} has been called, {@link DisplayMetrics#scaledDensity} will
+   * be modified to reflect the value specified. Note that this is not a realistic state.
+   *
+   * @deprecated This behavior is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  @Implementation
+  protected void getMetrics(DisplayMetrics outMetrics) {
+    if (isJB()) {
+      outMetrics.density = densityDpi * DisplayMetrics.DENSITY_DEFAULT_SCALE;
+      outMetrics.densityDpi = densityDpi;
+      outMetrics.scaledDensity = scaledDensity;
+      outMetrics.widthPixels = width;
+      outMetrics.heightPixels = height;
+      outMetrics.xdpi = xdpi;
+      outMetrics.ydpi = ydpi;
+    } else {
+      reflector(_Display_.class, realObject).getMetrics(outMetrics);
+      if (scaledDensity != null) {
+        outMetrics.scaledDensity = scaledDensity;
+      }
+    }
+  }
+
+  /**
+   * If {@link #setScaledDensity(float)} has been called, {@link DisplayMetrics#scaledDensity} will
+   * be modified to reflect the value specified. Note that this is not a realistic state.
+   *
+   * @deprecated This behavior is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  @Implementation
+  protected void getRealMetrics(DisplayMetrics outMetrics) {
+    if (isJB()) {
+      getMetrics(outMetrics);
+      outMetrics.widthPixels = realWidth;
+      outMetrics.heightPixels = realHeight;
+    } else {
+      reflector(_Display_.class, realObject).getRealMetrics(outMetrics);
+      if (scaledDensity != null) {
+        outMetrics.scaledDensity = scaledDensity;
+      }
+    }
+  }
+
+  /**
+   * If {@link #setDisplayId(int)} has been called, this method will return the specified value.
+   *
+   * @deprecated This behavior is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  @Implementation
+  protected int getDisplayId() {
+    return displayId == null ? reflector(_Display_.class, realObject).getDisplayId() : displayId;
+  }
+
+  /**
+   * If {@link #setRefreshRate(float)} has been called, this method will return the specified value.
+   *
+   * @deprecated This behavior is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  @Implementation
+  protected float getRefreshRate() {
+    if (refreshRate != null) {
+      return refreshRate;
+    }
+    float realRefreshRate = reflector(_Display_.class, realObject).getRefreshRate();
+    // refresh rate may be set by native code. if its 0, set to 60fps
+    if (realRefreshRate < 0.1) {
+      realRefreshRate = 60;
+    }
+    return realRefreshRate;
+  }
+
+  /**
+   * If {@link #setPixelFormat(int)} has been called, this method will return the specified value.
+   *
+   * @deprecated This behavior is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  @Implementation
+  protected int getPixelFormat() {
+    return pixelFormat == null
+        ? reflector(_Display_.class, realObject).getPixelFormat()
+        : pixelFormat;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN)
+  protected void getSizeInternal(Point outSize, boolean doCompat) {
+    outSize.x = width;
+    outSize.y = height;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN)
+  protected void getCurrentSizeRange(Point outSmallestSize, Point outLargestSize) {
+    int minimum = Math.min(width, height);
+    int maximum = Math.max(width, height);
+    outSmallestSize.set(minimum, minimum);
+    outLargestSize.set(maximum, maximum);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN)
+  protected void getRealSize(Point outSize) {
+    outSize.set(realWidth, realHeight);
+  }
+
+  /**
+   * Changes the density for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setDensity(float density) {
+    setDensityDpi((int) (density * DisplayMetrics.DENSITY_DEFAULT));
+  }
+
+  /**
+   * Changes the density for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setDensityDpi(int densityDpi) {
+    if (isJB()) {
+      this.densityDpi = densityDpi;
+    } else {
+      ShadowDisplayManager.changeDisplay(
+          realObject.getDisplayId(), di -> di.logicalDensityDpi = densityDpi);
+    }
+  }
+
+  /**
+   * Changes the horizontal DPI for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setXdpi(float xdpi) {
+    if (isJB()) {
+      this.xdpi = xdpi;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.physicalXDpi = xdpi);
+    }
+  }
+
+  /**
+   * Changes the vertical DPI for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setYdpi(float ydpi) {
+    if (isJB()) {
+      this.ydpi = ydpi;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.physicalYDpi = ydpi);
+    }
+  }
+
+  /**
+   * Changes the scaled density for this display.
+   *
+   * @deprecated This method is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  public void setScaledDensity(float scaledDensity) {
+    this.scaledDensity = scaledDensity;
+  }
+
+  /**
+   * Changes the ID for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @deprecated This method is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  public void setDisplayId(int displayId) {
+    this.displayId = displayId;
+  }
+
+  /**
+   * Changes the name for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setName(String name) {
+    if (isJB()) {
+      this.name = name;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.name = name);
+    }
+  }
+
+  /**
+   * Changes the flags for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setFlags(int flags) {
+    reflector(_Display_.class, realObject).setFlags(flags);
+
+    if (!isJB()) {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.flags = flags);
+    }
+  }
+
+  /**
+   * Changes the width available to the application for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @param width the new width in pixels
+   */
+  public void setWidth(int width) {
+    if (isJB()) {
+      this.width = width;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.appWidth = width);
+    }
+  }
+
+  /**
+   * Changes the height available to the application for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @param height new height in pixels
+   */
+  public void setHeight(int height) {
+    if (isJB()) {
+      this.height = height;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.appHeight = height);
+    }
+  }
+
+  /**
+   * Changes the simulated physical width for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @param width the new width in pixels
+   */
+  public void setRealWidth(int width) {
+    if (isJB()) {
+      this.realWidth = width;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.logicalWidth = width);
+    }
+  }
+
+  /**
+   * Changes the simulated physical height for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @param height the new height in pixels
+   */
+  public void setRealHeight(int height) {
+    if (isJB()) {
+      this.realHeight = height;
+    } else {
+      ShadowDisplayManager.changeDisplay(
+          realObject.getDisplayId(), di -> di.logicalHeight = height);
+    }
+  }
+
+  /**
+   * Changes the refresh rate for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   */
+  public void setRefreshRate(float refreshRate) {
+    this.refreshRate = refreshRate;
+  }
+
+  /**
+   * Changes the rotation for this display.
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @param rotation one of {@link Surface#ROTATION_0}, {@link Surface#ROTATION_90}, {@link
+   *     Surface#ROTATION_180}, {@link Surface#ROTATION_270}
+   */
+  public void setRotation(int rotation) {
+    if (isJB()) {
+      this.rotation = rotation;
+    } else {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.rotation = rotation);
+    }
+  }
+
+  /**
+   * Changes the pixel format for this display.
+   *
+   * @deprecated This method is deprecated and will be removed in Robolectric 3.7.
+   */
+  @Deprecated
+  public void setPixelFormat(int pixelFormat) {
+    this.pixelFormat = pixelFormat;
+  }
+
+  /**
+   * Changes the simulated state for this display, such as whether it is on or off
+   *
+   * <p>Any registered {@link android.hardware.display.DisplayManager.DisplayListener}s will be
+   * notified of the change.
+   *
+   * @param state the new state: one of {@link Display#STATE_OFF}, {@link Display#STATE_ON}, {@link
+   *     Display#STATE_DOZE}, {@link Display#STATE_DOZE_SUSPEND}, or {@link Display#STATE_UNKNOWN}.
+   */
+  public void setState(int state) {
+    if (!isJB()) {
+      ShadowDisplayManager.changeDisplay(realObject.getDisplayId(), di -> di.state = state);
+    }
+  }
+
+  /**
+   * Set HDR capabilities to the display sourced with displayId. see {@link HdrCapabilities} for
+   * supportedHdrTypes.
+   *
+   * @throws UnsupportedOperationException if the method is called below Android vesrsion N.
+   */
+  public void setDisplayHdrCapabilities(
+      int displayId,
+      float maxLuminance,
+      float maxAverageLuminance,
+      float minLuminance,
+      int... supportedHdrTypes) {
+    if (Build.VERSION.SDK_INT < VERSION_CODES.N) {
+      throw new UnsupportedOperationException("HDR capabilities are not supported below Android N");
+    }
+
+    ShadowDisplayManager.changeDisplay(
+        displayId,
+        displayConfig -> {
+          displayConfig.hdrCapabilities =
+              new HdrCapabilities(
+                  supportedHdrTypes, maxLuminance, maxAverageLuminance, minLuminance);
+        });
+  }
+
+  /**
+   * Changes the display cutout for this display.
+   *
+   * @throws UnsupportedOperationException if the method is called below Android version Q.
+   */
+  public void setDisplayCutout(Object displayCutout) {
+    if (Build.VERSION.SDK_INT < VERSION_CODES.Q) {
+      throw new UnsupportedOperationException("Display cutouts are not supported below Android Q");
+    }
+
+    ShadowDisplayManager.changeDisplay(
+        realObject.getDisplayId(), displayConfig -> displayConfig.displayCutout = displayCutout);
+  }
+
+  private boolean isJB() {
+    return RuntimeEnvironment.getApiLevel() == JELLY_BEAN;
+  }
+
+  void configureForJBOnly(Configuration configuration, DisplayMetrics displayMetrics) {
+    int widthPx = (int) (configuration.screenWidthDp * displayMetrics.density);
+    int heightPx = (int) (configuration.screenHeightDp * displayMetrics.density);
+
+    name = "Built-in screen";
+    displayId = 0;
+    width = widthPx;
+    height = heightPx;
+    realWidth = widthPx;
+    realHeight = heightPx;
+    densityDpi = displayMetrics.densityDpi;
+    xdpi = (float) displayMetrics.densityDpi;
+    ydpi = (float) displayMetrics.densityDpi;
+    scaledDensity = displayMetrics.densityDpi * DisplayMetrics.DENSITY_DEFAULT_SCALE;
+    rotation =
+        configuration.orientation == Configuration.ORIENTATION_PORTRAIT
+            ? Surface.ROTATION_0
+            : Surface.ROTATION_90;
+  }
+
+  /** Reflector interface for {@link Display}'s internals. */
+  @ForType(Display.class)
+  interface _Display_ {
+
+    @Direct
+    void getMetrics(DisplayMetrics outMetrics);
+
+    @Direct
+    void getRealMetrics(DisplayMetrics outMetrics);
+
+    @Direct
+    int getDisplayId();
+
+    @Direct
+    float getRefreshRate();
+
+    @Direct
+    int getPixelFormat();
+
+    @Accessor("mFlags")
+    void setFlags(int flags);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java
new file mode 100644
index 0000000..36c8240
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java
@@ -0,0 +1,293 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+
+import android.os.MessageQueue;
+import android.os.SystemClock;
+import android.view.Choreographer;
+import android.view.DisplayEventReceiver;
+import dalvik.system.CloseGuard;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Array;
+import java.time.Duration;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.res.android.NativeObjRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.WithType;
+
+/**
+ * Shadow of {@link DisplayEventReceiver}. The {@link Choreographer} is a subclass of {@link
+ * DisplayEventReceiver}, and receives vsync events from the display indicating the frequency that
+ * frames should be generated.
+ *
+ * <p>The {@code ShadowDisplayEventReceiver} can run in either a paused mode or a non-paused mode,
+ * see {@link ShadowChoreographer#isPaused()} and {@link ShadowChoreographer#setPaused(boolean)}. By
+ * default it runs unpaused, and each time a frame callback is scheduled with the {@link
+ * Choreographer} the clock is advanced to the next frame, configured by {@link
+ * ShadowChoreographer#setFrameDelay(Duration)}. In paused mode the clock is not auto advanced and
+ * the next frame will only trigger when the clock is advance manually or via the {@link
+ * ShadowLooper}.
+ */
+@Implements(
+    className = "android.view.DisplayEventReceiver",
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowDisplayEventReceiver {
+
+  private static NativeObjRegistry<NativeDisplayEventReceiver> nativeObjRegistry =
+      new NativeObjRegistry<>(NativeDisplayEventReceiver.class);
+
+  @RealObject protected DisplayEventReceiver realReceiver;
+  @ReflectorObject private DisplayEventReceiverReflector displayEventReceiverReflector;
+
+  @Implementation(minSdk = O, maxSdk = Q)
+  protected static long nativeInit(
+      WeakReference<DisplayEventReceiver> receiver, MessageQueue msgQueue, int vsyncSource) {
+    return nativeObjRegistry.register(new NativeDisplayEventReceiver(receiver));
+  }
+
+  @Implementation(minSdk = M, maxSdk = N_MR1)
+  protected static long nativeInit(
+      WeakReference<DisplayEventReceiver> receiver, MessageQueue msgQueue) {
+    return nativeObjRegistry.register(new NativeDisplayEventReceiver(receiver));
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH, maxSdk = LOLLIPOP_MR1)
+  protected static long nativeInit(DisplayEventReceiver receiver, MessageQueue msgQueue) {
+    return nativeObjRegistry.register(
+        new NativeDisplayEventReceiver(new WeakReference<>(receiver)));
+  }
+
+  @Implementation(maxSdk = KITKAT)
+  protected static int nativeInit(Object receiver, Object msgQueue) {
+    return (int)
+        nativeObjRegistry.register(
+            new NativeDisplayEventReceiver(new WeakReference<>((DisplayEventReceiver) receiver)));
+  }
+
+  @Implementation(minSdk = R)
+  protected static long nativeInit(
+      WeakReference<DisplayEventReceiver> receiver,
+      MessageQueue msgQueue,
+      int vsyncSource,
+      int configChanged) {
+    return nativeInit(receiver, msgQueue);
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected static void nativeDispose(long receiverPtr) {
+    NativeDisplayEventReceiver receiver = nativeObjRegistry.unregister(receiverPtr);
+    if (receiver != null) {
+      receiver.dispose();
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT)
+  protected static void nativeDispose(int receiverPtr) {
+    NativeDisplayEventReceiver receiver = nativeObjRegistry.unregister(receiverPtr);
+    if (receiver != null) {
+      receiver.dispose();
+    }
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected static void nativeScheduleVsync(long receiverPtr) {
+    nativeObjRegistry.getNativeObject(receiverPtr).scheduleVsync();
+  }
+
+  @Implementation(maxSdk = KITKAT)
+  protected static void nativeScheduleVsync(int receiverPtr) {
+    nativeObjRegistry.getNativeObject(receiverPtr).scheduleVsync();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1, maxSdk = R)
+  protected void dispose(boolean finalized) {
+    CloseGuard closeGuard = displayEventReceiverReflector.getCloseGuard();
+    // Suppresses noisy CloseGuard warning
+    if (closeGuard != null) {
+      closeGuard.close();
+    }
+    displayEventReceiverReflector.dispose(finalized);
+  }
+
+  protected void onVsync() {
+    if (RuntimeEnvironment.getApiLevel() <= JELLY_BEAN) {
+      displayEventReceiverReflector.onVsync(ShadowSystem.nanoTime(), 1);
+    } else if (RuntimeEnvironment.getApiLevel() < Q) {
+      displayEventReceiverReflector.onVsync(
+          ShadowSystem.nanoTime(), 0, /* SurfaceControl.BUILT_IN_DISPLAY_ID_MAIN */ 1);
+    } else if (RuntimeEnvironment.getApiLevel() < S) {
+      displayEventReceiverReflector.onVsync(
+          ShadowSystem.nanoTime(), 0L, /* SurfaceControl.BUILT_IN_DISPLAY_ID_MAIN */ 1);
+    } else if (RuntimeEnvironment.getApiLevel() < TIRAMISU) {
+      try {
+        // onVsync takes a package-private VSyncData class as a parameter, thus reflection
+        // needs to be used
+        Object vsyncData =
+            ReflectionHelpers.callConstructor(
+                Class.forName("android.view.DisplayEventReceiver$VsyncEventData"),
+                ClassParameter.from(long.class, 1), /* id */
+                ClassParameter.from(long.class, 10), /* frameDeadline */
+                ClassParameter.from(long.class, 1)); /* frameInterval */
+
+        displayEventReceiverReflector.onVsync(
+            ShadowSystem.nanoTime(),
+            0L, /* physicalDisplayId currently ignored */
+            /* frame= */ 1,
+            vsyncData /* VsyncEventData */);
+      } catch (ClassNotFoundException e) {
+        throw new LinkageError("Unable to construct VsyncEventData", e);
+      }
+    } else {
+      displayEventReceiverReflector.onVsync(
+          ShadowSystem.nanoTime(),
+          0L, /* physicalDisplayId currently ignored */
+          1, /* frame */
+          newVsyncEventData() /* VsyncEventData */);
+    }
+  }
+
+  /**
+   * A simulation of the native code that provides synchronization with the display hardware frames
+   * (aka vsync), that attempts to provide relatively accurate behavior, while adjusting for
+   * Robolectric's fixed system clock.
+   *
+   * <p>In the default mode, requests for a vsync callback will be processed immediately inline. The
+   * system clock is also auto advanced by VSYNC_DELAY to appease the calling Choreographer that
+   * expects an advancing system clock. This mode allows seamless view layout / traversal operations
+   * with a simple {@link ShadowLooper#idle()} call.
+   *
+   * <p>However, the default mode can cause problems with animations which continually request vsync
+   * callbacks, leading to timeouts and hamper attempts to verify animations in progress. For those
+   * use cases, an 'async' callback mode is provided (via the {@link
+   * ShadowChoreographer#setPostFrameCallbackDelay(int)} API. In this mode, vsync requests will be
+   * scheduled asynchronously by listening to clock updates.
+   */
+  private static class NativeDisplayEventReceiver {
+
+    private final WeakReference<DisplayEventReceiver> receiverRef;
+    private final ShadowPausedSystemClock.Listener clockListener = this::onClockAdvanced;
+
+    @GuardedBy("this")
+    private long nextVsyncTime = 0;
+
+    public NativeDisplayEventReceiver(WeakReference<DisplayEventReceiver> receiverRef) {
+      this.receiverRef = receiverRef;
+      // register a clock listener for the async mode
+      ShadowPausedSystemClock.addListener(clockListener);
+    }
+
+    private void onClockAdvanced() {
+      synchronized (this) {
+        if (nextVsyncTime == 0 || ShadowPausedSystemClock.uptimeMillis() < nextVsyncTime) {
+          return;
+        }
+        nextVsyncTime = 0;
+      }
+      doVsync();
+    }
+
+    void dispose() {
+      ShadowPausedSystemClock.removeListener(clockListener);
+    }
+
+    public void scheduleVsync() {
+      Duration frameDelay = ShadowChoreographer.getFrameDelay();
+      if (ShadowChoreographer.isPaused()) {
+        synchronized (this) {
+          nextVsyncTime = SystemClock.uptimeMillis() + frameDelay.toMillis();
+        }
+      } else {
+        // simulate an immediate callback
+        ShadowSystemClock.advanceBy(frameDelay);
+        doVsync();
+      }
+    }
+
+    private void doVsync() {
+      DisplayEventReceiver receiver = receiverRef.get();
+      if (receiver != null) {
+        ShadowDisplayEventReceiver shadowReceiver = Shadow.extract(receiver);
+        shadowReceiver.onVsync();
+      }
+    }
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected Object getLatestVsyncEventData() {
+    return newVsyncEventData();
+  }
+
+  private Object newVsyncEventData() {
+    try {
+      // onVsync on T takes a package-private VsyncEventData class, which is itself composed of a
+      // package private VsyncEventData.FrameTimeline  class. So use reflection to build these up
+      Class<?> frameTimelineClass =
+          Class.forName("android.view.DisplayEventReceiver$VsyncEventData$FrameTimeline");
+      Object timeline =
+          ReflectionHelpers.callConstructor(
+              frameTimelineClass,
+              ClassParameter.from(long.class, 1) /* vsync id */,
+              ClassParameter.from(long.class, 1) /* expectedPresentTime */,
+              ClassParameter.from(long.class, 10) /* deadline */);
+
+      Object timelineArray = Array.newInstance(frameTimelineClass, 1);
+      Array.set(timelineArray, 0, timeline);
+
+      // get FrameTimeline[].class
+      Class<?> frameTimeLineArrayClass =
+          Class.forName("[Landroid.view.DisplayEventReceiver$VsyncEventData$FrameTimeline;");
+      return ReflectionHelpers.callConstructor(
+          Class.forName("android.view.DisplayEventReceiver$VsyncEventData"),
+          ClassParameter.from(frameTimeLineArrayClass, timelineArray),
+          ClassParameter.from(int.class, 0), /* frameDeadline */
+          ClassParameter.from(long.class, 1)); /* frameInterval */
+    } catch (ClassNotFoundException e) {
+      throw new LinkageError("Unable to construct VsyncEventData", e);
+    }
+  }
+
+  /** Reflector interface for {@link DisplayEventReceiver}'s internals. */
+  @ForType(DisplayEventReceiver.class)
+  protected interface DisplayEventReceiverReflector {
+
+    @Direct
+    void dispose(boolean finalized);
+
+    void onVsync(long timestampNanos, int frame);
+
+    void onVsync(long timestampNanos, int physicalDisplayId, int frame);
+
+    void onVsync(long timestampNanos, long physicalDisplayId, int frame);
+
+    void onVsync(
+        long timestampNanos,
+        long physicalDisplayId,
+        int frame,
+        @WithType("android.view.DisplayEventReceiver$VsyncEventData") Object vsyncEventData);
+
+    @Accessor("mCloseGuard")
+    CloseGuard getCloseGuard();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayHashManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayHashManager.java
new file mode 100644
index 0000000..bb9fc55
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayHashManager.java
@@ -0,0 +1,34 @@
+package org.robolectric.shadows;
+
+import android.view.displayhash.DisplayHash;
+import android.view.displayhash.DisplayHashManager;
+import android.view.displayhash.VerifiedDisplayHash;
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link android.view.displayhash.DisplayHashManager}. */
+@Implements(value = DisplayHashManager.class, isInAndroidSdk = false)
+public class ShadowDisplayHashManager {
+
+  private static VerifiedDisplayHash verifyDisplayHashResult;
+
+  /**
+   * Sets the {@link VerifiedDisplayHash} that's going to be returned by following
+   * {DisplayHashManager#verifyDisplayHash} calls.
+   */
+  public static void setVerifyDisplayHashResult(VerifiedDisplayHash verifyDisplayHashResult) {
+    ShadowDisplayHashManager.verifyDisplayHashResult = verifyDisplayHashResult;
+  }
+
+  @Implementation(minSdk = 31)
+  protected Set<String> getSupportedHashAlgorithms() {
+    return ImmutableSet.of("PHASH");
+  }
+
+  @Implementation(minSdk = 31)
+  protected VerifiedDisplayHash verifyDisplayHash(DisplayHash displayHash) {
+    return verifyDisplayHashResult;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayListCanvas.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayListCanvas.java
new file mode 100644
index 0000000..71b2ca1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayListCanvas.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.R;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link android.view.DisplayListCanvas} from API versions M to R */
+@Implements(
+    className = "android.view.DisplayListCanvas",
+    isInAndroidSdk = false,
+    minSdk = M,
+    maxSdk = R)
+public class ShadowDisplayListCanvas extends ShadowCanvas {
+
+  @Implementation(minSdk = O, maxSdk = P)
+  protected static long nCreateDisplayListCanvas(long node, int width, int height) {
+    return 1;
+  }
+
+  @Implementation(minSdk = N, maxSdk = N_MR1)
+  protected static long nCreateDisplayListCanvas(int width, int height) {
+    return 1;
+  }
+
+  @Implementation(maxSdk = M)
+  protected static long nCreateDisplayListCanvas() {
+    return 1;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManager.java
new file mode 100644
index 0000000..9959ca1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManager.java
@@ -0,0 +1,311 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.shadow.api.Shadow.extract;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.hardware.display.BrightnessChangeEvent;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayManagerGlobal;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.DisplayInfo;
+import android.view.Surface;
+import androidx.annotation.RequiresApi;
+import com.google.auto.value.AutoBuilder;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.Bootstrap;
+import org.robolectric.android.internal.DisplayConfig;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.res.Qualifiers;
+import org.robolectric.util.Consumer;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * For tests, display properties may be changed and devices may be added or removed
+ * programmatically.
+ */
+@Implements(value = DisplayManager.class, minSdk = JELLY_BEAN_MR1, looseSignatures = true)
+public class ShadowDisplayManager {
+
+  @RealObject private DisplayManager realDisplayManager;
+
+  private Context context;
+
+  @Implementation
+  protected void __constructor__(Context context) {
+    this.context = context;
+
+    invokeConstructor(
+        DisplayManager.class, realDisplayManager, ClassParameter.from(Context.class, context));
+  }
+
+  /**
+   * Adds a simulated display and drain the main looper queue to ensure all the callbacks are
+   * processed.
+   *
+   * @param qualifiersStr the {@link Qualifiers} string representing characteristics of the new
+   *     display.
+   * @return the new display's ID
+   */
+  public static int addDisplay(String qualifiersStr) {
+    int id = getShadowDisplayManagerGlobal().addDisplay(createDisplayInfo(qualifiersStr, null));
+    shadowMainLooper().idle();
+    return id;
+  }
+
+  /** internal only */
+  public static void configureDefaultDisplay(
+      Configuration configuration, DisplayMetrics displayMetrics) {
+    ShadowDisplayManagerGlobal shadowDisplayManagerGlobal = getShadowDisplayManagerGlobal();
+    if (DisplayManagerGlobal.getInstance().getDisplayIds().length != 0) {
+      throw new IllegalStateException("this method should only be called by Robolectric");
+    }
+
+    shadowDisplayManagerGlobal.addDisplay(createDisplayInfo(configuration, displayMetrics));
+  }
+
+  private static DisplayInfo createDisplayInfo(
+      Configuration configuration, DisplayMetrics displayMetrics) {
+    int widthPx = (int) (configuration.screenWidthDp * displayMetrics.density);
+    int heightPx = (int) (configuration.screenHeightDp * displayMetrics.density);
+
+    DisplayInfo displayInfo = new DisplayInfo();
+    displayInfo.name = "Built-in screen";
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+      displayInfo.uniqueId = "screen0";
+    }
+    displayInfo.appWidth = widthPx;
+    displayInfo.appHeight = heightPx;
+    fixNominalDimens(displayInfo);
+    displayInfo.logicalWidth = widthPx;
+    displayInfo.logicalHeight = heightPx;
+    displayInfo.rotation =
+        configuration.orientation == Configuration.ORIENTATION_PORTRAIT
+            ? Surface.ROTATION_0
+            : Surface.ROTATION_90;
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+      displayInfo.modeId = 0;
+      displayInfo.defaultModeId = 0;
+      displayInfo.supportedModes = new Display.Mode[] {new Display.Mode(0, widthPx, heightPx, 60)};
+    }
+    displayInfo.logicalDensityDpi = displayMetrics.densityDpi;
+    displayInfo.physicalXDpi = displayMetrics.densityDpi;
+    displayInfo.physicalYDpi = displayMetrics.densityDpi;
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+      displayInfo.state = Display.STATE_ON;
+    }
+
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+      displayInfo.getAppMetrics(displayMetrics);
+    }
+
+    return displayInfo;
+  }
+
+  private static DisplayInfo createDisplayInfo(String qualifiersStr, DisplayInfo baseDisplayInfo) {
+    Configuration configuration = new Configuration();
+    DisplayMetrics displayMetrics = new DisplayMetrics();
+
+    if (qualifiersStr.startsWith("+") && baseDisplayInfo != null) {
+      configuration.orientation =
+          (baseDisplayInfo.rotation == Surface.ROTATION_0
+                  || baseDisplayInfo.rotation == Surface.ROTATION_180)
+              ? Configuration.ORIENTATION_PORTRAIT
+              : Configuration.ORIENTATION_LANDSCAPE;
+      configuration.screenWidthDp =
+          baseDisplayInfo.logicalWidth
+              * DisplayMetrics.DENSITY_DEFAULT
+              / baseDisplayInfo.logicalDensityDpi;
+      configuration.screenHeightDp =
+          baseDisplayInfo.logicalHeight
+              * DisplayMetrics.DENSITY_DEFAULT
+              / baseDisplayInfo.logicalDensityDpi;
+      configuration.densityDpi = baseDisplayInfo.logicalDensityDpi;
+      displayMetrics.densityDpi = baseDisplayInfo.logicalDensityDpi;
+      displayMetrics.density =
+          baseDisplayInfo.logicalDensityDpi * DisplayMetrics.DENSITY_DEFAULT_SCALE;
+    }
+
+    Bootstrap.applyQualifiers(
+        qualifiersStr, RuntimeEnvironment.getApiLevel(), configuration, displayMetrics);
+
+    return createDisplayInfo(configuration, displayMetrics);
+  }
+
+  private static void fixNominalDimens(DisplayInfo displayInfo) {
+    int smallest = Math.min(displayInfo.appWidth, displayInfo.appHeight);
+    int largest = Math.max(displayInfo.appWidth, displayInfo.appHeight);
+
+    displayInfo.smallestNominalAppWidth = smallest;
+    displayInfo.smallestNominalAppHeight = smallest;
+    displayInfo.largestNominalAppWidth = largest;
+    displayInfo.largestNominalAppHeight = largest;
+  }
+
+  /**
+   * Changes properties of a simulated display. If {@param qualifiersStr} starts with a plus ('+')
+   * sign, the display's previous configuration is modified with the given qualifiers; otherwise
+   * defaults are applied as described <a
+   * href="http://robolectric.org/device-configuration/">here</a>.
+   *
+   * <p>Idles the main looper to ensure all listeners are notified.
+   *
+   * @param displayId the display id to change
+   * @param qualifiersStr the {@link Qualifiers} string representing characteristics of the new
+   *     display
+   */
+  public static void changeDisplay(int displayId, String qualifiersStr) {
+    DisplayInfo baseDisplayInfo = DisplayManagerGlobal.getInstance().getDisplayInfo(displayId);
+    DisplayInfo displayInfo = createDisplayInfo(qualifiersStr, baseDisplayInfo);
+    getShadowDisplayManagerGlobal().changeDisplay(displayId, displayInfo);
+    shadowMainLooper().idle();
+  }
+
+  /**
+   * Sets supported modes to the specified display with ID {@code displayId}.
+   *
+   * <p>Idles the main looper to ensure all listeners are notified.
+   *
+   * @param displayId the display id to change
+   * @param supportedModes the display's supported modes
+   */
+  public static void setSupportedModes(int displayId, Display.Mode... supportedModes) {
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+      throw new UnsupportedOperationException("multiple display modes not supported before M");
+    }
+    DisplayInfo displayInfo = DisplayManagerGlobal.getInstance().getDisplayInfo(displayId);
+    displayInfo.supportedModes = supportedModes;
+    getShadowDisplayManagerGlobal().changeDisplay(displayId, displayInfo);
+    shadowMainLooper().idle();
+  }
+
+  /**
+   * Changes properties of a simulated display. The original properties will be passed to the
+   * {@param consumer}, which may modify them in place. The display will be updated with the new
+   * properties.
+   *
+   * @param displayId the display id to change
+   * @param consumer a function which modifies the display properties
+   */
+  static void changeDisplay(int displayId, Consumer<DisplayConfig> consumer) {
+    DisplayInfo displayInfo = DisplayManagerGlobal.getInstance().getDisplayInfo(displayId);
+    if (displayInfo != null) {
+      DisplayConfig displayConfig = new DisplayConfig(displayInfo);
+      consumer.accept(displayConfig);
+      displayConfig.copyTo(displayInfo);
+      fixNominalDimens(displayInfo);
+    }
+
+    getShadowDisplayManagerGlobal().changeDisplay(displayId, displayInfo);
+  }
+
+  /**
+   * Removes a simulated display and idles the main looper to ensure all listeners are notified.
+   *
+   * @param displayId the display id to remove
+   */
+  public static void removeDisplay(int displayId) {
+    getShadowDisplayManagerGlobal().removeDisplay(displayId);
+    shadowMainLooper().idle();
+  }
+
+  /**
+   * Returns the current display saturation level set via {@link
+   * android.hardware.display.DisplayManager#setSaturationLevel(float)}.
+   */
+  public float getSaturationLevel() {
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      ShadowColorDisplayManager shadowCdm =
+          extract(context.getSystemService(Context.COLOR_DISPLAY_SERVICE));
+      return shadowCdm.getSaturationLevel() / 100f;
+    }
+    return getShadowDisplayManagerGlobal().getSaturationLevel();
+  }
+
+  /**
+   * Sets the current display saturation level.
+   *
+   * <p>This is a workaround for tests which cannot use the relevant hidden {@link
+   * android.annotation.SystemApi}, {@link
+   * android.hardware.display.DisplayManager#setSaturationLevel(float)}.
+   */
+  @Implementation(minSdk = P)
+  public void setSaturationLevel(float level) {
+    reflector(DisplayManagerReflector.class, realDisplayManager).setSaturationLevel(level);
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected void setBrightnessConfiguration(Object config) {
+    setBrightnessConfigurationForUser(config, 0, context.getPackageName());
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected void setBrightnessConfigurationForUser(
+      Object config, Object userId, Object packageName) {
+    getShadowDisplayManagerGlobal().setBrightnessConfigurationForUser(config, userId, packageName);
+  }
+
+  /** Set the default brightness configuration for this device. */
+  public static void setDefaultBrightnessConfiguration(Object config) {
+    getShadowDisplayManagerGlobal().setDefaultBrightnessConfiguration(config);
+  }
+
+  /** Set the slider events the system has seen. */
+  public static void setBrightnessEvents(List<BrightnessChangeEvent> events) {
+    getShadowDisplayManagerGlobal().setBrightnessEvents(events);
+  }
+
+  private static ShadowDisplayManagerGlobal getShadowDisplayManagerGlobal() {
+    if (Build.VERSION.SDK_INT < JELLY_BEAN_MR1) {
+      throw new UnsupportedOperationException("multiple displays not supported in Jelly Bean");
+    }
+
+    return extract(DisplayManagerGlobal.getInstance());
+  }
+
+  @RequiresApi(api = Build.VERSION_CODES.M)
+  static Display.Mode displayModeOf(int modeId, int width, int height, float refreshRate) {
+    return new Display.Mode(modeId, width, height, refreshRate);
+  }
+
+  /** Builder class for {@link Display.Mode} */
+  @RequiresApi(api = Build.VERSION_CODES.M)
+  @AutoBuilder(callMethod = "displayModeOf")
+  public abstract static class ModeBuilder {
+    public static ModeBuilder modeBuilder(int modeId) {
+      return new AutoBuilder_ShadowDisplayManager_ModeBuilder().setModeId(modeId);
+    }
+
+    abstract ModeBuilder setModeId(int modeId);
+
+    public abstract ModeBuilder setWidth(int width);
+
+    public abstract ModeBuilder setHeight(int height);
+
+    public abstract ModeBuilder setRefreshRate(float refreshRate);
+
+    public abstract Display.Mode build();
+  }
+
+  @ForType(DisplayManager.class)
+  interface DisplayManagerReflector {
+
+    @Direct
+    void setSaturationLevel(float level);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java
new file mode 100644
index 0000000..c28e51f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java
@@ -0,0 +1,272 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Point;
+import android.hardware.display.BrightnessChangeEvent;
+import android.hardware.display.BrightnessConfiguration;
+import android.hardware.display.DisplayManagerGlobal;
+import android.hardware.display.IDisplayManager;
+import android.hardware.display.IDisplayManagerCallback;
+import android.hardware.display.WifiDisplayStatus;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.util.SparseArray;
+import android.view.Display;
+import android.view.DisplayInfo;
+import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+import org.robolectric.android.Bootstrap;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link DisplayManagerGlobal}. */
+@Implements(
+    value = DisplayManagerGlobal.class,
+    isInAndroidSdk = false,
+    minSdk = JELLY_BEAN_MR1,
+    looseSignatures = true)
+public class ShadowDisplayManagerGlobal {
+  private static DisplayManagerGlobal instance;
+
+  private float saturationLevel = 1f;
+  private final SparseArray<BrightnessConfiguration> brightnessConfiguration = new SparseArray<>();
+  private final List<BrightnessChangeEvent> brightnessChangeEvents = new ArrayList<>();
+  private Object defaultBrightnessConfiguration;
+
+  private MyDisplayManager mDm;
+
+  @Resetter
+  public static void reset() {
+    instance = null;
+  }
+
+  @Implementation
+  protected void __constructor__(IDisplayManager dm) {
+    // No-op the constructor. The real constructor references the ColorSpace named constants, which
+    // require native calls to instantiate. This will cause native graphics libraries to be loaded
+    // any time an Application object is created. Instead override the constructor to avoid
+    // referencing the ColorSpace named constants, making application creation around 0.75s faster.
+  }
+
+  @Implementation
+  public static synchronized DisplayManagerGlobal getInstance() {
+    if (instance == null) {
+      MyDisplayManager myIDisplayManager = new MyDisplayManager();
+      IDisplayManager proxy =
+          ReflectionHelpers.createDelegatingProxy(IDisplayManager.class, myIDisplayManager);
+      instance = newDisplayManagerGlobal(proxy);
+      ShadowDisplayManagerGlobal shadow = Shadow.extract(instance);
+      shadow.mDm = myIDisplayManager;
+      Bootstrap.setUpDisplay();
+    }
+    return instance;
+  }
+
+  private static DisplayManagerGlobal newDisplayManagerGlobal(IDisplayManager displayManager) {
+    instance = Shadow.newInstanceOf(DisplayManagerGlobal.class);
+    DisplayManagerGlobalReflector displayManagerGlobal =
+        reflector(DisplayManagerGlobalReflector.class, instance);
+    displayManagerGlobal.setDm(displayManager);
+    displayManagerGlobal.setLock(new Object());
+    displayManagerGlobal.setDisplayListeners(new ArrayList<>());
+    displayManagerGlobal.setDisplayInfoCache(new SparseArray<>());
+    return instance;
+  }
+
+  @VisibleForTesting
+  static DisplayManagerGlobal getGlobalInstance() {
+    return instance;
+  }
+
+  @Implementation
+  protected WifiDisplayStatus getWifiDisplayStatus() {
+    return new WifiDisplayStatus();
+  }
+
+  /** Returns the 'natural' dimensions of the default display. */
+  @Implementation(minSdk = O_MR1)
+  public Point getStableDisplaySize() throws RemoteException {
+    DisplayInfo defaultDisplayInfo = mDm.getDisplayInfo(Display.DEFAULT_DISPLAY);
+    return new Point(defaultDisplayInfo.getNaturalWidth(), defaultDisplayInfo.getNaturalHeight());
+  }
+
+  int addDisplay(DisplayInfo displayInfo) {
+    fixNominalDimens(displayInfo);
+
+    return mDm.addDisplay(displayInfo);
+  }
+
+  private void fixNominalDimens(DisplayInfo displayInfo) {
+    int min = Math.min(displayInfo.appWidth, displayInfo.appHeight);
+    int max = Math.max(displayInfo.appWidth, displayInfo.appHeight);
+    displayInfo.smallestNominalAppHeight = displayInfo.smallestNominalAppWidth = min;
+    displayInfo.largestNominalAppHeight = displayInfo.largestNominalAppWidth = max;
+  }
+
+  void changeDisplay(int displayId, DisplayInfo displayInfo) {
+    mDm.changeDisplay(displayId, displayInfo);
+  }
+
+  void removeDisplay(int displayId) {
+    mDm.removeDisplay(displayId);
+  }
+
+  private static class MyDisplayManager {
+    private final TreeMap<Integer, DisplayInfo> displayInfos = new TreeMap<>();
+    private int nextDisplayId = 0;
+    private final List<IDisplayManagerCallback> callbacks = new ArrayList<>();
+
+    // @Override
+    public DisplayInfo getDisplayInfo(int i) throws RemoteException {
+      DisplayInfo displayInfo = displayInfos.get(i);
+      return displayInfo == null ? null : new DisplayInfo(displayInfo);
+    }
+
+    // @Override // todo: use @Implements/@Implementation for signature checking
+    public int[] getDisplayIds() {
+      int[] ids = new int[displayInfos.size()];
+      int i = 0;
+      for (Integer displayId : displayInfos.keySet()) {
+        ids[i++] = displayId;
+      }
+      return ids;
+    }
+
+    // Added in Android T
+    @SuppressWarnings("unused")
+    public int[] getDisplayIds(boolean ignoredIncludeDisabled) {
+      return getDisplayIds();
+    }
+
+    // @Override
+    public void registerCallback(IDisplayManagerCallback iDisplayManagerCallback)
+        throws RemoteException {
+      this.callbacks.add(iDisplayManagerCallback);
+    }
+
+    public void registerCallbackWithEventMask(
+        IDisplayManagerCallback iDisplayManagerCallback, long ignoredEventsMask)
+        throws RemoteException {
+      registerCallback(iDisplayManagerCallback);
+    }
+
+    private synchronized int addDisplay(DisplayInfo displayInfo) {
+      int nextId = nextDisplayId++;
+      displayInfos.put(nextId, displayInfo);
+      notifyListeners(nextId, DisplayManagerGlobal.EVENT_DISPLAY_ADDED);
+      return nextId;
+    }
+
+    private synchronized void changeDisplay(int displayId, DisplayInfo displayInfo) {
+      if (!displayInfos.containsKey(displayId)) {
+        throw new IllegalStateException("no display " + displayId);
+      }
+
+      displayInfos.put(displayId, displayInfo);
+      notifyListeners(displayId, DisplayManagerGlobal.EVENT_DISPLAY_CHANGED);
+    }
+
+    private synchronized void removeDisplay(int displayId) {
+      if (!displayInfos.containsKey(displayId)) {
+        throw new IllegalStateException("no display " + displayId);
+      }
+
+      displayInfos.remove(displayId);
+      notifyListeners(displayId, DisplayManagerGlobal.EVENT_DISPLAY_REMOVED);
+    }
+
+    private void notifyListeners(int nextId, int event) {
+      for (IDisplayManagerCallback callback : callbacks) {
+        try {
+          callback.onDisplayEvent(nextId, event);
+        } catch (RemoteException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+  }
+
+  @Implementation(minSdk = P, maxSdk = P)
+  protected void setSaturationLevel(float level) {
+    if (level < 0f || level > 1f) {
+      throw new IllegalArgumentException("Saturation level must be between 0 and 1");
+    }
+    saturationLevel = level;
+  }
+
+  /**
+   * Returns the current display saturation level; {@link android.os.Build.VERSION_CODES.P} only.
+   */
+  float getSaturationLevel() {
+    return saturationLevel;
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected void setBrightnessConfigurationForUser(
+      Object configObject, Object userId, Object packageName) {
+    BrightnessConfiguration config = (BrightnessConfiguration) configObject;
+    brightnessConfiguration.put((int) userId, config);
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected Object getBrightnessConfigurationForUser(int userId) {
+    BrightnessConfiguration config = brightnessConfiguration.get(userId);
+    if (config != null) {
+      return config;
+    } else {
+      return getDefaultBrightnessConfiguration();
+    }
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected Object getDefaultBrightnessConfiguration() {
+    return defaultBrightnessConfiguration;
+  }
+
+  void setDefaultBrightnessConfiguration(@Nullable Object configObject) {
+    BrightnessConfiguration config = (BrightnessConfiguration) configObject;
+    defaultBrightnessConfiguration = config;
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected List<BrightnessChangeEvent> getBrightnessEvents(String callingPackage) {
+    return brightnessChangeEvents;
+  }
+
+  void setBrightnessEvents(List<BrightnessChangeEvent> events) {
+    brightnessChangeEvents.clear();
+    brightnessChangeEvents.addAll(events);
+  }
+
+  @ForType(DisplayManagerGlobal.class)
+  interface DisplayManagerGlobalReflector {
+    @Accessor("mDm")
+    void setDm(IDisplayManager displayManager);
+
+    @Accessor("mLock")
+    void setLock(Object lock);
+
+    @Accessor("mDisplayListeners")
+    void setDisplayListeners(ArrayList<Handler> list);
+
+    @Accessor("mDisplayInfoCache")
+    void setDisplayInfoCache(SparseArray<DisplayInfo> displayInfoCache);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDownloadManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDownloadManager.java
new file mode 100644
index 0000000..aadbf80
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDownloadManager.java
@@ -0,0 +1,535 @@
+package org.robolectric.shadows;
+
+import android.app.DownloadManager;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.util.Pair;
+import com.android.internal.util.ArrayUtils;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.fakes.BaseCursor;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(DownloadManager.class)
+public class ShadowDownloadManager {
+
+  private long queueCounter = -1; // First request starts at 0 just like in the real DownloadManager
+  private Map<Long, DownloadManager.Request> requestMap = new TreeMap<>();
+
+  private long completedCounter = -1;
+  private Map<Long, CompletedDownload> completedDownloadsMap = new HashMap<>();
+
+  @Implementation
+  protected long enqueue(DownloadManager.Request request) {
+    queueCounter++;
+    requestMap.put(queueCounter, request);
+    return queueCounter;
+  }
+
+  @Implementation
+  protected int remove(long... ids) {
+    int removeCount = 0;
+    for (long id : ids) {
+      if (requestMap.remove(id) != null) {
+        removeCount++;
+      }
+    }
+    return removeCount;
+  }
+
+  @Implementation
+  protected Cursor query(DownloadManager.Query query) {
+    ResultCursor result = new ResultCursor();
+    ShadowQuery shadow = Shadow.extract(query);
+    long[] ids = shadow.getIds();
+
+    if (ids != null) {
+      for (long id : ids) {
+        DownloadManager.Request request = requestMap.get(id);
+        if (request != null) {
+          result.requests.add(request);
+        }
+      }
+    } else {
+      result.requests.addAll(requestMap.values());
+    }
+    return result;
+  }
+
+  @Implementation
+  protected long addCompletedDownload(
+      String title,
+      String description,
+      boolean isMediaScannerScannable,
+      String mimeType,
+      String path,
+      long length,
+      boolean showNotification) {
+    return addCompletedDownload(
+        title,
+        description,
+        isMediaScannerScannable,
+        mimeType,
+        path,
+        length,
+        showNotification,
+        /* uri= */ null,
+        /* referrer= */ null);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected long addCompletedDownload(
+      String title,
+      String description,
+      boolean isMediaScannerScannable,
+      String mimeType,
+      String path,
+      long length,
+      boolean showNotification,
+      Uri uri,
+      Uri referrer) {
+    completedCounter++;
+    completedDownloadsMap.put(
+        completedCounter,
+        new CompletedDownload(
+            title,
+            description,
+            isMediaScannerScannable,
+            mimeType,
+            path,
+            length,
+            showNotification,
+            uri,
+            referrer));
+    return completedCounter;
+  }
+
+  public DownloadManager.Request getRequest(long id) {
+    return requestMap.get(id);
+  }
+
+  public int getRequestCount() {
+    return requestMap.size();
+  }
+
+  public CompletedDownload getCompletedDownload(long id) {
+    return completedDownloadsMap.get(id);
+  }
+
+  public int getCompletedDownloadsCount() {
+    return completedDownloadsMap.size();
+  }
+
+  @Implements(DownloadManager.Request.class)
+  public static class ShadowRequest {
+    @RealObject DownloadManager.Request realObject;
+
+    private int status;
+    private long totalSize;
+    private long bytesSoFar;
+
+    public int getStatus() {
+      return this.status;
+    }
+
+    public void setStatus(int status) {
+      this.status = status;
+    }
+
+    public long getTotalSize() {
+      return this.totalSize;
+    }
+
+    public void setTotalSize(long totalSize) {
+      this.totalSize = totalSize;
+    }
+
+    public long getBytesSoFar() {
+      return this.bytesSoFar;
+    }
+
+    public void setBytesSoFar(long bytesSoFar) {
+      this.bytesSoFar = bytesSoFar;
+    }
+
+    public Uri getUri() {
+      return getFieldReflectively("mUri", realObject, Uri.class);
+    }
+
+    public Uri getDestination() {
+      return getFieldReflectively("mDestinationUri", realObject, Uri.class);
+    }
+
+    public CharSequence getTitle() {
+      return getFieldReflectively("mTitle", realObject, CharSequence.class);
+    }
+
+    public CharSequence getDescription() {
+      return getFieldReflectively("mDescription", realObject, CharSequence.class);
+    }
+
+    public CharSequence getMimeType() {
+      return getFieldReflectively("mMimeType", realObject, CharSequence.class);
+    }
+
+    public int getNotificationVisibility() {
+      return getFieldReflectively("mNotificationVisibility", realObject, Integer.class);
+    }
+
+    public int getAllowedNetworkTypes() {
+      return getFieldReflectively("mAllowedNetworkTypes", realObject, Integer.class);
+    }
+
+    public boolean getAllowedOverRoaming() {
+      return getFieldReflectively("mRoamingAllowed", realObject, Boolean.class);
+    }
+
+    public boolean getAllowedOverMetered() {
+      return getFieldReflectively("mMeteredAllowed", realObject, Boolean.class);
+    }
+
+    public boolean getVisibleInDownloadsUi() {
+      return getFieldReflectively("mIsVisibleInDownloadsUi", realObject, Boolean.class);
+    }
+
+    public List<Pair<String, String>> getRequestHeaders() {
+      return getFieldReflectively("mRequestHeaders", realObject, List.class);
+    }
+
+    @Implementation
+    protected DownloadManager.Request setDestinationInExternalPublicDir(
+        String dirType, String subPath) throws Exception {
+      File file = Environment.getExternalStoragePublicDirectory(dirType);
+      if (file == null) {
+        throw new IllegalStateException("Failed to get external storage public directory");
+      }
+      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+          && !ArrayUtils.contains(Environment.STANDARD_DIRECTORIES, dirType)) {
+        throw new IllegalStateException("Not one of standard directories: " + dirType);
+      }
+
+      if (file.exists()) {
+        if (!file.isDirectory()) {
+          throw new IllegalStateException(
+              file.getAbsolutePath() + " already exists and is not a directory");
+        }
+      } else if (!file.mkdirs()) {
+        throw new IllegalStateException("Unable to create directory: " + file.getAbsolutePath());
+      }
+      setDestinationFromBase(file, subPath);
+
+      return realObject;
+    }
+
+    @Implementation
+    protected void setDestinationFromBase(File base, String subPath) {
+      if (subPath == null) {
+        throw new NullPointerException("subPath cannot be null");
+      }
+      ReflectionHelpers.setField(
+          realObject, "mDestinationUri", Uri.withAppendedPath(Uri.fromFile(base), subPath));
+    }
+  }
+
+  @Implements(DownloadManager.Query.class)
+  public static class ShadowQuery {
+    @RealObject DownloadManager.Query realObject;
+
+    public long[] getIds() {
+      return getFieldReflectively("mIds", realObject, long[].class);
+    }
+  }
+
+  private static class ResultCursor extends BaseCursor {
+    private static final int COLUMN_INDEX_LOCAL_FILENAME = 0;
+    private static final int COLUMN_INDEX_DESCRIPTION = 1;
+    private static final int COLUMN_INDEX_REASON = 2;
+    private static final int COLUMN_INDEX_STATUS = 3;
+    private static final int COLUMN_INDEX_URI = 4;
+    private static final int COLUMN_INDEX_LOCAL_URI = 5;
+    private static final int COLUMN_INDEX_TITLE = 6;
+    private static final int COLUMN_INDEX_TOTAL_SIZE = 7;
+    private static final int COLUMN_INDEX_BYTES_SO_FAR = 8;
+
+    public List<DownloadManager.Request> requests = new ArrayList<>();
+    private int positionIndex = -1;
+    private boolean closed;
+
+    @Override
+    public int getCount() {
+      checkClosed();
+      return requests.size();
+    }
+
+    @Override
+    public int getPosition() {
+      return positionIndex;
+    }
+
+    @Override
+    public boolean moveToFirst() {
+      checkClosed();
+      positionIndex = 0;
+      return !requests.isEmpty();
+    }
+
+    @Override
+    public boolean moveToNext() {
+      checkClosed();
+      positionIndex += 1;
+      return positionIndex < requests.size();
+    }
+
+    @Override
+    public int getColumnIndex(String columnName) {
+      checkClosed();
+
+      if (DownloadManager.COLUMN_LOCAL_FILENAME.equals(columnName)) {
+        return COLUMN_INDEX_LOCAL_FILENAME;
+
+      } else if (DownloadManager.COLUMN_DESCRIPTION.equals(columnName)) {
+        return COLUMN_INDEX_DESCRIPTION;
+
+      } else if (DownloadManager.COLUMN_REASON.equals(columnName)) {
+        return COLUMN_INDEX_REASON;
+
+      } else if (DownloadManager.COLUMN_STATUS.equals(columnName)) {
+        return COLUMN_INDEX_STATUS;
+
+      } else if (DownloadManager.COLUMN_URI.equals(columnName)) {
+        return COLUMN_INDEX_URI;
+
+      } else if (DownloadManager.COLUMN_LOCAL_URI.equals(columnName)) {
+        return COLUMN_INDEX_LOCAL_URI;
+
+      } else if (DownloadManager.COLUMN_TITLE.equals(columnName)) {
+        return COLUMN_INDEX_TITLE;
+      } else if (DownloadManager.COLUMN_TOTAL_SIZE_BYTES.equals(columnName)) {
+        return COLUMN_INDEX_TOTAL_SIZE;
+      } else if (DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR.equals(columnName)) {
+        return COLUMN_INDEX_BYTES_SO_FAR;
+      }
+
+      return -1;
+    }
+
+    @Override
+    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
+      checkClosed();
+
+      int columnIndex = getColumnIndex(columnName);
+      if (columnIndex == -1) {
+        throw new IllegalArgumentException("Column not found.");
+      }
+      return columnIndex;
+    }
+
+    @Override
+    public void close() {
+      this.closed = true;
+    }
+
+    @Override
+    public boolean isClosed() {
+      return closed;
+    }
+
+    @Override
+    public String getString(int columnIndex) {
+      checkClosed();
+      ShadowRequest request = Shadow.extract(requests.get(positionIndex));
+      switch (columnIndex) {
+        case COLUMN_INDEX_LOCAL_FILENAME:
+          return "local file name not implemented";
+
+        case COLUMN_INDEX_REASON:
+          return "reason not implemented";
+
+        case COLUMN_INDEX_DESCRIPTION:
+          return request.getDescription().toString();
+
+        case COLUMN_INDEX_URI:
+          return request.getUri().toString();
+
+        case COLUMN_INDEX_LOCAL_URI:
+          return request.getDestination().toString();
+
+        case COLUMN_INDEX_TITLE:
+          return request.getTitle().toString();
+      }
+
+      return "Unknown ColumnIndex " + columnIndex;
+    }
+
+    @Override
+    public int getInt(int columnIndex) {
+      checkClosed();
+      ShadowRequest request = Shadow.extract(requests.get(positionIndex));
+      if (columnIndex == COLUMN_INDEX_STATUS) {
+        return request.getStatus();
+      }
+      return 0;
+    }
+
+    @Override
+    public long getLong(int columnIndex) {
+      checkClosed();
+      ShadowRequest request = Shadow.extract(requests.get(positionIndex));
+      if (columnIndex == COLUMN_INDEX_TOTAL_SIZE) {
+        return request.getTotalSize();
+      } else if (columnIndex == COLUMN_INDEX_BYTES_SO_FAR) {
+        return request.getBytesSoFar();
+      }
+      return 0;
+    }
+
+    private void checkClosed() {
+      if (closed) {
+        throw new IllegalStateException("Cursor is already closed.");
+      }
+    }
+  }
+
+  /**
+   * Value class to represent a "completed download" sent to {@link DownloadManager} using the
+   * addCompletedDownload APIs.
+   */
+  public static class CompletedDownload {
+    private final String title;
+    private final String description;
+    private final boolean isMediaScannerScannable;
+    private final String mimeType;
+    private final String path;
+    private final long length;
+    private final boolean showNotification;
+    private final Uri uri;
+    private final Uri referrer;
+
+    public CompletedDownload(
+        String title,
+        String description,
+        boolean isMediaScannerScannable,
+        String mimeType,
+        String path,
+        long length,
+        boolean showNotification) {
+      this(
+          title,
+          description,
+          isMediaScannerScannable,
+          mimeType,
+          path,
+          length,
+          showNotification,
+          /* uri= */ null,
+          /* referrer= */ null);
+    }
+
+    public CompletedDownload(
+        String title,
+        String description,
+        boolean isMediaScannerScannable,
+        String mimeType,
+        String path,
+        long length,
+        boolean showNotification,
+        @Nullable Uri uri,
+        @Nullable Uri referrer) {
+      Preconditions.checkArgument(!Strings.isNullOrEmpty(title), "title can't be null");
+      Preconditions.checkArgument(!Strings.isNullOrEmpty(description), "description can't be null");
+      Preconditions.checkArgument(!Strings.isNullOrEmpty(path), "path can't be null");
+      Preconditions.checkArgument(!Strings.isNullOrEmpty(mimeType), "mimeType can't be null");
+      if (length < 0) {
+        throw new IllegalArgumentException("invalid value for param: length");
+      }
+      this.title = title;
+      this.description = description;
+      this.isMediaScannerScannable = isMediaScannerScannable;
+      this.mimeType = mimeType;
+      this.path = path;
+      this.length = length;
+      this.showNotification = showNotification;
+      this.uri = uri;
+      this.referrer = referrer;
+    }
+
+    public String getTitle() {
+      return title;
+    }
+
+    public String getDescription() {
+      return description;
+    }
+
+    public boolean isMediaScannerScannable() {
+      return isMediaScannerScannable;
+    }
+
+    public String getMimeType() {
+      return mimeType;
+    }
+
+    public String getPath() {
+      return path;
+    }
+
+    public long getLength() {
+      return length;
+    }
+
+    public boolean showNotification() {
+      return showNotification;
+    }
+
+    @Nullable
+    public Uri getUri() {
+      return uri;
+    }
+
+    @Nullable
+    public Uri getReferrer() {
+      return referrer;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof CompletedDownload)) {
+        return false;
+      }
+      CompletedDownload other = (CompletedDownload) o;
+      return this.title.equals(other.getTitle())
+          && this.description.equals(other.getDescription())
+          && this.isMediaScannerScannable == other.isMediaScannerScannable()
+          && this.mimeType.equals(other.getMimeType())
+          && this.path.equals(other.getPath())
+          && this.length == other.getLength()
+          && this.showNotification == other.showNotification()
+          && Objects.equals(this.uri, other.getUri())
+          && Objects.equals(this.referrer, other.getReferrer());
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(
+          title, description, mimeType, path, length, showNotification, uri, referrer);
+    }
+  }
+
+  private static <T> T getFieldReflectively(String fieldName, Object object, Class<T> clazz) {
+    return clazz.cast(ReflectionHelpers.getField(object, fieldName));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDrawable.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDrawable.java
new file mode 100644
index 0000000..85690e9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDrawable.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import java.io.InputStream;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Drawable.class)
+public class ShadowDrawable {
+
+  @RealObject Drawable realDrawable;
+
+  int createdFromResId = -1;
+  InputStream createdFromInputStream;
+
+  private boolean wasInvalidated;
+
+  /**
+   * Returns an invalid Drawable with the given the resource id.
+   *
+   * @deprecated use {@code ContextCompat.getDrawable(context, resourceId)}
+   */
+  @Deprecated
+  public static Drawable createFromResourceId(int resourceId) {
+    Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
+    BitmapDrawable drawable = new BitmapDrawable(bitmap);
+    ShadowBitmapDrawable shadowBitmapDrawable = Shadow.extract(drawable);
+    shadowBitmapDrawable.validate(); // start off not invalidated
+    shadowBitmapDrawable.setCreatedFromResId(resourceId, null);
+    return drawable;
+  }
+
+  protected void setCreatedFromResId(int createdFromResId, String resourceName) {
+    this.createdFromResId = createdFromResId;
+  }
+
+  public InputStream getInputStream() {
+    return createdFromInputStream;
+  }
+
+  @Implementation
+  protected void invalidateSelf() {
+    wasInvalidated = true;
+    reflector(DrawableReflector.class, realDrawable).invalidateSelf();
+  }
+
+  public int getCreatedFromResId() {
+    return createdFromResId;
+  }
+
+  public boolean wasInvalidated() {
+    return wasInvalidated;
+  }
+
+  public void validate() {
+    wasInvalidated = false;
+  }
+
+  @ForType(Drawable.class)
+  interface DrawableReflector {
+
+    @Direct
+    void invalidateSelf();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDropBoxManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDropBoxManager.java
new file mode 100644
index 0000000..99a4790
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDropBoxManager.java
@@ -0,0 +1,75 @@
+package org.robolectric.shadows;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.os.DropBoxManager;
+import android.os.DropBoxManager.Entry;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Fake dropbox manager that starts with no entries. */
+@Implements(value = DropBoxManager.class)
+public class ShadowDropBoxManager {
+  private final SortedMap<Long, Entry> entries = new TreeMap<>();
+
+  public ShadowDropBoxManager() {
+    reset();
+  }
+
+  /**
+   * Adds entry to the DropboxManager with the flag indicating data is text.
+   *
+   * <p>The existing {@link DropBoxManager#addData} and {@link DropBoxManager#addFile} methods in
+   * DropBoxManager are not shadowed (and do not work), but {@link DropBoxManager#addText} is. This
+   * method is a convenience for quickly adding multiple historical entries. The entries can be
+   * added in any order since this shadow will sort the entries by the specified timestamp.
+   *
+   * <p>The flag will be set to {@link DropBoxManager#IS_TEXT} so that {@link
+   * DropBoxManager.Entry#getText} can be used.
+   *
+   * @param tag can be any arbitrary string
+   * @param timestamp a unique timestamp for the entry, relative to {@link
+   *     System#currentTimeMillis()}
+   * @param data must not be null
+   */
+  public void addData(String tag, long wallTimestamp, byte[] data) {
+    if (entries.containsKey(wallTimestamp)) {
+      throw new AssertionError("Cannot add multiple entries with the exact same timestamp.");
+    }
+    entries.put(
+        wallTimestamp, new DropBoxManager.Entry(tag, wallTimestamp, data, DropBoxManager.IS_TEXT));
+  }
+
+  /**
+   * Adds a text entry to dropbox with the current timestamp using UTF-8 encoding.
+   *
+   * <p>If adding multiple entries, it is required to ensure they have unique timestamps by bumping
+   * the wall-clock time, using {@link android.os.SystemClock} or similar.
+   */
+  @Implementation
+  protected void addText(String tag, String data) {
+    // NOTE: Need to use ShadowSystemClock for current time, because this doesn't run in the
+    // ClassLoader that customizes System.currentTimeMillis.
+    addData(tag, ShadowSystem.currentTimeMillis(), data.getBytes(UTF_8));
+  }
+
+  /**
+   * Clears all entries.
+   */
+  public void reset() {
+    entries.clear();
+  }
+
+  @Implementation
+  protected DropBoxManager.Entry getNextEntry(String tag, long millis) {
+    for (DropBoxManager.Entry entry : entries.tailMap(millis).values()) {
+      if ((tag != null && !entry.getTag().equals(tag)) || entry.getTimeMillis() <= millis) {
+        continue;
+      }
+      return entry;
+    }
+    return null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDynamicsProcessing.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDynamicsProcessing.java
new file mode 100644
index 0000000..ed2bfe8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDynamicsProcessing.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import android.media.audiofx.AudioEffect;
+import android.media.audiofx.DynamicsProcessing;
+import com.google.common.collect.ImmutableMap;
+import java.nio.ByteBuffer;
+import java.util.Optional;
+import org.robolectric.annotation.Implements;
+
+/** Implements {@link DynamicsProcessing} by relying on {@link ShadowAudioEffect}. */
+@Implements(value = DynamicsProcessing.class, minSdk = 28)
+public class ShadowDynamicsProcessing extends ShadowAudioEffect {
+
+  // Default parameters needed in the DynamicsProcessing ctor.
+  private static final ImmutableMap<ByteBuffer, ByteBuffer> DEFAULT_PARAMETERS =
+      ImmutableMap.of(
+          intToByteBuffer(0x10), // DynamicsProcessing.PARAM_GET_CHANNEL_COUNT
+          intToByteBuffer(2) // Default channel count = STEREO
+          );
+
+  @Override
+  protected Optional<ByteBuffer> getDefaultParameter(ByteBuffer parameter) {
+    return Optional.ofNullable(DEFAULT_PARAMETERS.get(parameter));
+  }
+
+  private static ByteBuffer intToByteBuffer(int value) {
+    return ShadowAudioEffect.createReadOnlyByteBuffer(AudioEffect.intToByteArray(value));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java
new file mode 100644
index 0000000..475607a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java
@@ -0,0 +1,133 @@
+package org.robolectric.shadows;
+
+import android.opengl.EGL14;
+import android.opengl.EGLConfig;
+import android.opengl.EGLContext;
+import android.opengl.EGLDisplay;
+import android.opengl.EGLSurface;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow for EGL14. Currently doesn't handle real graphics work, but avoids crashing when run. */
+@Implements(value = EGL14.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowEGL14 {
+  private static final long UNUSED_HANDLE_ID = 43L;
+
+  @Implementation
+  protected static EGLDisplay eglGetDisplay(int displayId) {
+    return createEglDisplay();
+  }
+
+  @Implementation
+  protected static boolean eglInitialize(
+      EGLDisplay dpy, int[] major, int majorOffset, int[] minor, int minorOffset) {
+    return true;
+  }
+
+  @Implementation
+  protected static boolean eglChooseConfig(
+      EGLDisplay dpy,
+      int[] attribList,
+      int attribListOffset,
+      EGLConfig[] configs,
+      int configsOffset,
+      int configSize,
+      int[] numConfig,
+      int numConfigOffset) {
+    configs[configsOffset] = createEglConfig();
+    numConfig[numConfigOffset] = 1;
+    return true;
+  }
+
+  @Implementation
+  protected static EGLContext eglCreateContext(
+      EGLDisplay dpy, EGLConfig config, EGLContext shareContext, int[] attribList, int offset) {
+    int majorVersion = getAttribValue(attribList, EGL14.EGL_CONTEXT_CLIENT_VERSION);
+    switch (majorVersion) {
+      case 2:
+      case 3:
+        return createEglContext(majorVersion);
+      default:
+        break;
+    }
+    return EGL14.EGL_NO_CONTEXT;
+  }
+
+  @Implementation
+  protected static boolean eglQueryContext(
+      EGLDisplay dpy, EGLContext ctx, int attribute, int[] value, int offset) {
+    value[offset] = 0;
+    switch (attribute) {
+      case EGL14.EGL_CONTEXT_CLIENT_VERSION:
+        // We stored the version in the handle field when we created the context.
+        value[offset] = (int) ctx.getNativeHandle();
+        break;
+      default:
+        // Use default output set above switch.
+    }
+    return true;
+  }
+
+  @Implementation
+  protected static EGLSurface eglCreatePbufferSurface(
+      EGLDisplay dpy, EGLConfig config, int[] attribList, int offset) {
+    return createEglSurface();
+  }
+
+  @Implementation
+  protected static EGLSurface eglCreateWindowSurface(
+      EGLDisplay dpy, EGLConfig config, Object win, int[] attribList, int offset) {
+    return createEglSurface();
+  }
+
+  @Implementation
+  protected static boolean eglMakeCurrent(
+      EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) {
+    return true;
+  }
+
+  @Implementation
+  protected static boolean eglSwapBuffers(EGLDisplay dpy, EGLSurface surface) {
+    return true;
+  }
+
+  @Implementation
+  protected static int eglGetError() {
+    return EGL14.EGL_SUCCESS;
+  }
+
+  private static EGLDisplay createEglDisplay() {
+    return ReflectionHelpers.callConstructor(
+        EGLDisplay.class, ClassParameter.from(long.class, UNUSED_HANDLE_ID));
+  }
+
+  private static EGLConfig createEglConfig() {
+    return ReflectionHelpers.callConstructor(
+        EGLConfig.class, ClassParameter.from(long.class, UNUSED_HANDLE_ID));
+  }
+
+  private static EGLContext createEglContext(int version) {
+    // As a hack store the version number in the unused handle ID so we can retrieve it later
+    // if the caller queries a context.
+    return ReflectionHelpers.callConstructor(
+        EGLContext.class, ClassParameter.from(long.class, version));
+  }
+
+  private static EGLSurface createEglSurface() {
+    return ReflectionHelpers.callConstructor(
+        EGLSurface.class, ClassParameter.from(long.class, UNUSED_HANDLE_ID));
+  }
+
+  private static int getAttribValue(int[] attribList, int attribute) {
+    int attribValue = 0;
+    for (int i = 0; i < attribList.length; i += 2) {
+      if (attribList[i] == attribute) {
+        attribValue = attribList[i + 1];
+      }
+    }
+    return attribValue;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEdgeEffect.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEdgeEffect.java
new file mode 100644
index 0000000..e54f93d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEdgeEffect.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+
+import android.view.MotionEvent;
+import android.widget.EdgeEffect;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link android.widget.EdgeEffect} */
+@Implements(EdgeEffect.class)
+public class ShadowEdgeEffect {
+
+  /**
+   * Disable edge effects for Android S and above. The problem with edge effects in S+ is that
+   * ScrollView will intercept/swallow all touch events while edge effects are still running (in
+   * {@link android.widget.ScrollView#onInterceptTouchEvent(MotionEvent)}. {@link EdgeEffect}
+   * completion depends on a free-running clock and draw traversals being continuously performed. So
+   * for Robolectric to ensure that edge effects are complete, it has to bump the uptime and then
+   * re-run draw traversals any time an edge effect starts.
+   *
+   * <p>Because edge effects are not critical for unit testing, it is simpler to disable them.
+   */
+  @Implementation(minSdk = S)
+  protected int getCurrentEdgeEffectBehavior() {
+    return -1; // EdgeEffect.TYPE_NONE (disables edge effects)
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEnvironment.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEnvironment.java
new file mode 100644
index 0000000..c684b69
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEnvironment.java
@@ -0,0 +1,304 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Environment;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(Environment.class)
+@SuppressWarnings("NewApi")
+public class ShadowEnvironment {
+  private static String externalStorageState = Environment.MEDIA_REMOVED;
+  private static final Map<File, Boolean> STORAGE_EMULATED = new HashMap<>();
+  private static final Map<File, Boolean> STORAGE_REMOVABLE = new HashMap<>();
+  private static boolean sIsExternalStorageEmulated;
+  private static boolean isExternalStorageLegacy;
+  private static Path tmpExternalFilesDirBase;
+  private static final List<File> externalDirs = new ArrayList<>();
+  private static Map<Path, String> storageState = new HashMap<>();
+
+  static Path EXTERNAL_CACHE_DIR;
+  static Path EXTERNAL_FILES_DIR;
+
+  @Implementation
+  protected static String getExternalStorageState() {
+    return externalStorageState;
+  }
+
+  /**
+   * Sets the return value of {@link #getExternalStorageState()}.
+   *
+   * @param externalStorageState Value to return from {@link #getExternalStorageState()}.
+   */
+  public static void setExternalStorageState(String externalStorageState) {
+    ShadowEnvironment.externalStorageState = externalStorageState;
+  }
+
+  /**
+   * Sets the return value of {@link #isExternalStorageEmulated()}.
+   *
+   * @param emulated Value to return from {@link #isExternalStorageEmulated()}.
+   */
+  public static void setIsExternalStorageEmulated(boolean emulated) {
+    ShadowEnvironment.sIsExternalStorageEmulated = emulated;
+  }
+
+  /**
+   * Sets the return value of {@link #isExternalStorageLegacy()} ()}.
+   *
+   * @param legacy Value to return from {@link #isExternalStorageLegacy()}.
+   */
+  public static void setIsExternalStorageLegacy(boolean legacy) {
+    ShadowEnvironment.isExternalStorageLegacy = legacy;
+  }
+
+  /**
+   * Sets the return value of {@link #getExternalStorageDirectory()}.  Note that
+   * the default value provides a directory that is usable in the test environment.
+   * If the test app uses this method to override that default directory, please
+   * clean up any files written to that directory, as the Robolectric environment
+   * will not purge that directory when the test ends.
+   *
+   * @param directory Path to return from {@link #getExternalStorageDirectory()}.
+   */
+  public static void setExternalStorageDirectory(Path directory) {
+    EXTERNAL_CACHE_DIR = directory;
+  }
+
+  @Implementation
+  protected static File getExternalStorageDirectory() {
+    if (EXTERNAL_CACHE_DIR == null) {
+
+      EXTERNAL_CACHE_DIR =
+          RuntimeEnvironment.getTempDirectory().createIfNotExists("external-cache");
+    }
+    return EXTERNAL_CACHE_DIR.toFile();
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected static File[] buildExternalStorageAppCacheDirs(String packageName) {
+    Path externalStorageDirectoryPath = getExternalStorageDirectory().toPath();
+    // Add cache directory in path.
+    String cacheDirectory = packageName + "-cache";
+    Path path = externalStorageDirectoryPath.resolve(cacheDirectory);
+    try {
+      Files.createDirectory(path);
+    } catch (FileAlreadyExistsException e) {
+      // That's ok
+      return new File[] {path.toFile()};
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return new File[] {path.toFile()};
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR2)
+  protected static File getExternalStorageAppCacheDirectory(String packageName) {
+    return buildExternalStorageAppCacheDirs(packageName)[0];
+  }
+
+  @Implementation
+  protected static File getExternalStoragePublicDirectory(String type) {
+    if (externalStorageState.equals(Environment.MEDIA_UNKNOWN)) {
+      return null;
+    }
+    if (EXTERNAL_FILES_DIR == null) {
+      EXTERNAL_FILES_DIR =
+          RuntimeEnvironment.getTempDirectory().createIfNotExists("external-files");
+    }
+    if (type == null) return EXTERNAL_FILES_DIR.toFile();
+    Path path = EXTERNAL_FILES_DIR.resolve(type);
+    try {
+      Files.createDirectories(path);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return path.toFile();
+  }
+
+  @Resetter
+  public static void reset() {
+
+    EXTERNAL_CACHE_DIR = null;
+    EXTERNAL_FILES_DIR = null;
+
+    STORAGE_EMULATED.clear();
+    STORAGE_REMOVABLE.clear();
+
+    storageState = new HashMap<>();
+    externalDirs.clear();
+    externalStorageState = Environment.MEDIA_REMOVED;
+
+    sIsExternalStorageEmulated = false;
+    isExternalStorageLegacy = false;
+  }
+
+  @Implementation
+  protected static boolean isExternalStorageRemovable() {
+    final Boolean exists = STORAGE_REMOVABLE.get(getExternalStorageDirectory());
+    return exists != null ? exists : false;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected static String getStorageState(File directory) {
+    Path directoryPath = directory.toPath();
+    for (Map.Entry<Path, String> entry : storageState.entrySet()) {
+      if (directoryPath.startsWith(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String getExternalStorageState(File directory) {
+    Path directoryPath = directory.toPath();
+    for (Map.Entry<Path, String> entry : storageState.entrySet()) {
+      if (directoryPath.startsWith(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean isExternalStorageRemovable(File path) {
+    final Boolean exists = STORAGE_REMOVABLE.get(path);
+    return exists != null ? exists : false;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean isExternalStorageEmulated(File path) {
+    final Boolean emulated = STORAGE_EMULATED.get(path);
+    return emulated != null ? emulated : false;
+  }
+
+  @Implementation
+  protected static boolean isExternalStorageEmulated() {
+    return sIsExternalStorageEmulated;
+  }
+
+  @Implementation(minSdk = Q)
+  protected static boolean isExternalStorageLegacy(File path) {
+    return isExternalStorageLegacy;
+  }
+
+  @Implementation(minSdk = Q)
+  protected static boolean isExternalStorageLegacy() {
+    return isExternalStorageLegacy;
+  }
+
+  /**
+   * Sets the "isRemovable" flag of a particular file.
+   *
+   * @param file Target file.
+   * @param isRemovable True if the filesystem is removable.
+   */
+  public static void setExternalStorageRemovable(File file, boolean isRemovable) {
+    STORAGE_REMOVABLE.put(file, isRemovable);
+  }
+
+  /**
+   * Sets the "isEmulated" flag of a particular file.
+   *
+   * @param file Target file.
+   * @param isEmulated True if the filesystem is emulated.
+   */
+  public static void setExternalStorageEmulated(File file, boolean isEmulated) {
+    STORAGE_EMULATED.put(file, isEmulated);
+  }
+
+  /**
+   * Adds a directory to list returned by {@link ShadowUserEnvironment#getExternalDirs()}.
+   *
+   * @param path the external dir to add
+   */
+  public static File addExternalDir(String path) {
+    Path externalFileDir;
+    if (path == null) {
+      externalFileDir = null;
+    } else {
+      try {
+        if (tmpExternalFilesDirBase == null) {
+          tmpExternalFilesDirBase =
+              RuntimeEnvironment.getTempDirectory().create("external-files-base");
+        }
+        externalFileDir = tmpExternalFilesDirBase.resolve(path);
+        Files.createDirectories(externalFileDir);
+        externalDirs.add(externalFileDir.toFile());
+      } catch (IOException e) {
+        throw new RuntimeException("Could not create external files dir", e);
+      }
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= JELLY_BEAN_MR1
+        && RuntimeEnvironment.getApiLevel() < KITKAT) {
+      if (externalDirs.size() == 1 && externalFileDir != null) {
+        Environment.UserEnvironment userEnvironment =
+            ReflectionHelpers.getStaticField(Environment.class, "sCurrentUser");
+        reflector(_UserEnvironment_.class, userEnvironment)
+            .setExternalStorageAndroidData(externalFileDir.toFile());
+      }
+    } else if (RuntimeEnvironment.getApiLevel() >= KITKAT && RuntimeEnvironment.getApiLevel() < M) {
+      Environment.UserEnvironment userEnvironment =
+          ReflectionHelpers.getStaticField(Environment.class, "sCurrentUser");
+      reflector(_UserEnvironment_.class, userEnvironment)
+          .setExternalDirsForApp(externalDirs.toArray(new File[0]));
+    }
+
+    if (externalFileDir == null) {
+      return null;
+    }
+    return externalFileDir.toFile();
+  }
+
+  /**
+   * Sets the {@link #getExternalStorageState(File)} for given directory.
+   *
+   * @param externalStorageState Value to return from {@link #getExternalStorageState(File)}.
+   */
+  public static void setExternalStorageState(File directory, String state) {
+    storageState.put(directory.toPath(), state);
+  }
+
+  @Implements(className = "android.os.Environment$UserEnvironment", isInAndroidSdk = false,
+      minSdk = JELLY_BEAN_MR1)
+  public static class ShadowUserEnvironment {
+
+    @Implementation(minSdk = M)
+    protected File[] getExternalDirs() {
+      return externalDirs.toArray(new File[externalDirs.size()]);
+    }
+  }
+
+  /** Accessor interface for Environment.UserEnvironment's internals. */
+  @ForType(className = "android.os.Environment$UserEnvironment")
+  interface _UserEnvironment_ {
+    @Accessor("mExternalDirsForApp")
+    void setExternalDirsForApp(File[] files);
+
+    @Accessor("mExternalStorageAndroidData")
+    void setExternalStorageAndroidData(File file);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEuiccManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEuiccManager.java
new file mode 100644
index 0000000..ae0b74f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEuiccManager.java
@@ -0,0 +1,49 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.telephony.euicc.EuiccManager;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = EuiccManager.class, minSdk = P)
+public class ShadowEuiccManager {
+
+  private final Map<Integer, EuiccManager> cardIdsToEuiccManagers = new HashMap<>();
+  private boolean enabled;
+  private String eid;
+
+  @Implementation(minSdk = Q)
+  protected EuiccManager createForCardId(int cardId) {
+    return cardIdsToEuiccManagers.get(cardId);
+  }
+
+  /** Sets the value returned by {@link EuiccManager#createForCardId(int)}. */
+  public void setEuiccManagerForCardId(int cardId, EuiccManager euiccManager) {
+    cardIdsToEuiccManagers.put(cardId, euiccManager);
+  }
+
+  /** Returns {@code false}, or the value specified by calling {@link #setIsEnabled}. */
+  @Implementation
+  protected boolean isEnabled() {
+    return enabled;
+  }
+
+  /** Set the value to be returned by {@link EuiccManager#isEnabled}. */
+  public void setIsEnabled(boolean isEnabled) {
+    enabled = isEnabled;
+  }
+
+  @Implementation
+  protected String getEid() {
+    return eid;
+  }
+
+  /** Set the value to be returned by {@link EuiccManager#getEid}. */
+  public void setEid(String eid) {
+    this.eid = eid;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEventLog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEventLog.java
new file mode 100644
index 0000000..3cab09a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEventLog.java
@@ -0,0 +1,172 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.util.EventLog;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(EventLog.class)
+public class ShadowEventLog {
+
+  /**
+   * Constant written to the log if the parameter is null.
+   *
+   * <p>This matches how the real android code handles nulls.
+   */
+  static final String NULL_PLACE_HOLDER = "NULL";
+
+  @Implements(EventLog.Event.class)
+  public static class ShadowEvent {
+
+    private Object data;
+    private int tag;
+    private int processId;
+    private int threadId;
+    private long timeNanos;
+
+    @Implementation
+    protected Object getData() {
+      return data;
+    }
+
+    @Implementation
+    protected int getTag() {
+      return tag;
+    }
+
+    @Implementation
+    protected int getProcessId() {
+      return processId;
+    }
+
+    @Implementation
+    protected int getThreadId() {
+      return threadId;
+    }
+
+    @Implementation
+    protected long getTimeNanos() {
+      return timeNanos;
+    }
+  }
+
+  private static final List<EventLog.Event> events = new ArrayList<>();
+
+  /** Class to build {@link EventLog.Event} */
+  public static class EventBuilder {
+
+    private final Object data;
+    private final int tag;
+    private int processId = ShadowProcess.myPid();
+    private int threadId = ShadowProcess.myTid();
+    private long timeNanos = System.nanoTime();
+
+    public EventBuilder(int tag, Object data) {
+      this.tag = tag;
+      this.data = data;
+    }
+
+    public EventBuilder setProcessId(int processId) {
+      this.processId = processId;
+      return this;
+    }
+
+    public EventBuilder setThreadId(int threadId) {
+      this.threadId = threadId;
+      return this;
+    }
+
+    public EventBuilder setTimeNanos(long timeNanos) {
+      this.timeNanos = timeNanos;
+      return this;
+    }
+
+    public EventLog.Event build() {
+      EventLog.Event event;
+      if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N) {
+        // Prefer a real factory method over reflection for construction.
+        event = EventLog.Event.fromBytes(new byte[0]);
+      } else {
+        event = Shadow.newInstanceOf(EventLog.Event.class);
+      }
+      ShadowEvent shadowEvent = Shadow.extract(event);
+      shadowEvent.data = data;
+      shadowEvent.tag = tag;
+      shadowEvent.processId = processId;
+      shadowEvent.threadId = threadId;
+      shadowEvent.timeNanos = timeNanos;
+      return event;
+    }
+  }
+
+  /** Add event to {@link EventLog}. */
+  public static void addEvent(EventLog.Event event) {
+    events.add(event);
+  }
+
+  @Resetter
+  public static void clearAll() {
+    events.clear();
+  }
+
+  /** Writes an event log message, returning an approximation of the bytes written. */
+  @Implementation
+  protected static int writeEvent(int tag, String str) {
+    if (str == null) {
+      str = NULL_PLACE_HOLDER;
+    }
+    addEvent(new EventBuilder(tag, str).build());
+    return Integer.BYTES + str.length();
+  }
+
+  /** Writes an event log message, returning an approximation of the bytes written. */
+  @Implementation
+  protected static int writeEvent(int tag, Object... list) {
+    if (list == null) {
+      // This matches how the real android code handles nulls
+      return writeEvent(tag, (String) null);
+    }
+    addEvent(new EventBuilder(tag, list).build());
+    return Integer.BYTES + list.length * Integer.BYTES;
+  }
+
+  /** Writes an event log message, returning an approximation of the bytes written. */
+  @Implementation
+  protected static int writeEvent(int tag, int value) {
+    addEvent(new EventBuilder(tag, value).build());
+    return Integer.BYTES + Integer.BYTES;
+  }
+
+  /** Writes an event log message, returning an approximation of the bytes written. */
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected static int writeEvent(int tag, float value) {
+    addEvent(new EventBuilder(tag, value).build());
+    return Integer.BYTES + Float.BYTES;
+  }
+
+  /** Writes an event log message, returning an approximation of the bytes written. */
+  @Implementation
+  protected static int writeEvent(int tag, long value) {
+    addEvent(new EventBuilder(tag, value).build());
+    return Integer.BYTES + Long.BYTES;
+  }
+
+  @Implementation
+  protected static void readEvents(int[] tags, Collection<EventLog.Event> output) {
+    for (EventLog.Event event : events) {
+      for (int tag : tags) {
+        if (tag == event.getTag()) {
+          output.add(event);
+          break;
+        }
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFileObserver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFileObserver.java
new file mode 100644
index 0000000..c4ddd8b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFileObserver.java
@@ -0,0 +1,274 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.os.FileObserver;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/**
+ * A shadow implementation of FileObserver that uses java.nio.file.WatchService.
+ *
+ * <p>Currently only supports MODIFY, DELETE and CREATE (CREATE will encompass also events that
+ * would normally register as MOVED_FROM, and DELETE will encompass also events that would normally
+ * register as MOVED_TO). Other event types will be silently ignored.
+ */
+@Implements(FileObserver.class)
+public class ShadowFileObserver {
+  @RealObject private FileObserver realFileObserver;
+
+  private final WatchService watchService;
+  private final Map<String, WatchedDirectory> watchedDirectories = new HashMap<>();
+  private final Map<WatchKey, Path> watchedKeys = new HashMap<>();
+
+  private WatchEvent.Kind<?>[] watchEvents = new WatchEvent.Kind<?>[0];
+
+  @GuardedBy("this")
+  private WatcherRunnable watcherRunnable = null;
+
+  public ShadowFileObserver() {
+    try {
+      this.watchService = FileSystems.getDefault().newWatchService();
+    } catch (IOException ioException) {
+      throw new RuntimeException(ioException);
+    }
+  }
+
+  @Override
+  @Implementation
+  protected void finalize() throws Throwable {
+    stopWatching();
+  }
+
+  private void setMask(int mask) {
+    Set<WatchEvent.Kind<Path>> watchEventsSet = new HashSet<>();
+    if ((mask & FileObserver.MODIFY) != 0) {
+      watchEventsSet.add(StandardWatchEventKinds.ENTRY_MODIFY);
+    }
+    if ((mask & FileObserver.DELETE) != 0) {
+      watchEventsSet.add(StandardWatchEventKinds.ENTRY_DELETE);
+    }
+    if ((mask & FileObserver.CREATE) != 0) {
+      watchEventsSet.add(StandardWatchEventKinds.ENTRY_CREATE);
+    }
+    watchEvents = watchEventsSet.toArray(new WatchEvent.Kind<?>[0]);
+  }
+
+  private void addFile(File file) {
+    List<File> list = new ArrayList<>(1);
+    list.add(file);
+    addFiles(list);
+  }
+
+  private void addFiles(List<File> files) {
+    // Break all watched files into their directories.
+    for (File file : files) {
+      Path path = file.toPath();
+      if (Files.isDirectory(path)) {
+        WatchedDirectory watchedDirectory = new WatchedDirectory(path);
+        watchedDirectories.put(path.toString(), watchedDirectory);
+      } else {
+        Path directory = path.getParent();
+        String filename = path.getFileName().toString();
+        WatchedDirectory watchedDirectory = watchedDirectories.get(directory.toString());
+        if (watchedDirectory == null) {
+          watchedDirectory = new WatchedDirectory(directory);
+        }
+        watchedDirectory.addFile(filename);
+        watchedDirectories.put(directory.toString(), watchedDirectory);
+      }
+    }
+  }
+
+  @Implementation
+  protected void __constructor__(String path, int mask) {
+    setMask(mask);
+    addFile(new File(path));
+  }
+
+  @Implementation(minSdk = Q)
+  protected void __constructor__(List<File> files, int mask) {
+    setMask(mask);
+    addFiles(files);
+  }
+
+  /**
+   * Represents a directory to watch, including specific files in that directory (or the entire
+   * directory contents if no file is specified).
+   */
+  private class WatchedDirectory {
+    @GuardedBy("this")
+    private WatchKey watchKey = null;
+
+    private final Path dirPath;
+    private final Set<String> watchedFiles = new HashSet<>();
+
+    WatchedDirectory(Path dirPath) {
+      this.dirPath = dirPath;
+    }
+
+    void addFile(String filename) {
+      watchedFiles.add(filename);
+    }
+
+    synchronized void register() throws IOException {
+      unregister();
+      this.watchKey = dirPath.register(watchService, watchEvents);
+      watchedKeys.put(watchKey, dirPath);
+    }
+
+    synchronized void unregister() {
+      if (this.watchKey != null) {
+        watchedKeys.remove(watchKey);
+        watchKey.cancel();
+        this.watchKey = null;
+      }
+    }
+  }
+
+  @Implementation
+  protected synchronized void startWatching() throws IOException {
+    // If we're already watching, startWatching is a no-op.
+    if (watcherRunnable != null) {
+      return;
+    }
+
+    // If we don't have any supported events to watch for, don't do anything.
+    if (watchEvents.length == 0) {
+      return;
+    }
+
+    for (WatchedDirectory watchedDirectory : watchedDirectories.values()) {
+      watchedDirectory.register();
+    }
+
+    watcherRunnable =
+        new WatcherRunnable(realFileObserver, watchedDirectories, watchedKeys, watchService);
+    Thread thread = new Thread(watcherRunnable, "ShadowFileObserver");
+    thread.start();
+  }
+
+  @Implementation
+  protected void stopWatching() {
+    for (WatchedDirectory watchedDirectory : watchedDirectories.values()) {
+      watchedDirectory.unregister();
+    }
+
+    synchronized (this) {
+      if (watcherRunnable != null) {
+        watcherRunnable.stop();
+        watcherRunnable = null;
+      }
+    }
+  }
+
+  private static int fileObserverEventFromWatcherEvent(WatchEvent.Kind<?> kind) {
+    if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
+      return FileObserver.CREATE;
+    } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
+      return FileObserver.DELETE;
+    } else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
+      return FileObserver.MODIFY;
+    }
+    return 0;
+  }
+
+  /** Runnable implementation that processes all events for keys queued to the watcher. */
+  private static class WatcherRunnable implements Runnable {
+    @GuardedBy("this")
+    private boolean shouldStop = false;
+
+    private final FileObserver realFileObserver;
+    private final Map<String, WatchedDirectory> watchedDirectories;
+    private final Map<WatchKey, Path> watchedKeys;
+    private final WatchService watchService;
+
+    public WatcherRunnable(
+        FileObserver realFileObserver,
+        Map<String, WatchedDirectory> watchedDirectories,
+        Map<WatchKey, Path> watchedKeys,
+        WatchService watchService) {
+      this.realFileObserver = realFileObserver;
+      this.watchedDirectories = watchedDirectories;
+      this.watchedKeys = watchedKeys;
+      this.watchService = watchService;
+    }
+
+    public synchronized void stop() {
+      this.shouldStop = true;
+    }
+
+    public synchronized boolean shouldContinue() {
+      return !shouldStop;
+    }
+
+    @SuppressWarnings("unchecked")
+    private WatchEvent<Path> castToPathWatchEvent(WatchEvent<?> untypedWatchEvent) {
+      return (WatchEvent<Path>) untypedWatchEvent;
+    }
+
+    @Override
+    public void run() {
+      while (shouldContinue()) {
+        // wait for key to be signalled
+        WatchKey key;
+        try {
+          key = watchService.take();
+        } catch (InterruptedException x) {
+          return;
+        }
+
+        Path dir = watchedKeys.get(key);
+        if (dir != null) {
+          WatchedDirectory watchedDirectory = watchedDirectories.get(dir.toString());
+          List<WatchEvent<?>> events = key.pollEvents();
+
+          for (WatchEvent<?> event : events) {
+            WatchEvent.Kind<?> kind = event.kind();
+
+            // Ignore OVERFLOW events
+            if (kind == StandardWatchEventKinds.OVERFLOW) {
+              continue;
+            }
+
+            WatchEvent<Path> ev = castToPathWatchEvent(event);
+            Path fileName = ev.context().getFileName();
+
+            if (watchedDirectory.watchedFiles.isEmpty()) {
+              realFileObserver.onEvent(
+                  fileObserverEventFromWatcherEvent(kind), fileName.toString());
+            } else {
+              for (String watchedFile : watchedDirectory.watchedFiles) {
+                if (fileName.toString().equals(watchedFile)) {
+                  realFileObserver.onEvent(
+                      fileObserverEventFromWatcherEvent(kind), fileName.toString());
+                }
+              }
+            }
+          }
+        }
+        boolean valid = key.reset();
+        if (!valid) {
+          return;
+        }
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFileUtils.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFileUtils.java
new file mode 100644
index 0000000..fe4c7ae
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFileUtils.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.os.CancellationSignal;
+import android.os.FileUtils;
+import android.os.FileUtils.ProgressListener;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = FileUtils.class, isInAndroidSdk = false, minSdk = P)
+public class ShadowFileUtils {
+
+  @Implementation(minSdk = P, maxSdk = P)
+  protected static long copy(
+      FileDescriptor in,
+      FileDescriptor out,
+      ProgressListener listener,
+      CancellationSignal signal,
+      long count)
+      throws IOException {
+    // never do the native copy optimization block
+    return ReflectionHelpers.callStaticMethod(FileUtils.class,
+        "copyInternalUserspace",
+        from(FileDescriptor.class, in),
+        from(FileDescriptor.class, out),
+        from(ProgressListener.class, listener),
+        from(CancellationSignal.class, signal),
+        from(long.class, count));
+  }
+
+  @Implementation(minSdk = android.os.Build.VERSION_CODES.Q)
+  protected static long copy(
+      FileDescriptor in,
+      FileDescriptor out,
+      long count,
+      CancellationSignal signal,
+      Executor executor,
+      ProgressListener listener)
+      throws IOException {
+    // never do the native copy optimization block
+    return ReflectionHelpers.callStaticMethod(
+        FileUtils.class,
+        "copyInternalUserspace",
+        from(FileDescriptor.class, in),
+        from(FileDescriptor.class, out),
+        from(long.class, count),
+        from(CancellationSignal.class, signal),
+        from(Executor.class, executor),
+        from(ProgressListener.class, listener));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFilter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFilter.java
new file mode 100644
index 0000000..67fcbd7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFilter.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import android.widget.Filter;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(Filter.class)
+public class ShadowFilter {
+  @RealObject private Filter realObject;
+
+  @Implementation
+  protected void filter(CharSequence constraint, Filter.FilterListener listener) {
+    try {
+      Class<?> forName = Class.forName("android.widget.Filter$FilterResults");
+      Object filtering;
+      try {
+        filtering = ReflectionHelpers.callInstanceMethod(realObject, "performFiltering",
+            ClassParameter.from(CharSequence.class, constraint));
+      } catch (Exception e) {
+        e.printStackTrace();
+        filtering = ReflectionHelpers.newInstance(forName);
+      }
+
+      ReflectionHelpers.callInstanceMethod(realObject, "publishResults",
+          ClassParameter.from(CharSequence.class, constraint),
+          ClassParameter.from(forName, filtering));
+
+      if (listener != null) {
+        int count = filtering == null ? -1 : (int) ReflectionHelpers.getField(filtering, "count");
+        listener.onFilterComplete(count);
+      }
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException("Cannot load android.widget.Filter$FilterResults");
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFingerprintManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFingerprintManager.java
new file mode 100644
index 0000000..42f7711
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFingerprintManager.java
@@ -0,0 +1,186 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.hardware.fingerprint.Fingerprint;
+import android.hardware.fingerprint.FingerprintManager;
+import android.hardware.fingerprint.FingerprintManager.AuthenticationCallback;
+import android.hardware.fingerprint.FingerprintManager.AuthenticationResult;
+import android.hardware.fingerprint.FingerprintManager.CryptoObject;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.util.Log;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.IntStream;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Provides testing APIs for {@link FingerprintManager} */
+@SuppressWarnings("NewApi")
+@Implements(FingerprintManager.class)
+public class ShadowFingerprintManager {
+
+  private static final String TAG = "ShadowFingerprintManager";
+
+  private boolean isHardwareDetected;
+  protected CryptoObject pendingCryptoObject;
+  private AuthenticationCallback pendingCallback;
+  private List<Fingerprint> fingerprints = Collections.emptyList();
+
+  /**
+   * Simulates a successful fingerprint authentication. An authentication request must have been
+   * issued with {@link FingerprintManager#authenticate(CryptoObject, CancellationSignal, int, AuthenticationCallback, Handler)} and not cancelled.
+   */
+  public void authenticationSucceeds() {
+    if (pendingCallback == null) {
+      throw new IllegalStateException("No active fingerprint authentication request.");
+    }
+
+    pendingCallback.onAuthenticationSucceeded(createAuthenticationResult());
+  }
+
+  protected AuthenticationResult createAuthenticationResult() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      return new AuthenticationResult(pendingCryptoObject, null, 0, false);
+    } else if (RuntimeEnvironment.getApiLevel() >= N_MR1) {
+      return ReflectionHelpers.callConstructor(
+          AuthenticationResult.class,
+          ClassParameter.from(CryptoObject.class, pendingCryptoObject),
+          ClassParameter.from(Fingerprint.class, null),
+          ClassParameter.from(int.class, 0));
+    } else {
+      return ReflectionHelpers.callConstructor(
+          AuthenticationResult.class,
+          ClassParameter.from(CryptoObject.class, pendingCryptoObject),
+          ClassParameter.from(Fingerprint.class, null));
+    }
+  }
+
+  /**
+   * Simulates a failed fingerprint authentication. An authentication request must have been
+   * issued with {@link FingerprintManager#authenticate(CryptoObject, CancellationSignal, int, AuthenticationCallback, Handler)} and not cancelled.
+   */
+  public void authenticationFails() {
+    if (pendingCallback == null) {
+      throw new IllegalStateException("No active fingerprint authentication request.");
+    }
+
+    pendingCallback.onAuthenticationFailed();
+  }
+
+  /**
+   * Success or failure can be simulated with a subsequent call to {@link #authenticationSucceeds()}
+   * or {@link #authenticationFails()}.
+   */
+  @Implementation(minSdk = M)
+  protected void authenticate(
+      CryptoObject crypto,
+      CancellationSignal cancel,
+      int flags,
+      AuthenticationCallback callback,
+      Handler handler) {
+    if (callback == null) {
+      throw new IllegalArgumentException("Must supply an authentication callback");
+    }
+
+    if (cancel != null) {
+      if (cancel.isCanceled()) {
+        Log.w(TAG, "authentication already canceled");
+        return;
+      } else {
+        cancel.setOnCancelListener(() -> {
+          this.pendingCallback = null;
+          this.pendingCryptoObject = null;
+        });
+      }
+    }
+
+    this.pendingCryptoObject = crypto;
+    this.pendingCallback = callback;
+  }
+
+  /**
+   * Sets the return value of {@link FingerprintManager#hasEnrolledFingerprints()}.
+   *
+   * @deprecated use {@link #setDefaultFingerprints} instead.
+   */
+  @Deprecated
+  public void setHasEnrolledFingerprints(boolean hasEnrolledFingerprints) {
+    setDefaultFingerprints(hasEnrolledFingerprints ? 1 : 0);
+  }
+
+  /**
+   * Returns {@code false} by default, or the value specified via
+   * {@link #setHasEnrolledFingerprints(boolean)}.
+   */
+  @Implementation(minSdk = M)
+  protected boolean hasEnrolledFingerprints() {
+    return !fingerprints.isEmpty();
+  }
+
+  /**
+   * @return lists of current fingerprint items, the list be set via {@link #setDefaultFingerprints}
+   */
+  @HiddenApi
+  @Implementation(minSdk = M)
+  protected List<Fingerprint> getEnrolledFingerprints() {
+    return new ArrayList<>(fingerprints);
+  }
+
+  /**
+   * @return Returns the finger ID for the given index.
+   */
+  public int getFingerprintId(int index) {
+    return ReflectionHelpers.callInstanceMethod(
+        getEnrolledFingerprints().get(index),
+        RuntimeEnvironment.getApiLevel() > P ? "getBiometricId" : "getFingerId");
+  }
+
+  /**
+   * Enrolls the given number of fingerprints, which will be returned in {@link
+   * #getEnrolledFingerprints}.
+   *
+   * @param num the quantity of fingerprint item.
+   */
+  public void setDefaultFingerprints(int num) {
+    setEnrolledFingerprints(
+        IntStream.range(0, num)
+            .mapToObj(
+                i ->
+                    new Fingerprint(
+                        /* name= */ "Fingerprint " + i,
+                        /* groupId= */ 0,
+                        /* fingerId= */ i,
+                        /* deviceId= */ 0))
+            .toArray(Fingerprint[]::new));
+  }
+
+  private void setEnrolledFingerprints(Fingerprint... fingerprints) {
+    this.fingerprints = Arrays.asList(fingerprints);
+  }
+
+  /**
+   * Sets the return value of {@link FingerprintManager#isHardwareDetected()}.
+   */
+  public void setIsHardwareDetected(boolean isHardwareDetected) {
+    this.isHardwareDetected = isHardwareDetected;
+  }
+
+  /**
+   * @return false by default, or the value specified via {@link #setIsHardwareDetected(boolean)}
+   */
+  @Implementation(minSdk = M)
+  protected boolean isHardwareDetected() {
+    return this.isHardwareDetected;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFloatMath.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFloatMath.java
new file mode 100644
index 0000000..c2befda
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFloatMath.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2007 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+
+import android.util.FloatMath;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link FloatMath}. Prior to SDK 23, this was implemented using native methods. */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = FloatMath.class, maxSdk = LOLLIPOP_MR1)
+public class ShadowFloatMath {
+  @Implementation
+  protected static float floor(float value) {
+    return (float) Math.floor(value);
+  }
+
+  @Implementation
+  protected static float ceil(float value) {
+    return (float) Math.ceil(value);
+  }
+
+  @Implementation
+  protected static float sin(float angle) {
+    return (float) Math.sin(angle);
+  }
+
+  @Implementation
+  protected static float cos(float angle) {
+    return (float) Math.cos(angle);
+  }
+
+  @Implementation
+  protected static float sqrt(float value) {
+    return (float) Math.sqrt(value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFont.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFont.java
new file mode 100644
index 0000000..ac0f600
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFont.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+
+import android.graphics.fonts.Font;
+import java.nio.ByteBuffer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link Font} for Android S */
+@Implements(value = Font.class, minSdk = S)
+public class ShadowFont {
+  @Implementation
+  protected static int nGetPackedStyle(long fontPtr) {
+    // This value represents FontStyle.FONT_WEIGHT_NORMAL (first four bits)
+    // combined with FONT_SLANT_UPRIGHT (0 in the 5th bit).
+    return ShadowFontBuilder.getPackedStyle(fontPtr);
+  }
+
+  @Implementation
+  protected static ByteBuffer nNewByteBuffer(long font) {
+    return ShadowFontBuilder.getBuffer(font);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontBuilder.java
new file mode 100644
index 0000000..9bae6df
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontBuilder.java
@@ -0,0 +1,188 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.content.res.AssetManager;
+import android.graphics.fonts.Font;
+import android.graphics.fonts.FontStyle;
+import androidx.annotation.RequiresApi;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.android.ApkAssetsCookie;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.Asset.AccessMode;
+import org.robolectric.res.android.CppAssetManager2;
+import org.robolectric.res.android.Registries;
+
+/** Shadow for {@link android.graphics.fonts.Font.Builder} */
+@Implements(value = Font.Builder.class, minSdk = Q)
+@RequiresApi(api = Q)
+public class ShadowFontBuilder {
+  private static final Map<Long, FontInternal> resettableByteBuffers = new HashMap<>();
+
+  @Resetter
+  public static void reset() {
+    for (Map.Entry<Long, FontInternal> entry : resettableByteBuffers.entrySet()) {
+      FontInternal fontInternal = entry.getValue();
+      if (fontInternal == null || fontInternal.buffer == null) {
+        continue;
+      }
+      ByteBuffer buffer = fontInternal.buffer;
+      buffer.rewind();
+      buffer.clear();
+    }
+    resettableByteBuffers.clear();
+  }
+
+  static ByteBuffer getBuffer(long ptr) {
+    FontInternal fontInternal = resettableByteBuffers.get(ptr);
+    if (fontInternal == null) {
+      return ByteBuffer.allocate(0);
+    }
+    return fontInternal.buffer;
+  }
+
+  static int getPackedStyle(long ptr) {
+    FontInternal fontInternal = resettableByteBuffers.get(ptr);
+    if (fontInternal == null) {
+      return FontStyle.FONT_WEIGHT_NORMAL;
+    }
+    return pack(fontInternal.weight, fontInternal.italic);
+  }
+
+  private static int pack(int weight, boolean italic) {
+    // See android.graphics.fonts.FontUtils#pack
+    return weight | (italic ? 0x10000 : 0);
+  }
+
+  // transliterated from frameworks/base/core/jni/android/graphics/fonts/Font.cpp
+
+  @Implementation(maxSdk = Q)
+  protected static long nGetNativeAsset(
+      AssetManager assetMgr, String path, boolean isAsset, int cookie) {
+    // NPE_CHECK_RETURN_ZERO(env, assetMgr);
+    Preconditions.checkNotNull(assetMgr);
+    // NPE_CHECK_RETURN_ZERO(env, path);
+    Preconditions.checkNotNull(path);
+
+    // Guarded<AssetManager2>* mgr = AssetManagerForJavaObject(env, assetMgr);
+    CppAssetManager2 mgr = ShadowArscAssetManager10.AssetManagerForJavaObject(assetMgr);
+    //if (mgr == nullptr) {
+    if (mgr == null) {
+      return 0;
+    }
+
+    // ScopedUtfChars str(env, path);
+    // if (str.c_str() == nullptr) {
+    //   return 0;
+    // }
+
+    // std::unique_ptr<Asset> asset;
+    Asset asset;
+    {
+      // ScopedLock<AssetManager2> locked_mgr(*mgr);
+      if (isAsset) {
+        // asset = locked_mgr->Open(str.c_str(), Asset::ACCESS_BUFFER);
+        asset = mgr.Open(path, AccessMode.ACCESS_BUFFER);
+      } else if (cookie > 0) {
+        // Valid java cookies are 1-based, but AssetManager cookies are 0-based.
+        // asset = locked_mgr->OpenNonAsset(str.c_str(), static_cast<ApkAssetsCookie>(cookie - 1), Asset::ACCESS_BUFFER);
+        asset = mgr.OpenNonAsset(path, ApkAssetsCookie.forInt(cookie - 1), AccessMode.ACCESS_BUFFER);
+      } else {
+        // asset = locked_mgr->OpenNonAsset(str.c_str(), Asset::ACCESS_BUFFER);
+        asset = mgr.OpenNonAsset(path, AccessMode.ACCESS_BUFFER);
+      }
+    }
+
+    // return reinterpret_cast<jlong>(asset.release());
+    return Registries.NATIVE_ASSET_REGISTRY.register(asset);
+  }
+
+  @Implementation(maxSdk = Q)
+  protected static ByteBuffer nGetAssetBuffer(long nativeAsset) {
+    // Asset* asset = toAsset(nativeAsset);
+    Asset asset = Registries.NATIVE_ASSET_REGISTRY.getNativeObject(nativeAsset);
+    //return env->NewDirectByteBuffer(const_cast<void*>(asset->getBuffer(false)), asset->getLength());
+    return ByteBuffer.wrap(asset.getBuffer(false));
+  }
+
+  @Implementation(maxSdk = Q)
+  protected static long nGetReleaseNativeAssetFunc() {
+    // return reinterpret_cast<jlong>(&releaseAsset);
+    // TODO: implement
+    return 0;
+  }
+
+  /** Re-implement to avoid call to DirectByteBuffer#array, which is not supported on JDK */
+  @Implementation(minSdk = R)
+  protected static ByteBuffer createBuffer(
+      AssetManager am, String path, boolean isAsset, int cookie) throws IOException {
+    Preconditions.checkNotNull(am, "assetManager can not be null");
+    Preconditions.checkNotNull(path, "path can not be null");
+
+    try (InputStream assetStream =
+        isAsset
+            ? am.open(path, AssetManager.ACCESS_BUFFER)
+            : am.openNonAsset(cookie, path, AssetManager.ACCESS_BUFFER)) {
+
+      int capacity = assetStream.available();
+      ByteBuffer buffer = ByteBuffer.allocate(capacity);
+      buffer.order(ByteOrder.nativeOrder());
+      assetStream.read(buffer.array(), buffer.arrayOffset(), assetStream.available());
+
+      if (assetStream.read() != -1) {
+        throw new IOException("Unable to access full contents of " + path);
+      }
+
+      return buffer;
+    }
+  }
+
+  @Implementation(minSdk = S)
+  protected static long nBuild(
+      long builderPtr,
+      ByteBuffer buffer,
+      String filePath,
+      String localeList,
+      int weight,
+      boolean italic,
+      int ttcIndex) {
+    Preconditions.checkNotNull(buffer);
+    Preconditions.checkNotNull(filePath);
+    Preconditions.checkNotNull(localeList);
+
+    buffer.rewind();
+    // If users use one ttf file for different style, for example one ttf for bold and normal,
+    // the buffer's hasCode is the same. We can use packed style as addition to calculate identical
+    // pointer address for native object.
+    long ptr = ((long) buffer.hashCode()) * 32 + pack(weight, italic);
+    FontInternal fontInternal = new FontInternal();
+    fontInternal.buffer = buffer;
+    fontInternal.filePath = filePath;
+    fontInternal.localeList = localeList;
+    fontInternal.weight = weight;
+    fontInternal.italic = italic;
+    fontInternal.ttcIndex = ttcIndex;
+    resettableByteBuffers.put(ptr, fontInternal);
+    return ptr;
+  }
+
+  private static class FontInternal {
+    ByteBuffer buffer;
+    String filePath;
+    String localeList;
+    int weight;
+    boolean italic;
+    int ttcIndex;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontFamily.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontFamily.java
new file mode 100644
index 0000000..6d46c62
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontFamily.java
@@ -0,0 +1,40 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+
+import android.content.res.AssetManager;
+import android.graphics.FontFamily;
+import android.graphics.fonts.FontVariationAxis;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = FontFamily.class, minSdk = LOLLIPOP, isInAndroidSdk = false)
+public class ShadowFontFamily {
+
+  @Implementation(minSdk = O)
+  protected static long nInitBuilder(String lang, int variant) {
+    return 1;
+  }
+
+  @Implementation(minSdk = O)
+  protected void abortCreation() {}
+
+  @Implementation(minSdk = O)
+  protected boolean addFontFromAssetManager(
+      AssetManager mgr,
+      String path,
+      int cookie,
+      boolean isAsset,
+      int ttcIndex,
+      int weight,
+      int isItalic,
+      FontVariationAxis[] axes) {
+    return true;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean freeze() {
+    return true;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontsContract.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontsContract.java
new file mode 100644
index 0000000..5eaa2e5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontsContract.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.provider.FontRequest;
+import android.provider.FontsContract;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = FontsContract.class, minSdk = O)
+public class ShadowFontsContract {
+
+  /** Returns a stub typeface immediately. */
+  @Implementation
+  public static Typeface getFontSync(FontRequest request) {
+    return Typeface.create(request.getQuery(), Typeface.NORMAL);
+  }
+
+  @Resetter
+  public static void reset() {
+    reflector(FontsContractReflector.class).setContext(null);
+  }
+
+  @ForType(FontsContract.class)
+  private interface FontsContractReflector {
+    @Static
+    @Accessor("sContext")
+    void setContext(Context context);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontsFontFamily.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontsFontFamily.java
new file mode 100644
index 0000000..27b5d1c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowFontsFontFamily.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.fonts.Font;
+import android.graphics.fonts.FontFamily;
+import java.util.ArrayList;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link FontFamily}. */
+@Implements(value = FontFamily.class, minSdk = Q)
+public class ShadowFontsFontFamily {
+
+  private ArrayList<Font> fonts = new ArrayList<>();
+
+  /**
+   * The real {@link FontFamily#getFont} calls into native code, so it needs to be shadowed to
+   * prevent an NPE.
+   */
+  @Implementation
+  protected Font getFont(int index) {
+    return fonts.get(index);
+  }
+
+  /** Shadow for {@link FontFamily.Builder}. */
+  @Implements(value = FontFamily.Builder.class, minSdk = Q)
+  public static class ShadowFontsFontFamilyBuilder {
+
+    @RealObject FontFamily.Builder realFontFamilyBuilder;
+
+    @Implementation
+    protected FontFamily build() {
+      FontFamily result =
+          reflector(FontFamilyBuilderReflector.class, realFontFamilyBuilder).build();
+      ShadowFontsFontFamily shadowFontFamily = Shadow.extract(result);
+      shadowFontFamily.fonts =
+          reflector(FontFamilyBuilderReflector.class, realFontFamilyBuilder).getFonts();
+      return result;
+    }
+
+    @ForType(FontFamily.Builder.class)
+    interface FontFamilyBuilderReflector {
+      @Direct
+      FontFamily build();
+
+      @Accessor("mFonts")
+      ArrayList<Font> getFonts();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGLES20.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGLES20.java
new file mode 100644
index 0000000..27d06fc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGLES20.java
@@ -0,0 +1,69 @@
+package org.robolectric.shadows;
+
+import android.opengl.GLES20;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake implementation of {@link GLES20}
+ */
+@Implements(GLES20.class)
+public class ShadowGLES20 {
+  private static int framebufferCount = 0;
+  private static int textureCount = 0;
+  private static int shaderCount = 0;
+  private static int programCount = 0;
+
+  @Implementation
+  protected static void glGenFramebuffers(int n, int[] framebuffers, int offset) {
+    for (int i = 0; i < n; i++) {
+      framebuffers[offset + i] = ++framebufferCount;
+    }
+  }
+
+  @Implementation
+  protected static void glGenTextures(int n, int[] textures, int offset) {
+    for (int i = 0; i < n; i++) {
+      textures[offset + i] = ++textureCount;
+    }
+  }
+
+  @Implementation
+  protected static int glCreateShader(int type) {
+    if (type != GLES20.GL_VERTEX_SHADER && type != GLES20.GL_FRAGMENT_SHADER) {
+      return GLES20.GL_INVALID_ENUM;
+    }
+    return ++shaderCount;
+  }
+
+  @Implementation
+  protected static int glCreateProgram() {
+    return ++programCount;
+  }
+
+  @Implementation
+  protected static void glGetShaderiv(int shader, int pname, int[] params, int offset) {
+    switch (pname) {
+      case GLES20.GL_COMPILE_STATUS:
+        params[0] = GLES20.GL_TRUE;
+        break;
+      default:  // no-op
+    }
+  }
+
+  @Implementation
+  protected static void glGetProgramiv(int program, int pname, int[] params, int offset) {
+    switch (pname) {
+      case GLES20.GL_LINK_STATUS:
+        params[0] = GLES20.GL_TRUE;
+        break;
+      default:  // no-op
+    }
+  }
+
+  /** Always returns {@link GLES20#GL_FRAMEBUFFER_COMPLETE}. */
+  @Implementation
+  protected static int glCheckFramebufferStatus(int target) {
+    return GLES20.GL_FRAMEBUFFER_COMPLETE;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGLSurfaceView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGLSurfaceView.java
new file mode 100644
index 0000000..157896e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGLSurfaceView.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import android.opengl.GLSurfaceView;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Fake implementation of GLSurfaceView */
+@Implements(GLSurfaceView.class)
+public class ShadowGLSurfaceView extends ShadowSurfaceView {
+  @Implementation
+  protected void onPause() {}
+
+  @Implementation
+  protected void onResume() {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGeocoder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGeocoder.java
new file mode 100644
index 0000000..a218db7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGeocoder.java
@@ -0,0 +1,62 @@
+package org.robolectric.shadows;
+
+import android.location.Address;
+import android.location.Geocoder;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(Geocoder.class)
+public final class ShadowGeocoder {
+
+  private static boolean isPresent = true;
+  private List<Address> fromLocation = new ArrayList<>();
+
+  /** @return true by default, or the value specified via {@link #setIsPresent(boolean)} */
+  @Implementation
+  protected static boolean isPresent() {
+    return isPresent;
+  }
+
+  /**
+   * Returns an empty list by default, or the last value set by {@link #setFromLocation(List)}
+   *
+   * <p>{@param latitude} and {@param longitude} are ignored by this implementation, except to check
+   * that they are in appropriate bounds. {@param maxResults} determines the maximum number of
+   * addresses to return.
+   */
+  @Implementation
+  protected List<Address> getFromLocation(double latitude, double longitude, int maxResults)
+      throws IOException {
+    Preconditions.checkArgument(
+        -90 <= latitude && latitude <= 90, "Latitude must be between -90 and 90, got %s", latitude);
+    Preconditions.checkArgument(
+        -180 <= longitude && longitude <= 180,
+        "Longitude must be between -180 and 180, got %s",
+        longitude);
+    return fromLocation.subList(0, Math.min(maxResults, fromLocation.size()));
+  }
+
+  /**
+   * Sets the value to be returned by {@link Geocoder#isPresent()}.
+   *
+   * <p>This value is reset to true for each test.
+   */
+  public static void setIsPresent(boolean value) {
+    isPresent = value;
+  }
+
+  /** Sets the value to be returned by {@link Geocoder#getFromLocation(double, double, int)}. */
+  public void setFromLocation(List<Address> list) {
+    fromLocation = list;
+  }
+
+  @Resetter
+  public static void reset() {
+    isPresent = true;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGestureDetector.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGestureDetector.java
new file mode 100644
index 0000000..89cd32f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGestureDetector.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import static android.view.GestureDetector.OnDoubleTapListener;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.os.Handler;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(GestureDetector.class)
+public class ShadowGestureDetector {
+  @RealObject private GestureDetector realObject;
+
+  private static GestureDetector lastActiveGestureDetector;
+
+  private MotionEvent onTouchEventMotionEvent;
+  private GestureDetector.OnGestureListener listener;
+  private OnDoubleTapListener onDoubleTapListener;
+
+  @Implementation
+  protected void __constructor__(
+      Context context, GestureDetector.OnGestureListener listener, Handler handler) {
+    Shadow.invokeConstructor(
+        GestureDetector.class,
+        realObject,
+        from(Context.class, context),
+        from(GestureDetector.OnGestureListener.class, listener),
+        from(Handler.class, handler));
+    this.listener = listener;
+  }
+
+  @Implementation
+  protected boolean onTouchEvent(MotionEvent ev) {
+    lastActiveGestureDetector = realObject;
+    onTouchEventMotionEvent = ev;
+
+    return reflector(GestureDetectorReflector.class, realObject).onTouchEvent(ev);
+  }
+
+  @Implementation
+  protected void setOnDoubleTapListener(OnDoubleTapListener onDoubleTapListener) {
+    reflector(GestureDetectorReflector.class, realObject)
+        .setOnDoubleTapListener(onDoubleTapListener);
+    this.onDoubleTapListener = onDoubleTapListener;
+  }
+
+  public MotionEvent getOnTouchEventMotionEvent() {
+    return onTouchEventMotionEvent;
+  }
+
+  public void reset() {
+    onTouchEventMotionEvent = null;
+  }
+
+  public GestureDetector.OnGestureListener getListener() {
+    return listener;
+  }
+
+  public static GestureDetector getLastActiveDetector() {
+    return lastActiveGestureDetector;
+  }
+
+  public OnDoubleTapListener getOnDoubleTapListener() {
+    return onDoubleTapListener;
+  }
+
+  @ForType(GestureDetector.class)
+  interface GestureDetectorReflector {
+
+    @Direct
+    boolean onTouchEvent(MotionEvent ev);
+
+    @Direct
+    void setOnDoubleTapListener(OnDoubleTapListener onDoubleTapListener);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGradientDrawable.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGradientDrawable.java
new file mode 100644
index 0000000..fed709b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowGradientDrawable.java
@@ -0,0 +1,62 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.drawable.GradientDrawable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(GradientDrawable.class)
+public class ShadowGradientDrawable extends ShadowDrawable {
+
+  @RealObject private GradientDrawable realGradientDrawable;
+
+  private int color;
+  private int strokeColor;
+  private int strokeWidth;
+
+  @Implementation
+  protected void setColor(int color) {
+    this.color = color;
+    reflector(GradientDrawableReflector.class, realGradientDrawable).setColor(color);
+  }
+
+  @Implementation
+  protected void setStroke(int width, int color) {
+    this.strokeWidth = width;
+    this.strokeColor = color;
+    reflector(GradientDrawableReflector.class, realGradientDrawable).setStroke(width, color);
+  }
+
+
+  /**
+   * Returns the color of this drawable as set by the last call to {@link #setColor(int color)}.
+   *
+   * <p>Note that this only works if the color is explicitly set with {@link #setColor(int color)}.
+   * If the color of this drawable is set by another method, the result will be {@code 0}.
+   */
+  public int getLastSetColor() {
+    return color;
+  }
+
+  public int getStrokeWidth() {
+    return strokeWidth;
+  }
+
+  public int getStrokeColor() {
+    return strokeColor;
+  }
+
+  @ForType(GradientDrawable.class)
+  interface GradientDrawableReflector {
+
+    @Direct
+    void setColor(int color);
+
+    @Direct
+    void setStroke(int width, int color);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHardwareBuffer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHardwareBuffer.java
new file mode 100644
index 0000000..71690aa
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHardwareBuffer.java
@@ -0,0 +1,120 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+
+import android.hardware.HardwareBuffer;
+import android.os.Parcel;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.res.android.NativeObjRegistry;
+
+/** Shadow of android.hardware.HardwareBuffer. */
+@Implements(HardwareBuffer.class)
+public class ShadowHardwareBuffer {
+  private static final long INVALID_BUFFER_ID = 0;
+
+  private static final long USAGE_FLAGS_O =
+      HardwareBuffer.USAGE_CPU_READ_RARELY
+          | HardwareBuffer.USAGE_CPU_READ_OFTEN
+          | HardwareBuffer.USAGE_CPU_WRITE_RARELY
+          | HardwareBuffer.USAGE_CPU_WRITE_OFTEN
+          | HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
+          | HardwareBuffer.USAGE_GPU_COLOR_OUTPUT
+          | HardwareBuffer.USAGE_PROTECTED_CONTENT
+          | HardwareBuffer.USAGE_VIDEO_ENCODE
+          | HardwareBuffer.USAGE_GPU_DATA_BUFFER
+          | HardwareBuffer.USAGE_SENSOR_DIRECT_DATA;
+
+  private static final long USAGE_FLAGS_P =
+      HardwareBuffer.USAGE_GPU_CUBE_MAP | HardwareBuffer.USAGE_GPU_MIPMAP_COMPLETE;
+
+  private static final long VALID_USAGE_FLAGS;
+
+  private static class BufferState {
+    public int width;
+    public int height;
+    public int layers;
+    public int format;
+    public long usage;
+  }
+
+  private static final NativeObjRegistry<BufferState> BUFFER_STATE_REGISTRY =
+      new NativeObjRegistry<>(BufferState.class);
+
+  static {
+    long usageFlags = 0;
+
+    if (getApiLevel() >= O) {
+      usageFlags |= USAGE_FLAGS_O;
+    }
+
+    if (getApiLevel() >= P) {
+      usageFlags |= USAGE_FLAGS_P;
+    }
+
+    VALID_USAGE_FLAGS = usageFlags;
+  }
+
+  @Implementation(minSdk = O)
+  protected static long nCreateHardwareBuffer(
+      int width, int height, int format, int layers, long usage) {
+    if ((usage & ~VALID_USAGE_FLAGS) != 0) {
+      return INVALID_BUFFER_ID;
+    }
+
+    BufferState bufferState = new BufferState();
+    bufferState.width = width;
+    bufferState.height = height;
+    bufferState.format = format;
+    bufferState.layers = layers;
+    bufferState.usage = usage;
+    return BUFFER_STATE_REGISTRY.register(bufferState);
+  }
+
+  @Implementation(minSdk = O)
+  protected static void nWriteHardwareBufferToParcel(long nativeObject, Parcel dest) {
+    BufferState bufferState = BUFFER_STATE_REGISTRY.getNativeObject(nativeObject);
+    dest.writeInt(bufferState.width);
+    dest.writeInt(bufferState.height);
+    dest.writeInt(bufferState.format);
+    dest.writeInt(bufferState.layers);
+    dest.writeLong(bufferState.usage);
+  }
+
+  @Implementation(minSdk = O)
+  protected static long nReadHardwareBufferFromParcel(Parcel in) {
+    int width = in.readInt();
+    int height = in.readInt();
+    int format = in.readInt();
+    int layers = in.readInt();
+    long usage = in.readLong();
+    return nCreateHardwareBuffer(width, height, format, layers, usage);
+  }
+
+  @Implementation(minSdk = O)
+  protected static int nGetWidth(long nativeObject) {
+    return BUFFER_STATE_REGISTRY.getNativeObject(nativeObject).width;
+  }
+
+  @Implementation(minSdk = O)
+  protected static int nGetHeight(long nativeObject) {
+    return BUFFER_STATE_REGISTRY.getNativeObject(nativeObject).height;
+  }
+
+  @Implementation(minSdk = O)
+  protected static int nGetFormat(long nativeObject) {
+    return BUFFER_STATE_REGISTRY.getNativeObject(nativeObject).format;
+  }
+
+  @Implementation(minSdk = O)
+  protected static int nGetLayers(long nativeObject) {
+    return BUFFER_STATE_REGISTRY.getNativeObject(nativeObject).layers;
+  }
+
+  @Implementation(minSdk = O)
+  protected static long nGetUsage(long nativeObject) {
+    return BUFFER_STATE_REGISTRY.getNativeObject(nativeObject).usage;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHardwareRenderer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHardwareRenderer.java
new file mode 100644
index 0000000..7b86dc2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHardwareRenderer.java
@@ -0,0 +1,63 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.HardwareRenderer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(
+    value = HardwareRenderer.class,
+    isInAndroidSdk = false,
+    looseSignatures = true,
+    minSdk = Q)
+public class ShadowHardwareRenderer {
+
+  private static long nextCreateProxy = 0;
+
+  @Implementation(maxSdk = Q)
+  protected static long nCreateProxy(boolean translucent, long rootRenderNode) {
+    return ++nextCreateProxy;
+  }
+
+  @Implementation
+  protected static long nCreateTextureLayer(long nativeProxy) {
+    return ShadowVirtualRefBasePtr.put(nativeProxy);
+  }
+
+  @Implementation(minSdk = R, maxSdk = R)
+  protected static long nCreateProxy(
+      boolean translucent, boolean isWideGamut, long rootRenderNode) {
+    return nCreateProxy(translucent, rootRenderNode);
+  }
+
+  // need to use loose signatures here to account for signature changes
+  @Implementation(minSdk = S)
+  protected static long nCreateProxy(Object translucent, Object rootRenderNode) {
+    return nCreateProxy((boolean) translucent, (long) rootRenderNode);
+  }
+
+  @Implementation
+  protected static Bitmap createHardwareBitmap(
+      /*RenderNode*/ Object node, /*int*/ Object width, /*int*/ Object height) {
+    return createHardwareBitmap((int) width, (int) height);
+  }
+
+  private static Bitmap createHardwareBitmap(int width, int height) {
+    Bitmap bitmap = Bitmap.createBitmap(width, height, Config.HARDWARE);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.setMutable(false);
+    return bitmap;
+  }
+
+  @Resetter
+  public static void reset() {
+    nextCreateProxy = 0;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHttpResponseCache.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHttpResponseCache.java
new file mode 100644
index 0000000..0fb712d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowHttpResponseCache.java
@@ -0,0 +1,101 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.shadow.api.Shadow.newInstanceOf;
+
+import android.net.http.HttpResponseCache;
+import java.io.File;
+import java.net.CacheRequest;
+import java.net.CacheResponse;
+import java.net.URI;
+import java.net.URLConnection;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = HttpResponseCache.class, callThroughByDefault = false)
+public class ShadowHttpResponseCache {
+  private static final Object LOCK = new Object();
+
+  static ShadowHttpResponseCache installed = null;
+
+  private HttpResponseCache originalObject;
+  private File directory;
+  private long maxSize;
+  private int requestCount = 0;
+  private int hitCount = 0;
+  private int networkCount = 0;
+
+  @Implementation
+  protected static HttpResponseCache install(File directory, long maxSize) {
+    HttpResponseCache cache = newInstanceOf(HttpResponseCache.class);
+    ShadowHttpResponseCache shadowCache = Shadow.extract(cache);
+    shadowCache.originalObject = cache;
+    shadowCache.directory = directory;
+    shadowCache.maxSize = maxSize;
+    synchronized (LOCK) {
+      installed = shadowCache;
+      return cache;
+    }
+  }
+
+  @Implementation
+  protected static HttpResponseCache getInstalled() {
+    synchronized (LOCK) {
+      return (installed != null) ? installed.originalObject : null;
+    }
+  }
+
+  @Implementation
+  protected long maxSize() {
+    return maxSize;
+  }
+
+  @Implementation
+  protected long size() {
+    return 0;
+  }
+
+  @Implementation
+  protected void close() {
+    synchronized (LOCK) {
+      installed = null;
+    }
+  }
+
+  @Implementation
+  protected void delete() {
+    close();
+  }
+
+  @Implementation
+  protected int getHitCount() {
+    return hitCount;
+  }
+
+  @Implementation
+  protected int getNetworkCount() {
+    return networkCount;
+  }
+
+  @Implementation
+  protected int getRequestCount() {
+    return requestCount;
+  }
+
+  @Implementation
+  protected CacheResponse get(
+      URI uri, String requestMethod, Map<String, List<String>> requestHeaders) {
+    requestCount += 1;
+    networkCount += 1; // Always pretend we had a cache miss and had to fall back to the network.
+    return null;
+  }
+
+  @Implementation
+  protected CacheRequest put(URI uri, URLConnection urlConnection) {
+    // Do not cache any data. All requests will be a miss.
+    return null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIAppOpsService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIAppOpsService.java
new file mode 100644
index 0000000..ee0da5c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIAppOpsService.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+
+import android.os.IBinder;
+import com.android.internal.app.IAppOpsService;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+public class ShadowIAppOpsService {
+
+  @Implements(value = IAppOpsService.Stub.class, isInAndroidSdk = false)
+  public static class ShadowStub {
+
+    @Implementation(minSdk = JELLY_BEAN_MR2)
+    public static IAppOpsService asInterface(IBinder obj) {
+      return ReflectionHelpers.createNullProxy(IAppOpsService.class);
+    }
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowICU.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowICU.java
new file mode 100644
index 0000000..d09d1c0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowICU.java
@@ -0,0 +1,56 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+
+import android.icu.util.ULocale;
+import java.util.Locale;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = libcore.icu.ICU.class, isInAndroidSdk = false)
+public class ShadowICU {
+
+  @Implementation
+  public static String addLikelySubtags(String languageTag) {
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      return ULocale.addLikelySubtags(ULocale.forLanguageTag(languageTag)).toLanguageTag();
+    } else {
+      // Return what is essentially the given locale, normalized by passing through the Locale
+      // factory method.
+      Locale locale = Locale.forLanguageTag(languageTag);
+      // To support testing with the ar-XB pseudo locale add "Arab" as the script for "ar" language,
+      // this is used by the Configuration to set the layout direction.
+      if (locale.getScript().isEmpty()
+          && locale.getLanguage().equals(new Locale("ar").getLanguage())) {
+        locale = new Locale.Builder().setLanguageTag(languageTag).setScript("Arab").build();
+      }
+      return locale.toLanguageTag();
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  public static String getBestDateTimePattern(String skeleton, Locale locale) {
+    switch (skeleton) {
+      case "jmm":
+        return getjmmPattern(locale);
+      default:
+        return skeleton;
+    }
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT_WATCH)
+  public static String getBestDateTimePattern(String skeleton, String locale) {
+    return skeleton;
+  }
+
+  private static String getjmmPattern(Locale locale) {
+    if (locale.equals(new Locale("pt", "BR")) || locale.equals(Locale.UK)) {
+      return "H:mm";
+    }
+    return "h:mm a";
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIcon.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIcon.java
new file mode 100644
index 0000000..72aa2fe
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIcon.java
@@ -0,0 +1,136 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.graphics.drawable.Icon.OnDrawableLoadedListener;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import androidx.annotation.Nullable;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = Icon.class, minSdk = M)
+public class ShadowIcon {
+
+  @Nullable private static Executor executorOverride;
+
+  /** Set the executor where async drawable loading will run. */
+  public static void overrideExecutor(Executor executor) {
+    executorOverride = executor;
+  }
+
+  @RealObject private Icon realIcon;
+
+  @HiddenApi
+  @Implementation
+  public int getType() {
+    return reflector(IconReflector.class, realIcon).getType();
+  }
+
+  @HiddenApi
+  @Implementation
+  public int getResId() {
+    return reflector(IconReflector.class, realIcon).getResId();
+  }
+
+  @HiddenApi
+  @Implementation
+  public Bitmap getBitmap() {
+    return reflector(IconReflector.class, realIcon).getBitmap();
+  }
+
+  @HiddenApi
+  @Implementation
+  public Uri getUri() {
+    return reflector(IconReflector.class, realIcon).getUri();
+  }
+
+  @HiddenApi
+  @Implementation
+  public int getDataLength() {
+    return reflector(IconReflector.class, realIcon).getDataLength();
+  }
+
+  @HiddenApi
+  @Implementation
+  public int getDataOffset() {
+    return reflector(IconReflector.class, realIcon).getDataOffset();
+  }
+
+  @HiddenApi
+  @Implementation
+  public byte[] getDataBytes() {
+    return reflector(IconReflector.class, realIcon).getDataBytes();
+  }
+
+  @Implementation
+  protected void loadDrawableAsync(Context context, Message andThen) {
+    if (executorOverride != null) {
+      executorOverride.execute(
+          () -> {
+            andThen.obj = realIcon.loadDrawable(context);
+            andThen.sendToTarget();
+          });
+    } else {
+      reflector(IconReflector.class, realIcon).loadDrawableAsync(context, andThen);
+    }
+  }
+
+  @Implementation
+  protected void loadDrawableAsync(
+      Context context, final OnDrawableLoadedListener listener, Handler handler) {
+    if (executorOverride != null) {
+      executorOverride.execute(
+          () -> {
+            Drawable result = realIcon.loadDrawable(context);
+            handler.post(() -> listener.onDrawableLoaded(result));
+          });
+    } else {
+      reflector(IconReflector.class, realIcon).loadDrawableAsync(context, listener, handler);
+    }
+  }
+
+  @ForType(Icon.class)
+  interface IconReflector {
+
+    @Direct
+    int getType();
+
+    @Direct
+    int getResId();
+
+    @Direct
+    Bitmap getBitmap();
+
+    @Direct
+    Uri getUri();
+
+    @Direct
+    int getDataLength();
+
+    @Direct
+    int getDataOffset();
+
+    @Direct
+    byte[] getDataBytes();
+
+    @Direct
+    void loadDrawableAsync(Context context, Message andThen);
+
+    @Direct
+    void loadDrawableAsync(
+        Context context, final OnDrawableLoadedListener listener, Handler handler);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java
new file mode 100644
index 0000000..791ece2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java
@@ -0,0 +1,398 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.content.res.AssetManager;
+import android.content.res.AssetManager.AssetInputStream;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.ColorSpace;
+import android.graphics.ImageDecoder;
+import android.graphics.ImageDecoder.DecodeException;
+import android.graphics.ImageDecoder.Source;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.util.Size;
+import java.io.ByteArrayInputStream;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.res.android.NativeObjRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/jni/android/graphics/ImageDecoder.cpp
+@SuppressWarnings({"NewApi", "UnusedDeclaration"})
+// ImageDecoder is in fact in SDK, but make it false for now so projects which compile against < P
+// still work
+@Implements(value = ImageDecoder.class, isInAndroidSdk = false, minSdk = P)
+public class ShadowImageDecoder {
+
+  private abstract static class ImgStream {
+
+    private final int width;
+    private final int height;
+    private final boolean animated = false;
+    private final boolean ninePatch;
+
+    ImgStream() {
+      InputStream inputStream = getInputStream();
+      final Point size = ImageUtil.getImageSizeFromStream(inputStream);
+      this.width = size == null ? 10 : size.x;
+      this.height = size == null ? 10 : size.y;
+      if (inputStream instanceof AssetManager.AssetInputStream) {
+        ShadowAssetInputStream sis = Shadow.extract(inputStream);
+        this.ninePatch = sis.isNinePatch();
+      } else {
+        this.ninePatch = false;
+      }
+    }
+
+    protected abstract InputStream getInputStream();
+
+    int getWidth() {
+      return width;
+    }
+
+    int getHeight() {
+      return height;
+    }
+
+    boolean isAnimated() {
+      return animated;
+    }
+
+    boolean isNinePatch() {
+      return ninePatch;
+    }
+  }
+
+  private static final class CppImageDecoder {
+
+    private final ImgStream imgStream;
+
+    CppImageDecoder(ImgStream imgStream) {
+      this.imgStream = imgStream;
+    }
+
+  }
+
+  private static final NativeObjRegistry<CppImageDecoder> NATIVE_IMAGE_DECODER_REGISTRY =
+      new NativeObjRegistry<>(CppImageDecoder.class);
+
+  private static ImageDecoder jniCreateDecoder(ImgStream imgStream) {
+    CppImageDecoder cppImageDecoder = new CppImageDecoder(imgStream);
+    long cppImageDecoderPtr = NATIVE_IMAGE_DECODER_REGISTRY.register(cppImageDecoder);
+    return ReflectionHelpers.callConstructor(
+        ImageDecoder.class,
+        ClassParameter.from(long.class, cppImageDecoderPtr),
+        ClassParameter.from(int.class, imgStream.getWidth()),
+        ClassParameter.from(int.class, imgStream.getHeight()),
+        ClassParameter.from(boolean.class, imgStream.isAnimated()),
+        ClassParameter.from(boolean.class, imgStream.isNinePatch()));
+  }
+
+  protected static ImageDecoder ImageDecoder_nCreateFd(
+      FileDescriptor fileDescriptor, Source source) {
+    throw new UnsupportedOperationException();
+    // int descriptor = jniGetFDFromFileDescriptor(fileDescriptor);
+    // struct stat fdStat;
+    // if (fstat(descriptor, &fdStat) == -1) {
+    //   throw_exception(ShadowImageDecoder.Error.kSourceMalformedData,
+    //       "broken file descriptor; fstat returned -1", null, source);
+    // }
+    // int dupDescriptor = dup(descriptor);
+    // FILE* file = fdopen(dupDescriptor, "r");
+    // if (file == NULL) {
+    //   close(dupDescriptor);
+    //   throw_exception(ShadowImageDecoder.Error.kSourceMalformedData, "Could not open file",
+    //       null, source);
+    // }
+    // SkFILEStream fileStream(new SkFILEStream(file));
+    // return native_create(fileStream, source);
+  }
+
+  protected static ImageDecoder ImageDecoder_nCreateInputStream(
+      InputStream is, byte[] storage, Source source) {
+    // SkStream stream = CreateJavaInputStreamAdaptor(is, storage, false);
+    // if (!isTruthy(stream)) {
+    //   throw_exception(ShadowImageDecoder.Error.kSourceMalformedData, "Failed to create a stream",
+    //       null, source);
+    // }
+    // SkStream bufferedStream =
+    //     SkFrontBufferedStream.Make(stream,
+    //     SkCodec.MinBufferedBytesNeeded()));
+    // return native_create(bufferedStream, source);
+
+    return jniCreateDecoder(new ImgStream() {
+      @Override
+      protected InputStream getInputStream() {
+        return is;
+      }
+    });
+  }
+
+  protected static ImageDecoder ImageDecoder_nCreateAsset(long asset_ptr, Source source)
+      throws DecodeException {
+    // Asset* asset = reinterpret_cast<Asset*>(assetPtr);
+    // SkStream stream = new AssetStreamAdaptor(asset);
+    // return jniCreateDecoder(stream, source);
+    Resources resources = ReflectionHelpers.getField(source, "mResources");
+    AssetInputStream assetInputStream = ShadowAssetInputStream.createAssetInputStream(
+        null, asset_ptr, resources.getAssets());
+    return jniCreateDecoder(
+        new ImgStream() {
+          @Override
+          protected InputStream getInputStream() {
+            return assetInputStream;
+          }
+        });
+  }
+
+  protected static ImageDecoder ImageDecoder_nCreateByteBuffer(
+      ByteBuffer jbyteBuffer, int initialPosition, int limit, Source source)
+      throws DecodeException {
+    // SkStream stream = CreateByteBufferStreamAdaptor(jbyteBuffer,
+    //     initialPosition, limit);
+    // if (!isTruthy(stream)) {
+    //   throw_exception(ShadowImageDecoder.Error.kSourceMalformedData, "Failed to read ByteBuffer",
+    //       null, source);
+    // }
+    // return native_create(stream, source);
+    return jniCreateDecoder(new ImgStream() {
+      @Override
+      protected InputStream getInputStream() {
+        return new ByteArrayInputStream(jbyteBuffer.array());
+      }
+    });
+  }
+
+  protected static ImageDecoder ImageDecoder_nCreateByteArray(
+      byte[] byteArray, int offset, int length, Source source) {
+    // SkStream stream = CreateByteArrayStreamAdaptor(byteArray, offset, length);
+    // return native_create(stream, source);
+    return jniCreateDecoder(new ImgStream() {
+      @Override
+      protected InputStream getInputStream() {
+        return new ByteArrayInputStream(byteArray);
+      }
+    });
+  }
+
+  protected static Bitmap ImageDecoder_nDecodeBitmap(long nativePtr,
+      ImageDecoder decoder,
+      boolean doPostProcess,
+      int width, int height,
+      Rect cropRect, boolean mutable,
+      int allocator, boolean unpremulRequired,
+      boolean conserveMemory, boolean decodeAsAlphaMask,
+      ColorSpace desiredColorSpace)
+      throws IOException {
+    CppImageDecoder cppImageDecoder = NATIVE_IMAGE_DECODER_REGISTRY.getNativeObject(nativePtr);
+
+    final ImgStream imgStream = cppImageDecoder.imgStream;
+    final InputStream stream = imgStream.getInputStream();
+
+    if (stream == null) {
+      return null;
+    }
+
+    Bitmap bitmap = BitmapFactory.decodeStream(stream);
+
+    // TODO: Make this more efficient by transliterating nDecodeBitmap
+    // Ensure that nDecodeBitmap should return a scaled bitmap as specified by height/width
+    if (bitmap.getWidth() != width || bitmap.getHeight() != height) {
+      bitmap = Bitmap.createScaledBitmap(bitmap, width, height, true);
+    }
+
+    if (imgStream.isNinePatch() && ReflectionHelpers.getField(bitmap, "mNinePatchChunk") == null) {
+      ReflectionHelpers.setField(Bitmap.class, bitmap, "mNinePatchChunk", new byte[0]);
+    }
+    return bitmap;
+  }
+
+  static Size ImageDecoder_nGetSampledSize(long nativePtr,
+      int sampleSize) {
+    CppImageDecoder decoder = NATIVE_IMAGE_DECODER_REGISTRY.getNativeObject(nativePtr);
+    // SkISize size = decoder.mCodec.getSampledDimensions(sampleSize);
+    // return env.NewObject(gSize_class, gSize_constructorMethodID, size.width(), size.height());
+    // return new Size(size.width(), size.height());
+    throw new UnsupportedOperationException();
+  }
+
+  static void ImageDecoder_nGetPadding(long nativePtr,
+      Rect outPadding) {
+    CppImageDecoder decoder = NATIVE_IMAGE_DECODER_REGISTRY.getNativeObject(nativePtr);
+    // decoder.mPeeker.getPadding(outPadding);
+    if (decoder.imgStream.isNinePatch()) {
+      outPadding.set(0, 0, 0, 0);
+    } else {
+      outPadding.set(-1, -1, -1, -1);
+    }
+  }
+
+  static void ImageDecoder_nClose(long nativePtr) {
+    // delete reinterpret_cast<ImageDecoder*>(nativePtr);
+    NATIVE_IMAGE_DECODER_REGISTRY.unregister(nativePtr);
+  }
+
+  static String ImageDecoder_nGetMimeType(long nativePtr) {
+    CppImageDecoder decoder = NATIVE_IMAGE_DECODER_REGISTRY.getNativeObject(nativePtr);
+    // return encodedFormatToString(decoder.mCodec.getEncodedFormat());
+    throw new UnsupportedOperationException();
+  }
+
+  static ColorSpace ImageDecoder_nGetColorSpace(long nativePtr) {
+    // auto colorType = codec.computeOutputColorType(codec.getInfo().colorType());
+    // sk_sp<SkColorSpace> colorSpace = codec.computeOutputColorSpace(colorType);
+    // return GraphicsJNI.getColorSpace(colorSpace, colorType);
+    throw new UnsupportedOperationException();
+  }
+
+  // native method implementations...
+
+  @Implementation(maxSdk = Q)
+  protected static ImageDecoder nCreate(long asset, Source source) throws IOException {
+    return ImageDecoder_nCreateAsset(asset, source);
+  }
+
+  @Implementation(maxSdk = Q)
+  protected static ImageDecoder nCreate(ByteBuffer buffer, int position, int limit, Source src)
+      throws IOException {
+    return ImageDecoder_nCreateByteBuffer(buffer, position, limit, src);
+  }
+
+  @Implementation(maxSdk = Q)
+  protected static ImageDecoder nCreate(byte[] data, int offset, int length, Source src)
+      throws IOException {
+    return ImageDecoder_nCreateByteArray(data, offset, length, src);
+  }
+
+  @Implementation(maxSdk = Q)
+  protected static ImageDecoder nCreate(InputStream is, byte[] storage, Source source) {
+    return ImageDecoder_nCreateInputStream(is, storage, source);
+  }
+
+  // The fd must be seekable.
+  @Implementation(maxSdk = Q)
+  protected static ImageDecoder nCreate(FileDescriptor fd, Source src) throws IOException {
+    return ImageDecoder_nCreateFd(fd, src);
+  }
+
+  @Implementation(minSdk = R)
+  protected static ImageDecoder nCreate(long asset, boolean preferAnimation, Source source)
+      throws IOException {
+    return ImageDecoder_nCreateAsset(asset, source);
+  }
+
+  @Implementation(minSdk = R)
+  protected static ImageDecoder nCreate(
+      ByteBuffer buffer, int position, int limit, boolean preferAnimation, Source src)
+      throws IOException {
+    return ImageDecoder_nCreateByteBuffer(buffer, position, limit, src);
+  }
+
+  @Implementation(minSdk = R)
+  protected static ImageDecoder nCreate(
+      byte[] data, int offset, int length, boolean preferAnimation, Source src) throws IOException {
+    return ImageDecoder_nCreateByteArray(data, offset, length, src);
+  }
+
+  @Implementation(minSdk = R)
+  protected static ImageDecoder nCreate(
+      InputStream is, byte[] storage, boolean preferAnimation, Source source) {
+    return ImageDecoder_nCreateInputStream(is, storage, source);
+  }
+
+  // The fd must be seekable.
+  @Implementation(minSdk = R, maxSdk = R)
+  protected static ImageDecoder nCreate(FileDescriptor fd, boolean preferAnimation, Source src)
+      throws IOException {
+    return ImageDecoder_nCreateFd(fd, src);
+  }
+
+  @Implementation(maxSdk = P)
+  protected static Bitmap nDecodeBitmap(
+      long nativePtr,
+      ImageDecoder decoder,
+      boolean doPostProcess,
+      int width,
+      int height,
+      android.graphics.Rect cropRect,
+      boolean mutable,
+      int allocator,
+      boolean unpremulRequired,
+      boolean conserveMemory,
+      boolean decodeAsAlphaMask,
+      android.graphics.ColorSpace desiredColorSpace)
+      throws IOException {
+    return ImageDecoder_nDecodeBitmap(nativePtr,
+        decoder,
+        doPostProcess,
+        width, height,
+        cropRect, mutable,
+        allocator, unpremulRequired,
+        conserveMemory, decodeAsAlphaMask,
+        desiredColorSpace);
+  }
+
+  @Implementation(minSdk = Q)
+  protected static Bitmap nDecodeBitmap(
+      long nativePtr,
+      ImageDecoder decoder,
+      boolean doPostProcess,
+      int width,
+      int height,
+      Rect cropRect,
+      boolean mutable,
+      int allocator,
+      boolean unpremulRequired,
+      boolean conserveMemory,
+      boolean decodeAsAlphaMask,
+      long desiredColorSpace,
+      boolean extended)
+      throws IOException {
+    return ImageDecoder_nDecodeBitmap(nativePtr,
+        decoder,
+        doPostProcess,
+        width, height,
+        cropRect, mutable,
+        allocator, unpremulRequired,
+        conserveMemory, decodeAsAlphaMask,
+        null);
+  }
+
+  @Implementation
+  protected static Size nGetSampledSize(long nativePtr,
+      int sampleSize) {
+    return ImageDecoder_nGetSampledSize(nativePtr, sampleSize);
+  }
+
+  @Implementation
+  protected static void nGetPadding(long nativePtr, Rect outRect) {
+    ImageDecoder_nGetPadding(nativePtr, outRect);
+  }
+
+  @Implementation
+  protected static void nClose(long nativePtr) {
+    ImageDecoder_nClose(nativePtr);
+  }
+
+  @Implementation
+  protected static String nGetMimeType(long nativePtr) {
+    return ImageDecoder_nGetMimeType(nativePtr);
+  }
+
+  @Implementation
+  protected static ColorSpace nGetColorSpace(long nativePtr) {
+    return ImageDecoder_nGetColorSpace(nativePtr);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java
new file mode 100644
index 0000000..15923c8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java
@@ -0,0 +1,160 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.media.Image;
+import android.media.ImageReader;
+import android.media.ImageReader.OnImageAvailableListener;
+import android.os.Handler;
+import android.view.Surface;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.media.ImageReader} */
+@Implements(ImageReader.class)
+public class ShadowImageReader {
+  // Using same return codes as ImageReader.
+  private static final int ACQUIRE_SUCCESS = 0;
+  private static final int ACQUIRE_NO_BUFS = 1;
+  private static final int ACQUIRE_MAX_IMAGES = 2;
+  private final AtomicLong imageCount = new AtomicLong(0);
+  private final AtomicBoolean readerValid = new AtomicBoolean(true);
+  private final AtomicLong availableBuffers = new AtomicLong(0);
+  private final List<Image> openedImages = new ArrayList<>();
+  private Surface surface;
+  @RealObject private ImageReader imageReader;
+  private Canvas canvas;
+
+  @Implementation(minSdk = KITKAT)
+  protected void close() {
+    readerValid.set(false);
+    openedImages.clear();
+  }
+
+  @Implementation(minSdk = KITKAT, maxSdk = S_V2)
+  protected int nativeImageSetup(Image image) {
+    if (!readerValid.get()) {
+      throw new IllegalStateException("ImageReader closed.");
+    }
+    if (openedImages.size() >= imageReader.getMaxImages()) {
+      return ACQUIRE_MAX_IMAGES;
+    }
+    if (availableBuffers.get() == 0) {
+      return ACQUIRE_NO_BUFS;
+    }
+    availableBuffers.getAndDecrement();
+    openedImages.add(image);
+    ShadowSurfaceImage shadowSurfaceImage = Shadow.extract(image);
+    shadowSurfaceImage.setTimeStamp(imageCount.get());
+    return ACQUIRE_SUCCESS;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected int nativeImageSetup(Image image, boolean useLegacyImageFormat) {
+    return nativeImageSetup(image);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void nativeReleaseImage(Image i) {
+    openedImages.remove(i);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected Surface nativeGetSurface() {
+    if (surface == null) {
+      surface = new FakeSurface();
+    }
+    return surface;
+  }
+
+  private class FakeSurface extends Surface {
+    public FakeSurface() {}
+
+    @Override
+    public Canvas lockCanvas(Rect inOutDirty) {
+      if (canvas == null) {
+        canvas = new Canvas();
+      }
+      return canvas;
+    }
+
+    @Override
+    public Canvas lockHardwareCanvas() {
+      if (canvas == null) {
+        canvas = new Canvas();
+      }
+      return canvas;
+    }
+
+    @Override
+    public void unlockCanvasAndPost(Canvas canvas) {
+      availableBuffers.getAndIncrement();
+      imageCount.getAndIncrement();
+      OnImageAvailableListener listener =
+          reflector(ImageReaderReflector.class, imageReader).getListener();
+      Handler handler = reflector(ImageReaderReflector.class, imageReader).getListenerHandler();
+      if (listener == null) {
+        return;
+      }
+      if (handler == null) {
+        Objects.requireNonNull(listener).onImageAvailable(imageReader);
+        return;
+      }
+
+      Objects.requireNonNull(handler)
+          .post(() -> Objects.requireNonNull(listener).onImageAvailable(imageReader));
+    }
+  }
+
+  /** Shadow for {@link android.media.Image} */
+  @Implements(className = "android.media.ImageReader$SurfaceImage")
+  public static class ShadowSurfaceImage {
+    @RealObject Object surfaceImage;
+
+    @Implementation(minSdk = KITKAT)
+    protected int getWidth() {
+      ImageReader reader = ReflectionHelpers.getField(surfaceImage, "this$0");
+      return reader.getWidth();
+    }
+
+    @Implementation(minSdk = KITKAT)
+    protected int getHeight() {
+      ImageReader reader = ReflectionHelpers.getField(surfaceImage, "this$0");
+      return reader.getHeight();
+    }
+
+    @Implementation(minSdk = KITKAT)
+    protected int getFormat() {
+      ImageReader reader = ReflectionHelpers.getField(surfaceImage, "this$0");
+      return reader.getImageFormat();
+    }
+
+    public void setTimeStamp(long timestamp) {
+      ReflectionHelpers.setField(surfaceImage, "mTimestamp", timestamp);
+    }
+  }
+
+  @ForType(ImageReader.class)
+  interface ImageReaderReflector {
+    @Accessor("mListener")
+    OnImageAvailableListener getListener();
+
+    @Accessor("mListenerHandler")
+    Handler getListenerHandler();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImeTracingClientImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImeTracingClientImpl.java
new file mode 100644
index 0000000..9386562
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImeTracingClientImpl.java
@@ -0,0 +1,19 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(
+    className = "android.util.imetracing.ImeTracingClientImpl",
+    isInAndroidSdk = false,
+    minSdk = S,
+    maxSdk = S_V2)
+public class ShadowImeTracingClientImpl {
+
+  // no-op the constructor to avoid deadlocks
+  @Implementation
+  protected void __constructor__() {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImsMmTelManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImsMmTelManager.java
new file mode 100644
index 0000000..59dd707
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImsMmTelManager.java
@@ -0,0 +1,205 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.Manifest;
+import android.annotation.CallbackExecutor;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.os.Build.VERSION_CODES;
+import android.telephony.SubscriptionManager;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ImsMmTelManager;
+import android.telephony.ims.ImsMmTelManager.CapabilityCallback;
+import android.telephony.ims.ImsMmTelManager.RegistrationCallback;
+import android.telephony.ims.ImsReasonInfo;
+import android.telephony.ims.feature.MmTelFeature.MmTelCapabilities;
+import android.telephony.ims.stub.ImsRegistrationImplBase;
+import android.util.ArrayMap;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * Supports IMS by default. IMS unregistered by default.
+ *
+ * @see #setImsAvailableOnDevice(boolean)
+ * @see #setImsRegistered(int)
+ */
+@Implements(
+    value = ImsMmTelManager.class,
+    minSdk = VERSION_CODES.Q,
+    looseSignatures = true,
+    isInAndroidSdk = false)
+@SystemApi
+public class ShadowImsMmTelManager {
+
+  protected static final Map<Integer, ImsMmTelManager> existingInstances = new ArrayMap<>();
+
+  private final Map<RegistrationCallback, Executor> registrationCallbackExecutorMap =
+      new ArrayMap<>();
+  private final Map<CapabilityCallback, Executor> capabilityCallbackExecutorMap = new ArrayMap<>();
+  private boolean imsAvailableOnDevice = true;
+  private MmTelCapabilities mmTelCapabilitiesAvailable =
+      new MmTelCapabilities(); // start with empty
+  private int imsRegistrationTech = ImsRegistrationImplBase.REGISTRATION_TECH_NONE;
+  private int subId;
+
+  @Implementation(maxSdk = VERSION_CODES.R)
+  protected void __constructor__(int subId) {
+    this.subId = subId;
+  }
+
+  /**
+   * Sets whether IMS is available on the device. Setting this to false will cause {@link
+   * ImsException} to be thrown whenever methods requiring IMS support are invoked including {@link
+   * #registerImsRegistrationCallback(Executor, RegistrationCallback)} and {@link
+   * #registerMmTelCapabilityCallback(Executor, CapabilityCallback)}.
+   */
+  public void setImsAvailableOnDevice(boolean imsAvailableOnDevice) {
+    this.imsAvailableOnDevice = imsAvailableOnDevice;
+  }
+
+  @RequiresPermission(Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
+  @Implementation
+  protected void registerImsRegistrationCallback(
+      @NonNull @CallbackExecutor Executor executor, @NonNull RegistrationCallback c)
+      throws ImsException {
+    if (!imsAvailableOnDevice) {
+      throw new ImsException(
+          "IMS not available on device.", ImsException.CODE_ERROR_UNSUPPORTED_OPERATION);
+    }
+    registrationCallbackExecutorMap.put(c, executor);
+  }
+
+  @RequiresPermission(Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
+  @Implementation
+  protected void unregisterImsRegistrationCallback(@NonNull RegistrationCallback c) {
+    registrationCallbackExecutorMap.remove(c);
+  }
+
+  /**
+   * Triggers {@link RegistrationCallback#onRegistering(int)} for all registered {@link
+   * RegistrationCallback} callbacks.
+   *
+   * @see #registerImsRegistrationCallback(Executor, RegistrationCallback)
+   */
+  public void setImsRegistering(int imsRegistrationTech) {
+    for (Map.Entry<RegistrationCallback, Executor> entry :
+        registrationCallbackExecutorMap.entrySet()) {
+      entry.getValue().execute(() -> entry.getKey().onRegistering(imsRegistrationTech));
+    }
+  }
+
+  /**
+   * Triggers {@link RegistrationCallback#onRegistered(int)} for all registered {@link
+   * RegistrationCallback} callbacks.
+   *
+   * @see #registerImsRegistrationCallback(Executor, RegistrationCallback)
+   */
+  public void setImsRegistered(int imsRegistrationTech) {
+    this.imsRegistrationTech = imsRegistrationTech;
+    for (Map.Entry<RegistrationCallback, Executor> entry :
+        registrationCallbackExecutorMap.entrySet()) {
+      entry.getValue().execute(() -> entry.getKey().onRegistered(imsRegistrationTech));
+    }
+  }
+
+  /**
+   * Triggers {@link RegistrationCallback#onUnregistered(ImsReasonInfo)} for all registered {@link
+   * RegistrationCallback} callbacks.
+   *
+   * @see #registerImsRegistrationCallback(Executor, RegistrationCallback)
+   */
+  public void setImsUnregistered(@NonNull ImsReasonInfo imsReasonInfo) {
+    this.imsRegistrationTech = ImsRegistrationImplBase.REGISTRATION_TECH_NONE;
+    for (Map.Entry<RegistrationCallback, Executor> entry :
+        registrationCallbackExecutorMap.entrySet()) {
+      entry.getValue().execute(() -> entry.getKey().onUnregistered(imsReasonInfo));
+    }
+  }
+
+  @RequiresPermission(Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
+  @Implementation
+  protected void registerMmTelCapabilityCallback(
+      @NonNull @CallbackExecutor Executor executor, @NonNull CapabilityCallback c)
+      throws ImsException {
+    if (!imsAvailableOnDevice) {
+      throw new ImsException(
+          "IMS not available on device.", ImsException.CODE_ERROR_UNSUPPORTED_OPERATION);
+    }
+    capabilityCallbackExecutorMap.put(c, executor);
+  }
+
+  @RequiresPermission(Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
+  @Implementation
+  protected void unregisterMmTelCapabilityCallback(@NonNull CapabilityCallback c) {
+    capabilityCallbackExecutorMap.remove(c);
+  }
+
+  @RequiresPermission(Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
+  @Implementation
+  protected boolean isAvailable(
+      @MmTelCapabilities.MmTelCapability int capability,
+      @ImsRegistrationImplBase.ImsRegistrationTech int imsRegTech) {
+    // Available if MmTelCapability enabled and IMS registered under same tech
+    return mmTelCapabilitiesAvailable.isCapable(capability) && imsRegTech == imsRegistrationTech;
+  }
+
+  /**
+   * Sets the available {@link MmTelCapabilities}. Only invokes {@link
+   * CapabilityCallback#onCapabilitiesStatusChanged(MmTelCapabilities)} if IMS has been registered
+   * using {@link #setImsUnregistered(ImsReasonInfo)}.
+   */
+  public void setMmTelCapabilitiesAvailable(@NonNull MmTelCapabilities capabilities) {
+    this.mmTelCapabilitiesAvailable = capabilities;
+    if (imsRegistrationTech != ImsRegistrationImplBase.REGISTRATION_TECH_NONE) {
+      for (Map.Entry<CapabilityCallback, Executor> entry :
+          capabilityCallbackExecutorMap.entrySet()) {
+        entry.getValue().execute(() -> entry.getKey().onCapabilitiesStatusChanged(capabilities));
+      }
+    }
+  }
+
+  /** Get subscription id */
+  public int getSubscriptionId() {
+    return subId;
+  }
+
+  /** Returns only one instance per subscription id. */
+  @RequiresApi(api = VERSION_CODES.Q)
+  @Implementation
+  protected static ImsMmTelManager createForSubscriptionId(int subId) {
+    if (!SubscriptionManager.isValidSubscriptionId(subId)) {
+      throw new IllegalArgumentException("Invalid subscription ID");
+    }
+
+    if (existingInstances.containsKey(subId)) {
+      return existingInstances.get(subId);
+    }
+    ImsMmTelManager imsMmTelManager =
+        reflector(ImsMmTelManagerReflector.class).createForSubscriptionId(subId);
+    existingInstances.put(subId, imsMmTelManager);
+    return imsMmTelManager;
+  }
+
+  @Resetter
+  public static void clearExistingInstances() {
+    existingInstances.clear();
+  }
+
+  @ForType(ImsMmTelManager.class)
+  interface ImsMmTelManagerReflector {
+
+    @Static
+    @Direct
+    ImsMmTelManager createForSubscriptionId(int subId);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallAdapter.java
new file mode 100644
index 0000000..3f671aa
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallAdapter.java
@@ -0,0 +1,61 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build.VERSION_CODES;
+import android.telecom.CallAudioState;
+import android.telecom.InCallAdapter;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.telecom.InCallAdapter}. */
+@Implements(value = InCallAdapter.class, isInAndroidSdk = false)
+public class ShadowInCallAdapter {
+
+  @RealObject private InCallAdapter inCallAdapter;
+
+  private int audioRoute = CallAudioState.ROUTE_EARPIECE;
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected void setAudioRoute(int route) {
+    audioRoute = route;
+    if (isInternalInCallAdapterSet()) {
+      reflector(ReflectorInCallAdapter.class, inCallAdapter).setAudioRoute(route);
+    }
+  }
+
+  /** Returns audioRoute set by setAudioRoute. Defaults to CallAudioState.ROUTE_EARPIECE. */
+  public int getAudioRoute() {
+    return audioRoute;
+  }
+
+  /**
+   * Checks if the InCallService was bound using {@link
+   * com.android.internal.telecom.IInCallService#setInCallAdapter(com.android.internal.telecom.IInCallAdapter)}.
+   * ;
+   *
+   * <p>If it was bound using this interface, the internal InCallAdapter will be set and it will
+   * forward invocations to FakeTelecomServer.
+   *
+   * <p>Otherwise, invoking these methods will yield NullPointerExceptions, so we will avoid
+   * forwarding the calls to the real objects.
+   */
+  private boolean isInternalInCallAdapterSet() {
+    Object internalAdapter =
+        reflector(ReflectorInCallAdapter.class, inCallAdapter).getInternalInCallAdapter();
+    return internalAdapter != null;
+  }
+
+  @ForType(InCallAdapter.class)
+  interface ReflectorInCallAdapter {
+    @Direct
+    void setAudioRoute(int route);
+
+    @Accessor("mAdapter")
+    Object getInternalInCallAdapter();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java
new file mode 100644
index 0000000..4ab7b89
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java
@@ -0,0 +1,209 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothDevice;
+import android.os.Build.VERSION;
+import android.os.Bundle;
+import android.os.Handler;
+import android.telecom.Call;
+import android.telecom.CallAudioState;
+import android.telecom.InCallAdapter;
+import android.telecom.InCallService;
+import android.telecom.ParcelableCall;
+import android.telecom.Phone;
+import com.android.internal.os.SomeArgs;
+import com.android.internal.telecom.IInCallAdapter;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.telecom.InCallService}. */
+@Implements(value = InCallService.class, minSdk = M)
+public class ShadowInCallService extends ShadowService {
+  @RealObject private InCallService inCallService;
+  private static final int MSG_ADD_CALL = 2;
+  private static final int MSG_SET_POST_DIAL_WAIT = 4;
+  private static final int MSG_ON_CONNECTION_EVENT = 9;
+
+  private ShadowPhone shadowPhone;
+  private boolean canAddCall;
+  private boolean muted;
+  private int audioRoute = CallAudioState.ROUTE_EARPIECE;
+  private BluetoothDevice bluetoothDevice;
+  private int supportedRouteMask;
+
+  @Implementation
+  protected void __constructor__() {
+    InCallAdapter adapter = Shadow.newInstanceOf(InCallAdapter.class);
+    Phone phone;
+    if (VERSION.SDK_INT > N_MR1) {
+      phone =
+          ReflectionHelpers.callConstructor(
+              Phone.class,
+              ClassParameter.from(InCallAdapter.class, adapter),
+              ClassParameter.from(String.class, ""),
+              ClassParameter.from(int.class, 0));
+    } else {
+      phone =
+          ReflectionHelpers.callConstructor(
+              Phone.class, ClassParameter.from(InCallAdapter.class, adapter));
+    }
+    shadowPhone = Shadow.extract(phone);
+    ReflectionHelpers.setField(inCallService, "mPhone", phone);
+    invokeConstructor(InCallService.class, inCallService);
+  }
+
+  public void addCall(Call call) {
+    shadowPhone.addCall(call);
+  }
+
+  public void addCall(ParcelableCall parcelableCall) {
+    getHandler().obtainMessage(MSG_ADD_CALL, parcelableCall).sendToTarget();
+  }
+
+  /**
+   * Exposes {@link IIInCallService.Stub#setPostDialWait}. This is normally invoked by Telecom but
+   * in Robolectric, Telecom doesn't exist, so tests can invoke this to simulate Telecom's actions.
+   */
+  public void setPostDialWait(String callId, String remaining) {
+    SomeArgs args = SomeArgs.obtain();
+    args.arg1 = callId;
+    args.arg2 = remaining;
+    getHandler().obtainMessage(MSG_SET_POST_DIAL_WAIT, args).sendToTarget();
+  }
+
+  /**
+   * Exposes {@link IIInCallService.Stub#onConnectionEvent}. This is normally invoked by Telecom but
+   * in Robolectric, Telecom doesn't exist, so tests can invoke this to simulate Telecom's actions.
+   */
+  public void onConnectionEvent(String callId, String event, Bundle extras) {
+    SomeArgs args = SomeArgs.obtain();
+    args.arg1 = callId;
+    args.arg2 = event;
+    args.arg3 = extras;
+    getHandler().obtainMessage(MSG_ON_CONNECTION_EVENT, args).sendToTarget();
+  }
+
+  public void removeCall(Call call) {
+    shadowPhone.removeCall(call);
+  }
+
+  @Implementation
+  protected boolean canAddCall() {
+    return canAddCall;
+  }
+
+  /** Set the value that {@code canAddCall()} method should return. */
+  public void setCanAddCall(boolean canAddCall) {
+    this.canAddCall = canAddCall;
+  }
+
+  @Implementation
+  protected void setMuted(boolean muted) {
+    this.muted = muted;
+    if (isInCallAdapterSet()) {
+      reflector(ReflectorInCallService.class, inCallService).setMuted(muted);
+    }
+  }
+
+  @Implementation
+  protected void setAudioRoute(int audioRoute) {
+    this.audioRoute = audioRoute;
+    if (isInCallAdapterSet()) {
+      reflector(ReflectorInCallService.class, inCallService).setAudioRoute(audioRoute);
+    }
+  }
+
+  @Implementation
+  protected CallAudioState getCallAudioState() {
+    if (isInCallAdapterSet()) {
+      return reflector(ReflectorInCallService.class, inCallService).getCallAudioState();
+    }
+    return new CallAudioState(muted, audioRoute, supportedRouteMask);
+  }
+
+  public void setSupportedRouteMask(int mask) {
+    this.supportedRouteMask = mask;
+  }
+
+  @Implementation(minSdk = P)
+  protected void requestBluetoothAudio(BluetoothDevice bluetoothDevice) {
+    this.bluetoothDevice = bluetoothDevice;
+    if (isInCallAdapterSet()) {
+      reflector(ReflectorInCallService.class, inCallService).requestBluetoothAudio(bluetoothDevice);
+    }
+  }
+
+  /** @return the last value provided to {@code requestBluetoothAudio()}. */
+  @TargetApi(P)
+  public BluetoothDevice getBluetoothAudio() {
+    return bluetoothDevice;
+  }
+
+  private Handler getHandler() {
+    return reflector(ReflectorInCallService.class, inCallService).getHandler();
+  }
+
+  /**
+   * Checks if the InCallService was bound using {@link
+   * com.android.internal.telecom.IInCallService#setInCallAdapter(IInCallAdapter)}.
+   *
+   * <p>If it was bound using this interface, the internal InCallAdapter will be set and it will
+   * forward invocations to FakeTelecomServer.
+   *
+   * <p>Otherwise, invoking these methods will yield NullPointerExceptions, so we will avoid
+   * forwarding the calls to the real objects.
+   */
+  private boolean isInCallAdapterSet() {
+    Phone phone = reflector(ReflectorInCallService.class, inCallService).getPhone();
+    InCallAdapter inCallAdapter = reflector(ReflectorPhone.class, phone).getInCallAdapter();
+    Object internalAdapter =
+        reflector(ReflectorInCallAdapter.class, inCallAdapter).getInternalInCallAdapter();
+    return internalAdapter != null;
+  }
+
+  @ForType(InCallService.class)
+  interface ReflectorInCallService {
+    @Accessor("mHandler")
+    Handler getHandler();
+
+    @Accessor("mPhone")
+    Phone getPhone();
+
+    @Direct
+    void requestBluetoothAudio(BluetoothDevice bluetoothDevice);
+
+    @Direct
+    void setAudioRoute(int audioRoute);
+
+    @Direct
+    void setMuted(boolean muted);
+
+    @Direct
+    CallAudioState getCallAudioState();
+  }
+
+  @ForType(Phone.class)
+  interface ReflectorPhone {
+    @Accessor("mInCallAdapter")
+    InCallAdapter getInCallAdapter();
+  }
+
+  @ForType(InCallAdapter.class)
+  interface ReflectorInCallAdapter {
+    @Accessor("mAdapter")
+    Object getInternalInCallAdapter();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIncidentManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIncidentManager.java
new file mode 100644
index 0000000..d910f35
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIncidentManager.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.net.Uri;
+import android.os.IncidentManager;
+import android.os.IncidentManager.IncidentReport;
+import androidx.annotation.NonNull;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow class for {@link IncidentManager}. */
+@Implements(value = IncidentManager.class, minSdk = R, isInAndroidSdk = false)
+public class ShadowIncidentManager {
+
+  private final Map<Uri, IncidentReport> reports = new LinkedHashMap<>();
+
+  @NonNull
+  @Implementation
+  protected List<Uri> getIncidentReportList(String receiverClass) {
+    return new ArrayList<>(reports.keySet());
+  }
+
+  @Implementation
+  protected IncidentReport getIncidentReport(Uri uri) {
+    return reports.get(uri);
+  }
+
+  @Implementation
+  protected void deleteIncidentReports(Uri uri) {
+    reports.remove(uri);
+  }
+
+  /** Add {@link IncidentReport} to the list of reported incidents. */
+  public void addIncidentReport(Uri uri, IncidentReport report) {
+    reports.put(uri, report);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInetAddressUtils.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInetAddressUtils.java
new file mode 100644
index 0000000..3ef8146
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInetAddressUtils.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import libcore.net.InetAddressUtils;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow InetAddressUtils class that doesn't use native Libcore function. */
+@Implements(value = InetAddressUtils.class, minSdk = Build.VERSION_CODES.Q, isInAndroidSdk = false)
+public class ShadowInetAddressUtils {
+  @Implementation
+  protected static InetAddress parseNumericAddressNoThrow(String address) {
+    try {
+      return InetAddress.getByName(address);
+    } catch (UnknownHostException e) {
+      return null;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputDevice.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputDevice.java
new file mode 100644
index 0000000..7094856
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputDevice.java
@@ -0,0 +1,49 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+
+import android.view.InputDevice;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(InputDevice.class)
+public class ShadowInputDevice {
+  private String deviceName;
+  private int productId;
+  private int vendorId;
+
+  public static InputDevice makeInputDeviceNamed(String deviceName) {
+    InputDevice inputDevice = Shadow.newInstanceOf(InputDevice.class);
+    ShadowInputDevice shadowInputDevice = Shadow.extract(inputDevice);
+    shadowInputDevice.setDeviceName(deviceName);
+    return inputDevice;
+  }
+
+  @Implementation
+  protected String getName() {
+    return deviceName;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected int getProductId() {
+    return productId;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected int getVendorId() {
+    return vendorId;
+  }
+
+  public void setDeviceName(String deviceName) {
+    this.deviceName = deviceName;
+  }
+
+  public void setProductId(int productId) {
+    this.productId = productId;
+  }
+
+  public void setVendorId(int vendorId) {
+    this.vendorId = vendorId;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputEvent.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputEvent.java
new file mode 100644
index 0000000..86aa12b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputEvent.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import android.view.InputDevice;
+import android.view.InputEvent;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(InputEvent.class)
+public class ShadowInputEvent {
+  protected InputDevice device;
+
+  @Implementation
+  protected InputDevice getDevice() {
+    return device;
+  }
+
+  public void setDevice(InputDevice device) {
+    this.device = device;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputEventReceiver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputEventReceiver.java
new file mode 100644
index 0000000..9082a5b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputEventReceiver.java
@@ -0,0 +1,47 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+
+import android.view.InputEventReceiver;
+import dalvik.system.CloseGuard;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = InputEventReceiver.class, isInAndroidSdk = false)
+public class ShadowInputEventReceiver {
+
+  @ReflectorObject private InputEventReceiverReflector inputEventReceiverReflector;
+
+  @Implementation
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  public void consumeBatchedInputEvents(long frameTimeNanos) {
+    // The real implementation of this calls a JNI method, and logs a statement if the native
+    // object isn't present. Since the native object will never be present in Robolectric tests, it
+    // ends up being rather spammy in test logs, so we no-op it.
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void dispose(boolean finalized) {
+    CloseGuard closeGuard = inputEventReceiverReflector.getCloseGuard();
+    // Suppresses noisy CloseGuard warning
+    if (closeGuard != null) {
+      closeGuard.close();
+    }
+    inputEventReceiverReflector.dispose(finalized);
+  }
+
+  /** Reflector interface for {@link InputEventReceiver}'s internals. */
+  @ForType(InputEventReceiver.class)
+  interface InputEventReceiverReflector {
+
+    @Direct
+    void dispose(boolean finalized);
+
+    @Accessor("mCloseGuard")
+    CloseGuard getCloseGuard();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java
new file mode 100644
index 0000000..50635c8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java
@@ -0,0 +1,83 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.R;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.hardware.input.InputManager;
+import android.view.InputDevice;
+import android.view.InputEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.VerifiedKeyEvent;
+import android.view.VerifiedMotionEvent;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow for {@link InputManager} */
+@Implements(value = InputManager.class, looseSignatures = true)
+public class ShadowInputManager {
+
+  @Implementation
+  protected boolean injectInputEvent(InputEvent event, int mode) {
+    // ignore
+    return true;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected boolean[] deviceHasKeys(int id, int[] keyCodes) {
+    return new boolean[keyCodes.length];
+  }
+
+  /** Used in {@link InputDevice#getDeviceIds()} */
+  @Implementation
+  protected int[] getInputDeviceIds() {
+    return new int[0];
+  }
+
+  /**
+   * Provides a local java implementation, since the real implementation is in system server +
+   * native code.
+   */
+  @Implementation(minSdk = R)
+  protected Object verifyInputEvent(Object inputEvent) {
+    if (inputEvent instanceof MotionEvent) {
+      MotionEvent motionEvent = (MotionEvent) inputEvent;
+      return new VerifiedMotionEvent(
+          motionEvent.getDeviceId(),
+          MILLISECONDS.toNanos(motionEvent.getEventTime()),
+          motionEvent.getSource(),
+          motionEvent.getDisplayId(),
+          motionEvent.getRawX(),
+          motionEvent.getRawY(),
+          motionEvent.getActionMasked(),
+          MILLISECONDS.toNanos(motionEvent.getDownTime()),
+          motionEvent.getFlags(),
+          motionEvent.getMetaState(),
+          motionEvent.getButtonState());
+    } else if (inputEvent instanceof KeyEvent) {
+      KeyEvent keyEvent = (KeyEvent) inputEvent;
+      return new VerifiedKeyEvent(
+          keyEvent.getDeviceId(),
+          MILLISECONDS.toNanos(keyEvent.getEventTime()),
+          keyEvent.getSource(),
+          keyEvent.getDisplayId(),
+          keyEvent.getAction(),
+          MILLISECONDS.toNanos(keyEvent.getDownTime()),
+          keyEvent.getFlags(),
+          keyEvent.getKeyCode(),
+          keyEvent.getScanCode(),
+          keyEvent.getMetaState(),
+          keyEvent.getRepeatCount());
+    } else {
+      throw new IllegalArgumentException("unknown input event: " + inputEvent.getClass().getName());
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    ReflectionHelpers.setStaticField(InputManager.class, "sInstance", null);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java
new file mode 100644
index 0000000..ee25a81
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java
@@ -0,0 +1,297 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.ResultReceiver;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.inputmethod.CompletionInfo;
+import android.view.inputmethod.InputMethodInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.view.inputmethod.InputMethodSubtype;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = InputMethodManager.class)
+public class ShadowInputMethodManager {
+
+  /**
+   * Handler for receiving soft input visibility changed event.
+   *
+   * <p>Since Android does not have any API for retrieving soft input status, most application
+   * relies on GUI layout changes to detect the soft input change event. Currently, Robolectric are
+   * not able to simulate the GUI change when application changes the soft input through {@code
+   * InputMethodManager}, this handler can be used by application to simulate GUI change in response
+   * of the soft input change.
+   */
+  public interface SoftInputVisibilityChangeHandler {
+
+    void handleSoftInputVisibilityChange(boolean softInputVisible);
+  }
+
+  /** Handler for receiving PrivateCommands. */
+  public interface PrivateCommandListener {
+    void onPrivateCommand(View view, String action, Bundle data);
+  }
+
+  private boolean softInputVisible;
+  private Optional<SoftInputVisibilityChangeHandler> visibilityChangeHandler = Optional.absent();
+  private Optional<PrivateCommandListener> privateCommandListener = Optional.absent();
+  private List<InputMethodInfo> inputMethodInfoList = ImmutableList.of();
+  private List<InputMethodInfo> enabledInputMethodInfoList = ImmutableList.of();
+  private Optional<InputMethodSubtype> inputMethodSubtype = Optional.absent();
+
+  @Implementation
+  protected boolean showSoftInput(View view, int flags) {
+    return showSoftInput(view, flags, null);
+  }
+
+  @Implementation(maxSdk = R)
+  protected boolean showSoftInput(View view, int flags, ResultReceiver resultReceiver) {
+    setSoftInputVisibility(true);
+    return true;
+  }
+
+  @Implementation(minSdk = S)
+  protected boolean showSoftInput(
+      View view, int flags, ResultReceiver resultReceiver, int ignoredReason) {
+    return showSoftInput(view, flags, resultReceiver);
+  }
+
+  @Implementation(minSdk = S)
+  protected boolean hideSoftInputFromWindow(
+      IBinder windowToken, int flags, ResultReceiver resultReceiver, int ignoredReason) {
+    return hideSoftInputFromWindow(windowToken, flags, resultReceiver);
+  }
+
+  @Implementation(maxSdk = R)
+  protected boolean hideSoftInputFromWindow(IBinder windowToken, int flags) {
+    return hideSoftInputFromWindow(windowToken, flags, null);
+  }
+
+  @Implementation
+  protected boolean hideSoftInputFromWindow(
+      IBinder windowToken, int flags, ResultReceiver resultReceiver) {
+    int resultCode;
+    if (isSoftInputVisible()) {
+      setSoftInputVisibility(false);
+      resultCode = InputMethodManager.RESULT_HIDDEN;
+    } else {
+      resultCode = InputMethodManager.RESULT_UNCHANGED_HIDDEN;
+    }
+
+    if (resultReceiver != null) {
+      resultReceiver.send(resultCode, null);
+    }
+    return true;
+  }
+
+  @Implementation
+  protected void toggleSoftInput(int showFlags, int hideFlags) {
+    setSoftInputVisibility(!isSoftInputVisible());
+  }
+
+  public boolean isSoftInputVisible() {
+    return softInputVisible;
+  }
+
+  public void setSoftInputVisibilityHandler(
+      SoftInputVisibilityChangeHandler visibilityChangeHandler) {
+    this.visibilityChangeHandler =
+        Optional.<SoftInputVisibilityChangeHandler>of(visibilityChangeHandler);
+  }
+
+  private void setSoftInputVisibility(boolean visible) {
+    if (visible == softInputVisible) {
+      return;
+    }
+    softInputVisible = visible;
+    if (visibilityChangeHandler.isPresent()) {
+      visibilityChangeHandler.get().handleSoftInputVisibilityChange(softInputVisible);
+    }
+  }
+
+  /**
+   * The framework implementation does a blocking call to system server. This will deadlock on
+   * Robolectric, so just stub out the method.
+   */
+  @Implementation(minSdk = S)
+  protected void closeCurrentInput() {}
+
+  /**
+   * Returns the list of {@link InputMethodInfo} that are installed.
+   *
+   * <p>This method differs from Android implementation by allowing the list to be set using {@link
+   * #setInputMethodInfoList(List)}.
+   */
+  @Implementation
+  protected List<InputMethodInfo> getInputMethodList() {
+    return inputMethodInfoList;
+  }
+
+  /**
+   * Sets the list of {@link InputMethodInfo} that are marked as installed. See {@link
+   * #getInputMethodList()}.
+   */
+  public void setInputMethodInfoList(List<InputMethodInfo> inputMethodInfoList) {
+    this.inputMethodInfoList = inputMethodInfoList;
+  }
+
+  /**
+   * Returns the {@link InputMethodSubtype} that is installed.
+   *
+   * <p>This method differs from Android implementation by allowing the list to be set using {@link
+   * #setCurrentInputMethodSubtype(InputMethodSubtype)}.
+   */
+  @Implementation
+  protected InputMethodSubtype getCurrentInputMethodSubtype() {
+    return inputMethodSubtype.orNull();
+  }
+
+  /**
+   * Sets the current {@link InputMethodSubtype} that will be returned by {@link
+   * #getCurrentInputMethodSubtype()}.
+   */
+  public void setCurrentInputMethodSubtype(InputMethodSubtype inputMethodSubtype) {
+    this.inputMethodSubtype = Optional.of(inputMethodSubtype);
+  }
+
+  /**
+   * Returns the list of {@link InputMethodInfo} that are enabled.
+   *
+   * <p>This method differs from Android implementation by allowing the list to be set using {@link
+   * #setEnabledInputMethodInfoList(List)}.
+   */
+  @Implementation
+  protected List<InputMethodInfo> getEnabledInputMethodList() {
+    return enabledInputMethodInfoList;
+  }
+
+  /**
+   * Sets the list of {@link InputMethodInfo} that are marked as enabled. See {@link
+   * #getEnabledInputMethodList()}.
+   */
+  public void setEnabledInputMethodInfoList(List<InputMethodInfo> inputMethodInfoList) {
+    this.enabledInputMethodInfoList = inputMethodInfoList;
+  }
+
+  @Implementation
+  protected void restartInput(View view) {}
+
+  @Implementation
+  protected boolean isActive(View view) {
+    return false;
+  }
+
+  @Implementation
+  protected boolean isActive() {
+    return false;
+  }
+
+  @Implementation
+  protected boolean isFullscreenMode() {
+    return false;
+  }
+
+  @Implementation(maxSdk = Q)
+  protected void focusIn(View view) {}
+
+  @Implementation(minSdk = M, maxSdk = Q)
+  protected void onViewDetachedFromWindow(View view) {}
+
+  @Implementation
+  protected void displayCompletions(View view, CompletionInfo[] completions) {}
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected static InputMethodManager peekInstance() {
+    // Android has a bug pre M where peekInstance was dereferenced without a null check:-
+    // https://github.com/aosp-mirror/platform_frameworks_base/commit/a046faaf38ad818e6b5e981a39fd7394cf7cee03
+    // So for earlier versions, just call through directly to getInstance()
+    if (RuntimeEnvironment.getApiLevel() <= JELLY_BEAN_MR1) {
+      return ReflectionHelpers.callStaticMethod(
+          InputMethodManager.class,
+          "getInstance",
+          ClassParameter.from(Looper.class, Looper.getMainLooper()));
+    } else if (RuntimeEnvironment.getApiLevel() <= LOLLIPOP_MR1) {
+      return InputMethodManager.getInstance();
+    }
+    return reflector(_InputMethodManager_.class).peekInstance();
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean startInputInner(
+      int startInputReason,
+      IBinder windowGainingFocus,
+      int startInputFlags,
+      int softInputMode,
+      int windowFlags) {
+    return true;
+  }
+
+  @Implementation(minSdk = M)
+  protected void sendAppPrivateCommand(View view, String action, Bundle data) {
+    if (privateCommandListener.isPresent()) {
+      privateCommandListener.get().onPrivateCommand(view, action, data);
+    }
+  }
+
+  public void setAppPrivateCommandListener(PrivateCommandListener listener) {
+    privateCommandListener = Optional.of(listener);
+  }
+
+  @Resetter
+  public static void reset() {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    _InputMethodManager_ _reflector = reflector(_InputMethodManager_.class);
+    if (apiLevel <= JELLY_BEAN_MR1) {
+      _reflector.setMInstance(null);
+    } else {
+      _reflector.setInstance(null);
+    }
+    if (apiLevel > P) {
+      _reflector.getInstanceMap().clear();
+    }
+  }
+
+  @ForType(InputMethodManager.class)
+  interface _InputMethodManager_ {
+
+    @Static
+    @Direct
+    InputMethodManager peekInstance();
+
+    @Static
+    @Accessor("mInstance")
+    void setMInstance(InputMethodManager instance);
+
+    @Static
+    @Accessor("sInstance")
+    void setInstance(InputMethodManager instance);
+
+    @Static
+    @Accessor("sInstanceMap")
+    SparseArray<InputMethodManager> getInstanceMap();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInstrumentation.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInstrumentation.java
new file mode 100644
index 0000000..e76b797
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInstrumentation.java
@@ -0,0 +1,1174 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Activity;
+import android.app.ActivityThread;
+import android.app.Fragment;
+import android.app.IUiAutomationConnection;
+import android.app.Instrumentation;
+import android.app.Instrumentation.ActivityResult;
+import android.app.UiAutomation;
+import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.content.Intent.FilterComparison;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Process;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.util.Pair;
+import androidx.annotation.Nullable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowActivity.IntentForResult;
+import org.robolectric.shadows.ShadowApplication.Wrapper;
+import org.robolectric.util.Logger;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.WithType;
+
+@Implements(value = Instrumentation.class, looseSignatures = true)
+public class ShadowInstrumentation {
+
+  @RealObject private Instrumentation realObject;
+
+  private final List<Intent> startedActivities = Collections.synchronizedList(new ArrayList<>());
+  private final List<IntentForResult> startedActivitiesForResults =
+      Collections.synchronizedList(new ArrayList<>());
+  private final Map<FilterComparison, TargetAndRequestCode> intentRequestCodeMap =
+      Collections.synchronizedMap(new HashMap<>());
+  private final List<Intent.FilterComparison> startedServices =
+      Collections.synchronizedList(new ArrayList<>());
+  private final List<Intent.FilterComparison> stoppedServices =
+      Collections.synchronizedList(new ArrayList<>());
+  private final List<Intent> broadcastIntents = Collections.synchronizedList(new ArrayList<>());
+  private final Map<Intent, Bundle> broadcastOptions = Collections.synchronizedMap(new HashMap<>());
+  private final Map<UserHandle, List<Intent>> broadcastIntentsForUser =
+      Collections.synchronizedMap(new HashMap<>());
+  private final List<ServiceConnection> boundServiceConnections =
+      Collections.synchronizedList(new ArrayList<>());
+  private final List<ServiceConnection> unboundServiceConnections =
+      Collections.synchronizedList(new ArrayList<>());
+
+  @GuardedBy("itself")
+  private final List<Wrapper> registeredReceivers = new ArrayList<>();
+  // map of pid+uid to granted permissions
+  private final Map<Pair<Integer, Integer>, Set<String>> grantedPermissionsMap =
+      Collections.synchronizedMap(new HashMap<>());
+  private boolean unbindServiceShouldThrowIllegalArgument = false;
+  private SecurityException exceptionForBindService = null;
+  private boolean bindServiceCallsOnServiceConnectedInline;
+  private final Map<Intent.FilterComparison, ServiceConnectionDataWrapper>
+      serviceConnectionDataForIntent = Collections.synchronizedMap(new HashMap<>());
+  // default values for bindService
+  private ServiceConnectionDataWrapper defaultServiceConnectionData =
+      new ServiceConnectionDataWrapper(null, null);
+  private final List<String> unbindableActions = Collections.synchronizedList(new ArrayList<>());
+  private final List<ComponentName> unbindableComponents =
+      Collections.synchronizedList(new ArrayList<>());
+  private final Map<String, Intent> stickyIntents =
+      Collections.synchronizedMap(new LinkedHashMap<>());
+  private Handler mainHandler;
+  private final Map<ServiceConnection, ServiceConnectionDataWrapper>
+      serviceConnectionDataForServiceConnection = Collections.synchronizedMap(new HashMap<>());
+
+  private boolean checkActivities;
+  // This will default to False in the future to correctly mirror real Android behavior.
+  private boolean unbindServiceCallsOnServiceDisconnected = true;
+  @Nullable private UiAutomation uiAutomation;
+
+  @Implementation(minSdk = P)
+  protected Activity startActivitySync(Intent intent, Bundle options) {
+    throw new UnsupportedOperationException("Implement me!!");
+  }
+
+  @Implementation
+  protected void execStartActivities(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      Activity target,
+      Intent[] intents,
+      Bundle options) {
+    for (Intent intent : intents) {
+      execStartActivity(who, contextThread, token, target, intent, -1, options);
+    }
+  }
+
+  @Implementation
+  protected ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      Activity target,
+      Intent intent,
+      int requestCode,
+      Bundle options) {
+
+    verifyActivityInManifest(intent);
+    logStartedActivity(intent, null, requestCode, options);
+
+    if (who == null) {
+      return null;
+    }
+    return reflector(_Instrumentation_.class, realObject)
+        .execStartActivity(who, contextThread, token, target, intent, requestCode, options);
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      Fragment target,
+      Intent intent,
+      int requestCode,
+      Bundle options) {
+    verifyActivityInManifest(intent);
+    logStartedActivity(intent, null, requestCode, options);
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      String target,
+      Intent intent,
+      int requestCode,
+      Bundle options) {
+    verifyActivityInManifest(intent);
+    logStartedActivity(intent, target, requestCode, options);
+
+    return reflector(_Instrumentation_.class, realObject)
+        .execStartActivity(who, contextThread, token, target, intent, requestCode, options);
+  }
+
+  /**
+   * Behaves as {@link #execStartActivity(Context, IBinder, IBinder, Activity, Intent, int, Bundle).
+   *
+   * <p>Currently ignores the user.
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR1, maxSdk = N_MR1)
+  protected ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      Activity resultWho,
+      Intent intent,
+      int requestCode,
+      Bundle options,
+      UserHandle user) {
+    return execStartActivity(who, contextThread, token, resultWho, intent, requestCode, options);
+  }
+
+  /**
+   * Behaves as {@link #execStartActivity(Context, IBinder, IBinder, String, Intent, int, Bundle).
+   *
+   * <p>Currently ignores the user.
+   */
+  @Implementation(minSdk = O)
+  protected ActivityResult execStartActivity(
+      Context who,
+      IBinder contextThread,
+      IBinder token,
+      String resultWho,
+      Intent intent,
+      int requestCode,
+      Bundle options,
+      UserHandle user) {
+    return execStartActivity(who, contextThread, token, resultWho, intent, requestCode, options);
+  }
+
+  @Implementation
+  protected void setInTouchMode(boolean inTouchMode) {
+    ShadowWindowManagerGlobal.setInTouchMode(inTouchMode);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = M)
+  protected UiAutomation getUiAutomation() {
+    return getUiAutomation(0);
+  }
+
+  @Implementation(minSdk = N)
+  protected UiAutomation getUiAutomation(int flags) {
+    if (uiAutomation == null) {
+      // Create a new automation using reflection, the real code just connects through the
+      // automation connection and to the accessibility service, neither of which exist in
+      // Robolectric.
+      uiAutomation =
+          ReflectionHelpers.callConstructor(
+              UiAutomation.class,
+              ClassParameter.from(Looper.class, Looper.getMainLooper()),
+              ClassParameter.from(
+                  IUiAutomationConnection.class,
+                  ReflectionHelpers.createNullProxy(IUiAutomationConnection.class)));
+    }
+    return uiAutomation;
+  }
+
+  private void logStartedActivity(Intent intent, String target, int requestCode, Bundle options) {
+    startedActivities.add(intent);
+    intentRequestCodeMap.put(
+        new FilterComparison(intent), new TargetAndRequestCode(target, requestCode));
+    startedActivitiesForResults.add(new IntentForResult(intent, requestCode, options));
+  }
+
+  private void verifyActivityInManifest(Intent intent) {
+    if (checkActivities
+        && RuntimeEnvironment.getApplication()
+                .getPackageManager()
+                .resolveActivity(intent, MATCH_DEFAULT_ONLY)
+            == null) {
+      throw new ActivityNotFoundException(intent.getAction());
+    }
+  }
+
+  void sendOrderedBroadcastAsUser(
+      Intent intent,
+      UserHandle userHandle,
+      String receiverPermission,
+      BroadcastReceiver resultReceiver,
+      Handler scheduler,
+      int initialCode,
+      String initialData,
+      Bundle initialExtras,
+      Context context) {
+    List<Wrapper> receivers =
+        getAppropriateWrappers(
+            context, userHandle, intent, receiverPermission, /* broadcastOptions= */ null);
+    sortByPriority(receivers);
+    if (resultReceiver != null) {
+      receivers.add(new Wrapper(resultReceiver, null, context, null, scheduler, 0));
+    }
+    postOrderedToWrappers(receivers, intent, initialCode, initialData, initialExtras, context);
+  }
+
+  void assertNoBroadcastListenersOfActionRegistered(ContextWrapper context, String action) {
+    synchronized (registeredReceivers) {
+      for (Wrapper registeredReceiver : registeredReceivers) {
+        if (registeredReceiver.context == context.getBaseContext()) {
+          Iterator<String> actions = registeredReceiver.intentFilter.actionsIterator();
+          while (actions.hasNext()) {
+            if (actions.next().equals(action)) {
+              RuntimeException e =
+                  new IllegalStateException(
+                      "Unexpected BroadcastReceiver on "
+                          + context
+                          + " with action "
+                          + action
+                          + " "
+                          + registeredReceiver.broadcastReceiver
+                          + " that was originally registered here:");
+              e.setStackTrace(registeredReceiver.exception.getStackTrace());
+              throw e;
+            }
+          }
+        }
+      }
+    }
+  }
+
+  /** Returns the BroadcaseReceivers wrappers, matching intent's action and permissions. */
+  private List<Wrapper> getAppropriateWrappers(
+      Context context,
+      @Nullable UserHandle userHandle,
+      Intent intent,
+      String receiverPermission,
+      @Nullable Bundle broadcastOptions) {
+    broadcastIntents.add(intent);
+    this.broadcastOptions.put(intent, broadcastOptions);
+
+    if (userHandle != null) {
+      List<Intent> intentsForUser = broadcastIntentsForUser.get(userHandle);
+      if (intentsForUser == null) {
+        intentsForUser = new ArrayList<>();
+        broadcastIntentsForUser.put(userHandle, intentsForUser);
+      }
+      intentsForUser.add(intent);
+    }
+
+    List<Wrapper> result = new ArrayList<>();
+
+    List<Wrapper> copy = new ArrayList<>();
+    synchronized (registeredReceivers) {
+      copy.addAll(registeredReceivers);
+    }
+
+    for (Wrapper wrapper : copy) {
+      if (broadcastReceiverMatchesIntent(context, wrapper, intent, receiverPermission)) {
+        result.add(wrapper);
+      }
+    }
+    System.err.format("Intent = %s; Matching wrappers: %s\n", intent, result);
+    return result;
+  }
+
+  private static boolean broadcastReceiverMatchesIntent(
+      Context broadcastContext, Wrapper wrapper, Intent intent, String receiverPermission) {
+    String intentClass =
+        intent.getComponent() != null ? intent.getComponent().getClassName() : null;
+    boolean matchesIntentClass =
+        intentClass != null && intentClass.equals(wrapper.broadcastReceiver.getClass().getName());
+
+    // The receiver must hold the permission specified by sendBroadcast, and the broadcaster must
+    // hold the permission specified by registerReceiver.
+    boolean hasPermissionFromManifest =
+        hasRequiredPermissionForBroadcast(wrapper.context, receiverPermission)
+            && hasRequiredPermissionForBroadcast(broadcastContext, wrapper.broadcastPermission);
+    // Many existing tests don't declare manifest permissions, relying on the old equality check.
+    boolean hasPermissionForBackwardsCompatibility =
+        TextUtils.equals(receiverPermission, wrapper.broadcastPermission);
+    boolean hasPermission = hasPermissionFromManifest || hasPermissionForBackwardsCompatibility;
+
+    boolean matchesAction = wrapper.intentFilter.matchAction(intent.getAction());
+
+    final int match =
+        wrapper.intentFilter.matchData(intent.getType(), intent.getScheme(), intent.getData());
+    boolean matchesDataAndType =
+        match != IntentFilter.NO_MATCH_DATA && match != IntentFilter.NO_MATCH_TYPE;
+
+    return matchesIntentClass || (hasPermission && matchesAction && matchesDataAndType);
+  }
+
+  /** A null {@code requiredPermission} indicates that no permission is required. */
+  private static boolean hasRequiredPermissionForBroadcast(
+      Context context, @Nullable String requiredPermission) {
+    if (requiredPermission == null) {
+      return true;
+    }
+    // Check manifest-based permissions from PackageManager.
+    Context applicationContext = RuntimeEnvironment.getApplication();
+    if (applicationContext
+            .getPackageManager()
+            .checkPermission(requiredPermission, context.getPackageName())
+        == PERMISSION_GRANTED) {
+      return true;
+    }
+    // Check dynamically-granted permissions from here in ShadowInstrumentation.
+    if (Objects.equals(context.getPackageName(), applicationContext.getPackageName())
+        && applicationContext.checkPermission(requiredPermission, Process.myPid(), Process.myUid())
+            == PERMISSION_GRANTED) {
+      return true;
+    }
+    return false;
+  }
+
+  private void postIntent(
+      Intent intent, Wrapper wrapper, final AtomicBoolean abort, Context context, int resultCode) {
+    final Handler scheduler =
+        (wrapper.scheduler != null) ? wrapper.scheduler : getMainHandler(context);
+    final BroadcastReceiver receiver = wrapper.broadcastReceiver;
+    final ShadowBroadcastReceiver shReceiver = Shadow.extract(receiver);
+    final Intent broadcastIntent = intent;
+    scheduler.post(
+        new Runnable() {
+          @Override
+          public void run() {
+            receiver.setPendingResult(
+                ShadowBroadcastPendingResult.create(resultCode, null, null, false));
+            shReceiver.onReceive(context, broadcastIntent, abort);
+          }
+        });
+  }
+
+  private void postToWrappers(
+      List<Wrapper> wrappers, Intent intent, Context context, int resultCode) {
+    AtomicBoolean abort =
+        new AtomicBoolean(false); // abort state is shared among all broadcast receivers
+    for (Wrapper wrapper : wrappers) {
+      postIntent(intent, wrapper, abort, context, resultCode);
+    }
+  }
+
+  private void postOrderedToWrappers(
+      List<Wrapper> wrappers,
+      final Intent intent,
+      int initialCode,
+      String data,
+      Bundle extras,
+      final Context context) {
+    final AtomicBoolean abort =
+        new AtomicBoolean(false); // abort state is shared among all broadcast receivers
+    ListenableFuture<BroadcastResultHolder> future =
+        immediateFuture(new BroadcastResultHolder(initialCode, data, extras));
+    for (final Wrapper wrapper : wrappers) {
+      future = postIntent(wrapper, intent, future, abort, context);
+    }
+    final ListenableFuture<?> finalFuture = future;
+    future.addListener(
+        new Runnable() {
+          @Override
+          public void run() {
+            getMainHandler(context)
+                .post(
+                    new Runnable() {
+                      @Override
+                      public void run() {
+                        try {
+                          finalFuture.get();
+                        } catch (InterruptedException | ExecutionException e) {
+                          throw new RuntimeException(e);
+                        }
+                      }
+                    });
+          }
+        },
+        directExecutor());
+  }
+
+  /**
+   * Enforces that BroadcastReceivers invoked during an ordered broadcast run serially, passing
+   * along their results.
+   */
+  private ListenableFuture<BroadcastResultHolder> postIntent(
+      final Wrapper wrapper,
+      final Intent intent,
+      ListenableFuture<BroadcastResultHolder> oldResult,
+      final AtomicBoolean abort,
+      final Context context) {
+    final Handler scheduler =
+        (wrapper.scheduler != null) ? wrapper.scheduler : getMainHandler(context);
+    return Futures.transformAsync(
+        oldResult,
+        new AsyncFunction<BroadcastResultHolder, BroadcastResultHolder>() {
+          @Override
+          public ListenableFuture<BroadcastResultHolder> apply(
+              BroadcastResultHolder broadcastResultHolder) throws Exception {
+            final BroadcastReceiver.PendingResult result =
+                ShadowBroadcastPendingResult.create(
+                    broadcastResultHolder.resultCode,
+                    broadcastResultHolder.resultData,
+                    broadcastResultHolder.resultExtras,
+                    true /*ordered */);
+            wrapper.broadcastReceiver.setPendingResult(result);
+            scheduler.post(
+                () -> {
+                  ShadowBroadcastReceiver shadowBroadcastReceiver =
+                      Shadow.extract(wrapper.broadcastReceiver);
+                  shadowBroadcastReceiver.onReceive(context, intent, abort);
+                });
+            return BroadcastResultHolder.transform(result);
+          }
+        },
+        directExecutor());
+  }
+
+  /**
+   * Broadcasts the {@code Intent} by iterating through the registered receivers, invoking their
+   * filters including permissions, and calling {@code onReceive(Application, Intent)} as
+   * appropriate. Does not enqueue the {@code Intent} for later inspection.
+   *
+   * @param context
+   * @param intent the {@code Intent} to broadcast todo: enqueue the Intent for later inspection
+   */
+  void sendBroadcastWithPermission(
+      Intent intent, UserHandle userHandle, String receiverPermission, Context context) {
+    sendBroadcastWithPermission(
+        intent,
+        userHandle,
+        receiverPermission,
+        context,
+        /* broadcastOptions= */ null,
+        /* resultCode= */ 0);
+  }
+
+  void sendBroadcastWithPermission(
+      Intent intent, String receiverPermission, Context context, int resultCode) {
+    sendBroadcastWithPermission(
+        intent, /*userHandle=*/ null, receiverPermission, context, null, resultCode);
+  }
+
+  void sendBroadcastWithPermission(
+      Intent intent,
+      String receiverPermission,
+      Context context,
+      @Nullable Bundle broadcastOptions,
+      int resultCode) {
+    sendBroadcastWithPermission(
+        intent, /*userHandle=*/ null, receiverPermission, context, broadcastOptions, resultCode);
+  }
+
+  void sendBroadcastWithPermission(
+      Intent intent,
+      UserHandle userHandle,
+      String receiverPermission,
+      Context context,
+      @Nullable Bundle broadcastOptions,
+      int resultCode) {
+    List<Wrapper> wrappers =
+        getAppropriateWrappers(context, userHandle, intent, receiverPermission, broadcastOptions);
+    postToWrappers(wrappers, intent, context, resultCode);
+  }
+
+  void sendOrderedBroadcastWithPermission(
+      Intent intent, String receiverPermission, Context context) {
+    List<Wrapper> wrappers =
+        getAppropriateWrappers(
+            context,
+            /*userHandle=*/ null,
+            intent,
+            receiverPermission,
+            /* broadcastOptions= */ null);
+    // sort by the decrease of priorities
+    sortByPriority(wrappers);
+
+    postOrderedToWrappers(wrappers, intent, 0, null, null, context);
+  }
+
+  private void sortByPriority(List<Wrapper> wrappers) {
+    Collections.sort(
+        wrappers,
+        new Comparator<Wrapper>() {
+          @Override
+          public int compare(Wrapper o1, Wrapper o2) {
+            return Integer.compare(
+                o2.getIntentFilter().getPriority(), o1.getIntentFilter().getPriority());
+          }
+        });
+  }
+
+  List<Intent> getBroadcastIntents() {
+    return broadcastIntents;
+  }
+
+  @Nullable
+  Bundle getBroadcastOptions(Intent intent) {
+    synchronized (broadcastOptions) {
+      for (Intent broadcastIntent : broadcastOptions.keySet()) {
+        if (broadcastIntent.filterEquals(intent)) {
+          return broadcastOptions.get(broadcastIntent);
+        }
+      }
+      return null;
+    }
+  }
+
+  List<Intent> getBroadcastIntentsForUser(UserHandle userHandle) {
+    List<Intent> intentsForUser = broadcastIntentsForUser.get(userHandle);
+    if (intentsForUser == null) {
+      intentsForUser = new ArrayList<>();
+      broadcastIntentsForUser.put(userHandle, intentsForUser);
+    }
+    return intentsForUser;
+  }
+
+  void clearBroadcastIntents() {
+    broadcastIntents.clear();
+    broadcastOptions.clear();
+    broadcastIntentsForUser.clear();
+  }
+
+  Intent getNextStartedActivity() {
+    if (startedActivities.isEmpty()) {
+      return null;
+    } else {
+      return startedActivities.remove(startedActivities.size() - 1);
+    }
+  }
+
+  Intent peekNextStartedActivity() {
+    if (startedActivities.isEmpty()) {
+      return null;
+    } else {
+      return startedActivities.get(startedActivities.size() - 1);
+    }
+  }
+
+  /**
+   * Clears all {@code Intent}s started by {@link #execStartActivity(Context, IBinder, IBinder,
+   * Activity, Intent, int, Bundle)}, {@link #execStartActivity(Context, IBinder, IBinder, Fragment,
+   * Intent, int, Bundle)}, and {@link #execStartActivity(Context, IBinder, IBinder, String, Intent,
+   * int, Bundle)}.
+   */
+  void clearNextStartedActivities() {
+    startedActivities.clear();
+    startedActivitiesForResults.clear();
+  }
+
+  IntentForResult getNextStartedActivityForResult() {
+    if (startedActivitiesForResults.isEmpty()) {
+      return null;
+    } else {
+      return startedActivitiesForResults.remove(startedActivitiesForResults.size() - 1);
+    }
+  }
+
+  IntentForResult peekNextStartedActivityForResult() {
+    if (startedActivitiesForResults.isEmpty()) {
+      return null;
+    } else {
+      return startedActivitiesForResults.get(startedActivitiesForResults.size() - 1);
+    }
+  }
+
+  void checkActivities(boolean checkActivities) {
+    this.checkActivities = checkActivities;
+  }
+
+  TargetAndRequestCode getTargetAndRequestCodeForIntent(Intent requestIntent) {
+    return checkNotNull(
+        intentRequestCodeMap.get(new Intent.FilterComparison(requestIntent)),
+        "No intent matches %s among %s",
+        requestIntent,
+        intentRequestCodeMap.keySet());
+  }
+
+  protected ComponentName startService(Intent intent) {
+    startedServices.add(new Intent.FilterComparison(intent));
+    if (intent.getComponent() != null) {
+      return intent.getComponent();
+    }
+    return new ComponentName("some.service.package", "SomeServiceName-FIXME");
+  }
+
+  boolean stopService(Intent name) {
+    stoppedServices.add(new Intent.FilterComparison(name));
+    return startedServices.contains(new Intent.FilterComparison(name));
+  }
+
+  /**
+   * Set the default IBinder implementation that will be returned when the service is bound using
+   * the specified Intent. The IBinder can implement the methods to simulate a bound Service. Useful
+   * for testing the ServiceConnection implementation.
+   *
+   * @param name The ComponentName of the Service
+   * @param service The IBinder implementation to return when the service is bound.
+   */
+  void setComponentNameAndServiceForBindService(ComponentName name, IBinder service) {
+    defaultServiceConnectionData = new ServiceConnectionDataWrapper(name, service);
+  }
+
+  /**
+   * Set the IBinder implementation that will be returned when the service is bound using the
+   * specified Intent. The IBinder can implement the methods to simulate a bound Service. Useful for
+   * testing the ServiceConnection implementation.
+   *
+   * @param intent The exact Intent used in Context#bindService(...)
+   * @param name The ComponentName of the Service
+   * @param service The IBinder implementation to return when the service is bound.
+   */
+  void setComponentNameAndServiceForBindServiceForIntent(
+      Intent intent, ComponentName name, IBinder service) {
+    serviceConnectionDataForIntent.put(
+        new Intent.FilterComparison(intent), new ServiceConnectionDataWrapper(name, service));
+  }
+
+  protected boolean bindService(
+      Intent intent, int flags, Executor executor, ServiceConnection serviceConnection) {
+    return bindService(intent, serviceConnection, new ExecutorServiceCallbackScheduler(executor));
+  }
+
+  protected boolean bindService(
+      final Intent intent, final ServiceConnection serviceConnection, int flags) {
+    return bindService(intent, serviceConnection, new HandlerCallbackScheduler());
+  }
+
+  private boolean bindService(
+      final Intent intent,
+      final ServiceConnection serviceConnection,
+      ServiceCallbackScheduler serviceCallbackScheduler) {
+    boundServiceConnections.add(serviceConnection);
+    unboundServiceConnections.remove(serviceConnection);
+    if (exceptionForBindService != null) {
+      throw exceptionForBindService;
+    }
+    final Intent.FilterComparison filterComparison = new Intent.FilterComparison(intent);
+    final ServiceConnectionDataWrapper serviceConnectionDataWrapper =
+        serviceConnectionDataForIntent.getOrDefault(filterComparison, defaultServiceConnectionData);
+    if (unbindableActions.contains(intent.getAction())
+        || unbindableComponents.contains(intent.getComponent())
+        || unbindableComponents.contains(
+            serviceConnectionDataWrapper.componentNameForBindService)) {
+      return false;
+    }
+    startedServices.add(filterComparison);
+    Runnable onServiceConnectedRunnable =
+        () -> {
+          serviceConnectionDataForServiceConnection.put(
+              serviceConnection, serviceConnectionDataWrapper);
+          serviceConnection.onServiceConnected(
+              serviceConnectionDataWrapper.componentNameForBindService,
+              serviceConnectionDataWrapper.binderForBindService);
+        };
+
+    if (bindServiceCallsOnServiceConnectedInline) {
+      onServiceConnectedRunnable.run();
+    } else {
+      serviceCallbackScheduler.schedule(onServiceConnectedRunnable);
+    }
+    return true;
+  }
+
+  protected void setUnbindServiceCallsOnServiceDisconnected(boolean flag) {
+    unbindServiceCallsOnServiceDisconnected = flag;
+  }
+
+  protected void unbindService(final ServiceConnection serviceConnection) {
+    if (unbindServiceShouldThrowIllegalArgument) {
+      throw new IllegalArgumentException();
+    }
+
+    unboundServiceConnections.add(serviceConnection);
+    boundServiceConnections.remove(serviceConnection);
+    Handler handler = new Handler(Looper.getMainLooper());
+    handler.post(
+        () -> {
+          final ServiceConnectionDataWrapper serviceConnectionDataWrapper;
+          if (serviceConnectionDataForServiceConnection.containsKey(serviceConnection)) {
+            serviceConnectionDataWrapper =
+                serviceConnectionDataForServiceConnection.get(serviceConnection);
+          } else {
+            serviceConnectionDataWrapper = defaultServiceConnectionData;
+          }
+          if (unbindServiceCallsOnServiceDisconnected) {
+            Logger.warn(
+                "Configured to call onServiceDisconnected when unbindService is called. This is"
+                    + " not accurate Android behavior. Please update your tests and call"
+                    + " ShadowActivity#setUnbindCallsOnServiceDisconnected(false). This will"
+                    + " become default behavior in the future, which may break your tests if you"
+                    + " are expecting this inaccurate behavior.");
+            serviceConnection.onServiceDisconnected(
+                serviceConnectionDataWrapper.componentNameForBindService);
+          }
+        });
+  }
+
+  protected List<ServiceConnection> getBoundServiceConnections() {
+    return boundServiceConnections;
+  }
+
+  void setUnbindServiceShouldThrowIllegalArgument(boolean flag) {
+    unbindServiceShouldThrowIllegalArgument = flag;
+  }
+
+  void setThrowInBindService(SecurityException e) {
+    exceptionForBindService = e;
+  }
+
+  void setBindServiceCallsOnServiceConnectedDirectly(
+      boolean bindServiceCallsOnServiceConnectedInline) {
+    this.bindServiceCallsOnServiceConnectedInline = bindServiceCallsOnServiceConnectedInline;
+  }
+
+  protected List<ServiceConnection> getUnboundServiceConnections() {
+    return unboundServiceConnections;
+  }
+
+  void declareActionUnbindable(String action) {
+    unbindableActions.add(action);
+  }
+
+  void declareComponentUnbindable(ComponentName component) {
+    checkNotNull(component);
+    unbindableComponents.add(component);
+  }
+
+  public List<String> getUnbindableActions() {
+    return unbindableActions;
+  }
+
+  List<ComponentName> getUnbindableComponents() {
+    return unbindableComponents;
+  }
+
+  /**
+   * Consumes the most recent {@code Intent} started by {@link
+   * #startService(android.content.Intent)} and returns it.
+   *
+   * @return the most recently started {@code Intent}
+   */
+  Intent getNextStartedService() {
+    if (startedServices.isEmpty()) {
+      return null;
+    } else {
+      return startedServices.remove(0).getIntent();
+    }
+  }
+
+  /**
+   * Returns the most recent {@code Intent} started by {@link #startService(android.content.Intent)}
+   * without consuming it.
+   *
+   * @return the most recently started {@code Intent}
+   */
+  Intent peekNextStartedService() {
+    if (startedServices.isEmpty()) {
+      return null;
+    } else {
+      return startedServices.get(0).getIntent();
+    }
+  }
+
+  /** Clears all {@code Intent} started by {@link #startService(android.content.Intent)}. */
+  void clearStartedServices() {
+    startedServices.clear();
+  }
+
+  /**
+   * Returns all {@code Intent} started by {@link #startService(android.content.Intent)} without
+   * consuming them.
+   *
+   * @return the list of {@code Intent}
+   */
+  List<Intent> getAllStartedServices() {
+    ArrayList<Intent> startedServicesIntents = new ArrayList<>();
+    for (Intent.FilterComparison filterComparison : startedServices) {
+      startedServicesIntents.add(filterComparison.getIntent());
+    }
+    return startedServicesIntents;
+  }
+
+  /**
+   * Consumes the {@code Intent} requested to stop a service by {@link
+   * #stopService(android.content.Intent)} from the bottom of the stack of stop requests.
+   */
+  Intent getNextStoppedService() {
+    if (stoppedServices.isEmpty()) {
+      return null;
+    } else {
+      return stoppedServices.remove(0).getIntent();
+    }
+  }
+
+  void sendStickyBroadcast(Intent intent, Context context) {
+    stickyIntents.put(intent.getAction(), intent);
+    sendBroadcast(intent, context);
+  }
+
+  void sendBroadcast(Intent intent, Context context) {
+    sendBroadcastWithPermission(
+        intent, /*userHandle=*/ null, /*receiverPermission=*/ null, context);
+  }
+
+  Intent registerReceiver(
+      BroadcastReceiver receiver, IntentFilter filter, int flags, Context context) {
+    return registerReceiver(receiver, filter, null, null, flags, context);
+  }
+
+  Intent registerReceiver(
+      BroadcastReceiver receiver,
+      IntentFilter filter,
+      String broadcastPermission,
+      Handler scheduler,
+      int flags,
+      Context context) {
+    return registerReceiverWithContext(
+        receiver, filter, broadcastPermission, scheduler, flags, context);
+  }
+
+  Intent registerReceiverWithContext(
+      BroadcastReceiver receiver,
+      IntentFilter filter,
+      String broadcastPermission,
+      Handler scheduler,
+      int flags,
+      Context context) {
+    if (receiver != null) {
+      synchronized (registeredReceivers) {
+        registeredReceivers.add(
+            new Wrapper(receiver, filter, context, broadcastPermission, scheduler, flags));
+      }
+    }
+    return processStickyIntents(filter, receiver, context);
+  }
+
+  private Intent processStickyIntents(
+      IntentFilter filter, BroadcastReceiver receiver, Context context) {
+    Intent result = null;
+    for (Intent stickyIntent : stickyIntents.values()) {
+      if (filter.matchAction(stickyIntent.getAction())) {
+        if (result == null) {
+          result = stickyIntent;
+        }
+        if (receiver != null) {
+          receiver.setPendingResult(ShadowBroadcastPendingResult.createSticky(stickyIntent));
+          receiver.onReceive(context, stickyIntent);
+          receiver.setPendingResult(null);
+        } else if (result != null) {
+          break;
+        }
+      }
+    }
+    return result;
+  }
+
+  void unregisterReceiver(BroadcastReceiver broadcastReceiver) {
+    boolean found = false;
+
+    synchronized (registeredReceivers) {
+      Iterator<Wrapper> iterator = registeredReceivers.iterator();
+      while (iterator.hasNext()) {
+        Wrapper wrapper = iterator.next();
+        if (wrapper.broadcastReceiver == broadcastReceiver) {
+          iterator.remove();
+          found = true;
+        }
+      }
+    }
+
+    if (!found) {
+      throw new IllegalArgumentException("Receiver not registered: " + broadcastReceiver);
+    }
+  }
+
+  void clearRegisteredReceivers() {
+    synchronized (registeredReceivers) {
+      registeredReceivers.clear();
+    }
+  }
+
+  /**
+   * @deprecated use PackageManager.queryBroadcastReceivers instead
+   */
+  @Deprecated
+  boolean hasReceiverForIntent(Intent intent) {
+    synchronized (registeredReceivers) {
+      for (Wrapper wrapper : registeredReceivers) {
+        if (wrapper.intentFilter.matchAction(intent.getAction())) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  /**
+   * @deprecated use PackageManager.queryBroadcastReceivers instead
+   */
+  @Deprecated
+  List<BroadcastReceiver> getReceiversForIntent(Intent intent) {
+    ArrayList<BroadcastReceiver> broadcastReceivers = new ArrayList<>();
+
+    synchronized (registeredReceivers) {
+      for (Wrapper wrapper : registeredReceivers) {
+        if (wrapper.intentFilter.matchAction(intent.getAction())) {
+          broadcastReceivers.add(wrapper.getBroadcastReceiver());
+        }
+      }
+    }
+    return broadcastReceivers;
+  }
+
+  /**
+   * @return copy of the list of {@link Wrapper}s for registered receivers
+   */
+  ImmutableList<Wrapper> getRegisteredReceivers() {
+    ImmutableList<Wrapper> copy;
+    synchronized (registeredReceivers) {
+      copy = ImmutableList.copyOf(registeredReceivers);
+    }
+
+    return copy;
+  }
+
+  int checkPermission(String permission, int pid, int uid) {
+    Set<String> grantedPermissionsForPidUid = grantedPermissionsMap.get(new Pair(pid, uid));
+    return grantedPermissionsForPidUid != null && grantedPermissionsForPidUid.contains(permission)
+        ? PERMISSION_GRANTED
+        : PERMISSION_DENIED;
+  }
+
+  void grantPermissions(String... permissionNames) {
+    grantPermissions(Process.myPid(), Process.myUid(), permissionNames);
+  }
+
+  void grantPermissions(int pid, int uid, String... permissions) {
+    Set<String> grantedPermissionsForPidUid = grantedPermissionsMap.get(new Pair<>(pid, uid));
+    if (grantedPermissionsForPidUid == null) {
+      grantedPermissionsForPidUid = new HashSet<>();
+      grantedPermissionsMap.put(new Pair<>(pid, uid), grantedPermissionsForPidUid);
+    }
+    Collections.addAll(grantedPermissionsForPidUid, permissions);
+  }
+
+  void denyPermissions(String... permissionNames) {
+    denyPermissions(Process.myPid(), Process.myUid(), permissionNames);
+  }
+
+  void denyPermissions(int pid, int uid, String... permissions) {
+    Set<String> grantedPermissionsForPidUid = grantedPermissionsMap.get(new Pair<>(pid, uid));
+    if (grantedPermissionsForPidUid != null) {
+      for (String permissionName : permissions) {
+        grantedPermissionsForPidUid.remove(permissionName);
+      }
+    }
+  }
+
+  private Handler getMainHandler(Context context) {
+    if (mainHandler == null) {
+      mainHandler = new Handler(context.getMainLooper());
+    }
+    return mainHandler;
+  }
+
+  /** Reflector interface for {@link Instrumentation}'s internals. */
+  @ForType(Instrumentation.class)
+  public interface _Instrumentation_ {
+    // <= JELLY_BEAN_MR1:
+    void init(
+        ActivityThread thread,
+        Context instrContext,
+        Context appContext,
+        ComponentName component,
+        @WithType("android.app.IInstrumentationWatcher") Object watcher);
+
+    // > JELLY_BEAN_MR1:
+    void init(
+        ActivityThread thread,
+        Context instrContext,
+        Context appContext,
+        ComponentName component,
+        @WithType("android.app.IInstrumentationWatcher") Object watcher,
+        @WithType("android.app.IUiAutomationConnection") Object uiAutomationConnection);
+
+    @Direct
+    ActivityResult execStartActivity(
+        Context who,
+        IBinder contextThread,
+        IBinder token,
+        Activity target,
+        Intent intent,
+        int requestCode,
+        Bundle options);
+
+    @Direct
+    ActivityResult execStartActivity(
+        Context who,
+        IBinder contextThread,
+        IBinder token,
+        String target,
+        Intent intent,
+        int requestCode,
+        Bundle options);
+  }
+
+  private static final class BroadcastResultHolder {
+    private final int resultCode;
+    private final String resultData;
+    private final Bundle resultExtras;
+
+    private BroadcastResultHolder(int resultCode, String resultData, Bundle resultExtras) {
+      this.resultCode = resultCode;
+      this.resultData = resultData;
+      this.resultExtras = resultExtras;
+    }
+
+    private static ListenableFuture<BroadcastResultHolder> transform(
+        BroadcastReceiver.PendingResult result) {
+      ShadowBroadcastPendingResult shadowBroadcastPendingResult = Shadow.extract(result);
+      return Futures.transform(
+          shadowBroadcastPendingResult.getFuture(),
+          pendingResult ->
+              new BroadcastResultHolder(
+                  pendingResult.getResultCode(),
+                  pendingResult.getResultData(),
+                  pendingResult.getResultExtras(false)),
+          directExecutor());
+    }
+  }
+
+  private static class ServiceConnectionDataWrapper {
+    public final ComponentName componentNameForBindService;
+    public final IBinder binderForBindService;
+
+    private ServiceConnectionDataWrapper(
+        ComponentName componentNameForBindService, IBinder binderForBindService) {
+      this.componentNameForBindService = componentNameForBindService;
+      this.binderForBindService = binderForBindService;
+    }
+  }
+
+  /** Handles thread on which service lifecycle callbacks are run. */
+  private interface ServiceCallbackScheduler {
+    void schedule(Runnable runnable);
+  }
+
+  private static final class ExecutorServiceCallbackScheduler implements ServiceCallbackScheduler {
+    private final Executor executor;
+
+    ExecutorServiceCallbackScheduler(Executor executor) {
+      this.executor = executor;
+    }
+
+    @Override
+    public void schedule(Runnable runnable) {
+      executor.execute(runnable);
+    }
+  }
+
+  private static final class HandlerCallbackScheduler implements ServiceCallbackScheduler {
+    private final Handler mainHandler = new Handler(Looper.getMainLooper());
+
+    @Override
+    public void schedule(Runnable runnable) {
+      mainHandler.post(runnable);
+    }
+  }
+
+  static final class TargetAndRequestCode {
+    final String target;
+    final int requestCode;
+
+    private TargetAndRequestCode(String target, int requestCode) {
+      this.target = target;
+      this.requestCode = requestCode;
+    }
+  }
+
+  public static Instrumentation getInstrumentation() {
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    if (activityThread != null) {
+      return activityThread.getInstrumentation();
+    }
+    return null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIntent.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIntent.java
new file mode 100644
index 0000000..bcce460
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIntent.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import android.content.Intent;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Intent.class)
+public class ShadowIntent {
+  @RealObject private Intent realIntent;
+
+  /**
+   * Returns the {@code Class} object set by
+   * {@link Intent#setClass(android.content.Context, Class)}
+   *
+   * @return the {@code Class} object set by
+   *         {@link Intent#setClass(android.content.Context, Class)}
+   */
+  public Class<?> getIntentClass() {
+    try {
+      return Class.forName(realIntent.getComponent().getClassName());
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIntentService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIntentService.java
new file mode 100644
index 0000000..1bec91e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIntentService.java
@@ -0,0 +1,34 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.IntentService;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(IntentService.class)
+public class ShadowIntentService extends ShadowService {
+  @RealObject IntentService realIntentService;
+  private boolean mRedelivery;
+
+  public boolean getIntentRedelivery() {
+    return mRedelivery;
+  }
+
+  @Implementation
+  protected void setIntentRedelivery(boolean enabled) {
+    mRedelivery = enabled;
+    reflector(IntentServiceReflector.class, realIntentService).setIntentRedelivery(enabled);
+  }
+
+  @ForType(IntentService.class)
+  interface IntentServiceReflector {
+
+    @Direct
+    void setIntentRedelivery(boolean enabled);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIoUtils.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIoUtils.java
new file mode 100644
index 0000000..6d22ba3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIoUtils.java
@@ -0,0 +1,26 @@
+package org.robolectric.shadows;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.os.Build;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import libcore.io.IoUtils;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = IoUtils.class, isInAndroidSdk = false)
+public class ShadowIoUtils {
+
+  @Implementation
+  public static String readFileAsString(String absolutePath) throws IOException {
+    return new String(Files.readAllBytes(Paths.get(absolutePath)), UTF_8);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected static void setFdOwner(FileDescriptor fd, Object owner) {
+    // ignore, fails in JVM environment
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIsoDep.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIsoDep.java
new file mode 100644
index 0000000..89b5a8b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowIsoDep.java
@@ -0,0 +1,92 @@
+package org.robolectric.shadows;
+
+import android.annotation.SuppressLint;
+import android.nfc.Tag;
+import android.nfc.tech.IsoDep;
+import java.io.IOException;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Extends IsoDep to allow for testing.
+ *
+ * <p>Control the allowed packet size with {@link #setExtendedLengthApduSupported} and {@link
+ * #setMaxTransceiveLength}. Note that extended Apdu packets have a max transceive length of 0x10008
+ * but most hardware implementations will have a lower limit. If extended length apdus are not
+ * supported, the theoretical max transceive length is 0x105 but, again, may be lower in practice.
+ *
+ * <p>Dictate the Apdu response returned in {@link transceive} via {@link #setTransceiveResponse} or
+ * {@link #setNextTransceiveResponse}. The former will be returned with every call to transceive
+ * while the later will be returned only once. If neither is set, transceive will throw an
+ * IOException.
+ */
+@Implements(IsoDep.class)
+public class ShadowIsoDep extends ShadowBasicTagTechnology {
+
+  @SuppressLint("PrivateApi")
+  @SuppressWarnings("unchecked")
+  public static IsoDep newInstance() {
+    return Shadow.newInstance(IsoDep.class, new Class<?>[] {Tag.class}, new Object[] {null});
+  }
+
+  private byte[] transceiveResponse = null;
+  private byte[] nextTransceiveResponse = null;
+  private boolean isExtendedLengthApduSupported = true;
+  private int timeout = 300; // Default timeout in AOSP
+  private int maxTransceiveLength = 0xFEFF; // Default length in AOSP
+
+  @Implementation
+  protected void __constructor__(Tag tag) {}
+
+  @Implementation
+  protected byte[] transceive(byte[] data) throws IOException {
+    if (nextTransceiveResponse != null) {
+      try {
+        return nextTransceiveResponse;
+      } finally {
+        nextTransceiveResponse = null;
+      }
+    }
+    if (transceiveResponse != null) {
+      return transceiveResponse;
+    }
+    throw new IOException();
+  }
+
+  public void setTransceiveResponse(byte[] response) {
+    transceiveResponse = response;
+  }
+
+  public void setNextTransceiveResponse(byte[] response) {
+    nextTransceiveResponse = response;
+  }
+
+  @Implementation
+  protected void setTimeout(int timeoutMillis) {
+    timeout = timeoutMillis;
+  }
+
+  @Implementation
+  protected int getTimeout() {
+    return timeout;
+  }
+
+  @Implementation
+  protected int getMaxTransceiveLength() {
+    return maxTransceiveLength;
+  }
+
+  public void setMaxTransceiveLength(int length) {
+    maxTransceiveLength = length;
+  }
+
+  @Implementation
+  protected boolean isExtendedLengthApduSupported() {
+    return isExtendedLengthApduSupported;
+  }
+
+  public void setExtendedLengthApduSupported(boolean supported) {
+    isExtendedLengthApduSupported = supported;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobScheduler.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobScheduler.java
new file mode 100644
index 0000000..2a7fdf1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobScheduler.java
@@ -0,0 +1,133 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.annotation.SystemApi;
+import android.annotation.TargetApi;
+import android.app.JobSchedulerImpl;
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.app.job.JobWorkItem;
+import android.os.Build;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = JobScheduler.class, minSdk = LOLLIPOP)
+public abstract class ShadowJobScheduler {
+
+  @Implementation
+  protected abstract int schedule(JobInfo job);
+
+  @Implementation(minSdk = N)
+  @SystemApi
+  @HiddenApi
+  protected abstract int scheduleAsPackage(JobInfo job, String packageName, int userId, String tag);
+
+  @Implementation
+  protected abstract void cancel(int jobId);
+
+  @Implementation
+  protected abstract void cancelAll();
+
+  @Implementation
+  protected abstract List<JobInfo> getAllPendingJobs();
+
+  @Implementation(minSdk = N)
+  @HiddenApi
+  public abstract JobInfo getPendingJob(int jobId);
+
+  @Implementation(minSdk = O)
+  protected abstract int enqueue(JobInfo job, JobWorkItem work);
+
+  public abstract void failOnJob(int jobId);
+
+  /** Whether to fail a job if it is set as expedited. */
+  public abstract void failExpeditedJob(boolean enabled);
+
+  @Implements(value = JobSchedulerImpl.class, isInAndroidSdk = false, minSdk = LOLLIPOP)
+  public static class ShadowJobSchedulerImpl extends ShadowJobScheduler {
+
+    private Map<Integer, JobInfo> scheduledJobs = new LinkedHashMap<>();
+    private Set<Integer> jobsToFail = new HashSet<>();
+    private boolean failExpeditedJobEnabled;
+
+    @Override
+    @Implementation
+    public int schedule(JobInfo job) {
+      if (jobsToFail.contains(job.getId())) {
+        return JobScheduler.RESULT_FAILURE;
+      }
+
+      if (Build.VERSION.SDK_INT >= S && failExpeditedJobEnabled && job.isExpedited()) {
+        return JobScheduler.RESULT_FAILURE;
+      }
+
+      scheduledJobs.put(job.getId(), job);
+      return JobScheduler.RESULT_SUCCESS;
+    }
+
+    /**
+     * Simple implementation redirecting all calls to {@link #schedule(JobInfo)}. Ignores all
+     * arguments other than {@code job}.
+     */
+    @Override
+    @Implementation(minSdk = N)
+    @SystemApi
+    @HiddenApi
+    protected int scheduleAsPackage(JobInfo job, String packageName, int userId, String tag) {
+      return schedule(job);
+    }
+
+    @Override
+    @Implementation
+    public void cancel(int jobId) {
+      scheduledJobs.remove(jobId);
+    }
+
+    @Override
+    @Implementation
+    public void cancelAll() {
+      scheduledJobs.clear();
+    }
+
+    @Override
+    @Implementation
+    public List<JobInfo> getAllPendingJobs() {
+      return new ArrayList<>(scheduledJobs.values());
+    }
+
+    @Override
+    @Implementation(minSdk = N)
+    public JobInfo getPendingJob(int jobId) {
+      return scheduledJobs.get(jobId);
+    }
+
+    @Override
+    @Implementation(minSdk = O)
+    public int enqueue(JobInfo job, JobWorkItem work) {
+      // Shadow-wise, enqueue and schedule are identical.
+      return schedule(job);
+    }
+
+    @Override
+    public void failOnJob(int jobId) {
+      jobsToFail.add(jobId);
+    }
+
+    @Override
+    @TargetApi(S)
+    public void failExpeditedJob(boolean enabled) {
+      failExpeditedJobEnabled = enabled;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java
new file mode 100644
index 0000000..42baa4c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = JobService.class, minSdk = LOLLIPOP)
+public class ShadowJobService extends ShadowService {
+
+  private boolean isJobFinished = false;
+  private boolean isRescheduleNeeded = false;
+
+  @Implementation
+  protected void jobFinished(JobParameters params, boolean needsReschedule) {
+    this.isJobFinished = true;
+    this.isRescheduleNeeded = needsReschedule;
+  }
+
+  /**
+   * Returns whether the job has finished running. When using this shadow this returns true after
+   * {@link #jobFinished(JobParameters, boolean)} is called.
+   */
+  public boolean getIsJobFinished() {
+    return isJobFinished;
+  }
+
+  /**
+   * Returns whether the job needs to be rescheduled. When using this shadow it returns the last
+   * value passed into {@link #jobFinished(JobParameters, boolean)}.
+   */
+  public boolean getIsRescheduleNeeded() {
+    return isRescheduleNeeded;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJsPromptResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJsPromptResult.java
new file mode 100644
index 0000000..331521e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJsPromptResult.java
@@ -0,0 +1,14 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.shadow.api.Shadow.newInstanceOf;
+
+import android.webkit.JsPromptResult;
+import org.robolectric.annotation.Implements;
+
+@Implements(JsPromptResult.class)
+public class ShadowJsPromptResult extends ShadowJsResult{
+
+  public static JsPromptResult newInstance() {
+    return newInstanceOf(JsPromptResult.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJsResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJsResult.java
new file mode 100644
index 0000000..cf12551
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJsResult.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import android.webkit.JsResult;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(JsResult.class)
+public class ShadowJsResult {
+
+  private boolean wasCancelled;
+
+  @Implementation
+  protected void cancel() {
+    wasCancelled = true;
+  }
+
+  public boolean wasCancelled() {
+    return wasCancelled;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowKeyCharacterMap.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowKeyCharacterMap.java
new file mode 100644
index 0000000..7ce4c60
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowKeyCharacterMap.java
@@ -0,0 +1,278 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(KeyCharacterMap.class)
+public class ShadowKeyCharacterMap {
+  private static final Map<Character, Integer> CHAR_TO_KEY_CODE = new HashMap<>();
+  private static final Map<Character, Integer> CHAR_TO_KEY_CODE_SHIFT_ON = new HashMap<>();
+
+  private static final Map<Integer, Character> KEY_CODE_TO_CHAR = new HashMap<>();
+  private static final Map<Integer, Character> KEY_CODE_TO_CHAR_SHIFT_ON = new HashMap<>();
+
+  static {
+    CHAR_TO_KEY_CODE.put('0', KeyEvent.KEYCODE_0);
+    CHAR_TO_KEY_CODE.put('1', KeyEvent.KEYCODE_1);
+    CHAR_TO_KEY_CODE.put('2', KeyEvent.KEYCODE_2);
+    CHAR_TO_KEY_CODE.put('3', KeyEvent.KEYCODE_3);
+    CHAR_TO_KEY_CODE.put('4', KeyEvent.KEYCODE_4);
+    CHAR_TO_KEY_CODE.put('5', KeyEvent.KEYCODE_5);
+    CHAR_TO_KEY_CODE.put('6', KeyEvent.KEYCODE_6);
+    CHAR_TO_KEY_CODE.put('7', KeyEvent.KEYCODE_7);
+    CHAR_TO_KEY_CODE.put('8', KeyEvent.KEYCODE_8);
+    CHAR_TO_KEY_CODE.put('9', KeyEvent.KEYCODE_9);
+    CHAR_TO_KEY_CODE.put('A', KeyEvent.KEYCODE_A);
+    CHAR_TO_KEY_CODE.put('B', KeyEvent.KEYCODE_B);
+    CHAR_TO_KEY_CODE.put('C', KeyEvent.KEYCODE_C);
+    CHAR_TO_KEY_CODE.put('D', KeyEvent.KEYCODE_D);
+    CHAR_TO_KEY_CODE.put('E', KeyEvent.KEYCODE_E);
+    CHAR_TO_KEY_CODE.put('F', KeyEvent.KEYCODE_F);
+    CHAR_TO_KEY_CODE.put('G', KeyEvent.KEYCODE_G);
+    CHAR_TO_KEY_CODE.put('H', KeyEvent.KEYCODE_H);
+    CHAR_TO_KEY_CODE.put('I', KeyEvent.KEYCODE_I);
+    CHAR_TO_KEY_CODE.put('J', KeyEvent.KEYCODE_J);
+    CHAR_TO_KEY_CODE.put('K', KeyEvent.KEYCODE_K);
+    CHAR_TO_KEY_CODE.put('L', KeyEvent.KEYCODE_L);
+    CHAR_TO_KEY_CODE.put('M', KeyEvent.KEYCODE_M);
+    CHAR_TO_KEY_CODE.put('N', KeyEvent.KEYCODE_N);
+    CHAR_TO_KEY_CODE.put('O', KeyEvent.KEYCODE_O);
+    CHAR_TO_KEY_CODE.put('P', KeyEvent.KEYCODE_P);
+    CHAR_TO_KEY_CODE.put('Q', KeyEvent.KEYCODE_Q);
+    CHAR_TO_KEY_CODE.put('R', KeyEvent.KEYCODE_R);
+    CHAR_TO_KEY_CODE.put('S', KeyEvent.KEYCODE_S);
+    CHAR_TO_KEY_CODE.put('T', KeyEvent.KEYCODE_T);
+    CHAR_TO_KEY_CODE.put('U', KeyEvent.KEYCODE_U);
+    CHAR_TO_KEY_CODE.put('V', KeyEvent.KEYCODE_V);
+    CHAR_TO_KEY_CODE.put('W', KeyEvent.KEYCODE_W);
+    CHAR_TO_KEY_CODE.put('X', KeyEvent.KEYCODE_X);
+    CHAR_TO_KEY_CODE.put('Y', KeyEvent.KEYCODE_Y);
+    CHAR_TO_KEY_CODE.put('Z', KeyEvent.KEYCODE_Z);
+    CHAR_TO_KEY_CODE.put(' ', KeyEvent.KEYCODE_SPACE);
+    CHAR_TO_KEY_CODE.put('-', KeyEvent.KEYCODE_MINUS);
+    CHAR_TO_KEY_CODE.put('+', KeyEvent.KEYCODE_PLUS);
+    CHAR_TO_KEY_CODE.put('@', KeyEvent.KEYCODE_AT);
+    CHAR_TO_KEY_CODE.put('.', KeyEvent.KEYCODE_PERIOD);
+    CHAR_TO_KEY_CODE.put(',', KeyEvent.KEYCODE_COMMA);
+    CHAR_TO_KEY_CODE.put('[', KeyEvent.KEYCODE_LEFT_BRACKET);
+    CHAR_TO_KEY_CODE.put(']', KeyEvent.KEYCODE_RIGHT_BRACKET);
+    CHAR_TO_KEY_CODE.put('\'', KeyEvent.KEYCODE_APOSTROPHE);
+    CHAR_TO_KEY_CODE.put(')', KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN);
+    CHAR_TO_KEY_CODE.put('(', KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN);
+    CHAR_TO_KEY_CODE.put('#', KeyEvent.KEYCODE_POUND);
+    CHAR_TO_KEY_CODE.put('*', KeyEvent.KEYCODE_STAR);
+    CHAR_TO_KEY_CODE.put('/', KeyEvent.KEYCODE_SLASH);
+    CHAR_TO_KEY_CODE.put('=', KeyEvent.KEYCODE_EQUALS);
+    CHAR_TO_KEY_CODE.put('`', KeyEvent.KEYCODE_GRAVE);
+    CHAR_TO_KEY_CODE.put('\\', KeyEvent.KEYCODE_BACKSLASH);
+    CHAR_TO_KEY_CODE.put('\n', KeyEvent.KEYCODE_ENTER);
+
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('_', KeyEvent.KEYCODE_MINUS);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('{', KeyEvent.KEYCODE_LEFT_BRACKET);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('}', KeyEvent.KEYCODE_RIGHT_BRACKET);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('\"', KeyEvent.KEYCODE_APOSTROPHE);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('!', KeyEvent.KEYCODE_1);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('$', KeyEvent.KEYCODE_4);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('%', KeyEvent.KEYCODE_5);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('^', KeyEvent.KEYCODE_6);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('&', KeyEvent.KEYCODE_7);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('?', KeyEvent.KEYCODE_SLASH);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('|', KeyEvent.KEYCODE_BACKSLASH);
+    CHAR_TO_KEY_CODE_SHIFT_ON.put('~', KeyEvent.KEYCODE_GRAVE);
+
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_0, '0');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_1, '1');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_2, '2');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_3, '3');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_4, '4');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_5, '5');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_6, '6');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_7, '7');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_8, '8');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_9, '9');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_A, 'A');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_B, 'B');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_C, 'C');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_D, 'D');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_E, 'E');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_F, 'F');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_G, 'G');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_H, 'H');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_I, 'I');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_J, 'J');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_K, 'K');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_L, 'L');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_M, 'M');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_N, 'N');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_O, 'O');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_P, 'P');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_Q, 'Q');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_R, 'R');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_S, 'S');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_T, 'T');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_U, 'U');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_V, 'V');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_W, 'W');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_X, 'X');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_Y, 'Y');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_Z, 'Z');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_SPACE, ' ');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_MINUS, '-');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_PLUS, '+');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_AT, '@');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_PERIOD, '.');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_COMMA, ',');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_LEFT_BRACKET, '[');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_RIGHT_BRACKET, ']');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_APOSTROPHE, '\'');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN, ')');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN, '(');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_POUND, '#');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_STAR, '*');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_SLASH, '/');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_EQUALS, '=');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_GRAVE, '`');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_BACKSLASH, '\\');
+    KEY_CODE_TO_CHAR.put(KeyEvent.KEYCODE_ENTER, '\n');
+
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_MINUS, '_');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_LEFT_BRACKET, '{');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_RIGHT_BRACKET, '}');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_APOSTROPHE, '\"');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_1, '!');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_4, '$');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_5, '%');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_6, '^');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_7, '&');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_SLASH, '?');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_BACKSLASH, '|');
+    KEY_CODE_TO_CHAR_SHIFT_ON.put(KeyEvent.KEYCODE_GRAVE, '~');
+  }
+
+  @Implementation
+  protected static KeyCharacterMap load(int deviceId) {
+    return ReflectionHelpers.callConstructor(KeyCharacterMap.class);
+  }
+
+  @Implementation
+  protected KeyEvent[] getEvents(char[] chars) {
+    if (chars == null) {
+      throw new IllegalArgumentException("chars must not be null.");
+    }
+    int eventsPerChar = 2;
+    KeyEvent[] events = new KeyEvent[chars.length * eventsPerChar];
+
+    for (int i = 0; i < chars.length; i++) {
+      events[eventsPerChar * i] = getDownEvent(chars[i]);
+      events[eventsPerChar * i + 1] = getUpEvent(chars[i]);
+    }
+
+    return events;
+  }
+
+  @Implementation
+  protected int getKeyboardType() {
+    return KeyCharacterMap.FULL;
+  }
+
+  @Implementation
+  protected int get(int keyCode, int metaState) {
+    boolean metaShiftOn = (metaState & KeyEvent.META_SHIFT_ON) != 0;
+    Character character = KEY_CODE_TO_CHAR.get(keyCode);
+    if (character == null) {
+      return 0;
+    } else if (metaShiftOn) {
+      return KEY_CODE_TO_CHAR_SHIFT_ON.getOrDefault(keyCode, character);
+    } else {
+      return Character.toLowerCase(character);
+    }
+  }
+
+  public KeyEvent getDownEvent(char a) {
+    return new KeyEvent(
+        0,
+        0,
+        KeyEvent.ACTION_DOWN,
+        toCharKeyCode(a),
+        0,
+        getMetaState(a),
+        KeyCharacterMap.VIRTUAL_KEYBOARD,
+        0);
+  }
+
+  public KeyEvent getUpEvent(char a) {
+    return new KeyEvent(
+        0,
+        0,
+        KeyEvent.ACTION_UP,
+        toCharKeyCode(a),
+        0,
+        getMetaState(a),
+        KeyCharacterMap.VIRTUAL_KEYBOARD,
+        0);
+  }
+
+  @Implementation
+  protected char getDisplayLabel(int keyCode) {
+    return KEY_CODE_TO_CHAR.getOrDefault(keyCode, (char) 0);
+  }
+
+  @Implementation
+  protected boolean isPrintingKey(int keyCode) {
+    int type = Character.getType(getDisplayLabel(keyCode));
+    switch (type) {
+      case Character.SPACE_SEPARATOR:
+      case Character.LINE_SEPARATOR:
+      case Character.PARAGRAPH_SEPARATOR:
+      case Character.CONTROL:
+      case Character.FORMAT:
+        return false;
+      default:
+        return true;
+    }
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected static char nativeGetNumber(long ptr, int keyCode) {
+    Character character = KEY_CODE_TO_CHAR.get(keyCode);
+    if (character == null) {
+      return 0;
+    }
+    return character;
+  }
+
+  @Implementation(maxSdk = KITKAT)
+  protected static char nativeGetNumber(int ptr, int keyCode) {
+    Character character = KEY_CODE_TO_CHAR.get(keyCode);
+    if (character == null) {
+      return 0;
+    }
+    return character;
+  }
+
+  private int toCharKeyCode(char a) {
+    if (CHAR_TO_KEY_CODE.containsKey(Character.toUpperCase(a))) {
+      return CHAR_TO_KEY_CODE.get(Character.toUpperCase(a));
+    } else if (CHAR_TO_KEY_CODE_SHIFT_ON.containsKey(a)) {
+      return CHAR_TO_KEY_CODE_SHIFT_ON.get(a);
+    } else {
+      return 0;
+    }
+  }
+
+  private int getMetaState(char a) {
+    if (Character.isUpperCase(a) || CHAR_TO_KEY_CODE_SHIFT_ON.containsKey(a)) {
+      return KeyEvent.META_SHIFT_ON;
+    } else {
+      return 0;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowKeyguardManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowKeyguardManager.java
new file mode 100644
index 0000000..d28f705
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowKeyguardManager.java
@@ -0,0 +1,279 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+
+import android.app.Activity;
+import android.app.KeyguardManager;
+import android.app.KeyguardManager.KeyguardDismissCallback;
+import android.content.Intent;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(KeyguardManager.class)
+public class ShadowKeyguardManager {
+  // These have to be static because on Android L and below, a new instance of KeyguardManager is
+  // created each time it is requested.
+  private static final Set<Integer> deviceLockedForUsers = new HashSet<Integer>();
+  private static final Set<Integer> deviceSecureForUsers = new HashSet<Integer>();
+  private static boolean inRestrictedInputMode;
+  private static boolean isKeyguardLocked;
+  private static boolean isDeviceLocked;
+  private static boolean isKeyguardSecure;
+  private static boolean isDeviceSecure;
+  private static Intent confirmFactoryResetCredentialIntent;
+  private static KeyguardManager.KeyguardDismissCallback callback;
+
+  /**
+   * For tests, returns the value set via {@link #setinRestrictedInputMode(boolean)}, or false by
+   * default.
+   *
+   * @see #setInRestrictedInputMode(boolean)
+   */
+  @Implementation
+  protected boolean inKeyguardRestrictedInputMode() {
+    return inRestrictedInputMode;
+  }
+
+  @Implementation(minSdk = O)
+  protected void requestDismissKeyguard(
+      Activity activity, KeyguardManager.KeyguardDismissCallback callback) {
+    if (isKeyguardLocked) {
+      if (ShadowKeyguardManager.callback != null) {
+        callback.onDismissError();
+      }
+      ShadowKeyguardManager.callback = callback;
+    } else {
+      callback.onDismissError();
+    }
+  }
+
+  /**
+   * For tests, returns the value set via {@link #setKeyguardLocked(boolean)}, or false by default.
+   *
+   * @see #setKeyguardLocked(boolean)
+   */
+  @Implementation
+  protected boolean isKeyguardLocked() {
+    return isKeyguardLocked;
+  }
+
+  /**
+   * Sets whether the device keyguard is locked or not. This affects the value to be returned by
+   * {@link #isKeyguardLocked()} and also invokes callbacks set in {@link
+   * KeyguardManager#requestDismissKeyguard(Activity, KeyguardDismissCallback)} ()}.
+   *
+   * @param isKeyguardLocked true to lock the keyguard. If a KeyguardDismissCallback is set will
+   *     fire {@link KeyguardDismissCallback#onDismissCancelled()} or false to unlock and dismiss
+   *     the keyguard firing {@link KeyguardDismissCallback#onDismissSucceeded()} if a
+   *     KeyguardDismissCallback is set.
+   */
+  public void setKeyguardLocked(boolean isKeyguardLocked) {
+    ShadowKeyguardManager.isKeyguardLocked = isKeyguardLocked;
+    if (callback != null) {
+      if (isKeyguardLocked) {
+        callback.onDismissCancelled();
+      } else {
+        callback.onDismissSucceeded();
+      }
+      callback = null;
+    }
+  }
+
+  /**
+   * Sets the value to be returned by {@link KeyguardManager#inKeyguardRestrictedInputMode()}.
+   *
+   * @see KeyguardManager#inKeyguardRestrictedInputMode()
+   * @deprecated use {@link #setInRestrictedInputMode(boolean)} instead
+   */
+  @Deprecated
+  public void setinRestrictedInputMode(boolean restricted) {
+    inRestrictedInputMode = restricted;
+  }
+
+  /**
+   * Sets the value to be returned by {@link KeyguardManager#inKeyguardRestrictedInputMode()}.
+   *
+   * @see KeyguardManager#inKeyguardRestrictedInputMode()
+   */
+  public void setInRestrictedInputMode(boolean restricted) {
+    inRestrictedInputMode = restricted;
+  }
+
+  /**
+   * For tests, returns the value set by {@link #setIsKeyguardSecure(boolean)}, or false by default.
+   *
+   * @see #setIsKeyguardSecure(boolean)
+   */
+  @Implementation
+  protected boolean isKeyguardSecure() {
+    return isKeyguardSecure;
+  }
+
+  /**
+   * Sets the value to be returned by {@link #isKeyguardSecure()}.
+   *
+   * @see #isKeyguardSecure()
+   */
+  public void setIsKeyguardSecure(boolean secure) {
+    isKeyguardSecure = secure;
+  }
+
+  /**
+   * For tests on Android >=M, returns the value set by {@link #setIsDeviceSecure(boolean)}, or
+   * false by default.
+   *
+   * @see #setIsDeviceSecure(boolean)
+   */
+  @Implementation(minSdk = M)
+  protected boolean isDeviceSecure() {
+    return isDeviceSecure;
+  }
+
+  /**
+   * For tests on Android >=M, sets the value to be returned by {@link #isDeviceSecure()}.
+   *
+   * @see #isDeviceSecure()
+   */
+  public void setIsDeviceSecure(boolean isDeviceSecure) {
+    ShadowKeyguardManager.isDeviceSecure = isDeviceSecure;
+  }
+
+  /**
+   * For tests on Android >=M, returns the value set by {@link #setIsDeviceSecure(int, boolean)}, or
+   * false by default.
+   *
+   * @see #setIsDeviceSecure(int, boolean)
+   */
+  @Implementation(minSdk = M)
+  protected boolean isDeviceSecure(int userId) {
+    return deviceSecureForUsers.contains(userId);
+  }
+
+  /**
+   * For tests on Android >=M, sets the value to be returned by {@link #isDeviceSecure(int)}.
+   *
+   * @see #isDeviceSecure(int)
+   */
+  public void setIsDeviceSecure(int userId, boolean isDeviceSecure) {
+    if (isDeviceSecure) {
+      deviceSecureForUsers.add(userId);
+    } else {
+      deviceSecureForUsers.remove(userId);
+    }
+  }
+
+  /**
+   * For tests on Android >=L MR1, sets the value to be returned by {@link #isDeviceLocked()}.
+   *
+   * @see #isDeviceLocked()
+   */
+  public void setIsDeviceLocked(boolean isDeviceLocked) {
+    ShadowKeyguardManager.isDeviceLocked = isDeviceLocked;
+  }
+
+  /**
+   * @return false by default, or the value passed to {@link #setIsDeviceLocked(boolean)}.
+   * @see #isDeviceLocked()
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected boolean isDeviceLocked() {
+    return isDeviceLocked;
+  }
+
+  /**
+   * For tests on Android >= L MR1, sets the value to be returned by {@link #isDeviceLocked(int)}.
+   *
+   * @see #isDeviceLocked(int)
+   */
+  public void setIsDeviceLocked(int userId, boolean isLocked) {
+    if (isLocked) {
+      deviceLockedForUsers.add(userId);
+    } else {
+      deviceLockedForUsers.remove(userId);
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected boolean isDeviceLocked(int userId) {
+    return deviceLockedForUsers.contains(userId);
+  }
+
+  /**
+   * For tests on Android >= O MR1, sets the value to be returned by
+   * {@link #createConfirmFactoryResetCredentialIntent(CharSequence,CharSequence,CharSequence)}.
+   *
+   * @see #createConfirmFactoryResetCredentialIntent(CharSequence,CharSequence,CharSequence)
+   */
+  public void setConfirmFactoryResetCredentialIntent(Intent intent) {
+    confirmFactoryResetCredentialIntent = intent;
+  }
+
+  /**
+   * Returns the intent set via
+   * {@link #setConfirmFactoryResetCredentialIntent(Intent)}, otherwise null.
+   */
+  @Implementation(minSdk = O_MR1)
+  protected Intent createConfirmFactoryResetCredentialIntent(
+      CharSequence title, CharSequence description, CharSequence alternateButtonLabel) {
+    return confirmFactoryResetCredentialIntent;
+  }
+
+  /** An implementation of {@link KeyguardManager.KeyguardLock}, for use in tests. */
+  @Implements(KeyguardManager.KeyguardLock.class)
+  public static class ShadowKeyguardLock {
+    private static boolean keyguardEnabled = true;
+
+    /**
+     * Sets the value to be returned by {@link #isEnabled()} to false.
+     *
+     * @see #isEnabled()
+     */
+    @Implementation
+    protected void disableKeyguard() {
+      keyguardEnabled = false;
+    }
+
+    /**
+     * Sets the value to be returned by {@link #isEnabled()} to true.
+     *
+     * @see #isEnabled()
+     */
+    @Implementation
+    protected void reenableKeyguard() {
+      keyguardEnabled = true;
+    }
+
+    /**
+     * For tests, returns the value set via {@link #disableKeyguard()} or {@link
+     * #reenableKeyguard()}, or true by default.
+     *
+     * @see #setKeyguardLocked(boolean)
+     */
+    public boolean isEnabled() {
+      return keyguardEnabled;
+    }
+
+    @Resetter
+    public static void reset() {
+      keyguardEnabled = true;
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    deviceLockedForUsers.clear();
+    deviceSecureForUsers.clear();
+    inRestrictedInputMode = false;
+    isKeyguardLocked = false;
+    isDeviceLocked = false;
+    isKeyguardSecure = false;
+    isDeviceSecure = false;
+    callback = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLauncherApps.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLauncherApps.java
new file mode 100644
index 0000000..4b28baf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLauncherApps.java
@@ -0,0 +1,378 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.L;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ComponentName;
+import android.content.IntentSender;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.LauncherActivityInfo;
+import android.content.pm.LauncherApps;
+import android.content.pm.LauncherApps.ShortcutQuery;
+import android.content.pm.PackageInstaller.SessionCallback;
+import android.content.pm.PackageInstaller.SessionInfo;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ShortcutInfo;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Process;
+import android.os.UserHandle;
+import android.util.Pair;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow of {@link android.content.pm.LauncherApps}. */
+@Implements(value = LauncherApps.class, minSdk = LOLLIPOP)
+public class ShadowLauncherApps {
+  private List<ShortcutInfo> shortcuts = new ArrayList<>();
+  private final Multimap<UserHandle, String> enabledPackages = HashMultimap.create();
+  private final Multimap<UserHandle, LauncherActivityInfo> shortcutActivityList =
+      HashMultimap.create();
+  private final Multimap<UserHandle, LauncherActivityInfo> activityList = HashMultimap.create();
+  private final Map<UserHandle, Map<String, ApplicationInfo>> applicationInfoList = new HashMap<>();
+
+  private final List<Pair<LauncherApps.Callback, Handler>> callbacks = new ArrayList<>();
+  private boolean hasShortcutHostPermission = false;
+
+  /**
+   * Adds a dynamic shortcut to be returned by {@link #getShortcuts(ShortcutQuery, UserHandle)}.
+   *
+   * @param shortcutInfo the shortcut to add.
+   */
+  public void addDynamicShortcut(ShortcutInfo shortcutInfo) {
+    shortcuts.add(shortcutInfo);
+    shortcutsChanged(shortcutInfo.getPackage(), Lists.newArrayList(shortcutInfo));
+  }
+
+  private void shortcutsChanged(String packageName, List<ShortcutInfo> shortcuts) {
+    for (Pair<LauncherApps.Callback, Handler> callbackPair : callbacks) {
+      callbackPair.second.post(
+          () ->
+              callbackPair.first.onShortcutsChanged(
+                  packageName, shortcuts, Process.myUserHandle()));
+    }
+  }
+
+  /**
+   * Fires {@link LauncherApps.Callback#onPackageAdded(String, UserHandle)} on all of the registered
+   * callbacks, with the provided packageName.
+   *
+   * @param packageName the package the was added.
+   */
+  public void notifyPackageAdded(String packageName) {
+    for (Pair<LauncherApps.Callback, Handler> callbackPair : callbacks) {
+      callbackPair.second.post(
+          () -> callbackPair.first.onPackageAdded(packageName, Process.myUserHandle()));
+    }
+  }
+
+  /**
+   * Adds an enabled package to be checked by {@link #isPackageEnabled(String, UserHandle)}.
+   *
+   * @param userHandle the user handle to be added.
+   * @param packageName the package name to be added.
+   */
+  public void addEnabledPackage(UserHandle userHandle, String packageName) {
+    enabledPackages.put(userHandle, packageName);
+  }
+
+  /**
+   * Adds a {@link LauncherActivityInfo} to be retrieved by {@link
+   * #getShortcutConfigActivityList(String, UserHandle)}.
+   *
+   * @param userHandle the user handle to be added.
+   * @param activityInfo the {@link LauncherActivityInfo} to be added.
+   */
+  public void addShortcutConfigActivity(UserHandle userHandle, LauncherActivityInfo activityInfo) {
+    shortcutActivityList.put(userHandle, activityInfo);
+  }
+
+  /**
+   * Adds a {@link LauncherActivityInfo} to be retrieved by {@link #getActivityList(String,
+   * UserHandle)}.
+   *
+   * @param userHandle the user handle to be added.
+   * @param activityInfo the {@link LauncherActivityInfo} to be added.
+   */
+  public void addActivity(UserHandle userHandle, LauncherActivityInfo activityInfo) {
+    activityList.put(userHandle, activityInfo);
+  }
+
+  /**
+   * Fires {@link LauncherApps.Callback#onPackageRemoved(String, UserHandle)} on all of the
+   * registered callbacks, with the provided packageName.
+   *
+   * @param packageName the package the was removed.
+   */
+  public void notifyPackageRemoved(String packageName) {
+    for (Pair<LauncherApps.Callback, Handler> callbackPair : callbacks) {
+      callbackPair.second.post(
+          () -> callbackPair.first.onPackageRemoved(packageName, Process.myUserHandle()));
+    }
+  }
+
+  /**
+   * Adds a {@link ApplicationInfo} to be retrieved by {@link #getApplicationInfo(String, int,
+   * UserHandle)}.
+   *
+   * @param userHandle the user handle to be added.
+   * @param packageName the package name to be added.
+   * @param applicationInfo the application info to be added.
+   */
+  public void addApplicationInfo(
+      UserHandle userHandle, String packageName, ApplicationInfo applicationInfo) {
+    if (!applicationInfoList.containsKey(userHandle)) {
+      applicationInfoList.put(userHandle, new HashMap<>());
+    }
+    applicationInfoList.get(userHandle).put(packageName, applicationInfo);
+  }
+
+  @Implementation(minSdk = Q)
+  protected void startPackageInstallerSessionDetailsActivity(
+      @NonNull SessionInfo sessionInfo, @Nullable Rect sourceBounds, @Nullable Bundle opts) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation
+  protected void startAppDetailsActivity(
+      ComponentName component, UserHandle user, Rect sourceBounds, Bundle opts) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation(minSdk = O)
+  protected List<LauncherActivityInfo> getShortcutConfigActivityList(
+      @Nullable String packageName, @NonNull UserHandle user) {
+    return shortcutActivityList.get(user).stream()
+        .filter(matchesPackage(packageName))
+        .collect(Collectors.toList());
+  }
+
+  @Implementation(minSdk = O)
+  @Nullable
+  protected IntentSender getShortcutConfigActivityIntent(@NonNull LauncherActivityInfo info) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation
+  protected boolean isPackageEnabled(String packageName, UserHandle user) {
+    return enabledPackages.get(user).contains(packageName);
+  }
+
+  @Implementation(minSdk = L)
+  protected List<LauncherActivityInfo> getActivityList(String packageName, UserHandle user) {
+    return activityList.get(user).stream()
+        .filter(matchesPackage(packageName))
+        .collect(Collectors.toList());
+  }
+
+  @Implementation(minSdk = O)
+  protected ApplicationInfo getApplicationInfo(
+      @NonNull String packageName, int flags, @NonNull UserHandle user)
+      throws NameNotFoundException {
+    if (applicationInfoList.containsKey(user)) {
+      Map<String, ApplicationInfo> map = applicationInfoList.get(user);
+      if (map.containsKey(packageName)) {
+        return map.get(packageName);
+      }
+    }
+    throw new NameNotFoundException(
+        "Package " + packageName + " not found for user " + user.getIdentifier());
+  }
+
+  @Implementation(minSdk = P)
+  @Nullable
+  protected Bundle getSuspendedPackageLauncherExtras(String packageName, UserHandle user) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean shouldHideFromSuggestions(
+      @NonNull String packageName, @NonNull UserHandle user) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation
+  protected boolean isActivityEnabled(ComponentName component, UserHandle user) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  /**
+   * Sets the return value of {@link #hasShortcutHostPermission()}. If this isn't explicitly set,
+   * {@link #hasShortcutHostPermission()} defaults to returning false.
+   *
+   * @param permission boolean to be returned
+   */
+  public void setHasShortcutHostPermission(boolean permission) {
+    hasShortcutHostPermission = permission;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean hasShortcutHostPermission() {
+    return hasShortcutHostPermission;
+  }
+
+  /**
+   * This method is an incomplete implementation of this API that only supports querying for pinned
+   * dynamic shortcuts. It also doesn't not support {@link ShortcutQuery#setChangedSince(long)}.
+   */
+  @Implementation(minSdk = N_MR1)
+  @Nullable
+  protected List<ShortcutInfo> getShortcuts(
+      @NonNull ShortcutQuery query, @NonNull UserHandle user) {
+    if (reflector(ReflectorShortcutQuery.class, query).getChangedSince() != 0) {
+      throw new UnsupportedOperationException(
+          "Robolectric does not currently support ShortcutQueries that filter on time since"
+              + " change.");
+    }
+    int flags = reflector(ReflectorShortcutQuery.class, query).getQueryFlags();
+    if ((flags & ShortcutQuery.FLAG_MATCH_PINNED) == 0
+        || (flags & ShortcutQuery.FLAG_MATCH_DYNAMIC) == 0) {
+      throw new UnsupportedOperationException(
+          "Robolectric does not currently support ShortcutQueries that match non-dynamic"
+              + " Shortcuts.");
+    }
+    Iterable<ShortcutInfo> shortcutsItr = shortcuts;
+
+    List<String> ids = reflector(ReflectorShortcutQuery.class, query).getShortcutIds();
+    if (ids != null) {
+      shortcutsItr = Iterables.filter(shortcutsItr, shortcut -> ids.contains(shortcut.getId()));
+    }
+    ComponentName activity = reflector(ReflectorShortcutQuery.class, query).getActivity();
+    if (activity != null) {
+      shortcutsItr =
+          Iterables.filter(shortcutsItr, shortcut -> shortcut.getActivity().equals(activity));
+    }
+    String packageName = reflector(ReflectorShortcutQuery.class, query).getPackage();
+    if (packageName != null && !packageName.isEmpty()) {
+      shortcutsItr =
+          Iterables.filter(shortcutsItr, shortcut -> shortcut.getPackage().equals(packageName));
+    }
+    return Lists.newArrayList(shortcutsItr);
+  }
+
+  @Implementation(minSdk = N_MR1)
+  protected void pinShortcuts(
+      @NonNull String packageName, @NonNull List<String> shortcutIds, @NonNull UserHandle user) {
+    Iterable<ShortcutInfo> changed =
+        Iterables.filter(shortcuts, shortcut -> !shortcutIds.contains(shortcut.getId()));
+    List<ShortcutInfo> ret = Lists.newArrayList(changed);
+    shortcuts =
+        Lists.newArrayList(
+            Iterables.filter(shortcuts, shortcut -> shortcutIds.contains(shortcut.getId())));
+
+    shortcutsChanged(packageName, ret);
+  }
+
+  @Implementation(minSdk = N_MR1)
+  protected void startShortcut(
+      @NonNull String packageName,
+      @NonNull String shortcutId,
+      @Nullable Rect sourceBounds,
+      @Nullable Bundle startActivityOptions,
+      @NonNull UserHandle user) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation(minSdk = N_MR1)
+  protected void startShortcut(
+      @NonNull ShortcutInfo shortcut,
+      @Nullable Rect sourceBounds,
+      @Nullable Bundle startActivityOptions) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation
+  protected void registerCallback(LauncherApps.Callback callback) {
+    registerCallback(callback, null);
+  }
+
+  @Implementation
+  protected void registerCallback(LauncherApps.Callback callback, Handler handler) {
+    callbacks.add(
+        Pair.create(callback, handler != null ? handler : new Handler(Looper.myLooper())));
+  }
+
+  @Implementation
+  protected void unregisterCallback(LauncherApps.Callback callback) {
+    int index = Iterables.indexOf(this.callbacks, pair -> pair.first == callback);
+    if (index != -1) {
+      this.callbacks.remove(index);
+    }
+  }
+
+  @Implementation(minSdk = Q)
+  protected void registerPackageInstallerSessionCallback(
+      @NonNull Executor executor, @NonNull SessionCallback callback) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation(minSdk = Q)
+  protected void unregisterPackageInstallerSessionCallback(@NonNull SessionCallback callback) {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  @Implementation(minSdk = Q)
+  @NonNull
+  protected List<SessionInfo> getAllPackageInstallerSessions() {
+    throw new UnsupportedOperationException(
+        "This method is not currently supported in Robolectric.");
+  }
+
+  private Predicate<LauncherActivityInfo> matchesPackage(@Nullable String packageName) {
+    return info ->
+        packageName == null
+            || (info.getComponentName() != null
+                && packageName.equals(info.getComponentName().getPackageName()));
+  }
+
+  @ForType(ShortcutQuery.class)
+  private interface ReflectorShortcutQuery {
+    @Accessor("mChangedSince")
+    long getChangedSince();
+
+    @Accessor("mQueryFlags")
+    int getQueryFlags();
+
+    @Accessor("mShortcutIds")
+    List<String> getShortcutIds();
+
+    @Accessor("mActivity")
+    ComponentName getActivity();
+
+    @Accessor("mPackage")
+    String getPackage();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLayoutAnimationController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLayoutAnimationController.java
new file mode 100644
index 0000000..ec66505
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLayoutAnimationController.java
@@ -0,0 +1,24 @@
+package org.robolectric.shadows;
+
+import android.view.animation.LayoutAnimationController;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@Implements(LayoutAnimationController.class)
+public class ShadowLayoutAnimationController {
+  @RealObject
+  private LayoutAnimationController realAnimation;
+
+  private int loadedFromResourceId = -1;
+
+  public void setLoadedFromResourceId(int loadedFromResourceId) {
+    this.loadedFromResourceId = loadedFromResourceId;
+  }
+
+  public int getLoadedFromResourceId() {
+    if (loadedFromResourceId == -1) {
+      throw new IllegalStateException("not loaded from a resource");
+    }
+    return loadedFromResourceId;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyApkAssets.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyApkAssets.java
new file mode 100644
index 0000000..a9679f5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyApkAssets.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.content.res.ApkAssets;
+import com.android.internal.util.Preconditions;
+import java.io.IOException;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+// transliterated from
+// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/core/jni/android_content_res_ApkAssets.cpp
+
+/** Shadow for {@link ApkAssets} that is used for legacy resources. */
+@Implements(value = ApkAssets.class, minSdk = P, isInAndroidSdk = false)
+public class ShadowLegacyApkAssets extends ShadowApkAssets {
+
+  private String assetPath;
+
+  @Implementation(maxSdk = Q)
+  protected void __constructor__(
+      String path, boolean system, boolean forceSharedLib, boolean overlay) throws IOException {
+    Preconditions.checkNotNull(path, "path");
+    this.assetPath = path;
+  }
+
+
+  @Implementation
+  protected String getAssetPath() {
+    return assetPath;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAssetInputStream.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAssetInputStream.java
new file mode 100644
index 0000000..09fb271
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAssetInputStream.java
@@ -0,0 +1,141 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetManager.AssetInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadows.ShadowAssetInputStream.Picker;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings("UnusedDeclaration")
+@Implements(value = AssetInputStream.class, shadowPicker = Picker.class)
+public class ShadowLegacyAssetInputStream extends ShadowAssetInputStream {
+
+  @RealObject private AssetInputStream realObject;
+
+  private InputStream delegate;
+  private boolean ninePatch;
+
+  @Override
+  InputStream getDelegate() {
+    return delegate;
+  }
+
+  void setDelegate(InputStream delegate) {
+    this.delegate = delegate;
+  }
+
+  @Override
+  boolean isNinePatch() {
+    return ninePatch;
+  }
+
+  void setNinePatch(boolean ninePatch) {
+    this.ninePatch = ninePatch;
+  }
+
+  @Implementation
+  protected int read() throws IOException {
+    return delegate == null
+        ? reflector(AssetInputStreamReflector.class, realObject).read()
+        : delegate.read();
+  }
+
+  @Implementation
+  protected int read(byte[] b) throws IOException {
+    return delegate == null
+        ? reflector(AssetInputStreamReflector.class, realObject).read(b)
+        : delegate.read(b);
+  }
+
+  @Implementation
+  protected int read(byte[] b, int off, int len) throws IOException {
+    return delegate == null
+        ? reflector(AssetInputStreamReflector.class, realObject).read(b, off, len)
+        : delegate.read(b, off, len);
+  }
+
+  @Implementation
+  protected long skip(long n) throws IOException {
+    return delegate == null
+        ? reflector(AssetInputStreamReflector.class, realObject).skip(n)
+        : delegate.skip(n);
+  }
+
+  @Implementation
+  protected int available() throws IOException {
+    return delegate == null
+        ? reflector(AssetInputStreamReflector.class, realObject).available()
+        : delegate.available();
+  }
+
+  @Implementation
+  protected void close() throws IOException {
+    if (delegate == null) {
+      reflector(AssetInputStreamReflector.class, realObject).close();
+    } else {
+      delegate.close();
+    }
+  }
+
+  @Implementation
+  protected void mark(int readlimit) {
+    if (delegate == null) {
+      reflector(AssetInputStreamReflector.class, realObject).mark(readlimit);
+    } else {
+      delegate.mark(readlimit);
+    }
+  }
+
+  @Implementation
+  protected void reset() throws IOException {
+    if (delegate == null) {
+      reflector(AssetInputStreamReflector.class, realObject).reset();
+    } else {
+      delegate.reset();
+    }
+  }
+
+  @Implementation
+  protected boolean markSupported() {
+    return delegate == null
+        ? reflector(AssetInputStreamReflector.class, realObject).markSupported()
+        : delegate.markSupported();
+  }
+
+  @ForType(AssetInputStream.class)
+  interface AssetInputStreamReflector {
+
+    @Direct
+    int read();
+
+    @Direct
+    int read(byte[] b);
+
+    @Direct
+    int read(byte[] b, int off, int len);
+
+    @Direct
+    long skip(long n);
+
+    @Direct
+    int available();
+
+    @Direct
+    void close();
+
+    @Direct
+    void mark(int readlimit);
+
+    @Direct
+    void reset();
+
+    @Direct
+    boolean markSupported();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAssetManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAssetManager.java
new file mode 100644
index 0000000..7a8a57c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAssetManager.java
@@ -0,0 +1,1496 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.RuntimeEnvironment.castNativePtr;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.SuppressLint;
+import android.content.res.ApkAssets;
+import android.content.res.AssetFileDescriptor;
+import android.content.res.AssetManager;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelFileDescriptor;
+import android.util.AttributeSet;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import com.google.common.collect.Ordering;
+import dalvik.system.VMRuntime;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import javax.annotation.Nonnull;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.XmlResourceParserImpl;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.AttrData;
+import org.robolectric.res.AttributeResource;
+import org.robolectric.res.EmptyStyle;
+import org.robolectric.res.FileTypedResource;
+import org.robolectric.res.Fs;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResType;
+import org.robolectric.res.ResourceIds;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.Style;
+import org.robolectric.res.StyleData;
+import org.robolectric.res.StyleResolver;
+import org.robolectric.res.ThemeStyleSet;
+import org.robolectric.res.TypedResource;
+import org.robolectric.res.android.Asset;
+import org.robolectric.res.android.Registries;
+import org.robolectric.res.android.ResTable_config;
+import org.robolectric.res.builder.XmlBlock;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowAssetManager.Picker;
+import org.robolectric.util.Logger;
+import org.robolectric.util.TempDirectory;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressLint("NewApi")
+@Implements(value = AssetManager.class, /* this one works for P too... maxSdk = VERSION_CODES.O_MR1,*/
+    looseSignatures = true, shadowPicker = Picker.class)
+public class ShadowLegacyAssetManager extends ShadowAssetManager {
+
+  public static final Ordering<String> ATTRIBUTE_TYPE_PRECIDENCE =
+      Ordering.explicit(
+          "reference",
+          "color",
+          "boolean",
+          "integer",
+          "fraction",
+          "dimension",
+          "float",
+          "enum",
+          "flag",
+          "flags",
+          "string");
+
+  static boolean strictErrors = false;
+
+  private static final int STYLE_NUM_ENTRIES = 6;
+  private static final int STYLE_TYPE = 0;
+  private static final int STYLE_DATA = 1;
+  private static final int STYLE_ASSET_COOKIE = 2;
+  private static final int STYLE_RESOURCE_ID = 3;
+  private static final int STYLE_CHANGING_CONFIGURATIONS = 4;
+  private static final int STYLE_DENSITY = 5;
+
+  private static long nextInternalThemeId = 1000;
+  private static final Map<Long, NativeTheme> nativeThemes = new HashMap<>();
+
+  @RealObject protected AssetManager realObject;
+
+  boolean isSystem = false;
+
+  class NativeTheme {
+    private ThemeStyleSet themeStyleSet;
+
+    public NativeTheme(ThemeStyleSet themeStyleSet) {
+      this.themeStyleSet = themeStyleSet;
+    }
+
+    public ShadowLegacyAssetManager getShadowAssetManager() {
+      return ShadowLegacyAssetManager.this;
+    }
+  }
+
+  ResTable_config config = new ResTable_config();
+  private final Set<Path> assetDirs = new CopyOnWriteArraySet<>();
+
+  private void convertAndFill(AttributeResource attribute, TypedValue outValue, ResTable_config config, boolean resolveRefs) {
+    if (attribute.isNull()) {
+      outValue.type = TypedValue.TYPE_NULL;
+      outValue.data = TypedValue.DATA_NULL_UNDEFINED;
+      return;
+    } else if (attribute.isEmpty()) {
+      outValue.type = TypedValue.TYPE_NULL;
+      outValue.data = TypedValue.DATA_NULL_EMPTY;
+      return;
+    }
+
+    // short-circuit Android caching of loaded resources cuz our string positions don't remain stable...
+    outValue.assetCookie = Converter.getNextStringCookie();
+    outValue.changingConfigurations = 0;
+
+    // TODO: Handle resource and style references
+    if (attribute.isStyleReference()) {
+      return;
+    }
+
+    while (attribute.isResourceReference()) {
+      Integer resourceId;
+      ResName resName = attribute.getResourceReference();
+      if (attribute.getReferenceResId() != null) {
+        resourceId = attribute.getReferenceResId();
+      } else {
+        resourceId = getResourceTable().getResourceId(resName);
+      }
+
+      if (resourceId == null) {
+        throw new Resources.NotFoundException("unknown resource " + resName);
+      }
+      outValue.type = TypedValue.TYPE_REFERENCE;
+      if (!resolveRefs) {
+        // Just return the resourceId if resolveRefs is false.
+        outValue.data = resourceId;
+        return;
+      }
+
+      outValue.resourceId = resourceId;
+
+      TypedResource dereferencedRef = getResourceTable().getValue(resName, config);
+      if (dereferencedRef == null) {
+        Logger.strict("couldn't resolve %s from %s", resName.getFullyQualifiedName(), attribute);
+        return;
+      } else {
+        if (dereferencedRef.isFile()) {
+          outValue.type = TypedValue.TYPE_STRING;
+          outValue.data = 0;
+          outValue.assetCookie = Converter.getNextStringCookie();
+          outValue.string = dereferencedRef.asString();
+          return;
+        } else if (dereferencedRef.getData() instanceof String) {
+          attribute = new AttributeResource(attribute.resName, dereferencedRef.asString(), resName.packageName);
+          if (attribute.isResourceReference()) {
+            continue;
+          }
+          if (resolveRefs) {
+            Converter.getConverter(dereferencedRef.getResType()).fillTypedValue(attribute.value, outValue);
+            return;
+          }
+        }
+      }
+      break;
+    }
+
+    if (attribute.isNull()) {
+      outValue.type = TypedValue.TYPE_NULL;
+      return;
+    }
+
+    TypedResource attrTypeData = getAttrTypeData(attribute.resName);
+    if (attrTypeData != null) {
+      AttrData attrData = (AttrData) attrTypeData.getData();
+      String format = attrData.getFormat();
+      String[] types = format.split("\\|");
+      Arrays.sort(types, ATTRIBUTE_TYPE_PRECIDENCE);
+      for (String type : types) {
+        if ("reference".equals(type)) continue; // already handled above
+        Converter converter = Converter.getConverterFor(attrData, type);
+
+        if (converter != null) {
+          if (converter.fillTypedValue(attribute.value, outValue)) {
+            return;
+          }
+        }
+      }
+    } else {
+      /**
+       * In cases where the runtime framework doesn't know this attribute, e.g: viewportHeight (added in 21) on a
+       * KitKat runtine, then infer the attribute type from the value.
+       *
+       * TODO: When we are able to pass the SDK resources from the build environment then we can remove this
+       * and replace the NullResourceLoader with simple ResourceProvider that only parses attribute type information.
+       */
+      ResType resType = ResType.inferFromValue(attribute.value);
+      Converter.getConverter(resType).fillTypedValue(attribute.value, outValue);
+    }
+  }
+
+
+  public TypedResource getAttrTypeData(ResName resName) {
+    return getResourceTable().getValue(resName, config);
+  }
+
+  @Implementation
+  protected void __constructor__() {
+    if (RuntimeEnvironment.getApiLevel() >= P) {
+      invokeConstructor(AssetManager.class, realObject);
+    }
+
+  }
+
+  @Implementation
+  protected void __constructor__(boolean isSystem) {
+    this.isSystem = isSystem;
+    if (RuntimeEnvironment.getApiLevel() >= P) {
+      invokeConstructor(AssetManager.class, realObject, from(boolean.class, isSystem));
+    }
+
+  }
+
+  @Implementation(minSdk = P)
+  protected static long nativeCreate() {
+    // Return a fake pointer, must not be 0.
+    return 1;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected void init() {
+    // no op
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected void init(boolean isSystem) {
+    // no op
+  }
+
+  protected ResourceTable getResourceTable() {
+    return isSystem
+        ? RuntimeEnvironment.getSystemResourceTable()
+        : RuntimeEnvironment.getAppResourceTable();
+  }
+
+  @HiddenApi @Implementation
+  public CharSequence getResourceText(int ident) {
+    TypedResource value = getAndResolve(ident, config, true);
+    if (value == null) return null;
+    return (CharSequence) value.getData();
+  }
+
+  @HiddenApi @Implementation
+  public CharSequence getResourceBagText(int ident, int bagEntryId) {
+    throw new UnsupportedOperationException(); // todo
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected int getStringBlockCount() {
+    return 0;
+  }
+
+  @HiddenApi @Implementation
+  public String[] getResourceStringArray(final int id) {
+    CharSequence[] resourceTextArray = getResourceTextArray(id);
+    if (resourceTextArray == null) return null;
+    String[] strings = new String[resourceTextArray.length];
+    for (int i = 0; i < strings.length; i++) {
+      strings[i] = resourceTextArray[i].toString();
+    }
+    return strings;
+  }
+
+  @HiddenApi @Implementation
+  public int getResourceIdentifier(String name, String defType, String defPackage) {
+    Integer resourceId =
+        getResourceTable().getResourceId(ResName.qualifyResName(name, defPackage, defType));
+    return resourceId == null ? 0 : resourceId;
+  }
+
+  @HiddenApi @Implementation
+  public boolean getResourceValue(int ident, int density, TypedValue outValue, boolean resolveRefs) {
+    TypedResource value = getAndResolve(ident, config, resolveRefs);
+    if (value == null) return false;
+
+    getConverter(value).fillTypedValue(value.getData(), outValue);
+    return true;
+  }
+
+  private Converter getConverter(TypedResource value) {
+    if (value instanceof FileTypedResource.Image
+        || (value instanceof FileTypedResource
+            && ((FileTypedResource) value).getPath().getFileName().toString().endsWith(".xml"))) {
+      return new Converter.FromFilePath();
+    }
+    return Converter.getConverter(value.getResType());
+  }
+
+  @HiddenApi @Implementation
+  public CharSequence[] getResourceTextArray(int resId) {
+    TypedResource value = getAndResolve(resId, config, true);
+    if (value == null) return null;
+    List<TypedResource> items = getConverter(value).getItems(value);
+    CharSequence[] charSequences = new CharSequence[items.size()];
+    for (int i = 0; i < items.size(); i++) {
+      TypedResource typedResource = resolve(items.get(i), config, resId);
+      charSequences[i] = getConverter(typedResource).asCharSequence(typedResource);
+    }
+    return charSequences;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  public boolean getThemeValue(int themePtr, int ident, TypedValue outValue, boolean resolveRefs) {
+    return getThemeValue((long) themePtr, ident, outValue, resolveRefs);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  public boolean getThemeValue(long themePtr, int ident, TypedValue outValue, boolean resolveRefs) {
+    ResName resName = getResourceTable().getResName(ident);
+
+    ThemeStyleSet themeStyleSet = getNativeTheme(themePtr).themeStyleSet;
+    AttributeResource attrValue = themeStyleSet.getAttrValue(resName);
+    while(attrValue != null && attrValue.isStyleReference()) {
+      ResName attrResName = attrValue.getStyleReference();
+      if (attrValue.resName.equals(attrResName)) {
+        Logger.info("huh... circular reference for %s?", attrResName.getFullyQualifiedName());
+        return false;
+      }
+      attrValue = themeStyleSet.getAttrValue(attrResName);
+    }
+    if (attrValue != null) {
+      convertAndFill(attrValue, outValue, config, resolveRefs);
+      return true;
+    }
+    return false;
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected Object ensureStringBlocks() {
+    return null;
+  }
+
+  @Implementation
+  protected final InputStream open(String fileName) throws IOException {
+    return Fs.getInputStream(findAssetFile(fileName));
+  }
+
+  @Implementation
+  protected final InputStream open(String fileName, int accessMode) throws IOException {
+    return Fs.getInputStream(findAssetFile(fileName));
+  }
+
+  @Implementation
+  protected final AssetFileDescriptor openFd(String fileName) throws IOException {
+    Path path = findAssetFile(fileName);
+    if (path.getFileSystem().provider().getScheme().equals("jar")) {
+      path = getFileFromZip(path);
+    }
+    ParcelFileDescriptor parcelFileDescriptor =
+        ParcelFileDescriptor.open(path.toFile(), ParcelFileDescriptor.MODE_READ_ONLY);
+    return new AssetFileDescriptor(parcelFileDescriptor, 0, Files.size(path));
+  }
+
+  private Path findAssetFile(String fileName) throws IOException {
+    for (Path assetDir : getAllAssetDirs()) {
+      Path assetFile = assetDir.resolve(fileName);
+      if (Files.exists(assetFile)) {
+        return assetFile;
+      }
+    }
+
+    throw new FileNotFoundException("Asset file " + fileName + " not found");
+  }
+
+  /**
+   * Extract an asset from a zipped up assets provided by the build system, this is required because
+   * there is no way to get a FileDescriptor from a zip entry. This is a temporary measure for Bazel
+   * which can be removed once binary resources are supported.
+   */
+  private static Path getFileFromZip(Path path) {
+    byte[] buffer = new byte[1024];
+    try {
+      Path outputDir = new TempDirectory("robolectric_assets").create("fromzip");
+      try (InputStream zis = Fs.getInputStream(path)) {
+        Path fileFromZip = outputDir.resolve(path.getFileName().toString());
+
+        try (OutputStream fos = Files.newOutputStream(fileFromZip)) {
+          int len;
+          while ((len = zis.read(buffer)) > 0) {
+            fos.write(buffer, 0, len);
+          }
+        }
+        return fileFromZip;
+      }
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Implementation
+  protected final String[] list(String path) throws IOException {
+    List<String> assetFiles = new ArrayList<>();
+
+    for (Path assetsDir : getAllAssetDirs()) {
+      Path file;
+      if (path.isEmpty()) {
+        file = assetsDir;
+      } else {
+        file = assetsDir.resolve(path);
+      }
+
+      if (Files.isDirectory(file)) {
+        Collections.addAll(assetFiles, Fs.listFileNames(file));
+      }
+    }
+    return assetFiles.toArray(new String[assetFiles.size()]);
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected Number openAsset(String fileName, int mode) throws FileNotFoundException {
+    return 0;
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected ParcelFileDescriptor openAssetFd(String fileName, long[] outOffsets) throws IOException {
+    return null;
+  }
+
+  @HiddenApi @Implementation
+  public final InputStream openNonAsset(int cookie, String fileName, int accessMode) throws IOException {
+    final ResName resName = qualifyFromNonAssetFileName(fileName);
+
+    final FileTypedResource typedResource =
+        (FileTypedResource) getResourceTable().getValue(resName, config);
+
+    if (typedResource == null) {
+      throw new IOException("Unable to find resource for " + fileName);
+    }
+
+    InputStream stream;
+    if (accessMode == AssetManager.ACCESS_STREAMING) {
+      stream = Fs.getInputStream(typedResource.getPath());
+    } else {
+      stream = new ByteArrayInputStream(Fs.getBytes(typedResource.getPath()));
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= P) {
+      Asset asset = Asset.newFileAsset(typedResource);
+      long assetPtr = Registries.NATIVE_ASSET_REGISTRY.register(asset);
+      // Camouflage the InputStream as an AssetInputStream so subsequent instanceof checks pass.
+      stream = ShadowAssetInputStream.createAssetInputStream(stream, assetPtr, realObject);
+    }
+
+    return stream;
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected Number openNonAssetNative(int cookie, String fileName, int accessMode)
+      throws FileNotFoundException {
+    throw new IllegalStateException();
+  }
+
+  private ResName qualifyFromNonAssetFileName(String fileName) {
+    // Resources from a jar belong to the "android" namespace, except when they come from "resource_files.zip"
+    // when they are application resources produced by Bazel.
+    if (fileName.startsWith("jar:") && !fileName.contains("resource_files.zip")) {
+      // Must remove "jar:" prefix, or else qualifyFromFilePath fails on Windows
+      if (File.separatorChar == '\\') {
+        fileName = windowsWorkaround(fileName);
+      }
+      return ResName.qualifyFromFilePath("android", fileName.replaceFirst("jar:", ""));
+    } else {
+      return ResName.qualifyFromFilePath(
+          RuntimeEnvironment.getApplication().getPackageName(), fileName);
+    }
+  }
+
+  private String windowsWorkaround(String fileWithinJar) {
+    try {
+      String path = new URL(new URL(fileWithinJar).getPath()).getPath();
+      int bangI = path.indexOf('!');
+      String jarPath = path.substring(1, bangI);
+      return URLDecoder.decode(URLDecoder.decode(jarPath, "UTF-8"), "UTF-8")
+          + "!" + path.substring(bangI + 1);
+    } catch (MalformedURLException | UnsupportedEncodingException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @HiddenApi @Implementation
+  public final AssetFileDescriptor openNonAssetFd(int cookie, String fileName) throws IOException {
+    throw new IllegalStateException();
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected ParcelFileDescriptor openNonAssetFdNative(int cookie, String fileName, long[] outOffsets)
+      throws IOException {
+    throw new IllegalStateException();
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected Number openXmlAssetNative(int cookie, String fileName) throws FileNotFoundException {
+    throw new IllegalStateException();
+  }
+
+  @Implementation
+  protected final XmlResourceParser openXmlResourceParser(int cookie, String fileName)
+      throws IOException {
+    XmlBlock xmlBlock = XmlBlock.create(Fs.fromUrl(fileName), getResourceTable().getPackageName());
+    if (xmlBlock == null) {
+      throw new Resources.NotFoundException(fileName);
+    }
+    return getXmlResourceParser(getResourceTable(), xmlBlock, getResourceTable().getPackageName());
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final long seekAsset(int asset, long offset, int whence) {
+    return seekAsset((long) asset, offset, whence);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected long seekAsset(long asset, long offset, int whence) {
+    return 0;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final long getAssetLength(int asset) {
+    return getAssetLength((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected long getAssetLength(long asset) {
+    return 0;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final long getAssetRemainingLength(int asset) {
+    return getAssetRemainingLength((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected long getAssetRemainingLength(long assetHandle) {
+    return 0;
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected final void destroyAsset(int asset) {
+    destroyAsset((long) asset);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected void destroyAsset(long asset) {
+    // no op
+  }
+
+  protected XmlResourceParser loadXmlResourceParser(int resId, String type) throws Resources.NotFoundException {
+    ResName resName = getResName(resId);
+    ResName resolvedResName = resolveResName(resName, config);
+    if (resolvedResName == null) {
+      throw new RuntimeException("couldn't resolve " + resName.getFullyQualifiedName());
+    }
+    resName = resolvedResName;
+
+    XmlBlock block = getResourceTable().getXml(resName, config);
+    if (block == null) {
+      throw new Resources.NotFoundException(resName.getFullyQualifiedName());
+    }
+
+    ResourceTable resourceProvider = ResourceIds.isFrameworkResource(resId) ? RuntimeEnvironment.getSystemResourceTable() : RuntimeEnvironment.getCompileTimeResourceTable();
+
+    return getXmlResourceParser(resourceProvider, block, resName.packageName);
+  }
+
+  private XmlResourceParser getXmlResourceParser(ResourceTable resourceProvider, XmlBlock block, String packageName) {
+    return new XmlResourceParserImpl(
+        block.getDocument(),
+        block.getPath(),
+        block.getPackageName(),
+        packageName,
+        resourceProvider);
+  }
+
+  @HiddenApi @Implementation
+  public int addAssetPath(String path) {
+    assetDirs.add(Fs.fromUrl(path));
+    return 1;
+  }
+
+  @HiddenApi @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = M)
+  final protected int addAssetPathNative(String path) {
+    return addAssetPathNative(path, false);
+  }
+
+  @HiddenApi @Implementation(minSdk = N, maxSdk = O_MR1)
+  protected int addAssetPathNative(String path, boolean appAsLib) {
+    return 0;
+  }
+
+  @HiddenApi @Implementation(minSdk = P)
+  public void setApkAssets(Object apkAssetsObject, Object invalidateCachesObject) {
+    ApkAssets[] apkAssets = (ApkAssets[]) apkAssetsObject;
+    boolean invalidateCaches = (boolean) invalidateCachesObject;
+
+    for (ApkAssets apkAsset : apkAssets) {
+      assetDirs.add(Fs.fromUrl(apkAsset.getAssetPath()));
+    }
+    reflector(AssetManagerReflector.class, realObject).setApkAssets(apkAssets, invalidateCaches);
+  }
+
+  @HiddenApi @Implementation
+  public boolean isUpToDate() {
+    return true;
+  }
+
+  @HiddenApi @Implementation(maxSdk = M)
+  public void setLocale(String locale) {
+  }
+
+  @Implementation
+  protected String[] getLocales() {
+    return new String[0]; // todo
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = N_MR1)
+  public final void setConfiguration(
+      int mcc,
+      int mnc,
+      String locale,
+      int orientation,
+      int touchscreen,
+      int density,
+      int keyboard,
+      int keyboardHidden,
+      int navigation,
+      int screenWidth,
+      int screenHeight,
+      int smallestScreenWidthDp,
+      int screenWidthDp,
+      int screenHeightDp,
+      int screenLayout,
+      int uiMode,
+      int sdkVersion) {
+    setConfiguration(
+        mcc,
+        mnc,
+        locale,
+        orientation,
+        touchscreen,
+        density,
+        keyboard,
+        keyboardHidden,
+        navigation,
+        screenWidth,
+        screenHeight,
+        smallestScreenWidthDp,
+        screenWidthDp,
+        screenHeightDp,
+        screenLayout,
+        uiMode,
+        0,
+        sdkVersion);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = VERSION_CODES.O)
+  public void setConfiguration(
+      int mcc,
+      int mnc,
+      String locale,
+      int orientation,
+      int touchscreen,
+      int density,
+      int keyboard,
+      int keyboardHidden,
+      int navigation,
+      int screenWidth,
+      int screenHeight,
+      int smallestScreenWidthDp,
+      int screenWidthDp,
+      int screenHeightDp,
+      int screenLayout,
+      int uiMode,
+      int colorMode,
+      int majorVersion) {
+    // AssetManager* am = assetManagerForJavaObject(env, clazz);
+
+    ResTable_config config = new ResTable_config();
+
+    // Constants duplicated from Java class android.content.res.Configuration.
+    final int kScreenLayoutRoundMask = 0x300;
+    final int kScreenLayoutRoundShift = 8;
+
+    config.mcc = mcc;
+    config.mnc = mnc;
+    config.orientation = orientation;
+    config.touchscreen = touchscreen;
+    config.density = density;
+    config.keyboard = keyboard;
+    config.inputFlags = keyboardHidden;
+    config.navigation = navigation;
+    config.screenWidth = screenWidth;
+    config.screenHeight = screenHeight;
+    config.smallestScreenWidthDp = smallestScreenWidthDp;
+    config.screenWidthDp = screenWidthDp;
+    config.screenHeightDp = screenHeightDp;
+    config.screenLayout = screenLayout;
+    config.uiMode = uiMode;
+    // config.colorMode = colorMode; // todo
+    config.sdkVersion = majorVersion;
+    config.minorVersion = 0;
+
+    // In Java, we use a 32bit integer for screenLayout, while we only use an 8bit integer
+    // in C++. We must extract the round qualifier out of the Java screenLayout and put it
+    // into screenLayout2.
+    config.screenLayout2 =
+        (byte)((screenLayout & kScreenLayoutRoundMask) >> kScreenLayoutRoundShift);
+
+    if (locale != null) {
+      config.setBcp47Locale(locale);
+    }
+    // am->setConfiguration(config, locale8);
+
+    this.config = config;
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  public int[] getArrayIntResource(int resId) {
+    TypedResource value = getAndResolve(resId, config, true);
+    if (value == null) return null;
+    List<TypedResource> items = getConverter(value).getItems(value);
+    int[] ints = new int[items.size()];
+    for (int i = 0; i < items.size(); i++) {
+      TypedResource typedResource = resolve(items.get(i), config, resId);
+      ints[i] = getConverter(typedResource).asInt(typedResource);
+    }
+    return ints;
+  }
+
+  @HiddenApi @Implementation(minSdk = P)
+  protected int[] getResourceIntArray(int resId) {
+    return getArrayIntResource(resId);
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected String[] getArrayStringResource(int arrayResId) {
+    return new String[0];
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected int[] getArrayStringInfo(int arrayResId) {
+    return new int[0];
+  }
+
+  @HiddenApi @Implementation(maxSdk = O_MR1)
+  protected Number newTheme() {
+    return null;
+  }
+
+  protected TypedArray getTypedArrayResource(Resources resources, int resId) {
+    TypedResource value = getAndResolve(resId, config, true);
+    if (value == null) {
+      return null;
+    }
+    List<TypedResource> items = getConverter(value).getItems(value);
+    return getTypedArray(resources, items, resId);
+  }
+
+  private TypedArray getTypedArray(Resources resources, List<TypedResource> typedResources, int resId) {
+    final CharSequence[] stringData = new CharSequence[typedResources.size()];
+    final int totalLen = typedResources.size() * STYLE_NUM_ENTRIES;
+    final int[] data = new int[totalLen];
+
+    for (int i = 0; i < typedResources.size(); i++) {
+      final int offset = i * STYLE_NUM_ENTRIES;
+      TypedResource typedResource = typedResources.get(i);
+
+      // Classify the item.
+      int type = getResourceType(typedResource);
+      if (type == -1) {
+        // This type is unsupported; leave empty.
+        continue;
+      }
+
+      final TypedValue typedValue = new TypedValue();
+
+      if (type == TypedValue.TYPE_REFERENCE) {
+        final String reference = typedResource.asString();
+        ResName refResName =
+            AttributeResource.getResourceReference(
+                reference, typedResource.getXmlContext().getPackageName(), null);
+        typedValue.resourceId = getResourceTable().getResourceId(refResName);
+        typedValue.data = typedValue.resourceId;
+        typedResource = resolve(typedResource, config, typedValue.resourceId);
+
+        if (typedResource != null) {
+          // Reclassify to a non-reference type.
+          type = getResourceType(typedResource);
+          if (type == TypedValue.TYPE_ATTRIBUTE) {
+            type = TypedValue.TYPE_REFERENCE;
+          } else if (type == -1) {
+            // This type is unsupported; leave empty.
+            continue;
+          }
+        }
+      }
+
+      if (type == TypedValue.TYPE_ATTRIBUTE) {
+        final String reference = typedResource.asString();
+        final ResName attrResName =
+            AttributeResource.getStyleReference(
+                reference, typedResource.getXmlContext().getPackageName(), "attr");
+        typedValue.data = getResourceTable().getResourceId(attrResName);
+      }
+
+      if (typedResource != null && type != TypedValue.TYPE_NULL && type != TypedValue.TYPE_ATTRIBUTE) {
+        getConverter(typedResource).fillTypedValue(typedResource.getData(), typedValue);
+      }
+
+      data[offset + STYLE_TYPE] = type;
+      data[offset + STYLE_RESOURCE_ID] = typedValue.resourceId;
+      data[offset + STYLE_DATA] = typedValue.data;
+      data[offset + STYLE_ASSET_COOKIE] = typedValue.assetCookie;
+      data[offset + STYLE_CHANGING_CONFIGURATIONS] = typedValue.changingConfigurations;
+      data[offset + STYLE_DENSITY] = typedValue.density;
+      stringData[i] = typedResource == null ? null : typedResource.asString();
+    }
+
+    int[] indices = new int[typedResources.size() + 1]; /* keep zeroed out */
+    return ShadowTypedArray.create(resources, null, data, indices, typedResources.size(), stringData);
+  }
+
+  private int getResourceType(TypedResource typedResource) {
+    if (typedResource == null) {
+      return -1;
+    }
+    final ResType resType = typedResource.getResType();
+    int type;
+    if (typedResource.getData() == null || resType == ResType.NULL) {
+      type = TypedValue.TYPE_NULL;
+    } else if (typedResource.isReference()) {
+      type = TypedValue.TYPE_REFERENCE;
+    } else if (resType == ResType.STYLE) {
+      type = TypedValue.TYPE_ATTRIBUTE;
+    } else if (resType == ResType.CHAR_SEQUENCE || resType == ResType.DRAWABLE) {
+      type = TypedValue.TYPE_STRING;
+    } else if (resType == ResType.INTEGER) {
+      type = TypedValue.TYPE_INT_DEC;
+    } else if (resType == ResType.FLOAT || resType == ResType.FRACTION) {
+      type = TypedValue.TYPE_FLOAT;
+    } else if (resType == ResType.BOOLEAN) {
+      type = TypedValue.TYPE_INT_BOOLEAN;
+    } else if (resType == ResType.DIMEN) {
+      type = TypedValue.TYPE_DIMENSION;
+    } else if (resType == ResType.COLOR) {
+      type = TypedValue.TYPE_INT_COLOR_ARGB8;
+    } else if (resType == ResType.TYPED_ARRAY || resType == ResType.CHAR_SEQUENCE_ARRAY) {
+      type = TypedValue.TYPE_REFERENCE;
+    } else {
+      type = -1;
+    }
+    return type;
+  }
+
+  @HiddenApi @Implementation
+  public Number createTheme() {
+    synchronized (nativeThemes) {
+      long nativePtr = nextInternalThemeId++;
+      nativeThemes.put(nativePtr, new NativeTheme(new ThemeStyleSet()));
+      return castNativePtr(nativePtr);
+    }
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected static void dumpTheme(long theme, int priority, String tag, String prefix) {
+    throw new UnsupportedOperationException("not yet implemented");
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  public void releaseTheme(int themePtr) {
+    // no op
+  }
+
+  private static NativeTheme getNativeTheme(long themePtr) {
+    NativeTheme nativeTheme;
+    synchronized (nativeThemes) {
+      nativeTheme = nativeThemes.get(themePtr);
+    }
+    if (nativeTheme == null) {
+      throw new RuntimeException("no theme " + themePtr + " found in AssetManager");
+    }
+    return nativeTheme;
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP)
+  public void releaseTheme(long themePtr) {
+    synchronized (nativeThemes) {
+      nativeThemes.remove(themePtr);
+    }
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  protected void deleteTheme(int theme) {
+    deleteTheme((long) theme);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected void deleteTheme(long theme) {
+    // no op
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  public static void applyThemeStyle(int themePtr, int styleRes, boolean force) {
+    applyThemeStyle((long) themePtr, styleRes, force);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  public static void applyThemeStyle(long themePtr, int styleRes, boolean force) {
+    NativeTheme nativeTheme = getNativeTheme(themePtr);
+    Style style = nativeTheme.getShadowAssetManager().resolveStyle(styleRes, null);
+    nativeTheme.themeStyleSet.apply(style, force);
+  }
+
+  @HiddenApi @Implementation(maxSdk = KITKAT_WATCH)
+  public static void copyTheme(int destPtr, int sourcePtr) {
+    copyTheme((long) destPtr, (long) sourcePtr);
+  }
+
+  @HiddenApi @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  public static void copyTheme(long destPtr, long sourcePtr) {
+    NativeTheme destNativeTheme = getNativeTheme(destPtr);
+    NativeTheme sourceNativeTheme = getNativeTheme(sourcePtr);
+    destNativeTheme.themeStyleSet = sourceNativeTheme.themeStyleSet.copy();
+  }
+
+  @HiddenApi @Implementation(minSdk = P, maxSdk = P)
+  protected static void nativeThemeCopy(long destPtr, long sourcePtr) {
+    copyTheme(destPtr, sourcePtr);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = VERSION_CODES.Q)
+  protected static void nativeThemeCopy(
+      long dstAssetManagerPtr, long dstThemePtr, long srcAssetManagerPtr, long srcThemePtr) {
+    copyTheme(dstThemePtr, srcThemePtr);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean applyStyle(
+      int themeToken,
+      int defStyleAttr,
+      int defStyleRes,
+      int xmlParserToken,
+      int[] attrs,
+      int[] outValues,
+      int[] outIndices) {
+    return applyStyle(
+        (long) themeToken,
+        defStyleAttr,
+        defStyleRes,
+        (long) xmlParserToken,
+        attrs,
+        outValues,
+        outIndices);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = O, maxSdk = O_MR1)
+  protected static void applyStyle(
+      long themeToken,
+      int defStyleAttr,
+      int defStyleRes,
+      long xmlParserToken,
+      int[] inAttrs,
+      int length,
+      long outValuesAddress,
+      long outIndicesAddress) {
+    ShadowVMRuntime shadowVMRuntime = Shadow.extract(VMRuntime.getRuntime());
+    int[] outValues = (int[])shadowVMRuntime.getObjectForAddress(outValuesAddress);
+    int[] outIndices = (int[])shadowVMRuntime.getObjectForAddress(outIndicesAddress);
+    applyStyle(
+        themeToken, defStyleAttr, defStyleRes, xmlParserToken, inAttrs, outValues, outIndices);
+  }
+
+  @HiddenApi @Implementation(minSdk = P)
+  protected void applyStyleToTheme(long themePtr, int resId, boolean force) {
+    applyThemeStyle(themePtr, resId, force);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected static boolean applyStyle(
+      long themeToken,
+      int defStyleAttr,
+      int defStyleRes,
+      long xmlParserToken,
+      int[] attrs,
+      int[] outValues,
+      int[] outIndices) {
+    // no-op
+    return false;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected static boolean resolveAttrs(
+      long themeToken,
+      int defStyleAttr,
+      int defStyleRes,
+      int[] inValues,
+      int[] attrs,
+      int[] outValues,
+      int[] outIndices) {
+    // no-op
+    return false;
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected boolean retrieveAttributes(
+      int xmlParserToken, int[] attrs, int[] outValues, int[] outIndices) {
+    return retrieveAttributes((long)xmlParserToken, attrs, outValues, outIndices);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected boolean retrieveAttributes(
+      long xmlParserToken, int[] attrs, int[] outValues, int[] outIndices) {
+    return false;
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int loadThemeAttributeValue(
+      int themeHandle, int ident, TypedValue outValue, boolean resolve) {
+    return loadThemeAttributeValue((long) themeHandle, ident, outValue, resolve);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected static int loadThemeAttributeValue(
+      long themeHandle, int ident, TypedValue outValue, boolean resolve) {
+    // no-op
+    return 0;
+  }
+
+  /////////////////////////
+
+  Style resolveStyle(int resId, Style themeStyleSet) {
+    return resolveStyle(getResName(resId), themeStyleSet);
+  }
+
+  private Style resolveStyle(@Nonnull ResName themeStyleName, Style themeStyleSet) {
+    TypedResource themeStyleResource = getResourceTable().getValue(themeStyleName, config);
+    if (themeStyleResource == null) return null;
+    StyleData themeStyleData = (StyleData) themeStyleResource.getData();
+    if (themeStyleSet == null) {
+      themeStyleSet = new ThemeStyleSet();
+    }
+    return new StyleResolver(
+        getResourceTable(),
+        legacyShadowOf(AssetManager.getSystem()).getResourceTable(),
+        themeStyleData,
+        themeStyleSet,
+        themeStyleName,
+        config);
+  }
+
+  private TypedResource getAndResolve(int resId, ResTable_config config, boolean resolveRefs) {
+    TypedResource value = getResourceTable().getValue(resId, config);
+    if (resolveRefs) {
+      value = resolve(value, config, resId);
+    }
+    return value;
+  }
+
+  TypedResource resolve(TypedResource value, ResTable_config config, int resId) {
+    return resolveResourceValue(value, config, resId);
+  }
+
+  protected ResName resolveResName(ResName resName, ResTable_config config) {
+    TypedResource value = getResourceTable().getValue(resName, config);
+    return resolveResource(value, config, resName);
+  }
+
+  // todo: DRY up #resolveResource vs #resolveResourceValue
+  private ResName resolveResource(TypedResource value, ResTable_config config, ResName resName) {
+    while (value != null && value.isReference()) {
+      String s = value.asString();
+      if (AttributeResource.isNull(s) || AttributeResource.isEmpty(s)) {
+        value = null;
+      } else {
+        String refStr = s.substring(1).replace("+", "");
+        resName = ResName.qualifyResName(refStr, resName);
+        value = getResourceTable().getValue(resName, config);
+      }
+    }
+
+    return resName;
+  }
+
+  private TypedResource resolveResourceValue(TypedResource value, ResTable_config config, ResName resName) {
+    while (value != null && value.isReference()) {
+      String s = value.asString();
+      if (AttributeResource.isNull(s) || AttributeResource.isEmpty(s)) {
+        value = null;
+      } else {
+        String refStr = s.substring(1).replace("+", "");
+        resName = ResName.qualifyResName(refStr, resName);
+        value = getResourceTable().getValue(resName, config);
+      }
+    }
+
+    return value;
+  }
+
+  protected TypedResource resolveResourceValue(TypedResource value, ResTable_config config, int resId) {
+    ResName resName = getResName(resId);
+    return resolveResourceValue(value, config, resName);
+  }
+
+  private TypedValue buildTypedValue(AttributeSet set, int resId, int defStyleAttr, Style themeStyleSet, int defStyleRes) {
+    /*
+     * When determining the final value of a particular attribute, there are four inputs that come into play:
+     *
+     * 1. Any attribute values in the given AttributeSet.
+     * 2. The style resource specified in the AttributeSet (named "style").
+     * 3. The default style specified by defStyleAttr and defStyleRes
+     * 4. The base values in this theme.
+     */
+    Style defStyleFromAttr = null;
+    Style defStyleFromRes = null;
+    Style styleAttrStyle = null;
+
+    if (defStyleAttr != 0) {
+      // Load the theme attribute for the default style attributes. E.g., attr/buttonStyle
+      ResName defStyleName = getResName(defStyleAttr);
+
+      // Load the style for the default style attribute. E.g. "@style/Widget.Robolectric.Button";
+      AttributeResource defStyleAttribute = themeStyleSet.getAttrValue(defStyleName);
+      if (defStyleAttribute != null) {
+        while (defStyleAttribute.isStyleReference()) {
+          AttributeResource other = themeStyleSet.getAttrValue(defStyleAttribute.getStyleReference());
+          if (other == null) {
+            throw new RuntimeException("couldn't dereference " + defStyleAttribute);
+          }
+          defStyleAttribute = other;
+        }
+
+        if (defStyleAttribute.isResourceReference()) {
+          ResName defStyleResName = defStyleAttribute.getResourceReference();
+          defStyleFromAttr = resolveStyle(defStyleResName, themeStyleSet);
+        }
+      }
+    }
+
+    if (set != null && set.getStyleAttribute() != 0) {
+      ResName styleAttributeResName = getResName(set.getStyleAttribute());
+      while (styleAttributeResName.type.equals("attr")) {
+        AttributeResource attrValue = themeStyleSet.getAttrValue(styleAttributeResName);
+        if (attrValue == null) {
+          throw new RuntimeException(
+              "no value for "
+                  + styleAttributeResName.getFullyQualifiedName()
+                  + " in "
+                  + themeStyleSet);
+        }
+        if (attrValue.isResourceReference()) {
+          styleAttributeResName = attrValue.getResourceReference();
+        } else if (attrValue.isStyleReference()) {
+          styleAttributeResName = attrValue.getStyleReference();
+        }
+      }
+      styleAttrStyle = resolveStyle(styleAttributeResName, themeStyleSet);
+    }
+
+    if (defStyleRes != 0) {
+      ResName resName = getResName(defStyleRes);
+      if (resName.type.equals("attr")) {
+        // todo: this should be a style resId, not an attr
+        System.out.println("WARN: " + resName.getFullyQualifiedName() + " should be a style resId");
+        // AttributeResource attributeValue = findAttributeValue(defStyleRes, set, styleAttrStyle, defStyleFromAttr, defStyleFromAttr, themeStyleSet);
+        // if (attributeValue != null) {
+        //   if (attributeValue.isStyleReference()) {
+        //     resName = themeStyleSet.getAttrValue(attributeValue.getStyleReference()).getResourceReference();
+        //   } else if (attributeValue.isResourceReference()) {
+        //     resName = attributeValue.getResourceReference();
+        //   }
+        // }
+      } else if (resName.type.equals("style")) {
+        defStyleFromRes = resolveStyle(resName, themeStyleSet);
+      }
+    }
+
+    AttributeResource attribute = findAttributeValue(resId, set, styleAttrStyle, defStyleFromAttr, defStyleFromRes, themeStyleSet);
+    while (attribute != null && attribute.isStyleReference()) {
+      ResName otherAttrName = attribute.getStyleReference();
+      if (attribute.resName.equals(otherAttrName)) {
+        Logger.info("huh... circular reference for %s?", attribute.resName.getFullyQualifiedName());
+        return null;
+      }
+      ResName resName = getResourceTable().getResName(resId);
+
+      AttributeResource otherAttr = themeStyleSet.getAttrValue(otherAttrName);
+      if (otherAttr == null) {
+        strictError(
+            "no such attr %s in %s while resolving value for %s",
+            attribute.value, themeStyleSet, resName.getFullyQualifiedName());
+        attribute = null;
+      } else {
+        attribute = new AttributeResource(resName, otherAttr.value, otherAttr.contextPackageName);
+      }
+    }
+
+    if (attribute == null || attribute.isNull()) {
+      return null;
+    } else {
+      TypedValue typedValue = new TypedValue();
+      convertAndFill(attribute, typedValue, config, true);
+      return typedValue;
+    }
+  }
+
+  private void strictError(String message, Object... args) {
+    if (strictErrors) {
+      throw new RuntimeException(String.format(message, args));
+    } else {
+      Logger.strict(message, args);
+    }
+  }
+
+  TypedArray attrsToTypedArray(Resources resources, AttributeSet set, int[] attrs, int defStyleAttr, long nativeTheme, int defStyleRes) {
+    CharSequence[] stringData = new CharSequence[attrs.length];
+    int[] data = new int[attrs.length * STYLE_NUM_ENTRIES];
+    int[] indices = new int[attrs.length + 1];
+    int nextIndex = 0;
+
+    Style themeStyleSet = nativeTheme == 0
+        ? new EmptyStyle()
+        : getNativeTheme(nativeTheme).themeStyleSet;
+
+    for (int i = 0; i < attrs.length; i++) {
+      int offset = i * STYLE_NUM_ENTRIES;
+
+      TypedValue typedValue = buildTypedValue(set, attrs[i], defStyleAttr, themeStyleSet, defStyleRes);
+      if (typedValue != null) {
+        //noinspection PointlessArithmeticExpression
+        data[offset + STYLE_TYPE] = typedValue.type;
+        data[offset + STYLE_DATA] = typedValue.type == TypedValue.TYPE_STRING ? i : typedValue.data;
+        data[offset + STYLE_ASSET_COOKIE] = typedValue.assetCookie;
+        data[offset + STYLE_RESOURCE_ID] = typedValue.resourceId;
+        data[offset + STYLE_CHANGING_CONFIGURATIONS] = typedValue.changingConfigurations;
+        data[offset + STYLE_DENSITY] = typedValue.density;
+        stringData[i] = typedValue.string;
+
+        indices[nextIndex + 1] = i;
+        nextIndex++;
+      }
+    }
+
+    indices[0] = nextIndex;
+
+    TypedArray typedArray = ShadowTypedArray.create(resources, attrs, data, indices, nextIndex, stringData);
+    if (set != null) {
+      ShadowTypedArray shadowTypedArray = Shadow.extract(typedArray);
+      shadowTypedArray.positionDescription = set.getPositionDescription();
+    }
+    return typedArray;
+  }
+
+  private AttributeResource findAttributeValue(int resId, AttributeSet attributeSet, Style styleAttrStyle, Style defStyleFromAttr, Style defStyleFromRes, @Nonnull Style themeStyleSet) {
+    if (attributeSet != null) {
+      for (int i = 0; i < attributeSet.getAttributeCount(); i++) {
+        if (attributeSet.getAttributeNameResource(i) == resId) {
+          String attributeValue;
+          try {
+            attributeValue = attributeSet.getAttributeValue(i);
+          } catch (IndexOutOfBoundsException e) {
+            // type is TypedValue.TYPE_NULL, ignore...
+            continue;
+          }
+          if (attributeValue != null) {
+            String defaultPackageName =
+                ResourceIds.isFrameworkResource(resId)
+                    ? "android"
+                    : RuntimeEnvironment.getApplication().getPackageName();
+            ResName resName =
+                ResName.qualifyResName(
+                    attributeSet.getAttributeName(i), defaultPackageName, "attr");
+            Integer referenceResId = null;
+            if (AttributeResource.isResourceReference(attributeValue)) {
+              referenceResId = attributeSet.getAttributeResourceValue(i, -1);
+              // binary AttributeSet references have a string value of @resId rather than fully qualified resource name
+              if (referenceResId != 0) {
+                ResName refResName = getResourceTable().getResName(referenceResId);
+                if (refResName != null) {
+                  attributeValue = "@" + refResName.getFullyQualifiedName();
+                }
+              }
+            }
+            return new AttributeResource(resName, attributeValue, "fixme!!!", referenceResId);
+          }
+        }
+      }
+    }
+
+    ResName attrName = getResourceTable().getResName(resId);
+    if (attrName == null) return null;
+
+    if (styleAttrStyle != null) {
+      AttributeResource attribute = styleAttrStyle.getAttrValue(attrName);
+      if (attribute != null) {
+        return attribute;
+      }
+    }
+
+    // else if attr in defStyleFromAttr, use its value
+    if (defStyleFromAttr != null) {
+      AttributeResource attribute = defStyleFromAttr.getAttrValue(attrName);
+      if (attribute != null) {
+        return attribute;
+      }
+    }
+
+    if (defStyleFromRes != null) {
+      AttributeResource attribute = defStyleFromRes.getAttrValue(attrName);
+      if (attribute != null) {
+        return attribute;
+      }
+    }
+
+    // else if attr in theme, use its value
+    return themeStyleSet.getAttrValue(attrName);
+  }
+
+  @Override
+  Collection<Path> getAllAssetDirs() {
+    return assetDirs;
+  }
+
+  @Nonnull private ResName getResName(int id) {
+    ResName resName = getResourceTable().getResName(id);
+    if (resName == null) {
+      throw new Resources.NotFoundException("Resource ID #0x" + Integer.toHexString(id));
+    }
+    return resName;
+  }
+
+  @Implementation
+  protected String getResourceName(int resid) {
+    return getResName(resid).getFullyQualifiedName();
+  }
+
+  @Implementation
+  protected String getResourcePackageName(int resid) {
+    return getResName(resid).packageName;
+  }
+
+  @Implementation
+  protected String getResourceTypeName(int resid) {
+    return getResName(resid).type;
+  }
+
+  @Implementation
+  protected String getResourceEntryName(int resid) {
+    return getResName(resid).name;
+  }
+
+  @Implementation(maxSdk = O_MR1)
+  protected int getArraySize(int id) {
+    return 0;
+  }
+
+  @Implementation(maxSdk = O_MR1)
+  protected int retrieveArray(int id, int[] outValues) {
+    return 0;
+  }
+
+  @Implementation(maxSdk = O_MR1)
+  protected Number getNativeStringBlock(int block) {
+    throw new IllegalStateException();
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected final SparseArray<String> getAssignedPackageIdentifiers() {
+    return new SparseArray<>();
+  }
+
+  @Implementation(maxSdk = O_MR1)
+  protected int loadResourceValue(int ident, short density, TypedValue outValue, boolean resolve) {
+    return 0;
+  }
+
+  @Implementation(maxSdk = O_MR1)
+  protected int loadResourceBagValue(int ident, int bagEntryId, TypedValue outValue, boolean resolve) {
+    return 0;
+  }
+
+  // static void NativeAssetDestroy(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static void nativeAssetDestroy(long asset_ptr) {
+    ShadowArscAssetManager9.nativeAssetDestroy(asset_ptr);
+  }
+
+  // static jint NativeAssetReadChar(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static int nativeAssetReadChar(long asset_ptr) {
+    return ShadowArscAssetManager9.nativeAssetReadChar(asset_ptr);
+  }
+
+  // static jint NativeAssetRead(JNIEnv* env, jclass /*clazz*/, jlong asset_ptr, jbyteArray java_buffer,
+//                             jint offset, jint len) {
+  @Implementation(minSdk = P)
+  protected static int nativeAssetRead(long asset_ptr, byte[] java_buffer, int offset, int len)
+      throws IOException {
+    return ShadowArscAssetManager9.nativeAssetRead(asset_ptr, java_buffer, offset, len);
+  }
+
+  // static jlong NativeAssetSeek(JNIEnv* env, jclass /*clazz*/, jlong asset_ptr, jlong offset,
+//                              jint whence) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetSeek(long asset_ptr, long offset, int whence) {
+    return ShadowArscAssetManager9.nativeAssetSeek(asset_ptr, offset, whence);
+  }
+
+  // static jlong NativeAssetGetLength(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetGetLength(long asset_ptr) {
+    return ShadowArscAssetManager9.nativeAssetGetLength(asset_ptr);
+  }
+
+  // static jlong NativeAssetGetRemainingLength(JNIEnv* /*env*/, jclass /*clazz*/, jlong asset_ptr) {
+  @Implementation(minSdk = P)
+  protected static long nativeAssetGetRemainingLength(long asset_ptr) {
+    return ShadowArscAssetManager9.nativeAssetGetRemainingLength(asset_ptr);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.Q, maxSdk = VERSION_CODES.R)
+  protected static String[] nativeCreateIdmapsForStaticOverlaysTargetingAndroid() {
+    return new String[0];
+  }
+
+  @Resetter
+  public static void reset() {
+    // todo: ShadowPicker doesn't discriminate properly between concrete shadow classes for resetters...
+    if (useLegacy()) {
+      if (RuntimeEnvironment.getApiLevel() >= P) {
+        _AssetManager28_ _assetManagerStatic_ = reflector(_AssetManager28_.class);
+        _assetManagerStatic_.setSystemApkAssetsSet(null);
+        _assetManagerStatic_.setSystemApkAssets(null);
+      }
+      reflector(_AssetManager_.class).setSystem(null);
+    }
+  }
+
+  @ForType(AssetManager.class)
+  interface AssetManagerReflector {
+
+    @Direct
+    void setApkAssets(ApkAssets[] apkAssetsObject, boolean invalidateCachesObject);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAsyncTask.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAsyncTask.java
new file mode 100644
index 0000000..597c6d8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAsyncTask.java
@@ -0,0 +1,166 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.AsyncTask;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.ForType;
+
+/** A {@link AsyncTask} shadow for {@link LooperMode.Mode.LEGACY}. */
+@Implements(
+    value = AsyncTask.class,
+    shadowPicker = ShadowAsyncTask.Picker.class,
+    // TODO: turn off shadowOf generation. Figure out why this is needed
+    isInAndroidSdk = false)
+public class ShadowLegacyAsyncTask<Params, Progress, Result> extends ShadowAsyncTask {
+
+  @RealObject private AsyncTask<Params, Progress, Result> realAsyncTask;
+
+  private final FutureTask<Result> future;
+  private final BackgroundWorker worker;
+  private AsyncTask.Status status = AsyncTask.Status.PENDING;
+
+  public ShadowLegacyAsyncTask() {
+    worker = new BackgroundWorker();
+    future = createFuture(worker);
+  }
+
+  protected FutureTask<Result> createFuture(Callable<Result> callable) {
+    return new FutureTask<Result>(callable) {
+      @Override
+      protected void done() {
+        status = AsyncTask.Status.FINISHED;
+        try {
+          final Result result = get();
+
+          try {
+            RuntimeEnvironment.getMasterScheduler()
+                .post(
+                    () ->
+                        reflector(LegacyAsyncTaskReflector.class, realAsyncTask)
+                            .onPostExecute(result));
+          } catch (Throwable t) {
+            throw new OnPostExecuteException(t);
+          }
+        } catch (CancellationException e) {
+          RuntimeEnvironment.getMasterScheduler()
+              .post(
+                  () -> reflector(LegacyAsyncTaskReflector.class, realAsyncTask).onCancelled(null));
+        } catch (InterruptedException e) {
+          // Ignore.
+        } catch (OnPostExecuteException e) {
+          throw new RuntimeException(e.getCause());
+        } catch (Throwable t) {
+          throw new RuntimeException(
+              "An error occurred while executing doInBackground()", t.getCause());
+        }
+      }
+    };
+  }
+
+  @Implementation
+  protected boolean isCancelled() {
+    return future.isCancelled();
+  }
+
+  @Implementation
+  protected boolean cancel(boolean mayInterruptIfRunning) {
+    return future.cancel(mayInterruptIfRunning);
+  }
+
+  @Implementation
+  protected Result get() throws InterruptedException, ExecutionException {
+    return future.get();
+  }
+
+  @Implementation
+  protected Result get(long timeout, TimeUnit unit)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    return future.get(timeout, unit);
+  }
+
+  @Implementation
+  protected AsyncTask<Params, Progress, Result> execute(final Params... params) {
+    status = AsyncTask.Status.RUNNING;
+    reflector(LegacyAsyncTaskReflector.class, realAsyncTask).onPreExecute();
+
+    worker.params = params;
+
+    ShadowLegacyLooper.getBackgroundThreadScheduler().post(future);
+
+    return realAsyncTask;
+  }
+
+  @Implementation
+  protected AsyncTask<Params, Progress, Result> executeOnExecutor(
+      Executor executor, Params... params) {
+    status = AsyncTask.Status.RUNNING;
+    reflector(LegacyAsyncTaskReflector.class, realAsyncTask).onPreExecute();
+
+    worker.params = params;
+    executor.execute(future);
+
+    return realAsyncTask;
+  }
+
+  @Implementation
+  protected AsyncTask.Status getStatus() {
+    return status;
+  }
+
+  /**
+   * Enqueue a call to {@link AsyncTask#onProgressUpdate(Object[])} on UI looper (or run it
+   * immediately if the looper it is not paused).
+   *
+   * @param values The progress values to update the UI with.
+   * @see AsyncTask#publishProgress(Object[])
+   */
+  @Implementation
+  protected void publishProgress(final Progress... values) {
+    RuntimeEnvironment.getMasterScheduler()
+        .post(
+            () ->
+                reflector(LegacyAsyncTaskReflector.class, realAsyncTask).onProgressUpdate(values));
+  }
+
+  private final class BackgroundWorker implements Callable<Result> {
+    Params[] params;
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Result call() throws Exception {
+      return (Result)
+          reflector(LegacyAsyncTaskReflector.class, realAsyncTask).doInBackground(params);
+    }
+  }
+
+  private static class OnPostExecuteException extends Exception {
+    public OnPostExecuteException(Throwable throwable) {
+      super(throwable);
+    }
+  }
+
+  @ForType(AsyncTask.class)
+  interface LegacyAsyncTaskReflector {
+    Object doInBackground(Object... params);
+
+    void onPreExecute();
+
+    void onPostExecute(Object result);
+
+    void onProgressUpdate(Object... values);
+
+    void onCancelled(Object object);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAsyncTaskLoader.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAsyncTaskLoader.java
new file mode 100644
index 0000000..c1ad707
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyAsyncTaskLoader.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import android.content.AsyncTaskLoader;
+import android.content.Context;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+
+/**
+ * The shadow {@link AsyncTaskLoader} for {@link LooperMode.Mode.LEGACY}.
+ */
+@Implements(value = AsyncTaskLoader.class, shadowPicker = ShadowAsyncTaskLoader.Picker.class,
+    isInAndroidSdk = false)
+public class ShadowLegacyAsyncTaskLoader<D> extends ShadowAsyncTaskLoader {
+  @RealObject private AsyncTaskLoader<D> realObject;
+  private BackgroundWorker worker;
+
+  @Implementation
+  protected void __constructor__(Context context) {
+    worker = new BackgroundWorker();
+  }
+
+  @Implementation
+  protected void onForceLoad() {
+    FutureTask<D> future = new FutureTask<D>(worker) {
+      @Override
+      protected void done() {
+        try {
+          final D result = get();
+          ShadowApplication.getInstance().getForegroundThreadScheduler().post(new Runnable() {
+            @Override
+            public void run() {
+              realObject.deliverResult(result);
+            }
+          });
+        } catch (InterruptedException e) {
+          // Ignore
+        } catch (ExecutionException e) {
+          throw new RuntimeException(e.getCause());
+        }
+      }
+    };
+
+    ShadowApplication.getInstance().getBackgroundThreadScheduler().post(future);
+  }
+
+  private final class BackgroundWorker implements Callable<D> {
+    @Override
+    public D call() throws Exception {
+      return realObject.loadInBackground();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyChoreographer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyChoreographer.java
new file mode 100644
index 0000000..3e48f65
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyChoreographer.java
@@ -0,0 +1,186 @@
+package org.robolectric.shadows;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.view.Choreographer;
+import android.view.Choreographer.FrameCallback;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.SoftThreadLocal;
+import org.robolectric.util.TimeUtils;
+
+/**
+ * The {@link Choreographer} shadow for {@link LooperMode.Mode.PAUSED}.
+ *
+ * <p>In {@link LooperMode.Mode.PAUSED} mode, Robolectric maintains its own concept of the current
+ * time from the Choreographer's point of view, aimed at making animations work correctly. Time
+ * starts out at 0 and advances by {@code frameInterval} every time {@link
+ * Choreographer#getFrameTimeNanos()} is called.
+ */
+@Implements(
+    value = Choreographer.class,
+    shadowPicker = ShadowChoreographer.Picker.class,
+    isInAndroidSdk = false)
+public class ShadowLegacyChoreographer extends ShadowChoreographer {
+  private long nanoTime = 0;
+  private static long FRAME_INTERVAL = 10 * TimeUtils.NANOS_PER_MS; // 10ms
+  private static final Thread MAIN_THREAD = Thread.currentThread();
+  private static SoftThreadLocal<Choreographer> instance = makeThreadLocal();
+  private Handler handler = new Handler(Looper.myLooper());
+  private static volatile int postCallbackDelayMillis = 0;
+  private static volatile int postFrameCallbackDelayMillis = 0;
+
+  @SuppressWarnings("ReturnValueIgnored")
+  private static SoftThreadLocal<Choreographer> makeThreadLocal() {
+    return new SoftThreadLocal<Choreographer>() {
+      @Override
+      protected Choreographer create() {
+        Looper looper = Looper.myLooper();
+        if (looper == null) {
+          throw new IllegalStateException("The current thread must have a looper!");
+        }
+
+        // Choreographer's constructor changes somewhere in Android O...
+        try {
+          Choreographer.class.getDeclaredConstructor(Looper.class);
+          return Shadow.newInstance(
+              Choreographer.class, new Class[] {Looper.class}, new Object[] {looper});
+        } catch (NoSuchMethodException e) {
+          return Shadow.newInstance(
+              Choreographer.class, new Class[] {Looper.class, int.class}, new Object[] {looper, 0});
+        }
+      }
+    };
+  }
+
+  /**
+   * Allows application to specify a fixed amount of delay when {@link #postCallback(int, Runnable,
+   * Object)} is invoked. The default delay value is 0. This can be used to avoid infinite animation
+   * tasks to be spawned when the Robolectric {@link org.robolectric.util.Scheduler} is in {@link
+   * org.robolectric.util.Scheduler.IdleState#PAUSED} mode.
+   */
+  public static void setPostCallbackDelay(int delayMillis) {
+    postCallbackDelayMillis = delayMillis;
+  }
+
+  /**
+   * Allows application to specify a fixed amount of delay when {@link
+   * #postFrameCallback(FrameCallback)} is invoked. The default delay value is 0. This can be used
+   * to avoid infinite animation tasks to be spawned when the Robolectric {@link
+   * org.robolectric.util.Scheduler} is in {@link org.robolectric.util.Scheduler.IdleState#PAUSED}
+   * mode.
+   */
+  public static void setPostFrameCallbackDelay(int delayMillis) {
+    postFrameCallbackDelayMillis = delayMillis;
+  }
+
+  @Implementation
+  protected static Choreographer getInstance() {
+    return instance.get();
+  }
+
+  /**
+   * The default implementation will call {@link #postCallbackDelayed(int, Runnable, Object, long)}
+   * with no delay. {@link android.animation.AnimationHandler} calls this method to schedule
+   * animation updates infinitely. Because during a Robolectric test the system time is paused and
+   * execution of the event loop is invoked for each test instruction, the behavior of
+   * AnimationHandler would result in endless looping (the execution of the task results in a new
+   * animation task created and scheduled to the front of the event loop queue).
+   *
+   * <p>To prevent endless looping, a test may call {@link #setPostCallbackDelay(int)} to specify a
+   * small delay when animation is scheduled.
+   *
+   * @see #setPostCallbackDelay(int)
+   */
+  @Implementation
+  protected void postCallback(int callbackType, Runnable action, Object token) {
+    postCallbackDelayed(callbackType, action, token, postCallbackDelayMillis);
+  }
+
+  @Implementation
+  protected void postCallbackDelayed(
+      int callbackType, Runnable action, Object token, long delayMillis) {
+    handler.postDelayed(action, delayMillis);
+  }
+
+  @Implementation
+  protected void removeCallbacks(int callbackType, Runnable action, Object token) {
+    handler.removeCallbacks(action, token);
+  }
+
+  /**
+   * The default implementation will call {@link #postFrameCallbackDelayed(FrameCallback, long)}
+   * with no delay. {@link android.animation.AnimationHandler} calls this method to schedule
+   * animation updates infinitely. Because during a Robolectric test the system time is paused and
+   * execution of the event loop is invoked for each test instruction, the behavior of
+   * AnimationHandler would result in endless looping (the execution of the task results in a new
+   * animation task created and scheduled to the front of the event loop queue).
+   *
+   * <p>To prevent endless looping, a test may call {@link #setPostFrameCallbackDelay(int)} to
+   * specify a small delay when animation is scheduled.
+   *
+   * @see #setPostCallbackDelay(int)
+   */
+  @Implementation
+  protected void postFrameCallback(final FrameCallback callback) {
+    postFrameCallbackDelayed(callback, postFrameCallbackDelayMillis);
+  }
+
+  @Implementation
+  protected void postFrameCallbackDelayed(final FrameCallback callback, long delayMillis) {
+    handler.postAtTime(
+        new Runnable() {
+          @Override
+          public void run() {
+            callback.doFrame(getFrameTimeNanos());
+          }
+        },
+        callback,
+        SystemClock.uptimeMillis() + delayMillis);
+  }
+
+  @Implementation
+  protected void removeFrameCallback(FrameCallback callback) {
+    handler.removeCallbacksAndMessages(callback);
+  }
+
+  @Implementation
+  protected long getFrameTimeNanos() {
+    final long now = nanoTime;
+    nanoTime += ShadowLegacyChoreographer.FRAME_INTERVAL;
+    return now;
+  }
+
+  /**
+   * Return the current inter-frame interval.
+   *
+   * @return Inter-frame interval.
+   */
+  public static long getFrameInterval() {
+    return ShadowLegacyChoreographer.FRAME_INTERVAL;
+  }
+
+  /**
+   * Set the inter-frame interval used to advance the clock. By default, this is set to 1ms.
+   *
+   * @param frameInterval Inter-frame interval.
+   */
+  public static void setFrameInterval(long frameInterval) {
+    ShadowLegacyChoreographer.FRAME_INTERVAL = frameInterval;
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    // Blech. We need to share the main looper because somebody might refer to it in a static
+    // field. We also need to keep it in a soft reference so we don't max out permgen.
+    if (Thread.currentThread() != MAIN_THREAD) {
+      throw new RuntimeException("You should only call this from the main thread!");
+    }
+    instance = makeThreadLocal();
+    FRAME_INTERVAL = 10 * TimeUtils.NANOS_PER_MS; // 10ms
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyCursorWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyCursorWindow.java
new file mode 100644
index 0000000..49fcaca
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyCursorWindow.java
@@ -0,0 +1,394 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.RuntimeEnvironment.castNativePtr;
+
+import android.database.Cursor;
+import android.database.CursorWindow;
+import com.almworks.sqlite4java.SQLiteConstants;
+import com.almworks.sqlite4java.SQLiteException;
+import com.almworks.sqlite4java.SQLiteStatement;
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Legacy shadow for {@link CursowWindow}. */
+@Implements(value = CursorWindow.class, isInAndroidSdk = false)
+public class ShadowLegacyCursorWindow extends ShadowCursorWindow {
+  private static final WindowData WINDOW_DATA = new WindowData();
+
+  @Implementation
+  protected static Number nativeCreate(String name, int cursorWindowSize) {
+    return castNativePtr(WINDOW_DATA.create(name, cursorWindowSize));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeDispose(int windowPtr) {
+    nativeDispose((long) windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeDispose(long windowPtr) {
+    WINDOW_DATA.close(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static byte[] nativeGetBlob(int windowPtr, int row, int column) {
+    return nativeGetBlob((long) windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static byte[] nativeGetBlob(long windowPtr, int row, int column) {
+    Value value = WINDOW_DATA.get(windowPtr).value(row, column);
+
+    switch (value.type) {
+      case Cursor.FIELD_TYPE_NULL:
+        return null;
+      case Cursor.FIELD_TYPE_BLOB:
+        // This matches Android's behavior, which does not match the SQLite spec
+        byte[] blob = (byte[])value.value;
+        return blob == null ? new byte[]{} : blob;
+      case Cursor.FIELD_TYPE_STRING:
+        // Matches the Android behavior to contain a zero-byte at the end
+        byte[] stringBytes = ((String) value.value).getBytes(UTF_8);
+        return Arrays.copyOf(stringBytes, stringBytes.length + 1);
+      default:
+        throw new android.database.sqlite.SQLiteException(
+            "Getting blob when column is non-blob. Row " + row + ", col " + column);
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetString(int windowPtr, int row, int column) {
+    return nativeGetString((long) windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetString(long windowPtr, int row, int column) {
+    Value val = WINDOW_DATA.get(windowPtr).value(row, column);
+    if (val.type == Cursor.FIELD_TYPE_BLOB) {
+      throw new android.database.sqlite.SQLiteException(
+          "Getting string when column is blob. Row " + row + ", col " + column);
+    }
+    Object value = val.value;
+    return value == null ? null : String.valueOf(value);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeGetLong(int windowPtr, int row, int column) {
+    return nativeGetLong((long) windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeGetLong(long windowPtr, int row, int column) {
+    return nativeGetNumber(windowPtr, row, column).longValue();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static double nativeGetDouble(int windowPtr, int row, int column) {
+    return nativeGetDouble((long) windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static double nativeGetDouble(long windowPtr, int row, int column) {
+    return nativeGetNumber(windowPtr, row, column).doubleValue();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetType(int windowPtr, int row, int column) {
+    return nativeGetType((long) windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetType(long windowPtr, int row, int column) {
+    return WINDOW_DATA.get(windowPtr).value(row, column).type;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeClear(int windowPtr) {
+    nativeClear((long) windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeClear(long windowPtr) {
+    WINDOW_DATA.clear(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetNumRows(int windowPtr) {
+    return nativeGetNumRows((long) windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetNumRows(long windowPtr) {
+    return WINDOW_DATA.get(windowPtr).numRows();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutBlob(int windowPtr, byte[] value, int row, int column) {
+    return nativePutBlob((long) windowPtr, value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutBlob(long windowPtr, byte[] value, int row, int column) {
+    // Real Android will crash in native code if putString is called with a null value.
+    Preconditions.checkNotNull(value);
+    return WINDOW_DATA.get(windowPtr).putValue(new Value(value, Cursor.FIELD_TYPE_BLOB), row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutString(int windowPtr, String value, int row, int column) {
+    return nativePutString((long) windowPtr, value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutString(long windowPtr, String value, int row, int column) {
+    // Real Android will crash in native code if putString is called with a null value.
+    Preconditions.checkNotNull(value);
+    return WINDOW_DATA.get(windowPtr).putValue(new Value(value, Cursor.FIELD_TYPE_STRING), row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutLong(int windowPtr, long value, int row, int column) {
+    return nativePutLong((long) windowPtr, value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutLong(long windowPtr, long value, int row, int column) {
+    return WINDOW_DATA.get(windowPtr).putValue(new Value(value, Cursor.FIELD_TYPE_INTEGER), row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutDouble(int windowPtr, double value, int row, int column) {
+    return nativePutDouble((long) windowPtr, value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutDouble(long windowPtr, double value, int row, int column) {
+    return WINDOW_DATA.get(windowPtr).putValue(new Value(value, Cursor.FIELD_TYPE_FLOAT), row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutNull(int windowPtr, int row, int column) {
+    return nativePutNull((long) windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutNull(long windowPtr, int row, int column) {
+    return WINDOW_DATA.get(windowPtr).putValue(new Value(null, Cursor.FIELD_TYPE_NULL), row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativeAllocRow(int windowPtr) {
+    return nativeAllocRow((long) windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativeAllocRow(long windowPtr) {
+    return WINDOW_DATA.get(windowPtr).allocRow();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativeSetNumColumns(int windowPtr, int columnNum) {
+    return nativeSetNumColumns((long) windowPtr, columnNum);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativeSetNumColumns(long windowPtr, int columnNum) {
+    return WINDOW_DATA.get(windowPtr).setNumColumns(columnNum);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetName(int windowPtr) {
+    return nativeGetName((long) windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetName(long windowPtr) {
+    return WINDOW_DATA.get(windowPtr).getName();
+  }
+
+  protected static int setData(long windowPtr, SQLiteStatement stmt) throws SQLiteException {
+    return WINDOW_DATA.setData(windowPtr, stmt);
+  }
+
+  private static Number nativeGetNumber(long windowPtr, int row, int column) {
+    Value value = WINDOW_DATA.get(windowPtr).value(row, column);
+    switch (value.type) {
+      case Cursor.FIELD_TYPE_NULL:
+      case SQLiteConstants.SQLITE_NULL:
+        return 0;
+      case Cursor.FIELD_TYPE_INTEGER:
+      case Cursor.FIELD_TYPE_FLOAT:
+        return (Number) value.value;
+      case Cursor.FIELD_TYPE_STRING: {
+        try {
+          return Double.parseDouble((String) value.value);
+        } catch (NumberFormatException e) {
+          return 0;
+        }
+      }
+      case Cursor.FIELD_TYPE_BLOB:
+        throw new android.database.sqlite.SQLiteException("could not convert "+value);
+      default:
+        throw new android.database.sqlite.SQLiteException("unknown type: "+value.type);
+    }
+  }
+
+  private static class Data {
+    private final List<Row> rows;
+    private final String name;
+    private int numColumns;
+
+    public Data(String name, int cursorWindowSize) {
+      this.name = name;
+      this.rows = new ArrayList<Row>();
+    }
+
+    public Value value(int rowN, int colN) {
+      Row row = rows.get(rowN);
+      if (row == null) {
+        throw new IllegalArgumentException("Bad row number: " + rowN + ", count: " + rows.size());
+      }
+      return row.get(colN);
+    }
+
+    public int numRows() {
+      return rows.size();
+    }
+
+    public boolean putValue(Value value, int rowN, int colN) {
+      return rows.get(rowN).set(colN, value);
+    }
+
+    public void fillWith(SQLiteStatement stmt) throws SQLiteException {
+      //Android caches results in the WindowedCursor to allow moveToPrevious() to function.
+      //Robolectric will have to cache the results too. In the rows list.
+      while (stmt.step()) {
+        rows.add(fillRowValues(stmt));
+      }
+    }
+
+    private static int cursorValueType(final int sqliteType) {
+      switch (sqliteType) {
+        case SQLiteConstants.SQLITE_NULL:    return Cursor.FIELD_TYPE_NULL;
+        case SQLiteConstants.SQLITE_INTEGER: return Cursor.FIELD_TYPE_INTEGER;
+        case SQLiteConstants.SQLITE_FLOAT:   return Cursor.FIELD_TYPE_FLOAT;
+        case SQLiteConstants.SQLITE_TEXT:    return Cursor.FIELD_TYPE_STRING;
+        case SQLiteConstants.SQLITE_BLOB:    return Cursor.FIELD_TYPE_BLOB;
+        default:
+          throw new IllegalArgumentException(
+              "Bad SQLite type " + sqliteType + ". See possible values in SQLiteConstants.");
+      }
+    }
+
+    private static Row fillRowValues(SQLiteStatement stmt) throws SQLiteException {
+      final int columnCount = stmt.columnCount();
+      Row row = new Row(columnCount);
+      for (int index = 0; index < columnCount; index++) {
+        row.set(index, new Value(stmt.columnValue(index), cursorValueType(stmt.columnType(index))));
+      }
+      return row;
+    }
+
+    public void clear() {
+      rows.clear();
+    }
+
+    public boolean allocRow() {
+      rows.add(new Row(numColumns));
+      return true;
+    }
+
+    public boolean setNumColumns(int numColumns) {
+      this.numColumns = numColumns;
+      return true;
+    }
+
+    public String getName() {
+      return name;
+    }
+  }
+
+  private static class Row {
+    private final List<Value> values;
+
+    public Row(int length) {
+      values = new ArrayList<Value>(length);
+      for (int i=0; i<length; i++) {
+        values.add(new Value(null, Cursor.FIELD_TYPE_NULL));
+      }
+    }
+
+    public Value get(int n) {
+      return values.get(n);
+    }
+
+    public boolean set(int colN, Value value) {
+      values.set(colN, value);
+      return true;
+    }
+  }
+
+  private static class Value {
+    private final Object value;
+    private final int type;
+
+    public Value(final Object value, final int type) {
+      this.value = value;
+      this.type = type;
+    }
+  }
+
+  private static class WindowData {
+    private final AtomicLong windowPtrCounter = new AtomicLong(0);
+    private final Map<Number, Data> dataMap = new ConcurrentHashMap<>();
+
+    public Data get(long ptr) {
+      Data data = dataMap.get(ptr);
+      if (data == null) {
+        throw new IllegalArgumentException(
+            "Invalid window pointer: " + ptr + "; current pointers: " + dataMap.keySet());
+      }
+      return data;
+    }
+
+    public int setData(final long ptr, final SQLiteStatement stmt) throws SQLiteException {
+      Data data = get(ptr);
+      data.fillWith(stmt);
+      return data.numRows();
+    }
+
+    public void close(final long ptr) {
+      Data removed = dataMap.remove(ptr);
+      if (removed == null) {
+        throw new IllegalArgumentException(
+            "Bad cursor window pointer " + ptr + ". Valid pointers: " + dataMap.keySet());
+      }
+    }
+
+    public void clear(final long ptr) {
+      get(ptr).clear();
+    }
+
+    public long create(String name, int cursorWindowSize) {
+      long ptr = windowPtrCounter.incrementAndGet();
+      dataMap.put(ptr, new Data(name, cursorWindowSize));
+      return ptr;
+    }
+  }
+
+  // TODO: Implement these methods
+  // private static native int nativeCreateFromParcel(Parcel parcel);
+  // private static native void nativeWriteToParcel($ptrClass windowPtr, Parcel parcel);
+  // private static native void nativeFreeLastRow($ptrClass windowPtr);
+  // private static native void nativeCopyStringToBuffer($ptrClass windowPtr, int row, int column, CharArrayBuffer buffer);
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java
new file mode 100644
index 0000000..f07c7fa
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java
@@ -0,0 +1,347 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static org.robolectric.RuntimeEnvironment.isMainThread;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.os.Looper;
+import android.os.MessageQueue;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.RoboSettings;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.Scheduler;
+
+/**
+ * The shadow Looper implementation for {@link LooperMode.Mode.LEGACY}.
+ *
+ * <p>Robolectric enqueues posted {@link Runnable}s to be run (on this thread) later. {@code
+ * Runnable}s that are scheduled to run immediately can be triggered by calling {@link #idle()}.
+ *
+ * @see ShadowMessageQueue
+ */
+@Implements(value = Looper.class, isInAndroidSdk = false)
+@SuppressWarnings("SynchronizeOnNonFinalField")
+public class ShadowLegacyLooper extends ShadowLooper {
+
+  // Replaced SoftThreadLocal with a WeakHashMap, because ThreadLocal make it impossible to access
+  // their contents from other threads, but we need to be able to access the loopers for all
+  // threads so that we can shut them down when resetThreadLoopers()
+  // is called. This also allows us to implement the useful getLooperForThread() method.
+  // Note that the main looper is handled differently and is not put in this hash, because we need
+  // to be able to "switch" the thread that the main looper is associated with.
+  private static Map<Thread, Looper> loopingLoopers =
+      Collections.synchronizedMap(new WeakHashMap<Thread, Looper>());
+
+  private static Looper mainLooper;
+
+  private static Scheduler backgroundScheduler;
+
+  private @RealObject Looper realObject;
+
+  boolean quit;
+
+  @Resetter
+  public static synchronized void resetThreadLoopers() {
+    // do not use looperMode() here, because its cached value might already have been reset
+    if (ConfigurationRegistry.get(LooperMode.Mode.class) == LooperMode.Mode.PAUSED) {
+      // ignore if realistic looper
+      return;
+    }
+    // Blech. We need to keep the main looper because somebody might refer to it in a static
+    // field. The other loopers need to be wrapped in WeakReferences so that they are not prevented
+    // from being garbage collected.
+    if (!isMainThread()) {
+      throw new IllegalStateException("you should only be calling this from the main thread!");
+    }
+    synchronized (loopingLoopers) {
+      for (Looper looper : loopingLoopers.values()) {
+        synchronized (looper) {
+          if (!shadowOf(looper).quit) {
+            looper.quit();
+          } else {
+            // Reset the schedulers of all loopers. This prevents un-run tasks queued up in static
+            // background handlers from leaking to subsequent tests.
+            shadowOf(looper).getScheduler().reset();
+            shadowOf(looper.getQueue()).reset();
+          }
+        }
+      }
+    }
+    // Because resetStaticState() is called by AndroidTestEnvironment on startup before
+    // prepareMainLooper() is called, this might be null on that occasion.
+    if (mainLooper != null) {
+      shadowOf(mainLooper).reset();
+    }
+  }
+
+  static synchronized Scheduler getBackgroundThreadScheduler() {
+    return backgroundScheduler;
+  }
+
+  /** Internal API to initialize background thread scheduler from AndroidTestEnvironment. */
+  public static void internalInitializeBackgroundThreadScheduler() {
+    backgroundScheduler =
+        RoboSettings.isUseGlobalScheduler()
+            ? RuntimeEnvironment.getMasterScheduler()
+            : new Scheduler();
+  }
+
+  @Implementation
+  protected void __constructor__(boolean quitAllowed) {
+    invokeConstructor(Looper.class, realObject, from(boolean.class, quitAllowed));
+    if (isMainThread()) {
+      mainLooper = realObject;
+    } else {
+      loopingLoopers.put(Thread.currentThread(), realObject);
+    }
+    resetScheduler();
+  }
+
+  @Implementation
+  protected static Looper getMainLooper() {
+    return mainLooper;
+  }
+
+  @Implementation
+  protected static Looper myLooper() {
+    return getLooperForThread(Thread.currentThread());
+  }
+
+  @Implementation
+  protected static void loop() {
+    shadowOf(Looper.myLooper()).doLoop();
+  }
+
+  private void doLoop() {
+    if (realObject != Looper.getMainLooper()) {
+      synchronized (realObject) {
+        while (!quit) {
+          try {
+            realObject.wait();
+          } catch (InterruptedException ignore) {
+          }
+        }
+      }
+    }
+  }
+
+  @Implementation
+  protected void quit() {
+    if (realObject == Looper.getMainLooper()) {
+      throw new RuntimeException("Main thread not allowed to quit");
+    }
+    quitUnchecked();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected void quitSafely() {
+    quit();
+  }
+
+  @Override
+  public void quitUnchecked() {
+    synchronized (realObject) {
+      quit = true;
+      realObject.notifyAll();
+      getScheduler().reset();
+      shadowOf(realObject.getQueue()).reset();
+    }
+  }
+
+  @Override
+  public boolean hasQuit() {
+    synchronized (realObject) {
+      return quit;
+    }
+  }
+
+  public static Looper getLooperForThread(Thread thread) {
+    return isMainThread(thread) ? mainLooper : loopingLoopers.get(thread);
+  }
+
+  /** Return loopers for all threads including main thread. */
+  protected static Collection<Looper> getLoopers() {
+    List<Looper> loopers = new ArrayList<>(loopingLoopers.values());
+    loopers.add(mainLooper);
+    return Collections.unmodifiableCollection(loopers);
+  }
+
+  @Override
+  public void idle() {
+    idle(0, TimeUnit.MILLISECONDS);
+  }
+
+  @Override
+  public void idleFor(long time, TimeUnit timeUnit) {
+    getScheduler().advanceBy(time, timeUnit);
+  }
+
+  @Override
+  public boolean isIdle() {
+    return !getScheduler().areAnyRunnable();
+  }
+
+  @Override
+  public void idleIfPaused() {
+    // ignore
+  }
+
+  @Override
+  public void idleConstantly(boolean shouldIdleConstantly) {
+    getScheduler().idleConstantly(shouldIdleConstantly);
+  }
+
+  @Override
+  public void runToEndOfTasks() {
+    getScheduler().advanceToLastPostedRunnable();
+  }
+
+  @Override
+  public void runToNextTask() {
+    getScheduler().advanceToNextPostedRunnable();
+  }
+
+  @Override
+  public void runOneTask() {
+    getScheduler().runOneTask();
+  }
+
+  /**
+   * Enqueue a task to be run later.
+   *
+   * @param runnable the task to be run
+   * @param delayMillis how many milliseconds into the (virtual) future to run it
+   * @return true if the runnable is enqueued
+   * @see android.os.Handler#postDelayed(Runnable,long)
+   * @deprecated Use a {@link android.os.Handler} instance to post to a looper.
+   */
+  @Override
+  @Deprecated
+  public boolean post(Runnable runnable, long delayMillis) {
+    if (!quit) {
+      getScheduler().postDelayed(runnable, delayMillis, TimeUnit.MILLISECONDS);
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  /**
+   * Enqueue a task to be run ahead of all other delayed tasks.
+   *
+   * @param runnable the task to be run
+   * @return true if the runnable is enqueued
+   * @see android.os.Handler#postAtFrontOfQueue(Runnable)
+   * @deprecated Use a {@link android.os.Handler} instance to post to a looper.
+   */
+  @Override
+  @Deprecated
+  public boolean postAtFrontOfQueue(Runnable runnable) {
+    if (!quit) {
+      getScheduler().postAtFrontOfQueue(runnable);
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  @Override
+  public void pause() {
+    getScheduler().pause();
+  }
+
+  @Override
+  public Duration getNextScheduledTaskTime() {
+    return getScheduler().getNextScheduledTaskTime();
+  }
+
+  @Override
+  public Duration getLastScheduledTaskTime() {
+    return getScheduler().getLastScheduledTaskTime();
+  }
+
+  @Override
+  public void unPause() {
+    getScheduler().unPause();
+  }
+
+  @Override
+  public boolean isPaused() {
+    return getScheduler().isPaused();
+  }
+
+  @Override
+  public boolean setPaused(boolean shouldPause) {
+    boolean wasPaused = isPaused();
+    if (shouldPause) {
+      pause();
+    } else {
+      unPause();
+    }
+    return wasPaused;
+  }
+
+  @Override
+  public void resetScheduler() {
+    ShadowMessageQueue shadowMessageQueue = shadowOf(realObject.getQueue());
+    if (realObject == Looper.getMainLooper() || RoboSettings.isUseGlobalScheduler()) {
+      shadowMessageQueue.setScheduler(RuntimeEnvironment.getMasterScheduler());
+    } else {
+      shadowMessageQueue.setScheduler(new Scheduler());
+    }
+  }
+
+  /** Causes all enqueued tasks to be discarded, and pause state to be reset */
+  @Override
+  public void reset() {
+    shadowOf(realObject.getQueue()).reset();
+    resetScheduler();
+
+    quit = false;
+  }
+
+  /**
+   * Returns the {@link org.robolectric.util.Scheduler} that is being used to manage the enqueued
+   * tasks. This scheduler is managed by the Looper's associated queue.
+   *
+   * @return the {@link org.robolectric.util.Scheduler} that is being used to manage the enqueued
+   *     tasks.
+   */
+  @Override
+  public Scheduler getScheduler() {
+    return shadowOf(realObject.getQueue()).getScheduler();
+  }
+
+  @Override
+  public void runPaused(Runnable r) {
+    boolean wasPaused = setPaused(true);
+    try {
+      r.run();
+    } finally {
+      if (!wasPaused) unPause();
+    }
+  }
+
+  private static ShadowLegacyLooper shadowOf(Looper looper) {
+    return Shadow.extract(looper);
+  }
+
+  private static ShadowMessageQueue shadowOf(MessageQueue mq) {
+    return Shadow.extract(mq);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyMessage.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyMessage.java
new file mode 100644
index 0000000..938e41a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyMessage.java
@@ -0,0 +1,101 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.shadow.api.Shadow.directlyOn;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * The shadow {@link Message} for {@link LooperMode.Mode.LEGACY}.
+ *
+ * <p>In {@link LooperMode.Mode.LEGACY}, each Message is associated with a Runnable posted to the
+ * {@link Scheduler}.
+ *
+ * @see ShadowLooper, ShadowLegacyMessageQueue
+ */
+@Implements(value = Message.class, isInAndroidSdk = false)
+public class ShadowLegacyMessage extends ShadowMessage {
+  @RealObject
+  private Message realMessage;
+  private Runnable scheduledRunnable;
+
+  private void unschedule() {
+    Handler target = realMessage.getTarget();
+
+    if (target != null && scheduledRunnable != null) {
+      shadowOf(target.getLooper()).getScheduler().remove(scheduledRunnable);
+      scheduledRunnable = null;
+    }
+  }
+
+  /**
+   * Hook to unscheduled the callback when the message is recycled.
+   * Invokes {@link #unschedule()} and then calls through to the
+   * package private method {@link Message#recycleUnchecked()}
+   * on the real object.
+   */
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP)
+  public void recycleUnchecked() {
+    if (getApiLevel() >= LOLLIPOP) {
+      unschedule();
+      reflector(MessageReflector.class, realMessage).recycleUnchecked();
+    } else {
+      // provide forward compatibility with SDK 21.
+      recycle();
+    }
+  }
+
+  /**
+   * Hook to unscheduled the callback when the message is recycled. Invokes {@link #unschedule()}
+   * and then calls through to {@link Message#recycle()} on the real object.
+   */
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void recycle() {
+    unschedule();
+    directlyOn(realMessage, Message.class, "recycle");
+  }
+
+  @Override
+  public void setScheduledRunnable(Runnable r) {
+    scheduledRunnable = r;
+  }
+
+  @Override
+  public Message getNext() {
+    return reflector(MessageReflector.class, realMessage).getNext();
+  }
+
+  @Override
+  public void setNext(Message next) {
+    reflector(MessageReflector.class, realMessage).setNext(next);
+  }
+
+  private static ShadowLooper shadowOf(Looper looper) {
+    return Shadow.extract(looper);
+  }
+
+  /** Accessor interface for {@link Message}'s internals. */
+  @ForType(Message.class)
+  interface LegacyMessageReflector {
+
+    void markInUse();
+
+    void recycle();
+
+    void recycleUnchecked();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyMessageQueue.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyMessageQueue.java
new file mode 100644
index 0000000..9934fb4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyMessageQueue.java
@@ -0,0 +1,195 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.ReflectionHelpers.getField;
+import static org.robolectric.util.ReflectionHelpers.setField;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Handler;
+import android.os.Message;
+import android.os.MessageQueue;
+import java.util.ArrayList;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowLegacyMessage.LegacyMessageReflector;
+import org.robolectric.util.Logger;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * The shadow {@link MessageQueue} for {@link LooperMode.Mode.LEGACY}.
+ *
+ * <p>In {@link LooperMode.Mode.LEGACY} Robolectric puts {@link android.os.Message}s into the
+ * scheduler queue instead of sending them to be handled on a separate thread. {@link
+ * android.os.Message}s that are scheduled to be dispatched can be triggered by calling {@link
+ * ShadowLooper#idleMainLooper}.
+ *
+ * @see ShadowLooper
+ */
+@Implements(value = MessageQueue.class, isInAndroidSdk = false)
+public class ShadowLegacyMessageQueue extends ShadowMessageQueue {
+
+  @RealObject private MessageQueue realQueue;
+
+  private Scheduler scheduler;
+
+  // Stub out the native peer - scheduling
+  // is handled by the Scheduler class which is user-driven
+  // rather than automatic.
+  @HiddenApi
+  @Implementation
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  public static Number nativeInit() {
+    return 1;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT_WATCH)
+  public static void nativeDestroy(int ptr) {
+    nativeDestroy((long) ptr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeDestroy(long ptr) {}
+
+  @HiddenApi
+  @Implementation(minSdk = KITKAT, maxSdk = KITKAT_WATCH)
+  public static boolean nativeIsIdling(int ptr) {
+    return nativeIsIdling((long) ptr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = LOLLIPOP_MR1)
+  protected static boolean nativeIsIdling(long ptr) {
+    return false;
+  }
+
+  @Override
+  public Scheduler getScheduler() {
+    return scheduler;
+  }
+
+  @Override
+  public void setScheduler(Scheduler scheduler) {
+    this.scheduler = scheduler;
+  }
+
+  @Override
+  public Message getHead() {
+    return getField(realQueue, "mMessages");
+  }
+
+  @Override
+  public void setHead(Message msg) {
+    reflector(MessageQueueReflector.class, realQueue).setMessages(msg);
+  }
+
+  @Override
+  public void reset() {
+    setHead(null);
+    setField(realQueue, "mIdleHandlers", new ArrayList<>());
+    setField(realQueue, "mNextBarrierToken", 0);
+  }
+
+  @Implementation
+  @SuppressWarnings("SynchronizeOnNonFinalField")
+  protected boolean enqueueMessage(final Message msg, long when) {
+    final boolean retval =
+        reflector(MessageQueueReflector.class, realQueue).enqueueMessage(msg, when);
+    if (retval) {
+      final Runnable callback = new Runnable() {
+        @Override
+        public void run() {
+          synchronized (realQueue) {
+            Message m = getHead();
+            if (m == null) {
+              return;
+            }
+
+            Message n = shadowOf(m).getNext();
+            if (m == msg) {
+              setHead(n);
+            } else {
+              while (n != null) {
+                if (n == msg) {
+                  n = shadowOf(n).getNext();
+                  shadowOf(m).setNext(n);
+                  break;
+                }
+                m = n;
+                n = shadowOf(m).getNext();
+              }
+            }
+          }
+          dispatchMessage(msg);
+        }
+      };
+      shadowOf(msg).setScheduledRunnable(callback);
+      if (when == 0) {
+        scheduler.postAtFrontOfQueue(callback);
+      } else {
+        scheduler.postDelayed(callback, when - scheduler.getCurrentTime());
+      }
+    }
+    return retval;
+  }
+
+  private static void dispatchMessage(Message msg) {
+    final Handler target = msg.getTarget();
+
+    shadowOf(msg).setNext(null);
+    // If target is null it means the message has been removed
+    // from the queue prior to being dispatched by the scheduler.
+    if (target != null) {
+      LegacyMessageReflector msgProxy = reflector(LegacyMessageReflector.class, msg);
+      msgProxy.markInUse();
+      target.dispatchMessage(msg);
+
+      if (getApiLevel() >= LOLLIPOP) {
+        msgProxy.recycleUnchecked();
+      } else {
+        msgProxy.recycle();
+      }
+    }
+  }
+
+  @Implementation
+  @HiddenApi
+  protected void removeSyncBarrier(int token) {
+    // TODO(https://github.com/robolectric/robolectric/issues/6852): workaround scheduler corruption
+    // of message queue
+    try {
+      reflector(MessageQueueReflector.class, realQueue).removeSyncBarrier(token);
+    } catch (IllegalStateException e) {
+      Logger.warn("removeSyncBarrier failed! Could not find token %d", token);
+    }
+  }
+
+  private static ShadowLegacyMessage shadowOf(Message actual) {
+    return (ShadowLegacyMessage) Shadow.extract(actual);
+  }
+
+  /** Reflector interface for {@link MessageQueue}'s internals. */
+  @ForType(MessageQueue.class)
+  interface MessageQueueReflector {
+
+    @Direct
+    boolean enqueueMessage(Message msg, long when);
+
+    @Direct
+    void removeSyncBarrier(int token);
+
+    @Accessor("mMessages")
+    void setMessages(Message msg);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyResourcesImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyResourcesImpl.java
new file mode 100644
index 0000000..ecad6fd
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyResourcesImpl.java
@@ -0,0 +1,195 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static org.robolectric.shadows.ShadowAssetManager.legacyShadowOf;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetFileDescriptor;
+import android.content.res.AssetManager;
+import android.content.res.Resources;
+import android.content.res.ResourcesImpl;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.graphics.drawable.Drawable;
+import android.os.ParcelFileDescriptor;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Locale;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.Plural;
+import org.robolectric.res.PluralRules;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResType;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.TypedResource;
+import org.robolectric.shadows.ShadowResourcesImpl.Picker;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings("NewApi")
+@Implements(
+    value = ResourcesImpl.class,
+    isInAndroidSdk = false,
+    minSdk = N,
+    shadowPicker = Picker.class)
+public class ShadowLegacyResourcesImpl extends ShadowResourcesImpl {
+
+  @Resetter
+  public static void reset() {
+    if (RuntimeEnvironment.useLegacyResources()) {
+      ShadowResourcesImpl.reset();
+    }
+  }
+
+  @RealObject private ResourcesImpl realResourcesImpl;
+
+  @Implementation(maxSdk = M)
+  public String getQuantityString(int id, int quantity, Object... formatArgs) throws Resources.NotFoundException {
+    String raw = getQuantityString(id, quantity);
+    return String.format(Locale.ENGLISH, raw, formatArgs);
+  }
+
+  @Implementation(maxSdk = M)
+  public String getQuantityString(int resId, int quantity) throws Resources.NotFoundException {
+    ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResourcesImpl.getAssets());
+
+    TypedResource typedResource = shadowAssetManager.getResourceTable().getValue(resId, shadowAssetManager.config);
+    if (typedResource != null && typedResource instanceof PluralRules) {
+      PluralRules pluralRules = (PluralRules) typedResource;
+      Plural plural = pluralRules.find(quantity);
+
+      if (plural == null) {
+        return null;
+      }
+
+      TypedResource<?> resolvedTypedResource =
+          shadowAssetManager.resolve(
+              new TypedResource<>(
+                  plural.getString(), ResType.CHAR_SEQUENCE, pluralRules.getXmlContext()),
+              shadowAssetManager.config,
+              resId);
+      return resolvedTypedResource == null ? null : resolvedTypedResource.asString();
+    } else {
+      return null;
+    }
+  }
+
+  @Implementation(maxSdk = M)
+  public InputStream openRawResource(int id) throws Resources.NotFoundException {
+    ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResourcesImpl.getAssets());
+    ResourceTable resourceTable = shadowAssetManager.getResourceTable();
+    InputStream inputStream = resourceTable.getRawValue(id, shadowAssetManager.config);
+    if (inputStream == null) {
+      throw newNotFoundException(id);
+    } else {
+      return inputStream;
+    }
+  }
+
+  /**
+   * Since {@link AssetFileDescriptor}s are not yet supported by Robolectric, {@code null} will
+   * be returned if the resource is found. If the resource cannot be found, {@link Resources.NotFoundException} will
+   * be thrown.
+   */
+  @Implementation(maxSdk = M)
+  public AssetFileDescriptor openRawResourceFd(int id) throws Resources.NotFoundException {
+    InputStream inputStream = openRawResource(id);
+    if (!(inputStream instanceof FileInputStream)) {
+      // todo fixme
+      return null;
+    }
+
+    FileInputStream fis = (FileInputStream) inputStream;
+    try {
+      return new AssetFileDescriptor(ParcelFileDescriptor.dup(fis.getFD()), 0, fis.getChannel().size());
+    } catch (IOException e) {
+      throw newNotFoundException(id);
+    }
+  }
+
+  private Resources.NotFoundException newNotFoundException(int id) {
+    ResourceTable resourceTable = legacyShadowOf(realResourcesImpl.getAssets()).getResourceTable();
+    ResName resName = resourceTable.getResName(id);
+    if (resName == null) {
+      return new Resources.NotFoundException("resource ID #0x" + Integer.toHexString(id));
+    } else {
+      return new Resources.NotFoundException(resName.getFullyQualifiedName());
+    }
+  }
+
+  @HiddenApi @Implementation(maxSdk = M)
+  public XmlResourceParser loadXmlResourceParser(int resId, String type) throws Resources.NotFoundException {
+    ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResourcesImpl.getAssets());
+    return shadowAssetManager.loadXmlResourceParser(resId, type);
+  }
+
+  @HiddenApi @Implementation
+  public XmlResourceParser loadXmlResourceParser(String file, int id, int assetCookie, String type) throws Resources.NotFoundException {
+    return loadXmlResourceParser(id, type);
+  }
+
+  @Implements(
+      value = ResourcesImpl.ThemeImpl.class,
+      minSdk = N,
+      isInAndroidSdk = false,
+      shadowPicker = ShadowResourcesImpl.ShadowThemeImpl.Picker.class)
+  public static class ShadowLegacyThemeImpl extends ShadowThemeImpl {
+    @RealObject ResourcesImpl.ThemeImpl realThemeImpl;
+
+    @Implementation
+    public TypedArray obtainStyledAttributes(Resources.Theme wrapper, AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes) {
+      Resources resources = wrapper.getResources();
+      AssetManager assets = resources.getAssets();
+      return legacyShadowOf(assets)
+          .attrsToTypedArray(resources, set, attrs, defStyleAttr, getNativePtr(), defStyleRes);
+    }
+
+    public long getNativePtr() {
+      return ReflectionHelpers.getField(realThemeImpl, "mTheme");
+    }
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  public Drawable loadDrawable(Resources wrapper, TypedValue value, int id, Resources.Theme theme, boolean useCache) throws Resources.NotFoundException {
+    Drawable drawable =
+        reflector(ResourcesImplReflector.class, realResourcesImpl)
+            .loadDrawable(wrapper, value, id, theme, useCache);
+
+    ShadowResources.setCreatedFromResId(wrapper, id, drawable);
+    return drawable;
+  }
+
+  @Implementation(minSdk = O)
+  public Drawable loadDrawable(Resources wrapper,  TypedValue value, int id, int density, Resources.Theme theme) {
+    Drawable drawable =
+        reflector(ResourcesImplReflector.class, realResourcesImpl)
+            .loadDrawable(wrapper, value, id, density, theme);
+
+    ShadowResources.setCreatedFromResId(wrapper, id, drawable);
+    return drawable;
+  }
+
+  @ForType(ResourcesImpl.class)
+  interface ResourcesImplReflector {
+
+    @Direct
+    Drawable loadDrawable(
+        Resources wrapper, TypedValue value, int id, Resources.Theme theme, boolean useCache);
+
+    @Direct
+    Drawable loadDrawable(
+        Resources wrapper, TypedValue value, int id, int density, Resources.Theme theme);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacySQLiteConnection.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacySQLiteConnection.java
new file mode 100644
index 0000000..fa14160
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacySQLiteConnection.java
@@ -0,0 +1,905 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static org.robolectric.RuntimeEnvironment.castNativePtr;
+
+import android.database.sqlite.SQLiteAbortException;
+import android.database.sqlite.SQLiteAccessPermException;
+import android.database.sqlite.SQLiteBindOrColumnIndexOutOfRangeException;
+import android.database.sqlite.SQLiteBlobTooBigException;
+import android.database.sqlite.SQLiteCantOpenDatabaseException;
+import android.database.sqlite.SQLiteConstraintException;
+import android.database.sqlite.SQLiteCustomFunction;
+import android.database.sqlite.SQLiteDatabaseCorruptException;
+import android.database.sqlite.SQLiteDatabaseLockedException;
+import android.database.sqlite.SQLiteDatatypeMismatchException;
+import android.database.sqlite.SQLiteDiskIOException;
+import android.database.sqlite.SQLiteDoneException;
+import android.database.sqlite.SQLiteFullException;
+import android.database.sqlite.SQLiteMisuseException;
+import android.database.sqlite.SQLiteOutOfMemoryException;
+import android.database.sqlite.SQLiteReadOnlyDatabaseException;
+import android.database.sqlite.SQLiteTableLockedException;
+import android.os.OperationCanceledException;
+import com.almworks.sqlite4java.SQLiteConnection;
+import com.almworks.sqlite4java.SQLiteConstants;
+import com.almworks.sqlite4java.SQLiteException;
+import com.almworks.sqlite4java.SQLiteStatement;
+import com.google.common.util.concurrent.Uninterruptibles;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadows.util.SQLiteLibraryLoader;
+import org.robolectric.util.PerfStatsCollector;
+
+/** Shadow for {@link android.database.sqlite.SQLiteConnection} that is backed by sqlite4java. */
+@Implements(value = android.database.sqlite.SQLiteConnection.class, isInAndroidSdk = false)
+public class ShadowLegacySQLiteConnection extends ShadowSQLiteConnection {
+
+  private static final String IN_MEMORY_PATH = ":memory:";
+  private static final Connections CONNECTIONS = new Connections();
+  private static final Pattern COLLATE_LOCALIZED_UNICODE_PATTERN =
+      Pattern.compile("\\s+COLLATE\\s+(LOCALIZED|UNICODE)", Pattern.CASE_INSENSITIVE);
+
+  // indicates an ignored statement
+  private static final int IGNORED_REINDEX_STMT = -2;
+
+  @Implementation(maxSdk = O)
+  protected static Number nativeOpen(
+      String path, int openFlags, String label, boolean enableTrace, boolean enableProfile) {
+    SQLiteLibraryLoader.load();
+    return castNativePtr(CONNECTIONS.open(path));
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected static long nativeOpen(
+      String path,
+      int openFlags,
+      String label,
+      boolean enableTrace,
+      boolean enableProfile,
+      int lookasideSlotSize,
+      int lookasideSlotCount) {
+    return nativeOpen(path, openFlags, label, enableTrace, enableProfile).longValue();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativePrepareStatement(int connectionPtr, String sql) {
+    return (int) nativePrepareStatement((long) connectionPtr, sql);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativePrepareStatement(long connectionPtr, String sql) {
+    final String newSql = convertSQLWithLocalizedUnicodeCollator(sql);
+    return CONNECTIONS.prepareStatement(connectionPtr, newSql);
+  }
+
+  /**
+   * Convert SQL with phrase COLLATE LOCALIZED or COLLATE UNICODE to COLLATE NOCASE.
+   */
+  static String convertSQLWithLocalizedUnicodeCollator(String sql) {
+    Matcher matcher = COLLATE_LOCALIZED_UNICODE_PATTERN.matcher(sql);
+    return matcher.replaceAll(" COLLATE NOCASE");
+  }
+
+  @Resetter
+  public static void reset() {
+    CONNECTIONS.reset();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeClose(int connectionPtr) {
+    nativeClose((long) connectionPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeClose(long connectionPtr) {
+    CONNECTIONS.close(connectionPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeFinalizeStatement(int connectionPtr, int statementPtr) {
+    nativeFinalizeStatement((long) connectionPtr, statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeFinalizeStatement(long connectionPtr, long statementPtr) {
+    CONNECTIONS.finalizeStmt(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetParameterCount(int connectionPtr, int statementPtr) {
+    return nativeGetParameterCount((long) connectionPtr, statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetParameterCount(final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.getParameterCount(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativeIsReadOnly(int connectionPtr, int statementPtr) {
+    return nativeIsReadOnly((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativeIsReadOnly(final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.isReadOnly(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeExecuteForLong(int connectionPtr, int statementPtr) {
+    return nativeExecuteForLong((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeExecuteForLong(final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.executeForLong(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeExecute(int connectionPtr, int statementPtr) {
+    nativeExecute((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = S_V2)
+  protected static void nativeExecute(final long connectionPtr, final long statementPtr) {
+    CONNECTIONS.executeStatement(connectionPtr, statementPtr);
+  }
+
+  @Implementation(minSdk = 33)
+  protected static void nativeExecute(
+      final long connectionPtr, final long statementPtr, boolean isPragmaStmt) {
+    CONNECTIONS.executeStatement(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeExecuteForString(int connectionPtr, int statementPtr) {
+    return nativeExecuteForString((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeExecuteForString(
+      final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.executeForString(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetColumnCount(int connectionPtr, int statementPtr) {
+    return nativeGetColumnCount((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetColumnCount(final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.getColumnCount(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetColumnName(int connectionPtr, int statementPtr, int index) {
+    return nativeGetColumnName((long) connectionPtr, (long) statementPtr, index);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetColumnName(
+      final long connectionPtr, final long statementPtr, final int index) {
+    return CONNECTIONS.getColumnName(connectionPtr, statementPtr, index);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindNull(int connectionPtr, int statementPtr, int index) {
+    nativeBindNull((long) connectionPtr, (long) statementPtr, index);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindNull(
+      final long connectionPtr, final long statementPtr, final int index) {
+    CONNECTIONS.bindNull(connectionPtr, statementPtr, index);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindLong(int connectionPtr, int statementPtr, int index, long value) {
+    nativeBindLong((long) connectionPtr, (long) statementPtr, index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindLong(
+      final long connectionPtr, final long statementPtr, final int index, final long value) {
+    CONNECTIONS.bindLong(connectionPtr, statementPtr, index, value);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindDouble(
+      int connectionPtr, int statementPtr, int index, double value) {
+    nativeBindDouble((long) connectionPtr, (long) statementPtr, index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindDouble(
+      final long connectionPtr, final long statementPtr, final int index, final double value) {
+    CONNECTIONS.bindDouble(connectionPtr, statementPtr, index, value);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindString(
+      int connectionPtr, int statementPtr, int index, String value) {
+    nativeBindString((long) connectionPtr, (long) statementPtr, index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindString(
+      final long connectionPtr, final long statementPtr, final int index, final String value) {
+    CONNECTIONS.bindString(connectionPtr, statementPtr, index, value);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindBlob(
+      int connectionPtr, int statementPtr, int index, byte[] value) {
+    nativeBindBlob((long) connectionPtr, (long) statementPtr, index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindBlob(
+      final long connectionPtr, final long statementPtr, final int index, final byte[] value) {
+    CONNECTIONS.bindBlob(connectionPtr, statementPtr, index, value);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeRegisterLocalizedCollators(int connectionPtr, String locale) {
+    nativeRegisterLocalizedCollators((long) connectionPtr, locale);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeRegisterLocalizedCollators(long connectionPtr, String locale) {
+    // TODO: find a way to create a collator
+    // http://www.sqlite.org/c3ref/create_collation.html
+    // xerial jdbc driver does not have a Java method for sqlite3_create_collation
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeExecuteForChangedRowCount(int connectionPtr, int statementPtr) {
+    return nativeExecuteForChangedRowCount((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeExecuteForChangedRowCount(
+      final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.executeForChangedRowCount(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeExecuteForLastInsertedRowId(int connectionPtr, int statementPtr) {
+    return nativeExecuteForLastInsertedRowId((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeExecuteForLastInsertedRowId(
+      final long connectionPtr, final long statementPtr) {
+    return CONNECTIONS.executeForLastInsertedRowId(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeExecuteForCursorWindow(
+      int connectionPtr,
+      int statementPtr,
+      int windowPtr,
+      int startPos,
+      int requiredPos,
+      boolean countAllRows) {
+    return nativeExecuteForCursorWindow((long) connectionPtr, (long) statementPtr, (long) windowPtr,
+        startPos, requiredPos, countAllRows);
+}
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeExecuteForCursorWindow(
+      final long connectionPtr,
+      final long statementPtr,
+      final long windowPtr,
+      final int startPos,
+      final int requiredPos,
+      final boolean countAllRows) {
+    return CONNECTIONS.executeForCursorWindow(connectionPtr, statementPtr, windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeResetStatementAndClearBindings(int connectionPtr, int statementPtr) {
+    nativeResetStatementAndClearBindings((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeResetStatementAndClearBindings(
+      final long connectionPtr, final long statementPtr) {
+    CONNECTIONS.resetStatementAndClearBindings(connectionPtr, statementPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeCancel(int connectionPtr) {
+    nativeCancel((long) connectionPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeCancel(long connectionPtr) {
+    CONNECTIONS.cancel(connectionPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeResetCancel(int connectionPtr, boolean cancelable) {
+    nativeResetCancel((long) connectionPtr, cancelable);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeResetCancel(long connectionPtr, boolean cancelable) {
+    // handled in com.almworks.sqlite4java.SQLiteConnection#exec
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeRegisterCustomFunction(
+      int connectionPtr, SQLiteCustomFunction function) {
+    nativeRegisterCustomFunction((long) connectionPtr, function);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = Q)
+  protected static void nativeRegisterCustomFunction(
+      long connectionPtr, SQLiteCustomFunction function) {
+    // not supported
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeExecuteForBlobFileDescriptor(int connectionPtr, int statementPtr) {
+    return nativeExecuteForBlobFileDescriptor((long) connectionPtr, (long) statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeExecuteForBlobFileDescriptor(long connectionPtr, long statementPtr) {
+    // impossible to support without native code?
+    return -1;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetDbLookaside(int connectionPtr) {
+    return nativeGetDbLookaside((long) connectionPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetDbLookaside(long connectionPtr) {
+    // not supported by sqlite4java
+    return 0;
+  }
+// VisibleForTesting
+static class Connections {
+
+  private final Object lock = new Object();
+  private final AtomicLong pointerCounter = new AtomicLong(0);
+  private final Map<Long, SQLiteStatement> statementsMap = new HashMap<>();
+  private final Map<Long, SQLiteConnection> connectionsMap = new HashMap<>();
+  private final Map<Long, List<Long>> statementPtrsForConnection = new HashMap<>();
+
+    private ExecutorService dbExecutor = Executors.newSingleThreadExecutor(threadFactory());
+
+    static ThreadFactory threadFactory() {
+      ThreadFactory delegate = Executors.defaultThreadFactory();
+      return r -> {
+        Thread worker = delegate.newThread(r);
+        worker.setName(ShadowLegacySQLiteConnection.class.getSimpleName() + " worker");
+        return worker;
+      };
+    }
+
+  SQLiteConnection getConnection(final long connectionPtr) {
+    synchronized (lock) {
+      final SQLiteConnection connection = connectionsMap.get(connectionPtr);
+      if (connection == null) {
+          throw new IllegalStateException(
+              "Illegal connection pointer "
+                  + connectionPtr
+                  + ". Current pointers for thread "
+                  + Thread.currentThread()
+                  + " "
+                  + connectionsMap.keySet());
+      }
+      return connection;
+    }
+  }
+
+  SQLiteStatement getStatement(final long connectionPtr, final long statementPtr) {
+    synchronized (lock) {
+      // ensure connection is ok
+      getConnection(connectionPtr);
+
+      final SQLiteStatement statement = statementsMap.get(statementPtr);
+      if (statement == null) {
+          throw new IllegalArgumentException(
+              "Invalid prepared statement pointer: "
+                  + statementPtr
+                  + ". Current pointers: "
+                  + statementsMap.keySet());
+      }
+      if (statement.isDisposed()) {
+          throw new IllegalStateException(
+              "Statement " + statementPtr + " " + statement + " is disposed");
+      }
+      return statement;
+    }
+  }
+
+  long open(final String path) {
+    synchronized (lock) {
+        final SQLiteConnection dbConnection =
+            execute(
+                "open SQLite connection",
+                new Callable<SQLiteConnection>() {
+                  @Override
+                  public SQLiteConnection call() throws Exception {
+                    SQLiteConnection connection =
+                        useInMemoryDatabase.get() || IN_MEMORY_PATH.equals(path)
+                            ? new SQLiteConnection()
+                            : new SQLiteConnection(new File(path));
+
+                    connection.open();
+                    return connection;
+                  }
+                });
+
+      final long connectionPtr = pointerCounter.incrementAndGet();
+      connectionsMap.put(connectionPtr, dbConnection);
+      statementPtrsForConnection.put(connectionPtr, new ArrayList<>());
+      return connectionPtr;
+    }
+  }
+
+  long prepareStatement(final long connectionPtr, final String sql) {
+    // TODO: find a way to create collators
+    if ("REINDEX LOCALIZED".equals(sql)) {
+      return IGNORED_REINDEX_STMT;
+    }
+
+    synchronized (lock) {
+      final SQLiteConnection connection = getConnection(connectionPtr);
+        final SQLiteStatement statement =
+            execute(
+                "prepare statement",
+                new Callable<SQLiteStatement>() {
+                  @Override
+                  public SQLiteStatement call() throws Exception {
+                    return connection.prepare(sql);
+                  }
+                });
+
+      final long statementPtr = pointerCounter.incrementAndGet();
+      statementsMap.put(statementPtr, statement);
+      statementPtrsForConnection.get(connectionPtr).add(statementPtr);
+      return statementPtr;
+    }
+  }
+
+  void close(final long connectionPtr) {
+    synchronized (lock) {
+      final SQLiteConnection connection = getConnection(connectionPtr);
+        execute("close connection", new Callable<Void>() {
+        @Override
+        public Void call() throws Exception {
+          connection.dispose();
+          return null;
+        }
+      });
+      connectionsMap.remove(connectionPtr);
+      statementPtrsForConnection.remove(connectionPtr);
+    }
+  }
+
+  void reset() {
+    ExecutorService oldDbExecutor;
+    Collection<SQLiteConnection> openConnections;
+
+    synchronized (lock) {
+      oldDbExecutor = dbExecutor;
+      openConnections = new ArrayList<>(connectionsMap.values());
+
+        dbExecutor = Executors.newSingleThreadExecutor(threadFactory());
+      connectionsMap.clear();
+      statementsMap.clear();
+      statementPtrsForConnection.clear();
+    }
+
+    shutdownDbExecutor(oldDbExecutor, openConnections);
+  }
+
+  private static void shutdownDbExecutor(ExecutorService executorService, Collection<SQLiteConnection> connections) {
+    for (final SQLiteConnection connection : connections) {
+      getFuture("close connection on reset", executorService.submit(new Callable<Void>() {
+        @Override
+        public Void call() throws Exception {
+          connection.dispose();
+          return null;
+        }
+      }));
+    }
+
+    executorService.shutdown();
+    try {
+      executorService.awaitTermination(30, TimeUnit.SECONDS);
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  void finalizeStmt(final long connectionPtr, final long statementPtr) {
+    if (statementPtr == IGNORED_REINDEX_STMT) {
+      return;
+    }
+
+    synchronized (lock) {
+      final SQLiteStatement statement = getStatement(connectionPtr, statementPtr);
+      statementsMap.remove(statementPtr);
+
+        execute("finalize statement", new Callable<Void>() {
+        @Override
+        public Void call() throws Exception {
+          statement.dispose();
+          return null;
+        }
+      });
+    }
+  }
+
+  void cancel(final long connectionPtr) {
+    synchronized (lock) {
+      getConnection(connectionPtr); // check connection
+
+      for (Long statementPtr : statementPtrsForConnection.get(connectionPtr)) {
+        final SQLiteStatement statement = statementsMap.get(statementPtr);
+        if (statement != null) {
+            execute("cancel", new Callable<Void>() {
+            @Override
+            public Void call() throws Exception {
+              statement.cancel();
+              return null;
+            }
+          });
+        }
+      }
+    }
+  }
+
+  int getParameterCount(final long connectionPtr, final long statementPtr) {
+    if (statementPtr == IGNORED_REINDEX_STMT) {
+      return 0;
+    }
+
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "get parameters count in prepared statement",
+          new StatementOperation<Integer>() {
+            @Override
+            public Integer call(final SQLiteStatement statement) throws Exception {
+              return statement.getBindParameterCount();
+            }
+          });
+  }
+
+  boolean isReadOnly(final long connectionPtr, final long statementPtr) {
+    if (statementPtr == IGNORED_REINDEX_STMT) {
+      return true;
+    }
+
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "call isReadOnly",
+          new StatementOperation<Boolean>() {
+            @Override
+            public Boolean call(final SQLiteStatement statement) throws Exception {
+              return statement.isReadOnly();
+            }
+          });
+  }
+
+  long executeForLong(final long connectionPtr, final long statementPtr) {
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "execute for long",
+          new StatementOperation<Long>() {
+            @Override
+            public Long call(final SQLiteStatement statement) throws Exception {
+              if (!statement.step()) {
+                throw new SQLiteException(
+                    SQLiteConstants.SQLITE_DONE, "No rows returned from query");
+              }
+              return statement.columnLong(0);
+            }
+          });
+  }
+
+  void executeStatement(final long connectionPtr, final long statementPtr) {
+    if (statementPtr == IGNORED_REINDEX_STMT) {
+      return;
+    }
+
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "execute",
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.stepThrough();
+              return null;
+            }
+          });
+  }
+
+  String executeForString(final long connectionPtr, final long statementPtr) {
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "execute for string",
+          new StatementOperation<String>() {
+            @Override
+            public String call(final SQLiteStatement statement) throws Exception {
+              if (!statement.step()) {
+                throw new SQLiteException(
+                    SQLiteConstants.SQLITE_DONE, "No rows returned from query");
+              }
+              return statement.columnString(0);
+            }
+          });
+  }
+
+  int getColumnCount(final long connectionPtr, final long statementPtr) {
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "get columns count",
+          new StatementOperation<Integer>() {
+            @Override
+            public Integer call(final SQLiteStatement statement) throws Exception {
+              return statement.columnCount();
+            }
+          });
+  }
+
+  String getColumnName(final long connectionPtr, final long statementPtr, final int index) {
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "get column name at index " + index,
+          new StatementOperation<String>() {
+            @Override
+            public String call(final SQLiteStatement statement) throws Exception {
+              return statement.getColumnName(index);
+            }
+          });
+  }
+
+  void bindNull(final long connectionPtr, final long statementPtr, final int index) {
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "bind null at index " + index,
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.bindNull(index);
+              return null;
+            }
+          });
+  }
+
+  void bindLong(final long connectionPtr, final long statementPtr, final int index, final long value) {
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "bind long at index " + index + " with value " + value,
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.bind(index, value);
+              return null;
+            }
+          });
+  }
+
+  void bindDouble(final long connectionPtr, final long statementPtr, final int index, final double value) {
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "bind double at index " + index + " with value " + value,
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.bind(index, value);
+              return null;
+            }
+          });
+  }
+
+  void bindString(final long connectionPtr, final long statementPtr, final int index, final String value) {
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "bind string at index " + index,
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.bind(index, value);
+              return null;
+            }
+          });
+  }
+
+  void bindBlob(final long connectionPtr, final long statementPtr, final int index, final byte[] value) {
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "bind blob at index " + index,
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.bind(index, value);
+              return null;
+            }
+          });
+  }
+
+  int executeForChangedRowCount(final long connectionPtr, final long statementPtr) {
+    synchronized (lock) {
+      final SQLiteConnection connection = getConnection(connectionPtr);
+      final SQLiteStatement statement = getStatement(connectionPtr, statementPtr);
+
+        return execute(
+            "execute for changed row count",
+            new Callable<Integer>() {
+              @Override
+              public Integer call() throws Exception {
+                if (statement.step()) {
+                  throw new android.database.sqlite.SQLiteException(
+                      "Queries can be performed using SQLiteDatabase query or rawQuery methods"
+                          + " only.");
+                }
+                return connection.getChanges();
+              }
+            });
+    }
+  }
+
+  long executeForLastInsertedRowId(final long connectionPtr, final long statementPtr) {
+    synchronized (lock) {
+      final SQLiteConnection connection = getConnection(connectionPtr);
+      final SQLiteStatement statement = getStatement(connectionPtr, statementPtr);
+
+        return execute(
+            "execute for last inserted row ID",
+            new Callable<Long>() {
+              @Override
+              public Long call() throws Exception {
+                statement.stepThrough();
+                return connection.getChanges() > 0 ? connection.getLastInsertId() : -1L;
+              }
+            });
+    }
+  }
+
+  long executeForCursorWindow(final long connectionPtr, final long statementPtr, final long windowPtr) {
+      return executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "execute for cursor window",
+          new StatementOperation<Integer>() {
+            @Override
+            public Integer call(final SQLiteStatement statement) throws Exception {
+              return ShadowLegacyCursorWindow.setData(windowPtr, statement);
+            }
+          });
+  }
+
+  void resetStatementAndClearBindings(final long connectionPtr, final long statementPtr) {
+      executeStatementOperation(
+          connectionPtr,
+          statementPtr,
+          "reset statement",
+          new StatementOperation<Void>() {
+            @Override
+            public Void call(final SQLiteStatement statement) throws Exception {
+              statement.reset(true);
+              return null;
+            }
+          });
+  }
+
+  interface StatementOperation<T> {
+    T call(final SQLiteStatement statement) throws Exception;
+  }
+
+  private <T> T executeStatementOperation(final long connectionPtr,
+                                          final long statementPtr,
+                                          final String comment,
+                                          final StatementOperation<T> statementOperation) {
+    synchronized (lock) {
+      final SQLiteStatement statement = getStatement(connectionPtr, statementPtr);
+      return execute(comment, new Callable<T>() {
+        @Override
+        public T call() throws Exception {
+          return statementOperation.call(statement);
+        }
+      });
+    }
+  }
+
+  /**
+   * Any Callable passed in to execute must not synchronize on lock, as this will result in a deadlock
+   */
+  private <T> T execute(final String comment, final Callable<T> work) {
+    synchronized (lock) {
+        return PerfStatsCollector.getInstance()
+            .measure("sqlite", () -> getFuture(comment, dbExecutor.submit(work)));
+    }
+  }
+
+  private static <T> T getFuture(final String comment, final Future<T> future) {
+    try {
+      return Uninterruptibles.getUninterruptibly(future);
+      // No need to catch cancellationexception - we never cancel these futures
+    } catch (ExecutionException e) {
+      Throwable t = e.getCause();
+      if (t instanceof SQLiteException) {
+          final RuntimeException sqlException =
+              getSqliteException("Cannot " + comment, ((SQLiteException) t).getBaseErrorCode());
+        sqlException.initCause(e);
+        throw sqlException;
+        } else if (t instanceof android.database.sqlite.SQLiteException) {
+          throw (android.database.sqlite.SQLiteException) t;
+      } else {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  private static RuntimeException getSqliteException(final String message, final int baseErrorCode) {
+    // Mapping is from throw_sqlite3_exception in android_database_SQLiteCommon.cpp
+    switch (baseErrorCode) {
+      case SQLiteConstants.SQLITE_ABORT: return new SQLiteAbortException(message);
+      case SQLiteConstants.SQLITE_PERM: return new SQLiteAccessPermException(message);
+      case SQLiteConstants.SQLITE_RANGE: return new SQLiteBindOrColumnIndexOutOfRangeException(message);
+      case SQLiteConstants.SQLITE_TOOBIG: return new SQLiteBlobTooBigException(message);
+      case SQLiteConstants.SQLITE_CANTOPEN: return new SQLiteCantOpenDatabaseException(message);
+      case SQLiteConstants.SQLITE_CONSTRAINT: return new SQLiteConstraintException(message);
+      case SQLiteConstants.SQLITE_NOTADB: // fall through
+      case SQLiteConstants.SQLITE_CORRUPT: return new SQLiteDatabaseCorruptException(message);
+      case SQLiteConstants.SQLITE_BUSY: return new SQLiteDatabaseLockedException(message);
+      case SQLiteConstants.SQLITE_MISMATCH: return new SQLiteDatatypeMismatchException(message);
+      case SQLiteConstants.SQLITE_IOERR: return new SQLiteDiskIOException(message);
+      case SQLiteConstants.SQLITE_DONE: return new SQLiteDoneException(message);
+      case SQLiteConstants.SQLITE_FULL: return new SQLiteFullException(message);
+      case SQLiteConstants.SQLITE_MISUSE: return new SQLiteMisuseException(message);
+      case SQLiteConstants.SQLITE_NOMEM: return new SQLiteOutOfMemoryException(message);
+      case SQLiteConstants.SQLITE_READONLY: return new SQLiteReadOnlyDatabaseException(message);
+      case SQLiteConstants.SQLITE_LOCKED: return new SQLiteTableLockedException(message);
+      case SQLiteConstants.SQLITE_INTERRUPT: return new OperationCanceledException(message);
+      default: return new android.database.sqlite.SQLiteException(message
+          + ", base error code: " + baseErrorCode);
+    }
+  }
+}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacySystemClock.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacySystemClock.java
new file mode 100644
index 0000000..11c2f96
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacySystemClock.java
@@ -0,0 +1,122 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.os.SystemClock;
+import java.time.DateTimeException;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * A shadow SystemClock for {@link LooperMode.Mode.LEGACY}
+ *
+ * <p>In LEGACY LooperMode, Robolectric's concept of current time is base on the current time of the
+ * UI Scheduler for consistency with previous implementations. This is not ideal, since both
+ * schedulers (background and foreground), can see different values for the current time.
+ */
+@Implements(
+    value = SystemClock.class,
+    shadowPicker = ShadowSystemClock.Picker.class,
+    // turn off shadowOf generation
+    isInAndroidSdk = false)
+public class ShadowLegacySystemClock extends ShadowSystemClock {
+  private static long bootedAt = 0;
+  private static long nanoTime = 0;
+  private static final int MILLIS_PER_NANO = 1000000;
+
+  static long now() {
+    return RuntimeEnvironment.getMasterScheduler().getCurrentTime();
+  }
+
+  @Implementation
+  protected static void sleep(long millis) {
+    nanoTime = millis * MILLIS_PER_NANO;
+    RuntimeEnvironment.getMasterScheduler().advanceBy(millis, TimeUnit.MILLISECONDS);
+  }
+
+  @Implementation
+  protected static boolean setCurrentTimeMillis(long millis) {
+    if (now() > millis) {
+      return false;
+    }
+    nanoTime = millis * MILLIS_PER_NANO;
+    RuntimeEnvironment.getMasterScheduler().advanceTo(millis);
+    return true;
+  }
+
+  @Implementation
+  protected static long uptimeMillis() {
+    return now() - bootedAt;
+  }
+
+  @Implementation
+  protected static long elapsedRealtime() {
+    return uptimeMillis();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static long elapsedRealtimeNanos() {
+    return elapsedRealtime() * MILLIS_PER_NANO;
+  }
+
+  @Implementation
+  protected static long currentThreadTimeMillis() {
+    return uptimeMillis();
+  }
+
+  @HiddenApi
+  @Implementation
+  public static long currentThreadTimeMicro() {
+    return uptimeMillis() * 1000;
+  }
+
+  @HiddenApi
+  @Implementation
+  public static long currentTimeMicro() {
+    return now() * 1000;
+  }
+
+  /**
+   * Implements {@link System#currentTimeMillis} through ShadowWrangler.
+   *
+   * @return Current time in millis.
+   */
+  @SuppressWarnings("unused")
+  public static long currentTimeMillis() {
+    return nanoTime / MILLIS_PER_NANO;
+  }
+
+  /**
+   * Implements {@link System#nanoTime} through ShadowWrangler.
+   *
+   * @return Current time with nanos.
+   */
+  @SuppressWarnings("unused")
+  public static long nanoTime() {
+    return nanoTime;
+  }
+
+  public static void setNanoTime(long nanoTime) {
+    ShadowLegacySystemClock.nanoTime = nanoTime;
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected static long currentNetworkTimeMillis() {
+    if (networkTimeAvailable) {
+      return currentTimeMillis();
+    } else {
+      throw new DateTimeException("Network time not available");
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    ShadowSystemClock.reset();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLibcoreOsConstants.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLibcoreOsConstants.java
new file mode 100644
index 0000000..2c2d073
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLibcoreOsConstants.java
@@ -0,0 +1,46 @@
+package org.robolectric.shadows;
+
+import java.lang.reflect.Field;
+import java.util.regex.Pattern;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Implements OsConstants on APIs 20 and below. */
+@Implements(className = "libcore.io.OsConstants", maxSdk = 20, isInAndroidSdk = false)
+public final class ShadowLibcoreOsConstants {
+  private static final Pattern ERRNO_PATTERN = Pattern.compile("E[A-Z0-9]+");
+
+  @Implementation
+  protected static void initConstants() {
+    int errnos = 1;
+    try {
+      for (Field field : Class.forName("libcore.io.OsConstants").getDeclaredFields()) {
+        if (ERRNO_PATTERN.matcher(field.getName()).matches() && field.getType() == int.class) {
+          field.setInt(null, errnos++);
+        }
+
+        // Type of file.
+        if (field.getName().equals(OsConstantsValues.S_IFMT)) {
+          field.setInt(null, OsConstantsValues.S_IFMT_VALUE);
+          continue;
+        }
+        // Directory.
+        if (field.getName().equals(OsConstantsValues.S_IFDIR)) {
+          field.setInt(null, OsConstantsValues.S_IFDIR_VALUE);
+          continue;
+        }
+        // Regular file.
+        if (field.getName().equals(OsConstantsValues.S_IFREG)) {
+          field.setInt(null, OsConstantsValues.S_IFREG_VALUE);
+          continue;
+        }
+        // Symbolic link.
+        if (field.getName().equals(OsConstantsValues.S_IFLNK)) {
+          field.setInt(null, OsConstantsValues.S_IFLNK_VALUE);
+        }
+      }
+    } catch (ReflectiveOperationException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLineBreaker.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLineBreaker.java
new file mode 100644
index 0000000..756b9aa
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLineBreaker.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import android.graphics.text.LineBreaker;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.res.android.NativeObjRegistry;
+
+/** Shadow for android.graphics.text.LineBreaker */
+@Implements(value = LineBreaker.class, isInAndroidSdk = false, minSdk = Build.VERSION_CODES.Q)
+public class ShadowLineBreaker {
+
+  static class NativeLineBreakerResult {
+    char[] chars;
+  }
+
+  static final NativeObjRegistry<NativeLineBreakerResult> nativeObjectRegistry =
+      new NativeObjRegistry<>(NativeLineBreakerResult.class);
+
+  @Implementation
+  protected static long nComputeLineBreaks(
+      /* non zero */ long nativePtr,
+      // Inputs
+      char[] text,
+      long measuredTextPtr,
+      int length,
+      float firstWidth,
+      int firstWidthLineCount,
+      float restWidth,
+      float[] variableTabStops,
+      float defaultTabStop,
+      int indentsOffset) {
+    NativeLineBreakerResult nativeLineBreakerResult = new NativeLineBreakerResult();
+    nativeLineBreakerResult.chars = text;
+    return nativeObjectRegistry.register(nativeLineBreakerResult);
+  }
+
+  @Implementation
+  protected static int nGetLineCount(long ptr) {
+    return 1;
+  }
+
+  @Implementation
+  protected static int nGetLineBreakOffset(long ptr, int idx) {
+    NativeLineBreakerResult nativeLineBreakerResult = nativeObjectRegistry.getNativeObject(ptr);
+    return (nativeLineBreakerResult != null) ? nativeLineBreakerResult.chars.length : 1;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinearLayout.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinearLayout.java
new file mode 100644
index 0000000..bfcfbae
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinearLayout.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.widget.LinearLayout;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(LinearLayout.class)
+public class ShadowLinearLayout extends ShadowViewGroup {
+  @RealObject LinearLayout realObject;
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  public int getGravity() {
+    return ReflectionHelpers.getField(realObject, "mGravity");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinkMovementMethod.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinkMovementMethod.java
new file mode 100644
index 0000000..4df7e34
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinkMovementMethod.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import android.text.method.LinkMovementMethod;
+import android.text.method.MovementMethod;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(LinkMovementMethod.class)
+public class ShadowLinkMovementMethod {
+  @Implementation
+  protected static MovementMethod getInstance() {
+    return new LinkMovementMethod();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinux.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinux.java
new file mode 100644
index 0000000..3053314
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLinux.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N_MR1;
+
+import android.os.Build;
+import android.system.ErrnoException;
+import android.system.OsConstants;
+import android.system.StructStat;
+import android.util.Log;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.time.Duration;
+import libcore.io.Linux;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = Linux.class, minSdk = Build.VERSION_CODES.O, isInAndroidSdk = false)
+public class ShadowLinux {
+  @Implementation
+  public void mkdir(String path, int mode) throws ErrnoException {
+    new File(path).mkdirs();
+  }
+
+  @Implementation
+  public StructStat stat(String path) throws ErrnoException {
+    int mode = OsConstantsValues.getMode(path);
+    long size = 0;
+    long modifiedTime = 0;
+    if (path != null) {
+      File file = new File(path);
+      size = file.length();
+      modifiedTime = Duration.ofMillis(file.lastModified()).getSeconds();
+    }
+    return new StructStat(
+        0, // st_dev
+        0, // st_ino
+        mode, // st_mode
+        0, // st_nlink
+        0, // st_uid
+        0, // st_gid
+        0, // st_rdev
+        size, // st_size
+        0, // st_atime
+        modifiedTime, // st_mtime
+        0, // st_ctime,
+        0, // st_blksize
+        0 // st_blocks
+        );
+  }
+
+  @Implementation
+  protected StructStat lstat(String path) throws ErrnoException {
+    return stat(path);
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected StructStat fstat(String path) throws ErrnoException {
+    return stat(path);
+  }
+
+  @Implementation
+  protected StructStat fstat(FileDescriptor fd) throws ErrnoException {
+    return stat(null);
+  }
+
+  @Implementation
+  protected FileDescriptor open(String path, int flags, int mode) throws ErrnoException {
+    try {
+      RandomAccessFile randomAccessFile = new RandomAccessFile(path, modeToString(mode));
+      return randomAccessFile.getFD();
+    } catch (IOException e) {
+      Log.e("ShadowLinux", "open failed for " + path, e);
+      throw new ErrnoException("open", OsConstants.EIO);
+    }
+  }
+
+  private static String modeToString(int mode) {
+    if (mode == OsConstants.O_RDONLY) {
+      return "r";
+    }
+    return "rw";
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowListPopupWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowListPopupWindow.java
new file mode 100644
index 0000000..41f98e5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowListPopupWindow.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.ListPopupWindow;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(ListPopupWindow.class)
+public class ShadowListPopupWindow {
+
+  @RealObject private ListPopupWindow realListPopupWindow;
+
+  @Implementation
+  protected void show() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    shadowApplication.setLatestListPopupWindow(realListPopupWindow);
+    reflector(ListPopupWindowReflector.class, realListPopupWindow).show();
+  }
+
+  public static ListPopupWindow getLatestListPopupWindow() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    return shadowApplication.getLatestListPopupWindow();
+  }
+
+  @ForType(ListPopupWindow.class)
+  interface ListPopupWindowReflector {
+
+    @Direct
+    void show();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowListView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowListView.java
new file mode 100644
index 0000000..28075aa
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowListView.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import android.view.View;
+import android.widget.HeaderViewListAdapter;
+import android.widget.ListView;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ListView.class)
+public class ShadowListView extends ShadowAbsListView {
+  @RealObject private ListView realListView;
+
+  public List<View> getHeaderViews() {
+    HeaderViewListAdapter adapter = (HeaderViewListAdapter) realListView.getAdapter();
+    ArrayList<View> headerViews = new ArrayList<>();
+    int headersCount = adapter.getHeadersCount();
+    for (int i = 0; i < headersCount; i++) {
+      headerViews.add(adapter.getView(i, null, realListView));
+    }
+    return headerViews;
+  }
+
+  public List<View> getFooterViews() {
+    HeaderViewListAdapter adapter = (HeaderViewListAdapter) realListView.getAdapter();
+    ArrayList<View> footerViews = new ArrayList<>();
+    int offset = adapter.getHeadersCount() + adapter.getCount() - adapter.getFootersCount();
+    int itemCount = adapter.getCount();
+    for (int i = offset; i < itemCount; i++) {
+      footerViews.add(adapter.getView(i, null, realListView));
+    }
+    return footerViews;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLoadedApk.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLoadedApk.java
new file mode 100644
index 0000000..2a3a61b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLoadedApk.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import android.app.Application;
+import android.app.LoadedApk;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = LoadedApk.class, isInAndroidSdk = false)
+public class ShadowLoadedApk {
+
+  @Implementation
+  public ClassLoader getClassLoader() {
+    return this.getClass().getClassLoader();
+  }
+
+  @Implementation(minSdk = VERSION_CODES.O)
+  public ClassLoader getSplitClassLoader(String splitName) throws NameNotFoundException {
+    return this.getClass().getClassLoader();
+  }
+
+  /** Accessor interface for {@link LoadedApk}'s internals. */
+  @ForType(LoadedApk.class)
+  public interface _LoadedApk_ {
+
+    @Accessor("mApplication")
+    void setApplication(Application application);
+
+    @Accessor("mResources")
+    void setResources(Resources resources);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleData.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleData.java
new file mode 100644
index 0000000..803495e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleData.java
@@ -0,0 +1,283 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import java.util.Locale;
+import libcore.icu.LocaleData;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Robolectric only supports en_US regardless of the default locale set in the JVM. */
+@Implements(value = LocaleData.class, isInAndroidSdk = false, maxSdk = S_V2)
+public class ShadowLocaleData {
+  public static final String REAL_CLASS_NAME = "libcore.icu.LocaleData";
+
+  @Implementation
+  public static LocaleData get(Locale locale) {
+    LocaleData localeData = (LocaleData) Shadow.newInstanceOf(REAL_CLASS_NAME);
+    if (locale == null) {
+      locale = Locale.getDefault();
+    }
+    setEnUsLocaleData(localeData);
+    return localeData;
+  }
+
+  private static void setEnUsLocaleData(LocaleData localeData) {
+    localeData.amPm = new String[] {"AM", "PM"};
+    localeData.eras = new String[] {"BC", "AD"};
+
+    localeData.firstDayOfWeek = 1;
+    localeData.minimalDaysInFirstWeek = 1;
+
+    localeData.longMonthNames =
+        new String[] {
+          "January",
+          "February",
+          "March",
+          "April",
+          "May",
+          "June",
+          "July",
+          "August",
+          "September",
+          "October",
+          "November",
+          "December"
+        };
+    localeData.shortMonthNames =
+        new String[] {
+          "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+        };
+
+    _LocaleData_ localDataReflector = reflector(_LocaleData_.class, localeData);
+    if (getApiLevel() >= JELLY_BEAN_MR1) {
+      localeData.tinyMonthNames =
+          new String[] {"J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"};
+      localeData.tinyStandAloneMonthNames = localeData.tinyMonthNames;
+      localeData.tinyWeekdayNames = new String[] {"", "S", "M", "T", "W", "T", "F", "S"};
+      localeData.tinyStandAloneWeekdayNames = localeData.tinyWeekdayNames;
+
+      if (getApiLevel() <= R) {
+        localDataReflector.setYesterday("Yesterday");
+      }
+      localeData.today = "Today";
+      localeData.tomorrow = "Tomorrow";
+    }
+
+    localeData.longStandAloneMonthNames = localeData.longMonthNames;
+    localeData.shortStandAloneMonthNames = localeData.shortMonthNames;
+
+    localeData.longWeekdayNames =
+        new String[] {
+          "", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
+        };
+    localeData.shortWeekdayNames =
+        new String[] {"", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
+
+    localeData.longStandAloneWeekdayNames = localeData.longWeekdayNames;
+    localeData.shortStandAloneWeekdayNames = localeData.shortWeekdayNames;
+
+    localDataReflector.setFullTimeFormat("h:mm:ss a zzzz");
+    localDataReflector.setLongTimeFormat("h:mm:ss a z");
+    localDataReflector.setMediumTimeFormat("h:mm:ss a");
+    localDataReflector.setShortTimeFormat("h:mm a");
+
+    if (getApiLevel() >= M) {
+      localeData.timeFormat_hm = "h:mm a";
+      localeData.timeFormat_Hm = "HH:mm";
+    } else if (getApiLevel() >= JELLY_BEAN_MR2) {
+      localDataReflector.setTimeFormat12("h:mm a");
+      localDataReflector.setTimeFormat24("HH:mm");
+    }
+
+    localDataReflector.setFullDateFormat("EEEE, MMMM d, y");
+    localDataReflector.setLongDateFormat("MMMM d, y");
+    localDataReflector.setMediumDateFormat("MMM d, y");
+    localDataReflector.setShortDateFormat("M/d/yy");
+    if (getApiLevel() >= KITKAT && getApiLevel() < M) {
+      localDataReflector.setShortDateFormat4("M/d/yyyy");
+    }
+
+    localeData.zeroDigit = '0';
+    localDataReflector.setDecimalSeparator('.');
+    localDataReflector.setGroupingSeparator(',');
+    localDataReflector.setPatternSeparator(';');
+
+    if (getApiLevel() >= LOLLIPOP_MR1) {
+      // Lollipop MR1 uses a String
+      localDataReflector.setPercent("%");
+    } else {
+      // Upto Lollipop was a char
+      localDataReflector.setPercent('%');
+    }
+
+    if (getApiLevel() >= android.os.Build.VERSION_CODES.P) {
+      // P uses a String
+      localDataReflector.setPerMill("\u2030"); // '‰'
+    } else {
+      // Up to P was a char
+      localDataReflector.setPerMill('\u2030'); // '‰'
+    }
+
+    localDataReflector.setMonetarySeparator('.');
+
+    if (getApiLevel() >= LOLLIPOP) {
+      // Lollipop uses a String
+      localDataReflector.setMinusSign("-");
+    } else {
+      // Upto KitKat was a char
+      localDataReflector.setMinusSign('-');
+    }
+
+    localDataReflector.setExponentSeparator("E");
+    localDataReflector.setInfinity("\u221E");
+    localDataReflector.setNaN("NaN");
+
+    if (getApiLevel() <= R) {
+      localDataReflector.setCurrencySymbol("$");
+      localDataReflector.setInternationalCurrencySymbol("USD");
+    }
+
+    localDataReflector.setNumberPattern("\u0023,\u0023\u00230.\u0023\u0023\u0023");
+    localDataReflector.setIntegerPattern("\u0023,\u0023\u00230");
+    localDataReflector.setCurrencyPattern(
+        "\u00A4\u0023,\u0023\u00230.00;(\u00A4\u0023,\u0023\u00230.00)");
+    localDataReflector.setPercentPattern("\u0023,\u0023\u00230%");
+  }
+
+  /** Accessor interface for {@link LocaleData}'s internals. */
+  @ForType(LocaleData.class)
+  interface _LocaleData_ {
+
+    @Accessor("minusSign")
+    void setMinusSign(char c);
+
+    @Accessor("percent")
+    void setPercent(char c);
+
+    @Accessor("perMill")
+    void setPerMill(char c);
+
+    @Accessor("timeFormat12")
+    void setTimeFormat12(String format);
+
+    @Accessor("timeFormat24")
+    void setTimeFormat24(String format);
+
+    @Accessor("shortDateFormat4")
+    void setShortDateFormat4(String format);
+
+    // <= R
+    @Accessor("yesterday")
+    void setYesterday(String yesterday);
+
+    // <= R
+    @Accessor("currencySymbol")
+    void setCurrencySymbol(String symbol);
+
+    // <= R
+    @Accessor("internationalCurrencySymbol")
+    void setInternationalCurrencySymbol(String symbol);
+
+    // <= S_V2
+    @Accessor("fullTimeFormat")
+    void setFullTimeFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("longTimeFormat")
+    void setLongTimeFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("mediumTimeFormat")
+    void setMediumTimeFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("shortTimeFormat")
+    void setShortTimeFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("fullDateFormat")
+    void setFullDateFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("longDateFormat")
+    void setLongDateFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("mediumDateFormat")
+    void setMediumDateFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("shortDateFormat")
+    void setShortDateFormat(String symbol);
+
+    // <= S_V2
+    @Accessor("decimalSeparator")
+    void setDecimalSeparator(char symbol);
+
+    // <= S_V2
+    @Accessor("groupingSeparator")
+    void setGroupingSeparator(char symbol);
+
+    // <= S_V2
+    @Accessor("patternSeparator")
+    void setPatternSeparator(char symbol);
+
+    // <= S_V2
+    @Accessor("percent")
+    void setPercent(String symbol);
+
+    // <= S_V2
+    @Accessor("perMill")
+    void setPerMill(String symbol);
+
+    // <= S_V2
+    @Accessor("monetarySeparator")
+    void setMonetarySeparator(char symbol);
+
+    // <= S_V2
+    @Accessor("minusSign")
+    void setMinusSign(String symbol);
+
+    // <= S_V2
+    @Accessor("exponentSeparator")
+    void setExponentSeparator(String symbol);
+
+    // <= S_V2
+    @Accessor("infinity")
+    void setInfinity(String symbol);
+
+    // <= S_V2
+    @Accessor("NaN")
+    void setNaN(String symbol);
+
+    // <= S_V2
+    @Accessor("numberPattern")
+    void setNumberPattern(String symbol);
+
+    // <= S_V2
+    @Accessor("integerPattern")
+    void setIntegerPattern(String symbol);
+
+    // <= S_V2
+    @Accessor("currencyPattern")
+    void setCurrencyPattern(String symbol);
+
+    // <= S_V2
+    @Accessor("percentPattern")
+    void setPercentPattern(String symbol);
+  }
+}
+
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleList.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleList.java
new file mode 100644
index 0000000..8e38cc5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleList.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build.VERSION_CODES;
+import android.os.LocaleList;
+import java.util.Locale;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Shadow for {@link android.os.LocaleList} */
+@Implements(value = LocaleList.class, minSdk = VERSION_CODES.N)
+public class ShadowLocaleList {
+
+  @Resetter
+  public static void reset() {
+    LocaleListReflector localeListReflector = reflector(LocaleListReflector.class);
+    localeListReflector.setLastDefaultLocale(null);
+    localeListReflector.setDefaultLocaleList(null);
+    localeListReflector.setDefaultAdjustedLocaleList(null);
+    localeListReflector.setLastExplicitlySetLocaleList(null);
+  }
+
+  @ForType(LocaleList.class)
+  interface LocaleListReflector {
+    @Static
+    @Accessor("sLastDefaultLocale")
+    void setLastDefaultLocale(Locale lastDefaultLocal);
+
+    @Static
+    @Accessor("sDefaultLocaleList")
+    void setDefaultLocaleList(LocaleList localeList);
+
+    @Static
+    @Accessor("sDefaultAdjustedLocaleList")
+    void setDefaultAdjustedLocaleList(LocaleList localeList);
+
+    @Static
+    @Accessor("sLastExplicitlySetLocaleList")
+    void setLastExplicitlySetLocaleList(LocaleList localeList);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleManager.java
new file mode 100644
index 0000000..880c21a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocaleManager.java
@@ -0,0 +1,76 @@
+package org.robolectric.shadows;
+
+import android.app.LocaleManager;
+import android.os.Build.VERSION_CODES;
+import android.os.LocaleList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link LocaleManager} */
+@Implements(value = LocaleManager.class, minSdk = VERSION_CODES.TIRAMISU, isInAndroidSdk = false)
+public class ShadowLocaleManager {
+
+  private final Map<String, LocaleList> appLocales = new HashMap<>();
+  private final Set<String> packagesInstalledByCaller = new HashSet<>();
+  private boolean enforceInstallerCheck;
+
+  /**
+   * Returns the stored locales from in-memory map for the given package when {@link
+   * LocaleManager#getApplicationLocales} is invoked in source code via tests.
+   *
+   * <p>If {@link #enforceInstallerCheck} is set as true, this method will return locales only if
+   * the package is installed by caller. Else it will throw a {@link SecurityException}.
+   *
+   * <p>Adds the package name in a set to record that this method was invoked for given package.
+   *
+   * @see #enforceInstallerCheck
+   * @see #setCallerAsInstallerForPackage
+   */
+  @Implementation
+  protected LocaleList getApplicationLocales(String packageName) {
+    if (enforceInstallerCheck) {
+      if (!packagesInstalledByCaller.contains(packageName)) {
+        throw new SecurityException(
+            "Caller does not have permission to query locales for package " + packageName);
+      }
+    }
+    return appLocales.getOrDefault(packageName, LocaleList.getEmptyLocaleList());
+  }
+
+  /**
+   * Stores the passed locales for the given package in-memory.
+   *
+   * <p>Use this method in tests to substitute call for {@link LocaleManager#setApplicationLocales}.
+   */
+  @Implementation
+  protected void setApplicationLocales(String packageName, LocaleList locales) {
+    appLocales.put(packageName, locales);
+  }
+
+  /**
+   * Sets the value of {@link #enforceInstallerCheck}.
+   *
+   * <p>Set this to true if the intention to invoke {@link #getApplicationLocales} is as an
+   * installer of the app.
+   *
+   * <p>In order to mark apps as installed by the caller(installer), use {@link
+   * #setCallerAsInstallerForPackage}.
+   */
+  public void enforceInstallerCheck(boolean value) {
+    enforceInstallerCheck = value;
+  }
+
+  /**
+   * Sets the caller as the installer of the given package.
+   *
+   * <p>We are explicitly not storing the package name of the installer. It's implied that the test
+   * app is the installer if using this method.
+   */
+  public void setCallerAsInstallerForPackage(String packageName) {
+    packagesInstalledByCaller.add(packageName);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocationManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocationManager.java
new file mode 100644
index 0000000..0d61eb6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLocationManager.java
@@ -0,0 +1,2167 @@
+package org.robolectric.shadows;
+
+import static android.location.LocationManager.GPS_PROVIDER;
+import static android.location.LocationManager.NETWORK_PROVIDER;
+import static android.location.LocationManager.PASSIVE_PROVIDER;
+import static android.os.Build.VERSION_CODES.P;
+import static android.provider.Settings.Secure.LOCATION_MODE;
+import static android.provider.Settings.Secure.LOCATION_MODE_BATTERY_SAVING;
+import static android.provider.Settings.Secure.LOCATION_MODE_HIGH_ACCURACY;
+import static android.provider.Settings.Secure.LOCATION_MODE_OFF;
+import static android.provider.Settings.Secure.LOCATION_MODE_SENSORS_ONLY;
+import static android.provider.Settings.Secure.LOCATION_PROVIDERS_ALLOWED;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.content.Context;
+import android.content.Intent;
+import android.location.Criteria;
+import android.location.GnssAntennaInfo;
+import android.location.GnssMeasurementsEvent;
+import android.location.GnssStatus;
+import android.location.GpsStatus;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.location.LocationProvider;
+import android.location.LocationRequest;
+import android.location.OnNmeaMessageListener;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Process;
+import android.os.SystemClock;
+import android.os.UserHandle;
+import android.os.WorkSource;
+import android.provider.Settings.Secure;
+import android.text.TextUtils;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.function.Consumer;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadows.ShadowSettings.ShadowSecure;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Shadow for {@link LocationManager}. Note that the default state of location on Android devices is
+ * location on, gps provider enabled, network provider disabled.
+ */
+@SuppressWarnings("deprecation")
+@Implements(value = LocationManager.class, looseSignatures = true)
+public class ShadowLocationManager {
+
+  private static final long GET_CURRENT_LOCATION_TIMEOUT_MS = 30 * 1000;
+  private static final long MAX_CURRENT_LOCATION_AGE_MS = 10 * 1000;
+
+  /**
+   * ProviderProperties is not public prior to S, so a new class is required to represent it prior
+   * to that platform.
+   */
+  public static class ProviderProperties {
+    @Nullable private final Object properties;
+
+    private final boolean requiresNetwork;
+    private final boolean requiresSatellite;
+    private final boolean requiresCell;
+    private final boolean hasMonetaryCost;
+    private final boolean supportsAltitude;
+    private final boolean supportsSpeed;
+    private final boolean supportsBearing;
+    private final int powerRequirement;
+    private final int accuracy;
+
+    @RequiresApi(VERSION_CODES.S)
+    ProviderProperties(android.location.provider.ProviderProperties properties) {
+      this.properties = Objects.requireNonNull(properties);
+      this.requiresNetwork = false;
+      this.requiresSatellite = false;
+      this.requiresCell = false;
+      this.hasMonetaryCost = false;
+      this.supportsAltitude = false;
+      this.supportsSpeed = false;
+      this.supportsBearing = false;
+      this.powerRequirement = 0;
+      this.accuracy = 0;
+    }
+
+    public ProviderProperties(
+        boolean requiresNetwork,
+        boolean requiresSatellite,
+        boolean requiresCell,
+        boolean hasMonetaryCost,
+        boolean supportsAltitude,
+        boolean supportsSpeed,
+        boolean supportsBearing,
+        int powerRequirement,
+        int accuracy) {
+      if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.S) {
+        properties =
+            new android.location.provider.ProviderProperties.Builder()
+                .setHasNetworkRequirement(requiresNetwork)
+                .setHasSatelliteRequirement(requiresSatellite)
+                .setHasCellRequirement(requiresCell)
+                .setHasMonetaryCost(hasMonetaryCost)
+                .setHasAltitudeSupport(supportsAltitude)
+                .setHasSpeedSupport(supportsSpeed)
+                .setHasBearingSupport(supportsBearing)
+                .setPowerUsage(powerRequirement)
+                .setAccuracy(accuracy)
+                .build();
+      } else {
+        properties = null;
+      }
+
+      this.requiresNetwork = requiresNetwork;
+      this.requiresSatellite = requiresSatellite;
+      this.requiresCell = requiresCell;
+      this.hasMonetaryCost = hasMonetaryCost;
+      this.supportsAltitude = supportsAltitude;
+      this.supportsSpeed = supportsSpeed;
+      this.supportsBearing = supportsBearing;
+      this.powerRequirement = powerRequirement;
+      this.accuracy = accuracy;
+    }
+
+    public ProviderProperties(Criteria criteria) {
+      this(
+          false,
+          false,
+          false,
+          criteria.isCostAllowed(),
+          criteria.isAltitudeRequired(),
+          criteria.isSpeedRequired(),
+          criteria.isBearingRequired(),
+          criteria.getPowerRequirement(),
+          criteria.getAccuracy());
+    }
+
+    @RequiresApi(VERSION_CODES.S)
+    android.location.provider.ProviderProperties getProviderProperties() {
+      return (android.location.provider.ProviderProperties) Objects.requireNonNull(properties);
+    }
+
+    Object getLegacyProviderProperties() {
+      try {
+        return ReflectionHelpers.callConstructor(
+            Class.forName("com.android.internal.location.ProviderProperties"),
+            ClassParameter.from(boolean.class, requiresNetwork),
+            ClassParameter.from(boolean.class, requiresSatellite),
+            ClassParameter.from(boolean.class, requiresCell),
+            ClassParameter.from(boolean.class, hasMonetaryCost),
+            ClassParameter.from(boolean.class, supportsAltitude),
+            ClassParameter.from(boolean.class, supportsSpeed),
+            ClassParameter.from(boolean.class, supportsBearing),
+            ClassParameter.from(int.class, powerRequirement),
+            ClassParameter.from(int.class, accuracy));
+      } catch (ClassNotFoundException c) {
+        throw new RuntimeException("Unable to load old ProviderProperties class", c);
+      }
+    }
+
+    public boolean hasNetworkRequirement() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).hasNetworkRequirement();
+      } else {
+        return requiresNetwork;
+      }
+    }
+
+    public boolean hasSatelliteRequirement() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties)
+            .hasSatelliteRequirement();
+      } else {
+        return requiresSatellite;
+      }
+    }
+
+    public boolean isRequiresCell() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).hasCellRequirement();
+      } else {
+        return requiresCell;
+      }
+    }
+
+    public boolean isHasMonetaryCost() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).hasMonetaryCost();
+      } else {
+        return hasMonetaryCost;
+      }
+    }
+
+    public boolean hasAltitudeSupport() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).hasAltitudeSupport();
+      } else {
+        return supportsAltitude;
+      }
+    }
+
+    public boolean hasSpeedSupport() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).hasSpeedSupport();
+      } else {
+        return supportsSpeed;
+      }
+    }
+
+    public boolean hasBearingSupport() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).hasBearingSupport();
+      } else {
+        return supportsBearing;
+      }
+    }
+
+    public int getPowerUsage() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).getPowerUsage();
+      } else {
+        return powerRequirement;
+      }
+    }
+
+    public int getAccuracy() {
+      if (properties != null) {
+        return ((android.location.provider.ProviderProperties) properties).getAccuracy();
+      } else {
+        return accuracy;
+      }
+    }
+
+    boolean meetsCriteria(Criteria criteria) {
+      if (criteria.getAccuracy() != Criteria.NO_REQUIREMENT
+          && criteria.getAccuracy() < getAccuracy()) {
+        return false;
+      }
+      if (criteria.getPowerRequirement() != Criteria.NO_REQUIREMENT
+          && criteria.getPowerRequirement() < getPowerUsage()) {
+        return false;
+      }
+      if (criteria.isAltitudeRequired() && !hasAltitudeSupport()) {
+        return false;
+      }
+      if (criteria.isSpeedRequired() && !hasSpeedSupport()) {
+        return false;
+      }
+      if (criteria.isBearingRequired() && !hasBearingSupport()) {
+        return false;
+      }
+      if (!criteria.isCostAllowed() && hasMonetaryCost) {
+        return false;
+      }
+      return true;
+    }
+  }
+
+  @GuardedBy("ShadowLocationManager.class")
+  @Nullable
+  private static Constructor<LocationProvider> locationProviderConstructor;
+
+  @RealObject private LocationManager realLocationManager;
+
+  @GuardedBy("providers")
+  private final HashSet<ProviderEntry> providers = new HashSet<>();
+
+  @GuardedBy("gpsStatusListeners")
+  private final HashSet<GpsStatus.Listener> gpsStatusListeners = new HashSet<>();
+
+  @GuardedBy("gnssStatusTransports")
+  private final CopyOnWriteArrayList<GnssStatusCallbackTransport> gnssStatusTransports =
+      new CopyOnWriteArrayList<>();
+
+  @GuardedBy("nmeaMessageTransports")
+  private final CopyOnWriteArrayList<OnNmeaMessageListenerTransport> nmeaMessageTransports =
+      new CopyOnWriteArrayList<>();
+
+  @GuardedBy("gnssMeasurementTransports")
+  private final CopyOnWriteArrayList<GnssMeasurementsEventCallbackTransport>
+      gnssMeasurementTransports = new CopyOnWriteArrayList<>();
+
+  @GuardedBy("gnssAntennaInfoTransports")
+  private final CopyOnWriteArrayList<GnssAntennaInfoListenerTransport> gnssAntennaInfoTransports =
+      new CopyOnWriteArrayList<>();
+
+  @Nullable private String gnssHardwareModelName;
+
+  private int gnssYearOfHardware;
+
+  public ShadowLocationManager() {
+    // create default providers
+    providers.add(
+        new ProviderEntry(
+            GPS_PROVIDER,
+            new ProviderProperties(
+                true,
+                true,
+                false,
+                false,
+                true,
+                true,
+                true,
+                Criteria.POWER_HIGH,
+                Criteria.ACCURACY_FINE)));
+    providers.add(
+        new ProviderEntry(
+            NETWORK_PROVIDER,
+            new ProviderProperties(
+                false,
+                false,
+                false,
+                false,
+                true,
+                true,
+                true,
+                Criteria.POWER_LOW,
+                Criteria.ACCURACY_COARSE)));
+    providers.add(
+        new ProviderEntry(
+            PASSIVE_PROVIDER,
+            new ProviderProperties(
+                false,
+                false,
+                false,
+                false,
+                false,
+                false,
+                false,
+                Criteria.POWER_LOW,
+                Criteria.ACCURACY_COARSE)));
+  }
+
+  @Implementation
+  protected List<String> getAllProviders() {
+    ArrayList<String> allProviders = new ArrayList<>();
+    for (ProviderEntry providerEntry : getProviderEntries()) {
+      allProviders.add(providerEntry.getName());
+    }
+    return allProviders;
+  }
+
+  @Implementation
+  @Nullable
+  protected LocationProvider getProvider(String name) {
+    if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.KITKAT) {
+      // jelly bean has no way to properly construct a LocationProvider, we give up
+      return null;
+    }
+
+    ProviderEntry providerEntry = getProviderEntry(name);
+    if (providerEntry == null) {
+      return null;
+    }
+
+    ProviderProperties properties = providerEntry.getProperties();
+    if (properties == null) {
+      return null;
+    }
+
+    try {
+      synchronized (ShadowLocationManager.class) {
+        if (locationProviderConstructor == null) {
+          if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.S) {
+            locationProviderConstructor =
+                LocationProvider.class.getConstructor(
+                    String.class, android.location.provider.ProviderProperties.class);
+          } else {
+            locationProviderConstructor =
+                LocationProvider.class.getConstructor(
+                    String.class,
+                    Class.forName("com.android.internal.location.ProviderProperties"));
+          }
+          locationProviderConstructor.setAccessible(true);
+        }
+
+        if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.S) {
+          return locationProviderConstructor.newInstance(name, properties.getProviderProperties());
+        } else {
+          return locationProviderConstructor.newInstance(
+              name, properties.getLegacyProviderProperties());
+        }
+      }
+    } catch (ReflectiveOperationException e) {
+      throw new LinkageError(e.getMessage(), e);
+    }
+  }
+
+  @Implementation
+  protected List<String> getProviders(boolean enabledOnly) {
+    return getProviders(null, enabledOnly);
+  }
+
+  @Implementation
+  protected List<String> getProviders(@Nullable Criteria criteria, boolean enabled) {
+    ArrayList<String> matchingProviders = new ArrayList<>();
+    for (ProviderEntry providerEntry : getProviderEntries()) {
+      if (enabled && !isProviderEnabled(providerEntry.getName())) {
+        continue;
+      }
+      if (criteria != null && !providerEntry.meetsCriteria(criteria)) {
+        continue;
+      }
+      matchingProviders.add(providerEntry.getName());
+    }
+    return matchingProviders;
+  }
+
+  @Implementation
+  @Nullable
+  protected String getBestProvider(Criteria criteria, boolean enabled) {
+    List<String> providers = getProviders(criteria, enabled);
+    if (providers.isEmpty()) {
+      providers = getProviders(null, enabled);
+    }
+
+    if (!providers.isEmpty()) {
+      if (providers.contains(GPS_PROVIDER)) {
+        return GPS_PROVIDER;
+      } else if (providers.contains(NETWORK_PROVIDER)) {
+        return NETWORK_PROVIDER;
+      } else {
+        return providers.get(0);
+      }
+    }
+
+    return null;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  @Nullable
+  protected Object getProviderProperties(Object providerStr) {
+    String provider = (String) providerStr;
+    if (provider == null) {
+      throw new IllegalArgumentException();
+    }
+
+    ProviderEntry providerEntry = getProviderEntry(provider);
+    if (providerEntry == null) {
+      return null;
+    }
+
+    ProviderProperties properties = providerEntry.getProperties();
+    if (properties == null) {
+      return null;
+    }
+
+    return properties.getProviderProperties();
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  protected boolean hasProvider(String provider) {
+    if (provider == null) {
+      throw new IllegalArgumentException();
+    }
+
+    return getProviderEntry(provider) != null;
+  }
+
+  @Implementation
+  protected boolean isProviderEnabled(String provider) {
+    if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.P) {
+      if (!isLocationEnabled()) {
+        return false;
+      }
+    }
+
+    ProviderEntry entry = getProviderEntry(provider);
+    return entry != null && entry.isEnabled();
+  }
+
+  /** Completely removes a provider. */
+  public void removeProvider(String name) {
+    removeProviderEntry(name);
+  }
+
+  /**
+   * Sets the properties of the given provider. The provider will be created if it doesn't exist
+   * already. This overload functions for all Android SDK levels.
+   */
+  public void setProviderProperties(String name, @Nullable ProviderProperties properties) {
+    getOrCreateProviderEntry(Objects.requireNonNull(name)).setProperties(properties);
+  }
+
+  /**
+   * Sets the given provider enabled or disabled. The provider will be created if it doesn't exist
+   * already. On P and above, location must also be enabled via {@link #setLocationEnabled(boolean)}
+   * in order for a provider to be considered enabled.
+   */
+  public void setProviderEnabled(String name, boolean enabled) {
+    getOrCreateProviderEntry(name).setEnabled(enabled);
+  }
+
+  // @SystemApi
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected boolean isLocationEnabledForUser(UserHandle userHandle) {
+    return isLocationEnabled();
+  }
+
+  @Implementation(minSdk = P)
+  protected boolean isLocationEnabled() {
+    return getLocationMode() != LOCATION_MODE_OFF;
+  }
+
+  // @SystemApi
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected void setLocationEnabledForUser(boolean enabled, UserHandle userHandle) {
+    setLocationModeInternal(enabled ? LOCATION_MODE_HIGH_ACCURACY : LOCATION_MODE_OFF);
+  }
+
+  /**
+   * On P and above, turns location on or off. On pre-P devices, sets the location mode to {@link
+   * android.provider.Settings.Secure#LOCATION_MODE_HIGH_ACCURACY} or {@link
+   * android.provider.Settings.Secure#LOCATION_MODE_OFF}.
+   */
+  public void setLocationEnabled(boolean enabled) {
+    setLocationEnabledForUser(enabled, Process.myUserHandle());
+  }
+
+  private int getLocationMode() {
+    return Secure.getInt(getContext().getContentResolver(), LOCATION_MODE, LOCATION_MODE_OFF);
+  }
+
+  /**
+   * On pre-P devices, sets the device location mode. For P and above, use {@link
+   * #setLocationEnabled(boolean)} and {@link #setProviderEnabled(String, boolean)} in combination
+   * to achieve the desired effect.
+   */
+  public void setLocationMode(int locationMode) {
+    if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.P) {
+      throw new AssertionError(
+          "Tests may not set location mode directly on P and above. Instead, use"
+              + " setLocationEnabled() and setProviderEnabled() in combination to achieve the"
+              + " desired result.");
+    }
+
+    setLocationModeInternal(locationMode);
+  }
+
+  private void setLocationModeInternal(int locationMode) {
+    Secure.putInt(getContext().getContentResolver(), LOCATION_MODE, locationMode);
+  }
+
+  @Implementation
+  @Nullable
+  protected Location getLastKnownLocation(String provider) {
+    ProviderEntry providerEntry = getProviderEntry(provider);
+    if (providerEntry == null) {
+      return null;
+    }
+
+    return providerEntry.getLastLocation();
+  }
+
+  /**
+   * @deprecated Use {@link #simulateLocation(Location)} to update the last location for a provider.
+   */
+  @Deprecated
+  public void setLastKnownLocation(String provider, @Nullable Location location) {
+    getOrCreateProviderEntry(provider).setLastLocation(location);
+  }
+
+  @RequiresApi(api = VERSION_CODES.R)
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void getCurrentLocation(
+      String provider,
+      @Nullable CancellationSignal cancellationSignal,
+      Executor executor,
+      Consumer<Location> consumer) {
+    getCurrentLocationInternal(
+        provider, LocationRequest.create(), cancellationSignal, executor, consumer);
+  }
+
+  @RequiresApi(api = VERSION_CODES.S)
+  @Implementation(minSdk = VERSION_CODES.S)
+  protected void getCurrentLocation(
+      String provider,
+      LocationRequest request,
+      @Nullable CancellationSignal cancellationSignal,
+      Executor executor,
+      Consumer<Location> consumer) {
+    getCurrentLocationInternal(provider, request, cancellationSignal, executor, consumer);
+  }
+
+  @RequiresApi(api = VERSION_CODES.R)
+  private void getCurrentLocationInternal(
+      String provider,
+      LocationRequest request,
+      @Nullable CancellationSignal cancellationSignal,
+      Executor executor,
+      Consumer<Location> consumer) {
+    if (cancellationSignal != null) {
+      cancellationSignal.throwIfCanceled();
+    }
+
+    final Location location = getLastKnownLocation(provider);
+    if (location != null) {
+      long locationAgeMs =
+          SystemClock.elapsedRealtime() - NANOSECONDS.toMillis(location.getElapsedRealtimeNanos());
+      if (locationAgeMs < MAX_CURRENT_LOCATION_AGE_MS) {
+        executor.execute(() -> consumer.accept(location));
+        return;
+      }
+    }
+
+    CurrentLocationTransport listener = new CurrentLocationTransport(executor, consumer);
+    requestLocationUpdatesInternal(
+        provider, new RoboLocationRequest(request), Runnable::run, listener);
+
+    if (cancellationSignal != null) {
+      cancellationSignal.setOnCancelListener(listener::cancel);
+    }
+
+    listener.startTimeout(GET_CURRENT_LOCATION_TIMEOUT_MS);
+  }
+
+  @Implementation
+  protected void requestSingleUpdate(
+      String provider, LocationListener listener, @Nullable Looper looper) {
+    if (looper == null) {
+      looper = Looper.myLooper();
+      if (looper == null) {
+        // forces appropriate exception
+        new Handler();
+      }
+    }
+    requestLocationUpdatesInternal(
+        provider,
+        new RoboLocationRequest(provider, 0, 0, true),
+        new HandlerExecutor(new Handler(looper)),
+        listener);
+  }
+
+  @Implementation
+  protected void requestSingleUpdate(
+      Criteria criteria, LocationListener listener, @Nullable Looper looper) {
+    String bestProvider = getBestProvider(criteria, true);
+    if (bestProvider == null) {
+      throw new IllegalArgumentException("no providers found for criteria");
+    }
+    if (looper == null) {
+      looper = Looper.myLooper();
+      if (looper == null) {
+        // forces appropriate exception
+        new Handler();
+      }
+    }
+    requestLocationUpdatesInternal(
+        bestProvider,
+        new RoboLocationRequest(bestProvider, 0, 0, true),
+        new HandlerExecutor(new Handler(looper)),
+        listener);
+  }
+
+  @Implementation
+  protected void requestSingleUpdate(String provider, PendingIntent pendingIntent) {
+    requestLocationUpdatesInternal(
+        provider, new RoboLocationRequest(provider, 0, 0, true), pendingIntent);
+  }
+
+  @Implementation
+  protected void requestSingleUpdate(Criteria criteria, PendingIntent pendingIntent) {
+    String bestProvider = getBestProvider(criteria, true);
+    if (bestProvider == null) {
+      throw new IllegalArgumentException("no providers found for criteria");
+    }
+    requestLocationUpdatesInternal(
+        bestProvider, new RoboLocationRequest(bestProvider, 0, 0, true), pendingIntent);
+  }
+
+  @Implementation
+  protected void requestLocationUpdates(
+      String provider, long minTime, float minDistance, LocationListener listener) {
+    requestLocationUpdatesInternal(
+        provider,
+        new RoboLocationRequest(provider, minTime, minDistance, false),
+        new HandlerExecutor(new Handler()),
+        listener);
+  }
+
+  @Implementation
+  protected void requestLocationUpdates(
+      String provider,
+      long minTime,
+      float minDistance,
+      LocationListener listener,
+      @Nullable Looper looper) {
+    if (looper == null) {
+      looper = Looper.myLooper();
+      if (looper == null) {
+        // forces appropriate exception
+        new Handler();
+      }
+    }
+    requestLocationUpdatesInternal(
+        provider,
+        new RoboLocationRequest(provider, minTime, minDistance, false),
+        new HandlerExecutor(new Handler(looper)),
+        listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void requestLocationUpdates(
+      String provider,
+      long minTime,
+      float minDistance,
+      Executor executor,
+      LocationListener listener) {
+    requestLocationUpdatesInternal(
+        provider,
+        new RoboLocationRequest(provider, minTime, minDistance, false),
+        executor,
+        listener);
+  }
+
+  @Implementation
+  protected void requestLocationUpdates(
+      long minTime,
+      float minDistance,
+      Criteria criteria,
+      LocationListener listener,
+      @Nullable Looper looper) {
+    String bestProvider = getBestProvider(criteria, true);
+    if (bestProvider == null) {
+      throw new IllegalArgumentException("no providers found for criteria");
+    }
+    if (looper == null) {
+      looper = Looper.myLooper();
+      if (looper == null) {
+        // forces appropriate exception
+        new Handler();
+      }
+    }
+    requestLocationUpdatesInternal(
+        bestProvider,
+        new RoboLocationRequest(bestProvider, minTime, minDistance, false),
+        new HandlerExecutor(new Handler(looper)),
+        listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void requestLocationUpdates(
+      long minTime,
+      float minDistance,
+      Criteria criteria,
+      Executor executor,
+      LocationListener listener) {
+    String bestProvider = getBestProvider(criteria, true);
+    if (bestProvider == null) {
+      throw new IllegalArgumentException("no providers found for criteria");
+    }
+    requestLocationUpdatesInternal(
+        bestProvider,
+        new RoboLocationRequest(bestProvider, minTime, minDistance, false),
+        executor,
+        listener);
+  }
+
+  @Implementation
+  protected void requestLocationUpdates(
+      String provider, long minTime, float minDistance, PendingIntent pendingIntent) {
+    requestLocationUpdatesInternal(
+        provider, new RoboLocationRequest(provider, minTime, minDistance, false), pendingIntent);
+  }
+
+  @Implementation
+  protected void requestLocationUpdates(
+      long minTime, float minDistance, Criteria criteria, PendingIntent pendingIntent) {
+    String bestProvider = getBestProvider(criteria, true);
+    if (bestProvider == null) {
+      throw new IllegalArgumentException("no providers found for criteria");
+    }
+    requestLocationUpdatesInternal(
+        bestProvider,
+        new RoboLocationRequest(bestProvider, minTime, minDistance, false),
+        pendingIntent);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void requestLocationUpdates(
+      @Nullable LocationRequest request, Executor executor, LocationListener listener) {
+    if (request == null) {
+      request = LocationRequest.create();
+    }
+    requestLocationUpdatesInternal(
+        request.getProvider(), new RoboLocationRequest(request), executor, listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.KITKAT)
+  protected void requestLocationUpdates(
+      @Nullable LocationRequest request, LocationListener listener, Looper looper) {
+    if (request == null) {
+      request = LocationRequest.create();
+    }
+    if (looper == null) {
+      looper = Looper.myLooper();
+      if (looper == null) {
+        // forces appropriate exception
+        new Handler();
+      }
+    }
+    requestLocationUpdatesInternal(
+        request.getProvider(),
+        new RoboLocationRequest(request),
+        new HandlerExecutor(new Handler(looper)),
+        listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.KITKAT)
+  protected void requestLocationUpdates(
+      @Nullable LocationRequest request, PendingIntent pendingIntent) {
+    if (request == null) {
+      request = LocationRequest.create();
+    }
+    requestLocationUpdatesInternal(
+        request.getProvider(), new RoboLocationRequest(request), pendingIntent);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  protected void requestLocationUpdates(
+      String provider, LocationRequest request, Executor executor, LocationListener listener) {
+    requestLocationUpdatesInternal(provider, new RoboLocationRequest(request), executor, listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  protected void requestLocationUpdates(
+      String provider, LocationRequest request, PendingIntent pendingIntent) {
+    requestLocationUpdatesInternal(provider, new RoboLocationRequest(request), pendingIntent);
+  }
+
+  private void requestLocationUpdatesInternal(
+      String provider, RoboLocationRequest request, Executor executor, LocationListener listener) {
+    if (provider == null || request == null || executor == null || listener == null) {
+      throw new IllegalArgumentException();
+    }
+    getOrCreateProviderEntry(provider).addListener(listener, request, executor);
+  }
+
+  private void requestLocationUpdatesInternal(
+      String provider, RoboLocationRequest request, PendingIntent pendingIntent) {
+    if (provider == null || request == null || pendingIntent == null) {
+      throw new IllegalArgumentException();
+    }
+    getOrCreateProviderEntry(provider).addListener(pendingIntent, request);
+  }
+
+  @Implementation
+  protected void removeUpdates(LocationListener listener) {
+    removeUpdatesInternal(listener);
+  }
+
+  @Implementation
+  protected void removeUpdates(PendingIntent pendingIntent) {
+    removeUpdatesInternal(pendingIntent);
+  }
+
+  private void removeUpdatesInternal(Object key) {
+    for (ProviderEntry providerEntry : getProviderEntries()) {
+      providerEntry.removeListener(key);
+    }
+  }
+
+  /**
+   * Returns the list of {@link LocationRequest} currently registered under the given provider.
+   * Clients compiled against the public Android SDK should only use this method on S+, clients
+   * compiled against the system Android SDK may only use this method on Kitkat+.
+   *
+   * <p>Prior to Android S {@link LocationRequest} equality is not well defined, so prefer using
+   * {@link #getLegacyLocationRequests(String)} instead if equality is required for testing.
+   */
+  @RequiresApi(VERSION_CODES.KITKAT)
+  public List<LocationRequest> getLocationRequests(String provider) {
+    ProviderEntry providerEntry = getProviderEntry(provider);
+    if (providerEntry == null) {
+      return ImmutableList.of();
+    }
+
+    return ImmutableList.copyOf(
+        Iterables.transform(
+            providerEntry.getTransports(),
+            transport -> transport.getRequest().getLocationRequest()));
+  }
+
+  /**
+   * Returns the list of {@link RoboLocationRequest} currently registered under the given provider.
+   * Since {@link LocationRequest} was not publicly visible prior to S, and did not exist prior to
+   * Kitkat, {@link RoboLocationRequest} allows querying the location requests prior to those
+   * platforms, and also implements proper equality comparisons for testing.
+   */
+  public List<RoboLocationRequest> getLegacyLocationRequests(String provider) {
+    ProviderEntry providerEntry = getProviderEntry(provider);
+    if (providerEntry == null) {
+      return ImmutableList.of();
+    }
+
+    return ImmutableList.copyOf(
+        Iterables.transform(providerEntry.getTransports(), LocationTransport::getRequest));
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected boolean injectLocation(Location location) {
+    return false;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  @Nullable
+  protected String getGnssHardwareModelName() {
+    return gnssHardwareModelName;
+  }
+
+  /**
+   * Sets the GNSS hardware model name returned by {@link
+   * LocationManager#getGnssHardwareModelName()}.
+   */
+  public void setGnssHardwareModelName(@Nullable String gnssHardwareModelName) {
+    this.gnssHardwareModelName = gnssHardwareModelName;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected int getGnssYearOfHardware() {
+    return gnssYearOfHardware;
+  }
+
+  /** Sets the GNSS year of hardware returned by {@link LocationManager#getGnssYearOfHardware()}. */
+  public void setGnssYearOfHardware(int gnssYearOfHardware) {
+    this.gnssYearOfHardware = gnssYearOfHardware;
+  }
+
+  @Implementation
+  protected boolean addGpsStatusListener(GpsStatus.Listener listener) {
+    if (RuntimeEnvironment.getApiLevel() > VERSION_CODES.R) {
+      throw new UnsupportedOperationException(
+          "GpsStatus APIs not supported, please use GnssStatus APIs instead");
+    }
+
+    synchronized (gpsStatusListeners) {
+      gpsStatusListeners.add(listener);
+    }
+
+    return true;
+  }
+
+  @Implementation
+  protected void removeGpsStatusListener(GpsStatus.Listener listener) {
+    if (RuntimeEnvironment.getApiLevel() > VERSION_CODES.R) {
+      throw new UnsupportedOperationException(
+          "GpsStatus APIs not supported, please use GnssStatus APIs instead");
+    }
+
+    synchronized (gpsStatusListeners) {
+      gpsStatusListeners.remove(listener);
+    }
+  }
+
+  /** Returns the list of currently registered {@link GpsStatus.Listener}s. */
+  public List<GpsStatus.Listener> getGpsStatusListeners() {
+    synchronized (gpsStatusListeners) {
+      return new ArrayList<>(gpsStatusListeners);
+    }
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected boolean registerGnssStatusCallback(GnssStatus.Callback callback, Handler handler) {
+    if (handler == null) {
+      handler = new Handler();
+    }
+
+    return registerGnssStatusCallback(new HandlerExecutor(handler), callback);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected boolean registerGnssStatusCallback(Executor executor, GnssStatus.Callback listener) {
+    synchronized (gnssStatusTransports) {
+      Iterables.removeIf(gnssStatusTransports, transport -> transport.getListener() == listener);
+      gnssStatusTransports.add(new GnssStatusCallbackTransport(executor, listener));
+    }
+
+    return true;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected void unregisterGnssStatusCallback(GnssStatus.Callback listener) {
+    synchronized (gnssStatusTransports) {
+      Iterables.removeIf(gnssStatusTransports, transport -> transport.getListener() == listener);
+    }
+  }
+
+  /** Simulates a GNSS status started event. */
+  @RequiresApi(VERSION_CODES.N)
+  public void simulateGnssStatusStarted() {
+    List<GnssStatusCallbackTransport> transports;
+    synchronized (gnssStatusTransports) {
+      transports = gnssStatusTransports;
+    }
+
+    for (GnssStatusCallbackTransport transport : transports) {
+      transport.onStarted();
+    }
+  }
+
+  /** Simulates a GNSS status first fix event. */
+  @RequiresApi(VERSION_CODES.N)
+  public void simulateGnssStatusFirstFix(int ttff) {
+    List<GnssStatusCallbackTransport> transports;
+    synchronized (gnssStatusTransports) {
+      transports = gnssStatusTransports;
+    }
+
+    for (GnssStatusCallbackTransport transport : transports) {
+      transport.onFirstFix(ttff);
+    }
+  }
+
+  /** Simulates a GNSS status event. */
+  @RequiresApi(VERSION_CODES.N)
+  public void simulateGnssStatus(GnssStatus status) {
+    List<GnssStatusCallbackTransport> transports;
+    synchronized (gnssStatusTransports) {
+      transports = gnssStatusTransports;
+    }
+
+    for (GnssStatusCallbackTransport transport : transports) {
+      transport.onSatelliteStatusChanged(status);
+    }
+  }
+
+  /**
+   * @deprecated Use {@link #simulateGnssStatus(GnssStatus)} instead.
+   */
+  @Deprecated
+  @RequiresApi(VERSION_CODES.N)
+  public void sendGnssStatus(GnssStatus status) {
+    simulateGnssStatus(status);
+  }
+
+  /** Simulates a GNSS status stopped event. */
+  @RequiresApi(VERSION_CODES.N)
+  public void simulateGnssStatusStopped() {
+    List<GnssStatusCallbackTransport> transports;
+    synchronized (gnssStatusTransports) {
+      transports = gnssStatusTransports;
+    }
+
+    for (GnssStatusCallbackTransport transport : transports) {
+      transport.onStopped();
+    }
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected boolean addNmeaListener(OnNmeaMessageListener listener, Handler handler) {
+    if (handler == null) {
+      handler = new Handler();
+    }
+
+    return addNmeaListener(new HandlerExecutor(handler), listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected boolean addNmeaListener(Executor executor, OnNmeaMessageListener listener) {
+    synchronized (nmeaMessageTransports) {
+      Iterables.removeIf(nmeaMessageTransports, transport -> transport.getListener() == listener);
+      nmeaMessageTransports.add(new OnNmeaMessageListenerTransport(executor, listener));
+    }
+
+    return true;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected void removeNmeaListener(OnNmeaMessageListener listener) {
+    synchronized (nmeaMessageTransports) {
+      Iterables.removeIf(nmeaMessageTransports, transport -> transport.getListener() == listener);
+    }
+  }
+
+  /** Simulates a NMEA message. */
+  @RequiresApi(api = VERSION_CODES.N)
+  public void simulateNmeaMessage(String message, long timestamp) {
+    List<OnNmeaMessageListenerTransport> transports;
+    synchronized (nmeaMessageTransports) {
+      transports = nmeaMessageTransports;
+    }
+
+    for (OnNmeaMessageListenerTransport transport : transports) {
+      transport.onNmeaMessage(message, timestamp);
+    }
+  }
+
+  /**
+   * @deprecated Use {@link #simulateNmeaMessage(String, long)} instead.
+   */
+  @Deprecated
+  @RequiresApi(api = VERSION_CODES.N)
+  public void sendNmeaMessage(String message, long timestamp) {
+    simulateNmeaMessage(message, timestamp);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected boolean registerGnssMeasurementsCallback(
+      GnssMeasurementsEvent.Callback listener, Handler handler) {
+    if (handler == null) {
+      handler = new Handler();
+    }
+
+    return registerGnssMeasurementsCallback(new HandlerExecutor(handler), listener);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected boolean registerGnssMeasurementsCallback(
+      Executor executor, GnssMeasurementsEvent.Callback listener) {
+    synchronized (gnssMeasurementTransports) {
+      Iterables.removeIf(
+          gnssMeasurementTransports, transport -> transport.getListener() == listener);
+      gnssMeasurementTransports.add(new GnssMeasurementsEventCallbackTransport(executor, listener));
+    }
+
+    return true;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected void unregisterGnssMeasurementsCallback(GnssMeasurementsEvent.Callback listener) {
+    synchronized (gnssMeasurementTransports) {
+      Iterables.removeIf(
+          gnssMeasurementTransports, transport -> transport.getListener() == listener);
+    }
+  }
+
+  /** Simulates a GNSS measurements event. */
+  @RequiresApi(api = VERSION_CODES.N)
+  public void simulateGnssMeasurementsEvent(GnssMeasurementsEvent event) {
+    List<GnssMeasurementsEventCallbackTransport> transports;
+    synchronized (gnssMeasurementTransports) {
+      transports = gnssMeasurementTransports;
+    }
+
+    for (GnssMeasurementsEventCallbackTransport transport : transports) {
+      transport.onGnssMeasurementsReceived(event);
+    }
+  }
+
+  /**
+   * @deprecated Use {@link #simulateGnssMeasurementsEvent(GnssMeasurementsEvent)} instead.
+   */
+  @Deprecated
+  @RequiresApi(api = VERSION_CODES.N)
+  public void sendGnssMeasurementsEvent(GnssMeasurementsEvent event) {
+    simulateGnssMeasurementsEvent(event);
+  }
+
+  /** Simulates a GNSS measurements status change. */
+  @RequiresApi(api = VERSION_CODES.N)
+  public void simulateGnssMeasurementsStatus(int status) {
+    List<GnssMeasurementsEventCallbackTransport> transports;
+    synchronized (gnssMeasurementTransports) {
+      transports = gnssMeasurementTransports;
+    }
+
+    for (GnssMeasurementsEventCallbackTransport transport : transports) {
+      transport.onStatusChanged(status);
+    }
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected Object registerAntennaInfoListener(Object executor, Object listener) {
+    synchronized (gnssAntennaInfoTransports) {
+      Iterables.removeIf(
+          gnssAntennaInfoTransports, transport -> transport.getListener() == listener);
+      gnssAntennaInfoTransports.add(
+          new GnssAntennaInfoListenerTransport(
+              (Executor) executor, (GnssAntennaInfo.Listener) listener));
+    }
+    return true;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void unregisterAntennaInfoListener(Object listener) {
+    synchronized (gnssAntennaInfoTransports) {
+      Iterables.removeIf(
+          gnssAntennaInfoTransports, transport -> transport.getListener() == listener);
+    }
+  }
+
+  /** Simulates a GNSS antenna info event. */
+  @RequiresApi(api = VERSION_CODES.R)
+  public void simulateGnssAntennaInfo(List<GnssAntennaInfo> antennaInfos) {
+    List<GnssAntennaInfoListenerTransport> transports;
+    synchronized (gnssAntennaInfoTransports) {
+      transports = gnssAntennaInfoTransports;
+    }
+
+    for (GnssAntennaInfoListenerTransport transport : transports) {
+      transport.onGnssAntennaInfoReceived(new ArrayList<>(antennaInfos));
+    }
+  }
+
+  /**
+   * @deprecated Use {@link #simulateGnssAntennaInfo(List)} instead.
+   */
+  @Deprecated
+  @RequiresApi(api = VERSION_CODES.R)
+  public void sendGnssAntennaInfo(List<GnssAntennaInfo> antennaInfos) {
+    simulateGnssAntennaInfo(antennaInfos);
+  }
+
+  /**
+   * A convenience function equivalent to invoking {@link #simulateLocation(String, Location)} with
+   * the provider of the given location.
+   */
+  public void simulateLocation(Location location) {
+    simulateLocation(location.getProvider(), location);
+  }
+
+  /**
+   * Delivers to the given provider (which will be created if necessary) a new location which will
+   * be delivered to appropriate listeners and updates state accordingly. Delivery will ignore the
+   * enabled/disabled state of providers, unlike location on a real device.
+   *
+   * <p>The location will also be delivered to the passive provider.
+   */
+  public void simulateLocation(String provider, Location location) {
+    ProviderEntry providerEntry = getOrCreateProviderEntry(provider);
+    if (!PASSIVE_PROVIDER.equals(providerEntry.getName())) {
+      providerEntry.simulateLocation(location);
+    }
+
+    ProviderEntry passiveProviderEntry = getProviderEntry(PASSIVE_PROVIDER);
+    if (passiveProviderEntry != null) {
+      passiveProviderEntry.simulateLocation(location);
+    }
+  }
+
+  /**
+   * @deprecated Do not test listeners, instead use {@link #simulateLocation(Location)} and test the
+   *     results of those listeners being invoked.
+   */
+  @Deprecated
+  public List<LocationListener> getRequestLocationUpdateListeners() {
+    return getLocationUpdateListeners();
+  }
+
+  /**
+   * @deprecated Do not test listeners, instead use {@link #simulateLocation(Location)} and test the
+   *     results of those listeners being invoked.
+   */
+  @Deprecated
+  public List<LocationListener> getLocationUpdateListeners() {
+    HashSet<LocationListener> listeners = new HashSet<>();
+    for (ProviderEntry providerEntry : getProviderEntries()) {
+      Iterables.addAll(
+          listeners,
+          Iterables.transform(
+              Iterables.filter(providerEntry.getTransports(), LocationListenerTransport.class),
+              LocationTransport::getKey));
+    }
+    return new ArrayList<>(listeners);
+  }
+
+  /**
+   * @deprecated Do not test listeners, instead use {@link #simulateLocation(Location)} and test the
+   *     results of those listeners being invoked.
+   */
+  @Deprecated
+  public List<LocationListener> getLocationUpdateListeners(String provider) {
+    ProviderEntry providerEntry = getProviderEntry(provider);
+    if (providerEntry == null) {
+      return Collections.emptyList();
+    }
+
+    HashSet<LocationListener> listeners = new HashSet<>();
+    Iterables.addAll(
+        listeners,
+        Iterables.transform(
+            Iterables.filter(providerEntry.getTransports(), LocationListenerTransport.class),
+            LocationTransport::getKey));
+    return new ArrayList<>(listeners);
+  }
+
+  /**
+   * @deprecated Do not test pending intents, instead use {@link #simulateLocation(Location)} and
+   *     test the results of those pending intent being invoked.
+   */
+  @Deprecated
+  public List<PendingIntent> getLocationUpdatePendingIntents() {
+    HashSet<PendingIntent> listeners = new HashSet<>();
+    for (ProviderEntry providerEntry : getProviderEntries()) {
+      Iterables.addAll(
+          listeners,
+          Iterables.transform(
+              Iterables.filter(providerEntry.getTransports(), LocationPendingIntentTransport.class),
+              LocationTransport::getKey));
+    }
+    return new ArrayList<>(listeners);
+  }
+
+  /**
+   * Retrieves a list of all currently registered pending intents for the given provider.
+   *
+   * @deprecated Do not test pending intents, instead use {@link #simulateLocation(Location)} and
+   *     test the results of those pending intent being invoked.
+   */
+  @Deprecated
+  public List<PendingIntent> getLocationUpdatePendingIntents(String provider) {
+    ProviderEntry providerEntry = getProviderEntry(provider);
+    if (providerEntry == null) {
+      return Collections.emptyList();
+    }
+
+    HashSet<PendingIntent> listeners = new HashSet<>();
+    Iterables.addAll(
+        listeners,
+        Iterables.transform(
+            Iterables.filter(providerEntry.getTransports(), LocationPendingIntentTransport.class),
+            LocationTransport::getKey));
+    return new ArrayList<>(listeners);
+  }
+
+  private Context getContext() {
+    return ReflectionHelpers.getField(realLocationManager, "mContext");
+  }
+
+  private ProviderEntry getOrCreateProviderEntry(String name) {
+    if (name == null) {
+      throw new IllegalArgumentException("cannot use a null provider");
+    }
+
+    synchronized (providers) {
+      ProviderEntry providerEntry = getProviderEntry(name);
+      if (providerEntry == null) {
+        providerEntry = new ProviderEntry(name, null);
+        providers.add(providerEntry);
+      }
+      return providerEntry;
+    }
+  }
+
+  @Nullable
+  private ProviderEntry getProviderEntry(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    synchronized (providers) {
+      for (ProviderEntry providerEntry : providers) {
+        if (name.equals(providerEntry.getName())) {
+          return providerEntry;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  private Set<ProviderEntry> getProviderEntries() {
+    synchronized (providers) {
+      return providers;
+    }
+  }
+
+  private void removeProviderEntry(String name) {
+    synchronized (providers) {
+      providers.remove(getProviderEntry(name));
+    }
+  }
+
+  // provider enabled logic is complicated due to many changes over different versions of android. a
+  // brief explanation of how the logic works in this shadow (which is subtly different and more
+  // complicated from how the logic works in real android):
+  //
+  // 1) prior to P, the source of truth for whether a provider is enabled must be the
+  //    LOCATION_PROVIDERS_ALLOWED setting, so that direct writes into that setting are respected.
+  //    changes to the network and gps providers must change LOCATION_MODE appropriately as well.
+  // 2) for P, providers are considered enabled if the LOCATION_MODE setting is not off AND they are
+  //    enabled via LOCATION_PROVIDERS_ALLOWED. direct writes into LOCATION_PROVIDERS_ALLOWED should
+  //    be respected (if the LOCATION_MODE is not off). changes to LOCATION_MODE will change the
+  //    state of the network and gps providers.
+  // 3) for Q/R, providers are considered enabled if the LOCATION_MODE settings is not off AND they
+  //    are enabled, but the store for the enabled state may not be LOCATION_PROVIDERS_ALLOWED, as
+  //    writes into LOCATION_PROVIDERS_ALLOWED should not be respected. LOCATION_PROVIDERS_ALLOWED
+  //    should still be updated so that provider state changes can be listened to via that setting.
+  //    changes to LOCATION_MODE should not change the state of the network and gps provider.
+  // 5) the passive provider is always special-cased at all API levels - it's state is controlled
+  //    programmatically, and should never be determined by LOCATION_PROVIDERS_ALLOWED.
+  private final class ProviderEntry {
+
+    private final String name;
+
+    @GuardedBy("this")
+    private final CopyOnWriteArrayList<LocationTransport<?>> locationTransports =
+        new CopyOnWriteArrayList<>();
+
+    @GuardedBy("this")
+    @Nullable
+    private ProviderProperties properties;
+
+    @GuardedBy("this")
+    private boolean enabled;
+
+    @GuardedBy("this")
+    @Nullable
+    private Location lastLocation;
+
+    ProviderEntry(String name, @Nullable ProviderProperties properties) {
+      this.name = name;
+
+      this.properties = properties;
+
+      switch (name) {
+        case PASSIVE_PROVIDER:
+          // passive provider always starts enabled
+          enabled = true;
+          break;
+        case GPS_PROVIDER:
+          enabled = ShadowSecure.INITIAL_GPS_PROVIDER_STATE;
+          break;
+        case NETWORK_PROVIDER:
+          enabled = ShadowSecure.INITIAL_NETWORK_PROVIDER_STATE;
+          break;
+        default:
+          enabled = false;
+          break;
+      }
+    }
+
+    public String getName() {
+      return name;
+    }
+
+    public synchronized List<LocationTransport<?>> getTransports() {
+      return locationTransports;
+    }
+
+    @Nullable
+    public synchronized ProviderProperties getProperties() {
+      return properties;
+    }
+
+    public synchronized void setProperties(@Nullable ProviderProperties properties) {
+      this.properties = properties;
+    }
+
+    public boolean isEnabled() {
+      if (PASSIVE_PROVIDER.equals(name) || RuntimeEnvironment.getApiLevel() >= VERSION_CODES.Q) {
+        synchronized (this) {
+          return enabled;
+        }
+      } else {
+        String allowedProviders =
+            Secure.getString(getContext().getContentResolver(), LOCATION_PROVIDERS_ALLOWED);
+        if (TextUtils.isEmpty(allowedProviders)) {
+          return false;
+        } else {
+          return Arrays.asList(allowedProviders.split(",")).contains(name);
+        }
+      }
+    }
+
+    public void setEnabled(boolean enabled) {
+      List<LocationTransport<?>> transports;
+      synchronized (this) {
+        if (PASSIVE_PROVIDER.equals(name)) {
+          // the passive provider cannot be disabled, but the passive provider didn't exist in
+          // previous versions of this shadow. for backwards compatibility, we let the passive
+          // provider be disabled. this also help emulate the situation where an app only has COARSE
+          // permissions, which this shadow normally can't emulate.
+          this.enabled = enabled;
+          return;
+        }
+
+        int oldLocationMode = getLocationMode();
+        int newLocationMode = oldLocationMode;
+        if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.P) {
+          if (GPS_PROVIDER.equals(name)) {
+            if (enabled) {
+              switch (oldLocationMode) {
+                case LOCATION_MODE_OFF:
+                  newLocationMode = LOCATION_MODE_SENSORS_ONLY;
+                  break;
+                case LOCATION_MODE_BATTERY_SAVING:
+                  newLocationMode = LOCATION_MODE_HIGH_ACCURACY;
+                  break;
+                default:
+                  break;
+              }
+            } else {
+              switch (oldLocationMode) {
+                case LOCATION_MODE_SENSORS_ONLY:
+                  newLocationMode = LOCATION_MODE_OFF;
+                  break;
+                case LOCATION_MODE_HIGH_ACCURACY:
+                  newLocationMode = LOCATION_MODE_BATTERY_SAVING;
+                  break;
+                default:
+                  break;
+              }
+            }
+          } else if (NETWORK_PROVIDER.equals(name)) {
+            if (enabled) {
+              switch (oldLocationMode) {
+                case LOCATION_MODE_OFF:
+                  newLocationMode = LOCATION_MODE_BATTERY_SAVING;
+                  break;
+                case LOCATION_MODE_SENSORS_ONLY:
+                  newLocationMode = LOCATION_MODE_HIGH_ACCURACY;
+                  break;
+                default:
+                  break;
+              }
+            } else {
+              switch (oldLocationMode) {
+                case LOCATION_MODE_BATTERY_SAVING:
+                  newLocationMode = LOCATION_MODE_OFF;
+                  break;
+                case LOCATION_MODE_HIGH_ACCURACY:
+                  newLocationMode = LOCATION_MODE_SENSORS_ONLY;
+                  break;
+                default:
+                  break;
+              }
+            }
+          }
+        }
+
+        if (newLocationMode != oldLocationMode) {
+          // this sets LOCATION_MODE and LOCATION_PROVIDERS_ALLOWED
+          setLocationModeInternal(newLocationMode);
+        } else if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.Q) {
+          if (enabled == this.enabled) {
+            return;
+          }
+
+          this.enabled = enabled;
+          // set LOCATION_PROVIDERS_ALLOWED directly, without setting LOCATION_MODE. do this even
+          // though LOCATION_PROVIDERS_ALLOWED is not the source of truth - we keep it up to date,
+          // but ignore any direct writes to it
+          ShadowSettings.ShadowSecure.updateEnabledProviders(
+              getContext().getContentResolver(), name, enabled);
+        } else {
+          if (enabled == this.enabled) {
+            return;
+          }
+
+          this.enabled = enabled;
+          // set LOCATION_PROVIDERS_ALLOWED directly, without setting LOCATION_MODE
+          ShadowSettings.ShadowSecure.updateEnabledProviders(
+              getContext().getContentResolver(), name, enabled);
+        }
+
+        transports = locationTransports;
+      }
+
+      for (LocationTransport<?> transport : transports) {
+        if (!transport.invokeOnProviderEnabled(name, enabled)) {
+          synchronized (this) {
+            Iterables.removeIf(locationTransports, current -> current == transport);
+          }
+        }
+      }
+    }
+
+    @Nullable
+    public synchronized Location getLastLocation() {
+      return lastLocation;
+    }
+
+    public synchronized void setLastLocation(@Nullable Location location) {
+      lastLocation = location;
+    }
+
+    public void simulateLocation(Location location) {
+      List<LocationTransport<?>> transports;
+      synchronized (this) {
+        lastLocation = new Location(location);
+        transports = locationTransports;
+      }
+
+      for (LocationTransport<?> transport : transports) {
+        if (!transport.invokeOnLocation(location)) {
+          synchronized (this) {
+            Iterables.removeIf(locationTransports, current -> current == transport);
+          }
+        }
+      }
+    }
+
+    public synchronized boolean meetsCriteria(Criteria criteria) {
+      if (PASSIVE_PROVIDER.equals(name)) {
+        return false;
+      }
+
+      if (properties == null) {
+        return false;
+      }
+      return properties.meetsCriteria(criteria);
+    }
+
+    public void addListener(
+        LocationListener listener, RoboLocationRequest request, Executor executor) {
+      addListenerInternal(new LocationListenerTransport(listener, request, executor));
+    }
+
+    public void addListener(PendingIntent pendingIntent, RoboLocationRequest request) {
+      addListenerInternal(new LocationPendingIntentTransport(getContext(), pendingIntent, request));
+    }
+
+    private void addListenerInternal(LocationTransport<?> transport) {
+      boolean invokeOnProviderEnabled;
+      synchronized (this) {
+        Iterables.removeIf(locationTransports, current -> current.getKey() == transport.getKey());
+        locationTransports.add(transport);
+        invokeOnProviderEnabled = !enabled;
+      }
+
+      if (invokeOnProviderEnabled) {
+        if (!transport.invokeOnProviderEnabled(name, false)) {
+          synchronized (this) {
+            Iterables.removeIf(locationTransports, current -> current == transport);
+          }
+        }
+      }
+    }
+
+    public synchronized void removeListener(Object key) {
+      Iterables.removeIf(locationTransports, transport -> transport.getKey() == key);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ProviderEntry) {
+        ProviderEntry that = (ProviderEntry) o;
+        return Objects.equals(name, that.name);
+      }
+
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(name);
+    }
+  }
+
+  /**
+   * LocationRequest doesn't exist prior to Kitkat, and is not public prior to S, so a new class is
+   * required to represent it prior to those platforms.
+   */
+  public static final class RoboLocationRequest {
+    @Nullable private final Object locationRequest;
+
+    // all these parameters are meaningless if locationRequest is set
+    private final long intervalMillis;
+    private final float minUpdateDistanceMeters;
+    private final boolean singleShot;
+
+    @RequiresApi(VERSION_CODES.KITKAT)
+    public RoboLocationRequest(LocationRequest locationRequest) {
+      this.locationRequest = Objects.requireNonNull(locationRequest);
+      intervalMillis = 0;
+      minUpdateDistanceMeters = 0;
+      singleShot = false;
+    }
+
+    public RoboLocationRequest(
+        String provider, long intervalMillis, float minUpdateDistanceMeters, boolean singleShot) {
+      if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.KITKAT) {
+        locationRequest =
+            LocationRequest.createFromDeprecatedProvider(
+                provider, intervalMillis, minUpdateDistanceMeters, singleShot);
+      } else {
+        locationRequest = null;
+      }
+
+      this.intervalMillis = intervalMillis;
+      this.minUpdateDistanceMeters = minUpdateDistanceMeters;
+      this.singleShot = singleShot;
+    }
+
+    @RequiresApi(VERSION_CODES.KITKAT)
+    public LocationRequest getLocationRequest() {
+      return (LocationRequest) Objects.requireNonNull(locationRequest);
+    }
+
+    public long getIntervalMillis() {
+      if (locationRequest != null) {
+        return ((LocationRequest) locationRequest).getInterval();
+      } else {
+        return intervalMillis;
+      }
+    }
+
+    public float getMinUpdateDistanceMeters() {
+      if (locationRequest != null) {
+        return ((LocationRequest) locationRequest).getSmallestDisplacement();
+      } else {
+        return minUpdateDistanceMeters;
+      }
+    }
+
+    public boolean isSingleShot() {
+      if (locationRequest != null) {
+        return ((LocationRequest) locationRequest).getNumUpdates() == 1;
+      } else {
+        return singleShot;
+      }
+    }
+
+    long getMinUpdateIntervalMillis() {
+      if (locationRequest != null) {
+        return ((LocationRequest) locationRequest).getFastestInterval();
+      } else {
+        return intervalMillis;
+      }
+    }
+
+    int getMaxUpdates() {
+      if (locationRequest != null) {
+        return ((LocationRequest) locationRequest).getNumUpdates();
+      } else {
+        return singleShot ? 1 : Integer.MAX_VALUE;
+      }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof RoboLocationRequest) {
+        RoboLocationRequest that = (RoboLocationRequest) o;
+
+        // location request equality is not well-defined prior to S
+        if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.S) {
+          return Objects.equals(locationRequest, that.locationRequest);
+        } else {
+          if (intervalMillis != that.intervalMillis
+              || singleShot != that.singleShot
+              || Float.compare(that.minUpdateDistanceMeters, minUpdateDistanceMeters) != 0
+              || (locationRequest == null) != (that.locationRequest == null)) {
+            return false;
+          }
+
+          if (locationRequest != null) {
+            LocationRequest lr = (LocationRequest) locationRequest;
+            LocationRequest thatLr = (LocationRequest) that.locationRequest;
+
+            if (lr.getQuality() != thatLr.getQuality()
+                || lr.getInterval() != thatLr.getInterval()
+                || lr.getFastestInterval() != thatLr.getFastestInterval()
+                || lr.getExpireAt() != thatLr.getExpireAt()
+                || lr.getNumUpdates() != thatLr.getNumUpdates()
+                || lr.getSmallestDisplacement() != thatLr.getSmallestDisplacement()
+                || lr.getHideFromAppOps() != thatLr.getHideFromAppOps()
+                || !Objects.equals(lr.getProvider(), thatLr.getProvider())) {
+              return false;
+            }
+
+            // allow null worksource to match empty worksource
+            WorkSource workSource =
+                lr.getWorkSource() == null ? new WorkSource() : lr.getWorkSource();
+            WorkSource thatWorkSource =
+                thatLr.getWorkSource() == null ? new WorkSource() : thatLr.getWorkSource();
+            if (!workSource.equals(thatWorkSource)) {
+              return false;
+            }
+
+            if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.Q) {
+              if (lr.isLowPowerMode() != thatLr.isLowPowerMode()
+                  || lr.isLocationSettingsIgnored() != thatLr.isLocationSettingsIgnored()) {
+                return false;
+              }
+            }
+          }
+
+          return true;
+        }
+      }
+
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      if (locationRequest != null) {
+        return locationRequest.hashCode();
+      } else {
+        return Objects.hash(intervalMillis, singleShot, minUpdateDistanceMeters);
+      }
+    }
+
+    @Override
+    public String toString() {
+      if (locationRequest != null) {
+        return locationRequest.toString();
+      } else {
+        return "Request[interval="
+            + intervalMillis
+            + ", minUpdateDistance="
+            + minUpdateDistanceMeters
+            + ", singleShot="
+            + singleShot
+            + "]";
+      }
+    }
+  }
+
+  private abstract static class LocationTransport<KeyT> {
+
+    private final KeyT key;
+    private final RoboLocationRequest request;
+
+    private Location lastDeliveredLocation;
+    private int numDeliveries;
+
+    LocationTransport(KeyT key, RoboLocationRequest request) {
+      if (key == null) {
+        throw new IllegalArgumentException();
+      }
+
+      this.key = key;
+      this.request = request;
+    }
+
+    public KeyT getKey() {
+      return key;
+    }
+
+    public RoboLocationRequest getRequest() {
+      return request;
+    }
+
+    // return false if this listener should be removed by this invocation
+    public boolean invokeOnLocation(Location location) {
+      if (lastDeliveredLocation != null) {
+        if (location.getTime() - lastDeliveredLocation.getTime()
+            < request.getMinUpdateIntervalMillis()) {
+          return true;
+        }
+        if (distanceBetween(location, lastDeliveredLocation)
+            < request.getMinUpdateDistanceMeters()) {
+          return true;
+        }
+      }
+
+      lastDeliveredLocation = new Location(location);
+
+      boolean needsRemoval = false;
+
+      if (++numDeliveries >= request.getMaxUpdates()) {
+        needsRemoval = true;
+      }
+
+      try {
+        onLocation(location);
+      } catch (CanceledException e) {
+        needsRemoval = true;
+      }
+
+      return !needsRemoval;
+    }
+
+    // return false if this listener should be removed by this invocation
+    public boolean invokeOnProviderEnabled(String provider, boolean enabled) {
+      try {
+        onProviderEnabled(provider, enabled);
+        return true;
+      } catch (CanceledException e) {
+        return false;
+      }
+    }
+
+    abstract void onLocation(Location location) throws CanceledException;
+
+    abstract void onProviderEnabled(String provider, boolean enabled) throws CanceledException;
+  }
+
+  private static final class LocationListenerTransport extends LocationTransport<LocationListener> {
+
+    private final Executor executor;
+
+    LocationListenerTransport(
+        LocationListener key, RoboLocationRequest request, Executor executor) {
+      super(key, request);
+      this.executor = executor;
+    }
+
+    @Override
+    public void onLocation(Location location) {
+      executor.execute(() -> getKey().onLocationChanged(new Location(location)));
+    }
+
+    @Override
+    public void onProviderEnabled(String provider, boolean enabled) {
+      executor.execute(
+          () -> {
+            if (enabled) {
+              getKey().onProviderEnabled(provider);
+            } else {
+              getKey().onProviderDisabled(provider);
+            }
+          });
+    }
+  }
+
+  private static final class LocationPendingIntentTransport
+      extends LocationTransport<PendingIntent> {
+
+    private final Context context;
+
+    LocationPendingIntentTransport(
+        Context context, PendingIntent key, RoboLocationRequest request) {
+      super(key, request);
+      this.context = context;
+    }
+
+    @Override
+    public void onLocation(Location location) throws CanceledException {
+      Intent intent = new Intent();
+      intent.putExtra(LocationManager.KEY_LOCATION_CHANGED, new Location(location));
+      getKey().send(context, 0, intent);
+    }
+
+    @Override
+    public void onProviderEnabled(String provider, boolean enabled) throws CanceledException {
+      Intent intent = new Intent();
+      intent.putExtra(LocationManager.KEY_PROVIDER_ENABLED, enabled);
+      getKey().send(context, 0, intent);
+    }
+  }
+
+  /**
+   * Returns the distance between the two locations in meters. Adapted from:
+   * http://stackoverflow.com/questions/837872/calculate-distance-in-meters-when-you-know-longitude-and-latitude-in-java
+   */
+  static float distanceBetween(Location location1, Location location2) {
+    double earthRadius = 3958.75;
+    double latDifference = Math.toRadians(location2.getLatitude() - location1.getLatitude());
+    double lonDifference = Math.toRadians(location2.getLongitude() - location1.getLongitude());
+    double a =
+        Math.sin(latDifference / 2) * Math.sin(latDifference / 2)
+            + Math.cos(Math.toRadians(location1.getLatitude()))
+                * Math.cos(Math.toRadians(location2.getLatitude()))
+                * Math.sin(lonDifference / 2)
+                * Math.sin(lonDifference / 2);
+    double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+    double dist = Math.abs(earthRadius * c);
+
+    int meterConversion = 1609;
+
+    return (float) (dist * meterConversion);
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    locationProviderConstructor = null;
+  }
+
+  @RequiresApi(api = VERSION_CODES.N)
+  private final class CurrentLocationTransport implements LocationListener {
+
+    private final Executor executor;
+    private final Consumer<Location> consumer;
+    private final Handler timeoutHandler;
+
+    @GuardedBy("this")
+    private boolean triggered;
+
+    @Nullable Runnable timeoutRunnable;
+
+    CurrentLocationTransport(Executor executor, Consumer<Location> consumer) {
+      this.executor = executor;
+      this.consumer = consumer;
+      timeoutHandler = new Handler(Looper.getMainLooper());
+    }
+
+    public void cancel() {
+      synchronized (this) {
+        if (triggered) {
+          return;
+        }
+        triggered = true;
+      }
+
+      cleanup();
+    }
+
+    public void startTimeout(long timeoutMs) {
+      synchronized (this) {
+        if (triggered) {
+          return;
+        }
+
+        timeoutRunnable =
+            () -> {
+              timeoutRunnable = null;
+              onLocationChanged((Location) null);
+            };
+        timeoutHandler.postDelayed(timeoutRunnable, timeoutMs);
+      }
+    }
+
+    @Override
+    public void onStatusChanged(String provider, int status, Bundle extras) {}
+
+    @Override
+    public void onProviderEnabled(String provider) {}
+
+    @Override
+    public void onProviderDisabled(String provider) {
+      onLocationChanged((Location) null);
+    }
+
+    @Override
+    public void onLocationChanged(@Nullable Location location) {
+      synchronized (this) {
+        if (triggered) {
+          return;
+        }
+        triggered = true;
+      }
+
+      executor.execute(() -> consumer.accept(location));
+
+      cleanup();
+    }
+
+    private void cleanup() {
+      removeUpdates(this);
+      if (timeoutRunnable != null) {
+        timeoutHandler.removeCallbacks(timeoutRunnable);
+        timeoutRunnable = null;
+      }
+    }
+  }
+
+  private static final class GnssStatusCallbackTransport {
+
+    private final Executor executor;
+    private final GnssStatus.Callback listener;
+
+    GnssStatusCallbackTransport(Executor executor, GnssStatus.Callback listener) {
+      this.executor = Objects.requireNonNull(executor);
+      this.listener = Objects.requireNonNull(listener);
+    }
+
+    GnssStatus.Callback getListener() {
+      return listener;
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onStarted() {
+      executor.execute(listener::onStarted);
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onFirstFix(int ttff) {
+      executor.execute(() -> listener.onFirstFix(ttff));
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onSatelliteStatusChanged(GnssStatus status) {
+      executor.execute(() -> listener.onSatelliteStatusChanged(status));
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onStopped() {
+      executor.execute(listener::onStopped);
+    }
+  }
+
+  private static final class OnNmeaMessageListenerTransport {
+
+    private final Executor executor;
+    private final OnNmeaMessageListener listener;
+
+    OnNmeaMessageListenerTransport(Executor executor, OnNmeaMessageListener listener) {
+      this.executor = Objects.requireNonNull(executor);
+      this.listener = Objects.requireNonNull(listener);
+    }
+
+    OnNmeaMessageListener getListener() {
+      return listener;
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onNmeaMessage(String message, long timestamp) {
+      executor.execute(() -> listener.onNmeaMessage(message, timestamp));
+    }
+  }
+
+  private static final class GnssMeasurementsEventCallbackTransport {
+
+    private final Executor executor;
+    private final GnssMeasurementsEvent.Callback listener;
+
+    GnssMeasurementsEventCallbackTransport(
+        Executor executor, GnssMeasurementsEvent.Callback listener) {
+      this.executor = Objects.requireNonNull(executor);
+      this.listener = Objects.requireNonNull(listener);
+    }
+
+    GnssMeasurementsEvent.Callback getListener() {
+      return listener;
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onStatusChanged(int status) {
+      executor.execute(() -> listener.onStatusChanged(status));
+    }
+
+    @RequiresApi(api = VERSION_CODES.N)
+    public void onGnssMeasurementsReceived(GnssMeasurementsEvent event) {
+      executor.execute(() -> listener.onGnssMeasurementsReceived(event));
+    }
+  }
+
+  private static final class GnssAntennaInfoListenerTransport {
+
+    private final Executor executor;
+    private final GnssAntennaInfo.Listener listener;
+
+    GnssAntennaInfoListenerTransport(Executor executor, GnssAntennaInfo.Listener listener) {
+      this.executor = Objects.requireNonNull(executor);
+      this.listener = Objects.requireNonNull(listener);
+    }
+
+    GnssAntennaInfo.Listener getListener() {
+      return listener;
+    }
+
+    @RequiresApi(api = VERSION_CODES.R)
+    public void onGnssAntennaInfoReceived(List<GnssAntennaInfo> antennaInfos) {
+      executor.execute(() -> listener.onGnssAntennaInfoReceived(antennaInfos));
+    }
+  }
+
+  private static final class HandlerExecutor implements Executor {
+    private final Handler handler;
+
+    HandlerExecutor(Handler handler) {
+      this.handler = Objects.requireNonNull(handler);
+    }
+
+    @Override
+    public void execute(Runnable command) {
+      if (!handler.post(command)) {
+        throw new RejectedExecutionException(handler + " is shutting down");
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLog.java
new file mode 100644
index 0000000..72c79ff
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLog.java
@@ -0,0 +1,351 @@
+package org.robolectric.shadows;
+
+import android.util.Log;
+import com.google.common.base.Ascii;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.function.Supplier;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Controls the behavior of {@link android.util.Log} and provides access to log messages. */
+@Implements(Log.class)
+public class ShadowLog {
+  public static PrintStream stream;
+
+  private static final int EXTRA_LOG_LENGTH = "l/: \n".length();
+
+  private static final Map<String, Queue<LogItem>> logsByTag = Collections.synchronizedMap(new
+      HashMap<String, Queue<LogItem>>());
+  private static final Queue<LogItem> logs = new ConcurrentLinkedQueue<>();
+  private static final Map<String, Integer> tagToLevel = Collections.synchronizedMap(new
+      HashMap<String, Integer>());
+
+  /**
+   * Whether calling {@link Log#wtf} will throw {@link TerribleFailure}. This is analogous to
+   * Android's {@link android.provider.Settings.Global#WTF_IS_FATAL}. The default value is false to
+   * preserve existing behavior.
+   */
+  private static boolean wtfIsFatal = false;
+
+  /** Provides string that will be used as time in logs. */
+  private static Supplier<String> timeSupplier;
+
+  @Implementation
+  protected static int e(String tag, String msg) {
+    return e(tag, msg, null);
+  }
+
+  @Implementation
+  protected static int e(String tag, String msg, Throwable throwable) {
+    return addLog(Log.ERROR, tag, msg, throwable);
+  }
+
+  @Implementation
+  protected static int d(String tag, String msg) {
+    return d(tag, msg, null);
+  }
+
+  @Implementation
+  protected static int d(String tag, String msg, Throwable throwable) {
+    return addLog(Log.DEBUG, tag, msg, throwable);
+  }
+
+  @Implementation
+  protected static int i(String tag, String msg) {
+    return i(tag, msg, null);
+  }
+
+  @Implementation
+  protected static int i(String tag, String msg, Throwable throwable) {
+    return addLog(Log.INFO, tag, msg, throwable);
+  }
+
+  @Implementation
+  protected static int v(String tag, String msg) {
+    return v(tag, msg, null);
+  }
+
+  @Implementation
+  protected static int v(String tag, String msg, Throwable throwable) {
+    return addLog(Log.VERBOSE, tag, msg, throwable);
+  }
+
+  @Implementation
+  protected static int w(String tag, String msg) {
+    return w(tag, msg, null);
+  }
+
+  @Implementation
+  protected static int w(String tag, Throwable throwable) {
+    return w(tag, null, throwable);
+  }
+
+  @Implementation
+  protected static int w(String tag, String msg, Throwable throwable) {
+    return addLog(Log.WARN, tag, msg, throwable);
+  }
+
+  @Implementation
+  protected static int wtf(String tag, String msg) {
+    return wtf(tag, msg, null);
+  }
+
+  @Implementation
+  protected static int wtf(String tag, String msg, Throwable throwable) {
+    addLog(Log.ASSERT, tag, msg, throwable);
+    if (wtfIsFatal) {
+      throw new TerribleFailure(msg, throwable);
+    }
+    return 0;
+  }
+
+  /** Sets whether calling {@link Log#wtf} will throw {@link TerribleFailure}. */
+  public static void setWtfIsFatal(boolean fatal) {
+    wtfIsFatal = fatal;
+  }
+
+  /** Sets supplier that can be used to get time to add to logs. */
+  public static void setTimeSupplier(Supplier<String> supplier) {
+    timeSupplier = supplier;
+  }
+
+  @Implementation
+  protected static boolean isLoggable(String tag, int level) {
+    synchronized (tagToLevel) {
+      if (tagToLevel.containsKey(tag)) {
+        return level >= tagToLevel.get(tag);
+      }
+    }
+    return level >= Log.INFO;
+  }
+
+  @Implementation
+  protected static int println_native(int bufID, int priority, String tag, String msg) {
+    addLog(priority, tag, msg, null);
+    int tagLength = tag == null ? 0 : tag.length();
+    int msgLength = msg == null ? 0 : msg.length();
+    return EXTRA_LOG_LENGTH + tagLength + msgLength;
+  }
+
+  /**
+   * Sets the log level of a given tag, that {@link #isLoggable} will follow.
+   * @param tag A log tag
+   * @param level A log level, from {@link android.util.Log}
+   */
+  public static void setLoggable(String tag, int level) {
+    tagToLevel.put(tag, level);
+  }
+
+  private static int addLog(int level, String tag, String msg, Throwable throwable) {
+    String timeString = null;
+    if (timeSupplier != null) {
+      timeString = timeSupplier.get();
+    }
+
+    if (stream != null) {
+      logToStream(stream, timeString, level, tag, msg, throwable);
+    }
+
+    LogItem item = new LogItem(timeString, level, tag, msg, throwable);
+    Queue<LogItem> itemList;
+
+    synchronized (logsByTag) {
+      if (!logsByTag.containsKey(tag)) {
+        itemList = new ConcurrentLinkedQueue<>();
+        logsByTag.put(tag, itemList);
+      } else {
+        itemList = logsByTag.get(tag);
+      }
+    }
+
+    itemList.add(item);
+    logs.add(item);
+
+    return 0;
+  }
+
+  protected static char levelToChar(int level) {
+    final char c;
+    switch (level) {
+      case Log.ASSERT: c = 'A'; break;
+      case Log.DEBUG:  c = 'D'; break;
+      case Log.ERROR:  c = 'E'; break;
+      case Log.WARN:   c = 'W'; break;
+      case Log.INFO:   c = 'I'; break;
+      case Log.VERBOSE:c = 'V'; break;
+      default:         c = '?';
+    }
+    return c;
+  }
+
+  private static void logToStream(
+      PrintStream ps, String timeString, int level, String tag, String msg, Throwable throwable) {
+
+    String outputString;
+    if (timeString != null && timeString.length() > 0) {
+      outputString = timeString + " " + levelToChar(level) + "/" + tag + ": " + msg;
+    } else {
+      outputString = levelToChar(level) + "/" + tag + ": " + msg;
+    }
+
+    ps.println(outputString);
+    if (throwable != null) {
+      throwable.printStackTrace(ps);
+    }
+  }
+
+  /**
+   * Returns ordered list of all log entries.
+   *
+   * @return List of log items
+   */
+  public static ImmutableList<LogItem> getLogs() {
+    return ImmutableList.copyOf(logs);
+  }
+
+  /**
+   * Returns ordered list of all log items for a specific tag.
+   *
+   * @param tag The tag to get logs for
+   * @return The list of log items for the tag or an empty list if no logs for that tag exist.
+   */
+  public static ImmutableList<LogItem> getLogsForTag(String tag) {
+    Queue<LogItem> logs = logsByTag.get(tag);
+    return logs == null ? ImmutableList.of() : ImmutableList.copyOf(logs);
+  }
+
+  /** Clear all accumulated logs. */
+  public static void clear() {
+    reset();
+  }
+
+  @Resetter
+  public static void reset() {
+    logs.clear();
+    logsByTag.clear();
+    tagToLevel.clear();
+    wtfIsFatal = false;
+  }
+
+  @SuppressWarnings("CatchAndPrintStackTrace")
+  public static void setupLogging() {
+    String logging = System.getProperty("robolectric.logging");
+    if (logging != null && stream == null) {
+      PrintStream stream = null;
+      if (Ascii.equalsIgnoreCase("stdout", logging)) {
+        stream = System.out;
+      } else if (Ascii.equalsIgnoreCase("stderr", logging)) {
+        stream = System.err;
+      } else {
+        try {
+          final PrintStream file = new PrintStream(new FileOutputStream(logging), true);
+          stream = file;
+          Runtime.getRuntime()
+              .addShutdownHook(
+                  new Thread() {
+                    @Override
+                    public void run() {
+                      file.close();
+                    }
+                  });
+        } catch (IOException e) {
+          e.printStackTrace();
+        }
+      }
+      ShadowLog.stream = stream;
+    }
+  }
+
+  /** A single log item. */
+  public static final class LogItem {
+    public final String timeString;
+    public final int type;
+    public final String tag;
+    public final String msg;
+    public final Throwable throwable;
+
+    public LogItem(int type, String tag, String msg, Throwable throwable) {
+      this.timeString = null;
+      this.type = type;
+      this.tag = tag;
+      this.msg = msg;
+      this.throwable = throwable;
+    }
+
+    public LogItem(String timeString, int type, String tag, String msg, Throwable throwable) {
+      this.timeString = timeString;
+      this.type = type;
+      this.tag = tag;
+      this.msg = msg;
+      this.throwable = throwable;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof LogItem)) {
+        return false;
+      }
+
+      LogItem log = (LogItem) o;
+      return type == log.type
+          && !(timeString != null ? !timeString.equals(log.timeString) : log.timeString != null)
+          && !(msg != null ? !msg.equals(log.msg) : log.msg != null)
+          && !(tag != null ? !tag.equals(log.tag) : log.tag != null)
+          && !(throwable != null ? !throwable.equals(log.throwable) : log.throwable != null);
+    }
+
+    @Override
+    public int hashCode() {
+      int result = 0;
+      result = 31 * result + (timeString != null ? timeString.hashCode() : 0);
+      result = 31 * result + type;
+      result = 31 * result + (tag != null ? tag.hashCode() : 0);
+      result = 31 * result + (msg != null ? msg.hashCode() : 0);
+      result = 31 * result + (throwable != null ? throwable.hashCode() : 0);
+      return result;
+    }
+
+    @Override
+    public String toString() {
+      return "LogItem{"
+          + "\n  timeString='"
+          + timeString
+          + '\''
+          + "\n  type="
+          + type
+          + "\n  tag='"
+          + tag
+          + '\''
+          + "\n  msg='"
+          + msg
+          + '\''
+          + "\n  throwable="
+          + (throwable == null ? null : Throwables.getStackTraceAsString(throwable))
+          + "\n}";
+    }
+  }
+
+  /**
+   * Failure thrown when wtf_is_fatal is true and Log.wtf is called. This is a parallel
+   * implementation of framework's hidden API {@link android.util.Log#TerribleFailure}, to allow
+   * tests to catch / expect these exceptions.
+   */
+  public static final class TerribleFailure extends RuntimeException {
+    TerribleFailure(String msg, Throwable cause) {
+      super(msg, cause);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLooper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLooper.java
new file mode 100644
index 0000000..71f65f2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLooper.java
@@ -0,0 +1,366 @@
+package org.robolectric.shadows;
+
+import static android.os.Looper.getMainLooper;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.os.Looper;
+import com.google.errorprone.annotations.InlineMe;
+import com.google.errorprone.annotations.concurrent.GuardedBy;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.Scheduler;
+
+/**
+ * The base shadow API class for controlling Loopers.
+ *
+ * <p>It will delegate calls to the appropriate shadow based on the current LooperMode.
+ */
+@Implements(value = Looper.class, shadowPicker = ShadowLooper.Picker.class)
+public abstract class ShadowLooper {
+
+  // cache for looperMode(), since this can be an expensive call.
+  @GuardedBy("looperModeLock")
+  private static LooperMode.Mode cachedLooperMode = null;
+
+  private static final Object looperModeLock = new Object();
+
+  public static void assertLooperMode(LooperMode.Mode expectedMode) {
+    if (looperMode() != expectedMode) {
+      throw new IllegalStateException("this action is not supported in " + looperMode() + " mode.");
+    }
+  }
+
+  private static ShadowLooper shadowLooper(Looper looper) {
+    return Shadow.extract(looper);
+  }
+
+  /** @deprecated Use {@code shadowOf({@link Looper#getMainLooper()})} instead. */
+  @Deprecated
+  public static ShadowLooper getShadowMainLooper() {
+    return shadowLooper(getMainLooper());
+  }
+
+  // TODO: should probably remove this
+  public static ShadowLooper shadowMainLooper() {
+    return shadowLooper(getMainLooper());
+  }
+
+  public static Looper getLooperForThread(Thread thread) {
+    if (looperMode() == LEGACY) {
+      return ShadowLegacyLooper.getLooperForThread(thread);
+    }
+    throw new UnsupportedOperationException(
+        "this action is not supported in " + looperMode() + " mode.");
+  }
+
+  /** Return all created loopers. */
+  public static Collection<Looper> getAllLoopers() {
+    if (looperMode() == LEGACY) {
+      return ShadowLegacyLooper.getLoopers();
+    } else {
+      return ShadowPausedLooper.getLoopers();
+    }
+  }
+
+  /** Should not be called directly - Robolectric internal use only. */
+  public static void resetThreadLoopers() {
+    if (looperMode() == LEGACY) {
+      ShadowLegacyLooper.resetThreadLoopers();
+      return;
+    }
+    throw new UnsupportedOperationException(
+        "this action is not supported in " + looperMode() + " mode.");
+  }
+
+  /** Return the current {@link LooperMode}. */
+  public static LooperMode.Mode looperMode() {
+    synchronized (looperModeLock) {
+      if (cachedLooperMode == null) {
+        cachedLooperMode = ConfigurationRegistry.get(LooperMode.Mode.class);
+      }
+      return cachedLooperMode;
+    }
+  }
+
+  @Resetter
+  public static synchronized void clearLooperMode() {
+    synchronized (looperModeLock) {
+      cachedLooperMode = null;
+    }
+  }
+
+  /**
+   * Pauses execution of tasks posted to the ShadowLegacyLooper. This means that during tests, tasks
+   * sent to the looper will not execute immediately, but will be queued in a way that is similar to
+   * how a real looper works. These queued tasks must be executed explicitly by calling {@link
+   * #runToEndOftasks} or a similar method, otherwise they will not run at all before your test
+   * ends.
+   *
+   * @param looper the looper to pause
+   */
+  public static void pauseLooper(Looper looper) {
+    shadowLooper(looper).pause();
+  }
+
+  /**
+   * Puts the shadow looper in an "unpaused" state (this is the default state). This means that
+   * during tests, tasks sent to the looper will execute inline, immediately, on the calling (main)
+   * thread instead of being queued, in a way similar to how Guava's "DirectExecutorService" works.
+   * This is likely not to be what you want: it will cause code to be potentially executed in a
+   * different order than how it would execute on the device, and if you are using certain Android
+   * APIs (such as view animations) that are non-reentrant, they may not work at all or do
+   * unpredictable things. For more information, see <a
+   * href="https://github.com/robolectric/robolectric/issues/3369">this discussion</a>.
+   *
+   * @param looper the looper to pause
+   */
+  public static void unPauseLooper(Looper looper) {
+    shadowLooper(looper).unPause();
+  }
+
+  /**
+   * Puts the main ShadowLegacyLooper in an "paused" state.
+   *
+   * @see #pauseLooper
+   */
+  public static void pauseMainLooper() {
+    getShadowMainLooper().pause();
+  }
+
+  /**
+   * Puts the main ShadowLegacyLooper in an "unpaused" state.
+   *
+   * @see #unPauseLooper
+   */
+  public static void unPauseMainLooper() {
+    getShadowMainLooper().unPause();
+  }
+
+  public static void idleMainLooper() {
+    getShadowMainLooper().idle();
+  }
+
+  /** @deprecated Use {@link #idleMainLooper(long, TimeUnit)}. */
+  @InlineMe(
+      replacement = "ShadowLooper.idleMainLooper(interval, MILLISECONDS)",
+      imports = "org.robolectric.shadows.ShadowLooper",
+      staticImports = "java.util.concurrent.TimeUnit.MILLISECONDS")
+  @Deprecated
+  public static void idleMainLooper(long interval) {
+    idleMainLooper(interval, MILLISECONDS);
+  }
+
+  public static void idleMainLooper(long amount, TimeUnit unit) {
+    getShadowMainLooper().idleFor(amount, unit);
+  }
+
+  public static void idleMainLooperConstantly(boolean shouldIdleConstantly) {
+    getShadowMainLooper().idleConstantly(shouldIdleConstantly);
+  }
+
+  public static void runMainLooperOneTask() {
+    getShadowMainLooper().runOneTask();
+  }
+
+  public static void runMainLooperToNextTask() {
+    getShadowMainLooper().runToNextTask();
+  }
+
+  /**
+   * Runs any immediately runnable tasks previously queued on the UI thread, e.g. by {@link
+   * android.app.Activity#runOnUiThread(Runnable)} or {@link
+   * android.os.AsyncTask#onPostExecute(Object)}.
+   *
+   * <p>**Note:** calling this method does not pause or un-pause the scheduler.
+   *
+   * @see #runUiThreadTasksIncludingDelayedTasks
+   */
+  public static void runUiThreadTasks() {
+    getShadowMainLooper().idle();
+  }
+
+  /**
+   * Runs all runnable tasks (pending and future) that have been queued on the UI thread. Such tasks
+   * may be queued by e.g. {@link android.app.Activity#runOnUiThread(Runnable)} or {@link
+   * android.os.AsyncTask#onPostExecute(Object)}.
+   *
+   * <p>**Note:** calling this method does not pause or un-pause the scheduler, however the clock is
+   * advanced as future tasks are run.
+   *
+   * @see #runUiThreadTasks
+   */
+  public static void runUiThreadTasksIncludingDelayedTasks() {
+    getShadowMainLooper().runToEndOfTasks();
+  }
+
+  public abstract void quitUnchecked();
+
+  public abstract boolean hasQuit();
+
+  /** Executes all posted tasks scheduled before or at the current time. */
+  public abstract void idle();
+
+  /**
+   * Advances the system clock by the given time, then executes all posted tasks scheduled before or
+   * at the given time.
+   */
+  public abstract void idleFor(long time, TimeUnit timeUnit);
+
+  /** A variant of {@link #idleFor(long, TimeUnit)} that accepts a Duration. */
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  public void idleFor(Duration duration) {
+    idleFor(duration.toMillis(), TimeUnit.MILLISECONDS);
+  }
+
+  /** Returns true if there are no pending tasks scheduled to be executed before current time. */
+  public abstract boolean isIdle();
+
+  /** Not supported for the main Looper in {@link LooperMode.Mode.PAUSED}. */
+  public abstract void unPause();
+
+  public abstract boolean isPaused();
+
+  /**
+   * Control the paused state of the Looper.
+   *
+   * <p>Not supported for the main Looper in {@link LooperMode.Mode.PAUSED}.
+   */
+  public abstract boolean setPaused(boolean shouldPause);
+
+  /** Only supported for {@link LooperMode.Mode.LEGACY}. */
+  public abstract void resetScheduler();
+
+  /** Causes all enqueued tasks to be discarded, and pause state to be reset */
+  public abstract void reset();
+
+  /**
+   * Returns the {@link org.robolectric.util.Scheduler} that is being used to manage the enqueued
+   * tasks. This scheduler is managed by the Looper's associated queue.
+   *
+   * <p>Only supported for {@link LooperMode.Mode.LEGACY}.
+   *
+   * @return the {@link org.robolectric.util.Scheduler} that is being used to manage the enqueued
+   *     tasks.
+   */
+  public abstract Scheduler getScheduler();
+
+  /**
+   * Runs the current task with the looper paused.
+   *
+   * <p>When LooperMode is PAUSED, this will execute all pending tasks scheduled before the current
+   * time.
+   */
+  public abstract void runPaused(Runnable run);
+
+  /**
+   * Helper method to selectively call idle() only if LooperMode is PAUSED.
+   *
+   * <p>Intended for backwards compatibility, to avoid changing behavior for tests still using
+   * LEGACY LooperMode.
+   */
+  public abstract void idleIfPaused();
+
+  /**
+   * Causes {@link Runnable}s that have been scheduled to run within the next {@code intervalMillis}
+   * milliseconds to run while advancing the scheduler's clock.
+   *
+   * @deprecated Use {@link #idleFor(Duration)}.
+   */
+  @Deprecated
+  @InlineMe(
+      replacement = "this.idleFor(Duration.ofMillis(intervalMillis))",
+      imports = "java.time.Duration")
+  public final void idle(long intervalMillis) {
+    idleFor(Duration.ofMillis(intervalMillis));
+  }
+
+  /**
+   * Causes {@link Runnable}s that have been scheduled to run within the next specified amount of
+   * time to run while advancing the clock.
+   *
+   * @deprecated use {@link #idleFor(long, TimeUnit)}
+   */
+  @Deprecated
+  @InlineMe(replacement = "this.idleFor(amount, unit)")
+  public final void idle(long amount, TimeUnit unit) {
+    idleFor(amount, unit);
+  }
+
+  public abstract void idleConstantly(boolean shouldIdleConstantly);
+
+  /**
+   * Causes all of the {@link Runnable}s that have been scheduled to run while advancing the clock
+   * to the start time of the last scheduled {@link Runnable}.
+   */
+  public abstract void runToEndOfTasks();
+
+  /**
+   * Causes the next {@link Runnable}(s) that have been scheduled to run while advancing the clock
+   * to its start time. If more than one {@link Runnable} is scheduled to run at this time then they
+   * will all be run.
+   */
+  public abstract void runToNextTask();
+
+  /**
+   * Causes only one of the next {@link Runnable}s that have been scheduled to run while advancing
+   * the clock to its start time. Only one {@link Runnable} will run even if more than one has been
+   * scheduled to run at the same time.
+   */
+  public abstract void runOneTask();
+
+  /**
+   * Enqueue a task to be run later.
+   *
+   * @param runnable the task to be run
+   * @param delayMillis how many milliseconds into the (virtual) future to run it
+   * @return true if the runnable is enqueued
+   * @see android.os.Handler#postDelayed(Runnable,long)
+   * @deprecated Use a {@link android.os.Handler} instance to post to a looper.
+   */
+  @Deprecated
+  public abstract boolean post(Runnable runnable, long delayMillis);
+
+  /**
+   * Enqueue a task to be run ahead of all other delayed tasks.
+   *
+   * @param runnable the task to be run
+   * @return true if the runnable is enqueued
+   * @see android.os.Handler#postAtFrontOfQueue(Runnable)
+   * @deprecated Use a {@link android.os.Handler} instance to post to a looper.
+   */
+  @Deprecated
+  public abstract boolean postAtFrontOfQueue(Runnable runnable);
+
+  /**
+   * Pause the looper.
+   *
+   * <p>Has no practical effect for realistic looper, since it is always paused.
+   */
+  public abstract void pause();
+
+  /**
+   * @return the scheduled time of the next posted task; Duration.ZERO if there is no currently
+   *     scheduled task.
+   */
+  public abstract Duration getNextScheduledTaskTime();
+
+  /**
+   * @return the scheduled time of the last posted task; Duration.ZERO 0 if there is no currently
+   *     scheduled task.
+   */
+  public abstract Duration getLastScheduledTaskTime();
+
+  public static class Picker extends LooperShadowPicker<ShadowLooper> {
+
+    public Picker() {
+      super(ShadowLegacyLooper.class, ShadowPausedLooper.class);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMagnificationController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMagnificationController.java
new file mode 100644
index 0000000..faa8a96
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMagnificationController.java
@@ -0,0 +1,106 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+
+import android.accessibilityservice.AccessibilityService.MagnificationController;
+import android.graphics.Region;
+import android.os.Handler;
+import android.os.Looper;
+import java.util.HashMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** Shadow of MagnificationController. */
+@Implements(value = MagnificationController.class, minSdk = N)
+public class ShadowMagnificationController {
+
+  private static final float DEFAULT_CENTER_X = 0.0f;
+  private static final float DEFAULT_CENTER_Y = 0.0f;
+  private static final float DEFAULT_SCALE = 1.0f;
+
+  @RealObject private MagnificationController realObject;
+
+  private final HashMap<MagnificationController.OnMagnificationChangedListener, Handler> listeners =
+      new HashMap<>();
+
+  private final Region magnificationRegion = new Region();
+  private float centerX = DEFAULT_CENTER_X;
+  private float centerY = DEFAULT_CENTER_Y;
+  private float scale = DEFAULT_SCALE;
+
+  @Implementation
+  protected void addListener(
+      MagnificationController.OnMagnificationChangedListener listener, Handler handler) {
+    listeners.put(listener, handler);
+  }
+
+  @Implementation
+  protected void addListener(MagnificationController.OnMagnificationChangedListener listener) {
+    addListener(listener, new Handler(Looper.getMainLooper()));
+  }
+
+  @Implementation
+  protected float getCenterX() {
+    return centerX;
+  }
+
+  @Implementation
+  protected float getCenterY() {
+    return centerY;
+  }
+
+  @Implementation
+  protected Region getMagnificationRegion() {
+    return magnificationRegion;
+  }
+
+  @Implementation
+  protected float getScale() {
+    return scale;
+  }
+
+  @Implementation
+  protected boolean removeListener(
+      MagnificationController.OnMagnificationChangedListener listener) {
+    if (!listeners.containsKey(listener)) {
+      return false;
+    }
+    listeners.remove(listener);
+    return true;
+  }
+
+  @Implementation
+  protected boolean reset(boolean animate) {
+    centerX = DEFAULT_CENTER_X;
+    centerY = DEFAULT_CENTER_Y;
+    scale = DEFAULT_SCALE;
+    notifyListeners();
+    return true;
+  }
+
+  @Implementation
+  protected boolean setCenter(float centerX, float centerY, boolean animate) {
+    this.centerX = centerX;
+    this.centerY = centerY;
+    notifyListeners();
+    return true;
+  }
+
+  @Implementation
+  protected boolean setScale(float scale, boolean animate) {
+    this.scale = scale;
+    notifyListeners();
+    return true;
+  }
+
+  private void notifyListeners() {
+    for (MagnificationController.OnMagnificationChangedListener listener : listeners.keySet()) {
+      Handler handler = listeners.get(listener);
+      handler.post(
+          () ->
+              listener.onMagnificationChanged(
+                  realObject, magnificationRegion, scale, centerX, centerY));
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMatrix.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMatrix.java
new file mode 100644
index 0000000..ef26f9e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMatrix.java
@@ -0,0 +1,661 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.graphics.Matrix;
+import android.graphics.Matrix.ScaleToFit;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import java.awt.geom.AffineTransform;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Matrix.class)
+public class ShadowMatrix {
+  public static final String TRANSLATE = "translate";
+  public static final String SCALE = "scale";
+  public static final String ROTATE = "rotate";
+  public static final String SINCOS = "sincos";
+  public static final String SKEW = "skew";
+  public static final String MATRIX = "matrix";
+
+  private static final float EPSILON = 1e-3f;
+
+  private final Deque<String> preOps = new ArrayDeque<>();
+  private final Deque<String> postOps = new ArrayDeque<>();
+  private final Map<String, String> setOps = new LinkedHashMap<>();
+
+  private SimpleMatrix simpleMatrix = SimpleMatrix.newIdentityMatrix();
+
+  @Implementation
+  protected void __constructor__(Matrix src) {
+    set(src);
+  }
+
+  /**
+   * A list of all 'pre' operations performed on this Matrix. The last operation performed will
+   * be first in the list.
+   * @return A list of all 'pre' operations performed on this Matrix.
+   */
+  public List<String> getPreOperations() {
+    return Collections.unmodifiableList(new ArrayList<>(preOps));
+  }
+
+  /**
+   * A list of all 'post' operations performed on this Matrix. The last operation performed will
+   * be last in the list.
+   * @return A list of all 'post' operations performed on this Matrix.
+   */
+  public List<String> getPostOperations() {
+    return Collections.unmodifiableList(new ArrayList<>(postOps));
+  }
+
+  /**
+   * A map of all 'set' operations performed on this Matrix.
+   * @return A map of all 'set' operations performed on this Matrix.
+   */
+  public Map<String, String> getSetOperations() {
+    return Collections.unmodifiableMap(new LinkedHashMap<>(setOps));
+  }
+
+  @Implementation
+  protected boolean isIdentity() {
+    return simpleMatrix.equals(SimpleMatrix.IDENTITY);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isAffine() {
+    return simpleMatrix.isAffine();
+  }
+
+  @Implementation
+  protected boolean rectStaysRect() {
+    return simpleMatrix.rectStaysRect();
+  }
+
+  @Implementation
+  protected void getValues(float[] values) {
+    simpleMatrix.getValues(values);
+  }
+
+  @Implementation
+  protected void setValues(float[] values) {
+    simpleMatrix = new SimpleMatrix(values);
+  }
+
+  @Implementation
+  protected void set(Matrix src) {
+    reset();
+    if (src != null) {
+      ShadowMatrix shadowMatrix = Shadow.extract(src);
+      preOps.addAll(shadowMatrix.preOps);
+      postOps.addAll(shadowMatrix.postOps);
+      setOps.putAll(shadowMatrix.setOps);
+      simpleMatrix = new SimpleMatrix(getSimpleMatrix(src));
+    }
+  }
+
+  @Implementation
+  protected void reset() {
+    preOps.clear();
+    postOps.clear();
+    setOps.clear();
+    simpleMatrix = SimpleMatrix.newIdentityMatrix();
+  }
+
+  @Implementation
+  protected void setTranslate(float dx, float dy) {
+    setOps.put(TRANSLATE, dx + " " + dy);
+    simpleMatrix = SimpleMatrix.translate(dx, dy);
+  }
+
+  @Implementation
+  protected void setScale(float sx, float sy, float px, float py) {
+    setOps.put(SCALE, sx + " " + sy + " " + px + " " + py);
+    simpleMatrix = SimpleMatrix.scale(sx, sy, px, py);
+  }
+
+  @Implementation
+  protected void setScale(float sx, float sy) {
+    setOps.put(SCALE, sx + " " + sy);
+    simpleMatrix = SimpleMatrix.scale(sx, sy);
+  }
+
+  @Implementation
+  protected void setRotate(float degrees, float px, float py) {
+    setOps.put(ROTATE, degrees + " " + px + " " + py);
+    simpleMatrix = SimpleMatrix.rotate(degrees, px, py);
+  }
+
+  @Implementation
+  protected void setRotate(float degrees) {
+    setOps.put(ROTATE, Float.toString(degrees));
+    simpleMatrix = SimpleMatrix.rotate(degrees);
+  }
+
+  @Implementation
+  protected void setSinCos(float sinValue, float cosValue, float px, float py) {
+    setOps.put(SINCOS, sinValue + " " + cosValue + " " + px + " " + py);
+    simpleMatrix = SimpleMatrix.sinCos(sinValue, cosValue, px, py);
+  }
+
+  @Implementation
+  protected void setSinCos(float sinValue, float cosValue) {
+    setOps.put(SINCOS, sinValue + " " + cosValue);
+    simpleMatrix = SimpleMatrix.sinCos(sinValue, cosValue);
+  }
+
+  @Implementation
+  protected void setSkew(float kx, float ky, float px, float py) {
+    setOps.put(SKEW, kx + " " + ky + " " + px + " " + py);
+    simpleMatrix = SimpleMatrix.skew(kx, ky, px, py);
+  }
+
+  @Implementation
+  protected void setSkew(float kx, float ky) {
+    setOps.put(SKEW, kx + " " + ky);
+    simpleMatrix = SimpleMatrix.skew(kx, ky);
+  }
+
+  @Implementation
+  protected boolean setConcat(Matrix a, Matrix b) {
+    simpleMatrix = getSimpleMatrix(a).multiply(getSimpleMatrix(b));
+    return true;
+  }
+
+  @Implementation
+  protected boolean preTranslate(float dx, float dy) {
+    preOps.addFirst(TRANSLATE + " " + dx + " " + dy);
+    return preConcat(SimpleMatrix.translate(dx, dy));
+  }
+
+  @Implementation
+  protected boolean preScale(float sx, float sy, float px, float py) {
+    preOps.addFirst(SCALE + " " + sx + " " + sy + " " + px + " " + py);
+    return preConcat(SimpleMatrix.scale(sx, sy, px, py));
+  }
+
+  @Implementation
+  protected boolean preScale(float sx, float sy) {
+    preOps.addFirst(SCALE + " " + sx + " " + sy);
+    return preConcat(SimpleMatrix.scale(sx, sy));
+  }
+
+  @Implementation
+  protected boolean preRotate(float degrees, float px, float py) {
+    preOps.addFirst(ROTATE + " " + degrees + " " + px + " " + py);
+    return preConcat(SimpleMatrix.rotate(degrees, px, py));
+  }
+
+  @Implementation
+  protected boolean preRotate(float degrees) {
+    preOps.addFirst(ROTATE + " " + Float.toString(degrees));
+    return preConcat(SimpleMatrix.rotate(degrees));
+  }
+
+  @Implementation
+  protected boolean preSkew(float kx, float ky, float px, float py) {
+    preOps.addFirst(SKEW + " " + kx + " " + ky + " " + px + " " + py);
+    return preConcat(SimpleMatrix.skew(kx, ky, px, py));
+  }
+
+  @Implementation
+  protected boolean preSkew(float kx, float ky) {
+    preOps.addFirst(SKEW + " " + kx + " " + ky);
+    return preConcat(SimpleMatrix.skew(kx, ky));
+  }
+
+  @Implementation
+  protected boolean preConcat(Matrix other) {
+    preOps.addFirst(MATRIX + " " + other);
+    return preConcat(getSimpleMatrix(other));
+  }
+
+  @Implementation
+  protected boolean postTranslate(float dx, float dy) {
+    postOps.addLast(TRANSLATE + " " + dx + " " + dy);
+    return postConcat(SimpleMatrix.translate(dx, dy));
+  }
+
+  @Implementation
+  protected boolean postScale(float sx, float sy, float px, float py) {
+    postOps.addLast(SCALE + " " + sx + " " + sy + " " + px + " " + py);
+    return postConcat(SimpleMatrix.scale(sx, sy, px, py));
+  }
+
+  @Implementation
+  protected boolean postScale(float sx, float sy) {
+    postOps.addLast(SCALE + " " + sx + " " + sy);
+    return postConcat(SimpleMatrix.scale(sx, sy));
+  }
+
+  @Implementation
+  protected boolean postRotate(float degrees, float px, float py) {
+    postOps.addLast(ROTATE + " " + degrees + " " + px + " " + py);
+    return postConcat(SimpleMatrix.rotate(degrees, px, py));
+  }
+
+  @Implementation
+  protected boolean postRotate(float degrees) {
+    postOps.addLast(ROTATE + " " + Float.toString(degrees));
+    return postConcat(SimpleMatrix.rotate(degrees));
+  }
+
+  @Implementation
+  protected boolean postSkew(float kx, float ky, float px, float py) {
+    postOps.addLast(SKEW + " " + kx + " " + ky + " " + px + " " + py);
+    return postConcat(SimpleMatrix.skew(kx, ky, px, py));
+  }
+
+  @Implementation
+  protected boolean postSkew(float kx, float ky) {
+    postOps.addLast(SKEW + " " + kx + " " + ky);
+    return postConcat(SimpleMatrix.skew(kx, ky));
+  }
+
+  @Implementation
+  protected boolean postConcat(Matrix other) {
+    postOps.addLast(MATRIX + " " + other);
+    return postConcat(getSimpleMatrix(other));
+  }
+
+  @Implementation
+  protected boolean invert(Matrix inverse) {
+    final SimpleMatrix inverseMatrix = simpleMatrix.invert();
+    if (inverseMatrix != null) {
+      if (inverse != null) {
+        final ShadowMatrix shadowInverse = Shadow.extract(inverse);
+        shadowInverse.simpleMatrix = inverseMatrix;
+      }
+      return true;
+    }
+    return false;
+  }
+
+  boolean hasPerspective() {
+    return (simpleMatrix.mValues[6] != 0 || simpleMatrix.mValues[7] != 0 || simpleMatrix.mValues[8] != 1);
+  }
+
+  protected AffineTransform getAffineTransform() {
+    // the AffineTransform constructor takes the value in a different order
+    // for a matrix [ 0 1 2 ]
+    //              [ 3 4 5 ]
+    // the order is 0, 3, 1, 4, 2, 5...
+    return new AffineTransform(
+        simpleMatrix.mValues[0],
+        simpleMatrix.mValues[3],
+        simpleMatrix.mValues[1],
+        simpleMatrix.mValues[4],
+        simpleMatrix.mValues[2],
+        simpleMatrix.mValues[5]);
+  }
+
+  public PointF mapPoint(float x, float y) {
+    return simpleMatrix.transform(new PointF(x, y));
+  }
+
+  public PointF mapPoint(PointF point) {
+    return simpleMatrix.transform(point);
+  }
+
+  @Implementation
+  protected boolean mapRect(RectF destination, RectF source) {
+    final PointF leftTop = mapPoint(source.left, source.top);
+    final PointF rightBottom = mapPoint(source.right, source.bottom);
+    destination.set(
+        Math.min(leftTop.x, rightBottom.x),
+        Math.min(leftTop.y, rightBottom.y),
+        Math.max(leftTop.x, rightBottom.x),
+        Math.max(leftTop.y, rightBottom.y));
+    return true;
+  }
+
+  @Implementation
+  protected void mapPoints(float[] dst, int dstIndex, float[] src, int srcIndex, int pointCount) {
+    for (int i = 0; i < pointCount; i++) {
+      final PointF mapped = mapPoint(src[srcIndex + i * 2], src[srcIndex + i * 2 + 1]);
+      dst[dstIndex + i * 2] = mapped.x;
+      dst[dstIndex + i * 2 + 1] = mapped.y;
+    }
+  }
+
+  @Implementation
+  protected void mapVectors(float[] dst, int dstIndex, float[] src, int srcIndex, int vectorCount) {
+    final float transX = simpleMatrix.mValues[Matrix.MTRANS_X];
+    final float transY = simpleMatrix.mValues[Matrix.MTRANS_Y];
+
+    simpleMatrix.mValues[Matrix.MTRANS_X] = 0;
+    simpleMatrix.mValues[Matrix.MTRANS_Y] = 0;
+
+    for (int i = 0; i < vectorCount; i++) {
+      final PointF mapped = mapPoint(src[srcIndex + i * 2], src[srcIndex + i * 2 + 1]);
+      dst[dstIndex + i * 2] = mapped.x;
+      dst[dstIndex + i * 2 + 1] = mapped.y;
+    }
+
+    simpleMatrix.mValues[Matrix.MTRANS_X] = transX;
+    simpleMatrix.mValues[Matrix.MTRANS_Y] = transY;
+  }
+
+  @Implementation
+  protected float mapRadius(float radius) {
+    float[] src = new float[] {radius, 0.f, 0.f, radius};
+    mapVectors(src, 0, src, 0, 2);
+
+    float l1 = (float) Math.hypot(src[0], src[1]);
+    float l2 = (float) Math.hypot(src[2], src[3]);
+    return (float) Math.sqrt(l1 * l2);
+  }
+
+  @Implementation
+  protected boolean setRectToRect(RectF src, RectF dst, Matrix.ScaleToFit stf) {
+    if (src.isEmpty()) {
+      reset();
+      return false;
+    }
+    return simpleMatrix.setRectToRect(src, dst, stf);
+  }
+
+  @Implementation
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof Matrix) {
+        return getSimpleMatrix(((Matrix) obj)).equals(simpleMatrix);
+    } else {
+        return obj instanceof ShadowMatrix && obj.equals(simpleMatrix);
+    }
+  }
+
+  @Implementation(minSdk = KITKAT)
+  @Override
+  public int hashCode() {
+      return Objects.hashCode(simpleMatrix);
+  }
+
+  public String getDescription() {
+    return "Matrix[pre=" + preOps + ", set=" + setOps + ", post=" + postOps + "]";
+  }
+
+  private static SimpleMatrix getSimpleMatrix(Matrix matrix) {
+    final ShadowMatrix otherMatrix = Shadow.extract(matrix);
+    return otherMatrix.simpleMatrix;
+  }
+
+  private boolean postConcat(SimpleMatrix matrix) {
+    simpleMatrix = matrix.multiply(simpleMatrix);
+    return true;
+  }
+
+  private boolean preConcat(SimpleMatrix matrix) {
+    simpleMatrix = simpleMatrix.multiply(matrix);
+    return true;
+  }
+
+  /**
+   * A simple implementation of an immutable matrix.
+   */
+  private static class SimpleMatrix {
+    private static final SimpleMatrix IDENTITY = newIdentityMatrix();
+
+    private static SimpleMatrix newIdentityMatrix() {
+      return new SimpleMatrix(
+          new float[] {
+            1.0f, 0.0f, 0.0f,
+            0.0f, 1.0f, 0.0f,
+            0.0f, 0.0f, 1.0f,
+          });
+    }
+
+    private final float[] mValues;
+
+    SimpleMatrix(SimpleMatrix matrix) {
+      mValues = Arrays.copyOf(matrix.mValues, matrix.mValues.length);
+    }
+
+    private SimpleMatrix(float[] values) {
+      if (values.length != 9) {
+        throw new ArrayIndexOutOfBoundsException();
+      }
+      mValues = Arrays.copyOf(values, 9);
+    }
+
+    public boolean isAffine() {
+      return mValues[6] == 0.0f && mValues[7] == 0.0f && mValues[8] == 1.0f;
+    }
+
+    public boolean rectStaysRect() {
+      final float m00 = mValues[0];
+      final float m01 = mValues[1];
+      final float m10 = mValues[3];
+      final float m11 = mValues[4];
+      return (m00 == 0 && m11 == 0 && m01 != 0 && m10 != 0) || (m00 != 0 && m11 != 0 && m01 == 0 && m10 == 0);
+    }
+
+    public void getValues(float[] values) {
+      if (values.length < 9) {
+        throw new ArrayIndexOutOfBoundsException();
+      }
+      System.arraycopy(mValues, 0, values, 0, 9);
+    }
+
+    public static SimpleMatrix translate(float dx, float dy) {
+      return new SimpleMatrix(new float[] {
+          1.0f, 0.0f, dx,
+          0.0f, 1.0f, dy,
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public static SimpleMatrix scale(float sx, float sy, float px, float py) {
+      return new SimpleMatrix(new float[] {
+          sx,   0.0f, px * (1 - sx),
+          0.0f, sy,   py * (1 - sy),
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public static SimpleMatrix scale(float sx, float sy) {
+      return new SimpleMatrix(new float[] {
+          sx,   0.0f, 0.0f,
+          0.0f, sy,   0.0f,
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public static SimpleMatrix rotate(float degrees, float px, float py) {
+      final double radians = Math.toRadians(degrees);
+      final float sin = (float) Math.sin(radians);
+      final float cos = (float) Math.cos(radians);
+      return sinCos(sin, cos, px, py);
+    }
+
+    public static SimpleMatrix rotate(float degrees) {
+      final double radians = Math.toRadians(degrees);
+      final float sin = (float) Math.sin(radians);
+      final float cos = (float) Math.cos(radians);
+      return sinCos(sin, cos);
+    }
+
+    public static SimpleMatrix sinCos(float sin, float cos, float px, float py) {
+      return new SimpleMatrix(new float[] {
+          cos,  -sin, sin * py + (1 - cos) * px,
+          sin,  cos,  -sin * px + (1 - cos) * py,
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public static SimpleMatrix sinCos(float sin, float cos) {
+      return new SimpleMatrix(new float[] {
+          cos,  -sin, 0.0f,
+          sin,  cos,  0.0f,
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public static SimpleMatrix skew(float kx, float ky, float px, float py) {
+      return new SimpleMatrix(new float[] {
+          1.0f, kx,   -kx * py,
+          ky,   1.0f, -ky * px,
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public static SimpleMatrix skew(float kx, float ky) {
+      return new SimpleMatrix(new float[] {
+          1.0f, kx,   0.0f,
+          ky,   1.0f, 0.0f,
+          0.0f, 0.0f, 1.0f,
+      });
+    }
+
+    public SimpleMatrix multiply(SimpleMatrix matrix) {
+      final float[] values = new float[9];
+      for (int i = 0; i < values.length; ++i) {
+        final int row = i / 3;
+        final int col = i % 3;
+        for (int j = 0; j < 3; ++j) {
+          values[i] += mValues[row * 3 + j] * matrix.mValues[j * 3 + col];
+        }
+      }
+      return new SimpleMatrix(values);
+    }
+
+    public SimpleMatrix invert() {
+      final float invDet = inverseDeterminant();
+      if (invDet == 0) {
+        return null;
+      }
+
+      final float[] src = mValues;
+      final float[] dst = new float[9];
+      dst[0] = cross_scale(src[4], src[8], src[5], src[7], invDet);
+      dst[1] = cross_scale(src[2], src[7], src[1], src[8], invDet);
+      dst[2] = cross_scale(src[1], src[5], src[2], src[4], invDet);
+
+      dst[3] = cross_scale(src[5], src[6], src[3], src[8], invDet);
+      dst[4] = cross_scale(src[0], src[8], src[2], src[6], invDet);
+      dst[5] = cross_scale(src[2], src[3], src[0], src[5], invDet);
+
+      dst[6] = cross_scale(src[3], src[7], src[4], src[6], invDet);
+      dst[7] = cross_scale(src[1], src[6], src[0], src[7], invDet);
+      dst[8] = cross_scale(src[0], src[4], src[1], src[3], invDet);
+      return new SimpleMatrix(dst);
+    }
+
+    public PointF transform(PointF point) {
+      return new PointF(
+          point.x * mValues[0] + point.y * mValues[1] + mValues[2],
+          point.x * mValues[3] + point.y * mValues[4] + mValues[5]);
+    }
+
+    // See: https://android.googlesource.com/platform/frameworks/base/+/6fca81de9b2079ec88e785f58bf49bf1f0c105e2/tools/layoutlib/bridge/src/android/graphics/Matrix_Delegate.java
+    protected boolean setRectToRect(RectF src, RectF dst, ScaleToFit stf) {
+      if (dst.isEmpty()) {
+        mValues[0] =
+            mValues[1] =
+                mValues[2] = mValues[3] = mValues[4] = mValues[5] = mValues[6] = mValues[7] = 0;
+        mValues[8] = 1;
+      } else {
+        float tx = dst.width() / src.width();
+        float sx = dst.width() / src.width();
+        float ty = dst.height() / src.height();
+        float sy = dst.height() / src.height();
+        boolean xLarger = false;
+
+        if (stf != ScaleToFit.FILL) {
+          if (sx > sy) {
+            xLarger = true;
+            sx = sy;
+          } else {
+            sy = sx;
+          }
+        }
+
+        tx = dst.left - src.left * sx;
+        ty = dst.top - src.top * sy;
+        if (stf == ScaleToFit.CENTER || stf == ScaleToFit.END) {
+          float diff;
+
+          if (xLarger) {
+            diff = dst.width() - src.width() * sy;
+          } else {
+            diff = dst.height() - src.height() * sy;
+          }
+
+          if (stf == ScaleToFit.CENTER) {
+            diff = diff / 2;
+          }
+
+          if (xLarger) {
+            tx += diff;
+          } else {
+            ty += diff;
+          }
+        }
+
+        mValues[0] = sx;
+        mValues[4] = sy;
+        mValues[2] = tx;
+        mValues[5] = ty;
+        mValues[1] = mValues[3] = mValues[6] = mValues[7] = 0;
+      }
+      // shared cleanup
+      mValues[8] = 1;
+      return true;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return this == o || (o instanceof SimpleMatrix && equals((SimpleMatrix) o));
+    }
+
+    @SuppressWarnings("NonOverridingEquals")
+    public boolean equals(SimpleMatrix matrix) {
+      if (matrix == null) {
+        return false;
+      }
+      for (int i = 0; i < mValues.length; i++) {
+        if (!isNearlyZero(matrix.mValues[i] - mValues[i])) {
+          return false;
+        }
+      }
+      return true;
+    }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(mValues);
+    }
+
+    private static boolean isNearlyZero(float value) {
+      return Math.abs(value) < EPSILON;
+    }
+
+    private static float cross(float a, float b, float c, float d) {
+      return a * b - c * d;
+    }
+
+    private static float cross_scale(float a, float b, float c, float d, float scale) {
+      return cross(a, b, c, d) * scale;
+    }
+
+    private float inverseDeterminant() {
+      final float determinant = mValues[0] * cross(mValues[4], mValues[8], mValues[5], mValues[7]) +
+          mValues[1] * cross(mValues[5], mValues[6], mValues[3], mValues[8]) +
+          mValues[2] * cross(mValues[3], mValues[7], mValues[4], mValues[6]);
+      return isNearlyZero(determinant) ? 0.0f : 1.0f / determinant;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMeasuredParagraph.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMeasuredParagraph.java
new file mode 100644
index 0000000..bc87e63
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMeasuredParagraph.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+
+import android.text.MeasuredParagraph;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = MeasuredParagraph.class, minSdk = P, isInAndroidSdk = false)
+public class ShadowMeasuredParagraph {
+
+  private static int nativeCounter = 0;
+
+  @Implementation(maxSdk = P)
+  protected static long nInitBuilder() {
+    return ++nativeCounter;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMeasuredTextBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMeasuredTextBuilder.java
new file mode 100644
index 0000000..625fa5a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMeasuredTextBuilder.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S_V2;
+
+import android.graphics.text.MeasuredText;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(
+    value = MeasuredText.Builder.class,
+    minSdk = Build.VERSION_CODES.Q,
+    isInAndroidSdk = false)
+public class ShadowMeasuredTextBuilder {
+
+  private static int nativeCounter = 0;
+
+  @Implementation
+  protected static long nInitBuilder() {
+    return ++nativeCounter;
+  }
+
+  @Implementation(maxSdk = S_V2)
+  protected static long nBuildMeasuredText(
+      long nativeBuilderPtr,
+      long hintMtPtr,
+      char[] text,
+      boolean computeHyphenation,
+      boolean computeLayout) {
+    return ++nativeCounter;
+  }
+
+  @Resetter
+  public static void reset() {
+    nativeCounter = 0;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaActionSound.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaActionSound.java
new file mode 100644
index 0000000..7d34663
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaActionSound.java
@@ -0,0 +1,71 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.media.MediaActionSound;
+import android.os.Build;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** A shadow implementation of {@link android.media.MediaActionSound}. */
+@Implements(value = MediaActionSound.class, minSdk = Build.VERSION_CODES.JELLY_BEAN)
+public class ShadowMediaActionSound {
+  @RealObject MediaActionSound realObject;
+
+  private static final int[] ALL_SOUNDS = {
+    MediaActionSound.SHUTTER_CLICK,
+    MediaActionSound.FOCUS_COMPLETE,
+    MediaActionSound.START_VIDEO_RECORDING,
+    MediaActionSound.STOP_VIDEO_RECORDING
+  };
+  private static final int NUM_SOUNDS = ALL_SOUNDS.length;
+  private static final Map<Integer, AtomicInteger> playCount = initializePlayCountMap();
+
+  private static final HashMap<Integer, AtomicInteger> initializePlayCountMap() {
+    HashMap<Integer, AtomicInteger> playCount = new HashMap<>();
+    for (int sound : ALL_SOUNDS) {
+      playCount.put(sound, new AtomicInteger(0));
+    }
+    return playCount;
+  }
+
+  /** Get the number of times a sound has been played. */
+  public static int getPlayCount(int soundName) {
+    if (soundName < 0 || soundName >= NUM_SOUNDS) {
+      throw new RuntimeException("Invalid sound name: " + soundName);
+    }
+
+    return playCount.get(soundName).get();
+  }
+
+  @Resetter
+  public static void reset() {
+    synchronized (playCount) {
+      for (AtomicInteger soundCount : playCount.values()) {
+        soundCount.set(0);
+      }
+    }
+  }
+
+  /** Instrumented call to {@link android.media.MediaActionSound#play} */
+  @Implementation
+  protected void play(int soundName) {
+    reflector(MediaActionSoundReflector.class, realObject).play(soundName);
+
+    playCount.get(soundName).incrementAndGet();
+  }
+
+  @ForType(MediaActionSound.class)
+  interface MediaActionSoundReflector {
+
+    @Direct
+    void play(int soundName);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java
new file mode 100644
index 0000000..f6481f5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java
@@ -0,0 +1,538 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CodecException;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.view.Surface;
+import com.google.common.annotations.VisibleForTesting;
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingDeque;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Implementation of {@link android.media.MediaCodec} which supports both asynchronous and
+ * synchronous modes.
+ *
+ * <p>By default for any encoded required, a 1 to 1 mapping will be used between the input and
+ * output buffers. Data from a queued input buffer will be copied to the output buffer. In the case
+ * that is it necessary so simulate some form of data compression, a custom encoder or decoder can
+ * be added via {@link #addEncoder(String, CodecConfig)} and {@link #addDecoder(String,
+ * CodecConfig)} respectively.
+ *
+ * <p>Asynchronous mode: Once the codec is started, a format change will be reported, switching to
+ * an empty {@link android.media.MediaFormat} with fake codec-specific info. Following this, the
+ * implementation will present an input buffer, which will be copied to an output buffer once
+ * queued, which will be subsequently presented to the callback handler.
+ */
+@Implements(value = MediaCodec.class, minSdk = JELLY_BEAN, looseSignatures = true)
+public class ShadowMediaCodec {
+  private static final int DEFAULT_BUFFER_SIZE = 512;
+  @VisibleForTesting static final int BUFFER_COUNT = 10;
+
+  // Must keep in sync with MediaCodec.java
+  private static final int EVENT_CALLBACK = 1;
+  private static final int CB_INPUT_AVAILABLE = 1;
+  private static final int CB_OUTPUT_AVAILABLE = 2;
+  private static final int CB_OUTPUT_FORMAT_CHANGE = 4;
+
+  private static final Map<String, CodecConfig> encoders = new HashMap<>();
+  private static final Map<String, CodecConfig> decoders = new HashMap<>();
+
+  /**
+   * Default codec that simply moves bytes from the input to the output buffers where the buffers
+   * are of equal size.
+   */
+  private static final CodecConfig DEFAULT_CODEC =
+      new CodecConfig(DEFAULT_BUFFER_SIZE, DEFAULT_BUFFER_SIZE, (in, out) -> out.put(in));
+
+  /** Add a fake encoding codec to the Shadow. */
+  public static void addEncoder(String type, CodecConfig config) {
+    encoders.put(type, config);
+  }
+
+  /** Add a fake decoding codec to the Shadow. */
+  public static void addDecoder(String type, CodecConfig config) {
+    decoders.put(type, config);
+  }
+
+  /** Clears any previously added encoders and decoders. */
+  @Resetter
+  public static void clearCodecs() {
+    encoders.clear();
+    decoders.clear();
+  }
+
+  @RealObject private MediaCodec realCodec;
+  @Nullable private CodecConfig.Codec fakeCodec;
+
+  @Nullable private MediaCodec.Callback callback;
+
+  @Nullable private MediaFormat pendingOutputFormat;
+  @Nullable private MediaFormat outputFormat;
+
+  private final BlockingQueue<Integer> inputBuffersPendingDequeue = new LinkedBlockingDeque<>();
+  private final BlockingQueue<Integer> outputBuffersPendingDequeue = new LinkedBlockingDeque<>();
+  /*
+   * Ensures that a dequeued input buffer cannot be queued again until its corresponding output
+   * buffer is dequeued and released.
+   */
+  private final List<Integer> inputBuffersPendingQueuing =
+      Collections.synchronizedList(new ArrayList<>());
+
+  private final ByteBuffer[] inputBuffers = new ByteBuffer[BUFFER_COUNT];
+  private final ByteBuffer[] outputBuffers = new ByteBuffer[BUFFER_COUNT];
+  private final BufferInfo[] outputBufferInfos = new BufferInfo[BUFFER_COUNT];
+
+  private boolean isAsync = false;
+
+  // Member methods.
+
+  @Implementation
+  protected void __constructor__(String name, boolean nameIsType, boolean encoder) {
+    invokeConstructor(
+        MediaCodec.class,
+        realCodec,
+        ClassParameter.from(String.class, name),
+        ClassParameter.from(boolean.class, nameIsType),
+        ClassParameter.from(boolean.class, encoder));
+
+    if (!nameIsType) {
+      for (MediaCodecInfo codecInfo :
+          new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) {
+        if (codecInfo.getName().equals(name)) {
+          encoder = codecInfo.isEncoder();
+          break;
+        }
+      }
+    }
+
+    CodecConfig codecConfig =
+        encoder
+            ? encoders.getOrDefault(name, DEFAULT_CODEC)
+            : decoders.getOrDefault(name, DEFAULT_CODEC);
+    fakeCodec = codecConfig.codec;
+
+    for (int i = 0; i < BUFFER_COUNT; i++) {
+      inputBuffers[i] =
+          ByteBuffer.allocateDirect(codecConfig.inputBufferSize).order(ByteOrder.LITTLE_ENDIAN);
+      outputBuffers[i] =
+          ByteBuffer.allocateDirect(codecConfig.outputBufferSize).order(ByteOrder.LITTLE_ENDIAN);
+      outputBufferInfos[i] = new BufferInfo();
+    }
+  }
+
+  /** Saves the callback to allow use inside the shadow. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void native_setCallback(MediaCodec.Callback callback) {
+    this.callback = callback;
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected void native_configure(
+      String[] keys, Object[] values, Surface surface, MediaCrypto crypto, int flags) {
+    innerConfigure(keys, values, surface, crypto, flags);
+  }
+
+  @Implementation(minSdk = O)
+  protected void native_configure(
+      Object keys,
+      Object values,
+      Object surface,
+      Object crypto,
+      Object descramblerBinder,
+      Object flags) {
+    innerConfigure(
+        (String[]) keys, (Object[]) values, (Surface) surface, (MediaCrypto) crypto, (int) flags);
+  }
+
+  private void innerConfigure(
+      String[] keys,
+      Object[] values,
+      @Nullable Surface surface,
+      @Nullable MediaCrypto mediaCrypto,
+      int flags) {
+    isAsync = callback != null;
+    pendingOutputFormat = recreateMediaFormatFromKeysValues(keys, values);
+    fakeCodec.onConfigured(pendingOutputFormat, surface, mediaCrypto, flags);
+  }
+
+  /**
+   * Starts the async encoding process, by first reporting a format change event, and then
+   * presenting an input buffer to the callback.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void native_start() {
+    // Reset state
+    inputBuffersPendingDequeue.clear();
+    outputBuffersPendingDequeue.clear();
+    for (int i = 0; i < BUFFER_COUNT; i++) {
+      inputBuffersPendingDequeue.add(i);
+    }
+
+    if (isAsync) {
+      // Report the format as changed, to simulate adding codec specific info before making input
+      // buffers available.
+      HashMap<String, Object> format = new HashMap<>();
+      format.put("csd-0", ByteBuffer.wrap(new byte[] {0x13, 0x10}));
+      format.put("csd-1", ByteBuffer.wrap(new byte[0]));
+      postFakeNativeEvent(EVENT_CALLBACK, CB_OUTPUT_FORMAT_CHANGE, 0, format);
+
+      try {
+        makeInputBufferAvailable(inputBuffersPendingDequeue.take());
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+    }
+  }
+
+  /** Flushes the available output buffers. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void native_flush() {
+    // Reset input buffers only if the MediaCodec is in synchronous mode. If it is in asynchronous
+    // mode, the client needs to call start().
+    if (!isAsync) {
+      inputBuffersPendingDequeue.clear();
+      outputBuffersPendingDequeue.clear();
+      inputBuffersPendingQueuing.clear();
+      for (int i = 0; i < BUFFER_COUNT; i++) {
+        inputBuffersPendingDequeue.add(i);
+        ((Buffer) inputBuffers[i]).clear();
+      }
+    }
+  }
+
+  /** Returns the shadow buffers used for input or output. */
+  @Implementation
+  protected ByteBuffer[] getBuffers(boolean input) {
+    return input ? inputBuffers : outputBuffers;
+  }
+
+  /** Returns the input or output buffer corresponding to the given index, or null if invalid. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected ByteBuffer getBuffer(boolean input, int index) {
+    ByteBuffer[] buffers = input ? inputBuffers : outputBuffers;
+    return index >= 0 && index < buffers.length && !(input && codecOwnsInputBuffer(index))
+        ? buffers[index]
+        : null;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected int native_dequeueInputBuffer(long timeoutUs) {
+    checkState(!isAsync, "Attempting to deque buffer in Async mode.");
+    try {
+      Integer index;
+
+      if (timeoutUs < 0) {
+        index = inputBuffersPendingDequeue.take();
+      } else {
+        index = inputBuffersPendingDequeue.poll(timeoutUs, MICROSECONDS);
+      }
+
+      if (index == null) {
+        return MediaCodec.INFO_TRY_AGAIN_LATER;
+      }
+
+      inputBuffersPendingQueuing.add(index);
+      return index;
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      return MediaCodec.INFO_TRY_AGAIN_LATER;
+    }
+  }
+
+  /**
+   * Triggers presentation of the corresponding output buffer for the given input buffer, and passes
+   * the given metadata as buffer info.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void native_queueInputBuffer(
+      int index, int offset, int size, long presentationTimeUs, int flags) {
+    if (index < 0
+        || index >= inputBuffers.length
+        || codecOwnsInputBuffer(index)
+        || !canQueueInputBuffer(index)) {
+      throwCodecException(
+          /* errorCode= */ 0, /* actionCode= */ 0, "Input buffer not owned by client: " + index);
+    }
+
+    BufferInfo info = new BufferInfo();
+    info.set(offset, size, presentationTimeUs, flags);
+
+    makeOutputBufferAvailable(index, info);
+    inputBuffersPendingQueuing.remove(Integer.valueOf(index));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected int native_dequeueOutputBuffer(BufferInfo info, long timeoutUs) {
+    checkState(!isAsync, "Attempting to deque buffer in Async mode.");
+    try {
+      if (pendingOutputFormat != null) {
+        outputFormat = pendingOutputFormat;
+        pendingOutputFormat = null;
+        return MediaCodec.INFO_OUTPUT_FORMAT_CHANGED;
+      }
+
+      Integer index;
+      if (timeoutUs < 0) {
+        index = outputBuffersPendingDequeue.take();
+      } else {
+        index = outputBuffersPendingDequeue.poll(timeoutUs, MICROSECONDS);
+      }
+
+      if (index == null) {
+        return MediaCodec.INFO_TRY_AGAIN_LATER;
+      }
+
+      copyBufferInfo(outputBufferInfos[index], info);
+
+      return index;
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      return MediaCodec.INFO_TRY_AGAIN_LATER;
+    }
+  }
+
+  @Implementation
+  protected void releaseOutputBuffer(int index, boolean renderer) {
+    releaseOutputBuffer(index);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void releaseOutputBuffer(int index, long renderTimestampNs) {
+    releaseOutputBuffer(index);
+  }
+
+  private void releaseOutputBuffer(int index) {
+    if (outputBuffersPendingDequeue.contains(index)) {
+      throw new IllegalStateException("Cannot release a buffer when it's still owned by the codec");
+    }
+    makeInputBufferAvailable(index);
+  }
+
+  private void makeInputBufferAvailable(int index) {
+    if (index < 0 || index >= inputBuffers.length) {
+      throw new IndexOutOfBoundsException("Cannot make non-existent input available.");
+    }
+
+    // Reset the input buffer.
+    ((Buffer) inputBuffers[index]).clear();
+
+    if (isAsync) {
+      inputBuffersPendingQueuing.add(index);
+      // Signal input buffer availability.
+      postFakeNativeEvent(EVENT_CALLBACK, CB_INPUT_AVAILABLE, index, null);
+    } else {
+      inputBuffersPendingDequeue.add(index);
+    }
+  }
+
+  private void makeOutputBufferAvailable(int index, BufferInfo info) {
+    if (index < 0 || index >= outputBuffers.length) {
+      throw new IndexOutOfBoundsException("Cannot make non-existent output buffer available.");
+    }
+    Buffer inputBuffer = inputBuffers[index];
+    Buffer outputBuffer = outputBuffers[index];
+    BufferInfo outputBufferInfo = outputBufferInfos[index];
+
+    // Clears the output buffer, as it's already fully consumed.
+    outputBuffer.clear();
+
+    inputBuffer.position(info.offset).limit(info.offset + info.size);
+    fakeCodec.process(inputBuffers[index], outputBuffers[index]);
+
+    outputBufferInfo.flags = info.flags;
+    outputBufferInfo.size = outputBuffer.position();
+    outputBufferInfo.offset = info.offset;
+    outputBufferInfo.presentationTimeUs = info.presentationTimeUs;
+    outputBuffer.flip();
+
+    outputBuffersPendingDequeue.add(index);
+
+    if (isAsync) {
+      // Dequeue the buffer to signal its availablility to the client.
+      outputBuffersPendingDequeue.remove(Integer.valueOf(index));
+      // Signal output buffer availability.
+      postFakeNativeEvent(EVENT_CALLBACK, CB_OUTPUT_AVAILABLE, index, outputBufferInfos[index]);
+    }
+  }
+
+  private void postFakeNativeEvent(int what, int arg1, int arg2, @Nullable Object obj) {
+    ReflectionHelpers.callInstanceMethod(
+        MediaCodec.class,
+        realCodec,
+        "postEventFromNative",
+        ClassParameter.from(int.class, what),
+        ClassParameter.from(int.class, arg1),
+        ClassParameter.from(int.class, arg2),
+        ClassParameter.from(Object.class, obj));
+  }
+
+  private boolean codecOwnsInputBuffer(int index) {
+    return inputBuffersPendingDequeue.contains(index);
+  }
+
+  private boolean canQueueInputBuffer(int index) {
+    return inputBuffersPendingQueuing.contains(index);
+  }
+
+  /** Prevents calling Android-only methods on basic ByteBuffer objects. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void invalidateByteBuffer(@Nullable ByteBuffer[] buffers, int index) {}
+
+  /** Prevents calling Android-only methods on basic ByteBuffer objects. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void validateInputByteBuffer(@Nullable ByteBuffer[] buffers, int index) {}
+
+  /** Prevents calling Android-only methods on basic ByteBuffer objects. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void revalidateByteBuffer(@Nullable ByteBuffer[] buffers, int index) {}
+
+  /**
+   * Prevents calling Android-only methods on basic ByteBuffer objects. Replicates existing behavior
+   * adjusting buffer positions and limits.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void validateOutputByteBuffer(
+      @Nullable ByteBuffer[] buffers, int index, @NonNull BufferInfo info) {
+    if (buffers != null && index >= 0 && index < buffers.length) {
+      Buffer buffer = (Buffer) buffers[index];
+      if (buffer != null) {
+        buffer.limit(info.offset + info.size).position(info.offset);
+      }
+    }
+  }
+
+  /** Prevents calling Android-only methods on basic ByteBuffer objects. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void invalidateByteBuffers(@Nullable ByteBuffer[] buffers) {}
+
+  /** Prevents attempting to free non-direct ByteBuffer objects. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected void freeByteBuffer(@Nullable ByteBuffer buffer) {}
+
+  /** Shadows CodecBuffer to prevent attempting to free non-direct ByteBuffer objects. */
+  @Implements(className = "android.media.MediaCodec$BufferMap$CodecBuffer", minSdk = LOLLIPOP)
+  protected static class ShadowCodecBuffer {
+
+    // Seems to be required to work.
+    public ShadowCodecBuffer() {}
+
+    // Seems to be required to work.
+    @Implementation
+    protected void __constructor__() {}
+
+    /** Prevents attempting to free non-direct ByteBuffer objects. */
+    @Implementation
+    protected void free() {}
+  }
+
+  /** Returns a default {@link MediaFormat} if not set via {@link #getOutputFormat()}. */
+  @Implementation
+  protected MediaFormat getOutputFormat() {
+    if (outputFormat == null) {
+      return new MediaFormat();
+    }
+    return outputFormat;
+  }
+
+  private static void copyBufferInfo(BufferInfo from, BufferInfo to) {
+    to.set(from.offset, from.size, from.presentationTimeUs, from.flags);
+  }
+
+  private static MediaFormat recreateMediaFormatFromKeysValues(String[] keys, Object[] values) {
+    MediaFormat mediaFormat = new MediaFormat();
+
+    // This usage of `instanceof` is how API 29 `MediaFormat#getValueTypeForKey` works.
+    for (int i = 0; i < keys.length; i++) {
+      if (values[i] == null || values[i] instanceof ByteBuffer) {
+        mediaFormat.setByteBuffer(keys[i], (ByteBuffer) values[i]);
+      } else if (values[i] instanceof Integer) {
+        mediaFormat.setInteger(keys[i], (Integer) values[i]);
+      } else if (values[i] instanceof Long) {
+        mediaFormat.setLong(keys[i], (Long) values[i]);
+      } else if (values[i] instanceof Float) {
+        mediaFormat.setFloat(keys[i], (Float) values[i]);
+      } else if (values[i] instanceof String) {
+        mediaFormat.setString(keys[i], (String) values[i]);
+      } else {
+        throw new IllegalArgumentException("Invalid value for key.");
+      }
+    }
+
+    return mediaFormat;
+  }
+
+  /**
+   * Configuration that can be supplied to {@link ShadowMediaCodec} to simulate actual
+   * encoding/decoding.
+   */
+  public static final class CodecConfig {
+
+    private final int inputBufferSize;
+    private final int outputBufferSize;
+    private final Codec codec;
+
+    /**
+     * @param inputBufferSize the size of the buffers offered as input to the codec.
+     * @param outputBufferSize the size of the buffers offered as output from the codec.
+     * @param codec should be able to map from input size -> output size
+     */
+    public CodecConfig(int inputBufferSize, int outputBufferSize, Codec codec) {
+      this.inputBufferSize = inputBufferSize;
+      this.outputBufferSize = outputBufferSize;
+
+      this.codec = codec;
+    }
+
+    /**
+     * A codec is implemented as part of the configuration to allow the {@link ShadowMediaCodec} to
+     * simulate actual encoding/decoding. It's not expected for implementations to perform real
+     * encoding/decoding, but to produce a output similar in size ratio to the expected codec..
+     */
+    public interface Codec {
+
+      /** Move the bytes on the in buffer to the out buffer */
+      void process(ByteBuffer in, ByteBuffer out);
+      /** Called when the codec is configured. @see MediaCodec#configure */
+      default void onConfigured(
+          MediaFormat format, @Nullable Surface surface, @Nullable MediaCrypto crypto, int flags) {}
+    }
+  }
+
+  /** Reflectively throws a {@link CodecException}. */
+  private static void throwCodecException(int errorCode, int actionCode, String message) {
+    throw callConstructor(
+        MediaCodec.CodecException.class,
+        ClassParameter.from(Integer.TYPE, errorCode),
+        ClassParameter.from(Integer.TYPE, actionCode),
+        ClassParameter.from(String.class, message));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodecList.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodecList.java
new file mode 100644
index 0000000..d16fd12
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodecList.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.annotation.TargetApi;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Implementation of {@link MediaCodecList}.
+ *
+ * <p>Custom {@link MediaCodecInfo} can be created using {@link MediaCodecInfoBuilder} and added to
+ * the list of codecs via {@link #addCodec}.
+ */
+@Implements(value = MediaCodecList.class, minSdk = LOLLIPOP)
+public class ShadowMediaCodecList {
+
+  private static final List<MediaCodecInfo> mediaCodecInfos =
+      Collections.synchronizedList(new ArrayList<>());
+
+  /**
+   * Add a {@link MediaCodecInfo} to the list of MediaCodecInfos.
+   *
+   * @param mediaCodecInfo {@link MediaCodecInfo} describing the codec. Use {@link
+   *     MediaCodecInfoBuilder} to create an instance of it.
+   */
+  @TargetApi(Q)
+  public static void addCodec(MediaCodecInfo mediaCodecInfo) {
+    mediaCodecInfos.add(mediaCodecInfo);
+  }
+
+  @Resetter
+  public static void reset() {
+    mediaCodecInfos.clear();
+    ReflectionHelpers.setStaticField(MediaCodecList.class, "sAllCodecInfos", null);
+    ReflectionHelpers.setStaticField(MediaCodecList.class, "sRegularCodecInfos", null);
+  }
+
+  @Implementation
+  protected static int native_getCodecCount() {
+    return mediaCodecInfos.size();
+  }
+
+  @Implementation
+  protected static MediaCodecInfo getNewCodecInfoAt(int index) {
+    return mediaCodecInfos.get(index);
+  }
+
+  @Implementation(minSdk = M)
+  protected static Map<String, Object> native_getGlobalSettings() {
+    return new HashMap<>();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaController.java
new file mode 100644
index 0000000..809b5cc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaController.java
@@ -0,0 +1,200 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.NonNull;
+import android.app.PendingIntent;
+import android.media.MediaMetadata;
+import android.media.Rating;
+import android.media.session.MediaController;
+import android.media.session.MediaController.Callback;
+import android.media.session.MediaController.PlaybackInfo;
+import android.media.session.PlaybackState;
+import android.os.Bundle;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Implementation of {@link android.media.session.MediaController}. */
+@Implements(value = MediaController.class, minSdk = LOLLIPOP)
+public class ShadowMediaController {
+  @RealObject private MediaController realMediaController;
+  private PlaybackState playbackState;
+  private PlaybackInfo playbackInfo;
+  private MediaMetadata mediaMetadata;
+  private PendingIntent sessionActivity;
+
+  /**
+   * A value of RATING_NONE for ratingType indicates that rating media is not supported by the media
+   * session associated with the media controller
+   */
+  private int ratingType = Rating.RATING_NONE;
+
+  private final List<Callback> callbacks = new ArrayList<>();
+
+  /** Saves the package name for use inside the shadow. */
+  public void setPackageName(String packageName) {
+    ReflectionHelpers.setField(realMediaController, "mPackageName", packageName);
+  }
+
+  /**
+   * Saves the playbackState to control the return value of {@link
+   * MediaController#getPlaybackState()}.
+   */
+  public void setPlaybackState(PlaybackState playbackState) {
+    this.playbackState = playbackState;
+  }
+
+  /** Gets the playbackState set via {@link #setPlaybackState}. */
+  @Implementation
+  protected PlaybackState getPlaybackState() {
+    return playbackState;
+  }
+
+  /**
+   * Saves the playbackInfo to control the return value of {@link
+   * MediaController#getPlaybackInfo()}.
+   *
+   * <p>{@link PlaybackInfoBuilder} can be used to create PlaybackInfo instances.
+   */
+  public void setPlaybackInfo(PlaybackInfo playbackInfo) {
+    this.playbackInfo = playbackInfo;
+  }
+
+  /** Gets the playbackInfo set via {@link #setPlaybackInfo}. */
+  @Implementation
+  protected PlaybackInfo getPlaybackInfo() {
+    return playbackInfo;
+  }
+
+  /**
+   * Saves the mediaMetadata to control the return value of {@link MediaController#getMetadata()}.
+   */
+  public void setMetadata(MediaMetadata mediaMetadata) {
+    this.mediaMetadata = mediaMetadata;
+  }
+
+  /** Gets the mediaMetadata set via {@link #setMetadata}. */
+  @Implementation
+  protected MediaMetadata getMetadata() {
+    return mediaMetadata;
+  }
+
+  /**
+   * Saves the rating type to control the return value of {@link MediaController#getRatingType()}.
+   */
+  public void setRatingType(int ratingType) {
+    if (ratingType >= 0 && ratingType <= Rating.RATING_PERCENTAGE) {
+      this.ratingType = ratingType;
+    } else {
+      throw new IllegalArgumentException(
+          "Invalid RatingType value "
+              + ratingType
+              + ". The valid range is from 0 to "
+              + Rating.RATING_PERCENTAGE);
+    }
+  }
+
+  /** Gets the rating type set via {@link #setRatingType}. */
+  @Implementation
+  protected int getRatingType() {
+    return ratingType;
+  }
+
+  /**
+   * Saves the sessionActivty to control the return value of {@link
+   * MediaController#getSessionActivity()}.
+   */
+  public void setSessionActivity(PendingIntent sessionActivity) {
+    this.sessionActivity = sessionActivity;
+  }
+
+  /** Gets the playbackState set via {@link #setSessionActivity}. */
+  @Implementation
+  protected PendingIntent getSessionActivity() {
+    return sessionActivity;
+  }
+
+  /**
+   * Register callback and store it in the shadow to make it easier to check the state of the
+   * registered callbacks.
+   */
+  @Implementation
+  protected void registerCallback(@NonNull Callback callback) {
+    callbacks.add(callback);
+    reflector(MediaControllerReflector.class, realMediaController).registerCallback(callback);
+  }
+
+  /**
+   * Unregister callback and remove it from the shadow to make it easier to check the state of the
+   * registered callbacks.
+   */
+  @Implementation
+  protected void unregisterCallback(@NonNull Callback callback) {
+    callbacks.remove(callback);
+    reflector(MediaControllerReflector.class, realMediaController).unregisterCallback(callback);
+  }
+
+  /** Gets the callbacks registered to MediaController. */
+  public List<Callback> getCallbacks() {
+    return callbacks;
+  }
+
+  /** Executes all registered onPlaybackStateChanged callbacks. */
+  public void executeOnPlaybackStateChanged(PlaybackState playbackState) {
+    setPlaybackState(playbackState);
+
+    int messageId =
+        ReflectionHelpers.getStaticField(MediaController.class, "MSG_UPDATE_PLAYBACK_STATE");
+    ReflectionHelpers.callInstanceMethod(
+        MediaController.class,
+        realMediaController,
+        "postMessage",
+        ClassParameter.from(int.class, messageId),
+        ClassParameter.from(Object.class, playbackState),
+        ClassParameter.from(Bundle.class, new Bundle()));
+  }
+
+  /** Executes all registered onSessionDestroyed callbacks. */
+  public void executeOnSessionDestroyed() {
+    int messageId = ReflectionHelpers.getStaticField(MediaController.class, "MSG_DESTROYED");
+    ReflectionHelpers.callInstanceMethod(
+        MediaController.class,
+        realMediaController,
+        "postMessage",
+        ClassParameter.from(int.class, messageId),
+        ClassParameter.from(Object.class, null),
+        ClassParameter.from(Bundle.class, null));
+  }
+
+  /** Executes all registered onMetadataChanged callbacks. */
+  public void executeOnMetadataChanged(MediaMetadata metadata) {
+    setMetadata(metadata);
+
+    int messageId = ReflectionHelpers.getStaticField(MediaController.class, "MSG_UPDATE_METADATA");
+    ReflectionHelpers.callInstanceMethod(
+        MediaController.class,
+        realMediaController,
+        "postMessage",
+        ClassParameter.from(int.class, messageId),
+        ClassParameter.from(Object.class, metadata),
+        ClassParameter.from(Bundle.class, new Bundle()));
+  }
+
+  @ForType(MediaController.class)
+  interface MediaControllerReflector {
+
+    @Direct
+    void registerCallback(@NonNull Callback callback);
+
+    @Direct
+    void unregisterCallback(@NonNull Callback callback);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaExtractor.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaExtractor.java
new file mode 100644
index 0000000..bd06be2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaExtractor.java
@@ -0,0 +1,211 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static java.lang.Math.min;
+import static org.robolectric.shadows.util.DataSource.toDataSource;
+
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.media.MediaDataSource;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.net.Uri;
+import java.io.FileDescriptor;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadows.util.DataSource;
+
+/**
+ * A shadow for the MediaExtractor class.
+ *
+ * <p>Returns data previously injected by {@link #addTrack(DataSource, MediaFormat, byte[])}.
+ *
+ * <p>Note several limitations, due to not using actual media codecs for decoding:
+ *
+ * <ul>
+ *   <li>Only one track may be selected at a time; multi-track selection is not supported.
+ *   <li>{@link #advance()} will advance by the size of the last read (i.e. the return value of the
+ *       last call to {@link #readSampleData(ByteBuffer, int)}).
+ *   <li>{@link MediaExtractor#getSampleTime()} and {@link MediaExtractor#getSampleSize()} are
+ *       unimplemented.
+ *   <li>{@link MediaExtractor#seekTo()} is unimplemented.
+ * </ul>
+ */
+@Implements(MediaExtractor.class)
+public class ShadowMediaExtractor {
+
+  private static class TrackInfo {
+    MediaFormat format;
+    byte[] sampleData;
+  }
+
+  private static final Map<DataSource, List<TrackInfo>> tracksMap = new HashMap<>();
+
+  private List<TrackInfo> tracks;
+  private int[] trackSampleReadPositions;
+  private int[] trackLastReadSize;
+  private int selectedTrackIndex = -1;
+
+  /**
+   * Adds a track of data to an associated {@link org.robolectric.shadows.util.DataSource}.
+   *
+   * @param format the format which will be returned by {@link MediaExtractor#getTrackFormat(int)}
+   * @param sampleData the data which will be iterated upon and returned by {@link
+   *     MediaExtractor#readSampleData(ByteBuffer, int)}.
+   */
+  public static void addTrack(DataSource dataSource, MediaFormat format, byte[] sampleData) {
+    TrackInfo trackInfo = new TrackInfo();
+    trackInfo.format = format;
+    trackInfo.sampleData = sampleData;
+    tracksMap.putIfAbsent(dataSource, new ArrayList<TrackInfo>());
+    List<TrackInfo> tracks = tracksMap.get(dataSource);
+    tracks.add(trackInfo);
+  }
+
+  private void setDataSource(DataSource dataSource) {
+    if (tracksMap.containsKey(dataSource)) {
+      this.tracks = tracksMap.get(dataSource);
+    } else {
+      this.tracks = new ArrayList<>();
+    }
+
+    this.trackSampleReadPositions = new int[tracks.size()];
+    Arrays.fill(trackSampleReadPositions, 0);
+    this.trackLastReadSize = new int[tracks.size()];
+    Arrays.fill(trackLastReadSize, 0);
+  }
+
+  @Implementation(minSdk = N)
+  protected void setDataSource(AssetFileDescriptor assetFileDescriptor) {
+    setDataSource(toDataSource(assetFileDescriptor));
+  }
+
+  @Implementation
+  protected void setDataSource(Context context, Uri uri, Map<String, String> headers) {
+    setDataSource(toDataSource(context, uri, headers));
+  }
+
+  @Implementation
+  protected void setDataSource(FileDescriptor fileDescriptor) {
+    setDataSource(toDataSource(fileDescriptor));
+  }
+
+  @Implementation(minSdk = M)
+  protected void setDataSource(MediaDataSource mediaDataSource) {
+    setDataSource(toDataSource(mediaDataSource));
+  }
+
+  @Implementation
+  protected void setDataSource(FileDescriptor fileDescriptor, long offset, long length) {
+    setDataSource(toDataSource(fileDescriptor, offset, length));
+  }
+
+  @Implementation
+  protected void setDataSource(String path) {
+    setDataSource(toDataSource(path));
+  }
+
+  @Implementation
+  protected void setDataSource(String path, Map<String, String> headers) {
+    setDataSource(toDataSource(path));
+  }
+
+  @Implementation
+  protected boolean advance() {
+    if (selectedTrackIndex == -1) {
+      throw new IllegalStateException("Called advance() with no selected track");
+    }
+
+    int readPosition = trackSampleReadPositions[selectedTrackIndex];
+    int trackDataLength = tracks.get(selectedTrackIndex).sampleData.length;
+    if (readPosition >= trackDataLength) {
+      return false;
+    }
+
+    trackSampleReadPositions[selectedTrackIndex] += trackLastReadSize[selectedTrackIndex];
+    return true;
+  }
+
+  @Implementation
+  protected int getSampleTrackIndex() {
+    return selectedTrackIndex;
+  }
+
+  @Implementation
+  protected final int getTrackCount() {
+    return tracks.size();
+  }
+
+  @Implementation
+  protected MediaFormat getTrackFormat(int index) {
+    if (index >= tracks.size()) {
+      throw new ArrayIndexOutOfBoundsException(
+          "Called getTrackFormat() with index:"
+              + index
+              + ", beyond number of tracks:"
+              + tracks.size());
+    }
+
+    return tracks.get(index).format;
+  }
+
+  @Implementation
+  protected int readSampleData(ByteBuffer byteBuf, int offset) {
+    if (selectedTrackIndex == -1) {
+      return 0;
+    }
+    int currentReadPosition = trackSampleReadPositions[selectedTrackIndex];
+    TrackInfo trackInfo = tracks.get(selectedTrackIndex);
+    int trackDataLength = trackInfo.sampleData.length;
+    if (currentReadPosition >= trackDataLength) {
+      return -1;
+    }
+
+    int length = min(byteBuf.capacity(), trackDataLength - currentReadPosition);
+    byteBuf.put(trackInfo.sampleData, currentReadPosition, length);
+    trackLastReadSize[selectedTrackIndex] = length;
+    return length;
+  }
+
+  @Implementation
+  protected void selectTrack(int index) {
+    if (selectedTrackIndex != -1) {
+      throw new IllegalStateException(
+          "Called selectTrack() when there is already a track selected; call unselectTrack() first."
+              + " ShadowMediaExtractor does not support multiple track selection.");
+    }
+    if (index >= tracks.size()) {
+      throw new ArrayIndexOutOfBoundsException(
+          "Called selectTrack() with index:"
+              + index
+              + ", beyond number of tracks:"
+              + tracks.size());
+    }
+
+    selectedTrackIndex = index;
+  }
+
+  @Implementation
+  protected void unselectTrack(int index) {
+    if (selectedTrackIndex != index) {
+      throw new IllegalStateException(
+          "Called unselectTrack() on a track other than the single selected track."
+              + " ShadowMediaExtractor does not support multiple track selection.");
+    }
+    selectedTrackIndex = -1;
+  }
+
+  @Resetter
+  public static void reset() {
+    tracksMap.clear();
+    DataSource.reset();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaMetadataRetriever.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaMetadataRetriever.java
new file mode 100644
index 0000000..b38da28
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaMetadataRetriever.java
@@ -0,0 +1,217 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static org.robolectric.shadows.util.DataSource.toDataSource;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.media.MediaDataSource;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import java.io.FileDescriptor;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadows.util.DataSource;
+
+@Implements(MediaMetadataRetriever.class)
+public class ShadowMediaMetadataRetriever {
+  private DataSource dataSource;
+  private static final Map<DataSource, Map<Integer, String>> metadata = new HashMap<>();
+  private static final Map<DataSource, Map<Long, Bitmap>> frames = new HashMap<>();
+  private static final Map<DataSource, Map<String, Bitmap>> scaledFrames = new HashMap<>();
+  private static final Map<DataSource, RuntimeException> exceptions = new HashMap<>();
+
+  public void setDataSource(DataSource dataSource) {
+    RuntimeException e = exceptions.get(dataSource);
+    if (e != null) {
+      e.fillInStackTrace();
+      throw e;
+    }
+    this.dataSource = dataSource;
+  }
+
+  @Implementation
+  protected void setDataSource(String path) {
+    setDataSource(toDataSource(path));
+  }
+
+  @Implementation
+  protected void setDataSource(Context context, Uri uri) {
+    setDataSource(toDataSource(context, uri));
+  }
+
+  @Implementation
+  protected void setDataSource(String uri, Map<String, String> headers) {
+    setDataSource(toDataSource(uri, headers));
+  }
+
+  @Implementation
+  protected void setDataSource(FileDescriptor fd, long offset, long length) {
+    setDataSource(toDataSource(fd, offset, length));
+  }
+
+  @Implementation(minSdk = M)
+  protected void setDataSource(MediaDataSource mediaDataSource) {
+    setDataSource(toDataSource(mediaDataSource));
+  }
+
+  @Implementation
+  protected String extractMetadata(int keyCode) {
+    if (metadata.containsKey(dataSource)) {
+      return metadata.get(dataSource).get(keyCode);
+    }
+    return null;
+  }
+
+  @Implementation
+  protected Bitmap getFrameAtTime(long timeUs, int option) {
+    return (frames.containsKey(dataSource) ? frames.get(dataSource).get(timeUs) : null);
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected Bitmap getScaledFrameAtTime(long timeUs, int option, int dstWidth, int dstHeight) {
+    return (scaledFrames.containsKey(dataSource)
+        ? scaledFrames.get(dataSource).get(getScaledFrameKey(timeUs, dstWidth, dstHeight))
+        : null);
+  }
+
+  /**
+   * Configures an exception to be thrown when {@link #setDataSource} is called for the given data
+   * source.
+   *
+   * @param ds the data source that will trigger an exception
+   * @param e the exception to trigger, or null to avoid throwing an exception.
+   */
+  public static void addException(DataSource ds, RuntimeException e) {
+    exceptions.put(ds, e);
+  }
+
+  public static void addMetadata(DataSource ds, int keyCode, String value) {
+    if (!metadata.containsKey(ds)) {
+      metadata.put(ds, new HashMap<Integer, String>());
+    }
+    metadata.get(ds).put(keyCode, value);
+  }
+
+  /**
+   * Adds the given keyCode/value pair for the given data source. Uses {@code path} to call {@link
+   * org.robolectric.shadows.util.DataSource#toDataSource(String)} and then calls {@link
+   * #addMetadata(DataSource, int, String)}. This method is retained mostly for backwards
+   * compatibility; you can call {@link #addMetadata(DataSource, int, String)} directly.
+   *
+   * @param path the path to the data source whose metadata is being set.
+   * @param keyCode the keyCode for the metadata being set, as used by {@link
+   *     MediaMetadataRetriever#extractMetadata(int)}.
+   * @param value the value for the specified metadata.
+   */
+  public static void addMetadata(String path, int keyCode, String value) {
+    addMetadata(toDataSource(path), keyCode, value);
+  }
+
+  public static void addFrame(DataSource ds, long time, Bitmap bitmap) {
+    if (!frames.containsKey(ds)) {
+      frames.put(ds, new HashMap<Long, Bitmap>());
+    }
+    frames.get(ds).put(time, bitmap);
+  }
+
+  public static void addScaledFrame(
+      DataSource ds, long time, int dstWidth, int dstHeight, Bitmap bitmap) {
+    if (!scaledFrames.containsKey(ds)) {
+      scaledFrames.put(ds, new HashMap<String, Bitmap>());
+    }
+    scaledFrames.get(ds).put(getScaledFrameKey(time, dstWidth, dstHeight), bitmap);
+  }
+
+  /**
+   * Adds the given bitmap at the given time for the given data source. Uses {@code path} to call
+   * {@link org.robolectric.shadows.util.DataSource#toDataSource(String)} and then calls {@link
+   * #addFrame(DataSource, long, Bitmap)}. This method is retained mostly for backwards
+   * compatibility; you can call {@link #addFrame(DataSource, long, Bitmap)} directly.
+   *
+   * @param path the path to the data source.
+   * @param time the playback time at which the specified bitmap should be retrieved.
+   * @param bitmap the bitmap to retrieve.
+   */
+  public static void addFrame(String path, long time, Bitmap bitmap) {
+    addFrame(toDataSource(path), time, bitmap);
+  }
+
+  /**
+   * Adds the given bitmap at the given time for the given data source. Uses {@code path} to call
+   * {@link org.robolectric.shadows.util.DataSource#toDataSource(Context, Uri)} and then calls
+   * {@link #addFrame(DataSource, long, Bitmap)}. This method is retained mostly for backwards
+   * compatibility; you can call {@link #addFrame(DataSource, long, Bitmap)} directly.
+   *
+   * @param context the Context object to match on the data source.
+   * @param uri the Uri of the data source.
+   * @param time the playback time at which the specified bitmap should be retrieved.
+   * @param bitmap the bitmap to retrieve.
+   */
+  public static void addFrame(Context context, Uri uri, long time, Bitmap bitmap) {
+    addFrame(toDataSource(context, uri), time, bitmap);
+  }
+
+  /**
+   * Adds the given bitmap at the given time for the given data source. Uses {@code path} to call
+   * {@link org.robolectric.shadows.util.DataSource#toDataSource(String, Map)} and then calls {@link
+   * #addFrame(DataSource, long, Bitmap)}. This method is retained mostly for backwards
+   * compatibility; you can call {@link #addFrame(DataSource, long, Bitmap)} directly.
+   *
+   * @param uri the Uri of the data source.
+   * @param headers the headers to use when requesting the specified uri.
+   * @param time the playback time at which the specified bitmap should be retrieved.
+   * @param bitmap the bitmap to retrieve.
+   */
+  public static void addFrame(String uri, Map<String, String> headers, long time, Bitmap bitmap) {
+    addFrame(toDataSource(uri, headers), time, bitmap);
+  }
+
+  /**
+   * Adds the given bitmap at the given time for the given data source. Uses {@code path} to call
+   * {@link org.robolectric.shadows.util.DataSource#toDataSource(FileDescriptor)} and then calls
+   * {@link #addFrame(DataSource, long, Bitmap)}. This method is retained mostly for backwards
+   * compatibility; you can call {@link #addFrame(DataSource, long, Bitmap)} directly.
+   *
+   * @param fd file descriptor of the data source.
+   * @param time the playback time at which the specified bitmap should be retrieved.
+   * @param bitmap the bitmap to retrieve.
+   */
+  public static void addFrame(FileDescriptor fd, long time, Bitmap bitmap) {
+    addFrame(toDataSource(fd), time, bitmap);
+  }
+
+  /**
+   * Adds the given bitmap at the given time for the given data source. Uses {@code path} to call
+   * {@link org.robolectric.shadows.util.DataSource#toDataSource(FileDescriptor, long, long)} and
+   * then calls {@link #addFrame(DataSource, long, Bitmap)}. This method is retained mostly for
+   * backwards compatibility; you can call {@link #addFrame(DataSource, long, Bitmap)} directly.
+   *
+   * @param fd file descriptor of the data source.
+   * @param offset the byte offset within the specified file from which to start reading the data.
+   * @param length the number of bytes to read from the file.
+   * @param time the playback time at which the specified bitmap should be retrieved.
+   * @param bitmap the bitmap to retrieve.
+   */
+  public static void addFrame(
+      FileDescriptor fd, long offset, long length, long time, Bitmap bitmap) {
+    addFrame(toDataSource(fd, offset, length), time, bitmap);
+  }
+
+  @Resetter
+  public static void reset() {
+    metadata.clear();
+    frames.clear();
+    scaledFrames.clear();
+    exceptions.clear();
+    DataSource.reset();
+  }
+
+  private static String getScaledFrameKey(long time, int dstWidth, int dstHeight) {
+    return String.format("%d_%dx%d", time, dstWidth, dstHeight);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaMuxer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaMuxer.java
new file mode 100644
index 0000000..56e893c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaMuxer.java
@@ -0,0 +1,166 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+
+import android.annotation.NonNull;
+import android.media.MediaCodec;
+import android.media.MediaMuxer;
+import android.media.MediaMuxer.Format;
+import dalvik.system.CloseGuard;
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Implementation of {@link android.media.MediaMuxer} which directly passes input bytes to the
+ * specified file, with no modification.
+ */
+@Implements(value = MediaMuxer.class, minSdk = LOLLIPOP)
+public class ShadowMediaMuxer {
+  // Maps between 'native' ids and corresponding output streams.
+  private static final ConcurrentHashMap<Long, FileOutputStream> outputStreams =
+      new ConcurrentHashMap<>();
+
+  // Maps between 'native' ids and AtomicInteger objects tracking next track indices.
+  private static final ConcurrentHashMap<Long, AtomicInteger> nextTrackIndices =
+      new ConcurrentHashMap<>();
+
+  // Maps between file descriptors and their original output stream.
+  private static final ConcurrentHashMap<FileDescriptor, FileOutputStream> fdToStream =
+      new ConcurrentHashMap<>();
+
+  private static final Random random = new Random();
+
+  // Keep in sync with MediaMuxer.java.
+  private static final int MUXER_STATE_INITIALIZED = 0;
+
+  @RealObject private MediaMuxer realMuxer;
+
+  /**
+   * Opens a FileOutputStream for the given path, and sets muxer state.
+   *
+   * <p>This needs to be shadowed, because the current MediaMuxer constructor opens a
+   * RandomAccessFile, passes only the FileDescriptor along, and then closes the file. Since there
+   * does not appear to be an easy way to go from FileDescriptor to a writeable stream in Java, this
+   * method overrides that behavior to instead open and maintain a FileOutputStream.
+   */
+  @Implementation
+  protected void __constructor__(@NonNull String path, @Format int format) throws IOException {
+    if (path == null) {
+      throw new IllegalArgumentException("path must not be null");
+    }
+
+    // Create a stream, and cache a mapping from file descriptor to stream.
+    FileOutputStream stream = new FileOutputStream(path);
+    FileDescriptor fd = stream.getFD();
+    fdToStream.put(fd, stream);
+
+    // Initialize the CloseGuard and last track index, since they are otherwise null and 0.
+    CloseGuard guard = CloseGuard.get();
+    ReflectionHelpers.setField(MediaMuxer.class, realMuxer, "mCloseGuard", guard);
+    ReflectionHelpers.setField(MediaMuxer.class, realMuxer, "mLastTrackIndex", -1);
+
+    // Pre-OREO jumps straight to nativeSetup inside the constructor.
+    if (RuntimeEnvironment.getApiLevel() < O) {
+      long nativeObject = nativeSetup(fd, format);
+      ReflectionHelpers.setField(MediaMuxer.class, realMuxer, "mNativeObject", nativeObject);
+      ReflectionHelpers.setField(MediaMuxer.class, realMuxer, "mState", MUXER_STATE_INITIALIZED);
+      guard.open("release");
+    } else {
+      ReflectionHelpers.callInstanceMethod(
+          MediaMuxer.class,
+          realMuxer,
+          "setUpMediaMuxer",
+          ReflectionHelpers.ClassParameter.from(FileDescriptor.class, fd),
+          ReflectionHelpers.ClassParameter.from(int.class, format));
+    }
+  }
+
+  /**
+   * Generates and returns an internal id to track the FileOutputStream corresponding to individual
+   * MediaMuxer instances.
+   */
+  @Implementation
+  protected static long nativeSetup(@NonNull FileDescriptor fd, int format) throws IOException {
+    FileOutputStream outputStream = fdToStream.get(fd);
+
+    long potentialKey;
+    do {
+      potentialKey = random.nextLong();
+    } while (potentialKey == 0 || outputStreams.putIfAbsent(potentialKey, outputStream) != null);
+
+    nextTrackIndices.put(potentialKey, new AtomicInteger(0));
+    return potentialKey;
+  }
+
+  /** Returns an incremented track id for the associated muxer. */
+  @Implementation
+  protected static int nativeAddTrack(
+      long nativeObject, @NonNull String[] keys, @NonNull Object[] values) {
+    AtomicInteger nextTrackIndex = nextTrackIndices.get(nativeObject);
+    if (nextTrackIndex == null) {
+      throw new IllegalStateException("No next track index configured for key: " + nativeObject);
+    }
+
+    return nextTrackIndex.getAndIncrement();
+  }
+
+  /** Writes the given data to the FileOutputStream for the associated muxer. */
+  @Implementation
+  protected static void nativeWriteSampleData(
+      long nativeObject,
+      int trackIndex,
+      @NonNull ByteBuffer byteBuf,
+      int offset,
+      int size,
+      long presentationTimeUs,
+      @MediaCodec.BufferFlag int flags) {
+    byte[] bytes = new byte[size];
+    int oldPosition = byteBuf.position();
+    // The offset is the start-offset of the data in the buffer. We should use input offset for
+    // byteBuf to read bytes, instead of byteBuf current offset.
+    // See https://developer.android.com/reference/android/media/MediaCodec.BufferInfo#offset.
+    byteBuf.position(offset);
+    byteBuf.get(bytes, 0, size);
+    byteBuf.position(oldPosition);
+
+    try {
+      getStream(nativeObject).write(bytes);
+    } catch (IOException e) {
+      throw new RuntimeException("Unable to write to temporary file.", e);
+    }
+  }
+
+  /** Closes the FileOutputStream for the associated muxer. */
+  @Implementation
+  protected static void nativeStop(long nativeObject) {
+    try {
+      // Close the output stream.
+      getStream(nativeObject).close();
+
+      // Clear the stream from both internal caches.
+      fdToStream.remove(outputStreams.remove(nativeObject).getFD());
+    } catch (IOException e) {
+      throw new RuntimeException("Unable to close temporary file.", e);
+    }
+  }
+
+  private static FileOutputStream getStream(long streamKey) {
+    FileOutputStream stream = outputStreams.get(streamKey);
+    if (stream == null) {
+      throw new IllegalStateException("No output stream configured for key: " + streamKey);
+    }
+
+    return stream;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java
new file mode 100644
index 0000000..5873560
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java
@@ -0,0 +1,1544 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.END;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.ERROR;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.IDLE;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.INITIALIZED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PAUSED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PLAYBACK_COMPLETED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PREPARED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.PREPARING;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.STARTED;
+import static org.robolectric.shadows.ShadowMediaPlayer.State.STOPPED;
+import static org.robolectric.shadows.util.DataSource.toDataSource;
+
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.media.MediaDataSource;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.net.HttpCookie;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Random;
+import java.util.TreeMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.util.DataSource;
+
+/**
+ * Automated testing of media playback can be a difficult thing - especially testing that your code
+ * properly handles asynchronous errors and events. This near impossible task is made quite
+ * straightforward using this implementation of {@link MediaPlayer} with Robolectric.
+ *
+ * <p>This shadow implementation provides much of the functionality needed to emulate {@link
+ * MediaPlayer} initialization and playback behavior without having to play actual media files. A
+ * summary of the features included are:
+ *
+ * <ul>
+ *   <li>Construction-time callback hook {@link CreateListener} so that newly-created {@link
+ *       MediaPlayer} instances can have their shadows configured before they are used.
+ *   <li>Emulation of the {@link android.media.MediaPlayer.OnCompletionListener
+ *       OnCompletionListener}, {@link android.media.MediaPlayer.OnErrorListener OnErrorListener},
+ *       {@link android.media.MediaPlayer.OnInfoListener OnInfoListener}, {@link
+ *       android.media.MediaPlayer.OnPreparedListener OnPreparedListener} and {@link
+ *       android.media.MediaPlayer.OnSeekCompleteListener OnSeekCompleteListener}.
+ *   <li>Full support of the {@link MediaPlayer} internal states and their transition map.
+ *   <li>Configure time parameters such as playback duration, preparation delay and {@link
+ *       #setSeekDelay(int)}.
+ *   <li>Emulation of asynchronous callback events during playback through Robolectric's scheduling
+ *       system using the {@link MediaInfo} inner class.
+ *   <li>Emulation of error behavior when methods are called from invalid states, or to throw
+ *       assertions when methods are invoked in invalid states (using {@link
+ *       #setInvalidStateBehavior}).
+ *   <li>Emulation of different playback behaviors based on the current data source, as passed in to
+ *       {@link #setDataSource(String) setDataSource()}, using {@link #addMediaInfo} or {@link
+ *       #setMediaInfoProvider(MediaInfoProvider)}.
+ *   <li>Emulation of exceptions when calling {@link #setDataSource} using {@link #addException}.
+ * </ul>
+ *
+ * <b>Note</b>: One gotcha with this shadow is that you need to either configure an exception using
+ * {@link #addException(DataSource, IOException)} or a {@link ShadowMediaPlayer.MediaInfo} instance
+ * for that data source using {@link #addMediaInfo(DataSource, MediaInfo)} or {@link
+ * #setMediaInfoProvider(MediaInfoProvider)} <i>before</i> calling {@link #setDataSource}, otherwise
+ * you'll get an {@link IllegalArgumentException}.
+ *
+ * <p>The current features of {@code ShadowMediaPlayer} were focused on development for testing
+ * playback of audio tracks. Thus support for emulating timed text and video events is incomplete.
+ * None of these features would be particularly onerous to add/fix - contributions welcome, of
+ * course!
+ *
+ * @author Fr Jeremy Krieg, Holy Monastery of St Nectarios, Adelaide, Australia
+ */
+@Implements(MediaPlayer.class)
+public class ShadowMediaPlayer extends ShadowPlayerBase {
+  @Implementation
+  protected static void __staticInitializer__() {
+    // don't bind the JNI library
+  }
+
+  /** Provides a {@link MediaInfo} for a given {@link DataSource}. */
+  public interface MediaInfoProvider {
+    MediaInfo get(DataSource dataSource);
+  }
+
+  /**
+   * Listener that is called when a new MediaPlayer is constructed.
+   *
+   * @see #setCreateListener(CreateListener)
+   */
+  protected static CreateListener createListener;
+
+  private static final Map<DataSource, Exception> exceptions = new HashMap<>();
+  private static final Map<DataSource, MediaInfo> mediaInfoMap = new HashMap<>();
+
+  private static final MediaInfoProvider DEFAULT_MEDIA_INFO_PROVIDER = mediaInfoMap::get;
+  private static MediaInfoProvider mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER;
+
+  @RealObject private MediaPlayer player;
+
+  /**
+   * Possible states for the media player to be in. These states are as defined in the documentation
+   * for {@link android.media.MediaPlayer}.
+   */
+  public enum State {
+    IDLE,
+    INITIALIZED,
+    PREPARING,
+    PREPARED,
+    STARTED,
+    STOPPED,
+    PAUSED,
+    PLAYBACK_COMPLETED,
+    END,
+    ERROR
+  }
+
+  /**
+   * Possible behavior modes for the media player when a method is invoked in an invalid state.
+   *
+   * @see #setInvalidStateBehavior
+   */
+  public enum InvalidStateBehavior {
+    SILENT,
+    EMULATE,
+    ASSERT
+  }
+
+  /**
+   * Reference to the next playback event scheduled to run. We keep a reference to this handy in
+   * case we need to cancel it.
+   */
+  private RunList nextPlaybackEvent;
+
+  /**
+   * Class for grouping events that are meant to fire at the same time. Also schedules the next
+   * event to run.
+   */
+  @SuppressWarnings("serial")
+  private static class RunList extends ArrayList<MediaEvent> implements MediaEvent {
+
+    public RunList() {
+      // Set the default size to one as most of the time we will
+      // only have one event.
+      super(1);
+    }
+
+    @Override
+    public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+      for (MediaEvent e : this) {
+        e.run(mp, smp);
+      }
+    }
+  }
+
+  public interface MediaEvent {
+    public void run(MediaPlayer mp, ShadowMediaPlayer smp);
+  }
+
+  /**
+   * Class specifying information for an emulated media object. Used by ShadowMediaPlayer when
+   * setDataSource() is called to populate the shadow player with the specified values.
+   */
+  public static class MediaInfo {
+    public int duration;
+    private int preparationDelay;
+
+    /** Map that maps time offsets to media events. */
+    public TreeMap<Integer, RunList> events = new TreeMap<>();
+
+    /**
+     * Creates a new {@code MediaInfo} object with default duration (1000ms) and default preparation
+     * delay (0ms).
+     */
+    public MediaInfo() {
+      this(1000, 0);
+    }
+
+    /**
+     * Creates a new {@code MediaInfo} object with the given duration and preparation delay. A
+     * completion callback event is scheduled at {@code duration} ms from the end.
+     *
+     * @param duration the duration (in ms) of this emulated media. A callback event will be
+     *     scheduled at this offset to stop playback simulation and invoke the completion callback.
+     * @param preparationDelay the preparation delay (in ms) to emulate for this media. If set to
+     *     -1, then {@link #prepare()} will complete instantly but {@link #prepareAsync()} will not
+     *     complete automatically; you will need to call {@link #invokePreparedListener()} manually.
+     */
+    public MediaInfo(int duration, int preparationDelay) {
+      this.duration = duration;
+      this.preparationDelay = preparationDelay;
+
+      scheduleEventAtOffset(duration, completionCallback);
+    }
+
+    /**
+     * Retrieves the current preparation delay for this media.
+     *
+     * @return The current preparation delay (in ms).
+     */
+    public int getPreparationDelay() {
+      return preparationDelay;
+    }
+
+    /**
+     * Sets the current preparation delay for this media.
+     *
+     * @param preparationDelay the new preparation delay (in ms).
+     */
+    public void setPreparationDelay(int preparationDelay) {
+      this.preparationDelay = preparationDelay;
+    }
+
+    /**
+     * Schedules a generic event to run at the specified playback offset. Events are run on the
+     * thread on which the {@link android.media.MediaPlayer MediaPlayer} was created.
+     *
+     * @param offset the offset from the start of playback at which this event will run.
+     * @param event the event to run.
+     */
+    public void scheduleEventAtOffset(int offset, MediaEvent event) {
+      RunList runList = events.get(offset);
+      if (runList == null) {
+        // Given that most run lists will only contain one event,
+        // we use 1 as the default capacity.
+        runList = new RunList();
+        events.put(offset, runList);
+      }
+      runList.add(event);
+    }
+
+    /**
+     * Schedules an error event to run at the specified playback offset. A reference to the actual
+     * MediaEvent that is scheduled is returned, which can be used in a subsequent call to {@link
+     * #removeEventAtOffset}.
+     *
+     * @param offset the offset from the start of playback at which this error will trigger.
+     * @param what the value for the {@code what} parameter to use in the call to {@link
+     *     android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int) onError()}.
+     * @param extra the value for the {@code extra} parameter to use in the call to {@link
+     *     android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int) onError()}.
+     * @return A reference to the MediaEvent object that was created and scheduled.
+     */
+    public MediaEvent scheduleErrorAtOffset(int offset, int what, int extra) {
+      ErrorCallback callback = new ErrorCallback(what, extra);
+      scheduleEventAtOffset(offset, callback);
+      return callback;
+    }
+
+    /**
+     * Schedules an info event to run at the specified playback offset. A reference to the actual
+     * MediaEvent that is scheduled is returned, which can be used in a subsequent call to {@link
+     * #removeEventAtOffset}.
+     *
+     * @param offset the offset from the start of playback at which this event will trigger.
+     * @param what the value for the {@code what} parameter to use in the call to {@link
+     *     android.media.MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int) onInfo()}.
+     * @param extra the value for the {@code extra} parameter to use in the call to {@link
+     *     android.media.MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int) onInfo()}.
+     * @return A reference to the MediaEvent object that was created and scheduled.
+     */
+    public MediaEvent scheduleInfoAtOffset(int offset, final int what, final int extra) {
+      MediaEvent callback =
+          new MediaEvent() {
+            @Override
+            public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+              smp.invokeInfoListener(what, extra);
+            }
+          };
+      scheduleEventAtOffset(offset, callback);
+      return callback;
+    }
+
+    /**
+     * Schedules a simulated buffer underrun event to run at the specified playback offset. A
+     * reference to the actual MediaEvent that is scheduled is returned, which can be used in a
+     * subsequent call to {@link #removeEventAtOffset}.
+     *
+     * <p>This event will issue an {@link MediaPlayer.OnInfoListener#onInfo onInfo()} callback with
+     * {@link MediaPlayer#MEDIA_INFO_BUFFERING_START} to signal the start of buffering and then call
+     * {@link #doStop()} to internally pause playback. Finally it will schedule an event to fire
+     * after {@code length} ms which fires a {@link MediaPlayer#MEDIA_INFO_BUFFERING_END} info event
+     * and invokes {@link #doStart()} to resume playback.
+     *
+     * @param offset the offset from the start of playback at which this underrun will trigger.
+     * @param length the length of time (in ms) for which playback will be paused.
+     * @return A reference to the MediaEvent object that was created and scheduled.
+     */
+    public MediaEvent scheduleBufferUnderrunAtOffset(int offset, final int length) {
+      final MediaEvent restart =
+          new MediaEvent() {
+            @Override
+            public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+              smp.invokeInfoListener(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0);
+              smp.doStart();
+            }
+          };
+      MediaEvent callback =
+          new MediaEvent() {
+            @Override
+            public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+              smp.doStop();
+              smp.invokeInfoListener(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0);
+              smp.postEventDelayed(restart, length);
+            }
+          };
+      scheduleEventAtOffset(offset, callback);
+      return callback;
+    }
+
+    /**
+     * Removes the specified event from the playback schedule at the given playback offset.
+     *
+     * @param offset the offset at which the event was scheduled.
+     * @param event the event to remove.
+     * @see ShadowMediaPlayer.MediaInfo#removeEvent(ShadowMediaPlayer.MediaEvent)
+     */
+    public void removeEventAtOffset(int offset, MediaEvent event) {
+      RunList runList = events.get(offset);
+      if (runList != null) {
+        runList.remove(event);
+        if (runList.isEmpty()) {
+          events.remove(offset);
+        }
+      }
+    }
+
+    /**
+     * Removes the specified event from the playback schedule at all playback offsets where it has
+     * been scheduled.
+     *
+     * @param event the event to remove.
+     * @see ShadowMediaPlayer.MediaInfo#removeEventAtOffset(int,ShadowMediaPlayer.MediaEvent)
+     */
+    public void removeEvent(MediaEvent event) {
+      for (Iterator<Entry<Integer, RunList>> iter = events.entrySet().iterator();
+          iter.hasNext(); ) {
+        Entry<Integer, RunList> entry = iter.next();
+        RunList runList = entry.getValue();
+        runList.remove(event);
+        if (runList.isEmpty()) {
+          iter.remove();
+        }
+      }
+    }
+  }
+
+  public void postEvent(MediaEvent e) {
+    Message msg = handler.obtainMessage(MEDIA_EVENT, e);
+    handler.sendMessage(msg);
+  }
+
+  public void postEventDelayed(MediaEvent e, long delay) {
+    Message msg = handler.obtainMessage(MEDIA_EVENT, e);
+    handler.sendMessageDelayed(msg, delay);
+  }
+
+  /**
+   * Callback interface for clients that wish to be informed when a new {@link MediaPlayer} instance
+   * is constructed.
+   *
+   * @see #setCreateListener
+   */
+  public static interface CreateListener {
+    /**
+     * Method that is invoked when a new {@link MediaPlayer} is created. This method is invoked at
+     * the end of the constructor, after all of the default setup has been completed.
+     *
+     * @param player reference to the newly-created media player object.
+     * @param shadow reference to the corresponding shadow object for the newly-created media player
+     *     (provided for convenience).
+     */
+    public void onCreate(MediaPlayer player, ShadowMediaPlayer shadow);
+  }
+
+  /** Current state of the media player. */
+  private State state = IDLE;
+
+  /** Delay for calls to {@link #seekTo} (in ms). */
+  private int seekDelay = 0;
+
+  private int auxEffect;
+  private int audioSessionId;
+  private int audioStreamType;
+  private boolean looping;
+  private int pendingSeek = -1;
+  /** Various source variables from setDataSource() */
+  private Uri sourceUri;
+
+  private int sourceResId;
+  private DataSource dataSource;
+  private MediaInfo mediaInfo;
+
+  /** The time (in ms) at which playback was last started/resumed. */
+  private long startTime = -1;
+
+  /**
+   * The offset (in ms) from the start of the current clip at which the last call to seek/pause was.
+   * If the MediaPlayer is not in the STARTED state, then this is equal to currentPosition; if it is
+   * in the STARTED state and no seek is pending then you need to add the number of ms since start()
+   * was called to get the current position (see {@link #startTime}).
+   */
+  private int startOffset = 0;
+
+  private int videoHeight;
+  private int videoWidth;
+  private float leftVolume;
+  private float rightVolume;
+  private MediaPlayer.OnCompletionListener completionListener;
+  private MediaPlayer.OnSeekCompleteListener seekCompleteListener;
+  private MediaPlayer.OnPreparedListener preparedListener;
+  private MediaPlayer.OnInfoListener infoListener;
+  private MediaPlayer.OnErrorListener errorListener;
+
+  /**
+   * Flag indicating how the shadow media player should behave when a method is invoked in an
+   * invalid state.
+   */
+  private InvalidStateBehavior invalidStateBehavior = InvalidStateBehavior.SILENT;
+
+  private Handler handler;
+
+  private static final MediaEvent completionCallback =
+      new MediaEvent() {
+        @Override
+        public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+          if (mp.isLooping()) {
+            smp.startOffset = 0;
+            smp.doStart();
+          } else {
+            smp.doStop();
+            smp.invokeCompletionListener();
+          }
+        }
+      };
+
+  private static final MediaEvent preparedCallback =
+      new MediaEvent() {
+        @Override
+        public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+          smp.invokePreparedListener();
+        }
+      };
+
+  private static final MediaEvent seekCompleteCallback =
+      new MediaEvent() {
+        @Override
+        public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+          smp.invokeSeekCompleteListener();
+        }
+      };
+
+  /**
+   * Callback to use when a method is invoked from an invalid state. Has {@code what = -38} and
+   * {@code extra = 0}, which are values that were determined by inspection.
+   */
+  private static final ErrorCallback invalidStateErrorCallback = new ErrorCallback(-38, 0);
+
+  public static final int MEDIA_EVENT = 1;
+
+  /** Callback to use for scheduled errors. */
+  private static class ErrorCallback implements MediaEvent {
+    private int what;
+    private int extra;
+
+    public ErrorCallback(int what, int extra) {
+      this.what = what;
+      this.extra = extra;
+    }
+
+    @Override
+    public void run(MediaPlayer mp, ShadowMediaPlayer smp) {
+      smp.invokeErrorListener(what, extra);
+    }
+  }
+
+  @Implementation
+  protected static MediaPlayer create(Context context, int resId) {
+    MediaPlayer mp = new MediaPlayer();
+    ShadowMediaPlayer shadow = Shadow.extract(mp);
+    shadow.sourceResId = resId;
+    try {
+      shadow.setState(INITIALIZED);
+      mp.setDataSource("android.resource://" + context.getPackageName() + "/" + resId);
+      mp.prepare();
+    } catch (Exception e) {
+      return null;
+    }
+
+    return mp;
+  }
+
+  @Implementation
+  protected static MediaPlayer create(Context context, Uri uri) {
+    MediaPlayer mp = new MediaPlayer();
+    try {
+      mp.setDataSource(context, uri);
+      mp.prepare();
+    } catch (Exception e) {
+      return null;
+    }
+
+    return mp;
+  }
+
+  @Implementation
+  protected void __constructor__() {
+    // Contract of audioSessionId is that if it is 0 (which represents
+    // the master mix) then that's an error. By default it generates
+    // an ID that is unique system-wide. We could simulate guaranteed
+    // uniqueness (get AudioManager's help?) but it's probably not
+    // worth the effort - a random non-zero number will probably do.
+    Random random = new Random();
+    audioSessionId = random.nextInt(Integer.MAX_VALUE) + 1;
+    Looper myLooper = Looper.myLooper();
+    if (myLooper != null) {
+      handler = getHandler(myLooper);
+    } else {
+      handler = getHandler(Looper.getMainLooper());
+    }
+    // This gives test suites a chance to customize the MP instance
+    // and its shadow when it is created, without having to modify
+    // the code under test in order to do so.
+    if (createListener != null) {
+      createListener.onCreate(player, this);
+    }
+    // Ensure that the real object is set up properly.
+    Shadow.invokeConstructor(MediaPlayer.class, player);
+  }
+
+  private Handler getHandler(Looper looper) {
+    return new Handler(looper) {
+      @Override
+      public void handleMessage(Message msg) {
+        switch (msg.what) {
+          case MEDIA_EVENT:
+            MediaEvent e = (MediaEvent) msg.obj;
+            e.run(player, ShadowMediaPlayer.this);
+            scheduleNextPlaybackEvent();
+            break;
+        }
+      }
+    };
+  }
+
+  /**
+   * Common code path for all {@code setDataSource()} implementations.
+   *
+   * <p>* Checks for any specified exceptions for the specified data source and throws them. *
+   * Checks the current state and throws an exception if it is in an invalid state. * If no
+   * exception is thrown in either of the previous two steps, then {@link
+   * #doSetDataSource(DataSource)} is called to set the data source. * Sets the player state to
+   * {@code INITIALIZED}. Usually this method would not be called directly, but indirectly through
+   * one of the other {@link #setDataSource(String)} implementations, which use {@link
+   * DataSource#toDataSource(String)} methods to convert their discrete parameters into a single
+   * {@link DataSource} instance.
+   *
+   * @param dataSource the data source that is being set.
+   * @throws IOException if the specified data source has been configured to throw an IO exception.
+   * @see #addException(DataSource, IOException)
+   * @see #addException(DataSource, RuntimeException)
+   * @see #doSetDataSource(DataSource)
+   */
+  public void setDataSource(DataSource dataSource) throws IOException {
+    Exception e = exceptions.get(dataSource);
+    if (e != null) {
+      e.fillInStackTrace();
+      if (e instanceof IOException) {
+        throw (IOException) e;
+      } else if (e instanceof RuntimeException) {
+        throw (RuntimeException) e;
+      }
+      throw new AssertionError("Invalid exception type for setDataSource: <" + e + '>');
+    }
+    checkStateException("setDataSource()", idleState);
+    doSetDataSource(dataSource);
+    state = INITIALIZED;
+  }
+
+  @Implementation
+  protected void setDataSource(String path) throws IOException {
+    setDataSource(toDataSource(path));
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected void setDataSource(Context context, Uri uri) throws IOException {
+    setDataSource(context, uri, null, null);
+  }
+
+  @Implementation(minSdk = ICE_CREAM_SANDWICH, maxSdk = N_MR1)
+  protected void setDataSource(Context context, Uri uri, Map<String, String> headers)
+      throws IOException {
+    setDataSource(context, uri, headers, null);
+  }
+
+  @Implementation(minSdk = O)
+  protected void setDataSource(
+      Context context, Uri uri, Map<String, String> headers, List<HttpCookie> cookies)
+      throws IOException {
+    setDataSource(toDataSource(context, uri, headers, cookies));
+    sourceUri = uri;
+  }
+
+  @Implementation
+  protected void setDataSource(String uri, Map<String, String> headers) throws IOException {
+    setDataSource(toDataSource(uri, headers));
+  }
+
+  @Implementation
+  protected void setDataSource(FileDescriptor fd, long offset, long length) throws IOException {
+    setDataSource(toDataSource(fd, offset, length));
+  }
+
+  @Implementation(minSdk = M)
+  protected void setDataSource(MediaDataSource mediaDataSource) throws IOException {
+    setDataSource(toDataSource(mediaDataSource));
+  }
+
+  @Implementation(minSdk = N)
+  protected void setDataSource(AssetFileDescriptor assetFileDescriptor) throws IOException {
+    setDataSource(toDataSource(assetFileDescriptor));
+  }
+
+  /**
+   * Sets the data source without doing any other emulation. Sets the internal data source only.
+   * Calling directly can be useful for setting up a {@link ShadowMediaPlayer} instance during
+   * specific testing so that you don't have to clutter your tests catching exceptions you know
+   * won't be thrown.
+   *
+   * @param dataSource the data source that is being set.
+   * @see #setDataSource(DataSource)
+   */
+  public void doSetDataSource(DataSource dataSource) {
+    MediaInfo mediaInfo = mediaInfoProvider.get(dataSource);
+    if (mediaInfo == null) {
+      throw new IllegalArgumentException(
+          "Don't know what to do with dataSource "
+              + dataSource
+              + " - either add an exception with addException() or media info with "
+              + "addMediaInfo()");
+    }
+    this.mediaInfo = mediaInfo;
+    this.dataSource = dataSource;
+  }
+
+  public static MediaInfo getMediaInfo(DataSource dataSource) {
+    return mediaInfoProvider.get(dataSource);
+  }
+
+  /**
+   * Adds a {@link MediaInfo} for a {@link DataSource}.
+   *
+   * <p>This overrides any {@link MediaInfoProvider} previously set by calling {@link
+   * #setMediaInfoProvider}, i.e., the provider will not be used for any {@link DataSource}.
+   */
+  public static void addMediaInfo(DataSource dataSource, MediaInfo info) {
+    ShadowMediaPlayer.mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER;
+    mediaInfoMap.put(dataSource, info);
+  }
+
+  /**
+   * Sets a {@link MediaInfoProvider} to be used to get {@link MediaInfo} for any {@link
+   * DataSource}.
+   *
+   * <p>This overrides any {@link MediaInfo} previously set by calling {@link #addMediaInfo}, i.e.,
+   * {@link MediaInfo} provided by this {@link MediaInfoProvider} will be used instead.
+   */
+  public static void setMediaInfoProvider(MediaInfoProvider mediaInfoProvider) {
+    ShadowMediaPlayer.mediaInfoProvider = mediaInfoProvider;
+  }
+
+  public static void addException(DataSource dataSource, RuntimeException e) {
+    exceptions.put(dataSource, e);
+  }
+
+  public static void addException(DataSource dataSource, IOException e) {
+    exceptions.put(dataSource, e);
+  }
+
+  /**
+   * Checks states for methods that only log when there is an error. Such methods throw an {@link
+   * IllegalArgumentException} when invoked in the END state, but log an error in other disallowed
+   * states. This method will either emulate this behavior or else will generate an assertion if
+   * invoked from a disallowed state if {@link #setAssertOnError assertOnError} is set.
+   *
+   * @param method the name of the method being tested.
+   * @param allowedStates the states that this method is allowed to be called from.
+   * @see #setAssertOnError
+   * @see #checkStateError(String, EnumSet)
+   * @see #checkStateException(String, EnumSet)
+   */
+  private void checkStateLog(String method, EnumSet<State> allowedStates) {
+    switch (invalidStateBehavior) {
+      case SILENT:
+        break;
+      case EMULATE:
+        if (state == END) {
+          String msg = "Can't call " + method + " from state " + state;
+          throw new IllegalStateException(msg);
+        }
+        break;
+      case ASSERT:
+        if (!allowedStates.contains(state) || state == END) {
+          String msg = "Can't call " + method + " from state " + state;
+          throw new AssertionError(msg);
+        }
+    }
+  }
+
+  /**
+   * Checks states for methods that asynchronously invoke {@link
+   * android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int) onError()} when
+   * invoked in an illegal state. Such methods always throw {@link IllegalStateException} rather
+   * than invoke {@code onError()} if they are invoked from the END state.
+   *
+   * <p>This method will either emulate this behavior by posting an {@code onError()} callback to
+   * the current thread's message queue (or throw an {@link IllegalStateException} if invoked from
+   * the END state), or else it will generate an assertion if {@link #setAssertOnError
+   * assertOnError} is set.
+   *
+   * @param method the name of the method being tested.
+   * @param allowedStates the states that this method is allowed to be called from.
+   * @see #getHandler
+   * @see #setAssertOnError
+   * @see #checkStateLog(String, EnumSet)
+   * @see #checkStateException(String, EnumSet)
+   */
+  private boolean checkStateError(String method, EnumSet<State> allowedStates) {
+    if (!allowedStates.contains(state)) {
+      switch (invalidStateBehavior) {
+        case SILENT:
+          break;
+        case EMULATE:
+          if (state == END) {
+            String msg = "Can't call " + method + " from state " + state;
+            throw new IllegalStateException(msg);
+          }
+          state = ERROR;
+          postEvent(invalidStateErrorCallback);
+          return false;
+        case ASSERT:
+          String msg = "Can't call " + method + " from state " + state;
+          throw new AssertionError(msg);
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Checks states for methods that synchronously throw an exception when invoked in an illegal
+   * state. This method will likewise throw an {@link IllegalArgumentException} if it determines
+   * that the method has been invoked from a disallowed state, or else it will generate an assertion
+   * if {@link #setAssertOnError assertOnError} is set.
+   *
+   * @param method the name of the method being tested.
+   * @param allowedStates the states that this method is allowed to be called from.
+   * @see #setAssertOnError
+   * @see #checkStateLog(String, EnumSet)
+   * @see #checkStateError(String, EnumSet)
+   */
+  private void checkStateException(String method, EnumSet<State> allowedStates) {
+    if (!allowedStates.contains(state)) {
+      String msg = "Can't call " + method + " from state " + state;
+      switch (invalidStateBehavior) {
+        case SILENT:
+          break;
+        case EMULATE:
+          throw new IllegalStateException(msg);
+        case ASSERT:
+          throw new AssertionError(msg);
+      }
+    }
+  }
+
+  @Implementation
+  protected void setOnCompletionListener(MediaPlayer.OnCompletionListener listener) {
+    completionListener = listener;
+  }
+
+  @Implementation
+  protected void setOnSeekCompleteListener(MediaPlayer.OnSeekCompleteListener listener) {
+    seekCompleteListener = listener;
+  }
+
+  @Implementation
+  protected void setOnPreparedListener(MediaPlayer.OnPreparedListener listener) {
+    preparedListener = listener;
+  }
+
+  @Implementation
+  protected void setOnInfoListener(MediaPlayer.OnInfoListener listener) {
+    infoListener = listener;
+  }
+
+  @Implementation
+  protected void setOnErrorListener(MediaPlayer.OnErrorListener listener) {
+    errorListener = listener;
+  }
+
+  @Implementation
+  protected boolean isLooping() {
+    checkStateException("isLooping()", nonEndStates);
+    return looping;
+  }
+
+  private static final EnumSet<State> nonEndStates = EnumSet.complementOf(EnumSet.of(END));
+  private static final EnumSet<State> nonErrorStates = EnumSet.complementOf(EnumSet.of(ERROR, END));
+
+  @Implementation
+  protected void setLooping(boolean looping) {
+    checkStateError("setLooping()", nonErrorStates);
+    this.looping = looping;
+  }
+
+  @Implementation
+  protected void setVolume(float left, float right) {
+    checkStateError("setVolume()", nonErrorStates);
+    leftVolume = left;
+    rightVolume = right;
+  }
+
+  @Implementation
+  protected boolean isPlaying() {
+    checkStateError("isPlaying()", nonErrorStates);
+    return state == STARTED;
+  }
+
+  private static EnumSet<State> preparableStates = EnumSet.of(INITIALIZED, STOPPED);
+
+  /**
+   * Simulates {@link MediaPlayer#prepareAsync()}. Sleeps for {@link MediaInfo#getPreparationDelay()
+   * preparationDelay} ms by calling {@link SystemClock#sleep(long)} before calling {@link
+   * #invokePreparedListener()}.
+   *
+   * <p>If {@code preparationDelay} is not positive and non-zero, there is no sleep.
+   *
+   * @see MediaInfo#setPreparationDelay(int)
+   * @see #invokePreparedListener()
+   */
+  @Implementation
+  protected void prepare() {
+    checkStateException("prepare()", preparableStates);
+    MediaInfo info = getMediaInfo();
+    if (info.preparationDelay > 0) {
+      SystemClock.sleep(info.preparationDelay);
+    }
+    state = PREPARED;
+    postEvent(
+        (mp, smp) -> {
+          if (preparedListener != null) {
+            preparedListener.onPrepared(mp);
+          }
+        });
+  }
+
+  /**
+   * Simulates {@link MediaPlayer#prepareAsync()}. Sets state to PREPARING and posts a callback to
+   * {@link #invokePreparedListener()} if the current preparation delay for the current media (see
+   * {@link #getMediaInfo()}) is &gt;= 0, otherwise the test suite is responsible for calling {@link
+   * #invokePreparedListener()} directly if required.
+   *
+   * @see MediaInfo#setPreparationDelay(int)
+   * @see #invokePreparedListener()
+   */
+  @Implementation
+  protected void prepareAsync() {
+    checkStateException("prepareAsync()", preparableStates);
+    state = PREPARING;
+    MediaInfo info = getMediaInfo();
+    if (info.preparationDelay >= 0) {
+      postEventDelayed(preparedCallback, info.preparationDelay);
+    }
+  }
+
+  private static EnumSet<State> startableStates =
+      EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED);
+
+  /**
+   * Simulates private native method {@link MediaPlayer#_start()}. Sets state to STARTED and calls
+   * {@link #doStart()} to start scheduling playback callback events.
+   *
+   * <p>If the current state is PLAYBACK_COMPLETED, the current position is reset to zero before
+   * starting playback.
+   *
+   * @see #doStart()
+   */
+  @Implementation
+  protected void start() {
+    if (checkStateError("start()", startableStates)) {
+      if (state == PLAYBACK_COMPLETED) {
+        startOffset = 0;
+      }
+      state = STARTED;
+      doStart();
+    }
+  }
+
+  private void scheduleNextPlaybackEvent() {
+    if (!isReallyPlaying()) {
+      return;
+    }
+    final int currentPosition = getCurrentPositionRaw();
+    MediaInfo info = getMediaInfo();
+    Entry<Integer, RunList> event = info.events.higherEntry(currentPosition);
+    if (event == null) {
+      // This means we've "seeked" past the end. Get the last
+      // event (which should be the completion event) and
+      // invoke that, setting the position to the duration.
+      postEvent(completionCallback);
+    } else {
+      final int runListOffset = event.getKey();
+      nextPlaybackEvent = event.getValue();
+      postEventDelayed(nextPlaybackEvent, runListOffset - currentPosition);
+    }
+  }
+
+  /**
+   * Tests to see if the player is really playing.
+   *
+   * <p>The player is defined as "really playing" if simulated playback events (including playback
+   * completion) are being scheduled and invoked and {@link #getCurrentPosition currentPosition} is
+   * being updated as time passes. Note that while the player will normally be really playing if in
+   * the STARTED state, this is not always the case - for example, if a pending seek is in progress,
+   * or perhaps a buffer underrun is being simulated.
+   *
+   * @return {@code true} if the player is really playing or {@code false} if the player is
+   *     internally paused.
+   * @see #doStart
+   * @see #doStop
+   */
+  public boolean isReallyPlaying() {
+    return startTime >= 0;
+  }
+
+  /**
+   * Starts simulated playback. Until this method is called, the player is not "really playing" (see
+   * {@link #isReallyPlaying} for a definition of "really playing").
+   *
+   * <p>This method is used internally by the various shadow method implementations of the
+   * MediaPlayer public API, but may also be called directly by the test suite if you wish to
+   * simulate an internal pause. For example, to simulate a buffer underrun (player is in PLAYING
+   * state but isn't actually advancing the current position through the media), you could call
+   * {@link #doStop()} to mark the start of the buffer underrun and {@link #doStart()} to mark its
+   * end and restart normal playback (which is what {@link
+   * ShadowMediaPlayer.MediaInfo#scheduleBufferUnderrunAtOffset(int, int)
+   * scheduleBufferUnderrunAtOffset()} does).
+   *
+   * @see #isReallyPlaying()
+   * @see #doStop()
+   */
+  public void doStart() {
+    startTime = SystemClock.uptimeMillis();
+    scheduleNextPlaybackEvent();
+  }
+
+  /**
+   * Pauses simulated playback. After this method is called, the player is no longer "really
+   * playing" (see {@link #isReallyPlaying} for a definition of "really playing").
+   *
+   * <p>This method is used internally by the various shadow method implementations of the
+   * MediaPlayer public API, but may also be called directly by the test suite if you wish to
+   * simulate an internal pause.
+   *
+   * @see #isReallyPlaying()
+   * @see #doStart()
+   */
+  public void doStop() {
+    startOffset = getCurrentPositionRaw();
+    if (nextPlaybackEvent != null) {
+      handler.removeMessages(MEDIA_EVENT);
+      nextPlaybackEvent = null;
+    }
+    startTime = -1;
+  }
+
+  private static final EnumSet<State> pausableStates =
+      EnumSet.of(STARTED, PAUSED, PLAYBACK_COMPLETED);
+
+  /**
+   * Simulates {@link MediaPlayer#_pause()}. Invokes {@link #doStop()} to suspend playback event
+   * callbacks and sets the state to PAUSED.
+   *
+   * @see #doStop()
+   */
+  @Implementation
+  protected void _pause() {
+    if (checkStateError("pause()", pausableStates)) {
+      doStop();
+      state = PAUSED;
+    }
+  }
+
+  static final EnumSet<State> allStates = EnumSet.allOf(State.class);
+
+  /**
+   * Simulates call to {@link MediaPlayer#_release()}. Calls {@link #doStop()} to suspend playback
+   * event callbacks and sets the state to END.
+   */
+  @Implementation
+  protected void _release() {
+    checkStateException("release()", allStates);
+    doStop();
+    state = END;
+    handler.removeMessages(MEDIA_EVENT);
+  }
+
+  /**
+   * Simulates call to {@link MediaPlayer#_reset()}. Calls {@link #doStop()} to suspend playback
+   * event callbacks and sets the state to IDLE.
+   */
+  @Implementation
+  protected void _reset() {
+    checkStateException("reset()", nonEndStates);
+    doStop();
+    state = IDLE;
+    handler.removeMessages(MEDIA_EVENT);
+    startOffset = 0;
+  }
+
+  private static final EnumSet<State> stoppableStates =
+      EnumSet.of(PREPARED, STARTED, PAUSED, STOPPED, PLAYBACK_COMPLETED);
+
+  /**
+   * Simulates call to {@link MediaPlayer#release()}. Calls {@link #doStop()} to suspend playback
+   * event callbacks and sets the state to STOPPED.
+   */
+  @Implementation
+  protected void _stop() {
+    if (checkStateError("stop()", stoppableStates)) {
+      doStop();
+      state = STOPPED;
+    }
+  }
+
+  private static final EnumSet<State> attachableStates =
+      EnumSet.of(INITIALIZED, PREPARING, PREPARED, STARTED, PAUSED, STOPPED, PLAYBACK_COMPLETED);
+
+  @Implementation
+  protected void attachAuxEffect(int effectId) {
+    checkStateError("attachAuxEffect()", attachableStates);
+    auxEffect = effectId;
+  }
+
+  @Implementation
+  protected int getAudioSessionId() {
+    checkStateException("getAudioSessionId()", allStates);
+    return audioSessionId;
+  }
+
+  /**
+   * Simulates call to {@link MediaPlayer#getCurrentPosition()}. Simply does the state validity
+   * checks and then invokes {@link #getCurrentPositionRaw()} to calculate the simulated playback
+   * position.
+   *
+   * @return The current offset (in ms) of the simulated playback.
+   * @see #getCurrentPositionRaw()
+   */
+  @Implementation
+  protected int getCurrentPosition() {
+    checkStateError("getCurrentPosition()", attachableStates);
+    return getCurrentPositionRaw();
+  }
+
+  /**
+   * Simulates call to {@link MediaPlayer#getDuration()}. Retrieves the duration as defined by the
+   * current {@link MediaInfo} instance.
+   *
+   * @return The duration (in ms) of the current simulated playback.
+   * @see #addMediaInfo(DataSource, MediaInfo)
+   */
+  @Implementation
+  protected int getDuration() {
+    checkStateError("getDuration()", stoppableStates);
+    return getMediaInfo().duration;
+  }
+
+  @Implementation
+  protected int getVideoHeight() {
+    checkStateLog("getVideoHeight()", attachableStates);
+    return videoHeight;
+  }
+
+  @Implementation
+  protected int getVideoWidth() {
+    checkStateLog("getVideoWidth()", attachableStates);
+    return videoWidth;
+  }
+
+  private static final EnumSet<State> seekableStates =
+      EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED);
+
+  /**
+   * Simulates seeking to specified position. The seek will complete after {@link #seekDelay} ms
+   * (defaults to 0), or else if seekDelay is negative then the controlling test is expected to
+   * simulate seek completion by manually invoking {@link #invokeSeekCompleteListener}.
+   *
+   * @param seekTo the offset (in ms) from the start of the track to seek to.
+   */
+  @Implementation
+  protected void seekTo(int seekTo) {
+    seekTo(seekTo, MediaPlayer.SEEK_PREVIOUS_SYNC);
+  }
+
+  @Implementation(minSdk = O)
+  protected void seekTo(long seekTo, int mode) {
+    boolean success = checkStateError("seekTo()", seekableStates);
+    // Cancel any pending seek operations.
+    handler.removeMessages(MEDIA_EVENT, seekCompleteCallback);
+
+    if (success) {
+      // Need to call doStop() before setting pendingSeek,
+      // because if pendingSeek is called it changes
+      // the behavior of getCurrentPosition(), which doStop()
+      // depends on.
+      doStop();
+      pendingSeek = (int) seekTo;
+      if (seekDelay >= 0) {
+        postEventDelayed(seekCompleteCallback, seekDelay);
+      }
+    }
+  }
+
+  private static final EnumSet<State> idleState = EnumSet.of(IDLE);
+
+  @Implementation
+  protected void setAudioSessionId(int sessionId) {
+    checkStateError("setAudioSessionId()", idleState);
+    audioSessionId = sessionId;
+  }
+
+  private static final EnumSet<State> nonPlayingStates = EnumSet.of(IDLE, INITIALIZED, STOPPED);
+
+  @Implementation
+  protected void setAudioStreamType(int audioStreamType) {
+    checkStateError("setAudioStreamType()", nonPlayingStates);
+    this.audioStreamType = audioStreamType;
+  }
+
+  /**
+   * Sets a listener that is invoked whenever a new shadowed {@link MediaPlayer} object is
+   * constructed.
+   *
+   * <p>Registering a listener gives you a chance to customize the shadowed object appropriately
+   * without needing to modify the application-under-test to provide access to the instance at the
+   * appropriate point in its life cycle. This is useful because normally a new {@link MediaPlayer}
+   * is created and {@link #setDataSource setDataSource()} is invoked soon after, without a break in
+   * the code. Using this callback means you don't have to change this common pattern just so that
+   * you can customize the shadow for testing.
+   *
+   * @param createListener the listener to be invoked
+   */
+  public static void setCreateListener(CreateListener createListener) {
+    ShadowMediaPlayer.createListener = createListener;
+  }
+
+  /**
+   * Retrieves the {@link Handler} object used by this {@code ShadowMediaPlayer}. Can be used for
+   * posting custom asynchronous events to the thread (eg, asynchronous errors). Use this for
+   * scheduling events to take place at a particular "real" time (ie, time as measured by the
+   * scheduler). For scheduling events to occur at a particular playback offset (no matter how long
+   * playback may be paused for, or where you seek to, etc), see {@link
+   * MediaInfo#scheduleEventAtOffset(int, ShadowMediaPlayer.MediaEvent)} and its various helpers.
+   *
+   * @return Handler object that can be used to schedule asynchronous events on this media player.
+   */
+  public Handler getHandler() {
+    return handler;
+  }
+
+  /**
+   * Retrieves current flag specifying the behavior of the media player when a method is invoked in
+   * an invalid state. See {@link #setInvalidStateBehavior(InvalidStateBehavior)} for a discussion
+   * of the available modes and their associated behaviors.
+   *
+   * @return The current invalid state behavior mode.
+   * @see #setInvalidStateBehavior
+   */
+  public InvalidStateBehavior getInvalidStateBehavior() {
+    return invalidStateBehavior;
+  }
+
+  /**
+   * Specifies how the media player should behave when a method is invoked in an invalid state.
+   * Three modes are supported (as defined by the {@link InvalidStateBehavior} enum):
+   *
+   * <h3>{@link InvalidStateBehavior#SILENT SILENT}</h3>
+   *
+   * No invalid state checking is done at all. All methods can be invoked from any state without
+   * throwing any exceptions or invoking the error listener.
+   *
+   * <p>This mode is provided primarily for backwards compatibility, and for this reason it is the
+   * default. For proper testing one of the other two modes is probably preferable.
+   *
+   * <h3>{@link InvalidStateBehavior#EMULATE EMULATE}</h3>
+   *
+   * The shadow will attempt to emulate the behavior of the actual {@link MediaPlayer}
+   * implementation. This is based on a reading of the documentation and on actual experiments done
+   * on a Jelly Bean device. The official documentation is not all that clear, but basically methods
+   * fall into three categories:
+   *
+   * <ul>
+   *   <li>Those that log an error when invoked in an invalid state but don't throw an exception or
+   *       invoke {@code onError()}. An example is {@link #getVideoHeight()}.
+   *   <li>Synchronous error handling: methods always throw an exception (usually {@link
+   *       IllegalStateException} but don't invoke {@code onError()}. Examples are {@link
+   *       #prepare()} and {@link #setDataSource(String)}.
+   *   <li>Asynchronous error handling: methods don't throw an exception but invoke {@code
+   *       onError()}.
+   * </ul>
+   *
+   * Additionally, all three methods behave synchronously (throwing {@link IllegalStateException}
+   * when invoked from the END state.
+   *
+   * <p>To complicate matters slightly, the official documentation sometimes contradicts observed
+   * behavior. For example, the documentation says it is illegal to call {@link #setDataSource} from
+   * the ERROR state - however, in practice it works fine. Conversely, the documentation says that
+   * it is legal to invoke {@link #getCurrentPosition()} from the INITIALIZED state, however testing
+   * showed that this caused an error. Wherever there is a discrepancy between documented and
+   * observed behavior, this implementation has gone with the most conservative implementation (ie,
+   * it is illegal to invoke {@link #setDataSource} from the ERROR state and likewise illegal to
+   * invoke {@link #getCurrentPosition()} from the INITIALIZED state.
+   *
+   * <h3>{@link InvalidStateBehavior#ASSERT ASSERT}</h3>
+   *
+   * The shadow will raise an assertion any time that a method is invoked in an invalid state. The
+   * philosophy behind this mode is that to invoke a method in an invalid state is a programming
+   * error - a bug, pure and simple. As such it should be discovered and eliminated at development
+   * and testing time, rather than anticipated and handled at runtime. Asserting is a way of testing
+   * for these bugs during testing.
+   *
+   * @param invalidStateBehavior the behavior mode for this shadow to use during testing.
+   * @see #getInvalidStateBehavior()
+   */
+  public void setInvalidStateBehavior(InvalidStateBehavior invalidStateBehavior) {
+    this.invalidStateBehavior = invalidStateBehavior;
+  }
+
+  /**
+   * Retrieves the currently selected {@link MediaInfo}. This instance is used to define current
+   * duration, preparation delay, exceptions for {@code setDataSource()}, playback events, etc.
+   *
+   * @return The currently selected {@link MediaInfo}.
+   * @see #addMediaInfo
+   * @see #setMediaInfoProvider
+   * @see #doSetDataSource(DataSource)
+   */
+  public MediaInfo getMediaInfo() {
+    return mediaInfo;
+  }
+
+  /**
+   * Sets the current position, bypassing the normal state checking. Use with care.
+   *
+   * @param position the new playback position.
+   */
+  public void setCurrentPosition(int position) {
+    startOffset = position;
+  }
+
+  /**
+   * Retrieves the current position without doing the state checking that the emulated version of
+   * {@link #getCurrentPosition()} does.
+   *
+   * @return The current playback position within the current clip.
+   */
+  public int getCurrentPositionRaw() {
+    int currentPos = startOffset;
+    if (isReallyPlaying()) {
+      currentPos += (int) (SystemClock.uptimeMillis() - startTime);
+    }
+    return currentPos;
+  }
+
+  /**
+   * Retrieves the current duration without doing the state checking that the emulated version does.
+   *
+   * @return The duration of the current clip loaded by the player.
+   */
+  public int getDurationRaw() {
+    return getMediaInfo().duration;
+  }
+
+  /**
+   * Retrieves the current state of the {@link MediaPlayer}. Uses the states as defined in the
+   * {@link MediaPlayer} documentation.
+   *
+   * @return The current state of the {@link MediaPlayer}, as defined in the MediaPlayer
+   *     documentation.
+   * @see #setState
+   * @see MediaPlayer
+   */
+  public State getState() {
+    return state;
+  }
+
+  /**
+   * Forces the @link MediaPlayer} into the specified state. Uses the states as defined in the
+   * {@link MediaPlayer} documentation.
+   *
+   * <p>Note that by invoking this method directly you can get the player into an inconsistent state
+   * that a real player could not be put in (eg, in the END state but with playback events still
+   * happening). Use with care.
+   *
+   * @param state the new state of the {@link MediaPlayer}, as defined in the MediaPlayer
+   *     documentation.
+   * @see #getState
+   * @see MediaPlayer
+   */
+  public void setState(State state) {
+    this.state = state;
+  }
+
+  /**
+   * Note: This has a funny name at the moment to avoid having to produce an API-specific shadow -
+   * if it were called {@code getAudioStreamType()} then the {@code RobolectricWiringTest} will
+   * inform us that it should be annotated with {@link Implementation}, because there is a private
+   * method in the later API versions with the same name, however this would fail on earlier
+   * versions.
+   *
+   * @return audioStreamType
+   */
+  public int getTheAudioStreamType() {
+    return audioStreamType;
+  }
+
+  /** @return seekDelay */
+  public int getSeekDelay() {
+    return seekDelay;
+  }
+
+  /**
+   * Sets the length of time (ms) that seekTo() will delay before completing. Default is 0. If set
+   * to -1, then seekTo() will not call the OnSeekCompleteListener automatically; you will need to
+   * call invokeSeekCompleteListener() manually.
+   *
+   * @param seekDelay length of time to delay (ms)
+   */
+  public void setSeekDelay(int seekDelay) {
+    this.seekDelay = seekDelay;
+  }
+
+  /**
+   * Useful for assertions.
+   *
+   * @return The current {@code auxEffect} setting.
+   */
+  public int getAuxEffect() {
+    return auxEffect;
+  }
+
+  /**
+   * Retrieves the pending seek setting.
+   *
+   * @return The position to which the shadow player is seeking for the seek in progress (ie, after
+   *     the call to {@link #seekTo} but before a call to {@link #invokeSeekCompleteListener()}).
+   *     Returns {@code -1} if no seek is in progress.
+   */
+  public int getPendingSeek() {
+    return pendingSeek;
+  }
+
+  /**
+   * Retrieves the data source (if any) that was passed in to {@link #setDataSource(DataSource)}.
+   *
+   * <p>Useful for assertions.
+   *
+   * @return The source passed in to {@code setDataSource}.
+   */
+  public DataSource getDataSource() {
+    return dataSource;
+  }
+
+  /**
+   * Retrieves the source path (if any) that was passed in to {@link
+   * MediaPlayer#setDataSource(Context, Uri, Map)} or {@link MediaPlayer#setDataSource(Context,
+   * Uri)}.
+   *
+   * @return The source Uri passed in to {@code setDataSource}.
+   */
+  public Uri getSourceUri() {
+    return sourceUri;
+  }
+
+  /**
+   * Retrieves the resource ID used in the call to {@link #create(Context, int)} (if any).
+   *
+   * @return The resource ID passed in to {@code create()}, or {@code -1} if a different method of
+   *     setting the source was used.
+   */
+  public int getSourceResId() {
+    return sourceResId;
+  }
+
+  /**
+   * Retrieves the current setting for the left channel volume.
+   *
+   * @return The left channel volume.
+   */
+  public float getLeftVolume() {
+    return leftVolume;
+  }
+
+  /** @return The right channel volume. */
+  public float getRightVolume() {
+    return rightVolume;
+  }
+
+  @Implementation(minSdk = P)
+  protected boolean native_setOutputDevice(int preferredDeviceId) {
+    return true;
+  }
+
+  private static EnumSet<State> preparedStates =
+      EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED);
+
+  /**
+   * Tests to see if the player is in the PREPARED state. This is mainly used for backward
+   * compatibility. {@link #getState} may be more useful for new testing applications.
+   *
+   * @return {@code true} if the MediaPlayer is in the PREPARED state, false otherwise.
+   */
+  public boolean isPrepared() {
+    return preparedStates.contains(state);
+  }
+
+  /** @return the OnCompletionListener */
+  public MediaPlayer.OnCompletionListener getOnCompletionListener() {
+    return completionListener;
+  }
+
+  /** @return the OnPreparedListener */
+  public MediaPlayer.OnPreparedListener getOnPreparedListener() {
+    return preparedListener;
+  }
+
+  /**
+   * Allows test cases to simulate 'prepared' state by invoking callback. Sets the player's state to
+   * PREPARED and invokes the {@link MediaPlayer.OnPreparedListener#onPrepared preparedListener()}
+   */
+  public void invokePreparedListener() {
+    state = PREPARED;
+    if (preparedListener == null) {
+      return;
+    }
+    preparedListener.onPrepared(player);
+  }
+
+  /**
+   * Simulates end-of-playback. Changes the player into PLAYBACK_COMPLETED state and calls {@link
+   * MediaPlayer.OnCompletionListener#onCompletion(MediaPlayer) onCompletion()} if a listener has
+   * been set.
+   */
+  public void invokeCompletionListener() {
+    state = PLAYBACK_COMPLETED;
+    if (completionListener == null) {
+      return;
+    }
+    completionListener.onCompletion(player);
+  }
+
+  /** Allows test cases to simulate seek completion by invoking callback. */
+  public void invokeSeekCompleteListener() {
+    int duration = getMediaInfo().duration;
+    setCurrentPosition(pendingSeek > duration ? duration : pendingSeek < 0 ? 0 : pendingSeek);
+    pendingSeek = -1;
+    if (state == STARTED) {
+      doStart();
+    }
+    if (seekCompleteListener == null) {
+      return;
+    }
+    seekCompleteListener.onSeekComplete(player);
+  }
+
+  /**
+   * Allows test cases to directly simulate invocation of the OnInfo event.
+   *
+   * @param what parameter to pass in to {@code what} in {@link
+   *     MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int)}.
+   * @param extra parameter to pass in to {@code extra} in {@link
+   *     MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int)}.
+   */
+  public void invokeInfoListener(int what, int extra) {
+    if (infoListener != null) {
+      infoListener.onInfo(player, what, extra);
+    }
+  }
+
+  /**
+   * Allows test cases to directly simulate invocation of the OnError event.
+   *
+   * @param what parameter to pass in to {@code what} in {@link
+   *     MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)}.
+   * @param extra parameter to pass in to {@code extra} in {@link
+   *     MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)}.
+   */
+  public void invokeErrorListener(int what, int extra) {
+    // Calling doStop() un-schedules the next event and
+    // stops normal event flow from continuing.
+    doStop();
+    state = ERROR;
+    boolean handled = errorListener != null && errorListener.onError(player, what, extra);
+    if (!handled) {
+      // The documentation isn't very clear if onCompletion is
+      // supposed to be called from non-playing states
+      // (ie, states other than STARTED or PAUSED). Testing
+      // revealed that onCompletion is invoked even if playback
+      // hasn't started or is not in progress.
+      invokeCompletionListener();
+      // Need to set this again because
+      // invokeCompletionListener() will set the state
+      // to PLAYBACK_COMPLETED
+      state = ERROR;
+    }
+  }
+
+  @Resetter
+  public static void resetStaticState() {
+    createListener = null;
+    mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER;
+    exceptions.clear();
+    mediaInfoMap.clear();
+    DataSource.reset();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaRecorder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaRecorder.java
new file mode 100644
index 0000000..eed1f62
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaRecorder.java
@@ -0,0 +1,291 @@
+package org.robolectric.shadows;
+
+import android.graphics.SurfaceTexture;
+import android.hardware.Camera;
+import android.media.MediaRecorder;
+import android.os.Build.VERSION_CODES;
+import android.view.Surface;
+import com.google.common.base.Preconditions;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(MediaRecorder.class)
+public class ShadowMediaRecorder {
+  @SuppressWarnings("UnusedDeclaration")
+  @Implementation
+  protected static void __staticInitializer__() {
+    // don't bind the JNI library
+  }
+
+  // Recording machine state, as per:
+  // http://developer.android.com/reference/android/media/MediaRecorder.html
+  public static final int STATE_ERROR = -1;
+  public static final int STATE_INITIAL = 1;
+  public static final int STATE_INITIALIZED = 2;
+  public static final int STATE_DATA_SOURCE_CONFIGURED = 3;
+  public static final int STATE_PREPARED = 4;
+  public static final int STATE_RECORDING = 5;
+  public static final int STATE_RELEASED = 6;
+
+  private int state;
+
+  private Camera camera;
+  private int audioChannels;
+  private int audioEncoder;
+  private int audioBitRate;
+  private int audioSamplingRate;
+  private int audioSource;
+  private int maxDuration;
+  private long maxFileSize;
+  private String outputPath;
+  private int outputFormat;
+  private int videoEncoder;
+  private int videoBitRate;
+  private int videoFrameRate;
+  private int videoWidth;
+  private int videoHeight;
+  private int videoSource;
+
+  private Surface previewDisplay;
+  private Surface recordingSurface;
+  private SurfaceTexture recordingSurfaceTexture;
+  private MediaRecorder.OnErrorListener errorListener;
+  private MediaRecorder.OnInfoListener infoListener;
+
+  @Implementation
+  protected void __constructor__() {
+    state = STATE_INITIAL;
+  }
+
+  @Implementation
+  protected void setAudioChannels(int numChannels) {
+    audioChannels = numChannels;
+  }
+
+  @Implementation
+  protected void setAudioEncoder(int audio_encoder) {
+    audioEncoder = audio_encoder;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setAudioEncodingBitRate(int bitRate) {
+    audioBitRate = bitRate;
+  }
+
+  @Implementation
+  protected void setAudioSamplingRate(int samplingRate) {
+    audioSamplingRate = samplingRate;
+  }
+
+  @Implementation
+  protected void setAudioSource(int audio_source) {
+    audioSource = audio_source;
+    state = STATE_INITIALIZED;
+  }
+
+  @Implementation
+  protected void setCamera(Camera c) {
+    camera = c;
+  }
+
+  @Implementation
+  protected void setMaxDuration(int max_duration_ms) {
+    maxDuration = max_duration_ms;
+  }
+
+  @Implementation
+  protected void setMaxFileSize(long max_filesize_bytes) {
+    maxFileSize = max_filesize_bytes;
+  }
+
+  @Implementation
+  protected void setOnErrorListener(MediaRecorder.OnErrorListener l) {
+    errorListener = l;
+  }
+
+  @Implementation
+  protected void setOnInfoListener(MediaRecorder.OnInfoListener listener) {
+    infoListener = listener;
+  }
+
+  @Implementation
+  protected void setOutputFile(String path) {
+    outputPath = path;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setOutputFormat(int output_format) {
+    outputFormat = output_format;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setPreviewDisplay(Surface sv) {
+    previewDisplay = sv;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setVideoEncoder(int video_encoder) {
+    videoEncoder = video_encoder;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setVideoEncodingBitRate(int bitRate) {
+    videoBitRate = bitRate;
+  }
+
+  @Implementation
+  protected void setVideoFrameRate(int rate) {
+    videoFrameRate = rate;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setVideoSize(int width, int height) {
+    videoWidth = width;
+    videoHeight = height;
+    state = STATE_DATA_SOURCE_CONFIGURED;
+  }
+
+  @Implementation
+  protected void setVideoSource(int video_source) {
+    videoSource = video_source;
+    state = STATE_INITIALIZED;
+  }
+
+  @Implementation
+  protected void prepare() {
+    state = STATE_PREPARED;
+  }
+
+  @Implementation
+  protected void start() {
+    state = STATE_RECORDING;
+  }
+
+  @Implementation
+  protected void stop() {
+    state = STATE_INITIAL;
+  }
+
+  @Implementation
+  protected void reset() {
+    state = STATE_INITIAL;
+  }
+
+  @Implementation
+  protected void release() {
+    state = STATE_RELEASED;
+    if (recordingSurface != null) {
+      recordingSurface.release();
+      recordingSurface = null;
+    }
+    if (recordingSurfaceTexture != null) {
+      recordingSurfaceTexture.release();
+      recordingSurfaceTexture = null;
+    }
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected Surface getSurface() {
+    Preconditions.checkState(
+        getVideoSource() == MediaRecorder.VideoSource.SURFACE,
+        "getSurface can only be called when setVideoSource is set to SURFACE");
+    // There is a diagram of the MediaRecorder state machine here:
+    // https://developer.android.com/reference/android/media/MediaRecorder
+    Preconditions.checkState(
+        state == STATE_PREPARED || state == STATE_RECORDING,
+        "getSurface must be called after prepare() and before stop()");
+
+    if (recordingSurface == null) {
+      recordingSurfaceTexture = new SurfaceTexture(/*texName=*/ 0);
+      recordingSurface = new Surface(recordingSurfaceTexture);
+    }
+
+    return recordingSurface;
+  }
+
+  public Camera getCamera() {
+    return camera;
+  }
+
+  public int getAudioChannels() {
+    return audioChannels;
+  }
+
+  public int getAudioEncoder() {
+    return audioEncoder;
+  }
+
+  public int getAudioEncodingBitRate() {
+    return audioBitRate;
+  }
+
+  public int getAudioSamplingRate() {
+    return audioSamplingRate;
+  }
+
+  public int getAudioSource() {
+    return audioSource;
+  }
+
+  public int getMaxDuration() {
+    return maxDuration;
+  }
+
+  public long getMaxFileSize() {
+    return maxFileSize;
+  }
+
+  public String getOutputPath() {
+    return outputPath;
+  }
+
+  public int getOutputFormat() {
+    return outputFormat;
+  }
+
+  public int getVideoEncoder() {
+    return videoEncoder;
+  }
+
+  public int getVideoEncodingBitRate() {
+    return videoBitRate;
+  }
+
+  public int getVideoFrameRate() {
+    return videoFrameRate;
+  }
+
+  public int getVideoWidth() {
+    return videoWidth;
+  }
+
+  public int getVideoHeight() {
+    return videoHeight;
+  }
+
+  public int getVideoSource() {
+    return videoSource;
+  }
+
+  public Surface getPreviewDisplay() {
+    return previewDisplay;
+  }
+
+  public MediaRecorder.OnErrorListener getErrorListener() {
+    return errorListener;
+  }
+
+  public MediaRecorder.OnInfoListener getInfoListener() {
+    return infoListener;
+  }
+
+  public int getState() {
+    return state;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaRouter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaRouter.java
new file mode 100644
index 0000000..17da8e1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaRouter.java
@@ -0,0 +1,98 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+
+import android.media.AudioRoutesInfo;
+import android.media.MediaRouter;
+import android.media.MediaRouter.RouteInfo;
+import android.os.Parcel;
+import android.text.TextUtils;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow class for {@link android.media.MediaRouter}. */
+@Implements(MediaRouter.class)
+public class ShadowMediaRouter {
+  public static final String BLUETOOTH_DEVICE_NAME = "TestBluetoothDevice";
+
+  private @RealObject MediaRouter realObject;
+
+  /**
+   * Adds the Bluetooth A2DP route and ensures it's the selected route, simulating connecting a
+   * Bluetooth device.
+   */
+  public void addBluetoothRoute() {
+    updateBluetoothAudioRoute(BLUETOOTH_DEVICE_NAME);
+
+    if (RuntimeEnvironment.getApiLevel() <= JELLY_BEAN_MR1) {
+      ReflectionHelpers.callInstanceMethod(
+          MediaRouter.class,
+          realObject,
+          "selectRouteInt",
+          ClassParameter.from(int.class, MediaRouter.ROUTE_TYPE_LIVE_AUDIO),
+          ClassParameter.from(RouteInfo.class, getBluetoothA2dpRoute()));
+    } else {
+      realObject.selectRoute(MediaRouter.ROUTE_TYPE_LIVE_AUDIO, getBluetoothA2dpRoute());
+    }
+  }
+
+  /** Removes the Bluetooth A2DP route, simulating disconnecting the Bluetooth device. */
+  public void removeBluetoothRoute() {
+    // Android's AudioService passes a null Bluetooth device name to MediaRouter to signal that the
+    // A2DP route should be removed.
+    updateBluetoothAudioRoute(null);
+  }
+
+  /** Returns whether the Bluetooth A2DP route is the currently selected route. */
+  public boolean isBluetoothRouteSelected(int type) {
+    return realObject.getSelectedRoute(type).equals(getBluetoothA2dpRoute());
+  }
+
+  private static RouteInfo getBluetoothA2dpRoute() {
+    return ReflectionHelpers.getField(
+        ReflectionHelpers.getStaticField(MediaRouter.class, "sStatic"), "mBluetoothA2dpRoute");
+  }
+
+  /**
+   * Updates the MediaRouter's Bluetooth audio route.
+   *
+   * @param bluetoothDeviceName the name of the Bluetooth device or null to indicate that the
+   *     already-existing Bluetooth A2DP device should be removed
+   */
+  private void updateBluetoothAudioRoute(@Nullable String bluetoothDeviceName) {
+    callUpdateAudioRoutes(newAudioRouteInfo(bluetoothDeviceName));
+  }
+
+  /**
+   * Creates a new {@link AudioRoutesInfo} to be used for updating the Bluetooth audio route.
+   *
+   * @param bluetoothDeviceName the name of the Bluetooth device or null to indicate that the
+   *     already-existing Bluetooth A2DP device should be removed
+   */
+  private static AudioRoutesInfo newAudioRouteInfo(@Nullable String bluetoothDeviceName) {
+    Parcel p = Parcel.obtain();
+    TextUtils.writeToParcel(bluetoothDeviceName, p, /* parcelableFlags= */ 0);
+    p.setDataPosition(0);
+    return AudioRoutesInfo.CREATOR.createFromParcel(p);
+  }
+
+  private void callUpdateAudioRoutes(AudioRoutesInfo routesInfo) {
+    ReflectionHelpers.callInstanceMethod(
+        ReflectionHelpers.getStaticField(MediaRouter.class, "sStatic"),
+        RuntimeEnvironment.getApiLevel() <= JELLY_BEAN
+            ? "updateRoutes"
+            : "updateAudioRoutes",
+        ClassParameter.from(AudioRoutesInfo.class, routesInfo));
+  }
+
+  @Resetter
+  public static void reset() {
+    ReflectionHelpers.setStaticField(MediaRouter.class, "sStatic", null);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaScannerConnection.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaScannerConnection.java
new file mode 100644
index 0000000..62c821f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaScannerConnection.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.media.MediaScannerConnection;
+import android.media.MediaScannerConnection.OnScanCompletedListener;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow of {@link MediaScannerConnection} */
+@Implements(value = MediaScannerConnection.class)
+public class ShadowMediaScannerConnection {
+  private static final Set<String> savedPaths = new HashSet<>();
+  private static final Set<String> savedMimeTypes = new HashSet<>();
+
+  @Implementation
+  protected static void scanFile(
+      Context context, String[] paths, String[] mimeTypes, OnScanCompletedListener callback) {
+    if (paths != null) {
+      Collections.addAll(savedPaths, paths);
+    }
+    if (mimeTypes != null) {
+      Collections.addAll(savedMimeTypes, mimeTypes);
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    savedPaths.clear();
+    savedMimeTypes.clear();
+  }
+
+  /** Return the set of file paths scanned by scanFile() */
+  public static Set<String> getSavedPaths() {
+    return new HashSet<>(savedPaths);
+  }
+
+  /** Return the set of file mimeTypes scanned by scanFile() */
+  public static Set<String> getSavedMimeTypes() {
+    return new HashSet<>(savedMimeTypes);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaSession.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaSession.java
new file mode 100644
index 0000000..cbe683f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaSession.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.content.Context;
+import android.media.session.MediaSession;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = MediaSession.class, minSdk = LOLLIPOP)
+public class ShadowMediaSession {
+
+  @Implementation
+  protected void __constructor__(Context context, String tag) {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaSessionManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaSessionManager.java
new file mode 100644
index 0000000..1e634d4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaSessionManager.java
@@ -0,0 +1,96 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.ReflectionHelpers.createDeepProxy;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.session.ISessionManager;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.MediaSessionManager.OnActiveSessionsChangedListener;
+import android.os.Handler;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CopyOnWriteArraySet;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link MediaSessionManager}. */
+@Implements(value = MediaSessionManager.class, minSdk = LOLLIPOP)
+public class ShadowMediaSessionManager {
+  private final List<MediaController> controllers = new CopyOnWriteArrayList<>();
+  private final Set<OnActiveSessionsChangedListener> listeners = new CopyOnWriteArraySet<>();
+  @RealObject MediaSessionManager realMediaSessionManager;
+
+  @Implementation(minSdk = S)
+  protected void __constructor__(Context context) {
+    // the real constructor throws NPE when trying to load the service
+    reflector(MediaSessionManagerReflector.class, realMediaSessionManager).setContext(context);
+    reflector(MediaSessionManagerReflector.class, realMediaSessionManager)
+        .setService(createDeepProxy(ISessionManager.class));
+  }
+
+  @Implementation
+  protected List<MediaController> getActiveSessions(ComponentName ignoredNotificationListener) {
+    return ImmutableList.copyOf(controllers);
+  }
+
+  @Implementation
+  protected void addOnActiveSessionsChangedListener(
+      OnActiveSessionsChangedListener listener, ComponentName ignoredNotificationListener) {
+    listeners.add(listener);
+  }
+
+  @Implementation
+  protected void addOnActiveSessionsChangedListener(
+      OnActiveSessionsChangedListener listener,
+      ComponentName ignoredNotificationListener,
+      Handler ignoreHandler) {
+    listeners.add(listener);
+  }
+
+  @Implementation
+  protected void removeOnActiveSessionsChangedListener(OnActiveSessionsChangedListener listener) {
+    listeners.remove(listener);
+  }
+
+  /**
+   * Adds a {@link MediaController} that will be returned when calling {@link
+   * #getActiveSessions(ComponentName)}. This will trigger a callback on each {@link
+   * OnActiveSessionsChangedListener} callback registered with this class.
+   *
+   * @param controller The controller to add.
+   */
+  public void addController(MediaController controller) {
+    controllers.add(controller);
+    for (OnActiveSessionsChangedListener listener : listeners) {
+      listener.onActiveSessionsChanged(controllers);
+    }
+  }
+
+  /**
+   * Clears all controllers such that {@link #getActiveSessions(ComponentName)} will return the
+   * empty list.
+   */
+  public void clearControllers() {
+    controllers.clear();
+  }
+
+  @ForType(MediaSessionManager.class)
+  interface MediaSessionManagerReflector {
+
+    @Accessor("mContext")
+    void setContext(Context context);
+
+    @Accessor("mService")
+    void setService(ISessionManager service);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaStore.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaStore.java
new file mode 100644
index 0000000..be962b3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaStore.java
@@ -0,0 +1,96 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ContentResolver;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory.Options;
+import android.net.Uri;
+import android.provider.MediaStore;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link MediaStore}. */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(MediaStore.class)
+public class ShadowMediaStore {
+
+  private static Bitmap stubBitmap = null;
+
+  @Resetter
+  public static void reset() {
+    stubBitmap = null;
+  }
+
+  /** Shadow for {@link MediaStore.Images}. */
+  @Implements(MediaStore.Images.class)
+  public static class ShadowImages {
+
+    /** Shadow for {@link MediaStore.Images.Media}. */
+    @Implements(MediaStore.Images.Media.class)
+    public static class ShadowMedia {
+
+      @Implementation
+      protected static Bitmap getBitmap(ContentResolver cr, Uri url) {
+        return ShadowBitmapFactory.create(url.toString(), null, null);
+      }
+    }
+
+    /** Shadow for {@link MediaStore.Images.Thumbnails}. */
+    @Implements(MediaStore.Images.Thumbnails.class)
+    public static class ShadowThumbnails {
+
+      @Implementation
+      protected static Bitmap getThumbnail(
+          ContentResolver cr, long imageId, int kind, Options options) {
+        if (stubBitmap != null) {
+          return stubBitmap;
+        } else {
+          return reflector(ImagesThumbnailsReflector.class)
+              .getThumbnail(cr, imageId, kind, options);
+        }
+      }
+    }
+  }
+
+  /** Shadow for {@link MediaStore.Video}. */
+  @Implements(MediaStore.Video.class)
+  public static class ShadowVideo {
+
+    /** Shadow for {@link MediaStore.Video.Thumbnails}. */
+    @Implements(MediaStore.Video.Thumbnails.class)
+    public static class ShadowThumbnails {
+
+      @Implementation
+      protected static Bitmap getThumbnail(
+          ContentResolver cr, long imageId, int kind, Options options) {
+        if (stubBitmap != null) {
+          return stubBitmap;
+        } else {
+          return reflector(VideoThumbnailsReflector.class).getThumbnail(cr, imageId, kind, options);
+        }
+      }
+    }
+  }
+
+  public static void setStubBitmapForThumbnails(Bitmap bitmap) {
+    stubBitmap = bitmap;
+  }
+
+  /** Accessor interface for {@link MediaStore.Images.Thumbnails}'s internals. */
+  @ForType(MediaStore.Images.Thumbnails.class)
+  interface ImagesThumbnailsReflector {
+    @Direct
+    Bitmap getThumbnail(ContentResolver cr, long imageId, int kind, Options options);
+  }
+
+  /** Accessor interface for {@link MediaStore.Video.Thumbnails}'s internals. */
+  @ForType(MediaStore.Video.Thumbnails.class)
+  interface VideoThumbnailsReflector {
+    @Direct
+    Bitmap getThumbnail(ContentResolver cr, long imageId, int kind, Options options);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMemoryMappedFile.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMemoryMappedFile.java
new file mode 100644
index 0000000..98421b0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMemoryMappedFile.java
@@ -0,0 +1,148 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+
+import android.system.ErrnoException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import libcore.io.BufferIterator;
+import libcore.io.MemoryMappedFile;
+import libcore.io.Streams;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * This is used by Android to load and inferFromValue time zone information. Robolectric emulates
+ * this functionality by proxying to a time zone database file packaged into the android-all jar.
+ */
+@Implements(value = MemoryMappedFile.class, isInAndroidSdk = false)
+public class ShadowMemoryMappedFile {
+  protected byte[] bytes;
+  private static final String TZ_DATA_1 = "/misc/zoneinfo/tzdata";
+  private static final String TZ_DATA_2 = "/usr/share/zoneinfo/tzdata";
+  private static final String TZ_DATA_3 = "/misc/zoneinfo/current/tzdata";
+
+  @Implementation
+  public static MemoryMappedFile mmapRO(String path) throws Throwable {
+    if (path.endsWith(TZ_DATA_1) || path.endsWith(TZ_DATA_2) || path.endsWith(TZ_DATA_3)) {
+      InputStream is = MemoryMappedFile.class.getResourceAsStream(TZ_DATA_2);
+      if (is == null) {
+        throw (Throwable)
+            exceptionClass().getConstructor(String.class, int.class).newInstance("open", -1);
+      }
+      try {
+        MemoryMappedFile memoryMappedFile = new MemoryMappedFile(0L, 0L);
+        ShadowMemoryMappedFile shadowMemoryMappedFile = Shadow.extract(memoryMappedFile);
+        shadowMemoryMappedFile.bytes = Streams.readFully(is);
+        return memoryMappedFile;
+      } catch (IOException e) {
+        throw (Throwable)
+            exceptionClass()
+                .getConstructor(String.class, int.class, Throwable.class)
+                .newInstance("mmap", -1, e);
+      }
+    } else {
+      throw new IllegalArgumentException("Unknown file for mmap: '" + path);
+    }
+  }
+
+  private static Class exceptionClass() {
+    if (getApiLevel() >= LOLLIPOP) {
+      return ErrnoException.class;
+    } else {
+      try {
+        return MemoryMappedFile.class.getClassLoader().loadClass("libcore.io.ErrnoException");
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  @Implementation
+  public synchronized void close() throws Exception {
+    bytes = null;
+  }
+
+  @Implementation
+  public BufferIterator bigEndianIterator() {
+    return getHeapBufferIterator(ByteOrder.BIG_ENDIAN);
+  }
+
+  @Implementation
+  public BufferIterator littleEndianIterator() {
+    return getHeapBufferIterator(ByteOrder.LITTLE_ENDIAN);
+  }
+
+  protected BufferIterator getHeapBufferIterator(ByteOrder endianness) {
+    return new RoboBufferIterator(bytes, endianness);
+  }
+
+  @Implementation
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  public int size() {
+    return bytes.length;
+  }
+
+  protected static class RoboBufferIterator extends BufferIterator {
+    protected final ByteBuffer buffer;
+
+    public RoboBufferIterator(byte[] buffer, ByteOrder order) {
+      this.buffer = ByteBuffer.wrap(buffer);
+    }
+
+    @Override
+    public void seek(int offset) {
+      ((Buffer) buffer).position(offset);
+    }
+
+    @Override
+    public void skip(int byteCount) {
+      ((Buffer) buffer).position(buffer.position() + byteCount);
+    }
+
+    @Override
+    public int pos() {
+      return 0;
+    }
+
+    @Override
+    public void readByteArray(byte[] dst, int dstOffset, int byteCount) {
+      System.arraycopy(buffer.array(), buffer.position(), dst, dstOffset, byteCount);
+      skip(byteCount);
+    }
+
+    @Override
+    public byte readByte() {
+      return buffer.get();
+    }
+
+    @Override
+    public int readInt() {
+      return buffer.getInt();
+    }
+
+    @Override
+    public void readIntArray(int[] dst, int dstOffset, int intCount) {
+      for (int i = 0; i < intCount; i++) {
+        dst[dstOffset + i] = buffer.getInt();
+      }
+    }
+
+    @Override
+    public void readLongArray(long[] dst, int dstOffset, int longCount) {
+      for (int i = 0; i < longCount; i++) {
+        dst[dstOffset + i] = buffer.getLong();
+      }
+    }
+
+    @Override
+    public short readShort() {
+      return buffer.getShort();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMemoryMappedFileS.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMemoryMappedFileS.java
new file mode 100644
index 0000000..79920d2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMemoryMappedFileS.java
@@ -0,0 +1,128 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+
+import android.system.ErrnoException;
+import com.android.i18n.timezone.internal.BufferIterator;
+import com.android.i18n.timezone.internal.MemoryMappedFile;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.Buffer;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import libcore.io.Streams;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+/** Fork of ShadowMemoryMappedFile to adjust to changed package names of MemoryMappedFile in S. */
+@Implements(value = MemoryMappedFile.class, isInAndroidSdk = false, minSdk = S)
+public class ShadowMemoryMappedFileS {
+  protected byte[] bytes;
+  private static final String TZ_DATA_1 = "/misc/zoneinfo/tzdata";
+  private static final String TZ_DATA_2 = "/usr/share/zoneinfo/tzdata";
+  private static final String TZ_DATA_3 = "/misc/zoneinfo/current/tzdata";
+
+  @Implementation
+  public static MemoryMappedFile mmapRO(String path) throws Throwable {
+    if (path.endsWith(TZ_DATA_1) || path.endsWith(TZ_DATA_2) || path.endsWith(TZ_DATA_3)) {
+      InputStream is = MemoryMappedFile.class.getResourceAsStream(TZ_DATA_2);
+      if (is == null) {
+        throw new ErrnoException("open", -1);
+      }
+      try {
+        MemoryMappedFile memoryMappedFile = new MemoryMappedFile(0L, 0L);
+        ShadowMemoryMappedFileS shadowMemoryMappedFile = Shadow.extract(memoryMappedFile);
+        shadowMemoryMappedFile.bytes = Streams.readFully(is);
+        return memoryMappedFile;
+      } catch (IOException e) {
+        throw new ErrnoException("mmap", -1);
+      }
+    } else {
+      throw new IllegalArgumentException("Unknown file for mmap: '" + path);
+    }
+  }
+
+  @Implementation
+  public synchronized void close() throws Exception {
+    bytes = null;
+  }
+
+  @Implementation
+  public BufferIterator bigEndianIterator() {
+    return getHeapBufferIterator(ByteOrder.BIG_ENDIAN);
+  }
+
+  @Implementation
+  public BufferIterator littleEndianIterator() {
+    return getHeapBufferIterator(ByteOrder.LITTLE_ENDIAN);
+  }
+
+  protected BufferIterator getHeapBufferIterator(ByteOrder endianness) {
+    return new RoboBufferIterator(bytes, endianness);
+  }
+
+  @Implementation
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  public int size() {
+    return bytes.length;
+  }
+
+  protected static class RoboBufferIterator extends BufferIterator {
+    protected final ByteBuffer buffer;
+
+    public RoboBufferIterator(byte[] buffer, ByteOrder order) {
+      this.buffer = ByteBuffer.wrap(buffer);
+    }
+
+    @Override
+    public void seek(int offset) {
+      ((Buffer) buffer).position(offset);
+    }
+
+    @Override
+    public void skip(int byteCount) {
+      ((Buffer) buffer).position(buffer.position() + byteCount);
+    }
+
+    @Override
+    public int pos() {
+      return 0;
+    }
+
+    @Override
+    public void readByteArray(byte[] dst, int dstOffset, int byteCount) {
+      System.arraycopy(buffer.array(), buffer.position(), dst, dstOffset, byteCount);
+      skip(byteCount);
+    }
+
+    @Override
+    public byte readByte() {
+      return buffer.get();
+    }
+
+    @Override
+    public int readInt() {
+      return buffer.getInt();
+    }
+
+    @Override
+    public void readIntArray(int[] dst, int dstOffset, int intCount) {
+      for (int i = 0; i < intCount; i++) {
+        dst[dstOffset + i] = buffer.getInt();
+      }
+    }
+
+    @Override
+    public void readLongArray(long[] dst, int dstOffset, int longCount) {
+      for (int i = 0; i < longCount; i++) {
+        dst[dstOffset + i] = buffer.getLong();
+      }
+    }
+
+    @Override
+    public short readShort() {
+      return buffer.getShort();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessage.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessage.java
new file mode 100644
index 0000000..09b7ce1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessage.java
@@ -0,0 +1,114 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Handler;
+import android.os.Message;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * The shadow API for {@link android.os.Message}.
+ *
+ * <p>Different shadow implementations will be used depending on the current {@link LooperMode}. See
+ * {@link ShadowLegacyMessage} and {@link ShadowPausedMessage} for details.
+ */
+@Implements(value = Message.class, shadowPicker = ShadowMessage.Picker.class)
+public abstract class ShadowMessage {
+
+  /** The shadow Picker for this class */
+  public static class Picker extends LooperShadowPicker<ShadowMessage> {
+
+    public Picker() {
+      super(ShadowLegacyMessage.class, ShadowPausedMessage.class);
+    }
+  }
+
+  /** Exposes the package-private {@link Message#recycleUnchecked()} */
+  public abstract void recycleUnchecked();
+
+  /**
+   * Stores the {@link Runnable} instance that has been scheduled to invoke this message. This is
+   * called when the message is enqueued by {@link ShadowLegacyMessageQueue#enqueueMessage} and is
+   * used when the message is recycled to ensure that the correct {@link Runnable} instance is
+   * removed from the associated scheduler.
+   *
+   * @param r the {@link Runnable} instance that is scheduled to trigger this message.
+   *     <p>#if ($api >= 21) * @see #recycleUnchecked() #else * @see #recycle() #end
+   *     <p>Only supported in {@link LooperMode.Mode.LEGACY}.
+   */
+  public abstract void setScheduledRunnable(Runnable r);
+
+  /**
+   * Convenience method to provide getter access to the private field {@code Message.next}.
+   *
+   * <p>Only supported in {@link LooperMode.Mode.LEGACY}
+   *
+   * @return The next message in the current message chain.
+   * @see #setNext(Message)
+   */
+  public abstract Message getNext();
+
+  /**
+   * Convenience method to provide setter access to the private field {@code Message.next}.
+   *
+   * <p>Only supported in {@link LooperMode.Mode.LEGACY}
+   *
+   * @param next the new next message for the current message.
+   * @see #getNext()
+   */
+  public abstract void setNext(Message next);
+
+  /**
+   * Resets the static state of the {@link Message} class by
+   * emptying the message pool.
+   */
+  @Resetter
+  public static void reset() {
+    Object lock = reflector(MessageReflector.class).getPoolSync();
+    synchronized (lock) {
+      reflector(MessageReflector.class).setPoolSize(0);
+      reflector(MessageReflector.class).setPool(null);
+    }
+  }
+
+  /** Accessor interface for {@link Message}'s internals. */
+  @ForType(Message.class)
+  interface MessageReflector {
+
+    @Direct
+    void recycle();
+
+    @Direct
+    void recycleUnchecked();
+
+    @Static
+    @Accessor("sPool")
+    void setPool(Message o);
+
+    @Static
+    @Accessor("sPoolSize")
+    void setPoolSize(int size);
+
+    @Static
+    @Accessor("sPoolSync")
+    Object getPoolSync();
+
+    @Accessor("when")
+    long getWhen();
+
+    @Accessor("next")
+    Message getNext();
+
+    @Accessor("next")
+    void setNext(Message next);
+
+    @Accessor("target")
+    Handler getTarget();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessageQueue.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessageQueue.java
new file mode 100644
index 0000000..0891e27
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessageQueue.java
@@ -0,0 +1,58 @@
+package org.robolectric.shadows;
+
+import android.os.Message;
+import android.os.MessageQueue;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.Scheduler;
+
+/**
+ * The shadow API for {@link MessageQueue}.
+ *
+ * <p>Different shadow implementations will be used depending on the current {@link LooperMode}. See
+ * {@link ShadowLegacyMessageQueue} and {@link ShadowPausedMessageQueue} for details.
+ */
+@Implements(value = MessageQueue.class, shadowPicker = ShadowMessageQueue.Picker.class)
+public abstract class ShadowMessageQueue {
+
+  /** The shadow Picker for this class. */
+  public static class Picker extends LooperShadowPicker<ShadowMessageQueue> {
+
+    public Picker() {
+      super(ShadowLegacyMessageQueue.class, ShadowPausedMessageQueue.class);
+    }
+  }
+
+  /**
+   * Return this queue's Scheduler.
+   *
+   * <p>Only supported in {@link LooperMode.Mode.LEGACY}.
+   */
+  public abstract Scheduler getScheduler();
+
+  /**
+   * Set this queue's Scheduler.
+   *
+   * <p>Only supported in {@link LooperMode.Mode.LEGACY}.
+   */
+  public abstract void setScheduler(Scheduler scheduler);
+
+  /**
+   * Retrieves the current Message at the top of the queue.
+   *
+   * <p>Only supported in {@link LooperMode.Mode.LEGACY}.
+   */
+  public abstract Message getHead();
+
+  /**
+   * Sets the current Message at the top of the queue.
+   *
+   * <p>Only supported in {@link LooperMode.Mode.LEGACY}.
+   */
+  public abstract void setHead(Message msg);
+
+  /**
+   * Reset the messageQueue state. Should not be called by tests - it intended for use by the
+   * Robolectric framework.
+   */
+  public abstract void reset();
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessenger.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessenger.java
new file mode 100644
index 0000000..6087ce3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMessenger.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(Messenger.class)
+public class ShadowMessenger {
+  private static Message lastMessageSent = null;
+
+  /** Returns the last {@link Message} sent, or {@code null} if there isn't any message sent. */
+  public static Message getLastMessageSent() {
+    return lastMessageSent;
+  }
+
+  @RealObject private Messenger messenger;
+
+  @Implementation
+  protected void send(Message message) throws RemoteException {
+    lastMessageSent = Message.obtain(message);
+    reflector(MessengerReflector.class, messenger).send(message);
+  }
+
+  @Resetter
+  public static void reset() {
+    lastMessageSent = null;
+  }
+
+  @ForType(Messenger.class)
+  interface MessengerReflector {
+
+    @Direct
+    void send(Message message);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMimeTypeMap.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMimeTypeMap.java
new file mode 100644
index 0000000..d1e9f3d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMimeTypeMap.java
@@ -0,0 +1,74 @@
+package org.robolectric.shadows;
+
+import android.webkit.MimeTypeMap;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(MimeTypeMap.class)
+public class ShadowMimeTypeMap {
+  private final Map<String, String> extensionToMimeTypeMap = new HashMap<>();
+  private final Map<String, String> mimeTypeToExtensionMap = new HashMap<>();
+  private static volatile MimeTypeMap singleton = null;
+  private static final Object singletonLock = new Object();
+
+  @Implementation
+  protected static MimeTypeMap getSingleton() {
+    if (singleton == null) {
+      synchronized (singletonLock) {
+        if (singleton == null) {
+          singleton = Shadow.newInstanceOf(MimeTypeMap.class);
+        }
+      }
+    }
+
+    return singleton;
+  }
+
+  @Resetter
+  public static void reset() {
+    if (singleton != null) {
+      ShadowMimeTypeMap shadowMimeTypeMap = Shadow.extract(getSingleton());
+      shadowMimeTypeMap.clearMappings();
+    }
+  }
+
+  @Implementation
+  protected String getMimeTypeFromExtension(String extension) {
+    if (extensionToMimeTypeMap.containsKey(extension))
+      return extensionToMimeTypeMap.get(extension);
+
+    return null;
+  }
+
+  @Implementation
+  protected String getExtensionFromMimeType(String mimeType) {
+    if (mimeTypeToExtensionMap.containsKey(mimeType))
+      return mimeTypeToExtensionMap.get(mimeType);
+
+    return null;
+  }
+
+  public void addExtensionMimeTypMapping(String extension, String mimeType) {
+    extensionToMimeTypeMap.put(extension, mimeType);
+    mimeTypeToExtensionMap.put(mimeType, extension);
+  }
+
+  public void clearMappings() {
+    extensionToMimeTypeMap.clear();
+    mimeTypeToExtensionMap.clear();
+  }
+
+  @Implementation
+  protected boolean hasExtension(String extension) {
+    return extensionToMimeTypeMap.containsKey(extension);
+  }
+
+  @Implementation
+  protected boolean hasMimeType(String mimeType) {
+    return mimeTypeToExtensionMap.containsKey(mimeType);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMotionEvent.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMotionEvent.java
new file mode 100644
index 0000000..6b8e67e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMotionEvent.java
@@ -0,0 +1,1028 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_ORIENTATION;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_PRESSURE;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_SIZE;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOOL_MAJOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOOL_MINOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOUCH_MAJOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_TOUCH_MINOR;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_X;
+import static org.robolectric.shadows.NativeAndroidInput.AMOTION_EVENT_AXIS_Y;
+
+import android.graphics.Matrix;
+import android.os.Parcel;
+import android.view.MotionEvent;
+import android.view.MotionEvent.PointerCoords;
+import android.view.MotionEvent.PointerProperties;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.android.NativeObjRegistry;
+import org.robolectric.util.ReflectionHelpers;
+
+/**
+ * Shadow of MotionEvent.
+ *
+ * <p>The Android framework stores motion events in a pool of native objects. All motion event data
+ * is stored natively, and accessed via a series of static native methods following the pattern
+ * nativeGetXXXX(mNativePtr, ...)
+ *
+ * <p>This shadow mirrors this design, but has java equivalents of each native object. Most of the
+ * contents of this class were transliterated from oreo-mr1 (SDK 27)
+ * frameworks/base/core/jni/android_view_MotionEvent.cpp
+ *
+ * @see <a
+ *     href="https://android.googlesource.com/platform/frameworks/base/+/oreo-mr1-release/core/jni/android_view_MotionEvent.cpp">core/jni/android_view_MotionEvent.cpp</a>
+ *     <p>Tests should not reference this class directly. MotionEvents should be created via one of
+ *     the MotionEvent.obtain methods or via MotionEventBuilder.
+ */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(MotionEvent.class)
+public class ShadowMotionEvent extends ShadowInputEvent {
+
+  private static NativeObjRegistry<NativeInput.MotionEvent> nativeMotionEventRegistry =
+      new NativeObjRegistry<>(NativeInput.MotionEvent.class);
+
+  private static final int HISTORY_CURRENT = -0x80000000;
+
+  @RealObject private MotionEvent realMotionEvent;
+
+  @Resetter
+  public static void reset() {
+    // rely on MotionEvent finalizer to clear native object instead of calling
+    // nativeMotionEventRegistry.clear();
+    ReflectionHelpers.setStaticField(MotionEvent.class, "gRecyclerTop", null);
+    ReflectionHelpers.setStaticField(MotionEvent.class, "gSharedTempPointerCoords", null);
+    ReflectionHelpers.setStaticField(MotionEvent.class, "gSharedTempPointerProperties", null);
+    ReflectionHelpers.setStaticField(MotionEvent.class, "gRecyclerUsed", 0);
+    ReflectionHelpers.setStaticField(MotionEvent.class, "gSharedTempPointerIndexMap", null);
+  }
+
+  private static void validatePointerCount(int pointerCount) {
+    checkState(pointerCount >= 1, "pointerCount must be at least 1");
+  }
+
+  private static void validatePointerPropertiesArray(
+      PointerProperties[] pointerPropertiesObjArray, int pointerCount) {
+    checkNotNull(pointerPropertiesObjArray, "pointerProperties array must not be null");
+    checkState(
+        pointerPropertiesObjArray.length >= pointerCount,
+        "pointerProperties array must be large enough to hold all pointers");
+  }
+
+  private static void validatePointerCoordsObjArray(
+      PointerCoords[] pointerCoordsObjArray, int pointerCount) {
+    checkNotNull(pointerCoordsObjArray, "pointerCoords array must not be null");
+    checkState(
+        pointerCoordsObjArray.length >= pointerCount,
+        "pointerCoords array must be large enough to hold all pointers");
+  }
+
+  private static void validatePointerIndex(int pointerIndex, int pointerCount) {
+    checkState(pointerIndex >= 0 && pointerIndex < pointerCount, "pointerIndex out of range");
+  }
+
+  private static void validateHistoryPos(int historyPos, int historySize) {
+    checkState(historyPos >= 0 && historyPos < historySize, "historyPos out of range");
+  }
+
+  private static void validatePointerCoords(PointerCoords pointerCoordsObj) {
+    checkNotNull(pointerCoordsObj, "pointerCoords must not be null");
+  }
+
+  private static void validatePointerProperties(PointerProperties pointerPropertiesObj) {
+    checkNotNull(pointerPropertiesObj, "pointerProperties must not be null");
+  }
+
+  private static NativeInput.PointerCoords pointerCoordsToNative(
+      PointerCoords pointerCoordsObj, float xOffset, float yOffset) {
+    NativeInput.PointerCoords outRawPointerCoords = new NativeInput.PointerCoords();
+    outRawPointerCoords.clear();
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_X, pointerCoordsObj.x - xOffset);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_Y, pointerCoordsObj.y - yOffset);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_PRESSURE, pointerCoordsObj.pressure);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_SIZE, pointerCoordsObj.size);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_TOUCH_MAJOR, pointerCoordsObj.touchMajor);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_TOUCH_MINOR, pointerCoordsObj.touchMinor);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_TOOL_MAJOR, pointerCoordsObj.toolMajor);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_TOOL_MINOR, pointerCoordsObj.toolMinor);
+    outRawPointerCoords.setAxisValue(AMOTION_EVENT_AXIS_ORIENTATION, pointerCoordsObj.orientation);
+    long packedAxisBits = ReflectionHelpers.getField(pointerCoordsObj, "mPackedAxisBits");
+    NativeBitSet64 bits = new NativeBitSet64(packedAxisBits);
+    if (!bits.isEmpty()) {
+      float[] valuesArray = ReflectionHelpers.getField(pointerCoordsObj, "mPackedAxisValues");
+      if (valuesArray != null) {
+        int index = 0;
+        do {
+          int axis = bits.clearFirstMarkedBit();
+          outRawPointerCoords.setAxisValue(axis, valuesArray[index++]);
+        } while (!bits.isEmpty());
+      }
+    }
+    return outRawPointerCoords;
+  }
+
+  private static float[] obtainPackedAxisValuesArray(
+      int minSize, PointerCoords outPointerCoordsObj) {
+    float[] outValuesArray = ReflectionHelpers.getField(outPointerCoordsObj, "mPackedAxisValues");
+    if (outValuesArray != null) {
+      int size = outValuesArray.length;
+      if (minSize <= size) {
+        return outValuesArray;
+      }
+    }
+    int size = 8;
+    while (size < minSize) {
+      size *= 2;
+    }
+    outValuesArray = new float[size];
+    ReflectionHelpers.setField(outPointerCoordsObj, "mPackedAxisValues", outValuesArray);
+    return outValuesArray;
+  }
+
+  private static void pointerCoordsFromNative(
+      NativeInput.PointerCoords rawPointerCoords,
+      float xOffset,
+      float yOffset,
+      PointerCoords outPointerCoordsObj) {
+    outPointerCoordsObj.x = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_X) + xOffset;
+    outPointerCoordsObj.y = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_Y) + yOffset;
+    outPointerCoordsObj.pressure = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_PRESSURE);
+    outPointerCoordsObj.size = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_SIZE);
+    outPointerCoordsObj.touchMajor = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_TOUCH_MAJOR);
+    outPointerCoordsObj.touchMinor = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_TOUCH_MINOR);
+    outPointerCoordsObj.toolMajor = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_TOOL_MAJOR);
+    outPointerCoordsObj.toolMinor = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_TOOL_MINOR);
+    outPointerCoordsObj.orientation = rawPointerCoords.getAxisValue(AMOTION_EVENT_AXIS_ORIENTATION);
+    long outBits = 0;
+    NativeBitSet64 bits = new NativeBitSet64(rawPointerCoords.getBits());
+    bits.clearBit(AMOTION_EVENT_AXIS_X);
+    bits.clearBit(AMOTION_EVENT_AXIS_Y);
+    bits.clearBit(AMOTION_EVENT_AXIS_PRESSURE);
+    bits.clearBit(AMOTION_EVENT_AXIS_SIZE);
+    bits.clearBit(AMOTION_EVENT_AXIS_TOUCH_MAJOR);
+    bits.clearBit(AMOTION_EVENT_AXIS_TOUCH_MINOR);
+    bits.clearBit(AMOTION_EVENT_AXIS_TOOL_MAJOR);
+    bits.clearBit(AMOTION_EVENT_AXIS_TOOL_MINOR);
+    bits.clearBit(AMOTION_EVENT_AXIS_ORIENTATION);
+    if (!bits.isEmpty()) {
+      int packedAxesCount = bits.count();
+      float[] outValuesArray = obtainPackedAxisValuesArray(packedAxesCount, outPointerCoordsObj);
+      float[] outValues = outValuesArray;
+      int index = 0;
+      do {
+        int axis = bits.clearFirstMarkedBit();
+        outBits |= NativeBitSet64.valueForBit(axis);
+        outValues[index++] = rawPointerCoords.getAxisValue(axis);
+      } while (!bits.isEmpty());
+    }
+    ReflectionHelpers.setField(outPointerCoordsObj, "mPackedAxisBits", outBits);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeInitialize(
+      int nativePtr,
+      int deviceId,
+      int source,
+      int action,
+      int flags,
+      int edgeFlags,
+      int metaState,
+      int buttonState,
+      float xOffset,
+      float yOffset,
+      float xPrecision,
+      float yPrecision,
+      long downTimeNanos,
+      long eventTimeNanos,
+      int pointerCount,
+      PointerProperties[] pointerIds,
+      PointerCoords[] pointerCoords) {
+    return (int)
+        nativeInitialize(
+            (long) nativePtr,
+            deviceId,
+            source,
+            action,
+            flags,
+            edgeFlags,
+            metaState,
+            buttonState,
+            xOffset,
+            yOffset,
+            xPrecision,
+            yPrecision,
+            downTimeNanos,
+            eventTimeNanos,
+            pointerCount,
+            pointerIds,
+            pointerCoords);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = P)
+  @HiddenApi
+  protected static long nativeInitialize(
+      long nativePtr,
+      int deviceId,
+      int source,
+      int action,
+      int flags,
+      int edgeFlags,
+      int metaState,
+      int buttonState,
+      float xOffset,
+      float yOffset,
+      float xPrecision,
+      float yPrecision,
+      long downTimeNanos,
+      long eventTimeNanos,
+      int pointerCount,
+      PointerProperties[] pointerPropertiesObjArray,
+      PointerCoords[] pointerCoordsObjArray) {
+
+    validatePointerCount(pointerCount);
+    validatePointerPropertiesArray(pointerPropertiesObjArray, pointerCount);
+    validatePointerCoordsObjArray(pointerCoordsObjArray, pointerCount);
+
+    NativeInput.MotionEvent event;
+    if (nativePtr > 0) {
+      event = nativeMotionEventRegistry.getNativeObject(nativePtr);
+    } else {
+      event = new NativeInput.MotionEvent();
+      nativePtr = nativeMotionEventRegistry.register(event);
+    }
+
+    NativeInput.PointerCoords[] rawPointerCoords = new NativeInput.PointerCoords[pointerCount];
+    for (int i = 0; i < pointerCount; i++) {
+      PointerCoords pointerCoordsObj = pointerCoordsObjArray[i];
+      checkNotNull(pointerCoordsObj);
+      rawPointerCoords[i] = pointerCoordsToNative(pointerCoordsObj, xOffset, yOffset);
+    }
+
+    event.initialize(
+        deviceId,
+        source,
+        action,
+        0,
+        flags,
+        edgeFlags,
+        metaState,
+        buttonState,
+        xOffset,
+        yOffset,
+        xPrecision,
+        yPrecision,
+        downTimeNanos,
+        eventTimeNanos,
+        pointerCount,
+        pointerPropertiesObjArray,
+        rawPointerCoords);
+    return nativePtr;
+  }
+
+  // TODO(brettchabot): properly handle displayId
+  @Implementation(minSdk = android.os.Build.VERSION_CODES.Q)
+  @HiddenApi
+  protected static long nativeInitialize(
+      long nativePtr,
+      int deviceId,
+      int source,
+      int displayId,
+      int action,
+      int flags,
+      int edgeFlags,
+      int metaState,
+      int buttonState,
+      int classification,
+      float xOffset,
+      float yOffset,
+      float xPrecision,
+      float yPrecision,
+      long downTimeNanos,
+      long eventTimeNanos,
+      int pointerCount,
+      PointerProperties[] pointerIds,
+      PointerCoords[] pointerCoords) {
+        return
+        nativeInitialize(
+            nativePtr,
+            deviceId,
+            source,
+            action,
+            flags,
+            edgeFlags,
+            metaState,
+            buttonState,
+            xOffset,
+            yOffset,
+            xPrecision,
+            yPrecision,
+            downTimeNanos,
+            eventTimeNanos,
+            pointerCount,
+            pointerIds,
+            pointerCoords);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeDispose(int nativePtr) {
+    nativeDispose((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeDispose(long nativePtr) {
+    nativeMotionEventRegistry.unregister(nativePtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeAddBatch(
+      int nativePtr, long eventTimeNanos, PointerCoords[] pointerCoordsObjArray, int metaState) {
+    nativeAddBatch((long) nativePtr, eventTimeNanos, pointerCoordsObjArray, metaState);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeAddBatch(
+      long nativePtr, long eventTimeNanos, PointerCoords[] pointerCoordsObjArray, int metaState) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerCoordsObjArray(pointerCoordsObjArray, pointerCount);
+    NativeInput.PointerCoords[] rawPointerCoords = new NativeInput.PointerCoords[pointerCount];
+    for (int i = 0; i < pointerCount; i++) {
+      PointerCoords pointerCoordsObj = pointerCoordsObjArray[i];
+      checkNotNull(pointerCoordsObj);
+      rawPointerCoords[i] =
+          pointerCoordsToNative(pointerCoordsObj, event.getXOffset(), event.getYOffset());
+    }
+    event.addSample(eventTimeNanos, rawPointerCoords);
+    event.setMetaState(event.getMetaState() | metaState);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeGetPointerCoords(
+      int nativePtr, int pointerIndex, int historyPos, PointerCoords outPointerCoordsObj) {
+    nativeGetPointerCoords((long) nativePtr, pointerIndex, historyPos, outPointerCoordsObj);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeGetPointerCoords(
+      long nativePtr, int pointerIndex, int historyPos, PointerCoords outPointerCoordsObj) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerIndex(pointerIndex, pointerCount);
+    validatePointerCoords(outPointerCoordsObj);
+
+    NativeInput.PointerCoords rawPointerCoords;
+    if (historyPos == HISTORY_CURRENT) {
+      rawPointerCoords = event.getRawPointerCoords(pointerIndex);
+    } else {
+      int historySize = event.getHistorySize();
+      validateHistoryPos(historyPos, historySize);
+      rawPointerCoords = event.getHistoricalRawPointerCoords(pointerIndex, historyPos);
+    }
+    pointerCoordsFromNative(
+        rawPointerCoords, event.getXOffset(), event.getYOffset(), outPointerCoordsObj);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeGetPointerProperties(
+      int nativePtr, int pointerIndex, PointerProperties outPointerPropertiesObj) {
+    nativeGetPointerProperties((long) nativePtr, pointerIndex, outPointerPropertiesObj);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeGetPointerProperties(
+      long nativePtr, int pointerIndex, PointerProperties outPointerPropertiesObj) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerIndex(pointerIndex, pointerCount);
+    validatePointerProperties(outPointerPropertiesObj);
+
+    PointerProperties pointerProperties = event.getPointerProperties(pointerIndex);
+    // pointerPropertiesFromNative(env, pointerProperties, outPointerPropertiesObj);
+    outPointerPropertiesObj.copyFrom(pointerProperties);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeReadFromParcel(int nativePtr, Parcel parcelObj) {
+    return (int) nativeReadFromParcel((long) nativePtr, parcelObj);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static long nativeReadFromParcel(long nativePtr, Parcel parcelObj) {
+    NativeInput.MotionEvent event;
+    if (nativePtr == 0) {
+      event = new NativeInput.MotionEvent();
+      nativePtr = nativeMotionEventRegistry.register(event);
+    } else {
+      event = nativeMotionEventRegistry.getNativeObject(nativePtr);
+    }
+    boolean status = event.readFromParcel(parcelObj);
+    if (!status) {
+      if (nativePtr > 0) {
+        nativeMotionEventRegistry.unregister(nativePtr);
+      }
+      throw new RuntimeException("Failed to read MotionEvent parcel.");
+    }
+    return nativePtr;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeWriteToParcel(int nativePtr, Parcel parcel) {
+    nativeWriteToParcel((long) nativePtr, parcel);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeWriteToParcel(long nativePtr, Parcel parcel) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    if (!event.writeToParcel(parcel)) {
+      throw new RuntimeException("Failed to write MotionEvent parcel.");
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static String nativeAxisToString(int axis) {
+    // The native code just mirrors the AXIS_* constants defined in MotionEvent.java.
+    // Look up the field value by reflection to future proof this method
+    for (Field field : MotionEvent.class.getDeclaredFields()) {
+      int modifiers = field.getModifiers();
+      try {
+        if (Modifier.isStatic(modifiers)
+            && Modifier.isPublic(modifiers)
+            && field.getName().startsWith("AXIS_")
+            && field.getInt(null) == axis) {
+          // return the field name stripping off the "AXIS_" prefix
+          return field.getName().substring(5);
+        }
+      } catch (IllegalAccessException e) {
+        // ignore
+      }
+    }
+    return null;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeAxisFromString(String label) {
+    // The native code just mirrors the AXIS_* constants defined in MotionEvent.java. Look up
+    // the field value by reflection
+    try {
+      Field constantField = MotionEvent.class.getDeclaredField("AXIS_" + label);
+      return constantField.getInt(null);
+    } catch (NoSuchFieldException | IllegalAccessException e) {
+      return 0;
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetPointerId(int nativePtr, int pointerIndex) {
+    return nativeGetPointerId((long) nativePtr, pointerIndex);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetPointerId(long nativePtr, int pointerIndex) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerIndex(pointerIndex, pointerCount);
+    return event.getPointerId(pointerIndex);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetToolType(int nativePtr, int pointerIndex) {
+    return nativeGetToolType((long) nativePtr, pointerIndex);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetToolType(long nativePtr, int pointerIndex) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerIndex(pointerIndex, pointerCount);
+    return event.getToolType(pointerIndex);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static long nativeGetEventTimeNanos(int nativePtr, int historyPos) {
+    return nativeGetEventTimeNanos((long) nativePtr, historyPos);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static long nativeGetEventTimeNanos(long nativePtr, int historyPos) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    if (historyPos == HISTORY_CURRENT) {
+      return event.getEventTime();
+    } else {
+      int historySize = event.getHistorySize();
+      validateHistoryPos(historyPos, historySize);
+      return event.getHistoricalEventTime(historyPos);
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static float nativeGetRawAxisValue(
+      int nativePtr, int axis, int pointerIndex, int historyPos) {
+    return nativeGetRawAxisValue((long) nativePtr, axis, pointerIndex, historyPos);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static float nativeGetRawAxisValue(
+      long nativePtr, int axis, int pointerIndex, int historyPos) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerIndex(pointerIndex, pointerCount);
+
+    if (historyPos == HISTORY_CURRENT) {
+      return event.getRawAxisValue(axis, pointerIndex);
+    } else {
+      int historySize = event.getHistorySize();
+      validateHistoryPos(historyPos, historySize);
+      return event.getHistoricalRawAxisValue(axis, pointerIndex, historyPos);
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static float nativeGetAxisValue(
+      int nativePtr, int axis, int pointerIndex, int historyPos) {
+    return nativeGetAxisValue((long) nativePtr, axis, pointerIndex, historyPos);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static float nativeGetAxisValue(
+      long nativePtr, int axis, int pointerIndex, int historyPos) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    int pointerCount = event.getPointerCount();
+    validatePointerIndex(pointerIndex, pointerCount);
+
+    if (historyPos == HISTORY_CURRENT) {
+      return event.getAxisValue(axis, pointerIndex);
+    } else {
+      int historySize = event.getHistorySize();
+      validateHistoryPos(historyPos, historySize);
+      return event.getHistoricalAxisValue(axis, pointerIndex, historyPos);
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeCopy(int destNativePtr, int sourceNativePtr, boolean keepHistory) {
+    return (int) nativeCopy((long) destNativePtr, (long) sourceNativePtr, keepHistory);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static long nativeCopy(long destNativePtr, long sourceNativePtr, boolean keepHistory) {
+    NativeInput.MotionEvent destEvent = nativeMotionEventRegistry.peekNativeObject(destNativePtr);
+    if (destEvent == null) {
+      destEvent = new NativeInput.MotionEvent();
+      destNativePtr = nativeMotionEventRegistry.register(destEvent);
+    }
+    NativeInput.MotionEvent sourceEvent = getNativeMotionEvent(sourceNativePtr);
+    destEvent.copyFrom(sourceEvent, keepHistory);
+    return destNativePtr;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetDeviceId(int nativePtr) {
+    return nativeGetDeviceId((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetDeviceId(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getDeviceId();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetSource(int nativePtr) {
+    return nativeGetSource((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetSource(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getSource();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeSetSource(int nativePtr, int source) {
+    nativeSetSource((long) nativePtr, source);
+    return 0;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  protected static void nativeSetSource(long nativePtr, int source) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setSource(source);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetAction(int nativePtr) {
+    return nativeGetAction((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetAction(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getAction();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeSetAction(int nativePtr, int action) {
+    nativeSetAction((long) nativePtr, action);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeSetAction(long nativePtr, int action) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setAction(action);
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected static int nativeGetActionButton(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getActionButton();
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected static void nativeSetActionButton(long nativePtr, int button) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setActionButton(button);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static boolean nativeIsTouchEvent(int nativePtr) {
+    return nativeIsTouchEvent((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static boolean nativeIsTouchEvent(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.isTouchEvent();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetFlags(int nativePtr) {
+    return nativeGetFlags((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetFlags(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getFlags();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeSetFlags(int nativePtr, int flags) {
+    nativeSetFlags((long) nativePtr, flags);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeSetFlags(long nativePtr, int flags) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setFlags(flags);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetEdgeFlags(int nativePtr) {
+    return nativeGetEdgeFlags((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetEdgeFlags(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getEdgeFlags();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeSetEdgeFlags(int nativePtr, int edgeFlags) {
+    nativeSetEdgeFlags((long) nativePtr, edgeFlags);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeSetEdgeFlags(long nativePtr, int edgeFlags) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setEdgeFlags(edgeFlags);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetMetaState(int nativePtr) {
+    return nativeGetMetaState((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetMetaState(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getMetaState();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetButtonState(int nativePtr) {
+    return nativeGetButtonState((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetButtonState(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getButtonState();
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected static void nativeSetButtonState(long nativePtr, int buttonState) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setButtonState(buttonState);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeOffsetLocation(int nativePtr, float deltaX, float deltaY) {
+    nativeOffsetLocation((long) nativePtr, deltaX, deltaY);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeOffsetLocation(long nativePtr, float deltaX, float deltaY) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.offsetLocation(deltaX, deltaY);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static float nativeGetXOffset(int nativePtr) {
+    return nativeGetXOffset((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static float nativeGetXOffset(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getXOffset();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static float nativeGetYOffset(int nativePtr) {
+    return nativeGetYOffset((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static float nativeGetYOffset(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getYOffset();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static float nativeGetXPrecision(int nativePtr) {
+    return nativeGetXPrecision((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static float nativeGetXPrecision(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getXPrecision();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static float nativeGetYPrecision(int nativePtr) {
+    return nativeGetYPrecision((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static float nativeGetYPrecision(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getYPrecision();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static long nativeGetDownTimeNanos(int nativePtr) {
+    return nativeGetDownTimeNanos((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static long nativeGetDownTimeNanos(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getDownTime();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeSetDownTimeNanos(int nativePtr, long downTimeNanos) {
+    nativeSetDownTimeNanos((long) nativePtr, downTimeNanos);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeSetDownTimeNanos(long nativePtr, long downTimeNanos) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.setDownTime(downTimeNanos);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetPointerCount(int nativePtr) {
+    return nativeGetPointerCount((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetPointerCount(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getPointerCount();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeFindPointerIndex(int nativePtr, int pointerId) {
+    return nativeFindPointerIndex((long) nativePtr, pointerId);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeFindPointerIndex(long nativePtr, int pointerId) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.findPointerIndex(pointerId);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static int nativeGetHistorySize(int nativePtr) {
+    return nativeGetHistorySize((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static int nativeGetHistorySize(long nativePtr) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    return event.getHistorySize();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  @HiddenApi
+  protected static void nativeScale(int nativePtr, float scale) {
+    nativeScale((long) nativePtr, scale);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected static void nativeScale(long nativePtr, float scale) {
+    NativeInput.MotionEvent event = getNativeMotionEvent(nativePtr);
+    event.scale(scale);
+  }
+
+  private static NativeInput.MotionEvent getNativeMotionEvent(long nativePtr) {
+    // check that MotionEvent was initialized properly. This can occur if MotionEvent was mocked
+    checkState(
+        nativePtr > 0,
+        "MotionEvent has not been initialized. "
+            + "Ensure MotionEvent.obtain was used to create it, instead of creating it directly "
+            + "or via a Mocking framework");
+
+    return nativeMotionEventRegistry.getNativeObject(nativePtr);
+  }
+
+  @Implementation
+  protected final void transform(Matrix matrix) {
+    checkNotNull(matrix);
+    NativeInput.MotionEvent event = getNativeMotionEvent();
+
+    float[] m = new float[9];
+    matrix.getValues(m);
+    event.transform(m);
+  }
+
+  private NativeInput.MotionEvent getNativeMotionEvent() {
+    long nativePtr;
+    if (RuntimeEnvironment.getApiLevel() <= KITKAT_WATCH) {
+      Integer nativePtrInt = ReflectionHelpers.getField(realMotionEvent, "mNativePtr");
+      nativePtr = nativePtrInt.longValue();
+    } else {
+      nativePtr = ReflectionHelpers.getField(realMotionEvent, "mNativePtr");
+    }
+    return nativeMotionEventRegistry.getNativeObject(nativePtr);
+  }
+
+  // Testing API methods
+
+  /**
+   * @deprecated use {@link MotionEvent#obtain} or {@link
+   *     androidx.test.core.view.MotionEventBuilder} to create a MotionEvent with desired data.
+   */
+  @Deprecated
+  public MotionEvent setPointer2(float pointer1X, float pointer1Y) {
+    NativeInput.MotionEvent event = getNativeMotionEvent();
+    List<NativeInput.PointerCoords> pointerCoords = event.getSamplePointerCoords();
+    List<PointerProperties> pointerProperties = event.getPointerProperties();
+    ensureTwoPointers(pointerCoords, pointerProperties);
+
+    pointerCoords.get(1).setAxisValue(AMOTION_EVENT_AXIS_X, pointer1X);
+    pointerCoords.get(1).setAxisValue(AMOTION_EVENT_AXIS_Y, pointer1Y);
+    return realMotionEvent;
+  }
+
+  private static void ensureTwoPointers(
+      List<NativeInput.PointerCoords> pointerCoords, List<PointerProperties> pointerProperties) {
+    if (pointerCoords.size() < 2) {
+      pointerCoords.add(new NativeInput.PointerCoords());
+    }
+    if (pointerProperties.size() < 2) {
+      pointerProperties.add(new PointerProperties());
+    }
+  }
+
+  /**
+   * @deprecated use {@link MotionEvent#obtain} or {@link
+   *     androidx.test.core.view.MotionEventBuilder#setPointerAction(int, int)} to create a
+   *     MotionEvent with desired data.
+   */
+  @Deprecated
+  public void setPointerIndex(int pointerIndex) {
+    NativeInput.MotionEvent event = getNativeMotionEvent();
+    // pointer index is stored in upper two bytes of action
+    event.setAction(
+        event.getAction() | ((pointerIndex & 0xff) << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+  }
+
+  /**
+   * @deprecated use {@link MotionEvent#obtain} or {@link MotionEventBuilder} to create a
+   *     MotionEvent with desired data
+   */
+  @Deprecated
+  public void setPointerIds(int index0PointerId, int index1PointerId) {
+    NativeInput.MotionEvent event = getNativeMotionEvent();
+    List<NativeInput.PointerCoords> pointerCoords = event.getSamplePointerCoords();
+    List<PointerProperties> pointerProperties = event.getPointerProperties();
+    ensureTwoPointers(pointerCoords, pointerProperties);
+
+    pointerProperties.get(0).id = index0PointerId;
+    pointerProperties.get(1).id = index1PointerId;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeAllocationRegistry.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeAllocationRegistry.java
new file mode 100644
index 0000000..dfe78a2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeAllocationRegistry.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+
+import libcore.util.NativeAllocationRegistry;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = NativeAllocationRegistry.class, minSdk = N, isInAndroidSdk = false, looseSignatures = true)
+public class ShadowNativeAllocationRegistry {
+
+  @Implementation
+  protected Runnable registerNativeAllocation(Object referent, Object allocator) {
+    return () -> {};
+  }
+
+  @Implementation
+  protected Runnable registerNativeAllocation(Object referent, long nativePtr) {
+    return () -> {};
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeCursorWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeCursorWindow.java
new file mode 100644
index 0000000..71e0de7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeCursorWindow.java
@@ -0,0 +1,204 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.database.CharArrayBuffer;
+import android.database.CursorWindow;
+import com.google.common.base.Preconditions;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.nativeruntime.CursorWindowNatives;
+import org.robolectric.nativeruntime.DefaultNativeRuntimeLoader;
+
+/** Shadow for {@link CursorWindow} that is backed by native code */
+@Implements(value = CursorWindow.class, isInAndroidSdk = false)
+public class ShadowNativeCursorWindow extends ShadowCursorWindow {
+
+  @Implementation
+  protected static Number nativeCreate(String name, int cursorWindowSize) {
+    DefaultNativeRuntimeLoader.injectAndLoad();
+    long result = CursorWindowNatives.nativeCreate(name, cursorWindowSize);
+    if (RuntimeEnvironment.getApiLevel() < LOLLIPOP) {
+      return PreLPointers.register(result);
+    }
+    return result;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeDispose(int windowPtr) {
+    nativeDispose(PreLPointers.get(windowPtr));
+    PreLPointers.remove(windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeDispose(long windowPtr) {
+    CursorWindowNatives.nativeDispose(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetName(int windowPtr) {
+    return nativeGetName(PreLPointers.get(windowPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetName(long windowPtr) {
+    return CursorWindowNatives.nativeGetName(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static byte[] nativeGetBlob(int windowPtr, int row, int column) {
+    return nativeGetBlob(PreLPointers.get(windowPtr), row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static byte[] nativeGetBlob(long windowPtr, int row, int column) {
+    return CursorWindowNatives.nativeGetBlob(windowPtr, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetString(int windowPtr, int row, int column) {
+    return nativeGetString(PreLPointers.get(windowPtr), row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetString(long windowPtr, int row, int column) {
+    return CursorWindowNatives.nativeGetString(windowPtr, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeCopyStringToBuffer(
+      long windowPtr, int row, int column, CharArrayBuffer buffer) {
+    CursorWindowNatives.nativeCopyStringToBuffer(windowPtr, row, column, buffer);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutBlob(int windowPtr, byte[] value, int row, int column) {
+    return nativePutBlob(PreLPointers.get(windowPtr), value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutBlob(long windowPtr, byte[] value, int row, int column) {
+    // Real Android will crash in native code if putBlob is called with a null value.
+    Preconditions.checkNotNull(value);
+    return CursorWindowNatives.nativePutBlob(windowPtr, value, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutString(int windowPtr, String value, int row, int column) {
+    return nativePutString(PreLPointers.get(windowPtr), value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutString(long windowPtr, String value, int row, int column) {
+    // Real Android will crash in native code if putString is called with a null value.
+    Preconditions.checkNotNull(value);
+    return CursorWindowNatives.nativePutString(windowPtr, value, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeClear(int windowPtr) {
+    nativeClear(PreLPointers.get(windowPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeClear(long windowPtr) {
+    CursorWindowNatives.nativeClear(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetNumRows(int windowPtr) {
+    return nativeGetNumRows(PreLPointers.get(windowPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetNumRows(long windowPtr) {
+    return CursorWindowNatives.nativeGetNumRows(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativeSetNumColumns(int windowPtr, int columnNum) {
+    return nativeSetNumColumns(PreLPointers.get(windowPtr), columnNum);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativeSetNumColumns(long windowPtr, int columnNum) {
+    return CursorWindowNatives.nativeSetNumColumns(windowPtr, columnNum);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativeAllocRow(int windowPtr) {
+    return nativeAllocRow(PreLPointers.get(windowPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativeAllocRow(long windowPtr) {
+    return CursorWindowNatives.nativeAllocRow(windowPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeFreeLastRow(long windowPtr) {
+    CursorWindowNatives.nativeFreeLastRow(windowPtr);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetType(int windowPtr, int row, int column) {
+    return nativeGetType(PreLPointers.get(windowPtr), row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetType(long windowPtr, int row, int column) {
+    return CursorWindowNatives.nativeGetType(windowPtr, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeGetLong(int windowPtr, int row, int column) {
+    return nativeGetLong(PreLPointers.get(windowPtr), row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeGetLong(long windowPtr, int row, int column) {
+    return CursorWindowNatives.nativeGetLong(windowPtr, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static double nativeGetDouble(int windowPtr, int row, int column) {
+    return nativeGetDouble(PreLPointers.get(windowPtr), row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static double nativeGetDouble(long windowPtr, int row, int column) {
+    return CursorWindowNatives.nativeGetDouble(windowPtr, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutLong(int windowPtr, long value, int row, int column) {
+    return nativePutLong(PreLPointers.get(windowPtr), value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutLong(long windowPtr, long value, int row, int column) {
+    return CursorWindowNatives.nativePutLong(windowPtr, value, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutDouble(int windowPtr, double value, int row, int column) {
+    return nativePutDouble(PreLPointers.get(windowPtr), value, row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutDouble(long windowPtr, double value, int row, int column) {
+    return CursorWindowNatives.nativePutDouble(windowPtr, value, row, column);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativePutNull(int windowPtr, int row, int column) {
+    return nativePutNull(PreLPointers.get(windowPtr), row, column);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativePutNull(long windowPtr, int row, int column) {
+    return CursorWindowNatives.nativePutNull(windowPtr, row, column);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePluralRules.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePluralRules.java
new file mode 100644
index 0000000..e99af08
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePluralRules.java
@@ -0,0 +1,25 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.M;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(className = "libcore.icu.NativePluralRules", isInAndroidSdk = false, maxSdk = M)
+public class ShadowNativePluralRules {
+
+  @Implementation(minSdk = KITKAT)
+  protected static int quantityForIntImpl(long address, int quantity) {
+    // just return the mapping for english locale for now
+    if (quantity == 1) return 1;
+    else return 5 /* other */;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR2)
+  protected static int quantityForIntImpl(int address, int quantity) {
+    return quantityForIntImpl((long)address, quantity);
+
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeSQLiteConnection.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeSQLiteConnection.java
new file mode 100644
index 0000000..1d3faff
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeSQLiteConnection.java
@@ -0,0 +1,446 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S_V2;
+
+import android.database.sqlite.SQLiteConnection;
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.nativeruntime.DefaultNativeRuntimeLoader;
+import org.robolectric.nativeruntime.SQLiteConnectionNatives;
+import org.robolectric.util.PerfStatsCollector;
+
+/** Shadow for {@link SQLiteConnection} that is backed by native code */
+@Implements(className = "android.database.sqlite.SQLiteConnection", isInAndroidSdk = false)
+public class ShadowNativeSQLiteConnection extends ShadowSQLiteConnection {
+  @Implementation(maxSdk = O)
+  protected static Number nativeOpen(
+      String path, int openFlags, String label, boolean enableTrace, boolean enableProfile) {
+    DefaultNativeRuntimeLoader.injectAndLoad();
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> {
+              long result =
+                  SQLiteConnectionNatives.nativeOpen(
+                      path, openFlags, label, enableTrace, enableProfile, 0, 0);
+              if (RuntimeEnvironment.getApiLevel() < LOLLIPOP) {
+                return PreLPointers.register(result);
+              }
+              return result;
+            });
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected static long nativeOpen(
+      String path,
+      int openFlags,
+      String label,
+      boolean enableTrace,
+      boolean enableProfile,
+      int lookasideSlotSize,
+      int lookasideSlotCount) {
+    return nativeOpen(path, openFlags, label, enableTrace, enableProfile).longValue();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeClose(int connectionPtr) {
+    nativeClose(PreLPointers.get(connectionPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeClose(long connectionPtr) {
+    PerfStatsCollector.getInstance()
+        .measure("androidsqlite", () -> SQLiteConnectionNatives.nativeClose(connectionPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativePrepareStatement(int connectionPtr, String sql) {
+    long statementPtr = nativePrepareStatement(PreLPointers.get(connectionPtr), sql);
+    return PreLPointers.register(statementPtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativePrepareStatement(long connectionPtr, String sql) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativePrepareStatement(connectionPtr, sql));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeFinalizeStatement(int connectionPtr, int statementPtr) {
+    nativeFinalizeStatement(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeFinalizeStatement(long connectionPtr, long statementPtr) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeFinalizeStatement(connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetParameterCount(int connectionPtr, int statementPtr) {
+    return nativeGetParameterCount(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetParameterCount(final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeGetParameterCount(connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean nativeIsReadOnly(int connectionPtr, int statementPtr) {
+    return nativeIsReadOnly(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static boolean nativeIsReadOnly(final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeIsReadOnly(connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeExecuteForString(int connectionPtr, int statementPtr) {
+    return nativeExecuteForString(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeExecuteForString(
+      final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeExecuteForString(connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeRegisterLocalizedCollators(int connectionPtr, String locale) {
+    nativeRegisterLocalizedCollators(PreLPointers.get(connectionPtr), locale);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeRegisterLocalizedCollators(long connectionPtr, String locale) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeRegisterLocalizedCollators(connectionPtr, locale));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeExecuteForLong(int connectionPtr, int statementPtr) {
+    return nativeExecuteForLong(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeExecuteForLong(final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeExecuteForLong(connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeExecute(int connectionPtr, int statementPtr) {
+    nativeExecute(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = S_V2)
+  protected static void nativeExecute(final long connectionPtr, final long statementPtr) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeExecute(connectionPtr, statementPtr, false));
+  }
+
+  @Implementation(minSdk = 33)
+  protected static void nativeExecute(
+      final long connectionPtr, final long statementPtr, boolean isPragmaStmt) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeExecute(connectionPtr, statementPtr, isPragmaStmt));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeExecuteForChangedRowCount(int connectionPtr, int statementPtr) {
+    return nativeExecuteForChangedRowCount(
+        PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeExecuteForChangedRowCount(
+      final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeExecuteForChangedRowCount(
+                    connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetColumnCount(int connectionPtr, int statementPtr) {
+    return nativeGetColumnCount(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetColumnCount(final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeGetColumnCount(connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetColumnName(int connectionPtr, int statementPtr, int index) {
+    return nativeGetColumnName(
+        PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr), index);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetColumnName(
+      final long connectionPtr, final long statementPtr, final int index) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeGetColumnName(connectionPtr, statementPtr, index));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindNull(int connectionPtr, int statementPtr, int index) {
+    nativeBindNull(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr), index);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindNull(
+      final long connectionPtr, final long statementPtr, final int index) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeBindNull(connectionPtr, statementPtr, index));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindLong(int connectionPtr, int statementPtr, int index, long value) {
+    nativeBindLong(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr), index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindLong(
+      final long connectionPtr, final long statementPtr, final int index, final long value) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeBindLong(connectionPtr, statementPtr, index, value));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindDouble(
+      int connectionPtr, int statementPtr, int index, double value) {
+    nativeBindDouble(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr), index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindDouble(
+      final long connectionPtr, final long statementPtr, final int index, final double value) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeBindDouble(
+                    connectionPtr, statementPtr, index, value));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindString(
+      int connectionPtr, int statementPtr, int index, String value) {
+    nativeBindString(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr), index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindString(
+      final long connectionPtr, final long statementPtr, final int index, final String value) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeBindString(
+                    connectionPtr, statementPtr, index, value));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeBindBlob(
+      int connectionPtr, int statementPtr, int index, byte[] value) {
+    nativeBindBlob(PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr), index, value);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeBindBlob(
+      final long connectionPtr, final long statementPtr, final int index, final byte[] value) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeBindBlob(connectionPtr, statementPtr, index, value));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeResetStatementAndClearBindings(int connectionPtr, int statementPtr) {
+    nativeResetStatementAndClearBindings(
+        PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeResetStatementAndClearBindings(
+      final long connectionPtr, final long statementPtr) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeResetStatementAndClearBindings(
+                    connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeExecuteForLastInsertedRowId(int connectionPtr, int statementPtr) {
+    return nativeExecuteForLastInsertedRowId(
+        PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeExecuteForLastInsertedRowId(
+      final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeExecuteForLastInsertedRowId(
+                    connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static long nativeExecuteForCursorWindow(
+      int connectionPtr,
+      int statementPtr,
+      int windowPtr,
+      int startPos,
+      int requiredPos,
+      boolean countAllRows) {
+    return nativeExecuteForCursorWindow(
+        PreLPointers.get(connectionPtr),
+        PreLPointers.get(statementPtr),
+        PreLPointers.get(windowPtr),
+        startPos,
+        requiredPos,
+        countAllRows);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeExecuteForCursorWindow(
+      final long connectionPtr,
+      final long statementPtr,
+      final long windowPtr,
+      final int startPos,
+      final int requiredPos,
+      final boolean countAllRows) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeExecuteForCursorWindow(
+                    connectionPtr, statementPtr, windowPtr, startPos, requiredPos, countAllRows));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeExecuteForBlobFileDescriptor(int connectionPtr, int statementPtr) {
+    return nativeExecuteForBlobFileDescriptor(
+        PreLPointers.get(connectionPtr), PreLPointers.get(statementPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeExecuteForBlobFileDescriptor(
+      final long connectionPtr, final long statementPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeExecuteForBlobFileDescriptor(
+                    connectionPtr, statementPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeCancel(int connectionPtr) {
+    nativeCancel(PreLPointers.get(connectionPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeCancel(long connectionPtr) {
+    PerfStatsCollector.getInstance()
+        .measure("androidsqlite", () -> SQLiteConnectionNatives.nativeCancel(connectionPtr));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeResetCancel(int connectionPtr, boolean cancelable) {
+    nativeResetCancel(PreLPointers.get(connectionPtr), cancelable);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeResetCancel(long connectionPtr, boolean cancelable) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () -> SQLiteConnectionNatives.nativeResetCancel(connectionPtr, cancelable));
+  }
+
+  @Implementation(minSdk = R)
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  protected static void nativeRegisterCustomScalarFunction(
+      long connectionPtr, String name, UnaryOperator<String> function) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeRegisterCustomScalarFunction(
+                    connectionPtr, name, function));
+  }
+
+  @Implementation(minSdk = R)
+  @SuppressWarnings("AndroidJdkLibsChecker")
+  protected static void nativeRegisterCustomAggregateFunction(
+      long connectionPtr, String name, BinaryOperator<String> function) {
+    PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite",
+            () ->
+                SQLiteConnectionNatives.nativeRegisterCustomAggregateFunction(
+                    connectionPtr, name, function));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetDbLookaside(int connectionPtr) {
+    return nativeGetDbLookaside(PreLPointers.get(connectionPtr));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetDbLookaside(long connectionPtr) {
+    return PerfStatsCollector.getInstance()
+        .measure(
+            "androidsqlite", () -> SQLiteConnectionNatives.nativeGetDbLookaside(connectionPtr));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetwork.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetwork.java
new file mode 100644
index 0000000..5d57f69
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetwork.java
@@ -0,0 +1,95 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.net.Network;
+import java.io.FileDescriptor;
+import java.net.DatagramSocket;
+import java.net.Socket;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = Network.class, minSdk = LOLLIPOP)
+public class ShadowNetwork {
+
+  @RealObject private Network realObject;
+
+  private final Set<Socket> boundSockets = new HashSet<>();
+  private final Set<DatagramSocket> boundDatagramSockets = new HashSet<>();
+  private final Set<FileDescriptor> boundFileDescriptors = new HashSet<>();
+
+  /**
+   * Creates new instance of {@link Network}, because its constructor is hidden.
+   *
+   * @param netId The netId.
+   * @return The Network instance.
+   */
+  public static Network newInstance(int netId) {
+    return Shadow.newInstance(Network.class, new Class[] {int.class}, new Object[] {netId});
+  }
+
+  /** Checks if the {@code socket} was previously bound to this network. */
+  public boolean isSocketBound(Socket socket) {
+    return boundSockets.contains(socket);
+  }
+
+  /** Checks if the {@code datagramSocket} was previously bound to this network. */
+  public boolean isSocketBound(DatagramSocket socket) {
+    return boundDatagramSockets.contains(socket);
+  }
+
+  /** Checks if the {@code fileDescriptor} was previously bound to this network. */
+  public boolean isSocketBound(FileDescriptor fd) {
+    return boundFileDescriptors.contains(fd);
+  }
+
+  /** Returns the total number of sockets bound to this network interface. */
+  public int boundSocketCount() {
+    return boundSockets.size() + boundDatagramSockets.size() + boundFileDescriptors.size();
+  }
+
+  /**
+   * Simulates a socket bind. isSocketBound can be called to verify that the socket was bound to
+   * this network interface, and boundSocketCount() will increment for any unique socket.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected void bindSocket(DatagramSocket socket) {
+    boundDatagramSockets.add(socket);
+  }
+
+  /**
+   * Simulates a socket bind. isSocketBound can be called to verify that the socket was bound to
+   * this network interface, and boundSocketCount() will increment for any unique socket.
+   */
+  @Implementation
+  protected void bindSocket(Socket socket) {
+    boundSockets.add(socket);
+  }
+
+  /**
+   * Simulates a socket bind. isSocketBound can be called to verify that the fd was bound to
+   * this network interface, and boundSocketCount() will increment for any unique socket.
+   */
+  @Implementation(minSdk = M)
+  protected void bindSocket(FileDescriptor fd) {
+    boundFileDescriptors.add(fd);
+  }
+
+  /**
+   * Allows to get the stored netId.
+   *
+   * @return The netId.
+   */
+  @Implementation(minSdk = R)
+  public int getNetId() {
+    return ReflectionHelpers.getField(realObject, "netId");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkCapabilities.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkCapabilities.java
new file mode 100644
index 0000000..39e3d9b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkCapabilities.java
@@ -0,0 +1,126 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.net.NetworkCapabilities;
+import android.net.NetworkSpecifier;
+import android.net.TransportInfo;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Robolectic provides overrides for fetching and updating transport. */
+@Implements(value = NetworkCapabilities.class, minSdk = LOLLIPOP)
+public class ShadowNetworkCapabilities {
+
+  @RealObject protected NetworkCapabilities realNetworkCapabilities;
+
+  public static NetworkCapabilities newInstance() {
+    return Shadow.newInstanceOf(NetworkCapabilities.class);
+  }
+
+  /** Updates the transport types for this network capablities to include {@code transportType}. */
+  @HiddenApi
+  @Implementation
+  public NetworkCapabilities addTransportType(int transportType) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .addTransportType(transportType);
+  }
+
+  /** Updates the transport types for this network capablities to remove {@code transportType}. */
+  @HiddenApi
+  @Implementation
+  public NetworkCapabilities removeTransportType(int transportType) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .removeTransportType(transportType);
+  }
+
+  /** Adds {@code capability} to the NetworkCapabilities. */
+  @HiddenApi
+  @Implementation
+  public NetworkCapabilities addCapability(int capability) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .addCapability(capability);
+  }
+
+  /** Removes {@code capability} from the NetworkCapabilities. */
+  @HiddenApi
+  @Implementation
+  public NetworkCapabilities removeCapability(int capability) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .removeCapability(capability);
+  }
+
+  /**
+   * Changes {@link NetworkSpecifier} for this network capabilities. Works only on Android O and
+   * higher. For lower versions use {@link #setNetworkSpecifier(String)}
+   */
+  @Implementation(minSdk = O)
+  public NetworkCapabilities setNetworkSpecifier(NetworkSpecifier networkSpecifier) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .setNetworkSpecifier(networkSpecifier);
+  }
+
+  /**
+   * Changes {@link NetworkSpecifier} for this network capabilities. Works only on Android N_MR1 and
+   * lower. For higher versions use {@link #setNetworkSpecifier(NetworkSpecifier)}
+   */
+  @Implementation(minSdk = N, maxSdk = N_MR1)
+  public NetworkCapabilities setNetworkSpecifier(String networkSpecifier) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .setNetworkSpecifier(networkSpecifier);
+  }
+
+  /** Sets the {@code transportInfo} of the NetworkCapabilities. */
+  @HiddenApi
+  @Implementation(minSdk = Q)
+  public NetworkCapabilities setTransportInfo(TransportInfo transportInfo) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .setTransportInfo(transportInfo);
+  }
+
+  /** Sets the LinkDownstreamBandwidthKbps of the NetworkCapabilities. */
+  @HiddenApi
+  @Implementation(minSdk = Q)
+  public NetworkCapabilities setLinkDownstreamBandwidthKbps(int kbps) {
+    return reflector(NetworkCapabilitiesReflector.class, realNetworkCapabilities)
+        .setLinkDownstreamBandwidthKbps(kbps);
+  }
+
+  @ForType(NetworkCapabilities.class)
+  interface NetworkCapabilitiesReflector {
+
+    @Direct
+    NetworkCapabilities addTransportType(int transportType);
+
+    @Direct
+    NetworkCapabilities removeTransportType(int transportType);
+
+    @Direct
+    NetworkCapabilities addCapability(int capability);
+
+    @Direct
+    NetworkCapabilities removeCapability(int capability);
+
+    @Direct
+    NetworkCapabilities setNetworkSpecifier(NetworkSpecifier networkSpecifier);
+
+    @Direct
+    NetworkCapabilities setNetworkSpecifier(String networkSpecifier);
+
+    @Direct
+    NetworkCapabilities setTransportInfo(TransportInfo transportInfo);
+
+    @Direct
+    NetworkCapabilities setLinkDownstreamBandwidthKbps(int kbps);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkInfo.java
new file mode 100644
index 0000000..067b3f6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkInfo.java
@@ -0,0 +1,156 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.net.NetworkInfo;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(NetworkInfo.class)
+public class ShadowNetworkInfo {
+  private boolean isAvailable;
+  private NetworkInfo.State state;
+  private int connectionType;
+  private int connectionSubType;
+  private NetworkInfo.DetailedState detailedState;
+
+  @RealObject private NetworkInfo realNetworkInfo;
+
+  @ForType(NetworkInfo.class)
+  interface NetworkInfoReflector {
+    @Direct
+    void setExtraInfo(String extraInfo);
+  }
+
+  @Implementation
+  protected static void __staticInitializer__() {}
+
+  /**
+   * @deprecated use {@link #newInstance(NetworkInfo.DetailedState, int, int, boolean,
+   *     NetworkInfo.State)} instead
+   */
+  @Deprecated
+  public static NetworkInfo newInstance(
+      NetworkInfo.DetailedState detailedState,
+      int type,
+      int subType,
+      boolean isAvailable,
+      boolean isConnected) {
+    return newInstance(
+        detailedState,
+        type,
+        subType,
+        isAvailable,
+        isConnected ? NetworkInfo.State.CONNECTED : NetworkInfo.State.DISCONNECTED);
+  }
+
+  /** Allows developers to create a {@link NetworkInfo} instance for testing. */
+  public static NetworkInfo newInstance(
+      NetworkInfo.DetailedState detailedState,
+      int type,
+      int subType,
+      boolean isAvailable,
+      NetworkInfo.State state) {
+    NetworkInfo networkInfo = Shadow.newInstanceOf(NetworkInfo.class);
+    final ShadowNetworkInfo info = Shadow.extract(networkInfo);
+    info.setConnectionType(type);
+    info.setSubType(subType);
+    info.setDetailedState(detailedState);
+    info.setAvailableStatus(isAvailable);
+    info.setConnectionStatus(state);
+    return networkInfo;
+  }
+
+  @Implementation
+  protected boolean isConnected() {
+    return state == NetworkInfo.State.CONNECTED;
+  }
+
+  @Implementation
+  protected boolean isConnectedOrConnecting() {
+    return isConnected() || state == NetworkInfo.State.CONNECTING;
+  }
+
+  @Implementation
+  protected NetworkInfo.State getState() {
+    return state;
+  }
+
+  @Implementation
+  protected NetworkInfo.DetailedState getDetailedState() {
+    return detailedState;
+  }
+
+  @Implementation
+  protected int getType() {
+    return connectionType;
+  }
+
+  @Implementation
+  protected int getSubtype() {
+    return connectionSubType;
+  }
+
+  @Implementation
+  protected boolean isAvailable() {
+    return isAvailable;
+  }
+
+  /**
+   * Sets up the return value of {@link #isAvailable()}.
+   *
+   * @param isAvailable the value that {@link #isAvailable()} will return.
+   */
+  public void setAvailableStatus(boolean isAvailable) {
+    this.isAvailable = isAvailable;
+  }
+
+  /**
+   * Sets up the return value of {@link #isConnectedOrConnecting()}, {@link #isConnected()}, and
+   * {@link #getState()}. If the input is true, state will be {@link NetworkInfo.State#CONNECTED},
+   * else it will be {@link NetworkInfo.State#DISCONNECTED}.
+   *
+   * @param isConnected the value that {@link #isConnectedOrConnecting()} and {@link #isConnected()}
+   *     will return.
+   * @deprecated use {@link #setConnectionStatus(NetworkInfo.State)} instead
+   */
+  @Deprecated
+  public void setConnectionStatus(boolean isConnected) {
+    setConnectionStatus(isConnected ? NetworkInfo.State.CONNECTED : NetworkInfo.State.DISCONNECTED);
+  }
+
+  /**
+   * Sets up the return value of {@link #getState()}.
+   *
+   * @param state the value that {@link #getState()} will return.
+   */
+  public void setConnectionStatus(NetworkInfo.State state) {
+    this.state = state;
+  }
+
+  /**
+   * Sets up the return value of {@link #getType()}.
+   *
+   * @param connectionType the value that {@link #getType()} will return.
+   */
+  public void setConnectionType(int connectionType) {
+    this.connectionType = connectionType;
+  }
+
+  public void setSubType(int subType) {
+    this.connectionSubType = subType;
+  }
+
+  public void setDetailedState(NetworkInfo.DetailedState detailedState) {
+    this.detailedState = detailedState;
+  }
+
+  @Implementation
+  public void setExtraInfo(String extraInfo) {
+    reflector(NetworkInfoReflector.class, realNetworkInfo).setExtraInfo(extraInfo);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkScoreManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkScoreManager.java
new file mode 100644
index 0000000..71ace5e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNetworkScoreManager.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import android.net.NetworkScoreManager;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Provides testing APIs for {@link NetworkScoreManager}. */
+@Implements(
+  value = NetworkScoreManager.class,
+  isInAndroidSdk = false,
+  minSdk = Build.VERSION_CODES.LOLLIPOP
+)
+public class ShadowNetworkScoreManager {
+  private String activeScorerPackage;
+  private boolean isScoringEnabled = true;
+
+  @Implementation
+  public String getActiveScorerPackage() {
+    return activeScorerPackage;
+  }
+
+  @Implementation
+  public boolean setActiveScorer(String packageName) {
+    activeScorerPackage = packageName;
+    return true;
+  }
+
+  /** @see #isScoringEnabled() */
+  @Implementation
+  protected void disableScoring() {
+    isScoringEnabled = false;
+  }
+
+  /** Whether scoring is enabled. */
+  public boolean isScoringEnabled() {
+    return isScoringEnabled;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java
new file mode 100644
index 0000000..98847ab
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java
@@ -0,0 +1,249 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.nfc.NdefMessage;
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.os.Build;
+import android.os.Bundle;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Shadow implementation of {@link NfcAdapter}. */
+@Implements(NfcAdapter.class)
+public class ShadowNfcAdapter {
+  @RealObject NfcAdapter nfcAdapter;
+  private static boolean hardwareExists = true;
+  private boolean enabled;
+  private Activity enabledActivity;
+  private PendingIntent intent;
+  private IntentFilter[] filters;
+  private String[][] techLists;
+  private Activity disabledActivity;
+  private NdefMessage ndefPushMessage;
+  private boolean ndefPushMessageSet;
+  private NfcAdapter.CreateNdefMessageCallback ndefPushMessageCallback;
+  private NfcAdapter.OnNdefPushCompleteCallback onNdefPushCompleteCallback;
+  private NfcAdapter.ReaderCallback readerCallback;
+
+  @Implementation
+  protected static NfcAdapter getNfcAdapter(Context context) {
+    if (!hardwareExists) {
+      return null;
+    }
+    return reflector(NfcAdapterReflector.class).getNfcAdapter(context);
+  }
+
+  @Implementation
+  protected void enableForegroundDispatch(
+      Activity activity, PendingIntent intent, IntentFilter[] filters, String[][] techLists) {
+    this.enabledActivity = activity;
+    this.intent = intent;
+    this.filters = filters;
+    this.techLists = techLists;
+  }
+
+  @Implementation
+  protected void disableForegroundDispatch(Activity activity) {
+    disabledActivity = activity;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.KITKAT)
+  protected void enableReaderMode(
+      Activity activity, NfcAdapter.ReaderCallback callback, int flags, Bundle extras) {
+    if (!RuntimeEnvironment.getApplication()
+        .getPackageManager()
+        .hasSystemFeature(PackageManager.FEATURE_NFC)) {
+      throw new UnsupportedOperationException();
+    }
+    if (callback == null) {
+      throw new NullPointerException("ReaderCallback is null");
+    }
+    readerCallback = callback;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.KITKAT)
+  protected void disableReaderMode(Activity activity) {
+    if (!RuntimeEnvironment.getApplication()
+        .getPackageManager()
+        .hasSystemFeature(PackageManager.FEATURE_NFC)) {
+      throw new UnsupportedOperationException();
+    }
+    readerCallback = null;
+  }
+
+  /** Returns true if NFC is in reader mode. */
+  public boolean isInReaderMode() {
+    return readerCallback != null;
+  }
+
+  /** Dispatches the tag onto any registered readers. */
+  public void dispatchTagDiscovered(Tag tag) {
+    if (readerCallback != null) {
+      readerCallback.onTagDiscovered(tag);
+    }
+  }
+
+  /**
+   * Mocks setting NDEF push message so that it could be verified in the test. Use {@link
+   * #getNdefPushMessage()} to verify that message was set.
+   */
+  @Implementation
+  protected void setNdefPushMessage(
+      NdefMessage message, Activity activity, Activity... activities) {
+    if (activity == null) {
+      throw new NullPointerException("activity cannot be null");
+    }
+    for (Activity a : activities) {
+      if (a == null) {
+        throw new NullPointerException("activities cannot contain null");
+      }
+    }
+    this.ndefPushMessage = message;
+    this.ndefPushMessageSet = true;
+  }
+
+  @Implementation
+  protected void setNdefPushMessageCallback(
+      NfcAdapter.CreateNdefMessageCallback callback, Activity activity, Activity... activities) {
+    this.ndefPushMessageCallback = callback;
+  }
+
+  /**
+   * Sets callback that should be used on successful Android Beam (TM).
+   *
+   * <p>The last registered callback is recalled and can be fetched using {@link
+   * #getOnNdefPushCompleteCallback}.
+   */
+  @Implementation
+  protected void setOnNdefPushCompleteCallback(
+      NfcAdapter.OnNdefPushCompleteCallback callback, Activity activity, Activity... activities) {
+    if (activity == null) {
+      throw new NullPointerException("activity cannot be null");
+    }
+    for (Activity a : activities) {
+      if (a == null) {
+        throw new NullPointerException("activities cannot contain null");
+      }
+    }
+    this.onNdefPushCompleteCallback = callback;
+  }
+
+  @Implementation
+  protected boolean isEnabled() {
+    return enabled;
+  }
+
+  @Implementation
+  protected boolean enable() {
+    enabled = true;
+    return true;
+  }
+
+  @Implementation
+  protected boolean disable() {
+    enabled = false;
+    return true;
+  }
+
+  /**
+   * Modifies the behavior of {@link #getNfcAdapter(Context)} to return {@code null}, to simulate
+   * absence of NFC hardware.
+   */
+  public static void setNfcHardwareExists(boolean hardwareExists) {
+    ShadowNfcAdapter.hardwareExists = hardwareExists;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public Activity getEnabledActivity() {
+    return enabledActivity;
+  }
+
+  public PendingIntent getIntent() {
+    return intent;
+  }
+
+  public IntentFilter[] getFilters() {
+    return filters;
+  }
+
+  public String[][] getTechLists() {
+    return techLists;
+  }
+
+  public Activity getDisabledActivity() {
+    return disabledActivity;
+  }
+
+  /** Returns last registered callback, or {@code null} if none was set. */
+  public NfcAdapter.CreateNdefMessageCallback getNdefPushMessageCallback() {
+    return ndefPushMessageCallback;
+  }
+
+  public NfcAdapter.OnNdefPushCompleteCallback getOnNdefPushCompleteCallback() {
+    return onNdefPushCompleteCallback;
+  }
+
+  /** Returns last set NDEF message, or throws {@code IllegalStateException} if it was never set. */
+  public NdefMessage getNdefPushMessage() {
+    if (!ndefPushMessageSet) {
+      throw new IllegalStateException();
+    }
+    return ndefPushMessage;
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    hardwareExists = true;
+    NfcAdapterReflector nfcAdapterReflector = reflector(NfcAdapterReflector.class);
+    nfcAdapterReflector.setIsInitialized(false);
+    Map<Context, NfcAdapter> adapters = nfcAdapterReflector.getNfcAdapters();
+    if (adapters != null) {
+      adapters.clear();
+    }
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      nfcAdapterReflector.setHasNfcFeature(false);
+      nfcAdapterReflector.setHasBeamFeature(false);
+    }
+  }
+
+  @ForType(NfcAdapter.class)
+  interface NfcAdapterReflector {
+    @Static
+    @Accessor("sIsInitialized")
+    void setIsInitialized(boolean isInitialized);
+
+    @Static
+    @Accessor("sHasNfcFeature")
+    void setHasNfcFeature(boolean hasNfcFeature);
+
+    @Static
+    @Accessor("sHasBeamFeature")
+    void setHasBeamFeature(boolean hasBeamFeature);
+
+    @Static
+    @Accessor("sNfcAdapters")
+    Map<Context, NfcAdapter> getNfcAdapters();
+
+    @Direct
+    @Static
+    NfcAdapter getNfcAdapter(Context context);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNinePatch.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNinePatch.java
new file mode 100644
index 0000000..9175860
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNinePatch.java
@@ -0,0 +1,13 @@
+package org.robolectric.shadows;
+
+import android.graphics.NinePatch;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(NinePatch.class)
+public class ShadowNinePatch {
+  @Implementation
+  protected static boolean isNinePatchChunk(byte[] chunk) {
+    return chunk != null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotification.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotification.java
new file mode 100644
index 0000000..164f5bd
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotification.java
@@ -0,0 +1,151 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.shadows.ResourceHelper.getInternalResourceId;
+
+import android.annotation.SuppressLint;
+import android.app.Notification;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.os.Build;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(Notification.class)
+@SuppressLint("NewApi")
+public class ShadowNotification {
+
+  @RealObject
+  Notification realNotification;
+
+  public CharSequence getContentTitle() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getCharSequence(Notification.EXTRA_TITLE)
+        : findText(applyContentView(), "title");
+  }
+
+  public CharSequence getContentText() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getCharSequence(Notification.EXTRA_TEXT)
+        : findText(applyContentView(), "text");
+  }
+
+  public CharSequence getContentInfo() {
+    if (getApiLevel() >= N) {
+      return realNotification.extras.getCharSequence(Notification.EXTRA_INFO_TEXT);
+    } else {
+      return findText(applyContentView(), "info");
+    }
+  }
+
+  public boolean isOngoing() {
+    return ((realNotification.flags & Notification.FLAG_ONGOING_EVENT) == Notification.FLAG_ONGOING_EVENT);
+  }
+
+  public CharSequence getBigText() {
+    if (getApiLevel() >= N) {
+      return realNotification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT);
+    } else {
+      return findText(applyBigContentView(), "big_text");
+    }
+  }
+
+  public CharSequence getBigContentTitle() {
+    if (getApiLevel() >= N) {
+      return realNotification.extras.getCharSequence(Notification.EXTRA_TITLE_BIG);
+    } else {
+      return findText(applyBigContentView(), "title");
+    }
+  }
+
+  public CharSequence getBigContentText() {
+    if (getApiLevel() >= N) {
+      return realNotification.extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT);
+    } else {
+      return findText(applyBigContentView(),  "text");
+    }
+  }
+
+  public Bitmap getBigPicture() {
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N) {
+      return realNotification.extras.getParcelable(Notification.EXTRA_PICTURE);
+    } else {
+      ImageView imageView =
+          (ImageView) applyBigContentView().findViewById(getInternalResourceId("big_picture"));
+      return imageView != null && imageView.getDrawable() != null
+          ? ((BitmapDrawable) imageView.getDrawable()).getBitmap() : null;
+    }
+  }
+
+  public boolean isWhenShown() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getBoolean(Notification.EXTRA_SHOW_WHEN)
+        : findView(applyContentView(), "chronometer").getVisibility() == View.VISIBLE
+        || findView(applyContentView(), "time").getVisibility() == View.VISIBLE;
+  }
+
+  private ProgressBar getProgressBar_PreN() {
+    return ((ProgressBar) findView(applyContentView(), "progress"));
+  }
+
+  public boolean isIndeterminate() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE)
+        : getProgressBar_PreN().isIndeterminate();
+  }
+
+  public int getMax() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getInt(Notification.EXTRA_PROGRESS_MAX)
+        : getProgressBar_PreN().getMax();
+  }
+
+  public int getProgress() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getInt(Notification.EXTRA_PROGRESS)
+        : getProgressBar_PreN().getProgress();
+  }
+
+  public boolean usesChronometer() {
+    return RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.N
+        ? realNotification.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)
+        : findView(applyContentView(), "chronometer").getVisibility() == View.VISIBLE;
+  }
+
+  private View applyContentView() {
+    return realNotification.contentView.apply(
+        RuntimeEnvironment.getApplication(), new FrameLayout(RuntimeEnvironment.getApplication()));
+  }
+
+  private View applyBigContentView() {
+    return realNotification.bigContentView.apply(
+        RuntimeEnvironment.getApplication(), new FrameLayout(RuntimeEnvironment.getApplication()));
+  }
+
+  private CharSequence findText(View view, String resourceName) {
+    TextView textView = (TextView) findView(view, resourceName);
+    return textView.getText();
+  }
+
+  private View findView(View view, String resourceName) {
+    View subView = view.findViewById(getInternalResourceId(resourceName));
+    if (subView == null) {
+      ByteArrayOutputStream buf = new ByteArrayOutputStream();
+      ShadowView shadowView = Shadow.extract(view);
+      shadowView.dump(new PrintStream(buf), 4);
+      throw new IllegalArgumentException(
+          "no id." + resourceName + " found in view:\n" + buf.toString());
+    }
+    return subView;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotificationListenerService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotificationListenerService.java
new file mode 100644
index 0000000..df8b3c6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotificationListenerService.java
@@ -0,0 +1,170 @@
+package org.robolectric.shadows;
+
+import static java.util.stream.Collectors.toCollection;
+
+import android.app.Notification;
+import android.content.ComponentName;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.NotificationListenerService.Ranking;
+import android.service.notification.NotificationListenerService.RankingMap;
+import android.service.notification.StatusBarNotification;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow implementation of {@link NotificationListenerService}. */
+@Implements(value = NotificationListenerService.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowNotificationListenerService extends ShadowService {
+  private static final AtomicInteger rebindRequestCount = new AtomicInteger(0);
+
+  private final List<StatusBarNotification> activeNotifications =
+      Collections.synchronizedList(new ArrayList<>());
+  private final AtomicInteger interruptionFilter =
+      new AtomicInteger(NotificationListenerService.INTERRUPTION_FILTER_UNKNOWN);
+  private final AtomicInteger hint = new AtomicInteger(0);
+  private final AtomicInteger unbindRequestCount = new AtomicInteger(0);
+  private final RankingMap emptyRankingMap = createEmptyRankingMap();
+
+  /**
+   * Adds the given {@link Notification} to the list of active Notifications. A corresponding {@link
+   * StatusBarNotification} will be generated from this Notification, which will be included in the
+   * result of {@link NotificationListenerService#getActiveNotifications}.
+   *
+   * @return the key of the generated {@link StatusBarNotification}
+   */
+  public String addActiveNotification(String packageName, int id, Notification notification) {
+    StatusBarNotification statusBarNotification =
+        new StatusBarNotification(
+            /* pkg= */ packageName,
+            /* opPkg= */ packageName,
+            id,
+            /* tag= */ null,
+            /* uid= */ 0,
+            /* initialPid= */ 0,
+            /* score= */ 0,
+            notification,
+            UserHandle.CURRENT,
+            notification.when);
+    return addActiveNotification(statusBarNotification);
+  }
+
+  /**
+   * Adds the given {@link StatusBarNotification} to the list of active Notifications. The given
+   * {@link StatusBarNotification} will be included in the result of {@link
+   * NotificationListenerService#getActiveNotifications}.
+   *
+   * @return the key of the given {@link StatusBarNotification}
+   */
+  public String addActiveNotification(StatusBarNotification statusBarNotification) {
+    activeNotifications.add(statusBarNotification);
+    return statusBarNotification.getKey();
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected static void requestRebind(ComponentName componentName) {
+    rebindRequestCount.incrementAndGet();
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected void requestUnbind() {
+    unbindRequestCount.incrementAndGet();
+  }
+
+  @Implementation
+  protected final void cancelAllNotifications() {
+    activeNotifications.clear();
+  }
+
+  @Implementation
+  protected final void cancelNotification(String key) {
+    synchronized (activeNotifications) {
+      Iterator<StatusBarNotification> iterator = activeNotifications.iterator();
+      while (iterator.hasNext()) {
+        StatusBarNotification statusBarNotification = iterator.next();
+        if (statusBarNotification.getKey().equals(key)) {
+          iterator.remove();
+          break;
+        }
+      }
+    }
+  }
+
+  /**
+   * Returns zero or more notifications, added by {@link #addActiveNotification}, that match one of
+   * the provided keys.
+   *
+   * @param keys the keys to match
+   * @param trim ignored, trimming is not supported
+   */
+  @Implementation
+  protected StatusBarNotification[] getActiveNotifications(String[] keys, int trim) {
+    if (keys == null) {
+      return activeNotifications.toArray(new StatusBarNotification[0]);
+    }
+
+    ImmutableSet<String> keySet = ImmutableSet.copyOf(keys);
+    return activeNotifications.stream()
+        .filter(notification -> keySet.contains(notification.getKey()))
+        .collect(toCollection(ArrayList::new))
+        .toArray(new StatusBarNotification[0]);
+  }
+
+  @Implementation
+  protected void requestInterruptionFilter(int interruptionFilter) {
+    this.interruptionFilter.set(interruptionFilter);
+  }
+
+  @Implementation
+  protected int getCurrentInterruptionFilter() {
+    return interruptionFilter.get();
+  }
+
+  @Implementation
+  protected void requestListenerHints(int hint) {
+    this.hint.set(hint);
+  }
+
+  @Implementation
+  protected int getCurrentListenerHints() {
+    return hint.get();
+  }
+
+  @Implementation
+  protected RankingMap getCurrentRanking() {
+    return emptyRankingMap;
+  }
+
+  /** Returns the number of times rebind was requested. */
+  public static int getRebindRequestCount() {
+    return rebindRequestCount.get();
+  }
+
+  /** Returns the number of times unbind was requested. */
+  public int getUnbindRequestCount() {
+    return unbindRequestCount.get();
+  }
+
+  /** Resets this shadow instance. */
+  @Resetter
+  public static void reset() {
+    rebindRequestCount.set(0);
+  }
+
+  private static RankingMap createEmptyRankingMap() {
+    return VERSION.SDK_INT < VERSION_CODES.Q
+        ? ReflectionHelpers.callConstructor(RankingMap.class)
+        : ReflectionHelpers.callConstructor(
+            RankingMap.class, ClassParameter.from(Ranking[].class, new Ranking[] {}));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotificationManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotificationManager.java
new file mode 100644
index 0000000..ef0f054
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNotificationManager.java
@@ -0,0 +1,522 @@
+package org.robolectric.shadows;
+
+import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.app.AutomaticZenRule;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.NotificationManager.Policy;
+import android.content.ComponentName;
+import android.os.Build;
+import android.os.Parcel;
+import android.service.notification.StatusBarNotification;
+import androidx.annotation.NonNull;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadows for NotificationManager. */
+@SuppressWarnings({"UnusedDeclaration", "AndroidConcurrentHashMap"})
+@Implements(value = NotificationManager.class, looseSignatures = true)
+public class ShadowNotificationManager {
+  private static final int MAX_NOTIFICATION_LIMIT = 25;
+  private boolean mAreNotificationsEnabled = true;
+  private boolean isNotificationPolicyAccessGranted = false;
+  private boolean enforceMaxNotificationLimit = false;
+  private final Map<Key, PostedNotification> notifications = new ConcurrentHashMap<>();
+  private final Map<String, Object> notificationChannels = new ConcurrentHashMap<>();
+  private final Map<String, Object> notificationChannelGroups = new ConcurrentHashMap<>();
+  private final Map<String, Object> deletedNotificationChannels = new ConcurrentHashMap<>();
+  private final Map<String, AutomaticZenRule> automaticZenRules = new ConcurrentHashMap<>();
+  private final Map<String, Boolean> listenerAccessGrantedComponents = new ConcurrentHashMap<>();
+  private final Set<String> canNotifyOnBehalfPackages = Sets.newConcurrentHashSet();
+
+  private int currentInteruptionFilter = INTERRUPTION_FILTER_ALL;
+  private Policy notificationPolicy;
+  private String notificationDelegate;
+  private int importance;
+
+  @Implementation
+  protected void notify(int id, Notification notification) {
+    notify(null, id, notification);
+  }
+
+  @Implementation
+  protected void notify(String tag, int id, Notification notification) {
+    if (!enforceMaxNotificationLimit || notifications.size() < MAX_NOTIFICATION_LIMIT) {
+      notifications.put(
+          new Key(tag, id), new PostedNotification(notification, ShadowSystem.currentTimeMillis()));
+    }
+  }
+
+  @Implementation
+  protected void cancel(int id) {
+    cancel(null, id);
+  }
+
+  @Implementation
+  protected void cancel(String tag, int id) {
+    Key key = new Key(tag, id);
+    if (notifications.containsKey(key)) {
+      notifications.remove(key);
+    }
+  }
+
+  @Implementation
+  protected void cancelAll() {
+    notifications.clear();
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected boolean areNotificationsEnabled() {
+    return mAreNotificationsEnabled;
+  }
+
+  public void setNotificationsEnabled(boolean areNotificationsEnabled) {
+    mAreNotificationsEnabled = areNotificationsEnabled;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected int getImportance() {
+    return importance;
+  }
+
+  public void setImportance(int importance) {
+    this.importance = importance;
+  }
+
+  @Implementation(minSdk = M)
+  public StatusBarNotification[] getActiveNotifications() {
+    // Must make a copy because otherwise the size of the map may change after we have allocated
+    // the array:
+    ImmutableMap<Key, PostedNotification> notifsCopy = ImmutableMap.copyOf(notifications);
+    StatusBarNotification[] statusBarNotifications = new StatusBarNotification[notifsCopy.size()];
+    int i = 0;
+    for (Map.Entry<Key, PostedNotification> entry : notifsCopy.entrySet()) {
+      statusBarNotifications[i++] =
+          new StatusBarNotification(
+              RuntimeEnvironment.getApplication().getPackageName(),
+              null /* opPkg */,
+              entry.getKey().id,
+              entry.getKey().tag,
+              android.os.Process.myUid() /* uid */,
+              android.os.Process.myPid() /* initialPid */,
+              0 /* score */,
+              entry.getValue().notification,
+              android.os.Process.myUserHandle(),
+              entry.getValue().postedTimeMillis /* postTime */);
+    }
+    return statusBarNotifications;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected Object /*NotificationChannel*/ getNotificationChannel(String channelId) {
+    return notificationChannels.get(channelId);
+  }
+
+  /** Returns a NotificationChannel that has the given parent and conversation ID. */
+  @Implementation(minSdk = R)
+  protected NotificationChannel getNotificationChannel(String channelId, String conversationId) {
+    for (Object object : getNotificationChannels()) {
+      NotificationChannel notificationChannel = (NotificationChannel) object;
+      if (conversationId.equals(notificationChannel.getConversationId())
+          && channelId.equals(notificationChannel.getParentChannelId())) {
+        return notificationChannel;
+      }
+    }
+    return null;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected void createNotificationChannelGroup(Object /*NotificationChannelGroup*/ group) {
+    String id = ReflectionHelpers.callInstanceMethod(group, "getId");
+    notificationChannelGroups.put(id, group);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected void createNotificationChannelGroups(
+      List<Object /*NotificationChannelGroup*/> groupList) {
+    for (Object group : groupList) {
+      createNotificationChannelGroup(group);
+    }
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected List<Object /*NotificationChannelGroup*/> getNotificationChannelGroups() {
+    return ImmutableList.copyOf(notificationChannelGroups.values());
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected void createNotificationChannel(Object /*NotificationChannel*/ channel) {
+    String id = ReflectionHelpers.callInstanceMethod(channel, "getId");
+    // Per documentation, recreating a deleted channel should have the same settings as the old
+    // deleted channel. See
+    // https://developer.android.com/reference/android/app/NotificationManager.html#deleteNotificationChannel%28java.lang.String%29
+    // for more info.
+    if (deletedNotificationChannels.containsKey(id)) {
+      notificationChannels.put(id, deletedNotificationChannels.remove(id));
+    }
+    NotificationChannel existingChannel = (NotificationChannel) notificationChannels.get(id);
+    // Per documentation, recreating a channel can change name and description, lower importance or
+    // set a group if no group set. Other settings remain unchanged. See
+    // https://developer.android.com/reference/android/app/NotificationManager#createNotificationChannel%28android.app.NotificationChannel@29
+    // for more info.
+    if (existingChannel != null) {
+      NotificationChannel newChannel = (NotificationChannel) channel;
+      existingChannel.setName(newChannel.getName());
+      existingChannel.setDescription(newChannel.getDescription());
+      if (newChannel.getImportance() < existingChannel.getImportance()) {
+        existingChannel.setImportance(newChannel.getImportance());
+      }
+      if (Strings.isNullOrEmpty(existingChannel.getGroup())) {
+        existingChannel.setGroup(newChannel.getGroup());
+      }
+      return;
+    }
+    notificationChannels.put(id, channel);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected void createNotificationChannels(List<Object /*NotificationChannel*/> channelList) {
+    for (Object channel : channelList) {
+      createNotificationChannel(channel);
+    }
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  public List<Object /*NotificationChannel*/> getNotificationChannels() {
+    return ImmutableList.copyOf(notificationChannels.values());
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected void deleteNotificationChannel(String channelId) {
+    if (getNotificationChannel(channelId) != null) {
+      Object /*NotificationChannel*/ channel = notificationChannels.remove(channelId);
+      deletedNotificationChannels.put(channelId, channel);
+    }
+  }
+
+  /**
+   * Delete a notification channel group and all notification channels associated with the group.
+   * This method will not notify any NotificationListenerService of resulting changes to
+   * notification channel groups nor to notification channels.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected void deleteNotificationChannelGroup(String channelGroupId) {
+    if (getNotificationChannelGroup(channelGroupId) != null) {
+      // Deleting a channel group also deleted all associated channels. See
+      // https://developer.android.com/reference/android/app/NotificationManager.html#deleteNotificationChannelGroup%28java.lang.String%29
+      // for more info.
+      for (/* NotificationChannel */ Object channel : getNotificationChannels()) {
+        String groupId = ReflectionHelpers.callInstanceMethod(channel, "getGroup");
+        if (channelGroupId.equals(groupId)) {
+          String channelId = ReflectionHelpers.callInstanceMethod(channel, "getId");
+          deleteNotificationChannel(channelId);
+        }
+      }
+      notificationChannelGroups.remove(channelGroupId);
+    }
+  }
+
+  /**
+   * @return {@link NotificationManager#INTERRUPTION_FILTER_ALL} by default, or the value specified
+   *     via {@link #setInterruptionFilter(int)}
+   */
+  @Implementation(minSdk = M)
+  protected final int getCurrentInterruptionFilter() {
+    return currentInteruptionFilter;
+  }
+
+  /**
+   * Currently does not support checking for granted policy access.
+   *
+   * @see NotificationManager#getCurrentInterruptionFilter()
+   */
+  @Implementation(minSdk = M)
+  protected final void setInterruptionFilter(int interruptionFilter) {
+    currentInteruptionFilter = interruptionFilter;
+  }
+
+  /** @return the value specified via {@link #setNotificationPolicy(Policy)} */
+  @Implementation(minSdk = M)
+  protected final Policy getNotificationPolicy() {
+    return notificationPolicy;
+  }
+
+  /** @return the value specified via {@link #setNotificationPolicyAccessGranted(boolean)} */
+  @Implementation(minSdk = M)
+  protected final boolean isNotificationPolicyAccessGranted() {
+    return isNotificationPolicyAccessGranted;
+  }
+
+  /**
+   * @return the value specified for the given {@link ComponentName} via {@link
+   *     #setNotificationListenerAccessGranted(ComponentName, boolean)} or false if unset.
+   */
+  @Implementation(minSdk = O_MR1)
+  protected final boolean isNotificationListenerAccessGranted(ComponentName componentName) {
+    return listenerAccessGrantedComponents.getOrDefault(componentName.flattenToString(), false);
+  }
+
+  /**
+   * Currently does not support checking for granted policy access.
+   *
+   * @see NotificationManager#getNotificationPolicy()
+   */
+  @Implementation(minSdk = M)
+  protected final void setNotificationPolicy(Policy policy) {
+    notificationPolicy = policy;
+  }
+
+  /**
+   * Sets the value returned by {@link NotificationManager#isNotificationPolicyAccessGranted()}. If
+   * {@code granted} is false, this also deletes all {@link AutomaticZenRule}s.
+   *
+   * @see NotificationManager#isNotificationPolicyAccessGranted()
+   */
+  public void setNotificationPolicyAccessGranted(boolean granted) {
+    isNotificationPolicyAccessGranted = granted;
+    if (!granted) {
+      automaticZenRules.clear();
+    }
+  }
+
+  /**
+   * Sets the value returned by {@link
+   * NotificationManager#isNotificationListenerAccessGranted(ComponentName)} for the provided {@link
+   * ComponentName}.
+   */
+  @Implementation(minSdk = O_MR1)
+  public void setNotificationListenerAccessGranted(ComponentName componentName, boolean granted) {
+    listenerAccessGrantedComponents.put(componentName.flattenToString(), granted);
+  }
+
+  @Implementation(minSdk = N)
+  protected AutomaticZenRule getAutomaticZenRule(String id) {
+    Preconditions.checkNotNull(id);
+    enforcePolicyAccess();
+
+    return automaticZenRules.get(id);
+  }
+
+  @Implementation(minSdk = N)
+  protected Map<String, AutomaticZenRule> getAutomaticZenRules() {
+    enforcePolicyAccess();
+
+    ImmutableMap.Builder<String, AutomaticZenRule> rules = new ImmutableMap.Builder();
+    for (Map.Entry<String, AutomaticZenRule> entry : automaticZenRules.entrySet()) {
+      rules.put(entry.getKey(), copyAutomaticZenRule(entry.getValue()));
+    }
+    return rules.build();
+  }
+
+  @Implementation(minSdk = N)
+  protected String addAutomaticZenRule(AutomaticZenRule automaticZenRule) {
+    Preconditions.checkNotNull(automaticZenRule);
+    Preconditions.checkNotNull(automaticZenRule.getName());
+    Preconditions.checkState(
+        automaticZenRule.getOwner() != null || automaticZenRule.getConfigurationActivity() != null,
+        "owner/configurationActivity cannot be null at the same time");
+
+    Preconditions.checkNotNull(automaticZenRule.getConditionId());
+    enforcePolicyAccess();
+
+    String id = UUID.randomUUID().toString().replace("-", "");
+    automaticZenRules.put(id, copyAutomaticZenRule(automaticZenRule));
+    return id;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean updateAutomaticZenRule(String id, AutomaticZenRule automaticZenRule) {
+    // NotificationManagerService doesn't check that id is non-null.
+    Preconditions.checkNotNull(automaticZenRule);
+    Preconditions.checkNotNull(automaticZenRule.getName());
+    Preconditions.checkState(
+        automaticZenRule.getOwner() != null || automaticZenRule.getConfigurationActivity() != null,
+        "owner/configurationActivity cannot be null at the same time");
+    Preconditions.checkNotNull(automaticZenRule.getConditionId());
+    enforcePolicyAccess();
+
+    // ZenModeHelper throws slightly cryptic exceptions.
+    if (id == null) {
+      throw new IllegalArgumentException("Rule doesn't exist");
+    } else if (!automaticZenRules.containsKey(id)) {
+      throw new SecurityException("Cannot update rules not owned by your condition provider");
+    }
+
+    automaticZenRules.put(id, copyAutomaticZenRule(automaticZenRule));
+    return true;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean removeAutomaticZenRule(String id) {
+    Preconditions.checkNotNull(id);
+    enforcePolicyAccess();
+    return automaticZenRules.remove(id) != null;
+  }
+
+  @Implementation(minSdk = Q)
+  protected String getNotificationDelegate() {
+    return notificationDelegate;
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean canNotifyAsPackage(@NonNull String pkg) {
+    // TODO: This doesn't work correctly with notification delegates because
+    // ShadowNotificationManager doesn't respect the associated context, it just uses the global
+    // RuntimeEnvironment.getApplication() context.
+
+    // So for the sake of testing, we will compare with values set using
+    // setCanNotifyAsPackage()
+    return canNotifyOnBehalfPackages.contains(pkg);
+  }
+
+  /**
+   * Sets notification delegate for the package provided.
+   *
+   * <p>{@link #canNotifyAsPackage(String)} will be returned based on this value.
+   *
+   * @param otherPackage the package for which the current package can notify on behalf
+   * @param canNotify whether the current package is set as notification delegate for 'otherPackage'
+   */
+  public void setCanNotifyAsPackage(@NonNull String otherPackage, boolean canNotify) {
+    if (canNotify) {
+      canNotifyOnBehalfPackages.add(otherPackage);
+    } else {
+      canNotifyOnBehalfPackages.remove(otherPackage);
+    }
+  }
+
+  @Implementation(minSdk = Q)
+  protected void setNotificationDelegate(String delegate) {
+    notificationDelegate = delegate;
+  }
+
+  /**
+   * Ensures a notification limit is applied before posting the notification.
+   *
+   * <p>When set to true a maximum notification limit of 25 is applied. Notifications past this
+   * limit are dropped and are not posted or enqueued.
+   *
+   * <p>When set to false no limit is applied and all notifications are posted or enqueued. This is
+   * the default behavior.
+   */
+  public void setEnforceMaxNotificationLimit(boolean enforceMaxNotificationLimit) {
+    this.enforceMaxNotificationLimit = enforceMaxNotificationLimit;
+  }
+
+  /**
+   * Enforces that the caller has notification policy access.
+   *
+   * @see NotificationManager#isNotificationPolicyAccessGranted()
+   * @throws SecurityException if the caller doesn't have notification policy access
+   */
+  private void enforcePolicyAccess() {
+    if (!isNotificationPolicyAccessGranted) {
+      throw new SecurityException("Notification policy access denied");
+    }
+  }
+
+  /** Returns a copy of {@code automaticZenRule}. */
+  private AutomaticZenRule copyAutomaticZenRule(AutomaticZenRule automaticZenRule) {
+    Parcel parcel = Parcel.obtain();
+    try {
+      automaticZenRule.writeToParcel(parcel, /* flags= */ 0);
+      parcel.setDataPosition(0);
+      return new AutomaticZenRule(parcel);
+    } finally {
+      parcel.recycle();
+    }
+  }
+
+  /**
+   * Checks whether a channel is considered a "deleted" channel by Android. This is a channel that
+   * was created but later deleted. If a channel is created that was deleted before, it recreates
+   * the channel with the old settings.
+   */
+  public boolean isChannelDeleted(String channelId) {
+    return deletedNotificationChannels.containsKey(channelId);
+  }
+
+  @Implementation(minSdk = P)
+  public Object /*NotificationChannelGroup*/ getNotificationChannelGroup(String id) {
+    return notificationChannelGroups.get(id);
+  }
+
+  public int size() {
+    return notifications.size();
+  }
+
+  public Notification getNotification(int id) {
+    PostedNotification postedNotification = notifications.get(new Key(null, id));
+    return postedNotification == null ? null : postedNotification.notification;
+  }
+
+  public Notification getNotification(String tag, int id) {
+    PostedNotification postedNotification = notifications.get(new Key(tag, id));
+    return postedNotification == null ? null : postedNotification.notification;
+  }
+
+  public List<Notification> getAllNotifications() {
+    List<Notification> result = new ArrayList<>(notifications.size());
+    for (PostedNotification postedNotification : notifications.values()) {
+      result.add(postedNotification.notification);
+    }
+    return result;
+  }
+
+  private static final class Key {
+    public final String tag;
+    public final int id;
+
+    private Key(String tag, int id) {
+      this.tag = tag;
+      this.id = id;
+    }
+
+    @Override
+    public int hashCode() {
+      int hashCode = 17;
+      hashCode = 37 * hashCode + (tag == null ? 0 : tag.hashCode());
+      hashCode = 37 * hashCode + id;
+      return hashCode;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Key)) return false;
+      Key other = (Key) o;
+      return (this.tag == null ? other.tag == null : this.tag.equals(other.tag))
+          && this.id == other.id;
+    }
+  }
+
+  private static final class PostedNotification {
+    private final Notification notification;
+    private final long postedTimeMillis;
+
+    private PostedNotification(Notification notification, long postedTimeMillis) {
+      this.notification = notification;
+      this.postedTimeMillis = postedTimeMillis;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNsdManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNsdManager.java
new file mode 100644
index 0000000..d852692
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNsdManager.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S_V2;
+
+import android.net.nsd.NsdManager;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(NsdManager.class)
+public class ShadowNsdManager {
+
+  @Implementation(maxSdk = S_V2)
+  protected void init() {
+    // do not blow up.
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNumberPicker.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNumberPicker.java
new file mode 100644
index 0000000..8cf21f9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNumberPicker.java
@@ -0,0 +1,88 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.NumberPicker;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = NumberPicker.class)
+public class ShadowNumberPicker extends ShadowLinearLayout {
+  @RealObject private NumberPicker realNumberPicker;
+  private int value;
+  private int minValue;
+  private int maxValue;
+  private boolean wrapSelectorWheel;
+  private String[] displayedValues;
+  private NumberPicker.OnValueChangeListener onValueChangeListener;
+
+  @Implementation
+  protected void setValue(int value) {
+    this.value = value;
+  }
+
+  @Implementation
+  protected int getValue() {
+    return value;
+  }
+
+  @Implementation
+  protected void setDisplayedValues(String[] displayedValues) {
+    this.displayedValues = displayedValues;
+  }
+
+  @Implementation
+  protected String[] getDisplayedValues() {
+    return displayedValues;
+  }
+
+  @Implementation
+  protected void setMinValue(int minValue) {
+    this.minValue = minValue;
+  }
+
+  @Implementation
+  protected void setMaxValue(int maxValue) {
+    this.maxValue = maxValue;
+  }
+
+  @Implementation
+  protected int getMinValue() {
+    return this.minValue;
+  }
+
+  @Implementation
+  protected int getMaxValue() {
+    return this.maxValue;
+  }
+
+  @Implementation
+  protected void setWrapSelectorWheel(boolean wrapSelectorWheel) {
+    this.wrapSelectorWheel = wrapSelectorWheel;
+  }
+
+  @Implementation
+  protected boolean getWrapSelectorWheel() {
+    return wrapSelectorWheel;
+  }
+
+  @Implementation
+  protected void setOnValueChangedListener(NumberPicker.OnValueChangeListener listener) {
+    reflector(NumberPickerReflector.class, realNumberPicker).setOnValueChangedListener(listener);
+    this.onValueChangeListener = listener;
+  }
+
+  public NumberPicker.OnValueChangeListener getOnValueChangeListener() {
+    return onValueChangeListener;
+  }
+
+  @ForType(NumberPicker.class)
+  interface NumberPickerReflector {
+
+    @Direct
+    void setOnValueChangedListener(NumberPicker.OnValueChangeListener listener);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java
new file mode 100644
index 0000000..2f91ba9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java
@@ -0,0 +1,128 @@
+package org.robolectric.shadows;
+
+import android.opengl.Matrix;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(Matrix.class)
+public class ShadowOpenGLMatrix {
+
+  /**
+   * Multiplies two 4x4 matrices together and stores the result in a third 4x4 matrix. In matrix
+   * notation: result = lhs x rhs. Due to the way matrix multiplication works, the result matrix
+   * will have the same effect as first multiplying by the rhs matrix, then multiplying by the lhs
+   * matrix. This is the opposite of what you might expect.
+   *
+   * <p>The same float array may be passed for result, lhs, and/or rhs. However, the result element
+   * values are undefined if the result elements overlap either the lhs or rhs elements.
+   *
+   * @param result The float array that holds the result.
+   * @param resultOffset The offset into the result array where the result is stored.
+   * @param lhs The float array that holds the left-hand-side matrix.
+   * @param lhsOffset The offset into the lhs array where the lhs is stored
+   * @param rhs The float array that holds the right-hand-side matrix.
+   * @param rhsOffset The offset into the rhs array where the rhs is stored.
+   * @throws IllegalArgumentException if result, lhs, or rhs are null, or if resultOffset + 16 >
+   *     result.length or lhsOffset + 16 > lhs.length or rhsOffset + 16 > rhs.length.
+   */
+  @Implementation
+  protected static void multiplyMM(
+      float[] result, int resultOffset, float[] lhs, int lhsOffset, float[] rhs, int rhsOffset) {
+    if (result == null) {
+      throw new IllegalArgumentException("result == null");
+    }
+    if (lhs == null) {
+      throw new IllegalArgumentException("lhs == null");
+    }
+    if (rhs == null) {
+      throw new IllegalArgumentException("rhs == null");
+    }
+    if (resultOffset + 16 > result.length) {
+      throw new IllegalArgumentException("resultOffset + 16 > result.length");
+    }
+    if (lhsOffset + 16 > lhs.length) {
+      throw new IllegalArgumentException("lhsOffset + 16 > lhs.length");
+    }
+    if (rhsOffset + 16 > rhs.length) {
+      throw new IllegalArgumentException("rhsOffset + 16 > rhs.length");
+    }
+    for (int i = 0; i < 4; i++) {
+      final float rhs_i0 = rhs[I(i, 0, rhsOffset)];
+      float ri0 = lhs[I(0, 0, lhsOffset)] * rhs_i0;
+      float ri1 = lhs[I(0, 1, lhsOffset)] * rhs_i0;
+      float ri2 = lhs[I(0, 2, lhsOffset)] * rhs_i0;
+      float ri3 = lhs[I(0, 3, lhsOffset)] * rhs_i0;
+      for (int j = 1; j < 4; j++) {
+        final float rhs_ij = rhs[I(i, j, rhsOffset)];
+        ri0 += lhs[I(j, 0, lhsOffset)] * rhs_ij;
+        ri1 += lhs[I(j, 1, lhsOffset)] * rhs_ij;
+        ri2 += lhs[I(j, 2, lhsOffset)] * rhs_ij;
+        ri3 += lhs[I(j, 3, lhsOffset)] * rhs_ij;
+      }
+      result[I(i, 0, resultOffset)] = ri0;
+      result[I(i, 1, resultOffset)] = ri1;
+      result[I(i, 2, resultOffset)] = ri2;
+      result[I(i, 3, resultOffset)] = ri3;
+    }
+  }
+
+  /**
+   * Multiplies a 4 element vector by a 4x4 matrix and stores the result in a 4-element column
+   * vector. In matrix notation: result = lhs x rhs
+   *
+   * <p>The same float array may be passed for resultVec, lhsMat, and/or rhsVec. However, the
+   * resultVec element values are undefined if the resultVec elements overlap either the lhsMat or
+   * rhsVec elements.
+   *
+   * @param resultVec The float array that holds the result vector.
+   * @param resultVecOffset The offset into the result array where the result vector is stored.
+   * @param lhsMat The float array that holds the left-hand-side matrix.
+   * @param lhsMatOffset The offset into the lhs array where the lhs is stored
+   * @param rhsVec The float array that holds the right-hand-side vector.
+   * @param rhsVecOffset The offset into the rhs vector where the rhs vector is stored.
+   * @throws IllegalArgumentException if resultVec, lhsMat, or rhsVec are null, or if
+   *     resultVecOffset + 4 > resultVec.length or lhsMatOffset + 16 > lhsMat.length or rhsVecOffset
+   *     + 4 > rhsVec.length.
+   */
+  @Implementation
+  protected static void multiplyMV(
+      float[] resultVec,
+      int resultVecOffset,
+      float[] lhsMat,
+      int lhsMatOffset,
+      float[] rhsVec,
+      int rhsVecOffset) {
+    if (resultVec == null) {
+      throw new IllegalArgumentException("resultVec == null");
+    }
+    if (lhsMat == null) {
+      throw new IllegalArgumentException("lhsMat == null");
+    }
+    if (rhsVec == null) {
+      throw new IllegalArgumentException("rhsVec == null");
+    }
+    if (resultVecOffset + 4 > resultVec.length) {
+      throw new IllegalArgumentException("resultVecOffset + 4 > resultVec.length");
+    }
+    if (lhsMatOffset + 16 > lhsMat.length) {
+      throw new IllegalArgumentException("lhsMatOffset + 16 > lhsMat.length");
+    }
+    if (rhsVecOffset + 4 > rhsVec.length) {
+      throw new IllegalArgumentException("rhsVecOffset + 4 > rhsVec.length");
+    }
+    final float x = rhsVec[rhsVecOffset + 0];
+    final float y = rhsVec[rhsVecOffset + 1];
+    final float z = rhsVec[rhsVecOffset + 2];
+    final float w = rhsVec[rhsVecOffset + 3];
+    resultVec[resultVecOffset + 0] = lhsMat[I(0, 0, lhsMatOffset)] * x + lhsMat[I(1, 0, lhsMatOffset)] * y + lhsMat[I(2, 0, lhsMatOffset)] * z + lhsMat[I(3, 0, lhsMatOffset)] * w;
+    resultVec[resultVecOffset + 1] = lhsMat[I(0, 1, lhsMatOffset)] * x + lhsMat[I(1, 1, lhsMatOffset)] * y + lhsMat[I(2, 1, lhsMatOffset)] * z + lhsMat[I(3, 1, lhsMatOffset)] * w;
+    resultVec[resultVecOffset + 2] = lhsMat[I(0, 2, lhsMatOffset)] * x + lhsMat[I(1, 2, lhsMatOffset)] * y + lhsMat[I(2, 2, lhsMatOffset)] * z + lhsMat[I(3, 2, lhsMatOffset)] * w;
+    resultVec[resultVecOffset + 3] = lhsMat[I(0, 3, lhsMatOffset)] * x + lhsMat[I(1, 3, lhsMatOffset)] * y + lhsMat[I(2, 3, lhsMatOffset)] * z + lhsMat[I(3, 3, lhsMatOffset)] * w;
+  }
+
+  private static int I(int i, int j, int offset) {
+    // #define I(_i, _j) ((_j)+ 4*(_i))
+    return offset + j + 4 * i;
+  }
+
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOs.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOs.java
new file mode 100644
index 0000000..9ecd207
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOs.java
@@ -0,0 +1,27 @@
+package org.robolectric.shadows;
+
+import android.system.Os;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** A Shadow for android.system.Os */
+@Implements(value = Os.class, minSdk = 21)
+public final class ShadowOs {
+
+  private ShadowOs() {}
+
+  private static final Map<Integer, Long> sysconfValues = new HashMap<>();
+
+  /** Configures values to be returned by sysconf. */
+  public static void setSysconfValue(int name, long value) {
+    sysconfValues.put(name, value);
+  }
+
+  /** Returns the value configured via setSysconfValue, or -1 if one hasn't been configured. */
+  @Implementation
+  protected static long sysconf(int name) {
+    return sysconfValues.getOrDefault(name, -1L);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOsConstants.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOsConstants.java
new file mode 100644
index 0000000..6c61fdb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOsConstants.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import android.system.OsConstants;
+import java.lang.reflect.Field;
+import java.util.regex.Pattern;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** */
+@Implements(value = OsConstants.class, minSdk = 21)
+public final class ShadowOsConstants {
+  private static final Pattern ERRNO_PATTERN = Pattern.compile("E[A-Z0-9]+");
+
+  @Implementation
+  protected static void initConstants() {
+    int errnos = 1;
+    try {
+      for (Field field : OsConstants.class.getDeclaredFields()) {
+        final String fieldName = field.getName();
+
+        if (ERRNO_PATTERN.matcher(fieldName).matches() && field.getType() == int.class) {
+          field.setInt(null, errnos++);
+        }
+        // Type of file.
+        if (fieldName.equals(OsConstantsValues.S_IFMT)) {
+          field.setInt(null, OsConstantsValues.S_IFMT_VALUE);
+          continue;
+        }
+        // Directory.
+        if (fieldName.equals(OsConstantsValues.S_IFDIR)) {
+          field.setInt(null, OsConstantsValues.S_IFDIR_VALUE);
+          continue;
+        }
+        // Regular file.
+        if (fieldName.equals(OsConstantsValues.S_IFREG)) {
+          field.setInt(null, OsConstantsValues.S_IFREG_VALUE);
+          continue;
+        }
+        // Symbolic link.
+        if (fieldName.equals(OsConstantsValues.S_IFLNK)) {
+          field.setInt(null, OsConstantsValues.S_IFLNK_VALUE);
+        }
+
+        // File open modes.
+        if (OsConstantsValues.OPEN_MODE_VALUES.containsKey(fieldName)) {
+          field.setInt(null, OsConstantsValues.OPEN_MODE_VALUES.get(fieldName));
+        }
+      }
+    } catch (ReflectiveOperationException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOutline.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOutline.java
new file mode 100644
index 0000000..d094668
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOutline.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.graphics.Outline;
+import android.graphics.Path;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = Outline.class, minSdk = LOLLIPOP)
+public class ShadowOutline {
+
+  @Implementation
+  protected void setConvexPath(Path convexPath) {}
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOverlayConfig.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOverlayConfig.java
new file mode 100644
index 0000000..5de9359
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOverlayConfig.java
@@ -0,0 +1,52 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Process;
+import com.android.internal.content.om.OverlayConfig;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Shadow for {@link OverlayConfig}. */
+@Implements(value = OverlayConfig.class, minSdk = R, isInAndroidSdk = false)
+public class ShadowOverlayConfig {
+
+  @RealObject private OverlayConfig realOverlayConfig;
+
+  /** Override to skip the check on pid == ROOT_PID */
+  @Implementation
+  protected static OverlayConfig getZygoteInstance() {
+    int origUid = Process.myUid();
+    ShadowProcess.setUid(0);
+    OverlayConfig result = reflector(OverlayConfigReflector.class).getZygoteInstance();
+    ShadowProcess.setUid(origUid);
+    return result;
+  }
+
+  @Implementation
+  protected String[] createImmutableFrameworkIdmapsInZygote() {
+    int origUid = Process.myUid();
+    ShadowProcess.setUid(0);
+    String[] result =
+        reflector(OverlayConfigReflector.class, realOverlayConfig)
+            .createImmutableFrameworkIdmapsInZygote();
+    ShadowProcess.setUid(origUid);
+    return result;
+  }
+
+  @ForType(OverlayConfig.class)
+  interface OverlayConfigReflector {
+
+    @Static
+    @Direct
+    OverlayConfig getZygoteInstance();
+
+    @Direct
+    String[] createImmutableFrameworkIdmapsInZygote();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageBackwardCompatibility.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageBackwardCompatibility.java
new file mode 100644
index 0000000..60a465b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageBackwardCompatibility.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+
+import com.android.server.pm.parsing.library.PackageSharedLibraryUpdater;
+import java.util.List;
+import java.util.function.Supplier;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow of {@link PackageBackwardCompatibility} to handle a scenario that can come up when
+ * multiple Android versions end up on the classpath
+ */
+@Implements(className = "android.content.pm.PackageBackwardCompatibility", maxSdk = P)
+public class ShadowPackageBackwardCompatibility {
+
+  /**
+   * Stubbing this out as if Android S+ is on the classpath, we'll get a ClassCastException instead
+   * of a ClassNotFoundException. Since we don't really need this logic, simpler to just skip it
+   */
+  @Implementation
+  protected static boolean addOptionalUpdater(
+      List<PackageSharedLibraryUpdater> packageUpdaters,
+      String className,
+      Supplier<PackageSharedLibraryUpdater> defaultUpdater) {
+    return false;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageInstaller.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageInstaller.java
new file mode 100644
index 0000000..b1b53e6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageInstaller.java
@@ -0,0 +1,285 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+import android.content.IntentSender;
+import android.content.IntentSender.SendIntentException;
+import android.content.pm.PackageInstaller;
+import android.content.pm.PackageInstaller.SessionInfo;
+import android.graphics.Bitmap;
+import android.os.Handler;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+/** Shadow for PackageInstaller. */
+@Implements(value = PackageInstaller.class, minSdk = LOLLIPOP)
+@SuppressLint("NewApi")
+public class ShadowPackageInstaller {
+  /** Shadow for PackageInstaller.SessionInfo. */
+  @Implements(value = PackageInstaller.SessionInfo.class, minSdk = LOLLIPOP)
+  public static class ShadowSessionInfo {
+    @RealObject private SessionInfo sessionInfo;
+
+    /** Real method makes a system call not available in tests. */
+    @Implementation
+    protected Bitmap getAppIcon() {
+      return sessionInfo.appIcon;
+    }
+  }
+
+  // According to the documentation, the session ID is always non-zero:
+  // https://developer.android.com/reference/android/content/pm/PackageInstaller#createSession(android.content.pm.PackageInstaller.SessionParams)
+  private int nextSessionId = 1;
+  private Map<Integer, PackageInstaller.SessionInfo> sessionInfos = new HashMap<>();
+  private Map<Integer, PackageInstaller.Session> sessions = new HashMap<>();
+  private Set<CallbackInfo> callbackInfos = Collections.synchronizedSet(new HashSet<>());
+
+  private static class CallbackInfo {
+    PackageInstaller.SessionCallback callback;
+    Handler handler;
+  }
+
+  @Implementation
+  protected List<PackageInstaller.SessionInfo> getAllSessions() {
+    return ImmutableList.copyOf(sessionInfos.values());
+  }
+
+  @Implementation
+  protected List<PackageInstaller.SessionInfo> getMySessions() {
+    return getAllSessions();
+  }
+
+  @Implementation
+  protected void registerSessionCallback(
+      @NonNull PackageInstaller.SessionCallback callback, @NonNull Handler handler) {
+    CallbackInfo callbackInfo = new CallbackInfo();
+    callbackInfo.callback = callback;
+    callbackInfo.handler = handler;
+    this.callbackInfos.add(callbackInfo);
+  }
+
+  @Implementation
+  protected void unregisterSessionCallback(@NonNull PackageInstaller.SessionCallback callback) {
+    for (Iterator<CallbackInfo> i = callbackInfos.iterator(); i.hasNext(); ) {
+      final CallbackInfo callbackInfo = i.next();
+      if (callbackInfo.callback == callback) {
+        i.remove();
+        return;
+      }
+    }
+  }
+
+  @Implementation
+  @Nullable
+  protected PackageInstaller.SessionInfo getSessionInfo(int sessionId) {
+    return sessionInfos.get(sessionId);
+  }
+
+  @Implementation
+  protected int createSession(@NonNull PackageInstaller.SessionParams params) throws IOException {
+    final PackageInstaller.SessionInfo sessionInfo = new PackageInstaller.SessionInfo();
+    sessionInfo.sessionId = nextSessionId++;
+    sessionInfo.active = true;
+    sessionInfo.appPackageName = params.appPackageName;
+    sessionInfo.appLabel = params.appLabel;
+    sessionInfo.appIcon = params.appIcon;
+
+    sessionInfos.put(sessionInfo.getSessionId(), sessionInfo);
+
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(() -> callbackInfo.callback.onCreated(sessionInfo.sessionId));
+    }
+
+    return sessionInfo.sessionId;
+  }
+
+  @Implementation
+  protected void abandonSession(int sessionId) {
+    sessionInfos.remove(sessionId);
+    sessions.remove(sessionId);
+
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(() -> callbackInfo.callback.onFinished(sessionId, false));
+    }
+  }
+
+  @Implementation
+  @NonNull
+  protected PackageInstaller.Session openSession(int sessionId) throws IOException {
+    if (!sessionInfos.containsKey(sessionId)) {
+      throw new SecurityException("Invalid session Id: " + sessionId);
+    }
+
+    PackageInstaller.Session session = new PackageInstaller.Session(null);
+    ShadowSession shadowSession = Shadow.extract(session);
+    shadowSession.setShadowPackageInstaller(sessionId, this);
+    sessions.put(sessionId, session);
+    return session;
+  }
+
+  @Implementation
+  protected void updateSessionAppIcon(int sessionId, Bitmap appIcon) {
+    SessionInfo sessionInfo = sessionInfos.get(sessionId);
+    if (sessionInfo == null) {
+      throw new SecurityException("Invalid session Id: " + sessionId);
+    }
+    sessionInfo.appIcon = appIcon;
+
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(
+          new Runnable() {
+            @Override
+            public void run() {
+              callbackInfo.callback.onBadgingChanged(sessionId);
+            }
+          });
+    }
+  }
+
+  @Implementation
+  protected void updateSessionAppLabel(int sessionId, CharSequence appLabel) {
+    SessionInfo sessionInfo = sessionInfos.get(sessionId);
+    if (sessionInfo == null) {
+      throw new SecurityException("Invalid session Id: " + sessionId);
+    }
+    sessionInfo.appLabel = appLabel;
+
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(
+          new Runnable() {
+            @Override
+            public void run() {
+              callbackInfo.callback.onBadgingChanged(sessionId);
+            }
+          });
+    }
+  }
+
+  public List<PackageInstaller.SessionCallback> getAllSessionCallbacks() {
+    return ImmutableList.copyOf(callbackInfos.stream().map(info -> info.callback).iterator());
+  }
+
+  public void setSessionProgress(final int sessionId, final float progress) {
+    SessionInfo sessionInfo = sessionInfos.get(sessionId);
+    if (sessionInfo == null) {
+      throw new SecurityException("Invalid session Id: " + sessionId);
+    }
+    sessionInfo.progress = progress;
+
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(() -> callbackInfo.callback.onProgressChanged(sessionId, progress));
+    }
+  }
+
+  public void setSessionActiveState(final int sessionId, final boolean active) {
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(() -> callbackInfo.callback.onActiveChanged(sessionId, active));
+    }
+  }
+
+  /**
+   * Prefer instead to use the Android APIs to close the session {@link
+   * android.content.pm.PackageInstaller.Session#commit(IntentSender)}
+   */
+  @Deprecated
+  public void setSessionSucceeds(int sessionId) {
+    setSessionFinishes(sessionId, true);
+  }
+
+  public void setSessionFails(int sessionId) {
+    setSessionFinishes(sessionId, false);
+  }
+
+  private void setSessionFinishes(final int sessionId, final boolean success) {
+    for (final CallbackInfo callbackInfo : new ArrayList<>(callbackInfos)) {
+      callbackInfo.handler.post(() -> callbackInfo.callback.onFinished(sessionId, success));
+    }
+
+    PackageInstaller.Session session = sessions.get(sessionId);
+    ShadowSession shadowSession = Shadow.extract(session);
+    if (success) {
+      try {
+        shadowSession.statusReceiver.sendIntent(
+            RuntimeEnvironment.getApplication(), 0, null, null, null, null);
+      } catch (SendIntentException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  @Implements(value = PackageInstaller.Session.class, minSdk = LOLLIPOP)
+  public static class ShadowSession {
+
+    private OutputStream outputStream;
+    private boolean outputStreamOpen;
+    private IntentSender statusReceiver;
+    private int sessionId;
+    private ShadowPackageInstaller shadowPackageInstaller;
+
+    @Implementation(maxSdk = KITKAT_WATCH)
+    protected void __constructor__() {}
+
+    @Implementation
+    @NonNull
+    protected OutputStream openWrite(@NonNull String name, long offsetBytes, long lengthBytes)
+        throws IOException {
+      outputStream =
+          new OutputStream() {
+            @Override
+            public void write(int aByte) throws IOException {}
+
+            @Override
+            public void close() throws IOException {
+              outputStreamOpen = false;
+            }
+          };
+      outputStreamOpen = true;
+      return outputStream;
+    }
+
+    @Implementation
+    protected void fsync(@NonNull OutputStream out) throws IOException {}
+
+    @Implementation
+    protected void commit(@NonNull IntentSender statusReceiver) {
+      this.statusReceiver = statusReceiver;
+      if (outputStreamOpen) {
+        throw new SecurityException("OutputStream still open");
+      }
+
+      shadowPackageInstaller.setSessionSucceeds(sessionId);
+    }
+
+    @Implementation
+    protected void close() {}
+
+    @Implementation
+    protected void abandon() {
+      shadowPackageInstaller.abandonSession(sessionId);
+    }
+
+    private void setShadowPackageInstaller(
+        int sessionId, ShadowPackageInstaller shadowPackageInstaller) {
+      this.sessionId = sessionId;
+      this.shadowPackageInstaller = shadowPackageInstaller;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageManager.java
new file mode 100644
index 0000000..3c9f578
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageManager.java
@@ -0,0 +1,1735 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+import static android.content.pm.PackageManager.GET_ACTIVITIES;
+import static android.content.pm.PackageManager.GET_CONFIGURATIONS;
+import static android.content.pm.PackageManager.GET_GIDS;
+import static android.content.pm.PackageManager.GET_INSTRUMENTATION;
+import static android.content.pm.PackageManager.GET_INTENT_FILTERS;
+import static android.content.pm.PackageManager.GET_META_DATA;
+import static android.content.pm.PackageManager.GET_PERMISSIONS;
+import static android.content.pm.PackageManager.GET_PROVIDERS;
+import static android.content.pm.PackageManager.GET_RECEIVERS;
+import static android.content.pm.PackageManager.GET_RESOLVED_FILTER;
+import static android.content.pm.PackageManager.GET_SERVICES;
+import static android.content.pm.PackageManager.GET_SHARED_LIBRARY_FILES;
+import static android.content.pm.PackageManager.GET_SIGNATURES;
+import static android.content.pm.PackageManager.GET_URI_PERMISSION_PATTERNS;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE;
+import static android.content.pm.PackageManager.MATCH_DISABLED_COMPONENTS;
+import static android.content.pm.PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS;
+import static android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES;
+import static android.content.pm.PackageManager.SIGNATURE_FIRST_NOT_SIGNED;
+import static android.content.pm.PackageManager.SIGNATURE_MATCH;
+import static android.content.pm.PackageManager.SIGNATURE_NEITHER_SIGNED;
+import static android.content.pm.PackageManager.SIGNATURE_NO_MATCH;
+import static android.content.pm.PackageManager.SIGNATURE_SECOND_NOT_SIGNED;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static java.util.Arrays.asList;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.Manifest;
+import android.annotation.UserIdInt;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.IntentSender;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.ComponentInfo;
+import android.content.pm.FeatureInfo;
+import android.content.pm.IPackageDataObserver;
+import android.content.pm.IPackageDeleteObserver;
+import android.content.pm.InstallSourceInfo;
+import android.content.pm.ModuleInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageParser;
+import android.content.pm.PackageParser.Component;
+import android.content.pm.PackageParser.IntentInfo;
+import android.content.pm.PackageParser.Package;
+import android.content.pm.PackageParser.PermissionGroup;
+import android.content.pm.PackageStats;
+import android.content.pm.PermissionGroupInfo;
+import android.content.pm.PermissionInfo;
+import android.content.pm.ProviderInfo;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.content.pm.Signature;
+import android.content.pm.pkg.FrameworkPackageUserState;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Build;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.util.Log;
+import android.util.Pair;
+import com.google.common.base.Function;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.function.BiConsumer;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadows.ShadowPackageParser._PackageParser_;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings("NewApi")
+@Implements(PackageManager.class)
+public class ShadowPackageManager {
+  static final String TAG = "PackageManager";
+
+  @RealObject PackageManager realPackageManager;
+
+  // The big-lock to control concurrency in this class.
+  // Note: not all APIs in this class have been made thread safe yet.
+  static final Object lock = new Object();
+  static Map<String, Boolean> permissionRationaleMap = new HashMap<>();
+  static List<FeatureInfo> systemAvailableFeatures = new ArrayList<>();
+  static final List<String> systemSharedLibraryNames = new ArrayList<>();
+
+  @GuardedBy("lock")
+  static final Map<String, PackageInfo> packageInfos = new LinkedHashMap<>();
+
+  @GuardedBy("lock")
+  static final Map<String, ModuleInfo> moduleInfos = new LinkedHashMap<>();
+
+  static final Set<Object> permissionListeners = new CopyOnWriteArraySet<>();
+
+  // Those maps contain filter for components. If component exists but doesn't have filters,
+  // it will have an entry in the map with an empty list.
+  static final SortedMap<ComponentName, List<IntentFilter>> activityFilters = new TreeMap<>();
+  static final SortedMap<ComponentName, List<IntentFilter>> serviceFilters = new TreeMap<>();
+  static final SortedMap<ComponentName, List<IntentFilter>> providerFilters = new TreeMap<>();
+  static final SortedMap<ComponentName, List<IntentFilter>> receiverFilters = new TreeMap<>();
+
+  private static Map<String, PackageInfo> packageArchiveInfo = new HashMap<>();
+  static final Map<String, PackageStats> packageStatsMap = new HashMap<>();
+  static final Map<String, String> packageInstallerMap = new HashMap<>();
+  static final Map<String, Object> packageInstallSourceInfoMap = new HashMap<>();
+  static final Map<Integer, String[]> packagesForUid = new HashMap<>();
+  static final Map<String, Integer> uidForPackage = new HashMap<>();
+  static final Map<Integer, String> namesForUid = new HashMap<>();
+  static final Map<Integer, Integer> verificationResults = new HashMap<>();
+  static final Map<Integer, Long> verificationTimeoutExtension = new HashMap<>();
+  static final Map<String, String> currentToCanonicalNames = new HashMap<>();
+  static final Map<String, String> canonicalToCurrentNames = new HashMap<>();
+  static final Map<ComponentName, ComponentState> componentList = new LinkedHashMap<>();
+  static final Map<ComponentName, Drawable> drawableList = new LinkedHashMap<>();
+  static final Map<String, Drawable> applicationIcons = new HashMap<>();
+  static final Map<String, Drawable> unbadgedApplicationIcons = new HashMap<>();
+  static final Map<String, Boolean> systemFeatureList =
+      new LinkedHashMap<>(SystemFeatureListInitializer.getSystemFeatures());
+  static final SortedMap<ComponentName, List<IntentFilter>> preferredActivities = new TreeMap<>();
+  static final SortedMap<ComponentName, List<IntentFilter>> persistentPreferredActivities =
+      new TreeMap<>();
+  static final Map<Pair<String, Integer>, Drawable> drawables = new LinkedHashMap<>();
+  /**
+   * Map of package names to an inner map where the key is the resource id which fetches its
+   * corresponding text.
+   */
+  static final Map<String, Map<Integer, String>> stringResources = new HashMap<>();
+
+  static final Map<String, Integer> applicationEnabledSettingMap = new HashMap<>();
+  static Map<String, PermissionInfo> extraPermissions = new HashMap<>();
+  static Map<String, PermissionGroupInfo> permissionGroups = new HashMap<>();
+  /**
+   * Map of package names to an inner map where the key is the permission and the integer represents
+   * the permission flags set for that particular permission
+   */
+  static Map<String, Map<String, Integer>> permissionFlags = new HashMap<>();
+
+  public static Map<String, Resources> resources = new HashMap<>();
+  static final Map<Intent, List<ResolveInfo>> resolveInfoForIntent =
+      new TreeMap<>(new IntentComparator());
+
+  @GuardedBy("lock")
+  static Set<String> deletedPackages = new HashSet<>();
+
+  static Map<String, IPackageDeleteObserver> pendingDeleteCallbacks = new HashMap<>();
+  static Set<String> hiddenPackages = new HashSet<>();
+  static Multimap<Integer, String> sequenceNumberChangedPackagesMap = HashMultimap.create();
+  static boolean canRequestPackageInstalls = false;
+  static boolean safeMode = false;
+  static boolean whitelisted = false;
+  boolean shouldShowActivityChooser = false;
+  static final Map<String, Integer> distractingPackageRestrictions = new ConcurrentHashMap<>();
+
+  /**
+   * Makes sure that given activity exists.
+   *
+   * If the activity doesn't exist yet, it will be created with {@code applicationInfo} set to an
+   * existing application, or if it doesn't exist, a new package will be created.
+   *
+   * @return existing or newly created activity info.
+   */
+  public ActivityInfo addActivityIfNotPresent(ComponentName componentName) {
+    return addComponent(
+        activityFilters,
+        p -> p.activities,
+        (p, a) -> p.activities = a,
+        updateName(componentName, new ActivityInfo()),
+        false);
+  }
+
+  /**
+   * Makes sure that given service exists.
+   *
+   * If the service doesn't exist yet, it will be created with {@code applicationInfo} set to an
+   * existing application, or if it doesn't exist, a new package will be created.
+   *
+   * @return existing or newly created service info.
+   */
+  public ServiceInfo addServiceIfNotPresent(ComponentName componentName) {
+    return addComponent(
+        serviceFilters,
+        p -> p.services,
+        (p, a) -> p.services = a,
+        updateName(componentName, new ServiceInfo()),
+        false);
+  }
+
+  /**
+   * Makes sure that given receiver exists.
+   *
+   * If the receiver doesn't exist yet, it will be created with {@code applicationInfo} set to an
+   * existing application, or if it doesn't exist, a new package will be created.
+   *
+   * @return existing or newly created receiver info.
+   */
+  public ActivityInfo addReceiverIfNotPresent(ComponentName componentName) {
+    return addComponent(
+        receiverFilters,
+        p -> p.receivers,
+        (p, a) -> p.receivers = a,
+        updateName(componentName, new ActivityInfo()),
+        false);
+  }
+
+  /**
+   * Makes sure that given provider exists.
+   *
+   * If the provider doesn't exist yet, it will be created with {@code applicationInfo} set to an
+   * existing application, or if it doesn't exist, a new package will be created.
+   *
+   * @return existing or newly created provider info.
+   */
+  public ProviderInfo addProviderIfNotPresent(ComponentName componentName) {
+    return addComponent(
+        providerFilters,
+        p -> p.providers,
+        (p, a) -> p.providers = a,
+        updateName(componentName, new ProviderInfo()),
+        false);
+  }
+
+  private <C extends ComponentInfo> C updateName(ComponentName name, C component) {
+    component.name = name.getClassName();
+    component.packageName = name.getPackageName();
+    if (component.applicationInfo != null) {
+      component.applicationInfo.packageName = component.packageName;
+    }
+    return component;
+  }
+
+  /**
+   * Adds or updates given activity in the system.
+   *
+   * If activity with the same {@link ComponentInfo#name} and {@code ComponentInfo#packageName}
+   * exists it will be updated. Its {@link ComponentInfo#applicationInfo} is always set to {@link
+   * ApplicationInfo} already existing in the system, but if no application exists a new one will
+   * be created using {@link ComponentInfo#applicationInfo} in this component.
+   */
+  public void addOrUpdateActivity(ActivityInfo activityInfo) {
+    addComponent(
+        activityFilters,
+        p -> p.activities,
+        (p, a) -> p.activities = a,
+        new ActivityInfo(activityInfo),
+        true);
+  }
+
+  /**
+   * Adds or updates given service in the system.
+   *
+   * If service with the same {@link ComponentInfo#name} and {@code ComponentInfo#packageName}
+   * exists it will be updated. Its {@link ComponentInfo#applicationInfo} is always set to {@link
+   * ApplicationInfo} already existing in the system, but if no application exists a new one will be
+   * created using {@link ComponentInfo#applicationInfo} in this component.
+   */
+  public void addOrUpdateService(ServiceInfo serviceInfo) {
+    addComponent(
+        serviceFilters,
+        p -> p.services,
+        (p, a) -> p.services = a,
+        new ServiceInfo(serviceInfo),
+        true);
+  }
+
+  /**
+   * Adds or updates given broadcast receiver in the system.
+   *
+   * If broadcast receiver with the same {@link ComponentInfo#name} and {@code
+   * ComponentInfo#packageName} exists it will be updated. Its {@link ComponentInfo#applicationInfo}
+   * is always set to {@link ApplicationInfo} already existing in the system, but if no
+   * application exists a new one will be created using {@link ComponentInfo#applicationInfo} in
+   * this component.
+   */
+  public void addOrUpdateReceiver(ActivityInfo receiverInfo) {
+    addComponent(
+        receiverFilters,
+        p -> p.receivers,
+        (p, a) -> p.receivers = a,
+        new ActivityInfo(receiverInfo),
+        true);
+  }
+
+  /**
+   * Adds or updates given content provider in the system.
+   *
+   * If content provider with the same {@link ComponentInfo#name} and {@code
+   * ComponentInfo#packageName} exists it will be updated. Its {@link ComponentInfo#applicationInfo}
+   * is always set to {@link ApplicationInfo} already existing in the system, but if no
+   * application exists a new one will be created using {@link ComponentInfo#applicationInfo} in
+   * this component.
+   */
+  public void addOrUpdateProvider(ProviderInfo providerInfo) {
+    addComponent(
+        providerFilters,
+        p -> p.providers,
+        (p, a) -> p.providers = a,
+        new ProviderInfo(providerInfo),
+        true);
+  }
+
+  /**
+   * Removes activity from the package manager.
+   *
+   * @return the removed component or {@code null} if no such component existed.
+   */
+  @Nullable
+  public ActivityInfo removeActivity(ComponentName componentName) {
+    return removeComponent(
+        componentName, activityFilters, p -> p.activities, (p, a) -> p.activities = a);
+  }
+
+  /**
+   * Removes service from the package manager.
+   *
+   * @return the removed component or {@code null} if no such component existed.
+   */
+  @Nullable
+  public ServiceInfo removeService(ComponentName componentName) {
+    return removeComponent(
+        componentName, serviceFilters, p -> p.services, (p, a) -> p.services = a);
+  }
+
+  /**
+   * Removes content provider from the package manager.
+   *
+   * @return the removed component or {@code null} if no such component existed.
+   */
+  @Nullable
+  public ProviderInfo removeProvider(ComponentName componentName) {
+    return removeComponent(
+        componentName, providerFilters, p -> p.providers, (p, a) -> p.providers = a);
+  }
+
+  /**
+   * Removes broadcast receiver from the package manager.
+   *
+   * @return the removed component or {@code null} if no such component existed.
+   */
+  @Nullable
+  public ActivityInfo removeReceiver(ComponentName componentName) {
+    return removeComponent(
+        componentName, receiverFilters, p -> p.receivers, (p, a) -> p.receivers = a);
+  }
+
+  private <C extends ComponentInfo> C addComponent(
+      SortedMap<ComponentName, List<IntentFilter>> filtersMap,
+      Function<PackageInfo, C[]> componentArrayInPackage,
+      BiConsumer<PackageInfo, C[]> componentsSetter,
+      C newComponent,
+      boolean updateIfExists) {
+    synchronized (lock) {
+      String packageName = newComponent.packageName;
+      if (packageName == null && newComponent.applicationInfo != null) {
+        packageName = newComponent.applicationInfo.packageName;
+      }
+      if (packageName == null) {
+        throw new IllegalArgumentException("Component needs a package name");
+      }
+      if (newComponent.name == null) {
+        throw new IllegalArgumentException("Component needs a name");
+      }
+      PackageInfo packageInfo = packageInfos.get(packageName);
+      if (packageInfo == null) {
+        packageInfo = new PackageInfo();
+        packageInfo.packageName = packageName;
+        packageInfo.applicationInfo = newComponent.applicationInfo;
+        installPackage(packageInfo);
+        packageInfo = packageInfos.get(packageName);
+      }
+      newComponent.applicationInfo = packageInfo.applicationInfo;
+      C[] components = componentArrayInPackage.apply(packageInfo);
+      if (components == null) {
+        @SuppressWarnings("unchecked")
+        C[] newComponentArray = (C[]) Array.newInstance(newComponent.getClass(), 0);
+        components = newComponentArray;
+      } else {
+        for (int i = 0; i < components.length; i++) {
+          if (newComponent.name.equals(components[i].name)) {
+            if (updateIfExists) {
+              components[i] = newComponent;
+            }
+            return components[i];
+          }
+        }
+      }
+      components = Arrays.copyOf(components, components.length + 1);
+      componentsSetter.accept(packageInfo, components);
+      components[components.length - 1] = newComponent;
+
+      filtersMap.put(
+          new ComponentName(newComponent.packageName, newComponent.name), new ArrayList<>());
+      return newComponent;
+    }
+  }
+
+  @Nullable
+  private <C extends ComponentInfo> C removeComponent(
+      ComponentName componentName,
+      SortedMap<ComponentName, List<IntentFilter>> filtersMap,
+      Function<PackageInfo, C[]> componentArrayInPackage,
+      BiConsumer<PackageInfo, C[]> componentsSetter) {
+    synchronized (lock) {
+      filtersMap.remove(componentName);
+      String packageName = componentName.getPackageName();
+      PackageInfo packageInfo = packageInfos.get(packageName);
+      if (packageInfo == null) {
+        return null;
+      }
+      C[] components = componentArrayInPackage.apply(packageInfo);
+      if (components == null) {
+        return null;
+      }
+      for (int i = 0; i < components.length; i++) {
+        C component = components[i];
+        if (componentName.getClassName().equals(component.name)) {
+          C[] newComponents;
+          if (components.length == 1) {
+            newComponents = null;
+          } else {
+            newComponents = Arrays.copyOf(components, components.length - 1);
+            System.arraycopy(components, i + 1, newComponents, i, components.length - i - 1);
+          }
+          componentsSetter.accept(packageInfo, newComponents);
+          return component;
+        }
+      }
+      return null;
+    }
+  }
+
+  /**
+   * Settings for a particular package.
+   *
+   * This class mirrors {@link com.android.server.pm.PackageSetting}, which is used by {@link
+   * PackageManager}.
+   */
+  public static class PackageSetting {
+
+    /** Whether the package is suspended in {@link PackageManager}. */
+    private boolean suspended = false;
+
+    /** The message to be displayed to the user when they try to launch the app. */
+    private String dialogMessage = null;
+
+    /**
+     * The info for how to display the dialog that shows to the user when they try to launch the
+     * app. On Q, one of this field or dialogMessage will be present when a package is suspended.
+     */
+    private Object dialogInfo = null;
+
+    /** An optional {@link PersistableBundle} shared with the app. */
+    private PersistableBundle suspendedAppExtras = null;
+
+    /** An optional {@link PersistableBundle} shared with the launcher. */
+    private PersistableBundle suspendedLauncherExtras = null;
+
+    public PackageSetting() {}
+
+    public PackageSetting(PackageSetting that) {
+      this.suspended = that.suspended;
+      this.dialogMessage = that.dialogMessage;
+      this.dialogInfo = that.dialogInfo;
+      this.suspendedAppExtras = deepCopyNullablePersistableBundle(that.suspendedAppExtras);
+      this.suspendedLauncherExtras =
+          deepCopyNullablePersistableBundle(that.suspendedLauncherExtras);
+    }
+
+    /**
+     * Sets the suspension state of the package.
+     *
+     * <p>If {@code suspended} is false, {@code dialogInfo}, {@code appExtras}, and {@code
+     * launcherExtras} will be ignored.
+     */
+    void setSuspended(
+        boolean suspended,
+        String dialogMessage,
+        /* SuspendDialogInfo */ Object dialogInfo,
+        PersistableBundle appExtras,
+        PersistableBundle launcherExtras) {
+      Preconditions.checkArgument(dialogMessage == null || dialogInfo == null);
+      this.suspended = suspended;
+      this.dialogMessage = suspended ? dialogMessage : null;
+      this.dialogInfo = suspended ? dialogInfo : null;
+      this.suspendedAppExtras = suspended ? deepCopyNullablePersistableBundle(appExtras) : null;
+      this.suspendedLauncherExtras =
+          suspended ? deepCopyNullablePersistableBundle(launcherExtras) : null;
+    }
+
+    public boolean isSuspended() {
+      return suspended;
+    }
+
+    public String getDialogMessage() {
+      return dialogMessage;
+    }
+
+    public Object getDialogInfo() {
+      return dialogInfo;
+    }
+
+    public PersistableBundle getSuspendedAppExtras() {
+      return suspendedAppExtras;
+    }
+
+    public PersistableBundle getSuspendedLauncherExtras() {
+      return suspendedLauncherExtras;
+    }
+
+    private static PersistableBundle deepCopyNullablePersistableBundle(PersistableBundle bundle) {
+      return bundle == null ? null : bundle.deepCopy();
+    }
+
+  }
+
+  static final Map<String, PackageSetting> packageSettings = new HashMap<>();
+
+  // From com.android.server.pm.PackageManagerService.compareSignatures().
+  static int compareSignature(Signature[] signatures1, Signature[] signatures2) {
+    if (signatures1 == null) {
+      return (signatures2 == null) ? SIGNATURE_NEITHER_SIGNED : SIGNATURE_FIRST_NOT_SIGNED;
+    }
+    if (signatures2 == null) {
+      return SIGNATURE_SECOND_NOT_SIGNED;
+    }
+    if (signatures1.length != signatures2.length) {
+      return SIGNATURE_NO_MATCH;
+    }
+    HashSet<Signature> signatures1set = new HashSet<>(asList(signatures1));
+    HashSet<Signature> signatures2set = new HashSet<>(asList(signatures2));
+    return signatures1set.equals(signatures2set) ? SIGNATURE_MATCH : SIGNATURE_NO_MATCH;
+  }
+
+  // TODO(christianw): reconcile with AndroidTestEnvironment.setUpPackageStorage
+  private static void setUpPackageStorage(ApplicationInfo applicationInfo) {
+    if (applicationInfo.sourceDir == null) {
+      applicationInfo.sourceDir = createTempDir(applicationInfo.packageName + "-sourceDir");
+    }
+
+    if (applicationInfo.dataDir == null) {
+      applicationInfo.dataDir = createTempDir(applicationInfo.packageName + "-dataDir");
+    }
+    if (applicationInfo.publicSourceDir == null) {
+      applicationInfo.publicSourceDir = applicationInfo.sourceDir;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      applicationInfo.credentialProtectedDataDir = createTempDir("userDataDir");
+      applicationInfo.deviceProtectedDataDir = createTempDir("deviceDataDir");
+    }
+  }
+
+  private static String createTempDir(String name) {
+    return RuntimeEnvironment.getTempDirectory()
+        .createIfNotExists(name)
+        .toAbsolutePath()
+        .toString();
+  }
+
+  /**
+   * Sets extra resolve infos for an intent.
+   *
+   * Those entries are added to whatever might be in the manifest already.
+   *
+   * Note that all resolve infos will have {@link ResolveInfo#isDefault} field set to {@code
+   * true} to allow their resolution for implicit intents. If this is not what you want, then you
+   * still have the reference to those ResolveInfos, and you can set the field back to {@code
+   * false}.
+   *
+   * @deprecated see the note on {@link #addResolveInfoForIntent(Intent, ResolveInfo)}.
+   */
+  @Deprecated
+  public void setResolveInfosForIntent(Intent intent, List<ResolveInfo> info) {
+    resolveInfoForIntent.remove(intent);
+    for (ResolveInfo resolveInfo : info) {
+      addResolveInfoForIntent(intent, resolveInfo);
+    }
+  }
+
+  /** @deprecated see note on {@link #addResolveInfoForIntent(Intent, ResolveInfo)}. */
+  @Deprecated
+  public void addResolveInfoForIntent(Intent intent, List<ResolveInfo> info) {
+    setResolveInfosForIntent(intent, info);
+  }
+
+  /**
+   * Adds extra resolve info for an intent.
+   *
+   * Note that this resolve info will have {@link ResolveInfo#isDefault} field set to {@code
+   * true} to allow its resolution for implicit intents. If this is not what you want, then please
+   * use {@link #addResolveInfoForIntentNoDefaults} instead.
+   *
+   * @deprecated use {@link #addIntentFilterForComponent} instead and if the component doesn't exist
+   *     add it using any of {@link #installPackage}, {@link #addOrUpdateActivity}, {@link
+   *     #addActivityIfNotPresent} or their counterparts for other types of components.
+   */
+  @Deprecated
+  public void addResolveInfoForIntent(Intent intent, ResolveInfo info) {
+    info.isDefault = true;
+    ComponentInfo[] componentInfos =
+        new ComponentInfo[] {
+          info.activityInfo,
+          info.serviceInfo,
+          Build.VERSION.SDK_INT >= KITKAT ? info.providerInfo : null
+        };
+    for (ComponentInfo component : componentInfos) {
+      if (component != null && component.applicationInfo != null) {
+        component.applicationInfo.flags |= ApplicationInfo.FLAG_INSTALLED;
+        if (component.applicationInfo.processName == null) {
+          component.applicationInfo.processName = component.applicationInfo.packageName;
+        }
+      }
+    }
+    if (info.match == 0) {
+      info.match = Integer.MAX_VALUE; // make sure, that this is as good match as possible.
+    }
+    addResolveInfoForIntentNoDefaults(intent, info);
+  }
+
+  /**
+   * Adds the {@code info} as {@link ResolveInfo} for the intent but without applying any default
+   * values.
+   *
+   * In particular it will not make the {@link ResolveInfo#isDefault} field {@code true}, that
+   * means that this resolve info will not resolve for {@link Intent#resolveActivity} and {@link
+   * Context#startActivity}.
+   *
+   * @deprecated see the note on {@link #addResolveInfoForIntent(Intent, ResolveInfo)}.
+   */
+  @Deprecated
+  public void addResolveInfoForIntentNoDefaults(Intent intent, ResolveInfo info) {
+    Preconditions.checkNotNull(info);
+    List<ResolveInfo> infoList = resolveInfoForIntent.get(intent);
+    if (infoList == null) {
+      infoList = new ArrayList<>();
+      resolveInfoForIntent.put(intent, infoList);
+    }
+    infoList.add(info);
+  }
+
+  /**
+   * Removes {@link ResolveInfo}s registered using {@link #addResolveInfoForIntent}.
+   *
+   * @deprecated see note on {@link #addResolveInfoForIntent(Intent, ResolveInfo)}.
+   */
+  @Deprecated
+  public void removeResolveInfosForIntent(Intent intent, String packageName) {
+    List<ResolveInfo> infoList = resolveInfoForIntent.get(intent);
+    if (infoList == null) {
+      infoList = new ArrayList<>();
+      resolveInfoForIntent.put(intent, infoList);
+    }
+
+    for (Iterator<ResolveInfo> iterator = infoList.iterator(); iterator.hasNext(); ) {
+      ResolveInfo resolveInfo = iterator.next();
+      if (getPackageName(resolveInfo).equals(packageName)) {
+        iterator.remove();
+      }
+    }
+  }
+
+  private static String getPackageName(ResolveInfo resolveInfo) {
+    if (resolveInfo.resolvePackageName != null) {
+      return resolveInfo.resolvePackageName;
+    } else if (resolveInfo.activityInfo != null) {
+      return resolveInfo.activityInfo.packageName;
+    } else if (resolveInfo.serviceInfo != null) {
+      return resolveInfo.serviceInfo.packageName;
+    } else if (resolveInfo.providerInfo != null) {
+      return resolveInfo.providerInfo.packageName;
+    }
+    throw new IllegalStateException(
+        "Could not find package name for ResolveInfo " + resolveInfo.toString());
+  }
+
+  public void addActivityIcon(ComponentName component, Drawable drawable) {
+    drawableList.put(component, drawable);
+  }
+
+  public void addActivityIcon(Intent intent, Drawable drawable) {
+    drawableList.put(intent.getComponent(), drawable);
+  }
+
+  public void setApplicationIcon(String packageName, Drawable drawable) {
+    applicationIcons.put(packageName, drawable);
+  }
+
+  public void setUnbadgedApplicationIcon(String packageName, Drawable drawable) {
+    unbadgedApplicationIcons.put(packageName, drawable);
+  }
+
+  /**
+   * Return the flags set in call to {@link
+   * android.app.ApplicationPackageManager#setComponentEnabledSetting(ComponentName, int, int)}.
+   *
+   * @param componentName The component name.
+   * @return The flags.
+   */
+  public int getComponentEnabledSettingFlags(ComponentName componentName) {
+    ComponentState state = componentList.get(componentName);
+    return state != null ? state.flags : 0;
+  }
+
+  /**
+   * Installs a module with the {@link PackageManager} as long as it is not {@code null}
+   *
+   * <p>In order to create ModuleInfo objects in a valid state please use {@link ModuleInfoBuilder}.
+   */
+  public void installModule(Object moduleInfoObject) {
+    synchronized (lock) {
+      ModuleInfo moduleInfo = (ModuleInfo) moduleInfoObject;
+      if (moduleInfo != null) {
+        moduleInfos.put(moduleInfo.getPackageName(), moduleInfo);
+        // Checking to see if package exists in the system
+        if (packageInfos.get(moduleInfo.getPackageName()) == null) {
+          ApplicationInfo applicationInfo = new ApplicationInfo();
+          applicationInfo.packageName = moduleInfo.getPackageName();
+          applicationInfo.name = moduleInfo.getName().toString();
+
+          PackageInfo packageInfo = new PackageInfo();
+          packageInfo.applicationInfo = applicationInfo;
+          packageInfo.packageName = moduleInfo.getPackageName();
+          installPackage(packageInfo);
+        }
+      }
+    }
+  }
+
+  /**
+   * Deletes a module when given the module's package name {@link ModuleInfo} be sure to give the
+   * correct name as this method does not ensure existence of the module before deletion. Since
+   * module installation ensures that a package exists in the device, also delete the package for
+   * full deletion.
+   *
+   * @param packageName should be the value of {@link ModuleInfo#getPackageName}.
+   * @return deleted module of {@code null} if no module with this name exists.
+   */
+  public Object deleteModule(String packageName) {
+    synchronized (lock) {
+      // Removes the accompanying package installed with the module
+      return moduleInfos.remove(packageName);
+    }
+  }
+
+  /**
+   * Installs a package with the {@link PackageManager}.
+   *
+   * In order to create PackageInfo objects in a valid state please use {@link
+   * androidx.test.core.content.pm.PackageInfoBuilder}.
+   *
+   * This method automatically simulates instalation of a package in the system, so it adds a
+   * flag {@link ApplicationInfo#FLAG_INSTALLED} to the application info and makes sure it exits. It
+   * will update applicationInfo in package components as well.
+   *
+   * If you don't want the package to be installed, use {@link #addPackageNoDefaults} instead.
+   */
+  public void installPackage(PackageInfo packageInfo) {
+    ApplicationInfo appInfo = packageInfo.applicationInfo;
+    if (appInfo == null) {
+      appInfo = new ApplicationInfo();
+      packageInfo.applicationInfo = appInfo;
+    }
+    if (appInfo.packageName == null) {
+      appInfo.packageName = packageInfo.packageName;
+    }
+    if (appInfo.processName == null) {
+      appInfo.processName = appInfo.packageName;
+    }
+    if (appInfo.targetSdkVersion == 0) {
+      appInfo.targetSdkVersion = RuntimeEnvironment.getApiLevel();
+    }
+    appInfo.flags |= ApplicationInfo.FLAG_INSTALLED;
+    ComponentInfo[][] componentInfoArrays =
+        new ComponentInfo[][] {
+          packageInfo.activities,
+          packageInfo.services,
+          packageInfo.providers,
+          packageInfo.receivers,
+        };
+    int uniqueNameCounter = 0;
+    for (ComponentInfo[] componentInfos : componentInfoArrays) {
+      if (componentInfos == null) {
+        continue;
+      }
+      for (ComponentInfo componentInfo : componentInfos) {
+        if (componentInfo.name == null) {
+          componentInfo.name = appInfo.packageName + ".DefaultName" + uniqueNameCounter++;
+          componentInfo.packageName = packageInfo.packageName;
+        }
+        componentInfo.applicationInfo = appInfo;
+        componentInfo.packageName = appInfo.packageName;
+        if (componentInfo.processName == null) {
+          componentInfo.processName = appInfo.processName;
+        }
+      }
+    }
+    addPackageNoDefaults(packageInfo);
+  }
+
+  /** Adds install source information for a package. */
+  public void setInstallSourceInfo(
+      String packageName, String initiatingPackage, String installerPackage) {
+    packageInstallSourceInfoMap.put(
+        packageName, new InstallSourceInfo(initiatingPackage, null, null, installerPackage));
+  }
+
+  /**
+   * Adds a package to the {@link PackageManager}, but doesn't set any default values on it.
+   *
+   * <p>Right now it will not set {@link ApplicationInfo#FLAG_INSTALLED} flag on its application, so
+   * if not set explicitly, it will be treated as not installed.
+   */
+  public void addPackageNoDefaults(PackageInfo packageInfo) {
+    PackageStats packageStats = new PackageStats(packageInfo.packageName);
+    addPackage(packageInfo, packageStats);
+  }
+
+  /**
+   * Installs a package with its stats with the {@link PackageManager}.
+   *
+   * <p>This method doesn't add any defaults to the {@code packageInfo} parameters. You should make
+   * sure it is valid (see {@link #installPackage(PackageInfo)}).
+   */
+  public void addPackage(PackageInfo packageInfo, PackageStats packageStats) {
+    synchronized (lock) {
+      if (packageInfo.applicationInfo != null
+          && (packageInfo.applicationInfo.flags & ApplicationInfo.FLAG_INSTALLED) == 0) {
+        Log.w(TAG, "Adding not installed package: " + packageInfo.packageName);
+      }
+      Preconditions.checkArgument(packageInfo.packageName.equals(packageStats.packageName));
+
+      packageInfos.put(packageInfo.packageName, packageInfo);
+      packageStatsMap.put(packageInfo.packageName, packageStats);
+
+      packageSettings.put(packageInfo.packageName, new PackageSetting());
+
+      applicationEnabledSettingMap.put(
+          packageInfo.packageName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT);
+      if (packageInfo.applicationInfo != null) {
+        uidForPackage.put(packageInfo.packageName, packageInfo.applicationInfo.uid);
+        namesForUid.put(packageInfo.applicationInfo.uid, packageInfo.packageName);
+      }
+    }
+  }
+
+  /** @deprecated Use {@link #installPackage(PackageInfo)} instead. */
+  @Deprecated
+  public void addPackage(String packageName) {
+    PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = packageName;
+
+    ApplicationInfo applicationInfo = new ApplicationInfo();
+
+    applicationInfo.packageName = packageName;
+    // TODO: setUpPackageStorage should be in installPackage but we need to fix all tests first
+    setUpPackageStorage(applicationInfo);
+    packageInfo.applicationInfo = applicationInfo;
+    installPackage(packageInfo);
+  }
+
+  /** This method is getting renamed to {link {@link #installPackage}. */
+  @Deprecated
+  public void addPackage(PackageInfo packageInfo) {
+    installPackage(packageInfo);
+  }
+
+  /**
+   * Testing API allowing to retrieve internal package representation.
+   *
+   * This will allow to modify the package in a way visible to Robolectric, as this is
+   * Robolectric's internal full package representation.
+   *
+   * Note that maybe a better way is to just modify the test manifest to make those modifications
+   * in a standard way.
+   *
+   * Retrieving package info using {@link PackageManager#getPackageInfo} / {@link
+   * PackageManager#getApplicationInfo} will return defensive copies that will be stripped out of
+   * information according to provided flags. Don't use it to modify Robolectric state.
+   */
+  public PackageInfo getInternalMutablePackageInfo(String packageName) {
+    synchronized (lock) {
+      return packageInfos.get(packageName);
+    }
+  }
+
+  public void addPermissionInfo(PermissionInfo permissionInfo) {
+    extraPermissions.put(permissionInfo.name, permissionInfo);
+  }
+
+  /**
+   * Adds {@code packageName} to the list of changed packages for the particular {@code
+   * sequenceNumber}.
+   *
+   * @param sequenceNumber has to be >= 0
+   * @param packageName name of the package that was changed
+   */
+  public void addChangedPackage(int sequenceNumber, String packageName) {
+    if (sequenceNumber < 0) {
+      return;
+    }
+    sequenceNumberChangedPackagesMap.put(sequenceNumber, packageName);
+  }
+
+  /**
+   * Allows overriding or adding permission-group elements. These would be otherwise specified by
+   * either (the
+   * system)[https://developer.android.com/guide/topics/permissions/requesting.html#perm-groups] or
+   * by (the app
+   * itself)[https://developer.android.com/guide/topics/manifest/permission-group-element.html], as
+   * part of its manifest
+   *
+   * {@link android.content.pm.PackageParser.PermissionGroup}s added through this method have
+   * precedence over those specified with the same name by one of the aforementioned methods.
+   *
+   * @see PackageManager#getAllPermissionGroups(int)
+   * @see PackageManager#getPermissionGroupInfo(String, int)
+   */
+  public void addPermissionGroupInfo(PermissionGroupInfo permissionGroupInfo) {
+    permissionGroups.put(permissionGroupInfo.name, permissionGroupInfo);
+  }
+
+  public void removePackage(String packageName) {
+    synchronized (lock) {
+      packageInfos.remove(packageName);
+
+      packageSettings.remove(packageName);
+    }
+  }
+
+  public void setSystemFeature(String name, boolean supported) {
+    systemFeatureList.put(name, supported);
+  }
+
+  public void addDrawableResolution(String packageName, int resourceId, Drawable drawable) {
+    drawables.put(new Pair(packageName, resourceId), drawable);
+  }
+
+  public void setNameForUid(int uid, String name) {
+    namesForUid.put(uid, name);
+  }
+
+  public void setPackagesForCallingUid(String... packagesForCallingUid) {
+    packagesForUid.put(Binder.getCallingUid(), packagesForCallingUid);
+    for (String packageName : packagesForCallingUid) {
+      uidForPackage.put(packageName, Binder.getCallingUid());
+    }
+  }
+
+  public void setPackagesForUid(int uid, String... packagesForCallingUid) {
+    packagesForUid.put(uid, packagesForCallingUid);
+    for (String packageName : packagesForCallingUid) {
+      uidForPackage.put(packageName, uid);
+    }
+  }
+
+  @Implementation
+  @Nullable
+  protected String[] getPackagesForUid(int uid) {
+    return packagesForUid.get(uid);
+  }
+
+  public void setPackageArchiveInfo(String archiveFilePath, PackageInfo packageInfo) {
+    packageArchiveInfo.put(archiveFilePath, packageInfo);
+  }
+
+  public int getVerificationResult(int id) {
+    Integer result = verificationResults.get(id);
+    if (result == null) {
+      // 0 isn't a "valid" result, so we can check for the case when verification isn't
+      // called, if needed
+      return 0;
+    }
+    return result;
+  }
+
+  public long getVerificationExtendedTimeout(int id) {
+    Long result = verificationTimeoutExtension.get(id);
+    if (result == null) {
+      return 0;
+    }
+    return result;
+  }
+
+  public void setShouldShowRequestPermissionRationale(String permission, boolean show) {
+    permissionRationaleMap.put(permission, show);
+  }
+
+  public void addSystemAvailableFeature(FeatureInfo featureInfo) {
+    systemAvailableFeatures.add(featureInfo);
+  }
+
+  public void clearSystemAvailableFeatures() {
+    systemAvailableFeatures.clear();
+  }
+
+  /** Adds a value to be returned by {@link PackageManager#getSystemSharedLibraryNames()}. */
+  public void addSystemSharedLibraryName(String name) {
+    systemSharedLibraryNames.add(name);
+  }
+
+  /** Clears the values returned by {@link PackageManager#getSystemSharedLibraryNames()}. */
+  public void clearSystemSharedLibraryNames() {
+    systemSharedLibraryNames.clear();
+  }
+
+  @Deprecated
+  /** @deprecated use {@link #addCanonicalName} instead.} */
+  public void addCurrentToCannonicalName(String currentName, String canonicalName) {
+    currentToCanonicalNames.put(currentName, canonicalName);
+  }
+
+  /**
+   * Adds a canonical package name for a package.
+   *
+   * <p>This will be reflected when calling {@link
+   * PackageManager#currentToCanonicalPackageNames(String[])} or {@link
+   * PackageManager#canonicalToCurrentPackageNames(String[])} (String[])}.
+   */
+  public void addCanonicalName(String currentName, String canonicalName) {
+    currentToCanonicalNames.put(currentName, canonicalName);
+    canonicalToCurrentNames.put(canonicalName, currentName);
+  }
+
+  /**
+   * Sets if the {@link PackageManager} is allowed to request package installs through package
+   * installer.
+   */
+  public void setCanRequestPackageInstalls(boolean canRequestPackageInstalls) {
+    ShadowPackageManager.canRequestPackageInstalls = canRequestPackageInstalls;
+  }
+
+  @Implementation(minSdk = N)
+  protected List<ResolveInfo> queryBroadcastReceiversAsUser(
+      Intent intent, int flags, UserHandle userHandle) {
+    return null;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected List<ResolveInfo> queryBroadcastReceivers(
+      Intent intent, int flags, @UserIdInt int userId) {
+    return null;
+  }
+
+  @Implementation
+  protected PackageInfo getPackageArchiveInfo(String archiveFilePath, int flags) {
+    PackageInfo shadowPackageInfo = getShadowPackageArchiveInfo(archiveFilePath, flags);
+    if (shadowPackageInfo != null) {
+      return shadowPackageInfo;
+    } else {
+      return reflector(PackageManagerReflector.class, realPackageManager)
+          .getPackageArchiveInfo(archiveFilePath, flags);
+    }
+  }
+
+  protected PackageInfo getShadowPackageArchiveInfo(String archiveFilePath, int flags) {
+    synchronized (lock) {
+      if (packageArchiveInfo.containsKey(archiveFilePath)) {
+        return packageArchiveInfo.get(archiveFilePath);
+      }
+
+      List<PackageInfo> result = new ArrayList<>();
+      for (PackageInfo packageInfo : packageInfos.values()) {
+        if (applicationEnabledSettingMap.get(packageInfo.packageName)
+                != COMPONENT_ENABLED_STATE_DISABLED
+            || (flags & MATCH_UNINSTALLED_PACKAGES) == MATCH_UNINSTALLED_PACKAGES) {
+          result.add(packageInfo);
+        }
+      }
+
+      List<PackageInfo> packages = result;
+      for (PackageInfo aPackage : packages) {
+        ApplicationInfo appInfo = aPackage.applicationInfo;
+        if (appInfo != null && archiveFilePath.equals(appInfo.sourceDir)) {
+          return aPackage;
+        }
+      }
+      return null;
+    }
+  }
+
+  @Implementation
+  protected void freeStorageAndNotify(long freeStorageSize, IPackageDataObserver observer) {}
+
+  @Implementation
+  protected void freeStorage(long freeStorageSize, IntentSender pi) {}
+
+  /**
+   * Uninstalls the package from the system in a way, that will allow its discovery through {@link
+   * PackageManager#MATCH_UNINSTALLED_PACKAGES}.
+   */
+  public void deletePackage(String packageName) {
+    synchronized (lock) {
+      deletedPackages.add(packageName);
+      packageInfos.remove(packageName);
+      mapForPackage(activityFilters, packageName).clear();
+      mapForPackage(serviceFilters, packageName).clear();
+      mapForPackage(providerFilters, packageName).clear();
+      mapForPackage(receiverFilters, packageName).clear();
+      moduleInfos.remove(packageName);
+    }
+  }
+
+  @Implementation
+  protected void deletePackage(String packageName, IPackageDeleteObserver observer, int flags) {
+    pendingDeleteCallbacks.put(packageName, observer);
+  }
+
+  /**
+   * Runs the callbacks pending from calls to {@link PackageManager#deletePackage(String,
+   * IPackageDeleteObserver, int)}
+   */
+  public void doPendingUninstallCallbacks() {
+    synchronized (lock) {
+      boolean hasDeletePackagesPermission = false;
+      String[] requestedPermissions =
+          packageInfos.get(RuntimeEnvironment.getApplication().getPackageName())
+              .requestedPermissions;
+      if (requestedPermissions != null) {
+        for (String permission : requestedPermissions) {
+          if (Manifest.permission.DELETE_PACKAGES.equals(permission)) {
+            hasDeletePackagesPermission = true;
+            break;
+          }
+        }
+      }
+
+      for (String packageName : pendingDeleteCallbacks.keySet()) {
+        int resultCode = PackageManager.DELETE_FAILED_INTERNAL_ERROR;
+
+        PackageInfo removed = packageInfos.get(packageName);
+        if (hasDeletePackagesPermission && removed != null) {
+          deletePackage(packageName);
+          resultCode = PackageManager.DELETE_SUCCEEDED;
+        }
+
+        try {
+          pendingDeleteCallbacks.get(packageName).packageDeleted(packageName, resultCode);
+        } catch (RemoteException e) {
+          throw new RuntimeException(e);
+        }
+      }
+      pendingDeleteCallbacks.clear();
+    }
+  }
+
+  /**
+   * Returns package names successfully deleted with {@link PackageManager#deletePackage(String,
+   * IPackageDeleteObserver, int)} Note that like real {@link PackageManager} the calling context
+   * must have {@link android.Manifest.permission#DELETE_PACKAGES} permission set.
+   */
+  public Set<String> getDeletedPackages() {
+    synchronized (lock) {
+      return deletedPackages;
+    }
+  }
+
+  protected List<ResolveInfo> queryOverriddenIntents(Intent intent, int flags) {
+    List<ResolveInfo> overrides = resolveInfoForIntent.get(intent);
+    if (overrides == null) {
+      return Collections.emptyList();
+    }
+    List<ResolveInfo> result = new ArrayList<>(overrides.size());
+    for (ResolveInfo resolveInfo : overrides) {
+      result.add(ShadowResolveInfo.newResolveInfo(resolveInfo));
+    }
+    return result;
+  }
+
+  /**
+   * Internal use only.
+   *
+   * @param appPackage
+   */
+  public void addPackageInternal(Package appPackage) {
+    int flags =
+        GET_ACTIVITIES
+            | GET_RECEIVERS
+            | GET_SERVICES
+            | GET_PROVIDERS
+            | GET_INSTRUMENTATION
+            | GET_INTENT_FILTERS
+            | GET_SIGNATURES
+            | GET_RESOLVED_FILTER
+            | GET_META_DATA
+            | GET_GIDS
+            | MATCH_DISABLED_COMPONENTS
+            | GET_SHARED_LIBRARY_FILES
+            | GET_URI_PERMISSION_PATTERNS
+            | GET_PERMISSIONS
+            | MATCH_UNINSTALLED_PACKAGES
+            | GET_CONFIGURATIONS
+            | MATCH_DISABLED_UNTIL_USED_COMPONENTS
+            | MATCH_DIRECT_BOOT_UNAWARE
+            | MATCH_DIRECT_BOOT_AWARE;
+
+    for (PermissionGroup permissionGroup : appPackage.permissionGroups) {
+      PermissionGroupInfo permissionGroupInfo =
+          PackageParser.generatePermissionGroupInfo(permissionGroup, flags);
+      addPermissionGroupInfo(permissionGroupInfo);
+    }
+    PackageInfo packageInfo = generatePackageInfo(appPackage, flags);
+
+    packageInfo.applicationInfo.uid = Process.myUid();
+    packageInfo.applicationInfo.dataDir = createTempDir(packageInfo.packageName + "-dataDir");
+    installPackage(packageInfo);
+    addFilters(activityFilters, appPackage.activities);
+    addFilters(serviceFilters, appPackage.services);
+    addFilters(providerFilters, appPackage.providers);
+    addFilters(receiverFilters, appPackage.receivers);
+  }
+
+  protected PackageInfo generatePackageInfo(Package appPackage, int flags) {
+
+    if (RuntimeEnvironment.getApiLevel() >= TIRAMISU) {
+      return PackageParser.generatePackageInfo(
+          appPackage,
+          new int[] {0},
+          flags,
+          0,
+          0,
+          Collections.emptySet(),
+          FrameworkPackageUserState.DEFAULT,
+          0);
+    } else {
+      return reflector(_PackageParser_.class)
+          .generatePackageInfo(appPackage, new int[] {0}, flags, 0, 0);
+    }
+  }
+
+  private void addFilters(
+      Map<ComponentName, List<IntentFilter>> componentMap,
+      List<? extends PackageParser.Component<?>> components) {
+    if (components == null) {
+      return;
+    }
+    for (Component<?> component : components) {
+      ComponentName componentName = component.getComponentName();
+      List<IntentFilter> registeredFilters = componentMap.get(componentName);
+      if (registeredFilters == null) {
+        registeredFilters = new ArrayList<>();
+        componentMap.put(componentName, registeredFilters);
+      }
+      for (IntentInfo intentInfo : component.intents) {
+        registeredFilters.add(new IntentFilter(intentInfo));
+      }
+    }
+  }
+
+  public static class IntentComparator implements Comparator<Intent> {
+
+    @Override
+    public int compare(Intent i1, Intent i2) {
+      if (i1 == null && i2 == null) return 0;
+      if (i1 == null && i2 != null) return -1;
+      if (i1 != null && i2 == null) return 1;
+      if (i1.equals(i2)) return 0;
+      String action1 = i1.getAction();
+      String action2 = i2.getAction();
+      if (action1 == null && action2 != null) return -1;
+      if (action1 != null && action2 == null) return 1;
+      if (action1 != null && action2 != null) {
+        if (!action1.equals(action2)) {
+          return action1.compareTo(action2);
+        }
+      }
+      Uri data1 = i1.getData();
+      Uri data2 = i2.getData();
+      if (data1 == null && data2 != null) return -1;
+      if (data1 != null && data2 == null) return 1;
+      if (data1 != null && data2 != null) {
+        if (!data1.equals(data2)) {
+          return data1.compareTo(data2);
+        }
+      }
+      ComponentName component1 = i1.getComponent();
+      ComponentName component2 = i2.getComponent();
+      if (component1 == null && component2 != null) return -1;
+      if (component1 != null && component2 == null) return 1;
+      if (component1 != null && component2 != null) {
+        if (!component1.equals(component2)) {
+          return component1.compareTo(component2);
+        }
+      }
+      String package1 = i1.getPackage();
+      String package2 = i2.getPackage();
+      if (package1 == null && package2 != null) return -1;
+      if (package1 != null && package2 == null) return 1;
+      if (package1 != null && package2 != null) {
+        if (!package1.equals(package2)) {
+          return package1.compareTo(package2);
+        }
+      }
+      Set<String> categories1 = i1.getCategories();
+      Set<String> categories2 = i2.getCategories();
+      if (categories1 == null) return categories2 == null ? 0 : -1;
+      if (categories2 == null) return 1;
+      if (categories1.size() > categories2.size()) return 1;
+      if (categories1.size() < categories2.size()) return -1;
+      String[] array1 = categories1.toArray(new String[0]);
+      String[] array2 = categories2.toArray(new String[0]);
+      Arrays.sort(array1);
+      Arrays.sort(array2);
+      for (int i = 0; i < array1.length; ++i) {
+        int val = array1[i].compareTo(array2[i]);
+        if (val != 0) return val;
+      }
+      return 0;
+    }
+  }
+
+  /**
+   * Compares {@link ResolveInfo}s, ordering better matches before worse ones. This is the order in
+   * which resolve infos should be returned to the user.
+   */
+  static class ResolveInfoComparator implements Comparator<ResolveInfo> {
+
+    @Override
+    public int compare(ResolveInfo o1, ResolveInfo o2) {
+      if (o1 == null && o2 == null) {
+        return 0;
+      }
+      if (o1 == null) {
+        return -1;
+      }
+      if (o2 == null) {
+        return 1;
+      }
+      if (o1.preferredOrder != o2.preferredOrder) {
+        // higher priority is before lower
+        return -Integer.compare(o1.preferredOrder, o2.preferredOrder);
+      }
+      if (o1.priority != o2.priority) {
+        // higher priority is before lower
+        return -Integer.compare(o1.priority, o2.priority);
+      }
+      if (o1.match != o2.match) {
+        // higher match is before lower
+        return -Integer.compare(o1.match, o2.match);
+      }
+      return 0;
+    }
+  }
+
+  protected static class ComponentState {
+    public int newState;
+    public int flags;
+
+    public ComponentState(int newState, int flags) {
+      this.newState = newState;
+      this.flags = flags;
+    }
+  }
+
+  /**
+   * Get list of intent filters defined for given activity.
+   *
+   * @param componentName Name of the activity whose intent filters are to be retrieved
+   * @return the activity's intent filters
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public List<IntentFilter> getIntentFiltersForActivity(ComponentName componentName) {
+    return getIntentFiltersForComponent(componentName, activityFilters);
+  }
+
+  /**
+   * Get list of intent filters defined for given service.
+   *
+   * @param componentName Name of the service whose intent filters are to be retrieved
+   * @return the service's intent filters
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public List<IntentFilter> getIntentFiltersForService(ComponentName componentName) {
+    return getIntentFiltersForComponent(componentName, serviceFilters);
+  }
+
+  /**
+   * Get list of intent filters defined for given receiver.
+   *
+   * @param componentName Name of the receiver whose intent filters are to be retrieved
+   * @return the receiver's intent filters
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public List<IntentFilter> getIntentFiltersForReceiver(ComponentName componentName) {
+      return getIntentFiltersForComponent(componentName, receiverFilters);
+  }
+
+  /**
+   * Get list of intent filters defined for given provider.
+   *
+   * @param componentName Name of the provider whose intent filters are to be retrieved
+   * @return the provider's intent filters
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public List<IntentFilter> getIntentFiltersForProvider(ComponentName componentName) {
+    return getIntentFiltersForComponent(componentName, providerFilters);
+  }
+
+  /**
+   * Add intent filter for given activity.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void addIntentFilterForActivity(ComponentName componentName, IntentFilter filter) {
+    addIntentFilterForComponent(componentName, filter, activityFilters);
+  }
+
+  /**
+   * Add intent filter for given service.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void addIntentFilterForService(ComponentName componentName, IntentFilter filter) {
+    addIntentFilterForComponent(componentName, filter, serviceFilters);
+  }
+
+  /**
+   * Add intent filter for given receiver.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void addIntentFilterForReceiver(ComponentName componentName, IntentFilter filter) {
+    addIntentFilterForComponent(componentName, filter, receiverFilters);
+  }
+
+  /**
+   * Add intent filter for given provider.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void addIntentFilterForProvider(ComponentName componentName, IntentFilter filter) {
+    addIntentFilterForComponent(componentName, filter, providerFilters);
+  }
+
+  /**
+   * Clears intent filters for given activity.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void clearIntentFilterForActivity(ComponentName componentName) {
+    clearIntentFilterForComponent(componentName, activityFilters);
+  }
+
+  /**
+   * Clears intent filters for given service.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void clearIntentFilterForService(ComponentName componentName) {
+    clearIntentFilterForComponent(componentName, serviceFilters);
+  }
+
+  /**
+   * Clears intent filters for given receiver.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void clearIntentFilterForReceiver(ComponentName componentName) {
+    clearIntentFilterForComponent(componentName, receiverFilters);
+  }
+
+  /**
+   * Clears intent filters for given provider.
+   *
+   * @throws IllegalArgumentException if component with given name doesn't exist.
+   */
+  public void clearIntentFilterForProvider(ComponentName componentName) {
+    clearIntentFilterForComponent(componentName, providerFilters);
+  }
+
+  private void addIntentFilterForComponent(
+      ComponentName componentName,
+      IntentFilter filter,
+      Map<ComponentName, List<IntentFilter>> filterMap) {
+    // Existing components should have an entry in respective filterMap.
+    // It is OK to search over all filter maps, as it is impossible to have the same component name
+    // being of two comopnent types (like activity and service at the same time).
+    List<IntentFilter> filters = filterMap.get(componentName);
+    if (filters != null) {
+      filters.add(filter);
+      return;
+    }
+    throw new IllegalArgumentException(componentName + " doesn't exist");
+  }
+
+  private void clearIntentFilterForComponent(
+      ComponentName componentName, Map<ComponentName, List<IntentFilter>> filterMap) {
+    List<IntentFilter> filters = filterMap.get(componentName);
+    if (filters != null) {
+      filters.clear();
+      return;
+    }
+    throw new IllegalArgumentException(componentName + " doesn't exist");
+  }
+
+  private List<IntentFilter> getIntentFiltersForComponent(
+      ComponentName componentName, Map<ComponentName, List<IntentFilter>> filterMap) {
+    List<IntentFilter> filters = filterMap.get(componentName);
+    if (filters != null) {
+      return new ArrayList<>(filters);
+    }
+    throw new IllegalArgumentException(componentName + " doesn't exist");
+  }
+
+  /**
+   * Method to retrieve persistent preferred activities as set by {@link
+   * android.app.admin.DevicePolicyManager#addPersistentPreferredActivity}.
+   *
+   * <p>Works the same way as analogous {@link PackageManager#getPreferredActivities} for regular
+   * preferred activities.
+   */
+  public int getPersistentPreferredActivities(
+      List<IntentFilter> outFilters, List<ComponentName> outActivities, String packageName) {
+    return getPreferredActivitiesInternal(
+        outFilters, outActivities, packageName, persistentPreferredActivities);
+  }
+
+  protected static int getPreferredActivitiesInternal(
+      List<IntentFilter> outFilters,
+      List<ComponentName> outActivities,
+      String packageName,
+      SortedMap<ComponentName, List<IntentFilter>> preferredActivitiesMap) {
+    SortedMap<ComponentName, List<IntentFilter>> preferredMap = preferredActivitiesMap;
+    if (packageName != null) {
+      preferredMap = mapForPackage(preferredActivitiesMap, packageName);
+    }
+    int result = 0;
+    for (Entry<ComponentName, List<IntentFilter>> entry : preferredMap.entrySet()) {
+      int filterCount = entry.getValue().size();
+      result += filterCount;
+      ComponentName[] componentNames = new ComponentName[filterCount];
+      Arrays.fill(componentNames, entry.getKey());
+      outActivities.addAll(asList(componentNames));
+      outFilters.addAll(entry.getValue());
+    }
+
+    return result;
+  }
+
+  void clearPackagePersistentPreferredActivities(String packageName) {
+    clearPackagePreferredActivitiesInternal(packageName, persistentPreferredActivities);
+  }
+
+  protected static void clearPackagePreferredActivitiesInternal(
+      String packageName, SortedMap<ComponentName, List<IntentFilter>> preferredActivitiesMap) {
+    mapForPackage(preferredActivitiesMap, packageName).clear();
+  }
+
+  void addPersistentPreferredActivity(IntentFilter filter, ComponentName activity) {
+    addPreferredActivityInternal(filter, activity, persistentPreferredActivities);
+  }
+
+  protected static void addPreferredActivityInternal(
+      IntentFilter filter,
+      ComponentName activity,
+      SortedMap<ComponentName, List<IntentFilter>> preferredActivitiesMap) {
+    List<IntentFilter> filters = preferredActivitiesMap.get(activity);
+    if (filters == null) {
+      filters = new ArrayList<>();
+      preferredActivitiesMap.put(activity, filters);
+    }
+    filters.add(filter);
+  }
+
+  protected static <V> SortedMap<ComponentName, V> mapForPackage(
+      SortedMap<ComponentName, V> input, @Nullable String packageName) {
+    if (packageName == null) {
+      return input;
+    }
+    if (packageName == null) {
+      return input;
+    }
+    return input.subMap(
+        new ComponentName(packageName, ""), new ComponentName(packageName + " ", ""));
+  }
+
+  static boolean isComponentEnabled(@Nullable ComponentInfo componentInfo) {
+    if (componentInfo == null) {
+      return true;
+    }
+    if (componentInfo.applicationInfo == null
+        || componentInfo.applicationInfo.packageName == null
+        || componentInfo.name == null) {
+      return componentInfo.enabled;
+    }
+    ComponentName name =
+        new ComponentName(componentInfo.applicationInfo.packageName, componentInfo.name);
+    ComponentState componentState = componentList.get(name);
+    if (componentState == null
+        || componentState.newState == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
+      return componentInfo.enabled;
+    }
+    return componentState.newState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
+  }
+
+  /**
+   * Returns the current {@link PackageSetting} of {@code packageName}.
+   *
+   * If {@code packageName} is not present in this {@link ShadowPackageManager}, this method will
+   * return null.
+   */
+  public PackageSetting getPackageSetting(String packageName) {
+    PackageSetting setting = packageSettings.get(packageName);
+    return setting == null ? null : new PackageSetting(setting);
+  }
+
+  /**
+   * If this method has been called with true, then in cases where many activities match a filter,
+   * an activity chooser will be resolved instead of just the first pick.
+   */
+  public void setShouldShowActivityChooser(boolean shouldShowActivityChooser) {
+    this.shouldShowActivityChooser = shouldShowActivityChooser;
+  }
+
+  /** Set value to be returned by {@link PackageManager#isSafeMode}. */
+  public void setSafeMode(boolean safeMode) {
+    ShadowPackageManager.safeMode = safeMode;
+  }
+
+  /**
+   * Returns the last value provided to {@code setDistractingPackageRestrictions} for {@code pkg}.
+   *
+   * Defaults to {@code PackageManager.RESTRICTION_NONE} if {@code
+   * setDistractingPackageRestrictions} has not been called for {@code pkg}.
+   */
+  public int getDistractingPackageRestrictions(String pkg) {
+    return distractingPackageRestrictions.getOrDefault(pkg, PackageManager.RESTRICTION_NONE);
+  }
+
+  /**
+   * Adds a String resource with {@code resId} corresponding to {@code packageName}. This is
+   * retrieved in shadow implementation of {@link PackageManager#getText(String, int,
+   * ApplicationInfo)}.
+   */
+  public void addStringResource(String packageName, int resId, String text) {
+    if (!stringResources.containsKey(packageName)) {
+      stringResources.put(packageName, new HashMap<>());
+    }
+
+    stringResources.get(packageName).put(resId, text);
+  }
+
+  /** Set value to be returned by {@link PackageManager#isAutoRevokeWhitelisted}. */
+  public void setAutoRevokeWhitelisted(boolean whitelisted) {
+    ShadowPackageManager.whitelisted = whitelisted;
+  }
+
+  @Resetter
+  public static void reset() {
+    synchronized (lock) {
+      permissionRationaleMap.clear();
+      systemAvailableFeatures.clear();
+      systemSharedLibraryNames.clear();
+      packageInfos.clear();
+      packageArchiveInfo.clear();
+      packageStatsMap.clear();
+      packageInstallerMap.clear();
+      packageInstallSourceInfoMap.clear();
+      packagesForUid.clear();
+      uidForPackage.clear();
+      namesForUid.clear();
+      verificationResults.clear();
+      verificationTimeoutExtension.clear();
+      currentToCanonicalNames.clear();
+      canonicalToCurrentNames.clear();
+      componentList.clear();
+      drawableList.clear();
+      applicationIcons.clear();
+      unbadgedApplicationIcons.clear();
+      systemFeatureList.clear();
+      systemFeatureList.putAll(SystemFeatureListInitializer.getSystemFeatures());
+      preferredActivities.clear();
+      persistentPreferredActivities.clear();
+      drawables.clear();
+      stringResources.clear();
+      applicationEnabledSettingMap.clear();
+      extraPermissions.clear();
+      permissionGroups.clear();
+      permissionFlags.clear();
+      resources.clear();
+      resolveInfoForIntent.clear();
+      deletedPackages.clear();
+      pendingDeleteCallbacks.clear();
+      hiddenPackages.clear();
+      sequenceNumberChangedPackagesMap.clear();
+      activityFilters.clear();
+      serviceFilters.clear();
+      providerFilters.clear();
+      receiverFilters.clear();
+      packageSettings.clear();
+      safeMode = false;
+      whitelisted = false;
+    }
+  }
+
+  @ForType(PackageManager.class)
+  interface PackageManagerReflector {
+
+    @Direct
+    PackageInfo getPackageArchiveInfo(String archiveFilePath, int flags);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageParser.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageParser.java
new file mode 100644
index 0000000..a37fbf9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPackageParser.java
@@ -0,0 +1,206 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageParser;
+import android.content.pm.PackageParser.Callback;
+import android.content.pm.PackageParser.Package;
+import android.os.Build;
+import android.util.ArraySet;
+import android.util.DisplayMetrics;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.res.Fs;
+import org.robolectric.shadows.ShadowLog.LogItem;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+import org.robolectric.util.reflector.WithType;
+
+@Implements(value = PackageParser.class, isInAndroidSdk = false)
+@SuppressWarnings("NewApi")
+public class ShadowPackageParser {
+
+  /** Parses an AndroidManifest.xml file using the framework PackageParser. */
+  public static Package callParsePackage(Path apkFile) {
+    PackageParser packageParser = new PackageParser();
+
+    try {
+      Package thePackage;
+      if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.LOLLIPOP) {
+        // TODO(christianw/brettchabot): workaround for NPE from probable bug in Q.
+        // Can be removed when upstream properly handles a null callback
+        // PackageParser#setMinAspectRatio(Package)
+        if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+          QHelper.setCallback(packageParser);
+        }
+        thePackage = packageParser.parsePackage(apkFile.toFile(), 0);
+      } else { // JB -> KK
+        thePackage =
+            reflector(_PackageParser_.class, packageParser)
+                .parsePackage(apkFile.toFile(), Fs.externalize(apkFile), new DisplayMetrics(), 0);
+      }
+
+      if (thePackage == null) {
+        List<LogItem> logItems = ShadowLog.getLogsForTag("PackageParser");
+        if (logItems.isEmpty()) {
+          throw new RuntimeException(
+              "Failed to parse package " + apkFile);
+        } else {
+          LogItem logItem = logItems.get(0);
+          throw new RuntimeException(
+              "Failed to parse package " + apkFile + ": " + logItem.msg, logItem.throwable);
+        }
+      }
+
+      return thePackage;
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Prevents ClassNotFoundError for Callback on pre-26.
+   */
+  private static class QHelper {
+    private static void setCallback(PackageParser packageParser) {
+      // TODO(christianw): this should be a CallbackImpl with the ApplicationPackageManager...
+
+      packageParser.setCallback(
+          new Callback() {
+            @Override
+            public boolean hasFeature(String s) {
+              return false;
+            }
+
+            // @Override for SDK < 30
+            public String[] getOverlayPaths(String s, String s1) {
+              return null;
+            }
+
+            // @Override for SDK < 30
+            public String[] getOverlayApks(String s) {
+              return null;
+            }
+          });
+    }
+  }
+
+  /** Accessor interface for {@link PackageParser}'s internals. */
+  @ForType(PackageParser.class)
+  interface _PackageParser_ {
+
+    // <= JELLY_BEAN
+    @Static
+    PackageInfo generatePackageInfo(
+        PackageParser.Package p,
+        int[] gids,
+        int flags,
+        long firstInstallTime,
+        long lastUpdateTime,
+        HashSet<String> grantedPermissions);
+
+    // <= LOLLIPOP
+    @Static
+    PackageInfo generatePackageInfo(
+        PackageParser.Package p,
+        int[] gids,
+        int flags,
+        long firstInstallTime,
+        long lastUpdateTime,
+        HashSet<String> grantedPermissions,
+        @WithType("android.content.pm.PackageUserState")
+            Object state);
+
+    // LOLLIPOP_MR1
+    @Static
+    PackageInfo generatePackageInfo(
+        PackageParser.Package p,
+        int[] gids,
+        int flags,
+        long firstInstallTime,
+        long lastUpdateTime,
+        ArraySet<String> grantedPermissions,
+        @WithType("android.content.pm.PackageUserState")
+            Object state);
+
+    @Static
+    PackageInfo generatePackageInfo(
+        PackageParser.Package p,
+        int[] gids,
+        int flags,
+        long firstInstallTime,
+        long lastUpdateTime,
+        Set<String> grantedPermissions,
+        @WithType("android.content.pm.PackageUserState") Object state);
+
+    default PackageInfo generatePackageInfo(
+        PackageParser.Package p,
+        int[] gids,
+        int flags,
+        long firstInstallTime,
+        long lastUpdateTime) {
+      int apiLevel = RuntimeEnvironment.getApiLevel();
+
+      if (apiLevel <= JELLY_BEAN) {
+        return generatePackageInfo(p, gids, flags, firstInstallTime, lastUpdateTime,
+            new HashSet<>());
+      } else if (apiLevel <= LOLLIPOP) {
+        return generatePackageInfo(
+            p,
+            gids,
+            flags,
+            firstInstallTime,
+            lastUpdateTime,
+            new HashSet<>(),
+            newPackageUserState());
+      } else if (apiLevel <= LOLLIPOP_MR1) {
+        return generatePackageInfo(
+            p,
+            gids,
+            flags,
+            firstInstallTime,
+            lastUpdateTime,
+            new ArraySet<>(),
+            newPackageUserState());
+      } else {
+        return generatePackageInfo(
+            p,
+            gids,
+            flags,
+            firstInstallTime,
+            lastUpdateTime,
+            (Set<String>) new HashSet<String>(),
+            newPackageUserState());
+      }
+    }
+
+    Package parsePackage(File file, String fileName, DisplayMetrics displayMetrics, int flags);
+  }
+
+  private static Object newPackageUserState() {
+    try {
+      return ReflectionHelpers.newInstance(Class.forName("android.content.pm.PackageUserState"));
+    } catch (ClassNotFoundException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  /** Accessor interface for {@link Package}'s internals. */
+  @ForType(Package.class)
+  public interface _Package_ {
+
+    @Accessor("mPath")
+    String getPath();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPaint.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPaint.java
new file mode 100644
index 0000000..8fec646
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPaint.java
@@ -0,0 +1,652 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.L;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.annotation.TextLayoutMode.Mode.REALISTIC;
+
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Paint.FontMetricsInt;
+import android.graphics.PathEffect;
+import android.graphics.Shader;
+import android.graphics.Typeface;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.TextLayoutMode;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = Paint.class, looseSignatures = true)
+public class ShadowPaint {
+
+  private int color;
+  private Paint.Style style;
+  private Paint.Cap cap;
+  private Paint.Join join;
+  private float width;
+  private float shadowRadius;
+  private float shadowDx;
+  private float shadowDy;
+  private int shadowColor;
+  private Shader shader;
+  private int alpha;
+  private ColorFilter filter;
+  private boolean antiAlias;
+  private boolean dither;
+  private int flags;
+  private PathEffect pathEffect;
+  private float letterSpacing;
+  private float textScaleX = 1f;
+  private float textSkewX;
+  private float wordSpacing;
+
+  @RealObject Paint paint;
+  private Typeface typeface;
+  private float textSize;
+  private Paint.Align textAlign = Paint.Align.LEFT;
+
+  @Implementation
+  protected void __constructor__(Paint otherPaint) {
+    ShadowPaint otherShadowPaint = Shadow.extract(otherPaint);
+    this.color = otherShadowPaint.color;
+    this.style = otherShadowPaint.style;
+    this.cap = otherShadowPaint.cap;
+    this.join = otherShadowPaint.join;
+    this.width = otherShadowPaint.width;
+    this.shadowRadius = otherShadowPaint.shadowRadius;
+    this.shadowDx = otherShadowPaint.shadowDx;
+    this.shadowDy = otherShadowPaint.shadowDy;
+    this.shadowColor = otherShadowPaint.shadowColor;
+    this.shader = otherShadowPaint.shader;
+    this.alpha = otherShadowPaint.alpha;
+    this.filter = otherShadowPaint.filter;
+    this.antiAlias = otherShadowPaint.antiAlias;
+    this.dither = otherShadowPaint.dither;
+    this.flags = otherShadowPaint.flags;
+    this.pathEffect = otherShadowPaint.pathEffect;
+    this.letterSpacing = otherShadowPaint.letterSpacing;
+    this.textScaleX = otherShadowPaint.textScaleX;
+    this.textSkewX = otherShadowPaint.textSkewX;
+    this.wordSpacing = otherShadowPaint.wordSpacing;
+
+    Shadow.invokeConstructor(Paint.class, paint, ClassParameter.from(Paint.class, otherPaint));
+  }
+
+  @Implementation(minSdk = N)
+  protected static long nInit() {
+    return 1;
+  }
+
+  @Implementation
+  protected int getFlags() {
+    return flags;
+  }
+
+  @Implementation
+  protected void setFlags(int flags) {
+    this.flags = flags;
+  }
+
+  @Implementation
+  protected void setUnderlineText(boolean underlineText) {
+    if (underlineText) {
+      setFlags(flags | Paint.UNDERLINE_TEXT_FLAG);
+    } else {
+      setFlags(flags & ~Paint.UNDERLINE_TEXT_FLAG);
+    }
+  }
+
+  @Implementation
+  protected Shader setShader(Shader shader) {
+    this.shader = shader;
+    return shader;
+  }
+
+  @Implementation
+  protected int getAlpha() {
+    return alpha;
+  }
+
+  @Implementation
+  protected void setAlpha(int alpha) {
+    this.alpha = alpha;
+  }
+
+  @Implementation
+  protected Shader getShader() {
+    return shader;
+  }
+
+  @Implementation
+  protected void setColor(int color) {
+    this.color = color;
+  }
+
+  @Implementation
+  protected int getColor() {
+    return color;
+  }
+
+  @Implementation
+  protected void setStyle(Paint.Style style) {
+    this.style = style;
+  }
+
+  @Implementation
+  protected Paint.Style getStyle() {
+    return style;
+  }
+
+  @Implementation
+  protected void setStrokeCap(Paint.Cap cap) {
+    this.cap = cap;
+  }
+
+  @Implementation
+  protected Paint.Cap getStrokeCap() {
+    return cap;
+  }
+
+  @Implementation
+  protected void setStrokeJoin(Paint.Join join) {
+    this.join = join;
+  }
+
+  @Implementation
+  protected Paint.Join getStrokeJoin() {
+    return join;
+  }
+
+  @Implementation
+  protected void setStrokeWidth(float width) {
+    this.width = width;
+  }
+
+  @Implementation
+  protected float getStrokeWidth() {
+    return width;
+  }
+
+  @Implementation
+  protected void setShadowLayer(float radius, float dx, float dy, int color) {
+    shadowRadius = radius;
+    shadowDx = dx;
+    shadowDy = dy;
+    shadowColor = color;
+  }
+
+  @Implementation
+  protected Typeface getTypeface() {
+    return typeface;
+  }
+
+  @Implementation
+  protected Typeface setTypeface(Typeface typeface) {
+    this.typeface = typeface;
+    return typeface;
+  }
+
+  @Implementation
+  protected float getTextSize() {
+    return textSize;
+  }
+
+  @Implementation
+  protected void setTextSize(float textSize) {
+    this.textSize = textSize;
+  }
+
+  @Implementation
+  protected float getTextScaleX() {
+    return textScaleX;
+  }
+
+  @Implementation
+  protected void setTextScaleX(float scaleX) {
+    this.textScaleX = scaleX;
+  }
+
+  @Implementation
+  protected float getTextSkewX() {
+    return textSkewX;
+  }
+
+  @Implementation
+  protected void setTextSkewX(float skewX) {
+    this.textSkewX = skewX;
+  }
+
+  @Implementation(minSdk = L)
+  protected float getLetterSpacing() {
+    return letterSpacing;
+  }
+
+  @Implementation(minSdk = L)
+  protected void setLetterSpacing(float letterSpacing) {
+    this.letterSpacing = letterSpacing;
+  }
+
+  @Implementation(minSdk = Q)
+  protected float getWordSpacing() {
+    return wordSpacing;
+  }
+
+  @Implementation(minSdk = Q)
+  protected void setWordSpacing(float wordSpacing) {
+    this.wordSpacing = wordSpacing;
+  }
+
+  @Implementation
+  protected void setTextAlign(Paint.Align align) {
+    textAlign = align;
+  }
+
+  @Implementation
+  protected Paint.Align getTextAlign() {
+    return textAlign;
+  }
+
+  /**
+   * @return shadow radius (Paint related shadow, not Robolectric Shadow)
+   */
+  public float getShadowRadius() {
+    return shadowRadius;
+  }
+
+  /**
+   * @return shadow Dx (Paint related shadow, not Robolectric Shadow)
+   */
+  public float getShadowDx() {
+    return shadowDx;
+  }
+
+  /**
+   * @return shadow Dx (Paint related shadow, not Robolectric Shadow)
+   */
+  public float getShadowDy() {
+    return shadowDy;
+  }
+
+  /**
+   * @return shadow color (Paint related shadow, not Robolectric Shadow)
+   */
+  public int getShadowColor() {
+    return shadowColor;
+  }
+
+  public Paint.Cap getCap() {
+    return cap;
+  }
+
+  public Paint.Join getJoin() {
+    return join;
+  }
+
+  public float getWidth() {
+    return width;
+  }
+
+  @Implementation
+  protected ColorFilter getColorFilter() {
+    return filter;
+  }
+
+  @Implementation
+  protected ColorFilter setColorFilter(ColorFilter filter) {
+    this.filter = filter;
+    return filter;
+  }
+
+  @Implementation
+  protected void setAntiAlias(boolean antiAlias) {
+    this.flags = (flags & ~Paint.ANTI_ALIAS_FLAG) | (antiAlias ? Paint.ANTI_ALIAS_FLAG : 0);
+  }
+
+  @Implementation
+  protected void setDither(boolean dither) {
+    this.dither = dither;
+  }
+
+  @Implementation
+  protected final boolean isDither() {
+    return dither;
+  }
+
+  @Implementation
+  protected final boolean isAntiAlias() {
+    return (flags & Paint.ANTI_ALIAS_FLAG) == Paint.ANTI_ALIAS_FLAG;
+  }
+
+  @Implementation
+  protected PathEffect getPathEffect() {
+    return pathEffect;
+  }
+
+  @Implementation
+  protected PathEffect setPathEffect(PathEffect effect) {
+    this.pathEffect = effect;
+    return effect;
+  }
+
+  @Implementation
+  protected float measureText(String text) {
+    return applyTextScaleX(text.length());
+  }
+
+  @Implementation
+  protected float measureText(CharSequence text, int start, int end) {
+    return applyTextScaleX(end - start);
+  }
+
+  @Implementation
+  protected float measureText(String text, int start, int end) {
+    return applyTextScaleX(end - start);
+  }
+
+  @Implementation
+  protected float measureText(char[] text, int index, int count) {
+    return applyTextScaleX(count);
+  }
+
+  private float applyTextScaleX(float textWidth) {
+    return Math.max(0f, textScaleX) * textWidth;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected int native_breakText(
+      char[] text, int index, int count, float maxWidth, float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT_WATCH)
+  protected int native_breakText(
+      char[] text, int index, int count, float maxWidth, int bidiFlags, float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = M)
+  protected static int native_breakText(
+      long native_object,
+      long native_typeface,
+      char[] text,
+      int index,
+      int count,
+      float maxWidth,
+      int bidiFlags,
+      float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = N, maxSdk = O_MR1)
+  protected static int nBreakText(
+      long nObject,
+      long nTypeface,
+      char[] text,
+      int index,
+      int count,
+      float maxWidth,
+      int bidiFlags,
+      float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = P)
+  protected static int nBreakText(
+      long nObject,
+      char[] text,
+      int index,
+      int count,
+      float maxWidth,
+      int bidiFlags,
+      float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  private static int breakText(char[] text, float maxWidth, float[] measuredWidth) {
+    if (measuredWidth != null) {
+      measuredWidth[0] = maxWidth;
+    }
+    return text.length;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected int native_breakText(
+      String text, boolean measureForwards, float maxWidth, float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT_WATCH)
+  protected int native_breakText(
+      String text, boolean measureForwards, float maxWidth, int bidiFlags, float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = M)
+  protected static int native_breakText(
+      long native_object,
+      long native_typeface,
+      String text,
+      boolean measureForwards,
+      float maxWidth,
+      int bidiFlags,
+      float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = N, maxSdk = O_MR1)
+  protected static int nBreakText(
+      long nObject,
+      long nTypeface,
+      String text,
+      boolean measureForwards,
+      float maxWidth,
+      int bidiFlags,
+      float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  @Implementation(minSdk = P)
+  protected static int nBreakText(
+      long nObject,
+      String text,
+      boolean measureForwards,
+      float maxWidth,
+      int bidiFlags,
+      float[] measuredWidth) {
+    return breakText(text, maxWidth, measuredWidth);
+  }
+
+  private static int breakText(String text, float maxWidth, float[] measuredWidth) {
+    if (measuredWidth != null) {
+      measuredWidth[0] = maxWidth;
+    }
+    return text.length();
+  }
+
+  @Implementation(minSdk = P)
+  protected static int nGetFontMetricsInt(long paintPtr, FontMetricsInt fmi) {
+    if (ConfigurationRegistry.get(TextLayoutMode.Mode.class) == REALISTIC) {
+      // TODO: hack, just set values to those we see on emulator
+      int descent = 7;
+      int ascent = -28;
+      int leading = 0;
+
+      if (fmi != null) {
+        fmi.top = -32;
+        fmi.ascent = ascent;
+        fmi.descent = descent;
+        fmi.bottom = 9;
+        fmi.leading = leading;
+      }
+      return descent - ascent + leading;
+    }
+    return 0;
+  }
+
+  @Implementation(minSdk = O, maxSdk = O_MR1)
+  protected static int nGetFontMetricsInt(
+      long nativePaint, long nativeTypeface, FontMetricsInt fmi) {
+    return nGetFontMetricsInt(nativePaint, fmi);
+  }
+
+  @Implementation(minSdk = N, maxSdk = N_MR1)
+  protected int nGetFontMetricsInt(Object nativePaint, Object nativeTypeface, Object fmi) {
+    return nGetFontMetricsInt((long) nativePaint, (FontMetricsInt) fmi);
+  }
+
+  @Implementation(maxSdk = M)
+  protected int getFontMetricsInt(FontMetricsInt fmi) {
+    return nGetFontMetricsInt(0, fmi);
+  }
+
+  @Implementation(minSdk = P)
+  protected static float nGetRunAdvance(
+      long paintPtr,
+      char[] text,
+      int start,
+      int end,
+      int contextStart,
+      int contextEnd,
+      boolean isRtl,
+      int offset) {
+    if (ConfigurationRegistry.get(TextLayoutMode.Mode.class) == REALISTIC) {
+      // be consistent with measureText for measurements, and measure 1 pixel per char
+      return end - start;
+    }
+    return 0f;
+  }
+
+  @Implementation(minSdk = N, maxSdk = O_MR1)
+  protected static float nGetRunAdvance(
+      long paintPtr,
+      long typefacePtr,
+      char[] text,
+      int start,
+      int end,
+      int contextStart,
+      int contextEnd,
+      boolean isRtl,
+      int offset) {
+    return nGetRunAdvance(paintPtr, text, start, end, contextStart, contextEnd, isRtl, offset);
+  }
+
+  @Implementation(minSdk = M, maxSdk = M)
+  protected static float native_getRunAdvance(
+      long nativeObject,
+      long nativeTypeface,
+      char[] text,
+      int start,
+      int end,
+      int contextStart,
+      int contextEnd,
+      boolean isRtl,
+      int offset) {
+    return nGetRunAdvance(0, text, start, end, contextStart, contextEnd, isRtl, offset);
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH, maxSdk = LOLLIPOP_MR1)
+  protected static float native_getTextRunAdvances(
+      long nativeObject,
+      long nativeTypeface,
+      char[] text,
+      int index,
+      int count,
+      int contextIndex,
+      int contextCount,
+      boolean isRtl,
+      float[] advances,
+      int advancesIndex) {
+    return nGetRunAdvance(
+        0, text, index, index + count, contextIndex, contextIndex + contextCount, isRtl, index);
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH, maxSdk = LOLLIPOP_MR1)
+  protected static float native_getTextRunAdvances(
+      long nativeObject,
+      long nativeTypeface,
+      String text,
+      int start,
+      int end,
+      int contextStart,
+      int contextEnd,
+      boolean isRtl,
+      float[] advances,
+      int advancesIndex) {
+    return nGetRunAdvance(0, text.toCharArray(), start, end, contextStart, contextEnd, isRtl, 0);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT)
+  protected static float native_getTextRunAdvances(
+      int nativeObject,
+      char[] text,
+      int index,
+      int count,
+      int contextIndex,
+      int contextCount,
+      int flags,
+      float[] advances,
+      int advancesIndex) {
+    return nGetRunAdvance(
+        0, text, index, index + count, contextIndex, contextIndex + contextCount, false, index);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT)
+  protected static float native_getTextRunAdvances(
+      int nativeObject,
+      String text,
+      int start,
+      int end,
+      int contextStart,
+      int contextEnd,
+      int flags,
+      float[] advances,
+      int advancesIndex) {
+    return nGetRunAdvance(0, text.toCharArray(), start, end, contextStart, contextEnd, false, 0);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected static float native_getTextRunAdvances(
+      int nativeObject,
+      char[] text,
+      int index,
+      int count,
+      int contextIndex,
+      int contextCount,
+      int flags,
+      float[] advances,
+      int advancesIndex,
+      int reserved) {
+    return nGetRunAdvance(
+        0, text, index, index + count, contextIndex, contextIndex + contextCount, false, index);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected static float native_getTextRunAdvances(
+      int nativeObject,
+      String text,
+      int start,
+      int end,
+      int contextStart,
+      int contextEnd,
+      int flags,
+      float[] advances,
+      int advancesIndex,
+      int reserved) {
+    return nGetRunAdvance(0, text.toCharArray(), start, end, contextStart, contextEnd, false, 0);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowParcel.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowParcel.java
new file mode 100644
index 0000000..10fc408
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowParcel.java
@@ -0,0 +1,1386 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.RuntimeEnvironment.castNativePtr;
+
+import android.os.BadParcelableException;
+import android.os.IBinder;
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+import android.os.Parcelable.Creator;
+import android.util.Log;
+import android.util.Pair;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.RandomAccessFile;
+import java.io.Serializable;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.android.NativeObjRegistry;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Robolectric's {@link Parcel} pretends to be backed by a byte buffer, closely matching {@link
+ * Parcel}'s position, size, and capacity behavior. However, its internal pure-Java representation
+ * is strongly typed, to detect non-portable code and common testing mistakes. It may throw {@link
+ * IllegalArgumentException} or {@link IllegalStateException} for error-prone behavior normal {@link
+ * Parcel} tolerates.
+ */
+@Implements(value = Parcel.class, looseSignatures = true)
+public class ShadowParcel {
+  protected static final String TAG = "Parcel";
+
+  @RealObject private Parcel realObject;
+
+  private static final NativeObjRegistry<ByteBuffer> NATIVE_BYTE_BUFFER_REGISTRY =
+      new NativeObjRegistry<>(ByteBuffer.class);
+
+  private static final HashMap<ClassLoader, HashMap<String, Pair<Creator<?>, Class<?>>>>
+      pairedCreators = new HashMap<>();
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  @SuppressWarnings("TypeParameterUnusedInFormals")
+  protected <T extends Parcelable> T readParcelable(ClassLoader loader) {
+    // prior to JB MR2, readParcelableCreator() is inlined here.
+    Parcelable.Creator<?> creator = readParcelableCreator(loader);
+    if (creator == null) {
+      return null;
+    }
+
+    if (creator instanceof Parcelable.ClassLoaderCreator<?>) {
+      Parcelable.ClassLoaderCreator<?> classLoaderCreator =
+          (Parcelable.ClassLoaderCreator<?>) creator;
+      return (T) classLoaderCreator.createFromParcel(realObject, loader);
+    }
+    return (T) creator.createFromParcel(realObject);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  public Parcelable.Creator<?> readParcelableCreator(ClassLoader loader) {
+    // note: calling `readString` will also consume the string, and increment the data-pointer.
+    // which is exactly what we need, since we do not call the real `readParcelableCreator`.
+    final String name = realObject.readString();
+    if (name == null) {
+      return null;
+    }
+
+    Parcelable.Creator<?> creator;
+    try {
+      // If loader == null, explicitly emulate Class.forName(String) "caller
+      // classloader" behavior.
+      ClassLoader parcelableClassLoader = (loader == null ? getClass().getClassLoader() : loader);
+      // Avoid initializing the Parcelable class until we know it implements
+      // Parcelable and has the necessary CREATOR field.
+      Class<?> parcelableClass = Class.forName(name, false /* initialize */, parcelableClassLoader);
+      if (!Parcelable.class.isAssignableFrom(parcelableClass)) {
+        throw new BadParcelableException(
+            "Parcelable protocol requires that the " + "class implements Parcelable");
+      }
+      Field f = parcelableClass.getField("CREATOR");
+
+      // this is a fix for JDK8<->Android VM incompatibility:
+      // Apparently, JDK will not allow access to a public field if its
+      // class is not visible (private or package-private) from the call-site.
+      f.setAccessible(true);
+
+      if ((f.getModifiers() & Modifier.STATIC) == 0) {
+        throw new BadParcelableException(
+            "Parcelable protocol requires " + "the CREATOR object to be static on class " + name);
+      }
+      Class<?> creatorType = f.getType();
+      if (!Parcelable.Creator.class.isAssignableFrom(creatorType)) {
+        // Fail before calling Field.get(), not after, to avoid initializing
+        // parcelableClass unnecessarily.
+        throw new BadParcelableException(
+            "Parcelable protocol requires a "
+                + "Parcelable.Creator object called "
+                + "CREATOR on class "
+                + name);
+      }
+      creator = (Parcelable.Creator<?>) f.get(null);
+    } catch (IllegalAccessException e) {
+      Log.e(TAG, "Illegal access when unmarshalling: " + name, e);
+      throw new BadParcelableException("IllegalAccessException when unmarshalling: " + name);
+    } catch (ClassNotFoundException e) {
+      Log.e(TAG, "Class not found when unmarshalling: " + name, e);
+      throw new BadParcelableException("ClassNotFoundException when unmarshalling: " + name);
+    } catch (NoSuchFieldException e) {
+      throw new BadParcelableException(
+          "Parcelable protocol requires a "
+              + "Parcelable.Creator object called "
+              + "CREATOR on class "
+              + name);
+    }
+    if (creator == null) {
+      throw new BadParcelableException(
+          "Parcelable protocol requires a "
+              + "non-null Parcelable.Creator object called "
+              + "CREATOR on class "
+              + name);
+    }
+    return creator;
+  }
+
+  /**
+   * The goal of this shadow method is to workaround a JVM/ART incompatibility.
+   *
+   * <p>In ART, a public field is visible regardless whether or not the enclosing class is public.
+   * On the JVM, this is not the case. For compatibility, we need to use {@link
+   * Field#setAccessible(boolean)} to simulate the same behavior.
+   */
+  @SuppressWarnings("unchecked")
+  @Implementation(minSdk = TIRAMISU)
+  protected <T> Parcelable.Creator<T> readParcelableCreatorInternal(
+      ClassLoader loader, Class<T> clazz) {
+    String name = realObject.readString();
+    if (name == null) {
+      return null;
+    }
+
+    Pair<Creator<?>, Class<?>> creatorAndParcelableClass;
+    synchronized (pairedCreators) {
+      HashMap<String, Pair<Creator<?>, Class<?>>> map = pairedCreators.get(loader);
+      if (map == null) {
+        pairedCreators.put(loader, new HashMap<>());
+        creatorAndParcelableClass = null;
+      } else {
+        creatorAndParcelableClass = map.get(name);
+      }
+    }
+
+    if (creatorAndParcelableClass != null) {
+      Parcelable.Creator<?> creator = creatorAndParcelableClass.first;
+      Class<?> parcelableClass = creatorAndParcelableClass.second;
+      if (clazz != null) {
+        if (!clazz.isAssignableFrom(parcelableClass)) {
+          throw newBadTypeParcelableException(
+              "Parcelable creator "
+                  + name
+                  + " is not "
+                  + "a subclass of required class "
+                  + clazz.getName()
+                  + " provided in the parameter");
+        }
+      }
+
+      return (Parcelable.Creator<T>) creator;
+    }
+
+    Parcelable.Creator<?> creator;
+    Class<?> parcelableClass;
+    try {
+      // If loader == null, explicitly emulate Class.forName(String) "caller
+      // classloader" behavior.
+      ClassLoader parcelableClassLoader = (loader == null ? getClass().getClassLoader() : loader);
+      // Avoid initializing the Parcelable class until we know it implements
+      // Parcelable and has the necessary CREATOR field.
+      parcelableClass = Class.forName(name, /* initialize= */ false, parcelableClassLoader);
+      if (!Parcelable.class.isAssignableFrom(parcelableClass)) {
+        throw new BadParcelableException(
+            "Parcelable protocol requires subclassing " + "from Parcelable on class " + name);
+      }
+      if (clazz != null) {
+        if (!clazz.isAssignableFrom(parcelableClass)) {
+          throw newBadTypeParcelableException(
+              "Parcelable creator "
+                  + name
+                  + " is not "
+                  + "a subclass of required class "
+                  + clazz.getName()
+                  + " provided in the parameter");
+        }
+      }
+
+      Field f = parcelableClass.getField("CREATOR");
+
+      // this is a fix for JDK8<->Android VM incompatibility:
+      // Apparently, JDK will not allow access to a public field if its
+      // class is not visible (private or package-private) from the call-site.
+      f.setAccessible(true);
+
+      if ((f.getModifiers() & Modifier.STATIC) == 0) {
+        throw new BadParcelableException(
+            "Parcelable protocol requires " + "the CREATOR object to be static on class " + name);
+      }
+      Class<?> creatorType = f.getType();
+      if (!Parcelable.Creator.class.isAssignableFrom(creatorType)) {
+        // Fail before calling Field.get(), not after, to avoid initializing
+        // parcelableClass unnecessarily.
+        throw new BadParcelableException(
+            "Parcelable protocol requires a "
+                + "Parcelable.Creator object called "
+                + "CREATOR on class "
+                + name);
+      }
+      creator = (Parcelable.Creator<?>) f.get(null);
+    } catch (IllegalAccessException e) {
+      Log.e(TAG, "Illegal access when unmarshalling: " + name, e);
+      throw new BadParcelableException("IllegalAccessException when unmarshalling: " + name, e);
+    } catch (ClassNotFoundException e) {
+      Log.e(TAG, "Class not found when unmarshalling: " + name, e);
+      throw new BadParcelableException("ClassNotFoundException when unmarshalling: " + name, e);
+    } catch (NoSuchFieldException e) {
+      throw new BadParcelableException(
+          "Parcelable protocol requires a "
+              + "Parcelable.Creator object called "
+              + "CREATOR on class "
+              + name,
+          e);
+    }
+    if (creator == null) {
+      throw new BadParcelableException(
+          "Parcelable protocol requires a "
+              + "non-null Parcelable.Creator object called "
+              + "CREATOR on class "
+              + name);
+    }
+
+    synchronized (pairedCreators) {
+      pairedCreators.get(loader).put(name, Pair.create(creator, parcelableClass));
+    }
+
+    return (Parcelable.Creator<T>) creator;
+  }
+
+  private BadParcelableException newBadTypeParcelableException(String message) {
+    try {
+      return (BadParcelableException)
+          ReflectionHelpers.callConstructor(
+              Class.forName("android.os.BadTypeParcelableException"),
+              ClassParameter.from(String.class, message));
+    } catch (ClassNotFoundException e) {
+      throw new LinkageError(e.getMessage(), e);
+    }
+  }
+
+  @Implementation
+  protected void writeByteArray(byte[] b, int offset, int len) {
+    if (b == null) {
+      realObject.writeInt(-1);
+      return;
+    }
+    Number nativePtr = ReflectionHelpers.getField(realObject, "mNativePtr");
+    nativeWriteByteArray(nativePtr.longValue(), b, offset, len);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static int nativeDataSize(int nativePtr) {
+    return nativeDataSize((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeDataSize(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).dataSize();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static int nativeDataAvail(int nativePtr) {
+    return nativeDataAvail((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeDataAvail(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).dataAvailable();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static int nativeDataPosition(int nativePtr) {
+    return nativeDataPosition((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeDataPosition(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).dataPosition();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static int nativeDataCapacity(int nativePtr) {
+    return nativeDataCapacity((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeDataCapacity(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).dataCapacity();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeSetDataSize(int nativePtr, int size) {
+    nativeSetDataSize((long) nativePtr, size);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  protected static void nativeSetDataSize(long nativePtr, int size) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).setDataSize(size);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeSetDataPosition(int nativePtr, int pos) {
+    nativeSetDataPosition((long) nativePtr, pos);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeSetDataPosition(long nativePtr, int pos) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).setDataPosition(pos);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeSetDataCapacity(int nativePtr, int size) {
+    nativeSetDataCapacity((long) nativePtr, size);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeSetDataCapacity(long nativePtr, int size) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).setDataCapacityAtLeast(size);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteByteArray(int nativePtr, byte[] b, int offset, int len) {
+    nativeWriteByteArray((long) nativePtr, b, offset, len);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeWriteByteArray(long nativePtr, byte[] b, int offset, int len) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeByteArray(b, offset, len);
+  }
+
+  // duplicate the writeBlob implementation from latest android, to avoid referencing the
+  // non-existent-in-JDK java.util.Arrays.checkOffsetAndCount method.
+  @Implementation(minSdk = M)
+  protected void writeBlob(byte[] b, int offset, int len) {
+    if (b == null) {
+      realObject.writeInt(-1);
+      return;
+    }
+    throwsIfOutOfBounds(b.length, offset, len);
+    long nativePtr = ReflectionHelpers.getField(realObject, "mNativePtr");
+    nativeWriteBlob(nativePtr, b, offset, len);
+  }
+
+  private static void throwsIfOutOfBounds(int len, int offset, int count) {
+    if (len < 0) {
+      throw new ArrayIndexOutOfBoundsException("Negative length: " + len);
+    }
+
+    if ((offset | count) < 0 || offset > len - count) {
+      throw new ArrayIndexOutOfBoundsException();
+    }
+  }
+
+  // nativeWriteBlob was introduced in lollipop, thus no need for a int nativePtr variant
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeWriteBlob(long nativePtr, byte[] b, int offset, int len) {
+    nativeWriteByteArray(nativePtr, b, offset, len);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteInt(int nativePtr, int val) {
+    nativeWriteInt((long) nativePtr, val);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = R)
+  protected static void nativeWriteInt(long nativePtr, int val) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeInt(val);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteLong(int nativePtr, long val) {
+    nativeWriteLong((long) nativePtr, val);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = R)
+  protected static void nativeWriteLong(long nativePtr, long val) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeLong(val);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteFloat(int nativePtr, float val) {
+    nativeWriteFloat((long) nativePtr, val);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = R)
+  protected static void nativeWriteFloat(long nativePtr, float val) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeFloat(val);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteDouble(int nativePtr, double val) {
+    nativeWriteDouble((long) nativePtr, val);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = R)
+  protected static void nativeWriteDouble(long nativePtr, double val) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeDouble(val);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteString(int nativePtr, String val) {
+    nativeWriteString((long) nativePtr, val);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = Q)
+  protected static void nativeWriteString(long nativePtr, String val) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeString(val);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeWriteStrongBinder(int nativePtr, IBinder val) {
+    nativeWriteStrongBinder((long) nativePtr, val);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeWriteStrongBinder(long nativePtr, IBinder val) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeStrongBinder(val);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static byte[] nativeCreateByteArray(int nativePtr) {
+    return nativeCreateByteArray((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static byte[] nativeCreateByteArray(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).createByteArray();
+  }
+
+  // nativeReadBlob was introduced in lollipop, thus no need for a int nativePtr variant
+  @Implementation(minSdk = LOLLIPOP)
+  protected static byte[] nativeReadBlob(long nativePtr) {
+    return nativeCreateByteArray(nativePtr);
+  }
+
+  @Implementation(minSdk = O_MR1)
+  protected static boolean nativeReadByteArray(long nativePtr, byte[] dest, int destLen) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readByteArray(dest, destLen);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static int nativeReadInt(int nativePtr) {
+    return nativeReadInt((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeReadInt(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readInt();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static long nativeReadLong(int nativePtr) {
+    return nativeReadLong((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static long nativeReadLong(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readLong();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static float nativeReadFloat(int nativePtr) {
+    return nativeReadFloat((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static float nativeReadFloat(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readFloat();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static double nativeReadDouble(int nativePtr) {
+    return nativeReadDouble((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static double nativeReadDouble(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readDouble();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static String nativeReadString(int nativePtr) {
+    return nativeReadString((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = Q)
+  protected static String nativeReadString(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readString();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static IBinder nativeReadStrongBinder(int nativePtr) {
+    return nativeReadStrongBinder((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static IBinder nativeReadStrongBinder(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readStrongBinder();
+  }
+
+  @Implementation
+  @HiddenApi
+  public static Number nativeCreate() {
+    return castNativePtr(NATIVE_BYTE_BUFFER_REGISTRY.register(new ByteBuffer()));
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeFreeBuffer(int nativePtr) {
+    nativeFreeBuffer((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  protected static void nativeFreeBuffer(long nativePtr) {
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).clear();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeDestroy(int nativePtr) {
+    nativeDestroy((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeDestroy(long nativePtr) {
+    NATIVE_BYTE_BUFFER_REGISTRY.unregister(nativePtr);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static byte[] nativeMarshall(int nativePtr) {
+    return nativeMarshall((long) nativePtr);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static byte[] nativeMarshall(long nativePtr) {
+    return NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).toByteArray();
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeUnmarshall(int nativePtr, byte[] data, int offset, int length) {
+    nativeUnmarshall((long) nativePtr, data, offset, length);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  protected static void nativeUnmarshall(long nativePtr, byte[] data, int offset, int length) {
+    NATIVE_BYTE_BUFFER_REGISTRY.update(nativePtr, ByteBuffer.fromByteArray(data, offset, length));
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeAppendFrom(
+      int thisNativePtr, int otherNativePtr, int offset, int length) {
+    nativeAppendFrom((long) thisNativePtr, otherNativePtr, offset, length);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+  protected static void nativeAppendFrom(
+      long thisNativePtr, long otherNativePtr, int offset, int length) {
+    ByteBuffer thisByteBuffer = NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(thisNativePtr);
+    ByteBuffer otherByteBuffer = NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(otherNativePtr);
+    thisByteBuffer.appendFrom(otherByteBuffer, offset, length);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeWriteInterfaceToken(int nativePtr, String interfaceName) {
+    nativeWriteInterfaceToken((long) nativePtr, interfaceName);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeWriteInterfaceToken(long nativePtr, String interfaceName) {
+    // Write StrictMode.ThreadPolicy bits (assume 0 for test).
+    nativeWriteInt(nativePtr, 0);
+    nativeWriteString(nativePtr, interfaceName);
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  public static void nativeEnforceInterface(int nativePtr, String interfaceName) {
+    nativeEnforceInterface((long) nativePtr, interfaceName);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeEnforceInterface(long nativePtr, String interfaceName) {
+    // Consume StrictMode.ThreadPolicy bits (don't bother setting in test).
+    nativeReadInt(nativePtr);
+    String actualInterfaceName = nativeReadString(nativePtr);
+    if (!Objects.equals(interfaceName, actualInterfaceName)) {
+      throw new SecurityException("Binder invocation to an incorrect interface");
+    }
+  }
+
+  /**
+   * Robolectric-specific error thrown when tests exercise error-prone behavior in Parcel.
+   *
+   * <p>Standard Android parcels rarely throw exceptions, but will happily behave in unintended
+   * ways. Parcels are not strongly typed, so will happily re-interpret corrupt contents in ways
+   * that cause hard-to-diagnose failures, or will cause tests to pass when they should not.
+   * ShadowParcel attempts to detect these conditions.
+   *
+   * <p>This exception is package-private because production or test code should never catch or rely
+   * on this, and may be changed to be an Error (rather than Exception) in the future.
+   */
+  static class UnreliableBehaviorError extends AssertionError {
+    UnreliableBehaviorError(String message) {
+      super(message);
+    }
+
+    UnreliableBehaviorError(String message, Throwable cause) {
+      super(message, cause);
+    }
+
+    UnreliableBehaviorError(
+        Class<?> clazz, int position, ByteBuffer.FakeEncodedItem item, String extraMessage) {
+      super(
+          String.format(
+              Locale.US,
+              "Looking for %s at position %d, found %s [%s] taking %d bytes, %s",
+              clazz.getSimpleName(),
+              position,
+              item.value == null ? "null" : item.value.getClass().getSimpleName(),
+              item.value,
+              item.sizeBytes,
+              extraMessage));
+    }
+  }
+
+  /**
+   * ByteBuffer pretends to be the underlying Parcel implementation.
+   *
+   * <p>It faithfully simulates Parcel's handling of position, size, and capacity, but is strongly
+   * typed internally. It was debated whether this should instead faithfully represent Android's
+   * Parcel bug-for-bug as a true byte array, along with all of its error-tolerant behavior and
+   * ability essentially to {@code reinterpret_cast} data. However, the fail-fast behavior here has
+   * found several test bugs and avoids reliance on architecture-specific details like Endian-ness.
+   *
+   * <p>Quirky behavior this explicitly emulates:
+   *
+   * <ul>
+   *   <li>Continuing to read past the end returns zeros/nulls.
+   *   <li>{@link setDataCapacity} never decreases buffer size.
+   *   <li>It is possible to partially or completely overwrite byte ranges in the buffer.
+   *   <li>Zero bytes can be exchanged between primitive data types and empty array/string.
+   * </ul>
+   *
+   * <p>Quirky behavior this forbids:
+   *
+   * <ul>
+   *   <li>Reading past the end after writing without calling setDataPosition(0), since there's no
+   *       legitimate reason to do this, and is a very common test bug.
+   *   <li>Writing one type and reading another; for example, writing a Long and reading two
+   *       Integers, or writing a byte array and reading a String. This, effectively like {@code
+   *       reinterpret_cast}, may not be portable across architectures.
+   *   <li>Similarly, reading from objects that have been truncated or partially overwritten, or
+   *       reading from the middle of them.
+   *   <li>Using appendFrom to overwrite data, which in Parcel will overwrite the data <i>and</i>
+   *       expand data size by the same amount, introducing empty gaps.
+   *   <li>Reading from or marshalling buffers with uninitialized gaps (e.g. where data position was
+   *       expanded but nothing was written)
+   * </ul>
+   *
+   * <p>Possibly-unwanted divergent behavior:
+   *
+   * <ul>
+   *   <li>Reading an object will often return the same instance that was written.
+   *   <li>The marshalled form does not at all resemble Parcel's. This is to maintain compatibility
+   *       with existing clients that rely on the Java-serialization-based format.
+   *   <li>Uses substantially more memory, since each "byte" takes at minimum 4 bytes for a pointer,
+   *       and even more for the overhead of allocating a record for each write. But note there is
+   *       only at most one allocation for every 4 byte positions.
+   * </ul>
+   */
+  private static class ByteBuffer {
+    /** Number of bytes in Parcel used by an int, length, or anything smaller. */
+    private static final int INT_SIZE_BYTES = 4;
+    /** Number of bytes in Parcel used by a long or double. */
+    private static final int LONG_OR_DOUBLE_SIZE_BYTES = 8;
+    /** Immutable empty byte array. */
+    private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+
+    /** Representation for an item that has been serialized in a parcel. */
+    private static class FakeEncodedItem implements Serializable {
+      /** Number of consecutive bytes consumed by this object. */
+      final int sizeBytes;
+      /** The original typed value stored. */
+      final Object value;
+      /**
+       * Whether this item's byte-encoding is all zero.
+       *
+       * <p>This is the one exception to strong typing in ShadowParcel. Since zero can be portably
+       * handled by many primitive types as zeros, and strings and arrays as empty. Note that when
+       * zeroes are successfully read, the size of this entry may be ignored and the position may
+       * progress to the middle of this, which remains safe as long as types that handle zeros are
+       * used.
+       */
+      final boolean isEncodedAsAllZeroBytes;
+
+      FakeEncodedItem(int sizeBytes, Object value) {
+        this.sizeBytes = sizeBytes;
+        this.value = value;
+        this.isEncodedAsAllZeroBytes = isEncodedAsAllZeroBytes(value);
+      }
+    }
+
+    /**
+     * A type-safe simulation of the Parcel's data buffer.
+     *
+     * <p>Each index represents a byte of the parcel. Instead of storing raw bytes, this contains
+     * records containing both the original data (in its original Java type) as well as the length.
+     * Consecutive indices will point to the same FakeEncodedItem instance; for example, an item
+     * with sizeBytes of 24 will, in normal cases, have references from 24 consecutive indices.
+     *
+     * <p>There are two main fail-fast features in this type-safe buffer. First, objects may only be
+     * read from the parcel as the same type they were stored with, enforced by casting. Second,
+     * this fails fast when reading incomplete or partially overwritten items.
+     *
+     * <p>Even though writing a custom resizable array is a code smell vs ArrayList, arrays' fixed
+     * capacity closely models Parcel's dataCapacity (which we emulate anyway), and bulk array
+     * utilities are robust compared to ArrayList's bulk operations.
+     */
+    private FakeEncodedItem[] data;
+    /** The read/write pointer. */
+    private int dataPosition;
+    /** The length of the buffer; the capacity is data.length. */
+    private int dataSize;
+    /**
+     * Whether the next read should fail if it's past the end of the array.
+     *
+     * <p>This is set true when modifying the end of the buffer, and cleared if a data position was
+     * explicitly set.
+     */
+    private boolean failNextReadIfPastEnd;
+
+    ByteBuffer() {
+      clear();
+    }
+
+    /** Removes all elements from the byte buffer */
+    public void clear() {
+      data = new FakeEncodedItem[0];
+      dataPosition = 0;
+      dataSize = 0;
+      failNextReadIfPastEnd = false;
+    }
+
+    /** Reads a byte array from the byte buffer based on the current data position */
+    public byte[] createByteArray() {
+      // It would be simpler just to store the byte array without a separate length.  However, the
+      // "non-native" code in Parcel short-circuits null to -1, so this must consistently write a
+      // separate length field in all cases.
+      int length = readInt();
+      if (length == -1) {
+        return null;
+      }
+      if (length == 0) {
+        return EMPTY_BYTE_ARRAY;
+      }
+      Object current = peek();
+      if (current instanceof Byte) {
+        // Legacy-encoded byte arrays (created by some tests) encode individual bytes, and do not
+        // align to the integer.
+        return readLegacyByteArray(length);
+      } else if (readZeroes(alignToInt(length))) {
+        return new byte[length];
+      }
+      byte[] result = readValue(EMPTY_BYTE_ARRAY, byte[].class, /* allowNull= */ false);
+      if (result.length != length) {
+        // Looks like the length doesn't correspond to the array.
+        throw new UnreliableBehaviorError(
+            String.format(
+                Locale.US,
+                "Byte array's length prefix is %d but real length is %d",
+                length,
+                result.length));
+      }
+      return result;
+    }
+
+    /** Reads a byte array encoded the way ShadowParcel previously encoded byte arrays. */
+    private byte[] readLegacyByteArray(int length) {
+      // Some tests rely on ShadowParcel's previous byte-by-byte encoding.
+      byte[] result = new byte[length];
+      for (int i = 0; i < length; i++) {
+        result[i] = readPrimitive(1, (byte) 0, Byte.class);
+      }
+      return result;
+    }
+
+    /** Reads a byte array from the byte buffer based on the current data position */
+    public boolean readByteArray(byte[] dest, int destLen) {
+      byte[] result = createByteArray();
+      if (result == null || destLen != result.length) {
+        // Since older versions of Android (pre O MR1) don't call this method at all, let's be more
+        // consistent with them and let android.os.Parcel throw RuntimeException, instead of
+        // throwing a more helpful exception.
+        return false;
+      }
+      System.arraycopy(result, 0, dest, 0, destLen);
+      return true;
+    }
+
+    /**
+     * Writes a byte array starting at offset for length bytes to the byte buffer at the current
+     * data position
+     */
+    public void writeByteArray(byte[] b, int offset, int length) {
+      writeInt(length);
+      // Native parcel writes a byte array as length plus the individual bytes.  But we can't write
+      // bytes individually because each byte would take up 4 bytes due to Parcel's alignment
+      // behavior.  Instead we write the length, and if non-empty, we write the array.
+      if (length != 0) {
+        writeValue(length, Arrays.copyOfRange(b, offset, offset + length));
+      }
+    }
+
+    /** Writes an int to the byte buffer at the current data position */
+    public void writeInt(int i) {
+      writeValue(INT_SIZE_BYTES, i);
+    }
+
+    /** Reads a int from the byte buffer based on the current data position */
+    public int readInt() {
+      return readPrimitive(INT_SIZE_BYTES, 0, Integer.class);
+    }
+
+    /** Writes a long to the byte buffer at the current data position */
+    public void writeLong(long l) {
+      writeValue(LONG_OR_DOUBLE_SIZE_BYTES, l);
+    }
+
+    /** Reads a long from the byte buffer based on the current data position */
+    public long readLong() {
+      return readPrimitive(LONG_OR_DOUBLE_SIZE_BYTES, 0L, Long.class);
+    }
+
+    /** Writes a float to the byte buffer at the current data position */
+    public void writeFloat(float f) {
+      writeValue(INT_SIZE_BYTES, f);
+    }
+
+    /** Reads a float from the byte buffer based on the current data position */
+    public float readFloat() {
+      return readPrimitive(INT_SIZE_BYTES, 0f, Float.class);
+    }
+
+    /** Writes a double to the byte buffer at the current data position */
+    public void writeDouble(double d) {
+      writeValue(LONG_OR_DOUBLE_SIZE_BYTES, d);
+    }
+
+    /** Reads a double from the byte buffer based on the current data position */
+    public double readDouble() {
+      return readPrimitive(LONG_OR_DOUBLE_SIZE_BYTES, 0d, Double.class);
+    }
+
+    /** Writes a String to the byte buffer at the current data position */
+    public void writeString(String s) {
+      int nullTerminatedChars = (s != null) ? (s.length() + 1) : 0;
+      // Android encodes strings as length plus a null-terminated array of 2-byte characters.
+      // writeValue will pad to nearest 4 bytes.  Null is encoded as just -1.
+      int sizeBytes = INT_SIZE_BYTES + (nullTerminatedChars * 2);
+      writeValue(sizeBytes, s);
+    }
+
+    /** Reads a String from the byte buffer based on the current data position */
+    public String readString() {
+      if (readZeroes(INT_SIZE_BYTES * 2)) {
+        // Empty string is 4 bytes for length of 0, and 4 bytes for null terminator and padding.
+        return "";
+      }
+      return readValue(null, String.class, /* allowNull= */ true);
+    }
+
+    /** Writes an IBinder to the byte buffer at the current data position */
+    public void writeStrongBinder(IBinder b) {
+      // Size of struct flat_binder_object in android/binder.h used to encode binders in the real
+      // parceling code.
+      int length = 5 * INT_SIZE_BYTES;
+      writeValue(length, b);
+    }
+
+    /** Reads an IBinder from the byte buffer based on the current data position */
+    public IBinder readStrongBinder() {
+      return readValue(null, IBinder.class, /* allowNull= */ true);
+    }
+
+    /**
+     * Appends the contents of the other byte buffer to this byte buffer starting at offset and
+     * ending at length.
+     *
+     * @param other ByteBuffer to append to this one
+     * @param offset number of bytes from beginning of byte buffer to start copy from
+     * @param length number of bytes to copy
+     */
+    public void appendFrom(ByteBuffer other, int offset, int length) {
+      int oldSize = dataSize;
+      if (dataPosition != dataSize) {
+        // Parcel.cpp will always expand the buffer by length even if it is overwriting existing
+        // data, yielding extra uninitialized data at the end, in contrast to write methods that
+        // won't increase the data length if they are overwriting in place.  This is surprising
+        // behavior that production code should avoid.
+        throw new UnreliableBehaviorError(
+            "Real Android parcels behave unreliably if appendFrom is "
+                + "called from any position other than the end");
+      }
+      setDataSize(oldSize + length);
+      // Just blindly copy whatever happens to be in the buffer.  Reads will validate whether any
+      // of the objects were only incompletely copied.
+      System.arraycopy(other.data, offset, data, dataPosition, length);
+      dataPosition += length;
+      failNextReadIfPastEnd = true;
+    }
+
+    /** Returns whether a data type is encoded as all zeroes. */
+    private static boolean isEncodedAsAllZeroBytes(Object value) {
+      if (value == null) {
+        return false; // Nulls are usually encoded as -1.
+      }
+      if (value instanceof Number) {
+        Number number = (Number) value;
+        return number.longValue() == 0 && number.doubleValue() == 0;
+      }
+      if (value instanceof byte[]) {
+        byte[] array = (byte[]) value;
+        return isAllZeroes(array, 0, array.length);
+      }
+      // NOTE: While empty string is all zeros, trying to read an empty string as zeroes is
+      // probably unintended; the reverse is supported just so all-zero buffers don't fail.
+      return false;
+    }
+
+    /** Identifies all zeroes, which can be safely reinterpreted to other types. */
+    private static boolean isAllZeroes(byte[] array, int offset, int length) {
+      for (int i = offset; i < length; i++) {
+        if (array[i] != 0) {
+          return false;
+        }
+      }
+      return true;
+    }
+
+    /**
+     * Creates a Byte buffer from a raw byte array.
+     *
+     * @param array byte array to read from
+     * @param offset starting position in bytes to start reading array at
+     * @param length number of bytes to read from array
+     */
+    @SuppressWarnings("BanSerializableRead")
+    public static ByteBuffer fromByteArray(byte[] array, int offset, int length) {
+      ByteBuffer byteBuffer = new ByteBuffer();
+
+      if (isAllZeroes(array, offset, length)) {
+        // Special case: for all zeroes, it's definitely not an ObjectInputStream, because it has a
+        // non-zero mandatory magic.  Zeroes have a portable, unambiguous interpretation.
+        byteBuffer.setDataSize(length);
+        byteBuffer.writeItem(new FakeEncodedItem(length, new byte[length]));
+        return byteBuffer;
+      }
+
+      try {
+        ByteArrayInputStream bis = new ByteArrayInputStream(array, offset, length);
+        ObjectInputStream ois = new ObjectInputStream(bis);
+        int numElements = ois.readInt();
+        for (int i = 0; i < numElements; i++) {
+          int sizeOf = ois.readInt();
+          Object value = ois.readObject();
+          // NOTE: Bypassing writeValue so that this will support ShadowParcels that were
+          // marshalled before ShadowParcel simulated alignment.
+          byteBuffer.writeItem(new FakeEncodedItem(sizeOf, value));
+        }
+        // Android leaves the data position at the end in this case.
+        return byteBuffer;
+      } catch (Exception e) {
+        throw new UnreliableBehaviorError("ShadowParcel unable to unmarshall its custom format", e);
+      }
+    }
+
+    /**
+     * Converts a ByteBuffer to a raw byte array. This method should be symmetrical with
+     * fromByteArray.
+     */
+    public byte[] toByteArray() {
+      int oldDataPosition = dataPosition;
+      try {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        ObjectOutputStream oos = new ObjectOutputStream(bos);
+        // NOTE: Serializing the data array would be simpler, and serialization would actually
+        // preserve reference equality between entries.  However, the length-encoded format here
+        // preserves the previous format, which some tests appear to rely on.
+        List<FakeEncodedItem> entries = new ArrayList<>();
+        // NOTE: Use readNextItem to scan so the contents can be proactively validated.
+        dataPosition = 0;
+        while (dataPosition < dataSize) {
+          entries.add(readNextItem(Object.class));
+        }
+        oos.writeInt(entries.size());
+        for (FakeEncodedItem item : entries) {
+          oos.writeInt(item.sizeBytes);
+          oos.writeObject(item.value);
+        }
+        oos.flush();
+        return bos.toByteArray();
+      } catch (IOException e) {
+        throw new UnreliableBehaviorError("ErrorProne unable to serialize its custom format", e);
+      } finally {
+        dataPosition = oldDataPosition;
+      }
+    }
+
+    /** Number of unused bytes in this byte buffer. */
+    public int dataAvailable() {
+      return dataSize() - dataPosition();
+    }
+
+    /** Total buffer size in bytes of byte buffer included unused space. */
+    public int dataCapacity() {
+      return data.length;
+    }
+
+    /** Current data position of byte buffer in bytes. Reads / writes are from this position. */
+    public int dataPosition() {
+      return dataPosition;
+    }
+
+    /** Current amount of bytes currently written for ByteBuffer. */
+    public int dataSize() {
+      return dataSize;
+    }
+
+    /**
+     * Sets the current data position.
+     *
+     * @param pos Desired position in bytes
+     */
+    public void setDataPosition(int pos) {
+      if (pos > dataSize) {
+        // NOTE: Real parcel ignores this until a write occurs.
+        throw new UnreliableBehaviorError(pos + " greater than dataSize " + dataSize);
+      }
+      dataPosition = pos;
+      failNextReadIfPastEnd = false;
+    }
+
+    public void setDataSize(int size) {
+      if (size < dataSize) {
+        // Clear all the inaccessible bytes when shrinking, to allow garbage collection, and so
+        // they remain cleared if expanded again.  Note this might truncate something mid-object,
+        // which would be handled at read time.
+        Arrays.fill(data, size, dataSize, null);
+      }
+      setDataCapacityAtLeast(size);
+      dataSize = size;
+      if (dataPosition >= dataSize) {
+        dataPosition = dataSize;
+      }
+    }
+
+    public void setDataCapacityAtLeast(int newCapacity) {
+      // NOTE: Oddly, Parcel only every increases data capacity, and never decreases it, so this
+      // really should have never been named setDataCapacity.
+      if (newCapacity > data.length) {
+        FakeEncodedItem[] newData = new FakeEncodedItem[newCapacity];
+        dataSize = Math.min(dataSize, newCapacity);
+        dataPosition = Math.min(dataPosition, dataSize);
+        System.arraycopy(data, 0, newData, 0, dataSize);
+        data = newData;
+      }
+    }
+
+    /** Rounds to next 4-byte bounder similar to native Parcel. */
+    private int alignToInt(int unpaddedSizeBytes) {
+      return ((unpaddedSizeBytes + 3) / 4) * 4;
+    }
+
+    /**
+     * Ensures that the next sizeBytes are all the initial value we read.
+     *
+     * <p>This detects:
+     *
+     * <ul>
+     *   <li>Reading an item, but not starting at its start position
+     *   <li>Reading items that were truncated by setSize
+     *   <li>Reading items that were partially overwritten by another
+     * </ul>
+     */
+    private void checkConsistentReadAndIncrementPosition(Class<?> clazz, FakeEncodedItem item) {
+      int endPosition = dataPosition + item.sizeBytes;
+      for (int i = dataPosition; i < endPosition; i++) {
+        FakeEncodedItem foundItemItem = i < dataSize ? data[i] : null;
+        if (foundItemItem != item) {
+          throw new UnreliableBehaviorError(
+              clazz,
+              dataPosition,
+              item,
+              String.format(
+                  Locale.US,
+                  "but [%s] interrupts it at position %d",
+                  foundItemItem == null
+                      ? "uninitialized data or the end of the buffer"
+                      : foundItemItem.value,
+                  i));
+        }
+      }
+      dataPosition = Math.min(dataSize, dataPosition + item.sizeBytes);
+    }
+
+    /** Returns the item at the current position, or null if uninitialized or null. */
+    private Object peek() {
+      return dataPosition < dataSize && data[dataPosition] != null
+          ? data[dataPosition].value
+          : null;
+    }
+
+    /**
+     * Reads a complete item in the byte buffer.
+     *
+     * @param clazz this is the type that is being read, but not checked in this method
+     * @return null if the default value should be returned, otherwise the item holding the data
+     */
+    private <T> FakeEncodedItem readNextItem(Class<T> clazz) {
+      FakeEncodedItem item = data[dataPosition];
+      if (item == null) {
+        // While Parcel will treat these as zeros, in tests, this is almost always an error.
+        throw new UnreliableBehaviorError("Reading uninitialized data at position " + dataPosition);
+      }
+      checkConsistentReadAndIncrementPosition(clazz, item);
+      return item;
+    }
+
+    /**
+     * Reads the next value in the byte buffer of a specified type.
+     *
+     * @param pastEndValue value to return when reading past the end of the buffer
+     * @param clazz this is the type that is being read, but not checked in this method
+     * @param allowNull whether null values are permitted
+     */
+    private <T> T readValue(T pastEndValue, Class<T> clazz, boolean allowNull) {
+      if (dataPosition >= dataSize) {
+        // Normally, reading past the end is permitted, and returns the default values.  However,
+        // writing to a parcel then reading without setting the position back to 0 is an incredibly
+        // common error to make in tests, and should never really happen in production code, so
+        // this shadow will fail in this condition.
+        if (failNextReadIfPastEnd) {
+          throw new UnreliableBehaviorError(
+              "Did you forget to setDataPosition(0) before reading the parcel?");
+        }
+        return pastEndValue;
+      }
+      int startPosition = dataPosition;
+      FakeEncodedItem item = readNextItem(clazz);
+      if (item == null) {
+        return pastEndValue;
+      } else if (item.value == null && allowNull) {
+        return null;
+      } else if (clazz.isInstance(item.value)) {
+        return clazz.cast(item.value);
+      } else {
+        // Numerous existing tests rely on ShadowParcel throwing RuntimeException and catching
+        // them.  Many of these tests are trying to test what happens when an invalid Parcel is
+        // provided.  However, Android has no concept of an "invalid parcel" because Parcel will
+        // happily return garbage if you ask for it.  The only runtime exceptions are thrown on
+        // array length mismatches, or higher-level APIs like Parcelable (which has its own safety
+        // checks).  Tests trying to test error-handling behavior should instead craft a Parcel
+        // that specifically triggers a BadParcelableException.
+        throw new RuntimeException(
+            new UnreliableBehaviorError(
+                clazz, startPosition, item, "and it is non-portable to reinterpret it"));
+      }
+    }
+
+    /**
+     * Determines if there is a sequence of castable zeroes, and consumes them.
+     *
+     * <p>This is the only exception for strong typing, because zero bytes are portable and
+     * unambiguous. There are a few situations where well-written code can rely on this, so it is
+     * worthwhile making a special exception for. This tolerates partially-overwritten and truncated
+     * values if all bytes are zero.
+     */
+    private boolean readZeroes(int bytes) {
+      int endPosition = dataPosition + bytes;
+      if (endPosition > dataSize) {
+        return false;
+      }
+      for (int i = dataPosition; i < endPosition; i++) {
+        if (data[i] == null || !data[i].isEncodedAsAllZeroBytes) {
+          return false;
+        }
+      }
+      // Note in this case we short-circuit other verification -- even if we are reading weirdly
+      // clobbered zeroes, they're still zeroes.  Future reads might fail, though.
+      dataPosition = endPosition;
+      return true;
+    }
+
+    /**
+     * Reads a primitive, which may reinterpret zeros of other types.
+     *
+     * @param defaultSizeBytes if reinterpreting zeros, the number of bytes to consume
+     * @param defaultValue the default value for zeros or reading past the end
+     * @param clazz this is the type that is being read, but not checked in this method
+     */
+    private <T> T readPrimitive(int defaultSizeBytes, T defaultValue, Class<T> clazz) {
+      // Check for zeroes first, since partially-overwritten values are not an error for zeroes.
+      if (readZeroes(defaultSizeBytes)) {
+        return defaultValue;
+      }
+      return readValue(defaultValue, clazz, /* allowNull= */ false);
+    }
+
+    /** Writes an encoded item directly, bypassing alignment, and possibly repeating an item. */
+    private void writeItem(FakeEncodedItem item) {
+      int endPosition = dataPosition + item.sizeBytes;
+      if (endPosition > data.length) {
+        // Parcel grows by 3/2 of the new size.
+        setDataCapacityAtLeast(endPosition * 3 / 2);
+      }
+      if (endPosition > dataSize) {
+        failNextReadIfPastEnd = true;
+        dataSize = endPosition;
+      }
+      Arrays.fill(data, dataPosition, endPosition, item);
+      dataPosition = endPosition;
+    }
+
+    /**
+     * Writes a value to the next range of bytes.
+     *
+     * <p>Writes are aligned to 4-byte regions.
+     */
+    private void writeValue(int unpaddedSizeBytes, Object o) {
+      // Create the item with its final, aligned byte size.
+      writeItem(new FakeEncodedItem(alignToInt(unpaddedSizeBytes), o));
+    }
+  }
+
+  @Implementation(maxSdk = P)
+  protected static FileDescriptor openFileDescriptor(String file, int mode) throws IOException {
+    RandomAccessFile randomAccessFile =
+        new RandomAccessFile(file, mode == ParcelFileDescriptor.MODE_READ_ONLY ? "r" : "rw");
+    return randomAccessFile.getFD();
+  }
+
+  @Implementation(minSdk = M, maxSdk = R)
+  protected static long nativeWriteFileDescriptor(long nativePtr, FileDescriptor val) {
+    // The Java version of FileDescriptor stored the fd in a field called "fd", and the Android
+    // version changed the field name to "descriptor". But it looks like Robolectric uses the
+    // Java version of FileDescriptor instead of the Android version.
+    int fd = ReflectionHelpers.getField(val, "fd");
+    NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).writeInt(fd);
+    return (long) nativeDataPosition(nativePtr);
+  }
+
+  @Implementation(minSdk = M)
+  protected static FileDescriptor nativeReadFileDescriptor(long nativePtr) {
+    int fd = NATIVE_BYTE_BUFFER_REGISTRY.getNativeObject(nativePtr).readInt();
+    return ReflectionHelpers.callConstructor(
+        FileDescriptor.class, ClassParameter.from(int.class, fd));
+  }
+
+  @Implementation(minSdk = R)
+  protected static void nativeWriteString8(long nativePtr, String val) {
+    nativeWriteString(nativePtr, val);
+  }
+
+  @Implementation(minSdk = R)
+  protected static void nativeWriteString16(long nativePtr, String val) {
+    nativeWriteString(nativePtr, val);
+  }
+
+  @Implementation(minSdk = R)
+  protected static String nativeReadString8(long nativePtr) {
+    return nativeReadString(nativePtr);
+  }
+
+  @Implementation(minSdk = R)
+  protected static String nativeReadString16(long nativePtr) {
+    return nativeReadString(nativePtr);
+  }
+
+  // need to use looseSignatures for the S methods because method signatures differ only by return
+  // type
+  @Implementation(minSdk = S)
+  protected static int nativeWriteInt(Object nativePtr, Object val) {
+    nativeWriteInt((long) nativePtr, (int) val);
+    return 0; /* OK */
+  }
+
+  @Implementation(minSdk = S)
+  protected static int nativeWriteLong(Object nativePtr, Object val) {
+    nativeWriteLong((long) nativePtr, (long) val);
+    return 0; /* OK */
+  }
+
+  @Implementation(minSdk = S)
+  protected static int nativeWriteFloat(Object nativePtr, Object val) {
+    nativeWriteFloat((long) nativePtr, (float) val);
+    return 0; /* OK */
+  }
+
+  @Implementation(minSdk = S)
+  protected static int nativeWriteDouble(Object nativePtr, Object val) {
+    nativeWriteDouble((long) nativePtr, (double) val);
+    return 0; /* OK */
+  }
+
+  @Implementation(minSdk = S)
+  protected static void nativeWriteFileDescriptor(Object nativePtr, Object val) {
+    nativeWriteFileDescriptor((long) nativePtr, (FileDescriptor) val);
+  }
+
+  @Resetter
+  public static void reset() {
+    pairedCreators.clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowParcelFileDescriptor.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowParcelFileDescriptor.java
new file mode 100644
index 0000000..3f27850
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowParcelFileDescriptor.java
@@ -0,0 +1,189 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.SuppressLint;
+import android.os.Handler;
+import android.os.ParcelFileDescriptor;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.lang.reflect.Constructor;
+import java.util.UUID;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(ParcelFileDescriptor.class)
+@SuppressLint("NewApi")
+public class ShadowParcelFileDescriptor {
+  // TODO: consider removing this shadow in favor of shadowing file operations at the libcore.os
+  // level
+  private static final String PIPE_TMP_DIR = "ShadowParcelFileDescriptor";
+  private static final String PIPE_FILE_NAME = "pipe";
+  private RandomAccessFile file;
+  private boolean closed;
+  private Handler handler;
+  private ParcelFileDescriptor.OnCloseListener onCloseListener;
+
+  @RealObject private ParcelFileDescriptor realParcelFd;
+  @RealObject private ParcelFileDescriptor realObject;
+
+  @Implementation
+  protected void __constructor__(ParcelFileDescriptor wrapped) {
+    invokeConstructor(
+        ParcelFileDescriptor.class, realObject, from(ParcelFileDescriptor.class, wrapped));
+    if (wrapped != null) {
+      ShadowParcelFileDescriptor shadowParcelFileDescriptor = Shadow.extract(wrapped);
+      this.file = shadowParcelFileDescriptor.file;
+    }
+  }
+
+  @Implementation
+  protected static ParcelFileDescriptor open(File file, int mode) throws FileNotFoundException {
+    ParcelFileDescriptor pfd;
+    try {
+      Constructor<ParcelFileDescriptor> constructor =
+          ParcelFileDescriptor.class.getDeclaredConstructor(FileDescriptor.class);
+      pfd = constructor.newInstance(new FileDescriptor());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+    ShadowParcelFileDescriptor shadowParcelFileDescriptor = Shadow.extract(pfd);
+    shadowParcelFileDescriptor.file = new RandomAccessFile(file, getFileMode(mode));
+    if ((mode & ParcelFileDescriptor.MODE_TRUNCATE) != 0) {
+      try {
+        shadowParcelFileDescriptor.file.setLength(0);
+      } catch (IOException ioe) {
+        FileNotFoundException fnfe = new FileNotFoundException("Unable to truncate");
+        fnfe.initCause(ioe);
+        throw fnfe;
+      }
+    }
+    if ((mode & ParcelFileDescriptor.MODE_APPEND) != 0) {
+      try {
+        shadowParcelFileDescriptor.file.seek(shadowParcelFileDescriptor.file.length());
+      } catch (IOException ioe) {
+        FileNotFoundException fnfe = new FileNotFoundException("Unable to append");
+        fnfe.initCause(ioe);
+        throw fnfe;
+      }
+    }
+    return pfd;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected static ParcelFileDescriptor open(
+      File file, int mode, Handler handler, ParcelFileDescriptor.OnCloseListener listener)
+      throws IOException {
+    if (handler == null) {
+      throw new IllegalArgumentException("Handler must not be null");
+    }
+    if (listener == null) {
+      throw new IllegalArgumentException("Listener must not be null");
+    }
+    ParcelFileDescriptor pfd = open(file, mode);
+    ShadowParcelFileDescriptor shadowParcelFileDescriptor = Shadow.extract(pfd);
+    shadowParcelFileDescriptor.handler = handler;
+    shadowParcelFileDescriptor.onCloseListener = listener;
+    return pfd;
+  }
+
+  private static String getFileMode(int mode) {
+    if ((mode & ParcelFileDescriptor.MODE_CREATE) != 0) {
+      return "rw";
+    }
+    switch (mode & ParcelFileDescriptor.MODE_READ_WRITE) {
+      case ParcelFileDescriptor.MODE_READ_ONLY:
+        return "r";
+      case ParcelFileDescriptor.MODE_WRITE_ONLY:
+      case ParcelFileDescriptor.MODE_READ_WRITE:
+        return "rw";
+    }
+    return "rw";
+  }
+
+  @Implementation
+  protected static ParcelFileDescriptor[] createPipe() throws IOException {
+    File file =
+        new File(
+            RuntimeEnvironment.getTempDirectory().createIfNotExists(PIPE_TMP_DIR).toFile(),
+            PIPE_FILE_NAME + "-" + UUID.randomUUID());
+    if (!file.createNewFile()) {
+      throw new IOException("Cannot create pipe file: " + file.getAbsolutePath());
+    }
+    ParcelFileDescriptor readSide = open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+    ParcelFileDescriptor writeSide = open(file, ParcelFileDescriptor.MODE_READ_WRITE);
+    file.deleteOnExit();
+    return new ParcelFileDescriptor[] {readSide, writeSide};
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected static ParcelFileDescriptor[] createReliablePipe() throws IOException {
+    return createPipe();
+  }
+
+  @Implementation
+  protected FileDescriptor getFileDescriptor() {
+    try {
+      return file.getFD();
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Implementation
+  protected long getStatSize() {
+    try {
+      return file.length();
+    } catch (IOException e) {
+      // This might occur when the file object has been closed.
+      return -1;
+    }
+  }
+
+  @Implementation
+  protected int getFd() {
+    if (closed) {
+      throw new IllegalStateException("Already closed");
+    }
+
+    try {
+      return ReflectionHelpers.getField(file.getFD(), "fd");
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Implementation
+  protected void close() throws IOException {
+    // Act this status check the same as real close operation in AOSP.
+    if (closed) {
+      return;
+    }
+
+    file.close();
+    reflector(ParcelFileDescriptorReflector.class, realParcelFd).close();
+    closed = true;
+    if (handler != null && onCloseListener != null) {
+      handler.post(() -> onCloseListener.onClose(null));
+    }
+  }
+
+  @ForType(ParcelFileDescriptor.class)
+  interface ParcelFileDescriptorReflector {
+
+    @Direct
+    void close();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPath.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPath.java
new file mode 100644
index 0000000..889736f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPath.java
@@ -0,0 +1,614 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.shadow.api.Shadow.extract;
+import static org.robolectric.shadows.ShadowPath.Point.Type.LINE_TO;
+import static org.robolectric.shadows.ShadowPath.Point.Type.MOVE_TO;
+
+import android.graphics.Matrix;
+import android.graphics.Path;
+import android.graphics.Path.Direction;
+import android.graphics.RectF;
+import android.util.Log;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Arc2D;
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.GeneralPath;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.geom.RoundRectangle2D;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/**
+ * The shadow only supports straight-line paths.
+ */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Path.class)
+public class ShadowPath {
+  private static final String TAG = ShadowPath.class.getSimpleName();
+  private static final float EPSILON = 1e-4f;
+
+  @RealObject private Path realObject;
+
+  private List<Point> points = new ArrayList<>();
+
+  private float mLastX = 0;
+  private float mLastY = 0;
+  private Path2D mPath = new Path2D.Double();
+  private boolean mCachedIsEmpty = true;
+  private Path.FillType mFillType = Path.FillType.WINDING;
+  protected boolean isSimplePath;
+
+  @Implementation
+  protected void __constructor__(Path path) {
+    ShadowPath shadowPath = extract(path);
+    points = new ArrayList<>(shadowPath.getPoints());
+    mPath.append(shadowPath.mPath, /*connect=*/ false);
+    mFillType = shadowPath.getFillType();
+  }
+
+  Path2D getJavaShape() {
+    return mPath;
+  }
+
+  @Implementation
+  protected void moveTo(float x, float y) {
+    mPath.moveTo(mLastX = x, mLastY = y);
+
+    // Legacy recording behavior
+    Point p = new Point(x, y, MOVE_TO);
+    points.add(p);
+  }
+
+  @Implementation
+  protected void lineTo(float x, float y) {
+    if (!hasPoints()) {
+      mPath.moveTo(mLastX = 0, mLastY = 0);
+    }
+    mPath.lineTo(mLastX = x, mLastY = y);
+
+    // Legacy recording behavior
+    Point point = new Point(x, y, LINE_TO);
+    points.add(point);
+  }
+
+  @Implementation
+  protected void quadTo(float x1, float y1, float x2, float y2) {
+    isSimplePath = false;
+    if (!hasPoints()) {
+      moveTo(0, 0);
+    }
+    mPath.quadTo(x1, y1, mLastX = x2, mLastY = y2);
+  }
+
+  @Implementation
+  protected void cubicTo(float x1, float y1, float x2, float y2, float x3, float y3) {
+    if (!hasPoints()) {
+      mPath.moveTo(0, 0);
+    }
+    mPath.curveTo(x1, y1, x2, y2, mLastX = x3, mLastY = y3);
+  }
+
+  private boolean hasPoints() {
+    return !mPath.getPathIterator(null).isDone();
+  }
+
+  @Implementation
+  protected void reset() {
+    mPath.reset();
+    mLastX = 0;
+    mLastY = 0;
+
+    // Legacy recording behavior
+    points.clear();
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected float[] approximate(float acceptableError) {
+    PathIterator iterator = mPath.getPathIterator(null, acceptableError);
+
+    float segment[] = new float[6];
+    float totalLength = 0;
+    ArrayList<Point2D.Float> points = new ArrayList<Point2D.Float>();
+    Point2D.Float previousPoint = null;
+    while (!iterator.isDone()) {
+      int type = iterator.currentSegment(segment);
+      Point2D.Float currentPoint = new Point2D.Float(segment[0], segment[1]);
+      // MoveTo shouldn't affect the length
+      if (previousPoint != null && type != PathIterator.SEG_MOVETO) {
+        totalLength += (float) currentPoint.distance(previousPoint);
+      }
+      previousPoint = currentPoint;
+      points.add(currentPoint);
+      iterator.next();
+    }
+
+    int nPoints = points.size();
+    float[] result = new float[nPoints * 3];
+    previousPoint = null;
+    // Distance that we've covered so far. Used to calculate the fraction of the path that
+    // we've covered up to this point.
+    float walkedDistance = .0f;
+    for (int i = 0; i < nPoints; i++) {
+      Point2D.Float point = points.get(i);
+      float distance = previousPoint != null ? (float) previousPoint.distance(point) : .0f;
+      walkedDistance += distance;
+      result[i * 3] = walkedDistance / totalLength;
+      result[i * 3 + 1] = point.x;
+      result[i * 3 + 2] = point.y;
+
+      previousPoint = point;
+    }
+
+    return result;
+  }
+
+  /**
+   * @return all the points that have been added to the {@code Path}
+   */
+  public List<Point> getPoints() {
+    return points;
+  }
+
+  public static class Point {
+    private final float x;
+    private final float y;
+    private final Type type;
+
+    public enum Type {
+      MOVE_TO,
+      LINE_TO
+    }
+
+    public Point(float x, float y, Type type) {
+      this.x = x;
+      this.y = y;
+      this.type = type;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) return true;
+      if (!(o instanceof Point)) return false;
+
+      Point point = (Point) o;
+
+      if (Float.compare(point.x, x) != 0) return false;
+      if (Float.compare(point.y, y) != 0) return false;
+      if (type != point.type) return false;
+
+      return true;
+    }
+
+    @Override
+    public int hashCode() {
+      int result = (x != +0.0f ? Float.floatToIntBits(x) : 0);
+      result = 31 * result + (y != +0.0f ? Float.floatToIntBits(y) : 0);
+      result = 31 * result + (type != null ? type.hashCode() : 0);
+      return result;
+    }
+
+    @Override
+    public String toString() {
+      return "Point(" + x + "," + y + "," + type + ")";
+    }
+
+    public float getX() {
+      return x;
+    }
+
+    public float getY() {
+      return y;
+    }
+
+    public Type getType() {
+      return type;
+    }
+  }
+
+  @Implementation
+  protected void rewind() {
+    // call out to reset since there's nothing to optimize in
+    // terms of data structs.
+    reset();
+  }
+
+  @Implementation
+  protected void set(Path src) {
+    mPath.reset();
+
+    ShadowPath shadowSrc = extract(src);
+    setFillType(shadowSrc.mFillType);
+    mPath.append(shadowSrc.mPath, false /*connect*/);
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected boolean op(Path path1, Path path2, Path.Op op) {
+    Log.w(TAG, "android.graphics.Path#op() not supported yet.");
+    return false;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isConvex() {
+    Log.w(TAG, "android.graphics.Path#isConvex() not supported yet.");
+    return true;
+  }
+
+  @Implementation
+  protected Path.FillType getFillType() {
+    return mFillType;
+  }
+
+  @Implementation
+  protected void setFillType(Path.FillType fillType) {
+    mFillType = fillType;
+    mPath.setWindingRule(getWindingRule(fillType));
+  }
+
+  /**
+   * Returns the Java2D winding rules matching a given Android {@link FillType}.
+   *
+   * @param type the android fill type
+   * @return the matching java2d winding rule.
+   */
+  private static int getWindingRule(Path.FillType type) {
+    switch (type) {
+      case WINDING:
+      case INVERSE_WINDING:
+        return GeneralPath.WIND_NON_ZERO;
+      case EVEN_ODD:
+      case INVERSE_EVEN_ODD:
+        return GeneralPath.WIND_EVEN_ODD;
+
+      default:
+        assert false;
+        return GeneralPath.WIND_NON_ZERO;
+    }
+  }
+
+  @Implementation
+  protected boolean isInverseFillType() {
+    throw new UnsupportedOperationException("isInverseFillType");
+  }
+
+  @Implementation
+  protected void toggleInverseFillType() {
+    throw new UnsupportedOperationException("toggleInverseFillType");
+  }
+
+  @Implementation
+  protected boolean isEmpty() {
+    if (!mCachedIsEmpty) {
+      return false;
+    }
+
+    float[] coords = new float[6];
+    mCachedIsEmpty = Boolean.TRUE;
+    for (PathIterator it = mPath.getPathIterator(null); !it.isDone(); it.next()) {
+      // int type = it.currentSegment(coords);
+      // if (type != PathIterator.SEG_MOVETO) {
+      // Once we know that the path is not empty, we do not need to check again unless
+      // Path#reset is called.
+      mCachedIsEmpty = false;
+      return false;
+      // }
+    }
+
+    return true;
+  }
+
+  @Implementation
+  protected boolean isRect(RectF rect) {
+    // create an Area that can test if the path is a rect
+    Area area = new Area(mPath);
+    if (area.isRectangular()) {
+      if (rect != null) {
+        fillBounds(rect);
+      }
+
+      return true;
+    }
+
+    return false;
+  }
+
+  @Implementation
+  protected void computeBounds(RectF bounds, boolean exact) {
+    fillBounds(bounds);
+  }
+
+  @Implementation
+  protected void incReserve(int extraPtCount) {
+    throw new UnsupportedOperationException("incReserve");
+  }
+
+  @Implementation
+  protected void rMoveTo(float dx, float dy) {
+    dx += mLastX;
+    dy += mLastY;
+    mPath.moveTo(mLastX = dx, mLastY = dy);
+  }
+
+  @Implementation
+  protected void rLineTo(float dx, float dy) {
+    if (!hasPoints()) {
+      mPath.moveTo(mLastX = 0, mLastY = 0);
+    }
+
+    if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) {
+      // The delta is so small that this shouldn't generate a line
+      return;
+    }
+
+    dx += mLastX;
+    dy += mLastY;
+    mPath.lineTo(mLastX = dx, mLastY = dy);
+  }
+
+  @Implementation
+  protected void rQuadTo(float dx1, float dy1, float dx2, float dy2) {
+    if (!hasPoints()) {
+      mPath.moveTo(mLastX = 0, mLastY = 0);
+    }
+    dx1 += mLastX;
+    dy1 += mLastY;
+    dx2 += mLastX;
+    dy2 += mLastY;
+    mPath.quadTo(dx1, dy1, mLastX = dx2, mLastY = dy2);
+  }
+
+  @Implementation
+  protected void rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3) {
+    if (!hasPoints()) {
+      mPath.moveTo(mLastX = 0, mLastY = 0);
+    }
+    x1 += mLastX;
+    y1 += mLastY;
+    x2 += mLastX;
+    y2 += mLastY;
+    x3 += mLastX;
+    y3 += mLastY;
+    mPath.curveTo(x1, y1, x2, y2, mLastX = x3, mLastY = y3);
+  }
+
+  @Implementation
+  protected void arcTo(RectF oval, float startAngle, float sweepAngle) {
+    arcTo(oval.left, oval.top, oval.right, oval.bottom, startAngle, sweepAngle, false);
+  }
+
+  @Implementation
+  protected void arcTo(RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) {
+    arcTo(oval.left, oval.top, oval.right, oval.bottom, startAngle, sweepAngle, forceMoveTo);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void arcTo(
+      float left,
+      float top,
+      float right,
+      float bottom,
+      float startAngle,
+      float sweepAngle,
+      boolean forceMoveTo) {
+    isSimplePath = false;
+    Arc2D arc =
+        new Arc2D.Float(
+            left, top, right - left, bottom - top, -startAngle, -sweepAngle, Arc2D.OPEN);
+    mPath.append(arc, true /*connect*/);
+    if (hasPoints()) {
+      resetLastPointFromPath();
+    }
+  }
+
+  @Implementation
+  protected void close() {
+    if (!hasPoints()) {
+      mPath.moveTo(mLastX = 0, mLastY = 0);
+    }
+    mPath.closePath();
+  }
+
+  @Implementation
+  protected void addRect(RectF rect, Direction dir) {
+    addRect(rect.left, rect.top, rect.right, rect.bottom, dir);
+  }
+
+  @Implementation
+  protected void addRect(float left, float top, float right, float bottom, Path.Direction dir) {
+    moveTo(left, top);
+
+    switch (dir) {
+      case CW:
+        lineTo(right, top);
+        lineTo(right, bottom);
+        lineTo(left, bottom);
+        break;
+      case CCW:
+        lineTo(left, bottom);
+        lineTo(right, bottom);
+        lineTo(right, top);
+        break;
+    }
+
+    close();
+
+    resetLastPointFromPath();
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addOval(float left, float top, float right, float bottom, Path.Direction dir) {
+    mPath.append(new Ellipse2D.Float(left, top, right - left, bottom - top), false);
+  }
+
+  @Implementation
+  protected void addCircle(float x, float y, float radius, Path.Direction dir) {
+    mPath.append(new Ellipse2D.Float(x - radius, y - radius, radius * 2, radius * 2), false);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addArc(
+      float left, float top, float right, float bottom, float startAngle, float sweepAngle) {
+    mPath.append(
+        new Arc2D.Float(
+            left, top, right - left, bottom - top, -startAngle, -sweepAngle, Arc2D.OPEN),
+        false);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN)
+  protected void addRoundRect(RectF rect, float rx, float ry, Direction dir) {
+    addRoundRect(rect.left, rect.top, rect.right, rect.bottom, rx, ry, dir);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN)
+  protected void addRoundRect(RectF rect, float[] radii, Direction dir) {
+    if (rect == null) {
+      throw new NullPointerException("need rect parameter");
+    }
+    addRoundRect(rect.left, rect.top, rect.right, rect.bottom, radii, dir);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addRoundRect(
+      float left, float top, float right, float bottom, float rx, float ry, Path.Direction dir) {
+    mPath.append(
+        new RoundRectangle2D.Float(left, top, right - left, bottom - top, rx * 2, ry * 2), false);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void addRoundRect(
+      float left, float top, float right, float bottom, float[] radii, Path.Direction dir) {
+    if (radii.length < 8) {
+      throw new ArrayIndexOutOfBoundsException("radii[] needs 8 values");
+    }
+    isSimplePath = false;
+
+    float[] cornerDimensions = new float[radii.length];
+    for (int i = 0; i < radii.length; i++) {
+      cornerDimensions[i] = 2 * radii[i];
+    }
+    mPath.append(
+        new RoundRectangle(left, top, right - left, bottom - top, cornerDimensions), false);
+  }
+
+  @Implementation
+  protected void addPath(Path src, float dx, float dy) {
+    isSimplePath = false;
+    ShadowPath.addPath(realObject, src, AffineTransform.getTranslateInstance(dx, dy));
+  }
+
+  @Implementation
+  protected void addPath(Path src) {
+    isSimplePath = false;
+    ShadowPath.addPath(realObject, src, null);
+  }
+
+  @Implementation
+  protected void addPath(Path src, Matrix matrix) {
+    if (matrix == null) {
+      return;
+    }
+    ShadowPath shadowSrc = extract(src);
+    if (!shadowSrc.isSimplePath) isSimplePath = false;
+
+    ShadowMatrix shadowMatrix = extract(matrix);
+    ShadowPath.addPath(realObject, src, shadowMatrix.getAffineTransform());
+  }
+
+  private static void addPath(Path destPath, Path srcPath, AffineTransform transform) {
+    if (destPath == null) {
+      return;
+    }
+
+    if (srcPath == null) {
+      return;
+    }
+
+    ShadowPath shadowDestPath = extract(destPath);
+    ShadowPath shadowSrcPath = extract(srcPath);
+    if (transform != null) {
+      shadowDestPath.mPath.append(shadowSrcPath.mPath.getPathIterator(transform), false);
+    } else {
+      shadowDestPath.mPath.append(shadowSrcPath.mPath, false);
+    }
+  }
+
+  @Implementation
+  protected void offset(float dx, float dy, Path dst) {
+    if (dst != null) {
+      dst.set(realObject);
+    } else {
+      dst = realObject;
+    }
+    dst.offset(dx, dy);
+  }
+
+  @Implementation
+  protected void offset(float dx, float dy) {
+    GeneralPath newPath = new GeneralPath();
+
+    PathIterator iterator = mPath.getPathIterator(new AffineTransform(0, 0, dx, 0, 0, dy));
+
+    newPath.append(iterator, false /*connect*/);
+    mPath = newPath;
+  }
+
+  @Implementation
+  protected void setLastPoint(float dx, float dy) {
+    mLastX = dx;
+    mLastY = dy;
+  }
+
+  @Implementation
+  protected void transform(Matrix matrix, Path dst) {
+    ShadowMatrix shadowMatrix = extract(matrix);
+
+    if (shadowMatrix.hasPerspective()) {
+      Log.w(TAG, "android.graphics.Path#transform() only supports affine transformations.");
+    }
+
+    GeneralPath newPath = new GeneralPath();
+
+    PathIterator iterator = mPath.getPathIterator(shadowMatrix.getAffineTransform());
+    newPath.append(iterator, false /*connect*/);
+
+    if (dst != null) {
+      ShadowPath shadowPath = extract(dst);
+      shadowPath.mPath = newPath;
+    } else {
+      mPath = newPath;
+    }
+  }
+
+  @Implementation
+  protected void transform(Matrix matrix) {
+    transform(matrix, null);
+  }
+
+  /**
+   * Fills the given {@link RectF} with the path bounds.
+   *
+   * @param bounds the RectF to be filled.
+   */
+  public void fillBounds(RectF bounds) {
+    Rectangle2D rect = mPath.getBounds2D();
+    bounds.left = (float) rect.getMinX();
+    bounds.right = (float) rect.getMaxX();
+    bounds.top = (float) rect.getMinY();
+    bounds.bottom = (float) rect.getMaxY();
+  }
+
+  private void resetLastPointFromPath() {
+    Point2D last = mPath.getCurrentPoint();
+    mLastX = (float) last.getX();
+    mLastY = (float) last.getY();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPathMeasure.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPathMeasure.java
new file mode 100644
index 0000000..5bc2b97
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPathMeasure.java
@@ -0,0 +1,58 @@
+package org.robolectric.shadows;
+
+import android.graphics.Path;
+import android.graphics.PathMeasure;
+import java.math.BigDecimal;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(PathMeasure.class)
+public class ShadowPathMeasure {
+
+  private CachedPathIteratorFactory mOriginalPathIterator;
+
+  @Implementation
+  protected void __constructor__(Path path, boolean forceClosed) {
+    if (path != null) {
+      ShadowPath shadowPath = (ShadowPath) Shadow.extract(path);
+      mOriginalPathIterator =
+          new CachedPathIteratorFactory(shadowPath.getJavaShape().getPathIterator(null));
+    }
+  }
+
+  /**
+   * Return the total length of the current contour, or 0 if no path is associated with this measure
+   * object.
+   */
+  @Implementation
+  protected float getLength() {
+    if (mOriginalPathIterator == null) {
+      return 0;
+    }
+
+    return mOriginalPathIterator.iterator().getTotalLength();
+  }
+
+  /** Note: This is not mathematically correct. */
+  @Implementation
+  protected boolean getPosTan(float distance, float pos[], float tan[]) {
+    if ((pos != null && pos.length < 2) || (tan != null && tan.length < 2)) {
+      throw new ArrayIndexOutOfBoundsException();
+    }
+
+    // This is not mathematically correct, but the simulation keeps the support library happy.
+    if (getLength() > 0) {
+      pos[0] = round(distance / getLength(), 4);
+      pos[1] = round(distance / getLength(), 4);
+    }
+
+    return true;
+  }
+
+  private static float round(float d, int decimalPlace) {
+    BigDecimal bd = new BigDecimal(d);
+    bd = bd.setScale(decimalPlace, BigDecimal.ROUND_HALF_UP);
+    return bd.floatValue();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPathParser.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPathParser.java
new file mode 100644
index 0000000..21eb62e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPathParser.java
@@ -0,0 +1,589 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+
+import android.graphics.Path;
+import android.util.Log;
+import android.util.PathParser;
+import android.util.PathParser.PathData;
+import java.util.ArrayList;
+import java.util.Arrays;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = PathParser.class, minSdk = N, isInAndroidSdk = false)
+public class ShadowPathParser {
+
+  static final String LOGTAG = ShadowPathParser.class.getSimpleName();
+
+  @Implementation
+  protected static Path createPathFromPathData(String pathData) {
+    Path path = new Path();
+    PathDataNode[] nodes = createNodesFromPathData(pathData);
+    if (nodes != null) {
+      PathDataNode.nodesToPath(nodes, path);
+      return path;
+    }
+    return null;
+  }
+
+  public static PathDataNode[] createNodesFromPathData(String pathData) {
+    if (pathData == null) {
+      return null;
+    }
+    int start = 0;
+    int end = 1;
+
+    ArrayList<PathDataNode> list = new ArrayList<PathDataNode>();
+    while (end < pathData.length()) {
+      end = nextStart(pathData, end);
+      String s = pathData.substring(start, end).trim();
+      if (s.length() > 0) {
+        float[] val = getFloats(s);
+        addNode(list, s.charAt(0), val);
+      }
+
+      start = end;
+      end++;
+    }
+    if ((end - start) == 1 && start < pathData.length()) {
+      addNode(list, pathData.charAt(start), new float[0]);
+    }
+    return list.toArray(new PathDataNode[list.size()]);
+  }
+
+  @Implementation
+  protected static boolean interpolatePathData(
+      PathData outData, PathData fromData, PathData toData, float fraction) {
+    return true;
+  }
+
+  @Implementation
+  public static boolean nCanMorph(long fromDataPtr, long toDataPtr) {
+    return true;
+  }
+
+  private static int nextStart(String s, int end) {
+    char c;
+
+    while (end < s.length()) {
+      c = s.charAt(end);
+      if (((c - 'A') * (c - 'Z') <= 0) || (((c - 'a') * (c - 'z') <= 0))) {
+        return end;
+      }
+      end++;
+    }
+    return end;
+  }
+
+  private static void addNode(ArrayList<PathDataNode> list, char cmd, float[] val) {
+    list.add(new PathDataNode(cmd, val));
+  }
+
+  private static class ExtractFloatResult {
+    // We need to return the position of the next separator and whether the
+    // next float starts with a '-'.
+    int mEndPosition;
+    boolean mEndWithNegSign;
+  }
+
+  private static float[] getFloats(String s) {
+    if (s.charAt(0) == 'z' || s.charAt(0) == 'Z') {
+      return new float[0];
+    }
+    try {
+      float[] results = new float[s.length()];
+      int count = 0;
+      int startPosition = 1;
+      int endPosition = 0;
+
+      ExtractFloatResult result = new ExtractFloatResult();
+      int totalLength = s.length();
+
+      // The startPosition should always be the first character of the
+      // current number, and endPosition is the character after the current
+      // number.
+      while (startPosition < totalLength) {
+        extract(s, startPosition, result);
+        endPosition = result.mEndPosition;
+
+        if (startPosition < endPosition) {
+          results[count++] = Float.parseFloat(s.substring(startPosition, endPosition));
+        }
+
+        if (result.mEndWithNegSign) {
+          // Keep the '-' sign with next number.
+          startPosition = endPosition;
+        } else {
+          startPosition = endPosition + 1;
+        }
+      }
+      return Arrays.copyOf(results, count);
+    } catch (NumberFormatException e) {
+      Log.e(LOGTAG, "error in parsing \"" + s + "\"");
+      throw e;
+    }
+  }
+
+  private static void extract(String s, int start, ExtractFloatResult result) {
+    // Now looking for ' ', ',' or '-' from the start.
+    int currentIndex = start;
+    boolean foundSeparator = false;
+    result.mEndWithNegSign = false;
+    for (; currentIndex < s.length(); currentIndex++) {
+      char currentChar = s.charAt(currentIndex);
+      switch (currentChar) {
+        case ' ':
+        case ',':
+          foundSeparator = true;
+          break;
+        case '-':
+          if (currentIndex != start) {
+            foundSeparator = true;
+            result.mEndWithNegSign = true;
+          }
+          break;
+      }
+      if (foundSeparator) {
+        break;
+      }
+    }
+    // When there is nothing found, then we put the end position to the end
+    // of the string.
+    result.mEndPosition = currentIndex;
+  }
+
+  public static class PathDataNode {
+    private char mType;
+    private float[] mParams;
+
+    private PathDataNode(char type, float[] params) {
+      mType = type;
+      mParams = params;
+    }
+
+    /**
+     * Convert an array of PathDataNode to Path.
+     *
+     * @param node The source array of PathDataNode.
+     * @param path The target Path object.
+     */
+    public static void nodesToPath(PathDataNode[] node, Path path) {
+      float[] current = new float[4];
+      char previousCommand = 'm';
+      for (int i = 0; i < node.length; i++) {
+        addCommand(path, current, previousCommand, node[i].mType, node[i].mParams);
+        previousCommand = node[i].mType;
+      }
+    }
+
+    /**
+     * The current PathDataNode will be interpolated between the <code>nodeFrom</code> and <code>
+     * nodeTo</code> according to the <code>fraction</code>.
+     *
+     * @param nodeFrom The start value as a PathDataNode.
+     * @param nodeTo The end value as a PathDataNode
+     * @param fraction The fraction to interpolate.
+     */
+    public void interpolatePathDataNode(
+        PathDataNode nodeFrom, PathDataNode nodeTo, float fraction) {
+      for (int i = 0; i < nodeFrom.mParams.length; i++) {
+        mParams[i] = nodeFrom.mParams[i] * (1 - fraction) + nodeTo.mParams[i] * fraction;
+      }
+    }
+
+    private static void addCommand(
+        Path path, float[] current, char previousCmd, char cmd, float[] val) {
+
+      int incr = 2;
+      float currentX = current[0];
+      float currentY = current[1];
+      float ctrlPointX = current[2];
+      float ctrlPointY = current[3];
+      float reflectiveCtrlPointX;
+      float reflectiveCtrlPointY;
+
+      switch (cmd) {
+        case 'z':
+        case 'Z':
+          path.close();
+          return;
+        case 'm':
+        case 'M':
+        case 'l':
+        case 'L':
+        case 't':
+        case 'T':
+          incr = 2;
+          break;
+        case 'h':
+        case 'H':
+        case 'v':
+        case 'V':
+          incr = 1;
+          break;
+        case 'c':
+        case 'C':
+          incr = 6;
+          break;
+        case 's':
+        case 'S':
+        case 'q':
+        case 'Q':
+          incr = 4;
+          break;
+        case 'a':
+        case 'A':
+          incr = 7;
+          break;
+      }
+      for (int k = 0; k < val.length; k += incr) {
+        switch (cmd) {
+          case 'm': // moveto - Start a new sub-path (relative)
+            path.rMoveTo(val[k + 0], val[k + 1]);
+            currentX += val[k + 0];
+            currentY += val[k + 1];
+            break;
+          case 'M': // moveto - Start a new sub-path
+            path.moveTo(val[k + 0], val[k + 1]);
+            currentX = val[k + 0];
+            currentY = val[k + 1];
+            break;
+          case 'l': // lineto - Draw a line from the current point (relative)
+            path.rLineTo(val[k + 0], val[k + 1]);
+            currentX += val[k + 0];
+            currentY += val[k + 1];
+            break;
+          case 'L': // lineto - Draw a line from the current point
+            path.lineTo(val[k + 0], val[k + 1]);
+            currentX = val[k + 0];
+            currentY = val[k + 1];
+            break;
+          case 'z': // closepath - Close the current subpath
+          case 'Z': // closepath - Close the current subpath
+            path.close();
+            break;
+          case 'h': // horizontal lineto - Draws a horizontal line (relative)
+            path.rLineTo(val[k + 0], 0);
+            currentX += val[k + 0];
+            break;
+          case 'H': // horizontal lineto - Draws a horizontal line
+            path.lineTo(val[k + 0], currentY);
+            currentX = val[k + 0];
+            break;
+          case 'v': // vertical lineto - Draws a vertical line from the current point (r)
+            path.rLineTo(0, val[k + 0]);
+            currentY += val[k + 0];
+            break;
+          case 'V': // vertical lineto - Draws a vertical line from the current point
+            path.lineTo(currentX, val[k + 0]);
+            currentY = val[k + 0];
+            break;
+          case 'c': // curveto - Draws a cubic Bézier curve (relative)
+            path.rCubicTo(val[k + 0], val[k + 1], val[k + 2], val[k + 3], val[k + 4], val[k + 5]);
+
+            ctrlPointX = currentX + val[k + 2];
+            ctrlPointY = currentY + val[k + 3];
+            currentX += val[k + 4];
+            currentY += val[k + 5];
+
+            break;
+          case 'C': // curveto - Draws a cubic Bézier curve
+            path.cubicTo(val[k + 0], val[k + 1], val[k + 2], val[k + 3], val[k + 4], val[k + 5]);
+            currentX = val[k + 4];
+            currentY = val[k + 5];
+            ctrlPointX = val[k + 2];
+            ctrlPointY = val[k + 3];
+            break;
+          case 's': // smooth curveto - Draws a cubic Bézier curve (reflective cp)
+            reflectiveCtrlPointX = 0;
+            reflectiveCtrlPointY = 0;
+            if (previousCmd == 'c'
+                || previousCmd == 's'
+                || previousCmd == 'C'
+                || previousCmd == 'S') {
+              reflectiveCtrlPointX = currentX - ctrlPointX;
+              reflectiveCtrlPointY = currentY - ctrlPointY;
+            }
+            path.rCubicTo(
+                reflectiveCtrlPointX,
+                reflectiveCtrlPointY,
+                val[k + 0],
+                val[k + 1],
+                val[k + 2],
+                val[k + 3]);
+
+            ctrlPointX = currentX + val[k + 0];
+            ctrlPointY = currentY + val[k + 1];
+            currentX += val[k + 2];
+            currentY += val[k + 3];
+            break;
+          case 'S': // shorthand/smooth curveto Draws a cubic Bézier curve(reflective cp)
+            reflectiveCtrlPointX = currentX;
+            reflectiveCtrlPointY = currentY;
+            if (previousCmd == 'c'
+                || previousCmd == 's'
+                || previousCmd == 'C'
+                || previousCmd == 'S') {
+              reflectiveCtrlPointX = 2 * currentX - ctrlPointX;
+              reflectiveCtrlPointY = 2 * currentY - ctrlPointY;
+            }
+            path.cubicTo(
+                reflectiveCtrlPointX,
+                reflectiveCtrlPointY,
+                val[k + 0],
+                val[k + 1],
+                val[k + 2],
+                val[k + 3]);
+            ctrlPointX = val[k + 0];
+            ctrlPointY = val[k + 1];
+            currentX = val[k + 2];
+            currentY = val[k + 3];
+            break;
+          case 'q': // Draws a quadratic Bézier (relative)
+            path.rQuadTo(val[k + 0], val[k + 1], val[k + 2], val[k + 3]);
+            ctrlPointX = currentX + val[k + 0];
+            ctrlPointY = currentY + val[k + 1];
+            currentX += val[k + 2];
+            currentY += val[k + 3];
+            break;
+          case 'Q': // Draws a quadratic Bézier
+            path.quadTo(val[k + 0], val[k + 1], val[k + 2], val[k + 3]);
+            ctrlPointX = val[k + 0];
+            ctrlPointY = val[k + 1];
+            currentX = val[k + 2];
+            currentY = val[k + 3];
+            break;
+          case 't': // Draws a quadratic Bézier curve(reflective control point)(relative)
+            reflectiveCtrlPointX = 0;
+            reflectiveCtrlPointY = 0;
+            if (previousCmd == 'q'
+                || previousCmd == 't'
+                || previousCmd == 'Q'
+                || previousCmd == 'T') {
+              reflectiveCtrlPointX = currentX - ctrlPointX;
+              reflectiveCtrlPointY = currentY - ctrlPointY;
+            }
+            path.rQuadTo(reflectiveCtrlPointX, reflectiveCtrlPointY, val[k + 0], val[k + 1]);
+            ctrlPointX = currentX + reflectiveCtrlPointX;
+            ctrlPointY = currentY + reflectiveCtrlPointY;
+            currentX += val[k + 0];
+            currentY += val[k + 1];
+            break;
+          case 'T': // Draws a quadratic Bézier curve (reflective control point)
+            reflectiveCtrlPointX = currentX;
+            reflectiveCtrlPointY = currentY;
+            if (previousCmd == 'q'
+                || previousCmd == 't'
+                || previousCmd == 'Q'
+                || previousCmd == 'T') {
+              reflectiveCtrlPointX = 2 * currentX - ctrlPointX;
+              reflectiveCtrlPointY = 2 * currentY - ctrlPointY;
+            }
+            path.quadTo(reflectiveCtrlPointX, reflectiveCtrlPointY, val[k + 0], val[k + 1]);
+            ctrlPointX = reflectiveCtrlPointX;
+            ctrlPointY = reflectiveCtrlPointY;
+            currentX = val[k + 0];
+            currentY = val[k + 1];
+            break;
+          case 'a': // Draws an elliptical arc
+            // (rx ry x-axis-rotation large-arc-flag sweep-flag x y)
+            drawArc(
+                path,
+                currentX,
+                currentY,
+                val[k + 5] + currentX,
+                val[k + 6] + currentY,
+                val[k + 0],
+                val[k + 1],
+                val[k + 2],
+                val[k + 3] != 0,
+                val[k + 4] != 0);
+            currentX += val[k + 5];
+            currentY += val[k + 6];
+            ctrlPointX = currentX;
+            ctrlPointY = currentY;
+            break;
+          case 'A': // Draws an elliptical arc
+            drawArc(
+                path,
+                currentX,
+                currentY,
+                val[k + 5],
+                val[k + 6],
+                val[k + 0],
+                val[k + 1],
+                val[k + 2],
+                val[k + 3] != 0,
+                val[k + 4] != 0);
+            currentX = val[k + 5];
+            currentY = val[k + 6];
+            ctrlPointX = currentX;
+            ctrlPointY = currentY;
+            break;
+        }
+        previousCmd = cmd;
+      }
+      current[0] = currentX;
+      current[1] = currentY;
+      current[2] = ctrlPointX;
+      current[3] = ctrlPointY;
+    }
+
+    private static void drawArc(
+        Path p,
+        float x0,
+        float y0,
+        float x1,
+        float y1,
+        float a,
+        float b,
+        float theta,
+        boolean isMoreThanHalf,
+        boolean isPositiveArc) {
+
+      /* Convert rotation angle from degrees to radians */
+      double thetaD = Math.toRadians(theta);
+      /* Pre-compute rotation matrix entries */
+      double cosTheta = Math.cos(thetaD);
+      double sinTheta = Math.sin(thetaD);
+      /* Transform (x0, y0) and (x1, y1) into unit space */
+      /* using (inverse) rotation, followed by (inverse) scale */
+      double x0p = (x0 * cosTheta + y0 * sinTheta) / a;
+      double y0p = (-x0 * sinTheta + y0 * cosTheta) / b;
+      double x1p = (x1 * cosTheta + y1 * sinTheta) / a;
+      double y1p = (-x1 * sinTheta + y1 * cosTheta) / b;
+
+      /* Compute differences and averages */
+      double dx = x0p - x1p;
+      double dy = y0p - y1p;
+      double xm = (x0p + x1p) / 2;
+      double ym = (y0p + y1p) / 2;
+      /* Solve for intersecting unit circles */
+      double dsq = dx * dx + dy * dy;
+      if (dsq == 0.0) {
+        Log.w(LOGTAG, " Points are coincident");
+        return; /* Points are coincident */
+      }
+      double disc = 1.0 / dsq - 1.0 / 4.0;
+      if (disc < 0.0) {
+        Log.w(LOGTAG, "Points are too far apart " + dsq);
+        float adjust = (float) (Math.sqrt(dsq) / 1.99999);
+        drawArc(p, x0, y0, x1, y1, a * adjust, b * adjust, theta, isMoreThanHalf, isPositiveArc);
+        return; /* Points are too far apart */
+      }
+      double s = Math.sqrt(disc);
+      double sdx = s * dx;
+      double sdy = s * dy;
+      double cx;
+      double cy;
+      if (isMoreThanHalf == isPositiveArc) {
+        cx = xm - sdy;
+        cy = ym + sdx;
+      } else {
+        cx = xm + sdy;
+        cy = ym - sdx;
+      }
+
+      double eta0 = Math.atan2((y0p - cy), (x0p - cx));
+
+      double eta1 = Math.atan2((y1p - cy), (x1p - cx));
+
+      double sweep = (eta1 - eta0);
+      if (isPositiveArc != (sweep >= 0)) {
+        if (sweep > 0) {
+          sweep -= 2 * Math.PI;
+        } else {
+          sweep += 2 * Math.PI;
+        }
+      }
+
+      cx *= a;
+      cy *= b;
+      double tcx = cx;
+      cx = cx * cosTheta - cy * sinTheta;
+      cy = tcx * sinTheta + cy * cosTheta;
+
+      arcToBezier(p, cx, cy, a, b, x0, y0, thetaD, eta0, sweep);
+    }
+
+    /**
+     * Converts an arc to cubic Bezier segments and records them in p.
+     *
+     * @param p The target for the cubic Bezier segments
+     * @param cx The x coordinate center of the ellipse
+     * @param cy The y coordinate center of the ellipse
+     * @param a The radius of the ellipse in the horizontal direction
+     * @param b The radius of the ellipse in the vertical direction
+     * @param e1x E(eta1) x coordinate of the starting point of the arc
+     * @param e1y E(eta2) y coordinate of the starting point of the arc
+     * @param theta The angle that the ellipse bounding rectangle makes with horizontal plane
+     * @param start The start angle of the arc on the ellipse
+     * @param sweep The angle (positive or negative) of the sweep of the arc on the ellipse
+     */
+    private static void arcToBezier(
+        Path p,
+        double cx,
+        double cy,
+        double a,
+        double b,
+        double e1x,
+        double e1y,
+        double theta,
+        double start,
+        double sweep) {
+      // Taken from equations at: http://spaceroots.org/documents/ellipse/node8.html
+      // and http://www.spaceroots.org/documents/ellipse/node22.html
+
+      // Maximum of 45 degrees per cubic Bezier segment
+      int numSegments = Math.abs((int) Math.ceil(sweep * 4 / Math.PI));
+
+      double eta1 = start;
+      double cosTheta = Math.cos(theta);
+      double sinTheta = Math.sin(theta);
+      double cosEta1 = Math.cos(eta1);
+      double sinEta1 = Math.sin(eta1);
+      double ep1x = (-a * cosTheta * sinEta1) - (b * sinTheta * cosEta1);
+      double ep1y = (-a * sinTheta * sinEta1) + (b * cosTheta * cosEta1);
+
+      double anglePerSegment = sweep / numSegments;
+      for (int i = 0; i < numSegments; i++) {
+        double eta2 = eta1 + anglePerSegment;
+        double sinEta2 = Math.sin(eta2);
+        double cosEta2 = Math.cos(eta2);
+        double e2x = cx + (a * cosTheta * cosEta2) - (b * sinTheta * sinEta2);
+        double e2y = cy + (a * sinTheta * cosEta2) + (b * cosTheta * sinEta2);
+        double ep2x = -a * cosTheta * sinEta2 - b * sinTheta * cosEta2;
+        double ep2y = -a * sinTheta * sinEta2 + b * cosTheta * cosEta2;
+        double tanDiff2 = Math.tan((eta2 - eta1) / 2);
+        double alpha = Math.sin(eta2 - eta1) * (Math.sqrt(4 + (3 * tanDiff2 * tanDiff2)) - 1) / 3;
+        double q1x = e1x + alpha * ep1x;
+        double q1y = e1y + alpha * ep1y;
+        double q2x = e2x - alpha * ep2x;
+        double q2y = e2y - alpha * ep2y;
+
+        p.cubicTo((float) q1x, (float) q1y, (float) q2x, (float) q2y, (float) e2x, (float) e2y);
+        eta1 = eta2;
+        e1x = e2x;
+        e1y = e2y;
+        ep1x = ep2x;
+        ep1y = ep2y;
+      }
+    }
+  }
+
+  @Implementation
+  protected static long nCreatePathDataFromString(String pathString, int stringLength) {
+    return 1;
+  }
+
+  @Implementation
+  protected static long nCreateEmptyPathData() {
+    return 1;
+  }
+
+  @Implementation
+  protected static long nCreatePathData(long nativePtr) {
+    return 1;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedAsyncTask.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedAsyncTask.java
new file mode 100644
index 0000000..1de1f93
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedAsyncTask.java
@@ -0,0 +1,75 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.AsyncTask;
+import androidx.test.annotation.Beta;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * A {@link AsyncTask} shadow for {@link LooperMode.Mode.PAUSED}
+ *
+ * <p>This is beta API, and will likely be renamed/removed in a future Robolectric release.
+ */
+@Implements(
+    value = AsyncTask.class,
+    shadowPicker = ShadowAsyncTask.Picker.class,
+    // TODO: turn off shadowOf generation. Figure out why this is needed
+    isInAndroidSdk = false)
+@Beta
+public class ShadowPausedAsyncTask<Params, Progress, Result> extends ShadowAsyncTask {
+
+  private static Executor executorOverride = null;
+
+  @RealObject private AsyncTask<Params, Progress, Result> realObject;
+
+  @Resetter
+  public static void reset() {
+    executorOverride = null;
+  }
+
+  @Implementation
+  protected AsyncTask<Params, Progress, Result> executeOnExecutor(Executor exec, Params... params) {
+    Executor executorToUse = executorOverride == null ? exec : executorOverride;
+    return reflector(AsyncTaskReflector.class, realObject).executeOnExecutor(executorToUse, params);
+  }
+
+  private ClassParameter[] buildClassParams(Params... params) {
+    ClassParameter[] classParameters = new ClassParameter[params.length];
+    for (int i = 0; i < params.length; i++) {
+      classParameters[i] = ClassParameter.from(Object.class, params[i]);
+    }
+    return classParameters;
+  }
+
+  /**
+   * Globally override the executor used for all AsyncTask#execute* calls.
+   *
+   * <p>This can be useful if you want to use a more determinstic executor for tests, like {@link
+   * org.robolectric.android.util.concurrent.PausedExecutorService} or {@link
+   * org.robolectric.android.util.concurrent.InlineExecutorService}.
+   *
+   * <p>Use this API as a last resort. Its recommended instead to use dependency injection to
+   * provide a custom executor to AsyncTask#executeOnExecutor.
+   *
+   * <p>Beta API, may be removed or changed in a future Robolectric release
+   */
+  @Beta
+  public static void overrideExecutor(Executor executor) {
+    executorOverride = executor;
+  }
+
+  @ForType(AsyncTask.class)
+  interface AsyncTaskReflector {
+
+    @Direct
+    AsyncTask executeOnExecutor(Executor executorToUse, Object[] params);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedAsyncTaskLoader.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedAsyncTaskLoader.java
new file mode 100644
index 0000000..f7e6450
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedAsyncTaskLoader.java
@@ -0,0 +1,45 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.AsyncTaskLoader;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * The shadow {@link AsyncTaskLoader} for {@link LooperMode.Mode.PAUSED}.
+ *
+ * <p>In {@link LooperMode.Mode.PAUSED} mode, Robolectric just uses the real AsyncTaskLoader for
+ * now.
+ */
+@Implements(
+    value = AsyncTaskLoader.class,
+    shadowPicker = ShadowAsyncTaskLoader.Picker.class,
+    // TODO: turn off shadowOf generation. Figure out why this is needed
+    isInAndroidSdk = false)
+public class ShadowPausedAsyncTaskLoader<D> extends ShadowAsyncTaskLoader<D> {
+
+  @RealObject private AsyncTaskLoader<D> realObject;
+
+  /**
+   * Allows overriding background executor used by the AsyncLoader.
+   *
+   * @deprecated It is recommended to switch to androidx's AsyncTaskLoader, which provides an
+   *     overridable getExecutor method.
+   */
+  @Deprecated
+  public void setExecutor(Executor executor) {
+    reflector(ReflectorAsyncTaskLoader.class, realObject).setExecutor(executor);
+  }
+
+  /** Accessor interface for {@link android.content.AsyncTaskLoader}'s internals. */
+  @ForType(AsyncTaskLoader.class)
+  private interface ReflectorAsyncTaskLoader {
+    @Accessor("mExecutor")
+    void setExecutor(Executor executor);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedChoreographer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedChoreographer.java
new file mode 100644
index 0000000..8a0a949
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedChoreographer.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.Choreographer;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * A {@link Choreographer} shadow for {@link LooperMode.Mode.PAUSED}.
+ *
+ * <p>This shadow is largely a no-op. In {@link LooperMode.Mode.PAUSED} mode, the shadowing is done
+ * at a lower level via {@link ShadowDisplayEventReceiver}.
+ *
+ * <p>This class should not be referenced directly - use {@link ShadowChoreographer} instead.
+ */
+@Implements(
+    value = Choreographer.class,
+    shadowPicker = ShadowChoreographer.Picker.class,
+    isInAndroidSdk = false)
+public class ShadowPausedChoreographer extends ShadowChoreographer {
+
+  @Resetter
+  public static void reset() {
+    reflector(ChoreographerReflector.class).getThreadInstance().remove();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java
new file mode 100644
index 0000000..df4e15b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java
@@ -0,0 +1,485 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.Preconditions.checkState;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue.IdleHandler;
+import android.os.SystemClock;
+import android.util.Log;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * The shadow Looper for {@link LooperMode.Mode.PAUSED}.
+ *
+ * <p>This shadow differs from the legacy {@link ShadowLegacyLooper} in the following ways:\ - Has
+ * no connection to {@link org.robolectric.util.Scheduler}. Its APIs are standalone - The main
+ * looper is always paused. Posted messages are not executed unless {@link #idle()} is called. -
+ * Just like in real Android, each looper has its own thread, and posted tasks get executed in that
+ * thread. - - There is only a single {@link SystemClock} value that all loopers read from. Unlike
+ * legacy behavior where each {@link org.robolectric.util.Scheduler} kept their own clock value.
+ *
+ * <p>This class should not be used directly; use {@link ShadowLooper} instead.
+ */
+@Implements(
+    value = Looper.class,
+    // turn off shadowOf generation.
+    isInAndroidSdk = false)
+@SuppressWarnings("NewApi")
+public final class ShadowPausedLooper extends ShadowLooper {
+
+  // Keep reference to all created Loopers so they can be torn down after test
+  private static Set<Looper> loopingLoopers =
+      Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<Looper, Boolean>()));
+
+  @RealObject private Looper realLooper;
+  private boolean isPaused = false;
+  // the Executor that executes looper messages. Must be written to on looper thread
+  private Executor looperExecutor;
+
+  @Implementation
+  protected void __constructor__(boolean quitAllowed) {
+    invokeConstructor(Looper.class, realLooper, from(boolean.class, quitAllowed));
+
+    loopingLoopers.add(realLooper);
+    looperExecutor = new HandlerExecutor(new Handler(realLooper));
+  }
+
+  protected static Collection<Looper> getLoopers() {
+    List<Looper> loopers = new ArrayList<>(loopingLoopers);
+    return Collections.unmodifiableCollection(loopers);
+  }
+
+  @Override
+  public void quitUnchecked() {
+    throw new UnsupportedOperationException(
+        "this action is not" + " supported" + " in " + looperMode() + " mode.");
+  }
+
+  @Override
+  public boolean hasQuit() {
+    throw new UnsupportedOperationException(
+        "this action is not" + " supported" + " in " + looperMode() + " mode.");
+  }
+
+  @Override
+  public void idle() {
+    executeOnLooper(new IdlingRunnable());
+  }
+
+  @Override
+  public void idleFor(long time, TimeUnit timeUnit) {
+    long endingTimeMs = SystemClock.uptimeMillis() + timeUnit.toMillis(time);
+    long nextScheduledTimeMs = getNextScheduledTaskTime().toMillis();
+    while (nextScheduledTimeMs != 0 && nextScheduledTimeMs <= endingTimeMs) {
+      SystemClock.setCurrentTimeMillis(nextScheduledTimeMs);
+      idle();
+      nextScheduledTimeMs = getNextScheduledTaskTime().toMillis();
+    }
+    SystemClock.setCurrentTimeMillis(endingTimeMs);
+    // the last SystemClock update might have added new tasks to the main looper via Choreographer
+    // so idle once more.
+    idle();
+  }
+
+  @Override
+  public boolean isIdle() {
+    if (Thread.currentThread() == realLooper.getThread() || isPaused) {
+      return shadowQueue().isIdle();
+    } else {
+      return shadowQueue().isIdle() && shadowQueue().isPolling();
+    }
+  }
+
+  @Override
+  public void unPause() {
+    if (realLooper == Looper.getMainLooper()) {
+      throw new UnsupportedOperationException("main looper cannot be unpaused");
+    }
+    executeOnLooper(new UnPauseRunnable());
+  }
+
+  @Override
+  public void pause() {
+    if (!isPaused()) {
+      executeOnLooper(new PausedLooperExecutor());
+    }
+  }
+
+  @Override
+  public boolean isPaused() {
+    return isPaused;
+  }
+
+  @Override
+  public boolean setPaused(boolean shouldPause) {
+    if (shouldPause) {
+      pause();
+    } else {
+      unPause();
+    }
+    return true;
+  }
+
+  @Override
+  public void resetScheduler() {
+    throw new UnsupportedOperationException(
+        "this action is not" + " supported" + " in " + looperMode() + " mode.");
+  }
+
+  @Override
+  public void reset() {
+    throw new UnsupportedOperationException(
+        "this action is not" + " supported" + " in " + looperMode() + " mode.");
+  }
+
+  @Override
+  public void idleIfPaused() {
+    idle();
+  }
+
+  @Override
+  public void idleConstantly(boolean shouldIdleConstantly) {
+    throw new UnsupportedOperationException(
+        "this action is not" + " supported" + " in " + looperMode() + " mode.");
+  }
+
+  @Override
+  public void runToEndOfTasks() {
+    idleFor(Duration.ofMillis(getLastScheduledTaskTime().toMillis() - SystemClock.uptimeMillis()));
+  }
+
+  @Override
+  public void runToNextTask() {
+    idleFor(Duration.ofMillis(getNextScheduledTaskTime().toMillis() - SystemClock.uptimeMillis()));
+  }
+
+  @Override
+  public void runOneTask() {
+    executeOnLooper(new RunOneRunnable());
+  }
+
+  @Override
+  public boolean post(Runnable runnable, long delayMillis) {
+    return new Handler(realLooper).postDelayed(runnable, delayMillis);
+  }
+
+  @Override
+  public boolean postAtFrontOfQueue(Runnable runnable) {
+    return new Handler(realLooper).postAtFrontOfQueue(runnable);
+  }
+
+  // this API doesn't make sense in LooperMode.PAUSED, but just retain it for backwards
+  // compatibility for now
+  @Override
+  public void runPaused(Runnable runnable) {
+    if (isPaused && Thread.currentThread() == realLooper.getThread()) {
+      // just run
+      runnable.run();
+    } else {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  /**
+   * Polls the message queue waiting until a message is posted to the head of the queue. This will
+   * suspend the thread until a new message becomes available. Returns immediately if the queue is
+   * not idle. There's no guarantee that the message queue will not still be idle when returning,
+   * but if the message queue becomes not idle it will return immediately.
+   *
+   * <p>This method is only applicable for the main looper's queue when called on the main thread,
+   * as the main looper in Robolectric is processed manually (it doesn't loop)--looper threads are
+   * using the native polling of their loopers. Throws an exception if called for another looper's
+   * queue. Non-main thread loopers should use {@link #unPause()}.
+   *
+   * <p>This should be used with care, it can be used to suspend the main (i.e. test) thread while
+   * worker threads perform some work, and then resumed by posting to the main looper. Used in a
+   * loop to wait on some condition it can process messages on the main looper, simulating the
+   * behavior of the real looper, for example:
+   *
+   * <pre>{@code
+   * while (!condition) {
+   *   shadowMainLooper.poll(timeout);
+   *   shadowMainLooper.idle();
+   * }
+   * }</pre>
+   *
+   * <p>Beware though that a message must be posted to the main thread after the condition is
+   * satisfied, or the condition satisfied while idling the main thread, otherwise the main thread
+   * will continue to be suspended until the timeout.
+   *
+   * @param timeout Timeout in milliseconds, the maximum time to wait before returning, or 0 to wait
+   *     indefinitely,
+   */
+  public void poll(long timeout) {
+    checkState(Looper.myLooper() == Looper.getMainLooper() && Looper.myLooper() == realLooper);
+    shadowQueue().poll(timeout);
+  }
+
+  @Override
+  public Duration getNextScheduledTaskTime() {
+    return shadowQueue().getNextScheduledTaskTime();
+  }
+
+  @Override
+  public Duration getLastScheduledTaskTime() {
+    return shadowQueue().getLastScheduledTaskTime();
+  }
+
+  @Resetter
+  public static synchronized void resetLoopers() {
+    // do not use looperMode() here, because its cached value might already have been reset
+    if (ConfigurationRegistry.get(LooperMode.Mode.class) != LooperMode.Mode.PAUSED) {
+      // ignore if not realistic looper
+      return;
+    }
+
+    Collection<Looper> loopersCopy = new ArrayList(loopingLoopers);
+    for (Looper looper : loopersCopy) {
+      ShadowPausedMessageQueue shadowQueue = Shadow.extract(looper.getQueue());
+      shadowQueue.reset();
+    }
+  }
+
+  @Implementation
+  protected static void prepareMainLooper() {
+    reflector(LooperReflector.class).prepareMainLooper();
+    ShadowPausedLooper pausedLooper = Shadow.extract(Looper.getMainLooper());
+    pausedLooper.isPaused = true;
+  }
+
+  @Implementation
+  protected void quit() {
+    if (isPaused()) {
+      executeOnLooper(new UnPauseRunnable());
+    }
+    reflector(LooperReflector.class, realLooper).quit();
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR2)
+  protected void quitSafely() {
+    if (isPaused()) {
+      executeOnLooper(new UnPauseRunnable());
+    }
+    reflector(LooperReflector.class, realLooper).quitSafely();
+  }
+
+  @Override
+  public Scheduler getScheduler() {
+    throw new UnsupportedOperationException(
+        String.format("this action is not supported in %s mode.", looperMode()));
+  }
+
+  private static ShadowPausedMessage shadowMsg(Message msg) {
+    return Shadow.extract(msg);
+  }
+
+  private ShadowPausedMessageQueue shadowQueue() {
+    return Shadow.extract(realLooper.getQueue());
+  }
+
+  private void setLooperExecutor(Executor executor) {
+    looperExecutor = executor;
+  }
+
+  /** Retrieves the next message or null if the queue is idle. */
+  private Message getNextExecutableMessage() {
+    synchronized (realLooper.getQueue()) {
+      // Use null if the queue is idle, otherwise getNext() will block.
+      return shadowQueue().isIdle() ? null : shadowQueue().getNext();
+    }
+  }
+
+  /**
+   * If the given {@code lastMessageRead} is not null and the queue is now idle, get the idle
+   * handlers and run them. This synchronization mirrors what happens in the real message queue
+   * next() method, but does not block after running the idle handlers.
+   */
+  private void triggerIdleHandlersIfNeeded(Message lastMessageRead) {
+    List<IdleHandler> idleHandlers;
+    // Mirror the synchronization of MessageQueue.next(). If a message was read on the last call
+    // to next() and the queue is now idle, make a copy of the idle handlers and release the lock.
+    // Run the idle handlers without holding the lock, removing those that return false from their
+    // queueIdle() method.
+    synchronized (realLooper.getQueue()) {
+      if (lastMessageRead == null || !shadowQueue().isIdle()) {
+        return;
+      }
+      idleHandlers = shadowQueue().getIdleHandlersCopy();
+    }
+    for (IdleHandler idleHandler : idleHandlers) {
+      if (!idleHandler.queueIdle()) {
+        // This method already has synchronization internally.
+        realLooper.getQueue().removeIdleHandler(idleHandler);
+      }
+    }
+  }
+
+  /** A runnable that changes looper state, and that must be run from looper's thread */
+  private abstract static class ControlRunnable implements Runnable {
+
+    protected final CountDownLatch runLatch = new CountDownLatch(1);
+
+    public void waitTillComplete() {
+      try {
+        runLatch.await();
+      } catch (InterruptedException e) {
+        Log.w("ShadowPausedLooper", "wait till idle interrupted");
+      }
+    }
+  }
+
+  private class IdlingRunnable extends ControlRunnable {
+
+    @Override
+    public void run() {
+      try {
+        while (true) {
+          Message msg = getNextExecutableMessage();
+          if (msg == null) {
+            break;
+          }
+          msg.getTarget().dispatchMessage(msg);
+          shadowMsg(msg).recycleUnchecked();
+          triggerIdleHandlersIfNeeded(msg);
+        }
+      } finally {
+        runLatch.countDown();
+      }
+    }
+  }
+
+  private class RunOneRunnable extends ControlRunnable {
+
+    @Override
+    public void run() {
+      try {
+        Message msg = shadowQueue().getNextIgnoringWhen();
+        if (msg != null) {
+          SystemClock.setCurrentTimeMillis(shadowMsg(msg).getWhen());
+          msg.getTarget().dispatchMessage(msg);
+          triggerIdleHandlersIfNeeded(msg);
+        }
+      } finally {
+        runLatch.countDown();
+      }
+    }
+  }
+
+  /** Executes the given runnable on the loopers thread, and waits for it to complete. */
+  private void executeOnLooper(ControlRunnable runnable) {
+    if (Thread.currentThread() == realLooper.getThread()) {
+      if (runnable instanceof UnPauseRunnable) {
+        // Need to trigger the unpause action in PausedLooperExecutor
+        looperExecutor.execute(runnable);
+      } else {
+        runnable.run();
+      }
+    } else {
+      if (realLooper.equals(Looper.getMainLooper())) {
+        throw new UnsupportedOperationException(
+            "main looper can only be controlled from main thread");
+      }
+      looperExecutor.execute(runnable);
+      runnable.waitTillComplete();
+    }
+  }
+
+  /**
+   * A runnable that will block normal looper execution of messages aka will 'pause' the looper.
+   *
+   * <p>Message execution can be triggered by posting messages to this runnable.
+   */
+  private class PausedLooperExecutor extends ControlRunnable implements Executor {
+
+    private final LinkedBlockingQueue<Runnable> executionQueue = new LinkedBlockingQueue<>();
+
+    @Override
+    public void execute(Runnable runnable) {
+      executionQueue.add(runnable);
+    }
+
+    @Override
+    public void run() {
+      setLooperExecutor(this);
+      isPaused = true;
+      runLatch.countDown();
+      while (true) {
+        try {
+          Runnable runnable = executionQueue.take();
+          runnable.run();
+          if (runnable instanceof UnPauseRunnable) {
+            setLooperExecutor(new HandlerExecutor(new Handler(realLooper)));
+            return;
+          }
+        } catch (InterruptedException e) {
+          // ignore
+        }
+      }
+    }
+  }
+
+  private class UnPauseRunnable extends ControlRunnable {
+    @Override
+    public void run() {
+      isPaused = false;
+      runLatch.countDown();
+    }
+  }
+
+  private static class HandlerExecutor implements Executor {
+    private final Handler handler;
+
+    private HandlerExecutor(Handler handler) {
+      this.handler = handler;
+    }
+
+    @Override
+    public void execute(Runnable runnable) {
+      if (!handler.post(runnable)) {
+        throw new IllegalStateException(
+            String.format("post to %s failed. Is handler thread dead?", handler));
+      }
+    }
+  }
+
+  @ForType(Looper.class)
+  interface LooperReflector {
+
+    @Static
+    @Direct
+    void prepareMainLooper();
+
+    @Direct
+    void quit();
+
+    @Direct
+    void quitSafely();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessage.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessage.java
new file mode 100644
index 0000000..2174acf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessage.java
@@ -0,0 +1,66 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build;
+import android.os.Handler;
+import android.os.Message;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+
+/**
+ * The shadow {@link Message} for {@link LooperMode.Mode.PAUSED}.
+ *
+ * <p>This class should not be referenced directly. Use {@link ShadowMessage} instead.
+ */
+@Implements(value = Message.class, isInAndroidSdk = false)
+public class ShadowPausedMessage extends ShadowMessage {
+
+  @RealObject private Message realObject;
+
+  @Implementation
+  protected long getWhen() {
+    return reflector(MessageReflector.class, realObject).getWhen();
+  }
+
+  Message internalGetNext() {
+    return reflector(MessageReflector.class, realObject).getNext();
+  }
+
+  // TODO: reconsider this being exposed as a public method
+  @Override
+  @Implementation(minSdk = LOLLIPOP)
+  public void recycleUnchecked() {
+    if (Build.VERSION.SDK_INT >= LOLLIPOP) {
+      reflector(MessageReflector.class, realObject).recycleUnchecked();
+    } else {
+      reflector(MessageReflector.class, realObject).recycle();
+    }
+  }
+
+  @Override
+  public void setScheduledRunnable(Runnable r) {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode");
+  }
+
+  // we could support these methods, but intentionally do not for now as its unclear what the
+  // use case is.
+
+  @Override
+  public Message getNext() {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode");
+  }
+
+  @Override
+  public void setNext(Message next) {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode");
+  }
+
+  @Implementation
+  protected Handler getTarget() {
+    return reflector(MessageReflector.class, realObject).getTarget();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java
new file mode 100644
index 0000000..8ddcc7b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java
@@ -0,0 +1,421 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.base.Preconditions.checkState;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.MessageQueue.IdleHandler;
+import android.os.SystemClock;
+import java.time.Duration;
+import java.util.ArrayList;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.res.android.NativeObjRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Scheduler;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * The shadow {@link} MessageQueue} for {@link LooperMode.Mode.PAUSED}
+ *
+ * <p>This class should not be referenced directly. Use {@link ShadowMessageQueue} instead.
+ */
+@SuppressWarnings("SynchronizeOnNonFinalField")
+@Implements(value = MessageQueue.class, isInAndroidSdk = false, looseSignatures = true)
+public class ShadowPausedMessageQueue extends ShadowMessageQueue {
+
+  @RealObject private MessageQueue realQueue;
+
+  // just use this class as the native object
+  private static NativeObjRegistry<ShadowPausedMessageQueue> nativeQueueRegistry =
+      new NativeObjRegistry<ShadowPausedMessageQueue>(ShadowPausedMessageQueue.class);
+  private boolean isPolling = false;
+  private ShadowPausedSystemClock.Listener clockListener;
+
+  // shadow constructor instead of nativeInit because nativeInit signature has changed across SDK
+  // versions
+  @Implementation
+  protected void __constructor__(boolean quitAllowed) {
+    invokeConstructor(MessageQueue.class, realQueue, from(boolean.class, quitAllowed));
+    int ptr = (int) nativeQueueRegistry.register(this);
+    reflector(MessageQueueReflector.class, realQueue).setPtr(ptr);
+    clockListener = () -> nativeWake(ptr);
+    ShadowPausedSystemClock.addListener(clockListener);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected void nativeDestroy() {
+    nativeDestroy(reflector(MessageQueueReflector.class, realQueue).getPtr());
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT)
+  protected static void nativeDestroy(int ptr) {
+    nativeDestroy((long) ptr);
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected static void nativeDestroy(long ptr) {
+    ShadowPausedMessageQueue q = nativeQueueRegistry.unregister(ptr);
+    ShadowPausedSystemClock.removeListener(q.clockListener);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected void nativePollOnce(int ptr, int timeoutMillis) {
+    nativePollOnce((long) ptr, timeoutMillis);
+  }
+
+  // use the generic Object parameter types here, to avoid conflicts with the non-static
+  // nativePollOnce
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = LOLLIPOP_MR1)
+  protected static void nativePollOnce(Object ptr, Object timeoutMillis) {
+    long ptrLong = getLong(ptr);
+    nativeQueueRegistry.getNativeObject(ptrLong).nativePollOnce(ptrLong, (int) timeoutMillis);
+  }
+
+  @Implementation(minSdk = M)
+  protected void nativePollOnce(long ptr, int timeoutMillis) {
+    if (timeoutMillis == 0) {
+      return;
+    }
+    synchronized (realQueue) {
+      // only block if queue is empty
+      // ignore timeout since clock is not advancing. ClockListener will notify when clock advances
+      while (isIdle() && !isQuitting()) {
+        isPolling = true;
+        try {
+          realQueue.wait();
+        } catch (InterruptedException e) {
+          // ignore
+        }
+      }
+      isPolling = false;
+    }
+  }
+
+  /**
+   * Polls the message queue waiting until a message is posted to the head of the queue. This will
+   * suspend the thread until a new message becomes available. Returns immediately if the queue is
+   * not idle. There's no guarantee that the message queue will not still be idle when returning,
+   * but if the message queue becomes not idle it will return immediately.
+   *
+   * <p>See {@link ShadowPausedLooper#poll(long)} for more information.
+   *
+   * @param timeout Timeout in milliseconds, the maximum time to wait before returning, or 0 to wait
+   *     indefinitely,
+   */
+  void poll(long timeout) {
+    checkState(Looper.myLooper() == Looper.getMainLooper() && Looper.myQueue() == realQueue);
+    // Message queue typically expects the looper to loop calling next() which returns current
+    // messages from the head of the queue. If no messages are current it will mark itself blocked
+    // and call nativePollOnce (see above) which suspends the thread until the next message's time.
+    // When messages are posted to the queue, if a new message is posted to the head and the queue
+    // is marked as blocked, then the enqueue function will notify and resume next(), allowing it
+    // return the next message. To simulate this behavior check if the queue is idle and if it is
+    // mark the queue as blocked and wait on a new message.
+    synchronized (realQueue) {
+      if (isIdle()) {
+        ReflectionHelpers.setField(realQueue, "mBlocked", true);
+        try {
+          realQueue.wait(timeout);
+        } catch (InterruptedException ignored) {
+          // Fall through and unblock with no messages.
+        } finally {
+          ReflectionHelpers.setField(realQueue, "mBlocked", false);
+        }
+      }
+    }
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected void nativeWake(int ptr) {
+    synchronized (realQueue) {
+      realQueue.notifyAll();
+    }
+  }
+
+  // use the generic Object parameter types here, to avoid conflicts with the non-static
+  // nativeWake
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT)
+  protected static void nativeWake(Object ptr) {
+    // JELLY_BEAN_MR2 has a bug where nativeWake can get called when pointer has already been
+    // destroyed. See here where nativeWake is called outside the synchronized block
+    // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/jb-mr2-release/core/java/android/os/MessageQueue.java#239
+    // So check to see if native object exists first
+    ShadowPausedMessageQueue q = nativeQueueRegistry.peekNativeObject(getLong(ptr));
+    if (q != null) {
+      q.nativeWake(getInt(ptr));
+    }
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected static void nativeWake(long ptr) {
+    nativeQueueRegistry.getNativeObject(ptr).nativeWake((int) ptr);
+  }
+
+  @Implementation(minSdk = M)
+  protected static boolean nativeIsPolling(long ptr) {
+    return nativeQueueRegistry.getNativeObject(ptr).isPolling;
+  }
+
+  /** Exposes the API23+_isIdle method to older platforms */
+  @Implementation(minSdk = 23)
+  public boolean isIdle() {
+    synchronized (realQueue) {
+      Message msg = peekNextExecutableMessage();
+      if (msg == null) {
+          return true;
+      }
+
+      long now = SystemClock.uptimeMillis();
+      long when = shadowOfMsg(msg).getWhen();
+      return now < when;
+    }
+  }
+
+  Message peekNextExecutableMessage() {
+    MessageQueueReflector internalQueue = reflector(MessageQueueReflector.class, realQueue);
+    Message msg = internalQueue.getMessages();
+
+    if (msg != null && shadowOfMsg(msg).getTarget() == null) {
+      // Stalled by a barrier.  Find the next asynchronous message in the queue.
+      do {
+        msg = shadowOfMsg(msg).internalGetNext();
+      } while (msg != null && !msg.isAsynchronous());
+    }
+
+    return msg;
+  }
+
+  Message getNext() {
+    return reflector(MessageQueueReflector.class, realQueue).next();
+  }
+
+  boolean isQuitAllowed() {
+    return reflector(MessageQueueReflector.class, realQueue).getQuitAllowed();
+  }
+
+  void doEnqueueMessage(Message msg, long when) {
+    reflector(MessageQueueReflector.class, realQueue).enqueueMessage(msg, when);
+  }
+
+  Message getMessages() {
+    return reflector(MessageQueueReflector.class, realQueue).getMessages();
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean isPolling() {
+    synchronized (realQueue) {
+      return isPolling;
+    }
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected void quit() {
+    if (RuntimeEnvironment.getApiLevel() >= JELLY_BEAN_MR2) {
+      reflector(MessageQueueReflector.class, realQueue).quit(false);
+    } else {
+      reflector(MessageQueueReflector.class, realQueue).quit();
+    }
+  }
+
+  private boolean isQuitting() {
+    if (RuntimeEnvironment.getApiLevel() >= KITKAT) {
+      return reflector(MessageQueueReflector.class, realQueue).getQuitting();
+    } else {
+      return reflector(MessageQueueReflector.class, realQueue).getQuiting();
+    }
+  }
+
+  private static long getLong(Object intOrLongObj) {
+    if (intOrLongObj instanceof Long) {
+      return (long) intOrLongObj;
+    } else {
+      Integer intObj = (Integer) intOrLongObj;
+      return intObj.longValue();
+    }
+  }
+
+  private static int getInt(Object intOrLongObj) {
+    if (intOrLongObj instanceof Integer) {
+      return (int) intOrLongObj;
+    } else {
+      Long longObj = (Long) intOrLongObj;
+      return longObj.intValue();
+    }
+  }
+
+  Duration getNextScheduledTaskTime() {
+    Message next = peekNextExecutableMessage();
+
+    if (next == null) {
+      return Duration.ZERO;
+    }
+    return Duration.ofMillis(convertWhenToScheduledTime(shadowOfMsg(next).getWhen()));
+  }
+
+  Duration getLastScheduledTaskTime() {
+    long when = 0;
+    synchronized (realQueue) {
+      Message next = getMessages();
+      if (next == null) {
+        return Duration.ZERO;
+      }
+      while (next != null) {
+        when = shadowOfMsg(next).getWhen();
+        next = shadowOfMsg(next).internalGetNext();
+      }
+    }
+    return Duration.ofMillis(convertWhenToScheduledTime(when));
+  }
+
+  private static long convertWhenToScheduledTime(long when) {
+    // in some situations, when can be 0 or less than uptimeMillis. Always floor it to at least
+    // convertWhenToUptime
+    if (when < SystemClock.uptimeMillis()) {
+      when = SystemClock.uptimeMillis();
+    }
+    return when;
+  }
+
+  /**
+   * Internal method to get the number of entries in the MessageQueue.
+   *
+   * <p>Do not use, will likely be removed in a future release.
+   */
+  public int internalGetSize() {
+    int count = 0;
+    synchronized (realQueue) {
+      Message next = getMessages();
+      while (next != null) {
+        count++;
+        next = shadowOfMsg(next).internalGetNext();
+      }
+    }
+    return count;
+  }
+
+  /**
+   * Returns the message at the head of the queue immediately, regardless of its scheduled time.
+   * Compare to {@link #getNext()} which will only return the next message if the system clock is
+   * advanced to its scheduled time.
+   */
+  Message getNextIgnoringWhen() {
+    synchronized (realQueue) {
+      Message head = getMessages();
+      if (head != null) {
+        Message next = shadowOfMsg(head).internalGetNext();
+        reflector(MessageQueueReflector.class, realQueue).setMessages(next);
+      }
+      return head;
+    }
+  }
+
+  // TODO: reconsider exposing this as a public API. Only ShadowPausedLooper needs to access this,
+  // so it should be package private
+  @Override
+  public void reset() {
+    MessageQueueReflector msgQueue = reflector(MessageQueueReflector.class, realQueue);
+    msgQueue.setMessages(null);
+    msgQueue.setIdleHandlers(new ArrayList<>());
+    msgQueue.setNextBarrierToken(0);
+  }
+
+  private static ShadowPausedMessage shadowOfMsg(Message head) {
+    return Shadow.extract(head);
+  }
+
+  @Override
+  public Scheduler getScheduler() {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode.");
+  }
+
+  @Override
+  public void setScheduler(Scheduler scheduler) {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode.");
+  }
+
+  // intentionally do not support direct access to MessageQueue internals
+
+  @Override
+  public Message getHead() {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode.");
+  }
+
+  @Override
+  public void setHead(Message msg) {
+    throw new UnsupportedOperationException("Not supported in PAUSED LooperMode.");
+  }
+
+  /**
+   * Retrieves a copy of the current list of idle handlers. Idle handlers are read with
+   * synchronization on the real queue.
+   */
+  ArrayList<IdleHandler> getIdleHandlersCopy() {
+    synchronized (realQueue) {
+      return new ArrayList<>(reflector(MessageQueueReflector.class, realQueue).getIdleHandlers());
+    }
+  }
+
+  /** Accessor interface for {@link MessageQueue}'s internals. */
+  @ForType(MessageQueue.class)
+  private interface MessageQueueReflector {
+
+    boolean enqueueMessage(Message msg, long when);
+
+    Message next();
+
+    @Accessor("mMessages")
+    void setMessages(Message msg);
+
+    @Accessor("mMessages")
+    Message getMessages();
+
+    @Accessor("mIdleHandlers")
+    void setIdleHandlers(ArrayList<IdleHandler> list);
+
+    @Accessor("mIdleHandlers")
+    ArrayList<IdleHandler> getIdleHandlers();
+
+    @Accessor("mNextBarrierToken")
+    void setNextBarrierToken(int token);
+
+    @Accessor("mQuitAllowed")
+    boolean getQuitAllowed();
+
+    @Accessor("mPtr")
+    void setPtr(int ptr);
+
+    @Accessor("mPtr")
+    int getPtr();
+
+    // for APIs < JELLYBEAN_MR2
+    @Direct
+    void quit();
+
+    @Direct
+    void quit(boolean b);
+
+    // for APIs < KITKAT
+    @Accessor("mQuiting")
+    boolean getQuiting();
+
+    @Accessor("mQuitting")
+    boolean getQuitting();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedSystemClock.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedSystemClock.java
new file mode 100644
index 0000000..9d583e3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedSystemClock.java
@@ -0,0 +1,140 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.os.SystemClock;
+import java.time.DateTimeException;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * A shadow SystemClock used when {@link LooperMode.Mode#PAUSED} is active.
+ *
+ * <p>In this variant, there is just one global system time controlled by this class. The current
+ * time is fixed in place, and manually advanced by calling {@link
+ * SystemClock#setCurrentTimeMillis(long)}
+ *
+ * <p>{@link SystemClock#uptimeMillis()} and {@link SystemClock#currentThreadTimeMillis()} are
+ * identical.
+ *
+ * <p>This class should not be referenced directly. Use ShadowSystemClock instead.
+ */
+@Implements(
+    value = SystemClock.class,
+    isInAndroidSdk = false,
+    shadowPicker = ShadowSystemClock.Picker.class)
+public class ShadowPausedSystemClock extends ShadowSystemClock {
+  private static final long INITIAL_TIME = 100;
+  private static final int MILLIS_PER_NANO = 1000000;
+
+  @GuardedBy("ShadowPausedSystemClock.class")
+  private static long currentTimeMillis = INITIAL_TIME;
+
+  private static final List<Listener> listeners = new CopyOnWriteArrayList<>();
+
+  /**
+   * Callback for clock updates
+   */
+  interface Listener {
+    void onClockAdvanced();
+  }
+
+  static void addListener(Listener listener) {
+    listeners.add(listener);
+  }
+
+  static void removeListener(Listener listener) {
+    listeners.remove(listener);
+  }
+
+  /** Advances the current time by given millis, without sleeping the current thread/ */
+  @Implementation
+  protected static void sleep(long millis) {
+    synchronized (ShadowPausedSystemClock.class) {
+      currentTimeMillis += millis;
+    }
+    for (Listener listener : listeners) {
+      listener.onClockAdvanced();
+    }
+  }
+
+  /**
+   * Sets the current wall time.
+   *
+   * <p>Currently does not perform any permission checks.
+   *
+   * @return false if specified time is less than current time.
+   */
+  @Implementation
+  protected static boolean setCurrentTimeMillis(long millis) {
+    synchronized (ShadowPausedSystemClock.class) {
+      if (currentTimeMillis > millis) {
+        return false;
+      } else if (currentTimeMillis == millis) {
+        return true;
+      } else {
+        currentTimeMillis = millis;
+      }
+    }
+    for (Listener listener : listeners) {
+      listener.onClockAdvanced();
+    }
+    return true;
+  }
+
+  @Implementation
+  protected static synchronized long uptimeMillis() {
+    return currentTimeMillis;
+  }
+
+  @Implementation
+  protected static long elapsedRealtime() {
+    return uptimeMillis();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static long elapsedRealtimeNanos() {
+    return elapsedRealtime() * MILLIS_PER_NANO;
+  }
+
+  @Implementation
+  protected static long currentThreadTimeMillis() {
+    return uptimeMillis();
+  }
+
+  @HiddenApi
+  @Implementation
+  protected static long currentThreadTimeMicro() {
+    return uptimeMillis() * 1000;
+  }
+
+  @HiddenApi
+  @Implementation
+  protected static long currentTimeMicro() {
+    return currentThreadTimeMicro();
+  }
+
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected static synchronized long currentNetworkTimeMillis() {
+    if (networkTimeAvailable) {
+      return currentTimeMillis;
+    } else {
+      throw new DateTimeException("Network time not available");
+    }
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    currentTimeMillis = INITIAL_TIME;
+    ShadowSystemClock.reset();
+    listeners.clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPeerHandle.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPeerHandle.java
new file mode 100644
index 0000000..c94d5df
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPeerHandle.java
@@ -0,0 +1,15 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+
+import android.net.wifi.aware.PeerHandle;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = PeerHandle.class, minSdk = O)
+public class ShadowPeerHandle {
+
+  public static PeerHandle newInstance() {
+    return ReflectionHelpers.callConstructor(PeerHandle.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPendingIntent.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPendingIntent.java
new file mode 100644
index 0000000..8f031d8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPendingIntent.java
@@ -0,0 +1,690 @@
+package org.robolectric.shadows;
+
+import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
+import static android.app.PendingIntent.FLAG_IMMUTABLE;
+import static android.app.PendingIntent.FLAG_NO_CREATE;
+import static android.app.PendingIntent.FLAG_ONE_SHOT;
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.NonNull;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.ActivityThread;
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.app.PendingIntent.OnMarshaledListener;
+import android.content.Context;
+import android.content.IIntentSender;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Parcel;
+import android.os.Parcelable.Creator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.fakes.RoboIntentSender;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(PendingIntent.class)
+@SuppressLint("NewApi")
+public class ShadowPendingIntent {
+
+  private enum Type {
+    ACTIVITY,
+    BROADCAST,
+    SERVICE,
+    FOREGROUND_SERVICE
+  }
+
+  private static final int NULL_PENDING_INTENT_VALUE = -1;
+
+  @GuardedBy("lock")
+  private static final List<PendingIntent> createdIntents = new ArrayList<>();
+
+  private static final Object lock = new Object();
+
+  private static final List<PendingIntent> parceledPendingIntents = new ArrayList<>();
+
+  @RealObject private PendingIntent realPendingIntent;
+
+  @NonNull private Intent[] savedIntents;
+  private Context savedContext;
+  private Type type;
+  private int requestCode;
+  private int flags;
+  @Nullable private Bundle options;
+  private String creatorPackage;
+  private int creatorUid;
+  private boolean canceled;
+  @Nullable private PendingIntent.OnFinished lastOnFinished;
+
+  @Implementation
+  protected static void __staticInitializer__() {
+    Shadow.directInitialize(PendingIntent.class);
+    ReflectionHelpers.setStaticField(PendingIntent.class, "CREATOR", ShadowPendingIntent.CREATOR);
+  }
+
+  @Implementation
+  protected static PendingIntent getActivity(
+      Context context, int requestCode, @NonNull Intent intent, int flags) {
+    return create(context, new Intent[] {intent}, Type.ACTIVITY, requestCode, flags, null);
+  }
+
+  @Implementation
+  protected static PendingIntent getActivity(
+      Context context, int requestCode, @NonNull Intent intent, int flags, Bundle options) {
+    return create(context, new Intent[] {intent}, Type.ACTIVITY, requestCode, flags, options);
+  }
+
+  @Implementation
+  protected static PendingIntent getActivities(
+      Context context, int requestCode, @NonNull Intent[] intents, int flags) {
+    return create(context, intents, Type.ACTIVITY, requestCode, flags, null);
+  }
+
+  @Implementation
+  protected static PendingIntent getActivities(
+      Context context, int requestCode, @NonNull Intent[] intents, int flags, Bundle options) {
+    return create(context, intents, Type.ACTIVITY, requestCode, flags, options);
+  }
+
+  @Implementation
+  protected static PendingIntent getBroadcast(
+      Context context, int requestCode, @NonNull Intent intent, int flags) {
+    return create(context, new Intent[] {intent}, Type.BROADCAST, requestCode, flags, null);
+  }
+
+  @Implementation
+  protected static PendingIntent getService(
+      Context context, int requestCode, @NonNull Intent intent, int flags) {
+    return create(context, new Intent[] {intent}, Type.SERVICE, requestCode, flags, null);
+  }
+
+  @Implementation(minSdk = O)
+  protected static PendingIntent getForegroundService(
+      Context context, int requestCode, @NonNull Intent intent, int flags) {
+    return create(
+        context, new Intent[] {intent}, Type.FOREGROUND_SERVICE, requestCode, flags, null);
+  }
+
+  @Implementation
+  @SuppressWarnings("ReferenceEquality")
+  protected void cancel() {
+    synchronized (lock) {
+      for (Iterator<PendingIntent> i = createdIntents.iterator(); i.hasNext(); ) {
+        PendingIntent pendingIntent = i.next();
+        if (pendingIntent == realPendingIntent) {
+          canceled = true;
+          i.remove();
+          break;
+        }
+      }
+    }
+  }
+
+  @Implementation
+  protected void send() throws CanceledException {
+    send(savedContext, 0, null);
+  }
+
+  @Implementation
+  protected void send(int code) throws CanceledException {
+    send(savedContext, code, null);
+  }
+
+  @Implementation
+  protected void send(int code, PendingIntent.OnFinished onFinished, Handler handler)
+      throws CanceledException {
+    send(savedContext, code, null, onFinished, handler);
+  }
+
+  @Implementation
+  protected void send(Context context, int code, Intent intent) throws CanceledException {
+    send(context, code, intent, null, null);
+  }
+
+  @Implementation
+  protected void send(
+      Context context,
+      int code,
+      Intent intent,
+      PendingIntent.OnFinished onFinished,
+      Handler handler)
+      throws CanceledException {
+    send(context, code, intent, onFinished, handler, null);
+  }
+
+  @Implementation
+  protected void send(
+      Context context,
+      int code,
+      Intent intent,
+      PendingIntent.OnFinished onFinished,
+      Handler handler,
+      String requiredPermission)
+      throws CanceledException {
+    // Manually propagating to keep only one implementation regardless of SDK
+    send(context, code, intent, onFinished, handler, requiredPermission, null);
+  }
+
+  @Implementation(minSdk = M)
+  protected void send(
+      Context context,
+      int code,
+      Intent intent,
+      PendingIntent.OnFinished onFinished,
+      Handler handler,
+      String requiredPermission,
+      Bundle options)
+      throws CanceledException {
+    send(context, code, intent, onFinished, handler, requiredPermission, options, 0);
+  }
+
+  void send(
+      Context context,
+      int code,
+      Intent intent,
+      PendingIntent.OnFinished onFinished,
+      Handler handler,
+      String requiredPermission,
+      Bundle options,
+      int requestCode)
+      throws CanceledException {
+    this.lastOnFinished =
+        handler == null
+            ? onFinished
+            : (pendingIntent, intent1, resultCode, resultData, resultExtras) ->
+                handler.post(
+                    () ->
+                        onFinished.onSendFinished(
+                            pendingIntent, intent1, resultCode, resultData, resultExtras));
+
+    if (canceled) {
+      throw new CanceledException();
+    }
+
+    // Fill in the last Intent, if it is mutable, with information now available at send-time.
+    Intent[] intentsToSend;
+    if (intent != null && isMutable(flags)) {
+      // Copy the last intent before filling it in to avoid modifying this PendingIntent.
+      intentsToSend = Arrays.copyOf(savedIntents, savedIntents.length);
+      Intent lastIntentCopy = new Intent(intentsToSend[intentsToSend.length - 1]);
+      lastIntentCopy.fillIn(intent, 0);
+      intentsToSend[intentsToSend.length - 1] = lastIntentCopy;
+    } else {
+      intentsToSend = savedIntents;
+    }
+
+    ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread();
+    ShadowInstrumentation shadowInstrumentation =
+        Shadow.extract(activityThread.getInstrumentation());
+    if (isActivity()) {
+      for (Intent intentToSend : intentsToSend) {
+        shadowInstrumentation.execStartActivity(
+            context,
+            (IBinder) null,
+            (IBinder) null,
+            (Activity) null,
+            intentToSend,
+            requestCode,
+            (Bundle) null);
+      }
+    } else if (isBroadcast()) {
+      for (Intent intentToSend : intentsToSend) {
+        shadowInstrumentation.sendBroadcastWithPermission(
+            intentToSend, requiredPermission, context, options, code);
+      }
+    } else if (isService()) {
+      for (Intent intentToSend : intentsToSend) {
+        context.startService(intentToSend);
+      }
+    } else if (isForegroundService()) {
+      for (Intent intentToSend : intentsToSend) {
+        context.startForegroundService(intentToSend);
+      }
+    }
+
+    if (isOneShot(flags)) {
+      cancel();
+    }
+  }
+
+  @Implementation
+  protected IntentSender getIntentSender() {
+    return new RoboIntentSender(realPendingIntent);
+  }
+
+  /**
+   * Returns {@code true} if this {@code PendingIntent} was created with {@link #getActivity} or
+   * {@link #getActivities}.
+   *
+   * <p>This method is intentionally left {@code public} rather than {@code protected} because it
+   * serves a secondary purpose as a utility shadow method for API levels < 31.
+   */
+  @Implementation(minSdk = S)
+  public boolean isActivity() {
+    return type == Type.ACTIVITY;
+  }
+
+  /**
+   * Returns {@code true} if this {@code PendingIntent} was created with {@link #getBroadcast}.
+   *
+   * <p>This method is intentionally left {@code public} rather than {@code protected} because it
+   * serves a secondary purpose as a utility shadow method for API levels < 31.
+   */
+  @Implementation(minSdk = S)
+  public boolean isBroadcast() {
+    return type == Type.BROADCAST;
+  }
+
+  /**
+   * Returns {@code true} if this {@code PendingIntent} was created with {@link
+   * #getForegroundService}.
+   *
+   * <p>This method is intentionally left {@code public} rather than {@code protected} because it
+   * serves a secondary purpose as a utility shadow method for API levels < 31.
+   */
+  @Implementation(minSdk = S)
+  public boolean isForegroundService() {
+    return type == Type.FOREGROUND_SERVICE;
+  }
+
+  /**
+   * Returns {@code true} if this {@code PendingIntent} was created with {@link #getService}.
+   *
+   * <p>This method is intentionally left {@code public} rather than {@code protected} because it
+   * serves a secondary purpose as a utility shadow method for API levels < 31.
+   */
+  @Implementation(minSdk = S)
+  public boolean isService() {
+    return type == Type.SERVICE;
+  }
+
+  /**
+   * Returns {@code true} if this {@code PendingIntent} is marked with {@link
+   * PendingIntent#FLAG_IMMUTABLE}.
+   *
+   * <p>This method is intentionally left {@code public} rather than {@code protected} because it
+   * serves a secondary purpose as a utility shadow method for API levels < 31.
+   */
+  @Implementation(minSdk = S)
+  public boolean isImmutable() {
+    return (flags & FLAG_IMMUTABLE) > 0;
+  }
+
+  /**
+   * @return {@code true} iff sending this PendingIntent will start an activity
+   * @deprecated prefer {@link #isActivity} which was added to {@link PendingIntent} in API 31
+   *     (Android S).
+   */
+  @Deprecated
+  public boolean isActivityIntent() {
+    return type == Type.ACTIVITY;
+  }
+
+  /**
+   * @return {@code true} iff sending this PendingIntent will broadcast an Intent
+   * @deprecated prefer {@link #isBroadcast} which was added to {@link PendingIntent} in API 31
+   *     (Android S).
+   */
+  @Deprecated
+  public boolean isBroadcastIntent() {
+    return type == Type.BROADCAST;
+  }
+
+  /**
+   * @return {@code true} iff sending this PendingIntent will start a service
+   * @deprecated prefer {@link #isService} which was added to {@link PendingIntent} in API 31
+   *     (Android S).
+   */
+  @Deprecated
+  public boolean isServiceIntent() {
+    return type == Type.SERVICE;
+  }
+
+  /**
+   * @return {@code true} iff sending this PendingIntent will start a foreground service
+   * @deprecated prefer {@link #isForegroundService} which was added to {@link PendingIntent} in API
+   *     31 (Android S).
+   */
+  @Deprecated
+  public boolean isForegroundServiceIntent() {
+    return type == Type.FOREGROUND_SERVICE;
+  }
+
+  /**
+   * @return the context in which this PendingIntent was created
+   */
+  public Context getSavedContext() {
+    return savedContext;
+  }
+
+  /**
+   * This returns the last Intent in the Intent[] to be delivered when the PendingIntent is sent.
+   * This method is particularly useful for PendingIntents created with a single Intent:
+   *
+   * <ul>
+   *   <li>{@link #getActivity(Context, int, Intent, int)}
+   *   <li>{@link #getActivity(Context, int, Intent, int, Bundle)}
+   *   <li>{@link #getBroadcast(Context, int, Intent, int)}
+   *   <li>{@link #getService(Context, int, Intent, int)}
+   * </ul>
+   *
+   * @return the final Intent to be delivered when the PendingIntent is sent
+   */
+  public Intent getSavedIntent() {
+    return savedIntents[savedIntents.length - 1];
+  }
+
+  /**
+   * This method is particularly useful for PendingIntents created with multiple Intents:
+   *
+   * <ul>
+   *   <li>{@link #getActivities(Context, int, Intent[], int)}
+   *   <li>{@link #getActivities(Context, int, Intent[], int, Bundle)}
+   * </ul>
+   *
+   * @return all Intents to be delivered when the PendingIntent is sent
+   */
+  public Intent[] getSavedIntents() {
+    return savedIntents;
+  }
+
+  /**
+   * @return {@true} iff this PendingIntent has been canceled
+   */
+  public boolean isCanceled() {
+    return canceled;
+  }
+
+  /**
+   * @return the request code with which this PendingIntent was created
+   */
+  public int getRequestCode() {
+    return requestCode;
+  }
+
+  /**
+   * @return the flags with which this PendingIntent was created
+   */
+  public int getFlags() {
+    return flags;
+  }
+
+  /**
+   * @return the flags with which this PendingIntent was created
+   */
+  public @Nullable Bundle getOptions() {
+    return options;
+  }
+
+  /**
+   * Calls {@link PendingIntent.OnFinished#onSendFinished} on the last {@link
+   * PendingIntent.OnFinished} passed with {@link #send()}.
+   *
+   * <p>{@link PendingIntent.OnFinished#onSendFinished} is called on the {@link Handler} passed with
+   * {@link #send()} (if any). If no {@link Handler} was provided it's invoked on the calling
+   * thread.
+   *
+   * @return false if no {@link PendingIntent.OnFinished} callback was passed with the last {@link
+   *     #send()} call, true otherwise.
+   */
+  public boolean callLastOnFinished(
+      Intent intent, int resultCode, String resultData, Bundle resultExtras) {
+    if (lastOnFinished == null) {
+      return false;
+    }
+
+    lastOnFinished.onSendFinished(realPendingIntent, intent, resultCode, resultData, resultExtras);
+    return true;
+  }
+
+  @Implementation
+  protected String getTargetPackage() {
+    return getCreatorPackage();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected String getCreatorPackage() {
+    return (creatorPackage == null)
+        ? RuntimeEnvironment.getApplication().getPackageName()
+        : creatorPackage;
+  }
+
+  public void setCreatorPackage(String creatorPackage) {
+    this.creatorPackage = creatorPackage;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected int getCreatorUid() {
+    return creatorUid;
+  }
+
+  public void setCreatorUid(int uid) {
+    this.creatorUid = uid;
+  }
+
+  @Override
+  @Implementation
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || realPendingIntent.getClass() != o.getClass()) return false;
+    ShadowPendingIntent that = Shadow.extract((PendingIntent) o);
+
+    String packageName = savedContext == null ? null : savedContext.getPackageName();
+    String thatPackageName = that.savedContext == null ? null : that.savedContext.getPackageName();
+    if (!Objects.equals(packageName, thatPackageName)) {
+      return false;
+    }
+
+    if (this.savedIntents.length != that.savedIntents.length) {
+      return false;
+    }
+
+    for (int i = 0; i < this.savedIntents.length; i++) {
+      if (!this.savedIntents[i].filterEquals(that.savedIntents[i])) {
+        return false;
+      }
+    }
+
+    if (this.requestCode != that.requestCode) {
+      return false;
+    }
+    return true;
+  }
+
+  @Override
+  @Implementation
+  public int hashCode() {
+    int result = savedIntents != null ? Arrays.hashCode(savedIntents) : 0;
+    if (savedContext != null) {
+      String packageName = savedContext.getPackageName();
+      result = 31 * result + (packageName != null ? packageName.hashCode() : 0);
+    }
+    result = 31 * result + requestCode;
+    return result;
+  }
+
+  @Implementation
+  @Nullable
+  public static PendingIntent readPendingIntentOrNullFromParcel(@NonNull Parcel in) {
+    int intentIndex = in.readInt();
+    if (intentIndex == NULL_PENDING_INTENT_VALUE) {
+      return null;
+    }
+    return parceledPendingIntents.get(intentIndex);
+  }
+
+  @Implementation
+  public static void writePendingIntentOrNullToParcel(
+      @Nullable PendingIntent sender, @NonNull Parcel out) {
+    if (sender == null) {
+      out.writeInt(NULL_PENDING_INTENT_VALUE);
+      return;
+    }
+
+    int index = parceledPendingIntents.size();
+    parceledPendingIntents.add(sender);
+    out.writeInt(index);
+
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      ThreadLocal<OnMarshaledListener> sOnMarshaledListener =
+          ReflectionHelpers.getStaticField(PendingIntent.class, "sOnMarshaledListener");
+      OnMarshaledListener listener = sOnMarshaledListener.get();
+      if (listener != null) {
+        listener.onMarshaled(sender, out, 0);
+      }
+    }
+  }
+
+  static final Creator<PendingIntent> CREATOR =
+      new Creator<PendingIntent>() {
+        @Override
+        public PendingIntent createFromParcel(Parcel in) {
+          return readPendingIntentOrNullFromParcel(in);
+        }
+
+        @Override
+        public PendingIntent[] newArray(int size) {
+          return new PendingIntent[size];
+        }
+      };
+
+  @Implementation
+  protected void writeToParcel(Parcel out, int flags) {
+    writePendingIntentOrNullToParcel(realPendingIntent, out);
+  }
+
+  private static PendingIntent create(
+      Context context,
+      Intent[] intents,
+      Type type,
+      int requestCode,
+      int flags,
+      @Nullable Bundle options) {
+    synchronized (lock) {
+      Objects.requireNonNull(intents, "intents may not be null");
+
+      // Search for a matching PendingIntent.
+      PendingIntent pendingIntent = getCreatedIntentFor(type, intents, requestCode, flags);
+      if ((flags & FLAG_NO_CREATE) != 0) {
+        return pendingIntent;
+      }
+
+      // If requested, update the existing PendingIntent if one exists.
+      if (pendingIntent != null && (flags & FLAG_UPDATE_CURRENT) != 0) {
+        ShadowPendingIntent shadowPendingIntent = Shadow.extract(pendingIntent);
+        Intent intent = shadowPendingIntent.getSavedIntent();
+        Bundle extras = intent.getExtras();
+        if (extras != null) {
+          extras.clear();
+        }
+        intent.putExtras(intents[intents.length - 1]);
+        return pendingIntent;
+      }
+
+      // If requested, cancel the existing PendingIntent if one exists.
+      if (pendingIntent != null && (flags & FLAG_CANCEL_CURRENT) != 0) {
+        ShadowPendingIntent shadowPendingIntent = Shadow.extract(pendingIntent);
+        shadowPendingIntent.cancel();
+        pendingIntent = null;
+      }
+
+      // Build the PendingIntent if it does not exist.
+      if (pendingIntent == null) {
+        pendingIntent = ReflectionHelpers.callConstructor(PendingIntent.class);
+        // Some methods (e.g. toString) may NPE if 'mTarget' is null.
+        reflector(PendingIntentReflector.class, pendingIntent)
+            .setTarget(ReflectionHelpers.createNullProxy(IIntentSender.class));
+        ShadowPendingIntent shadowPendingIntent = Shadow.extract(pendingIntent);
+        shadowPendingIntent.savedIntents = intents;
+        shadowPendingIntent.type = type;
+        shadowPendingIntent.savedContext = context;
+        shadowPendingIntent.requestCode = requestCode;
+        shadowPendingIntent.flags = flags;
+        shadowPendingIntent.options = options;
+
+        createdIntents.add(pendingIntent);
+      }
+
+      return pendingIntent;
+    }
+  }
+
+  private static PendingIntent getCreatedIntentFor(
+      Type type, Intent[] intents, int requestCode, int flags) {
+    synchronized (lock) {
+      for (PendingIntent createdIntent : createdIntents) {
+        ShadowPendingIntent shadowPendingIntent = Shadow.extract(createdIntent);
+
+        if (isOneShot(shadowPendingIntent.flags) != isOneShot(flags)) {
+          continue;
+        }
+
+        if (isMutable(shadowPendingIntent.flags) != isMutable(flags)) {
+          continue;
+        }
+
+        if (shadowPendingIntent.type != type) {
+          continue;
+        }
+
+        if (shadowPendingIntent.requestCode != requestCode) {
+          continue;
+        }
+
+        // The last Intent in the array acts as the "significant element" for matching as per
+        // {@link #getActivities(Context, int, Intent[], int)}.
+        Intent savedIntent = shadowPendingIntent.getSavedIntent();
+        Intent targetIntent = intents[intents.length - 1];
+
+        if (savedIntent == null ? targetIntent == null : savedIntent.filterEquals(targetIntent)) {
+          return createdIntent;
+        }
+      }
+      return null;
+    }
+  }
+
+  private static boolean isOneShot(int flags) {
+    return (flags & FLAG_ONE_SHOT) != 0;
+  }
+
+  private static boolean isMutable(int flags) {
+    return (flags & FLAG_IMMUTABLE) == 0;
+  }
+
+  @Resetter
+  public static void reset() {
+    synchronized (lock) {
+      createdIntents.clear();
+    }
+  }
+
+  @ForType(PendingIntent.class)
+  interface PendingIntentReflector {
+    @Accessor("mTarget")
+    void setTarget(IIntentSender target);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhone.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhone.java
new file mode 100644
index 0000000..88fe7dc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhone.java
@@ -0,0 +1,81 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.telecom.Call;
+import android.telecom.CallAudioState;
+import android.telecom.InCallAdapter;
+import android.telecom.Phone;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.telecom.Phone}. */
+@Implements(value = Phone.class, isInAndroidSdk = false)
+public class ShadowPhone {
+  @RealObject private Phone phone;
+
+  private final List<Call> calls = new ArrayList<>();
+
+  @Implementation(minSdk = M)
+  protected final List<Call> getCalls() {
+    List<Call> unmodifiableCalls = reflector(ReflectorPhone.class, phone).getUnmodifiableCalls();
+    if (unmodifiableCalls != null) {
+      return unmodifiableCalls;
+    }
+    return Collections.unmodifiableList(calls);
+  }
+
+  @Implementation(minSdk = M)
+  protected final CallAudioState getCallAudioState() {
+    CallAudioState callAudioState = reflector(ReflectorPhone.class, phone).getCallAudioState();
+    if (callAudioState != null) {
+      return callAudioState;
+    }
+    InCallAdapter inCallAdapter = ReflectionHelpers.getField(phone, "mInCallAdapter");
+    int audioRoute = ((ShadowInCallAdapter) Shadow.extract(inCallAdapter)).getAudioRoute();
+
+    return new CallAudioState(
+        /* muted= */ false,
+        audioRoute,
+        CallAudioState.ROUTE_SPEAKER | CallAudioState.ROUTE_EARPIECE);
+  }
+
+  /** Add Call to a collection that returns when getCalls is called. */
+  public void addCall(Call call) {
+    calls.add(call);
+    List<Call> realCalls = reflector(ReflectorPhone.class, phone).getCalls();
+    if (realCalls != null) {
+      realCalls.add(call);
+    }
+  }
+
+  /** Remove call that has previously been added via addCall(). */
+  public void removeCall(Call call) {
+    calls.remove(call);
+    List<Call> realCalls = reflector(ReflectorPhone.class, phone).getCalls();
+    if (realCalls != null) {
+      realCalls.remove(call);
+    }
+  }
+
+  @ForType(Phone.class)
+  interface ReflectorPhone {
+    @Accessor("mUnmodifiableCalls")
+    List<Call> getUnmodifiableCalls();
+
+    @Accessor("mCalls")
+    List<Call> getCalls();
+
+    @Accessor("mCallAudioState")
+    CallAudioState getCallAudioState();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhoneWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhoneWindow.java
new file mode 100644
index 0000000..2321329
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhoneWindow.java
@@ -0,0 +1,47 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.drawable.Drawable;
+import android.view.Gravity;
+import android.view.Window;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * Shadow for PhoneWindow for APIs 23+
+ */
+@Implements(className = "com.android.internal.policy.PhoneWindow", isInAndroidSdk = false,
+    minSdk = M, looseSignatures = true)
+public class ShadowPhoneWindow extends ShadowWindow {
+  @SuppressWarnings("UnusedDeclaration")
+  protected @RealObject Window realWindow;
+
+  @Implementation(minSdk = M)
+  public void setTitle(CharSequence title) {
+    this.title = title;
+    reflector(DirectPhoneWindowReflector.class, realWindow).setTitle(title);
+  }
+
+  @Implementation(minSdk = M)
+  public void setBackgroundDrawable(Drawable drawable) {
+    this.backgroundDrawable = drawable;
+    reflector(DirectPhoneWindowReflector.class, realWindow).setBackgroundDrawable(drawable);
+  }
+
+  @Implementation
+  protected int getOptionsPanelGravity() {
+    return Gravity.CENTER | Gravity.BOTTOM;
+  }
+
+  @ForType(className = "com.android.internal.policy.PhoneWindow", direct = true)
+  interface DirectPhoneWindowReflector {
+
+    void setTitle(CharSequence title);
+
+    void setBackgroundDrawable(Drawable drawable);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhoneWindowFor22.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhoneWindowFor22.java
new file mode 100644
index 0000000..f39f66e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPhoneWindowFor22.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.drawable.Drawable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * Shadow for the API 16-22 PhoneWindow.li
+ */
+@Implements(className = "com.android.internal.policy.impl.PhoneWindow", maxSdk = LOLLIPOP_MR1,
+    looseSignatures = true, isInAndroidSdk = false)
+public class ShadowPhoneWindowFor22 extends ShadowPhoneWindow {
+
+  @Override @Implementation(maxSdk = LOLLIPOP_MR1)
+  public void setTitle(CharSequence title) {
+    this.title = title;
+    reflector(DirectPhoneWindowFor22Reflector.class, realWindow).setTitle(title);
+  }
+
+  @Override @Implementation(maxSdk = LOLLIPOP_MR1)
+  public void setBackgroundDrawable(Drawable drawable) {
+    this.backgroundDrawable = drawable;
+    reflector(DirectPhoneWindowFor22Reflector.class, realWindow).setBackgroundDrawable(drawable);
+  }
+
+  @Override @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected int getOptionsPanelGravity() {
+    return super.getOptionsPanelGravity();
+  }
+
+  @ForType(className = "com.android.internal.policy.impl.PhoneWindow", direct = true)
+  interface DirectPhoneWindowFor22Reflector extends DirectPhoneWindowReflector {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPicture.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPicture.java
new file mode 100644
index 0000000..bff1e90
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPicture.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Picture;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(Picture.class)
+public class ShadowPicture {
+
+  private int width;
+  private int height;
+  private static long nativePtr = 0;
+
+  @Implementation(maxSdk = KITKAT)
+  protected static int nativeConstructor(int nativeSrc) {
+    // just return a non zero value, so it appears that native allocation was successful
+    return (int) nativeConstructor((long) nativeSrc);
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected static long nativeConstructor(long nativeSrc) {
+    // just return a non zero value, so it appears that native allocation was successful
+    return ++nativePtr;
+  }
+
+  @Implementation
+  protected void __constructor__(Picture src) {
+    width = src.getWidth();
+    height = src.getHeight();
+  }
+
+  @Implementation
+  protected int getWidth() {
+    return width;
+  }
+
+  @Implementation
+  protected int getHeight() {
+    return height;
+  }
+
+  @Implementation
+  protected Canvas beginRecording(int width, int height) {
+    this.width = width;
+    this.height = height;
+    return new Canvas(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPlayerBase.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPlayerBase.java
new file mode 100644
index 0000000..58df3c3
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPlayerBase.java
@@ -0,0 +1,19 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+
+import android.media.IAudioService;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(className = "android.media.PlayerBase", isInAndroidSdk = false,
+    minSdk = Build.VERSION_CODES.N)
+public class ShadowPlayerBase {
+
+  @Implementation(minSdk = O)
+  public static IAudioService getService() {
+    return ReflectionHelpers.createNullProxy(IAudioService.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPolicyManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPolicyManager.java
new file mode 100644
index 0000000..04a0cd8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPolicyManager.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.Window;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(
+  className = "com.android.internal.policy.PolicyManager",
+  isInAndroidSdk = false,
+  maxSdk = LOLLIPOP_MR1
+)
+public class ShadowPolicyManager {
+
+  @Implementation
+  protected static LayoutInflater makeNewLayoutInflater(Context context) {
+    Class<LayoutInflater> phoneLayoutInflaterClass =
+        (Class<LayoutInflater>)
+            ReflectionHelpers.loadClass(
+                ShadowPolicyManager.class.getClassLoader(),
+                "com.android.internal.policy.impl.PhoneLayoutInflater");
+    return ReflectionHelpers.callConstructor(
+        phoneLayoutInflaterClass, ClassParameter.from(Context.class, context));
+  }
+
+  @Implementation
+  protected static Window makeNewWindow(Context context) {
+    try {
+      return ShadowWindow.create(context);
+    } catch (ClassNotFoundException e) {
+      throw new AssertionError("Exception in makeNewWindow", e);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPopupMenu.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPopupMenu.java
new file mode 100644
index 0000000..352fba0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPopupMenu.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.PopupMenu;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(PopupMenu.class)
+public class ShadowPopupMenu {
+
+  @RealObject private PopupMenu realPopupMenu;
+
+  private boolean isShowing;
+  private PopupMenu.OnMenuItemClickListener onMenuItemClickListener;
+
+  @Implementation
+  protected void show() {
+    this.isShowing = true;
+    setLatestPopupMenu(this);
+    reflector(PopupMenuReflector.class, realPopupMenu).show();
+  }
+
+  @Implementation
+  protected void dismiss() {
+    this.isShowing = false;
+    reflector(PopupMenuReflector.class, realPopupMenu).dismiss();
+  }
+
+  @Implementation
+  protected void setOnMenuItemClickListener(PopupMenu.OnMenuItemClickListener listener) {
+    this.onMenuItemClickListener = listener;
+    reflector(PopupMenuReflector.class, realPopupMenu).setOnMenuItemClickListener(listener);
+  }
+
+  public boolean isShowing() {
+    return isShowing;
+  }
+
+  public static PopupMenu getLatestPopupMenu() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    ShadowPopupMenu popupMenu = shadowApplication.getLatestPopupMenu();
+    return popupMenu == null ? null : popupMenu.realPopupMenu;
+  }
+
+  public static void setLatestPopupMenu(ShadowPopupMenu latestPopupMenu) {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    if (shadowApplication != null) shadowApplication.setLatestPopupMenu(latestPopupMenu);
+  }
+
+  public PopupMenu.OnMenuItemClickListener getOnMenuItemClickListener() {
+    return onMenuItemClickListener;
+  }
+
+  @ForType(PopupMenu.class)
+  interface PopupMenuReflector {
+
+    @Direct
+    void show();
+
+    @Direct
+    void dismiss();
+
+    @Direct
+    void setOnMenuItemClickListener(PopupMenu.OnMenuItemClickListener listener);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPopupWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPopupWindow.java
new file mode 100644
index 0000000..2f4047f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPopupWindow.java
@@ -0,0 +1,30 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.WindowManager;
+import android.widget.PopupWindow;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(PopupWindow.class)
+public class ShadowPopupWindow {
+
+  @RealObject private PopupWindow realPopupWindow;
+
+  @Implementation
+  protected void invokePopup(WindowManager.LayoutParams p) {
+    ShadowApplication.getInstance().setLatestPopupWindow(realPopupWindow);
+    reflector(PopupWindowReflector.class, realPopupWindow).invokePopup(p);
+  }
+
+  @ForType(PopupWindow.class)
+  interface PopupWindowReflector {
+
+    @Direct
+    void invokePopup(WindowManager.LayoutParams p);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPorterDuffColorFilter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPorterDuffColorFilter.java
new file mode 100644
index 0000000..f0da844
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPorterDuffColorFilter.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(PorterDuffColorFilter.class)
+public class ShadowPorterDuffColorFilter {
+  private int color;
+  private PorterDuff.Mode mode;
+
+  @RealObject private PorterDuffColorFilter realPorterDuffColorFilter;
+
+  @Implementation(maxSdk = KITKAT)
+  protected void __constructor__(int color, PorterDuff.Mode mode) {
+    // We need these copies because before Lollipop, PorterDuffColorFilter had no fields, it would
+    // just delegate to a native instance. If we remove them, the shadow cannot access the fields
+    // on KitKat and earlier.
+    this.color = color;
+    this.mode = mode;
+  }
+
+  /**
+   * @return Returns the ARGB color used to tint the source pixels when this filter is applied.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  public int getColor() {
+    if (RuntimeEnvironment.getApiLevel() <= KITKAT) {
+      return color;
+    } else {
+      return reflector(PorterDuffColorFilterReflector.class, realPorterDuffColorFilter).getColor();
+    }
+  }
+
+  /**
+   * @return Returns the Porter-Duff mode used to composite this color filter's color with the
+   *     source pixel when this filter is applied.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  public PorterDuff.Mode getMode() {
+    if (RuntimeEnvironment.getApiLevel() <= KITKAT) {
+      return mode;
+    } else {
+      return reflector(PorterDuffColorFilterReflector.class, realPorterDuffColorFilter).getMode();
+    }
+  }
+
+  @ForType(PorterDuffColorFilter.class)
+  interface PorterDuffColorFilterReflector {
+    @Accessor("mColor")
+    int getColor();
+
+    @Accessor("mMode")
+    PorterDuff.Mode getMode();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPosix.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPosix.java
new file mode 100644
index 0000000..6cf1dbf
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPosix.java
@@ -0,0 +1,81 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import android.system.ErrnoException;
+import android.system.StructStat;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.time.Duration;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(className = "libcore.io.Posix", maxSdk = Build.VERSION_CODES.N_MR1, isInAndroidSdk = false)
+public class ShadowPosix {
+  @Implementation
+  public static void mkdir(String path, int mode) throws ErrnoException {
+    new File(path).mkdirs();
+  }
+
+  @Implementation
+  public static Object stat(String path) throws ErrnoException {
+    int mode = OsConstantsValues.getMode(path);
+    long size = 0;
+    long modifiedTime = 0;
+    if (path != null) {
+      File file = new File(path);
+      size = file.length();
+      modifiedTime = Duration.ofMillis(file.lastModified()).getSeconds();
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.LOLLIPOP) {
+      return new StructStat(
+        1, // st_dev
+        0, // st_ino
+        mode, // st_mode
+        0, // st_nlink
+        0, // st_uid
+        0, // st_gid
+        0, // st_rdev
+        size, // st_size
+        0, // st_atime
+        modifiedTime, // st_mtime
+        0, // st_ctime,
+        0, // st_blksize
+        0 // st_blocks
+        );
+    } else {
+      Object structStat =
+          ReflectionHelpers.newInstance(
+              ReflectionHelpers.loadClass(
+                  ShadowPosix.class.getClassLoader(), "libcore.io.StructStat"));
+      setMode(mode, structStat);
+      setSize(size, structStat);
+      setModifiedTime(modifiedTime, structStat);
+      return structStat;
+    }
+  }
+
+  @Implementation
+  protected static Object lstat(String path) throws ErrnoException {
+    return stat(path);
+  }
+
+  @Implementation
+  protected static Object fstat(FileDescriptor fd) throws ErrnoException {
+    return stat(null);
+  }
+
+  private static void setMode(int mode, Object structStat) {
+    ReflectionHelpers.setField(structStat, "st_mode", mode);
+  }
+
+  private static void setSize(long size, Object structStat) {
+    ReflectionHelpers.setField(structStat, "st_size", size);
+  }
+
+  private static void setModifiedTime(long modifiedTime, Object structStat) {
+    ReflectionHelpers.setField(structStat, "st_mtime", modifiedTime);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPowerManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPowerManager.java
new file mode 100644
index 0000000..db47fc8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPowerManager.java
@@ -0,0 +1,574 @@
+package org.robolectric.shadows;
+
+import static android.content.Intent.ACTION_SCREEN_OFF;
+import static android.content.Intent.ACTION_SCREEN_ON;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toCollection;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.Manifest.permission;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Build.VERSION_CODES;
+import android.os.PowerManager;
+import android.os.PowerManager.WakeLock;
+import android.os.SystemClock;
+import android.os.WorkSource;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow of PowerManager */
+@Implements(value = PowerManager.class, looseSignatures = true)
+public class ShadowPowerManager {
+
+  @RealObject private PowerManager realPowerManager;
+
+  private boolean isInteractive = true;
+  private boolean isPowerSaveMode = false;
+  private boolean isDeviceIdleMode = false;
+  private boolean isLightDeviceIdleMode = false;
+  @Nullable private Duration batteryDischargePrediction = null;
+  private boolean isBatteryDischargePredictionPersonalized = false;
+
+  @PowerManager.LocationPowerSaveMode
+  private int locationMode = PowerManager.LOCATION_MODE_ALL_DISABLED_WHEN_SCREEN_OFF;
+
+  private List<String> rebootReasons = new ArrayList<String>();
+  private Map<String, Boolean> ignoringBatteryOptimizations = new HashMap<>();
+
+  private int thermalStatus = 0;
+  // Intentionally use Object instead of PowerManager.OnThermalStatusChangedListener to avoid
+  // ClassLoader exceptions on earlier SDKs that don't have this class.
+  private final Set<Object> thermalListeners = new HashSet<>();
+
+  private final Set<String> ambientDisplaySuppressionTokens =
+      Collections.synchronizedSet(new HashSet<>());
+  private volatile boolean isAmbientDisplayAvailable = true;
+  private volatile boolean isRebootingUserspaceSupported = false;
+  private volatile boolean adaptivePowerSaveEnabled = false;
+
+  private static PowerManager.WakeLock latestWakeLock;
+
+  @Implementation
+  protected PowerManager.WakeLock newWakeLock(int flags, String tag) {
+    PowerManager.WakeLock wl = Shadow.newInstanceOf(PowerManager.WakeLock.class);
+    ((ShadowWakeLock) Shadow.extract(wl)).setTag(tag);
+    latestWakeLock = wl;
+    return wl;
+  }
+
+  @Implementation
+  protected boolean isScreenOn() {
+    return isInteractive;
+  }
+
+  /**
+   * @deprecated Use {@link #setIsInteractive(boolean)} instead.
+   */
+  @Deprecated
+  public void setIsScreenOn(boolean screenOn) {
+    setIsInteractive(screenOn);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isInteractive() {
+    return isInteractive;
+  }
+
+  /**
+   * @deprecated Prefer {@link #turnScreenOn(boolean)} instead.
+   */
+  @Deprecated
+  public void setIsInteractive(boolean interactive) {
+    isInteractive = interactive;
+  }
+
+  /** Emulates turning the screen on/off if the screen is not already on/off. */
+  public void turnScreenOn(boolean screenOn) {
+    if (isInteractive != screenOn) {
+      isInteractive = screenOn;
+      getContext().sendBroadcast(new Intent(screenOn ? ACTION_SCREEN_ON : ACTION_SCREEN_OFF));
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isPowerSaveMode() {
+    return isPowerSaveMode;
+  }
+
+  public void setIsPowerSaveMode(boolean powerSaveMode) {
+    isPowerSaveMode = powerSaveMode;
+  }
+
+  private Map<Integer, Boolean> supportedWakeLockLevels = new HashMap<>();
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isWakeLockLevelSupported(int level) {
+    return supportedWakeLockLevels.containsKey(level) ? supportedWakeLockLevels.get(level) : false;
+  }
+
+  public void setIsWakeLockLevelSupported(int level, boolean supported) {
+    supportedWakeLockLevels.put(level, supported);
+  }
+
+  /**
+   * @return false by default, or the value specified via {@link #setIsDeviceIdleMode(boolean)}
+   */
+  @Implementation(minSdk = M)
+  protected boolean isDeviceIdleMode() {
+    return isDeviceIdleMode;
+  }
+
+  /** Sets the value returned by {@link #isDeviceIdleMode()}. */
+  public void setIsDeviceIdleMode(boolean isDeviceIdleMode) {
+    this.isDeviceIdleMode = isDeviceIdleMode;
+  }
+
+  /**
+   * @return false by default, or the value specified via {@link #setIsLightDeviceIdleMode(boolean)}
+   */
+  @Implementation(minSdk = N)
+  protected boolean isLightDeviceIdleMode() {
+    return isLightDeviceIdleMode;
+  }
+
+  /** Sets the value returned by {@link #isLightDeviceIdleMode()}. */
+  public void setIsLightDeviceIdleMode(boolean lightDeviceIdleMode) {
+    isLightDeviceIdleMode = lightDeviceIdleMode;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected boolean isDeviceLightIdleMode() {
+    return isLightDeviceIdleMode();
+  }
+
+  /** Sets the value returned by {@link #isDeviceLightIdleMode()}. */
+  public void setIsDeviceLightIdleMode(boolean lightDeviceIdleMode) {
+    setIsLightDeviceIdleMode(lightDeviceIdleMode);
+  }
+
+  /**
+   * Returns how location features should behave when battery saver is on. When battery saver is
+   * off, this will always return {@link #LOCATION_MODE_NO_CHANGE}.
+   */
+  @Implementation(minSdk = P)
+  @PowerManager.LocationPowerSaveMode
+  protected int getLocationPowerSaveMode() {
+    if (!isPowerSaveMode()) {
+      return PowerManager.LOCATION_MODE_NO_CHANGE;
+    }
+    return locationMode;
+  }
+
+  /** Sets the value returned by {@link #getLocationPowerSaveMode()} when battery saver is on. */
+  public void setLocationPowerSaveMode(@PowerManager.LocationPowerSaveMode int locationMode) {
+    checkState(
+        locationMode >= PowerManager.MIN_LOCATION_MODE,
+        "Location Power Save Mode must be at least " + PowerManager.MIN_LOCATION_MODE);
+    checkState(
+        locationMode <= PowerManager.MAX_LOCATION_MODE,
+        "Location Power Save Mode must be no more than " + PowerManager.MAX_LOCATION_MODE);
+    this.locationMode = locationMode;
+  }
+
+  /** This function returns the current thermal status of the device. */
+  @Implementation(minSdk = Q)
+  protected int getCurrentThermalStatus() {
+    return thermalStatus;
+  }
+
+  /** This function adds a listener for thermal status change. */
+  @Implementation(minSdk = Q)
+  protected void addThermalStatusListener(Object listener) {
+    checkState(
+        listener instanceof PowerManager.OnThermalStatusChangedListener,
+        "Listener must implement PowerManager.OnThermalStatusChangedListener");
+    this.thermalListeners.add(listener);
+  }
+
+  /** This function gets listeners for thermal status change. */
+  public ImmutableSet<Object> getThermalStatusListeners() {
+    return ImmutableSet.copyOf(this.thermalListeners);
+  }
+
+  /** This function removes a listener for thermal status change. */
+  @Implementation(minSdk = Q)
+  protected void removeThermalStatusListener(Object listener) {
+    checkState(
+        listener instanceof PowerManager.OnThermalStatusChangedListener,
+        "Listener must implement PowerManager.OnThermalStatusChangedListener");
+    this.thermalListeners.remove(listener);
+  }
+
+  /** Sets the value returned by {@link #getCurrentThermalStatus()}. */
+  public void setCurrentThermalStatus(int thermalStatus) {
+    checkState(
+        thermalStatus >= PowerManager.THERMAL_STATUS_NONE,
+        "Thermal status must be at least " + PowerManager.THERMAL_STATUS_NONE);
+    checkState(
+        thermalStatus <= PowerManager.THERMAL_STATUS_SHUTDOWN,
+        "Thermal status must be no more than " + PowerManager.THERMAL_STATUS_SHUTDOWN);
+    this.thermalStatus = thermalStatus;
+    for (Object listener : thermalListeners) {
+      ((PowerManager.OnThermalStatusChangedListener) listener)
+          .onThermalStatusChanged(thermalStatus);
+    }
+  }
+
+  /** Discards the most recent {@code PowerManager.WakeLock}s */
+  @Resetter
+  public static void reset() {
+    clearWakeLocks();
+  }
+
+  /**
+   * Retrieves the most recent wakelock registered by the application
+   *
+   * @return Most recent wake lock.
+   */
+  public static PowerManager.WakeLock getLatestWakeLock() {
+    return latestWakeLock;
+  }
+
+  /** Clears most recent recorded wakelock. */
+  public static void clearWakeLocks() {
+    latestWakeLock = null;
+  }
+
+  /**
+   * Controls result from {@link #getLatestWakeLock()}
+   *
+   * @deprecated do not use
+   */
+  @Deprecated
+  static void addWakeLock(WakeLock wl) {
+    latestWakeLock = wl;
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean isIgnoringBatteryOptimizations(String packageName) {
+    Boolean result = ignoringBatteryOptimizations.get(packageName);
+    return result == null ? false : result;
+  }
+
+  public void setIgnoringBatteryOptimizations(String packageName, boolean value) {
+    ignoringBatteryOptimizations.put(packageName, Boolean.valueOf(value));
+  }
+
+  /**
+   * Differs from real implementation as device charging state is not checked.
+   *
+   * @param timeRemaining The time remaining as a {@link Duration}.
+   * @param isPersonalized true if personalized based on device usage history, false otherwise.
+   */
+  @SystemApi
+  @RequiresPermission(android.Manifest.permission.DEVICE_POWER)
+  @Implementation(minSdk = S)
+  protected void setBatteryDischargePrediction(
+      @NonNull Duration timeRemaining, boolean isPersonalized) {
+    this.batteryDischargePrediction = timeRemaining;
+    this.isBatteryDischargePredictionPersonalized = isPersonalized;
+  }
+
+  /**
+   * Returns the current battery life remaining estimate.
+   *
+   * <p>Differs from real implementation as the time that {@link #setBatteryDischargePrediction} was
+   * called is not taken into account.
+   *
+   * @return The estimated battery life remaining as a {@link Duration}. Will be {@code null} if the
+   *     prediction has not been set.
+   */
+  @Nullable
+  @Implementation(minSdk = S)
+  protected Duration getBatteryDischargePrediction() {
+    return this.batteryDischargePrediction;
+  }
+
+  /**
+   * Returns whether the current battery life remaining estimate is personalized based on device
+   * usage history or not. This value does not take a device's powered or charging state into
+   * account.
+   *
+   * @return A boolean indicating if the current discharge estimate is personalized based on
+   *     historical device usage or not.
+   */
+  @Implementation(minSdk = S)
+  protected boolean isBatteryDischargePredictionPersonalized() {
+    return this.isBatteryDischargePredictionPersonalized;
+  }
+
+  @Implementation
+  protected void reboot(String reason) {
+    if (RuntimeEnvironment.getApiLevel() >= R
+        && "userspace".equals(reason)
+        && !isRebootingUserspaceSupported()) {
+      throw new UnsupportedOperationException(
+          "Attempted userspace reboot on a device that doesn't support it");
+    }
+    rebootReasons.add(reason);
+  }
+
+  /** Returns the number of times {@link #reboot(String)} was called. */
+  public int getTimesRebooted() {
+    return rebootReasons.size();
+  }
+
+  /** Returns the list of reasons for each reboot, in chronological order. */
+  public ImmutableList<String> getRebootReasons() {
+    return ImmutableList.copyOf(rebootReasons);
+  }
+
+  /** Sets the value returned by {@link #isAmbientDisplayAvailable()}. */
+  public void setAmbientDisplayAvailable(boolean available) {
+    this.isAmbientDisplayAvailable = available;
+  }
+
+  /** Sets the value returned by {@link #isRebootingUserspaceSupported()}. */
+  public void setIsRebootingUserspaceSupported(boolean supported) {
+    this.isRebootingUserspaceSupported = supported;
+  }
+
+  /**
+   * Returns true by default, or the value specified via {@link
+   * #setAmbientDisplayAvailable(boolean)}.
+   */
+  @Implementation(minSdk = R)
+  @SystemApi
+  @RequiresPermission(permission.READ_DREAM_STATE)
+  protected boolean isAmbientDisplayAvailable() {
+    return isAmbientDisplayAvailable;
+  }
+
+  /**
+   * If true, suppress the device's ambient display. Ambient display is defined as anything visible
+   * on the display when {@link PowerManager#isInteractive} is false.
+   *
+   * @param token An identifier for the ambient display suppression.
+   * @param suppress If {@code true}, suppresses the ambient display. Otherwise, unsuppresses the
+   *     ambient display for the given token.
+   */
+  @Implementation(minSdk = R)
+  @SystemApi
+  @RequiresPermission(permission.WRITE_DREAM_STATE)
+  protected void suppressAmbientDisplay(String token, boolean suppress) {
+    String suppressionToken = Binder.getCallingUid() + "_" + token;
+    if (suppress) {
+      ambientDisplaySuppressionTokens.add(suppressionToken);
+    } else {
+      ambientDisplaySuppressionTokens.remove(suppressionToken);
+    }
+  }
+
+  /**
+   * Returns true if {@link #suppressAmbientDisplay(String, boolean)} has been called with any
+   * token.
+   */
+  @Implementation(minSdk = R)
+  @SystemApi
+  @RequiresPermission(permission.READ_DREAM_STATE)
+  protected boolean isAmbientDisplaySuppressed() {
+    return !ambientDisplaySuppressionTokens.isEmpty();
+  }
+
+  /**
+   * Returns last value specified in {@link #setIsRebootingUserspaceSupported(boolean)} or {@code
+   * false} by default.
+   */
+  @Implementation(minSdk = R)
+  @SystemApi
+  protected boolean isRebootingUserspaceSupported() {
+    return isRebootingUserspaceSupported;
+  }
+
+  /**
+   * Sets whether Adaptive Power Saver is enabled.
+   *
+   * <p>This has no effect, other than the value of {@link #getAdaptivePowerSaveEnabled()} is
+   * changed, which can be used to ensure this method is called correctly.
+   *
+   * @return true if the value has changed.
+   */
+  @Implementation(minSdk = Q)
+  @SystemApi
+  protected boolean setAdaptivePowerSaveEnabled(boolean enabled) {
+    boolean changed = adaptivePowerSaveEnabled != enabled;
+    adaptivePowerSaveEnabled = enabled;
+    return changed;
+  }
+
+  /** Gets the value set by {@link #setAdaptivePowerSaveEnabled(boolean)}. */
+  public boolean getAdaptivePowerSaveEnabled() {
+    return adaptivePowerSaveEnabled;
+  }
+
+  @Implements(PowerManager.WakeLock.class)
+  public static class ShadowWakeLock {
+    private boolean refCounted = true;
+    private WorkSource workSource = null;
+    private int timesHeld = 0;
+    private String tag = null;
+    private List<Optional<Long>> timeoutTimestampList = new ArrayList<>();
+
+    private void acquireInternal(Optional<Long> timeoutOptional) {
+      ++timesHeld;
+      timeoutTimestampList.add(timeoutOptional);
+    }
+
+    /** Iterate all the wake lock and remove those timeouted ones. */
+    private void refreshTimeoutTimestampList() {
+      timeoutTimestampList =
+          timeoutTimestampList.stream()
+              .filter(o -> !o.isPresent() || o.get() >= SystemClock.elapsedRealtime())
+              .collect(toCollection(ArrayList::new));
+    }
+
+    @Implementation
+    protected void acquire() {
+      acquireInternal(Optional.empty());
+    }
+
+    @Implementation
+    protected synchronized void acquire(long timeout) {
+      Long timeoutMillis = timeout + SystemClock.elapsedRealtime();
+      if (timeoutMillis > 0) {
+        acquireInternal(Optional.of(timeoutMillis));
+      } else {
+        // This is because many existing tests use Long.MAX_VALUE as timeout, which will cause a
+        // long overflow.
+        acquireInternal(Optional.empty());
+      }
+    }
+
+    /** Releases the wake lock. The {@code flags} are ignored. */
+    @Implementation
+    protected synchronized void release(int flags) {
+      refreshTimeoutTimestampList();
+
+      // Dequeue the wake lock with smallest timeout.
+      // Map the subtracted value to 1 and -1 to avoid long->int cast overflow.
+      Optional<Optional<Long>> wakeLockOptional =
+          timeoutTimestampList.stream()
+              .min(
+                  comparing(
+                      (Optional<Long> arg) -> arg.orElse(Long.MAX_VALUE),
+                      (Long leftProperty, Long rightProperty) ->
+                          (leftProperty - rightProperty) > 0 ? 1 : -1));
+
+      if (wakeLockOptional.isEmpty()) {
+        if (refCounted) {
+          throw new RuntimeException("WakeLock under-locked");
+        } else {
+          return;
+        }
+      }
+
+      Optional<Long> wakeLock = wakeLockOptional.get();
+
+      if (refCounted) {
+        timeoutTimestampList.remove(wakeLock);
+      } else {
+        // If a wake lock is not reference counted, then one call to release() is sufficient to undo
+        // the effect of all previous calls to acquire().
+        timeoutTimestampList = new ArrayList<>();
+      }
+    }
+
+    @Implementation
+    protected synchronized boolean isHeld() {
+      refreshTimeoutTimestampList();
+      return !timeoutTimestampList.isEmpty();
+    }
+
+    /**
+     * Retrieves if the wake lock is reference counted or not
+     *
+     * @return Is the wake lock reference counted?
+     */
+    public boolean isReferenceCounted() {
+      return refCounted;
+    }
+
+    @Implementation
+    protected void setReferenceCounted(boolean value) {
+      refCounted = value;
+    }
+
+    @Implementation
+    protected synchronized void setWorkSource(WorkSource ws) {
+      workSource = ws;
+    }
+
+    public synchronized WorkSource getWorkSource() {
+      return workSource;
+    }
+
+    /** Returns how many times the wakelock was held. */
+    public int getTimesHeld() {
+      return timesHeld;
+    }
+
+    /** Returns the tag. */
+    @HiddenApi
+    @Implementation(minSdk = O)
+    public String getTag() {
+      return tag;
+    }
+
+    /** Sets the tag. */
+    @Implementation(minSdk = LOLLIPOP_MR1)
+    protected void setTag(String tag) {
+      this.tag = tag;
+    }
+  }
+
+  private Context getContext() {
+    if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.JELLY_BEAN_MR1) {
+      return RuntimeEnvironment.getApplication();
+    } else {
+      return reflector(ReflectorPowerManager.class, realPowerManager).getContext();
+    }
+  }
+
+  /** Reflector interface for {@link PowerManager}'s internals. */
+  @ForType(PowerManager.class)
+  private interface ReflectorPowerManager {
+
+    @Accessor("mContext")
+    Context getContext();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPrecomputedText.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPrecomputedText.java
new file mode 100644
index 0000000..8e05d89
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPrecomputedText.java
@@ -0,0 +1,19 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(className = "android.text.PrecomputedText", minSdk = P, isInAndroidSdk = false)
+public class ShadowPrecomputedText {
+
+  private static int nativeCounter = 0;
+
+  @Implementation(maxSdk = O_MR1)
+  protected static long nInitBuilder() {
+    return ++nativeCounter;
+  }
+  
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPreference.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPreference.java
new file mode 100644
index 0000000..93f90b2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPreference.java
@@ -0,0 +1,30 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.preference.Preference;
+import android.preference.PreferenceManager;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(Preference.class)
+public class ShadowPreference {
+  @RealObject private Preference realPreference;
+
+  public void callOnAttachedToHierarchy(PreferenceManager preferenceManager) {
+    reflector(PreferenceReflector.class, realPreference).onAttachedToHierarchy(preferenceManager);
+  }
+
+  public boolean click() {
+    return realPreference.getOnPreferenceClickListener().onPreferenceClick(realPreference);
+  }
+
+  @ForType(Preference.class)
+  interface PreferenceReflector {
+
+    @Direct
+    void onAttachedToHierarchy(PreferenceManager preferenceManager);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowProcess.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowProcess.java
new file mode 100644
index 0000000..001391d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowProcess.java
@@ -0,0 +1,201 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import androidx.annotation.NonNull;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+import javax.annotation.concurrent.GuardedBy;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(android.os.Process.class)
+public class ShadowProcess {
+  private static int pid;
+  private static final int UID = getRandomApplicationUid();
+  private static Integer uidOverride;
+  private static int tid = getRandomApplicationUid();
+  private static final Object threadPrioritiesLock = new Object();
+  private static final Object killedProcessesLock = new Object();
+  // The range of thread priority values is specified by
+  // android.os.Process#setThreadPriority(int, int), which is [-20,19].
+  private static final int THREAD_PRIORITY_HIGHEST = -20;
+  private static final int THREAD_PRIORITY_LOWEST = 19;
+
+  @GuardedBy("threadPrioritiesLock")
+  private static final Map<Integer, Integer> threadPriorities = new HashMap<Integer, Integer>();
+
+  @GuardedBy("killedProcessesLock")
+  private static final Set<Integer> killedProcesses = new HashSet<>();
+
+  /**
+   * Stores requests for killing processes. Processe that were requested to be killed can be
+   * retrieved by calling {@link #wasKilled(int)}. Use {@link #clearKilledProcesses()} to clear the
+   * list.
+   */
+  @Implementation
+  protected static final void killProcess(int pid) {
+    synchronized (killedProcessesLock) {
+      killedProcesses.add(pid);
+    }
+  }
+
+  @Implementation
+  protected static final int myPid() {
+    return pid;
+  }
+
+  /**
+   * Returns the identifier of this process's uid. Unlike Android UIDs are randomly initialized to
+   * prevent tests from depending on any given value. Tests should access the current process UID
+   * via {@link android.os.Process#myUid()}. You can override this value by calling {@link
+   * #setUid(int)}.
+   */
+  @Implementation
+  protected static final int myUid() {
+    if (uidOverride != null) {
+      return uidOverride;
+    }
+    return UID;
+  }
+
+  /**
+   * Returns the identifier ({@link java.lang.Thread#getId()}) of the current thread ({@link
+   * java.lang.Thread#currentThread()}).
+   */
+  @Implementation
+  protected static final int myTid() {
+    return (int) Thread.currentThread().getId();
+  }
+
+  /**
+   * Stores priority for the current thread, but doesn't actually change it to not mess up with test
+   * runner. Unlike real implementation does not throw any exceptions.
+   */
+  @Implementation
+  protected static final void setThreadPriority(int priority) {
+    synchronized (threadPrioritiesLock) {
+      threadPriorities.put(ShadowProcess.myTid(), priority);
+    }
+  }
+
+  /**
+   * Stores priority for the given thread, but doesn't actually change it to not mess up with test
+   * runner. Unlike real implementation does not throw any exceptions.
+   *
+   * @param tid The identifier of the thread. If equals zero, the identifier of the calling thread
+   *     will be used.
+   * @param priority The priority to be set for the thread. The range of values accepted is
+   *     specified by {@link android.os.Process#setThreadPriority(int, int)}, which is [-20,19].
+   */
+  @Implementation
+  protected static final void setThreadPriority(int tid, int priority) {
+    checkArgument(
+        priority >= THREAD_PRIORITY_HIGHEST && priority <= THREAD_PRIORITY_LOWEST,
+        "priority %s out of range [%s, %s]. It is recommended to use a Process.THREAD_PRIORITY_*"
+            + " constant.",
+        priority,
+        Integer.toString(THREAD_PRIORITY_HIGHEST),
+        Integer.toString(THREAD_PRIORITY_LOWEST));
+
+    if (tid == 0) {
+      tid = ShadowProcess.myTid();
+    }
+    synchronized (threadPrioritiesLock) {
+      threadPriorities.put(tid, priority);
+    }
+  }
+
+  /**
+   * Returns priority stored for the given thread.
+   *
+   * @param tid The identifier of the thread. If equals zero, the identifier of the calling thread
+   *     will be used.
+   */
+  @Implementation
+  protected static final int getThreadPriority(int tid) {
+    if (tid == 0) {
+      tid = ShadowProcess.myTid();
+    }
+    synchronized (threadPrioritiesLock) {
+      return threadPriorities.getOrDefault(tid, 0);
+    }
+  }
+
+  public static void clearKilledProcesses() {
+    synchronized (killedProcessesLock) {
+      killedProcesses.clear();
+    }
+  }
+
+  /**
+   * Sets the identifier of this process.
+   */
+  public static void setUid(int uid) {
+    ShadowProcess.uidOverride = uid;
+  }
+
+  /**
+   * Sets the identifier of this process.
+   */
+  public static void setPid(int pid) {
+    ShadowProcess.pid = pid;
+  }
+
+  @Resetter
+  public static void reset() {
+    ShadowProcess.pid = 0;
+    ShadowProcess.clearKilledProcesses();
+    synchronized (threadPrioritiesLock) {
+      threadPriorities.clear();
+    }
+    // We cannot re-randomize uid, because it would break code that statically depends on
+    // android.os.Process.myUid(), which persists between tests.
+    ShadowProcess.uidOverride = null;
+    ShadowProcess.processName = "";
+  }
+
+  static int getRandomApplicationUid() {
+    // UIDs are randomly initialized to prevent tests from depending on any given value. Tests
+    // should access the current process UID via android.os.Process::myUid().
+    return ThreadLocalRandom.current()
+        .nextInt(
+            android.os.Process.FIRST_APPLICATION_UID, android.os.Process.LAST_APPLICATION_UID + 1);
+  }
+
+  /**
+   * Gets an indication of whether or not a process was killed (using {@link #killProcess(int)}).
+   */
+  public static boolean wasKilled(int pid) {
+    synchronized (killedProcessesLock) {
+      return killedProcesses.contains(pid);
+    }
+  }
+
+  private static String processName = "";
+
+  /**
+   * Returns the name of the process. You can override this value by calling {@link
+   * #setProcessName(String)}.
+   *
+   * @return process name.
+   */
+  @Implementation(minSdk = TIRAMISU)
+  protected static String myProcessName() {
+    return processName;
+  }
+
+  /**
+   * Sets the process name returned by {@link #myProcessName()}.
+   *
+   * @param processName New process name to set. Cannot be null.
+   */
+  public static void setProcessName(@NonNull String processName) {
+    ShadowProcess.processName = processName;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowProgressDialog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowProgressDialog.java
new file mode 100644
index 0000000..b21d042
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowProgressDialog.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.ProgressDialog;
+import android.widget.TextView;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ProgressDialog.class)
+public class ShadowProgressDialog extends ShadowAlertDialog {
+  @RealObject ProgressDialog realProgressDialog;
+
+  private int mProgressStyle;
+
+  /**
+   * @return the message displayed in the dialog
+   */
+  @Override
+  public CharSequence getMessage() {
+    if (mProgressStyle == ProgressDialog.STYLE_HORIZONTAL) {
+      return super.getMessage();
+    } else {
+      TextView message = ReflectionHelpers.getField(realProgressDialog, "mMessageView");
+      return message.getText();
+    }
+  }
+
+  @Implementation
+  protected void setProgressStyle(int style) {
+    mProgressStyle = style;
+    reflector(ProgressDialogReflector.class, realProgressDialog).setProgressStyle(style);
+  }
+
+  /**
+   * @return the style of the progress dialog
+   */
+  public int getProgressStyle() {
+    return mProgressStyle;
+  }
+
+  @ForType(ProgressDialog.class)
+  interface ProgressDialogReflector {
+
+    @Direct
+    void setProgressStyle(int style);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowQueuedWork.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowQueuedWork.java
new file mode 100644
index 0000000..32f0796
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowQueuedWork.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.QueuedWork;
+import android.os.Build;
+import android.os.Handler;
+import java.util.LinkedList;
+import java.util.concurrent.ExecutorService;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = QueuedWork.class, isInAndroidSdk = false)
+public class ShadowQueuedWork {
+
+  @Resetter
+  public static void reset() {
+
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.O) {
+      resetStateApi26();
+    } else {
+      QueuedWork.waitToFinish();
+      reflector(_QueuedWork_.class).setSingleThreadExecutor(null);
+    }
+  }
+
+  private static void resetStateApi26() {
+    Handler queuedWorkHandler = ReflectionHelpers.getStaticField(QueuedWork.class, "sHandler");
+    if (queuedWorkHandler != null) {
+      queuedWorkHandler.removeCallbacksAndMessages(null);
+    }
+    _QueuedWork_ _queuedWorkStatic_ = reflector(_QueuedWork_.class);
+    _queuedWorkStatic_.getFinishers().clear();
+    _queuedWorkStatic_.getWork().clear();
+    _queuedWorkStatic_.setNumWaits(0);
+    _queuedWorkStatic_.setHandler(null);
+  }
+
+  /** Accessor interface for {@link QueuedWork}'s internals. */
+  @ForType(QueuedWork.class)
+  interface _QueuedWork_ {
+
+    @Static @Accessor("sFinishers")
+    LinkedList<Runnable> getFinishers();
+
+    @Static @Accessor("sSingleThreadExecutor")
+    void setSingleThreadExecutor(ExecutorService o);
+
+    @Static @Accessor("sWork")
+    LinkedList<Runnable> getWork();
+
+    // yep, it starts with 'm' but it's static
+    @Static @Accessor("mNumWaits")
+    void setNumWaits(int i);
+
+    @Static
+    @Accessor("sHandler")
+    void setHandler(Handler handler);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowQuickAccessWalletService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowQuickAccessWalletService.java
new file mode 100644
index 0000000..e9f6fe5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowQuickAccessWalletService.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.annotation.SystemApi;
+import android.service.quickaccesswallet.QuickAccessWalletService;
+import android.service.quickaccesswallet.WalletServiceEvent;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nonnull;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow of {@link QuickAccessWalletService} */
+@Implements(
+    value = QuickAccessWalletService.class,
+    minSdk = R,
+    // turn off shadowOf generation
+    isInAndroidSdk = false)
+public class ShadowQuickAccessWalletService extends ShadowService {
+
+  private static final List<WalletServiceEvent> serviceEvents = new ArrayList<>(0);
+
+  /** Capture events sent by the service to SysUI */
+  @Implementation
+  @SystemApi
+  public final void sendWalletServiceEvent(@Nonnull WalletServiceEvent serviceEvent) {
+    serviceEvents.add(serviceEvent);
+  }
+
+  /** Returns a list of service events sent with {@link #sendWalletServiceEvent} */
+  public static List<WalletServiceEvent> getServiceEvents() {
+    return new ArrayList<>(serviceEvents);
+  }
+
+  @Resetter
+  public static void reset() {
+    serviceEvents.clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRangingResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRangingResult.java
new file mode 100644
index 0000000..2e25558
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRangingResult.java
@@ -0,0 +1,211 @@
+package org.robolectric.shadows;
+
+import android.net.MacAddress;
+import android.net.wifi.rtt.RangingResult;
+import android.net.wifi.rtt.ResponderLocation;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow for {@link android.net.wifi.rtt.RangingResult}. */
+@Implements(value = RangingResult.class, minSdk = VERSION_CODES.P)
+public class ShadowRangingResult {
+
+  /**
+   * A builder for creating ShadowRangingResults. Status, macaddress, distance [mm] and timestamp
+   * are all mandatory fields. Additional fields can be specified by setters. Use build() to return
+   * the ShadowRangingResult object.
+   */
+  public static class Builder {
+    // Required Values
+    private final int status;
+    private final MacAddress mac;
+    private final int distanceMm;
+    private final long timestampMillis;
+
+    // Optional Values
+    private int distanceStdDevMm = 0;
+    private int rssi = 0;
+    private int numAttemptedMeasurements = 0;
+    private int numSuccessfulMeasurements = 0;
+    private byte[] lci = new byte[0];
+    private byte[] lcr = new byte[0];
+    private ResponderLocation unverifiedResponderLocation = null;
+    private boolean is80211mcMeasurement = true;
+
+    public Builder(int status, MacAddress mac, long timestampMillis, int distanceMm) {
+      this.status = status;
+      this.mac = mac;
+      this.timestampMillis = timestampMillis;
+      this.distanceMm = distanceMm;
+    }
+
+    public Builder setDistanceStandardDeviation(int stddev) {
+      this.distanceStdDevMm = stddev;
+      return this;
+    }
+
+    public Builder setRssi(int rssi) {
+      this.rssi = rssi;
+      return this;
+    }
+
+    public Builder setNumAttemptedMeasurements(int num) {
+      this.numAttemptedMeasurements = num;
+      return this;
+    }
+
+    public Builder setNumSuccessfulMeasurements(int num) {
+      this.numSuccessfulMeasurements = num;
+      return this;
+    }
+
+    public Builder setLci(byte[] lci) {
+      this.lci = lci;
+      return this;
+    }
+
+    public Builder setLcr(byte[] lcr) {
+      this.lcr = lcr;
+      return this;
+    }
+
+    public Builder setUnverifiedResponderLocation(ResponderLocation unverifiedResponderLocation) {
+      this.unverifiedResponderLocation = unverifiedResponderLocation;
+      return this;
+    }
+
+    public Builder setIs80211mcMeasurement(boolean is80211mcMeasurement) {
+      this.is80211mcMeasurement = is80211mcMeasurement;
+      return this;
+    }
+
+    public RangingResult build() {
+      if (RuntimeEnvironment.getApiLevel() > Build.VERSION_CODES.R) {
+        return asRangingResultS(
+            status,
+            mac,
+            distanceMm,
+            distanceStdDevMm,
+            rssi,
+            numAttemptedMeasurements,
+            numSuccessfulMeasurements,
+            lci,
+            lcr,
+            unverifiedResponderLocation,
+            timestampMillis,
+            is80211mcMeasurement);
+      }
+
+      if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+        return asRangingResultQ(
+            status,
+            mac,
+            distanceMm,
+            distanceStdDevMm,
+            rssi,
+            numAttemptedMeasurements,
+            numSuccessfulMeasurements,
+            lci,
+            lcr,
+            unverifiedResponderLocation,
+            timestampMillis);
+      }
+
+      return asRangingResultP(
+          status,
+          mac,
+          distanceMm,
+          distanceStdDevMm,
+          rssi,
+          numAttemptedMeasurements,
+          numSuccessfulMeasurements,
+          lci,
+          lcr,
+          timestampMillis);
+    }
+  }
+
+  private static RangingResult asRangingResultP(
+      int status,
+      MacAddress mac,
+      int distanceMm,
+      int distanceStdDevMm,
+      int rssi,
+      int numAttemptedMeasurements,
+      int numSuccessfulMeasurements,
+      byte[] lci,
+      byte[] lcr,
+      long timestampMillis) {
+    return ReflectionHelpers.callConstructor(
+        RangingResult.class,
+        ReflectionHelpers.ClassParameter.from(int.class, status),
+        ReflectionHelpers.ClassParameter.from(MacAddress.class, mac),
+        ReflectionHelpers.ClassParameter.from(int.class, distanceMm),
+        ReflectionHelpers.ClassParameter.from(int.class, distanceStdDevMm),
+        ReflectionHelpers.ClassParameter.from(int.class, rssi),
+        ReflectionHelpers.ClassParameter.from(int.class, numAttemptedMeasurements),
+        ReflectionHelpers.ClassParameter.from(int.class, numSuccessfulMeasurements),
+        ReflectionHelpers.ClassParameter.from(byte[].class, lci),
+        ReflectionHelpers.ClassParameter.from(byte[].class, lcr),
+        ReflectionHelpers.ClassParameter.from(long.class, timestampMillis));
+  }
+
+  private static RangingResult asRangingResultQ(
+      int status,
+      MacAddress mac,
+      int distanceMm,
+      int distanceStdDevMm,
+      int rssi,
+      int numAttemptedMeasurements,
+      int numSuccessfulMeasurements,
+      byte[] lci,
+      byte[] lcr,
+      ResponderLocation unverifiedResponderLocation,
+      long timestampMillis) {
+    return ReflectionHelpers.callConstructor(
+        RangingResult.class,
+        ReflectionHelpers.ClassParameter.from(int.class, status),
+        ReflectionHelpers.ClassParameter.from(MacAddress.class, mac),
+        ReflectionHelpers.ClassParameter.from(int.class, distanceMm),
+        ReflectionHelpers.ClassParameter.from(int.class, distanceStdDevMm),
+        ReflectionHelpers.ClassParameter.from(int.class, rssi),
+        ReflectionHelpers.ClassParameter.from(int.class, numAttemptedMeasurements),
+        ReflectionHelpers.ClassParameter.from(int.class, numSuccessfulMeasurements),
+        ReflectionHelpers.ClassParameter.from(byte[].class, lci),
+        ReflectionHelpers.ClassParameter.from(byte[].class, lcr),
+        ReflectionHelpers.ClassParameter.from(ResponderLocation.class, unverifiedResponderLocation),
+        ReflectionHelpers.ClassParameter.from(long.class, timestampMillis));
+  }
+
+  private static RangingResult asRangingResultS(
+      int status,
+      MacAddress mac,
+      int distanceMm,
+      int distanceStdDevMm,
+      int rssi,
+      int numAttemptedMeasurements,
+      int numSuccessfulMeasurements,
+      byte[] lci,
+      byte[] lcr,
+      ResponderLocation unverifiedResponderLocation,
+      long timestamp,
+      boolean is80211mcMeasurement) {
+    return ReflectionHelpers.callConstructor(
+        RangingResult.class,
+        ReflectionHelpers.ClassParameter.from(int.class, status),
+        ReflectionHelpers.ClassParameter.from(MacAddress.class, mac),
+        ReflectionHelpers.ClassParameter.from(int.class, distanceMm),
+        ReflectionHelpers.ClassParameter.from(int.class, distanceStdDevMm),
+        ReflectionHelpers.ClassParameter.from(int.class, rssi),
+        ReflectionHelpers.ClassParameter.from(int.class, numAttemptedMeasurements),
+        ReflectionHelpers.ClassParameter.from(int.class, numSuccessfulMeasurements),
+        ReflectionHelpers.ClassParameter.from(byte[].class, lci),
+        ReflectionHelpers.ClassParameter.from(byte[].class, lcr),
+        ReflectionHelpers.ClassParameter.from(ResponderLocation.class, unverifiedResponderLocation),
+        ReflectionHelpers.ClassParameter.from(long.class, timestamp),
+        ReflectionHelpers.ClassParameter.from(boolean.class, is80211mcMeasurement));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRangingSession.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRangingSession.java
new file mode 100644
index 0000000..bc63b22
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRangingSession.java
@@ -0,0 +1,102 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.os.PersistableBundle;
+import android.uwb.RangingSession;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+/** Adds Robolectric support for UWB ranging. */
+@Implements(value = RangingSession.class, minSdk = VERSION_CODES.S, isInAndroidSdk = false)
+public class ShadowRangingSession {
+  /**
+   * Adapter interface for state change events, provided by the tester to dictate ranging results.
+   */
+  public interface Adapter {
+    void onOpen(RangingSession session, RangingSession.Callback callback, PersistableBundle params);
+
+    void onStart(
+        RangingSession session, RangingSession.Callback callback, PersistableBundle params);
+
+    void onReconfigure(
+        RangingSession session, RangingSession.Callback callback, PersistableBundle params);
+
+    void onStop(RangingSession session, RangingSession.Callback callback);
+
+    void onClose(RangingSession session, RangingSession.Callback callback);
+  }
+
+  static RangingSession newInstance(
+      Executor executor, RangingSession.Callback callback, Adapter adapter) {
+    RangingSession rangingSession =
+        RangingSessionBuilder.newBuilder().setExecutor(executor).setCallback(callback).build();
+
+    ShadowRangingSession shadow = Shadow.extract(rangingSession);
+    shadow.setCallback(callback, executor);
+    shadow.setAdapter(adapter);
+
+    return rangingSession;
+  }
+
+  @RealObject private RangingSession realRangingSession;
+
+  private RangingSession.Callback callback;
+  private Executor executor;
+  private Adapter adapter;
+
+  /**
+   * Forwards parameters and the session's callback to the Shadow's adapter, allowing the tester to
+   * dictate the results of the call.
+   */
+  @Implementation
+  protected void start(PersistableBundle params) {
+    executor.execute(() -> adapter.onStart(realRangingSession, callback, params));
+  }
+
+  /**
+   * Forwards parameters and the session's callback to the Shadow's adapter, allowing the tester to
+   * dictate the results of the call.
+   */
+  @Implementation
+  protected void reconfigure(PersistableBundle params) {
+    executor.execute(() -> adapter.onReconfigure(realRangingSession, callback, params));
+  }
+
+  /**
+   * Forwards parameters and the session's callback to the Shadow's adapter, allowing the tester to
+   * dictate the results of the call.
+   */
+  @Implementation
+  protected void stop() {
+    executor.execute(() -> adapter.onStop(realRangingSession, callback));
+  }
+
+  /**
+   * Forwards parameters and the session's callback to the Shadow's adapter, allowing the tester to
+   * dictate the results of the call.
+   */
+  @Implementation
+  protected void close() {
+    executor.execute(() -> adapter.onClose(realRangingSession, callback));
+  }
+
+  /**
+   * Forwards parameters and the session's callback to the Shadow's adapter, allowing the tester to
+   * dictate the results of the call.
+   */
+  void open(PersistableBundle params) {
+    executor.execute(() -> adapter.onOpen(realRangingSession, callback, params));
+  }
+
+  private void setCallback(RangingSession.Callback callback, Executor executor) {
+    this.callback = callback;
+    this.executor = executor;
+  }
+
+  private void setAdapter(Adapter adapter) {
+    this.adapter = adapter;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRanking.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRanking.java
new file mode 100644
index 0000000..67c7012
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRanking.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.NotificationChannel;
+import android.os.Build.VERSION_CODES;
+import android.service.notification.NotificationListenerService.Ranking;
+import java.util.ArrayList;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.service.notification.NotificationListenerService.Ranking}. */
+@Implements(value = Ranking.class, minSdk = VERSION_CODES.KITKAT_WATCH)
+public class ShadowRanking {
+  @RealObject private Ranking realObject;
+
+  /** Overrides the return value for {@link Ranking#getChannel()}. */
+  public void setChannel(NotificationChannel notificationChannel) {
+    reflector(RankingReflector.class, realObject).setChannel(notificationChannel);
+  }
+
+  /** Overrides the return value for {@link Ranking#getSmartReplies()}. */
+  public void setSmartReplies(ArrayList<CharSequence> smartReplies) {
+    reflector(RankingReflector.class, realObject).setSmartReplies(smartReplies);
+  }
+
+  /** Accessor interface for {@link Ranking}'s internals. */
+  @ForType(Ranking.class)
+  interface RankingReflector {
+
+    @Accessor("mChannel")
+    void setChannel(NotificationChannel notificationChannel);
+
+    @Accessor("mSmartReplies")
+    void setSmartReplies(ArrayList<CharSequence> smartReplies);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRcsUceAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRcsUceAdapter.java
new file mode 100644
index 0000000..2f97d9c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRcsUceAdapter.java
@@ -0,0 +1,127 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.net.Uri;
+import android.telephony.ims.RcsContactUceCapability;
+import android.telephony.ims.RcsUceAdapter;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** A shadow for {@link RcsUceAdapter}. */
+@Implements(
+    value = RcsUceAdapter.class,
+    // turn off shadowOf generation
+    isInAndroidSdk = false,
+    minSdk = R)
+public class ShadowRcsUceAdapter {
+  private static final Set<Integer> subscriptionIdsWithUceSettingEnabled = new HashSet<>();
+  private static final Map<Uri, RcsContactUceCapability> capabilitiesMap = new HashMap<>();
+  private static final Map<Uri, CapabilityFailureInfo> capabilitiesFailureMap = new HashMap<>();
+
+  @RealObject private RcsUceAdapter realRcsUceAdapter;
+
+  /**
+   * Overrides the value returned by {@link RcsUceAdapter#isUceSettingEnabled()} for RcsUceAdapters
+   * associated with {@code subscriptionId}.
+   */
+  public static void setUceSettingEnabledForSubscriptionId(
+      int subscriptionId, boolean uceSettingEnabled) {
+    if (uceSettingEnabled) {
+      subscriptionIdsWithUceSettingEnabled.add(subscriptionId);
+    } else {
+      subscriptionIdsWithUceSettingEnabled.remove(subscriptionId);
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    subscriptionIdsWithUceSettingEnabled.clear();
+    capabilitiesMap.clear();
+    capabilitiesFailureMap.clear();
+  }
+
+  /**
+   * Returns the value specified for the {@code subscriptionId} corresponding to the {@link
+   * RcsUceAdapter} by {@link ShadowRcsUceAdapter#setUceSettingEnabledForSubscriptionId(int,
+   * boolean)}. If no value has been specified, returns false.
+   */
+  @Implementation
+  protected boolean isUceSettingEnabled() {
+    int subscriptionId = reflector(ReflectorRcsUceAdapter.class, realRcsUceAdapter).getSubId();
+    return subscriptionIdsWithUceSettingEnabled.contains(subscriptionId);
+  }
+
+  public static void setCapabilitiesForUri(Uri uri, RcsContactUceCapability capabilities) {
+    capabilitiesMap.put(uri, capabilities);
+  }
+
+  public static void setCapabilitiesFailureForUri(Uri uri, CapabilityFailureInfo failureInfo) {
+    capabilitiesFailureMap.put(uri, failureInfo);
+  }
+
+  @Implementation(minSdk = S)
+  protected void requestCapabilities(
+      Collection<Uri> contactNumbers, Executor executor, RcsUceAdapter.CapabilitiesCallback c) {
+    boolean completedSuccessfully = true;
+    for (Uri contact : contactNumbers) {
+      if (capabilitiesFailureMap.containsKey(contact)) {
+        CapabilityFailureInfo failureInfo = capabilitiesFailureMap.get(contact);
+        executor.execute(() -> c.onError(failureInfo.errorCode(), failureInfo.retryMillis()));
+        completedSuccessfully = false;
+        break;
+      }
+      if (capabilitiesMap.containsKey(contact)) {
+        executor.execute(
+            () -> c.onCapabilitiesReceived(ImmutableList.of(capabilitiesMap.get(contact))));
+      } else {
+        executor.execute(
+            () ->
+                c.onCapabilitiesReceived(
+                    ImmutableList.of(new RcsContactUceCapability.OptionsBuilder(contact).build())));
+      }
+    }
+    if (completedSuccessfully) {
+      executor.execute(c::onComplete);
+    }
+  }
+
+  @Implementation(minSdk = S)
+  protected void requestAvailability(
+      Uri contactNumber, Executor executor, RcsUceAdapter.CapabilitiesCallback c) {
+    requestCapabilities(ImmutableList.of(contactNumber), executor, c);
+  }
+
+  /** A data class holding the info for a failed capabilities exchange */
+  @AutoValue
+  public abstract static class CapabilityFailureInfo {
+    public static CapabilityFailureInfo create(int errorCode, long retryMillis) {
+      return new AutoValue_ShadowRcsUceAdapter_CapabilityFailureInfo(errorCode, retryMillis);
+    }
+
+    public abstract int errorCode();
+
+    public abstract long retryMillis();
+  }
+
+  /** Accessor interface for {@link RcsUceAdapter}'s internals. */
+  @ForType(RcsUceAdapter.class)
+  private interface ReflectorRcsUceAdapter {
+    @Accessor("mSubId")
+    int getSubId();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRecordingCanvas.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRecordingCanvas.java
new file mode 100644
index 0000000..1048d56
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRecordingCanvas.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.graphics.RecordingCanvas;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = RecordingCanvas.class, isInAndroidSdk = false, minSdk = Q)
+public class ShadowRecordingCanvas extends ShadowCanvas {
+
+  @Implementation
+  protected static long nCreateDisplayListCanvas(long node, int width, int height) {
+    return 1;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRegion.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRegion.java
new file mode 100644
index 0000000..753e322
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRegion.java
@@ -0,0 +1,48 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Region;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(Region.class)
+public class ShadowRegion {
+  @RealObject Region realRegion;
+
+  public static long nextId = 1;
+
+  /**
+   * The real {@link Region#equals(Object)} calls into native code, which is a no-op in Robolectric,
+   * and will always return false no matter what is compared. We can special-case some simple
+   * scenarios here.
+   */
+  @Implementation
+  @SuppressWarnings("EqualsHashCode")
+  public boolean equals(Object obj) {
+    if (obj == realRegion) {
+      return true;
+    }
+    if (!(obj instanceof Region)) {
+      return false;
+    }
+    return reflector(RegionReflector.class, realRegion).equals(obj);
+  }
+
+  @HiddenApi
+  @Implementation
+  protected static Number nativeConstructor() {
+    return RuntimeEnvironment.castNativePtr(nextId++);
+  }
+
+  @ForType(Region.class)
+  interface RegionReflector {
+    @Direct
+    boolean equals(Object obj);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRemoteCallbackList.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRemoteCallbackList.java
new file mode 100644
index 0000000..84e41c1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRemoteCallbackList.java
@@ -0,0 +1,140 @@
+package org.robolectric.shadows;
+
+import android.os.IBinder;
+import android.os.IInterface;
+import android.os.RemoteCallbackList;
+import android.os.RemoteException;
+import java.util.HashMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(RemoteCallbackList.class)
+public class ShadowRemoteCallbackList<E extends IInterface> {
+  private final HashMap<IBinder, Callback> callbacks = new HashMap<>();
+  private Object[] activeBroadcast;
+  private int broadcastCount = -1;
+  private boolean killed = false;
+
+  private final class Callback implements IBinder.DeathRecipient {
+    final E callback;
+    final Object cookie;
+
+    Callback(E callback, Object cookie) {
+      this.callback = callback;
+      this.cookie = cookie;
+    }
+
+    @Override public void binderDied() {
+      synchronized (callbacks) {
+        callbacks.remove(callback.asBinder());
+      }
+      onCallbackDied(callback, cookie);
+    }
+  }
+
+  @Implementation
+  protected boolean register(E callback) {
+    return register(callback, null);
+  }
+
+  @Implementation
+  protected boolean register(E callback, Object cookie) {
+    synchronized (callbacks) {
+      if (killed) {
+        return false;
+      }
+      IBinder binder = callback.asBinder();
+      try {
+        Callback cb = new Callback(callback, cookie);
+        binder.linkToDeath(cb, 0);
+        callbacks.put(binder, cb);
+        return true;
+      } catch (RemoteException e) {
+        return false;
+      }
+    }
+  }
+
+  @Implementation
+  protected boolean unregister(E callback) {
+    synchronized (callbacks) {
+      Callback cb = callbacks.remove(callback.asBinder());
+      if (cb != null) {
+        cb.callback.asBinder().unlinkToDeath(cb, 0);
+        return true;
+      }
+      return false;
+    }
+  }
+
+  @Implementation
+  protected void kill() {
+    synchronized (callbacks) {
+      for (Callback cb : callbacks.values()) {
+        cb.callback.asBinder().unlinkToDeath(cb, 0);
+      }
+      callbacks.clear();
+      killed = true;
+    }
+  }
+
+  @Implementation
+  protected void onCallbackDied(E callback) {}
+
+  @Implementation
+  protected void onCallbackDied(E callback, Object cookie) {
+    onCallbackDied(callback);
+  }
+
+  @Implementation
+  protected int beginBroadcast() {
+    synchronized (callbacks) {
+      if (broadcastCount > 0) {
+        throw new IllegalStateException("beginBroadcast() called while already in a broadcast");
+      }
+      final int N = broadcastCount = callbacks.size();
+      if (N <= 0) {
+        return 0;
+      }
+      Object[] active = activeBroadcast;
+      if (active == null || active.length < N) {
+        activeBroadcast = active = new Object[N];
+      }
+      int i = 0;
+      for (Callback cb : callbacks.values()) {
+        active[i++] = cb;
+      }
+      return i;
+    }
+  }
+
+  @Implementation
+  protected E getBroadcastItem(int index) {
+    return ((Callback) activeBroadcast[index]).callback;
+  }
+
+  @Implementation
+  protected Object getBroadcastCookie(int index) {
+    return ((Callback) activeBroadcast[index]).cookie;
+  }
+
+  @Implementation
+  protected void finishBroadcast() {
+    if (broadcastCount < 0) {
+      throw new IllegalStateException("finishBroadcast() called outside of a broadcast");
+    }
+    Object[] active = activeBroadcast;
+    if (active != null) {
+      final int N = broadcastCount;
+      for (int i = 0; i < N; i++) {
+        active[i] = null;
+      }
+    }
+    broadcastCount = -1;
+  }
+
+  @Implementation(minSdk = 17)
+  protected int getRegisteredCallbackCount() {
+    return callbacks.size();
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNode.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNode.java
new file mode 100644
index 0000000..6c3d62b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNode.java
@@ -0,0 +1,382 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.graphics.Camera;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(
+    className = "android.view.RenderNode",
+    isInAndroidSdk = false,
+    minSdk = LOLLIPOP,
+    maxSdk = P)
+public class ShadowRenderNode {
+  private static final float NON_ZERO_EPSILON = 0.001f;
+
+  private float alpha = 1f;
+  private float cameraDistance;
+  private boolean clipToOutline;
+  private float elevation;
+  private boolean overlappingRendering;
+  private boolean pivotExplicitlySet;
+  private float pivotX;
+  private float pivotY;
+  private float rotation;
+  private float rotationX;
+  private float rotationY;
+  private float scaleX = 1f;
+  private float scaleY = 1f;
+  private float translationX;
+  private float translationY;
+  private float translationZ;
+  private int left;
+  private int top;
+  private int right;
+  private int bottom;
+
+  @Implementation
+  protected boolean setAlpha(float alpha) {
+    this.alpha = alpha;
+    return true;
+  }
+
+  @Implementation
+  protected float getAlpha() {
+    return alpha;
+  }
+
+  @Implementation
+  protected boolean setCameraDistance(float cameraDistance) {
+    this.cameraDistance = cameraDistance;
+    return true;
+  }
+
+  @Implementation
+  protected float getCameraDistance() {
+    return cameraDistance;
+  }
+
+  @Implementation
+  protected boolean setClipToOutline(boolean clipToOutline) {
+    this.clipToOutline = clipToOutline;
+    return true;
+  }
+
+  @Implementation
+  protected boolean getClipToOutline() {
+    return clipToOutline;
+  }
+
+  @Implementation
+  protected boolean setElevation(float lift) {
+    elevation = lift;
+    return true;
+  }
+
+  @Implementation
+  protected float getElevation() {
+    return elevation;
+  }
+
+  @Implementation
+  protected boolean setHasOverlappingRendering(boolean overlappingRendering) {
+    this.overlappingRendering = overlappingRendering;
+    return true;
+  }
+
+  @Implementation
+  protected boolean hasOverlappingRendering() {
+    return overlappingRendering;
+  }
+
+  @Implementation
+  protected boolean setRotation(float rotation) {
+    this.rotation = rotation;
+    return true;
+  }
+
+  @Implementation
+  protected float getRotation() {
+    return rotation;
+  }
+
+  @Implementation
+  protected boolean setRotationX(float rotationX) {
+    this.rotationX = rotationX;
+    return true;
+  }
+
+  @Implementation
+  protected float getRotationX() {
+    return rotationX;
+  }
+
+  @Implementation
+  protected boolean setRotationY(float rotationY) {
+    this.rotationY = rotationY;
+    return true;
+  }
+
+  @Implementation
+  protected float getRotationY() {
+    return rotationY;
+  }
+
+  @Implementation
+  protected boolean setScaleX(float scaleX) {
+    this.scaleX = scaleX;
+    return true;
+  }
+
+  @Implementation
+  protected float getScaleX() {
+    return scaleX;
+  }
+
+  @Implementation
+  protected boolean setScaleY(float scaleY) {
+    this.scaleY = scaleY;
+    return true;
+  }
+
+  @Implementation
+  protected float getScaleY() {
+    return scaleY;
+  }
+
+  @Implementation
+  protected boolean setTranslationX(float translationX) {
+    this.translationX = translationX;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setTranslationY(float translationY) {
+    this.translationY = translationY;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setTranslationZ(float translationZ) {
+    this.translationZ = translationZ;
+    return true;
+  }
+
+  @Implementation
+  protected float getTranslationX() {
+    return translationX;
+  }
+
+  @Implementation
+  protected float getTranslationY() {
+    return translationY;
+  }
+
+  @Implementation
+  protected float getTranslationZ() {
+    return translationZ;
+  }
+
+  @Implementation
+  protected boolean isPivotExplicitlySet() {
+    return pivotExplicitlySet;
+  }
+
+  @Implementation
+  protected boolean resetPivot() {
+    this.pivotExplicitlySet = false;
+    this.pivotX = 0;
+    this.pivotY = 0;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setPivotX(float pivotX) {
+    this.pivotX = pivotX;
+    this.pivotExplicitlySet = true;
+    return true;
+  }
+
+  @Implementation
+  protected float getPivotX() {
+    return pivotX;
+  }
+
+  @Implementation
+  protected boolean setPivotY(float pivotY) {
+    this.pivotY = pivotY;
+    this.pivotExplicitlySet = true;
+    return true;
+  }
+
+  @Implementation
+  protected float getPivotY() {
+    return pivotY;
+  }
+
+  @Implementation
+  protected boolean setLeft(int left) {
+    this.left = left;
+    return true;
+  }
+
+  @Implementation
+  protected int getLeft() {
+    return left;
+  }
+
+  @Implementation
+  protected boolean setTop(int top) {
+    this.top = top;
+    return true;
+  }
+
+  @Implementation
+  protected int getTop() {
+    return top;
+  }
+
+  @Implementation
+  protected boolean setRight(int right) {
+    this.right = right;
+    return true;
+  }
+
+  @Implementation
+  protected int getRight() {
+    return right;
+  }
+
+  @Implementation
+  protected boolean setBottom(int bottom) {
+    this.bottom = bottom;
+    return true;
+  }
+
+  @Implementation
+  protected int getBottom() {
+    return bottom;
+  }
+
+  @Implementation
+  protected int getWidth() {
+    return right - left;
+  }
+
+  @Implementation
+  protected int getHeight() {
+    return bottom - top;
+  }
+
+  @Implementation
+  protected boolean setLeftTopRightBottom(int left, int top, int right, int bottom) {
+    return setPosition(left, top, right, bottom);
+  }
+
+  @Implementation
+  protected boolean setPosition(int left, int top, int right, int bottom) {
+    this.left = left;
+    this.top = top;
+    this.right = right;
+    this.bottom = bottom;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setPosition(Rect position) {
+    this.left = position.left;
+    this.top = position.top;
+    this.right = position.right;
+    this.bottom = position.bottom;
+    return true;
+  }
+
+  @Implementation
+  protected boolean offsetLeftAndRight(int offset) {
+    this.left += offset;
+    this.right += offset;
+    return true;
+  }
+
+  @Implementation
+  protected boolean offsetTopAndBottom(int offset) {
+    this.top += offset;
+    this.bottom += offset;
+    return true;
+  }
+
+  @Implementation
+  protected void getInverseMatrix(Matrix matrix) {
+    getMatrix(matrix);
+    matrix.invert(matrix);
+  }
+
+  @Implementation
+  protected void getMatrix(Matrix matrix) {
+    if (!pivotExplicitlySet) {
+      pivotX = getWidth() / 2f;
+      pivotY = getHeight() / 2f;
+    }
+    matrix.reset();
+    if (isZero(rotationX) && isZero(rotationY)) {
+      matrix.setTranslate(translationX, translationY);
+      matrix.preRotate(rotation, pivotX, pivotY);
+      matrix.preScale(scaleX, scaleY, pivotX, pivotY);
+    } else {
+      matrix.preScale(scaleX, scaleY, pivotX, pivotY);
+      Camera camera = new Camera();
+      camera.rotateX(rotationX);
+      camera.rotateY(rotationY);
+      camera.rotateZ(-rotation);
+      Matrix transform = new Matrix();
+      camera.getMatrix(transform);
+      transform.preTranslate(-pivotX, -pivotY);
+      transform.postTranslate(pivotX + translationX, pivotY + translationY);
+      matrix.postConcat(transform);
+    }
+  }
+
+  @Implementation
+  protected boolean hasIdentityMatrix() {
+    Matrix matrix = new Matrix();
+    getMatrix(matrix);
+    return matrix.isIdentity();
+  }
+
+  @Implementation
+  protected boolean isValid() {
+    return true;
+  }
+
+  /**
+   * Implementation of native method nSetLayerType
+   *
+   * @param renderNode Ignored
+   * @param layerType Ignored
+   * @return Always true
+   */
+  @Implementation
+  protected static boolean nSetLayerType(long renderNode, int layerType) {
+    return true;
+  }
+
+  /**
+   * Implementation of native method nSetLayerPaint
+   *
+   * @param renderNode Ignored
+   * @param paint Ignored
+   * @return Always true
+   */
+  @Implementation
+  protected static boolean nSetLayerPaint(long renderNode, long paint) {
+    return true;
+  }
+
+  private static boolean isZero(float value) {
+    return Math.abs(value) <= NON_ZERO_EPSILON;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeAnimator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeAnimator.java
new file mode 100644
index 0000000..a35ed8b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeAnimator.java
@@ -0,0 +1,155 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.Choreographer;
+import android.view.Choreographer.FrameCallback;
+import android.view.RenderNodeAnimator;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = RenderNodeAnimator.class, isInAndroidSdk = false, minSdk = LOLLIPOP, maxSdk = Q)
+public class ShadowRenderNodeAnimator {
+  private static final int STATE_FINISHED = 3;
+
+  @RealObject RenderNodeAnimator realObject;
+  private Choreographer choreographer = Choreographer.getInstance();
+  private boolean scheduled = false;
+  private long startTime = -1;
+  private boolean isEnding = false;
+
+  @Resetter
+  public static void reset() {
+    // sAnimationHelper is a static field used for processing delayed animations. Since it registers
+    // callbacks on the Choreographer, this is a problem if not reset between tests (as once the
+    // test is complete, its scheduled callbacks would be removed, but the static object would still
+    // believe it was registered and not re-register for the next test).
+    if (RuntimeEnvironment.getApiLevel() <= Q) {
+      reflector(RenderNodeAnimatorReflector.class).setAnimationHelper(new ThreadLocal<>());
+    }
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  public void moveToRunningState() {
+    reflector(RenderNodeAnimatorReflector.class, realObject).moveToRunningState();
+    if (!isEnding) {
+      // Only schedule if this wasn't called during an end() call, as Robolectric will run any
+      // Choreographer callbacks synchronously when unpaused (and thus end up running the full
+      // animation even though RenderNodeAnimator just wanted to kick it into STATE_STARTED).
+      schedule();
+    }
+  }
+
+  @Implementation
+  public void doStart() {
+    reflector(RenderNodeAnimatorReflector.class, realObject).doStart();
+    if (getApiLevel() <= LOLLIPOP) {
+      schedule();
+    }
+  }
+
+  @Implementation
+  public void cancel() {
+    RenderNodeAnimatorReflector renderNodeReflector =
+        reflector(RenderNodeAnimatorReflector.class, realObject);
+    renderNodeReflector.cancel();
+    if (getApiLevel() <= LOLLIPOP) {
+      int state = renderNodeReflector.getState();
+      if (state != STATE_FINISHED) {
+        // In 21, RenderNodeAnimator only calls nEnd, it doesn't call the Java end method. Thus, it
+        // expects the native code will end up calling onFinished, so we do that here.
+        renderNodeReflector.onFinished();
+      }
+    }
+  }
+
+  @Implementation
+  public void end() {
+    RenderNodeAnimatorReflector renderNodeReflector =
+        reflector(RenderNodeAnimatorReflector.class, realObject);
+
+    // Set this to true to prevent us from scheduling and running the full animation on the end()
+    // call. This can happen if the animation had not been started yet.
+    isEnding = true;
+    renderNodeReflector.end();
+    isEnding = false;
+    unschedule();
+
+    int state = renderNodeReflector.getState();
+    if (state != STATE_FINISHED) {
+      // This means that the RenderNodeAnimator called out to native code to finish the animation,
+      // expecting that it would end up calling onFinished. Since that won't happen in Robolectric,
+      // we call onFinished ourselves.
+      renderNodeReflector.onFinished();
+    }
+  }
+
+  private void schedule() {
+    if (!scheduled) {
+      scheduled = true;
+      choreographer.postFrameCallback(frameCallback);
+    }
+  }
+
+  private void unschedule() {
+    if (scheduled) {
+      choreographer.removeFrameCallback(frameCallback);
+      scheduled = false;
+    }
+  }
+
+  private final FrameCallback frameCallback =
+      new FrameCallback() {
+        @Override
+        public void doFrame(long frameTimeNanos) {
+          scheduled = false;
+          if (startTime == -1) {
+            startTime = frameTimeNanos;
+          }
+
+          long duration = realObject.getDuration();
+          long curTime = frameTimeNanos - startTime;
+          if (curTime >= duration) {
+            reflector(RenderNodeAnimatorReflector.class, realObject).onFinished();
+          } else {
+            schedule();
+          }
+        }
+      };
+
+  @ForType(value = RenderNodeAnimator.class)
+  interface RenderNodeAnimatorReflector {
+
+    @Accessor("mState")
+    int getState();
+
+    @Static
+    @Accessor("sAnimationHelper")
+    void setAnimationHelper(ThreadLocal<?> threadLocal);
+
+    void onFinished();
+
+    @Direct
+    void doStart();
+
+    @Direct
+    void cancel();
+
+    @Direct
+    void moveToRunningState();
+
+    @Direct
+    void end();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeAnimatorR.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeAnimatorR.java
new file mode 100644
index 0000000..a8f7f4d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeAnimatorR.java
@@ -0,0 +1,150 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.animation.RenderNodeAnimator;
+import android.view.Choreographer;
+import android.view.Choreographer.FrameCallback;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/**
+ * Copy of ShadowRenderNodeAnimator that reflects move of RenderNodeAnimator to android.graphics in
+ * R
+ */
+@Implements(value = RenderNodeAnimator.class, minSdk = R, isInAndroidSdk = false)
+public class ShadowRenderNodeAnimatorR {
+  private static final int STATE_FINISHED = 3;
+
+  @RealObject RenderNodeAnimator realObject;
+  private boolean scheduled = false;
+  private long startTime = -1;
+  private boolean isEnding = false;
+
+  @Resetter
+  public static void reset() {
+    // sAnimationHelper is a static field used for processing delayed animations. Since it registers
+    // callbacks on the Choreographer, this is a problem if not reset between tests (as once the
+    // test is complete, its scheduled callbacks would be removed, but the static object would still
+    // believe it was registered and not re-register for the next test).
+    reflector(RenderNodeAnimatorReflector.class).setAnimationHelper(new ThreadLocal<>());
+  }
+
+  @Implementation
+  public void moveToRunningState() {
+    reflector(RenderNodeAnimatorReflector.class, realObject).moveToRunningState();
+    if (!isEnding) {
+      // Only schedule if this wasn't called during an end() call, as Robolectric will run any
+      // Choreographer callbacks synchronously when unpaused (and thus end up running the full
+      // animation even though RenderNodeAnimator just wanted to kick it into STATE_STARTED).
+      schedule();
+    }
+  }
+
+  @Implementation
+  public void doStart() {
+    reflector(RenderNodeAnimatorReflector.class, realObject).doStart();
+    schedule();
+  }
+
+  @Implementation
+  public void cancel() {
+    RenderNodeAnimatorReflector renderNodeReflector =
+        reflector(RenderNodeAnimatorReflector.class, realObject);
+    renderNodeReflector.cancel();
+
+    int state = renderNodeReflector.getState();
+
+    if (state != STATE_FINISHED) {
+      // In 21, RenderNodeAnimator only calls nEnd, it doesn't call the Java end method. Thus, it
+      // expects the native code will end up calling onFinished, so we do that here.
+      renderNodeReflector.onFinished();
+    }
+  }
+
+  @Implementation
+  public void end() {
+    RenderNodeAnimatorReflector renderNodeReflector =
+        reflector(RenderNodeAnimatorReflector.class, realObject);
+
+    // Set this to true to prevent us from scheduling and running the full animation on the end()
+    // call. This can happen if the animation had not been started yet.
+    isEnding = true;
+    renderNodeReflector.end();
+    isEnding = false;
+    unschedule();
+
+    int state = renderNodeReflector.getState();
+    if (state != STATE_FINISHED) {
+      // This means that the RenderNodeAnimator called out to native code to finish the animation,
+      // expecting that it would end up calling onFinished. Since that won't happen in Robolectric,
+      // we call onFinished ourselves.
+      renderNodeReflector.onFinished();
+    }
+  }
+
+  private void schedule() {
+    if (!scheduled) {
+      scheduled = true;
+      Choreographer.getInstance().postFrameCallback(frameCallback);
+    }
+  }
+
+  private void unschedule() {
+    if (scheduled) {
+      Choreographer.getInstance().removeFrameCallback(frameCallback);
+      scheduled = false;
+    }
+  }
+
+  private final FrameCallback frameCallback =
+      new FrameCallback() {
+        @Override
+        public void doFrame(long frameTimeNanos) {
+          scheduled = false;
+          if (startTime == -1) {
+            startTime = frameTimeNanos;
+          }
+
+          long duration = realObject.getDuration();
+          long curTime = frameTimeNanos - startTime;
+          if (curTime >= duration) {
+            reflector(RenderNodeAnimatorReflector.class, realObject).onFinished();
+          } else {
+            schedule();
+          }
+        }
+      };
+
+  @ForType(value = RenderNodeAnimator.class)
+  interface RenderNodeAnimatorReflector {
+
+    @Accessor("mState")
+    int getState();
+
+    @Static
+    @Accessor("sAnimationHelper")
+    void setAnimationHelper(ThreadLocal<?> threadLocal);
+
+    void onFinished();
+
+    @Direct
+    void doStart();
+
+    @Direct
+    void cancel();
+
+    @Direct
+    void moveToRunningState();
+
+    @Direct
+    void end();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeQ.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeQ.java
new file mode 100644
index 0000000..5c5c67d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRenderNodeQ.java
@@ -0,0 +1,353 @@
+package org.robolectric.shadows;
+
+import android.graphics.Camera;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RenderNode;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = RenderNode.class, isInAndroidSdk = false, minSdk = Build.VERSION_CODES.Q)
+public class ShadowRenderNodeQ {
+  private static final float NON_ZERO_EPSILON = 0.001f;
+
+  private float alpha = 1f;
+  private float cameraDistance;
+  private boolean clipToOutline;
+  private float elevation;
+  private boolean overlappingRendering;
+  private boolean pivotExplicitlySet;
+  private float pivotX;
+  private float pivotY;
+  private float rotation;
+  private float rotationX;
+  private float rotationY;
+  private float scaleX = 1f;
+  private float scaleY = 1f;
+  private float translationX;
+  private float translationY;
+  private float translationZ;
+  private int left;
+  private int top;
+  private int right;
+  private int bottom;
+
+  @Implementation
+  protected boolean setAlpha(float alpha) {
+    this.alpha = alpha;
+    return true;
+  }
+
+  @Implementation
+  protected float getAlpha() {
+    return alpha;
+  }
+
+  @Implementation
+  protected boolean setCameraDistance(float cameraDistance) {
+    this.cameraDistance = cameraDistance;
+    return true;
+  }
+
+  @Implementation
+  protected float getCameraDistance() {
+    return cameraDistance;
+  }
+
+  @Implementation
+  protected boolean setClipToOutline(boolean clipToOutline) {
+    this.clipToOutline = clipToOutline;
+    return true;
+  }
+
+  @Implementation
+  protected boolean getClipToOutline() {
+    return clipToOutline;
+  }
+
+  @Implementation
+  protected boolean setElevation(float lift) {
+    elevation = lift;
+    return true;
+  }
+
+  @Implementation
+  protected float getElevation() {
+    return elevation;
+  }
+
+  @Implementation
+  protected boolean setHasOverlappingRendering(boolean overlappingRendering) {
+    this.overlappingRendering = overlappingRendering;
+    return true;
+  }
+
+  @Implementation
+  protected boolean hasOverlappingRendering() {
+    return overlappingRendering;
+  }
+
+  @Implementation
+  protected boolean setRotationZ(float rotation) {
+    this.rotation = rotation;
+    return true;
+  }
+
+  @Implementation
+  protected float getRotationZ() {
+    return rotation;
+  }
+
+  @Implementation
+  protected boolean setRotationX(float rotationX) {
+    this.rotationX = rotationX;
+    return true;
+  }
+
+  @Implementation
+  protected float getRotationX() {
+    return rotationX;
+  }
+
+  @Implementation
+  protected boolean setRotationY(float rotationY) {
+    this.rotationY = rotationY;
+    return true;
+  }
+
+  @Implementation
+  protected float getRotationY() {
+    return rotationY;
+  }
+
+  @Implementation
+  protected boolean setScaleX(float scaleX) {
+    this.scaleX = scaleX;
+    return true;
+  }
+
+  @Implementation
+  protected float getScaleX() {
+    return scaleX;
+  }
+
+  @Implementation
+  protected boolean setScaleY(float scaleY) {
+    this.scaleY = scaleY;
+    return true;
+  }
+
+  @Implementation
+  protected float getScaleY() {
+    return scaleY;
+  }
+
+  @Implementation
+  protected boolean setTranslationX(float translationX) {
+    this.translationX = translationX;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setTranslationY(float translationY) {
+    this.translationY = translationY;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setTranslationZ(float translationZ) {
+    this.translationZ = translationZ;
+    return true;
+  }
+
+  @Implementation
+  protected float getTranslationX() {
+    return translationX;
+  }
+
+  @Implementation
+  protected float getTranslationY() {
+    return translationY;
+  }
+
+  @Implementation
+  protected float getTranslationZ() {
+    return translationZ;
+  }
+
+  @Implementation
+  protected boolean isPivotExplicitlySet() {
+    return pivotExplicitlySet;
+  }
+
+  @Implementation
+  protected boolean resetPivot() {
+    this.pivotExplicitlySet = false;
+    this.pivotX = 0;
+    this.pivotY = 0;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setPivotX(float pivotX) {
+    this.pivotX = pivotX;
+    this.pivotExplicitlySet = true;
+    return true;
+  }
+
+  @Implementation
+  protected float getPivotX() {
+    return pivotX;
+  }
+
+  @Implementation
+  protected boolean setPivotY(float pivotY) {
+    this.pivotY = pivotY;
+    this.pivotExplicitlySet = true;
+    return true;
+  }
+
+  @Implementation
+  protected float getPivotY() {
+    return pivotY;
+  }
+
+  @Implementation
+  protected boolean setLeft(int left) {
+    this.left = left;
+    return true;
+  }
+
+  @Implementation
+  protected int getLeft() {
+    return left;
+  }
+
+  @Implementation
+  protected boolean setTop(int top) {
+    this.top = top;
+    return true;
+  }
+
+  @Implementation
+  protected int getTop() {
+    return top;
+  }
+
+  @Implementation
+  protected boolean setRight(int right) {
+    this.right = right;
+    return true;
+  }
+
+  @Implementation
+  protected int getRight() {
+    return right;
+  }
+
+  @Implementation
+  protected boolean setBottom(int bottom) {
+    this.bottom = bottom;
+    return true;
+  }
+
+  @Implementation
+  protected int getBottom() {
+    return bottom;
+  }
+
+  @Implementation
+  protected int getWidth() {
+    return right - left;
+  }
+
+  @Implementation
+  protected int getHeight() {
+    return bottom - top;
+  }
+
+  @Implementation
+  protected boolean setLeftTopRightBottom(int left, int top, int right, int bottom) {
+    return setPosition(left, top, right, bottom);
+  }
+
+  @Implementation
+  protected boolean setPosition(int left, int top, int right, int bottom) {
+    this.left = left;
+    this.top = top;
+    this.right = right;
+    this.bottom = bottom;
+    return true;
+  }
+
+  @Implementation
+  protected boolean setPosition(Rect position) {
+    this.left = position.left;
+    this.top = position.top;
+    this.right = position.right;
+    this.bottom = position.bottom;
+    return true;
+  }
+
+  @Implementation
+  protected boolean offsetLeftAndRight(int offset) {
+    this.left += offset;
+    this.right += offset;
+    return true;
+  }
+
+  @Implementation
+  protected boolean offsetTopAndBottom(int offset) {
+    this.top += offset;
+    this.bottom += offset;
+    return true;
+  }
+
+  @Implementation
+  protected void getInverseMatrix(Matrix matrix) {
+    getMatrix(matrix);
+    matrix.invert(matrix);
+  }
+
+  @Implementation
+  protected void getMatrix(Matrix matrix) {
+    if (!pivotExplicitlySet) {
+      pivotX = getWidth() / 2f;
+      pivotY = getHeight() / 2f;
+    }
+    matrix.reset();
+    if (isZero(rotationX) && isZero(rotationY)) {
+      matrix.setTranslate(translationX, translationY);
+      matrix.preRotate(rotation, pivotX, pivotY);
+      matrix.preScale(scaleX, scaleY, pivotX, pivotY);
+    } else {
+      matrix.preScale(scaleX, scaleY, pivotX, pivotY);
+      Camera camera = new Camera();
+      camera.rotateX(rotationX);
+      camera.rotateY(rotationY);
+      camera.rotateZ(-rotation);
+      Matrix transform = new Matrix();
+      camera.getMatrix(transform);
+      transform.preTranslate(-pivotX, -pivotY);
+      transform.postTranslate(pivotX + translationX, pivotY + translationY);
+      matrix.postConcat(transform);
+    }
+  }
+
+  @Implementation
+  protected boolean hasIdentityMatrix() {
+    Matrix matrix = new Matrix();
+    getMatrix(matrix);
+    return matrix.isIdentity();
+  }
+
+  @Implementation
+  protected static boolean nIsValid(long n) {
+    return true;
+  }
+
+  private static boolean isZero(float value) {
+    return Math.abs(value) <= NON_ZERO_EPSILON;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResolveInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResolveInfo.java
new file mode 100644
index 0000000..0ff8223
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResolveInfo.java
@@ -0,0 +1,70 @@
+package org.robolectric.shadows;
+
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.ResolveInfo;
+import android.os.Build;
+
+/** Utilities for {@link ResolveInfo}. */
+// TODO: Create a ResolveInfoBuilder in androidx and migrate factory methods there.
+public class ShadowResolveInfo {
+
+  /**
+   * Creates a {@link ResolveInfo}.
+   *
+   * @param displayName Display name.
+   * @param packageName Package name.
+   * @return Resolve info instance.
+   */
+  public static ResolveInfo newResolveInfo(String displayName, String packageName) {
+    return newResolveInfo(displayName, packageName, packageName + ".TestActivity");
+  }
+
+  /**
+   * Creates a {@link ResolveInfo}.
+   *
+   * @param displayName Display name.
+   * @param packageName Package name.
+   * @param activityName Activity name.
+   * @return Resolve info instance.
+   */
+  public static ResolveInfo newResolveInfo(String displayName, String packageName, String activityName) {
+    ResolveInfo resInfo = new ResolveInfo();
+    ActivityInfo actInfo = new ActivityInfo();
+    actInfo.applicationInfo = new ApplicationInfo();
+    actInfo.packageName = packageName;
+    actInfo.applicationInfo.packageName = packageName;
+    actInfo.name = activityName;
+    resInfo.activityInfo = actInfo;
+    resInfo.nonLocalizedLabel = displayName;
+    return resInfo;
+  }
+
+  /**
+   * Copies {@link ResolveInfo}.
+   *
+   * <p>Note that this is shallow copy as performed by the copy constructor existing in API 17.
+   */
+  public static ResolveInfo newResolveInfo(ResolveInfo orig) {
+    ResolveInfo copy;
+    if (Build.VERSION.SDK_INT >= 17) {
+      copy = new ResolveInfo(orig);
+    } else {
+      copy = new ResolveInfo();
+      copy.activityInfo = orig.activityInfo;
+      copy.serviceInfo = orig.serviceInfo;
+      copy.filter = orig.filter;
+      copy.priority = orig.priority;
+      copy.preferredOrder = orig.preferredOrder;
+      copy.match = orig.match;
+      copy.specificIndex = orig.specificIndex;
+      copy.labelRes = orig.labelRes;
+      copy.nonLocalizedLabel = orig.nonLocalizedLabel;
+      copy.icon = orig.icon;
+      copy.resolvePackageName = orig.resolvePackageName;
+    }
+    // For some reason isDefault field is not copied by the copy constructor.
+    copy.isDefault = orig.isDefault;
+    return copy;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java
new file mode 100644
index 0000000..e6120d7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java
@@ -0,0 +1,485 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.shadows.ShadowAssetManager.legacyShadowOf;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.res.AssetFileDescriptor;
+import android.content.res.AssetManager;
+import android.content.res.CompatibilityInfo;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.Resources.NotFoundException;
+import android.content.res.ResourcesImpl;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.graphics.drawable.Drawable;
+import android.os.ParcelFileDescriptor;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.LongSparseArray;
+import android.util.TypedValue;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.android.Bootstrap;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.internal.bytecode.ShadowedObject;
+import org.robolectric.res.Plural;
+import org.robolectric.res.PluralRules;
+import org.robolectric.res.ResName;
+import org.robolectric.res.ResType;
+import org.robolectric.res.ResourceTable;
+import org.robolectric.res.TypedResource;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowLegacyResourcesImpl.ShadowLegacyThemeImpl;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow of {@link Resources}. */
+@Implements(Resources.class)
+public class ShadowResources {
+
+  private static Resources system = null;
+  private static List<LongSparseArray<?>> resettableArrays;
+
+  @RealObject Resources realResources;
+  private final Set<OnConfigurationChangeListener> configurationChangeListeners = new HashSet<>();
+
+  @Resetter
+  public static void reset() {
+    if (resettableArrays == null) {
+      resettableArrays = obtainResettableArrays();
+    }
+    for (LongSparseArray<?> sparseArray : resettableArrays) {
+      sparseArray.clear();
+    }
+    system = null;
+
+    ReflectionHelpers.setStaticField(Resources.class, "mSystem", null);
+  }
+
+  @Implementation
+  protected static Resources getSystem() {
+    if (system == null) {
+      AssetManager assetManager = AssetManager.getSystem();
+      DisplayMetrics metrics = new DisplayMetrics();
+      Configuration config = new Configuration();
+      system = new Resources(assetManager, metrics, config);
+      Bootstrap.updateConfiguration(system);
+    }
+    return system;
+  }
+
+  @Implementation
+  protected TypedArray obtainAttributes(AttributeSet set, int[] attrs) {
+    if (isLegacyAssetManager()) {
+      return legacyShadowOf(realResources.getAssets())
+          .attrsToTypedArray(realResources, set, attrs, 0, 0, 0);
+    } else {
+      return reflector(ResourcesReflector.class, realResources).obtainAttributes(set, attrs);
+    }
+  }
+
+  @Implementation
+  protected String getQuantityString(int id, int quantity, Object... formatArgs)
+      throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      String raw = getQuantityString(id, quantity);
+      return String.format(Locale.ENGLISH, raw, formatArgs);
+    } else {
+      return reflector(ResourcesReflector.class, realResources)
+          .getQuantityString(id, quantity, formatArgs);
+    }
+  }
+
+  @Implementation
+  protected String getQuantityString(int resId, int quantity) throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
+
+      TypedResource typedResource =
+          shadowAssetManager.getResourceTable().getValue(resId, shadowAssetManager.config);
+      if (typedResource != null && typedResource instanceof PluralRules) {
+        PluralRules pluralRules = (PluralRules) typedResource;
+        Plural plural = pluralRules.find(quantity);
+
+        if (plural == null) {
+          return null;
+        }
+
+        TypedResource<?> resolvedTypedResource =
+            shadowAssetManager.resolve(
+                new TypedResource<>(
+                    plural.getString(), ResType.CHAR_SEQUENCE, pluralRules.getXmlContext()),
+                shadowAssetManager.config,
+                resId);
+        return resolvedTypedResource == null ? null : resolvedTypedResource.asString();
+      } else {
+        return null;
+      }
+    } else {
+      return reflector(ResourcesReflector.class, realResources).getQuantityString(resId, quantity);
+    }
+  }
+
+  @Implementation
+  protected InputStream openRawResource(int id) throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
+      ResourceTable resourceTable = shadowAssetManager.getResourceTable();
+      InputStream inputStream = resourceTable.getRawValue(id, shadowAssetManager.config);
+      if (inputStream == null) {
+        throw newNotFoundException(id);
+      } else {
+        return inputStream;
+      }
+    } else {
+      return reflector(ResourcesReflector.class, realResources).openRawResource(id);
+    }
+  }
+
+  /**
+   * Since {@link AssetFileDescriptor}s are not yet supported by Robolectric, {@code null} will be
+   * returned if the resource is found. If the resource cannot be found, {@link
+   * Resources.NotFoundException} will be thrown.
+   */
+  @Implementation
+  protected AssetFileDescriptor openRawResourceFd(int id) throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      InputStream inputStream = openRawResource(id);
+      if (!(inputStream instanceof FileInputStream)) {
+        // todo fixme
+        return null;
+      }
+
+      FileInputStream fis = (FileInputStream) inputStream;
+      try {
+        return new AssetFileDescriptor(
+            ParcelFileDescriptor.dup(fis.getFD()), 0, fis.getChannel().size());
+      } catch (IOException e) {
+        throw newNotFoundException(id);
+      }
+    } else {
+      return reflector(ResourcesReflector.class, realResources).openRawResourceFd(id);
+    }
+  }
+
+  private Resources.NotFoundException newNotFoundException(int id) {
+    ResourceTable resourceTable = legacyShadowOf(realResources.getAssets()).getResourceTable();
+    ResName resName = resourceTable.getResName(id);
+    if (resName == null) {
+      return new Resources.NotFoundException("resource ID #0x" + Integer.toHexString(id));
+    } else {
+      return new Resources.NotFoundException(resName.getFullyQualifiedName());
+    }
+  }
+
+  @Implementation
+  protected TypedArray obtainTypedArray(int id) throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
+      TypedArray typedArray = shadowAssetManager.getTypedArrayResource(realResources, id);
+      if (typedArray != null) {
+        return typedArray;
+      } else {
+        throw newNotFoundException(id);
+      }
+    } else {
+      return reflector(ResourcesReflector.class, realResources).obtainTypedArray(id);
+    }
+  }
+
+  @HiddenApi
+  @Implementation
+  protected XmlResourceParser loadXmlResourceParser(int resId, String type)
+      throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
+      return setSourceResourceId(shadowAssetManager.loadXmlResourceParser(resId, type), resId);
+    } else {
+      ResourcesReflector relectedResources = reflector(ResourcesReflector.class, realResources);
+      return setSourceResourceId(relectedResources.loadXmlResourceParser(resId, type), resId);
+    }
+  }
+
+  @HiddenApi
+  @Implementation
+  protected XmlResourceParser loadXmlResourceParser(
+      String file, int id, int assetCookie, String type) throws Resources.NotFoundException {
+    if (isLegacyAssetManager()) {
+      return loadXmlResourceParser(id, type);
+    } else {
+      ResourcesReflector relectedResources = reflector(ResourcesReflector.class, realResources);
+      return setSourceResourceId(
+          relectedResources.loadXmlResourceParser(file, id, assetCookie, type), id);
+    }
+  }
+
+  private static XmlResourceParser setSourceResourceId(XmlResourceParser parser, int resourceId) {
+    Object shadow = parser instanceof ShadowedObject ? Shadow.extract(parser) : null;
+    if (shadow instanceof ShadowXmlBlock.ShadowParser) {
+      ((ShadowXmlBlock.ShadowParser) shadow).setSourceResourceId(resourceId);
+    }
+    return parser;
+  }
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected Drawable loadDrawable(TypedValue value, int id) {
+    Drawable drawable = reflector(ResourcesReflector.class, realResources).loadDrawable(value, id);
+    setCreatedFromResId(realResources, id, drawable);
+    return drawable;
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected Drawable loadDrawable(TypedValue value, int id, Resources.Theme theme)
+      throws Resources.NotFoundException {
+    Drawable drawable =
+        reflector(ResourcesReflector.class, realResources).loadDrawable(value, id, theme);
+    setCreatedFromResId(realResources, id, drawable);
+    return drawable;
+  }
+
+  private static List<LongSparseArray<?>> obtainResettableArrays() {
+    List<LongSparseArray<?>> resettableArrays = new ArrayList<>();
+    Field[] allFields = Resources.class.getDeclaredFields();
+    for (Field field : allFields) {
+      if (Modifier.isStatic(field.getModifiers())
+          && field.getType().equals(LongSparseArray.class)) {
+        field.setAccessible(true);
+        try {
+          LongSparseArray<?> longSparseArray = (LongSparseArray<?>) field.get(null);
+          if (longSparseArray != null) {
+            resettableArrays.add(longSparseArray);
+          }
+        } catch (IllegalAccessException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+    return resettableArrays;
+  }
+
+  /**
+   * Returns the layout resource id the attribute set was inflated from. Backwards compatible
+   * version of {@link Resources#getAttributeSetSourceResId(AttributeSet)}, passes through to the
+   * underlying implementation on API levels where it is supported.
+   */
+  @Implementation(minSdk = Q)
+  public static int getAttributeSetSourceResId(AttributeSet attrs) {
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      return reflector(ResourcesReflector.class).getAttributeSetSourceResId(attrs);
+    } else {
+      Object shadow = attrs instanceof ShadowedObject ? Shadow.extract(attrs) : null;
+      return shadow instanceof ShadowXmlBlock.ShadowParser
+          ? ((ShadowXmlBlock.ShadowParser) shadow).getSourceResourceId()
+          : 0;
+    }
+  }
+
+  /**
+   * Listener callback that's called when the configuration is updated for a resources. The callback
+   * receives the old and new configs (and can use {@link Configuration#diff(Configuration)} to
+   * produce a diff). The callback is called after the configuration has been applied to the
+   * underlying resources, so obtaining resources will use the new configuration in the callback.
+   */
+  public interface OnConfigurationChangeListener {
+    void onConfigurationChange(
+        Configuration oldConfig, Configuration newConfig, DisplayMetrics newMetrics);
+  }
+
+  /**
+   * Add a listener to observe resource configuration changes. See {@link
+   * OnConfigurationChangeListener}.
+   */
+  public void addConfigurationChangeListener(OnConfigurationChangeListener listener) {
+    configurationChangeListeners.add(listener);
+  }
+
+  /**
+   * Remove a listener to observe resource configuration changes. See {@link
+   * OnConfigurationChangeListener}.
+   */
+  public void removeConfigurationChangeListener(OnConfigurationChangeListener listener) {
+    configurationChangeListeners.remove(listener);
+  }
+
+  @Implementation
+  protected void updateConfiguration(
+      Configuration config, DisplayMetrics metrics, CompatibilityInfo compat) {
+    Configuration oldConfig;
+    try {
+      oldConfig = new Configuration(realResources.getConfiguration());
+    } catch (NullPointerException e) {
+      // In old versions of Android the resource constructor calls updateConfiguration, in the
+      // app compat ResourcesWrapper subclass the reference to the underlying resources hasn't been
+      // configured yet, so it'll throw an NPE, catch this to avoid crashing.
+      oldConfig = null;
+    }
+    reflector(ResourcesReflector.class, realResources).updateConfiguration(config, metrics, compat);
+    if (oldConfig != null && config != null) {
+      for (OnConfigurationChangeListener listener : configurationChangeListeners) {
+        listener.onConfigurationChange(oldConfig, config, metrics);
+      }
+    }
+  }
+
+  /** Base class for shadows of {@link Resources.Theme}. */
+  public abstract static class ShadowTheme {
+
+    /** Shadow picker for {@link ShadowTheme}. */
+    public static class Picker extends ResourceModeShadowPicker<ShadowTheme> {
+
+      public Picker() {
+        super(ShadowLegacyTheme.class, null, null);
+      }
+    }
+  }
+
+  /** Shadow for {@link Resources.Theme}. */
+  @Implements(value = Resources.Theme.class, shadowPicker = ShadowTheme.Picker.class)
+  public static class ShadowLegacyTheme extends ShadowTheme {
+    @RealObject Resources.Theme realTheme;
+
+    long getNativePtr() {
+      if (RuntimeEnvironment.getApiLevel() >= N) {
+        ResourcesImpl.ThemeImpl themeImpl = ReflectionHelpers.getField(realTheme, "mThemeImpl");
+        return ((ShadowLegacyThemeImpl) Shadow.extract(themeImpl)).getNativePtr();
+      } else {
+        return ((Number) ReflectionHelpers.getField(realTheme, "mTheme")).longValue();
+      }
+    }
+
+    @Implementation(maxSdk = M)
+    protected TypedArray obtainStyledAttributes(int[] attrs) {
+      return obtainStyledAttributes(0, attrs);
+    }
+
+    @Implementation(maxSdk = M)
+    protected TypedArray obtainStyledAttributes(int resid, int[] attrs)
+        throws Resources.NotFoundException {
+      return obtainStyledAttributes(null, attrs, 0, resid);
+    }
+
+    @Implementation(maxSdk = M)
+    protected TypedArray obtainStyledAttributes(
+        AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes) {
+      return getShadowAssetManager()
+          .attrsToTypedArray(
+              innerGetResources(), set, attrs, defStyleAttr, getNativePtr(), defStyleRes);
+    }
+
+    private ShadowLegacyAssetManager getShadowAssetManager() {
+      return legacyShadowOf(innerGetResources().getAssets());
+    }
+
+    private Resources innerGetResources() {
+      if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+        return realTheme.getResources();
+      }
+      return ReflectionHelpers.getField(realTheme, "this$0");
+    }
+  }
+
+  static void setCreatedFromResId(Resources resources, int id, Drawable drawable) {
+    // todo: this kinda sucks, find some better way...
+    if (drawable != null && Shadow.extract(drawable) instanceof ShadowDrawable) {
+      ShadowDrawable shadowDrawable = Shadow.extract(drawable);
+
+      String resourceName;
+      try {
+        resourceName = resources.getResourceName(id);
+      } catch (NotFoundException e) {
+        resourceName = "Unknown resource #0x" + Integer.toHexString(id);
+      }
+
+      shadowDrawable.setCreatedFromResId(id, resourceName);
+    }
+  }
+
+  private boolean isLegacyAssetManager() {
+    return ShadowAssetManager.useLegacy();
+  }
+
+  /** Shadow for {@link Resources.NotFoundException}. */
+  @Implements(Resources.NotFoundException.class)
+  public static class ShadowNotFoundException {
+    @RealObject Resources.NotFoundException realObject;
+
+    private String message;
+
+    @Implementation
+    protected void __constructor__() {}
+
+    @Implementation
+    protected void __constructor__(String name) {
+      this.message = name;
+    }
+
+    @Override
+    @Implementation
+    public String toString() {
+      return realObject.getClass().getName() + ": " + message;
+    }
+  }
+
+  @ForType(Resources.class)
+  interface ResourcesReflector {
+
+    @Direct
+    XmlResourceParser loadXmlResourceParser(int resId, String type);
+
+    @Direct
+    XmlResourceParser loadXmlResourceParser(String file, int id, int assetCookie, String type);
+
+    @Direct
+    Drawable loadDrawable(TypedValue value, int id);
+
+    @Direct
+    Drawable loadDrawable(TypedValue value, int id, Resources.Theme theme);
+
+    @Direct
+    TypedArray obtainAttributes(AttributeSet set, int[] attrs);
+
+    @Direct
+    String getQuantityString(int id, int quantity, Object... formatArgs);
+
+    @Direct
+    String getQuantityString(int resId, int quantity);
+
+    @Direct
+    InputStream openRawResource(int id);
+
+    @Direct
+    AssetFileDescriptor openRawResourceFd(int id);
+
+    @Direct
+    TypedArray obtainTypedArray(int id);
+
+    @Direct
+    int getAttributeSetSourceResId(AttributeSet attrs);
+
+    @Direct
+    void updateConfiguration(
+        Configuration config, DisplayMetrics metrics, CompatibilityInfo compat);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResourcesImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResourcesImpl.java
new file mode 100644
index 0000000..97f96ef
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResourcesImpl.java
@@ -0,0 +1,59 @@
+package org.robolectric.shadows;
+
+import android.content.res.Resources;
+import android.util.LongSparseArray;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.shadows.ShadowLegacyResourcesImpl.ShadowLegacyThemeImpl;
+
+abstract public class ShadowResourcesImpl {
+
+  public static class Picker extends ResourceModeShadowPicker<ShadowResourcesImpl> {
+
+    public Picker() {
+      super(ShadowLegacyResourcesImpl.class, ShadowArscResourcesImpl.class,
+          ShadowArscResourcesImpl.class);
+    }
+  }
+
+  private static List<LongSparseArray<?>> resettableArrays;
+
+  public static void reset() {
+    if (resettableArrays == null) {
+      resettableArrays = obtainResettableArrays();
+    }
+    for (LongSparseArray<?> sparseArray : resettableArrays) {
+      sparseArray.clear();
+    }
+  }
+
+  private static List<LongSparseArray<?>> obtainResettableArrays() {
+    List<LongSparseArray<?>> resettableArrays = new ArrayList<>();
+    Field[] allFields = Resources.class.getDeclaredFields();
+    for (Field field : allFields) {
+      if (Modifier.isStatic(field.getModifiers()) && field.getType().equals(LongSparseArray.class)) {
+        field.setAccessible(true);
+        try {
+          LongSparseArray<?> longSparseArray = (LongSparseArray<?>) field.get(null);
+          if (longSparseArray != null) {
+            resettableArrays.add(longSparseArray);
+          }
+        } catch (IllegalAccessException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+    return resettableArrays;
+  }
+
+  abstract public static class ShadowThemeImpl {
+    public static class Picker extends ResourceModeShadowPicker<ShadowThemeImpl> {
+
+      public Picker() {
+        super(ShadowLegacyThemeImpl.class, null, null);
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResourcesManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResourcesManager.java
new file mode 100644
index 0000000..64e7287
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResourcesManager.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.ResourcesManager;
+import android.content.res.CompatibilityInfo;
+import android.content.res.Configuration;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = ResourcesManager.class, isInAndroidSdk = false, minSdk = KITKAT)
+public class ShadowResourcesManager {
+  @RealObject ResourcesManager realResourcesManager;
+
+  @Resetter
+  public static void reset() {
+    reflector(_ResourcesManager_.class).setResourcesManager(null);
+  }
+
+  /**
+   * Exposes {@link ResourcesManager#applyCompatConfigurationLocked(int, Configuration)}.
+   */
+  public boolean callApplyConfigurationToResourcesLocked(Configuration configuration,
+      CompatibilityInfo compatibilityInfo) {
+    return reflector(_ResourcesManager_.class, realResourcesManager)
+        .applyConfigurationToResourcesLocked(configuration, compatibilityInfo);
+  }
+
+  /** Accessor interface for {@link ResourcesManager}'s internals. */
+  @ForType(ResourcesManager.class)
+  private interface _ResourcesManager_ {
+    boolean applyConfigurationToResourcesLocked(Configuration config, CompatibilityInfo compat);
+
+    @Static @Accessor("sResourcesManager")
+    void setResourcesManager(ResourcesManager resourcesManager);
+  }
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRestrictionsManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRestrictionsManager.java
new file mode 100644
index 0000000..5334dd9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRestrictionsManager.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.content.RestrictionsManager;
+import android.os.Bundle;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link android.content.RestrictionsManager}. */
+@Implements(value = RestrictionsManager.class, minSdk=LOLLIPOP)
+public class ShadowRestrictionsManager {
+  private Bundle applicationRestrictions;
+
+  /**
+   * Sets the application restrictions as returned by {@link RestrictionsManager#getApplicationRestrictions()}.
+   */
+  public void setApplicationRestrictions(Bundle applicationRestrictions) {
+    this.applicationRestrictions = applicationRestrictions;
+  }
+
+  /**
+   * @return null by default, or the value specified via {@link #setApplicationRestrictions(Bundle)}
+   */
+  @Implementation
+  protected Bundle getApplicationRestrictions() {
+    return applicationRestrictions;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResultReceiver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResultReceiver.java
new file mode 100644
index 0000000..6ee4aa0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResultReceiver.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import android.os.Bundle;
+import android.os.ResultReceiver;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(ResultReceiver.class)
+public class ShadowResultReceiver {
+  @RealObject private ResultReceiver realResultReceiver;
+
+  @Implementation
+  protected void send(int resultCode, android.os.Bundle resultData) {
+    ReflectionHelpers.callInstanceMethod(realResultReceiver, "onReceiveResult",
+        ClassParameter.from(Integer.TYPE, resultCode),
+        ClassParameter.from(Bundle.class, resultData));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRingtoneManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRingtoneManager.java
new file mode 100644
index 0000000..706b57e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRingtoneManager.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static android.media.RingtoneManager.TYPE_ALARM;
+import static android.media.RingtoneManager.TYPE_NOTIFICATION;
+import static android.media.RingtoneManager.TYPE_RINGTONE;
+
+import android.content.Context;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.provider.Settings;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** A shadow implementation of {@link android.media.RingtoneManager}. */
+@Implements(RingtoneManager.class)
+public final class ShadowRingtoneManager {
+
+  @Implementation
+  protected static void setActualDefaultRingtoneUri(Context context, int type, Uri ringtoneUri) {
+    Settings.System.putString(
+        context.getContentResolver(),
+        getSettingForType(type),
+        ringtoneUri != null ? ringtoneUri.toString() : null);
+  }
+
+  @Implementation
+  protected static String getSettingForType(int type) {
+    if ((type & TYPE_RINGTONE) != 0) {
+      return Settings.System.RINGTONE;
+    } else if ((type & TYPE_NOTIFICATION) != 0) {
+      return Settings.System.NOTIFICATION_SOUND;
+    } else if ((type & TYPE_ALARM) != 0) {
+      return Settings.System.ALARM_ALERT;
+    } else {
+      return null;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRoleControllerManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRoleControllerManager.java
new file mode 100644
index 0000000..bdf2be8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRoleControllerManager.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.app.role.RoleControllerManager;
+import android.content.ComponentName;
+import android.content.Context;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link RoleControllerManager} */
+@Implements(value = RoleControllerManager.class, minSdk = Q, isInAndroidSdk = false)
+public class ShadowRoleControllerManager {
+
+  @Implementation(minSdk = S)
+  protected static ComponentName getRemoteServiceComponentName(Context context) {
+    return new ComponentName("org.robolectric", "FakeRoleControllerManagerService");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRoleManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRoleManager.java
new file mode 100644
index 0000000..ae36b83
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRoleManager.java
@@ -0,0 +1,80 @@
+package org.robolectric.shadows;
+
+import android.app.role.RoleManager;
+import android.os.Build;
+import android.util.ArraySet;
+import androidx.annotation.NonNull;
+import com.android.internal.util.Preconditions;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** A shadow implementation of {@link android.app.role.RoleManager}. */
+@Implements(value = RoleManager.class, minSdk = Build.VERSION_CODES.Q)
+public class ShadowRoleManager {
+
+  @RealObject protected RoleManager roleManager;
+
+  private final Set<String> heldRoles = new ArraySet<>();
+  private final Set<String> availableRoles = new ArraySet<>();
+
+  /**
+   * Check whether the calling application is holding a particular role.
+   *
+   * <p>Callers can add held roles via {@link #addHeldRole(String)}
+   *
+   * @param roleName the name of the role to check for
+   * @return whether the calling application is holding the role
+   */
+  @Implementation
+  protected boolean isRoleHeld(@NonNull String roleName) {
+    Preconditions.checkStringNotEmpty(roleName, "roleName cannot be null or empty");
+    return heldRoles.contains(roleName);
+  }
+
+  /**
+   * Add a role that would be held by the calling app when invoking {@link
+   * RoleManager#isRoleHeld(String)}.
+   */
+  public void addHeldRole(@NonNull String roleName) {
+    heldRoles.add(roleName);
+  }
+
+  /* Remove a role previously added via {@link #addHeldRole(String)}. */
+  public void removeHeldRole(@NonNull String roleName) {
+    Preconditions.checkArgument(
+        heldRoles.contains(roleName), "the supplied roleName was never added.");
+    heldRoles.remove(roleName);
+  }
+
+  /**
+   * Check whether a particular role is available on the device.
+   *
+   * <p>Callers can add available roles via {@link #addAvailableRole(String)}
+   *
+   * @param roleName the name of the role to check for
+   * @return whether the role is available
+   */
+  @Implementation
+  protected boolean isRoleAvailable(@NonNull String roleName) {
+    Preconditions.checkStringNotEmpty(roleName, "roleName cannot be null or empty");
+    return availableRoles.contains(roleName);
+  }
+
+  /**
+   * Add a role that will be recognized as available when invoking {@link
+   * RoleManager#isRoleAvailable(String)}.
+   */
+  public void addAvailableRole(@NonNull String roleName) {
+    Preconditions.checkStringNotEmpty(roleName, "roleName cannot be null or empty");
+    availableRoles.add(roleName);
+  }
+
+  /* Remove a role previously added via {@link #addAvailableRole(String)}. */
+  public void removeAvailableRole(@NonNull String roleName) {
+    Preconditions.checkArgument(
+        availableRoles.contains(roleName), "the supplied roleName was never added.");
+    availableRoles.remove(roleName);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRollbackManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRollbackManager.java
new file mode 100644
index 0000000..0b30f37
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRollbackManager.java
@@ -0,0 +1,38 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.content.rollback.RollbackInfo;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** A Shadow for android.content.rollback.RollbackManager added in Android Q. */
+@Implements(
+    className = "android.content.rollback.RollbackManager",
+    minSdk = Q,
+    isInAndroidSdk = false)
+public final class ShadowRollbackManager {
+
+  private final List<RollbackInfo> availableRollbacks = new ArrayList<>();
+  private final List<RollbackInfo> recentlyCommittedRollbacks = new ArrayList<>();
+
+  public void addAvailableRollbacks(RollbackInfo rollbackInfo) {
+    availableRollbacks.add(rollbackInfo);
+  }
+
+  public void addRecentlyCommittedRollbacks(RollbackInfo rollbackInfo) {
+    recentlyCommittedRollbacks.add(rollbackInfo);
+  }
+
+  @Implementation
+  protected List<RollbackInfo> getAvailableRollbacks() {
+    return availableRollbacks;
+  }
+
+  @Implementation
+  protected List<RollbackInfo> getRecentlyCommittedRollbacks() {
+    return recentlyCommittedRollbacks;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRotationWatcher.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRotationWatcher.java
new file mode 100644
index 0000000..5ade250
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRotationWatcher.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+
+import com.android.internal.policy.PhoneWindow;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow for RotationWatcher for API 23+
+ */
+@Implements(className = "com.android.internal.policy.PhoneWindow$RotationWatcher",
+    isInAndroidSdk = false, minSdk = M)
+public class ShadowRotationWatcher {
+
+  @Implementation
+  protected void addWindow(PhoneWindow phoneWindow) {
+    // ignore
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRotationWatcherFor22.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRotationWatcherFor22.java
new file mode 100644
index 0000000..b27c7d6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowRotationWatcherFor22.java
@@ -0,0 +1,19 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow for RotationWatcher for API 16-22
+ */
+@Implements(className = "com.android.internal.policy.impl.PhoneWindow$RotationWatcher",
+    isInAndroidSdk = false, maxSdk = LOLLIPOP_MR1, looseSignatures = true)
+public class ShadowRotationWatcherFor22 {
+
+  @Implementation
+  protected void addWindow(Object phoneWindow) {
+    // ignore
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteConnection.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteConnection.java
new file mode 100644
index 0000000..a57bf40
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteConnection.java
@@ -0,0 +1,109 @@
+package org.robolectric.shadows;
+
+import android.database.sqlite.SQLiteConnection;
+import android.os.SystemProperties;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.annotation.SQLiteMode;
+import org.robolectric.annotation.SQLiteMode.Mode;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/**
+ * The base shadow class for {@link SQLiteConnection} shadow APIs.
+ *
+ * <p>The actual shadow class for {@link SQLiteConnection} will be selected during runtime by the
+ * Picker.
+ */
+@Implements(
+    className = "android.database.sqlite.SQLiteConnection",
+    isInAndroidSdk = false,
+    shadowPicker = ShadowSQLiteConnection.Picker.class)
+public class ShadowSQLiteConnection {
+
+  Throwable onCreate = new Throwable();
+
+  protected static AtomicBoolean useInMemoryDatabase = new AtomicBoolean();
+
+  /** Shadow {@link Picker} for {@link ShadowSQLiteConnection} */
+  public static class Picker extends SQLiteShadowPicker<ShadowSQLiteConnection> {
+    public Picker() {
+      super(ShadowLegacySQLiteConnection.class, ShadowNativeSQLiteConnection.class);
+    }
+  }
+
+  private final AtomicBoolean disposed = new AtomicBoolean();
+
+  @RealObject SQLiteConnection realObject;
+
+  @ReflectorObject SQLiteConnectionReflector sqliteConnectionReflector;
+
+  @Implementation
+  protected void dispose(boolean finalized) {
+    // On the JVM there may be two concurrent finalizer threads running if 'System.runFinalization'
+    // is called. Because CursorWindow.dispose is not thread safe, we can work around it
+    // by manually making it thread safe.
+    if (disposed.compareAndSet(false, true)) {
+      sqliteConnectionReflector.dispose(finalized);
+    }
+  }
+
+  public static void setUseInMemoryDatabase(boolean value) {
+    if (sqliteMode() == Mode.LEGACY) {
+      useInMemoryDatabase.set(value);
+    } else {
+      throw new UnsupportedOperationException(
+          "this action is not supported in " + sqliteMode() + " mode.");
+    }
+  }
+
+  public static SQLiteMode.Mode sqliteMode() {
+    return ConfigurationRegistry.get(SQLiteMode.Mode.class);
+  }
+
+  /**
+   * Sets the default sync mode for SQLite databases. Robolectric uses "OFF" by default in order to
+   * improve SQLite performance. The Android default is "FULL" in order to be more resilient to
+   * process crashes. However, this is not a requirement for Robolectric processes, where all
+   * database files are temporary and get deleted after each test.
+   *
+   * <p>This also updates the default sync mode used when SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING
+   * (WAL) is used.
+   *
+   * <p>If your test expects SQLite files being synced to disk, such as having multiple processes
+   * interact with the database, or deleting SQLite files while connections are open and having this
+   * reflected in the open connection, use "FULL" mode.
+   */
+  public static void setDefaultSyncMode(String value) {
+    SystemProperties.set("debug.sqlite.syncmode", value);
+    SystemProperties.set("debug.sqlite.wal.syncmode", value);
+  }
+
+  /**
+   * Sets the default journal mode for SQLite databases. Robolectric uses "MEMORY" by default in
+   * order to improve SQLite performance. The Android default is <code>PERSIST</code> in SDKs <= 25
+   * and <code>TRUNCATE</code> in SDKs > 25.
+   *
+   * <p>Similarly to {@link setDefaultSyncMode}, if your test expects SQLite rollback journal to be
+   * synced to disk, use <code>PERSIST</code> or <code>TRUNCATE</code>.
+   */
+  public static void setDefaultJournalMode(String value) {
+    SystemProperties.set("debug.sqlite.journalmode", value);
+  }
+
+  @Resetter
+  public static void reset() {
+    useInMemoryDatabase.set(false);
+  }
+
+  @ForType(SQLiteConnection.class)
+  interface SQLiteConnectionReflector {
+    @Direct
+    void dispose(boolean finalized);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteOpenHelper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteOpenHelper.java
new file mode 100644
index 0000000..a3208e0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteOpenHelper.java
@@ -0,0 +1,20 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O_MR1;
+
+import android.database.sqlite.SQLiteOpenHelper;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Avoid calls to setIdleConnectionTimeout.
+ * They shouldn't matter for tests, but sometimes induced deadlocks.
+ */
+@Implements(SQLiteOpenHelper.class)
+public class ShadowSQLiteOpenHelper {
+  @Implementation(minSdk = O_MR1)
+  protected void setIdleConnectionTimeout(long idleConnectionTimeoutMs) {
+    // Calling the real one currently results in a Robolectric deadlock. Just ignore it.
+    // See https://github.com/robolectric/robolectric/issues/6853.
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSafetyCenterManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSafetyCenterManager.java
new file mode 100644
index 0000000..43600c6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSafetyCenterManager.java
@@ -0,0 +1,84 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.safetycenter.SafetyCenterManager;
+import android.safetycenter.SafetyEvent;
+import android.safetycenter.SafetySourceData;
+import android.safetycenter.SafetySourceErrorDetails;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link SafetyCenterManager}. */
+@Implements(
+    value = SafetyCenterManager.class,
+    minSdk = VERSION_CODES.TIRAMISU,
+    isInAndroidSdk = false)
+public class ShadowSafetyCenterManager {
+
+  private final Map<String, SafetySourceData> dataById = new HashMap<>();
+  private final Map<String, SafetyEvent> eventsById = new HashMap<>();
+  private final Map<String, SafetySourceErrorDetails> errorsById = new HashMap<>();
+
+  private boolean enabled = false;
+
+  @Implementation
+  protected boolean isSafetyCenterEnabled() {
+    return enabled;
+  }
+
+  @Implementation
+  protected void setSafetySourceData(
+      @NonNull String safetySourceId,
+      @Nullable SafetySourceData safetySourceData,
+      @NonNull SafetyEvent safetyEvent) {
+    if (isSafetyCenterEnabled()) {
+      dataById.put(safetySourceId, safetySourceData);
+      eventsById.put(safetySourceId, safetyEvent);
+    }
+  }
+
+  @Implementation
+  protected SafetySourceData getSafetySourceData(@NonNull String safetySourceId) {
+    if (isSafetyCenterEnabled()) {
+      return dataById.get(safetySourceId);
+    } else {
+      return null;
+    }
+  }
+
+  @Implementation
+  protected void reportSafetySourceError(
+      @NonNull String safetySourceId, @NonNull SafetySourceErrorDetails safetySourceErrorDetails) {
+    if (isSafetyCenterEnabled()) {
+      errorsById.put(safetySourceId, safetySourceErrorDetails);
+    }
+  }
+
+  /**
+   * Sets the return value for {@link #isSafetyCenterEnabled} which also enables the {@link
+   * #setSafetySourceData} and {@link #getSafetySourceData} methods.
+   */
+  public void setSafetyCenterEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  /**
+   * Returns the {@link SafetyEvent} that was given to {@link SafetyCenterManager} the last time
+   * {@link #setSafetySourceData} was called with this {@code safetySourceId}.
+   */
+  public SafetyEvent getLastSafetyEvent(@NonNull String safetySourceId) {
+    return eventsById.get(safetySourceId);
+  }
+
+  /**
+   * Returns the {@link SafetySourceErrorDetails} that was given to {@link SafetyCenterManager} the
+   * last time {@link #reportSafetySourceError} was called with this {@code safetySourceId}.
+   */
+  public SafetySourceErrorDetails getLastSafetySourceError(@NonNull String safetySourceId) {
+    return errorsById.get(safetySourceId);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScaleGestureDetector.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScaleGestureDetector.java
new file mode 100644
index 0000000..c7f9205
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScaleGestureDetector.java
@@ -0,0 +1,69 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ScaleGestureDetector.class)
+public class ShadowScaleGestureDetector {
+
+  private MotionEvent onTouchEventMotionEvent;
+  private ScaleGestureDetector.OnScaleGestureListener listener;
+  private float scaleFactor = 1;
+  private float focusX;
+  private float focusY;
+
+  @Implementation
+  protected void __constructor__(
+      Context context, ScaleGestureDetector.OnScaleGestureListener listener) {
+    this.listener = listener;
+  }
+
+  @Implementation
+  protected boolean onTouchEvent(MotionEvent event) {
+    onTouchEventMotionEvent = event;
+    return true;
+  }
+
+  public MotionEvent getOnTouchEventMotionEvent() {
+    return onTouchEventMotionEvent;
+  }
+
+  public void reset() {
+    onTouchEventMotionEvent = null;
+    scaleFactor = 1;
+    focusX = 0;
+    focusY = 0;
+  }
+
+  public ScaleGestureDetector.OnScaleGestureListener getListener() {
+    return listener;
+  }
+
+  public void setScaleFactor(float scaleFactor) {
+    this.scaleFactor = scaleFactor;
+  }
+
+  @Implementation
+  protected float getScaleFactor() {
+    return scaleFactor;
+  }
+
+  public void setFocusXY(float focusX, float focusY) {
+    this.focusX = focusX;
+    this.focusY = focusY;
+  }
+
+  @Implementation
+  protected float getFocusX() {
+    return focusX;
+  }
+
+  @Implementation
+  protected float getFocusY() {
+    return focusY;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScanResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScanResult.java
new file mode 100644
index 0000000..0c42cef
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScanResult.java
@@ -0,0 +1,51 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+
+import android.net.wifi.ScanResult;
+import android.os.Build;
+import org.robolectric.shadow.api.Shadow;
+
+public class ShadowScanResult {
+  /**
+   * @deprecated use ScanResult() instead
+   */
+  @Deprecated
+  public static ScanResult newInstance(
+      String SSID, String BSSID, String caps, int level, int frequency) {
+    return newInstance(SSID, BSSID, caps, level, frequency, false);
+  }
+
+  /**
+   * @deprecated use ScanResult() instead
+   */
+  @Deprecated
+  public static ScanResult newInstance(
+      String SSID,
+      String BSSID,
+      String caps,
+      int level,
+      int frequency,
+      boolean is80211McRTTResponder) {
+    ScanResult scanResult;
+    if (Build.VERSION.SDK_INT >= 30) {
+      // ScanResult() was introduced as public API in 30
+      scanResult = new ScanResult();
+    } else {
+      scanResult = Shadow.newInstanceOf(ScanResult.class);
+    }
+    scanResult.SSID = SSID;
+    scanResult.BSSID = BSSID;
+    scanResult.capabilities = caps;
+    scanResult.level = level;
+    scanResult.frequency = frequency;
+    if (Build.VERSION.SDK_INT >= P) {
+      if (is80211McRTTResponder) {
+        scanResult.setFlag(ScanResult.FLAG_80211mc_RESPONDER);
+      } else {
+      scanResult.setFlag(0);
+      }
+    }
+    return scanResult;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScrollView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScrollView.java
new file mode 100644
index 0000000..a96c00e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowScrollView.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.ScrollView;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(ScrollView.class)
+public class ShadowScrollView extends ShadowViewGroup {
+
+  @RealObject ScrollView realScrollView;
+
+  @Implementation
+  protected void smoothScrollTo(int x, int y) {
+    if (useRealGraphics()) {
+      reflector(ScrollViewReflector.class, realScrollView).smoothScrollTo(x, y);
+    } else {
+      scrollTo(x, y);
+    }
+  }
+
+  @Implementation
+  protected void smoothScrollBy(int x, int y) {
+    if (useRealGraphics()) {
+      reflector(ScrollViewReflector.class, realScrollView).smoothScrollBy(x, y);
+    } else {
+      scrollBy(x, y);
+    }
+  }
+
+  @ForType(ScrollView.class)
+  interface ScrollViewReflector {
+    @Direct
+    void smoothScrollBy(int x, int y);
+
+    @Direct
+    void smoothScrollTo(int x, int y);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSearchManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSearchManager.java
new file mode 100644
index 0000000..204dc4c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSearchManager.java
@@ -0,0 +1,17 @@
+package org.robolectric.shadows;
+
+import android.app.SearchManager;
+import android.app.SearchableInfo;
+import android.content.ComponentName;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(SearchManager.class)
+public class ShadowSearchManager {
+
+  @Implementation
+  protected SearchableInfo getSearchableInfo(ComponentName componentName) {
+    // Prevent Robolectric from calling through
+    return null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSeekBar.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSeekBar.java
new file mode 100644
index 0000000..d57de8d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSeekBar.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.widget.SeekBar;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(SeekBar.class)
+public class ShadowSeekBar extends ShadowView {
+
+  @RealObject private SeekBar realSeekBar;
+
+  private SeekBar.OnSeekBarChangeListener listener;
+
+  @Implementation
+  protected void setOnSeekBarChangeListener(SeekBar.OnSeekBarChangeListener listener) {
+    this.listener = listener;
+    reflector(SeekBarReflector.class, realSeekBar).setOnSeekBarChangeListener(listener);
+  }
+
+  public SeekBar.OnSeekBarChangeListener getOnSeekBarChangeListener() {
+    return this.listener;
+  }
+
+  @ForType(SeekBar.class)
+  interface SeekBarReflector {
+
+    @Direct
+    void setOnSeekBarChangeListener(SeekBar.OnSeekBarChangeListener listener);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSensor.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSensor.java
new file mode 100644
index 0000000..4496337
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSensor.java
@@ -0,0 +1,88 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.hardware.Sensor;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(Sensor.class)
+public class ShadowSensor {
+
+  @RealObject private Sensor realSensor;
+
+  private float maximumRange = 0;
+
+  /** Constructs a {@link Sensor} with a given type. */
+  public static Sensor newInstance(int type) {
+    Sensor sensor = Shadow.newInstanceOf(Sensor.class);
+    reflector(_Sensor_.class, sensor).setTypeCompat(type);
+    return sensor;
+  }
+
+  /** Controls the return value of {@link Sensor#isWakeUpSensor()}. */
+  public void setWakeUpFlag(boolean wakeup) {
+    int wakeUpSensorMask = reflector(_Sensor_.class).getWakeUpSensorFlag();
+
+    if (wakeup) {
+      setMask(wakeUpSensorMask);
+    } else {
+      clearMask(wakeUpSensorMask);
+    }
+  }
+
+  /** Sets the return value for {@link Sensor#getMaximumRange}. */
+  public void setMaximumRange(float range) {
+    maximumRange = range;
+  }
+
+  @Implementation
+  protected float getMaximumRange() {
+    return maximumRange;
+  }
+
+  private void setMask(int mask) {
+    _Sensor_ _sensor_ = reflector(_Sensor_.class, realSensor);
+    _sensor_.setFlags(_sensor_.getFlags() | mask);
+  }
+
+  private void clearMask(int mask) {
+    _Sensor_ _sensor_ = reflector(_Sensor_.class, realSensor);
+    _sensor_.setFlags(_sensor_.getFlags() & ~mask);
+  }
+
+  /** Accessor interface for {@link Sensor}'s internals. */
+  @ForType(Sensor.class)
+  interface _Sensor_ {
+
+    @Accessor("mType")
+    void setTypeField(int type);
+
+    void setType(int type);
+
+    default void setTypeCompat(int type) {
+      if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.M) {
+        setType(type);
+      } else {
+        setTypeField(type);
+      }
+    }
+
+    @Accessor("mFlags")
+    int getFlags();
+
+    @Accessor("mFlags")
+    void setFlags(int flags);
+
+    @Static
+    @Accessor("SENSOR_FLAG_WAKE_UP_SENSOR")
+    int getWakeUpSensorFlag();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSensorManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSensorManager.java
new file mode 100644
index 0000000..5aa44af
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSensorManager.java
@@ -0,0 +1,200 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.hardware.Sensor;
+import android.hardware.SensorDirectChannel;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Handler;
+import android.os.MemoryFile;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(value = SensorManager.class, looseSignatures = true)
+public class ShadowSensorManager {
+  public boolean forceListenersToFail = false;
+  private final Map<Integer, Sensor> sensorMap = new HashMap<>();
+  private final Multimap<SensorEventListener, Sensor> listeners =
+      Multimaps.synchronizedMultimap(HashMultimap.<SensorEventListener, Sensor>create());
+
+  @RealObject private SensorManager realObject;
+
+  /**
+   * Provide a Sensor for the indicated sensor type.
+   *
+   * @param sensorType from Sensor constants
+   * @param sensor Sensor instance
+   * @deprecated Use {@link ShadowSensor#newInstance(int)} to construct your {@link Sensor} and add
+   *     to the {@link SensorManager} using {@link #addSensor(Sensor)} instead. This method will be
+   *     removed at some point allowing us to use more of the real {@link SensorManager} code.
+   */
+  @Deprecated
+  public void addSensor(int sensorType, Sensor sensor) {
+    checkNotNull(sensor);
+    sensorMap.put(sensorType, sensor);
+  }
+
+  /** Adds a {@link Sensor} to the {@link SensorManager} */
+  public void addSensor(Sensor sensor) {
+    checkNotNull(sensor);
+    sensorMap.put(sensor.getType(), sensor);
+  }
+
+  public void removeSensor(Sensor sensor) {
+    checkNotNull(sensor);
+    sensorMap.remove(sensor.getType());
+  }
+
+  @Implementation
+  protected Sensor getDefaultSensor(int type) {
+    return sensorMap.get(type);
+  }
+
+  @Implementation
+  public List<Sensor> getSensorList(int type) {
+    List<Sensor> sensorList = new ArrayList<>();
+    Sensor sensor = sensorMap.get(type);
+    if (sensor != null) {
+      sensorList.add(sensor);
+    }
+    return sensorList;
+  }
+
+  /** @param handler is ignored. */
+  @Implementation
+  protected boolean registerListener(
+      SensorEventListener listener, Sensor sensor, int rate, Handler handler) {
+    return registerListener(listener, sensor, rate);
+  }
+
+  /**
+   * @param maxLatency is ignored.
+   */
+  @Implementation(minSdk = KITKAT)
+  protected boolean registerListener(
+      SensorEventListener listener, Sensor sensor, int rate, int maxLatency) {
+    return registerListener(listener, sensor, rate);
+  }
+
+  /**
+   * @param maxLatency is ignored.
+   * @param handler is ignored
+   */
+  @Implementation(minSdk = KITKAT)
+  protected boolean registerListener(
+      SensorEventListener listener, Sensor sensor, int rate, int maxLatency, Handler handler) {
+    return registerListener(listener, sensor, rate);
+  }
+
+  @Implementation
+  protected boolean registerListener(SensorEventListener listener, Sensor sensor, int rate) {
+    if (forceListenersToFail) {
+      return false;
+    }
+    listeners.put(listener, sensor);
+    return true;
+  }
+
+  @Implementation
+  protected void unregisterListener(SensorEventListener listener, Sensor sensor) {
+    listeners.remove(listener, sensor);
+  }
+
+  @Implementation
+  protected void unregisterListener(SensorEventListener listener) {
+    listeners.removeAll(listener);
+  }
+
+  /** Tests if the sensor manager has a registration for the given listener. */
+  public boolean hasListener(SensorEventListener listener) {
+    return listeners.containsKey(listener);
+  }
+
+  /** Tests if the sensor manager has a registration for the given listener for the given sensor. */
+  public boolean hasListener(SensorEventListener listener, Sensor sensor) {
+    return listeners.containsEntry(listener, sensor);
+  }
+
+  /**
+   * Returns the list of {@link SensorEventListener}s registered on this SensorManager. Note that
+   * the list is unmodifiable, any attempt to modify it will throw an exception.
+   */
+  public List<SensorEventListener> getListeners() {
+    return ImmutableList.copyOf(listeners.keySet());
+  }
+
+  /** Propagates the {@code event} to all registered listeners. */
+  public void sendSensorEventToListeners(SensorEvent event) {
+    for (SensorEventListener listener : getListeners()) {
+      listener.onSensorChanged(event);
+    }
+  }
+
+  public SensorEvent createSensorEvent() {
+    return ReflectionHelpers.callConstructor(SensorEvent.class);
+  }
+
+  /**
+   * Creates a {@link SensorEvent} with the given value array size, which the caller should set
+   * based on the type of {@link Sensor} which is being emulated.
+   *
+   * <p>Callers can then specify individual values for the event. For example, for a proximity event
+   * a caller may wish to specify the distance value:
+   *
+   * <pre>{@code
+   * event.values[0] = distance;
+   * }</pre>
+   *
+   * <p>See {@link SensorEvent#values} for more information about values.
+   */
+  public static SensorEvent createSensorEvent(int valueArraySize) {
+    return createSensorEvent(valueArraySize, Sensor.TYPE_GRAVITY);
+  }
+
+  /**
+   * Creates a {@link SensorEvent} for the given {@link Sensor} type with the given value array
+   * size, which the caller should set based on the type of sensor which is being emulated.
+   *
+   * <p>Callers can then specify individual values for the event. For example, for a proximity event
+   * a caller may wish to specify the distance value:
+   *
+   * <pre>{@code
+   * event.values[0] = distance;
+   * }</pre>
+   *
+   * <p>See {@link SensorEvent#values} for more information about values.
+   */
+  public static SensorEvent createSensorEvent(int valueArraySize, int sensorType) {
+    checkArgument(valueArraySize > 0);
+    ClassParameter<Integer> valueArraySizeParam = new ClassParameter<>(int.class, valueArraySize);
+    SensorEvent sensorEvent =
+        ReflectionHelpers.callConstructor(SensorEvent.class, valueArraySizeParam);
+    sensorEvent.sensor = ShadowSensor.newInstance(sensorType);
+    return sensorEvent;
+  }
+
+  @Implementation(minSdk = O)
+  protected Object createDirectChannel(MemoryFile mem) {
+    return ReflectionHelpers.callConstructor(SensorDirectChannel.class,
+        ClassParameter.from(SensorManager.class, realObject),
+        ClassParameter.from(int.class, 0),
+        ClassParameter.from(int.class, SensorDirectChannel.TYPE_MEMORY_FILE),
+        ClassParameter.from(long.class, mem.length()));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowService.java
new file mode 100644
index 0000000..bbc6160
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowService.java
@@ -0,0 +1,156 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.pm.ServiceInfo.ForegroundServiceType;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Service.class)
+public class ShadowService extends ShadowContextWrapper {
+  @RealObject Service realService;
+
+  private int lastForegroundNotificationId;
+  private Notification lastForegroundNotification;
+  private boolean lastForegroundNotificationAttached = false;
+  private boolean selfStopped = false;
+  private boolean foregroundStopped;
+  private boolean notificationShouldRemoved;
+  private int stopSelfId;
+  private int stopSelfResultId;
+  private int foregroundServiceType;
+
+  @Implementation
+  protected void onDestroy() {
+    if (lastForegroundNotificationAttached) {
+      lastForegroundNotificationAttached = false;
+      removeForegroundNotification();
+    }
+  }
+
+  @Implementation
+  protected void stopSelf() {
+    selfStopped = true;
+  }
+
+  @Implementation
+  protected void stopSelf(int id) {
+    selfStopped = true;
+    stopSelfId = id;
+  }
+
+  @Implementation
+  protected boolean stopSelfResult(int id) {
+    selfStopped = true;
+    stopSelfResultId = id;
+    return true;
+  }
+
+  @Implementation
+  protected final void startForeground(int id, Notification notification) {
+    foregroundStopped = false;
+    lastForegroundNotificationId = id;
+    lastForegroundNotification = notification;
+    lastForegroundNotificationAttached = true;
+    notification.flags |= Notification.FLAG_FOREGROUND_SERVICE;
+    NotificationManager nm =
+        (NotificationManager)
+            RuntimeEnvironment.getApplication().getSystemService(Context.NOTIFICATION_SERVICE);
+    nm.notify(id, notification);
+    this.foregroundServiceType = 0;
+  }
+
+  @Implementation(minSdk = Q)
+  protected final void startForeground(
+      int id, Notification notification, @ForegroundServiceType int foregroundServiceType) {
+    startForeground(id, notification);
+    this.foregroundServiceType = foregroundServiceType;
+  }
+
+  @Implementation
+  protected void stopForeground(boolean removeNotification) {
+    foregroundStopped = true;
+    notificationShouldRemoved = removeNotification;
+    if (removeNotification) {
+      removeForegroundNotification();
+    }
+  }
+
+  @Implementation(minSdk = Q)
+  @ForegroundServiceType
+  protected final int getForegroundServiceType() {
+    return foregroundServiceType;
+  }
+
+  @Implementation(minSdk = N)
+  protected void stopForeground(int flags) {
+    if ((flags & Service.STOP_FOREGROUND_DETACH) != 0) {
+      lastForegroundNotificationAttached = false;
+    }
+    stopForeground((flags & Service.STOP_FOREGROUND_REMOVE) != 0);
+  }
+
+  private void removeForegroundNotification() {
+    NotificationManager nm =
+        (NotificationManager)
+            RuntimeEnvironment.getApplication().getSystemService(Context.NOTIFICATION_SERVICE);
+    nm.cancel(lastForegroundNotificationId);
+    lastForegroundNotification = null;
+    lastForegroundNotificationAttached = false;
+  }
+
+  public int getLastForegroundNotificationId() {
+    return lastForegroundNotificationId;
+  }
+
+  public Notification getLastForegroundNotification() {
+    return lastForegroundNotification;
+  }
+
+  /**
+   * Returns whether the last foreground notification is still "attached" to the service,
+   * meaning it will be removed when the service is destroyed.
+   */
+  public boolean isLastForegroundNotificationAttached() {
+    return lastForegroundNotificationAttached;
+  }
+
+  /**
+   * @return Is this service stopped by self.
+   */
+  public boolean isStoppedBySelf() {
+    return selfStopped;
+  }
+
+  public boolean isForegroundStopped() {
+    return foregroundStopped;
+  }
+
+  public boolean getNotificationShouldRemoved() {
+    return notificationShouldRemoved;
+  }
+
+  /**
+   * Returns id passed to {@link #stopSelf(int)} method. Make sure to check result of {@link
+   * #isStoppedBySelf()} first.
+   */
+  public int getStopSelfId() {
+    return stopSelfId;
+  }
+
+  /**
+   * Returns id passed to {@link #stopSelfResult(int)} method. Make sure to check result of {@link
+   * #isStoppedBySelf()} first.
+   */
+  public int getStopSelfResultId() {
+    return stopSelfResultId;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java
new file mode 100644
index 0000000..40dcad0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java
@@ -0,0 +1,319 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+
+import android.accounts.IAccountManager;
+import android.app.IAlarmManager;
+import android.app.ILocaleManager;
+import android.app.INotificationManager;
+import android.app.ISearchManager;
+import android.app.IUiModeManager;
+import android.app.IWallpaperManager;
+import android.app.admin.IDevicePolicyManager;
+import android.app.ambientcontext.IAmbientContextManager;
+import android.app.job.IJobScheduler;
+import android.app.role.IRoleManager;
+import android.app.slice.ISliceManager;
+import android.app.timedetector.ITimeDetectorService;
+import android.app.timezonedetector.ITimeZoneDetectorService;
+import android.app.trust.ITrustManager;
+import android.app.usage.IStorageStatsManager;
+import android.app.usage.IUsageStatsManager;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.IBluetooth;
+import android.bluetooth.IBluetoothManager;
+import android.content.Context;
+import android.content.IClipboard;
+import android.content.IRestrictionsManager;
+import android.content.integrity.IAppIntegrityManager;
+import android.content.pm.ICrossProfileApps;
+import android.content.pm.IShortcutService;
+import android.content.rollback.IRollbackManager;
+import android.hardware.biometrics.IAuthService;
+import android.hardware.biometrics.IBiometricService;
+import android.hardware.fingerprint.IFingerprintService;
+import android.hardware.input.IInputManager;
+import android.hardware.location.IContextHubService;
+import android.hardware.usb.IUsbManager;
+import android.location.ICountryDetector;
+import android.location.ILocationManager;
+import android.media.IAudioService;
+import android.media.IMediaRouterService;
+import android.media.session.ISessionManager;
+import android.net.IConnectivityManager;
+import android.net.IIpSecService;
+import android.net.INetworkPolicyManager;
+import android.net.INetworkScoreService;
+import android.net.ITetheringConnector;
+import android.net.nsd.INsdManager;
+import android.net.vcn.IVcnManagementService;
+import android.net.wifi.IWifiManager;
+import android.net.wifi.aware.IWifiAwareManager;
+import android.net.wifi.p2p.IWifiP2pManager;
+import android.net.wifi.rtt.IWifiRttManager;
+import android.nfc.INfcAdapter;
+import android.os.BatteryStats;
+import android.os.Binder;
+import android.os.IBatteryPropertiesRegistrar;
+import android.os.IBinder;
+import android.os.IDumpstate;
+import android.os.IInterface;
+import android.os.IPowerManager;
+import android.os.IThermalService;
+import android.os.IUserManager;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.os.storage.IStorageManager;
+import android.permission.ILegacyPermissionManager;
+import android.permission.IPermissionManager;
+import android.safetycenter.ISafetyCenterManager;
+import android.speech.IRecognitionServiceManager;
+import android.uwb.IUwbAdapter;
+import android.view.IWindowManager;
+import android.view.contentcapture.IContentCaptureManager;
+import android.view.translation.ITranslationManager;
+import com.android.internal.app.IAppOpsService;
+import com.android.internal.app.IBatteryStats;
+import com.android.internal.appwidget.IAppWidgetService;
+import com.android.internal.os.IDropBoxManagerService;
+import com.android.internal.statusbar.IStatusBar;
+import com.android.internal.telephony.ITelephony;
+import com.android.internal.telephony.ITelephonyRegistry;
+import com.android.internal.view.IInputMethodManager;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow for {@link ServiceManager}. */
+@SuppressWarnings("NewApi")
+@Implements(value = ServiceManager.class, isInAndroidSdk = false)
+public class ShadowServiceManager {
+
+  private static final Map<String, BinderService> binderServices = new HashMap<>();
+  private static final Set<String> unavailableServices = new HashSet<>();
+
+  static {
+    addBinderService(Context.CLIPBOARD_SERVICE, IClipboard.class);
+    addBinderService(Context.WIFI_P2P_SERVICE, IWifiP2pManager.class);
+    addBinderService(Context.ACCOUNT_SERVICE, IAccountManager.class);
+    addBinderService(Context.USB_SERVICE, IUsbManager.class);
+    addBinderService(Context.LOCATION_SERVICE, ILocationManager.class);
+    addBinderService(Context.INPUT_METHOD_SERVICE, IInputMethodManager.class);
+    addBinderService(Context.ALARM_SERVICE, IAlarmManager.class);
+    addBinderService(Context.POWER_SERVICE, IPowerManager.class);
+    addBinderService(BatteryStats.SERVICE_NAME, IBatteryStats.class);
+    addBinderService(Context.DROPBOX_SERVICE, IDropBoxManagerService.class);
+    addBinderService(Context.DEVICE_POLICY_SERVICE, IDevicePolicyManager.class);
+    addBinderService(Context.TELEPHONY_SERVICE, ITelephony.class);
+    addBinderService(Context.CONNECTIVITY_SERVICE, IConnectivityManager.class);
+    addBinderService(Context.WIFI_SERVICE, IWifiManager.class);
+    addBinderService(Context.SEARCH_SERVICE, ISearchManager.class);
+    addBinderService(Context.UI_MODE_SERVICE, IUiModeManager.class);
+    addBinderService(Context.NETWORK_POLICY_SERVICE, INetworkPolicyManager.class);
+    addBinderService(Context.INPUT_SERVICE, IInputManager.class);
+    addBinderService(Context.COUNTRY_DETECTOR, ICountryDetector.class);
+    addBinderService(Context.NSD_SERVICE, INsdManager.class);
+    addBinderService(Context.AUDIO_SERVICE, IAudioService.class);
+    addBinderService(Context.APPWIDGET_SERVICE, IAppWidgetService.class);
+    addBinderService(Context.NOTIFICATION_SERVICE, INotificationManager.class);
+    addBinderService(Context.WALLPAPER_SERVICE, IWallpaperManager.class);
+    addBinderService(Context.BLUETOOTH_SERVICE, IBluetooth.class);
+    addBinderService(Context.WINDOW_SERVICE, IWindowManager.class);
+    addBinderService(Context.NFC_SERVICE, INfcAdapter.class, true);
+
+    if (RuntimeEnvironment.getApiLevel() >= JELLY_BEAN_MR1) {
+      addBinderService(Context.USER_SERVICE, IUserManager.class);
+      addBinderService(BluetoothAdapter.BLUETOOTH_MANAGER_SERVICE, IBluetoothManager.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= JELLY_BEAN_MR2) {
+      addBinderService(Context.APP_OPS_SERVICE, IAppOpsService.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= KITKAT) {
+      addBinderService("batteryproperties", IBatteryPropertiesRegistrar.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      addBinderService(Context.RESTRICTIONS_SERVICE, IRestrictionsManager.class);
+      addBinderService(Context.TRUST_SERVICE, ITrustManager.class);
+      addBinderService(Context.JOB_SCHEDULER_SERVICE, IJobScheduler.class);
+      addBinderService(Context.NETWORK_SCORE_SERVICE, INetworkScoreService.class);
+      addBinderService(Context.USAGE_STATS_SERVICE, IUsageStatsManager.class);
+      addBinderService(Context.MEDIA_ROUTER_SERVICE, IMediaRouterService.class);
+      addBinderService(Context.MEDIA_SESSION_SERVICE, ISessionManager.class, true);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= M) {
+      addBinderService(Context.FINGERPRINT_SERVICE, IFingerprintService.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      addBinderService(Context.CONTEXTHUB_SERVICE, IContextHubService.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= N_MR1) {
+      addBinderService(Context.SHORTCUT_SERVICE, IShortcutService.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= O) {
+      addBinderService("mount", IStorageManager.class);
+      addBinderService(Context.WIFI_AWARE_SERVICE, IWifiAwareManager.class);
+      addBinderService(Context.STORAGE_STATS_SERVICE, IStorageStatsManager.class);
+    } else {
+      addBinderService("mount", "android.os.storage.IMountService");
+    }
+    if (RuntimeEnvironment.getApiLevel() >= P) {
+      addBinderService(Context.SLICE_SERVICE, ISliceManager.class);
+      addBinderService(Context.CROSS_PROFILE_APPS_SERVICE, ICrossProfileApps.class);
+      addBinderService(Context.WIFI_RTT_RANGING_SERVICE, IWifiRttManager.class);
+      addBinderService(Context.IPSEC_SERVICE, IIpSecService.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      addBinderService(Context.BIOMETRIC_SERVICE, IBiometricService.class);
+      addBinderService(Context.CONTENT_CAPTURE_MANAGER_SERVICE, IContentCaptureManager.class);
+      addBinderService(Context.ROLE_SERVICE, IRoleManager.class);
+      addBinderService(Context.ROLLBACK_SERVICE, IRollbackManager.class);
+      addBinderService(Context.THERMAL_SERVICE, IThermalService.class);
+      addBinderService(Context.BUGREPORT_SERVICE, IDumpstate.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      addBinderService(Context.APP_INTEGRITY_SERVICE, IAppIntegrityManager.class);
+      addBinderService(Context.AUTH_SERVICE, IAuthService.class);
+      addBinderService(Context.TETHERING_SERVICE, ITetheringConnector.class);
+      addBinderService("telephony.registry", ITelephonyRegistry.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= S) {
+      addBinderService("permissionmgr", IPermissionManager.class);
+      addBinderService(Context.TIME_ZONE_DETECTOR_SERVICE, ITimeZoneDetectorService.class);
+      addBinderService(Context.TIME_DETECTOR_SERVICE, ITimeDetectorService.class);
+      addBinderService(Context.SPEECH_RECOGNITION_SERVICE, IRecognitionServiceManager.class);
+      addBinderService(Context.LEGACY_PERMISSION_SERVICE, ILegacyPermissionManager.class);
+      addBinderService(Context.UWB_SERVICE, IUwbAdapter.class);
+      addBinderService(Context.VCN_MANAGEMENT_SERVICE, IVcnManagementService.class);
+      addBinderService(Context.TRANSLATION_MANAGER_SERVICE, ITranslationManager.class);
+    }
+    if (RuntimeEnvironment.getApiLevel() >= TIRAMISU) {
+      addBinderService(Context.AMBIENT_CONTEXT_SERVICE, IAmbientContextManager.class);
+      addBinderService(Context.LOCALE_SERVICE, ILocaleManager.class);
+      addBinderService(Context.SAFETY_CENTER_SERVICE, ISafetyCenterManager.class);
+      addBinderService(Context.STATUS_BAR_SERVICE, IStatusBar.class);
+    }
+  }
+
+  /**
+   * A data class that holds descriptor information about binder services. It also holds the cached
+   * binder object if it is requested by {@link #getService(String)}.
+   */
+  private static class BinderService {
+
+    private final Class<? extends IInterface> clazz;
+    private final String className;
+    private final boolean useDeepBinder;
+    private Binder cachedBinder;
+
+    BinderService(Class<? extends IInterface> clazz, String className, boolean useDeepBinder) {
+      this.clazz = clazz;
+      this.className = className;
+      this.useDeepBinder = useDeepBinder;
+    }
+
+    // Needs to be synchronized in case multiple threads call ServiceManager.getService
+    // concurrently.
+    synchronized IBinder getBinder() {
+      if (cachedBinder == null) {
+        cachedBinder = new Binder();
+        cachedBinder.attachInterface(
+            useDeepBinder
+                ? ReflectionHelpers.createDeepProxy(clazz)
+                : ReflectionHelpers.createNullProxy(clazz),
+            className);
+      }
+      return cachedBinder;
+    }
+  }
+
+  protected static void addBinderService(String name, Class<? extends IInterface> clazz) {
+    addBinderService(name, clazz, clazz.getCanonicalName(), false);
+  }
+
+  protected static void addBinderService(
+      String name, Class<? extends IInterface> clazz, boolean useDeepBinder) {
+    addBinderService(name, clazz, clazz.getCanonicalName(), useDeepBinder);
+  }
+
+  protected static void addBinderService(String name, String className) {
+    Class<? extends IInterface> clazz;
+    try {
+      clazz = Class.forName(className).asSubclass(IInterface.class);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+    addBinderService(name, clazz, className, false);
+  }
+
+  protected static void addBinderService(
+      String name, Class<? extends IInterface> clazz, String className, boolean useDeepBinder) {
+    binderServices.put(name, new BinderService(clazz, className, useDeepBinder));
+  }
+  /**
+   * Returns the binder associated with the given system service. If the given service is set to
+   * unavailable in {@link #setServiceAvailability}, {@code null} will be returned.
+   */
+  @Implementation
+  protected static IBinder getService(String name) {
+    if (unavailableServices.contains(name)) {
+      return null;
+    }
+    BinderService binderService = binderServices.get(name);
+    if (binderService == null) {
+      return null;
+    }
+
+    return binderService.getBinder();
+  }
+
+  @Implementation
+  protected static void addService(String name, IBinder service) {}
+
+  @Implementation
+  protected static IBinder checkService(String name) {
+    return null;
+  }
+
+  @Implementation
+  protected static String[] listServices() throws RemoteException {
+    return null;
+  }
+
+  @Implementation
+  protected static void initServiceCache(Map<String, IBinder> cache) {}
+
+  /**
+   * Sets the availability of the given system service. If the service is set as unavailable,
+   * subsequent calls to {@link Context#getSystemService} for that service will return {@code null}.
+   */
+  public static void setServiceAvailability(String service, boolean available) {
+    if (available) {
+      unavailableServices.remove(service);
+    } else {
+      unavailableServices.add(service);
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    unavailableServices.clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSettings.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSettings.java
new file mode 100644
index 0000000..43a758f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSettings.java
@@ -0,0 +1,579 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.provider.Settings.Secure.LOCATION_MODE_OFF;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.location.LocationManager;
+import android.os.Build;
+import android.provider.Settings;
+import android.provider.Settings.Secure;
+import android.provider.Settings.SettingNotFoundException;
+import android.text.TextUtils;
+import com.google.common.collect.ImmutableMap;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Settings.class)
+public class ShadowSettings {
+
+  @Implements(value = Settings.System.class)
+  public static class ShadowSystem {
+    private static final ImmutableMap<String, Optional<Object>> DEFAULTS =
+        ImmutableMap.<String, Optional<Object>>builder()
+            .put(Settings.System.ANIMATOR_DURATION_SCALE, Optional.of(1))
+            .build();
+    private static final Map<String, Optional<Object>> settings = new ConcurrentHashMap<>(DEFAULTS);
+
+    @Implementation
+    protected static boolean putInt(ContentResolver cr, String name, int value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static int getInt(ContentResolver cr, String name, int def) {
+      return get(Integer.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static int getInt(ContentResolver cr, String name) throws SettingNotFoundException {
+      return get(Integer.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    @Implementation
+    protected static boolean putString(ContentResolver cr, String name, String value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static String getString(ContentResolver cr, String name) {
+      return get(String.class, name).orElse(null);
+    }
+
+    @Implementation(minSdk = JELLY_BEAN_MR1)
+    protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
+      return get(String.class, name).orElse(null);
+    }
+
+    @Implementation
+    protected static boolean putLong(ContentResolver cr, String name, long value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static long getLong(ContentResolver cr, String name, long def) {
+      return get(Long.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static long getLong(ContentResolver cr, String name) throws SettingNotFoundException {
+      return get(Long.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    @Implementation
+    protected static boolean putFloat(ContentResolver cr, String name, float value) {
+      boolean result = put(cr, name, value);
+      if (Settings.System.WINDOW_ANIMATION_SCALE.equals(name)) {
+        ShadowValueAnimator.setDurationScale(value);
+      }
+      return result;
+    }
+
+    @Implementation
+    protected static float getFloat(ContentResolver cr, String name, float def) {
+      return get(Float.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static float getFloat(ContentResolver cr, String name)
+        throws SettingNotFoundException {
+      return get(Float.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    private static boolean put(ContentResolver cr, String name, Object value) {
+      if (!Objects.equals(
+          settings.put(name, Optional.ofNullable(value)), Optional.ofNullable(value))) {
+        if (cr != null) {
+          cr.notifyChange(Settings.System.getUriFor(name), null);
+        }
+      }
+      return true;
+    }
+
+    private static <T> Optional<T> get(Class<T> type, String name) {
+      return settings.getOrDefault(name, Optional.empty()).filter(type::isInstance).map(type::cast);
+    }
+
+    @Resetter
+    public static void reset() {
+      settings.clear();
+      settings.putAll(DEFAULTS);
+    }
+  }
+
+  @Implements(value = Settings.Secure.class)
+  public static class ShadowSecure {
+    private static final HashMap<String, Optional<Object>> SECURE_DEFAULTS = new HashMap<>();
+
+    // source of truth for initial location state
+    static final boolean INITIAL_GPS_PROVIDER_STATE = true;
+    static final boolean INITIAL_NETWORK_PROVIDER_STATE = false;
+
+    static {
+      if (INITIAL_GPS_PROVIDER_STATE && INITIAL_NETWORK_PROVIDER_STATE) {
+        SECURE_DEFAULTS.put(Secure.LOCATION_MODE, Optional.of(Secure.LOCATION_MODE_HIGH_ACCURACY));
+        SECURE_DEFAULTS.put(Secure.LOCATION_PROVIDERS_ALLOWED, Optional.of("gps,network"));
+      } else if (INITIAL_GPS_PROVIDER_STATE) {
+        SECURE_DEFAULTS.put(Secure.LOCATION_MODE, Optional.of(Secure.LOCATION_MODE_SENSORS_ONLY));
+        SECURE_DEFAULTS.put(Secure.LOCATION_PROVIDERS_ALLOWED, Optional.of("gps"));
+      } else if (INITIAL_NETWORK_PROVIDER_STATE) {
+        SECURE_DEFAULTS.put(Secure.LOCATION_MODE, Optional.of(Secure.LOCATION_MODE_BATTERY_SAVING));
+        SECURE_DEFAULTS.put(Secure.LOCATION_PROVIDERS_ALLOWED, Optional.of("network"));
+      } else {
+        SECURE_DEFAULTS.put(Secure.LOCATION_MODE, Optional.of(LOCATION_MODE_OFF));
+      }
+    }
+
+    private static final Map<String, Optional<Object>> dataMap =
+        new ConcurrentHashMap<>(SECURE_DEFAULTS);
+
+    @Implementation(minSdk = JELLY_BEAN_MR1, maxSdk = P)
+    @SuppressWarnings("robolectric.ShadowReturnTypeMismatch")
+    protected static boolean setLocationProviderEnabledForUser(
+        ContentResolver cr, String provider, boolean enabled, int uid) {
+      return updateEnabledProviders(cr, provider, enabled);
+    }
+
+    @Implementation(maxSdk = JELLY_BEAN)
+    protected static void setLocationProviderEnabled(
+        ContentResolver cr, String provider, boolean enabled) {
+      updateEnabledProviders(cr, provider, enabled);
+    }
+
+    // only for use locally and by ShadowLocationManager, which requires a tight integration with
+    // ShadowSettings due to historical weirdness between LocationManager and Settings.
+    static boolean updateEnabledProviders(ContentResolver cr, String provider, boolean enabled) {
+      Set<String> providers = new HashSet<>();
+      String oldProviders =
+          Settings.Secure.getString(cr, Settings.Secure.LOCATION_PROVIDERS_ALLOWED);
+      if (!TextUtils.isEmpty(oldProviders)) {
+        providers.addAll(Arrays.asList(oldProviders.split(",")));
+      }
+
+      if (enabled == oldProviders.contains(provider)) {
+        return true;
+      }
+
+      if (enabled) {
+        providers.add(provider);
+      } else {
+        providers.remove(provider);
+      }
+
+      String newProviders = TextUtils.join(",", providers.toArray());
+      boolean r =
+          Settings.Secure.putString(cr, Settings.Secure.LOCATION_PROVIDERS_ALLOWED, newProviders);
+
+      Intent providersBroadcast = new Intent(LocationManager.PROVIDERS_CHANGED_ACTION);
+      if (RuntimeEnvironment.getApiLevel() >= Q) {
+        providersBroadcast.putExtra(LocationManager.EXTRA_PROVIDER_NAME, provider);
+      }
+      if (RuntimeEnvironment.getApiLevel() >= R) {
+        providersBroadcast.putExtra(LocationManager.EXTRA_PROVIDER_ENABLED, enabled);
+      }
+      RuntimeEnvironment.getApplication().sendBroadcast(providersBroadcast);
+
+      return r;
+    }
+
+    @Implementation
+    protected static boolean putInt(ContentResolver cr, String name, int value) {
+      boolean changed = !Objects.equals(dataMap.put(name, Optional.of(value)), Optional.of(value));
+
+      if (Settings.Secure.LOCATION_MODE.equals(name)) {
+        if (RuntimeEnvironment.getApiLevel() <= P) {
+          // do this after setting location mode but before invoking contentobservers, so that
+          // observers for both settings will see the correct values
+          boolean gps =
+              (value == Settings.Secure.LOCATION_MODE_SENSORS_ONLY
+                  || value == Settings.Secure.LOCATION_MODE_HIGH_ACCURACY);
+          boolean network =
+              (value == Settings.Secure.LOCATION_MODE_BATTERY_SAVING
+                  || value == Settings.Secure.LOCATION_MODE_HIGH_ACCURACY);
+          Settings.Secure.setLocationProviderEnabled(cr, LocationManager.GPS_PROVIDER, gps);
+          Settings.Secure.setLocationProviderEnabled(cr, LocationManager.NETWORK_PROVIDER, network);
+        }
+
+        Intent modeBroadcast = new Intent(LocationManager.MODE_CHANGED_ACTION);
+        if (RuntimeEnvironment.getApiLevel() >= R) {
+          modeBroadcast.putExtra(
+              LocationManager.EXTRA_LOCATION_ENABLED, value != LOCATION_MODE_OFF);
+        }
+        RuntimeEnvironment.getApplication().sendBroadcast(modeBroadcast);
+      }
+
+      if (changed && cr != null) {
+        cr.notifyChange(Settings.Secure.getUriFor(name), null);
+      }
+
+      return true;
+    }
+
+    @Implementation(minSdk = LOLLIPOP)
+    protected static boolean putIntForUser(
+        ContentResolver cr, String name, int value, int userHandle) {
+      putInt(cr, name, value);
+      return true;
+    }
+
+    @Implementation(minSdk = JELLY_BEAN_MR1)
+    protected static int getIntForUser(ContentResolver cr, String name, int def, int userHandle) {
+      // ignore userhandle
+      return getInt(cr, name, def);
+    }
+
+    @Implementation(minSdk = JELLY_BEAN_MR1)
+    protected static int getIntForUser(ContentResolver cr, String name, int userHandle)
+        throws SettingNotFoundException {
+      // ignore userhandle
+      return getInt(cr, name);
+    }
+
+    @Implementation
+    protected static int getInt(ContentResolver cr, String name) throws SettingNotFoundException {
+      if (Settings.Secure.LOCATION_MODE.equals(name)
+          && RuntimeEnvironment.getApiLevel() >= KITKAT
+          && RuntimeEnvironment.getApiLevel() < P) {
+        // Map from to underlying location provider storage API to location mode
+        return Shadow.directlyOn(
+            Settings.Secure.class,
+            "getLocationModeForUser",
+            ClassParameter.from(ContentResolver.class, cr),
+            ClassParameter.from(int.class, 0));
+      }
+
+      return get(Integer.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    @Implementation
+    protected static int getInt(ContentResolver cr, String name, int def) {
+      if (Settings.Secure.LOCATION_MODE.equals(name)
+          && RuntimeEnvironment.getApiLevel() >= KITKAT
+          && RuntimeEnvironment.getApiLevel() < P) {
+        // Map from to underlying location provider storage API to location mode
+        return Shadow.directlyOn(
+            Settings.Secure.class,
+            "getLocationModeForUser",
+            ClassParameter.from(ContentResolver.class, cr),
+            ClassParameter.from(int.class, 0));
+      }
+
+      return get(Integer.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static boolean putString(ContentResolver cr, String name, String value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static String getString(ContentResolver cr, String name) {
+      return get(String.class, name).orElse(null);
+    }
+
+    @Implementation(minSdk = JELLY_BEAN_MR1)
+    protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
+      return getString(cr, name);
+    }
+
+    @Implementation
+    protected static boolean putLong(ContentResolver cr, String name, long value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static long getLong(ContentResolver cr, String name, long def) {
+      return get(Long.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static long getLong(ContentResolver cr, String name) throws SettingNotFoundException {
+      return get(Long.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    @Implementation
+    protected static boolean putFloat(ContentResolver cr, String name, float value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static float getFloat(ContentResolver cr, String name, float def) {
+      return get(Float.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static float getFloat(ContentResolver cr, String name)
+        throws SettingNotFoundException {
+      return get(Float.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    private static boolean put(ContentResolver cr, String name, Object value) {
+      if (!Objects.equals(
+          dataMap.put(name, Optional.ofNullable(value)), Optional.ofNullable(value))) {
+        if (cr != null) {
+          cr.notifyChange(Settings.Secure.getUriFor(name), null);
+        }
+      }
+      return true;
+    }
+
+    private static <T> Optional<T> get(Class<T> type, String name) {
+      return dataMap.getOrDefault(name, Optional.empty()).filter(type::isInstance).map(type::cast);
+    }
+
+    @Resetter
+    public static void reset() {
+      dataMap.clear();
+      dataMap.putAll(SECURE_DEFAULTS);
+    }
+  }
+
+  @Implements(value = Settings.Global.class, minSdk = JELLY_BEAN_MR1)
+  public static class ShadowGlobal {
+    private static final ImmutableMap<String, Optional<Object>> DEFAULTS =
+        ImmutableMap.<String, Optional<Object>>builder()
+            .put(Settings.Global.ANIMATOR_DURATION_SCALE, Optional.of(1))
+            .build();
+    private static final Map<String, Optional<Object>> settings = new ConcurrentHashMap<>(DEFAULTS);
+
+    @Implementation
+    protected static boolean putInt(ContentResolver cr, String name, int value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static int getInt(ContentResolver cr, String name, int def) {
+      return get(Integer.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static int getInt(ContentResolver cr, String name) throws SettingNotFoundException {
+      return get(Integer.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    @Implementation
+    protected static boolean putString(ContentResolver cr, String name, String value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static String getString(ContentResolver cr, String name) {
+      return get(String.class, name).orElse(null);
+    }
+
+    @Implementation(minSdk = JELLY_BEAN_MR1)
+    protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
+      return getString(cr, name);
+    }
+
+    @Implementation
+    protected static boolean putLong(ContentResolver cr, String name, long value) {
+      return put(cr, name, value);
+    }
+
+    @Implementation
+    protected static long getLong(ContentResolver cr, String name, long def) {
+      return get(Long.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static long getLong(ContentResolver cr, String name) throws SettingNotFoundException {
+      return get(Long.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    @Implementation
+    protected static boolean putFloat(ContentResolver cr, String name, float value) {
+      boolean result = put(cr, name, value);
+      if (Settings.Global.ANIMATOR_DURATION_SCALE.equals(name)) {
+        ShadowValueAnimator.setDurationScale(value);
+      }
+      return result;
+    }
+
+    @Implementation
+    protected static float getFloat(ContentResolver cr, String name, float def) {
+      return get(Float.class, name).orElse(def);
+    }
+
+    @Implementation
+    protected static float getFloat(ContentResolver cr, String name)
+        throws SettingNotFoundException {
+      return get(Float.class, name).orElseThrow(() -> new SettingNotFoundException(name));
+    }
+
+    private static boolean put(ContentResolver cr, String name, Object value) {
+      if (!Objects.equals(
+          settings.put(name, Optional.ofNullable(value)), Optional.ofNullable(value))) {
+        if (cr != null) {
+          cr.notifyChange(Settings.Global.getUriFor(name), null);
+        }
+      }
+      return true;
+    }
+
+    private static <T> Optional<T> get(Class<T> type, String name) {
+      return settings.getOrDefault(name, Optional.empty()).filter(type::isInstance).map(type::cast);
+    }
+
+    @Resetter
+    public static void reset() {
+      settings.clear();
+      settings.putAll(DEFAULTS);
+    }
+  }
+
+  /**
+   * Sets the value of the {@link Settings.System#AIRPLANE_MODE_ON} setting.
+   *
+   * @param isAirplaneMode new status for airplane mode
+   */
+  public static void setAirplaneMode(boolean isAirplaneMode) {
+    Settings.Global.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Global.AIRPLANE_MODE_ON,
+        isAirplaneMode ? 1 : 0);
+    Settings.System.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.System.AIRPLANE_MODE_ON,
+        isAirplaneMode ? 1 : 0);
+  }
+
+  /**
+   * Non-Android accessor that allows the value of the WIFI_ON setting to be set.
+   *
+   * @param isOn new status for wifi mode
+   */
+  public static void setWifiOn(boolean isOn) {
+    Settings.Global.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Global.WIFI_ON,
+        isOn ? 1 : 0);
+    Settings.System.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.System.WIFI_ON,
+        isOn ? 1 : 0);
+  }
+
+  /**
+   * Sets the value of the {@link Settings.System#TIME_12_24} setting.
+   *
+   * @param use24HourTimeFormat new status for the time setting
+   */
+  public static void set24HourTimeFormat(boolean use24HourTimeFormat) {
+    Settings.System.putString(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.System.TIME_12_24,
+        use24HourTimeFormat ? "24" : "12");
+  }
+
+  private static boolean canDrawOverlays = false;
+
+  /**
+   * @return false by default, or the value specified via {@link #setCanDrawOverlays(boolean)}
+   */
+  @Implementation(minSdk = M)
+  protected static boolean canDrawOverlays(Context context) {
+    return canDrawOverlays;
+  }
+
+  /** Sets the value returned by {@link #canDrawOverlays(Context)}. */
+  public static void setCanDrawOverlays(boolean canDrawOverlays) {
+    ShadowSettings.canDrawOverlays = canDrawOverlays;
+  }
+
+  /**
+   * Sets the value of the {@link Settings.Global#ADB_ENABLED} setting or {@link
+   * Settings.Secure#ADB_ENABLED} depending on API level.
+   *
+   * @param adbEnabled new value for whether adb is enabled
+   */
+  public static void setAdbEnabled(boolean adbEnabled) {
+    // This setting moved from Secure to Global in JELLY_BEAN_MR1
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+      Settings.Global.putInt(
+          RuntimeEnvironment.getApplication().getContentResolver(),
+          Settings.Global.ADB_ENABLED,
+          adbEnabled ? 1 : 0);
+    }
+    // Support all clients by always setting the Secure version of the setting
+    Settings.Secure.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Secure.ADB_ENABLED,
+        adbEnabled ? 1 : 0);
+  }
+
+  /**
+   * Sets the value of the {@link Settings.Global#INSTALL_NON_MARKET_APPS} setting or {@link
+   * Settings.Secure#INSTALL_NON_MARKET_APPS} depending on API level.
+   *
+   * @param installNonMarketApps new value for whether non-market apps are allowed to be installed
+   */
+  public static void setInstallNonMarketApps(boolean installNonMarketApps) {
+    // This setting moved from Secure to Global in JELLY_BEAN_MR1 and then moved it back to Global
+    // in LOLLIPOP. Support all clients by always setting this field on all versions >=
+    // JELLY_BEAN_MR1.
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+      Settings.Global.putInt(
+          RuntimeEnvironment.getApplication().getContentResolver(),
+          Settings.Global.INSTALL_NON_MARKET_APPS,
+          installNonMarketApps ? 1 : 0);
+    }
+    // Always set the Secure version of the setting
+    Settings.Secure.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Secure.INSTALL_NON_MARKET_APPS,
+        installNonMarketApps ? 1 : 0);
+  }
+
+  public static void setLockScreenShowNotifications(boolean lockScreenShowNotifications) {
+    Settings.Secure.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS,
+        lockScreenShowNotifications ? 1 : 0);
+  }
+
+  public static void setLockScreenAllowPrivateNotifications(
+      boolean lockScreenAllowPrivateNotifications) {
+    Settings.Secure.putInt(
+        RuntimeEnvironment.getApplication().getContentResolver(),
+        Settings.Secure.LOCK_SCREEN_ALLOW_PRIVATE_NOTIFICATIONS,
+        lockScreenAllowPrivateNotifications ? 1 : 0);
+  }
+
+  @Resetter
+  public static void reset() {
+    canDrawOverlays = false;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSharedMemory.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSharedMemory.java
new file mode 100644
index 0000000..21f0204
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSharedMemory.java
@@ -0,0 +1,134 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import android.os.SharedMemory;
+import android.system.ErrnoException;
+import android.system.OsConstants;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.MappedByteBuffer;
+import java.nio.channels.FileChannel.MapMode;
+import java.nio.file.Files;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.TempDirectory;
+
+/**
+ * A {@link SharedMemory} fake that uses a private temporary disk file for storage and Java's {@link
+ * MappedByteBuffer} for the memory mappings.
+ */
+@Implements(
+    value = SharedMemory.class,
+    minSdk = Build.VERSION_CODES.O_MR1,
+    /* not quite true, but this prevents a useless `shadowOf()` accessor showing
+     * up, which would break people compiling against API 26 and earlier.
+     */
+    isInAndroidSdk = false)
+public class ShadowSharedMemory {
+  private static final Map<Integer, File> filesByFd = new ConcurrentHashMap<>();
+
+  private static final AtomicReference<ErrnoException> fakeCreateException =
+      new AtomicReference<>();
+
+  @RealObject private SharedMemory realObject;
+
+  @Resetter
+  public static void reset() {
+    filesByFd.clear();
+  }
+
+  /**
+   * Only works on {@link SharedMemory} instances from {@link SharedMemory#create}.
+   *
+   * <p>"prot" is ignored -- all mappings are read/write.
+   */
+  @Implementation
+  protected ByteBuffer map(int prot, int offset, int length) throws ErrnoException {
+    ReflectionHelpers.callInstanceMethod(realObject, "checkOpen");
+    FileDescriptor fileDescriptor = getRealFileDescriptor();
+    int fd = ReflectionHelpers.getField(fileDescriptor, "fd");
+    File file = filesByFd.get(fd);
+    if (file == null) {
+      throw new IllegalStateException("Cannot find the backing file from fd");
+    }
+
+    try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw")) {
+      // It would be easy to support a MapMode.READ_ONLY type mapping as well except none of the
+      // OsConstants fields are even initialized by robolectric and so "prot" is always zero!
+      return randomAccessFile.getChannel().map(MapMode.READ_WRITE, offset, length);
+    } catch (IOException e) {
+      throw new ErrnoException(e.getMessage(), OsConstants.EIO, e);
+    }
+  }
+
+  @Implementation
+  protected static void unmap(ByteBuffer mappedBuf) throws ErrnoException {
+    if (mappedBuf instanceof MappedByteBuffer) {
+      // There's no API to clean up a MappedByteBuffer other than GC so we've got nothing to do!
+    } else {
+      throw new IllegalArgumentException(
+          "ByteBuffer wasn't created by #map(int, int, int); can't unmap");
+    }
+  }
+
+  @Implementation
+  protected static FileDescriptor nCreate(String name, int size) throws ErrnoException {
+    maybeThrow(fakeCreateException);
+
+    TempDirectory tempDirectory = RuntimeEnvironment.getTempDirectory();
+
+    try {
+      // Give each instance its own private file.
+      File sharedMemoryFile =
+          Files.createTempFile(
+                  tempDirectory.createIfNotExists("SharedMemory"), "shmem-" + name, ".tmp")
+              .toFile();
+      RandomAccessFile randomAccessFile = new RandomAccessFile(sharedMemoryFile, "rw");
+      randomAccessFile.setLength(0);
+      randomAccessFile.setLength(size);
+
+      FileDescriptor fileDescriptor = randomAccessFile.getFD();
+      int fd = ReflectionHelpers.getField(fileDescriptor, "fd");
+      filesByFd.put(fd, sharedMemoryFile);
+      return fileDescriptor;
+    } catch (IOException e) {
+      throw new RuntimeException("Unable to create file descriptior", e);
+    }
+  }
+
+  @Implementation
+  protected static int nGetSize(FileDescriptor fd) {
+    int internalFd = ReflectionHelpers.getField(fd, "fd");
+    return (int) filesByFd.get(internalFd).length();
+  }
+
+  private FileDescriptor getRealFileDescriptor() {
+    return ReflectionHelpers.getField(realObject, "mFileDescriptor");
+  }
+
+  /**
+   * Causes subsequent calls to {@link SharedMemory#create)} to throw the specified exception, if
+   * non-null. Pass null to restore create to normal operation.
+   */
+  public static void setCreateShouldThrow(ErrnoException e) {
+    fakeCreateException.set(e);
+  }
+
+  private static void maybeThrow(AtomicReference<ErrnoException> exceptionRef)
+      throws ErrnoException {
+    ErrnoException exception = exceptionRef.get();
+    if (exception != null) {
+      throw exception;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSharedPreferences.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSharedPreferences.java
new file mode 100644
index 0000000..101dd48
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSharedPreferences.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
+
+import android.app.QueuedWork;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+/** Placeholder container class for nested shadow class */
+public class ShadowSharedPreferences {
+
+  @Implements(
+      className = "android.app.SharedPreferencesImpl$EditorImpl",
+      minSdk = VERSION_CODES.O,
+      isInAndroidSdk = false)
+  public static class ShadowSharedPreferencesEditorImpl {
+
+    @RealObject Object realObject;
+    private static final Object lock = new Object();
+
+    @Implementation
+    protected void apply() {
+      if (ShadowLooper.looperMode() == LEGACY) {
+        synchronized (lock) {
+          Shadow.directlyOn(realObject, "android.app.SharedPreferencesImpl$EditorImpl", "apply");
+          // Flush QueuedWork. This resolves the deadlock of calling 'apply' followed by 'commit'.
+          QueuedWork.waitToFinish();
+        }
+      } else {
+        Shadow.directlyOn(realObject, "android.app.SharedPreferencesImpl$EditorImpl", "apply");
+        // Flush QueuedWork. This resolves the deadlock of calling 'apply' followed by 'commit'.
+        QueuedWork.waitToFinish();
+      }
+    }
+
+    @Implementation
+    protected boolean commit() {
+      // In Legacy LooperMode, all Android loopers run on a single thread.
+      // This lock resolves the deadlock of when the main thread/looper is blocked until
+      // 'commit' finishes, but QueuedWork is blocked until the main looper is unblocked.
+      if (ShadowLooper.looperMode() == LEGACY) {
+        synchronized (lock) {
+          return Shadow.directlyOn(
+              realObject, "android.app.SharedPreferencesImpl$EditorImpl", "commit");
+        }
+      } else {
+        return Shadow.directlyOn(
+            realObject, "android.app.SharedPreferencesImpl$EditorImpl", "commit");
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowShortcutManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowShortcutManager.java
new file mode 100644
index 0000000..c2fa93b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowShortcutManager.java
@@ -0,0 +1,281 @@
+package org.robolectric.shadows;
+
+import static android.content.pm.ShortcutManager.FLAG_MATCH_CACHED;
+import static android.content.pm.ShortcutManager.FLAG_MATCH_DYNAMIC;
+import static android.content.pm.ShortcutManager.FLAG_MATCH_MANIFEST;
+import static android.content.pm.ShortcutManager.FLAG_MATCH_PINNED;
+import static android.os.Build.VERSION_CODES.R;
+import static java.util.stream.Collectors.toCollection;
+
+import android.content.Intent;
+import android.content.IntentSender;
+import android.content.IntentSender.SendIntentException;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** */
+@Implements(value = ShortcutManager.class, minSdk = Build.VERSION_CODES.N_MR1)
+public class ShadowShortcutManager {
+
+  private static final int MAX_ICON_DIMENSION = 128;
+
+  private final Map<String, ShortcutInfo> dynamicShortcuts = new HashMap<>();
+  private final Map<String, ShortcutInfo> activePinnedShortcuts = new HashMap<>();
+  private final Map<String, ShortcutInfo> disabledPinnedShortcuts = new HashMap<>();
+
+  private List<ShortcutInfo> manifestShortcuts = ImmutableList.of();
+
+  private boolean isRequestPinShortcutSupported = true;
+  private int maxShortcutCountPerActivity = 16;
+  private int maxIconHeight = MAX_ICON_DIMENSION;
+  private int maxIconWidth = MAX_ICON_DIMENSION;
+
+  @Implementation
+  protected boolean addDynamicShortcuts(List<ShortcutInfo> shortcutInfoList) {
+    for (ShortcutInfo shortcutInfo : shortcutInfoList) {
+      shortcutInfo.addFlags(ShortcutInfo.FLAG_DYNAMIC);
+      if (activePinnedShortcuts.containsKey(shortcutInfo.getId())) {
+        ShortcutInfo previousShortcut = activePinnedShortcuts.get(shortcutInfo.getId());
+        if (!previousShortcut.isImmutable()) {
+          activePinnedShortcuts.put(shortcutInfo.getId(), shortcutInfo);
+        }
+      } else if (disabledPinnedShortcuts.containsKey(shortcutInfo.getId())) {
+        ShortcutInfo previousShortcut = disabledPinnedShortcuts.get(shortcutInfo.getId());
+        if (!previousShortcut.isImmutable()) {
+          disabledPinnedShortcuts.put(shortcutInfo.getId(), shortcutInfo);
+        }
+      } else if (dynamicShortcuts.containsKey(shortcutInfo.getId())) {
+        ShortcutInfo previousShortcut = dynamicShortcuts.get(shortcutInfo.getId());
+        if (!previousShortcut.isImmutable()) {
+          dynamicShortcuts.put(shortcutInfo.getId(), shortcutInfo);
+        }
+      } else {
+        dynamicShortcuts.put(shortcutInfo.getId(), shortcutInfo);
+      }
+    }
+    return true;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected Intent createShortcutResultIntent(ShortcutInfo shortcut) {
+    if (disabledPinnedShortcuts.containsKey(shortcut.getId())) {
+      throw new IllegalArgumentException();
+    }
+    return new Intent();
+  }
+
+  @Implementation
+  protected void disableShortcuts(List<String> shortcutIds) {
+    disableShortcuts(shortcutIds, "Shortcut is disabled.");
+  }
+
+  @Implementation
+  protected void disableShortcuts(List<String> shortcutIds, CharSequence unused) {
+    for (String shortcutId : shortcutIds) {
+      ShortcutInfo shortcut = activePinnedShortcuts.remove(shortcutId);
+      if (shortcut != null) {
+        disabledPinnedShortcuts.put(shortcutId, shortcut);
+      }
+    }
+  }
+
+  @Implementation
+  protected void enableShortcuts(List<String> shortcutIds) {
+    for (String shortcutId : shortcutIds) {
+      ShortcutInfo shortcut = disabledPinnedShortcuts.remove(shortcutId);
+      if (shortcut != null) {
+        activePinnedShortcuts.put(shortcutId, shortcut);
+      }
+    }
+  }
+
+  @Implementation
+  protected List<ShortcutInfo> getDynamicShortcuts() {
+    return ImmutableList.copyOf(dynamicShortcuts.values());
+  }
+
+  @Implementation
+  protected int getIconMaxHeight() {
+    return maxIconHeight;
+  }
+
+  @Implementation
+  protected int getIconMaxWidth() {
+    return maxIconWidth;
+  }
+
+  /** Sets the value returned by {@link #getIconMaxHeight()}. */
+  public void setIconMaxHeight(int height) {
+    maxIconHeight = height;
+  }
+
+  /** Sets the value returned by {@link #getIconMaxWidth()}. */
+  public void setIconMaxWidth(int width) {
+    maxIconWidth = width;
+  }
+
+  @Implementation
+  protected List<ShortcutInfo> getManifestShortcuts() {
+    return manifestShortcuts;
+  }
+
+  /** Sets the value returned by {@link #getManifestShortcuts()}. */
+  public void setManifestShortcuts(List<ShortcutInfo> manifestShortcuts) {
+    for (ShortcutInfo shortcutInfo : manifestShortcuts) {
+      shortcutInfo.addFlags(ShortcutInfo.FLAG_MANIFEST);
+    }
+    this.manifestShortcuts = manifestShortcuts;
+  }
+
+  @Implementation
+  protected int getMaxShortcutCountPerActivity() {
+    return maxShortcutCountPerActivity;
+  }
+
+  /** Sets the value returned by {@link #getMaxShortcutCountPerActivity()} . */
+  public void setMaxShortcutCountPerActivity(int value) {
+    maxShortcutCountPerActivity = value;
+  }
+
+  @Implementation
+  protected List<ShortcutInfo> getPinnedShortcuts() {
+    ImmutableList.Builder<ShortcutInfo> pinnedShortcuts = ImmutableList.builder();
+    pinnedShortcuts.addAll(activePinnedShortcuts.values());
+    pinnedShortcuts.addAll(disabledPinnedShortcuts.values());
+    return pinnedShortcuts.build();
+  }
+
+  @Implementation
+  protected boolean isRateLimitingActive() {
+    return false;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected boolean isRequestPinShortcutSupported() {
+    return isRequestPinShortcutSupported;
+  }
+
+  public void setIsRequestPinShortcutSupported(boolean isRequestPinShortcutSupported) {
+    this.isRequestPinShortcutSupported = isRequestPinShortcutSupported;
+  }
+
+  @Implementation
+  protected void removeAllDynamicShortcuts() {
+    dynamicShortcuts.clear();
+  }
+
+  @Implementation
+  protected void removeDynamicShortcuts(List<String> shortcutIds) {
+    for (String shortcutId : shortcutIds) {
+      dynamicShortcuts.remove(shortcutId);
+    }
+  }
+
+  @Implementation
+  protected void reportShortcutUsed(String shortcutId) {}
+
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected boolean requestPinShortcut(ShortcutInfo shortcut, IntentSender resultIntent) {
+    shortcut.addFlags(ShortcutInfo.FLAG_PINNED);
+    if (disabledPinnedShortcuts.containsKey(shortcut.getId())) {
+      throw new IllegalArgumentException(
+          "Shortcut with ID [" + shortcut.getId() + "] already exists and is disabled.");
+    }
+    if (dynamicShortcuts.containsKey(shortcut.getId())) {
+      activePinnedShortcuts.put(shortcut.getId(), dynamicShortcuts.remove(shortcut.getId()));
+    } else {
+      activePinnedShortcuts.put(shortcut.getId(), shortcut);
+    }
+    if (resultIntent != null) {
+      try {
+        resultIntent.sendIntent(RuntimeEnvironment.getApplication(), 0, null, null, null);
+      } catch (SendIntentException e) {
+        throw new IllegalStateException();
+      }
+    }
+    return true;
+  }
+
+  @Implementation
+  protected boolean setDynamicShortcuts(List<ShortcutInfo> shortcutInfoList) {
+    dynamicShortcuts.clear();
+    return addDynamicShortcuts(shortcutInfoList);
+  }
+
+  @Implementation
+  protected boolean updateShortcuts(List<ShortcutInfo> shortcutInfoList) {
+    List<ShortcutInfo> existingShortcutsToUpdate = new ArrayList<>();
+    for (ShortcutInfo shortcutInfo : shortcutInfoList) {
+      if (dynamicShortcuts.containsKey(shortcutInfo.getId())
+          || activePinnedShortcuts.containsKey(shortcutInfo.getId())
+          || disabledPinnedShortcuts.containsKey(shortcutInfo.getId())) {
+        existingShortcutsToUpdate.add(shortcutInfo);
+      }
+    }
+    return addDynamicShortcuts(existingShortcutsToUpdate);
+  }
+
+  /**
+   * No-op on Robolectric. The real implementation calls out to a service, which will NPE on
+   * Robolectric.
+   */
+  protected void updateShortcutVisibility(
+      final String packageName, final byte[] certificate, final boolean visible) {}
+
+  /**
+   * In Robolectric, ShadowShortcutManager doesn't perform any caching so long lived shortcuts are
+   * returned on place of shortcuts cached when shown in notifications.
+   */
+  @Implementation(minSdk = R)
+  protected List<ShortcutInfo> getShortcuts(int matchFlags) {
+    if (matchFlags == 0) {
+      return Lists.newArrayList();
+    }
+
+    Set<ShortcutInfo> shortcutInfoSet = new HashSet<>();
+    shortcutInfoSet.addAll(getManifestShortcuts());
+    shortcutInfoSet.addAll(getDynamicShortcuts());
+    shortcutInfoSet.addAll(getPinnedShortcuts());
+
+    return shortcutInfoSet.stream()
+        .filter(
+            shortcutInfo ->
+                ((matchFlags & FLAG_MATCH_MANIFEST) != 0 && shortcutInfo.isDeclaredInManifest())
+                    || ((matchFlags & FLAG_MATCH_DYNAMIC) != 0 && shortcutInfo.isDynamic())
+                    || ((matchFlags & FLAG_MATCH_PINNED) != 0 && shortcutInfo.isPinned())
+                    || ((matchFlags & FLAG_MATCH_CACHED) != 0
+                        && (shortcutInfo.isCached() || shortcutInfo.isLongLived())))
+        .collect(toCollection(ArrayList::new));
+  }
+
+  /**
+   * In Robolectric, ShadowShortcutManager doesn't handle rate limiting or shortcut count limits.
+   * So, pushDynamicShortcut is similar to {@link #addDynamicShortcuts(List)} but with only one
+   * {@link ShortcutInfo}.
+   */
+  @Implementation(minSdk = R)
+  protected void pushDynamicShortcut(ShortcutInfo shortcut) {
+    addDynamicShortcuts(Arrays.asList(shortcut));
+  }
+
+  /**
+   * No-op on Robolectric. The real implementation calls out to a service, which will NPE on
+   * Robolectric.
+   */
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void removeLongLivedShortcuts(List<String> shortcutIds) {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSigningInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSigningInfo.java
new file mode 100644
index 0000000..9e1d0f5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSigningInfo.java
@@ -0,0 +1,88 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.content.pm.Signature;
+import android.content.pm.SigningInfo;
+import android.os.Parcel;
+import android.os.Parcelable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = SigningInfo.class, minSdk = P)
+public class ShadowSigningInfo {
+  public static final Parcelable.Creator<SigningInfo> CREATOR =
+      new Parcelable.Creator<SigningInfo>() {
+        @Override
+        public SigningInfo createFromParcel(Parcel in) {
+          SigningInfo signingInfo = Shadow.newInstanceOf(SigningInfo.class);
+          shadowOf(signingInfo).setSignatures(in.createTypedArray(Signature.CREATOR));
+          shadowOf(signingInfo).setPastSigningCertificates(in.createTypedArray(Signature.CREATOR));
+          return signingInfo;
+        }
+
+        @Override
+        public SigningInfo[] newArray(int size) {
+          return new SigningInfo[size];
+        }
+      };
+
+  private Signature[] signatures;
+  private Signature[] pastSigningCertificates;
+
+  /**
+   * Set the current Signatures for this package. If signatures has a size greater than 1,
+   * {@link #hasMultipleSigners} will be true and {@link #getSigningCertificateHistory} will return
+   * null.
+   */
+  public void setSignatures(Signature[] signatures) {
+    this.signatures = signatures;
+  }
+
+  /**
+   * Sets the history of Signatures for this package.
+   */
+  public void setPastSigningCertificates(Signature[] pastSigningCertificates) {
+    this.pastSigningCertificates = pastSigningCertificates;
+  }
+
+  @Implementation
+  protected boolean hasMultipleSigners() {
+    return signatures != null && signatures.length > 1;
+  }
+
+  @Implementation
+  protected boolean hasPastSigningCertificates() {
+    return signatures != null && pastSigningCertificates != null;
+  }
+
+  @Implementation
+  protected Signature[] getSigningCertificateHistory() {
+    if (hasMultipleSigners()) {
+      return null;
+    } else if (!hasPastSigningCertificates()) {
+      // this package is only signed by one signer with no history, return it
+      return signatures;
+    } else {
+      // this package has provided proof of past signing certificates, include them
+      return pastSigningCertificates;
+    }
+  }
+
+  @Implementation
+  protected Signature[] getApkContentsSigners() {
+    return signatures;
+  }
+
+  @Implementation
+  public void writeToParcel(Parcel parcel, int flags) {
+    // Overwrite the CREATOR so that we can simulate reading from parcel.
+    ReflectionHelpers.setStaticField(SigningInfo.class, "CREATOR", CREATOR);
+
+    parcel.writeTypedArray(signatures, flags);
+    parcel.writeTypedArray(pastSigningCertificates, flags);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSliceManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSliceManager.java
new file mode 100644
index 0000000..74d6418
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSliceManager.java
@@ -0,0 +1,111 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+
+import android.app.slice.SliceManager;
+import android.app.slice.SliceSpec;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.net.Uri;
+import android.os.Handler;
+import androidx.annotation.NonNull;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow of {@link SliceManager}. */
+@Implements(value = SliceManager.class, minSdk = P)
+public class ShadowSliceManager {
+
+  private static final Map<Integer, Collection<Uri>> packageUidsToPermissionGrantedSliceUris =
+      new HashMap<>();
+  private final Map<Uri, Set<SliceSpec>> pinnedUriMap = new HashMap<>();
+  private Context context;
+
+  @Implementation
+  protected void __constructor__(Context context, Handler handler) {
+    this.context = context;
+  }
+
+  @Implementation
+  protected synchronized List<Uri> getPinnedSlices() {
+    return new ArrayList<>(pinnedUriMap.keySet());
+  }
+
+  @Implementation
+  protected synchronized void grantSlicePermission(String toPackage, Uri uri) {
+    int packageUid = getUidForPackage(toPackage);
+    Collection<Uri> uris = packageUidsToPermissionGrantedSliceUris.get(packageUid);
+    if (uris == null) {
+      uris = new ArrayList<>();
+      packageUidsToPermissionGrantedSliceUris.put(packageUid, uris);
+    }
+    uris.add(uri);
+  }
+
+  @Implementation
+  protected synchronized void revokeSlicePermission(String toPackage, Uri uri) {
+    int packageUid = getUidForPackage(toPackage);
+    Collection<Uri> uris = packageUidsToPermissionGrantedSliceUris.get(packageUid);
+    if (uris != null) {
+      uris.remove(uri);
+      if (uris.isEmpty()) {
+        packageUidsToPermissionGrantedSliceUris.remove(packageUid);
+      }
+    }
+  }
+
+  @Implementation
+  protected synchronized int checkSlicePermission(Uri uri, int pid, int uid) {
+    if (uid == 0) {
+      return PackageManager.PERMISSION_GRANTED;
+    }
+    Collection<Uri> uris = packageUidsToPermissionGrantedSliceUris.get(uid);
+    if (uris != null && uris.contains(uri)) {
+      return PackageManager.PERMISSION_GRANTED;
+    }
+    return PackageManager.PERMISSION_DENIED;
+  }
+
+  @Implementation
+  protected void pinSlice(@NonNull Uri uri, @NonNull Set<SliceSpec> specs) {
+    pinnedUriMap.put(uri, specs);
+  }
+
+  @Implementation
+  protected void unpinSlice(@NonNull Uri uri) {
+    pinnedUriMap.remove(uri);
+  }
+
+  @Implementation
+  @NonNull
+  protected Set<SliceSpec> getPinnedSpecs(Uri uri) {
+    if (pinnedUriMap.containsKey(uri)) {
+      return pinnedUriMap.get(uri);
+    } else {
+      return ImmutableSet.of();
+    }
+  }
+
+  private int getUidForPackage(String packageName) {
+    PackageManager packageManager = context.getPackageManager();
+    try {
+      return packageManager.getPackageUid(packageName, 0);
+    } catch (NameNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    packageUidsToPermissionGrantedSliceUris.clear();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSmsManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSmsManager.java
new file mode 100644
index 0000000..95232db
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSmsManager.java
@@ -0,0 +1,554 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.telephony.SmsManager;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = SmsManager.class, minSdk = JELLY_BEAN_MR2)
+public class ShadowSmsManager {
+
+  private String smscAddress;
+  private boolean hasSmscAddressPermission = true;
+  private static int defaultSmsSubscriptionId = -1;
+
+  @Resetter
+  public static void reset() {
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP_MR1) {
+      Map<String, Object> sSubInstances =
+          ReflectionHelpers.getStaticField(SmsManager.class, "sSubInstances");
+      sSubInstances.clear();
+      defaultSmsSubscriptionId = -1;
+    }
+  }
+
+  // SMS functionality
+
+  protected TextSmsParams lastTextSmsParams;
+  protected TextMultipartParams lastTextMultipartParams;
+  protected DataMessageParams lastDataParams;
+
+  @Implementation
+  protected void sendDataMessage(
+      String destinationAddress,
+      String scAddress,
+      short destinationPort,
+      byte[] data,
+      PendingIntent sentIntent,
+      PendingIntent deliveryIntent) {
+    if (TextUtils.isEmpty(destinationAddress)) {
+      throw new IllegalArgumentException("Invalid destinationAddress");
+    }
+
+    lastDataParams =
+        new DataMessageParams(
+            destinationAddress, scAddress, destinationPort, data, sentIntent, deliveryIntent);
+  }
+
+  @Implementation
+  protected void sendTextMessage(
+      String destinationAddress,
+      String scAddress,
+      String text,
+      PendingIntent sentIntent,
+      PendingIntent deliveryIntent) {
+    if (TextUtils.isEmpty(destinationAddress)) {
+      throw new IllegalArgumentException("Invalid destinationAddress");
+    }
+
+    if (TextUtils.isEmpty(text)) {
+      throw new IllegalArgumentException("Invalid message body");
+    }
+
+    lastTextSmsParams =
+        new TextSmsParams(destinationAddress, scAddress, text, sentIntent, deliveryIntent);
+  }
+
+  @Implementation(minSdk = R)
+  protected void sendTextMessage(
+      String destinationAddress,
+      String scAddress,
+      String text,
+      PendingIntent sentIntent,
+      PendingIntent deliveryIntent,
+      long messageId) {
+    if (TextUtils.isEmpty(destinationAddress)) {
+      throw new IllegalArgumentException("Invalid destinationAddress");
+    }
+
+    if (TextUtils.isEmpty(text)) {
+      throw new IllegalArgumentException("Invalid message body");
+    }
+
+    lastTextSmsParams =
+        new TextSmsParams(
+            destinationAddress, scAddress, text, sentIntent, deliveryIntent, messageId);
+  }
+
+  @Implementation
+  protected void sendMultipartTextMessage(
+      String destinationAddress,
+      String scAddress,
+      ArrayList<String> parts,
+      ArrayList<PendingIntent> sentIntents,
+      ArrayList<PendingIntent> deliveryIntents) {
+    if (TextUtils.isEmpty(destinationAddress)) {
+      throw new IllegalArgumentException("Invalid destinationAddress");
+    }
+
+    if (parts == null) {
+      throw new IllegalArgumentException("Invalid message parts");
+    }
+
+    lastTextMultipartParams =
+        new TextMultipartParams(destinationAddress, scAddress, parts, sentIntents, deliveryIntents);
+  }
+
+  /** @return Parameters for last call to {@link #sendDataMessage}. */
+  public DataMessageParams getLastSentDataMessageParams() {
+    return lastDataParams;
+  }
+
+  /** Clear last recorded parameters for {@link #sendDataMessage}. */
+  public void clearLastSentDataMessageParams() {
+    lastDataParams = null;
+  }
+
+  /** @return Parameters for last call to {@link #sendTextMessage}. */
+  public TextSmsParams getLastSentTextMessageParams() {
+    return lastTextSmsParams;
+  }
+
+  /** Clear last recorded parameters for {@link #sendTextMessage}. */
+  public void clearLastSentTextMessageParams() {
+    lastTextSmsParams = null;
+  }
+
+  /** @return Parameters for last call to {@link #sendMultipartTextMessage}. */
+  public TextMultipartParams getLastSentMultipartTextMessageParams() {
+    return lastTextMultipartParams;
+  }
+
+  /** Clear last recorded parameters for {@link #sendMultipartTextMessage}. */
+  public void clearLastSentMultipartTextMessageParams() {
+    lastTextMultipartParams = null;
+  }
+
+  public static class DataMessageParams {
+    private final String destinationAddress;
+    private final String scAddress;
+    private final short destinationPort;
+    private final byte[] data;
+    private final PendingIntent sentIntent;
+    private final PendingIntent deliveryIntent;
+
+    public DataMessageParams(
+        String destinationAddress,
+        String scAddress,
+        short destinationPort,
+        byte[] data,
+        PendingIntent sentIntent,
+        PendingIntent deliveryIntent) {
+      this.destinationAddress = destinationAddress;
+      this.scAddress = scAddress;
+      this.destinationPort = destinationPort;
+      this.data = data;
+      this.sentIntent = sentIntent;
+      this.deliveryIntent = deliveryIntent;
+    }
+
+    public String getDestinationAddress() {
+      return destinationAddress;
+    }
+
+    public String getScAddress() {
+      return scAddress;
+    }
+
+    public short getDestinationPort() {
+      return destinationPort;
+    }
+
+    public byte[] getData() {
+      return data;
+    }
+
+    public PendingIntent getSentIntent() {
+      return sentIntent;
+    }
+
+    public PendingIntent getDeliveryIntent() {
+      return deliveryIntent;
+    }
+  }
+
+  public static class TextSmsParams {
+    private final String destinationAddress;
+    private final String scAddress;
+    private final String text;
+    private final PendingIntent sentIntent;
+    private final PendingIntent deliveryIntent;
+    private final long messageId;
+
+    public TextSmsParams(
+        String destinationAddress,
+        String scAddress,
+        String text,
+        PendingIntent sentIntent,
+        PendingIntent deliveryIntent) {
+      this(destinationAddress, scAddress, text, sentIntent, deliveryIntent, 0L);
+    }
+
+    public TextSmsParams(
+        String destinationAddress,
+        String scAddress,
+        String text,
+        PendingIntent sentIntent,
+        PendingIntent deliveryIntent,
+        long messageId) {
+      this.destinationAddress = destinationAddress;
+      this.scAddress = scAddress;
+      this.text = text;
+      this.sentIntent = sentIntent;
+      this.deliveryIntent = deliveryIntent;
+      this.messageId = messageId;
+    }
+
+    public String getDestinationAddress() {
+      return destinationAddress;
+    }
+
+    public String getScAddress() {
+      return scAddress;
+    }
+
+    public String getText() {
+      return text;
+    }
+
+    public PendingIntent getSentIntent() {
+      return sentIntent;
+    }
+
+    public PendingIntent getDeliveryIntent() {
+      return deliveryIntent;
+    }
+
+    public long getMessageId() {
+      return messageId;
+    }
+  }
+
+  public static class TextMultipartParams {
+    private final String destinationAddress;
+    private final String scAddress;
+    private final List<String> parts;
+    private final List<PendingIntent> sentIntents;
+    private final List<PendingIntent> deliveryIntents;
+    private final long messageId;
+
+    public TextMultipartParams(
+        String destinationAddress,
+        String scAddress,
+        ArrayList<String> parts,
+        ArrayList<PendingIntent> sentIntents,
+        ArrayList<PendingIntent> deliveryIntents) {
+      this(destinationAddress, scAddress, parts, sentIntents, deliveryIntents, 0L);
+    }
+
+    public TextMultipartParams(
+        String destinationAddress,
+        String scAddress,
+        List<String> parts,
+        List<PendingIntent> sentIntents,
+        List<PendingIntent> deliveryIntents,
+        long messageId) {
+      this.destinationAddress = destinationAddress;
+      this.scAddress = scAddress;
+      this.parts = parts;
+      this.sentIntents = sentIntents;
+      this.deliveryIntents = deliveryIntents;
+      this.messageId = messageId;
+    }
+
+    public String getDestinationAddress() {
+      return destinationAddress;
+    }
+
+    public String getScAddress() {
+      return scAddress;
+    }
+
+    public List<String> getParts() {
+      return parts;
+    }
+
+    public List<android.app.PendingIntent> getSentIntents() {
+      return sentIntents;
+    }
+
+    public List<android.app.PendingIntent> getDeliveryIntents() {
+      return deliveryIntents;
+    }
+
+    public long getMessageId() {
+      return messageId;
+    }
+  }
+
+  // MMS functionality
+
+  protected SendMultimediaMessageParams lastSentMultimediaMessageParams;
+  protected DownloadMultimediaMessageParams lastDownloadedMultimediaMessageParams;
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void sendMultimediaMessage(
+      Context context,
+      Uri contentUri,
+      @Nullable String locationUrl,
+      @Nullable Bundle configOverrides,
+      @Nullable PendingIntent sentIntent) {
+    if (contentUri == null || TextUtils.isEmpty(contentUri.getHost())) {
+      throw new IllegalArgumentException("Invalid contentUri");
+    }
+
+    lastSentMultimediaMessageParams =
+        new SendMultimediaMessageParams(contentUri, locationUrl, configOverrides, sentIntent, 0L);
+  }
+
+  @Implementation(minSdk = S)
+  protected void sendMultimediaMessage(
+      Context context,
+      Uri contentUri,
+      @Nullable String locationUrl,
+      @Nullable Bundle configOverrides,
+      @Nullable PendingIntent sentIntent,
+      long messageId) {
+    if (contentUri == null || TextUtils.isEmpty(contentUri.getHost())) {
+      throw new IllegalArgumentException("Invalid contentUri");
+    }
+
+    lastSentMultimediaMessageParams =
+        new SendMultimediaMessageParams(
+            contentUri, locationUrl, configOverrides, sentIntent, messageId);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void downloadMultimediaMessage(
+      Context context,
+      String locationUrl,
+      Uri contentUri,
+      @Nullable Bundle configOverrides,
+      @Nullable PendingIntent sentIntent) {
+    if (contentUri == null || TextUtils.isEmpty(contentUri.getHost())) {
+      throw new IllegalArgumentException("Invalid contentUri");
+    }
+
+    if (TextUtils.isEmpty(locationUrl)) {
+      throw new IllegalArgumentException("Invalid locationUrl");
+    }
+
+    lastDownloadedMultimediaMessageParams =
+        new DownloadMultimediaMessageParams(
+            contentUri, locationUrl, configOverrides, sentIntent, 0L);
+  }
+
+  @Implementation(minSdk = S)
+  protected void downloadMultimediaMessage(
+      Context context,
+      String locationUrl,
+      Uri contentUri,
+      @Nullable Bundle configOverrides,
+      @Nullable PendingIntent sentIntent,
+      long messageId) {
+    if (contentUri == null || TextUtils.isEmpty(contentUri.getHost())) {
+      throw new IllegalArgumentException("Invalid contentUri");
+    }
+
+    if (TextUtils.isEmpty(locationUrl)) {
+      throw new IllegalArgumentException("Invalid locationUrl");
+    }
+
+    lastDownloadedMultimediaMessageParams =
+        new org.robolectric.shadows.ShadowSmsManager.DownloadMultimediaMessageParams(
+            contentUri, locationUrl, configOverrides, sentIntent, messageId);
+  }
+
+  /** @return Parameters for last call to {@link #sendMultimediaMessage}. */
+  public SendMultimediaMessageParams getLastSentMultimediaMessageParams() {
+    return lastSentMultimediaMessageParams;
+  }
+
+  /** Clear last recorded parameters for {@link #sendMultimediaMessage}. */
+  public void clearLastSentMultimediaMessageParams() {
+    lastSentMultimediaMessageParams = null;
+  }
+
+  /** @return Parameters for last call to {@link #downloadMultimediaMessage}. */
+  public DownloadMultimediaMessageParams getLastDownloadedMultimediaMessageParams() {
+    return lastDownloadedMultimediaMessageParams;
+  }
+
+  /** Clear last recorded parameters for {@link #downloadMultimediaMessage}. */
+  public void clearLastDownloadedMultimediaMessageParams() {
+    lastDownloadedMultimediaMessageParams = null;
+  }
+
+  @Implementation(minSdk = R)
+  protected void sendMultipartTextMessage(
+      String destinationAddress,
+      String scAddress,
+      List<String> parts,
+      List<PendingIntent> sentIntents,
+      List<PendingIntent> deliveryIntents,
+      long messageId) {
+    if (TextUtils.isEmpty(destinationAddress)) {
+      throw new IllegalArgumentException("Invalid destinationAddress");
+    }
+
+    if (parts == null) {
+      throw new IllegalArgumentException("Invalid message parts");
+    }
+
+    lastTextMultipartParams =
+        new TextMultipartParams(
+            destinationAddress, scAddress, parts, sentIntents, deliveryIntents, messageId);
+  }
+
+  /**
+   * Base class for testable parameters from calls to either {@link #downloadMultimediaMessage} or
+   * {@link #downloadMultimediaMessage}.
+   */
+  public abstract static class MultimediaMessageParams {
+    private final Uri contentUri;
+    protected final String locationUrl;
+    @Nullable private final Bundle configOverrides;
+    @Nullable protected final PendingIntent pendingIntent;
+    protected final long messageId;
+
+    protected MultimediaMessageParams(
+        Uri contentUri,
+        String locationUrl,
+        @Nullable Bundle configOverrides,
+        @Nullable PendingIntent pendingIntent,
+        long messageId) {
+      this.contentUri = contentUri;
+      this.locationUrl = locationUrl;
+      this.configOverrides = configOverrides;
+      this.pendingIntent = pendingIntent;
+      this.messageId = messageId;
+    }
+
+    public Uri getContentUri() {
+      return contentUri;
+    }
+
+    @Nullable
+    public Bundle getConfigOverrides() {
+      return configOverrides;
+    }
+
+    public long getMessageId() {
+      return messageId;
+    }
+  }
+
+  /** Testable parameters from calls to {@link #sendMultimediaMessage}. */
+  public static final class SendMultimediaMessageParams extends MultimediaMessageParams {
+    public SendMultimediaMessageParams(
+        Uri contentUri,
+        @Nullable String locationUrl,
+        @Nullable Bundle configOverrides,
+        @Nullable PendingIntent pendingIntent,
+        long messageId) {
+      super(contentUri, locationUrl, configOverrides, pendingIntent, messageId);
+    }
+
+    @Nullable
+    public String getLocationUrl() {
+      return locationUrl;
+    }
+
+    @Nullable
+    public PendingIntent getSentIntent() {
+      return pendingIntent;
+    }
+  }
+
+  /** Testable parameters from calls to {@link #downloadMultimediaMessage}. */
+  public static final class DownloadMultimediaMessageParams extends MultimediaMessageParams {
+    public DownloadMultimediaMessageParams(
+        Uri contentUri,
+        String locationUrl,
+        @Nullable Bundle configOverrides,
+        @Nullable PendingIntent pendingIntent,
+        long messageId) {
+      super(contentUri, locationUrl, configOverrides, pendingIntent, messageId);
+    }
+
+    public String getLocationUrl() {
+      return locationUrl;
+    }
+
+    @Nullable
+    public PendingIntent getDownloadedIntent() {
+      return pendingIntent;
+    }
+  }
+
+  /**
+   * Sets a boolean value to simulate whether or not the required permissions to call {@link
+   * #getSmscAddress()} have been granted.
+   */
+  public void setSmscAddressPermission(boolean smscAddressPermission) {
+    this.hasSmscAddressPermission = smscAddressPermission;
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link #setSmscAddress(String)}.
+   * Required permission is set by {@link #setSmscAddressPermission(boolean)}.
+   */
+  @Implementation(minSdk = R)
+  protected String getSmscAddress() {
+    if (!hasSmscAddressPermission) {
+      throw new SecurityException();
+    }
+    return smscAddress;
+  }
+
+  /** Sets the value to be returned by {@link #getDefaultSmsSubscriptionId()}. */
+  public static void setDefaultSmsSubscriptionId(int id) {
+    defaultSmsSubscriptionId = id;
+  }
+
+  /**
+   * Returns {@code -1} by default or the value specified in {@link
+   * #setDefaultSmsSubscriptionId(int)}.
+   */
+  @Implementation(minSdk = R)
+  protected static int getDefaultSmsSubscriptionId() {
+    return defaultSmsSubscriptionId;
+  }
+
+  /** Sets the value returned by {@link SmsManager#getSmscAddress()}. */
+  public void setSmscAddress(String smscAddress) {
+    this.smscAddress = smscAddress;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSocketTagger.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSocketTagger.java
new file mode 100644
index 0000000..ebd890b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSocketTagger.java
@@ -0,0 +1,19 @@
+package org.robolectric.shadows;
+
+import dalvik.system.SocketTagger;
+import java.net.Socket;
+import java.net.SocketException;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = SocketTagger.class, isInAndroidSdk = false)
+public class ShadowSocketTagger {
+
+  @Implementation
+  public final void tag(Socket socket) throws SocketException {
+  }
+
+  @Implementation
+  public final void untag(Socket socket) throws SocketException {
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoftKeyboardController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoftKeyboardController.java
new file mode 100644
index 0000000..10f5802
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoftKeyboardController.java
@@ -0,0 +1,64 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityService.SoftKeyboardController;
+import android.os.Handler;
+import android.os.Looper;
+import java.util.HashMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** Shadow of SoftKeyboardController. */
+@Implements(value = SoftKeyboardController.class, minSdk = N)
+public class ShadowSoftKeyboardController {
+
+  @RealObject private SoftKeyboardController realObject;
+
+  private final HashMap<SoftKeyboardController.OnShowModeChangedListener, Handler> listeners =
+      new HashMap<>();
+  private int showMode = AccessibilityService.SHOW_MODE_AUTO;
+
+  @Implementation
+  protected void addOnShowModeChangedListener(
+      SoftKeyboardController.OnShowModeChangedListener listener, Handler handler) {
+    listeners.put(listener, handler);
+  }
+
+  @Implementation
+  protected void addOnShowModeChangedListener(
+      SoftKeyboardController.OnShowModeChangedListener listener) {
+    listeners.put(listener, new Handler(Looper.getMainLooper()));
+  }
+
+  @Implementation
+  protected int getShowMode() {
+    return showMode;
+  }
+
+  @Implementation
+  protected boolean setShowMode(int showMode) {
+    this.showMode = showMode;
+    notifyOnShowModeChangedListeners();
+    return true;
+  }
+
+  @Implementation
+  protected boolean removeOnShowModeChangedListener(
+      SoftKeyboardController.OnShowModeChangedListener listener) {
+    if (!listeners.containsKey(listener)) {
+      return false;
+    }
+    listeners.remove(listener);
+    return true;
+  }
+
+  private void notifyOnShowModeChangedListeners() {
+    for (SoftKeyboardController.OnShowModeChangedListener listener : listeners.keySet()) {
+      Handler handler = listeners.get(listener);
+      handler.post(() -> listener.onShowModeChanged(realObject, showMode));
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java
new file mode 100644
index 0000000..cf06d40
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java
@@ -0,0 +1,215 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+
+import android.content.Context;
+import android.media.IAudioService;
+import android.media.SoundPool;
+import android.media.SoundPool.OnLoadCompleteListener;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(SoundPool.class)
+public class ShadowSoundPool {
+  @RealObject SoundPool realObject;
+
+  /** Generates sound ids when they are loaded. */
+  private final AtomicInteger soundIds = new AtomicInteger(1);
+
+  /** Tracks mapping between sound id and the paths they refer too. */
+  private final SparseArray<String> idToPaths = new SparseArray<>();
+
+  /** Tracks mapping between sound ids and the resource id they refer too. */
+  private final SparseIntArray idToRes = new SparseIntArray();
+
+  private final List<Playback> playedSounds = new ArrayList<>();
+
+  private OnLoadCompleteListener listener;
+
+  @Implementation(minSdk = N, maxSdk = N_MR1)
+  protected static IAudioService getService() {
+    return ReflectionHelpers.createNullProxy(IAudioService.class);
+  }
+
+  // Pre api 23, the SoundPool holds an internal delegate rather than directly been used itself.
+  // Because of this it's necessary to override the public method, rather than the internal
+  // native method.
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  protected int play(
+      int soundID, float leftVolume, float rightVolume, int priority, int loop, float rate) {
+    playedSounds.add(new Playback(soundID, leftVolume, rightVolume, priority, loop, rate));
+    return 1;
+  }
+
+  @Implementation(minSdk = M)
+  protected int _play(
+      int soundID, float leftVolume, float rightVolume, int priority, int loop, float rate) {
+    playedSounds.add(new Playback(soundID, leftVolume, rightVolume, priority, loop, rate));
+    return 1;
+  }
+
+  // It's not possible to override the native _load method as that would only give access to a
+  // FileDescriptor which would make it difficult to check if a given sound has been placed.
+  @Implementation
+  protected int load(String path, int priority) {
+    int soundId = soundIds.getAndIncrement();
+    idToPaths.put(soundId, path);
+    return soundId;
+  }
+
+  @Implementation
+  protected int load(Context context, int resId, int priority) {
+    int soundId = soundIds.getAndIncrement();
+    idToRes.put(soundId, resId);
+    return soundId;
+  }
+
+  @Implementation
+  protected void setOnLoadCompleteListener(OnLoadCompleteListener listener) {
+    this.listener = listener;
+  }
+
+  /** Notify the {@link OnLoadCompleteListener}, if present, that the given path was loaded. */
+  public void notifyPathLoaded(String path, boolean success) {
+    boolean soundIsKnown = false;
+    for (int pathIdx = 0; pathIdx < idToPaths.size(); ++pathIdx) {
+      if (idToPaths.valueAt(pathIdx).equals(path)) {
+        if (listener != null) {
+          listener.onLoadComplete(realObject, idToPaths.keyAt(pathIdx), success ? 0 : 1);
+        }
+        soundIsKnown = true;
+      }
+    }
+    if (!soundIsKnown) {
+      throw new IllegalArgumentException("Unknown sound. You need to call load() first");
+    }
+  }
+
+  /** Notify the {@link OnLoadCompleteListener}, if present, that the given resource was loaded. */
+  public void notifyResourceLoaded(int resId, boolean success) {
+    boolean soundIsKnown = false;
+    for (int resIdx = 0; resIdx < idToRes.size(); ++resIdx) {
+      if (idToRes.valueAt(resIdx) == resId) {
+        if (listener != null) {
+          listener.onLoadComplete(realObject, idToRes.keyAt(resIdx), success ? 0 : 1);
+        }
+        soundIsKnown = true;
+      }
+    }
+    if (!soundIsKnown) {
+      throw new IllegalArgumentException("Unknown sound. You need to call load() first");
+    }
+  }
+
+  /** Returns {@code true} if the given path was played. */
+  public boolean wasPathPlayed(String path) {
+    for (Playback playback : playedSounds) {
+      if (idIsForPath(playback.soundId, path)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns {@code true} if the given resource was played. */
+  public boolean wasResourcePlayed(int resId) {
+    for (Playback playback : playedSounds) {
+      if (idIsForResource(playback.soundId, resId)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Return a list of calls to {@code play} made for the given path. */
+  public List<Playback> getPathPlaybacks(String path) {
+    ImmutableList.Builder<Playback> playbacks = ImmutableList.builder();
+    for (Playback playback : playedSounds) {
+      if (idIsForPath(playback.soundId, path)) {
+        playbacks.add(playback);
+      }
+    }
+    return playbacks.build();
+  }
+
+  /** Return a list of calls to {@code play} made for the given resource. */
+  public List<Playback> getResourcePlaybacks(int resId) {
+    ImmutableList.Builder<Playback> playbacks = ImmutableList.builder();
+    for (Playback playback : playedSounds) {
+      if (idIsForResource(playback.soundId, resId)) {
+        playbacks.add(playback);
+      }
+    }
+    return playbacks.build();
+  }
+
+  private boolean idIsForPath(int soundId, String path) {
+    return idToPaths.indexOfKey(soundId) >= 0 && idToPaths.get(soundId).equals(path);
+  }
+
+  private boolean idIsForResource(int soundId, int resId) {
+    return idToRes.indexOfKey(soundId) >= 0 && idToRes.get(soundId) == resId;
+  }
+
+  /** Clears the sounds played by this SoundPool. */
+  public void clearPlayed() {
+    playedSounds.clear();
+  }
+
+  /** Record of a single call to {@link SoundPool#play }. */
+  public static final class Playback {
+    public final int soundId;
+    public final float leftVolume;
+    public final float rightVolume;
+    public final int priority;
+    public final int loop;
+    public final float rate;
+
+    public Playback(
+        int soundId, float leftVolume, float rightVolume, int priority, int loop, float rate) {
+      this.soundId = soundId;
+      this.leftVolume = leftVolume;
+      this.rightVolume = rightVolume;
+      this.priority = priority;
+      this.loop = loop;
+      this.rate = rate;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Playback)) {
+        return false;
+      }
+      Playback that = (Playback) o;
+      return this.soundId == that.soundId
+          && this.leftVolume == that.leftVolume
+          && this.rightVolume == that.rightVolume
+          && this.priority == that.priority
+          && this.loop == that.loop
+          && this.rate == that.rate;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(soundId, leftVolume, rightVolume, priority, loop, rate);
+    }
+
+    @Override
+    public String toString() {
+      return Arrays.asList(soundId, leftVolume, rightVolume, priority, loop, rate).toString();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java
new file mode 100644
index 0000000..f82e91b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java
@@ -0,0 +1,195 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.speech.IRecognitionService;
+import android.speech.RecognitionListener;
+import android.speech.RecognitionSupport;
+import android.speech.RecognitionSupportCallback;
+import android.speech.SpeechRecognizer;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Robolectric shadow for SpeechRecognizer. */
+@Implements(SpeechRecognizer.class)
+public class ShadowSpeechRecognizer {
+
+  @RealObject SpeechRecognizer realSpeechRecognizer;
+  protected static SpeechRecognizer latestSpeechRecognizer;
+  private Intent recognizerIntent;
+  private RecognitionListener recognitionListener;
+  private static boolean isOnDeviceRecognitionAvailable = true;
+
+  private RecognitionSupportCallback recognitionSupportCallback;
+  private Executor recognitionSupportExecutor;
+  @Nullable private Intent latestModelDownloadIntent;
+
+  /**
+   * Returns the latest SpeechRecognizer. This method can only be called after {@link
+   * SpeechRecognizer#createSpeechRecognizer()} is called.
+   */
+  public static SpeechRecognizer getLatestSpeechRecognizer() {
+    return latestSpeechRecognizer;
+  }
+
+  /** Returns the argument passed to the last call to {@link SpeechRecognizer#startListening}. */
+  public Intent getLastRecognizerIntent() {
+    return recognizerIntent;
+  }
+
+  @Resetter
+  public static void reset() {
+    latestSpeechRecognizer = null;
+    isOnDeviceRecognitionAvailable = true;
+  }
+
+  @Implementation
+  protected static SpeechRecognizer createSpeechRecognizer(
+      final Context context, final ComponentName serviceComponent) {
+    SpeechRecognizer result =
+        reflector(SpeechRecognizerReflector.class)
+            .createSpeechRecognizer(context, serviceComponent);
+    latestSpeechRecognizer = result;
+    return result;
+  }
+
+  @Implementation
+  protected void startListening(Intent recognizerIntent) {
+    this.recognizerIntent = recognizerIntent;
+    // the real implementation connects to a service
+    // simulate the resulting behavior once the service is connected
+    Handler mainHandler = new Handler(Looper.getMainLooper());
+    // perform the onServiceConnected logic
+    mainHandler.post(
+        () -> {
+          SpeechRecognizerReflector recognizerReflector =
+              reflector(SpeechRecognizerReflector.class, realSpeechRecognizer);
+          recognizerReflector.setService(
+              ReflectionHelpers.createNullProxy(IRecognitionService.class));
+          Queue<Message> pendingTasks = recognizerReflector.getPendingTasks();
+          while (!pendingTasks.isEmpty()) {
+            recognizerReflector.getHandler().sendMessage(pendingTasks.poll());
+          }
+        });
+  }
+
+  /**
+   * Handles changing the listener and allows access to the internal listener to trigger events and
+   * sets the latest SpeechRecognizer.
+   */
+  @Implementation
+  protected void handleChangeListener(RecognitionListener listener) {
+    recognitionListener = listener;
+  }
+
+  public void triggerOnEndOfSpeech() {
+    recognitionListener.onEndOfSpeech();
+  }
+
+  public void triggerOnError(int error) {
+    recognitionListener.onError(error);
+  }
+
+  public void triggerOnReadyForSpeech(Bundle bundle) {
+    recognitionListener.onReadyForSpeech(bundle);
+  }
+
+  public void triggerOnPartialResults(Bundle bundle) {
+    recognitionListener.onPartialResults(bundle);
+  }
+
+  public void triggerOnResults(Bundle bundle) {
+    recognitionListener.onResults(bundle);
+  }
+
+  public void triggerOnRmsChanged(float rmsdB) {
+    recognitionListener.onRmsChanged(rmsdB);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected static SpeechRecognizer createOnDeviceSpeechRecognizer(final Context context) {
+    SpeechRecognizer result =
+        reflector(SpeechRecognizerReflector.class).createOnDeviceSpeechRecognizer(context);
+    latestSpeechRecognizer = result;
+    return result;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected static boolean isOnDeviceRecognitionAvailable(final Context context) {
+    return isOnDeviceRecognitionAvailable;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected void checkRecognitionSupport(
+      Intent recognizerIntent, Executor executor, RecognitionSupportCallback supportListener) {
+    recognitionSupportExecutor = executor;
+    recognitionSupportCallback = supportListener;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected void triggerModelDownload(Intent recognizerIntent) {
+    latestModelDownloadIntent = recognizerIntent;
+  }
+
+  public static void setIsOnDeviceRecognitionAvailable(boolean available) {
+    isOnDeviceRecognitionAvailable = available;
+  }
+
+  @RequiresApi(VERSION_CODES.TIRAMISU)
+  public void triggerSupportResult(RecognitionSupport recognitionSupport) {
+    recognitionSupportExecutor.execute(
+        () -> recognitionSupportCallback.onSupportResult(recognitionSupport));
+  }
+
+  @RequiresApi(VERSION_CODES.TIRAMISU)
+  public void triggerSupportError(int error) {
+    recognitionSupportExecutor.execute(() -> recognitionSupportCallback.onError(error));
+  }
+
+  @RequiresApi(VERSION_CODES.TIRAMISU)
+  @Nullable
+  public Intent getLatestModelDownloadIntent() {
+    return latestModelDownloadIntent;
+  }
+
+  /** Reflector interface for {@link SpeechRecognizer}'s internals. */
+  @ForType(SpeechRecognizer.class)
+  interface SpeechRecognizerReflector {
+
+    @Static
+    @Direct
+    SpeechRecognizer createSpeechRecognizer(Context context, ComponentName serviceComponent);
+
+    @Accessor("mService")
+    void setService(IRecognitionService service);
+
+    @Accessor("mPendingTasks")
+    Queue<Message> getPendingTasks();
+
+    @Accessor("mHandler")
+    Handler getHandler();
+
+    @Static
+    @Direct
+    SpeechRecognizer createOnDeviceSpeechRecognizer(Context context);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpellChecker.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpellChecker.java
new file mode 100644
index 0000000..d5f6b21
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpellChecker.java
@@ -0,0 +1,8 @@
+package org.robolectric.shadows;
+
+import android.widget.SpellChecker;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = SpellChecker.class, callThroughByDefault = false, isInAndroidSdk = false)
+public class ShadowSpellChecker {
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSslErrorHandler.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSslErrorHandler.java
new file mode 100644
index 0000000..116365a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSslErrorHandler.java
@@ -0,0 +1,30 @@
+package org.robolectric.shadows;
+
+import android.webkit.SslErrorHandler;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(SslErrorHandler.class)
+public class ShadowSslErrorHandler {
+
+  private boolean cancelCalled = false;
+  private boolean proceedCalled = false;
+
+  @Implementation
+  protected void cancel() {
+    cancelCalled = true;
+  }
+
+  public boolean wasCancelCalled() {
+    return cancelCalled;
+  }
+
+  @Implementation
+  protected void proceed() {
+    proceedCalled = true;
+  }
+
+  public boolean wasProceedCalled() {
+    return proceedCalled;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatFs.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatFs.java
new file mode 100644
index 0000000..d685d5b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatFs.java
@@ -0,0 +1,165 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+
+import android.os.StatFs;
+import java.io.File;
+import java.util.Map;
+import java.util.TreeMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Robolectic doesn't provide actual filesystem stats; rather, it provides the ability to specify
+ * stats values in advance.
+ *
+ * @see #registerStats(File, int, int, int)
+ */
+@Implements(StatFs.class)
+public class ShadowStatFs {
+  public static final int BLOCK_SIZE = 4096;
+  private static final Stats DEFAULT_STATS = new Stats(0, 0, 0);
+  private static TreeMap<String, Stats> stats = new TreeMap<>();
+  private Stats stat;
+
+  @Implementation
+  protected void __constructor__(String path) {
+    restat(path);
+  }
+
+  @Implementation
+  protected int getBlockSize() {
+    return BLOCK_SIZE;
+  }
+
+  @Implementation
+  protected int getBlockCount() {
+    return stat.blockCount;
+  }
+
+  @Implementation
+  protected int getFreeBlocks() {
+    return stat.freeBlocks;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getFreeBlocksLong() {
+    return stat.freeBlocks;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getFreeBytes() {
+    return getBlockSizeLong() * getFreeBlocksLong();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getAvailableBytes() {
+    return getBlockSizeLong() * getAvailableBlocksLong();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getTotalBytes() {
+    return getBlockSizeLong() * getBlockCountLong();
+  }
+
+  @Implementation
+  protected int getAvailableBlocks() {
+    return stat.availableBlocks;
+  }
+
+  @Implementation
+  protected void restat(String path) {
+    Map.Entry<String, Stats> mapEntry = stats.floorEntry(path);
+    for (;;) {
+      // We will hit all matching paths, longest one first. We may hit non-matching paths before we
+      // find the right one.
+      if (mapEntry == null) {
+        stat = DEFAULT_STATS;
+        return;
+      }
+      String key = mapEntry.getKey();
+      if (path.startsWith(key)) {
+        stat = mapEntry.getValue();
+        return;
+      }
+      mapEntry = stats.lowerEntry(key);
+    }
+  }
+
+  /** Robolectric always uses a block size of 4096. */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getBlockSizeLong() {
+    return BLOCK_SIZE;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getBlockCountLong() {
+    return stat.blockCount;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected long getAvailableBlocksLong() {
+    return stat.availableBlocks;
+  }
+
+  /**
+   * Register stats for a path, which will be used when a matching {@link StatFs} instance is
+   * created.
+   *
+   * @param path path to the file
+   * @param blockCount number of blocks
+   * @param freeBlocks number of free blocks
+   * @param availableBlocks number of available blocks
+   */
+  public static void registerStats(File path, int blockCount, int freeBlocks, int availableBlocks) {
+    registerStats(path.getAbsolutePath(), blockCount, freeBlocks, availableBlocks);
+  }
+
+  /**
+   * Register stats for a path, which will be used when a matching {@link StatFs} instance is
+   * created.  A {@link StatFs} instance matches if it extends path. If several registered paths
+   * match, we pick the longest one.
+   *
+   * @param path path to the file
+   * @param blockCount number of blocks
+   * @param freeBlocks number of free blocks
+   * @param availableBlocks number of available blocks
+   */
+  public static void registerStats(String path, int blockCount, int freeBlocks,
+      int availableBlocks) {
+    stats.put(path, new Stats(blockCount, freeBlocks, availableBlocks));
+  }
+
+  /**
+   * Unregister stats for a path. If the path is not registered, it will be a no-op.
+   *
+   * @param path path to the file
+   */
+  public static void unregisterStats(File path) {
+    unregisterStats(path.getAbsolutePath());
+  }
+
+  /**
+   * Unregister stats for a path. If the path is not registered, it will be a no-op.
+   *
+   * @param path path to the file
+   */
+  public static void unregisterStats(String path) {
+    stats.remove(path);
+  }
+
+  @Resetter
+  public static void reset() {
+    stats.clear();
+  }
+
+  private static class Stats {
+    Stats(int blockCount, int freeBlocks, int availableBlocks) {
+      this.blockCount = blockCount;
+      this.freeBlocks = freeBlocks;
+      this.availableBlocks = availableBlocks;
+    }
+    int blockCount, freeBlocks, availableBlocks;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStateListDrawable.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStateListDrawable.java
new file mode 100644
index 0000000..9822204
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStateListDrawable.java
@@ -0,0 +1,59 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.util.StateSet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(StateListDrawable.class)
+public class ShadowStateListDrawable extends ShadowDrawable {
+
+  @RealObject StateListDrawable realStateListDrawable;
+
+  private final Map<List<Integer>, Drawable> stateToDrawable = new HashMap<>();
+
+  @Implementation
+  protected void addState(int[] stateSet, Drawable drawable) {
+    stateToDrawable.put(createStateList(stateSet), drawable);
+    reflector(StateListDrawableReflector.class, realStateListDrawable).addState(stateSet, drawable);
+  }
+
+  /**
+   * Non Android accessor to retrieve drawable added for a specific state.
+   *
+   * @param stateSet Int array describing the state
+   * @return Drawable added via {@link #addState(int[], android.graphics.drawable.Drawable)}
+   */
+  public Drawable getDrawableForState(int[] stateSet) {
+    return stateToDrawable.get(createStateList(stateSet));
+  }
+
+  private List<Integer> createStateList(int[] stateSet) {
+    List<Integer> stateList = new ArrayList<>();
+    if (stateSet == StateSet.WILD_CARD) {
+      stateList.add(-1);
+    } else {
+      for (int state : stateSet) {
+        stateList.add(state);
+      }
+    }
+
+    return stateList;
+  }
+
+  @ForType(StateListDrawable.class)
+  interface StateListDrawableReflector {
+    @Direct
+    void addState(int[] stateSet, Drawable drawable);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStaticLayout.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStaticLayout.java
new file mode 100644
index 0000000..5a08de4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStaticLayout.java
@@ -0,0 +1,82 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.text.DynamicLayout;
+import android.text.StaticLayout;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for android.text.StaticLayout */
+@Implements(value = StaticLayout.class, looseSignatures = true)
+public class ShadowStaticLayout {
+
+  @ForType(className = "android.text.StaticLayout$LineBreaks")
+  interface LineBreaksReflector {
+    @Accessor("breaks")
+    void setBreaks(int[] breaks);
+  }
+
+  @Resetter
+  public static void reset() {
+    if (RuntimeEnvironment.getApiLevel() >= M) {
+      ReflectionHelpers.setStaticField(DynamicLayout.class, "sStaticLayout", null);
+      ReflectionHelpers.setStaticField(DynamicLayout.class, "sBuilder", null);
+    }
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP, maxSdk = LOLLIPOP_MR1)
+  public static int[] nLineBreakOpportunities(
+      String locale, char[] text, int length, int[] recycle) {
+    return new int[] {-1};
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = M, maxSdk = O_MR1)
+  public static int nComputeLineBreaks(
+      Object nativePtr,
+      Object recycle,
+      Object recycleBreaks,
+      Object recycleWidths,
+      Object recycleFlags,
+      Object recycleLength) {
+    return 1;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = P, maxSdk = P)
+  protected static int nComputeLineBreaks(
+      Object nativePtr,
+      Object text,
+      Object measuredTextPtr,
+      Object length,
+      Object firstWidth,
+      Object firstWidthLineCount,
+      Object restWidth,
+      Object variableTabStops,
+      Object defaultTabStop,
+      Object indentsOffset,
+      Object recycle,
+      Object recycleLength,
+      Object recycleBreaks,
+      Object recycleWidths,
+      Object recycleAscents,
+      Object recycleDescents,
+      Object recycleFlags,
+      Object charWidths) {
+    reflector(LineBreaksReflector.class, recycle).setBreaks(new int[] {((char[]) text).length});
+    return 1;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatsLog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatsLog.java
new file mode 100644
index 0000000..024edf1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatsLog.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.util.StatsEvent;
+import android.util.StatsLog;
+import com.google.auto.value.AutoValue;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow for {@link StatsLog} */
+@Implements(value = StatsLog.class, minSdk = R)
+public class ShadowStatsLog {
+
+  @Implementation
+  protected static void __staticInitializer__() {}
+
+  private static List<StatsLogItem> statsLogs = Collections.synchronizedList(new ArrayList<>());
+
+  public static List<StatsLogItem> getStatsLogs() {
+    return Collections.unmodifiableList(statsLogs);
+  }
+
+  @Resetter
+  public static void reset() {
+    statsLogs = Collections.synchronizedList(new ArrayList<>());
+  }
+
+  @Implementation
+  public static void write(final StatsEvent statsEvent) {
+    statsLogs.add(
+        StatsLogItem.create(
+            statsEvent.getAtomId(), statsEvent.getNumBytes(), statsEvent.getBytes()));
+    statsEvent.release();
+  }
+
+  /** Single atom log item for write api. */
+  @AutoValue
+  public abstract static class StatsLogItem {
+    public abstract int atomId();
+
+    public abstract int numBytes();
+
+    @SuppressWarnings("AutoValueImmutableFields")
+    public abstract byte[] bytes();
+
+    public static StatsLogItem create(int atomId, int numBytes, byte[] bytes) {
+      return new AutoValue_ShadowStatsLog_StatsLogItem(atomId, numBytes, bytes.clone());
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatusBarManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatusBarManager.java
new file mode 100644
index 0000000..7282eee
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStatusBarManager.java
@@ -0,0 +1,93 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.StatusBarManager;
+import androidx.annotation.VisibleForTesting;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Robolectric implementation of {@link android.app.StatusBarManager}. */
+@Implements(value = StatusBarManager.class, isInAndroidSdk = false)
+public class ShadowStatusBarManager {
+
+  public static final int DEFAULT_DISABLE_MASK = StatusBarManager.DISABLE_MASK;
+  public static final int DEFAULT_DISABLE2_MASK = StatusBarManager.DISABLE2_MASK;
+  public static final int DISABLE_NOTIFICATION_ALERTS = 0x00040000;
+  public static final int DISABLE_EXPAND = 0x00010000;
+  public static final int DISABLE_HOME = 0x00200000;
+  public static final int DISABLE_CLOCK = 0x00800000;
+  public static final int DISABLE_RECENT = 0x01000000;
+  public static final int DISABLE_SEARCH = 0x02000000;
+  public static final int DISABLE_NONE = 0x00000000;
+  public static final int DISABLE2_ROTATE_SUGGESTIONS = 1 << 4;
+  public static final int DISABLE2_NONE = 0x00000000;
+
+  private int disabled = StatusBarManager.DISABLE_NONE;
+  private int disabled2 = StatusBarManager.DISABLE2_NONE;
+
+  private int navBarMode = StatusBarManager.NAV_BAR_MODE_DEFAULT;
+
+  @Implementation
+  protected void disable(int what) {
+    disabled = what;
+  }
+
+  @Implementation(minSdk = M)
+  protected void disable2(int what) {
+    disabled2 = what;
+  }
+
+  @Implementation(minSdk = Q)
+  protected void setDisabledForSetup(boolean disabled) {
+    disable(disabled ? getDefaultSetupDisableFlags() : StatusBarManager.DISABLE_NONE);
+    disable2(disabled ? getDefaultSetupDisable2Flags() : StatusBarManager.DISABLE2_NONE);
+  }
+
+  @VisibleForTesting
+  static int getDefaultSetupDisableFlags() {
+    return reflector(StatusBarManagerReflector.class).getDefaultSetupDisableFlags();
+  }
+
+  @VisibleForTesting
+  static int getDefaultSetupDisable2Flags() {
+    return reflector(StatusBarManagerReflector.class).getDefaultSetupDisable2Flags();
+  }
+
+  /** Returns the disable flags previously set in {@link #disable}. */
+  public int getDisableFlags() {
+    return disabled;
+  }
+
+  /** Returns the disable flags previously set in {@link #disable2}. */
+  public int getDisable2Flags() {
+    return disabled2;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void setNavBarMode(int mode) {
+    navBarMode = mode;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected int getNavBarMode() {
+    return navBarMode;
+  }
+
+  @ForType(StatusBarManager.class)
+  interface StatusBarManagerReflector {
+    @Static
+    @Accessor("DEFAULT_SETUP_DISABLE_FLAGS")
+    int getDefaultSetupDisableFlags();
+
+    @Static
+    @Accessor("DEFAULT_SETUP_DISABLE2_FLAGS")
+    int getDefaultSetupDisable2Flags();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStorageManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStorageManager.java
new file mode 100644
index 0000000..9f15dda
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStorageManager.java
@@ -0,0 +1,108 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+
+import android.os.UserManager;
+import android.os.storage.StorageManager;
+import android.os.storage.StorageVolume;
+import com.google.common.base.Preconditions;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+/**
+ * Fake implementation of {@link android.os.storage.StorageManager}
+ */
+@Implements(StorageManager.class)
+public class ShadowStorageManager {
+
+  private static boolean isFileEncryptionSupported = true;
+  private final List<StorageVolume> storageVolumeList = new ArrayList<>();
+
+  @Implementation(minSdk = M)
+  protected static StorageVolume[] getVolumeList(int userId, int flags) {
+    return new StorageVolume[0];
+  }
+
+  /**
+   * Gets the volume list from {@link #getVolumeList(int, int)}
+   *
+   * @return volume list
+   */
+  @Implementation
+  public StorageVolume[] getVolumeList() {
+    return getVolumeList(0, 0);
+  }
+
+  /**
+   * Adds a {@link StorageVolume} to the list returned by {@link #getStorageVolumes()}.
+   *
+   * @param StorageVolume to add to list
+   */
+  public void addStorageVolume(StorageVolume storageVolume) {
+    Preconditions.checkNotNull(storageVolume);
+    storageVolumeList.add(storageVolume);
+  }
+
+  /**
+   * Returns the storage volumes configured via {@link #addStorageVolume()}.
+   *
+   * @return StorageVolume list
+   */
+  @Implementation(minSdk = N)
+  protected List<StorageVolume> getStorageVolumes() {
+    return storageVolumeList;
+  }
+
+  /** Clears the storageVolumeList. */
+  public void resetStorageVolumeList() {
+    storageVolumeList.clear();
+  }
+
+  /**
+   * Checks whether File belongs to any {@link StorageVolume} in the list returned by {@link
+   * #getStorageVolumes()}.
+   *
+   * @param File to check
+   * @return StorageVolume for the file
+   */
+  @Implementation(minSdk = N)
+  public StorageVolume getStorageVolume(File file) {
+    for (StorageVolume volume : storageVolumeList) {
+      File volumeFile = volume.getPathFile();
+      if (file.getAbsolutePath().startsWith(volumeFile.getAbsolutePath())) {
+        return volume;
+      }
+    }
+    return null;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = N)
+  protected static boolean isFileEncryptedNativeOrEmulated() {
+    return isFileEncryptionSupported;
+  }
+
+  /**
+   * Setter for {@link #isFileEncryptedNativeOrEmulated()}
+   *
+   * @param isSupported a boolean value to set file encrypted native or not
+   */
+  public void setFileEncryptedNativeOrEmulated(boolean isSupported) {
+    isFileEncryptionSupported = isSupported;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = N)
+  protected static boolean isUserKeyUnlocked(int userId) {
+    ShadowUserManager extract =
+        Shadow.extract(RuntimeEnvironment.getApplication().getSystemService(UserManager.class));
+    return extract.isUserUnlocked();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStorageStatsManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStorageStatsManager.java
new file mode 100644
index 0000000..4720787
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStorageStatsManager.java
@@ -0,0 +1,170 @@
+package org.robolectric.shadows;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import android.app.usage.StorageStats;
+import android.app.usage.StorageStatsManager;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.UserHandle;
+import android.os.storage.StorageManager;
+import com.google.auto.value.AutoValue;
+import java.io.IOException;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Fake implementation of {@link android.app.usage.StorageStatsManager} that provides a fake
+ * implementation of query for {@link StorageStats} of a package.
+ */
+@Implements(value = StorageStatsManager.class, minSdk = Build.VERSION_CODES.O)
+public class ShadowStorageStatsManager {
+
+  public static final long DEFAULT_STORAGE_FREE_BYTES = 4L * 1024L * 1024L * 1024L; // 4 GB
+  public static final long DEFAULT_STORAGE_TOTAL_BYTES = 8L * 1024L * 1024L * 1024L; // 8 GB
+
+  private final Map<UUID, FreeAndTotalBytesPair> freeAndTotalBytesMap =
+      createFreeAndTotalBytesMapWithSingleEntry(
+          StorageManager.UUID_DEFAULT, DEFAULT_STORAGE_FREE_BYTES, DEFAULT_STORAGE_TOTAL_BYTES);
+  private final Map<StorageStatsKey, StorageStats> storageStatsMap = new ConcurrentHashMap<>();
+
+  /**
+   * Sets the {@code storageUuid} to return the specified {@code freeBytes} and {@code totalBytes}
+   * when queried in {@link #getFreeBytes} and {@link #getTotalBytes} respectively.
+   *
+   * <p>Both {@code freeBytes} and {@code totalBytes} have to be non-negative, else this method will
+   * throw {@link IllegalArgumentException}.
+   */
+  public void setStorageDeviceFreeAndTotalBytes(UUID storageUuid, long freeBytes, long totalBytes) {
+    checkArgument(
+        freeBytes >= 0 && totalBytes >= 0, "Both freeBytes and totalBytes must be non-negative!");
+    freeAndTotalBytesMap.put(storageUuid, FreeAndTotalBytesPair.create(freeBytes, totalBytes));
+  }
+
+  /**
+   * Removes a storage device identified by {@code storageUuid} if it's currently present.
+   * Otherwise, this method will be a no-op.
+   */
+  public void removeStorageDevice(UUID storageUuid) {
+    freeAndTotalBytesMap.remove(storageUuid);
+  }
+
+  /**
+   * Sets the {@link StorageStats} to return when queried with matching {@code storageUuid}, {@code
+   * packageName} and {@code userHandle}.
+   */
+  public void addStorageStats(
+      UUID storageUuid,
+      String packageName,
+      UserHandle userHandle,
+      StorageStats storageStatsToReturn) {
+    storageStatsMap.put(
+        StorageStatsKey.create(storageUuid, packageName, userHandle), storageStatsToReturn);
+  }
+
+  /** Clears all {@link StorageStats} set in {@link ShadowStorageStatsManager#addStorageStats}. */
+  public void clearStorageStats() {
+    storageStatsMap.clear();
+  }
+
+  /**
+   * Fake implementation of {@link StorageStatsManager#getFreeBytes} that returns test setup values.
+   * This fake implementation does not check for access permission. It only checks for arguments
+   * matching those set in {@link ShadowStorageStatsManager#setStorageDeviceFreeAndTotalBytes}.
+   */
+  @Implementation
+  protected long getFreeBytes(UUID storageUuid) throws IOException {
+    FreeAndTotalBytesPair freeAndTotalBytesPair = freeAndTotalBytesMap.get(storageUuid);
+    if (freeAndTotalBytesPair == null) {
+      throw new IOException(
+          "getFreeBytes with non-existent storageUuid! Did you forget to call"
+              + " setStorageDeviceFreeAndTotalBytes?");
+    }
+    return freeAndTotalBytesPair.freeBytes();
+  }
+
+  /**
+   * Fake implementation of {@link StorageStatsManager#getTotalBytes} that returns test setup
+   * values. This fake implementation does not check for access permission. It only checks for
+   * arguments matching those set in {@link
+   * ShadowStorageStatsManager#setStorageDeviceFreeAndTotalBytes}.
+   */
+  @Implementation
+  protected long getTotalBytes(UUID storageUuid) throws IOException {
+    FreeAndTotalBytesPair freeAndTotalBytesPair = freeAndTotalBytesMap.get(storageUuid);
+    if (freeAndTotalBytesPair == null) {
+      throw new IOException(
+          "getTotalBytes with non-existent storageUuid! Did you forget to call"
+              + " setStorageDeviceFreeAndTotalBytes?");
+    }
+    return freeAndTotalBytesPair.totalBytes();
+  }
+
+  /**
+   * Fake implementation of {@link StorageStatsManager#queryStatsForPackage} that returns test setup
+   * values. This fake implementation does not check for access permission. It only checks for
+   * arguments matching those set in {@link ShadowStorageStatsManager#addStorageStats}.
+   */
+  @Implementation
+  protected StorageStats queryStatsForPackage(UUID storageUuid, String packageName, UserHandle user)
+      throws PackageManager.NameNotFoundException, IOException {
+    StorageStats storageStat =
+        storageStatsMap.get(StorageStatsKey.create(storageUuid, packageName, user));
+    if (storageStat == null) {
+      throw new PackageManager.NameNotFoundException(
+          "queryStatsForPackage with non matching arguments. Did you forget to call"
+              + " addStorageStats?");
+    }
+    return storageStat;
+  }
+
+  private static Map<UUID, FreeAndTotalBytesPair> createFreeAndTotalBytesMapWithSingleEntry(
+      UUID storageUuid, long freeBytes, long totalBytes) {
+    Map<UUID, FreeAndTotalBytesPair> currMap = new ConcurrentHashMap<>();
+    currMap.put(storageUuid, FreeAndTotalBytesPair.create(freeBytes, totalBytes));
+    return currMap;
+  }
+
+  /** Simple wrapper to combine freeBytes and totalBytes in one object. */
+  @AutoValue
+  abstract static class FreeAndTotalBytesPair {
+
+    FreeAndTotalBytesPair() {}
+
+    /** Returns the freeBytes. */
+    abstract long freeBytes();
+
+    /** Returns the totalBytes. */
+    abstract long totalBytes();
+
+    /** Creates {@link FreeAndTotalBytesPair}. */
+    static FreeAndTotalBytesPair create(long freeBytes, long totalBytes) {
+      return new AutoValue_ShadowStorageStatsManager_FreeAndTotalBytesPair(freeBytes, totalBytes);
+    }
+  }
+
+  /** Simple wrapper for parameters of {@link StorageStatsManager#queryStatsForPackage} method. */
+  @AutoValue
+  abstract static class StorageStatsKey {
+
+    StorageStatsKey() {}
+
+    /** Returns the storage UUID part of this key. */
+    abstract UUID storageUuid();
+
+    /** Returns the package name part of this key. */
+    abstract String packageName();
+
+    /** Returns the user handle part of this key. */
+    abstract UserHandle userHandle();
+
+    /** Creates StorageStatsKey. */
+    static StorageStatsKey create(UUID storageUuid, String packageName, UserHandle userHandle) {
+      return new AutoValue_ShadowStorageStatsManager_StorageStatsKey(
+          storageUuid, packageName, userHandle);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStrictModeVmPolicy.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStrictModeVmPolicy.java
new file mode 100644
index 0000000..b6f6797
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStrictModeVmPolicy.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.os.Build;
+import android.os.StrictMode;
+import android.os.StrictMode.VmPolicy;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(value = StrictMode.VmPolicy.class, minSdk = Build.VERSION_CODES.P)
+public class ShadowStrictModeVmPolicy {
+
+  @Implementation
+  protected static void __staticInitializer__() {
+    Shadow.directInitialize(StrictMode.VmPolicy.class);
+
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.P) {
+      // if VmPolicy was referenced first, sVmPolicy won't be set properly. So force a
+      // re-initialization.
+      reflector(_StrictMode_.class).setVmPolicy(VmPolicy.LAX);
+    }
+  }
+
+  /** Accessor interface for {@link StrictMode}'s internals. */
+  @ForType(StrictMode.class)
+  private interface _StrictMode_ {
+    @Static
+    @Accessor("sVmPolicy")
+    void setVmPolicy(VmPolicy vmPolicy);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStringBlock.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStringBlock.java
new file mode 100644
index 0000000..8582291
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowStringBlock.java
@@ -0,0 +1,117 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.res.android.Util.SIZEOF_INT;
+
+import java.nio.ByteBuffer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.android.ResStringPool;
+import org.robolectric.res.android.ResourceTypes.ResStringPool_span;
+
+@Implements(className = "android.content.res.StringBlock", isInAndroidSdk = false)
+public class ShadowStringBlock {
+
+  @RealObject
+  Object realObject;
+
+  @Implementation
+  protected static Number nativeCreate(byte[] data, int offset, int size) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int nativeGetSize(int nativeId) {
+    return nativeGetSize((long) nativeId);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int nativeGetSize(long nativeId) {
+    return ResStringPool.getNativeObject(nativeId).size();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String nativeGetString(int nativeId, int index) {
+    return nativeGetString((long) nativeId, index);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String nativeGetString(long nativeId, int index) {
+    return ResStringPool.getNativeObject(nativeId).stringAt(index);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int[] nativeGetStyle(int obj, int idx) {
+    return nativeGetStyle((long) obj, idx);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int[] nativeGetStyle(long obj, int idx) {
+    ResStringPool osb = ResStringPool.getNativeObject(obj);
+
+    ResStringPool_span spans = osb.styleAt(idx);
+    if (spans == null) {
+      return null;
+    }
+
+    ResStringPool_span pos = spans;
+    int num = 0;
+    while (pos.name.index != ResStringPool_span.END) {
+      num++;
+      // pos++;
+      pos = new ResStringPool_span(pos.myBuf(), pos.myOffset() + ResStringPool_span.SIZEOF);
+    }
+
+    if (num == 0) {
+      return null;
+    }
+
+    // jintArray array = env->NewIntArray((num*sizeof(ResStringPool_span))/sizeof(jint));
+    int[] array = new int[num * ResStringPool_span.SIZEOF / SIZEOF_INT];
+    if (array == null) { // NewIntArray already threw OutOfMemoryError.
+      return null;
+    }
+
+    num = 0;
+    final int numInts = ResStringPool_span.SIZEOF / SIZEOF_INT;
+    while (spans.name.index != ResStringPool_span.END) {
+      // env->SetIntArrayRegion(array,
+      //     num*numInts, numInts,
+      //     (jint*)spans);
+      setIntArrayRegion(array, num, numInts, spans);
+      // spans++;
+      spans = new ResStringPool_span(spans.myBuf(), spans.myOffset() + ResStringPool_span.SIZEOF);
+      num++;
+    }
+
+    return array;
+  }
+
+  private static void setIntArrayRegion(int[] array, int num, int numInts, ResStringPool_span spans) {
+    ByteBuffer buf = spans.myBuf();
+    int startOffset = spans.myOffset();
+
+    int start = num * numInts;
+    for (int i = 0; i < numInts; i++) {
+      array[start + i] = buf.getInt(startOffset + i * SIZEOF_INT);
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static void nativeDestroy(int obj) {
+    nativeDestroy((long) obj);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static void nativeDestroy(long obj) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Resetter
+  public static void reset() {
+    // NATIVE_STRING_POOLS.clear(); // nope!
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java
new file mode 100644
index 0000000..ff81de5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java
@@ -0,0 +1,478 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.os.Build.VERSION;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.SubscriptionManager.OnSubscriptionsChangedListener;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = SubscriptionManager.class, minSdk = LOLLIPOP_MR1)
+public class ShadowSubscriptionManager {
+
+  private boolean readPhoneStatePermission = true;
+  public static final int INVALID_PHONE_INDEX =
+      ReflectionHelpers.getStaticField(SubscriptionManager.class, "INVALID_PHONE_INDEX");
+
+  private static int defaultSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+  private static int defaultDataSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+  private static int defaultSmsSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+  private static int defaultVoiceSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+
+  /** Returns value set with {@link #setDefaultSubscriptionId(int)}. */
+  @Implementation(minSdk = N)
+  protected static int getDefaultSubscriptionId() {
+    return defaultSubscriptionId;
+  }
+
+  /** Returns value set with {@link #setDefaultDataSubscriptionId(int)}. */
+  @Implementation(minSdk = N)
+  protected static int getDefaultDataSubscriptionId() {
+    return defaultDataSubscriptionId;
+  }
+
+  /** Returns value set with {@link #setDefaultSmsSubscriptionId(int)}. */
+  @Implementation(minSdk = N)
+  protected static int getDefaultSmsSubscriptionId() {
+    return defaultSmsSubscriptionId;
+  }
+
+  /** Returns value set with {@link #setDefaultVoiceSubscriptionId(int)}. */
+  @Implementation(minSdk = N)
+  protected static int getDefaultVoiceSubscriptionId() {
+    return defaultVoiceSubscriptionId;
+  }
+
+  @Implementation(maxSdk = M)
+  @HiddenApi
+  protected static int getDefaultSubId() {
+    return defaultSubscriptionId;
+  }
+
+  @Implementation(maxSdk = M)
+  @HiddenApi
+  protected static int getDefaultVoiceSubId() {
+    return defaultVoiceSubscriptionId;
+  }
+
+  @Implementation(maxSdk = M)
+  @HiddenApi
+  protected static int getDefaultSmsSubId() {
+    return defaultSmsSubscriptionId;
+  }
+
+  @Implementation(maxSdk = M)
+  @HiddenApi
+  protected static int getDefaultDataSubId() {
+    return defaultDataSubscriptionId;
+  }
+
+  /** Sets the value that will be returned by {@link #getDefaultSubscriptionId()}. */
+  public static void setDefaultSubscriptionId(int defaultSubscriptionId) {
+    ShadowSubscriptionManager.defaultSubscriptionId = defaultSubscriptionId;
+  }
+
+  public static void setDefaultDataSubscriptionId(int defaultDataSubscriptionId) {
+    ShadowSubscriptionManager.defaultDataSubscriptionId = defaultDataSubscriptionId;
+  }
+
+  public static void setDefaultSmsSubscriptionId(int defaultSmsSubscriptionId) {
+    ShadowSubscriptionManager.defaultSmsSubscriptionId = defaultSmsSubscriptionId;
+  }
+
+  public static void setDefaultVoiceSubscriptionId(int defaultVoiceSubscriptionId) {
+    ShadowSubscriptionManager.defaultVoiceSubscriptionId = defaultVoiceSubscriptionId;
+  }
+
+  /**
+   * Cache of phone IDs used by {@link getPhoneId}. Managed by {@link putPhoneId} and {@link
+   * removePhoneId}.
+   */
+  private static Map<Integer, Integer> phoneIds = new HashMap<>();
+
+  /**
+   * Cache of {@link SubscriptionInfo} used by {@link #getActiveSubscriptionInfoList}.
+   * Managed by {@link #setActiveSubscriptionInfoList}.
+   */
+  private List<SubscriptionInfo> subscriptionList = new ArrayList<>();
+  /**
+   * Cache of {@link SubscriptionInfo} used by {@link #getAvailableSubscriptionInfoList}. Managed by
+   * {@link #setAvailableSubscriptionInfos}.
+   */
+  private List<SubscriptionInfo> availableSubscriptionList = new ArrayList<>();
+  /**
+   * List of listeners to be notified if the list of {@link SubscriptionInfo} changes. Managed by
+   * {@link #addOnSubscriptionsChangedListener} and {@link removeOnSubscriptionsChangedListener}.
+   */
+  private List<OnSubscriptionsChangedListener> listeners = new ArrayList<>();
+  /**
+   * Cache of subscription ids used by {@link #isNetworkRoaming}. Managed by {@link
+   * #setNetworkRoamingStatus} and {@link #clearNetworkRoamingStatus}.
+   */
+  private Set<Integer> roamingSimSubscriptionIds = new HashSet<>();
+
+  /**
+   * Returns the active list of {@link SubscriptionInfo} that were set via {@link
+   * #setActiveSubscriptionInfoList}.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected List<SubscriptionInfo> getActiveSubscriptionInfoList() {
+    checkReadPhoneStatePermission();
+    return subscriptionList;
+  }
+
+  /**
+   * Returns the available list of {@link SubscriptionInfo} that were set via {@link
+   * #setAvailableSubscriptionInfoList}.
+   */
+  @Implementation(minSdk = O_MR1)
+  protected List<SubscriptionInfo> getAvailableSubscriptionInfoList() {
+    return availableSubscriptionList;
+  }
+
+  /**
+   * Returns the size of the list of {@link SubscriptionInfo} that were set via {@link
+   * #setActiveSubscriptionInfoList}. If no list was set, returns 0.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected int getActiveSubscriptionInfoCount() {
+    checkReadPhoneStatePermission();
+    return subscriptionList == null ? 0 : subscriptionList.size();
+  }
+
+  /**
+   * Returns subscription that were set via {@link #setActiveSubscriptionInfoList} if it can find
+   * one with the specified id or null if none found.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected SubscriptionInfo getActiveSubscriptionInfo(int subId) {
+    if (subscriptionList == null) {
+      return null;
+    }
+    for (SubscriptionInfo info : subscriptionList) {
+      if (info.getSubscriptionId() == subId) {
+        return info;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * @return the maximum number of active subscriptions that will be returned by {@link
+   *     #getActiveSubscriptionInfoList} and the value returned by {@link
+   *     #getActiveSubscriptionInfoCount}.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected int getActiveSubscriptionInfoCountMax() {
+    List<SubscriptionInfo> infoList = getActiveSubscriptionInfoList();
+
+    if (infoList == null) {
+      return getActiveSubscriptionInfoCount();
+    }
+
+    return Math.max(getActiveSubscriptionInfoList().size(), getActiveSubscriptionInfoCount());
+  }
+
+  /**
+   * Returns subscription that were set via {@link #setActiveSubscriptionInfoList} if it can find
+   * one with the specified slot index or null if none found.
+   */
+  @Implementation(minSdk = N)
+  protected SubscriptionInfo getActiveSubscriptionInfoForSimSlotIndex(int slotIndex) {
+    checkReadPhoneStatePermission();
+    if (subscriptionList == null) {
+      return null;
+    }
+    for (SubscriptionInfo info : subscriptionList) {
+      if (info.getSimSlotIndex() == slotIndex) {
+        return info;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Sets the active list of {@link SubscriptionInfo}. This call internally triggers {@link
+   * OnSubscriptionsChangedListener#onSubscriptionsChanged()} to all the listeners.
+   * @param list - The subscription info list, can be null.
+   */
+  public void setActiveSubscriptionInfoList(List<SubscriptionInfo> list) {
+    subscriptionList = list;
+    dispatchOnSubscriptionsChanged();
+  }
+
+  /**
+   * Sets the active list of {@link SubscriptionInfo}. This call internally triggers {@link
+   * OnSubscriptionsChangedListener#onSubscriptionsChanged()} to all the listeners.
+   *
+   * @param list - The subscription info list, can be null.
+   */
+  public void setAvailableSubscriptionInfoList(List<SubscriptionInfo> list) {
+    availableSubscriptionList = list;
+    dispatchOnSubscriptionsChanged();
+  }
+
+  /**
+   * Sets the active list of {@link SubscriptionInfo}. This call internally triggers {@link
+   * OnSubscriptionsChangedListener#onSubscriptionsChanged()} to all the listeners.
+   */
+  public void setActiveSubscriptionInfos(SubscriptionInfo... infos) {
+    if (infos == null) {
+      setActiveSubscriptionInfoList(ImmutableList.of());
+    } else {
+      setActiveSubscriptionInfoList(Arrays.asList(infos));
+    }
+  }
+
+  /**
+   * Sets the active list of {@link SubscriptionInfo}. This call internally triggers {@link
+   * OnSubscriptionsChangedListener#onSubscriptionsChanged()} to all the listeners.
+   */
+  public void setAvailableSubscriptionInfos(SubscriptionInfo... infos) {
+    if (infos == null) {
+      setAvailableSubscriptionInfoList(ImmutableList.of());
+    } else {
+      setAvailableSubscriptionInfoList(Arrays.asList(infos));
+    }
+  }
+
+  /**
+   * Adds a listener to a local list of listeners. Will be triggered by {@link
+   * #setActiveSubscriptionInfoList} when the local list of {@link SubscriptionInfo} is updated.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected void addOnSubscriptionsChangedListener(OnSubscriptionsChangedListener listener) {
+    listeners.add(listener);
+    listener.onSubscriptionsChanged();
+  }
+
+  /**
+   * Removes a listener from a local list of listeners. Will be triggered by {@link
+   * #setActiveSubscriptionInfoList} when the local list of {@link SubscriptionInfo} is updated.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected void removeOnSubscriptionsChangedListener(OnSubscriptionsChangedListener listener) {
+    listeners.remove(listener);
+  }
+
+  /** Returns subscription Ids that were set via {@link #setActiveSubscriptionInfoList}. */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  @HiddenApi
+  protected int[] getActiveSubscriptionIdList() {
+    final List<SubscriptionInfo> infos = getActiveSubscriptionInfoList();
+    if (infos == null) {
+      return new int[0];
+    }
+    int[] ids = new int[infos.size()];
+    for (int i = 0; i < infos.size(); i++) {
+      ids[i] = infos.get(i).getSubscriptionId();
+    }
+    return ids;
+  }
+
+  /**
+   * Notifies {@link OnSubscriptionsChangedListener} listeners that the list of {@link
+   * SubscriptionInfo} has been updated.
+   */
+  private void dispatchOnSubscriptionsChanged() {
+    for (OnSubscriptionsChangedListener listener : listeners) {
+      listener.onSubscriptionsChanged();
+    }
+  }
+
+  /** Clears the local cache of roaming subscription Ids used by {@link #isNetworkRoaming}. */
+  public void clearNetworkRoamingStatus(){
+    roamingSimSubscriptionIds.clear();
+  }
+
+  /**
+   * If isNetworkRoaming is set, it will mark the provided sim subscriptionId as roaming in a local
+   * cache. If isNetworkRoaming is unset it will remove the subscriptionId from the local cache. The
+   * local cache is used to provide roaming status returned by {@link #isNetworkRoaming}.
+   */
+  public void setNetworkRoamingStatus(int simSubscriptionId, boolean isNetworkRoaming) {
+    if (isNetworkRoaming) {
+      roamingSimSubscriptionIds.add(simSubscriptionId);
+    } else {
+      roamingSimSubscriptionIds.remove(simSubscriptionId);
+    }
+  }
+
+  /**
+   * Uses the local cache of roaming sim subscription Ids managed by {@link
+   * #setNetworkRoamingStatus} to return subscription Ids marked as roaming. Otherwise subscription
+   * Ids will be considered as non-roaming if they are not in the cache.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected boolean isNetworkRoaming(int simSubscriptionId) {
+    return roamingSimSubscriptionIds.contains(simSubscriptionId);
+  }
+
+  /** Adds a subscription ID-phone ID mapping to the map used by {@link getPhoneId}. */
+  public static void putPhoneId(int subId, int phoneId) {
+    phoneIds.put(subId, phoneId);
+  }
+
+  /**
+   * Removes a subscription ID-phone ID mapping from the map used by {@link getPhoneId}.
+   *
+   * @return the previous phone ID associated with the subscription ID, or null if there was no
+   *     mapping for the subscription ID
+   */
+  public static Integer removePhoneId(int subId) {
+    return phoneIds.remove(subId);
+  }
+
+  /**
+   * Removes all mappings between subscription IDs and phone IDs from the map used by {@link
+   * getPhoneId}.
+   */
+  public static void clearPhoneIds() {
+    phoneIds.clear();
+  }
+
+  /**
+   * Uses the map of subscription IDs to phone IDs managed by {@link putPhoneId} and {@link
+   * removePhoneId} to return the phone ID for a given subscription ID.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1, maxSdk = P)
+  @HiddenApi
+  protected static int getPhoneId(int subId) {
+    if (phoneIds.containsKey(subId)) {
+      return phoneIds.get(subId);
+    }
+    return INVALID_PHONE_INDEX;
+  }
+
+  /**
+   * When set to false methods requiring {@link android.Manifest.permission.READ_PHONE_STATE}
+   * permission will throw a {@link SecurityException}. By default it's set to true for backwards
+   * compatibility.
+   */
+  public void setReadPhoneStatePermission(boolean readPhoneStatePermission) {
+    this.readPhoneStatePermission = readPhoneStatePermission;
+  }
+
+  private void checkReadPhoneStatePermission() {
+    if (!readPhoneStatePermission) {
+      throw new SecurityException();
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    defaultDataSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+    defaultSmsSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+    defaultVoiceSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+    defaultSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+    phoneIds.clear();
+  }
+
+  /** Builder class to create instance of {@link SubscriptionInfo}. */
+  public static class SubscriptionInfoBuilder {
+    private final SubscriptionInfo subscriptionInfo =
+        ReflectionHelpers.callConstructor(SubscriptionInfo.class);
+
+    public static SubscriptionInfoBuilder newBuilder() {
+      return new SubscriptionInfoBuilder();
+    }
+
+    public SubscriptionInfo buildSubscriptionInfo() {
+      return subscriptionInfo;
+    }
+
+    public SubscriptionInfoBuilder setId(int id) {
+      ReflectionHelpers.setField(subscriptionInfo, "mId", id);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setIccId(String iccId) {
+      ReflectionHelpers.setField(subscriptionInfo, "mIccId", iccId);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setSimSlotIndex(int index) {
+      ReflectionHelpers.setField(subscriptionInfo, "mSimSlotIndex", index);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setDisplayName(String name) {
+      ReflectionHelpers.setField(subscriptionInfo, "mDisplayName", name);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setCarrierName(String carrierName) {
+      ReflectionHelpers.setField(subscriptionInfo, "mCarrierName", carrierName);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setIconTint(int iconTint) {
+      ReflectionHelpers.setField(subscriptionInfo, "mIconTint", iconTint);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setNumber(String number) {
+      ReflectionHelpers.setField(subscriptionInfo, "mNumber", number);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setDataRoaming(int dataRoaming) {
+      ReflectionHelpers.setField(subscriptionInfo, "mDataRoaming", dataRoaming);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setCountryIso(String countryIso) {
+      ReflectionHelpers.setField(subscriptionInfo, "mCountryIso", countryIso);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setProfileClass(int profileClass) {
+      ReflectionHelpers.setField(subscriptionInfo, "mProfileClass", profileClass);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setIsEmbedded(boolean isEmbedded) {
+      ReflectionHelpers.setField(subscriptionInfo, "mIsEmbedded", isEmbedded);
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setMnc(String mnc) {
+      if (VERSION.SDK_INT < Q) {
+        ReflectionHelpers.setField(subscriptionInfo, "mMnc", Integer.valueOf(mnc));
+      } else {
+        ReflectionHelpers.setField(subscriptionInfo, "mMnc", mnc);
+      }
+      return this;
+    }
+
+    public SubscriptionInfoBuilder setMcc(String mcc) {
+      if (VERSION.SDK_INT < Q) {
+        ReflectionHelpers.setField(subscriptionInfo, "mMcc", Integer.valueOf(mcc));
+      } else {
+        ReflectionHelpers.setField(subscriptionInfo, "mMcc", mcc);
+      }
+      return this;
+    }
+
+    // Use {@link #newBuilder} to construct builders.
+    private SubscriptionInfoBuilder() {}
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurface.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurface.java
new file mode 100644
index 0000000..558816e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurface.java
@@ -0,0 +1,164 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.SurfaceTexture;
+import android.view.Surface;
+import dalvik.system.CloseGuard;
+import java.lang.ref.WeakReference;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowSurfaceTexture.SurfaceTextureReflector;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.view.Surface} */
+@Implements(value = Surface.class, looseSignatures = true)
+public class ShadowSurface {
+  private static final AtomicInteger nativeObject = new AtomicInteger();
+
+  private SurfaceTexture surfaceTexture;
+  private Canvas canvas;
+  @RealObject private Surface realSurface;
+  @ReflectorObject private SurfaceReflector surfaceReflector;
+
+  private final AtomicBoolean valid = new AtomicBoolean(true);
+  private final AtomicBoolean canvasLocked = new AtomicBoolean(false);
+
+  @Implementation
+  protected void __constructor__(SurfaceTexture surfaceTexture) {
+    this.surfaceTexture = surfaceTexture;
+    Shadow.invokeConstructor(
+        Surface.class, realSurface, ClassParameter.from(SurfaceTexture.class, surfaceTexture));
+  }
+
+  public SurfaceTexture getSurfaceTexture() {
+    return surfaceTexture;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected void finalize() throws Throwable {
+    // Suppress noisy CloseGuard errors that may exist in SDK 17+.
+    CloseGuard closeGuard = surfaceReflector.getCloseGuard();
+    if (closeGuard != null) {
+      closeGuard.close();
+    }
+    surfaceReflector.finalize();
+  }
+
+  @Implementation
+  protected boolean isValid() {
+    return valid.get();
+  }
+
+  @Implementation
+  protected void release() {
+    valid.set(false);
+    surfaceReflector.release();
+  }
+
+  private void checkNotReleased() {
+    if (!valid.get()) {
+      throw new IllegalStateException("Surface has already been released.");
+    }
+  }
+
+  private void checkNotLocked() {
+    if (canvasLocked.get()) {
+      throw new IllegalStateException("Surface has already been locked.");
+    }
+  }
+
+  private void checkNotReleasedOrLocked() {
+    checkNotReleased();
+    checkNotLocked();
+  }
+
+  @Implementation
+  protected Canvas lockCanvas(Rect inOutDirty) {
+    checkNotReleasedOrLocked();
+    canvasLocked.set(true);
+    if (canvas == null) {
+      canvas = new Canvas();
+    }
+    return canvas;
+  }
+
+  @Implementation(minSdk = M)
+  protected Canvas lockHardwareCanvas() {
+    checkNotReleasedOrLocked();
+    canvasLocked.set(true);
+    canvas = surfaceReflector.lockHardwareCanvas();
+    return canvas;
+  }
+
+  @Implementation
+  protected void unlockCanvasAndPost(Canvas canvas) {
+    checkNotReleased();
+    if (!canvasLocked.get()) {
+      throw new IllegalStateException("Canvas is not locked!");
+    }
+    if (surfaceTexture != null) {
+      if (RuntimeEnvironment.getApiLevel() > KITKAT) {
+        reflector(SurfaceTextureReflector.class, surfaceTexture)
+            .postEventFromNative(new WeakReference<>(surfaceTexture));
+      } else {
+        reflector(SurfaceTextureReflector.class, surfaceTexture)
+            .postEventFromNative((Object) new WeakReference<>(surfaceTexture));
+      }
+    }
+    canvasLocked.set(false);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static Object nativeCreateFromSurfaceTexture(Object surfaceTexture) {
+    return nativeObject.incrementAndGet();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static Object nativeCreateFromSurfaceControl(Object surfaceControlNativeObject) {
+    return nativeObject.incrementAndGet();
+  }
+
+  @Implementation(minSdk = Q)
+  protected static long nativeGetFromSurfaceControl(
+      long surfaceObject, long surfaceControlNativeObject) {
+    return nativeObject.incrementAndGet();
+  }
+
+  @Resetter
+  public static void reset() {
+    nativeObject.set(0);
+  }
+
+  @ForType(Surface.class)
+  interface SurfaceReflector {
+    @Accessor("mCloseGuard")
+    CloseGuard getCloseGuard();
+
+    @Direct
+    void finalize();
+
+    @Direct
+    void release();
+
+    @Direct
+    Canvas lockHardwareCanvas();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java
new file mode 100644
index 0000000..bc85287
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java
@@ -0,0 +1,98 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.os.Parcel;
+import android.view.SurfaceControl;
+import android.view.SurfaceSession;
+import dalvik.system.CloseGuard;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link android.view.SurfaceControl} */
+@Implements(value = SurfaceControl.class, isInAndroidSdk = false, minSdk = JELLY_BEAN_MR2)
+public class ShadowSurfaceControl {
+  private static final AtomicInteger nativeObject = new AtomicInteger();
+
+  @ReflectorObject private SurfaceControlReflector surfaceControlReflector;
+
+  @Resetter
+  public static void reset() {
+    nativeObject.set(0);
+  }
+
+  @Implementation
+  protected void finalize() throws Throwable {
+    // Suppress noisy CloseGuard errors.
+    CloseGuard closeGuard = surfaceControlReflector.getCloseGuard();
+    if (closeGuard != null) {
+      closeGuard.close();
+    }
+    surfaceControlReflector.finalize();
+  }
+
+  @Implementation(maxSdk = N_MR1)
+  protected static Number nativeCreate(
+      SurfaceSession session, String name, int w, int h, int format, int flags) {
+    // Return a non-zero value otherwise constructing a SurfaceControl fails with
+    // OutOfResourcesException.
+    return nativeObject.incrementAndGet();
+  }
+
+  @Implementation(minSdk = O, maxSdk = P)
+  protected static long nativeCreate(
+      SurfaceSession session,
+      String name,
+      int w,
+      int h,
+      int format,
+      int flags,
+      long parentObject,
+      int windowType,
+      int ownerUid) {
+    // Return a non-zero value otherwise constructing a SurfaceControl fails with
+    // OutOfResourcesException.
+    return nativeObject.incrementAndGet();
+  }
+
+  @Implementation(minSdk = Q)
+  protected static long nativeCreate(
+      SurfaceSession session,
+      String name,
+      int w,
+      int h,
+      int format,
+      int flags,
+      long parentObject,
+      Parcel metadata) {
+    // Return a non-zero value otherwise constructing a SurfaceControl fails with
+    // OutOfResourcesException.
+    return nativeObject.incrementAndGet();
+  }
+
+  void initializeNativeObject() {
+    surfaceControlReflector.setNativeObject(nativeObject.incrementAndGet());
+  }
+
+  @ForType(SurfaceControl.class)
+  interface SurfaceControlReflector {
+    @Accessor("mCloseGuard")
+    CloseGuard getCloseGuard();
+
+    @Accessor("mNativeObject")
+    void setNativeObject(long nativeObject);
+
+    @Direct
+    void finalize();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceTexture.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceTexture.java
new file mode 100644
index 0000000..79e3590
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceTexture.java
@@ -0,0 +1,21 @@
+package org.robolectric.shadows;
+
+import android.graphics.SurfaceTexture;
+import java.lang.ref.WeakReference;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Shadow for {@link android.graphics.SurfaceTexture} */
+@Implements(SurfaceTexture.class)
+public class ShadowSurfaceTexture {
+
+  @ForType(SurfaceTexture.class)
+  interface SurfaceTextureReflector {
+    @Static
+    void postEventFromNative(WeakReference<SurfaceTexture> weakSelf);
+
+    @Static
+    void postEventFromNative(Object weakSelf);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceView.java
new file mode 100644
index 0000000..7a258da
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceView.java
@@ -0,0 +1,99 @@
+package org.robolectric.shadows;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(SurfaceView.class)
+@SuppressWarnings({"UnusedDeclaration"})
+public class ShadowSurfaceView extends ShadowView {
+  private final FakeSurfaceHolder fakeSurfaceHolder = new FakeSurfaceHolder();
+
+  @Implementation
+  protected void onAttachedToWindow() {}
+
+  @Implementation
+  protected SurfaceHolder getHolder() {
+    return fakeSurfaceHolder;
+  }
+
+  public FakeSurfaceHolder getFakeSurfaceHolder() {
+    return fakeSurfaceHolder;
+  }
+
+  /**
+   * Robolectric implementation of {@link android.view.SurfaceHolder}.
+   */
+  public static class FakeSurfaceHolder implements SurfaceHolder {
+    private final Set<Callback> callbacks = new HashSet<>();
+
+    @Override
+    public void addCallback(Callback callback) {
+      callbacks.add(callback);
+    }
+
+    public Set<Callback> getCallbacks() {
+      return callbacks;
+    }
+
+    @Override
+    public void removeCallback(Callback callback) {
+      callbacks.remove(callback);
+    }
+
+    @Override
+    public boolean isCreating() {
+      return false;
+    }
+
+    @Override
+    public void setType(int i) {
+    }
+
+    @Override
+    public void setFixedSize(int i, int i1) {
+    }
+
+    @Override
+    public void setSizeFromLayout() {
+    }
+
+    @Override
+    public void setFormat(int i) {
+    }
+
+    @Override
+    public void setKeepScreenOn(boolean b) {
+    }
+
+    @Override
+    public Canvas lockCanvas() {
+      return null;
+    }
+
+    @Override
+    public Canvas lockCanvas(Rect rect) {
+      return null;
+    }
+
+    @Override
+    public void unlockCanvasAndPost(Canvas canvas) {
+    }
+
+    @Override
+    public Rect getSurfaceFrame() {
+      return null;
+    }
+
+    @Override
+    public Surface getSurface() {
+      return null;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSuspendDialogInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSuspendDialogInfo.java
new file mode 100644
index 0000000..0a7baee
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSuspendDialogInfo.java
@@ -0,0 +1,99 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.DrawableRes;
+import android.annotation.StringRes;
+import android.content.pm.SuspendDialogInfo;
+import android.os.Build;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow of {@link SuspendDialogInfo} to expose hidden methods. */
+@Implements(value = SuspendDialogInfo.class, isInAndroidSdk = false, minSdk = Build.VERSION_CODES.Q)
+public class ShadowSuspendDialogInfo {
+
+  @RealObject protected SuspendDialogInfo realInfo;
+
+  /** Returns the resource id of the icon to be used with the dialog. */
+  @Implementation
+  @HiddenApi
+  @DrawableRes
+  public int getIconResId() {
+    return reflector(SuspendDialogInfoReflector.class, realInfo).getIconResId();
+  }
+
+  /** Returns the resource id of the title to be used with the dialog. */
+  @Implementation
+  @HiddenApi
+  @StringRes
+  public int getTitleResId() {
+    return reflector(SuspendDialogInfoReflector.class, realInfo).getTitleResId();
+  }
+
+  /** Returns the resource id of the text to be shown in the dialog's body. */
+  @Implementation
+  @HiddenApi
+  @StringRes
+  public int getDialogMessageResId() {
+    return reflector(SuspendDialogInfoReflector.class, realInfo).getDialogMessageResId();
+  }
+
+  /**
+   * Returns the text to be shown in the dialog's body, or {@code null} if {@link
+   * #getDialogMessageResId()} returns a valid resource id.
+   */
+  @Implementation
+  @HiddenApi
+  @Nullable
+  public String getDialogMessage() {
+    return reflector(SuspendDialogInfoReflector.class, realInfo).getDialogMessage();
+  }
+
+  /** Returns the text to be shown. */
+  @Implementation
+  @HiddenApi
+  @StringRes
+  public int getNeutralButtonTextResId() {
+    return reflector(SuspendDialogInfoReflector.class, realInfo).getNeutralButtonTextResId();
+  }
+
+  /**
+   * Returns the action expected to happen on neutral button tap.
+   *
+   * @return {@link SuspendDialogInfo.BUTTON_ACTION_MORE_DETAILS} or {@link
+   *     SuspendDialogInfo.BUTTON_ACTION_UNSUSPEND}
+   */
+  @Implementation(minSdk = R)
+  public int getNeutralButtonAction() {
+    return reflector(SuspendDialogInfoReflector.class, realInfo).getNeutralButtonAction();
+  }
+
+  @ForType(SuspendDialogInfo.class)
+  interface SuspendDialogInfoReflector {
+
+    @Direct
+    int getIconResId();
+
+    @Direct
+    int getTitleResId();
+
+    @Direct
+    int getDialogMessageResId();
+
+    @Direct
+    String getDialogMessage();
+
+    @Direct
+    int getNeutralButtonTextResId();
+
+    @Direct
+    int getNeutralButtonAction();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java
new file mode 100644
index 0000000..a2bb38a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows;
+
+import android.os.SystemClock;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.LooperMode;
+
+public class ShadowSystem {
+
+  /**
+   * Implements {@link System#nanoTime} through ShadowWrangler.
+   *
+   * @return Current time with nanos.
+   */
+  @SuppressWarnings("unused")
+  public static long nanoTime() {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      return TimeUnit.MILLISECONDS.toNanos(SystemClock.uptimeMillis());
+    } else {
+      return ShadowLegacySystemClock.nanoTime();
+    }
+  }
+
+  /**
+   * Implements {@link System#currentTimeMillis} through ShadowWrangler.
+   *
+   * @return Current time with millis.
+   */
+  @SuppressWarnings("unused")
+  public static long currentTimeMillis() {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      return SystemClock.uptimeMillis();
+    } else {
+      return ShadowLegacySystemClock.currentTimeMillis();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemClock.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemClock.java
new file mode 100644
index 0000000..03d756c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemClock.java
@@ -0,0 +1,116 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static java.time.ZoneOffset.UTC;
+import static org.robolectric.shadows.ShadowLooper.assertLooperMode;
+
+import android.os.SimpleClock;
+import android.os.SystemClock;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.LooperMode.Mode;
+
+/**
+ * The shadow API for {@link SystemClock}.
+ *
+ * The behavior of SystemClock in Robolectric will differ based on the current {@link
+ * LooperMode}. See {@link ShadowLegacySystemClock} and {@link ShadowPausedSystemClock} for more
+ * details.
+ */
+@Implements(value = SystemClock.class, shadowPicker = ShadowSystemClock.Picker.class,
+    looseSignatures = true)
+public abstract class ShadowSystemClock {
+  protected static boolean networkTimeAvailable = true;
+  private static boolean gnssTimeAvailable = true;
+
+  /**
+   * Implements {@link System#currentTimeMillis} through ShadowWrangler.
+   *
+   * @return Current time in millis.
+   */
+  @SuppressWarnings("unused")
+  public static long currentTimeMillis() {
+    return ShadowLegacySystemClock.currentTimeMillis();
+  }
+
+  /**
+   * Implements {@link System#nanoTime}.
+   *
+   * @return Current time with nanos.
+   * @deprecated Don't call this method directly; instead, use {@link System#nanoTime()}.
+   */
+  @SuppressWarnings("unused")
+  @Deprecated
+  public static long nanoTime() {
+    return ShadowSystem.nanoTime();
+  }
+
+  /**
+   * Sets the value for {@link System#nanoTime()}.
+   *
+   * <p>May only be used for {@link LooperMode.Mode#LEGACY}. For {@link LooperMode.Mode#PAUSED},
+   * {@param nanoTime} is calculated based on {@link SystemClock#uptimeMillis()} and can't be set
+   * explicitly.
+   */
+  public static void setNanoTime(long nanoTime) {
+    assertLooperMode(Mode.LEGACY);
+    ShadowLegacySystemClock.setNanoTime(nanoTime);
+  }
+
+  /** Sets whether network time is available. */
+  public static void setNetworkTimeAvailable(boolean available) {
+    networkTimeAvailable = available;
+  }
+
+  /**
+   * An alternate to {@link #advanceBy(Duration)} for older Android code bases where Duration is not
+   * available.
+   */
+  public static void advanceBy(long time, TimeUnit unit) {
+    SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + unit.toMillis(time));
+  }
+
+  /**
+   * A convenience method for advancing the clock via {@link SystemClock#setCurrentTimeMillis(long)}
+   *
+   * @param duration The interval by which to advance.
+   */
+  public static void advanceBy(Duration duration) {
+    SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + duration.toMillis());
+  }
+
+  @Implementation(minSdk = Q)
+  protected static Object currentGnssTimeClock() {
+    if (gnssTimeAvailable) {
+      return new SimpleClock(UTC) {
+        @Override
+        public long millis() {
+          return SystemClock.uptimeMillis();
+        }
+      };
+    } else {
+      throw new DateTimeException("Gnss based time is not available.");
+    }
+  }
+
+  /** Sets whether gnss location based time is available. */
+  public static void setGnssTimeAvailable(boolean available) {
+    gnssTimeAvailable = available;
+  }
+
+  public static void reset() {
+    networkTimeAvailable = true;
+    gnssTimeAvailable = true;
+  }
+
+  public static class Picker extends LooperShadowPicker<ShadowSystemClock> {
+
+    public Picker() {
+      super(ShadowLegacySystemClock.class, ShadowPausedSystemClock.class);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemFonts.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemFonts.java
new file mode 100644
index 0000000..ab2227e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemFonts.java
@@ -0,0 +1,70 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.graphics.fonts.Font;
+import android.graphics.fonts.FontCustomizationParser.Result;
+import android.graphics.fonts.FontFamily;
+import android.graphics.fonts.SystemFonts;
+import android.os.Build;
+import android.text.FontConfig;
+import android.text.FontConfig.Alias;
+import android.util.ArrayMap;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(
+    className = "android.graphics.fonts.SystemFonts",
+    minSdk = Build.VERSION_CODES.Q,
+    isInAndroidSdk = false)
+public class ShadowSystemFonts {
+
+  @Implementation(maxSdk = R)
+  protected static FontConfig.Alias[] buildSystemFallback(
+      String xmlPath,
+      String fontDir,
+      Result oemCustomization,
+      ArrayMap<String, android.graphics.fonts.FontFamily[]> fallbackMap,
+      ArrayList<Font> availableFonts) {
+    return new Alias[] {new FontConfig.Alias("sans-serif", "sans-serif", 0)};
+  }
+
+  @Implementation(maxSdk = R)
+  protected static FontFamily[] getSystemFallback(String familyName) {
+    FontFamily[] result = reflector(SystemFontsReflector.class).getSystemFallback(familyName);
+    if (result == null) {
+      result = new FontFamily[0];
+    }
+    return result;
+  }
+
+  /** Overrides to prevent the Log.e Failed to open/read system font configurations */
+  @Implementation(minSdk = S)
+  protected static FontConfig getSystemFontConfigInternal(
+      String fontsXml,
+      String systemFontDir,
+      String oemXml,
+      String productFontDir,
+      Map<String, File> updatableFontMap,
+      long lastModifiedDate,
+      int configVersion) {
+    return new FontConfig(Collections.emptyList(), Collections.emptyList(), 0, 0);
+  }
+
+  @ForType(SystemFonts.class)
+  interface SystemFontsReflector {
+
+    @Static
+    @Direct
+    FontFamily[] getSystemFallback(String familyName);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemProperties.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemProperties.java
new file mode 100644
index 0000000..47577f5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemProperties.java
@@ -0,0 +1,135 @@
+package org.robolectric.shadows;
+
+import android.os.SystemProperties;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Properties;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = SystemProperties.class, isInAndroidSdk = false)
+public class ShadowSystemProperties {
+  private static Properties buildProperties = null;
+
+  @Implementation
+  protected static String native_get(String key) {
+    return native_get(key, "");
+  }
+
+  @Implementation
+  protected static String native_get(String key, String def) {
+    String value = getProperty(key);
+    return value == null ? def : value;
+  }
+
+  @Implementation
+  protected static int native_get_int(String key, int def) {
+    String stringValue = getProperty(key);
+    return stringValue == null ? def : Integer.parseInt(stringValue);
+  }
+
+  @Implementation
+  protected static long native_get_long(String key, long def) {
+    String stringValue = getProperty(key);
+    return stringValue == null ? def : Long.parseLong(stringValue);
+  }
+
+  @Implementation
+  protected static boolean native_get_boolean(String key, boolean def) {
+    String stringValue = getProperty(key);
+    if ("1".equals(stringValue)
+        || "y".equals(stringValue)
+        || "yes".equals(stringValue)
+        || "on".equals(stringValue)
+        || "true".equals(stringValue)) {
+      return true;
+    }
+    if ("0".equals(stringValue)
+        || "n".equals(stringValue)
+        || "no".equals(stringValue)
+        || "off".equals(stringValue)
+        || "false".equals(stringValue)) {
+      return false;
+    }
+    return def;
+  }
+
+  @Implementation
+  protected static void native_set(String key, String val) {
+    if (val == null) {
+      loadProperties().remove(key);
+    } else {
+      loadProperties().setProperty(key, val);
+    }
+  }
+
+  /**
+   * Overrides the system property for testing. Similar to the Android implementation, the value may
+   * be coerced to other types like boolean or long depending on the get method that is used.
+   *
+   * <p>Note: Use {@link org.robolectric.shadows.ShadowBuild} instead for changing fields in {@link
+   * android.os.Build}.
+   */
+  public static void override(String key, String val) {
+    SystemProperties.set(key, val);
+  }
+
+  // ignored/unimplemented methods
+  // private static native void native_add_change_callback();
+  // private static native void native_report_sysprop_change();
+
+  private static synchronized String getProperty(String key) {
+    return loadProperties().getProperty(key);
+  }
+
+  private static synchronized Properties loadProperties() {
+    if (buildProperties == null) {
+      // load the prop from classpath
+      ClassLoader cl = SystemProperties.class.getClassLoader();
+      try (InputStream is = cl.getResourceAsStream("build.prop")) {
+        Preconditions.checkNotNull(is, "could not find build.prop");
+        buildProperties = new Properties();
+        buildProperties.load(is);
+        setDefaults(buildProperties);
+      } catch (IOException e) {
+        throw new RuntimeException("failed to load build.prop", e);
+      }
+    }
+    return buildProperties;
+  }
+
+  private static void setDefaults(Properties buildProperties) {
+    // The default generated build.prop can make this look like the emulator.
+    // Override common default properties to indicate platform is robolectric
+    // TODO: put these values directly in build.prop generated from build system
+    buildProperties.setProperty("ro.build.fingerprint", "robolectric");
+    buildProperties.setProperty("ro.product.device", "robolectric");
+    buildProperties.setProperty("ro.product.name", "robolectric");
+    buildProperties.setProperty("ro.product.model", "robolectric");
+    buildProperties.setProperty("ro.hardware", "robolectric");
+    buildProperties.setProperty("ro.build.characteristics", "robolectric");
+
+    // for backwards-compatiblity reasons, set CPUS to unknown/ARM
+    buildProperties.setProperty("ro.product.cpu.abi", "unknown");
+    buildProperties.setProperty("ro.product.cpu.abi2", "unknown");
+    buildProperties.setProperty("ro.product.cpu.abilist", "armeabi-v7a");
+    buildProperties.setProperty("ro.product.cpu.abilist32", "armeabi-v7a,armeabi");
+    buildProperties.setProperty("ro.product.cpu.abilist64", "armeabi-v7a,armeabi");
+
+    // Update SQLite sync mode and journal mode defaults for faster SQLite operations due to less
+    // file I/O.
+    buildProperties.setProperty("debug.sqlite.syncmode", "OFF");
+    buildProperties.setProperty("debug.sqlite.wal.syncmode", "OFF");
+    buildProperties.setProperty("debug.sqlite.journalmode", "MEMORY");
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    ReflectionHelpers.setStaticField(SystemProperties.class, "sChangeCallbacks", new ArrayList<>());
+    buildProperties = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemServiceRegistry.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemServiceRegistry.java
new file mode 100644
index 0000000..f8e7bc6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemServiceRegistry.java
@@ -0,0 +1,137 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.os.Build;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(
+  className = "android.app.SystemServiceRegistry",
+  isInAndroidSdk = false,
+  looseSignatures = true,
+  minSdk = Build.VERSION_CODES.M
+)
+public class ShadowSystemServiceRegistry {
+
+  private static final String STATIC_SERVICE_FETCHER_CLASS_NAME =
+      "android.app.SystemServiceRegistry$StaticServiceFetcher";
+  private static final String STATIC_CONTEXT_SERVICE_FETCHER_CLASS_NAME_M =
+      "android.app.SystemServiceRegistry$StaticOuterContextServiceFetcher";
+  private static final String STATIC_CONTEXT_SERVICE_FETCHER_CLASS_NAME_N =
+      "android.app.SystemServiceRegistry$StaticApplicationContextServiceFetcher";
+  private static final String CACHED_SERVICE_FETCHER_CLASS_NAME =
+      "android.app.SystemServiceRegistry$CachedServiceFetcher";
+
+  @Resetter
+  public static void reset() {
+    Map<String, Object> fetchers =
+        reflector(_SystemServiceRegistry_.class).getSystemServiceFetchers();
+
+    for (Map.Entry<String, Object> oFetcher : fetchers.entrySet()) {
+      _ServiceFetcher_.get(oFetcher.getKey(), oFetcher.getValue()).clearInstance();
+    }
+  }
+
+  /** Accessor interface for {@link android.app.SystemServiceRegistry}'s internals. */
+  @ForType(className = "android.app.SystemServiceRegistry")
+  interface _SystemServiceRegistry_ {
+    @Accessor("SYSTEM_SERVICE_FETCHERS")
+    Map<String, Object> getSystemServiceFetchers();
+  }
+
+  /** Accessor interface the various {@link android.app.SystemServiceRegistry.ServiceFetcher}s. */
+  interface _ServiceFetcher_ {
+
+    void setCachedInstance(Object o);
+
+    static _ServiceFetcher_ get(String key, Object serviceFetcher) {
+      String serviceFetcherClassName = getConcreteClassName(serviceFetcher);
+      if (serviceFetcherClassName == null) {
+        throw new IllegalStateException("no idea what to do with " + key + " " + serviceFetcher);
+      }
+
+      switch (serviceFetcherClassName) {
+        case STATIC_SERVICE_FETCHER_CLASS_NAME:
+          return reflector(_StaticServiceFetcher_.class, serviceFetcher);
+        case STATIC_CONTEXT_SERVICE_FETCHER_CLASS_NAME_M:
+          return reflector(_ServiceFetcherM_.class, serviceFetcher);
+        case STATIC_CONTEXT_SERVICE_FETCHER_CLASS_NAME_N:
+          return reflector(_ServiceFetcherN_.class, serviceFetcher);
+        case CACHED_SERVICE_FETCHER_CLASS_NAME:
+          return o -> {}; // these are accessors via the ContextImpl instance, so no reset needed
+        default:
+          if (key.equals(Context.INPUT_METHOD_SERVICE)) {
+            return o -> {}; // handled by ShadowInputMethodManager.reset()
+          }
+          throw new IllegalStateException("no idea what to do with " + key + " " + serviceFetcher);
+      }
+    }
+
+    static String getConcreteClassName(Object serviceFetcher) {
+      Class<?> serviceFetcherClass = serviceFetcher.getClass();
+      while (serviceFetcherClass != null && serviceFetcherClass.getCanonicalName() == null){
+        serviceFetcherClass = serviceFetcherClass.getSuperclass();
+      }
+      return serviceFetcherClass == null
+          ? null
+          : serviceFetcherClass.getName();
+    }
+
+    default void clearInstance() {
+      setCachedInstance(null);
+    }
+  }
+
+  /**
+   * Accessor interface for {@link android.app.SystemServiceRegistry.StaticServiceFetcher}'s
+   * internals.
+   */
+  @ForType(className = STATIC_SERVICE_FETCHER_CLASS_NAME)
+  public interface _StaticServiceFetcher_ extends _ServiceFetcher_ {
+    @Accessor("mCachedInstance")
+    void setCachedInstance(Object o);
+  }
+
+  /**
+   * Accessor interface for {@code
+   * android.app.SystemServiceRegistry.StaticOuterContextServiceFetcher}'s internals (for M).
+   */
+  @ForType(className = STATIC_CONTEXT_SERVICE_FETCHER_CLASS_NAME_M)
+  public interface _ServiceFetcherM_ extends _ServiceFetcher_ {
+    @Accessor("mCachedInstance")
+    void setCachedInstance(Object o);
+  }
+
+  /**
+   * Accessor interface for
+   * {@link android.app.SystemServiceRegistry.StaticApplicationContextServiceFetcher}'s
+   * internals (for N+).
+   */
+  @ForType(className = STATIC_CONTEXT_SERVICE_FETCHER_CLASS_NAME_N)
+  public interface _ServiceFetcherN_ extends _ServiceFetcher_ {
+    @Accessor("mCachedInstance")
+    void setCachedInstance(Object o);
+  }
+
+  @Implementation(minSdk = O)
+  protected static void onServiceNotFound(/* ServiceNotFoundException */ Object e0) {
+    // otherwise the full stacktrace might be swallowed...
+    Exception e = (Exception) e0;
+    e.printStackTrace();
+  }
+
+  private static Class classForName(String className) {
+    try {
+      return Class.forName(className);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemVibrator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemVibrator.java
new file mode 100644
index 0000000..840c626
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemVibrator.java
@@ -0,0 +1,199 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+
+import android.media.AudioAttributes;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemVibrator;
+import android.os.VibrationAttributes;
+import android.os.VibrationEffect;
+import android.os.vibrator.VibrationEffectSegment;
+import java.util.List;
+import java.util.Optional;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = SystemVibrator.class, isInAndroidSdk = false)
+public class ShadowSystemVibrator extends ShadowVibrator {
+
+  private final Handler handler = new Handler(Looper.getMainLooper());
+  private final Runnable stopVibratingRunnable = () -> vibrating = false;
+
+  @Implementation
+  protected boolean hasVibrator() {
+    return hasVibrator;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean hasAmplitudeControl() {
+    return hasAmplitudeControl;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected void vibrate(long[] pattern, int repeat) {
+    recordVibratePattern(pattern, repeat);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT_WATCH)
+  protected void vibrate(int owningUid, String owningPackage, long[] pattern, int repeat) {
+    recordVibratePattern(pattern, repeat);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected void vibrate(
+      int uid, String opPkg, long[] pattern, int repeat, AudioAttributes attributes) {
+    recordVibratePattern(pattern, repeat);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  public void vibrate(long milliseconds) {
+    recordVibrate(milliseconds);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2, maxSdk = KITKAT_WATCH)
+  public void vibrate(int owningUid, String owningPackage, long milliseconds) {
+    recordVibrate(milliseconds);
+  }
+
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected void vibrate(int uid, String opPkg, long milliseconds, AudioAttributes attributes) {
+    recordVibrate(milliseconds);
+  }
+
+  @Implementation(minSdk = O, maxSdk = P)
+  protected void vibrate(
+      int uid, String opPkg, VibrationEffect effect, AudioAttributes attributes) {
+    vibrate(uid, opPkg, effect, null, attributes);
+  }
+
+  @Implementation(minSdk = Q, maxSdk = R)
+  protected void vibrate(
+      int uid, String opPkg, VibrationEffect effect, String reason, AudioAttributes attributes) {
+    try {
+      Class<?> waveformClass = Class.forName("android.os.VibrationEffect$Waveform");
+      Class<?> prebakedClass = Class.forName("android.os.VibrationEffect$Prebaked");
+      Class<?> oneShotClass = Class.forName("android.os.VibrationEffect$OneShot");
+      Optional<Class<?>> composedClass = Optional.empty();
+      if (RuntimeEnvironment.getApiLevel() == R) {
+        composedClass = Optional.of(Class.forName("android.os.VibrationEffect$Composed"));
+      }
+
+      if (waveformClass.isInstance(effect)) {
+        recordVibratePattern(
+            (long[]) ReflectionHelpers.callInstanceMethod(effect, "getTimings"),
+            ReflectionHelpers.callInstanceMethod(effect, "getRepeatIndex"));
+      } else if (prebakedClass.isInstance(effect)) {
+        recordVibratePredefined(
+            ReflectionHelpers.callInstanceMethod(effect, "getDuration"),
+            ReflectionHelpers.callInstanceMethod(effect, "getId"));
+      } else if (oneShotClass.isInstance(effect)) {
+        long timing;
+
+        if (RuntimeEnvironment.getApiLevel() >= P) {
+          timing = ReflectionHelpers.callInstanceMethod(effect, "getDuration");
+        } else {
+          timing = ReflectionHelpers.callInstanceMethod(effect, "getTiming");
+        }
+
+        recordVibrate(timing);
+      } else if (composedClass.isPresent() && composedClass.get().isInstance(effect)) {
+        VibrationEffect.Composed composed = (VibrationEffect.Composed) effect;
+        List<Object> effects =
+            ReflectionHelpers.callInstanceMethod(composed, "getPrimitiveEffects");
+        primitiveEffects.clear();
+        for (Object primitiveEffect : effects) {
+          primitiveEffects.add(
+              new PrimitiveEffect(
+                  /* id= */ ReflectionHelpers.getField(primitiveEffect, "id"),
+                  /* scale= */ ReflectionHelpers.getField(primitiveEffect, "scale"),
+                  /* delay= */ ReflectionHelpers.getField(primitiveEffect, "delay")));
+        }
+      } else {
+        throw new UnsupportedOperationException(
+            "unrecognized effect type " + effect.getClass().getName());
+      }
+      audioAttributesFromLastVibration = attributes;
+    } catch (ClassNotFoundException e) {
+      throw new UnsupportedOperationException(
+          "unrecognized effect type " + effect.getClass().getName(), e);
+    }
+  }
+
+  @Implementation(minSdk = S)
+  protected void vibrate(
+      int uid,
+      String opPkg,
+      VibrationEffect effect,
+      String reason,
+      VibrationAttributes attributes) {
+    if (effect instanceof VibrationEffect.Composed) {
+      VibrationEffect.Composed composedEffect = (VibrationEffect.Composed) effect;
+      vibrationAttributesFromLastVibration = attributes;
+      recordVibratePattern(composedEffect.getSegments(), composedEffect.getRepeatIndex());
+    } else {
+      throw new UnsupportedOperationException(
+          "unrecognized effect type " + effect.getClass().getName());
+    }
+  }
+
+  private void recordVibratePattern(List<VibrationEffectSegment> segments, int repeatIndex) {
+    long[] pattern = new long[segments.size()];
+    int i = 0;
+    for (VibrationEffectSegment segment : segments) {
+      pattern[i] = segment.getDuration();
+      i++;
+    }
+    vibrationEffectSegments.clear();
+    vibrationEffectSegments.addAll(segments);
+    recordVibratePattern(pattern, repeatIndex);
+  }
+
+  private void recordVibratePredefined(long milliseconds, int effectId) {
+    vibrating = true;
+    this.effectId = effectId;
+    this.milliseconds = milliseconds;
+    handler.removeCallbacks(stopVibratingRunnable);
+    handler.postDelayed(stopVibratingRunnable, this.milliseconds);
+  }
+
+  private void recordVibrate(long milliseconds) {
+    vibrating = true;
+    this.milliseconds = milliseconds;
+    handler.removeCallbacks(stopVibratingRunnable);
+    handler.postDelayed(stopVibratingRunnable, this.milliseconds);
+  }
+
+  protected void recordVibratePattern(long[] pattern, int repeat) {
+    vibrating = true;
+    this.pattern = pattern;
+    this.repeat = repeat;
+    handler.removeCallbacks(stopVibratingRunnable);
+    if (repeat < 0) {
+      long endDelayMillis = 0;
+      for (long t : pattern) {
+        endDelayMillis += t;
+      }
+      this.milliseconds = endDelayMillis;
+      handler.postDelayed(stopVibratingRunnable, endDelayMillis);
+    }
+  }
+
+  @Implementation
+  protected void cancel() {
+    cancelled = true;
+    vibrating = false;
+    handler.removeCallbacks(stopVibratingRunnable);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabActivity.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabActivity.java
new file mode 100644
index 0000000..d6d0e84
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabActivity.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import android.app.TabActivity;
+import android.widget.TabHost;
+import android.widget.TabWidget;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(TabActivity.class)
+public class ShadowTabActivity extends ShadowActivityGroup {
+  @RealObject private TabActivity realTabActivity;
+  private TabHost tabhost;
+
+  @Implementation
+  protected TabHost getTabHost() {
+    if (tabhost==null) {
+      tabhost = new TabHost(realTabActivity);
+    }
+    return tabhost;
+  }
+
+  @Implementation
+  protected TabWidget getTabWidget() {
+    return getTabHost().getTabWidget();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabHost.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabHost.java
new file mode 100644
index 0000000..bc82481
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabHost.java
@@ -0,0 +1,230 @@
+package org.robolectric.shadows;
+
+import android.R;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.widget.TabHost;
+import android.widget.TabHost.TabSpec;
+import android.widget.TabWidget;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(TabHost.class)
+public class ShadowTabHost extends ShadowViewGroup {
+  private List<TabHost.TabSpec> tabSpecs = new ArrayList<>();
+  private TabHost.OnTabChangeListener listener;
+  private int currentTab = -1;
+
+  @RealObject
+  private TabHost realObject;
+
+  @Implementation
+  protected android.widget.TabHost.TabSpec newTabSpec(java.lang.String tag) {
+    TabSpec realTabSpec = Shadow.newInstanceOf(TabHost.TabSpec.class);
+    ShadowTabSpec shadowTabSpec = Shadow.extract(realTabSpec);
+    shadowTabSpec.setTag(tag);
+    return realTabSpec;
+  }
+
+  @Implementation
+  protected void addTab(android.widget.TabHost.TabSpec tabSpec) {
+    tabSpecs.add(tabSpec);
+    ShadowTabSpec shadowTabSpec = Shadow.extract(tabSpec);
+    View indicatorAsView = shadowTabSpec.getIndicatorAsView();
+    if (indicatorAsView != null) {
+      realObject.addView(indicatorAsView);
+    }
+  }
+
+  @Implementation
+  protected void setCurrentTab(int index) {
+    currentTab = index;
+    if (listener != null) {
+      listener.onTabChanged(getCurrentTabTag());
+    }
+  }
+
+  @Implementation
+  protected void setCurrentTabByTag(String tag) {
+    for (int x = 0; x < tabSpecs.size(); x++) {
+      TabSpec tabSpec = tabSpecs.get(x);
+      if (tabSpec.getTag().equals(tag)) {
+        currentTab = x;
+      }
+    }
+    if (listener != null) {
+      listener.onTabChanged(getCurrentTabTag());
+    }
+  }
+
+  @Implementation
+  protected int getCurrentTab() {
+    if (currentTab == -1 && tabSpecs.size() > 0) currentTab = 0;
+    return currentTab;
+  }
+
+  public TabSpec getCurrentTabSpec() {
+    return tabSpecs.get(getCurrentTab());
+  }
+
+  @Implementation
+  protected String getCurrentTabTag() {
+    int i = getCurrentTab();
+    if (i >= 0 && i < tabSpecs.size()) {
+      return tabSpecs.get(i).getTag();
+    }
+    return null;
+  }
+
+  @Implementation
+  protected void setOnTabChangedListener(android.widget.TabHost.OnTabChangeListener listener) {
+    this.listener = listener;
+  }
+
+  @Implementation
+  protected View getCurrentView() {
+    ShadowTabSpec ts = Shadow.extract(getCurrentTabSpec());
+    View v = ts.getContentView();
+    if (v == null) {
+      int viewId = ts.getContentViewId();
+      if (realView.getContext() instanceof Activity) {
+        v = ((Activity) realView.getContext()).findViewById(viewId);
+      } else {
+        return null;
+      }
+    }
+    return v;
+  }
+
+  @Implementation
+  protected TabWidget getTabWidget() {
+    Context context = realView.getContext();
+    if (context instanceof Activity) {
+      return (TabWidget) ((Activity)context).findViewById(R.id.tabs);
+    } else {
+      return null;
+    }
+  }
+
+  public TabHost.TabSpec getSpecByTag(String tag) {
+    for (TabHost.TabSpec tabSpec : tabSpecs) {
+      if (tag.equals(tabSpec.getTag())) {
+        return tabSpec;
+      }
+    }
+    return null;
+  }
+
+  @SuppressWarnings({"UnusedDeclaration"})
+  @Implements(TabSpec.class)
+  public static class ShadowTabSpec {
+
+    @RealObject
+    TabSpec realObject;
+    private String tag;
+    private View indicatorView;
+    private Intent intent;
+    private int viewId;
+    private View contentView;
+    private CharSequence label;
+    private Drawable icon;
+
+    /**
+     * Sets the tag on the TabSpec.
+     *
+     * @param tag The tag.
+     */
+    public void setTag(String tag) {
+      this.tag = tag;
+    }
+
+    @Implementation
+    protected String getTag() {
+      return tag;
+    }
+
+    /**
+     * @return the view object set in a call to {@code TabSpec#setIndicator(View)}
+     */
+    public View getIndicatorAsView() {
+      return this.indicatorView;
+    }
+
+    public String getIndicatorLabel() {
+      return this.label.toString();
+    }
+
+    public Drawable getIndicatorIcon() {
+      return this.icon;
+    }
+
+    /**
+     * Same as GetIndicatorLabel()
+     *
+     * @return Tab text.
+     */
+    public String getText() {
+      return label.toString();
+    }
+
+    @Implementation
+    protected TabSpec setIndicator(View view) {
+      this.indicatorView = view;
+      return realObject;
+    }
+
+    @Implementation
+    protected TabSpec setIndicator(CharSequence label) {
+      this.label = label;
+      return realObject;
+    }
+
+    @Implementation
+    protected TabSpec setIndicator(CharSequence label, Drawable icon) {
+      this.label = label;
+      this.icon = icon;
+      return realObject;
+    }
+
+    /**
+     * @return the intent object set in a call to {@code TabSpec#setContent(Intent)}
+     */
+    public Intent getContentAsIntent() {
+      return intent;
+    }
+
+    @Implementation
+    protected TabSpec setContent(Intent intent) {
+      this.intent = intent;
+      return realObject;
+    }
+
+    @Implementation
+    protected TabSpec setContent(TabHost.TabContentFactory factory) {
+      contentView = factory.createTabContent(this.tag);
+      return realObject;
+    }
+
+    @Implementation
+    protected TabSpec setContent(int viewId) {
+      this.viewId = viewId;
+      return realObject;
+    }
+
+    public int getContentViewId() {
+      return viewId;
+    }
+
+    public View getContentView() {
+      return contentView;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabWidget.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabWidget.java
new file mode 100644
index 0000000..556463d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTabWidget.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+
+import android.widget.TabWidget;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(TabWidget.class)
+public class ShadowTabWidget extends ShadowLinearLayout {
+
+  @HiddenApi @Implementation(maxSdk = M)
+  public void initTabWidget() {
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelecomManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelecomManager.java
new file mode 100644
index 0000000..3fd351f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelecomManager.java
@@ -0,0 +1,736 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.R;
+import static com.google.common.base.Verify.verifyNotNull;
+
+import android.annotation.SystemApi;
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothDevice;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.telecom.CallAudioState;
+import android.telecom.Connection;
+import android.telecom.ConnectionRequest;
+import android.telecom.ConnectionService;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.telecom.VideoProfile;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import androidx.annotation.Nullable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.android.controller.ServiceController;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = TelecomManager.class, minSdk = LOLLIPOP)
+public class ShadowTelecomManager {
+
+  /**
+   * Mode describing how the shadow handles incoming ({@link TelecomManager#addNewIncomingCall}) and
+   * outgoing ({@link TelecomManager#placeCall}) call requests.
+   */
+  public enum CallRequestMode {
+    /** Automatically allows all call requests. */
+    ALLOW_ALL,
+
+    /** Automatically denies all call requests. */
+    DENY_ALL,
+
+    /**
+     * Do not automatically allow or deny any call requests. Instead, call requests should be
+     * allowed or denied manually by calling the following methods:
+     *
+     * <ul>
+     *   <li>{@link #allowIncomingCall(IncomingCallRecord)}
+     *   <li>{@link #denyIncomingCall(IncomingCallRecord)}
+     *   <li>{@link #allowOutgoingCall(OutgoingCallRecord)}
+     *   <li>{@link #denyOutgoingCall(OutgoingCallRecord)}
+     * </ul>
+     */
+    MANUAL,
+  }
+
+  @RealObject
+  private TelecomManager realObject;
+
+  private final LinkedHashMap<PhoneAccountHandle, PhoneAccount> accounts = new LinkedHashMap<>();
+  private final LinkedHashMap<PhoneAccountHandle, String> voicemailNumbers = new LinkedHashMap<>();
+
+  private final List<IncomingCallRecord> incomingCalls = new ArrayList<>();
+  private final List<OutgoingCallRecord> outgoingCalls = new ArrayList<>();
+  private final List<UnknownCallRecord> unknownCalls = new ArrayList<>();
+  private final Map<String, PhoneAccountHandle> defaultOutgoingPhoneAccounts = new ArrayMap<>();
+  private Intent manageBlockNumbersIntent;
+  private CallRequestMode callRequestMode = CallRequestMode.MANUAL;
+  private PhoneAccountHandle simCallManager;
+  private String defaultDialerPackageName;
+  private String systemDefaultDialerPackageName;
+  private boolean isInCall;
+  private boolean ttySupported;
+  private PhoneAccountHandle userSelectedOutgoingPhoneAccount;
+
+  public CallRequestMode getCallRequestMode() {
+    return callRequestMode;
+  }
+
+  public void setCallRequestMode(CallRequestMode callRequestMode) {
+    this.callRequestMode = callRequestMode;
+  }
+
+  /**
+   * Set default outgoing phone account to be returned from {@link
+   * #getDefaultOutgoingPhoneAccount(String)} for corresponding {@code uriScheme}.
+   */
+  public void setDefaultOutgoingPhoneAccount(String uriScheme, PhoneAccountHandle handle) {
+    defaultOutgoingPhoneAccounts.put(uriScheme, handle);
+  }
+
+  /** Remove default outgoing phone account for corresponding {@code uriScheme}. */
+  public void removeDefaultOutgoingPhoneAccount(String uriScheme) {
+    defaultOutgoingPhoneAccounts.remove(uriScheme);
+  }
+
+  /**
+   * Returns default outgoing phone account set through {@link
+   * #setDefaultOutgoingPhoneAccount(String, PhoneAccountHandle)} for corresponding {@code
+   * uriScheme}.
+   */
+  @Implementation
+  protected PhoneAccountHandle getDefaultOutgoingPhoneAccount(String uriScheme) {
+    return defaultOutgoingPhoneAccounts.get(uriScheme);
+  }
+
+  @Implementation
+  @HiddenApi
+  public PhoneAccountHandle getUserSelectedOutgoingPhoneAccount() {
+    return userSelectedOutgoingPhoneAccount;
+  }
+
+  @Implementation
+  @HiddenApi
+  public void setUserSelectedOutgoingPhoneAccount(PhoneAccountHandle accountHandle) {
+    userSelectedOutgoingPhoneAccount = accountHandle;
+  }
+
+  @Implementation
+  protected PhoneAccountHandle getSimCallManager() {
+    return simCallManager;
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  public PhoneAccountHandle getSimCallManager(int userId) {
+    return null;
+  }
+
+  @Implementation
+  @HiddenApi
+  public PhoneAccountHandle getConnectionManager() {
+    return this.getSimCallManager();
+  }
+
+  @Implementation
+  @HiddenApi
+  public List<PhoneAccountHandle> getPhoneAccountsSupportingScheme(String uriScheme) {
+    List<PhoneAccountHandle> result = new ArrayList<>();
+
+    for (PhoneAccountHandle handle : accounts.keySet()) {
+      PhoneAccount phoneAccount = accounts.get(handle);
+      if (phoneAccount.getSupportedUriSchemes().contains(uriScheme)) {
+        result.add(handle);
+      }
+    }
+    return result;
+  }
+
+  @Implementation(minSdk = M)
+  protected List<PhoneAccountHandle> getCallCapablePhoneAccounts() {
+    return this.getCallCapablePhoneAccounts(false);
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  public List<PhoneAccountHandle> getCallCapablePhoneAccounts(boolean includeDisabledAccounts) {
+    List<PhoneAccountHandle> result = new ArrayList<>();
+
+    for (PhoneAccountHandle handle : accounts.keySet()) {
+      PhoneAccount phoneAccount = accounts.get(handle);
+      if (!phoneAccount.isEnabled() && !includeDisabledAccounts) {
+        continue;
+      }
+      result.add(handle);
+    }
+    return result;
+  }
+
+  @Implementation(minSdk = O)
+  public List<PhoneAccountHandle> getSelfManagedPhoneAccounts() {
+    List<PhoneAccountHandle> result = new ArrayList<>();
+
+    for (PhoneAccountHandle handle : accounts.keySet()) {
+      PhoneAccount phoneAccount = accounts.get(handle);
+      if ((phoneAccount.getCapabilities() & PhoneAccount.CAPABILITY_SELF_MANAGED)
+          == PhoneAccount.CAPABILITY_SELF_MANAGED) {
+        result.add(handle);
+      }
+    }
+    return result;
+  }
+
+  @Implementation
+  @HiddenApi
+  public List<PhoneAccountHandle> getPhoneAccountsForPackage() {
+    Context context = ReflectionHelpers.getField(realObject, "mContext");
+
+    List<PhoneAccountHandle> results = new ArrayList<>();
+    for (PhoneAccountHandle handle : accounts.keySet()) {
+      if (handle.getComponentName().getPackageName().equals(context.getPackageName())) {
+        results.add(handle);
+      }
+    }
+    return results;
+  }
+
+  @Implementation
+  protected PhoneAccount getPhoneAccount(PhoneAccountHandle account) {
+    return accounts.get(account);
+  }
+
+  @Implementation
+  @HiddenApi
+  public int getAllPhoneAccountsCount() {
+    return accounts.size();
+  }
+
+  @Implementation
+  @HiddenApi
+  public List<PhoneAccount> getAllPhoneAccounts() {
+    return ImmutableList.copyOf(accounts.values());
+  }
+
+  @Implementation
+  @HiddenApi
+  public List<PhoneAccountHandle> getAllPhoneAccountHandles() {
+    return ImmutableList.copyOf(accounts.keySet());
+  }
+
+  @Implementation
+  protected void registerPhoneAccount(PhoneAccount account) {
+    accounts.put(account.getAccountHandle(), account);
+  }
+
+  @Implementation
+  protected void unregisterPhoneAccount(PhoneAccountHandle accountHandle) {
+    accounts.remove(accountHandle);
+  }
+
+  /** @deprecated */
+  @Deprecated
+  @Implementation
+  @HiddenApi
+  public void clearAccounts() {
+    accounts.clear();
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  @HiddenApi
+  public void clearAccountsForPackage(String packageName) {
+    Set<PhoneAccountHandle> phoneAccountHandlesInPackage = new HashSet<>();
+
+    for (PhoneAccountHandle handle : accounts.keySet()) {
+      if (handle.getComponentName().getPackageName().equals(packageName)) {
+        phoneAccountHandlesInPackage.add(handle);
+      }
+    }
+
+    for (PhoneAccountHandle handle : phoneAccountHandlesInPackage) {
+      accounts.remove(handle);
+    }
+  }
+
+  /** @deprecated */
+  @Deprecated
+  @Implementation
+  @HiddenApi
+  public ComponentName getDefaultPhoneApp() {
+    return null;
+  }
+
+  @Implementation(minSdk = M)
+  protected String getDefaultDialerPackage() {
+    return defaultDialerPackageName;
+  }
+
+  /** @deprecated API deprecated since Q, for testing, use setDefaultDialerPackage instead */
+  @Deprecated
+  @Implementation(minSdk = M)
+  @HiddenApi
+  public boolean setDefaultDialer(String packageName) {
+    this.defaultDialerPackageName = packageName;
+    return true;
+  }
+
+  /** Set returned value of {@link #getDefaultDialerPackage()}. */
+  public void setDefaultDialerPackage(String packageName) {
+    this.defaultDialerPackageName = packageName;
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi // API goes public in Q
+  protected String getSystemDialerPackage() {
+    return systemDefaultDialerPackageName;
+  }
+
+  /** Set returned value of {@link #getSystemDialerPackage()}. */
+  public void setSystemDialerPackage(String packageName) {
+    this.systemDefaultDialerPackageName = packageName;
+  }
+
+  public void setVoicemailNumber(PhoneAccountHandle accountHandle, String number) {
+    voicemailNumbers.put(accountHandle, number);
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean isVoiceMailNumber(PhoneAccountHandle accountHandle, String number) {
+    return TextUtils.equals(number, voicemailNumbers.get(accountHandle));
+  }
+
+  @Implementation(minSdk = M)
+  protected String getVoiceMailNumber(PhoneAccountHandle accountHandle) {
+    return voicemailNumbers.get(accountHandle);
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected String getLine1Number(PhoneAccountHandle accountHandle) {
+    return null;
+  }
+
+  /** Sets the return value for {@link TelecomManager#isInCall}. */
+  public void setIsInCall(boolean isInCall) {
+    this.isInCall = isInCall;
+  }
+
+  /**
+   * Overrides behavior of {@link TelecomManager#isInCall} to return pre-set result.
+   *
+   * @return Value set by calling {@link ShadowTelecomManager#setIsInCall}. If setIsInCall has not
+   *     previously been called, will return false.
+   */
+  @Implementation
+  protected boolean isInCall() {
+    return isInCall;
+  }
+
+  @Implementation
+  @HiddenApi
+  public int getCallState() {
+    return 0;
+  }
+
+  @Implementation
+  @HiddenApi
+  public boolean isRinging() {
+    for (IncomingCallRecord callRecord : incomingCalls) {
+      if (callRecord.isRinging) {
+        return true;
+      }
+    }
+    for (UnknownCallRecord callRecord : unknownCalls) {
+      if (callRecord.isRinging) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Implementation
+  @HiddenApi
+  public boolean endCall() {
+    return false;
+  }
+
+  @Implementation
+  protected void acceptRingingCall() {}
+
+  @Implementation
+  protected void silenceRinger() {
+    for (IncomingCallRecord callRecord : incomingCalls) {
+      callRecord.isRinging = false;
+    }
+    for (UnknownCallRecord callRecord : unknownCalls) {
+      callRecord.isRinging = false;
+    }
+  }
+
+  @Implementation
+  protected boolean isTtySupported() {
+    return ttySupported;
+  }
+
+  /** Sets the value to be returned by {@link #isTtySupported()}. */
+  public void setTtySupported(boolean isSupported) {
+    ttySupported = isSupported;
+  }
+
+  @Implementation
+  @HiddenApi
+  public int getCurrentTtyMode() {
+    return 0;
+  }
+
+  @Implementation
+  protected void addNewIncomingCall(PhoneAccountHandle phoneAccount, Bundle extras) {
+    IncomingCallRecord call = new IncomingCallRecord(phoneAccount, extras);
+    incomingCalls.add(call);
+
+    switch (callRequestMode) {
+      case ALLOW_ALL:
+        allowIncomingCall(call);
+        break;
+      case DENY_ALL:
+        denyIncomingCall(call);
+        break;
+      default:
+        // Do nothing.
+    }
+  }
+
+  public List<IncomingCallRecord> getAllIncomingCalls() {
+    return ImmutableList.copyOf(incomingCalls);
+  }
+
+  public IncomingCallRecord getLastIncomingCall() {
+    return Iterables.getLast(incomingCalls);
+  }
+
+  public IncomingCallRecord getOnlyIncomingCall() {
+    return Iterables.getOnlyElement(incomingCalls);
+  }
+
+  /**
+   * Allows an {@link IncomingCallRecord} created via {@link TelecomManager#addNewIncomingCall}.
+   *
+   * <p>Specifically, this method sets up the relevant {@link ConnectionService} and returns the
+   * result of {@link ConnectionService#onCreateIncomingConnection}.
+   */
+  @TargetApi(M)
+  @Nullable
+  public Connection allowIncomingCall(IncomingCallRecord call) {
+    if (call.isHandled) {
+      throw new IllegalStateException("Call has already been allowed or denied.");
+    }
+    call.isHandled = true;
+
+    PhoneAccountHandle phoneAccount = verifyNotNull(call.phoneAccount);
+    ConnectionRequest request = buildConnectionRequestForIncomingCall(call);
+    ConnectionService service = setupConnectionService(phoneAccount);
+    return service.onCreateIncomingConnection(phoneAccount, request);
+  }
+
+  /**
+   * Denies an {@link IncomingCallRecord} created via {@link TelecomManager#addNewIncomingCall}.
+   *
+   * <p>Specifically, this method sets up the relevant {@link ConnectionService} and calls {@link
+   * ConnectionService#onCreateIncomingConnectionFailed}.
+   */
+  @TargetApi(O)
+  public void denyIncomingCall(IncomingCallRecord call) {
+    if (call.isHandled) {
+      throw new IllegalStateException("Call has already been allowed or denied.");
+    }
+    call.isHandled = true;
+
+    PhoneAccountHandle phoneAccount = verifyNotNull(call.phoneAccount);
+    ConnectionRequest request = buildConnectionRequestForIncomingCall(call);
+    ConnectionService service = setupConnectionService(phoneAccount);
+    service.onCreateIncomingConnectionFailed(phoneAccount, request);
+  }
+
+  private static ConnectionRequest buildConnectionRequestForIncomingCall(IncomingCallRecord call) {
+    PhoneAccountHandle phoneAccount = verifyNotNull(call.phoneAccount);
+    Bundle extras = verifyNotNull(call.extras);
+    Uri address = extras.getParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS);
+    int videoState =
+        extras.getInt(
+            TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_AUDIO_ONLY);
+    return new ConnectionRequest(phoneAccount, address, new Bundle(extras), videoState);
+  }
+
+  @Implementation(minSdk = M)
+  protected void placeCall(Uri address, Bundle extras) {
+    OutgoingCallRecord call = new OutgoingCallRecord(address, extras);
+    outgoingCalls.add(call);
+
+    switch (callRequestMode) {
+      case ALLOW_ALL:
+        allowOutgoingCall(call);
+        break;
+      case DENY_ALL:
+        denyOutgoingCall(call);
+        break;
+      default:
+        // Do nothing.
+    }
+  }
+
+  public List<OutgoingCallRecord> getAllOutgoingCalls() {
+    return ImmutableList.copyOf(outgoingCalls);
+  }
+
+  public OutgoingCallRecord getLastOutgoingCall() {
+    return Iterables.getLast(outgoingCalls);
+  }
+
+  public OutgoingCallRecord getOnlyOutgoingCall() {
+    return Iterables.getOnlyElement(outgoingCalls);
+  }
+
+  /**
+   * Allows an {@link OutgoingCallRecord} created via {@link TelecomManager#placeCall}.
+   *
+   * <p>Specifically, this method sets up the relevant {@link ConnectionService} and returns the
+   * result of {@link ConnectionService#onCreateOutgoingConnection}.
+   */
+  @TargetApi(M)
+  @Nullable
+  public Connection allowOutgoingCall(OutgoingCallRecord call) {
+    if (call.isHandled) {
+      throw new IllegalStateException("Call has already been allowed or denied.");
+    }
+    call.isHandled = true;
+
+    PhoneAccountHandle phoneAccount = verifyNotNull(call.phoneAccount);
+    ConnectionRequest request = buildConnectionRequestForOutgoingCall(call);
+    ConnectionService service = setupConnectionService(phoneAccount);
+    return service.onCreateOutgoingConnection(phoneAccount, request);
+  }
+
+  /**
+   * Denies an {@link OutgoingCallRecord} created via {@link TelecomManager#placeCall}.
+   *
+   * <p>Specifically, this method sets up the relevant {@link ConnectionService} and calls {@link
+   * ConnectionService#onCreateOutgoingConnectionFailed}.
+   */
+  @TargetApi(O)
+  public void denyOutgoingCall(OutgoingCallRecord call) {
+    if (call.isHandled) {
+      throw new IllegalStateException("Call has already been allowed or denied.");
+    }
+    call.isHandled = true;
+
+    PhoneAccountHandle phoneAccount = verifyNotNull(call.phoneAccount);
+    ConnectionRequest request = buildConnectionRequestForOutgoingCall(call);
+    ConnectionService service = setupConnectionService(phoneAccount);
+    service.onCreateOutgoingConnectionFailed(phoneAccount, request);
+  }
+
+  private static ConnectionRequest buildConnectionRequestForOutgoingCall(OutgoingCallRecord call) {
+    PhoneAccountHandle phoneAccount = verifyNotNull(call.phoneAccount);
+    Uri address = verifyNotNull(call.address);
+    Bundle extras = verifyNotNull(call.extras);
+    Bundle outgoingCallExtras = extras.getBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS);
+    int videoState =
+        extras.getInt(
+            TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, VideoProfile.STATE_AUDIO_ONLY);
+    return new ConnectionRequest(
+        phoneAccount,
+        address,
+        outgoingCallExtras == null ? null : new Bundle(outgoingCallExtras),
+        videoState);
+  }
+
+  @Implementation
+  @HiddenApi
+  public void addNewUnknownCall(PhoneAccountHandle phoneAccount, Bundle extras) {
+    unknownCalls.add(new UnknownCallRecord(phoneAccount, extras));
+  }
+
+  public List<UnknownCallRecord> getAllUnknownCalls() {
+    return ImmutableList.copyOf(unknownCalls);
+  }
+
+  public UnknownCallRecord getLastUnknownCall() {
+    return Iterables.getLast(unknownCalls);
+  }
+
+  public UnknownCallRecord getOnlyUnknownCall() {
+    return Iterables.getOnlyElement(unknownCalls);
+  }
+
+  private static ConnectionService setupConnectionService(PhoneAccountHandle phoneAccount) {
+    ComponentName service = phoneAccount.getComponentName();
+    Class<? extends ConnectionService> clazz;
+    try {
+      clazz = Class.forName(service.getClassName()).asSubclass(ConnectionService.class);
+    } catch (ClassNotFoundException e) {
+      throw new IllegalArgumentException(e);
+    }
+    return verifyNotNull(
+        ServiceController.of(ReflectionHelpers.callConstructor(clazz), null).create().get());
+  }
+
+  @Implementation
+  protected boolean handleMmi(String dialString) {
+    return false;
+  }
+
+  @Implementation(minSdk = M)
+  protected boolean handleMmi(String dialString, PhoneAccountHandle accountHandle) {
+    return false;
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected Uri getAdnUriForPhoneAccount(PhoneAccountHandle accountHandle) {
+    return Uri.parse("content://icc/adn");
+  }
+
+  @Implementation
+  protected void cancelMissedCallsNotification() {}
+
+  @Implementation
+  protected void showInCallScreen(boolean showDialpad) {}
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  public void enablePhoneAccount(PhoneAccountHandle handle, boolean isEnabled) {
+  }
+
+  /**
+   * Returns the intent set by {@link ShadowTelecomManager#setManageBlockNumbersIntent(Intent)} ()}
+   */
+  @Implementation(minSdk = N)
+  protected Intent createManageBlockedNumbersIntent() {
+    return this.manageBlockNumbersIntent;
+  }
+
+  /**
+   * Sets the BlockNumbersIntent to be returned by {@link
+   * ShadowTelecomManager#createManageBlockedNumbersIntent()}
+   */
+  public void setManageBlockNumbersIntent(Intent intent) {
+    this.manageBlockNumbersIntent = intent;
+  }
+
+  @Implementation(maxSdk = LOLLIPOP_MR1)
+  public void setSimCallManager(PhoneAccountHandle simCallManager) {
+    this.simCallManager = simCallManager;
+  }
+
+  /**
+   * Creates a new {@link CallAudioState}. The real constructor of {@link CallAudioState} is hidden.
+   */
+  public CallAudioState newCallAudioState(
+      boolean muted,
+      int route,
+      int supportedRouteMask,
+      BluetoothDevice activeBluetoothDevice,
+      Collection<BluetoothDevice> supportedBluetoothDevices) {
+    return new CallAudioState(
+        muted, route, supportedRouteMask, activeBluetoothDevice, supportedBluetoothDevices);
+  }
+
+  @Implementation(minSdk = R)
+  @SystemApi
+  protected Intent createLaunchEmergencyDialerIntent(String number) {
+    // copy of logic from TelecomManager service
+    Context context = ReflectionHelpers.getField(realObject, "mContext");
+    // use reflection to get resource id since it can vary based on SDK version, and compiler will
+    // inline the value if used explicitly
+    int configEmergencyDialerPackageId =
+        ReflectionHelpers.getStaticField(
+            com.android.internal.R.string.class, "config_emergency_dialer_package");
+    String packageName = context.getString(configEmergencyDialerPackageId);
+    Intent intent = new Intent(Intent.ACTION_DIAL_EMERGENCY).setPackage(packageName);
+    ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, 0);
+    if (resolveInfo == null) {
+      // No matching activity from config, fallback to default platform implementation
+      intent.setPackage(null);
+    }
+    if (!TextUtils.isEmpty(number) && TextUtils.isDigitsOnly(number)) {
+      intent.setData(Uri.parse("tel:" + number));
+    }
+    return intent;
+  }
+
+  /**
+   * Details about a call request made via {@link TelecomManager#addNewIncomingCall} or {@link
+   * TelecomManager#addNewUnknownCall}.
+   *
+   * @deprecated Use {@link IncomingCallRecord} or {@link UnknownCallRecord} instead.
+   */
+  @Deprecated
+  public static class CallRecord {
+    public final PhoneAccountHandle phoneAccount;
+    public final Bundle extras;
+    protected boolean isRinging = true;
+
+    /** @deprecated Use {@link extras} instead. */
+    @Deprecated public final Bundle bundle;
+
+    public CallRecord(PhoneAccountHandle phoneAccount, Bundle extras) {
+      this.phoneAccount = phoneAccount;
+      this.extras = extras == null ? null : new Bundle(extras);
+
+      // Keep the deprecated "bundle" name around for a while.
+      this.bundle = this.extras;
+    }
+  }
+
+  /** Details about an incoming call request made via {@link TelecomManager#addNewIncomingCall}. */
+  public static class IncomingCallRecord extends CallRecord {
+    private boolean isHandled = false;
+
+    public IncomingCallRecord(PhoneAccountHandle phoneAccount, Bundle extras) {
+      super(phoneAccount, extras);
+    }
+  }
+
+  /** Details about an outgoing call request made via {@link TelecomManager#placeCall}. */
+  public static class OutgoingCallRecord {
+    public final PhoneAccountHandle phoneAccount;
+    public final Uri address;
+    public final Bundle extras;
+
+    private boolean isHandled = false;
+
+    public OutgoingCallRecord(Uri address, Bundle extras) {
+      this.address = address;
+      if (extras != null) {
+        this.extras = new Bundle(extras);
+        this.phoneAccount = extras.getParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE);
+      } else {
+        this.extras = null;
+        this.phoneAccount = null;
+      }
+    }
+  }
+
+  /** Details about an unknown call request made via {@link TelecomManager#addNewUnknownCall}. */
+  public static class UnknownCallRecord extends CallRecord {
+    public UnknownCallRecord(PhoneAccountHandle phoneAccount, Bundle extras) {
+      super(phoneAccount, extras);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephony.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephony.java
new file mode 100644
index 0000000..7f3f57a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephony.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.provider.Telephony;
+import android.provider.Telephony.Sms;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(value = Telephony.class, minSdk = VERSION_CODES.KITKAT)
+public class ShadowTelephony {
+  @Implements(value = Sms.class, minSdk = VERSION_CODES.KITKAT)
+  public static class ShadowSms {
+    @Nullable private static String defaultSmsPackage;
+
+    @Implementation
+    protected static String getDefaultSmsPackage(Context context) {
+      return defaultSmsPackage;
+    }
+
+    /**
+     * Override the package name returned from calling {@link Sms#getDefaultSmsPackage(Context)}.
+     *
+     * <p>This will be reset for the next test.
+     */
+    public static void setDefaultSmsPackage(String defaultSmsPackage) {
+      ShadowSms.defaultSmsPackage = defaultSmsPackage;
+    }
+
+    @Resetter
+    public static synchronized void reset() {
+      defaultSmsPackage = null;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyFrameworkInitializer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyFrameworkInitializer.java
new file mode 100644
index 0000000..56fddd7
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyFrameworkInitializer.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.os.TelephonyServiceManager;
+import android.telephony.TelephonyFrameworkInitializer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow for {@link TelephonyFrameworkInitializer} */
+@Implements(value = TelephonyFrameworkInitializer.class, minSdk = R, isInAndroidSdk = false)
+public class ShadowTelephonyFrameworkInitializer {
+
+  private static TelephonyServiceManager telephonyServiceManager = null;
+
+  @Implementation
+  protected static TelephonyServiceManager getTelephonyServiceManager() {
+    if (telephonyServiceManager == null) {
+      telephonyServiceManager = new TelephonyServiceManager();
+    }
+    return telephonyServiceManager;
+  }
+
+  @Resetter
+  public static void reset() {
+    telephonyServiceManager = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java
new file mode 100644
index 0000000..e7499ea
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java
@@ -0,0 +1,1313 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static android.telephony.PhoneStateListener.LISTEN_CALL_STATE;
+import static android.telephony.PhoneStateListener.LISTEN_CELL_INFO;
+import static android.telephony.PhoneStateListener.LISTEN_CELL_LOCATION;
+import static android.telephony.PhoneStateListener.LISTEN_NONE;
+import static android.telephony.PhoneStateListener.LISTEN_SERVICE_STATE;
+import static android.telephony.TelephonyManager.CALL_STATE_IDLE;
+import static android.telephony.TelephonyManager.CALL_STATE_RINGING;
+
+import android.annotation.CallSuper;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.PersistableBundle;
+import android.os.SystemProperties;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.Annotation.NetworkType;
+import android.telephony.Annotation.OverrideNetworkType;
+import android.telephony.CellInfo;
+import android.telephony.CellLocation;
+import android.telephony.PhoneStateListener;
+import android.telephony.ServiceState;
+import android.telephony.SignalStrength;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyCallback;
+import android.telephony.TelephonyCallback.CallStateListener;
+import android.telephony.TelephonyCallback.CellInfoListener;
+import android.telephony.TelephonyCallback.CellLocationListener;
+import android.telephony.TelephonyCallback.DisplayInfoListener;
+import android.telephony.TelephonyCallback.ServiceStateListener;
+import android.telephony.TelephonyCallback.SignalStrengthsListener;
+import android.telephony.TelephonyDisplayInfo;
+import android.telephony.TelephonyManager;
+import android.telephony.TelephonyManager.CellInfoCallback;
+import android.telephony.VisualVoicemailSmsFilterSettings;
+import android.text.TextUtils;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+import com.google.common.base.Ascii;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = TelephonyManager.class, looseSignatures = true)
+public class ShadowTelephonyManager {
+
+  @RealObject protected TelephonyManager realTelephonyManager;
+
+  private final Map<PhoneStateListener, Integer> phoneStateRegistrations = new HashMap<>();
+  private final List<TelephonyCallback> telephonyCallbackRegistrations = new ArrayList<>();
+  private final Map<Integer, String> slotIndexToDeviceId = new HashMap<>();
+  private final Map<Integer, String> slotIndexToImei = new HashMap<>();
+  private final Map<Integer, String> slotIndexToMeid = new HashMap<>();
+  private final Map<PhoneAccountHandle, Boolean> voicemailVibrationEnabledMap = new HashMap<>();
+  private final Map<PhoneAccountHandle, Uri> voicemailRingtoneUriMap = new HashMap<>();
+  private final Map<PhoneAccountHandle, TelephonyManager> phoneAccountToTelephonyManagers =
+      new HashMap<>();
+
+  private PhoneStateListener lastListener;
+  private TelephonyCallback lastTelephonyCallback;
+  private int lastEventFlags;
+
+  private String deviceId;
+  private String imei;
+  private String meid;
+  private String groupIdLevel1;
+  private String networkOperatorName = "";
+  private String networkCountryIso;
+  private String networkOperator = "";
+  private Locale simLocale;
+  private String simOperator;
+  private String simOperatorName;
+  private String simSerialNumber;
+  private boolean readPhoneStatePermission = true;
+  private int phoneType = TelephonyManager.PHONE_TYPE_GSM;
+  private String line1Number;
+  private int networkType;
+  private int dataNetworkType = TelephonyManager.NETWORK_TYPE_UNKNOWN;
+  private int voiceNetworkType = TelephonyManager.NETWORK_TYPE_UNKNOWN;
+  private List<CellInfo> allCellInfo = Collections.emptyList();
+  private List<CellInfo> callbackCellInfos = null;
+  private CellLocation cellLocation = null;
+  private int callState = CALL_STATE_IDLE;
+  private int dataState = TelephonyManager.DATA_DISCONNECTED;
+  private String incomingPhoneNumber = null;
+  private boolean isSmsCapable = true;
+  private boolean voiceCapable = true;
+  private String voiceMailNumber;
+  private String voiceMailAlphaTag;
+  private int phoneCount = 1;
+  private int activeModemCount = 1;
+  private Map<Integer, TelephonyManager> subscriptionIdsToTelephonyManagers = new HashMap<>();
+  private PersistableBundle carrierConfig;
+  private ServiceState serviceState;
+  private boolean isNetworkRoaming;
+  private final SparseIntArray simStates = new SparseIntArray();
+  private final SparseIntArray currentPhoneTypes = new SparseIntArray();
+  private final SparseArray<List<String>> carrierPackageNames = new SparseArray<>();
+  private final Map<Integer, String> simCountryIsoMap = new HashMap<>();
+  private int simCarrierId;
+  private int carrierIdFromSimMccMnc;
+  private String subscriberId;
+  private /*UiccSlotInfo[]*/ Object uiccSlotInfos;
+  private String visualVoicemailPackageName = null;
+  private SignalStrength signalStrength;
+  private boolean dataEnabled = false;
+  private boolean isRttSupported;
+  private final List<String> sentDialerSpecialCodes = new ArrayList<>();
+  private boolean hearingAidCompatibilitySupported = false;
+  private int requestCellInfoUpdateErrorCode = 0;
+  private Throwable requestCellInfoUpdateDetail = null;
+  private Object telephonyDisplayInfo;
+  private boolean isDataConnectionAllowed;
+  private static int callComposerStatus = 0;
+  private VisualVoicemailSmsParams lastVisualVoicemailSmsParams;
+  private VisualVoicemailSmsFilterSettings visualVoicemailSmsFilterSettings;
+
+  /**
+   * Should be {@link TelephonyManager.BootstrapAuthenticationCallback} but this object was
+   * introduced in Android S, so we are using Object to avoid breaking other SDKs
+   *
+   * <p>XXX Look into using the real types if we're now compiling against S
+   */
+  private Object callback;
+
+  {
+    resetSimStates();
+    resetSimCountryIsos();
+  }
+
+  @Resetter
+  public static void reset() {
+    callComposerStatus = 0;
+  }
+
+  public static void setCallComposerStatus(int callComposerStatus) {
+    ShadowTelephonyManager.callComposerStatus = callComposerStatus;
+  }
+
+  @Implementation(minSdk = S)
+  @HiddenApi
+  protected int getCallComposerStatus() {
+    return callComposerStatus;
+  }
+
+  public Object getBootstrapAuthenticationCallback() {
+    return callback;
+  }
+
+  @Implementation(minSdk = S)
+  @HiddenApi
+  public void bootstrapAuthenticationRequest(
+      Object appType,
+      Object nafId,
+      Object securityProtocol,
+      Object forceBootStrapping,
+      Object e,
+      Object callback) {
+    this.callback = callback;
+  }
+
+  @Implementation
+  protected void listen(PhoneStateListener listener, int flags) {
+    lastListener = listener;
+    lastEventFlags = flags;
+
+    if (flags == LISTEN_NONE) {
+      phoneStateRegistrations.remove(listener);
+    } else {
+      initListener(listener, flags);
+      phoneStateRegistrations.put(listener, flags);
+    }
+  }
+
+  /**
+   * Returns the most recent listener passed to #listen().
+   *
+   * @return Phone state listener.
+   * @deprecated Avoid using.
+   */
+  @Deprecated
+  public PhoneStateListener getListener() {
+    return lastListener;
+  }
+
+  /**
+   * Returns the most recent flags passed to #listen().
+   *
+   * @return Event flags.
+   * @deprecated Avoid using.
+   */
+  @Deprecated
+  public int getEventFlags() {
+    return lastEventFlags;
+  }
+
+  @Implementation(minSdk = S)
+  public void registerTelephonyCallback(Executor executor, TelephonyCallback callback) {
+    lastTelephonyCallback = callback;
+    initTelephonyCallback(callback);
+    telephonyCallbackRegistrations.add(callback);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void registerTelephonyCallback(
+      int includeLocationData, Executor executor, TelephonyCallback callback) {
+    registerTelephonyCallback(executor, callback);
+  }
+
+  @Implementation(minSdk = S)
+  public void unregisterTelephonyCallback(TelephonyCallback callback) {
+    telephonyCallbackRegistrations.remove(callback);
+  }
+
+  /** Returns the most recent callback passed to #registerTelephonyCallback(). */
+  public TelephonyCallback getLastTelephonyCallback() {
+    return lastTelephonyCallback;
+  }
+
+  /** Call state may be specified via {@link #setCallState(int)}. */
+  @Implementation
+  protected int getCallState() {
+    checkReadPhoneStatePermission();
+    return callState;
+  }
+
+  /** Sets the current call state to the desired state and updates any listeners. */
+  public void setCallState(int callState) {
+    setCallState(callState, null);
+  }
+
+  /**
+   * Sets the current call state with the option to specify an incoming phone number for the
+   * CALL_STATE_RINGING state. The incoming phone number will be ignored for all other cases.
+   */
+  public void setCallState(int callState, String incomingPhoneNumber) {
+    if (callState != CALL_STATE_RINGING) {
+      incomingPhoneNumber = null;
+    }
+
+    this.callState = callState;
+    this.incomingPhoneNumber = incomingPhoneNumber;
+
+    for (PhoneStateListener listener : getListenersForFlags(LISTEN_CALL_STATE)) {
+      listener.onCallStateChanged(callState, incomingPhoneNumber);
+    }
+    if (VERSION.SDK_INT >= S) {
+      for (CallStateListener listener : getCallbackForListener(CallStateListener.class)) {
+        listener.onCallStateChanged(callState);
+      }
+    }
+  }
+
+  /**
+   * Data state may be specified via {@link #setDataState(int)}. If no override is set, this
+   * defaults to {@link TelephonyManager#DATA_DISCONNECTED}.
+   */
+  @Implementation
+  protected int getDataState() {
+    return dataState;
+  }
+
+  /** Sets the data state returned by {@link #getDataState()}. */
+  public void setDataState(int dataState) {
+    this.dataState = dataState;
+  }
+
+  @Implementation
+  protected String getDeviceId() {
+    checkReadPhoneStatePermission();
+    return deviceId;
+  }
+
+  public void setDeviceId(String newDeviceId) {
+    deviceId = newDeviceId;
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  public void setNetworkOperatorName(String networkOperatorName) {
+    this.networkOperatorName = networkOperatorName;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected String getImei() {
+    checkReadPhoneStatePermission();
+    return imei;
+  }
+
+  @Implementation(minSdk = O)
+  protected String getImei(int slotIndex) {
+    checkReadPhoneStatePermission();
+    return slotIndexToImei.get(slotIndex);
+  }
+
+  /** Set the IMEI returned by getImei(). */
+  public void setImei(String imei) {
+    this.imei = imei;
+  }
+
+  /** Set the IMEI returned by {@link #getImei(int)}. */
+  public void setImei(int slotIndex, String imei) {
+    slotIndexToImei.put(slotIndex, imei);
+  }
+
+  @Implementation(minSdk = O)
+  protected String getMeid() {
+    checkReadPhoneStatePermission();
+    return meid;
+  }
+
+  @Implementation(minSdk = O)
+  protected String getMeid(int slotIndex) {
+    checkReadPhoneStatePermission();
+    return slotIndexToMeid.get(slotIndex);
+  }
+
+  /** Set the MEID returned by getMeid(). */
+  public void setMeid(String meid) {
+    this.meid = meid;
+  }
+
+  /** Set the MEID returned by {@link #getMeid(int)}. */
+  public void setMeid(int slotIndex, String meid) {
+    slotIndexToMeid.put(slotIndex, meid);
+  }
+
+  @Implementation
+  protected String getNetworkOperatorName() {
+    return networkOperatorName;
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1, maxSdk = P)
+  public void setNetworkCountryIso(String networkCountryIso) {
+    this.networkCountryIso = networkCountryIso;
+  }
+
+  /**
+   * Returns the SIM country lowercase. This matches the API this shadows:
+   * https://developer.android.com/reference/android/telephony/TelephonyManager#getNetworkCountryIso().
+   */
+  @Implementation
+  protected String getNetworkCountryIso() {
+    return networkCountryIso == null ? null : Ascii.toLowerCase(networkCountryIso);
+  }
+
+  /** Sets the sim locale returned by {@link #getSimLocale()}. */
+  public void setSimLocale(Locale simLocale) {
+    this.simLocale = simLocale;
+  }
+
+  /** Returns sim locale set by {@link #setSimLocale}. */
+  @Implementation(minSdk = Q)
+  protected Locale getSimLocale() {
+    return simLocale;
+  }
+
+  public void setNetworkOperator(String networkOperator) {
+    this.networkOperator = networkOperator;
+  }
+
+  @Implementation
+  protected String getNetworkOperator() {
+    return networkOperator;
+  }
+
+  @Implementation
+  protected String getSimOperator() {
+    return simOperator;
+  }
+
+  public void setSimOperator(String simOperator) {
+    this.simOperator = simOperator;
+  }
+
+  @Implementation
+  protected String getSimOperatorName() {
+    return simOperatorName;
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  public void setSimOperatorName(String simOperatorName) {
+    this.simOperatorName = simOperatorName;
+  }
+
+  @Implementation
+  protected String getSimSerialNumber() {
+    checkReadPhoneStatePermission();
+    return this.simSerialNumber;
+  }
+
+  /** sets the serial number that will be returned by {@link #getSimSerialNumber}. */
+  public void setSimSerialNumber(String simSerialNumber) {
+    this.simSerialNumber = simSerialNumber;
+  }
+
+  /**
+   * Returns the SIM country lowercase. This matches the API it shadows:
+   * https://developer.android.com/reference/android/telephony/TelephonyManager#getSimCountryIso().
+   */
+  @Implementation
+  protected String getSimCountryIso() {
+    String simCountryIso = simCountryIsoMap.get(/* subId= */ 0);
+    return simCountryIso == null ? simCountryIso : Ascii.toLowerCase(simCountryIso);
+  }
+
+  @Implementation(minSdk = N, maxSdk = Q)
+  @HiddenApi
+  protected String getSimCountryIso(int subId) {
+    return simCountryIsoMap.get(subId);
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  public void setSimCountryIso(String simCountryIso) {
+    setSimCountryIso(/* subId= */ 0, simCountryIso);
+  }
+
+  /** Sets the {@code simCountryIso} for the given {@code subId}. */
+  public void setSimCountryIso(int subId, String simCountryIso) {
+    simCountryIsoMap.put(subId, simCountryIso);
+  }
+
+  /** Clears {@code subId} to simCountryIso mapping and resets to default state. */
+  public void resetSimCountryIsos() {
+    simCountryIsoMap.clear();
+    simCountryIsoMap.put(0, "");
+  }
+
+  @Implementation
+  protected int getSimState() {
+    return getSimState(/* slotIndex= */ 0);
+  }
+
+  /** Sets the sim state of slot 0. */
+  public void setSimState(int simState) {
+    setSimState(/* slotIndex= */ 0, simState);
+  }
+
+  /** Set the sim state for the given {@code slotIndex}. */
+  public void setSimState(int slotIndex, int state) {
+    simStates.put(slotIndex, state);
+  }
+
+  @Implementation(minSdk = O)
+  protected int getSimState(int slotIndex) {
+    return simStates.get(slotIndex, TelephonyManager.SIM_STATE_UNKNOWN);
+  }
+
+  /** Sets the UICC slots information returned by {@link #getUiccSlotsInfo()}. */
+  public void setUiccSlotsInfo(/*UiccSlotInfo[]*/ Object uiccSlotsInfos) {
+    this.uiccSlotInfos = uiccSlotsInfos;
+  }
+
+  /** Returns the UICC slots information set by {@link #setUiccSlotsInfo}. */
+  @Implementation(minSdk = P)
+  @HiddenApi
+  protected /*UiccSlotInfo[]*/ Object getUiccSlotsInfo() {
+    return uiccSlotInfos;
+  }
+
+  /** Clears {@code slotIndex} to state mapping and resets to default state. */
+  public void resetSimStates() {
+    simStates.clear();
+    simStates.put(0, TelephonyManager.SIM_STATE_READY);
+  }
+
+  public void setReadPhoneStatePermission(boolean readPhoneStatePermission) {
+    this.readPhoneStatePermission = readPhoneStatePermission;
+  }
+
+  private void checkReadPhoneStatePermission() {
+    if (!readPhoneStatePermission) {
+      throw new SecurityException();
+    }
+  }
+
+  @Implementation
+  protected int getPhoneType() {
+    return phoneType;
+  }
+
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  public void setPhoneType(int phoneType) {
+    this.phoneType = phoneType;
+  }
+
+  @Implementation
+  protected String getLine1Number() {
+    checkReadPhoneStatePermission();
+    return line1Number;
+  }
+
+  public void setLine1Number(String line1Number) {
+    this.line1Number = line1Number;
+  }
+
+  @Implementation
+  protected int getNetworkType() {
+    checkReadPhoneStatePermission();
+    return networkType;
+  }
+
+  /**
+   * @deprecated {@link TelephonyManager#getNetworkType()} was replaced with {@link
+   *     TelephonyManager#getDataNetworkType()} in Android N, and has been deprecated in Android R.
+   *     Use {@link #setDataNetworkType instead}.
+   */
+  @Deprecated
+  public void setNetworkType(int networkType) {
+    this.networkType = networkType;
+  }
+
+  /**
+   * Returns whatever value was set by the last call to {@link #setDataNetworkType}, defaulting to
+   * {@link TelephonyManager#NETWORK_TYPE_UNKNOWN} if it was never called.
+   */
+  @Implementation(minSdk = N)
+  protected int getDataNetworkType() {
+    checkReadPhoneStatePermission();
+    return dataNetworkType;
+  }
+
+  /**
+   * Sets the value to be returned by calls to {@link #getDataNetworkType}. This <b>should</b>
+   * correspond to one of the {@code NETWORK_TYPE_*} constants defined on {@link TelephonyManager},
+   * but this is not enforced.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  public void setDataNetworkType(int dataNetworkType) {
+    this.dataNetworkType = dataNetworkType;
+  }
+
+  /**
+   * Returns whatever value was set by the last call to {@link #setVoiceNetworkType}, defaulting to
+   * {@link TelephonyManager#NETWORK_TYPE_UNKNOWN} if it was never called.
+   *
+   * <p>An exception will be thrown if the READ_PHONE_STATE permission has not been granted.
+   */
+  @Implementation(minSdk = N)
+  protected int getVoiceNetworkType() {
+    checkReadPhoneStatePermission();
+    return voiceNetworkType;
+  }
+
+  /**
+   * Sets the value to be returned by calls to {@link getVoiceNetworkType}. This <b>should</b>
+   * correspond to one of the {@code NETWORK_TYPE_*} constants defined on {@link TelephonyManager},
+   * but this is not enforced.
+   */
+  public void setVoiceNetworkType(int voiceNetworkType) {
+    this.voiceNetworkType = voiceNetworkType;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected List<CellInfo> getAllCellInfo() {
+    return allCellInfo;
+  }
+
+  public void setAllCellInfo(List<CellInfo> allCellInfo) {
+    this.allCellInfo = allCellInfo;
+
+    if (VERSION.SDK_INT >= JELLY_BEAN_MR1) {
+      for (PhoneStateListener listener : getListenersForFlags(LISTEN_CELL_INFO)) {
+        listener.onCellInfoChanged(allCellInfo);
+      }
+    }
+    if (VERSION.SDK_INT >= S) {
+      for (CellInfoListener listener : getCallbackForListener(CellInfoListener.class)) {
+        listener.onCellInfoChanged(allCellInfo);
+      }
+    }
+  }
+
+  /**
+   * Returns the value set by {@link #setCallbackCellInfos}, defaulting to calling the real {@link
+   * TelephonyManager#NETWORK_TYPE_UNKNOWN} if it was never called.
+   */
+  @Implementation(minSdk = Q)
+  protected void requestCellInfoUpdate(Object cellInfoExecutor, Object cellInfoCallback) {
+    Executor executor = (Executor) cellInfoExecutor;
+    if (callbackCellInfos == null) {
+      // ignore
+    } else if (requestCellInfoUpdateErrorCode != 0 || requestCellInfoUpdateDetail != null) {
+      // perform the "failure" callback operation via the specified executor
+      executor.execute(
+          () -> {
+            // Must cast 'callback' inside the anonymous class to avoid NoClassDefFoundError when
+            // referring to 'CellInfoCallback'.
+            CellInfoCallback callback = (CellInfoCallback) cellInfoCallback;
+            callback.onError(requestCellInfoUpdateErrorCode, requestCellInfoUpdateDetail);
+          });
+    } else {
+      // perform the "success" callback operation via the specified executor
+      executor.execute(
+          () -> {
+            // Must cast 'callback' inside the anonymous class to avoid NoClassDefFoundError when
+            // referring to 'CellInfoCallback'.
+            CellInfoCallback callback = (CellInfoCallback) cellInfoCallback;
+            callback.onCellInfo(callbackCellInfos);
+          });
+    }
+  }
+
+  /**
+   * Sets the value to be returned by calls to {@link requestCellInfoUpdate}. Note that it does not
+   * set the value to be returned by calls to {@link getAllCellInfo}; for that, see {@link
+   * setAllCellInfo}.
+   */
+  public void setCallbackCellInfos(List<CellInfo> callbackCellInfos) {
+    this.callbackCellInfos = callbackCellInfos;
+  }
+
+  /**
+   * Sets the values to be returned by a presumed error condition in {@link requestCellInfoUpdate}.
+   * These values will persist until cleared: to clear, set (0, null) using this method.
+   */
+  public void setRequestCellInfoUpdateErrorValues(int errorCode, Throwable detail) {
+    requestCellInfoUpdateErrorCode = errorCode;
+    requestCellInfoUpdateDetail = detail;
+  }
+
+  @Implementation
+  protected CellLocation getCellLocation() {
+    return this.cellLocation;
+  }
+
+  public void setCellLocation(CellLocation cellLocation) {
+    this.cellLocation = cellLocation;
+
+    for (PhoneStateListener listener : getListenersForFlags(LISTEN_CELL_LOCATION)) {
+      listener.onCellLocationChanged(cellLocation);
+    }
+    if (VERSION.SDK_INT >= S) {
+      for (CellLocationListener listener : getCallbackForListener(CellLocationListener.class)) {
+        listener.onCellLocationChanged(cellLocation);
+      }
+    }
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected String getGroupIdLevel1() {
+    checkReadPhoneStatePermission();
+    return this.groupIdLevel1;
+  }
+
+  public void setGroupIdLevel1(String groupIdLevel1) {
+    this.groupIdLevel1 = groupIdLevel1;
+  }
+
+  @CallSuper
+  protected void initListener(PhoneStateListener listener, int flags) {
+    if ((flags & LISTEN_CALL_STATE) != 0) {
+      listener.onCallStateChanged(callState, incomingPhoneNumber);
+    }
+    if ((flags & LISTEN_CELL_INFO) != 0) {
+      if (VERSION.SDK_INT >= JELLY_BEAN_MR1) {
+        listener.onCellInfoChanged(allCellInfo);
+      }
+    }
+    if ((flags & LISTEN_CELL_LOCATION) != 0) {
+      listener.onCellLocationChanged(cellLocation);
+    }
+
+    if (telephonyDisplayInfo != null
+        && ((flags & PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED) != 0)) {
+      listener.onDisplayInfoChanged((TelephonyDisplayInfo) telephonyDisplayInfo);
+    }
+
+    if (serviceState != null && ((flags & PhoneStateListener.LISTEN_SERVICE_STATE) != 0)) {
+      listener.onServiceStateChanged(serviceState);
+    }
+  }
+
+  @CallSuper
+  protected void initTelephonyCallback(TelephonyCallback callback) {
+    if (VERSION.SDK_INT < S) {
+      return;
+    }
+
+    if (callback instanceof CallStateListener) {
+      ((CallStateListener) callback).onCallStateChanged(callState);
+    }
+    if (callback instanceof CellInfoListener) {
+      ((CellInfoListener) callback).onCellInfoChanged(allCellInfo);
+    }
+    if (callback instanceof CellLocationListener) {
+      ((CellLocationListener) callback).onCellLocationChanged(cellLocation);
+    }
+    if (telephonyDisplayInfo != null && callback instanceof DisplayInfoListener) {
+      ((DisplayInfoListener) callback)
+          .onDisplayInfoChanged((TelephonyDisplayInfo) telephonyDisplayInfo);
+    }
+    if (serviceState != null && callback instanceof ServiceStateListener) {
+      ((ServiceStateListener) callback).onServiceStateChanged(serviceState);
+    }
+  }
+
+  protected Iterable<PhoneStateListener> getListenersForFlags(int flags) {
+    return Iterables.filter(
+        phoneStateRegistrations.keySet(),
+        new Predicate<PhoneStateListener>() {
+          @Override
+          public boolean apply(PhoneStateListener input) {
+            // only select PhoneStateListeners with matching flags
+            return (phoneStateRegistrations.get(input) & flags) != 0;
+          }
+        });
+  }
+
+  /**
+   * Returns a view of {@code telephonyCallbackRegistrations} containing all elements that are of
+   * the type {@code clazz}.
+   */
+  protected <T> Iterable<T> getCallbackForListener(Class<T> clazz) {
+    // Only selects TelephonyCallback with matching class.
+    return Iterables.filter(telephonyCallbackRegistrations, clazz);
+  }
+
+  /**
+   * @return true by default, or the value specified via {@link #setIsSmsCapable(boolean)}
+   */
+  @Implementation
+  protected boolean isSmsCapable() {
+    return isSmsCapable;
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#isSmsCapable()}. */
+  public void setIsSmsCapable(boolean isSmsCapable) {
+    this.isSmsCapable = isSmsCapable;
+  }
+
+  /**
+   * Returns a new empty {@link PersistableBundle} by default, or the value specified via {@link
+   * #setCarrierConfig(PersistableBundle)}.
+   */
+  @Implementation(minSdk = O)
+  protected PersistableBundle getCarrierConfig() {
+    checkReadPhoneStatePermission();
+    return carrierConfig != null ? carrierConfig : new PersistableBundle();
+  }
+
+  /**
+   * Sets the value returned by {@link TelephonyManager#getCarrierConfig()}.
+   *
+   * @param carrierConfig
+   */
+  public void setCarrierConfig(PersistableBundle carrierConfig) {
+    this.carrierConfig = carrierConfig;
+  }
+
+  /**
+   * Returns {@code null} by default, or the value specified via {@link
+   * #setVoiceMailNumber(String)}.
+   */
+  @Implementation
+  protected String getVoiceMailNumber() {
+    checkReadPhoneStatePermission();
+    return voiceMailNumber;
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#getVoiceMailNumber()}. */
+  public void setVoiceMailNumber(String voiceMailNumber) {
+    this.voiceMailNumber = voiceMailNumber;
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setVoiceMailAlphaTag(String)}.
+   */
+  @Implementation
+  protected String getVoiceMailAlphaTag() {
+    checkReadPhoneStatePermission();
+    return voiceMailAlphaTag;
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#getVoiceMailAlphaTag()}. */
+  public void setVoiceMailAlphaTag(String voiceMailAlphaTag) {
+    this.voiceMailAlphaTag = voiceMailAlphaTag;
+  }
+
+  /** Returns 1 by default or the value specified via {@link #setPhoneCount(int)}. */
+  @Implementation(minSdk = M)
+  protected int getPhoneCount() {
+    return phoneCount;
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#getPhoneCount()}. */
+  public void setPhoneCount(int phoneCount) {
+    this.phoneCount = phoneCount;
+  }
+
+  /** Returns 1 by default or the value specified via {@link #setActiveModemCount(int)}. */
+  @Implementation(minSdk = R)
+  protected int getActiveModemCount() {
+    return activeModemCount;
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#getActiveModemCount()}. */
+  public void setActiveModemCount(int activeModemCount) {
+    this.activeModemCount = activeModemCount;
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link #setDeviceId(int, String)}.
+   */
+  @Implementation(minSdk = M)
+  protected String getDeviceId(int slot) {
+    return slotIndexToDeviceId.get(slot);
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#getDeviceId(int)}. */
+  public void setDeviceId(int slot, String deviceId) {
+    slotIndexToDeviceId.put(slot, deviceId);
+  }
+
+  /**
+   * Returns {@code true} by default or the value specified via {@link #setVoiceCapable(boolean)}.
+   */
+  @Implementation(minSdk = LOLLIPOP_MR1)
+  protected boolean isVoiceCapable() {
+    return voiceCapable;
+  }
+
+  /** Sets the value returned by {@link #isVoiceCapable()}. */
+  public void setVoiceCapable(boolean voiceCapable) {
+    this.voiceCapable = voiceCapable;
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setVoicemailVibrationEnabled(PhoneAccountHandle, boolean)}.
+   */
+  @Implementation(minSdk = N)
+  protected boolean isVoicemailVibrationEnabled(PhoneAccountHandle handle) {
+    Boolean result = voicemailVibrationEnabledMap.get(handle);
+    return result != null ? result : false;
+  }
+
+  /**
+   * Sets the value returned by {@link
+   * TelephonyManager#isVoicemailVibrationEnabled(PhoneAccountHandle)}.
+   */
+  @Implementation(minSdk = O)
+  protected void setVoicemailVibrationEnabled(PhoneAccountHandle handle, boolean isEnabled) {
+    voicemailVibrationEnabledMap.put(handle, isEnabled);
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setVoicemailRingtoneUri(PhoneAccountHandle, Uri)}.
+   */
+  @Implementation(minSdk = N)
+  protected Uri getVoicemailRingtoneUri(PhoneAccountHandle handle) {
+    return voicemailRingtoneUriMap.get(handle);
+  }
+
+  /**
+   * Sets the value returned by {@link
+   * TelephonyManager#getVoicemailRingtoneUri(PhoneAccountHandle)}.
+   */
+  @Implementation(minSdk = O)
+  protected void setVoicemailRingtoneUri(PhoneAccountHandle handle, Uri uri) {
+    voicemailRingtoneUriMap.put(handle, uri);
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setTelephonyManagerForHandle(PhoneAccountHandle, TelephonyManager)}.
+   */
+  @Implementation(minSdk = O)
+  protected TelephonyManager createForPhoneAccountHandle(PhoneAccountHandle handle) {
+    return phoneAccountToTelephonyManagers.get(handle);
+  }
+
+  /**
+   * Sets the value returned by {@link
+   * TelephonyManager#createForPhoneAccountHandle(PhoneAccountHandle)}.
+   */
+  public void setTelephonyManagerForHandle(
+      PhoneAccountHandle handle, TelephonyManager telephonyManager) {
+    phoneAccountToTelephonyManagers.put(handle, telephonyManager);
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setTelephonyManagerForSubscriptionId(int, TelephonyManager)}
+   */
+  @Implementation(minSdk = N)
+  protected TelephonyManager createForSubscriptionId(int subId) {
+    return subscriptionIdsToTelephonyManagers.get(subId);
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#createForSubscriptionId(int)}. */
+  public void setTelephonyManagerForSubscriptionId(
+      int subscriptionId, TelephonyManager telephonyManager) {
+    subscriptionIdsToTelephonyManagers.put(subscriptionId, telephonyManager);
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setServiceState(ServiceState)}
+   */
+  @Implementation(minSdk = O)
+  protected ServiceState getServiceState() {
+    checkReadPhoneStatePermission();
+    return serviceState;
+  }
+
+  /**
+   * Returns {@code null} by default or the value specified via {@link
+   * #setServiceState(ServiceState)}
+   */
+  @Implementation(minSdk = TIRAMISU)
+  protected ServiceState getServiceState(int includeLocationData) {
+    return getServiceState();
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#getServiceState()}. */
+  public void setServiceState(ServiceState serviceState) {
+    this.serviceState = serviceState;
+
+    for (PhoneStateListener listener : getListenersForFlags(LISTEN_SERVICE_STATE)) {
+      listener.onServiceStateChanged(serviceState);
+    }
+    if (VERSION.SDK_INT >= S) {
+      for (ServiceStateListener listener : getCallbackForListener(ServiceStateListener.class)) {
+        listener.onServiceStateChanged(serviceState);
+      }
+    }
+  }
+
+  /**
+   * Returns {@code false} by default or the value specified via {@link
+   * #setIsNetworkRoaming(boolean)}
+   */
+  @Implementation
+  protected boolean isNetworkRoaming() {
+    return isNetworkRoaming;
+  }
+
+  /** Sets the value returned by {@link TelephonyManager#isNetworkRoaming()}. */
+  public void setIsNetworkRoaming(boolean isNetworkRoaming) {
+    this.isNetworkRoaming = isNetworkRoaming;
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected int getCurrentPhoneType(int subId) {
+    return currentPhoneTypes.get(subId, TelephonyManager.PHONE_TYPE_NONE);
+  }
+
+  /** Sets the phone type for the given {@code subId}. */
+  public void setCurrentPhoneType(int subId, int phoneType) {
+    currentPhoneTypes.put(subId, phoneType);
+  }
+
+  /** Removes all {@code subId} to {@code phoneType} mappings. */
+  public void clearPhoneTypes() {
+    currentPhoneTypes.clear();
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected List<String> getCarrierPackageNamesForIntentAndPhone(Intent intent, int phoneId) {
+    return carrierPackageNames.get(phoneId);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  @HiddenApi
+  protected List<String> getCarrierPackageNamesForIntent(Intent intent) {
+    return carrierPackageNames.get(SubscriptionManager.DEFAULT_SUBSCRIPTION_ID);
+  }
+
+  /** Sets the {@code packages} for the given {@code phoneId}. */
+  public void setCarrierPackageNamesForPhone(int phoneId, List<String> packages) {
+    carrierPackageNames.put(phoneId, packages);
+  }
+
+  @Implementation(minSdk = Q)
+  protected int getCarrierIdFromSimMccMnc() {
+    return carrierIdFromSimMccMnc;
+  }
+
+  /** Sets the value to be returned by {@link #getCarrierIdFromSimMccMnc()}. */
+  public void setCarrierIdFromSimMccMnc(int carrierIdFromSimMccMnc) {
+    this.carrierIdFromSimMccMnc = carrierIdFromSimMccMnc;
+  }
+
+  @Implementation(minSdk = P)
+  protected int getSimCarrierId() {
+    return simCarrierId;
+  }
+
+  /** Sets the value to be returned by {@link #getSimCarrierId()}. */
+  public void setSimCarrierId(int simCarrierId) {
+    this.simCarrierId = simCarrierId;
+  }
+
+  @Implementation
+  protected String getSubscriberId() {
+    checkReadPhoneStatePermission();
+    return subscriberId;
+  }
+
+  /** Sets the value to be returned by {@link #getSubscriberId()}. */
+  public void setSubscriberId(String subscriberId) {
+    this.subscriberId = subscriberId;
+  }
+
+  /** Returns the value set by {@link #setVisualVoicemailPackageName(String)}. */
+  @Implementation(minSdk = O)
+  protected String getVisualVoicemailPackageName() {
+    checkReadPhoneStatePermission();
+    return visualVoicemailPackageName;
+  }
+
+  /** Sets the value to be returned by {@link #getVisualVoicemailPackageName()}. */
+  public void setVisualVoicemailPackageName(String visualVoicemailPackageName) {
+    this.visualVoicemailPackageName = visualVoicemailPackageName;
+  }
+
+  @Implementation(minSdk = P)
+  protected SignalStrength getSignalStrength() {
+    return signalStrength;
+  }
+
+  /** Sets the value to be returned by {@link #getSignalStrength()} */
+  public void setSignalStrength(SignalStrength signalStrength) {
+    this.signalStrength = signalStrength;
+    for (PhoneStateListener listener :
+        getListenersForFlags(PhoneStateListener.LISTEN_SIGNAL_STRENGTHS)) {
+      listener.onSignalStrengthsChanged(signalStrength);
+    }
+    if (VERSION.SDK_INT >= S) {
+      for (SignalStrengthsListener listener :
+          getCallbackForListener(SignalStrengthsListener.class)) {
+        listener.onSignalStrengthsChanged(signalStrength);
+      }
+    }
+  }
+
+  /**
+   * Cribbed from {@link android.telephony.PhoneNumberUtils#isEmergencyNumberInternal}.
+   *
+   * <p>TODO: need better implementation
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected boolean isEmergencyNumber(String number) {
+
+    if (number == null) {
+      return false;
+    }
+
+    Context context = ReflectionHelpers.getField(realTelephonyManager, "mContext");
+    Locale locale = context == null ? null : context.getResources().getConfiguration().locale;
+    String defaultCountryIso = locale == null ? null : locale.getCountry();
+
+    int slotId = -1;
+    boolean useExactMatch = true;
+
+    // retrieve the list of emergency numbers
+    // check read-write ecclist property first
+    String ecclist = (slotId <= 0) ? "ril.ecclist" : ("ril.ecclist" + slotId);
+
+    String emergencyNumbers = SystemProperties.get(ecclist, "");
+
+    if (TextUtils.isEmpty(emergencyNumbers)) {
+      // then read-only ecclist property since old RIL only uses this
+      emergencyNumbers = SystemProperties.get("ro.ril.ecclist");
+    }
+
+    if (!TextUtils.isEmpty(emergencyNumbers)) {
+      // searches through the comma-separated list for a match,
+      // return true if one is found.
+      for (String emergencyNum : emergencyNumbers.split(",")) {
+        // It is not possible to append additional digits to an emergency number to dial
+        // the number in Brazil - it won't connect.
+        if (useExactMatch || "BR".equalsIgnoreCase(defaultCountryIso)) {
+          if (number.equals(emergencyNum)) {
+            return true;
+          }
+        } else {
+          if (number.startsWith(emergencyNum)) {
+            return true;
+          }
+        }
+      }
+      // no matches found against the list!
+      return false;
+    }
+
+    emergencyNumbers = ((slotId < 0) ? "112,911,000,08,110,118,119,999" : "112,911");
+    for (String emergencyNum : emergencyNumbers.split(",")) {
+      if (useExactMatch) {
+        if (number.equals(emergencyNum)) {
+          return true;
+        }
+      } else {
+        if (number.startsWith(emergencyNum)) {
+          return true;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected boolean isPotentialEmergencyNumber(String number) {
+    return isEmergencyNumber(number);
+  }
+
+  /**
+   * Implementation for {@link TelephonyManager#isDataEnabled}.
+   *
+   * @return False by default, unless set with {@link TelephonyManager#setDataEnabled}.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected boolean isDataEnabled() {
+    checkReadPhoneStatePermission();
+    return dataEnabled;
+  }
+
+  /**
+   * Implementation for {@link TelephonyManager#setDataEnabled}. Marked as public in order to allow
+   * it to be used as a test API.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  public void setDataEnabled(boolean enabled) {
+    dataEnabled = enabled;
+  }
+
+  /**
+   * Implementation for {@link TelephonyManager#isRttSupported}.
+   *
+   * @return False by default, unless set with {@link #setRttSupported(boolean)}.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected boolean isRttSupported() {
+    return isRttSupported;
+  }
+
+  /** Sets the value to be returned by {@link #isRttSupported()} */
+  public void setRttSupported(boolean isRttSupported) {
+    this.isRttSupported = isRttSupported;
+  }
+
+  /**
+   * Implementation for {@link TelephonyManager#sendDialerSpecialCode(String)}.
+   *
+   * @param inputCode special code to be sent.
+   */
+  @Implementation(minSdk = O)
+  public void sendDialerSpecialCode(String inputCode) {
+    sentDialerSpecialCodes.add(inputCode);
+  }
+
+  /**
+   * Returns immutable list of special codes sent using {@link
+   * TelephonyManager#sendDialerSpecialCode(String)}. Special codes contained in the list are in the
+   * order they were sent.
+   */
+  public List<String> getSentDialerSpecialCodes() {
+    return ImmutableList.copyOf(sentDialerSpecialCodes);
+  }
+
+  /** Sets the value to be returned by {@link #isHearingAidCompatibilitySupported()}. */
+  public void setHearingAidCompatibilitySupported(boolean isSupported) {
+    hearingAidCompatibilitySupported = isSupported;
+  }
+
+  /**
+   * Implementation for {@link TelephonyManager#isHearingAidCompatibilitySupported()}.
+   *
+   * @return False by default, unless set with {@link
+   *     #setHearingAidCompatibilitySupported(boolean)}.
+   */
+  @Implementation(minSdk = M)
+  protected boolean isHearingAidCompatibilitySupported() {
+    return hearingAidCompatibilitySupported;
+  }
+
+  /**
+   * Creates a {@link TelephonyDisplayInfo}.
+   *
+   * @param networkType The packet-switching cellular network type (see {@link NetworkType})
+   * @param overrideNetworkType The override network type (see {@link OverrideNetworkType})
+   */
+  public static Object createTelephonyDisplayInfo(
+      @NetworkType int networkType, @OverrideNetworkType int overrideNetworkType) {
+    return new TelephonyDisplayInfo(networkType, overrideNetworkType);
+  }
+
+  /**
+   * Sets the current {@link TelephonyDisplayInfo}, and notifies all the {@link PhoneStateListener}s
+   * that were registered with the {@link PhoneStateListener#LISTEN_DISPLAY_INFO_CHANGED} flag.
+   *
+   * @param telephonyDisplayInfo The {@link TelephonyDisplayInfo} to set. May not be null.
+   * @throws NullPointerException if telephonyDisplayInfo is null.
+   */
+  public void setTelephonyDisplayInfo(Object telephonyDisplayInfo) {
+    Preconditions.checkNotNull(telephonyDisplayInfo);
+    this.telephonyDisplayInfo = telephonyDisplayInfo;
+
+    for (PhoneStateListener listener :
+        getListenersForFlags(PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED)) {
+      listener.onDisplayInfoChanged((TelephonyDisplayInfo) telephonyDisplayInfo);
+    }
+    if (VERSION.SDK_INT >= S) {
+      for (DisplayInfoListener listener : getCallbackForListener(DisplayInfoListener.class)) {
+        listener.onDisplayInfoChanged((TelephonyDisplayInfo) telephonyDisplayInfo);
+      }
+    }
+  }
+
+  @Implementation(minSdk = R)
+  @HiddenApi
+  protected boolean isDataConnectionAllowed() {
+    checkReadPhoneStatePermission();
+    return isDataConnectionAllowed;
+  }
+
+  public void setIsDataConnectionAllowed(boolean isDataConnectionAllowed) {
+    this.isDataConnectionAllowed = isDataConnectionAllowed;
+  }
+
+  @Implementation(minSdk = O)
+  public void sendVisualVoicemailSms(
+      String number, int port, String text, PendingIntent sentIntent) {
+    lastVisualVoicemailSmsParams = new VisualVoicemailSmsParams(number, port, text, sentIntent);
+  }
+
+  public VisualVoicemailSmsParams getLastSentVisualVoicemailSmsParams() {
+    return lastVisualVoicemailSmsParams;
+  }
+
+  /**
+   * Implementation for {@link
+   * TelephonyManager#setVisualVoicemailSmsFilterSettings(VisualVoicemailSmsFilterSettings)}.
+   *
+   * @param settings The settings for the filter, or null to disable the filter.
+   */
+  @Implementation(minSdk = O)
+  public void setVisualVoicemailSmsFilterSettings(VisualVoicemailSmsFilterSettings settings) {
+    visualVoicemailSmsFilterSettings = settings;
+  }
+
+  /** Returns the last set {@link VisualVoicemailSmsFilterSettings}. */
+  public VisualVoicemailSmsFilterSettings getVisualVoicemailSmsFilterSettings() {
+    return visualVoicemailSmsFilterSettings;
+  }
+
+  /** Testable parameters from calls to {@link #sendVisualVoicemailSms}. */
+  public static class VisualVoicemailSmsParams {
+    private final String destinationAddress;
+    private final int port;
+    private final String text;
+    private final PendingIntent sentIntent;
+
+    public VisualVoicemailSmsParams(
+        String destinationAddress, int port, String text, PendingIntent sentIntent) {
+      this.destinationAddress = destinationAddress;
+      this.port = port;
+      this.text = text;
+      this.sentIntent = sentIntent;
+    }
+
+    public String getDestinationAddress() {
+      return destinationAddress;
+    }
+
+    public int getPort() {
+      return port;
+    }
+
+    public String getText() {
+      return text;
+    }
+
+    public PendingIntent getSentIntent() {
+      return sentIntent;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextToSpeech.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextToSpeech.java
new file mode 100644
index 0000000..e5b6b5e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextToSpeech.java
@@ -0,0 +1,352 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.TextToSpeech.Engine;
+import android.speech.tts.UtteranceProgressListener;
+import android.speech.tts.Voice;
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowMediaPlayer.MediaInfo;
+import org.robolectric.shadows.util.DataSource;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(TextToSpeech.class)
+public class ShadowTextToSpeech {
+
+  private static final Set<Locale> languageAvailabilities = new HashSet<>();
+  private static final Set<Voice> voices = new HashSet<>();
+  private static TextToSpeech lastTextToSpeechInstance;
+
+  @RealObject private TextToSpeech tts;
+
+  private Context context;
+  private TextToSpeech.OnInitListener listener;
+  private String lastSpokenText;
+  private boolean shutdown = false;
+  private boolean stopped = true;
+  private int queueMode = -1;
+  private Locale language = null;
+  private File lastSynthesizeToFile;
+  private String lastSynthesizeToFileText;
+  private Voice currentVoice = null;
+
+  // This is not the value returned by synthesizeToFile, but rather controls the callbacks.
+  // See
+  // http://cs/android/frameworks/base/core/java/android/speech/tts/TextToSpeech.java?rcl=db6d9c1ced6b9af1de8f12e912a223f3c7f42ecd&l=1874.
+  private int synthesizeToFileResult = TextToSpeech.SUCCESS;
+
+  private boolean completeSynthesis = false;
+
+  private final List<String> spokenTextList = new ArrayList<>();
+
+  @Implementation
+  protected void __constructor__(
+      Context context,
+      TextToSpeech.OnInitListener listener,
+      String engine,
+      String packageName,
+      boolean useFallback) {
+    this.context = context;
+    this.listener = listener;
+    lastTextToSpeechInstance = tts;
+    Shadow.invokeConstructor(
+        TextToSpeech.class,
+        tts,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(TextToSpeech.OnInitListener.class, listener),
+        ClassParameter.from(String.class, engine),
+        ClassParameter.from(String.class, packageName),
+        ClassParameter.from(boolean.class, useFallback));
+  }
+
+  /**
+   * Sets up synthesizeToFile to succeed or fail in the synthesis operation.
+   *
+   * <p>This controls calls the relevant callbacks but does not set the return value of
+   * synthesizeToFile.
+   *
+   * @param result TextToSpeech enum (SUCCESS, ERROR, or one of the ERROR_ codes from TextToSpeech)
+   */
+  public void simulateSynthesizeToFileResult(int result) {
+    this.synthesizeToFileResult = result;
+    this.completeSynthesis = true;
+  }
+
+  @Implementation
+  protected int initTts() {
+    // Has to be overridden because the real code attempts to connect to a non-existent TTS
+    // system service.
+    return TextToSpeech.SUCCESS;
+  }
+
+  /**
+   * Speaks the string using the specified queuing strategy and speech parameters.
+   *
+   * @param params The real implementation converts the hashmap into a bundle, but the bundle
+   *     argument is not used in the shadow implementation.
+   */
+  @Implementation
+  protected int speak(
+      final String text, final int queueMode, final HashMap<String, String> params) {
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      return reflector(TextToSpeechReflector.class, tts).speak(text, queueMode, params);
+    }
+    return speak(
+        text, queueMode, null, params == null ? null : params.get(Engine.KEY_PARAM_UTTERANCE_ID));
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected int speak(
+      final CharSequence text, final int queueMode, final Bundle params, final String utteranceId) {
+    stopped = false;
+    lastSpokenText = text.toString();
+    spokenTextList.add(text.toString());
+    this.queueMode = queueMode;
+
+    if (RuntimeEnvironment.getApiLevel() >= ICE_CREAM_SANDWICH_MR1) {
+      if (utteranceId != null) {
+        // The onStart and onDone callbacks are normally delivered asynchronously. Since in
+        // Robolectric we don't need the wait for TTS package, the asynchronous callbacks are
+        // simulated by posting it on a handler. The behavior of the callback can be changed for
+        // each individual test by changing the idling mode of the foreground scheduler.
+        Handler handler = new Handler(Looper.getMainLooper());
+        handler.post(
+            () -> {
+              UtteranceProgressListener utteranceProgressListener = getUtteranceProgressListener();
+              if (utteranceProgressListener != null) {
+                utteranceProgressListener.onStart(utteranceId);
+              }
+              // The onDone callback is posted in a separate run-loop from onStart, so that tests
+              // can pause the scheduler and test the behavior between these two callbacks.
+              handler.post(
+                  () -> {
+                    UtteranceProgressListener utteranceProgressListener2 =
+                        getUtteranceProgressListener();
+                    if (utteranceProgressListener2 != null) {
+                      utteranceProgressListener2.onDone(utteranceId);
+                    }
+                  });
+            });
+      }
+    }
+    return TextToSpeech.SUCCESS;
+  }
+
+  @Implementation
+  protected void shutdown() {
+    shutdown = true;
+  }
+
+  @Implementation
+  protected int stop() {
+    stopped = true;
+    return TextToSpeech.SUCCESS;
+  }
+
+  @Implementation
+  protected int isLanguageAvailable(Locale lang) {
+    for (Locale locale : languageAvailabilities) {
+      if (locale.getISO3Language().equals(lang.getISO3Language())) {
+        if (locale.getISO3Country().equals(lang.getISO3Country())) {
+          if (locale.getVariant().equals(lang.getVariant())) {
+            return TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE;
+          }
+          return TextToSpeech.LANG_COUNTRY_AVAILABLE;
+        }
+        return TextToSpeech.LANG_AVAILABLE;
+      }
+    }
+    return TextToSpeech.LANG_NOT_SUPPORTED;
+  }
+
+  @Implementation
+  protected int setLanguage(Locale locale) {
+    this.language = locale;
+    return isLanguageAvailable(locale);
+  }
+
+  /**
+   * Stores {@code text} and returns {@link TextToSpeech#SUCCESS}.
+   *
+   * @see #getLastSynthesizeToFileText()
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected int synthesizeToFile(CharSequence text, Bundle params, File file, String utteranceId)
+      throws IOException {
+    this.lastSynthesizeToFileText = text.toString();
+
+    if (!Boolean.getBoolean("robolectric.enableShadowTtsSynthesisToFileWriteToFileSuppression")) {
+      this.lastSynthesizeToFile = file;
+      try (PrintWriter writer = new PrintWriter(file, UTF_8.name())) {
+        writer.println(text);
+      }
+
+      ShadowMediaPlayer.addMediaInfo(
+          DataSource.toDataSource(file.getAbsolutePath()), new MediaInfo());
+    }
+
+    UtteranceProgressListener utteranceProgressListener = getUtteranceProgressListener();
+
+    /*
+     * The Java system property robolectric.shadowTtsEnableSynthesisToFileCallbackSuppression can be
+     * used by test targets that fail due to tests relying on previous behavior of this fake, where
+     * the listeners were not called.
+     */
+    if (completeSynthesis
+        && utteranceProgressListener != null
+        && !Boolean.getBoolean("robolectric.enableShadowTtsSynthesisToFileCallbackSuppression")) {
+      switch (synthesizeToFileResult) {
+          // Right now this only supports success an error though there are other possible
+          // situations.
+        case TextToSpeech.SUCCESS:
+          utteranceProgressListener.onStart(utteranceId);
+          utteranceProgressListener.onDone(utteranceId);
+          break;
+        default:
+          utteranceProgressListener.onError(utteranceId, synthesizeToFileResult);
+          break;
+      }
+    }
+
+    // This refers to the result of the queueing operation.
+    // See
+    // http://cs/android/frameworks/base/core/java/android/speech/tts/TextToSpeech.java?rcl=db6d9c1ced6b9af1de8f12e912a223f3c7f42ecd&l=1890.
+    return TextToSpeech.SUCCESS;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected int setVoice(Voice voice) {
+    this.currentVoice = voice;
+    return TextToSpeech.SUCCESS;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected Set<Voice> getVoices() {
+    return voices;
+  }
+
+  public UtteranceProgressListener getUtteranceProgressListener() {
+    return ReflectionHelpers.getField(tts, "mUtteranceProgressListener");
+  }
+
+  public Context getContext() {
+    return context;
+  }
+
+  public TextToSpeech.OnInitListener getOnInitListener() {
+    return listener;
+  }
+
+  public String getLastSpokenText() {
+    return lastSpokenText;
+  }
+
+  public void clearLastSpokenText() {
+    lastSpokenText = null;
+  }
+
+  public boolean isShutdown() {
+    return shutdown;
+  }
+
+  /** @return {@code true} if the TTS is stopped. */
+  public boolean isStopped() {
+    return stopped;
+  }
+
+  public int getQueueMode() {
+    return queueMode;
+  }
+
+  /**
+   * Returns {@link Locale} set using {@link TextToSpeech#setLanguage(Locale)} or null if not set.
+   */
+  public Locale getCurrentLanguage() {
+    return language;
+  }
+
+  /**
+   * Returns last text {@link CharSequence} passed to {@link
+   * TextToSpeech#synthesizeToFile(CharSequence, Bundle, File, String)}.
+   */
+  public String getLastSynthesizeToFileText() {
+    return lastSynthesizeToFileText;
+  }
+
+  /**
+   * Returns last file {@link File} written to by {@link TextToSpeech#synthesizeToFile(CharSequence,
+   * Bundle, File, String)}.
+   */
+  public File getLastSynthesizeToFile() {
+    return lastSynthesizeToFile;
+  }
+
+  /** Returns list of all the text spoken by {@link #speak}. */
+  public ImmutableList<String> getSpokenTextList() {
+    return ImmutableList.copyOf(spokenTextList);
+  }
+
+  /**
+   * Makes {@link Locale} an available language returned by {@link
+   * TextToSpeech#isLanguageAvailable(Locale)}. The value returned by {@link
+   * #isLanguageAvailable(Locale)} will vary depending on language, country, and variant.
+   */
+  public static void addLanguageAvailability(Locale locale) {
+    languageAvailabilities.add(locale);
+  }
+
+  /** Makes {@link Voice} an available voice returned by {@link TextToSpeech#getVoices()}. */
+  public static void addVoice(Voice voice) {
+    voices.add(voice);
+  }
+
+  /** Returns {@link Voice} set using {@link TextToSpeech#setVoice(Voice)}, or null if not set. */
+  public Voice getCurrentVoice() {
+    return currentVoice;
+  }
+
+  /** Returns the most recently instantiated {@link TextToSpeech} or null if none exist. */
+  public static TextToSpeech getLastTextToSpeechInstance() {
+    return lastTextToSpeechInstance;
+  }
+
+  @Resetter
+  public static void reset() {
+    languageAvailabilities.clear();
+    voices.clear();
+    lastTextToSpeechInstance = null;
+  }
+
+  @ForType(TextToSpeech.class)
+  interface TextToSpeechReflector {
+
+    @Direct
+    int speak(final String text, final int queueMode, final HashMap params);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextUtils.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextUtils.java
new file mode 100644
index 0000000..e442a23
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextUtils.java
@@ -0,0 +1,29 @@
+package org.robolectric.shadows;
+
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.TextUtils.TruncateAt;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Implement {@lint TextUtils#ellipsize} by truncating the text.
+ */
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(TextUtils.class)
+public class ShadowTextUtils {
+
+  @Implementation
+  protected static CharSequence ellipsize(
+      CharSequence text, TextPaint p, float avail, TruncateAt where) {
+    // This shadow follows the convention of ShadowPaint#measureText where each
+    // characters width is 1.0.
+    if (avail <= 0) {
+      return "";
+    } else if (text.length() < (int) (avail)) {
+      return text;
+    } else {
+      return text.subSequence(0, (int) avail);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextView.java
new file mode 100644
index 0000000..e0f7070
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTextView.java
@@ -0,0 +1,175 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.TextView;
+import java.io.PrintStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(TextView.class)
+public class ShadowTextView extends ShadowView {
+  @RealObject TextView realTextView;
+
+  private TextView.OnEditorActionListener onEditorActionListener;
+  private int textAppearanceId;
+  protected int selectionStart = -1;
+  protected int selectionEnd = -1;
+
+  private List<TextWatcher> watchers = new ArrayList<>();
+  private List<Integer> previousKeyCodes = new ArrayList<>();
+  private List<KeyEvent> previousKeyEvents = new ArrayList<>();
+  private int compoundDrawablesWithIntrinsicBoundsLeft;
+  private int compoundDrawablesWithIntrinsicBoundsTop;
+  private int compoundDrawablesWithIntrinsicBoundsRight;
+  private int compoundDrawablesWithIntrinsicBoundsBottom;
+
+  @Implementation
+  protected void setTextAppearance(Context context, int resid) {
+    textAppearanceId = resid;
+    reflector(TextViewReflector.class, realTextView).setTextAppearance(context, resid);
+  }
+
+  @Implementation
+  protected boolean onKeyDown(int keyCode, KeyEvent event) {
+    previousKeyCodes.add(keyCode);
+    previousKeyEvents.add(event);
+    return reflector(TextViewReflector.class, realTextView).onKeyDown(keyCode, event);
+  }
+
+  @Implementation
+  protected boolean onKeyUp(int keyCode, KeyEvent event) {
+    previousKeyCodes.add(keyCode);
+    previousKeyEvents.add(event);
+    return reflector(TextViewReflector.class, realTextView).onKeyUp(keyCode, event);
+  }
+
+  public int getPreviousKeyCode(int index) {
+    return previousKeyCodes.get(index);
+  }
+
+  public KeyEvent getPreviousKeyEvent(int index) {
+    return previousKeyEvents.get(index);
+  }
+
+  /**
+   * Returns the text string of this {@code TextView}.
+   *
+   * Robolectric extension.
+   */
+  @Override
+  public String innerText() {
+    CharSequence text = realTextView.getText();
+    return (text == null || realTextView.getVisibility() != View.VISIBLE) ? "" : text.toString();
+  }
+
+  public int getTextAppearanceId() {
+    return textAppearanceId;
+  }
+
+  @Implementation
+  protected void addTextChangedListener(TextWatcher watcher) {
+    this.watchers.add(watcher);
+    reflector(TextViewReflector.class, realTextView).addTextChangedListener(watcher);
+  }
+
+  @Implementation
+  protected void removeTextChangedListener(TextWatcher watcher) {
+    this.watchers.remove(watcher);
+    reflector(TextViewReflector.class, realTextView).removeTextChangedListener(watcher);
+  }
+
+  /**
+   * @return the list of currently registered watchers/listeners
+   */
+  public List<TextWatcher> getWatchers() {
+    return watchers;
+  }
+
+  @HiddenApi @Implementation
+  public Locale getTextServicesLocale() {
+    return Locale.getDefault();
+  }
+
+  @Override
+  protected void dumpAttributes(PrintStream out) {
+    super.dumpAttributes(out);
+    CharSequence text = realTextView.getText();
+    if (text != null && text.length() > 0) {
+      dumpAttribute(out, "text", text.toString());
+    }
+  }
+
+  @Implementation
+  protected void setOnEditorActionListener(TextView.OnEditorActionListener l) {
+    this.onEditorActionListener = l;
+    reflector(TextViewReflector.class, realTextView).setOnEditorActionListener(l);
+  }
+
+  public TextView.OnEditorActionListener getOnEditorActionListener() {
+    return onEditorActionListener;
+  }
+
+  @Implementation
+  protected void setCompoundDrawablesWithIntrinsicBounds(int left, int top, int right, int bottom) {
+    this.compoundDrawablesWithIntrinsicBoundsLeft = left;
+    this.compoundDrawablesWithIntrinsicBoundsTop = top;
+    this.compoundDrawablesWithIntrinsicBoundsRight = right;
+    this.compoundDrawablesWithIntrinsicBoundsBottom = bottom;
+    reflector(TextViewReflector.class, realTextView)
+        .setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
+  }
+
+  public int getCompoundDrawablesWithIntrinsicBoundsLeft() {
+    return compoundDrawablesWithIntrinsicBoundsLeft;
+  }
+
+  public int getCompoundDrawablesWithIntrinsicBoundsTop() {
+    return compoundDrawablesWithIntrinsicBoundsTop;
+  }
+
+  public int getCompoundDrawablesWithIntrinsicBoundsRight() {
+    return compoundDrawablesWithIntrinsicBoundsRight;
+  }
+
+  public int getCompoundDrawablesWithIntrinsicBoundsBottom() {
+    return compoundDrawablesWithIntrinsicBoundsBottom;
+  }
+
+  @ForType(TextView.class)
+  interface TextViewReflector {
+
+    @Direct
+    void setTextAppearance(Context context, int resid);
+
+    @Direct
+    boolean onKeyDown(int keyCode, KeyEvent event);
+
+    @Direct
+    boolean onKeyUp(int keyCode, KeyEvent event);
+
+    @Direct
+    void addTextChangedListener(TextWatcher watcher);
+
+    @Direct
+    void removeTextChangedListener(TextWatcher watcher);
+
+    @Direct
+    void setOnEditorActionListener(TextView.OnEditorActionListener l);
+
+    @Direct
+    void setCompoundDrawablesWithIntrinsicBounds(int left, int top, int right, int bottom);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowThreadedRenderer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowThreadedRenderer.java
new file mode 100644
index 0000000..7675c99
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowThreadedRenderer.java
@@ -0,0 +1,37 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(
+    className = "android.view.ThreadedRenderer",
+    isInAndroidSdk = false,
+    looseSignatures = true,
+    minSdk = O,
+    maxSdk = P)
+public class ShadowThreadedRenderer {
+
+  @Implementation
+  protected static Bitmap createHardwareBitmap(
+      /*RenderNode*/ Object node, /*int*/ Object width, /*int*/ Object height) {
+    return createHardwareBitmap((int) width, (int) height);
+  }
+
+  private static Bitmap createHardwareBitmap(int width, int height) {
+    Bitmap bitmap = Bitmap.createBitmap(width, height, Config.HARDWARE);
+    ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
+    shadowBitmap.setMutable(false);
+    return bitmap;
+  }
+
+  @Implementation
+  protected static long nCreateTextureLayer(long nativeProxy) {
+    return ShadowVirtualRefBasePtr.put(nativeProxy);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTile.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTile.java
new file mode 100644
index 0000000..df1da4f
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTile.java
@@ -0,0 +1,13 @@
+package org.robolectric.shadows;
+
+import android.os.Build;
+import android.service.quicksettings.Tile;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = Tile.class, minSdk = Build.VERSION_CODES.N)
+public final class ShadowTile {
+
+  @Implementation
+  protected void updateTile() {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTileService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTileService.java
new file mode 100644
index 0000000..74c31fb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTileService.java
@@ -0,0 +1,53 @@
+package org.robolectric.shadows;
+
+import android.content.Intent;
+import android.os.Build;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(value = TileService.class, minSdk = Build.VERSION_CODES.N)
+public class ShadowTileService {
+
+  private Tile tile;
+  private boolean isLocked = false;
+  @RealObject private TileService realObject;
+
+  @Implementation
+  protected final Tile getQsTile() {
+    if (tile == null) {
+      tile = createTile();
+    }
+    return tile;
+  }
+
+  @Implementation
+  protected final void unlockAndRun(Runnable runnable) {
+    setLocked(false);
+    if (runnable != null) {
+      runnable.run();
+    }
+  }
+
+  /** Starts an activity without collapsing the quick settings panel. */
+  @Implementation
+  protected void startActivityAndCollapse(Intent intent) {
+    realObject.startActivity(intent);
+  }
+
+  @Implementation
+  protected boolean isLocked() {
+    return isLocked;
+  }
+
+  public void setLocked(boolean locked) {
+    this.isLocked = locked;
+  }
+
+  private static Tile createTile() {
+    return Shadow.newInstanceOf(Tile.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTime.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTime.java
new file mode 100644
index 0000000..df76150
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTime.java
@@ -0,0 +1,427 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.os.SystemClock;
+import android.text.format.Time;
+import android.util.TimeFormatException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.Strftime;
+
+@Implements(value = Time.class)
+public class ShadowTime {
+  @RealObject private Time time;
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void setToNow() {
+    time.set(SystemClock.currentThreadTimeMillis());
+  }
+
+  private static final long SECOND_IN_MILLIS = 1000;
+  private static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
+  private static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
+  private static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void __constructor__() {
+    __constructor__(getCurrentTimezone());
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void __constructor__(String timezone) {
+    if (timezone == null) {
+      throw new NullPointerException("timezone is null!");
+    }
+    time.timezone = timezone;
+    time.year = 1970;
+    time.monthDay = 1;
+    time.isDst = -1;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void __constructor__(Time other) {
+    set(other);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void set(Time other) {
+    time.timezone = other.timezone;
+    time.second = other.second;
+    time.minute = other.minute;
+    time.hour = other.hour;
+    time.monthDay = other.monthDay;
+    time.month = other.month;
+    time.year = other.year;
+    time.weekDay = other.weekDay;
+    time.yearDay = other.yearDay;
+    time.isDst = other.isDst;
+    time.gmtoff = other.gmtoff;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static boolean isEpoch(Time time) {
+    long millis = time.toMillis(true);
+    return getJulianDay(millis, 0) == Time.EPOCH_JULIAN_DAY;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int getJulianDay(long millis, long gmtoff) {
+    long offsetMillis = gmtoff * 1000;
+    long julianDay = (millis + offsetMillis) / DAY_IN_MILLIS;
+    return (int) julianDay + Time.EPOCH_JULIAN_DAY;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected long setJulianDay(int julianDay) {
+    // Don't bother with the GMT offset since we don't know the correct
+    // value for the given Julian day.  Just get close and then adjust
+    // the day.
+    // long millis = (julianDay - EPOCH_JULIAN_DAY) * DateUtils.DAY_IN_MILLIS;
+    long millis = (julianDay - Time.EPOCH_JULIAN_DAY) * DAY_IN_MILLIS;
+    set(millis);
+
+    // Figure out how close we are to the requested Julian day.
+    // We can't be off by more than a day.
+    int approximateDay = getJulianDay(millis, time.gmtoff);
+    int diff = julianDay - approximateDay;
+    time.monthDay += diff;
+
+    // Set the time to 12am and re-normalize.
+    time.hour = 0;
+    time.minute = 0;
+    time.second = 0;
+    millis = time.normalize(true);
+    return millis;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void set(long millis) {
+    Calendar c = getCalendar();
+    c.setTimeInMillis(millis);
+    set(
+        c.get(Calendar.SECOND),
+        c.get(Calendar.MINUTE),
+        c.get(Calendar.HOUR_OF_DAY),
+        c.get(Calendar.DAY_OF_MONTH),
+        c.get(Calendar.MONTH),
+        c.get(Calendar.YEAR));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected long toMillis(boolean ignoreDst) {
+    Calendar c = getCalendar();
+    return c.getTimeInMillis();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void set(int second, int minute, int hour, int monthDay, int month, int year) {
+    time.second = second;
+    time.minute = minute;
+    time.hour = hour;
+    time.monthDay = monthDay;
+    time.month = month;
+    time.year = year;
+    time.weekDay = 0;
+    time.yearDay = 0;
+    time.isDst = -1;
+    time.gmtoff = 0;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void set(int monthDay, int month, int year) {
+    set(0, 0, 0, monthDay, month, year);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void clear(String timezone) {
+    if (timezone == null) {
+      throw new NullPointerException("timezone is null!");
+    }
+    time.timezone = timezone;
+    time.allDay = false;
+    time.second = 0;
+    time.minute = 0;
+    time.hour = 0;
+    time.monthDay = 0;
+    time.month = 0;
+    time.year = 0;
+    time.weekDay = 0;
+    time.yearDay = 0;
+    time.gmtoff = 0;
+    time.isDst = -1;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static String getCurrentTimezone() {
+    return TimeZone.getDefault().getID();
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected void switchTimezone(String timezone) {
+    long date = toMillis(true);
+    long gmtoff = TimeZone.getTimeZone(timezone).getOffset(date);
+    set(date + gmtoff);
+    time.timezone = timezone;
+    time.gmtoff = (gmtoff / 1000);
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected static int compare(Time a, Time b) {
+    long ams = a.toMillis(false);
+    long bms = b.toMillis(false);
+    if (ams == bms) {
+      return 0;
+    } else if (ams < bms) {
+      return -1;
+    } else {
+      return 1;
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected boolean before(Time other) {
+    return Time.compare(time, other) < 0;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected boolean after(Time other) {
+    return Time.compare(time, other) > 0;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected boolean parse(String timeString) {
+    TimeZone tz;
+    if (timeString.endsWith("Z")) {
+      timeString = timeString.substring(0, timeString.length() - 1);
+      tz = TimeZone.getTimeZone("UTC");
+    } else {
+      tz = TimeZone.getTimeZone(time.timezone);
+    }
+    SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH);
+    SimpleDateFormat dfShort = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH);
+    df.setTimeZone(tz);
+    dfShort.setTimeZone(tz);
+    time.timezone = tz.getID();
+    try {
+      set(df.parse(timeString).getTime());
+    } catch (ParseException e) {
+      try {
+        set(dfShort.parse(timeString).getTime());
+      } catch (ParseException e2) {
+        throwTimeFormatException(e2.getLocalizedMessage());
+      }
+    }
+    return "UTC".equals(tz.getID());
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected String format2445() {
+    String value = format("%Y%m%dT%H%M%S");
+    if ("UTC".equals(time.timezone)) {
+      value += "Z";
+    }
+    return value;
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected String format3339(boolean allDay) {
+    if (allDay) {
+      return format("%Y-%m-%d");
+    } else if ("UTC".equals(time.timezone)) {
+      return format("%Y-%m-%dT%H:%M:%S.000Z");
+    } else {
+      String base = format("%Y-%m-%dT%H:%M:%S.000");
+      String sign = (time.gmtoff < 0) ? "-" : "+";
+      int offset = (int) Math.abs(time.gmtoff);
+      int minutes = (offset % 3600) / 60;
+      int hours = offset / 3600;
+      return String.format("%s%s%02d:%02d", base, sign, hours, minutes);
+    }
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected boolean nativeParse3339(String s) {
+    // In lollipop, the native implementation was replaced with java
+    // this is a copy of the aosp-pie implementation
+    int len = s.length();
+    if (len < 10) {
+      throwTimeFormatException("String too short --- expected at least 10 characters.");
+    }
+    boolean inUtc = false;
+
+    // year
+    int n = getChar(s, 0, 1000);
+    n += getChar(s, 1, 100);
+    n += getChar(s, 2, 10);
+    n += getChar(s, 3, 1);
+    time.year = n;
+
+    checkChar(s, 4, '-');
+
+    // month
+    n = getChar(s, 5, 10);
+    n += getChar(s, 6, 1);
+    --n;
+    time.month = n;
+
+    checkChar(s, 7, '-');
+
+    // day
+    n = getChar(s, 8, 10);
+    n += getChar(s, 9, 1);
+    time.monthDay = n;
+
+    if (len >= 19) {
+      // T
+      checkChar(s, 10, 'T');
+      time.allDay = false;
+
+      // hour
+      n = getChar(s, 11, 10);
+      n += getChar(s, 12, 1);
+
+      // Note that this.hour is not set here. It is set later.
+      int hour = n;
+
+      checkChar(s, 13, ':');
+
+      // minute
+      n = getChar(s, 14, 10);
+      n += getChar(s, 15, 1);
+      // Note that this.minute is not set here. It is set later.
+      int minute = n;
+
+      checkChar(s, 16, ':');
+
+      // second
+      n = getChar(s, 17, 10);
+      n += getChar(s, 18, 1);
+      time.second = n;
+
+      // skip the '.XYZ' -- we don't care about subsecond precision.
+
+      int tzIndex = 19;
+      if (tzIndex < len && s.charAt(tzIndex) == '.') {
+        do {
+          tzIndex++;
+        } while (tzIndex < len && Character.isDigit(s.charAt(tzIndex)));
+      }
+
+      int offset = 0;
+      if (len > tzIndex) {
+        char c = s.charAt(tzIndex);
+        // NOTE: the offset is meant to be subtracted to get from local time
+        // to UTC.  we therefore use 1 for '-' and -1 for '+'.
+        switch (c) {
+          case 'Z':
+            // Zulu time -- UTC
+            offset = 0;
+            break;
+          case '-':
+            offset = 1;
+            break;
+          case '+':
+            offset = -1;
+            break;
+          default:
+            throwTimeFormatException(
+                String.format(
+                    "Unexpected character 0x%02d at position %d.  Expected + or -",
+                    (int) c, tzIndex));
+        }
+        inUtc = true;
+
+        if (offset != 0) {
+          if (len < tzIndex + 6) {
+            throwTimeFormatException(
+                String.format("Unexpected length; should be %d characters", tzIndex + 6));
+          }
+
+          // hour
+          n = getChar(s, tzIndex + 1, 10);
+          n += getChar(s, tzIndex + 2, 1);
+          n *= offset;
+          hour += n;
+
+          // minute
+          n = getChar(s, tzIndex + 4, 10);
+          n += getChar(s, tzIndex + 5, 1);
+          n *= offset;
+          minute += n;
+        }
+      }
+      time.hour = hour;
+      time.minute = minute;
+
+      if (offset != 0) {
+        time.normalize(false);
+      }
+    } else {
+      time.allDay = true;
+      time.hour = 0;
+      time.minute = 0;
+      time.second = 0;
+    }
+
+    time.weekDay = 0;
+    time.yearDay = 0;
+    time.isDst = -1;
+    time.gmtoff = 0;
+    return inUtc;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static int getChar(String s, int spos, int mul) {
+    char c = s.charAt(spos);
+    if (Character.isDigit(c)) {
+      return Character.getNumericValue(c) * mul;
+    } else {
+      throwTimeFormatException("Parse error at pos=" + spos);
+    }
+    return -1;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected void checkChar(String s, int spos, char expected) {
+    char c = s.charAt(spos);
+    if (c != expected) {
+      throwTimeFormatException(
+          String.format(
+              "Unexpected character 0x%02d at pos=%d.  Expected 0x%02d (\'%c\').",
+              (int) c, spos, (int) expected, expected));
+    }
+  }
+
+  private static void throwTimeFormatException(String optionalMessage) {
+    throw ReflectionHelpers.callConstructor(
+        TimeFormatException.class,
+        ReflectionHelpers.ClassParameter.from(
+            String.class, optionalMessage == null ? "fail" : optionalMessage));
+  }
+
+  @Implementation(maxSdk = KITKAT_WATCH)
+  protected String format(String format) {
+    return Strftime.format(
+        format,
+        new Date(toMillis(false)),
+        Locale.getDefault(),
+        TimeZone.getTimeZone(time.timezone));
+  }
+
+  private Calendar getCalendar() {
+    Calendar c = Calendar.getInstance(TimeZone.getTimeZone(time.timezone));
+    c.set(time.year, time.month, time.monthDay, time.hour, time.minute, time.second);
+    c.set(Calendar.MILLISECOND, 0);
+    return c;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeManager.java
new file mode 100644
index 0000000..7a77328
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeManager.java
@@ -0,0 +1,76 @@
+package org.robolectric.shadows;
+
+import android.annotation.SystemApi;
+import android.app.time.Capabilities;
+import android.app.time.Capabilities.CapabilityState;
+import android.app.time.ExternalTimeSuggestion;
+import android.app.time.TimeManager;
+import android.app.time.TimeZoneCapabilities;
+import android.app.time.TimeZoneCapabilitiesAndConfig;
+import android.app.time.TimeZoneConfiguration;
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for internal Android {@code TimeManager} class introduced in S. */
+@Implements(value = TimeManager.class, minSdk = VERSION_CODES.S, isInAndroidSdk = false)
+public class ShadowTimeManager {
+
+  public static final String CONFIGURE_GEO_DETECTION_CAPABILITY =
+      "configure_geo_detection_capability";
+
+  private TimeZoneCapabilities timeZoneCapabilities =
+      new TimeZoneCapabilities.Builder(UserHandle.CURRENT)
+          .setConfigureAutoDetectionEnabledCapability(Capabilities.CAPABILITY_POSSESSED)
+          .setConfigureGeoDetectionEnabledCapability(Capabilities.CAPABILITY_POSSESSED)
+          .setSuggestManualTimeZoneCapability(Capabilities.CAPABILITY_POSSESSED)
+          .build();
+
+  private TimeZoneConfiguration timeZoneConfiguration;
+
+  /**
+   * Capabilites are predefined and not controlled by user, so they can't be changed via TimeManager
+   * API.
+   */
+  public void setCapabilityState(String capability, @CapabilityState int value) {
+    TimeZoneCapabilities.Builder builder = new TimeZoneCapabilities.Builder(timeZoneCapabilities);
+
+    switch (capability) {
+      case CONFIGURE_GEO_DETECTION_CAPABILITY:
+        builder.setConfigureGeoDetectionEnabledCapability(value);
+        break;
+      default:
+        throw new IllegalArgumentException("Unrecognized capability=" + capability);
+    }
+
+    this.timeZoneCapabilities = builder.build();
+  }
+
+  @Implementation
+  @SystemApi
+  protected TimeZoneCapabilitiesAndConfig getTimeZoneCapabilitiesAndConfig() {
+    Objects.requireNonNull(timeZoneConfiguration, "timeZoneConfiguration was not set");
+
+    return new TimeZoneCapabilitiesAndConfig(timeZoneCapabilities, timeZoneConfiguration);
+  }
+
+  @Implementation
+  @SystemApi
+  protected boolean updateTimeZoneConfiguration(TimeZoneConfiguration configuration) {
+    this.timeZoneConfiguration = configuration;
+    return true;
+  }
+
+  @Implementation
+  protected void addTimeZoneDetectorListener(
+      Executor executor, TimeManager.TimeZoneDetectorListener listener) {}
+
+  @Implementation
+  protected void removeTimeZoneDetectorListener(TimeManager.TimeZoneDetectorListener listener) {}
+
+  @Implementation
+  protected void suggestExternalTime(ExternalTimeSuggestion timeSuggestion) {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimePickerDialog.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimePickerDialog.java
new file mode 100644
index 0000000..0c06e89
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimePickerDialog.java
@@ -0,0 +1,41 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.TimePickerDialog;
+import android.widget.TimePicker;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = TimePickerDialog.class)
+public class ShadowTimePickerDialog extends ShadowAlertDialog {
+  @RealObject
+  protected TimePickerDialog realTimePickerDialog;
+
+  public int getHourOfDay() {
+    return reflector(TimePickerDialogProvider.class, realTimePickerDialog)
+        .getTimePicker()
+        .getCurrentHour();
+  }
+
+  public int getMinute() {
+    return reflector(TimePickerDialogProvider.class, realTimePickerDialog)
+        .getTimePicker()
+        .getCurrentMinute();
+  }
+
+  public boolean getIs24HourView() {
+    return reflector(TimePickerDialogProvider.class, realTimePickerDialog)
+        .getTimePicker()
+        .is24HourView();
+  }
+
+  @ForType(TimePickerDialog.class)
+  interface TimePickerDialogProvider {
+
+    @Accessor("mTimePicker")
+    TimePicker getTimePicker();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinder.java
new file mode 100644
index 0000000..926ae6e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinder.java
@@ -0,0 +1,65 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow of TimeZoneFinder for Android O and P. */
+@Implements(
+    className = "libcore.util.TimeZoneFinder",
+    minSdk = O,
+    maxSdk = P,
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowTimeZoneFinder {
+
+  private static final String TZLOOKUP_PATH = "/usr/share/zoneinfo/tzlookup.xml";
+
+  @Implementation
+  protected static Object getInstance() {
+    try {
+      return ReflectionHelpers.callStaticMethod(
+          Class.forName("libcore.util.TimeZoneFinder"),
+          "createInstanceForTests",
+          ClassParameter.from(String.class, readTzlookup()));
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Reads tzlookup.xml from the files bundled inside android-all JARs. We need to read the file
+   * instead of passing in the path because the real implementation uses {@link java.nio.file.Paths}
+   * which doesn't support reading from JARs.
+   */
+  public static String readTzlookup() {
+    StringBuilder stringBuilder = new StringBuilder();
+    InputStream is = null;
+    try {
+      try {
+        is = ShadowTimeZoneFinder.class.getResourceAsStream(TZLOOKUP_PATH);
+        BufferedReader reader = new BufferedReader(new InputStreamReader(is, UTF_8));
+        for (String line; (line = reader.readLine()) != null; ) {
+          stringBuilder.append(line);
+        }
+      } finally {
+        if (is != null) {
+          is.close();
+        }
+      }
+    } catch (IOException e) {
+      // ignore
+    }
+
+    return stringBuilder.toString();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinderQ.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinderQ.java
new file mode 100644
index 0000000..97134bd
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinderQ.java
@@ -0,0 +1,32 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.shadows.ShadowTimeZoneFinder.readTzlookup;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow for TimeZoneFinder on Q after Developer Preview 1. */
+@Implements(
+    className = "libcore.timezone.TimeZoneFinder",
+    minSdk = Q,
+    maxSdk = R,
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowTimeZoneFinderQ {
+
+  @Implementation
+  protected static Object getInstance() {
+    try {
+      return ReflectionHelpers.callStaticMethod(
+          Class.forName("libcore.timezone.TimeZoneFinder"),
+          "createInstanceForTests",
+          ClassParameter.from(String.class, readTzlookup()));
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinderS.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinderS.java
new file mode 100644
index 0000000..f28a12e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTimeZoneFinderS.java
@@ -0,0 +1,55 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.android.i18n.timezone.TimeZoneFinder;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for TimeZoneFinder on S or above. */
+@Implements(
+    value = TimeZoneFinder.class,
+    minSdk = S,
+    isInAndroidSdk = false,
+    looseSignatures = true)
+public class ShadowTimeZoneFinderS {
+
+  private static final String TZLOOKUP_PATH = "/usr/share/zoneinfo/tzlookup.xml";
+
+  @Implementation
+  protected static Object getInstance() {
+    return TimeZoneFinder.createInstanceForTests(readTzlookup());
+  }
+
+  /**
+   * Reads tzlookup.xml from the files bundled inside android-all JARs. We need to read the file
+   * instead of passing in the path because the real implementation uses {@link java.nio.file.Paths}
+   * which doesn't support reading from JARs.
+   */
+  private static String readTzlookup() {
+    StringBuilder stringBuilder = new StringBuilder();
+    InputStream is = null;
+    try {
+      try {
+        is = ShadowTimeZoneFinder.class.getResourceAsStream(TZLOOKUP_PATH);
+        BufferedReader reader = new BufferedReader(new InputStreamReader(is, UTF_8));
+        for (String line; (line = reader.readLine()) != null; ) {
+          stringBuilder.append(line);
+        }
+      } finally {
+        if (is != null) {
+          is.close();
+        }
+      }
+    } catch (IOException e) {
+      // ignore
+    }
+
+    return stringBuilder.toString();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowToast.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowToast.java
new file mode 100644
index 0000000..af095b4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowToast.java
@@ -0,0 +1,212 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Toast.class)
+public class ShadowToast {
+  private String text;
+  private int duration;
+  private int gravity;
+  private int xOffset;
+  private int yOffset;
+  private View view;
+  private boolean cancelled;
+
+  @RealObject Toast toast;
+
+  @Implementation
+  protected void __constructor__(Context context) {}
+
+  @Implementation
+  protected static Toast makeText(Context context, int resId, int duration) {
+    return makeText(context, context.getResources().getString(resId), duration);
+  }
+
+  @Implementation
+  protected static Toast makeText(Context context, CharSequence text, int duration) {
+    Toast toast = new Toast(context);
+    toast.setDuration(duration);
+    ShadowToast shadowToast = Shadow.extract(toast);
+    shadowToast.text = text.toString();
+    return toast;
+  }
+
+  @Implementation
+  protected void show() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    shadowApplication.getShownToasts().add(toast);
+  }
+
+  @Implementation
+  protected void setText(int resId) {
+    this.text = RuntimeEnvironment.getApplication().getString(resId);
+  }
+
+  @Implementation
+  protected void setText(CharSequence text) {
+    this.text = text.toString();
+  }
+
+  @Implementation
+  protected void setView(View view) {
+    this.view = view;
+  }
+
+  @Implementation
+  protected View getView() {
+    return view;
+  }
+
+  @Implementation
+  protected void setGravity(int gravity, int xOffset, int yOffset) {
+    this.gravity = gravity;
+    this.xOffset = xOffset;
+    this.yOffset = yOffset;
+  }
+
+  @Implementation
+  protected int getGravity() {
+    return gravity;
+  }
+
+  @Implementation
+  protected int getXOffset() {
+    return xOffset;
+  }
+
+  @Implementation
+  protected int getYOffset() {
+    return yOffset;
+  }
+
+  @Implementation
+  protected void setDuration(int duration) {
+    this.duration = duration;
+  }
+
+  @Implementation
+  protected int getDuration() {
+    return duration;
+  }
+
+  @Implementation
+  protected void cancel() {
+    cancelled = true;
+  }
+
+  public boolean isCancelled() {
+    return cancelled;
+  }
+
+  /**
+   * Discards the recorded {@code Toast}s. Shown toasts are automatically cleared between
+   * tests. This method allows the user to discard recorded toasts during the test in order to make assertions clearer
+   * e.g:
+   *
+   * <pre>
+   *
+   *   // Show a single toast
+   *   myClass.showToast();
+   *
+   *   assertThat(ShadowToast.shownToastCount()).isEqualTo(1);
+   *   ShadowToast.reset();
+   *
+   *    // Show another toast
+   *   myClass.showToast();
+   *
+   *   assertThat(ShadowToast.shownToastCount()).isEqualTo(1);
+   *
+   * </pre>
+   */
+  public static void reset() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    shadowApplication.getShownToasts().clear();
+  }
+
+  /**
+   * Returns the number of {@code Toast} requests that have been made during this test run
+   * or since {@link #reset()} has been called.
+   *
+   * @return the number of {@code Toast} requests that have been made during this test run
+   *         or since {@link #reset()} has been called.
+   */
+  public static int shownToastCount() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    return shadowApplication.getShownToasts().size();
+  }
+
+  /**
+   * Returns whether or not a particular custom {@code Toast} has been shown.
+   *
+   * @param message the message to search for
+   * @param layoutResourceIdToCheckForMessage
+   *                the id of the resource that contains the toast messages
+   * @return whether the {@code Toast} was requested
+   */
+  public static boolean showedCustomToast(CharSequence message, int layoutResourceIdToCheckForMessage) {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    for (Toast toast : shadowApplication.getShownToasts()) {
+      String text = ((TextView) toast.getView().findViewById(layoutResourceIdToCheckForMessage)).getText().toString();
+      if (text.equals(message.toString())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * query method that returns whether or not a particular {@code Toast} has been shown.
+   *
+   * @param message the message to search for
+   * @return whether the {@code Toast} was requested
+   */
+  public static boolean showedToast(CharSequence message) {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    for (Toast toast : shadowApplication.getShownToasts()) {
+      ShadowToast shadowToast = Shadow.extract(toast);
+      String text = shadowToast.text;
+      if (text != null && text.equals(message.toString())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Returns the text of the most recently shown {@code Toast}.
+   *
+   * @return the text of the most recently shown {@code Toast}
+   */
+  public static String getTextOfLatestToast() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    List<Toast> shownToasts = shadowApplication.getShownToasts();
+    if (shownToasts.isEmpty()) {
+      return null;
+    } else {
+      Toast latestToast = shownToasts.get(shownToasts.size() - 1);
+      ShadowToast shadowToast = Shadow.extract(latestToast);
+      return shadowToast.text;
+    }
+  }
+
+  /**
+   * Returns the most recently shown {@code Toast}.
+   *
+   * @return the most recently shown {@code Toast}
+   */
+  public static Toast getLatestToast() {
+    ShadowApplication shadowApplication = Shadow.extract(RuntimeEnvironment.getApplication());
+    List<Toast> shownToasts = shadowApplication.getShownToasts();
+    return (shownToasts.size() == 0) ? null : shownToasts.get(shownToasts.size() - 1);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowToneGenerator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowToneGenerator.java
new file mode 100644
index 0000000..f244976
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowToneGenerator.java
@@ -0,0 +1,89 @@
+package org.robolectric.shadows;
+
+import android.media.ToneGenerator;
+import androidx.annotation.VisibleForTesting;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Shadow of ToneGenerator.
+ *
+ * <p>Records all tones that were passed to the class.
+ *
+ * <p>This class uses _static_ state to store the tones that were passed to it. This is because
+ * users of the original class are expected to instantiate new instances of ToneGenerator on demand
+ * and clean up the instance after use. This makes it messy to grab the correct instance of
+ * ToneGenerator to properly shadow.
+ *
+ * <p>Additionally, there is a maximum number of tones that this class can support. Tones are stored
+ * in a first-in-first-out basis.
+ */
+@Implements(value = ToneGenerator.class)
+public class ShadowToneGenerator {
+  // A maximum value is required to avoid OOM errors
+  // The number chosen here is arbitrary but should be reasonable for any use case of this class
+  @VisibleForTesting static final int MAXIMUM_STORED_TONES = 2000;
+
+  // This is static because using ToneGenerator has the object created or destroyed on demand.
+  // This makes it very difficult for a test to get access the appropriate instance of
+  // toneGenerator.
+  // The list offers a record of all tones that have been started.
+  // The list has a maximum size of MAXIMUM_STORED_TONES to avoid OOM errors
+  private static final Deque<Tone> playedTones = new ArrayDeque<>();
+
+  /**
+   * This method will intercept calls to startTone and record the played tone into a static list.
+   *
+   * <p>Note in the original {@link ToneGenerator}, this function will start a tone. Subsequent
+   * calls to this function will cancel the currently playing tone and play a new tone instead.
+   * Since no tone is actually played and no process is started, this tone cannot be interrupted.
+   */
+  @Implementation
+  protected boolean startTone(int toneType, int durationMs) {
+    playedTones.add(Tone.create(toneType, Duration.ofMillis(durationMs)));
+    if (playedTones.size() > MAXIMUM_STORED_TONES) {
+      playedTones.removeFirst();
+    }
+
+    return true;
+  }
+
+  /**
+   * This function returns the list of tones that the application requested to be played. Note that
+   * this will return all tones requested by all ToneGenerators.
+   *
+   * @return A defensive copy of the list of tones played by all tone generators.
+   */
+  public static ImmutableList<Tone> getPlayedTones() {
+    return ImmutableList.copyOf(playedTones);
+  }
+
+  @Resetter
+  public static void reset() {
+    playedTones.clear();
+  }
+
+  /** Stores data about a tone played by the ToneGenerator */
+  @AutoValue
+  public abstract static class Tone {
+
+    /**
+     * The type of the tone.
+     *
+     * @see ToneGenerator for a list of possible tones
+     */
+    public abstract int type();
+
+    public abstract Duration duration();
+
+    static Tone create(int type, Duration duration) {
+      return new AutoValue_ShadowToneGenerator_Tone(type, duration);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTotalCaptureResult.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTotalCaptureResult.java
new file mode 100644
index 0000000..efebb85
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTotalCaptureResult.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows;
+
+import android.hardware.camera2.TotalCaptureResult;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow of {@link TotalCaptureResult}. */
+@Implements(value = TotalCaptureResult.class, minSdk = VERSION_CODES.LOLLIPOP)
+public class ShadowTotalCaptureResult extends ShadowCaptureResult {
+
+  /** Convenience method which returns a new instance of {@link TotalCaptureResult}. */
+  public static TotalCaptureResult newTotalCaptureResult() {
+    return ReflectionHelpers.callConstructor(TotalCaptureResult.class);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTouchDelegate.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTouchDelegate.java
new file mode 100644
index 0000000..429f2d6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTouchDelegate.java
@@ -0,0 +1,35 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+
+import android.graphics.Rect;
+import android.view.TouchDelegate;
+import android.view.View;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(TouchDelegate.class)
+public class ShadowTouchDelegate {
+  @RealObject private TouchDelegate realObject;
+  private Rect bounds;
+  private View delegateView;
+
+  @Implementation
+  protected void __constructor__(Rect bounds, View delegateView) {
+    this.bounds = bounds;
+    this.delegateView = delegateView;
+    invokeConstructor(TouchDelegate.class, realObject,
+        ClassParameter.from(Rect.class, bounds),
+        ClassParameter.from(View.class, delegateView));
+  }
+
+  public Rect getBounds() {
+    return this.bounds;
+  }
+
+  public View getDelegateView() {
+    return this.delegateView;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTrace.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTrace.java
new file mode 100644
index 0000000..16e5015
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTrace.java
@@ -0,0 +1,253 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.Q;
+import static com.google.common.base.Verify.verifyNotNull;
+
+import android.os.Trace;
+import android.util.Log;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.function.Supplier;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Shadow implementation for {@link Trace}, which stores the traces locally in arrays (unlike the
+ * real implementation) and allows reading them.
+ */
+@Implements(Trace.class)
+public class ShadowTrace {
+  private static final String TAG = "ShadowTrace";
+
+  private static final ThreadLocal<Deque<String>> currentSections =
+      ThreadLocal.withInitial(() -> new ArrayDeque<>());
+
+  private static final ThreadLocal<Queue<String>> previousSections =
+      ThreadLocal.withInitial((Supplier<Deque<String>>) () -> new ArrayDeque<>());
+
+  private static final Set<AsyncTraceSection> currentAsyncSections = new HashSet<>();
+
+  private static final Set<AsyncTraceSection> previousAsyncSections = new HashSet<>();
+
+  private static final List<Counter> counters = new ArrayList<>();
+
+  private static final boolean CRASH_ON_INCORRECT_USAGE_DEFAULT = true;
+  private static boolean crashOnIncorrectUsage = CRASH_ON_INCORRECT_USAGE_DEFAULT;
+  private static boolean isEnabled = true;
+
+  private static final long TRACE_TAG_APP = 1L << 12;
+  private static final int MAX_SECTION_NAME_LEN = 127;
+
+  private static long tags = TRACE_TAG_APP;
+
+  /** Starts a new trace section with given name. */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static void beginSection(String sectionName) {
+    if (tags == 0) {
+      return;
+    }
+    if (!checkValidSectionName(sectionName)) {
+      return;
+    }
+    currentSections.get().addFirst(sectionName);
+  }
+
+  /** Ends the most recent active trace section. */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static void endSection() {
+    if (tags == 0) {
+      return;
+    }
+    if (currentSections.get().isEmpty()) {
+      Log.e(TAG, "Trying to end a trace section that was never started");
+      return;
+    }
+    previousSections.get().offer(currentSections.get().removeFirst());
+  }
+
+  /** Starts a new async trace section with given name. */
+  @Implementation(minSdk = Q)
+  protected static synchronized void beginAsyncSection(String sectionName, int cookie) {
+    if (tags == 0) {
+      return;
+    }
+    if (!checkValidSectionName(sectionName)) {
+      return;
+    }
+    AsyncTraceSection newSection =
+        AsyncTraceSection.newBuilder().setSectionName(sectionName).setCookie(cookie).build();
+    if (currentAsyncSections.contains(newSection)) {
+      if (crashOnIncorrectUsage) {
+        throw new IllegalStateException("Section is already running");
+      }
+      Log.w(TAG, "Section is already running");
+      return;
+    }
+    currentAsyncSections.add(newSection);
+  }
+
+  /** Ends async trace trace section. */
+  @Implementation(minSdk = Q)
+  protected static synchronized void endAsyncSection(String sectionName, int cookie) {
+    if (tags == 0) {
+      return;
+    }
+    AsyncTraceSection section =
+        AsyncTraceSection.newBuilder().setSectionName(sectionName).setCookie(cookie).build();
+    if (!currentAsyncSections.contains(section)) {
+      Log.e(TAG, "Trying to end a trace section that was never started");
+      return;
+    }
+    currentAsyncSections.remove(section);
+    previousAsyncSections.add(section);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static long nativeGetEnabledTags() {
+    return tags;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static void setAppTracingAllowed(boolean appTracingAllowed) {
+    tags = appTracingAllowed ? TRACE_TAG_APP : 0;
+  }
+
+  /** Returns whether systrace is enabled. */
+  @Implementation(minSdk = Q)
+  protected static boolean isEnabled() {
+    return isEnabled;
+  }
+
+  @Implementation(minSdk = Q)
+  protected static void setCounter(String counterName, long counterValue) {
+    verifyNotNull(counterName);
+    counters.add(Counter.newBuilder().setName(counterName).setValue(counterValue).build());
+  }
+
+  /** Sets the systrace to enabled or disabled. */
+  public static void setEnabled(boolean enabled) {
+    ShadowTrace.isEnabled = enabled;
+  }
+
+  /** Returns a stack of the currently active trace sections for the current thread. */
+  public static Deque<String> getCurrentSections() {
+    return new ArrayDeque<>(currentSections.get());
+  }
+
+  /** Returns a queue of all the previously active trace sections for the current thread. */
+  public static Queue<String> getPreviousSections() {
+    return new ArrayDeque<>(previousSections.get());
+  }
+
+  /** Returns a set of all the current active async trace sections. */
+  public static ImmutableSet<AsyncTraceSection> getCurrentAsyncSections() {
+    return ImmutableSet.copyOf(currentAsyncSections);
+  }
+
+  /** Returns a set of all the previously active async trace sections. */
+  public static ImmutableSet<AsyncTraceSection> getPreviousAsyncSections() {
+    return ImmutableSet.copyOf(previousAsyncSections);
+  }
+
+  /** Returns an ordered list of previous counters. */
+  public static ImmutableList<Counter> getCounters() {
+    return ImmutableList.copyOf(counters);
+  }
+
+  /**
+   * Do not use this method unless absolutely necessary. Prefer fixing the tests instead.
+   *
+   * <p>Sets whether to crash on incorrect usage (e.g., calling {@link #endSection()} before {@link
+   * beginSection(String)}. Default value - {@code true}.
+   */
+  public static void doNotUseSetCrashOnIncorrectUsage(boolean crashOnIncorrectUsage) {
+    ShadowTrace.crashOnIncorrectUsage = crashOnIncorrectUsage;
+  }
+
+  private static boolean checkValidSectionName(String sectionName) {
+    if (sectionName == null) {
+      if (crashOnIncorrectUsage) {
+        throw new NullPointerException("sectionName cannot be null");
+      }
+      Log.w(TAG, "Section name cannot be null");
+      return false;
+    } else if (sectionName.length() > MAX_SECTION_NAME_LEN) {
+      if (crashOnIncorrectUsage) {
+        throw new IllegalArgumentException("sectionName is too long");
+      }
+      Log.w(TAG, "Section name is too long");
+      return false;
+    }
+    return true;
+  }
+
+  /** Resets internal lists of active trace sections. */
+  @Resetter
+  public static void reset() {
+    // TODO: clear sections from other threads
+    currentSections.get().clear();
+    previousSections.get().clear();
+    currentAsyncSections.clear();
+    previousAsyncSections.clear();
+    counters.clear();
+    ShadowTrace.isEnabled = true;
+    crashOnIncorrectUsage = CRASH_ON_INCORRECT_USAGE_DEFAULT;
+  }
+
+  /** AutoValue representation of a trace triggered by one of the async apis */
+  @AutoValue
+  public abstract static class AsyncTraceSection {
+
+    public abstract String getSectionName();
+
+    public abstract Integer getCookie();
+
+    public static Builder newBuilder() {
+      return new AutoValue_ShadowTrace_AsyncTraceSection.Builder();
+    }
+
+    /** Builder for traces triggered by one of the async apis */
+    @AutoValue.Builder()
+    public abstract static class Builder {
+      public abstract Builder setSectionName(String sectionName);
+
+      public abstract Builder setCookie(Integer cookie);
+
+      public abstract AsyncTraceSection build();
+    }
+  }
+
+  /** Counters emitted with the setCounter API */
+  @AutoValue
+  public abstract static class Counter {
+
+    public abstract String getName();
+
+    public abstract long getValue();
+
+    public static Builder newBuilder() {
+      return new AutoValue_ShadowTrace_Counter.Builder();
+    }
+
+    /** Builder for counters emitted with the setCounter API */
+    @AutoValue.Builder()
+    public abstract static class Builder {
+
+      public abstract Builder setName(String value);
+
+      public abstract Builder setValue(long value);
+
+      public abstract Counter build();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTrafficStats.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTrafficStats.java
new file mode 100644
index 0000000..096b5ec
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTrafficStats.java
@@ -0,0 +1,209 @@
+package org.robolectric.shadows;
+
+import android.net.TrafficStats;
+import android.os.Build;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(TrafficStats.class)
+public class ShadowTrafficStats {
+
+  private static int mobileTxPackets = TrafficStats.UNSUPPORTED;
+  private static int mobileRxPackets = TrafficStats.UNSUPPORTED;
+  private static int mobileTxBytes = TrafficStats.UNSUPPORTED;
+  private static int mobileRxBytes = TrafficStats.UNSUPPORTED;
+  private static int totalTxPackets = TrafficStats.UNSUPPORTED;
+  private static int totalRxPackets = TrafficStats.UNSUPPORTED;
+  private static int totalTxBytes = TrafficStats.UNSUPPORTED;
+  private static int totalRxBytes = TrafficStats.UNSUPPORTED;
+
+  private static final ThreadLocal<Integer> threadTag =
+      ThreadLocal.withInitial(() -> TrafficStats.UNSUPPORTED);
+
+  @Implementation
+  protected static void setThreadStatsTag(int tag) {
+    threadTag.set(tag);
+  }
+
+  @Implementation
+  protected static int getThreadStatsTag() {
+    return threadTag.get();
+  }
+
+  @Implementation
+  protected static void clearThreadStatsTag() {
+    threadTag.set(TrafficStats.UNSUPPORTED);
+  }
+
+  @Implementation
+  protected static void tagSocket(java.net.Socket socket) throws java.net.SocketException {}
+
+  /** No-op in tests. */
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected static void tagDatagramSocket(java.net.DatagramSocket socket)
+      throws java.net.SocketException {}
+
+  @Implementation
+  protected static void untagSocket(java.net.Socket socket) throws java.net.SocketException {}
+
+  @Implementation
+  protected static void incrementOperationCount(int operationCount) {}
+
+  @Implementation
+  protected static void incrementOperationCount(int tag, int operationCount) {}
+
+  @Implementation
+  protected static long getMobileTxPackets() {
+    return mobileTxPackets;
+  }
+
+  @Implementation
+  protected static long getMobileRxPackets() {
+    return mobileRxPackets;
+  }
+
+  @Implementation
+  protected static long getMobileTxBytes() {
+    return mobileTxBytes;
+  }
+
+  @Implementation
+  protected static long getMobileRxBytes() {
+    return mobileRxBytes;
+  }
+
+  @Implementation
+  protected static long getTotalTxPackets() {
+    return totalTxPackets;
+  }
+
+  @Implementation
+  protected static long getTotalRxPackets() {
+    return totalRxPackets;
+  }
+
+  @Implementation
+  protected static long getTotalTxBytes() {
+    return totalTxBytes;
+  }
+
+  @Implementation
+  protected static long getTotalRxBytes() {
+    return totalRxBytes;
+  }
+
+  @Implementation
+  protected static long getUidTxBytes(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidRxBytes(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidTxPackets(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidRxPackets(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidTcpTxBytes(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidTcpRxBytes(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidUdpTxBytes(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidUdpRxBytes(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidTcpTxSegments(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidTcpRxSegments(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidUdpTxPackets(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  @Implementation
+  protected static long getUidUdpRxPackets(int i) {
+    return TrafficStats.UNSUPPORTED;
+  }
+
+  /** Sets the value returned by {@link #getMobileTxPackets()} for testing */
+  public static void setMobileTxPackets(int mobileTxPackets) {
+    ShadowTrafficStats.mobileTxPackets = mobileTxPackets;
+  }
+
+  /** Sets the value returned by {@link #getMobileRxPackets()} for testing */
+  public static void setMobileRxPackets(int mobileRxPackets) {
+    ShadowTrafficStats.mobileRxPackets = mobileRxPackets;
+  }
+
+  /** Sets the value returned by {@link #getMobileTxBytes()} for testing */
+  public static void setMobileTxBytes(int mobileTxBytes) {
+    ShadowTrafficStats.mobileTxBytes = mobileTxBytes;
+  }
+
+  /** Sets the value returned by {@link #getMobileRxBytes()} for testing */
+  public static void setMobileRxBytes(int mobileRxBytes) {
+    ShadowTrafficStats.mobileRxBytes = mobileRxBytes;
+  }
+
+  /** Sets the value returned by {@link #getTotalTxPackets()} for testing */
+  public static void setTotalTxPackets(int totalTxPackets) {
+    ShadowTrafficStats.totalTxPackets = totalTxPackets;
+  }
+
+  /** Sets the value returned by {@link #getTotalRxPackets()} for testing */
+  public static void setTotalRxPackets(int totalRxPackets) {
+    ShadowTrafficStats.totalRxPackets = totalRxPackets;
+  }
+
+  /** Sets the value returned by {@link #getTotalTxBytes()} for testing */
+  public static void setTotalTxBytes(int totalTxBytes) {
+    ShadowTrafficStats.totalTxBytes = totalTxBytes;
+  }
+
+  /** Sets the value returned by {@link #getTotalRxBytes()} for testing */
+  public static void setTotalRxBytes(int totalRxBytes) {
+    ShadowTrafficStats.totalRxBytes = totalRxBytes;
+  }
+
+  /** Updates all non UID specific fields back to {@link TrafficStats#UNSUPPORTED} */
+  @Resetter
+  public static void restoreDefaults() {
+    mobileTxPackets = TrafficStats.UNSUPPORTED;
+    mobileRxPackets = TrafficStats.UNSUPPORTED;
+    mobileTxBytes = TrafficStats.UNSUPPORTED;
+    mobileRxBytes = TrafficStats.UNSUPPORTED;
+    totalTxPackets = TrafficStats.UNSUPPORTED;
+    totalRxPackets = TrafficStats.UNSUPPORTED;
+    totalTxBytes = TrafficStats.UNSUPPORTED;
+    totalRxBytes = TrafficStats.UNSUPPORTED;
+    threadTag.remove();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTranslationManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTranslationManager.java
new file mode 100644
index 0000000..ed33b59
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTranslationManager.java
@@ -0,0 +1,33 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.view.translation.TranslationCapability;
+import android.view.translation.TranslationManager;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import java.util.Set;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link TranslationManager}. */
+@Implements(value = TranslationManager.class, minSdk = VERSION_CODES.S)
+public class ShadowTranslationManager {
+  private final Table<Integer, Integer, ImmutableSet<TranslationCapability>>
+      onDeviceTranslationCapabilities = HashBasedTable.create();
+
+  @Implementation
+  protected Set<TranslationCapability> getOnDeviceTranslationCapabilities(
+      int sourceFormat, int targetFormat) {
+    if (!onDeviceTranslationCapabilities.contains(sourceFormat, targetFormat)) {
+      return ImmutableSet.of();
+    }
+    return onDeviceTranslationCapabilities.get(sourceFormat, targetFormat);
+  }
+
+  public void setOnDeviceTranslationCapabilities(
+      int sourceFormat, int targetFormat, Set<TranslationCapability> capabilities) {
+    onDeviceTranslationCapabilities.put(
+        sourceFormat, targetFormat, ImmutableSet.copyOf(capabilities));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTypedArray.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTypedArray.java
new file mode 100644
index 0000000..05c0509
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTypedArray.java
@@ -0,0 +1,156 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.res.android.AttributeResolution.STYLE_ASSET_COOKIE;
+import static org.robolectric.res.android.AttributeResolution.STYLE_CHANGING_CONFIGURATIONS;
+import static org.robolectric.res.android.AttributeResolution.STYLE_DATA;
+import static org.robolectric.res.android.AttributeResolution.STYLE_DENSITY;
+import static org.robolectric.res.android.AttributeResolution.STYLE_NUM_ENTRIES;
+import static org.robolectric.res.android.AttributeResolution.STYLE_RESOURCE_ID;
+import static org.robolectric.res.android.AttributeResolution.STYLE_TYPE;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.StyleableRes;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.os.Build;
+import android.util.TypedValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = TypedArray.class, shadowPicker = ShadowTypedArray.Picker.class)
+public class ShadowTypedArray {
+  public static class Picker extends ResourceModeShadowPicker<ShadowTypedArray> {
+    public Picker() {
+      super(ShadowTypedArray.class, null, null);
+    }
+  }
+
+  @RealObject private TypedArray realTypedArray;
+  private CharSequence[] stringData;
+  public String positionDescription;
+
+  public static TypedArray create(Resources realResources, int[] attrs, int[] data, int[] indices, int len, CharSequence[] stringData) {
+    TypedArray typedArray;
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.O) {
+      typedArray =
+          ReflectionHelpers.callConstructor(
+              TypedArray.class, ClassParameter.from(Resources.class, realResources));
+      ReflectionHelpers.setField(typedArray, "mData", data);
+      ReflectionHelpers.setField(typedArray, "mLength", len);
+      ReflectionHelpers.setField(typedArray, "mIndices", indices);
+    } else {
+      typedArray =
+          ReflectionHelpers.callConstructor(
+              TypedArray.class,
+              ClassParameter.from(Resources.class, realResources),
+              ClassParameter.from(int[].class, data),
+              ClassParameter.from(int[].class, indices),
+              ClassParameter.from(int.class, len));
+    }
+
+    ShadowTypedArray shadowTypedArray = Shadow.extract(typedArray);
+    shadowTypedArray.stringData = stringData;
+    return typedArray;
+  }
+
+  @HiddenApi @Implementation
+  protected CharSequence loadStringValueAt(int index) {
+    return stringData[index / STYLE_NUM_ENTRIES];
+  }
+
+  @Implementation
+  protected String getNonResourceString(@StyleableRes int index) {
+    return reflector(TypedArrayReflector.class, realTypedArray).getString(index);
+  }
+
+  @Implementation
+  protected String getNonConfigurationString(@StyleableRes int index, int allowedChangingConfigs) {
+    return reflector(TypedArrayReflector.class, realTypedArray).getString(index);
+  }
+
+  @Implementation
+  protected String getPositionDescription() {
+    return positionDescription;
+  }
+
+  @SuppressWarnings("NewApi")
+  public static void dump(TypedArray typedArray) {
+    int[] data = ReflectionHelpers.getField(typedArray, "mData");
+
+    StringBuilder result = new StringBuilder();
+    for (int index = 0; index < data.length; index+= STYLE_NUM_ENTRIES) {
+      final int type = data[index+STYLE_TYPE];
+      result.append("Index: ").append(index / STYLE_NUM_ENTRIES).append(System.lineSeparator());
+      result
+          .append(Strings.padEnd("Type: ", 25, ' '))
+          .append(TYPE_MAP.get(type))
+          .append(System.lineSeparator());
+      if (type != TypedValue.TYPE_NULL) {
+        result
+            .append(Strings.padEnd("Style data: ", 25, ' '))
+            .append(data[index + STYLE_DATA])
+            .append(System.lineSeparator());
+        result
+            .append(Strings.padEnd("Asset cookie ", 25, ' '))
+            .append(data[index + STYLE_ASSET_COOKIE])
+            .append(System.lineSeparator());
+        result
+            .append(Strings.padEnd("Style resourceId: ", 25, ' '))
+            .append(data[index + STYLE_RESOURCE_ID])
+            .append(System.lineSeparator());
+        result
+            .append(Strings.padEnd("Changing configurations ", 25, ' '))
+            .append(data[index + STYLE_CHANGING_CONFIGURATIONS])
+            .append(System.lineSeparator());
+        result
+            .append(Strings.padEnd("Style density: ", 25, ' '))
+            .append(data[index + STYLE_DENSITY])
+            .append(System.lineSeparator());
+        if (type == TypedValue.TYPE_STRING) {
+          ShadowTypedArray shadowTypedArray = Shadow.extract(typedArray);
+          result
+              .append(Strings.padEnd("Style value: ", 25, ' '))
+              .append(shadowTypedArray.loadStringValueAt(index))
+              .append(System.lineSeparator());
+        }
+      }
+      result.append(System.lineSeparator());
+    }
+    System.out.println(result.toString());
+  }
+
+  private static final ImmutableMap<Integer, String> TYPE_MAP = ImmutableMap.<Integer, String>builder()
+          .put(TypedValue.TYPE_NULL, "TYPE_NULL")
+          .put(TypedValue.TYPE_REFERENCE, "TYPE_REFERENCE")
+          .put(TypedValue.TYPE_ATTRIBUTE, "TYPE_ATTRIBUTE")
+          .put(TypedValue.TYPE_STRING, "TYPE_STRING")
+          .put(TypedValue.TYPE_FLOAT, "TYPE_FLOAT")
+          .put(TypedValue.TYPE_DIMENSION, "TYPE_DIMENSION")
+          .put(TypedValue.TYPE_FRACTION, "TYPE_FRACTION")
+          .put(TypedValue.TYPE_INT_DEC, "TYPE_INT_DEC")
+          .put(TypedValue.TYPE_INT_HEX, "TYPE_INT_HEX")
+          .put(TypedValue.TYPE_INT_BOOLEAN, "TYPE_INT_BOOLEAN")
+          .put(TypedValue.TYPE_INT_COLOR_ARGB8, "TYPE_INT_COLOR_ARGB8")
+          .put(TypedValue.TYPE_INT_COLOR_RGB8, "TYPE_INT_COLOR_RGB8")
+          .put(TypedValue.TYPE_INT_COLOR_ARGB4, "TYPE_INT_COLOR_ARGB4")
+          .put(TypedValue.TYPE_INT_COLOR_RGB4, "TYPE_INT_COLOR_RGB4")
+          .build();
+
+  @ForType(TypedArray.class)
+  interface TypedArrayReflector {
+
+    @Direct
+    String getString(int index);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTypeface.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTypeface.java
new file mode 100644
index 0000000..58abb58
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTypeface.java
@@ -0,0 +1,319 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.O_MR1;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.annotation.SuppressLint;
+import android.content.res.AssetManager;
+import android.graphics.FontFamily;
+import android.graphics.Typeface;
+import android.util.ArrayMap;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicLong;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.Fs;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@Implements(value = Typeface.class, looseSignatures = true)
+@SuppressLint("NewApi")
+public class ShadowTypeface {
+  private static final Map<Long, FontDesc> FONTS = Collections.synchronizedMap(new HashMap<>());
+  private static final AtomicLong nextFontId = new AtomicLong(1);
+  private FontDesc description;
+
+  @HiddenApi
+  @Implementation(maxSdk = KITKAT)
+  protected void __constructor__(int fontId) {
+    description = findById((long) fontId);
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP)
+  protected void __constructor__(long fontId) {
+    description = findById(fontId);
+  }
+
+  @Implementation
+  protected static void __staticInitializer__() {
+    Shadow.directInitialize(Typeface.class);
+    if (RuntimeEnvironment.getApiLevel() > R) {
+      Typeface.loadPreinstalledSystemFontMap();
+    }
+  }
+
+  @Implementation(minSdk = P)
+  protected static Typeface create(Typeface family, int weight, boolean italic) {
+    if (family == null) {
+      return createUnderlyingTypeface(null, weight);
+    } else {
+      ShadowTypeface shadowTypeface = Shadow.extract(family);
+      return createUnderlyingTypeface(shadowTypeface.getFontDescription().getFamilyName(), weight);
+    }
+  }
+
+  @Implementation
+  protected static Typeface create(String familyName, int style) {
+    return createUnderlyingTypeface(familyName, style);
+  }
+
+  @Implementation
+  protected static Typeface create(Typeface family, int style) {
+    if (family == null) {
+      return createUnderlyingTypeface(null, style);
+    } else {
+      ShadowTypeface shadowTypeface = Shadow.extract(family);
+      return createUnderlyingTypeface(shadowTypeface.getFontDescription().getFamilyName(), style);
+    }
+  }
+
+  @Implementation
+  protected static Typeface createFromAsset(AssetManager mgr, String path) {
+    ShadowAssetManager shadowAssetManager = Shadow.extract(mgr);
+    Collection<Path> assetDirs = shadowAssetManager.getAllAssetDirs();
+    for (Path assetDir : assetDirs) {
+      Path assetFile = assetDir.resolve(path);
+      if (Files.exists(assetFile)) {
+        return createUnderlyingTypeface(path, Typeface.NORMAL);
+      }
+
+      // maybe path is e.g. "myFont", but we should match "myFont.ttf" too?
+      Path[] files;
+      try {
+        files = Fs.listFiles(assetDir, f -> f.getFileName().toString().startsWith(path));
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+      if (files.length != 0) {
+        return createUnderlyingTypeface(path, Typeface.NORMAL);
+      }
+    }
+
+    throw new RuntimeException("Font asset not found " + path);
+  }
+
+  @Implementation(minSdk = O, maxSdk = P)
+  protected static Typeface createFromResources(AssetManager mgr, String path, int cookie) {
+    return createUnderlyingTypeface(path, Typeface.NORMAL);
+  }
+
+  @Implementation(minSdk = O)
+  protected static Typeface createFromResources(
+      Object /* FamilyResourceEntry */ entry,
+      Object /* AssetManager */ mgr,
+      Object /* String */ path) {
+    return createUnderlyingTypeface((String) path, Typeface.NORMAL);
+  }
+
+  @Implementation
+  protected static Typeface createFromFile(File path) {
+    String familyName = path.toPath().getFileName().toString();
+    return createUnderlyingTypeface(familyName, Typeface.NORMAL);
+  }
+
+  @Implementation
+  protected static Typeface createFromFile(String path) {
+    return createFromFile(new File(path));
+  }
+
+  @Implementation
+  protected int getStyle() {
+    return description.getStyle();
+  }
+
+  @Override
+  @Implementation
+  public boolean equals(Object o) {
+    if (o instanceof Typeface) {
+      Typeface other = ((Typeface) o);
+      return Objects.equals(getFontDescription(), shadowOf(other).getFontDescription());
+    }
+    return false;
+  }
+
+  @Override
+  @Implementation
+  public int hashCode() {
+    return getFontDescription().hashCode();
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP)
+  protected static Typeface createFromFamilies(Object /*FontFamily[]*/ families) {
+    return null;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
+  protected static Typeface createFromFamiliesWithDefault(Object /*FontFamily[]*/ families) {
+    return null;
+  }
+
+  @Implementation(minSdk = O, maxSdk = O_MR1)
+  protected static Typeface createFromFamiliesWithDefault(
+      Object /*FontFamily[]*/ families, Object /* int */ weight, Object /* int */ italic) {
+    return createUnderlyingTypeface("fake-font", Typeface.NORMAL);
+  }
+
+  @Implementation(minSdk = P)
+  protected static Typeface createFromFamiliesWithDefault(
+      Object /*FontFamily[]*/ families,
+      Object /* String */ fallbackName,
+      Object /* int */ weight,
+      Object /* int */ italic) {
+    return createUnderlyingTypeface((String) fallbackName, Typeface.NORMAL);
+  }
+
+  @Implementation(minSdk = P, maxSdk = P)
+  protected static void buildSystemFallback(
+      String xmlPath,
+      String fontDir,
+      ArrayMap<String, Typeface> fontMap,
+      ArrayMap<String, FontFamily[]> fallbackMap) {
+    fontMap.put("sans-serif", createUnderlyingTypeface("sans-serif", 0));
+  }
+
+  /** Avoid spurious error message about /system/etc/fonts.xml */
+  @Implementation(minSdk = LOLLIPOP, maxSdk = O_MR1)
+  protected static void init() {}
+
+  @HiddenApi
+  @Implementation(minSdk = Q, maxSdk = R)
+  public static void initSystemDefaultTypefaces(
+      Object systemFontMap, Object fallbacks, Object aliases) {}
+
+  @Resetter
+  public static synchronized void reset() {
+    FONTS.clear();
+  }
+
+  protected static Typeface createUnderlyingTypeface(String familyName, int style) {
+    long thisFontId = nextFontId.getAndIncrement();
+    FONTS.put(thisFontId, new FontDesc(familyName, style));
+    if (getApiLevel() >= LOLLIPOP) {
+      return ReflectionHelpers.callConstructor(
+          Typeface.class, ClassParameter.from(long.class, thisFontId));
+    } else {
+      return ReflectionHelpers.callConstructor(
+          Typeface.class, ClassParameter.from(int.class, (int) thisFontId));
+    }
+  }
+
+  private static synchronized FontDesc findById(long fontId) {
+    if (FONTS.containsKey(fontId)) {
+      return FONTS.get(fontId);
+    }
+    throw new RuntimeException("Unknown font id: " + fontId);
+  }
+
+  @Implementation(minSdk = O, maxSdk = R)
+  protected static long nativeCreateFromArray(long[] familyArray, int weight, int italic) {
+    // TODO: implement this properly
+    long thisFontId = nextFontId.getAndIncrement();
+    FONTS.put(thisFontId, new FontDesc(null, weight));
+    return thisFontId;
+  }
+
+  /**
+   * Returns the font description.
+   *
+   * @return Font description.
+   */
+  public FontDesc getFontDescription() {
+    return description;
+  }
+
+  @Implementation(minSdk = S)
+  protected static void nativeForceSetStaticFinalField(String fieldname, Typeface typeface) {
+    ReflectionHelpers.setStaticField(Typeface.class, fieldname, typeface);
+  }
+
+  @Implementation(minSdk = S)
+  protected static long nativeCreateFromArray(
+      long[] familyArray, long fallbackTypeface, int weight, int italic) {
+    return ShadowTypeface.nativeCreateFromArray(familyArray, weight, italic);
+  }
+
+  public static class FontDesc {
+    public final String familyName;
+    public final int style;
+
+    public FontDesc(String familyName, int style) {
+      this.familyName = familyName;
+      this.style = style;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof FontDesc)) {
+        return false;
+      }
+
+      FontDesc fontDesc = (FontDesc) o;
+
+      if (style != fontDesc.style) {
+        return false;
+      }
+      if (familyName != null
+          ? !familyName.equals(fontDesc.familyName)
+          : fontDesc.familyName != null) {
+        return false;
+      }
+
+      return true;
+    }
+
+    @Override
+    public int hashCode() {
+      int result = familyName != null ? familyName.hashCode() : 0;
+      result = 31 * result + style;
+      return result;
+    }
+
+    public String getFamilyName() {
+      return familyName;
+    }
+
+    public int getStyle() {
+      return style;
+    }
+  }
+
+  /** Shadow for {@link Typeface.Builder} */
+  @Implements(value = Typeface.Builder.class, minSdk = Q)
+  public static class ShadowBuilder {
+    @RealObject Typeface.Builder realBuilder;
+
+    @Implementation
+    protected Typeface build() {
+      String path = ReflectionHelpers.getField(realBuilder, "mPath");
+      return createUnderlyingTypeface(path, Typeface.NORMAL);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUIModeManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUIModeManager.java
new file mode 100644
index 0000000..44d6eca
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUIModeManager.java
@@ -0,0 +1,233 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.SystemApi;
+import android.app.UiModeManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.provider.Settings;
+import androidx.annotation.GuardedBy;
+import com.google.common.collect.ImmutableSet;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for {@link UiModeManager}. */
+@Implements(UiModeManager.class)
+public class ShadowUIModeManager {
+  public int currentModeType = Configuration.UI_MODE_TYPE_UNDEFINED;
+  public int currentNightMode = UiModeManager.MODE_NIGHT_AUTO;
+  public int lastFlags;
+  public int lastCarModePriority;
+  private int currentApplicationNightMode = 0;
+  private final Set<Integer> activeProjectionTypes = new HashSet<>();
+  private boolean failOnProjectionToggle;
+
+  private static final ImmutableSet<Integer> VALID_NIGHT_MODES =
+      ImmutableSet.of(
+          UiModeManager.MODE_NIGHT_AUTO, UiModeManager.MODE_NIGHT_NO, UiModeManager.MODE_NIGHT_YES);
+
+  private static final int DEFAULT_PRIORITY = 0;
+
+  private final Object lock = new Object();
+
+  @GuardedBy("lock")
+  private int nightModeCustomType = UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN;
+
+  @GuardedBy("lock")
+  private boolean isNightModeOn = false;
+
+  @RealObject UiModeManager realUiModeManager;
+
+  private static final ImmutableSet<Integer> VALID_NIGHT_MODE_CUSTOM_TYPES =
+      ImmutableSet.of(
+          UiModeManager.MODE_NIGHT_CUSTOM_TYPE_SCHEDULE,
+          UiModeManager.MODE_NIGHT_CUSTOM_TYPE_BEDTIME);
+
+  @Implementation
+  protected int getCurrentModeType() {
+    return currentModeType;
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.Q)
+  protected void enableCarMode(int flags) {
+    enableCarMode(DEFAULT_PRIORITY, flags);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.R)
+  protected void enableCarMode(int priority, int flags) {
+    currentModeType = Configuration.UI_MODE_TYPE_CAR;
+    lastCarModePriority = priority;
+    lastFlags = flags;
+  }
+
+  @Implementation
+  protected void disableCarMode(int flags) {
+    currentModeType = Configuration.UI_MODE_TYPE_NORMAL;
+    lastFlags = flags;
+  }
+
+  @Implementation
+  protected int getNightMode() {
+    return currentNightMode;
+  }
+
+  @Implementation
+  protected void setNightMode(int mode) {
+    synchronized (lock) {
+      ContentResolver resolver = getContentResolver();
+      switch (mode) {
+        case UiModeManager.MODE_NIGHT_NO:
+        case UiModeManager.MODE_NIGHT_YES:
+        case UiModeManager.MODE_NIGHT_AUTO:
+          currentNightMode = mode;
+          nightModeCustomType = UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN;
+          if (resolver != null) {
+            Settings.Secure.putInt(resolver, Settings.Secure.UI_NIGHT_MODE, mode);
+            Settings.Secure.putInt(
+                resolver,
+                Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE,
+                UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+          }
+          break;
+        default:
+          currentNightMode = UiModeManager.MODE_NIGHT_AUTO;
+          if (resolver != null) {
+            Settings.Secure.putInt(
+                resolver, Settings.Secure.UI_NIGHT_MODE, UiModeManager.MODE_NIGHT_AUTO);
+          }
+      }
+    }
+  }
+
+  public int getApplicationNightMode() {
+    return currentApplicationNightMode;
+  }
+
+  public Set<Integer> getActiveProjectionTypes() {
+    return new HashSet<>(activeProjectionTypes);
+  }
+
+  public void setFailOnProjectionToggle(boolean failOnProjectionToggle) {
+    this.failOnProjectionToggle = failOnProjectionToggle;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  @HiddenApi
+  protected void setApplicationNightMode(int mode) {
+    currentApplicationNightMode = mode;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  @SystemApi
+  protected boolean requestProjection(int projectionType) {
+    if (projectionType == UiModeManager.PROJECTION_TYPE_AUTOMOTIVE) {
+      assertHasPermission(android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION);
+    }
+    if (failOnProjectionToggle) {
+      return false;
+    }
+    activeProjectionTypes.add(projectionType);
+    return true;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.S)
+  @SystemApi
+  protected boolean releaseProjection(int projectionType) {
+    if (projectionType == UiModeManager.PROJECTION_TYPE_AUTOMOTIVE) {
+      assertHasPermission(android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION);
+    }
+    if (failOnProjectionToggle) {
+      return false;
+    }
+    return activeProjectionTypes.remove(projectionType);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected int getNightModeCustomType() {
+    synchronized (lock) {
+      return nightModeCustomType;
+    }
+  }
+
+  /** Returns whether night mode is currently on when a custom night mode type is selected. */
+  public boolean isNightModeOn() {
+    synchronized (lock) {
+      return isNightModeOn;
+    }
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void setNightModeCustomType(int mode) {
+    synchronized (lock) {
+      ContentResolver resolver = getContentResolver();
+      if (VALID_NIGHT_MODE_CUSTOM_TYPES.contains(mode)) {
+        nightModeCustomType = mode;
+        currentNightMode = UiModeManager.MODE_NIGHT_CUSTOM;
+        if (resolver != null) {
+          Settings.Secure.putInt(resolver, Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE, mode);
+        }
+      } else {
+        nightModeCustomType = UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN;
+        if (resolver != null) {
+          Settings.Secure.putInt(
+              resolver,
+              Settings.Secure.UI_NIGHT_MODE_CUSTOM_TYPE,
+              UiModeManager.MODE_NIGHT_CUSTOM_TYPE_UNKNOWN);
+        }
+      }
+    }
+  }
+
+  private ContentResolver getContentResolver() {
+    Context context = getContext();
+    return context == null ? null : context.getContentResolver();
+  }
+
+  // Note: UiModeManager stores the context only starting from Android R.
+  private Context getContext() {
+    if (VERSION.SDK_INT < VERSION_CODES.R) {
+      return null;
+    }
+    return reflector(UiModeManagerReflector.class, realUiModeManager).getContext();
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected boolean setNightModeActivatedForCustomMode(int mode, boolean active) {
+    synchronized (lock) {
+      if (VALID_NIGHT_MODE_CUSTOM_TYPES.contains(mode) && nightModeCustomType == mode) {
+        isNightModeOn = active;
+        return true;
+      }
+      return false;
+    }
+  }
+
+  @ForType(UiModeManager.class)
+  interface UiModeManagerReflector {
+    @Accessor("mContext")
+    Context getContext();
+  }
+
+  private void assertHasPermission(String... permissions) {
+    Context context = RuntimeEnvironment.getApplication();
+    for (String permission : permissions) {
+      if (context.getPackageManager().checkPermission(permission, context.getPackageName())
+          != PackageManager.PERMISSION_GRANTED) {
+        throw new SecurityException("Missing required permission: " + permission);
+      }
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUiAutomation.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUiAutomation.java
new file mode 100644
index 0000000..73575c8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUiAutomation.java
@@ -0,0 +1,307 @@
+package org.robolectric.shadows;
+
+import static android.app.UiAutomation.ROTATION_FREEZE_0;
+import static android.app.UiAutomation.ROTATION_FREEZE_180;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
+import static android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Activity;
+import android.app.UiAutomation;
+import android.content.ContentResolver;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Point;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.Looper;
+import android.provider.Settings;
+import android.view.Display;
+import android.view.InputEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewRootImpl;
+import android.view.WindowManager;
+import android.view.WindowManagerGlobal;
+import android.view.WindowManagerImpl;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitor;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.Stage;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow for {@link UiAutomation}. */
+@Implements(value = UiAutomation.class, minSdk = JELLY_BEAN_MR2)
+public class ShadowUiAutomation {
+
+  private static final Predicate<Root> IS_FOCUSABLE = hasLayoutFlag(FLAG_NOT_FOCUSABLE).negate();
+  private static final Predicate<Root> IS_TOUCHABLE = hasLayoutFlag(FLAG_NOT_TOUCHABLE).negate();
+  private static final Predicate<Root> IS_TOUCH_MODAL =
+      IS_FOCUSABLE.and(hasLayoutFlag(FLAG_NOT_TOUCH_MODAL).negate());
+  private static final Predicate<Root> WATCH_TOUCH_OUTSIDE =
+      IS_TOUCH_MODAL.negate().and(hasLayoutFlag(FLAG_WATCH_OUTSIDE_TOUCH));
+
+  /**
+   * Sets the animation scale, see {@link UiAutomation#setAnimationScale(float)}. Provides backwards
+   * compatible access to SDKs < T.
+   */
+  @SuppressWarnings("deprecation")
+  public static void setAnimationScaleCompat(float scale) {
+    ContentResolver cr = RuntimeEnvironment.getApplication().getContentResolver();
+    if (RuntimeEnvironment.getApiLevel() >= JELLY_BEAN_MR1) {
+      Settings.Global.putFloat(cr, Settings.Global.ANIMATOR_DURATION_SCALE, scale);
+      Settings.Global.putFloat(cr, Settings.Global.TRANSITION_ANIMATION_SCALE, scale);
+      Settings.Global.putFloat(cr, Settings.Global.WINDOW_ANIMATION_SCALE, scale);
+    } else {
+      Settings.System.putFloat(cr, Settings.System.ANIMATOR_DURATION_SCALE, scale);
+      Settings.System.putFloat(cr, Settings.System.TRANSITION_ANIMATION_SCALE, scale);
+      Settings.System.putFloat(cr, Settings.System.WINDOW_ANIMATION_SCALE, scale);
+    }
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void setAnimationScale(float scale) {
+    setAnimationScaleCompat(scale);
+  }
+
+  @Implementation
+  protected boolean setRotation(int rotation) {
+    if (rotation == UiAutomation.ROTATION_FREEZE_CURRENT
+        || rotation == UiAutomation.ROTATION_UNFREEZE) {
+      return true;
+    }
+    Display display = ShadowDisplay.getDefaultDisplay();
+    int currentRotation = display.getRotation();
+    boolean isRotated =
+        (rotation == ROTATION_FREEZE_0 || rotation == ROTATION_FREEZE_180)
+            != (currentRotation == ROTATION_FREEZE_0 || currentRotation == ROTATION_FREEZE_180);
+    shadowOf(display).setRotation(rotation);
+    if (isRotated) {
+      int currentOrientation = Resources.getSystem().getConfiguration().orientation;
+      String rotationQualifier =
+          "+" + (currentOrientation == Configuration.ORIENTATION_PORTRAIT ? "land" : "port");
+      ShadowDisplayManager.changeDisplay(display.getDisplayId(), rotationQualifier);
+      RuntimeEnvironment.setQualifiers(rotationQualifier);
+    }
+    return true;
+  }
+
+  @Implementation
+  protected void throwIfNotConnectedLocked() {}
+
+  @Implementation
+  protected Bitmap takeScreenshot() {
+    if (!ShadowView.useRealGraphics()) {
+      return null;
+    }
+    Point displaySize = new Point();
+    ShadowDisplay.getDefaultDisplay().getRealSize(displaySize);
+    Bitmap screenshot = Bitmap.createBitmap(displaySize.x, displaySize.y, Bitmap.Config.ARGB_8888);
+    Canvas screenshotCanvas = new Canvas(screenshot);
+    Paint paint = new Paint();
+    for (Root root : getViewRoots().reverse()) {
+      View rootView = root.getRootView();
+      if (rootView.getWidth() <= 0 || rootView.getHeight() <= 0) {
+        continue;
+      }
+      Bitmap window =
+          Bitmap.createBitmap(rootView.getWidth(), rootView.getHeight(), Bitmap.Config.ARGB_8888);
+      Canvas windowCanvas = new Canvas(window);
+      rootView.draw(windowCanvas);
+      screenshotCanvas.drawBitmap(window, root.params.x, root.params.y, paint);
+    }
+    return screenshot;
+  }
+
+  /**
+   * Injects a motion event into the appropriate window, see {@link
+   * UiAutomation#injectInputEvent(InputEvent, boolean)}. This can be used through the {@link
+   * UiAutomation} API, this method is provided for backwards compatibility with SDK < 18.
+   */
+  public static boolean injectInputEvent(InputEvent event) {
+    checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!");
+    if (event instanceof MotionEvent) {
+      return injectMotionEvent((MotionEvent) event);
+    } else if (event instanceof KeyEvent) {
+      return injectKeyEvent((KeyEvent) event);
+    } else {
+      throw new IllegalArgumentException("Unrecognized event type: " + event);
+    }
+  }
+
+  @Implementation
+  protected boolean injectInputEvent(InputEvent event, boolean sync) {
+    return injectInputEvent(event);
+  }
+
+  private static boolean injectMotionEvent(MotionEvent event) {
+    // TODO(paulsowden): The real implementation will send a full event stream (a touch down
+    //  followed by a series of moves, etc) to the same window/root even if the subsequent events
+    //  leave the window bounds, and will split pointer down events based on the window flags.
+    //  This will be necessary to support more sophisticated multi-window use cases.
+
+    List<Root> touchableRoots = getViewRoots().stream().filter(IS_TOUCHABLE).collect(toList());
+    for (int i = 0; i < touchableRoots.size(); i++) {
+      Root root = touchableRoots.get(i);
+      if (i == touchableRoots.size() - 1 || root.isTouchModal() || root.isTouchInside(event)) {
+        event.offsetLocation(-root.params.x, -root.params.y);
+        root.getRootView().dispatchTouchEvent(event);
+        event.offsetLocation(root.params.x, root.params.y);
+        break;
+      } else if (event.getActionMasked() == MotionEvent.ACTION_DOWN && root.watchTouchOutside()) {
+        MotionEvent outsideEvent = MotionEvent.obtain(event);
+        outsideEvent.setAction(MotionEvent.ACTION_OUTSIDE);
+        outsideEvent.offsetLocation(-root.params.x, -root.params.y);
+        root.getRootView().dispatchTouchEvent(outsideEvent);
+        outsideEvent.recycle();
+      }
+    }
+    return true;
+  }
+
+  private static boolean injectKeyEvent(KeyEvent event) {
+    getViewRoots().stream()
+        .filter(IS_FOCUSABLE)
+        .findFirst()
+        .ifPresent(root -> root.getRootView().dispatchKeyEvent(event));
+    return true;
+  }
+
+  private static ImmutableList<Root> getViewRoots() {
+    List<ViewRootImpl> viewRootImpls = getViewRootImpls();
+    List<WindowManager.LayoutParams> params = getRootLayoutParams();
+    checkState(
+        params.size() == viewRootImpls.size(),
+        "number params is not consistent with number of view roots!");
+    Set<IBinder> startedActivityTokens = getStartedActivityTokens();
+    ArrayList<Root> roots = new ArrayList<>();
+    for (int i = 0; i < viewRootImpls.size(); i++) {
+      Root root = new Root(viewRootImpls.get(i), params.get(i), i);
+      // TODO: Should we also filter out sub-windows of non-started application windows?
+      if (root.getType() != WindowManager.LayoutParams.TYPE_BASE_APPLICATION
+          || startedActivityTokens.contains(root.impl.getView().getApplicationWindowToken())) {
+        roots.add(root);
+      }
+    }
+    roots.sort(
+        comparingInt(Root::getType)
+            .reversed()
+            .thenComparing(comparingInt(Root::getIndex).reversed()));
+    return ImmutableList.copyOf(roots);
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<ViewRootImpl> getViewRootImpls() {
+    Object windowManager = getViewRootsContainer();
+    Object viewRootsObj = ReflectionHelpers.getField(windowManager, "mRoots");
+    Class<?> viewRootsClass = viewRootsObj.getClass();
+    if (ViewRootImpl[].class.isAssignableFrom(viewRootsClass)) {
+      return Arrays.asList((ViewRootImpl[]) viewRootsObj);
+    } else if (List.class.isAssignableFrom(viewRootsClass)) {
+      return (List<ViewRootImpl>) viewRootsObj;
+    } else {
+      throw new IllegalStateException(
+          "WindowManager.mRoots is an unknown type " + viewRootsClass.getName());
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<WindowManager.LayoutParams> getRootLayoutParams() {
+    Object windowManager = getViewRootsContainer();
+    Object paramsObj = ReflectionHelpers.getField(windowManager, "mParams");
+    Class<?> paramsClass = paramsObj.getClass();
+    if (WindowManager.LayoutParams[].class.isAssignableFrom(paramsClass)) {
+      return Arrays.asList((WindowManager.LayoutParams[]) paramsObj);
+    } else if (List.class.isAssignableFrom(paramsClass)) {
+      return (List<WindowManager.LayoutParams>) paramsObj;
+    } else {
+      throw new IllegalStateException(
+          "WindowManager.mParams is an unknown type " + paramsClass.getName());
+    }
+  }
+
+  private static Object getViewRootsContainer() {
+    if (RuntimeEnvironment.getApiLevel() <= Build.VERSION_CODES.JELLY_BEAN) {
+      return ReflectionHelpers.callStaticMethod(WindowManagerImpl.class, "getDefault");
+    } else {
+      return WindowManagerGlobal.getInstance();
+    }
+  }
+
+  private static Set<IBinder> getStartedActivityTokens() {
+    ActivityLifecycleMonitor monitor = ActivityLifecycleMonitorRegistry.getInstance();
+    return ImmutableSet.<Activity>builder()
+        .addAll(monitor.getActivitiesInStage(Stage.STARTED))
+        .addAll(monitor.getActivitiesInStage(Stage.RESUMED))
+        .build()
+        .stream()
+        .map(activity -> activity.getWindow().getDecorView().getApplicationWindowToken())
+        .collect(toSet());
+  }
+
+  private static Predicate<Root> hasLayoutFlag(int flag) {
+    return root -> (root.params.flags & flag) == flag;
+  }
+
+  private static final class Root {
+    final ViewRootImpl impl;
+    final WindowManager.LayoutParams params;
+    final int index;
+
+    Root(ViewRootImpl impl, WindowManager.LayoutParams params, int index) {
+      this.impl = impl;
+      this.params = params;
+      this.index = index;
+    }
+
+    int getIndex() {
+      return index;
+    }
+
+    int getType() {
+      return params.type;
+    }
+
+    View getRootView() {
+      return impl.getView();
+    }
+
+    boolean isTouchInside(MotionEvent event) {
+      int index = event.getActionIndex();
+      return event.getX(index) >= params.x
+          && event.getX(index) <= params.x + impl.getView().getWidth()
+          && event.getY(index) >= params.y
+          && event.getY(index) <= params.y + impl.getView().getHeight();
+    }
+
+    boolean isTouchModal() {
+      return IS_TOUCH_MODAL.test(this);
+    }
+
+    boolean watchTouchOutside() {
+      return WATCH_TOUCH_OUTSIDE.test(this);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsageStatsManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsageStatsManager.java
new file mode 100644
index 0000000..40dc526
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsageStatsManager.java
@@ -0,0 +1,790 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.TargetApi;
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.app.usage.BroadcastResponseStats;
+import android.app.usage.UsageEvents;
+import android.app.usage.UsageEvents.Event;
+import android.app.usage.UsageStats;
+import android.app.usage.UsageStatsManager;
+import android.app.usage.UsageStatsManager.StandbyBuckets;
+import android.app.usage.UsageStatsManager.UsageSource;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Parcel;
+import android.util.ArraySet;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.Range;
+import com.google.common.collect.SetMultimap;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.concurrent.TimeUnit;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow of {@link UsageStatsManager}. */
+@Implements(
+    value = UsageStatsManager.class,
+    minSdk = Build.VERSION_CODES.LOLLIPOP,
+    looseSignatures = true)
+public class ShadowUsageStatsManager {
+  private static @StandbyBuckets int currentAppStandbyBucket =
+      UsageStatsManager.STANDBY_BUCKET_ACTIVE;
+
+  @UsageSource
+  private static int currentUsageSource = UsageStatsManager.USAGE_SOURCE_TASK_ROOT_ACTIVITY;
+
+  // This map will sort events by time, but otherwise will preserve order events were added in
+  private static final NavigableMap<Long, List<Event>> eventsByTimeStamp =
+      Maps.synchronizedNavigableMap(Maps.newTreeMap());
+
+  /**
+   * Keys {@link UsageStats} objects by intervalType (e.g. {@link
+   * UsageStatsManager#INTERVAL_WEEKLY}).
+   */
+  private SetMultimap<Integer, UsageStats> usageStatsByIntervalType =
+      Multimaps.synchronizedSetMultimap(HashMultimap.create());
+
+  private static final Map<String, Integer> appStandbyBuckets = Maps.newConcurrentMap();
+
+  /** Used with T APIs for {@link BroadcastResponseStats}. */
+  private final Map<String, Map<Long, Object /*BroadcastResponseStats */>> appBroadcastStats =
+      Maps.newConcurrentMap();
+
+  /**
+   * App usage observer registered via {@link UsageStatsManager#registerAppUsageObserver(int,
+   * String[], long, TimeUnit, PendingIntent)}.
+   */
+  @AutoValue
+  public abstract static class AppUsageObserver {
+
+    public static AppUsageObserver build(
+        int observerId,
+        @NonNull Collection<String> packageNames,
+        long timeLimit,
+        @NonNull TimeUnit timeUnit,
+        @NonNull PendingIntent callbackIntent) {
+      return new AutoValue_ShadowUsageStatsManager_AppUsageObserver(
+          observerId, ImmutableList.copyOf(packageNames), timeLimit, timeUnit, callbackIntent);
+    }
+
+    public abstract int getObserverId();
+
+    @NonNull
+    public abstract ImmutableList<String> getPackageNames();
+
+    public abstract long getTimeLimit();
+
+    @NonNull
+    public abstract TimeUnit getTimeUnit();
+
+    @NonNull
+    public abstract PendingIntent getCallbackIntent();
+  }
+
+  private static final Map<Integer, AppUsageObserver> appUsageObserversById =
+      Maps.newConcurrentMap();
+
+  /**
+   * Usage session observer registered via {@link
+   * UsageStatsManager#registerUsageSessionObserver(int, String[], long, TimeUnit, long, TimeUnit,
+   * PendingIntent, PendingIntent)}.
+   */
+  @AutoValue
+  public abstract static class UsageSessionObserver {
+    public static UsageSessionObserver build(
+        int observerId,
+        @NonNull List<String> packageNames,
+        Duration sessionStepDuration,
+        Duration thresholdDuration,
+        @NonNull PendingIntent sessionStepTriggeredIntent,
+        @NonNull PendingIntent sessionEndedIntent) {
+      return new AutoValue_ShadowUsageStatsManager_UsageSessionObserver(
+          observerId,
+          ImmutableList.copyOf(packageNames),
+          sessionStepDuration,
+          thresholdDuration,
+          sessionStepTriggeredIntent,
+          sessionEndedIntent);
+    }
+
+    public abstract int getObserverId();
+
+    @NonNull
+    public abstract ImmutableList<String> getPackageNames();
+
+    @Nullable
+    public abstract Duration getSessionStepDuration();
+
+    @Nullable
+    public abstract Duration getThresholdDuration();
+
+    @NonNull
+    public abstract PendingIntent getSessionStepTriggeredIntent();
+
+    @NonNull
+    public abstract PendingIntent getSessionEndedIntent();
+  }
+
+  protected static final Map<Integer, UsageSessionObserver> usageSessionObserversById =
+      new LinkedHashMap<>();
+
+  /**
+   * App usage limit observer registered via {@link
+   * UsageStatsManager#registerAppUsageLimitObserver(int, String[], Duration, Duration,
+   * PendingIntent)}.
+   */
+  public static final class AppUsageLimitObserver {
+    private final int observerId;
+    private final ImmutableList<String> packageNames;
+    private final Duration timeLimit;
+    private final Duration timeUsed;
+    private final PendingIntent callbackIntent;
+
+    public AppUsageLimitObserver(
+        int observerId,
+        @NonNull List<String> packageNames,
+        @NonNull Duration timeLimit,
+        @NonNull Duration timeUsed,
+        @NonNull PendingIntent callbackIntent) {
+      this.observerId = observerId;
+      this.packageNames = ImmutableList.copyOf(packageNames);
+      this.timeLimit = checkNotNull(timeLimit);
+      this.timeUsed = checkNotNull(timeUsed);
+      this.callbackIntent = checkNotNull(callbackIntent);
+    }
+
+    public int getObserverId() {
+      return observerId;
+    }
+
+    @NonNull
+    public ImmutableList<String> getPackageNames() {
+      return packageNames;
+    }
+
+    @NonNull
+    public Duration getTimeLimit() {
+      return timeLimit;
+    }
+
+    @NonNull
+    public Duration getTimeUsed() {
+      return timeUsed;
+    }
+
+    @NonNull
+    public PendingIntent getCallbackIntent() {
+      return callbackIntent;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      AppUsageLimitObserver that = (AppUsageLimitObserver) o;
+      return observerId == that.observerId
+          && packageNames.equals(that.packageNames)
+          && timeLimit.equals(that.timeLimit)
+          && timeUsed.equals(that.timeUsed)
+          && callbackIntent.equals(that.callbackIntent);
+    }
+
+    @Override
+    public int hashCode() {
+      int result = observerId;
+      result = 31 * result + packageNames.hashCode();
+      result = 31 * result + timeLimit.hashCode();
+      result = 31 * result + timeUsed.hashCode();
+      result = 31 * result + callbackIntent.hashCode();
+      return result;
+    }
+  }
+
+  private static final Map<Integer, AppUsageLimitObserver> appUsageLimitObserversById =
+      Maps.newConcurrentMap();
+
+  @Implementation
+  protected UsageEvents queryEvents(long beginTime, long endTime) {
+    List<Event> results =
+        ImmutableList.copyOf(
+            Iterables.concat(eventsByTimeStamp.subMap(beginTime, endTime).values()));
+    return createUsageEvents(results);
+  }
+
+  private static UsageEvents createUsageEvents(List<Event> results) {
+    ArraySet<String> names = new ArraySet<>();
+    for (Event result : results) {
+      if (result.mPackage != null) {
+        names.add(result.mPackage);
+      }
+      if (result.mClass != null) {
+        names.add(result.mClass);
+      }
+    }
+
+    String[] table = names.toArray(new String[0]);
+    Arrays.sort(table);
+
+    // We can't directly construct usable UsageEvents, so we replicate what the framework does:
+    // First the system marshalls the usage events into a Parcel.
+    UsageEvents usageEvents = new UsageEvents(results, table);
+    Parcel parcel = Parcel.obtain();
+    usageEvents.writeToParcel(parcel, 0);
+    // Then the app unmarshalls the usage events from the Parcel.
+    parcel.setDataPosition(0);
+    return new UsageEvents(parcel);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  protected UsageEvents queryEventsForSelf(long beginTime, long endTime) {
+    String packageName = RuntimeEnvironment.getApplication().getOpPackageName();
+    ImmutableList.Builder<Event> listBuilder = new ImmutableList.Builder<>();
+    for (Event event : Iterables.concat(eventsByTimeStamp.subMap(beginTime, endTime).values())) {
+      if (packageName.equals(event.getPackageName())) {
+        listBuilder.add(event);
+      }
+    }
+    return createUsageEvents(listBuilder.build());
+  }
+
+  /**
+   * Adds an event to be returned by {@link UsageStatsManager#queryEvents}.
+   *
+   * <p>This method won't affect the results of {@link #queryUsageStats} method.
+   *
+   * @deprecated Use {@link #addEvent(Event)} and {@link EventBuilder} instead.
+   */
+  @Deprecated
+  public void addEvent(String packageName, long timeStamp, int eventType) {
+    EventBuilder eventBuilder =
+        EventBuilder.buildEvent()
+            .setPackage(packageName)
+            .setTimeStamp(timeStamp)
+            .setEventType(eventType);
+    if (eventType == Event.CONFIGURATION_CHANGE) {
+      eventBuilder.setConfiguration(new Configuration());
+    }
+    addEvent(eventBuilder.build());
+  }
+
+  /**
+   * Adds an event to be returned by {@link UsageStatsManager#queryEvents}.
+   *
+   * <p>This method won't affect the results of {@link #queryUsageStats} method.
+   *
+   * <p>The {@link Event} can be built by {@link EventBuilder}.
+   */
+  public void addEvent(Event event) {
+    List<Event> eventsAtTime = eventsByTimeStamp.get(event.getTimeStamp());
+    if (eventsAtTime == null) {
+      eventsAtTime = new ArrayList<>(1);
+      eventsByTimeStamp.put(event.getTimeStamp(), eventsAtTime);
+    }
+    eventsAtTime.add(event);
+  }
+
+  /**
+   * Simulates the operations done by the framework when there is a time change. If the time is
+   * changed, the timestamps of all existing usage events will be shifted by the same offset as the
+   * time change, in order to make sure they remain stable relative to the new time.
+   *
+   * <p>This method won't affect the results of {@link #queryUsageStats} method.
+   *
+   * @param offsetToAddInMillis the offset to be applied to all events. For example, if {@code
+   *     offsetInMillis} is 60,000, then all {@link Event}s will be shifted forward by 1 minute
+   *     (into the future). Likewise, if {@code offsetInMillis} is -60,000, then all {@link Event}s
+   *     will be shifted backward by 1 minute (into the past).
+   */
+  public void simulateTimeChange(long offsetToAddInMillis) {
+    List<Event> oldEvents = ImmutableList.copyOf(Iterables.concat(eventsByTimeStamp.values()));
+    eventsByTimeStamp.clear();
+    for (Event event : oldEvents) {
+      long newTimestamp = event.getTimeStamp() + offsetToAddInMillis;
+      addEvent(EventBuilder.fromEvent(event).setTimeStamp(newTimestamp).build());
+    }
+  }
+
+  /**
+   * Returns aggregated UsageStats added by calling {@link #addUsageStats}.
+   *
+   * <p>The real implementation creates these aggregated objects from individual {@link Event}. This
+   * aggregation logic is nontrivial, so the shadow implementation just returns the aggregate data
+   * added using {@link #addUsageStats}.
+   */
+  @Implementation
+  protected List<UsageStats> queryUsageStats(int intervalType, long beginTime, long endTime) {
+    List<UsageStats> results = new ArrayList<>();
+    Range<Long> queryRange = Range.closed(beginTime, endTime);
+    for (UsageStats stats : usageStatsByIntervalType.get(intervalType)) {
+      Range<Long> statsRange = Range.closed(stats.getFirstTimeStamp(), stats.getLastTimeStamp());
+      if (queryRange.isConnected(statsRange)) {
+        results.add(stats);
+      }
+    }
+    return results;
+  }
+
+  /**
+   * Adds an aggregated {@code UsageStats} object, to be returned by {@link #queryUsageStats}.
+   * Construct these objects with {@link UsageStatsBuilder}, and set the firstTimestamp and
+   * lastTimestamp fields to make time filtering work in {@link #queryUsageStats}.
+   *
+   * @param intervalType An interval type constant, e.g. {@link UsageStatsManager#INTERVAL_WEEKLY}.
+   */
+  public void addUsageStats(int intervalType, UsageStats stats) {
+    usageStatsByIntervalType.put(intervalType, stats);
+  }
+
+  /**
+   * Returns the current standby bucket of the specified app that is set by {@code
+   * setAppStandbyBucket}. If the standby bucket value has never been set, return {@link
+   * UsageStatsManager.STANDBY_BUCKET_ACTIVE}.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @HiddenApi
+  public @StandbyBuckets int getAppStandbyBucket(String packageName) {
+    Integer bucket = appStandbyBuckets.get(packageName);
+    return (bucket == null) ? UsageStatsManager.STANDBY_BUCKET_ACTIVE : bucket;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @HiddenApi
+  public Map<String, Integer> getAppStandbyBuckets() {
+    return new HashMap<>(appStandbyBuckets);
+  }
+
+  /** Sets the standby bucket of the specified app. */
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @HiddenApi
+  public void setAppStandbyBucket(String packageName, @StandbyBuckets int bucket) {
+    appStandbyBuckets.put(packageName, bucket);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @HiddenApi
+  public void setAppStandbyBuckets(Map<String, Integer> appBuckets) {
+    appStandbyBuckets.putAll(appBuckets);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @HiddenApi
+  protected void registerAppUsageObserver(
+      int observerId,
+      String[] packages,
+      long timeLimit,
+      TimeUnit timeUnit,
+      PendingIntent callbackIntent) {
+    appUsageObserversById.put(
+        observerId,
+        AppUsageObserver.build(
+            observerId, ImmutableList.copyOf(packages), timeLimit, timeUnit, callbackIntent));
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @HiddenApi
+  protected void unregisterAppUsageObserver(int observerId) {
+    appUsageObserversById.remove(observerId);
+  }
+
+  /** Returns the {@link AppUsageObserver}s currently registered in {@link UsageStatsManager}. */
+  public Collection<AppUsageObserver> getRegisteredAppUsageObservers() {
+    return ImmutableList.copyOf(appUsageObserversById.values());
+  }
+
+  /**
+   * Triggers a currently registered {@link AppUsageObserver} with {@code observerId}.
+   *
+   * <p>The observer will be no longer registered afterwards.
+   */
+  public void triggerRegisteredAppUsageObserver(int observerId, long timeUsedInMillis) {
+    AppUsageObserver observer = appUsageObserversById.remove(observerId);
+    long timeLimitInMillis = observer.getTimeUnit().toMillis(observer.getTimeLimit());
+    Intent intent =
+        new Intent()
+            .putExtra(UsageStatsManager.EXTRA_OBSERVER_ID, observerId)
+            .putExtra(UsageStatsManager.EXTRA_TIME_LIMIT, timeLimitInMillis)
+            .putExtra(UsageStatsManager.EXTRA_TIME_USED, timeUsedInMillis);
+    try {
+      observer.getCallbackIntent().send(RuntimeEnvironment.getApplication(), 0, intent);
+    } catch (CanceledException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected void registerUsageSessionObserver(
+      int observerId,
+      String[] packages,
+      Duration sessionStepDuration,
+      Duration thresholdTimeDuration,
+      PendingIntent sessionStepTriggeredIntent,
+      PendingIntent sessionEndedIntent) {
+    usageSessionObserversById.put(
+        observerId,
+        UsageSessionObserver.build(
+            observerId,
+            ImmutableList.copyOf(packages),
+            sessionStepDuration,
+            thresholdTimeDuration,
+            sessionStepTriggeredIntent,
+            sessionEndedIntent));
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  protected void unregisterUsageSessionObserver(int observerId) {
+    usageSessionObserversById.remove(observerId);
+  }
+
+  /**
+   * Returns the {@link UsageSessionObserver}s currently registered in {@link UsageStatsManager}.
+   */
+  public List<UsageSessionObserver> getRegisteredUsageSessionObservers() {
+    return ImmutableList.copyOf(usageSessionObserversById.values());
+  }
+
+  /**
+   * Triggers a currently registered {@link UsageSessionObserver} with {@code observerId}.
+   *
+   * <p>The observer SHOULD be registered afterwards.
+   */
+  public void triggerRegisteredSessionStepObserver(int observerId, long timeUsedInMillis) {
+    UsageSessionObserver observer = usageSessionObserversById.get(observerId);
+    long sessionStepTimeInMillis = observer.getSessionStepDuration().toMillis();
+    Intent intent =
+        new Intent()
+            .putExtra(UsageStatsManager.EXTRA_OBSERVER_ID, observerId)
+            .putExtra(UsageStatsManager.EXTRA_TIME_LIMIT, sessionStepTimeInMillis)
+            .putExtra(UsageStatsManager.EXTRA_TIME_USED, timeUsedInMillis);
+    try {
+      observer.getSessionStepTriggeredIntent().send(RuntimeEnvironment.getApplication(), 0, intent);
+    } catch (CanceledException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Triggers a currently registered {@link UsageSessionObserver} with {@code observerId}.
+   *
+   * <p>The observer SHOULD be registered afterwards.
+   */
+  public void triggerRegisteredSessionEndedObserver(int observerId) {
+    UsageSessionObserver observer = usageSessionObserversById.get(observerId);
+    Intent intent = new Intent().putExtra(UsageStatsManager.EXTRA_OBSERVER_ID, observerId);
+    try {
+      observer.getSessionEndedIntent().send(RuntimeEnvironment.getApplication(), 0, intent);
+    } catch (CanceledException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Registers an app usage limit observer that receives a callback on {@code callbackIntent} when
+   * the sum of usages of apps and tokens in {@code observedEntities} exceeds {@code timeLimit -
+   * timeUsed}.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  @HiddenApi
+  protected void registerAppUsageLimitObserver(
+      int observerId,
+      String[] observedEntities,
+      Duration timeLimit,
+      Duration timeUsed,
+      PendingIntent callbackIntent) {
+    appUsageLimitObserversById.put(
+        observerId,
+        new AppUsageLimitObserver(
+            observerId,
+            ImmutableList.copyOf(observedEntities),
+            timeLimit,
+            timeUsed,
+            callbackIntent));
+  }
+
+  /** Unregisters the app usage limit observer specified by {@code observerId}. */
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  @HiddenApi
+  protected void unregisterAppUsageLimitObserver(int observerId) {
+    appUsageLimitObserversById.remove(observerId);
+  }
+
+  /**
+   * Returns the {@link AppUsageLimitObserver}s currently registered in {@link UsageStatsManager}.
+   */
+  public ImmutableList<AppUsageLimitObserver> getRegisteredAppUsageLimitObservers() {
+    return ImmutableList.copyOf(appUsageLimitObserversById.values());
+  }
+
+  /**
+   * Triggers a currently registered {@link AppUsageLimitObserver} with {@code observerId}.
+   *
+   * <p>The observer will still be registered afterwards.
+   */
+  public void triggerRegisteredAppUsageLimitObserver(int observerId, Duration timeUsed) {
+    AppUsageLimitObserver observer = appUsageLimitObserversById.get(observerId);
+    Intent intent =
+        new Intent()
+            .putExtra(UsageStatsManager.EXTRA_OBSERVER_ID, observerId)
+            .putExtra(UsageStatsManager.EXTRA_TIME_LIMIT, observer.timeLimit.toMillis())
+            .putExtra(UsageStatsManager.EXTRA_TIME_USED, timeUsed.toMillis());
+    try {
+      observer.callbackIntent.send(RuntimeEnvironment.getApplication(), 0, intent);
+    } catch (CanceledException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Returns the current app's standby bucket that is set by {@code setCurrentAppStandbyBucket}. If
+   * the standby bucket value has never been set, return {@link
+   * UsageStatsManager.STANDBY_BUCKET_ACTIVE}.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.P)
+  @StandbyBuckets
+  protected int getAppStandbyBucket() {
+    return currentAppStandbyBucket;
+  }
+
+  /** Sets the current app's standby bucket */
+  public void setCurrentAppStandbyBucket(@StandbyBuckets int bucket) {
+    currentAppStandbyBucket = bucket;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.Q)
+  @UsageSource
+  @HiddenApi
+  protected int getUsageSource() {
+    return currentUsageSource;
+  }
+
+  /** Sets what app usage observers will consider the source of usage for an activity. */
+  @TargetApi(Build.VERSION_CODES.Q)
+  public void setUsageSource(@UsageSource int usageSource) {
+    currentUsageSource = usageSource;
+  }
+
+  /**
+   * Requires loose signatures because return value is a list of {@link BroadcastResponseStats},
+   * which is a hidden class introduced in Android T.
+   */
+  @SuppressWarnings("unchecked")
+  @Implementation(minSdk = TIRAMISU)
+  protected Object /* List<BroadcastResponseStats> */ queryBroadcastResponseStats(
+      @Nullable Object packageName, Object id) {
+    List<BroadcastResponseStats> result = new ArrayList<>();
+    for (Map.Entry<String, Map<Long, Object /*BroadcastResponseStats*/>> entry :
+        appBroadcastStats.entrySet()) {
+      if (packageName == null || entry.getKey().equals(packageName)) {
+        result.addAll(
+            (List<BroadcastResponseStats>)
+                queryBroadcastResponseStatsForId(entry.getValue(), (long) id));
+      }
+    }
+    return result;
+  }
+
+  private Object /* List<BroadcastResponseStats> */ queryBroadcastResponseStatsForId(
+      Map<Long, Object /*BroadcastResponseStats*/> idToResponseStats, long id) {
+    List<BroadcastResponseStats> result = new ArrayList<>();
+    for (Map.Entry<Long, Object /*BroadcastResponseStats*/> entry : idToResponseStats.entrySet()) {
+      if (id == 0 || entry.getKey() == id) {
+        result.add((BroadcastResponseStats) entry.getValue());
+      }
+    }
+    return result;
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected void clearBroadcastResponseStats(@Nullable String packageName, long id) {
+    for (Map.Entry<String, Map<Long, Object /*BroadcastResponseStats*/>> entry :
+        appBroadcastStats.entrySet()) {
+      if (packageName == null || entry.getKey().equals(packageName)) {
+        clearBroadcastResponseStatsForId(entry.getValue(), id);
+      }
+    }
+    appBroadcastStats.values().removeIf(Map::isEmpty);
+  }
+
+  private void clearBroadcastResponseStatsForId(
+      Map<Long, Object /*BroadcastResponseStats*/> idToResponseStats, long idToRemove) {
+    idToResponseStats.keySet().removeIf(id -> id == idToRemove || idToRemove == 0);
+  }
+
+  @TargetApi(Build.VERSION_CODES.TIRAMISU)
+  public void addBroadcastResponseStats(Object /*BroadcastResponseStats*/ statsObject) {
+    BroadcastResponseStats stats = (BroadcastResponseStats) statsObject;
+    Map<Long, Object /*BroadcastResponseStats*/> idToStats =
+        appBroadcastStats.computeIfAbsent(
+            stats.getPackageName(), unused -> Maps.newConcurrentMap());
+    idToStats.put(stats.getId(), stats);
+  }
+
+  @Resetter
+  public static void reset() {
+    currentAppStandbyBucket = UsageStatsManager.STANDBY_BUCKET_ACTIVE;
+    currentUsageSource = UsageStatsManager.USAGE_SOURCE_TASK_ROOT_ACTIVITY;
+    eventsByTimeStamp.clear();
+
+    appStandbyBuckets.clear();
+    appUsageObserversById.clear();
+    usageSessionObserversById.clear();
+    appUsageLimitObserversById.clear();
+  }
+
+  /**
+   * Builder for constructing {@link UsageStats} objects. The constructor of UsageStats is not part
+   * of the Android API.
+   */
+  public static class UsageStatsBuilder {
+    private UsageStats usageStats = new UsageStats();
+
+    // Use {@link #newBuilder} to construct builders.
+    private UsageStatsBuilder() {}
+
+    public static UsageStatsBuilder newBuilder() {
+      return new UsageStatsBuilder();
+    }
+
+    public UsageStats build() {
+      return usageStats;
+    }
+
+    public UsageStatsBuilder setPackageName(String packageName) {
+      usageStats.mPackageName = packageName;
+      return this;
+    }
+
+    public UsageStatsBuilder setFirstTimeStamp(long firstTimeStamp) {
+      usageStats.mBeginTimeStamp = firstTimeStamp;
+      return this;
+    }
+
+    public UsageStatsBuilder setLastTimeStamp(long lastTimeStamp) {
+      usageStats.mEndTimeStamp = lastTimeStamp;
+      return this;
+    }
+
+    public UsageStatsBuilder setTotalTimeInForeground(long totalTimeInForeground) {
+      usageStats.mTotalTimeInForeground = totalTimeInForeground;
+      return this;
+    }
+
+    public UsageStatsBuilder setLastTimeUsed(long lastTimeUsed) {
+      usageStats.mLastTimeUsed = lastTimeUsed;
+      return this;
+    }
+  }
+
+  /**
+   * Builder for constructing {@link Event} objects. The fields of Event are not part of the Android
+   * API.
+   */
+  public static class EventBuilder {
+    private Event event = new Event();
+
+    private EventBuilder() {}
+
+    public static EventBuilder fromEvent(Event event) {
+      EventBuilder eventBuilder =
+          new EventBuilder()
+              .setPackage(event.mPackage)
+              .setClass(event.mClass)
+              .setTimeStamp(event.mTimeStamp)
+              .setEventType(event.mEventType)
+              .setConfiguration(event.mConfiguration);
+      if (event.mEventType == Event.CONFIGURATION_CHANGE) {
+        eventBuilder.setConfiguration(new Configuration());
+      }
+      return eventBuilder;
+    }
+
+    public static EventBuilder buildEvent() {
+      return new EventBuilder();
+    }
+
+    public Event build() {
+      return event;
+    }
+
+    public EventBuilder setPackage(String packageName) {
+      event.mPackage = packageName;
+      return this;
+    }
+
+    public EventBuilder setClass(String className) {
+      event.mClass = className;
+      return this;
+    }
+
+    public EventBuilder setTimeStamp(long timeStamp) {
+      event.mTimeStamp = timeStamp;
+      return this;
+    }
+
+    public EventBuilder setEventType(int eventType) {
+      event.mEventType = eventType;
+      return this;
+    }
+
+    public EventBuilder setConfiguration(Configuration configuration) {
+      event.mConfiguration = configuration;
+      return this;
+    }
+
+    public EventBuilder setShortcutId(String shortcutId) {
+      event.mShortcutId = shortcutId;
+      return this;
+    }
+
+    @TargetApi(Build.VERSION_CODES.Q)
+    public EventBuilder setInstanceId(int instanceId) {
+      event.mInstanceId = instanceId;
+      return this;
+    }
+
+    @TargetApi(Build.VERSION_CODES.Q)
+    public EventBuilder setTaskRootPackage(String taskRootPackage) {
+      event.mTaskRootPackage = taskRootPackage;
+      return this;
+    }
+
+    @TargetApi(Build.VERSION_CODES.Q)
+    public EventBuilder setTaskRootClass(String taskRootClass) {
+      event.mTaskRootClass = taskRootClass;
+      return this;
+    }
+
+    @TargetApi(Build.VERSION_CODES.P)
+    public EventBuilder setAppStandbyBucket(int bucket) {
+      event.mBucketAndReason &= 0xFFFF;
+      event.mBucketAndReason |= bucket << 16;
+      return this;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbDeviceConnection.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbDeviceConnection.java
new file mode 100644
index 0000000..af3501a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbDeviceConnection.java
@@ -0,0 +1,136 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.O;
+
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.hardware.usb.UsbRequest;
+import java.io.IOException;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.concurrent.TimeoutException;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Robolectric implementation of {@link android.hardware.usb.UsbDeviceConnection}. */
+@Implements(value = UsbDeviceConnection.class)
+public class ShadowUsbDeviceConnection {
+
+  private PipedInputStream outgoingDataPipedInputStream;
+  private PipedOutputStream outgoingDataPipedOutputStream;
+
+  private DataListener dataListener;
+
+  @Implementation
+  protected boolean claimInterface(UsbInterface intf, boolean force) {
+    try {
+      this.outgoingDataPipedInputStream = new PipedInputStream();
+      this.outgoingDataPipedOutputStream = new PipedOutputStream(outgoingDataPipedInputStream);
+    } catch (IOException e) {
+      return false;
+    }
+
+    return true;
+  }
+
+  @Implementation
+  protected boolean releaseInterface(UsbInterface intf) {
+    try {
+      outgoingDataPipedInputStream.close();
+    } catch (IOException e) {
+      // ignored
+    }
+    try {
+      outgoingDataPipedOutputStream.close();
+    } catch (IOException e) {
+      // ignored
+    }
+
+    return true;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected int controlTransfer(
+      int requestType, int request, int value, int index, byte[] buffer, int length, int timeout) {
+    return length;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected int controlTransfer(
+      int requestType,
+      int request,
+      int value,
+      int index,
+      byte[] buffer,
+      int offset,
+      int length,
+      int timeout) {
+    return length;
+  }
+
+  @Implementation
+  protected UsbRequest requestWait() {
+    if (dataListener == null) {
+      throw new IllegalStateException("No UsbRequest initialized for this UsbDeviceConnection");
+    }
+
+    return dataListener.getUsbRequest();
+  }
+
+  @Implementation(minSdk = O)
+  protected UsbRequest requestWait(long timeout) throws TimeoutException {
+    return requestWait();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected int bulkTransfer(
+      UsbEndpoint endpoint, byte[] buffer, int offset, int length, int timeout) {
+    try {
+      outgoingDataPipedOutputStream.write(buffer, offset, length);
+      return length;
+    } catch (IOException e) {
+      return -1;
+    }
+  }
+
+  @Implementation
+  protected int bulkTransfer(UsbEndpoint endpoint, byte[] buffer, int length, int timeout) {
+    try {
+      outgoingDataPipedOutputStream.write(buffer, /* off= */ 0, length);
+      return length;
+    } catch (IOException e) {
+      return -1;
+    }
+  }
+
+  /** Fills the buffer with data that was written by UsbDeviceConnection#bulkTransfer. */
+  public void readOutgoingData(byte[] buffer) throws IOException {
+    outgoingDataPipedInputStream.read(buffer);
+  }
+
+  /** Passes data that can then be read by an initialized UsbRequest#queue(ByteBuffer). */
+  public void writeIncomingData(byte[] data) {
+    if (dataListener == null) {
+      throw new IllegalStateException("No UsbRequest initialized for this UsbDeviceConnection");
+    }
+
+    dataListener.onDataReceived(data);
+  }
+
+  void registerDataListener(DataListener dataListener) {
+    this.dataListener = dataListener;
+  }
+
+  void unregisterDataListener() {
+    this.dataListener = null;
+  }
+
+  interface DataListener {
+    void onDataReceived(byte[] data);
+
+    UsbRequest getUsbRequest();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbManager.java
new file mode 100644
index 0000000..6e7ab58
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbManager.java
@@ -0,0 +1,362 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+import static org.robolectric.util.ReflectionHelpers.getStaticField;
+
+import android.annotation.Nullable;
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.hardware.usb.UsbAccessory;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbManager;
+import android.hardware.usb.UsbPort;
+import android.hardware.usb.UsbPortStatus;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import com.google.common.base.Preconditions;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.ForType;
+
+/** Robolectric implementation of {@link android.hardware.usb.UsbManager}. */
+@Implements(value = UsbManager.class, looseSignatures = true)
+public class ShadowUsbManager {
+
+  @RealObject private UsbManager realUsbManager;
+
+  /**
+   * A mapping from the package names to a list of USB devices for which permissions are granted.
+   */
+  private final HashMap<String, List<UsbDevice>> grantedDevicePermissions = new HashMap<>();
+
+  /**
+   * A mapping from the package names to a list of USB accessories for which permissions are
+   * granted.
+   */
+  private final HashMap<String, List<UsbAccessory>> grantedAccessoryPermissions = new HashMap<>();
+
+  /**
+   * A mapping from the USB device names to the USB device instances.
+   *
+   * @see UsbManager#getDeviceList()
+   */
+  private final HashMap<String, UsbDevice> usbDevices = new HashMap<>();
+
+  /** A mapping from USB port ID to the port object. */
+  private final HashMap<String, UsbPort> usbPorts = new HashMap<>();
+
+  /** A mapping from USB port to the status of that port. */
+  private final HashMap<UsbPort, UsbPortStatus> usbPortStatuses = new HashMap<>();
+
+  private UsbAccessory attachedUsbAccessory = null;
+
+  /** Returns true if the caller has permission to access the device. */
+  @Implementation
+  protected boolean hasPermission(UsbDevice device) {
+    return hasPermissionForPackage(device, RuntimeEnvironment.getApplication().getPackageName());
+  }
+
+  /** Returns true if the given package has permission to access the device. */
+  public boolean hasPermissionForPackage(UsbDevice device, String packageName) {
+    List<UsbDevice> usbDevices = grantedDevicePermissions.get(packageName);
+    return usbDevices != null && usbDevices.contains(device);
+  }
+
+  /** Returns true if the caller has permission to access the accessory. */
+  @Implementation
+  protected boolean hasPermission(UsbAccessory accessory) {
+    return hasPermissionForPackage(accessory, RuntimeEnvironment.getApplication().getPackageName());
+  }
+
+  /** Returns true if the given package has permission to access the device. */
+  public boolean hasPermissionForPackage(UsbAccessory accessory, String packageName) {
+    List<UsbAccessory> usbAccessories = grantedAccessoryPermissions.get(packageName);
+    return usbAccessories != null && usbAccessories.contains(accessory);
+  }
+
+  @Implementation(minSdk = N)
+  @HiddenApi
+  protected void grantPermission(UsbDevice device) {
+    grantPermission(device, RuntimeEnvironment.getApplication().getPackageName());
+  }
+
+  @Implementation(minSdk = N_MR1)
+  @HiddenApi // SystemApi
+  protected void grantPermission(UsbDevice device, String packageName) {
+    List<UsbDevice> usbDevices = grantedDevicePermissions.get(packageName);
+    if (usbDevices == null) {
+      usbDevices = new ArrayList<>();
+      grantedDevicePermissions.put(packageName, usbDevices);
+    }
+    usbDevices.add(device);
+  }
+
+  /** Grants permission for the accessory. */
+  public void grantPermission(UsbAccessory accessory) {
+    String packageName = RuntimeEnvironment.getApplication().getPackageName();
+    List<UsbAccessory> usbAccessories = grantedAccessoryPermissions.get(packageName);
+    if (usbAccessories == null) {
+      usbAccessories = new ArrayList<>();
+      grantedAccessoryPermissions.put(packageName, usbAccessories);
+    }
+    usbAccessories.add(accessory);
+  }
+
+  /**
+   * Revokes permission to a USB device granted to a package. This method does nothing if the
+   * package doesn't have permission to access the device.
+   */
+  public void revokePermission(UsbDevice device, String packageName) {
+    List<UsbDevice> usbDevices = grantedDevicePermissions.get(packageName);
+    if (usbDevices != null) {
+      usbDevices.remove(device);
+    }
+  }
+
+  /**
+   * Revokes permission to a USB accessory granted to a package. This method does nothing if the
+   * package doesn't have permission to access the accessory.
+   */
+  public void revokePermission(UsbAccessory accessory, String packageName) {
+    List<UsbAccessory> usbAccessories = grantedAccessoryPermissions.get(packageName);
+    if (usbAccessories != null) {
+      usbAccessories.remove(accessory);
+    }
+  }
+
+  /**
+   * Returns a HashMap containing all USB devices currently attached. USB device name is the key for
+   * the returned HashMap. The result will be empty if no devices are attached, or if USB host mode
+   * is inactive or unsupported.
+   */
+  @Implementation
+  protected HashMap<String, UsbDevice> getDeviceList() {
+    return new HashMap<>(usbDevices);
+  }
+
+  @Implementation
+  protected UsbAccessory[] getAccessoryList() {
+    // Currently Android only supports having a single accessory attached, and if nothing
+    // is attached, this method actually returns null in the real implementation.
+    if (attachedUsbAccessory == null) {
+      return null;
+    }
+
+    return new UsbAccessory[] {attachedUsbAccessory};
+  }
+
+  /** Sets the currently attached Usb accessory returned in #getAccessoryList. */
+  public void setAttachedUsbAccessory(UsbAccessory usbAccessory) {
+    this.attachedUsbAccessory = usbAccessory;
+  }
+
+  /**
+   * Adds a USB device into available USB devices map with permission value. If the USB device
+   * already exists, updates the USB device with new permission value.
+   */
+  public void addOrUpdateUsbDevice(UsbDevice usbDevice, boolean hasPermission) {
+    Preconditions.checkNotNull(usbDevice);
+    Preconditions.checkNotNull(usbDevice.getDeviceName());
+    usbDevices.put(usbDevice.getDeviceName(), usbDevice);
+    if (hasPermission) {
+      grantPermission(usbDevice);
+    } else {
+      revokePermission(usbDevice, RuntimeEnvironment.getApplication().getPackageName());
+    }
+  }
+
+  /** Removes a USB device from available USB devices map. */
+  public void removeUsbDevice(UsbDevice usbDevice) {
+    Preconditions.checkNotNull(usbDevice);
+    usbDevices.remove(usbDevice.getDeviceName());
+    revokePermission(usbDevice, RuntimeEnvironment.getApplication().getPackageName());
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected /* UsbPort[] */ Object getPorts() {
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      return new ArrayList<>(usbPortStatuses.keySet());
+    }
+
+    return usbPortStatuses.keySet().toArray(new UsbPort[usbPortStatuses.size()]);
+  }
+
+  /** Remove all added ports from UsbManager. */
+  public void clearPorts() {
+    usbPorts.clear();
+    usbPortStatuses.clear();
+  }
+
+  /** Adds a USB port with given ID to UsbManager. */
+  public void addPort(String portId) {
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      addPort(
+          portId,
+          UsbPortStatus.MODE_DUAL,
+          UsbPortStatus.POWER_ROLE_SINK,
+          UsbPortStatus.DATA_ROLE_DEVICE,
+          0);
+      return;
+    }
+
+    UsbPort usbPort =
+        callConstructor(
+            UsbPort.class,
+            from(String.class, portId),
+            from(int.class, getStaticField(UsbPort.class, "MODE_DUAL")));
+    usbPorts.put(portId, usbPort);
+    usbPortStatuses.put(
+        usbPort,
+        (UsbPortStatus)
+            createUsbPortStatus(
+                getStaticField(UsbPort.class, "MODE_DUAL"),
+                getStaticField(UsbPort.class, "POWER_ROLE_SINK"),
+                getStaticField(UsbPort.class, "DATA_ROLE_DEVICE"),
+                0));
+  }
+
+  /** Adds a USB port with given ID and {@link UsbPortStatus} parameters to UsbManager for Q+. */
+  @TargetApi(Build.VERSION_CODES.Q)
+  public void addPort(
+      String portId,
+      int statusCurrentMode,
+      int statusCurrentPowerRole,
+      int statusCurrentDataRole,
+      int statusSupportedRoleCombinations) {
+    Preconditions.checkState(RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q);
+    UsbPort usbPort = (UsbPort) createUsbPort(realUsbManager, portId, statusCurrentMode);
+    usbPorts.put(portId, usbPort);
+    usbPortStatuses.put(
+        usbPort,
+        (UsbPortStatus)
+            createUsbPortStatus(
+                statusCurrentMode,
+                statusCurrentPowerRole,
+                statusCurrentDataRole,
+                statusSupportedRoleCombinations));
+  }
+
+  /**
+   * Returns the {@link UsbPortStatus} corresponding to the {@link UsbPort} with given {@code
+   * portId} if present; otherwise returns {@code null}.
+   */
+  @Nullable
+  public /* UsbPortStatus */ Object getPortStatus(String portId) {
+    return usbPortStatuses.get(usbPorts.get(portId));
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected /* UsbPortStatus */ Object getPortStatus(/* UsbPort */ Object port) {
+    return usbPortStatuses.get(port);
+  }
+
+  @Implementation(minSdk = M)
+  @HiddenApi
+  protected void setPortRoles(
+      /* UsbPort */ Object port, /* int */ Object powerRole, /* int */ Object dataRole) {
+    UsbPortStatus status = usbPortStatuses.get(port);
+    usbPortStatuses.put(
+        (UsbPort) port,
+        (UsbPortStatus)
+            createUsbPortStatus(
+                status.getCurrentMode(),
+                (int) powerRole,
+                (int) dataRole,
+                status.getSupportedRoleCombinations()));
+    RuntimeEnvironment.getApplication()
+        .sendBroadcast(new Intent(UsbManager.ACTION_USB_PORT_CHANGED));
+  }
+
+  /** Opens a file descriptor from a temporary file. */
+  @Implementation
+  protected UsbDeviceConnection openDevice(UsbDevice device) {
+    return createUsbDeviceConnection(device);
+  }
+
+  /** Opens a file descriptor from a temporary file. */
+  @Implementation
+  protected ParcelFileDescriptor openAccessory(UsbAccessory accessory) {
+    try {
+      File tmpUsbDir =
+          RuntimeEnvironment.getTempDirectory().createIfNotExists("usb-accessory").toFile();
+      return ParcelFileDescriptor.open(
+          new File(tmpUsbDir, "usb-accessory-file"), ParcelFileDescriptor.MODE_READ_WRITE);
+    } catch (FileNotFoundException error) {
+      throw new RuntimeException("Error shadowing openAccessory", error);
+    }
+  }
+
+  /**
+   * Helper method for creating a {@link UsbPortStatus}.
+   *
+   * <p>Returns Object to avoid referencing the API M+ UsbPortStatus when running on older
+   * platforms.
+   */
+  private static Object createUsbPortStatus(
+      int currentMode, int currentPowerRole, int currentDataRole, int supportedRoleCombinations) {
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      return new UsbPortStatus(
+          currentMode, currentPowerRole, currentDataRole, supportedRoleCombinations, 0, 0);
+    }
+    return callConstructor(
+        UsbPortStatus.class,
+        from(int.class, currentMode),
+        from(int.class, currentPowerRole),
+        from(int.class, currentDataRole),
+        from(int.class, supportedRoleCombinations));
+  }
+
+  /**
+   * Helper method for creating a {@link UsbPort}.
+   *
+   * <p>Returns Object to avoid referencing the API M+ UsbPort when running on older platforms.
+   */
+  private static Object createUsbPort(UsbManager usbManager, String id, int supportedModes) {
+    if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) {
+      return new UsbPort(usbManager, id, supportedModes, 0, false, false);
+    }
+    return callConstructor(
+        UsbPort.class,
+        from(UsbManager.class, usbManager),
+        from(String.class, id),
+        from(int.class, supportedModes));
+  }
+
+  /** Helper method for creating a {@link UsbDeviceConnection}. */
+  private static UsbDeviceConnection createUsbDeviceConnection(UsbDevice device) {
+    return callConstructor(UsbDeviceConnection.class, from(UsbDevice.class, device));
+  }
+
+  /** Accessor interface for {@link UsbManager}'s internals. */
+  @ForType(UsbManager.class)
+  public interface _UsbManager_ {
+
+    UsbPort[] getPorts();
+
+    UsbPortStatus getPortStatus(UsbPort port);
+
+    void setPortRoles(UsbPort port, int powerRole, int dataRole);
+  }
+
+  /** Accessor interface for {@link UsbManager}'s internals (Q+). */
+  @ForType(UsbManager.class)
+  public interface _UsbManagerQ_ {
+
+    List<UsbPort> getPorts();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbRequest.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbRequest.java
new file mode 100644
index 0000000..b266d04
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUsbRequest.java
@@ -0,0 +1,110 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static java.lang.Math.min;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbRequest;
+import android.os.Build;
+import java.io.IOException;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.nio.ByteBuffer;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/** Robolectric implementation of {@link android.hardware.usb.UsbRequest}. */
+@Implements(value = UsbRequest.class)
+public class ShadowUsbRequest {
+
+  @RealObject private UsbRequest realUsbRequest;
+
+  private UsbDeviceConnection usbDeviceConnection;
+
+  private PipedInputStream incomingDataPipedInputStream;
+  private PipedOutputStream incomingDataPipedOutputStream;
+
+  private final ShadowUsbDeviceConnection.DataListener dataListener =
+      new ShadowUsbDeviceConnection.DataListener() {
+        @Override
+        public void onDataReceived(byte[] data) {
+          try {
+            incomingDataPipedOutputStream.write(data);
+          } catch (IOException e) {
+            // ignored
+          }
+        }
+
+        @Override
+        public UsbRequest getUsbRequest() {
+          return realUsbRequest;
+        }
+      };
+
+  @Implementation
+  protected boolean initialize(UsbDeviceConnection connection, UsbEndpoint endpoint) {
+    try {
+      this.incomingDataPipedInputStream = new PipedInputStream();
+      this.incomingDataPipedOutputStream = new PipedOutputStream(incomingDataPipedInputStream);
+    } catch (IOException e) {
+      return false;
+    }
+
+    shadowOf(connection).registerDataListener(dataListener);
+    this.usbDeviceConnection = connection;
+    return true;
+  }
+
+  @Implementation
+  protected void close() {
+    if (usbDeviceConnection != null) {
+      shadowOf(usbDeviceConnection).unregisterDataListener();
+      usbDeviceConnection = null;
+
+      try {
+        incomingDataPipedInputStream.close();
+      } catch (IOException e) {
+        // ignored
+      }
+      try {
+        incomingDataPipedOutputStream.close();
+      } catch (IOException e) {
+        // ignored
+      }
+    }
+  }
+
+  @Implementation
+  protected boolean queue(ByteBuffer buffer, int length) {
+    if (Build.VERSION.SDK_INT < P) {
+      length = min(length, 16384);
+    }
+
+    byte[] bytes = new byte[length];
+    try {
+      int totalBytesRead = 0;
+      while (totalBytesRead < length) {
+        int bytesRead =
+            incomingDataPipedInputStream.read(
+                bytes, /*off=*/ totalBytesRead, /*len=*/ length - totalBytesRead);
+        if (bytesRead < 0) {
+          return false;
+        }
+        totalBytesRead += bytesRead;
+      }
+    } catch (IOException e) {
+      return false;
+    }
+    buffer.put(bytes);
+    return true;
+  }
+
+  @Implementation(minSdk = O)
+  protected boolean queue(ByteBuffer buffer) {
+    return queue(buffer, buffer.remaining());
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java
new file mode 100644
index 0000000..5a17708
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java
@@ -0,0 +1,1166 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.N_MR1;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static android.os.UserManager.USER_TYPE_FULL_GUEST;
+import static android.os.UserManager.USER_TYPE_FULL_RESTRICTED;
+import static android.os.UserManager.USER_TYPE_FULL_SECONDARY;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.Manifest.permission;
+import android.annotation.UserIdInt;
+import android.app.Application;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.UserInfo;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IUserManager;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Robolectric implementation of {@link android.os.UserManager}. */
+@Implements(value = UserManager.class, minSdk = JELLY_BEAN_MR1)
+public class ShadowUserManager {
+
+  /**
+   * The default user ID user for secondary user testing, when the ID is not otherwise specified.
+   */
+  public static final int DEFAULT_SECONDARY_USER_ID = 10;
+
+  private static final int DEFAULT_MAX_SUPPORTED_USERS = 1;
+
+  public static final int FLAG_PRIMARY = UserInfo.FLAG_PRIMARY;
+  public static final int FLAG_ADMIN = UserInfo.FLAG_ADMIN;
+  public static final int FLAG_GUEST = UserInfo.FLAG_GUEST;
+  public static final int FLAG_RESTRICTED = UserInfo.FLAG_RESTRICTED;
+  public static final int FLAG_DEMO = UserInfo.FLAG_DEMO;
+  public static final int FLAG_MANAGED_PROFILE = UserInfo.FLAG_MANAGED_PROFILE;
+  public static final int FLAG_PROFILE = UserInfo.FLAG_PROFILE;
+  public static final int FLAG_FULL = UserInfo.FLAG_FULL;
+  public static final int FLAG_SYSTEM = UserInfo.FLAG_SYSTEM;
+
+  private static int maxSupportedUsers = DEFAULT_MAX_SUPPORTED_USERS;
+  private static boolean isMultiUserSupported = false;
+
+  @RealObject private UserManager realObject;
+  private UserManagerState userManagerState;
+  private Boolean managedProfile;
+  private boolean userUnlocked = true;
+  private boolean isSystemUser = true;
+
+  /**
+   * Holds whether or not a managed profile can be unlocked. If a profile is not in this map, it is
+   * assume it can be unlocked.
+   */
+  private String seedAccountName;
+
+  private String seedAccountType;
+  private PersistableBundle seedAccountOptions;
+
+  private Context context;
+  private boolean enforcePermissions;
+  private int userSwitchability = UserManager.SWITCHABILITY_STATUS_OK;
+
+  /**
+   * Global UserManager state. Shared across {@link UserManager}s created in different {@link
+   * Context}s.
+   */
+  static class UserManagerState {
+    private final Map<Integer, Integer> userPidMap = new HashMap<>();
+    /** Holds the serial numbers for all users and profiles, indexed by UserHandle.id */
+    private final BiMap<Integer, Long> userSerialNumbers = HashBiMap.create();
+    /** Holds all UserStates, indexed by UserHandle.id */
+    private final Map<Integer, UserState> userState = new HashMap<>();
+    /** Holds the UserInfo for all registered users and profiles, indexed by UserHandle.id */
+    private final Map<Integer, UserInfo> userInfoMap = new HashMap<>();
+    /**
+     * Each user holds a list of UserHandles of assocated profiles and user itself. User is indexed
+     * by UserHandle.id. See UserManager.getProfiles(userId).
+     */
+    private final Map<Integer, List<UserHandle>> userProfilesListMap = new HashMap<>();
+
+    private final Map<Integer, Bundle> userRestrictions = new HashMap<>();
+    private final Map<String, Bundle> applicationRestrictions = new HashMap<>();
+    private final Map<Integer, Boolean> profileIsLocked = new HashMap<>();
+    private final Map<Integer, Bitmap> userIcon = new HashMap<>();
+
+    private int nextUserId = DEFAULT_SECONDARY_USER_ID;
+
+    public UserManagerState() {
+      int id = UserHandle.USER_SYSTEM;
+      String name = "system_user";
+      int flags = UserInfo.FLAG_PRIMARY | UserInfo.FLAG_ADMIN;
+
+      userSerialNumbers.put(id, (long) id);
+      // Start the user as shut down.
+      userState.put(id, UserState.STATE_SHUTDOWN);
+
+      // Update UserInfo regardless if was added or not
+      userInfoMap.put(id, new UserInfo(id, name, flags));
+      userProfilesListMap.put(id, new ArrayList<>());
+      // getUserProfiles() includes user's handle
+      userProfilesListMap.get(id).add(new UserHandle(id));
+      userPidMap.put(id, Process.myUid());
+    }
+  }
+
+  @Implementation
+  protected void __constructor__(Context context, IUserManager service) {
+    this.context = context;
+    invokeConstructor(
+        UserManager.class,
+        realObject,
+        from(Context.class, context),
+        from(IUserManager.class, service));
+
+    userManagerState = ShadowApplication.getInstance().getUserManagerState();
+  }
+
+  /**
+   * Compared to real Android, there is no check that the package name matches the application
+   * package name and the method returns instantly.
+   *
+   * @see #setApplicationRestrictions(String, Bundle)
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected Bundle getApplicationRestrictions(String packageName) {
+    Bundle bundle = userManagerState.applicationRestrictions.get(packageName);
+    return bundle != null ? bundle : new Bundle();
+  }
+
+  /** Sets the value returned by {@link UserManager#getApplicationRestrictions(String)}. */
+  public void setApplicationRestrictions(String packageName, Bundle restrictions) {
+    userManagerState.applicationRestrictions.put(packageName, restrictions);
+  }
+
+  /**
+   * Adds a profile associated for the user that the calling process is running on.
+   *
+   * <p>The user is assigned an arbitrary unique serial number.
+   *
+   * @return the user's serial number
+   * @deprecated use either addUser() or addProfile()
+   */
+  @Deprecated
+  public long addUserProfile(UserHandle userHandle) {
+    addProfile(UserHandle.myUserId(), userHandle.getIdentifier(), "", 0);
+    return userManagerState.userSerialNumbers.get(userHandle.getIdentifier());
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected List<UserHandle> getUserProfiles() {
+    ImmutableList.Builder<UserHandle> builder = new ImmutableList.Builder<>();
+    List<UserHandle> profiles = userManagerState.userProfilesListMap.get(UserHandle.myUserId());
+    if (profiles != null) {
+      return builder.addAll(profiles).build();
+    }
+    for (List<UserHandle> profileList : userManagerState.userProfilesListMap.values()) {
+      if (profileList.contains(Process.myUserHandle())) {
+        return builder.addAll(profileList).build();
+      }
+    }
+    return ImmutableList.of(Process.myUserHandle());
+  }
+
+  /**
+   * If any profiles have been added using {@link #addProfile}, return those profiles.
+   *
+   * <p>Otherwise follow real android behaviour.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected List<UserInfo> getProfiles(int userHandle) {
+    if (userManagerState.userProfilesListMap.containsKey(userHandle)) {
+      ArrayList<UserInfo> infos = new ArrayList<>();
+      for (UserHandle profileHandle : userManagerState.userProfilesListMap.get(userHandle)) {
+        infos.add(userManagerState.userInfoMap.get(profileHandle.getIdentifier()));
+      }
+      return infos;
+    }
+    return reflector(UserManagerReflector.class, realObject).getProfiles(userHandle);
+  }
+
+  @Implementation(minSdk = R)
+  protected List<UserHandle> getEnabledProfiles() {
+    ArrayList<UserHandle> userHandles = new ArrayList<>();
+    for (UserHandle profileHandle : getAllProfiles()) {
+      if (userManagerState.userInfoMap.get(profileHandle.getIdentifier()).isEnabled()) {
+        userHandles.add(profileHandle);
+      }
+    }
+
+    return userHandles;
+  }
+
+  @Implementation(minSdk = R)
+  protected List<UserHandle> getAllProfiles() {
+    ArrayList<UserHandle> userHandles = new ArrayList<>();
+    if (userManagerState.userProfilesListMap.containsKey(context.getUserId())) {
+      userHandles.addAll(userManagerState.userProfilesListMap.get(context.getUserId()));
+      return userHandles;
+    }
+
+    userHandles.add(UserHandle.of(context.getUserId()));
+    return userHandles;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected UserInfo getProfileParent(int userId) {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException("Requires MANAGE_USERS permission");
+    }
+    UserInfo profile = getUserInfo(userId);
+    if (profile == null) {
+      return null;
+    }
+    int parentUserId = profile.profileGroupId;
+    if (parentUserId == userId || parentUserId == UserInfo.NO_PROFILE_GROUP_ID) {
+      return null;
+    } else {
+      return getUserInfo(parentUserId);
+    }
+  }
+
+  @Implementation(minSdk = R)
+  protected UserHandle createProfile(String name, String userType, Set<String> disallowedPackages) {
+    int flags = getDefaultUserTypeFlags(userType);
+    flags |= FLAG_PROFILE; // assume createProfile used with a profile userType
+    if (enforcePermissions && !hasManageUsersPermission() && !hasCreateUsersPermission()) {
+      throw new SecurityException(
+          "You either need MANAGE_USERS or CREATE_USERS "
+              + "permission to create an user with flags: "
+              + flags);
+    }
+
+    if (userManagerState.userInfoMap.size() >= getMaxSupportedUsers()) {
+      return null;
+    }
+
+    int profileId = userManagerState.nextUserId++;
+    addProfile(context.getUserId(), profileId, name, flags);
+    userManagerState.userInfoMap.get(profileId).userType = userType;
+    return UserHandle.of(profileId);
+  }
+
+  private static int getDefaultUserTypeFlags(String userType) {
+    switch (userType) {
+      case UserManager.USER_TYPE_PROFILE_MANAGED:
+        return FLAG_PROFILE | FLAG_MANAGED_PROFILE;
+      case UserManager.USER_TYPE_FULL_SECONDARY:
+        return FLAG_FULL;
+      case UserManager.USER_TYPE_FULL_GUEST:
+        return FLAG_FULL | FLAG_GUEST;
+      case UserManager.USER_TYPE_FULL_DEMO:
+        return FLAG_FULL | FLAG_DEMO;
+      case UserManager.USER_TYPE_FULL_RESTRICTED:
+        return FLAG_FULL | FLAG_RESTRICTED;
+      case UserManager.USER_TYPE_FULL_SYSTEM:
+        return FLAG_FULL | FLAG_SYSTEM;
+      case UserManager.USER_TYPE_SYSTEM_HEADLESS:
+        return FLAG_SYSTEM;
+      default:
+        return 0;
+    }
+  }
+
+  /** Add a profile to be returned by {@link #getProfiles(int)}.* */
+  public void addProfile(
+      int userHandle, int profileUserHandle, String profileName, int profileFlags) {
+    // Don't override serial number set by setSerialNumberForUser()
+    if (!userManagerState.userSerialNumbers.containsKey(profileUserHandle)) {
+      // use UserHandle id as serial number unless setSerialNumberForUser() is used
+      userManagerState.userSerialNumbers.put(profileUserHandle, (long) profileUserHandle);
+    }
+    UserInfo profileUserInfo = new UserInfo(profileUserHandle, profileName, profileFlags);
+    if (RuntimeEnvironment.getApiLevel() >= LOLLIPOP) {
+      profileUserInfo.profileGroupId = userHandle;
+      UserInfo parentUserInfo = getUserInfo(userHandle);
+      if (parentUserInfo != null) {
+        parentUserInfo.profileGroupId = userHandle;
+      }
+    }
+    userManagerState.userInfoMap.put(profileUserHandle, profileUserInfo);
+    // Insert profile to the belonging user's userProfilesList
+    userManagerState.userProfilesListMap.putIfAbsent(userHandle, new ArrayList<>());
+    List<UserHandle> list = userManagerState.userProfilesListMap.get(userHandle);
+    UserHandle handle = new UserHandle(profileUserHandle);
+    if (!list.contains(handle)) {
+      list.add(handle);
+    }
+  }
+
+  /** Setter for {@link UserManager#isUserUnlocked()} */
+  public void setUserUnlocked(boolean userUnlocked) {
+    this.userUnlocked = userUnlocked;
+  }
+
+  @Implementation(minSdk = N)
+  protected boolean isUserUnlocked() {
+    return userUnlocked;
+  }
+
+  /** @see #setUserState(UserHandle, UserState) */
+  @Implementation(minSdk = 24)
+  protected boolean isUserUnlocked(UserHandle handle) {
+    checkPermissions();
+    UserState state = userManagerState.userState.get(handle.getIdentifier());
+
+    return state == UserState.STATE_RUNNING_UNLOCKED;
+  }
+
+  /**
+   * If permissions are enforced (see {@link #enforcePermissionChecks(boolean)}) and the application
+   * doesn't have the {@link android.Manifest.permission#MANAGE_USERS} permission, throws a {@link
+   * SecurityManager} exception.
+   *
+   * @return false by default, or the value specified via {@link #setManagedProfile(boolean)}
+   * @see #enforcePermissionChecks(boolean)
+   * @see #setManagedProfile(boolean)
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean isManagedProfile() {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException(
+          "You need MANAGE_USERS permission to: check if specified user a "
+              + "managed profile outside your profile group");
+    }
+
+    if (managedProfile != null) {
+      return managedProfile;
+    }
+
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      return isManagedProfile(context.getUserId());
+    }
+
+    return false;
+  }
+
+  /**
+   * If permissions are enforced (see {@link #enforcePermissionChecks(boolean)}) and the application
+   * doesn't have the {@link android.Manifest.permission#MANAGE_USERS} permission, throws a {@link
+   * SecurityManager} exception.
+   *
+   * @return true if the profile added has FLAG_MANAGED_PROFILE
+   * @see #enforcePermissionChecks(boolean)
+   * @see #addProfile(int, int, String, int)
+   * @see #addUser(int, String, int)
+   */
+  @Implementation(minSdk = N)
+  protected boolean isManagedProfile(int userHandle) {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException(
+          "You need MANAGE_USERS permission to: check if specified user a "
+              + "managed profile outside your profile group");
+    }
+    UserInfo info = getUserInfo(userHandle);
+    return info != null && ((info.flags & FLAG_MANAGED_PROFILE) == FLAG_MANAGED_PROFILE);
+  }
+
+  public void enforcePermissionChecks(boolean enforcePermissions) {
+    this.enforcePermissions = enforcePermissions;
+  }
+
+  /** Setter for {@link UserManager#isManagedProfile()}. */
+  public void setManagedProfile(boolean managedProfile) {
+    this.managedProfile = managedProfile;
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean isProfile() {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException(
+          "You need INTERACT_ACROSS_USERS or MANAGE_USERS permission to: check isProfile");
+    }
+
+    return getUserInfo(context.getUserId()).isProfile();
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean isUserOfType(String userType) {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException("You need MANAGE_USERS permission to: check user type");
+    }
+
+    UserInfo info = getUserInfo(context.getUserId());
+    return info != null && info.userType != null && info.userType.equals(userType);
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean isSameProfileGroup(UserHandle user, UserHandle otherUser) {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException(
+          "You need MANAGE_USERS permission to: check if in the same profile group");
+    }
+
+    UserInfo userInfo = userManagerState.userInfoMap.get(user.getIdentifier());
+    UserInfo otherUserInfo = userManagerState.userInfoMap.get(otherUser.getIdentifier());
+
+    if (userInfo == null
+        || otherUserInfo == null
+        || userInfo.profileGroupId == UserInfo.NO_PROFILE_GROUP_ID
+        || otherUserInfo.profileGroupId == UserInfo.NO_PROFILE_GROUP_ID) {
+      return false;
+    }
+
+    return userInfo.profileGroupId == otherUserInfo.profileGroupId;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean hasUserRestriction(String restrictionKey, UserHandle userHandle) {
+    Bundle bundle = userManagerState.userRestrictions.get(userHandle.getIdentifier());
+    return bundle != null && bundle.getBoolean(restrictionKey);
+  }
+
+  /**
+   * Shadows UserManager.setUserRestriction() API. This allows UserManager.hasUserRestriction() to
+   * return meaningful results in test environment; thus, allowing test to verify the invoking of
+   * UserManager.setUserRestriction().
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected void setUserRestriction(String key, boolean value, UserHandle userHandle) {
+    Bundle bundle = getUserRestrictionsForUser(userHandle);
+    bundle.putBoolean(key, value);
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected void setUserRestriction(String key, boolean value) {
+    setUserRestriction(key, value, Process.myUserHandle());
+  }
+
+  /**
+   * @deprecated When possible, please use the real Android framework API {@link
+   *     UserManager#setUserRestriction()}.
+   */
+  @Deprecated
+  public void setUserRestriction(UserHandle userHandle, String restrictionKey, boolean value) {
+    setUserRestriction(restrictionKey, value, userHandle);
+  }
+
+  /** Removes all user restrictions set of a user identified by {@code userHandle}. */
+  public void clearUserRestrictions(UserHandle userHandle) {
+    userManagerState.userRestrictions.remove(userHandle.getIdentifier());
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected Bundle getUserRestrictions(UserHandle userHandle) {
+    return new Bundle(getUserRestrictionsForUser(userHandle));
+  }
+
+  private Bundle getUserRestrictionsForUser(UserHandle userHandle) {
+    Bundle bundle = userManagerState.userRestrictions.get(userHandle.getIdentifier());
+    if (bundle == null) {
+      bundle = new Bundle();
+      userManagerState.userRestrictions.put(userHandle.getIdentifier(), bundle);
+    }
+    return bundle;
+  }
+
+  /**
+   * @see #addProfile(int, int, String, int)
+   * @see #addUser(int, String, int)
+   */
+  @Implementation
+  protected long getSerialNumberForUser(UserHandle userHandle) {
+    Long result = userManagerState.userSerialNumbers.get(userHandle.getIdentifier());
+    return result == null ? -1L : result;
+  }
+
+  /**
+   * {@link #addUser} uses UserHandle for serialNumber. setSerialNumberForUser() allows assigning an
+   * arbitary serialNumber. Some test use serialNumber!=0 as secondary user check, so it's necessary
+   * to "fake" the serialNumber to a non-zero value.
+   */
+  public void setSerialNumberForUser(UserHandle userHandle, long serialNumber) {
+    userManagerState.userSerialNumbers.put(userHandle.getIdentifier(), serialNumber);
+  }
+
+  /**
+   * @see #addProfile(int, int, String, int)
+   * @see #addUser(int, String, int)
+   */
+  @Implementation
+  protected UserHandle getUserForSerialNumber(long serialNumber) {
+    Integer userHandle = userManagerState.userSerialNumbers.inverse().get(serialNumber);
+    return userHandle == null ? null : new UserHandle(userHandle);
+  }
+
+  /**
+   * @see #addProfile(int, int, String, int)
+   * @see #addUser(int, String, int)
+   */
+  @Implementation
+  protected int getUserSerialNumber(@UserIdInt int userHandle) {
+    Long result = userManagerState.userSerialNumbers.get(userHandle);
+    return result != null ? result.intValue() : -1;
+  }
+
+  private String getUserName(@UserIdInt int userHandle) {
+    UserInfo user = getUserInfo(userHandle);
+    return user == null ? "" : user.name;
+  }
+
+  /**
+   * Returns the name of the user.
+   *
+   * <p>On real Android, if a UserHandle.USER_SYSTEM user is found but does not have a name, it will
+   * return a name like "Owner". In Robolectric, the USER_SYSTEM user always has a name.
+   */
+  @Implementation(minSdk = Q)
+  protected String getUserName() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      return getUserName(context.getUserId());
+    }
+
+    return getUserName(UserHandle.myUserId());
+  }
+
+  @Implementation(minSdk = R)
+  protected void setUserName(String name) {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException("You need MANAGE_USERS permission to: rename users");
+    }
+    UserInfo user = getUserInfo(context.getUserId());
+    user.name = name;
+  }
+
+  @Implementation(minSdk = Q)
+  protected Bitmap getUserIcon() {
+    if (enforcePermissions
+        && !hasManageUsersPermission()
+        && !hasGetAccountsPrivilegedPermission()) {
+      throw new SecurityException(
+          "You need MANAGE_USERS or GET_ACCOUNTS_PRIVILEGED permissions to: get user icon");
+    }
+
+    int userId = UserHandle.myUserId();
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      userId = context.getUserId();
+    }
+
+    return userManagerState.userIcon.get(userId);
+  }
+
+  @Implementation(minSdk = Q)
+  protected void setUserIcon(Bitmap icon) {
+    if (enforcePermissions && !hasManageUsersPermission()) {
+      throw new SecurityException("You need MANAGE_USERS permission to: update users");
+    }
+
+    int userId = UserHandle.myUserId();
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      userId = context.getUserId();
+    }
+
+    userManagerState.userIcon.put(userId, icon);
+  }
+
+  /** @return user id for given user serial number. */
+  @HiddenApi
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  @UserIdInt
+  protected int getUserHandle(int serialNumber) {
+    Integer userHandle = userManagerState.userSerialNumbers.inverse().get((long) serialNumber);
+    return userHandle == null ? -1 : userHandle;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected static int getMaxSupportedUsers() {
+    return maxSupportedUsers;
+  }
+
+  public void setMaxSupportedUsers(int maxSupportedUsers) {
+    ShadowUserManager.maxSupportedUsers = maxSupportedUsers;
+  }
+
+  private boolean hasManageUsersPermission() {
+    return context
+            .getPackageManager()
+            .checkPermission(permission.MANAGE_USERS, context.getPackageName())
+        == PackageManager.PERMISSION_GRANTED;
+  }
+
+  private boolean hasCreateUsersPermission() {
+    return context
+            .getPackageManager()
+            .checkPermission(permission.CREATE_USERS, context.getPackageName())
+        == PackageManager.PERMISSION_GRANTED;
+  }
+
+  private boolean hasModifyQuietModePermission() {
+    return context
+            .getPackageManager()
+            .checkPermission(permission.MODIFY_QUIET_MODE, context.getPackageName())
+        == PackageManager.PERMISSION_GRANTED;
+  }
+
+  private boolean hasGetAccountsPrivilegedPermission() {
+    return context
+            .getPackageManager()
+            .checkPermission(permission.GET_ACCOUNTS_PRIVILEGED, context.getPackageName())
+        == PackageManager.PERMISSION_GRANTED;
+  }
+
+  private void checkPermissions() {
+    // TODO Ensure permisions
+    //              throw new SecurityException("You need INTERACT_ACROSS_USERS or MANAGE_USERS
+    // permission "
+    //                + "to: check " + name);throw new SecurityException();
+  }
+
+  /** @return false by default, or the value specified via {@link #setIsDemoUser(boolean)} */
+  @Implementation(minSdk = N_MR1)
+  protected boolean isDemoUser() {
+    return getUserInfo(UserHandle.myUserId()).isDemo();
+  }
+
+  /**
+   * Sets that the current user is a demo user; controls the return value of {@link
+   * UserManager#isDemoUser()}.
+   *
+   * @deprecated Use {@link ShadowUserManager#addUser(int, String, int)} to create a demo user
+   *     instead of changing default user flags.
+   */
+  @Deprecated
+  public void setIsDemoUser(boolean isDemoUser) {
+    UserInfo userInfo = getUserInfo(UserHandle.myUserId());
+    if (isDemoUser) {
+      userInfo.flags |= UserInfo.FLAG_DEMO;
+    } else {
+      userInfo.flags &= ~UserInfo.FLAG_DEMO;
+    }
+  }
+
+  /** @return 'true' by default, or the value specified via {@link #setIsSystemUser(boolean)} */
+  @Implementation(minSdk = M)
+  protected boolean isSystemUser() {
+    if (isSystemUser == false) {
+      return false;
+    } else {
+      return reflector(UserManagerReflector.class, realObject).isSystemUser();
+    }
+  }
+
+  /**
+   * Sets that the current user is the system user; controls the return value of {@link
+   * UserManager#isSystemUser()}.
+   *
+   * @deprecated Use {@link ShadowUserManager#addUser(int, String, int)} to create a system user
+   *     instead of changing default user flags.
+   */
+  @Deprecated
+  public void setIsSystemUser(boolean isSystemUser) {
+    this.isSystemUser = isSystemUser;
+  }
+
+  /**
+   * Sets that the current user is the primary user; controls the return value of {@link
+   * UserManager#isPrimaryUser()}.
+   *
+   * @deprecated Use {@link ShadowUserManager#addUser(int, String, int)} to create a primary user
+   *     instead of changing default user flags.
+   */
+  @Deprecated
+  public void setIsPrimaryUser(boolean isPrimaryUser) {
+    UserInfo userInfo = getUserInfo(UserHandle.myUserId());
+    if (isPrimaryUser) {
+      userInfo.flags |= UserInfo.FLAG_PRIMARY;
+    } else {
+      userInfo.flags &= ~UserInfo.FLAG_PRIMARY;
+    }
+  }
+
+  /** @return 'false' by default, or the value specified via {@link #setIsLinkedUser(boolean)} */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean isLinkedUser() {
+    return isRestrictedProfile();
+  }
+
+  /**
+   * Sets that the current user is the linked user; controls the return value of {@link
+   * UserManager#isLinkedUser()}.
+   *
+   * @deprecated Use {@link ShadowUserManager#addUser(int, String, int)} to create a linked user
+   *     instead of changing default user flags.
+   */
+  @Deprecated
+  public void setIsLinkedUser(boolean isLinkedUser) {
+    setIsRestrictedProfile(isLinkedUser);
+  }
+
+  /**
+   * Returns 'false' by default, or the value specified via {@link
+   * #setIsRestrictedProfile(boolean)}.
+   */
+  @Implementation(minSdk = P)
+  protected boolean isRestrictedProfile() {
+    return getUserInfo(UserHandle.myUserId()).isRestricted();
+  }
+
+  /**
+   * Sets this process running under a restricted profile; controls the return value of {@link
+   * UserManager#isRestrictedProfile()}.
+   *
+   * @deprecated use {@link ShadowUserManager#addUser()} instead
+   */
+  @Deprecated
+  public void setIsRestrictedProfile(boolean isRestrictedProfile) {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      setUserType(isRestrictedProfile ? USER_TYPE_FULL_RESTRICTED : USER_TYPE_FULL_SECONDARY);
+      return;
+    }
+    UserInfo userInfo = getUserInfo(UserHandle.myUserId());
+    if (isRestrictedProfile) {
+      userInfo.flags |= UserInfo.FLAG_RESTRICTED;
+    } else {
+      userInfo.flags &= ~UserInfo.FLAG_RESTRICTED;
+    }
+  }
+
+  /**
+   * Sets that the current user is the guest user; controls the return value of {@link
+   * UserManager#isGuestUser()}.
+   *
+   * @deprecated Use {@link ShadowUserManager#addUser(int, String, int)} to create a guest user
+   *     instead of changing default user flags.
+   */
+  @Deprecated
+  public void setIsGuestUser(boolean isGuestUser) {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      setUserType(isGuestUser ? USER_TYPE_FULL_GUEST : USER_TYPE_FULL_SECONDARY);
+      return;
+    }
+    UserInfo userInfo = getUserInfo(UserHandle.myUserId());
+    if (isGuestUser) {
+      userInfo.flags |= UserInfo.FLAG_GUEST;
+    } else {
+      userInfo.flags &= ~UserInfo.FLAG_GUEST;
+    }
+  }
+
+  public void setIsUserEnabled(int userId, boolean enabled) {
+    UserInfo userInfo = getUserInfo(userId);
+    if (enabled) {
+      userInfo.flags &= ~UserInfo.FLAG_DISABLED;
+    } else {
+      userInfo.flags |= UserInfo.FLAG_DISABLED;
+    }
+  }
+
+  /** @see #setUserState(UserHandle, UserState) */
+  @Implementation
+  protected boolean isUserRunning(UserHandle handle) {
+    checkPermissions();
+    UserState state = userManagerState.userState.get(handle.getIdentifier());
+
+    if (state == UserState.STATE_RUNNING_LOCKED
+        || state == UserState.STATE_RUNNING_UNLOCKED
+        || state == UserState.STATE_RUNNING_UNLOCKING) {
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  /** @see #setUserState(UserHandle, UserState) */
+  @Implementation
+  protected boolean isUserRunningOrStopping(UserHandle handle) {
+    checkPermissions();
+    UserState state = userManagerState.userState.get(handle.getIdentifier());
+
+    if (state == UserState.STATE_RUNNING_LOCKED
+        || state == UserState.STATE_RUNNING_UNLOCKED
+        || state == UserState.STATE_RUNNING_UNLOCKING
+        || state == UserState.STATE_STOPPING) {
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  /** @see #setUserState(UserHandle, UserState) */
+  @Implementation(minSdk = R)
+  protected boolean isUserUnlockingOrUnlocked(UserHandle handle) {
+    checkPermissions();
+    UserState state = userManagerState.userState.get(handle.getIdentifier());
+
+    return state == UserState.STATE_RUNNING_UNLOCKING || state == UserState.STATE_RUNNING_UNLOCKED;
+  }
+
+  /**
+   * Describes the current state of the user. State can be set using {@link
+   * #setUserState(UserHandle, UserState)}.
+   */
+  public enum UserState {
+    // User is first coming up.
+    STATE_BOOTING,
+    // User is in the locked state.
+    STATE_RUNNING_LOCKED,
+    // User is in the unlocking state.
+    STATE_RUNNING_UNLOCKING,
+    // User is in the running state.
+    STATE_RUNNING_UNLOCKED,
+    // User is in the initial process of being stopped.
+    STATE_STOPPING,
+    // User is in the final phase of stopping, sending Intent.ACTION_SHUTDOWN.
+    STATE_SHUTDOWN
+  }
+
+  /**
+   * Sets the current state for a given user, see {@link UserManager#isUserRunning(UserHandle)} and
+   * {@link UserManager#isUserRunningOrStopping(UserHandle)}
+   */
+  public void setUserState(UserHandle handle, UserState state) {
+    userManagerState.userState.put(handle.getIdentifier(), state);
+  }
+
+  /**
+   * Query whether the quiet mode is enabled for a managed profile.
+   *
+   * <p>This method checks whether the user handle corresponds to a managed profile, and then query
+   * its state. When quiet, the user is not running.
+   */
+  @Implementation(minSdk = O)
+  protected boolean isQuietModeEnabled(UserHandle userHandle) {
+    // Return false if this is not a managed profile (this is the OS's behavior).
+    if (!isManagedProfileWithoutPermission(userHandle)) {
+      return false;
+    }
+
+    UserInfo info = getUserInfo(userHandle.getIdentifier());
+    return (info.flags & UserInfo.FLAG_QUIET_MODE) == UserInfo.FLAG_QUIET_MODE;
+  }
+
+  /**
+   * Request the quiet mode.
+   *
+   * <p>This will succeed unless {@link #setProfileIsLocked(UserHandle, boolean)} is called with
+   * {@code true} for the managed profile, in which case it will always fail.
+   */
+  @Implementation(minSdk = Q)
+  protected boolean requestQuietModeEnabled(boolean enableQuietMode, UserHandle userHandle) {
+    if (enforcePermissions && !hasManageUsersPermission() && !hasModifyQuietModePermission()) {
+      throw new SecurityException("Requires MANAGE_USERS or MODIFY_QUIET_MODE permission");
+    }
+    Preconditions.checkArgument(isManagedProfileWithoutPermission(userHandle));
+    int userProfileHandle = userHandle.getIdentifier();
+    UserInfo info = getUserInfo(userHandle.getIdentifier());
+    if (enableQuietMode) {
+      userManagerState.userState.put(userProfileHandle, UserState.STATE_SHUTDOWN);
+      info.flags |= UserInfo.FLAG_QUIET_MODE;
+    } else {
+      if (userManagerState.profileIsLocked.getOrDefault(userProfileHandle, false)) {
+        return false;
+      }
+      userManagerState.userState.put(userProfileHandle, UserState.STATE_RUNNING_UNLOCKED);
+      info.flags &= ~UserInfo.FLAG_QUIET_MODE;
+    }
+
+    if (enableQuietMode) {
+      sendQuietModeBroadcast(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE, userHandle);
+    } else {
+      sendQuietModeBroadcast(Intent.ACTION_MANAGED_PROFILE_AVAILABLE, userHandle);
+      sendQuietModeBroadcast(Intent.ACTION_MANAGED_PROFILE_UNLOCKED, userHandle);
+    }
+
+    return true;
+  }
+
+  /**
+   * If the current application has the necessary rights, it will receive the background action too.
+   */
+  protected void sendQuietModeBroadcast(String action, UserHandle profileHandle) {
+    Intent intent = new Intent(action);
+    intent.putExtra(Intent.EXTRA_USER, profileHandle);
+    intent.setFlags(Intent.FLAG_RECEIVER_FOREGROUND | Intent.FLAG_RECEIVER_REGISTERED_ONLY);
+    // Send the broadcast to the context-registered receivers.
+    context.sendBroadcast(intent);
+  }
+
+  /**
+   * Check if a profile is managed, not checking permissions.
+   *
+   * <p>This is useful to implement other methods.
+   */
+  private boolean isManagedProfileWithoutPermission(UserHandle userHandle) {
+    UserInfo info = getUserInfo(userHandle.getIdentifier());
+    return (info != null && ((info.flags & FLAG_MANAGED_PROFILE) == FLAG_MANAGED_PROFILE));
+  }
+
+  public void setProfileIsLocked(UserHandle profileHandle, boolean isLocked) {
+    userManagerState.profileIsLocked.put(profileHandle.getIdentifier(), isLocked);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected long[] getSerialNumbersOfUsers(boolean excludeDying) {
+    return getUsers().stream()
+        .map(userInfo -> getUserSerialNumber(userInfo.getUserHandle().getIdentifier()))
+        .mapToLong(l -> l)
+        .toArray();
+  }
+
+  @Implementation
+  protected List<UserInfo> getUsers() {
+    return new ArrayList<>(userManagerState.userInfoMap.values());
+  }
+
+  @Implementation
+  protected UserInfo getUserInfo(int userHandle) {
+    return userManagerState.userInfoMap.get(userHandle);
+  }
+
+  /**
+   * Sets whether switching users is allowed or not; controls the return value of {@link
+   * UserManager#canSwitchUser()}
+   *
+   * @deprecated use {@link #setUserSwitchability} instead
+   */
+  @Deprecated
+  public void setCanSwitchUser(boolean canSwitchUser) {
+    setUserSwitchability(
+        canSwitchUser
+            ? UserManager.SWITCHABILITY_STATUS_OK
+            : UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected String getSeedAccountName() {
+    return seedAccountName;
+  }
+
+  /** Setter for {@link UserManager#getSeedAccountName()} */
+  public void setSeedAccountName(String seedAccountName) {
+    this.seedAccountName = seedAccountName;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected String getSeedAccountType() {
+    return seedAccountType;
+  }
+
+  /** Setter for {@link UserManager#getSeedAccountType()} */
+  public void setSeedAccountType(String seedAccountType) {
+    this.seedAccountType = seedAccountType;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected PersistableBundle getSeedAccountOptions() {
+    return seedAccountOptions;
+  }
+
+  /** Setter for {@link UserManager#getSeedAccountOptions()} */
+  public void setSeedAccountOptions(PersistableBundle seedAccountOptions) {
+    this.seedAccountOptions = seedAccountOptions;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.N)
+  protected void clearSeedAccountData() {
+    seedAccountName = null;
+    seedAccountType = null;
+    seedAccountOptions = null;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR1)
+  protected boolean removeUser(int userHandle) {
+    userManagerState.userInfoMap.remove(userHandle);
+    userManagerState.userPidMap.remove(userHandle);
+    userManagerState.userSerialNumbers.remove(userHandle);
+    userManagerState.userState.remove(userHandle);
+    userManagerState.userRestrictions.remove(userHandle);
+    userManagerState.profileIsLocked.remove(userHandle);
+    userManagerState.userIcon.remove(userHandle);
+    userManagerState.userProfilesListMap.remove(userHandle);
+    // if it's a profile, remove from the belong list in userManagerState.userProfilesListMap
+    UserHandle profileHandle = new UserHandle(userHandle);
+    for (List<UserHandle> list : userManagerState.userProfilesListMap.values()) {
+      if (list.remove(profileHandle)) {
+        break;
+      }
+    }
+    return true;
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean removeUser(UserHandle user) {
+    return removeUser(user.getIdentifier());
+  }
+
+  @Implementation(minSdk = N)
+  protected static boolean supportsMultipleUsers() {
+    return isMultiUserSupported;
+  }
+
+  /**
+   * Sets whether multiple users are supported; controls the return value of {@link
+   * UserManager#supportsMultipleUser}.
+   */
+  public void setSupportsMultipleUsers(boolean isMultiUserSupported) {
+    ShadowUserManager.isMultiUserSupported = isMultiUserSupported;
+  }
+
+  /**
+   * Switches the current user to {@code userHandle}.
+   *
+   * @param userId the integer handle of the user, where 0 is the primary user.
+   */
+  public void switchUser(int userId) {
+    if (!userManagerState.userInfoMap.containsKey(userId)) {
+      throw new UnsupportedOperationException("Must add user before switching to it");
+    }
+
+    ShadowProcess.setUid(userManagerState.userPidMap.get(userId));
+
+    Application application = (Application) context.getApplicationContext();
+    ShadowContextImpl shadowContext = Shadow.extract(application.getBaseContext());
+    shadowContext.setUserId(userId);
+
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      reflector(UserManagerReflector.class, realObject).setUserId(userId);
+    }
+  }
+
+  /**
+   * Creates a user with the specified name, userId and flags.
+   *
+   * @param id the unique id of user
+   * @param name name of the user
+   * @param flags 16 bits for user type. See {@link UserInfo#flags}
+   * @return a handle to the new user
+   */
+  public UserHandle addUser(int id, String name, int flags) {
+    UserHandle userHandle =
+        id == UserHandle.USER_SYSTEM ? Process.myUserHandle() : new UserHandle(id);
+
+    // Don't override serial number set by setSerialNumberForUser()
+    if (!userManagerState.userSerialNumbers.containsKey(id)) {
+      // use UserHandle id as serial number unless setSerialNumberForUser() is used
+      userManagerState.userSerialNumbers.put(id, (long) id);
+    }
+    // Start the user as shut down.
+    userManagerState.userState.put(id, UserState.STATE_SHUTDOWN);
+
+    // Update UserInfo regardless if was added or not
+    userManagerState.userInfoMap.put(id, new UserInfo(id, name, flags));
+    if (!userManagerState.userProfilesListMap.containsKey(id)) {
+      userManagerState.userProfilesListMap.put(id, new ArrayList<>());
+      // getUserProfiles() includes user's handle
+      userManagerState.userProfilesListMap.get(id).add(new UserHandle(id));
+      userManagerState.userPidMap.put(
+          id,
+          id == UserHandle.USER_SYSTEM
+              ? Process.myUid()
+              : id * UserHandle.PER_USER_RANGE + ShadowProcess.getRandomApplicationUid());
+    }
+    return userHandle;
+  }
+
+  /**
+   * Returns {@code true} by default, or the value specified via {@link #setCanSwitchUser(boolean)}.
+   */
+  @Implementation(minSdk = N, maxSdk = Q)
+  protected boolean canSwitchUsers() {
+    return getUserSwitchability() == UserManager.SWITCHABILITY_STATUS_OK;
+  }
+
+  @Implementation(minSdk = Q)
+  protected int getUserSwitchability() {
+    return userSwitchability;
+  }
+
+  /** Sets the user switchability for all users. */
+  public void setUserSwitchability(int switchability) {
+    this.userSwitchability = switchability;
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean hasUserRestrictionForUser(String restrictionKey, UserHandle userHandle) {
+    return hasUserRestriction(restrictionKey, userHandle);
+  }
+
+  private void setUserType(String userType) {
+    UserInfo userInfo = getUserInfo(UserHandle.myUserId());
+    userInfo.userType = userType;
+  }
+
+  /**
+   * Request the quiet mode.
+   *
+   * <p>If {@link #setProfileIsLocked(UserHandle, boolean)} is called with {@code true} for the
+   * managed profile a request to disable the quiet mode will fail and return {@code false} (i.e. as
+   * if the user refused to authenticate). Otherwise, the call will always succeed and return {@code
+   * true}.
+   *
+   * <p>This method simply re-directs to {@link ShadowUserManager#requestQuietModeEnabled(boolean,
+   * UserHandle)} as it already has the desired behavior irrespective of the flag's value.
+   */
+  @Implementation(minSdk = R)
+  protected boolean requestQuietModeEnabled(
+      boolean enableQuietMode, UserHandle userHandle, int flags) {
+    return requestQuietModeEnabled(enableQuietMode, userHandle);
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected Bundle getUserRestrictions() {
+    return getUserRestrictions(UserHandle.getUserHandleForUid(Process.myUid()));
+  }
+
+  @Implementation(minSdk = TIRAMISU)
+  protected boolean hasUserRestrictionForUser(String restrictionKey, int userId) {
+    Bundle bundle = getUserRestrictions(UserHandle.getUserHandleForUid(userId));
+    return bundle != null && bundle.getBoolean(restrictionKey);
+  }
+
+  @Resetter
+  public static void reset() {
+    maxSupportedUsers = DEFAULT_MAX_SUPPORTED_USERS;
+    isMultiUserSupported = false;
+  }
+
+  @ForType(UserManager.class)
+  interface UserManagerReflector {
+
+    @Direct
+    List getProfiles(int userHandle);
+
+    @Direct
+    boolean isSystemUser();
+
+    @Accessor("mUserId")
+    void setUserId(int userId);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUwbAdapterStateListener.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUwbAdapterStateListener.java
new file mode 100644
index 0000000..37aceec
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUwbAdapterStateListener.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.uwb.AdapterStateListener;
+import android.uwb.UwbManager.AdapterStateCallback;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Adds Robolectric support for UWB adapter state listener methods. */
+@Implements(value = AdapterStateListener.class, minSdk = VERSION_CODES.S, isInAndroidSdk = false)
+public class ShadowUwbAdapterStateListener {
+  private int adapterState = AdapterStateCallback.STATE_DISABLED;
+  private final Map<AdapterStateCallback, Executor> callbackToExecutorMap = new HashMap<>();
+
+  /** Gets the adapter state set via {@link ShadowUwbAdapterStateListener#setEnabled(boolean)} */
+  @Implementation
+  protected int getAdapterState() {
+    return adapterState;
+  }
+
+  /**
+   * Sets a local variable that stores the adapter state, which can be retrieved with {@link
+   * ShadowUwbAdapterStateListener#getAdapterState()}.
+   */
+  @Implementation
+  protected void setEnabled(boolean isEnabled) {
+    adapterState =
+        isEnabled
+            ? AdapterStateCallback.STATE_ENABLED_INACTIVE
+            : AdapterStateCallback.STATE_DISABLED;
+  }
+
+  /**
+   * Sets a local variable that stores the adapter state, and invokes any callbacks that were
+   * registered via {@link ShadowUwbAdapterStateListener#register(Executor, AdapterStateCallback)}
+   */
+  @Implementation
+  protected void onAdapterStateChanged(int state, int reason) {
+    adapterState = state;
+
+    for (Entry<AdapterStateCallback, Executor> callbackToExecutor :
+        callbackToExecutorMap.entrySet()) {
+      callbackToExecutor
+          .getValue()
+          .execute(() -> callbackToExecutor.getKey().onStateChanged(state, reason));
+    }
+  }
+
+  /**
+   * Registers a callback which is invoked when {@link
+   * ShadowUwbAdapterStateListener#onAdapterStateChanged(int, int)} is called.
+   */
+  @Implementation
+  protected void register(Executor executor, AdapterStateCallback callback) {
+    callbackToExecutorMap.put(callback, executor);
+
+    executor.execute(
+        () ->
+            callback.onStateChanged(
+                adapterState, AdapterStateCallback.STATE_CHANGED_REASON_ERROR_UNKNOWN));
+  }
+
+  /** Unregisters a callback. */
+  @Implementation
+  protected void unregister(AdapterStateCallback callback) {
+    callbackToExecutorMap.remove(callback);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUwbManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUwbManager.java
new file mode 100644
index 0000000..18b08f6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUwbManager.java
@@ -0,0 +1,107 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+
+import android.os.Build.VERSION_CODES;
+import android.os.CancellationSignal;
+import android.os.PersistableBundle;
+import android.uwb.RangingSession;
+import android.uwb.UwbManager;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+/** Adds Robolectric support for UWB ranging. */
+@Implements(value = UwbManager.class, minSdk = VERSION_CODES.S, isInAndroidSdk = false)
+public class ShadowUwbManager {
+
+  private PersistableBundle specificationInfo = new PersistableBundle();
+
+  private List<PersistableBundle> chipInfos = new ArrayList<>();
+
+  private ShadowRangingSession.Adapter adapter =
+      new ShadowRangingSession.Adapter() {
+        @Override
+        public void onOpen(
+            RangingSession session, RangingSession.Callback callback, PersistableBundle params) {}
+
+        @Override
+        public void onStart(
+            RangingSession session, RangingSession.Callback callback, PersistableBundle params) {}
+
+        @Override
+        public void onReconfigure(
+            RangingSession session, RangingSession.Callback callback, PersistableBundle params) {}
+
+        @Override
+        public void onStop(RangingSession session, RangingSession.Callback callback) {}
+
+        @Override
+        public void onClose(RangingSession session, RangingSession.Callback callback) {}
+      };
+
+  /**
+   * Simply returns the bundle provided by {@link ShadowUwbManager#setSpecificationInfo()}, allowing
+   * the tester to dictate available features.
+   */
+  @Implementation
+  protected PersistableBundle getSpecificationInfo() {
+    return specificationInfo;
+  }
+
+  /**
+   * Instantiates a {@link ShadowRangingSession} with the adapter provided by {@link
+   * ShadowUwbManager#setUwbAdapter()}, allowing the tester dictate the results of ranging attempts.
+   */
+  @Implementation
+  protected CancellationSignal openRangingSession(
+      PersistableBundle params, Executor executor, RangingSession.Callback callback) {
+    RangingSession session = ShadowRangingSession.newInstance(executor, callback, adapter);
+    CancellationSignal cancellationSignal = new CancellationSignal();
+    cancellationSignal.setOnCancelListener(session::close);
+    Shadow.<ShadowRangingSession>extract(session).open(params);
+    return cancellationSignal;
+  }
+
+  /** Sets the UWB adapter to use for new {@link ShadowRangingSession}s. */
+  public void setUwbAdapter(ShadowRangingSession.Adapter adapter) {
+    this.adapter = adapter;
+  }
+
+  /** Sets the bundle to be returned by {@link android.uwb.UwbManager#getSpecificationInfo}. */
+  public void setSpecificationInfo(PersistableBundle specificationInfo) {
+    this.specificationInfo = new PersistableBundle(specificationInfo);
+  }
+
+  /**
+   * Instantiates a {@link ShadowRangingSession} with the multi-chip API call. {@code chipId} is
+   * unused in the shadow implementation, so this is equivalent to {@link
+   * ShadowUwbManager#openRangingSession(PersistableBundle, Executor, RangingSession.Callback)}
+   */
+  @Implementation(minSdk = TIRAMISU)
+  protected CancellationSignal openRangingSession(
+      PersistableBundle params,
+      Executor executor,
+      RangingSession.Callback callback,
+      String chipId) {
+    return openRangingSession(params, executor, callback);
+  }
+
+  /**
+   * Simply returns the List of bundles provided by {@link ShadowUwbManager#setChipInfos(List)} ,
+   * allowing the tester to set multi-chip configuration.
+   */
+  @Implementation(minSdk = TIRAMISU)
+  protected List<PersistableBundle> getChipInfos() {
+    return ImmutableList.copyOf(chipInfos);
+  }
+
+  /** Sets the list of bundles to be returned by {@link android.uwb.UwbManager#getChipInfos}. */
+  public void setChipInfos(List<PersistableBundle> chipInfos) {
+    this.chipInfos = new ArrayList<>(chipInfos);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVMRuntime.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVMRuntime.java
new file mode 100644
index 0000000..4a9848d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVMRuntime.java
@@ -0,0 +1,96 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.annotation.TargetApi;
+import dalvik.system.VMRuntime;
+import java.lang.reflect.Array;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.res.android.NativeObjRegistry;
+
+@Implements(value = VMRuntime.class, isInAndroidSdk = false)
+public class ShadowVMRuntime {
+
+  private final NativeObjRegistry<Object> nativeObjRegistry =
+      new NativeObjRegistry<>("VRRuntime.nativeObjectRegistry");
+  // There actually isn't any android JNI code to call through to in Robolectric due to
+  // cross-platform compatibility issues. We default to a reasonable value that reflects the devices
+  // that would commonly run this code.
+  private static boolean is64Bit = true;
+
+  @Nullable private static String currentInstructionSet = null;
+
+  @Implementation(minSdk = LOLLIPOP)
+  public Object newUnpaddedArray(Class<?> klass, int size) {
+    return Array.newInstance(klass, size);
+  }
+
+  @Implementation
+  public Object newNonMovableArray(Class<?> type, int size) {
+    if (type.equals(int.class)) {
+      return new int[size];
+    }
+    return null;
+  }
+
+  /**
+   * Returns a unique identifier of the object instead of a 'native' address.
+   */
+  @Implementation
+  public long addressOf(Object obj) {
+    return nativeObjRegistry.register(obj);
+  }
+
+  /**
+   * Returns the object previously registered with {@link #addressOf(Object)}.
+   */
+  public @Nullable
+  Object getObjectForAddress(long address) {
+    return nativeObjRegistry.getNativeObject(address);
+  }
+
+  /**
+   * Returns whether the VM is running in 64-bit mode. Available in Android L+. Defaults to true.
+   */
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean is64Bit() {
+    return ShadowVMRuntime.is64Bit;
+  }
+
+  /** Sets whether the VM is running in 64-bit mode. */
+  @TargetApi(LOLLIPOP)
+  public static void setIs64Bit(boolean is64Bit) {
+    ShadowVMRuntime.is64Bit = is64Bit;
+  }
+
+  /** Returns the instruction set of the current runtime. */
+  @Implementation(minSdk = LOLLIPOP)
+  protected static String getCurrentInstructionSet() {
+    return currentInstructionSet;
+  }
+
+  /** Sets the instruction set of the current runtime. */
+  @TargetApi(LOLLIPOP)
+  public static void setCurrentInstructionSet(@Nullable String currentInstructionSet) {
+    ShadowVMRuntime.currentInstructionSet = currentInstructionSet;
+  }
+
+  @Resetter
+  public static void reset() {
+    ShadowVMRuntime.is64Bit = true;
+    ShadowVMRuntime.currentInstructionSet = null;
+  }
+
+  @Implementation(minSdk = Q)
+  protected static int getNotifyNativeInterval() {
+    // The value '384' is from
+    // https://cs.android.com/android/platform/superproject/+/android-12.0.0_r18:art/runtime/gc/heap.h;l=172
+    // Note that value returned is irrelevant for the JVM, it just has to be greater than zero to
+    // avoid a divide-by-zero error in VMRuntime.notifyNativeAllocation.
+    return 384; // must be greater than 0
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowValueAnimator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowValueAnimator.java
new file mode 100644
index 0000000..30481fc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowValueAnimator.java
@@ -0,0 +1,90 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.animation.AnimationHandler;
+import android.animation.ValueAnimator;
+import android.app.UiAutomation;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+@Implements(ValueAnimator.class)
+public class ShadowValueAnimator {
+
+  @RealObject private ValueAnimator realObject;
+
+  private int actualRepeatCount;
+
+  @Resetter
+  public static void reset() {
+    /* ValueAnimator.sAnimationHandler is a static thread local that otherwise would survive between
+     * tests. The AnimationHandler.mAnimationScheduled is set to true when the scheduleAnimation() is
+     * called and the reset to false when run() is called by the Choreographer. If an animation is
+     * already scheduled, it will not post to the Choreographer. This is a problem if a previous
+     * test leaves animations on the Choreographers callback queue without running them as it will
+     * cause the AnimationHandler not to post a callback. We reset the thread local here so a new
+     * one will be created for each test with a fresh state.
+     */
+    if (RuntimeEnvironment.getApiLevel() >= N) {
+      ThreadLocal<AnimationHandler> animatorHandlerTL =
+          ReflectionHelpers.getStaticField(AnimationHandler.class, "sAnimatorHandler");
+      animatorHandlerTL.remove();
+    } else {
+      ReflectionHelpers.callStaticMethod(ValueAnimator.class, "clearAllAnimations");
+      ThreadLocal<AnimationHandler> animatorHandlerTL =
+          ReflectionHelpers.getStaticField(ValueAnimator.class, "sAnimationHandler");
+      animatorHandlerTL.remove();
+    }
+
+    setDurationScale(1);
+  }
+
+  @Implementation
+  protected void setRepeatCount(int count) {
+    actualRepeatCount = count;
+    if (count == ValueAnimator.INFINITE) {
+      count = 1;
+    }
+    reflector(ValueAnimatorReflector.class, realObject).setRepeatCount(count);
+  }
+
+  /**
+   * Returns the value that was set as the repeat count. This is otherwise the same
+   * as getRepeatCount(), except when the count was set to infinite.
+   *
+   * @return Repeat count.
+   */
+  public int getActualRepeatCount() {
+    return actualRepeatCount;
+  }
+
+  /**
+   * Sets the duration scale for value animator. To set this value use {@link
+   * UiAutomation#setAnimationScale(float)} or {@link
+   * ShadowUiAutomation#setAnimationScaleCompat(float)}.
+   */
+  @Implementation
+  protected static void setDurationScale(float duration) {
+    reflector(ValueAnimatorReflector.class, null).setDurationScale(duration);
+  }
+
+  @ForType(ValueAnimator.class)
+  interface ValueAnimatorReflector {
+
+    @Direct
+    void setRepeatCount(int count);
+
+    @Static
+    @Accessor("sDurationScale")
+    void setDurationScale(float duration);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVcnManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVcnManager.java
new file mode 100644
index 0000000..dcacd0c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVcnManager.java
@@ -0,0 +1,107 @@
+package org.robolectric.shadows;
+
+import android.net.vcn.VcnConfig;
+import android.net.vcn.VcnManager;
+import android.net.vcn.VcnManager.VcnStatusCallback;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelUuid;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** A Shadow for android.net.vcn.VcnManager added in Android S. */
+@Implements(value = VcnManager.class, minSdk = VERSION_CODES.S, isInAndroidSdk = false)
+public class ShadowVcnManager {
+
+  private final Map<VcnStatusCallback, VcnStatusCallbackInfo> callbacks = new HashMap<>();
+  private final Map<ParcelUuid, VcnConfig> configs = new HashMap<>();
+
+  private int currentVcnStatus = VcnManager.VCN_STATUS_CODE_NOT_CONFIGURED;
+
+  @Implementation
+  protected void registerVcnStatusCallback(
+      ParcelUuid subGroup, Executor executor, VcnStatusCallback callback) {
+
+    callbacks.put(callback, new VcnStatusCallbackInfo(executor, subGroup));
+  }
+
+  @Implementation
+  protected void unregisterVcnStatusCallback(VcnStatusCallback callback) {
+    if (callback == null) {
+      throw new IllegalArgumentException("VcnStatusCallback == null");
+    } else if (!callbacks.containsKey(callback)) {
+      throw new IllegalArgumentException("VcnStatusCallback not registered");
+    }
+    callbacks.remove(callback);
+  }
+
+  @Implementation
+  protected void setVcnConfig(ParcelUuid subGroup, VcnConfig config) {
+    configs.put(subGroup, config);
+  }
+
+  @Implementation
+  protected void clearVcnConfig(ParcelUuid subGroup) {
+    if (subGroup == null) {
+      throw new IllegalArgumentException("subGroup == null");
+    }
+    configs.remove(subGroup);
+  }
+
+  @Implementation
+  protected List<ParcelUuid> getConfiguredSubscriptionGroups() {
+    return new ArrayList<>(configs.keySet());
+  }
+
+  /** Gets a list of all registered VcnStatusCallbacks. */
+  public Set<VcnStatusCallback> getRegisteredVcnStatusCallbacks() {
+    return Collections.unmodifiableSet(callbacks.keySet());
+  }
+
+  /**
+   * Set the vcn status code (see {@link #currentVcnStatus}). Triggers {@link
+   * VcnStatusCallback#onStatusChanged} of all registered {@link #callbacks}
+   */
+  public void setStatus(int statusCode) {
+    currentVcnStatus = statusCode;
+    for (VcnStatusCallback callback : callbacks.keySet()) {
+      callbacks.get(callback).executor.execute(() -> callback.onStatusChanged(currentVcnStatus));
+    }
+  }
+
+  /**
+   * Triggers onGatewayConnectionError of VcnStatusCallback {@link
+   * VcnStatusCallback#onGatewayConnectionError}).
+   */
+  public void setGatewayConnectionError(
+      String gatewayConnectionName, int errorCode, Throwable detail) {
+    for (VcnStatusCallback callback : callbacks.keySet()) {
+      callbacks
+          .get(callback)
+          .executor
+          .execute(
+              () -> callback.onGatewayConnectionError(gatewayConnectionName, errorCode, detail));
+    }
+  }
+
+  /** Gets the subscription group of given VcnStatusCallback in {@link #callbacks}. */
+  public ParcelUuid getRegisteredSubscriptionGroup(VcnStatusCallback callback) {
+    return callbacks.get(callback).subGroup;
+  }
+
+  private static final class VcnStatusCallbackInfo {
+    private final Executor executor;
+    private final ParcelUuid subGroup;
+
+    private VcnStatusCallbackInfo(Executor executor, ParcelUuid subGroup) {
+      this.executor = executor;
+      this.subGroup = subGroup;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVectorDrawable.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVectorDrawable.java
new file mode 100644
index 0000000..0153f59
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVectorDrawable.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.N;
+import static org.robolectric.shadows.ShadowVirtualRefBasePtr.get;
+import static org.robolectric.shadows.ShadowVirtualRefBasePtr.put;
+
+import android.graphics.drawable.VectorDrawable;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = VectorDrawable.class, minSdk = N)
+public class ShadowVectorDrawable extends ShadowDrawable {
+  //  private static native long nCreateTree(long rootGroupPtr);
+//  private static native long nCreateTreeFromCopy(long treeToCopy, long rootGroupPtr);
+//  private static native void nSetRendererViewportSize(long rendererPtr, float viewportWidth,
+//                                                      float viewportHeight);
+//  private static native boolean nSetRootAlpha(long rendererPtr, float alpha);
+//  private static native float nGetRootAlpha(long rendererPtr);
+//  private static native void nSetAllowCaching(long rendererPtr, boolean allowCaching);
+//
+//  private static native int nDraw(long rendererPtr, long canvasWrapperPtr,
+//                                  long colorFilterPtr, Rect bounds, boolean needsMirroring, boolean canReuseCache);
+
+  private static final int STROKE_WIDTH_INDEX = 0;
+  private static final int STROKE_COLOR_INDEX = 1;
+  private static final int STROKE_ALPHA_INDEX = 2;
+  private static final int FILL_COLOR_INDEX = 3;
+  private static final int FILL_ALPHA_INDEX = 4;
+  private static final int TRIM_PATH_START_INDEX = 5;
+  private static final int TRIM_PATH_END_INDEX = 6;
+  private static final int TRIM_PATH_OFFSET_INDEX = 7;
+  private static final int STROKE_LINE_CAP_INDEX = 8;
+  private static final int STROKE_LINE_JOIN_INDEX = 9;
+  private static final int STROKE_MITER_LIMIT_INDEX = 10;
+  private static final int FILL_TYPE_INDEX = 11;
+  private static final int TOTAL_PROPERTY_COUNT = 12;
+
+  private static class Path implements Cloneable {
+    float strokeWidth;
+    int strokeColor;
+    float strokeAlpha;
+    int fillColor;
+    float fillAlpha;
+    float trimPathStart;
+    float trimPathEnd;
+    float trimPathOffset;
+    int strokeLineCap;
+    int strokeLineJoin;
+    float strokeMiterLimit;
+    int fillType;
+
+    @Override
+    protected Object clone() {
+      try {
+        return super.clone();
+      } catch (CloneNotSupportedException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  private static Path getPath(long pathPtr) {
+    return get(pathPtr, Path.class);
+  }
+
+  @Implementation
+  protected static long nCreateFullPath() {
+    return put(new Path());
+  }
+
+  @Implementation
+  protected static long nCreateFullPath(long nativeFullPathPtr) {
+    return put(getPath(nativeFullPathPtr).clone());
+  }
+
+  @Implementation
+  protected static boolean nGetFullPathProperties(long pathPtr, byte[] properties, int length) {
+    if (length != TOTAL_PROPERTY_COUNT * 4) return false;
+
+    Path path = getPath(pathPtr);
+    ByteBuffer propertiesBB = ByteBuffer.wrap(properties);
+    propertiesBB.order(ByteOrder.nativeOrder());
+    propertiesBB.putFloat(STROKE_WIDTH_INDEX * 4, path.strokeWidth);
+    propertiesBB.putInt(STROKE_COLOR_INDEX * 4, path.strokeColor);
+    propertiesBB.putFloat(STROKE_ALPHA_INDEX * 4, path.strokeAlpha);
+    propertiesBB.putInt(FILL_COLOR_INDEX * 4, path.fillColor);
+    propertiesBB.putFloat(FILL_ALPHA_INDEX * 4, path.fillAlpha);
+    propertiesBB.putFloat(TRIM_PATH_START_INDEX * 4, path.trimPathStart);
+    propertiesBB.putFloat(TRIM_PATH_END_INDEX * 4, path.trimPathEnd);
+    propertiesBB.putFloat(TRIM_PATH_OFFSET_INDEX * 4, path.trimPathOffset);
+    propertiesBB.putInt(STROKE_LINE_CAP_INDEX * 4, path.strokeLineCap);
+    propertiesBB.putInt(STROKE_LINE_JOIN_INDEX * 4, path.strokeLineJoin);
+    propertiesBB.putFloat(STROKE_MITER_LIMIT_INDEX * 4, path.strokeMiterLimit);
+    propertiesBB.putInt(FILL_TYPE_INDEX * 4, path.fillType);
+
+    return true;
+  }
+
+  @Implementation
+  protected static void nUpdateFullPathProperties(
+      long pathPtr,
+      float strokeWidth,
+      int strokeColor,
+      float strokeAlpha,
+      int fillColor,
+      float fillAlpha,
+      float trimPathStart,
+      float trimPathEnd,
+      float trimPathOffset,
+      float strokeMiterLimit,
+      int strokeLineCap,
+      int strokeLineJoin,
+      int fillType) {
+    Path path = getPath(pathPtr);
+    path.strokeWidth = strokeWidth;
+    path.strokeColor = strokeColor;
+    path.strokeAlpha = strokeAlpha;
+    path.fillColor = fillColor;
+    path.fillAlpha = fillAlpha;
+    path.trimPathStart = trimPathStart;
+    path.trimPathEnd = trimPathEnd;
+    path.trimPathOffset = trimPathOffset;
+    path.strokeLineCap = strokeLineCap;
+    path.strokeLineJoin = strokeLineJoin;
+    path.strokeMiterLimit = strokeMiterLimit;
+    path.fillType = fillType;
+  }
+
+//  @Implementation
+//  public static void nUpdateFullPathFillGradient(long pathPtr, long fillGradientPtr) {
+//
+//  }
+//
+//  @Implementation
+//  public static void nUpdateFullPathStrokeGradient(long pathPtr, long strokeGradientPtr) {
+//
+//  }
+//
+//  private static native long nCreateClipPath();
+//  private static native long nCreateClipPath(long clipPathPtr);
+
+
+  static class Group implements Cloneable {
+    float rotation;
+    float pivotX;
+    float pivotY;
+    float scaleX;
+    float scaleY;
+    float translateX;
+    float translateY;
+
+    @Override
+    protected Object clone() {
+      try {
+        return super.clone();
+      } catch (CloneNotSupportedException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  private static Group getGroup(long groupPtr) {
+    return get(groupPtr, Group.class);
+  }
+
+  @Implementation
+  protected static long nCreateGroup() {
+    return put(new Group());
+  }
+
+  @Implementation
+  protected static long nCreateGroup(long groupPtr) {
+    return put(getGroup(groupPtr).clone());
+  }
+
+  //  public static void nSetName(long nodePtr, String name) {
+  //  }
+
+  @Implementation
+  protected static boolean nGetGroupProperties(long groupPtr, float[] properties, int length) {
+    if (length != 7) return false;
+    Group group = getGroup(groupPtr);
+    properties[0] = group.rotation;
+    properties[1] = group.pivotX;
+    properties[2] = group.pivotY;
+    properties[3] = group.scaleX;
+    properties[4] = group.scaleY;
+    properties[5] = group.translateX;
+    properties[6] = group.translateY;
+    return true;
+  }
+
+  @Implementation
+  protected static void nUpdateGroupProperties(
+      long groupPtr,
+      float rotate,
+      float pivotX,
+      float pivotY,
+      float scaleX,
+      float scaleY,
+      float translateX,
+      float translateY) {
+    Group group = getGroup(groupPtr);
+    group.rotation = rotate;
+    group.pivotX = pivotX;
+    group.pivotY = pivotY;
+    group.scaleX = scaleX;
+    group.scaleY = scaleY;
+    group.translateX = translateX;
+    group.translateY = translateY;
+  }
+
+//  private static native void nAddChild(long groupPtr, long nodePtr);
+//  private static native void nSetPathString(long pathPtr, String pathString, int length);
+//
+//  /**
+//   * The setters and getters below for paths and groups are here temporarily, and will be
+//   * removed once the animation in AVD is replaced with RenderNodeAnimator, in which case the
+//   * animation will modify these properties in native. By then no JNI hopping would be necessary
+//   * for VD during animation, and these setters and getters will be obsolete.
+//   */
+//  // Setters and getters during animation.
+//  private static native float nGetRotation(long groupPtr);
+//  private static native void nSetRotation(long groupPtr, float rotation);
+//  private static native float nGetPivotX(long groupPtr);
+//  private static native void nSetPivotX(long groupPtr, float pivotX);
+//  private static native float nGetPivotY(long groupPtr);
+//  private static native void nSetPivotY(long groupPtr, float pivotY);
+//  private static native float nGetScaleX(long groupPtr);
+//  private static native void nSetScaleX(long groupPtr, float scaleX);
+//  private static native float nGetScaleY(long groupPtr);
+//  private static native void nSetScaleY(long groupPtr, float scaleY);
+//  private static native float nGetTranslateX(long groupPtr);
+//  private static native void nSetTranslateX(long groupPtr, float translateX);
+//  private static native float nGetTranslateY(long groupPtr);
+//  private static native void nSetTranslateY(long groupPtr, float translateY);
+//
+//  // Setters and getters for VPath during animation.
+//  private static native void nSetPathData(long pathPtr, long pathDataPtr);
+//  private static native float nGetStrokeWidth(long pathPtr);
+//  private static native void nSetStrokeWidth(long pathPtr, float width);
+//  private static native int nGetStrokeColor(long pathPtr);
+//  private static native void nSetStrokeColor(long pathPtr, int strokeColor);
+//  private static native float nGetStrokeAlpha(long pathPtr);
+//  private static native void nSetStrokeAlpha(long pathPtr, float alpha);
+//  private static native int nGetFillColor(long pathPtr);
+//  private static native void nSetFillColor(long pathPtr, int fillColor);
+//  private static native float nGetFillAlpha(long pathPtr);
+//  private static native void nSetFillAlpha(long pathPtr, float fillAlpha);
+//  private static native float nGetTrimPathStart(long pathPtr);
+//  private static native void nSetTrimPathStart(long pathPtr, float trimPathStart);
+//  private static native float nGetTrimPathEnd(long pathPtr);
+//  private static native void nSetTrimPathEnd(long pathPtr, float trimPathEnd);
+//  private static native float nGetTrimPathOffset(long pathPtr);
+//  private static native void nSetTrimPathOffset(long pathPtr, float trimPathOffset);
+
+}
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVelocityTracker.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVelocityTracker.java
new file mode 100644
index 0000000..aed29a4
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVelocityTracker.java
@@ -0,0 +1,194 @@
+package org.robolectric.shadows;
+
+import android.util.SparseArray;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(VelocityTracker.class)
+public class ShadowVelocityTracker {
+  private static final int ACTIVE_POINTER_ID = -1;
+  private static final int HISTORY_SIZE = 20;
+  private static final long HORIZON_MS = 200L;
+  private static final long MIN_DURATION = 10L;
+
+  private boolean initialized = false;
+  private int activePointerId = -1;
+  private final Movement[] movements = new Movement[HISTORY_SIZE];
+  private int curIndex = 0;
+
+  private SparseArray<Float> computedVelocityX = new SparseArray<>();
+  private SparseArray<Float> computedVelocityY = new SparseArray<>();
+
+  private void maybeInitialize() {
+    if (initialized) {
+      return;
+    }
+
+    for (int i = 0; i < movements.length; i++) {
+      movements[i] = new Movement();
+    }
+    initialized = true;
+  }
+
+  @Implementation
+  protected void clear() {
+    maybeInitialize();
+    curIndex = 0;
+    computedVelocityX.clear();
+    computedVelocityY.clear();
+    for (Movement movement : movements) {
+      movement.clear();
+    }
+  }
+
+  @Implementation
+  protected void addMovement(MotionEvent event) {
+    maybeInitialize();
+    if (event == null) {
+      throw new IllegalArgumentException("event must not be null");
+    }
+
+    if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+      clear();
+    } else if (event.getActionMasked() != MotionEvent.ACTION_MOVE) {
+      // only listen for DOWN and MOVE events
+      return;
+    }
+
+    curIndex = (curIndex + 1) % HISTORY_SIZE;
+    movements[curIndex].set(event);
+  }
+
+  @Implementation
+  protected void computeCurrentVelocity(int units) {
+    computeCurrentVelocity(units, Float.MAX_VALUE);
+  }
+
+  @Implementation
+  protected void computeCurrentVelocity(int units, float maxVelocity) {
+    maybeInitialize();
+
+    // Estimation based on AOSP's LegacyVelocityTrackerStrategy
+    Movement newestMovement = movements[curIndex];
+    if (!newestMovement.isSet()) {
+      // no movements added, so we can assume that the current velocity is 0 (and already set that
+      // way)
+      return;
+    }
+
+    for (int pointerId : newestMovement.pointerIds) {
+      // Find the oldest sample that is for the same pointer, but not older than HORIZON_MS
+      long minTime = newestMovement.eventTime - HORIZON_MS;
+      int oldestIndex = curIndex;
+      int numTouches = 1;
+      do {
+        int nextOldestIndex = (oldestIndex == 0 ? HISTORY_SIZE : oldestIndex) - 1;
+        Movement nextOldestMovement = movements[nextOldestIndex];
+        if (!nextOldestMovement.hasPointer(pointerId) || nextOldestMovement.eventTime < minTime) {
+          break;
+        }
+
+        oldestIndex = nextOldestIndex;
+      } while (++numTouches < HISTORY_SIZE);
+
+      float accumVx = 0f;
+      float accumVy = 0f;
+      int index = oldestIndex;
+      Movement oldestMovement = movements[oldestIndex];
+      long lastDuration = 0;
+
+      while (numTouches-- > 1) {
+        if (++index == HISTORY_SIZE) {
+          index = 0;
+        }
+
+        Movement movement = movements[index];
+        long duration = movement.eventTime - oldestMovement.eventTime;
+
+        if (duration >= MIN_DURATION) {
+          float scale = 1000f / duration; // one over time delta in seconds
+          float vx = (movement.x.get(pointerId) - oldestMovement.x.get(pointerId)) * scale;
+          float vy = (movement.y.get(pointerId) - oldestMovement.y.get(pointerId)) * scale;
+          accumVx = (accumVx * lastDuration + vx * duration) / (duration + lastDuration);
+          accumVy = (accumVy * lastDuration + vy * duration) / (duration + lastDuration);
+          lastDuration = duration;
+        }
+      }
+
+      computedVelocityX.put(pointerId, windowed(accumVx * units / 1000, maxVelocity));
+      computedVelocityY.put(pointerId, windowed(accumVy * units / 1000, maxVelocity));
+    }
+
+    activePointerId = newestMovement.activePointerId;
+  }
+
+  private float windowed(float value, float max) {
+    return Math.min(max, Math.max(-max, value));
+  }
+
+  @Implementation
+  protected float getXVelocity() {
+    return getXVelocity(ACTIVE_POINTER_ID);
+  }
+
+  @Implementation
+  protected float getYVelocity() {
+    return getYVelocity(ACTIVE_POINTER_ID);
+  }
+
+  @Implementation
+  protected float getXVelocity(int id) {
+    if (id == ACTIVE_POINTER_ID) {
+      id = activePointerId;
+    }
+
+    return computedVelocityX.get(id, 0f);
+  }
+
+  @Implementation
+  protected float getYVelocity(int id) {
+    if (id == ACTIVE_POINTER_ID) {
+      id = activePointerId;
+    }
+
+    return computedVelocityY.get(id, 0f);
+  }
+
+  private static class Movement {
+    public int pointerCount = 0;
+    public int[] pointerIds = new int[0];
+    public int activePointerId = -1;
+    public long eventTime;
+    public SparseArray<Float> x = new SparseArray<>();
+    public SparseArray<Float> y = new SparseArray<>();
+
+    public void set(MotionEvent event) {
+      pointerCount = event.getPointerCount();
+      pointerIds = new int[pointerCount];
+      x.clear();
+      y.clear();
+      for (int i = 0; i < pointerCount; i++) {
+        pointerIds[i] = event.getPointerId(i);
+        x.put(pointerIds[i], event.getX(i));
+        y.put(pointerIds[i], event.getY(i));
+      }
+      activePointerId = event.getPointerId(0);
+      eventTime = event.getEventTime();
+    }
+
+    public void clear() {
+      pointerCount = 0;
+      activePointerId = -1;
+    }
+
+    public boolean isSet() {
+      return pointerCount != 0;
+    }
+
+    public boolean hasPointer(int pointerId) {
+      return x.get(pointerId) != null;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVibrator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVibrator.java
new file mode 100644
index 0000000..3159bf9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVibrator.java
@@ -0,0 +1,159 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+
+import android.media.AudioAttributes;
+import android.os.VibrationAttributes;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.os.vibrator.VibrationEffectSegment;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(Vibrator.class)
+public class ShadowVibrator {
+  boolean vibrating;
+  boolean cancelled;
+  long milliseconds;
+  protected long[] pattern;
+  protected final List<VibrationEffectSegment> vibrationEffectSegments = new ArrayList<>();
+  protected final List<PrimitiveEffect> primitiveEffects = new ArrayList<>();
+  protected final List<Integer> supportedPrimitives = new ArrayList<>();
+  @Nullable protected VibrationAttributes vibrationAttributesFromLastVibration;
+  @Nullable protected AudioAttributes audioAttributesFromLastVibration;
+  int repeat;
+  boolean hasVibrator = true;
+  boolean hasAmplitudeControl = false;
+  int effectId;
+
+  /** Controls the return value of {@link Vibrator#hasVibrator()} the default is true. */
+  public void setHasVibrator(boolean hasVibrator) {
+    this.hasVibrator = hasVibrator;
+  }
+
+  /** Controls the return value of {@link Vibrator#hasAmplitudeControl()} the default is false. */
+  public void setHasAmplitudeControl(boolean hasAmplitudeControl) {
+    this.hasAmplitudeControl = hasAmplitudeControl;
+  }
+
+  /**
+   * Returns true if the Vibrator is currently vibrating as controlled by {@link
+   * Vibrator#vibrate(long)}
+   */
+  @Implementation(minSdk = R)
+  public boolean isVibrating() {
+    return vibrating;
+  }
+
+  /** Returns true if the Vibrator has been cancelled. */
+  public boolean isCancelled() {
+    return cancelled;
+  }
+
+  /** Returns the last vibration duration in MS. */
+  public long getMilliseconds() {
+    return milliseconds;
+  }
+
+  /** Returns the last vibration pattern. */
+  public long[] getPattern() {
+    return pattern;
+  }
+
+  /**
+   * Returns the last vibration effect ID of a {@link VibrationEffect#Prebaked} (e.g. {@link
+   * VibrationEffect#EFFECT_CLICK}).
+   *
+   * <p>This field is non-zero only if a {@link VibrationEffect#Prebaked} was ever requested.
+   */
+  public int getEffectId() {
+    return effectId;
+  }
+
+  /** Returns the last vibration repeat times. */
+  public int getRepeat() {
+    return repeat;
+  }
+
+  /** Returns the last list of {@link VibrationEffectSegment}. */
+  public List<VibrationEffectSegment> getVibrationEffectSegments() {
+    return vibrationEffectSegments;
+  }
+
+  /** Returns the last list of {@link PrimitiveEffect}. */
+  @Nullable
+  public List<PrimitiveEffect> getPrimitiveEffects() {
+    return primitiveEffects;
+  }
+
+  @Implementation(minSdk = R)
+  protected boolean areAllPrimitivesSupported(int... primitiveIds) {
+    for (int i = 0; i < primitiveIds.length; i++) {
+      if (!supportedPrimitives.contains(primitiveIds[i])) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Adds supported vibration primitives. */
+  public void setSupportedPrimitives(Collection<Integer> primitives) {
+    supportedPrimitives.clear();
+    supportedPrimitives.addAll(primitives);
+  }
+
+  /** Returns the {@link VibrationAttributes} from the last vibration. */
+  @Nullable
+  public VibrationAttributes getVibrationAttributesFromLastVibration() {
+    return vibrationAttributesFromLastVibration;
+  }
+
+  /** Returns the {@link AudioAttributes} from the last vibration. */
+  @Nullable
+  public AudioAttributes getAudioAttributesFromLastVibration() {
+    return audioAttributesFromLastVibration;
+  }
+
+  /**
+   * A data class for exposing {@link VibrationEffect.Composition$PrimitiveEffect}, which is a
+   * hidden non TestApi class introduced in Android R.
+   */
+  public static class PrimitiveEffect {
+    public final int id;
+    public final float scale;
+    public final int delay;
+
+    public PrimitiveEffect(int id, float scale, int delay) {
+      this.id = id;
+      this.scale = scale;
+      this.delay = delay;
+    }
+
+    @Override
+    public String toString() {
+      return "PrimitiveEffect{" + "id=" + id + ", scale=" + scale + ", delay=" + delay + '}';
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || !getClass().isInstance(o)) {
+        return false;
+      }
+      PrimitiveEffect that = (PrimitiveEffect) o;
+      return id == that.id && Float.compare(that.scale, scale) == 0 && delay == that.delay;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(id, scale, delay);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVideoView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVideoView.java
new file mode 100644
index 0000000..2b6a106
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVideoView.java
@@ -0,0 +1,168 @@
+package org.robolectric.shadows;
+
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.widget.VideoView;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(VideoView.class)
+@SuppressWarnings({"UnusedDeclaration"})
+public class ShadowVideoView extends ShadowSurfaceView {
+  private MediaPlayer.OnCompletionListener completionListner;
+  private MediaPlayer.OnErrorListener errorListener;
+  private MediaPlayer.OnPreparedListener preparedListener;
+
+  private Uri uri;
+  private String path;
+  private int duration = 0;
+
+  public static final int STOP = 0;
+  public static final int START = 1;
+  public static final int SUSPEND = 2;
+  public static final int PAUSE = 3;
+  public static final int RESUME = 4;
+
+  private int currentState = -1;
+  private int prevState;
+  private int currentPosition;
+
+  @Implementation
+  protected void setOnPreparedListener(MediaPlayer.OnPreparedListener l) {
+    preparedListener = l;
+  }
+
+  @Implementation
+  protected void setOnErrorListener(MediaPlayer.OnErrorListener l) {
+    errorListener = l;
+  }
+
+  @Implementation
+  protected void setOnCompletionListener(MediaPlayer.OnCompletionListener l) {
+    completionListner = l;
+  }
+
+  @Implementation
+  protected void setVideoPath(String path) {
+    this.path = path;
+  }
+
+  @Implementation
+  protected void setVideoURI(Uri uri) {
+    this.uri = uri;
+  }
+
+  @Implementation
+  protected void start() {
+    savePrevState();
+    currentState = ShadowVideoView.START;
+  }
+
+  @Implementation
+  protected void stopPlayback() {
+    savePrevState();
+    currentState = ShadowVideoView.STOP;
+  }
+
+  @Implementation
+  protected void suspend() {
+    savePrevState();
+    currentState = ShadowVideoView.SUSPEND;
+  }
+
+  @Implementation
+  protected void pause() {
+    savePrevState();
+    currentState = ShadowVideoView.PAUSE;
+  }
+
+  @Implementation
+  protected void resume() {
+    savePrevState();
+    currentState = ShadowVideoView.RESUME;
+  }
+
+  @Implementation
+  protected boolean isPlaying() {
+    return (currentState == ShadowVideoView.START);
+  }
+
+  @Implementation
+  protected boolean canPause() {
+    return (currentState != ShadowVideoView.PAUSE &&
+        currentState != ShadowVideoView.STOP &&
+        currentState != ShadowVideoView.SUSPEND);
+  }
+
+  @Implementation
+  protected void seekTo(int msec) {
+    currentPosition = msec;
+  }
+
+  @Implementation
+  protected int getCurrentPosition() {
+    return currentPosition;
+  }
+
+  @Implementation
+  protected int getDuration() {
+    return duration;
+  }
+
+  /**
+   * @return On prepared listener.
+   */
+  public MediaPlayer.OnPreparedListener getOnPreparedListener() {
+    return preparedListener;
+  }
+
+  /**
+   * @return On error listener.
+   */
+  public MediaPlayer.OnErrorListener getOnErrorListener() {
+    return errorListener;
+  }
+
+  /**
+   * @return On completion listener.
+   */
+  public MediaPlayer.OnCompletionListener getOnCompletionListener() {
+    return completionListner;
+  }
+
+  /**
+   * @return Video path.
+   */
+  public String getVideoPath() {
+    return path;
+  }
+
+  /**
+   * @return Video URI.
+   */
+  public String getVideoURIString() {
+    return uri == null ? null : uri.toString();
+  }
+
+  /**
+   * @return Current video state.
+   */
+  public int getCurrentVideoState() {
+    return currentState;
+  }
+
+  /**
+   * @return Previous video state.
+   */
+  public int getPrevVideoState() {
+    return prevState;
+  }
+
+  public void setDuration(int duration) {
+    this.duration = duration;
+  }
+
+  private void savePrevState() {
+    prevState = currentState;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java
new file mode 100644
index 0000000..70cb369
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java
@@ -0,0 +1,1060 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.O;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.shadow.api.Shadow.invokeConstructor;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+import static org.robolectric.util.ReflectionHelpers.getField;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Choreographer;
+import android.view.IWindowFocusObserver;
+import android.view.IWindowId;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewParent;
+import android.view.WindowId;
+import android.view.WindowManager;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+import com.google.common.collect.ImmutableList;
+import java.io.PrintStream;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.ReflectorObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.TimeUtils;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(View.class)
+@SuppressLint("NewApi")
+public class ShadowView {
+
+  @RealObject protected View realView;
+  @ReflectorObject protected _View_ viewReflector;
+  private static final List<View.OnClickListener> globalClickListeners =
+      new CopyOnWriteArrayList<>();
+  private static final List<View.OnLongClickListener> globalLongClickListeners =
+      new CopyOnWriteArrayList<>();
+  private View.OnClickListener onClickListener;
+  private View.OnLongClickListener onLongClickListener;
+  private View.OnFocusChangeListener onFocusChangeListener;
+  private View.OnSystemUiVisibilityChangeListener onSystemUiVisibilityChangeListener;
+  private final HashSet<View.OnAttachStateChangeListener> onAttachStateChangeListeners =
+      new HashSet<>();
+  private final HashSet<View.OnLayoutChangeListener> onLayoutChangeListeners = new HashSet<>();
+  private boolean wasInvalidated;
+  private View.OnTouchListener onTouchListener;
+  protected AttributeSet attributeSet;
+  public Point scrollToCoordinates = new Point();
+  private boolean didRequestLayout;
+  private MotionEvent lastTouchEvent;
+  private int hapticFeedbackPerformed = -1;
+  private boolean onLayoutWasCalled;
+  private View.OnCreateContextMenuListener onCreateContextMenuListener;
+  private Rect globalVisibleRect;
+  private int layerType;
+  private final ArrayList<Animation> animations = new ArrayList<>();
+  private AnimationRunner animationRunner;
+
+  /**
+   * Calls {@code performClick()} on a {@code View} after ensuring that it and its ancestors are
+   * visible and that it is enabled.
+   *
+   * @param view the view to click on
+   * @return true if {@code View.OnClickListener}s were found and fired, false otherwise.
+   * @throws RuntimeException if the preconditions are not met.
+   * @deprecated Please use Espresso for view interactions
+   */
+  @Deprecated
+  public static boolean clickOn(View view) {
+    ShadowView shadowView = Shadow.extract(view);
+    return shadowView.checkedPerformClick();
+  }
+
+  /**
+   * Returns a textual representation of the appearance of the object.
+   *
+   * @param view the view to visualize
+   * @return Textual representation of the appearance of the object.
+   */
+  public static String visualize(View view) {
+    Canvas canvas = new Canvas();
+    view.draw(canvas);
+    if (!useRealGraphics()) {
+      ShadowCanvas shadowCanvas = Shadow.extract(canvas);
+      return shadowCanvas.getDescription();
+    } else {
+      return "";
+    }
+  }
+
+  /**
+   * Emits an xml-like representation of the view to System.out.
+   *
+   * @param view the view to dump.
+   * @deprecated - Please use {@link androidx.test.espresso.util.HumanReadables#describe(View)}
+   */
+  @SuppressWarnings("UnusedDeclaration")
+  @Deprecated
+  public static void dump(View view) {
+    ShadowView shadowView = Shadow.extract(view);
+    shadowView.dump();
+  }
+
+  /**
+   * Returns the text contained within this view.
+   *
+   * @param view the view to scan for text
+   * @return Text contained within this view.
+   */
+  @SuppressWarnings("UnusedDeclaration")
+  public static String innerText(View view) {
+    ShadowView shadowView = Shadow.extract(view);
+    return shadowView.innerText();
+  }
+
+  // Only override up to kitkat, while this version exists after kitkat it just calls through to the
+  // __constructor__(Context, AttributeSet, int, int) variant below.
+  @Implementation(maxSdk = KITKAT)
+  protected void __constructor__(Context context, AttributeSet attributeSet, int defStyle) {
+    this.attributeSet = attributeSet;
+    invokeConstructor(
+        View.class,
+        realView,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(AttributeSet.class, attributeSet),
+        ClassParameter.from(int.class, defStyle));
+  }
+
+  @Implementation(minSdk = KITKAT_WATCH)
+  protected void __constructor__(
+      Context context, AttributeSet attributeSet, int defStyleAttr, int defStyleRes) {
+    this.attributeSet = attributeSet;
+    invokeConstructor(
+        View.class,
+        realView,
+        ClassParameter.from(Context.class, context),
+        ClassParameter.from(AttributeSet.class, attributeSet),
+        ClassParameter.from(int.class, defStyleAttr),
+        ClassParameter.from(int.class, defStyleRes));
+  }
+
+  @Implementation
+  protected void setLayerType(int layerType, Paint paint) {
+    this.layerType = layerType;
+    reflector(_View_.class, realView).setLayerType(layerType, paint);
+  }
+
+  @Implementation
+  protected void setOnFocusChangeListener(View.OnFocusChangeListener l) {
+    onFocusChangeListener = l;
+    reflector(_View_.class, realView).setOnFocusChangeListener(l);
+  }
+
+  @Implementation
+  protected void setOnClickListener(View.OnClickListener onClickListener) {
+    this.onClickListener = onClickListener;
+    reflector(_View_.class, realView).setOnClickListener(onClickListener);
+  }
+
+  @Implementation
+  protected void setOnLongClickListener(View.OnLongClickListener onLongClickListener) {
+    this.onLongClickListener = onLongClickListener;
+    reflector(_View_.class, realView).setOnLongClickListener(onLongClickListener);
+  }
+
+  @Implementation
+  protected void setOnSystemUiVisibilityChangeListener(
+      View.OnSystemUiVisibilityChangeListener onSystemUiVisibilityChangeListener) {
+    this.onSystemUiVisibilityChangeListener = onSystemUiVisibilityChangeListener;
+    reflector(_View_.class, realView)
+        .setOnSystemUiVisibilityChangeListener(onSystemUiVisibilityChangeListener);
+  }
+
+  @Implementation
+  protected void setOnCreateContextMenuListener(
+      View.OnCreateContextMenuListener onCreateContextMenuListener) {
+    this.onCreateContextMenuListener = onCreateContextMenuListener;
+    reflector(_View_.class, realView).setOnCreateContextMenuListener(onCreateContextMenuListener);
+  }
+
+  @Implementation
+  protected void addOnAttachStateChangeListener(
+      View.OnAttachStateChangeListener onAttachStateChangeListener) {
+    onAttachStateChangeListeners.add(onAttachStateChangeListener);
+    reflector(_View_.class, realView).addOnAttachStateChangeListener(onAttachStateChangeListener);
+  }
+
+  @Implementation
+  protected void removeOnAttachStateChangeListener(
+      View.OnAttachStateChangeListener onAttachStateChangeListener) {
+    onAttachStateChangeListeners.remove(onAttachStateChangeListener);
+    reflector(_View_.class, realView)
+        .removeOnAttachStateChangeListener(onAttachStateChangeListener);
+  }
+
+  @Implementation
+  protected void addOnLayoutChangeListener(View.OnLayoutChangeListener onLayoutChangeListener) {
+    onLayoutChangeListeners.add(onLayoutChangeListener);
+    reflector(_View_.class, realView).addOnLayoutChangeListener(onLayoutChangeListener);
+  }
+
+  @Implementation
+  protected void removeOnLayoutChangeListener(View.OnLayoutChangeListener onLayoutChangeListener) {
+    onLayoutChangeListeners.remove(onLayoutChangeListener);
+    reflector(_View_.class, realView).removeOnLayoutChangeListener(onLayoutChangeListener);
+  }
+
+  @Implementation
+  protected void draw(Canvas canvas) {
+    Drawable background = realView.getBackground();
+    if (background != null && !useRealGraphics()) {
+      Object shadowCanvas = Shadow.extract(canvas);
+      // Check that Canvas is not a Mockito mock
+      if (shadowCanvas instanceof ShadowCanvas) {
+        ((ShadowCanvas) shadowCanvas).appendDescription("background:");
+      }
+    }
+    reflector(_View_.class, realView).draw(canvas);
+  }
+
+  @Implementation
+  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+    onLayoutWasCalled = true;
+    reflector(_View_.class, realView).onLayout(changed, left, top, right, bottom);
+  }
+
+  public boolean onLayoutWasCalled() {
+    return onLayoutWasCalled;
+  }
+
+  @Implementation
+  protected void requestLayout() {
+    didRequestLayout = true;
+    reflector(_View_.class, realView).requestLayout();
+  }
+
+  @Implementation
+  protected boolean performClick() {
+    for (View.OnClickListener listener : globalClickListeners) {
+      listener.onClick(realView);
+    }
+    return reflector(_View_.class, realView).performClick();
+  }
+
+  /**
+   * Registers an {@link View.OnClickListener} to the {@link ShadowView}.
+   *
+   * @param listener The {@link View.OnClickListener} to be registered.
+   */
+  public static void addGlobalPerformClickListener(View.OnClickListener listener) {
+    ShadowView.globalClickListeners.add(listener);
+  }
+
+  /**
+   * Removes an {@link View.OnClickListener} from the {@link ShadowView}.
+   *
+   * @param listener The {@link View.OnClickListener} to be removed.
+   */
+  public static void removeGlobalPerformClickListener(View.OnClickListener listener) {
+    ShadowView.globalClickListeners.remove(listener);
+  }
+
+  @Implementation
+  protected boolean performLongClick() {
+    for (View.OnLongClickListener listener : globalLongClickListeners) {
+      listener.onLongClick(realView);
+    }
+    return reflector(_View_.class, realView).performLongClick();
+  }
+
+  /**
+   * Registers an {@link View.OnLongClickListener} to the {@link ShadowView}.
+   *
+   * @param listener The {@link View.OnLongClickListener} to be registered.
+   */
+  public static void addGlobalPerformLongClickListener(View.OnLongClickListener listener) {
+    ShadowView.globalLongClickListeners.add(listener);
+  }
+
+  /**
+   * Removes an {@link View.OnLongClickListener} from the {@link ShadowView}.
+   *
+   * @param listener The {@link View.OnLongClickListener} to be removed.
+   */
+  public static void removeGlobalPerformLongClickListener(View.OnLongClickListener listener) {
+    ShadowView.globalLongClickListeners.remove(listener);
+  }
+
+  @Resetter
+  public static void reset() {
+    ShadowView.globalClickListeners.clear();
+    ShadowView.globalLongClickListeners.clear();
+  }
+
+  public boolean didRequestLayout() {
+    return didRequestLayout;
+  }
+
+  public void setDidRequestLayout(boolean didRequestLayout) {
+    this.didRequestLayout = didRequestLayout;
+  }
+
+  public void setViewFocus(boolean hasFocus) {
+    if (onFocusChangeListener != null) {
+      onFocusChangeListener.onFocusChange(realView, hasFocus);
+    }
+  }
+
+  @Implementation
+  protected void invalidate() {
+    wasInvalidated = true;
+    reflector(_View_.class, realView).invalidate();
+  }
+
+  @Implementation
+  protected boolean onTouchEvent(MotionEvent event) {
+    lastTouchEvent = event;
+    return reflector(_View_.class, realView).onTouchEvent(event);
+  }
+
+  @Implementation
+  protected void setOnTouchListener(View.OnTouchListener onTouchListener) {
+    this.onTouchListener = onTouchListener;
+    reflector(_View_.class, realView).setOnTouchListener(onTouchListener);
+  }
+
+  public MotionEvent getLastTouchEvent() {
+    return lastTouchEvent;
+  }
+
+  /**
+   * Returns a string representation of this {@code View}. Unless overridden, it will be an empty
+   * string.
+   *
+   * <p>Robolectric extension.
+   *
+   * @return String representation of this view.
+   */
+  public String innerText() {
+    return "";
+  }
+
+  /**
+   * Dumps the status of this {@code View} to {@code System.out}
+   *
+   * @deprecated - Please use {@link androidx.test.espresso.util.HumanReadables#describe(View)}
+   */
+  @Deprecated
+  public void dump() {
+    dump(System.out, 0);
+  }
+
+  /**
+   * Dumps the status of this {@code View} to {@code System.out} at the given indentation level
+   *
+   * @param out Output stream.
+   * @param indent Indentation level.
+   * @deprecated - Please use {@link androidx.test.espresso.util.HumanReadables#describe(View)}
+   */
+  @Deprecated
+  public void dump(PrintStream out, int indent) {
+    dumpFirstPart(out, indent);
+    out.println("/>");
+  }
+
+  @Deprecated
+  protected void dumpFirstPart(PrintStream out, int indent) {
+    dumpIndent(out, indent);
+
+    out.print("<" + realView.getClass().getSimpleName());
+    dumpAttributes(out);
+  }
+
+  @Deprecated
+  protected void dumpAttributes(PrintStream out) {
+    if (realView.getId() > 0) {
+      dumpAttribute(
+          out, "id", realView.getContext().getResources().getResourceName(realView.getId()));
+    }
+
+    switch (realView.getVisibility()) {
+      case View.VISIBLE:
+        break;
+      case View.INVISIBLE:
+        dumpAttribute(out, "visibility", "INVISIBLE");
+        break;
+      case View.GONE:
+        dumpAttribute(out, "visibility", "GONE");
+        break;
+    }
+  }
+
+  @Deprecated
+  protected void dumpAttribute(PrintStream out, String name, String value) {
+    out.print(" " + name + "=\"" + (value == null ? null : TextUtils.htmlEncode(value)) + "\"");
+  }
+
+  @Deprecated
+  protected void dumpIndent(PrintStream out, int indent) {
+    for (int i = 0; i < indent; i++) out.print(" ");
+  }
+
+  /**
+   * @return whether or not {@link #invalidate()} has been called
+   */
+  public boolean wasInvalidated() {
+    return wasInvalidated;
+  }
+
+  /** Clears the wasInvalidated flag */
+  public void clearWasInvalidated() {
+    wasInvalidated = false;
+  }
+
+  /**
+   * Utility method for clicking on views exposing testing scenarios that are not possible when
+   * using the actual app.
+   *
+   * <p>If running with LooperMode PAUSED will also idle the main Looper.
+   *
+   * @throws RuntimeException if the view is disabled or if the view or any of its parents are not
+   *     visible.
+   * @return Return value of the underlying click operation.
+   * @deprecated - Please use Espresso for View interactions.
+   */
+  @Deprecated
+  public boolean checkedPerformClick() {
+    if (!realView.isShown()) {
+      throw new RuntimeException("View is not visible and cannot be clicked");
+    }
+    if (!realView.isEnabled()) {
+      throw new RuntimeException("View is not enabled and cannot be clicked");
+    }
+    boolean res = realView.performClick();
+    shadowMainLooper().idleIfPaused();
+    return res;
+  }
+
+  /**
+   * @return Touch listener, if set.
+   */
+  public View.OnTouchListener getOnTouchListener() {
+    return onTouchListener;
+  }
+
+  /**
+   * @return Returns click listener, if set.
+   */
+  public View.OnClickListener getOnClickListener() {
+    return onClickListener;
+  }
+
+  /**
+   * @return Returns long click listener, if set.
+   */
+  @Implementation(minSdk = R)
+  public View.OnLongClickListener getOnLongClickListener() {
+    if (RuntimeEnvironment.getApiLevel() >= R) {
+      return reflector(_View_.class, realView).getOnLongClickListener();
+    } else {
+      return onLongClickListener;
+    }
+  }
+
+  /**
+   * @return Returns system ui visibility change listener.
+   */
+  public View.OnSystemUiVisibilityChangeListener getOnSystemUiVisibilityChangeListener() {
+    return onSystemUiVisibilityChangeListener;
+  }
+
+  /**
+   * @return Returns create ContextMenu listener, if set.
+   */
+  public View.OnCreateContextMenuListener getOnCreateContextMenuListener() {
+    return onCreateContextMenuListener;
+  }
+
+  /**
+   * @return Returns the attached listeners, or the empty set if none are present.
+   */
+  public Set<View.OnAttachStateChangeListener> getOnAttachStateChangeListeners() {
+    return onAttachStateChangeListeners;
+  }
+
+  /**
+   * @return Returns the layout change listeners, or the empty set if none are present.
+   */
+  public Set<View.OnLayoutChangeListener> getOnLayoutChangeListeners() {
+    return onLayoutChangeListeners;
+  }
+
+  @Implementation
+  protected boolean post(Runnable action) {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      return reflector(_View_.class, realView).post(action);
+    } else {
+      ShadowApplication.getInstance().getForegroundThreadScheduler().post(action);
+      return true;
+    }
+  }
+
+  @Implementation
+  protected boolean postDelayed(Runnable action, long delayMills) {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      return reflector(_View_.class, realView).postDelayed(action, delayMills);
+    } else {
+      ShadowApplication.getInstance()
+          .getForegroundThreadScheduler()
+          .postDelayed(action, delayMills);
+      return true;
+    }
+  }
+
+  @Implementation
+  protected void postInvalidateDelayed(long delayMilliseconds) {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      reflector(_View_.class, realView).postInvalidateDelayed(delayMilliseconds);
+    } else {
+      ShadowApplication.getInstance()
+          .getForegroundThreadScheduler()
+          .postDelayed(
+              new Runnable() {
+                @Override
+                public void run() {
+                  realView.invalidate();
+                }
+              },
+              delayMilliseconds);
+    }
+  }
+
+  @Implementation
+  protected boolean removeCallbacks(Runnable callback) {
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      return reflector(_View_.class, realView).removeCallbacks(callback);
+    } else {
+      ShadowLegacyLooper shadowLooper = Shadow.extract(Looper.getMainLooper());
+      shadowLooper.getScheduler().remove(callback);
+      return true;
+    }
+  }
+
+  @Implementation
+  protected void scrollTo(int x, int y) {
+    if (useRealGraphics()) {
+      reflector(_View_.class, realView).scrollTo(x, y);
+    } else {
+      reflector(_View_.class, realView)
+          .onScrollChanged(x, y, scrollToCoordinates.x, scrollToCoordinates.y);
+      scrollToCoordinates = new Point(x, y);
+      reflector(_View_.class, realView).setMemberScrollX(x);
+      reflector(_View_.class, realView).setMemberScrollY(y);
+    }
+  }
+
+  @Implementation
+  protected void scrollBy(int x, int y) {
+    if (useRealGraphics()) {
+      reflector(_View_.class, realView).scrollBy(x, y);
+    } else {
+      scrollTo(getScrollX() + x, getScrollY() + y);
+    }
+  }
+
+  @Implementation
+  protected int getScrollX() {
+    if (useRealGraphics()) {
+      return reflector(_View_.class, realView).getScrollX();
+    } else {
+      return scrollToCoordinates != null ? scrollToCoordinates.x : 0;
+    }
+  }
+
+  @Implementation
+  protected int getScrollY() {
+    if (useRealGraphics()) {
+      return reflector(_View_.class, realView).getScrollY();
+    } else {
+      return scrollToCoordinates != null ? scrollToCoordinates.y : 0;
+    }
+  }
+
+  @Implementation
+  protected void setScrollX(int scrollX) {
+    if (useRealGraphics()) {
+      reflector(_View_.class, realView).setScrollX(scrollX);
+    } else {
+      scrollTo(scrollX, scrollToCoordinates.y);
+    }
+  }
+
+  @Implementation
+  protected void setScrollY(int scrollY) {
+    if (useRealGraphics()) {
+      reflector(_View_.class, realView).setScrollY(scrollY);
+    } else {
+      scrollTo(scrollToCoordinates.x, scrollY);
+    }
+  }
+
+  @Implementation
+  protected void getLocationOnScreen(int[] outLocation) {
+    reflector(_View_.class, realView).getLocationOnScreen(outLocation);
+    int[] windowLocation = getWindowLocation();
+    outLocation[0] += windowLocation[0];
+    outLocation[1] += windowLocation[1];
+  }
+
+  @Implementation(minSdk = O)
+  protected void mapRectFromViewToScreenCoords(RectF rect, boolean clipToParent) {
+    reflector(_View_.class, realView).mapRectFromViewToScreenCoords(rect, clipToParent);
+    int[] windowLocation = getWindowLocation();
+    rect.offset(windowLocation[0], windowLocation[1]);
+  }
+
+  // TODO(paulsowden): Should configure the correct frame on the ViewRootImpl instead and remove
+  //  this.
+  private int[] getWindowLocation() {
+    int[] location = new int[2];
+    LayoutParams rootParams = realView.getRootView().getLayoutParams();
+    if (rootParams instanceof WindowManager.LayoutParams) {
+      location[0] = ((WindowManager.LayoutParams) rootParams).x;
+      location[1] = ((WindowManager.LayoutParams) rootParams).y;
+    }
+    return location;
+  }
+
+  @Implementation
+  protected int getLayerType() {
+    return this.layerType;
+  }
+
+  /** Returns a list of all animations that have been set on this view. */
+  public ImmutableList<Animation> getAnimations() {
+    return ImmutableList.copyOf(animations);
+  }
+
+  /** Resets the list returned by {@link #getAnimations()} to an empty list. */
+  public void clearAnimations() {
+    animations.clear();
+  }
+
+  @Implementation
+  protected void setAnimation(final Animation animation) {
+    reflector(_View_.class, realView).setAnimation(animation);
+
+    if (animation != null) {
+      animations.add(animation);
+      if (animationRunner != null) {
+        animationRunner.cancel();
+      }
+      animationRunner = new AnimationRunner(animation);
+      animationRunner.start();
+    }
+  }
+
+  @Implementation
+  protected void clearAnimation() {
+    reflector(_View_.class, realView).clearAnimation();
+
+    if (animationRunner != null) {
+      animationRunner.cancel();
+      animationRunner = null;
+    }
+  }
+
+  @Implementation
+  protected boolean initialAwakenScrollBars() {
+    // Temporarily allow disabling initial awaken of scroll bars to aid in migration of tests to
+    // default to window's being marked visible, this will be removed once migration is complete.
+    if (Boolean.getBoolean("robolectric.disableInitialAwakenScrollBars")) {
+      return false;
+    } else {
+      return viewReflector.initialAwakenScrollBars();
+    }
+  }
+
+  private class AnimationRunner implements Runnable {
+    private final Animation animation;
+    private final Transformation transformation = new Transformation();
+    private long startTime;
+    private long elapsedTime;
+    private boolean canceled;
+
+    AnimationRunner(Animation animation) {
+      this.animation = animation;
+    }
+
+    private void start() {
+      startTime = animation.getStartTime();
+      long startOffset = animation.getStartOffset();
+      long startDelay =
+          startTime == Animation.START_ON_FIRST_FRAME
+              ? startOffset
+              : (startTime + startOffset) - SystemClock.uptimeMillis();
+      Choreographer.getInstance()
+          .postCallbackDelayed(Choreographer.CALLBACK_ANIMATION, this, null, startDelay);
+    }
+
+    private boolean step() {
+      long animationTime =
+          animation.getStartTime() == Animation.START_ON_FIRST_FRAME
+              ? SystemClock.uptimeMillis()
+              : (animation.getStartTime() + animation.getStartOffset() + elapsedTime);
+      // Note in real android the parent is non-nullable, retain legacy robolectric behavior which
+      // allows detached views to animate.
+      if (!animation.isInitialized() && realView.getParent() != null) {
+        View parent = (View) realView.getParent();
+        animation.initialize(
+            realView.getWidth(), realView.getHeight(), parent.getWidth(), parent.getHeight());
+      }
+      boolean next = animation.getTransformation(animationTime, transformation);
+      // Note in real view implementation it doesn't check the animation equality before clearing,
+      // but in the real implementation the animation listeners are posted so it doesn't race with
+      // chained animations.
+      if (realView.getAnimation() == animation && !next) {
+        if (!animation.getFillAfter()) {
+          realView.clearAnimation();
+        }
+      }
+      // We can't handle infinitely repeating animations in the current scheduling model, so abort
+      // after one iteration.
+      return next
+          && (animation.getRepeatCount() != Animation.INFINITE
+              || elapsedTime < animation.getDuration());
+    }
+
+    @Override
+    public void run() {
+      // Abort if start time has been messed with, as this simulation is only designed to handle
+      // standard situations.
+      if (!canceled && animation.getStartTime() == startTime && step()) {
+        // Start time updates for repeating animations and if START_ON_FIRST_FRAME.
+        startTime = animation.getStartTime();
+        elapsedTime +=
+            ShadowLooper.looperMode().equals(LooperMode.Mode.LEGACY)
+                ? ShadowChoreographer.getFrameInterval() / TimeUtils.NANOS_PER_MS
+                : ShadowChoreographer.getFrameDelay().toMillis();
+        Choreographer.getInstance().postCallback(Choreographer.CALLBACK_ANIMATION, this, null);
+      } else if (animationRunner == this) {
+        animationRunner = null;
+      }
+    }
+
+    public void cancel() {
+      this.canceled = true;
+      Choreographer.getInstance()
+          .removeCallbacks(Choreographer.CALLBACK_ANIMATION, animationRunner, null);
+    }
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected boolean isAttachedToWindow() {
+    return getAttachInfo() != null;
+  }
+
+  private Object getAttachInfo() {
+    return reflector(_View_.class, realView).getAttachInfo();
+  }
+
+  /** Reflector interface for {@link View}'s internals. */
+  @ForType(View.class)
+  private interface _View_ {
+
+    @Direct
+    void draw(Canvas canvas);
+
+    @Direct
+    void onLayout(boolean changed, int left, int top, int right, int bottom);
+
+    void assignParent(ViewParent viewParent);
+
+    @Direct
+    void setOnFocusChangeListener(View.OnFocusChangeListener l);
+
+    @Direct
+    void setLayerType(int layerType, Paint paint);
+
+    @Direct
+    void setOnClickListener(View.OnClickListener onClickListener);
+
+    @Direct
+    void setOnLongClickListener(View.OnLongClickListener onLongClickListener);
+
+    @Direct
+    View.OnLongClickListener getOnLongClickListener();
+
+    @Direct
+    void setOnSystemUiVisibilityChangeListener(
+        View.OnSystemUiVisibilityChangeListener onSystemUiVisibilityChangeListener);
+
+    @Direct
+    void setOnCreateContextMenuListener(
+        View.OnCreateContextMenuListener onCreateContextMenuListener);
+
+    @Direct
+    void addOnAttachStateChangeListener(
+        View.OnAttachStateChangeListener onAttachStateChangeListener);
+
+    @Direct
+    void removeOnAttachStateChangeListener(
+        View.OnAttachStateChangeListener onAttachStateChangeListener);
+
+    @Direct
+    void addOnLayoutChangeListener(View.OnLayoutChangeListener onLayoutChangeListener);
+
+    @Direct
+    void removeOnLayoutChangeListener(View.OnLayoutChangeListener onLayoutChangeListener);
+
+    @Direct
+    void requestLayout();
+
+    @Direct
+    boolean performClick();
+
+    @Direct
+    boolean performLongClick();
+
+    @Direct
+    void invalidate();
+
+    @Direct
+    boolean onTouchEvent(MotionEvent event);
+
+    @Direct
+    void setOnTouchListener(View.OnTouchListener onTouchListener);
+
+    @Direct
+    boolean post(Runnable action);
+
+    @Direct
+    boolean postDelayed(Runnable action, long delayMills);
+
+    @Direct
+    void postInvalidateDelayed(long delayMilliseconds);
+
+    @Direct
+    boolean removeCallbacks(Runnable callback);
+
+    @Direct
+    void setAnimation(final Animation animation);
+
+    @Direct
+    void clearAnimation();
+
+    @Direct
+    boolean getGlobalVisibleRect(Rect rect, Point globalOffset);
+
+    @Direct
+    WindowId getWindowId();
+
+    @Accessor("mAttachInfo")
+    Object getAttachInfo();
+
+    void onAttachedToWindow();
+
+    void onDetachedFromWindow();
+
+    void onScrollChanged(int l, int t, int oldl, int oldt);
+
+    @Direct
+    void getLocationOnScreen(int[] outLocation);
+
+    @Direct
+    void mapRectFromViewToScreenCoords(RectF rect, boolean clipToParent);
+
+    @Direct
+    int getSourceLayoutResId();
+
+    @Direct
+    boolean initialAwakenScrollBars();
+
+    @Accessor("mScrollX")
+    void setMemberScrollX(int value);
+
+    @Accessor("mScrollY")
+    void setMemberScrollY(int value);
+
+    @Direct
+    void scrollTo(int x, int y);
+
+    @Direct
+    void scrollBy(int x, int y);
+
+    @Direct
+    int getScrollX();
+
+    @Direct
+    int getScrollY();
+
+    @Direct
+    void setScrollX(int value);
+
+    @Direct
+    void setScrollY(int value);
+  }
+
+  public void callOnAttachedToWindow() {
+    reflector(_View_.class, realView).onAttachedToWindow();
+  }
+
+  public void callOnDetachedFromWindow() {
+    reflector(_View_.class, realView).onDetachedFromWindow();
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected WindowId getWindowId() {
+    return WindowIdHelper.getWindowId(this);
+  }
+
+  @Implementation
+  protected boolean performHapticFeedback(int hapticFeedbackType) {
+    hapticFeedbackPerformed = hapticFeedbackType;
+    return true;
+  }
+
+  @Implementation
+  protected boolean getGlobalVisibleRect(Rect rect, Point globalOffset) {
+    if (globalVisibleRect == null) {
+      return reflector(_View_.class, realView).getGlobalVisibleRect(rect, globalOffset);
+    }
+
+    if (!globalVisibleRect.isEmpty()) {
+      rect.set(globalVisibleRect);
+      if (globalOffset != null) {
+        rect.offset(-globalOffset.x, -globalOffset.y);
+      }
+      return true;
+    }
+    rect.setEmpty();
+    return false;
+  }
+
+  public void setGlobalVisibleRect(Rect rect) {
+    if (rect != null) {
+      globalVisibleRect = new Rect();
+      globalVisibleRect.set(rect);
+    } else {
+      globalVisibleRect = null;
+    }
+  }
+
+  public int lastHapticFeedbackPerformed() {
+    return hapticFeedbackPerformed;
+  }
+
+  public void setMyParent(ViewParent viewParent) {
+    reflector(_View_.class, realView).assignParent(viewParent);
+  }
+
+  @Implementation
+  protected void getWindowVisibleDisplayFrame(Rect outRect) {
+    // TODO: figure out how to simulate this logic instead
+    // if (mAttachInfo != null) {
+    //   mAttachInfo.mSession.getDisplayFrame(mAttachInfo.mWindow, outRect);
+
+    ShadowDisplay.getDefaultDisplay().getRectSize(outRect);
+  }
+
+  @Implementation(minSdk = N)
+  protected void getWindowDisplayFrame(Rect outRect) {
+    // TODO: figure out how to simulate this logic instead
+    // if (mAttachInfo != null) {
+    //   mAttachInfo.mSession.getDisplayFrame(mAttachInfo.mWindow, outRect);
+
+    ShadowDisplay.getDefaultDisplay().getRectSize(outRect);
+  }
+
+  /**
+   * Returns the layout resource id this view was inflated from. Backwards compatible version of
+   * {@link View#getSourceLayoutResId()}, passes through to the underlying implementation on API
+   * levels where it is supported.
+   */
+  @Implementation(minSdk = Q)
+  public int getSourceLayoutResId() {
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      return reflector(_View_.class, realView).getSourceLayoutResId();
+    } else {
+      return ShadowResources.getAttributeSetSourceResId(attributeSet);
+    }
+  }
+
+  public static class WindowIdHelper {
+    public static WindowId getWindowId(ShadowView shadowView) {
+      if (shadowView.isAttachedToWindow()) {
+        Object attachInfo = shadowView.getAttachInfo();
+        if (getField(attachInfo, "mWindowId") == null) {
+          IWindowId iWindowId = new MyIWindowIdStub();
+          reflector(_AttachInfo_.class, attachInfo).setWindowId(new WindowId(iWindowId));
+          reflector(_AttachInfo_.class, attachInfo).setIWindowId(iWindowId);
+        }
+      }
+
+      return reflector(_View_.class, shadowView.realView).getWindowId();
+    }
+
+    private static class MyIWindowIdStub extends IWindowId.Stub {
+      @Override
+      public void registerFocusObserver(IWindowFocusObserver iWindowFocusObserver)
+          throws RemoteException {}
+
+      @Override
+      public void unregisterFocusObserver(IWindowFocusObserver iWindowFocusObserver)
+          throws RemoteException {}
+
+      @Override
+      public boolean isFocused() throws RemoteException {
+        return true;
+      }
+    }
+  }
+
+  /** Reflector interface for android.view.View.AttachInfo's internals. */
+  @ForType(className = "android.view.View$AttachInfo")
+  interface _AttachInfo_ {
+
+    @Accessor("mIWindowId")
+    void setIWindowId(IWindowId iWindowId);
+
+    @Accessor("mWindowId")
+    void setWindowId(WindowId windowId);
+  }
+
+  static boolean useRealGraphics() {
+    return Boolean.getBoolean("robolectric.nativeruntime.enableGraphics");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewAnimator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewAnimator.java
new file mode 100644
index 0000000..41a66d2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewAnimator.java
@@ -0,0 +1,42 @@
+package org.robolectric.shadows;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ViewAnimator;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(ViewAnimator.class)
+public class ShadowViewAnimator extends ShadowViewGroup {
+
+  private int currentChild = 0;
+
+  @Implementation
+  protected int getDisplayedChild() {
+    return currentChild;
+  }
+
+  @Implementation
+  protected void setDisplayedChild(int whichChild) {
+    currentChild = whichChild;
+    for (int i = ((ViewGroup) realView).getChildCount() - 1; i >= 0; i--) {
+      View child = ((ViewGroup) realView).getChildAt(i);
+      child.setVisibility(i == whichChild ? View.VISIBLE : View.GONE);
+    }
+  }
+
+  @Implementation
+  protected View getCurrentView() {
+    return ((ViewGroup) realView).getChildAt(getDisplayedChild());
+  }
+
+  @Implementation
+  protected void showNext() {
+    setDisplayedChild((getDisplayedChild() + 1) % ((ViewGroup) realView).getChildCount());
+  }
+
+  @Implementation
+  protected void showPrevious() {
+    setDisplayedChild(getDisplayedChild() == 0 ? ((ViewGroup) realView).getChildCount() - 1 : getDisplayedChild() - 1);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewConfiguration.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewConfiguration.java
new file mode 100644
index 0000000..d2c28b8
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewConfiguration.java
@@ -0,0 +1,249 @@
+/*
+ * Portions of this code came from frameworks/base/core/java/android/view/ViewConfiguration.java,
+ * which contains the following license text:
+ *
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.util.DisplayMetrics;
+import android.view.ViewConfiguration;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ViewConfiguration.class)
+public class ShadowViewConfiguration {
+
+  private static final int SCROLL_BAR_SIZE = 10;
+  private static final int SCROLL_BAR_FADE_DURATION = 250;
+  private static final int SCROLL_BAR_DEFAULT_DELAY = 300;
+  private static final int FADING_EDGE_LENGTH = 12;
+  private static final int PRESSED_STATE_DURATION = 125;
+  private static final int LONG_PRESS_TIMEOUT = 500;
+  private static final int GLOBAL_ACTIONS_KEY_TIMEOUT = 500;
+  private static final int TAP_TIMEOUT = 115;
+  private static final int JUMP_TAP_TIMEOUT = 500;
+  private static final int DOUBLE_TAP_TIMEOUT = 300;
+  private static final int ZOOM_CONTROLS_TIMEOUT = 3000;
+  private static final int EDGE_SLOP = 12;
+  private static final int TOUCH_SLOP = 16;
+  private static final int PAGING_TOUCH_SLOP = TOUCH_SLOP * 2;
+  private static final int DOUBLE_TAP_SLOP = 100;
+  private static final int WINDOW_TOUCH_SLOP = 16;
+  private static final int MINIMUM_FLING_VELOCITY = 50;
+  private static final int MAXIMUM_FLING_VELOCITY = 4000;
+  private static final int MAXIMUM_DRAWING_CACHE_SIZE = 320 * 480 * 4;
+  private static final float SCROLL_FRICTION = 0.015f;
+
+  private int edgeSlop;
+  private int fadingEdgeLength;
+  private int minimumFlingVelocity;
+  private int maximumFlingVelocity;
+  private int scrollbarSize;
+  private int touchSlop;
+  private int pagingTouchSlop;
+  private int doubleTapSlop;
+  private int windowTouchSlop;
+  private static boolean hasPermanentMenuKey = true;
+
+  private void setup(Context context) {
+    DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+    float density = metrics.density;
+
+    edgeSlop = (int) (density * EDGE_SLOP + 0.5f);
+    fadingEdgeLength = (int) (density * FADING_EDGE_LENGTH + 0.5f);
+    minimumFlingVelocity = (int) (density * MINIMUM_FLING_VELOCITY + 0.5f);
+    maximumFlingVelocity = (int) (density * MAXIMUM_FLING_VELOCITY + 0.5f);
+    scrollbarSize = (int) (density * SCROLL_BAR_SIZE + 0.5f);
+    touchSlop = (int) (density * TOUCH_SLOP + 0.5f);
+    pagingTouchSlop = (int) (density * PAGING_TOUCH_SLOP + 0.5f);
+    doubleTapSlop = (int) (density * DOUBLE_TAP_SLOP + 0.5f);
+    windowTouchSlop = (int) (density * WINDOW_TOUCH_SLOP + 0.5f);
+  }
+
+  @Implementation
+  protected static ViewConfiguration get(Context context) {
+    ViewConfiguration viewConfiguration = new ViewConfiguration();
+    ShadowViewConfiguration shadowViewConfiguration = Shadow.extract(viewConfiguration);
+    shadowViewConfiguration.setup(context);
+
+    if (RuntimeEnvironment.getApiLevel() >= android.os.Build.VERSION_CODES.Q) {
+      reflector(ViewConfigurationReflector.class, viewConfiguration)
+          .setConstructedWithContext(true);
+    }
+
+    return viewConfiguration;
+  }
+
+  @Implementation
+  protected static int getScrollBarSize() {
+    return SCROLL_BAR_SIZE;
+  }
+
+  @Implementation
+  protected int getScaledScrollBarSize() {
+    return scrollbarSize;
+  }
+
+  @Implementation
+  protected static int getScrollBarFadeDuration() {
+    return SCROLL_BAR_FADE_DURATION;
+  }
+
+  @Implementation
+  protected static int getScrollDefaultDelay() {
+    return SCROLL_BAR_DEFAULT_DELAY;
+  }
+
+  @Implementation
+  protected static int getFadingEdgeLength() {
+    return FADING_EDGE_LENGTH;
+  }
+
+  @Implementation
+  protected int getScaledFadingEdgeLength() {
+    return fadingEdgeLength;
+  }
+
+  @Implementation
+  protected static int getPressedStateDuration() {
+    return PRESSED_STATE_DURATION;
+  }
+
+  @Implementation
+  protected static int getLongPressTimeout() {
+    return LONG_PRESS_TIMEOUT;
+  }
+
+  @Implementation
+  protected static int getTapTimeout() {
+    return TAP_TIMEOUT;
+  }
+
+  @Implementation
+  protected static int getJumpTapTimeout() {
+    return JUMP_TAP_TIMEOUT;
+  }
+
+  @Implementation
+  protected static int getDoubleTapTimeout() {
+    return DOUBLE_TAP_TIMEOUT;
+  }
+
+  @Implementation
+  protected static int getEdgeSlop() {
+    return EDGE_SLOP;
+  }
+
+  @Implementation
+  protected int getScaledEdgeSlop() {
+    return edgeSlop;
+  }
+
+  @Implementation
+  protected static int getTouchSlop() {
+    return TOUCH_SLOP;
+  }
+
+  @Implementation
+  protected int getScaledTouchSlop() {
+    return touchSlop;
+  }
+
+  @Implementation
+  protected int getScaledPagingTouchSlop() {
+    return pagingTouchSlop;
+  }
+
+  @Implementation
+  protected int getScaledDoubleTapSlop() {
+    return doubleTapSlop;
+  }
+
+  @Implementation
+  protected static int getWindowTouchSlop() {
+    return WINDOW_TOUCH_SLOP;
+  }
+
+  @Implementation
+  protected int getScaledWindowTouchSlop() {
+    return windowTouchSlop;
+  }
+
+  @Implementation
+  protected static int getMinimumFlingVelocity() {
+    return MINIMUM_FLING_VELOCITY;
+  }
+
+  @Implementation
+  protected int getScaledMinimumFlingVelocity() {
+    return minimumFlingVelocity;
+  }
+
+  @Implementation
+  protected static int getMaximumFlingVelocity() {
+    return MAXIMUM_FLING_VELOCITY;
+  }
+
+  @Implementation
+  protected int getScaledMaximumFlingVelocity() {
+    return maximumFlingVelocity;
+  }
+
+  @Implementation
+  protected static int getMaximumDrawingCacheSize() {
+    return MAXIMUM_DRAWING_CACHE_SIZE;
+  }
+
+  @Implementation
+  protected static long getZoomControlsTimeout() {
+    return ZOOM_CONTROLS_TIMEOUT;
+  }
+
+  @Implementation
+  protected static long getGlobalActionKeyTimeout() {
+    return GLOBAL_ACTIONS_KEY_TIMEOUT;
+  }
+
+  @Implementation
+  protected static float getScrollFriction() {
+    return SCROLL_FRICTION;
+  }
+
+  @Implementation
+  protected boolean hasPermanentMenuKey() {
+    return hasPermanentMenuKey;
+  }
+
+  public static void setHasPermanentMenuKey(boolean value) {
+    hasPermanentMenuKey = value;
+  }
+
+  @ForType(ViewConfiguration.class)
+  interface ViewConfigurationReflector {
+    @Accessor("mConstructedWithContext")
+    void setConstructedWithContext(boolean value);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java
new file mode 100644
index 0000000..28e6680
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java
@@ -0,0 +1,122 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import java.io.PrintStream;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.LooperMode;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ViewGroup.class)
+public class ShadowViewGroup extends ShadowView {
+  @RealObject protected ViewGroup realViewGroup;
+
+  private boolean disallowInterceptTouchEvent = false;
+  private MotionEvent interceptedTouchEvent;
+
+  @Implementation
+  protected void addView(final View child, final int index, final ViewGroup.LayoutParams params) {
+    Runnable addViewRunnable =
+        () -> {
+          reflector(ViewGroupReflector.class, realViewGroup).addView(child, index, params);
+        };
+    if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) {
+      addViewRunnable.run();
+    } else {
+      shadowMainLooper().runPaused(addViewRunnable);
+    }
+  }
+
+  /**
+   * Returns a string representation of this {@code ViewGroup} by concatenating all of the
+   * strings contained in all of the descendants of this {@code ViewGroup}.
+   */
+  @Override
+  public String innerText() {
+    StringBuilder innerText = new StringBuilder();
+    String delimiter = "";
+
+    for (int i = 0; i < realViewGroup.getChildCount(); i++) {
+      View child = realViewGroup.getChildAt(i);
+      ShadowView shadowView = Shadow.extract(child);
+      String childText = shadowView.innerText();
+      if (childText.length() > 0) {
+        innerText.append(delimiter);
+        delimiter = " ";
+      }
+      innerText.append(childText);
+    }
+    return innerText.toString();
+  }
+
+  /**
+   * Dumps the state of this {@code ViewGroup} to {@code System.out}.
+   * @deprecated - Please use {@link androidx.test.espresso.util.HumanReadables#describe(View)}
+   */
+  @Override
+  @Deprecated
+  public void dump(PrintStream out, int indent) {
+    dumpFirstPart(out, indent);
+    if (realViewGroup.getChildCount() > 0) {
+      out.println(">");
+
+      for (int i = 0; i < realViewGroup.getChildCount(); i++) {
+        View child = realViewGroup.getChildAt(i);
+        ShadowView shadowChild = Shadow.extract(child);
+        shadowChild.dump(out, indent + 2);
+      }
+
+      dumpIndent(out, indent);
+      out.println("</" + realView.getClass().getSimpleName() + ">");
+    } else {
+      out.println("/>");
+    }
+  }
+
+  @Implementation
+  protected void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+    reflector(ViewGroupReflector.class, realViewGroup)
+        .requestDisallowInterceptTouchEvent(disallowIntercept);
+    disallowInterceptTouchEvent = disallowIntercept;
+  }
+
+  public boolean getDisallowInterceptTouchEvent() {
+    return disallowInterceptTouchEvent;
+  }
+
+  protected void removedChild(View child) {
+    if (isAttachedToWindow()) {
+      ShadowView shadowView = Shadow.extract(child);
+      shadowView.callOnDetachedFromWindow();
+    }
+  }
+
+  public MotionEvent getInterceptedTouchEvent() {
+    return interceptedTouchEvent;
+  }
+
+  @Implementation
+  protected boolean onInterceptTouchEvent(MotionEvent ev) {
+    interceptedTouchEvent = ev;
+    return false;
+  }
+
+  @ForType(ViewGroup.class)
+  interface ViewGroupReflector {
+
+    @Direct
+    void addView(View child, int index, ViewGroup.LayoutParams params);
+
+    @Direct
+    void requestDisallowInterceptTouchEvent(boolean disallowIntercept);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewRootImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewRootImpl.java
new file mode 100644
index 0000000..921d278
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewRootImpl.java
@@ -0,0 +1,393 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static org.robolectric.annotation.TextLayoutMode.Mode.REALISTIC;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.MergedConfiguration;
+import android.view.Display;
+import android.view.HandlerActionQueue;
+import android.view.IWindowSession;
+import android.view.InsetsState;
+import android.view.SurfaceControl;
+import android.view.View;
+import android.view.ViewRootImpl;
+import android.view.WindowManager;
+import android.window.ClientWindowFrames;
+import java.util.ArrayList;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.annotation.TextLayoutMode;
+import org.robolectric.config.ConfigurationRegistry;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+import org.robolectric.util.reflector.WithType;
+
+@Implements(value = ViewRootImpl.class, isInAndroidSdk = false)
+public class ShadowViewRootImpl {
+
+  private static final int RELAYOUT_RES_IN_TOUCH_MODE = 0x1;
+
+  @RealObject protected ViewRootImpl realObject;
+
+
+  @Implementation(maxSdk = VERSION_CODES.JELLY_BEAN)
+  protected static IWindowSession getWindowSession(Looper mainLooper) {
+    IWindowSession windowSession = ShadowWindowManagerGlobal.getWindowSession();
+    ReflectionHelpers.setStaticField(ViewRootImpl.class, "sWindowSession", windowSession);
+    return windowSession;
+  }
+
+  @Implementation
+  public void playSoundEffect(int effectId) {}
+
+  @Implementation
+  protected int relayoutWindow(
+      WindowManager.LayoutParams params, int viewVisibility, boolean insetsPending)
+      throws RemoteException {
+    // TODO(christianw): probably should return WindowManagerGlobal.RELAYOUT_RES_SURFACE_RESIZED?
+    int result = 0;
+    if (ShadowWindowManagerGlobal.getInTouchMode() && RuntimeEnvironment.getApiLevel() <= S_V2) {
+      result |= RELAYOUT_RES_IN_TOUCH_MODE;
+    }
+    if (RuntimeEnvironment.getApiLevel() >= Q) {
+      // Simulate initializing the SurfaceControl member object, which happens during this method.
+      SurfaceControl surfaceControl =
+          reflector(ViewRootImplReflector.class, realObject).getSurfaceControl();
+      ShadowSurfaceControl shadowSurfaceControl = Shadow.extract(surfaceControl);
+      shadowSurfaceControl.initializeNativeObject();
+    }
+    return result;
+  }
+
+  public void callDispatchResized() {
+    if (RuntimeEnvironment.getApiLevel() > Build.VERSION_CODES.S_V2) {
+      Display display = getDisplay();
+      Rect frame = new Rect();
+      display.getRectSize(frame);
+
+      ClientWindowFrames frames = new ClientWindowFrames();
+      // set the final field
+      ReflectionHelpers.setField(frames, "frame", frame);
+
+      ReflectionHelpers.callInstanceMethod(
+          ViewRootImpl.class,
+          realObject,
+          "dispatchResized",
+          ClassParameter.from(ClientWindowFrames.class, frames),
+          ClassParameter.from(boolean.class, true), /* reportDraw */
+          ClassParameter.from(
+              MergedConfiguration.class, new MergedConfiguration()), /* mergedConfiguration */
+          ClassParameter.from(InsetsState.class, new InsetsState()), /* insetsState */
+          ClassParameter.from(boolean.class, false), /* forceLayout */
+          ClassParameter.from(boolean.class, false), /* alwaysConsumeSystemBars */
+          ClassParameter.from(int.class, 0), /* displayId */
+          ClassParameter.from(int.class, 0), /* syncSeqId */
+          ClassParameter.from(int.class, 0) /* resizeMode */);
+    } else if (RuntimeEnvironment.getApiLevel() > Build.VERSION_CODES.R) {
+      Display display = getDisplay();
+      Rect frame = new Rect();
+      display.getRectSize(frame);
+
+      ClientWindowFrames frames = new ClientWindowFrames();
+      // set the final field
+      ReflectionHelpers.setField(frames, "frame", frame);
+
+      ReflectionHelpers.callInstanceMethod(
+          ViewRootImpl.class,
+          realObject,
+          "dispatchResized",
+          ClassParameter.from(ClientWindowFrames.class, frames),
+          ClassParameter.from(boolean.class, true), /* reportDraw */
+          ClassParameter.from(
+              MergedConfiguration.class, new MergedConfiguration()), /* mergedConfiguration */
+          ClassParameter.from(boolean.class, false), /* forceLayout */
+          ClassParameter.from(boolean.class, false), /* alwaysConsumeSystemBars */
+          ClassParameter.from(int.class, 0) /* displayId */);
+    } else if (RuntimeEnvironment.getApiLevel() > Build.VERSION_CODES.Q) {
+      Display display = getDisplay();
+      Rect frame = new Rect();
+      display.getRectSize(frame);
+
+      Rect emptyRect = new Rect(0, 0, 0, 0);
+      ReflectionHelpers.callInstanceMethod(
+          ViewRootImpl.class,
+          realObject,
+          "dispatchResized",
+          ClassParameter.from(Rect.class, frame),
+          ClassParameter.from(Rect.class, emptyRect),
+          ClassParameter.from(Rect.class, emptyRect),
+          ClassParameter.from(Rect.class, emptyRect),
+          ClassParameter.from(boolean.class, true),
+          ClassParameter.from(MergedConfiguration.class, new MergedConfiguration()),
+          ClassParameter.from(Rect.class, frame),
+          ClassParameter.from(boolean.class, false),
+          ClassParameter.from(boolean.class, false),
+          ClassParameter.from(int.class, 0),
+          ClassParameter.from(
+              android.view.DisplayCutout.ParcelableWrapper.class,
+              new android.view.DisplayCutout.ParcelableWrapper()));
+    } else {
+      Display display = getDisplay();
+      Rect frame = new Rect();
+      display.getRectSize(frame);
+      reflector(ViewRootImplReflector.class, realObject).dispatchResized(frame);
+    }
+  }
+
+  protected Display getDisplay() {
+    if (RuntimeEnvironment.getApiLevel() > VERSION_CODES.JELLY_BEAN_MR1) {
+      return reflector(ViewRootImplReflector.class, realObject).getDisplay();
+    } else {
+      WindowManager windowManager =
+          (WindowManager)
+              realObject.getView().getContext().getSystemService(Context.WINDOW_SERVICE);
+      return windowManager.getDefaultDisplay();
+    }
+  }
+
+  @Implementation
+  protected void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
+    reflector(ViewRootImplReflector.class, realObject).setView(view, attrs, panelParentView);
+    if (ConfigurationRegistry.get(TextLayoutMode.Mode.class) == REALISTIC) {
+      Rect winFrame = new Rect();
+      getDisplay().getRectSize(winFrame);
+      reflector(ViewRootImplReflector.class, realObject).setWinFrame(winFrame);
+    }
+  }
+
+  @Implementation(minSdk = R)
+  protected void setView(
+      View view, WindowManager.LayoutParams attrs, View panelParentView, int userId) {
+    reflector(ViewRootImplReflector.class, realObject)
+        .setView(view, attrs, panelParentView, userId);
+    if (ConfigurationRegistry.get(TextLayoutMode.Mode.class) == REALISTIC) {
+      Rect winFrame = new Rect();
+      getDisplay().getRectSize(winFrame);
+      reflector(ViewRootImplReflector.class, realObject).setWinFrame(winFrame);
+    }
+  }
+
+  @Resetter
+  public static void reset() {
+    ViewRootImplReflector viewRootImplStatic = reflector(ViewRootImplReflector.class);
+    viewRootImplStatic.setRunQueues(new ThreadLocal<>());
+    viewRootImplStatic.setFirstDrawHandlers(new ArrayList<>());
+    viewRootImplStatic.setFirstDrawComplete(false);
+    viewRootImplStatic.setConfigCallbacks(new ArrayList<>());
+  }
+
+  public void callWindowFocusChanged(boolean hasFocus) {
+    if (RuntimeEnvironment.getApiLevel() <= S_V2) {
+      reflector(ViewRootImplReflector.class, realObject)
+          .windowFocusChanged(hasFocus, ShadowWindowManagerGlobal.getInTouchMode());
+    } else {
+      reflector(ViewRootImplReflector.class, realObject).windowFocusChanged(hasFocus);
+    }
+  }
+
+  /** Reflector interface for {@link ViewRootImpl}'s internals. */
+  @ForType(ViewRootImpl.class)
+  protected interface ViewRootImplReflector {
+
+    @Direct
+    void setView(View view, WindowManager.LayoutParams attrs, View panelParentView);
+
+    @Direct
+    void setView(View view, WindowManager.LayoutParams attrs, View panelParentView, int userId);
+
+    @Static
+    @Accessor("sRunQueues")
+    void setRunQueues(ThreadLocal<HandlerActionQueue> threadLocal);
+
+    @Static
+    @Accessor("sFirstDrawHandlers")
+    void setFirstDrawHandlers(ArrayList<Runnable> handlers);
+
+    @Static
+    @Accessor("sFirstDrawComplete")
+    void setFirstDrawComplete(boolean isComplete);
+
+    @Static
+    @Accessor("sConfigCallbacks")
+    void setConfigCallbacks(ArrayList<ViewRootImpl.ConfigChangedCallback> callbacks);
+
+    @Accessor("sNewInsetsMode")
+    @Static
+    int getNewInsetsMode();
+
+    @Accessor("mWinFrame")
+    void setWinFrame(Rect winFrame);
+
+    @Accessor("mDisplay")
+    Display getDisplay();
+
+    @Accessor("mSurfaceControl")
+    SurfaceControl getSurfaceControl();
+
+    // <= JELLY_BEAN
+    void dispatchResized(
+        int w,
+        int h,
+        Rect contentInsets,
+        Rect visibleInsets,
+        boolean reportDraw,
+        Configuration newConfig);
+
+    // <= JELLY_BEAN_MR1
+    void dispatchResized(
+        Rect frame,
+        Rect contentInsets,
+        Rect visibleInsets,
+        boolean reportDraw,
+        Configuration newConfig);
+
+    // <= KITKAT
+    void dispatchResized(
+        Rect frame,
+        Rect overscanInsets,
+        Rect contentInsets,
+        Rect visibleInsets,
+        boolean reportDraw,
+        Configuration newConfig);
+
+    // <= LOLLIPOP_MR1
+    void dispatchResized(
+        Rect frame,
+        Rect overscanInsets,
+        Rect contentInsets,
+        Rect visibleInsets,
+        Rect stableInsets,
+        boolean reportDraw,
+        Configuration newConfig);
+
+    // <= M
+    void dispatchResized(
+        Rect frame,
+        Rect overscanInsets,
+        Rect contentInsets,
+        Rect visibleInsets,
+        Rect stableInsets,
+        Rect outsets,
+        boolean reportDraw,
+        Configuration newConfig);
+
+    // <= N_MR1
+    void dispatchResized(
+        Rect frame,
+        Rect overscanInsets,
+        Rect contentInsets,
+        Rect visibleInsets,
+        Rect stableInsets,
+        Rect outsets,
+        boolean reportDraw,
+        Configuration newConfig,
+        Rect backDropFrame,
+        boolean forceLayout,
+        boolean alwaysConsumeNavBar);
+
+    // <= O_MR1
+    void dispatchResized(
+        Rect frame,
+        Rect overscanInsets,
+        Rect contentInsets,
+        Rect visibleInsets,
+        Rect stableInsets,
+        Rect outsets,
+        boolean reportDraw,
+        @WithType("android.util.MergedConfiguration") Object mergedConfiguration,
+        Rect backDropFrame,
+        boolean forceLayout,
+        boolean alwaysConsumeNavBar,
+        int displayId);
+
+    // >= P
+    void dispatchResized(
+        Rect frame,
+        Rect overscanInsets,
+        Rect contentInsets,
+        Rect visibleInsets,
+        Rect stableInsets,
+        Rect outsets,
+        boolean reportDraw,
+        @WithType("android.util.MergedConfiguration") Object mergedConfiguration,
+        Rect backDropFrame,
+        boolean forceLayout,
+        boolean alwaysConsumeNavBar,
+        int displayId,
+        @WithType("android.view.DisplayCutout$ParcelableWrapper") Object displayCutout);
+
+    default void dispatchResized(Rect frame) {
+      Rect emptyRect = new Rect(0, 0, 0, 0);
+
+      int apiLevel = RuntimeEnvironment.getApiLevel();
+      if (apiLevel <= Build.VERSION_CODES.JELLY_BEAN) {
+        dispatchResized(frame.width(), frame.height(), emptyRect, emptyRect, true, null);
+      } else if (apiLevel <= VERSION_CODES.JELLY_BEAN_MR1) {
+        dispatchResized(frame, emptyRect, emptyRect, true, null);
+      } else if (apiLevel <= Build.VERSION_CODES.KITKAT) {
+        dispatchResized(frame, emptyRect, emptyRect, emptyRect, true, null);
+      } else if (apiLevel <= Build.VERSION_CODES.LOLLIPOP_MR1) {
+        dispatchResized(frame, emptyRect, emptyRect, emptyRect, emptyRect, true, null);
+      } else if (apiLevel <= Build.VERSION_CODES.M) {
+        dispatchResized(frame, emptyRect, emptyRect, emptyRect, emptyRect, emptyRect, true, null);
+      } else if (apiLevel <= Build.VERSION_CODES.N_MR1) {
+        dispatchResized(
+            frame, emptyRect, emptyRect, emptyRect, emptyRect, emptyRect, true, null, frame, false,
+            false);
+      } else if (apiLevel <= Build.VERSION_CODES.O_MR1) {
+        dispatchResized(
+            frame,
+            emptyRect,
+            emptyRect,
+            emptyRect,
+            emptyRect,
+            emptyRect,
+            true,
+            new MergedConfiguration(),
+            frame,
+            false,
+            false,
+            0);
+      } else { // apiLevel >= Build.VERSION_CODES.P
+        dispatchResized(
+            frame,
+            emptyRect,
+            emptyRect,
+            emptyRect,
+            emptyRect,
+            emptyRect,
+            true,
+            new MergedConfiguration(),
+            frame,
+            false,
+            false,
+            0,
+            new android.view.DisplayCutout.ParcelableWrapper());
+      }
+    }
+
+    // SDK <= S_V2
+    void windowFocusChanged(boolean hasFocus, boolean inTouchMode);
+
+    // SDK >= T
+    void windowFocusChanged(boolean hasFocus);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVirtualRefBasePtr.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVirtualRefBasePtr.java
new file mode 100644
index 0000000..df4db75
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVirtualRefBasePtr.java
@@ -0,0 +1,58 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import com.android.internal.util.VirtualRefBasePtr;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.res.android.NativeObjRegistry;
+
+@Implements(value = VirtualRefBasePtr.class, isInAndroidSdk = false)
+public class ShadowVirtualRefBasePtr {
+  private static final NativeObjRegistry<RefHolder> NATIVE_REGISTRY =
+      new NativeObjRegistry<>(RefHolder.class);
+
+  protected static synchronized <T> long put(T object) {
+    return NATIVE_REGISTRY.register(new RefHolder<T>(object));
+  }
+
+  protected static synchronized <T> T get(long nativePtr, Class<T> clazz) {
+    return clazz.cast(NATIVE_REGISTRY.getNativeObject(nativePtr).nativeThing);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static synchronized void nIncStrong(long ptr) {
+    if (ptr == 0) {
+      return;
+    }
+    NATIVE_REGISTRY.getNativeObject(ptr).incr();
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected static synchronized void nDecStrong(long ptr) {
+    if (ptr == 0) {
+      return;
+    }
+    if (NATIVE_REGISTRY.getNativeObject(ptr).decr()) {
+      NATIVE_REGISTRY.unregister(ptr);
+    }
+  }
+
+  private static final class RefHolder<T> {
+    private T nativeThing;
+    private int refCount;
+
+    private RefHolder(T object) {
+      this.nativeThing = object;
+    }
+
+    private synchronized void incr() {
+      refCount++;
+    }
+
+    private synchronized boolean decr() {
+      refCount--;
+      return refCount == 0;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualVoicemailSms.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualVoicemailSms.java
new file mode 100644
index 0000000..17478d6
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualVoicemailSms.java
@@ -0,0 +1,103 @@
+package org.robolectric.shadows;
+
+import android.annotation.Nullable;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable.Creator;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.VisualVoicemailSms;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(value = VisualVoicemailSms.class, minSdk = VERSION_CODES.O)
+public class ShadowVisualVoicemailSms {
+  private PhoneAccountHandle phoneAccountHandle;
+
+  @Nullable private String prefix;
+
+  @Nullable private Bundle fields;
+
+  private String messageBody;
+
+  @Implementation
+  protected static void __staticInitializer__() {
+    ReflectionHelpers.setStaticField(
+        VisualVoicemailSms.class, "CREATOR", ShadowVisualVoicemailSms.CREATOR);
+  }
+
+  @Implementation
+  protected PhoneAccountHandle getPhoneAccountHandle() {
+    return phoneAccountHandle;
+  }
+
+  public ShadowVisualVoicemailSms setPhoneAccountHandle(PhoneAccountHandle phoneAccountHandle) {
+    this.phoneAccountHandle = phoneAccountHandle;
+    return this;
+  }
+
+  @Implementation
+  protected String getPrefix() {
+    return prefix;
+  }
+
+  public ShadowVisualVoicemailSms setPrefix(String prefix) {
+    this.prefix = prefix;
+    return this;
+  }
+
+  @Implementation
+  protected Bundle getFields() {
+    return fields;
+  }
+
+  public ShadowVisualVoicemailSms setFields(Bundle fields) {
+    this.fields = fields;
+    return this;
+  }
+
+  @Implementation
+  protected String getMessageBody() {
+    return messageBody;
+  }
+
+  public ShadowVisualVoicemailSms setMessageBody(String messageBody) {
+    this.messageBody = messageBody;
+    return this;
+  }
+
+  public static final Creator<VisualVoicemailSms> CREATOR =
+      new Creator<VisualVoicemailSms>() {
+        @Override
+        public VisualVoicemailSms createFromParcel(Parcel in) {
+          VisualVoicemailSms sms = Shadow.newInstanceOf(VisualVoicemailSms.class);
+          ShadowVisualVoicemailSms shadowSms = Shadow.extract(sms);
+          shadowSms
+              .setPhoneAccountHandle(in.readParcelable(PhoneAccountHandle.class.getClassLoader()))
+              .setPrefix(in.readString())
+              .setFields(in.readBundle())
+              .setMessageBody(in.readString());
+          return sms;
+        }
+
+        @Override
+        public VisualVoicemailSms[] newArray(int size) {
+          return new VisualVoicemailSms[size];
+        }
+      };
+
+  @Implementation
+  protected int describeContents() {
+    return 0;
+  }
+
+  @Implementation
+  protected void writeToParcel(Parcel dest, int flags) {
+    dest.writeParcelable(getPhoneAccountHandle(), flags);
+    dest.writeString(getPrefix());
+    dest.writeBundle(getFields());
+    dest.writeString(getMessageBody());
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualVoicemailTask.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualVoicemailTask.java
new file mode 100644
index 0000000..c598956
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualVoicemailTask.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.VisualVoicemailService.VisualVoicemailTask;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow of {@link VisualVoicemailTask}. */
+@Implements(value = VisualVoicemailTask.class, minSdk = VERSION_CODES.O)
+public class ShadowVisualVoicemailTask {
+
+  private boolean isFinished;
+
+  @Implementation
+  public void finish() {
+    isFinished = true;
+  }
+
+  public boolean isFinished() {
+    return isFinished;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualizer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualizer.java
new file mode 100644
index 0000000..9451cb0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVisualizer.java
@@ -0,0 +1,176 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.GINGERBREAD;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.media.audiofx.Visualizer;
+import android.media.audiofx.Visualizer.MeasurementPeakRms;
+import android.media.audiofx.Visualizer.OnDataCaptureListener;
+import java.util.concurrent.atomic.AtomicReference;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow for the {@link Visualizer} class. */
+@Implements(value = Visualizer.class, minSdk = GINGERBREAD)
+public class ShadowVisualizer {
+
+  @RealObject private Visualizer realObject;
+
+  private final AtomicReference<VisualizerSource> source =
+      new AtomicReference<>(new VisualizerSource() {});
+
+  private boolean enabled = false;
+  private OnDataCaptureListener captureListener = null;
+  private boolean captureWaveform;
+  private boolean captureFft;
+  private int captureSize;
+  private int errorCode;
+
+  public void setSource(VisualizerSource source) {
+    this.source.set(source);
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int setDataCaptureListener(
+      OnDataCaptureListener listener, int rate, boolean waveform, boolean fft) {
+    if (errorCode != Visualizer.SUCCESS) {
+      return errorCode;
+    }
+    captureListener = listener;
+    captureWaveform = waveform;
+    captureFft = fft;
+    return Visualizer.SUCCESS;
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int native_getSamplingRate() {
+    return source.get().getSamplingRate();
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int native_getWaveForm(byte[] waveform) {
+    return source.get().getWaveForm(waveform);
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int native_getFft(byte[] fft) {
+    return source.get().getFft(fft);
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected boolean native_getEnabled() {
+    return enabled;
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int native_setCaptureSize(int size) {
+    if (errorCode != Visualizer.SUCCESS) {
+      return errorCode;
+    }
+    captureSize = size;
+    return Visualizer.SUCCESS;
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int native_getCaptureSize() {
+    return captureSize;
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected int native_setEnabled(boolean enabled) {
+    if (errorCode != Visualizer.SUCCESS) {
+      return errorCode;
+    }
+    this.enabled = enabled;
+    return Visualizer.SUCCESS;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected int native_getPeakRms(MeasurementPeakRms measurement) {
+    return source.get().getPeakRms(measurement);
+  }
+
+  @Implementation(minSdk = GINGERBREAD)
+  protected void native_release() {
+    source.get().release();
+  }
+
+  /**
+   * Trigger calls to the existing {@link OnDataCaptureListener}.
+   *
+   * <p>This is a no-op if the listener has not been set.
+   */
+  public void triggerDataCapture() {
+    if (captureListener == null) {
+      return;
+    }
+    if (captureWaveform) {
+      byte[] waveform = new byte[captureSize];
+      realObject.getWaveForm(waveform);
+      captureListener.onWaveFormDataCapture(realObject, waveform, realObject.getSamplingRate());
+    }
+    if (captureFft) {
+      byte[] fft = new byte[captureSize];
+      realObject.getFft(fft);
+      captureListener.onFftDataCapture(realObject, fft, realObject.getSamplingRate());
+    }
+  }
+
+  /**
+   * Updates the state of the {@link Visualizer} itself.
+   *
+   * <p>This can be used e.g. to put the Visualizer in an unexpected state and cause an exception
+   * the next time the Visualizer is used.
+   */
+  public void setState(int newState) {
+    reflector(ReflectorVisualizer.class, realObject).setState(newState);
+  }
+
+  /**
+   * Sets the error code to override setter methods in this class.
+   *
+   * <p>When the error code is set to anything other than {@link Visualizer.SUCCESS} setters in the
+   * Visualizer will early-out and return that error code.
+   */
+  public void setErrorCode(int errorCode) {
+    this.errorCode = errorCode;
+  }
+
+  /**
+   * Provides underlying data for the {@link ShadowVisualizer}. The default implementations are
+   * there only to help tests to run when they don't need to verify specific behaviour, otherwise
+   * tests should probably override these and provide some specific implementation that allows them
+   * to verify the functionality needed.
+   */
+  public interface VisualizerSource {
+
+    default int getSamplingRate() {
+      return 0;
+    }
+
+    default int getWaveForm(byte[] waveform) {
+      return Visualizer.SUCCESS;
+    }
+
+    default int getFft(byte[] fft) {
+      return Visualizer.SUCCESS;
+    }
+
+    default int getPeakRms(MeasurementPeakRms measurement) {
+      return Visualizer.SUCCESS;
+    }
+
+    default void release() {}
+  }
+
+  /** Accessor interface for {@link Visualizer}'s internals. */
+  @ForType(Visualizer.class)
+  private interface ReflectorVisualizer {
+    @Accessor("mState")
+    void setState(int state);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractionService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractionService.java
new file mode 100644
index 0000000..b493c05
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractionService.java
@@ -0,0 +1,110 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.Bundle;
+import android.service.voice.VoiceInteractionService;
+import com.google.common.collect.Iterables;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/** Shadow implementation of {@link android.service.voice.VoiceInteractionService}. */
+@Implements(value = VoiceInteractionService.class, minSdk = LOLLIPOP)
+public class ShadowVoiceInteractionService extends ShadowService {
+
+  @Nullable private static ComponentName activeService = null;
+
+  private final List<Bundle> hintBundles = Collections.synchronizedList(new ArrayList<>());
+  private final List<Bundle> sessionBundles = Collections.synchronizedList(new ArrayList<>());
+  private boolean isReady = false;
+
+  /**
+   * Sets return value for {@link #isActiveService(Context context, ComponentName componentName)}
+   * method.
+   */
+  public static void setActiveService(@Nullable ComponentName activeService) {
+    ShadowVoiceInteractionService.activeService = activeService;
+  }
+
+  @Implementation
+  protected void onReady() {
+    isReady = true;
+  }
+
+  @Implementation(minSdk = Q)
+  protected void setUiHints(Bundle hints) {
+    // The actual implementation of this code on Android will also throw the exception if the
+    // service isn't ready.
+    // Throwing here will hopefully make sure these issues are caught before production.
+    if (!isReady) {
+      throw new NullPointerException(
+          "setUiHints() called before onReady() callback for VoiceInteractionService!");
+    }
+
+    if (hints != null) {
+      hintBundles.add(hints);
+    }
+  }
+
+  @Implementation(minSdk = M)
+  protected void showSession(Bundle args, int flags) {
+    if (!isReady) {
+      throw new NullPointerException(
+          "showSession() called before onReady() callback for VoiceInteractionService!");
+    }
+
+    if (args != null) {
+      sessionBundles.add(args);
+    }
+  }
+
+  @Implementation
+  protected static boolean isActiveService(Context context, ComponentName componentName) {
+    return componentName.equals(activeService);
+  }
+
+  /**
+   * Returns list of bundles provided with calls to {@link #setUiHints(Bundle bundle)} in invocation
+   * order.
+   */
+  public List<Bundle> getPreviousUiHintBundles() {
+    return Collections.unmodifiableList(hintBundles);
+  }
+
+  /**
+   * Returns the last Bundle object set via {@link #setUiHints(Bundle bundle)} or null if there
+   * wasn't any.
+   */
+  @Nullable
+  public Bundle getLastUiHintBundle() {
+    if (hintBundles.isEmpty()) {
+      return null;
+    }
+
+    return hintBundles.get(hintBundles.size() - 1);
+  }
+
+  /**
+   * Returns the last Bundle object set via {@link #setUiHints(Bundle bundle)} or null if there
+   * wasn't any.
+   */
+  @Nullable
+  public Bundle getLastSessionBundle() {
+    return Iterables.getLast(sessionBundles, null);
+  }
+
+  /** Resets this shadow instance. */
+  @Resetter
+  public static void reset() {
+    activeService = null;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractionSession.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractionSession.java
new file mode 100644
index 0000000..2d4ac3b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractionSession.java
@@ -0,0 +1,180 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+
+import android.app.Dialog;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.service.voice.VoiceInteractionSession;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Shadow implementation of {@link android.service.voice.VoiceInteractionSession}. */
+@Implements(value = VoiceInteractionSession.class, minSdk = LOLLIPOP)
+public class ShadowVoiceInteractionSession {
+
+  private final List<Intent> assistantActivityIntents = new ArrayList<>();
+  private final List<Intent> voiceActivityIntents = new ArrayList<>();
+
+  private boolean isFinishing;
+  @Nullable private RuntimeException startVoiceActivityException;
+  @RealObject private VoiceInteractionSession realSession;
+
+  /**
+   * Simulates the creation of the {@link VoiceInteractionSession}, as if it was being created by
+   * the framework.
+   *
+   * <p>This method must be called before state changing methods of {@link VoiceInteractionSession}.
+   */
+  public void create() {
+    try {
+      Class<?> serviceClass =
+          Class.forName("com.android.internal.app.IVoiceInteractionManagerService");
+      Object service =
+          ReflectionHelpers.createDelegatingProxy(
+              serviceClass, new FakeVoiceInteractionManagerService());
+
+      Binder token = new Binder();
+
+      ReflectionHelpers.callInstanceMethod(
+          realSession,
+          "doCreate",
+          ClassParameter.from(serviceClass, service),
+          ClassParameter.from(IBinder.class, token));
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Returns the last {@link Intent} passed into {@link
+   * VoiceInteractionSession#startAssistantActivity(Intent)} or {@code null} if there wasn't any.
+   */
+  @Nullable
+  public Intent getLastAssistantActivityIntent() {
+    return Iterables.getLast(assistantActivityIntents, /* defaultValue= */ null);
+  }
+
+  /**
+   * Returns the list of {@link Intent} instances passed into {@link
+   * VoiceInteractionSession#startAssistantActivity(Intent)} in invocation order.
+   */
+  public ImmutableList<Intent> getAssistantActivityIntents() {
+    return ImmutableList.copyOf(assistantActivityIntents);
+  }
+
+  /**
+   * Returns the last {@link Intent} passed into {@link
+   * VoiceInteractionSession#startVoiceActivity(Intent)} or {@code null} if there wasn't any.
+   */
+  @Nullable
+  public Intent getLastVoiceActivityIntent() {
+    return Iterables.getLast(voiceActivityIntents, /* defaultValue= */ null);
+  }
+
+  /**
+   * Returns the list of {@link Intent} instances passed into {@link
+   * VoiceInteractionSession#startVoiceActivity(Intent)} in invocation order.
+   */
+  public ImmutableList<Intent> getVoiceActivityIntents() {
+    return ImmutableList.copyOf(voiceActivityIntents);
+  }
+
+  /**
+   * Returns whether the window from {@link VoiceInteractionSession} is currently visible. Although
+   * window is visible this method does not check whether UI content of window is also showed.
+   */
+  public boolean isWindowVisible() {
+    return ReflectionHelpers.getField(realSession, "mWindowVisible");
+  }
+
+  /** Returns whether the UI window from {@link VoiceInteractionSession} is currently showing. */
+  public boolean isWindowShowing() {
+    Dialog window = ReflectionHelpers.getField(realSession, "mWindow");
+    return isWindowVisible() && window != null && window.isShowing();
+  }
+
+  /**
+   * Returns whether the UI is set to be enabled through {@link
+   * VoiceInteractionSession#setUiEnabled(boolean)}.
+   */
+  public boolean isUiEnabled() {
+    return ReflectionHelpers.getField(realSession, "mUiEnabled");
+  }
+
+  /**
+   * Returns whether the {@link VoiceInteractionSession} is in the process of being destroyed and
+   * finishing.
+   */
+  public boolean isFinishing() {
+    return isFinishing;
+  }
+
+  /**
+   * Sets a {@link RuntimeException} that should be thrown when {@link
+   * VoiceInteractionSession#startVoiceActivity(Intent)} is invoked.
+   */
+  public void setStartVoiceActivityException(RuntimeException exception) {
+    startVoiceActivityException = exception;
+  }
+
+  // Extends com.android.internal.app.IVoiceInteractionManagerService.Stub
+  private class FakeVoiceInteractionManagerService {
+
+    // @Override
+    public boolean showSessionFromSession(IBinder token, Bundle args, int flags) {
+      try {
+        Class<?> callbackClass =
+            Class.forName("com.android.internal.app.IVoiceInteractionSessionShowCallback");
+        Object callback = ReflectionHelpers.createDeepProxy(callbackClass);
+
+        ReflectionHelpers.callInstanceMethod(
+            realSession,
+            "doShow",
+            ClassParameter.from(Bundle.class, args),
+            ClassParameter.from(int.class, flags),
+            ClassParameter.from(callbackClass, callback));
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+      return true;
+    }
+
+    // @Override
+    public boolean hideSessionFromSession(IBinder token) {
+      ReflectionHelpers.callInstanceMethod(realSession, "doHide");
+      return true;
+    }
+
+    // @Override
+    public int startVoiceActivity(IBinder token, Intent intent, String resolvedType) {
+      RuntimeException exception = startVoiceActivityException;
+      if (exception != null) {
+        throw exception;
+      }
+      voiceActivityIntents.add(intent);
+      return 0;
+    }
+
+    // @Override
+    public int startAssistantActivity(IBinder token, Intent intent, String resolvedType) {
+      assistantActivityIntents.add(intent);
+      return 0;
+    }
+
+    // @Override
+    public void finish(IBinder token) {
+      ReflectionHelpers.callInstanceMethod(realSession, "doDestroy");
+      isFinishing = true;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractor.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractor.java
new file mode 100644
index 0000000..79bc05d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVoiceInteractor.java
@@ -0,0 +1,125 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.VoiceInteractor;
+import android.app.VoiceInteractor.AbortVoiceRequest;
+import android.app.VoiceInteractor.CommandRequest;
+import android.app.VoiceInteractor.CompleteVoiceRequest;
+import android.app.VoiceInteractor.ConfirmationRequest;
+import android.app.VoiceInteractor.PickOptionRequest;
+import android.app.VoiceInteractor.Prompt;
+import android.app.VoiceInteractor.Request;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow implementation of {@link android.app.VoiceInteractor}. */
+@Implements(value = VoiceInteractor.class, minSdk = M)
+public class ShadowVoiceInteractor {
+
+  private int directActionsInvalidationCount = 0;
+  private final List<String> voiceInteractions = new CopyOnWriteArrayList<>();
+  public static String assistantPackageName = "test_package";
+
+  @Implementation(minSdk = TIRAMISU)
+  protected String getPackageName() {
+    return assistantPackageName;
+  }
+
+  public void setPackageName(String packageName) {
+    assistantPackageName = packageName;
+  }
+
+  @Implementation(minSdk = Q)
+  protected void notifyDirectActionsChanged() {
+    directActionsInvalidationCount += 1;
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean submitRequest(Request request, String name) {
+    if (request instanceof ConfirmationRequest) {
+      processPrompt(reflector(ReflectorConfirmationRequest.class, request).getPrompt());
+    } else if (request instanceof CompleteVoiceRequest) {
+      processPrompt(reflector(ReflectorCompleteVoiceRequest.class, request).getPrompt());
+    } else if (request instanceof AbortVoiceRequest) {
+      processPrompt(reflector(ReflectorAbortVoiceRequest.class, request).getPrompt());
+    } else if (request instanceof CommandRequest) {
+      voiceInteractions.add(reflector(ReflectorCommandRequest.class, request).getCommand());
+    } else if (request instanceof PickOptionRequest) {
+      processPrompt(reflector(ReflectorPickOptionRequest.class, request).getPrompt());
+    }
+    return true;
+  }
+
+  @Implementation(minSdk = Q)
+  protected boolean submitRequest(Request request) {
+    return submitRequest(request, null);
+  }
+
+  /**
+   * Returns the number of times {@code notifyDirectActionsChanged} was called on the {@link
+   * android.app.VoiceInteractor} instance associated with this shadow
+   */
+  public int getDirectActionsInvalidationCount() {
+    return directActionsInvalidationCount;
+  }
+
+  /**
+   * Returns the voice interactions called on {@link VoiceInteractor} instance associated with this
+   * shadow.
+   */
+  public List<String> getVoiceInteractions() {
+    return voiceInteractions;
+  }
+
+  private void processPrompt(Prompt prompt) {
+    if (prompt.countVoicePrompts() <= 0) {
+      return;
+    }
+    for (int i = 0; i < prompt.countVoicePrompts(); i++) {
+      voiceInteractions.add(prompt.getVoicePromptAt(i).toString());
+    }
+  }
+
+  /** Accessor interface for {@link CompleteVoiceRequest}'s internal. */
+  @ForType(CompleteVoiceRequest.class)
+  interface ReflectorCompleteVoiceRequest {
+    @Accessor("mPrompt")
+    Prompt getPrompt();
+  }
+
+  /** Accessor interface for {@link ConfirmationRequest}'s internal. */
+  @ForType(ConfirmationRequest.class)
+  interface ReflectorConfirmationRequest {
+    @Accessor("mPrompt")
+    Prompt getPrompt();
+  }
+
+  /** Accessor interface for {@link AbortVoiceRequest}'s internal. */
+  @ForType(AbortVoiceRequest.class)
+  interface ReflectorAbortVoiceRequest {
+    @Accessor("mPrompt")
+    Prompt getPrompt();
+  }
+
+  /** Accessor interface for {@link CommandRequest}'s internal. */
+  @ForType(CommandRequest.class)
+  interface ReflectorCommandRequest {
+    @Accessor("mCommand")
+    String getCommand();
+  }
+
+  /** Accessor interface for {@link PickOptionRequest}'s internal. */
+  @ForType(PickOptionRequest.class)
+  interface ReflectorPickOptionRequest {
+    @Accessor("mPrompt")
+    Prompt getPrompt();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVpnService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVpnService.java
new file mode 100644
index 0000000..59fb2fe
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVpnService.java
@@ -0,0 +1,39 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.VpnService;
+import java.net.Socket;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+@Implements(VpnService.class)
+public class ShadowVpnService extends ShadowService {
+
+  private static Intent prepareIntent = new Intent();
+
+  /** @see #setPrepareResult(Intent). */
+  @Implementation
+  protected static Intent prepare(Context context) {
+    return prepareIntent;
+  }
+
+  /** Sets the return value of #prepare(Context). */
+  public static void setPrepareResult(Intent intent) {
+    prepareIntent = intent;
+  }
+
+  /**
+   * No-ops and always return true, override to avoid call to non-existent Socket.getFileDescriptor.
+   */
+  @Implementation
+  protected boolean protect(Socket socket) {
+    return true;
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    prepareIntent = new Intent();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWallpaperManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWallpaperManager.java
new file mode 100644
index 0000000..87cb0f2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWallpaperManager.java
@@ -0,0 +1,305 @@
+package org.robolectric.shadows;
+
+import android.Manifest.permission;
+import android.annotation.FloatRange;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.app.WallpaperInfo;
+import android.app.WallpaperManager;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.BitmapFactory;
+import android.graphics.Rect;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.util.MathUtils;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.Logger;
+import org.xmlpull.v1.XmlPullParserException;
+
+@Implements(WallpaperManager.class)
+public class ShadowWallpaperManager {
+  private static final String TAG = "ShadowWallpaperManager";
+  private Bitmap lockScreenImage = null;
+  private Bitmap homeScreenImage = null;
+  private boolean isWallpaperAllowed = true;
+  private boolean isWallpaperSupported = true;
+  private WallpaperInfo wallpaperInfo = null;
+  private final List<WallpaperCommandRecord> wallpaperCommandRecords = new ArrayList<>();
+  private AtomicInteger wallpaperId = new AtomicInteger(0);
+  private int lockScreenId;
+  private int homeScreenId;
+
+  private float wallpaperDimAmount = 0.0f;
+
+  @Implementation
+  protected void sendWallpaperCommand(
+      IBinder windowToken, String action, int x, int y, int z, Bundle extras) {
+    wallpaperCommandRecords.add(new WallpaperCommandRecord(windowToken, action, x, y, z, extras));
+  }
+
+  /**
+   * Sets a resource id as the current wallpaper.
+   *
+   * <p>This only caches the resource id in memory. Calling this will override any previously set
+   * resource and does not differentiate between users.
+   */
+  @Implementation(maxSdk = VERSION_CODES.M)
+  protected void setResource(int resid) {
+    setResource(resid, WallpaperManager.FLAG_SYSTEM | WallpaperManager.FLAG_LOCK);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected int setResource(int resid, int which) {
+    if ((which & (WallpaperManager.FLAG_SYSTEM | WallpaperManager.FLAG_LOCK)) == 0) {
+      return 0;
+    }
+    if ((which & WallpaperManager.FLAG_SYSTEM) == WallpaperManager.FLAG_SYSTEM) {
+      homeScreenId = resid;
+    }
+    if ((which & WallpaperManager.FLAG_LOCK) == WallpaperManager.FLAG_LOCK) {
+      lockScreenId = resid;
+    }
+    return wallpaperId.incrementAndGet();
+  }
+
+  /**
+   * Returns whether the current wallpaper has been set through {@link #setResource(int)} or {@link
+   * #setResource(int, int)} with the same resource id.
+   */
+  @Implementation(minSdk = VERSION_CODES.JELLY_BEAN_MR1)
+  protected boolean hasResourceWallpaper(int resid) {
+    return resid == this.lockScreenId || resid == this.homeScreenId;
+  }
+
+  /**
+   * Caches {@code fullImage} in the memory based on {@code which}.
+   *
+   * <p>After a success call, any previously set live wallpaper is removed,
+   *
+   * @param fullImage the bitmap image to be cached in the memory
+   * @param visibleCropHint not used
+   * @param allowBackup not used
+   * @param which either {@link WallpaperManager#FLAG_LOCK} or {WallpaperManager#FLAG_SYSTEM}
+   * @return 0 if fails to cache. Otherwise, 1.
+   */
+  @Implementation(minSdk = VERSION_CODES.P)
+  protected int setBitmap(Bitmap fullImage, Rect visibleCropHint, boolean allowBackup, int which) {
+    if (which == WallpaperManager.FLAG_LOCK) {
+      lockScreenImage = fullImage;
+      wallpaperInfo = null;
+      return 1;
+    } else if (which == WallpaperManager.FLAG_SYSTEM) {
+      homeScreenImage = fullImage;
+      wallpaperInfo = null;
+      return 1;
+    }
+    return 0;
+  }
+
+  /**
+   * Returns the memory cached {@link Bitmap} associated with {@code which}.
+   *
+   * @param which either {@link WallpaperManager#FLAG_LOCK} or {WallpaperManager#FLAG_SYSTEM}.
+   * @return The memory cached {@link Bitmap} associated with {@code which}. {@code null} if no
+   *     bitmap was set.
+   */
+  @Nullable
+  public Bitmap getBitmap(int which) {
+    if (which == WallpaperManager.FLAG_LOCK) {
+      return lockScreenImage;
+    } else if (which == WallpaperManager.FLAG_SYSTEM) {
+      return homeScreenImage;
+    }
+    return null;
+  }
+
+  /**
+   * Gets a wallpaper file associated with {@code which}.
+   *
+   * @param which either {@link WallpaperManager#FLAG_LOCK} or {WallpaperManager#FLAG_SYSTEM}
+   * @return An open, readable file descriptor to the requested wallpaper image file; {@code null}
+   *     if no such wallpaper is configured.
+   */
+  @Implementation(minSdk = VERSION_CODES.P)
+  @Nullable
+  protected ParcelFileDescriptor getWallpaperFile(int which) {
+    if (which == WallpaperManager.FLAG_SYSTEM && homeScreenImage != null) {
+      return createParcelFileDescriptorFromBitmap(homeScreenImage, "home_wallpaper");
+    } else if (which == WallpaperManager.FLAG_LOCK && lockScreenImage != null) {
+      return createParcelFileDescriptorFromBitmap(lockScreenImage, "lock_screen_wallpaper");
+    }
+    return null;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected boolean isSetWallpaperAllowed() {
+    return isWallpaperAllowed;
+  }
+
+  public void setIsSetWallpaperAllowed(boolean allowed) {
+    isWallpaperAllowed = allowed;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected boolean isWallpaperSupported() {
+    return isWallpaperSupported;
+  }
+
+  public void setIsWallpaperSupported(boolean supported) {
+    isWallpaperSupported = supported;
+  }
+
+  /**
+   * Caches {@code bitmapData} in the memory based on {@code which}.
+   *
+   * @param bitmapData the input stream which contains a bitmap image to be cached in the memory
+   * @param visibleCropHint not used
+   * @param allowBackup not used
+   * @param which either {@link WallpaperManager#FLAG_LOCK} or {WallpaperManager#FLAG_SYSTEM}
+   * @return 0 if fails to cache. Otherwise, 1.
+   */
+  @Implementation(minSdk = VERSION_CODES.N)
+  protected int setStream(
+      InputStream bitmapData, Rect visibleCropHint, boolean allowBackup, int which) {
+    if (which == WallpaperManager.FLAG_LOCK) {
+      lockScreenImage = BitmapFactory.decodeStream(bitmapData);
+      return 1;
+    } else if (which == WallpaperManager.FLAG_SYSTEM) {
+      homeScreenImage = BitmapFactory.decodeStream(bitmapData);
+      return 1;
+    }
+    return 0;
+  }
+
+  /**
+   * Sets a live wallpaper, {@code wallpaperService}, as the current wallpaper.
+   *
+   * <p>This only caches the live wallpaper info in the memory. Calling this will remove any
+   * previously set static wallpaper.
+   */
+  @SystemApi
+  @Implementation(minSdk = VERSION_CODES.M)
+  @RequiresPermission(permission.SET_WALLPAPER_COMPONENT)
+  protected boolean setWallpaperComponent(ComponentName wallpaperService)
+      throws IOException, XmlPullParserException {
+    enforceWallpaperComponentPermission();
+
+    Intent wallpaperServiceIntent = new Intent().setComponent(wallpaperService);
+    List<ResolveInfo> resolveInfoList =
+        RuntimeEnvironment.getApplication()
+            .getPackageManager()
+            .queryIntentServices(wallpaperServiceIntent, PackageManager.GET_META_DATA);
+    if (resolveInfoList.size() != 1) {
+      throw new IllegalArgumentException(
+          "Can't locate the given wallpaper service: " + wallpaperService);
+    }
+
+    wallpaperInfo = new WallpaperInfo(RuntimeEnvironment.getApplication(), resolveInfoList.get(0));
+    lockScreenImage = null;
+    homeScreenImage = null;
+    return true;
+  }
+
+  /**
+   * Returns the information about the wallpaper if the current wallpaper is a live wallpaper
+   * component. Otherwise, if the wallpaper is a static image, this returns null.
+   */
+  @Implementation(minSdk = VERSION_CODES.M)
+  protected WallpaperInfo getWallpaperInfo() {
+    return wallpaperInfo;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  protected void setWallpaperDimAmount(@FloatRange(from = 0f, to = 1f) float dimAmount) {
+    wallpaperDimAmount = MathUtils.saturate(dimAmount);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.TIRAMISU)
+  @FloatRange(from = 0f, to = 1f)
+  protected float getWallpaperDimAmount() {
+    return wallpaperDimAmount;
+  }
+
+  /** Returns all the invocation records to {@link WallpaperManager#sendWallpaperCommand} */
+  public List<WallpaperCommandRecord> getWallpaperCommandRecords() {
+    return Collections.unmodifiableList(wallpaperCommandRecords);
+  }
+
+  /**
+   * Throws {@link SecurityException} if the caller doesn't have {@link
+   * permission.SET_WALLPAPER_COMPONENT}.
+   */
+  private static void enforceWallpaperComponentPermission() {
+    // Robolectric doesn't stimulate IPC calls. When this code is executed, it will still be running
+    // in the caller process.
+    if (RuntimeEnvironment.getApplication().checkSelfPermission(permission.SET_WALLPAPER_COMPONENT)
+        != PackageManager.PERMISSION_GRANTED) {
+      throw new SecurityException(
+          "Permission " + permission.SET_WALLPAPER_COMPONENT + " isn't granted.");
+    }
+  }
+
+  /**
+   * Returns an open, readable file descriptor to the given {@code image} or {@code null} if there
+   * is an {@link IOException}.
+   */
+  private static ParcelFileDescriptor createParcelFileDescriptorFromBitmap(
+      Bitmap image, String fileName) {
+    File tmpFile = new File(RuntimeEnvironment.getApplication().getCacheDir(), fileName);
+    try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile)) {
+      image.compress(CompressFormat.PNG, /* quality= */ 0, fileOutputStream);
+      return ParcelFileDescriptor.open(tmpFile, ParcelFileDescriptor.MODE_READ_ONLY);
+    } catch (IOException e) {
+      Logger.error("Fail to close file output stream when reading wallpaper from file", e);
+      return null;
+    }
+  }
+
+  /** Represents an invocation record of {@link WallpaperManager#sendWallpaperCommand} */
+  public static class WallpaperCommandRecord {
+    /** The first parameter of {@link WallpaperManager#sendWallpaperCommand} */
+    public final IBinder windowToken;
+
+    /** The second parameter of {@link WallpaperManager#sendWallpaperCommand} */
+    public final String action;
+
+    /** The third parameter of {@link WallpaperManager#sendWallpaperCommand} */
+    public final int x;
+
+    /** The forth parameter of {@link WallpaperManager#sendWallpaperCommand} */
+    public final int y;
+
+    /** The fifth parameter of {@link WallpaperManager#sendWallpaperCommand} */
+    public final int z;
+
+    /** The sixth parameter of {@link WallpaperManager#sendWallpaperCommand} */
+    public final Bundle extras;
+
+    WallpaperCommandRecord(IBinder windowToken, String action, int x, int y, int z, Bundle extras) {
+      this.windowToken = windowToken;
+      this.action = action;
+      this.x = x;
+      this.y = y;
+      this.z = z;
+      this.extras = extras;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebSettings.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebSettings.java
new file mode 100644
index 0000000..555a211
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebSettings.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+
+import android.content.Context;
+import android.webkit.WebSettings;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Shadow of {@link WebSettings} which returns a dummy user a stub instance rather than the
+ * User-Agent used by a WebView.
+ */
+@Implements(value = WebSettings.class)
+public class ShadowWebSettings {
+
+  private static String defaultUserAgent = "user";
+
+  /**
+   * Returns the default User-Agent used by a WebView. An instance of WebView could use a different
+   * User-Agent if a call is made to {@link WebSettings#setUserAgentString(String)}.
+   *
+   * @param context a Context object used to access application assets
+   */
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static String getDefaultUserAgent(Context context) {
+    return defaultUserAgent;
+  }
+
+  /**
+   * Sets the default user agent for the WebView. The value set here is returned from {@link
+   * #getDefaultUserAgent(Context)}.
+   */
+  public static void setDefaultUserAgent(String defaultUserAgent) {
+    ShadowWebSettings.defaultUserAgent = defaultUserAgent;
+  }
+
+  @Resetter
+  public static void reset() {
+    ShadowWebSettings.defaultUserAgent = "user";
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebStorage.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebStorage.java
new file mode 100644
index 0000000..abfdba2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebStorage.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows;
+
+import android.webkit.WebStorage;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow of {@link WebStorage} which constructs a stub instance rather than attempting to create a
+ * full Chromium-backed instance.
+ */
+@Implements(value = WebStorage.class)
+public class ShadowWebStorage {
+
+  @Implementation
+  protected static WebStorage getInstance() {
+    return new WebStorage();
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebSyncManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebSyncManager.java
new file mode 100644
index 0000000..e46fd96
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebSyncManager.java
@@ -0,0 +1,22 @@
+package org.robolectric.shadows;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(className = "android.webkit.WebSyncManager")
+public class ShadowWebSyncManager {
+  protected boolean synced = false;
+
+  @Implementation
+  protected void sync() {
+    synced = true;
+  }
+
+  public boolean synced() {
+    return synced;
+  }
+
+  public void reset() {
+    synced = false;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebView.java
new file mode 100644
index 0000000..706d86e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebView.java
@@ -0,0 +1,807 @@
+package org.robolectric.shadows;
+
+import android.annotation.ColorInt;
+import android.content.pm.PackageInfo;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.ViewGroup.LayoutParams;
+import android.webkit.DownloadListener;
+import android.webkit.ValueCallback;
+import android.webkit.WebBackForwardList;
+import android.webkit.WebChromeClient;
+import android.webkit.WebHistoryItem;
+import android.webkit.WebMessagePort;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebView.HitTestResult;
+import android.webkit.WebViewClient;
+import android.webkit.WebViewFactoryProvider;
+import com.google.common.collect.ImmutableList;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.fakes.RoboWebMessagePort;
+import org.robolectric.fakes.RoboWebSettings;
+import org.robolectric.util.ReflectionHelpers;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(value = WebView.class)
+public class ShadowWebView extends ShadowViewGroup {
+  @RealObject private WebView realWebView;
+
+  private static final String HISTORY_KEY = "ShadowWebView.History";
+  private static final String HISTORY_INDEX_KEY = "ShadowWebView.HistoryIndex";
+
+  private static PackageInfo packageInfo = null;
+
+  private List<RoboWebMessagePort[]> allCreatedPorts = new ArrayList<>();
+  private String lastUrl;
+  private Map<String, String> lastAdditionalHttpHeaders;
+  private HashMap<String, Object> javascriptInterfaces = new HashMap<>();
+  private WebSettings webSettings = new RoboWebSettings();
+  private WebViewClient webViewClient = null;
+  private boolean clearCacheCalled = false;
+  private boolean clearCacheIncludeDiskFiles = false;
+  private boolean clearFormDataCalled = false;
+  private boolean clearHistoryCalled = false;
+  private boolean clearViewCalled = false;
+  private boolean destroyCalled = false;
+  private boolean onPauseCalled = false;
+  private boolean onResumeCalled = false;
+  private WebChromeClient webChromeClient;
+  private boolean canGoBack;
+  private Bitmap currentFavicon = null;
+  private int goBackInvocations = 0;
+  private int goForwardInvocations = 0;
+  private int reloadInvocations = 0;
+  private LoadData lastLoadData;
+  private LoadDataWithBaseURL lastLoadDataWithBaseURL;
+  private String originalUrl;
+  private int historyIndex = -1;
+  private ArrayList<String> history = new ArrayList<>();
+  private String lastEvaluatedJavascript;
+  private ValueCallback<String> lastEvaluatedJavascriptCallback;
+  // TODO: Delete this when setCanGoBack is deleted. This is only used to determine which "path" we
+  // use when canGoBack or goBack is called.
+  private boolean canGoBackIsSet;
+  private PageLoadType pageLoadType = PageLoadType.UNDEFINED;
+  private HitTestResult hitTestResult = new HitTestResult();
+  private int backgroundColor = 0;
+  private DownloadListener downloadListener;
+  private static WebViewFactoryProvider webViewFactoryProvider;
+
+  @HiddenApi
+  @Implementation
+  protected static WebViewFactoryProvider getFactory() {
+    if (webViewFactoryProvider == null) {
+      webViewFactoryProvider = ReflectionHelpers.createDeepProxy(WebViewFactoryProvider.class);
+    }
+    return webViewFactoryProvider;
+  }
+
+  @HiddenApi
+  @Implementation
+  public void ensureProviderCreated() {
+    final ClassLoader classLoader = getClass().getClassLoader();
+    Class<?> webViewProviderClass = getClassNamed("android.webkit.WebViewProvider");
+    Field mProvider;
+    try {
+      mProvider = WebView.class.getDeclaredField("mProvider");
+      mProvider.setAccessible(true);
+      if (mProvider.get(realView) == null) {
+        Object provider =
+            Proxy.newProxyInstance(
+                classLoader,
+                new Class[] {webViewProviderClass},
+                new InvocationHandler() {
+                  @Override
+                  public Object invoke(Object proxy, Method method, Object[] args)
+                      throws Throwable {
+                    if (method.getName().equals("getViewDelegate")
+                        || method.getName().equals("getScrollDelegate")) {
+                      return Proxy.newProxyInstance(
+                          classLoader,
+                          new Class[] {
+                            getClassNamed("android.webkit.WebViewProvider$ViewDelegate"),
+                            getClassNamed("android.webkit.WebViewProvider$ScrollDelegate")
+                          },
+                          new InvocationHandler() {
+                            @Override
+                            public Object invoke(Object proxy, Method method, Object[] args)
+                                throws Throwable {
+                              return nullish(method);
+                            }
+                          });
+                    }
+
+                    return nullish(method);
+                  }
+                });
+        mProvider.set(realView, provider);
+      }
+    } catch (NoSuchFieldException | IllegalAccessException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Implementation
+  protected void setLayoutParams(LayoutParams params) {
+    ReflectionHelpers.setField(realWebView, "mLayoutParams", params);
+  }
+
+  private Object nullish(Method method) {
+    Class<?> returnType = method.getReturnType();
+    if (returnType.equals(long.class)
+        || returnType.equals(double.class)
+        || returnType.equals(int.class)
+        || returnType.equals(float.class)
+        || returnType.equals(short.class)
+        || returnType.equals(byte.class)) return 0;
+    if (returnType.equals(char.class)) return '\0';
+    if (returnType.equals(boolean.class)) return false;
+    return null;
+  }
+
+  private Class<?> getClassNamed(String className) {
+    try {
+      return getClass().getClassLoader().loadClass(className);
+    } catch (ClassNotFoundException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Implementation
+  protected void loadUrl(String url) {
+    loadUrl(url, null);
+  }
+
+  /**
+   * Fires a request to load the given {@code url} in WebView.
+   *
+   * <p>The {@code url} is is not added to the history until {@link #pushEntryToHistory(String)} is
+   * called. If you want to simulate a redirect you can pass the redirect URL to {@link
+   * #pushEntryToHistory(String)}.
+   */
+  @Implementation
+  protected void loadUrl(String url, Map<String, String> additionalHttpHeaders) {
+    originalUrl = url;
+    lastUrl = url;
+
+    if (additionalHttpHeaders != null) {
+      this.lastAdditionalHttpHeaders = Collections.unmodifiableMap(additionalHttpHeaders);
+    } else {
+      this.lastAdditionalHttpHeaders = null;
+    }
+
+    performPageLoadType(url);
+  }
+
+  @Implementation
+  protected void loadDataWithBaseURL(
+      String baseUrl, String data, String mimeType, String encoding, String historyUrl) {
+    if (historyUrl != null) {
+      originalUrl = historyUrl;
+    }
+    lastLoadDataWithBaseURL =
+        new LoadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl);
+
+    performPageLoadType(baseUrl);
+  }
+
+  @Implementation
+  protected void loadData(String data, String mimeType, String encoding) {
+    lastLoadData = new LoadData(data, mimeType, encoding);
+
+    performPageLoadType(data);
+  }
+
+  /**
+   * Pushes an entry to the history with the given {@code url}.
+   *
+   * <p>This method can be used after a {@link #loadUrl(String)} call to push that navigation into
+   * the history. This matches the prod behaviour of WebView, a navigation is never committed to
+   * history inline and can take an arbitrary amount of time depending on the network connection.
+   * Notice that the given {@code url} does not need to match that of the {@link #loadUrl(String)}
+   * as URL can be changed e.g. through server-side redirects without WebView being notified by the
+   * time it is committed.
+   *
+   * <p>This method can also be used to simulate navigations started by user interaction, as these
+   * would still add an entry to the history themselves.
+   *
+   * <p>If there are any entries ahead of the current index (for forward navigation) these are
+   * removed.
+   */
+  public void pushEntryToHistory(String url) {
+    history.subList(historyIndex + 1, history.size()).clear();
+
+    history.add(url);
+    historyIndex++;
+
+    originalUrl = url;
+  }
+
+  /**
+   * Performs no callbacks on {@link WebViewClient} and {@link WebChromeClient} when any of {@link
+   * #loadUrl}, {@link loadData} or {@link #loadDataWithBaseURL} is called.
+   */
+  public void performNoPageLoadClientCallbacks() {
+    this.pageLoadType = PageLoadType.UNDEFINED;
+  }
+
+  /**
+   * Performs callbacks on {@link WebViewClient} and {@link WebChromeClient} that simulates a
+   * successful page load when any of {@link #loadUrl}, {@link loadData} or {@link
+   * #loadDataWithBaseURL} is called.
+   */
+  public void performSuccessfulPageLoadClientCallbacks() {
+    this.pageLoadType = PageLoadType.SUCCESS;
+  }
+
+  private void performPageLoadType(String url) {
+    switch (pageLoadType) {
+      case SUCCESS:
+        performSuccessfulPageLoad(url);
+        break;
+      case UNDEFINED:
+        break;
+    }
+  }
+
+  private void performSuccessfulPageLoad(String url) {
+    new Handler(Looper.getMainLooper())
+        .post(
+            () -> {
+              if (webChromeClient != null) {
+                webChromeClient.onProgressChanged(realWebView, 10);
+              }
+              if (webViewClient != null) {
+                webViewClient.onPageStarted(realWebView, url, /* favicon= */ null);
+              }
+              if (webChromeClient != null) {
+                webChromeClient.onProgressChanged(realWebView, 40);
+                webChromeClient.onProgressChanged(realWebView, 80);
+              }
+              if (webViewClient != null && VERSION.SDK_INT >= 23) {
+                webViewClient.onPageCommitVisible(realWebView, url);
+              }
+              if (webChromeClient != null) {
+                webChromeClient.onReceivedTitle(realWebView, url);
+                webChromeClient.onProgressChanged(realWebView, 100);
+              }
+              if (webViewClient != null) {
+                webViewClient.onPageFinished(realWebView, url);
+              }
+            });
+  }
+
+  /**
+   * @return the last loaded url
+   */
+  public String getLastLoadedUrl() {
+    return lastUrl;
+  }
+
+  @Implementation
+  protected String getOriginalUrl() {
+    return originalUrl;
+  }
+
+  @Implementation
+  protected String getUrl() {
+    return originalUrl;
+  }
+
+  @Implementation
+  protected String getTitle() {
+    return originalUrl;
+  }
+
+  /**
+   * @return the additional Http headers that in the same request with last loaded url
+   */
+  public Map<String, String> getLastAdditionalHttpHeaders() {
+    return lastAdditionalHttpHeaders;
+  }
+
+  @Implementation
+  protected WebSettings getSettings() {
+    return webSettings;
+  }
+
+  @Implementation
+  protected void setWebViewClient(WebViewClient client) {
+    webViewClient = client;
+  }
+
+  @Implementation
+  protected void setWebChromeClient(WebChromeClient client) {
+    webChromeClient = client;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.O)
+  public WebViewClient getWebViewClient() {
+    return webViewClient;
+  }
+
+  @Implementation
+  protected void addJavascriptInterface(Object obj, String interfaceName) {
+    javascriptInterfaces.put(interfaceName, obj);
+  }
+
+  public Object getJavascriptInterface(String interfaceName) {
+    return javascriptInterfaces.get(interfaceName);
+  }
+
+  @Implementation
+  protected void removeJavascriptInterface(String name) {
+    javascriptInterfaces.remove(name);
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.M)
+  protected WebMessagePort[] createWebMessageChannel() {
+    RoboWebMessagePort[] ports = RoboWebMessagePort.createPair();
+    allCreatedPorts.add(ports);
+    return ports;
+  }
+
+  public List<RoboWebMessagePort[]> getCreatedPorts() {
+    return ImmutableList.copyOf(allCreatedPorts);
+  }
+
+  @Implementation
+  protected void clearCache(boolean includeDiskFiles) {
+    clearCacheCalled = true;
+    clearCacheIncludeDiskFiles = includeDiskFiles;
+  }
+
+  public boolean wasClearCacheCalled() {
+    return clearCacheCalled;
+  }
+
+  public boolean didClearCacheIncludeDiskFiles() {
+    return clearCacheIncludeDiskFiles;
+  }
+
+  @Implementation
+  protected void clearFormData() {
+    clearFormDataCalled = true;
+  }
+
+  public boolean wasClearFormDataCalled() {
+    return clearFormDataCalled;
+  }
+
+  @Implementation
+  protected void clearHistory() {
+    clearHistoryCalled = true;
+    history.clear();
+    historyIndex = -1;
+  }
+
+  public boolean wasClearHistoryCalled() {
+    return clearHistoryCalled;
+  }
+
+  @Implementation
+  protected void reload() {
+    reloadInvocations++;
+  }
+
+  /** Returns the number of times {@code android.webkit.WebView#reload()} was invoked */
+  public int getReloadInvocations() {
+    return reloadInvocations;
+  }
+
+  @Implementation
+  protected void clearView() {
+    clearViewCalled = true;
+  }
+
+  public boolean wasClearViewCalled() {
+    return clearViewCalled;
+  }
+
+  @Implementation
+  protected void onPause() {
+    onPauseCalled = true;
+  }
+
+  public boolean wasOnPauseCalled() {
+    return onPauseCalled;
+  }
+
+  @Implementation
+  protected void onResume() {
+    onResumeCalled = true;
+  }
+
+  public boolean wasOnResumeCalled() {
+    return onResumeCalled;
+  }
+
+  @Implementation
+  protected void destroy() {
+    destroyCalled = true;
+  }
+
+  public boolean wasDestroyCalled() {
+    return destroyCalled;
+  }
+
+  /**
+   * @return webChromeClient
+   */
+  @Implementation(minSdk = VERSION_CODES.O)
+  public WebChromeClient getWebChromeClient() {
+    return webChromeClient;
+  }
+
+  @Implementation
+  protected boolean canGoBack() {
+    // TODO: Remove the canGoBack check when setCanGoBack is deleted.
+    if (canGoBackIsSet) {
+      return canGoBack;
+    }
+    return historyIndex > 0;
+  }
+
+  @Implementation
+  protected boolean canGoForward() {
+    return historyIndex < history.size() - 1;
+  }
+
+  @Implementation
+  protected void goBack() {
+    if (canGoBack()) {
+      goBackInvocations++;
+      // TODO: Delete this when setCanGoBack is deleted, since this creates two different behavior
+      // paths.
+      if (canGoBackIsSet) {
+        return;
+      }
+      historyIndex--;
+      originalUrl = history.get(historyIndex);
+    }
+  }
+
+  @Implementation
+  protected void goForward() {
+    if (canGoForward()) {
+      goForwardInvocations++;
+      historyIndex++;
+      originalUrl = history.get(historyIndex);
+    }
+  }
+
+  @Implementation
+  protected void goBackOrForward(int steps) {
+    if (steps == 0) {
+      return;
+    }
+    if (steps > 0) {
+      while (steps-- > 0) {
+        goForward();
+      }
+      return;
+    }
+
+    while (steps++ < 0) {
+      goBack();
+    }
+  }
+
+  @Implementation
+  protected WebBackForwardList copyBackForwardList() {
+    return new BackForwardList(history, historyIndex);
+  }
+
+  @Implementation
+  protected static String findAddress(String addr) {
+    return null;
+  }
+
+  /**
+   * Overrides the system implementation for getting the WebView package.
+   *
+   * <p>Returns null by default, but this can be changed with {@code #setCurrentWebviewPackage()}.
+   */
+  @Implementation(minSdk = Build.VERSION_CODES.O)
+  protected static PackageInfo getCurrentWebViewPackage() {
+    return packageInfo;
+  }
+
+  /** Sets the value to return from {@code #getCurrentWebviewPackage()}. */
+  public static void setCurrentWebViewPackage(PackageInfo webViewPackageInfo) {
+    packageInfo = webViewPackageInfo;
+  }
+
+  /** Gets the favicon for the current page set by {@link #setFavicon}. */
+  @Implementation
+  protected Bitmap getFavicon() {
+    return currentFavicon;
+  }
+
+  /** Sets the favicon to return from {@link #getFavicon}. */
+  public void setFavicon(Bitmap favicon) {
+    currentFavicon = favicon;
+  }
+
+  @Implementation(minSdk = Build.VERSION_CODES.KITKAT)
+  protected void evaluateJavascript(String script, ValueCallback<String> callback) {
+    this.lastEvaluatedJavascript = script;
+    this.lastEvaluatedJavascriptCallback = callback;
+  }
+
+  /**
+   * Returns the last evaluated Javascript value provided to {@link #evaluateJavascript(String,
+   * ValueCallback)} or null if the method has not been called.
+   */
+  public String getLastEvaluatedJavascript() {
+    return lastEvaluatedJavascript;
+  }
+
+  /**
+   * Returns the last callback value provided to {@link #evaluateJavascript(String, ValueCallback)}
+   * or null if the method has not been called.
+   */
+  public ValueCallback<String> getLastEvaluatedJavascriptCallback() {
+    return lastEvaluatedJavascriptCallback;
+  }
+
+  /**
+   * Sets the value to return from {@code android.webkit.WebView#canGoBack()}
+   *
+   * @param canGoBack Value to return from {@code android.webkit.WebView#canGoBack()}
+   * @deprecated Do not depend on this method as it will be removed in a future update. The
+   *     preferered method is to populate a fake web history to use for going back.
+   */
+  @Deprecated
+  public void setCanGoBack(boolean canGoBack) {
+    canGoBackIsSet = true;
+    this.canGoBack = canGoBack;
+  }
+
+  /** Returns the number of times {@code android.webkit.WebView#goBack()} was invoked. */
+  public int getGoBackInvocations() {
+    return goBackInvocations;
+  }
+
+  /** Returns the number of times {@code android.webkit.WebView#goForward()} was invoked. */
+  public int getGoForwardInvocations() {
+    return goForwardInvocations;
+  }
+
+  public LoadData getLastLoadData() {
+    return lastLoadData;
+  }
+
+  public LoadDataWithBaseURL getLastLoadDataWithBaseURL() {
+    return lastLoadDataWithBaseURL;
+  }
+
+  @Implementation
+  protected WebBackForwardList saveState(Bundle outState) {
+    if (history.size() > 0) {
+      outState.putStringArrayList(HISTORY_KEY, history);
+      outState.putInt(HISTORY_INDEX_KEY, historyIndex);
+    }
+    return new BackForwardList(history, historyIndex);
+  }
+
+  @Implementation
+  protected WebBackForwardList restoreState(Bundle inState) {
+    history = inState.getStringArrayList(HISTORY_KEY);
+    if (history == null) {
+      history = new ArrayList<>();
+      historyIndex = -1;
+    } else {
+      historyIndex = inState.getInt(HISTORY_INDEX_KEY);
+    }
+
+    if (history.size() > 0) {
+      originalUrl = history.get(historyIndex);
+      lastUrl = history.get(historyIndex);
+      return new BackForwardList(history, historyIndex);
+    }
+    return null;
+  }
+
+  @Implementation
+  protected HitTestResult getHitTestResult() {
+    return hitTestResult;
+  }
+
+  /** Creates an instance of {@link HitTestResult}. */
+  public static HitTestResult createHitTestResult(int type, String extra) {
+    HitTestResult hitTestResult = new HitTestResult();
+    hitTestResult.setType(type);
+    hitTestResult.setExtra(extra);
+    return hitTestResult;
+  }
+
+  /** Sets the {@link HitTestResult} that should be returned from {@link #getHitTestResult()}. */
+  public void setHitTestResult(HitTestResult hitTestResult) {
+    this.hitTestResult = hitTestResult;
+  }
+
+  @Resetter
+  public static void reset() {
+    packageInfo = null;
+  }
+
+  @Implementation(minSdk = VERSION_CODES.KITKAT)
+  public static void setWebContentsDebuggingEnabled(boolean enabled) {}
+
+  /**
+   * Sets the {@link android.graphics.Color} int that should be returned from {@link
+   * #getBackgroundColor}.
+   *
+   * <p>WebView uses the background color set by the {@link
+   * android.webkit.WebView#setBackgroundColor} method to internally tint the background color of
+   * web pages until they are drawn. The way this API works is completely independent of the {@link
+   * android.view.View#setBackgroundColor} method and it interacts directly with WebView renderers.
+   * Tests can access the set background color using the {@link #getBackgroundColor} method.
+   */
+  @Implementation
+  protected void setBackgroundColor(@ColorInt int backgroundColor) {
+    this.backgroundColor = backgroundColor;
+  }
+
+  /**
+   * Returns the {@link android.graphics.Color} int that has been set by {@link
+   * #setBackgroundColor}.
+   */
+  public int getBackgroundColor() {
+    return backgroundColor;
+  }
+
+  @Implementation
+  protected void setDownloadListener(DownloadListener downloadListener) {
+    this.downloadListener = downloadListener;
+  }
+
+  /** Returns the {@link DownloadListener} set with {@link #setDownloadListener}, if any. */
+  public DownloadListener getDownloadListener() {
+    return this.downloadListener;
+  }
+
+  public static class LoadDataWithBaseURL {
+    public final String baseUrl;
+    public final String data;
+    public final String mimeType;
+    public final String encoding;
+    public final String historyUrl;
+
+    public LoadDataWithBaseURL(
+        String baseUrl, String data, String mimeType, String encoding, String historyUrl) {
+      this.baseUrl = baseUrl;
+      this.data = data;
+      this.mimeType = mimeType;
+      this.encoding = encoding;
+      this.historyUrl = historyUrl;
+    }
+  }
+
+  public static class LoadData {
+    public final String data;
+    public final String mimeType;
+    public final String encoding;
+
+    public LoadData(String data, String mimeType, String encoding) {
+      this.data = data;
+      this.mimeType = mimeType;
+      this.encoding = encoding;
+    }
+  }
+
+  /**
+   * Defines a type of page load which is associated with a certain order of {@link WebViewClient}
+   * and {@link WebChromeClient} callbacks.
+   *
+   * <p>A page load is triggered either using {@link #loadUrl}, {@link loadData} or {@link
+   * loadDataWithBaseURL}.
+   */
+  private enum PageLoadType {
+    /** Default type, triggers no {@link WebViewClient} or {@link WebChromeClient} callbacks. */
+    UNDEFINED,
+    /**
+     * Represents a successful page load, which triggers all the associated {@link WebViewClient} or
+     * {@link WebChromeClient} callbacks from {@code onPageStarted} until {@code onPageFinished}
+     * without any error.
+     */
+    SUCCESS
+  }
+
+  private static class BackForwardList extends WebBackForwardList {
+    private final ArrayList<String> history;
+    private final int index;
+
+    public BackForwardList(ArrayList<String> history, int index) {
+      this.history = (ArrayList<String>) history.clone();
+      this.index = index;
+    }
+
+    @Override
+    public int getCurrentIndex() {
+      return index;
+    }
+
+    @Override
+    public int getSize() {
+      return history.size();
+    }
+
+    @Override
+    public HistoryItem getCurrentItem() {
+      if (history.isEmpty()) {
+        return null;
+      }
+
+      return new HistoryItem(history.get(getCurrentIndex()));
+    }
+
+    @Override
+    public HistoryItem getItemAtIndex(int index) {
+      return new HistoryItem(history.get(index));
+    }
+
+    @Override
+    protected WebBackForwardList clone() {
+      return new BackForwardList(history, index);
+    }
+  }
+
+  private static class HistoryItem extends WebHistoryItem {
+    private final String url;
+
+    public HistoryItem(String url) {
+      this.url = url;
+    }
+
+    @Override
+    public int getId() {
+      return url.hashCode();
+    }
+
+    @Override
+    public Bitmap getFavicon() {
+      return null;
+    }
+
+    @Override
+    public String getOriginalUrl() {
+      return url;
+    }
+
+    @Override
+    public String getTitle() {
+      return url;
+    }
+
+    @Override
+    public String getUrl() {
+      return url;
+    }
+
+    @Override
+    protected HistoryItem clone() {
+      return new HistoryItem(url);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebViewDatabase.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebViewDatabase.java
new file mode 100644
index 0000000..1967453
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebViewDatabase.java
@@ -0,0 +1,85 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.webkit.WebViewDatabase;
+import javax.annotation.Nullable;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = WebViewDatabase.class, callThroughByDefault = false)
+public class ShadowWebViewDatabase {
+  private static RoboWebViewDatabase webViewDatabase;
+
+  @Implementation
+  protected static WebViewDatabase getInstance(Context ignored) {
+    if (webViewDatabase == null) {
+      webViewDatabase = new RoboWebViewDatabase();
+    }
+    return webViewDatabase;
+  }
+
+  /** Resets the {@code WebViewDatabase} instance to clear any state between tests. */
+  public void resetDatabase() {
+    webViewDatabase = null;
+  }
+
+  /** Returns {@code true} if {@link WebViewDatabase#clearFormData()} was called. */
+  public boolean wasClearFormDataCalled() {
+    return webViewDatabase.wasClearFormDataCalled();
+  }
+
+  /** Resets {@link #wasClearFormDataCalled()}, setting it back to false. */
+  public void resetClearFormData() {
+    webViewDatabase.resetClearFormData();
+  }
+
+  private static final class RoboWebViewDatabase extends WebViewDatabase {
+    private boolean wasClearFormDataCalled = false;
+
+    RoboWebViewDatabase() {}
+
+    @Override
+    public boolean hasUsernamePassword() {
+      return false;
+    }
+
+    @Override
+    public void clearUsernamePassword() {}
+
+    @Override
+    public boolean hasHttpAuthUsernamePassword() {
+      return false;
+    }
+
+    @Override
+    public void clearHttpAuthUsernamePassword() {}
+
+    @Override
+    public void setHttpAuthUsernamePassword(
+        String host, String realm, String username, String password) {}
+
+    @Nullable
+    @Override
+    public String[] getHttpAuthUsernamePassword(String host, String realm) {
+      return null;
+    }
+
+    @Override
+    public boolean hasFormData() {
+      return false;
+    }
+
+    @Override
+    public void clearFormData() {
+      wasClearFormDataCalled = true;
+    }
+
+    private boolean wasClearFormDataCalled() {
+      return wasClearFormDataCalled;
+    }
+
+    private void resetClearFormData() {
+      wasClearFormDataCalled = false;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiAwareManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiAwareManager.java
new file mode 100644
index 0000000..6a814cc
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiAwareManager.java
@@ -0,0 +1,112 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.O;
+
+import android.net.wifi.aware.AttachCallback;
+import android.net.wifi.aware.DiscoverySessionCallback;
+import android.net.wifi.aware.PublishConfig;
+import android.net.wifi.aware.PublishDiscoverySession;
+import android.net.wifi.aware.SubscribeConfig;
+import android.net.wifi.aware.SubscribeDiscoverySession;
+import android.net.wifi.aware.WifiAwareManager;
+import android.net.wifi.aware.WifiAwareSession;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.Looper;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow Implementation of {@link android.net.wifi.aware.WifiAwareManager} */
+@Implements(value = WifiAwareManager.class, minSdk = O)
+public class ShadowWifiAwareManager {
+  private boolean available = true;
+  private WifiAwareSession session;
+  private boolean sessionDetached = true;
+  private PublishDiscoverySession discoverySessionToPublish;
+  private SubscribeDiscoverySession discoverySessionToSubscribe;
+
+  @Implementation
+  protected boolean isAvailable() {
+    return available;
+  }
+
+  @Implementation
+  protected void attach(AttachCallback callback, Handler handler) {
+    if (available && sessionDetached) {
+      sessionDetached = false;
+      handler.post(() -> callback.onAttached(session));
+    } else if (available && !sessionDetached) {
+      return;
+    } else {
+      handler.post(callback::onAttachFailed);
+    }
+  }
+
+  @Implementation
+  protected void publish(
+      int clientId, Looper looper, PublishConfig publishConfig, DiscoverySessionCallback callback) {
+    if (available) {
+      Handler handler = new Handler(looper);
+      handler.post(() -> callback.onPublishStarted(discoverySessionToPublish));
+    }
+  }
+
+  @Implementation
+  protected void subscribe(
+      int clientId,
+      Looper looper,
+      SubscribeConfig subscribeConfig,
+      DiscoverySessionCallback callback) {
+    if (available) {
+      Handler handler = new Handler(looper);
+      handler.post(() -> callback.onSubscribeStarted(discoverySessionToSubscribe));
+    }
+  }
+
+  /** Returns a new instance of PublishDiscoverySession. */
+  public static PublishDiscoverySession newPublishDiscoverySession(
+      WifiAwareManager manager, int clientId, int sessionId) {
+    return new PublishDiscoverySession(manager, clientId, sessionId);
+  }
+
+  /** Returns a new instance of SubscribeDiscoverySession. */
+  public static SubscribeDiscoverySession newSubscribeDiscoverySession(
+      WifiAwareManager manager, int clientId, int sessionId) {
+    return new SubscribeDiscoverySession(manager, clientId, sessionId);
+  }
+
+  /** Returns a new instance of WifiAwareSession. */
+  public static WifiAwareSession newWifiAwareSession(
+      WifiAwareManager manager, Binder binder, int clientId) {
+    return new WifiAwareSession(manager, binder, clientId);
+  }
+
+  /** Sets the availability of the wifiAwareManager. */
+  public void setAvailable(boolean available) {
+    this.available = available;
+  }
+
+  /** Sets parameter to pass to AttachCallback#onAttach(WifiAwareSession session) */
+  public void setWifiAwareSession(WifiAwareSession session) {
+    this.session = session;
+  }
+
+  /** Sets the boolean value indicating if a wifiAwareSession has been detached. */
+  public void setSessionDetached(boolean sessionDetached) {
+    this.sessionDetached = sessionDetached;
+  }
+
+  /**
+   * Sets parameter to pass to DiscoverySessionCallback#onPublishStarted(PublishDiscoverySession)
+   */
+  public void setDiscoverySessionToPublish(PublishDiscoverySession publishDiscoverySession) {
+    this.discoverySessionToPublish = publishDiscoverySession;
+  }
+
+  /**
+   * Sets param to pass to DiscoverySessionCallback#onSubscribeStarted(SubscribeDiscoverySession)
+   */
+  public void setDiscoverySessionToSubscribe(SubscribeDiscoverySession subscribeDiscoverySession) {
+    this.discoverySessionToSubscribe = subscribeDiscoverySession;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiConfiguration.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiConfiguration.java
new file mode 100644
index 0000000..cc2ed7e
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiConfiguration.java
@@ -0,0 +1,59 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.net.wifi.SecurityParams;
+import android.net.wifi.WifiConfiguration;
+import com.google.common.collect.ImmutableSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+/** Shadow Implementation of {@link android.net.wifi.WifiConfiguration} */
+@Implements(value = WifiConfiguration.class)
+public class ShadowWifiConfiguration {
+  @RealObject private WifiConfiguration realObject;
+
+  private int securityType = -1; // for Android R
+
+  /* Returns a copy of the {@link WifiConfiguration} it shadows. */
+  public WifiConfiguration copy() {
+    return new WifiConfiguration(realObject);
+  }
+
+  @Implementation(minSdk = R, maxSdk = R)
+  protected void setSecurityParams(int securityType) {
+    reflector(WifiConfigurationReflector.class, realObject).setSecurityParams(securityType);
+    this.securityType = securityType;
+  }
+
+  /** Returns the security type set by {@code setSecurityParams}. */
+  public Set<Integer> getSecurityTypes() {
+    if (RuntimeEnvironment.getApiLevel() == R) {
+      return ImmutableSet.of(securityType);
+    } else {
+      List<Object> params =
+          reflector(WifiConfigurationReflector.class, realObject).getSecurityParams();
+      return params.stream()
+          .map(s -> ((SecurityParams) s).getSecurityType())
+          .collect(Collectors.toSet());
+    }
+  }
+
+  @ForType(WifiConfiguration.class)
+  interface WifiConfigurationReflector {
+    @Accessor("mSecurityParamsList")
+    List<Object> getSecurityParams();
+
+    @Direct
+    void setSecurityParams(int securityType);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiInfo.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiInfo.java
new file mode 100644
index 0000000..7f3b1c0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiInfo.java
@@ -0,0 +1,132 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.net.wifi.SupplicantState;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiSsid;
+import java.net.InetAddress;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+import org.robolectric.util.reflector.WithType;
+
+@Implements(WifiInfo.class)
+public class ShadowWifiInfo {
+
+  public static WifiInfo newInstance() {
+    return ReflectionHelpers.callConstructor(WifiInfo.class);
+  }
+
+  @RealObject WifiInfo realObject;
+
+  @Implementation
+  public void setInetAddress(InetAddress address) {
+    reflector(WifiInfoReflector.class, realObject).setInetAddress(address);
+  }
+
+  @Implementation
+  public void setMacAddress(String newMacAddress) {
+    reflector(WifiInfoReflector.class, realObject).setMacAddress(newMacAddress);
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN)
+  public void setSSID(String ssid) {
+    if (RuntimeEnvironment.getApiLevel() <= JELLY_BEAN) {
+      reflector(WifiInfoReflector.class, realObject).setSSID(ssid);
+    } else {
+      reflector(WifiInfoReflector.class, realObject).setSSID(getWifiSsid(ssid));
+    }
+  }
+
+  private static Object getWifiSsid(String ssid) {
+    WifiSsid wifiSsid;
+    if (ssid.startsWith("0x")) {
+      wifiSsid = reflector(WifiSsidReflector.class).createFromHex(ssid);
+    } else {
+      wifiSsid = reflector(WifiSsidReflector.class).createFromAsciiEncoded(ssid);
+    }
+    return wifiSsid;
+  }
+
+  @Implementation
+  public void setBSSID(String bssid) {
+    reflector(WifiInfoReflector.class, realObject).setBSSID(bssid);
+  }
+
+  @Implementation
+  public void setSupplicantState(SupplicantState state) {
+    reflector(WifiInfoReflector.class, realObject).setSupplicantState(state);
+  }
+
+  @Implementation
+  public void setRssi(int rssi) {
+    reflector(WifiInfoReflector.class, realObject).setRssi(rssi);
+  }
+
+  @Implementation
+  public void setLinkSpeed(int linkSpeed) {
+    reflector(WifiInfoReflector.class, realObject).setLinkSpeed(linkSpeed);
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  public void setFrequency(int frequency) {
+    reflector(WifiInfoReflector.class, realObject).setFrequency(frequency);
+  }
+
+  @Implementation
+  public void setNetworkId(int id) {
+    reflector(WifiInfoReflector.class, realObject).setNetworkId(id);
+  }
+
+  @ForType(WifiInfo.class)
+  interface WifiInfoReflector {
+
+    @Direct
+    void setInetAddress(InetAddress address);
+
+    @Direct
+    void setMacAddress(String newMacAddress);
+
+    @Direct
+    void setSSID(String ssid);
+
+    void setSSID(@WithType("android.net.wifi.WifiSsid") Object ssid);
+
+    @Direct
+    void setBSSID(String bssid);
+
+    @Direct
+    void setSupplicantState(SupplicantState state);
+
+    @Direct
+    void setRssi(int rssi);
+
+    @Direct
+    void setLinkSpeed(int linkSpeed);
+
+    @Direct
+    void setNetworkId(int id);
+
+    @Direct
+    void setFrequency(int frequency);
+  }
+
+  @ForType(WifiSsid.class)
+  interface WifiSsidReflector {
+    // pre-T
+    @Static
+    WifiSsid createFromHex(String hexStr);
+
+    // pre-T
+    @Static
+    WifiSsid createFromAsciiEncoded(String asciiEncoded);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java
new file mode 100644
index 0000000..83ca1fb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java
@@ -0,0 +1,620 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.LOLLIPOP;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.DhcpInfo;
+import android.net.NetworkInfo;
+import android.net.wifi.ScanResult;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.MulticastLock;
+import android.net.wifi.WifiUsabilityStatsEntry;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.util.ArraySet;
+import android.util.Pair;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Shadow for {@link android.net.wifi.WifiManager}. */
+@Implements(value = WifiManager.class, looseSignatures = true)
+@SuppressWarnings("AndroidConcurrentHashMap")
+public class ShadowWifiManager {
+  private static final int LOCAL_HOST = 2130706433;
+
+  private static float sSignalLevelInPercent = 1f;
+  private boolean accessWifiStatePermission = true;
+  private int wifiState = WifiManager.WIFI_STATE_ENABLED;
+  private boolean wasSaved = false;
+  private WifiInfo wifiInfo;
+  private List<ScanResult> scanResults;
+  private final Map<Integer, WifiConfiguration> networkIdToConfiguredNetworks = new LinkedHashMap<>();
+  private Pair<Integer, Boolean> lastEnabledNetwork;
+  private final Set<Integer> enabledNetworks = new HashSet<>();
+  private DhcpInfo dhcpInfo;
+  private boolean startScanSucceeds = true;
+  private boolean is5GHzBandSupported = false;
+  private boolean isStaApConcurrencySupported = false;
+  private AtomicInteger activeLockCount = new AtomicInteger(0);
+  private final BitSet readOnlyNetworkIds = new BitSet();
+  private final ConcurrentHashMap<WifiManager.OnWifiUsabilityStatsListener, Executor>
+      wifiUsabilityStatsListeners = new ConcurrentHashMap<>();
+  private final List<WifiUsabilityScore> usabilityScores = new ArrayList<>();
+  @RealObject WifiManager wifiManager;
+  private WifiConfiguration apConfig;
+
+  @Implementation
+  protected boolean setWifiEnabled(boolean wifiEnabled) {
+    checkAccessWifiStatePermission();
+    this.wifiState = wifiEnabled ? WifiManager.WIFI_STATE_ENABLED : WifiManager.WIFI_STATE_DISABLED;
+    return true;
+  }
+
+  public void setWifiState(int wifiState) {
+    checkAccessWifiStatePermission();
+    this.wifiState = wifiState;
+  }
+
+  @Implementation
+  protected boolean isWifiEnabled() {
+    checkAccessWifiStatePermission();
+    return wifiState == WifiManager.WIFI_STATE_ENABLED;
+  }
+
+  @Implementation
+  protected int getWifiState() {
+    checkAccessWifiStatePermission();
+    return wifiState;
+  }
+
+  @Implementation
+  protected WifiInfo getConnectionInfo() {
+    checkAccessWifiStatePermission();
+    if (wifiInfo == null) {
+      wifiInfo = ReflectionHelpers.callConstructor(WifiInfo.class);
+    }
+    return wifiInfo;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected boolean is5GHzBandSupported() {
+    return is5GHzBandSupported;
+  }
+
+  /** Sets whether 5ghz band is supported. */
+  public void setIs5GHzBandSupported(boolean is5GHzBandSupported) {
+    this.is5GHzBandSupported = is5GHzBandSupported;
+  }
+
+  /** Returns last value provided to #setStaApConcurrencySupported. */
+  @Implementation(minSdk = R)
+  protected boolean isStaApConcurrencySupported() {
+    return isStaApConcurrencySupported;
+  }
+
+  /** Sets whether STA/AP concurrency is supported. */
+  public void setStaApConcurrencySupported(boolean isStaApConcurrencySupported) {
+    this.isStaApConcurrencySupported = isStaApConcurrencySupported;
+  }
+
+  /**
+   * Sets the connection info as the provided {@link WifiInfo}.
+   */
+  public void setConnectionInfo(WifiInfo wifiInfo) {
+    this.wifiInfo = wifiInfo;
+  }
+
+  /** Sets the return value of {@link #startScan}. */
+  public void setStartScanSucceeds(boolean succeeds) {
+    this.startScanSucceeds = succeeds;
+  }
+
+  @Implementation
+  protected List<ScanResult> getScanResults() {
+    return scanResults;
+  }
+
+  @Implementation
+  protected List<WifiConfiguration> getConfiguredNetworks() {
+    final ArrayList<WifiConfiguration> wifiConfigurations = new ArrayList<>();
+    for (WifiConfiguration wifiConfiguration : networkIdToConfiguredNetworks.values()) {
+      wifiConfigurations.add(wifiConfiguration);
+    }
+    return wifiConfigurations;
+  }
+
+  @Implementation(minSdk = LOLLIPOP)
+  protected List<WifiConfiguration> getPrivilegedConfiguredNetworks() {
+    return getConfiguredNetworks();
+  }
+
+  @Implementation
+  protected int addNetwork(WifiConfiguration config) {
+    if (config == null) {
+      return -1;
+    }
+    int networkId = networkIdToConfiguredNetworks.size();
+    config.networkId = -1;
+    networkIdToConfiguredNetworks.put(networkId, makeCopy(config, networkId));
+    return networkId;
+  }
+
+  @Implementation
+  protected boolean removeNetwork(int netId) {
+    networkIdToConfiguredNetworks.remove(netId);
+    return true;
+  }
+
+  /**
+   * Adds or updates a network which can later be retrieved with {@link #getWifiConfiguration(int)}
+   * method. A null {@param config}, or one with a networkId less than 0, or a networkId that had
+   * its updatePermission removed using the {@link #setUpdateNetworkPermission(int, boolean)} will
+   * return -1, which indicates a failure to update.
+   */
+  @Implementation
+  protected int updateNetwork(WifiConfiguration config) {
+    if (config == null || config.networkId < 0 || readOnlyNetworkIds.get(config.networkId)) {
+      return -1;
+    }
+    networkIdToConfiguredNetworks.put(config.networkId, makeCopy(config, config.networkId));
+    return config.networkId;
+  }
+
+  @Implementation
+  protected boolean saveConfiguration() {
+    wasSaved = true;
+    return true;
+  }
+
+  @Implementation
+  protected boolean enableNetwork(int netId, boolean attemptConnect) {
+    lastEnabledNetwork = new Pair<>(netId, attemptConnect);
+    enabledNetworks.add(netId);
+    return true;
+  }
+
+  @Implementation
+  protected boolean disableNetwork(int netId) {
+    return enabledNetworks.remove(netId);
+  }
+
+  @Implementation
+  protected WifiManager.WifiLock createWifiLock(int lockType, String tag) {
+    WifiManager.WifiLock wifiLock = ReflectionHelpers.callConstructor(WifiManager.WifiLock.class);
+    shadowOf(wifiLock).setWifiManager(wifiManager);
+    return wifiLock;
+  }
+
+  @Implementation
+  protected WifiManager.WifiLock createWifiLock(String tag) {
+    return createWifiLock(WifiManager.WIFI_MODE_FULL, tag);
+  }
+
+  @Implementation
+  protected MulticastLock createMulticastLock(String tag) {
+    MulticastLock multicastLock = ReflectionHelpers.callConstructor(MulticastLock.class);
+    shadowOf(multicastLock).setWifiManager(wifiManager);
+    return multicastLock;
+  }
+
+  @Implementation
+  protected static int calculateSignalLevel(int rssi, int numLevels) {
+    return (int) (sSignalLevelInPercent * (numLevels - 1));
+  }
+
+  /**
+   * Does nothing and returns the configured success status.
+   *
+   * <p>That is different from the Android implementation which always returns {@code true} up to
+   * and including Android 8, and either {@code true} or {@code false} on Android 9+.
+   *
+   * @return the value configured by {@link #setStartScanSucceeds}, or {@code true} if that method
+   *     was never called.
+   */
+  @Implementation
+  protected boolean startScan() {
+    if (getScanResults() != null && !getScanResults().isEmpty()) {
+      new Handler(Looper.getMainLooper())
+          .post(
+              () -> {
+                Intent intent = new Intent(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION);
+                RuntimeEnvironment.getApplication().sendBroadcast(intent);
+              });
+    }
+    return startScanSucceeds;
+  }
+
+  @Implementation
+  protected DhcpInfo getDhcpInfo() {
+    return dhcpInfo;
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected boolean isScanAlwaysAvailable() {
+    return Settings.Global.getInt(
+            getContext().getContentResolver(), Settings.Global.WIFI_SCAN_ALWAYS_AVAILABLE, 1)
+        == 1;
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = KITKAT)
+  protected void connect(WifiConfiguration wifiConfiguration, WifiManager.ActionListener listener) {
+    WifiInfo wifiInfo = getConnectionInfo();
+
+    String ssid = isQuoted(wifiConfiguration.SSID)
+        ? stripQuotes(wifiConfiguration.SSID)
+        : wifiConfiguration.SSID;
+
+    ShadowWifiInfo shadowWifiInfo = Shadow.extract(wifiInfo);
+    shadowWifiInfo.setSSID(ssid);
+    shadowWifiInfo.setBSSID(wifiConfiguration.BSSID);
+    shadowWifiInfo.setNetworkId(wifiConfiguration.networkId);
+    setConnectionInfo(wifiInfo);
+
+    // Now that we're "connected" to wifi, update Dhcp and point it to localhost.
+    DhcpInfo dhcpInfo = new DhcpInfo();
+    dhcpInfo.gateway = LOCAL_HOST;
+    dhcpInfo.ipAddress = LOCAL_HOST;
+    setDhcpInfo(dhcpInfo);
+
+    // Now add the network to ConnectivityManager.
+    NetworkInfo networkInfo =
+        ShadowNetworkInfo.newInstance(
+            NetworkInfo.DetailedState.CONNECTED,
+            ConnectivityManager.TYPE_WIFI,
+            0 /* subType */,
+            true /* isAvailable */,
+            true /* isConnected */);
+    ShadowConnectivityManager connectivityManager =
+        Shadow.extract(
+            RuntimeEnvironment.getApplication().getSystemService(Context.CONNECTIVITY_SERVICE));
+    connectivityManager.setActiveNetworkInfo(networkInfo);
+
+    if (listener != null) {
+      listener.onSuccess();
+    }
+  }
+
+  @HiddenApi
+  @Implementation(minSdk = KITKAT)
+  protected void connect(int networkId, WifiManager.ActionListener listener) {
+    WifiConfiguration wifiConfiguration = new WifiConfiguration();
+    wifiConfiguration.networkId = networkId;
+    wifiConfiguration.SSID = "";
+    wifiConfiguration.BSSID = "";
+    connect(wifiConfiguration, listener);
+  }
+
+  private static boolean isQuoted(String str) {
+    if (str == null || str.length() < 2) {
+      return false;
+    }
+
+    return str.charAt(0) == '"' && str.charAt(str.length() - 1) == '"';
+  }
+
+  private static String stripQuotes(String str) {
+    return str.substring(1, str.length() - 1);
+  }
+
+  @Implementation
+  protected boolean reconnect() {
+    WifiConfiguration wifiConfiguration = getMostRecentNetwork();
+    if (wifiConfiguration == null) {
+      return false;
+    }
+
+    connect(wifiConfiguration, null);
+    return true;
+  }
+
+  private WifiConfiguration getMostRecentNetwork() {
+    if (getLastEnabledNetwork() == null) {
+      return null;
+    }
+
+    return getWifiConfiguration(getLastEnabledNetwork().first);
+  }
+
+  public static void setSignalLevelInPercent(float level) {
+    if (level < 0 || level > 1) {
+      throw new IllegalArgumentException("level needs to be between 0 and 1");
+    }
+    sSignalLevelInPercent = level;
+  }
+
+  public void setAccessWifiStatePermission(boolean accessWifiStatePermission) {
+    this.accessWifiStatePermission = accessWifiStatePermission;
+  }
+
+  /**
+   * Prevents a networkId from being updated using the {@link updateNetwork(WifiConfiguration)}
+   * method. This is to simulate the case where a separate application creates a network, and the
+   * Android security model prevents your application from updating it.
+   */
+  public void setUpdateNetworkPermission(int networkId, boolean hasPermission) {
+    readOnlyNetworkIds.set(networkId, !hasPermission);
+  }
+
+  public void setScanResults(List<ScanResult> scanResults) {
+    this.scanResults = scanResults;
+  }
+
+  public void setDhcpInfo(DhcpInfo dhcpInfo) {
+    this.dhcpInfo = dhcpInfo;
+  }
+
+  public Pair<Integer, Boolean> getLastEnabledNetwork() {
+    return lastEnabledNetwork;
+  }
+
+  /** Whether the network is enabled or not. */
+  public boolean isNetworkEnabled(int netId) {
+    return enabledNetworks.contains(netId);
+  }
+
+  /** Returns the number of WifiLocks and MulticastLocks that are currently acquired. */
+  public int getActiveLockCount() {
+    return activeLockCount.get();
+  }
+
+  public boolean wasConfigurationSaved() {
+    return wasSaved;
+  }
+
+  public void setIsScanAlwaysAvailable(boolean isScanAlwaysAvailable) {
+    Settings.Global.putInt(
+        getContext().getContentResolver(),
+        Settings.Global.WIFI_SCAN_ALWAYS_AVAILABLE,
+        isScanAlwaysAvailable ? 1 : 0);
+  }
+
+  private void checkAccessWifiStatePermission() {
+    if (!accessWifiStatePermission) {
+      throw new SecurityException();
+    }
+  }
+
+  private WifiConfiguration makeCopy(WifiConfiguration config, int networkId) {
+    ShadowWifiConfiguration shadowWifiConfiguration = Shadow.extract(config);
+    WifiConfiguration copy = shadowWifiConfiguration.copy();
+    copy.networkId = networkId;
+    return copy;
+  }
+
+  public WifiConfiguration getWifiConfiguration(int netId) {
+    return networkIdToConfiguredNetworks.get(netId);
+  }
+
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  protected void addOnWifiUsabilityStatsListener(Object executorObject, Object listenerObject) {
+    Executor executor = (Executor) executorObject;
+    WifiManager.OnWifiUsabilityStatsListener listener =
+        (WifiManager.OnWifiUsabilityStatsListener) listenerObject;
+    wifiUsabilityStatsListeners.put(listener, executor);
+  }
+
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  protected void removeOnWifiUsabilityStatsListener(Object listenerObject) {
+    WifiManager.OnWifiUsabilityStatsListener listener =
+        (WifiManager.OnWifiUsabilityStatsListener) listenerObject;
+    wifiUsabilityStatsListeners.remove(listener);
+  }
+
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  protected void updateWifiUsabilityScore(int seqNum, int score, int predictionHorizonSec) {
+    synchronized (usabilityScores) {
+      usabilityScores.add(new WifiUsabilityScore(seqNum, score, predictionHorizonSec));
+    }
+  }
+
+  @Implementation
+  protected boolean setWifiApConfiguration(WifiConfiguration apConfig) {
+    this.apConfig = apConfig;
+    return true;
+  }
+
+  @Implementation
+  protected WifiConfiguration getWifiApConfiguration() {
+    return apConfig;
+  }
+
+  /**
+   * Returns wifi usability scores previous passed to {@link WifiManager#updateWifiUsabilityScore}
+   */
+  public List<WifiUsabilityScore> getUsabilityScores() {
+    synchronized (usabilityScores) {
+      return ImmutableList.copyOf(usabilityScores);
+    }
+  }
+
+  /**
+   * Clears wifi usability scores previous passed to {@link WifiManager#updateWifiUsabilityScore}
+   */
+  public void clearUsabilityScores() {
+    synchronized (usabilityScores) {
+      usabilityScores.clear();
+    }
+  }
+
+  /**
+   * Post Wifi stats to any listeners registered with {@link
+   * WifiManager#addOnWifiUsabilityStatsListener}
+   */
+  public void postUsabilityStats(
+      int seqNum, boolean isSameBssidAndFreq, WifiUsabilityStatsEntryBuilder statsBuilder) {
+    WifiUsabilityStatsEntry stats = statsBuilder.build();
+
+    Set<Map.Entry<WifiManager.OnWifiUsabilityStatsListener, Executor>> toNotify = new ArraySet<>();
+    toNotify.addAll(wifiUsabilityStatsListeners.entrySet());
+    for (Map.Entry<WifiManager.OnWifiUsabilityStatsListener, Executor> entry : toNotify) {
+      entry
+          .getValue()
+          .execute(
+              new Runnable() {
+                // Using a lambda here means loading the ShadowWifiManager class tries
+                // to load the WifiManager.OnWifiUsabilityStatsListener which fails if
+                // not building against a system API.
+                @Override
+                public void run() {
+                  entry.getKey().onWifiUsabilityStats(seqNum, isSameBssidAndFreq, stats);
+                }
+              });
+    }
+  }
+
+  private Context getContext() {
+    return ReflectionHelpers.getField(wifiManager, "mContext");
+  }
+
+  @Implements(WifiManager.WifiLock.class)
+  public static class ShadowWifiLock {
+    private int refCount;
+    private boolean refCounted = true;
+    private boolean locked;
+    private WifiManager wifiManager;
+    public static final int MAX_ACTIVE_LOCKS = 50;
+
+    private void setWifiManager(WifiManager wifiManager) {
+      this.wifiManager = wifiManager;
+    }
+
+    @Implementation
+    protected synchronized void acquire() {
+      if (wifiManager != null) {
+        shadowOf(wifiManager).activeLockCount.getAndIncrement();
+      }
+      if (refCounted) {
+        if (++refCount >= MAX_ACTIVE_LOCKS) {
+          throw new UnsupportedOperationException("Exceeded maximum number of wifi locks");
+        }
+      } else {
+        locked = true;
+      }
+    }
+
+    @Implementation
+    protected synchronized void release() {
+      if (wifiManager != null) {
+        shadowOf(wifiManager).activeLockCount.getAndDecrement();
+      }
+      if (refCounted) {
+        if (--refCount < 0) throw new RuntimeException("WifiLock under-locked");
+      } else {
+        locked = false;
+      }
+    }
+
+    @Implementation
+    protected synchronized boolean isHeld() {
+      return refCounted ? refCount > 0 : locked;
+    }
+
+    @Implementation
+    protected void setReferenceCounted(boolean refCounted) {
+      this.refCounted = refCounted;
+    }
+  }
+
+  @Implements(MulticastLock.class)
+  public static class ShadowMulticastLock {
+    private int refCount;
+    private boolean refCounted = true;
+    private boolean locked;
+    static final int MAX_ACTIVE_LOCKS = 50;
+    private WifiManager wifiManager;
+
+    private void setWifiManager(WifiManager wifiManager) {
+      this.wifiManager = wifiManager;
+    }
+
+    @Implementation
+    protected void acquire() {
+      if (wifiManager != null) {
+        shadowOf(wifiManager).activeLockCount.getAndIncrement();
+      }
+      if (refCounted) {
+        if (++refCount >= MAX_ACTIVE_LOCKS) {
+          throw new UnsupportedOperationException("Exceeded maximum number of wifi locks");
+        }
+      } else {
+        locked = true;
+      }
+    }
+
+    @Implementation
+    protected synchronized void release() {
+      if (wifiManager != null) {
+        shadowOf(wifiManager).activeLockCount.getAndDecrement();
+      }
+      if (refCounted) {
+        if (--refCount < 0) throw new RuntimeException("WifiLock under-locked");
+      } else {
+        locked = false;
+      }
+    }
+
+    @Implementation
+    protected void setReferenceCounted(boolean refCounted) {
+      this.refCounted = refCounted;
+    }
+
+    @Implementation
+    protected synchronized boolean isHeld() {
+      return refCounted ? refCount > 0 : locked;
+    }
+  }
+
+  private static ShadowWifiLock shadowOf(WifiManager.WifiLock o) {
+    return Shadow.extract(o);
+  }
+
+  private static ShadowMulticastLock shadowOf(WifiManager.MulticastLock o) {
+    return Shadow.extract(o);
+  }
+
+  private static ShadowWifiManager shadowOf(WifiManager o) {
+    return Shadow.extract(o);
+  }
+
+  /** Class to record scores passed to WifiManager#updateWifiUsabilityScore */
+  public static class WifiUsabilityScore {
+    public final int seqNum;
+    public final int score;
+    public final int predictionHorizonSec;
+
+    private WifiUsabilityScore(int seqNum, int score, int predictionHorizonSec) {
+      this.seqNum = seqNum;
+      this.score = score;
+      this.predictionHorizonSec = predictionHorizonSec;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiP2pGroup.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiP2pGroup.java
new file mode 100644
index 0000000..c66adb9
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiP2pGroup.java
@@ -0,0 +1,44 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.net.wifi.p2p.WifiP2pGroup;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(WifiP2pGroup.class)
+public class ShadowWifiP2pGroup {
+
+  @RealObject private WifiP2pGroup realObject;
+
+  @Implementation
+  public void setInterface(String intf) {
+    reflector(WifiP2pGroupReflector.class, realObject).setInterface(intf);
+  }
+
+  @Implementation
+  public void setPassphrase(String passphrase) {
+    reflector(WifiP2pGroupReflector.class, realObject).setInterface(passphrase);
+  }
+
+  @Implementation
+  public void setNetworkName(String networkName) {
+    reflector(WifiP2pGroupReflector.class, realObject).setInterface(networkName);
+  }
+
+  @ForType(WifiP2pGroup.class)
+  interface WifiP2pGroupReflector {
+
+    @Direct
+    void setInterface(String intf);
+
+    @Direct
+    void setPassphrase(String passphrase);
+
+    @Direct
+    void setNetworkName(String networkName);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiP2pManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiP2pManager.java
new file mode 100644
index 0000000..54e6b28
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiP2pManager.java
@@ -0,0 +1,108 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+
+import android.content.Context;
+import android.net.wifi.p2p.WifiP2pGroup;
+import android.net.wifi.p2p.WifiP2pManager;
+import android.net.wifi.p2p.WifiP2pManager.ActionListener;
+import android.net.wifi.p2p.WifiP2pManager.Channel;
+import android.os.Handler;
+import android.os.Looper;
+import com.google.common.base.Preconditions;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(WifiP2pManager.class)
+public class ShadowWifiP2pManager {
+
+  private static final int NO_FAILURE = -1;
+
+  private int listeningChannel;
+  private int operatingChannel;
+  private WifiP2pManager.GroupInfoListener groupInfoListener;
+  private Handler handler;
+  private int nextActionFailure = NO_FAILURE;
+  private Map<Channel, WifiP2pGroup> p2pGroupmap = new HashMap<>();
+
+  public int getListeningChannel() {
+    return listeningChannel;
+  }
+
+  public int getOperatingChannel() {
+    return operatingChannel;
+  }
+
+  public WifiP2pManager.GroupInfoListener getGroupInfoListener() {
+    return groupInfoListener;
+  }
+
+  @Implementation(minSdk = KITKAT)
+  protected void setWifiP2pChannels(
+      Channel c, int listeningChannel, int operatingChannel, ActionListener al) {
+    Preconditions.checkNotNull(c);
+    Preconditions.checkNotNull(al);
+    this.listeningChannel = listeningChannel;
+    this.operatingChannel = operatingChannel;
+  }
+
+  @Implementation
+  protected Channel initialize(
+      Context context, Looper looper, WifiP2pManager.ChannelListener listener) {
+    handler = new Handler(looper);
+    return ReflectionHelpers.newInstance(Channel.class);
+  }
+
+  @Implementation
+  protected void createGroup(Channel c, ActionListener al) {
+    postActionListener(al);
+  }
+
+  private void postActionListener(final ActionListener al) {
+    if (al == null) {
+      return;
+    }
+
+    handler.post(new Runnable() {
+      @Override
+      public void run() {
+        if (nextActionFailure == -1) {
+          al.onSuccess();
+        } else {
+          al.onFailure(nextActionFailure);
+        }
+        nextActionFailure = NO_FAILURE;
+      }
+    });
+  }
+
+  @Implementation
+  protected void requestGroupInfo(final Channel c, final WifiP2pManager.GroupInfoListener gl) {
+    if (gl == null) {
+      return;
+    }
+
+    handler.post(new Runnable() {
+      @Override
+      public void run() {
+        gl.onGroupInfoAvailable(p2pGroupmap.get(c));
+      }
+    });
+  }
+
+  @Implementation
+  protected void removeGroup(Channel c, ActionListener al) {
+    postActionListener(al);
+  }
+
+  public void setNextActionFailure(int nextActionFailure) {
+    this.nextActionFailure = nextActionFailure;
+  }
+
+  public void setGroupInfo(Channel channel, WifiP2pGroup wifiP2pGroup) {
+    p2pGroupmap.put(channel, wifiP2pGroup);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiRttManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiRttManager.java
new file mode 100644
index 0000000..9e2566a
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiRttManager.java
@@ -0,0 +1,50 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.P;
+
+import android.net.wifi.rtt.RangingRequest;
+import android.net.wifi.rtt.RangingResult;
+import android.net.wifi.rtt.RangingResultCallback;
+import android.net.wifi.rtt.WifiRttManager;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow for {@link android.net.wifi.rtt.WifiRttManager}. */
+@Implements(value = WifiRttManager.class, minSdk = P)
+public class ShadowWifiRttManager {
+  private List<RangingResult> rangingResults = new ArrayList<>();
+
+  /**
+   * If there are RangingResults set by the setRangeResults method of this shadow class, this method
+   * will call the onRangingResults method of the callback on the executor thread and pass the list
+   * of RangingResults. If there are no ranging results set, it will pass
+   * RangingResultCallback.STATUS_CODE_FAIL to the onRangingFailure method of the callback, also
+   * called on the executor thread.
+   */
+  @Implementation(minSdk = P)
+  protected void startRanging(
+      RangingRequest request, Executor executor, RangingResultCallback callback) {
+    if (!rangingResults.isEmpty()) {
+      executor.execute(() -> callback.onRangingResults(this.rangingResults));
+    } else {
+      executor.execute(() -> callback.onRangingFailure(RangingResultCallback.STATUS_CODE_FAIL));
+    }
+  }
+
+  /** Assumes the WifiRttManager is always available. */
+  @Implementation(minSdk = P)
+  protected boolean isAvailable() {
+    return true;
+  }
+
+  /**
+   * This method sets the RangingResults that are passed to the RangingResultCallback when the
+   * shadow startRanging method is called.
+   */
+  public void setRangeResults(List<RangingResult> rangingResults) {
+    this.rangingResults = rangingResults;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindow.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindow.java
new file mode 100644
index 0000000..91e1036
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindow.java
@@ -0,0 +1,155 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.KITKAT;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.N;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.view.FrameMetrics;
+import android.view.Window;
+import android.view.Window.OnFrameMetricsAvailableListener;
+import java.util.HashSet;
+import java.util.Set;
+import org.robolectric.annotation.HiddenApi;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(Window.class)
+public class ShadowWindow {
+  @RealObject private Window realWindow;
+
+  protected CharSequence title = "";
+  protected Drawable backgroundDrawable;
+  private int flags;
+  private int privateFlags;
+  private int softInputMode;
+  private final Set<OnFrameMetricsAvailableListener> onFrameMetricsAvailableListeners =
+      new HashSet<>();
+
+  public static Window create(Context context) throws ClassNotFoundException {
+    String className = getApiLevel() >= M
+        ? "com.android.internal.policy.PhoneWindow"
+        : "com.android.internal.policy.impl.PhoneWindow";
+    Class<? extends Window> phoneWindowClass =
+        (Class<? extends Window>) Window.class.getClassLoader().loadClass(className);
+    return ReflectionHelpers.callConstructor(phoneWindowClass, ClassParameter.from(Context.class, context));
+  }
+
+  @Implementation
+  protected void setFlags(int flags, int mask) {
+    this.flags = (this.flags & ~mask) | (flags & mask);
+    reflector(WindowReflector.class, realWindow).setFlags(flags, mask);
+  }
+
+  @Implementation(minSdk = Q)
+  @HiddenApi
+  protected void addSystemFlags(int flags) {
+    this.privateFlags |= flags;
+    reflector(WindowReflector.class, realWindow).addSystemFlags(flags);
+  }
+
+  @Implementation(minSdk = KITKAT, maxSdk = R)
+  @HiddenApi
+  protected void addPrivateFlags(int flags) {
+    this.privateFlags |= flags;
+    reflector(WindowReflector.class, realWindow).addPrivateFlags(flags);
+  }
+
+  @Implementation
+  protected void setSoftInputMode(int softInputMode) {
+    this.softInputMode = softInputMode;
+    reflector(WindowReflector.class, realWindow).setSoftInputMode(softInputMode);
+  }
+
+  public boolean getFlag(int flag) {
+    return (flags & flag) == flag;
+  }
+
+  /**
+   * Return the value from a private flag (a.k.a system flag).
+   *
+   * <p>Private flags can be set via either {@link #addPrivateFlags} (SDK 19-30) or {@link
+   * #addSystemFlags} (SDK 29+) methods.
+   */
+  public boolean getPrivateFlag(int flag) {
+    return (privateFlags & flag) == flag;
+  }
+
+  public CharSequence getTitle() {
+    return title;
+  }
+
+  public int getSoftInputMode() {
+    return softInputMode;
+  }
+
+  public Drawable getBackgroundDrawable() {
+    return backgroundDrawable;
+  }
+
+  @Implementation(minSdk = N)
+  protected void addOnFrameMetricsAvailableListener(
+      Window.OnFrameMetricsAvailableListener listener, Handler handler) {
+    onFrameMetricsAvailableListeners.add(listener);
+  }
+
+  @Implementation(minSdk = N)
+  protected void removeOnFrameMetricsAvailableListener(
+      Window.OnFrameMetricsAvailableListener listener) {
+    if (!onFrameMetricsAvailableListeners.remove(listener)) {
+      // Matches current behavior of android.
+      throw new IllegalArgumentException(
+          "attempt to remove OnFrameMetricsAvailableListener that was never added");
+    }
+  }
+
+  /**
+   * Calls {@link Window.OnFrameMetrisAvailableListener#onFrameMetricsAvailable()} on each current
+   * listener with 0 as the dropCountSinceLastInvocation.
+   */
+  public void reportOnFrameMetricsAvailable(FrameMetrics frameMetrics) {
+    reportOnFrameMetricsAvailable(frameMetrics, /* dropCountSinceLastInvocation= */ 0);
+  }
+
+  /**
+   * Calls {@link Window.OnFrameMetrisAvailableListener#onFrameMetricsAvailable()} on each current
+   * listener.
+   *
+   * @param frameMetrics the {@link FrameMetrics} instance passed to the listeners.
+   * @param dropCountSinceLastInvocation the dropCountSinceLastInvocation passed to the listeners.
+   */
+  public void reportOnFrameMetricsAvailable(
+      FrameMetrics frameMetrics, int dropCountSinceLastInvocation) {
+    for (OnFrameMetricsAvailableListener listener : onFrameMetricsAvailableListeners) {
+      listener.onFrameMetricsAvailable(realWindow, frameMetrics, dropCountSinceLastInvocation);
+    }
+  }
+
+  @ForType(Window.class)
+  interface WindowReflector {
+
+    @Direct
+    void setFlags(int flags, int mask);
+
+    @Direct
+    void addSystemFlags(int flags);
+
+    @Direct
+    void addPrivateFlags(int flags);
+
+    @Direct
+    void setSoftInputMode(int softInputMode);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManager.java
new file mode 100644
index 0000000..cf40fde
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManager.java
@@ -0,0 +1,8 @@
+package org.robolectric.shadows;
+
+import android.view.WindowManager;
+import org.robolectric.annotation.Implements;
+
+@Implements(WindowManager.class)
+public class ShadowWindowManager {
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManagerGlobal.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManagerGlobal.java
new file mode 100644
index 0000000..80b484c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManagerGlobal.java
@@ -0,0 +1,401 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1;
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
+import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
+import static android.os.Build.VERSION_CODES.M;
+import static android.os.Build.VERSION_CODES.P;
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static org.robolectric.shadows.ShadowView.useRealGraphics;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.app.Instrumentation;
+import android.content.ClipData;
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Binder;
+import android.os.Build.VERSION;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.view.DisplayCutout;
+import android.view.IWindow;
+import android.view.IWindowManager;
+import android.view.IWindowSession;
+import android.view.InputChannel;
+import android.view.InsetsSourceControl;
+import android.view.InsetsState;
+import android.view.InsetsVisibilities;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.View;
+import android.view.WindowManager;
+import android.view.WindowManagerGlobal;
+import androidx.annotation.Nullable;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.Static;
+
+/** Shadow for {@link WindowManagerGlobal}. */
+@SuppressWarnings("unused") // Unused params are implementations of Android SDK methods.
+@Implements(
+    value = WindowManagerGlobal.class,
+    isInAndroidSdk = false,
+    minSdk = JELLY_BEAN_MR1,
+    looseSignatures = true)
+public class ShadowWindowManagerGlobal {
+  private static WindowSessionDelegate windowSessionDelegate;
+  private static IWindowSession windowSession;
+
+  @Resetter
+  public static void reset() {
+    reflector(WindowManagerGlobalReflector.class).setDefaultWindowManager(null);
+    windowSessionDelegate = null;
+    windowSession = null;
+  }
+
+  private static synchronized WindowSessionDelegate getWindowSessionDelegate() {
+    if (windowSessionDelegate == null) {
+      int apiLevel = RuntimeEnvironment.getApiLevel();
+      if (apiLevel >= S_V2) {
+        windowSessionDelegate = new WindowSessionDelegateSV2();
+      } else if (apiLevel >= S) {
+        windowSessionDelegate = new WindowSessionDelegateS();
+      } else if (apiLevel >= R) {
+        windowSessionDelegate = new WindowSessionDelegateR();
+      } else if (apiLevel >= Q) {
+        windowSessionDelegate = new WindowSessionDelegateQ();
+      } else if (apiLevel >= P) {
+        windowSessionDelegate = new WindowSessionDelegateP();
+      } else if (apiLevel >= M) {
+        windowSessionDelegate = new WindowSessionDelegateM();
+      } else if (apiLevel >= LOLLIPOP_MR1) {
+        windowSessionDelegate = new WindowSessionDelegateLMR1();
+      } else if (apiLevel >= JELLY_BEAN_MR1) {
+        windowSessionDelegate = new WindowSessionDelegateJBMR1();
+      } else {
+        windowSessionDelegate = new WindowSessionDelegateJB();
+      }
+    }
+    return windowSessionDelegate;
+  }
+
+  public static boolean getInTouchMode() {
+    return getWindowSessionDelegate().getInTouchMode();
+  }
+
+  /**
+   * Sets whether the window manager is in touch mode. Use {@link
+   * Instrumentation#setInTouchMode(boolean)} to modify this from a test.
+   */
+  static void setInTouchMode(boolean inTouchMode) {
+    getWindowSessionDelegate().setInTouchMode(inTouchMode);
+  }
+
+  /**
+   * Returns the last {@link ClipData} passed to a drag initiated from a call to {@link
+   * View#startDrag} or {@link View#startDragAndDrop}, or null if there isn't one.
+   */
+  @Nullable
+  public static ClipData getLastDragClipData() {
+    return windowSessionDelegate != null ? windowSessionDelegate.lastDragClipData : null;
+  }
+
+  /** Clears the data returned by {@link #getLastDragClipData()}. */
+  public static void clearLastDragClipData() {
+    if (windowSessionDelegate != null) {
+      windowSessionDelegate.lastDragClipData = null;
+    }
+  }
+
+  @Implementation(minSdk = JELLY_BEAN_MR2)
+  protected static synchronized IWindowSession getWindowSession() {
+    if (windowSession == null) {
+      windowSession =
+          ReflectionHelpers.createDelegatingProxy(IWindowSession.class, getWindowSessionDelegate());
+    }
+    return windowSession;
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN_MR1)
+  protected static Object getWindowSession(Looper looper) {
+    return getWindowSession();
+  }
+
+  @Implementation
+  protected static synchronized IWindowSession peekWindowSession() {
+    return windowSession;
+  }
+
+  @Implementation
+  public static Object getWindowManagerService() throws RemoteException {
+    IWindowManager service =
+        reflector(WindowManagerGlobalReflector.class).getWindowManagerService();
+    if (service == null) {
+      service = IWindowManager.Stub.asInterface(ServiceManager.getService(Context.WINDOW_SERVICE));
+      reflector(WindowManagerGlobalReflector.class).setWindowManagerService(service);
+      if (VERSION.SDK_INT >= 30) {
+        reflector(WindowManagerGlobalReflector.class).setUseBlastAdapter(service.useBLAST());
+      }
+    }
+    return service;
+  }
+
+  @ForType(WindowManagerGlobal.class)
+  interface WindowManagerGlobalReflector {
+    @Accessor("sDefaultWindowManager")
+    @Static
+    void setDefaultWindowManager(WindowManagerGlobal global);
+
+    @Static
+    @Accessor("sWindowManagerService")
+    IWindowManager getWindowManagerService();
+
+    @Static
+    @Accessor("sWindowManagerService")
+    void setWindowManagerService(IWindowManager service);
+
+    @Static
+    @Accessor("sUseBLASTAdapter")
+    void setUseBlastAdapter(boolean useBlastAdapter);
+  }
+
+  private abstract static class WindowSessionDelegate {
+    // From WindowManagerGlobal (was WindowManagerImpl in JB).
+    static final int ADD_FLAG_IN_TOUCH_MODE = 0x1;
+    static final int ADD_FLAG_APP_VISIBLE = 0x2;
+
+    // TODO: Default to touch mode always.
+    private boolean inTouchMode = useRealGraphics();
+    @Nullable protected ClipData lastDragClipData;
+
+    protected int getAddFlags() {
+      int res = 0;
+      // Temporarily enable this based on a system property to allow for test migration. This will
+      // eventually be updated to default and true and eventually removed, Robolectric's previous
+      // behavior of not marking windows as visible by default is a bug. This flag should only be
+      // used as a temporary toggle during migration.
+      if (useRealGraphics()
+          || "true".equals(System.getProperty("robolectric.areWindowsMarkedVisible", "false"))) {
+        res |= ADD_FLAG_APP_VISIBLE;
+      }
+      if (getInTouchMode()) {
+        res |= ADD_FLAG_IN_TOUCH_MODE;
+      }
+      return res;
+    }
+
+    public boolean getInTouchMode() {
+      return inTouchMode;
+    }
+
+    public void setInTouchMode(boolean inTouchMode) {
+      this.inTouchMode = inTouchMode;
+    }
+
+    // @Implementation(maxSdk = O_MR1)
+    public IBinder prepareDrag(
+        IWindow window, int flags, int thumbnailWidth, int thumbnailHeight, Surface outSurface) {
+      return new Binder();
+    }
+
+    // @Implementation(maxSdk = M)
+    public boolean performDrag(
+        IWindow window,
+        IBinder dragToken,
+        float touchX,
+        float touchY,
+        float thumbCenterX,
+        float thumbCenterY,
+        ClipData data) {
+      lastDragClipData = data;
+      return true;
+    }
+
+    // @Implementation(minSdk = N, maxSdk = O_MR1)
+    public boolean performDrag(
+        IWindow window,
+        IBinder dragToken,
+        int touchSource,
+        float touchX,
+        float touchY,
+        float thumbCenterX,
+        float thumbCenterY,
+        ClipData data) {
+      lastDragClipData = data;
+      return true;
+    }
+  }
+
+  private static class WindowSessionDelegateJB extends WindowSessionDelegate {
+    // @Implementation(maxSdk = JELLY_BEAN)
+    public int add(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        Rect outContentInsets,
+        InputChannel outInputChannel) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateJBMR1 extends WindowSessionDelegateJB {
+    // @Implementation(minSdk = JELLY_BEAN_MR1, maxSdk = LOLLIPOP)
+    public int addToDisplay(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        Rect outContentInsets,
+        InputChannel outInputChannel) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateLMR1 extends WindowSessionDelegateJBMR1 {
+    // @Implementation(sdk = LOLLIPOP_MR1)
+    public int addToDisplay(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        Rect outContentInsets,
+        Rect outStableInsets,
+        InputChannel outInputChannel) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateM extends WindowSessionDelegateLMR1 {
+    // @Implementation(minSdk = M, maxSdk = O_MR1)
+    public int addToDisplay(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        Rect outContentInsets,
+        Rect outStableInsets,
+        Rect outInsets,
+        InputChannel outInputChannel) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateP extends WindowSessionDelegateM {
+    // @Implementation(sdk = P)
+    public int addToDisplay(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        Rect outFrame,
+        Rect outContentInsets,
+        Rect outStableInsets,
+        Rect outOutsets,
+        DisplayCutout.ParcelableWrapper displayCutout,
+        InputChannel outInputChannel) {
+      return getAddFlags();
+    }
+
+    // @Implementation(minSdk = P)
+    public IBinder performDrag(
+        IWindow window,
+        int flags,
+        SurfaceControl surface,
+        int touchSource,
+        float touchX,
+        float touchY,
+        float thumbCenterX,
+        float thumbCenterY,
+        ClipData data) {
+      lastDragClipData = data;
+      return new Binder();
+    }
+  }
+
+  private static class WindowSessionDelegateQ extends WindowSessionDelegateP {
+    // @Implementation(sdk = Q)
+    public int addToDisplay(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        Rect outFrame,
+        Rect outContentInsets,
+        Rect outStableInsets,
+        Rect outOutsets,
+        DisplayCutout.ParcelableWrapper displayCutout,
+        InputChannel outInputChannel,
+        InsetsState insetsState) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateR extends WindowSessionDelegateQ {
+    // @Implementation(sdk = R)
+    public int addToDisplayAsUser(
+        IWindow window,
+        int seq,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        int userId,
+        Rect outFrame,
+        Rect outContentInsets,
+        Rect outStableInsets,
+        DisplayCutout.ParcelableWrapper displayCutout,
+        InputChannel outInputChannel,
+        InsetsState insetsState,
+        InsetsSourceControl[] activeControls) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateS extends WindowSessionDelegateR {
+    // @Implementation(sdk = S)
+    public int addToDisplayAsUser(
+        IWindow window,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int layerStackId,
+        int userId,
+        InsetsState requestedVisibility,
+        InputChannel outInputChannel,
+        InsetsState insetsState,
+        InsetsSourceControl[] activeControls) {
+      return getAddFlags();
+    }
+  }
+
+  private static class WindowSessionDelegateSV2 extends WindowSessionDelegateS {
+    // @Implementation(minSdk = S_V2)
+    public int addToDisplayAsUser(
+        IWindow window,
+        WindowManager.LayoutParams attrs,
+        int viewVisibility,
+        int displayId,
+        int userId,
+        InsetsVisibilities requestedVisibilities,
+        InputChannel outInputChannel,
+        InsetsState outInsetsState,
+        InsetsSourceControl[] outActiveControls) {
+      return getAddFlags();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManagerImpl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManagerImpl.java
new file mode 100644
index 0000000..0f6972b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWindowManagerImpl.java
@@ -0,0 +1,175 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.JELLY_BEAN;
+import static android.os.Build.VERSION_CODES.R;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.view.View.SYSTEM_UI_FLAG_VISIBLE;
+import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING;
+import static org.robolectric.RuntimeEnvironment.getApiLevel;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Insets;
+import android.graphics.Rect;
+import android.os.Build.VERSION_CODES;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.DisplayCutout;
+import android.view.InsetsState;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+import android.view.WindowManagerImpl;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import java.util.HashMap;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowViewRootImpl.ViewRootImplReflector;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.Direct;
+import org.robolectric.util.reflector.ForType;
+
+@Implements(value = WindowManagerImpl.class, isInAndroidSdk = false)
+public class ShadowWindowManagerImpl extends ShadowWindowManager {
+
+  private static Display defaultDisplayJB;
+
+  @RealObject WindowManagerImpl realObject;
+  private static final Multimap<Integer, View> views = ArrayListMultimap.create();
+
+  // removed from WindowManagerImpl in S
+  public static final int NEW_INSETS_MODE_FULL = 2;
+
+  /** internal only */
+  public static void configureDefaultDisplayForJBOnly(
+      Configuration configuration, DisplayMetrics displayMetrics) {
+    Class<?> arg2Type =
+        ReflectionHelpers.loadClass(
+            ShadowWindowManagerImpl.class.getClassLoader(), "android.view.CompatibilityInfoHolder");
+
+    defaultDisplayJB =
+        ReflectionHelpers.callConstructor(
+            Display.class, ClassParameter.from(int.class, 0), ClassParameter.from(arg2Type, null));
+    ShadowDisplay shadowDisplay = Shadow.extract(defaultDisplayJB);
+    shadowDisplay.configureForJBOnly(configuration, displayMetrics);
+  }
+
+  @Implementation
+  public void addView(View view, android.view.ViewGroup.LayoutParams layoutParams) {
+    views.put(realObject.getDefaultDisplay().getDisplayId(), view);
+    // views.add(view);
+    reflector(ReflectorWindowManagerImpl.class, realObject).addView(view, layoutParams);
+  }
+
+  @Implementation
+  public void removeView(View view) {
+    views.remove(realObject.getDefaultDisplay().getDisplayId(), view);
+    reflector(ReflectorWindowManagerImpl.class, realObject).removeView(view);
+  }
+
+  @Implementation
+  protected void removeViewImmediate(View view) {
+    views.remove(realObject.getDefaultDisplay().getDisplayId(), view);
+    reflector(ReflectorWindowManagerImpl.class, realObject).removeViewImmediate(view);
+  }
+
+  public List<View> getViews() {
+    return ImmutableList.copyOf(views.get(realObject.getDefaultDisplay().getDisplayId()));
+  }
+
+  @Implementation(maxSdk = JELLY_BEAN)
+  public Display getDefaultDisplay() {
+    if (getApiLevel() > JELLY_BEAN) {
+      return reflector(ReflectorWindowManagerImpl.class, realObject).getDefaultDisplay();
+    } else {
+      return defaultDisplayJB;
+    }
+  }
+
+  @Implements(className = "android.view.WindowManagerImpl$CompatModeWrapper", maxSdk = JELLY_BEAN)
+  public static class ShadowCompatModeWrapper {
+    @Implementation(maxSdk = JELLY_BEAN)
+    protected Display getDefaultDisplay() {
+      return defaultDisplayJB;
+    }
+  }
+
+  /** Re implement to avoid server call */
+  @Implementation(minSdk = R, maxSdk = S_V2)
+  protected WindowInsets getWindowInsetsFromServer(WindowManager.LayoutParams attrs, Rect bounds) {
+    Context context = reflector(ReflectorWindowManagerImpl.class, realObject).getContext();
+    final Rect systemWindowInsets = new Rect();
+    final Rect stableInsets = new Rect();
+    final DisplayCutout.ParcelableWrapper displayCutout = new DisplayCutout.ParcelableWrapper();
+    final InsetsState insetsState = new InsetsState();
+    final boolean alwaysConsumeSystemBars = true;
+
+    final boolean isScreenRound = context.getResources().getConfiguration().isScreenRound();
+    if (getApiLevel() <= R
+        && reflector(ViewRootImplReflector.class).getNewInsetsMode() == NEW_INSETS_MODE_FULL) {
+      return ReflectionHelpers.callInstanceMethod(
+          insetsState,
+          "calculateInsets",
+          ClassParameter.from(Rect.class, bounds),
+          null,
+          ClassParameter.from(Boolean.TYPE, isScreenRound),
+          ClassParameter.from(Boolean.TYPE, alwaysConsumeSystemBars),
+          ClassParameter.from(DisplayCutout.ParcelableWrapper.class, displayCutout.get()),
+          ClassParameter.from(int.class, SOFT_INPUT_ADJUST_NOTHING),
+          ClassParameter.from(int.class, SYSTEM_UI_FLAG_VISIBLE),
+          null);
+    } else {
+      return new WindowInsets.Builder()
+          .setAlwaysConsumeSystemBars(alwaysConsumeSystemBars)
+          .setRound(isScreenRound)
+          .setSystemWindowInsets(Insets.of(systemWindowInsets))
+          .setStableInsets(Insets.of(stableInsets))
+          .setDisplayCutout(displayCutout.get())
+          .build();
+    }
+  }
+
+  @ForType(WindowManagerImpl.class)
+  interface ReflectorWindowManagerImpl {
+
+    @Direct
+    void addView(View view, ViewGroup.LayoutParams layoutParams);
+
+    @Direct
+    void removeView(View view);
+
+    @Direct
+    void removeViewImmediate(View view);
+
+    @Direct
+    Display getDefaultDisplay();
+
+    @Accessor("mContext")
+    Context getContext();
+  }
+
+  @Resetter
+  public static void reset() {
+    defaultDisplayJB = null;
+    views.clear();
+    if (getApiLevel() <= VERSION_CODES.JELLY_BEAN) {
+      ReflectionHelpers.setStaticField(
+          WindowManagerImpl.class,
+          "sWindowManager",
+          ReflectionHelpers.newInstance(WindowManagerImpl.class));
+      HashMap windowManagers =
+          ReflectionHelpers.getStaticField(WindowManagerImpl.class, "sCompatWindowManagers");
+      windowManagers.clear();
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowXmlBlock.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowXmlBlock.java
new file mode 100644
index 0000000..9f0496d
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowXmlBlock.java
@@ -0,0 +1,385 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.res.android.Errors.NO_ERROR;
+
+import android.os.Build.VERSION_CODES;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.res.android.Ref;
+import org.robolectric.res.android.Registries;
+import org.robolectric.res.android.ResXMLParser;
+import org.robolectric.res.android.ResXMLTree;
+import org.robolectric.res.android.ResourceTypes.Res_value;
+import org.xmlpull.v1.XmlPullParserException;
+
+@Implements(className = "android.content.res.XmlBlock", isInAndroidSdk = false)
+public class ShadowXmlBlock {
+
+  @Implementation
+  protected static Number nativeCreate(byte[] bArray, int off, int len) {
+    if (bArray == null) {
+      throw new NullPointerException();
+    }
+
+    int bLen = bArray.length;
+    if (off < 0 || off >= bLen || len < 0 || len > bLen || (off+len) > bLen) {
+      throw new IndexOutOfBoundsException();
+    }
+
+    // todo: optimize
+    byte[] b = new byte[len];
+    System.arraycopy(bArray, off, b, 0, len);
+
+    ResXMLTree osb = new ResXMLTree(null);
+    osb.setTo(b, len, true);
+//    env->ReleaseByteArrayElements(bArray, b, 0);
+
+    if (osb.getError() != NO_ERROR) {
+      throw new IllegalArgumentException();
+    }
+
+    return RuntimeEnvironment.castNativePtr(Registries.NATIVE_RES_XML_TREES.register(osb));
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetStringBlock(int obj) {
+    return (int)nativeGetStringBlock((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static Number nativeGetStringBlock(long obj) {
+    ResXMLTree osb = Registries.NATIVE_RES_XML_TREES.getNativeObject(obj);
+//    if (osb == NULL) {
+//      jniThrowNullPointerException(env, NULL);
+//      return 0;
+//    }
+
+    return RuntimeEnvironment.castNativePtr(osb.getStrings().getNativePtr());
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeCreateParseState(int obj) {
+    return (int)nativeCreateParseState((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP, maxSdk = VERSION_CODES.P)
+  protected static long nativeCreateParseState(long obj) {
+    ResXMLTree osb = Registries.NATIVE_RES_XML_TREES.getNativeObject(obj);
+//    if (osb == NULL) {
+//      jniThrowNullPointerException(env, NULL);
+//      return 0;
+//    }
+
+    ResXMLParser st = new ResXMLParser(osb);
+//    if (st == NULL) {
+//      jniThrowException(env, "java/lang/OutOfMemoryError", NULL);
+//      return 0;
+//    }
+
+    st.restart();
+
+    return Registries.NATIVE_RES_XML_PARSERS.register(st);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.Q)
+  protected static long nativeCreateParseState(long obj, int resid) {
+    ResXMLTree osb = Registries.NATIVE_RES_XML_TREES.getNativeObject(obj);
+//    if (osb == NULL) {
+//      jniThrowNullPointerException(env, NULL);
+//      return 0;
+//    }
+
+    ResXMLParser st = new ResXMLParser(osb);
+    //    if (st == NULL) {
+    //      jniThrowException(env, "java/lang/OutOfMemoryError", NULL);
+    //      return 0;
+    //    }
+
+    st.setSourceResourceId(resid);
+    st.restart();
+
+    return Registries.NATIVE_RES_XML_PARSERS.register(st);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeNext(int state) throws XmlPullParserException {
+    return (int)nativeNext((long)state);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeNext(long state) throws XmlPullParserException {
+    ResXMLParser st = getResXMLParser(state);
+    if (st == null) {
+      return ResXMLParser.event_code_t.END_DOCUMENT;
+    }
+
+    do {
+      int code = st.next();
+      switch (code) {
+        case ResXMLParser.event_code_t.START_TAG:
+          return 2;
+        case ResXMLParser.event_code_t.END_TAG:
+          return 3;
+        case ResXMLParser.event_code_t.TEXT:
+          return 4;
+        case ResXMLParser.event_code_t.START_DOCUMENT:
+          return 0;
+        case ResXMLParser.event_code_t.END_DOCUMENT:
+          return 1;
+        case ResXMLParser.event_code_t.BAD_DOCUMENT:
+//                goto bad;
+          throw new XmlPullParserException("Corrupt XML binary file");
+        default:
+          break;
+      }
+
+    } while (true);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetNamespace(int state) {
+    return nativeGetNamespace((long)state);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetNamespace(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    if (resXMLParser == null) {
+      return -1;
+    }
+    return resXMLParser.getElementNamespaceID();
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetName(int state) {
+    return nativeGetName((long) state);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetName(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    if (resXMLParser == null) {
+      return -1;
+    }
+    return resXMLParser.getElementNameID();
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetText(int state) {
+    return nativeGetText((long) state);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetText(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    if (resXMLParser == null) {
+      return -1;
+    }
+    return resXMLParser.getTextID();
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetLineNumber(int state) {
+    return (int)nativeGetLineNumber((long)state);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetLineNumber(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getLineNumber();
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeCount(int state) {
+    return (int)nativeGetAttributeCount((long)state);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeCount(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeCount();
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeNamespace(int state, int idx) {
+    return (int)nativeGetAttributeNamespace((long)state, idx);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeNamespace(long state, int idx) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeNamespaceID(idx);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeName(int state, int idx) {
+    return (int)nativeGetAttributeName((long) state, idx);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeName(long state, int idx) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeNameID(idx);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeResource(int state, int idx) {
+    return (int)nativeGetAttributeResource((long)state, idx);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeResource(long state, int idx) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeNameResID(idx);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeDataType(int state, int idx) {
+    return (int)nativeGetAttributeDataType((long)state, idx);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeDataType(long state, int idx) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeDataType(idx);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeData(int state, int idx) {
+    return (int)nativeGetAttributeData((long)state, idx);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeData(long state, int idx) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeData(idx);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeStringValue(int state, int idx) {
+    return (int)nativeGetAttributeStringValue((long)state, idx);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeStringValue(long state, int idx) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    return resXMLParser.getAttributeValueStringID(idx);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetIdAttribute(int obj) {
+    return (int)nativeGetIdAttribute((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetIdAttribute(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    int idx = resXMLParser.indexOfID();
+    return idx >= 0 ? resXMLParser.getAttributeValueStringID(idx) : -1;
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetClassAttribute(int obj) {
+    return (int)nativeGetClassAttribute((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetClassAttribute(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    int idx = resXMLParser.indexOfClass();
+    return idx >= 0 ? resXMLParser.getAttributeValueStringID(idx) : -1;
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetStyleAttribute(int obj) {
+    return (int)nativeGetStyleAttribute((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetStyleAttribute(long state) {
+    ResXMLParser resXMLParser = getResXMLParser(state);
+    int idx = resXMLParser.indexOfStyle();
+    if (idx < 0) {
+      return 0;
+    }
+
+    final Ref<Res_value> valueRef = new Ref<>(new Res_value());
+    if (resXMLParser.getAttributeValue(idx, valueRef) < 0) {
+      return 0;
+    }
+    Res_value value = valueRef.get();
+
+    return value.dataType == org.robolectric.res.android.ResourceTypes.Res_value.TYPE_REFERENCE
+        || value.dataType == org.robolectric.res.android.ResourceTypes.Res_value.TYPE_ATTRIBUTE
+        ? value.data : 0;
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static int nativeGetAttributeIndex(int obj, String ns, String name) {
+    return (int)nativeGetAttributeIndex((long)obj, ns, name);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static int nativeGetAttributeIndex(long token, String ns, String name) {
+    ResXMLParser st = getResXMLParser(token);
+    if (st == null || name == null) {
+      throw new NullPointerException();
+    }
+
+    int nsLen = 0;
+    if (ns != null) {
+      nsLen = ns.length();
+    }
+
+    return st.indexOfAttribute(ns, nsLen, name, name.length());
+  }
+
+  @Implementation(minSdk = VERSION_CODES.Q)
+  protected static int nativeGetSourceResId(long state) {
+    ResXMLParser st = getResXMLParser(state);
+    if (st == null) {
+      return 0;
+    } else {
+      return st.getSourceResourceId();
+    }
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static void nativeDestroyParseState(int obj) {
+    nativeDestroyParseState((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static void nativeDestroyParseState(long state) {
+    Registries.NATIVE_RES_XML_PARSERS.unregister(state);
+  }
+
+  @Implementation(maxSdk = VERSION_CODES.KITKAT_WATCH)
+  protected static void nativeDestroy(int obj) {
+    nativeDestroy((long)obj);
+  }
+
+  @Implementation(minSdk = VERSION_CODES.LOLLIPOP)
+  protected static void nativeDestroy(long obj) {
+    Registries.NATIVE_RES_XML_TREES.unregister(obj);
+  }
+
+  private static ResXMLParser getResXMLParser(long state) {
+    return Registries.NATIVE_RES_XML_PARSERS.peekNativeObject(state);
+  }
+
+  /** Shadow of XmlBlock.Parser. */
+  @Implements(className = "android.content.res.XmlBlock$Parser", isInAndroidSdk = false)
+  public static class ShadowParser {
+    private int sourceResourceId;
+
+    void setSourceResourceId(int sourceResourceId) {
+      this.sourceResourceId = sourceResourceId;
+    }
+
+    int getSourceResourceId() {
+      return sourceResourceId;
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowZoomButtonsController.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowZoomButtonsController.java
new file mode 100644
index 0000000..2642083
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowZoomButtonsController.java
@@ -0,0 +1,28 @@
+package org.robolectric.shadows;
+
+import android.view.View;
+import android.widget.ZoomButtonsController;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(ZoomButtonsController.class)
+public class ShadowZoomButtonsController {
+  private ZoomButtonsController.OnZoomListener listener;
+
+  @Implementation
+  protected void __constructor__(View ownerView) {}
+
+  @Implementation
+  protected void setOnZoomListener(ZoomButtonsController.OnZoomListener listener) {
+    this.listener = listener;
+  }
+
+  public void simulateZoomInButtonClick() {
+    listener.onZoom(true);
+  }
+
+  public void simulateZoomOutButtonClick() {
+    listener.onZoom(false);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/StorageVolumeBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/StorageVolumeBuilder.java
new file mode 100644
index 0000000..c1b38ed
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/StorageVolumeBuilder.java
@@ -0,0 +1,162 @@
+package org.robolectric.shadows;
+
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import android.os.storage.StorageVolume;
+import java.io.File;
+import java.util.UUID;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Class to build {@link StorageVolume} */
+public final class StorageVolumeBuilder {
+
+  private final String id;
+  private int storageId = 0;
+  private final File path;
+  private File internalPath;
+  private final String description;
+  private boolean primary = true;
+  private boolean removable = false;
+  private boolean emulated = false;
+  private long mtpReserveSize = 00L;
+  private boolean externallyManaged = false;
+  private boolean allowMassStorage = false;
+  private long maxFileSize = 100L;
+  private final UserHandle owner;
+  private String fsUuid = UUID.randomUUID().toString();
+  private final UUID uuid = UUID.randomUUID();
+  private final String state;
+
+  public StorageVolumeBuilder(
+      String id, File path, String description, UserHandle owner, String state) {
+    this.id = id;
+    this.path = path;
+    this.internalPath = path;
+    this.description = description;
+    this.owner = owner;
+    this.state = state;
+  }
+
+  public StorageVolumeBuilder setStorageId(int storageId) {
+    this.storageId = storageId;
+    return this;
+  }
+
+  public StorageVolumeBuilder setIsPrimary(boolean isPrimary) {
+    this.primary = isPrimary;
+    return this;
+  }
+
+  public StorageVolumeBuilder setIsRemovable(boolean isRemovable) {
+    this.removable = isRemovable;
+    return this;
+  }
+
+  public StorageVolumeBuilder setIsEmulated(boolean isEmulated) {
+    this.emulated = isEmulated;
+    return this;
+  }
+
+  public StorageVolumeBuilder setMtpReserveSize(long mtpReserveSize) {
+    this.mtpReserveSize = mtpReserveSize;
+    return this;
+  }
+
+  public StorageVolumeBuilder setExternallyManaged(boolean externallyManaged) {
+    this.externallyManaged = externallyManaged;
+    return this;
+  }
+
+  public StorageVolumeBuilder setAllowMassStorage(boolean allowMassStorage) {
+    this.allowMassStorage = allowMassStorage;
+    return this;
+  }
+
+  public StorageVolumeBuilder setMaxFileSize(long maxFileSize) {
+    this.maxFileSize = maxFileSize;
+    return this;
+  }
+
+  public StorageVolumeBuilder setFsUuid(String fsUuid) {
+    this.fsUuid = fsUuid;
+    return this;
+  }
+
+  public StorageVolumeBuilder setInternalPath(File internalPath) {
+    this.internalPath = internalPath;
+    return this;
+  }
+
+  public StorageVolume build() throws IllegalStateException {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel >= VERSION_CODES.N && apiLevel < VERSION_CODES.P) {
+      return ReflectionHelpers.callConstructor(
+          StorageVolume.class,
+          from(String.class, id), // String id,
+          from(int.class, storageId), // int storageId
+          from(File.class, path), // File path,
+          from(String.class, description), // String description
+          from(boolean.class, primary), // boolean primary,
+          from(boolean.class, removable), // boolean removable,
+          from(boolean.class, emulated), // boolean emulated,
+          from(long.class, mtpReserveSize), //  long mtpReserveSize,
+          from(boolean.class, allowMassStorage),
+          from(long.class, maxFileSize), // long maxFileSize,
+          from(UserHandle.class, owner), // UserHandle owner,
+          from(String.class, fsUuid), //  String fsUuid,
+          from(String.class, state)); // String state
+    } else if (apiLevel >= VERSION_CODES.P && apiLevel <= VERSION_CODES.R) {
+      return ReflectionHelpers.callConstructor(
+          StorageVolume.class,
+          from(String.class, id), // String id,
+          from(File.class, path), // File path,
+          from(File.class, internalPath), // File internalPath
+          from(String.class, description), // String description
+          from(boolean.class, primary), // boolean primary,
+          from(boolean.class, removable), // boolean removable,
+          from(boolean.class, emulated), // boolean emulated,
+          from(boolean.class, allowMassStorage), //  boolean allowMassStorage,
+          from(long.class, maxFileSize), // long maxFileSize,
+          from(UserHandle.class, owner), // UserHandle owner,
+          from(String.class, fsUuid), //  String fsUuid,
+          from(String.class, state)); // String state
+    } else if (apiLevel > VERSION_CODES.R && apiLevel <= VERSION_CODES.S_V2) {
+      return ReflectionHelpers.callConstructor(
+          StorageVolume.class,
+          from(String.class, id), // String id,
+          from(File.class, path), // File path,
+          from(File.class, internalPath), // File internalPath
+          from(String.class, description), // String description
+          from(boolean.class, primary), // boolean primary,
+          from(boolean.class, removable), // boolean removable,
+          from(boolean.class, emulated), // boolean emulated,
+          from(boolean.class, allowMassStorage), //  boolean allowMassStorage,
+          from(long.class, maxFileSize), // long maxFileSize,
+          from(UserHandle.class, owner), // UserHandle owner,
+          from(UUID.class, uuid), // UUID uuid
+          from(String.class, fsUuid), //  String fsUuid,
+          from(String.class, state)); // String state
+    } else if (apiLevel >= 33) {
+      return ReflectionHelpers.callConstructor(
+          StorageVolume.class,
+          from(String.class, id), // String id,
+          from(File.class, path), // File path,
+          from(File.class, internalPath), // File internalPath
+          from(String.class, description), // String description
+          from(boolean.class, primary), // boolean primary,
+          from(boolean.class, removable), // boolean removable,
+          from(boolean.class, emulated), // boolean emulated,
+          from(boolean.class, externallyManaged), // boolean externallyManaged,
+          from(boolean.class, allowMassStorage), //  boolean allowMassStorage,
+          from(long.class, maxFileSize), // long maxFileSize,
+          from(UserHandle.class, owner), // UserHandle owner,
+          from(UUID.class, uuid), // UUID uuid
+          from(String.class, fsUuid), //  String fsUuid,
+          from(String.class, state)); // String state
+    }
+    throw new IllegalStateException("StorageVolume hidden constructor not found");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/StreamConfigurationMapBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/StreamConfigurationMapBuilder.java
new file mode 100644
index 0000000..b6e64d2
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/StreamConfigurationMapBuilder.java
@@ -0,0 +1,126 @@
+package org.robolectric.shadows;
+
+import android.hardware.camera2.params.StreamConfiguration;
+import android.hardware.camera2.params.StreamConfigurationMap;
+import android.os.Build.VERSION_CODES;
+import android.util.Size;
+import android.util.SparseIntArray;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Builder for StreamConfigurationMap */
+public final class StreamConfigurationMapBuilder {
+  // from system/core/include/system/graphics.h
+  private static final int HAL_PIXEL_FORMAT_IMPLEMENTATION_DEFINED = 0x22;
+
+  private final HashMap<Integer, Collection<Size>> inputFormatWithSupportedSize = new HashMap<>();
+  private final HashMap<Integer, Collection<Size>> outputFormatWithSupportedSize = new HashMap<>();
+
+  /** Create a new {@link StreamConfigurationMapBuilder}. */
+  public static StreamConfigurationMapBuilder newBuilder() {
+    return new StreamConfigurationMapBuilder();
+  }
+
+  /**
+   * Adds an output size to be returned by {@link StreamConfigurationMap#getOutputSizes} for the
+   * provided format.
+   *
+   * <p>The provided format must be one of the formats defined in {@link ImageFormat} or {@link
+   * PixelFormat}.
+   */
+  public StreamConfigurationMapBuilder addOutputSize(int format, Size outputSize) {
+    if (!outputFormatWithSupportedSize.containsKey(format)) {
+      Collection<Size> outputSizes = new ArrayList<>();
+      outputFormatWithSupportedSize.put(format, outputSizes);
+    }
+    outputFormatWithSupportedSize.get(format).add(outputSize);
+    return this;
+  }
+
+  /**
+   * Adds an input size to be returned by {@link StreamConfigurationMap#getInputSizes} for the
+   * provided format.
+   *
+   * <p>The provided format must be one of the formats defined in {@link ImageFormat} or {@link
+   * PixelFormat}.
+   */
+  public StreamConfigurationMapBuilder addInputSize(int format, Size inputSize) {
+    if (!inputFormatWithSupportedSize.containsKey(format)) {
+      List<Size> inputSizes = new ArrayList<>();
+      inputFormatWithSupportedSize.put(format, inputSizes);
+    }
+    inputFormatWithSupportedSize.get(format).add(inputSize);
+    return this;
+  }
+
+  /**
+   * Adds an output size to be returned by {@link StreamConfigurationMap#getOutputSizes}.
+   *
+   * <p>Calling this method is equivalent to calling {@link addOutputSize(int, Size)} with format
+   * {@link ImageFormat#PRIVATE}.
+   */
+  public StreamConfigurationMapBuilder addOutputSize(Size outputSize) {
+    addOutputSize(HAL_PIXEL_FORMAT_IMPLEMENTATION_DEFINED, outputSize);
+    return this;
+  }
+
+  /** Builds a StreamConfigurationMap based on data previously added to this builder. */
+  public StreamConfigurationMap build() {
+    Collection<StreamConfiguration> configsList = new ArrayList<>();
+
+    for (Map.Entry<Integer, Collection<Size>> entry : outputFormatWithSupportedSize.entrySet()) {
+      for (Size size : entry.getValue()) {
+        configsList.add(
+            new StreamConfiguration(
+                entry.getKey(), size.getWidth(), size.getHeight(), /*input=*/ false));
+      }
+    }
+
+    for (Map.Entry<Integer, Collection<Size>> entry : inputFormatWithSupportedSize.entrySet()) {
+      for (Size size : entry.getValue()) {
+        configsList.add(
+            new StreamConfiguration(
+                entry.getKey(), size.getWidth(), size.getHeight(), /*input=*/ true));
+      }
+    }
+
+    StreamConfiguration[] configs = new StreamConfiguration[configsList.size()];
+    configsList.toArray(configs);
+
+    StreamConfigurationMap map = ReflectionHelpers.callConstructor(StreamConfigurationMap.class);
+    ReflectionHelpers.setField(StreamConfigurationMap.class, map, "mConfigurations", configs);
+
+    if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.M) {
+      HashMap<Integer, Integer> outputFormats = new HashMap<>();
+      for (int format : outputFormatWithSupportedSize.keySet()) {
+        outputFormats.put(format, outputFormatWithSupportedSize.get(format).size());
+      }
+      ReflectionHelpers.setField(
+          StreamConfigurationMap.class, map, "mOutputFormats", outputFormats);
+    } else {
+      SparseIntArray outputFormats = new SparseIntArray();
+      for (int format : outputFormatWithSupportedSize.keySet()) {
+        outputFormats.put(format, outputFormatWithSupportedSize.get(format).size());
+      }
+      ReflectionHelpers.setField(
+          StreamConfigurationMap.class, map, "mOutputFormats", outputFormats);
+      ReflectionHelpers.setField(
+          StreamConfigurationMap.class, map, "mAllOutputFormats", outputFormats);
+
+      // Add input formats for reprocessing
+      SparseIntArray inputFormats = new SparseIntArray();
+      for (int format : inputFormatWithSupportedSize.keySet()) {
+        inputFormats.put(format, inputFormatWithSupportedSize.get(format).size());
+      }
+      ReflectionHelpers.setField(StreamConfigurationMap.class, map, "mInputFormats", inputFormats);
+    }
+    return map;
+  }
+
+  private StreamConfigurationMapBuilder() {}
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/SystemFeatureListInitializer.java b/shadows/framework/src/main/java/org/robolectric/shadows/SystemFeatureListInitializer.java
new file mode 100644
index 0000000..23dc71c
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/SystemFeatureListInitializer.java
@@ -0,0 +1,30 @@
+package org.robolectric.shadows;
+
+import android.content.pm.PackageManager;
+import android.os.Build.VERSION_CODES;
+import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.RuntimeEnvironment;
+
+final class SystemFeatureListInitializer {
+
+  public static ImmutableMap<String, Boolean> getSystemFeatures() {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    Map<String, Boolean> features = new HashMap<>();
+
+    if (apiLevel >= VERSION_CODES.N_MR1) {
+      features.put(PackageManager.FEATURE_WIFI, true);
+    }
+
+    if (apiLevel >= VERSION_CODES.O) {
+      features.put(PackageManager.FEATURE_WIFI_AWARE, true);
+    }
+
+    if (apiLevel >= VERSION_CODES.P) {
+      features.put(PackageManager.FEATURE_WIFI_DIRECT, true);
+    }
+
+    return ImmutableMap.copyOf(features);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/UiccCardInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/UiccCardInfoBuilder.java
new file mode 100644
index 0000000..77db260
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/UiccCardInfoBuilder.java
@@ -0,0 +1,126 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.UiccCardInfo;
+import android.telephony.UiccPortInfo;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Builder for {@link UiccCardInfo} which includes modifications made in Android T to support MEP.
+ */
+@RequiresApi(VERSION_CODES.Q)
+public class UiccCardInfoBuilder {
+
+  private int cardId;
+  private String eid;
+  private String iccId;
+  private int slotIndex;
+  private int physicalSlotIndex;
+  private List<UiccPortInfo> portList = new ArrayList<>();
+  private boolean isEuicc;
+  private boolean isMultipleEnabledProfilesSupported;
+  private boolean isRemovable;
+
+  private UiccCardInfoBuilder() {}
+
+  public static UiccCardInfoBuilder newBuilder() {
+    return new UiccCardInfoBuilder();
+  }
+
+  @CanIgnoreReturnValue
+  public UiccCardInfoBuilder setCardId(int cardId) {
+    this.cardId = cardId;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccCardInfoBuilder setEid(String eid) {
+    this.eid = eid;
+    return this;
+  }
+
+  /**
+   * @deprecated This is no longer set on T+ due to MEP as a single eUICC can have more than one
+   *     ICCID tied to it. It is instead set via {@code UiccPortInfo}.
+   */
+  @CanIgnoreReturnValue
+  @Deprecated
+  public UiccCardInfoBuilder setIccId(String iccId) {
+    this.iccId = iccId;
+    return this;
+  }
+
+  /**
+   * @deprecated Use {@link setPhysicalSlotIndex} for Android T+ instead.
+   */
+  @CanIgnoreReturnValue
+  @Deprecated
+  public UiccCardInfoBuilder setSlotIndex(int slotIndex) {
+    this.slotIndex = slotIndex;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  @RequiresApi(VERSION_CODES.TIRAMISU)
+  public UiccCardInfoBuilder setPhysicalSlotIndex(int physicalSlotIndex) {
+    this.physicalSlotIndex = physicalSlotIndex;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  @RequiresApi(VERSION_CODES.TIRAMISU)
+  public UiccCardInfoBuilder setPorts(@NonNull List<UiccPortInfo> portList) {
+    this.portList = portList;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  @RequiresApi(VERSION_CODES.TIRAMISU)
+  public UiccCardInfoBuilder setIsMultipleEnabledProfilesSupported(
+      boolean isMultipleEnabledProfilesSupported) {
+    this.isMultipleEnabledProfilesSupported = isMultipleEnabledProfilesSupported;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccCardInfoBuilder setIsEuicc(boolean isEuicc) {
+    this.isEuicc = isEuicc;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccCardInfoBuilder setIsRemovable(boolean isRemovable) {
+    this.isRemovable = isRemovable;
+    return this;
+  }
+
+  public UiccCardInfo build() {
+    if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.TIRAMISU) {
+      return ReflectionHelpers.callConstructor(
+          UiccCardInfo.class,
+          ClassParameter.from(boolean.class, isEuicc),
+          ClassParameter.from(int.class, cardId),
+          ClassParameter.from(String.class, eid),
+          ClassParameter.from(String.class, iccId),
+          ClassParameter.from(int.class, slotIndex),
+          ClassParameter.from(boolean.class, isRemovable));
+    }
+    // T added the UiccPortInfo list and deprecated some top-level fields.
+    return ReflectionHelpers.callConstructor(
+        UiccCardInfo.class,
+        ClassParameter.from(boolean.class, isEuicc),
+        ClassParameter.from(int.class, cardId),
+        ClassParameter.from(String.class, eid),
+        ClassParameter.from(int.class, physicalSlotIndex),
+        ClassParameter.from(boolean.class, isRemovable),
+        ClassParameter.from(boolean.class, isMultipleEnabledProfilesSupported),
+        ClassParameter.from(List.class, portList));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/UiccPortInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/UiccPortInfoBuilder.java
new file mode 100644
index 0000000..23b45b0
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/UiccPortInfoBuilder.java
@@ -0,0 +1,57 @@
+package org.robolectric.shadows;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.UiccPortInfo;
+import androidx.annotation.RequiresApi;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link UiccPortInfo} which was introduced in Android T. */
+@RequiresApi(VERSION_CODES.TIRAMISU)
+public class UiccPortInfoBuilder {
+
+  private String iccId;
+  private int portIndex;
+  private int logicalSlotIndex;
+  private boolean isActive;
+
+  private UiccPortInfoBuilder() {}
+
+  public static UiccPortInfoBuilder newBuilder() {
+    return new UiccPortInfoBuilder();
+  }
+
+  @CanIgnoreReturnValue
+  public UiccPortInfoBuilder setIccId(String iccId) {
+    this.iccId = iccId;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccPortInfoBuilder setPortIndex(int portIndex) {
+    this.portIndex = portIndex;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccPortInfoBuilder setLogicalSlotIndex(int logicalSlotIndex) {
+    this.logicalSlotIndex = logicalSlotIndex;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccPortInfoBuilder setIsActive(boolean isActive) {
+    this.isActive = isActive;
+    return this;
+  }
+
+  public UiccPortInfo build() {
+    return ReflectionHelpers.callConstructor(
+        UiccPortInfo.class,
+        ClassParameter.from(String.class, iccId),
+        ClassParameter.from(int.class, portIndex),
+        ClassParameter.from(int.class, logicalSlotIndex),
+        ClassParameter.from(boolean.class, isActive));
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/UiccSlotInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/UiccSlotInfoBuilder.java
new file mode 100644
index 0000000..88d0ebb
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/UiccSlotInfoBuilder.java
@@ -0,0 +1,100 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.Q;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+
+import android.telephony.UiccPortInfo;
+import android.telephony.UiccSlotInfo;
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link UiccSlotInfo} which was introduced in Android P. */
+public class UiccSlotInfoBuilder {
+
+  private boolean isEuicc = true;
+  private String cardId = "";
+  private int cardStateInfo;
+  private boolean isExtendedApduSupported;
+  private boolean isRemovable; // For API > 28
+  private boolean isActive; // For API < 33
+  private int logicalSlotIdx; // For API < 33
+  private final List<UiccPortInfo> portList = new ArrayList<>();
+
+  private UiccSlotInfoBuilder() {}
+
+  public static UiccSlotInfoBuilder newBuilder() {
+    return new UiccSlotInfoBuilder();
+  }
+
+  @CanIgnoreReturnValue
+  public UiccSlotInfoBuilder setIsEuicc(boolean isEuicc) {
+    this.isEuicc = isEuicc;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccSlotInfoBuilder setCardId(String cardId) {
+    this.cardId = cardId;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccSlotInfoBuilder setCardStateInfo(int cardStateInfo) {
+    this.cardStateInfo = cardStateInfo;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccSlotInfoBuilder setIsExtendedApduSupported(boolean isExtendedApduSupported) {
+    this.isExtendedApduSupported = isExtendedApduSupported;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccSlotInfoBuilder setIsRemovable(boolean isRemovable) {
+    this.isRemovable = isRemovable;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public UiccSlotInfoBuilder addPort(
+      String iccId, int portIndex, int logicSlotIndex, boolean isActive) {
+    if (RuntimeEnvironment.getApiLevel() >= TIRAMISU) {
+      this.portList.add(new UiccPortInfo(iccId, portIndex, logicSlotIndex, isActive));
+    } else {
+      this.isActive = isActive;
+      this.logicalSlotIdx = logicSlotIndex;
+    }
+    return this;
+  }
+
+  public UiccSlotInfo build() {
+    if (RuntimeEnvironment.getApiLevel() >= TIRAMISU) {
+      return new UiccSlotInfo(
+          isEuicc,
+          cardId,
+          cardStateInfo,
+          isExtendedApduSupported,
+          isRemovable,
+          ImmutableList.copyOf(portList));
+    } else if (RuntimeEnvironment.getApiLevel() >= Q) {
+      return ReflectionHelpers.callConstructor(
+          UiccSlotInfo.class,
+          ClassParameter.from(boolean.class, isActive),
+          ClassParameter.from(boolean.class, isEuicc),
+          ClassParameter.from(String.class, cardId),
+          ClassParameter.from(int.class, cardStateInfo),
+          ClassParameter.from(int.class, logicalSlotIdx),
+          ClassParameter.from(boolean.class, isExtendedApduSupported),
+          ClassParameter.from(boolean.class, isRemovable));
+    } else {
+      return new UiccSlotInfo(
+          isActive, isActive, cardId, cardStateInfo, logicalSlotIdx, isExtendedApduSupported);
+    }
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/VibrationAttributesBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/VibrationAttributesBuilder.java
new file mode 100644
index 0000000..55ea722
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/VibrationAttributesBuilder.java
@@ -0,0 +1,50 @@
+package org.robolectric.shadows;
+
+import static android.os.Build.VERSION_CODES.S;
+import static android.os.Build.VERSION_CODES.S_V2;
+import static android.os.Build.VERSION_CODES.TIRAMISU;
+import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
+
+import android.media.AudioAttributes;
+import android.os.VibrationAttributes;
+import android.os.VibrationEffect;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+
+/** Class to build {@link VibrationAttributes} */
+public final class VibrationAttributesBuilder {
+
+  private AudioAttributes audioAttributes;
+  private VibrationEffect vibrationEffect;
+
+  private VibrationAttributesBuilder() {}
+
+  public static VibrationAttributesBuilder newBuilder() {
+    return new VibrationAttributesBuilder();
+  }
+
+  public VibrationAttributesBuilder setAudioAttributes(AudioAttributes audioAttributes) {
+    this.audioAttributes = audioAttributes;
+    return this;
+  }
+
+  public VibrationAttributesBuilder setVibrationEffect(VibrationEffect vibrationEffect) {
+    this.vibrationEffect = vibrationEffect;
+    return this;
+  }
+
+  public VibrationAttributes build() {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel >= S && apiLevel <= S_V2) {
+      return ReflectionHelpers.callConstructor(
+              VibrationAttributes.Builder.class,
+              from(AudioAttributes.class, audioAttributes),
+              from(VibrationEffect.class, vibrationEffect))
+          .build();
+
+    } else if (apiLevel >= TIRAMISU) {
+      return new VibrationAttributes.Builder(audioAttributes).build();
+    }
+    throw new IllegalStateException("VibrationAttributes hidden constructor not found");
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/WifiUsabilityStatsEntryBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/WifiUsabilityStatsEntryBuilder.java
new file mode 100644
index 0000000..cbb6e78
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/WifiUsabilityStatsEntryBuilder.java
@@ -0,0 +1,280 @@
+package org.robolectric.shadows;
+
+import android.net.wifi.WifiUsabilityStatsEntry;
+import android.net.wifi.WifiUsabilityStatsEntry.ContentionTimeStats;
+import android.net.wifi.WifiUsabilityStatsEntry.RadioStats;
+import android.net.wifi.WifiUsabilityStatsEntry.RateStats;
+import android.os.Build.VERSION_CODES;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/** Builder for {@link WifiUsabilityStatsEntry}. */
+public class WifiUsabilityStatsEntryBuilder {
+  private long timeStampMillis;
+  private int rssi;
+  private int linkSpeedMbps;
+  private long totalTxSuccess;
+  private long totalTxRetries;
+  private long totalTxBad;
+  private long totalRxSuccess;
+  private long totalRadioOnTimeMillis;
+  private long totalRadioTxTimeMillis;
+  private long totalRadioRxTimeMillis;
+  private long totalScanTimeMillis;
+  private long totalNanScanTimeMillis;
+  private long totalBackgroundScanTimeMillis;
+  private long totalRoamScanTimeMillis;
+  private long totalPnoScanTimeMillis;
+  private long totalHotspot2ScanTimeMillis;
+  private long totalCcaBusyFreqTimeMillis;
+  private long totalRadioOnFreqTimeMillis;
+  private long totalBeaconRx;
+  private int probeStatusSinceLastUpdate;
+  private int probeElapsedTimeSinceLastUpdateMillis;
+  private int probeMcsRateSinceLastUpdate;
+  private int rxLinkSpeedMbps;
+  private int timeSliceDutyCycleInPercent;
+  private static final int CHANNEL_UTILIZATION_RATIO = 0;
+  private boolean isThroughputSufficient = true;
+  private boolean isWifiScoringEnabled = true;
+  private boolean isCellularDataAvailable = true;
+  private int cellularDataNetworkType;
+  private int cellularSignalStrengthDbm;
+  private int cellularSignalStrengthDb;
+  private boolean isSameRegisteredCell;
+
+  public WifiUsabilityStatsEntry build() {
+    if (RuntimeEnvironment.getApiLevel() <= VERSION_CODES.R) {
+      return ReflectionHelpers.callConstructor(
+          WifiUsabilityStatsEntry.class,
+          ClassParameter.from(long.class, timeStampMillis),
+          ClassParameter.from(int.class, rssi),
+          ClassParameter.from(int.class, linkSpeedMbps),
+          ClassParameter.from(long.class, totalTxSuccess),
+          ClassParameter.from(long.class, totalTxRetries),
+          ClassParameter.from(long.class, totalTxBad),
+          ClassParameter.from(long.class, totalRxSuccess),
+          ClassParameter.from(long.class, totalRadioOnTimeMillis),
+          ClassParameter.from(long.class, totalRadioTxTimeMillis),
+          ClassParameter.from(long.class, totalRadioRxTimeMillis),
+          ClassParameter.from(long.class, totalScanTimeMillis),
+          ClassParameter.from(long.class, totalNanScanTimeMillis),
+          ClassParameter.from(long.class, totalBackgroundScanTimeMillis),
+          ClassParameter.from(long.class, totalRoamScanTimeMillis),
+          ClassParameter.from(long.class, totalPnoScanTimeMillis),
+          ClassParameter.from(long.class, totalHotspot2ScanTimeMillis),
+          ClassParameter.from(long.class, totalCcaBusyFreqTimeMillis),
+          ClassParameter.from(long.class, totalRadioOnFreqTimeMillis),
+          ClassParameter.from(long.class, totalBeaconRx),
+          ClassParameter.from(int.class, probeStatusSinceLastUpdate),
+          ClassParameter.from(int.class, probeElapsedTimeSinceLastUpdateMillis),
+          ClassParameter.from(int.class, probeMcsRateSinceLastUpdate),
+          ClassParameter.from(int.class, rxLinkSpeedMbps),
+          ClassParameter.from(int.class, cellularDataNetworkType),
+          ClassParameter.from(int.class, cellularSignalStrengthDbm),
+          ClassParameter.from(int.class, cellularSignalStrengthDb),
+          ClassParameter.from(boolean.class, isSameRegisteredCell));
+    } else {
+      return new WifiUsabilityStatsEntry(
+          timeStampMillis,
+          rssi,
+          linkSpeedMbps,
+          totalTxSuccess,
+          totalTxRetries,
+          totalTxBad,
+          totalRxSuccess,
+          totalRadioOnTimeMillis,
+          totalRadioTxTimeMillis,
+          totalRadioRxTimeMillis,
+          totalScanTimeMillis,
+          totalNanScanTimeMillis,
+          totalBackgroundScanTimeMillis,
+          totalRoamScanTimeMillis,
+          totalPnoScanTimeMillis,
+          totalHotspot2ScanTimeMillis,
+          totalCcaBusyFreqTimeMillis,
+          totalRadioOnFreqTimeMillis,
+          totalBeaconRx,
+          probeStatusSinceLastUpdate,
+          probeElapsedTimeSinceLastUpdateMillis,
+          probeMcsRateSinceLastUpdate,
+          rxLinkSpeedMbps,
+          timeSliceDutyCycleInPercent,
+          new ContentionTimeStats[] {},
+          new RateStats[] {},
+          new RadioStats[] {},
+          CHANNEL_UTILIZATION_RATIO,
+          isThroughputSufficient,
+          isWifiScoringEnabled,
+          isCellularDataAvailable,
+          cellularDataNetworkType,
+          cellularSignalStrengthDbm,
+          cellularSignalStrengthDb,
+          isSameRegisteredCell);
+    }
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTimeStampMillis(long timeStampMillis) {
+    this.timeStampMillis = timeStampMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setRssi(int rssi) {
+    this.rssi = rssi;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setLinkSpeedMbps(int linkSpeedMbps) {
+    this.linkSpeedMbps = linkSpeedMbps;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalTxSuccess(long totalTxSuccess) {
+    this.totalTxSuccess = totalTxSuccess;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalTxRetries(long totalTxRetries) {
+    this.totalTxRetries = totalTxRetries;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalTxBad(long totalTxBad) {
+    this.totalTxBad = totalTxBad;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalRxSuccess(long totalRxSuccess) {
+    this.totalRxSuccess = totalRxSuccess;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalRadioOnTimeMillis(long totalRadioOnTimeMillis) {
+    this.totalRadioOnTimeMillis = totalRadioOnTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalRadioTxTimeMillis(long totalRadioTxTimeMillis) {
+    this.totalRadioTxTimeMillis = totalRadioTxTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalRadioRxTimeMillis(long totalRadioRxTimeMillis) {
+    this.totalRadioRxTimeMillis = totalRadioRxTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalScanTimeMillis(long totalScanTimeMillis) {
+    this.totalScanTimeMillis = totalScanTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalNanScanTimeMillis(long totalNanScanTimeMillis) {
+    this.totalNanScanTimeMillis = totalNanScanTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalBackgroundScanTimeMillis(
+      long totalBackgroundScanTimeMillis) {
+    this.totalBackgroundScanTimeMillis = totalBackgroundScanTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalRoamScanTimeMillis(long totalRoamScanTimeMillis) {
+    this.totalRoamScanTimeMillis = totalRoamScanTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalPnoScanTimeMillis(long totalPnoScanTimeMillis) {
+    this.totalPnoScanTimeMillis = totalPnoScanTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalHotspot2ScanTimeMillis(
+      long totalHotspot2ScanTimeMillis) {
+    this.totalHotspot2ScanTimeMillis = totalHotspot2ScanTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalCcaBusyFreqTimeMillis(
+      long totalCcaBusyFreqTimeMillis) {
+    this.totalCcaBusyFreqTimeMillis = totalCcaBusyFreqTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalRadioOnFreqTimeMillis(
+      long totalRadioOnFreqTimeMillis) {
+    this.totalRadioOnFreqTimeMillis = totalRadioOnFreqTimeMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTotalBeaconRx(long totalBeaconRx) {
+    this.totalBeaconRx = totalBeaconRx;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setProbeStatusSinceLastUpdate(
+      int probeStatusSinceLastUpdate) {
+    this.probeStatusSinceLastUpdate = probeStatusSinceLastUpdate;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setProbeElapsedTimeSinceLastUpdateMillis(
+      int probeElapsedTimeSinceLastUpdateMillis) {
+    this.probeElapsedTimeSinceLastUpdateMillis = probeElapsedTimeSinceLastUpdateMillis;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setProbeMcsRateSinceLastUpdate(
+      int probeMcsRateSinceLastUpdate) {
+    this.probeMcsRateSinceLastUpdate = probeMcsRateSinceLastUpdate;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setRxLinkSpeedMbps(int rxLinkSpeedMbps) {
+    this.rxLinkSpeedMbps = rxLinkSpeedMbps;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setCellularDataNetworkType(int cellularDataNetworkType) {
+    this.cellularDataNetworkType = cellularDataNetworkType;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setCellularSignalStrengthDbm(
+      int cellularSignalStrengthDbm) {
+    this.cellularSignalStrengthDbm = cellularSignalStrengthDbm;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setCellularSignalStrengthDb(int cellularSignalStrengthDb) {
+    this.cellularSignalStrengthDb = cellularSignalStrengthDb;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setSameRegisteredCell(boolean sameRegisteredCell) {
+    isSameRegisteredCell = sameRegisteredCell;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setTimeSliceDutyCycleInPercent(int percent) {
+    this.timeSliceDutyCycleInPercent = percent;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setIsCellularDataAvailable(boolean avail) {
+    this.isCellularDataAvailable = avail;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setIsThroughputSufficient(boolean sufficient) {
+    this.isThroughputSufficient = sufficient;
+    return this;
+  }
+
+  public WifiUsabilityStatsEntryBuilder setIsWifiScoringEnabled(boolean enabled) {
+    this.isWifiScoringEnabled = enabled;
+    return this;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/_Activity_.java b/shadows/framework/src/main/java/org/robolectric/shadows/_Activity_.java
new file mode 100644
index 0000000..e2eb442
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/_Activity_.java
@@ -0,0 +1,397 @@
+package org.robolectric.shadows;
+
+import android.app.Activity;
+import android.app.ActivityThread;
+import android.app.Application;
+import android.app.Dialog;
+import android.app.Instrumentation;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.view.View;
+import android.view.Window;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.reflector.Accessor;
+import org.robolectric.util.reflector.ForType;
+import org.robolectric.util.reflector.WithType;
+
+/** Accessor interface for {@link Activity}'s internals. */
+@ForType(Activity.class)
+public interface _Activity_ {
+
+  @Accessor("mToken")
+  IBinder getToken();
+
+  // <= KITKAT:
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration);
+
+  // <= LOLLIPOP:
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration,
+      @WithType("com.android.internal.app.IVoiceInteractor") Object iVoiceInteractor);
+
+  // <= M
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration,
+      String referer,
+      @WithType("com.android.internal.app.IVoiceInteractor") Object iVoiceInteractor);
+
+  // <= N_MR1
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration,
+      String referer,
+      @WithType("com.android.internal.app.IVoiceInteractor") Object iVoiceInteractor,
+      Window window);
+
+  // <= P
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration,
+      String referer,
+      @WithType("com.android.internal.app.IVoiceInteractor") Object iVoiceInteractor,
+      Window window,
+      @WithType("android.view.ViewRootImpl$ActivityConfigCallback") Object activityConfigCallback);
+
+  // <= R
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration,
+      String referer,
+      @WithType("com.android.internal.app.IVoiceInteractor") Object iVoiceInteractor,
+      Window window,
+      @WithType("android.view.ViewRootImpl$ActivityConfigCallback") Object activityConfigCallback,
+      IBinder assistToken);
+
+  // >= S
+  void attach(
+      Context context,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      IBinder token,
+      int ident,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      CharSequence title,
+      Activity parent,
+      String id,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances,
+      Configuration configuration,
+      String referer,
+      @WithType("com.android.internal.app.IVoiceInteractor") Object iVoiceInteractor,
+      Window window,
+      @WithType("android.view.ViewRootImpl$ActivityConfigCallback") Object activityConfigCallback,
+      IBinder assistToken,
+      IBinder shareableActivityToken);
+
+  default void callAttach(
+      Activity realActivity,
+      Context baseContext,
+      ActivityThread activityThread,
+      Instrumentation instrumentation,
+      Application application,
+      Intent intent,
+      ActivityInfo activityInfo,
+      IBinder token,
+      CharSequence activityTitle,
+      @WithType("android.app.Activity$NonConfigurationInstances")
+          Object lastNonConfigurationInstances) {
+    int apiLevel = RuntimeEnvironment.getApiLevel();
+    if (apiLevel <= Build.VERSION_CODES.KITKAT) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration());
+    } else if (apiLevel <= Build.VERSION_CODES.LOLLIPOP) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration(),
+          null);
+    } else if (apiLevel <= Build.VERSION_CODES.M) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration(),
+          "referrer",
+          null);
+    } else if (apiLevel <= Build.VERSION_CODES.N_MR1) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration(),
+          "referrer",
+          null,
+          null);
+    } else if (apiLevel <= Build.VERSION_CODES.P) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration(),
+          "referrer",
+          null,
+          null,
+          null);
+    } else if (apiLevel <= VERSION_CODES.R) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration(),
+          "referrer",
+          null,
+          null,
+          null,
+          null);
+    } else if (apiLevel > Build.VERSION_CODES.R) {
+      attach(
+          baseContext,
+          activityThread,
+          instrumentation,
+          token,
+          0,
+          application,
+          intent,
+          activityInfo,
+          activityTitle,
+          null,
+          null,
+          lastNonConfigurationInstances,
+          application.getResources().getConfiguration(),
+          "referrer",
+          null,
+          null,
+          null,
+          null,
+          null);
+    }
+    Shadow.<ShadowActivityThread>extract(activityThread)
+        .registerActivityLaunch(intent, activityInfo, realActivity, token);
+  }
+
+  void performCreate(Bundle icicle);
+
+  void performDestroy();
+
+  void performPause();
+
+  void performRestart();
+
+  void performRestart(boolean start, String reason);
+
+  void performRestoreInstanceState(Bundle savedInstanceState);
+
+  void performResume();
+
+  void performResume(boolean followedByPause, String reason);
+
+  void performTopResumedActivityChanged(boolean isTopResumedActivity, String reason);
+
+  void performSaveInstanceState(Bundle outState);
+
+  void performStart();
+
+  void performStart(String reason);
+
+  void performStop();
+
+  void performStop(boolean preserveWindow);
+
+  void performStop(boolean preserveWindow, String reason);
+
+  void onPostCreate(Bundle savedInstanceState);
+
+  void onPostResume();
+
+  void makeVisible();
+
+  void onNewIntent(Intent intent);
+
+  void onActivityResult(int requestCode, int resultCode, Intent data);
+
+  void dispatchActivityResult(String who, int requestCode, int resultCode, Intent data);
+
+  void dispatchActivityResult(
+      String who, int requestCode, int resultCode, Intent data, String type);
+
+  Dialog onCreateDialog(int id);
+
+  void onPrepareDialog(int id, Dialog dialog, Bundle args);
+
+  void onPrepareDialog(int id, Dialog dialog);
+
+  Object retainNonConfigurationInstances();
+
+  @Accessor("mApplication")
+  void setApplication(Application application);
+
+  @Accessor("mDecor")
+  void setDecor(View decorView);
+
+  @Accessor("mFinished")
+  void setFinished(boolean finished);
+
+  @Accessor("mLastNonConfigurationInstances")
+  void setLastNonConfigurationInstances(Object nonConfigInstance);
+
+  void setVoiceInteractor(
+      @WithType("com.android.internal.app.IVoiceInteractor") Object voiceInteractor);
+
+  @Accessor("mWindowAdded")
+  boolean getWindowAdded();
+
+  @Accessor("mWindow")
+  void setWindow(Window window);
+
+  @Accessor("mChangingConfigurations")
+  void setChangingConfigurations(boolean value);
+
+  @Accessor("mConfigChangeFlags")
+  void setConfigChangeFlags(int value);
+
+  @Accessor("mInstrumentation")
+  Instrumentation getInstrumentation();
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/package-info.java b/shadows/framework/src/main/java/org/robolectric/shadows/package-info.java
new file mode 100644
index 0000000..a23bef1
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Package containing shadow classes for the Android SDK.
+ */
+package org.robolectric.shadows;
\ No newline at end of file
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/util/DataSource.java b/shadows/framework/src/main/java/org/robolectric/shadows/util/DataSource.java
new file mode 100644
index 0000000..b3128e5
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/util/DataSource.java
@@ -0,0 +1,115 @@
+package org.robolectric.shadows.util;
+
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.media.MediaDataSource;
+import android.net.Uri;
+import java.io.FileDescriptor;
+import java.net.HttpCookie;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Opaque class for uniquely identifying a media data source, as used by {@link
+ * org.robolectric.shadows.ShadowMediaPlayer}, {@link
+ * org.robolectric.shadows.ShadowMediaMetadataRetriever}, and {@link
+ * org.robolectric.shadows.ShadowMediaExtractor}
+ *
+ * @author Fr Jeremy Krieg
+ */
+public class DataSource {
+  private String dataSource;
+
+  @SuppressWarnings("ObjectToString")
+  private static final FileDescriptorTransform DEFAULT_FD_TRANSFORM =
+      (fd, offset) -> fd.toString() + offset;
+
+  private static FileDescriptorTransform fdTransform = DEFAULT_FD_TRANSFORM;
+
+  /** Transform a {@link FileDescriptor} to a string. */
+  public interface FileDescriptorTransform {
+    String toString(FileDescriptor fd, long offset);
+  }
+
+  /**
+   * Optional transformation for {@link FileDescriptor}.
+   *
+   * <p>Helpful for associating a real test file to the data source used by shadow objects in
+   * stubbed methods.
+   */
+  public static void setFileDescriptorTransform(FileDescriptorTransform transform) {
+    fdTransform = transform;
+  }
+
+  private DataSource(String dataSource) {
+    this.dataSource = dataSource;
+  }
+
+  public static DataSource toDataSource(String path) {
+    return new DataSource(path);
+  }
+
+  public static DataSource toDataSource(Context context, Uri uri) {
+    return toDataSource(uri.toString());
+  }
+
+  public static DataSource toDataSource(Context context, Uri uri, Map<String, String> headers) {
+    return toDataSource(context, uri);
+  }
+
+  public static DataSource toDataSource(
+      Context context, Uri uri, Map<String, String> headers, List<HttpCookie> cookies) {
+    return toDataSource(context, uri, headers);
+  }
+
+  public static DataSource toDataSource(String uri, Map<String, String> headers) {
+    return toDataSource(uri);
+  }
+
+  public static DataSource toDataSource(FileDescriptor fd) {
+    return toDataSource(fd, 0, 0);
+  }
+
+  public static DataSource toDataSource(MediaDataSource mediaDataSource) {
+    return toDataSource("MediaDataSource");
+  }
+
+  public static DataSource toDataSource(AssetFileDescriptor assetFileDescriptor) {
+    return toDataSource(
+        "AssetFileDescriptor"
+            + assetFileDescriptor.getStartOffset()
+            + assetFileDescriptor.getLength());
+  }
+
+  public static DataSource toDataSource(FileDescriptor fd, long offset, long length) {
+    return toDataSource(fdTransform.toString(fd, offset));
+  }
+
+  public static void reset() {
+    fdTransform = DEFAULT_FD_TRANSFORM;
+  }
+
+  @Override
+  public int hashCode() {
+    return ((dataSource == null) ? 0 : dataSource.hashCode());
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (obj == null || !(obj instanceof DataSource)) {
+      return false;
+    }
+    final DataSource other = (DataSource) obj;
+    if (dataSource == null) {
+      if (other.dataSource != null) {
+        return false;
+      }
+    } else if (!dataSource.equals(other.dataSource)) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/util/SQLiteLibraryLoader.java b/shadows/framework/src/main/java/org/robolectric/shadows/util/SQLiteLibraryLoader.java
new file mode 100644
index 0000000..a66948b
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/util/SQLiteLibraryLoader.java
@@ -0,0 +1,175 @@
+package org.robolectric.shadows.util;
+
+import com.almworks.sqlite4java.SQLite;
+import com.almworks.sqlite4java.SQLiteException;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.io.ByteSource;
+import com.google.common.io.Files;
+import com.google.common.io.Resources;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Locale;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/** Initializes sqlite native libraries. */
+public class SQLiteLibraryLoader {
+  private static SQLiteLibraryLoader instance;
+  private static final String SQLITE4JAVA = "sqlite4java";
+  private static final String OS_WIN = "win32", OS_LINUX = "linux", OS_MAC = "osx";
+  private static final String OS_ARCH_ARM64 = "aarch64";
+  private static final String SYSTEM_PROPERTY_OS_NAME = "os.name";
+  private static final String SYSTEM_PROPERTY_OS_ARCH = "os.arch";
+
+  private final LibraryNameMapper libraryNameMapper;
+  private boolean loaded;
+
+  public SQLiteLibraryLoader() {
+    this(DEFAULT_MAPPER);
+  }
+
+  public SQLiteLibraryLoader(LibraryNameMapper mapper) {
+    libraryNameMapper = mapper;
+  }
+
+  private static final LibraryNameMapper DEFAULT_MAPPER = System::mapLibraryName;
+
+  public static synchronized void load() {
+    if (instance == null) {
+      instance = new SQLiteLibraryLoader();
+    }
+    instance.doLoad();
+  }
+
+  public void doLoad() {
+    if (loaded) {
+      return;
+    }
+    final long startTime = System.currentTimeMillis();
+    File tempDir = Files.createTempDir();
+    tempDir.deleteOnExit();
+    File extractedLibraryPath = new File(tempDir, getLibName());
+    try (FileOutputStream outputStream = new FileOutputStream(extractedLibraryPath)) {
+      getLibraryByteSource().copyTo(outputStream);
+    } catch (IOException e) {
+      throw new RuntimeException("Cannot extract SQLite library into " + extractedLibraryPath, e);
+    }
+    loadFromDirectory(tempDir);
+    logWithTime("SQLite natives prepared in", startTime);
+  }
+
+  public String getLibClasspathResourceName() {
+    return "sqlite4java/" + getNativesResourcesPathPart() + "/" + getLibName();
+  }
+
+  private ByteSource getLibraryByteSource() {
+    return Resources.asByteSource(Resources.getResource(getLibClasspathResourceName()));
+  }
+
+  private void logWithTime(final String message, final long startTime) {
+    log(message + " " + (System.currentTimeMillis() - startTime));
+  }
+
+  private void log(final String message) {
+    org.robolectric.util.Logger.debug(message);
+  }
+
+  @VisibleForTesting
+  public boolean isLoaded() {
+    return loaded;
+  }
+
+  public static boolean isOsSupported() {
+    String prefix = getOsPrefix();
+    String arch = getArchitecture();
+    // We know macOS with aarch64 arch is not supported by sqlite4java now.
+    return !(OS_MAC.equals(prefix) && OS_ARCH_ARM64.equals(arch));
+  }
+
+  private void loadFromDirectory(final File libPath) {
+    // configure less verbose logging
+    Logger.getLogger("com.almworks.sqlite4java").setLevel(Level.WARNING);
+
+    SQLite.setLibraryPath(libPath.getAbsolutePath());
+    try {
+      log(
+          "SQLite version: library "
+              + SQLite.getLibraryVersion()
+              + " / core "
+              + SQLite.getSQLiteVersion());
+    } catch (SQLiteException e) {
+      throw new RuntimeException(e);
+    }
+    loaded = true;
+  }
+
+  private String getLibName() {
+    return libraryNameMapper.mapLibraryName(SQLITE4JAVA);
+  }
+
+  private String getNativesResourcesPathPart() {
+    String prefix = getOsPrefix();
+    String suffix = getArchitectureSuffix(prefix);
+    if (suffix != null) {
+      return prefix + "-" + suffix;
+    } else {
+      return prefix;
+    }
+  }
+
+  private static String getOsPrefix() {
+    String name = System.getProperty(SYSTEM_PROPERTY_OS_NAME).toLowerCase(Locale.US);
+    if (name.contains("win")) {
+      return OS_WIN;
+    } else if (name.contains("linux")) {
+      return OS_LINUX;
+    } else if (name.contains("mac")) {
+      return OS_MAC;
+    } else {
+      throw new UnsupportedOperationException(
+          "Platform '" + name + "' is not supported by SQLite library");
+    }
+  }
+
+  private static String getArchitectureSuffix(String prefix) {
+    String arch = getArchitecture();
+    switch (prefix) {
+      case OS_MAC:
+        // Current sqlite4java doesn't support macOS aarch64.
+        if (!OS_ARCH_ARM64.equals(arch)) {
+          return null;
+        }
+        break;
+      case OS_LINUX:
+        switch (arch) {
+          case "i386":
+          case "x86":
+            return "i386";
+          case "x86_64":
+          case "amd64":
+            return "amd64";
+        }
+        break;
+      case OS_WIN:
+        switch (arch) {
+          case "x86":
+            return "x86";
+          case "x86_64":
+          case "amd64":
+            return "x64";
+        }
+        break;
+    }
+    throw new UnsupportedOperationException(
+        "Architecture '" + arch + "' is not supported by SQLite library");
+  }
+
+  private static String getArchitecture() {
+    return System.getProperty(SYSTEM_PROPERTY_OS_ARCH).toLowerCase(Locale.US).replaceAll("\\W", "");
+  }
+
+  public interface LibraryNameMapper {
+    String mapLibraryName(String name);
+  }
+}
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/util/package-info.java b/shadows/framework/src/main/java/org/robolectric/shadows/util/package-info.java
new file mode 100644
index 0000000..dd7cb89
--- /dev/null
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/util/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Package containing shadow related utility classes.
+ */
+package org.robolectric.shadows.util;
\ No newline at end of file
diff --git a/shadows/httpclient/build.gradle b/shadows/httpclient/build.gradle
new file mode 100644
index 0000000..332c83e
--- /dev/null
+++ b/shadows/httpclient/build.gradle
@@ -0,0 +1,44 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+apply plugin: ShadowsPlugin
+
+shadows {
+    packageName "org.robolectric.shadows.httpclient"
+    sdkCheckMode "OFF"
+}
+
+configurations {
+    earlyRuntime
+}
+
+dependencies {
+    api project(":annotations")
+    api project(":shadowapi")
+    api project(":utils")
+
+    // We should keep httpclient version for low level API compatibility.
+    earlyRuntime "org.apache.httpcomponents:httpcore:4.0.1"
+    api "org.apache.httpcomponents:httpclient:4.0.3"
+    compileOnly(AndroidSdk.LOLLIPOP_MR1.coordinates) { force = true }
+
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+    testImplementation "androidx.test.ext:junit:$axtJunitVersion@aar"
+
+    testCompileOnly(AndroidSdk.LOLLIPOP_MR1.coordinates) { force = true }
+    testRuntimeOnly AndroidSdk.S.coordinates
+}
+
+// httpcore needs to come before android-all on runtime classpath; the gradle IntelliJ plugin
+//   needs the compileClasspath order patched too (bug?)
+sourceSets.main.compileClasspath = configurations.earlyRuntime + sourceSets.main.compileClasspath
+sourceSets.main.runtimeClasspath = configurations.earlyRuntime + sourceSets.main.runtimeClasspath
+
+sourceSets.test.compileClasspath = configurations.earlyRuntime + sourceSets.test.compileClasspath
+sourceSets.test.runtimeClasspath = configurations.earlyRuntime + sourceSets.test.runtimeClasspath
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/ShadowAndroidHttpClient.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/ShadowAndroidHttpClient.java
new file mode 100644
index 0000000..e92f409
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/ShadowAndroidHttpClient.java
@@ -0,0 +1,104 @@
+package org.robolectric.shadows;
+
+import android.content.Context;
+import android.net.http.AndroidHttpClient;
+import java.io.IOException;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.ResponseHandler;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.conn.ClientConnectionManager;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.params.HttpParams;
+import org.apache.http.protocol.HttpContext;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.util.ReflectionHelpers;
+
+@Implements(AndroidHttpClient.class)
+public class ShadowAndroidHttpClient {
+
+  private HttpClient httpClient = new DefaultHttpClient();
+
+  @Implementation
+  protected static AndroidHttpClient newInstance(String userAgent) {
+    return ReflectionHelpers.callConstructor(AndroidHttpClient.class);
+  }
+
+  @Implementation
+  protected static AndroidHttpClient newInstance(String userAgent, Context context) {
+    return ReflectionHelpers.callConstructor(AndroidHttpClient.class);
+  }
+
+  @Implementation
+  protected HttpParams getParams() {
+    return httpClient.getParams();
+  }
+
+  @Implementation
+  protected ClientConnectionManager getConnectionManager() {
+    return httpClient.getConnectionManager();
+  }
+
+  @Implementation
+  protected HttpResponse execute(HttpUriRequest httpUriRequest)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpUriRequest);
+  }
+
+  @Implementation
+  protected HttpResponse execute(HttpUriRequest httpUriRequest, HttpContext httpContext)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpUriRequest, httpContext);
+  }
+
+  @Implementation
+  protected HttpResponse execute(HttpHost httpHost, HttpRequest httpRequest)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpHost, httpRequest);
+  }
+
+  @Implementation
+  protected HttpResponse execute(
+      HttpHost httpHost, HttpRequest httpRequest, HttpContext httpContext)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpHost, httpRequest, httpContext);
+  }
+
+  @Implementation
+  protected <T> T execute(
+      HttpUriRequest httpUriRequest, ResponseHandler<? extends T> responseHandler)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpUriRequest, responseHandler);
+  }
+
+  @Implementation
+  protected <T> T execute(
+      HttpUriRequest httpUriRequest,
+      ResponseHandler<? extends T> responseHandler,
+      HttpContext httpContext)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpUriRequest, responseHandler, httpContext);
+  }
+
+  @Implementation
+  protected <T> T execute(
+      HttpHost httpHost, HttpRequest httpRequest, ResponseHandler<? extends T> responseHandler)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpHost, httpRequest, responseHandler);
+  }
+
+  @Implementation
+  protected <T> T execute(
+      HttpHost httpHost,
+      HttpRequest httpRequest,
+      ResponseHandler<? extends T> responseHandler,
+      HttpContext httpContext)
+      throws IOException, ClientProtocolException {
+    return httpClient.execute(httpHost, httpRequest, responseHandler, httpContext);
+  }
+}
\ No newline at end of file
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/DefaultRequestDirector.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/DefaultRequestDirector.java
new file mode 100644
index 0000000..fb56683
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/DefaultRequestDirector.java
@@ -0,0 +1,1179 @@
+// copied verbatim (except for constructor access) from httpclient-4.0.3 sources
+
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package org.robolectric.shadows.httpclient;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.http.ConnectionReuseStrategy;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpEntityEnclosingRequest;
+import org.apache.http.HttpException;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+import org.apache.http.ProtocolException;
+import org.apache.http.ProtocolVersion;
+import org.apache.http.auth.AuthScheme;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.AuthState;
+import org.apache.http.auth.AuthenticationException;
+import org.apache.http.auth.Credentials;
+import org.apache.http.auth.MalformedChallengeException;
+import org.apache.http.client.AuthenticationHandler;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.HttpRequestRetryHandler;
+import org.apache.http.client.NonRepeatableRequestException;
+import org.apache.http.client.RedirectException;
+import org.apache.http.client.RedirectHandler;
+import org.apache.http.client.RequestDirector;
+import org.apache.http.client.UserTokenHandler;
+import org.apache.http.client.methods.AbortableHttpRequest;
+import org.apache.http.client.params.ClientPNames;
+import org.apache.http.client.params.HttpClientParams;
+import org.apache.http.client.protocol.ClientContext;
+import org.apache.http.client.utils.URIUtils;
+import org.apache.http.conn.BasicManagedEntity;
+import org.apache.http.conn.ClientConnectionManager;
+import org.apache.http.conn.ClientConnectionRequest;
+import org.apache.http.conn.ConnectionKeepAliveStrategy;
+import org.apache.http.conn.ManagedClientConnection;
+import org.apache.http.conn.params.ConnManagerParams;
+import org.apache.http.conn.routing.BasicRouteDirector;
+import org.apache.http.conn.routing.HttpRoute;
+import org.apache.http.conn.routing.HttpRouteDirector;
+import org.apache.http.conn.routing.HttpRoutePlanner;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.entity.BufferedHttpEntity;
+import org.apache.http.impl.client.EntityEnclosingRequestWrapper;
+import org.apache.http.impl.client.RequestWrapper;
+import org.apache.http.impl.client.RoutedRequest;
+import org.apache.http.impl.client.TunnelRefusedException;
+import org.apache.http.message.BasicHttpRequest;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+import org.apache.http.params.HttpProtocolParams;
+import org.apache.http.protocol.ExecutionContext;
+import org.apache.http.protocol.HttpContext;
+import org.apache.http.protocol.HttpProcessor;
+import org.apache.http.protocol.HttpRequestExecutor;
+
+/**
+ * Default implementation of {@link RequestDirector}.
+ *
+ * <p>The following parameters can be used to customize the behavior of this class:
+ *
+ * <ul>
+ *   <li>{@link org.apache.http.params.CoreProtocolPNames#PROTOCOL_VERSION}
+ *   <li>{@link org.apache.http.params.CoreProtocolPNames#STRICT_TRANSFER_ENCODING}
+ *   <li>{@link org.apache.http.params.CoreProtocolPNames#HTTP_ELEMENT_CHARSET}
+ *   <li>{@link org.apache.http.params.CoreProtocolPNames#USE_EXPECT_CONTINUE}
+ *   <li>{@link org.apache.http.params.CoreProtocolPNames#WAIT_FOR_CONTINUE}
+ *   <li>{@link org.apache.http.params.CoreProtocolPNames#USER_AGENT}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#SOCKET_BUFFER_SIZE}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#MAX_LINE_LENGTH}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#MAX_HEADER_COUNT}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#SO_TIMEOUT}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#SO_LINGER}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#TCP_NODELAY}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#CONNECTION_TIMEOUT}
+ *   <li>{@link org.apache.http.params.CoreConnectionPNames#STALE_CONNECTION_CHECK}
+ *   <li>{@link org.apache.http.conn.params.ConnRoutePNames#FORCED_ROUTE}
+ *   <li>{@link org.apache.http.conn.params.ConnRoutePNames#LOCAL_ADDRESS}
+ *   <li>{@link org.apache.http.conn.params.ConnRoutePNames#DEFAULT_PROXY}
+ *   <li>{@link org.apache.http.conn.params.ConnManagerPNames#TIMEOUT}
+ *   <li>{@link org.apache.http.conn.params.ConnManagerPNames#MAX_CONNECTIONS_PER_ROUTE}
+ *   <li>{@link org.apache.http.conn.params.ConnManagerPNames#MAX_TOTAL_CONNECTIONS}
+ *   <li>{@link org.apache.http.cookie.params.CookieSpecPNames#DATE_PATTERNS}
+ *   <li>{@link org.apache.http.cookie.params.CookieSpecPNames#SINGLE_COOKIE_HEADER}
+ *   <li>{@link org.apache.http.auth.params.AuthPNames#CREDENTIAL_CHARSET}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#COOKIE_POLICY}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#HANDLE_AUTHENTICATION}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#HANDLE_REDIRECTS}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#MAX_REDIRECTS}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#ALLOW_CIRCULAR_REDIRECTS}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#VIRTUAL_HOST}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#DEFAULT_HOST}
+ *   <li>{@link org.apache.http.client.params.ClientPNames#DEFAULT_HEADERS}
+ * </ul>
+ *
+ * @since 4.0
+ */
+public class DefaultRequestDirector implements RequestDirector {
+
+  private final Log log;
+
+  /** The connection manager. */
+  protected final ClientConnectionManager connManager;
+
+  /** The route planner. */
+  protected final HttpRoutePlanner routePlanner;
+
+  /** The connection re-use strategy. */
+  protected final ConnectionReuseStrategy reuseStrategy;
+
+  /** The keep-alive duration strategy. */
+  protected final ConnectionKeepAliveStrategy keepAliveStrategy;
+
+  /** The request executor. */
+  protected final HttpRequestExecutor requestExec;
+
+  /** The HTTP protocol processor. */
+  protected final HttpProcessor httpProcessor;
+
+  /** The request retry handler. */
+  protected final HttpRequestRetryHandler retryHandler;
+
+  /** The redirect handler. */
+  protected final RedirectHandler redirectHandler;
+
+  /** The target authentication handler. */
+  protected final AuthenticationHandler targetAuthHandler;
+
+  /** The proxy authentication handler. */
+  protected final AuthenticationHandler proxyAuthHandler;
+
+  /** The user token handler. */
+  protected final UserTokenHandler userTokenHandler;
+
+  /** The HTTP parameters. */
+  protected final HttpParams params;
+
+  /** The currently allocated connection. */
+  protected ManagedClientConnection managedConn;
+
+  protected final AuthState targetAuthState;
+
+  protected final AuthState proxyAuthState;
+
+  private int redirectCount;
+
+  private int maxRedirects;
+
+  private HttpHost virtualHost;
+
+  public DefaultRequestDirector(
+      final Log log,
+      final HttpRequestExecutor requestExec,
+      final ClientConnectionManager conman,
+      final ConnectionReuseStrategy reustrat,
+      final ConnectionKeepAliveStrategy kastrat,
+      final HttpRoutePlanner rouplan,
+      final HttpProcessor httpProcessor,
+      final HttpRequestRetryHandler retryHandler,
+      final RedirectHandler redirectHandler,
+      final AuthenticationHandler targetAuthHandler,
+      final AuthenticationHandler proxyAuthHandler,
+      final UserTokenHandler userTokenHandler,
+      final HttpParams params) {
+
+    if (log == null) {
+      throw new IllegalArgumentException
+        ("Log may not be null.");
+    }
+    if (requestExec == null) {
+      throw new IllegalArgumentException
+        ("Request executor may not be null.");
+    }
+    if (conman == null) {
+      throw new IllegalArgumentException
+        ("Client connection manager may not be null.");
+    }
+    if (reustrat == null) {
+      throw new IllegalArgumentException
+        ("Connection reuse strategy may not be null.");
+    }
+    if (kastrat == null) {
+      throw new IllegalArgumentException
+        ("Connection keep alive strategy may not be null.");
+    }
+    if (rouplan == null) {
+      throw new IllegalArgumentException
+        ("Route planner may not be null.");
+    }
+    if (httpProcessor == null) {
+      throw new IllegalArgumentException
+        ("HTTP protocol processor may not be null.");
+    }
+    if (retryHandler == null) {
+      throw new IllegalArgumentException
+        ("HTTP request retry handler may not be null.");
+    }
+    if (redirectHandler == null) {
+      throw new IllegalArgumentException
+        ("Redirect handler may not be null.");
+    }
+    if (targetAuthHandler == null) {
+      throw new IllegalArgumentException
+        ("Target authentication handler may not be null.");
+    }
+    if (proxyAuthHandler == null) {
+      throw new IllegalArgumentException
+        ("Proxy authentication handler may not be null.");
+    }
+    if (userTokenHandler == null) {
+      throw new IllegalArgumentException
+        ("User token handler may not be null.");
+    }
+    if (params == null) {
+      throw new IllegalArgumentException
+        ("HTTP parameters may not be null");
+    }
+    this.log               = log;
+    this.requestExec       = requestExec;
+    this.connManager       = conman;
+    this.reuseStrategy     = reustrat;
+    this.keepAliveStrategy = kastrat;
+    this.routePlanner      = rouplan;
+    this.httpProcessor     = httpProcessor;
+    this.retryHandler      = retryHandler;
+    this.redirectHandler   = redirectHandler;
+    this.targetAuthHandler = targetAuthHandler;
+    this.proxyAuthHandler  = proxyAuthHandler;
+    this.userTokenHandler  = userTokenHandler;
+    this.params            = params;
+
+    this.managedConn       = null;
+
+    this.redirectCount = 0;
+    this.maxRedirects = this.params.getIntParameter(ClientPNames.MAX_REDIRECTS, 100);
+    this.targetAuthState = new AuthState();
+    this.proxyAuthState = new AuthState();
+  } // constructor
+
+  public DefaultRequestDirector(
+      final HttpRequestExecutor requestExec,
+      final ClientConnectionManager conman,
+      final ConnectionReuseStrategy reustrat,
+      final ConnectionKeepAliveStrategy kastrat,
+      final HttpRoutePlanner rouplan,
+      final HttpProcessor httpProcessor,
+      final HttpRequestRetryHandler retryHandler,
+      final RedirectHandler redirectHandler,
+      final AuthenticationHandler targetAuthHandler,
+      final AuthenticationHandler proxyAuthHandler,
+      final UserTokenHandler userTokenHandler,
+      final HttpParams params) {
+    this(LogFactory.getLog(DefaultRequestDirector.class),
+        requestExec,
+        conman,
+        reustrat,
+        kastrat,
+        rouplan,
+        httpProcessor,
+        retryHandler,
+        redirectHandler,
+        targetAuthHandler,
+        proxyAuthHandler,
+        userTokenHandler,
+        params);
+
+  }
+
+  private RequestWrapper wrapRequest(
+      final HttpRequest request) throws ProtocolException {
+    if (request instanceof HttpEntityEnclosingRequest) {
+      return new EntityEnclosingRequestWrapper(
+          (HttpEntityEnclosingRequest) request);
+    } else {
+      return new RequestWrapper(
+          request);
+    }
+  }
+
+
+  protected void rewriteRequestURI(
+      final RequestWrapper request,
+      final HttpRoute route) throws ProtocolException {
+    try {
+
+      URI uri = request.getURI();
+      if (route.getProxyHost() != null && !route.isTunnelled()) {
+        // Make sure the request URI is absolute
+        if (!uri.isAbsolute()) {
+          HttpHost target = route.getTargetHost();
+          uri = URIUtils.rewriteURI(uri, target);
+          request.setURI(uri);
+        }
+      } else {
+        // Make sure the request URI is relative
+        if (uri.isAbsolute()) {
+          uri = URIUtils.rewriteURI(uri, null);
+          request.setURI(uri);
+        }
+      }
+
+    } catch (URISyntaxException ex) {
+      throw new ProtocolException("Invalid URI: " +
+          request.getRequestLine().getUri(), ex);
+    }
+  }
+
+
+  // non-javadoc, see interface ClientRequestDirector
+  @Override public HttpResponse execute(HttpHost target, HttpRequest request,
+                HttpContext context)
+    throws HttpException, IOException {
+
+    HttpRequest orig = request;
+    RequestWrapper origWrapper = wrapRequest(orig);
+    origWrapper.setParams(params);
+    HttpRoute origRoute = determineRoute(target, origWrapper, context);
+
+    virtualHost = (HttpHost) orig.getParams().getParameter(
+        ClientPNames.VIRTUAL_HOST);
+
+    RoutedRequest roureq = new RoutedRequest(origWrapper, origRoute);
+
+    long timeout = ConnManagerParams.getTimeout(params);
+
+    int execCount = 0;
+
+    boolean reuse = false;
+    boolean done = false;
+    try {
+      HttpResponse response = null;
+      while (!done) {
+        // In this loop, the RoutedRequest may be replaced by a
+        // followup request and route. The request and route passed
+        // in the method arguments will be replaced. The original
+        // request is still available in 'orig'.
+
+        RequestWrapper wrapper = roureq.getRequest();
+        HttpRoute route = roureq.getRoute();
+        response = null;
+
+        // See if we have a user token bound to the execution context
+        Object userToken = context.getAttribute(ClientContext.USER_TOKEN);
+
+        // Allocate connection if needed
+        if (managedConn == null) {
+          ClientConnectionRequest connRequest = connManager.requestConnection(
+              route, userToken);
+          if (orig instanceof AbortableHttpRequest) {
+            ((AbortableHttpRequest) orig).setConnectionRequest(connRequest);
+          }
+
+          try {
+            managedConn = connRequest.getConnection(timeout, TimeUnit.MILLISECONDS);
+          } catch(InterruptedException interrupted) {
+            InterruptedIOException iox = new InterruptedIOException();
+            iox.initCause(interrupted);
+            throw iox;
+          }
+
+          if (HttpConnectionParams.isStaleCheckingEnabled(params)) {
+            // validate connection
+            if (managedConn.isOpen()) {
+              this.log.debug("Stale connection check");
+              if (managedConn.isStale()) {
+                this.log.debug("Stale connection detected");
+                managedConn.close();
+              }
+            }
+          }
+        }
+
+        if (orig instanceof AbortableHttpRequest) {
+          ((AbortableHttpRequest) orig).setReleaseTrigger(managedConn);
+        }
+
+        // Reopen connection if needed
+        if (!managedConn.isOpen()) {
+          managedConn.open(route, context, params);
+        } else {
+          managedConn.setSocketTimeout(HttpConnectionParams.getSoTimeout(params));
+        }
+
+        try {
+          establishRoute(route, context);
+        } catch (TunnelRefusedException ex) {
+          if (this.log.isDebugEnabled()) {
+            this.log.debug(ex.getMessage());
+          }
+          response = ex.getResponse();
+          break;
+        }
+
+        // Reset headers on the request wrapper
+        wrapper.resetHeaders();
+
+        // Re-write request URI if needed
+        rewriteRequestURI(wrapper, route);
+
+        // Use virtual host if set
+        target = virtualHost;
+
+        if (target == null) {
+          target = route.getTargetHost();
+        }
+
+        HttpHost proxy = route.getProxyHost();
+
+        // Populate the execution context
+        context.setAttribute(ExecutionContext.HTTP_TARGET_HOST,
+            target);
+        context.setAttribute(ExecutionContext.HTTP_PROXY_HOST,
+            proxy);
+        context.setAttribute(ExecutionContext.HTTP_CONNECTION,
+            managedConn);
+        context.setAttribute(ClientContext.TARGET_AUTH_STATE,
+            targetAuthState);
+        context.setAttribute(ClientContext.PROXY_AUTH_STATE,
+            proxyAuthState);
+
+        // Run request protocol interceptors
+        requestExec.preProcess(wrapper, httpProcessor, context);
+
+        boolean retrying = true;
+        Exception retryReason = null;
+        while (retrying) {
+          // Increment total exec count (with redirects)
+          execCount++;
+          // Increment exec count for this particular request
+          wrapper.incrementExecCount();
+          if (!wrapper.isRepeatable()) {
+            this.log.debug("Cannot retry non-repeatable request");
+            if (retryReason != null) {
+              throw new NonRepeatableRequestException("Cannot retry request " +
+                "with a non-repeatable request entity.  The cause lists the " +
+                "reason the original request failed: " + retryReason);
+            } else {
+              throw new NonRepeatableRequestException("Cannot retry request " +
+                  "with a non-repeatable request entity.");
+            }
+          }
+
+          try {
+            if (this.log.isDebugEnabled()) {
+              this.log.debug("Attempt " + execCount + " to execute request");
+            }
+            response = requestExec.execute(wrapper, managedConn, context);
+            retrying = false;
+
+          } catch (IOException ex) {
+            this.log.debug("Closing the connection.");
+            managedConn.close();
+            if (retryHandler.retryRequest(ex, wrapper.getExecCount(), context)) {
+              if (this.log.isInfoEnabled()) {
+                this.log.info("I/O exception ("+ ex.getClass().getName() +
+                    ") caught when processing request: "
+                    + ex.getMessage());
+              }
+              if (this.log.isDebugEnabled()) {
+                this.log.debug(ex.getMessage(), ex);
+              }
+              this.log.info("Retrying request");
+              retryReason = ex;
+            } else {
+              throw ex;
+            }
+
+            // If we have a direct route to the target host
+            // just re-open connection and re-try the request
+            if (!route.isTunnelled()) {
+              this.log.debug("Reopening the direct connection.");
+              managedConn.open(route, context, params);
+            } else {
+              // otherwise give up
+              this.log.debug("Proxied connection. Need to start over.");
+              retrying = false;
+            }
+
+          }
+
+        }
+
+        if (response == null) {
+          // Need to start over
+          continue;
+        }
+
+        // Run response protocol interceptors
+        response.setParams(params);
+        requestExec.postProcess(response, httpProcessor, context);
+
+
+        // The connection is in or can be brought to a re-usable state.
+        reuse = reuseStrategy.keepAlive(response, context);
+        if (reuse) {
+          // Set the idle duration of this connection
+          long duration = keepAliveStrategy.getKeepAliveDuration(response, context);
+          managedConn.setIdleDuration(duration, TimeUnit.MILLISECONDS);
+
+          if (this.log.isDebugEnabled()) {
+            if (duration >= 0) {
+              this.log.debug("Connection can be kept alive for " + duration + " ms");
+            } else {
+              this.log.debug("Connection can be kept alive indefinitely");
+            }
+          }
+        }
+
+        RoutedRequest followup = handleResponse(roureq, response, context);
+        if (followup == null) {
+          done = true;
+        } else {
+          if (reuse) {
+            // Make sure the response body is fully consumed, if present
+            HttpEntity entity = response.getEntity();
+            if (entity != null) {
+              entity.consumeContent();
+            }
+            // entity consumed above is not an auto-release entity,
+            // need to mark the connection re-usable explicitly
+            managedConn.markReusable();
+          } else {
+            managedConn.close();
+          }
+          // check if we can use the same connection for the followup
+          if (!followup.getRoute().equals(roureq.getRoute())) {
+            releaseConnection();
+          }
+          roureq = followup;
+        }
+
+        if (managedConn != null && userToken == null) {
+          userToken = userTokenHandler.getUserToken(context);
+          context.setAttribute(ClientContext.USER_TOKEN, userToken);
+          if (userToken != null) {
+            managedConn.setState(userToken);
+          }
+        }
+
+      } // while not done
+
+
+      // check for entity, release connection if possible
+      if ((response == null) || (response.getEntity() == null) ||
+        !response.getEntity().isStreaming()) {
+        // connection not needed and (assumed to be) in re-usable state
+        if (reuse)
+          managedConn.markReusable();
+        releaseConnection();
+      } else {
+        // install an auto-release entity
+        HttpEntity entity = response.getEntity();
+        entity = new BasicManagedEntity(entity, managedConn, reuse);
+        response.setEntity(entity);
+      }
+
+      return response;
+
+    } catch (HttpException | RuntimeException | IOException ex) {
+      abortConnection();
+      throw ex;
+    }
+  } // execute
+
+  /**
+   * Returns the connection back to the connection manager
+   * and prepares for retrieving a new connection during
+   * the next request.
+   */
+  protected void releaseConnection() {
+    // Release the connection through the ManagedConnection instead of the
+    // ConnectionManager directly.  This lets the connection control how
+    // it is released.
+    try {
+      managedConn.releaseConnection();
+    } catch(IOException ignored) {
+      this.log.debug("IOException releasing connection", ignored);
+    }
+    managedConn = null;
+  }
+
+  /**
+   * Determines the route for a request.
+   * Called by {@link #execute}
+   * to determine the route for either the original or a followup request.
+   *
+   * @param target    the target host for the request.
+   *                  Implementations may accept {@code null}
+   *                  if they can still determine a route, for example
+   *                  to a default target or by inspecting the request.
+   * @param request   the request to execute
+   * @param context   the context to use for the execution,
+   *                  never {@code null}
+   *
+   * @return  the route the request should take
+   *
+   * @throws HttpException    in case of a problem
+   */
+  protected HttpRoute determineRoute(HttpHost    target,
+                       HttpRequest request,
+                       HttpContext context)
+    throws HttpException {
+
+    if (target == null) {
+      target = (HttpHost) request.getParams().getParameter(
+        ClientPNames.DEFAULT_HOST);
+    }
+    if (target == null) {
+      throw new IllegalStateException
+        ("Target host must not be null, or set in parameters.");
+    }
+
+    return this.routePlanner.determineRoute(target, request, context);
+  }
+
+
+  /**
+   * Establishes the target route.
+   *
+   * @param route     the route to establish
+   * @param context   the context for the request execution
+   *
+   * @throws HttpException    in case of a problem
+   * @throws IOException      in case of an IO problem
+   */
+  protected void establishRoute(HttpRoute route, HttpContext context)
+    throws HttpException, IOException {
+
+    HttpRouteDirector rowdy = new BasicRouteDirector();
+    int step;
+    do {
+      HttpRoute fact = managedConn.getRoute();
+      step = rowdy.nextStep(route, fact);
+
+      switch (step) {
+
+      case HttpRouteDirector.CONNECT_TARGET:
+      case HttpRouteDirector.CONNECT_PROXY:
+        managedConn.open(route, context, this.params);
+        break;
+
+      case HttpRouteDirector.TUNNEL_TARGET: {
+        boolean secure = createTunnelToTarget(route, context);
+        this.log.debug("Tunnel to target created.");
+        managedConn.tunnelTarget(secure, this.params);
+      }   break;
+
+      case HttpRouteDirector.TUNNEL_PROXY: {
+        // The most simple example for this case is a proxy chain
+        // of two proxies, where P1 must be tunnelled to P2.
+        // route: Source -> P1 -> P2 -> Target (3 hops)
+        // fact:  Source -> P1 -> Target       (2 hops)
+        final int hop = fact.getHopCount()-1; // the hop to establish
+        boolean secure = createTunnelToProxy(route, hop, context);
+        this.log.debug("Tunnel to proxy created.");
+        managedConn.tunnelProxy(route.getHopTarget(hop),
+                    secure, this.params);
+      }   break;
+
+
+      case HttpRouteDirector.LAYER_PROTOCOL:
+        managedConn.layerProtocol(context, this.params);
+        break;
+
+      case HttpRouteDirector.UNREACHABLE:
+        throw new IllegalStateException
+          ("Unable to establish route." +
+           "\nplanned = " + route +
+           "\ncurrent = " + fact);
+
+      case HttpRouteDirector.COMPLETE:
+        // do nothing
+        break;
+
+      default:
+        throw new IllegalStateException
+          ("Unknown step indicator "+step+" from RouteDirector.");
+      } // switch
+
+    } while (step > HttpRouteDirector.COMPLETE);
+
+  } // establishConnection
+
+
+  /**
+   * Creates a tunnel to the target server.
+   * The connection must be established to the (last) proxy.
+   * A CONNECT request for tunnelling through the proxy will
+   * be created and sent, the response received and checked.
+   * This method does <i>not</i> update the connection with
+   * information about the tunnel, that is left to the caller.
+   *
+   * @param route     the route to establish
+   * @param context   the context for request execution
+   *
+   * @return  {@code true} if the tunnelled route is secure,
+   *          {@code false} otherwise.
+   *          The implementation here always returns {@code false},
+   *          but derived classes may override.
+   *
+   * @throws HttpException    in case of a problem
+   * @throws IOException      in case of an IO problem
+   */
+  protected boolean createTunnelToTarget(HttpRoute route,
+                       HttpContext context)
+    throws HttpException, IOException {
+
+    HttpHost proxy = route.getProxyHost();
+    HttpHost target = route.getTargetHost();
+    HttpResponse response = null;
+
+    boolean done = false;
+    while (!done) {
+
+      done = true;
+
+      if (!this.managedConn.isOpen()) {
+        this.managedConn.open(route, context, this.params);
+      }
+
+      HttpRequest connect = createConnectRequest(route, context);
+      connect.setParams(this.params);
+
+      // Populate the execution context
+      context.setAttribute(ExecutionContext.HTTP_TARGET_HOST,
+          target);
+      context.setAttribute(ExecutionContext.HTTP_PROXY_HOST,
+          proxy);
+      context.setAttribute(ExecutionContext.HTTP_CONNECTION,
+          managedConn);
+      context.setAttribute(ClientContext.TARGET_AUTH_STATE,
+          targetAuthState);
+      context.setAttribute(ClientContext.PROXY_AUTH_STATE,
+          proxyAuthState);
+      context.setAttribute(ExecutionContext.HTTP_REQUEST,
+          connect);
+
+      this.requestExec.preProcess(connect, this.httpProcessor, context);
+
+      response = this.requestExec.execute(connect, this.managedConn, context);
+
+      response.setParams(this.params);
+      this.requestExec.postProcess(response, this.httpProcessor, context);
+
+      int status = response.getStatusLine().getStatusCode();
+      if (status < 200) {
+        throw new HttpException("Unexpected response to CONNECT request: " +
+            response.getStatusLine());
+      }
+
+      CredentialsProvider credsProvider = (CredentialsProvider)
+        context.getAttribute(ClientContext.CREDS_PROVIDER);
+
+      if (credsProvider != null && HttpClientParams.isAuthenticating(params)) {
+        if (this.proxyAuthHandler.isAuthenticationRequested(response, context)) {
+
+          this.log.debug("Proxy requested authentication");
+          Map<String, Header> challenges = this.proxyAuthHandler.getChallenges(
+              response, context);
+          try {
+            processChallenges(
+                challenges, this.proxyAuthState, this.proxyAuthHandler,
+                response, context);
+          } catch (AuthenticationException ex) {
+            if (this.log.isWarnEnabled()) {
+              this.log.warn("Authentication error: " +  ex.getMessage());
+              break;
+            }
+          }
+          updateAuthState(this.proxyAuthState, proxy, credsProvider);
+
+          if (this.proxyAuthState.getCredentials() != null) {
+            done = false;
+
+            // Retry request
+            if (this.reuseStrategy.keepAlive(response, context)) {
+              this.log.debug("Connection kept alive");
+              // Consume response content
+              HttpEntity entity = response.getEntity();
+              if (entity != null) {
+                entity.consumeContent();
+              }
+            } else {
+              this.managedConn.close();
+            }
+
+          }
+
+        } else {
+          // Reset proxy auth scope
+          this.proxyAuthState.setAuthScope(null);
+        }
+      }
+    }
+
+    int status = response.getStatusLine().getStatusCode(); // can't be null
+
+    if (status > 299) {
+
+      // Buffer response content
+      HttpEntity entity = response.getEntity();
+      if (entity != null) {
+        response.setEntity(new BufferedHttpEntity(entity));
+      }
+
+      this.managedConn.close();
+      throw new TunnelRefusedException("CONNECT refused by proxy: " +
+          response.getStatusLine(), response);
+    }
+
+    this.managedConn.markReusable();
+
+    // How to decide on security of the tunnelled connection?
+    // The socket factory knows only about the segment to the proxy.
+    // Even if that is secure, the hop to the target may be insecure.
+    // Leave it to derived classes, consider insecure by default here.
+    return false;
+
+  } // createTunnelToTarget
+
+
+
+  /**
+   * Creates a tunnel to an intermediate proxy.
+   * This method is <i>not</i> implemented in this class.
+   * It just throws an exception here.
+   *
+   * @param route     the route to establish
+   * @param hop       the hop in the route to establish now.
+   *                  {@code route.getHopTarget(hop)}
+   *                  will return the proxy to tunnel to.
+   * @param context   the context for request execution
+   *
+   * @return  {@code true} if the partially tunnelled connection
+   *          is secure, {@code false} otherwise.
+   *
+   * @throws HttpException    in case of a problem
+   * @throws IOException      in case of an IO problem
+   */
+  protected boolean createTunnelToProxy(HttpRoute route, int hop,
+                      HttpContext context)
+    throws HttpException, IOException {
+
+    // Have a look at createTunnelToTarget and replicate the parts
+    // you need in a custom derived class. If your proxies don't require
+    // authentication, it is not too hard. But for the stock version of
+    // HttpClient, we cannot make such simplifying assumptions and would
+    // have to include proxy authentication code. The HttpComponents team
+    // is currently not in a position to support rarely used code of this
+    // complexity. Feel free to submit patches that refactor the code in
+    // createTunnelToTarget to facilitate re-use for proxy tunnelling.
+
+    throw new UnsupportedOperationException
+      ("Proxy chains are not supported.");
+  }
+
+
+
+  /**
+   * Creates the CONNECT request for tunnelling.
+   * Called by {@link #createTunnelToTarget createTunnelToTarget}.
+   *
+   * @param route     the route to establish
+   * @param context   the context for request execution
+   *
+   * @return  the CONNECT request for tunnelling
+   */
+  protected HttpRequest createConnectRequest(HttpRoute route,
+                         HttpContext context) {
+    // see RFC 2817, section 5.2 and
+    // INTERNET-DRAFT: Tunneling TCP based protocols through
+    // Web proxy servers
+
+    HttpHost target = route.getTargetHost();
+
+    String host = target.getHostName();
+    int port = target.getPort();
+    if (port < 0) {
+      Scheme scheme = connManager.getSchemeRegistry().
+        getScheme(target.getSchemeName());
+      port = scheme.getDefaultPort();
+    }
+
+    StringBuilder buffer = new StringBuilder(host.length() + 6);
+    buffer.append(host);
+    buffer.append(':');
+    buffer.append(Integer.toString(port));
+
+    String authority = buffer.toString();
+    ProtocolVersion ver = HttpProtocolParams.getVersion(params);
+    HttpRequest req = new BasicHttpRequest
+      ("CONNECT", authority, ver);
+
+    return req;
+  }
+
+
+  /**
+   * Analyzes a response to check need for a followup.
+   *
+   * @param roureq    the request and route.
+   * @param response  the response to analayze
+   * @param context   the context used for the current request execution
+   *
+   * @return  the followup request and route if there is a followup, or
+   *          {@code null} if the response should be returned as is
+   *
+   * @throws HttpException    in case of a problem
+   * @throws IOException      in case of an IO problem
+   */
+  protected RoutedRequest handleResponse(RoutedRequest roureq,
+                       HttpResponse response,
+                       HttpContext context)
+    throws HttpException, IOException {
+
+    HttpRoute route = roureq.getRoute();
+    RequestWrapper request = roureq.getRequest();
+
+    HttpParams params = request.getParams();
+    if (HttpClientParams.isRedirecting(params) &&
+        this.redirectHandler.isRedirectRequested(response, context)) {
+
+      if (redirectCount >= maxRedirects) {
+        throw new RedirectException("Maximum redirects ("
+            + maxRedirects + ") exceeded");
+      }
+      redirectCount++;
+
+      // Virtual host cannot be used any longer
+      virtualHost = null;
+
+      URI uri = this.redirectHandler.getLocationURI(response, context);
+
+      HttpHost newTarget = new HttpHost(
+          uri.getHost(),
+          uri.getPort(),
+          uri.getScheme());
+
+      // Unset auth scope
+      targetAuthState.setAuthScope(null);
+      proxyAuthState.setAuthScope(null);
+
+      // Invalidate auth states if redirecting to another host
+      if (!route.getTargetHost().equals(newTarget)) {
+        targetAuthState.invalidate();
+        AuthScheme authScheme = proxyAuthState.getAuthScheme();
+        if (authScheme != null && authScheme.isConnectionBased()) {
+          proxyAuthState.invalidate();
+        }
+      }
+
+      HttpRedirect redirect = new HttpRedirect(request.getMethod(), uri);
+      HttpRequest orig = request.getOriginal();
+      redirect.setHeaders(orig.getAllHeaders());
+
+      RequestWrapper wrapper = new RequestWrapper(redirect);
+      wrapper.setParams(params);
+
+      HttpRoute newRoute = determineRoute(newTarget, wrapper, context);
+      RoutedRequest newRequest = new RoutedRequest(wrapper, newRoute);
+
+      if (this.log.isDebugEnabled()) {
+        this.log.debug("Redirecting to '" + uri + "' via " + newRoute);
+      }
+
+      return newRequest;
+    }
+
+    CredentialsProvider credsProvider = (CredentialsProvider)
+      context.getAttribute(ClientContext.CREDS_PROVIDER);
+
+    if (credsProvider != null && HttpClientParams.isAuthenticating(params)) {
+
+      if (this.targetAuthHandler.isAuthenticationRequested(response, context)) {
+
+        HttpHost target = (HttpHost)
+          context.getAttribute(ExecutionContext.HTTP_TARGET_HOST);
+        if (target == null) {
+          target = route.getTargetHost();
+        }
+
+        this.log.debug("Target requested authentication");
+        Map<String, Header> challenges = this.targetAuthHandler.getChallenges(
+            response, context);
+        try {
+          processChallenges(challenges,
+              this.targetAuthState, this.targetAuthHandler,
+              response, context);
+        } catch (AuthenticationException ex) {
+          if (this.log.isWarnEnabled()) {
+            this.log.warn("Authentication error: " +  ex.getMessage());
+            return null;
+          }
+        }
+        updateAuthState(this.targetAuthState, target, credsProvider);
+
+        if (this.targetAuthState.getCredentials() != null) {
+          // Re-try the same request via the same route
+          return roureq;
+        } else {
+          return null;
+        }
+      } else {
+        // Reset target auth scope
+        this.targetAuthState.setAuthScope(null);
+      }
+
+      if (this.proxyAuthHandler.isAuthenticationRequested(response, context)) {
+
+        HttpHost proxy = route.getProxyHost();
+
+        this.log.debug("Proxy requested authentication");
+        Map<String, Header> challenges = this.proxyAuthHandler.getChallenges(
+            response, context);
+        try {
+          processChallenges(challenges,
+              this.proxyAuthState, this.proxyAuthHandler,
+              response, context);
+        } catch (AuthenticationException ex) {
+          if (this.log.isWarnEnabled()) {
+            this.log.warn("Authentication error: " +  ex.getMessage());
+            return null;
+          }
+        }
+        updateAuthState(this.proxyAuthState, proxy, credsProvider);
+
+        if (this.proxyAuthState.getCredentials() != null) {
+          // Re-try the same request via the same route
+          return roureq;
+        } else {
+          return null;
+        }
+      } else {
+        // Reset proxy auth scope
+        this.proxyAuthState.setAuthScope(null);
+      }
+    }
+    return null;
+  } // handleResponse
+
+
+  /**
+   * Shuts down the connection.
+   * This method is called from a {@code catch} block in
+   * {@link #execute execute} during exception handling.
+   */
+  private void abortConnection() {
+    ManagedClientConnection mcc = managedConn;
+    if (mcc != null) {
+      // we got here as the result of an exception
+      // no response will be returned, release the connection
+      managedConn = null;
+      try {
+        mcc.abortConnection();
+      } catch (IOException ex) {
+        if (this.log.isDebugEnabled()) {
+          this.log.debug(ex.getMessage(), ex);
+        }
+      }
+      // ensure the connection manager properly releases this connection
+      try {
+        mcc.releaseConnection();
+      } catch(IOException ignored) {
+        this.log.debug("Error releasing connection", ignored);
+      }
+    }
+  } // abortConnection
+
+
+  private void processChallenges(
+      final Map<String, Header> challenges,
+      final AuthState authState,
+      final AuthenticationHandler authHandler,
+      final HttpResponse response,
+      final HttpContext context)
+        throws MalformedChallengeException, AuthenticationException {
+
+    AuthScheme authScheme = authState.getAuthScheme();
+    if (authScheme == null) {
+      // Authentication not attempted before
+      authScheme = authHandler.selectScheme(challenges, response, context);
+      authState.setAuthScheme(authScheme);
+    }
+    String id = authScheme.getSchemeName();
+
+    Header challenge = challenges.get(id.toLowerCase(Locale.ENGLISH));
+    if (challenge == null) {
+      throw new AuthenticationException(id +
+        " authorization challenge expected, but not found");
+    }
+    authScheme.processChallenge(challenge);
+    this.log.debug("Authorization challenge processed");
+  }
+
+
+  private void updateAuthState(
+      final AuthState authState,
+      final HttpHost host,
+      final CredentialsProvider credsProvider) {
+
+    if (!authState.isValid()) {
+      return;
+    }
+
+    String hostname = host.getHostName();
+    int port = host.getPort();
+    if (port < 0) {
+      Scheme scheme = connManager.getSchemeRegistry().getScheme(host);
+      port = scheme.getDefaultPort();
+    }
+
+    AuthScheme authScheme = authState.getAuthScheme();
+    AuthScope authScope = new AuthScope(
+        hostname,
+        port,
+        authScheme.getRealm(),
+        authScheme.getSchemeName());
+
+    if (this.log.isDebugEnabled()) {
+      this.log.debug("Authentication scope: " + authScope);
+    }
+    Credentials creds = authState.getCredentials();
+    if (creds == null) {
+      creds = credsProvider.getCredentials(authScope);
+      if (this.log.isDebugEnabled()) {
+        if (creds != null) {
+          this.log.debug("Found credentials");
+        } else {
+          this.log.debug("Credentials not found");
+        }
+      }
+    } else {
+      if (authScheme.isComplete()) {
+        this.log.debug("Authentication failed");
+        creds = null;
+      }
+    }
+    authState.setAuthScope(authScope);
+    authState.setCredentials(creds);
+  }
+
+} // class DefaultClientRequestDirector
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/FakeHttp.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/FakeHttp.java
new file mode 100644
index 0000000..8b2f4a3
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/FakeHttp.java
@@ -0,0 +1,196 @@
+package org.robolectric.shadows.httpclient;
+
+import com.google.errorprone.annotations.InlineMe;
+import java.util.List;
+import org.apache.http.Header;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+
+/**
+ * Collection of static methods used interact with HTTP requests / responses.
+ */
+public class FakeHttp {
+  private static FakeHttpLayer instance = new FakeHttpLayer();
+
+  /**
+   * Sets up an HTTP response to be returned by calls to Apache's {@code HttpClient} implementers.
+   *
+   * @param statusCode   the status code of the response
+   * @param responseBody the body of the response
+   * @param headers      optional headers for the request
+   */
+  public static void addPendingHttpResponse(int statusCode, String responseBody, Header... headers) {
+    getFakeHttpLayer().addPendingHttpResponse(statusCode, responseBody, headers);
+  }
+
+  /**
+   * Sets up an HTTP response to be returned by calls to Apache's {@code HttpClient} implementers.
+   *
+   * @param statusCode the status code of the response
+   * @param responseBody the body of the response
+   * @param contentType the contentType of the response
+   * @deprecated use {@link #addPendingHttpResponse(int, String, org.apache.http.Header...)} instead
+   */
+  @Deprecated
+  @InlineMe(
+      replacement =
+          "FakeHttp.getFakeHttpLayer().addPendingHttpResponse(statusCode, responseBody,"
+              + " contentType)",
+      imports = "org.robolectric.shadows.httpclient.FakeHttp")
+  public static final void addPendingHttpResponseWithContentType(
+      int statusCode, String responseBody, Header contentType) {
+    getFakeHttpLayer().addPendingHttpResponse(statusCode, responseBody, contentType);
+  }
+
+  /**
+   * Sets up an HTTP response to be returned by calls to Apache's {@code HttpClient} implementers.
+   *
+   * @param httpResponse the response
+   */
+  public static void addPendingHttpResponse(HttpResponse httpResponse) {
+    getFakeHttpLayer().addPendingHttpResponse(httpResponse);
+  }
+
+  /**
+   * Sets up an HTTP response to be returned by calls to Apache's {@code HttpClient} implementers.
+   *
+   * @param httpResponseGenerator an HttpResponseGenerator that will provide responses
+   */
+  public static void addPendingHttpResponse(HttpResponseGenerator httpResponseGenerator) {
+    getFakeHttpLayer().addPendingHttpResponse(httpResponseGenerator);
+  }
+
+  /**
+   * Accessor to obtain HTTP requests made during the current test in the order in which they were made.
+   *
+   * @param index index of the request to retrieve.
+   * @return the requested request.
+   */
+  public static HttpRequest getSentHttpRequest(int index) {
+    return getFakeHttpLayer().getSentHttpRequestInfo(index).getHttpRequest();
+  }
+
+  public static HttpRequest getLatestSentHttpRequest() {
+    return ShadowDefaultRequestDirector.getLatestSentHttpRequest();
+  }
+
+  /**
+   * Accessor to find out if HTTP requests were made during the current test.
+   *
+   * @return whether a request was made.
+   */
+  public static boolean httpRequestWasMade() {
+    return getFakeHttpLayer().hasRequestInfos();
+  }
+
+  public static boolean httpRequestWasMade(String uri) {
+    return getFakeHttpLayer().hasRequestMatchingRule(
+        new FakeHttpLayer.UriRequestMatcher(uri));
+  }
+
+  /**
+   * Accessor to obtain metadata for an HTTP request made during the current test in the order in which they were made.
+   *
+   * @param index index of the request to retrieve.
+   * @return the requested request metadata.
+   */
+  public static HttpRequestInfo getSentHttpRequestInfo(int index) {
+    return getFakeHttpLayer().getSentHttpRequestInfo(index);
+  }
+
+  /**
+   * Accessor to obtain HTTP requests made during the current test in the order in which they were made.
+   *
+   * @return the requested request or null if there are none.
+   */
+  public static HttpRequest getNextSentHttpRequest() {
+    HttpRequestInfo httpRequestInfo = getFakeHttpLayer().getNextSentHttpRequestInfo();
+    return httpRequestInfo == null ? null : httpRequestInfo.getHttpRequest();
+  }
+
+  /**
+   * Accessor to obtain metadata for an HTTP request made during the current test in the order in which they were made.
+   *
+   * @return the requested request metadata or null if there are none.
+   */
+  public static HttpRequestInfo getNextSentHttpRequestInfo() {
+    return getFakeHttpLayer().getNextSentHttpRequestInfo();
+  }
+
+  /**
+   * Adds an HTTP response rule. The response will be returned when the rule is matched.
+   *
+   * @param method   method to match.
+   * @param uri      uri to match.
+   * @param response response to return when a match is found.
+   */
+  public static void addHttpResponseRule(String method, String uri, HttpResponse response) {
+    getFakeHttpLayer().addHttpResponseRule(method, uri, response);
+  }
+
+  /**
+   * Adds an HTTP response rule with a default method of GET. The response will be returned when the rule is matched.
+   *
+   * @param uri      uri to match.
+   * @param response response to return when a match is found.
+   */
+  public static void addHttpResponseRule(String uri, HttpResponse response) {
+    getFakeHttpLayer().addHttpResponseRule(uri, response);
+  }
+
+  /**
+   * Adds an HTTP response rule. The response will be returned when the rule is matched.
+   *
+   * @param uri      uri to match.
+   * @param response response to return when a match is found.
+   */
+  public static void addHttpResponseRule(String uri, String response) {
+    getFakeHttpLayer().addHttpResponseRule(uri, response);
+  }
+
+  /**
+   * Adds an HTTP response rule. The response will be returned when the rule is matched.
+   *
+   * @param requestMatcher custom {@code RequestMatcher}.
+   * @param response       response to return when a match is found.
+   */
+  public static void addHttpResponseRule(RequestMatcher requestMatcher, HttpResponse response) {
+    getFakeHttpLayer().addHttpResponseRule(requestMatcher, response);
+  }
+
+  /**
+   * Adds an HTTP response rule. For each time the rule is matched, responses will be shifted
+   * off the list and returned. When all responses have been given and the rule is matched again,
+   * an exception will be thrown.
+   *
+   * @param requestMatcher custom {@code RequestMatcher}.
+   * @param responses      responses to return in order when a match is found.
+   */
+  public static void addHttpResponseRule(RequestMatcher requestMatcher, List<? extends HttpResponse> responses) {
+    getFakeHttpLayer().addHttpResponseRule(requestMatcher, responses);
+  }
+
+  public static FakeHttpLayer getFakeHttpLayer() {
+    return instance;
+  }
+
+  public static void setDefaultHttpResponse(int statusCode, String responseBody) {
+    getFakeHttpLayer().setDefaultHttpResponse(statusCode, responseBody);
+  }
+
+  public static void setDefaultHttpResponse(HttpResponse defaultHttpResponse) {
+    getFakeHttpLayer().setDefaultHttpResponse(defaultHttpResponse);
+  }
+
+  public static void clearHttpResponseRules() {
+    getFakeHttpLayer().clearHttpResponseRules();
+  }
+
+  public static void clearPendingHttpResponses() {
+    getFakeHttpLayer().clearPendingHttpResponses();
+  }
+
+  public static void reset() {
+    instance = new FakeHttpLayer();
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/FakeHttpLayer.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/FakeHttpLayer.java
new file mode 100644
index 0000000..ba508ba
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/FakeHttpLayer.java
@@ -0,0 +1,498 @@
+package org.robolectric.shadows.httpclient;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpException;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.RequestDirector;
+import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
+import org.apache.http.conn.ConnectTimeoutException;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+import org.apache.http.protocol.HttpContext;
+
+public class FakeHttpLayer {
+  private final List<HttpResponseGenerator> pendingHttpResponses = new ArrayList<>();
+  private final List<HttpRequestInfo> httpRequestInfos = new ArrayList<>();
+  private final List<HttpResponse> httpResponses = new ArrayList<>();
+  private final List<HttpEntityStub.ResponseRule> httpResponseRules = new ArrayList<>();
+  private HttpResponse defaultHttpResponse;
+  private boolean interceptHttpRequests = true;
+  private boolean logHttpRequests = false;
+  private List<byte[]> httpResposeContent = new ArrayList<>();
+  private boolean interceptResponseContent;
+
+  public HttpRequestInfo getLastSentHttpRequestInfo() {
+    List<HttpRequestInfo> requestInfos = getSentHttpRequestInfos();
+    if (requestInfos.isEmpty()) {
+      return null;
+    }
+    return requestInfos.get(requestInfos.size() - 1);
+  }
+
+  public void addPendingHttpResponse(int statusCode, String responseBody, Header... headers) {
+    addPendingHttpResponse(new TestHttpResponse(statusCode, responseBody, headers));
+  }
+
+  public void addPendingHttpResponse(final HttpResponse httpResponse) {
+    addPendingHttpResponse(new HttpResponseGenerator() {
+      @Override
+      public HttpResponse getResponse(HttpRequest request) {
+        return httpResponse;
+      }
+    });
+  }
+
+  public void addPendingHttpResponse(HttpResponseGenerator httpResponseGenerator) {
+    pendingHttpResponses.add(httpResponseGenerator);
+  }
+
+  public void addHttpResponseRule(String method, String uri, HttpResponse response) {
+    addHttpResponseRule(new DefaultRequestMatcher(method, uri), response);
+  }
+
+  public void addHttpResponseRule(String uri, HttpResponse response) {
+    addHttpResponseRule(new UriRequestMatcher(uri), response);
+  }
+
+  public void addHttpResponseRule(String uri, String response) {
+    addHttpResponseRule(new UriRequestMatcher(uri), new TestHttpResponse(200, response));
+  }
+
+  public void addHttpResponseRule(RequestMatcher requestMatcher, HttpResponse response) {
+    addHttpResponseRule(new RequestMatcherResponseRule(requestMatcher, response));
+  }
+
+  /**
+   * Add a response rule.
+   *
+   * @param requestMatcher Request matcher
+   * @param responses      A list of responses that are returned to matching requests in order from first to last.
+   */
+  public void addHttpResponseRule(RequestMatcher requestMatcher, List<? extends HttpResponse> responses) {
+    addHttpResponseRule(new RequestMatcherResponseRule(requestMatcher, responses));
+  }
+
+  public void addHttpResponseRule(HttpEntityStub.ResponseRule responseRule) {
+    httpResponseRules.add(0, responseRule);
+  }
+
+  public void setDefaultHttpResponse(HttpResponse defaultHttpResponse) {
+    this.defaultHttpResponse = defaultHttpResponse;
+  }
+
+  public void setDefaultHttpResponse(int statusCode, String responseBody) {
+    setDefaultHttpResponse(new TestHttpResponse(statusCode, responseBody));
+  }
+
+  private HttpResponse findResponse(HttpRequest httpRequest) throws HttpException, IOException {
+    if (!pendingHttpResponses.isEmpty()) {
+      return pendingHttpResponses.remove(0).getResponse(httpRequest);
+    }
+
+    for (HttpEntityStub.ResponseRule httpResponseRule : httpResponseRules) {
+      if (httpResponseRule.matches(httpRequest)) {
+        return httpResponseRule.getResponse();
+      }
+    }
+
+    System.err.println("Unexpected HTTP call " + httpRequest.getRequestLine());
+
+    return defaultHttpResponse;
+  }
+
+  public HttpResponse emulateRequest(HttpHost httpHost, HttpRequest httpRequest, HttpContext httpContext, RequestDirector requestDirector) throws HttpException, IOException {
+    if (logHttpRequests) {
+      System.out.println("  <-- " + httpRequest.getRequestLine());
+    }
+    HttpResponse httpResponse = findResponse(httpRequest);
+    if (logHttpRequests) {
+      System.out.println(
+          "  --> " + (httpResponse == null ? null : httpResponse.getStatusLine().getStatusCode()));
+    }
+
+    if (httpResponse == null) {
+      throw new RuntimeException(
+          "Unexpected call to execute, no pending responses are available. See"
+              + " Robolectric.addPendingResponse(). Request was: "
+              + httpRequest.getRequestLine().getMethod()
+              + " "
+              + httpRequest.getRequestLine().getUri());
+    } else {
+      HttpParams params = httpResponse.getParams();
+
+      if (HttpConnectionParams.getConnectionTimeout(params) < 0) {
+        throw new ConnectTimeoutException("Socket is not connected");
+      } else if (HttpConnectionParams.getSoTimeout(params) < 0) {
+        throw new ConnectTimeoutException("The operation timed out");
+      }
+    }
+
+    addRequestInfo(new HttpRequestInfo(httpRequest, httpHost, httpContext, requestDirector));
+    addHttpResponse(httpResponse);
+    return httpResponse;
+  }
+  public boolean hasPendingResponses() {
+    return !pendingHttpResponses.isEmpty();
+  }
+
+  public boolean hasRequestInfos() {
+    return !httpRequestInfos.isEmpty();
+  }
+
+  public void clearRequestInfos() {
+    httpRequestInfos.clear();
+  }
+
+  /**
+   * This method is not supposed to be consumed by tests. This exists solely for the purpose of
+   * logging real HTTP requests, so that functional/integration tests can verify if those were made, without
+   * messing with the fake http layer to actually perform the http call, instead of returning a mocked response.
+   *
+   * If you are just using mocked http calls, you should not even notice this method here.
+   *
+   * @param requestInfo Request info object to add.
+   */
+  public void addRequestInfo(HttpRequestInfo requestInfo) {
+    httpRequestInfos.add(requestInfo);
+  }
+
+  public boolean hasResponseRules() {
+    return !httpResponseRules.isEmpty();
+  }
+
+  public boolean hasRequestMatchingRule(RequestMatcher rule) {
+    for (HttpRequestInfo requestInfo : httpRequestInfos) {
+      if (rule.matches(requestInfo.httpRequest)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public HttpRequestInfo getSentHttpRequestInfo(int index) {
+    return httpRequestInfos.get(index);
+  }
+
+  public HttpRequestInfo getNextSentHttpRequestInfo() {
+    return httpRequestInfos.size() > 0 ? httpRequestInfos.remove(0) : null;
+  }
+
+  public void logHttpRequests() {
+    logHttpRequests = true;
+  }
+
+  public void silence() {
+    logHttpRequests = false;
+  }
+
+  public List<HttpRequestInfo> getSentHttpRequestInfos() {
+    return new ArrayList<>(httpRequestInfos);
+  }
+
+  public void clearHttpResponseRules() {
+    httpResponseRules.clear();
+  }
+
+  public void clearPendingHttpResponses() {
+    pendingHttpResponses.clear();
+  }
+
+  /**
+   * This method return a list containing all the HTTP responses logged by the fake http layer, be it
+   * mocked http responses, be it real http calls (if {code}interceptHttpRequests{/code} is set to false).
+   *
+   * It doesn't make much sense to call this method if said property is set to true, as you yourself are
+   * providing the response, but it's here nonetheless.
+   *
+   * @return List of all HTTP Responses logged by the fake http layer.
+   */
+  public List<HttpResponse> getHttpResponses() {
+    return new ArrayList<>(httpResponses);
+  }
+
+  /**
+   * As a consumer of the fake http call, you should never call this method. This should be used solely
+   * by components that exercises http calls.
+   *
+   * @param response The final response received by the server
+   */
+  public void addHttpResponse(HttpResponse response) {
+    this.httpResponses.add(response);
+  }
+
+  public void addHttpResponseContent(byte[] content) {
+    this.httpResposeContent.add(content);
+  }
+
+  public List<byte[]> getHttpResposeContentList() {
+    return httpResposeContent;
+  }
+
+  /**
+   * Helper method that returns the latest received response from the server.
+   * @return The latest HTTP response or null, if no responses are available
+   */
+  public HttpResponse getLastHttpResponse() {
+    if (httpResponses.isEmpty()) return null;
+    return httpResponses.get(httpResponses.size()-1) ;
+  }
+
+  /**
+   * Call this method if you want to ensure that there's no http responses logged from this point until
+   * the next response arrives. Helpful to ensure that the state is "clear" before actions are executed.
+   */
+  public void clearHttpResponses() {
+    this.httpResponses.clear();
+  }
+
+  /**
+   * You can disable Robolectric's fake HTTP layer temporarily
+   * by calling this method.
+   * @param interceptHttpRequests whether all HTTP requests should be
+   *                              intercepted (true by default)
+   */
+  public void interceptHttpRequests(boolean interceptHttpRequests) {
+    this.interceptHttpRequests = interceptHttpRequests;
+  }
+
+  public boolean isInterceptingHttpRequests() {
+    return interceptHttpRequests;
+  }
+
+  public void interceptResponseContent(boolean interceptResponseContent) {
+    this.interceptResponseContent = interceptResponseContent;
+  }
+
+  public boolean isInterceptingResponseContent() {
+    return interceptResponseContent;
+  }
+
+  public static class RequestMatcherResponseRule implements HttpEntityStub.ResponseRule {
+    private RequestMatcher requestMatcher;
+    private HttpResponse responseToGive;
+    private IOException ioException;
+    private HttpException httpException;
+    private List<? extends HttpResponse> responses;
+
+    public RequestMatcherResponseRule(RequestMatcher requestMatcher, HttpResponse responseToGive) {
+      this.requestMatcher = requestMatcher;
+      this.responseToGive = responseToGive;
+    }
+
+    public RequestMatcherResponseRule(RequestMatcher requestMatcher, IOException ioException) {
+      this.requestMatcher = requestMatcher;
+      this.ioException = ioException;
+    }
+
+    public RequestMatcherResponseRule(RequestMatcher requestMatcher, HttpException httpException) {
+      this.requestMatcher = requestMatcher;
+      this.httpException = httpException;
+    }
+
+    public RequestMatcherResponseRule(RequestMatcher requestMatcher, List<? extends HttpResponse> responses) {
+      this.requestMatcher = requestMatcher;
+      this.responses = responses;
+    }
+
+    @Override
+    public boolean matches(HttpRequest request) {
+      return requestMatcher.matches(request);
+    }
+
+    @Override
+    public HttpResponse getResponse() throws HttpException, IOException {
+      if (httpException != null) throw httpException;
+      if (ioException != null) throw ioException;
+      if (responseToGive != null) {
+        return responseToGive;
+      } else {
+        if (responses.isEmpty()) {
+          throw new RuntimeException("No more responses left to give");
+        }
+        return responses.remove(0);
+      }
+    }
+  }
+
+  public static class DefaultRequestMatcher implements RequestMatcher {
+    private String method;
+    private String uri;
+
+    public DefaultRequestMatcher(String method, String uri) {
+      this.method = method;
+      this.uri = uri;
+    }
+
+    @Override
+    public boolean matches(HttpRequest request) {
+      return request.getRequestLine().getMethod().equals(method) &&
+          request.getRequestLine().getUri().equals(uri);
+    }
+  }
+
+  public static class UriRequestMatcher implements RequestMatcher {
+    private String uri;
+
+    public UriRequestMatcher(String uri) {
+      this.uri = uri;
+    }
+
+    @Override
+    public boolean matches(HttpRequest request) {
+      return request.getRequestLine().getUri().equals(uri);
+    }
+  }
+
+  public static class RequestMatcherBuilder implements RequestMatcher {
+    private String method, hostname, path;
+    private boolean noParams;
+    private Map<String, String> params = new HashMap<>();
+    private Map<String, String> headers = new HashMap<>();
+    private PostBodyMatcher postBodyMatcher;
+
+    public interface PostBodyMatcher {
+      /**
+       * Hint: you can use EntityUtils.toString(actualPostBody) to help you implement your matches method.
+       *
+       * @param actualPostBody The post body of the actual request that we are matching against.
+       * @return true if you consider the body to match
+       * @throws IOException Get turned into a RuntimeException to cause your test to fail.
+       */
+      boolean matches(HttpEntity actualPostBody) throws IOException;
+    }
+
+    public RequestMatcherBuilder method(String method) {
+      this.method = method;
+      return this;
+    }
+
+    public RequestMatcherBuilder host(String hostname) {
+      this.hostname = hostname;
+      return this;
+    }
+
+    public RequestMatcherBuilder path(String path) {
+      if (path.startsWith("/")) {
+        throw new RuntimeException("Path should not start with '/'");
+      }
+      this.path = "/" + path;
+      return this;
+    }
+
+    public RequestMatcherBuilder param(String name, String value) {
+      params.put(name, value);
+      return this;
+    }
+
+    public RequestMatcherBuilder noParams() {
+      noParams = true;
+      return this;
+    }
+
+    public RequestMatcherBuilder postBody(PostBodyMatcher postBodyMatcher) {
+      this.postBodyMatcher = postBodyMatcher;
+      return this;
+    }
+
+    public RequestMatcherBuilder header(String name, String value) {
+      headers.put(name, value);
+      return this;
+    }
+
+    @Override
+    public boolean matches(HttpRequest request) {
+      URI uri = URI.create(request.getRequestLine().getUri());
+      if (method != null && !method.equals(request.getRequestLine().getMethod())) {
+        return false;
+      }
+      if (hostname != null && !hostname.equals(uri.getHost())) {
+        return false;
+      }
+      if (path != null && !path.equals(uri.getRawPath())) {
+        return false;
+      }
+      if (noParams && uri.getRawQuery() != null) {
+        return false;
+      }
+      if (params.size() > 0) {
+        Map<String, String> requestParams = ParamsParser.parseParams(request);
+        if (!requestParams.equals(params)) {
+          return false;
+        }
+      }
+      if (headers.size() > 0) {
+        Map<String, String> actualRequestHeaders = new HashMap<>();
+        for (Header header : request.getAllHeaders()) {
+          actualRequestHeaders.put(header.getName(), header.getValue());
+        }
+        if (!headers.equals(actualRequestHeaders)) {
+          return false;
+        }
+      }
+      if (postBodyMatcher != null) {
+        if (!(request instanceof HttpEntityEnclosingRequestBase)) {
+          return false;
+        }
+        HttpEntityEnclosingRequestBase postOrPut = (HttpEntityEnclosingRequestBase) request;
+        try {
+          if (!postBodyMatcher.matches(postOrPut.getEntity())) {
+            return false;
+          }
+        } catch (IOException e) {
+          throw new RuntimeException(e);
+        }
+      }
+      return true;
+    }
+
+    public String getHostname() {
+      return hostname;
+    }
+
+    public String getPath() {
+      return path;
+    }
+
+    public String getParam(String key) {
+      return params.get(key);
+    }
+
+    public String getHeader(String key) {
+      return headers.get(key);
+    }
+
+    public boolean isNoParams() {
+      return noParams;
+    }
+
+    public String getMethod() {
+      return method;
+    }
+  }
+
+  public static class UriRegexMatcher implements RequestMatcher {
+    private String method;
+    private final Pattern uriRegex;
+
+    public UriRegexMatcher(String method, String uriRegex) {
+      this.method = method;
+      this.uriRegex = Pattern.compile(uriRegex);
+    }
+
+    @Override
+    public boolean matches(HttpRequest request) {
+      return request.getRequestLine().getMethod().equals(method) &&
+          uriRegex.matcher(request.getRequestLine().getUri()).matches();
+    }
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpEntityStub.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpEntityStub.java
new file mode 100644
index 0000000..119ea5f
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpEntityStub.java
@@ -0,0 +1,54 @@
+package org.robolectric.shadows.httpclient;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+
+public class HttpEntityStub implements HttpEntity {
+  @Override public boolean isRepeatable() {
+    return true;
+  }
+
+  @Override public boolean isChunked() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public long getContentLength() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Header getContentType() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Header getContentEncoding() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public InputStream getContent() throws IOException, IllegalStateException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void writeTo(OutputStream outputStream) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public boolean isStreaming() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void consumeContent() throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  public static interface ResponseRule {
+    boolean matches(HttpRequest request);
+
+    HttpResponse getResponse() throws HttpException, IOException;
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpRedirect.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpRedirect.java
new file mode 100644
index 0000000..6c7013f
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpRedirect.java
@@ -0,0 +1,60 @@
+// copied verbatim from httpclient-4.0.3 sources
+
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package org.robolectric.shadows.httpclient;
+
+import java.net.URI;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpHead;
+import org.apache.http.client.methods.HttpRequestBase;
+
+/**
+ * Redirect request (can be either GET or HEAD).
+ *
+ * @since 4.0
+ */
+class HttpRedirect extends HttpRequestBase {
+
+  private String method;
+
+  public HttpRedirect(final String method, final URI uri) {
+    super();
+    if (method.equalsIgnoreCase(HttpHead.METHOD_NAME)) {
+      this.method = HttpHead.METHOD_NAME;
+    } else {
+      this.method = HttpGet.METHOD_NAME;
+    }
+    setURI(uri);
+  }
+
+  @Override
+  public String getMethod() {
+    return this.method;
+  }
+
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpRequestInfo.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpRequestInfo.java
new file mode 100644
index 0000000..a0b9c80
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpRequestInfo.java
@@ -0,0 +1,36 @@
+package org.robolectric.shadows.httpclient;
+
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
+import org.apache.http.client.RequestDirector;
+import org.apache.http.protocol.HttpContext;
+
+public class HttpRequestInfo {
+  HttpRequest httpRequest;
+  HttpHost httpHost;
+  HttpContext httpContext;
+  RequestDirector requestDirector;
+
+  public HttpRequestInfo(HttpRequest httpRequest, HttpHost httpHost, HttpContext httpContext, RequestDirector requestDirector) {
+    this.httpRequest = httpRequest;
+    this.httpHost = httpHost;
+    this.httpContext = httpContext;
+    this.requestDirector = requestDirector;
+  }
+
+  public HttpRequest getHttpRequest() {
+    return httpRequest;
+  }
+
+  public HttpHost getHttpHost() {
+    return httpHost;
+  }
+
+  public HttpContext getHttpContext() {
+    return httpContext;
+  }
+
+  public RequestDirector getRequestDirector() {
+    return requestDirector;
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpResponseGenerator.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpResponseGenerator.java
new file mode 100644
index 0000000..23405a9
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpResponseGenerator.java
@@ -0,0 +1,8 @@
+package org.robolectric.shadows.httpclient;
+
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+
+public interface HttpResponseGenerator {
+  public HttpResponse getResponse(HttpRequest request);
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpResponseStub.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpResponseStub.java
new file mode 100644
index 0000000..26417fb
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/HttpResponseStub.java
@@ -0,0 +1,121 @@
+package org.robolectric.shadows.httpclient;
+
+import java.util.Locale;
+import org.apache.http.Header;
+import org.apache.http.HeaderIterator;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.ProtocolVersion;
+import org.apache.http.StatusLine;
+import org.apache.http.params.HttpParams;
+
+public class HttpResponseStub implements HttpResponse {
+  @Override public StatusLine getStatusLine() {
+    throw new UnsupportedOperationException();
+
+  }
+
+  @Override public void setStatusLine(StatusLine statusLine) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setStatusLine(ProtocolVersion protocolVersion, int i) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setStatusLine(ProtocolVersion protocolVersion, int i, String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setStatusCode(int i) throws IllegalStateException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setReasonPhrase(String s) throws IllegalStateException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public HttpEntity getEntity() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setEntity(HttpEntity httpEntity) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Locale getLocale() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setLocale(Locale locale) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public ProtocolVersion getProtocolVersion() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public boolean containsHeader(String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Header[] getHeaders(String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Header getFirstHeader(String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Header getLastHeader(String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public Header[] getAllHeaders() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void addHeader(Header header) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void addHeader(String s, String s1) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setHeader(Header header) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setHeader(String s, String s1) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setHeaders(Header[] headers) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void removeHeader(Header header) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void removeHeaders(String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public HeaderIterator headerIterator() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public HeaderIterator headerIterator(String s) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public HttpParams getParams() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public void setParams(HttpParams httpParams) {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/ParamsParser.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/ParamsParser.java
new file mode 100644
index 0000000..700ebae
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/ParamsParser.java
@@ -0,0 +1,54 @@
+package org.robolectric.shadows.httpclient;
+
+import android.net.Uri;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpRequest;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.utils.URLEncodedUtils;
+
+public class ParamsParser {
+
+  public static Map<String, String> parseParams(HttpRequest request) {
+    if (request instanceof HttpGet) {
+      return parseParamsForGet(request);
+    }
+    if (request instanceof HttpEntityEnclosingRequestBase) {
+      return parseParamsForRequestWithEntity((HttpEntityEnclosingRequestBase) request);
+    }
+    return new LinkedHashMap<>();
+  }
+
+  private static Map<String, String> parseParamsForRequestWithEntity(HttpEntityEnclosingRequestBase request) {
+    try {
+      LinkedHashMap<String, String> map = new LinkedHashMap<>();
+      HttpEntity entity = request.getEntity();
+      if (entity != null) {
+        List<NameValuePair> pairs = URLEncodedUtils.parse(entity);
+
+        for (NameValuePair pair : pairs) {
+          map.put(pair.getName(), pair.getValue());
+        }
+      }
+      return map;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static Map<String, String> parseParamsForGet(HttpRequest request) {
+    Uri uri = Uri.parse(request.getRequestLine().getUri());
+    Set<String> paramNames = uri.getQueryParameterNames();
+    LinkedHashMap<String, String> map = new LinkedHashMap<>();
+    for (String paramName : paramNames) {
+      map.put(paramName, uri.getQueryParameter(paramName));
+    }
+    return map;
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/RequestMatcher.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/RequestMatcher.java
new file mode 100644
index 0000000..332a8fa
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/RequestMatcher.java
@@ -0,0 +1,7 @@
+package org.robolectric.shadows.httpclient;
+
+import org.apache.http.HttpRequest;
+
+public interface RequestMatcher {
+  public boolean matches(HttpRequest request);
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/ShadowDefaultRequestDirector.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/ShadowDefaultRequestDirector.java
new file mode 100644
index 0000000..12fecbb
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/ShadowDefaultRequestDirector.java
@@ -0,0 +1,277 @@
+package org.robolectric.shadows.httpclient;
+
+import com.google.errorprone.annotations.InlineMe;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.http.ConnectionReuseStrategy;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpException;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.AuthenticationHandler;
+import org.apache.http.client.HttpRequestRetryHandler;
+import org.apache.http.client.RedirectHandler;
+import org.apache.http.client.UserTokenHandler;
+import org.apache.http.conn.ClientConnectionManager;
+import org.apache.http.conn.ConnectionKeepAliveStrategy;
+import org.apache.http.conn.routing.HttpRoutePlanner;
+import org.apache.http.entity.BasicHttpEntity;
+import org.apache.http.entity.HttpEntityWrapper;
+import org.apache.http.impl.client.DefaultRequestDirector;
+import org.apache.http.params.HttpParams;
+import org.apache.http.protocol.HttpContext;
+import org.apache.http.protocol.HttpProcessor;
+import org.apache.http.protocol.HttpRequestExecutor;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.annotation.Resetter;
+import org.robolectric.util.Util;
+
+@SuppressWarnings({"UnusedDeclaration"})
+@Implements(DefaultRequestDirector.class)
+public class ShadowDefaultRequestDirector {
+  @RealObject DefaultRequestDirector realObject;
+
+  protected Log log;
+  protected ClientConnectionManager connectionManager;
+  protected HttpRoutePlanner httpRoutePlanner;
+  protected ConnectionReuseStrategy connectionReuseStrategy;
+  protected ConnectionKeepAliveStrategy connectionKeepAliveStrategy;
+  protected HttpRequestExecutor httpRequestExecutor;
+  protected HttpProcessor httpProcessor;
+  protected HttpRequestRetryHandler httpRequestRetryHandler;
+  protected RedirectHandler redirectHandler;
+  protected AuthenticationHandler targetAuthenticationHandler;
+  protected AuthenticationHandler proxyAuthenticationHandler;
+  protected UserTokenHandler userTokenHandler;
+  protected HttpParams httpParams;
+
+  org.robolectric.shadows.httpclient.DefaultRequestDirector redirector;
+
+  @Implementation
+  protected void __constructor__(
+      Log log,
+      HttpRequestExecutor requestExec,
+      ClientConnectionManager conman,
+      ConnectionReuseStrategy reustrat,
+      ConnectionKeepAliveStrategy kastrat,
+      HttpRoutePlanner rouplan,
+      HttpProcessor httpProcessor,
+      HttpRequestRetryHandler retryHandler,
+      RedirectHandler redirectHandler,
+      AuthenticationHandler targetAuthHandler,
+      AuthenticationHandler proxyAuthHandler,
+      UserTokenHandler userTokenHandler,
+      HttpParams params) {
+    this.log = log;
+    this.httpRequestExecutor = requestExec;
+    this.connectionManager = conman;
+    this.connectionReuseStrategy = reustrat;
+    this.connectionKeepAliveStrategy = kastrat;
+    this.httpRoutePlanner = rouplan;
+    this.httpProcessor = httpProcessor;
+    this.httpRequestRetryHandler = retryHandler;
+    this.redirectHandler = redirectHandler;
+    this.targetAuthenticationHandler = targetAuthHandler;
+    this.proxyAuthenticationHandler = proxyAuthHandler;
+    this.userTokenHandler = userTokenHandler;
+    this.httpParams = params;
+
+    try {
+      redirector = new org.robolectric.shadows.httpclient.DefaultRequestDirector(
+          log,
+          requestExec,
+          conman,
+          reustrat,
+          kastrat,
+          rouplan,
+          httpProcessor,
+          retryHandler,
+          redirectHandler,
+          targetAuthHandler,
+          proxyAuthHandler,
+          userTokenHandler,
+          params
+      );
+    } catch (IllegalArgumentException ignored) {
+      FakeHttp.getFakeHttpLayer().interceptHttpRequests(true);
+    }
+  }
+
+  @Implementation
+  protected void __constructor__(
+      HttpRequestExecutor requestExec,
+      ClientConnectionManager conman,
+      ConnectionReuseStrategy reustrat,
+      ConnectionKeepAliveStrategy kastrat,
+      HttpRoutePlanner rouplan,
+      HttpProcessor httpProcessor,
+      HttpRequestRetryHandler retryHandler,
+      RedirectHandler redirectHandler,
+      AuthenticationHandler targetAuthHandler,
+      AuthenticationHandler proxyAuthHandler,
+      UserTokenHandler userTokenHandler,
+      HttpParams params) {
+    __constructor__(
+        LogFactory.getLog(DefaultRequestDirector.class),
+        requestExec,
+        conman,
+        reustrat,
+        kastrat,
+        rouplan,
+        httpProcessor,
+        retryHandler,
+        redirectHandler,
+        targetAuthHandler,
+        proxyAuthHandler,
+        userTokenHandler,
+        params);
+  }
+
+  /**
+   * Get the sent {@link HttpRequest} for the given index.
+   *
+   * @param index The index
+   * @deprecated Use {@link FakeHttp#getSentHttpRequestInfo(int)} instead.)
+   * @return HttpRequest
+   */
+  @Deprecated
+  public static HttpRequest getSentHttpRequest(int index) {
+    return getSentHttpRequestInfo(index).getHttpRequest();
+  }
+
+  public static HttpRequest getLatestSentHttpRequest() {
+    return getLatestSentHttpRequestInfo().getHttpRequest();
+  }
+
+  public static HttpRequestInfo getLatestSentHttpRequestInfo() {
+    int requestCount = FakeHttp.getFakeHttpLayer().getSentHttpRequestInfos().size();
+    return FakeHttp.getFakeHttpLayer().getSentHttpRequestInfo(requestCount - 1);
+  }
+
+  /**
+   * Get the sent {@link HttpRequestInfo} for the given index.
+   *
+   * @param index The index
+   * @deprecated Use {@link FakeHttp#getSentHttpRequest(int)} instead.)
+   * @return HttpRequestInfo
+   */
+  @Deprecated
+  @InlineMe(
+      replacement = "FakeHttp.getFakeHttpLayer().getSentHttpRequestInfo(index)",
+      imports = "org.robolectric.shadows.httpclient.FakeHttp")
+  public static final HttpRequestInfo getSentHttpRequestInfo(int index) {
+    return FakeHttp.getFakeHttpLayer().getSentHttpRequestInfo(index);
+  }
+
+  @Implementation
+  protected HttpResponse execute(
+      HttpHost httpHost, HttpRequest httpRequest, HttpContext httpContext)
+      throws HttpException, IOException {
+    if (FakeHttp.getFakeHttpLayer().isInterceptingHttpRequests()) {
+      return FakeHttp.getFakeHttpLayer().emulateRequest(httpHost, httpRequest, httpContext, realObject);
+    } else {
+      FakeHttp.getFakeHttpLayer().addRequestInfo(new HttpRequestInfo(httpRequest, httpHost, httpContext, redirector));
+      HttpResponse response = redirector.execute(httpHost, httpRequest, httpContext);
+
+      if (FakeHttp.getFakeHttpLayer().isInterceptingResponseContent()) {
+        interceptResponseContent(response);
+      }
+
+      FakeHttp.getFakeHttpLayer().addHttpResponse(response);
+      return response;
+    }
+  }
+
+  public Log getLog() {
+    return log;
+  }
+
+  public ClientConnectionManager getConnectionManager() {
+    return connectionManager;
+  }
+
+  public HttpRoutePlanner getHttpRoutePlanner() {
+    return httpRoutePlanner;
+  }
+
+  public ConnectionReuseStrategy getConnectionReuseStrategy() {
+    return connectionReuseStrategy;
+  }
+
+  public ConnectionKeepAliveStrategy getConnectionKeepAliveStrategy() {
+    return connectionKeepAliveStrategy;
+  }
+
+  public HttpRequestExecutor getHttpRequestExecutor() {
+    return httpRequestExecutor;
+  }
+
+  public HttpProcessor getHttpProcessor() {
+    return httpProcessor;
+  }
+
+  public HttpRequestRetryHandler getHttpRequestRetryHandler() {
+    return httpRequestRetryHandler;
+  }
+
+  public RedirectHandler getRedirectHandler() {
+    return redirectHandler;
+  }
+
+  public AuthenticationHandler getTargetAuthenticationHandler() {
+    return targetAuthenticationHandler;
+  }
+
+  public AuthenticationHandler getProxyAuthenticationHandler() {
+    return proxyAuthenticationHandler;
+  }
+
+  public UserTokenHandler getUserTokenHandler() {
+    return userTokenHandler;
+  }
+
+  public HttpParams getHttpParams() {
+    return httpParams;
+  }
+
+  @Resetter
+  public static void reset() {
+    FakeHttp.reset();
+  }
+
+  private void interceptResponseContent(HttpResponse response) {
+    HttpEntity entity = response.getEntity();
+    if (entity instanceof HttpEntityWrapper) {
+      HttpEntityWrapper entityWrapper = (HttpEntityWrapper) entity;
+      try {
+        Field wrappedEntity = HttpEntityWrapper.class.getDeclaredField("wrappedEntity");
+        wrappedEntity.setAccessible(true);
+        entity = (HttpEntity) wrappedEntity.get(entityWrapper);
+      } catch (Exception e) {
+        // fail to record
+      }
+    }
+    if (entity instanceof BasicHttpEntity) {
+      BasicHttpEntity basicEntity = (BasicHttpEntity) entity;
+      try {
+        Field contentField = BasicHttpEntity.class.getDeclaredField("content");
+        contentField.setAccessible(true);
+        InputStream content = (InputStream) contentField.get(basicEntity);
+
+        byte[] buffer = Util.readBytes(content);
+
+        FakeHttp.getFakeHttpLayer().addHttpResponseContent(buffer);
+        contentField.set(basicEntity, new ByteArrayInputStream(buffer));
+      } catch (Exception e) {
+        // fail to record
+      }
+    }
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/StatusLineStub.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/StatusLineStub.java
new file mode 100644
index 0000000..ec978ea
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/StatusLineStub.java
@@ -0,0 +1,18 @@
+package org.robolectric.shadows.httpclient;
+
+import org.apache.http.ProtocolVersion;
+import org.apache.http.StatusLine;
+
+public class StatusLineStub implements StatusLine {
+  @Override public ProtocolVersion getProtocolVersion() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public int getStatusCode() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override public String getReasonPhrase() {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/TestHttpResponse.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/TestHttpResponse.java
new file mode 100644
index 0000000..8cc8fa9
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/TestHttpResponse.java
@@ -0,0 +1,248 @@
+package org.robolectric.shadows.httpclient;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.NoSuchElementException;
+import org.apache.http.Header;
+import org.apache.http.HeaderIterator;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpVersion;
+import org.apache.http.ProtocolVersion;
+import org.apache.http.StatusLine;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.HttpParams;
+
+public class TestHttpResponse extends HttpResponseStub {
+
+  private int statusCode;
+  private byte[] responseBody;
+  private TestStatusLine statusLine = new TestStatusLine();
+  private TestHttpEntity httpEntity = new TestHttpEntity();
+  private int openEntityContentStreamCount = 0;
+  private Header[] headers = new Header[0];
+  private HttpParams params = new BasicHttpParams();
+
+  public TestHttpResponse() {
+    this.statusCode = 200;
+    this.responseBody = new byte[0];
+  }
+
+  public TestHttpResponse(int statusCode, String responseBody) {
+    this.statusCode = statusCode;
+    this.responseBody = responseBody.getBytes(UTF_8);
+  }
+
+  public TestHttpResponse(int statusCode, String responseBody, Header... headers) {
+    this(statusCode, responseBody.getBytes(UTF_8), headers);
+  }
+
+  public TestHttpResponse(int statusCode, byte[] responseBody, Header... headers) {
+    this.statusCode = statusCode;
+    this.responseBody = responseBody.clone();
+    this.headers = headers;
+  }
+
+  protected void setResponseBody(String responseBody) {
+    this.responseBody = responseBody.getBytes(UTF_8);
+  }
+
+  @Override public StatusLine getStatusLine() {
+    return statusLine;
+  }
+
+  @Override public HttpEntity getEntity() {
+    return httpEntity;
+  }
+
+  @Override public Header[] getAllHeaders() {
+    return headers;
+  }
+
+  @Override public Header getFirstHeader(String s) {
+    for (Header h : headers) {
+      if (s.equalsIgnoreCase(h.getName())) {
+        return h;
+      }
+    }
+    return null;
+  }
+
+  @Override public Header getLastHeader(String s) {
+    for (int i = headers.length -1; i >= 0; i--) {
+      if (headers[i].getName().equalsIgnoreCase(s)) {
+        return headers[i];
+      }
+    }
+    return null;
+  }
+
+  @Override public Header[] getHeaders(String s) {
+    List<Header> found = new ArrayList<>();
+    for (Header h : headers) {
+      if (h.getName().equalsIgnoreCase(s)) found.add(h);
+    }
+    return found.toArray(new Header[found.size()]);
+  }
+
+  @Override
+  public void addHeader(Header header) {
+    List<Header> temp = new ArrayList<>();
+    Collections.addAll(temp, headers);
+    temp.add(header);
+    headers = temp.toArray(new Header[temp.size()]);
+  }
+
+  @Override
+  public void setHeader(Header newHeader) {
+    for (int i = 0; i < headers.length; i++) {
+      Header header = headers[i];
+      if (header.getName().equals(newHeader.getName())) {
+        headers[i] = newHeader;
+        return;
+      }
+    }
+  }
+
+  @Override public HeaderIterator headerIterator() {
+    return new HeaderIterator() {
+      int index = 0;
+
+      @Override public boolean hasNext() {
+        return index < headers.length;
+      }
+
+      @Override public Header nextHeader() {
+        if (index >= headers.length) throw new NoSuchElementException();
+        return headers[index++];
+      }
+
+      @Override public Object next() {
+        return nextHeader();
+      }
+
+      @Override public void remove() {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+
+
+  @Override public HeaderIterator headerIterator(final String s) {
+    return new HeaderIterator() {
+      int index = 0;
+
+      @Override public boolean hasNext() {
+        return nextIndex() != -1;
+      }
+
+      private int nextIndex() {
+        for (int i = index; i<headers.length; i++) {
+          if (headers[i].getName().equalsIgnoreCase(s)) {
+            return i;
+          }
+        }
+        return -1;
+      }
+
+      @Override public Header nextHeader() {
+        index = nextIndex();
+        if (index == -1) throw new NoSuchElementException();
+        return headers[index++];
+      }
+
+      @Override public Object next() {
+        return nextHeader();
+      }
+
+      @Override public void remove() {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+
+  @Override public boolean containsHeader(String s) {
+    return getFirstHeader(s) != null;
+
+  }
+
+  @Override public HttpParams getParams() {
+    return params;
+  }
+
+  @Override public void setParams(HttpParams httpParams) {
+    this.params = httpParams;
+  }
+
+  public boolean entityContentStreamsHaveBeenClosed() {
+    return openEntityContentStreamCount == 0;
+  }
+
+  public class TestHttpEntity extends HttpEntityStub {
+
+    private ByteArrayInputStream inputStream;
+
+    @Override public long getContentLength() {
+      return responseBody.length;
+    }
+
+    @Override public Header getContentType() {
+      return getFirstHeader("Content-Type");
+    }
+
+    @Override public Header getContentEncoding() {
+      return getFirstHeader("Content-Encoding");
+    }
+
+    @Override public boolean isStreaming() {
+      return true;
+    }
+
+    @Override public boolean isRepeatable() {
+      return true;
+    }
+
+    @Override public InputStream getContent() throws IOException, IllegalStateException {
+      openEntityContentStreamCount++;
+      inputStream = new ByteArrayInputStream(responseBody) {
+        @Override
+        public void close() throws IOException {
+          openEntityContentStreamCount--;
+          super.close();
+        }
+      };
+      return inputStream;
+    }
+
+    @Override public void writeTo(OutputStream outputStream) throws IOException {
+      outputStream.write(responseBody);
+    }
+
+    @Override public void consumeContent() throws IOException {
+    }
+  }
+
+  public class TestStatusLine extends StatusLineStub {
+    @Override public ProtocolVersion getProtocolVersion() {
+      return new HttpVersion(1, 0);
+    }
+
+    @Override public int getStatusCode() {
+      return statusCode;
+    }
+
+    @Override public String getReasonPhrase() {
+      return "HTTP status " + statusCode;
+    }
+
+    @Override public String toString() {
+      return "TestStatusLine[" + getReasonPhrase() + "]";
+    }
+  }
+}
diff --git a/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/package-info.java b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/package-info.java
new file mode 100644
index 0000000..b7a7158
--- /dev/null
+++ b/shadows/httpclient/src/main/java/org/robolectric/shadows/httpclient/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Shadows for Apache HTTP Client.
+ *
+ * To use this in your project, add the artifact {@code org.robolectric:shadows-httpclient}
+ * to your project. These shadows are only provided for legacy compatibility. They are no
+ * longer actively maintained and will be removed in a future release.
+ */
+@Deprecated
+package org.robolectric.shadows.httpclient;
\ No newline at end of file
diff --git a/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/AndroidHttpClientTest.java b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/AndroidHttpClientTest.java
new file mode 100644
index 0000000..e5ef907
--- /dev/null
+++ b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/AndroidHttpClientTest.java
@@ -0,0 +1,43 @@
+package org.robolectric.shadows.httpclient;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.net.http.AndroidHttpClient;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.CharStreams;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+
+/** Tests for {@link AndroidHttpClient} */
+@RunWith(AndroidJUnit4.class)
+public class AndroidHttpClientTest {
+
+  @Test
+  public void testNewInstance() throws Exception {
+    AndroidHttpClient client = AndroidHttpClient.newInstance("foo");
+    assertThat(client).isNotNull();
+  }
+
+  @Test
+  public void testNewInstanceWithContext() throws Exception {
+    AndroidHttpClient client =
+        AndroidHttpClient.newInstance("foo", RuntimeEnvironment.getApplication());
+    assertThat(client).isNotNull();
+  }
+
+  @Test
+  public void testExecute() throws IOException {
+    AndroidHttpClient client = AndroidHttpClient.newInstance("foo");
+    FakeHttp.addPendingHttpResponse(200, "foo");
+    HttpResponse resp = client.execute(new HttpGet("/foo"));
+    assertThat(resp.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(CharStreams.toString(new InputStreamReader(resp.getEntity().getContent(), UTF_8)))
+        .isEqualTo("foo");
+  }
+}
\ No newline at end of file
diff --git a/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/FakeHttpLayerTest.java b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/FakeHttpLayerTest.java
new file mode 100644
index 0000000..1638925
--- /dev/null
+++ b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/FakeHttpLayerTest.java
@@ -0,0 +1,86 @@
+package org.robolectric.shadows.httpclient;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.util.EntityUtils;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link FakeHttpLayer} */
+@RunWith(AndroidJUnit4.class)
+public class FakeHttpLayerTest {
+  private FakeHttpLayer.RequestMatcherBuilder requestMatcherBuilder;
+
+  @Before
+  public void setUp() throws Exception {
+    requestMatcherBuilder = new FakeHttpLayer.RequestMatcherBuilder();
+  }
+
+  @Test
+  public void requestMatcherBuilder_shouldAddHost() throws Exception {
+    requestMatcherBuilder.host("example.com");
+    assertThat(requestMatcherBuilder.getHostname()).isEqualTo("example.com");
+  }
+
+  @Test
+  public void requestMatcherBuilder_shouldAddMethod() throws Exception {
+    requestMatcherBuilder.method("POST");
+    assertThat(requestMatcherBuilder.getMethod()).isEqualTo("POST");
+  }
+
+  @Test
+  public void requestMatcherBuilder_shouldAddPath() throws Exception {
+    requestMatcherBuilder.path("foo/bar");
+    assertThat(requestMatcherBuilder.getPath()).isEqualTo("/foo/bar");
+  }
+
+  @Test
+  public void requestMatcherBuilder_shouldAddParams() throws Exception {
+    requestMatcherBuilder.param("param1", "param one");
+    assertThat(requestMatcherBuilder.getParam("param1")).isEqualTo("param one");
+  }
+
+  @Test
+  public void requestMatcherBuilder_shouldAddHeaders() throws Exception {
+    requestMatcherBuilder.header("header1", "header one");
+    assertThat(requestMatcherBuilder.getHeader("header1")).isEqualTo("header one");
+  }
+
+  @Test
+  public void matches_shouldMatchHeaders() throws Exception {
+    requestMatcherBuilder.header("header1", "header one");
+    HttpGet match = new HttpGet("example.com");
+    HttpGet noMatch = new HttpGet("example.com");
+    match.setHeader(new BasicHeader("header1", "header one"));
+    noMatch.setHeader(new BasicHeader("header1", "header not a match"));
+
+    assertThat(requestMatcherBuilder.matches(new HttpGet("example.com"))).isFalse();
+    assertThat(requestMatcherBuilder.matches(noMatch)).isFalse();
+    assertThat(requestMatcherBuilder.matches(match)).isTrue();
+  }
+
+  @Test
+  public void matches_shouldMatchPostBody() throws Exception {
+    final String expectedText = "some post body text";
+
+    requestMatcherBuilder.postBody(
+        actualPostBody -> EntityUtils.toString(actualPostBody).equals(expectedText));
+
+    HttpPut match = new HttpPut("example.com");
+    match.setEntity(new StringEntity(expectedText));
+
+    HttpPost noMatch = new HttpPost("example.com");
+    noMatch.setEntity(new StringEntity("some text that does not match"));
+
+    assertThat(requestMatcherBuilder.matches(new HttpGet("example.com"))).isFalse();
+    assertThat(requestMatcherBuilder.matches(noMatch)).isFalse();
+    assertThat(requestMatcherBuilder.matches(match)).isTrue();
+  }
+}
diff --git a/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/FakeHttpTest.java b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/FakeHttpTest.java
new file mode 100644
index 0000000..e65271a
--- /dev/null
+++ b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/FakeHttpTest.java
@@ -0,0 +1,59 @@
+package org.robolectric.shadows.httpclient;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.io.IOException;
+import org.apache.http.HttpException;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.conn.ConnectionKeepAliveStrategy;
+import org.apache.http.impl.client.DefaultRequestDirector;
+import org.apache.http.protocol.HttpContext;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link FakeHttp} */
+@RunWith(AndroidJUnit4.class)
+public class FakeHttpTest {
+
+  @Test
+  public void httpRequestWasSent_ReturnsTrueIfRequestWasSent() throws IOException, HttpException {
+    makeRequest("http://example.com");
+
+    assertTrue(FakeHttp.httpRequestWasMade());
+  }
+
+  @Test
+  public void httpRequestWasMade_ReturnsFalseIfNoRequestWasMade() {
+    assertFalse(FakeHttp.httpRequestWasMade());
+  }
+
+  @Test
+  public void httpRequestWasMade_returnsTrueIfRequestMatchingGivenRuleWasMade() throws IOException, HttpException {
+    makeRequest("http://example.com");
+    assertTrue(FakeHttp.httpRequestWasMade("http://example.com"));
+  }
+
+  @Test
+  public void httpRequestWasMade_returnsFalseIfNoRequestMatchingGivenRuleWasMAde() throws IOException, HttpException {
+    makeRequest("http://example.com");
+    assertFalse(FakeHttp.httpRequestWasMade("http://example.org"));
+  }
+
+  private void makeRequest(String uri) throws HttpException, IOException {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+
+    ConnectionKeepAliveStrategy connectionKeepAliveStrategy = new ConnectionKeepAliveStrategy() {
+      @Override
+      public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpContext) {
+        return 0;
+      }
+
+    };
+    DefaultRequestDirector requestDirector = new DefaultRequestDirector(null, null, null, connectionKeepAliveStrategy, null, null, null, null, null, null, null, null);
+
+    requestDirector.execute(null, new HttpGet(uri), null);
+  }
+}
\ No newline at end of file
diff --git a/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/ParamsParserTest.java b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/ParamsParserTest.java
new file mode 100644
index 0000000..8ecf948
--- /dev/null
+++ b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/ParamsParserTest.java
@@ -0,0 +1,58 @@
+package org.robolectric.shadows.httpclient;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Map;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.entity.StringEntity;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ParamsParser} */
+@RunWith(AndroidJUnit4.class)
+public class ParamsParserTest {
+  @Test
+  public void parseParams_shouldParsePostEntitiesIntoParams() throws Exception {
+    HttpPost post = new HttpPost("example.com");
+    StringEntity entity = new StringEntity("param1=foobar");
+    entity.setContentType("application/x-www-form-urlencoded");
+    post.setEntity(entity);
+    Map<String,String> params = ParamsParser.parseParams(post);
+    assertThat(params.get("param1")).isEqualTo("foobar");
+  }
+
+  @Test
+  public void parseParams_shouldParsePutEntitiesIntoParams() throws Exception {
+    HttpPut put = new HttpPut("example.com");
+    StringEntity entity = new StringEntity("param1=foobar");
+    entity.setContentType("application/x-www-form-urlencoded");
+    put.setEntity(entity);
+    Map<String,String> params = ParamsParser.parseParams(put);
+    assertThat(params.get("param1")).isEqualTo("foobar");
+  }
+
+  @Test
+  public void parseParams_shouldDoNothingForEmptyEntity() throws Exception {
+    HttpPut put = new HttpPut("example.com");
+    Map<String,String> params = ParamsParser.parseParams(put);
+    assertThat(params).isEmpty();
+  }
+
+  @Test
+  public void parseParams_shouldParseParamsFromGetRequests() throws Exception {
+    HttpGet httpGet = new HttpGet("http://example.com/path?foo=bar");
+    Map<String, String> parsed = ParamsParser.parseParams(httpGet);
+    assertThat(parsed.size()).isEqualTo(1);
+    assertThat(parsed.get("foo")).isEqualTo("bar");
+  }
+
+  @Test
+  public void parseParams_returnsNullForUnsupportedOperations() throws Exception {
+    HttpDelete httpDelete = new HttpDelete("http://example.com/deleteme");
+    assertThat(ParamsParser.parseParams(httpDelete)).isEmpty();
+  }
+}
diff --git a/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/ShadowDefaultRequestDirectorTest.java b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/ShadowDefaultRequestDirectorTest.java
new file mode 100644
index 0000000..deaf739
--- /dev/null
+++ b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/ShadowDefaultRequestDirectorTest.java
@@ -0,0 +1,444 @@
+package org.robolectric.shadows.httpclient;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.robolectric.shadows.httpclient.Shadows.shadowOf;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.io.CharStreams;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URI;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.conn.ConnectTimeoutException;
+import org.apache.http.conn.ConnectionKeepAliveStrategy;
+import org.apache.http.impl.client.BasicResponseHandler;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.impl.client.DefaultRequestDirector;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link DefaultRequestDirector} */
+@RunWith(AndroidJUnit4.class)
+public class ShadowDefaultRequestDirectorTest {
+
+  private DefaultRequestDirector requestDirector;
+  private ConnectionKeepAliveStrategy connectionKeepAliveStrategy;
+
+  @Before
+  public void setUp_EnsureStaticStateIsReset() {
+    FakeHttpLayer fakeHttpLayer = FakeHttp.getFakeHttpLayer();
+    assertFalse(fakeHttpLayer.hasPendingResponses());
+    assertFalse(fakeHttpLayer.hasRequestInfos());
+    assertFalse(fakeHttpLayer.hasResponseRules());
+
+    connectionKeepAliveStrategy = (httpResponse, httpContext) -> 0;
+    requestDirector =
+        new DefaultRequestDirector(
+            null,
+            null,
+            null,
+            connectionKeepAliveStrategy,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null);
+  }
+
+  @After
+  public void tearDown_EnsureStaticStateIsReset() {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+  }
+
+  @Test
+  public void shouldGetHttpResponseFromExecute() throws Exception {
+    FakeHttp.addPendingHttpResponse(new TestHttpResponse(200, "a happy response body"));
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://example.com"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a happy response body");
+  }
+
+  @Test
+  public void shouldPreferPendingResponses() throws Exception {
+    FakeHttp.addPendingHttpResponse(new TestHttpResponse(200, "a happy response body"));
+
+    FakeHttp.addHttpResponseRule(HttpGet.METHOD_NAME, "http://some.uri",
+        new TestHttpResponse(200, "a cheery response body"));
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a happy response body");
+  }
+
+  @Test
+  public void shouldReturnRequestsByRule() throws Exception {
+    FakeHttp.addHttpResponseRule(HttpGet.METHOD_NAME, "http://some.uri",
+        new TestHttpResponse(200, "a cheery response body"));
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a cheery response body");
+  }
+
+  @Test
+  public void shouldReturnRequestsByRule_MatchingMethod() throws Exception {
+    FakeHttp.setDefaultHttpResponse(404, "no such page");
+    FakeHttp.addHttpResponseRule(HttpPost.METHOD_NAME, "http://some.uri",
+        new TestHttpResponse(200, "a cheery response body"));
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(404);
+  }
+
+  @Test
+  public void shouldReturnRequestsByRule_AnyMethod() throws Exception {
+    FakeHttp.addHttpResponseRule(
+        "http://some.uri", new TestHttpResponse(200, "a cheery response body"));
+
+    HttpResponse getResponse = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+    assertNotNull(getResponse);
+    assertThat(getResponse.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(getResponse)).isEqualTo("a cheery response body");
+
+    HttpResponse postResponse =
+        requestDirector.execute(null, new HttpPost("http://some.uri"), null);
+    assertNotNull(postResponse);
+    assertThat(postResponse.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(postResponse)).isEqualTo("a cheery response body");
+  }
+
+  @Test
+  public void shouldReturnRequestsByRule_KeepsTrackOfOpenContentStreams() throws Exception {
+    TestHttpResponse testHttpResponse = new TestHttpResponse(200, "a cheery response body");
+    FakeHttp.addHttpResponseRule("http://some.uri", testHttpResponse);
+
+    assertThat(testHttpResponse.entityContentStreamsHaveBeenClosed()).isTrue();
+
+    HttpResponse getResponse = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+    InputStream getResponseStream = getResponse.getEntity().getContent();
+    assertThat(CharStreams.toString(new InputStreamReader(getResponseStream, UTF_8)))
+        .isEqualTo("a cheery response body");
+    assertThat(testHttpResponse.entityContentStreamsHaveBeenClosed()).isFalse();
+
+    HttpResponse postResponse =
+        requestDirector.execute(null, new HttpPost("http://some.uri"), null);
+    InputStream postResponseStream = postResponse.getEntity().getContent();
+    assertThat(CharStreams.toString(new InputStreamReader(postResponseStream, UTF_8)))
+        .isEqualTo("a cheery response body");
+    assertThat(testHttpResponse.entityContentStreamsHaveBeenClosed()).isFalse();
+
+    getResponseStream.close();
+    assertThat(testHttpResponse.entityContentStreamsHaveBeenClosed()).isFalse();
+
+    postResponseStream.close();
+    assertThat(testHttpResponse.entityContentStreamsHaveBeenClosed()).isTrue();
+  }
+
+  @Test
+  public void shouldReturnRequestsByRule_WithTextResponse() throws Exception {
+    FakeHttp.addHttpResponseRule("http://some.uri", "a cheery response body");
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a cheery response body");
+  }
+
+  @Test
+  public void clearHttpResponseRules_shouldRemoveAllRules() throws Exception {
+    FakeHttp.addHttpResponseRule("http://some.uri", "a cheery response body");
+    FakeHttp.clearHttpResponseRules();
+    FakeHttp.addHttpResponseRule("http://some.uri", "a gloomy response body");
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a gloomy response body");
+  }
+
+  @Test
+  public void clearPendingHttpResponses() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "earlier");
+    FakeHttp.clearPendingHttpResponses();
+    FakeHttp.addPendingHttpResponse(500, "later");
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://some.uri"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(500);
+    assertThat(getStringContent(response)).isEqualTo("later");
+  }
+
+  @Test
+  public void shouldReturnRequestsByRule_WithCustomRequestMatcher() throws Exception {
+    FakeHttp.setDefaultHttpResponse(404, "no such page");
+
+    FakeHttp.addHttpResponseRule(
+        request -> request.getRequestLine().getUri().equals("http://matching.uri"),
+        new TestHttpResponse(200, "a cheery response body"));
+
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://matching.uri"), null);
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a cheery response body");
+
+    response = requestDirector.execute(null, new HttpGet("http://non-matching.uri"), null);
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(404);
+    assertThat(getStringContent(response)).isEqualTo("no such page");
+  }
+
+  @Test
+  public void shouldGetHttpResponseFromExecuteSimpleApi() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://example.com"), null);
+
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a happy response body");
+  }
+
+  @Test
+  public void shouldHandleMultipleInvocations() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    FakeHttp.addPendingHttpResponse(201, "another happy response body");
+
+    HttpResponse response1 = requestDirector.execute(null, new HttpGet("http://example.com"), null);
+    HttpResponse response2 = requestDirector.execute(null, new HttpGet("www.example.com"), null);
+
+    assertThat(response1.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response1)).isEqualTo("a happy response body");
+
+    assertThat(response2.getStatusLine().getStatusCode()).isEqualTo(201);
+    assertThat(getStringContent(response2)).isEqualTo("another happy response body");
+  }
+
+  @Test
+  public void shouldHandleMultipleInvocationsOfExecute() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    FakeHttp.addPendingHttpResponse(201, "another happy response body");
+
+    requestDirector.execute(null, new HttpGet("http://example.com"), null);
+    requestDirector.execute(null, new HttpGet("www.example.com"), null);
+
+    HttpUriRequest request1 = (HttpUriRequest) FakeHttp.getSentHttpRequest(0);
+    assertThat(request1.getMethod()).isEqualTo(HttpGet.METHOD_NAME);
+    assertThat(request1.getURI()).isEqualTo(URI.create("http://example.com"));
+
+    HttpUriRequest request2 = (HttpUriRequest) FakeHttp.getSentHttpRequest(1);
+    assertThat(request2.getMethod()).isEqualTo(HttpGet.METHOD_NAME);
+    assertThat(request2.getURI()).isEqualTo(URI.create("www.example.com"));
+  }
+
+  @Test
+  public void shouldRejectUnexpectedCallsToExecute() throws Exception {
+    try {
+      requestDirector.execute(null, new HttpGet("http://example.com"), null);
+      fail();
+    } catch (RuntimeException expected) {
+      assertThat(expected.getMessage())
+          .isEqualTo(
+              "Unexpected call to execute, no pending responses are available. "
+                  + "See Robolectric.addPendingResponse(). Request was: GET http://example.com");
+    }
+  }
+
+  @Test
+  public void shouldRecordExtendedRequestData() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    HttpGet httpGet = new HttpGet("http://example.com");
+    requestDirector.execute(null, httpGet, null);
+
+    assertSame(FakeHttp.getSentHttpRequestInfo(0).getHttpRequest(), httpGet);
+    ConnectionKeepAliveStrategy strategy =
+        shadowOf((DefaultRequestDirector) FakeHttp.getSentHttpRequestInfo(0).getRequestDirector())
+            .getConnectionKeepAliveStrategy();
+    assertSame(strategy, connectionKeepAliveStrategy);
+  }
+
+  @Test
+  public void getNextSentHttpRequestInfo_shouldRemoveHttpRequestInfos() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    HttpGet httpGet = new HttpGet("http://example.com");
+    requestDirector.execute(null, httpGet, null);
+
+    assertSame(FakeHttp.getNextSentHttpRequestInfo().getHttpRequest(), httpGet);
+    assertNull(FakeHttp.getNextSentHttpRequestInfo());
+  }
+
+  @Test
+  public void getNextSentHttpRequest_shouldRemoveHttpRequests() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    HttpGet httpGet = new HttpGet("http://example.com");
+    requestDirector.execute(null, httpGet, null);
+
+    assertSame(FakeHttp.getNextSentHttpRequest(), httpGet);
+    assertNull(FakeHttp.getNextSentHttpRequest());
+  }
+
+  @Test
+  public void shouldSupportBasicResponseHandlerHandleResponse() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "OK", new BasicHeader("Content-Type", "text/plain"));
+
+    DefaultHttpClient client = new DefaultHttpClient();
+    HttpResponse response = client.execute(new HttpGet("http://www.nowhere.org"));
+
+    assertThat(((HttpUriRequest) FakeHttp.getSentHttpRequest(0)).getURI())
+        .isEqualTo(URI.create("http://www.nowhere.org"));
+
+    Assert.assertNotNull(response);
+    String responseStr = new BasicResponseHandler().handleResponse(response);
+    Assert.assertEquals("OK", responseStr);
+  }
+
+  @Test
+  public void shouldFindLastRequestMade() throws Exception {
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+    FakeHttp.addPendingHttpResponse(200, "a happy response body");
+
+    DefaultHttpClient client = new DefaultHttpClient();
+    client.execute(new HttpGet("http://www.first.org"));
+    client.execute(new HttpGet("http://www.second.org"));
+    client.execute(new HttpGet("http://www.third.org"));
+
+    assertThat(((HttpUriRequest) FakeHttp.getLatestSentHttpRequest()).getURI())
+        .isEqualTo(URI.create("http://www.third.org"));
+  }
+
+
+  @Test
+  public void shouldSupportConnectionTimeoutWithExceptions() throws Exception {
+    FakeHttp.setDefaultHttpResponse(new TestHttpResponse() {
+      @Override
+      public HttpParams getParams() {
+        HttpParams httpParams = super.getParams();
+        HttpConnectionParams.setConnectionTimeout(httpParams, -1);
+        return httpParams;
+      }
+    });
+
+    DefaultHttpClient client = new DefaultHttpClient();
+    try {
+      client.execute(new HttpGet("http://www.nowhere.org"));
+    } catch (ConnectTimeoutException x) {
+      return;
+    }
+
+    fail("Exception should have been thrown");
+  }
+
+  @Test
+  public void shouldSupportSocketTimeoutWithExceptions() throws Exception {
+    FakeHttp.setDefaultHttpResponse(new TestHttpResponse() {
+      @Override
+      public HttpParams getParams() {
+        HttpParams httpParams = super.getParams();
+        HttpConnectionParams.setSoTimeout(httpParams, -1);
+        return httpParams;
+      }
+    });
+
+    DefaultHttpClient client = new DefaultHttpClient();
+    try {
+      client.execute(new HttpGet("http://www.nowhere.org"));
+    } catch (ConnectTimeoutException x) {
+      return;
+    }
+
+    fail("Exception should have been thrown");
+  }
+
+  @Test
+  public void shouldSupportRealHttpRequests() {
+    FakeHttp.getFakeHttpLayer().interceptHttpRequests(false);
+    DefaultHttpClient client = new DefaultHttpClient();
+
+    assertThrows(
+        IOException.class,
+        () ->
+            client.execute(new HttpGet("http://www.this-host-should-not-exist-123456790.org:999")));
+  }
+
+  @Test
+  public void shouldSupportRealHttpRequestsAddingRequestInfo() throws Exception {
+    FakeHttp.getFakeHttpLayer().interceptHttpRequests(false);
+    DefaultHttpClient client = new DefaultHttpClient();
+
+    // it's really bad to depend on an external server in order to get a test pass,
+    // but this test is about making sure that we can intercept calls to external servers
+    // so, I think that in this specific case, it's appropriate...
+    client.execute(new HttpGet("http://google.com"));
+
+    assertNotNull(FakeHttp.getFakeHttpLayer().getLastSentHttpRequestInfo());
+    assertNotNull(FakeHttp.getFakeHttpLayer().getLastHttpResponse());
+  }
+
+  @Test
+  public void realHttpRequestsShouldMakeContentDataAvailable() throws Exception {
+    FakeHttp.getFakeHttpLayer().interceptHttpRequests(false);
+    FakeHttp.getFakeHttpLayer().interceptResponseContent(true);
+    DefaultHttpClient client = new DefaultHttpClient();
+
+    client.execute(new HttpGet("http://google.com"));
+
+    byte[] cachedContent = FakeHttp.getFakeHttpLayer().getHttpResposeContentList().get(0);
+    assertThat(cachedContent.length).isNotEqualTo(0);
+
+    InputStream content =
+        FakeHttp.getFakeHttpLayer().getLastHttpResponse().getEntity().getContent();
+    BufferedReader contentReader = new BufferedReader(new InputStreamReader(content, UTF_8));
+    String firstLineOfContent = contentReader.readLine();
+    assertThat(firstLineOfContent).contains("Google");
+
+    BufferedReader cacheReader =
+        new BufferedReader(new InputStreamReader(new ByteArrayInputStream(cachedContent), UTF_8));
+    String firstLineOfCachedContent = cacheReader.readLine();
+    assertThat(firstLineOfCachedContent).isEqualTo(firstLineOfContent);
+  }
+
+  @Test
+  public void shouldReturnResponseFromHttpResponseGenerator() throws Exception {
+    FakeHttp.addPendingHttpResponse(request -> new TestHttpResponse(200, "a happy response body"));
+    HttpResponse response = requestDirector.execute(null, new HttpGet("http://example.com"), null);
+
+    assertNotNull(response);
+    assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200);
+    assertThat(getStringContent(response)).isEqualTo("a happy response body");
+  }
+
+  private static String getStringContent(HttpResponse response) throws IOException {
+    return CharStreams.toString(new InputStreamReader(response.getEntity().getContent(), UTF_8));
+  }
+}
diff --git a/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/TestHttpResponseTest.java b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/TestHttpResponseTest.java
new file mode 100644
index 0000000..f254648
--- /dev/null
+++ b/shadows/httpclient/src/test/java/org/robolectric/shadows/httpclient/TestHttpResponseTest.java
@@ -0,0 +1,131 @@
+package org.robolectric.shadows.httpclient;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.apache.http.Header;
+import org.apache.http.HeaderIterator;
+import org.apache.http.HttpResponse;
+import org.apache.http.message.BasicHeader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link TestHttpResponse} */
+@RunWith(AndroidJUnit4.class)
+public class TestHttpResponseTest {
+
+  @Test
+  public void shouldSupportGetFirstHeader() throws Exception {
+    HttpResponse resp =
+        new TestHttpResponse(304, "REDIRECTED",
+            new BasicHeader("Location", "http://bar.com"));
+
+    assertThat(resp.getFirstHeader("None")).isNull();
+    assertThat(new TestHttpResponse(200, "OK").getFirstHeader("Foo")).isNull();
+
+    for (String l : new String[] { "location", "Location" }) {
+      assertThat(resp.getFirstHeader(l).getValue()).isEqualTo("http://bar.com");
+    }
+  }
+
+  @Test
+  public void shouldSupportGetLastHeader() throws Exception {
+    HttpResponse resp =
+        new TestHttpResponse(304, "REDIRECTED",
+            new BasicHeader("Location", "http://bar.com"),
+            new BasicHeader("Location", "http://zombo.com"));
+
+    assertThat(resp.getLastHeader("None")).isNull();
+
+    for (String l : new String[] { "location", "Location" }) {
+      assertThat(resp.getLastHeader(l).getValue()).isEqualTo("http://zombo.com");
+    }
+  }
+
+  @Test
+  public void shouldSupportContainsHeader() throws Exception {
+    HttpResponse resp =
+        new TestHttpResponse(304, "ZOMBO",
+            new BasicHeader("X-Zombo-Com", "Welcome"));
+
+    assertThat(resp.containsHeader("X-Zombo-Com")).isTrue();
+    assertThat(resp.containsHeader("Location")).isFalse();
+  }
+
+  @Test
+  public void shouldSupportHeaderIterator() throws Exception {
+    HttpResponse resp =
+        new TestHttpResponse(304, "REDIRECTED",
+            new BasicHeader("Location", "http://bar.com"),
+            new BasicHeader("Location", "http://zombo.com"));
+
+    HeaderIterator it = resp.headerIterator();
+
+    assertThat(it.hasNext()).isTrue();
+    assertThat(it.nextHeader().getValue()).isEqualTo("http://bar.com");
+    assertThat(it.nextHeader().getValue()).isEqualTo("http://zombo.com");
+    assertThat(it.hasNext()).isFalse();
+  }
+
+  @Test
+  public void shouldSupportHeaderIteratorWithArg() throws Exception {
+    HttpResponse resp =
+        new TestHttpResponse(304, "REDIRECTED",
+            new BasicHeader("Location", "http://bar.com"),
+            new BasicHeader("X-Zombo-Com", "http://zombo.com"),
+            new BasicHeader("Location", "http://foo.com"));
+
+    HeaderIterator it = resp.headerIterator("Location");
+
+    assertThat(it.hasNext()).isTrue();
+    assertThat(it.nextHeader().getValue()).isEqualTo("http://bar.com");
+    assertThat(it.hasNext()).isTrue();
+    assertThat(it.nextHeader().getValue()).isEqualTo("http://foo.com");
+    assertThat(it.hasNext()).isFalse();
+  }
+
+
+  @Test
+  public void shouldSupportGetHeadersWithArg() throws Exception {
+    HttpResponse resp =
+        new TestHttpResponse(304, "REDIRECTED",
+            new BasicHeader("Location", "http://bar.com"),
+            new BasicHeader("X-Zombo-Com", "http://zombo.com"),
+            new BasicHeader("Location", "http://foo.com"));
+
+
+    Header[] headers = resp.getHeaders("Location");
+    assertThat(headers.length).isEqualTo(2);
+    assertThat(headers[0].getValue()).isEqualTo("http://bar.com");
+    assertThat(headers[1].getValue()).isEqualTo("http://foo.com");
+  }
+
+  @Test
+  public void canAddNewBasicHeader() {
+    TestHttpResponse response = new TestHttpResponse(200, "abc");
+    assertThat(response.getAllHeaders().length).isEqualTo(0);
+    response.addHeader(new BasicHeader("foo", "bar"));
+    assertThat(response.getAllHeaders().length).isEqualTo(1);
+    assertThat(response.getHeaders("foo")[0].getValue()).isEqualTo("bar");
+  }
+
+  @Test
+  public void canOverrideExistingHeaderValue() {
+    TestHttpResponse response = new TestHttpResponse(200, "abc", new BasicHeader("foo", "bar"));
+    response.setHeader(new BasicHeader("foo", "bletch"));
+    assertThat(response.getAllHeaders().length).isEqualTo(1);
+    assertThat(response.getHeaders("foo")[0].getValue()).isEqualTo("bletch");
+  }
+
+  @Test
+  public void onlyOverridesFirstHeaderValue() {
+    TestHttpResponse response =
+        new TestHttpResponse(
+            200, "abc", new BasicHeader("foo", "bar"), new BasicHeader("foo", "baz"));
+    response.setHeader(new BasicHeader("foo", "bletch"));
+    assertThat(response.getAllHeaders().length).isEqualTo(2);
+    assertThat(response.getHeaders("foo")[0].getValue()).isEqualTo("bletch");
+    assertThat(response.getHeaders("foo")[1].getValue()).isEqualTo("baz");
+  }
+
+}
diff --git a/shadows/httpclient/src/test/resources/AndroidManifest.xml b/shadows/httpclient/src/test/resources/AndroidManifest.xml
new file mode 100644
index 0000000..b09fe8b
--- /dev/null
+++ b/shadows/httpclient/src/test/resources/AndroidManifest.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="org.robolectric">
+
+    <uses-sdk android:targetSdkVersion="18"/>
+
+    <application android:name="android.app.Application">
+    </application>
+</manifest>
diff --git a/shadows/multidex/build.gradle b/shadows/multidex/build.gradle
new file mode 100644
index 0000000..6dd76d3
--- /dev/null
+++ b/shadows/multidex/build.gradle
@@ -0,0 +1,21 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+apply plugin: ShadowsPlugin
+
+shadows {
+    packageName "org.robolectric.shadows.multidex"
+    sdkCheckMode "OFF"
+}
+
+dependencies {
+    compileOnly project(":shadows:framework")
+    api project(":annotations")
+
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testImplementation project(":robolectric")
+}
diff --git a/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/ShadowAndroidXMultiDex.java b/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/ShadowAndroidXMultiDex.java
new file mode 100644
index 0000000..1ef9670
--- /dev/null
+++ b/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/ShadowAndroidXMultiDex.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows.multidex;
+
+import android.content.Context;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(className = "androidx.multidex.MultiDex")
+@SuppressWarnings("robolectric.internal.IgnoreMissingClass")
+public class ShadowAndroidXMultiDex {
+
+  @Implementation
+  protected static void install(Context context) {
+    // Do nothing since with Robolectric nothing is dexed.
+  }
+
+}
diff --git a/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/ShadowMultiDex.java b/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/ShadowMultiDex.java
new file mode 100644
index 0000000..bf9f7c7
--- /dev/null
+++ b/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/ShadowMultiDex.java
@@ -0,0 +1,16 @@
+package org.robolectric.shadows.multidex;
+
+import android.content.Context;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** No-op shadow for {@link android.support.multidex.MultiDex}. */
+@Implements(className = "android.support.multidex.MultiDex")
+@SuppressWarnings("robolectric.internal.IgnoreMissingClass")
+public class ShadowMultiDex {
+
+  @Implementation
+  protected static void install(Context context) {
+    // Do nothing since with Robolectric nothing is dexed.
+  }
+}
diff --git a/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/package-info.java b/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/package-info.java
new file mode 100644
index 0000000..969b789
--- /dev/null
+++ b/shadows/multidex/src/main/java/org/robolectric/shadows/multidex/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Shadows for the Android Multidex Library.
+ *
+ * To use this in your project, add the artifact {@code org.robolectric:shadows-multidex}
+ * to your project.
+ */
+package org.robolectric.shadows.multidex;
\ No newline at end of file
diff --git a/shadows/playservices/build.gradle b/shadows/playservices/build.gradle
new file mode 100644
index 0000000..df8c753
--- /dev/null
+++ b/shadows/playservices/build.gradle
@@ -0,0 +1,38 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+apply plugin: ShadowsPlugin
+
+shadows {
+    packageName "org.robolectric.shadows.gms"
+    sdkCheckMode "OFF"
+}
+
+dependencies {
+    compileOnly project(":shadows:framework")
+    api project(":annotations")
+    api "com.google.guava:guava:$guavaJREVersion"
+
+    compileOnly "com.android.support:support-fragment:28.0.0"
+    compileOnly "com.google.android.gms:play-services-base:8.4.0"
+    compileOnly "com.google.android.gms:play-services-basement:8.4.0"
+
+    compileOnly AndroidSdk.MAX_SDK.coordinates
+
+    testCompileOnly AndroidSdk.MAX_SDK.coordinates
+    testCompileOnly "com.google.android.gms:play-services-base:8.4.0"
+    testCompileOnly "com.google.android.gms:play-services-basement:8.4.0"
+
+    testImplementation project(":robolectric")
+    testImplementation "junit:junit:$junitVersion"
+    testImplementation "com.google.truth:truth:$truthVersion"
+    testImplementation "org.mockito:mockito-core:$mockitoVersion"
+    testRuntimeOnly "com.android.support:support-fragment:28.0.0"
+    testRuntimeOnly "com.google.android.gms:play-services-base:8.4.0"
+    testRuntimeOnly "com.google.android.gms:play-services-basement:8.4.0"
+
+    testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
+}
diff --git a/shadows/playservices/src/main/java/org/robolectric/shadows/gms/ShadowGoogleAuthUtil.java b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/ShadowGoogleAuthUtil.java
new file mode 100644
index 0000000..9e6a9b1
--- /dev/null
+++ b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/ShadowGoogleAuthUtil.java
@@ -0,0 +1,223 @@
+package org.robolectric.shadows.gms;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import com.google.android.gms.auth.AccountChangeEvent;
+import com.google.android.gms.auth.GoogleAuthException;
+import com.google.android.gms.auth.GoogleAuthUtil;
+import com.google.android.gms.auth.GooglePlayServicesAvailabilityException;
+import com.google.android.gms.auth.UserRecoverableAuthException;
+import com.google.android.gms.auth.UserRecoverableNotifiedException;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Calls to static method of {@link GoogleAuthUtil} will be redirected to the provided
+ * {@link GoogleAuthUtilImpl} implementation. Use {@link #provideImpl(GoogleAuthUtilImpl)}
+ * to set the implementation instance. By default, a {@link GoogleAuthUtilImpl} is used in call
+ * redirection. Use mocks or subclassing {@link GoogleAuthUtilImpl} to achieve desired behaviors.
+ */
+@Implements(GoogleAuthUtil.class)
+public class ShadowGoogleAuthUtil {
+
+  private static GoogleAuthUtilImpl googleAuthUtilImpl = new GoogleAuthUtilImpl();
+
+  public static synchronized GoogleAuthUtilImpl getImpl() {
+    return googleAuthUtilImpl;
+  }
+
+  public static synchronized void provideImpl(GoogleAuthUtilImpl impl) {
+    googleAuthUtilImpl = Preconditions.checkNotNull(impl);
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    googleAuthUtilImpl = new GoogleAuthUtilImpl();
+  }
+
+  @Implementation
+  public static synchronized void clearToken(Context context, String token)
+      throws GooglePlayServicesAvailabilityException, GoogleAuthException, IOException {
+    googleAuthUtilImpl.clearToken(context, token);
+  }
+
+  @Implementation
+  public static synchronized List<AccountChangeEvent> getAccountChangeEvents(Context context,
+      int eventIndex, String accountName)
+          throws GoogleAuthException, IOException {
+    return googleAuthUtilImpl.getAccountChangeEvents(context, eventIndex, accountName);
+  }
+
+  @Implementation
+  public static synchronized String getAccountId(Context ctx, String accountName)
+      throws GoogleAuthException, IOException {
+    return googleAuthUtilImpl.getAccountId(ctx, accountName);
+  }
+
+  @Implementation
+  public static synchronized String getToken(Context context, Account account, String scope)
+      throws IOException, UserRecoverableAuthException, GoogleAuthException {
+    return googleAuthUtilImpl.getToken(context, account, scope);
+  }
+
+  @Implementation
+  public static synchronized String getToken(Context context, Account account, String scope,
+      Bundle extras) throws IOException, UserRecoverableAuthException, GoogleAuthException {
+    return googleAuthUtilImpl.getToken(context, account, scope, extras);
+  }
+
+  @Implementation
+  public static synchronized String getToken(Context context, String accountName, String scope)
+      throws IOException, UserRecoverableAuthException, GoogleAuthException {
+    return googleAuthUtilImpl.getToken(context, accountName, scope);
+  }
+
+  @Implementation
+  public static synchronized String getToken(Context context, String accountName, String scope,
+      Bundle extras) throws IOException, UserRecoverableAuthException, GoogleAuthException {
+    return googleAuthUtilImpl.getToken(context, accountName, scope, extras);
+  }
+
+  @Implementation
+  public static synchronized String getTokenWithNotification(Context context, Account account,
+      String scope, Bundle extras)
+          throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+    return googleAuthUtilImpl.getTokenWithNotification(context, account, scope, extras);
+  }
+
+  @Implementation
+  public static synchronized String getTokenWithNotification(Context context, Account account,
+      String scope, Bundle extras, Intent callback)
+          throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+    return googleAuthUtilImpl
+        .getTokenWithNotification(context, account, scope, extras, callback);
+  }
+
+  @Implementation
+  public static synchronized String getTokenWithNotification(Context context, Account account,
+      String scope, Bundle extras, String authority, Bundle syncBundle)
+          throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+    return googleAuthUtilImpl
+        .getTokenWithNotification(context, account, scope, extras, authority, syncBundle);
+  }
+
+  @Implementation
+  public static synchronized String getTokenWithNotification(Context context, String accountName,
+      String scope, Bundle extras, Intent callback)
+          throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+    return googleAuthUtilImpl
+        .getTokenWithNotification(context, accountName, scope, extras, callback);
+  }
+
+  @Implementation
+  public static synchronized String getTokenWithNotification(Context context, String accountName,
+      String scope, Bundle extras)
+          throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+    return googleAuthUtilImpl.getTokenWithNotification(context, accountName, scope, extras);
+  }
+
+  @Implementation
+  public static synchronized String getTokenWithNotification(Context context, String accountName,
+      String scope, Bundle extras, String authority, Bundle syncBundle)
+          throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+    return googleAuthUtilImpl.getTokenWithNotification(context, accountName, scope, extras,
+        authority, syncBundle);
+  }
+
+  @Implementation
+  public static synchronized void invalidateToken(Context context, String token) {
+    googleAuthUtilImpl.invalidateToken(context, token);
+  }
+
+  /**
+   * Class containing methods with same signatures of the static methods of {@link GoogleAuthUtil}
+   */
+  public static class GoogleAuthUtilImpl {
+    public void clearToken(Context context, String token)
+        throws GooglePlayServicesAvailabilityException, GoogleAuthException, IOException {}
+
+    public List<AccountChangeEvent> getAccountChangeEvents(Context context, int eventIndex,
+        String accountName) throws GoogleAuthException, IOException {
+      return new ArrayList<>();
+    }
+
+    public String getAccountId(Context ctx, String accountName)
+        throws GoogleAuthException, IOException {
+      return "accountId";
+    }
+
+    public String getToken(Context context, Account account, String scope)
+        throws IOException, UserRecoverableAuthException, GoogleAuthException {
+      return "token";
+    }
+
+    public String getToken(Context context, Account account, String scope, Bundle extras)
+        throws IOException, UserRecoverableAuthException, GoogleAuthException {
+      return "token";
+    }
+
+    public String getToken(Context context, String accountName, String scope)
+        throws IOException, UserRecoverableAuthException, GoogleAuthException {
+      return getToken(context, new Account(accountName, "robo"), scope);
+    }
+
+    public String getToken(Context context, String accountName, String scope, Bundle extras)
+        throws IOException, UserRecoverableAuthException, GoogleAuthException {
+      return getToken(context, new Account(accountName, "robo"), scope, extras);
+    }
+
+    public String getTokenWithNotification(Context context, Account account, String scope,
+        Bundle extras)
+            throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+      return "token";
+    }
+
+    public String getTokenWithNotification(Context context, Account account, String scope,
+        Bundle extras, Intent callback)
+            throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+      if (callback == null) {
+        throw new IllegalArgumentException("Callback cannot be null.");
+      }
+      return "token";
+    }
+
+    public String getTokenWithNotification(Context context, Account account, String scope,
+        Bundle extras, String authority, Bundle syncBundle)
+            throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+      if (authority == null || authority.length() == 0) {
+        throw new IllegalArgumentException("Authority cannot be empty.");
+      }
+      return "token";
+    }
+
+    public String getTokenWithNotification(Context context, String accountName, String scope,
+        Bundle extras)
+            throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+      return getTokenWithNotification(context, new Account(accountName, "robo"), scope,
+          extras);
+    }
+
+    public String getTokenWithNotification(Context context, String accountName, String scope,
+        Bundle extras, Intent callback)
+            throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+      return getTokenWithNotification(context, new Account(accountName, "robo"), scope,
+          extras, callback);
+    }
+
+    public String getTokenWithNotification(Context context, String accountName, String scope,
+        Bundle extras, String authority, Bundle syncBundle)
+            throws IOException, UserRecoverableNotifiedException, GoogleAuthException {
+      return getTokenWithNotification(context, new Account(accountName, "robo"), scope,
+          extras, authority, syncBundle);
+    }
+
+    public void invalidateToken(Context context, String token) {}
+  }
+}
diff --git a/shadows/playservices/src/main/java/org/robolectric/shadows/gms/ShadowGooglePlayServicesUtil.java b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/ShadowGooglePlayServicesUtil.java
new file mode 100644
index 0000000..e2f9dcd
--- /dev/null
+++ b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/ShadowGooglePlayServicesUtil.java
@@ -0,0 +1,168 @@
+package org.robolectric.shadows.gms;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.support.v4.app.Fragment;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GooglePlayServicesUtil;
+import com.google.common.base.Preconditions;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+/**
+ * Calls to static method of {@link GooglePlayServicesUtil} will be redirected to the provided
+ * {@link GooglePlayServicesUtilImpl} implementation. Use
+ * {@link #provideImpl(GooglePlayServicesUtilImpl)} to
+ * set the implementation instance. By default, a {@link GooglePlayServicesUtilImpl} is used in call
+ * redirection. Use mocks or subclassing {@link GooglePlayServicesUtilImpl} to achieve desired
+ * behaviors.
+ */
+@Implements(GooglePlayServicesUtil.class)
+public class ShadowGooglePlayServicesUtil {
+  private static GooglePlayServicesUtilImpl googlePlayServicesUtilImpl = 
+      new GooglePlayServicesUtilImpl();
+
+  public static synchronized GooglePlayServicesUtilImpl getImpl() {
+    return googlePlayServicesUtilImpl;
+  }
+
+  public static synchronized void provideImpl(GooglePlayServicesUtilImpl impl) {
+    googlePlayServicesUtilImpl = Preconditions.checkNotNull(impl);
+  }
+
+  @Resetter
+  public static synchronized void reset() {
+    googlePlayServicesUtilImpl = new GooglePlayServicesUtilImpl();
+  }
+
+  @Implementation
+  public static synchronized Context getRemoteContext(Context context) {
+    return googlePlayServicesUtilImpl.getRemoteContext(context);
+  }
+
+  @Implementation
+  public static synchronized Resources getRemoteResource(Context context) {
+    return googlePlayServicesUtilImpl.getRemoteResource(context);
+  }
+
+  @Implementation
+  public static synchronized boolean showErrorDialogFragment(int errorCode, Activity activity,
+      Fragment fragment, int requestCode, OnCancelListener cancelListener) {
+    return googlePlayServicesUtilImpl.showErrorDialogFragment(
+        errorCode, activity, fragment, requestCode, cancelListener);
+  }
+
+  @Implementation
+  public static synchronized boolean showErrorDialogFragment(int errorCode, Activity activity,
+      int requestCode) {
+    return googlePlayServicesUtilImpl.showErrorDialogFragment(
+        errorCode, activity, requestCode);
+  }
+
+  @Implementation
+  public static synchronized boolean showErrorDialogFragment(
+      int errorCode, Activity activity, int requestCode, OnCancelListener cancelListener) {
+    return googlePlayServicesUtilImpl.showErrorDialogFragment(
+        errorCode, activity, requestCode, cancelListener);
+  }
+
+  @Implementation
+  public static synchronized Dialog getErrorDialog(int errorCode, Activity activity,
+      int requestCode) {
+    return googlePlayServicesUtilImpl.getErrorDialog(errorCode, activity, requestCode);
+  }
+
+  @Implementation
+  public static synchronized Dialog getErrorDialog(int errorCode, Activity activity,
+      int requestCode, OnCancelListener cancelListener) {
+    return googlePlayServicesUtilImpl.getErrorDialog(
+        errorCode, activity, requestCode, cancelListener);
+  }
+
+  @Implementation
+  public static synchronized PendingIntent getErrorPendingIntent(int errorCode, Context context,
+      int requestCode) {
+    return googlePlayServicesUtilImpl.getErrorPendingIntent(errorCode, context, requestCode);
+  }
+
+  @Implementation
+  public static synchronized String getOpenSourceSoftwareLicenseInfo(Context context) {
+    return googlePlayServicesUtilImpl.getOpenSourceSoftwareLicenseInfo(context);
+  }
+
+  @Implementation
+  public static synchronized int isGooglePlayServicesAvailable(Context context) {
+    return googlePlayServicesUtilImpl.isGooglePlayServicesAvailable(context);
+  }
+
+  @Implementation
+  public static synchronized void showErrorNotification(int errorCode, Context context) {
+    googlePlayServicesUtilImpl.showErrorNotification(errorCode, context);
+  }
+
+  /**
+   * Class containing methods with same signatures of the static methods of
+   * {@link GooglePlayServicesUtil}.
+   */
+  public static class GooglePlayServicesUtilImpl {
+    public Dialog getErrorDialog(int errorCode, Activity activity, int requestCode) {
+      return getErrorDialog(errorCode, activity, requestCode, null);
+    }
+
+    public Dialog getErrorDialog(int errorCode, Activity activity, int requestCode,
+        OnCancelListener cancelListener) {
+      if (errorCode == ConnectionResult.SUCCESS) {
+        return null;
+      }
+      return new Dialog(RuntimeEnvironment.getApplication());
+    }
+
+    public PendingIntent getErrorPendingIntent(int errorCode, Context context,
+        int requestCode) {
+      if (errorCode == ConnectionResult.SUCCESS) {
+        return null;
+      }
+      return PendingIntent.getActivity(
+          context, requestCode, new Intent(), PendingIntent.FLAG_CANCEL_CURRENT);
+    }
+
+    public String getOpenSourceSoftwareLicenseInfo(Context context) {
+      return "license";
+    }
+
+    public Context getRemoteContext(Context context) {
+      return RuntimeEnvironment.getApplication();
+    }
+
+    public Resources getRemoteResource(Context context) {
+      return RuntimeEnvironment.getApplication().getResources();
+    }
+
+    public int isGooglePlayServicesAvailable(Context context) {
+      return ConnectionResult.SERVICE_MISSING;
+    }
+
+    public boolean showErrorDialogFragment(int errorCode, Activity activity,
+        Fragment fragment, int requestCode, OnCancelListener cancelListener) {
+      return false;
+    }
+
+    public boolean showErrorDialogFragment(int errorCode, Activity activity, int requestCode) {
+      return false;
+    }
+
+    public boolean showErrorDialogFragment(int errorCode, Activity activity, int requestCode,
+        OnCancelListener cancelListener) {
+      return false;
+    }
+
+    public void showErrorNotification(int errorCode, Context context) {}
+  }
+}
diff --git a/shadows/playservices/src/main/java/org/robolectric/shadows/gms/common/ShadowGoogleApiAvailability.java b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/common/ShadowGoogleApiAvailability.java
new file mode 100644
index 0000000..cb9bf19
--- /dev/null
+++ b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/common/ShadowGoogleApiAvailability.java
@@ -0,0 +1,72 @@
+package org.robolectric.shadows.gms.common;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadow.api.Shadow;
+
+@Implements(GoogleApiAvailability.class)
+public class ShadowGoogleApiAvailability {
+    private int availabilityCode = ConnectionResult.SERVICE_MISSING;
+    private boolean isUserResolvableError = false;
+    private String openSourceSoftwareLicenseInfo = "";
+    private Dialog errorDialog;
+
+    @Implementation
+    public static GoogleApiAvailability getInstance(){
+        return InstanceHolder.INSTANCE;
+    }
+
+    @Implementation
+    public int isGooglePlayServicesAvailable(Context context){
+        return availabilityCode;
+    }
+
+    public void setIsGooglePlayServicesAvailable(int availabilityCode) {
+        this.availabilityCode = availabilityCode;
+    }
+
+    @Implementation
+    public final boolean isUserResolvableError(int errorCode) {
+        return isUserResolvableError;
+    }
+
+    public void setIsUserResolvableError(final boolean isUserResolvableError){
+        this.isUserResolvableError = isUserResolvableError;
+    }
+
+    @Implementation
+    public String getOpenSourceSoftwareLicenseInfo(Context context){
+        return openSourceSoftwareLicenseInfo;
+    }
+
+    public void setOpenSourceSoftwareLicenseInfo(final String openSourceSoftwareLicenseInfo){
+        this.openSourceSoftwareLicenseInfo = openSourceSoftwareLicenseInfo;
+    }
+
+    @Implementation
+    public Dialog getErrorDialog(Activity activity, int errorCode, int requestCode) {
+        return errorDialog;
+    }
+
+    @Implementation
+    public Dialog getErrorDialog(Activity activity, int errorCode, int requestCode,
+                                 DialogInterface.OnCancelListener cancelListener) {
+        return errorDialog;
+    }
+
+    public void setErrorDialog(final Dialog errorDialog){
+        this.errorDialog = errorDialog;
+    }
+
+    private static class InstanceHolder {
+        private static final GoogleApiAvailability INSTANCE = Shadow.newInstance(
+                GoogleApiAvailability.class, new Class[]{}, new Object[]{});
+
+    }
+}
diff --git a/shadows/playservices/src/main/java/org/robolectric/shadows/gms/package-info.java b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/package-info.java
new file mode 100644
index 0000000..623837d
--- /dev/null
+++ b/shadows/playservices/src/main/java/org/robolectric/shadows/gms/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Shadows for the Google Play Services Library.
+ *
+ * To use this in your project, add the artifact {@code org.robolectric:shadows-play-services}
+ * to your project.
+ */
+package org.robolectric.shadows.gms;
\ No newline at end of file
diff --git a/shadows/playservices/src/test/java/org/robolectric/shadows/gms/ShadowGoogleAuthUtilTest.java b/shadows/playservices/src/test/java/org/robolectric/shadows/gms/ShadowGoogleAuthUtilTest.java
new file mode 100644
index 0000000..de442ff
--- /dev/null
+++ b/shadows/playservices/src/test/java/org/robolectric/shadows/gms/ShadowGoogleAuthUtilTest.java
@@ -0,0 +1,138 @@
+package org.robolectric.shadows.gms;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.accounts.Account;
+import android.content.Intent;
+import com.google.android.gms.auth.AccountChangeEvent;
+import com.google.android.gms.auth.GoogleAuthUtil;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.gms.ShadowGoogleAuthUtil.GoogleAuthUtilImpl;
+
+/**
+ * Unit test for {@link ShadowGoogleAuthUtil}.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE, shadows = {ShadowGoogleAuthUtil.class})
+public class ShadowGoogleAuthUtilTest {
+
+  @Mock private GoogleAuthUtilImpl mockGoogleAuthUtil;
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Before
+  public void setup() {
+    MockitoAnnotations.initMocks(this);
+    ShadowGoogleAuthUtil.reset();
+  }
+
+  @Test
+  public void getImplementation_defaultNotNull() {
+    assertNotNull(ShadowGoogleAuthUtil.getImpl());
+  }
+
+  @Test
+  public void provideImplementation_nullValueNotAllowed() {
+    thrown.expect(NullPointerException.class);
+    ShadowGoogleAuthUtil.provideImpl(null);
+  }
+
+  @Test
+  public void getImplementation_shouldGetSetted() {
+    ShadowGoogleAuthUtil.provideImpl(mockGoogleAuthUtil);
+    GoogleAuthUtilImpl googleAuthUtil = ShadowGoogleAuthUtil.getImpl();
+    assertSame(googleAuthUtil, mockGoogleAuthUtil);
+  }
+
+  @Test
+  public void canRedirectStaticMethodToImplementation() throws Exception {
+    ShadowGoogleAuthUtil.provideImpl(mockGoogleAuthUtil);
+    GoogleAuthUtil.clearToken(RuntimeEnvironment.getApplication(), "token");
+    verify(mockGoogleAuthUtil, times(1)).clearToken(RuntimeEnvironment.getApplication(), "token");
+  }
+
+  @Test
+  public void getAccountChangeEvents_defaultReturnEmptyList() throws Exception {
+    List<AccountChangeEvent> list =
+        GoogleAuthUtil.getAccountChangeEvents(RuntimeEnvironment.getApplication(), 0, "name");
+    assertNotNull(list);
+    assertEquals(0, list.size());
+  }
+
+  @Test
+  public void getAccountId_defaultNotNull() throws Exception {
+    assertNotNull(GoogleAuthUtil.getAccountId(RuntimeEnvironment.getApplication(), "name"));
+  }
+
+  @Test
+  public void getToken_defaultNotNull() throws Exception {
+    assertNotNull(GoogleAuthUtil.getToken(RuntimeEnvironment.getApplication(), "name", "scope"));
+    assertNotNull(
+        GoogleAuthUtil.getToken(RuntimeEnvironment.getApplication(), "name", "scope", null));
+    assertNotNull(
+        GoogleAuthUtil.getToken(
+            RuntimeEnvironment.getApplication(), new Account("name", "robo"), "scope"));
+    assertNotNull(
+        GoogleAuthUtil.getToken(
+            RuntimeEnvironment.getApplication(), new Account("name", "robo"), "scope", null));
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(), "name", "scope", null));
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(), "name", "scope", null, new Intent()));
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(), "name", "scope", null, "authority", null));
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(), new Account("name", "robo"), "scope", null));
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(),
+            new Account("name", "robo"),
+            "scope",
+            null,
+            new Intent()));
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(),
+            new Account("name", "robo"),
+            "scope",
+            null,
+            "authority",
+            null));
+  }
+
+  @Test
+  public void getTokenWithNotification_nullCallBackThrowIllegalArgumentException()
+      throws Exception {
+    thrown.expect(IllegalArgumentException.class);
+    GoogleAuthUtil.getTokenWithNotification(
+        RuntimeEnvironment.getApplication(), "name", "scope", null, null);
+  }
+
+  @Test
+  public void getTokenWithNotification_nullAuthorityThrowIllegalArgumentException()
+      throws Exception {
+    thrown.expect(IllegalArgumentException.class);
+    assertNotNull(
+        GoogleAuthUtil.getTokenWithNotification(
+            RuntimeEnvironment.getApplication(), "name", "scope", null, null, null));
+  }
+}
diff --git a/shadows/playservices/src/test/java/org/robolectric/shadows/gms/ShadowGooglePlayServicesUtilTest.java b/shadows/playservices/src/test/java/org/robolectric/shadows/gms/ShadowGooglePlayServicesUtilTest.java
new file mode 100644
index 0000000..cd75b96
--- /dev/null
+++ b/shadows/playservices/src/test/java/org/robolectric/shadows/gms/ShadowGooglePlayServicesUtilTest.java
@@ -0,0 +1,122 @@
+package org.robolectric.shadows.gms;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.Context;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GooglePlayServicesUtil;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.gms.ShadowGooglePlayServicesUtil.GooglePlayServicesUtilImpl;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE, shadows = {ShadowGooglePlayServicesUtil.class})
+public class ShadowGooglePlayServicesUtilTest {
+
+  @Mock
+  private GooglePlayServicesUtilImpl mockGooglePlayServicesUtil;
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Before
+  public void setup() {
+    MockitoAnnotations.initMocks(this);
+  }
+
+  @Test
+  public void getImplementation_defaultNotNull() {
+    assertNotNull(ShadowGooglePlayServicesUtil.getImpl());
+  }
+
+  @Test
+  public void provideImplementation_nullValueNotAllowed() {
+    thrown.expect(NullPointerException.class);
+    ShadowGooglePlayServicesUtil.provideImpl(null);
+  }
+
+  @Test
+  public void getImplementation_shouldGetSetted() {
+    ShadowGooglePlayServicesUtil.provideImpl(mockGooglePlayServicesUtil);
+    ShadowGooglePlayServicesUtil.GooglePlayServicesUtilImpl googlePlayServicesUtil =
+        ShadowGooglePlayServicesUtil.getImpl();
+    assertSame(googlePlayServicesUtil, mockGooglePlayServicesUtil);
+  }
+
+  @Test
+  public void canRedirectStaticMethodToImplementation() {
+    ShadowGooglePlayServicesUtil.provideImpl(mockGooglePlayServicesUtil);
+    when(mockGooglePlayServicesUtil.isGooglePlayServicesAvailable(
+        any(Context.class))).thenReturn(ConnectionResult.INTERNAL_ERROR);
+    assertEquals(
+        ConnectionResult.INTERNAL_ERROR,
+        GooglePlayServicesUtil.isGooglePlayServicesAvailable(RuntimeEnvironment.getApplication()));
+  }
+
+  @Test
+  public void getErrorString_goesToRealImpl() {
+    assertEquals("SUCCESS", GooglePlayServicesUtil.getErrorString(ConnectionResult.SUCCESS));
+    assertEquals("SERVICE_MISSING", GooglePlayServicesUtil
+        .getErrorString(ConnectionResult.SERVICE_MISSING));
+  }
+
+  @Test
+  public void getRemoteContext_defaultNotNull() {
+    assertNotNull(GooglePlayServicesUtil.getRemoteContext(RuntimeEnvironment.getApplication()));
+  }
+
+  @Test
+  public void getRemoteResource_defaultNotNull() {
+    assertNotNull(GooglePlayServicesUtil.getRemoteResource(RuntimeEnvironment.getApplication()));
+  }
+
+  @Test
+  public void getErrorDialog() {
+    assertNotNull(GooglePlayServicesUtil.getErrorDialog(
+        ConnectionResult.SERVICE_MISSING, new Activity(), 0));
+    assertNull(GooglePlayServicesUtil.getErrorDialog(
+        ConnectionResult.SUCCESS, new Activity(), 0));
+    assertNotNull(GooglePlayServicesUtil.getErrorDialog(
+        ConnectionResult.SERVICE_MISSING, new Activity(), 0, null));
+    assertNull(GooglePlayServicesUtil.getErrorDialog(
+        ConnectionResult.SUCCESS, new Activity(), 0, null));
+  }
+
+  @Test
+  public void getErrorPendingIntent() {
+    assertNotNull(
+        GooglePlayServicesUtil.getErrorPendingIntent(
+            ConnectionResult.SERVICE_MISSING, RuntimeEnvironment.getApplication(), 0));
+    assertNull(
+        GooglePlayServicesUtil.getErrorPendingIntent(
+            ConnectionResult.SUCCESS, RuntimeEnvironment.getApplication(), 0));
+  }
+
+  @Test
+  public void getOpenSourceSoftwareLicenseInfo_defaultNotNull() {
+    assertNotNull(
+        GooglePlayServicesUtil.getOpenSourceSoftwareLicenseInfo(
+            RuntimeEnvironment.getApplication()));
+  }
+
+  @Test
+  public void isGooglePlayServicesAvailable_defaultServiceMissing() {
+    assertEquals(
+        ConnectionResult.SERVICE_MISSING,
+        GooglePlayServicesUtil.isGooglePlayServicesAvailable(RuntimeEnvironment.getApplication()));
+  }
+}
diff --git a/shadows/playservices/src/test/java/org/robolectric/shadows/gms/common/ShadowGoogleApiAvailabilityTest.java b/shadows/playservices/src/test/java/org/robolectric/shadows/gms/common/ShadowGoogleApiAvailabilityTest.java
new file mode 100644
index 0000000..7e12ec7
--- /dev/null
+++ b/shadows/playservices/src/test/java/org/robolectric/shadows/gms/common/ShadowGoogleApiAvailabilityTest.java
@@ -0,0 +1,143 @@
+package org.robolectric.shadows.gms.common;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.gms.Shadows;
+
+/**
+ * Created by diegotori on 2/14/16.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE, shadows = {ShadowGoogleApiAvailability.class})
+public class ShadowGoogleApiAvailabilityTest {
+
+    private Context roboContext;
+
+    @Before
+    public void setUp() {
+    roboContext = RuntimeEnvironment.getApplication();
+    }
+
+    @After
+    public void tearDown() {
+        roboContext = null;
+    }
+
+    @Test
+    public void getInstance() {
+        //Given the expected GoogleApiAvailability instance
+        final GoogleApiAvailability expected = GoogleApiAvailability.getInstance();
+
+        //When getting the actual one from the shadow
+        final GoogleApiAvailability actual = ShadowGoogleApiAvailability.getInstance();
+
+    // Then verify that the expected is a not null and equal to the actual one
+    assertThat(expected).isEqualTo(actual);
+    }
+
+    @Test
+    public void shadowOf() {
+        final ShadowGoogleApiAvailability shadowGoogleApiAvailability
+                = Shadows.shadowOf(GoogleApiAvailability.getInstance());
+        assertThat(shadowGoogleApiAvailability).isNotNull();
+    }
+
+    @Test
+    public void setIsGooglePlayServicesAvailable() {
+        //Given an expected and injected ConnectionResult code
+        final ShadowGoogleApiAvailability shadowGoogleApiAvailability
+                = Shadows.shadowOf(GoogleApiAvailability.getInstance());
+        final int expectedCode = ConnectionResult.SUCCESS;
+        shadowGoogleApiAvailability.setIsGooglePlayServicesAvailable(expectedCode);
+
+        //When getting the actual ConnectionResult code
+        final int actualCode = GoogleApiAvailability.getInstance()
+                .isGooglePlayServicesAvailable(roboContext);
+
+        //Then verify that we got back our expected code and not the default one.
+        assertThat(actualCode)
+                .isEqualTo(expectedCode);
+    }
+
+    @Test
+    public void setIsUserResolvableError() {
+        //Given an injected user resolvable error flag
+        final ShadowGoogleApiAvailability shadowGoogleApiAvailability
+                = Shadows.shadowOf(GoogleApiAvailability.getInstance());
+        shadowGoogleApiAvailability.setIsUserResolvableError(true);
+
+        //When getting the actual flag value
+        final boolean actual = GoogleApiAvailability.getInstance()
+                .isUserResolvableError(ConnectionResult.API_UNAVAILABLE);
+
+        //Then verify that its equal to true
+        assertThat(actual).isTrue();
+    }
+
+    @Test
+    public void setOpenSourceSoftwareLicenseInfo() {
+        //Given mock open source license info
+        final String expected = "Mock open source license info";
+        final ShadowGoogleApiAvailability shadowGoogleApiAvailability
+                = Shadows.shadowOf(GoogleApiAvailability.getInstance());
+        shadowGoogleApiAvailability.setOpenSourceSoftwareLicenseInfo(expected);
+
+        //When getting the actual value
+        final String actual = GoogleApiAvailability.getInstance()
+                .getOpenSourceSoftwareLicenseInfo(roboContext);
+
+        //Then verify that its not null, not empty, and equal to the expected value
+        assertThat(actual)
+                .isEqualTo(expected);
+    }
+
+    @Test
+    public void setErrorDialog(){
+        final ShadowGoogleApiAvailability shadowGoogleApiAvailability
+                = Shadows.shadowOf(GoogleApiAvailability.getInstance());
+        final Dialog expectedDialog = mock(Dialog.class);
+        final Activity mockActivity = mock(Activity.class);
+        final int mockErrorCode = ConnectionResult.API_UNAVAILABLE;
+        final int mockRequestCode = 1234;
+        shadowGoogleApiAvailability.setErrorDialog(expectedDialog);
+
+        final Dialog actualDialog = GoogleApiAvailability.getInstance()
+                .getErrorDialog(mockActivity, mockErrorCode, mockRequestCode);
+
+        assertThat(actualDialog)
+                .isEqualTo(expectedDialog);
+    }
+
+    @Test
+    public void setErrorDialog__OnCancelListenerMethod(){
+        final ShadowGoogleApiAvailability shadowGoogleApiAvailability
+                = Shadows.shadowOf(GoogleApiAvailability.getInstance());
+        final Dialog expectedDialog = mock(Dialog.class);
+        final Activity mockActivity = mock(Activity.class);
+        final DialogInterface.OnCancelListener mockOnCancelListener =
+                mock(DialogInterface.OnCancelListener.class);
+        final int mockErrorCode = ConnectionResult.API_UNAVAILABLE;
+        final int mockRequestCode = 1234;
+        shadowGoogleApiAvailability.setErrorDialog(expectedDialog);
+
+        final Dialog actualDialog = GoogleApiAvailability.getInstance()
+                .getErrorDialog(mockActivity, mockErrorCode, mockRequestCode, mockOnCancelListener);
+
+        assertThat(actualDialog)
+                .isEqualTo(expectedDialog);
+    }
+}
diff --git a/testapp/build.gradle b/testapp/build.gradle
new file mode 100644
index 0000000..651ced0
--- /dev/null
+++ b/testapp/build.gradle
@@ -0,0 +1,19 @@
+apply plugin: 'com.android.library'
+
+android {
+    compileSdk 33
+
+    defaultConfig {
+        minSdk 16
+        targetSdk 33
+        versionCode 1
+        versionName "1.0"
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        vectorDrawables.useSupportLibrary = true
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
diff --git a/testapp/src/main/AndroidManifest.xml b/testapp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d594f23
--- /dev/null
+++ b/testapp/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.robolectric.testapp"
+    android:versionCode="123"
+    android:versionName="aVersionName">
+
+    <application
+        android:theme="@style/Theme.Robolectric" android:enabled="true">
+      <activity android:name=".TestActivity" android:exported="true">
+        <intent-filter>
+            <category android:name="android.intent.category.LAUNCHER"/>
+            <action android:name="android.intent.action.MAIN"/>
+        </intent-filter>
+      </activity>
+      <activity android:name=".DisabledTestActivity" android:enabled="false" android:exported="true"/>
+      <service android:name=".TestService" android:exported="true"/>
+
+      <activity
+          android:name=".ActivityWithAnotherTheme"
+          android:theme="@style/Theme.AnotherTheme" android:exported="true"/>
+      <activity android:name=".ActivityWithoutTheme" android:exported="true"/>
+
+    </application>
+</manifest>
diff --git a/testapp/src/main/assets/assetsHome.txt b/testapp/src/main/assets/assetsHome.txt
new file mode 100644
index 0000000..0b27d62
--- /dev/null
+++ b/testapp/src/main/assets/assetsHome.txt
@@ -0,0 +1 @@
+assetsHome!
\ No newline at end of file
diff --git a/testapp/src/main/assets/myFont.ttf b/testapp/src/main/assets/myFont.ttf
new file mode 100644
index 0000000..05c4d7e
--- /dev/null
+++ b/testapp/src/main/assets/myFont.ttf
@@ -0,0 +1 @@
+myFontData
\ No newline at end of file
diff --git a/testapp/src/main/assets/robolectric.png b/testapp/src/main/assets/robolectric.png
new file mode 100644
index 0000000..ea52306
--- /dev/null
+++ b/testapp/src/main/assets/robolectric.png
Binary files differ
diff --git a/testapp/src/main/assets/testing/hello.txt b/testapp/src/main/assets/testing/hello.txt
new file mode 100644
index 0000000..3462721
--- /dev/null
+++ b/testapp/src/main/assets/testing/hello.txt
@@ -0,0 +1 @@
+hello!
\ No newline at end of file
diff --git a/testapp/src/main/java/org/robolectric/testapp/ActivityWithAnotherTheme.java b/testapp/src/main/java/org/robolectric/testapp/ActivityWithAnotherTheme.java
new file mode 100644
index 0000000..ad973dc
--- /dev/null
+++ b/testapp/src/main/java/org/robolectric/testapp/ActivityWithAnotherTheme.java
@@ -0,0 +1,21 @@
+package org.robolectric.testapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+/** A test activity that can customize the theme. */
+public class ActivityWithAnotherTheme extends Activity {
+
+  public static Integer setThemeBeforeContentView = null;
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+
+    if (setThemeBeforeContentView != null) {
+      setTheme(setThemeBeforeContentView);
+    }
+
+    setContentView(R.layout.styles_button_layout);
+  }
+}
diff --git a/testapp/src/main/java/org/robolectric/testapp/ActivityWithoutTheme.java b/testapp/src/main/java/org/robolectric/testapp/ActivityWithoutTheme.java
new file mode 100644
index 0000000..91a4c1f
--- /dev/null
+++ b/testapp/src/main/java/org/robolectric/testapp/ActivityWithoutTheme.java
@@ -0,0 +1,15 @@
+package org.robolectric.testapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+/** A test activity with no theme. */
+public class ActivityWithoutTheme extends Activity {
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+
+    setContentView(R.layout.styles_button_layout);
+  }
+}
diff --git a/testapp/src/main/java/org/robolectric/testapp/DisabledTestActivity.java b/testapp/src/main/java/org/robolectric/testapp/DisabledTestActivity.java
new file mode 100644
index 0000000..872c240
--- /dev/null
+++ b/testapp/src/main/java/org/robolectric/testapp/DisabledTestActivity.java
@@ -0,0 +1,6 @@
+package org.robolectric.testapp;
+
+import android.app.Activity;
+
+/** Test activity that is disabled in the manifest. */
+public class DisabledTestActivity extends Activity {}
diff --git a/testapp/src/main/java/org/robolectric/testapp/TestActivity.java b/testapp/src/main/java/org/robolectric/testapp/TestActivity.java
new file mode 100644
index 0000000..561e2b7
--- /dev/null
+++ b/testapp/src/main/java/org/robolectric/testapp/TestActivity.java
@@ -0,0 +1,6 @@
+package org.robolectric.testapp;
+
+import android.app.Activity;
+
+/** Test activity that is enabled in the manifest. */
+public class TestActivity extends Activity {}
diff --git a/testapp/src/main/java/org/robolectric/testapp/TestService.java b/testapp/src/main/java/org/robolectric/testapp/TestService.java
new file mode 100644
index 0000000..68060f9
--- /dev/null
+++ b/testapp/src/main/java/org/robolectric/testapp/TestService.java
@@ -0,0 +1,13 @@
+package org.robolectric.testapp;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+/** Test service. */
+public class TestService extends Service {
+  @Override
+  public IBinder onBind(Intent intent) {
+    return null;
+  }
+}
diff --git a/testapp/src/main/res/anim/animation_list.xml b/testapp/src/main/res/anim/animation_list.xml
new file mode 100644
index 0000000..ef11209
--- /dev/null
+++ b/testapp/src/main/res/anim/animation_list.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
+  <item android:drawable="@drawable/an_image" android:duration="400"/>
+  <item android:drawable="@drawable/an_other_image" android:duration="400"/>
+  <item android:drawable="@drawable/third_image" android:duration="300"/>
+</animation-list>
\ No newline at end of file
diff --git a/testapp/src/main/res/anim/test_anim_1.xml b/testapp/src/main/res/anim/test_anim_1.xml
new file mode 100644
index 0000000..7d7b305
--- /dev/null
+++ b/testapp/src/main/res/anim/test_anim_1.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+   android:interpolator="@android:anim/accelerate_interpolator"
+   android:shareInterpolator="true">
+  <alpha
+      android:fromAlpha="0.1"
+      android:toAlpha="0.2"/>
+  <scale
+      android:fromXScale="0.1"
+      android:toXScale="0.2"
+      android:fromYScale="0.3"
+      android:toYScale="0.4"
+      android:pivotX="0.5"
+      android:pivotY="0.6"/>
+  <translate
+      android:fromXDelta="0.1"
+      android:toXDelta="0.2"
+      android:fromYDelta="0.3"
+      android:toYDelta="0.4"/>
+  <rotate
+      android:fromDegrees="0.1"
+      android:toDegrees="0.2"
+      android:pivotX="0.3"
+      android:pivotY="0.4"/>
+
+  <animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="true">
+    <item android:drawable="@drawable/l0_red" android:duration="200" />
+    <item android:drawable="@drawable/l1_orange" android:duration="200" />
+    <item android:drawable="@drawable/l2_yellow" android:duration="200" />
+  </animation-list>
+</set>
\ No newline at end of file
diff --git a/testapp/src/main/res/animator/fade.xml b/testapp/src/main/res/animator/fade.xml
new file mode 100644
index 0000000..8ea39b9
--- /dev/null
+++ b/testapp/src/main/res/animator/fade.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android" android:propertyName="alpha" android:valueFrom="0.0" android:valueTo="1.0" android:duration="10"/>
diff --git a/testapp/src/main/res/animator/spinning.xml b/testapp/src/main/res/animator/spinning.xml
new file mode 100644
index 0000000..db0a704
--- /dev/null
+++ b/testapp/src/main/res/animator/spinning.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<animator xmlns:android="http://schemas.android.com/apk/res/android" android:valueTo="100" android:valueFrom="0" />
\ No newline at end of file
diff --git a/testapp/src/main/res/color/color_state_list.xml b/testapp/src/main/res/color/color_state_list.xml
new file mode 100644
index 0000000..669b5e2
--- /dev/null
+++ b/testapp/src/main/res/color/color_state_list.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:state_pressed="true" android:color="#ffff0000"/>
+  <item android:state_focused="true" android:color="#ff0000ff"/>
+  <item android:color="#ff000000"/>
+</selector>
\ No newline at end of file
diff --git a/testapp/src/main/res/color/custom_state_view_text_color.xml b/testapp/src/main/res/color/custom_state_view_text_color.xml
new file mode 100644
index 0000000..5f4f159
--- /dev/null
+++ b/testapp/src/main/res/color/custom_state_view_text_color.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android"
+          xmlns:aCustomNamespace="http://schemas.android.com/apk/res-auto">
+  <item android:color="#ffff0000" aCustomNamespace:stateFoo="true"/>
+  <item android:color="#ff000000"/>
+</selector>
diff --git a/testapp/src/main/res/drawable-anydpi/an_image_or_vector.xml b/testapp/src/main/res/drawable-anydpi/an_image_or_vector.xml
new file mode 100644
index 0000000..d90482e
--- /dev/null
+++ b/testapp/src/main/res/drawable-anydpi/an_image_or_vector.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <group
+            android:name="icon_null"
+            android:translateX="24"
+            android:translateY="24"
+            android:scaleX="0.2"
+            android:scaleY="0.2">
+        <group
+                android:name="check"
+                android:scaleX="7.5"
+                android:scaleY="7.5">
+            <path
+                    android:name="check_path_merged"
+                    android:pathData="M 7.0,-9.0 c 0.0,0.0 -14.0,0.0 -14.0,0.0 c -1.1044921875,0.0 -2.0,0.8955078125 -2.0,2.0 c 0.0,0.0 0.0,14.0 0.0,14.0 c 0.0,1.1044921875 0.8955078125,2.0 2.0,2.0 c 0.0,0.0 14.0,0.0 14.0,0.0 c 1.1044921875,0.0 2.0,-0.8955078125 2.0,-2.0 c 0.0,0.0 0.0,-14.0 0.0,-14.0 c 0.0,-1.1044921875 -0.8955078125,-2.0 -2.0,-2.0 c 0.0,0.0 0.0,0.0 0.0,0.0 Z M -2.0,5.00001525879 c 0.0,0.0 -5.0,-5.00001525879 -5.0,-5.00001525879 c 0.0,0.0 1.41409301758,-1.41409301758 1.41409301758,-1.41409301758 c 0.0,0.0 3.58590698242,3.58601379395 3.58590698242,3.58601379395 c 0.0,0.0 7.58590698242,-7.58601379395 7.58590698242,-7.58601379395 c 0.0,0.0 1.41409301758,1.41409301758 1.41409301758,1.41409301758 c 0.0,0.0 -9.0,9.00001525879 -9.0,9.00001525879 Z"
+                    android:fillColor="#FF000000" />
+        </group>
+        <group
+                android:name="box_dilate"
+                android:scaleX="7.5"
+                android:scaleY="7.5">
+            <path
+                    android:fillColor="#FFFF0000"
+                    android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
+        </group>
+    </group>
+</vector>
diff --git a/testapp/src/main/res/drawable-hdpi/an_image.png b/testapp/src/main/res/drawable-hdpi/an_image.png
new file mode 100644
index 0000000..a40f229
--- /dev/null
+++ b/testapp/src/main/res/drawable-hdpi/an_image.png
Binary files differ
diff --git a/testapp/src/main/res/drawable-hdpi/robolectric.png b/testapp/src/main/res/drawable-hdpi/robolectric.png
new file mode 100644
index 0000000..ea52306
--- /dev/null
+++ b/testapp/src/main/res/drawable-hdpi/robolectric.png
Binary files differ
diff --git a/testapp/src/main/res/drawable-mdpi/robolectric.png b/testapp/src/main/res/drawable-mdpi/robolectric.png
new file mode 100644
index 0000000..7d9902d
--- /dev/null
+++ b/testapp/src/main/res/drawable-mdpi/robolectric.png
Binary files differ
diff --git a/testapp/src/main/res/drawable-xlarge/rainbow.xml b/testapp/src/main/res/drawable-xlarge/rainbow.xml
new file mode 100644
index 0000000..8e12d46
--- /dev/null
+++ b/testapp/src/main/res/drawable-xlarge/rainbow.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:drawable="@drawable/l0_red" />
+  <item android:drawable="@drawable/l1_orange" />
+  <item android:drawable="@drawable/l2_yellow" />
+  <item android:drawable="@drawable/l3_green" />
+  <item android:drawable="@drawable/l4_blue" />
+  <item android:drawable="@drawable/l6_violet" />
+</layer-list>
diff --git a/testapp/src/main/res/drawable/an_image.png b/testapp/src/main/res/drawable/an_image.png
new file mode 100644
index 0000000..a40f229
--- /dev/null
+++ b/testapp/src/main/res/drawable/an_image.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/an_image_or_vector.png b/testapp/src/main/res/drawable/an_image_or_vector.png
new file mode 100644
index 0000000..65d000a
--- /dev/null
+++ b/testapp/src/main/res/drawable/an_image_or_vector.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/an_other_image.gif b/testapp/src/main/res/drawable/an_other_image.gif
new file mode 100644
index 0000000..d64678d
--- /dev/null
+++ b/testapp/src/main/res/drawable/an_other_image.gif
Binary files differ
diff --git a/testapp/src/main/res/drawable/drawable_with_nine_patch.xml b/testapp/src/main/res/drawable/drawable_with_nine_patch.xml
new file mode 100644
index 0000000..9bd5451
--- /dev/null
+++ b/testapp/src/main/res/drawable/drawable_with_nine_patch.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <!-- default -->
+  <item>
+    <nine-patch android:src="@drawable/nine_patch_drawable">
+    </nine-patch>
+  </item>
+</layer-list>
\ No newline at end of file
diff --git a/testapp/src/main/res/drawable/fourth_image.jpg b/testapp/src/main/res/drawable/fourth_image.jpg
new file mode 100644
index 0000000..d173fea
--- /dev/null
+++ b/testapp/src/main/res/drawable/fourth_image.jpg
Binary files differ
diff --git a/testapp/src/main/res/drawable/image_background.png b/testapp/src/main/res/drawable/image_background.png
new file mode 100644
index 0000000..b93206c
--- /dev/null
+++ b/testapp/src/main/res/drawable/image_background.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l0_red.png b/testapp/src/main/res/drawable/l0_red.png
new file mode 100644
index 0000000..2a26476
--- /dev/null
+++ b/testapp/src/main/res/drawable/l0_red.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l1_orange.png b/testapp/src/main/res/drawable/l1_orange.png
new file mode 100644
index 0000000..016a52b
--- /dev/null
+++ b/testapp/src/main/res/drawable/l1_orange.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l2_yellow.png b/testapp/src/main/res/drawable/l2_yellow.png
new file mode 100644
index 0000000..30f971d
--- /dev/null
+++ b/testapp/src/main/res/drawable/l2_yellow.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l3_green.png b/testapp/src/main/res/drawable/l3_green.png
new file mode 100644
index 0000000..67ffb0a
--- /dev/null
+++ b/testapp/src/main/res/drawable/l3_green.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l4_blue.png b/testapp/src/main/res/drawable/l4_blue.png
new file mode 100644
index 0000000..5528619
--- /dev/null
+++ b/testapp/src/main/res/drawable/l4_blue.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l5_indigo.png b/testapp/src/main/res/drawable/l5_indigo.png
new file mode 100644
index 0000000..fbc8e42
--- /dev/null
+++ b/testapp/src/main/res/drawable/l5_indigo.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l6_violet.png b/testapp/src/main/res/drawable/l6_violet.png
new file mode 100644
index 0000000..b9f4f8d
--- /dev/null
+++ b/testapp/src/main/res/drawable/l6_violet.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/l7_white.png b/testapp/src/main/res/drawable/l7_white.png
new file mode 100644
index 0000000..288d33c
--- /dev/null
+++ b/testapp/src/main/res/drawable/l7_white.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/nine_patch_drawable.9.png b/testapp/src/main/res/drawable/nine_patch_drawable.9.png
new file mode 100644
index 0000000..6e25b0b
--- /dev/null
+++ b/testapp/src/main/res/drawable/nine_patch_drawable.9.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/rainbow.xml b/testapp/src/main/res/drawable/rainbow.xml
new file mode 100644
index 0000000..03352cb
--- /dev/null
+++ b/testapp/src/main/res/drawable/rainbow.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:drawable="@drawable/l7_white" />
+  <item android:drawable="@drawable/l0_red" />
+  <item android:drawable="@drawable/l1_orange" />
+  <item android:drawable="@drawable/l2_yellow" />
+  <item android:drawable="@drawable/l3_green" />
+  <item android:drawable="@drawable/l4_blue" />
+  <item android:drawable="@drawable/l5_indigo" />
+  <item android:drawable="@drawable/l6_violet" />
+</layer-list>
diff --git a/testapp/src/main/res/drawable/state_drawable.xml b/testapp/src/main/res/drawable/state_drawable.xml
new file mode 100644
index 0000000..8489487
--- /dev/null
+++ b/testapp/src/main/res/drawable/state_drawable.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<selector
+  xmlns:android="http://schemas.android.com/apk/res/android">
+
+   <item android:state_selected="true" android:drawable="@drawable/l0_red" />
+   <item android:state_pressed="true" android:drawable="@drawable/l1_orange" />
+   <item android:state_focused="true" android:drawable="@drawable/l2_yellow" />
+   <item android:state_checkable="true" android:drawable="@drawable/l3_green" />
+   <item android:state_checked="true" android:drawable="@drawable/l4_blue" />
+   <item android:state_enabled="true" android:drawable="@drawable/l5_indigo" />
+   <item android:state_window_focused="true" android:drawable="@drawable/l6_violet" />
+   
+   <item android:drawable="@drawable/l7_white" /> 
+
+</selector>
\ No newline at end of file
diff --git a/testapp/src/main/res/drawable/third_image.png b/testapp/src/main/res/drawable/third_image.png
new file mode 100644
index 0000000..4734497
--- /dev/null
+++ b/testapp/src/main/res/drawable/third_image.png
Binary files differ
diff --git a/testapp/src/main/res/drawable/vector.xml b/testapp/src/main/res/drawable/vector.xml
new file mode 100644
index 0000000..a143b80
--- /dev/null
+++ b/testapp/src/main/res/drawable/vector.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="12.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FFFF0000"
+        android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
+</vector>
diff --git a/testapp/src/main/res/font/downloadable.xml b/testapp/src/main/res/font/downloadable.xml
new file mode 100644
index 0000000..9caa538
--- /dev/null
+++ b/testapp/src/main/res/font/downloadable.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<font-family xmlns:android="http://schemas.android.com/apk/res/android"
+    android:fontProviderAuthority="com.example.fontprovider.authority"
+    android:fontProviderPackage="com.example.fontprovider"
+    android:fontProviderQuery="example font"/>
diff --git a/testapp/src/main/res/font/vt323.xml b/testapp/src/main/res/font/vt323.xml
new file mode 100644
index 0000000..7963261
--- /dev/null
+++ b/testapp/src/main/res/font/vt323.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<font-family xmlns:android="http://schemas.android.com/apk/res/android">
+  <font
+      android:fontStyle="normal"
+      android:fontWeight="400"
+      android:font="@font/vt323_regular" />
+  <font
+      android:fontStyle="normal"
+      android:fontWeight="700"
+      android:font="@font/vt323_bold" />
+</font-family>
diff --git a/testapp/src/main/res/font/vt323_bold.ttf b/testapp/src/main/res/font/vt323_bold.ttf
new file mode 100644
index 0000000..afa6909
--- /dev/null
+++ b/testapp/src/main/res/font/vt323_bold.ttf
Binary files differ
diff --git a/testapp/src/main/res/font/vt323_regular.ttf b/testapp/src/main/res/font/vt323_regular.ttf
new file mode 100644
index 0000000..afa6909
--- /dev/null
+++ b/testapp/src/main/res/font/vt323_regular.ttf
Binary files differ
diff --git a/testapp/src/main/res/layout-land/different_screen_sizes.xml b/testapp/src/main/res/layout-land/different_screen_sizes.xml
new file mode 100644
index 0000000..d264edb
--- /dev/null
+++ b/testapp/src/main/res/layout-land/different_screen_sizes.xml
@@ -0,0 +1,12 @@
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content"
+  >
+  <TextView
+    android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="land"
+    />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout-land/multi_orientation.xml b/testapp/src/main/res/layout-land/multi_orientation.xml
new file mode 100644
index 0000000..a251957
--- /dev/null
+++ b/testapp/src/main/res/layout-land/multi_orientation.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/landscape"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/my_landscape_text"
+      android:text="Landscape!"
+      android:layout_width="match_parent"
+      android:layout_height="match_parent"
+      />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout-sw320dp/layout_320_smallest_width.xml b/testapp/src/main/res/layout-sw320dp/layout_320_smallest_width.xml
new file mode 100644
index 0000000..43103af
--- /dev/null
+++ b/testapp/src/main/res/layout-sw320dp/layout_320_smallest_width.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:orientation="vertical"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+/>
diff --git a/testapp/src/main/res/layout-sw320dp/layout_smallest_width.xml b/testapp/src/main/res/layout-sw320dp/layout_smallest_width.xml
new file mode 100644
index 0000000..f23364e
--- /dev/null
+++ b/testapp/src/main/res/layout-sw320dp/layout_smallest_width.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:orientation="vertical"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content">
+        <TextView android:id="@+id/text1"
+                  android:layout_width="wrap_content"
+                  android:layout_height="wrap_content"
+                  android:text="320"/>
+></LinearLayout>
diff --git a/testapp/src/main/res/layout-sw720dp/layout_smallest_width.xml b/testapp/src/main/res/layout-sw720dp/layout_smallest_width.xml
new file mode 100644
index 0000000..1fb3098
--- /dev/null
+++ b/testapp/src/main/res/layout-sw720dp/layout_smallest_width.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:orientation="vertical"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content">
+        <TextView android:id="@+id/text1"
+                  android:layout_width="wrap_content"
+                  android:layout_height="wrap_content"
+                  android:text="720"/>
+></LinearLayout>
diff --git a/testapp/src/main/res/layout-xlarge/different_screen_sizes.xml b/testapp/src/main/res/layout-xlarge/different_screen_sizes.xml
new file mode 100644
index 0000000..86d5b8e
--- /dev/null
+++ b/testapp/src/main/res/layout-xlarge/different_screen_sizes.xml
@@ -0,0 +1,12 @@
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content"
+  >
+  <TextView
+    android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="xlarge"
+    />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/activity_list_item.xml b/testapp/src/main/res/layout/activity_list_item.xml
new file mode 100644
index 0000000..df33117
--- /dev/null
+++ b/testapp/src/main/res/layout/activity_list_item.xml
@@ -0,0 +1,18 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="match_parent"
+  android:layout_height="wrap_content"
+  android:paddingTop="1dip"
+  android:paddingBottom="1dip"
+  android:paddingLeft="6dip"
+  android:paddingRight="6dip">
+
+  <ImageView android:id="@+id/icon"
+    android:layout_width="24dip"
+    android:layout_height="24dip"/>
+
+  <TextView android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_gravity="center_horizontal"
+    android:paddingLeft="6dip" />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/activity_main.xml b/testapp/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..5daef49
--- /dev/null
+++ b/testapp/src/main/res/layout/activity_main.xml
@@ -0,0 +1,11 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" >
+
+    <TextView
+        android:id="@+id/hello"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="hello" />
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/activity_main_1.xml b/testapp/src/main/res/layout/activity_main_1.xml
new file mode 100644
index 0000000..9649e98
--- /dev/null
+++ b/testapp/src/main/res/layout/activity_main_1.xml
@@ -0,0 +1,24 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" >
+
+    <LinearLayout
+        android:id="@+id/id_declared_in_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical" >
+
+        <TextView
+            android:id="@+id/hello"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="hello" />
+
+        <TextView
+            android:id="@+id/world"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="world" />
+    </LinearLayout>
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/custom_layout.xml b/testapp/src/main/res/layout/custom_layout.xml
new file mode 100644
index 0000000..8773ddb
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_layout.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:robolectric="http://schemas.android.com/apk/res-auto"
+    android:gravity="center"
+    robolectric:message="@string/hello"
+    robolectric:itemType="marsupial"
+    />
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/custom_layout2.xml b/testapp/src/main/res/layout/custom_layout2.xml
new file mode 100644
index 0000000..de67e3e
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_layout2.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomView2
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    message="@string/hello">
+  <org.robolectric.android.CustomView2
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      >
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+  </org.robolectric.android.CustomView2>
+</org.robolectric.android.CustomView2>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/custom_layout3.xml b/testapp/src/main/res/layout/custom_layout3.xml
new file mode 100644
index 0000000..85c858b
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_layout3.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<view class="org.robolectric.shadows.ShadowLayoutInflaterTest$CustomView3" android:text="Hello bonjour"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    />
diff --git a/testapp/src/main/res/layout/custom_layout4.xml b/testapp/src/main/res/layout/custom_layout4.xml
new file mode 100644
index 0000000..f541c6d
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_layout4.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:robolectric="http://schemas.android.com/apk/res-auto"
+    xmlns:fakens="http://example.com/fakens"
+    >
+  <org.robolectric.android.CustomView
+      android:id="@+id/custom_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:gravity="center"
+      robolectric:message="@string/hello"
+      robolectric:itemType="marsupial"
+      fakens:message="@layout/text_views"
+      >
+    <View
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        />
+  </org.robolectric.android.CustomView>
+</LinearLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/custom_layout5.xml b/testapp/src/main/res/layout/custom_layout5.xml
new file mode 100644
index 0000000..e4d2974
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_layout5.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:robolectric="http://schemas.android.com/apk/res-auto"
+    android:gravity="center"
+    robolectric:message="@string/hello"
+    robolectric:itemType="marsupial"
+    />
diff --git a/testapp/src/main/res/layout/custom_layout6.xml b/testapp/src/main/res/layout/custom_layout6.xml
new file mode 100644
index 0000000..eb099fb
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_layout6.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<org.robolectric.android.CustomStateView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:text="Some text"
+    android:textColor="@color/custom_state_view_text_color"
+    />
diff --git a/testapp/src/main/res/layout/custom_title.xml b/testapp/src/main/res/layout/custom_title.xml
new file mode 100644
index 0000000..37f0af4
--- /dev/null
+++ b/testapp/src/main/res/layout/custom_title.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="30dp" >
+
+    <TextView
+        android:id="@+id/custom_title_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/hello" />
+
+</RelativeLayout>
diff --git a/testapp/src/main/res/layout/different_screen_sizes.xml b/testapp/src/main/res/layout/different_screen_sizes.xml
new file mode 100644
index 0000000..313abfb
--- /dev/null
+++ b/testapp/src/main/res/layout/different_screen_sizes.xml
@@ -0,0 +1,12 @@
+<LinearLayout
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="wrap_content"
+  android:layout_height="wrap_content"
+  >
+  <TextView
+    android:id="@android:id/text1"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:text="default"
+    />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/edit_text.xml b/testapp/src/main/res/layout/edit_text.xml
new file mode 100644
index 0000000..f0c0fa6
--- /dev/null
+++ b/testapp/src/main/res/layout/edit_text.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<EditText
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:hint="Hello, Hint"
+  />
diff --git a/testapp/src/main/res/layout/fragment.xml b/testapp/src/main/res/layout/fragment.xml
new file mode 100644
index 0000000..cd32b7e
--- /dev/null
+++ b/testapp/src/main/res/layout/fragment.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<fragment
+      xmlns:android="http://schemas.android.com/apk/res/android"
+      android:name="org.robolectric.util.CustomFragment"
+      android:id="@+id/my_fragment"
+      android:layout_width="match_parent"
+      android:layout_height="match_parent"/>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/fragment_activity.xml b/testapp/src/main/res/layout/fragment_activity.xml
new file mode 100644
index 0000000..bea8241
--- /dev/null
+++ b/testapp/src/main/res/layout/fragment_activity.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/fragment_container"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <fragment
+      android:id="@+id/fragment"
+      android:tag="fragment_tag"
+      android:name="org.robolectric.shadows.TestFragment"
+      />
+
+  <LinearLayout
+      android:id="@+id/dynamic_fragment_container"
+      />
+
+
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/fragment_contents.xml b/testapp/src/main/res/layout/fragment_contents.xml
new file mode 100644
index 0000000..c3d9cfa
--- /dev/null
+++ b/testapp/src/main/res/layout/fragment_contents.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    android:orientation="vertical"
+    >
+  <TextView
+      android:id="@+id/tacos"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="TACOS"/>
+  <TextView
+      android:id="@+id/burritos"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="BURRITOS"/>
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/included_layout_parent.xml b/testapp/src/main/res/layout/included_layout_parent.xml
new file mode 100644
index 0000000..9c2bd86
--- /dev/null
+++ b/testapp/src/main/res/layout/included_layout_parent.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include layout="@layout/included_linear_layout"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/included_linear_layout.xml b/testapp/src/main/res/layout/included_linear_layout.xml
new file mode 100644
index 0000000..e02b8c1
--- /dev/null
+++ b/testapp/src/main/res/layout/included_linear_layout.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    />
diff --git a/testapp/src/main/res/layout/inner_merge.xml b/testapp/src/main/res/layout/inner_merge.xml
new file mode 100644
index 0000000..538b0b8
--- /dev/null
+++ b/testapp/src/main/res/layout/inner_merge.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+  <TextView
+      android:id="@+id/inner_text"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+</merge>
diff --git a/testapp/src/main/res/layout/main.xml b/testapp/src/main/res/layout/main.xml
new file mode 100644
index 0000000..6fc3b8c
--- /dev/null
+++ b/testapp/src/main/res/layout/main.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include layout="@layout/snippet"/>
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/time"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:enabled="false"
+      android:contentDescription="@string/howdy"
+      android:alpha="0.3"
+      />
+
+  <TextView
+      android:id="@+id/title"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:maxHeight="46dip"
+      android:singleLine="false"
+      android:gravity="center_horizontal"
+      android:text="Main Layout"
+      android:textSize="18dip"
+      android:textStyle="bold"
+      android:textColor="#fff"
+      android:drawableTop="@drawable/an_image"
+      android:drawableRight="@drawable/an_other_image"
+      android:drawableBottom="@drawable/third_image"
+      android:drawableLeft="@drawable/fourth_image"
+      />
+
+  <TextView
+      android:id="@+id/subtitle"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:gravity="center_horizontal"
+      android:text="@string/hello"
+      android:maxHeight="36dip"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      />
+
+  <CheckBox
+      android:id="@+id/true_checkbox"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:checked="true"
+      />
+  <CheckBox
+      android:id="@+id/false_checkbox"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:checked="false"
+      />
+  <CheckBox
+      android:id="@+id/default_checkbox"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+
+  <ImageView
+      android:id="@+id/image"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:src="@drawable/an_image"
+      android:background="@drawable/image_background"
+      />
+
+    <ImageView
+            android:id="@+id/mipmapImage"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:src="@mipmap/robolectric"
+            />
+
+  <Button
+      android:id="@+id/button"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:onClick="onButtonClick"
+      />
+
+  <!-- see https://github.com/robolectric/robolectric/issues/521 -->
+  <HorizontalScrollView
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/mapview.xml b/testapp/src/main/res/layout/mapview.xml
new file mode 100644
index 0000000..ef8e34c
--- /dev/null
+++ b/testapp/src/main/res/layout/mapview.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    >
+
+  <com.google.android.maps.MapView
+      android:id="@+id/map_view"
+      android:layout_width="fill_parent"
+      android:layout_height="fill_parent"
+      android:clickable="true"
+      android:apiKey="Your Maps API Key goes here"
+      />
+</RelativeLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/media.xml b/testapp/src/main/res/layout/media.xml
new file mode 100644
index 0000000..d25af5c
--- /dev/null
+++ b/testapp/src/main/res/layout/media.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include android:id="@+id/include_id" layout="@layout/snippet"/>
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/time"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:text="Media Layout"
+      />
+
+  <TextView
+      android:id="@+id/title"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:maxHeight="46dip"
+      android:singleLine="false"
+      android:gravity="center_horizontal"
+      android:textSize="18dip"
+      android:textStyle="bold"
+      android:textColor="#fff"
+      />
+
+  <TextView
+      android:id="@+id/subtitle"
+      android:layout_width="138dip"
+      android:layout_height="wrap_content"
+      android:gravity="center_horizontal"
+      android:maxHeight="36dip"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:visibility="gone"
+      />
+
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/multi_orientation.xml b/testapp/src/main/res/layout/multi_orientation.xml
new file mode 100644
index 0000000..5b5e267
--- /dev/null
+++ b/testapp/src/main/res/layout/multi_orientation.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/portrait"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <TextView
+      android:id="@+id/title"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:textSize="14dip"
+      android:textColor="#fff"
+      android:text="I'm a Portrait Layout!"
+      />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/ordinal_scrollbar.xml b/testapp/src/main/res/layout/ordinal_scrollbar.xml
new file mode 100644
index 0000000..571f1a1
--- /dev/null
+++ b/testapp/src/main/res/layout/ordinal_scrollbar.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ListView
+        android:id="@+id/list_view_with_enum_scrollbar"
+        android:layout_width="match_parent"
+        android:layout_height="0px"
+        android:layout_weight="1"
+        android:scrollbarStyle="@integer/scrollbar_style_ordinal_outside_overlay"/>
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/outer.xml b/testapp/src/main/res/layout/outer.xml
new file mode 100644
index 0000000..d1309fc
--- /dev/null
+++ b/testapp/src/main/res/layout/outer.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/outer_merge"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:gravity="center_horizontal"
+    >
+
+  <include android:id="@+id/include_id" layout="@layout/inner_merge"/>
+
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/override_include.xml b/testapp/src/main/res/layout/override_include.xml
new file mode 100644
index 0000000..24ceea0
--- /dev/null
+++ b/testapp/src/main/res/layout/override_include.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <include
+      layout="@layout/snippet"
+      android:visibility="invisible"
+      />
+
+  <include layout="@layout/inner_merge"/>
+
+
+</LinearLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/remote_views.xml b/testapp/src/main/res/layout/remote_views.xml
new file mode 100644
index 0000000..4247966
--- /dev/null
+++ b/testapp/src/main/res/layout/remote_views.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content">
+
+    <TextView
+        android:id="@+id/remote_view_1"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+    <ImageView
+        android:id="@+id/remote_view_2"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+    <Button
+        android:id="@+id/remote_view_3"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/request_focus.xml b/testapp/src/main/res/layout/request_focus.xml
new file mode 100644
index 0000000..03131ab
--- /dev/null
+++ b/testapp/src/main/res/layout/request_focus.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+  <EditText
+      android:id="@+id/edit_text"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+  <FrameLayout
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      >
+    <requestFocus/>
+    <!-- focus should be given to the FrameLayout, *not* the EditText -->
+  </FrameLayout>
+  <View
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+</LinearLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/request_focus_with_two_edit_texts.xml b/testapp/src/main/res/layout/request_focus_with_two_edit_texts.xml
new file mode 100644
index 0000000..fc4db8a
--- /dev/null
+++ b/testapp/src/main/res/layout/request_focus_with_two_edit_texts.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+  <EditText
+      android:id="@+id/edit_text"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+  <EditText
+      android:id="@+id/edit_text2"
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+  <View
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+</LinearLayout>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/snippet.xml b/testapp/src/main/res/layout/snippet.xml
new file mode 100644
index 0000000..1e517eb
--- /dev/null
+++ b/testapp/src/main/res/layout/snippet.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<TextView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/snippet_text"
+    android:layout_width="fill_parent"
+    android:layout_height="10dip"
+    android:visibility="gone"
+    />
diff --git a/testapp/src/main/res/layout/styles_button_layout.xml b/testapp/src/main/res/layout/styles_button_layout.xml
new file mode 100644
index 0000000..4dc1f9b
--- /dev/null
+++ b/testapp/src/main/res/layout/styles_button_layout.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Button xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@id/button"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    />
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/styles_button_with_style_layout.xml b/testapp/src/main/res/layout/styles_button_with_style_layout.xml
new file mode 100644
index 0000000..612d6cf
--- /dev/null
+++ b/testapp/src/main/res/layout/styles_button_with_style_layout.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Button xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@id/button"
+    style="@style/Sized"
+    />
diff --git a/testapp/src/main/res/layout/tab_activity.xml b/testapp/src/main/res/layout/tab_activity.xml
new file mode 100644
index 0000000..a2eb52a
--- /dev/null
+++ b/testapp/src/main/res/layout/tab_activity.xml
@@ -0,0 +1,26 @@
+<merge  xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/main"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent">
+
+  <TabHost
+      android:id="@android:id/tabhost"
+      android:layout_width="fill_parent"
+      android:layout_height="fill_parent">
+    <LinearLayout
+        android:orientation="vertical"
+        android:layout_width="fill_parent"
+        android:layout_height="fill_parent">
+      <TabWidget
+          android:id="@android:id/tabs"
+          android:layout_width="fill_parent"
+          android:layout_height="wrap_content"
+          android:background="@color/list_separator"/>
+      <FrameLayout
+          android:id="@android:id/tabcontent"
+          android:layout_width="fill_parent"
+          android:layout_height="fill_parent">
+      </FrameLayout>
+    </LinearLayout>
+  </TabHost>
+</merge>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/text_views.xml b/testapp/src/main/res/layout/text_views.xml
new file mode 100644
index 0000000..e7f20e4
--- /dev/null
+++ b/testapp/src/main/res/layout/text_views.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <TextView
+      android:id="@+id/black_text_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Black Text"
+      android:textColor="#000000"
+      />
+
+  <TextView
+      android:id="@+id/white_text_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="White Text"
+      android:textColor="@android:color/white"
+      />
+
+  <TextView
+      android:id="@+id/grey_text_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:text="Grey Text"
+      android:textColor="@color/grey42"
+      />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/text_views_hints.xml b/testapp/src/main/res/layout/text_views_hints.xml
new file mode 100644
index 0000000..95a183c
--- /dev/null
+++ b/testapp/src/main/res/layout/text_views_hints.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+  <TextView
+      android:id="@+id/black_text_view_hint"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:hint="Black Hint"
+      android:textColorHint="#000000"
+      />
+
+  <TextView
+      android:id="@+id/white_text_view_hint"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:hint="White Hint"
+      android:textColorHint="@android:color/white"
+      />
+
+  <TextView
+      android:id="@+id/grey_text_view_hint"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:hint="Grey Hint"
+      android:textColorHint="@color/grey42"
+      />
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/toplevel_merge.xml b/testapp/src/main/res/layout/toplevel_merge.xml
new file mode 100644
index 0000000..5d62c22
--- /dev/null
+++ b/testapp/src/main/res/layout/toplevel_merge.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<merge
+    android:id="@+id/main"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+
+  <View
+      android:layout_width="fill_parent"
+      android:layout_height="10dip"
+      />
+ </merge>
\ No newline at end of file
diff --git a/testapp/src/main/res/layout/webview_holder.xml b/testapp/src/main/res/layout/webview_holder.xml
new file mode 100644
index 0000000..85c41dc
--- /dev/null
+++ b/testapp/src/main/res/layout/webview_holder.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    >
+
+  <WebView
+      android:id="@+id/web_view"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      />
+
+</LinearLayout>
diff --git a/testapp/src/main/res/layout/with_invalid_onclick.xml b/testapp/src/main/res/layout/with_invalid_onclick.xml
new file mode 100644
index 0000000..2e16215
--- /dev/null
+++ b/testapp/src/main/res/layout/with_invalid_onclick.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Button
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/invalid_onclick_button"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:onClick="someInvalidMethod"
+    />
diff --git a/testapp/src/main/res/menu/action_menu.xml b/testapp/src/main/res/menu/action_menu.xml
new file mode 100644
index 0000000..b071173
--- /dev/null
+++ b/testapp/src/main/res/menu/action_menu.xml
@@ -0,0 +1,5 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:id="@+id/action_search"
+          android:actionViewClass="android.widget.SearchView"
+          android:showAsAction="always"/>
+</menu>
diff --git a/testapp/src/main/res/menu/test.xml b/testapp/src/main/res/menu/test.xml
new file mode 100644
index 0000000..4aed5f2
--- /dev/null
+++ b/testapp/src/main/res/menu/test.xml
@@ -0,0 +1,6 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:id="@+id/test_menu_1"
+    android:title="Test menu item 1" />
+  <item android:id="@+id/test_menu_2"
+    android:title="@string/test_menu_2" />
+</menu>
diff --git a/testapp/src/main/res/menu/test_withchilds.xml b/testapp/src/main/res/menu/test_withchilds.xml
new file mode 100644
index 0000000..e7b920c
--- /dev/null
+++ b/testapp/src/main/res/menu/test_withchilds.xml
@@ -0,0 +1,14 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:id="@+id/test_menu_1" android:title="Test menu item 1">
+  </item>
+  <group android:id="@+id/group_id_1">
+    <item android:id="@+id/test_menu_2" android:title="Test menu item 2" />
+    <item android:id="@+id/test_menu_3" android:title="Test menu item 3" />
+  </group>
+  <item android:id="@+id/test_submenu_1">
+    <menu>
+      <item android:id="@+id/test_menu_2" android:title="Test menu item 2" />
+      <item android:id="@+id/test_menu_3" android:title="Test menu item 3" />
+    </menu>
+  </item>
+</menu>
diff --git a/testapp/src/main/res/menu/test_withorder.xml b/testapp/src/main/res/menu/test_withorder.xml
new file mode 100644
index 0000000..d73a158
--- /dev/null
+++ b/testapp/src/main/res/menu/test_withorder.xml
@@ -0,0 +1,8 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+  <item android:id="@+id/test_menu_2"
+        android:orderInCategory="2"
+        android:title="Test menu item 2"/>
+  <item android:id="@+id/test_menu_1"
+        android:orderInCategory="1"
+        android:title="Test menu item 1"/>
+</menu>
diff --git a/testapp/src/main/res/mipmap-v26/robolectric_xml.xml b/testapp/src/main/res/mipmap-v26/robolectric_xml.xml
new file mode 100644
index 0000000..7d0b17e
--- /dev/null
+++ b/testapp/src/main/res/mipmap-v26/robolectric_xml.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+  <background>
+    <bitmap android:src="@mipmap/robolectric"/>
+  </background>
+  <foreground>
+    <bitmap android:src="@mipmap/robolectric"/>
+  </foreground>
+</adaptive-icon>
diff --git a/testapp/src/main/res/mipmap/robolectric.png b/testapp/src/main/res/mipmap/robolectric.png
new file mode 100644
index 0000000..ea52306
--- /dev/null
+++ b/testapp/src/main/res/mipmap/robolectric.png
Binary files differ
diff --git a/testapp/src/main/res/raw/raw_no_ext b/testapp/src/main/res/raw/raw_no_ext
new file mode 100644
index 0000000..bfc4c5f
--- /dev/null
+++ b/testapp/src/main/res/raw/raw_no_ext
@@ -0,0 +1 @@
+no ext file contents
\ No newline at end of file
diff --git a/testapp/src/main/res/raw/raw_resource.txt b/testapp/src/main/res/raw/raw_resource.txt
new file mode 100644
index 0000000..6f46228
--- /dev/null
+++ b/testapp/src/main/res/raw/raw_resource.txt
@@ -0,0 +1 @@
+raw txt file contents
\ No newline at end of file
diff --git a/testapp/src/main/res/values-b+sr+Latn/values.xml b/testapp/src/main/res/values-b+sr+Latn/values.xml
new file mode 100644
index 0000000..f8b057e
--- /dev/null
+++ b/testapp/src/main/res/values-b+sr+Latn/values.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+  <string name="howdy">Kako si</string>
+  <string name="hello">Zdravo</string>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values-de/strings.xml b/testapp/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..1e38290
--- /dev/null
+++ b/testapp/src/main/res/values-de/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="minute_singular">Minute</string>
+  <string name="minute_plural">Minuten</string>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values-fr/strings.xml b/testapp/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000..279987b
--- /dev/null
+++ b/testapp/src/main/res/values-fr/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="hello">Bonjour</string>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values-sw600dp-v14/booleans.xml b/testapp/src/main/res/values-sw600dp-v14/booleans.xml
new file mode 100644
index 0000000..97e9722
--- /dev/null
+++ b/testapp/src/main/res/values-sw600dp-v14/booleans.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+	<bool name="different_resource_boolean">true</bool>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values-w320dp/bools.xml b/testapp/src/main/res/values-w320dp/bools.xml
new file mode 100644
index 0000000..c16ae30
--- /dev/null
+++ b/testapp/src/main/res/values-w320dp/bools.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="value_only_present_in_w320dp">true</bool>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values-w820dp/bools.xml b/testapp/src/main/res/values-w820dp/bools.xml
new file mode 100644
index 0000000..cf947fe
--- /dev/null
+++ b/testapp/src/main/res/values-w820dp/bools.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="different_resource_boolean">true</bool>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values-w820dp/layout.xml b/testapp/src/main/res/values-w820dp/layout.xml
new file mode 100644
index 0000000..eb73406
--- /dev/null
+++ b/testapp/src/main/res/values-w820dp/layout.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="main_layout" type="layout">@layout/activity_main_1</item>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/attrs.xml b/testapp/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..fa449cb
--- /dev/null
+++ b/testapp/src/main/res/values/attrs.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <declare-styleable name="CustomView">
+    <attr name="multiformat" format="integer|string|boolean"/>
+
+    <attr name="itemType" format="enum">
+      <enum name="marsupial" value="0"/>
+      <enum name="ungulate" value="1"/>
+    </attr>
+    <attr name="message" format="string"/>
+
+    <attr name="scrollBars">
+      <flag name="horizontal" value="0x00000100" />
+      <flag name="vertical" value="0x00000200" />
+      <flag name="sideways" value="0x00000400" />
+    </attr>
+
+    <attr name="quitKeyCombo" format="string"/>
+
+    <attr name="numColumns" format="integer" min="0">
+      <!-- Display as many columns as possible to fill the available space. -->
+      <enum name="auto_fit" value="-1" />
+    </attr>
+
+    <attr name="sugarinessPercent" format="integer" min="0"/>
+
+    <attr name="gravity"/>
+
+    <attr name="keycode"/>
+
+    <attr name="aspectRatio" format="float" />
+    <attr name="aspectRatioEnabled" format="boolean" />
+    <attr name="animalStyle" format="reference" />
+
+    <!-- Test the same attr name as android namespace with different format -->
+    <attr name="typeface" format="string" />
+
+    <attr name="someLayoutOne" format="reference" />
+    <attr name="someLayoutTwo" format="reference" />
+
+    <attr name="bar" format="reference" />
+  </declare-styleable>
+
+  <attr name="gravity">
+    <flag name="center" value="0x11" />
+    <flag name="fill_vertical" value="0x70" />
+  </attr>
+
+  <attr name="keycode">
+    <enum name="KEYCODE_SOFT_RIGHT" value="2" />
+    <enum name="KEYCODE_HOME" value="3" />
+  </attr>
+
+  <attr name="responses" format="reference"/>
+  <attr name="string1" format="string"/>
+  <attr name="string2" format="string"/>
+  <attr name="string3" format="string"/>
+  <attr name="parentStyleReference" format="reference"/>
+  <attr name="styleNotSpecifiedInAnyTheme" format="reference"/>
+  <attr name="title" format="string"/>
+
+  <declare-styleable name="CustomStateView">
+    <attr name="stateFoo" format="boolean" />
+  </declare-styleable>
+
+  <declare-styleable name="Theme.AnotherTheme.Attributes">
+    <attr name="averageSheepWidth" format="reference"/>
+    <attr name="isSugary" format="reference"/>
+    <attr name="logoHeight" format="reference"/>
+    <attr name="logoWidth" format="reference"/>
+    <attr name="styleReference" format="reference"/>
+    <attr name="styleReferenceWithoutExplicitType" format="reference"/>
+    <attr name="snail" format="reference"/>
+  </declare-styleable>
+</resources>
diff --git a/testapp/src/main/res/values/bools.xml b/testapp/src/main/res/values/bools.xml
new file mode 100644
index 0000000..7198dfb
--- /dev/null
+++ b/testapp/src/main/res/values/bools.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="false_bool_value">false</bool>
+  <bool name="true_bool_value">true</bool>
+  <bool name="reference_to_true">@bool/true_bool_value</bool>
+  <item name="true_as_item" type="bool">true</item>
+  <bool name="different_resource_boolean">false</bool>
+  <bool name="typed_array_true">true</bool>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/colors.xml b/testapp/src/main/res/values/colors.xml
new file mode 100644
index 0000000..bb4aea0
--- /dev/null
+++ b/testapp/src/main/res/values/colors.xml
@@ -0,0 +1,25 @@
+<resources>
+  <color name="foreground">@color/grey42</color>
+
+  <color name="clear">#00000001</color>
+  <color name="white">#FFFFFF</color>
+  <color name="black">#000000</color>
+  <color name="blue">#0000ff</color>
+  <color name="grey42">#f5f5f5</color>
+  <color name="color_with_alpha">#802C76AD</color>
+
+  <color name="background">@color/grey42</color>
+
+  <color name="android_namespaced_black">@android:color/black</color>
+
+  <color name="android_namespaced_transparent">@android:color/transparent</color>
+
+  <color name="list_separator">#111111</color>
+
+  <color name="typed_array_orange">#FF5C00</color>
+
+  <color name="test_ARGB4">#0001</color>
+  <color name="test_ARGB8">#00000002</color>
+  <color name="test_RGB4">#00f</color>
+  <color name="test_RGB8">#000004</color>
+</resources>
diff --git a/testapp/src/main/res/values/dimens.xml b/testapp/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..b6e2f95
--- /dev/null
+++ b/testapp/src/main/res/values/dimens.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <dimen name="test_dp_dimen">8dp</dimen>
+  <dimen name="test_dip_dimen">20dip</dimen>
+  <dimen name="test_pt_dimen">12pt</dimen>
+  <dimen name="test_px_dimen">15px</dimen>
+  <dimen name="test_sp_dimen">5sp</dimen>
+  <dimen name="test_mm_dimen">42mm</dimen>
+  <dimen name="test_in_dimen">99in</dimen>
+
+  <dimen name="ref_to_px_dimen">@dimen/test_px_dimen</dimen>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/fractions.xml b/testapp/src/main/res/values/fractions.xml
new file mode 100644
index 0000000..65e3ea4
--- /dev/null
+++ b/testapp/src/main/res/values/fractions.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <fraction name="half">50%</fraction>
+    <fraction name="half_of_parent">50%p</fraction>
+    <item name="quarter_as_item" type="fraction">25%</item>
+    <item name="quarter_of_parent_as_item" type="fraction">25%p</item>
+    <fraction name="fifth">20%</fraction>
+    <fraction name="fifth_as_reference">@fraction/fifth</fraction>
+    <fraction name="fifth_of_parent">20%p</fraction>
+    <fraction name="fifth_of_parent_as_reference">@fraction/fifth_of_parent</fraction>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/ids.xml b/testapp/src/main/res/values/ids.xml
new file mode 100644
index 0000000..3975c1c
--- /dev/null
+++ b/testapp/src/main/res/values/ids.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <item name="id_declared_in_item_tag" type="id"/>
+  <item name="id_with_string_value" type="id"/>
+</resources>
diff --git a/testapp/src/main/res/values/int_arrays.xml b/testapp/src/main/res/values/int_arrays.xml
new file mode 100644
index 0000000..f4d5ba0
--- /dev/null
+++ b/testapp/src/main/res/values/int_arrays.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+   <integer-array name="zero_to_four_int_array">
+      <item>0</item>
+      <item>1</item>
+      <item>2</item>
+      <item>3</item>
+      <item>4</item>
+   </integer-array>
+   <integer-array name="empty_int_array" />
+   <integer-array name="with_references_int_array">
+      <item>0</item>
+      <item>@integer/test_integer1</item>
+      <item>1</item>
+   </integer-array>
+   <integer-array name="referenced_colors_int_array">
+      <item>@color/clear</item>
+      <item>@color/white</item>
+      <item>@color/black</item>
+      <item>@color/grey42</item>
+      <item>@color/color_with_alpha</item>
+   </integer-array>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/integer.xml b/testapp/src/main/res/values/integer.xml
new file mode 100644
index 0000000..5318c1c
--- /dev/null
+++ b/testapp/src/main/res/values/integer.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<resources>
+  <string name="test_non_integer">This is not an integer</string>
+  <integer name="test_integer1">2000</integer>
+  <integer name="test_integer2">9</integer>
+  <integer name="test_large_hex">0xFFFF0000</integer>
+  <integer name="test_value_with_zero">07210</integer>
+  <integer name="scrollbar_style_ordinal_outside_overlay">0x02000000</integer>
+  <integer name="typed_array_5">5</integer>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/integers.xml b/testapp/src/main/res/values/integers.xml
new file mode 100644
index 0000000..26ef9d5
--- /dev/null
+++ b/testapp/src/main/res/values/integers.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <integer name="meaning_of_life">42</integer>
+  <integer name="loneliest_number">1</integer>
+  <integer name="there_can_be_only">@integer/loneliest_number</integer>
+  <integer name="hex_int">0xFFFF0000</integer>
+  <integer name="reference_to_meaning_of_life">@integer/meaning_of_life_as_item</integer>
+  <item name="meaning_of_life_as_item" type="integer">42</item>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/layout.xml b/testapp/src/main/res/values/layout.xml
new file mode 100644
index 0000000..35cfbb7
--- /dev/null
+++ b/testapp/src/main/res/values/layout.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="main_layout" type="layout">@layout/activity_main</item>
+    <item name="multiline_layout" type="layout">
+        @layout/activity_main
+    </item>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/mipmaps.xml b/testapp/src/main/res/values/mipmaps.xml
new file mode 100644
index 0000000..f1289d1
--- /dev/null
+++ b/testapp/src/main/res/values/mipmaps.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <item name="mipmap_reference" type="mipmap">@mipmap/robolectric</item>
+  <item name="mipmap_reference_xml" type="mipmap">@mipmap/robolectric_xml</item>
+</resources>
diff --git a/testapp/src/main/res/values/plurals.xml b/testapp/src/main/res/values/plurals.xml
new file mode 100644
index 0000000..823968a
--- /dev/null
+++ b/testapp/src/main/res/values/plurals.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <plurals name="beer">
+    <!--
+    In us-en locale, only one and other plurals are used because there are only two possible
+    variants: singular vs plural tense.
+    -->
+    <item quantity="one">a beer</item>
+    <item quantity="other">some beers</item>
+  </plurals>
+  <plurals name="minute">
+    <item quantity="one">@string/minute_singular</item>
+    <item quantity="other">@string/minute_plural</item>
+  </plurals>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/refs.xml b/testapp/src/main/res/values/refs.xml
new file mode 100644
index 0000000..a9a5f2a
--- /dev/null
+++ b/testapp/src/main/res/values/refs.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="example_item_drawable" type="drawable">@drawable/an_image</item>
+</resources>
diff --git a/testapp/src/main/res/values/string_arrays.xml b/testapp/src/main/res/values/string_arrays.xml
new file mode 100644
index 0000000..2d77f1c
--- /dev/null
+++ b/testapp/src/main/res/values/string_arrays.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string-array name="more_items">
+    <item>baz</item>
+    <item>bang</item>
+  </string-array>
+
+  <string-array name="greetings">
+    <item>hola</item>
+    <item>@string/hello</item>
+  </string-array>
+
+   <string-array name="alertDialogTestItems">
+    <item>Aloha</item>
+    <item>Hawai</item>
+  </string-array>
+
+  <string-array name="emailAddressTypes">
+    <item>Doggy</item>
+    <item>Catty</item>
+  </string-array>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/values/strings.xml b/testapp/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f1d7df9
--- /dev/null
+++ b/testapp/src/main/res/values/strings.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+  <string name="greeting">@string/howdy</string>
+  <string name="howdy">Howdy</string>
+  <string name="hello">Hello</string>
+  <string name="some_html"><b>Hello, <i>world</i></b></string>
+  <string-array name="items">
+    <item>foo</item>
+    <item>bar</item>
+  </string-array>
+  <string name="copy">Local Copy</string>
+  <string name="not_in_the_r_file">Proguarded Out Probably</string>
+  <string name="only_in_main">from main</string>
+  <string name="in_all_libs">from main</string>
+  <string name="in_main_and_lib1">from main</string>
+  <string name="ok">@string/ok2</string>
+  <string name="ok2">ok yup!</string>
+  <string name="interpolate">Here is a %s!</string>
+  <string name="surrounding_quotes">"This'll work"</string>
+  <string name="escaped_apostrophe">This\'ll also work</string>
+  <string name="escaped_quotes">Click \"OK\"</string>
+  <string name="leading_and_trailing_new_lines">
+    Some text
+  </string>
+  <string name="new_lines_and_tabs">
+    <xliff:g id="minutes" example="3 min">%1$s</xliff:g>\tmph\nfaster
+  </string>
+  <string name="non_breaking_space">Closing soon:\u00A05pm</string>
+  <string name="space">Closing soon:\u00205pm</string>
+  <string name="app_name">Testing App</string>
+  <string name="activity_name">Testing App Activity</string>
+  <string name="minute_singular">minute</string>
+  <string name="minute_plural">minutes</string>
+  <string name="str_int">123456</string>
+  <string name="preference_resource_key">preference_resource_key_value</string>
+  <string name="preference_resource_title">preference_resource_title_value</string>
+  <string name="preference_resource_summary">preference_resource_summary_value</string>
+  <string name="preference_resource_default_value">preference_resource_default_value</string>
+  <string name="test_menu_2">Test menu item 2</string>
+  <item name="say_it_with_item" type="string">flowers</item>
+  <string name="typed_array_a">apple</string>
+  <string name="typed_array_b">banana</string>
+  <string name="test_permission_description">permission string</string>
+  <string name="test_permission_label">permission label</string>
+  <string name="string_with_spaces">
+    Up to <xliff:g id="upper_limit" example="12">%1$s</xliff:g> <xliff:g id="space"> </xliff:g> <xliff:g id="currency" example="USD">%2$s</xliff:g>
+  </string>
+
+  <string name="internal_whitespace_blocks">Whitespace     in     the          middle</string>
+  <string name="internal_newlines">Some
+
+
+  Newlines
+  </string>
+  <string name="font_tag_with_attribute">This string <font size="16">has a font tag</font></string>
+  <string name="link_tag_with_attribute">This string <a href="http://robolectric.org">has a link tag</a></string>
+
+  <!--
+    Resources to validate examples from https://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling
+  -->
+  <string name="bad_example">This is a "bad string".</string>
+  <!-- Quotes are stripped; displays as: This is a bad string. -->
+</resources>
diff --git a/testapp/src/main/res/values/themes.xml b/testapp/src/main/res/values/themes.xml
new file mode 100644
index 0000000..1ff94bc
--- /dev/null
+++ b/testapp/src/main/res/values/themes.xml
@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <style name="Theme.Robolectric" parent="@android:style/Theme">
+    <item name="android:buttonStyle">@style/Widget.Robolectric.Button</item>
+
+    <item name="string1">string 1 from Theme.Robolectric</item>
+    <item name="string3">string 3 from Theme.Robolectric</item>
+  </style>
+
+  <style name="Theme.Robolectric.ImplicitChild">
+    <item name="string2">string 2 from Theme.Robolectric.ImplicitChild</item>
+    <item name="string3">string 3 from Theme.Robolectric.ImplicitChild</item>
+  </style>
+
+  <style name="Theme.Robolectric.EmptyParent" parent=""/>
+
+  <style name="Theme.AnotherTheme" parent="@style/Theme.Robolectric">
+    <item name="android:buttonStyle">@style/Widget.AnotherTheme.Button</item>
+    <item name="logoWidth">?attr/averageSheepWidth</item>
+    <item name="logoHeight">@dimen/test_dp_dimen</item>
+    <item name="averageSheepWidth">@dimen/test_dp_dimen</item>
+    <item name="animalStyle">@style/Gastropod</item>
+    <item name="isSugary">?attr/isSugary</item>
+    <item name="styleReference">?android:attr/buttonStyle</item>
+    <item name="typeface">custom_font</item>
+    <item name="string1">string 1 from Theme.AnotherTheme</item>
+    <item name="string2">string 2 from Theme.AnotherTheme</item>
+  </style>
+
+  <style name="Theme.ThirdTheme" parent="@style/Theme.Robolectric">
+    <item name="snail">@style/Gastropod</item>
+    <item name="animalStyle">?attr/snail</item>
+    <item name="someLayoutOne">@layout/activity_main</item>
+    <item name="someLayoutTwo">?someLayoutOne</item>
+  </style>
+
+  <style name="Theme">
+  </style>
+
+  <style name="Theme.ThemeReferredToByParentAttrReference">
+    <item name="parentStyleReference">@style/StyleReferredToByParentAttrReference</item>
+  </style>
+
+  <style name="Theme.ThemeContainingStyleReferences" parent="">
+    <item name="styleReference">@style/StyleReferredToByParentAttrReference</item>
+    <item name="styleReferenceWithoutExplicitType">@style/StyleReferredToByParentAttrReference</item>
+  </style>
+
+  <style name="StyleReferredToByParentAttrReference">
+    <item name="string2">string 2 from StyleReferredToByParentAttrReference</item>
+  </style>
+
+  <style name="Theme.ThemeWithAttrReferenceAsParent" parent="@style/StyleReferredToByParentAttrReference">
+    <item name="string1">string 1 from Theme.ThemeWithAttrReferenceAsParent</item>
+  </style>
+
+  <style name="Widget.Robolectric.Button" parent="@android:style/Widget.Button">
+    <item name="android:background">#ff00ff00</item>
+  </style>
+
+  <style name="Widget.AnotherTheme.Button" parent="@android:style/Widget.Button">
+    <item name="android:background">#ffff0000</item>
+    <item name="android:minWidth">?attr/logoWidth</item>
+    <item name="android:minHeight">?attr/logoHeight</item>
+  </style>
+
+  <style name="Widget.AnotherTheme.Button.Blarf"/>
+
+  <style name="MyCustomView">
+    <item name="aspectRatioEnabled">true</item>
+  </style>
+
+  <style name="SomeStyleable">
+    <item name="snail">@style/Gastropod</item>
+    <item name="animalStyle">@style/Gastropod</item>
+  </style>
+
+  <style name="Sized">
+    <item name="android:layout_width">42px</item>
+    <item name="android:layout_height">42px</item>
+  </style>
+
+  <style name="Gastropod">
+      <item name="aspectRatio">1.69</item>
+  </style>
+
+  <style name="MyBlackTheme">
+    <item name="android:windowBackground">@android:color/black</item>
+    <item name="android:textColorHint">@android:color/darker_gray</item>
+  </style>
+
+  <style name="MyBlueTheme">
+    <item name="android:windowBackground">@color/blue</item>
+    <item name="android:textColor">@color/white</item>
+  </style>
+
+  <style name="ThemeWithSelfReferencingTextAttr">
+    <!-- android's Widget style (among others) does this, wtf? -->
+    <item name="android:textAppearance">?android:attr/textAppearance</item>
+  </style>
+
+  <style name="IndirectButtonStyle" parent="@android:style/Widget.Button">
+    <item name="android:minHeight">12dp</item>
+  </style>
+</resources>
diff --git a/testapp/src/main/res/values/typed_arrays.xml b/testapp/src/main/res/values/typed_arrays.xml
new file mode 100644
index 0000000..7060a9f
--- /dev/null
+++ b/testapp/src/main/res/values/typed_arrays.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <array name="typed_array_values">
+        <item>abcdefg</item>
+        <item>3875</item>
+        <item>2.0</item>
+        <item>#ffff00ff</item>
+        <item>#00ffff</item>
+        <item>8px</item>
+        <item>12dp</item>
+        <item>6dip</item>
+        <item>3mm</item>
+        <item>4in</item>
+        <item>36sp</item>
+        <item>18pt</item>
+    </array>
+
+    <array name="typed_array_references">
+        <item>@string/typed_array_a</item>
+        <item>@string/typed_array_b</item>
+        <item>@integer/typed_array_5</item>
+        <item>@bool/typed_array_true</item>
+        <item>@null</item>
+        <item>@drawable/an_image</item>
+        <item>@color/typed_array_orange</item>
+        <item>?attr/animalStyle</item>
+        <item>@array/string_array_values</item>
+        <item>@style/Theme.Robolectric</item>
+    </array>
+
+    <array name="typed_array_with_resource_id">
+        <item>@id/id_declared_in_item_tag</item>
+        <item>@id/id_declared_in_layout</item>
+    </array>
+
+    <string-array name="string_array_values">
+        <item>abcdefg</item>
+        <item>3875</item>
+        <item>2.0</item>
+        <item>#ffff00ff</item>
+        <item>#00ffff</item>
+        <item>8px</item>
+        <item>12dp</item>
+        <item>6dip</item>
+        <item>3mm</item>
+        <item>4in</item>
+        <item>36sp</item>
+        <item>18pt</item>
+    </string-array>
+</resources>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml-v25/shortcuts.xml b/testapp/src/main/res/xml-v25/shortcuts.xml
new file mode 100644
index 0000000..b5c0c5a
--- /dev/null
+++ b/testapp/src/main/res/xml-v25/shortcuts.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shortcuts xmlns:android="http://schemas.android.com/apk/res/android" >
+
+</shortcuts>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/app_restrictions.xml b/testapp/src/main/res/xml/app_restrictions.xml
new file mode 100644
index 0000000..1f131f2
--- /dev/null
+++ b/testapp/src/main/res/xml/app_restrictions.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
+  <restriction
+      android:key="restrictionKey"
+      android:title="@string/ok"
+      android:restrictionType="bool"
+      android:defaultValue="false" />
+</restrictions>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/dialog_preferences.xml b/testapp/src/main/res/xml/dialog_preferences.xml
new file mode 100644
index 0000000..87e3e02
--- /dev/null
+++ b/testapp/src/main/res/xml/dialog_preferences.xml
@@ -0,0 +1,12 @@
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+  <org.robolectric.shadows.testing.TestDialogPreference
+      android:key="dialog"
+      android:title="Dialog Preference"
+      android:summary="This is the dialog summary"
+      android:dialogMessage="This is the dialog message"
+      android:positiveButtonText="YES"
+      android:negativeButtonText="NO"/>
+
+</PreferenceScreen>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/has_attribute_resource_value.xml b/testapp/src/main/res/xml/has_attribute_resource_value.xml
new file mode 100644
index 0000000..01b4142
--- /dev/null
+++ b/testapp/src/main/res/xml/has_attribute_resource_value.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo xmlns:app="http://schemas.android.com/apk/res-auto" app:bar="@layout/main"/>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/has_emoji.xml b/testapp/src/main/res/xml/has_emoji.xml
new file mode 100644
index 0000000..b08c21f
--- /dev/null
+++ b/testapp/src/main/res/xml/has_emoji.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- The emoji unicode is \uD83D\uDE00 -->
+<EmojiRoot
+    label1="no emoji"
+    label2="&#128512;"
+    label3="😀"
+    label4="😀internal1😀internal2😀"
+    label5="&#128512;internal1&#128512;internal2&#128512;"
+    label6="don't worry be 😀">
+</EmojiRoot>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/has_id.xml b/testapp/src/main/res/xml/has_id.xml
new file mode 100644
index 0000000..0cd5a26
--- /dev/null
+++ b/testapp/src/main/res/xml/has_id.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo id="@+id/tacos"/>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/has_parent_style_reference.xml b/testapp/src/main/res/xml/has_parent_style_reference.xml
new file mode 100644
index 0000000..0f9172c
--- /dev/null
+++ b/testapp/src/main/res/xml/has_parent_style_reference.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo style="?parentStyleReference"/>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/has_style_attribute_reference.xml b/testapp/src/main/res/xml/has_style_attribute_reference.xml
new file mode 100644
index 0000000..b4bd862
--- /dev/null
+++ b/testapp/src/main/res/xml/has_style_attribute_reference.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<foo style="?attr/parentStyleReference"/>
\ No newline at end of file
diff --git a/testapp/src/main/res/xml/preferences.xml b/testapp/src/main/res/xml/preferences.xml
new file mode 100644
index 0000000..1192279
--- /dev/null
+++ b/testapp/src/main/res/xml/preferences.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+  <PreferenceCategory
+      android:key="category"
+      android:title="Category Test">
+
+    <Preference
+        android:key="inside_category"
+        android:title="Inside Category Test"
+        android:summary=""/>
+
+  </PreferenceCategory>
+
+  <PreferenceScreen
+      android:key="screen"
+      android:title="Screen Test"
+      android:summary="Screen summary">
+
+    <Preference
+        android:key="inside_screen"
+        android:title="Inside Screen Test"
+        android:summary=""/>
+
+    <Preference
+        android:key="inside_screen_dependent"
+        android:title="Inside Screen Dependent Test"
+        android:dependency="inside_screen"
+        android:summary=""/>
+
+  </PreferenceScreen>
+
+  <CheckBoxPreference
+      android:key="checkbox"
+      android:title="Checkbox Test"
+      android:summary=""
+      android:defaultValue="true"/>
+
+  <EditTextPreference
+      android:key="edit_text"
+      android:title="EditText Test"
+      android:summary=""/>
+
+  <ListPreference
+      android:key="list"
+      android:title="List Test"
+      android:summary=""/>
+
+  <Preference
+      android:key="preference"
+      android:title="Preference Title"
+      android:summary=""/>
+
+  <RingtonePreference
+      android:key="ringtone"
+      android:title="Ringtone Test"
+      android:summary=""/>
+
+  <Preference
+      android:key="@string/preference_resource_key"
+      android:title="@string/preference_resource_title"
+      android:summary="@string/preference_resource_summary"/>
+
+  <Preference
+      android:key="dependant"
+      android:title="This preference is dependant on something else"
+      android:summary="Still depending on the preference above"
+      android:dependency="preference"/>
+
+  <Preference
+      android:key="intent"
+      android:title="Intent test"
+      android:summary="">
+
+    <intent
+        android:targetPackage="org.robolectric"
+        android:targetClass="org.robolectric.test.Intent"
+        android:mimeType="application/text"
+        android:action="action"
+        android:data="tel://1235"/>
+  </Preference>
+
+</PreferenceScreen>
diff --git a/testapp/src/main/res/xml/xml_attrs.xml b/testapp/src/main/res/xml/xml_attrs.xml
new file mode 100644
index 0000000..c35de47
--- /dev/null
+++ b/testapp/src/main/res/xml/xml_attrs.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<whatever xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:width="1234px"
+    android:height="@android:dimen/app_icon_size"
+    android:scrollbarFadeDuration="1111"
+    android:title="Android Title"
+    app:title="App Title"
+    android:id="@android:id/text1"
+    style="@android:style/TextAppearance.Small"
+    class="none"
+    id="@android:id/text2"
+    />
\ No newline at end of file
diff --git a/utils/build.gradle b/utils/build.gradle
new file mode 100644
index 0000000..4bd030f
--- /dev/null
+++ b/utils/build.gradle
@@ -0,0 +1,69 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: 'kotlin'
+apply plugin: DeployedRoboJavaModulePlugin
+apply plugin: "com.diffplug.spotless"
+
+spotless {
+    kotlin {
+        target '**/*.kt'
+        ktfmt('0.34').googleStyle()
+    }
+}
+
+tasks.withType(GenerateModuleMetadata) {
+    // We don't want to release gradle module metadata now to avoid
+    // potential compatibility problems.
+    enabled = false
+}
+
+compileKotlin {
+    // Use java/main classes directory to replace default kotlin/main to
+    // avoid d8 error when dexing & desugaring kotlin classes with non-exist
+    // kotlin/main directory because utils module doesn't have kotlin code
+    // in production. If utils module starts to add Kotlin code in main source
+    // set, we can remove this destinationDirectory modification.
+    destinationDirectory = file("${projectDir}/build/classes/java/main")
+    kotlinOptions {
+        jvmTarget = "1.8"
+    }
+}
+
+afterEvaluate {
+    configurations {
+        runtimeElements {
+            attributes {
+                // We should add artifactType with jar to ensure standard runtimeElements variant
+                // has a max priority selection sequence than other variants that brought by
+                // kotlin plugin.
+                attribute(
+                        Attribute.of("artifactType", String.class),
+                        ArtifactTypeDefinition.JAR_TYPE
+                )
+            }
+        }
+    }
+}
+
+dependencies {
+    api project(":annotations")
+    api project(":pluginapi")
+    api "javax.inject:javax.inject:1"
+    api "javax.annotation:javax.annotation-api:1.3.2"
+
+    // For @VisibleForTesting and ByteStreams
+    implementation "com.google.guava:guava:$guavaJREVersion"
+    compileOnly "com.google.code.findbugs:jsr305:3.0.2"
+
+    testCompileOnly "com.google.auto.service:auto-service-annotations:$autoServiceVersion"
+    testAnnotationProcessor "com.google.auto.service:auto-service:$autoServiceVersion"
+    testAnnotationProcessor "com.google.errorprone:error_prone_core:$errorproneVersion"
+    implementation "com.google.errorprone:error_prone_annotations:$errorproneVersion"
+
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+    testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+    testImplementation "org.mockito:mockito-core:${mockitoVersion}"
+}
diff --git a/utils/reflector/build.gradle b/utils/reflector/build.gradle
new file mode 100644
index 0000000..3027345
--- /dev/null
+++ b/utils/reflector/build.gradle
@@ -0,0 +1,16 @@
+import org.robolectric.gradle.DeployedRoboJavaModulePlugin
+import org.robolectric.gradle.RoboJavaModulePlugin
+
+apply plugin: RoboJavaModulePlugin
+apply plugin: DeployedRoboJavaModulePlugin
+
+dependencies {
+    api "org.ow2.asm:asm:${asmVersion}"
+    api "org.ow2.asm:asm-commons:${asmVersion}"
+    api "org.ow2.asm:asm-util:${asmVersion}"
+    api project(":utils")
+
+    testImplementation project(":shadowapi")
+    testImplementation "junit:junit:${junitVersion}"
+    testImplementation "com.google.truth:truth:${truthVersion}"
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/Accessor.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/Accessor.java
new file mode 100644
index 0000000..80c9ec2
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/Accessor.java
@@ -0,0 +1,16 @@
+package org.robolectric.util.reflector;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that the annotated method is an accessor for a non-visible field.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Accessor {
+  /** The name of the field. */
+  String value();
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/Direct.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/Direct.java
new file mode 100644
index 0000000..2ea161e
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/Direct.java
@@ -0,0 +1,14 @@
+package org.robolectric.util.reflector;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that the annotated method should use the original $$robo$$-prefixed implementation so
+ * that it can be invoked in the shadow for the method.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Direct {}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/ForType.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/ForType.java
new file mode 100644
index 0000000..dc75fd2
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/ForType.java
@@ -0,0 +1,20 @@
+package org.robolectric.util.reflector;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that the annotated interface is an accessor object for use by {@link Reflector}.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ForType {
+
+  Class<?> value() default void.class;
+
+  String className() default "";
+
+  boolean direct() default false;
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java
new file mode 100644
index 0000000..2873e28
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java
@@ -0,0 +1,159 @@
+package org.robolectric.util.reflector;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.robolectric.util.PerfStatsCollector;
+
+/**
+ * Provides accessor objects for efficiently calling otherwise inaccessible (non-public) methods.
+ *
+ * <p>This is orders of magnitude faster than using reflection directly.
+ *
+ * <p>For example, to access a private method on some class {@code Foo}, declare an accessor
+ * interface:
+ *
+ * <pre>
+ * class Foo {
+ *   private String getName() { ... }
+ * }
+ *
+ * &#064;ForType(Foo.class)
+ * interface _Foo_ {
+ *   String getName();
+ * }
+ *
+ * reflector(_Foo_.class, new Foo()).getName();
+ * </pre>
+ */
+@SuppressWarnings("NewApi")
+public class Reflector {
+
+  private static final boolean DEBUG = false;
+  private static final AtomicInteger COUNTER = new AtomicInteger();
+  private static final Map<Class<?>, Constructor<?>> cache = new ConcurrentHashMap<>();
+  /**
+   * Returns an object which provides accessors for invoking otherwise inaccessible static methods
+   * and fields.
+   *
+   * @param iClass an interface with methods matching private methods on the target
+   */
+  public static <T> T reflector(Class<T> iClass) {
+    return reflector(iClass, null);
+  }
+
+  /**
+   * Returns an object which provides accessors for invoking otherwise inaccessible methods and
+   * fields.
+   *
+   * @param iClass an interface with methods matching private methods on the target
+   * @param target the target object
+   */
+  public static <T> T reflector(Class<T> iClass, Object target) {
+    Class<?> targetClass = determineTargetClass(iClass);
+
+    Constructor<? extends T> ctor = (Constructor<? extends T>) cache.get(iClass);
+    try {
+      if (ctor == null) {
+        Class<? extends T> reflectorClass =
+            PerfStatsCollector.getInstance()
+                .measure(
+                    "createReflectorClass",
+                    () -> Reflector.<T>createReflectorClass(iClass, targetClass));
+        ctor = reflectorClass.getConstructor(targetClass);
+        ctor.setAccessible(true);
+      }
+
+      cache.put(iClass, ctor);
+
+      return ctor.newInstance(target);
+    } catch (NoSuchMethodException
+        | InstantiationException
+        | IllegalAccessException
+        | InvocationTargetException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private static <T> Class<?> determineTargetClass(Class<T> iClass) {
+    ForType forType = iClass.getAnnotation(ForType.class);
+    if (forType == null) {
+      throw new IllegalArgumentException("no @ForType annotation found for " + iClass);
+    }
+    Class<?> targetClass = forType.value();
+    if (targetClass.equals(void.class)) {
+      String forClassName = forType.className();
+      if (forClassName.isEmpty()) {
+        throw new IllegalArgumentException(
+            "no value or className given for @ForType for " + iClass);
+      }
+
+      try {
+        targetClass = Class.forName(forClassName, false, iClass.getClassLoader());
+      } catch (ClassNotFoundException e) {
+        throw new IllegalArgumentException("failed to resolve @ForType class for " + iClass, e);
+      }
+    }
+    return targetClass;
+  }
+
+  private static <T> Class<? extends T> createReflectorClass(
+      Class<T> iClass, Class<?> targetClass) {
+    String reflectorClassName = iClass.getName() + "$$Reflector" + COUNTER.getAndIncrement();
+    byte[] bytecode = getBytecode(iClass, targetClass, reflectorClassName);
+
+    if (DEBUG) {
+      File file = new File("/tmp", reflectorClassName + ".class");
+      System.out.println("Generated reflector: " + file.getAbsolutePath());
+      try (OutputStream out = new FileOutputStream(file)) {
+        out.write(bytecode);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    final Class<?> proxyClass;
+    proxyClass = defineViaUnsafe(iClass, reflectorClassName, bytecode);
+    // proxyClass = defineViaNewClassLoader(iClass, reflectorClassName, bytecode);
+
+    return proxyClass.asSubclass(iClass);
+  }
+
+  private static <T> Class<?> defineViaUnsafe(
+      Class<T> iClass, String reflectorClassName, byte[] bytecode) {
+    return UnsafeAccess.defineClass(iClass, reflectorClassName, bytecode);
+  }
+
+  @SuppressWarnings("unused")
+  private static <T> Class<?> defineViaNewClassLoader(
+      Class<T> iClass, String reflectorClassName, byte[] bytecode) {
+    Class<?> proxyClass;
+    ClassLoader classLoader =
+        new ClassLoader(iClass.getClassLoader()) {
+          @Override
+          protected Class<?> findClass(String name) throws ClassNotFoundException {
+            return defineClass(name, bytecode, 0, bytecode.length);
+          }
+        };
+    try {
+      proxyClass = classLoader.loadClass(reflectorClassName);
+    } catch (ClassNotFoundException e) {
+      throw new AssertionError(e);
+    }
+    return proxyClass;
+  }
+
+  private static <T> byte[] getBytecode(
+      Class<T> iClass, Class<?> targetClass, String reflectorClassName) {
+    ReflectorClassWriter writer = new ReflectorClassWriter(iClass, targetClass, reflectorClassName);
+    writer.write();
+
+    return writer.toByteArray();
+  }
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java
new file mode 100644
index 0000000..d3e3668
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java
@@ -0,0 +1,498 @@
+package org.robolectric.util.reflector;
+
+import static org.objectweb.asm.Opcodes.ACC_FINAL;
+import static org.objectweb.asm.Opcodes.ACC_PRIVATE;
+import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
+import static org.objectweb.asm.Opcodes.ACC_STATIC;
+import static org.objectweb.asm.Opcodes.ACC_SUPER;
+import static org.objectweb.asm.Opcodes.V1_5;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AccessibleObject;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.HashSet;
+import java.util.Set;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Label;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.commons.GeneratorAdapter;
+
+@SuppressWarnings("NewApi")
+class ReflectorClassWriter extends ClassWriter {
+
+  private static final Type OBJECT_TYPE = Type.getType(Object.class);
+  private static final Type CLASS_TYPE = Type.getType(Class.class);
+  private static final Type FIELD_TYPE = Type.getType(Field.class);
+  private static final Type METHOD_TYPE = Type.getType(Method.class);
+  private static final Type STRING_TYPE = Type.getType(String.class);
+  private static final Type STRINGBUILDER_TYPE = Type.getType(StringBuilder.class);
+
+  private static final Type THROWABLE_TYPE = Type.getType(Throwable.class);
+  private static final Type ASSERTION_ERROR_TYPE = Type.getType(AssertionError.class);
+
+  private static final Type INVOCATION_TARGET_EXCEPTION_TYPE =
+      Type.getType(InvocationTargetException.class);
+
+  private static final Type REFLECTIVE_OPERATION_EXCEPTION_TYPE =
+      Type.getType(ReflectiveOperationException.class);
+
+  private static final org.objectweb.asm.commons.Method CLASS$GET_DECLARED_FIELD =
+      findMethod(Class.class, "getDeclaredField", new Class<?>[] {String.class});
+  private static final org.objectweb.asm.commons.Method CLASS$GET_DECLARED_METHOD =
+      findMethod(Class.class, "getDeclaredMethod", new Class<?>[] {String.class, Class[].class});
+  private static final org.objectweb.asm.commons.Method ACCESSIBLE_OBJECT$SET_ACCESSIBLE =
+      findMethod(AccessibleObject.class, "setAccessible", new Class<?>[] {boolean.class});
+  private static final org.objectweb.asm.commons.Method FIELD$GET =
+      findMethod(Field.class, "get", new Class<?>[] {Object.class});
+  private static final org.objectweb.asm.commons.Method FIELD$SET =
+      findMethod(Field.class, "set", new Class<?>[] {Object.class, Object.class});
+  private static final org.objectweb.asm.commons.Method METHOD$INVOKE =
+      findMethod(Method.class, "invoke", new Class<?>[] {Object.class, Object[].class});
+  private static final org.objectweb.asm.commons.Method THROWABLE$GET_CAUSE =
+      findMethod(Throwable.class, "getCause", new Class<?>[] {});
+  private static final org.objectweb.asm.commons.Method OBJECT_INIT =
+      new org.objectweb.asm.commons.Method("<init>", Type.VOID_TYPE, new Type[0]);
+  private static final org.objectweb.asm.commons.Method STRINGBUILDER$APPEND =
+      findMethod(StringBuilder.class, "append", new Class<?>[] {String.class});
+  private static final org.objectweb.asm.commons.Method STRINGBUILDER$TO_STRING =
+      findMethod(StringBuilder.class, "toString", new Class<?>[] {});
+  private static final org.objectweb.asm.commons.Method CLASS$GET_CLASS_LOADER =
+      findMethod(Class.class, "getClassLoader", new Class<?>[] {});
+  private static final org.objectweb.asm.commons.Method STRING$VALUE_OF =
+      findMethod(String.class, "valueOf", new Class<?>[] {Object.class});
+  private static final org.objectweb.asm.commons.Method ASSERTION_ERROR_INIT =
+      new org.objectweb.asm.commons.Method(
+          "<init>", Type.VOID_TYPE, new Type[] {STRING_TYPE, THROWABLE_TYPE});
+  private static final String TARGET_FIELD = "__target__";
+
+  private static org.objectweb.asm.commons.Method findMethod(
+      Class<?> clazz, String methodName, Class<?>[] paramTypes) {
+    try {
+      return asmMethod(clazz.getMethod(methodName, paramTypes));
+    } catch (NoSuchMethodException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private final Class<?> iClass;
+  private final Type iType;
+  private final Type reflectorType;
+  private final Type targetType;
+  private final boolean directModifier;
+
+  private int nextMethodNumber = 0;
+  private final Set<String> fieldRefs = new HashSet<>();
+
+  ReflectorClassWriter(Class<?> iClass, Class<?> targetClass, String reflectorName) {
+    super(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+
+    this.iClass = iClass;
+    iType = Type.getType(iClass);
+    reflectorType = asType(reflectorName);
+    targetType = Type.getType(targetClass);
+
+    ForType forType = iClass.getAnnotation(ForType.class);
+    directModifier = forType != null && forType.direct();
+  }
+
+  void write() {
+    int accessModifiers = iClass.getModifiers() & Modifier.PUBLIC;
+    visit(
+        V1_5,
+        accessModifiers | ACC_SUPER | ACC_FINAL,
+        reflectorType.getInternalName(),
+        null,
+        OBJECT_TYPE.getInternalName(),
+        new String[] {iType.getInternalName()});
+
+    writeTargetField();
+
+    writeConstructor();
+
+    for (Method method : iClass.getMethods()) {
+      if (method.isDefault()) continue;
+
+      Accessor accessor = method.getAnnotation(Accessor.class);
+      if (accessor != null) {
+        new AccessorMethodWriter(method, accessor).write();
+      } else {
+        new ReflectorMethodWriter(method).write();
+      }
+    }
+
+    visitEnd();
+  }
+
+  private void writeTargetField() {
+    visitField(ACC_PRIVATE, TARGET_FIELD, targetType.getDescriptor(), null, null);
+  }
+
+  private void writeConstructor() {
+    org.objectweb.asm.commons.Method initMethod =
+        new org.objectweb.asm.commons.Method("<init>", Type.VOID_TYPE, new Type[] {targetType});
+
+    GeneratorAdapter init = new GeneratorAdapter(ACC_PUBLIC, initMethod, null, null, this);
+    init.loadThis();
+    init.invokeConstructor(OBJECT_TYPE, OBJECT_INIT);
+
+    init.loadThis();
+    init.loadArg(0);
+    init.putField(reflectorType, TARGET_FIELD, targetType);
+
+    init.returnValue();
+    init.endMethod();
+  }
+
+  /** Generates bytecode for a setter or getter method. */
+  private class AccessorMethodWriter extends BaseAdapter {
+
+    private final String targetFieldName;
+    private final String fieldRefName;
+    private final boolean isSetter;
+
+    private AccessorMethodWriter(Method method, Accessor accessor) {
+      super(method);
+
+      targetFieldName = accessor.value();
+      this.fieldRefName = "field$" + targetFieldName;
+
+      String methodName = method.getName();
+      if (methodName.startsWith("get")) {
+        if (method.getReturnType().equals(void.class)) {
+          throw new IllegalArgumentException(method + " should have a non-void return type");
+        }
+        if (method.getParameterCount() != 0) {
+          throw new IllegalArgumentException(method + " should take no parameters");
+        }
+        isSetter = false;
+      } else if (methodName.startsWith("set")) {
+        if (!method.getReturnType().equals(void.class)) {
+          throw new IllegalArgumentException(method + " should have a void return type");
+        }
+        if (method.getParameterCount() != 1) {
+          throw new IllegalArgumentException(method + " should take a single parameter");
+        }
+        isSetter = true;
+      } else {
+        throw new IllegalArgumentException(
+            methodName + " doesn't appear to be a setter or a getter");
+      }
+    }
+
+    void write() {
+      // write our field to hold target field reference (but just once)...
+      if (fieldRefs.add(targetFieldName)) {
+        visitField(ACC_PRIVATE | ACC_STATIC, fieldRefName, FIELD_TYPE.getDescriptor(), null, null);
+      }
+
+      visitCode();
+
+      if (isSetter) {
+        // pseudocode:
+        //   field_x.set(this, arg0);
+        loadFieldRef();
+        loadTarget();
+        loadArg(0);
+        Class<?> parameterType = iMethod.getParameterTypes()[0];
+        if (parameterType.isPrimitive()) {
+          box(Type.getType(parameterType));
+        }
+        invokeVirtual(FIELD_TYPE, FIELD$SET);
+        returnValue();
+      } else { // getter
+        // pseudocode:
+        //   return field_x.get(this);
+        loadFieldRef();
+        loadTarget();
+        invokeVirtual(FIELD_TYPE, FIELD$GET);
+
+        castForReturn(iMethod.getReturnType());
+        returnValue();
+      }
+
+      endMethod();
+    }
+
+    private void loadFieldRef() {
+      // pseudocode:
+      //   if (field$x == null) {
+      //     field$x = targetClass.getDeclaredField(name);
+      //     field$x.setAccessible(true);
+      //   }
+      // -> field reference on stack
+      getStatic(reflectorType, fieldRefName, FIELD_TYPE);
+      dup();
+      Label haveMethodRef = newLabel();
+      ifNonNull(haveMethodRef);
+      pop();
+
+      // pseudocode:
+      //   targetClass.getDeclaredField(name);
+      push(targetType);
+      push(targetFieldName);
+      invokeVirtual(CLASS_TYPE, CLASS$GET_DECLARED_FIELD);
+
+      // pseudocode:
+      //   <field>.setAccessible(true);
+      dup();
+      push(true);
+      invokeVirtual(FIELD_TYPE, ACCESSIBLE_OBJECT$SET_ACCESSIBLE);
+
+      // pseudocode:
+      //   field$x = method;
+      dup();
+      putStatic(reflectorType, fieldRefName, FIELD_TYPE);
+      mark(haveMethodRef);
+    }
+  }
+
+  private class ReflectorMethodWriter extends BaseAdapter {
+
+    private final String methodRefName;
+    private final Type[] targetParamTypes;
+
+    private ReflectorMethodWriter(Method method) {
+      super(method);
+      int myMethodNumber = nextMethodNumber++;
+      this.methodRefName = "method" + myMethodNumber;
+      this.targetParamTypes = resolveParamTypes(iMethod);
+    }
+
+    void write() {
+      // write field to hold method reference...
+      visitField(ACC_PRIVATE | ACC_STATIC, methodRefName, METHOD_TYPE.getDescriptor(), null, null);
+
+      visitCode();
+
+      // pseudocode:
+      //   try {
+      //     return methodN.invoke(this, *args);
+      //   } catch (InvocationTargetException e) {
+      //     throw e.getCause();
+      //   } catch (ReflectiveOperationException e) {
+      //     throw new AssertionError("Error invoking reflector method in ClassLoader " +
+      // Instrumentation.class.getClassLoader(), e);
+      //   }
+      Label tryStart = new Label();
+      Label tryEnd = new Label();
+      Label handleInvocationTargetException = new Label();
+      visitTryCatchBlock(
+          tryStart,
+          tryEnd,
+          handleInvocationTargetException,
+          INVOCATION_TARGET_EXCEPTION_TYPE.getInternalName());
+      Label handleReflectiveOperationException = new Label();
+      visitTryCatchBlock(
+          tryStart,
+          tryEnd,
+          handleReflectiveOperationException,
+          REFLECTIVE_OPERATION_EXCEPTION_TYPE.getInternalName());
+
+      mark(tryStart);
+      loadOriginalMethodRef();
+      loadTarget();
+      loadArgArray();
+      invokeVirtual(METHOD_TYPE, METHOD$INVOKE);
+      mark(tryEnd);
+
+      castForReturn(iMethod.getReturnType());
+      returnValue();
+
+      mark(handleInvocationTargetException);
+
+      int exceptionLocalVar = newLocal(THROWABLE_TYPE);
+      storeLocal(exceptionLocalVar);
+      loadLocal(exceptionLocalVar);
+      invokeVirtual(THROWABLE_TYPE, THROWABLE$GET_CAUSE);
+      throwException();
+      mark(handleReflectiveOperationException);
+      exceptionLocalVar = newLocal(REFLECTIVE_OPERATION_EXCEPTION_TYPE);
+      storeLocal(exceptionLocalVar);
+      newInstance(STRINGBUILDER_TYPE);
+      dup();
+      invokeConstructor(STRINGBUILDER_TYPE, OBJECT_INIT);
+      push("Error invoking reflector method in ClassLoader ");
+      invokeVirtual(STRINGBUILDER_TYPE, STRINGBUILDER$APPEND);
+      push(targetType);
+      invokeVirtual(CLASS_TYPE, CLASS$GET_CLASS_LOADER);
+      invokeStatic(STRING_TYPE, STRING$VALUE_OF);
+      invokeVirtual(STRINGBUILDER_TYPE, STRINGBUILDER$APPEND);
+      invokeVirtual(STRINGBUILDER_TYPE, STRINGBUILDER$TO_STRING);
+      int messageLocalVar = newLocal(STRING_TYPE);
+      storeLocal(messageLocalVar);
+      newInstance(ASSERTION_ERROR_TYPE);
+      dup();
+      loadLocal(messageLocalVar);
+      loadLocal(exceptionLocalVar);
+      invokeConstructor(ASSERTION_ERROR_TYPE, ASSERTION_ERROR_INIT);
+      throwException();
+
+      endMethod();
+    }
+
+    private void loadOriginalMethodRef() {
+      // pseudocode:
+      //   if (methodN == null) {
+      //     methodN = targetClass.getDeclaredMethod(name, paramTypes);
+      //     methodN.setAccessible(true);
+      //   }
+      // -> method reference on stack
+      getStatic(reflectorType, methodRefName, METHOD_TYPE);
+      dup();
+      Label haveMethodRef = newLabel();
+      ifNonNull(haveMethodRef);
+      pop();
+
+      // pseudocode:
+      //   targetClass.getDeclaredMethod(name, paramTypes);
+      push(targetType);
+      push(getMethodName());
+      Type[] paramTypes = targetParamTypes;
+      push(paramTypes.length);
+      newArray(CLASS_TYPE);
+      for (int i = 0; i < paramTypes.length; i++) {
+        dup();
+        push(i);
+        push(paramTypes[i]);
+        arrayStore(CLASS_TYPE);
+      }
+      invokeVirtual(CLASS_TYPE, CLASS$GET_DECLARED_METHOD);
+
+      // pseudocode:
+      //   <method>.setAccessible(true);
+      dup();
+      push(true);
+      invokeVirtual(METHOD_TYPE, ACCESSIBLE_OBJECT$SET_ACCESSIBLE);
+
+      // pseudocode:
+      //   methodN = method;
+      dup();
+      putStatic(reflectorType, methodRefName, METHOD_TYPE);
+      mark(haveMethodRef);
+    }
+
+    private Type[] resolveParamTypes(Method iMethod) {
+      Class<?>[] iParamTypes = iMethod.getParameterTypes();
+      Annotation[][] paramAnnotations = iMethod.getParameterAnnotations();
+
+      Type[] targetParamTypes = new Type[iParamTypes.length];
+      for (int i = 0; i < iParamTypes.length; i++) {
+        Class<?> paramType = findWithType(paramAnnotations[i]);
+        if (paramType == null) {
+          paramType = iParamTypes[i];
+        }
+        targetParamTypes[i] = Type.getType(paramType);
+      }
+      return targetParamTypes;
+    }
+
+    private Class<?> findWithType(Annotation[] paramAnnotation) {
+      for (Annotation annotation : paramAnnotation) {
+        if (annotation instanceof WithType) {
+          String withTypeName = ((WithType) annotation).value();
+          try {
+            return Class.forName(withTypeName, true, iClass.getClassLoader());
+          } catch (ClassNotFoundException e1) {
+            // it's okay, ignore
+          }
+        }
+      }
+      return null;
+    }
+  }
+
+  private static String[] getInternalNames(final Class<?>[] types) {
+    if (types == null) {
+      return null;
+    }
+    String[] names = new String[types.length];
+    for (int i = 0; i < names.length; ++i) {
+      names[i] = Type.getType(types[i]).getInternalName();
+    }
+    return names;
+  }
+
+  private Type asType(String reflectorName) {
+    return Type.getType("L" + reflectorName.replace('.', '/') + ";");
+  }
+
+  private static org.objectweb.asm.commons.Method asmMethod(Method method) {
+    return org.objectweb.asm.commons.Method.getMethod(method);
+  }
+
+  /** Hide ugly constructor chaining. */
+  private class BaseAdapter extends GeneratorAdapter {
+    final Method iMethod;
+
+    BaseAdapter(Method method) {
+      this(org.objectweb.asm.commons.Method.getMethod(method), method);
+    }
+
+    private BaseAdapter(
+        org.objectweb.asm.commons.Method asmMethod, Method method) {
+      this(
+          method,
+          asmMethod,
+          ReflectorClassWriter.this.visitMethod(
+              Opcodes.ACC_PUBLIC,
+              asmMethod.getName(),
+              asmMethod.getDescriptor(),
+              null,
+              ReflectorClassWriter.getInternalNames(method.getExceptionTypes())));
+    }
+
+    private BaseAdapter(
+        Method method, org.objectweb.asm.commons.Method asmMethod, MethodVisitor methodVisitor) {
+      super(
+          Opcodes.ASM6,
+          methodVisitor,
+          Opcodes.ACC_PUBLIC,
+          asmMethod.getName(),
+          asmMethod.getDescriptor());
+
+      this.iMethod = method;
+    }
+
+    void loadTarget() {
+      if (isAnnotatedStatic()) {
+        loadNull();
+      } else {
+        loadThis();
+        getField(reflectorType, TARGET_FIELD, targetType);
+      }
+    }
+
+    void castForReturn(Class<?> returnType) {
+      if (returnType.isPrimitive()) {
+        unbox(Type.getType(returnType));
+      } else {
+        checkCast(Type.getType(returnType));
+      }
+    }
+
+    String getMethodName() {
+      String methodName = iMethod.getName();
+      if (iMethod.isAnnotationPresent(Direct.class) || directModifier) {
+        methodName =
+            "$$robo$$"
+                + targetType.getClassName().replace('.', '_').replace('$', '_')
+                + "$"
+                + methodName;
+      }
+      return methodName;
+    }
+
+    boolean isAnnotatedStatic() {
+      return iMethod.isAnnotationPresent(Static.class);
+    }
+
+    void loadNull() {
+      visitInsn(Opcodes.ACONST_NULL);
+    }
+  }
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/Static.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/Static.java
new file mode 100644
index 0000000..66f65ce
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/Static.java
@@ -0,0 +1,13 @@
+package org.robolectric.util.reflector;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Marks {@link Reflector} methods which serve as accessors for static members. */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Static {
+
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/UnsafeAccess.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/UnsafeAccess.java
new file mode 100644
index 0000000..4f0a39c
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/UnsafeAccess.java
@@ -0,0 +1,104 @@
+package org.robolectric.util.reflector;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.security.ProtectionDomain;
+import org.robolectric.util.Util;
+import sun.misc.Unsafe;
+
+/** Access to sun.misc.Unsafe and the various scary things within. */
+@SuppressWarnings("NewApi")
+public class UnsafeAccess {
+
+  private static final Danger DANGER =
+      Util.getJavaVersion() < 11 ? new DangerPre11() : new Danger11Plus();
+
+  interface Danger {
+    <T> Class<?> defineClass(Class<T> iClass, String reflectorClassName, byte[] bytecode);
+  }
+
+  static <T> Class<?> defineClass(Class<T> iClass, String reflectorClassName, byte[] bytecode) {
+    return DANGER.defineClass(iClass, reflectorClassName, bytecode);
+  }
+
+  @SuppressWarnings("RethrowReflectiveOperationExceptionAsLinkageError")
+  private static class DangerPre11 implements Danger {
+    private final Unsafe unsafe;
+    private final Method defineClassMethod;
+
+    {
+      try {
+        Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
+        unsafeField.setAccessible(true);
+        unsafe = (Unsafe) unsafeField.get(null);
+        defineClassMethod =
+            Unsafe.class.getMethod(
+                "defineClass",
+                String.class,
+                byte[].class,
+                int.class,
+                int.class,
+                ClassLoader.class,
+                ProtectionDomain.class);
+      } catch (NoSuchFieldException | IllegalAccessException | NoSuchMethodException e) {
+        throw new AssertionError(e);
+      }
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <T> Class<?> defineClass(Class<T> iClass, String reflectorClassName, byte[] bytecode) {
+      // use reflection to call since this method does not exist on JDK11
+      try {
+        return (Class<?>)
+            defineClassMethod.invoke(
+                unsafe,
+                reflectorClassName,
+                bytecode,
+                0,
+                bytecode.length,
+                iClass.getClassLoader(),
+                null);
+      } catch (IllegalAccessException | InvocationTargetException e) {
+        throw new AssertionError(e);
+      }
+    }
+  }
+
+  @SuppressWarnings("RethrowReflectiveOperationExceptionAsLinkageError")
+  private static class Danger11Plus implements Danger {
+    private final Method privateLookupInMethod;
+    private final Method defineClassMethod;
+
+    {
+      try {
+        privateLookupInMethod =
+            MethodHandles.class.getMethod(
+                "privateLookupIn", Class.class, MethodHandles.Lookup.class);
+        defineClassMethod =
+            MethodHandles.Lookup.class.getMethod("defineClass", byte[].class);
+      } catch (NoSuchMethodException e) {
+        throw new AssertionError(e);
+      }
+    }
+
+    @Override
+    public <T> Class<?> defineClass(Class<T> iClass, String reflectorClassName, byte[] bytecode) {
+      MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+      try {
+        // MethodHandles.Lookup privateLookup = MethodHandles.privateLookupIn(iClass, lookup);
+        MethodHandles.Lookup privateLookup =
+            (Lookup) privateLookupInMethod.invoke(lookup, iClass, lookup);
+
+        // return privateLookup.defineClass(bytecode);
+        return (Class<?>) defineClassMethod.invoke(privateLookup, bytecode);
+      } catch (IllegalAccessException | InvocationTargetException e) {
+        throw new AssertionError(e);
+      }
+    }
+  }
+}
diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/WithType.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/WithType.java
new file mode 100644
index 0000000..6befd71
--- /dev/null
+++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/WithType.java
@@ -0,0 +1,20 @@
+package org.robolectric.util.reflector;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Parameters with types that can't be resolved at compile time may be annotated @WithType. */
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface WithType {
+
+  /**
+   * The class name intended for this parameter.
+   *
+   * <p>Use the value as returned from {@link Class#getName()}, not {@link
+   * Class#getCanonicalName()}; e.g. {@code Foo$Bar} instead of {@code Foo.Bar}.
+   */
+  String value();
+}
diff --git a/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java b/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java
new file mode 100644
index 0000000..8baf3d6
--- /dev/null
+++ b/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java
@@ -0,0 +1,268 @@
+package org.robolectric.util.reflector;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.robolectric.util.reflector.Reflector.reflector;
+
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+@RunWith(JUnit4.class)
+public class ReflectorTest {
+
+  private SomeClass someClass;
+  private _SomeClass_ reflector;
+  private _SomeClass_ staticReflector;
+
+  @Before
+  public void setUp() throws Exception {
+    someClass = new SomeClass("c");
+    reflector = reflector(_SomeClass_.class, someClass);
+
+    staticReflector = reflector(_SomeClass_.class, null);
+  }
+
+  @Test
+  public void reflector_shouldCallPrivateMethod() {
+    assertThat(reflector.someMethod("a", "b")).isEqualTo("a-b-c (someMethod)");
+  }
+
+  @Test
+  public void reflector_shouldHonorWithTypeAnnotationForParams() {
+    assertThat(reflector.anotherMethod("a", "b")).isEqualTo("a-b-c (anotherMethod)");
+  }
+
+  @Test
+  public void reflector_defaultMethodsShouldWork() {
+    assertThat(reflector.defaultMethod("someMethod", "a", "b")).isEqualTo("a-b-c (someMethod)");
+    assertThat(reflector.defaultMethod("anotherMethod", "a", "b"))
+        .isEqualTo("a-b-c (anotherMethod)");
+  }
+
+  @Test
+  public void reflector_shouldUnboxReturnValues() {
+    assertThat(reflector.returnLong()).isEqualTo(1234L);
+  }
+
+  @Test
+  public void reflector_shouldCallStaticMethod() {
+    assertThat(reflector.someStaticMethod("a", "b")).isEqualTo("a-b (someStaticMethod)");
+
+    assertThat(staticReflector.someStaticMethod("a", "b")).isEqualTo("a-b (someStaticMethod)");
+  }
+
+  @Test
+  public void reflector_fieldAccessors() {
+    assertThat(reflector.getC()).isEqualTo("c");
+
+    reflector.setC("c++");
+    assertThat(reflector.getC()).isEqualTo("c++");
+  }
+
+  @Test
+  public void reflector_primitiveFieldAccessors() {
+    assertThat(reflector.getD()).isEqualTo(0);
+
+    reflector.setD(1234);
+    assertThat(reflector.getD()).isEqualTo(1234);
+  }
+
+  @Test
+  public void reflector_staticFieldAccessors() {
+    assertThat(reflector.getEStatic()).isEqualTo(null);
+
+    reflector.setEStatic("eee!");
+    assertThat(reflector.getEStatic()).isEqualTo("eee!");
+  }
+
+  @Test
+  public void reflector_throwsCorrectExceptions() {
+    Throwable expected = new ArrayIndexOutOfBoundsException();
+    Throwable actual = null;
+    try {
+      reflector.throwException(expected);
+      fail("should have failed");
+    } catch (Exception thrown) {
+      actual = thrown;
+    }
+    assertThat(actual).isSameInstanceAs(expected);
+  }
+
+  @Ignore
+  @Test
+  public void methodPerf() {
+    SomeClass i = new SomeClass("c");
+
+    System.out.println("reflection = " + Collections.singletonList(methodByReflectionHelpers(i)));
+    System.out.println("accessor = " + Collections.singletonList(methodByReflector(i)));
+
+    _SomeClass_ accessor = reflector(_SomeClass_.class, i);
+
+    time("ReflectionHelpers", 10_000_000, () -> methodByReflectionHelpers(i));
+    time("accessor", 10_000_000, () -> methodByReflector(i));
+    time("saved accessor", 10_000_000, () -> methodBySavedReflector(accessor));
+
+    time("ReflectionHelpers", 10_000_000, () -> methodByReflectionHelpers(i));
+    time("accessor", 10_000_000, () -> methodByReflector(i));
+    time("saved accessor", 10_000_000, () -> methodBySavedReflector(accessor));
+  }
+
+  @Ignore
+  @Test
+  public void fieldPerf() {
+    SomeClass i = new SomeClass("c");
+
+    System.out.println("reflection = " + Collections.singletonList(fieldByReflectionHelpers(i)));
+    System.out.println("accessor = " + Collections.singletonList(fieldByReflector(i)));
+
+    _SomeClass_ accessor = reflector(_SomeClass_.class, i);
+
+    time("ReflectionHelpers", 10_000_000, () -> fieldByReflectionHelpers(i));
+    time("accessor", 10_000_000, () -> fieldByReflector(i));
+    time("saved accessor", 10_000_000, () -> fieldBySavedReflector(accessor));
+
+    time("ReflectionHelpers", 10_000_000, () -> fieldByReflectionHelpers(i));
+    time("accessor", 10_000_000, () -> fieldByReflector(i));
+    time("saved accessor", 10_000_000, () -> fieldBySavedReflector(accessor));
+  }
+
+  @Test
+  public void nonExistentMethod_throwsAssertionError() {
+    SomeClass i = new SomeClass("c");
+    _SomeClass_ accessor = reflector(_SomeClass_.class, i);
+    AssertionError ex =
+        assertThrows(AssertionError.class, () -> accessor.nonExistentMethod("a", "b", "c"));
+    assertThat(ex).hasMessageThat().startsWith("Error invoking reflector method in ClassLoader ");
+    assertThat(ex).hasCauseThat().isInstanceOf(NoSuchMethodException.class);
+  }
+
+  //////////////////////
+
+  /** Accessor interface for {@link SomeClass}'s internals. */
+  @ForType(SomeClass.class)
+  interface _SomeClass_ {
+
+    @Static
+    String someStaticMethod(String a, String b);
+
+    @Static @Accessor("eStatic")
+    void setEStatic(String value);
+
+    @Static @Accessor("eStatic")
+    String getEStatic();
+
+    @Accessor("c")
+    void setC(String value);
+
+    @Accessor("c")
+    String getC();
+
+    @Accessor("mD")
+    void setD(int value);
+
+    @Accessor("mD")
+    int getD();
+
+    String someMethod(String a, String b);
+
+    String nonExistentMethod(String a, String b, String c);
+
+    String anotherMethod(@WithType("java.lang.String") Object a, String b);
+
+    default String defaultMethod(String which, String a, String b) {
+      switch (which) {
+        case "someMethod":
+          return someMethod(a, b);
+        case "anotherMethod":
+          return anotherMethod(a, b);
+        default:
+          throw new IllegalStateException(which);
+      }
+    }
+
+    long returnLong();
+
+    void throwException(Throwable t);
+  }
+
+  @SuppressWarnings("unused")
+  static class SomeClass {
+
+    private static String eStatic;
+    private String c;
+    private int mD;
+
+    SomeClass(String c) {
+      this.c = c;
+    }
+
+    private static String someStaticMethod(String a, String b) {
+      return a + "-" + b + " (someStaticMethod)";
+    }
+
+    private String someMethod(String a, String b) {
+      return a + "-" + b + "-" + c + " (someMethod)";
+    }
+
+    private String anotherMethod(String a, String b) {
+      return a + "-" + b + "-" + c + " (anotherMethod)";
+    }
+
+    private long returnLong() {
+      return 1234L;
+    }
+
+    @SuppressWarnings("unused")
+    private void throwException(Throwable t) throws Throwable {
+      throw t;
+    }
+  }
+
+  private void time(String name, int times, Runnable runnable) {
+    long startTime = System.currentTimeMillis();
+    for (int i = 0; i < times; i++) {
+      runnable.run();
+    }
+    long elasedMs = System.currentTimeMillis() - startTime;
+    System.out.println(name + " took " + elasedMs);
+  }
+
+  private String methodByReflectionHelpers(SomeClass o) {
+    return ReflectionHelpers.callInstanceMethod(
+        o,
+        "someMethod",
+        ClassParameter.from(String.class, "a"),
+        ClassParameter.from(String.class, "b"));
+  }
+
+  private String methodByReflector(SomeClass o) {
+    _SomeClass_ accessor = reflector(_SomeClass_.class, o);
+    return accessor.someMethod("a", "b");
+  }
+
+  private String methodBySavedReflector(_SomeClass_ reflector) {
+    return reflector.someMethod("a", "b");
+  }
+
+  private String fieldByReflectionHelpers(SomeClass o) {
+    ReflectionHelpers.setField(o, "c", "abc");
+    return ReflectionHelpers.getField(o, "c");
+  }
+
+  private String fieldByReflector(SomeClass o) {
+    reflector(_SomeClass_.class, o).setC("abc");
+    return reflector(_SomeClass_.class, o).getC();
+  }
+
+  private String fieldBySavedReflector(_SomeClass_ reflector) {
+    reflector.setC("abc");
+    return reflector.getC();
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/AndroidMetadata.java b/utils/src/main/java/org/robolectric/AndroidMetadata.java
new file mode 100644
index 0000000..36566ad
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/AndroidMetadata.java
@@ -0,0 +1,25 @@
+package org.robolectric;
+
+import java.util.Map;
+
+/**
+ * Data related to Android tests.
+ */
+public class AndroidMetadata {
+
+  private final Map<String, String> deviceBootProperties;
+  private final String resourcesMode;
+
+  public AndroidMetadata(Map<String, String> deviceBootProperties, String resourcesMode) {
+    this.deviceBootProperties = deviceBootProperties;
+    this.resourcesMode = resourcesMode;
+  }
+
+  public Map<String, String> getDeviceBootProperties() {
+    return deviceBootProperties;
+  }
+
+  public String getResourcesMode() {
+    return resourcesMode;
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/Clock.java b/utils/src/main/java/org/robolectric/util/Clock.java
new file mode 100644
index 0000000..8b086c8
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Clock.java
@@ -0,0 +1,5 @@
+package org.robolectric.util;
+
+interface Clock {
+  long nanoTime();
+}
diff --git a/utils/src/main/java/org/robolectric/util/Consumer.java b/utils/src/main/java/org/robolectric/util/Consumer.java
new file mode 100644
index 0000000..4b8a2b3
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Consumer.java
@@ -0,0 +1,41 @@
+package org.robolectric.util;
+
+import java.util.Objects;
+
+/**
+ * Represents an operation that accepts a single input argument and returns no
+ * result. Unlike most other functional interfaces, {@code Consumer} is expected
+ * to operate via side-effects.
+ *
+ * Included in Robolectric since Android doesn't support streams yet (as of O).
+ *
+ * @param <T> the type of the input to the operation
+ */
+public interface Consumer<T> {
+
+  /**
+   * Performs this operation on the given argument.
+   *
+   * @param t the input argument
+   */
+  void accept(T t);
+
+  /**
+   * Returns a composed {@code Consumer} that performs, in sequence, this operation followed by the
+   * {@code after} operation. If performing either operation throws an exception, it is relayed to
+   * the caller of the composed operation.  If performing this operation throws an exception, the
+   * {@code after} operation will not be performed.
+   *
+   * @param after the operation to perform after this operation
+   * @return a composed {@code Consumer} that performs in sequence this operation followed by the
+   * {@code after} operation
+   * @throws NullPointerException if {@code after} is null
+   */
+  default Consumer<T> andThen(Consumer<? super T> after) {
+    Objects.requireNonNull(after);
+    return (T t) -> {
+      accept(t);
+      after.accept(t);
+    };
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/Join.java b/utils/src/main/java/org/robolectric/util/Join.java
new file mode 100644
index 0000000..a2abb42
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Join.java
@@ -0,0 +1,34 @@
+package org.robolectric.util;
+
+import java.util.Collection;
+
+/**
+ * Utility class used to join strings together with a delimiter.
+ */
+public class Join {
+  public static String join(String delimiter, Collection collection) {
+    String del = "";
+    StringBuilder sb = new StringBuilder();
+    for (Object obj : collection) {
+      String asString = obj == null ? null : obj.toString();
+      if (obj != null && asString.length() > 0) {
+        sb.append(del).append(obj);
+        del = delimiter;
+      }
+    }
+    return sb.toString();
+  }
+
+  public static String join(String delimiter, Object... collection) {
+    String del = "";
+    StringBuilder sb = new StringBuilder();
+    for (Object obj : collection) {
+      String asString = obj == null ? null : obj.toString();
+      if (asString != null && asString.length() > 0) {
+        sb.append(del).append(asString);
+        del = delimiter;
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/Logger.java b/utils/src/main/java/org/robolectric/util/Logger.java
new file mode 100644
index 0000000..14a313f
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Logger.java
@@ -0,0 +1,103 @@
+package org.robolectric.util;
+
+/**
+ * Logger for Robolectric. For now, it simply prints messages to stdout.
+ *
+ * <p>Logging can be enabled by setting the property: {@code robolectric.logging.enabled = true}.
+ */
+public class Logger {
+  private static final String LOGGING_ENABLED = "robolectric.logging.enabled";
+
+  public static void strict(String message, Throwable e) {
+    if (loggingEnabled()) {
+      System.out.print("WARNING: ");
+      System.out.println(message);
+      e.printStackTrace();
+    }
+  }
+
+  public static void strict(String message, Object... args) {
+    if (loggingEnabled()) {
+      System.out.print("WARNING: ");
+      System.out.printf(message + "%n", args);
+    }
+  }
+
+  /**
+   * Log an info message.
+   *
+   * @param message Message text.
+   * @param args Message arguments.
+   */
+  public static void info(String message, Object... args) {
+    if (loggingEnabled()) {
+      System.out.print("INFO: ");
+      System.out.printf(message + "%n", args);
+    }
+  }
+
+  /**
+   * Log a warning message.
+   *
+   * @param message Message text.
+   * @param args Message arguments.
+   */
+  public static void warn(String message, Object... args) {
+    if (loggingEnabled()) {
+      System.out.print("WARN: ");
+      System.out.printf(message + "%n", args);
+    }
+  }
+
+  /**
+   * Log an error message.
+   *
+   * @param message Message text.
+   * @param e The exception.
+   */
+  public static void error(String message, Throwable e) {
+    System.err.print("ERROR: ");
+    System.err.println(message);
+    e.printStackTrace();
+  }
+
+  /**
+   * Log an error message.
+   *
+   * @param message Message text.
+   * @param args Message arguments.
+   */
+  public static void error(String message, Object... args) {
+    System.err.print("ERROR: ");
+    System.err.printf(message + "%n", args);
+  }
+
+  /**
+   * Log a debug message.
+   *
+   * @param message Message text.
+   * @param args Message arguments.
+   */
+  public static void debug(String message, Object... args) {
+    if (loggingEnabled()) {
+      System.out.print("DEBUG: ");
+      System.out.printf(message + "%n", args);
+    }
+  }
+
+  /**
+   * Log a lifecycle message.
+   *
+   * @param message Message text.
+   * @param args Message arguments.
+   */
+  public static void lifecycle(String message, Object... args) {
+    if (loggingEnabled()) {
+      System.out.printf(message + "%n", args);
+    }
+  }
+
+  public static boolean loggingEnabled() {
+    return Boolean.getBoolean(LOGGING_ENABLED);
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/NamedStream.java b/utils/src/main/java/org/robolectric/util/NamedStream.java
new file mode 100644
index 0000000..6d7e470
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/NamedStream.java
@@ -0,0 +1,7 @@
+package org.robolectric.util;
+
+/**
+ * Marker interface for {@link java.io.InputStream} that need special handling.
+ */
+public interface NamedStream {
+}
diff --git a/utils/src/main/java/org/robolectric/util/PerfStatsCollector.java b/utils/src/main/java/org/robolectric/util/PerfStatsCollector.java
new file mode 100644
index 0000000..492a706
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/PerfStatsCollector.java
@@ -0,0 +1,192 @@
+package org.robolectric.util;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import org.robolectric.pluginapi.perf.Metadata;
+import org.robolectric.pluginapi.perf.Metric;
+import org.robolectric.pluginapi.perf.PerfStatsReporter;
+
+/**
+ * Collects performance statistics for later reporting via {@link PerfStatsReporter}.
+ *
+ * @since 3.6
+ */
+public class PerfStatsCollector {
+
+  private static final PerfStatsCollector INSTANCE = new PerfStatsCollector();
+
+  private final Clock clock;
+  private final Map<Class<?>, Object> metadata = new HashMap<>();
+  private final Map<MetricKey, Metric> metricMap = new HashMap<>();
+  private boolean enabled = true;
+
+  public PerfStatsCollector() {
+    this(System::nanoTime);
+  }
+
+  PerfStatsCollector(Clock clock) {
+    this.clock = clock;
+  }
+
+  public static PerfStatsCollector getInstance() {
+    return INSTANCE;
+  }
+
+  /**
+   * If not enabled, don't bother retaining perf stats, saving some memory and CPU cycles.
+   */
+  public void setEnabled(boolean isEnabled) {
+    this.enabled = isEnabled;
+  }
+
+  public Event startEvent(String eventName) {
+    return new Event(eventName);
+  }
+
+  public <T, E extends Exception> T measure(String eventName, ThrowingSupplier<T, E> supplier)
+      throws E {
+    boolean success = true;
+    Event event = startEvent(eventName);
+    try {
+      return supplier.get();
+    } catch (Exception e) {
+      success = false;
+      throw e;
+    } finally {
+      event.finished(success);
+    }
+  }
+
+  public void incrementCount(String eventName) {
+    synchronized (PerfStatsCollector.this) {
+      MetricKey key = new MetricKey(eventName, true);
+      Metric metric = metricMap.get(key);
+      if (metric == null) {
+        metricMap.put(key, metric = new Metric(key.name, key.success));
+      }
+      metric.incrementCount();
+    }
+  }
+
+  /**
+   * Supplier that throws an exception.
+   */
+  // @FunctionalInterface -- not available on Android yet...
+  public interface ThrowingSupplier<T, F extends Exception> {
+    T get() throws F;
+  }
+
+  public <E extends Exception> void measure(String eventName, ThrowingRunnable<E> runnable)
+      throws E {
+    boolean success = true;
+    Event event = startEvent(eventName);
+    try {
+      runnable.run();
+    } catch (Exception e) {
+      success = false;
+      throw e;
+    } finally {
+      event.finished(success);
+    }
+  }
+
+  /**
+   * Runnable that throws an exception.
+   */
+  // @FunctionalInterface -- not available on Android yet...
+  public interface ThrowingRunnable<F extends Exception> {
+    void run() throws F;
+  }
+
+  public synchronized Collection<Metric> getMetrics() {
+    return new ArrayList<>(metricMap.values());
+  }
+
+  public synchronized <T> void putMetadata(Class<T> metadataClass, T metadata) {
+    if (!enabled) {
+      return;
+    }
+
+    this.metadata.put(metadataClass, metadata);
+  }
+
+  public synchronized Metadata getMetadata() {
+    return new Metadata(metadata);
+  }
+
+  public void reset() {
+    metadata.clear();
+    metricMap.clear();
+  }
+
+  /**
+   * Event for perf stats collection.
+   */
+  public class Event {
+    private final String name;
+    private final long startTimeNs;
+
+    Event(String name) {
+      this.name = name;
+      this.startTimeNs = clock.nanoTime();
+    }
+
+    public void finished() {
+      finished(true);
+    }
+
+    public void finished(boolean success) {
+      if (!enabled) {
+        return;
+      }
+
+      synchronized (PerfStatsCollector.this) {
+        MetricKey key = new MetricKey(name, success);
+        Metric metric = metricMap.get(key);
+        if (metric == null) {
+          metricMap.put(key, metric = new Metric(key.name, key.success));
+        }
+        metric.record(clock.nanoTime() - startTimeNs);
+      }
+    }
+  }
+
+  /**
+   * Metric key for perf stats collection.
+   */
+  private static class MetricKey {
+    private final String name;
+    private final boolean success;
+
+    MetricKey(String name, boolean success) {
+      this.name = name;
+      this.success = success;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof MetricKey)) {
+        return false;
+      }
+
+      MetricKey metricKey = (MetricKey) o;
+
+      if (success != metricKey.success) {
+        return false;
+      }
+      return name != null ? name.equals(metricKey.name) : metricKey.name == null;
+    }
+
+    @Override
+    public int hashCode() {
+      int result = name != null ? name.hashCode() : 0;
+      result = 31 * result + (success ? 1 : 0);
+      return result;
+    }
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/Scheduler.java b/utils/src/main/java/org/robolectric/util/Scheduler.java
new file mode 100644
index 0000000..c345656
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Scheduler.java
@@ -0,0 +1,418 @@
+package org.robolectric.util;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.robolectric.util.Scheduler.IdleState.CONSTANT_IDLE;
+import static org.robolectric.util.Scheduler.IdleState.PAUSED;
+import static org.robolectric.util.Scheduler.IdleState.UNPAUSED;
+
+import com.google.errorprone.annotations.InlineMe;
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.PriorityQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Class that manages a queue of Runnables that are scheduled to run now (or at some time in the
+ * future). Runnables that are scheduled to run on the UI thread (tasks, animations, etc) eventually
+ * get routed to a Scheduler instance.
+ *
+ * <p>The execution of a scheduler can be in one of three states:
+ *
+ * <ul>
+ *   <li>paused ({@link #pause()}): if paused, then no posted events will be run unless the
+ *       Scheduler is explicitly instructed to do so, correctly matching Android's behavior.
+ *   <li>normal ({@link #unPause()}): if not paused but not set to idle constantly, then the
+ *       Scheduler will automatically run any {@link Runnable}s that are scheduled to run at or
+ *       before the Scheduler's current time, but it won't automatically run any future events. To
+ *       run future events the Scheduler needs to have its clock advanced.
+ *   <li>idling constantly: if {@link #idleConstantly(boolean)} is called with true, then the
+ *       Scheduler will continue looping through posted events (including future events), advancing
+ *       its clock as it goes.
+ * </ul>
+ */
+public class Scheduler {
+
+  /**
+   * Describes the current state of a {@link Scheduler}.
+   */
+  public enum IdleState {
+    /** The {@link Scheduler} will not automatically advance the clock nor execute any runnables. */
+    PAUSED,
+    /**
+     * The {@link Scheduler}'s clock won't automatically advance the clock but will automatically
+     * execute any runnables scheduled to execute at or before the current time.
+     */
+    UNPAUSED,
+    /**
+     * The {@link Scheduler} will automatically execute any runnables (past, present or future) as
+     * soon as they are posted and advance the clock if necessary.
+     */
+    CONSTANT_IDLE
+  }
+
+  private static final long START_TIME = 100;
+  private volatile long currentTime = START_TIME;
+  /**
+   * PriorityQueue doesn't maintain ordering based on insertion; track that ourselves to preserve
+   * FIFO order for posted runnables with the same scheduled time.
+   */
+  private long nextTimeDisambiguator = 0;
+
+  private boolean isExecutingRunnable = false;
+  private final Thread associatedThread = Thread.currentThread();
+  private final PriorityQueue<ScheduledRunnable> runnables = new PriorityQueue<>();
+  private volatile IdleState idleState = UNPAUSED;
+
+  /**
+   * Retrieves the current idling state of this {@link Scheduler}.
+   *
+   * @return The current idle state of this {@link Scheduler}.
+   * @see #setIdleState(IdleState)
+   * @see #isPaused()
+   */
+  public IdleState getIdleState() {
+    return idleState;
+  }
+
+  /**
+   * Sets the current idling state of this {@link Scheduler}. If transitioning to the {@link
+   * IdleState#UNPAUSED} state any tasks scheduled to be run at or before the current time will be
+   * run, and if transitioning to the {@link IdleState#CONSTANT_IDLE} state all scheduled tasks will
+   * be run and the clock advanced to the time of the last runnable.
+   *
+   * @param idleState The new idle state of this {@link Scheduler}.
+   * @see #setIdleState(IdleState)
+   * @see #isPaused()
+   */
+  public synchronized void setIdleState(IdleState idleState) {
+    this.idleState = idleState;
+    switch (idleState) {
+      case UNPAUSED:
+        advanceBy(0, TimeUnit.MILLISECONDS);
+        break;
+      case CONSTANT_IDLE:
+        advanceToLastPostedRunnable();
+        break;
+      default:
+    }
+  }
+
+  /**
+   * Get the current time (as seen by the scheduler), in milliseconds.
+   *
+   * @return  Current time in milliseconds.
+   */
+  public long getCurrentTime() {
+    return currentTime;
+  }
+
+  /**
+   * Pause the scheduler. Equivalent to {@code setIdleState(PAUSED)}.
+   *
+   * @see #unPause()
+   * @see #setIdleState(IdleState)
+   */
+  public synchronized void pause() {
+    setIdleState(PAUSED);
+  }
+
+  /**
+   * Un-pause the scheduler. Equivalent to {@code setIdleState(UNPAUSED)}.
+   *
+   * @see #pause()
+   * @see #setIdleState(IdleState)
+   */
+  public synchronized void unPause() {
+    setIdleState(UNPAUSED);
+  }
+
+  /**
+   * Determine if the scheduler is paused.
+   *
+   * @return true if it is paused.
+   */
+  public boolean isPaused() {
+    return idleState == PAUSED;
+  }
+
+  /**
+   * Add a runnable to the queue.
+   *
+   * @param runnable    Runnable to add.
+   */
+  public synchronized void post(Runnable runnable) {
+    postDelayed(runnable, 0, MILLISECONDS);
+  }
+
+  /**
+   * Add a runnable to the queue to be run after a delay.
+   *
+   * @param runnable    Runnable to add.
+   * @param delayMillis Delay in millis.
+   */
+  public synchronized void postDelayed(Runnable runnable, long delayMillis) {
+    postDelayed(runnable, delayMillis, MILLISECONDS);
+  }
+
+  /**
+   * Add a runnable to the queue to be run after a delay.
+   */
+  public synchronized void postDelayed(Runnable runnable, long delay, TimeUnit unit) {
+    long delayMillis = unit.toMillis(delay);
+    if ((idleState != CONSTANT_IDLE && (isPaused() || delayMillis > 0)) || Thread.currentThread() != associatedThread) {
+      runnables.add(new ScheduledRunnable(runnable, currentTime + delayMillis));
+    } else {
+      runOrQueueRunnable(runnable, currentTime + delayMillis);
+    }
+  }
+
+  /**
+   * Add a runnable to the head of the queue.
+   *
+   * @param runnable  Runnable to add.
+   */
+  public synchronized void postAtFrontOfQueue(Runnable runnable) {
+    if (isPaused() || Thread.currentThread() != associatedThread) {
+      final long timeDisambiguator;
+      if (runnables.isEmpty()) {
+        timeDisambiguator = nextTimeDisambiguator++;
+      } else {
+        timeDisambiguator = runnables.peek().timeDisambiguator - 1;
+      }
+      runnables.add(new ScheduledRunnable(runnable, 0, timeDisambiguator));
+    } else {
+      runOrQueueRunnable(runnable, currentTime);
+    }
+  }
+
+  /**
+   * Remove a runnable from the queue.
+   *
+   * @param runnable  Runnable to remove.
+   */
+  public synchronized void remove(Runnable runnable) {
+    Iterator<ScheduledRunnable> iterator = runnables.iterator();
+    while (iterator.hasNext()) {
+      if (iterator.next().runnable == runnable) {
+        iterator.remove();
+      }
+    }
+  }
+
+  /**
+   * Run all runnables in the queue, and any additional runnables they schedule that are scheduled
+   * before the latest scheduled runnable currently in the queue.
+   *
+   * @return True if a runnable was executed.
+   */
+  public synchronized boolean advanceToLastPostedRunnable() {
+    long currentMaxTime = currentTime;
+    for (ScheduledRunnable scheduled : runnables) {
+      if (currentMaxTime < scheduled.scheduledTime) {
+        currentMaxTime = scheduled.scheduledTime;
+      }
+    }
+    return advanceTo(currentMaxTime);
+  }
+
+  /**
+   * Run the next runnable in the queue.
+   *
+   * @return  True if a runnable was executed.
+   */
+  public synchronized boolean advanceToNextPostedRunnable() {
+    return !runnables.isEmpty() && advanceTo(runnables.peek().scheduledTime);
+  }
+
+  /**
+   * Run all runnables that are scheduled to run in the next time interval.
+   *
+   * @param interval Time interval (in millis).
+   * @return True if a runnable was executed.
+   * @deprecated Use {@link #advanceBy(long, TimeUnit)}.
+   */
+  @InlineMe(
+      replacement = "this.advanceBy(interval, MILLISECONDS)",
+      staticImports = "java.util.concurrent.TimeUnit.MILLISECONDS")
+  @Deprecated
+  public final synchronized boolean advanceBy(long interval) {
+    return advanceBy(interval, MILLISECONDS);
+  }
+
+  /**
+   * Run all runnables that are scheduled to run in the next time interval.
+   *
+   * @return  True if a runnable was executed.
+   */
+  public synchronized boolean advanceBy(long amount, TimeUnit unit) {
+    long endingTime = currentTime + unit.toMillis(amount);
+    return advanceTo(endingTime);
+  }
+
+  /**
+   * Run all runnables that are scheduled before the endTime.
+   *
+   * @param   endTime   Future time.
+   * @return  True if a runnable was executed.
+   */
+  public synchronized boolean advanceTo(long endTime) {
+    if (endTime < currentTime || runnables.isEmpty()) {
+      currentTime = endTime;
+      return false;
+    }
+
+    int runCount = 0;
+    while (nextTaskIsScheduledBefore(endTime)) {
+      runOneTask();
+      ++runCount;
+    }
+    currentTime = endTime;
+    return runCount > 0;
+  }
+
+  /**
+   * Run the next runnable in the queue.
+   *
+   * @return  True if a runnable was executed.
+   */
+  public synchronized boolean runOneTask() {
+    ScheduledRunnable postedRunnable = runnables.poll();
+    if (postedRunnable != null) {
+      if (postedRunnable.scheduledTime > currentTime) {
+        currentTime = postedRunnable.scheduledTime;
+      }
+      postedRunnable.run();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Determine if any enqueued runnables are enqueued before the current time.
+   *
+   * @return  True if any runnables can be executed.
+   */
+  public synchronized boolean areAnyRunnable() {
+    return nextTaskIsScheduledBefore(currentTime);
+  }
+
+  /**
+   * Reset the internal state of the Scheduler.
+   */
+  public synchronized void reset() {
+    runnables.clear();
+    idleState = UNPAUSED;
+    currentTime = START_TIME;
+    isExecutingRunnable = false;
+  }
+
+  /**
+   * Return the number of enqueued runnables.
+   *
+   * @return  Number of enqueues runnables.
+   */
+  public synchronized int size() {
+    return runnables.size();
+  }
+
+  @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
+  public synchronized Duration getNextScheduledTaskTime() {
+    return runnables.isEmpty() ? Duration.ZERO : Duration.ofMillis(runnables.peek().scheduledTime);
+  }
+
+  @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
+  public synchronized Duration getLastScheduledTaskTime() {
+    if (runnables.isEmpty()) {
+      return Duration.ZERO;
+    }
+    long currentMaxTime = currentTime;
+    for (ScheduledRunnable scheduled : runnables) {
+      if (currentMaxTime < scheduled.scheduledTime) {
+        currentMaxTime = scheduled.scheduledTime;
+      }
+
+    }
+    return Duration.ofMillis(currentMaxTime);
+  }
+
+  /**
+   * Set the idle state of the Scheduler. If necessary, the clock will be advanced and runnables
+   * executed as required by the newly-set state.
+   *
+   * @param shouldIdleConstantly If true the idle state will be set to {@link
+   *     IdleState#CONSTANT_IDLE}, otherwise it will be set to {@link IdleState#UNPAUSED}.
+   * @deprecated This method is ambiguous in how it should behave when turning off constant idle.
+   *     Use {@link #setIdleState(IdleState)} instead to explicitly set the state.
+   */
+  @Deprecated
+  @SuppressWarnings("InlineMeSuggester")
+  public void idleConstantly(boolean shouldIdleConstantly) {
+    setIdleState(shouldIdleConstantly ? CONSTANT_IDLE : UNPAUSED);
+  }
+
+  private boolean nextTaskIsScheduledBefore(long endingTime) {
+    return !runnables.isEmpty() && runnables.peek().scheduledTime <= endingTime;
+  }
+
+  private void runOrQueueRunnable(Runnable runnable, long scheduledTime) {
+    if (isExecutingRunnable) {
+      runnables.add(new ScheduledRunnable(runnable, scheduledTime));
+      return;
+    }
+    isExecutingRunnable = true;
+    try {
+      runnable.run();
+    } finally {
+      isExecutingRunnable = false;
+    }
+    if (scheduledTime > currentTime) {
+      currentTime = scheduledTime;
+    }
+    // The runnable we just ran may have queued other runnables. If there are
+    // any pending immediate execution we should run these now too, unless we are
+    // paused.
+    switch (idleState) {
+      case CONSTANT_IDLE:
+        advanceToLastPostedRunnable();
+        break;
+      case UNPAUSED:
+        advanceBy(0, MILLISECONDS);
+        break;
+      default:
+    }
+  }
+
+  private class ScheduledRunnable implements Comparable<ScheduledRunnable> {
+    private final Runnable runnable;
+    private final long scheduledTime;
+    private final long timeDisambiguator;
+
+    private ScheduledRunnable(Runnable runnable, long scheduledTime) {
+      this(runnable, scheduledTime, nextTimeDisambiguator++);
+    }
+
+    private ScheduledRunnable(Runnable runnable, long scheduledTime, long timeDisambiguator) {
+      this.runnable = runnable;
+      this.scheduledTime = scheduledTime;
+      this.timeDisambiguator = timeDisambiguator;
+    }
+
+    @Override
+    public int compareTo(ScheduledRunnable runnable) {
+      int timeCompare = Long.compare(scheduledTime, runnable.scheduledTime);
+      if (timeCompare == 0) {
+        return Long.compare(timeDisambiguator, runnable.timeDisambiguator);
+      }
+      return timeCompare;
+    }
+
+    public void run() {
+      isExecutingRunnable = true;
+      try {
+        runnable.run();
+      } finally {
+        isExecutingRunnable = false;
+      }
+    }
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/SimpleFuture.java b/utils/src/main/java/org/robolectric/util/SimpleFuture.java
new file mode 100644
index 0000000..3f7ef4b
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/SimpleFuture.java
@@ -0,0 +1,71 @@
+package org.robolectric.util;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A Future represents the result of an asynchronous computation.
+ *
+ * @param <T> The result type returned by this Future's get method.
+ * @deprecated This class can introduce deadlocks, since its lock is held while invoking run().
+ */
+@Deprecated
+public class SimpleFuture<T> {
+  private T result;
+  private boolean hasRun;
+  private boolean cancelled;
+  private final Callable<T> callable;
+
+  public SimpleFuture(Callable<T> callable) {
+    this.callable = callable;
+  }
+
+  public boolean isCancelled() {
+    return cancelled;
+  }
+
+  public boolean cancel(boolean mayInterruptIfRunning) {
+    if (!hasRun) {
+      cancelled = true;
+      done();
+    }
+
+    return cancelled;
+  }
+
+  public synchronized T get() throws InterruptedException {
+    if (cancelled) {
+      throw new CancellationException();
+    } else {
+      while (!hasRun) this.wait();
+      return result;
+    }
+  }
+
+  public synchronized T get(long timeout, TimeUnit unit) throws InterruptedException {
+    if (cancelled) {
+      throw new CancellationException();
+    } else {
+      while (!hasRun) this.wait(unit.toMillis(timeout));
+      return result;
+    }
+  }
+
+  public synchronized void run() {
+    try {
+      if (!cancelled) {
+        result = callable.call();
+        hasRun = true;
+        done();
+      }
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+
+    this.notify();
+  }
+
+  protected void done() {
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/SimplePerfStatsReporter.java b/utils/src/main/java/org/robolectric/util/SimplePerfStatsReporter.java
new file mode 100644
index 0000000..80cd133
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/SimplePerfStatsReporter.java
@@ -0,0 +1,167 @@
+package org.robolectric.util;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+import org.robolectric.AndroidMetadata;
+import org.robolectric.pluginapi.perf.Metadata;
+import org.robolectric.pluginapi.perf.Metric;
+import org.robolectric.pluginapi.perf.PerfStatsReporter;
+
+/** Simple implementation of PerfStatsReporter that writes stats to stdout. */
+public class SimplePerfStatsReporter implements PerfStatsReporter {
+
+  private final List<Data> perfStatsData = new ArrayList<>();
+
+  @Override
+  public synchronized void report(Metadata metadata, Collection<Metric> metrics) {
+    perfStatsData.add(new Data(metadata, metrics));
+  }
+
+  public void register() {
+    Runtime.getRuntime().addShutdownHook(new Thread(this::finalReport));
+  }
+
+  @SuppressWarnings("AndroidJdkLibsChecker)")
+  private synchronized void finalReport() {
+    Map<MetricKey, MetricValue> mergedMetrics = new TreeMap<>();
+    for (Data perfStatsData : perfStatsData) {
+      AndroidMetadata metadata = perfStatsData.metadata.get(AndroidMetadata.class);
+      Map<String, String> deviceBootProperties = metadata.getDeviceBootProperties();
+      int sdkInt = Integer.parseInt(deviceBootProperties.get("ro.build.version.sdk"));
+      String resourcesMode = metadata.getResourcesMode();
+
+      for (Metric metric : perfStatsData.metrics) {
+        MetricKey key = new MetricKey(metric.getName(), metric.isSuccess(), sdkInt, resourcesMode);
+        MetricValue mergedMetric = mergedMetrics.get(key);
+        if (mergedMetric == null) {
+          mergedMetric = new MetricValue();
+          mergedMetrics.put(key, mergedMetric);
+        }
+        mergedMetric.report(metric);
+      }
+    }
+
+    System.out.println("Name\tSDK\tResources\tSuccess\tCount\tMin ms\tMax ms\tAvg ms\tTotal ms");
+    for (Entry<MetricKey, MetricValue> entry : mergedMetrics.entrySet()) {
+      MetricKey key = entry.getKey();
+      MetricValue value = entry.getValue();
+
+      System.out.println(
+          MessageFormat
+              .format("{0}\t{1}\t{2}\t{3}\t{4}\t{5}\t{6}\t{7}\t{8}",
+                  key.name,
+                  key.sdkLevel,
+                  key.resourcesMode,
+                  key.success,
+                  value.count,
+                  (int) (value.minNs / 1000000),
+                  (int) (value.maxNs / 1000000),
+                  (int) (value.elapsedNs / 1000000 / value.count),
+                  (int) (value.elapsedNs / 1000000)));
+    }
+  }
+
+  private static class Data {
+    private final Metadata metadata;
+    private final Collection<Metric> metrics;
+
+    public Data(Metadata metadata, Collection<Metric> metrics) {
+      this.metadata = metadata;
+      this.metrics = metrics;
+    }
+  }
+
+  private static class MetricKey implements Comparable<MetricKey> {
+    private final String name;
+    private final boolean success;
+    private final int sdkLevel;
+    private final String resourcesMode;
+
+    public MetricKey(String name, boolean success, int sdkLevel, String resourcesMode) {
+      this.name = name;
+      this.success = success;
+      this.sdkLevel = sdkLevel;
+      this.resourcesMode = resourcesMode;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof MetricKey)) {
+        return false;
+      }
+
+      MetricKey metricKey = (MetricKey) o;
+
+      if (success != metricKey.success) {
+        return false;
+      }
+      if (name != null ? !name.equals(metricKey.name) : metricKey.name != null) {
+        return false;
+      }
+      if (sdkLevel != metricKey.sdkLevel) {
+        return false;
+      }
+      return resourcesMode != null
+          ? resourcesMode.equals(metricKey.resourcesMode)
+          : metricKey.resourcesMode == null;
+    }
+
+    @Override
+    public int hashCode() {
+      int result = name != null ? name.hashCode() : 0;
+      result = 31 * result + (success ? 1 : 0);
+      result = 31 * result + sdkLevel;
+      result = 31 * result + (resourcesMode != null ? resourcesMode.hashCode() : 0);
+      return result;
+    }
+
+    @Override
+    public int compareTo(MetricKey o) {
+      int i = name.compareTo(o.name);
+      if (i != 0) {
+        return i;
+      }
+
+      i = resourcesMode.compareTo(o.resourcesMode);
+      if (i != 0) {
+        return i;
+      }
+
+      i = Integer.compare(sdkLevel, o.sdkLevel);
+      if (i != 0) {
+        return i;
+      }
+
+      return Boolean.compare(success, o.success);
+    }
+  }
+
+  private static class MetricValue {
+    private int count;
+    private long minNs;
+    private long maxNs;
+    private long elapsedNs;
+
+    public void report(Metric metric) {
+      if (count == 0) {
+        count = metric.getCount();
+        minNs = metric.getMinNs();
+        maxNs = metric.getMaxNs();
+        elapsedNs = metric.getElapsedNs();
+      } else {
+        count += metric.getCount();
+        minNs = Math.min(minNs, metric.getMinNs());
+        maxNs = Math.max(maxNs, metric.getMaxNs());
+        elapsedNs += metric.getElapsedNs();
+      }
+    }
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/SoftThreadLocal.java b/utils/src/main/java/org/robolectric/util/SoftThreadLocal.java
new file mode 100644
index 0000000..56013ea
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/SoftThreadLocal.java
@@ -0,0 +1,30 @@
+package org.robolectric.util;
+
+import java.lang.ref.SoftReference;
+
+/**
+ * Soft reference to a {@code java.lang.ThreadLocal}.
+ *
+ * @param <T> The referent to track.
+ */
+public abstract class SoftThreadLocal<T> {
+
+  @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
+  private final ThreadLocal<SoftReference<T>> threadLocal =
+      ThreadLocal.withInitial(() -> new SoftReference<>(create()));
+
+  synchronized public T get() {
+    T item = threadLocal.get().get();
+    if (item == null) {
+      item = create();
+      threadLocal.set(new SoftReference<>(item));
+    }
+    return item;
+  }
+
+  public void set(T item) {
+    threadLocal.set(new SoftReference<>(item));
+  }
+
+  abstract protected T create();
+}
diff --git a/utils/src/main/java/org/robolectric/util/Strftime.java b/utils/src/main/java/org/robolectric/util/Strftime.java
new file mode 100644
index 0000000..9689ff4
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Strftime.java
@@ -0,0 +1,513 @@
+package org.robolectric.util;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * An implementation of the Unix strftime with some glibc extensions.
+ */
+public class Strftime {
+
+  /**
+   * Format a date string.
+   *
+   * @param format The format in strftime syntax.
+   * @param date The date to format.
+   * @param locale The locale to use for formatting.
+   * @param zone The timezone to use for formatting.
+   * @return The formatted datetime.
+   */
+  @SuppressWarnings("JavaUtilDate")
+  public static String format(String format, final Date date, Locale locale, TimeZone zone) {
+    StringBuilder buffer = new StringBuilder();
+
+    class Formatter {
+      SimpleDateFormat formatter;
+
+      public Formatter(
+          Date date,
+          Locale locale,
+          TimeZone timeZone) {
+        if (locale != null) {
+          formatter = new SimpleDateFormat("", locale);
+        } else {
+          formatter = new SimpleDateFormat("");
+        }
+        if (timeZone != null) {
+          formatter.setTimeZone(timeZone);
+        }
+      }
+
+      public String format(String format) {
+        formatter.applyPattern(format);
+        return formatter.format(date);
+      }
+    }
+
+    Formatter formatter = new Formatter(date, locale, zone);
+
+    Boolean inside = false;
+
+    Boolean removePad = false;
+    Boolean zeroPad = false;
+    Boolean spacePad = false;
+
+    Boolean upperCase = false;
+    Boolean swapCase = false;
+
+    StringBuilder padWidthBuffer = new StringBuilder();
+
+    for (int i = 0; i < format.length(); i++) {
+      Character c = format.charAt(i);
+
+      if (!inside && c == '%') {
+        inside = true;
+        removePad = false;
+        zeroPad = false;
+        spacePad = false;
+        upperCase = false;
+        swapCase = false;
+        padWidthBuffer = new StringBuilder();
+      } else if(inside) {
+        inside = false;
+        switch (c) {
+          // %a  Abbreviated weekday name according to locale.
+          case 'a':
+            buffer.append(
+                correctCase(
+                    formatter.format("EEE"),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %A  Full weekday name according to locale.
+          case 'A':
+            buffer.append(
+                correctCase(
+                    formatter.format("EEEE"),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %b  Abbreviated month name according to locale.
+          case 'b':
+            buffer.append(
+                correctCase(
+                    formatter.format("MMM"),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %B  Full month name according to locale.
+          case 'B':
+            buffer.append(
+                correctCase(
+                    formatter.format("MMMM"),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %c  Preferred date and time representation for locale.
+          case 'c':
+            // NOTE: en_US locale
+            buffer.append(
+                formatter.format("EEE dd MMM yyyy hh:mm:ss aa z"));
+            break;
+
+          // %C  Year divided by 100 and truncated to integer (00-99).
+          case 'C':
+            buffer.append(
+                formatter.format("y").substring(0, 2));
+            break;
+
+          // %d   Day of the month as decimal number (01-31).
+          case 'd':
+            buffer.append(
+                formatter.format("dd"));
+            break;
+
+          // %D  Same as "%m/%d/%y"
+          case 'D':
+            buffer.append(
+                formatter.format("MM/dd/yy"));
+            break;
+
+          // %e  Day of the month as decimal number, padded with space.
+          case 'e':
+            buffer.append(
+                correctPad(
+                    formatter.format("dd"),
+                    zeroPad,
+                    true,
+                    removePad,
+                    (padWidthBuffer.length() <= 0
+                        ? new StringBuilder("2")
+                        : padWidthBuffer)));
+            break;
+
+          // %E  Modifier, use a locale-dependent alternative representation.
+          case 'E':
+            inside = true;
+            throw new UnsupportedOperationException("Not implemented yet");
+//            break;
+
+          // %F  ISO 8601 date format: "%Y-%m-%d".
+          case 'F':
+            buffer.append(
+                formatter.format("yyyy-MM-dd"));
+            break;
+
+          // %g  2-digit year version of %G, (00-99)
+          case 'g':
+            buffer.append(
+                formatter.format("YY"));
+            break;
+
+          // %G  ISO 8601 week-based year.
+          case 'G':
+            buffer.append(
+                formatter.format("YYYY"));
+            break;
+
+          // %h  Like %b.
+          case 'h':
+            buffer.append(
+                formatter.format("MMM"));
+            break;
+
+          // %H  Hour (24-hour clock) as decimal number (00-23).
+          case 'H':
+            buffer.append(
+                formatter.format("HH"));
+            break;
+
+          // %I  Hour (12-hour clock) as decimal number (01-12).
+          case 'I':
+            buffer.append(
+                formatter.format("hh"));
+            break;
+
+          // %j  Day of the year as decimal number (001-366).
+          case 'j':
+            buffer.append(
+                formatter.format("DDD"));
+            break;
+
+          // %k  Hour (24-hour clock) as decimal number (0-23), space padded.
+          case 'k':
+            buffer.append(
+                correctPad(
+                    formatter.format("HH"),
+                    zeroPad,
+                    spacePad,
+                    removePad,
+                    (padWidthBuffer.length() <= 0
+                        ? new StringBuilder("2")
+                        : padWidthBuffer)));
+            break;
+
+          // %l  Hour (12-hour clock) as decimal number (1-12), space padded.
+          case 'l':
+            buffer.append(
+                correctPad(
+                    formatter.format("hh"),
+                    zeroPad,
+                    spacePad || !zeroPad,
+                    removePad,
+                    (padWidthBuffer.length() <= 0
+                        ? new StringBuilder("2")
+                        : padWidthBuffer)));
+            break;
+
+          // %m  Month as decimal number (01-12).
+          case 'm':
+            buffer.append(
+                correctPad(
+                    formatter.format("MM"),
+                    zeroPad,
+                    spacePad,
+                    removePad,
+                    (padWidthBuffer.length() <= 0
+                        ? new StringBuilder("2")
+                        : padWidthBuffer)));
+            break;
+
+          // %M  Minute as decimal number (00-59).
+          case 'M':
+            buffer.append(
+                correctCase(
+                    formatter.format("mm"),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %n  Newline.
+          case 'n':
+            buffer.append(
+                formatter.format("\n"));
+            break;
+
+          // %O  Modifier, use alternative numeric symbols (say, Roman numerals).
+          case 'O':
+            inside = true;
+            throw new UnsupportedOperationException("Not implemented yet");
+//            break;
+
+          // %p  "AM", "PM", or locale string. Noon = "PM", midnight = "AM".
+          case 'p':
+            buffer.append(
+                correctCase(
+                    formatter.format("a"),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %P  "am", "pm", or locale string. Noon = "pm", midnight = "am".
+          case 'P':
+            buffer.append(
+                correctCase(
+                    formatter.format("a").toLowerCase(),
+                    upperCase,
+                    swapCase));
+            break;
+
+          // %r  12-hour clock time.
+          case 'r':
+            buffer.append(
+                formatter.format("hh:mm:ss a"));
+            break;
+
+          // %R  24-hour clock time, "%H:%M".
+          case 'R':
+            buffer.append(
+                formatter.format("HH:mm"));
+            break;
+
+          // %s  Number of seconds since Epoch, 1970-01-01 00:00:00 +0000 (UTC).
+          case 's':
+            buffer.append(
+                ((Long) (date.getTime() / 1000)).toString());
+            break;
+
+          // %S  Second as decimal number (00-60). 60 for leap seconds.
+          case 'S':
+            buffer.append(
+                formatter.format("ss"));
+            break;
+
+          // %t  Tab.
+          case 't':
+            buffer.append(
+                formatter.format("\t"));
+            break;
+
+          // %T  24-hour time, "%H:%M:%S".
+          case 'T':
+            buffer.append(
+                formatter.format("HH:mm:ss"));
+            break;
+
+          // %u  The day of the week as a decimal, (1-7). Monday being 1.
+          case 'u':
+            buffer.append(
+                formatter.format("u"));
+            break;
+
+          // %U  week number of the current year as a decimal number, (00-53).
+          // Starting with the first Sunday as the first day of week 01.
+          case 'U':
+            throw new UnsupportedOperationException("Not implemented yet");
+            // buffer.append(
+            //     formatter.format("ww"));
+            // break;
+
+          // %V  ISO 8601 week number (00-53).
+          // Week 1 is the first week that has at least 4 days in the new year.
+          case 'V':
+            buffer.append(
+                formatter.format("ww"));
+            break;
+
+          // %w  Day of the week as a decimal, (0-6). Sunday being 0.
+          case 'w':
+            String dayNumberOfWeek = formatter.format("u"); // (1-7)
+            buffer.append(
+                (dayNumberOfWeek.equals("7") ? "0" : dayNumberOfWeek));
+            break;
+
+          // %W  Week number of the current year as a decimal number, (00-53).
+          // Starting with the first Monday as the first day of week 01.
+          case 'W':
+            throw new UnsupportedOperationException("Not implemented yet");
+            // buffer.append(
+            //     formatter.format("ww"));
+            // break;
+
+          // %x  Locale date without time.
+          case 'x':
+            buffer.append(
+                formatter.format("MM/dd/yyyy"));
+            break;
+
+          // %X  Locale time without date.
+          case 'X':
+            buffer.append(
+                formatter.format("hh:mm:ss aa"));
+            // buffer.append(
+            //     formatter.format("HH:mm:ss"));
+            break;
+
+          // %y  Year as decimal number without century (00-99).
+          case 'y':
+            buffer.append(
+                formatter.format("yy"));
+            break;
+
+          // %Y  Year as decimal number with century.
+          case 'Y':
+            buffer.append(
+                formatter.format("yyyy"));
+            break;
+
+          // %z  Numeric timezone as hour and minute offset from UTC "+hhmm" or "-hhmm".
+          case 'z':
+            buffer.append(
+                formatter.format("Z"));
+            break;
+
+          // %Z  Timezone, name, or abbreviation.
+          case 'Z':
+            buffer.append(
+                formatter.format("z"));
+            break;
+
+          // %%  Literal '%'.
+          case '%':
+            buffer.append(
+                formatter.format("%"));
+            break;
+
+          // glibc extension
+
+          // %^  Force upper case.
+          case '^':
+            inside = true;
+            upperCase = true;
+            break;
+
+          // %#  Swap case.
+          case '#':
+            inside = true;
+            swapCase = true;
+            break;
+
+          // %-  Remove padding.
+          case '-':
+            inside = true;
+            removePad = true;
+            break;
+
+          // %_  Space pad.
+          case '_':
+            inside = true;
+            spacePad = true;
+            break;
+
+          // %0  Zero pad.
+          //  0  Alternatively if preceded by another digit, defines padding width.
+          case '0':
+            inside = true;
+            if (padWidthBuffer.length() == 0) {
+              zeroPad = true;
+              spacePad = false;
+            } else {
+              padWidthBuffer.append(c);
+            }
+            break;
+
+          // %1  Padding width.
+          case '1':
+          case '2':
+          case '3':
+          case '4':
+          case '5':
+          case '6':
+          case '7':
+          case '8':
+          case '9':
+            inside = true;
+            // zeroPad = !spacePad; // Default to zero padding.
+            padWidthBuffer.append(c);
+            break;
+
+          default:
+            buffer.append(c.toString());
+            break;
+        }
+      } else {
+        buffer.append(c.toString());
+      }
+    }
+
+    return buffer.toString();
+  }
+
+  private static String correctCase(
+      String simple,
+      Boolean upperCase,
+      Boolean swapCase) {
+    if (upperCase) {
+      return simple.toUpperCase();
+    }
+
+    if (!swapCase) {
+      return simple;
+    }
+
+    // swap case
+    StringBuilder buffer = new StringBuilder();
+    for (int i = 0; i < simple.length(); i++) {
+      Character c = simple.charAt(i);
+      buffer.append(
+          (Character.isLowerCase(c)
+              ? Character.toUpperCase(c)
+              : Character.toLowerCase(c))
+          );
+    }
+
+    return buffer.toString();
+  }
+
+  private static String correctPad(
+      String simple,
+      Boolean zeroPad,
+      Boolean spacePad,
+      Boolean removePad,
+      StringBuilder padWidthBuffer) {
+    String unpadded = simple.replaceFirst("^(0+| +)(?!$)", "");
+
+    if (removePad) {
+      return unpadded;
+    }
+
+    int padWidth = 0;
+    if (padWidthBuffer.length() > 0) {
+      padWidth = (
+          Integer.parseInt(padWidthBuffer.toString()) - unpadded.length());
+    }
+
+    if (spacePad || zeroPad) {
+      StringBuilder buffer = new StringBuilder();
+      char padChar = (spacePad ? ' ' : '0');
+      for (int i = 0 ; i < padWidth ; i++) {
+        buffer.append(padChar);
+      }
+      buffer.append(unpadded);
+      return buffer.toString();
+    }
+
+    return simple;
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/TempDirectory.java b/utils/src/main/java/org/robolectric/util/TempDirectory.java
new file mode 100644
index 0000000..b8527a7
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/TempDirectory.java
@@ -0,0 +1,131 @@
+package org.robolectric.util;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+public class TempDirectory {
+  /**
+   * The number of concurrent deletions which should take place, too high and it'll become I/O
+   * bound, to low and it'll take a long time to complete. 5 is an estimate of a decent balance,
+   * feel free to experiment.
+   */
+  private static final int DELETE_THREAD_POOL_SIZE = 5;
+
+  /** Set to track the undeleted TempDirectory instances which we need to erase. */
+  private static final Set<TempDirectory> tempDirectoriesToDelete = new HashSet<>();
+
+  private final Path basePath;
+
+  public TempDirectory() {
+    this("test-dir");
+  }
+
+  public TempDirectory(String name) {
+    try {
+      basePath = Files.createTempDirectory("robolectric-" + name);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    synchronized (tempDirectoriesToDelete) {
+      // If we haven't initialised the shutdown hook we should set everything up.
+      if (tempDirectoriesToDelete.size() == 0) {
+        // Use a manual hook that actually clears the directory
+        // This is necessary because File.deleteOnExit won't delete non empty directories
+        Runtime.getRuntime().addShutdownHook(new Thread(TempDirectory::clearAllDirectories));
+      }
+
+      tempDirectoriesToDelete.add(this);
+    }
+  }
+
+  static void clearAllDirectories() {
+    ExecutorService deletionExecutorService = Executors.newFixedThreadPool(DELETE_THREAD_POOL_SIZE);
+    synchronized (tempDirectoriesToDelete) {
+      for (TempDirectory undeletedDirectory : tempDirectoriesToDelete) {
+        deletionExecutorService.execute(undeletedDirectory::destroy);
+      }
+    }
+    deletionExecutorService.shutdown();
+    try {
+      deletionExecutorService.awaitTermination(5, SECONDS);
+    } catch (InterruptedException e) {
+      deletionExecutorService.shutdownNow();
+      // Preserve interrupt status
+      Thread.currentThread().interrupt();
+    }
+  }
+
+  public Path createFile(String name, String contents) {
+    Path path = basePath.resolve(name);
+    try (Writer out = Files.newBufferedWriter(path)) {
+      out.write(contents);
+    } catch (IOException e) {
+      throw new RuntimeException("failed writing to " + name, e);
+    }
+    return path;
+  }
+
+  public Path create(String name) {
+    Path path = basePath.resolve(name);
+    try {
+      Files.createDirectory(path);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return path;
+  }
+
+  public Path createIfNotExists(String name) {
+    Path path = basePath.resolve(name);
+    try {
+      Files.createDirectory(path);
+    } catch (FileAlreadyExistsException e) {
+      // that's ok
+      return path;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return path;
+  }
+
+  public void destroy() {
+    try {
+      clearDirectory(basePath);
+      Files.delete(basePath);
+    } catch (IOException ignored) {
+      Logger.error("Failed to destroy temp directory", ignored);
+    }
+  }
+
+  private void clearDirectory(final Path directory) throws IOException {
+    Files.walkFileTree(directory, new SimpleFileVisitor<Path>() {
+      @Override
+      public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+        Files.delete(file);
+        return FileVisitResult.CONTINUE;
+      }
+
+      @Override
+      public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
+        if (!dir.equals(directory)) {
+          Files.delete(dir);
+        }
+        return FileVisitResult.CONTINUE;
+      }
+    });
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/TestRunnable.java b/utils/src/main/java/org/robolectric/util/TestRunnable.java
new file mode 100644
index 0000000..1d782b0
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/TestRunnable.java
@@ -0,0 +1,9 @@
+package org.robolectric.util;
+
+public class TestRunnable implements Runnable {
+  public boolean wasRun = false;
+
+  @Override public void run() {
+    wasRun = true;
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/TimeUtils.java b/utils/src/main/java/org/robolectric/util/TimeUtils.java
new file mode 100644
index 0000000..ff7b1ab
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/TimeUtils.java
@@ -0,0 +1,8 @@
+package org.robolectric.util;
+
+/**
+ * Utility methods for dealing with time.
+ */
+public class TimeUtils {
+  public static final long NANOS_PER_MS = 1000000;
+}
diff --git a/utils/src/main/java/org/robolectric/util/Util.java b/utils/src/main/java/org/robolectric/util/Util.java
new file mode 100755
index 0000000..b7292ad
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/Util.java
@@ -0,0 +1,124 @@
+package org.robolectric.util;
+
+import com.google.common.io.ByteStreams;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Generic collection of utility methods.
+ */
+public class Util {
+
+  /**
+   * Returns the Java version as an int value.
+   *
+   * @return the Java version as an int value (8, 9, etc.)
+   */
+  public static int getJavaVersion() {
+    String version = System.getProperty("java.version");
+    assert version != null;
+    if (version.startsWith("1.")) {
+      version = version.substring(2);
+    }
+    // Allow these formats:
+    // 1.8.0_72-ea
+    // 9-ea
+    // 9
+    // 9.0.1
+    int dotPos = version.indexOf('.');
+    int dashPos = version.indexOf('-');
+    return Integer.parseInt(
+        version.substring(0, dotPos > -1 ? dotPos : dashPos > -1 ? dashPos : version.length()));
+  }
+
+  public static void copy(InputStream in, OutputStream out) throws IOException {
+    try {
+      ByteStreams.copy(in, out);
+    } finally {
+      in.close();
+    }
+  }
+
+  /**
+   * This method consumes an input stream and returns its content, and closes it.
+   *
+   * @param is The input stream to read from.
+   * @return The bytes read from the stream.
+   * @throws IOException Error reading from stream.
+   */
+  public static byte[] readBytes(InputStream is) throws IOException {
+    try {
+      return ByteStreams.toByteArray(is);
+    } finally {
+      is.close();
+    }
+  }
+
+  public static <T> T[] reverse(T[] array) {
+    for (int i = 0; i < array.length / 2; i++) {
+      int destI = array.length - i - 1;
+      T o = array[destI];
+      array[destI] = array[i];
+      array[i] = o;
+    }
+    return array;
+  }
+
+  public static File file(String... pathParts) {
+    return file(new File("."), pathParts);
+  }
+
+  public static File file(File f, String... pathParts) {
+    for (String pathPart : pathParts) {
+      f = new File(f, pathPart);
+    }
+
+    String dotSlash = "." + File.separator;
+    if (f.getPath().startsWith(dotSlash)) {
+      f = new File(f.getPath().substring(dotSlash.length()));
+    }
+
+    return f;
+  }
+
+  @SuppressWarnings("NewApi")
+  public static Path pathFrom(URL localArtifactUrl) {
+    try {
+      return Paths.get(localArtifactUrl.toURI());
+    } catch (URISyntaxException e) {
+      throw new RuntimeException("huh? " + localArtifactUrl, e);
+    }
+  }
+  
+  public static int parseInt(String valueFor) {
+    if (valueFor.startsWith("0x")) {
+      return Integer.parseInt(valueFor.substring(2), 16);
+    } else {
+      return Integer.parseInt(valueFor, 10);
+    }
+  }
+
+  /**
+   * Re-throw {@code t} (even if it's a checked exception) without requiring a {@code throws}
+   * declaration.
+   * <p>
+   * This function declares a return type of {@link RuntimeException} but will never actually return
+   * a value. This allows you to use it with a {@code throw} statement to convince the compiler that
+   * the current branch will not complete.
+   * <pre>{@code
+   * throw Util.sneakyThrow(new IOException());
+   * }</pre>
+   * <p>
+   * Adapted from https://www.mail-archive.com/javaposse@googlegroups.com/msg05984.html
+   */
+  @SuppressWarnings("unchecked")
+  public static <T extends Throwable> RuntimeException sneakyThrow(Throwable t) throws T {
+    throw (T) t;
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/inject/AutoFactory.java b/utils/src/main/java/org/robolectric/util/inject/AutoFactory.java
new file mode 100644
index 0000000..ea3df1f
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/inject/AutoFactory.java
@@ -0,0 +1,23 @@
+package org.robolectric.util.inject;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Indicates that the annotated type will be used as a factory. The type must be an interface or
+ * {@link Injector} will throw an exception.
+ *
+ * {@link Injector} will inject an object implementing the annotated interface. When a method on
+ * the interface is called, a scoped injector will be created, any parameters passed to the method
+ * will be explicitly bound, and an implementation of the method's return type will be computed and
+ * returned.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface AutoFactory {
+
+}
diff --git a/utils/src/main/java/org/robolectric/util/inject/InjectionException.java b/utils/src/main/java/org/robolectric/util/inject/InjectionException.java
new file mode 100644
index 0000000..9cca737
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/inject/InjectionException.java
@@ -0,0 +1,26 @@
+package org.robolectric.util.inject;
+
+public class InjectionException extends RuntimeException {
+  public InjectionException(Class<?> clazz, String message, Throwable cause) {
+    super(clazz.getName() + ": " + message, cause);
+  }
+
+  public InjectionException(Injector.Key<?> key, String message, Throwable cause) {
+    super(key + ": " + message, cause);
+  }
+
+  public InjectionException(Class<?> clazz, String message) {
+    super(clazz.getName() + ": " + message);
+  }
+  public InjectionException(Injector.Key<?> key, String message) {
+    super(key + ": " + message);
+  }
+
+  public InjectionException(Class<?> clazz, Throwable cause) {
+    super(clazz.getName() + ": failed to inject", cause);
+  }
+
+  public InjectionException(Injector.Key<?> key, Throwable cause) {
+    super(key + ": failed to inject", cause);
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/inject/Injector.java b/utils/src/main/java/org/robolectric/util/inject/Injector.java
new file mode 100644
index 0000000..4f5320b
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/inject/Injector.java
@@ -0,0 +1,592 @@
+package org.robolectric.util.inject;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Executable;
+import java.lang.reflect.GenericArrayType;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Proxy;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import javax.annotation.concurrent.GuardedBy;
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Provider;
+import org.robolectric.util.Util;
+
+/**
+ * A tiny dependency injection and plugin helper for Robolectric.
+ *
+ * <p>Dependencies may be retrieved explicitly by calling {@link #getInstance}; transitive
+ * dependencies will be automatically injected as needed. For a given injector, all calls to {@link
+ * #getInstance} are idempotent.
+ *
+ * <p>Dependencies are identified by an interface or class, and optionally by a name specified with
+ * &#064;{@link Named}.
+ *
+ * <h3>Dependency Resolution</h3>
+ *
+ * When a dependency is requested, an implementation is sought.
+ *
+ * <p>The injector looks for any instance that has been previously found for the given interface, or
+ * that has been explicitly registered with {@link Builder#bind(Class, Object)} or {@link
+ * Builder#bind(Key, Object)}. If none is found, the injector searches for an implementing class
+ * from the following sources, in order:
+ *
+ * <ol>
+ *   <li>Explicitly-registered implementations registered with {@link Builder#bind(Class, Class)}.
+ *   <li>If the dependency type is an array or {@link Collection}, then its component type is
+ *       recursively sought using {@link PluginFinder#findPlugins(Class)} and an array or collection
+ *       of those instances is returned.
+ *   <li>Plugin implementations published as {@link java.util.ServiceLoader} services under the
+ *       dependency type (see also {@link PluginFinder#findPlugin(Class)}).
+ *   <li>Fallback default implementation classes registered with {@link Builder#bindDefault(Class,
+ *       Class)}.
+ *   <li>If the dependency type is a concrete class, then the dependency type itself.
+ * </ol>
+ *
+ * If the injector has a superinjector, it is always consulted first (with the exception of
+ * interfaces annotated &#064;{@link AutoFactory}; see <a href="#Scopes">Scopes</a> below).
+ *
+ * <p>If no implementing class is found in the injector or any superinjector, an exception is
+ * thrown.
+ *
+ * <p>
+ *
+ * <h3>Injection</h3>
+ *
+ * When the injector has determined an implementing class, it attempts to instantiate it. It
+ * searches for a constructor in the following order:
+ *
+ * <ol>
+ *   <li>A singular public constructor annotated &#064;{@link Inject}. (If multiple constructors are
+ *       &#064;{@link Inject} annotated, the injector will throw an exception.)
+ *   <li>A singular public constructor of any arity.
+ *   <li>If no constructor has yet been found, the injector will throw an exception.
+ * </ol>
+ *
+ * Any constructor parameters are treated as further dependencies, and the injector will recursively
+ * attempt to resolve an implementation for each before invoking the constructor and thereby
+ * instantiating the original dependency implementation.
+ *
+ * <h3 id="Scopes">Scopes</h3>
+ *
+ * If the dependency type is an interface annotated &#064;{@link AutoFactory}, then a factory object
+ * implementing that interface is created; a new scoped injector is created for every method call to
+ * the factory, with parameter arguments registered on the scoped injector.
+ *
+ * <h3>Thread Safety</h3>
+ *
+ * All methods are MT-safe.
+ */
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+public class Injector {
+
+  private static final Key<Object> INJECTOR_KEY = new Key<>(Injector.class);
+
+  private final Injector superInjector;
+  private final PluginFinder pluginFinder;
+
+  @GuardedBy("this")
+  private final Map<Key<?>, Provider<?>> providers;
+  private final Map<Key<?>, Class<?>> defaultImpls;
+
+  /** Creates a new empty injector. */
+  public Injector() {
+    this(new PluginFinder());
+  }
+
+  @VisibleForTesting
+  Injector(PluginFinder pluginFinder) {
+    this(null, Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(),
+        pluginFinder);
+  }
+
+  /** Creates a new injector based on values from a Builder. */
+  private Injector(Injector superInjector, Map<Key<?>, Provider<?>> providers,
+      Map<Key<?>, Class<?>> explicitImpls, Map<Key<?>, Class<?>> defaultImpls,
+      PluginFinder pluginFinder) {
+    this.superInjector = superInjector;
+
+    this.providers = new HashMap<>(providers);
+    for (Map.Entry<Key<?>, Class<?>> e : explicitImpls.entrySet()) {
+      this.providers.put(e.getKey(), memoized(() -> inject(e.getValue())));
+    }
+
+    this.defaultImpls = new HashMap<>(defaultImpls);
+    this.pluginFinder = pluginFinder;
+  }
+
+  /** Builder for {@link Injector}. */
+  public static class Builder {
+    private final Injector superInjector;
+    private final Map<Key<?>, Provider<?>> providers = new HashMap<>();
+    private final Map<Key<?>, Class<?>> explicitImpls = new HashMap<>();
+    private final Map<Key<?>, Class<?>> defaultImpls = new HashMap<>();
+    private final PluginFinder pluginFinder;
+
+    /** Creates a new builder. */
+    public Builder() {
+      this(null, (ClassLoader) null);
+    }
+
+    /** Creates a new builder using the specified ClassLoader for plugin loading. */
+    public Builder(ClassLoader classLoader) {
+      this(null, classLoader);
+    }
+
+    /** Creates a new builder with a parent injector. */
+    public Builder(Injector superInjector) {
+      this(superInjector, (ClassLoader) null);
+    }
+
+    /**
+     * Creates a new builder with a parent injector and the specified ClassLoader for plugin
+     * loading.
+     */
+    public Builder(Injector superInjector, ClassLoader classLoader) {
+      this(superInjector, new PluginFinder(classLoader));
+    }
+
+    @VisibleForTesting
+    Builder(Injector superInjector, PluginFinder pluginFinder) {
+      this.superInjector = superInjector;
+      this.pluginFinder = pluginFinder;
+    }
+
+    /** Registers an instance for the given dependency type. */
+    public <T> Builder bind(@Nonnull Class<T> type, @Nonnull T instance) {
+      return bind(new Key<>(type), instance);
+    }
+
+    /** Registers an instance for the given key. */
+    public <T> Builder bind(Key<T> key, @Nonnull T instance) {
+      providers.put(key, () -> instance);
+      return this;
+    }
+
+    /** Registers an implementing class for the given dependency type. */
+    public <T> Builder bind(@Nonnull Class<T> type, @Nonnull Class<? extends T> implementingClass) {
+      explicitImpls.put(new Key<>(type), implementingClass);
+      return this;
+    }
+
+    /** Registers a fallback implementing class for the given dependency type. */
+    public <T> Builder bindDefault(
+        @Nonnull Class<T> type, @Nonnull Class<? extends T> defaultImplementingClass) {
+      defaultImpls.put(new Key<>(type), defaultImplementingClass);
+      return this;
+    }
+
+    /** Builds an injector as previously configured. */
+    public Injector build() {
+      return new Injector(superInjector, providers, explicitImpls, defaultImpls, pluginFinder);
+    }
+  }
+
+  /** Finds an instance for the given class. Calls are guaranteed idempotent. */
+  @Nonnull public <T> T getInstance(@Nonnull Class<T> type) {
+    return getInstance(new Key<>(type));
+  }
+
+  /** Finds an instance for the given key. Calls are guaranteed idempotent. */
+  @Nonnull private <T> T getInstance(@Nonnull Key<T> key) {
+    try {
+      return getInstanceInternal(key);
+    } catch (UnsatisfiedDependencyException e) {
+      throw e.asInjectionException(key);
+    }
+  }
+
+  private <T> T getInstanceInternal(@Nonnull Key<T> key) {
+    // If we have a superinjector, check it for a provider first.
+    if (superInjector != null) {
+      try {
+        return superInjector.getInstanceInternal(key);
+      } catch (UnsatisfiedDependencyException | InjectionException e) {
+        // that's fine, we'll try locally next...
+      }
+    }
+
+    return getProvider(key).get();
+  }
+
+  public Injector.Builder newScopeBuilder(ClassLoader classLoader) {
+    return new Injector.Builder(this, classLoader);
+  }
+
+  @Nonnull private <T> Provider<T> memoized(@Nonnull Class<? extends T> implementingClass) {
+    return memoized(() -> inject(implementingClass));
+  }
+
+  @Nonnull private <T> Provider<T> memoized(@Nonnull Provider<T> tProvider) {
+    return new MemoizingProvider<>(tProvider);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Nonnull private <T> T inject(@Nonnull Class<? extends T> implementingClass) {
+    Constructor<T> ctor;
+    try {
+      ctor = findConstructor(implementingClass);
+    } catch (IllegalArgumentException e) {
+      throw new InjectionException(implementingClass, e);
+    }
+
+    Object[] params = resolveDependencies(ctor);
+    try {
+      return ctor.newInstance(params);
+    } catch (IllegalAccessException e) {
+      throw Util.sneakyThrow(e);
+    } catch (InstantiationException | InvocationTargetException e) {
+      throw Util.sneakyThrow(e.getCause());
+    }
+  }
+
+  private <T> Constructor<T> findConstructor(@Nonnull Class<? extends T> implementingClass) {
+    List<Constructor<T>> injectCtors = new ArrayList<>();
+    List<Constructor<T>> otherCtors = new ArrayList<>();
+
+    for (Constructor<?> ctor : implementingClass.getConstructors()) {
+      if (ctor.isAnnotationPresent(Inject.class)) {
+        injectCtors.add((Constructor<T>) ctor);
+      } else {
+        otherCtors.add((Constructor<T>) ctor);
+      }
+    }
+
+    if (injectCtors.size() > 1) { // ambiguous @Inject constructors
+      throw new InjectionException(implementingClass, "multiple public @Inject constructors");
+    } else if (injectCtors.size() == 1) { // single @Inject constructor, bingo!
+      return injectCtors.get(0);
+    } else if (otherCtors.size() > 1) { // ambiguous non-@Inject constructors
+      throw new InjectionException(implementingClass, "multiple public constructors");
+    } else if (otherCtors.size() == 1) { // single public constructor, bingo!
+      return otherCtors.get(0);
+    } else {
+      throw new InjectionException(implementingClass, "no public constructor");
+    }
+  }
+
+  private Object[] resolveDependencies(Executable ctor) {
+    final Object[] params = new Object[ctor.getParameterCount()];
+
+    AnnotatedType[] paramTypes = ctor.getAnnotatedParameterTypes();
+    Annotation[][] parameterAnnotations = ctor.getParameterAnnotations();
+    for (int i = 0; i < paramTypes.length; i++) {
+      AnnotatedType paramType = paramTypes[i];
+      String name = findName(parameterAnnotations[i]);
+      Key<?> key = new Key<>(paramType.getType(), name);
+      if (key.equals(INJECTOR_KEY)) {
+        params[i] = this;
+      } else {
+        try {
+          params[i] = getInstanceInternal(key);
+        } catch (UnsatisfiedDependencyException e) {
+          throw new UnsatisfiedDependencyException(new Key<>(ctor.getDeclaringClass()), e);
+        }
+      }
+    }
+    return params;
+  }
+
+  private String findName(Annotation[] annotations) {
+    for (Annotation annotation : annotations) {
+      if (annotation instanceof Named) {
+        return ((Named) annotation).value();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Finds a provider for the given key.
+   *
+   * Calls are guaranteed idempotent and non-blocking.
+   */
+  @SuppressWarnings("unchecked")
+  @Nonnull
+  private synchronized <T> Provider<T> getProvider(final Key<T> key) {
+    // Previously-gotten providers (including those from subinjectors) will already be present.
+    return (Provider<T>) providers.computeIfAbsent(key, k -> {
+      // @AutoFactory requests are always handled by the top-level injector.
+      if (key.isAutoFactory()) {
+        return memoized(new ScopeBuilderProvider<>(key.getDependencyClass()));
+      }
+
+      // Find a provider locally.
+      return findLocalProvider(key);
+    });
+  }
+
+  private <T> Provider<T> findLocalProvider(Key<T> key) throws UnsatisfiedDependencyException {
+    // If it's an array or collection, look for plugins.
+    if (key.isArray()) {
+      Provider<T> tProvider = new ArrayProvider(key.getComponentType());
+      return memoized(tProvider);
+    } else if (key.isCollection()) {
+      Provider<T> tProvider = new ListProvider(key.getComponentType());
+      return memoized(tProvider);
+    }
+
+    // Attempt to resolve an implementation class.
+    Class<T> dependencyClass = key.getDependencyClass();
+
+    // Try to find a solitary plugin...
+    Class<? extends T> implClass = pluginFinder.findPlugin(dependencyClass);
+
+    // ... or a default implementation class, if configured...
+    if (implClass == null) {
+      implClass = getDefaultImpl(key);
+    }
+
+    // ... otherwise if the dependency class is concrete (and not primitive or java.*), just use it.
+    if (implClass == null && isConcrete(dependencyClass) && !isSystem(dependencyClass)) {
+      implClass = dependencyClass;
+    }
+
+    if (implClass != null) {
+      // Found an implementation class!
+      return memoized(implClass);
+    }
+
+    // No luck.
+    throw new UnsatisfiedDependencyException(key, null);
+  }
+
+  private <T> boolean isConcrete(Class<T> clazz) {
+    return !clazz.isInterface() && !Modifier.isAbstract(clazz.getModifiers());
+  }
+
+  private <T> Class<? extends T> getDefaultImpl(Key<T> key) {
+    Class<?> aClass = defaultImpls.get(key);
+    return (Class<? extends T>) aClass;
+  }
+
+  private boolean isSystem(Class<?> clazz) {
+    if (clazz.isPrimitive()) {
+      return true;
+    }
+    Package aPackage = clazz.getPackage();
+    return aPackage == null || aPackage.getName().startsWith("java.");
+  }
+
+  /** Identifies an injection point. */
+  public static class Key<T> {
+
+    @Nonnull
+    private final Type theInterface;
+    private final String name;
+
+    private Key(@Nonnull Type theInterface) {
+      this(theInterface, null);
+    }
+
+    public Key(Type theInterface, String name) {
+      this.theInterface = theInterface;
+      this.name = name;
+    }
+
+    Class<T> getDependencyClass() {
+      //noinspection unchecked
+      return (Class<T>) theInterface;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Key)) {
+        return false;
+      }
+      Key key = (Key) o;
+      return theInterface.equals(key.theInterface) && Objects.equals(name, key.name);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(theInterface, name);
+    }
+
+    @Override
+    public String toString() {
+      StringBuilder buf = new StringBuilder();
+      buf.append("Key<").append(theInterface);
+      if (name != null) {
+        buf.append(" named \"")
+            .append(name)
+            .append("\"");
+      }
+      buf.append(">");
+      return buf.toString();
+    }
+
+    String toShortString() {
+      StringBuilder buf = new StringBuilder();
+      buf.append(theInterface instanceof Class
+          ? ((Class) theInterface).getSimpleName()
+          : theInterface.getTypeName());
+      if (name != null) {
+        buf.append(" \"")
+            .append(name)
+            .append("\"");
+      }
+      return buf.toString();
+    }
+
+    public boolean isArray() {
+      return (theInterface instanceof Class && ((Class) theInterface).isArray())
+          || theInterface instanceof GenericArrayType;
+    }
+
+    public boolean isCollection() {
+      if (theInterface instanceof ParameterizedType) {
+        Type rawType = ((ParameterizedType) theInterface).getRawType();
+        return Collection.class.isAssignableFrom((Class<?>) rawType);
+      }
+      return false;
+    }
+
+    Class<?> getComponentType() {
+      if (isArray()) {
+        if (theInterface instanceof Class) {
+          return ((Class) theInterface).getComponentType();
+        } else if (theInterface instanceof GenericArrayType) {
+          Type genericComponentType = ((GenericArrayType) theInterface).getGenericComponentType();
+          return (Class<?>) ((ParameterizedType) genericComponentType).getRawType();
+        } else {
+          throw new InjectionException(this, new IllegalArgumentException());
+        }
+      } else if (isCollection() && theInterface instanceof ParameterizedType) {
+        return (Class) ((ParameterizedType) theInterface).getActualTypeArguments()[0];
+      } else {
+        throw new IllegalStateException(theInterface + "...?");
+      }
+    }
+
+    boolean isAutoFactory() {
+      return theInterface instanceof Class
+          && ((Class) theInterface).isAnnotationPresent(AutoFactory.class);
+    }
+  }
+
+  private static class MemoizingProvider<T> implements Provider<T> {
+
+    private Provider<T> delegate;
+    private T instance;
+
+    private MemoizingProvider(Provider<T> delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public synchronized T get() {
+      if (instance == null) {
+        instance = delegate.get();
+        delegate = null;
+      }
+      return instance;
+    }
+  }
+
+  private class ListProvider<T> implements Provider<List<T>> {
+    private final Class<T> clazz;
+
+    ListProvider(Class<T> clazz) {
+      this.clazz = clazz;
+    }
+
+    @Override
+    public List<T> get() {
+      List<T> plugins = new ArrayList<>();
+      for (Class<? extends T> pluginClass : pluginFinder.findPlugins(clazz)) {
+        plugins.add(inject(pluginClass));
+      }
+      return Collections.unmodifiableList(plugins);
+    }
+  }
+
+  private class ArrayProvider<T> implements Provider<T[]> {
+    private final ListProvider<T> listProvider;
+
+    ArrayProvider(Class<T> clazz) {
+      this.listProvider = new ListProvider<>(clazz);
+    }
+
+    @Override
+    public T[] get() {
+      T[] emptyArray = (T[]) Array.newInstance(listProvider.clazz, 0);
+      return listProvider.get().toArray(emptyArray);
+    }
+  }
+
+  private class ScopeBuilderProvider<T> implements Provider<T> {
+
+    private final Class<T> clazz;
+
+    public ScopeBuilderProvider(Class<T> clazz) {
+      this.clazz = clazz;
+    }
+
+    @Override
+    public T get() {
+      return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz},
+          (proxy, method, args) -> create(method, args));
+    }
+
+    private Object create(Method method, Object[] args) {
+      Builder subBuilder = new Injector.Builder(Injector.this, pluginFinder);
+      if (method.getParameterCount() > 0) {
+        AnnotatedType[] parameterTypes = method.getAnnotatedParameterTypes();
+        Annotation[][] parameterAnnotations = method.getParameterAnnotations();
+        for (int i = 0; i < args.length; i++) {
+          Type paramType = parameterTypes[i].getType();
+          String name = findName(parameterAnnotations[i]);
+          Object arg = args[i];
+          subBuilder.bind(new Key<>(paramType, name), arg);
+        }
+      }
+
+      Class<?> returnType = method.getReturnType();
+      return subBuilder.build().getInstance(new Key<T>(returnType));
+    }
+  }
+
+  private static class UnsatisfiedDependencyException extends RuntimeException {
+
+    private final Key<?> key;
+    private final UnsatisfiedDependencyException inner;
+
+    UnsatisfiedDependencyException(Key<?> key, UnsatisfiedDependencyException inner) {
+      super(key.toString());
+      this.key = key;
+      this.inner = inner;
+    }
+
+    <T> InjectionException asInjectionException(Key<T> key) {
+      StringBuilder buf = new StringBuilder("Failed to resolve dependency: ");
+      UnsatisfiedDependencyException current = this;
+      while (current != null) {
+        buf.append(current.key.toShortString());
+        current = current.inner;
+        if (current != null) {
+          buf.append("/");
+        }
+      }
+      return new InjectionException(key, buf.toString(), this);
+    }
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/inject/PluginFinder.java b/utils/src/main/java/org/robolectric/util/inject/PluginFinder.java
new file mode 100644
index 0000000..ae0429b
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/inject/PluginFinder.java
@@ -0,0 +1,216 @@
+package org.robolectric.util.inject;
+
+import static java.util.Collections.reverseOrder;
+import static java.util.Comparator.comparing;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ServiceConfigurationError;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.Priority;
+import org.robolectric.util.PerfStatsCollector;
+
+@SuppressWarnings({"NewApi", "AndroidJdkLibsChecker"})
+class PluginFinder {
+
+  private final ServiceFinderAdapter serviceFinderAdapter;
+
+  public PluginFinder() {
+    this(new ServiceFinderAdapter(null));
+  }
+
+  /**
+   * @param classLoader the classloader to be used to load provider-configuration files and provider
+   *     classes, or null if the system classloader (or, failing that, the bootstrap classloader) is
+   *     to be used
+   */
+  public PluginFinder(ClassLoader classLoader) {
+    this(new ServiceFinderAdapter(classLoader));
+  }
+
+  PluginFinder(ServiceFinderAdapter serviceFinderAdapter) {
+    this.serviceFinderAdapter = serviceFinderAdapter;
+  }
+
+  /**
+   * Returns an implementation class for the specified plugin.
+   *
+   * <p>If there is more than such one candidate, the classes will be sorted by {@link Priority} and
+   * the one with the highest priority will be returned. If multiple classes claim the same
+   * priority, a {@link ServiceConfigurationError} will be thrown. Classes without a Priority are
+   * treated as {@code @Priority(0)}.
+   *
+   * @param pluginType the class of the plugin type
+   * @param <T> the class of the plugin type
+   * @return the implementing class with the highest priority
+   */
+  @Nullable
+  <T> Class<? extends T> findPlugin(Class<T> pluginType) {
+    return best(pluginType, findPlugins(pluginType));
+  }
+
+  /**
+   * Returns a list of implementation classes for the specified plugin, ordered from highest to
+   * lowest priority. If no implementing classes can be found, an empty list is returned.
+   *
+   * @param pluginType the class of the plugin type
+   * @param <T> the class of the plugin type
+   * @return a prioritized list of implementation classes
+   */
+  @Nonnull
+  <T> List<Class<? extends T>> findPlugins(Class<T> pluginType) {
+    return prioritize(filter(serviceFinderAdapter.load(pluginType)));
+  }
+
+  private <T> Iterable<Class<? extends T>> filter(Iterable<Class<? extends T>> classes) {
+    Set<Class<?>> superceded = new HashSet<>();
+    for (Class<? extends T> clazz : classes) {
+      Supercedes supercedes = clazz.getAnnotation(Supercedes.class);
+      if (supercedes != null) {
+        superceded.add(supercedes.value());
+      }
+    }
+    if (superceded.isEmpty()) {
+      return classes;
+    } else {
+      return () -> new Filterator<>(classes.iterator(), o -> !superceded.contains(o));
+    }
+  }
+
+  @Nullable
+  private <T> Class<? extends T> best(Class<T> pluginType,
+      List<Class<? extends T>> serviceClasses) {
+    if (serviceClasses.isEmpty()) {
+      return null;
+    }
+
+    Class<? extends T> first = serviceClasses.get(0);
+    if (serviceClasses.size() == 1) {
+      return first;
+    }
+
+    int topPriority = priority(first);
+    serviceClasses = serviceClasses.stream()
+        .filter(it -> priority(it) == topPriority)
+        .collect(Collectors.toList());
+
+    if (serviceClasses.size() == 1) {
+      return serviceClasses.get(0);
+    } else {
+      throw new InjectionException(pluginType, "too many implementations: " + serviceClasses);
+    }
+  }
+
+  static class ServiceFinderAdapter {
+
+    private final ClassLoader classLoader;
+
+    ServiceFinderAdapter(ClassLoader classLoader) {
+      this.classLoader = classLoader;
+    }
+
+    @Nonnull
+    <T> Iterable<Class<? extends T>> load(Class<T> pluginType) {
+      return PerfStatsCollector.getInstance()
+          .measure(
+              "loadPlugins",
+              () -> {
+                ClassLoader serviceClassLoader = classLoader;
+                if (serviceClassLoader == null) {
+                  serviceClassLoader = Thread.currentThread().getContextClassLoader();
+                }
+                HashSet<Class<? extends T>> result = new HashSet<>();
+
+                try {
+                  Enumeration<URL> urls =
+                      serviceClassLoader.getResources("META-INF/services/" + pluginType.getName());
+                  while (urls.hasMoreElements()) {
+                    URL url = urls.nextElement();
+                    BufferedReader reader =
+                        new BufferedReader(
+                            new InputStreamReader(url.openStream(), StandardCharsets.UTF_8));
+                    while (reader.ready()) {
+                      String s = reader.readLine();
+                      result.add(
+                          Class.forName(s, false, serviceClassLoader).asSubclass(pluginType));
+                    }
+                    reader.close();
+                  }
+                  return result;
+                } catch (IOException | ClassNotFoundException e) {
+                  throw new AssertionError(e);
+                }
+              });
+    }
+  }
+
+  @Nonnull
+  private <T> List<Class<? extends T>> prioritize(Iterable<Class<? extends T>> iterable) {
+    List<Class<? extends T>> serviceClasses = new ArrayList<>();
+
+    for (Class<? extends T> serviceClass : iterable) {
+      serviceClasses.add(serviceClass);
+    }
+
+    Comparator<Class<? extends T>> c = reverseOrder(comparing(PluginFinder::priority));
+    c = c.thenComparing(Class::getName);
+    serviceClasses.sort(c);
+
+    return serviceClasses;
+  }
+
+  private static <T> int priority(Class<? extends T> pluginClass) {
+    Priority priority = pluginClass.getAnnotation(Priority.class);
+    return priority == null ? 0 : priority.value();
+  }
+
+  private static class Filterator<T> implements Iterator<T> {
+
+    private final Iterator<T> delegate;
+    private final Predicate<T> predicate;
+    private T next;
+
+    public Filterator(Iterator<T> delegate, Predicate<T> predicate) {
+      this.delegate = delegate;
+      this.predicate = predicate;
+      findNext();
+    }
+
+    void findNext() {
+      while (delegate.hasNext()) {
+        next = delegate.next();
+        if (predicate.test(next)) {
+          return;
+        }
+      }
+      next = null;
+    }
+
+    @Override
+    public boolean hasNext() {
+      return next != null;
+    }
+
+    @Override
+    public T next() {
+      try {
+        return next;
+      } finally {
+        findNext();
+      }
+    }
+  }
+}
diff --git a/utils/src/main/java/org/robolectric/util/inject/Supercedes.java b/utils/src/main/java/org/robolectric/util/inject/Supercedes.java
new file mode 100644
index 0000000..5302ed2
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/inject/Supercedes.java
@@ -0,0 +1,18 @@
+package org.robolectric.util.inject;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Indicates that the annotated type is intended as a replacement for another type. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface Supercedes {
+
+  /** The type that is superceded by the annotated type. */
+  Class<?> value();
+
+}
diff --git a/utils/src/main/java/org/robolectric/util/package-info.java b/utils/src/main/java/org/robolectric/util/package-info.java
new file mode 100644
index 0000000..e95b6b1
--- /dev/null
+++ b/utils/src/main/java/org/robolectric/util/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Package containing general utility classes.
+ */
+package org.robolectric.util;
\ No newline at end of file
diff --git a/utils/src/test/java/org/robolectric/util/PerfStatsCollectorTest.kt b/utils/src/test/java/org/robolectric/util/PerfStatsCollectorTest.kt
new file mode 100644
index 0000000..ec0a221
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/PerfStatsCollectorTest.kt
@@ -0,0 +1,120 @@
+package org.robolectric.util
+
+import com.google.common.truth.Truth.assertThat
+import java.io.IOException
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.robolectric.pluginapi.perf.Metric
+
+@RunWith(JUnit4::class)
+class PerfStatsCollectorTest {
+  private lateinit var fakeClock: FakeClock
+  private lateinit var collector: PerfStatsCollector
+
+  @Before
+  @Throws(Exception::class)
+  fun setUp() {
+    fakeClock = FakeClock()
+    collector = PerfStatsCollector(fakeClock)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun shouldMeasureElapsedTimeForEvents() {
+    val firstEvent = collector.startEvent("first event")
+    fakeClock.delay(20)
+    firstEvent.finished()
+    val metrics = collector.metrics
+    assertThat(metrics).containsExactly(Metric("first event", 1, 20, true))
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun shouldMeasureElapsedTimeForRepeatedEvents() {
+    val firstEvent = collector.startEvent("repeatable event")
+    fakeClock.delay(20)
+    firstEvent.finished()
+    val secondEvent = collector.startEvent("repeatable event")
+    fakeClock.delay(20)
+    secondEvent.finished()
+    val thirdEvent = collector.startEvent("repeatable event")
+    fakeClock.delay(20)
+    thirdEvent.finished()
+    val metrics = collector.metrics
+    assertThat(metrics).containsExactly(Metric("repeatable event", 3, 60, true))
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun shouldRunAndMeasureSuccessfulCallable() {
+    assertThat(
+        collector.measure<String, RuntimeException>("event") {
+          fakeClock.delay(10)
+          "return value"
+        }
+      )
+      .isEqualTo("return value")
+    val metrics = collector.metrics
+    assertThat(metrics).containsExactly(Metric("event", 1, 10, true))
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun shouldRunAndMeasureExceptionThrowingCallable() {
+    collector.measure<String, RuntimeException>("event") {
+      fakeClock.delay(10)
+      "return value"
+    }
+    try {
+      collector.measure<Any, RuntimeException>("event") {
+        fakeClock.delay(5)
+        throw RuntimeException("fake")
+      }
+      Assert.fail("should have thrown")
+    } catch (e: RuntimeException) {
+      assertThat(e.message).isEqualTo("fake")
+    }
+    val metrics = collector.metrics
+    assertThat(metrics).containsAtLeast(Metric("event", 1, 10, true), Metric("event", 1, 5, false))
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun shouldRunAndMeasureCheckedException() {
+    try {
+      collector.measure<Any, IOException>("event") {
+        fakeClock.delay(5)
+        throw IOException("fake")
+      }
+      Assert.fail("should have thrown")
+    } catch (e: IOException) {
+      assertThat(e.message).isEqualTo("fake")
+    }
+    val metrics = collector.metrics
+    assertThat(metrics).contains(Metric("event", 1, 5, false))
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun reset_shouldClearAllMetadataAndMetrics() {
+    collector.putMetadata(String::class.java, "metadata")
+    collector.startEvent("event").finished()
+    collector.reset()
+    assertThat(collector.metadata.get(String::class.java)).isNull()
+    assertThat(collector.metrics).isEmpty()
+  }
+
+  private class FakeClock : Clock {
+    private var timeNs = 0
+    override fun nanoTime(): Long {
+      return timeNs.toLong()
+    }
+
+    fun delay(ms: Int) {
+      timeNs += ms
+    }
+  }
+}
diff --git a/utils/src/test/java/org/robolectric/util/SchedulerTest.kt b/utils/src/test/java/org/robolectric/util/SchedulerTest.kt
new file mode 100644
index 0000000..00e161a
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/SchedulerTest.kt
@@ -0,0 +1,485 @@
+package org.robolectric.util
+
+import com.google.common.collect.ImmutableList
+import com.google.common.collect.Iterables
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.ArrayList
+import java.util.Random
+import java.util.TreeMap
+import java.util.concurrent.atomic.AtomicLong
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.robolectric.util.Scheduler.IdleState.CONSTANT_IDLE
+import org.robolectric.util.Scheduler.IdleState.PAUSED
+import org.robolectric.util.Scheduler.IdleState.UNPAUSED
+
+@RunWith(JUnit4::class)
+class SchedulerTest {
+  private val scheduler = Scheduler()
+  private val transcript: MutableList<String> = ArrayList()
+  private var startTime: Long = 0
+
+  @Before
+  @Throws(Exception::class)
+  fun setUp() {
+    scheduler.pause()
+    startTime = scheduler.currentTime
+  }
+
+  @Test
+  fun whenIdleStateIsConstantIdle_isPausedReturnsFalse() {
+    scheduler.idleState = CONSTANT_IDLE
+    assertThat(scheduler.isPaused).isFalse()
+  }
+
+  @Test
+  fun whenIdleStateIsUnPaused_isPausedReturnsFalse() {
+    scheduler.idleState = UNPAUSED
+    assertThat(scheduler.isPaused).isFalse()
+  }
+
+  @Test
+  fun whenIdleStateIsPaused_isPausedReturnsTrue() {
+    scheduler.idleState = PAUSED
+    assertThat(scheduler.isPaused).isTrue()
+  }
+
+  @Test
+  fun pause_setsIdleState() {
+    scheduler.idleState = UNPAUSED
+    scheduler.pause()
+    assertThat(scheduler.idleState).isSameInstanceAs(PAUSED)
+  }
+
+  @Test
+  @SuppressWarnings("deprecation")
+  fun idleConstantly_setsIdleState() {
+    scheduler.idleState = UNPAUSED
+    scheduler.idleConstantly(true)
+    assertThat(scheduler.idleState).isSameInstanceAs(CONSTANT_IDLE)
+    scheduler.idleConstantly(false)
+    assertThat(scheduler.idleState).isSameInstanceAs(UNPAUSED)
+  }
+
+  @Test
+  fun unPause_setsIdleState() {
+    scheduler.idleState = PAUSED
+    scheduler.unPause()
+    assertThat(scheduler.idleState).isSameInstanceAs(UNPAUSED)
+  }
+
+  @Test
+  fun setIdleStateToUnPause_shouldRunPendingTasks() {
+    scheduler.postDelayed(AddToTranscript("one"), 0)
+    scheduler.postDelayed(AddToTranscript("two"), 0)
+    scheduler.postDelayed(AddToTranscript("three"), 1000)
+    assertThat(transcript).isEmpty()
+    val time = scheduler.currentTime
+    scheduler.idleState = UNPAUSED
+    assertThat(transcript).containsExactly("one", "two")
+    assertWithMessage("time").that(scheduler.currentTime).isEqualTo(time)
+  }
+
+  @Test
+  fun setIdleStateToConstantIdle_shouldRunAllTasks() {
+    scheduler.postDelayed(AddToTranscript("one"), 0)
+    scheduler.postDelayed(AddToTranscript("two"), 0)
+    scheduler.postDelayed(AddToTranscript("three"), 1000)
+    assertThat(transcript).isEmpty()
+    val time = scheduler.currentTime
+    scheduler.idleState = CONSTANT_IDLE
+    assertThat(transcript).containsExactly("one", "two", "three")
+    assertWithMessage("time").that(scheduler.currentTime).isEqualTo(time + 1000)
+  }
+
+  @Test
+  fun unPause_shouldRunPendingTasks() {
+    scheduler.postDelayed(AddToTranscript("one"), 0)
+    scheduler.postDelayed(AddToTranscript("two"), 0)
+    scheduler.postDelayed(AddToTranscript("three"), 1000)
+    assertThat(transcript).isEmpty()
+    val time = scheduler.currentTime
+    scheduler.unPause()
+    assertThat(transcript).containsExactly("one", "two")
+    assertWithMessage("time").that(scheduler.currentTime).isEqualTo(time)
+  }
+
+  @Test
+  @SuppressWarnings("deprecation")
+  fun idleConstantlyTrue_shouldRunAllTasks() {
+    scheduler.postDelayed(AddToTranscript("one"), 0)
+    scheduler.postDelayed(AddToTranscript("two"), 0)
+    scheduler.postDelayed(AddToTranscript("three"), 1000)
+    assertThat(transcript).isEmpty()
+    val time = scheduler.currentTime
+    scheduler.idleConstantly(true)
+    assertThat(transcript).containsExactly("one", "two", "three")
+    assertWithMessage("time").that(scheduler.currentTime).isEqualTo(time + 1000)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun advanceTo_shouldAdvanceTimeEvenIfThereIsNoWork() {
+    scheduler.advanceTo(1000)
+    assertThat(scheduler.currentTime).isEqualTo(1000)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  @SuppressWarnings("deprecation")
+  fun advanceBy_returnsTrueIffSomeJobWasRun() {
+    scheduler.postDelayed(AddToTranscript("one"), 0)
+    scheduler.postDelayed(AddToTranscript("two"), 0)
+    scheduler.postDelayed(AddToTranscript("three"), 1000)
+    assertThat(scheduler.advanceBy(0)).isTrue()
+    assertThat(transcript).containsExactly("one", "two")
+    transcript.clear()
+    assertThat(scheduler.advanceBy(0)).isFalse()
+    assertThat(transcript).isEmpty()
+    assertThat(scheduler.advanceBy(1000)).isTrue()
+    assertThat(transcript).containsExactly("three")
+  }
+
+  @Test
+  @Throws(Exception::class)
+  @SuppressWarnings("deprecation")
+  fun postDelayed_addsAJobToBeRunInTheFuture() {
+    scheduler.postDelayed(AddToTranscript("one"), 1000)
+    scheduler.postDelayed(AddToTranscript("two"), 2000)
+    scheduler.postDelayed(AddToTranscript("three"), 3000)
+    scheduler.advanceBy(1000)
+    assertThat(transcript).containsExactly("one")
+    transcript.clear()
+    scheduler.advanceBy(500)
+    assertThat(transcript).isEmpty()
+    scheduler.advanceBy(501)
+    assertThat(transcript).containsExactly("two")
+    transcript.clear()
+    scheduler.advanceBy(999)
+    assertThat(transcript).containsExactly("three")
+  }
+
+  @Test
+  fun postDelayed_whileIdlingConstantly_executesImmediately() {
+    scheduler.idleState = CONSTANT_IDLE
+    scheduler.postDelayed(AddToTranscript("one"), 1000)
+    assertThat(transcript).containsExactly("one")
+  }
+
+  @Test
+  fun postDelayed_whileIdlingConstantly_advancesTime() {
+    scheduler.idleState = CONSTANT_IDLE
+    scheduler.postDelayed(AddToTranscript("one"), 1000)
+    assertThat(scheduler.currentTime).isEqualTo(1000 + startTime)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun postAtFrontOfQueue_addsJobAtFrontOfQueue() {
+    scheduler.post(AddToTranscript("one"))
+    scheduler.post(AddToTranscript("two"))
+    scheduler.postAtFrontOfQueue(AddToTranscript("three"))
+    scheduler.runOneTask()
+    assertThat(transcript).containsExactly("three")
+    transcript.clear()
+    scheduler.runOneTask()
+    assertThat(transcript).containsExactly("one")
+    transcript.clear()
+    scheduler.runOneTask()
+    assertThat(transcript).containsExactly("two")
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun postAtFrontOfQueue_whenUnpaused_runsJobs() {
+    scheduler.unPause()
+    scheduler.postAtFrontOfQueue(AddToTranscript("three"))
+    assertThat(transcript).containsExactly("three")
+  }
+
+  @Test
+  @Throws(Exception::class)
+  @SuppressWarnings("deprecation")
+  fun postDelayed_whenMoreItemsAreAdded_runsJobs() {
+    scheduler.postDelayed(
+      {
+        transcript.add("one")
+        scheduler.postDelayed(
+          {
+            transcript.add("two")
+            scheduler.postDelayed(AddToTranscript("three"), 1000)
+          },
+          1000
+        )
+      },
+      1000
+    )
+    scheduler.advanceBy(1000)
+    assertThat(transcript).containsExactly("one")
+    transcript.clear()
+    scheduler.advanceBy(500)
+    assertThat(transcript).isEmpty()
+    scheduler.advanceBy(501)
+    assertThat(transcript).containsExactly("two")
+    transcript.clear()
+    scheduler.advanceBy(999)
+    assertThat(transcript).containsExactly("three")
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun remove_ShouldRemoveAllInstancesOfRunnableFromQueue() {
+    scheduler.post(TestRunnable())
+    val runnable = TestRunnable()
+    scheduler.post(runnable)
+    scheduler.post(runnable)
+    assertThat(scheduler.size()).isEqualTo(3)
+    scheduler.remove(runnable)
+    assertThat(scheduler.size()).isEqualTo(1)
+    scheduler.advanceToLastPostedRunnable()
+    assertThat(runnable.wasRun).isFalse()
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun reset_shouldUnPause() {
+    scheduler.pause()
+    val runnable = TestRunnable()
+    scheduler.post(runnable)
+    assertThat(runnable.wasRun).isFalse()
+    scheduler.reset()
+    scheduler.post(runnable)
+    assertThat(runnable.wasRun).isTrue()
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun reset_shouldClearPendingRunnables() {
+    scheduler.pause()
+    val runnable1 = TestRunnable()
+    scheduler.post(runnable1)
+    assertThat(runnable1.wasRun).isFalse()
+    scheduler.reset()
+    val runnable2 = TestRunnable()
+    scheduler.post(runnable2)
+    assertThat(runnable1.wasRun).isFalse()
+    assertThat(runnable2.wasRun).isTrue()
+  }
+
+  @Test
+  fun nestedPost_whilePaused_doesntAutomaticallyExecute() {
+    val order: MutableList<Int> = ArrayList()
+    scheduler.postDelayed(
+      {
+        order.add(1)
+        scheduler.post { order.add(4) }
+        order.add(2)
+      },
+      0
+    )
+    scheduler.postDelayed({ order.add(3) }, 0)
+    scheduler.runOneTask()
+    assertWithMessage("order:first run").that(order).containsExactly(1, 2)
+    assertWithMessage("size:first run").that(scheduler.size()).isEqualTo(2)
+    scheduler.runOneTask()
+    assertWithMessage("order:second run").that(order).containsExactly(1, 2, 3)
+    assertWithMessage("size:second run").that(scheduler.size()).isEqualTo(1)
+    scheduler.runOneTask()
+    assertWithMessage("order:third run").that(order).containsExactly(1, 2, 3, 4)
+    assertWithMessage("size:second run").that(scheduler.size()).isEqualTo(0)
+  }
+
+  @Test
+  fun nestedPost_whileUnpaused_automaticallyExecutes3After() {
+    val order: MutableList<Int> = ArrayList()
+    scheduler.unPause()
+    scheduler.postDelayed(
+      {
+        order.add(1)
+        scheduler.post { order.add(3) }
+        order.add(2)
+      },
+      0
+    )
+    assertWithMessage("order").that(order).containsExactly(1, 2, 3)
+    assertWithMessage("size").that(scheduler.size()).isEqualTo(0)
+  }
+
+  @Test
+  fun nestedPostAtFront_whilePaused_runsBeforeSubsequentPost() {
+    val order: MutableList<Int> = ArrayList()
+    scheduler.postDelayed(
+      {
+        order.add(1)
+        scheduler.postAtFrontOfQueue { order.add(3) }
+        order.add(2)
+      },
+      0
+    )
+    scheduler.postDelayed({ order.add(4) }, 0)
+    scheduler.advanceToLastPostedRunnable()
+    assertWithMessage("order").that(order).containsExactly(1, 2, 3, 4)
+    assertWithMessage("size").that(scheduler.size()).isEqualTo(0)
+  }
+
+  @Test
+  fun nestedPostAtFront_whileUnpaused_runsAfter() {
+    val order: MutableList<Int> = ArrayList()
+    scheduler.unPause()
+    scheduler.postDelayed(
+      {
+        order.add(1)
+        scheduler.postAtFrontOfQueue { order.add(3) }
+        order.add(2)
+      },
+      0
+    )
+    assertWithMessage("order").that(order).containsExactly(1, 2, 3)
+    assertWithMessage("size").that(scheduler.size()).isEqualTo(0)
+  }
+
+  @Test
+  fun nestedPostDelayed_whileUnpaused_doesntAutomaticallyExecute3() {
+    val order: MutableList<Int> = ArrayList()
+    scheduler.unPause()
+    scheduler.postDelayed(
+      {
+        order.add(1)
+        scheduler.postDelayed({ order.add(3) }, 1)
+        order.add(2)
+      },
+      0
+    )
+    assertWithMessage("order:before").that(order).containsExactly(1, 2)
+    assertWithMessage("size:before").that(scheduler.size()).isEqualTo(1)
+    scheduler.advanceToLastPostedRunnable()
+    assertWithMessage("order:after").that(order).containsExactly(1, 2, 3)
+    assertWithMessage("size:after").that(scheduler.size()).isEqualTo(0)
+    assertWithMessage("time:after").that(scheduler.currentTime).isEqualTo(1 + startTime)
+  }
+
+  @Test
+  fun nestedPostDelayed_whenIdlingConstantly_automaticallyExecutes3After() {
+    val order: MutableList<Int> = ArrayList()
+    scheduler.idleState = CONSTANT_IDLE
+    scheduler.postDelayed(
+      {
+        order.add(1)
+        scheduler.postDelayed({ order.add(3) }, 1)
+        order.add(2)
+      },
+      0
+    )
+    assertWithMessage("order").that(order).containsExactly(1, 2, 3)
+    assertWithMessage("size").that(scheduler.size()).isEqualTo(0)
+    assertWithMessage("time").that(scheduler.currentTime).isEqualTo(1 + startTime)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun post_whenTheRunnableThrows_executesSubsequentRunnables() {
+    val runnablesThatWereRun: MutableList<Int> = ArrayList()
+    scheduler.post {
+      runnablesThatWereRun.add(1)
+      throw RuntimeException("foo")
+    }
+    try {
+      scheduler.unPause()
+    } catch (ignored: RuntimeException) {}
+    scheduler.post { runnablesThatWereRun.add(2) }
+    assertThat(runnablesThatWereRun).containsExactly(1, 2)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun testTimeNotChangedByNegativeDelay() {
+    val currentTime = scheduler.currentTime
+    val observedTime = LongArray(1)
+    scheduler.postDelayed({ observedTime[0] = scheduler.currentTime }, -1000)
+    scheduler.advanceToLastPostedRunnable()
+    assertThat(observedTime[0]).isEqualTo(currentTime)
+    assertThat(scheduler.currentTime).isEqualTo(currentTime)
+  }
+
+  /** Tests for quadratic or exponential behavior in the scheduler, and stable sorting */
+  @Test(timeout = 1000)
+  fun schedulerWithManyRunnables() {
+    val random = Random(0)
+    val orderCheck: MutableMap<Int, MutableList<Int>> = TreeMap()
+    val actualOrder: MutableList<Int> = ArrayList()
+    for (i in 0..19999) {
+      val delay = random.nextInt(10)
+      var list = orderCheck[delay]
+      if (list == null) {
+        list = ArrayList()
+        orderCheck[delay] = list
+      }
+      list.add(i)
+      scheduler.postDelayed({ actualOrder.add(i) }, delay.toLong())
+    }
+    assertThat(actualOrder).isEmpty()
+    scheduler.advanceToLastPostedRunnable()
+    assertThat(actualOrder).isEqualTo(ImmutableList.copyOf(Iterables.concat(orderCheck.values)))
+  }
+
+  @Test(timeout = 1000)
+  @Throws(InterruptedException::class)
+  fun schedulerAllowsConcurrentTimeRead_whileLockIsHeld() {
+    val l = AtomicLong()
+    val t: Thread =
+      object : Thread("schedulerAllowsConcurrentTimeRead") {
+        override fun run() {
+          l.set(scheduler.currentTime)
+        }
+      }
+    // Grab the lock and then start a thread that tries to get the current time. The other thread
+    // should not deadlock.
+    synchronized(scheduler) {
+      t.start()
+      t.join()
+    }
+  }
+
+  @Test(timeout = 1000)
+  @Throws(InterruptedException::class)
+  fun schedulerAllowsConcurrentStateRead_whileLockIsHeld() {
+    val t: Thread =
+      object : Thread("schedulerAllowsConcurrentStateRead") {
+        override fun run() {
+          scheduler.idleState
+        }
+      }
+    // Grab the lock and then start a thread that tries to get the idle state. The other thread
+    // should not deadlock.
+    synchronized(scheduler) {
+      t.start()
+      t.join()
+    }
+  }
+
+  @Test(timeout = 1000)
+  @Throws(InterruptedException::class)
+  fun schedulerAllowsConcurrentIsPaused_whileLockIsHeld() {
+    val t: Thread =
+      object : Thread("schedulerAllowsConcurrentIsPaused") {
+        override fun run() {
+          scheduler.isPaused
+        }
+      }
+    // Grab the lock and then start a thread that tries to get the paused state. The other thread
+    // should not deadlock.
+    synchronized(scheduler) {
+      t.start()
+      t.join()
+    }
+  }
+
+  private inner class AddToTranscript(private val event: String) : Runnable {
+    override fun run() {
+      transcript.add(event)
+    }
+  }
+}
diff --git a/utils/src/test/java/org/robolectric/util/TempDirectoryTest.kt b/utils/src/test/java/org/robolectric/util/TempDirectoryTest.kt
new file mode 100644
index 0000000..0a23817
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/TempDirectoryTest.kt
@@ -0,0 +1,30 @@
+package org.robolectric.util
+
+import com.google.common.truth.Truth.assertThat
+import java.io.IOException
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class TempDirectoryTest {
+  @Test
+  @Throws(IOException::class)
+  fun createsDirsWithSameParent() {
+    val tempDir = TempDirectory("temp_dir")
+    val path = tempDir.create("dir1")
+    val path2 = tempDir.create("dir2")
+    assertThat(path.parent.toString()).isEqualTo(path2.parent.toString())
+  }
+
+  @Test
+  fun clearAllDirectories_removesDirectories() {
+    val tempDir = TempDirectory("temp_dir")
+    val dir = tempDir.create("dir1")
+    val file = tempDir.create("file1")
+    TempDirectory.clearAllDirectories()
+    assertThat(dir.toFile().exists()).isFalse()
+    assertThat(file.toFile().exists()).isFalse()
+    assertThat(dir.parent.toFile().exists()).isFalse()
+  }
+}
diff --git a/utils/src/test/java/org/robolectric/util/inject/InjectorTest.java b/utils/src/test/java/org/robolectric/util/inject/InjectorTest.java
new file mode 100644
index 0000000..821441a
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/inject/InjectorTest.java
@@ -0,0 +1,432 @@
+package org.robolectric.util.inject;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.auto.service.AutoService;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import javax.inject.Named;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class InjectorTest {
+
+  private Injector.Builder builder;
+  private Injector injector;
+  private final List<Class<?>> pluginClasses = new ArrayList<>();
+
+  @Before
+  public void setUp() throws Exception {
+    builder = new Injector.Builder();
+    injector = builder.build();
+  }
+
+  @Test
+  public void whenImplSpecified_shouldProvideInstance() throws Exception {
+    injector = builder.bind(Thing.class, MyThing.class).build();
+
+    assertThat(injector.getInstance(Thing.class))
+        .isInstanceOf(MyThing.class);
+  }
+
+  @Test
+  public void whenImplSpecified_shouldUseSameInstance() throws Exception {
+    injector = builder.bind(Thing.class, MyThing.class).build();
+
+    Thing thing = injector.getInstance(Thing.class);
+    assertThat(injector.getInstance(Thing.class)).isSameInstanceAs(thing);
+  }
+
+  @Test
+  public void whenServiceSpecified_shouldProvideInstance() throws Exception {
+    assertThat(injector.getInstance(Thing.class))
+        .isInstanceOf(ThingFromServiceConfig.class);
+  }
+
+  @Test
+  public void whenServiceSpecified_shouldUseSameInstance() throws Exception {
+    Thing thing = injector.getInstance(Thing.class);
+    assertThat(injector.getInstance(Thing.class)).isSameInstanceAs(thing);
+  }
+
+  @Test
+  public void whenConcreteClassRequested_shouldProvideInstance() throws Exception {
+    assertThat(injector.getInstance(MyUmm.class))
+        .isInstanceOf(MyUmm.class);
+  }
+
+  @Test
+  public void whenDefaultSpecified_shouldProvideInstance() throws Exception {
+    injector = builder.bindDefault(Umm.class, MyUmm.class).build();
+
+    assertThat(injector.getInstance(Umm.class))
+        .isInstanceOf(MyUmm.class);
+  }
+
+  @Test
+  public void whenDefaultSpecified_shouldUseSameInstance() throws Exception {
+    Thing thing = injector.getInstance(Thing.class);
+    assertThat(injector.getInstance(Thing.class)).isSameInstanceAs(thing);
+  }
+
+  @Test
+  public void whenNoImplOrServiceOrDefaultSpecified_shouldThrow() throws Exception {
+    try {
+      injector.getInstance(Umm.class);
+      fail();
+    } catch (InjectionException e) {
+      // ok
+    }
+  }
+
+  @Test
+  public void registerDefaultService_providesFallbackImplOnlyIfNoServiceSpecified()
+      throws Exception {
+    builder.bindDefault(Thing.class, MyThing.class);
+
+    assertThat(injector.getInstance(Thing.class))
+        .isInstanceOf(ThingFromServiceConfig.class);
+
+    builder.bindDefault(Umm.class, MyUmm.class);
+    assertThat(injector.getInstance(Thing.class))
+        .isInstanceOf(ThingFromServiceConfig.class);
+  }
+
+  @Test
+  public void shouldPreferSingularPublicConstructorAnnotatedInject() throws Exception {
+    injector = builder
+        .bind(Thing.class, MyThing.class)
+        .bind(Umm.class, MyUmm.class)
+        .build();
+
+    Umm umm = injector.getInstance(Umm.class);
+    assertThat(umm).isNotNull();
+    assertThat(umm).isInstanceOf(MyUmm.class);
+
+    MyUmm myUmm = (MyUmm) umm;
+    assertThat(myUmm.thing).isNotNull();
+    assertThat(myUmm.thing).isInstanceOf(MyThing.class);
+
+    assertThat(myUmm.thing).isSameInstanceAs(injector.getInstance(Thing.class));
+  }
+
+  @Test
+  public void shouldAcceptSingularPublicConstructorWithoutInjectAnnotation() throws Exception {
+    injector = builder
+        .bind(Thing.class, MyThing.class)
+        .bind(Umm.class, MyUmmNoInject.class)
+        .build();
+
+    Umm umm = injector.getInstance(Umm.class);
+    assertThat(umm).isNotNull();
+    assertThat(umm).isInstanceOf(MyUmmNoInject.class);
+
+    MyUmmNoInject myUmm = (MyUmmNoInject) umm;
+    assertThat(myUmm.thing).isNotNull();
+    assertThat(myUmm.thing).isInstanceOf(MyThing.class);
+
+    assertThat(myUmm.thing).isSameInstanceAs(injector.getInstance(Thing.class));
+  }
+
+  @Test
+  public void whenArrayRequested_mayReturnMultiplePlugins() throws Exception {
+    MultiThing[] multiThings = injector.getInstance(MultiThing[].class);
+
+    // X comes first because it has a higher priority
+    assertThat(classesOf(multiThings))
+        .containsExactly(MultiThingX.class, MultiThingA.class).inOrder();
+  }
+
+  @Test
+  public void whenCollectionRequested_mayReturnMultiplePlugins() throws Exception {
+    ThingRequiringMultiThings it = injector.getInstance(ThingRequiringMultiThings.class);
+
+    // X comes first because it has a higher priority
+    assertThat(classesOf(it.multiThings))
+        .containsExactly(MultiThingX.class, MultiThingA.class).inOrder();
+  }
+
+  @Test
+  public void whenListRequested_itIsUnmodifiable() throws Exception {
+    ThingRequiringMultiThings it = injector.getInstance(ThingRequiringMultiThings.class);
+
+    try {
+      it.multiThings.clear();
+      fail();
+    } catch (Exception e) {
+      assertThat(e).isInstanceOf(UnsupportedOperationException.class);
+    }
+  }
+
+  @Test public void autoFactory_factoryMethodsCreateNewInstances() throws Exception {
+    injector = builder.bind(Umm.class, MyUmm.class).build();
+    FooFactory factory = injector.getInstance(FooFactory.class);
+    Foo chauncey = factory.create("Chauncey");
+    assertThat(chauncey.name).isEqualTo("Chauncey");
+
+    Foo anotherChauncey = factory.create("Chauncey");
+    assertThat(anotherChauncey).isNotSameInstanceAs(chauncey);
+  }
+
+  @Test public void autoFactory_injectedValuesComeFromSuperInjector() throws Exception {
+    injector = builder.bind(Umm.class, MyUmm.class).build();
+    FooFactory factory = injector.getInstance(FooFactory.class);
+    Foo chauncey = factory.create("Chauncey");
+    assertThat(chauncey.thing).isSameInstanceAs(injector.getInstance(Thing.class));
+  }
+
+  @Test public void whenFactoryRequested_createsInjectedFactory() throws Exception {
+    injector = builder.bind(Umm.class, MyUmm.class).build();
+    FooFactory factory = injector.getInstance(FooFactory.class);
+    Foo chauncey = factory.create("Chauncey");
+    assertThat(chauncey.name).isEqualTo("Chauncey");
+
+    Foo anotherChauncey = factory.create("Chauncey");
+    assertThat(anotherChauncey).isNotSameInstanceAs(chauncey);
+
+    assertThat(chauncey.thing).isSameInstanceAs(injector.getInstance(Thing.class));
+  }
+
+  @Test public void scopedInjector_shouldCheckParentBeforeProvidingDefault() throws Exception {
+    injector = builder.build();
+    Injector subInjector = new Injector.Builder(injector).build();
+
+    MyUmm subUmm = subInjector.getInstance(MyUmm.class);
+    assertThat(injector.getInstance(MyUmm.class)).isSameInstanceAs(subUmm);
+  }
+
+  @Test public void shouldInjectByNamedKeys() throws Exception {
+    injector = builder
+        .bind(new Injector.Key<>(String.class, "namedThing"), "named value")
+        .bind(String.class, "unnamed value")
+        .build();
+    NamedParams namedParams = injector.getInstance(NamedParams.class);
+    assertThat(namedParams.withName).isEqualTo("named value");
+    assertThat(namedParams.withoutName).isEqualTo("unnamed value");
+  }
+
+  @Test public void shouldPreferPluginsOverConcreteClass() throws Exception {
+    PluginFinder pluginFinder = new PluginFinder(new MyServiceFinderAdapter(pluginClasses));
+    Injector injector = new Injector.Builder(null, pluginFinder).build();
+    pluginClasses.add(SubclassOfConcreteThing.class);
+    ConcreteThing instance = injector.getInstance(ConcreteThing.class);
+    assertThat(instance.getClass()).isEqualTo(SubclassOfConcreteThing.class);
+  }
+
+  @Test
+  public void subInjectorIsUsedForResolvingTransitiveDependencies() throws Exception {
+    FakeSandboxManager sandboxManager = injector.getInstance(FakeSandboxManager.class);
+    FakeSdk runtimeSdk = new FakeSdk("runtime");
+    FakeSdk compileSdk = new FakeSdk("compile");
+    FakeSandbox sandbox = sandboxManager.getSandbox(runtimeSdk, compileSdk);
+    assertThat(sandbox.runtimeSdk).isSameInstanceAs(runtimeSdk);
+    assertThat(sandbox.compileSdk).isSameInstanceAs(compileSdk);
+  }
+
+  @Test @Ignore("todo")
+  public void objectsCreatedByFactoryShareTransitiveDependencies() throws Exception {
+    FakeSandboxManager sandboxManager = injector.getInstance(FakeSandboxManager.class);
+    FakeSdk runtimeSdk = new FakeSdk("runtime");
+    FakeSdk compileASdk = new FakeSdk("compileA");
+    FakeSdk compileBSdk = new FakeSdk("compileB");
+    FakeSandbox sandboxA = sandboxManager.getSandbox(runtimeSdk, compileASdk);
+    FakeSandbox sandboxB = sandboxManager.getSandbox(runtimeSdk, compileBSdk);
+    assertThat(sandboxA.sandboxClassLoader).isSameInstanceAs(sandboxB.sandboxClassLoader);
+  }
+
+  @Test
+  public void shouldProvideDecentErrorMessages() throws Exception {
+    FakeSandboxManager sandboxManager = injector.getInstance(FakeSandboxManager.class);
+    Exception actualException = null;
+    try {
+      sandboxManager.brokenGetSandbox();
+      fail();
+    } catch (Exception e) {
+      actualException = e;
+    }
+    assertThat(actualException.getMessage())
+        .contains("Failed to resolve dependency: FakeSandbox/FakeSdk/String");
+  }
+
+  @Test @Ignore("todo")
+  public void shouldOnlyAttemptToResolveTypesKnownToClassLoader() throws Exception {
+  }
+
+  /////////////////////////////
+
+  private List<? extends Class<?>> classesOf(Object[] items) {
+    return classesOf(Arrays.asList(items));
+  }
+
+  private List<? extends Class<?>> classesOf(List<?> items) {
+    return items.stream().map(Object::getClass).collect(Collectors.toList());
+  }
+
+  /** A thing. */
+  public interface Thing {
+  }
+
+  public static class MyThing implements Thing {
+  }
+
+  public static class ConcreteThing {
+  }
+
+  public static class SubclassOfConcreteThing extends ConcreteThing {
+  }
+
+  /** Class for test. */
+  @AutoService(Thing.class)
+  public static class ThingFromServiceConfig implements Thing {
+  }
+
+  private interface Umm {
+
+  }
+
+  public static class MyUmm implements Umm {
+
+    private final Thing thing;
+
+    @Inject
+    public MyUmm(Thing thing) {
+      this.thing = thing;
+    }
+
+    @SuppressWarnings("unused")
+    public MyUmm(String thingz) {
+      this.thing = null;
+    }
+  }
+
+  /** Class for test. */
+  public static class MyUmmNoInject implements Umm {
+
+    private final Thing thing;
+
+    public MyUmmNoInject(Thing thing) {
+      this.thing = thing;
+    }
+  }
+
+  private interface MultiThing {
+
+  }
+
+  /** Class for test. */
+  @Priority(-5)
+  @AutoService(MultiThing.class)
+  public static class MultiThingA implements MultiThing {
+  }
+
+  /** Class for test. */
+  @AutoService(MultiThing.class)
+  public static class MultiThingX implements MultiThing {
+  }
+
+  /** Class for test. */
+  public static class ThingRequiringMultiThings {
+
+    private List<MultiThing> multiThings;
+
+    public ThingRequiringMultiThings(List<MultiThing> multiThings) {
+      this.multiThings = multiThings;
+    }
+  }
+
+  static class Foo {
+
+    private final Thing thing;
+    private final Umm umm;
+    private final String name;
+
+    public Foo(Thing thing, Umm umm, String name) {
+      this.thing = thing;
+      this.umm = umm;
+      this.name = name;
+    }
+  }
+
+  @AutoFactory
+  interface FooFactory {
+    Foo create(String name);
+  }
+
+  static class NamedParams {
+
+    private final Thing thing;
+    private final String withName;
+    private final String withoutName;
+
+    public NamedParams(Thing thing, @Named("namedThing") String withName, String withoutName) {
+      this.thing = thing;
+      this.withName = withName;
+      this.withoutName = withoutName;
+    }
+  }
+
+  static class FakeSdk {
+    private final String name;
+
+    public FakeSdk(String name) {
+      this.name = name;
+    }
+  }
+
+  static class FakeSandbox {
+
+    private final FakeSdk runtimeSdk;
+    private final FakeSdk compileSdk;
+    private final FakeSandboxClassLoader sandboxClassLoader;
+
+    public FakeSandbox(
+        @Named("runtimeSdk") FakeSdk runtimeSdk,
+        @Named("compileSdk") FakeSdk compileSdk,
+        FakeSandboxClassLoader sandboxClassLoader) {
+      this.runtimeSdk = runtimeSdk;
+      this.compileSdk = compileSdk;
+      this.sandboxClassLoader = sandboxClassLoader;
+    }
+  }
+
+  static class FakeSandboxClassLoader {
+    private final FakeSdk runtimeSdk;
+
+    public FakeSandboxClassLoader(@Named("runtimeSdk") FakeSdk runtimeSdk) {
+      this.runtimeSdk = runtimeSdk;
+    }
+  }
+
+  static class FakeSandboxManager {
+
+    private final FakeSandboxFactory sandboxFactory;
+
+    public FakeSandboxManager(FakeSandboxFactory sandboxFactory) {
+      this.sandboxFactory = sandboxFactory;
+    }
+
+    public FakeSandbox getSandbox(FakeSdk runtimeSdk, FakeSdk compileSdk) {
+      return sandboxFactory.createSandbox(runtimeSdk, compileSdk);
+    }
+
+    public FakeSandbox brokenGetSandbox() {
+      return sandboxFactory.createSandbox();
+    }
+  }
+
+  @AutoFactory
+  private interface FakeSandboxFactory {
+    FakeSandbox createSandbox(@Named("runtimeSdk") FakeSdk runtimeSdk,
+        @Named("compileSdk") FakeSdk compileSdk);
+    FakeSandbox createSandbox();
+  }
+}
\ No newline at end of file
diff --git a/utils/src/test/java/org/robolectric/util/inject/MyServiceFinderAdapter.kt b/utils/src/test/java/org/robolectric/util/inject/MyServiceFinderAdapter.kt
new file mode 100644
index 0000000..01418f7
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/inject/MyServiceFinderAdapter.kt
@@ -0,0 +1,21 @@
+package org.robolectric.util.inject
+
+import javax.annotation.Nonnull
+import org.robolectric.util.inject.PluginFinder.ServiceFinderAdapter
+
+internal class MyServiceFinderAdapter(private val pluginClasses: List<Class<*>>) :
+  ServiceFinderAdapter(null) {
+  @Nonnull
+  public override fun <T> load(pluginType: Class<T>): Iterable<Class<out T>> {
+    return fill()
+  }
+
+  @Nonnull
+  private fun <T> fill(): Iterable<Class<out T>> {
+    val classes: MutableList<Class<out T>> = ArrayList()
+    for (pluginClass in pluginClasses) {
+      classes.add(pluginClass as Class<out T>)
+    }
+    return classes
+  }
+}
diff --git a/utils/src/test/java/org/robolectric/util/inject/PluginFinderTest.kt b/utils/src/test/java/org/robolectric/util/inject/PluginFinderTest.kt
new file mode 100644
index 0000000..91aa6bf
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/inject/PluginFinderTest.kt
@@ -0,0 +1,104 @@
+package org.robolectric.util.inject
+
+import com.google.common.truth.Truth
+import javax.annotation.Priority
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class PluginFinderTest {
+  private val pluginClasses: MutableList<Class<*>> = ArrayList()
+  private lateinit var pluginFinder: PluginFinder
+
+  @Before
+  @Throws(Exception::class)
+  fun setUp() {
+    pluginFinder = PluginFinder(MyServiceFinderAdapter(pluginClasses))
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun findPlugin_shouldPickHighestPriorityClass() {
+    pluginClasses.addAll(
+      listOf(
+        ImplMinus1::class.java,
+        ImplZeroA::class.java,
+        ImplOne::class.java,
+        ImplZeroB::class.java
+      )
+    )
+    Truth.assertThat(pluginFinder.findPlugin(Iface::class.java)).isEqualTo(ImplOne::class.java)
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun findPlugin_shouldThrowIfAmbiguous() {
+    pluginClasses.addAll(
+      listOf(ImplMinus1::class.java, ImplZeroA::class.java, ImplZeroB::class.java)
+    )
+    try {
+      pluginFinder.findPlugin(Iface::class.java)
+      Assert.fail()
+    } catch (exception: Exception) {
+      Truth.assertThat(exception).isInstanceOf(InjectionException::class.java)
+    }
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun findPlugins_shouldSortClassesInReversePriority() {
+    pluginClasses.addAll(
+      listOf(
+        ImplMinus1::class.java,
+        ImplZeroA::class.java,
+        ImplOne::class.java,
+        ImplZeroB::class.java
+      )
+    )
+    Truth.assertThat(pluginFinder.findPlugins(Iface::class.java))
+      .containsExactly(
+        ImplOne::class.java,
+        ImplZeroA::class.java,
+        ImplZeroB::class.java,
+        ImplMinus1::class.java
+      )
+      .inOrder()
+  }
+
+  @Test
+  @Throws(Exception::class)
+  fun findPlugins_whenAnnotatedSupercedes_shouldExcludeSuperceded() {
+    pluginClasses.addAll(
+      listOf(
+        ImplMinus1::class.java,
+        ImplZeroXSupercedesA::class.java,
+        ImplZeroA::class.java,
+        ImplOne::class.java,
+        ImplZeroB::class.java
+      )
+    )
+    val plugins = pluginFinder.findPlugins(Iface::class.java)
+    Truth.assertThat(plugins)
+      .containsExactly(
+        ImplOne::class.java,
+        ImplZeroB::class.java,
+        ImplZeroXSupercedesA::class.java,
+        ImplMinus1::class.java
+      )
+      .inOrder()
+  }
+
+  ////////////////
+  @Priority(-1) private class ImplMinus1 : Iface
+
+  @Priority(0) private class ImplZeroA : Iface
+  private class ImplZeroB : Iface
+
+  @Priority(1) private class ImplOne : Iface
+
+  @Supercedes(ImplZeroA::class) private class ImplZeroXSupercedesA : Iface
+  private interface Iface
+}
diff --git a/utils/src/test/java/org/robolectric/util/inject/Thing.java b/utils/src/test/java/org/robolectric/util/inject/Thing.java
new file mode 100644
index 0000000..8dcceba
--- /dev/null
+++ b/utils/src/test/java/org/robolectric/util/inject/Thing.java
@@ -0,0 +1,5 @@
+package org.robolectric.util.inject;
+
+interface Thing {
+
+}
diff --git a/utils/src/test/resources/META-INF/services/org.robolectric.util.inject.Thing b/utils/src/test/resources/META-INF/services/org.robolectric.util.inject.Thing
new file mode 100644
index 0000000..1d9f2b4
--- /dev/null
+++ b/utils/src/test/resources/META-INF/services/org.robolectric.util.inject.Thing
@@ -0,0 +1 @@
+org.robolectric.util.inject.InjectorTest$ThingFromServiceConfig
\ No newline at end of file